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.
79 lines
2.4 KiB
JavaScript
79 lines
2.4 KiB
JavaScript
/**
|
|
* 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
|
|
}
|
|
}
|