diff --git a/.firebaserc b/.firebaserc
new file mode 100644
index 0000000..b6cc199
--- /dev/null
+++ b/.firebaserc
@@ -0,0 +1,7 @@
+{
+ "projects": {
+ "default": "secure-moment-188701",
+ "prod": "secure-moment-188701",
+ "dev": "secure-moment-188701"
+ }
+}
diff --git a/.gitignore b/.gitignore
index d5f1bed..9d8e475 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,5 +6,19 @@ lib/README.md
.claude
-*.*~
-*.*swp
+# Production / local Firebase config values are not committed β set them as
+# Netlify env vars, or keep them in a local, gitignored env file. Dev values
+# live in the committed app/.env.development (public, non-secret keys).
+.env.production
+.env.local
+.env.*.local
+
+# Firebase emulator artifacts
+firebase-debug.log
+firestore-debug.log
+ui-debug.log
+.emulator-data/
+
+*~
+*swo
+*swp
diff --git a/AYO.md b/AYO.md
new file mode 100644
index 0000000..e80e838
--- /dev/null
+++ b/AYO.md
@@ -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.
diff --git a/README.md b/README.md
index 77769fe..8af5c50 100644
--- a/README.md
+++ b/README.md
@@ -64,6 +64,22 @@ pnpm build # build the website
pnpm build:lib # build the publishable library
```
+### Leaderboard (local Firestore emulator)
+
+The leader board is backed by [Google Firestore](https://firebase.google.com). For local development the app talks to the **Firestore emulator** by default β fully local, no cloud, no deploy. The flag `VITE_FIRESTORE_EMULATOR=1` is already set in `app/.env.development`.
+
+You need a **JDK 21+** installed (the emulator runs on Java; `firebase-tools` itself is fetched on demand via `npx`). Then, in two terminals:
+
+```bash
+pnpm emulators # terminal 1 β start the Firestore emulator on :8080 (+ UI)
+pnpm seed:emulator # terminal 2, once β fill it with sample scores
+pnpm dev # terminal 2 β run the app against the emulator
+```
+
+If the emulator isn't running, the board simply shows *"unavailable"* (a refused connection). To skip the emulator β for quick UI-only work, or if you don't have a JDK β set `VITE_FIRESTORE_EMULATOR=` (empty) in a local, gitignored `app/.env.local`; the app then uses the cloud `mw-test` namespace instead.
+
+See [`docs/firebase-leaderboards.md`](./docs/firebase-leaderboards.md) for the full data model, security rules, environments, and deployment.
+
## Contributing
Contributions are welcome! See [`AGENTS.md`](./AGENTS.md) for the architecture, conventions, and release workflow before opening a pull request.
diff --git a/app/.env.development b/app/.env.development
new file mode 100644
index 0000000..c4efdb2
--- /dev/null
+++ b/app/.env.development
@@ -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
diff --git a/app/.env.example b/app/.env.example
index e69de29..3de0e3d 100644
--- a/app/.env.example
+++ b/app/.env.example
@@ -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=
diff --git a/app/index.html b/app/index.html
index d42f540..31189b9 100644
--- a/app/index.html
+++ b/app/index.html
@@ -31,9 +31,16 @@
+
Please use Chrome or Firefox.
+
+
+
+
diff --git a/app/legends.html b/app/legends.html
new file mode 100644
index 0000000..957daea
--- /dev/null
+++ b/app/legends.html
@@ -0,0 +1,101 @@
+
+
+
+
+
+
+
+
+ Minesweeper Legends π
+
+
+
+
+
+
+
+
+
π Legends
+
Epic scores β the top times set before version 1.0.
+
+
+ Noobs
+
+ #1 kaelynaverymumm 01.9
+ #2 isji 02.0
+ #3 Anonymous 02.9
+ #4 jenjereren 03.7
+ #5 S-Davies 03.8
+ #6 kaelynaverymumm 04.0
+ #7 jenn 04.1
+ #8 jenjereren 04.1
+ #9 BIGTHICCDADDY!! 04.4
+ #10 XZ123627703 04.5
+
+
+
+ Normies
+
+ #1 jenjereren 29.1
+ #2 jenjereren 29.3
+ #3 jenjereren 30.1
+ #4 jenjereren 30.4
+ #5 jenjereren 30.5
+ #6 jenjereren 30.6
+ #7 jenjereren 30.8
+ #8 jenjereren 30.9
+ #9 jenjereren 31.4
+ #10 jenjereren 31.5
+
+
+
+ Torment
+
+ #1 jenjereren 01:34.6
+ #2 jenjereren 01:36.6
+ #3 jenjereren 01:38.5
+ #4 jenjereren 01:38.9
+ #5 jenjereren 01:39.3
+ #6 jenjereren 01:39.4
+ #7 jenjereren 01:39.7
+ #8 jenjereren 01:45.1
+ #9 jenjereren 01:45.8
+ #10 jenjereren 01:46.1
+
+
+
+ Hell
+
+ #1 wasd 01:13.6
+ #2 wasd 02:40.0
+ #3 jenjereren 02:57.3
+ #4 jenjereren 02:57.7
+ #5 jenjereren 03:02.8
+ #6 jenjereren 03:06.0
+ #7 jenjereren 03:06.4
+ #8 jenjereren 03:15.8
+ #9 jenjereren 03:16.4
+ #10 jenjereren 03:19.5
+
+
+
+
+
+
+
diff --git a/app/main.css b/app/main.css
index 7ee0d8c..4d1c063 100644
--- a/app/main.css
+++ b/app/main.css
@@ -23,3 +23,23 @@ em {
#body-wrapper {
display: inline-block;
}
+
+#greeting {
+ margin: 10px auto;
+ font-size: 0.95em;
+ color: #DDDDDD;
+}
+
+#legends-link {
+ margin: 20px auto;
+}
+
+#legends-link a {
+ color: #DDDDDD;
+ text-decoration-color: orange;
+ transition: 500ms ease-in-out;
+}
+
+#legends-link a:hover {
+ text-decoration-thickness: 2px;
+}
diff --git a/app/main.js b/app/main.js
index 262934f..b95156a 100644
--- a/app/main.js
+++ b/app/main.js
@@ -1,45 +1,73 @@
import mnswpr from '@ayo-run/mnswpr/mnswpr.js'
import '@ayo-run/mnswpr/mnswpr.css'
import * as pkg from '@ayo-run/mnswpr/package.json'
-import { LoadingService } from '../utils/'
-import { LeaderBoardService } from './modules/leader-board/leader-board.js'
+import { configureLeaderboard } from '@cozy-games/leaderboard/leaderboard-element.js'
+import { FirebaseAdapter } from '@cozy-games/leaderboard/adapters/firebase.js'
+import { NicknameService } from './modules/nickname/nickname.js'
+import { UserService } from './modules/user/user.js'
-const leaderBoardService = new LeaderBoardService()
-const loadingService = new LoadingService()
+// Firebase config comes from Vite env vars so dev and production point at
+// different databases: dev values live in app/.env.development (committed β
+// these keys are public, not secrets, and access is governed by
+// firestore.rules); production values are supplied as Netlify env vars.
+// https://stackoverflow.com/questions/37482366/is-it-safe-to-expose-firebase-apikey-to-the-public/37484053#37484053
+const firebaseConfig = {
+ apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
+ authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
+ databaseURL: import.meta.env.VITE_FIREBASE_DATABASE_URL,
+ projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
+ storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
+ messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
+ appId: import.meta.env.VITE_FIREBASE_APP_ID,
+ measurementId: import.meta.env.VITE_FIREBASE_MEASUREMENT_ID
+}
+
+const user = new UserService()
+const nickname = new NicknameService()
+
+// The leaderboard UI is composed declaratively as in
+// index.html; here we only wire its backend once. Namespace selects prod (`mw`)
+// vs test (`mw-test`) collections in the same Firebase project, defaulting to
+// test so a missing env var can never write into the production board.
+// Local dev runs against the Firestore emulator by default (VITE_FIRESTORE_EMULATOR
+// in app/.env.development); production builds always use real Firestore.
+const useEmulator = ['1', 'true', 'yes'].includes(String(import.meta.env.VITE_FIRESTORE_EMULATOR))
+configureLeaderboard({
+ adapter: new FirebaseAdapter({
+ firebaseConfig,
+ namespace: import.meta.env.VITE_LB_NAMESPACE || 'mw-test',
+ emulator: useEmulator ? { host: '127.0.0.1', port: 8080 } : undefined
+ })
+})
const version = import.meta.env.MODE === 'development'
? 'dev'
: pkg.version
-const initializeGameBoard = async (level) => {
- const prevousLeaderBoard = document.getElementById('leaderboard')
- const prevousLoadingWrapper = document.getElementById('loading-wrapper')
- prevousLoadingWrapper?.remove()
- const loadingWrapper = document.createElement('div')
- loadingWrapper.id = 'loading-wrapper'
- loadingService.addLoading(loadingWrapper)
+// Ask for a nickname on first visit and show the greeting bar.
+nickname.ensure()
+const greeting = document.getElementById('greeting')
+if (greeting) nickname.render(greeting)
- const appElement = document.getElementById('app')
-
- if (prevousLeaderBoard){
- const parent = prevousLeaderBoard.parentNode
- parent.replaceChild(loadingWrapper, prevousLeaderBoard)
- }else{
- appElement.append(loadingWrapper)
- }
-
- const leaderBoardWrapper = await leaderBoardService.update(level.id, `Best Times (${level.name})`)
- leaderBoardWrapper.id = 'leaderboard'
- appElement.replaceChild(leaderBoardWrapper, loadingWrapper)
-}
-
-const sendGameResult = (game) => {
- leaderBoardService.send(game, 'time')
-}
+const board = document.querySelector('cozy-leaderboard')
const game = new mnswpr('app', version, {
- levelChanged: (level) => initializeGameBoard(level),
- gameDone: (game) => sendGameResult(game)
+ // Point the board at the current level; the element re-renders reactively and
+ // keeps the selected duration tab.
+ levelChanged: (level) => {
+ board.setAttribute('title', `Best Times (${level.name})`)
+ board.setAttribute('category', level.id)
+ },
+ // Submit the finished game through the element's service.
+ gameDone: (game) => board.submit({
+ name: nickname.get(),
+ playerId: user.browserId,
+ score: game.time,
+ category: game.level,
+ time_stamp: game.time_stamp,
+ status: game.status,
+ meta: { isMobile: game.isMobile }
+ })
})
game.initialize()
diff --git a/app/modules/leader-board/leader-board.js b/app/modules/leader-board/leader-board.js
deleted file mode 100644
index bd6ddfe..0000000
--- a/app/modules/leader-board/leader-board.js
+++ /dev/null
@@ -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.')
- }
- }
-}
diff --git a/app/modules/nickname/nickname.js b/app/modules/nickname/nickname.js
new file mode 100644
index 0000000..d6a8358
--- /dev/null
+++ b/app/modules/nickname/nickname.js
@@ -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))
+ }
+}
diff --git a/app/package.json b/app/package.json
index 89a9389..a56a4fe 100644
--- a/app/package.json
+++ b/app/package.json
@@ -11,7 +11,9 @@
},
"devDependencies": {
"@ayo-run/mnswpr": "workspace:*",
- "firebase": "^12.11.0"
+ "@cozy-games/leaderboard": "workspace:*",
+ "firebase": "^12.11.0",
+ "web-component-base": "^4.1.2"
},
"author": "Ayo Ayco"
}
diff --git a/app/vite.config.js b/app/vite.config.js
new file mode 100644
index 0000000..aab8e2e
--- /dev/null
+++ b/app/vite.config.js
@@ -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')
+ }
+ }
+ }
+})
diff --git a/docs/firebase-leaderboards.md b/docs/firebase-leaderboards.md
new file mode 100644
index 0000000..a8bfc0d
--- /dev/null
+++ b/docs/firebase-leaderboards.md
@@ -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.
diff --git a/docs/leaderboard-env-migration.md b/docs/leaderboard-env-migration.md
new file mode 100644
index 0000000..cc210c3
--- /dev/null
+++ b/docs/leaderboard-env-migration.md
@@ -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.
diff --git a/eslint.config.js b/eslint.config.js
index 22e33f9..848a1de 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -49,5 +49,11 @@ export default defineConfig([
}]
}
},
+ {
+ files: ['scripts/**/*.js'],
+ languageOptions: {
+ globals: globals.node
+ }
+ },
globalIgnores(['**/dist'])
])
diff --git a/firebase.json b/firebase.json
new file mode 100644
index 0000000..a21361e
--- /dev/null
+++ b/firebase.json
@@ -0,0 +1,15 @@
+{
+ "firestore": {
+ "rules": "firestore.rules",
+ "indexes": "firestore.indexes.json"
+ },
+ "emulators": {
+ "firestore": {
+ "port": 8080
+ },
+ "ui": {
+ "enabled": true
+ },
+ "singleProjectMode": true
+ }
+}
diff --git a/firestore.indexes.json b/firestore.indexes.json
new file mode 100644
index 0000000..6bf0972
--- /dev/null
+++ b/firestore.indexes.json
@@ -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": []
+}
diff --git a/firestore.rules b/firestore.rules
new file mode 100644
index 0000000..351f517
--- /dev/null
+++ b/firestore.rules
@@ -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;
+ }
+ }
+}
diff --git a/leaderboard/CONFIGURATION.md b/leaderboard/CONFIGURATION.md
new file mode 100644
index 0000000..9491832
--- /dev/null
+++ b/leaderboard/CONFIGURATION.md
@@ -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 β ``](#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`**
+ 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 β ``
+
+Configure the backend once in JS, then compose the UI in HTML.
+
+```html
+
+
+
+```
+
+### `configureLeaderboard(options)`
+
+Sets shared defaults for **every** `` 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.
diff --git a/leaderboard/README.md b/leaderboard/README.md
new file mode 100644
index 0000000..a5c008b
--- /dev/null
+++ b/leaderboard/README.md
@@ -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 `` 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 `` custom element. Configure the backend once,
+then drop the element anywhere in your markup β the board (duration tabs + list)
+renders itself.
+
+```html
+
+
+
+
+
+```
+
+No build step required β it works straight from a CDN too:
+
+```html
+
+
+
+```
+
+### 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?
+
+`` 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
+listScores(q: {
+ category: string,
+ since: Date | null, // rolling cutoff (time_stamp >= since); null = All Time
+ order: 'asc' | 'desc',
+ limit: number
+}): Promise>
+addScore(category: string, entry: object): Promise
+archive?(entry: object): Promise // 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.
diff --git a/leaderboard/adapters/firebase.js b/leaderboard/adapters/firebase.js
new file mode 100644
index 0000000..a80c012
--- /dev/null
+++ b/leaderboard/adapters/firebase.js
@@ -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} 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 })
+ }
+}
diff --git a/leaderboard/adapters/supabase.js b/leaderboard/adapters/supabase.js
new file mode 100644
index 0000000..b13b119
--- /dev/null
+++ b/leaderboard/adapters/supabase.js
@@ -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} 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
+ }
+}
diff --git a/leaderboard/leader-board.js b/leaderboard/leader-board.js
new file mode 100644
index 0000000..585d449
--- /dev/null
+++ b/leaderboard/leader-board.js
@@ -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
+ * - listScores({ category, field, value, order, limit }): Promise
+ * - addScore(category, entry): Promise
+ * - archive(entry): Promise // 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}
+ */
+ 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)
+ }
+}
diff --git a/leaderboard/leaderboard-element.js b/leaderboard/leaderboard-element.js
new file mode 100644
index 0000000..761529a
--- /dev/null
+++ b/leaderboard/leaderboard-element.js
@@ -0,0 +1,164 @@
+import { WebComponent } from 'web-component-base'
+import { LeaderBoardService } from './leader-board.js'
+
+/**
+ * `` β 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.
+ *
+ *
+ */
+
+// Shared config for every element on the page, set once via configureLeaderboard().
+let sharedConfig = {}
+const instances = new Set()
+
+/**
+ * Configure the backend + defaults for all 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)
+}
diff --git a/leaderboard/package.json b/leaderboard/package.json
new file mode 100644
index 0000000..a1ef19d
--- /dev/null
+++ b/leaderboard/package.json
@@ -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"
+}
diff --git a/leaderboard/vite.config.js b/leaderboard/vite.config.js
new file mode 100644
index 0000000..46de10f
--- /dev/null
+++ b/leaderboard/vite.config.js
@@ -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/]
+ }
+ }
+})
diff --git a/package.json b/package.json
index 9c490b2..14aa934 100644
--- a/package.json
+++ b/package.json
@@ -15,6 +15,8 @@
"test:watch": "vitest",
"dev": "vite app",
"start": "vite app",
+ "emulators": "npx -y firebase-tools emulators:start --only firestore",
+ "seed:emulator": "cd app && FIRESTORE_EMULATOR_HOST=127.0.0.1:8080 node ../scripts/seed-dev-scores.js",
"build": "vite build app",
"build:lib": "vite build lib",
"publish:lib": "node scripts/publish-lib.js",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 9943379..767b60d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -47,9 +47,24 @@ importers:
'@ayo-run/mnswpr':
specifier: workspace:*
version: link:../lib
+ '@cozy-games/leaderboard':
+ specifier: workspace:*
+ version: link:../leaderboard
firebase:
specifier: ^12.11.0
version: 12.11.0
+ web-component-base:
+ specifier: ^4.1.2
+ version: 4.1.2
+
+ leaderboard:
+ dependencies:
+ firebase:
+ specifier: ^12.11.0
+ version: 12.11.0
+ web-component-base:
+ specifier: ^4.1.2
+ version: 4.1.2
lib: {}
@@ -1299,6 +1314,9 @@ packages:
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
engines: {node: '>=18'}
+ web-component-base@4.1.2:
+ resolution: {integrity: sha512-Jti4FHgCcwtsFWJ+PPwFNhTm5AJVIHrTXDew0rk2Y3b8EChRIE5xr6fmUni43tOhc6w8HatvR3k+qnxjXr/7Mw==}
+
web-vitals@4.2.4:
resolution: {integrity: sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==}
@@ -2636,6 +2654,8 @@ snapshots:
dependencies:
xml-name-validator: 5.0.0
+ web-component-base@4.1.2: {}
+
web-vitals@4.2.4: {}
webidl-conversions@8.0.1: {}
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index e03aae0..665a34e 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -1,6 +1,8 @@
packages:
- "lib"
- "app"
+ - "leaderboard"
allowBuilds:
'@firebase/util': false
protobufjs: false
+ web-component-base: false
diff --git a/scripts/export-legends.js b/scripts/export-legends.js
new file mode 100644
index 0000000..b858f17
--- /dev/null
+++ b/scripts/export-legends.js
@@ -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, '"')
+
+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) =>
+ ` #${i + 1} ${escapeHtml(r.name)} ${pretty(r.time)} `
+ ).join('\n')
+
+ sections.push(`
+ ${escapeHtml(levels[id].name)}
+
+${items}
+
+ `)
+}
+
+const html = `
+
+
+
+
+
+
+
+ Minesweeper Legends π
+
+
+
+
+
+
+
+
+
π Legends
+
Epic scores β the top times set before version 1.0.
+
+${sections.join('\n')}
+
+
+
+
+
+`
+
+const __dirname = dirname(fileURLToPath(import.meta.url))
+const out = resolve(__dirname, '../app/legends.html')
+writeFileSync(out, html)
+console.log(`\nWrote ${out}`)
diff --git a/scripts/seed-dev-scores.js b/scripts/seed-dev-scores.js
new file mode 100644
index 0000000..f438aec
--- /dev/null
+++ b/scripts/seed-dev-scores.js
@@ -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)
+}
diff --git a/test/date-bucket.test.js b/test/date-bucket.test.js
new file mode 100644
index 0000000..dd5a403
--- /dev/null
+++ b/test/date-bucket.test.js
@@ -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'
+ })
+ })
+})
diff --git a/utils/date-bucket/date-bucket.js b/utils/date-bucket/date-bucket.js
new file mode 100644
index 0000000..985cb9c
--- /dev/null
+++ b/utils/date-bucket/date-bucket.js
@@ -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)
+ }
+}
diff --git a/utils/index.js b/utils/index.js
index b613eab..43307e9 100644
--- a/utils/index.js
+++ b/utils/index.js
@@ -2,4 +2,5 @@ export * from './logger/logger.js'
export * from './storage/storage.js'
export * from './timer/timer.js'
export * from './loading/loading.js'
+export * from './date-bucket/date-bucket.js'