From 4fa88fb43126c3236f1a41fab1495a0d773d471a Mon Sep 17 00:00:00 2001 From: Ayo Date: Fri, 3 Jul 2026 11:41:14 +0000 Subject: [PATCH] feat: reusable, time-windowed leaderboard (@cozy-games/leaderboard) Extract the leaderboard into a generic, backend-agnostic package and add rolling Today/Week/Month/All-Time windows, a web component, and local-first development against the Firestore emulator. Package (leaderboard/): - LeaderBoardService: backend-agnostic core via a storage-adapter seam, with Firebase and Supabase adapters (Supabase client injected, no added dep) - Rolling time windows (last 24h / 7d / 30d) with hover tooltips; top-N by score - web component built on web-component-base: compose the UI in HTML, configure the backend once in JS - Every user-facing string is configurable (labels, tooltips, empty/loading/ error messages, anonymous name) so i18n lives in the app; README + CONFIGURATION App (mnswpr.com): - Compose the board declaratively in index.html via - Nickname + randomized greeting bar; score submission through the element - Legends: the current all-time leaders frozen into a static /legends page - Firebase config and leaderboard namespace via Vite env vars; emulator-first local dev (VITE_FIRESTORE_EMULATOR) Firebase schema-as-code: - firebase.json, .firebaserc, firestore.rules (public reads, create-only scores, no client updates/deletes, namespace-generalized), empty indexes (rolling windows need none) - prod (mw-*) vs dev/test (mw-test-*) separation by collection namespace - emulator config, seed script, and docs (firebase-leaderboards.md, leaderboard-env-migration.md, AYO.md) Utils/tests: UTC date-bucket helper (retained as metadata) with Vitest coverage. Reviewed-on: https://git.ayo.run/ayo/mnswpr/pulls/1 Co-authored-by: Ayo Co-committed-by: Ayo --- .firebaserc | 7 + .gitignore | 18 +- AYO.md | 92 +++++++ README.md | 16 ++ app/.env.development | 23 ++ app/.env.example | 21 ++ app/index.html | 7 + app/legends.html | 101 ++++++++ app/main.css | 20 ++ app/main.js | 88 ++++--- app/modules/leader-board/leader-board.js | 176 ------------- app/modules/nickname/nickname.js | 100 ++++++++ app/package.json | 4 +- app/vite.config.js | 14 ++ docs/firebase-leaderboards.md | 216 ++++++++++++++++ docs/leaderboard-env-migration.md | 160 ++++++++++++ eslint.config.js | 6 + firebase.json | 15 ++ firestore.indexes.json | 5 + firestore.rules | 79 ++++++ leaderboard/CONFIGURATION.md | 234 ++++++++++++++++++ leaderboard/README.md | 248 +++++++++++++++++++ leaderboard/adapters/firebase.js | 75 ++++++ leaderboard/adapters/supabase.js | 79 ++++++ leaderboard/leader-board.js | 302 +++++++++++++++++++++++ leaderboard/leaderboard-element.js | 164 ++++++++++++ leaderboard/package.json | 39 +++ leaderboard/vite.config.js | 15 ++ package.json | 2 + pnpm-lock.yaml | 20 ++ pnpm-workspace.yaml | 2 + scripts/export-legends.js | 136 ++++++++++ scripts/seed-dev-scores.js | 104 ++++++++ test/date-bucket.test.js | 41 +++ utils/date-bucket/date-bucket.js | 55 +++++ utils/index.js | 1 + 36 files changed, 2476 insertions(+), 209 deletions(-) create mode 100644 .firebaserc create mode 100644 AYO.md create mode 100644 app/.env.development create mode 100644 app/legends.html delete mode 100644 app/modules/leader-board/leader-board.js create mode 100644 app/modules/nickname/nickname.js create mode 100644 app/vite.config.js create mode 100644 docs/firebase-leaderboards.md create mode 100644 docs/leaderboard-env-migration.md create mode 100644 firebase.json create mode 100644 firestore.indexes.json create mode 100644 firestore.rules create mode 100644 leaderboard/CONFIGURATION.md create mode 100644 leaderboard/README.md create mode 100644 leaderboard/adapters/firebase.js create mode 100644 leaderboard/adapters/supabase.js create mode 100644 leaderboard/leader-board.js create mode 100644 leaderboard/leaderboard-element.js create mode 100644 leaderboard/package.json create mode 100644 leaderboard/vite.config.js create mode 100644 scripts/export-legends.js create mode 100644 scripts/seed-dev-scores.js create mode 100644 test/date-bucket.test.js create mode 100644 utils/date-bucket/date-bucket.js 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. #1kaelynaverymumm01.9
  2. +
  3. #2isji02.0
  4. +
  5. #3Anonymous02.9
  6. +
  7. #4jenjereren03.7
  8. +
  9. #5S-Davies03.8
  10. +
  11. #6kaelynaverymumm04.0
  12. +
  13. #7jenn04.1
  14. +
  15. #8jenjereren04.1
  16. +
  17. #9BIGTHICCDADDY!!04.4
  18. +
  19. #10XZ12362770304.5
  20. +
+
+
+

Normies

+
    +
  1. #1jenjereren29.1
  2. +
  3. #2jenjereren29.3
  4. +
  5. #3jenjereren30.1
  6. +
  7. #4jenjereren30.4
  8. +
  9. #5jenjereren30.5
  10. +
  11. #6jenjereren30.6
  12. +
  13. #7jenjereren30.8
  14. +
  15. #8jenjereren30.9
  16. +
  17. #9jenjereren31.4
  18. +
  19. #10jenjereren31.5
  20. +
+
+
+

Torment

+
    +
  1. #1jenjereren01:34.6
  2. +
  3. #2jenjereren01:36.6
  4. +
  5. #3jenjereren01:38.5
  6. +
  7. #4jenjereren01:38.9
  8. +
  9. #5jenjereren01:39.3
  10. +
  11. #6jenjereren01:39.4
  12. +
  13. #7jenjereren01:39.7
  14. +
  15. #8jenjereren01:45.1
  16. +
  17. #9jenjereren01:45.8
  18. +
  19. #10jenjereren01:46.1
  20. +
+
+
+

Hell

+
    +
  1. #1wasd01:13.6
  2. +
  3. #2wasd02:40.0
  4. +
  5. #3jenjereren02:57.3
  6. +
  7. #4jenjereren02:57.7
  8. +
  9. #5jenjereren03:02.8
  10. +
  11. #6jenjereren03:06.0
  12. +
  13. #7jenjereren03:06.4
  14. +
  15. #8jenjereren03:15.8
  16. +
  17. #9jenjereren03:16.4
  18. +
  19. #10jenjereren03:19.5
  20. +
+
+
+ +
+ + 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'