import { WebComponent, html } from 'web-component-base' import { LeaderBoardService } from './leader-board.js' import { DURATIONS } from './leaderboard-read.js' /** * `` — a custom element that lets a developer compose the * leaderboard UI declaratively in HTML instead of wiring it in JavaScript. * * Built on `web-component-base` (WCB) the idiomatic way: the view is a pure * `html` template over a precomputed view-state object, WCB's own * `attributeChangedCallback` feeds observed attributes into `this.props` and * the `onChanges` hook, and updates go through the base class's `render()`. * Data access and user-facing strings stay in {@link LeaderBoardService} / * LeaderBoardReader — the element only turns query results into templates. * * `static props` is deliberately NOT used: WCB 4.1.2 writes each prop's * default onto the element as an attribute inside the constructor, which the * custom-elements spec forbids during synchronous construction — it breaks * `document.createElement('cozy-leaderboard')` entirely. Plain * `observedAttributes` + `onChanges` gives the same reactivity without that. * * Composition lives in HTML attributes; the storage backend (adapter) is set * once in JS via configureLeaderboard(), because env-var config can't live in * static markup. * * */ // Shared config for every element on the page, set once via configureLeaderboard(). let sharedConfig = {} const instances = new Set() /** * Configure the backend + defaults for all elements. Call * once at startup, after building your adapter (Firebase/Supabase/…). User-facing * strings (labels, emptyMessages, loadingText, errorText, anonymousName) can be * passed here to localize without changing the package. * @param {Object} options - { adapter, scoreOrder?, format?, formatScore?, qualifies?, labels?, emptyMessages?, loadingText?, errorText?, anonymousName? } */ export function configureLeaderboard(options = {}) { sharedConfig = { ...sharedConfig, ...options } instances.forEach(el => el._mount()) } const clean = (str, separator) => (str === '00' ? '' : `${str}${separator}`) // ms -> pretty time, e.g. 4200 -> "04.2" (mirrors utils/timer pretty()). const prettyTime = ms => { if (!ms) return '0' const milliseconds = parseInt((ms % 1000) / 100) const seconds = Math.floor((ms / 1000) % 60) const minutes = Math.floor((ms / (1000 * 60)) % 60) const hours = Math.floor((ms / (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, '.')}${milliseconds}` } // Built-in score formatters, selectable via the `format` attribute. const FORMATTERS = { time: prettyTime } const resolveFormat = name => FORMATTERS[name] // WCB coerces an empty attribute value to a non-string (`'' -> true`), so // observed values are read through this guard before use. const str = value => (typeof value === 'string' ? value : '') // Inline styles (as WCB `html` style objects) — the same visual output the // LeaderBoardReader produces for the imperative JS composition path. const STYLES = { wrapper: { maxWidth: '270px', margin: '0 auto' }, heading: { borderBottom: '1px solid #c0c0c0', paddingBottom: '10px' }, tabBar: { display: 'flex', justifyContent: 'center', gap: '8px', marginBottom: '10px' }, list: { listStyle: 'none', textAlign: 'left' }, row: { display: 'flex' }, name: { textOverflow: 'ellipsis', whiteSpace: 'nowrap', overflow: 'hidden', padding: '0 5px', fontWeight: 'bold', fontStyle: 'italic', flex: '1' } } const tabStyle = active => ({ background: 'none', border: 'none', cursor: 'pointer', padding: '2px 4px', fontSize: '0.85em', color: active ? '#ffffff' : '#999999', fontWeight: active ? 'bold' : 'normal', borderBottom: active ? '2px solid orange' : '2px solid transparent' }) export class CozyLeaderboard extends WebComponent { static get observedAttributes() { return ['category', 'title', 'duration', 'score-order', 'format'] } // View state the template renders from. Either { board: false } (not // configured) or { board: true, tabs, active, list } where list is // { rows: [{ index, name, score }] } or { message } (loading/empty/error). _view = { board: false } // Selected duration window; survives category changes and re-connects. // null until the board first mounts, so the `duration` attribute is honored. _activeDuration = null _connected = false _token = 0 // WCB lifecycle: connect mounts the board, disconnect unregisters. The // instances set lets configureLeaderboard() re-mount live elements. onInit() { this._connected = true instances.add(this) this._mount() } onDestroy() { this._connected = false instances.delete(this) } /** * WCB change hook — `property` is the attribute (kebab-case) name. A title * change needs no re-query: the heading reads `this.props.title`, so WCB's * own render already updated it. score-order/format changes rebuild the * service so the new config actually takes effect. */ onChanges({ property, currentValue }) { if (!this._connected || property === 'title') return if (property === 'score-order' || property === 'format') this._svc = null this._mount(property === 'duration' ? (str(currentValue) || undefined) : undefined) } get template() { const view = this._view if (!view.board) return html`Leaderboard not configured.` return html`

${str(this.props.title)}

${view.tabs.map(tab => html` `)}
${view.list.rows ? html`
${view.list.rows.map(row => html`
#${row.index}
${row.name}
${row.score}
`)}
` : html`${view.list.message}`}
` } // Per-element override properties (public): adapter, formatScore, qualifies, // labels, tooltips, emptyMessages, loadingText, errorText, anonymousName. // Each falls back to the shared configureLeaderboard() value, then the // package default. Set them before the element connects (or clear `_svc` to // force a rebuild). Rich values stay plain properties — WCB props are // attribute-backed and only carry serializable primitives. _service() { if (this._svc) return this._svc const adapter = this.adapter || sharedConfig.adapter if (!adapter) return null const formatScore = this.formatScore || resolveFormat(str(this.props.format)) || sharedConfig.formatScore || resolveFormat(sharedConfig.format) || String this._svc = new LeaderBoardService({ adapter, scoreOrder: str(this.props.scoreOrder) || sharedConfig.scoreOrder || 'asc', formatScore, qualifies: this.qualifies || sharedConfig.qualifies, // User-facing strings — pass through so apps localize without touching the package. labels: this.labels || sharedConfig.labels, tooltips: this.tooltips || sharedConfig.tooltips, emptyMessages: this.emptyMessages || sharedConfig.emptyMessages, loadingText: this.loadingText || sharedConfig.loadingText, errorText: this.errorText || sharedConfig.errorText, anonymousName: this.anonymousName || sharedConfig.anonymousName }) return this._svc } /** * (Re)mount the board. The first mount honors the author's `duration` * attribute; later mounts keep the selected duration (so switching category * keeps the selected tab) unless a duration is passed explicitly. */ _mount(durationArg) { if (!this._connected) return const service = this._service() if (!service) { this._view = { board: false } this._paint() return } const duration = durationArg ?? this._activeDuration ?? (str(this.props.duration) || 'today') this._activeDuration = duration this._load(service, duration) } _selectTab(id) { this._activeDuration = id this._load(this._service(), id) } /** * Query one duration window and project the result into view state: a * loading message immediately, then rows / a random empty message / the * error text. The token guards against a stale response (quick tab or * category switches) overwriting a newer one. */ async _load(service, durationId) { const reader = service.reader const tabs = DURATIONS.map(d => ({ id: d.id, label: reader.label(d), tooltip: reader.tooltip(d) })) const board = list => ({ board: true, tabs, active: durationId, list }) const token = ++this._token this._view = board({ message: reader.loadingText }) this._paint() let list try { const rows = await reader.list(str(this.props.category), durationId) list = (rows && rows.length) ? { rows: rows.map((row, index) => ({ index: index + 1, name: row.name || reader.anonymousName, score: reader.formatScore(row.score) })) } : { message: reader.emptyMessage() } } catch { list = { message: reader.errorText } } if (token !== this._token) return this._view = board(list) this._paint() } /** * Render the current view state through WCB. WCB's render() replaces the * whole subtree (no diffing yet), which would drop focus from a clicked * duration tab — the one behavior the base class can't preserve for us — so * focus is handed to the replacement tab explicitly. */ _paint() { const focused = document.activeElement const focusedTab = focused && this.contains(focused) ? focused.dataset.duration : undefined this.render() if (focusedTab) this.querySelector(`button[data-duration="${focusedTab}"]`)?.focus() } /** * Submit a finished game through this element's service — keeps score * submission a one-liner from the host app. * @param {Object} entry */ submit(entry) { const service = this._service() if (service) return service.submit(entry) } } if (!customElements.get('cozy-leaderboard')) { customElements.define('cozy-leaderboard', CozyLeaderboard) }