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.
6.1 KiB
Migration: env-var-driven prod/test separation
Why
Today the leaderboard has no separation between production and test data.
Every environment — a developer running pnpm dev, a Netlify preview, and the
live site — reads and writes the same collections (mw-scores, mw-all,
mw-config, legacy mw-leaders) in the single Firebase project
secure-moment-188701. version/import.meta.env.MODE only affects UI text
(lib/mnswpr.js:96); it never changes collection names. So
local play pollutes the production leaderboard.
We keep one Firebase project (there is no separate prod project — see docs/firebase-leaderboards.md) and separate data by collection namespace, chosen at build time via an env var:
| Environment | VITE_LB_NAMESPACE |
Collections |
|---|---|---|
| Production (Netlify) | mw |
mw-scores, mw-all, mw-config |
| Local dev / previews | mw-test |
mw-test-scores, mw-test-all, mw-test-config |
The Firebase web config (VITE_FIREBASE_*) is identical in both — same
project — so the only thing distinguishing prod from test is the namespace.
Existing production data under mw-* is untouched; mw-test-* starts empty.
Current vs target
| Piece | Now | After |
|---|---|---|
| Namespace | hardcoded 'mw' in app/main.js |
import.meta.env.VITE_LB_NAMESPACE |
| Dev writes | into prod mw-* |
into mw-test-* |
| Rules | only mw-* matches |
any mw / mw-test namespace |
| Indexes | games collection group |
unchanged (already covers all namespaces) |
.firebaserc |
REPLACE_WITH_PROD_PROJECT_ID placeholder |
secure-moment-188701 |
Steps
1. Add the namespace env var
- app/.env.development: add
VITE_LB_NAMESPACE=mw-test - app/.env.example: document
VITE_LB_NAMESPACE - Netlify (production build): set
VITE_LB_NAMESPACE=mwand the sameVITE_FIREBASE_*values as dev (same project).
2. Read it in the app
In app/main.js, replace the hardcoded namespace. Default to the test namespace so a missing/misconfigured var can never write to production:
new FirebaseAdapter({
firebaseConfig,
namespace: import.meta.env.VITE_LB_NAMESPACE || 'mw-test'
})
Production must set VITE_LB_NAMESPACE=mw explicitly. (Fail-safe: the worst case
of a missing var is an empty test board, never prod pollution.)
3. Generalize the security rules
Rewrite firestore.rules so a rule matches by the namespace
suffix instead of a literal mw- prefix. Firestore ORs all matching rules, so
one set of generic blocks covers mw, mw-test, and any future namespace.
Note: {ns}-scores/{cat}/games/{id}, {ns}-all/{id}/games/{s} and
{ns}-leaders/{cat}/games/{id} share the same 4-segment shape, so each block is
guarded by a regex on the captured collection name.
rules_version = '2'
service cloud.firestore {
match /databases/{database}/documents {
// Ranked, windowed scores — public read, validated create-only.
match /{scores}/{category}/games/{game} {
allow read: if scores.matches('mw(-[a-z]+)?-scores');
allow create: if scores.matches('mw(-[a-z]+)?-scores') && isValidScore(category);
allow update, delete: if false;
}
// Legacy all-time (now frozen into Legends) — read-only.
match /{leaders}/{category}/games/{game} {
allow read: if leaders.matches('mw(-[a-z]+)?-leaders');
allow write: if false;
}
// Per-browser personal archive — permissive, as before.
match /{all}/{browserId}/games/{session} {
allow read, write: if all.matches('mw(-[a-z]+)?-all');
}
// Server config — public read, no client write.
match /{config}/{document} {
allow read: if config.matches('mw(-[a-z]+)?-config');
allow write: if false;
}
function isValidScore(category) {
let d = request.resource.data;
return d.score is number
&& d.score >= 0
&& d.category == category
&& d.name is string
&& d.name.size() <= 24
&& d.day is string
&& d.week is string
&& d.month is string;
}
}
}
The regex mw(-[a-z]+)?-scores matches mw-scores and mw-test-scores
(and e.g. mw-preview-scores).
4. Indexes — none needed
Time windows are rolling (time_stamp >=) and all-time sorts by score, so the
queries use Firestore's automatic single-field indexes.
firestore.indexes.json is empty — nothing to add for
any namespace.
5. Seed the test config doc
Windowed qualification reads {ns}-config/configuration. Create
mw-test-config/configuration once (copy passingStatus and message from the
prod mw-config/configuration) via the Console, or a tiny one-off script. If it
is absent the default qualifier simply allows all entries — acceptable for test.
6. Point .firebaserc at the real project
Both aliases target the one project we actually have:
{ "projects": { "default": "secure-moment-188701", "prod": "secure-moment-188701", "dev": "secure-moment-188701" } }
(If a separate prod Firebase project is ever created, split these then.)
7. Deploy & verify
npx firebase deploy --only firestore:rules,firestore:indexes --project prod- Set the Netlify env vars (
VITE_LB_NAMESPACE=mw+VITE_FIREBASE_*). pnpm dev, win a game → confirm the write lands inmw-test-scores, and the prodmw-scoresis untouched (Firestore console).- Confirm reads on both
mw-scoresandmw-test-scoressucceed under the new rules, and a malformed write is rejected.
Not affected
- Legends — static, already exported from legacy
mw-leaders; no runtime DB. - Data migration — none. Prod keeps
mw-*; test starts fresh inmw-test-*. - The
@cozy-games/leaderboardpackage — already namespace-parameterized; only the app's wiring changes.
Rollback
Set VITE_LB_NAMESPACE=mw everywhere (or revert app/main.js to
the hardcoded 'mw') to return to shared collections. The generalized rules are
a superset of the old ones, so they stay valid either way.