Layouts keep every tile they are given, and show what is on offer

The bug behind this: for seventeen days the server rebuilt each saved tile
from the fields it knew and dropped the rest — including `variant`, so a
layout of five different timers came back from the server as five "All
timers". The client had carried `variant` since multi-tile modules arrived;
the server was the one that lagged, and it is the part of ProdDash that
always will (a booth updates weeks after the dev machine). So the server
stops deciding which tile fields exist: it checks the shape of the ones it
knows, keeps any other small plain-JSON field a tile carries, drops only
retired ones, stamps the file `v: 2`, and refuses a layout over a hundred
tiles instead of quietly saving the first hundred. A test round-trips a
fully-populated tile plus one the server has never heard of, and fails if
anything is lost.

Then the rule the arrangement is loaded by. A layout keeps every tile it is
given; the board shows the ones whose module is installed and enabled and —
for a tile added from a picker entry — whose entry is offered right now. The
rest keep their place and draw nothing, and appear the moment the registry
offers them again: a Service timer waits for PCO Plan, a transcript for its
channel. Never the module's default in their place. Because a module's entry
list can change with no event to mark it (PCO Plan comes back and, seconds
later, the timers module is offering its timers again), the registry is
re-read every few seconds while anything is folded away, and not at all
otherwise. The timers module's placeholder card for a missing solo timer
goes with this: nothing is drawn, the dot says why.

Loading says what it did — "Loaded 'FOH': 9 tiles, 2 not shown — Service,
#ops aren't offered right now" — with Undo in the message. Saving over a
name asks first, and the server keeps the copy it replaced one back; Admin →
Saved layouts gets Restore previous, which swaps the two so a restore is one
restore from undone. The browser keeps one good copy of its own layout
behind the live one and falls back to it when the live one will not parse.

Shell 1.8.0, timers 1.4.1. The layouts already saved on the booth machines
had their variants stripped before this and have to be saved again from a
browser that still holds the right tiles — nothing here recovers what the
files no longer contain.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-09-20 12:02:34 -04:00
co-authored by Claude Fable 5.1
parent c075655817
commit a7e40ee1a8
11 changed files with 494 additions and 62 deletions
+12 -34
View File
@@ -239,8 +239,6 @@ export default function create({ root, moduleApi }) {
: 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 ───────────────────────────────────────────────── */
@@ -376,22 +374,21 @@ export default function create({ root, moduleApi }) {
/* ── state → what this tile shows ───────────────────────────────── */
/** [{ id, source, items: [{ key, source, timer } | { key, missing }] }] */
/** [{ id, source, items: [{ key, source, timer }] }] */
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 }] }];
// The timer this tile was added for is not offered right now. Nothing is
// drawn for it — no placeholder that reads like a timer, and never the
// full list in its place. The shell folds the tile away on its next
// registry refresh (module guide, Presenting multiple tiles).
return [];
}
const groups = [];
for (const source of state.sources) {
@@ -451,7 +448,7 @@ export default function create({ root, moduleApi }) {
/** The parts of a card that only change with the snapshot (label, kind). */
function updateCard(card, item) {
card.item = item;
card.isClock = !item.missing && item.source?.kind === 'clock';
card.isClock = item.source?.kind === 'clock';
const isLtc = item.source?.kind === 'ltc';
const badge = card.nameEl.querySelector('.tm-ltc-badge');
if (isLtc && !badge) {
@@ -462,29 +459,13 @@ export default function create({ root, moduleApi }) {
} else if (!isLtc && badge) {
badge.remove();
}
const label = item.missing ? (lastSoloLabel || 'Timer') : card.isClock ? '' : item.timer.label;
const label = card.isClock ? '' : item.timer.label;
if (card.labelEl.textContent !== label) {
setText(card.labelEl, label);
needFit = true; // the title's size follows its length
}
}
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() {
@@ -633,10 +614,6 @@ export default function create({ root, moduleApi }) {
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;
@@ -735,8 +712,9 @@ export default function create({ root, moduleApi }) {
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'}`);
const n = groups.reduce((a, g) => a + g.items.length, 0);
if (solo && n === 0) moduleApi.setStatus('connecting', 'Not offered right now');
else 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 ────────── */
@@ -791,7 +769,7 @@ export default function create({ root, moduleApi }) {
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 title bar’s timers menu.';
else if (state && !groups.length && !solo) hint = 'Nothing selected — switch a source on in this tile’s settings, or pick timers in the title bar’s timers menu.';
hintEl.hidden = !hint;
hintEl.textContent = hint;
groupsEl.hidden = Boolean(hint);
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "propresenter-timers",
"name": "Timers",
"version": "1.4.0",
"version": "1.4.1",
"proddash": ">=1.4.0",
"description": "Timers, Clocks, and LTC From configured Modules",
"provides": [
-1
View File
@@ -295,7 +295,6 @@
}
/* A solo tile whose timer is no longer offered. */
[data-module="propresenter-timers"] .tm-card.is-missing { border-style: dashed; }
/* ── the LTC card: visually its own thing ── */