Files
Service-Times/client.js
T

472 lines
18 KiB
JavaScript
Raw Normal View History

2026-10-07 18:45:41 -04:00
/* 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 = '<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true">'
+ '<path d="M13.5 8a5.5 5.5 0 1 1-1.6-3.9" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>'
+ '<path d="M13.6 1.9v3.2h-3.2" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>';
const ICON_CLEAR = '<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true">'
+ '<path d="M3 4h10M6.5 4V2.8h3V4M4.4 4l.7 8.4a1 1 0 0 0 1 .9h3.8a1 1 0 0 0 1-.9L11.6 4"'
+ ' fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>';
const ICON_SMALLER = '<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true">'
+ '<path d="M3.5 8h9" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>';
const ICON_BIGGER = '<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true">'
+ '<path d="M3.5 8h9M8 3.5v9" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>';
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 = '';
},
};
}