@acms-subir/core 0.3.56 → 0.3.57

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 (42) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/cli/migrations.generated.mjs +16 -4
  3. package/docs/calendar.md +116 -2
  4. package/docs/server.md +33 -1
  5. package/package.json +1 -1
  6. package/src/actions/index.ts +52 -9
  7. package/src/components/EventPage.astro +6 -5
  8. package/src/components/calendar/CalendarBoard.tsx +6 -3
  9. package/src/components/editor/event/SingleDates.tsx +75 -15
  10. package/src/lib/calendar/api.ts +23 -2
  11. package/src/lib/calendar/board.ts +3 -2
  12. package/src/lib/calendar/cards.ts +4 -4
  13. package/src/lib/calendar/change-migration.ts +17 -3
  14. package/src/lib/calendar/data.ts +9 -2
  15. package/src/lib/calendar/event-words.ts +8 -0
  16. package/src/lib/calendar/events.ts +128 -15
  17. package/src/lib/calendar/google/map.ts +1 -1
  18. package/src/lib/calendar/host-requests.ts +65 -10
  19. package/src/lib/calendar/ics-write.ts +65 -15
  20. package/src/lib/calendar/member-words.ts +14 -1
  21. package/src/lib/calendar/moves.ts +15 -9
  22. package/src/lib/calendar/request-admin.ts +2 -1
  23. package/src/lib/calendar/request-service.ts +5 -5
  24. package/src/lib/calendar/schedule.ts +42 -8
  25. package/src/lib/calendar/segments.ts +522 -0
  26. package/src/lib/editor/schedule.ts +2 -2
  27. package/src/lib/mobile/calendar.ts +1 -0
  28. package/src/lib/mobile/schemas.ts +10 -3
  29. package/src/lib/push/service.ts +42 -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 +24 -8
  33. package/src/lib/repo/types.ts +3 -1
  34. package/src/lib/schema.ts +24 -0
  35. package/src/lib/sections.ts +1 -1
  36. package/src/lib/sync/catalog.ts +1 -0
  37. package/src/pages/admin/calendar/events/index.astro +9 -8
  38. package/src/pages/admin/calendar/requests.astro +5 -5
  39. package/src/pages/api/calendar/events/index.ts +2 -1
  40. package/src/pages/members/events/index.astro +84 -6
  41. package/src/server/index.ts +46 -3
  42. package/src/styles/editor.css +2 -0
@@ -7,6 +7,7 @@ import { roomColor, type CalEvent, type Calendar, type MovedDate, type Resource
7
7
  import { calendarFeedUrl, eventIcsUrl, eventUrl, publicEventFields } from './config';
8
8
  import { describeRepeat } from './rrule';
9
9
  import { localizeForm } from '../forms/localize';
10
+ import { currentPart, seriesParts } from './events';
10
11
 
11
12
  export interface EventJson {
12
13
  id: string;
@@ -23,7 +24,10 @@ export interface EventJson {
23
24
  allDay: boolean;
24
25
  timezone: string;
25
26
  rrule: string;
27
+ // the repeat in words for the part of the series in effect now (a segment's, once it took over)
26
28
  repeats: string;
29
+ // the series changed from a date on (CalEvent.segments), in order; [] = never
30
+ segments: SegmentJson[];
27
31
  exdates: string[];
28
32
  cancelledDates: string[];
29
33
  // single dates at another time, by original occurrence key: the new wall
@@ -44,6 +48,18 @@ export interface EventJson {
44
48
  }
45
49
 
46
50
  export interface RoomJson { slug: string; name: string; color: string | null }
51
+ /** One segment of a series: from which date (an occurrence key), its first date, rule, words, rooms and place (null = the event's) */
52
+ export interface SegmentJson {
53
+ from: string;
54
+ start: string;
55
+ end: string;
56
+ rrule: string;
57
+ repeats: string;
58
+ cancelled: boolean;
59
+ note: string;
60
+ location: Pick<CalEvent['location'], 'name' | 'address' | 'url'> | null;
61
+ rooms: RoomJson[];
62
+ }
47
63
  export type PublicMovedDate = Pick<MovedDate, 'start' | 'end' | 'note'> & Partial<MovedDate>;
48
64
 
49
65
  /** moved dates as public data: where and why, not who */
@@ -85,8 +101,13 @@ export function eventToJson(e: CalEvent, origin: string, calendars: Map<string,
85
101
  allDay: e.allDay,
86
102
  timezone: e.timezone || siteTz || 'UTC',
87
103
  rrule: e.rrule,
88
- repeats: describeRepeat(e.rrule, e.start, opts.locale ? untilDate(opts.locale) : undefined, opts.locale),
89
- exdates: e.exdates,
104
+ // the part of the series in effect now (a change from a date on, once it took over), in the request's language
105
+ repeats: ((p) => describeRepeat(p.rrule, p.start, opts.locale ? untilDate(opts.locale) : undefined, opts.locale))(currentPart(e, { timezone: siteTz })),
106
+ segments: seriesParts(e).slice(1).map((p) => ({
107
+ from: p.from, start: p.start, end: p.end, rrule: p.rrule, repeats: describeRepeat(p.rrule, p.start, opts.locale ? untilDate(opts.locale) : undefined, opts.locale), cancelled: p.cancelled, note: p.note,
108
+ location: p.location ? { name: p.location.name, address: p.location.address, url: p.location.url } : null,
109
+ rooms: roomsToJson(p.roomIds, opts.resources, opts.includePrivate),
110
+ })), exdates: e.exdates,
90
111
  cancelledDates: e.cancelledDates ?? [],
91
112
  movedDates: movedDatesToJson(e, opts.includePrivate),
92
113
  location: e.location,
@@ -5,7 +5,7 @@
5
5
  import type { ContentRepository } from '../repo/types';
6
6
  import type { CalEvent, Settings } from '../schema';
7
7
  import { collectViewEvents } from './data';
8
- import { occurrenceKey, safeColor } from './events';
8
+ import { occurrenceKey, partOf, safeColor } from './events';
9
9
  import { tzParts, zonedToUtc } from './ics';
10
10
  import { addDays, isIsoDay, type GridItem, type Iso } from './grid';
11
11
  import { bookingsEnabled } from './config';
@@ -134,7 +134,8 @@ export async function loadBoard(repo: ContentRepository, settings: Settings, fro
134
134
  calendarName: cal?.name ?? '',
135
135
  status: e ? eventBoardStatus(e) : 'feed',
136
136
  recurring: Boolean(e?.rrule),
137
- repeat: e?.rrule ? describeRepeat(e.rrule, e.start) : '',
137
+ // the rule of the part of the series this date is in (CalEvent.segments)
138
+ repeat: e?.rrule ? ((p) => describeRepeat(p.rrule, p.start))(partOf(e, { segment: v.segment })) : '',
138
139
  rooms: e ? (v.rooms?.map((r) => r.id) ?? e.roomHold.resourceIds).map((id) => roomName.get(id)).filter((n): n is string => Boolean(n)) : [],
139
140
  roomsHeld: e ? holdsRooms(e) : false,
140
141
  occurrence,
@@ -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; moved?: Occurrence['moved'] },
31
+ o: Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days'> & { cancelled?: boolean; moved?: Occurrence['moved']; location?: Occurrence['location'] },
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(o.moved?.location ?? e.location);
51
+ const loc = locationLine(o.location ?? e.location);
52
52
  const ctaHref = safeHref(e.cta.href);
53
53
  const cta =
54
54
  opts.showCta && e.cta.label && ctaHref && !cancelled
@@ -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'> & Pick<Partial<Occurrence>, 'moved'> & Pick<ViewEvent, 'rooms'>)[] = [];
83
+ const out: (Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days' | 'cancelled'> & Pick<Partial<Occurrence>, 'moved' | 'location'> & Pick<ViewEvent, 'rooms'>)[] = [];
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), ...(v.moved ? { moved: v.moved } : {}), rooms: v.rooms ?? [] });
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 } : {}), ...(v.place ? { location: v.place } : {}), rooms: v.rooms ?? [] });
88
88
  if (out.length >= opts.limit) break;
89
89
  }
90
90
  return out;
@@ -15,7 +15,8 @@
15
15
  //
16
16
  // Each migration's column lists are frozen: a later events column joins by a
17
17
  // new migration that recreates these triggers through eventChangeTriggers()
18
- // (the test that sorts every column fails until then). v66 added moved_dates.
18
+ // (the test that sorts every column fails until then). v66 added moved_dates,
19
+ // v70 segments.
19
20
  const now = "CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER)";
20
21
 
21
22
  // v65's lists
@@ -27,9 +28,9 @@ const V65_COLUMNS = ['slug', 'title', 'body_json', 'body_html', 'excerpt', 'cove
27
28
  'location', 'cta', 'calendar_ids', 'fields', 'rsvp', 'translations', 'room_hold', 'start_at', 'end_at'];
28
29
 
29
30
  /** 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
+ export const EVENT_CHANGE_SNAPSHOT = [...V65_SNAPSHOT, 'moved_dates', 'segments'];
31
32
  /** 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'];
33
+ export const EVENT_CHANGE_COLUMNS = [...V65_COLUMNS, 'moved_dates', 'segments'];
33
34
  /** columns no change is recorded for */
34
35
  export const EVENT_CHANGE_IGNORED = ['id', 'draft', 'host_ids', 'request', 'source_uid', 'published_at', 'scheduled_at', 'created_at', 'updated_at'];
35
36
 
@@ -88,3 +89,16 @@ export const MOVED_DATES_MIGRATION = (contentVersionUpdate: string) => ({ versio
88
89
  `DROP TRIGGER event_changes_delete`,
89
90
  ...eventChangeTriggers([...V65_SNAPSHOT, 'moved_dates'], [...V65_COLUMNS, 'moved_dates']),
90
91
  ] });
92
+
93
+ // v70: a series changed from one date on (CalEvent.segments: its rule, times,
94
+ // rooms and place from then), a column on the row like moved_dates: public,
95
+ // so it bumps the content version and its changes are recorded
96
+ export const SEGMENTS_MIGRATION = (contentVersionUpdate: string) => ({ version: 70, statements: [
97
+ `ALTER TABLE events ADD COLUMN segments TEXT NOT NULL DEFAULT '[]'`,
98
+ `DROP TRIGGER content_version_events_update`,
99
+ contentVersionUpdate,
100
+ `DROP TRIGGER event_changes_insert`,
101
+ `DROP TRIGGER event_changes_update`,
102
+ `DROP TRIGGER event_changes_delete`,
103
+ ...eventChangeTriggers([...V65_SNAPSHOT, 'moved_dates', 'segments'], [...V65_COLUMNS, 'moved_dates', 'segments']),
104
+ ] });
@@ -7,7 +7,7 @@
7
7
  // details, lib/display: published events only, private calendars when it
8
8
  // names them, bookings with the booker's name, never emails, phones or notes).
9
9
  import type { ContentRepository } from '../repo/types';
10
- import { roomColor, type BookingLink, type CalEvent, type Calendar, type Resource, type Settings } from '../schema';
10
+ import { roomColor, type BookingLink, type CalEvent, type Calendar, type EventLocation, type Resource, type Settings } from '../schema';
11
11
  import { eventIsListed, eventIsPublic, eventRoomIds, expandEvents, locationLine, safeColor, type OccurrenceMove } from './events';
12
12
  import { expandVEvents, loadFeed, tzParts, type CalendarEvent, type CalendarWindow } from './ics';
13
13
  import { CALENDAR, eventUrl, resourceUrl, withoutPrivateFields } from './config';
@@ -31,6 +31,10 @@ export interface ViewEvent extends CalendarEvent {
31
31
  key?: string;
32
32
  // this date was moved here: where it was (UTC ms) and the public note
33
33
  moved?: OccurrenceMove;
34
+ // native events changed from a date on: the segment this date belongs to (its `from` key)
35
+ segment?: string;
36
+ // native events: the date's own place (moved there, or its segment's), when not the event's
37
+ place?: EventLocation;
34
38
  }
35
39
 
36
40
  // color: '#rrggbb', or '' when the room has none
@@ -115,7 +119,8 @@ export async function collectViewEvents(
115
119
  events.push({
116
120
  start: o.start, end: o.end, allDay: o.allDay, days: o.days,
117
121
  title: e.title || words.untitled,
118
- location: locationLine(o.moved?.location ?? e.location),
122
+ // the date's own place (moved there, or its segment's), else the event's
123
+ location: locationLine(o.location ?? e.location),
119
124
  description: e.excerpt,
120
125
  cal: cal ? idx.get(cal.id) ?? 0 : 0,
121
126
  ref: e.id,
@@ -129,6 +134,8 @@ export async function collectViewEvents(
129
134
  rooms: rooms(o.roomIds),
130
135
  key: o.key,
131
136
  ...(o.moved ? { moved: o.moved } : {}),
137
+ ...(o.segment ? { segment: o.segment } : {}),
138
+ ...(o.location ? { place: o.location } : {}),
132
139
  });
133
140
  }
134
141
 
@@ -22,6 +22,9 @@ export interface EventStrings {
22
22
  moved: string;
23
23
  dateMoved: string;
24
24
  dateMovedNote: string;
25
+ /** the series changed from a date on (describeSeries): "From {date}: {repeat}", and every date cancelled from one on */
26
+ fromDate: string;
27
+ cancelledFrom: string;
25
28
  /** the side column's landmark label */
26
29
  details: string;
27
30
  joinOnline: string;
@@ -135,6 +138,7 @@ const STRINGS: Record<string, EventStrings> = {
135
138
  cancelled: 'Cancelled', eventCancelled: 'This event is cancelled.', eventCancelledNote: 'It stays listed so nobody is left wondering.',
136
139
  dateCancelled: 'Cancelled on {date}.', dateCancelledNote: 'Other dates go ahead as usual.',
137
140
  moved: 'Moved', dateMoved: 'Moved from {from}.', dateMovedNote: 'Other dates stay as usual.',
141
+ fromDate: 'From {date}: {repeat}', cancelledFrom: 'Cancelled from {date}',
138
142
  details: 'Event details', joinOnline: 'Join online', map: 'Map', directionsTo: 'Directions to {place}', mapOf: 'Map of {place}',
139
143
  directionsHere: 'Directions to the location', mapHere: 'Map of the location', showing: 'Showing {date}',
140
144
  addToCalendar: 'Add to calendar', downloadIcs: 'Download .ics', share: 'Share', linkCopied: 'Link copied',
@@ -168,6 +172,7 @@ const STRINGS: Record<string, EventStrings> = {
168
172
  cancelled: 'Cancelado', eventCancelled: 'Este evento está cancelado.', eventCancelledNote: 'Lo dejamos en la lista para que nadie se quede con la duda.',
169
173
  dateCancelled: 'Cancelado el {date}.', dateCancelledNote: 'Las demás fechas siguen como siempre.',
170
174
  moved: 'Cambio de horario', dateMoved: 'Cambió de horario: antes era el {from}.', dateMovedNote: 'Las demás fechas siguen como siempre.',
175
+ fromDate: 'Desde el {date}: {repeat}', cancelledFrom: 'Cancelado desde el {date}',
171
176
  details: 'Detalles del evento', joinOnline: 'Unirse en línea', map: 'Mapa', directionsTo: 'Cómo llegar a {place}', mapOf: 'Mapa de {place}',
172
177
  directionsHere: 'Cómo llegar al lugar', mapHere: 'Mapa del lugar', showing: 'Mostrando el {date}',
173
178
  addToCalendar: 'Agregar al calendario', downloadIcs: 'Descargar .ics', share: 'Compartir', linkCopied: 'Enlace copiado',
@@ -201,6 +206,7 @@ const STRINGS: Record<string, EventStrings> = {
201
206
  cancelled: 'Annulé', eventCancelled: 'Cet événement est annulé.', eventCancelledNote: 'Il reste affiché pour que personne ne se pose de questions.',
202
207
  dateCancelled: 'Annulé le {date}.', dateCancelledNote: 'Les autres dates sont maintenues.',
203
208
  moved: 'Déplacé', dateMoved: 'Déplacé : prévu initialement le {from}.', dateMovedNote: 'Les autres dates sont maintenues.',
209
+ fromDate: 'À partir du {date} : {repeat}', cancelledFrom: 'Annulé à partir du {date}',
204
210
  details: 'Détails de l’événement', joinOnline: 'Rejoindre en ligne', map: 'Carte', directionsTo: 'Itinéraire vers {place}', mapOf: 'Carte de {place}',
205
211
  directionsHere: 'Itinéraire vers le lieu', mapHere: 'Carte du lieu', showing: 'Date affichée : {date}',
206
212
  addToCalendar: 'Ajouter au calendrier', downloadIcs: 'Télécharger le .ics', share: 'Partager', linkCopied: 'Lien copié',
@@ -234,6 +240,7 @@ const STRINGS: Record<string, EventStrings> = {
234
240
  cancelled: 'Abgesagt', eventCancelled: 'Diese Veranstaltung ist abgesagt.', eventCancelledNote: 'Sie bleibt sichtbar, damit sich niemand wundert.',
235
241
  dateCancelled: 'Abgesagt am {date}.', dateCancelledNote: 'Die anderen Termine finden wie gewohnt statt.',
236
242
  moved: 'Verschoben', dateMoved: 'Verschoben: ursprünglich am {from}.', dateMovedNote: 'Die anderen Termine finden wie gewohnt statt.',
243
+ fromDate: 'Ab {date}: {repeat}', cancelledFrom: 'Abgesagt ab {date}',
237
244
  details: 'Veranstaltungsdetails', joinOnline: 'Online teilnehmen', map: 'Karte', directionsTo: 'Route zu {place}', mapOf: 'Karte von {place}',
238
245
  directionsHere: 'Route zum Ort', mapHere: 'Karte des Ortes', showing: 'Angezeigt: {date}',
239
246
  addToCalendar: 'Zum Kalender hinzufügen', downloadIcs: '.ics herunterladen', share: 'Teilen', linkCopied: 'Link kopiert',
@@ -267,6 +274,7 @@ const STRINGS: Record<string, EventStrings> = {
267
274
  cancelled: 'Cancelado', eventCancelled: 'Este evento foi cancelado.', eventCancelledNote: 'Ele continua na lista para que ninguém fique na dúvida.',
268
275
  dateCancelled: 'Cancelado em {date}.', dateCancelledNote: 'As outras datas continuam normalmente.',
269
276
  moved: 'Remarcado', dateMoved: 'Remarcado: antes era em {from}.', dateMovedNote: 'As outras datas continuam normalmente.',
277
+ fromDate: 'A partir de {date}: {repeat}', cancelledFrom: 'Cancelado a partir de {date}',
270
278
  details: 'Detalhes do evento', joinOnline: 'Participar on-line', map: 'Mapa', directionsTo: 'Como chegar a {place}', mapOf: 'Mapa de {place}',
271
279
  directionsHere: 'Como chegar ao local', mapHere: 'Mapa do local', showing: 'Exibindo {date}',
272
280
  addToCalendar: 'Adicionar ao calendário', downloadIcs: 'Baixar .ics', share: 'Compartilhar', linkCopied: 'Link copiado',
@@ -1,9 +1,9 @@
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, MovedDate, Settings } from '../schema';
4
+ import type { CalEvent, Calendar, EventLocation, MovedDate, SeriesSegment, Settings } from '../schema';
5
5
  import { expandVEvents, tzParts, zonedToUtc, type CalendarWindow, type IcsTime, type VEvent } from './ics';
6
- import { cleanRrule } from './rrule';
6
+ import { cleanRrule, describeRepeat } from './rrule';
7
7
  import { eventStrings } from './event-words';
8
8
 
9
9
  const DAY = 86_400_000;
@@ -130,6 +130,11 @@ export interface Occurrence {
130
130
  roomIds: string[];
131
131
  // this date was moved here (CalEvent.movedDates): where it was, and why
132
132
  moved?: OccurrenceMove;
133
+ // the part of the series it belongs to, when the series changed from a
134
+ // date on (CalEvent.segments): that segment's `from` key
135
+ segment?: string;
136
+ // the date's own place (a moved date's, or its segment's), when not the event's
137
+ location?: EventLocation;
133
138
  }
134
139
 
135
140
  export interface OccurrenceMove {
@@ -141,13 +146,105 @@ export interface OccurrenceMove {
141
146
  location?: EventLocation;
142
147
  }
143
148
 
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[] {
149
+ /** Every room an event may hold: its own and any a moved date or a segment holds instead. */
150
+ export function eventRoomIds(e: Pick<CalEvent, 'roomHold' | 'movedDates'> & { segments?: CalEvent['segments'] }): string[] {
146
151
  const ids = new Set(e.roomHold?.resourceIds ?? []);
147
152
  for (const m of Object.values(e.movedDates ?? {})) for (const id of m.roomIds ?? []) ids.add(id);
153
+ for (const seg of e.segments ?? []) for (const id of seg.roomIds ?? []) ids.add(id);
148
154
  return [...ids];
149
155
  }
150
156
 
157
+ // ---- segments ------------------------------------------------------------
158
+ // A series changed from a date on (CalEvent.segments) is one event made of
159
+ // parts: its own rule up to the first segment's `from`, then each segment's
160
+ // rule (from its own first date) up to the next one's `from`. A part's dates
161
+ // stop at the day of the next part's `from` (wall-clock in the event's
162
+ // zone), so retiming the earlier rule never leaves a second date on that
163
+ // day. A date of any part is like any other: cancelled, moved and skipped by
164
+ // its own key.
165
+
166
+ /** One part of a series: the event's own rule (`from` '') or a segment. */
167
+ export interface SeriesPart {
168
+ /** the segment's `from` key; '' for the event's own rule */
169
+ from: string;
170
+ /** the next part's `from` ('' = none): this part's dates from that day on are gone (cutsAt) */
171
+ until: string;
172
+ start: string;
173
+ end: string;
174
+ rrule: string;
175
+ roomIds: string[];
176
+ location?: EventLocation;
177
+ cancelled: boolean;
178
+ note: string;
179
+ }
180
+
181
+ /** Whether a part that stops at `until` (a key; '' = never) has no date at `key`: from that day on, it's the next part's. */
182
+ export const cutsAt = (until: string, key: string) => Boolean(until) && key.slice(0, 10) >= until.slice(0, 10);
183
+
184
+ /** The parts of an event's series, in order (just its own rule when it has no segments or doesn't repeat). */
185
+ export function seriesParts(e: Pick<CalEvent, 'start' | 'end' | 'rrule' | 'allDay' | 'roomHold'> & { segments?: SeriesSegment[] }): SeriesPart[] {
186
+ const own: SeriesPart = { from: '', until: '', start: e.start, end: e.end, rrule: e.rrule, roomIds: e.roomHold?.resourceIds ?? [], cancelled: false, note: '' };
187
+ if (!e.rrule || !e.segments?.length) return [own];
188
+ // usable segments only: keys and times in the event's own form (all-day or timed)
189
+ const usable = e.segments.filter((g) => { const f = parseWall(g.from), st = parseWall(g.start); return f && st && f.dateOnly === e.allDay && st.dateOnly === e.allDay; })
190
+ .sort((a, b) => (a.from < b.from ? -1 : a.from > b.from ? 1 : 0));
191
+ const parts = [own, ...usable.map((g): SeriesPart => ({
192
+ from: g.from, until: '', start: g.start, end: g.end || g.start, rrule: g.rrule, roomIds: g.roomIds ?? e.roomHold?.resourceIds ?? [],
193
+ ...(g.location ? { location: g.location } : {}), cancelled: g.cancelled, note: g.note,
194
+ }))];
195
+ for (let i = 0; i < parts.length - 1; i++) parts[i].until = parts[i + 1].from;
196
+ return parts;
197
+ }
198
+
199
+ /** A part's dates' length in wall-clock minutes (all-day: whole days × 1440). */
200
+ export function partMinutes(p: Pick<SeriesPart, 'start' | 'end'>): number {
201
+ const s = parseWall(p.start), en = parseWall(p.end) ?? s;
202
+ return s && en ? wallMinutesBetween(s, en) : 60;
203
+ }
204
+
205
+ /** The part of the series in effect at `now`: the one its next date (or, once all are over, its last) belongs to. */
206
+ export function currentPart(e: CalEvent, settings: Pick<Settings, 'timezone'>, now = Date.now()): SeriesPart {
207
+ const parts = seriesParts(e);
208
+ if (parts.length === 1) return parts[0];
209
+ const o = featuredOccurrence(e, settings, now);
210
+ return o ? partOf(e, o) : parts[0];
211
+ }
212
+
213
+ /**
214
+ * The repeat line of a series in `locale`: the rule in effect now, then the
215
+ * next change still to come ("Weekly on Thursday · From Wed, Jan 16: Weekly
216
+ * on Wednesday"), or "Cancelled from …". `formatDate` writes UNTIL dates as
217
+ * describeRepeat's does; `dateLocale` the change's day (default: `locale`).
218
+ */
219
+ export function describeSeries(e: CalEvent, settings: Pick<Settings, 'timezone'>, now: number, locale?: string, formatDate?: (ymd: string) => string, dateLocale?: string): string {
220
+ if (!e.rrule) return '';
221
+ const w = eventStrings(locale);
222
+ const parts = seriesParts(e);
223
+ const current = currentPart(e, settings, now);
224
+ const text = describeRepeat(current.rrule, current.start, formatDate, locale);
225
+ if (parts.length === 1) return text;
226
+ const tz = e.allDay ? settings.timezone || 'UTC' : eventTimeZone(e, settings);
227
+ const day = (ms: number) => {
228
+ try { return new Intl.DateTimeFormat(dateLocale || locale || 'en', { weekday: 'short', month: 'short', day: 'numeric', timeZone: tz }).format(ms); }
229
+ catch { return new Date(ms).toISOString().slice(0, 10); }
230
+ };
231
+ const dates = expandEvents([e], settings, { displayTz: settings.timezone || 'UTC', now, windowStartMs: now - 400 * DAY, windowEndMs: now + 400 * DAY });
232
+ const firstOf = (p: SeriesPart) => dates.find((o) => (o.segment ?? '') === p.from);
233
+ const out = [current.cancelled ? fillWords(w.cancelledFrom, { date: firstOf(current) ? day(firstOf(current)!.start) : '' }) : text];
234
+ const next = parts[parts.findIndex((p) => p.from === current.from) + 1];
235
+ const first = next && firstOf(next);
236
+ if (next && first && first.start > now) {
237
+ out.push(next.cancelled ? fillWords(w.cancelledFrom, { date: day(first.start) })
238
+ : fillWords(w.fromDate, { date: day(first.start), repeat: describeRepeat(next.rrule, next.start, formatDate, locale) }));
239
+ }
240
+ return out.filter(Boolean).join(' · ');
241
+ }
242
+ const fillWords = (t: string, v: Record<string, string>) => t.replace(/\{(\w+)\}/g, (m, k: string) => v[k] ?? m);
243
+
244
+ /** The part of the series a date belongs to (its occurrence's `segment`, '' for the event's own rule). */
245
+ export const partOf = (e: Parameters<typeof seriesParts>[0], o: Pick<Occurrence, 'segment'>): SeriesPart =>
246
+ seriesParts(e).find((p) => p.from === (o.segment ?? '')) ?? seriesParts(e)[0];
247
+
151
248
  /**
152
249
  * The occurrence key for an occurrence start. Timed events use the event's
153
250
  * own zone; all-day occurrences start at midnight in the display zone (how
@@ -166,16 +263,21 @@ export function expandEvents(events: CalEvent[], settings: Pick<Settings, 'timez
166
263
  const out: Occurrence[] = [];
167
264
  for (const e of events) {
168
265
  const tz = eventTimeZone(e, settings);
169
- const v = toVEvent(e, tz);
170
- if (!v) continue;
171
266
  // ?? []: editor previews pass unsaved events that may lack the field
172
267
  const cancelledDates = new Set(e.cancelledDates ?? []);
173
268
  const moved = movedEntries(e);
174
- for (const o of expandVEvents([v], tz, win, 0)) {
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;
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 ?? [] });
269
+ for (const part of seriesParts(e)) {
270
+ const v = toVEvent(part.from ? { ...e, start: part.start, end: part.end, rrule: part.rrule } : e, tz);
271
+ if (!v) continue;
272
+ for (const o of expandVEvents([v], tz, win, 0)) {
273
+ const key = occurrenceKey(e, o.start, settings, win.displayTz);
274
+ // the next part has the dates from its `from` on; a moved date comes out at its new time below
275
+ if (cutsAt(part.until, key) || moved.has(key)) continue;
276
+ out.push({
277
+ event: e, start: o.start, end: o.end, allDay: o.allDay, days: o.days, key, cancelled: e.cancelled || part.cancelled || cancelledDates.has(key), roomIds: part.roomIds,
278
+ ...(part.from ? { segment: part.from } : {}), ...(part.location ? { location: part.location } : {}),
279
+ });
280
+ }
179
281
  }
180
282
  for (const [key, m] of moved) {
181
283
  const at = movedInstants(e, m, settings, win.displayTz);
@@ -230,10 +332,13 @@ function movedOccurrence(e: CalEvent, key: string, m: MovedDate, settings: Pick<
230
332
  const at = movedInstants(e, m, settings, displayTz);
231
333
  const was = at && seriesOccurrence(e, key, settings, displayTz);
232
334
  if (!at || !was) return null;
335
+ // its own rooms and place, else its part's (a segment's, or the event's)
336
+ const location = m.location ?? was.location;
233
337
  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 ?? [],
338
+ event: e, start: at.start, end: at.end, allDay: e.allDay, days: at.days, key, cancelled: was.cancelled,
339
+ roomIds: m.roomIds ?? was.roomIds,
236
340
  moved: { from: { start: was.start, end: was.end }, note: m.note ?? '', ...(m.location ? { location: m.location } : {}) },
341
+ ...(was.segment ? { segment: was.segment } : {}), ...(location ? { location } : {}),
237
342
  };
238
343
  }
239
344
 
@@ -358,7 +463,7 @@ const dateStamp = (ms: number, tz: string) => {
358
463
 
359
464
  export function addToCalendarLinks(
360
465
  e: CalEvent,
361
- o: Pick<Occurrence, 'start' | 'end' | 'allDay'>,
466
+ o: Pick<Occurrence, 'start' | 'end' | 'allDay'> & Pick<Partial<Occurrence>, 'segment'>,
362
467
  opts: { pageUrl: string; icsUrl: string; tz: string }
363
468
  ): { google: string; outlook: string; ics: string } {
364
469
  const details = [e.excerpt, opts.pageUrl].filter(Boolean).join('\n\n');
@@ -371,7 +476,15 @@ export function addToCalendarLinks(
371
476
  location: loc,
372
477
  });
373
478
  if (!o.allDay) g.set('ctz', opts.tz);
374
- const rule = cleanRrule(e.rrule);
479
+ // the rule of the part of the series this date is in, stopped where the next part takes over
480
+ const part = partOf(e, o);
481
+ let rule = cleanRrule(part.rrule);
482
+ const cut = parseWall(part.until);
483
+ if (rule && cut) {
484
+ const ends = rule.split(';').filter((x) => !/^(COUNT|UNTIL)=/.test(x));
485
+ const last = zonedToUtc(cut.y, cut.m, cut.d, 0, 0, 0, opts.tz) - 1000;
486
+ rule = [...ends, o.allDay ? `UNTIL=${dateStamp(last, opts.tz)}` : `UNTIL=${utcStamp(last)}`].join(';');
487
+ }
375
488
  if (rule) g.set('recur', `RRULE:${rule}`);
376
489
  const iso = (ms: number) => new Date(ms).toISOString();
377
490
  const ol = new URLSearchParams({
@@ -396,7 +396,7 @@ export function asSiteEvent(f: Fields, settings: Pick<Settings, 'timezone'>, id
396
396
  return {
397
397
  id, slug: '', title: f.title, bodyJson: '', bodyHtml: '', excerpt: '', cover: '', coverAlt: '', seoTitle: '', seoDesc: '', status: 'published', featured: false,
398
398
  tags: [], template: '', start: f.when.start, end: f.when.end, allDay: f.when.allDay, timezone, rrule: f.repeat.rrule, exdates: f.repeat.exdates, cancelled: false,
399
- cancelledDates: [], movedDates: {}, location: EventLocation.parse({ name: f.place }), cta: { label: '', href: '', style: 'primary' }, calendarIds: [], fields: {},
399
+ cancelledDates: [], movedDates: {}, segments: [], location: EventLocation.parse({ name: f.place }), cta: { label: '', href: '', style: 'primary' }, calendarIds: [], fields: {},
400
400
  sourceUid: '', rsvp: { enabled: false, capacity: 0, autoConfirm: true, maxSeats: 4, closesAt: null, closeBefore: 0, notifyStaff: true, waitlist: false },
401
401
  roomHold: { resourceIds: [], span: 'event', before: 0, after: 0, whileDraft: false, keep: [] }, hostIds: [], request: null, startAt, endAt,
402
402
  translations: {}, draft: null, publishedAt: null, scheduledAt: null, createdAt: 0, updatedAt: 0,
@@ -6,7 +6,9 @@
6
6
  // - 'approve': every host move, and every move back, is a request.
7
7
  // Requests are rows in event_requests (repo.requests, RequestRecord kind
8
8
  // 'move', data: MoveRequestData) that staff who can publish approve (the move
9
- // runs as them, recorded as the host's) or decline with a note.
9
+ // runs as them, recorded as the host's) or decline with a note. A host's
10
+ // "Change from this date on" (C4) is always a request (kind 'from-date', data:
11
+ // FromDateRequestData), which staff apply with changeFromDate (segments.ts).
10
12
  //
11
13
  // Like requests.ts, everything checks who is acting, so the built-in My
12
14
  // events page and a site's own pages share the rules. Hosts need no
@@ -17,7 +19,9 @@ import type { CalEvent, RequestRecord } from '../schema';
17
19
  import { ApiError, coded } from '../api';
18
20
  import type { SaveHooks } from '../content-service';
19
21
  import { GOOGLE_SYNC, checkMove, moveOccurrence, moveOccurrenceBack, type MoveInput, type MoveResult } from './moves';
20
- import { eventTimeZone, findOccurrence, type Occurrence } from './events';
22
+ import { changeFromDate, checkChangeFromDate, type ChangeFromInput, type ChangeFromResult } from './segments';
23
+ import { eventRoomIds, eventTimeZone, findOccurrence, type Occurrence } from './events';
24
+ import { describeRepeat } from './rrule';
21
25
  import { MEMBERS } from './members-config';
22
26
  import { siteLocale } from '../dates';
23
27
  import { kickEmail } from '../email/service';
@@ -38,6 +42,16 @@ export interface MoveRequestData {
38
42
  back?: boolean;
39
43
  }
40
44
 
45
+ /** RequestRecord.data of a 'from-date' request: the series from its `key` on (ChangeFromInput, checked); `rrule` '' = just that date */
46
+ export interface FromDateRequestData {
47
+ start: string;
48
+ end: string;
49
+ rrule: string;
50
+ roomIds?: string[];
51
+ /** the note in the email to people who signed up */
52
+ note: string;
53
+ }
54
+
41
55
  /** What a host's move did: moved at once (`moved`), or became a request (`request`). */
42
56
  export type HostMoveResult = { moved: MoveResult; request?: undefined } | { request: RequestRecord; moved?: undefined };
43
57
 
@@ -100,15 +114,40 @@ export async function requestDateMove(repo: ContentRepository, who: HostActor |
100
114
  const data: MoveRequestData = input && !check.back
101
115
  ? { start: check.start!, end: check.end!, ...(input.roomIds !== undefined ? { roomIds: [...new Set(input.roomIds)] } : {}), note: check.note ?? '' }
102
116
  : { start: '', end: '', note: '', back: true };
103
- const note = text(message, LIMITS.note);
104
- const waiting = (await repo.requests.list({ eventId: id, userId: who!.id, kinds: ['move'], states: ['pending'], limit: -1 })).find((r) => r.key === key);
117
+ return saveRequest(repo, who!, id, 'move', key, data, text(message, LIMITS.note), hooks);
118
+ }
119
+
120
+ /**
121
+ * A host asks staff to change their event from one date on (new first date,
122
+ * times, repeat, rooms): checked like the change itself, then saved as a
123
+ * pending 'from-date' request. Asking again from the same date replaces the
124
+ * waiting one. Rooms: the event's own and the requestable ones.
125
+ */
126
+ export async function requestFromDate(repo: ContentRepository, who: HostActor | null, id: string, key: string, input: ChangeFromInput, message: string, hooks: SaveHooks): Promise<RequestRecord> {
127
+ const e = await hosted(repo, who, id);
128
+ if (!e.hostIds.includes(who!.id)) throw coded(404, 'not-found', 'Event not found.');
129
+ if (input.roomIds !== undefined) {
130
+ const allowed = new Set([...eventRoomIds(e), ...(await repo.listResources()).filter((r) => r.requestable).map((r) => r.id)]);
131
+ if (input.roomIds.some((r) => !allowed.has(r))) throw coded(403, 'room-not-allowed', 'Pick from the rooms this event uses, or ask the team for another room.');
132
+ }
133
+ const check = await checkChangeFromDate(repo, who!, id, key, { ...input, location: undefined });
134
+ if (!check.changed) throw coded(400, 'unchanged', 'The dates from then on are already like that.');
135
+ if (!await repo.email.rateLimit(`event-request:${who!.id}`, 20, 3_600_000)) throw coded(429, 'rate-limit', 'That’s a lot of requests in an hour. Try again later.', 3600);
136
+ const g = check.segment;
137
+ const data: FromDateRequestData = { start: g.start, end: g.end, rrule: g.rrule, ...(input.roomIds !== undefined ? { roomIds: [...new Set(input.roomIds)] } : {}), note: g.note };
138
+ return saveRequest(repo, who!, e.id, 'from-date', key, data, text(message, LIMITS.note), hooks);
139
+ }
140
+
141
+ // a host's waiting request about this date (and kind) is replaced; else a new one
142
+ async function saveRequest(repo: ContentRepository, who: HostActor, eventId: string, kind: 'move' | 'from-date', key: string, data: object, note: string, hooks: SaveHooks) {
143
+ const waiting = (await repo.requests.list({ eventId, userId: who.id, kinds: [kind], states: ['pending'], limit: -1 })).find((r) => r.key === key);
105
144
  let saved: RequestRecord | null;
106
145
  if (waiting) saved = await repo.requests.update(waiting.id, { data: { ...data }, note }, { state: 'pending' });
107
146
  else {
108
- if (await repo.requests.count({ userId: who!.id, states: ['pending'] }) >= LIMITS.pending) throw coded(429, 'too-many-waiting', 'You have many requests waiting already. Wait for the team to answer them first.');
147
+ if (await repo.requests.count({ userId: who.id, states: ['pending'] }) >= LIMITS.pending) throw coded(429, 'too-many-waiting', 'You have many requests waiting already. Wait for the team to answer them first.');
109
148
  const now = Date.now();
110
- saved = await repo.requests.create({ id: crypto.randomUUID(), kind: 'move', state: 'pending', source: 'member', userId: who!.id, requester: null,
111
- eventId: id, key, data: { ...data }, note, reply: '', reviewedBy: '', reviewedAt: 0, createdAt: now, updatedAt: now });
149
+ saved = await repo.requests.create({ id: crypto.randomUUID(), kind, state: 'pending', source: 'member', userId: who.id, requester: null,
150
+ eventId, key, data: { ...data }, note, reply: '', reviewedBy: '', reviewedAt: 0, createdAt: now, updatedAt: now });
112
151
  }
113
152
  if (!saved) throw coded(409, 'answered', 'That request was just answered. Look at My events again.');
114
153
  await alertStaff(repo, saved, hooks);
@@ -162,10 +201,19 @@ async function answerable(repo: ContentRepository, id: string, seen?: number): P
162
201
  }
163
202
 
164
203
  /** Staff approve a host's move request: the move runs as them (room clashes warn), recorded as the host's, and the host is emailed. Pass `seen` (the request's updatedAt they reviewed). */
165
- export async function approveDateRequest(repo: ContentRepository, who: HostActor | null, id: string, hooks: SaveHooks, seen?: number): Promise<{ request: RequestRecord; moved: MoveResult }> {
204
+ export async function approveDateRequest(repo: ContentRepository, who: HostActor | null, id: string, hooks: SaveHooks, seen?: number): Promise<{ request: RequestRecord; moved?: MoveResult; changed?: ChangeFromResult }> {
166
205
  requireApprover(who);
167
206
  const r = await answerable(repo, id, seen);
168
- if (r.kind !== 'move') throw coded(400, 'unavailable', 'Open the event to apply this change.');
207
+ if (r.kind === 'from-date') {
208
+ const f = r.data as FromDateRequestData;
209
+ const when = await dateRequestWhen(repo, r);
210
+ const request = await claim(repo, r, who);
211
+ let changed: ChangeFromResult;
212
+ try { changed = await changeFromDate(repo, who, r.eventId, r.key, { start: f.start, end: f.end, rrule: f.rrule, roomIds: f.roomIds, note: f.note }, hooks); }
213
+ catch (err) { await release(repo, id); throw err; }
214
+ await tellHost(repo, request, 'from-date-approved', '', when, hooks);
215
+ return { request, changed };
216
+ }
169
217
  const d = r.data as MoveRequestData;
170
218
  // worded before it moves: "where it was → where it goes"
171
219
  const when = await dateRequestWhen(repo, r);
@@ -210,11 +258,18 @@ export async function dateRequestTimes(repo: ContentRepository, r: RequestRecord
210
258
  const settings = await repo.getSettings();
211
259
  const d = r.data as MoveRequestData;
212
260
  const tz = e.allDay ? settings.timezone || 'UTC' : eventTimeZone(e, settings);
213
- const fmt = (o: Occurrence | null) => {
261
+ const fmt = (o: Pick<Occurrence, 'start'> | null) => {
214
262
  if (!o) return '';
215
263
  try { return new Intl.DateTimeFormat(siteLocale(settings), e.allDay ? { weekday: 'short', month: 'short', day: 'numeric', timeZone: tz } : { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZone: tz }).format(o.start); }
216
264
  catch { return new Date(o.start).toISOString(); }
217
265
  };
266
+ if (r.kind === 'from-date') {
267
+ // "Thu, Jan 17, 6:00 PM" → "Wed, Jan 16, 5:00 PM · Weekly on Wednesday": the new first date and its repeat
268
+ const f = r.data as FromDateRequestData;
269
+ const first = findOccurrence({ ...e, start: f.start, end: f.end, rrule: f.rrule, segments: [], movedDates: {}, cancelledDates: [], exdates: [] }, f.start, settings);
270
+ const at = findOccurrence({ ...e, movedDates: {} }, r.key, settings);
271
+ return { from: fmt(at), to: [fmt(first), describeRepeat(f.rrule, f.start)].filter(Boolean).join(' · ') };
272
+ }
218
273
  const to = d.back ? findOccurrence({ ...e, movedDates: {} }, r.key, settings)
219
274
  : findOccurrence({ ...e, movedDates: { ...e.movedDates, [r.key]: { start: d.start, end: d.end, note: d.note, by: '', at: 0, ...(d.roomIds ? { roomIds: d.roomIds } : {}) } } }, r.key, settings);
220
275
  return { from: fmt(findOccurrence(e, r.key, settings)), to: fmt(to) };