/* Timers — client part. The server part follows every source (ProPresenter, the LTC listener, the time of day, other modules offering timers) and streams one merged state object here over SSE: sources, each with its health and its timers as the module guide's standard timer objects. This tile renders every timer from every source it has switched on, grouped under the source's heading, as auto-sized cards — label, the big time, a status line, a detail line. The time is derived LOCALLY. A snapshot says what a timer's value was at its source's updatedAt; a running card counts on from there with this browser's clock, offset-corrected against the server's, so digits tick smoothly between snapshots and a slow provider never needs a fast poll. The LTC card counts individual frames the same way (see the SMPTE math). Per-tile choices: the gear popover's static switches (whole sources, headings, status lines, the clock's format, the state colours) and the "Timers ▾" header menu, which hides any single timer — keyed `source:id` and saved with the layout via saveInstanceSettings. The header can be hidden by the user, so the static switches stay the primary controls. Feedback — the state colours, and the thresholds behind them: running elapsed, no planned length running running elapsed with a planned length warning within WARN_FLOOR_MS or WARN_FRACTION of the length (whichever is larger, but never more than half the length — a 20 s countdown warns under 10 s, a 25 min one under 2:30) of the end; overrun past it running countdown warning with that much left; overrun at zero and below stopped countdown at zero ("complete") warning paused / idle / stopped idle (muted) the wall clock plain text colour LTC running = running colour, stopped = idle, no signal = idle digits + blinking warning line The colours are the tile's Running / Warning / Overrun / Idle settings, set as custom properties on the tile root so they win over the dashboard theme; left at their defaults they follow the theme's variables. */ /** Which instance switch governs a source kind. */ const SOURCE_SWITCH = { propresenter: 'showProPresenter', ltc: 'showLtc', clock: 'showClock', module: 'showModules' }; /** Colour settings → custom property, and the schema default (module.json) that means "follow the theme". */ const COLOR_SETTINGS = [ ['runningColor', '--tm-ok', '#2ee59a'], ['warningColor', '--tm-warn', '#f0b429'], ['overrunColor', '--tm-danger', '#f4433c'], ['idleColor', '--tm-muted', '#8b98a5'], ]; /** Warning thresholds (see the header comment). */ const WARN_FLOOR_MS = 60000; const WARN_FRACTION = 0.1; /** How often running digits are repainted. Whole seconds, so 5×/s is plenty. */ const PAINT_MS = 200; const LTC_KEY = 'ltc:ltc'; /* ── SMPTE timecode math (module-scope, exported for tests) ───────── The server anchors us ~4×/s with { time, fps, df, ageMs }; the card counts every frame in between locally. That needs exact frame↔timecode conversion, including 29.97 drop-frame (frames 00 and 01 don't exist at the top of a minute, except every tenth minute). */ export function parseTc(str) { const m = /^(\d{1,2}):(\d{2}):(\d{2})[:;.](\d{1,2})$/.exec(String(str || '').trim()); if (!m) return null; return { h: Number(m[1]), m: Number(m[2]), s: Number(m[3]), f: Number(m[4]) }; } export function tcToFrames(tc, nominal, df) { let n = (tc.h * 3600 + tc.m * 60 + tc.s) * nominal + tc.f; if (df) { const mins = tc.h * 60 + tc.m; n -= 2 * (mins - Math.floor(mins / 10)); } return n; } export function framesToTc(n, nominal, df) { // 10 DF minutes hold 17982 frames (the first minute 1800, the rest 1798). const perDay = df ? 24 * 6 * 17982 : 24 * 3600 * nominal; n = ((n % perDay) + perDay) % perDay; if (df) { const d = Math.floor(n / 17982); const m10 = n % 17982; const extra = m10 < 1800 ? 0 : Math.floor((m10 - 1800) / 1798) + 1; const totalMin = d * 10 + extra; // Re-add the two dropped frames per dropped minute, then read as 30fps. n += 2 * (totalMin - Math.floor(totalMin / 10)); } const f = n % nominal; const secs = Math.floor(n / nominal); const pad = (v) => String(v).padStart(2, '0'); return `${pad(Math.floor(secs / 3600))}:${pad(Math.floor(secs / 60) % 60)}:${pad(secs % 60)}:${pad(f)}`; } /* ── timer math (module-scope, exported for tests) ──────────────────── */ /** * A timer's value at server time `now`: remaining ms for a countdown, * elapsed ms otherwise, or null when the snapshot can't say. The values as * of the snapshot (`elapsedMs` / `remainingMs`) are preferred and carried * forward while running; `startedAt` is the fallback when they are absent. */ export function valueAt(timer, sourceUpdatedAt, now) { const running = timer.state === 'running'; const at = Number(sourceUpdatedAt) || now; const dt = running ? Math.max(0, now - at) : 0; const has = (v) => v !== null && v !== undefined && Number.isFinite(v); if (timer.kind === 'countdown') { if (has(timer.remainingMs)) return timer.remainingMs - dt; if (has(timer.targetMs) && has(timer.elapsedMs)) return timer.targetMs - timer.elapsedMs - dt; if (running && has(timer.targetMs) && has(timer.startedAt)) return timer.targetMs - (now - timer.startedAt); return null; } if (has(timer.elapsedMs)) return timer.elapsedMs + dt; if (running && has(timer.startedAt)) return now - timer.startedAt; return null; } /** Signed ms → "M:SS" / "H:MM:SS" (whole seconds, floor of the magnitude). */ export function fmtDuration(ms) { const sign = ms < 0 ? '-' : ''; const s = Math.floor(Math.abs(ms) / 1000); const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const sec = s % 60; const pad = (v) => String(v).padStart(2, '0'); return h ? `${sign}${h}:${pad(m)}:${pad(sec)}` : `${sign}${m}:${pad(sec)}`; } function warnWindow(targetMs) { if (!targetMs) return WARN_FLOOR_MS; return Math.min(Math.max(WARN_FLOOR_MS, targetMs * WARN_FRACTION), targetMs / 2); } /** The state colour for a (non-clock) timer given its current value. */ export function toneFor(timer, value) { if (timer.state === 'running') { if (value === null) return 'ok'; if (timer.kind === 'countdown') { if (value <= 0) return 'danger'; if (value <= warnWindow(timer.targetMs)) return 'warn'; return 'ok'; } if (timer.targetMs !== null && timer.targetMs !== undefined && Number.isFinite(timer.targetMs)) { if (value >= timer.targetMs) return 'danger'; if (value >= timer.targetMs - warnWindow(timer.targetMs)) return 'warn'; } return 'ok'; } if (timer.state === 'stopped' && timer.kind === 'countdown' && value !== null && value <= 0) return 'warn'; return 'muted'; } function defaultStatus(timer, value, tone) { if (timer.state === 'running') return tone === 'danger' ? 'Overrun' : 'Running'; if (timer.state === 'paused') return 'Paused'; if (timer.state === 'idle') return 'Ready'; return timer.kind === 'countdown' && value !== null && value <= 0 ? 'Complete' : 'Stopped'; } /* ── the tile ─────────────────────────────────────────────────────────── */ export default function create({ root, moduleApi }) { let state = null; let feedOffline = false; let stream = null; let resizeObserver = null; let paintTimer = null; /** Date.now() minus the server's clock, refined from every snapshot. */ let clockOffset = NaN; let needFit = true; const settings = () => moduleApi.instanceSettings; const keyOf = (source, timer) => `${source.id}:${timer.id}`; /* Per-tile hidden timers, keyed source:id. Layouts saved by the earlier ProPresenter-only tile stored `hiddenItems` (uuids, and __ltc__ for the timecode) — read those too, and write them back in the new key. */ const hidden = new Set(Array.isArray(settings().hiddenTimers) ? settings().hiddenTimers.map(String) : []); for (const old of Array.isArray(settings().hiddenItems) ? settings().hiddenItems : []) { hidden.add(old === '__ltc__' ? LTC_KEY : `propresenter:${old}`); } /* Which picker entry this tile was added as (the module's tiles() list): '' or 'all' = every timer, selectable via the Timers ▾ menu; 'solo::' = exactly one card, no menu, no headings. The earlier entries 'timer:' and 'ltc' map onto the new keys. */ const variant = String(moduleApi.variant || ''); const solo = variant === 'ltc' ? LTC_KEY : variant.startsWith('timer:') ? `propresenter:${variant.slice('timer:'.length)}` : variant.startsWith('solo:') ? variant.slice('solo:'.length) : ''; /** Last label seen for a solo timer, so its card stays labeled if the timer is later deleted upstream. */ let lastSoloLabel = ''; /* ── DOM skeleton ───────────────────────────────────────────────── */ root.innerHTML = `
`; const wrap = root.querySelector('.tm-wrap'); const bannerEl = root.querySelector('.tm-banner'); const hintEl = root.querySelector('.tm-hint'); const groupsEl = root.querySelector('.tm-groups'); /** key → { key, el, nameEl, timeEl, statusEl, detailEl, item, chars, cls } */ const cards = new Map(); /** group id (source id) → { el, headEl, titleEl, noteEl, emptyEl, gridEl, count } */ const groupEls = new Map(); /** The tile's colour settings → custom properties (only when changed from the theme-following default). */ function applyColors() { const s = settings(); for (const [key, prop, fallback] of COLOR_SETTINGS) { const v = String(s[key] || '').trim().toLowerCase(); if (/^#[0-9a-f]{6}$/.test(v) && v !== fallback) wrap.style.setProperty(prop, v); else wrap.style.removeProperty(prop); } } /* ── server clock ───────────────────────────────────────────────── */ function noteServerClock(serverNow) { if (!Number.isFinite(serverNow)) return; const sample = Date.now() - serverNow; // A step (first sample, a resumed laptop) snaps; jitter is smoothed. if (!Number.isFinite(clockOffset) || Math.abs(sample - clockOffset) > 1000) clockOffset = sample; else clockOffset += (sample - clockOffset) * 0.2; } const serverNow = () => Date.now() - (Number.isFinite(clockOffset) ? clockOffset : 0); /* ── title-bar menu: hide / show individual timers ──────────────── */ let menuEl = null; // the open menu's element (filled on each open) const itemsMenu = solo ? null : moduleApi.header.addMenu({ label: 'Timers ▾', title: 'Choose which timers this tile shows', build(menu) { // the shell hands over an emptied element on every open menu.classList.add('tm-menu'); menuEl = menu; rebuildMenu(true); }, }); function menuRows() { const rows = []; for (const source of state?.sources || []) { for (const t of source.timers) rows.push({ key: keyOf(source, t), text: `${source.label} › ${t.label}` }); } return rows; } function persistHidden() { // `hiddenItems` was the old key: clearing it keeps a layout from // re-hiding something the user has since re-enabled here. moduleApi.saveInstanceSettings({ hiddenTimers: [...hidden], hiddenItems: undefined }); } function toggleTimer(key) { if (hidden.has(key)) hidden.delete(key); else hidden.add(key); persistHidden(); rebuildMenu(); render(); } function updateMenuLabel() { if (!itemsMenu) return; const rows = menuRows(); const visible = rows.filter((r) => !hidden.has(r.key)).length; itemsMenu.setLabel(visible === rows.length ? 'Timers ▾' : `Timers (${visible}/${rows.length}) ▾`); } let menuSignature = ''; function rebuildMenu(force = false) { updateMenuLabel(); if (!menuEl) return; // menu not opened yet — the label is enough const rows = menuRows(); // Snapshots arrive several times a second; rebuilding the open menu's // rows each time would replace them under the user's finger. Only a // changed row set (or a toggled switch) is worth touching the DOM for. const signature = JSON.stringify(rows.map((r) => [r.key, r.text, hidden.has(r.key)])); if (!force && signature === menuSignature && menuEl.childElementCount) return; menuSignature = signature; menuEl.innerHTML = ''; if (!rows.length) { const note = document.createElement('div'); note.className = 'tm-menu-note'; note.textContent = 'No timers known yet'; menuEl.appendChild(note); } for (const row of rows) { const on = !hidden.has(row.key); const item = document.createElement('button'); item.className = 'tm-menu-item' + (on ? ' on' : ''); item.setAttribute('role', 'menuitemcheckbox'); item.setAttribute('aria-checked', String(on)); const name = document.createElement('span'); name.className = 'name'; name.textContent = row.text; const sw = document.createElement('span'); sw.className = 'tm-switch'; item.append(name, sw); item.addEventListener('click', (e) => { // keep the menu open: the rebuild detaches this item, which would // make the shell's outside-click handler think we clicked outside e.stopPropagation(); toggleTimer(row.key); }); menuEl.appendChild(item); } } /* ── state → what this tile shows ───────────────────────────────── */ function sourceShown(source) { const key = SOURCE_SWITCH[source.kind] || 'showModules'; return settings()[key] !== false; } /** [{ id, source, items: [{ key, source, timer } | { key, missing }] }] */ function visibleGroups() { if (!state) return []; if (solo) { for (const source of state.sources) { for (const timer of source.timers) { if (keyOf(source, timer) !== solo) continue; lastSoloLabel = timer.label; return [{ id: source.id, source, items: [{ key: solo, source, timer }] }]; } } // The timer this tile was added for isn't offered (yet, or any more) — // keep a clearly-labeled card up rather than erroring. const sourceId = solo.slice(0, solo.indexOf(':')); const source = state.sources.find((s) => s.id === sourceId) || null; return [{ id: 'missing', source, items: [{ key: solo, missing: true, source }] }]; } const groups = []; for (const source of state.sources) { if (!sourceShown(source)) continue; const items = []; for (const timer of source.timers) { const key = keyOf(source, timer); if (!hidden.has(key)) items.push({ key, source, timer }); } // Every timer of a live source hidden on purpose: nothing to say. if (!items.length && source.timers.length) continue; groups.push({ id: source.id, source, items }); } return groups; } /* ── cards ──────────────────────────────────────────────────────── */ function makeCard(item) { const el = document.createElement('div'); el.className = 'tm-card'; const nameEl = document.createElement('div'); nameEl.className = 'tm-name'; const labelEl = document.createElement('span'); labelEl.className = 'tm-label'; nameEl.appendChild(labelEl); const timeEl = document.createElement('div'); timeEl.className = 'tm-time'; const statusEl = document.createElement('div'); statusEl.className = 'tm-status'; const detailEl = document.createElement('div'); detailEl.className = 'tm-detail'; el.append(nameEl, timeEl, statusEl, detailEl); return { key: item.key, el, nameEl, labelEl, timeEl, statusEl, detailEl, item, chars: 5, cls: '' }; } function setText(el, text) { if (el.textContent !== text) el.textContent = text; } function setCardClass(card, cls) { if (card.cls === cls) return; card.cls = cls; card.el.className = cls; } /** The parts of a card that only change with the snapshot (label, kind). */ function updateCard(card, item) { card.item = item; const isLtc = item.source?.kind === 'ltc'; const badge = card.nameEl.querySelector('.tm-ltc-badge'); if (isLtc && !badge) { const b = document.createElement('span'); b.className = 'tm-ltc-badge'; b.textContent = 'LTC'; card.nameEl.prepend(b); } else if (!isLtc && badge) { badge.remove(); } setText(card.labelEl, item.missing ? (lastSoloLabel || 'Timer') : item.timer.label); } function updateMissingCard(card) { const source = card.item.source; setText(card.timeEl, '—'); card.chars = 5; let note; if (!source) note = 'Source not available — check Admin'; else if (source.status === 'ok') note = `Not offered by ${source.label}`; else note = '—'; setText(card.statusEl, note); card.statusEl.hidden = false; card.statusEl.className = 'tm-status is-note'; card.statusEl.dataset.tone = source && source.status === 'ok' ? 'warn' : 'muted'; card.detailEl.hidden = true; setCardClass(card, 'tm-card is-muted is-missing'); } /* ── clocks ─────────────────────────────────────────────────────── */ function clockOptions() { const s = settings(); const h12 = s.clockFormat === '12h'; return { hour12: h12, hour: h12 ? 'numeric' : '2-digit', minute: '2-digit', ...(s.clockSeconds !== false ? { second: '2-digit' } : {}), }; } function wallClockText() { return new Date().toLocaleTimeString([], clockOptions()); } function dateText() { return new Date().toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' }); } /** A provider's kind:"clock" timer carries an ISO time in status.text as of its snapshot. */ function providerClockText(timer, source, now) { const raw = String(timer.status?.text || ''); const d = new Date(raw); if (!raw || Number.isNaN(d.getTime())) return raw || '—'; const dt = timer.state === 'running' ? Math.max(0, now - (Number(source.updatedAt) || now)) : 0; return new Date(d.getTime() + dt).toLocaleTimeString([], clockOptions()); } /* ── real-time LTC: count every frame between server anchors ─────── */ // The server anchors us ~4×/s with the decoded timecode, its rate and // how old it is (ageMs). Between anchors a requestAnimationFrame loop // advances the display at the LTC's own rate, so every frame paints — // without pushing 30 SSE messages a second at every dashboard. let ltcAnchor = null; // { frames, at, recvAt, rate, nominal, df } let ltcRaf = 0; let ltcShownFrames = -1; /** Anchors arrive ~every 250 ms while running. None for this long means the feed state is changing — hold rather than freewheel into phantom frames the next update would visibly rewind. */ const ANCHOR_FRESH_MS = 350; function ltcTimer() { const source = state?.sources.find((s) => s.kind === 'ltc'); return source ? source.timers.find((t) => t.id === 'ltc') || null : null; } function updateLtcAnchor() { const l = ltcTimer()?.ltc; // The listener is extrapolatable — it knows the frame rate. const live = l && l.status === 'running' && l.fps > 0; const tc = live ? parseTc(l.time) : null; if (!tc) { ltcAnchor = null; ltcShownFrames = -1; if (ltcRaf) { cancelAnimationFrame(ltcRaf); ltcRaf = 0; } return; } const nominal = l.df ? 30 : Math.round(l.fps); ltcAnchor = { frames: tcToFrames(tc, nominal, l.df), at: performance.now() - (Number(l.ageMs) || 0), recvAt: performance.now(), rate: l.df ? 30000 / 1001 : nominal, nominal, df: l.df, }; if (!ltcRaf) ltcRaf = requestAnimationFrame(ltcTick); } /** Paint the frame the anchor implies for right now. Called both by the rAF loop (smooth, per-frame, on visible tabs) and by paint() at the anchor rate — so a throttled/backgrounded tab, where rAF is paused, still advances instead of freezing. */ function paintLtcFrame() { const a = ltcAnchor; const card = cards.get(LTC_KEY); if (!a || !card) return; const limit = Math.min(performance.now(), a.recvAt + ANCHOR_FRESH_MS); let n = a.frames + Math.round(((limit - a.at) / 1000) * a.rate); // Anchor jitter (±1 frame) must never tick the display backwards; // a genuine jump back (re-strike, seek) is far larger and passes. if (ltcShownFrames >= 0 && n < ltcShownFrames && ltcShownFrames - n <= 2) n = ltcShownFrames; if (n !== ltcShownFrames) { ltcShownFrames = n; card.timeEl.textContent = framesToTc(n, a.nominal, a.df); } } function ltcTick() { ltcRaf = 0; // No anchor, or the LTC card is hidden in this tile: stop — the next // server anchor (4×/s while running) re-arms the loop via render(). if (!ltcAnchor || !cards.get(LTC_KEY)) return; paintLtcFrame(); ltcRaf = requestAnimationFrame(ltcTick); } /* ── painting the numbers (every PAINT_MS, and on every snapshot) ── */ function paint() { if (!state) return; const now = serverNow(); const showStatus = settings().showStatus !== false; let charsChanged = false; for (const card of cards.values()) { const { item } = card; if (item.missing) { updateMissingCard(card); continue; } const { source, timer } = item; let text; let tone; let statusText = ''; let statusTone = ''; let detailText = ''; let extra = ''; if (timer.kind === 'clock') { if (source.kind === 'ltc') { const l = timer.ltc || {}; text = l.time || timer.status?.text || '—'; if (timer.state === 'running') tone = 'ok'; else if (timer.state === 'stopped') tone = 'muted'; else { tone = 'muted'; extra = l.status === 'nosignal' ? ' is-nosignal' : ' is-unavailable'; } statusText = timer.detail || ''; statusTone = timer.status?.tone || (tone === 'ok' ? 'ok' : extra ? 'warn' : 'muted'); } else if (source.kind === 'clock') { text = wallClockText(); tone = 'plain'; statusText = settings().clockDate ? dateText() : ''; statusTone = 'muted'; } else { text = providerClockText(timer, source, now); tone = timer.state === 'running' ? 'plain' : 'muted'; statusText = timer.detail || ''; statusTone = timer.status?.tone || 'muted'; } } else { const value = valueAt(timer, source.updatedAt, now); text = value === null ? '—' : fmtDuration(value); tone = toneFor(timer, value); statusText = timer.status?.text || defaultStatus(timer, value, tone); statusTone = timer.status?.tone || tone; detailText = timer.detail || ''; if (tone === 'danger' && timer.state === 'running') extra = ' is-overrun'; } // The LTC card's digits belong to the frame counter while an anchor is live. if (!(card.key === LTC_KEY && ltcAnchor)) setText(card.timeEl, text); else paintLtcFrame(); const chars = Math.max(5, text.length); if (chars !== card.chars) { card.chars = chars; charsChanged = true; } // Notes that must survive the compact look: an unavailable LTC input. const isNote = extra === ' is-unavailable'; setText(card.statusEl, statusText); card.statusEl.hidden = !statusText || (!showStatus && !isNote); card.statusEl.className = 'tm-status' + (isNote ? ' is-note' : ''); card.statusEl.dataset.tone = statusTone; setText(card.detailEl, detailText); card.detailEl.hidden = !detailText || !showStatus; const kindCls = source.kind === 'ltc' ? ' tm-card-ltc' : source.kind === 'clock' ? ' tm-card-clock' : ''; setCardClass(card, `tm-card is-${tone}${extra}${kindCls}`); } if (charsChanged) fit(); } /* ── status dot + degraded banner ───────────────────────────────── */ function renderStatus() { wrap.classList.toggle('is-feed-down', feedOffline); if (feedOffline) { bannerEl.hidden = false; bannerEl.textContent = 'Not receiving updates from the ProdDash server.'; moduleApi.setStatus('error', 'ProdDash server unreachable — reconnecting…'); return; } bannerEl.hidden = true; if (!state) { moduleApi.setStatus('connecting', 'Connecting…'); return; } const groups = visibleGroups(); const shown = groups.map((g) => g.source).filter(Boolean); const say = (list) => list.map((s) => `${s.label}: ${s.message}`).join(' · '); const bad = shown.filter((s) => s.status === 'error'); if (bad.length) { moduleApi.setStatus('error', say(bad)); return; } const waiting = shown.filter((s) => s.status === 'connecting'); if (waiting.length) { moduleApi.setStatus('connecting', say(waiting)); return; } const n = groups.reduce((a, g) => a + g.items.filter((i) => !i.missing).length, 0); moduleApi.setStatus('ok', `${n} timer${n === 1 ? '' : 's'} from ${shown.length} source${shown.length === 1 ? '' : 's'}`); } /* ── main render: diff groups and cards, update in place ────────── */ function makeGroup(id) { const el = document.createElement('section'); el.className = 'tm-group'; el.dataset.source = id; const headEl = document.createElement('div'); headEl.className = 'tm-head'; const titleEl = document.createElement('span'); titleEl.className = 'tm-title'; const noteEl = document.createElement('span'); noteEl.className = 'tm-note'; headEl.append(titleEl, noteEl); const emptyEl = document.createElement('div'); emptyEl.className = 'tm-empty'; emptyEl.hidden = true; const gridEl = document.createElement('div'); gridEl.className = 'tm-grid'; el.append(headEl, emptyEl, gridEl); return { el, headEl, titleEl, noteEl, emptyEl, gridEl, count: 0 }; } function render() { renderStatus(); rebuildMenu(); updateLtcAnchor(); // Admin-chosen digit font. A font swap changes glyph metrics, so re-fit // the digit sizing when it actually changes. const mono = state?.timerFont === 'monospace'; if (mono !== wrap.classList.contains('tm-mono')) { wrap.classList.toggle('tm-mono', mono); needFit = true; } const s = settings(); const showHeadings = !solo && s.showHeadings !== false; const showStatus = s.showStatus !== false; wrap.classList.toggle('tm-headings', showHeadings); wrap.classList.toggle('tm-nostatus', !showStatus); let hint = ''; const groups = visibleGroups(); if (state && !state.sources.length) hint = 'Every source is switched off — turn one on in Admin.'; else if (state && !groups.length) hint = 'Nothing selected — switch a source on in this tile’s settings, or pick timers in the Timers ▾ menu.'; hintEl.hidden = !hint; hintEl.textContent = hint; groupsEl.hidden = Boolean(hint); if (hint || !state) return; let structureChanged = false; const wantedKeys = new Set(); const orderedGroups = []; for (const g of groups) { let ge = groupEls.get(g.id); if (!ge) { ge = makeGroup(g.id); groupEls.set(g.id, ge); structureChanged = true; } const source = g.source; const status = source ? source.status : 'ok'; const message = source ? source.message : ''; ge.headEl.hidden = !showHeadings; setText(ge.titleEl, source ? source.label : 'Timer'); setText(ge.noteEl, status !== 'ok' ? message : g.items.length ? '' : 'no timers'); // Without headings the source's trouble still has to be said somewhere. const emptyText = status !== 'ok' ? message : 'No timers'; const showEmpty = !showHeadings && (status !== 'ok' || !g.items.length); ge.emptyEl.hidden = !showEmpty; setText(ge.emptyEl, showEmpty ? emptyText : ''); ge.el.classList.toggle('is-down', status !== 'ok'); ge.el.classList.toggle('is-stale', status === 'error' && g.items.length > 0); const els = []; for (const item of g.items) { let card = cards.get(item.key); if (!card) { card = makeCard(item); cards.set(item.key, card); structureChanged = true; } updateCard(card, item); wantedKeys.add(item.key); els.push(card.el); } if (ge.count !== els.length) structureChanged = true; ge.count = els.length; if (els.length !== ge.gridEl.children.length || els.some((el, i) => ge.gridEl.children[i] !== el)) { ge.gridEl.replaceChildren(...els); structureChanged = true; } orderedGroups.push(ge.el); } for (const [key, card] of cards) { if (wantedKeys.has(key)) continue; card.el.remove(); cards.delete(key); structureChanged = true; } for (const [id, ge] of groupEls) { if (groups.some((g) => g.id === id)) continue; ge.el.remove(); groupEls.delete(id); structureChanged = true; } if (orderedGroups.length !== groupsEl.children.length || orderedGroups.some((el, i) => groupsEl.children[i] !== el)) { groupsEl.replaceChildren(...orderedGroups); structureChanged = true; } paint(); if (structureChanged || needFit) { needFit = false; fit(); } } /* ── sizing: the column count that gives the biggest digits ─────── */ function fit() { const list = [...cards.values()]; const n = list.length; const W = root.clientWidth; const H = root.clientHeight; if (!n || !W || !H) return; const PAD = H < 120 ? 4 : 10; const GAP = H < 120 ? 5 : 8; const CARD_PAD = 12; // a card's own vertical padding + borders const HEAD_H = 20; // a source heading line, margin included const groups = [...groupEls.values()].filter((g) => g.el.isConnected); const headings = wrap.classList.contains('tm-headings') ? groups.length : 0; const showStatus = !wrap.classList.contains('tm-nostatus'); // Lines under the digits besides the label: status, and detail if any card has one. const metaLines = showStatus ? 1 + (list.some((c) => !c.detailEl.hidden) ? 1 : 0) : 0; // For each column count, budget a card's full stack (label, digits, // lines) and score by the digit size it affords — the SMALLEST card's, // so no timer ends up unreadable. Cards too short for the full stack // drop to a compact look (label + digits, the colour carries the // state), but any layout with a readable full stack beats every // compact one. let best = null; for (let cols = 1; cols <= n; cols += 1) { const rows = headings ? groups.reduce((a, g) => a + Math.max(1, Math.ceil(g.count / cols)), 0) : Math.ceil(n / cols); const cw = (W - PAD * 2 - GAP * (cols - 1)) / cols - 18; const ch = (H - PAD * 2 - headings * HEAD_H - GAP * (rows - 1) - (headings ? (headings - 1) * GAP : 0)) / rows - CARD_PAD; if (cw <= 0 || ch <= 0) continue; const meta = Math.max(9, Math.min(22, Math.floor(ch * 0.16))); const fullBudget = ch - (1 + metaLines) * 1.3 * meta; const compactBudget = ch - 1.3 * meta; let full = Infinity; let compact = Infinity; for (const c of list) { const byWidth = (cw * 1.55) / c.chars; full = Math.min(full, byWidth, fullBudget); compact = Math.min(compact, byWidth, compactBudget); } const mode = full >= 14 ? 'full' : 'compact'; const size = mode === 'full' ? full : compact; const score = (mode === 'full' ? 1000 : 0) + size; if (!best || score > best.score) best = { cols, meta, mode, cw, budget: mode === 'full' ? fullBudget : compactBudget, score }; } if (!best) return; for (const g of groups) { g.gridEl.style.gridTemplateColumns = `repeat(${best.cols}, 1fr)`; // Space is shared in proportion to each group's rows, so a card is // the same height under every heading. g.el.style.flexGrow = String(Math.max(1, Math.ceil(g.count / best.cols))); } wrap.style.setProperty('--tm-pad', PAD + 'px'); wrap.style.setProperty('--tm-gap', GAP + 'px'); wrap.style.setProperty('--tm-meta-size', best.meta + 'px'); wrap.classList.toggle('tm-compact', best.mode === 'compact'); for (const c of list) { const px = Math.max(11, Math.floor(Math.min((best.cw * 1.55) / c.chars, best.budget))); c.timeEl.style.fontSize = px + 'px'; } } /* ── live feed ──────────────────────────────────────────────────── */ function takeState(next) { if (!next || typeof next !== 'object' || !Array.isArray(next.sources)) return; noteServerClock(Number(next.serverNow)); state = next; feedOffline = false; render(); } function connect() { stream = moduleApi.sse('/stream', { open() { feedOffline = false; render(); }, error() { // EventSource retries by itself (and the shell recreates closed // streams), but the tile must admit it is frozen meanwhile. feedOffline = true; render(); }, events: { state(ev) { let parsed; try { parsed = JSON.parse(ev.data); } catch { return; } takeState(parsed); }, }, }); } /* ── lifecycle ──────────────────────────────────────────────────── */ return { start() { applyColors(); moduleApi.setStatus('connecting', 'Connecting…'); // Instant paint from the snapshot, then live updates. moduleApi.fetch('/state') .then((res) => (res.ok ? res.json() : null)) .then((body) => { if (body && body.state && !state) takeState(body.state); }) .catch(() => { /* the stream will cover it */ }); connect(); let lastW = 0; let lastH = 0; resizeObserver = new ResizeObserver(() => { const w = Math.round(root.clientWidth); const h = Math.round(root.clientHeight); if (w === lastW && h === lastH) return; lastW = w; lastH = h; fit(); }); resizeObserver.observe(root); paintTimer = setInterval(paint, PAINT_MS); render(); }, stop() { stream?.close(); stream = null; clearInterval(paintTimer); paintTimer = null; resizeObserver?.disconnect(); resizeObserver = null; if (ltcRaf) { cancelAnimationFrame(ltcRaf); ltcRaf = 0; } cards.clear(); groupEls.clear(); root.innerHTML = ''; // header controls are removed by the shell }, onResize() { fit(); }, }; }