mnswpr/scripts/export-legends.js
Ayo 4fa88fb431 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.

Reviewed-on: https://git.ayo.run/ayo/mnswpr/pulls/1
Co-authored-by: Ayo <ayo@ayco.io>
Co-committed-by: Ayo <ayo@ayco.io>
2026-07-03 13:42:03 +02:00

136 lines
4.7 KiB
JavaScript

/**
* 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}`)