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>
100 lines
2.8 KiB
JavaScript
100 lines
2.8 KiB
JavaScript
import { StorageService } from '../../../utils/index.js'
|
|
|
|
const NICK_KEY = 'nickname'
|
|
const MAX_LENGTH = 24
|
|
|
|
/**
|
|
* A few friendly greetings; `{nick}` is replaced with the player's nickname.
|
|
*/
|
|
const GREETINGS = [
|
|
'Welcome back, {nick}!',
|
|
'Good to see you, {nick}!',
|
|
'Hey {nick}, ready to sweep?',
|
|
'Let\'s go, {nick}!',
|
|
'Happy sweeping, {nick}!',
|
|
'Nice to have you, {nick}!'
|
|
]
|
|
|
|
/**
|
|
* Owns the player's display nickname: prompts for it once on first visit,
|
|
* persists it in localStorage, and renders a friendly greeting bar. The
|
|
* leaderboard package stays name-agnostic — the app passes this nickname in.
|
|
*/
|
|
export class NicknameService {
|
|
|
|
constructor() {
|
|
this.storage = new StorageService()
|
|
}
|
|
|
|
/**
|
|
* The stored nickname, or 'Anonymous' if none has been set yet.
|
|
* @returns {String}
|
|
*/
|
|
get() {
|
|
return this.storage.getFromLocal(NICK_KEY) || 'Anonymous'
|
|
}
|
|
|
|
/**
|
|
* Prompt for a nickname the first time only — used on page load.
|
|
*/
|
|
ensure() {
|
|
if (!this.storage.getFromLocal(NICK_KEY)) {
|
|
this.prompt()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ask for a nickname and persist it (trimmed, capped). Cancelling keeps the
|
|
* current value. A failing/unsupported prompt must never break page load, so
|
|
* it is guarded — the player just stays 'Anonymous'.
|
|
*/
|
|
prompt() {
|
|
let next
|
|
try {
|
|
next = window.prompt('Pick a nickname for the leaderboard', this.storage.getFromLocal(NICK_KEY) || '')
|
|
} catch {
|
|
return
|
|
}
|
|
if (next && next.trim()) {
|
|
this.storage.saveToLocal(NICK_KEY, next.trim().slice(0, MAX_LENGTH))
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render the greeting bar into a container. Clicking the nickname (or the
|
|
* pencil) re-prompts and re-renders in place.
|
|
* @param {HTMLElement} container
|
|
*/
|
|
render(container) {
|
|
if (!container) return
|
|
container.innerHTML = ''
|
|
|
|
const template = GREETINGS[Math.floor(Math.random() * GREETINGS.length)]
|
|
const [before, after] = template.split('{nick}')
|
|
|
|
const nameElement = document.createElement('span')
|
|
nameElement.innerText = this.get()
|
|
nameElement.style.fontWeight = 'bold'
|
|
nameElement.style.fontStyle = 'italic'
|
|
nameElement.style.cursor = 'pointer'
|
|
nameElement.style.textDecoration = 'underline'
|
|
nameElement.style.textDecorationColor = 'orange'
|
|
nameElement.setAttribute('title', 'Click to change your nickname')
|
|
|
|
const icon = document.createElement('span')
|
|
icon.innerText = ' ✏️'
|
|
icon.style.cursor = 'pointer'
|
|
icon.style.fontSize = '0.8em'
|
|
icon.style.opacity = '0.6'
|
|
icon.setAttribute('title', 'Click to change your nickname')
|
|
|
|
const change = () => {
|
|
this.prompt()
|
|
this.render(container)
|
|
}
|
|
nameElement.onclick = change
|
|
icon.onclick = change
|
|
|
|
container.append(document.createTextNode(before), nameElement, icon, document.createTextNode(after))
|
|
}
|
|
}
|