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 observed * attributes are declared as `static props` (typed defaults; the base derives * observedAttributes and feeds values into the reactive `this.props`), the view * is a pure `html` template over a precomputed view-state object, and change * reactions arrive through the `onChanges` hook. Data access and user-facing * strings stay in {@link LeaderBoardService} / LeaderBoardReader — the element * only turns query results into templates. * * (WCB ≥5 is required: v4 wrote each prop default onto the element as an * attribute inside the constructor, which the custom-elements spec forbids and * which broke `document.createElement('cozy-leaderboard')`. v5 defers that * reflection to connect and never clobbers authored attributes, making * `static props` safe here.) * * 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] // 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 { // Declared attributes (kebab-cased on the element: score-order). String // defaults keep `this.props.*` typed as strings, so an unset or emptied // attribute reads as '' rather than a coerced boolean. The base class derives // observedAttributes from these keys. static props = { category: '', title: '', duration: '', scoreOrder: '', 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 camelCase prop name (WCB ≥5). A title * change needs no re-query: the heading reads `this.props.title`, so the base * class's own render already updated it. score-order/format changes rebuild * the service so the new config actually takes effect. */ onChanges({ property }) { // Invalidate the cached service even while disconnected, so a scoreOrder/ // format change made on a detached element takes effect on re-connect. if (property === 'scoreOrder' || property === 'format') this._svc = null if (!this._connected || property === 'title') return this._mount(property === 'duration' ? (this.props.duration || undefined) : undefined) } get template() { const view = this._view if (!view.board) return html`Leaderboard not configured.` return html`

${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(this.props.format) || sharedConfig.formatScore || resolveFormat(sharedConfig.format) || String this._svc = new LeaderBoardService({ adapter, scoreOrder: 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 ?? (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(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() { // getRootNode(), not document: inside a shadow root, document.activeElement // is retargeted to the host and the focused tab would go undetected. const focused = this.getRootNode().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) }