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>
75 lines
2.8 KiB
JavaScript
75 lines
2.8 KiB
JavaScript
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 })
|
|
}
|
|
}
|