From dde9a00ee1a17096c8ade4b952e73151c4cbec5b Mon Sep 17 00:00:00 2001 From: Waters Church Date: Tue, 15 Sep 2026 12:56:22 -0400 Subject: [PATCH] Timers: "Group by source" on the All timers tile, off by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Off, every timer sits in one grid filled row by row — a wrapping flex row rather than a CSS grid, so the last row's cards widen to use the whole width and nothing is left empty; a source in trouble is named in one line above the cards, and its own cards dim. On, the sections per source come back, and "Source headings" appears (showWhen) to hide their titles. Co-Authored-By: Claude Fable 5.1 --- modules/propresenter-timers/client.js | 37 +++++++++++++++++++------ modules/propresenter-timers/module.json | 9 +++++- modules/propresenter-timers/style.css | 16 +++++++++++ 3 files changed, 53 insertions(+), 9 deletions(-) diff --git a/modules/propresenter-timers/client.js b/modules/propresenter-timers/client.js index 7d81938..ec5f392 100644 --- a/modules/propresenter-timers/client.js +++ b/modules/propresenter-timers/client.js @@ -64,6 +64,7 @@ const LTC_KEY = 'ltc:ltc'; the same values module.json gives the full "All timers" schema. */ const DEFAULTS = { showNames: true, + groupBySource: false, showHeadings: true, showStatus: true, showDetail: true, @@ -391,7 +392,18 @@ export default function create({ root, moduleApi }) { if (!items.length && source.timers.length) continue; groups.push({ id: source.id, source, items }); } - return groups; + if (pref('groupBySource') === true) return groups; + // Grouping off: every timer in one grid, filled row by row. A source in + // trouble is still named, in one line above the cards. + const trouble = groups.filter((g) => g.source && g.source.status !== 'ok'); + return [{ + id: 'all', + source: null, + flat: true, + items: groups.flatMap((g) => g.items), + status: trouble.length ? 'error' : 'ok', + message: trouble.map((g) => `${g.source.label}: ${g.source.message || 'unavailable'}`).join(' · '), + }]; } /* ── cards ──────────────────────────────────────────────────────── */ @@ -677,7 +689,8 @@ export default function create({ root, moduleApi }) { setText(card.detailEl, detailText); card.detailEl.hidden = !detailText || !showDetail; const kindCls = source.kind === 'ltc' ? ' tm-card-ltc' : source.kind === 'clock' ? ' tm-card-clock' : ''; - setCardClass(card, `tm-card is-${tone}${extra}${kindCls}`); + const stale = source.status === 'error' ? ' is-stale' : ''; + setCardClass(card, `tm-card is-${tone}${extra}${kindCls}${stale}`); } if (charsChanged) fit(); } @@ -748,7 +761,8 @@ export default function create({ root, moduleApi }) { wrap.classList.toggle('tm-mono', mono); needFit = true; } - const showHeadings = !solo && pref('showHeadings') !== false; + // Headings belong to sections; without grouping there is one grid and no heading. + const showHeadings = !solo && pref('groupBySource') === true && pref('showHeadings') !== false; wrap.classList.toggle('tm-headings', showHeadings); for (const [cls, on] of [ ['tm-nostatus', pref('showStatus') === false], @@ -781,18 +795,24 @@ export default function create({ root, moduleApi }) { structureChanged = true; } const source = g.source; - const status = source ? source.status : 'ok'; - const message = source ? source.message : ''; + const status = g.status ?? (source ? source.status : 'ok'); + const message = g.message ?? (source ? source.message : ''); ge.headEl.hidden = !showHeadings; - setText(ge.titleEl, source ? source.label : 'Timer'); + setText(ge.titleEl, source ? source.label : 'Timers'); 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); + ge.emptyEl.dataset.tone = showEmpty && status !== 'ok' ? 'warn' : ''; + // In the flat grid a card dims for its own source (paint), not the whole grid. + ge.el.classList.toggle('is-down', !g.flat && status !== 'ok'); + ge.el.classList.toggle('is-stale', !g.flat && status === 'error' && g.items.length > 0); + if (ge.gridEl.classList.contains('is-flow') !== Boolean(g.flat)) { + ge.gridEl.classList.toggle('is-flow', Boolean(g.flat)); + structureChanged = true; + } const els = []; for (const item of g.items) { @@ -920,6 +940,7 @@ export default function create({ root, moduleApi }) { if (!best) return; for (const g of groups) { g.gridEl.style.gridTemplateColumns = `repeat(${best.cols}, 1fr)`; + g.gridEl.style.setProperty('--tm-cols', String(best.cols)); // 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))); diff --git a/modules/propresenter-timers/module.json b/modules/propresenter-timers/module.json index e236599..683de2b 100644 --- a/modules/propresenter-timers/module.json +++ b/modules/propresenter-timers/module.json @@ -156,11 +156,18 @@ "default": false, "group": "Show" }, + "groupBySource": { + "type": "switch", + "label": "Group by source", + "default": false, + "group": "Show" + }, "showHeadings": { "type": "switch", "label": "Source headings", "default": true, - "group": "Show" + "group": "Show", + "showWhen": "groupBySource" }, "clock24h": { "type": "switch", diff --git a/modules/propresenter-timers/style.css b/modules/propresenter-timers/style.css index c951199..a216138 100644 --- a/modules/propresenter-timers/style.css +++ b/modules/propresenter-timers/style.css @@ -39,6 +39,7 @@ visually, per group: the LTC card keeps ticking when only ProPresenter is unreachable. When the ProdDash feed is down everything is old. */ [data-module="propresenter-timers"] .tm-group.is-stale .tm-card, +[data-module="propresenter-timers"] .tm-card.is-stale, [data-module="propresenter-timers"] .tm-wrap.is-feed-down .tm-card { opacity: 0.45; } [data-module="propresenter-timers"] .tm-hint { @@ -126,6 +127,21 @@ } [data-module="propresenter-timers"] .tm-grid:empty { display: none; } +/* Grouping off: one grid for every timer, filled row by row. A wrapping + flex row instead of a CSS grid, so the last row's cards widen to use the + whole width — nothing is left empty. Rows share the height equally. */ +[data-module="propresenter-timers"] .tm-grid.is-flow { + display: flex; + flex-wrap: wrap; + align-content: stretch; + align-items: stretch; +} +[data-module="propresenter-timers"] .tm-grid.is-flow .tm-card { + flex: 1 1 calc((100% - var(--tm-gap, 8px) * (var(--tm-cols, 1) - 1)) / var(--tm-cols, 1)); + min-width: 0; +} +[data-module="propresenter-timers"] .tm-empty[data-tone="warn"] { color: var(--tm-warn); } + /* Compact cards (short tiles) and "Status lines" off: the lines under the digits go, the colour stays. A note that explains a missing value (no LTC input, a solo timer no longer offered) must never be hidden. */