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>
This commit is contained in:
parent
28b90b1dab
commit
9e1114be78
36 changed files with 2476 additions and 209 deletions
7
.firebaserc
Normal file
7
.firebaserc
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"projects": {
|
||||
"default": "secure-moment-188701",
|
||||
"prod": "secure-moment-188701",
|
||||
"dev": "secure-moment-188701"
|
||||
}
|
||||
}
|
||||
18
.gitignore
vendored
18
.gitignore
vendored
|
|
@ -6,5 +6,19 @@ lib/README.md
|
|||
|
||||
.claude
|
||||
|
||||
*.*~
|
||||
*.*swp
|
||||
# Production / local Firebase config values are not committed — set them as
|
||||
# Netlify env vars, or keep them in a local, gitignored env file. Dev values
|
||||
# live in the committed app/.env.development (public, non-secret keys).
|
||||
.env.production
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Firebase emulator artifacts
|
||||
firebase-debug.log
|
||||
firestore-debug.log
|
||||
ui-debug.log
|
||||
.emulator-data/
|
||||
|
||||
*~
|
||||
*swo
|
||||
*swp
|
||||
|
|
|
|||
92
AYO.md
Normal file
92
AYO.md
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
# 🧹 AYO — Leaderboard Migration Checklist
|
||||
|
||||
Manual steps to finish the leaderboard rollout. **All code changes are already
|
||||
applied** in the working tree — these are the external actions on Firebase and
|
||||
Netlify that have to be done by hand.
|
||||
|
||||
> One project (`secure-moment-188701`), one difference between environments: the
|
||||
> **collection namespace**. Production uses `mw-*`, dev/test uses `mw-test-*`.
|
||||
> Full rationale: [`docs/leaderboard-env-migration.md`](docs/leaderboard-env-migration.md).
|
||||
|
||||
---
|
||||
|
||||
## ✅ Step 1 — Deploy Firestore rules + indexes
|
||||
|
||||
From the repo root:
|
||||
|
||||
```bash
|
||||
npx firebase login
|
||||
npx firebase deploy --only firestore:rules,firestore:indexes --project prod
|
||||
```
|
||||
|
||||
- Uses committed [`firestore.rules`](firestore.rules) + [`firestore.indexes.json`](firestore.indexes.json).
|
||||
- `prod` → `secure-moment-188701` (via [`.firebaserc`](.firebaserc)).
|
||||
- ⚠️ Deploying **replaces** the console rules. The committed rules cover every
|
||||
collection (`mw-*` and `mw-test-*`), so it's safe — but review first.
|
||||
- No composite indexes to build — rolling windows (`time_stamp >=`) and all-time
|
||||
(`orderBy score`) use Firestore's automatic single-field indexes.
|
||||
|
||||
## ✅ Step 2 — Set Netlify environment variables
|
||||
|
||||
In the Netlify site settings, add:
|
||||
|
||||
| Variable | Value |
|
||||
| --- | --- |
|
||||
| `VITE_FIREBASE_API_KEY` … (all 8) | **same as [`app/.env.development`](app/.env.development)** (same project) |
|
||||
| `VITE_LB_NAMESPACE` | **`mw`** ← makes production use the `mw-*` collections |
|
||||
|
||||
> Local dev already uses `mw-test` via the committed `.env.development` — nothing
|
||||
> to do there.
|
||||
|
||||
## ✅ Step 3 — (Optional) Seed the test config doc
|
||||
|
||||
Create `mw-test-config/configuration` in Firestore with the same `passingStatus`
|
||||
and `message` as the prod `mw-config/configuration`.
|
||||
|
||||
Skip it and the test board still works — the default qualifier just accepts all
|
||||
wins in test.
|
||||
|
||||
## ✅ Step 4 — Seed the dev database with sample scores
|
||||
|
||||
> ⚠️ **Must run _after_ Step 1.** The seed writes to `mw-test-scores`, which is
|
||||
> only allowed once the generalized rules are deployed — otherwise every write
|
||||
> returns `permission-denied`. (No indexes to wait on — the windows use
|
||||
> automatic single-field indexes.)
|
||||
|
||||
Populate the dev boards so they aren't empty while developing:
|
||||
|
||||
```bash
|
||||
(cd app && node ../scripts/seed-dev-scores.js)
|
||||
```
|
||||
|
||||
- Uses [`scripts/seed-dev-scores.js`](scripts/seed-dev-scores.js) — ~12 sample
|
||||
scores per level, timestamps spread across today / this week / this month /
|
||||
older so **all four tabs** populate.
|
||||
- Dev-only and idempotent-ish (re-running just adds more rows); it never touches
|
||||
the production `mw-*` collections.
|
||||
|
||||
> 💡 **Local dev uses the emulator by default — this cloud seed is optional.**
|
||||
> `pnpm dev` points at the local **Firestore emulator** (needs a JDK): run
|
||||
> `pnpm emulators` + `pnpm seed:emulator` and you're set — no deploy, no cloud.
|
||||
> The cloud seed above is only needed for a hosted/preview environment. To opt
|
||||
> out of the emulator, set `VITE_FIRESTORE_EMULATOR=` empty in `app/.env.local`.
|
||||
> See [`docs/firebase-leaderboards.md`](docs/firebase-leaderboards.md#local-firestore-emulator-default-for-local-dev).
|
||||
|
||||
## ✅ Step 5 — Verify
|
||||
|
||||
| Environment | How | Expected |
|
||||
| --- | --- | --- |
|
||||
| **Production** (`mw`) | Win a game on the live site | Score shows; doc in `mw-scores/{level}/games` |
|
||||
| **Local dev** (`mw-test`) | `pnpm dev` (after Step 4) | Board shows sample scores; winning adds to `mw-test-scores/{level}/games`; prod `mw-scores` untouched |
|
||||
| **Rules** | Read both boards | Reads succeed; a malformed write is rejected |
|
||||
|
||||
---
|
||||
|
||||
## 📌 Still open (not blocking)
|
||||
|
||||
- **Nothing is committed yet** — all changes are in the working tree.
|
||||
- **`scripts/export-legends.js`** still hard-codes the (dev = prod) Firebase keys
|
||||
from the one-off Legends export. It's identical to `app/.env.development`; can
|
||||
be de-duped to read from the env file on request.
|
||||
- **Legends** is already frozen into static HTML ([`app/legends.html`](app/legends.html))
|
||||
— no action needed.
|
||||
16
README.md
16
README.md
|
|
@ -64,6 +64,22 @@ pnpm build # build the website
|
|||
pnpm build:lib # build the publishable library
|
||||
```
|
||||
|
||||
### Leaderboard (local Firestore emulator)
|
||||
|
||||
The leader board is backed by [Google Firestore](https://firebase.google.com). For local development the app talks to the **Firestore emulator** by default — fully local, no cloud, no deploy. The flag `VITE_FIRESTORE_EMULATOR=1` is already set in `app/.env.development`.
|
||||
|
||||
You need a **JDK 21+** installed (the emulator runs on Java; `firebase-tools` itself is fetched on demand via `npx`). Then, in two terminals:
|
||||
|
||||
```bash
|
||||
pnpm emulators # terminal 1 — start the Firestore emulator on :8080 (+ UI)
|
||||
pnpm seed:emulator # terminal 2, once — fill it with sample scores
|
||||
pnpm dev # terminal 2 — run the app against the emulator
|
||||
```
|
||||
|
||||
If the emulator isn't running, the board simply shows *"unavailable"* (a refused connection). To skip the emulator — for quick UI-only work, or if you don't have a JDK — set `VITE_FIRESTORE_EMULATOR=` (empty) in a local, gitignored `app/.env.local`; the app then uses the cloud `mw-test` namespace instead.
|
||||
|
||||
See [`docs/firebase-leaderboards.md`](./docs/firebase-leaderboards.md) for the full data model, security rules, environments, and deployment.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! See [`AGENTS.md`](./AGENTS.md) for the architecture, conventions, and release workflow before opening a pull request.
|
||||
|
|
|
|||
23
app/.env.development
Normal file
23
app/.env.development
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# Development Firebase config (project: secure-moment-188701).
|
||||
# These keys are public, not secrets — access is governed by firestore.rules —
|
||||
# so this file is committed. Used by `pnpm dev`.
|
||||
# Production values are supplied as Netlify env vars at build time (see
|
||||
# docs/firebase-leaderboards.md); never put production values here.
|
||||
VITE_FIREBASE_API_KEY=AIzaSyCTi_5Sm5dHFNf0d_Gn0MNWmlGheFBf6MQ
|
||||
VITE_FIREBASE_AUTH_DOMAIN=moment-188701.firebaseapp.com
|
||||
VITE_FIREBASE_DATABASE_URL=https://moment-188701.firebaseio.com
|
||||
VITE_FIREBASE_PROJECT_ID=secure-moment-188701
|
||||
VITE_FIREBASE_STORAGE_BUCKET=secure-moment-188701.firebasestorage.app
|
||||
VITE_FIREBASE_MESSAGING_SENDER_ID=113827947104
|
||||
VITE_FIREBASE_APP_ID=1:113827947104:web:b176f746d8358302c51905
|
||||
VITE_FIREBASE_MEASUREMENT_ID=G-LZRDY0TG46
|
||||
|
||||
# Leaderboard collection namespace. Dev/test uses a separate set of collections
|
||||
# (mw-test-scores / mw-test-all / mw-test-config) in the SAME Firebase project,
|
||||
# so local play never pollutes the production board. Production sets this to `mw`.
|
||||
VITE_LB_NAMESPACE=mw-test
|
||||
|
||||
# Local dev runs against the Firestore emulator by default (no cloud, no deploy).
|
||||
# Start it with `pnpm emulators` and fill it via `pnpm seed:emulator`.
|
||||
# To use the cloud `mw-test` namespace instead, set this empty in app/.env.local.
|
||||
VITE_FIRESTORE_EMULATOR=1
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
# Firebase config template. Copy the keys you need into an env file:
|
||||
# - app/.env.development -> dev values (committed; used by `pnpm dev`)
|
||||
# - app/.env.production -> prod values (gitignored; optional, for local
|
||||
# prod builds and the Legends export). In hosting
|
||||
# (Netlify) set these as environment variables.
|
||||
# These Firebase keys are public (not secrets); access is governed by
|
||||
# firestore.rules.
|
||||
VITE_FIREBASE_API_KEY=
|
||||
VITE_FIREBASE_AUTH_DOMAIN=
|
||||
VITE_FIREBASE_DATABASE_URL=
|
||||
VITE_FIREBASE_PROJECT_ID=
|
||||
VITE_FIREBASE_STORAGE_BUCKET=
|
||||
VITE_FIREBASE_MESSAGING_SENDER_ID=
|
||||
VITE_FIREBASE_APP_ID=
|
||||
VITE_FIREBASE_MEASUREMENT_ID=
|
||||
|
||||
# Leaderboard collection namespace: `mw` in production, `mw-test` for dev/previews.
|
||||
VITE_LB_NAMESPACE=
|
||||
|
||||
# Set to 1 to point the app at a local Firestore emulator (dev only).
|
||||
VITE_FIRESTORE_EMULATOR=
|
||||
|
|
@ -31,9 +31,16 @@
|
|||
</head>
|
||||
<body>
|
||||
<div id="body-wrapper">
|
||||
<div id="greeting"></div>
|
||||
<div id="app">
|
||||
Please use Chrome or Firefox.
|
||||
</div>
|
||||
|
||||
<cozy-leaderboard category="beginner" title="Best Times" format="time" score-order="asc"></cozy-leaderboard>
|
||||
|
||||
<div id="legends-link">
|
||||
<a href="/legends.html">🏆 Legends</a>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="./main.js"></script>
|
||||
</body>
|
||||
|
|
|
|||
101
app/legends.html
Normal file
101
app/legends.html
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||
|
||||
<meta name="Description" content="Minesweeper Legends — Epic pre-version-one top scores." />
|
||||
<title>Minesweeper Legends 🏆</title>
|
||||
|
||||
<link rel="shortcut icon" type="image/png" href="/favicon.ico" />
|
||||
<link rel="stylesheet" href="./main.css" />
|
||||
<style>
|
||||
.legends section { max-width: 270px; margin: 0 auto 30px; }
|
||||
.legends h3 { border-bottom: 1px solid #c0c0c0; padding-bottom: 10px; }
|
||||
.legends ol { list-style: none; padding: 0; margin: 0; text-align: left; }
|
||||
.legends li { display: flex; }
|
||||
.legends .name {
|
||||
flex: 1;
|
||||
padding: 0 5px;
|
||||
font-weight: bold;
|
||||
font-style: italic;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Generated by scripts/export-legends.js — do not edit by hand. -->
|
||||
<div id="body-wrapper">
|
||||
<h1>🏆 Legends</h1>
|
||||
<p><em>Epic scores — the top times set before version 1.0.</em></p>
|
||||
<div class="legends">
|
||||
<section>
|
||||
<h3>Noobs</h3>
|
||||
<ol>
|
||||
<li><span class="rank">#1</span><span class="name" title="kaelynaverymumm">kaelynaverymumm</span><span class="time">01.9</span></li>
|
||||
<li><span class="rank">#2</span><span class="name" title="isji">isji</span><span class="time">02.0</span></li>
|
||||
<li><span class="rank">#3</span><span class="name" title="Anonymous">Anonymous</span><span class="time">02.9</span></li>
|
||||
<li><span class="rank">#4</span><span class="name" title="jenjereren">jenjereren</span><span class="time">03.7</span></li>
|
||||
<li><span class="rank">#5</span><span class="name" title="S-Davies">S-Davies</span><span class="time">03.8</span></li>
|
||||
<li><span class="rank">#6</span><span class="name" title="kaelynaverymumm">kaelynaverymumm</span><span class="time">04.0</span></li>
|
||||
<li><span class="rank">#7</span><span class="name" title="jenn">jenn</span><span class="time">04.1</span></li>
|
||||
<li><span class="rank">#8</span><span class="name" title="jenjereren">jenjereren</span><span class="time">04.1</span></li>
|
||||
<li><span class="rank">#9</span><span class="name" title="BIGTHICCDADDY!!">BIGTHICCDADDY!!</span><span class="time">04.4</span></li>
|
||||
<li><span class="rank">#10</span><span class="name" title="XZ123627703">XZ123627703</span><span class="time">04.5</span></li>
|
||||
</ol>
|
||||
</section>
|
||||
<section>
|
||||
<h3>Normies</h3>
|
||||
<ol>
|
||||
<li><span class="rank">#1</span><span class="name" title="jenjereren">jenjereren</span><span class="time">29.1</span></li>
|
||||
<li><span class="rank">#2</span><span class="name" title="jenjereren">jenjereren</span><span class="time">29.3</span></li>
|
||||
<li><span class="rank">#3</span><span class="name" title="jenjereren">jenjereren</span><span class="time">30.1</span></li>
|
||||
<li><span class="rank">#4</span><span class="name" title="jenjereren">jenjereren</span><span class="time">30.4</span></li>
|
||||
<li><span class="rank">#5</span><span class="name" title="jenjereren">jenjereren</span><span class="time">30.5</span></li>
|
||||
<li><span class="rank">#6</span><span class="name" title="jenjereren">jenjereren</span><span class="time">30.6</span></li>
|
||||
<li><span class="rank">#7</span><span class="name" title="jenjereren">jenjereren</span><span class="time">30.8</span></li>
|
||||
<li><span class="rank">#8</span><span class="name" title="jenjereren">jenjereren</span><span class="time">30.9</span></li>
|
||||
<li><span class="rank">#9</span><span class="name" title="jenjereren">jenjereren</span><span class="time">31.4</span></li>
|
||||
<li><span class="rank">#10</span><span class="name" title="jenjereren">jenjereren</span><span class="time">31.5</span></li>
|
||||
</ol>
|
||||
</section>
|
||||
<section>
|
||||
<h3>Torment</h3>
|
||||
<ol>
|
||||
<li><span class="rank">#1</span><span class="name" title="jenjereren">jenjereren</span><span class="time">01:34.6</span></li>
|
||||
<li><span class="rank">#2</span><span class="name" title="jenjereren">jenjereren</span><span class="time">01:36.6</span></li>
|
||||
<li><span class="rank">#3</span><span class="name" title="jenjereren">jenjereren</span><span class="time">01:38.5</span></li>
|
||||
<li><span class="rank">#4</span><span class="name" title="jenjereren">jenjereren</span><span class="time">01:38.9</span></li>
|
||||
<li><span class="rank">#5</span><span class="name" title="jenjereren">jenjereren</span><span class="time">01:39.3</span></li>
|
||||
<li><span class="rank">#6</span><span class="name" title="jenjereren">jenjereren</span><span class="time">01:39.4</span></li>
|
||||
<li><span class="rank">#7</span><span class="name" title="jenjereren">jenjereren</span><span class="time">01:39.7</span></li>
|
||||
<li><span class="rank">#8</span><span class="name" title="jenjereren">jenjereren</span><span class="time">01:45.1</span></li>
|
||||
<li><span class="rank">#9</span><span class="name" title="jenjereren">jenjereren</span><span class="time">01:45.8</span></li>
|
||||
<li><span class="rank">#10</span><span class="name" title="jenjereren">jenjereren</span><span class="time">01:46.1</span></li>
|
||||
</ol>
|
||||
</section>
|
||||
<section>
|
||||
<h3>Hell</h3>
|
||||
<ol>
|
||||
<li><span class="rank">#1</span><span class="name" title="wasd">wasd</span><span class="time">01:13.6</span></li>
|
||||
<li><span class="rank">#2</span><span class="name" title="wasd">wasd</span><span class="time">02:40.0</span></li>
|
||||
<li><span class="rank">#3</span><span class="name" title="jenjereren">jenjereren</span><span class="time">02:57.3</span></li>
|
||||
<li><span class="rank">#4</span><span class="name" title="jenjereren">jenjereren</span><span class="time">02:57.7</span></li>
|
||||
<li><span class="rank">#5</span><span class="name" title="jenjereren">jenjereren</span><span class="time">03:02.8</span></li>
|
||||
<li><span class="rank">#6</span><span class="name" title="jenjereren">jenjereren</span><span class="time">03:06.0</span></li>
|
||||
<li><span class="rank">#7</span><span class="name" title="jenjereren">jenjereren</span><span class="time">03:06.4</span></li>
|
||||
<li><span class="rank">#8</span><span class="name" title="jenjereren">jenjereren</span><span class="time">03:15.8</span></li>
|
||||
<li><span class="rank">#9</span><span class="name" title="jenjereren">jenjereren</span><span class="time">03:16.4</span></li>
|
||||
<li><span class="rank">#10</span><span class="name" title="jenjereren">jenjereren</span><span class="time">03:19.5</span></li>
|
||||
</ol>
|
||||
</section>
|
||||
</div>
|
||||
<div id="legends-link">
|
||||
<a href="/">← Back to the game</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
20
app/main.css
20
app/main.css
|
|
@ -23,3 +23,23 @@ em {
|
|||
#body-wrapper {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
#greeting {
|
||||
margin: 10px auto;
|
||||
font-size: 0.95em;
|
||||
color: #DDDDDD;
|
||||
}
|
||||
|
||||
#legends-link {
|
||||
margin: 20px auto;
|
||||
}
|
||||
|
||||
#legends-link a {
|
||||
color: #DDDDDD;
|
||||
text-decoration-color: orange;
|
||||
transition: 500ms ease-in-out;
|
||||
}
|
||||
|
||||
#legends-link a:hover {
|
||||
text-decoration-thickness: 2px;
|
||||
}
|
||||
|
|
|
|||
88
app/main.js
88
app/main.js
|
|
@ -1,45 +1,73 @@
|
|||
import mnswpr from '@ayo-run/mnswpr/mnswpr.js'
|
||||
import '@ayo-run/mnswpr/mnswpr.css'
|
||||
import * as pkg from '@ayo-run/mnswpr/package.json'
|
||||
import { LoadingService } from '../utils/'
|
||||
import { LeaderBoardService } from './modules/leader-board/leader-board.js'
|
||||
import { configureLeaderboard } from '@cozy-games/leaderboard/leaderboard-element.js'
|
||||
import { FirebaseAdapter } from '@cozy-games/leaderboard/adapters/firebase.js'
|
||||
import { NicknameService } from './modules/nickname/nickname.js'
|
||||
import { UserService } from './modules/user/user.js'
|
||||
|
||||
const leaderBoardService = new LeaderBoardService()
|
||||
const loadingService = new LoadingService()
|
||||
// Firebase config comes from Vite env vars so dev and production point at
|
||||
// different databases: dev values live in app/.env.development (committed —
|
||||
// these keys are public, not secrets, and access is governed by
|
||||
// firestore.rules); production values are supplied as Netlify env vars.
|
||||
// https://stackoverflow.com/questions/37482366/is-it-safe-to-expose-firebase-apikey-to-the-public/37484053#37484053
|
||||
const firebaseConfig = {
|
||||
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
|
||||
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
|
||||
databaseURL: import.meta.env.VITE_FIREBASE_DATABASE_URL,
|
||||
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
|
||||
storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
|
||||
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
|
||||
appId: import.meta.env.VITE_FIREBASE_APP_ID,
|
||||
measurementId: import.meta.env.VITE_FIREBASE_MEASUREMENT_ID
|
||||
}
|
||||
|
||||
const user = new UserService()
|
||||
const nickname = new NicknameService()
|
||||
|
||||
// The leaderboard UI is composed declaratively as <cozy-leaderboard> in
|
||||
// index.html; here we only wire its backend once. Namespace selects prod (`mw`)
|
||||
// vs test (`mw-test`) collections in the same Firebase project, defaulting to
|
||||
// test so a missing env var can never write into the production board.
|
||||
// Local dev runs against the Firestore emulator by default (VITE_FIRESTORE_EMULATOR
|
||||
// in app/.env.development); production builds always use real Firestore.
|
||||
const useEmulator = ['1', 'true', 'yes'].includes(String(import.meta.env.VITE_FIRESTORE_EMULATOR))
|
||||
configureLeaderboard({
|
||||
adapter: new FirebaseAdapter({
|
||||
firebaseConfig,
|
||||
namespace: import.meta.env.VITE_LB_NAMESPACE || 'mw-test',
|
||||
emulator: useEmulator ? { host: '127.0.0.1', port: 8080 } : undefined
|
||||
})
|
||||
})
|
||||
|
||||
const version = import.meta.env.MODE === 'development'
|
||||
? 'dev'
|
||||
: pkg.version
|
||||
|
||||
const initializeGameBoard = async (level) => {
|
||||
const prevousLeaderBoard = document.getElementById('leaderboard')
|
||||
const prevousLoadingWrapper = document.getElementById('loading-wrapper')
|
||||
prevousLoadingWrapper?.remove()
|
||||
const loadingWrapper = document.createElement('div')
|
||||
loadingWrapper.id = 'loading-wrapper'
|
||||
loadingService.addLoading(loadingWrapper)
|
||||
// Ask for a nickname on first visit and show the greeting bar.
|
||||
nickname.ensure()
|
||||
const greeting = document.getElementById('greeting')
|
||||
if (greeting) nickname.render(greeting)
|
||||
|
||||
const appElement = document.getElementById('app')
|
||||
|
||||
if (prevousLeaderBoard){
|
||||
const parent = prevousLeaderBoard.parentNode
|
||||
parent.replaceChild(loadingWrapper, prevousLeaderBoard)
|
||||
}else{
|
||||
appElement.append(loadingWrapper)
|
||||
}
|
||||
|
||||
const leaderBoardWrapper = await leaderBoardService.update(level.id, `Best Times (${level.name})`)
|
||||
leaderBoardWrapper.id = 'leaderboard'
|
||||
appElement.replaceChild(leaderBoardWrapper, loadingWrapper)
|
||||
}
|
||||
|
||||
const sendGameResult = (game) => {
|
||||
leaderBoardService.send(game, 'time')
|
||||
}
|
||||
const board = document.querySelector('cozy-leaderboard')
|
||||
|
||||
const game = new mnswpr('app', version, {
|
||||
levelChanged: (level) => initializeGameBoard(level),
|
||||
gameDone: (game) => sendGameResult(game)
|
||||
// Point the board at the current level; the element re-renders reactively and
|
||||
// keeps the selected duration tab.
|
||||
levelChanged: (level) => {
|
||||
board.setAttribute('title', `Best Times (${level.name})`)
|
||||
board.setAttribute('category', level.id)
|
||||
},
|
||||
// Submit the finished game through the element's service.
|
||||
gameDone: (game) => board.submit({
|
||||
name: nickname.get(),
|
||||
playerId: user.browserId,
|
||||
score: game.time,
|
||||
category: game.level,
|
||||
time_stamp: game.time_stamp,
|
||||
status: game.status,
|
||||
meta: { isMobile: game.isMobile }
|
||||
})
|
||||
})
|
||||
|
||||
game.initialize()
|
||||
|
|
|
|||
|
|
@ -1,176 +0,0 @@
|
|||
import { TimerService } from '../../../utils/timer/timer'
|
||||
import { LoggerService } from '../../../utils/logger/logger'
|
||||
import { UserService } from '../user/user'
|
||||
|
||||
import { initializeApp } from 'firebase/app'
|
||||
import {
|
||||
getFirestore, doc, getDocs, getDoc, setDoc, collection, query, orderBy, limit
|
||||
} from 'firebase/firestore/lite'
|
||||
|
||||
|
||||
export class LeaderBoardService {
|
||||
|
||||
timerService = new TimerService()
|
||||
loggerService = new LoggerService()
|
||||
user = new UserService()
|
||||
|
||||
/**
|
||||
*
|
||||
* Create the Leader Board service
|
||||
* @param {String} leaders
|
||||
* @param {String} all
|
||||
* @param {String} configuration
|
||||
*/
|
||||
constructor() {
|
||||
|
||||
// necessary keys to interact with firebase
|
||||
// not a secret
|
||||
// https://stackoverflow.com/questions/37482366/is-it-safe-to-expose-firebase-apikey-to-the-public/37484053#37484053
|
||||
// For Firebase JS SDK v7.20.0 and later, measurementId is optional
|
||||
const config = {
|
||||
apiKey: 'AIzaSyCTi_5Sm5dHFNf0d_Gn0MNWmlGheFBf6MQ',
|
||||
authDomain: 'moment-188701.firebaseapp.com',
|
||||
databaseURL: 'https://moment-188701.firebaseio.com',
|
||||
projectId: 'secure-moment-188701',
|
||||
storageBucket: 'secure-moment-188701.firebasestorage.app',
|
||||
messagingSenderId: '113827947104',
|
||||
appId: '1:113827947104:web:b176f746d8358302c51905',
|
||||
measurementId: 'G-LZRDY0TG46'
|
||||
}
|
||||
const app = initializeApp(config)
|
||||
this._store = getFirestore(app)
|
||||
|
||||
const configRef = doc(this.store, 'mw-config', 'configuration')
|
||||
getDoc(configRef)
|
||||
.then(res => {
|
||||
this.configuration = res.data()
|
||||
})
|
||||
}
|
||||
|
||||
get store() {
|
||||
return this._store
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the leader board
|
||||
* @param {String} level - the id of the game level
|
||||
* @param {String} title - the displayed name of the game level
|
||||
* @returns {HTMLDivElement} - element with the rendered leader board
|
||||
*/
|
||||
async update(level, title) {
|
||||
const displayElement = document.createElement('div')
|
||||
|
||||
this.lastPlace = Number.MAX_SAFE_INTEGER
|
||||
|
||||
const q = query(
|
||||
collection(this.store, 'mw-leaders', level, 'games'),
|
||||
orderBy('time'),
|
||||
limit(10)
|
||||
)
|
||||
this.topListSnapshot = await getDocs(q)
|
||||
this.renderList(displayElement, title, this.topListSnapshot.docs)
|
||||
|
||||
return displayElement
|
||||
}
|
||||
|
||||
renderList(displayElement, title, docs) {
|
||||
if (!displayElement) return
|
||||
|
||||
displayElement.innerHTML = ''
|
||||
const leaderHeading = document.createElement('h3')
|
||||
leaderHeading.innerText = title
|
||||
leaderHeading.style.borderBottom = '1px solid #c0c0c0'
|
||||
leaderHeading.style.paddingBottom = '10px'
|
||||
|
||||
|
||||
displayElement.style.maxWidth = '270px'
|
||||
displayElement.style.margin = '0 auto'
|
||||
|
||||
const leaderList = document.createElement('div')
|
||||
|
||||
leaderList.innerHTML = ''
|
||||
leaderList.style.listStyle = 'none'
|
||||
leaderList.style.textAlign = 'left'
|
||||
leaderList.style.marginTop = '-15px'
|
||||
|
||||
if (docs && docs.length) {
|
||||
let i = 1
|
||||
docs.forEach(game => {
|
||||
if (game) {
|
||||
const prettyTime = this.timerService.pretty(game.data().time)
|
||||
const name = game.data().name || 'Anonymous'
|
||||
const item = document.createElement('div')
|
||||
item.style.display = 'flex'
|
||||
const nameElement =document.createElement('div')
|
||||
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.cursor = 'pointer'
|
||||
nameElement.style.fontWeight = 'bold'
|
||||
nameElement.style.fontStyle = 'italic'
|
||||
// nameElement.onmousedown = () => console.log(game.data());
|
||||
|
||||
const indexElement = document.createElement('div')
|
||||
indexElement.innerText = `#${i++}`
|
||||
|
||||
const timeElement = document.createElement('div')
|
||||
timeElement.innerText = prettyTime
|
||||
|
||||
item.append(indexElement, nameElement, timeElement)
|
||||
leaderList.append(item)
|
||||
}
|
||||
})
|
||||
if (docs.length >= 10) {
|
||||
this.lastPlace = docs[9].data().time
|
||||
}
|
||||
|
||||
displayElement.append(leaderHeading, leaderList)
|
||||
} else {
|
||||
const message = document.createElement('em')
|
||||
message.innerText = 'Be the first to the top!'
|
||||
displayElement.append(leaderHeading, message)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async send(game, key) {
|
||||
const sessionId = new Date().toDateString().replace(/\s/g, '_')
|
||||
const gameId = new Date().toTimeString().replace(/\s/g, '_')
|
||||
const data = { }
|
||||
data[gameId] = game
|
||||
|
||||
const sessionRef = doc(this.store, 'mw-all', this.user.browserId, 'games', sessionId)
|
||||
await setDoc(sessionRef, data, { merge: true })
|
||||
|
||||
const winningCondigion = (
|
||||
this.configuration
|
||||
&& game.status === this.configuration.passingStatus
|
||||
&& game[key] < this.lastPlace
|
||||
)
|
||||
|
||||
if (winningCondigion) {
|
||||
let name = window.prompt(this.configuration.message)
|
||||
if (!name) {
|
||||
name = 'Anonymous'
|
||||
}
|
||||
|
||||
const newGame = {
|
||||
name,
|
||||
browserId: this.user.browserId,
|
||||
...game
|
||||
}
|
||||
|
||||
const gameScoreRef = doc(collection(this.store, 'mw-leaders', game.level, 'games'))
|
||||
await setDoc(gameScoreRef, newGame)
|
||||
}
|
||||
}
|
||||
|
||||
configurationPromt() {
|
||||
if (!this.configuration) {
|
||||
this.loggerService.debug('Failed to fetch server configuration. Please contact your developer.')
|
||||
}
|
||||
}
|
||||
}
|
||||
100
app/modules/nickname/nickname.js
Normal file
100
app/modules/nickname/nickname.js
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { StorageService } from '../../../utils/index.js'
|
||||
|
||||
const NICK_KEY = 'nickname'
|
||||
const MAX_LENGTH = 24
|
||||
|
||||
/**
|
||||
* A few friendly greetings; `{nick}` is replaced with the player's nickname.
|
||||
*/
|
||||
const GREETINGS = [
|
||||
'Welcome back, {nick}!',
|
||||
'Good to see you, {nick}!',
|
||||
'Hey {nick}, ready to sweep?',
|
||||
'Let\'s go, {nick}!',
|
||||
'Happy sweeping, {nick}!',
|
||||
'Nice to have you, {nick}!'
|
||||
]
|
||||
|
||||
/**
|
||||
* Owns the player's display nickname: prompts for it once on first visit,
|
||||
* persists it in localStorage, and renders a friendly greeting bar. The
|
||||
* leaderboard package stays name-agnostic — the app passes this nickname in.
|
||||
*/
|
||||
export class NicknameService {
|
||||
|
||||
constructor() {
|
||||
this.storage = new StorageService()
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored nickname, or 'Anonymous' if none has been set yet.
|
||||
* @returns {String}
|
||||
*/
|
||||
get() {
|
||||
return this.storage.getFromLocal(NICK_KEY) || 'Anonymous'
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt for a nickname the first time only — used on page load.
|
||||
*/
|
||||
ensure() {
|
||||
if (!this.storage.getFromLocal(NICK_KEY)) {
|
||||
this.prompt()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask for a nickname and persist it (trimmed, capped). Cancelling keeps the
|
||||
* current value. A failing/unsupported prompt must never break page load, so
|
||||
* it is guarded — the player just stays 'Anonymous'.
|
||||
*/
|
||||
prompt() {
|
||||
let next
|
||||
try {
|
||||
next = window.prompt('Pick a nickname for the leaderboard', this.storage.getFromLocal(NICK_KEY) || '')
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (next && next.trim()) {
|
||||
this.storage.saveToLocal(NICK_KEY, next.trim().slice(0, MAX_LENGTH))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the greeting bar into a container. Clicking the nickname (or the
|
||||
* pencil) re-prompts and re-renders in place.
|
||||
* @param {HTMLElement} container
|
||||
*/
|
||||
render(container) {
|
||||
if (!container) return
|
||||
container.innerHTML = ''
|
||||
|
||||
const template = GREETINGS[Math.floor(Math.random() * GREETINGS.length)]
|
||||
const [before, after] = template.split('{nick}')
|
||||
|
||||
const nameElement = document.createElement('span')
|
||||
nameElement.innerText = this.get()
|
||||
nameElement.style.fontWeight = 'bold'
|
||||
nameElement.style.fontStyle = 'italic'
|
||||
nameElement.style.cursor = 'pointer'
|
||||
nameElement.style.textDecoration = 'underline'
|
||||
nameElement.style.textDecorationColor = 'orange'
|
||||
nameElement.setAttribute('title', 'Click to change your nickname')
|
||||
|
||||
const icon = document.createElement('span')
|
||||
icon.innerText = ' ✏️'
|
||||
icon.style.cursor = 'pointer'
|
||||
icon.style.fontSize = '0.8em'
|
||||
icon.style.opacity = '0.6'
|
||||
icon.setAttribute('title', 'Click to change your nickname')
|
||||
|
||||
const change = () => {
|
||||
this.prompt()
|
||||
this.render(container)
|
||||
}
|
||||
nameElement.onclick = change
|
||||
icon.onclick = change
|
||||
|
||||
container.append(document.createTextNode(before), nameElement, icon, document.createTextNode(after))
|
||||
}
|
||||
}
|
||||
|
|
@ -11,7 +11,9 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@ayo-run/mnswpr": "workspace:*",
|
||||
"firebase": "^12.11.0"
|
||||
"@cozy-games/leaderboard": "workspace:*",
|
||||
"firebase": "^12.11.0",
|
||||
"web-component-base": "^4.1.2"
|
||||
},
|
||||
"author": "Ayo Ayco"
|
||||
}
|
||||
|
|
|
|||
14
app/vite.config.js
Normal file
14
app/vite.config.js
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { resolve } from 'node:path'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
// Multi-page build: the game (index.html) and the frozen Legends page.
|
||||
export default defineConfig({
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: resolve(import.meta.dirname, 'index.html'),
|
||||
legends: resolve(import.meta.dirname, 'legends.html')
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
216
docs/firebase-leaderboards.md
Normal file
216
docs/firebase-leaderboards.md
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
# Firebase & Leaderboards
|
||||
|
||||
How the mnswpr.com leaderboards are stored, how to deploy the schema, and what
|
||||
still has to be touched in the Firebase Console.
|
||||
|
||||
## Overview
|
||||
|
||||
Leaderboards are powered by Firestore (`firebase/firestore/lite`) through the
|
||||
reusable, game-agnostic [`@cozy-games/leaderboard`](../leaderboard/leader-board.js)
|
||||
package. The app wires minesweeper's specifics (finish-time as the `score`,
|
||||
ascending sort, time formatting) in [`app/main.js`](../app/main.js).
|
||||
|
||||
The board offers four time windows — **Today** (default), **Week**, **Month**,
|
||||
**All Time** — selected by tabs. Each played game that qualifies is written to a
|
||||
new, queryable collection; the four tabs are just different filters over it.
|
||||
|
||||
### Data model
|
||||
|
||||
Collections (all under namespace `mw`):
|
||||
|
||||
| Path | Purpose | Access |
|
||||
| --- | --- | --- |
|
||||
| `mw-scores/{level}/games/{id}` | Ranked entries for the live boards | public read, validated create-only |
|
||||
| `mw-all/{browserId}/games/{session}` | Per-browser archive of every game | read/write |
|
||||
| `mw-config/configuration` | Server config (`passingStatus`, `message`) | public read, no client write |
|
||||
| `mw-leaders/{level}/games/{id}` | **Legacy** all-time board, now frozen into Legends | read-only |
|
||||
|
||||
Each `mw-scores` entry:
|
||||
|
||||
```js
|
||||
{
|
||||
name, // display nickname
|
||||
playerId, // browser fingerprint (UserService)
|
||||
score, // ranked value — minesweeper: finish time in ms
|
||||
category, // segmentation — minesweeper: level id
|
||||
time_stamp, // when the game finished — drives the rolling time windows
|
||||
day, // "2026-07-03" (UTC calendar day) — retained, not queried
|
||||
week, // "2026-W27" (ISO week) — retained, not queried
|
||||
month, // "2026-07" (UTC calendar month) — retained, not queried
|
||||
meta // optional extras — minesweeper: { isMobile }
|
||||
}
|
||||
```
|
||||
|
||||
### How the time windows work
|
||||
|
||||
The windows are **rolling**, based on `time_stamp`, and strictly nested:
|
||||
|
||||
- Today → `where('time_stamp', '>=', now - 24h)` — last 24 hours
|
||||
- Week → `where('time_stamp', '>=', now - 7d)` — last 7 days
|
||||
- Month → `where('time_stamp', '>=', now - 30d)` — last 30 days
|
||||
- All Time → no time filter, `orderBy('score')` + `limit(10)`
|
||||
|
||||
Because Firestore requires the inequality field (`time_stamp`) to sort first, the
|
||||
adapter fetches the in-window rows and ranks them by `score` client-side (Supabase
|
||||
does it server-side). A score in the 7-day window is always in the 30-day window,
|
||||
so a player can be top-10 for the week but rank out of a busier month — that's
|
||||
competition, not a boundary quirk.
|
||||
|
||||
> The `day`/`week`/`month` bucket keys are still written to each entry (and
|
||||
> validated by the rules) but are **no longer used for querying** — they're kept
|
||||
> as denormalized metadata and for a possible calendar-bucket mode.
|
||||
|
||||
## Schema as code
|
||||
|
||||
The Firestore schema (security rules + indexes) lives in the repo:
|
||||
|
||||
- [`firebase.json`](../firebase.json) — points at the rules and indexes files.
|
||||
- [`.firebaserc`](../.firebaserc) — project aliases: `prod` (default, live site)
|
||||
and `dev` (`secure-moment-188701`).
|
||||
- [`firestore.rules`](../firestore.rules) — access + validation rules.
|
||||
- [`firestore.indexes.json`](../firestore.indexes.json) — **empty**: rolling
|
||||
windows (`time_stamp >=`) and all-time (`orderBy('score')`) use Firestore's
|
||||
automatic single-field indexes, so no composite indexes are needed.
|
||||
|
||||
## Environments (dev vs production)
|
||||
|
||||
There is **one Firebase project** (`secure-moment-188701`). Production and test
|
||||
data are separated not by project but by **collection namespace**, chosen with
|
||||
the `VITE_LB_NAMESPACE` env var read in [`app/main.js`](../app/main.js):
|
||||
|
||||
| Environment | `VITE_LB_NAMESPACE` | Collections |
|
||||
| --- | --- | --- |
|
||||
| Production (Netlify) | `mw` | `mw-scores`, `mw-all`, `mw-config` |
|
||||
| Local `pnpm 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 difference is the namespace. `app/main.js` defaults to the
|
||||
**test** namespace, so a missing/misconfigured var can never write into the
|
||||
production board; production must set `VITE_LB_NAMESPACE=mw` explicitly.
|
||||
|
||||
Dev config lives in the committed [`app/.env.development`](../app/.env.development)
|
||||
(the keys are public). Production sets `VITE_FIREBASE_*` **and**
|
||||
`VITE_LB_NAMESPACE=mw` as Netlify environment variables. `.env.production` and
|
||||
`.env*.local` stay gitignored.
|
||||
|
||||
> The full rationale and rollout steps are in
|
||||
> [leaderboard-env-migration.md](leaderboard-env-migration.md).
|
||||
|
||||
### One-time CLI setup
|
||||
|
||||
The Firebase project already exists — no need to create it.
|
||||
|
||||
```bash
|
||||
npx firebase login
|
||||
```
|
||||
|
||||
### Deploy rules + indexes
|
||||
|
||||
Deploys go to **production** by default. Target a project explicitly with
|
||||
`--project`:
|
||||
|
||||
```bash
|
||||
# production (default alias 'prod')
|
||||
npx firebase deploy --only firestore:rules,firestore:indexes --project prod
|
||||
|
||||
# development database
|
||||
npx firebase deploy --only firestore:rules,firestore:indexes --project dev
|
||||
```
|
||||
|
||||
> ⚠️ Deploying **replaces** whatever rules currently live in the Console. The
|
||||
> committed [`firestore.rules`](../firestore.rules) is written to cover every
|
||||
> collection the app uses, so a deploy is safe — but review it first.
|
||||
|
||||
No composite indexes are required — the rolling-window and all-time queries use
|
||||
Firestore's automatic single-field indexes.
|
||||
|
||||
## Local Firestore emulator (default for local dev)
|
||||
|
||||
Local development runs against the **Firebase Local Emulator Suite** by default —
|
||||
no cloud, no deploy, no auth, no `permission-denied` — and it loads the committed
|
||||
[`firestore.rules`](../firestore.rules) and [`firestore.indexes.json`](../firestore.indexes.json)
|
||||
locally (so you validate them before deploying). The flag
|
||||
`VITE_FIRESTORE_EMULATOR=1` is set in [`app/.env.development`](../app/.env.development).
|
||||
|
||||
> Prerequisite: the Firestore emulator is a Java process, so you need a JDK
|
||||
> (11+) installed. `firebase-tools` is fetched on demand via `npx`.
|
||||
|
||||
Everyday dev loop:
|
||||
|
||||
```bash
|
||||
pnpm emulators # terminal 1: Firestore emulator (+ Emulator UI) on :8080
|
||||
pnpm seed:emulator # terminal 2, once: fill it with sample scores
|
||||
pnpm dev # terminal 2: app runs against the local emulator
|
||||
```
|
||||
|
||||
Wiring: `app/main.js` passes `{ emulator: { host, port } }` to `FirebaseAdapter`,
|
||||
which calls `connectFirestoreEmulator`. If the emulator isn't running the board
|
||||
just shows "unavailable" (a refused connection) — start it, or opt out.
|
||||
|
||||
**Opting out** (no Java, or you want the real cloud `mw-test`): create
|
||||
`app/.env.local` with `VITE_FIRESTORE_EMULATOR=` (empty) — `.env.local` overrides
|
||||
`.env.development` and is gitignored.
|
||||
|
||||
The emulator is disposable — its data is gone on stop unless you use Firebase's
|
||||
`--import`/`--export-on-exit`.
|
||||
|
||||
## Console fallback
|
||||
|
||||
Everything above is doable via the CLI. If you can't use it:
|
||||
|
||||
- **Indexes**: nothing to do — the queries use automatic single-field indexes.
|
||||
(If you ever add a query that needs a composite index, Firestore prints a
|
||||
console error with a direct link to create it.)
|
||||
- **Rules**: edit them directly under **Firestore → Rules** in the Console
|
||||
(paste from [`firestore.rules`](../firestore.rules)).
|
||||
- **Server config**: `mw-config/configuration` (`passingStatus`, `message`) is
|
||||
**always** managed by hand in the Console — it is read-only to clients and has
|
||||
no code representation. `passingStatus` is the `status` value that makes a game
|
||||
eligible for the board (minesweeper: `"win"`).
|
||||
|
||||
## Legends (frozen hall of fame)
|
||||
|
||||
The old all-time leaders are preserved as a **fully-rendered static page** —
|
||||
[`app/legends.html`](../app/legends.html). The records are baked straight into the
|
||||
HTML with times pre-formatted; there is **no JavaScript and no Firebase** at page
|
||||
load. The data never changes.
|
||||
|
||||
The page is generated by [`scripts/export-legends.js`](../scripts/export-legends.js),
|
||||
which snapshots `mw-leaders/{level}/games` and writes the whole `legends.html`.
|
||||
It only needs re-running if you ever want to regenerate. Because `firebase` is an
|
||||
app-workspace dependency, run it so Node can resolve it:
|
||||
|
||||
```bash
|
||||
(cd app && node ../scripts/export-legends.js)
|
||||
```
|
||||
|
||||
The Firebase config for this one-off is hardcoded in the script (public keys).
|
||||
|
||||
## Reusing the leaderboard for another game
|
||||
|
||||
`@cozy-games/leaderboard` knows nothing about minesweeper **or** Firebase — the
|
||||
ranked value is a generic `score` and all storage goes through an injected
|
||||
adapter. Another game supplies its own adapter and config:
|
||||
|
||||
```js
|
||||
import { LeaderBoardService } from '@cozy-games/leaderboard/leader-board.js'
|
||||
import { FirebaseAdapter } from '@cozy-games/leaderboard/adapters/firebase.js'
|
||||
|
||||
new LeaderBoardService({
|
||||
adapter: new FirebaseAdapter({ firebaseConfig, namespace: 'yourgame' }),
|
||||
scoreOrder: 'desc', // 'desc' when higher is better (points); 'asc' for time
|
||||
formatScore: v => `${v} pts`,
|
||||
qualifies: entry => true // default: server passingStatus vs entry.status
|
||||
})
|
||||
```
|
||||
|
||||
To run on **Supabase** instead, swap in `SupabaseAdapter` — nothing else in the
|
||||
game changes. The adapter interface and the Supabase table/SQL schema are
|
||||
documented in [`leaderboard/README.md`](../leaderboard/README.md).
|
||||
|
||||
Then submit `{ name, playerId, score, category, time_stamp, status?, meta? }`
|
||||
and render with `render(category, title, duration)`.
|
||||
|
||||
**No composite indexes needed.** Rolling windows filter on `time_stamp` and
|
||||
all-time sorts by `score` — both use Firestore's automatic single-field indexes,
|
||||
in either sort direction, so a `desc`-scored game needs no extra index work.
|
||||
160
docs/leaderboard-env-migration.md
Normal file
160
docs/leaderboard-env-migration.md
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
# 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](../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](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](../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](../app/.env.development): add `VITE_LB_NAMESPACE=mw-test`
|
||||
- [app/.env.example](../app/.env.example): document `VITE_LB_NAMESPACE`
|
||||
- Netlify (production build): set `VITE_LB_NAMESPACE=mw` **and** the same
|
||||
`VITE_FIREBASE_*` values as dev (same project).
|
||||
|
||||
### 2. Read it in the app
|
||||
|
||||
In [app/main.js](../app/main.js), replace the hardcoded namespace. Default to the
|
||||
**test** namespace so a missing/misconfigured var can never write to production:
|
||||
|
||||
```js
|
||||
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](../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](../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:
|
||||
|
||||
```json
|
||||
{ "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
|
||||
|
||||
1. `npx firebase deploy --only firestore:rules,firestore:indexes --project prod`
|
||||
2. Set the Netlify env vars (`VITE_LB_NAMESPACE=mw` + `VITE_FIREBASE_*`).
|
||||
3. `pnpm dev`, win a game → confirm the write lands in **`mw-test-scores`**, and
|
||||
the prod `mw-scores` is untouched (Firestore console).
|
||||
4. Confirm reads on both `mw-scores` and `mw-test-scores` succeed 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 in `mw-test-*`.
|
||||
- **The `@cozy-games/leaderboard` package** — already namespace-parameterized;
|
||||
only the app's wiring changes.
|
||||
|
||||
## Rollback
|
||||
|
||||
Set `VITE_LB_NAMESPACE=mw` everywhere (or revert [app/main.js](../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.
|
||||
|
|
@ -49,5 +49,11 @@ export default defineConfig([
|
|||
}]
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['scripts/**/*.js'],
|
||||
languageOptions: {
|
||||
globals: globals.node
|
||||
}
|
||||
},
|
||||
globalIgnores(['**/dist'])
|
||||
])
|
||||
|
|
|
|||
15
firebase.json
Normal file
15
firebase.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"firestore": {
|
||||
"rules": "firestore.rules",
|
||||
"indexes": "firestore.indexes.json"
|
||||
},
|
||||
"emulators": {
|
||||
"firestore": {
|
||||
"port": 8080
|
||||
},
|
||||
"ui": {
|
||||
"enabled": true
|
||||
},
|
||||
"singleProjectMode": true
|
||||
}
|
||||
}
|
||||
5
firestore.indexes.json
Normal file
5
firestore.indexes.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"comment": "Time windows are rolling (time_stamp >= cutoff) and all-time sorts by score — both use Firestore's automatic single-field indexes, so no composite indexes are required.",
|
||||
"indexes": [],
|
||||
"fieldOverrides": []
|
||||
}
|
||||
79
firestore.rules
Normal file
79
firestore.rules
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
rules_version = '2'
|
||||
|
||||
// Security rules for the mnswpr leaderboard. Firestore is schemaless, so these
|
||||
// rules are the enforced schema: public reads for the boards, validated
|
||||
// create-only writes for scores, no client writes to server config.
|
||||
//
|
||||
// SECURITY MODEL: the Firebase web config is public (it ships to every browser),
|
||||
// so these rules — not secrecy of the config or the codebase — are what protect
|
||||
// the data. No collection allows a client to UPDATE or DELETE existing docs, so
|
||||
// no client (with or without the source) can wipe the production boards. The
|
||||
// powerful vectors (Admin SDK / service-account keys, and IAM console access)
|
||||
// bypass rules entirely — keep those out of the repo and locked down in GCP.
|
||||
// See docs/firebase-leaderboards.md ("Protecting production data").
|
||||
//
|
||||
// Rules match by namespace SUFFIX (regex) rather than a literal prefix, so the
|
||||
// same set covers production (`mw-*`) and test (`mw-test-*`) collections in the
|
||||
// one project — and any future namespace. Firestore ORs all matching rules, and
|
||||
// `{ns}-scores`, `{ns}-all` and `{ns}-leaders` share a 4-segment shape, so each
|
||||
// block is guarded by a regex on the captured collection name.
|
||||
//
|
||||
// Deploy with (see docs/firebase-leaderboards.md):
|
||||
// npx firebase deploy --only firestore:rules --project prod
|
||||
service cloud.firestore {
|
||||
match /databases/{database}/documents {
|
||||
|
||||
// Ranked, time-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 collection, frozen into the static Legends page — read-only.
|
||||
match /{leaders}/{category}/games/{game} {
|
||||
allow read: if leaders.matches('mw(-[a-z]+)?-leaders');
|
||||
allow write: if false;
|
||||
}
|
||||
|
||||
// Per-browser personal archive. Write-only from the client (the app never
|
||||
// reads it back), append/merge only, and NEVER deletable. This is the only
|
||||
// client-writable collection, so denying delete/read here removes the last
|
||||
// way a client could destroy or harvest data.
|
||||
match /{all}/{browserId}/games/{session} {
|
||||
allow read: if false;
|
||||
allow create, update: if all.matches('mw(-[a-z]+)?-all');
|
||||
allow delete: if false;
|
||||
}
|
||||
|
||||
// Server config (passingStatus, message) — public read, no client write.
|
||||
match /{config}/{document} {
|
||||
allow read: if config.matches('mw(-[a-z]+)?-config');
|
||||
allow write: if false;
|
||||
}
|
||||
|
||||
// Defense-in-depth: deny anything not matched above. Firestore already
|
||||
// default-denies unmatched paths; this makes the intent explicit and is a
|
||||
// backstop if a future rule is added carelessly. (Allows are OR-ed, so this
|
||||
// never revokes the specific grants above.)
|
||||
match /{document=**} {
|
||||
allow read, write: if false;
|
||||
}
|
||||
|
||||
// A well-formed score: numeric score, bounded name, string bucket keys, and
|
||||
// a category matching the path. Rules have no clock, so they can't verify
|
||||
// the buckets are correctly derived from time_stamp — that stays the client's
|
||||
// job. Extra fields (playerId, time_stamp, meta) are allowed.
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
234
leaderboard/CONFIGURATION.md
Normal file
234
leaderboard/CONFIGURATION.md
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
# Leaderboard configuration reference
|
||||
|
||||
Complete reference for configuring `@cozy-games/leaderboard`. There are two ways
|
||||
to use it — a **JS API** and a **web component** — and they share the same option
|
||||
set. The element simply forwards options to the service under the hood.
|
||||
|
||||
- [Options at a glance](#options-at-a-glance)
|
||||
- [JS API — `LeaderBoardService`](#js-api--leaderboardservice)
|
||||
- [Web component — `<cozy-leaderboard>`](#web-component--cozy-leaderboard)
|
||||
- [Adapters (backend config)](#adapters-backend-config)
|
||||
- [Localization (i18n)](#localization-i18n)
|
||||
- [Score entry shape](#score-entry-shape)
|
||||
- [Where config is read / precedence](#where-config-is-read--precedence)
|
||||
|
||||
---
|
||||
|
||||
## Options at a glance
|
||||
|
||||
| option | type | default | purpose |
|
||||
| --- | --- | --- | --- |
|
||||
| `adapter` | object | — (**required**) | storage backend — a `FirebaseAdapter`, `SupabaseAdapter`, or your own |
|
||||
| `scoreOrder` | `'asc'` \| `'desc'` | `'asc'` | `asc` = lower is better (time); `desc` = higher is better (points) |
|
||||
| `formatScore` | `(value) => string` | `String(value)` | how a score is displayed |
|
||||
| `qualifies` | `(entry) => boolean` | server `passingStatus` vs `entry.status` | whether a submitted entry is ranked |
|
||||
| `labels` | object | `{today,week,month,all}` English | tab labels, keyed by `today`/`week`/`month`/`all` |
|
||||
| `tooltips` | object | `Last 24 hours` / `Last 7 days` / `Last 30 days` / `All time` | tab hover text, keyed by `today`/`week`/`month`/`all` |
|
||||
| `emptyMessages` | `string[]` | built-in pool | messages for an empty window (one picked at random) |
|
||||
| `loadingText` | string | `Loading…` | shown while a window loads |
|
||||
| `errorText` | string | `Leaderboard unavailable right now.` | shown when a window fails to load |
|
||||
| `anonymousName` | string | `Anonymous` | fallback display name for entries without one |
|
||||
|
||||
Every user-facing string (`labels`, `emptyMessages`, `loadingText`, `errorText`,
|
||||
`anonymousName`) is an option — see [Localization](#localization-i18n).
|
||||
|
||||
---
|
||||
|
||||
## JS API — `LeaderBoardService`
|
||||
|
||||
```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',
|
||||
formatScore: ms => prettyTime(ms)
|
||||
})
|
||||
```
|
||||
|
||||
### Methods
|
||||
|
||||
- **`render(category, title, duration?) → Promise<HTMLElement>`**
|
||||
Builds the board (duration tabs + list) for `category` with heading `title`.
|
||||
`duration` is one of `today` (default) · `week` · `month` · `all`. When omitted
|
||||
on a re-render, the last-selected tab is kept. Append the returned element to
|
||||
the page.
|
||||
|
||||
Windows are **rolling** and strictly nested: `today` = last 24h, `week` = last
|
||||
7 days, `month` = last 30 days, `all` = everything — each shows the top scores
|
||||
whose `time_stamp` falls in the window. Each tab's hover text (`tooltips`)
|
||||
spells this out.
|
||||
|
||||
- **`submit(entry) → Promise`**
|
||||
Records a finished game (archives it; ranks it if it `qualifies`). See
|
||||
[Score entry shape](#score-entry-shape).
|
||||
|
||||
---
|
||||
|
||||
## Web component — `<cozy-leaderboard>`
|
||||
|
||||
Configure the backend once in JS, then compose the UI in HTML.
|
||||
|
||||
```html
|
||||
<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' }),
|
||||
scoreOrder: 'asc',
|
||||
format: 'time'
|
||||
})
|
||||
</script>
|
||||
|
||||
<cozy-leaderboard category="beginner" title="Best Times" format="time"></cozy-leaderboard>
|
||||
```
|
||||
|
||||
### `configureLeaderboard(options)`
|
||||
|
||||
Sets shared defaults for **every** `<cozy-leaderboard>` on the page. Accepts all
|
||||
the [options above](#options-at-a-glance), plus `format` (a preset shorthand for
|
||||
`formatScore` — see below). Call it once at startup. Calling it again re-renders
|
||||
mounted elements.
|
||||
|
||||
### Attributes
|
||||
|
||||
| attribute | values | purpose |
|
||||
| --- | --- | --- |
|
||||
| `category` | string | which board to show (e.g. a difficulty/level id) |
|
||||
| `title` | string | heading text above the tabs |
|
||||
| `duration` | `today` \| `week` \| `month` \| `all` | initial tab (default `today`) |
|
||||
| `score-order` | `asc` \| `desc` | overrides the shared `scoreOrder` |
|
||||
| `format` | `time` \| `number` \| `plain` | score display preset (below) |
|
||||
|
||||
Attributes are reactive: change `category`/`title` at runtime and the board
|
||||
re-renders, keeping the selected duration tab.
|
||||
|
||||
### `format` presets
|
||||
|
||||
| `format` | result |
|
||||
| --- | --- |
|
||||
| `time` | milliseconds → `mm:ss.t` (e.g. `01:34.6`) |
|
||||
| `number` / `plain` / unset | `String(value)` |
|
||||
|
||||
For anything else, set a `formatScore` function (via `configureLeaderboard` or the
|
||||
per-element property).
|
||||
|
||||
### Override properties & method
|
||||
|
||||
Set these JS properties on an element to override the shared config for that one
|
||||
element (useful for multi-board or multi-backend pages):
|
||||
|
||||
`adapter`, `formatScore`, `qualifies`, `labels`, `emptyMessages`, `loadingText`,
|
||||
`errorText`, `anonymousName`.
|
||||
|
||||
```js
|
||||
const el = document.querySelector('cozy-leaderboard')
|
||||
el.emptyMessages = ['¡Sé el primero!']
|
||||
el.formatScore = pts => `${pts} pts`
|
||||
```
|
||||
|
||||
- **`submit(entry)`** — submit a finished game through this element's service:
|
||||
```js
|
||||
el.submit({ name: 'Ayo', playerId: 'abc', score: 4200, category: 'beginner', time_stamp: new Date(), status: 'win' })
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Adapters (backend config)
|
||||
|
||||
### `FirebaseAdapter`
|
||||
|
||||
```js
|
||||
import { FirebaseAdapter } from '@cozy-games/leaderboard/adapters/firebase.js'
|
||||
new FirebaseAdapter({ firebaseConfig, namespace: 'mw', emulator })
|
||||
```
|
||||
|
||||
| option | type | default | purpose |
|
||||
| --- | --- | --- | --- |
|
||||
| `firebaseConfig` | object | — | Firebase web config (public; access governed by rules) |
|
||||
| `namespace` | string | `lb` | collection prefix → `{ns}-scores`, `{ns}-all`, `{ns}-config` |
|
||||
| `emulator` | `{ host?, port? }` | — | point at a local Firestore emulator (dev only); host `127.0.0.1`, port `8080` |
|
||||
|
||||
Needs the `firebase` peer dependency. Rolling-window and all-time queries use
|
||||
Firestore's automatic single-field indexes — no composite indexes to deploy.
|
||||
|
||||
### `SupabaseAdapter`
|
||||
|
||||
```js
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { SupabaseAdapter } from '@cozy-games/leaderboard/adapters/supabase.js'
|
||||
new SupabaseAdapter({ client: createClient(url, anonKey), namespace: 'mw' })
|
||||
```
|
||||
|
||||
| option | type | default | purpose |
|
||||
| --- | --- | --- | --- |
|
||||
| `client` | supabase-js client | — | you construct it (package takes no supabase dep) |
|
||||
| `namespace` | string | `lb` | table prefix → `{ns}_scores`, `{ns}_archive`, `{ns}_config` |
|
||||
|
||||
See the [README](./README.md#supabase-postgres) for the SQL schema.
|
||||
|
||||
---
|
||||
|
||||
## Localization (i18n)
|
||||
|
||||
This package ships **English defaults only** — localization is your app's job.
|
||||
Pass translated copy for every user-facing string:
|
||||
|
||||
```js
|
||||
configureLeaderboard({
|
||||
adapter,
|
||||
labels: { today: 'Hoy', week: 'Semana', month: 'Mes', all: 'Histórico' },
|
||||
emptyMessages: [
|
||||
'¡Sé el primero en el marcador!',
|
||||
'Aún no hay puntajes. ¡Reclama la cima!'
|
||||
],
|
||||
loadingText: 'Cargando…',
|
||||
errorText: 'Marcador no disponible ahora.',
|
||||
anonymousName: 'Anónimo'
|
||||
})
|
||||
```
|
||||
|
||||
The same keys work when constructing `LeaderBoardService` directly. To switch
|
||||
languages at runtime, call `configureLeaderboard()` again with the new strings
|
||||
(mounted elements re-render).
|
||||
|
||||
---
|
||||
|
||||
## Score entry shape
|
||||
|
||||
The object passed to `submit(entry)`:
|
||||
|
||||
```js
|
||||
{
|
||||
name, // string — display name (falls back to `anonymousName`)
|
||||
playerId, // string — opaque id (e.g. a browser fingerprint)
|
||||
score, // number — the ranked value (minesweeper: finish time in ms)
|
||||
category, // string — which board (minesweeper: level id)
|
||||
time_stamp, // Date — when the game finished (used to compute day/week/month)
|
||||
status, // string, optional — read by the default `qualifies`
|
||||
meta // object, optional — extra fields to store (e.g. { isMobile })
|
||||
}
|
||||
```
|
||||
|
||||
The package computes the `day`/`week`/`month` bucket keys from `time_stamp`
|
||||
(UTC) before storing — you don't set those.
|
||||
|
||||
---
|
||||
|
||||
## Where config is read / precedence
|
||||
|
||||
- **JS API:** options are read when you construct `LeaderBoardService`.
|
||||
- **Web component:** the element builds its service on first render (on connect),
|
||||
reading — in order — its **own attribute/property**, then the shared
|
||||
**`configureLeaderboard()`** value, then the **package default**:
|
||||
|
||||
```
|
||||
per-element attribute/property > configureLeaderboard(...) > built-in default
|
||||
```
|
||||
|
||||
The service is cached per element, so set overrides **before** the element
|
||||
connects (or set the property and clear the element's `_svc` to force a
|
||||
rebuild). `score-order` and `format` are read from attributes; the function/
|
||||
array/string overrides are read from properties.
|
||||
248
leaderboard/README.md
Normal file
248
leaderboard/README.md
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
# @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.
|
||||
75
leaderboard/adapters/firebase.js
Normal file
75
leaderboard/adapters/firebase.js
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { initializeApp } from 'firebase/app'
|
||||
import {
|
||||
getFirestore, connectFirestoreEmulator,
|
||||
doc, getDoc, getDocs, setDoc, collection, query, where, orderBy, limit
|
||||
} from 'firebase/firestore/lite'
|
||||
|
||||
/**
|
||||
* Firestore storage adapter for LeaderBoardService. Requires the `firebase`
|
||||
* peer dependency. Collections are namespaced: `{ns}-scores/{category}/games`,
|
||||
* `{ns}-all/{playerId}/games`, and `{ns}-config/configuration`.
|
||||
*/
|
||||
export class FirebaseAdapter {
|
||||
|
||||
/**
|
||||
* @param {Object} options
|
||||
* @param {Object} options.firebaseConfig - Firebase app config (public; access governed by security rules)
|
||||
* @param {String} [options.namespace] - collection prefix
|
||||
* @param {{ host?: string, port?: number }} [options.emulator] - point at a local Firestore emulator (dev/test only)
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
this.namespace = options.namespace || 'lb'
|
||||
const app = initializeApp(options.firebaseConfig)
|
||||
this.store = getFirestore(app)
|
||||
if (options.emulator) {
|
||||
const { host = '127.0.0.1', port = 8080 } = options.emulator
|
||||
connectFirestoreEmulator(this.store, host, port)
|
||||
}
|
||||
}
|
||||
|
||||
async getConfig() {
|
||||
const ref = doc(this.store, `${this.namespace}-config`, 'configuration')
|
||||
const snapshot = await getDoc(ref)
|
||||
return snapshot.data()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} q - { category, since, order, limit }
|
||||
* @returns {Promise<Object[]>} plain score records, best first
|
||||
*/
|
||||
async listScores(q) {
|
||||
const games = collection(this.store, `${this.namespace}-scores`, q.category, 'games')
|
||||
if (q.since) {
|
||||
// Rolling window: Firestore requires the inequality field to sort first,
|
||||
// so fetch the in-window rows (newest first, capped) and rank by score
|
||||
// client-side. The cap is a safety bound for busy windows.
|
||||
const snapshot = await getDocs(query(
|
||||
games, where('time_stamp', '>=', q.since), orderBy('time_stamp', 'desc'), limit(500)
|
||||
))
|
||||
const rows = snapshot.docs.map(d => d.data())
|
||||
rows.sort((a, b) => q.order === 'desc' ? b.score - a.score : a.score - b.score)
|
||||
return rows.slice(0, q.limit)
|
||||
}
|
||||
const snapshot = await getDocs(query(games, orderBy('score', q.order), limit(q.limit)))
|
||||
return snapshot.docs.map(d => d.data())
|
||||
}
|
||||
|
||||
async addScore(category, entry) {
|
||||
const ref = doc(collection(this.store, `${this.namespace}-scores`, category, 'games'))
|
||||
await setDoc(ref, entry)
|
||||
}
|
||||
|
||||
async archive(entry) {
|
||||
const sessionId = new Date().toDateString().replace(/\s/g, '_')
|
||||
const gameId = new Date().toTimeString().replace(/\s/g, '_')
|
||||
const data = {}
|
||||
data[gameId] = {
|
||||
score: entry.score,
|
||||
category: entry.category,
|
||||
time_stamp: entry.time_stamp,
|
||||
...(entry.meta || {})
|
||||
}
|
||||
const ref = doc(this.store, `${this.namespace}-all`, entry.playerId, 'games', sessionId)
|
||||
await setDoc(ref, data, { merge: true })
|
||||
}
|
||||
}
|
||||
79
leaderboard/adapters/supabase.js
Normal file
79
leaderboard/adapters/supabase.js
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
/**
|
||||
* Supabase (Postgres) storage adapter for LeaderBoardService.
|
||||
*
|
||||
* You pass in a supabase-js client you constructed yourself, so this package
|
||||
* takes NO supabase dependency:
|
||||
*
|
||||
* import { createClient } from '@supabase/supabase-js'
|
||||
* const client = createClient(url, anonKey)
|
||||
* const adapter = new SupabaseAdapter({ client, namespace: 'mw' })
|
||||
*
|
||||
* Expects three tables (see leaderboard/README.md for the SQL + row-level
|
||||
* security): `{ns}_scores`, `{ns}_archive`, `{ns}_config`. Column names are
|
||||
* snake_case; this adapter maps the generic entry's `playerId` <-> `player_id`.
|
||||
*/
|
||||
export class SupabaseAdapter {
|
||||
|
||||
/**
|
||||
* @param {Object} options
|
||||
* @param {Object} options.client - a supabase-js client
|
||||
* @param {String} [options.namespace] - table prefix
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
this.client = options.client
|
||||
this.namespace = options.namespace || 'lb'
|
||||
}
|
||||
|
||||
async getConfig() {
|
||||
const { data } = await this.client
|
||||
.from(`${this.namespace}_config`)
|
||||
.select('*')
|
||||
.eq('id', 'configuration')
|
||||
.maybeSingle()
|
||||
return data || undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} q - { category, since, order, limit }
|
||||
* @returns {Promise<Object[]>} plain score records (must expose `name`, `score`)
|
||||
*/
|
||||
async listScores(q) {
|
||||
let builder = this.client
|
||||
.from(`${this.namespace}_scores`)
|
||||
.select('*')
|
||||
.eq('category', q.category)
|
||||
// Rolling window: Postgres does the range + order + limit server-side.
|
||||
if (q.since) builder = builder.gte('time_stamp', q.since.toISOString())
|
||||
const { data, error } = await builder
|
||||
.order('score', { ascending: q.order === 'asc' })
|
||||
.limit(q.limit)
|
||||
if (error) throw error
|
||||
return data || []
|
||||
}
|
||||
|
||||
async addScore(category, entry) {
|
||||
const { error } = await this.client.from(`${this.namespace}_scores`).insert({
|
||||
name: entry.name,
|
||||
player_id: entry.playerId,
|
||||
score: entry.score,
|
||||
category: entry.category,
|
||||
time_stamp: entry.time_stamp,
|
||||
day: entry.day,
|
||||
week: entry.week,
|
||||
month: entry.month,
|
||||
meta: entry.meta || null
|
||||
})
|
||||
if (error) throw error
|
||||
}
|
||||
|
||||
async archive(entry) {
|
||||
const { error } = await this.client.from(`${this.namespace}_archive`).insert({
|
||||
player_id: entry.playerId,
|
||||
score: entry.score,
|
||||
category: entry.category,
|
||||
time_stamp: entry.time_stamp,
|
||||
meta: entry.meta || null
|
||||
})
|
||||
if (error) throw error
|
||||
}
|
||||
}
|
||||
302
leaderboard/leader-board.js
Normal file
302
leaderboard/leader-board.js
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
import { buckets } from '../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!'
|
||||
]
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* An adapter implements:
|
||||
* - getConfig(): Promise<Object|undefined>
|
||||
* - listScores({ category, field, value, order, limit }): Promise<Object[]>
|
||||
* - addScore(category, entry): Promise<void>
|
||||
* - archive(entry): Promise<void> // optional personal history
|
||||
*/
|
||||
export class LeaderBoardService {
|
||||
|
||||
/**
|
||||
* @param {Object} options
|
||||
* @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
|
||||
*/
|
||||
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(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
_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<HTMLDivElement>}
|
||||
*/
|
||||
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)
|
||||
}
|
||||
}
|
||||
164
leaderboard/leaderboard-element.js
Normal file
164
leaderboard/leaderboard-element.js
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
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)
|
||||
}
|
||||
39
leaderboard/package.json
Normal file
39
leaderboard/package.json
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
{
|
||||
"name": "@cozy-games/leaderboard",
|
||||
"version": "0.0.1",
|
||||
"description": "Generic, game-agnostic Firestore leaderboard with time-windowed views",
|
||||
"author": "Ayo Ayco",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/ayo-run/mnswpr"
|
||||
},
|
||||
"main": "leader-board.js",
|
||||
"exports": {
|
||||
".": {
|
||||
"default": "./dist/leader-board.js"
|
||||
},
|
||||
"./dist/*": {
|
||||
"default": "./dist/*"
|
||||
},
|
||||
"./*": {
|
||||
"default": "./*"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"./*",
|
||||
"./dist"
|
||||
],
|
||||
"dependencies": {
|
||||
"web-component-base": "^4.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"firebase": "^12.11.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"firebase": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"license": "BSD-2-Clause"
|
||||
}
|
||||
15
leaderboard/vite.config.js
Normal file
15
leaderboard/vite.config.js
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { resolve } from 'node:path'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
lib: {
|
||||
entry: resolve(import.meta.dirname, './leader-board.js'),
|
||||
name: 'leaderboard',
|
||||
fileName: 'leader-board'
|
||||
},
|
||||
rollupOptions: {
|
||||
external: [/^firebase/]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -15,6 +15,8 @@
|
|||
"test:watch": "vitest",
|
||||
"dev": "vite app",
|
||||
"start": "vite app",
|
||||
"emulators": "npx -y firebase-tools emulators:start --only firestore",
|
||||
"seed:emulator": "cd app && FIRESTORE_EMULATOR_HOST=127.0.0.1:8080 node ../scripts/seed-dev-scores.js",
|
||||
"build": "vite build app",
|
||||
"build:lib": "vite build lib",
|
||||
"publish:lib": "node scripts/publish-lib.js",
|
||||
|
|
|
|||
|
|
@ -47,9 +47,24 @@ importers:
|
|||
'@ayo-run/mnswpr':
|
||||
specifier: workspace:*
|
||||
version: link:../lib
|
||||
'@cozy-games/leaderboard':
|
||||
specifier: workspace:*
|
||||
version: link:../leaderboard
|
||||
firebase:
|
||||
specifier: ^12.11.0
|
||||
version: 12.11.0
|
||||
web-component-base:
|
||||
specifier: ^4.1.2
|
||||
version: 4.1.2
|
||||
|
||||
leaderboard:
|
||||
dependencies:
|
||||
firebase:
|
||||
specifier: ^12.11.0
|
||||
version: 12.11.0
|
||||
web-component-base:
|
||||
specifier: ^4.1.2
|
||||
version: 4.1.2
|
||||
|
||||
lib: {}
|
||||
|
||||
|
|
@ -1299,6 +1314,9 @@ packages:
|
|||
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
web-component-base@4.1.2:
|
||||
resolution: {integrity: sha512-Jti4FHgCcwtsFWJ+PPwFNhTm5AJVIHrTXDew0rk2Y3b8EChRIE5xr6fmUni43tOhc6w8HatvR3k+qnxjXr/7Mw==}
|
||||
|
||||
web-vitals@4.2.4:
|
||||
resolution: {integrity: sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==}
|
||||
|
||||
|
|
@ -2636,6 +2654,8 @@ snapshots:
|
|||
dependencies:
|
||||
xml-name-validator: 5.0.0
|
||||
|
||||
web-component-base@4.1.2: {}
|
||||
|
||||
web-vitals@4.2.4: {}
|
||||
|
||||
webidl-conversions@8.0.1: {}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
packages:
|
||||
- "lib"
|
||||
- "app"
|
||||
- "leaderboard"
|
||||
allowBuilds:
|
||||
'@firebase/util': false
|
||||
protobufjs: false
|
||||
web-component-base: false
|
||||
|
|
|
|||
136
scripts/export-legends.js
Normal file
136
scripts/export-legends.js
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
/**
|
||||
* One-time generator: snapshot the all-time leaders from the legacy
|
||||
* `mw-leaders/{level}/games` collection and write them into app/legends.html as
|
||||
* a FULLY-RENDERED, static page — no runtime JS, no Firebase at page load.
|
||||
*
|
||||
* The data never changes, so the page is a frozen artifact. Re-run only if you
|
||||
* ever need to regenerate it. Because `firebase` is an app-workspace dependency,
|
||||
* run it so Node can resolve it, e.g. from the app directory:
|
||||
* (cd app && node ../scripts/export-legends.js)
|
||||
*/
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { initializeApp } from 'firebase/app'
|
||||
import {
|
||||
getFirestore, getDocs, collection, query, orderBy, limit
|
||||
} from 'firebase/firestore/lite'
|
||||
|
||||
import { levels } from '../lib/levels.js'
|
||||
|
||||
// Mirror of TimerService.pretty() (utils/timer/timer.js) — inlined so this
|
||||
// generator has no cross-module import chain to resolve under raw Node.
|
||||
const clean = (str, separator) => (str === '00' ? '' : `${str}${separator}`)
|
||||
const pretty = duration => {
|
||||
if (!duration) return undefined
|
||||
const milliseconds = parseInt((duration % 1000) / 100)
|
||||
const seconds = Math.floor((duration / 1000) % 60)
|
||||
const minutes = Math.floor((duration / (1000 * 60)) % 60)
|
||||
const hours = Math.floor((duration / (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, '.')}${clean(milliseconds, '')}`
|
||||
}
|
||||
|
||||
// Hardcoded for this one-time run (public, non-secret keys — access is governed
|
||||
// by Firestore rules). This is the project the deployed app has always used.
|
||||
const firebaseConfig = {
|
||||
apiKey: 'AIzaSyCTi_5Sm5dHFNf0d_Gn0MNWmlGheFBf6MQ',
|
||||
authDomain: 'moment-188701.firebaseapp.com',
|
||||
databaseURL: 'https://moment-188701.firebaseio.com',
|
||||
projectId: 'secure-moment-188701',
|
||||
storageBucket: 'secure-moment-188701.firebasestorage.app',
|
||||
messagingSenderId: '113827947104',
|
||||
appId: '1:113827947104:web:b176f746d8358302c51905',
|
||||
measurementId: 'G-LZRDY0TG46'
|
||||
}
|
||||
|
||||
console.log(`Exporting Legends from project: ${firebaseConfig.projectId}`)
|
||||
|
||||
const TOP_N = 10
|
||||
|
||||
const escapeHtml = str => String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
|
||||
const app = initializeApp(firebaseConfig)
|
||||
const store = getFirestore(app)
|
||||
|
||||
const sections = []
|
||||
for (const id of Object.keys(levels)) {
|
||||
const q = query(
|
||||
collection(store, 'mw-leaders', id, 'games'),
|
||||
orderBy('time'),
|
||||
limit(TOP_N)
|
||||
)
|
||||
const snapshot = await getDocs(q)
|
||||
const rows = snapshot.docs.map(d => {
|
||||
const data = d.data()
|
||||
return { name: data.name || 'Anonymous', time: data.time }
|
||||
})
|
||||
console.log(`${id}: ${rows.length} record(s)`)
|
||||
|
||||
const items = rows.map((r, i) =>
|
||||
` <li><span class="rank">#${i + 1}</span><span class="name" title="${escapeHtml(r.name)}">${escapeHtml(r.name)}</span><span class="time">${pretty(r.time)}</span></li>`
|
||||
).join('\n')
|
||||
|
||||
sections.push(` <section>
|
||||
<h3>${escapeHtml(levels[id].name)}</h3>
|
||||
<ol>
|
||||
${items}
|
||||
</ol>
|
||||
</section>`)
|
||||
}
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||
|
||||
<meta name="Description" content="Minesweeper Legends — Epic pre-version-one top scores." />
|
||||
<title>Minesweeper Legends 🏆</title>
|
||||
|
||||
<link rel="shortcut icon" type="image/png" href="/favicon.ico" />
|
||||
<link rel="stylesheet" href="./main.css" />
|
||||
<style>
|
||||
.legends section { max-width: 270px; margin: 0 auto 30px; }
|
||||
.legends h3 { border-bottom: 1px solid #c0c0c0; padding-bottom: 10px; }
|
||||
.legends ol { list-style: none; padding: 0; margin: 0; text-align: left; }
|
||||
.legends li { display: flex; }
|
||||
.legends .name {
|
||||
flex: 1;
|
||||
padding: 0 5px;
|
||||
font-weight: bold;
|
||||
font-style: italic;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Generated by scripts/export-legends.js — do not edit by hand. -->
|
||||
<div id="body-wrapper">
|
||||
<h1>🏆 Legends</h1>
|
||||
<p><em>Epic scores — the top times set before version 1.0.</em></p>
|
||||
<div class="legends">
|
||||
${sections.join('\n')}
|
||||
</div>
|
||||
<div id="legends-link">
|
||||
<a href="/">← Back to the game</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const out = resolve(__dirname, '../app/legends.html')
|
||||
writeFileSync(out, html)
|
||||
console.log(`\nWrote ${out}`)
|
||||
104
scripts/seed-dev-scores.js
Normal file
104
scripts/seed-dev-scores.js
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
/**
|
||||
* Seed the DEVELOPMENT leaderboard with sample scores so the boards aren't empty
|
||||
* while developing. Writes to the `mw-test-*` namespace by default.
|
||||
*
|
||||
* Two ways to run it:
|
||||
*
|
||||
* 1. Against the local Firestore EMULATOR (recommended — no cloud, no deploy):
|
||||
* pnpm emulators # in one terminal
|
||||
* pnpm seed:emulator # in another
|
||||
* (seed:emulator sets FIRESTORE_EMULATOR_HOST so writes hit the emulator.)
|
||||
*
|
||||
* 2. Against the real cloud dev database: REQUIRES the generalized
|
||||
* firestore.rules to be deployed first (they permit writes to
|
||||
* `mw-test-scores`), else every write returns permission-denied:
|
||||
* npx firebase deploy --only firestore:rules --project dev
|
||||
* (cd app && node ../scripts/seed-dev-scores.js)
|
||||
*
|
||||
* `firebase` is an app-workspace dependency, so run it from the app directory so
|
||||
* Node can resolve it. Optional namespace override: LB_NAMESPACE=mw-test.
|
||||
*/
|
||||
import { initializeApp } from 'firebase/app'
|
||||
import { getFirestore, connectFirestoreEmulator, doc, collection, setDoc } from 'firebase/firestore/lite'
|
||||
|
||||
import { buckets } from '../utils/date-bucket/date-bucket.js'
|
||||
|
||||
const firebaseConfig = {
|
||||
apiKey: 'AIzaSyCTi_5Sm5dHFNf0d_Gn0MNWmlGheFBf6MQ',
|
||||
authDomain: 'moment-188701.firebaseapp.com',
|
||||
databaseURL: 'https://moment-188701.firebaseio.com',
|
||||
projectId: 'secure-moment-188701',
|
||||
storageBucket: 'secure-moment-188701.firebasestorage.app',
|
||||
messagingSenderId: '113827947104',
|
||||
appId: '1:113827947104:web:b176f746d8358302c51905'
|
||||
}
|
||||
|
||||
const namespace = process.env.LB_NAMESPACE || 'mw-test'
|
||||
|
||||
// Per-level rough time ranges (ms) so sample times look believable.
|
||||
const LEVELS = {
|
||||
beginner: [1800, 12000],
|
||||
intermediate: [28000, 95000],
|
||||
expert: [90000, 260000],
|
||||
nightmare: [180000, 620000]
|
||||
}
|
||||
|
||||
const NAMES = [
|
||||
'Ayo', 'Sweeper', 'MineWizard', 'jenjereren', 'wasd', 'kaboom',
|
||||
'flagella', 'ClickClick', 'Torpedo', 'S-Davies', 'Anonymous', 'zero-day',
|
||||
'BoomBot', 'gridlock', 'NoNiner', 'chord-lord', 'tenten', 'quicksilver'
|
||||
]
|
||||
|
||||
// Ages (days ago) spread so Today / Week / Month / All Time all get entries.
|
||||
const AGE_DAYS = [0, 0, 0, 1, 2, 4, 6, 10, 18, 27, 45, 80]
|
||||
|
||||
const now = Date.now()
|
||||
const rand = (min, max) => Math.floor(min + Math.random() * (max - min))
|
||||
const pick = arr => arr[Math.floor(Math.random() * arr.length)]
|
||||
|
||||
const store = getFirestore(initializeApp(firebaseConfig))
|
||||
|
||||
// Point at a local Firestore emulator when FIRESTORE_EMULATOR_HOST is set
|
||||
// (e.g. "127.0.0.1:8080"). Writes then hit the local emulator, not the cloud.
|
||||
if (process.env.FIRESTORE_EMULATOR_HOST) {
|
||||
const [host, port] = process.env.FIRESTORE_EMULATOR_HOST.split(':')
|
||||
connectFirestoreEmulator(store, host, Number(port))
|
||||
console.log(`Using Firestore emulator at ${process.env.FIRESTORE_EMULATOR_HOST}`)
|
||||
}
|
||||
|
||||
let ok = 0
|
||||
let failed = 0
|
||||
let firstError
|
||||
|
||||
for (const [level, [min, max]] of Object.entries(LEVELS)) {
|
||||
for (let i = 0; i < AGE_DAYS.length; i++) {
|
||||
const stamp = new Date(now - AGE_DAYS[i] * 86400000 - rand(0, 3600000))
|
||||
const bucket = buckets(stamp)
|
||||
const entry = {
|
||||
name: pick(NAMES),
|
||||
playerId: `seed-${level}-${i}`,
|
||||
score: rand(min, max),
|
||||
category: level,
|
||||
time_stamp: stamp,
|
||||
day: bucket.day,
|
||||
week: bucket.week,
|
||||
month: bucket.month,
|
||||
meta: { isMobile: Math.random() < 0.3, seed: true }
|
||||
}
|
||||
try {
|
||||
await setDoc(doc(collection(store, `${namespace}-scores`, level, 'games')), entry)
|
||||
ok++
|
||||
} catch (error) {
|
||||
failed++
|
||||
firstError = firstError || (error.code || error.message)
|
||||
}
|
||||
}
|
||||
console.log(`${level}: seeded`)
|
||||
}
|
||||
|
||||
console.log(`\nDone. Wrote ${ok} sample score(s) to ${namespace}-scores, ${failed} failed.`)
|
||||
if (failed) {
|
||||
console.error(`\nWrites failed (${firstError}). If this is 'permission-denied', deploy the rules first:`)
|
||||
console.error(' npx firebase deploy --only firestore:rules --project dev')
|
||||
process.exit(1)
|
||||
}
|
||||
41
test/date-bucket.test.js
Normal file
41
test/date-bucket.test.js
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { dayKey, weekKey, monthKey, buckets } from '../utils/date-bucket/date-bucket.js'
|
||||
|
||||
describe('date-bucket', () => {
|
||||
it('builds a zero-padded UTC day key', () => {
|
||||
expect(dayKey(new Date('2026-07-03T12:00:00Z'))).toBe('2026-07-03')
|
||||
expect(dayKey(new Date('2026-01-09T00:00:00Z'))).toBe('2026-01-09')
|
||||
})
|
||||
|
||||
it('builds a UTC month key', () => {
|
||||
expect(monthKey(new Date('2026-07-03T12:00:00Z'))).toBe('2026-07')
|
||||
expect(monthKey(new Date('2026-12-31T23:59:59Z'))).toBe('2026-12')
|
||||
})
|
||||
|
||||
it('builds an ISO week key', () => {
|
||||
// 2026-07-03 falls in ISO week 27
|
||||
expect(weekKey(new Date('2026-07-03T12:00:00Z'))).toBe('2026-W27')
|
||||
// 2026-01-01 is a Thursday -> ISO week 1 of 2026
|
||||
expect(weekKey(new Date('2026-01-01T00:00:00Z'))).toBe('2026-W01')
|
||||
})
|
||||
|
||||
it('rolls the ISO week-year back at the January boundary', () => {
|
||||
// 2027-01-01 is a Friday -> still ISO week 53 of 2026
|
||||
expect(weekKey(new Date('2027-01-01T00:00:00Z'))).toBe('2026-W53')
|
||||
})
|
||||
|
||||
it('uses UTC, not local time', () => {
|
||||
// Just before UTC midnight is still the 3rd in UTC
|
||||
expect(dayKey(new Date('2026-07-03T23:59:59Z'))).toBe('2026-07-03')
|
||||
// Just after is the 4th
|
||||
expect(dayKey(new Date('2026-07-04T00:00:01Z'))).toBe('2026-07-04')
|
||||
})
|
||||
|
||||
it('returns all three keys together', () => {
|
||||
expect(buckets(new Date('2026-07-03T12:00:00Z'))).toEqual({
|
||||
day: '2026-07-03',
|
||||
week: '2026-W27',
|
||||
month: '2026-07'
|
||||
})
|
||||
})
|
||||
})
|
||||
55
utils/date-bucket/date-bucket.js
Normal file
55
utils/date-bucket/date-bucket.js
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
/**
|
||||
* Time-bucket keys for leaderboards, computed in UTC so every player shares the
|
||||
* same boundaries worldwide. Each key is a plain string denormalized onto a
|
||||
* score document, letting a "Today / Week / Month" query be a cheap equality
|
||||
* filter instead of a range scan.
|
||||
*/
|
||||
|
||||
const pad = n => (n < 10 ? `0${n}` : `${n}`)
|
||||
|
||||
/**
|
||||
* Calendar-day key, e.g. `2026-07-03`.
|
||||
* @param {Date} date
|
||||
* @returns {String}
|
||||
*/
|
||||
export const dayKey = date => {
|
||||
return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Calendar-month key, e.g. `2026-07`.
|
||||
* @param {Date} date
|
||||
* @returns {String}
|
||||
*/
|
||||
export const monthKey = date => {
|
||||
return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* ISO-8601 week key, e.g. `2026-W27`. Weeks start Monday and the week-numbering
|
||||
* year can differ from the calendar year around January 1st, so we derive the
|
||||
* year from the week's Thursday.
|
||||
* @param {Date} date
|
||||
* @returns {String}
|
||||
*/
|
||||
export const weekKey = date => {
|
||||
const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()))
|
||||
const dayNum = d.getUTCDay() || 7
|
||||
d.setUTCDate(d.getUTCDate() + 4 - dayNum)
|
||||
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1))
|
||||
const week = Math.ceil((((d - yearStart) / 86400000) + 1) / 7)
|
||||
return `${d.getUTCFullYear()}-W${pad(week)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* All three bucket keys for a moment in time.
|
||||
* @param {Date} date
|
||||
* @returns {{ day: String, week: String, month: String }}
|
||||
*/
|
||||
export const buckets = date => {
|
||||
return {
|
||||
day: dayKey(date),
|
||||
week: weekKey(date),
|
||||
month: monthKey(date)
|
||||
}
|
||||
}
|
||||
|
|
@ -2,4 +2,5 @@ export * from './logger/logger.js'
|
|||
export * from './storage/storage.js'
|
||||
export * from './timer/timer.js'
|
||||
export * from './loading/loading.js'
|
||||
export * from './date-bucket/date-bucket.js'
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue