mnswpr/leaderboard/adapters/firebase.js
Ayo 9e1114be78 feat: reusable, time-windowed leaderboard (@cozy-games/leaderboard) (#1)
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: git.ayo.run:3000/
Co-authored-by: Ayo <ayo@ayco.io>
Co-committed-by: Ayo <ayo@ayco.io>
2026-07-03 11:41:14 +00:00

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 })
}
}