@acms-subir/core 0.3.49 → 0.3.51

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,216 @@
1
+ // One TV board, ready to render: which days it covers, which events it may
2
+ // show, and when it goes stale. Quick links (/display/calendar?…) and saved
3
+ // screens without private details show what calendar.ics shows: published
4
+ // events in public calendars (or an unlisted one named in cal=). A saved
5
+ // screen with private details on also shows private calendars it names, the
6
+ // bookings of its rooms with the booker's name, the rooms and hosts of every
7
+ // event. Never emails, phone numbers, notes or RSVP lists.
8
+ import type { ContentRepository } from '../repo/types';
9
+ import { roomColor, type Resource, type Settings } from '../schema';
10
+ import { collectViewEvents, type ViewEvent } from '../calendar/data';
11
+ import { eventFilterQuery, resolveEventFilter } from '../calendar/filter';
12
+ import { tzParts, zonedToUtc } from '../calendar/ics';
13
+ import { safeColor } from '../calendar/events';
14
+ import { addDays, dayOfWeek, monthWeeks, startOfWeek, type Iso } from '../calendar/grid';
15
+ import { siteLang, siteLocale } from '../dates';
16
+ import { DEFAULT_HOURS, MIN_AUTO_HOURS, type DisplaySettings, type HourRange } from './settings';
17
+ import { renderBoard, type DisplayItem, type DisplayModel, type DisplayRoom } from './render';
18
+ import { displayStrings } from './words';
19
+ import { qrSvg } from './http';
20
+
21
+ export const GOOGLE_BOOKING_TAG = '#google-booking';
22
+ const pad = (n: number) => String(n).padStart(2, '0');
23
+ const isoOf = (p: { y: number; m: number; d: number }) => `${p.y}-${pad(p.m)}-${pad(p.d)}`;
24
+
25
+ export interface DisplayRequest {
26
+ settings: DisplaySettings;
27
+ /** a saved screen with "Show private calendars and room bookings" on */
28
+ privateDetails: boolean;
29
+ /** the display's language ('' = the site's) */
30
+ lang: string;
31
+ now?: number;
32
+ /** show=qr: the calendar page phones open (origin + path); the board adds its public filter */
33
+ phoneLink?: string;
34
+ }
35
+
36
+ export interface DisplayBoard {
37
+ html: string;
38
+ /** when the board is stale (the next midnight, or the next now/next change) */
39
+ until: number;
40
+ tz: string;
41
+ locale: string;
42
+ lang: string;
43
+ rendered: number;
44
+ }
45
+
46
+ /** Dates in the display's language, in the site's region: es on an en-US site → es-US. */
47
+ export function displayLocale(site: Pick<Settings, 'locale'>, lang: string): string {
48
+ if (!lang || lang === siteLang(site)) return siteLocale(site);
49
+ const region = siteLocale(site).split('-')[1];
50
+ const wanted = region ? `${lang}-${region}` : lang;
51
+ try { return Intl.DateTimeFormat.supportedLocalesOf([wanted]).length ? wanted : lang; } catch { return lang; }
52
+ }
53
+
54
+ const asRoom = (r: Resource): DisplayRoom => ({ id: r.id, slug: r.slug, name: r.name, color: roomColor(r) });
55
+
56
+ /** the days a board covers, and (month) its weeks */
57
+ export function boardDays(s: DisplaySettings, today: Iso, siteWeekStart: 0 | 1): { days: Iso[]; weeks: Iso[][] } {
58
+ const ws: 0 | 1 = s.start === 'mon' ? 1 : s.start === 'sun' ? 0 : siteWeekStart;
59
+ const pick = (from: Iso, n: number) => Array.from({ length: n }, (_, i) => addDays(from, i));
60
+ const narrow = (days: Iso[]) => ('weekdays' in s.days ? days.filter((d) => (s.days as { weekdays: number[] }).weekdays.includes(dayOfWeek(d))) : days);
61
+ if (s.view === 'week') {
62
+ const first = s.start === 'today' ? today : startOfWeek(today, ws);
63
+ const days = 'count' in s.days ? pick(first, Math.min(7, s.days.count)) : narrow(pick(first, 7));
64
+ return { days, weeks: [] };
65
+ }
66
+ if (s.view === 'list') return { days: 'count' in s.days ? pick(today, s.days.count) : narrow(pick(today, 7)), weeks: [] };
67
+ if (s.view === 'month') {
68
+ const weeks = monthWeeks(today.slice(0, 7), ws).map((w) => ('count' in s.days ? w.slice(0, Math.min(7, s.days.count)) : narrow(w)));
69
+ return { days: weeks.flat(), weeks };
70
+ }
71
+ return { days: [today], weeks: [] };
72
+ }
73
+
74
+ /** hours=auto: the shown events' hours, whole hours, never less than 6 */
75
+ export function autoHours(items: Pick<DisplayItem, 'allDay' | 'startDay' | 'endDay' | 'startMin' | 'endMin'>[], days: Iso[]): HourRange {
76
+ const timed = items.filter((it) => !it.allDay && it.startDay === it.endDay && days.includes(it.startDay));
77
+ if (!timed.length) return { ...DEFAULT_HOURS };
78
+ let from = Math.floor(Math.min(...timed.map((it) => it.startMin)) / 60) * 60;
79
+ let to = Math.min(1440, Math.ceil(Math.max(...timed.map((it) => it.endMin)) / 60) * 60);
80
+ const min = MIN_AUTO_HOURS * 60;
81
+ if (to - from < min) {
82
+ to = Math.min(1440, from + min);
83
+ from = Math.max(0, to - min);
84
+ }
85
+ return { from, to };
86
+ }
87
+
88
+ export async function loadDisplayBoard(repo: ContentRepository, site: Settings, req: DisplayRequest): Promise<DisplayBoard> {
89
+ const s = req.settings;
90
+ const now = req.now ?? Date.now();
91
+ const tz = site.timezone || 'UTC';
92
+ const lang = req.lang || siteLang(site);
93
+ const locale = displayLocale(site, lang);
94
+ const words = displayStrings(lang);
95
+ const today = isoOf(tzParts(now, tz));
96
+ const midnight = (d: Iso) => zonedToUtc(+d.slice(0, 4), +d.slice(5, 7), +d.slice(8, 10), 0, 0, 0, tz);
97
+ const { days, weeks } = boardDays(s, today, site.calendar.weekStart);
98
+ const first = s.view === 'list' || s.view === 'next' ? today : days[0] ?? today;
99
+ const last = s.view === 'next' ? addDays(today, 1) : days[days.length - 1] ?? today;
100
+ const win = { displayTz: tz, now, windowStartMs: midnight(first), windowEndMs: midnight(addDays(last, 1)) };
101
+
102
+ const [calendars, resources] = await Promise.all([repo.listCalendars(), repo.listResources()]);
103
+ const filter = resolveEventFilter(s.filter, { calendars, resources, allowPrivate: req.privateDetails });
104
+ // `shownCalendars`: the calendars this audience may see, the only ones a board names
105
+ const { events, calendars: shownCalendars } = await collectViewEvents(repo, site, win, {
106
+ audience: req.privateDetails ? 'screen' : 'public',
107
+ filter,
108
+ // quick links show what calendar.ics shows: no subscribed feeds, no bookings
109
+ includeFeeds: req.privateDetails,
110
+ includeBookings: req.privateDetails,
111
+ // translated titles in the display's language
112
+ locale: lang,
113
+ });
114
+ // rooms visitors may see: the ones named on public events
115
+ const allowed = resources.filter((r) => req.privateDetails || r.showOnEvents);
116
+ const roomById = new Map(allowed.map((r) => [r.id, asRoom(r)]));
117
+ const calById = new Map(shownCalendars.map((c) => [c.id, c]));
118
+ // a person invited without a name has their email as one: never on a TV
119
+ const hostName = req.privateDetails && s.show.includes('host')
120
+ ? new Map((await repo.listUsers()).filter((u) => u.name.trim() && !u.name.includes('@')).map((u): [string, string] => [u.id, u.name.trim()]))
121
+ : new Map<string, string>();
122
+
123
+ const items = events.map((v, i) => displayItem(v, i, { tz, words, roomById, calById, hostName, privateDetails: req.privateDetails }));
124
+ const shown = new Set(days);
125
+ // a board only carries what it can show (the list: what hasn't ended)
126
+ const visible = items.filter((it) => {
127
+ if (s.view === 'list') return it.end > now && days.some((d) => it.startDay <= d && it.endDay >= d);
128
+ if (s.view === 'next') return it.end > now;
129
+ return [...shown].some((d) => it.startDay <= d && it.endDay >= d);
130
+ });
131
+
132
+ // view=rooms columns: the rooms the address names, in its order; otherwise
133
+ // every room the board may show (resources of kind "Room", else all)
134
+ let rooms: DisplayRoom[] = [];
135
+ if (s.view === 'rooms') {
136
+ if (filter.roomIds) rooms = filter.rooms.map(asRoom);
137
+ else {
138
+ const sorted = [...allowed].sort((a, b) => a.order - b.order || a.name.localeCompare(b.name));
139
+ const kindRoom = sorted.filter((r) => r.kind.trim().toLowerCase() === 'room');
140
+ rooms = (kindRoom.length ? kindRoom : sorted).map(asRoom);
141
+ }
142
+ }
143
+ const hours = s.hours === 'auto' ? autoHours(visible, days) : s.hours;
144
+
145
+ const legend: { name: string; color: string }[] = [];
146
+ for (const it of visible) {
147
+ const room = it.rooms.find((r) => r.color);
148
+ const entry = room ? { name: room.name, color: room.color } : it.calendar && it.color ? { name: shownCalendars.find((c) => c.slug === it.calendar)?.name ?? '', color: it.color } : null;
149
+ if (entry?.name && !legend.some((l) => l.name === entry.name)) legend.push(entry);
150
+ }
151
+
152
+ // the phone link shows the same events where the public calendar can: only
153
+ // calendars and rooms visitors may see, never "#" tags
154
+ let qr = '';
155
+ if (s.show.includes('qr') && req.phoneLink) {
156
+ const pub = resolveEventFilter(s.filter, { calendars, resources });
157
+ const query = eventFilterQuery({
158
+ cal: pub.calendars.map((c) => c.slug), room: pub.rooms.map((r) => r.slug),
159
+ tag: s.filter.tag.filter((t) => !t.trim().startsWith('#')), notag: s.filter.notag.filter((t) => !t.trim().startsWith('#')),
160
+ });
161
+ qr = qrSvg(req.phoneLink + (query ? `?${query}` : ''));
162
+ }
163
+
164
+ const model: DisplayModel = {
165
+ s, now, tz, locale, words, today, days, weeks, month: today.slice(0, 7), hours, items: visible, rooms, legend, midnight, qr,
166
+ };
167
+ let until = midnight(addDays(today, 1));
168
+ // now/next changes when the current event ends or the next one starts
169
+ if (s.view === 'next') for (const it of visible) for (const t of [it.start, it.end]) if (t > now && t < until) until = t;
170
+ return { html: renderBoard(model), until, tz, locale, lang, rendered: now };
171
+ }
172
+
173
+ interface ItemContext {
174
+ tz: string;
175
+ words: ReturnType<typeof displayStrings>;
176
+ roomById: Map<string, DisplayRoom>;
177
+ calById: Map<string, { id: string; slug: string; color: string }>;
178
+ hostName: Map<string, string>;
179
+ privateDetails: boolean;
180
+ }
181
+
182
+ function displayItem(v: ViewEvent, i: number, c: ItemContext): DisplayItem {
183
+ const local = (ms: number, endExclusive = false) => {
184
+ const p = tzParts(endExclusive ? ms - 1 : ms, c.tz);
185
+ return { day: isoOf(p), min: endExclusive ? p.hh * 60 + p.mm + 1 : p.hh * 60 + p.mm };
186
+ };
187
+ const s = local(v.start);
188
+ const e = local(Math.max(v.end, v.start + 60_000), true);
189
+ const startDay = s.day;
190
+ // all-day: its days, or (a booking of a day or more that isn't midnight to
191
+ // midnight) every day up to the one it ends on
192
+ const endDay = v.allDay ? [addDays(startDay, Math.max(1, v.days) - 1), e.day].sort().pop()! : e.day < s.day ? s.day : e.day;
193
+ // already in the display's language (collectViewEvents' locale)
194
+ const ev = v.event;
195
+ const booked = Boolean(ev?.tags.some((t) => t.trim().toLowerCase() === GOOGLE_BOOKING_TAG));
196
+ const rooms = v.rooms ?? (ev ? ev.roomHold.resourceIds.map((id) => c.roomById.get(id)).filter((r): r is DisplayRoom => Boolean(r)) : []);
197
+ const cal = ev ? ev.calendarIds.map((id) => c.calById.get(id)).find(Boolean) : undefined;
198
+ const moved = (v as ViewEvent & { moved?: { from: { start: number; end: number } } }).moved?.from ?? null;
199
+ return {
200
+ key: `${ev?.id ?? v.ref ?? 'x'}:${v.start}:${i}`,
201
+ kind: ev ? 'event' : v.rooms ? 'booking' : 'feed',
202
+ title: booked ? c.words.booked : v.title,
203
+ // under "Booked", a private screen also names the appointment
204
+ detail: booked && c.privateDetails ? v.title : '',
205
+ start: v.start, end: v.end, allDay: v.allDay,
206
+ startDay, endDay,
207
+ startMin: v.allDay ? 0 : s.min,
208
+ endMin: v.allDay ? 1440 : endDay === startDay ? e.min : 1440,
209
+ color: rooms.find((r) => r.color)?.color || (v.color ? safeColor(v.color) : ''),
210
+ rooms,
211
+ hosts: ev ? ev.hostIds.map((id) => c.hostName.get(id)).filter((n): n is string => Boolean(n)) : [],
212
+ cancelled: Boolean(v.cancelled),
213
+ moved,
214
+ calendar: cal?.slug ?? '',
215
+ };
216
+ }
@@ -0,0 +1,35 @@
1
+ // Response plumbing shared by the display routes.
2
+ import qrcode from 'qrcode-generator';
3
+
4
+ /**
5
+ * Display pages may be framed anywhere (signage players, other sites), are
6
+ * never indexed, and never send their address on: a saved screen's key is
7
+ * in it. Quick links may sit in a shared cache for a minute; saved screens
8
+ * never do (rotating a key must stop the old one at once).
9
+ */
10
+ export function displayHeaders(h: Headers, opts: { shared: boolean }) {
11
+ h.set('Content-Security-Policy', 'frame-ancestors *');
12
+ h.set('Referrer-Policy', 'no-referrer');
13
+ h.set('X-Robots-Tag', 'noindex, nofollow');
14
+ h.set('Cache-Control', opts.shared ? 'public, max-age=60, s-maxage=60' : 'private, no-store');
15
+ }
16
+
17
+ /** A QR code for `text` as an inline SVG (one path, no styles of its own); '' when it's too long for one. */
18
+ export function qrSvg(text: string): string {
19
+ const qr = qrcode(0, 'M');
20
+ qr.addData(text);
21
+ try { qr.make(); } catch { return ''; }
22
+ const n = qr.getModuleCount();
23
+ let d = '';
24
+ for (let y = 0; y < n; y++) {
25
+ for (let x = 0; x < n; x++) {
26
+ if (!qr.isDark(y, x)) continue;
27
+ // one run of dark modules per segment keeps the path short
28
+ let w = 1;
29
+ while (x + w < n && qr.isDark(y, x + w)) w++;
30
+ d += `M${x} ${y}h${w}v1h-${w}z`;
31
+ x += w - 1;
32
+ }
33
+ }
34
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -2 ${n + 4} ${n + 4}" shape-rendering="crispEdges" aria-hidden="true"><path fill="#000" d="${d}"/></svg>`;
35
+ }