mnswpr/leaderboard/leaderboard-element.js
Ayo 9e1114be78 feat: reusable, time-windowed leaderboard (@cozy-games/leaderboard) (#1)
Extract the leaderboard into a generic, backend-agnostic package and add
rolling Today/Week/Month/All-Time windows, a web component, and local-first
development against the Firestore emulator.

Package (leaderboard/):
- LeaderBoardService: backend-agnostic core via a storage-adapter seam, with
  Firebase and Supabase adapters (Supabase client injected, no added dep)
- Rolling time windows (last 24h / 7d / 30d) with hover tooltips; top-N by score
- <cozy-leaderboard> web component built on web-component-base: compose the UI
  in HTML, configure the backend once in JS
- Every user-facing string is configurable (labels, tooltips, empty/loading/
  error messages, anonymous name) so i18n lives in the app; README + CONFIGURATION

App (mnswpr.com):
- Compose the board declaratively in index.html via <cozy-leaderboard>
- Nickname + randomized greeting bar; score submission through the element
- Legends: the current all-time leaders frozen into a static /legends page
- Firebase config and leaderboard namespace via Vite env vars; emulator-first
  local dev (VITE_FIRESTORE_EMULATOR)

Firebase schema-as-code:
- firebase.json, .firebaserc, firestore.rules (public reads, create-only scores,
  no client updates/deletes, namespace-generalized), empty indexes (rolling
  windows need none)
- prod (mw-*) vs dev/test (mw-test-*) separation by collection namespace
- emulator config, seed script, and docs (firebase-leaderboards.md,
  leaderboard-env-migration.md, AYO.md)

Utils/tests: UTC date-bucket helper (retained as metadata) with Vitest coverage.

Reviewed-on: git.ayo.run:3000/
Co-authored-by: Ayo <ayo@ayco.io>
Co-committed-by: Ayo <ayo@ayco.io>
2026-07-03 11:41:14 +00:00

164 lines
6.1 KiB
JavaScript

import { WebComponent } from 'web-component-base'
import { LeaderBoardService } from './leader-board.js'
/**
* `<cozy-leaderboard>` — a custom element that lets a developer compose the
* leaderboard UI declaratively in HTML instead of wiring it in JavaScript.
*
* Built on `web-component-base` (WebComponent base class + lifecycle hooks). It
* extends WCB for the custom-element scaffolding (onInit/onDestroy, connect
* handling) and delegates all inner DOM — the duration tabs and the ranked
* list — to the existing LeaderBoardService, which already builds and manages
* that DOM (including in-place tab swaps).
*
* Composition lives in HTML attributes; the storage backend (adapter) is set
* once in JS via configureLeaderboard(), because env-var config can't live in
* static markup.
*
* <cozy-leaderboard category="beginner" title="Best Times" format="time"></cozy-leaderboard>
*/
// Shared config for every element on the page, set once via configureLeaderboard().
let sharedConfig = {}
const instances = new Set()
/**
* Configure the backend + defaults for all <cozy-leaderboard> elements. Call
* once at startup, after building your adapter (Firebase/Supabase/…). User-facing
* strings (labels, emptyMessages, loadingText, errorText, anonymousName) can be
* passed here to localize without changing the package.
* @param {Object} options - { adapter, scoreOrder?, format?, formatScore?, qualifies?, labels?, emptyMessages?, loadingText?, errorText?, anonymousName? }
*/
export function configureLeaderboard(options = {}) {
sharedConfig = { ...sharedConfig, ...options }
instances.forEach(el => el._mount())
}
const clean = (str, separator) => (str === '00' ? '' : `${str}${separator}`)
// ms -> pretty time, e.g. 4200 -> "04.2" (mirrors utils/timer pretty()).
const prettyTime = ms => {
if (!ms) return '0'
const milliseconds = parseInt((ms % 1000) / 100)
const seconds = Math.floor((ms / 1000) % 60)
const minutes = Math.floor((ms / (1000 * 60)) % 60)
const hours = Math.floor((ms / (1000 * 60 * 60)) % 24)
const hh = hours < 10 ? `0${hours}` : `${hours}`
const mm = minutes < 10 ? `0${minutes}` : `${minutes}`
const ss = seconds < 10 ? `0${seconds}` : `${seconds}`
return `${clean(hh, ':')}${clean(mm, ':')}${clean(ss, '.')}${milliseconds}`
}
// Built-in score formatters, selectable via the `format` attribute.
const FORMATTERS = { time: prettyTime }
const resolveFormat = name => FORMATTERS[name]
export class CozyLeaderboard extends WebComponent {
static get observedAttributes() {
return ['category', 'title', 'duration', 'score-order', 'format']
}
// WCB lifecycle: register/unregister so configureLeaderboard() can re-render.
onInit() {
instances.add(this)
}
onDestroy() {
instances.delete(this)
}
// Attributes are read directly (getAttribute) rather than through WCB's typed
// props proxy, so optional/empty values never trip its type enforcement.
attributeChangedCallback(name, previousValue, currentValue) {
if (previousValue === currentValue || !this.isConnected) return
this._mount(name === 'duration' ? (currentValue || undefined) : undefined)
}
// WCB calls render() on connect; we treat it as "(re)mount the board".
render() {
this._mount()
}
// Per-element override properties (public): adapter, formatScore, qualifies,
// labels, emptyMessages, loadingText, errorText, anonymousName. Each falls back
// to the shared configureLeaderboard() value, then the package default. Set
// them before the element connects (or clear `_svc` to force a rebuild).
_service() {
if (this._svc) return this._svc
const adapter = this.adapter || sharedConfig.adapter
if (!adapter) return null
const formatScore = this.formatScore
|| resolveFormat(this.getAttribute('format'))
|| sharedConfig.formatScore
|| resolveFormat(sharedConfig.format)
|| String
this._svc = new LeaderBoardService({
adapter,
scoreOrder: this.getAttribute('score-order') || sharedConfig.scoreOrder || 'asc',
formatScore,
qualifies: this.qualifies || sharedConfig.qualifies,
// User-facing strings — pass through so apps localize without touching the package.
labels: this.labels || sharedConfig.labels,
tooltips: this.tooltips || sharedConfig.tooltips,
emptyMessages: this.emptyMessages || sharedConfig.emptyMessages,
loadingText: this.loadingText || sharedConfig.loadingText,
errorText: this.errorText || sharedConfig.errorText,
anonymousName: this.anonymousName || sharedConfig.anonymousName
})
return this._svc
}
/**
* (Re)render the board. The first successful mount honors the author's
* `duration` attribute; later mounts preserve the service's remembered
* duration (so switching category keeps the selected tab) unless a duration
* is passed explicitly.
*/
_mount(durationArg) {
if (!this.isConnected) return
const service = this._service()
if (!service) {
this.replaceChildren(this._message('Leaderboard not configured.'))
return
}
let duration = durationArg
if (duration === undefined && !this._mounted) {
duration = this.getAttribute('duration') || undefined
}
const token = (this._token || 0) + 1
this._token = token
service.render(this.getAttribute('category') || '', this.getAttribute('title') || '', duration)
.then(el => {
if (this._token !== token) return
this.replaceChildren(el)
this._mounted = true
})
.catch(() => {
if (this._token !== token) return
this.replaceChildren(this._message('Leaderboard unavailable right now.'))
})
}
/**
* Submit a finished game through this element's service — keeps score
* submission a one-liner from the host app.
* @param {Object} entry
*/
submit(entry) {
const service = this._service()
if (service) return service.submit(entry)
}
_message(text) {
const em = document.createElement('em')
em.innerText = text
return em
}
}
if (!customElements.get('cozy-leaderboard')) {
customElements.define('cozy-leaderboard', CozyLeaderboard)
}