cozy-games/leaderboard/README.md
Ayo 4fa88fb431 feat: reusable, time-windowed leaderboard (@cozy-games/leaderboard)
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: https://git.ayo.run/ayo/mnswpr/pulls/1
Co-authored-by: Ayo <ayo@ayco.io>
Co-committed-by: Ayo <ayo@ayco.io>
2026-07-03 13:42:03 +02:00

248 lines
9.6 KiB
Markdown

# @cozy-games/leaderboard
A generic, framework-free leaderboard with **Today / Week / Month / All Time**
time windows. It is game-agnostic (the ranked value is a plain `score`) and
**backend-agnostic**: all storage I/O goes through an injected *adapter*, so you
can run it on Firebase, Supabase, or anything else.
```
LeaderBoardService (core: windows, sorting, rendering, submit)
│ uses
Adapter ── FirebaseAdapter | SupabaseAdapter | your own
```
## Quick start
```js
import { LeaderBoardService } from '@cozy-games/leaderboard/leader-board.js'
import { FirebaseAdapter } from '@cozy-games/leaderboard/adapters/firebase.js'
const service = new LeaderBoardService({
adapter: new FirebaseAdapter({ firebaseConfig, namespace: 'mw' }),
scoreOrder: 'asc', // 'asc' = lower is better (time); 'desc' = points
formatScore: ms => prettyTime(ms) // how a score is displayed
})
// render into the page (Today tab by default)
document.body.append(await service.render('beginner', 'Best Times'))
// submit a finished game
service.submit({
name: 'Ayo',
playerId: 'browser-abc',
score: 4200,
category: 'beginner',
time_stamp: new Date(),
status: 'win', // optional; used by default qualifier
meta: { isMobile: false } // optional extras
})
```
### Service options
| option | required | meaning |
| --- | --- | --- |
| `adapter` | yes | storage backend (see below) |
| `scoreOrder` | no | `'asc'` (default) or `'desc'` |
| `formatScore` | no | `(value) => string`; defaults to `String(value)` |
| `qualifies` | no | `(entry) => boolean`; default ranks entries whose `status` matches the server config's `passingStatus` (all entries if none) |
| `labels` | no | override tab labels, keyed by `today`/`week`/`month`/`all` |
| `tooltips` | no | override tab hover text, keyed by `today`/`week`/`month`/`all` |
| `emptyMessages` | no | `string[]` shown when a window has no scores (one picked at random) |
| `loadingText` | no | text shown while a window loads (default `Loading…`) |
| `errorText` | no | text shown when a window fails to load |
| `anonymousName` | no | fallback display name for entries without one (default `Anonymous`) |
**Localization** lives with you, not this package: every user-facing string —
tab `labels`, `emptyMessages`, `loadingText`, `errorText`, `anonymousName` — is an
option, so you pass your translated copy (from `<cozy-leaderboard>` use it via
`configureLeaderboard({ ... })`). The package only ships English defaults.
📖 **Full reference:** every option, the web-component attributes/properties,
adapters, i18n, and precedence rules are documented in
[CONFIGURATION.md](./CONFIGURATION.md).
## Use it as a web component
Prefer to **compose the UI in your HTML** instead of wiring it in JavaScript? The
package ships a `<cozy-leaderboard>` custom element. Configure the backend once,
then drop the element anywhere in your markup — the board (duration tabs + list)
renders itself.
```html
<!-- 1. configure the backend once (the only JS you need) -->
<script type="module">
import { configureLeaderboard } from '@cozy-games/leaderboard/leaderboard-element.js'
import { FirebaseAdapter } from '@cozy-games/leaderboard/adapters/firebase.js'
configureLeaderboard({
adapter: new FirebaseAdapter({ firebaseConfig, namespace: 'mw' })
})
</script>
<!-- 2. compose the UI declaratively, anywhere -->
<cozy-leaderboard category="beginner" title="Best Times" format="time"></cozy-leaderboard>
```
No build step required — it works straight from a CDN too:
```html
<script type="module">
import { configureLeaderboard } from 'https://esm.sh/@cozy-games/leaderboard/leaderboard-element.js'
import { FirebaseAdapter } from 'https://esm.sh/@cozy-games/leaderboard/adapters/firebase.js'
configureLeaderboard({ adapter: new FirebaseAdapter({ firebaseConfig, namespace: 'mw' }) })
</script>
<cozy-leaderboard category="expert" title="Legends" format="time" score-order="asc"></cozy-leaderboard>
```
### Attributes
| attribute | meaning |
| --- | --- |
| `category` | which board to show (e.g. a difficulty/level id) |
| `title` | heading text above the tabs |
| `duration` | initial tab: `today` (default) · `week` · `month` · `all` |
| `score-order` | `asc` (lower is better, default) or `desc` (higher is better) |
| `format` | score display preset: `time` (ms → `mm:ss.t`), `number`, or `plain` |
Change `category`/`title` at runtime and the board re-renders reactively while
**keeping the selected duration tab** — e.g. `el.setAttribute('category', 'expert')`.
### Properties & methods
- `.adapter`, `.formatScore`, `.qualifies` — per-element overrides of the shared
`configureLeaderboard()` defaults (for advanced/multi-backend pages).
- `.submit(entry)` — submit a finished game through this element's service:
```js
document.querySelector('cozy-leaderboard').submit({
name: 'Ayo', playerId: 'browser-abc', score: 4200,
category: 'beginner', time_stamp: new Date(), status: 'win'
})
```
## Why a web component?
`<cozy-leaderboard>` is built on **[web-component-base](https://github.com/ayo-run/wcb)**
(WCB) — a zero-dependency, tiny base class for reactive custom elements
([webcomponent.io](https://webcomponent.io)). Shipping the leaderboard as a web
component means:
- **Framework-agnostic & native.** A custom element is part of the platform, so
it drops into React, Vue, Svelte, Angular, Astro, or a plain HTML file
identically — no per-framework wrappers.
- **Declarative composition.** You place the board where it belongs in your
markup and set attributes, instead of imperative `createElement`/`append`
plumbing in JS.
- **Reactive.** Change an attribute (`category`, `title`, …) and the element
updates itself — the reactivity model WCB is built around.
- **Encapsulated & reusable.** One tag, one contract; reuse it across pages and
projects without copy-pasting wiring.
- **No build step.** Works today in every modern browser, straight from a CDN —
no compilers, transpilers, or polyfills. That "just use the platform" ethos is
exactly what WCB is for.
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)**.
## Choosing a backend
### Firebase (Firestore)
```js
import { FirebaseAdapter } from '@cozy-games/leaderboard/adapters/firebase.js'
const adapter = new FirebaseAdapter({ firebaseConfig, namespace: 'mw' })
```
Needs the `firebase` peer dependency. Uses collections
`{ns}-scores/{category}/games`, `{ns}-all/{playerId}/games`,
`{ns}-config/configuration`. Time windows are **rolling** (`time_stamp >= cutoff`)
and all-time sorts by `score`, so only Firestore's automatic single-field indexes
are needed — no composite indexes to deploy.
For local development, pass `emulator` to run against the
[Firestore emulator](https://firebase.google.com/docs/emulator-suite) — no cloud,
no deploy:
```js
new FirebaseAdapter({ firebaseConfig, namespace: 'mw', emulator: { host: '127.0.0.1', port: 8080 } })
```
### Supabase (Postgres)
```js
import { createClient } from '@supabase/supabase-js'
import { SupabaseAdapter } from '@cozy-games/leaderboard/adapters/supabase.js'
const client = createClient(url, anonKey)
const adapter = new SupabaseAdapter({ client, namespace: 'mw' })
```
You construct the supabase client yourself (the package takes no supabase
dependency). Create these tables (namespace `mw` shown):
```sql
create table mw_scores (
id bigint generated always as identity primary key,
name text not null check (char_length(name) <= 24),
player_id text,
score numeric not null,
category text not null,
time_stamp timestamptz not null default now(),
day text not null, -- '2026-07-03'
week text not null, -- '2026-W27'
month text not null, -- '2026-07'
meta jsonb
);
-- rolling-window lookups (time_stamp >= cutoff) + top-N by score
create index on mw_scores (category, time_stamp);
create index on mw_scores (category, score);
create table mw_archive (
id bigint generated always as identity primary key,
player_id text,
score numeric,
category text,
time_stamp timestamptz not null default now(),
meta jsonb
);
create table mw_config (
id text primary key, -- 'configuration'
passingStatus text,
message text
);
-- public read on the boards, append-only inserts on scores
alter table mw_scores enable row level security;
create policy read_scores on mw_scores for select using (true);
create policy write_scores on mw_scores for insert with check (score is not null);
alter table mw_config enable row level security;
create policy read_config on mw_config for select using (true);
```
Note: the `day`/`week`/`month` columns are still written (kept as metadata) but
windows query by `time_stamp`. For a `desc` game, add `(category, score desc)`.
## Writing your own adapter
An adapter is any object implementing:
```ts
getConfig(): Promise<object | undefined>
listScores(q: {
category: string,
since: Date | null, // rolling cutoff (time_stamp >= since); null = All Time
order: 'asc' | 'desc',
limit: number
}): Promise<Array<{ name: string, score: number, ...}>>
addScore(category: string, entry: object): Promise<void>
archive?(entry: object): Promise<void> // optional personal history
```
`listScores` must return the top `limit` records for the window, best-first,
exposing at least `name` and `score`. The core builds `entry` and the query
descriptor; the adapter only performs raw reads/writes. That's the whole
contract — implement it against any store.