Compare commits

...

1 commit

Author SHA1 Message Date
Ayo
f019afd7c8 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
- <cozy-leaderboard> web component built on web-component-base: compose the UI
  in HTML, configure the backend once in JS
- Every user-facing string is configurable (labels, tooltips, empty/loading/
  error messages, anonymous name) so i18n lives in the app; README + CONFIGURATION

App (mnswpr.com):
- Compose the board declaratively in index.html via <cozy-leaderboard>
- Nickname + randomized greeting bar; score submission through the element
- Legends: the current all-time leaders frozen into a static /legends page
- Firebase config and leaderboard namespace via Vite env vars; emulator-first
  local dev (VITE_FIRESTORE_EMULATOR)

Firebase schema-as-code:
- firebase.json, .firebaserc, firestore.rules (public reads, create-only scores,
  no client updates/deletes, namespace-generalized), empty indexes (rolling
  windows need none)
- prod (mw-*) vs dev/test (mw-test-*) separation by collection namespace
- emulator config, seed script, and docs (firebase-leaderboards.md,
  leaderboard-env-migration.md, AYO.md)

Utils/tests: UTC date-bucket helper (retained as metadata) with Vitest coverage.
2026-07-03 13:39:44 +02:00
36 changed files with 2476 additions and 209 deletions

7
.firebaserc Normal file
View file

@ -0,0 +1,7 @@
{
"projects": {
"default": "secure-moment-188701",
"prod": "secure-moment-188701",
"dev": "secure-moment-188701"
}
}

18
.gitignore vendored
View file

@ -6,5 +6,19 @@ lib/README.md
.claude
*.*~
*.*swp
# Production / local Firebase config values are not committed — set them as
# Netlify env vars, or keep them in a local, gitignored env file. Dev values
# live in the committed app/.env.development (public, non-secret keys).
.env.production
.env.local
.env.*.local
# Firebase emulator artifacts
firebase-debug.log
firestore-debug.log
ui-debug.log
.emulator-data/
*~
*swo
*swp

92
AYO.md Normal file
View file

@ -0,0 +1,92 @@
# 🧹 AYO — Leaderboard Migration Checklist
Manual steps to finish the leaderboard rollout. **All code changes are already
applied** in the working tree — these are the external actions on Firebase and
Netlify that have to be done by hand.
> One project (`secure-moment-188701`), one difference between environments: the
> **collection namespace**. Production uses `mw-*`, dev/test uses `mw-test-*`.
> Full rationale: [`docs/leaderboard-env-migration.md`](docs/leaderboard-env-migration.md).
---
## ✅ Step 1 — Deploy Firestore rules + indexes
From the repo root:
```bash
npx firebase login
npx firebase deploy --only firestore:rules,firestore:indexes --project prod
```
- Uses committed [`firestore.rules`](firestore.rules) + [`firestore.indexes.json`](firestore.indexes.json).
- `prod``secure-moment-188701` (via [`.firebaserc`](.firebaserc)).
- ⚠️ Deploying **replaces** the console rules. The committed rules cover every
collection (`mw-*` and `mw-test-*`), so it's safe — but review first.
- No composite indexes to build — rolling windows (`time_stamp >=`) and all-time
(`orderBy score`) use Firestore's automatic single-field indexes.
## ✅ Step 2 — Set Netlify environment variables
In the Netlify site settings, add:
| Variable | Value |
| --- | --- |
| `VITE_FIREBASE_API_KEY` … (all 8) | **same as [`app/.env.development`](app/.env.development)** (same project) |
| `VITE_LB_NAMESPACE` | **`mw`** ← makes production use the `mw-*` collections |
> Local dev already uses `mw-test` via the committed `.env.development` — nothing
> to do there.
## ✅ Step 3 — (Optional) Seed the test config doc
Create `mw-test-config/configuration` in Firestore with the same `passingStatus`
and `message` as the prod `mw-config/configuration`.
Skip it and the test board still works — the default qualifier just accepts all
wins in test.
## ✅ Step 4 — Seed the dev database with sample scores
> ⚠️ **Must run _after_ Step 1.** The seed writes to `mw-test-scores`, which is
> only allowed once the generalized rules are deployed — otherwise every write
> returns `permission-denied`. (No indexes to wait on — the windows use
> automatic single-field indexes.)
Populate the dev boards so they aren't empty while developing:
```bash
(cd app && node ../scripts/seed-dev-scores.js)
```
- Uses [`scripts/seed-dev-scores.js`](scripts/seed-dev-scores.js) — ~12 sample
scores per level, timestamps spread across today / this week / this month /
older so **all four tabs** populate.
- Dev-only and idempotent-ish (re-running just adds more rows); it never touches
the production `mw-*` collections.
> 💡 **Local dev uses the emulator by default — this cloud seed is optional.**
> `pnpm dev` points at the local **Firestore emulator** (needs a JDK): run
> `pnpm emulators` + `pnpm seed:emulator` and you're set — no deploy, no cloud.
> The cloud seed above is only needed for a hosted/preview environment. To opt
> out of the emulator, set `VITE_FIRESTORE_EMULATOR=` empty in `app/.env.local`.
> See [`docs/firebase-leaderboards.md`](docs/firebase-leaderboards.md#local-firestore-emulator-default-for-local-dev).
## ✅ Step 5 — Verify
| Environment | How | Expected |
| --- | --- | --- |
| **Production** (`mw`) | Win a game on the live site | Score shows; doc in `mw-scores/{level}/games` |
| **Local dev** (`mw-test`) | `pnpm dev` (after Step 4) | Board shows sample scores; winning adds to `mw-test-scores/{level}/games`; prod `mw-scores` untouched |
| **Rules** | Read both boards | Reads succeed; a malformed write is rejected |
---
## 📌 Still open (not blocking)
- **Nothing is committed yet** — all changes are in the working tree.
- **`scripts/export-legends.js`** still hard-codes the (dev = prod) Firebase keys
from the one-off Legends export. It's identical to `app/.env.development`; can
be de-duped to read from the env file on request.
- **Legends** is already frozen into static HTML ([`app/legends.html`](app/legends.html))
— no action needed.

View file

@ -64,6 +64,22 @@ pnpm build # build the website
pnpm build:lib # build the publishable library
```
### Leaderboard (local Firestore emulator)
The leader board is backed by [Google Firestore](https://firebase.google.com). For local development the app talks to the **Firestore emulator** by default — fully local, no cloud, no deploy. The flag `VITE_FIRESTORE_EMULATOR=1` is already set in `app/.env.development`.
You need a **JDK 21+** installed (the emulator runs on Java; `firebase-tools` itself is fetched on demand via `npx`). Then, in two terminals:
```bash
pnpm emulators # terminal 1 — start the Firestore emulator on :8080 (+ UI)
pnpm seed:emulator # terminal 2, once — fill it with sample scores
pnpm dev # terminal 2 — run the app against the emulator
```
If the emulator isn't running, the board simply shows *"unavailable"* (a refused connection). To skip the emulator — for quick UI-only work, or if you don't have a JDK — set `VITE_FIRESTORE_EMULATOR=` (empty) in a local, gitignored `app/.env.local`; the app then uses the cloud `mw-test` namespace instead.
See [`docs/firebase-leaderboards.md`](./docs/firebase-leaderboards.md) for the full data model, security rules, environments, and deployment.
## Contributing
Contributions are welcome! See [`AGENTS.md`](./AGENTS.md) for the architecture, conventions, and release workflow before opening a pull request.

23
app/.env.development Normal file
View file

@ -0,0 +1,23 @@
# Development Firebase config (project: secure-moment-188701).
# These keys are public, not secrets — access is governed by firestore.rules —
# so this file is committed. Used by `pnpm dev`.
# Production values are supplied as Netlify env vars at build time (see
# docs/firebase-leaderboards.md); never put production values here.
VITE_FIREBASE_API_KEY=AIzaSyCTi_5Sm5dHFNf0d_Gn0MNWmlGheFBf6MQ
VITE_FIREBASE_AUTH_DOMAIN=moment-188701.firebaseapp.com
VITE_FIREBASE_DATABASE_URL=https://moment-188701.firebaseio.com
VITE_FIREBASE_PROJECT_ID=secure-moment-188701
VITE_FIREBASE_STORAGE_BUCKET=secure-moment-188701.firebasestorage.app
VITE_FIREBASE_MESSAGING_SENDER_ID=113827947104
VITE_FIREBASE_APP_ID=1:113827947104:web:b176f746d8358302c51905
VITE_FIREBASE_MEASUREMENT_ID=G-LZRDY0TG46
# Leaderboard collection namespace. Dev/test uses a separate set of collections
# (mw-test-scores / mw-test-all / mw-test-config) in the SAME Firebase project,
# so local play never pollutes the production board. Production sets this to `mw`.
VITE_LB_NAMESPACE=mw-test
# Local dev runs against the Firestore emulator by default (no cloud, no deploy).
# Start it with `pnpm emulators` and fill it via `pnpm seed:emulator`.
# To use the cloud `mw-test` namespace instead, set this empty in app/.env.local.
VITE_FIRESTORE_EMULATOR=1

View file

@ -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=

View file

@ -31,9 +31,16 @@
</head>
<body>
<div id="body-wrapper">
<div id="greeting"></div>
<div id="app">
Please use Chrome or Firefox.
</div>
<cozy-leaderboard category="beginner" title="Best Times" format="time" score-order="asc"></cozy-leaderboard>
<div id="legends-link">
<a href="/legends.html">🏆 Legends</a>
</div>
</div>
<script type="module" src="./main.js"></script>
</body>

101
app/legends.html Normal file
View file

@ -0,0 +1,101 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<meta name="Description" content="Minesweeper Legends — Epic pre-version-one top scores." />
<title>Minesweeper Legends 🏆</title>
<link rel="shortcut icon" type="image/png" href="/favicon.ico" />
<link rel="stylesheet" href="./main.css" />
<style>
.legends section { max-width: 270px; margin: 0 auto 30px; }
.legends h3 { border-bottom: 1px solid #c0c0c0; padding-bottom: 10px; }
.legends ol { list-style: none; padding: 0; margin: 0; text-align: left; }
.legends li { display: flex; }
.legends .name {
flex: 1;
padding: 0 5px;
font-weight: bold;
font-style: italic;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
</head>
<body>
<!-- Generated by scripts/export-legends.js — do not edit by hand. -->
<div id="body-wrapper">
<h1>🏆 Legends</h1>
<p><em>Epic scores — the top times set before version 1.0.</em></p>
<div class="legends">
<section>
<h3>Noobs</h3>
<ol>
<li><span class="rank">#1</span><span class="name" title="kaelynaverymumm">kaelynaverymumm</span><span class="time">01.9</span></li>
<li><span class="rank">#2</span><span class="name" title="isji">isji</span><span class="time">02.0</span></li>
<li><span class="rank">#3</span><span class="name" title="Anonymous">Anonymous</span><span class="time">02.9</span></li>
<li><span class="rank">#4</span><span class="name" title="jenjereren">jenjereren</span><span class="time">03.7</span></li>
<li><span class="rank">#5</span><span class="name" title="S-Davies">S-Davies</span><span class="time">03.8</span></li>
<li><span class="rank">#6</span><span class="name" title="kaelynaverymumm">kaelynaverymumm</span><span class="time">04.0</span></li>
<li><span class="rank">#7</span><span class="name" title="jenn">jenn</span><span class="time">04.1</span></li>
<li><span class="rank">#8</span><span class="name" title="jenjereren">jenjereren</span><span class="time">04.1</span></li>
<li><span class="rank">#9</span><span class="name" title="BIGTHICCDADDY!!">BIGTHICCDADDY!!</span><span class="time">04.4</span></li>
<li><span class="rank">#10</span><span class="name" title="XZ123627703">XZ123627703</span><span class="time">04.5</span></li>
</ol>
</section>
<section>
<h3>Normies</h3>
<ol>
<li><span class="rank">#1</span><span class="name" title="jenjereren">jenjereren</span><span class="time">29.1</span></li>
<li><span class="rank">#2</span><span class="name" title="jenjereren">jenjereren</span><span class="time">29.3</span></li>
<li><span class="rank">#3</span><span class="name" title="jenjereren">jenjereren</span><span class="time">30.1</span></li>
<li><span class="rank">#4</span><span class="name" title="jenjereren">jenjereren</span><span class="time">30.4</span></li>
<li><span class="rank">#5</span><span class="name" title="jenjereren">jenjereren</span><span class="time">30.5</span></li>
<li><span class="rank">#6</span><span class="name" title="jenjereren">jenjereren</span><span class="time">30.6</span></li>
<li><span class="rank">#7</span><span class="name" title="jenjereren">jenjereren</span><span class="time">30.8</span></li>
<li><span class="rank">#8</span><span class="name" title="jenjereren">jenjereren</span><span class="time">30.9</span></li>
<li><span class="rank">#9</span><span class="name" title="jenjereren">jenjereren</span><span class="time">31.4</span></li>
<li><span class="rank">#10</span><span class="name" title="jenjereren">jenjereren</span><span class="time">31.5</span></li>
</ol>
</section>
<section>
<h3>Torment</h3>
<ol>
<li><span class="rank">#1</span><span class="name" title="jenjereren">jenjereren</span><span class="time">01:34.6</span></li>
<li><span class="rank">#2</span><span class="name" title="jenjereren">jenjereren</span><span class="time">01:36.6</span></li>
<li><span class="rank">#3</span><span class="name" title="jenjereren">jenjereren</span><span class="time">01:38.5</span></li>
<li><span class="rank">#4</span><span class="name" title="jenjereren">jenjereren</span><span class="time">01:38.9</span></li>
<li><span class="rank">#5</span><span class="name" title="jenjereren">jenjereren</span><span class="time">01:39.3</span></li>
<li><span class="rank">#6</span><span class="name" title="jenjereren">jenjereren</span><span class="time">01:39.4</span></li>
<li><span class="rank">#7</span><span class="name" title="jenjereren">jenjereren</span><span class="time">01:39.7</span></li>
<li><span class="rank">#8</span><span class="name" title="jenjereren">jenjereren</span><span class="time">01:45.1</span></li>
<li><span class="rank">#9</span><span class="name" title="jenjereren">jenjereren</span><span class="time">01:45.8</span></li>
<li><span class="rank">#10</span><span class="name" title="jenjereren">jenjereren</span><span class="time">01:46.1</span></li>
</ol>
</section>
<section>
<h3>Hell</h3>
<ol>
<li><span class="rank">#1</span><span class="name" title="wasd">wasd</span><span class="time">01:13.6</span></li>
<li><span class="rank">#2</span><span class="name" title="wasd">wasd</span><span class="time">02:40.0</span></li>
<li><span class="rank">#3</span><span class="name" title="jenjereren">jenjereren</span><span class="time">02:57.3</span></li>
<li><span class="rank">#4</span><span class="name" title="jenjereren">jenjereren</span><span class="time">02:57.7</span></li>
<li><span class="rank">#5</span><span class="name" title="jenjereren">jenjereren</span><span class="time">03:02.8</span></li>
<li><span class="rank">#6</span><span class="name" title="jenjereren">jenjereren</span><span class="time">03:06.0</span></li>
<li><span class="rank">#7</span><span class="name" title="jenjereren">jenjereren</span><span class="time">03:06.4</span></li>
<li><span class="rank">#8</span><span class="name" title="jenjereren">jenjereren</span><span class="time">03:15.8</span></li>
<li><span class="rank">#9</span><span class="name" title="jenjereren">jenjereren</span><span class="time">03:16.4</span></li>
<li><span class="rank">#10</span><span class="name" title="jenjereren">jenjereren</span><span class="time">03:19.5</span></li>
</ol>
</section>
</div>
<div id="legends-link">
<a href="/">← Back to the game</a>
</div>
</div>
</body>
</html>

View file

@ -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;
}

View file

@ -1,45 +1,73 @@
import mnswpr from '@ayo-run/mnswpr/mnswpr.js'
import '@ayo-run/mnswpr/mnswpr.css'
import * as pkg from '@ayo-run/mnswpr/package.json'
import { LoadingService } from '../utils/'
import { LeaderBoardService } from './modules/leader-board/leader-board.js'
import { configureLeaderboard } from '@cozy-games/leaderboard/leaderboard-element.js'
import { FirebaseAdapter } from '@cozy-games/leaderboard/adapters/firebase.js'
import { NicknameService } from './modules/nickname/nickname.js'
import { UserService } from './modules/user/user.js'
const leaderBoardService = new LeaderBoardService()
const loadingService = new LoadingService()
// Firebase config comes from Vite env vars so dev and production point at
// different databases: dev values live in app/.env.development (committed —
// these keys are public, not secrets, and access is governed by
// firestore.rules); production values are supplied as Netlify env vars.
// https://stackoverflow.com/questions/37482366/is-it-safe-to-expose-firebase-apikey-to-the-public/37484053#37484053
const firebaseConfig = {
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
databaseURL: import.meta.env.VITE_FIREBASE_DATABASE_URL,
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
appId: import.meta.env.VITE_FIREBASE_APP_ID,
measurementId: import.meta.env.VITE_FIREBASE_MEASUREMENT_ID
}
const user = new UserService()
const nickname = new NicknameService()
// The leaderboard UI is composed declaratively as <cozy-leaderboard> in
// index.html; here we only wire its backend once. Namespace selects prod (`mw`)
// vs test (`mw-test`) collections in the same Firebase project, defaulting to
// test so a missing env var can never write into the production board.
// Local dev runs against the Firestore emulator by default (VITE_FIRESTORE_EMULATOR
// in app/.env.development); production builds always use real Firestore.
const useEmulator = ['1', 'true', 'yes'].includes(String(import.meta.env.VITE_FIRESTORE_EMULATOR))
configureLeaderboard({
adapter: new FirebaseAdapter({
firebaseConfig,
namespace: import.meta.env.VITE_LB_NAMESPACE || 'mw-test',
emulator: useEmulator ? { host: '127.0.0.1', port: 8080 } : undefined
})
})
const version = import.meta.env.MODE === 'development'
? 'dev'
: pkg.version
const initializeGameBoard = async (level) => {
const prevousLeaderBoard = document.getElementById('leaderboard')
const prevousLoadingWrapper = document.getElementById('loading-wrapper')
prevousLoadingWrapper?.remove()
const loadingWrapper = document.createElement('div')
loadingWrapper.id = 'loading-wrapper'
loadingService.addLoading(loadingWrapper)
// Ask for a nickname on first visit and show the greeting bar.
nickname.ensure()
const greeting = document.getElementById('greeting')
if (greeting) nickname.render(greeting)
const appElement = document.getElementById('app')
if (prevousLeaderBoard){
const parent = prevousLeaderBoard.parentNode
parent.replaceChild(loadingWrapper, prevousLeaderBoard)
}else{
appElement.append(loadingWrapper)
}
const leaderBoardWrapper = await leaderBoardService.update(level.id, `Best Times (${level.name})`)
leaderBoardWrapper.id = 'leaderboard'
appElement.replaceChild(leaderBoardWrapper, loadingWrapper)
}
const sendGameResult = (game) => {
leaderBoardService.send(game, 'time')
}
const board = document.querySelector('cozy-leaderboard')
const game = new mnswpr('app', version, {
levelChanged: (level) => initializeGameBoard(level),
gameDone: (game) => sendGameResult(game)
// Point the board at the current level; the element re-renders reactively and
// keeps the selected duration tab.
levelChanged: (level) => {
board.setAttribute('title', `Best Times (${level.name})`)
board.setAttribute('category', level.id)
},
// Submit the finished game through the element's service.
gameDone: (game) => board.submit({
name: nickname.get(),
playerId: user.browserId,
score: game.time,
category: game.level,
time_stamp: game.time_stamp,
status: game.status,
meta: { isMobile: game.isMobile }
})
})
game.initialize()

View file

@ -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.')
}
}
}

View file

@ -0,0 +1,100 @@
import { StorageService } from '../../../utils/index.js'
const NICK_KEY = 'nickname'
const MAX_LENGTH = 24
/**
* A few friendly greetings; `{nick}` is replaced with the player's nickname.
*/
const GREETINGS = [
'Welcome back, {nick}!',
'Good to see you, {nick}!',
'Hey {nick}, ready to sweep?',
'Let\'s go, {nick}!',
'Happy sweeping, {nick}!',
'Nice to have you, {nick}!'
]
/**
* Owns the player's display nickname: prompts for it once on first visit,
* persists it in localStorage, and renders a friendly greeting bar. The
* leaderboard package stays name-agnostic the app passes this nickname in.
*/
export class NicknameService {
constructor() {
this.storage = new StorageService()
}
/**
* The stored nickname, or 'Anonymous' if none has been set yet.
* @returns {String}
*/
get() {
return this.storage.getFromLocal(NICK_KEY) || 'Anonymous'
}
/**
* Prompt for a nickname the first time only used on page load.
*/
ensure() {
if (!this.storage.getFromLocal(NICK_KEY)) {
this.prompt()
}
}
/**
* Ask for a nickname and persist it (trimmed, capped). Cancelling keeps the
* current value. A failing/unsupported prompt must never break page load, so
* it is guarded the player just stays 'Anonymous'.
*/
prompt() {
let next
try {
next = window.prompt('Pick a nickname for the leaderboard', this.storage.getFromLocal(NICK_KEY) || '')
} catch {
return
}
if (next && next.trim()) {
this.storage.saveToLocal(NICK_KEY, next.trim().slice(0, MAX_LENGTH))
}
}
/**
* Render the greeting bar into a container. Clicking the nickname (or the
* pencil) re-prompts and re-renders in place.
* @param {HTMLElement} container
*/
render(container) {
if (!container) return
container.innerHTML = ''
const template = GREETINGS[Math.floor(Math.random() * GREETINGS.length)]
const [before, after] = template.split('{nick}')
const nameElement = document.createElement('span')
nameElement.innerText = this.get()
nameElement.style.fontWeight = 'bold'
nameElement.style.fontStyle = 'italic'
nameElement.style.cursor = 'pointer'
nameElement.style.textDecoration = 'underline'
nameElement.style.textDecorationColor = 'orange'
nameElement.setAttribute('title', 'Click to change your nickname')
const icon = document.createElement('span')
icon.innerText = ' ✏️'
icon.style.cursor = 'pointer'
icon.style.fontSize = '0.8em'
icon.style.opacity = '0.6'
icon.setAttribute('title', 'Click to change your nickname')
const change = () => {
this.prompt()
this.render(container)
}
nameElement.onclick = change
icon.onclick = change
container.append(document.createTextNode(before), nameElement, icon, document.createTextNode(after))
}
}

View file

@ -11,7 +11,9 @@
},
"devDependencies": {
"@ayo-run/mnswpr": "workspace:*",
"firebase": "^12.11.0"
"@cozy-games/leaderboard": "workspace:*",
"firebase": "^12.11.0",
"web-component-base": "^4.1.2"
},
"author": "Ayo Ayco"
}

14
app/vite.config.js Normal file
View file

@ -0,0 +1,14 @@
import { resolve } from 'node:path'
import { defineConfig } from 'vite'
// Multi-page build: the game (index.html) and the frozen Legends page.
export default defineConfig({
build: {
rollupOptions: {
input: {
main: resolve(import.meta.dirname, 'index.html'),
legends: resolve(import.meta.dirname, 'legends.html')
}
}
}
})

View file

@ -0,0 +1,216 @@
# Firebase & Leaderboards
How the mnswpr.com leaderboards are stored, how to deploy the schema, and what
still has to be touched in the Firebase Console.
## Overview
Leaderboards are powered by Firestore (`firebase/firestore/lite`) through the
reusable, game-agnostic [`@cozy-games/leaderboard`](../leaderboard/leader-board.js)
package. The app wires minesweeper's specifics (finish-time as the `score`,
ascending sort, time formatting) in [`app/main.js`](../app/main.js).
The board offers four time windows — **Today** (default), **Week**, **Month**,
**All Time** — selected by tabs. Each played game that qualifies is written to a
new, queryable collection; the four tabs are just different filters over it.
### Data model
Collections (all under namespace `mw`):
| Path | Purpose | Access |
| --- | --- | --- |
| `mw-scores/{level}/games/{id}` | Ranked entries for the live boards | public read, validated create-only |
| `mw-all/{browserId}/games/{session}` | Per-browser archive of every game | read/write |
| `mw-config/configuration` | Server config (`passingStatus`, `message`) | public read, no client write |
| `mw-leaders/{level}/games/{id}` | **Legacy** all-time board, now frozen into Legends | read-only |
Each `mw-scores` entry:
```js
{
name, // display nickname
playerId, // browser fingerprint (UserService)
score, // ranked value — minesweeper: finish time in ms
category, // segmentation — minesweeper: level id
time_stamp, // when the game finished — drives the rolling time windows
day, // "2026-07-03" (UTC calendar day) — retained, not queried
week, // "2026-W27" (ISO week) — retained, not queried
month, // "2026-07" (UTC calendar month) — retained, not queried
meta // optional extras — minesweeper: { isMobile }
}
```
### How the time windows work
The windows are **rolling**, based on `time_stamp`, and strictly nested:
- Today → `where('time_stamp', '>=', now - 24h)` — last 24 hours
- Week → `where('time_stamp', '>=', now - 7d)` — last 7 days
- Month → `where('time_stamp', '>=', now - 30d)` — last 30 days
- All Time → no time filter, `orderBy('score')` + `limit(10)`
Because Firestore requires the inequality field (`time_stamp`) to sort first, the
adapter fetches the in-window rows and ranks them by `score` client-side (Supabase
does it server-side). A score in the 7-day window is always in the 30-day window,
so a player can be top-10 for the week but rank out of a busier month — that's
competition, not a boundary quirk.
> The `day`/`week`/`month` bucket keys are still written to each entry (and
> validated by the rules) but are **no longer used for querying** — they're kept
> as denormalized metadata and for a possible calendar-bucket mode.
## Schema as code
The Firestore schema (security rules + indexes) lives in the repo:
- [`firebase.json`](../firebase.json) — points at the rules and indexes files.
- [`.firebaserc`](../.firebaserc) — project aliases: `prod` (default, live site)
and `dev` (`secure-moment-188701`).
- [`firestore.rules`](../firestore.rules) — access + validation rules.
- [`firestore.indexes.json`](../firestore.indexes.json) — **empty**: rolling
windows (`time_stamp >=`) and all-time (`orderBy('score')`) use Firestore's
automatic single-field indexes, so no composite indexes are needed.
## Environments (dev vs production)
There is **one Firebase project** (`secure-moment-188701`). Production and test
data are separated not by project but by **collection namespace**, chosen with
the `VITE_LB_NAMESPACE` env var read in [`app/main.js`](../app/main.js):
| Environment | `VITE_LB_NAMESPACE` | Collections |
| --- | --- | --- |
| Production (Netlify) | `mw` | `mw-scores`, `mw-all`, `mw-config` |
| Local `pnpm dev` / previews | `mw-test` | `mw-test-scores`, `mw-test-all`, `mw-test-config` |
The Firebase web config (`VITE_FIREBASE_*`) is **identical** in both — same
project — so the only difference is the namespace. `app/main.js` defaults to the
**test** namespace, so a missing/misconfigured var can never write into the
production board; production must set `VITE_LB_NAMESPACE=mw` explicitly.
Dev config lives in the committed [`app/.env.development`](../app/.env.development)
(the keys are public). Production sets `VITE_FIREBASE_*` **and**
`VITE_LB_NAMESPACE=mw` as Netlify environment variables. `.env.production` and
`.env*.local` stay gitignored.
> The full rationale and rollout steps are in
> [leaderboard-env-migration.md](leaderboard-env-migration.md).
### One-time CLI setup
The Firebase project already exists — no need to create it.
```bash
npx firebase login
```
### Deploy rules + indexes
Deploys go to **production** by default. Target a project explicitly with
`--project`:
```bash
# production (default alias 'prod')
npx firebase deploy --only firestore:rules,firestore:indexes --project prod
# development database
npx firebase deploy --only firestore:rules,firestore:indexes --project dev
```
> ⚠️ Deploying **replaces** whatever rules currently live in the Console. The
> committed [`firestore.rules`](../firestore.rules) is written to cover every
> collection the app uses, so a deploy is safe — but review it first.
No composite indexes are required — the rolling-window and all-time queries use
Firestore's automatic single-field indexes.
## Local Firestore emulator (default for local dev)
Local development runs against the **Firebase Local Emulator Suite** by default —
no cloud, no deploy, no auth, no `permission-denied` — and it loads the committed
[`firestore.rules`](../firestore.rules) and [`firestore.indexes.json`](../firestore.indexes.json)
locally (so you validate them before deploying). The flag
`VITE_FIRESTORE_EMULATOR=1` is set in [`app/.env.development`](../app/.env.development).
> Prerequisite: the Firestore emulator is a Java process, so you need a JDK
> (11+) installed. `firebase-tools` is fetched on demand via `npx`.
Everyday dev loop:
```bash
pnpm emulators # terminal 1: Firestore emulator (+ Emulator UI) on :8080
pnpm seed:emulator # terminal 2, once: fill it with sample scores
pnpm dev # terminal 2: app runs against the local emulator
```
Wiring: `app/main.js` passes `{ emulator: { host, port } }` to `FirebaseAdapter`,
which calls `connectFirestoreEmulator`. If the emulator isn't running the board
just shows "unavailable" (a refused connection) — start it, or opt out.
**Opting out** (no Java, or you want the real cloud `mw-test`): create
`app/.env.local` with `VITE_FIRESTORE_EMULATOR=` (empty) — `.env.local` overrides
`.env.development` and is gitignored.
The emulator is disposable — its data is gone on stop unless you use Firebase's
`--import`/`--export-on-exit`.
## Console fallback
Everything above is doable via the CLI. If you can't use it:
- **Indexes**: nothing to do — the queries use automatic single-field indexes.
(If you ever add a query that needs a composite index, Firestore prints a
console error with a direct link to create it.)
- **Rules**: edit them directly under **Firestore → Rules** in the Console
(paste from [`firestore.rules`](../firestore.rules)).
- **Server config**: `mw-config/configuration` (`passingStatus`, `message`) is
**always** managed by hand in the Console — it is read-only to clients and has
no code representation. `passingStatus` is the `status` value that makes a game
eligible for the board (minesweeper: `"win"`).
## Legends (frozen hall of fame)
The old all-time leaders are preserved as a **fully-rendered static page**
[`app/legends.html`](../app/legends.html). The records are baked straight into the
HTML with times pre-formatted; there is **no JavaScript and no Firebase** at page
load. The data never changes.
The page is generated by [`scripts/export-legends.js`](../scripts/export-legends.js),
which snapshots `mw-leaders/{level}/games` and writes the whole `legends.html`.
It only needs re-running if you ever want to regenerate. Because `firebase` is an
app-workspace dependency, run it so Node can resolve it:
```bash
(cd app && node ../scripts/export-legends.js)
```
The Firebase config for this one-off is hardcoded in the script (public keys).
## Reusing the leaderboard for another game
`@cozy-games/leaderboard` knows nothing about minesweeper **or** Firebase — the
ranked value is a generic `score` and all storage goes through an injected
adapter. Another game supplies its own adapter and config:
```js
import { LeaderBoardService } from '@cozy-games/leaderboard/leader-board.js'
import { FirebaseAdapter } from '@cozy-games/leaderboard/adapters/firebase.js'
new LeaderBoardService({
adapter: new FirebaseAdapter({ firebaseConfig, namespace: 'yourgame' }),
scoreOrder: 'desc', // 'desc' when higher is better (points); 'asc' for time
formatScore: v => `${v} pts`,
qualifies: entry => true // default: server passingStatus vs entry.status
})
```
To run on **Supabase** instead, swap in `SupabaseAdapter` — nothing else in the
game changes. The adapter interface and the Supabase table/SQL schema are
documented in [`leaderboard/README.md`](../leaderboard/README.md).
Then submit `{ name, playerId, score, category, time_stamp, status?, meta? }`
and render with `render(category, title, duration)`.
**No composite indexes needed.** Rolling windows filter on `time_stamp` and
all-time sorts by `score` — both use Firestore's automatic single-field indexes,
in either sort direction, so a `desc`-scored game needs no extra index work.

View file

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

View file

@ -49,5 +49,11 @@ export default defineConfig([
}]
}
},
{
files: ['scripts/**/*.js'],
languageOptions: {
globals: globals.node
}
},
globalIgnores(['**/dist'])
])

15
firebase.json Normal file
View file

@ -0,0 +1,15 @@
{
"firestore": {
"rules": "firestore.rules",
"indexes": "firestore.indexes.json"
},
"emulators": {
"firestore": {
"port": 8080
},
"ui": {
"enabled": true
},
"singleProjectMode": true
}
}

5
firestore.indexes.json Normal file
View file

@ -0,0 +1,5 @@
{
"comment": "Time windows are rolling (time_stamp >= cutoff) and all-time sorts by score — both use Firestore's automatic single-field indexes, so no composite indexes are required.",
"indexes": [],
"fieldOverrides": []
}

79
firestore.rules Normal file
View file

@ -0,0 +1,79 @@
rules_version = '2'
// Security rules for the mnswpr leaderboard. Firestore is schemaless, so these
// rules are the enforced schema: public reads for the boards, validated
// create-only writes for scores, no client writes to server config.
//
// SECURITY MODEL: the Firebase web config is public (it ships to every browser),
// so these rules — not secrecy of the config or the codebase — are what protect
// the data. No collection allows a client to UPDATE or DELETE existing docs, so
// no client (with or without the source) can wipe the production boards. The
// powerful vectors (Admin SDK / service-account keys, and IAM console access)
// bypass rules entirely — keep those out of the repo and locked down in GCP.
// See docs/firebase-leaderboards.md ("Protecting production data").
//
// Rules match by namespace SUFFIX (regex) rather than a literal prefix, so the
// same set covers production (`mw-*`) and test (`mw-test-*`) collections in the
// one project — and any future namespace. Firestore ORs all matching rules, and
// `{ns}-scores`, `{ns}-all` and `{ns}-leaders` share a 4-segment shape, so each
// block is guarded by a regex on the captured collection name.
//
// Deploy with (see docs/firebase-leaderboards.md):
// npx firebase deploy --only firestore:rules --project prod
service cloud.firestore {
match /databases/{database}/documents {
// Ranked, time-windowed scores — public read, validated create-only.
match /{scores}/{category}/games/{game} {
allow read: if scores.matches('mw(-[a-z]+)?-scores');
allow create: if scores.matches('mw(-[a-z]+)?-scores') && isValidScore(category);
allow update, delete: if false;
}
// Legacy all-time collection, frozen into the static Legends page — read-only.
match /{leaders}/{category}/games/{game} {
allow read: if leaders.matches('mw(-[a-z]+)?-leaders');
allow write: if false;
}
// Per-browser personal archive. Write-only from the client (the app never
// reads it back), append/merge only, and NEVER deletable. This is the only
// client-writable collection, so denying delete/read here removes the last
// way a client could destroy or harvest data.
match /{all}/{browserId}/games/{session} {
allow read: if false;
allow create, update: if all.matches('mw(-[a-z]+)?-all');
allow delete: if false;
}
// Server config (passingStatus, message) — public read, no client write.
match /{config}/{document} {
allow read: if config.matches('mw(-[a-z]+)?-config');
allow write: if false;
}
// Defense-in-depth: deny anything not matched above. Firestore already
// default-denies unmatched paths; this makes the intent explicit and is a
// backstop if a future rule is added carelessly. (Allows are OR-ed, so this
// never revokes the specific grants above.)
match /{document=**} {
allow read, write: if false;
}
// A well-formed score: numeric score, bounded name, string bucket keys, and
// a category matching the path. Rules have no clock, so they can't verify
// the buckets are correctly derived from time_stamp — that stays the client's
// job. Extra fields (playerId, time_stamp, meta) are allowed.
function isValidScore(category) {
let d = request.resource.data;
return d.score is number
&& d.score >= 0
&& d.category == category
&& d.name is string
&& d.name.size() <= 24
&& d.day is string
&& d.week is string
&& d.month is string;
}
}
}

View file

@ -0,0 +1,234 @@
# Leaderboard configuration reference
Complete reference for configuring `@cozy-games/leaderboard`. There are two ways
to use it — a **JS API** and a **web component** — and they share the same option
set. The element simply forwards options to the service under the hood.
- [Options at a glance](#options-at-a-glance)
- [JS API — `LeaderBoardService`](#js-api--leaderboardservice)
- [Web component — `<cozy-leaderboard>`](#web-component--cozy-leaderboard)
- [Adapters (backend config)](#adapters-backend-config)
- [Localization (i18n)](#localization-i18n)
- [Score entry shape](#score-entry-shape)
- [Where config is read / precedence](#where-config-is-read--precedence)
---
## Options at a glance
| option | type | default | purpose |
| --- | --- | --- | --- |
| `adapter` | object | — (**required**) | storage backend — a `FirebaseAdapter`, `SupabaseAdapter`, or your own |
| `scoreOrder` | `'asc'` \| `'desc'` | `'asc'` | `asc` = lower is better (time); `desc` = higher is better (points) |
| `formatScore` | `(value) => string` | `String(value)` | how a score is displayed |
| `qualifies` | `(entry) => boolean` | server `passingStatus` vs `entry.status` | whether a submitted entry is ranked |
| `labels` | object | `{today,week,month,all}` English | tab labels, keyed by `today`/`week`/`month`/`all` |
| `tooltips` | object | `Last 24 hours` / `Last 7 days` / `Last 30 days` / `All time` | tab hover text, keyed by `today`/`week`/`month`/`all` |
| `emptyMessages` | `string[]` | built-in pool | messages for an empty window (one picked at random) |
| `loadingText` | string | `Loading…` | shown while a window loads |
| `errorText` | string | `Leaderboard unavailable right now.` | shown when a window fails to load |
| `anonymousName` | string | `Anonymous` | fallback display name for entries without one |
Every user-facing string (`labels`, `emptyMessages`, `loadingText`, `errorText`,
`anonymousName`) is an option — see [Localization](#localization-i18n).
---
## JS API — `LeaderBoardService`
```js
import { LeaderBoardService } from '@cozy-games/leaderboard/leader-board.js'
import { FirebaseAdapter } from '@cozy-games/leaderboard/adapters/firebase.js'
const service = new LeaderBoardService({
adapter: new FirebaseAdapter({ firebaseConfig, namespace: 'mw' }),
scoreOrder: 'asc',
formatScore: ms => prettyTime(ms)
})
```
### Methods
- **`render(category, title, duration?) → Promise<HTMLElement>`**
Builds the board (duration tabs + list) for `category` with heading `title`.
`duration` is one of `today` (default) · `week` · `month` · `all`. When omitted
on a re-render, the last-selected tab is kept. Append the returned element to
the page.
Windows are **rolling** and strictly nested: `today` = last 24h, `week` = last
7 days, `month` = last 30 days, `all` = everything — each shows the top scores
whose `time_stamp` falls in the window. Each tab's hover text (`tooltips`)
spells this out.
- **`submit(entry) → Promise`**
Records a finished game (archives it; ranks it if it `qualifies`). See
[Score entry shape](#score-entry-shape).
---
## Web component — `<cozy-leaderboard>`
Configure the backend once in JS, then compose the UI in HTML.
```html
<script type="module">
import { configureLeaderboard } from '@cozy-games/leaderboard/leaderboard-element.js'
import { FirebaseAdapter } from '@cozy-games/leaderboard/adapters/firebase.js'
configureLeaderboard({
adapter: new FirebaseAdapter({ firebaseConfig, namespace: 'mw' }),
scoreOrder: 'asc',
format: 'time'
})
</script>
<cozy-leaderboard category="beginner" title="Best Times" format="time"></cozy-leaderboard>
```
### `configureLeaderboard(options)`
Sets shared defaults for **every** `<cozy-leaderboard>` on the page. Accepts all
the [options above](#options-at-a-glance), plus `format` (a preset shorthand for
`formatScore` — see below). Call it once at startup. Calling it again re-renders
mounted elements.
### Attributes
| attribute | values | purpose |
| --- | --- | --- |
| `category` | string | which board to show (e.g. a difficulty/level id) |
| `title` | string | heading text above the tabs |
| `duration` | `today` \| `week` \| `month` \| `all` | initial tab (default `today`) |
| `score-order` | `asc` \| `desc` | overrides the shared `scoreOrder` |
| `format` | `time` \| `number` \| `plain` | score display preset (below) |
Attributes are reactive: change `category`/`title` at runtime and the board
re-renders, keeping the selected duration tab.
### `format` presets
| `format` | result |
| --- | --- |
| `time` | milliseconds → `mm:ss.t` (e.g. `01:34.6`) |
| `number` / `plain` / unset | `String(value)` |
For anything else, set a `formatScore` function (via `configureLeaderboard` or the
per-element property).
### Override properties & method
Set these JS properties on an element to override the shared config for that one
element (useful for multi-board or multi-backend pages):
`adapter`, `formatScore`, `qualifies`, `labels`, `emptyMessages`, `loadingText`,
`errorText`, `anonymousName`.
```js
const el = document.querySelector('cozy-leaderboard')
el.emptyMessages = ['¡Sé el primero!']
el.formatScore = pts => `${pts} pts`
```
- **`submit(entry)`** — submit a finished game through this element's service:
```js
el.submit({ name: 'Ayo', playerId: 'abc', score: 4200, category: 'beginner', time_stamp: new Date(), status: 'win' })
```
---
## Adapters (backend config)
### `FirebaseAdapter`
```js
import { FirebaseAdapter } from '@cozy-games/leaderboard/adapters/firebase.js'
new FirebaseAdapter({ firebaseConfig, namespace: 'mw', emulator })
```
| option | type | default | purpose |
| --- | --- | --- | --- |
| `firebaseConfig` | object | — | Firebase web config (public; access governed by rules) |
| `namespace` | string | `lb` | collection prefix → `{ns}-scores`, `{ns}-all`, `{ns}-config` |
| `emulator` | `{ host?, port? }` | — | point at a local Firestore emulator (dev only); host `127.0.0.1`, port `8080` |
Needs the `firebase` peer dependency. Rolling-window and all-time queries use
Firestore's automatic single-field indexes — no composite indexes to deploy.
### `SupabaseAdapter`
```js
import { createClient } from '@supabase/supabase-js'
import { SupabaseAdapter } from '@cozy-games/leaderboard/adapters/supabase.js'
new SupabaseAdapter({ client: createClient(url, anonKey), namespace: 'mw' })
```
| option | type | default | purpose |
| --- | --- | --- | --- |
| `client` | supabase-js client | — | you construct it (package takes no supabase dep) |
| `namespace` | string | `lb` | table prefix → `{ns}_scores`, `{ns}_archive`, `{ns}_config` |
See the [README](./README.md#supabase-postgres) for the SQL schema.
---
## Localization (i18n)
This package ships **English defaults only** — localization is your app's job.
Pass translated copy for every user-facing string:
```js
configureLeaderboard({
adapter,
labels: { today: 'Hoy', week: 'Semana', month: 'Mes', all: 'Histórico' },
emptyMessages: [
'¡Sé el primero en el marcador!',
'Aún no hay puntajes. ¡Reclama la cima!'
],
loadingText: 'Cargando…',
errorText: 'Marcador no disponible ahora.',
anonymousName: 'Anónimo'
})
```
The same keys work when constructing `LeaderBoardService` directly. To switch
languages at runtime, call `configureLeaderboard()` again with the new strings
(mounted elements re-render).
---
## Score entry shape
The object passed to `submit(entry)`:
```js
{
name, // string — display name (falls back to `anonymousName`)
playerId, // string — opaque id (e.g. a browser fingerprint)
score, // number — the ranked value (minesweeper: finish time in ms)
category, // string — which board (minesweeper: level id)
time_stamp, // Date — when the game finished (used to compute day/week/month)
status, // string, optional — read by the default `qualifies`
meta // object, optional — extra fields to store (e.g. { isMobile })
}
```
The package computes the `day`/`week`/`month` bucket keys from `time_stamp`
(UTC) before storing — you don't set those.
---
## Where config is read / precedence
- **JS API:** options are read when you construct `LeaderBoardService`.
- **Web component:** the element builds its service on first render (on connect),
reading — in order — its **own attribute/property**, then the shared
**`configureLeaderboard()`** value, then the **package default**:
```
per-element attribute/property > configureLeaderboard(...) > built-in default
```
The service is cached per element, so set overrides **before** the element
connects (or set the property and clear the element's `_svc` to force a
rebuild). `score-order` and `format` are read from attributes; the function/
array/string overrides are read from properties.

248
leaderboard/README.md Normal file
View file

@ -0,0 +1,248 @@
# @cozy-games/leaderboard
A generic, framework-free leaderboard with **Today / Week / Month / All Time**
time windows. It is game-agnostic (the ranked value is a plain `score`) and
**backend-agnostic**: all storage I/O goes through an injected *adapter*, so you
can run it on Firebase, Supabase, or anything else.
```
LeaderBoardService (core: windows, sorting, rendering, submit)
│ uses
Adapter ── FirebaseAdapter | SupabaseAdapter | your own
```
## Quick start
```js
import { LeaderBoardService } from '@cozy-games/leaderboard/leader-board.js'
import { FirebaseAdapter } from '@cozy-games/leaderboard/adapters/firebase.js'
const service = new LeaderBoardService({
adapter: new FirebaseAdapter({ firebaseConfig, namespace: 'mw' }),
scoreOrder: 'asc', // 'asc' = lower is better (time); 'desc' = points
formatScore: ms => prettyTime(ms) // how a score is displayed
})
// render into the page (Today tab by default)
document.body.append(await service.render('beginner', 'Best Times'))
// submit a finished game
service.submit({
name: 'Ayo',
playerId: 'browser-abc',
score: 4200,
category: 'beginner',
time_stamp: new Date(),
status: 'win', // optional; used by default qualifier
meta: { isMobile: false } // optional extras
})
```
### Service options
| option | required | meaning |
| --- | --- | --- |
| `adapter` | yes | storage backend (see below) |
| `scoreOrder` | no | `'asc'` (default) or `'desc'` |
| `formatScore` | no | `(value) => string`; defaults to `String(value)` |
| `qualifies` | no | `(entry) => boolean`; default ranks entries whose `status` matches the server config's `passingStatus` (all entries if none) |
| `labels` | no | override tab labels, keyed by `today`/`week`/`month`/`all` |
| `tooltips` | no | override tab hover text, keyed by `today`/`week`/`month`/`all` |
| `emptyMessages` | no | `string[]` shown when a window has no scores (one picked at random) |
| `loadingText` | no | text shown while a window loads (default `Loading…`) |
| `errorText` | no | text shown when a window fails to load |
| `anonymousName` | no | fallback display name for entries without one (default `Anonymous`) |
**Localization** lives with you, not this package: every user-facing string —
tab `labels`, `emptyMessages`, `loadingText`, `errorText`, `anonymousName` — is an
option, so you pass your translated copy (from `<cozy-leaderboard>` use it via
`configureLeaderboard({ ... })`). The package only ships English defaults.
📖 **Full reference:** every option, the web-component attributes/properties,
adapters, i18n, and precedence rules are documented in
[CONFIGURATION.md](./CONFIGURATION.md).
## Use it as a web component
Prefer to **compose the UI in your HTML** instead of wiring it in JavaScript? The
package ships a `<cozy-leaderboard>` custom element. Configure the backend once,
then drop the element anywhere in your markup — the board (duration tabs + list)
renders itself.
```html
<!-- 1. configure the backend once (the only JS you need) -->
<script type="module">
import { configureLeaderboard } from '@cozy-games/leaderboard/leaderboard-element.js'
import { FirebaseAdapter } from '@cozy-games/leaderboard/adapters/firebase.js'
configureLeaderboard({
adapter: new FirebaseAdapter({ firebaseConfig, namespace: 'mw' })
})
</script>
<!-- 2. compose the UI declaratively, anywhere -->
<cozy-leaderboard category="beginner" title="Best Times" format="time"></cozy-leaderboard>
```
No build step required — it works straight from a CDN too:
```html
<script type="module">
import { configureLeaderboard } from 'https://esm.sh/@cozy-games/leaderboard/leaderboard-element.js'
import { FirebaseAdapter } from 'https://esm.sh/@cozy-games/leaderboard/adapters/firebase.js'
configureLeaderboard({ adapter: new FirebaseAdapter({ firebaseConfig, namespace: 'mw' }) })
</script>
<cozy-leaderboard category="expert" title="Legends" format="time" score-order="asc"></cozy-leaderboard>
```
### Attributes
| attribute | meaning |
| --- | --- |
| `category` | which board to show (e.g. a difficulty/level id) |
| `title` | heading text above the tabs |
| `duration` | initial tab: `today` (default) · `week` · `month` · `all` |
| `score-order` | `asc` (lower is better, default) or `desc` (higher is better) |
| `format` | score display preset: `time` (ms → `mm:ss.t`), `number`, or `plain` |
Change `category`/`title` at runtime and the board re-renders reactively while
**keeping the selected duration tab** — e.g. `el.setAttribute('category', 'expert')`.
### Properties & methods
- `.adapter`, `.formatScore`, `.qualifies` — per-element overrides of the shared
`configureLeaderboard()` defaults (for advanced/multi-backend pages).
- `.submit(entry)` — submit a finished game through this element's service:
```js
document.querySelector('cozy-leaderboard').submit({
name: 'Ayo', playerId: 'browser-abc', score: 4200,
category: 'beginner', time_stamp: new Date(), status: 'win'
})
```
## Why a web component?
`<cozy-leaderboard>` is built on **[web-component-base](https://github.com/ayo-run/wcb)**
(WCB) — a zero-dependency, tiny base class for reactive custom elements
([webcomponent.io](https://webcomponent.io)). Shipping the leaderboard as a web
component means:
- **Framework-agnostic & native.** A custom element is part of the platform, so
it drops into React, Vue, Svelte, Angular, Astro, or a plain HTML file
identically — no per-framework wrappers.
- **Declarative composition.** You place the board where it belongs in your
markup and set attributes, instead of imperative `createElement`/`append`
plumbing in JS.
- **Reactive.** Change an attribute (`category`, `title`, …) and the element
updates itself — the reactivity model WCB is built around.
- **Encapsulated & reusable.** One tag, one contract; reuse it across pages and
projects without copy-pasting wiring.
- **No build step.** Works today in every modern browser, straight from a CDN —
no compilers, transpilers, or polyfills. That "just use the platform" ethos is
exactly what WCB is for.
Want to author your own custom elements this way? Check out
**[webcomponent.io](https://webcomponent.io)** and
**[web-component-base](https://github.com/ayo-run/wcb)**.
## Choosing a backend
### Firebase (Firestore)
```js
import { FirebaseAdapter } from '@cozy-games/leaderboard/adapters/firebase.js'
const adapter = new FirebaseAdapter({ firebaseConfig, namespace: 'mw' })
```
Needs the `firebase` peer dependency. Uses collections
`{ns}-scores/{category}/games`, `{ns}-all/{playerId}/games`,
`{ns}-config/configuration`. Time windows are **rolling** (`time_stamp >= cutoff`)
and all-time sorts by `score`, so only Firestore's automatic single-field indexes
are needed — no composite indexes to deploy.
For local development, pass `emulator` to run against the
[Firestore emulator](https://firebase.google.com/docs/emulator-suite) — no cloud,
no deploy:
```js
new FirebaseAdapter({ firebaseConfig, namespace: 'mw', emulator: { host: '127.0.0.1', port: 8080 } })
```
### Supabase (Postgres)
```js
import { createClient } from '@supabase/supabase-js'
import { SupabaseAdapter } from '@cozy-games/leaderboard/adapters/supabase.js'
const client = createClient(url, anonKey)
const adapter = new SupabaseAdapter({ client, namespace: 'mw' })
```
You construct the supabase client yourself (the package takes no supabase
dependency). Create these tables (namespace `mw` shown):
```sql
create table mw_scores (
id bigint generated always as identity primary key,
name text not null check (char_length(name) <= 24),
player_id text,
score numeric not null,
category text not null,
time_stamp timestamptz not null default now(),
day text not null, -- '2026-07-03'
week text not null, -- '2026-W27'
month text not null, -- '2026-07'
meta jsonb
);
-- rolling-window lookups (time_stamp >= cutoff) + top-N by score
create index on mw_scores (category, time_stamp);
create index on mw_scores (category, score);
create table mw_archive (
id bigint generated always as identity primary key,
player_id text,
score numeric,
category text,
time_stamp timestamptz not null default now(),
meta jsonb
);
create table mw_config (
id text primary key, -- 'configuration'
passingStatus text,
message text
);
-- public read on the boards, append-only inserts on scores
alter table mw_scores enable row level security;
create policy read_scores on mw_scores for select using (true);
create policy write_scores on mw_scores for insert with check (score is not null);
alter table mw_config enable row level security;
create policy read_config on mw_config for select using (true);
```
Note: the `day`/`week`/`month` columns are still written (kept as metadata) but
windows query by `time_stamp`. For a `desc` game, add `(category, score desc)`.
## Writing your own adapter
An adapter is any object implementing:
```ts
getConfig(): Promise<object | undefined>
listScores(q: {
category: string,
since: Date | null, // rolling cutoff (time_stamp >= since); null = All Time
order: 'asc' | 'desc',
limit: number
}): Promise<Array<{ name: string, score: number, ...}>>
addScore(category: string, entry: object): Promise<void>
archive?(entry: object): Promise<void> // optional personal history
```
`listScores` must return the top `limit` records for the window, best-first,
exposing at least `name` and `score`. The core builds `entry` and the query
descriptor; the adapter only performs raw reads/writes. That's the whole
contract — implement it against any store.

View file

@ -0,0 +1,75 @@
import { initializeApp } from 'firebase/app'
import {
getFirestore, connectFirestoreEmulator,
doc, getDoc, getDocs, setDoc, collection, query, where, orderBy, limit
} from 'firebase/firestore/lite'
/**
* Firestore storage adapter for LeaderBoardService. Requires the `firebase`
* peer dependency. Collections are namespaced: `{ns}-scores/{category}/games`,
* `{ns}-all/{playerId}/games`, and `{ns}-config/configuration`.
*/
export class FirebaseAdapter {
/**
* @param {Object} options
* @param {Object} options.firebaseConfig - Firebase app config (public; access governed by security rules)
* @param {String} [options.namespace] - collection prefix
* @param {{ host?: string, port?: number }} [options.emulator] - point at a local Firestore emulator (dev/test only)
*/
constructor(options = {}) {
this.namespace = options.namespace || 'lb'
const app = initializeApp(options.firebaseConfig)
this.store = getFirestore(app)
if (options.emulator) {
const { host = '127.0.0.1', port = 8080 } = options.emulator
connectFirestoreEmulator(this.store, host, port)
}
}
async getConfig() {
const ref = doc(this.store, `${this.namespace}-config`, 'configuration')
const snapshot = await getDoc(ref)
return snapshot.data()
}
/**
* @param {Object} q - { category, since, order, limit }
* @returns {Promise<Object[]>} plain score records, best first
*/
async listScores(q) {
const games = collection(this.store, `${this.namespace}-scores`, q.category, 'games')
if (q.since) {
// Rolling window: Firestore requires the inequality field to sort first,
// so fetch the in-window rows (newest first, capped) and rank by score
// client-side. The cap is a safety bound for busy windows.
const snapshot = await getDocs(query(
games, where('time_stamp', '>=', q.since), orderBy('time_stamp', 'desc'), limit(500)
))
const rows = snapshot.docs.map(d => d.data())
rows.sort((a, b) => q.order === 'desc' ? b.score - a.score : a.score - b.score)
return rows.slice(0, q.limit)
}
const snapshot = await getDocs(query(games, orderBy('score', q.order), limit(q.limit)))
return snapshot.docs.map(d => d.data())
}
async addScore(category, entry) {
const ref = doc(collection(this.store, `${this.namespace}-scores`, category, 'games'))
await setDoc(ref, entry)
}
async archive(entry) {
const sessionId = new Date().toDateString().replace(/\s/g, '_')
const gameId = new Date().toTimeString().replace(/\s/g, '_')
const data = {}
data[gameId] = {
score: entry.score,
category: entry.category,
time_stamp: entry.time_stamp,
...(entry.meta || {})
}
const ref = doc(this.store, `${this.namespace}-all`, entry.playerId, 'games', sessionId)
await setDoc(ref, data, { merge: true })
}
}

View file

@ -0,0 +1,79 @@
/**
* Supabase (Postgres) storage adapter for LeaderBoardService.
*
* You pass in a supabase-js client you constructed yourself, so this package
* takes NO supabase dependency:
*
* import { createClient } from '@supabase/supabase-js'
* const client = createClient(url, anonKey)
* const adapter = new SupabaseAdapter({ client, namespace: 'mw' })
*
* Expects three tables (see leaderboard/README.md for the SQL + row-level
* security): `{ns}_scores`, `{ns}_archive`, `{ns}_config`. Column names are
* snake_case; this adapter maps the generic entry's `playerId` <-> `player_id`.
*/
export class SupabaseAdapter {
/**
* @param {Object} options
* @param {Object} options.client - a supabase-js client
* @param {String} [options.namespace] - table prefix
*/
constructor(options = {}) {
this.client = options.client
this.namespace = options.namespace || 'lb'
}
async getConfig() {
const { data } = await this.client
.from(`${this.namespace}_config`)
.select('*')
.eq('id', 'configuration')
.maybeSingle()
return data || undefined
}
/**
* @param {Object} q - { category, since, order, limit }
* @returns {Promise<Object[]>} plain score records (must expose `name`, `score`)
*/
async listScores(q) {
let builder = this.client
.from(`${this.namespace}_scores`)
.select('*')
.eq('category', q.category)
// Rolling window: Postgres does the range + order + limit server-side.
if (q.since) builder = builder.gte('time_stamp', q.since.toISOString())
const { data, error } = await builder
.order('score', { ascending: q.order === 'asc' })
.limit(q.limit)
if (error) throw error
return data || []
}
async addScore(category, entry) {
const { error } = await this.client.from(`${this.namespace}_scores`).insert({
name: entry.name,
player_id: entry.playerId,
score: entry.score,
category: entry.category,
time_stamp: entry.time_stamp,
day: entry.day,
week: entry.week,
month: entry.month,
meta: entry.meta || null
})
if (error) throw error
}
async archive(entry) {
const { error } = await this.client.from(`${this.namespace}_archive`).insert({
player_id: entry.playerId,
score: entry.score,
category: entry.category,
time_stamp: entry.time_stamp,
meta: entry.meta || null
})
if (error) throw error
}
}

302
leaderboard/leader-board.js Normal file
View file

@ -0,0 +1,302 @@
import { buckets } from '../utils/date-bucket/date-bucket.js'
const DAY_MS = 24 * 60 * 60 * 1000
/**
* The four time windows are ROLLING: each shows entries from the last `ms`
* milliseconds (strictly nested 24h 7d 30d all), sorted by score.
* `ms: null` is the all-time view (no time filter). `title` is the hover tooltip
* that spells out the window.
*/
const DURATIONS = [
{ id: 'today', label: 'Today', ms: DAY_MS, title: 'Last 24 hours' },
{ id: 'week', label: 'Week', ms: 7 * DAY_MS, title: 'Last 7 days' },
{ id: 'month', label: 'Month', ms: 30 * DAY_MS, title: 'Last 30 days' },
{ id: 'all', label: 'All Time', ms: null, title: 'All time' }
]
/**
* Default empty-state messages challenging but friendly, and game-agnostic.
* One is picked at random each render. Override per app via the `emptyMessages`
* option (see below) so localization stays out of this package.
*/
const EMPTY_MESSAGES = [
'Be the first to enter the leader board!',
'No scores yet — claim the top spot!',
'This board is wide open. Conquer it!',
'No champions here yet. Will it be you?',
'Blank slate — set the score to beat!',
'Nobody\'s here yet. Be the first!',
'The top spot is up for grabs. Take it!',
'Empty board. Time to make your mark!'
]
/**
* Generic, game- AND backend-agnostic leaderboard. Nothing here knows about
* minesweeper or Firebase: the ranked value is a plain `score`, sorted in the
* configured direction and displayed through an injected formatter, while all
* storage I/O is delegated to an injected adapter (see adapters/). Games wire
* their specifics through the constructor options.
*
* An adapter implements:
* - getConfig(): Promise<Object|undefined>
* - listScores({ category, field, value, order, limit }): Promise<Object[]>
* - addScore(category, entry): Promise<void>
* - archive(entry): Promise<void> // optional personal history
*/
export class LeaderBoardService {
/**
* @param {Object} options
* @param {Object} options.adapter - storage backend (e.g. FirebaseAdapter, SupabaseAdapter)
* @param {'asc'|'desc'} [options.scoreOrder] - 'asc' = lower is better (e.g. time), 'desc' = higher is better
* @param {(value: number) => string} [options.formatScore] - display formatter for a score
* @param {(entry: Object) => boolean} [options.qualifies] - whether an entry is ranked; defaults to server passingStatus vs entry.status
* @param {Object} [options.labels] - optional tab-label overrides keyed by duration id
* @param {Object} [options.tooltips] - optional tab hover-text overrides keyed by duration id
* @param {string[]} [options.emptyMessages] - empty-state messages (one picked at random); localize here
* @param {string} [options.loadingText] - shown while a window loads
* @param {string} [options.errorText] - shown when a window fails to load
* @param {string} [options.anonymousName] - fallback display name for entries without one
*/
constructor(options = {}) {
this.adapter = options.adapter
this.scoreOrder = options.scoreOrder === 'desc' ? 'desc' : 'asc'
this.formatScore = options.formatScore || (value => String(value))
this.qualifies = options.qualifies || (entry => this._defaultQualifies(entry))
this.labels = options.labels || {}
this.tooltips = options.tooltips || {}
// User-facing strings — override to localize; the package ships English defaults.
this.emptyMessages = (Array.isArray(options.emptyMessages) && options.emptyMessages.length)
? options.emptyMessages
: EMPTY_MESSAGES
this.loadingText = options.loadingText || 'Loading…'
this.errorText = options.errorText || 'Leaderboard unavailable right now.'
this.anonymousName = options.anonymousName || 'Anonymous'
Promise.resolve(this.adapter.getConfig())
.then(config => {
this.configuration = config
})
.catch(() => {})
}
/**
* Default ranking gate: if the server config names a `passingStatus`, only
* entries whose `status` matches qualify; otherwise every entry qualifies.
*/
_defaultQualifies(entry) {
const passing = this.configuration && this.configuration.passingStatus
if (!passing) return true
return entry.status === passing
}
_label(duration) {
return this.labels[duration.id] || duration.label
}
_tooltip(duration) {
return this.tooltips[duration.id] || duration.title
}
_emptyMessage() {
return this.emptyMessages[Math.floor(Math.random() * this.emptyMessages.length)]
}
/**
* Backend-neutral query descriptor for a category and time window. `since` is
* the rolling cutoff (entries with `time_stamp >= since`); `null` means
* all-time (no time filter). The adapter turns this into a real query.
*/
_descriptor(category, duration) {
return {
category,
since: duration.ms ? new Date(Date.now() - duration.ms) : null,
order: this.scoreOrder,
limit: 10
}
}
/**
* Render the leaderboard for a category with a duration tab bar. When
* `duration` is omitted the last-selected tab is reused (so switching game
* category keeps the player on the same window), defaulting to "today".
* Returns the wrapper element; tab clicks re-query in place.
* @param {String} category
* @param {String} title
* @param {String} [duration]
* @returns {Promise<HTMLDivElement>}
*/
async render(category, title, duration) {
this.category = category
this.title = title
if (duration) this.duration = duration
if (!this.duration) this.duration = 'today'
duration = this.duration
const wrapper = document.createElement('div')
wrapper.style.maxWidth = '270px'
wrapper.style.margin = '0 auto'
const heading = document.createElement('h3')
heading.innerText = title
heading.style.borderBottom = '1px solid #c0c0c0'
heading.style.paddingBottom = '10px'
wrapper.append(heading)
const tabBar = document.createElement('div')
tabBar.style.display = 'flex'
tabBar.style.justifyContent = 'center'
tabBar.style.gap = '8px'
tabBar.style.marginBottom = '10px'
wrapper.append(tabBar)
const listWrapper = document.createElement('div')
wrapper.append(listWrapper)
const tabs = {}
const activate = (id) => {
this.duration = id
Object.entries(tabs).forEach(([tabId, el]) => this._styleTab(el, tabId === id))
this._loadList(listWrapper, category, DURATIONS.find(d => d.id === id))
}
DURATIONS.forEach(d => {
const tab = document.createElement('button')
tab.innerText = this._label(d)
tab.type = 'button'
tab.setAttribute('title', this._tooltip(d))
this._styleTab(tab, d.id === duration)
tab.onclick = () => activate(d.id)
tabs[d.id] = tab
tabBar.append(tab)
})
// Return the wrapper (heading + tabs) right away and fill the list
// asynchronously, so a slow or failing query never blocks the UI.
this._loadList(listWrapper, category, DURATIONS.find(d => d.id === duration))
return wrapper
}
_styleTab(tab, active) {
tab.style.background = 'none'
tab.style.border = 'none'
tab.style.cursor = 'pointer'
tab.style.padding = '2px 4px'
tab.style.fontSize = '0.85em'
tab.style.color = active ? '#ffffff' : '#999999'
tab.style.fontWeight = active ? 'bold' : 'normal'
tab.style.borderBottom = active ? '2px solid orange' : '2px solid transparent'
}
/**
* Load a window's entries into the list area, showing a loading placeholder
* and turning any failure (e.g. the backend being unreachable) into a
* message instead of an unhandled rejection. Guards against races when the
* player switches tabs quickly by tagging the in-flight request.
*/
async _loadList(listWrapper, category, duration) {
const token = (this._loadToken || 0) + 1
this._loadToken = token
listWrapper.innerHTML = ''
const loading = document.createElement('em')
loading.innerText = this.loadingText
listWrapper.append(loading)
try {
const rows = await this.adapter.listScores(this._descriptor(category, duration))
if (this._loadToken !== token) return
this._renderList(listWrapper, rows)
} catch {
if (this._loadToken !== token) return
listWrapper.innerHTML = ''
const message = document.createElement('em')
message.innerText = this.errorText
listWrapper.append(message)
}
}
_renderList(listWrapper, rows) {
listWrapper.innerHTML = ''
if (!rows || !rows.length) {
const message = document.createElement('em')
message.innerText = this._emptyMessage()
listWrapper.append(message)
return
}
const list = document.createElement('div')
list.style.listStyle = 'none'
list.style.textAlign = 'left'
let i = 1
rows.forEach(data => {
const item = document.createElement('div')
item.style.display = 'flex'
const indexElement = document.createElement('div')
indexElement.innerText = `#${i++}`
const nameElement = document.createElement('div')
const name = data.name || this.anonymousName
nameElement.innerHTML = name
nameElement.setAttribute('title', name)
nameElement.style.textOverflow = 'ellipsis'
nameElement.style.whiteSpace = 'nowrap'
nameElement.style.overflow = 'hidden'
nameElement.style.padding = '0 5px'
nameElement.style.fontWeight = 'bold'
nameElement.style.fontStyle = 'italic'
nameElement.style.flex = '1'
const scoreElement = document.createElement('div')
scoreElement.innerText = this.formatScore(data.score)
item.append(indexElement, nameElement, scoreElement)
list.append(item)
})
listWrapper.append(list)
}
/**
* Submit a completed game. Always archives it (personal history); if it
* qualifies, also writes a ranked entry with denormalized bucket keys. Both
* writes go through the adapter, so the storage backend is pluggable. The
* caller owns display-name/nickname UX.
* @param {Object} entry - { name, playerId, score, category, time_stamp, status?, meta? }
*/
async submit(entry) {
if (this.adapter.archive) {
await this.adapter.archive({
playerId: entry.playerId,
score: entry.score,
category: entry.category,
time_stamp: entry.time_stamp,
meta: entry.meta
})
}
if (!this.qualifies(entry)) return
const stamp = entry.time_stamp instanceof Date ? entry.time_stamp : new Date(entry.time_stamp)
const bucket = buckets(stamp)
const scoreDoc = {
name: entry.name || 'Anonymous',
playerId: entry.playerId,
score: entry.score,
category: entry.category,
time_stamp: entry.time_stamp,
day: bucket.day,
week: bucket.week,
month: bucket.month
}
if (entry.meta) scoreDoc.meta = entry.meta
await this.adapter.addScore(entry.category, scoreDoc)
}
}

View file

@ -0,0 +1,164 @@
import { WebComponent } from 'web-component-base'
import { LeaderBoardService } from './leader-board.js'
/**
* `<cozy-leaderboard>` a custom element that lets a developer compose the
* leaderboard UI declaratively in HTML instead of wiring it in JavaScript.
*
* Built on `web-component-base` (WebComponent base class + lifecycle hooks). It
* extends WCB for the custom-element scaffolding (onInit/onDestroy, connect
* handling) and delegates all inner DOM the duration tabs and the ranked
* list to the existing LeaderBoardService, which already builds and manages
* that DOM (including in-place tab swaps).
*
* Composition lives in HTML attributes; the storage backend (adapter) is set
* once in JS via configureLeaderboard(), because env-var config can't live in
* static markup.
*
* <cozy-leaderboard category="beginner" title="Best Times" format="time"></cozy-leaderboard>
*/
// Shared config for every element on the page, set once via configureLeaderboard().
let sharedConfig = {}
const instances = new Set()
/**
* Configure the backend + defaults for all <cozy-leaderboard> elements. Call
* once at startup, after building your adapter (Firebase/Supabase/). User-facing
* strings (labels, emptyMessages, loadingText, errorText, anonymousName) can be
* passed here to localize without changing the package.
* @param {Object} options - { adapter, scoreOrder?, format?, formatScore?, qualifies?, labels?, emptyMessages?, loadingText?, errorText?, anonymousName? }
*/
export function configureLeaderboard(options = {}) {
sharedConfig = { ...sharedConfig, ...options }
instances.forEach(el => el._mount())
}
const clean = (str, separator) => (str === '00' ? '' : `${str}${separator}`)
// ms -> pretty time, e.g. 4200 -> "04.2" (mirrors utils/timer pretty()).
const prettyTime = ms => {
if (!ms) return '0'
const milliseconds = parseInt((ms % 1000) / 100)
const seconds = Math.floor((ms / 1000) % 60)
const minutes = Math.floor((ms / (1000 * 60)) % 60)
const hours = Math.floor((ms / (1000 * 60 * 60)) % 24)
const hh = hours < 10 ? `0${hours}` : `${hours}`
const mm = minutes < 10 ? `0${minutes}` : `${minutes}`
const ss = seconds < 10 ? `0${seconds}` : `${seconds}`
return `${clean(hh, ':')}${clean(mm, ':')}${clean(ss, '.')}${milliseconds}`
}
// Built-in score formatters, selectable via the `format` attribute.
const FORMATTERS = { time: prettyTime }
const resolveFormat = name => FORMATTERS[name]
export class CozyLeaderboard extends WebComponent {
static get observedAttributes() {
return ['category', 'title', 'duration', 'score-order', 'format']
}
// WCB lifecycle: register/unregister so configureLeaderboard() can re-render.
onInit() {
instances.add(this)
}
onDestroy() {
instances.delete(this)
}
// Attributes are read directly (getAttribute) rather than through WCB's typed
// props proxy, so optional/empty values never trip its type enforcement.
attributeChangedCallback(name, previousValue, currentValue) {
if (previousValue === currentValue || !this.isConnected) return
this._mount(name === 'duration' ? (currentValue || undefined) : undefined)
}
// WCB calls render() on connect; we treat it as "(re)mount the board".
render() {
this._mount()
}
// Per-element override properties (public): adapter, formatScore, qualifies,
// labels, emptyMessages, loadingText, errorText, anonymousName. Each falls back
// to the shared configureLeaderboard() value, then the package default. Set
// them before the element connects (or clear `_svc` to force a rebuild).
_service() {
if (this._svc) return this._svc
const adapter = this.adapter || sharedConfig.adapter
if (!adapter) return null
const formatScore = this.formatScore
|| resolveFormat(this.getAttribute('format'))
|| sharedConfig.formatScore
|| resolveFormat(sharedConfig.format)
|| String
this._svc = new LeaderBoardService({
adapter,
scoreOrder: this.getAttribute('score-order') || sharedConfig.scoreOrder || 'asc',
formatScore,
qualifies: this.qualifies || sharedConfig.qualifies,
// User-facing strings — pass through so apps localize without touching the package.
labels: this.labels || sharedConfig.labels,
tooltips: this.tooltips || sharedConfig.tooltips,
emptyMessages: this.emptyMessages || sharedConfig.emptyMessages,
loadingText: this.loadingText || sharedConfig.loadingText,
errorText: this.errorText || sharedConfig.errorText,
anonymousName: this.anonymousName || sharedConfig.anonymousName
})
return this._svc
}
/**
* (Re)render the board. The first successful mount honors the author's
* `duration` attribute; later mounts preserve the service's remembered
* duration (so switching category keeps the selected tab) unless a duration
* is passed explicitly.
*/
_mount(durationArg) {
if (!this.isConnected) return
const service = this._service()
if (!service) {
this.replaceChildren(this._message('Leaderboard not configured.'))
return
}
let duration = durationArg
if (duration === undefined && !this._mounted) {
duration = this.getAttribute('duration') || undefined
}
const token = (this._token || 0) + 1
this._token = token
service.render(this.getAttribute('category') || '', this.getAttribute('title') || '', duration)
.then(el => {
if (this._token !== token) return
this.replaceChildren(el)
this._mounted = true
})
.catch(() => {
if (this._token !== token) return
this.replaceChildren(this._message('Leaderboard unavailable right now.'))
})
}
/**
* Submit a finished game through this element's service keeps score
* submission a one-liner from the host app.
* @param {Object} entry
*/
submit(entry) {
const service = this._service()
if (service) return service.submit(entry)
}
_message(text) {
const em = document.createElement('em')
em.innerText = text
return em
}
}
if (!customElements.get('cozy-leaderboard')) {
customElements.define('cozy-leaderboard', CozyLeaderboard)
}

39
leaderboard/package.json Normal file
View file

@ -0,0 +1,39 @@
{
"name": "@cozy-games/leaderboard",
"version": "0.0.1",
"description": "Generic, game-agnostic Firestore leaderboard with time-windowed views",
"author": "Ayo Ayco",
"type": "module",
"repository": {
"type": "git",
"url": "https://github.com/ayo-run/mnswpr"
},
"main": "leader-board.js",
"exports": {
".": {
"default": "./dist/leader-board.js"
},
"./dist/*": {
"default": "./dist/*"
},
"./*": {
"default": "./*"
}
},
"files": [
"./*",
"./dist"
],
"dependencies": {
"web-component-base": "^4.1.2"
},
"peerDependencies": {
"firebase": "^12.11.0"
},
"peerDependenciesMeta": {
"firebase": {
"optional": true
}
},
"license": "BSD-2-Clause"
}

View file

@ -0,0 +1,15 @@
import { resolve } from 'node:path'
import { defineConfig } from 'vite'
export default defineConfig({
build: {
lib: {
entry: resolve(import.meta.dirname, './leader-board.js'),
name: 'leaderboard',
fileName: 'leader-board'
},
rollupOptions: {
external: [/^firebase/]
}
}
})

View file

@ -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",

View file

@ -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: {}

View file

@ -1,6 +1,8 @@
packages:
- "lib"
- "app"
- "leaderboard"
allowBuilds:
'@firebase/util': false
protobufjs: false
web-component-base: false

136
scripts/export-legends.js Normal file
View file

@ -0,0 +1,136 @@
/**
* One-time generator: snapshot the all-time leaders from the legacy
* `mw-leaders/{level}/games` collection and write them into app/legends.html as
* a FULLY-RENDERED, static page no runtime JS, no Firebase at page load.
*
* The data never changes, so the page is a frozen artifact. Re-run only if you
* ever need to regenerate it. Because `firebase` is an app-workspace dependency,
* run it so Node can resolve it, e.g. from the app directory:
* (cd app && node ../scripts/export-legends.js)
*/
import { writeFileSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { initializeApp } from 'firebase/app'
import {
getFirestore, getDocs, collection, query, orderBy, limit
} from 'firebase/firestore/lite'
import { levels } from '../lib/levels.js'
// Mirror of TimerService.pretty() (utils/timer/timer.js) — inlined so this
// generator has no cross-module import chain to resolve under raw Node.
const clean = (str, separator) => (str === '00' ? '' : `${str}${separator}`)
const pretty = duration => {
if (!duration) return undefined
const milliseconds = parseInt((duration % 1000) / 100)
const seconds = Math.floor((duration / 1000) % 60)
const minutes = Math.floor((duration / (1000 * 60)) % 60)
const hours = Math.floor((duration / (1000 * 60 * 60)) % 24)
const hh = hours < 10 ? `0${hours}` : `${hours}`
const mm = minutes < 10 ? `0${minutes}` : `${minutes}`
const ss = seconds < 10 ? `0${seconds}` : `${seconds}`
return `${clean(hh, ':')}${clean(mm, ':')}${clean(ss, '.')}${clean(milliseconds, '')}`
}
// Hardcoded for this one-time run (public, non-secret keys — access is governed
// by Firestore rules). This is the project the deployed app has always used.
const firebaseConfig = {
apiKey: 'AIzaSyCTi_5Sm5dHFNf0d_Gn0MNWmlGheFBf6MQ',
authDomain: 'moment-188701.firebaseapp.com',
databaseURL: 'https://moment-188701.firebaseio.com',
projectId: 'secure-moment-188701',
storageBucket: 'secure-moment-188701.firebasestorage.app',
messagingSenderId: '113827947104',
appId: '1:113827947104:web:b176f746d8358302c51905',
measurementId: 'G-LZRDY0TG46'
}
console.log(`Exporting Legends from project: ${firebaseConfig.projectId}`)
const TOP_N = 10
const escapeHtml = str => String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
const app = initializeApp(firebaseConfig)
const store = getFirestore(app)
const sections = []
for (const id of Object.keys(levels)) {
const q = query(
collection(store, 'mw-leaders', id, 'games'),
orderBy('time'),
limit(TOP_N)
)
const snapshot = await getDocs(q)
const rows = snapshot.docs.map(d => {
const data = d.data()
return { name: data.name || 'Anonymous', time: data.time }
})
console.log(`${id}: ${rows.length} record(s)`)
const items = rows.map((r, i) =>
` <li><span class="rank">#${i + 1}</span><span class="name" title="${escapeHtml(r.name)}">${escapeHtml(r.name)}</span><span class="time">${pretty(r.time)}</span></li>`
).join('\n')
sections.push(` <section>
<h3>${escapeHtml(levels[id].name)}</h3>
<ol>
${items}
</ol>
</section>`)
}
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<meta name="Description" content="Minesweeper Legends — Epic pre-version-one top scores." />
<title>Minesweeper Legends 🏆</title>
<link rel="shortcut icon" type="image/png" href="/favicon.ico" />
<link rel="stylesheet" href="./main.css" />
<style>
.legends section { max-width: 270px; margin: 0 auto 30px; }
.legends h3 { border-bottom: 1px solid #c0c0c0; padding-bottom: 10px; }
.legends ol { list-style: none; padding: 0; margin: 0; text-align: left; }
.legends li { display: flex; }
.legends .name {
flex: 1;
padding: 0 5px;
font-weight: bold;
font-style: italic;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
</head>
<body>
<!-- Generated by scripts/export-legends.js do not edit by hand. -->
<div id="body-wrapper">
<h1>🏆 Legends</h1>
<p><em>Epic scores the top times set before version 1.0.</em></p>
<div class="legends">
${sections.join('\n')}
</div>
<div id="legends-link">
<a href="/"> Back to the game</a>
</div>
</div>
</body>
</html>
`
const __dirname = dirname(fileURLToPath(import.meta.url))
const out = resolve(__dirname, '../app/legends.html')
writeFileSync(out, html)
console.log(`\nWrote ${out}`)

104
scripts/seed-dev-scores.js Normal file
View file

@ -0,0 +1,104 @@
/**
* Seed the DEVELOPMENT leaderboard with sample scores so the boards aren't empty
* while developing. Writes to the `mw-test-*` namespace by default.
*
* Two ways to run it:
*
* 1. Against the local Firestore EMULATOR (recommended no cloud, no deploy):
* pnpm emulators # in one terminal
* pnpm seed:emulator # in another
* (seed:emulator sets FIRESTORE_EMULATOR_HOST so writes hit the emulator.)
*
* 2. Against the real cloud dev database: REQUIRES the generalized
* firestore.rules to be deployed first (they permit writes to
* `mw-test-scores`), else every write returns permission-denied:
* npx firebase deploy --only firestore:rules --project dev
* (cd app && node ../scripts/seed-dev-scores.js)
*
* `firebase` is an app-workspace dependency, so run it from the app directory so
* Node can resolve it. Optional namespace override: LB_NAMESPACE=mw-test.
*/
import { initializeApp } from 'firebase/app'
import { getFirestore, connectFirestoreEmulator, doc, collection, setDoc } from 'firebase/firestore/lite'
import { buckets } from '../utils/date-bucket/date-bucket.js'
const firebaseConfig = {
apiKey: 'AIzaSyCTi_5Sm5dHFNf0d_Gn0MNWmlGheFBf6MQ',
authDomain: 'moment-188701.firebaseapp.com',
databaseURL: 'https://moment-188701.firebaseio.com',
projectId: 'secure-moment-188701',
storageBucket: 'secure-moment-188701.firebasestorage.app',
messagingSenderId: '113827947104',
appId: '1:113827947104:web:b176f746d8358302c51905'
}
const namespace = process.env.LB_NAMESPACE || 'mw-test'
// Per-level rough time ranges (ms) so sample times look believable.
const LEVELS = {
beginner: [1800, 12000],
intermediate: [28000, 95000],
expert: [90000, 260000],
nightmare: [180000, 620000]
}
const NAMES = [
'Ayo', 'Sweeper', 'MineWizard', 'jenjereren', 'wasd', 'kaboom',
'flagella', 'ClickClick', 'Torpedo', 'S-Davies', 'Anonymous', 'zero-day',
'BoomBot', 'gridlock', 'NoNiner', 'chord-lord', 'tenten', 'quicksilver'
]
// Ages (days ago) spread so Today / Week / Month / All Time all get entries.
const AGE_DAYS = [0, 0, 0, 1, 2, 4, 6, 10, 18, 27, 45, 80]
const now = Date.now()
const rand = (min, max) => Math.floor(min + Math.random() * (max - min))
const pick = arr => arr[Math.floor(Math.random() * arr.length)]
const store = getFirestore(initializeApp(firebaseConfig))
// Point at a local Firestore emulator when FIRESTORE_EMULATOR_HOST is set
// (e.g. "127.0.0.1:8080"). Writes then hit the local emulator, not the cloud.
if (process.env.FIRESTORE_EMULATOR_HOST) {
const [host, port] = process.env.FIRESTORE_EMULATOR_HOST.split(':')
connectFirestoreEmulator(store, host, Number(port))
console.log(`Using Firestore emulator at ${process.env.FIRESTORE_EMULATOR_HOST}`)
}
let ok = 0
let failed = 0
let firstError
for (const [level, [min, max]] of Object.entries(LEVELS)) {
for (let i = 0; i < AGE_DAYS.length; i++) {
const stamp = new Date(now - AGE_DAYS[i] * 86400000 - rand(0, 3600000))
const bucket = buckets(stamp)
const entry = {
name: pick(NAMES),
playerId: `seed-${level}-${i}`,
score: rand(min, max),
category: level,
time_stamp: stamp,
day: bucket.day,
week: bucket.week,
month: bucket.month,
meta: { isMobile: Math.random() < 0.3, seed: true }
}
try {
await setDoc(doc(collection(store, `${namespace}-scores`, level, 'games')), entry)
ok++
} catch (error) {
failed++
firstError = firstError || (error.code || error.message)
}
}
console.log(`${level}: seeded`)
}
console.log(`\nDone. Wrote ${ok} sample score(s) to ${namespace}-scores, ${failed} failed.`)
if (failed) {
console.error(`\nWrites failed (${firstError}). If this is 'permission-denied', deploy the rules first:`)
console.error(' npx firebase deploy --only firestore:rules --project dev')
process.exit(1)
}

41
test/date-bucket.test.js Normal file
View file

@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest'
import { dayKey, weekKey, monthKey, buckets } from '../utils/date-bucket/date-bucket.js'
describe('date-bucket', () => {
it('builds a zero-padded UTC day key', () => {
expect(dayKey(new Date('2026-07-03T12:00:00Z'))).toBe('2026-07-03')
expect(dayKey(new Date('2026-01-09T00:00:00Z'))).toBe('2026-01-09')
})
it('builds a UTC month key', () => {
expect(monthKey(new Date('2026-07-03T12:00:00Z'))).toBe('2026-07')
expect(monthKey(new Date('2026-12-31T23:59:59Z'))).toBe('2026-12')
})
it('builds an ISO week key', () => {
// 2026-07-03 falls in ISO week 27
expect(weekKey(new Date('2026-07-03T12:00:00Z'))).toBe('2026-W27')
// 2026-01-01 is a Thursday -> ISO week 1 of 2026
expect(weekKey(new Date('2026-01-01T00:00:00Z'))).toBe('2026-W01')
})
it('rolls the ISO week-year back at the January boundary', () => {
// 2027-01-01 is a Friday -> still ISO week 53 of 2026
expect(weekKey(new Date('2027-01-01T00:00:00Z'))).toBe('2026-W53')
})
it('uses UTC, not local time', () => {
// Just before UTC midnight is still the 3rd in UTC
expect(dayKey(new Date('2026-07-03T23:59:59Z'))).toBe('2026-07-03')
// Just after is the 4th
expect(dayKey(new Date('2026-07-04T00:00:01Z'))).toBe('2026-07-04')
})
it('returns all three keys together', () => {
expect(buckets(new Date('2026-07-03T12:00:00Z'))).toEqual({
day: '2026-07-03',
week: '2026-W27',
month: '2026-07'
})
})
})

View file

@ -0,0 +1,55 @@
/**
* Time-bucket keys for leaderboards, computed in UTC so every player shares the
* same boundaries worldwide. Each key is a plain string denormalized onto a
* score document, letting a "Today / Week / Month" query be a cheap equality
* filter instead of a range scan.
*/
const pad = n => (n < 10 ? `0${n}` : `${n}`)
/**
* Calendar-day key, e.g. `2026-07-03`.
* @param {Date} date
* @returns {String}
*/
export const dayKey = date => {
return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}`
}
/**
* Calendar-month key, e.g. `2026-07`.
* @param {Date} date
* @returns {String}
*/
export const monthKey = date => {
return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}`
}
/**
* ISO-8601 week key, e.g. `2026-W27`. Weeks start Monday and the week-numbering
* year can differ from the calendar year around January 1st, so we derive the
* year from the week's Thursday.
* @param {Date} date
* @returns {String}
*/
export const weekKey = date => {
const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()))
const dayNum = d.getUTCDay() || 7
d.setUTCDate(d.getUTCDate() + 4 - dayNum)
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1))
const week = Math.ceil((((d - yearStart) / 86400000) + 1) / 7)
return `${d.getUTCFullYear()}-W${pad(week)}`
}
/**
* All three bucket keys for a moment in time.
* @param {Date} date
* @returns {{ day: String, week: String, month: String }}
*/
export const buckets = date => {
return {
day: dayKey(date),
week: weekKey(date),
month: monthKey(date)
}
}

View file

@ -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'