@acms-subir/core 0.3.47 → 0.3.49

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 (48) hide show
  1. package/CHANGELOG.md +56 -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/integration.mjs +37 -0
  7. package/package.json +1 -1
  8. package/src/actions/index.ts +76 -11
  9. package/src/components/EventPage.astro +18 -12
  10. package/src/components/calendar/CalendarBoard.tsx +43 -19
  11. package/src/components/editor/event/EventSheet.tsx +2 -0
  12. package/src/components/editor/event/SingleDates.tsx +123 -0
  13. package/src/lib/booking/holds.ts +7 -7
  14. package/src/lib/calendar/api.ts +11 -1
  15. package/src/lib/calendar/board.ts +7 -3
  16. package/src/lib/calendar/cards.ts +5 -5
  17. package/src/lib/calendar/change-migration.ts +40 -15
  18. package/src/lib/calendar/data.ts +9 -3
  19. package/src/lib/calendar/event-words.ts +9 -0
  20. package/src/lib/calendar/events.ts +94 -9
  21. package/src/lib/calendar/ics-write.ts +25 -2
  22. package/src/lib/calendar/moves.ts +291 -0
  23. package/src/lib/calendar/render.ts +10 -6
  24. package/src/lib/calendar/schedule.ts +73 -61
  25. package/src/lib/email/booking-words.ts +7 -1
  26. package/src/lib/email/service.ts +6 -1
  27. package/src/lib/email/templates.ts +6 -2
  28. package/src/lib/mobile/calendar.ts +5 -2
  29. package/src/lib/mobile/schemas.ts +7 -3
  30. package/src/lib/origin-check.ts +54 -0
  31. package/src/lib/push/service.ts +44 -10
  32. package/src/lib/push/types.ts +2 -0
  33. package/src/lib/repo/migrations.ts +5 -2
  34. package/src/lib/repo/sql.ts +28 -10
  35. package/src/lib/repo/types.ts +6 -3
  36. package/src/lib/schema.ts +21 -0
  37. package/src/lib/sections.ts +5 -2
  38. package/src/lib/sync/catalog.ts +1 -0
  39. package/src/middleware.ts +5 -0
  40. package/src/pages/api/calendar/events/index.ts +6 -0
  41. package/src/pages/members/_Layout.astro +1 -0
  42. package/src/pages/members/events/index.astro +1 -1
  43. package/src/server/index.ts +33 -0
  44. package/src/styles/editor.css +19 -0
  45. package/src/styles/schedule.css +2 -0
  46. package/src/styles/site.css +6 -0
  47. package/src/theme-sdk/calendar.ts +3 -2
  48. package/src/virtual.d.ts +2 -0
@@ -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
 
@@ -18,6 +18,10 @@ export interface EventStrings {
18
18
  /** one date of a repeating event */
19
19
  dateCancelled: string;
20
20
  dateCancelledNote: string;
21
+ /** one date moved to another time: the flag, then "Moved from {from}." and the note (staff's own note replaces it) */
22
+ moved: string;
23
+ dateMoved: string;
24
+ dateMovedNote: string;
21
25
  /** the side column's landmark label */
22
26
  details: string;
23
27
  joinOnline: string;
@@ -130,6 +134,7 @@ const STRINGS: Record<string, EventStrings> = {
130
134
  en: {
131
135
  cancelled: 'Cancelled', eventCancelled: 'This event is cancelled.', eventCancelledNote: 'It stays listed so nobody is left wondering.',
132
136
  dateCancelled: 'Cancelled on {date}.', dateCancelledNote: 'Other dates go ahead as usual.',
137
+ moved: 'Moved', dateMoved: 'Moved from {from}.', dateMovedNote: 'Other dates stay as usual.',
133
138
  details: 'Event details', joinOnline: 'Join online', map: 'Map', directionsTo: 'Directions to {place}', mapOf: 'Map of {place}',
134
139
  directionsHere: 'Directions to the location', mapHere: 'Map of the location', showing: 'Showing {date}',
135
140
  addToCalendar: 'Add to calendar', downloadIcs: 'Download .ics', share: 'Share', linkCopied: 'Link copied',
@@ -162,6 +167,7 @@ const STRINGS: Record<string, EventStrings> = {
162
167
  es: {
163
168
  cancelled: 'Cancelado', eventCancelled: 'Este evento está cancelado.', eventCancelledNote: 'Lo dejamos en la lista para que nadie se quede con la duda.',
164
169
  dateCancelled: 'Cancelado el {date}.', dateCancelledNote: 'Las demás fechas siguen como siempre.',
170
+ moved: 'Cambio de horario', dateMoved: 'Cambió de horario: antes era el {from}.', dateMovedNote: 'Las demás fechas siguen como siempre.',
165
171
  details: 'Detalles del evento', joinOnline: 'Unirse en línea', map: 'Mapa', directionsTo: 'Cómo llegar a {place}', mapOf: 'Mapa de {place}',
166
172
  directionsHere: 'Cómo llegar al lugar', mapHere: 'Mapa del lugar', showing: 'Mostrando el {date}',
167
173
  addToCalendar: 'Agregar al calendario', downloadIcs: 'Descargar .ics', share: 'Compartir', linkCopied: 'Enlace copiado',
@@ -194,6 +200,7 @@ const STRINGS: Record<string, EventStrings> = {
194
200
  fr: {
195
201
  cancelled: 'Annulé', eventCancelled: 'Cet événement est annulé.', eventCancelledNote: 'Il reste affiché pour que personne ne se pose de questions.',
196
202
  dateCancelled: 'Annulé le {date}.', dateCancelledNote: 'Les autres dates sont maintenues.',
203
+ moved: 'Déplacé', dateMoved: 'Déplacé : prévu initialement le {from}.', dateMovedNote: 'Les autres dates sont maintenues.',
197
204
  details: 'Détails de l’événement', joinOnline: 'Rejoindre en ligne', map: 'Carte', directionsTo: 'Itinéraire vers {place}', mapOf: 'Carte de {place}',
198
205
  directionsHere: 'Itinéraire vers le lieu', mapHere: 'Carte du lieu', showing: 'Date affichée : {date}',
199
206
  addToCalendar: 'Ajouter au calendrier', downloadIcs: 'Télécharger le .ics', share: 'Partager', linkCopied: 'Lien copié',
@@ -226,6 +233,7 @@ const STRINGS: Record<string, EventStrings> = {
226
233
  de: {
227
234
  cancelled: 'Abgesagt', eventCancelled: 'Diese Veranstaltung ist abgesagt.', eventCancelledNote: 'Sie bleibt sichtbar, damit sich niemand wundert.',
228
235
  dateCancelled: 'Abgesagt am {date}.', dateCancelledNote: 'Die anderen Termine finden wie gewohnt statt.',
236
+ moved: 'Verschoben', dateMoved: 'Verschoben: ursprünglich am {from}.', dateMovedNote: 'Die anderen Termine finden wie gewohnt statt.',
229
237
  details: 'Veranstaltungsdetails', joinOnline: 'Online teilnehmen', map: 'Karte', directionsTo: 'Route zu {place}', mapOf: 'Karte von {place}',
230
238
  directionsHere: 'Route zum Ort', mapHere: 'Karte des Ortes', showing: 'Angezeigt: {date}',
231
239
  addToCalendar: 'Zum Kalender hinzufügen', downloadIcs: '.ics herunterladen', share: 'Teilen', linkCopied: 'Link kopiert',
@@ -258,6 +266,7 @@ const STRINGS: Record<string, EventStrings> = {
258
266
  pt: {
259
267
  cancelled: 'Cancelado', eventCancelled: 'Este evento foi cancelado.', eventCancelledNote: 'Ele continua na lista para que ninguém fique na dúvida.',
260
268
  dateCancelled: 'Cancelado em {date}.', dateCancelledNote: 'As outras datas continuam normalmente.',
269
+ moved: 'Remarcado', dateMoved: 'Remarcado: antes era em {from}.', dateMovedNote: 'As outras datas continuam normalmente.',
261
270
  details: 'Detalhes do evento', joinOnline: 'Participar on-line', map: 'Mapa', directionsTo: 'Como chegar a {place}', mapOf: 'Mapa de {place}',
262
271
  directionsHere: 'Como chegar ao local', mapHere: 'Mapa do local', showing: 'Exibindo {date}',
263
272
  addToCalendar: 'Adicionar ao calendário', downloadIcs: 'Baixar .ics', share: 'Compartilhar', linkCopied: 'Link copiado',
@@ -1,7 +1,7 @@
1
1
  // Native events: wall-clock time math, recurrence expansion (reusing the ICS
2
2
  // expander, so feed events and native events follow identical rules),
3
3
  // visibility, and "add to calendar" links. Pure — no repo, no fetch.
4
- import type { CalEvent, Calendar, EventLocation, Settings } from '../schema';
4
+ import type { CalEvent, Calendar, EventLocation, MovedDate, Settings } from '../schema';
5
5
  import { expandVEvents, tzParts, zonedToUtc, type CalendarWindow, type IcsTime, type VEvent } from './ics';
6
6
  import { cleanRrule } from './rrule';
7
7
  import { eventStrings } from './event-words';
@@ -35,6 +35,9 @@ function fromMinutes(min: number, dateOnly: boolean): Wall {
35
35
  return { y: dt.getUTCFullYear(), m: dt.getUTCMonth() + 1, d: dt.getUTCDate(), hh: dateOnly ? 0 : dt.getUTCHours(), mm: dateOnly ? 0 : dt.getUTCMinutes(), dateOnly };
36
36
  }
37
37
  export const addWallDays = (w: Wall, n: number) => fromMinutes(wallMinutes(w) + n * 1440, w.dateOnly);
38
+ export const addWallMinutes = (w: Wall, n: number) => fromMinutes(wallMinutes(w) + n, w.dateOnly);
39
+ /** whole minutes from a to b (wall-clock, no zone) */
40
+ export const wallMinutesBetween = (a: Wall, b: Wall) => wallMinutes(b) - wallMinutes(a);
38
41
 
39
42
  export function isValidTimeZone(tz: string): boolean {
40
43
  if (!tz) return false;
@@ -122,8 +125,27 @@ export interface Occurrence {
122
125
  key: string;
123
126
  // the whole event is cancelled, or just this date
124
127
  cancelled: boolean;
125
- // the rooms this date holds (Resource ids; the event's roomHold.resourceIds)
128
+ // the rooms this date holds (Resource ids: the event's roomHold.resourceIds,
129
+ // or a moved date's own)
126
130
  roomIds: string[];
131
+ // this date was moved here (CalEvent.movedDates): where it was, and why
132
+ moved?: OccurrenceMove;
133
+ }
134
+
135
+ export interface OccurrenceMove {
136
+ /** the date's original start and end (UTC ms) */
137
+ from: { start: number; end: number };
138
+ /** staff's public note ('' for none) */
139
+ note: string;
140
+ /** the date's own place, when it moved somewhere else */
141
+ location?: EventLocation;
142
+ }
143
+
144
+ /** Every room an event may hold: its own and any a moved date holds instead. */
145
+ export function eventRoomIds(e: Pick<CalEvent, 'roomHold' | 'movedDates'>): string[] {
146
+ const ids = new Set(e.roomHold?.resourceIds ?? []);
147
+ for (const m of Object.values(e.movedDates ?? {})) for (const id of m.roomIds ?? []) ids.add(id);
148
+ return [...ids];
127
149
  }
128
150
 
129
151
  /**
@@ -148,14 +170,73 @@ export function expandEvents(events: CalEvent[], settings: Pick<Settings, 'timez
148
170
  if (!v) continue;
149
171
  // ?? []: editor previews pass unsaved events that may lack the field
150
172
  const cancelledDates = new Set(e.cancelledDates ?? []);
173
+ const moved = movedEntries(e);
151
174
  for (const o of expandVEvents([v], tz, win, 0)) {
152
175
  const key = occurrenceKey(e, o.start, settings, win.displayTz);
176
+ // a moved date comes out at its new time below
177
+ if (moved.has(key)) continue;
153
178
  out.push({ event: e, start: o.start, end: o.end, allDay: o.allDay, days: o.days, key, cancelled: e.cancelled || cancelledDates.has(key), roomIds: e.roomHold?.resourceIds ?? [] });
154
179
  }
180
+ for (const [key, m] of moved) {
181
+ const at = movedInstants(e, m, settings, win.displayTz);
182
+ if (!at || at.end <= win.windowStartMs || at.start >= win.windowEndMs) continue;
183
+ const o = movedOccurrence(e, key, m, settings, win.displayTz);
184
+ if (o) out.push(o);
185
+ }
155
186
  }
156
187
  return out.sort((a, b) => a.start - b.start || a.event.title.localeCompare(b.event.title));
157
188
  }
158
189
 
190
+ // ---- moved dates ---------------------------------------------------------
191
+ // A moved date (CalEvent.movedDates) keeps its original key; it comes out at
192
+ // its new time, in its own rooms (and place) when it has them, with `moved`
193
+ // saying where it was. Keys that no longer match a date of the series (it
194
+ // was retimed or its rule changed) are ignored, like stale cancelled keys,
195
+ // and a key that is also cancelled stays cancelled at its original time.
196
+
197
+ // the moves that count: not on a cancelled date
198
+ function movedEntries(e: Pick<CalEvent, 'movedDates' | 'cancelledDates'>): Map<string, MovedDate> {
199
+ const out = new Map<string, MovedDate>();
200
+ const cancelled = new Set(e.cancelledDates ?? []);
201
+ for (const [key, m] of Object.entries(e.movedDates ?? {})) if (!cancelled.has(key)) out.set(key, m);
202
+ return out;
203
+ }
204
+
205
+ /** A moved date's new instants (all-day: midnight in the display zone, as the expander places them), or null when unusable. */
206
+ export function movedInstants(e: Pick<CalEvent, 'allDay' | 'timezone'>, m: Pick<MovedDate, 'start' | 'end'>, settings: Pick<Settings, 'timezone'>, displayTz = settings.timezone || 'UTC'): { start: number; end: number; days: number } | null {
207
+ const s = parseWall(m.start);
208
+ if (!s || s.dateOnly !== e.allDay) return null;
209
+ const en = parseWall(m.end) ?? s;
210
+ if (e.allDay) {
211
+ const days = Math.max(1, Math.round((wallMinutes({ ...en, hh: 0, mm: 0 }) - wallMinutes(s)) / 1440) + 1);
212
+ const start = zonedToUtc(s.y, s.m, s.d, 0, 0, 0, displayTz || 'UTC');
213
+ return { start, end: start + days * DAY, days };
214
+ }
215
+ const tz = eventTimeZone(e, settings);
216
+ const start = wallToMs(s, tz), end = wallToMs(en, tz);
217
+ return end > start ? { start, end, days: 1 } : null;
218
+ }
219
+
220
+ // the date as the series alone makes it (no move), or null when it isn't one
221
+ function seriesOccurrence(e: CalEvent, key: string, settings: Pick<Settings, 'timezone'>, displayTz: string): Occurrence | null {
222
+ const w = parseWall(key);
223
+ if (!w || w.dateOnly !== e.allDay) return null;
224
+ const at = e.allDay ? zonedToUtc(w.y, w.m, w.d, 0, 0, 0, displayTz || 'UTC') : wallToMs(w, eventTimeZone(e, settings));
225
+ return expandEvents([{ ...e, movedDates: {} }], settings, { displayTz, now: at, windowStartMs: at - DAY, windowEndMs: at + DAY })
226
+ .find((o) => o.key === key && o.start === at) ?? null;
227
+ }
228
+
229
+ function movedOccurrence(e: CalEvent, key: string, m: MovedDate, settings: Pick<Settings, 'timezone'>, displayTz: string): Occurrence | null {
230
+ const at = movedInstants(e, m, settings, displayTz);
231
+ const was = at && seriesOccurrence(e, key, settings, displayTz);
232
+ if (!at || !was) return null;
233
+ return {
234
+ event: e, start: at.start, end: at.end, allDay: e.allDay, days: at.days, key, cancelled: e.cancelled,
235
+ roomIds: m.roomIds ?? e.roomHold?.resourceIds ?? [],
236
+ moved: { from: { start: was.start, end: was.end }, note: m.note ?? '', ...(m.location ? { location: m.location } : {}) },
237
+ };
238
+ }
239
+
159
240
  // When RSVPs to one date close (UTC ms), or null when they stay open until it
160
241
  // ends: the event's closesAt (wall time in its zone, one instant for every
161
242
  // date), else closeBefore minutes before this date's start. A deadline at or
@@ -168,18 +249,17 @@ export function rsvpDeadline(o: Pick<Occurrence, 'event' | 'start' | 'end'>, set
168
249
  }
169
250
 
170
251
  // The occurrence of a series with this key (null when the series has no
171
- // such date: never generated, skipped by exdates, or the series moved)
252
+ // such date: never generated, skipped by exdates, or the series moved). A
253
+ // moved date comes back at its new time.
172
254
  export function findOccurrence(e: CalEvent, key: string, settings: Pick<Settings, 'timezone'>, displayTz = settings.timezone || 'UTC'): Occurrence | null {
173
- const w = parseWall(key);
174
- if (!w || w.dateOnly !== e.allDay) return null;
175
- const at = e.allDay ? zonedToUtc(w.y, w.m, w.d, 0, 0, 0, displayTz || 'UTC') : wallToMs(w, eventTimeZone(e, settings));
176
- return expandEvents([e], settings, { displayTz, now: at, windowStartMs: at - DAY, windowEndMs: at + DAY })
177
- .find((o) => o.key === key && o.start === at) ?? null;
255
+ const m = movedEntries(e).get(key);
256
+ return m ? movedOccurrence(e, key, m, settings, displayTz) : seriesOccurrence(e, key, settings, displayTz);
178
257
  }
179
258
 
180
259
  // The occurrence to feature for an event: the next one that hasn't ended,
181
260
  // else the most recent. `on` ('YYYY-MM-DD') picks a specific day's instance
182
- // (links from the calendar grid carry it).
261
+ // (links from the calendar grid carry it); a moved date answers both its new
262
+ // day and the day it was moved from.
183
263
  export function featuredOccurrence(e: CalEvent, settings: Pick<Settings, 'timezone'>, now: number, on?: string): Occurrence | null {
184
264
  const tz = eventTimeZone(e, settings);
185
265
  const displayTz = settings.timezone || 'UTC';
@@ -190,6 +270,11 @@ export function featuredOccurrence(e: CalEvent, settings: Pick<Settings, 'timezo
190
270
  const hit = expandEvents([e], settings, { displayTz, now, windowStartMs: from, windowEndMs: from + DAY })
191
271
  .find((o) => o.start >= from - DAY);
192
272
  if (hit) return hit;
273
+ for (const [key, m] of movedEntries(e)) {
274
+ if (key.slice(0, 10) !== on) continue;
275
+ const o = movedOccurrence(e, key, m, settings, displayTz);
276
+ if (o) return o;
277
+ }
193
278
  }
194
279
  }
195
280
  if (!e.rrule) {
@@ -5,7 +5,8 @@
5
5
  // across DST — Google, Apple and Outlook all resolve IANA zone ids. All-day
6
6
  // events are VALUE=DATE with the RFC's exclusive DTEND. A single cancelled
7
7
  // date of a series is an override VEVENT (same UID, RECURRENCE-ID) with
8
- // STATUS:CANCELLED, so calendar apps strike that one date.
8
+ // STATUS:CANCELLED, so calendar apps strike that one date; a moved date is an
9
+ // override with its new DTSTART/DTEND (and its own place), so they show it there.
9
10
  import type { CalEvent, Calendar } from '../schema';
10
11
  import { addWallDays, eventInstants, eventTimeZone, findOccurrence, locationLine, parseWall, type Wall } from './events';
11
12
  import { zonedToUtc } from './ics';
@@ -103,10 +104,32 @@ export function eventToVevent(e: CalEvent, siteTz: string, opts: FeedOptions): s
103
104
  if (e.cancelled) lines.push('STATUS:CANCELLED');
104
105
  const stamps = [`LAST-MODIFIED:${utcStamp(e.updatedAt)}`, `SEQUENCE:${Math.max(0, Math.floor((e.updatedAt - e.createdAt) / 1000)) % 2147483647}`];
105
106
  lines.push(...stamps, 'END:VEVENT');
106
- if (rule && !e.cancelled) lines.push(...cancelledOverrides(e, s, en, tz, siteTz, url, opts, stamps));
107
+ if (rule && !e.cancelled) lines.push(...cancelledOverrides(e, s, en, tz, siteTz, url, opts, stamps), ...movedOverrides(e, tz, siteTz, url, opts, stamps));
107
108
  return lines;
108
109
  }
109
110
 
111
+ // one override per moved date that is still part of the series (and not
112
+ // cancelled): the same date (RECURRENCE-ID) at its new time
113
+ function movedOverrides(e: CalEvent, tz: string, siteTz: string, url: string, opts: FeedOptions, stamps: string[]): string[] {
114
+ const out: string[] = [];
115
+ const cancelled = new Set(e.cancelledDates ?? []);
116
+ for (const key of Object.keys(e.movedDates ?? {}).sort()) {
117
+ const m = e.movedDates[key];
118
+ const w = parseWall(key), ms = parseWall(m.start), me = parseWall(m.end) ?? ms;
119
+ if (cancelled.has(key) || !w || !ms || !me || !findOccurrence(e, key, { timezone: siteTz }, siteTz || 'UTC')) continue;
120
+ const when = e.allDay
121
+ ? [`RECURRENCE-ID;VALUE=DATE:${dateValue(w)}`, `DTSTART;VALUE=DATE:${dateValue(ms)}`, `DTEND;VALUE=DATE:${dateValue(addWallDays({ ...me, dateOnly: true }, 1))}`]
122
+ : [`RECURRENCE-ID;TZID=${tz}:${localValue(w)}`, `DTSTART;TZID=${tz}:${localValue(ms)}`, `DTEND;TZID=${tz}:${localValue(me)}`];
123
+ const place = m.location ?? e.location;
124
+ const loc = locationLine(place);
125
+ const desc = [m.note, e.excerpt, place.url && place.url !== loc ? place.url : '', url].filter(Boolean).join('\n\n');
126
+ out.push('BEGIN:VEVENT', `UID:${e.id}@${opts.host}`, `DTSTAMP:${utcStamp(opts.now ?? Date.now())}`, ...when,
127
+ `SUMMARY:${escapeText(e.title || 'Untitled event')}`, ...(loc ? [`LOCATION:${escapeText(loc)}`] : []),
128
+ `DESCRIPTION:${escapeText(desc)}`, `URL:${url}`, ...stamps, 'END:VEVENT');
129
+ }
130
+ return out;
131
+ }
132
+
110
133
  // one override per cancelled date that is still part of the series (keys
111
134
  // left behind by a moved series are ignored)
112
135
  function cancelledOverrides(e: CalEvent, s: Wall, en: Wall, tz: string, siteTz: string, url: string, opts: FeedOptions, stamps: string[]): string[] {