@acms-subir/core 0.3.48 → 0.3.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,426 @@
1
+ // The TV display's board as HTML: week, day and rooms (hour grids), month,
2
+ // list and now/next. Everything is rendered on the server; the page's small
3
+ // script (DisplayPage.astro) only moves the now line, dims what has ended,
4
+ // pages the list and swaps in a fresh board every few minutes. Nothing here
5
+ // can be focused or followed: no links, buttons or tabindex.
6
+ //
7
+ // Every length is a multiple of --dsp-u (the viewport height times the text
8
+ // size), so a board lays out the same at 960×540 and at 1920×1080, and the
9
+ // line counts below (month cells, the all-day row) are exact for 16:9.
10
+ // Stable class names (.acms-display, .display-*) are the styling contract;
11
+ // themes set the --display-* variables (display.css).
12
+ import { addDays, layoutDay, layoutWeek, type GridItem, type Iso } from '../calendar/grid';
13
+ import { tzParts } from '../calendar/ics';
14
+ import { SIZE_SCALE, type DisplaySettings, type HourRange } from './settings';
15
+ import { fill, type DisplayStrings } from './words';
16
+
17
+ export interface DisplayRoom { id: string; slug: string; name: string; color: string }
18
+
19
+ export interface DisplayItem {
20
+ key: string;
21
+ kind: 'event' | 'feed' | 'booking';
22
+ title: string;
23
+ /** a second line: the event's own title under "Booked", nothing otherwise */
24
+ detail: string;
25
+ start: number;
26
+ end: number;
27
+ allDay: boolean;
28
+ /** local days covered: first and last (inclusive) */
29
+ startDay: Iso;
30
+ endDay: Iso;
31
+ startMin: number;
32
+ endMin: number;
33
+ color: string;
34
+ rooms: DisplayRoom[];
35
+ hosts: string[];
36
+ cancelled: boolean;
37
+ /** a moved date: its old start and end (ms) */
38
+ moved: { start: number; end: number } | null;
39
+ calendar: string;
40
+ }
41
+
42
+ export interface DisplayModel {
43
+ s: DisplaySettings;
44
+ now: number;
45
+ tz: string;
46
+ /** the Intl locale for dates and times */
47
+ locale: string;
48
+ words: DisplayStrings;
49
+ today: Iso;
50
+ /** grid columns / list days, in order */
51
+ days: Iso[];
52
+ /** month view: whole weeks (columns already filtered to `days`' weekdays) */
53
+ weeks: Iso[][];
54
+ month: string;
55
+ hours: HourRange;
56
+ items: DisplayItem[];
57
+ /** view=rooms columns, in order */
58
+ rooms: DisplayRoom[];
59
+ legend: { name: string; color: string }[];
60
+ /** local midnight (ms) of each day in `days` and `weeks` */
61
+ midnight: (day: Iso) => number;
62
+ /** an SVG to show with show=qr ('' = none) */
63
+ qr: string;
64
+ }
65
+
66
+ const isoDay = (ms: number, tz: string) => { const p = tzParts(ms, tz); return `${p.y}-${String(p.m).padStart(2, '0')}-${String(p.d).padStart(2, '0')}`; };
67
+ const esc = (s: string) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
68
+ const pct = (n: number) => `${Math.round(n * 1000) / 1000}%`;
69
+
70
+ // ---- layout metrics (in --dsp-u; display.css uses the same numbers) -------------
71
+ export const METRICS = {
72
+ // page padding (top + bottom), header, the gap under it, the legend row
73
+ chrome: 5 + 8 + 2,
74
+ legend: 5,
75
+ dow: 3.5, // month weekday row
76
+ date: 3.4, // month cell's date label
77
+ line: 3.3, // one line of a month cell or the all-day row
78
+ cellPad: 0.6,
79
+ dayHead: 4.5, // hour grids: weekday + date
80
+ hoursGap: 0.8, // between the top row and the hours
81
+ // a timed block: padding + border, one title line, the gap, one meta line
82
+ blockPad: 1,
83
+ blockTitle: 2.26,
84
+ blockMeta: 1.9,
85
+ };
86
+ // a block this tall (in --dsp-u) fits the title and the meta line; two title lines and the meta
87
+ const FITS_META = METRICS.blockPad + METRICS.blockTitle + METRICS.blockMeta;
88
+ const FITS_TWO = FITS_META + METRICS.blockTitle;
89
+ const ALLDAY_LINES = { week: 2, day: 3 };
90
+
91
+ /** usable board height in --dsp-u for this size */
92
+ export function boardHeight(s: DisplaySettings): number {
93
+ return 100 / SIZE_SCALE[s.size] - METRICS.chrome - (s.show.includes('legend') ? METRICS.legend : 0);
94
+ }
95
+ /** how many event lines fit in a month cell */
96
+ export function monthLines(s: DisplaySettings, weeks: number): number {
97
+ const cell = (boardHeight(s) - METRICS.dow) / Math.max(1, weeks);
98
+ return Math.max(1, Math.floor((cell - METRICS.date - METRICS.cellPad) / METRICS.line + 0.001));
99
+ }
100
+
101
+ // ---- formatting ------------------------------------------------------------
102
+
103
+ function formatter(locale: string, tz: string, opts: Intl.DateTimeFormatOptions) {
104
+ try { return new Intl.DateTimeFormat(locale, { ...opts, timeZone: tz }); }
105
+ catch { return new Intl.DateTimeFormat('en-US', { ...opts, timeZone: 'UTC' }); }
106
+ }
107
+
108
+ export function timeFormat(locale: string, tz: string) {
109
+ const f = formatter(locale, tz, { hour: 'numeric', minute: '2-digit' });
110
+ // "7 PM", "7:30 PM"; 24-hour clocks keep their minutes ("19:00")
111
+ return (ms: number) => {
112
+ const parts = f.formatToParts(ms);
113
+ const hasPeriod = parts.some((p) => p.type === 'dayPeriod');
114
+ return parts.filter((p, i) => !(hasPeriod && p.type === 'minute' && p.value === '00') && !(hasPeriod && p.type === 'literal' && parts[i + 1]?.type === 'minute' && parts[i + 1].value === '00')).map((p) => p.value).join('');
115
+ };
116
+ }
117
+
118
+ // black or white text on a block colour
119
+ export function inkFor(hex: string): string {
120
+ const m = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex);
121
+ if (!m) return '';
122
+ const lin = (v: string) => { const c = parseInt(v, 16) / 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; };
123
+ const l = 0.2126 * lin(m[1]) + 0.7152 * lin(m[2]) + 0.0722 * lin(m[3]);
124
+ return l > 0.36 ? '#111111' : '#ffffff';
125
+ }
126
+
127
+ const colorStyle = (it: DisplayItem) => (it.color ? `--ev:${it.color};--ev-ink:${inkFor(it.color)};` : '');
128
+
129
+ function itemAttrs(it: DisplayItem, m: DisplayModel, extra = '') {
130
+ const cls = ['display-event', `is-${it.kind}`, it.cancelled ? 'is-cancelled' : '', it.moved ? 'is-moved' : '', it.end <= m.now ? 'is-past' : '', extra].filter(Boolean).join(' ');
131
+ const room = it.rooms[0]?.slug;
132
+ return `class="${cls}" data-end="${it.end}"${room ? ` data-room="${esc(room)}"` : ''}${it.calendar ? ` data-cal="${esc(it.calendar)}"` : ''}`;
133
+ }
134
+
135
+ /** time, rooms, hosts and the moved/cancelled notes, as the show= setting asks */
136
+ function metaLine(it: DisplayItem, m: DisplayModel, opts: { time?: boolean } = {}): string {
137
+ const t = timeFormat(m.locale, m.tz);
138
+ const show = m.s.show;
139
+ const bits: string[] = [];
140
+ if ((opts.time ?? true) && show.includes('time')) {
141
+ bits.push(it.allDay ? esc(m.words.allDay) : `${esc(t(it.start))} – ${esc(t(it.end))}`);
142
+ }
143
+ // the rooms view's columns already name the room
144
+ if (show.includes('room') && it.rooms.length && m.s.view !== 'rooms') bits.push(esc(it.rooms.map((r) => r.name).join(', ')));
145
+ if (show.includes('host') && it.hosts.length) bits.push(esc(it.hosts.join(', ')));
146
+ const notes: string[] = [];
147
+ if (it.cancelled) notes.push(`<span class="display-flag">${esc(m.words.cancelled)}</span>`);
148
+ if (it.moved) {
149
+ // its old time, with the weekday when it moved to another day
150
+ const sameDay = isoDay(it.moved.start, m.tz) === it.startDay;
151
+ const day = sameDay ? '' : formatter(m.locale, m.tz, { weekday: 'short' }).format(it.moved.start);
152
+ const was = [day, it.allDay ? '' : t(it.moved.start)].filter(Boolean).join(' ');
153
+ notes.push(`<span class="display-flag">${esc(m.words.moved)}</span>${was ? ` <span class="display-was">${esc(fill(m.words.was, { time: was }))}</span>` : ''}`);
154
+ }
155
+ return [...notes, ...bits.map((b) => `<span>${b}</span>`)].join('<span class="display-sep"> · </span>');
156
+ }
157
+
158
+ // ---- hour grids (week, day, rooms) -----------------------------------------
159
+
160
+ const isTimedOn = (it: DisplayItem, day: Iso) => !it.allDay && it.startDay === day && it.endDay === day;
161
+ const spansDay = (it: DisplayItem, day: Iso) => it.startDay <= day && it.endDay >= day;
162
+ const isTopRow = (it: DisplayItem) => it.allDay || it.endDay > it.startDay;
163
+
164
+ interface Shown extends GridItem { it: DisplayItem; cut: '' | 'start' | 'end' | 'both' }
165
+
166
+ // where a timed event sits in the visible hours: cut at the edges, and a
167
+ // short stub at the top or bottom edge when it's entirely outside them
168
+ function inRange(it: DisplayItem, h: HourRange): Shown {
169
+ const span = h.to - h.from;
170
+ const stub = Math.max(30, Math.round(span * 0.07));
171
+ const s = it.startMin, e = Math.max(it.endMin, it.startMin + 15);
172
+ let ds: number, de: number, cut: Shown['cut'];
173
+ if (e <= h.from) { ds = h.from; de = h.from + stub; cut = 'start'; }
174
+ else if (s >= h.to) { ds = h.to - stub; de = h.to; cut = 'end'; }
175
+ else {
176
+ ds = Math.max(s, h.from); de = Math.min(e, h.to);
177
+ cut = s < h.from && e > h.to ? 'both' : s < h.from ? 'start' : e > h.to ? 'end' : '';
178
+ }
179
+ return { key: it.key, title: it.title, startDay: it.startDay, endDay: it.endDay, allDay: false, startMin: ds, endMin: de, it, cut };
180
+ }
181
+
182
+ // `area`: the hours' height in --dsp-u, so a block only gets the lines it can show whole
183
+ function timedBlock(p: { item: Shown; col: number; cols: number }, m: DisplayModel, area: number): string {
184
+ const { it, cut } = p.item;
185
+ const span = m.hours.to - m.hours.from;
186
+ const top = (p.item.startMin - m.hours.from) / span * 100;
187
+ const height = (p.item.endMin - p.item.startMin) / span * 100;
188
+ const tall = height / 100 * area;
189
+ const fit = tall >= FITS_TWO ? '' : tall >= FITS_META ? 'is-mid' : 'is-short';
190
+ const style = `top:${pct(top)};height:${pct(height)};left:${pct(p.col / p.cols * 100)};width:${pct(100 / p.cols)};${colorStyle(it)}`;
191
+ const marks = cut ? ` data-cut="${cut}"` : '';
192
+ const before = cut === 'start' || cut === 'both' ? '<span class="display-cut" aria-hidden="true">▲</span>' : '';
193
+ const after = cut === 'end' || cut === 'both' ? '<span class="display-cut" aria-hidden="true">▼</span>' : '';
194
+ const detail = it.detail && !fit ? `<span class="display-event-detail">${esc(it.detail)}</span>` : '';
195
+ const meta = fit === 'is-short' ? '' : `<span class="display-event-meta">${metaLine(it, m)}</span>`;
196
+ return `<div ${itemAttrs(it, m, fit ? `display-block ${fit}` : 'display-block')}${marks} style="${style}"><span class="display-event-title">${before}${esc(it.title)}${after}</span>${detail}${meta}</div>`;
197
+ }
198
+
199
+ function hourAxis(m: DisplayModel): string {
200
+ // labels name wall-clock hours: read them off a day without DST changes
201
+ const t = timeFormat(m.locale, 'UTC');
202
+ const span = m.hours.to - m.hours.from;
203
+ const first = Math.ceil(m.hours.from / 60) * 60;
204
+ const out: string[] = [];
205
+ // a label every hour, every two when the range is long
206
+ const step = span > 16 * 60 ? 120 : 60;
207
+ for (let min = first; min < m.hours.to; min += step) {
208
+ const at = Date.UTC(2000, 0, 3) + min * 60_000;
209
+ out.push(`<span class="display-hour" style="top:${pct((min - m.hours.from) / span * 100)}">${esc(t(at))}</span>`);
210
+ }
211
+ return `<div class="display-axis" aria-hidden="true">${out.join('')}</div>`;
212
+ }
213
+
214
+ function hourLines(m: DisplayModel): string {
215
+ const span = m.hours.to - m.hours.from;
216
+ const out: string[] = [];
217
+ for (let min = Math.ceil(m.hours.from / 60) * 60; min < m.hours.to; min += 60) {
218
+ if (min === m.hours.from) continue;
219
+ out.push(`<span class="display-line" style="top:${pct((min - m.hours.from) / span * 100)}"></span>`);
220
+ }
221
+ return `<div class="display-lines" aria-hidden="true">${out.join('')}</div>`;
222
+ }
223
+
224
+ const nowLine = (day: Iso) => `<div class="display-now" data-now-day="${day}" hidden></div>`;
225
+
226
+ // the top row: all-day and multi-day events, as bars across consecutive
227
+ // columns (layoutWeek), a "+N more" when a day has more than fits
228
+ function topRow(cols: Iso[], items: DisplayItem[], m: DisplayModel, maxLines: number): { html: string; lines: number } {
229
+ const top = items.filter(isTopRow);
230
+ if (!top.some((it) => cols.some((d) => spansDay(it, d)))) return { html: '', lines: 0 };
231
+ const t = timeFormat(m.locale, m.tz);
232
+ const runs: { from: number; days: Iso[] }[] = [];
233
+ cols.forEach((d, i) => {
234
+ const last = runs[runs.length - 1];
235
+ if (last && addDays(last.days[last.days.length - 1], 1) === d) last.days.push(d);
236
+ else runs.push({ from: i, days: [d] });
237
+ });
238
+ const grid = top.map((it): GridItem & { it: DisplayItem } => ({ key: it.key, title: it.title, startDay: it.startDay, endDay: it.endDay, allDay: true, startMin: 0, endMin: 1440, it }));
239
+ const cells: string[] = [];
240
+ let used = 1;
241
+ for (const run of runs) {
242
+ const lay = layoutWeek(run.days, grid, maxLines);
243
+ for (const b of lay.bars) {
244
+ used = Math.max(used, b.lane + 1);
245
+ const it = b.item.it;
246
+ const label = it.allDay ? '' : `<span class="display-bar-time">${esc(t(it.start))}</span> `;
247
+ cells.push(`<div ${itemAttrs(it, m, 'display-bar')} style="grid-column:${run.from + b.col + 1} / span ${b.span};grid-row:${b.lane + 1};${colorStyle(it)}">`
248
+ + `<span class="display-event-title">${label}${esc(it.title)}</span></div>`);
249
+ }
250
+ lay.hidden.forEach((n, c) => {
251
+ if (!n) return;
252
+ used = maxLines;
253
+ cells.push(`<div class="display-more" style="grid-column:${run.from + c + 1};grid-row:${maxLines}">${esc(fill(m.words.more, { n }))}</div>`);
254
+ });
255
+ }
256
+ return { html: `<div class="display-top" style="--lines:${used}">${cells.join('')}</div>`, lines: used };
257
+ }
258
+
259
+ // the hours' height in --dsp-u under a top row of `lines`
260
+ const hoursArea = (m: DisplayModel, lines: number) =>
261
+ boardHeight(m.s) - METRICS.dayHead - (lines ? lines * METRICS.line + 0.6 : 0) - METRICS.hoursGap;
262
+
263
+ function dayHead(day: Iso, m: DisplayModel, label?: string): string {
264
+ const at = m.midnight(day) + 12 * 3_600_000;
265
+ const wd = formatter(m.locale, m.tz, { weekday: 'short' }).format(at);
266
+ const n = tzParts(at, m.tz).d;
267
+ const cls = ['display-dayhead', day === m.today ? 'is-today' : '', day < m.today ? 'is-past' : ''].filter(Boolean).join(' ');
268
+ return `<div class="${cls}">${label ? `<span class="display-dayname">${esc(label)}</span>` : `<span class="display-dayname">${esc(wd)}</span> <span class="display-daynum">${n}</span>`}</div>`;
269
+ }
270
+
271
+ function weekGrid(m: DisplayModel): string {
272
+ const cols = m.days;
273
+ const head = cols.map((d) => dayHead(d, m)).join('');
274
+ const top = topRow(cols, m.items, m, m.s.view === 'day' ? ALLDAY_LINES.day : ALLDAY_LINES.week);
275
+ const area = hoursArea(m, top.lines);
276
+ const columns = cols.map((d) => {
277
+ const placed = layoutDay(d, m.items.filter((it) => isTimedOn(it, d)).map((it) => inRange(it, m.hours)));
278
+ const cls = ['display-col', d === m.today ? 'is-today' : '', d < m.today ? 'is-past' : ''].filter(Boolean).join(' ');
279
+ return `<div class="${cls}" data-day="${d}">${placed.map((p) => timedBlock(p, m, area)).join('')}${nowLine(d)}</div>`;
280
+ }).join('');
281
+ return `<div class="display-grid" style="--cols:${cols.length}" data-from="${m.hours.from}" data-to="${m.hours.to}">`
282
+ + `<div class="display-heads">${head}</div>${top.html}`
283
+ + `<div class="display-hours">${hourAxis(m)}<div class="display-cols">${hourLines(m)}${columns}</div></div></div>`;
284
+ }
285
+
286
+ function roomsGrid(m: DisplayModel): string {
287
+ const d = m.today;
288
+ if (!m.rooms.length) return empty(m);
289
+ const head = m.rooms.map((r) => `<div class="display-dayhead display-roomhead"${r.color ? ` style="--ev:${r.color};--ev-ink:${inkFor(r.color)}"` : ''} data-room="${esc(r.slug)}"><span class="display-dayname">${esc(r.name)}</span></div>`).join('');
290
+ const inRoom = (r: DisplayRoom) => m.items.filter((it) => it.rooms.some((x) => x.id === r.id));
291
+ const lines = Math.max(0, ...m.rooms.map((r) => Math.min(ALLDAY_LINES.day, inRoom(r).filter((it) => isTopRow(it) && spansDay(it, d)).length)));
292
+ const area = hoursArea(m, lines);
293
+ const columns = m.rooms.map((r) => {
294
+ const placed = layoutDay(d, inRoom(r).filter((it) => isTimedOn(it, d)).map((it) => inRange(it, m.hours)));
295
+ return `<div class="display-col is-today" data-day="${d}" data-room="${esc(r.slug)}">${placed.map((p) => timedBlock(p, m, area)).join('')}</div>`;
296
+ }).join('');
297
+ // the top row: each room's all-day events in its own column
298
+ const tops = m.rooms.map((r, i) => inRoom(r).filter((it) => isTopRow(it) && spansDay(it, d)).slice(0, ALLDAY_LINES.day).map((it, line) =>
299
+ `<div ${itemAttrs(it, m, 'display-bar')} style="grid-column:${i + 1};grid-row:${line + 1};${colorStyle(it)}"><span class="display-event-title">${esc(it.title)}</span></div>`).join(''));
300
+ return `<div class="display-grid is-rooms" style="--cols:${m.rooms.length}" data-from="${m.hours.from}" data-to="${m.hours.to}">`
301
+ + `<div class="display-heads">${head}</div>${lines ? `<div class="display-top" style="--lines:${lines}">${tops.join('')}</div>` : ''}`
302
+ + `<div class="display-hours">${hourAxis(m)}<div class="display-cols">${hourLines(m)}${columns}${nowLine(d)}</div></div></div>`;
303
+ }
304
+
305
+ // ---- month -------------------------------------------------------------------
306
+
307
+ function monthGrid(m: DisplayModel): string {
308
+ const lines = monthLines(m.s, m.weeks.length);
309
+ const t = timeFormat(m.locale, m.tz);
310
+ const cols = m.weeks[0]?.length ?? 7;
311
+ const dows = (m.weeks[0] ?? []).map((d) => `<div class="display-dow">${esc(formatter(m.locale, m.tz, { weekday: 'short' }).format(m.midnight(d) + 12 * 3_600_000))}</div>`).join('');
312
+ const grid = m.items.map((it): GridItem & { it: DisplayItem } => ({ key: it.key, title: it.title, startDay: it.startDay, endDay: it.endDay, allDay: it.allDay, startMin: it.startMin, endMin: it.endMin, it }));
313
+ const rows = m.weeks.map((week) => {
314
+ const cells: string[] = [];
315
+ week.forEach((d, c) => {
316
+ const cls = ['display-cell', d === m.today ? 'is-today' : '', d < m.today ? 'is-past' : '', d.slice(0, 7) !== m.month ? 'is-other' : ''].filter(Boolean).join(' ');
317
+ cells.push(`<div class="${cls}" style="grid-column:${c + 1}"><span class="display-date">${+d.slice(8, 10)}</span></div>`);
318
+ });
319
+ // columns can skip weekdays (days=mon-fri): lay out each run of consecutive days
320
+ const runs: { from: number; days: Iso[] }[] = [];
321
+ week.forEach((d, i) => {
322
+ const last = runs[runs.length - 1];
323
+ if (last && addDays(last.days[last.days.length - 1], 1) === d) last.days.push(d);
324
+ else runs.push({ from: i, days: [d] });
325
+ });
326
+ for (const run of runs) {
327
+ const lay = layoutWeek(run.days, grid, lines);
328
+ for (const b of lay.bars) {
329
+ const it = b.item.it;
330
+ cells.push(`<div ${itemAttrs(it, m, 'display-bar')} style="grid-column:${run.from + b.col + 1} / span ${b.span};grid-row:${b.lane + 2};${colorStyle(it)}">`
331
+ + `<span class="display-event-title">${it.allDay ? '' : `<span class="display-bar-time">${esc(t(it.start))}</span> `}${esc(it.title)}</span></div>`);
332
+ }
333
+ lay.chips.forEach((chips, c) => {
334
+ for (const { item, line } of chips) {
335
+ const it = item.it;
336
+ cells.push(`<div ${itemAttrs(it, m, 'display-chip')} style="grid-column:${run.from + c + 1};grid-row:${line + 2};${colorStyle(it)}">`
337
+ + `<span class="display-event-title">${m.s.show.includes('time') ? `<span class="display-chip-time">${esc(t(it.start))}</span> ` : ''}${esc(it.title)}</span></div>`);
338
+ }
339
+ });
340
+ lay.hidden.forEach((n, c) => {
341
+ if (n) cells.push(`<div class="display-more" style="grid-column:${run.from + c + 1};grid-row:${lines + 1}">${esc(fill(m.words.more, { n }))}</div>`);
342
+ });
343
+ }
344
+ return `<div class="display-weekrow">${cells.join('')}</div>`;
345
+ }).join('');
346
+ return `<div class="display-month" style="--cols:${cols};--rows:${m.weeks.length};--lines:${lines}"><div class="display-dows">${dows}</div>${rows}</div>`;
347
+ }
348
+
349
+ // ---- list ------------------------------------------------------------------
350
+
351
+ function listView(m: DisplayModel): string {
352
+ const t = timeFormat(m.locale, m.tz);
353
+ const dayFmt = formatter(m.locale, m.tz, { weekday: 'long', month: 'long', day: 'numeric' });
354
+ const groups = m.days.map((d) => {
355
+ const items = m.items.filter((it) => spansDay(it, d) && it.end > m.now)
356
+ .sort((a, b) => Number(b.allDay) - Number(a.allDay) || a.start - b.start || a.title.localeCompare(b.title));
357
+ if (!items.length) return '';
358
+ const label = d === m.today ? `${m.words.today} · ${dayFmt.format(m.midnight(d) + 12 * 3_600_000)}` : dayFmt.format(m.midnight(d) + 12 * 3_600_000);
359
+ const rows = items.map((it) => {
360
+ const when = it.allDay || it.startDay < d ? m.words.allDay : t(it.start);
361
+ const meta = metaLine(it, m, { time: false });
362
+ return `<div ${itemAttrs(it, m, 'display-row')}><span class="display-dot" style="${colorStyle(it)}"></span><span class="display-row-time">${esc(when)}</span>`
363
+ + `<span class="display-row-body"><span class="display-event-title">${esc(it.title)}</span>${it.detail ? `<span class="display-event-detail">${esc(it.detail)}</span>` : ''}${meta ? `<span class="display-event-meta">${meta}</span>` : ''}</span></div>`;
364
+ }).join('');
365
+ return `<section class="display-day${d === m.today ? ' is-today' : ''}"><h2 class="display-day-title">${esc(label)}</h2>${rows}</section>`;
366
+ }).filter(Boolean);
367
+ if (!groups.length) return empty(m);
368
+ return `<div class="display-list" data-pages><div class="display-list-cols">${groups.join('')}</div></div><div class="display-page" data-page hidden></div>`;
369
+ }
370
+
371
+ // ---- now and next ------------------------------------------------------------
372
+
373
+ function nextView(m: DisplayModel): string {
374
+ const t = timeFormat(m.locale, m.tz);
375
+ const timed = m.items.filter((it) => !it.cancelled).sort((a, b) => a.start - b.start);
376
+ const now = timed.filter((it) => it.start <= m.now && it.end > m.now);
377
+ const later = timed.filter((it) => it.start > m.now);
378
+ const today = later.filter((it) => it.startDay === m.today);
379
+ const card = (it: DisplayItem, big: boolean) => `<div ${itemAttrs(it, m, big ? 'display-card is-big' : 'display-card')} style="${colorStyle(it)}">`
380
+ + `<span class="display-event-title">${esc(it.title)}</span>${it.detail ? `<span class="display-event-detail">${esc(it.detail)}</span>` : ''}`
381
+ + `<span class="display-event-meta">${metaLine(it, m)}</span></div>`;
382
+ const nextUp = today[0] ?? later[0];
383
+ const nextLabel = nextUp && nextUp.startDay !== m.today ? `${m.words.tomorrow} · ${t(nextUp.start)}` : m.words.next;
384
+ return `<div class="display-next">`
385
+ + `<section class="display-slot is-now"><h2 class="display-slot-title">${esc(m.words.now)}</h2>${now.length ? now.slice(0, 2).map((it) => card(it, true)).join('') : `<p class="display-empty">${esc(m.words.nothingNow)}</p>`}</section>`
386
+ + `<section class="display-slot is-next"><h2 class="display-slot-title">${esc(nextLabel)}</h2>${nextUp ? card(nextUp, false) : `<p class="display-empty">${esc(m.words.nothingNext)}</p>`}`
387
+ + `${today.slice(1, 4).map((it) => card(it, false)).join('')}</section></div>`;
388
+ }
389
+
390
+ const empty = (m: DisplayModel) => `<p class="display-empty is-board">${esc(m.words.noEvents)}</p>`;
391
+
392
+ // ---- the page -------------------------------------------------------------
393
+
394
+ function heading(m: DisplayModel): { title: string; sub: string } {
395
+ const day = (d: Iso, o: Intl.DateTimeFormatOptions) => formatter(m.locale, m.tz, o).format(m.midnight(d) + 12 * 3_600_000);
396
+ const range = () => {
397
+ const a = m.days[0], b = m.days[m.days.length - 1];
398
+ if (!a) return '';
399
+ if (a === b) return day(a, { weekday: 'long', month: 'long', day: 'numeric' });
400
+ try {
401
+ return formatter(m.locale, m.tz, { month: 'short', day: 'numeric' }).formatRange(m.midnight(a) + 43_200_000, m.midnight(b) + 43_200_000);
402
+ } catch { return `${day(a, { month: 'short', day: 'numeric' })} – ${day(b, { month: 'short', day: 'numeric' })}`; }
403
+ };
404
+ const v = m.s.view;
405
+ let title: string, sub: string;
406
+ if (v === 'month') { title = formatter(m.locale, 'UTC', { month: 'long', year: 'numeric' }).format(Date.UTC(+m.month.slice(0, 4), +m.month.slice(5, 7) - 1, 15)); sub = ''; }
407
+ else if (v === 'week') { title = m.s.start === 'today' ? fill(m.words.nextDays, { n: m.days.length }) : m.words.thisWeek; sub = range(); }
408
+ else if (v === 'list') { title = m.words.upcoming; sub = range(); }
409
+ else { title = m.words.today; sub = day(m.today, { weekday: 'long', month: 'long', day: 'numeric' }); }
410
+ // a custom title takes the heading's place
411
+ return { title: m.s.title || title, sub };
412
+ }
413
+
414
+ export function renderBoard(m: DisplayModel): string {
415
+ const v = m.s.view;
416
+ const body = v === 'week' || v === 'day' ? weekGrid(m) : v === 'rooms' ? roomsGrid(m) : v === 'month' ? monthGrid(m) : v === 'list' ? listView(m) : nextView(m);
417
+ const { title, sub } = heading(m);
418
+ const clock = formatter(m.locale, m.tz, { hour: 'numeric', minute: '2-digit' });
419
+ const legend = m.s.show.includes('legend') && m.legend.length
420
+ ? `<footer class="display-legend">${m.legend.map((l) => `<span class="display-key"><span class="display-dot" style="--ev:${l.color}"></span>${esc(l.name)}</span>`).join('')}</footer>` : '';
421
+ const qr = m.s.show.includes('qr') && m.qr ? `<div class="display-qr"><span class="display-qr-note">${esc(m.words.scan)}</span>${m.qr}</div>` : '';
422
+ return `<header class="display-head"><div class="display-heading"><h1 class="display-title">${esc(title)}</h1>${sub ? `<p class="display-sub">${esc(sub)}</p>` : ''}</div>`
423
+ + `<p class="display-status" data-status hidden></p>${qr}`
424
+ + (m.s.show.includes('clock') ? `<p class="display-clock" data-clock>${esc(clock.format(m.now))}</p>` : '')
425
+ + `</header><main class="display-board" data-view="${v}">${body}</main>${legend}`;
426
+ }
@@ -0,0 +1,160 @@
1
+ // What a TV display shows, read from its link (/display/calendar?view=week&
2
+ // hours=8-22…) or from a saved screen's stored parameters (Admin → Calendar →
3
+ // Screens keeps the same query string). One parser and one writer, so a quick
4
+ // link and a saved screen with the same settings render the same board, and
5
+ // the admin editor builds links the page reads back unchanged. Pure: no repo,
6
+ // no Intl time zones; the admin editor imports it in the browser.
7
+ import { readEventFilter, type EventFilter } from '../calendar/filter';
8
+
9
+ export const DISPLAY_VIEWS = ['week', 'day', 'month', 'list', 'rooms', 'next'] as const;
10
+ export type DisplayView = (typeof DISPLAY_VIEWS)[number];
11
+ export const DISPLAY_SHOW = ['time', 'room', 'host', 'clock', 'legend', 'qr'] as const;
12
+ export type DisplayShow = (typeof DISPLAY_SHOW)[number];
13
+ export const DISPLAY_SIZES = ['s', 'm', 'l', 'xl'] as const;
14
+ export type DisplaySize = (typeof DISPLAY_SIZES)[number];
15
+ // text scale per size; every length on the page is a multiple of it
16
+ export const SIZE_SCALE: Record<DisplaySize, number> = { s: 0.85, m: 1, l: 1.2, xl: 1.45 };
17
+
18
+ /** minutes after midnight; `to` may be 1440 (midnight at the end of the day) */
19
+ export interface HourRange { from: number; to: number }
20
+
21
+ export interface DisplaySettings {
22
+ view: DisplayView;
23
+ hours: HourRange | 'auto';
24
+ /** a count of days from the first day, or a set of weekdays (0 = Sunday) */
25
+ days: { count: number } | { weekdays: number[] };
26
+ /** first day of the week; '' = the site's setting (Calendar settings → Defaults) */
27
+ start: 'sun' | 'mon' | 'today' | '';
28
+ /** which events: cal, tag, notag, room (lib/calendar/filter.ts, shared with /calendar and the API) */
29
+ filter: EventFilter;
30
+ theme: 'dark' | 'light';
31
+ size: DisplaySize;
32
+ show: DisplayShow[];
33
+ /** replaces the board's own heading ("This week"); '' = the default */
34
+ title: string;
35
+ }
36
+
37
+ export const DEFAULT_HOURS: HourRange = { from: 8 * 60, to: 22 * 60 };
38
+ export const DEFAULT_SHOW: DisplayShow[] = ['time', 'room', 'clock'];
39
+ export const MIN_AUTO_HOURS = 6;
40
+ const MAX_TITLE = 80;
41
+
42
+ export const defaultDisplaySettings = (): DisplaySettings => ({
43
+ view: 'week', hours: { ...DEFAULT_HOURS }, days: { count: 7 }, start: '',
44
+ filter: { cal: [], tag: [], notag: [], room: [] },
45
+ theme: 'dark', size: 'm', show: [...DEFAULT_SHOW], title: '',
46
+ });
47
+
48
+ const WEEKDAY_NAMES = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
49
+ const weekday = (s: string) => {
50
+ const w = s.trim().toLowerCase().slice(0, 3);
51
+ // two-letter forms too ("mo", "tu") — what RRULE BYDAY uses
52
+ const i = WEEKDAY_NAMES.findIndex((n) => n === w || (w.length === 2 && n.startsWith(w)));
53
+ return i;
54
+ };
55
+
56
+ /** "8-22", "8:30-21", "8-24", "0-24" or "all" (every hour), "auto"; null when unreadable */
57
+ export function parseHours(raw: string): HourRange | 'auto' | null {
58
+ const v = raw.trim().toLowerCase();
59
+ if (v === 'auto') return 'auto';
60
+ if (v === 'all') return { from: 0, to: 1440 };
61
+ const m = /^(\d{1,2})(?::([0-5]\d))?-(\d{1,2})(?::([0-5]\d))?$/.exec(v);
62
+ if (!m) return null;
63
+ const from = +m[1] * 60 + (m[2] ? +m[2] : 0);
64
+ const to = +m[3] * 60 + (m[4] ? +m[4] : 0);
65
+ if (from > 23 * 60 + 59 || to > 1440 || to - from < 60) return null;
66
+ return { from, to };
67
+ }
68
+
69
+ const clock = (min: number) => {
70
+ const h = Math.floor(min / 60), m = min % 60;
71
+ return m ? `${h}:${String(m).padStart(2, '0')}` : String(h);
72
+ };
73
+ export const formatHours = (h: HourRange | 'auto') => (h === 'auto' ? 'auto' : `${clock(h.from)}-${clock(h.to)}`);
74
+
75
+ /** "7", "5", "mon-sat", "sun,wed,fri"; null when unreadable */
76
+ export function parseDays(raw: string): DisplaySettings['days'] | null {
77
+ const v = raw.trim().toLowerCase();
78
+ if (/^\d{1,2}$/.test(v)) {
79
+ const n = +v;
80
+ return n >= 1 && n <= 31 ? { count: n } : null;
81
+ }
82
+ const set = new Set<number>();
83
+ for (const part of v.split(',')) {
84
+ const range = part.split('-');
85
+ if (range.length === 2) {
86
+ const a = weekday(range[0]), b = weekday(range[1]);
87
+ if (a < 0 || b < 0) return null;
88
+ // wraps past Saturday: "fri-mon" is Fri, Sat, Sun, Mon
89
+ for (let d = a; ; d = (d + 1) % 7) { set.add(d); if (d === b) break; }
90
+ } else {
91
+ const d = weekday(part);
92
+ if (d < 0) return null;
93
+ set.add(d);
94
+ }
95
+ }
96
+ return set.size ? (set.size === 7 ? { count: 7 } : { weekdays: [...set].sort((a, b) => a - b) }) : null;
97
+ }
98
+
99
+ export function formatDays(d: DisplaySettings['days']): string {
100
+ if ('count' in d) return String(d.count);
101
+ // runs of consecutive weekdays read as ranges ("mon-fri"), the rest as a list
102
+ const out: string[] = [];
103
+ for (let i = 0; i < d.weekdays.length; ) {
104
+ let j = i;
105
+ while (j + 1 < d.weekdays.length && d.weekdays[j + 1] === d.weekdays[j] + 1) j++;
106
+ out.push(j - i >= 2 ? `${WEEKDAY_NAMES[d.weekdays[i]]}-${WEEKDAY_NAMES[d.weekdays[j]]}` : d.weekdays.slice(i, j + 1).map((n) => WEEKDAY_NAMES[n]).join(','));
107
+ i = j + 1;
108
+ }
109
+ return out.join(',');
110
+ }
111
+
112
+ /**
113
+ * The settings in a display link. Anything missing or unreadable takes its
114
+ * default, so an old or hand-typed link still shows something sensible.
115
+ */
116
+ export function readDisplaySettings(params: URLSearchParams): DisplaySettings {
117
+ const s = defaultDisplaySettings();
118
+ const one = (name: string) => (params.get(name) ?? '').trim().toLowerCase();
119
+ const view = one('view');
120
+ if ((DISPLAY_VIEWS as readonly string[]).includes(view)) s.view = view as DisplayView;
121
+ const hours = params.has('hours') ? parseHours(one('hours')) : null;
122
+ if (hours) s.hours = hours;
123
+ const days = params.has('days') ? parseDays(one('days')) : null;
124
+ if (days) s.days = days;
125
+ const start = one('start');
126
+ if (start === 'sun' || start === 'mon' || start === 'today') s.start = start;
127
+ s.filter = readEventFilter(params);
128
+ if (one('theme') === 'light') s.theme = 'light';
129
+ const size = one('size');
130
+ if ((DISPLAY_SIZES as readonly string[]).includes(size)) s.size = size as DisplaySize;
131
+ if (params.has('show')) {
132
+ const wanted = new Set(params.getAll('show').flatMap((v) => v.split(',')).map((v) => v.trim().toLowerCase()));
133
+ s.show = DISPLAY_SHOW.filter((k) => wanted.has(k));
134
+ }
135
+ s.title = (params.get('title') ?? '').replace(/\s+/g, ' ').trim().slice(0, MAX_TITLE);
136
+ return s;
137
+ }
138
+
139
+ // readable links: commas, colons and spaces stay as typed (all legal in a query value)
140
+ const enc = (v: string) => encodeURIComponent(v).replace(/%2C/gi, ',').replace(/%3A/gi, ':').replace(/%20/g, '+');
141
+
142
+ /** The canonical query for these settings, without the "?": defaults left out, a fixed order. */
143
+ export function displayQuery(s: DisplaySettings): string {
144
+ const d = defaultDisplaySettings();
145
+ const q: [string, string][] = [];
146
+ if (s.view !== d.view) q.push(['view', s.view]);
147
+ if (formatHours(s.hours) !== formatHours(d.hours)) q.push(['hours', formatHours(s.hours)]);
148
+ if (formatDays(s.days) !== formatDays(d.days)) q.push(['days', formatDays(s.days)]);
149
+ if (s.start) q.push(['start', s.start]);
150
+ for (const k of ['cal', 'tag', 'notag', 'room'] as const) if (s.filter[k].length) q.push([k, s.filter[k].join(',')]);
151
+ if (s.theme !== d.theme) q.push(['theme', s.theme]);
152
+ if (s.size !== d.size) q.push(['size', s.size]);
153
+ const show = DISPLAY_SHOW.filter((k) => s.show.includes(k));
154
+ if (show.join(',') !== d.show.join(',')) q.push(['show', show.join(',') || 'none']);
155
+ if (s.title) q.push(['title', s.title]);
156
+ return q.map(([k, v]) => `${k}=${enc(v)}`).join('&');
157
+ }
158
+
159
+ /** The same settings, written the canonical way (what a saved screen stores). */
160
+ export const canonicalDisplayQuery = (raw: string) => displayQuery(readDisplaySettings(new URLSearchParams(raw)));
@@ -0,0 +1,52 @@
1
+ // Words of the TV display (/display/…). Dates, weekdays and times come from
2
+ // Intl in the display's language; these are the rest. Plain strings with
3
+ // {slots} filled by `fill`. Other languages fall back to English.
4
+ export interface DisplayStrings {
5
+ thisWeek: string;
6
+ /** a rolling week (start=today) or list: "Next 7 days" */
7
+ nextDays: string;
8
+ today: string;
9
+ upcoming: string;
10
+ allDay: string;
11
+ /** shown while offline: when the events were last fetched */
12
+ updated: string;
13
+ now: string;
14
+ next: string;
15
+ nothingNow: string;
16
+ nothingNext: string;
17
+ noEvents: string;
18
+ more: string;
19
+ moved: string;
20
+ /** a moved date's old time */
21
+ was: string;
22
+ /** appointments from Google booking pages (#google-booking) */
23
+ booked: string;
24
+ cancelled: string;
25
+ scan: string;
26
+ tomorrow: string;
27
+ notFound: string;
28
+ notFoundNote: string;
29
+ }
30
+
31
+ const STRINGS: Record<string, DisplayStrings> = {
32
+ en: {
33
+ thisWeek: 'This week', nextDays: 'Next {n} days', today: 'Today', upcoming: 'Coming up', allDay: 'All day',
34
+ updated: 'Updated {time}', now: 'Now', next: 'Next', nothingNow: 'Nothing right now', nothingNext: 'Nothing else today',
35
+ noEvents: 'No events', more: '+{n} more', moved: 'Moved', was: 'was {time}', booked: 'Booked', cancelled: 'Cancelled',
36
+ scan: 'Scan for the calendar', tomorrow: 'Tomorrow',
37
+ notFound: 'This screen isn’t available', notFoundNote: 'Its link may have changed. Copy the new one from Admin → Calendar → Screens.',
38
+ },
39
+ es: {
40
+ thisWeek: 'Esta semana', nextDays: 'Próximos {n} días', today: 'Hoy', upcoming: 'Próximamente', allDay: 'Todo el día',
41
+ updated: 'Actualizado {time}', now: 'Ahora', next: 'Después', nothingNow: 'Nada en este momento', nothingNext: 'Nada más hoy',
42
+ noEvents: 'Sin eventos', more: '+{n} más', moved: 'Cambiado', was: 'antes {time}', booked: 'Reservado', cancelled: 'Cancelado',
43
+ scan: 'Escanea para ver el calendario', tomorrow: 'Mañana',
44
+ notFound: 'Esta pantalla no está disponible', notFoundNote: 'Puede que su enlace haya cambiado. Copia el nuevo en Admin → Calendario → Pantallas.',
45
+ },
46
+ };
47
+
48
+ export const displayStrings = (lang: string | null | undefined): DisplayStrings =>
49
+ STRINGS[(lang || 'en').toLowerCase().split('-')[0]] ?? STRINGS.en;
50
+
51
+ export const fill = (s: string, slots: Record<string, string | number>) =>
52
+ s.replace(/\{(\w+)\}/g, (m, k: string) => (k in slots ? String(slots[k]) : m));