From c2ffb0d7f6d6bae04bf22a1797da5c2590c9f4e2 Mon Sep 17 00:00:00 2001 From: Ayo Date: Sat, 4 Jul 2026 13:54:03 +0200 Subject: [PATCH] feat(leaderboard): read-write separation --- packages/leaderboard/README.md | 31 ++ packages/leaderboard/leader-board.js | 315 +++--------------- packages/leaderboard/leaderboard-read.js | 248 ++++++++++++++ packages/leaderboard/leaderboard-write.js | 80 +++++ .../test/read-write-separation.test.js | 113 +++++++ 5 files changed, 511 insertions(+), 276 deletions(-) create mode 100644 packages/leaderboard/leaderboard-read.js create mode 100644 packages/leaderboard/leaderboard-write.js create mode 100644 packages/leaderboard/test/read-write-separation.test.js diff --git a/packages/leaderboard/README.md b/packages/leaderboard/README.md index daeb0fd..7198d9d 100644 --- a/packages/leaderboard/README.md +++ b/packages/leaderboard/README.md @@ -147,6 +147,37 @@ Want to author your own custom elements this way? Check out **[webcomponent.io](https://webcomponent.io)** and **[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 ``) 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 ### Bring your own backend instance (injection) diff --git a/packages/leaderboard/leader-board.js b/packages/leaderboard/leader-board.js index e1d82d2..ff47627 100644 --- a/packages/leaderboard/leader-board.js +++ b/packages/leaderboard/leader-board.js @@ -1,302 +1,65 @@ -import { buckets } from '@cozy-games/utils/date-bucket/date-bucket.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!' -] +import { LeaderBoardReader } from './leaderboard-read.js' +import { LeaderBoardWriter } from './leaderboard-write.js' /** * Generic, game- AND backend-agnostic leaderboard. Nothing here knows about * minesweeper or Firebase: the ranked value is a plain `score`, sorted in the * configured direction and displayed through an injected formatter, while all - * storage I/O is delegated to an injected adapter (see adapters/). Games wire - * their specifics through the constructor options. + * storage I/O is delegated to an injected adapter (see adapters/). + * + * 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: * - getConfig(): Promise - * - listScores({ category, field, value, order, limit }): Promise - * - addScore(category, entry): Promise - * - archive(entry): Promise // optional personal history + * - listScores({ category, since, order, limit }): Promise // read + * - addScore(category, entry): Promise // write + * - archive(entry): Promise // optional personal history // write */ 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 {'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 {(entry: Object) => boolean} [options.qualifies] - whether an entry is ranked; defaults to server passingStatus vs entry.status - * @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 + * @param {'asc'|'desc'} [options.scoreOrder] + * @param {(value: number) => string} [options.formatScore] + * @param {(entry: Object) => boolean} [options.qualifies] + * @param {Object} [options.labels] + * @param {Object} [options.tooltips] + * @param {string[]} [options.emptyMessages] + * @param {string} [options.loadingText] + * @param {string} [options.errorText] + * @param {string} [options.anonymousName] */ constructor(options = {}) { this.adapter = options.adapter - this.scoreOrder = options.scoreOrder === 'desc' ? 'desc' : 'asc' - this.formatScore = options.formatScore || (value => String(value)) - 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(() => {}) + this.reader = new LeaderBoardReader(options) + this.writer = new LeaderBoardWriter(options) } /** - * Default ranking gate: if the server config names a `passingStatus`, only - * entries whose `status` matches qualify; otherwise every entry qualifies. + * Read surface — render the ranked list with a duration tab bar. + * @see LeaderBoardReader#render */ - _defaultQualifies(entry) { - const passing = this.configuration && this.configuration.passingStatus - 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)] + render(category, title, duration) { + return this.reader.render(category, title, duration) } /** - * 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. + * Write surface — submit a completed game (archive + ranked entry). + * @see LeaderBoardWriter#submit */ - _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} - */ - 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) + submit(entry) { + return this.writer.submit(entry) } } diff --git a/packages/leaderboard/leaderboard-read.js b/packages/leaderboard/leaderboard-read.js new file mode 100644 index 0000000..b1cefc7 --- /dev/null +++ b/packages/leaderboard/leaderboard-read.js @@ -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} + */ + 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) + } +} diff --git a/packages/leaderboard/leaderboard-write.js b/packages/leaderboard/leaderboard-write.js new file mode 100644 index 0000000..651dd2c --- /dev/null +++ b/packages/leaderboard/leaderboard-write.js @@ -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) + } +} diff --git a/packages/leaderboard/test/read-write-separation.test.js b/packages/leaderboard/test/read-write-separation.test.js new file mode 100644 index 0000000..bfacb9f --- /dev/null +++ b/packages/leaderboard/test/read-write-separation.test.js @@ -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) + }) +})