feat: reusable, time-windowed leaderboard (@cozy-games/leaderboard) #1
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
|
.claude
|
||||||
|
|
||||||
*.*~
|
# Production / local Firebase config values are not committed — set them as
|
||||||
*.*swp
|
# 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
|
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
|
## Contributing
|
||||||
|
|
||||||
Contributions are welcome! See [`AGENTS.md`](./AGENTS.md) for the architecture, conventions, and release workflow before opening a pull request.
|
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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="body-wrapper">
|
<div id="body-wrapper">
|
||||||
|
<div id="greeting"></div>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
Please use Chrome or Firefox.
|
Please use Chrome or Firefox.
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<script type="module" src="./main.js"></script>
|
<script type="module" src="./main.js"></script>
|
||||||
</body>
|
</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 {
|
#body-wrapper {
|
||||||
display: inline-block;
|
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 mnswpr from '@ayo-run/mnswpr/mnswpr.js'
|
||||||
import '@ayo-run/mnswpr/mnswpr.css'
|
import '@ayo-run/mnswpr/mnswpr.css'
|
||||||
import * as pkg from '@ayo-run/mnswpr/package.json'
|
import * as pkg from '@ayo-run/mnswpr/package.json'
|
||||||
import { LoadingService } from '../utils/'
|
import { configureLeaderboard } from '@cozy-games/leaderboard/leaderboard-element.js'
|
||||||
import { LeaderBoardService } from './modules/leader-board/leader-board.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()
|
// Firebase config comes from Vite env vars so dev and production point at
|
||||||
const loadingService = new LoadingService()
|
// 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'
|
const version = import.meta.env.MODE === 'development'
|
||||||
? 'dev'
|
? 'dev'
|
||||||
: pkg.version
|
: pkg.version
|
||||||
|
|
||||||
const initializeGameBoard = async (level) => {
|
// Ask for a nickname on first visit and show the greeting bar.
|
||||||
const prevousLeaderBoard = document.getElementById('leaderboard')
|
nickname.ensure()
|
||||||
const prevousLoadingWrapper = document.getElementById('loading-wrapper')
|
const greeting = document.getElementById('greeting')
|
||||||
prevousLoadingWrapper?.remove()
|
if (greeting) nickname.render(greeting)
|
||||||
const loadingWrapper = document.createElement('div')
|
|
||||||
loadingWrapper.id = 'loading-wrapper'
|
|
||||||
loadingService.addLoading(loadingWrapper)
|
|
||||||
|
|
||||||
const appElement = document.getElementById('app')
|
const board = document.querySelector('cozy-leaderboard')
|
||||||
|
|
||||||
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 game = new mnswpr('app', version, {
|
const game = new mnswpr('app', version, {
|
||||||
levelChanged: (level) => initializeGameBoard(level),
|
// Point the board at the current level; the element re-renders reactively and
|
||||||
gameDone: (game) => sendGameResult(game)
|
// 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()
|
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": {
|
"devDependencies": {
|
||||||
"@ayo-run/mnswpr": "workspace:*",
|
"@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"
|
"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'])
|
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",
|
"test:watch": "vitest",
|
||||||
"dev": "vite app",
|
"dev": "vite app",
|
||||||
"start": "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": "vite build app",
|
||||||
"build:lib": "vite build lib",
|
"build:lib": "vite build lib",
|
||||||
"publish:lib": "node scripts/publish-lib.js",
|
"publish:lib": "node scripts/publish-lib.js",
|
||||||
|
|
|
||||||
|
|
@ -47,9 +47,24 @@ importers:
|
||||||
'@ayo-run/mnswpr':
|
'@ayo-run/mnswpr':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../lib
|
version: link:../lib
|
||||||
|
'@cozy-games/leaderboard':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../leaderboard
|
||||||
firebase:
|
firebase:
|
||||||
specifier: ^12.11.0
|
specifier: ^12.11.0
|
||||||
version: 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: {}
|
lib: {}
|
||||||
|
|
||||||
|
|
@ -1299,6 +1314,9 @@ packages:
|
||||||
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
web-component-base@4.1.2:
|
||||||
|
resolution: {integrity: sha512-Jti4FHgCcwtsFWJ+PPwFNhTm5AJVIHrTXDew0rk2Y3b8EChRIE5xr6fmUni43tOhc6w8HatvR3k+qnxjXr/7Mw==}
|
||||||
|
|
||||||
web-vitals@4.2.4:
|
web-vitals@4.2.4:
|
||||||
resolution: {integrity: sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==}
|
resolution: {integrity: sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==}
|
||||||
|
|
||||||
|
|
@ -2636,6 +2654,8 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
xml-name-validator: 5.0.0
|
xml-name-validator: 5.0.0
|
||||||
|
|
||||||
|
web-component-base@4.1.2: {}
|
||||||
|
|
||||||
web-vitals@4.2.4: {}
|
web-vitals@4.2.4: {}
|
||||||
|
|
||||||
webidl-conversions@8.0.1: {}
|
webidl-conversions@8.0.1: {}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
packages:
|
packages:
|
||||||
- "lib"
|
- "lib"
|
||||||
- "app"
|
- "app"
|
||||||
|
- "leaderboard"
|
||||||
allowBuilds:
|
allowBuilds:
|
||||||
'@firebase/util': false
|
'@firebase/util': false
|
||||||
protobufjs: 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 './storage/storage.js'
|
||||||
export * from './timer/timer.js'
|
export * from './timer/timer.js'
|
||||||
export * from './loading/loading.js'
|
export * from './loading/loading.js'
|
||||||
|
export * from './date-bucket/date-bucket.js'
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue