feat(leaderboard): read-write separation
This commit is contained in:
parent
cd2b30ecc8
commit
c2ffb0d7f6
5 changed files with 511 additions and 276 deletions
|
|
@ -147,6 +147,37 @@ Want to author your own custom elements this way? Check out
|
||||||
**[webcomponent.io](https://webcomponent.io)** and
|
**[webcomponent.io](https://webcomponent.io)** and
|
||||||
**[web-component-base](https://github.com/ayo-run/wcb)**.
|
**[web-component-base](https://github.com/ayo-run/wcb)**.
|
||||||
|
|
||||||
|
## Separable read & write surfaces
|
||||||
|
|
||||||
|
The service is composed of two independently importable halves, so you never pull
|
||||||
|
in code you don't use — and can point each half at a differently-privileged
|
||||||
|
backend instance:
|
||||||
|
|
||||||
|
| Surface | Import | Uses | Adapter methods |
|
||||||
|
| ------- | ------ | ---- | --------------- |
|
||||||
|
| **Read / subscribe** | `@cozy-games/leaderboard/leaderboard-read.js` → `LeaderBoardReader` | `render()` — query a window + render the list | `listScores` |
|
||||||
|
| **Write** | `@cozy-games/leaderboard/leaderboard-write.js` → `LeaderBoardWriter` | `submit()` — archive + ranked entry | `addScore`, optional `archive`, `getConfig` |
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Read-only page (public, less-privileged instance) — no write code loaded:
|
||||||
|
import { LeaderBoardReader } from '@cozy-games/leaderboard/leaderboard-read.js'
|
||||||
|
const board = new LeaderBoardReader({ adapter: readAdapter, formatScore })
|
||||||
|
document.body.append(await board.render('beginner', 'Best Times'))
|
||||||
|
|
||||||
|
// Server / trusted path (privileged instance) — no DOM or render code loaded:
|
||||||
|
import { LeaderBoardWriter } from '@cozy-games/leaderboard/leaderboard-write.js'
|
||||||
|
const writer = new LeaderBoardWriter({ adapter: writeAdapter })
|
||||||
|
await writer.submit(entry)
|
||||||
|
```
|
||||||
|
|
||||||
|
The read module imports **no** write-path code (no bucket-key computation, no
|
||||||
|
write adapter calls) and the write module imports **no** read/render code (no
|
||||||
|
DOM, no `listScores`) — which also keeps each surface trivial to test in
|
||||||
|
isolation. `LeaderBoardService` (and `<cozy-leaderboard>`) remain the combined
|
||||||
|
facade — same `render()` + `submit()` API — for consumers that want both; it just
|
||||||
|
composes a `LeaderBoardReader` and a `LeaderBoardWriter` (exposed as `.reader` /
|
||||||
|
`.writer`).
|
||||||
|
|
||||||
## Choosing a backend
|
## Choosing a backend
|
||||||
|
|
||||||
### Bring your own backend instance (injection)
|
### Bring your own backend instance (injection)
|
||||||
|
|
|
||||||
|
|
@ -1,302 +1,65 @@
|
||||||
import { buckets } from '@cozy-games/utils/date-bucket/date-bucket.js'
|
import { LeaderBoardReader } from './leaderboard-read.js'
|
||||||
|
import { LeaderBoardWriter } from './leaderboard-write.js'
|
||||||
const DAY_MS = 24 * 60 * 60 * 1000
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The four time windows are ROLLING: each shows entries from the last `ms`
|
|
||||||
* milliseconds (strictly nested — 24h ⊆ 7d ⊆ 30d ⊆ all), sorted by score.
|
|
||||||
* `ms: null` is the all-time view (no time filter). `title` is the hover tooltip
|
|
||||||
* that spells out the window.
|
|
||||||
*/
|
|
||||||
const DURATIONS = [
|
|
||||||
{ id: 'today', label: 'Today', ms: DAY_MS, title: 'Last 24 hours' },
|
|
||||||
{ id: 'week', label: 'Week', ms: 7 * DAY_MS, title: 'Last 7 days' },
|
|
||||||
{ id: 'month', label: 'Month', ms: 30 * DAY_MS, title: 'Last 30 days' },
|
|
||||||
{ id: 'all', label: 'All Time', ms: null, title: 'All time' }
|
|
||||||
]
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default empty-state messages — challenging but friendly, and game-agnostic.
|
|
||||||
* One is picked at random each render. Override per app via the `emptyMessages`
|
|
||||||
* option (see below) so localization stays out of this package.
|
|
||||||
*/
|
|
||||||
const EMPTY_MESSAGES = [
|
|
||||||
'Be the first to enter the leader board!',
|
|
||||||
'No scores yet — claim the top spot!',
|
|
||||||
'This board is wide open. Conquer it!',
|
|
||||||
'No champions here yet. Will it be you?',
|
|
||||||
'Blank slate — set the score to beat!',
|
|
||||||
'Nobody\'s here yet. Be the first!',
|
|
||||||
'The top spot is up for grabs. Take it!',
|
|
||||||
'Empty board. Time to make your mark!'
|
|
||||||
]
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generic, game- AND backend-agnostic leaderboard. Nothing here knows about
|
* Generic, game- AND backend-agnostic leaderboard. Nothing here knows about
|
||||||
* minesweeper or Firebase: the ranked value is a plain `score`, sorted in the
|
* minesweeper or Firebase: the ranked value is a plain `score`, sorted in the
|
||||||
* configured direction and displayed through an injected formatter, while all
|
* configured direction and displayed through an injected formatter, while all
|
||||||
* storage I/O is delegated to an injected adapter (see adapters/). Games wire
|
* storage I/O is delegated to an injected adapter (see adapters/).
|
||||||
* their specifics through the constructor options.
|
*
|
||||||
|
* This is the COMBINED facade: it simply composes the two separable surfaces —
|
||||||
|
* {@link LeaderBoardReader} (read/subscribe/render) and {@link LeaderBoardWriter}
|
||||||
|
* (submit) — for consumers that want both in one object. Consumers who need only
|
||||||
|
* one half import it directly and pull in nothing from the other:
|
||||||
|
*
|
||||||
|
* import { LeaderBoardReader } from '@cozy-games/leaderboard/leaderboard-read.js'
|
||||||
|
* import { LeaderBoardWriter } from '@cozy-games/leaderboard/leaderboard-write.js'
|
||||||
|
*
|
||||||
|
* Public API is unchanged: `render()` (read) and `submit()` (write). The reader
|
||||||
|
* and writer are also exposed as `.reader` / `.writer` for direct access. Reads
|
||||||
|
* and writes may be wired to differently-privileged backend instances by passing
|
||||||
|
* the surfaces different adapters (construct a Reader and Writer directly).
|
||||||
*
|
*
|
||||||
* An adapter implements:
|
* An adapter implements:
|
||||||
* - getConfig(): Promise<Object|undefined>
|
* - getConfig(): Promise<Object|undefined>
|
||||||
* - listScores({ category, field, value, order, limit }): Promise<Object[]>
|
* - listScores({ category, since, order, limit }): Promise<Object[]> // read
|
||||||
* - addScore(category, entry): Promise<void>
|
* - addScore(category, entry): Promise<void> // write
|
||||||
* - archive(entry): Promise<void> // optional personal history
|
* - archive(entry): Promise<void> // optional personal history // write
|
||||||
*/
|
*/
|
||||||
export class LeaderBoardService {
|
export class LeaderBoardService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Object} options
|
* @param {Object} options - see {@link LeaderBoardReader} and {@link LeaderBoardWriter} for the full set
|
||||||
* @param {Object} options.adapter - storage backend (e.g. FirebaseAdapter, SupabaseAdapter)
|
* @param {Object} options.adapter - storage backend (e.g. FirebaseAdapter, SupabaseAdapter)
|
||||||
* @param {'asc'|'desc'} [options.scoreOrder] - 'asc' = lower is better (e.g. time), 'desc' = higher is better
|
* @param {'asc'|'desc'} [options.scoreOrder]
|
||||||
* @param {(value: number) => string} [options.formatScore] - display formatter for a score
|
* @param {(value: number) => string} [options.formatScore]
|
||||||
* @param {(entry: Object) => boolean} [options.qualifies] - whether an entry is ranked; defaults to server passingStatus vs entry.status
|
* @param {(entry: Object) => boolean} [options.qualifies]
|
||||||
* @param {Object} [options.labels] - optional tab-label overrides keyed by duration id
|
* @param {Object} [options.labels]
|
||||||
* @param {Object} [options.tooltips] - optional tab hover-text overrides keyed by duration id
|
* @param {Object} [options.tooltips]
|
||||||
* @param {string[]} [options.emptyMessages] - empty-state messages (one picked at random); localize here
|
* @param {string[]} [options.emptyMessages]
|
||||||
* @param {string} [options.loadingText] - shown while a window loads
|
* @param {string} [options.loadingText]
|
||||||
* @param {string} [options.errorText] - shown when a window fails to load
|
* @param {string} [options.errorText]
|
||||||
* @param {string} [options.anonymousName] - fallback display name for entries without one
|
* @param {string} [options.anonymousName]
|
||||||
*/
|
*/
|
||||||
constructor(options = {}) {
|
constructor(options = {}) {
|
||||||
this.adapter = options.adapter
|
this.adapter = options.adapter
|
||||||
this.scoreOrder = options.scoreOrder === 'desc' ? 'desc' : 'asc'
|
this.reader = new LeaderBoardReader(options)
|
||||||
this.formatScore = options.formatScore || (value => String(value))
|
this.writer = new LeaderBoardWriter(options)
|
||||||
this.qualifies = options.qualifies || (entry => this._defaultQualifies(entry))
|
|
||||||
this.labels = options.labels || {}
|
|
||||||
this.tooltips = options.tooltips || {}
|
|
||||||
|
|
||||||
// User-facing strings — override to localize; the package ships English defaults.
|
|
||||||
this.emptyMessages = (Array.isArray(options.emptyMessages) && options.emptyMessages.length)
|
|
||||||
? options.emptyMessages
|
|
||||||
: EMPTY_MESSAGES
|
|
||||||
this.loadingText = options.loadingText || 'Loading…'
|
|
||||||
this.errorText = options.errorText || 'Leaderboard unavailable right now.'
|
|
||||||
this.anonymousName = options.anonymousName || 'Anonymous'
|
|
||||||
|
|
||||||
Promise.resolve(this.adapter.getConfig())
|
|
||||||
.then(config => {
|
|
||||||
this.configuration = config
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default ranking gate: if the server config names a `passingStatus`, only
|
* Read surface — render the ranked list with a duration tab bar.
|
||||||
* entries whose `status` matches qualify; otherwise every entry qualifies.
|
* @see LeaderBoardReader#render
|
||||||
*/
|
*/
|
||||||
_defaultQualifies(entry) {
|
render(category, title, duration) {
|
||||||
const passing = this.configuration && this.configuration.passingStatus
|
return this.reader.render(category, title, duration)
|
||||||
if (!passing) return true
|
|
||||||
return entry.status === passing
|
|
||||||
}
|
|
||||||
|
|
||||||
_label(duration) {
|
|
||||||
return this.labels[duration.id] || duration.label
|
|
||||||
}
|
|
||||||
|
|
||||||
_tooltip(duration) {
|
|
||||||
return this.tooltips[duration.id] || duration.title
|
|
||||||
}
|
|
||||||
|
|
||||||
_emptyMessage() {
|
|
||||||
return this.emptyMessages[Math.floor(Math.random() * this.emptyMessages.length)]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Backend-neutral query descriptor for a category and time window. `since` is
|
* Write surface — submit a completed game (archive + ranked entry).
|
||||||
* the rolling cutoff (entries with `time_stamp >= since`); `null` means
|
* @see LeaderBoardWriter#submit
|
||||||
* all-time (no time filter). The adapter turns this into a real query.
|
|
||||||
*/
|
*/
|
||||||
_descriptor(category, duration) {
|
submit(entry) {
|
||||||
return {
|
return this.writer.submit(entry)
|
||||||
category,
|
|
||||||
since: duration.ms ? new Date(Date.now() - duration.ms) : null,
|
|
||||||
order: this.scoreOrder,
|
|
||||||
limit: 10
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render the leaderboard for a category with a duration tab bar. When
|
|
||||||
* `duration` is omitted the last-selected tab is reused (so switching game
|
|
||||||
* category keeps the player on the same window), defaulting to "today".
|
|
||||||
* Returns the wrapper element; tab clicks re-query in place.
|
|
||||||
* @param {String} category
|
|
||||||
* @param {String} title
|
|
||||||
* @param {String} [duration]
|
|
||||||
* @returns {Promise<HTMLDivElement>}
|
|
||||||
*/
|
|
||||||
async render(category, title, duration) {
|
|
||||||
this.category = category
|
|
||||||
this.title = title
|
|
||||||
if (duration) this.duration = duration
|
|
||||||
if (!this.duration) this.duration = 'today'
|
|
||||||
duration = this.duration
|
|
||||||
|
|
||||||
const wrapper = document.createElement('div')
|
|
||||||
wrapper.style.maxWidth = '270px'
|
|
||||||
wrapper.style.margin = '0 auto'
|
|
||||||
|
|
||||||
const heading = document.createElement('h3')
|
|
||||||
heading.innerText = title
|
|
||||||
heading.style.borderBottom = '1px solid #c0c0c0'
|
|
||||||
heading.style.paddingBottom = '10px'
|
|
||||||
wrapper.append(heading)
|
|
||||||
|
|
||||||
const tabBar = document.createElement('div')
|
|
||||||
tabBar.style.display = 'flex'
|
|
||||||
tabBar.style.justifyContent = 'center'
|
|
||||||
tabBar.style.gap = '8px'
|
|
||||||
tabBar.style.marginBottom = '10px'
|
|
||||||
wrapper.append(tabBar)
|
|
||||||
|
|
||||||
const listWrapper = document.createElement('div')
|
|
||||||
wrapper.append(listWrapper)
|
|
||||||
|
|
||||||
const tabs = {}
|
|
||||||
const activate = (id) => {
|
|
||||||
this.duration = id
|
|
||||||
Object.entries(tabs).forEach(([tabId, el]) => this._styleTab(el, tabId === id))
|
|
||||||
this._loadList(listWrapper, category, DURATIONS.find(d => d.id === id))
|
|
||||||
}
|
|
||||||
|
|
||||||
DURATIONS.forEach(d => {
|
|
||||||
const tab = document.createElement('button')
|
|
||||||
tab.innerText = this._label(d)
|
|
||||||
tab.type = 'button'
|
|
||||||
tab.setAttribute('title', this._tooltip(d))
|
|
||||||
this._styleTab(tab, d.id === duration)
|
|
||||||
tab.onclick = () => activate(d.id)
|
|
||||||
tabs[d.id] = tab
|
|
||||||
tabBar.append(tab)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Return the wrapper (heading + tabs) right away and fill the list
|
|
||||||
// asynchronously, so a slow or failing query never blocks the UI.
|
|
||||||
this._loadList(listWrapper, category, DURATIONS.find(d => d.id === duration))
|
|
||||||
|
|
||||||
return wrapper
|
|
||||||
}
|
|
||||||
|
|
||||||
_styleTab(tab, active) {
|
|
||||||
tab.style.background = 'none'
|
|
||||||
tab.style.border = 'none'
|
|
||||||
tab.style.cursor = 'pointer'
|
|
||||||
tab.style.padding = '2px 4px'
|
|
||||||
tab.style.fontSize = '0.85em'
|
|
||||||
tab.style.color = active ? '#ffffff' : '#999999'
|
|
||||||
tab.style.fontWeight = active ? 'bold' : 'normal'
|
|
||||||
tab.style.borderBottom = active ? '2px solid orange' : '2px solid transparent'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load a window's entries into the list area, showing a loading placeholder
|
|
||||||
* and turning any failure (e.g. the backend being unreachable) into a
|
|
||||||
* message instead of an unhandled rejection. Guards against races when the
|
|
||||||
* player switches tabs quickly by tagging the in-flight request.
|
|
||||||
*/
|
|
||||||
async _loadList(listWrapper, category, duration) {
|
|
||||||
const token = (this._loadToken || 0) + 1
|
|
||||||
this._loadToken = token
|
|
||||||
|
|
||||||
listWrapper.innerHTML = ''
|
|
||||||
const loading = document.createElement('em')
|
|
||||||
loading.innerText = this.loadingText
|
|
||||||
listWrapper.append(loading)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const rows = await this.adapter.listScores(this._descriptor(category, duration))
|
|
||||||
if (this._loadToken !== token) return
|
|
||||||
this._renderList(listWrapper, rows)
|
|
||||||
} catch {
|
|
||||||
if (this._loadToken !== token) return
|
|
||||||
listWrapper.innerHTML = ''
|
|
||||||
const message = document.createElement('em')
|
|
||||||
message.innerText = this.errorText
|
|
||||||
listWrapper.append(message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_renderList(listWrapper, rows) {
|
|
||||||
listWrapper.innerHTML = ''
|
|
||||||
|
|
||||||
if (!rows || !rows.length) {
|
|
||||||
const message = document.createElement('em')
|
|
||||||
message.innerText = this._emptyMessage()
|
|
||||||
listWrapper.append(message)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const list = document.createElement('div')
|
|
||||||
list.style.listStyle = 'none'
|
|
||||||
list.style.textAlign = 'left'
|
|
||||||
|
|
||||||
let i = 1
|
|
||||||
rows.forEach(data => {
|
|
||||||
const item = document.createElement('div')
|
|
||||||
item.style.display = 'flex'
|
|
||||||
|
|
||||||
const indexElement = document.createElement('div')
|
|
||||||
indexElement.innerText = `#${i++}`
|
|
||||||
|
|
||||||
const nameElement = document.createElement('div')
|
|
||||||
const name = data.name || this.anonymousName
|
|
||||||
nameElement.innerHTML = name
|
|
||||||
nameElement.setAttribute('title', name)
|
|
||||||
nameElement.style.textOverflow = 'ellipsis'
|
|
||||||
nameElement.style.whiteSpace = 'nowrap'
|
|
||||||
nameElement.style.overflow = 'hidden'
|
|
||||||
nameElement.style.padding = '0 5px'
|
|
||||||
nameElement.style.fontWeight = 'bold'
|
|
||||||
nameElement.style.fontStyle = 'italic'
|
|
||||||
nameElement.style.flex = '1'
|
|
||||||
|
|
||||||
const scoreElement = document.createElement('div')
|
|
||||||
scoreElement.innerText = this.formatScore(data.score)
|
|
||||||
|
|
||||||
item.append(indexElement, nameElement, scoreElement)
|
|
||||||
list.append(item)
|
|
||||||
})
|
|
||||||
|
|
||||||
listWrapper.append(list)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Submit a completed game. Always archives it (personal history); if it
|
|
||||||
* qualifies, also writes a ranked entry with denormalized bucket keys. Both
|
|
||||||
* writes go through the adapter, so the storage backend is pluggable. The
|
|
||||||
* caller owns display-name/nickname UX.
|
|
||||||
* @param {Object} entry - { name, playerId, score, category, time_stamp, status?, meta? }
|
|
||||||
*/
|
|
||||||
async submit(entry) {
|
|
||||||
if (this.adapter.archive) {
|
|
||||||
await this.adapter.archive({
|
|
||||||
playerId: entry.playerId,
|
|
||||||
score: entry.score,
|
|
||||||
category: entry.category,
|
|
||||||
time_stamp: entry.time_stamp,
|
|
||||||
meta: entry.meta
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.qualifies(entry)) return
|
|
||||||
|
|
||||||
const stamp = entry.time_stamp instanceof Date ? entry.time_stamp : new Date(entry.time_stamp)
|
|
||||||
const bucket = buckets(stamp)
|
|
||||||
const scoreDoc = {
|
|
||||||
name: entry.name || 'Anonymous',
|
|
||||||
playerId: entry.playerId,
|
|
||||||
score: entry.score,
|
|
||||||
category: entry.category,
|
|
||||||
time_stamp: entry.time_stamp,
|
|
||||||
day: bucket.day,
|
|
||||||
week: bucket.week,
|
|
||||||
month: bucket.month
|
|
||||||
}
|
|
||||||
if (entry.meta) scoreDoc.meta = entry.meta
|
|
||||||
|
|
||||||
await this.adapter.addScore(entry.category, scoreDoc)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
248
packages/leaderboard/leaderboard-read.js
Normal file
248
packages/leaderboard/leaderboard-read.js
Normal file
|
|
@ -0,0 +1,248 @@
|
||||||
|
/**
|
||||||
|
* The READ / subscribe surface of the leaderboard: querying a time window and
|
||||||
|
* rendering the ranked list. Importable WITHOUT any write-path code — no
|
||||||
|
* `submit`, no bucket-key computation, no write adapter calls — so read-only
|
||||||
|
* consumers (and tests) pull in nothing they don't need.
|
||||||
|
*
|
||||||
|
* Pairs with `leaderboard-write.js` (the write half) and `leader-board.js` (the
|
||||||
|
* combined facade). The READ side only ever calls `adapter.listScores`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The four time windows are ROLLING: each shows entries from the last `ms`
|
||||||
|
* milliseconds (strictly nested — 24h ⊆ 7d ⊆ 30d ⊆ all), sorted by score.
|
||||||
|
* `ms: null` is the all-time view (no time filter). `title` is the hover tooltip
|
||||||
|
* that spells out the window.
|
||||||
|
*/
|
||||||
|
const DURATIONS = [
|
||||||
|
{ id: 'today', label: 'Today', ms: DAY_MS, title: 'Last 24 hours' },
|
||||||
|
{ id: 'week', label: 'Week', ms: 7 * DAY_MS, title: 'Last 7 days' },
|
||||||
|
{ id: 'month', label: 'Month', ms: 30 * DAY_MS, title: 'Last 30 days' },
|
||||||
|
{ id: 'all', label: 'All Time', ms: null, title: 'All time' }
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default empty-state messages — challenging but friendly, and game-agnostic.
|
||||||
|
* One is picked at random each render. Override per app via the `emptyMessages`
|
||||||
|
* option so localization stays out of this package.
|
||||||
|
*/
|
||||||
|
const EMPTY_MESSAGES = [
|
||||||
|
'Be the first to enter the leader board!',
|
||||||
|
'No scores yet — claim the top spot!',
|
||||||
|
'This board is wide open. Conquer it!',
|
||||||
|
'No champions here yet. Will it be you?',
|
||||||
|
'Blank slate — set the score to beat!',
|
||||||
|
'Nobody\'s here yet. Be the first!',
|
||||||
|
'The top spot is up for grabs. Take it!',
|
||||||
|
'Empty board. Time to make your mark!'
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only leaderboard view: windows, sorting (via the adapter), and rendering.
|
||||||
|
* Nothing here writes; the ranked value is a plain `score` displayed through an
|
||||||
|
* injected formatter, and all query I/O is delegated to an injected adapter's
|
||||||
|
* `listScores`. Safe to wire to a read-only / less-privileged backend instance.
|
||||||
|
*/
|
||||||
|
export class LeaderBoardReader {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Object} options
|
||||||
|
* @param {Object} options.adapter - storage backend; the READ side uses `listScores`
|
||||||
|
* @param {'asc'|'desc'} [options.scoreOrder] - 'asc' = lower is better (e.g. time), 'desc' = higher is better
|
||||||
|
* @param {(value: number) => string} [options.formatScore] - display formatter for a score
|
||||||
|
* @param {Object} [options.labels] - optional tab-label overrides keyed by duration id
|
||||||
|
* @param {Object} [options.tooltips] - optional tab hover-text overrides keyed by duration id
|
||||||
|
* @param {string[]} [options.emptyMessages] - empty-state messages (one picked at random); localize here
|
||||||
|
* @param {string} [options.loadingText] - shown while a window loads
|
||||||
|
* @param {string} [options.errorText] - shown when a window fails to load
|
||||||
|
* @param {string} [options.anonymousName] - fallback display name for entries without one
|
||||||
|
*/
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.adapter = options.adapter
|
||||||
|
this.scoreOrder = options.scoreOrder === 'desc' ? 'desc' : 'asc'
|
||||||
|
this.formatScore = options.formatScore || (value => String(value))
|
||||||
|
this.labels = options.labels || {}
|
||||||
|
this.tooltips = options.tooltips || {}
|
||||||
|
|
||||||
|
// User-facing strings — override to localize; the package ships English defaults.
|
||||||
|
this.emptyMessages = (Array.isArray(options.emptyMessages) && options.emptyMessages.length)
|
||||||
|
? options.emptyMessages
|
||||||
|
: EMPTY_MESSAGES
|
||||||
|
this.loadingText = options.loadingText || 'Loading…'
|
||||||
|
this.errorText = options.errorText || 'Leaderboard unavailable right now.'
|
||||||
|
this.anonymousName = options.anonymousName || 'Anonymous'
|
||||||
|
}
|
||||||
|
|
||||||
|
_label(duration) {
|
||||||
|
return this.labels[duration.id] || duration.label
|
||||||
|
}
|
||||||
|
|
||||||
|
_tooltip(duration) {
|
||||||
|
return this.tooltips[duration.id] || duration.title
|
||||||
|
}
|
||||||
|
|
||||||
|
_emptyMessage() {
|
||||||
|
return this.emptyMessages[Math.floor(Math.random() * this.emptyMessages.length)]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backend-neutral query descriptor for a category and time window. `since` is
|
||||||
|
* the rolling cutoff (entries with `time_stamp >= since`); `null` means
|
||||||
|
* all-time (no time filter). The adapter turns this into a real query.
|
||||||
|
*/
|
||||||
|
_descriptor(category, duration) {
|
||||||
|
return {
|
||||||
|
category,
|
||||||
|
since: duration.ms ? new Date(Date.now() - duration.ms) : null,
|
||||||
|
order: this.scoreOrder,
|
||||||
|
limit: 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the leaderboard for a category with a duration tab bar. When
|
||||||
|
* `duration` is omitted the last-selected tab is reused (so switching game
|
||||||
|
* category keeps the player on the same window), defaulting to "today".
|
||||||
|
* Returns the wrapper element; tab clicks re-query in place.
|
||||||
|
* @param {String} category
|
||||||
|
* @param {String} title
|
||||||
|
* @param {String} [duration]
|
||||||
|
* @returns {Promise<HTMLDivElement>}
|
||||||
|
*/
|
||||||
|
async render(category, title, duration) {
|
||||||
|
this.category = category
|
||||||
|
this.title = title
|
||||||
|
if (duration) this.duration = duration
|
||||||
|
if (!this.duration) this.duration = 'today'
|
||||||
|
duration = this.duration
|
||||||
|
|
||||||
|
const wrapper = document.createElement('div')
|
||||||
|
wrapper.style.maxWidth = '270px'
|
||||||
|
wrapper.style.margin = '0 auto'
|
||||||
|
|
||||||
|
const heading = document.createElement('h3')
|
||||||
|
heading.innerText = title
|
||||||
|
heading.style.borderBottom = '1px solid #c0c0c0'
|
||||||
|
heading.style.paddingBottom = '10px'
|
||||||
|
wrapper.append(heading)
|
||||||
|
|
||||||
|
const tabBar = document.createElement('div')
|
||||||
|
tabBar.style.display = 'flex'
|
||||||
|
tabBar.style.justifyContent = 'center'
|
||||||
|
tabBar.style.gap = '8px'
|
||||||
|
tabBar.style.marginBottom = '10px'
|
||||||
|
wrapper.append(tabBar)
|
||||||
|
|
||||||
|
const listWrapper = document.createElement('div')
|
||||||
|
wrapper.append(listWrapper)
|
||||||
|
|
||||||
|
const tabs = {}
|
||||||
|
const activate = (id) => {
|
||||||
|
this.duration = id
|
||||||
|
Object.entries(tabs).forEach(([tabId, el]) => this._styleTab(el, tabId === id))
|
||||||
|
this._loadList(listWrapper, category, DURATIONS.find(d => d.id === id))
|
||||||
|
}
|
||||||
|
|
||||||
|
DURATIONS.forEach(d => {
|
||||||
|
const tab = document.createElement('button')
|
||||||
|
tab.innerText = this._label(d)
|
||||||
|
tab.type = 'button'
|
||||||
|
tab.setAttribute('title', this._tooltip(d))
|
||||||
|
this._styleTab(tab, d.id === duration)
|
||||||
|
tab.onclick = () => activate(d.id)
|
||||||
|
tabs[d.id] = tab
|
||||||
|
tabBar.append(tab)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Return the wrapper (heading + tabs) right away and fill the list
|
||||||
|
// asynchronously, so a slow or failing query never blocks the UI.
|
||||||
|
this._loadList(listWrapper, category, DURATIONS.find(d => d.id === duration))
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
_styleTab(tab, active) {
|
||||||
|
tab.style.background = 'none'
|
||||||
|
tab.style.border = 'none'
|
||||||
|
tab.style.cursor = 'pointer'
|
||||||
|
tab.style.padding = '2px 4px'
|
||||||
|
tab.style.fontSize = '0.85em'
|
||||||
|
tab.style.color = active ? '#ffffff' : '#999999'
|
||||||
|
tab.style.fontWeight = active ? 'bold' : 'normal'
|
||||||
|
tab.style.borderBottom = active ? '2px solid orange' : '2px solid transparent'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a window's entries into the list area, showing a loading placeholder
|
||||||
|
* and turning any failure (e.g. the backend being unreachable) into a
|
||||||
|
* message instead of an unhandled rejection. Guards against races when the
|
||||||
|
* player switches tabs quickly by tagging the in-flight request.
|
||||||
|
*/
|
||||||
|
async _loadList(listWrapper, category, duration) {
|
||||||
|
const token = (this._loadToken || 0) + 1
|
||||||
|
this._loadToken = token
|
||||||
|
|
||||||
|
listWrapper.innerHTML = ''
|
||||||
|
const loading = document.createElement('em')
|
||||||
|
loading.innerText = this.loadingText
|
||||||
|
listWrapper.append(loading)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rows = await this.adapter.listScores(this._descriptor(category, duration))
|
||||||
|
if (this._loadToken !== token) return
|
||||||
|
this._renderList(listWrapper, rows)
|
||||||
|
} catch {
|
||||||
|
if (this._loadToken !== token) return
|
||||||
|
listWrapper.innerHTML = ''
|
||||||
|
const message = document.createElement('em')
|
||||||
|
message.innerText = this.errorText
|
||||||
|
listWrapper.append(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderList(listWrapper, rows) {
|
||||||
|
listWrapper.innerHTML = ''
|
||||||
|
|
||||||
|
if (!rows || !rows.length) {
|
||||||
|
const message = document.createElement('em')
|
||||||
|
message.innerText = this._emptyMessage()
|
||||||
|
listWrapper.append(message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = document.createElement('div')
|
||||||
|
list.style.listStyle = 'none'
|
||||||
|
list.style.textAlign = 'left'
|
||||||
|
|
||||||
|
let i = 1
|
||||||
|
rows.forEach(data => {
|
||||||
|
const item = document.createElement('div')
|
||||||
|
item.style.display = 'flex'
|
||||||
|
|
||||||
|
const indexElement = document.createElement('div')
|
||||||
|
indexElement.innerText = `#${i++}`
|
||||||
|
|
||||||
|
const nameElement = document.createElement('div')
|
||||||
|
const name = data.name || this.anonymousName
|
||||||
|
nameElement.innerHTML = name
|
||||||
|
nameElement.setAttribute('title', name)
|
||||||
|
nameElement.style.textOverflow = 'ellipsis'
|
||||||
|
nameElement.style.whiteSpace = 'nowrap'
|
||||||
|
nameElement.style.overflow = 'hidden'
|
||||||
|
nameElement.style.padding = '0 5px'
|
||||||
|
nameElement.style.fontWeight = 'bold'
|
||||||
|
nameElement.style.fontStyle = 'italic'
|
||||||
|
nameElement.style.flex = '1'
|
||||||
|
|
||||||
|
const scoreElement = document.createElement('div')
|
||||||
|
scoreElement.innerText = this.formatScore(data.score)
|
||||||
|
|
||||||
|
item.append(indexElement, nameElement, scoreElement)
|
||||||
|
list.append(item)
|
||||||
|
})
|
||||||
|
|
||||||
|
listWrapper.append(list)
|
||||||
|
}
|
||||||
|
}
|
||||||
80
packages/leaderboard/leaderboard-write.js
Normal file
80
packages/leaderboard/leaderboard-write.js
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
import { buckets } from '@cozy-games/utils/date-bucket/date-bucket.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The WRITE surface of the leaderboard: submitting a completed game — the
|
||||||
|
* personal archive plus, if it qualifies, a ranked entry with denormalized
|
||||||
|
* day/week/month bucket keys. Importable WITHOUT any read/render code — no DOM,
|
||||||
|
* no list rendering, no `listScores` — so a consumer can wire writes to a
|
||||||
|
* separate, more-privileged backend instance (leaderboard-01) and pull in none
|
||||||
|
* of the read surface.
|
||||||
|
*
|
||||||
|
* The WRITE side calls `adapter.archive` (optional) and `adapter.addScore`; it
|
||||||
|
* also reads the ranking `config` once (an adapter call, not read/render code)
|
||||||
|
* to power the default qualifier.
|
||||||
|
*/
|
||||||
|
export class LeaderBoardWriter {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Object} options
|
||||||
|
* @param {Object} options.adapter - storage backend; the WRITE side uses `addScore`, optional `archive`, and `getConfig`
|
||||||
|
* @param {(entry: Object) => boolean} [options.qualifies] - whether an entry is ranked; defaults to server passingStatus vs entry.status
|
||||||
|
*/
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.adapter = options.adapter
|
||||||
|
this.qualifies = options.qualifies || (entry => this._defaultQualifies(entry))
|
||||||
|
|
||||||
|
// The default qualifier depends on the server's ranking config, so load it
|
||||||
|
// once. This is a config READ via the adapter — it pulls in no read/render
|
||||||
|
// module, keeping the write surface importable on its own.
|
||||||
|
Promise.resolve(this.adapter && this.adapter.getConfig ? this.adapter.getConfig() : undefined)
|
||||||
|
.then(config => { this.configuration = config })
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default ranking gate: if the server config names a `passingStatus`, only
|
||||||
|
* entries whose `status` matches qualify; otherwise every entry qualifies.
|
||||||
|
*/
|
||||||
|
_defaultQualifies(entry) {
|
||||||
|
const passing = this.configuration && this.configuration.passingStatus
|
||||||
|
if (!passing) return true
|
||||||
|
return entry.status === passing
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit a completed game. Always archives it (personal history); if it
|
||||||
|
* qualifies, also writes a ranked entry with denormalized bucket keys. Both
|
||||||
|
* writes go through the adapter, so the storage backend is pluggable. The
|
||||||
|
* caller owns display-name/nickname UX.
|
||||||
|
* @param {Object} entry - { name, playerId, score, category, time_stamp, status?, meta? }
|
||||||
|
*/
|
||||||
|
async submit(entry) {
|
||||||
|
if (this.adapter.archive) {
|
||||||
|
await this.adapter.archive({
|
||||||
|
playerId: entry.playerId,
|
||||||
|
score: entry.score,
|
||||||
|
category: entry.category,
|
||||||
|
time_stamp: entry.time_stamp,
|
||||||
|
meta: entry.meta
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.qualifies(entry)) return
|
||||||
|
|
||||||
|
const stamp = entry.time_stamp instanceof Date ? entry.time_stamp : new Date(entry.time_stamp)
|
||||||
|
const bucket = buckets(stamp)
|
||||||
|
const scoreDoc = {
|
||||||
|
name: entry.name || 'Anonymous',
|
||||||
|
playerId: entry.playerId,
|
||||||
|
score: entry.score,
|
||||||
|
category: entry.category,
|
||||||
|
time_stamp: entry.time_stamp,
|
||||||
|
day: bucket.day,
|
||||||
|
week: bucket.week,
|
||||||
|
month: bucket.month
|
||||||
|
}
|
||||||
|
if (entry.meta) scoreDoc.meta = entry.meta
|
||||||
|
|
||||||
|
await this.adapter.addScore(entry.category, scoreDoc)
|
||||||
|
}
|
||||||
|
}
|
||||||
113
packages/leaderboard/test/read-write-separation.test.js
Normal file
113
packages/leaderboard/test/read-write-separation.test.js
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
// @ts-check
|
||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { dirname, join } from 'node:path'
|
||||||
|
|
||||||
|
import { LeaderBoardReader } from '../leaderboard-read.js'
|
||||||
|
import { LeaderBoardWriter } from '../leaderboard-write.js'
|
||||||
|
import { LeaderBoardService } from '../leader-board.js'
|
||||||
|
|
||||||
|
const pkgDir = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||||
|
const source = (file) => readFileSync(join(pkgDir, file), 'utf8')
|
||||||
|
.replace(/\/\*[\s\S]*?\*\//g, '') // strip block comments
|
||||||
|
.replace(/\/\/.*$/gm, '') // strip line comments
|
||||||
|
|
||||||
|
describe('read/write surface separation — imports', () => {
|
||||||
|
it('the READ module pulls in no write-path code', () => {
|
||||||
|
const code = source('leaderboard-read.js')
|
||||||
|
// no import of the write module or its unique dependency (bucket keys)
|
||||||
|
expect(code).not.toMatch(/from\s+['"][^'"]*leaderboard-write/)
|
||||||
|
expect(code).not.toMatch(/date-bucket|buckets/)
|
||||||
|
// no write operations
|
||||||
|
expect(code).not.toMatch(/\bsubmit\b|\baddScore\b|\barchive\b/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('the WRITE module pulls in no read/render code', () => {
|
||||||
|
const code = source('leaderboard-write.js')
|
||||||
|
expect(code).not.toMatch(/from\s+['"][^'"]*leaderboard-read/)
|
||||||
|
// no DOM / rendering / listing
|
||||||
|
expect(code).not.toMatch(/\bdocument\b|\brender\b|\blistScores\b|createElement/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('read surface — usable standalone (no writes)', () => {
|
||||||
|
it('renders a ranked list via listScores and never calls write methods', async () => {
|
||||||
|
const listScores = vi.fn(async () => [{ name: 'Ada', score: 10 }, { name: 'Bo', score: 20 }])
|
||||||
|
// A deliberately read-only adapter: no addScore/archive at all.
|
||||||
|
const adapter = { listScores }
|
||||||
|
const reader = new LeaderBoardReader({ adapter, scoreOrder: 'asc', formatScore: String })
|
||||||
|
|
||||||
|
const el = await reader.render('beginner', 'Best Times')
|
||||||
|
expect(el.querySelector('h3')).toBeTruthy() // heading rendered
|
||||||
|
expect(el.querySelectorAll('button')).toHaveLength(4) // four duration tabs
|
||||||
|
expect(listScores).toHaveBeenCalledTimes(1) // 'today' window loaded once
|
||||||
|
|
||||||
|
// The list fills asynchronously — wait for the rows to appear (names use innerHTML).
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(el.textContent).toContain('Ada')
|
||||||
|
expect(el.textContent).toContain('Bo')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Reader exposes no write API.
|
||||||
|
expect(typeof (/** @type {any} */ (reader).submit)).toBe('undefined')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('write surface — usable standalone with an injected instance', () => {
|
||||||
|
it('submits a completed game through the injected adapter (archive + ranked write)', async () => {
|
||||||
|
const archive = vi.fn(async () => {})
|
||||||
|
const addScore = vi.fn(async () => {})
|
||||||
|
// Injected, write-capable adapter (no listScores/render needed).
|
||||||
|
const adapter = { archive, addScore }
|
||||||
|
const writer = new LeaderBoardWriter({ adapter })
|
||||||
|
|
||||||
|
await writer.submit({
|
||||||
|
name: 'Ada', playerId: 'p1', score: 42, category: 'beginner', time_stamp: Date.UTC(2026, 6, 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(archive).toHaveBeenCalledTimes(1)
|
||||||
|
expect(addScore).toHaveBeenCalledTimes(1)
|
||||||
|
const [category, doc] = addScore.mock.calls[0]
|
||||||
|
expect(category).toBe('beginner')
|
||||||
|
expect(doc).toMatchObject({ name: 'Ada', playerId: 'p1', score: 42 })
|
||||||
|
// denormalized bucket keys are computed on the write side
|
||||||
|
expect(doc).toEqual(expect.objectContaining({ day: expect.any(String), week: expect.any(String), month: expect.any(String) }))
|
||||||
|
|
||||||
|
// Writer exposes no read/render API.
|
||||||
|
expect(typeof (/** @type {any} */ (writer).render)).toBe('undefined')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('honors an explicit qualifies gate (skips the ranked write, still archives)', async () => {
|
||||||
|
const archive = vi.fn(async () => {})
|
||||||
|
const addScore = vi.fn(async () => {})
|
||||||
|
const writer = new LeaderBoardWriter({ adapter: { archive, addScore }, qualifies: () => false })
|
||||||
|
await writer.submit({ playerId: 'p1', score: 1, category: 'beginner', time_stamp: 0 })
|
||||||
|
expect(archive).toHaveBeenCalledTimes(1)
|
||||||
|
expect(addScore).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('facade — combined surface unchanged (regression)', () => {
|
||||||
|
it('LeaderBoardService delegates render (read) and submit (write)', async () => {
|
||||||
|
const listScores = vi.fn(async () => [{ name: 'Ada', score: 10 }])
|
||||||
|
const archive = vi.fn(async () => {})
|
||||||
|
const addScore = vi.fn(async () => {})
|
||||||
|
const adapter = { listScores, archive, addScore }
|
||||||
|
const service = new LeaderBoardService({ adapter, formatScore: String })
|
||||||
|
|
||||||
|
// read
|
||||||
|
const el = await service.render('beginner', 'Best')
|
||||||
|
expect(el.querySelector('h3')).toBeTruthy()
|
||||||
|
expect(listScores).toHaveBeenCalled()
|
||||||
|
await vi.waitFor(() => expect(el.textContent).toContain('Ada'))
|
||||||
|
|
||||||
|
// write
|
||||||
|
await service.submit({ name: 'Ada', playerId: 'p1', score: 5, category: 'beginner', time_stamp: 0 })
|
||||||
|
expect(addScore).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
// composed surfaces are reachable
|
||||||
|
expect(service.reader).toBeInstanceOf(LeaderBoardReader)
|
||||||
|
expect(service.writer).toBeInstanceOf(LeaderBoardWriter)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in a new issue