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