@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.
- package/CHANGELOG.md +35 -0
- package/cli/migrations.generated.mjs +42 -23
- package/docs/calendar.md +80 -0
- package/docs/server.md +28 -3
- package/docs/theme-authoring.md +1 -1
- package/package.json +1 -1
- package/src/actions/index.ts +76 -11
- package/src/components/EventPage.astro +18 -12
- package/src/components/calendar/CalendarBoard.tsx +43 -19
- package/src/components/editor/event/EventSheet.tsx +2 -0
- package/src/components/editor/event/SingleDates.tsx +123 -0
- package/src/lib/booking/holds.ts +7 -7
- package/src/lib/calendar/api.ts +11 -1
- package/src/lib/calendar/board.ts +7 -3
- package/src/lib/calendar/cards.ts +5 -5
- package/src/lib/calendar/change-migration.ts +40 -15
- package/src/lib/calendar/data.ts +9 -3
- package/src/lib/calendar/event-words.ts +9 -0
- package/src/lib/calendar/events.ts +94 -9
- package/src/lib/calendar/ics-write.ts +25 -2
- package/src/lib/calendar/moves.ts +291 -0
- package/src/lib/calendar/render.ts +10 -6
- package/src/lib/calendar/schedule.ts +73 -61
- package/src/lib/email/booking-words.ts +7 -1
- package/src/lib/email/service.ts +6 -1
- package/src/lib/email/templates.ts +6 -2
- package/src/lib/mobile/calendar.ts +5 -2
- package/src/lib/mobile/schemas.ts +7 -3
- package/src/lib/push/service.ts +44 -10
- package/src/lib/push/types.ts +2 -0
- package/src/lib/repo/migrations.ts +5 -2
- package/src/lib/repo/sql.ts +28 -10
- package/src/lib/repo/types.ts +6 -3
- package/src/lib/schema.ts +21 -0
- package/src/lib/sections.ts +5 -2
- package/src/lib/sync/catalog.ts +1 -0
- package/src/pages/api/calendar/events/index.ts +6 -0
- package/src/pages/members/_Layout.astro +1 -0
- package/src/pages/members/events/index.astro +1 -1
- package/src/server/index.ts +33 -0
- package/src/styles/editor.css +19 -0
- package/src/styles/schedule.css +2 -0
- package/src/styles/site.css +6 -0
- 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
|
|
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
|
|
174
|
-
|
|
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
|
|
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')}
|
|
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)+'">×</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)+' →</a>':'');
|
|
197
|
+
pop.innerHTML='<button type="button" class="pc-cal-pop-x" aria-label="'+esc(P.close)+'">×</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)+' →</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
|
|