@acms-subir/core 0.3.46 → 0.3.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/cli/migrations.generated.mjs +42 -23
  3. package/docs/calendar.md +80 -0
  4. package/docs/server.md +28 -3
  5. package/docs/theme-authoring.md +1 -1
  6. package/package.json +1 -1
  7. package/src/actions/index.ts +76 -11
  8. package/src/components/EventPage.astro +18 -12
  9. package/src/components/calendar/CalendarBoard.tsx +43 -19
  10. package/src/components/editor/event/EventSheet.tsx +2 -0
  11. package/src/components/editor/event/SingleDates.tsx +123 -0
  12. package/src/lib/booking/holds.ts +7 -7
  13. package/src/lib/calendar/api.ts +11 -1
  14. package/src/lib/calendar/board.ts +7 -3
  15. package/src/lib/calendar/cards.ts +14 -11
  16. package/src/lib/calendar/change-migration.ts +40 -15
  17. package/src/lib/calendar/data.ts +34 -12
  18. package/src/lib/calendar/event-words.ts +133 -0
  19. package/src/lib/calendar/events.ts +94 -9
  20. package/src/lib/calendar/feeds.ts +11 -3
  21. package/src/lib/calendar/filter.ts +140 -0
  22. package/src/lib/calendar/ics-write.ts +25 -2
  23. package/src/lib/calendar/moves.ts +291 -0
  24. package/src/lib/calendar/render.ts +26 -18
  25. package/src/lib/calendar/schedule.ts +73 -61
  26. package/src/lib/email/booking-words.ts +7 -1
  27. package/src/lib/email/service.ts +6 -1
  28. package/src/lib/email/templates.ts +6 -2
  29. package/src/lib/mobile/calendar.ts +32 -14
  30. package/src/lib/mobile/http.ts +5 -3
  31. package/src/lib/mobile/schemas.ts +7 -3
  32. package/src/lib/push/service.ts +44 -10
  33. package/src/lib/push/types.ts +2 -0
  34. package/src/lib/repo/migrations.ts +5 -2
  35. package/src/lib/repo/sql.ts +28 -10
  36. package/src/lib/repo/types.ts +6 -3
  37. package/src/lib/schema.ts +21 -0
  38. package/src/lib/sections.ts +5 -2
  39. package/src/lib/sync/catalog.ts +1 -0
  40. package/src/pages/api/calendar/events/index.ts +24 -12
  41. package/src/pages/calendar/[slug].ics.ts +8 -2
  42. package/src/pages/calendar/feed.ics.ts +9 -2
  43. package/src/pages/calendar/index.astro +104 -48
  44. package/src/pages/embed/calendar/[slug].astro +29 -9
  45. package/src/pages/members/_Layout.astro +1 -0
  46. package/src/pages/members/events/index.astro +1 -1
  47. package/src/server/index.ts +33 -0
  48. package/src/styles/editor.css +19 -0
  49. package/src/styles/schedule.css +2 -0
  50. package/src/styles/site.css +16 -0
  51. package/src/theme-sdk/calendar.ts +3 -2
@@ -1,17 +1,19 @@
1
- // Writes behind the admin Schedule: quick add, drag to move (a whole series
2
- // or one occurrence), undo, duplicate. Everything goes through the same
1
+ // Writes behind the admin Schedule: quick add, drag to move (a whole series,
2
+ // or one date: a moved date, lib/calendar/moves.ts), undo, duplicate. Everything goes through the same
3
3
  // save/apply path as the event editor, so hooks, feeds, search and webhooks
4
4
  // see the same changes. A move applies at once, even on a published event —
5
5
  // the editor's "stage until Update" flow is for content edits, not dates.
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 } from '../schema';
9
+ import type { CalEvent, MovedDate } 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, findOccurrence, formatWall, parseWall } from './events';
14
+ import { addWallDays, addWallMinutes, findOccurrence, formatWall, parseWall, wallMinutesBetween } from './events';
15
+ import { moveOccurrence, moveOccurrenceBack, type MoveActor } from './moves';
16
+ import type { BusySlot } from '../booking/holds';
15
17
  import { rruleMap, WEEKDAY_CODES, weekdayOf } from './rrule';
16
18
  import { waitUntilFrom } from '../webhooks';
17
19
  import { kickEmail } from '../email/service';
@@ -91,78 +93,80 @@ export function shiftRrule(rrule: string, days: number, newStart: string): strin
91
93
  return Object.entries(p).sort(([a], [b]) => order.indexOf(a) - order.indexOf(b)).map(([k, v]) => `${k}=${v}`).join(';');
92
94
  }
93
95
 
94
- const shift = (wall: string, days: number) => {
96
+ // a wall time moved by whole minutes (date-only walls by whole days)
97
+ const shiftBy = (wall: string, minutes: number) => {
95
98
  const w = parseWall(wall);
96
- return w ? formatWall(addWallDays(w, days)) : wall;
99
+ return w ? formatWall(addWallMinutes(w, w.dateOnly ? Math.round(minutes / 1440) * 1440 : minutes)) : wall;
100
+ };
101
+ const dayOf = (wall: string) => {
102
+ const w = parseWall(wall);
103
+ return w ? { ...w, hh: 0, mm: 0, dateOnly: true } : null;
97
104
  };
98
105
 
99
106
  export type MoveUndo =
100
- | { kind: 'series'; id: string; days: number }
107
+ | { kind: 'series'; id: string; days: number; minutes?: number }
108
+ // one date moved (previous: where it was moved before, null = not moved)
109
+ | { kind: 'date'; id: string; key: string; previous: Pick<MovedDate, 'start' | 'end' | 'roomIds' | 'location' | 'note'> | null }
110
+ // undo tokens from before moved dates: the date was split off into a copy
101
111
  | { kind: 'split'; id: string; seriesId: string; occurrence: string };
102
112
 
103
- // Move a whole event (and every repeat) by `days`
104
- export async function moveSeries(repo: ContentRepository, id: string, days: number, hooks: SaveHooks): Promise<{ event: CalEvent; undo: MoveUndo }> {
113
+ // Move a whole event (and every repeat) by `days`, and `minutes` more (a drag
114
+ // in the week view; all-day events move by whole days only)
115
+ export async function moveSeries(repo: ContentRepository, id: string, days: number, hooks: SaveHooks, minutes = 0): Promise<{ event: CalEvent; undo: MoveUndo }> {
105
116
  const e = await repo.getEvent(id);
106
117
  if (!e) throw new ApiError(404, 'Event not found.');
107
- if (!Number.isInteger(days) || Math.abs(days) > 3660) throw new ApiError(400, 'Pick a day to move it to.');
108
- const start = shift(e.start, days);
118
+ if (!Number.isInteger(days) || Math.abs(days) > 3660 || !Number.isInteger(minutes) || Math.abs(minutes) > 1440 * 7) throw new ApiError(400, 'Pick a day to move it to.');
119
+ const total = days * 1440 + (e.allDay ? 0 : minutes);
120
+ const start = shiftBy(e.start, total);
121
+ // the rule's weekdays follow the start's new day
122
+ const from = dayOf(e.start), to = dayOf(start);
123
+ const dayShift = from && to ? Math.round(wallMinutesBetween(from, to) / 1440) : days;
109
124
  let event = await saveNow(repo, e, {
110
- start, end: shift(e.end, days), allDay: e.allDay,
111
- ...(e.rrule ? { rrule: shiftRrule(e.rrule, days, start), exdates: e.exdates.map((x) => shift(x, days)) } : {}),
125
+ start, end: shiftBy(e.end, total), allDay: e.allDay,
126
+ ...(e.rrule ? { rrule: shiftRrule(e.rrule, dayShift, start), exdates: e.exdates.map((x) => shiftBy(x, total)) } : {}),
112
127
  }, hooks);
113
- // cancelled dates move with the series, like skipped ones
114
- if (e.rrule && e.cancelledDates.length) {
115
- await repo.setEventCancelledDates(id, e.cancelledDates.map((x) => shift(x, days)));
128
+ // 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
+ 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)));
116
133
  await flushEventHooks(repo, hooks);
117
134
  event = (await repo.getEvent(id))!;
118
135
  }
119
- return { event, undo: { kind: 'series', id, days: -days } };
136
+ return { event, undo: { kind: 'series', id, days: -days, minutes: -minutes } };
120
137
  }
121
138
 
122
- // Move one occurrence of a repeating event: skip that date in the series and
123
- // add a one-off copy on the new date. No schema change, and the copy is a
124
- // normal event the editor can open. The copy keeps the series' hosts, room
125
- // hold and translations, and that date's RSVPs move to it (people get the
126
- // "new time" email).
127
- export async function moveOccurrence(repo: ContentRepository, id: string, occurrence: string, days: number, hooks: SaveHooks): Promise<{ event: CalEvent; undo: MoveUndo }> {
139
+ // Move one date of a repeating event by `minutes` (a drag on the Schedule):
140
+ // a stored move (lib/calendar/moves.ts) that keeps its key, RSVPs and hosts,
141
+ // and its length. A date already moved moves on from where it is. A one-off
142
+ // event moves as a whole.
143
+ export async function moveDateBy(repo: ContentRepository, actor: MoveActor, id: string, key: string, minutes: number, hooks: SaveHooks): Promise<{ event: CalEvent; undo: MoveUndo; clashes: BusySlot[] }> {
128
144
  const e = await repo.getEvent(id);
129
145
  if (!e) throw new ApiError(404, 'Event not found.');
130
- if (!e.rrule) return moveSeries(repo, id, days, hooks);
131
- const occ = parseWall(occurrence);
132
- const first = parseWall(e.start);
133
- if (!occ || !first) throw new ApiError(400, 'That date isn’t part of this event.');
134
- if (e.exdates.includes(occurrence)) throw new ApiError(409, 'That date was already removed from the series.');
135
- // keep the occurrence's own length (first occurrence's start → end)
136
- const toMs = (w: { y: number; m: number; d: number; hh: number; mm: number }) => Date.UTC(w.y, w.m - 1, w.d, w.hh, w.mm);
137
- const lenMs = toMs(parseWall(e.end) ?? first) - toMs(first);
138
- const newStart = addWallDays(occ, days);
139
- const t = new Date(toMs(newStart) + lenMs);
140
- const pad = (n: number) => String(n).padStart(2, '0');
141
- const endDay = `${t.getUTCFullYear()}-${pad(t.getUTCMonth() + 1)}-${pad(t.getUTCDate())}`;
142
- const endStr = e.allDay ? endDay : `${endDay}T${pad(t.getUTCHours())}:${pad(t.getUTCMinutes())}`;
143
-
144
- const settings = await repo.getSettings();
145
- const was = findOccurrence(e, occurrence, settings);
146
- await saveNow(repo, e, { exdates: [...e.exdates, occurrence] }, hooks);
147
- const copy = await repo.createEvent({
148
- title: e.title, slug: await uniqueSlug(repo, e.title), start: formatWall(newStart), end: endStr, allDay: e.allDay,
149
- timezone: e.timezone, status: 'draft', excerpt: e.excerpt, bodyJson: e.bodyJson, bodyHtml: e.bodyHtml, cover: e.cover, coverAlt: e.coverAlt,
150
- seoTitle: e.seoTitle, seoDesc: e.seoDesc, location: e.location, cta: e.cta, calendarIds: e.calendarIds, fields: e.fields, tags: e.tags,
151
- // a cancelled date stays cancelled on its new day
152
- cancelled: e.cancelled || e.cancelledDates.includes(occurrence), rsvp: e.rsvp,
153
- template: e.template, featured: false, roomHold: e.roomHold, hostIds: e.hostIds, translations: e.translations,
154
- });
155
- const event = live(e.status) ? await saveEvent(repo, copy.id, { status: 'published' }, hooks) : copy;
156
- const now = findOccurrence(event, event.start, settings);
157
- if (was && now && await repo.moveOccurrenceRsvps(e.id, was.start, { eventId: event.id, start: now.start, end: now.end })) {
158
- await kickEmail(repo, hooks.locals, waitUntilFrom(hooks.locals));
146
+ if (!e.rrule) {
147
+ const r = await moveSeries(repo, id, Math.trunc(minutes / 1440), hooks, minutes % 1440);
148
+ return { ...r, clashes: [] };
159
149
  }
160
- return { event, undo: { kind: 'split', id: copy.id, seriesId: e.id, occurrence } };
150
+ if (!Number.isInteger(minutes) || Math.abs(minutes) > 1440 * 3660) throw new ApiError(400, 'Pick a day to move it to.');
151
+ const prior = e.movedDates[key];
152
+ const es = parseWall(e.start), ee = parseWall(e.end) ?? es;
153
+ const k = parseWall(key);
154
+ if (!k || !es || !ee) throw new ApiError(400, 'That date isn’t part of this event.');
155
+ const startWall = prior?.start ?? key;
156
+ const endWall = prior?.end ?? formatWall(addWallMinutes(k, wallMinutesBetween(es, ee)));
157
+ const r = await moveOccurrence(repo, actor, id, key, { start: shiftBy(startWall, minutes), end: shiftBy(endWall, minutes) }, hooks);
158
+ const previous = prior ? { start: prior.start, end: prior.end, roomIds: prior.roomIds, location: prior.location, note: prior.note } : null;
159
+ return { event: r.event, clashes: r.clashes, undo: { kind: 'date', id, key, previous } };
161
160
  }
162
161
 
163
- export async function undoMove(repo: ContentRepository, undo: MoveUndo, hooks: SaveHooks): Promise<void> {
162
+ export async function undoMove(repo: ContentRepository, actor: MoveActor, undo: MoveUndo, hooks: SaveHooks): Promise<void> {
164
163
  if (undo.kind === 'series') {
165
- await moveSeries(repo, undo.id, undo.days, hooks);
164
+ await moveSeries(repo, undo.id, undo.days, hooks, undo.minutes ?? 0);
165
+ return;
166
+ }
167
+ if (undo.kind === 'date') {
168
+ if (undo.previous) await moveOccurrence(repo, actor, undo.id, undo.key, { ...undo.previous, roomIds: undo.previous.roomIds ?? [], location: undo.previous.location ?? null }, hooks);
169
+ else await moveOccurrenceBack(repo, actor, undo.id, undo.key, hooks);
166
170
  return;
167
171
  }
168
172
  let series = await repo.getEvent(undo.seriesId);
@@ -185,21 +189,29 @@ export async function undoMove(repo: ContentRepository, undo: MoveUndo, hooks: S
185
189
  // The date stays on the calendar struck through ("Cancelled"), its room holds
186
190
  // are released, feeds mark it STATUS:CANCELLED, and its RSVPs are cancelled
187
191
  // (each person gets the "cancelled" email; no reminder). Restoring the date
188
- // leaves those RSVPs cancelled: people sign up again. These do no permission
192
+ // leaves those RSVPs cancelled: people sign up again. Cancelling a moved date
193
+ // cancels it where it was moved to, and drops the move. These do no permission
189
194
  // checks: callers (admin actions, site code through @acms-subir/core/server)
190
195
  // decide who may do this.
191
196
 
192
197
  async function setOccurrenceCancelled(repo: ContentRepository, id: string, key: string, cancel: boolean, hooks: SaveHooks, now = Date.now()): Promise<CalEvent> {
193
198
  const e = await repo.getEvent(id);
194
199
  if (!e) throw new ApiError(404, 'Event not found.');
195
- if (!e.rrule) throw new ApiError(400, 'Only a repeating event has single dates to cancel. Cancel the whole event instead.');
200
+ // error.code as for moves (lib/calendar/moves.ts MoveRefusal)
201
+ const refuse = (code: string, message: string) => Object.assign(new ApiError(400, message), { code });
202
+ if (!e.rrule) throw refuse('one-off', 'Only a repeating event has single dates to cancel. Cancel the whole event instead.');
196
203
  const settings = await repo.getSettings();
197
204
  const o = typeof key === 'string' && key.length <= 20 ? findOccurrence(e, key, settings) : null;
198
- if (!o) throw new ApiError(400, 'That date isn’t part of this event.');
199
- if (o.start <= now) throw new ApiError(400, 'That date has already started or passed.');
205
+ if (!o) throw refuse('not-a-date', 'That date isn’t part of this event.');
200
206
  const has = e.cancelledDates.includes(key);
201
- if (has === cancel) return e; // already so: nothing to change
202
- await repo.setEventCancelledDates(id, cancel ? [...e.cancelledDates, key].sort() : e.cancelledDates.filter((x) => x !== key));
207
+ if (has === cancel) return e; // already so: nothing to change (also once it has started)
208
+ if (o.start <= now) throw refuse('started', 'That date has already started or passed.');
209
+ const cancelledDates = cancel ? [...e.cancelledDates, key].sort() : e.cancelledDates.filter((x) => x !== key);
210
+ // a date is moved or cancelled, never both: cancelling a moved date drops its move (its RSVPs, at the new time, are cancelled below)
211
+ if (cancel && Object.hasOwn(e.movedDates, key)) {
212
+ const { [key]: _moved, ...moved } = e.movedDates;
213
+ await repo.setEventMovedDates(id, moved, cancelledDates);
214
+ } else await repo.setEventCancelledDates(id, cancelledDates);
203
215
  if (cancel && await repo.cancelOccurrenceRsvps(id, o.start)) await kickEmail(repo, hooks.locals, waitUntilFrom(hooks.locals));
204
216
  const after = (await repo.getEvent(id))!;
205
217
  await eventSideEffects(repo, hooks, after);
@@ -79,7 +79,8 @@ const STRINGS: Record<string, BookingStrings> = {
79
79
  offered: { subject: 'A spot opened: {title}', heading: 'A spot opened for {title}', intro: 'We’re holding it for you until {until}. Confirm it before then, or it goes to the next person in line.' },
80
80
  left: { subject: 'Off the waitlist: {title}', heading: 'You’re off the waitlist', intro: 'You’re no longer on the waitlist for this date.' },
81
81
  },
82
- manage: 'View or change your booking', rsvp: { manage: 'View or change your booking' },
82
+ manage: 'View or change your booking',
83
+ rsvp: { manage: 'View or change your booking' },
83
84
  waitlist: { manage: 'See your place or leave the waitlist', confirm: 'Confirm my spot' },
84
85
  what: 'What', when: 'When', where: 'Where', seats: 'Seats', reference: 'Reference {ref}', icsManage: 'Manage: {url}',
85
86
  lateDecline: 'We weren’t able to confirm this booking in time. Please pick another time.',
@@ -118,6 +119,8 @@ const STRINGS: Record<string, BookingStrings> = {
118
119
  manage: 'Ver o cambiar su reservación',
119
120
  rsvp: {
120
121
  manage: 'Ver o cambiar su confirmación',
122
+ // one date of the event moved (its RSVPs go with it); staff's note follows
123
+ rescheduled: { subject: 'Nuevo horario: {title} — {when}', heading: 'Esta fecha cambió', intro: 'Tiene un nuevo horario y su lugar se mantiene. Importe el archivo de calendario adjunto para actualizar su calendario, o cancele con el botón de abajo si no puede asistir.' },
121
124
  confirmed: { subject: 'Confirmado: {title} — {when}', heading: 'Ya tiene su lugar', intro: 'Está confirmado. Importe el archivo adjunto para agregarlo a su calendario.' },
122
125
  declined: { intro: 'Lo sentimos, el organizador no pudo confirmar su lugar.' },
123
126
  cancelled: { subject: 'Cancelado: {title}', heading: 'Confirmación cancelada', intro: 'Esta confirmación se canceló.' },
@@ -163,6 +166,7 @@ const STRINGS: Record<string, BookingStrings> = {
163
166
  manage: 'Voir ou modifier votre réservation',
164
167
  rsvp: {
165
168
  manage: 'Voir ou modifier votre inscription',
169
+ rescheduled: { subject: 'Nouvel horaire : {title} — {when}', heading: 'Cette date a changé', intro: 'Elle a un nouvel horaire et votre place est conservée. Importez le fichier de calendrier joint pour mettre à jour votre calendrier, ou annulez avec le bouton ci-dessous si vous ne pouvez pas venir.' },
166
170
  confirmed: { heading: 'Votre place est réservée' },
167
171
  declined: { intro: 'Désolés, l’organisateur n’a pas pu confirmer votre inscription.' },
168
172
  cancelled: { heading: 'Inscription annulée', intro: 'Cette inscription a été annulée.' },
@@ -208,6 +212,7 @@ const STRINGS: Record<string, BookingStrings> = {
208
212
  manage: 'Buchung ansehen oder ändern',
209
213
  rsvp: {
210
214
  manage: 'Anmeldung ansehen oder ändern',
215
+ rescheduled: { subject: 'Neuer Termin: {title} — {when}', heading: 'Dieser Termin wurde verschoben', intro: 'Er hat eine neue Uhrzeit, und Ihr Platz bleibt reserviert. Importieren Sie die angehängte Kalenderdatei, um Ihren Kalender zu aktualisieren, oder sagen Sie über die Schaltfläche unten ab, wenn Sie nicht kommen können.' },
211
216
  confirmed: { heading: 'Ihr Platz ist reserviert' },
212
217
  declined: { intro: 'Leider konnte der Veranstalter Ihre Anmeldung nicht bestätigen.' },
213
218
  cancelled: { heading: 'Anmeldung storniert', intro: 'Diese Anmeldung wurde storniert.' },
@@ -253,6 +258,7 @@ const STRINGS: Record<string, BookingStrings> = {
253
258
  manage: 'Ver ou alterar sua reserva',
254
259
  rsvp: {
255
260
  manage: 'Ver ou alterar sua inscrição',
261
+ rescheduled: { subject: 'Novo horário: {title} — {when}', heading: 'Esta data mudou', intro: 'Ela tem um novo horário e sua vaga foi mantida. Importe o arquivo de calendário anexo para atualizar seu calendário ou cancele pelo botão abaixo se não puder ir.' },
256
262
  confirmed: { heading: 'Sua vaga está reservada' },
257
263
  declined: { intro: 'Desculpe, o organizador não pôde confirmar sua inscrição.' },
258
264
  cancelled: { heading: 'Inscrição cancelada', intro: 'Esta inscrição foi cancelada.' },
@@ -47,9 +47,14 @@ async function render(repo: ContentRepository, job: EmailJob, locals?: unknown,
47
47
  return accountMail(payload.purpose, job.recipient, settings.siteTitle, `${origin}/account/${payload.purpose}?token=${encodeURIComponent(raw)}`);
48
48
  }
49
49
  if (job.kind === 'event-request') {
50
- const p = JSON.parse(job.payload) as { eventId: string; kind: 'staff-new' | 'staff-changes' | 'approved' | 'declined'; at: number; reply: string };
50
+ const p = JSON.parse(job.payload) as { eventId: string; kind: 'staff-new' | 'staff-changes' | 'staff-moved' | 'approved' | 'declined'; at: number; reply: string; by?: string; note?: string; when?: string };
51
51
  const e = await repo.getEvent(p.eventId);
52
52
  if (!e) return null;
53
+ // a host moved one date (lib/calendar/moves.ts): the words were fixed when it happened
54
+ if (p.kind === 'staff-moved') {
55
+ const by = p.by ? await repo.getUser(p.by) : null;
56
+ return eventRequestMail('staff-moved', job.recipient, settings.siteTitle, { title: e.title, when: p.when ?? '', by: by?.name ?? '', note: p.note ?? '' }, '', `${origin}/admin/calendar/events/${e.id}`);
57
+ }
53
58
  const staff = p.kind.startsWith('staff');
54
59
  // staff hear only about requests still waiting (not withdrawn or already answered)
55
60
  if (staff && (e.request?.state !== 'pending' || e.request.at !== p.at)) return null;
@@ -12,11 +12,15 @@ export function accountMail(kind: 'invite'|'reset'|'welcome'|'password-changed'|
12
12
  return { to, subject: `${copy[0]} · ${site}`, text: [copy[0], '', copy[1], ...(url ? ['',`${copy[2]}: ${url}`] : [])].join('\n'),
13
13
  html: `<!doctype html><html><body style="margin:0;background:#f6f5f2;color:#24211c;font:16px/1.6 system-ui,sans-serif"><div style="max-width:520px;margin:auto;padding:32px 24px"><p style="color:#7d776e">${escape(site)}</p><h1 style="font-size:24px">${escape(copy[0])}</h1><p>${escape(copy[1])}</p>${url ? `<p><a style="display:inline-block;background:#ee5d20;color:#fff;padding:10px 18px;border-radius:8px;text-decoration:none" href="${escape(url)}">${escape(copy[2])}</a></p>` : ''}</div></body></html>` };
14
14
  }
15
- /** members' event requests (lib/calendar/requests.ts): to staff when one arrives, to the member when it's answered */
16
- export function eventRequestMail(kind: 'staff-new' | 'staff-changes' | 'approved' | 'declined', to: string, site: string, event: { title: string; when: string; by: string; note: string }, reply: string, url: string): Mail {
15
+ /**
16
+ * members' event requests (lib/calendar/requests.ts): to staff when one arrives, to the member when it's answered;
17
+ * staff-moved: a host moved one date (lib/calendar/moves.ts), `when` = "old → new"
18
+ */
19
+ export function eventRequestMail(kind: 'staff-new' | 'staff-changes' | 'staff-moved' | 'approved' | 'declined', to: string, site: string, event: { title: string; when: string; by: string; note: string }, reply: string, url: string): Mail {
17
20
  const copy = {
18
21
  'staff-new': [`New event request: ${event.title}`, `${event.by || 'A member'} asked for “${event.title}” (${event.when}). It stays a draft until someone approves it.`, 'Review it'],
19
22
  'staff-changes': [`Changes to review: ${event.title}`, `${event.by || 'A host'} suggested changes to “${event.title}” (${event.when}). The live event stays as it is until someone approves them.`, 'Review the changes'],
23
+ 'staff-moved': [`Date moved: ${event.title}`, `${event.by || 'A host'} moved one date of “${event.title}”: ${event.when}. People who signed up for it were emailed the new time.`, 'Open the event'],
20
24
  approved: [`Approved: ${event.title}`, `Your event “${event.title}” (${event.when}) was approved and is now on the calendar.`, 'See your events'],
21
25
  declined: [`Not approved: ${event.title}`, `Your request for “${event.title}” (${event.when}) wasn’t approved.`, 'See your events'],
22
26
  }[kind];
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import type { ContentRepository } from '../repo/types';
3
- import { publicTags, tagFilter, type BookingLink, type CalEvent, type Calendar, type Resource, type Settings } from '../schema';
3
+ import { type BookingLink, type CalEvent, type Calendar, type Resource, type Settings } from '../schema';
4
4
  import { ApiError } from '../api';
5
5
  import { CALENDAR, bookingsEnabled, resourceUrl, bookingLinkUrl, publicEventFields } from '../calendar/config';
6
6
  import { calendarToJson, eventToJson, roomsToJson } from '../calendar/api';
@@ -17,6 +17,7 @@ import { localizeDoc } from '../translations';
17
17
  import { localeQuery, mobileLocale, localizedUrl } from './locale';
18
18
  import { confirmsInstantly, linkApproval } from '../booking/approval';
19
19
  import { changeTopics } from '../push/service';
20
+ import { occurrenceMatches, readEventFilter, resolveEventFilter, tagsMatch } from '../calendar/filter';
20
21
 
21
22
  export function requireCalendar(bookings = false) {
22
23
  if (!CALENDAR.enabled || (bookings && !bookingsEnabled())) throw new ApiError(404, 'This feature is disabled.');
@@ -27,14 +28,24 @@ export async function listCalendars(repo: ContentRepository, url: URL) {
27
28
  const origin = siteOrigin(await repo.getSettings(), url);
28
29
  return { calendars: (await repo.listCalendars()).filter(c => c.visibility === 'public').map(c => calendarToJson(c, origin)) };
29
30
  }
30
- // tag/excludeTag: a public tag's name or slug (internal "#" tags are never matched, as they are never shown)
31
- export const eventsQuery = z.object({ ...pageQuery, locale: localeQuery, from: isoDayOrInstant.optional(), to: isoDayOrInstant.optional(), calendar: z.string().min(1).max(200).optional(),
32
- tag: z.string().min(1).max(100).optional(), excludeTag: z.string().min(1).max(100).optional(), q: z.string().max(100).optional() }).strict();
31
+ // The website calendar's filters (calendar/filter.ts). Each takes one or more
32
+ // values, comma-separated or repeated. Tags are a public tag's name or slug
33
+ // (internal "#" tags never match, as they are never shown).
34
+ const FILTER_LISTS = ['calendar', 'cal', 'tag', 'excludeTag', 'notag', 'room'] as const;
35
+ const filterList = (what: string) => z.string().min(1).max(2100)
36
+ .refine((v) => { const parts = v.split(','); return parts.length <= 20 && parts.every((p) => p.trim().length <= 100); }, 'Use up to 20 values of up to 100 characters each.')
37
+ .optional().describe(`${what} One or more, comma-separated (a,b) or repeated.`);
38
+ export const eventsQuery = z.object({ ...pageQuery, locale: localeQuery, from: isoDayOrInstant.optional(), to: isoDayOrInstant.optional(),
39
+ calendar: filterList('Only events in these calendars (slugs).'), cal: filterList('Alias of calendar.'),
40
+ tag: filterList('Only events with one of these tags (a name in any case, or a slug).'), excludeTag: filterList('Leave out events with any of these tags.'), notag: filterList('Alias of excludeTag.'),
41
+ room: filterList('Only events held in one of these rooms (slugs of rooms shown on public events).'),
42
+ q: z.string().max(100).optional() }).strict();
33
43
  export async function listEvents(repo: ContentRepository, url: URL) {
34
44
  requireCalendar();
35
- const q = eventsQuery.parse(queryParams(url));
45
+ const q = eventsQuery.parse(queryParams(url, FILTER_LISTS));
36
46
  const locale = mobileLocale(q.locale);
37
47
  const term = q.q?.trim() ?? '';
48
+ const filter = readEventFilter(url.searchParams);
38
49
  const [settings, calendars, events, resources, indexed] = await Promise.all([repo.getSettings(), repo.listCalendars(), repo.listPublishedEvents(), roomMap(repo),
39
50
  // the search index's matches (body text, word prefixes), so "more" from /search continues here
40
51
  term ? repo.searchPublished(term, { limit: 200, collections: [] }).then((r) => new Set(r.events.map(({ doc }) => doc.id))) : null]);
@@ -47,14 +58,18 @@ export async function listEvents(repo: ContentRepository, url: URL) {
47
58
  const to = q.to ? at(q.to) : fromDay.length === 10 ? localMidnight(addDaysYmd(fromDay, 30), tz) : from + 30 * 86400000;
48
59
  if (to <= from || to - from > 90 * 86400000 + 3600000) throw new ApiError(400, 'Use an event window of 1 to 90 days.');
49
60
  const all = new Map(calendars.map(c => [c.id, c]));
50
- const cal = q.calendar ? calendars.find(c => c.slug === q.calendar && c.visibility !== 'private') : null;
51
- if (q.calendar && !cal) throw new ApiError(404, 'Calendar not found.');
52
- const publicCalendars = new Map(calendars.filter(c => c.visibility === 'public' || c.id === cal?.id).map(c => [c.id, c]));
53
- const tagged = tagFilter(q.tag, q.excludeTag);
54
- const visible = events.map(e => localizeDoc(e, locale ?? undefined)).filter(e => (cal ? e.calendarIds.includes(cal.id) && eventIsPublic(e, all) : eventIsListed(e, all))
55
- && tagged(publicTags(e.tags))
61
+ // private calendars and rooms not shown on public events answer like missing ones
62
+ const f = resolveEventFilter(filter, { calendars, resources: [...resources.values()] });
63
+ if (f.calendarIds && f.calendarIds.length < filter.cal.length) throw new ApiError(404, 'Calendar not found.');
64
+ if (f.roomIds && f.roomIds.length < filter.room.length) throw new ApiError(404, 'Room not found.');
65
+ const picked = f.calendarIds;
66
+ const publicCalendars = new Map(calendars.filter(c => c.visibility === 'public' || picked?.includes(c.id)).map(c => [c.id, c]));
67
+ const visible = events.map(e => localizeDoc(e, locale ?? undefined)).filter(e => (picked ? e.calendarIds.some(id => picked.includes(id)) && eventIsPublic(e, all) : eventIsListed(e, all))
68
+ && tagsMatch(f, e.tags)
56
69
  && (!term || indexed!.has(e.id) || `${e.title} ${e.excerpt} ${e.location.name}`.toLowerCase().includes(term.toLowerCase())));
57
- const occurrences = expandEvents(visible, settings, { displayTz: settings.timezone || 'UTC', now: Date.now(), windowStartMs: from, windowEndMs: to });
70
+ const occurrences = expandEvents(visible, settings, { displayTz: settings.timezone || 'UTC', now: Date.now(), windowStartMs: from, windowEndMs: to })
71
+ // rooms are per date (a moved date can change them)
72
+ .filter(o => !f.roomIds || occurrenceMatches(f, o));
58
73
  occurrences.sort((a, b) => a.start - b.start || (a.event.id < b.event.id ? -1 : a.event.id > b.event.id ? 1 : 0));
59
74
  const start = (q.page - 1) * q.limit;
60
75
  const origin = siteOrigin(settings, url);
@@ -91,9 +106,12 @@ export async function getEventDetail(repo: ContentRepository, url: URL, ref: str
91
106
  }
92
107
  /** every room by id, for naming the public ones (Resource.showOnEvents) */
93
108
  export const roomMap = async (repo: ContentRepository) => new Map((await repo.listResources()).map((r) => [r.id, r]));
94
- // one date: its time, whether it's cancelled, and the public rooms it holds
109
+ // one date: its time, whether it's cancelled, where it moved from, and the public rooms it holds
95
110
  export function occurrenceDto(o: Occurrence, resources: Map<string, Resource>) {
96
- return { id: `${o.event.id}:${o.start}`, start: new Date(o.start).toISOString(), end: new Date(o.end).toISOString(), allDay: o.allDay, cancelled: o.cancelled,
111
+ const iso = (n: number) => new Date(n).toISOString();
112
+ return { id: `${o.event.id}:${o.start}`, start: iso(o.start), end: iso(o.end), allDay: o.allDay, cancelled: o.cancelled,
113
+ moved: o.moved ? { from: { start: iso(o.moved.from.start), end: iso(o.moved.from.end) }, note: o.moved.note,
114
+ location: o.moved.location ? { name: o.moved.location.name, address: o.moved.location.address, url: o.moved.location.url } : null } : null,
97
115
  rooms: roomsToJson(o.roomIds, resources) };
98
116
  }
99
117
  // e is already localized; publicCalendars limits which calendars are named
@@ -43,11 +43,13 @@ export async function limitRequest(repo: ContentRepository, address: string, wri
43
43
  const key = `mobile:${write ? 'write' : 'read'}:${await sha256Hex(address)}`;
44
44
  if (!await repo.email.rateLimit(key, write ? 20 : 300, 60_000)) throw new ApiError(429, 'Too many requests. Try again in a minute.');
45
45
  }
46
- export function queryParams(url: URL) {
46
+ // lists: parameters that may repeat (tag=a&tag=b); their values are joined
47
+ // with commas, the same as tag=a,b
48
+ export function queryParams(url: URL, lists: readonly string[] = []) {
47
49
  const result: Record<string, string> = {};
48
50
  for (const [key, value] of url.searchParams) {
49
- if (key in result) throw new ApiError(400, `Duplicate query parameter: ${key}.`);
50
- result[key] = value;
51
+ if (key in result && !lists.includes(key)) throw new ApiError(400, `Duplicate query parameter: ${key}.`);
52
+ result[key] = key in result ? `${result[key]},${value}` : value;
51
53
  }
52
54
  return result;
53
55
  }
@@ -29,24 +29,28 @@ export const Page = ContentSummary.extend({
29
29
  });
30
30
  // a room an event (or one date) holds, named only when set to show on public events; color null = none
31
31
  export const EventRoom = z.object({ slug: str, name: str, color: str.nullable() });
32
+ // one date moved here from `from` (null when it wasn't); location: its own place, null = the event's
33
+ export const OccurrenceMoved = z.object({ from: z.object({ start: date, end: date }), note: str, location: z.object({ name: str, address: str, url: str }).nullable() }).nullable();
32
34
  export const Event = CalEvent.pick({
33
35
  id: true, slug: true, title: true, excerpt: true, status: true, cancelled: true, featured: true, start: true, end: true,
34
36
  allDay: true, timezone: true, rrule: true, exdates: true, cancelledDates: true, location: true, cta: true, tags: true, fields: true,
35
37
  }).extend({ rsvp: CalEvent.shape.rsvp.removeDefault().omit({ notifyStaff: true }).default({}).optional(), url: str, icsUrl: str, repeats: str, image: str, imageAlt: str, updatedAt: date,
36
38
  calendars: z.array(z.object({ id: str, slug: str, name: str, color: str })),
37
39
  rooms: z.array(EventRoom),
40
+ // single dates at another time, by original occurrence key (wall-clock, like start/end)
41
+ movedDates: z.record(z.object({ start: str, end: str, note: str })),
38
42
  // push topics told about date, time or place changes (Admin → Mobile app → When an event changes); [] = none
39
43
  notify: z.object({ changes: strings }),
40
44
  });
41
45
  export const Events = z.object({ locale, timezone: str, from: date, to: date, pagination: Pagination,
42
- occurrences: z.array(z.object({ id: str, start: date, end: date, allDay: bool, cancelled: bool, rooms: z.array(EventRoom), event: Event })),
46
+ occurrences: z.array(z.object({ id: str, start: date, end: date, allDay: bool, cancelled: bool, moved: OccurrenceMoved, rooms: z.array(EventRoom), event: Event })),
43
47
  });
44
48
  const Snippet = z.object({ text: str, highlights: z.array(z.object({ start: z.number().int(), end: z.number().int() })) });
45
49
  const ContentHit = ContentSummary.extend({ snippet: Snippet });
46
- export const EventDetail = z.object({ locale, occurrence: z.object({ id: str, start: date, end: date, allDay: bool, cancelled: bool, rooms: z.array(EventRoom) }).nullable(), event: Event });
50
+ export const EventDetail = z.object({ locale, occurrence: z.object({ id: str, start: date, end: date, allDay: bool, cancelled: bool, moved: OccurrenceMoved, rooms: z.array(EventRoom) }).nullable(), event: Event });
47
51
  export const SearchResults = z.object({ locale, query: str,
48
52
  posts: z.array(ContentHit), pages: z.array(ContentHit),
49
- events: z.array(z.object({ snippet: Snippet, occurrence: z.object({ start: date, end: date, allDay: bool, cancelled: bool, rooms: z.array(EventRoom) }).nullable(), event: Event })),
53
+ events: z.array(z.object({ snippet: Snippet, occurrence: z.object({ start: date, end: date, allDay: bool, cancelled: bool, moved: OccurrenceMoved, rooms: z.array(EventRoom) }).nullable(), event: Event })),
50
54
  more: z.object({ posts: bool, pages: bool, events: bool }),
51
55
  });
52
56
  const Icon = z.object({ type: z.enum(['svg', 'image', 'emoji']), value: str }).nullable();
@@ -96,23 +96,30 @@ interface Words {
96
96
  // event changes: dates come formatted, `more` ends a list of cancelled dates
97
97
  moved: (to: string, was: string) => string; movedTo: (to: string, place: string) => string; place: (p: string) => string;
98
98
  cancelled: (d: string) => string; cancelledFrom: (d: string) => string; cancelledOn: (d: string) => string; more: (n: number) => string;
99
+ // one date of a series moved (CalEvent.movedDates), or several: their new days
100
+ dateMoved: (to: string, was: string) => string; datesMoved: (d: string) => string;
99
101
  }
100
102
  const STRINGS: Record<string, Words> = {
101
103
  en: { startsIn: (m) => `Starts in ${m} min`, now: 'Starting now', live: 'We’re live',
102
104
  moved: (to, was) => `Now ${to} (was ${was})`, movedTo: (to, p) => `Now ${to} · ${p}`, place: (p) => `New place: ${p}`,
103
- cancelled: (d) => `Cancelled: ${d}`, cancelledFrom: (d) => `Cancelled: every date from ${d}`, cancelledOn: (d) => `Cancelled on ${d}`, more: (n) => `${n} more` },
105
+ cancelled: (d) => `Cancelled: ${d}`, cancelledFrom: (d) => `Cancelled: every date from ${d}`, cancelledOn: (d) => `Cancelled on ${d}`, more: (n) => `${n} more`,
106
+ dateMoved: (to, was) => `Moved to ${to} (was ${was})`, datesMoved: (d) => `New times on ${d}` },
104
107
  es: { startsIn: (m) => `Empieza en ${m} min`, now: 'Empieza ahora', live: 'Estamos en vivo',
105
108
  moved: (to, was) => `Ahora ${to} (antes ${was})`, movedTo: (to, p) => `Ahora ${to} · ${p}`, place: (p) => `Nuevo lugar: ${p}`,
106
- cancelled: (d) => `Cancelado: ${d}`, cancelledFrom: (d) => `Cancelado: todas las fechas desde el ${d}`, cancelledOn: (d) => `Cancelado el ${d}`, more: (n) => `${n} más` },
109
+ cancelled: (d) => `Cancelado: ${d}`, cancelledFrom: (d) => `Cancelado: todas las fechas desde el ${d}`, cancelledOn: (d) => `Cancelado el ${d}`, more: (n) => `${n} más`,
110
+ dateMoved: (to, was) => `Se cambia al ${to} (antes ${was})`, datesMoved: (d) => `Nuevo horario el ${d}` },
107
111
  fr: { startsIn: (m) => `Commence dans ${m} min`, now: 'Commence maintenant', live: 'Nous sommes en direct',
108
112
  moved: (to, was) => `Désormais ${to} (au lieu de ${was})`, movedTo: (to, p) => `Désormais ${to} · ${p}`, place: (p) => `Nouveau lieu : ${p}`,
109
- cancelled: (d) => `Annulé : ${d}`, cancelledFrom: (d) => `Annulé : toutes les dates à partir du ${d}`, cancelledOn: (d) => `Annulé le ${d}`, more: (n) => `${n} autres` },
113
+ cancelled: (d) => `Annulé : ${d}`, cancelledFrom: (d) => `Annulé : toutes les dates à partir du ${d}`, cancelledOn: (d) => `Annulé le ${d}`, more: (n) => `${n} autres`,
114
+ dateMoved: (to, was) => `Déplacé au ${to} (au lieu de ${was})`, datesMoved: (d) => `Nouveaux horaires le ${d}` },
110
115
  de: { startsIn: (m) => `Beginnt in ${m} Min.`, now: 'Beginnt jetzt', live: 'Wir sind live',
111
116
  moved: (to, was) => `Jetzt ${to} (statt ${was})`, movedTo: (to, p) => `Jetzt ${to} · ${p}`, place: (p) => `Neuer Ort: ${p}`,
112
- cancelled: (d) => `Abgesagt: ${d}`, cancelledFrom: (d) => `Abgesagt: alle Termine ab ${d}`, cancelledOn: (d) => `Abgesagt am ${d}`, more: (n) => `${n} weitere` },
117
+ cancelled: (d) => `Abgesagt: ${d}`, cancelledFrom: (d) => `Abgesagt: alle Termine ab ${d}`, cancelledOn: (d) => `Abgesagt am ${d}`, more: (n) => `${n} weitere`,
118
+ dateMoved: (to, was) => `Verschoben auf ${to} (statt ${was})`, datesMoved: (d) => `Neue Zeiten am ${d}` },
113
119
  pt: { startsIn: (m) => `Começa em ${m} min`, now: 'Começa agora', live: 'Estamos ao vivo',
114
120
  moved: (to, was) => `Agora ${to} (antes ${was})`, movedTo: (to, p) => `Agora ${to} · ${p}`, place: (p) => `Novo local: ${p}`,
115
- cancelled: (d) => `Cancelado: ${d}`, cancelledFrom: (d) => `Cancelado: todas as datas a partir de ${d}`, cancelledOn: (d) => `Cancelado em ${d}`, more: (n) => `mais ${n}` },
121
+ cancelled: (d) => `Cancelado: ${d}`, cancelledFrom: (d) => `Cancelado: todas as datas a partir de ${d}`, cancelledOn: (d) => `Cancelado em ${d}`, more: (n) => `mais ${n}`,
122
+ dateMoved: (to, was) => `Mudou para ${to} (antes ${was})`, datesMoved: (d) => `Novos horários em ${d}` },
116
123
  };
117
124
  export const pushStrings = (locale: string) => STRINGS[locale.split('-')[0]] ?? STRINGS.en;
118
125
 
@@ -291,7 +298,8 @@ export const CHANGE_SOON = 3 * HOUR;
291
298
  /** The public fields a change notice compares (staged edits live in `draft` and don't count until applied). */
292
299
  export function eventSnapshot(e: CalEvent): EventSnapshot {
293
300
  return { start: e.start, end: e.end, allDay: e.allDay, timezone: e.timezone, rrule: e.rrule, exdates: [...e.exdates].sort(),
294
- cancelled: e.cancelled, cancelledDates: [...e.cancelledDates].sort(), location: { name: e.location.name.trim(), address: e.location.address.trim() } };
301
+ cancelled: e.cancelled, cancelledDates: [...e.cancelledDates].sort(), location: { name: e.location.name.trim(), address: e.location.address.trim() },
302
+ movedDates: Object.fromEntries(Object.keys(e.movedDates ?? {}).sort().map((k) => [k, { start: e.movedDates[k].start, end: e.movedDates[k].end }])) };
295
303
  }
296
304
  function readSnapshot(raw: string | null): EventSnapshot | null {
297
305
  try { const r = EventSnapshot.safeParse(JSON.parse(raw ?? '')); return r.success ? r.data : null; } catch { return null; }
@@ -307,6 +315,8 @@ export interface EventChange {
307
315
  cancelled?: Slot;
308
316
  /** single dates of a repeating event newly cancelled, soonest first */
309
317
  cancelledDates?: Slot[];
318
+ /** single dates of a repeating event at a new time (moved, moved again or moved back), by their new time */
319
+ movedDates?: { from: Slot; to: Slot }[];
310
320
  /** when the soonest date it touches starts (a move: the old or the new time, whichever is sooner) */
311
321
  at: number;
312
322
  }
@@ -331,6 +341,7 @@ export function eventChange(was: EventSnapshot, e: CalEvent, settings: Pick<Sett
331
341
  const { upcoming } = slots(e, settings, now);
332
342
  if (!upcoming) return null;
333
343
  if (e.cancelled) return { cancelled: upcoming, at: upcoming.start };
344
+ // the series as people knew it, with today's moved dates (those are compared one by one below)
334
345
  const then: CalEvent = { ...e, start: was.start, end: was.end, allDay: was.allDay, timezone: was.timezone, rrule: was.rrule, exdates: was.exdates, cancelledDates: was.cancelledDates };
335
346
  // a date taken out of the series (dragged to its own event) or put back isn't a move
336
347
  const exdates = [...new Set([...was.exdates, ...e.exdates])];
@@ -353,6 +364,16 @@ export function eventChange(was: EventSnapshot, e: CalEvent, settings: Pick<Sett
353
364
  out.cancelledDates = dates.map((o) => ({ start: o.start, allDay: o.allDay }));
354
365
  starts.push(dates[0].start);
355
366
  }
367
+ // single dates at a new time (a cancelled one is reported above as cancelled)
368
+ const knew: CalEvent = { ...then, movedDates: Object.fromEntries(Object.entries(was.movedDates).map(([k, m]) => [k, { ...m, note: '', by: '', at: 0 }])) };
369
+ const moves = [...new Set([...Object.keys(was.movedDates), ...Object.keys(e.movedDates ?? {})])].filter((k) => !e.cancelledDates.includes(k))
370
+ .map((k) => ({ a: findOccurrence(knew, k, settings), b: findOccurrence(e, k, settings) }))
371
+ .filter((m): m is { a: Occurrence; b: Occurrence } => m.a !== null && m.b !== null && (m.a.start !== m.b.start || m.a.end !== m.b.end) && m.b.start > now)
372
+ .sort((x, y) => x.b.start - y.b.start);
373
+ if (moves.length) {
374
+ out.movedDates = moves.map(({ a, b }) => ({ from: { start: a.start, allDay: a.allDay }, to: { start: b.start, allDay: b.allDay } }));
375
+ starts.push(Math.min(...moves.map(({ a, b }) => (a.start > now ? Math.min(a.start, b.start) : b.start))));
376
+ }
356
377
  return starts.length ? { ...out, at: Math.min(...starts) } : null;
357
378
  }
358
379
 
@@ -396,11 +417,13 @@ export function eventChangeNotice(e: CalEvent, change: EventChange, settings: Se
396
417
  // all-day dates start at midnight in the site's zone (how they were expanded)
397
418
  const zone = (x: Slot) => (x.allDay ? siteTz : tz);
398
419
  const dates = change.cancelledDates ?? [];
399
- const kind = change.cancelled ? 'cancelled' : change.moved ? 'moved' : change.place ? 'place' : 'dateCancelled';
400
- // one cancelled date opens that date (?on= is its day in the site's zone, like calendar links)
420
+ const movedDates = change.movedDates ?? [];
421
+ const kind = change.cancelled ? 'cancelled' : change.moved ? 'moved' : change.place ? 'place' : movedDates.length ? 'dateMoved' : 'dateCancelled';
422
+ // one cancelled or moved date opens that date (?on= is its day in the site's zone, like calendar links)
401
423
  let on = '';
402
- if (kind === 'dateCancelled' && dates.length === 1) {
403
- const p = tzParts(dates[0].start, siteTz);
424
+ const one = kind === 'dateCancelled' && dates.length === 1 ? dates[0] : kind === 'dateMoved' && movedDates.length === 1 && !dates.length ? movedDates[0].to : null;
425
+ if (one) {
426
+ const p = tzParts(one.start, siteTz);
404
427
  on = `?on=${p.y}-${String(p.m).padStart(2, '0')}-${String(p.d).padStart(2, '0')}`;
405
428
  }
406
429
  const words = (ev: CalEvent, locale: string): Composed => {
@@ -418,6 +441,17 @@ export function eventChangeNotice(e: CalEvent, change: EventChange, settings: Se
418
441
  const sameDay = !from.allDay && !to.allDay && day(from) === day(to);
419
442
  parts.push(place ? s.movedTo(at(to), place) : s.moved(at(to), sameDay ? when(from.start, false, zone(from), locale, 'time') : at(from)));
420
443
  } else if (place) parts.push(s.place(place));
444
+ if (movedDates.length === 1) {
445
+ const { from, to } = movedDates[0];
446
+ const sameDay = !from.allDay && !to.allDay && day(from) === day(to);
447
+ parts.push(s.dateMoved(at(to), sameDay ? when(from.start, false, zone(from), locale, 'time') : at(from)));
448
+ } else if (movedDates.length) {
449
+ const list = movedDates.slice(0, 3).map(({ to }) => when(to.start, true, zone(to), locale, 'monthDay'));
450
+ if (movedDates.length > 3) list.push(s.more(movedDates.length - 3));
451
+ let joined: string;
452
+ try { joined = new Intl.ListFormat(locale, { type: 'conjunction' }).format(list); } catch { joined = list.join(', '); }
453
+ parts.push(s.datesMoved(joined));
454
+ }
421
455
  if (dates.length) {
422
456
  const list = dates.length === 1 ? [day(dates[0])] : dates.slice(0, 3).map((x) => when(x.start, true, zone(x), locale, 'monthDay'));
423
457
  if (dates.length > 3) list.push(s.more(dates.length - 3));
@@ -38,6 +38,8 @@ export type PushSettings = z.infer<typeof PushSettings>;
38
38
  export const EventSnapshot = z.object({
39
39
  start: z.string(), end: z.string(), allDay: z.boolean(), timezone: z.string(), rrule: z.string(), exdates: z.array(z.string()),
40
40
  cancelled: z.boolean(), cancelledDates: z.array(z.string()), location: z.object({ name: z.string(), address: z.string() }),
41
+ // single dates at another time (key → new wall times); notices queued before moved dates have none
42
+ movedDates: z.record(z.object({ start: z.string(), end: z.string() })).default({}),
41
43
  });
42
44
  export type EventSnapshot = z.infer<typeof EventSnapshot>;
43
45
 
@@ -5,7 +5,7 @@ import { WEBHOOK_MIGRATION } from '../webhook-sql';
5
5
  import { PUSH_MIGRATION } from '../push/migration';
6
6
  import { EDITOR_MIGRATION, EDITOR_GRACE_MIGRATION } from '../editor/migration';
7
7
  import { CALENDAR_SETTINGS_MIGRATION, ROOM_HOLD_MIGRATION } from '../calendar/migration';
8
- import { EVENT_CHANGES_MIGRATION } from '../calendar/change-migration';
8
+ import { EVENT_CHANGES_MIGRATION, MOVED_DATES_MIGRATION } from '../calendar/change-migration';
9
9
  import { PLACES_MIGRATION } from '../maps/migration';
10
10
  import { ROLES_MIGRATION } from '../permissions/migration';
11
11
  import { SYNC_MIGRATION } from '../sync/migration';
@@ -724,13 +724,16 @@ export const MIGRATIONS: Migration[] = [
724
724
  // every change to a published event, from any write path, queued for its
725
725
  // side effects (webhooks, "event changed" pushes, slug redirects)
726
726
  EVENT_CHANGES_MIGRATION,
727
+ // single dates of a series moved (CalEvent.movedDates): a column on the
728
+ // row, so the triggers on events (content version, event_changes) see a move
729
+ MOVED_DATES_MIGRATION(contentVersionTriggers('events', [...CONTENT_VERSION_COLUMNS.events, 'cancelled_dates', 'moved_dates'])[2]),
727
730
  ];
728
731
 
729
732
  // Current cache coverage; keep earlier migration inputs frozen for upgrade compatibility.
730
733
  export const CURRENT_CONTENT_VERSION_COLUMNS = {
731
734
  ...CONTENT_VERSION_COLUMNS,
732
735
  settings: [...CONTENT_VERSION_COLUMNS.settings, 'links_verified', 'links_avatar_shape', 'links_avatar_fit', 'links_avatar_scale', 'legal'],
733
- events: [...CONTENT_VERSION_COLUMNS.events, 'cancelled_dates'],
736
+ events: [...CONTENT_VERSION_COLUMNS.events, 'cancelled_dates', 'moved_dates'],
734
737
  resources: [...CONTENT_VERSION_COLUMNS.resources, 'color', 'show_on_events'],
735
738
  announcements: ANNOUNCEMENT_VISIBLE_COLUMNS,
736
739
  };