@acms-subir/core 0.3.49 → 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.
- package/CHANGELOG.md +12 -0
- package/docs/calendar.md +64 -0
- package/integration.mjs +4 -1
- package/package.json +2 -1
- package/src/components/display/DisplayPage.astro +192 -0
- package/src/lib/calendar/data.ts +28 -11
- package/src/lib/display/board.ts +216 -0
- package/src/lib/display/http.ts +35 -0
- package/src/lib/display/render.ts +426 -0
- package/src/lib/display/settings.ts +160 -0
- package/src/lib/display/words.ts +52 -0
- package/src/pages/display/calendar.astro +30 -0
- package/src/pages/display/sw.js.ts +71 -0
- package/src/styles/display.css +195 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @acms-subir/core
|
|
2
2
|
|
|
3
|
+
## 0.3.50
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 0d34128: A full-screen calendar for TVs at `/display/calendar` (and `/es/display/calendar`), for signage players such as AbleSign on a Fire TV Stick.
|
|
8
|
+
|
|
9
|
+
Everything about a board is in its link: `view` (`week`, `day`, `rooms`, `month`, `list`, `next` for now and next), `hours` (`8-22` by default, `8:30-21`, `8-24`, `0-24`, or `auto` to fit the events, at least 6 hours), `days` (`5`, `mon-fri`, `sun,wed,fri`; for a list, how many days), `start` (`sun`, `mon`, `today` for a rolling week), the shared filters `cal`, `tag`, `notag` and `room` (`room` also sets the rooms view's columns), `theme` (`dark` or `light`), `size` (`s` to `xl`), `show` (`time,room,host,clock,legend,qr`) and `title`. Whatever the parameters, a display link shows only what `calendar.ics` shows: published events in public calendars, rooms only when shown on public events, never bookings or hosts.
|
|
10
|
+
|
|
11
|
+
The first view is rendered on the server and lays out the same at 960×540 and 1080p. Week, day and rooms are hour grids: all-day events in a top row, events at the same time side by side, and events outside the hours cut and marked, never left out. The month's rows fill the screen and the list runs in columns, then pages. Events take their room's colour, else their calendar's; appointments tagged `#google-booking` show as "Booked"; cancelled dates are struck through, and moved dates show at their new time marked "Moved · was Tue 6 PM". A small script moves the now line, dims what has ended, fetches the board every 5 minutes and at midnight, and reloads once a night. Offline it keeps the last board and shows "Updated 7:41 PM", and a service worker (`/display/sw.js`, scope `/display/`) keeps a copy so a reload during an outage shows the last events. Nothing on the page can be focused or followed. It can be framed anywhere, sends no referrer, isn't indexed, and leaves out the site's chrome, head code and analytics; the theme's CSS restyles it through `--display-*` variables and `.display-*` classes. `npx acms docs calendar` has the parameters, theming and the AbleSign setup.
|
|
12
|
+
|
|
13
|
+
`/display` is now a reserved path: a collection or calendar base can't use it.
|
|
14
|
+
|
|
3
15
|
## 0.3.49
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/docs/calendar.md
CHANGED
|
@@ -190,3 +190,67 @@ a 404. Spans are at most 31 days, and a plain day means midnight in the site's
|
|
|
190
190
|
timezone. Answers are never cached, and each address gets 30 checks a minute. The
|
|
191
191
|
client has `roomFree({ room, start, end })`, and the native package has
|
|
192
192
|
`useRoomFree(query)`.
|
|
193
|
+
|
|
194
|
+
## Display: calendars on TVs
|
|
195
|
+
|
|
196
|
+
A full-screen calendar for a TV in a lobby or on a room's door. It shows a week, day, month, list, rooms or "now and next" view, with your own hours (8 AM to 10 PM by default). TVs have no mouse, so everything about a screen lives in its link. The page has nothing to focus or follow (no links, buttons or tab stops), because a remote's presses go to the page.
|
|
197
|
+
|
|
198
|
+
A display link spells out its settings in the address: `/display/calendar?view=week&hours=8-22`. Nothing is saved. Whatever its parameters, it shows only what `calendar.ics` shows: published events in public calendars, or in an unlisted calendar named in `cal=`. It never shows subscribed calendars, booking blocks, hosts, or rooms that aren't shown on public events.
|
|
199
|
+
|
|
200
|
+
Spanish (or any other site language): `/es/display/calendar?…`.
|
|
201
|
+
|
|
202
|
+
### Parameters
|
|
203
|
+
|
|
204
|
+
Each has a default; leave out what you don't need. Lists are comma-separated or repeated (`tag=Class,Youth` or `tag=Class&tag=Youth`). A list matches any of its values, and different parameters must all match.
|
|
205
|
+
|
|
206
|
+
| Parameter | Values | What it does |
|
|
207
|
+
|---|---|---|
|
|
208
|
+
| `view` | `week` · `day` · `rooms` · `month` · `list` · `next` | The layout. Default `week`. Week, day and rooms are hour grids; `next` is now and next for today. |
|
|
209
|
+
| `hours` | `8-22` · `8:30-21` · `8-24` · `0-24` (or `all`) · `auto` | The hour grids' visible hours. `8-24` ends at midnight. `auto` fits the events shown, never less than 6 hours. Events outside the hours are cut and marked (▲ ▼), never left out. Default `8-22`. |
|
|
210
|
+
| `days` | `7` · `5` · `mon-fri` · `mon-sat` · `sun,wed,fri` | A number is that many days from the first day; names pick weekdays. For `list`, a number is how many days the list covers. Default 7. |
|
|
211
|
+
| `start` | `sun` · `mon` · `today` | The week's first day. `today` gives a rolling week. Default: Calendar settings → Defaults. |
|
|
212
|
+
| `cal` | calendar slugs | Only these calendars. Default: every public calendar. Private calendars never show. |
|
|
213
|
+
| `tag` / `notag` | tag names | Only events with one of these tags, or none of them. `tag=Class` makes a class board. Internal `#` tags never match. |
|
|
214
|
+
| `room` | room slugs | Only events held in these rooms. With `view=rooms` it also sets the columns and their order (otherwise every room of kind "Room"). Only rooms shown on public events count; any other room shows nothing. |
|
|
215
|
+
| `theme` | `dark` · `light` | Default `dark`. |
|
|
216
|
+
| `size` | `s` · `m` · `l` · `xl` | Text size for the viewing distance. Default `m`. |
|
|
217
|
+
| `show` | `time,room,host,clock,legend,qr` (or `none`) | What each event and the page show. Default `time,room,clock`. `legend` is a colour key; `qr` is a code that opens the calendar on a phone (with the same filters). `host` is for saved screens with private details, which are coming next. |
|
|
218
|
+
| `title` | text | Replaces the heading ("This week", "Today"…). |
|
|
219
|
+
|
|
220
|
+
Examples:
|
|
221
|
+
|
|
222
|
+
- `/display/calendar?view=week&tag=Class&hours=8-22`: this week's classes.
|
|
223
|
+
- `/display/calendar?view=list&days=14&size=l`: the next two weeks, in large text.
|
|
224
|
+
- `/display/calendar?view=rooms&room=chapel,hall,youth&hours=auto`: today, one column per room.
|
|
225
|
+
|
|
226
|
+
### What a board does
|
|
227
|
+
|
|
228
|
+
- The first view is rendered on the server. A small script moves the now line, dims what has ended, and turns the list's pages (the list runs in columns, then pages, instead of scrolling).
|
|
229
|
+
- Every 5 minutes it fetches the board again from the same address, so a change made in the admin reaches the TV within 5 minutes. It also fetches at midnight, so the board rolls to the next day, week or month. It reloads once a night (between 3:00 and 3:40, site time).
|
|
230
|
+
- Offline, it keeps the last board and shows "Updated 7:41 PM". A small service worker (scope `/display/`) keeps a copy of the page, so a reload during an outage shows the last events instead of an error page.
|
|
231
|
+
- Events take their room's colour, else their calendar's. Appointments from Google booking pages (tagged `#google-booking`) show as "Booked". A cancelled date is struck through, and a moved date shows at its new time, marked "Moved" with its old time (and day, when that changed).
|
|
232
|
+
- The page may be framed anywhere (`frame-ancestors *`), sends no referrer, is never indexed, and needs no cookie. It may sit in a shared cache for a minute.
|
|
233
|
+
|
|
234
|
+
### Setting up a TV with AbleSign
|
|
235
|
+
|
|
236
|
+
AbleSign (app.ablesign.tv/screens) plays a playlist on each screen; a website is one item.
|
|
237
|
+
|
|
238
|
+
1. In AbleSign, open **Websites → Add website** and paste the display link.
|
|
239
|
+
2. Add the website to the screen's playlist with a long duration (AbleSign reloads a website every time the playlist plays it; the page refreshes itself).
|
|
240
|
+
3. Put a short image before it in the playlist, so the page loads in the background.
|
|
241
|
+
4. The Fire TV Stick needs Fire OS 6 or later (not Vega OS) and AbleSign 2.2.2 or later.
|
|
242
|
+
|
|
243
|
+
The page is sized for a TV's web view, which can report 960×540 on a 1080p TV; it lays out the same at any 16:9 size.
|
|
244
|
+
|
|
245
|
+
### Themes and site code
|
|
246
|
+
|
|
247
|
+
The board's markup is a styling contract: `.acms-display` (with `data-view`, `data-theme` and `data-size`), `.display-head`, `.display-title`, `.display-clock`, `.display-grid`, `.display-col`, `.display-block` (timed events), `.display-bar` (all-day), `.display-chip` (month), `.display-row` (list), `.display-card` (now and next), `.display-now`, `.display-legend` and `.display-qr`. Events carry `data-room` and `data-cal` with the room and calendar slugs.
|
|
248
|
+
|
|
249
|
+
A theme's `theme.css` loads after the board's own styles. The simplest way to restyle is to set these variables, which core never sets itself: `--display-bg`, `--display-fg`, `--display-muted`, `--display-line`, `--display-panel`, `--display-today`, `--display-now`, `--display-event`, `--display-font`, `--display-heading-font` and `--display-radius`. For example:
|
|
250
|
+
|
|
251
|
+
```css
|
|
252
|
+
:root { --display-now: #f5a524; }
|
|
253
|
+
.acms-display[data-theme='light'] { --display-bg: #fffaf2; --display-fg: #1d1a16; }
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Keep blocks flat: the built-in look has no accent stripes or gradients. The display leaves out the site's header, footer, head code and analytics. A site file at `src/pages/display/calendar.astro` replaces that page.
|
package/integration.mjs
CHANGED
|
@@ -124,7 +124,7 @@ export const CLIENT_DEPS = [
|
|
|
124
124
|
// First segments of the fixed routes above — a configured base landing on one
|
|
125
125
|
// of these would shadow a real page, so it's a build-time error.
|
|
126
126
|
const RESERVED_BASES = new Set([
|
|
127
|
-
'/admin', '/api', '/author', '/embed', '/go', '/links', '/media', '/preview', '/search', '/privacy', '/terms',
|
|
127
|
+
'/admin', '/api', '/author', '/display', '/embed', '/go', '/links', '/media', '/preview', '/search', '/privacy', '/terms',
|
|
128
128
|
]);
|
|
129
129
|
|
|
130
130
|
// Collection keys that would collide with admin sections or read ambiguously.
|
|
@@ -860,6 +860,9 @@ export default function acms(options = {}) {
|
|
|
860
860
|
[`${calendar.eventsBase}/[slug]`, 'events/[slug].astro'],
|
|
861
861
|
[`${calendar.eventsBase}/[slug].ics`, 'events/[slug].ics.ts'],
|
|
862
862
|
['/embed/calendar/[slug]', 'embed/calendar/[slug].astro'],
|
|
863
|
+
// TV displays: quick links (everything in the address) and their offline worker
|
|
864
|
+
['/display/calendar', 'display/calendar.astro'],
|
|
865
|
+
['/display/sw.js', 'display/sw.js.ts'],
|
|
863
866
|
['/api/v1/events', 'api/v1/events.ts'],
|
|
864
867
|
['/api/v1/calendars', 'api/v1/calendars.ts'],
|
|
865
868
|
// RSVPs from apps: a date's status, then POST /bookings (room bookings there need bookings on)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@acms-subir/core",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.50",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -76,6 +76,7 @@
|
|
|
76
76
|
"marked": "^15.0.0",
|
|
77
77
|
"pdfjs-dist": "^6.3.289",
|
|
78
78
|
"prismjs": "^1.30.0",
|
|
79
|
+
"qrcode-generator": "^2.0.4",
|
|
79
80
|
"sanitize-html": "^2.17.7",
|
|
80
81
|
"semver": "^7.7.2",
|
|
81
82
|
"simple-icons": "^16.27.1",
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
---
|
|
2
|
+
// The page around a TV board (lib/display/render.ts): its own lean layout,
|
|
3
|
+
// not Site.astro, so nothing from the site's chrome, head code or analytics
|
|
4
|
+
// reaches a TV (a playlist reloads the page all day). The theme's CSS comes
|
|
5
|
+
// along so it can restyle the board (--display-* variables); its web fonts
|
|
6
|
+
// load without holding up the first paint.
|
|
7
|
+
//
|
|
8
|
+
// The script is all the page runs: the clock, the now line, dimming what has
|
|
9
|
+
// ended, turning the list's pages, fetching a fresh board every 5 minutes
|
|
10
|
+
// (the same address, so the saved screen's key is checked each time), and a
|
|
11
|
+
// reload once a night. Offline it keeps the last board and says when it was
|
|
12
|
+
// updated; the service worker (/display/sw.js) keeps a copy of the page so
|
|
13
|
+
// a reload during an outage shows the last events, not an error.
|
|
14
|
+
import type { Settings } from '../../lib/schema';
|
|
15
|
+
import type { DisplaySettings } from '../../lib/display/settings';
|
|
16
|
+
import { SIZE_SCALE } from '../../lib/display/settings';
|
|
17
|
+
import type { DisplayBoard } from '../../lib/display/board';
|
|
18
|
+
import { displayStrings } from '../../lib/display/words';
|
|
19
|
+
import { resolveDesign } from '../../lib/theme';
|
|
20
|
+
import css from '../../styles/display.css?raw';
|
|
21
|
+
|
|
22
|
+
interface Props {
|
|
23
|
+
site: Settings;
|
|
24
|
+
/** null: the screen doesn't exist (a wrong or rotated key) */
|
|
25
|
+
board: DisplayBoard | null;
|
|
26
|
+
settings: DisplaySettings;
|
|
27
|
+
title: string;
|
|
28
|
+
lang: string;
|
|
29
|
+
/** refresh and keep offline copies; off in the admin preview */
|
|
30
|
+
live: boolean;
|
|
31
|
+
/** the service worker's address ('' = none) */
|
|
32
|
+
sw?: string;
|
|
33
|
+
}
|
|
34
|
+
const { site, board, settings: s, title, lang, live, sw = '' } = Astro.props;
|
|
35
|
+
const design = resolveDesign(site);
|
|
36
|
+
const words = displayStrings(lang);
|
|
37
|
+
const LIST_COLS = { s: 4, m: 3, l: 2, xl: 2 };
|
|
38
|
+
const style = `--dsp-scale:${SIZE_SCALE[s.size]};--dsp-list-cols:${LIST_COLS[s.size]}`;
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
<!doctype html>
|
|
42
|
+
<html lang={lang}>
|
|
43
|
+
<head>
|
|
44
|
+
<meta charset="utf-8" />
|
|
45
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
46
|
+
<meta name="robots" content="noindex, nofollow" />
|
|
47
|
+
<meta name="referrer" content="no-referrer" />
|
|
48
|
+
<meta name="color-scheme" content={s.theme === 'light' ? 'light' : 'dark'} />
|
|
49
|
+
<title>{title}</title>
|
|
50
|
+
<Fragment set:html={`<style>${css}</style>`} />
|
|
51
|
+
<Fragment set:html={`<style>${design.css}</style>`} />
|
|
52
|
+
{design.fontsHref && <link rel="stylesheet" href={design.fontsHref} media="print" onload="this.media='all'" />}
|
|
53
|
+
</head>
|
|
54
|
+
<body>
|
|
55
|
+
{board ? (
|
|
56
|
+
<div
|
|
57
|
+
class="acms-display"
|
|
58
|
+
data-theme={s.theme}
|
|
59
|
+
data-size={s.size}
|
|
60
|
+
data-view={s.view}
|
|
61
|
+
style={style}
|
|
62
|
+
data-tz={board.tz}
|
|
63
|
+
data-locale={board.locale}
|
|
64
|
+
data-until={board.until}
|
|
65
|
+
data-rendered={board.rendered}
|
|
66
|
+
data-updated={words.updated}
|
|
67
|
+
data-live={live ? '1' : undefined}
|
|
68
|
+
data-sw={sw || undefined}
|
|
69
|
+
set:html={board.html}
|
|
70
|
+
/>
|
|
71
|
+
) : (
|
|
72
|
+
<div class="acms-display display-missing" data-theme={s.theme} style={style}>
|
|
73
|
+
<h1 class="display-title">{words.notFound}</h1>
|
|
74
|
+
<p>{words.notFoundNote}</p>
|
|
75
|
+
</div>
|
|
76
|
+
)}
|
|
77
|
+
{board && (
|
|
78
|
+
<script is:inline>
|
|
79
|
+
(function () {
|
|
80
|
+
var REFRESH = 300000, PAGE_EVERY = 15000;
|
|
81
|
+
var root = function () { return document.querySelector('.acms-display'); };
|
|
82
|
+
var first = root();
|
|
83
|
+
if (!first || !window.Intl) return;
|
|
84
|
+
var tz = first.getAttribute('data-tz'), loc = first.getAttribute('data-locale');
|
|
85
|
+
var live = first.hasAttribute('data-live');
|
|
86
|
+
var wallFmt, clockFmt;
|
|
87
|
+
try {
|
|
88
|
+
wallFmt = new Intl.DateTimeFormat('en-US', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' });
|
|
89
|
+
clockFmt = new Intl.DateTimeFormat(loc, { timeZone: tz, hour: 'numeric', minute: '2-digit' });
|
|
90
|
+
} catch (e) { return; }
|
|
91
|
+
// the site's wall clock: day "YYYY-MM-DD" and minutes after midnight
|
|
92
|
+
var wall = function (ms) {
|
|
93
|
+
var p = {};
|
|
94
|
+
wallFmt.formatToParts(ms).forEach(function (x) { p[x.type] = x.value; });
|
|
95
|
+
return { day: p.year + '-' + p.month + '-' + p.day, min: (+p.hour % 24) * 60 + +p.minute };
|
|
96
|
+
};
|
|
97
|
+
var each = function (r, sel, fn) { Array.prototype.forEach.call(r.querySelectorAll(sel), fn); };
|
|
98
|
+
var lastOk = +first.getAttribute('data-rendered'), lastTry = 0, failed = false, busy = false, page = 0;
|
|
99
|
+
var jitter = Math.floor(Math.random() * 40);
|
|
100
|
+
|
|
101
|
+
function status(r) {
|
|
102
|
+
var el = r.querySelector('[data-status]');
|
|
103
|
+
if (!el) return;
|
|
104
|
+
// offline, or a copy kept from before an outage
|
|
105
|
+
var stale = failed || Date.now() - lastOk > REFRESH * 2;
|
|
106
|
+
el.hidden = !stale;
|
|
107
|
+
if (stale) el.textContent = (r.getAttribute('data-updated') || '{time}').replace('{time}', clockFmt.format(lastOk));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function pages(r) {
|
|
111
|
+
var box = r.querySelector('[data-pages]');
|
|
112
|
+
if (!box) return;
|
|
113
|
+
var cols = box.firstElementChild, mark = r.querySelector('[data-page]');
|
|
114
|
+
var gap = parseFloat(getComputedStyle(cols).columnGap) || 0, w = box.clientWidth + gap;
|
|
115
|
+
var n = Math.max(1, Math.round((box.scrollWidth + gap) / w));
|
|
116
|
+
page = page % n;
|
|
117
|
+
box.scrollLeft = page * w;
|
|
118
|
+
if (mark) { mark.hidden = n < 2; mark.textContent = (page + 1) + ' / ' + n; }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function tick() {
|
|
122
|
+
var r = root(), now = Date.now(), w = wall(now);
|
|
123
|
+
var c = r.querySelector('[data-clock]');
|
|
124
|
+
if (c) c.textContent = clockFmt.format(now);
|
|
125
|
+
var g = r.querySelector('.display-grid');
|
|
126
|
+
if (g) {
|
|
127
|
+
var from = +g.getAttribute('data-from'), to = +g.getAttribute('data-to');
|
|
128
|
+
each(r, '.display-now', function (n) {
|
|
129
|
+
var on = n.getAttribute('data-now-day') === w.day && w.min >= from && w.min <= to;
|
|
130
|
+
n.hidden = !on;
|
|
131
|
+
if (on) n.style.top = ((w.min - from) / (to - from) * 100) + '%';
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
each(r, '[data-end]', function (e) { if (+e.getAttribute('data-end') <= now) e.classList.add('is-past'); });
|
|
135
|
+
status(r);
|
|
136
|
+
if (!live) return;
|
|
137
|
+
// stale (midnight, or now/next changes): at most once a minute, whatever the TV's clock says
|
|
138
|
+
if (now >= +r.getAttribute('data-until') && now - lastTry > 60000) refresh();
|
|
139
|
+
// once a night (03:00–03:40 site time, spread out), while online
|
|
140
|
+
var night = 'acms-display-night';
|
|
141
|
+
try {
|
|
142
|
+
if (w.min >= 180 + jitter && w.min < 240 && !failed && sessionStorage.getItem(night) !== w.day) {
|
|
143
|
+
sessionStorage.setItem(night, w.day);
|
|
144
|
+
location.reload();
|
|
145
|
+
}
|
|
146
|
+
} catch (e) { /* storage off: skip the nightly reload */ }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function refresh() {
|
|
150
|
+
// a request stuck on bad Wi-Fi gives up after 30 s, so it can't block the next ones
|
|
151
|
+
if (busy && Date.now() - lastTry < 45000) return;
|
|
152
|
+
busy = true;
|
|
153
|
+
lastTry = Date.now();
|
|
154
|
+
var ctl = window.AbortController ? new AbortController() : null;
|
|
155
|
+
var timer = setTimeout(function () { if (ctl) ctl.abort(); }, 30000);
|
|
156
|
+
fetch(location.href, { cache: 'no-store', credentials: 'omit', signal: ctl ? ctl.signal : undefined })
|
|
157
|
+
.then(function (res) {
|
|
158
|
+
// a screen whose link was rotated or removed: show the page that says so
|
|
159
|
+
if (res.status === 404) { location.reload(); throw 0; }
|
|
160
|
+
if (!res.ok) throw 0;
|
|
161
|
+
return res.text();
|
|
162
|
+
})
|
|
163
|
+
.then(function (html) {
|
|
164
|
+
var next = new DOMParser().parseFromString(html, 'text/html').querySelector('.acms-display[data-rendered]');
|
|
165
|
+
if (!next) throw 0;
|
|
166
|
+
var cur = root();
|
|
167
|
+
cur.parentNode.replaceChild(document.importNode(next, true), cur);
|
|
168
|
+
failed = false;
|
|
169
|
+
lastOk = Date.now();
|
|
170
|
+
pages(root());
|
|
171
|
+
tick();
|
|
172
|
+
})
|
|
173
|
+
.catch(function () { failed = true; status(root()); })
|
|
174
|
+
.then(function () { clearTimeout(timer); busy = false; });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
tick();
|
|
178
|
+
pages(first);
|
|
179
|
+
setInterval(tick, 20000);
|
|
180
|
+
setInterval(function () { page++; pages(root()); }, PAGE_EVERY);
|
|
181
|
+
if (live) {
|
|
182
|
+
setInterval(refresh, REFRESH);
|
|
183
|
+
// a copy from the offline store: try for fresh events at once
|
|
184
|
+
if (Date.now() - lastOk > 60000) refresh();
|
|
185
|
+
var sw = first.getAttribute('data-sw');
|
|
186
|
+
if (sw && navigator.serviceWorker) navigator.serviceWorker.register(sw).catch(function () {});
|
|
187
|
+
}
|
|
188
|
+
})();
|
|
189
|
+
</script>
|
|
190
|
+
)}
|
|
191
|
+
</body>
|
|
192
|
+
</html>
|
package/src/lib/calendar/data.ts
CHANGED
|
@@ -2,8 +2,12 @@
|
|
|
2
2
|
// calendar surface — the /calendar page, the calendar card, embeds, the admin
|
|
3
3
|
// agenda. Native events are expanded from the DB; feed calendars are fetched
|
|
4
4
|
// live through the cached ICS loader. Visibility rules live in events.ts.
|
|
5
|
+
// Audiences: 'public' (visitors), 'admin' (staff views: drafts too, every
|
|
6
|
+
// calendar, booking names) and 'screen' (a saved TV screen showing private
|
|
7
|
+
// details, lib/display: published events only, private calendars when it
|
|
8
|
+
// names them, bookings with the booker's name, never emails, phones or notes).
|
|
5
9
|
import type { ContentRepository } from '../repo/types';
|
|
6
|
-
import { roomColor, type CalEvent, type Calendar, type Resource, type Settings } from '../schema';
|
|
10
|
+
import { roomColor, type BookingLink, type CalEvent, type Calendar, type Resource, type Settings } from '../schema';
|
|
7
11
|
import { eventIsListed, eventIsPublic, eventRoomIds, expandEvents, locationLine, safeColor, type OccurrenceMove } from './events';
|
|
8
12
|
import { expandVEvents, loadFeed, tzParts, type CalendarEvent, type CalendarWindow } from './ics';
|
|
9
13
|
import { CALENDAR, eventUrl, resourceUrl, withoutPrivateFields } from './config';
|
|
@@ -21,7 +25,7 @@ export interface ViewEvent extends CalendarEvent {
|
|
|
21
25
|
calendarName?: string;
|
|
22
26
|
event?: CalEvent;
|
|
23
27
|
// native events: the rooms this date holds; public views name only rooms
|
|
24
|
-
// set to show on public events (Resource.showOnEvents)
|
|
28
|
+
// set to show on public events (Resource.showOnEvents). Bookings: the room booked
|
|
25
29
|
rooms?: ViewRoom[];
|
|
26
30
|
// native events: the date's occurrence key (a moved date keeps its original one)
|
|
27
31
|
key?: string;
|
|
@@ -33,7 +37,7 @@ export interface ViewEvent extends CalendarEvent {
|
|
|
33
37
|
export interface ViewRoom { id: string; slug: string; name: string; color: string }
|
|
34
38
|
|
|
35
39
|
export interface ViewScope {
|
|
36
|
-
audience: 'public' | 'admin';
|
|
40
|
+
audience: 'public' | 'admin' | 'screen';
|
|
37
41
|
// limit to these calendars; omitted = every calendar the audience may see
|
|
38
42
|
calendarIds?: string[];
|
|
39
43
|
// native events outside any calendar (public only when unscoped)
|
|
@@ -65,7 +69,9 @@ export async function collectViewEvents(
|
|
|
65
69
|
const requested = ids ? new Set(ids) : null;
|
|
66
70
|
const visible = all.filter((c) => {
|
|
67
71
|
if (scope.audience === 'admin') return true;
|
|
68
|
-
// a directly requested calendar may be unlisted; private never is
|
|
72
|
+
// a directly requested calendar may be unlisted; private never is, except
|
|
73
|
+
// on a screen that names it
|
|
74
|
+
if (requested && scope.audience === 'screen') return true;
|
|
69
75
|
return requested ? c.visibility !== 'private' : c.visibility === 'public';
|
|
70
76
|
});
|
|
71
77
|
const inScope = requested ? visible.filter((c) => requested.has(c.id)) : visible;
|
|
@@ -81,7 +87,7 @@ export async function collectViewEvents(
|
|
|
81
87
|
if (requested) return false;
|
|
82
88
|
return scope.includeUncategorized ?? true;
|
|
83
89
|
}
|
|
84
|
-
if (!requested && scope.audience
|
|
90
|
+
if (!requested && scope.audience !== 'admin') return eventIsListed(e, byId);
|
|
85
91
|
return e.calendarIds.some((id) => scoped.has(id));
|
|
86
92
|
});
|
|
87
93
|
|
|
@@ -95,7 +101,8 @@ export async function collectViewEvents(
|
|
|
95
101
|
};
|
|
96
102
|
const resources = natives.some((e) => eventRoomIds(e).length) ? new Map((await repo.listResources()).map((r) => [r.id, r])) : new Map<string, Resource>();
|
|
97
103
|
const rooms = (ids: string[]) => ids.map((id) => resources.get(id))
|
|
98
|
-
|
|
104
|
+
// staff views and screens with private details name every room
|
|
105
|
+
.filter((r): r is Resource => Boolean(r) && (scope.audience !== 'public' || r!.showOnEvents))
|
|
99
106
|
.map((r) => ({ id: r.id, slug: r.slug, name: r.name, color: roomColor(r) }));
|
|
100
107
|
const words = eventStrings(scope.locale).calendar;
|
|
101
108
|
for (const o of expandEvents(natives, settings, win)) {
|
|
@@ -145,21 +152,31 @@ export async function collectViewEvents(
|
|
|
145
152
|
}
|
|
146
153
|
|
|
147
154
|
// resources marked "show on calendar": confirmed bookings appear as
|
|
148
|
-
// Reserved blocks (no guest names on public views) — only on unscoped views
|
|
155
|
+
// Reserved blocks (no guest names on public views) — only on unscoped views.
|
|
156
|
+
// A screen also shows the bookings of the rooms it names, as "<booking
|
|
157
|
+
// type> · <booker>" (the name only: never their email, phone or notes).
|
|
149
158
|
if (!requested && (scope.includeBookings ?? true) && CALENDAR.bookings) {
|
|
150
|
-
const
|
|
159
|
+
const named = scope.audience === 'screen' ? f?.roomIds ?? [] : [];
|
|
160
|
+
const resources = (await repo.listResources()).filter((r) => (r.showOnCalendar || named.includes(r.id)) && (!f || untaggedMatches(f, { roomId: r.id })));
|
|
151
161
|
if (resources.length) {
|
|
152
|
-
const booked = await
|
|
162
|
+
const [booked, links] = await Promise.all([
|
|
163
|
+
repo.listBookings({ from: win.windowStartMs, to: win.windowEndMs, statuses: ['confirmed'], kinds: ['booking'] }),
|
|
164
|
+
scope.audience === 'screen' ? repo.listBookingLinks() : Promise.resolve([] as BookingLink[]),
|
|
165
|
+
]);
|
|
153
166
|
const byId = new Map(resources.map((r) => [r.id, r]));
|
|
167
|
+
const linkName = new Map(links.map((l): [string, string] => [l.id, l.name]));
|
|
154
168
|
for (const b of booked) {
|
|
155
169
|
const r = byId.get(b.resourceId);
|
|
156
170
|
if (!r) continue;
|
|
157
171
|
const allDay = b.end - b.start >= 86_400_000 - 3_600_000;
|
|
172
|
+
const title = scope.audience === 'admin' ? `${r.name} · ${b.name}`
|
|
173
|
+
: scope.audience === 'screen' ? [linkName.get(b.linkId) || r.name, b.name].filter(Boolean).join(' · ')
|
|
174
|
+
: `${words.reserved} · ${r.name}`;
|
|
158
175
|
events.push({
|
|
159
176
|
start: b.start, end: b.end, allDay, days: allDay ? Math.max(1, Math.round((b.end - b.start) / 86_400_000)) : 1,
|
|
160
|
-
title:
|
|
161
|
-
location: r.name, description: '', cal: inScope.length, color: '#8e919a', calendarName: r.name,
|
|
177
|
+
title, location: r.name, description: '', cal: inScope.length, color: '#8e919a', calendarName: r.name,
|
|
162
178
|
url: scope.audience === 'public' ? resourceUrl(r.slug) : undefined,
|
|
179
|
+
rooms: [{ id: r.id, slug: r.slug, name: r.name, color: roomColor(r) }],
|
|
163
180
|
});
|
|
164
181
|
}
|
|
165
182
|
}
|
|
@@ -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
|
+
}
|