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>
136 lines
4.7 KiB
JavaScript
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
|
|
const app = initializeApp(firebaseConfig)
|
|
const store = getFirestore(app)
|
|
|
|
const sections = []
|
|
for (const id of Object.keys(levels)) {
|
|
const q = query(
|
|
collection(store, 'mw-leaders', id, 'games'),
|
|
orderBy('time'),
|
|
limit(TOP_N)
|
|
)
|
|
const snapshot = await getDocs(q)
|
|
const rows = snapshot.docs.map(d => {
|
|
const data = d.data()
|
|
return { name: data.name || 'Anonymous', time: data.time }
|
|
})
|
|
console.log(`${id}: ${rows.length} record(s)`)
|
|
|
|
const items = rows.map((r, i) =>
|
|
` <li><span class="rank">#${i + 1}</span><span class="name" title="${escapeHtml(r.name)}">${escapeHtml(r.name)}</span><span class="time">${pretty(r.time)}</span></li>`
|
|
).join('\n')
|
|
|
|
sections.push(` <section>
|
|
<h3>${escapeHtml(levels[id].name)}</h3>
|
|
<ol>
|
|
${items}
|
|
</ol>
|
|
</section>`)
|
|
}
|
|
|
|
const html = `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
|
|
|
<meta name="Description" content="Minesweeper Legends — Epic pre-version-one top scores." />
|
|
<title>Minesweeper Legends 🏆</title>
|
|
|
|
<link rel="shortcut icon" type="image/png" href="/favicon.ico" />
|
|
<link rel="stylesheet" href="./main.css" />
|
|
<style>
|
|
.legends section { max-width: 270px; margin: 0 auto 30px; }
|
|
.legends h3 { border-bottom: 1px solid #c0c0c0; padding-bottom: 10px; }
|
|
.legends ol { list-style: none; padding: 0; margin: 0; text-align: left; }
|
|
.legends li { display: flex; }
|
|
.legends .name {
|
|
flex: 1;
|
|
padding: 0 5px;
|
|
font-weight: bold;
|
|
font-style: italic;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<!-- Generated by scripts/export-legends.js — do not edit by hand. -->
|
|
<div id="body-wrapper">
|
|
<h1>🏆 Legends</h1>
|
|
<p><em>Epic scores — the top times set before version 1.0.</em></p>
|
|
<div class="legends">
|
|
${sections.join('\n')}
|
|
</div>
|
|
<div id="legends-link">
|
|
<a href="/">← Back to the game</a>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
`
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
const out = resolve(__dirname, '../app/legends.html')
|
|
writeFileSync(out, html)
|
|
console.log(`\nWrote ${out}`)
|