/* Service Times — the board. * * One card per campus, one row per service, and a mark on any service that * production has to treat differently this week. Everything is drawn from the * server's SSE state so every booth sees the same marks at the same moment; * nothing here talks to the website. * * Marking lives in the tile body, not the title bar, because the bar folds * away in fullscreen and on an auto-hiding dashboard — a control an operator * needs mid-service may not disappear with it. */ const STATUSES = [ { value: 'ok', label: 'Clear', short: '' }, { value: 'issue', label: 'Issue', short: 'Issue' }, { value: 'replay', label: 'Replay', short: 'Replay' }, { value: 'skip', label: 'Do not use', short: 'Do not use' }, ]; const ICON_REFRESH = ''; const ICON_CLEAR = ''; const ICON_SMALLER = ''; const ICON_BIGGER = ''; const TEXT_MIN = 0.8; const TEXT_MAX = 1.8; const TEXT_STEP = 0.1; export default function create({ root, moduleApi }) { let stream = null; let state = null; let editing = null; // { campus, service } while the editor is open let pendingRender = false; // a state arrived while the editor was open let board = null; let closeEditor = null; const settings = () => moduleApi.instanceSettings; function el(tag, className, text) { const node = document.createElement(tag); if (className) node.className = className; if (text != null) node.textContent = text; return node; } /** The campuses this tile is asked to show; no choice made means all of them. */ function visibleCampuses() { const all = state?.campuses || []; const chosen = settings().campuses; if (!Array.isArray(chosen) || !chosen.length) return all; return all.filter((campus) => chosen.includes(campus.slug)); } function textScale() { const saved = Number(settings().textSize); return Number.isFinite(saved) ? Math.min(TEXT_MAX, Math.max(TEXT_MIN, saved)) : 1; } function serviceLabel(service) { return service.label ? `${service.label} ${service.text}` : service.text; } /** Every service of the campus a resi campus takes its feed from. */ function sourceServices(campus) { if (!campus.source) return []; const origin = (state?.campuses || []).find((c) => c.slug === campus.source); return origin ? origin.services : []; } /** The mark on the origin service a resi service is taking, if any. */ function upstreamTrouble(campus, service) { const sourceId = service.mark?.source; if (!sourceId || !campus.source) return null; const origin = (state?.campuses || []).find((c) => c.slug === campus.source); const upstream = origin?.services.find((s) => s.id === sourceId); if (!upstream?.mark || upstream.mark.status === 'ok') return null; return { origin, upstream }; } /** Resi services pointed at this broadcast service. */ function dependentsOf(campus, service) { if (campus.role !== 'broadcast') return []; const out = []; for (const other of state?.campuses || []) { if (other.source !== campus.slug) continue; for (const theirs of other.services) { if (theirs.mark?.source === service.id) out.push({ campus: other, service: theirs }); } } return out; } /* ── drawing ──────────────────────────────────────────────────────── */ function renderServiceRow(campus, service) { const opts = settings(); const row = el('button', 'st-row'); row.type = 'button'; const status = service.mark?.status || 'ok'; if (status !== 'ok') row.classList.add(`st-${status}`); const time = el('span', 'st-time', serviceLabel(service)); row.appendChild(time); if (opts.showDay && service.day) row.appendChild(el('span', 'st-day', service.day)); const chips = el('span', 'st-chips'); const statusDef = STATUSES.find((s) => s.value === status); if (statusDef?.short) chips.appendChild(el('span', `st-chip st-chip-${status}`, statusDef.short)); // A resi service says which of the origin's services it is taking… if (opts.showSource && campus.role === 'resi' && service.mark?.source) { const origin = (state?.campuses || []).find((c) => c.slug === campus.source); const upstream = origin?.services.find((s) => s.id === service.mark.source); if (upstream) chips.appendChild(el('span', 'st-chip st-chip-source', `← ${upstream.text}`)); } // …and turns red when that service is the one in trouble. const trouble = upstreamTrouble(campus, service); if (trouble) { row.classList.add('st-affected'); const what = STATUSES.find((s) => s.value === trouble.upstream.mark.status)?.short || 'marked'; chips.appendChild(el('span', 'st-chip st-chip-skip', `source ${what.toLowerCase()}`)); } // A broadcast service says who is riding on it. if (opts.showDependents && campus.role === 'broadcast') { const riders = dependentsOf(campus, service); if (riders.length && status !== 'ok') { chips.appendChild(el('span', 'st-chip st-chip-warn', `affects ${riders.map((r) => r.campus.name).join(', ')}`)); } else if (riders.length) { chips.appendChild(el('span', 'st-chip st-chip-quiet', `→ ${riders.length}`)); } } row.appendChild(chips); if (opts.showNotes && service.mark?.note) { row.appendChild(el('span', 'st-note', service.mark.note)); } row.addEventListener('click', (event) => { event.stopPropagation(); openEditor(campus, service, row); }); return row; } function renderCampus(campus) { const opts = settings(); const card = el('section', 'st-campus'); if (campus.role === 'broadcast') card.classList.add('st-is-broadcast'); const head = el('header', 'st-campus-head'); head.appendChild(el('h3', 'st-campus-name', campus.name)); if (opts.showRole) { if (campus.role === 'broadcast') { head.appendChild(el('span', 'st-role st-role-broadcast', 'Broadcast')); } else if (campus.source) { head.appendChild(el('span', 'st-role', campus.sourceMissing ? `resi ← ${campus.sourceName} (not listed)` : `resi ← ${campus.sourceName}`)); } else { head.appendChild(el('span', 'st-role st-role-unset', 'no resi source')); } } if (campus.manual) head.appendChild(el('span', 'st-role st-role-manual', 'manual')); card.appendChild(head); if (opts.showDependents && campus.role === 'broadcast' && campus.dependents?.length) { card.appendChild(el('p', 'st-feeds', `feeds ${campus.dependents.map((d) => d.name).join(', ')}`)); } if (!campus.services.length) { card.appendChild(el('p', 'st-empty', campus.error || 'No times published')); return card; } const list = el('div', 'st-rows'); for (const service of campus.services) list.appendChild(renderServiceRow(campus, service)); card.appendChild(list); return card; } function render() { if (closeEditor) closeEditor(); root.innerHTML = ''; board = el('div', 'st-board'); board.style.fontSize = `${textScale()}em`; root.appendChild(board); if (!state) { board.appendChild(el('p', 'st-empty', 'Waiting for the server…')); return; } if (!state.configured) { board.appendChild(el('p', 'st-empty', 'No website address configured — set one in Admin.')); return; } const campuses = visibleCampuses(); if (!campuses.length) { board.appendChild(el('p', 'st-empty', state.reachable ? 'No campuses to show. Pick some in Admin, or in this tile’s settings.' : 'No service times yet.')); } else { const grid = el('div', settings().layout === 'list' ? 'st-grid st-grid-list' : 'st-grid'); for (const campus of campuses) grid.appendChild(renderCampus(campus)); board.appendChild(grid); } if (!state.reachable && state.lastError) { board.appendChild(el('p', 'st-warn', `Website unreachable — ${state.lastError}. Showing the last times read.`)); } if (settings().showUpdated && state.lastFetched) { board.appendChild(el('p', 'st-updated', `Times read ${new Date(state.lastFetched).toLocaleString([], { hour: 'numeric', minute: '2-digit', month: 'short', day: 'numeric' })}`)); } } /* ── the mark editor ──────────────────────────────────────────────── */ function openEditor(campus, service, anchor) { if (closeEditor) closeEditor(); editing = { campus: campus.slug, service: service.id }; const panel = el('div', 'st-editor'); panel.appendChild(el('div', 'st-editor-title', `${campus.name} — ${serviceLabel(service)}`)); const mark = service.mark || { status: 'ok', note: '', source: '' }; let status = mark.status || 'ok'; const row = el('div', 'st-editor-status'); const buttons = new Map(); for (const def of STATUSES) { const button = el('button', 'st-pick', def.label); button.type = 'button'; if (def.value === status) button.classList.add('active'); button.addEventListener('click', () => { status = def.value; for (const [value, node] of buttons) node.classList.toggle('active', value === status); }); buttons.set(def.value, button); row.appendChild(button); } panel.appendChild(row); // Which of the origin's services this one is taking — the answer to // "which service, and where, can be altered". let sourceSelect = null; const origins = campus.role === 'resi' ? sourceServices(campus) : []; if (origins.length) { const wrap = el('label', 'st-editor-field'); wrap.appendChild(el('span', 'st-editor-label', `Taking from ${campus.sourceName}`)); sourceSelect = el('select', 'st-editor-input'); const none = el('option', null, '— not set —'); none.value = ''; sourceSelect.appendChild(none); for (const candidate of origins) { const option = el('option', null, serviceLabel(candidate)); option.value = candidate.id; if (candidate.id === mark.source) option.selected = true; sourceSelect.appendChild(option); } wrap.appendChild(sourceSelect); panel.appendChild(wrap); } const noteWrap = el('label', 'st-editor-field'); noteWrap.appendChild(el('span', 'st-editor-label', 'Note')); const note = el('input', 'st-editor-input'); note.type = 'text'; note.maxLength = 280; note.value = mark.note || ''; note.placeholder = 'What production needs to know'; noteWrap.appendChild(note); panel.appendChild(noteWrap); const actions = el('div', 'st-editor-actions'); const save = el('button', 'st-pick st-save', 'Save'); save.type = 'button'; const cancel = el('button', 'st-pick', 'Cancel'); cancel.type = 'button'; actions.append(save, cancel); panel.appendChild(actions); async function commit() { const body = { campus: campus.slug, service: service.id, status, note: note.value.trim(), source: sourceSelect ? sourceSelect.value : (mark.source || ''), }; close(); try { const response = await moduleApi.fetch('/mark', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (!response.ok) throw new Error(`${response.status}`); } catch (err) { moduleApi.setStatus('error', `Could not save the mark: ${err.message || err}`); } } save.addEventListener('click', commit); cancel.addEventListener('click', () => close()); note.addEventListener('keydown', (event) => { if (event.key === 'Enter') commit(); if (event.key === 'Escape') close(); }); panel.addEventListener('click', (event) => event.stopPropagation()); function onOutside() { close(); } function onKey(event) { if (event.key === 'Escape') close(); } function close() { if (closeEditor !== close) return; panel.remove(); anchor?.classList.remove('st-open'); root.removeEventListener('click', onOutside); document.removeEventListener('keydown', onKey); closeEditor = null; editing = null; if (pendingRender) { pendingRender = false; render(); // closeEditor is already null, so this cannot re-enter } } closeEditor = close; anchor?.classList.add('st-open'); board.appendChild(panel); root.addEventListener('click', onOutside); document.addEventListener('keydown', onKey); note.focus(); note.select(); } /* ── lifecycle ────────────────────────────────────────────────────── */ function applyState(next) { state = next; if (!state) { moduleApi.setStatus('connecting', 'Waiting for the server'); } else if (!state.configured) { moduleApi.setStatus('error', 'No website address configured'); } else if (!state.reachable) { moduleApi.setStatus('error', state.lastError ? `Website unreachable — ${state.lastError}` : 'Website unreachable'); } else if (state.lastError) { moduleApi.setStatus('connecting', state.lastError); } else { const count = (state.campuses || []).reduce((sum, c) => sum + c.services.length, 0); moduleApi.setStatus('ok', `${count} service(s) across ${(state.campuses || []).length} campus(es)`); } // Re-rendering would close an open editor mid-sentence, so hold the change // back while someone is typing — and draw it the moment they are done, or // the board would sit stale until the next change happened to arrive. if (editing) { pendingRender = true; return; } render(); } async function loadState() { try { const response = await moduleApi.fetch('/state'); const body = await response.json(); applyState(body.state); } catch { /* the stream will bring it */ } } function setTextSize(delta) { const next = Math.min(TEXT_MAX, Math.max(TEXT_MIN, textScale() + delta)); moduleApi.saveInstanceSettings({ textSize: Number(next.toFixed(2)) }); if (board) board.style.fontSize = `${next}em`; } return { start() { moduleApi.setStatus('connecting', 'Reading service times'); render(); moduleApi.header.addButton({ icon: ICON_REFRESH, title: 'Refresh times', onClick: async () => { moduleApi.setStatus('connecting', 'Re-reading the website'); try { const response = await moduleApi.fetch('/refresh', { method: 'POST' }); const body = await response.json(); if (body.state) applyState(body.state); } catch (err) { moduleApi.setStatus('error', `Refresh failed: ${err.message || err}`); } }, }); moduleApi.header.addButton({ icon: ICON_CLEAR, title: 'Clear marks', onClick: async () => { const marked = (state?.campuses || []).reduce( (sum, campus) => sum + campus.services.filter((s) => s.mark).length, 0 ); if (!marked) return; // Marks are shared by every campus watching — never wipe them silently. if (!window.confirm(`Clear ${marked} mark${marked === 1 ? '' : 's'} for every campus?`)) return; try { await moduleApi.fetch('/clear', { method: 'POST' }); } catch (err) { moduleApi.setStatus('error', `Could not clear marks: ${err.message || err}`); } }, }); moduleApi.header.addButton({ icon: ICON_SMALLER, title: 'Smaller text', onClick: () => setTextSize(-TEXT_STEP), }); moduleApi.header.addButton({ icon: ICON_BIGGER, title: 'Larger text', onClick: () => setTextSize(TEXT_STEP), }); stream = moduleApi.sse('/stream', { open() { // The shared connection was (re)made — we may have missed a change. loadState(); }, error() { moduleApi.setStatus('error', 'Lost the connection to ProdDash'); }, events: { state(event) { try { applyState(JSON.parse(event.data)); } catch { /* a truncated frame; the next one will be whole */ } }, }, }); loadState(); }, stop() { pendingRender = false; // nothing to catch up on: the tile is going away if (closeEditor) closeEditor(); stream?.close(); stream = null; state = null; editing = null; board = null; root.innerHTML = ''; }, }; }