mnswpr/leaderboard
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
..
adapters feat: reusable, time-windowed leaderboard (@cozy-games/leaderboard) (#1) 2026-07-03 11:41:14 +00:00
CONFIGURATION.md feat: reusable, time-windowed leaderboard (@cozy-games/leaderboard) (#1) 2026-07-03 11:41:14 +00:00
leader-board.js feat: reusable, time-windowed leaderboard (@cozy-games/leaderboard) (#1) 2026-07-03 11:41:14 +00:00
leaderboard-element.js feat: reusable, time-windowed leaderboard (@cozy-games/leaderboard) (#1) 2026-07-03 11:41:14 +00:00
package.json feat: reusable, time-windowed leaderboard (@cozy-games/leaderboard) (#1) 2026-07-03 11:41:14 +00:00
README.md feat: reusable, time-windowed leaderboard (@cozy-games/leaderboard) (#1) 2026-07-03 11:41:14 +00:00
vite.config.js feat: reusable, time-windowed leaderboard (@cozy-games/leaderboard) (#1) 2026-07-03 11:41:14 +00:00

@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

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.

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.

<!-- 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:

<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:
    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 (WCB) — a zero-dependency, tiny base class for reactive custom elements (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 and web-component-base.

Choosing a backend

Firebase (Firestore)

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 — no cloud, no deploy:

new FirebaseAdapter({ firebaseConfig, namespace: 'mw', emulator: { host: '127.0.0.1', port: 8080 } })

Supabase (Postgres)

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):

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:

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.