@acms-subir/core 0.3.47 → 0.3.48

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 (44) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/cli/migrations.generated.mjs +42 -23
  3. package/docs/calendar.md +80 -0
  4. package/docs/server.md +28 -3
  5. package/docs/theme-authoring.md +1 -1
  6. package/package.json +1 -1
  7. package/src/actions/index.ts +76 -11
  8. package/src/components/EventPage.astro +18 -12
  9. package/src/components/calendar/CalendarBoard.tsx +43 -19
  10. package/src/components/editor/event/EventSheet.tsx +2 -0
  11. package/src/components/editor/event/SingleDates.tsx +123 -0
  12. package/src/lib/booking/holds.ts +7 -7
  13. package/src/lib/calendar/api.ts +11 -1
  14. package/src/lib/calendar/board.ts +7 -3
  15. package/src/lib/calendar/cards.ts +5 -5
  16. package/src/lib/calendar/change-migration.ts +40 -15
  17. package/src/lib/calendar/data.ts +9 -3
  18. package/src/lib/calendar/event-words.ts +9 -0
  19. package/src/lib/calendar/events.ts +94 -9
  20. package/src/lib/calendar/ics-write.ts +25 -2
  21. package/src/lib/calendar/moves.ts +291 -0
  22. package/src/lib/calendar/render.ts +10 -6
  23. package/src/lib/calendar/schedule.ts +73 -61
  24. package/src/lib/email/booking-words.ts +7 -1
  25. package/src/lib/email/service.ts +6 -1
  26. package/src/lib/email/templates.ts +6 -2
  27. package/src/lib/mobile/calendar.ts +5 -2
  28. package/src/lib/mobile/schemas.ts +7 -3
  29. package/src/lib/push/service.ts +44 -10
  30. package/src/lib/push/types.ts +2 -0
  31. package/src/lib/repo/migrations.ts +5 -2
  32. package/src/lib/repo/sql.ts +28 -10
  33. package/src/lib/repo/types.ts +6 -3
  34. package/src/lib/schema.ts +21 -0
  35. package/src/lib/sections.ts +5 -2
  36. package/src/lib/sync/catalog.ts +1 -0
  37. package/src/pages/api/calendar/events/index.ts +6 -0
  38. package/src/pages/members/_Layout.astro +1 -0
  39. package/src/pages/members/events/index.astro +1 -1
  40. package/src/server/index.ts +33 -0
  41. package/src/styles/editor.css +19 -0
  42. package/src/styles/schedule.css +2 -0
  43. package/src/styles/site.css +6 -0
  44. package/src/theme-sdk/calendar.ts +3 -2
@@ -10,11 +10,15 @@ import '../../styles/schedule.css';
10
10
 
11
11
  // The admin Schedule: a month (or week) grid of everything on the calendar —
12
12
  // events, drafts, subscribed calendars, bookings. Click a day to add, drag
13
- // an event to move it, click a day number for that day's list. Data comes
14
- // from actions.calendar.range; this file only does UI and layout.
13
+ // an event to move it (in the week view, to another time too; one date of a
14
+ // series becomes a moved date that keeps its sign-ups), click a day number
15
+ // for that day's list. Data comes from actions.calendar.range; this file only
16
+ // does UI and layout.
15
17
 
16
18
  type View = 'month' | 'week';
17
- type MoveUndo = { kind: 'series'; id: string; days: number } | { kind: 'split'; id: string; seriesId: string; occurrence: string };
19
+ type MoveUndo = { kind: 'series'; id: string; days: number; minutes?: number }
20
+ | { kind: 'date'; id: string; key: string; previous: { start: string; end: string; roomIds?: string[]; note: string } | null }
21
+ | { kind: 'split'; id: string; seriesId: string; occurrence: string };
18
22
 
19
23
  interface Props {
20
24
  initial: BoardData;
@@ -61,8 +65,10 @@ export default function CalendarBoard(props: Props) {
61
65
  const [peek, setPeek] = useState<{ item: BoardItem; rect: DOMRect } | null>(null);
62
66
  const [dragKey, setDragKey] = useState<string | null>(null);
63
67
  const [dropDay, setDropDay] = useState<Iso | null>(null);
64
- const [ask, setAsk] = useState<{ item: BoardItem; days: number; to: Iso } | null>(null);
65
- const [toast, setToast] = useState<{ text: string; undo?: MoveUndo; error?: boolean } | null>(null);
68
+ const [ask, setAsk] = useState<{ item: BoardItem; days: number; minutes: number; to: Iso } | null>(null);
69
+ const [toast, setToast] = useState<{ text: string; undo?: MoveUndo; error?: boolean; warning?: string } | null>(null);
70
+ // week view: where on the event the pointer grabbed it (minutes below its start)
71
+ const grab = useRef(0);
66
72
  const [menu, setMenu] = useState(false);
67
73
  const [compact, setCompact] = useState(false);
68
74
  const [selected, setSelected] = useState<Iso>(props.anchor);
@@ -182,18 +188,26 @@ export default function CalendarBoard(props: Props) {
182
188
  setDraft({
183
189
  key: '__draft', kind: 'event', id: '', title: d.title, startDay: d.day, endDay: d.endDay, allDay: d.allDay || d.endDay > d.day,
184
190
  startMin: d.startMin, endMin: d.endMin, time: d.allDay ? '' : shortTime(d.startMin), range: '', color, calendarId: d.calendarId,
185
- calendarName: '', status: 'draft', recurring: false, repeat: '', rooms: [], roomsHeld: false, occurrence: '', cancelled: false, dateCancelled: false, location: '', edited: false, url: '',
191
+ calendarName: '', status: 'draft', recurring: false, repeat: '', rooms: [], roomsHeld: false, occurrence: '', cancelled: false, dateCancelled: false, movedFrom: '', location: '', edited: false, url: '',
186
192
  });
187
193
  };
188
194
 
189
195
  // ---- moving ----------------------------------------------------------------
190
196
  const dayLabel = (d: Iso, o: Intl.DateTimeFormatOptions = { weekday: 'short', month: 'short', day: 'numeric' }) =>
191
197
  new Intl.DateTimeFormat(locale, { ...o, timeZone: 'UTC' }).format(utc(d));
192
- async function move(item: BoardItem, days: number, mode: 'series' | 'one', to: Iso) {
198
+ async function move(item: BoardItem, days: number, minutes: number, mode: 'series' | 'one', to: Iso) {
193
199
  setAsk(null);
194
- const r = await actions.events.move({ id: item.id, days, mode, occurrence: item.occurrence });
200
+ const r = await actions.events.move({ id: item.id, days, minutes, mode, occurrence: item.occurrence });
195
201
  if (r.error || !r.data) { setToast({ text: r.error?.message ?? 'Couldn’t move the event.', error: true }); return; }
196
- setToast({ text: `Moved “${item.title}” to ${dayLabel(to)}`, undo: r.data.undo as MoveUndo });
202
+ const when = !item.allDay && (minutes || view === 'week') ? `${dayLabel(to)}, ${shortTime(Math.max(0, Math.min(1439, item.startMin + minutes)))}` : dayLabel(to);
203
+ setToast({ text: `Moved “${item.title}” to ${when}`, undo: r.data.undo as MoveUndo, warning: r.data.warning || undefined });
204
+ await refresh();
205
+ }
206
+ async function moveBack(it: BoardItem) {
207
+ const r = await actions.events.moveDateBack({ id: it.id, key: it.occurrence });
208
+ setPeek(null);
209
+ if (r.error || !r.data) { setToast({ text: r.error?.message ?? 'Couldn’t move it back.', error: true }); return; }
210
+ setToast({ text: `Moved “${it.title}” back to ${it.movedFrom}`, warning: r.data.warning || undefined });
197
211
  await refresh();
198
212
  }
199
213
  async function undo(u: MoveUndo) {
@@ -205,7 +219,7 @@ export default function CalendarBoard(props: Props) {
205
219
  }
206
220
  useEffect(() => {
207
221
  if (!toast) return;
208
- const t = setTimeout(() => setToast(null), toast.undo ? 8000 : 4000);
222
+ const t = setTimeout(() => setToast(null), toast.warning ? 12000 : toast.undo ? 8000 : 4000);
209
223
  return () => clearTimeout(t);
210
224
  }, [toast]);
211
225
 
@@ -213,22 +227,29 @@ export default function CalendarBoard(props: Props) {
213
227
  if (!editable(it)) { e.preventDefault(); return; }
214
228
  e.dataTransfer.setData('text/plain', it.key);
215
229
  e.dataTransfer.effectAllowed = 'move';
230
+ // in the week's hour grid, keep the grabbed spot under the pointer
231
+ const box = (e.currentTarget as HTMLElement).getBoundingClientRect();
232
+ grab.current = Math.max(0, (e.clientY - box.top) / HOUR * 60);
216
233
  setPeek(null);
217
234
  setDragKey(it.key);
218
235
  };
219
- const onDrop = (e: DragEvent, day: Iso) => {
236
+ // `atMin`: the minute of the day the pointer dropped at (week view hour grid only)
237
+ const onDrop = (e: DragEvent, day: Iso, atMin?: number) => {
220
238
  e.preventDefault();
221
239
  const it = data.items.find((x) => x.key === (dragKey ?? e.dataTransfer.getData('text/plain')));
222
240
  setDragKey(null); setDropDay(null);
223
241
  if (!it) return;
224
242
  const days = diffDays(it.startDay, day);
225
- if (!days) return;
226
- if (it.recurring) setAsk({ item: it, days, to: day });
227
- else void move(it, days, 'series', day);
243
+ // to the quarter hour, keeping the event within the day
244
+ const minutes = atMin === undefined || it.allDay ? 0
245
+ : Math.max(-it.startMin, Math.min(1440 - 15 - it.startMin, Math.round((atMin - grab.current) / 15) * 15 - it.startMin));
246
+ if (!days && !minutes) return;
247
+ if (it.recurring) setAsk({ item: it, days, minutes, to: day });
248
+ else void move(it, days, minutes, 'series', day);
228
249
  };
229
- const dropProps = (day: Iso) => ({
250
+ const dropProps = (day: Iso, timed = false) => ({
230
251
  onDragOver: (e: DragEvent) => { if (dragKey) { e.preventDefault(); if (dropDay !== day) setDropDay(day); } },
231
- onDrop: (e: DragEvent) => onDrop(e, day),
252
+ onDrop: (e: DragEvent) => onDrop(e, day, timed ? (e.clientY - (e.currentTarget as HTMLElement).getBoundingClientRect().top) / HOUR * 60 : undefined),
232
253
  });
233
254
 
234
255
  // ---- actions on one item ---------------------------------------------------
@@ -402,7 +423,7 @@ export default function CalendarBoard(props: Props) {
402
423
  </div>
403
424
  <div className="sch-wk-cols" style={{ height: 24 * HOUR }}>
404
425
  {wdays.map((d) => (
405
- <div key={d} className={`sch-wk-col ${d === today ? 'today' : ''} ${d === dropDay ? 'drop' : ''}`} {...dropProps(d)}
426
+ <div key={d} className={`sch-wk-col ${d === today ? 'today' : ''} ${d === dropDay ? 'drop' : ''}`} {...dropProps(d, true)}
406
427
  onClick={(e) => {
407
428
  if ((e.target as HTMLElement).closest('.sch-ev')) return;
408
429
  const y = e.clientY - (e.currentTarget as HTMLElement).getBoundingClientRect().top;
@@ -480,12 +501,14 @@ export default function CalendarBoard(props: Props) {
480
501
  {statusWord(it) && <li>{statusWord(it)}{it.edited ? ' · has unpublished changes' : ''}</li>}
481
502
  {!statusWord(it) && it.edited && <li>Has unpublished changes</li>}
482
503
  {it.cancelled && <li>{it.dateCancelled ? 'This date is cancelled' : 'Cancelled'}</li>}
504
+ {it.movedFrom && <li>Moved here from {it.movedFrom}</li>}
483
505
  </ul>
484
506
  <div className="sch-peek-actions">
485
507
  {it.kind === 'event' && <>
486
508
  <a className="btn sm primary" href={`/admin/calendar/events/${it.id}`}>Edit</a>
487
509
  <button type="button" className="btn sm" onClick={() => duplicate(it)}>Duplicate</button>
488
510
  {it.recurring && it.dateCancelled && <button type="button" className="btn sm" onClick={() => cancelDate(it, true)}>Restore this date</button>}
511
+ {it.recurring && it.movedFrom && !it.cancelled && <button type="button" className="btn sm" onClick={() => moveBack(it)}>Move back</button>}
489
512
  {it.recurring && !it.cancelled && <button type="button" className="btn sm" onClick={() => cancelDate(it, false)}>Cancel this date</button>}
490
513
  {it.url && <a className="btn sm ghost" href={it.url} target="_blank" rel="noopener">View ↗</a>}
491
514
  <button type="button" className="btn sm ghost danger" onClick={() => remove(it)}>Delete</button>
@@ -591,8 +614,8 @@ export default function CalendarBoard(props: Props) {
591
614
  <b>Move “{ask.item.title}”?</b>
592
615
  <p>This event repeats. Move only {dayLabel(ask.item.startDay, { weekday: 'long', month: 'short', day: 'numeric' })}, or every date in the series?</p>
593
616
  <div className="sch-ask-actions">
594
- <button type="button" className="btn primary" onClick={() => move(ask.item, ask.days, 'one', ask.to)}>Only this date</button>
595
- <button type="button" className="btn" onClick={() => move(ask.item, ask.days, 'series', ask.to)}>Every date</button>
617
+ <button type="button" className="btn primary" onClick={() => move(ask.item, ask.days, ask.minutes, 'one', ask.to)}>Only this date</button>
618
+ <button type="button" className="btn" onClick={() => move(ask.item, ask.days, ask.minutes, 'series', ask.to)}>Every date</button>
596
619
  <button type="button" className="btn ghost" onClick={() => setAsk(null)}>Cancel</button>
597
620
  </div>
598
621
  </div>
@@ -601,6 +624,7 @@ export default function CalendarBoard(props: Props) {
601
624
  {toast && (
602
625
  <div className={`sch-toast ${toast.error ? 'err' : ''}`} role="status">
603
626
  {toast.text}
627
+ {toast.warning && <small className="sch-toast-warn">{toast.warning}</small>}
604
628
  {toast.undo && <button type="button" onClick={() => undo(toast.undo!)}>Undo</button>}
605
629
  </div>
606
630
  )}
@@ -11,6 +11,7 @@ import { LocationField } from './LocationField';
11
11
  import { OrganizeCard, StatusSection } from './OrganizeCard';
12
12
  import { RegistrationCard } from './RegistrationCard';
13
13
  import { WhenCard } from './WhenCard';
14
+ import { SingleDates } from './SingleDates';
14
15
  import { registrationMode } from './registration';
15
16
 
16
17
  // Every event setting, on the page above the event's own content, as one
@@ -106,6 +107,7 @@ export function EventSheet({ meta, onChange, calendars, fields, siteTimezone, ma
106
107
  <span>{meta.cancelled && <em>Cancelled · </em>}{summary.join(' · ')}</span>
107
108
  </p>
108
109
  <WhenCard meta={meta} onChange={onChange} siteTimezone={siteTimezone} eventId={eventId} />
110
+ <SingleDates eventId={eventId} repeats={Boolean(meta.rrule)} />
109
111
  <Section
110
112
  id="ev-where"
111
113
  title="Where"
@@ -0,0 +1,123 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { actions } from 'astro:actions';
3
+ import { Section } from './Section';
4
+
5
+ // Event settings → Single dates: the next dates of a saved repeating event,
6
+ // each with Move… and Cancel (or Move back / Restore). These apply at once,
7
+ // like on the Schedule, without touching unpublished edits: a moved date keeps
8
+ // its sign-ups (they're emailed the new time) and its hosts
9
+ // (lib/calendar/moves.ts). The list shows the event as saved.
10
+
11
+ interface DateRow { key: string; start: string; end: string; when: string; cancelled: boolean; moved: { from: string; note: string } | null; roomIds: string[] }
12
+ interface Data { allDay: boolean; roomIds: string[]; dates: DateRow[]; rooms: { id: string; name: string }[] }
13
+ interface Form { key: string; day: string; endDay: string; from: string; to: string; roomIds: string[]; note: string }
14
+
15
+ const datePart = (w: string) => w.slice(0, 10);
16
+ const timePart = (w: string) => w.slice(11, 16);
17
+
18
+ export function SingleDates({ eventId, repeats }: { eventId: string; repeats: boolean }) {
19
+ const [data, setData] = useState<Data | null>(null);
20
+ const [limit, setLimit] = useState(8);
21
+ const [form, setForm] = useState<Form | null>(null);
22
+ const [busy, setBusy] = useState('');
23
+ const [note, setNote] = useState<{ text: string; error?: boolean } | null>(null);
24
+
25
+ const load = async (n = limit) => {
26
+ const r = await actions.events.dates({ id: eventId, limit: n });
27
+ if (r.data) setData(r.data);
28
+ };
29
+ useEffect(() => { if (eventId && repeats) void load(); }, [eventId, repeats, limit]);
30
+
31
+ if (!eventId || !repeats || !data || !data.dates.length) return null;
32
+
33
+ const open = (d: DateRow) => {
34
+ setNote(null);
35
+ setForm({ key: d.key, day: datePart(d.start), endDay: datePart(d.end), from: timePart(d.start), to: timePart(d.end), roomIds: d.roomIds, note: d.moved?.note ?? '' });
36
+ };
37
+ const run = async (key: string, work: () => Promise<{ error?: { message: string }; data?: object }>, done: string) => {
38
+ setBusy(key);
39
+ const r = await work();
40
+ setBusy('');
41
+ if (r.error) { setNote({ text: r.error.message, error: true }); return; }
42
+ setForm(null);
43
+ const warning = r.data && 'warning' in r.data && typeof r.data.warning === 'string' ? r.data.warning : '';
44
+ setNote({ text: warning ? `${done} ${warning}` : done });
45
+ await load();
46
+ };
47
+ const save = (f: Form) => {
48
+ const start = data.allDay ? f.day : `${f.day}T${f.from}`;
49
+ // a time that ends before it starts runs past midnight
50
+ const end = data.allDay ? (f.endDay >= f.day ? f.endDay : f.day) : `${f.to <= f.from ? nextDay(f.day) : f.day}T${f.to}`;
51
+ return run(f.key, () => actions.events.moveDate({ id: eventId, key: f.key, start, end, roomIds: f.roomIds, note: f.note }), 'Moved. People who signed up were emailed the new time.');
52
+ };
53
+
54
+ return (
55
+ <Section id="ev-dates" title="Single dates" help="Move or cancel one date. It applies at once, even on a published event, and people who signed up for it are emailed.">
56
+ <ul className="pe-evd">
57
+ {data.dates.map((d) => (
58
+ <li key={d.key} className={`pe-evd-row${d.cancelled ? ' is-cancelled' : d.moved ? ' is-moved' : ''}`}>
59
+ <div className="pe-evd-main">
60
+ <span className="pe-evd-when">{d.cancelled ? <s>{d.when}</s> : d.when}</span>
61
+ {d.cancelled && <small className="pe-evd-state">Cancelled</small>}
62
+ {d.moved && <small className="pe-evd-state">Moved from {d.moved.from}{d.moved.note ? ` · “${d.moved.note}”` : ''}</small>}
63
+ </div>
64
+ <div className="pe-evd-acts">
65
+ {d.cancelled ? (
66
+ <button type="button" className="pe-btn sm" disabled={busy === d.key} onClick={() => run(d.key, () => actions.events.cancelDate({ id: eventId, occurrence: d.key, restore: true }), 'Restored.')}>Restore</button>
67
+ ) : (
68
+ <>
69
+ <button type="button" className="pe-btn sm" onClick={() => (form?.key === d.key ? setForm(null) : open(d))} aria-expanded={form?.key === d.key}>Move…</button>
70
+ {d.moved && <button type="button" className="pe-btn sm" disabled={busy === d.key} onClick={() => run(d.key, () => actions.events.moveDateBack({ id: eventId, key: d.key }), 'Moved back. People who signed up were emailed.')}>Move back</button>}
71
+ <button type="button" className="pe-btn ghost sm" disabled={busy === d.key}
72
+ onClick={() => { if (confirm(`Cancel ${d.when}? It shows as cancelled straight away, and people who signed up are emailed.`)) void run(d.key, () => actions.events.cancelDate({ id: eventId, occurrence: d.key, restore: false }), 'Cancelled.'); }}>Cancel date</button>
73
+ </>
74
+ )}
75
+ </div>
76
+ {form?.key === d.key && (
77
+ <form className="pe-evd-form" onSubmit={(e) => { e.preventDefault(); void save(form); }}>
78
+ <div className="pe-evd-fields">
79
+ <label className="pe-field"><span>{data.allDay ? 'From' : 'Date'}</span><input type="date" required value={form.day} onChange={(e) => setForm({ ...form, day: e.target.value })} /></label>
80
+ {data.allDay ? (
81
+ <label className="pe-field"><span>To</span><input type="date" required value={form.endDay} min={form.day} onChange={(e) => setForm({ ...form, endDay: e.target.value })} /></label>
82
+ ) : (
83
+ <>
84
+ <label className="pe-field"><span>Starts</span><input type="time" required step={300} value={form.from} onChange={(e) => setForm({ ...form, from: e.target.value })} /></label>
85
+ <label className="pe-field"><span>Ends</span><input type="time" required step={300} value={form.to} onChange={(e) => setForm({ ...form, to: e.target.value })} /></label>
86
+ </>
87
+ )}
88
+ </div>
89
+ {data.rooms.length > 0 && (
90
+ <fieldset className="pe-evd-rooms">
91
+ <legend>Rooms for this date</legend>
92
+ {data.rooms.map((r) => (
93
+ <label key={r.id} className="pe-evd-room">
94
+ <input type="checkbox" checked={form.roomIds.includes(r.id)}
95
+ onChange={(e) => setForm({ ...form, roomIds: e.target.checked ? [...form.roomIds, r.id] : form.roomIds.filter((x) => x !== r.id) })} />
96
+ {r.name}
97
+ </label>
98
+ ))}
99
+ </fieldset>
100
+ )}
101
+ <label className="pe-field"><span>Note <em>shown with the date, optional</em></span>
102
+ <input type="text" maxLength={500} value={form.note} placeholder="Moved for the youth retreat" onChange={(e) => setForm({ ...form, note: e.target.value })} />
103
+ </label>
104
+ <div className="pe-evd-formacts">
105
+ <button type="submit" className="pe-btn primary sm" disabled={busy === form.key}>{busy === form.key ? 'Moving…' : 'Move this date'}</button>
106
+ <button type="button" className="pe-btn ghost sm" onClick={() => setForm(null)}>Never mind</button>
107
+ </div>
108
+ </form>
109
+ )}
110
+ </li>
111
+ ))}
112
+ </ul>
113
+ {note && <p className={`pe-ev-note${note.error ? ' is-error' : ''}`} role="status">{note.text}</p>}
114
+ {data.dates.length >= limit && limit < 52 && <button type="button" className="pe-evw-link" onClick={() => setLimit(Math.min(52, limit + 12))}>Show more dates</button>}
115
+ </Section>
116
+ );
117
+ }
118
+
119
+ function nextDay(day: string): string {
120
+ const d = new Date(`${day}T00:00:00Z`);
121
+ d.setUTCDate(d.getUTCDate() + 1);
122
+ return d.toISOString().slice(0, 10);
123
+ }
@@ -8,7 +8,7 @@
8
8
  // staff, and roomBusy() answers "is this room taken then?" for one span.
9
9
  import type { ContentRepository } from '../repo/types';
10
10
  import type { Booking, CalEvent, Resource, Settings } from '../schema';
11
- import { expandEvents } from '../calendar/events';
11
+ import { eventRoomIds, expandEvents } from '../calendar/events';
12
12
  import { tzParts, zonedToUtc } from '../calendar/ics';
13
13
  import { resourceZone } from './availability';
14
14
 
@@ -27,8 +27,8 @@ export interface Hold {
27
27
  const DAY = 86_400_000;
28
28
 
29
29
  /** Does this event hold its rooms right now? */
30
- export function holdsRooms(e: Pick<CalEvent, 'status' | 'cancelled' | 'roomHold'>): boolean {
31
- if (e.cancelled || !e.roomHold.resourceIds.length) return false;
30
+ export function holdsRooms(e: Pick<CalEvent, 'status' | 'cancelled' | 'roomHold' | 'movedDates'>): boolean {
31
+ if (e.cancelled || !eventRoomIds(e).length) return false;
32
32
  return e.status === 'published' || e.status === 'scheduled' || (e.status === 'draft' && e.roomHold.whileDraft);
33
33
  }
34
34
 
@@ -52,8 +52,8 @@ export function holdsFromEvents(
52
52
  opts: { force?: boolean; resourceId?: string } = {}
53
53
  ): Hold[] {
54
54
  const out: Hold[] = [];
55
- const holding = events.filter((e) => (opts.force ? !e.cancelled && e.roomHold.resourceIds.length > 0 : holdsRooms(e))
56
- && (!opts.resourceId || e.roomHold.resourceIds.includes(opts.resourceId)));
55
+ const holding = events.filter((e) => (opts.force ? !e.cancelled && eventRoomIds(e).length > 0 : holdsRooms(e))
56
+ && (!opts.resourceId || eventRoomIds(e).includes(opts.resourceId)));
57
57
  if (!holding.length) return out;
58
58
  // widen the window so setup/cleanup and whole-day holds at the edges count
59
59
  const pad = 2 * DAY;
@@ -155,7 +155,7 @@ export async function eventClashes(
155
155
  const holds = holdsFromEvents([event], byId, settings, now, to, { force: true });
156
156
  if (!holds.length) return { clashes: [], holds };
157
157
  const rooms = new Set(holds.map((h) => h.resourceId));
158
- const others = events.filter((e) => e.id !== event.id && e.roomHold.resourceIds.some((id) => rooms.has(id)));
158
+ const others = events.filter((e) => e.id !== event.id && eventRoomIds(e).some((id) => rooms.has(id)));
159
159
  const from = Math.min(...holds.map((h) => h.start)), until = Math.max(...holds.map((h) => h.end));
160
160
  const theirs = holdsFromEvents(others, byId, settings, from, until);
161
161
  const clashes: EventClash[] = [];
@@ -199,7 +199,7 @@ export async function roomBusy(
199
199
  const rooms = resources.filter((r) => ids.has(r.id));
200
200
  const byId = new Map(rooms.map((r) => [r.id, r]));
201
201
  const out: BusySlot[] = [];
202
- const holding = events.filter((e) => e.roomHold.resourceIds.some((id) => ids.has(id)) && !(q.ignore && e.id === q.ignore.eventId && !q.ignore.key));
202
+ const holding = events.filter((e) => eventRoomIds(e).some((id) => ids.has(id)) && !(q.ignore && e.id === q.ignore.eventId && !q.ignore.key));
203
203
  for (const h of holdsFromEvents(holding, byId, settings, q.from, q.to)) {
204
204
  if (q.ignore?.key && h.eventId === q.ignore.eventId && h.key === q.ignore.key) continue;
205
205
  out.push({ resourceId: h.resourceId, start: h.start, end: h.end, kind: 'event', title: h.title, eventId: h.eventId, key: h.key });
@@ -3,7 +3,7 @@
3
3
  // everywhere. Drafts, internal ids of private calendars, staging data and
4
4
  // private custom fields (unless a staff caller asks for them) never appear
5
5
  // here. Rooms are named only when set to show on public events (or for staff).
6
- import { roomColor, type CalEvent, type Calendar, type Resource } from '../schema';
6
+ import { roomColor, type CalEvent, type Calendar, type MovedDate, type Resource } from '../schema';
7
7
  import { calendarFeedUrl, eventIcsUrl, eventUrl, publicEventFields } from './config';
8
8
  import { describeRepeat } from './rrule';
9
9
 
@@ -25,6 +25,9 @@ export interface EventJson {
25
25
  repeats: string;
26
26
  exdates: string[];
27
27
  cancelledDates: string[];
28
+ // single dates at another time, by original occurrence key: the new wall
29
+ // times and the public note (staff callers also get rooms, place and who moved it)
30
+ movedDates: Record<string, PublicMovedDate>;
28
31
  location: CalEvent['location'];
29
32
  cta: CalEvent['cta'];
30
33
  // notifyStaff (whether the hosts get RSVP emails) only for staff callers
@@ -40,6 +43,12 @@ export interface EventJson {
40
43
  }
41
44
 
42
45
  export interface RoomJson { slug: string; name: string; color: string | null }
46
+ export type PublicMovedDate = Pick<MovedDate, 'start' | 'end' | 'note'> & Partial<MovedDate>;
47
+
48
+ /** moved dates as public data: where and why, not who */
49
+ export function movedDatesToJson(e: Pick<CalEvent, 'movedDates'>, includePrivate = false): Record<string, PublicMovedDate> {
50
+ return Object.fromEntries(Object.entries(e.movedDates ?? {}).map(([k, m]) => [k, includePrivate ? m : { start: m.start, end: m.end, note: m.note }]));
51
+ }
43
52
 
44
53
  /** Rooms as public data: only those set to show on public events, unless `includePrivate` (staff). Unknown ids are skipped. */
45
54
  export function roomsToJson(ids: string[], resources: Map<string, Resource> | undefined, includePrivate = false): RoomJson[] {
@@ -70,6 +79,7 @@ export function eventToJson(e: CalEvent, origin: string, calendars: Map<string,
70
79
  repeats: describeRepeat(e.rrule, e.start),
71
80
  exdates: e.exdates,
72
81
  cancelledDates: e.cancelledDates ?? [],
82
+ movedDates: movedDatesToJson(e, opts.includePrivate),
73
83
  location: e.location,
74
84
  cta: e.cta,
75
85
  rsvp: opts.includePrivate ? e.rsvp : (({ notifyStaff: _, ...rsvp }) => rsvp)(e.rsvp),
@@ -34,6 +34,8 @@ export interface BoardItem extends GridItem {
34
34
  cancelled: boolean;
35
35
  // just this date is cancelled (Restore brings it back)
36
36
  dateCancelled: boolean;
37
+ // this date was moved here: "Thu, Jan 10 · 6:00 PM" (where it was), '' when not moved
38
+ movedFrom: string;
37
39
  location: string;
38
40
  edited: boolean; // live event with unpublished changes
39
41
  url: string; // public URL ('' when none)
@@ -99,6 +101,7 @@ export async function loadBoard(repo: ContentRepository, settings: Settings, fro
99
101
  const { events, calendars, errors } = await collectViewEvents(repo, settings, win, { audience: 'admin', includeFeeds: true, includeBookings: false });
100
102
  const locale = siteLocale(settings);
101
103
  const timeFmt = new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit', timeZone: tz });
104
+ const dayFmt = new Intl.DateTimeFormat(locale, { weekday: 'short', month: 'short', day: 'numeric', timeZone: tz });
102
105
  const rangeOf = (s: number, e: number, allDay: boolean) => {
103
106
  if (allDay) return 'All day';
104
107
  try { return timeFmt.formatRange(s, e); } catch { return `${timeFmt.format(s)} – ${timeFmt.format(e)}`; }
@@ -115,7 +118,7 @@ export async function loadBoard(repo: ContentRepository, settings: Settings, fro
115
118
  const startDay = s.day;
116
119
  const endDay = allDay ? addDays(startDay, Math.max(1, v.days) - 1) : en.day < s.day ? s.day : en.day;
117
120
  const cal = e ? e.calendarIds.map((id) => calById.get(id)).find(Boolean) : calendars.find((c) => c.name === v.calendarName && c.kind === 'feed');
118
- const occurrence = e ? occurrenceKey(e, v.start, settings, tz) : '';
121
+ const occurrence = e ? v.key ?? occurrenceKey(e, v.start, settings, tz) : '';
119
122
  items.push({
120
123
  key: e ? `e:${e.id}:${v.start}` : `f:${cal?.id ?? ''}:${v.start}:${v.title}`,
121
124
  kind: e ? 'event' : 'feed',
@@ -132,11 +135,12 @@ export async function loadBoard(repo: ContentRepository, settings: Settings, fro
132
135
  status: e ? eventBoardStatus(e) : 'feed',
133
136
  recurring: Boolean(e?.rrule),
134
137
  repeat: e?.rrule ? describeRepeat(e.rrule, e.start) : '',
135
- rooms: e ? e.roomHold.resourceIds.map((id) => roomName.get(id)).filter((n): n is string => Boolean(n)) : [],
138
+ rooms: e ? (v.rooms?.map((r) => r.id) ?? e.roomHold.resourceIds).map((id) => roomName.get(id)).filter((n): n is string => Boolean(n)) : [],
136
139
  roomsHeld: e ? holdsRooms(e) : false,
137
140
  occurrence,
138
141
  cancelled: Boolean(v.cancelled),
139
142
  dateCancelled: Boolean(e && !e.cancelled && (e.cancelledDates ?? []).includes(occurrence)),
143
+ movedFrom: v.moved ? `${dayFmt.format(v.moved.from.start)} · ${allDay ? 'All day' : timeFmt.format(v.moved.from.start)}` : '',
140
144
  location: v.location,
141
145
  edited: Boolean(e?.draft),
142
146
  url: e?.status === 'published' ? v.url ?? '' : '',
@@ -167,7 +171,7 @@ export async function loadBoard(repo: ContentRepository, settings: Settings, fro
167
171
  calendarId: `res:${b.resourceId}`,
168
172
  calendarName: r?.name ?? '',
169
173
  status: b.status as BoardStatus,
170
- recurring: false, repeat: '', rooms: [], roomsHeld: false, occurrence: '', cancelled: false, dateCancelled: false,
174
+ recurring: false, repeat: '', rooms: [], roomsHeld: false, occurrence: '', cancelled: false, dateCancelled: false, movedFrom: '',
171
175
  location: r?.name ?? '', edited: false, url: '',
172
176
  });
173
177
  }
@@ -28,7 +28,7 @@ const esc = (s: string) =>
28
28
  export const safeHref = (h: string) => (/^(https?:\/\/|\/(?!\/)|mailto:|tel:|#)/i.test(h.trim()) ? h.trim() : '');
29
29
 
30
30
  export function eventCardHtml(
31
- o: Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days'> & { cancelled?: boolean },
31
+ o: Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days'> & { cancelled?: boolean; moved?: Occurrence['moved'] },
32
32
  settings: Settings,
33
33
  opts: { showImage: boolean; showCta?: boolean; href?: string; heading?: 'h2' | 'h3' }
34
34
  ): string {
@@ -48,7 +48,7 @@ export function eventCardHtml(
48
48
  ? `<a class="pc-ev-card-media" href="${esc(href)}" tabindex="-1" aria-hidden="true"><img src="${esc(image.src)}" alt="${esc(image.alt)}" loading="lazy" /></a>`
49
49
  : `<a class="pc-ev-card-media is-tile" href="${esc(href)}" tabindex="-1" aria-hidden="true"><span class="pc-ev-tile"><b>${esc(f.month)}</b><span>${esc(f.day)}</span></span></a>`
50
50
  : '';
51
- const loc = locationLine(e.location);
51
+ const loc = locationLine(o.moved?.location ?? e.location);
52
52
  const ctaHref = safeHref(e.cta.href);
53
53
  const cta =
54
54
  opts.showCta && e.cta.label && ctaHref && !cancelled
@@ -56,7 +56,7 @@ export function eventCardHtml(
56
56
  : '';
57
57
  return (
58
58
  `<article class="pc-ev-card${cancelled ? ' is-cancelled' : ''}">${media}<div class="pc-ev-card-body">` +
59
- `<div class="pc-ev-card-when">${cancelled ? `<span class="pc-ev-flag">${esc(eventStrings(siteLang(settings)).cancelled)}</span> ` : ''}${esc(when)}</div>` +
59
+ `<div class="pc-ev-card-when">${cancelled ? `<span class="pc-ev-flag">${esc(eventStrings(siteLang(settings)).cancelled)}</span> ` : o.moved ? `<span class="pc-ev-flag is-moved">${esc(eventStrings(siteLang(settings)).moved)}</span> ` : ''}${esc(when)}</div>` +
60
60
  `<${h} class="pc-ev-card-title"><a href="${esc(href)}">${esc(e.title || eventStrings(siteLang(settings)).calendar.untitled)}</a></${h}>` +
61
61
  (e.excerpt ? `<p class="pc-ev-card-excerpt">${esc(e.excerpt)}</p>` : '') +
62
62
  (loc ? `<div class="pc-ev-card-loc">${esc(loc)}</div>` : '') +
@@ -80,11 +80,11 @@ export async function upcomingEvents(
80
80
  displayTz: tz, now, windowStartMs: now, windowEndMs: now + (opts.horizonDays ?? 400) * 86_400_000,
81
81
  }, { audience: 'public', calendarIds: opts.calendarIds, includeFeeds: false, filter: opts.filter, locale: opts.locale });
82
82
  const seen = new Set<string>();
83
- const out: Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days' | 'cancelled'>[] = [];
83
+ const out: (Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days' | 'cancelled'> & Pick<Partial<Occurrence>, 'moved'>)[] = [];
84
84
  for (const v of events) {
85
85
  if (!v.event || v.end <= now || seen.has(v.event.id) || !tagged(v.event.tags)) continue;
86
86
  seen.add(v.event.id);
87
- out.push({ event: v.event, start: v.start, end: v.end, allDay: v.allDay, days: v.days, cancelled: Boolean(v.cancelled) });
87
+ out.push({ event: v.event, start: v.start, end: v.end, allDay: v.allDay, days: v.days, cancelled: Boolean(v.cancelled), ...(v.moved ? { moved: v.moved } : {}) });
88
88
  if (out.length >= opts.limit) break;
89
89
  }
90
90
  return out;
@@ -13,27 +13,45 @@
13
13
  // but never member pushes (its first publishes are recorded as skipped, like
14
14
  // restores). Posts keep their v37 triggers.
15
15
  //
16
- // Frozen with v65: a later events column joins by a new migration that
17
- // recreates these triggers (the test that sorts every column fails until then).
16
+ // Each migration's column lists are frozen: a later events column joins by a
17
+ // new migration that recreates these triggers through eventChangeTriggers()
18
+ // (the test that sorts every column fails until then). v66 added moved_dates.
18
19
  const now = "CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER)";
19
20
 
20
- /** the columns copied into each snapshot (what the webhook payload and push rules read) */
21
- export const EVENT_CHANGE_SNAPSHOT = ['id', 'slug', 'title', 'excerpt', 'cover', 'cover_alt', 'status', 'featured', 'tags',
21
+ // v65's lists
22
+ const V65_SNAPSHOT = ['id', 'slug', 'title', 'excerpt', 'cover', 'cover_alt', 'status', 'featured', 'tags',
22
23
  'starts', 'ends', 'all_day', 'timezone', 'rrule', 'exdates', 'cancelled', 'cancelled_dates', 'location', 'cta', 'calendar_ids',
23
24
  'fields', 'rsvp', 'room_hold', 'start_at', 'end_at', 'published_at', 'scheduled_at', 'created_at', 'updated_at'];
24
- /** the columns whose change counts (published_at and scheduled_at follow status; room_hold names the rooms public data shows) */
25
- export const EVENT_CHANGE_COLUMNS = ['slug', 'title', 'body_json', 'body_html', 'excerpt', 'cover', 'cover_alt', 'seo_title', 'seo_desc',
25
+ const V65_COLUMNS = ['slug', 'title', 'body_json', 'body_html', 'excerpt', 'cover', 'cover_alt', 'seo_title', 'seo_desc',
26
26
  'status', 'featured', 'tags', 'template', 'starts', 'ends', 'all_day', 'timezone', 'rrule', 'exdates', 'cancelled', 'cancelled_dates',
27
27
  'location', 'cta', 'calendar_ids', 'fields', 'rsvp', 'translations', 'room_hold', 'start_at', 'end_at'];
28
+
29
+ /** the columns copied into each snapshot (what the webhook payload and push rules read) */
30
+ export const EVENT_CHANGE_SNAPSHOT = [...V65_SNAPSHOT, 'moved_dates'];
31
+ /** the columns whose change counts (published_at and scheduled_at follow status; room_hold names the rooms public data shows) */
32
+ export const EVENT_CHANGE_COLUMNS = [...V65_COLUMNS, 'moved_dates'];
28
33
  /** columns no change is recorded for */
29
34
  export const EVENT_CHANGE_IGNORED = ['id', 'draft', 'host_ids', 'request', 'source_uid', 'published_at', 'scheduled_at', 'created_at', 'updated_at'];
30
35
 
31
- const snapshot = (row: 'OLD' | 'NEW') => `json_object(${EVENT_CHANGE_SNAPSHOT.map((c) => `'${c}',${row}.${c}`).join(',')})`;
36
+ const snapshotOf = (cols: string[], row: 'OLD' | 'NEW') => `json_object(${cols.map((c) => `'${c}',${row}.${c}`).join(',')})`;
32
37
  const live = '(SELECT suppressed FROM webhook_control WHERE id=1)=0';
33
38
  const quiet = 'COALESCE((SELECT quiet FROM push_control WHERE id=1),0)';
34
39
  const record = (id: string, before: string, after: string) => `INSERT INTO event_changes (event_id,old_row,new_row,quiet,created_at)
35
40
  VALUES (${id},${before},${after},${quiet},${now});`;
36
- const changed = EVENT_CHANGE_COLUMNS.map((c) => `OLD.${c} IS NOT NEW.${c}`).join(' OR ');
41
+
42
+ /** The three event_changes triggers, for one migration's frozen lists (drop the old ones first when recreating). */
43
+ export function eventChangeTriggers(snapshotCols: string[], changedCols: string[]): string[] {
44
+ const changed = changedCols.map((c) => `OLD.${c} IS NOT NEW.${c}`).join(' OR ');
45
+ return [
46
+ `CREATE TRIGGER event_changes_insert AFTER INSERT ON events WHEN NEW.status='published' AND ${live}
47
+ BEGIN ${record('NEW.id', 'NULL', snapshotOf(snapshotCols, 'NEW'))} END`,
48
+ `CREATE TRIGGER event_changes_update AFTER UPDATE ON events
49
+ WHEN (OLD.status='published' OR NEW.status='published') AND (${changed}) AND ${live}
50
+ BEGIN ${record('NEW.id', snapshotOf(snapshotCols, 'OLD'), snapshotOf(snapshotCols, 'NEW'))} END`,
51
+ `CREATE TRIGGER event_changes_delete AFTER DELETE ON events WHEN OLD.status='published' AND ${live}
52
+ BEGIN ${record('OLD.id', snapshotOf(snapshotCols, 'OLD'), 'NULL')} END`,
53
+ ];
54
+ }
37
55
 
38
56
  // v37's first-publish ledger rows, now also skipped while push_control.quiet is set
39
57
  const skipped = `CASE WHEN (SELECT suppressed FROM webhook_control WHERE id=1)=1 OR ${quiet}=1 THEN 'skipped' ELSE 'pending' END`;
@@ -50,16 +68,23 @@ export const EVENT_CHANGES_MIGRATION = { version: 65, statements: [
50
68
  `CREATE INDEX event_changes_event ON event_changes (event_id, claimed_until)`,
51
69
  `CREATE TABLE push_control (id INTEGER PRIMARY KEY CHECK(id=1), quiet INTEGER NOT NULL DEFAULT 0)`,
52
70
  `INSERT INTO push_control (id,quiet) VALUES (1,0)`,
53
- `CREATE TRIGGER event_changes_insert AFTER INSERT ON events WHEN NEW.status='published' AND ${live}
54
- BEGIN ${record('NEW.id', 'NULL', snapshot('NEW'))} END`,
55
- `CREATE TRIGGER event_changes_update AFTER UPDATE ON events
56
- WHEN (OLD.status='published' OR NEW.status='published') AND (${changed}) AND ${live}
57
- BEGIN ${record('NEW.id', snapshot('OLD'), snapshot('NEW'))} END`,
58
- `CREATE TRIGGER event_changes_delete AFTER DELETE ON events WHEN OLD.status='published' AND ${live}
59
- BEGIN ${record('OLD.id', snapshot('OLD'), 'NULL')} END`,
71
+ ...eventChangeTriggers(V65_SNAPSHOT, V65_COLUMNS),
60
72
  `DROP TRIGGER push_event_insert`,
61
73
  `DROP TRIGGER push_event_publish`,
62
74
  `CREATE TRIGGER push_event_insert AFTER INSERT ON events WHEN NEW.status='published' BEGIN ${ledger} END`,
63
75
  `CREATE TRIGGER push_event_publish AFTER UPDATE OF status ON events
64
76
  WHEN NEW.status='published' AND OLD.status IS NOT 'published' BEGIN ${ledger} END`,
65
77
  ] };
78
+
79
+ // v66: single dates of a series moved to another time, room or place
80
+ // (CalEvent.movedDates, keyed by the original occurrence key), a column on the
81
+ // row: public, so it bumps the content version and its changes are recorded
82
+ export const MOVED_DATES_MIGRATION = (contentVersionUpdate: string) => ({ version: 66, statements: [
83
+ `ALTER TABLE events ADD COLUMN moved_dates TEXT NOT NULL DEFAULT '{}'`,
84
+ `DROP TRIGGER content_version_events_update`,
85
+ contentVersionUpdate,
86
+ `DROP TRIGGER event_changes_insert`,
87
+ `DROP TRIGGER event_changes_update`,
88
+ `DROP TRIGGER event_changes_delete`,
89
+ ...eventChangeTriggers([...V65_SNAPSHOT, 'moved_dates'], [...V65_COLUMNS, 'moved_dates']),
90
+ ] });
@@ -4,7 +4,7 @@
4
4
  // live through the cached ICS loader. Visibility rules live in events.ts.
5
5
  import type { ContentRepository } from '../repo/types';
6
6
  import { roomColor, type CalEvent, type Calendar, type Resource, type Settings } from '../schema';
7
- import { eventIsListed, eventIsPublic, expandEvents, safeColor } from './events';
7
+ import { eventIsListed, eventIsPublic, eventRoomIds, expandEvents, locationLine, safeColor, type OccurrenceMove } from './events';
8
8
  import { expandVEvents, loadFeed, tzParts, type CalendarEvent, type CalendarWindow } from './ics';
9
9
  import { CALENDAR, eventUrl, resourceUrl, withoutPrivateFields } from './config';
10
10
  import { occurrenceMatches, tagsMatch, untaggedMatches, type ResolvedEventFilter } from './filter';
@@ -23,6 +23,10 @@ export interface ViewEvent extends CalendarEvent {
23
23
  // native events: the rooms this date holds; public views name only rooms
24
24
  // set to show on public events (Resource.showOnEvents)
25
25
  rooms?: ViewRoom[];
26
+ // native events: the date's occurrence key (a moved date keeps its original one)
27
+ key?: string;
28
+ // this date was moved here: where it was (UTC ms) and the public note
29
+ moved?: OccurrenceMove;
26
30
  }
27
31
 
28
32
  // color: '#rrggbb', or '' when the room has none
@@ -89,7 +93,7 @@ export async function collectViewEvents(
89
93
  if (!shown.has(e.id)) shown.set(e.id, withoutPrivateFields(localizeDoc(e, scope.locale)));
90
94
  return shown.get(e.id)!;
91
95
  };
92
- const resources = natives.some((e) => e.roomHold.resourceIds.length) ? new Map((await repo.listResources()).map((r) => [r.id, r])) : new Map<string, Resource>();
96
+ const resources = natives.some((e) => eventRoomIds(e).length) ? new Map((await repo.listResources()).map((r) => [r.id, r])) : new Map<string, Resource>();
93
97
  const rooms = (ids: string[]) => ids.map((id) => resources.get(id))
94
98
  .filter((r): r is Resource => Boolean(r) && (scope.audience === 'admin' || r!.showOnEvents))
95
99
  .map((r) => ({ id: r.id, slug: r.slug, name: r.name, color: roomColor(r) }));
@@ -104,7 +108,7 @@ export async function collectViewEvents(
104
108
  events.push({
105
109
  start: o.start, end: o.end, allDay: o.allDay, days: o.days,
106
110
  title: e.title || words.untitled,
107
- location: [e.location.name, e.location.address].filter(Boolean).join(', '),
111
+ location: locationLine(o.moved?.location ?? e.location),
108
112
  description: e.excerpt,
109
113
  cal: cal ? idx.get(cal.id) ?? 0 : 0,
110
114
  ref: e.id,
@@ -116,6 +120,8 @@ export async function collectViewEvents(
116
120
  calendarName: cal?.name,
117
121
  event: e,
118
122
  rooms: rooms(o.roomIds),
123
+ key: o.key,
124
+ ...(o.moved ? { moved: o.moved } : {}),
119
125
  });
120
126
  }
121
127