domma-cms 0.102.1 → 0.104.0

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.
Files changed (50) hide show
  1. package/package.json +1 -1
  2. package/plugins/_lib/admin/mail/message-actions.js +56 -0
  3. package/plugins/_lib/admin/mail/reader-view.js +30 -1
  4. package/plugins/_lib/call/ice.js +58 -0
  5. package/plugins/_lib/call/rooms.js +249 -0
  6. package/plugins/_lib/call/routes.js +89 -0
  7. package/plugins/_lib/public/call/call.css +1 -0
  8. package/plugins/_lib/public/call/call.js +477 -0
  9. package/plugins/booking/CLAUDE.md +137 -0
  10. package/plugins/booking/admin/css/index.css +192 -0
  11. package/plugins/booking/admin/lib/details.js +58 -0
  12. package/plugins/booking/admin/lib/diary-layout.js +140 -0
  13. package/plugins/booking/admin/lib/extension-sections.js +160 -0
  14. package/plugins/booking/admin/lib/kit.js +249 -0
  15. package/plugins/booking/admin/templates/booking.html +1 -0
  16. package/plugins/booking/admin/views/booking.js +207 -0
  17. package/plugins/booking/admin/views/bookings.js +127 -0
  18. package/plugins/booking/admin/views/diary.js +374 -0
  19. package/plugins/booking/admin/views/panels.js +516 -0
  20. package/plugins/booking/admin/views/people.js +281 -0
  21. package/plugins/booking/admin/views/services.js +222 -0
  22. package/plugins/booking/admin/views/settings.js +203 -0
  23. package/plugins/booking/collections/booking-bookables.json +17 -0
  24. package/plugins/booking/collections/booking-bookings.json +18 -0
  25. package/plugins/booking/collections/booking-services.json +17 -0
  26. package/plugins/booking/config.js +36 -0
  27. package/plugins/booking/docs/guide.md +70 -0
  28. package/plugins/booking/inject/body.html +1 -0
  29. package/plugins/booking/lib/call.js +72 -0
  30. package/plugins/booking/lib/extensions.js +169 -0
  31. package/plugins/booking/lib/mail.js +113 -0
  32. package/plugins/booking/lib/store.js +390 -0
  33. package/plugins/booking/plugin.js +806 -0
  34. package/plugins/booking/plugin.json +80 -0
  35. package/plugins/booking/plugin.public.js +157 -0
  36. package/plugins/booking/public/booking.css +159 -0
  37. package/plugins/booking/public/booking.js +808 -0
  38. package/plugins/booking/public/lib/clean.js +141 -0
  39. package/plugins/booking/public/lib/page-format.js +121 -0
  40. package/plugins/booking/public/lib/slots.js +247 -0
  41. package/plugins/booking/public/lib/time.js +131 -0
  42. package/plugins/booking/templates/booking.html +5 -0
  43. package/plugins/booking/tests/admin-diary.test.js +73 -0
  44. package/plugins/booking/tests/api.test.js +445 -0
  45. package/plugins/booking/tests/public.test.js +72 -0
  46. package/plugins/booking/tests/pure.test.js +189 -0
  47. package/plugins/free-tier.lock.json +44 -1
  48. package/server/routes/api/related.js +20 -5
  49. package/server/server.js +3 -3
  50. package/server/services/hooks.js +35 -8
@@ -0,0 +1,374 @@
1
+ /**
2
+ * Booking › Diary.
3
+ *
4
+ * A day (one column per person or room) or a week (one column per day), in
5
+ * the business's time zone. Opening hours are the light bands; outside them
6
+ * is shaded. A booking is a block: click opens it, right-click / ⋮ /
7
+ * Shift+F10 is its menu. Click an empty time to book it. The first time
8
+ * round, a checklist says what to set up.
9
+ */
10
+
11
+ import {STATUS_LABEL, V, api, copy, errText, esc, openPanel, store, wireRows} from '../lib/kit.js';
12
+ import {columnItems, dayLabel, datesFor, gridHours, layoutColumn, minuteAt, placeOf} from '../lib/diary-layout.js';
13
+ import {withNames} from '../lib/details.js';
14
+ import {addDays, atWallClock, fromMinutes, isDate, wallClock} from '/plugins/booking/public/lib/time.js';
15
+ import {windowsOn} from '/plugins/booking/public/lib/slots.js';
16
+
17
+ const PX_PER_MIN = 1; // an hour is 60px
18
+ const VIEW_KEY = 'booking.diary-view';
19
+ const CANCELLED_KEY = 'booking.diary-cancelled';
20
+ const CHECKLIST_KEY = 'booking.checklist-hidden';
21
+
22
+ export async function mount(root, ctx) {
23
+ const {kit, scope, here, boot} = ctx;
24
+ const panels = await import(`/plugins/booking/admin/views/panels.js?v=${V}`);
25
+ if (!here()) return undefined;
26
+
27
+ const view = M.observable(store.get(VIEW_KEY) === 'week' ? 'week' : 'day');
28
+ const date = M.observable(boot.today);
29
+ const showCancelled = M.observable(store.get(CANCELLED_KEY) === true);
30
+ const bookings = M.observable([]);
31
+ /** Class sessions drawn as one block: key -> their bookings. */
32
+ const sessions = new Map();
33
+ const loading = M.observable(true);
34
+ const error = M.observable('');
35
+ const label = M.observable('');
36
+ scope(M.effect(() => store.set(VIEW_KEY, view.value)));
37
+ scope(M.effect(() => store.set(CANCELLED_KEY, showCancelled.value)));
38
+
39
+ root.innerHTML = '';
40
+ const card = document.createElement('div');
41
+ card.className = 'bk-card bk-diary';
42
+ card.innerHTML = `
43
+ <div class="bk-checklist" data-part="checklist" hidden></div>
44
+ <div class="bk-bar">
45
+ <div class="bk-seg" role="group" aria-label="View">
46
+ <button type="button" data-view="day" data-bind-class="view.value === 'day' && 'is-on'">Day</button>
47
+ <button type="button" data-view="week" data-bind-class="view.value === 'week' && 'is-on'">Week</button>
48
+ </div>
49
+ <div class="bk-seg" role="group" aria-label="Move">
50
+ <button type="button" data-step="-1" title="Earlier" aria-label="Earlier"><span data-icon="chevron-left" data-icon-size="12"></span></button>
51
+ <button type="button" data-step="0">Today</button>
52
+ <button type="button" data-step="1" title="Later" aria-label="Later"><span data-icon="chevron-right" data-icon-size="12"></span></button>
53
+ </div>
54
+ <input type="date" class="form-input bk-date" aria-label="Go to date" data-part="date">
55
+ <strong class="bk-when" data-bind-text="label.value"></strong>
56
+ <span class="bk-spacer"></span>
57
+ <label class="bk-check"><input type="checkbox" data-part="cancelled"> Show cancelled</label>
58
+ <span data-help-title="The Diary" data-help="Bookings in your business's time zone (${esc(ctx.tz)}). Hatched times are outside opening hours. Click a booking to open it; right-click it (or ⋮, or Shift+F10) for Move, Done, No-show and Cancel. Click an empty time to book it. Keys: ← → earlier and later, T today, D day, W week."></span>
59
+ </div>
60
+ <p class="bk-note is-bad" data-bind-hidden="!error.value" data-bind-text="error.value"></p>
61
+ <div class="bk-diary-wrap" data-part="grid" tabindex="-1"></div>`;
62
+ root.appendChild(card);
63
+ const gridBox = card.querySelector('[data-part="grid"]');
64
+ const dateIn = card.querySelector('[data-part="date"]');
65
+ const cancelledBox = card.querySelector('[data-part="cancelled"]');
66
+ const checklist = card.querySelector('[data-part="checklist"]');
67
+ scope(kit.helpPopovers(card, {position: 'bottom'}));
68
+ const bindings = M.applyBindings({view, label, error}, card);
69
+ scope(() => bindings.dispose());
70
+ I.scan(card);
71
+
72
+ cancelledBox.checked = showCancelled.peek();
73
+ cancelledBox.addEventListener('change', () => { showCancelled.value = cancelledBox.checked; });
74
+ dateIn.value = date.peek();
75
+ dateIn.addEventListener('change', () => { if (isDate(dateIn.value)) date.value = dateIn.value; });
76
+
77
+ const step = (n) => {
78
+ if (n === 0) { date.value = boot.today; return; }
79
+ date.value = addDays(date.peek(), n * (view.peek() === 'week' ? 7 : 1));
80
+ };
81
+ card.querySelector('.bk-bar').addEventListener('click', (e) => {
82
+ const v = e.target.closest('[data-view]');
83
+ if (v) view.value = v.dataset.view;
84
+ const s = e.target.closest('[data-step]');
85
+ if (s) step(Number(s.dataset.step));
86
+ });
87
+ const onKey = (e) => {
88
+ if (e.defaultPrevented || e.ctrlKey || e.metaKey || e.altKey) return;
89
+ if (e.target.closest('input, textarea, select, [contenteditable], .dm-slideover, [role="dialog"]')) return;
90
+ if (!root.isConnected) return;
91
+ const k = e.key.toLowerCase();
92
+ if (e.key === 'ArrowLeft') step(-1);
93
+ else if (e.key === 'ArrowRight') step(1);
94
+ else if (k === 't') step(0);
95
+ else if (k === 'd') view.value = 'day';
96
+ else if (k === 'w') view.value = 'week';
97
+ else return;
98
+ e.preventDefault();
99
+ };
100
+ document.addEventListener('keydown', onKey);
101
+ scope(() => document.removeEventListener('keydown', onKey));
102
+
103
+ // ---- The first-run checklist --------------------------------------------------
104
+
105
+ function drawChecklist() {
106
+ const steps = [
107
+ {done: boot.bookables.length > 0, text: 'Add who or what can be booked - a person or a room', go: () => ctx.go('people', 'new')},
108
+ {done: boot.services.length > 0, text: 'Add a service: what can be booked, for how long, and with whom', go: () => ctx.go('services', 'new')},
109
+ {done: store.get('booking.settings-seen') === true, text: boot.mailReady ? 'Check the settings: your booking page, notice rules and emails' : 'Check the settings - and set up outgoing mail, or no confirmation emails are sent',
110
+ go: () => { store.set('booking.settings-seen', true); ctx.openSettings(); drawChecklist(); }},
111
+ {done: store.get('booking.link-shared') === true, text: `Share your booking page: ${location.origin}${ctx.settings.bookingPath}`,
112
+ go: () => { store.set('booking.link-shared', true); copy(`${location.origin}${ctx.settings.bookingPath}`, 'Booking page address copied'); drawChecklist(); }}
113
+ ];
114
+ const left = steps.filter(s => !s.done).length;
115
+ if (!left || store.get(CHECKLIST_KEY) === true) { checklist.hidden = true; return; }
116
+ checklist.hidden = false;
117
+ checklist.innerHTML = `
118
+ <header><h3><span data-icon="clipboard-list" data-icon-size="15"></span> Getting started</h3>
119
+ <button type="button" class="btn btn-xs btn-ghost" data-hide title="Hide this list">Hide</button></header>
120
+ <ol>${steps.map((s, i) => `<li class="${s.done ? 'is-done' : ''}"><span data-icon="${s.done ? 'check-circle' : 'circle'}" data-icon-size="14"></span>
121
+ <button type="button" class="bk-link" data-step-i="${i}">${esc(s.text)}</button></li>`).join('')}</ol>`;
122
+ I.scan(checklist);
123
+ checklist.onclick = (e) => {
124
+ if (e.target.closest('[data-hide]')) { store.set(CHECKLIST_KEY, true); checklist.hidden = true; return; }
125
+ const i = e.target.closest('[data-step-i]')?.dataset.stepI;
126
+ if (i !== undefined) steps[Number(i)].go();
127
+ };
128
+ }
129
+ drawChecklist();
130
+
131
+ // ---- Loading ----------------------------------------------------------------
132
+
133
+ const dates = () => datesFor(view.peek(), date.peek());
134
+ let seq = 0;
135
+ async function load() {
136
+ const ds = dates();
137
+ const my = ++seq;
138
+ loading.value = true;
139
+ try {
140
+ const from = atWallClock(ds[0], 0, ctx.tz).toISOString();
141
+ const to = atWallClock(addDays(ds[ds.length - 1], 1), 0, ctx.tz).toISOString();
142
+ const list = await api.bookings.list({from, to});
143
+ if (my !== seq || !here()) return;
144
+ bookings.value = Array.isArray(list) ? list : [];
145
+ error.value = '';
146
+ } catch (err) {
147
+ if (my === seq) error.value = `The diary could not load: ${errText(err)}`;
148
+ } finally {
149
+ if (my === seq) loading.value = false;
150
+ }
151
+ }
152
+ ctx.onBookingsChanged = () => { if (here()) load(); };
153
+ scope(() => { ctx.onBookingsChanged = () => {}; });
154
+ scope(M.effect(() => { view.value; date.value; dateIn.value = date.value; load(); }));
155
+
156
+ // ---- Drawing ---------------------------------------------------------------
157
+
158
+ function columns() {
159
+ const ds = dates();
160
+ const active = boot.bookables.filter(b => b.active !== false);
161
+ if (view.peek() === 'week') {
162
+ return ds.map(d => ({key: d, date: d, title: dayLabel(d), today: d === boot.today, windows: mergeWindows(active.flatMap(b => windowsOn(b, d, ctx.settings.closures)))}));
163
+ }
164
+ const d = ds[0];
165
+ const used = new Set(bookings.peek().flatMap(b => (Array.isArray(b.holds) && b.holds.length ? b.holds : [b.bookableId])));
166
+ const shown = boot.bookables.filter(b => b.active !== false || used.has(b.id));
167
+ return shown.map(b => ({key: b.id, date: d, bookableId: b.id, title: b.name, colour: b.colour, kind: b.kind, today: d === boot.today,
168
+ windows: windowsOn(b, d, ctx.settings.closures)}));
169
+ }
170
+
171
+ function draw() {
172
+ const list = bookings.value.filter(b => showCancelled.value || b.status !== 'cancelled');
173
+ const ds = datesFor(view.value, date.value);
174
+ label.value = view.value === 'week' ? `${dayLabel(ds[0])} - ${dayLabel(ds[6])} ${ds[6].slice(0, 4)}` : `${dayLabel(ds[0])} ${ds[0].slice(0, 4)}`;
175
+ const cols = columns();
176
+ gridBox.innerHTML = '';
177
+ if (!boot.bookables.length) {
178
+ gridBox.innerHTML = `<div class="bk-empty"><span data-icon="users" data-icon-size="28"></span>
179
+ <p><strong>Add who or what can be booked.</strong><br>A person (for appointments) or a room or thing - each with their opening hours.</p>
180
+ <button type="button" class="btn btn-sm btn-primary" data-go="people"><span data-icon="plus" data-icon-size="13"></span> Add a person or room</button></div>`;
181
+ gridBox.querySelector('[data-go]').onclick = () => ctx.go('people', 'new');
182
+ I.scan(gridBox);
183
+ return;
184
+ }
185
+ const colours = new Map(boot.bookables.map(b => [b.id, b.colour || 'blue']));
186
+ sessions.clear();
187
+ const placed = list.map(b => ({b, ...placeOf(b, ctx.tz)})).filter(p => ds.includes(p.date));
188
+ const {from, to} = gridHours(boot.bookables.filter(b => b.active !== false), ds, placed);
189
+ const height = (to - from) * PX_PER_MIN;
190
+
191
+ const grid = document.createElement('div');
192
+ grid.className = `bk-diary-grid is-${view.value}`;
193
+ grid.style.setProperty('--bk-cols', String(cols.length));
194
+ grid.style.setProperty('--bk-h', `${height}px`);
195
+ grid.style.setProperty('--bk-hour', `${60 * PX_PER_MIN}px`);
196
+
197
+ // Heads
198
+ const head = document.createElement('div');
199
+ head.className = 'bk-diary-head';
200
+ head.appendChild(document.createElement('span'));
201
+ for (const c of cols) {
202
+ const h = document.createElement('div');
203
+ h.className = `bk-col-head${c.today && view.value === 'week' ? ' is-today' : ''}`;
204
+ if (c.colour) h.dataset.colour = c.colour;
205
+ h.innerHTML = `${c.kind ? `<span data-icon="${c.kind === 'resource' ? 'box' : 'user'}" data-icon-size="13"></span> ` : ''}<span>${esc(c.title)}</span>`;
206
+ if (view.value === 'week') { h.classList.add('bk-link-head'); h.title = 'Show this day'; h.onclick = () => { date.value = c.date; view.value = 'day'; }; }
207
+ head.appendChild(h);
208
+ }
209
+ grid.appendChild(head);
210
+
211
+ // Time gutter + columns
212
+ const bodyRow = document.createElement('div');
213
+ bodyRow.className = 'bk-diary-body';
214
+ const gutter = document.createElement('div');
215
+ gutter.className = 'bk-gutter';
216
+ for (let m = from; m < to; m += 60) {
217
+ const t = document.createElement('span');
218
+ t.style.top = `${(m - from) * PX_PER_MIN}px`;
219
+ t.textContent = fromMinutes(m);
220
+ gutter.appendChild(t);
221
+ }
222
+ bodyRow.appendChild(gutter);
223
+
224
+ const nowWall = wallClock(Date.now(), ctx.tz);
225
+ for (const c of cols) {
226
+ const col = document.createElement('div');
227
+ col.className = 'bk-col';
228
+ col.dataset.date = c.date;
229
+ if (c.bookableId) col.dataset.bookable = c.bookableId;
230
+ col.title = 'Click an empty time to book it';
231
+ for (const w of c.windows) {
232
+ const band = document.createElement('div');
233
+ band.className = 'bk-open';
234
+ const bandTop = (Math.max(w.from, from) - from) * PX_PER_MIN;
235
+ band.style.top = `${bandTop}px`;
236
+ // Keep its hour lines on the column's.
237
+ band.style.backgroundPositionY = `${-bandTop}px`;
238
+ band.style.height = `${(Math.min(w.to, to) - Math.max(w.from, from)) * PX_PER_MIN}px`;
239
+ col.appendChild(band);
240
+ }
241
+ if (c.date === nowWall.date && nowWall.minutes >= from && nowWall.minutes <= to) {
242
+ const line = document.createElement('div');
243
+ line.className = 'bk-now';
244
+ line.style.top = `${(nowWall.minutes - from) * PX_PER_MIN}px`;
245
+ col.appendChild(line);
246
+ }
247
+ const dayBookings = placed.filter(p => p.date === c.date).map(p => p.b);
248
+ const items = columnItems(dayBookings, {bookableId: c.bookableId || ''}).map(it => ({...it, ...placeOf(it.b, ctx.tz)}));
249
+ for (const p of layoutColumn(items)) {
250
+ const b = p.b;
251
+ const el = document.createElement('div');
252
+ el.className = `bk-block is-${b.status}${p.companion ? ' is-companion' : ''}${p.session ? ' is-session' : ''}`;
253
+ if (p.session) {
254
+ sessions.set(p.key, p.bookings);
255
+ el.dataset.id = `session:${p.key}`;
256
+ } else {
257
+ el.dataset.id = b.id;
258
+ }
259
+ el.dataset.colour = colours.get(b.bookableId) || 'grey';
260
+ el.tabIndex = 0;
261
+ el.setAttribute('role', 'button');
262
+ const top = (Math.max(p.startMin, from) - from) * PX_PER_MIN;
263
+ el.style.top = `${top}px`;
264
+ el.style.height = `${Math.max(18, (Math.min(p.endMin, to) - Math.max(p.startMin, from)) * PX_PER_MIN - 2)}px`;
265
+ el.style.left = `calc(${(p.lane / p.lanes) * 100}% + 2px)`;
266
+ el.style.width = `calc(${100 / p.lanes}% - 4px)`;
267
+ const s = wallClock(b.startAt, ctx.tz).time;
268
+ const e = wallClock(b.endAt, ctx.tz).time;
269
+ const time = view.value === 'week' ? esc(s) : `${esc(s)}-${esc(e)}`;
270
+ const statusNote = b.status !== 'confirmed' ? ` · ${esc({completed: 'done', 'no-show': 'no-show', cancelled: 'cancelled', pending: 'pending'}[b.status] || b.status)}` : '';
271
+ if (p.session) {
272
+ // A class: one block for everyone in it.
273
+ el.innerHTML = `<button type="button" class="bk-kebab" aria-label="Actions" title="Actions">⋮</button>
274
+ <strong>${time} ${esc(b.serviceName)} · ${p.bookings.length} booked</strong><small>${p.companion ? `with ${esc(b.bookableName)}` : esc(p.bookings.map(x => x.name).join(', '))}${view.value === 'week' && b.bookableName ? ` · ${esc(b.bookableName)}` : ''}</small>`;
275
+ el.title = `${s}-${e} ${b.serviceName}: ${p.bookings.length} booked${b.bookableName ? ` (with ${withNames(b)})` : ''}`;
276
+ } else if (p.companion) {
277
+ // Held along with someone else's booking (a court with the trainer).
278
+ el.innerHTML = `<button type="button" class="bk-kebab" aria-label="Actions" title="Actions">⋮</button>
279
+ <strong>${time} ${esc(b.name)}</strong><small>${esc(b.serviceName)} · with ${esc(b.bookableName)}${statusNote}</small>`;
280
+ el.title = `${s}-${e} ${b.serviceName}: ${b.name} - held with ${b.bookableName}'s booking`;
281
+ } else {
282
+ el.innerHTML = `<button type="button" class="bk-kebab" aria-label="Actions" title="Actions">⋮</button>
283
+ <strong>${time} ${esc(b.name)}</strong><small>${esc(b.serviceName)}${view.value === 'week' && b.bookableName ? ` · ${esc(withNames(b))}` : b.alsoNames?.length ? ` · with ${esc(b.alsoNames.join(', '))}` : ''}${statusNote}</small>`;
284
+ el.title = `${s}-${e} ${b.serviceName}: ${b.name}${withNames(b) ? ` (with ${withNames(b)})` : ''}`;
285
+ }
286
+ col.appendChild(el);
287
+ }
288
+ bodyRow.appendChild(col);
289
+ }
290
+ grid.appendChild(bodyRow);
291
+ gridBox.appendChild(grid);
292
+ if (!boot.services.length) {
293
+ const tip = document.createElement('p');
294
+ tip.className = 'bk-note';
295
+ tip.innerHTML = 'Next: <button type="button" class="bk-link">add a service</button> - what can be booked, for how long, and with whom.';
296
+ tip.querySelector('button').onclick = () => ctx.go('services', 'new');
297
+ gridBox.prepend(tip);
298
+ }
299
+ }
300
+ scope(M.effect(draw));
301
+
302
+ // A click on an empty time books it.
303
+ gridBox.addEventListener('click', (e) => {
304
+ if (e.target.closest('.bk-block')) return;
305
+ const col = e.target.closest('.bk-col');
306
+ if (!col) return;
307
+ const rect = col.getBoundingClientRect();
308
+ const {from} = gridHours(boot.bookables.filter(b => b.active !== false), datesFor(view.peek(), date.peek()),
309
+ bookings.peek().map(b => placeOf(b, ctx.tz)).filter(p => datesFor(view.peek(), date.peek()).includes(p.date)));
310
+ const minute = minuteAt(e.clientY - rect.top, {from, pxPerMin: PX_PER_MIN});
311
+ ctx.newBooking({date: col.dataset.date, time: fromMinutes(minute), bookableId: col.dataset.bookable || ''});
312
+ });
313
+
314
+ const byId = (id) => bookings.peek().find(b => b.id === id);
315
+ const sessionOf = (id) => (id.startsWith('session:') ? sessions.get(id.slice(8)) || [] : null);
316
+
317
+ /** A class: who is in it, each opening their booking. */
318
+ async function openSession(list) {
319
+ if (!list.length) return;
320
+ const first = list[0];
321
+ const s = wallClock(first.startAt, ctx.tz);
322
+ const e = wallClock(first.endAt, ctx.tz);
323
+ let panel = null;
324
+ panel = await openPanel({
325
+ title: `${first.serviceName}, ${s.time}-${e.time}`,
326
+ size: 'sm',
327
+ html: `<div class="bk-form">
328
+ <p class="bk-muted">${esc(dayLabel(s.date))}${first.bookableName ? ` · with ${esc(withNames(first))}` : ''} · ${list.length} booked</p>
329
+ <div class="bk-list bk-session-list">${list.map(b => `<button type="button" class="bk-row" data-open="${esc(b.id)}">
330
+ <span class="bk-main"><strong>${esc(b.name)}</strong><small>${esc([b.email, b.phone].filter(Boolean).join(' · '))}</small></span>
331
+ <span class="bk-chip">${esc(STATUS_LABEL[b.status] || b.status)}</span></button>`).join('')}</div>
332
+ </div>`,
333
+ after: (el) => {
334
+ el.addEventListener('click', (ev) => {
335
+ const row = ev.target.closest('[data-open]');
336
+ if (!row) return;
337
+ panel?.close(true);
338
+ ctx.openBooking(row.dataset.open);
339
+ });
340
+ setTimeout(() => el.querySelector('[data-open]')?.focus(), 60);
341
+ }
342
+ });
343
+ }
344
+
345
+ wireRows(gridBox, {
346
+ match: '.bk-block',
347
+ openMenuAt: kit.openMenuAt,
348
+ scope,
349
+ main: (id) => { const list = sessionOf(id); return list ? openSession(list) : ctx.openBooking(id); },
350
+ items: (id) => {
351
+ const list = sessionOf(id);
352
+ if (!list) return panels.bookingMenu(ctx, byId(id));
353
+ return [
354
+ {label: `Everyone in it (${list.length})`, icon: 'users', action: () => openSession(list)},
355
+ {type: 'divider'},
356
+ ...list.map(b => ({label: `Open ${b.name}`, icon: 'external-link', action: () => ctx.openBooking(b.id)}))
357
+ ];
358
+ }
359
+ });
360
+
361
+ return () => {};
362
+ }
363
+
364
+ function mergeWindows(list) {
365
+ const sorted = [...list].sort((a, b) => a.from - b.from);
366
+ const out = [];
367
+ for (const w of sorted) {
368
+ const last = out[out.length - 1];
369
+ if (last && w.from <= last.to) last.to = Math.max(last.to, w.to);
370
+ else out.push({...w});
371
+ }
372
+ return out;
373
+ }
374
+