@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,8 +7,12 @@
7
7
  // date of a series is an override VEVENT (same UID, RECURRENCE-ID) with
8
8
  // STATUS:CANCELLED, so calendar apps strike that one date; a moved date is an
9
9
  // override with its new DTSTART/DTEND (and its own place), so they show it there.
10
+ // A series changed from a date on (CalEvent.segments) is one VEVENT per part,
11
+ // the way Google writes "this and following": the event's own rule keeps its
12
+ // UID and stops with an UNTIL just before the change, and each segment is a
13
+ // recurring event of its own (UID `<id>-<from>`), with its own overrides.
10
14
  import type { CalEvent, Calendar } from '../schema';
11
- import { addWallDays, eventInstants, eventTimeZone, findOccurrence, locationLine, parseWall, type Wall } from './events';
15
+ import { addWallDays, cutsAt, eventInstants, eventTimeZone, expandEvents, findOccurrence, locationLine, parseWall, seriesParts, type SeriesPart, type Wall } from './events';
12
16
  import { zonedToUtc } from './ics';
13
17
  import { cleanRrule, rruleMap } from './rrule';
14
18
 
@@ -72,18 +76,64 @@ export interface FeedOptions {
72
76
  }
73
77
 
74
78
  export function eventToVevent(e: CalEvent, siteTz: string, opts: FeedOptions): string[] {
75
- const s = parseWall(e.start);
79
+ const parts = seriesParts(e);
80
+ if (parts.length === 1) return partVevent(e, e, '', siteTz, opts);
81
+ return parts.flatMap((p) => {
82
+ const rule = ruleUntil(e, p, siteTz);
83
+ // a part whose dates all went to the next one has nothing to say
84
+ if (rule === null) return [];
85
+ // its own dates' overrides only: the dates the series puts in this part
86
+ const mine = (key: string) => (findOccurrence(e, key, { timezone: siteTz }, siteTz || 'UTC')?.segment ?? '') === p.from;
87
+ const part: CalEvent = { ...e, start: p.start, end: p.end, rrule: rule, cancelled: e.cancelled || p.cancelled, location: p.location ?? e.location, segments: [],
88
+ cancelledDates: e.cancelledDates.filter(mine), movedDates: Object.fromEntries(Object.entries(e.movedDates).filter(([k]) => mine(k))) };
89
+ return partVevent(e, part, p.from ? `-${p.from.replace(/[-:]/g, '')}` : '', siteTz, opts);
90
+ });
91
+ }
92
+
93
+ // A part's rule for the feed: stopped just before the next part's `from`
94
+ // (RFC 5545 UNTIL is inclusive: the instant before it for timed events, the
95
+ // day before for all-day ones). COUNT becomes that UNTIL when the rule would
96
+ // still have dates there; null when the part has none left before it.
97
+ function ruleUntil(e: CalEvent, p: SeriesPart, siteTz: string): string | null {
98
+ const clean = cleanRrule(p.rrule);
99
+ if (!p.until || !clean) return clean;
100
+ const cut = parseWall(p.until);
101
+ if (!cut) return clean;
102
+ const tz = eventTimeZone(e, { timezone: siteTz });
103
+ const settings = { timezone: siteTz };
104
+ const solo: CalEvent = { ...e, start: p.start, end: p.end, rrule: clean, segments: [], movedDates: {}, cancelledDates: [] };
105
+ // parts stop at the day of the next one's `from` (events.ts cutsAt)
106
+ const cutMs = zonedToUtc(cut.y, cut.m, cut.d, 0, 0, 0, e.allDay ? siteTz || 'UTC' : tz);
107
+ const first = parseWall(p.start);
108
+ const startMs = first ? (e.allDay ? zonedToUtc(first.y, first.m, first.d, 0, 0, 0, siteTz || 'UTC') : zonedToUtc(first.y, first.m, first.d, first.hh, first.mm, 0, tz)) : cutMs;
109
+ if (startMs >= cutMs) return null;
110
+ // the rule would carry on past the cut (within the next two years): stop it there
111
+ const later = expandEvents([solo], settings, { displayTz: siteTz || 'UTC', now: cutMs, windowStartMs: cutMs, windowEndMs: cutMs + 800 * 86_400_000 }).some((o) => cutsAt(p.until, o.key));
112
+ if (!later) return clean;
113
+ const parts = rruleMap(clean);
114
+ delete parts.COUNT;
115
+ if (e.allDay) {
116
+ const before = addWallDays({ ...cut, dateOnly: true }, -1);
117
+ parts.UNTIL = `${before.y}${pad(before.m)}${pad(before.d)}`;
118
+ } else parts.UNTIL = utcStamp(cutMs - 1000);
119
+ return Object.entries(parts).map(([k, v]) => `${k}=${v}`).join(';');
120
+ }
121
+
122
+ // one VEVENT (and its date overrides) for an event, or for one part of it
123
+ // (`e` the event as stored, `part` the part as an event of its own)
124
+ function partVevent(e: CalEvent, part: CalEvent, uidSuffix: string, siteTz: string, opts: FeedOptions): string[] {
125
+ const s = parseWall(part.start);
76
126
  if (!s) return [];
77
- const en = parseWall(e.end) ?? s;
127
+ const en = parseWall(part.end) ?? s;
78
128
  const tz = eventTimeZone(e, { timezone: siteTz });
79
- const rule = feedRrule(e.rrule, e.allDay, tz);
80
- const lines: string[] = ['BEGIN:VEVENT', `UID:${e.id}@${opts.host}`, `DTSTAMP:${utcStamp(opts.now ?? Date.now())}`];
129
+ const rule = feedRrule(part.rrule, e.allDay, tz);
130
+ const lines: string[] = ['BEGIN:VEVENT', `UID:${e.id}${uidSuffix}@${opts.host}`, `DTSTAMP:${utcStamp(opts.now ?? Date.now())}`];
81
131
  if (e.allDay) {
82
132
  lines.push(`DTSTART;VALUE=DATE:${dateValue(s)}`, `DTEND;VALUE=DATE:${dateValue(addWallDays({ ...en, dateOnly: true }, 1))}`);
83
133
  } else if (rule) {
84
134
  lines.push(`DTSTART;TZID=${tz}:${localValue(s)}`, `DTEND;TZID=${tz}:${localValue(en)}`);
85
135
  } else {
86
- const { startAt, endAt } = eventInstants(e, tz);
136
+ const { startAt, endAt } = eventInstants(part, tz);
87
137
  lines.push(`DTSTART:${utcStamp(startAt)}`, `DTEND:${utcStamp(endAt)}`);
88
138
  }
89
139
  if (rule) lines.push(`RRULE:${rule}`);
@@ -93,24 +143,24 @@ export function eventToVevent(e: CalEvent, siteTz: string, opts: FeedOptions): s
93
143
  }
94
144
  const url = opts.origin + opts.eventUrl(e);
95
145
  lines.push(`SUMMARY:${escapeText(e.title || 'Untitled event')}`);
96
- const loc = locationLine(e.location);
146
+ const loc = locationLine(part.location);
97
147
  if (loc) lines.push(`LOCATION:${escapeText(loc)}`);
98
148
  // RFC 5545 GEO: latitude;longitude
99
- if (typeof e.location.lat === 'number' && typeof e.location.lng === 'number') lines.push(`GEO:${e.location.lat.toFixed(6)};${e.location.lng.toFixed(6)}`);
100
- const desc = [e.excerpt, e.location.url && e.location.url !== loc ? e.location.url : '', url].filter(Boolean).join('\n\n');
149
+ if (typeof part.location.lat === 'number' && typeof part.location.lng === 'number') lines.push(`GEO:${part.location.lat.toFixed(6)};${part.location.lng.toFixed(6)}`);
150
+ const desc = [e.excerpt, part.location.url && part.location.url !== loc ? part.location.url : '', url].filter(Boolean).join('\n\n');
101
151
  lines.push(`DESCRIPTION:${escapeText(desc)}`, `URL:${url}`);
102
152
  const cats = e.calendarIds.map((id) => opts.calendars?.get(id)?.name).filter((n): n is string => Boolean(n));
103
153
  if (cats.length) lines.push(`CATEGORIES:${cats.map(escapeText).join(',')}`);
104
- if (e.cancelled) lines.push('STATUS:CANCELLED');
154
+ if (part.cancelled) lines.push('STATUS:CANCELLED');
105
155
  const stamps = [`LAST-MODIFIED:${utcStamp(e.updatedAt)}`, `SEQUENCE:${Math.max(0, Math.floor((e.updatedAt - e.createdAt) / 1000)) % 2147483647}`];
106
156
  lines.push(...stamps, 'END:VEVENT');
107
- if (rule && !e.cancelled) lines.push(...cancelledOverrides(e, s, en, tz, siteTz, url, opts, stamps), ...movedOverrides(e, tz, siteTz, url, opts, stamps));
157
+ if (rule && !part.cancelled) lines.push(...cancelledOverrides(part, s, en, tz, siteTz, url, uidSuffix, opts, stamps), ...movedOverrides(part, tz, siteTz, url, uidSuffix, opts, stamps));
108
158
  return lines;
109
159
  }
110
160
 
111
161
  // one override per moved date that is still part of the series (and not
112
162
  // 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[] {
163
+ function movedOverrides(e: CalEvent, tz: string, siteTz: string, url: string, uidSuffix: string, opts: FeedOptions, stamps: string[]): string[] {
114
164
  const out: string[] = [];
115
165
  const cancelled = new Set(e.cancelledDates ?? []);
116
166
  for (const key of Object.keys(e.movedDates ?? {}).sort()) {
@@ -123,7 +173,7 @@ function movedOverrides(e: CalEvent, tz: string, siteTz: string, url: string, op
123
173
  const place = m.location ?? e.location;
124
174
  const loc = locationLine(place);
125
175
  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,
176
+ out.push('BEGIN:VEVENT', `UID:${e.id}${uidSuffix}@${opts.host}`, `DTSTAMP:${utcStamp(opts.now ?? Date.now())}`, ...when,
127
177
  `SUMMARY:${escapeText(e.title || 'Untitled event')}`, ...(loc ? [`LOCATION:${escapeText(loc)}`] : []),
128
178
  `DESCRIPTION:${escapeText(desc)}`, `URL:${url}`, ...stamps, 'END:VEVENT');
129
179
  }
@@ -132,7 +182,7 @@ function movedOverrides(e: CalEvent, tz: string, siteTz: string, url: string, op
132
182
 
133
183
  // one override per cancelled date that is still part of the series (keys
134
184
  // left behind by a moved series are ignored)
135
- function cancelledOverrides(e: CalEvent, s: Wall, en: Wall, tz: string, siteTz: string, url: string, opts: FeedOptions, stamps: string[]): string[] {
185
+ function cancelledOverrides(e: CalEvent, s: Wall, en: Wall, tz: string, siteTz: string, url: string, uidSuffix: string, opts: FeedOptions, stamps: string[]): string[] {
136
186
  const out: string[] = [];
137
187
  const toMin = (w: Wall) => Date.UTC(w.y, w.m - 1, w.d, w.hh, w.mm) / 60_000;
138
188
  const lenMin = e.allDay ? (toMin({ ...en, hh: 0, mm: 0 }) - toMin({ ...s, hh: 0, mm: 0 })) + 1440 : Math.max(0, toMin(en) - toMin(s));
@@ -144,7 +194,7 @@ function cancelledOverrides(e: CalEvent, s: Wall, en: Wall, tz: string, siteTz:
144
194
  const when = e.allDay
145
195
  ? [`RECURRENCE-ID;VALUE=DATE:${dateValue(w)}`, `DTSTART;VALUE=DATE:${dateValue(w)}`, `DTEND;VALUE=DATE:${dateValue(end)}`]
146
196
  : [`RECURRENCE-ID;TZID=${tz}:${localValue(w)}`, `DTSTART;TZID=${tz}:${localValue(w)}`, `DTEND;TZID=${tz}:${localValue(end)}`];
147
- out.push('BEGIN:VEVENT', `UID:${e.id}@${opts.host}`, `DTSTAMP:${utcStamp(opts.now ?? Date.now())}`, ...when,
197
+ out.push('BEGIN:VEVENT', `UID:${e.id}${uidSuffix}@${opts.host}`, `DTSTAMP:${utcStamp(opts.now ?? Date.now())}`, ...when,
148
198
  `SUMMARY:${escapeText(e.title || 'Untitled event')}`, `URL:${url}`, 'STATUS:CANCELLED', ...stamps, 'END:VEVENT');
149
199
  }
150
200
  return out;
@@ -16,7 +16,7 @@ export interface MemberStrings {
16
16
  /** after an action (?sent=1 …) */
17
17
  flash: {
18
18
  sent: string; updated: string; changes: string; withdrawn: string; cancelled: string; restored: string;
19
- moved: string; movedBack: string; moveSent: string; fromDateSent: string; dismissed: string;
19
+ moved: string; movedBack: string; moveSent: string; fromDateSent: string; fromDateDone: string; dismissed: string;
20
20
  };
21
21
  requests: string;
22
22
  upcoming: string;
@@ -88,6 +88,9 @@ export interface MemberStrings {
88
88
  changeFromRequested: string;
89
89
  weekday: string;
90
90
  everyWeek: string;
91
+ firstDate: string;
92
+ sameRepeat: string;
93
+ justThisDate: string;
91
94
  /** the request form */
92
95
  form: {
93
96
  title: string; when: string; date: string; starts: string; ends: string; optional: string; allDay: string; allDayHint: string;
@@ -129,6 +132,7 @@ const EN: MemberStrings = {
129
132
  movedBack: 'That date is back at its usual time. People who signed up were emailed.',
130
133
  moveSent: 'Your move is with the team. The date stays as it is until they approve it.',
131
134
  fromDateSent: 'Your schedule change is with the team. Nothing changes until they apply it.',
135
+ fromDateDone: 'Changed from that date on. People who signed up were emailed their new dates.',
132
136
  dismissed: 'Cleared.',
133
137
  },
134
138
  requests: 'Requests', upcoming: 'Upcoming', pastEvents: 'Past events ({count})', showingRecent: 'Showing the {count} most recent.',
@@ -151,6 +155,7 @@ const EN: MemberStrings = {
151
155
  changeFrom: 'Change from this date on…', changeFromLabel: 'Change {title} from {when} on',
152
156
  changeFromHint: 'For a lasting change, like a new day or time. Dates before this one stay as they are. The team applies it, and people who signed up hear about their new dates.',
153
157
  changeFromRequested: 'Change from {when} on waiting for approval', weekday: 'Day', everyWeek: 'Every week',
158
+ firstDate: 'First date', sameRepeat: 'Same as now, from the new day', justThisDate: 'Just that date',
154
159
  form: {
155
160
  title: 'Title', when: 'When', date: 'Date', starts: 'Starts', ends: 'Ends', optional: '(optional)', allDay: 'All day', allDayHint: 'No start or end time',
156
161
  multiDay: 'This event runs over {days} days. Changing the date moves all of them.', repeats: 'Repeats', where: 'Where',
@@ -197,6 +202,7 @@ const ES: MemberStrings = {
197
202
  movedBack: 'La fecha volvió a su horario de siempre. Avisamos por correo a quienes se inscribieron.',
198
203
  moveSent: 'Tu cambio de fecha ya lo tiene el equipo. La fecha sigue igual hasta que lo aprueben.',
199
204
  fromDateSent: 'Tu cambio de horario ya lo tiene el equipo. Nada cambia hasta que lo apliquen.',
205
+ fromDateDone: 'Se cambió desde esa fecha. Avisamos por correo a quienes se inscribieron.',
200
206
  dismissed: 'Listo.',
201
207
  },
202
208
  requests: 'Solicitudes', upcoming: 'Próximos', pastEvents: 'Eventos pasados ({count})', showingRecent: 'Se muestran los {count} más recientes.',
@@ -219,6 +225,7 @@ const ES: MemberStrings = {
219
225
  changeFrom: 'Cambiar desde esta fecha…', changeFromLabel: 'Cambiar {title} desde el {when}',
220
226
  changeFromHint: 'Para un cambio duradero, como un nuevo día u horario. Las fechas anteriores quedan igual. El equipo lo aplica, y a quienes se inscribieron les avisamos sus nuevas fechas.',
221
227
  changeFromRequested: 'Cambio desde el {when} en espera de aprobación', weekday: 'Día', everyWeek: 'Cada semana',
228
+ firstDate: 'Primera fecha', sameRepeat: 'Igual que ahora, desde el nuevo día', justThisDate: 'Solo esa fecha',
222
229
  form: {
223
230
  title: 'Título', when: 'Cuándo', date: 'Fecha', starts: 'Empieza', ends: 'Termina', optional: '(opcional)', allDay: 'Todo el día', allDayHint: 'Sin hora de inicio ni de fin',
224
231
  multiDay: 'Este evento dura {days} días. Si cambias la fecha, se mueven todos.', repeats: 'Se repite', where: 'Dónde',
@@ -265,6 +272,7 @@ const FR: MemberStrings = {
265
272
  movedBack: 'La date revient à son horaire habituel. Les inscrits ont été prévenus par e-mail.',
266
273
  moveSent: 'L’équipe a reçu votre déplacement. La date reste telle quelle jusqu’à son approbation.',
267
274
  fromDateSent: 'L’équipe a reçu votre changement d’horaire. Rien ne change avant qu’elle l’applique.',
275
+ fromDateDone: 'Changé à partir de cette date. Les inscrits ont été prévenus par e-mail.',
268
276
  dismissed: 'C’est effacé.',
269
277
  },
270
278
  requests: 'Demandes', upcoming: 'À venir', pastEvents: 'Événements passés ({count})', showingRecent: 'Les {count} plus récents sont affichés.',
@@ -287,6 +295,7 @@ const FR: MemberStrings = {
287
295
  changeFrom: 'Changer à partir de cette date…', changeFromLabel: 'Changer {title} à partir du {when}',
288
296
  changeFromHint: 'Pour un changement durable, comme un nouveau jour ou horaire. Les dates précédentes restent telles quelles. L’équipe l’applique, et les inscrits sont prévenus de leurs nouvelles dates.',
289
297
  changeFromRequested: 'Changement à partir du {when} en attente d’approbation', weekday: 'Jour', everyWeek: 'Chaque semaine',
298
+ firstDate: 'Première date', sameRepeat: 'Comme maintenant, à partir du nouveau jour', justThisDate: 'Seulement cette date',
290
299
  form: {
291
300
  title: 'Titre', when: 'Quand', date: 'Date', starts: 'Début', ends: 'Fin', optional: '(facultatif)', allDay: 'Toute la journée', allDayHint: 'Sans heure de début ni de fin',
292
301
  multiDay: 'Cet événement dure {days} jours. Changer la date les déplace tous.', repeats: 'Répétition', where: 'Lieu',
@@ -333,6 +342,7 @@ const DE: MemberStrings = {
333
342
  movedBack: 'Der Termin ist wieder zur gewohnten Zeit. Wer angemeldet ist, hat eine E-Mail bekommen.',
334
343
  moveSent: 'Das Team hat deine Verlegung. Der Termin bleibt, wie er ist, bis sie freigegeben ist.',
335
344
  fromDateSent: 'Das Team hat deine Planänderung. Es ändert sich nichts, bis sie übernommen ist.',
345
+ fromDateDone: 'Ab diesem Termin geändert. Angemeldete haben ihre neuen Termine per E-Mail bekommen.',
336
346
  dismissed: 'Erledigt.',
337
347
  },
338
348
  requests: 'Anfragen', upcoming: 'Demnächst', pastEvents: 'Vergangene Termine ({count})', showingRecent: 'Die {count} neuesten werden gezeigt.',
@@ -355,6 +365,7 @@ const DE: MemberStrings = {
355
365
  changeFrom: 'Ab diesem Termin ändern…', changeFromLabel: '{title} ab {when} ändern',
356
366
  changeFromHint: 'Für eine dauerhafte Änderung, etwa ein neuer Tag oder eine neue Zeit. Frühere Termine bleiben, wie sie sind. Das Team übernimmt sie, und Angemeldete erfahren ihre neuen Termine.',
357
367
  changeFromRequested: 'Änderung ab {when} wartet auf Freigabe', weekday: 'Tag', everyWeek: 'Jede Woche',
368
+ firstDate: 'Erster Termin', sameRepeat: 'Wie bisher, ab dem neuen Tag', justThisDate: 'Nur dieser Termin',
358
369
  form: {
359
370
  title: 'Titel', when: 'Wann', date: 'Datum', starts: 'Beginn', ends: 'Ende', optional: '(optional)', allDay: 'Ganztägig', allDayHint: 'Ohne Beginn- und Endzeit',
360
371
  multiDay: 'Dieser Termin dauert {days} Tage. Ein neues Datum verschiebt alle.', repeats: 'Wiederholung', where: 'Wo',
@@ -401,6 +412,7 @@ const PT: MemberStrings = {
401
412
  movedBack: 'A data voltou ao horário de sempre. Quem se inscreveu foi avisado por e-mail.',
402
413
  moveSent: 'A equipe recebeu sua mudança de data. A data continua como está até ser aprovada.',
403
414
  fromDateSent: 'A equipe recebeu sua mudança de horário. Nada muda até que ela seja aplicada.',
415
+ fromDateDone: 'Mudou a partir dessa data. Quem se inscreveu foi avisado por e-mail.',
404
416
  dismissed: 'Pronto.',
405
417
  },
406
418
  requests: 'Pedidos', upcoming: 'Próximos', pastEvents: 'Eventos passados ({count})', showingRecent: 'Mostrando os {count} mais recentes.',
@@ -423,6 +435,7 @@ const PT: MemberStrings = {
423
435
  changeFrom: 'Mudar a partir desta data…', changeFromLabel: 'Mudar {title} a partir de {when}',
424
436
  changeFromHint: 'Para uma mudança duradoura, como um novo dia ou horário. As datas anteriores continuam como estão. A equipe aplica, e quem se inscreveu é avisado das novas datas.',
425
437
  changeFromRequested: 'Mudança a partir de {when} aguardando aprovação', weekday: 'Dia', everyWeek: 'Toda semana',
438
+ firstDate: 'Primeira data', sameRepeat: 'Como agora, a partir do novo dia', justThisDate: 'Só essa data',
426
439
  form: {
427
440
  title: 'Título', when: 'Quando', date: 'Data', starts: 'Começa', ends: 'Termina', optional: '(opcional)', allDay: 'Dia inteiro', allDayHint: 'Sem hora de início ou fim',
428
441
  multiDay: 'Este evento dura {days} dias. Mudar a data move todos eles.', repeats: 'Repete', where: 'Onde',
@@ -20,7 +20,7 @@ import type { ContentRepository } from '../repo/types';
20
20
  import { EventLocation, type CalEvent, type MovedDate } from '../schema';
21
21
  import { ApiError, coded } from '../api';
22
22
  import { eventSideEffects, type SaveHooks } from '../content-service';
23
- import { addWallDays, addWallMinutes, eventRoomIds, eventTimeZone, expandEvents, findOccurrence, formatWall, movedInstants, normalizeEventTimes, parseWall, wallMinutesBetween, type Occurrence } from './events';
23
+ import { addWallDays, addWallMinutes, eventRoomIds, eventTimeZone, expandEvents, findOccurrence, formatWall, movedInstants, normalizeEventTimes, parseWall, wallMinutesBetween, type Occurrence, partOf, partMinutes, type SeriesPart } from './events';
24
24
  import { holdsFromEvents, roomBusy, type BusySlot } from '../booking/holds';
25
25
  import { kickEmail } from '../email/service';
26
26
  import { waitUntilFrom } from '../webhooks';
@@ -103,14 +103,14 @@ const started = () => refuse(400, 'started', 'That date has already started or p
103
103
 
104
104
  // The new wall times: `start` in the event's form, `end` defaulting to the
105
105
  // date's own length (all-day: the same number of days)
106
- function newTimes(e: CalEvent, input: MoveInput): { start: string; end: string } {
106
+ function newTimes(e: CalEvent, input: MoveInput, part: SeriesPart): { start: string; end: string } {
107
107
  const s = parseWall(input.start);
108
108
  if (!s) throw refuse(400, 'bad-time', e.allDay ? 'Pick a date (YYYY-MM-DD).' : 'Pick a date and time (YYYY-MM-DDTHH:mm).');
109
109
  if (s.dateOnly !== e.allDay) throw refuse(400, 'bad-time', e.allDay ? 'This event lasts all day: give a date only (YYYY-MM-DD).' : 'Give a date and a time (YYYY-MM-DDTHH:mm).');
110
110
  let end = input.end;
111
111
  if (!end) {
112
- const es = parseWall(e.start), ee = parseWall(e.end) ?? es;
113
- const length = es && ee ? wallMinutesBetween(es, ee) : 60;
112
+ // the length of its part's dates (a change from a date on may have its own)
113
+ const length = partMinutes(part);
114
114
  end = formatWall(e.allDay ? addWallDays(s, Math.round(length / 1440)) : addWallMinutes(s, Math.max(1, length)));
115
115
  }
116
116
  try {
@@ -162,7 +162,9 @@ async function planMove(repo: ContentRepository, actor: MoveActor | null, id: st
162
162
  const settings = await repo.getSettings();
163
163
  const current = dateToChange(e, key, settings);
164
164
  const prior = e.movedDates[key];
165
- const times = newTimes(e, input);
165
+ // its part of the series (CalEvent.segments): the length, rooms and place it has without a move
166
+ const part = partOf(e, current);
167
+ const times = newTimes(e, input, part);
166
168
 
167
169
  let roomIds = input.roomIds === undefined ? prior?.roomIds : [...new Set(input.roomIds)];
168
170
  if (roomIds !== undefined) {
@@ -175,11 +177,11 @@ async function planMove(repo: ContentRepository, actor: MoveActor | null, id: st
175
177
  if (roomIds.some((r) => !allowed.has(r))) throw refuse(403, 'room-not-allowed', 'Pick from the rooms this event uses, or ask the team for another room.');
176
178
  }
177
179
  // the event's own rooms aren't an override
178
- if (sameRooms(roomIds, e.roomHold.resourceIds)) roomIds = undefined;
180
+ if (sameRooms(roomIds, part.roomIds)) roomIds = undefined;
179
181
  }
180
182
  let location = input.location === undefined ? prior?.location : input.location === null ? undefined : EventLocation.parse({ ...e.location, ...input.location });
181
183
  if (location && !location.name.trim() && !location.address.trim()) throw new ApiError(400, 'Give the place a name or an address.');
182
- if (location && JSON.stringify(location) === JSON.stringify(e.location)) location = undefined;
184
+ if (location && JSON.stringify(location) === JSON.stringify(part.location ?? e.location)) location = undefined;
183
185
  const note = input.note === undefined ? prior?.note ?? '' : String(input.note).replace(/\s+/g, ' ').trim().slice(0, NOTE_MAX);
184
186
 
185
187
  const entry: MovedDate = { start: times.start, end: times.end, ...(roomIds ? { roomIds } : {}), ...(location ? { location } : {}), note, by: by || actorId(actor!), at: now };
@@ -306,6 +308,8 @@ export interface DateRow {
306
308
  /** "Thu, Jan 10 · 6:00 – 7:00 PM" */
307
309
  when: string;
308
310
  cancelled: boolean;
311
+ /** cancelled because every date from some date on is (a cancelled part, CalEvent.segments) */
312
+ partCancelled: boolean;
309
313
  /** moved: "Thu, Jan 10 · 6:00 PM" (where it was) and the note */
310
314
  moved: { from: string; note: string } | null;
311
315
  roomIds: string[];
@@ -319,19 +323,21 @@ export function upcomingDates(e: CalEvent, settings: Awaited<ReturnType<ContentR
319
323
  const day = new Intl.DateTimeFormat(locale, { weekday: 'short', month: 'short', day: 'numeric', timeZone: tz });
320
324
  const time = new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit', timeZone: tz });
321
325
  const range = (a: number, b: number) => { try { return time.formatRange(a, b); } catch { return `${time.format(a)} – ${time.format(b)}`; } };
322
- const es = parseWall(e.start), ee = parseWall(e.end) ?? es;
323
- const length = es && ee ? wallMinutesBetween(es, ee) : 60;
324
326
  return expandEvents([e], settings, { displayTz: settings.timezone || 'UTC', now, windowStartMs: now, windowEndMs: now + 400 * DAY })
325
327
  .slice(0, limit)
326
328
  .map((o) => {
327
329
  const m = o.moved ? e.movedDates[o.key] : undefined;
328
330
  const k = parseWall(o.key)!;
331
+ const part = partOf(e, o);
332
+ const length = partMinutes(part);
329
333
  return {
330
334
  key: o.key,
331
335
  start: m?.start ?? o.key,
332
336
  end: m?.end ?? formatWall(e.allDay ? addWallDays(k, Math.round(length / 1440)) : addWallMinutes(k, length)),
333
337
  when: e.allDay ? day.format(o.start) : `${day.format(o.start)} · ${range(o.start, o.end)}`,
334
338
  cancelled: o.cancelled,
339
+ // cancelled with the rest of its part (cancelFromDate): restored from the change, not one by one
340
+ partCancelled: part.cancelled,
335
341
  moved: o.moved ? { from: e.allDay ? day.format(o.moved.from.start) : `${day.format(o.moved.from.start)} · ${time.format(o.moved.from.start)}`, note: o.moved.note } : null,
336
342
  roomIds: o.roomIds,
337
343
  };
@@ -110,7 +110,8 @@ export async function requestRows(repo: ContentRepository, settings: Settings, t
110
110
  ? { taken: true, text: `${found[0].room} on ${f.slot(found[0].start, found[0].end)}${found[0].title ? ` (${found[0].title})` : ''}${found.length > 1 ? ` and ${found.length - 1} more` : ''}` }
111
111
  : { taken: false, text: `${names} ${d.roomIds.length > 1 ? 'are' : 'is'} free then` };
112
112
  }
113
- } else if (q.kind === 'move') {
113
+ } else if (q.kind === 'move' || q.kind === 'from-date') {
114
+ // a host's date move, or change from a date on ("Thu, Jan 17, 6:00 PM → Wed, Jan 16, 5:00 PM · Weekly on Wednesday")
114
115
  title = e?.title ?? 'A deleted event';
115
116
  line = await dateRequestWhen(repo, q);
116
117
  if (tab === 'pending' && e) {
@@ -429,11 +429,11 @@ export async function approveRequest(repo: ContentRepository, who: Actor | null,
429
429
  if (!event) throw new ApiError(409, 'That event was deleted. Decline the request, or reply to the person.');
430
430
  done = await decide(repo, q, answer);
431
431
  next = `/admin/calendar/events/${event.id}${q.key ? `?move=${encodeURIComponent(q.key)}` : ''}`;
432
- } else if (q.kind === 'move') {
433
- // a host's date move (lib/calendar/host-requests.ts): it runs, and the host hears about it there
432
+ } else if (q.kind === 'move' || q.kind === 'from-date') {
433
+ // a host's date move or change from a date on (lib/calendar/host-requests.ts): it runs, and the host hears about it there
434
434
  const { approveDateRequest } = await import('./host-requests');
435
- const { request, moved } = await approveDateRequest(repo, who, id, hooks, seen);
436
- return { request, event: moved.event, next: `/admin/calendar/events/${request.eventId}` };
435
+ const { request, moved, changed } = await approveDateRequest(repo, who, id, hooks, seen);
436
+ return { request, event: (moved ?? changed)!.event, next: `/admin/calendar/events/${request.eventId}` };
437
437
  } else {
438
438
  throw new ApiError(400, 'Open the event to answer this request.');
439
439
  }
@@ -464,7 +464,7 @@ async function draftFrom(repo: ContentRepository, q: RequestRecord, hooks: SaveH
464
464
  export async function declineRequest(repo: ContentRepository, who: Actor | null, id: string, reply: string, hooks: SaveHooks, seen?: number, opts: { email?: boolean } = {}): Promise<RequestRecord> {
465
465
  requireAnswerer(who);
466
466
  const q = await pendingRequest(repo, id, seen);
467
- if (q.kind === 'move') return (await import('./host-requests')).declineDateRequest(repo, who, id, reply, hooks, seen);
467
+ if (q.kind === 'move' || q.kind === 'from-date') return (await import('./host-requests')).declineDateRequest(repo, who, id, reply, hooks, seen);
468
468
  if (q.kind !== 'new' && q.kind !== 'change') throw new ApiError(400, 'Open the event to answer this request.');
469
469
  const reason = text(reply, REQUEST_LIMITS.message);
470
470
  const done = await decide(repo, q, { state: 'declined', reviewedBy: who.id, reviewedAt: Date.now(), reply: reason });
@@ -6,12 +6,12 @@
6
6
  // Cancelling one date of a series applies at once too, without touching any
7
7
  // staged edits.
8
8
  import type { ContentRepository } from '../repo/types';
9
- import type { CalEvent, MovedDate } from '../schema';
9
+ import type { CalEvent, MovedDate, SeriesSegment } from '../schema';
10
10
  import { EventLocation, slugify } from '../schema';
11
11
  import { defaultPlaceLocation } from '../maps/places';
12
12
  import { ApiError } from '../api';
13
13
  import { applyEventUpdate, eventSideEffects, flushEventHooks, saveEvent, type SaveHooks } from '../content-service';
14
- import { addWallDays, addWallMinutes, findOccurrence, formatWall, parseWall, wallMinutesBetween } from './events';
14
+ import { addWallDays, addWallMinutes, findOccurrence, formatWall, parseWall, wallMinutesBetween, partOf, partMinutes } from './events';
15
15
  import { moveOccurrence, moveOccurrenceBack, type MoveActor } from './moves';
16
16
  import type { BusySlot } from '../booking/holds';
17
17
  import { rruleMap, WEEKDAY_CODES, weekdayOf } from './rrule';
@@ -105,6 +105,8 @@ const dayOf = (wall: string) => {
105
105
 
106
106
  export type MoveUndo =
107
107
  | { kind: 'series'; id: string; days: number; minutes?: number }
108
+ // this and following dates (lib/calendar/segments.ts): the segments as they were, and where the change took over
109
+ | { kind: 'following'; id: string; from: string; previous: SeriesSegment[] }
108
110
  // one date moved (previous: where it was moved before, null = not moved)
109
111
  | { kind: 'date'; id: string; key: string; previous: Pick<MovedDate, 'start' | 'end' | 'roomIds' | 'location' | 'note'> | null }
110
112
  // undo tokens from before moved dates: the date was split off into a copy
@@ -126,10 +128,16 @@ export async function moveSeries(repo: ContentRepository, id: string, days: numb
126
128
  ...(e.rrule ? { rrule: shiftRrule(e.rrule, dayShift, start), exdates: e.exdates.map((x) => shiftBy(x, total)) } : {}),
127
129
  }, hooks);
128
130
  // cancelled dates move with the series, like skipped ones; a moved date
129
- // keeps its new time and follows under its new key
130
- if (e.rrule && (e.cancelledDates.length || Object.keys(e.movedDates).length)) {
131
+ // keeps its new time and follows under its new key; a change from a date on
132
+ // (segments) moves as a whole: its change point, first date and rule
133
+ if (e.rrule && (e.cancelledDates.length || Object.keys(e.movedDates).length || e.segments.length)) {
131
134
  const moved = Object.fromEntries(Object.entries(e.movedDates).map(([k, m]) => [shiftBy(k, total), m]));
132
- await repo.setEventMovedDates(id, moved, e.cancelledDates.map((x) => shiftBy(x, total)));
135
+ const segments = e.segments.map((g) => {
136
+ const gs = shiftBy(g.start, total);
137
+ const shift = ((a, b) => (a && b ? Math.round(wallMinutesBetween(a, b) / 1440) : days))(dayOf(g.start), dayOf(gs));
138
+ return { ...g, from: shiftBy(g.from, total), start: gs, end: shiftBy(g.end, total), rrule: shiftRrule(g.rrule, shift, gs) };
139
+ });
140
+ await repo.setEventDates(id, { movedDates: moved, cancelledDates: e.cancelledDates.map((x) => shiftBy(x, total)), segments });
133
141
  await flushEventHooks(repo, hooks);
134
142
  event = (await repo.getEvent(id))!;
135
143
  }
@@ -149,21 +157,47 @@ export async function moveDateBy(repo: ContentRepository, actor: MoveActor, id:
149
157
  }
150
158
  if (!Number.isInteger(minutes) || Math.abs(minutes) > 1440 * 3660) throw new ApiError(400, 'Pick a day to move it to.');
151
159
  const prior = e.movedDates[key];
152
- const es = parseWall(e.start), ee = parseWall(e.end) ?? es;
153
160
  const k = parseWall(key);
154
- if (!k || !es || !ee) throw new ApiError(400, 'That date isn’t part of this event.');
161
+ const at = k && findOccurrence({ ...e, movedDates: {} }, key, await repo.getSettings());
162
+ if (!k || !at) throw new ApiError(400, 'That date isn’t part of this event.');
163
+ // its length is its part's (a change from a date on may have its own)
155
164
  const startWall = prior?.start ?? key;
156
- const endWall = prior?.end ?? formatWall(addWallMinutes(k, wallMinutesBetween(es, ee)));
165
+ const endWall = prior?.end ?? formatWall(addWallMinutes(k, partMinutes(partOf(e, at))));
157
166
  const r = await moveOccurrence(repo, actor, id, key, { start: shiftBy(startWall, minutes), end: shiftBy(endWall, minutes) }, hooks);
158
167
  const previous = prior ? { start: prior.start, end: prior.end, roomIds: prior.roomIds, location: prior.location, note: prior.note } : null;
159
168
  return { event: r.event, clashes: r.clashes, undo: { kind: 'date', id, key, previous } };
160
169
  }
161
170
 
171
+ // Move one date of a repeating event and every date after it by `minutes`
172
+ // (a drag on the Schedule, "This and following dates"): a change from that
173
+ // date on (lib/calendar/segments.ts) whose rule follows the new weekday or
174
+ // day, keeping each date's length. The dates before it stay where they are.
175
+ export async function moveFollowingBy(repo: ContentRepository, actor: MoveActor, id: string, key: string, minutes: number, hooks: SaveHooks): Promise<{ event: CalEvent; undo: MoveUndo; clashes: BusySlot[] }> {
176
+ const e = await repo.getEvent(id);
177
+ if (!e) throw new ApiError(404, 'Event not found.');
178
+ if (!Number.isInteger(minutes) || Math.abs(minutes) > 1440 * 3660) throw new ApiError(400, 'Pick a day to move it to.');
179
+ const { changeFromDate, changeFromDefaults } = await import('./segments');
180
+ const from = await changeFromDefaults(repo, id, key);
181
+ if (!from) throw new ApiError(400, 'That date isn’t part of this event.');
182
+ // a moved date was dragged from where it is now
183
+ const moved = e.movedDates[key];
184
+ const r = await changeFromDate(repo, actor, id, key, { start: shiftBy(moved?.start ?? from.start, minutes), end: shiftBy(moved?.end ?? from.end, minutes) }, hooks);
185
+ // undo puts the segments back, from the earliest one that changed
186
+ const same = (a: SeriesSegment, list: SeriesSegment[]) => list.some((x) => JSON.stringify(x) === JSON.stringify(a));
187
+ const changed = [...r.event.segments.filter((g) => !same(g, e.segments)), ...e.segments.filter((g) => !same(g, r.event.segments))].map((g) => g.from).sort();
188
+ return { event: r.event, clashes: r.clashes, undo: { kind: 'following', id, from: changed[0] ?? key, previous: e.segments } };
189
+ }
190
+
162
191
  export async function undoMove(repo: ContentRepository, actor: MoveActor, undo: MoveUndo, hooks: SaveHooks): Promise<void> {
163
192
  if (undo.kind === 'series') {
164
193
  await moveSeries(repo, undo.id, undo.days, hooks, undo.minutes ?? 0);
165
194
  return;
166
195
  }
196
+ if (undo.kind === 'following') {
197
+ const { restoreSegments } = await import('./segments');
198
+ await restoreSegments(repo, actor, undo.id, undo.from, undo.previous, hooks);
199
+ return;
200
+ }
167
201
  if (undo.kind === 'date') {
168
202
  if (undo.previous) await moveOccurrence(repo, actor, undo.id, undo.key, { ...undo.previous, roomIds: undo.previous.roomIds ?? [], location: undo.previous.location ?? null }, hooks);
169
203
  else await moveOccurrenceBack(repo, actor, undo.id, undo.key, hooks);