472 lines
18 KiB
JavaScript
472 lines
18 KiB
JavaScript
/* 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 = '';
|
||
},
|
||
};
|
||
}
|