@acms-subir/core 0.3.47 → 0.3.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/cli/migrations.generated.mjs +42 -23
  3. package/docs/calendar.md +80 -0
  4. package/docs/server.md +28 -3
  5. package/docs/theme-authoring.md +1 -1
  6. package/package.json +1 -1
  7. package/src/actions/index.ts +76 -11
  8. package/src/components/EventPage.astro +18 -12
  9. package/src/components/calendar/CalendarBoard.tsx +43 -19
  10. package/src/components/editor/event/EventSheet.tsx +2 -0
  11. package/src/components/editor/event/SingleDates.tsx +123 -0
  12. package/src/lib/booking/holds.ts +7 -7
  13. package/src/lib/calendar/api.ts +11 -1
  14. package/src/lib/calendar/board.ts +7 -3
  15. package/src/lib/calendar/cards.ts +5 -5
  16. package/src/lib/calendar/change-migration.ts +40 -15
  17. package/src/lib/calendar/data.ts +9 -3
  18. package/src/lib/calendar/event-words.ts +9 -0
  19. package/src/lib/calendar/events.ts +94 -9
  20. package/src/lib/calendar/ics-write.ts +25 -2
  21. package/src/lib/calendar/moves.ts +291 -0
  22. package/src/lib/calendar/render.ts +10 -6
  23. package/src/lib/calendar/schedule.ts +73 -61
  24. package/src/lib/email/booking-words.ts +7 -1
  25. package/src/lib/email/service.ts +6 -1
  26. package/src/lib/email/templates.ts +6 -2
  27. package/src/lib/mobile/calendar.ts +5 -2
  28. package/src/lib/mobile/schemas.ts +7 -3
  29. package/src/lib/push/service.ts +44 -10
  30. package/src/lib/push/types.ts +2 -0
  31. package/src/lib/repo/migrations.ts +5 -2
  32. package/src/lib/repo/sql.ts +28 -10
  33. package/src/lib/repo/types.ts +6 -3
  34. package/src/lib/schema.ts +21 -0
  35. package/src/lib/sections.ts +5 -2
  36. package/src/lib/sync/catalog.ts +1 -0
  37. package/src/pages/api/calendar/events/index.ts +6 -0
  38. package/src/pages/members/_Layout.astro +1 -0
  39. package/src/pages/members/events/index.astro +1 -1
  40. package/src/server/index.ts +33 -0
  41. package/src/styles/editor.css +19 -0
  42. package/src/styles/schedule.css +2 -0
  43. package/src/styles/site.css +6 -0
  44. package/src/theme-sdk/calendar.ts +3 -2
@@ -18,6 +18,10 @@ export interface EventStrings {
18
18
  /** one date of a repeating event */
19
19
  dateCancelled: string;
20
20
  dateCancelledNote: string;
21
+ /** one date moved to another time: the flag, then "Moved from {from}." and the note (staff's own note replaces it) */
22
+ moved: string;
23
+ dateMoved: string;
24
+ dateMovedNote: string;
21
25
  /** the side column's landmark label */
22
26
  details: string;
23
27
  joinOnline: string;
@@ -130,6 +134,7 @@ const STRINGS: Record<string, EventStrings> = {
130
134
  en: {
131
135
  cancelled: 'Cancelled', eventCancelled: 'This event is cancelled.', eventCancelledNote: 'It stays listed so nobody is left wondering.',
132
136
  dateCancelled: 'Cancelled on {date}.', dateCancelledNote: 'Other dates go ahead as usual.',
137
+ moved: 'Moved', dateMoved: 'Moved from {from}.', dateMovedNote: 'Other dates stay as usual.',
133
138
  details: 'Event details', joinOnline: 'Join online', map: 'Map', directionsTo: 'Directions to {place}', mapOf: 'Map of {place}',
134
139
  directionsHere: 'Directions to the location', mapHere: 'Map of the location', showing: 'Showing {date}',
135
140
  addToCalendar: 'Add to calendar', downloadIcs: 'Download .ics', share: 'Share', linkCopied: 'Link copied',
@@ -162,6 +167,7 @@ const STRINGS: Record<string, EventStrings> = {
162
167
  es: {
163
168
  cancelled: 'Cancelado', eventCancelled: 'Este evento está cancelado.', eventCancelledNote: 'Lo dejamos en la lista para que nadie se quede con la duda.',
164
169
  dateCancelled: 'Cancelado el {date}.', dateCancelledNote: 'Las demás fechas siguen como siempre.',
170
+ moved: 'Cambio de horario', dateMoved: 'Cambió de horario: antes era el {from}.', dateMovedNote: 'Las demás fechas siguen como siempre.',
165
171
  details: 'Detalles del evento', joinOnline: 'Unirse en línea', map: 'Mapa', directionsTo: 'Cómo llegar a {place}', mapOf: 'Mapa de {place}',
166
172
  directionsHere: 'Cómo llegar al lugar', mapHere: 'Mapa del lugar', showing: 'Mostrando el {date}',
167
173
  addToCalendar: 'Agregar al calendario', downloadIcs: 'Descargar .ics', share: 'Compartir', linkCopied: 'Enlace copiado',
@@ -194,6 +200,7 @@ const STRINGS: Record<string, EventStrings> = {
194
200
  fr: {
195
201
  cancelled: 'Annulé', eventCancelled: 'Cet événement est annulé.', eventCancelledNote: 'Il reste affiché pour que personne ne se pose de questions.',
196
202
  dateCancelled: 'Annulé le {date}.', dateCancelledNote: 'Les autres dates sont maintenues.',
203
+ moved: 'Déplacé', dateMoved: 'Déplacé : prévu initialement le {from}.', dateMovedNote: 'Les autres dates sont maintenues.',
197
204
  details: 'Détails de l’événement', joinOnline: 'Rejoindre en ligne', map: 'Carte', directionsTo: 'Itinéraire vers {place}', mapOf: 'Carte de {place}',
198
205
  directionsHere: 'Itinéraire vers le lieu', mapHere: 'Carte du lieu', showing: 'Date affichée : {date}',
199
206
  addToCalendar: 'Ajouter au calendrier', downloadIcs: 'Télécharger le .ics', share: 'Partager', linkCopied: 'Lien copié',
@@ -226,6 +233,7 @@ const STRINGS: Record<string, EventStrings> = {
226
233
  de: {
227
234
  cancelled: 'Abgesagt', eventCancelled: 'Diese Veranstaltung ist abgesagt.', eventCancelledNote: 'Sie bleibt sichtbar, damit sich niemand wundert.',
228
235
  dateCancelled: 'Abgesagt am {date}.', dateCancelledNote: 'Die anderen Termine finden wie gewohnt statt.',
236
+ moved: 'Verschoben', dateMoved: 'Verschoben: ursprünglich am {from}.', dateMovedNote: 'Die anderen Termine finden wie gewohnt statt.',
229
237
  details: 'Veranstaltungsdetails', joinOnline: 'Online teilnehmen', map: 'Karte', directionsTo: 'Route zu {place}', mapOf: 'Karte von {place}',
230
238
  directionsHere: 'Route zum Ort', mapHere: 'Karte des Ortes', showing: 'Angezeigt: {date}',
231
239
  addToCalendar: 'Zum Kalender hinzufügen', downloadIcs: '.ics herunterladen', share: 'Teilen', linkCopied: 'Link kopiert',
@@ -258,6 +266,7 @@ const STRINGS: Record<string, EventStrings> = {
258
266
  pt: {
259
267
  cancelled: 'Cancelado', eventCancelled: 'Este evento foi cancelado.', eventCancelledNote: 'Ele continua na lista para que ninguém fique na dúvida.',
260
268
  dateCancelled: 'Cancelado em {date}.', dateCancelledNote: 'As outras datas continuam normalmente.',
269
+ moved: 'Remarcado', dateMoved: 'Remarcado: antes era em {from}.', dateMovedNote: 'As outras datas continuam normalmente.',
261
270
  details: 'Detalhes do evento', joinOnline: 'Participar on-line', map: 'Mapa', directionsTo: 'Como chegar a {place}', mapOf: 'Mapa de {place}',
262
271
  directionsHere: 'Como chegar ao local', mapHere: 'Mapa do local', showing: 'Exibindo {date}',
263
272
  addToCalendar: 'Adicionar ao calendário', downloadIcs: 'Baixar .ics', share: 'Compartilhar', linkCopied: 'Link copiado',
@@ -1,7 +1,7 @@
1
1
  // Native events: wall-clock time math, recurrence expansion (reusing the ICS
2
2
  // expander, so feed events and native events follow identical rules),
3
3
  // visibility, and "add to calendar" links. Pure — no repo, no fetch.
4
- import type { CalEvent, Calendar, EventLocation, Settings } from '../schema';
4
+ import type { CalEvent, Calendar, EventLocation, MovedDate, Settings } from '../schema';
5
5
  import { expandVEvents, tzParts, zonedToUtc, type CalendarWindow, type IcsTime, type VEvent } from './ics';
6
6
  import { cleanRrule } from './rrule';
7
7
  import { eventStrings } from './event-words';
@@ -35,6 +35,9 @@ function fromMinutes(min: number, dateOnly: boolean): Wall {
35
35
  return { y: dt.getUTCFullYear(), m: dt.getUTCMonth() + 1, d: dt.getUTCDate(), hh: dateOnly ? 0 : dt.getUTCHours(), mm: dateOnly ? 0 : dt.getUTCMinutes(), dateOnly };
36
36
  }
37
37
  export const addWallDays = (w: Wall, n: number) => fromMinutes(wallMinutes(w) + n * 1440, w.dateOnly);
38
+ export const addWallMinutes = (w: Wall, n: number) => fromMinutes(wallMinutes(w) + n, w.dateOnly);
39
+ /** whole minutes from a to b (wall-clock, no zone) */
40
+ export const wallMinutesBetween = (a: Wall, b: Wall) => wallMinutes(b) - wallMinutes(a);
38
41
 
39
42
  export function isValidTimeZone(tz: string): boolean {
40
43
  if (!tz) return false;
@@ -122,8 +125,27 @@ export interface Occurrence {
122
125
  key: string;
123
126
  // the whole event is cancelled, or just this date
124
127
  cancelled: boolean;
125
- // the rooms this date holds (Resource ids; the event's roomHold.resourceIds)
128
+ // the rooms this date holds (Resource ids: the event's roomHold.resourceIds,
129
+ // or a moved date's own)
126
130
  roomIds: string[];
131
+ // this date was moved here (CalEvent.movedDates): where it was, and why
132
+ moved?: OccurrenceMove;
133
+ }
134
+
135
+ export interface OccurrenceMove {
136
+ /** the date's original start and end (UTC ms) */
137
+ from: { start: number; end: number };
138
+ /** staff's public note ('' for none) */
139
+ note: string;
140
+ /** the date's own place, when it moved somewhere else */
141
+ location?: EventLocation;
142
+ }
143
+
144
+ /** Every room an event may hold: its own and any a moved date holds instead. */
145
+ export function eventRoomIds(e: Pick<CalEvent, 'roomHold' | 'movedDates'>): string[] {
146
+ const ids = new Set(e.roomHold?.resourceIds ?? []);
147
+ for (const m of Object.values(e.movedDates ?? {})) for (const id of m.roomIds ?? []) ids.add(id);
148
+ return [...ids];
127
149
  }
128
150
 
129
151
  /**
@@ -148,14 +170,73 @@ export function expandEvents(events: CalEvent[], settings: Pick<Settings, 'timez
148
170
  if (!v) continue;
149
171
  // ?? []: editor previews pass unsaved events that may lack the field
150
172
  const cancelledDates = new Set(e.cancelledDates ?? []);
173
+ const moved = movedEntries(e);
151
174
  for (const o of expandVEvents([v], tz, win, 0)) {
152
175
  const key = occurrenceKey(e, o.start, settings, win.displayTz);
176
+ // a moved date comes out at its new time below
177
+ if (moved.has(key)) continue;
153
178
  out.push({ event: e, start: o.start, end: o.end, allDay: o.allDay, days: o.days, key, cancelled: e.cancelled || cancelledDates.has(key), roomIds: e.roomHold?.resourceIds ?? [] });
154
179
  }
180
+ for (const [key, m] of moved) {
181
+ const at = movedInstants(e, m, settings, win.displayTz);
182
+ if (!at || at.end <= win.windowStartMs || at.start >= win.windowEndMs) continue;
183
+ const o = movedOccurrence(e, key, m, settings, win.displayTz);
184
+ if (o) out.push(o);
185
+ }
155
186
  }
156
187
  return out.sort((a, b) => a.start - b.start || a.event.title.localeCompare(b.event.title));
157
188
  }
158
189
 
190
+ // ---- moved dates ---------------------------------------------------------
191
+ // A moved date (CalEvent.movedDates) keeps its original key; it comes out at
192
+ // its new time, in its own rooms (and place) when it has them, with `moved`
193
+ // saying where it was. Keys that no longer match a date of the series (it
194
+ // was retimed or its rule changed) are ignored, like stale cancelled keys,
195
+ // and a key that is also cancelled stays cancelled at its original time.
196
+
197
+ // the moves that count: not on a cancelled date
198
+ function movedEntries(e: Pick<CalEvent, 'movedDates' | 'cancelledDates'>): Map<string, MovedDate> {
199
+ const out = new Map<string, MovedDate>();
200
+ const cancelled = new Set(e.cancelledDates ?? []);
201
+ for (const [key, m] of Object.entries(e.movedDates ?? {})) if (!cancelled.has(key)) out.set(key, m);
202
+ return out;
203
+ }
204
+
205
+ /** A moved date's new instants (all-day: midnight in the display zone, as the expander places them), or null when unusable. */
206
+ export function movedInstants(e: Pick<CalEvent, 'allDay' | 'timezone'>, m: Pick<MovedDate, 'start' | 'end'>, settings: Pick<Settings, 'timezone'>, displayTz = settings.timezone || 'UTC'): { start: number; end: number; days: number } | null {
207
+ const s = parseWall(m.start);
208
+ if (!s || s.dateOnly !== e.allDay) return null;
209
+ const en = parseWall(m.end) ?? s;
210
+ if (e.allDay) {
211
+ const days = Math.max(1, Math.round((wallMinutes({ ...en, hh: 0, mm: 0 }) - wallMinutes(s)) / 1440) + 1);
212
+ const start = zonedToUtc(s.y, s.m, s.d, 0, 0, 0, displayTz || 'UTC');
213
+ return { start, end: start + days * DAY, days };
214
+ }
215
+ const tz = eventTimeZone(e, settings);
216
+ const start = wallToMs(s, tz), end = wallToMs(en, tz);
217
+ return end > start ? { start, end, days: 1 } : null;
218
+ }
219
+
220
+ // the date as the series alone makes it (no move), or null when it isn't one
221
+ function seriesOccurrence(e: CalEvent, key: string, settings: Pick<Settings, 'timezone'>, displayTz: string): Occurrence | null {
222
+ const w = parseWall(key);
223
+ if (!w || w.dateOnly !== e.allDay) return null;
224
+ const at = e.allDay ? zonedToUtc(w.y, w.m, w.d, 0, 0, 0, displayTz || 'UTC') : wallToMs(w, eventTimeZone(e, settings));
225
+ return expandEvents([{ ...e, movedDates: {} }], settings, { displayTz, now: at, windowStartMs: at - DAY, windowEndMs: at + DAY })
226
+ .find((o) => o.key === key && o.start === at) ?? null;
227
+ }
228
+
229
+ function movedOccurrence(e: CalEvent, key: string, m: MovedDate, settings: Pick<Settings, 'timezone'>, displayTz: string): Occurrence | null {
230
+ const at = movedInstants(e, m, settings, displayTz);
231
+ const was = at && seriesOccurrence(e, key, settings, displayTz);
232
+ if (!at || !was) return null;
233
+ return {
234
+ event: e, start: at.start, end: at.end, allDay: e.allDay, days: at.days, key, cancelled: e.cancelled,
235
+ roomIds: m.roomIds ?? e.roomHold?.resourceIds ?? [],
236
+ moved: { from: { start: was.start, end: was.end }, note: m.note ?? '', ...(m.location ? { location: m.location } : {}) },
237
+ };
238
+ }
239
+
159
240
  // When RSVPs to one date close (UTC ms), or null when they stay open until it
160
241
  // ends: the event's closesAt (wall time in its zone, one instant for every
161
242
  // date), else closeBefore minutes before this date's start. A deadline at or
@@ -168,18 +249,17 @@ export function rsvpDeadline(o: Pick<Occurrence, 'event' | 'start' | 'end'>, set
168
249
  }
169
250
 
170
251
  // The occurrence of a series with this key (null when the series has no
171
- // such date: never generated, skipped by exdates, or the series moved)
252
+ // such date: never generated, skipped by exdates, or the series moved). A
253
+ // moved date comes back at its new time.
172
254
  export function findOccurrence(e: CalEvent, key: string, settings: Pick<Settings, 'timezone'>, displayTz = settings.timezone || 'UTC'): Occurrence | null {
173
- const w = parseWall(key);
174
- if (!w || w.dateOnly !== e.allDay) return null;
175
- const at = e.allDay ? zonedToUtc(w.y, w.m, w.d, 0, 0, 0, displayTz || 'UTC') : wallToMs(w, eventTimeZone(e, settings));
176
- return expandEvents([e], settings, { displayTz, now: at, windowStartMs: at - DAY, windowEndMs: at + DAY })
177
- .find((o) => o.key === key && o.start === at) ?? null;
255
+ const m = movedEntries(e).get(key);
256
+ return m ? movedOccurrence(e, key, m, settings, displayTz) : seriesOccurrence(e, key, settings, displayTz);
178
257
  }
179
258
 
180
259
  // The occurrence to feature for an event: the next one that hasn't ended,
181
260
  // else the most recent. `on` ('YYYY-MM-DD') picks a specific day's instance
182
- // (links from the calendar grid carry it).
261
+ // (links from the calendar grid carry it); a moved date answers both its new
262
+ // day and the day it was moved from.
183
263
  export function featuredOccurrence(e: CalEvent, settings: Pick<Settings, 'timezone'>, now: number, on?: string): Occurrence | null {
184
264
  const tz = eventTimeZone(e, settings);
185
265
  const displayTz = settings.timezone || 'UTC';
@@ -190,6 +270,11 @@ export function featuredOccurrence(e: CalEvent, settings: Pick<Settings, 'timezo
190
270
  const hit = expandEvents([e], settings, { displayTz, now, windowStartMs: from, windowEndMs: from + DAY })
191
271
  .find((o) => o.start >= from - DAY);
192
272
  if (hit) return hit;
273
+ for (const [key, m] of movedEntries(e)) {
274
+ if (key.slice(0, 10) !== on) continue;
275
+ const o = movedOccurrence(e, key, m, settings, displayTz);
276
+ if (o) return o;
277
+ }
193
278
  }
194
279
  }
195
280
  if (!e.rrule) {
@@ -5,7 +5,8 @@
5
5
  // across DST — Google, Apple and Outlook all resolve IANA zone ids. All-day
6
6
  // events are VALUE=DATE with the RFC's exclusive DTEND. A single cancelled
7
7
  // date of a series is an override VEVENT (same UID, RECURRENCE-ID) with
8
- // STATUS:CANCELLED, so calendar apps strike that one date.
8
+ // STATUS:CANCELLED, so calendar apps strike that one date; a moved date is an
9
+ // override with its new DTSTART/DTEND (and its own place), so they show it there.
9
10
  import type { CalEvent, Calendar } from '../schema';
10
11
  import { addWallDays, eventInstants, eventTimeZone, findOccurrence, locationLine, parseWall, type Wall } from './events';
11
12
  import { zonedToUtc } from './ics';
@@ -103,10 +104,32 @@ export function eventToVevent(e: CalEvent, siteTz: string, opts: FeedOptions): s
103
104
  if (e.cancelled) lines.push('STATUS:CANCELLED');
104
105
  const stamps = [`LAST-MODIFIED:${utcStamp(e.updatedAt)}`, `SEQUENCE:${Math.max(0, Math.floor((e.updatedAt - e.createdAt) / 1000)) % 2147483647}`];
105
106
  lines.push(...stamps, 'END:VEVENT');
106
- if (rule && !e.cancelled) lines.push(...cancelledOverrides(e, s, en, tz, siteTz, url, opts, stamps));
107
+ if (rule && !e.cancelled) lines.push(...cancelledOverrides(e, s, en, tz, siteTz, url, opts, stamps), ...movedOverrides(e, tz, siteTz, url, opts, stamps));
107
108
  return lines;
108
109
  }
109
110
 
111
+ // one override per moved date that is still part of the series (and not
112
+ // cancelled): the same date (RECURRENCE-ID) at its new time
113
+ function movedOverrides(e: CalEvent, tz: string, siteTz: string, url: string, opts: FeedOptions, stamps: string[]): string[] {
114
+ const out: string[] = [];
115
+ const cancelled = new Set(e.cancelledDates ?? []);
116
+ for (const key of Object.keys(e.movedDates ?? {}).sort()) {
117
+ const m = e.movedDates[key];
118
+ const w = parseWall(key), ms = parseWall(m.start), me = parseWall(m.end) ?? ms;
119
+ if (cancelled.has(key) || !w || !ms || !me || !findOccurrence(e, key, { timezone: siteTz }, siteTz || 'UTC')) continue;
120
+ const when = e.allDay
121
+ ? [`RECURRENCE-ID;VALUE=DATE:${dateValue(w)}`, `DTSTART;VALUE=DATE:${dateValue(ms)}`, `DTEND;VALUE=DATE:${dateValue(addWallDays({ ...me, dateOnly: true }, 1))}`]
122
+ : [`RECURRENCE-ID;TZID=${tz}:${localValue(w)}`, `DTSTART;TZID=${tz}:${localValue(ms)}`, `DTEND;TZID=${tz}:${localValue(me)}`];
123
+ const place = m.location ?? e.location;
124
+ const loc = locationLine(place);
125
+ const desc = [m.note, e.excerpt, place.url && place.url !== loc ? place.url : '', url].filter(Boolean).join('\n\n');
126
+ out.push('BEGIN:VEVENT', `UID:${e.id}@${opts.host}`, `DTSTAMP:${utcStamp(opts.now ?? Date.now())}`, ...when,
127
+ `SUMMARY:${escapeText(e.title || 'Untitled event')}`, ...(loc ? [`LOCATION:${escapeText(loc)}`] : []),
128
+ `DESCRIPTION:${escapeText(desc)}`, `URL:${url}`, ...stamps, 'END:VEVENT');
129
+ }
130
+ return out;
131
+ }
132
+
110
133
  // one override per cancelled date that is still part of the series (keys
111
134
  // left behind by a moved series are ignored)
112
135
  function cancelledOverrides(e: CalEvent, s: Wall, en: Wall, tz: string, siteTz: string, url: string, opts: FeedOptions, stamps: string[]): string[] {
@@ -0,0 +1,291 @@
1
+ // Moved dates (CalEvent.movedDates): one date of a repeating event at another
2
+ // time, and maybe in other rooms or at another place, keeping what makes it
3
+ // that date: its key, its RSVPs (they move with it, and people get the "new
4
+ // time" email in their language with their cancel link), its hosts and its
5
+ // page. Moving and moving back apply at once, even on a published event, and
6
+ // leave staged edits alone, like cancelling a date. A date is moved or
7
+ // cancelled, never both: cancelling a moved date drops the move.
8
+ //
9
+ // These check who is acting, so the admin, the built-in member pages and a
10
+ // site's own code (@acms-subir/core/server) share the rules:
11
+ // - staff ("Create and edit content") and GOOGLE_SYNC (a calendar sync acting
12
+ // with staff rights) may move any date. A room taken at the new time is
13
+ // returned as a warning, since the change may already have happened
14
+ // elsewhere;
15
+ // - a host of the event may move its dates while the rooms are free; a taken
16
+ // room refuses the move (409), and staff are emailed about each host move.
17
+ // `quiet` skips the app notice and the staff email (a sync catching up); the
18
+ // RSVP emails always go, because those people's own booking changed.
19
+ import type { ContentRepository } from '../repo/types';
20
+ import { EventLocation, type CalEvent, type MovedDate } from '../schema';
21
+ import { ApiError } from '../api';
22
+ import { eventSideEffects, type SaveHooks } from '../content-service';
23
+ import { addWallDays, addWallMinutes, eventTimeZone, expandEvents, findOccurrence, formatWall, movedInstants, normalizeEventTimes, parseWall, wallMinutesBetween, type Occurrence } from './events';
24
+ import { holdsFromEvents, roomBusy, type BusySlot } from '../booking/holds';
25
+ import { kickEmail } from '../email/service';
26
+ import { waitUntilFrom } from '../webhooks';
27
+ import { MEMBERS } from './members-config';
28
+ import { siteLocale } from '../dates';
29
+
30
+ /** The actor for a calendar sync (e.g. Google): staff rights, room clashes only warn. */
31
+ export const GOOGLE_SYNC = 'google-sync';
32
+ /** Who is acting: a person (currentUser() from @acms-subir/core/server fits) or the sync. */
33
+ export type MoveActor = { id: string; can(permission: string): boolean } | typeof GOOGLE_SYNC;
34
+
35
+ export interface MoveInput {
36
+ /** the new start, wall-clock in the event's timezone: YYYY-MM-DDTHH:mm, or YYYY-MM-DD for an all-day event */
37
+ start: string;
38
+ /** the new end in the same form (all-day: the last day, inclusive); default: the date keeps its length */
39
+ end?: string;
40
+ /** the rooms that date holds instead (Resource ids); omit to keep its current rooms */
41
+ roomIds?: string[];
42
+ /** that date's place instead; null = the event's place again; omit to keep its current one */
43
+ location?: Partial<EventLocation> | null;
44
+ /** a public note shown with the date ("Moved for the youth retreat"); omit to keep the current one */
45
+ note?: string;
46
+ }
47
+ export interface MoveOptions {
48
+ /** no app notice and no staff email (a sync catching up); RSVP emails still go */
49
+ quiet?: boolean;
50
+ now?: number;
51
+ }
52
+ export interface MoveResult {
53
+ event: CalEvent;
54
+ /** what else takes the date's rooms at its new time (warnings for staff; hosts are refused instead) */
55
+ clashes: BusySlot[];
56
+ /** false: the date already was exactly so, and nothing was written, sent or emailed */
57
+ changed: boolean;
58
+ }
59
+
60
+ /**
61
+ * Why a move was refused (error.code, with error.status): one-off (not a
62
+ * repeating event), cancelled-event, not-a-date (the key isn't one of its
63
+ * dates), cancelled-date, started (the date has started or passed),
64
+ * original-passed (moving back to a time that has passed), past (the new time
65
+ * has passed), bad-time, room-taken (a host, 409).
66
+ */
67
+ export type MoveRefusal = 'one-off' | 'cancelled-event' | 'not-a-date' | 'cancelled-date' | 'started' | 'original-passed' | 'past' | 'bad-time' | 'room-taken';
68
+ const refuse = (status: number, code: MoveRefusal, message: string) => Object.assign(new ApiError(status, message), { code });
69
+
70
+ const DAY = 86_400_000;
71
+ const NOTE_MAX = 500;
72
+
73
+ type Role = 'staff' | 'host';
74
+ function roleOf(actor: MoveActor | null, e: CalEvent): Role {
75
+ if (actor === GOOGLE_SYNC) return 'staff';
76
+ if (!actor) throw new ApiError(401, 'Sign in to continue.');
77
+ if (actor.can('content.edit')) return 'staff';
78
+ if (e.hostIds.includes(actor.id)) return 'host';
79
+ throw new ApiError(404, 'Event not found.');
80
+ }
81
+ const actorId = (actor: MoveActor) => (actor === GOOGLE_SYNC ? GOOGLE_SYNC : actor.id);
82
+
83
+ async function load(repo: ContentRepository, id: string) {
84
+ const e = await repo.getEvent(id);
85
+ if (!e || e.status === 'archived') throw new ApiError(404, 'Event not found.');
86
+ return e;
87
+ }
88
+
89
+ // the date as it is now (moved or not), checked for moving (`started` waits: an unchanged move is a no-op even then)
90
+ function dateToChange(e: CalEvent, key: string, settings: { timezone: string }): Occurrence {
91
+ if (!e.rrule) throw refuse(400, 'one-off', 'Only a repeating event has single dates to move. Change the event’s time instead.');
92
+ if (e.cancelled) throw refuse(409, 'cancelled-event', 'This event is cancelled.');
93
+ if (typeof key !== 'string' || key.length > 20) throw refuse(400, 'not-a-date', 'That date isn’t part of this event.');
94
+ if (e.cancelledDates.includes(key)) throw refuse(409, 'cancelled-date', 'That date is cancelled. Restore it before moving it.');
95
+ const o = findOccurrence(e, key, settings);
96
+ if (!o) throw refuse(400, 'not-a-date', 'That date isn’t part of this event.');
97
+ return o;
98
+ }
99
+ const started = () => refuse(400, 'started', 'That date has already started or passed.');
100
+
101
+ // The new wall times: `start` in the event's form, `end` defaulting to the
102
+ // date's own length (all-day: the same number of days)
103
+ function newTimes(e: CalEvent, input: MoveInput): { start: string; end: string } {
104
+ const s = parseWall(input.start);
105
+ if (!s) throw refuse(400, 'bad-time', e.allDay ? 'Pick a date (YYYY-MM-DD).' : 'Pick a date and time (YYYY-MM-DDTHH:mm).');
106
+ 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).');
107
+ let end = input.end;
108
+ if (!end) {
109
+ const es = parseWall(e.start), ee = parseWall(e.end) ?? es;
110
+ const length = es && ee ? wallMinutesBetween(es, ee) : 60;
111
+ end = formatWall(e.allDay ? addWallDays(s, Math.round(length / 1440)) : addWallMinutes(s, Math.max(1, length)));
112
+ }
113
+ try {
114
+ return normalizeEventTimes({ start: input.start, end, allDay: e.allDay });
115
+ } catch (err) {
116
+ throw refuse(400, 'bad-time', err instanceof Error ? err.message : 'Pick a valid time.');
117
+ }
118
+ }
119
+
120
+ // what else takes the rooms this date would hold at its new time (setup and
121
+ // cleanup, or the whole day, as the event holds them)
122
+ async function clashesFor(repo: ContentRepository, next: CalEvent, key: string, at: { start: number; end: number }, settings: { timezone: string }): Promise<BusySlot[]> {
123
+ const resources = new Map((await repo.listResources()).map((r) => [r.id, r]));
124
+ const holds = holdsFromEvents([next], resources, settings, at.start - 2 * DAY, at.end + 2 * DAY, { force: true }).filter((h) => h.key === key);
125
+ const seen = new Set<string>();
126
+ const out: BusySlot[] = [];
127
+ for (const h of holds) {
128
+ for (const b of await roomBusy(repo, settings, { resourceIds: [h.resourceId], from: h.start, to: h.end, ignore: { eventId: next.id, key } })) {
129
+ const id = `${b.resourceId}:${b.kind}:${b.bookingId ?? `${b.eventId}:${b.key}`}:${b.start}`;
130
+ if (!seen.has(id)) { seen.add(id); out.push(b); }
131
+ }
132
+ }
133
+ return out.sort((a, b) => a.start - b.start);
134
+ }
135
+
136
+ async function refuseForHost(repo: ContentRepository, clashes: BusySlot[]) {
137
+ const names = new Map((await repo.listResources()).map((r) => [r.id, r.name]));
138
+ const rooms = [...new Set(clashes.map((c) => names.get(c.resourceId) ?? 'The room'))];
139
+ const list = rooms.length > 1 ? `${rooms.slice(0, -1).join(', ')} and ${rooms[rooms.length - 1]}` : rooms[0];
140
+ throw refuse(409, 'room-taken', `${list} ${rooms.length > 1 ? 'are' : 'is'} taken at that time. Pick another time${rooms.length ? ' or room' : ''}, or ask the team.`);
141
+ }
142
+
143
+ const sameRooms = (a: string[] | undefined, b: string[]) => a !== undefined && a.length === b.length && a.every((x, i) => x === b[i]);
144
+ // the same move, whoever made it and when
145
+ const sameMove = (a: MovedDate, b: MovedDate) => a.start === b.start && a.end === b.end && a.note === b.note
146
+ && JSON.stringify(a.roomIds ?? null) === JSON.stringify(b.roomIds ?? null) && JSON.stringify(a.location ?? null) === JSON.stringify(b.location ?? null);
147
+
148
+ /**
149
+ * Move one date of a repeating event to a new time (and maybe other rooms or
150
+ * another place). Moving an already moved date replaces its move; moving it
151
+ * back to exactly where it was is moveOccurrenceBack. Moving it to where it
152
+ * already is changes nothing (`changed: false`): retries never email twice.
153
+ */
154
+ export async function moveOccurrence(repo: ContentRepository, actor: MoveActor | null, id: string, key: string, input: MoveInput, hooks: SaveHooks, opts: MoveOptions = {}): Promise<MoveResult> {
155
+ const now = opts.now ?? Date.now();
156
+ const e = await load(repo, id);
157
+ const role = roleOf(actor, e);
158
+ const settings = await repo.getSettings();
159
+ const current = dateToChange(e, key, settings);
160
+ const prior = e.movedDates[key];
161
+ const times = newTimes(e, input);
162
+
163
+ let roomIds = input.roomIds === undefined ? prior?.roomIds : [...new Set(input.roomIds)];
164
+ if (roomIds !== undefined) {
165
+ const known = new Set((await repo.listResources()).map((r) => r.id));
166
+ roomIds = roomIds.filter((r) => known.has(r));
167
+ // the event's own rooms aren't an override
168
+ if (sameRooms(roomIds, e.roomHold.resourceIds)) roomIds = undefined;
169
+ }
170
+ let location = input.location === undefined ? prior?.location : input.location === null ? undefined : EventLocation.parse({ ...e.location, ...input.location });
171
+ if (location && !location.name.trim() && !location.address.trim()) throw new ApiError(400, 'Give the place a name or an address.');
172
+ if (location && JSON.stringify(location) === JSON.stringify(e.location)) location = undefined;
173
+ const note = input.note === undefined ? prior?.note ?? '' : String(input.note).replace(/\s+/g, ' ').trim().slice(0, NOTE_MAX);
174
+
175
+ const entry: MovedDate = { start: times.start, end: times.end, ...(roomIds ? { roomIds } : {}), ...(location ? { location } : {}), note, by: actorId(actor!), at: now };
176
+ if (prior && sameMove(prior, entry)) return { event: e, clashes: [], changed: false };
177
+ const at = movedInstants(e, entry, settings);
178
+ if (!at) throw refuse(400, 'bad-time', 'Pick a valid time.');
179
+
180
+ // back where the series puts it, with nothing else different: that's moving it back
181
+ const series = findOccurrence({ ...e, movedDates: {} }, key, settings)!;
182
+ if (at.start === series.start && at.end === series.end && !roomIds && !location) return moveOccurrenceBack(repo, actor, id, key, hooks, opts);
183
+ if (current.start <= now) throw started();
184
+ if (at.start <= now) throw refuse(400, 'past', 'Pick a time that hasn’t passed yet.');
185
+
186
+ const next: CalEvent = { ...e, movedDates: { ...e.movedDates, [key]: entry } };
187
+ const clashes = await clashesFor(repo, next, key, at, settings);
188
+ if (clashes.length && role === 'host') await refuseForHost(repo, clashes);
189
+
190
+ await repo.setEventMovedDates(id, next.movedDates, undefined, { quiet: opts.quiet });
191
+ const after = (await repo.getEvent(id))!;
192
+ const moved = findOccurrence(after, key, settings)!;
193
+ await afterChange(repo, after, current, moved, { role, actor: actor!, note, hooks, opts, settings });
194
+ return { event: after, clashes, changed: true };
195
+ }
196
+
197
+ /** Put a moved date back where the series has it (idempotent: a date that isn't moved is `changed: false`). Hosts are refused if its rooms are taken there now. */
198
+ export async function moveOccurrenceBack(repo: ContentRepository, actor: MoveActor | null, id: string, key: string, hooks: SaveHooks, opts: MoveOptions = {}): Promise<MoveResult> {
199
+ const now = opts.now ?? Date.now();
200
+ const e = await load(repo, id);
201
+ const role = roleOf(actor, e);
202
+ if (!Object.hasOwn(e.movedDates, key)) return { event: e, clashes: [], changed: false };
203
+ const settings = await repo.getSettings();
204
+ const { [key]: _gone, ...rest } = e.movedDates;
205
+ const next: CalEvent = { ...e, movedDates: rest };
206
+ const current = findOccurrence(e, key, settings);
207
+ const back = findOccurrence(next, key, settings);
208
+ // a key the series no longer has (it was retimed): nothing to put back, just forget it
209
+ if (current && back) {
210
+ if (current.start <= now) throw started();
211
+ if (back.start <= now) throw refuse(400, 'original-passed', 'Its original time has already passed.');
212
+ }
213
+ const clashes = back ? await clashesFor(repo, next, key, back, settings) : [];
214
+ if (clashes.length && role === 'host') await refuseForHost(repo, clashes);
215
+ await repo.setEventMovedDates(id, rest, undefined, { quiet: opts.quiet });
216
+ const after = (await repo.getEvent(id))!;
217
+ if (current && back) await afterChange(repo, after, current, back, { role, actor: actor!, note: '', hooks, opts, settings });
218
+ else await eventSideEffects(repo, hooks, after);
219
+ return { event: after, clashes, changed: true };
220
+ }
221
+
222
+ // RSVPs follow the date (emailed when its time changed), then the usual side
223
+ // effects (webhook; the app notice unless the write was quiet), then staff hear about a host's move
224
+ async function afterChange(repo: ContentRepository, after: CalEvent, from: Occurrence, to: Occurrence,
225
+ c: { role: Role; actor: MoveActor; note: string; hooks: SaveHooks; opts: MoveOptions; settings: Awaited<ReturnType<ContentRepository['getSettings']>> }) {
226
+ if ((from.start !== to.start || from.end !== to.end) && await repo.moveOccurrenceRsvps(after.id, from.start, { start: to.start, end: to.end }, c.note)) {
227
+ await kickEmail(repo, c.hooks.locals, waitUntilFrom(c.hooks.locals));
228
+ }
229
+ await eventSideEffects(repo, c.hooks, after);
230
+ if (c.role === 'host' && !c.opts.quiet && c.actor !== GOOGLE_SYNC) await notifyStaffOfMove(repo, after, from, to, c.actor.id, c.note, c.settings, c.hooks);
231
+ }
232
+
233
+ // "Date moved" to members.notify (else the owners), like members' requests
234
+ async function notifyStaffOfMove(repo: ContentRepository, e: CalEvent, from: Occurrence, to: Occurrence, by: string, note: string,
235
+ settings: Awaited<ReturnType<ContentRepository['getSettings']>>, hooks: SaveHooks) {
236
+ const recipients = MEMBERS.notify.length ? MEMBERS.notify : (await repo.listUsers()).filter((u) => u.role === 'owner' && u.email).map((u) => u.email);
237
+ if (!recipients.length) return;
238
+ const tz = e.allDay ? settings.timezone || 'UTC' : eventTimeZone(e, settings);
239
+ const fmt = (o: Occurrence) => {
240
+ 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); }
241
+ catch { return new Date(o.start).toISOString(); }
242
+ };
243
+ const version = (await repo.email.config().catch(() => null))?.version ?? 0;
244
+ const at = Date.now();
245
+ for (const recipient of recipients) {
246
+ await repo.email.enqueue({ dedupe: `event-move:${e.id}:${from.key}:${at}:${recipient}`, kind: 'event-request', recipient,
247
+ payload: JSON.stringify({ eventId: e.id, kind: 'staff-moved', at, reply: '', by, note, when: `${fmt(from)} → ${fmt(to)}` }), configVersion: version, expiresAt: at + 7 * DAY });
248
+ }
249
+ await kickEmail(repo, hooks.locals, waitUntilFrom(hooks.locals)).catch(() => {});
250
+ }
251
+
252
+ /** One upcoming date for the editor's Dates panel: its key, wall times (for the move form), words, and state. */
253
+ export interface DateRow {
254
+ key: string;
255
+ /** where it is now, wall-clock in the event's zone (a moved date: its new time) */
256
+ start: string;
257
+ end: string;
258
+ /** "Thu, Jan 10 · 6:00 – 7:00 PM" */
259
+ when: string;
260
+ cancelled: boolean;
261
+ /** moved: "Thu, Jan 10 · 6:00 PM" (where it was) and the note */
262
+ moved: { from: string; note: string } | null;
263
+ roomIds: string[];
264
+ }
265
+
266
+ /** The next `limit` dates of a series from `now`, moved and cancelled ones included. */
267
+ export function upcomingDates(e: CalEvent, settings: Awaited<ReturnType<ContentRepository['getSettings']>>, limit: number, now = Date.now()): DateRow[] {
268
+ if (!e.rrule) return [];
269
+ const tz = e.allDay ? settings.timezone || 'UTC' : eventTimeZone(e, settings);
270
+ const locale = siteLocale(settings);
271
+ const day = new Intl.DateTimeFormat(locale, { weekday: 'short', month: 'short', day: 'numeric', timeZone: tz });
272
+ const time = new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit', timeZone: tz });
273
+ const range = (a: number, b: number) => { try { return time.formatRange(a, b); } catch { return `${time.format(a)} – ${time.format(b)}`; } };
274
+ const es = parseWall(e.start), ee = parseWall(e.end) ?? es;
275
+ const length = es && ee ? wallMinutesBetween(es, ee) : 60;
276
+ return expandEvents([e], settings, { displayTz: settings.timezone || 'UTC', now, windowStartMs: now, windowEndMs: now + 400 * DAY })
277
+ .slice(0, limit)
278
+ .map((o) => {
279
+ const m = o.moved ? e.movedDates[o.key] : undefined;
280
+ const k = parseWall(o.key)!;
281
+ return {
282
+ key: o.key,
283
+ start: m?.start ?? o.key,
284
+ end: m?.end ?? formatWall(e.allDay ? addWallDays(k, Math.round(length / 1440)) : addWallMinutes(k, length)),
285
+ when: e.allDay ? day.format(o.start) : `${day.format(o.start)} · ${range(o.start, o.end)}`,
286
+ cancelled: o.cancelled,
287
+ 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,
288
+ roomIds: o.roomIds,
289
+ };
290
+ });
291
+ }
@@ -12,6 +12,7 @@ import { getCalendarEvents, tzParts, zonedToUtc } from './ics';
12
12
  import { calendarPageUrl } from './src';
13
13
  import { collectViewEvents, type ViewEvent } from './data';
14
14
  import { eventStrings, type EventStrings } from './event-words';
15
+ import { fill } from '../live/words';
15
16
 
16
17
  // Card sources are ICS feed URLs or native site calendars:
17
18
  // 'acms:all' every public calendar (+ uncategorized events)
@@ -58,6 +59,7 @@ interface DisplayEvent {
58
59
  u?: string; // native events: the event page
59
60
  k?: string; // calendar color (#rrggbb), overrides the indexed dot color
60
61
  x?: 1; // cancelled
62
+ mv?: string; // a moved date: "Moved from Thu, Jan 10 · 6:00 PM."
61
63
  }
62
64
 
63
65
  function compactTime(s: string): string {
@@ -65,7 +67,8 @@ function compactTime(s: string): string {
65
67
  }
66
68
 
67
69
  function buildDisplay(events: ViewEvent[], tz: string, locale: string): DisplayEvent[] {
68
- const allDay = eventStrings(locale).allDay;
70
+ const ew = eventStrings(locale);
71
+ const allDay = ew.allDay;
69
72
  const timeFmt = new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit', timeZone: tz });
70
73
  const dayFmt = new Intl.DateTimeFormat(locale, { weekday: 'short', month: 'short', day: 'numeric', timeZone: tz });
71
74
  return events.map((e) => {
@@ -85,6 +88,7 @@ function buildDisplay(events: ViewEvent[], tz: string, locale: string): DisplayE
85
88
  ...(e.url ? { u: e.url } : {}),
86
89
  ...(e.color && /^#[0-9a-f]{6}$/i.test(e.color) ? { k: e.color } : {}),
87
90
  ...(e.cancelled ? { x: 1 as const } : {}),
91
+ ...(e.moved && !e.cancelled ? { mv: fill(ew.dateMoved, { from: e.allDay ? dayFmt.format(e.moved.from.start) : `${dayFmt.format(e.moved.from.start)} · ${timeFmt.format(e.moved.from.start)}` }) } : {}),
88
92
  };
89
93
  });
90
94
  }
@@ -105,7 +109,7 @@ function indexByDay(events: DisplayEvent[]): Map<number, number[]> {
105
109
 
106
110
  const dotStyle = (e: DisplayEvent) => (e.k ? ` style="--cal-dot:${e.k}"` : '');
107
111
  const chipHtml = (e: DisplayEvent, i: number) =>
108
- `<button type="button" class="pc-cal-event${e.x ? ' is-cancelled' : ''}" data-ev="${i}" data-cal="${e.c}"${dotStyle(e)}><span class="t">${esc(e.tm)}</span><b>${esc(e.t)}</b></button>`;
112
+ `<button type="button" class="pc-cal-event${e.x ? ' is-cancelled' : ''}${e.mv ? ' is-moved' : ''}" data-ev="${i}" data-cal="${e.c}"${dotStyle(e)}><span class="t">${esc(e.tm)}</span><b>${esc(e.t)}</b></button>`;
109
113
 
110
114
  function monthGridHtml(y: number, m: number, todayN: number, byDay: Map<number, number[]>, events: DisplayEvent[], dows: string[], monthsShort: string[], ws: number): string {
111
115
  const first = dayNum(y, m, 1);
@@ -137,7 +141,7 @@ function upcomingHtml(events: DisplayEvent[], order: number[], limit: number, w:
137
141
  if (!order.length) return `<p class="pc-cal-empty">${esc(w.calendar.noUpcoming)}</p>`;
138
142
  const items = order.slice(0, limit).map((i) => {
139
143
  const e = events[i];
140
- return `<li><button type="button" class="pc-cal-row${e.x ? ' is-cancelled' : ''}" data-ev="${i}" data-cal="${e.c}"${dotStyle(e)}><span class="d">${esc(e.rg.split(' · ')[0])}</span><span class="ti">${esc(e.t)}</span><span class="t">${esc(e.tm) || esc(w.allDay)}</span></button></li>`;
144
+ return `<li><button type="button" class="pc-cal-row${e.x ? ' is-cancelled' : ''}${e.mv ? ' is-moved' : ''}" data-ev="${i}" data-cal="${e.c}"${dotStyle(e)}><span class="d">${esc(e.rg.split(' · ')[0])}</span><span class="ti">${esc(e.t)}</span><span class="t">${esc(e.tm) || esc(w.allDay)}</span></button></li>`;
141
145
  });
142
146
  return `<ul class="pc-cal-agenda">${items.join('')}</ul>`;
143
147
  }
@@ -154,7 +158,7 @@ function fday(n){var d=new Date(n*D);return[d.getUTCFullYear(),d.getUTCMonth()+1
154
158
  function dow(n){return((n+4-(P.ws||0))%7+7)%7}
155
159
  var byDay={};P.events.forEach(function(e,i){var n0=dnum(e.d);for(var k=0;k<Math.min(e.n,60);k++){(byDay[n0+k]=byDay[n0+k]||[]).push(i)}});
156
160
  function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')}
157
- function chip(i){var e=P.events[i];return '<button type="button" class="pc-cal-event'+(e.x?' is-cancelled':'')+'" data-ev="'+i+'" data-cal="'+e.c+'"'+(e.k?' style="--cal-dot:'+e.k+'"':'')+'><span class="t">'+esc(e.tm)+'</span><b>'+esc(e.t)+'</b></button>'}
161
+ function chip(i){var e=P.events[i];return '<button type="button" class="pc-cal-event'+(e.x?' is-cancelled':'')+(e.mv?' is-moved':'')+'" data-ev="'+i+'" data-cal="'+e.c+'"'+(e.k?' style="--cal-dot:'+e.k+'"':'')+'><span class="t">'+esc(e.tm)+'</span><b>'+esc(e.t)+'</b></button>'}
158
162
  function chips(n){return(byDay[n]||[]).map(chip).join('')}
159
163
  var todayN=dnum(P.today),mY=P.month[0],mM=P.month[1],weekN=todayN-dow(todayN);
160
164
  function renderMonth(){
@@ -190,7 +194,7 @@ if(!hit||!root.contains(hit)){if(!t.closest('.pc-cal-pop'))closePop();return}
190
194
  var e=P.events[Number(hit.getAttribute('data-ev'))];if(!e)return;
191
195
  closePop();
192
196
  pop=document.createElement('div');pop.className='pc-cal-pop';
193
- pop.innerHTML='<button type="button" class="pc-cal-pop-x" aria-label="'+esc(P.close)+'">&times;</button>'+(e.x?'<span class="pc-cal-pop-flag">'+esc(P.cancelled)+'</span>':'')+'<strong class="pc-cal-pop-title">'+esc(e.t)+'</strong><div class="pc-cal-pop-when">'+esc(e.rg)+'</div>'+(e.l?'<div class="pc-cal-pop-loc">'+esc(e.l)+'</div>':'')+(e.ds?'<p class="pc-cal-pop-desc">'+esc(e.ds)+'</p>':'')+(e.u?'<a class="pc-cal-pop-link" href="'+esc(e.u)+'">'+esc(P.details)+' &rarr;</a>':'');
197
+ pop.innerHTML='<button type="button" class="pc-cal-pop-x" aria-label="'+esc(P.close)+'">&times;</button>'+(e.x?'<span class="pc-cal-pop-flag">'+esc(P.cancelled)+'</span>':'')+(e.mv?'<span class="pc-cal-pop-flag is-moved">'+esc(P.moved)+'</span>':'')+'<strong class="pc-cal-pop-title">'+esc(e.t)+'</strong><div class="pc-cal-pop-when">'+esc(e.rg)+'</div>'+(e.mv?'<div class="pc-cal-pop-moved">'+esc(e.mv)+'</div>':'')+(e.l?'<div class="pc-cal-pop-loc">'+esc(e.l)+'</div>':'')+(e.ds?'<p class="pc-cal-pop-desc">'+esc(e.ds)+'</p>':'')+(e.u?'<a class="pc-cal-pop-link" href="'+esc(e.u)+'">'+esc(P.details)+' &rarr;</a>':'');
194
198
  frame.appendChild(pop);
195
199
  var fr=frame.getBoundingClientRect(),cr=hit.getBoundingClientRect();
196
200
  pop.style.left=Math.max(8,Math.min(cr.left-fr.left,fr.width-pop.offsetWidth-8))+'px';
@@ -295,7 +299,7 @@ export function renderCalendarView(raw: ViewEvent[], settings: Settings, now: nu
295
299
  max: [maxY, maxM],
296
300
  months, monthsShort, dows, events, ws,
297
301
  // the pop-up's words in the dates' language, like event cards
298
- details: w.calendar.details, cancelled: w.cancelled, close: w.calendar.close,
302
+ details: w.calendar.details, cancelled: w.cancelled, close: w.calendar.close, moved: w.moved,
299
303
  };
300
304
  const json = JSON.stringify(payload).replace(/</g, '\\u003c');
301
305