@acms-subir/core 0.3.56 → 0.3.57
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/cli/migrations.generated.mjs +16 -4
- package/docs/calendar.md +116 -2
- package/docs/server.md +33 -1
- package/package.json +1 -1
- package/src/actions/index.ts +52 -9
- package/src/components/EventPage.astro +6 -5
- package/src/components/calendar/CalendarBoard.tsx +6 -3
- package/src/components/editor/event/SingleDates.tsx +75 -15
- package/src/lib/calendar/api.ts +23 -2
- package/src/lib/calendar/board.ts +3 -2
- package/src/lib/calendar/cards.ts +4 -4
- package/src/lib/calendar/change-migration.ts +17 -3
- package/src/lib/calendar/data.ts +9 -2
- package/src/lib/calendar/event-words.ts +8 -0
- package/src/lib/calendar/events.ts +128 -15
- package/src/lib/calendar/google/map.ts +1 -1
- package/src/lib/calendar/host-requests.ts +65 -10
- package/src/lib/calendar/ics-write.ts +65 -15
- package/src/lib/calendar/member-words.ts +14 -1
- package/src/lib/calendar/moves.ts +15 -9
- package/src/lib/calendar/request-admin.ts +2 -1
- package/src/lib/calendar/request-service.ts +5 -5
- package/src/lib/calendar/schedule.ts +42 -8
- package/src/lib/calendar/segments.ts +522 -0
- package/src/lib/editor/schedule.ts +2 -2
- package/src/lib/mobile/calendar.ts +1 -0
- package/src/lib/mobile/schemas.ts +10 -3
- package/src/lib/push/service.ts +42 -10
- package/src/lib/push/types.ts +2 -0
- package/src/lib/repo/migrations.ts +5 -2
- package/src/lib/repo/sql.ts +24 -8
- package/src/lib/repo/types.ts +3 -1
- package/src/lib/schema.ts +24 -0
- package/src/lib/sections.ts +1 -1
- package/src/lib/sync/catalog.ts +1 -0
- package/src/pages/admin/calendar/events/index.astro +9 -8
- package/src/pages/admin/calendar/requests.astro +5 -5
- package/src/pages/api/calendar/events/index.ts +2 -1
- package/src/pages/members/events/index.astro +84 -6
- package/src/server/index.ts +46 -3
- package/src/styles/editor.css +2 -0
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
// "From this date on" (C4): a repeating event changed from one of its dates,
|
|
2
|
+
// with new times, weekday, rule, rooms or place, while the dates before it
|
|
3
|
+
// stay exactly as they were. It stays one event (same page, slug, hosts,
|
|
4
|
+
// RSVPs): the change is a segment on the event row (CalEvent.segments, see
|
|
5
|
+
// events.ts seriesParts), so every write path, trigger and feed sees it.
|
|
6
|
+
//
|
|
7
|
+
// What follows the change point. Each date of the old schedule from `fromKey`
|
|
8
|
+
// on is matched to the new date nearest it, within half a week (3½ days),
|
|
9
|
+
// each new date taking at most one old date (closest pairs first):
|
|
10
|
+
// - its RSVPs move to that date, and each person gets the "new time" email
|
|
11
|
+
// (places in line move quietly); RSVPs on a date with no match are
|
|
12
|
+
// cancelled, with the "cancelled" email;
|
|
13
|
+
// - a cancelled date stays cancelled on its match, a skipped one (exdates)
|
|
14
|
+
// stays skipped, and a moved date keeps its new time under its match's key;
|
|
15
|
+
// - a cancelled, skipped or moved date with no match is dropped (`dropped`).
|
|
16
|
+
// Changing from a date that a segment starts with replaces that segment; a
|
|
17
|
+
// later change replaces every segment after it (Google's "this and
|
|
18
|
+
// following"); a change that gives exactly what the earlier rule gives
|
|
19
|
+
// removes the segment. A cancelled part (cancelFromDate) cancels every date
|
|
20
|
+
// from a date on, and restoreFromDate puts them back.
|
|
21
|
+
//
|
|
22
|
+
// Who: staff ("Create and edit content") and GOOGLE_SYNC. Room clashes come
|
|
23
|
+
// back as warnings. Hosts send it as a request (host-requests.ts), and staff
|
|
24
|
+
// apply it here.
|
|
25
|
+
import type { ContentRepository } from '../repo/types';
|
|
26
|
+
import { EventLocation, type CalEvent, type SeriesSegment } from '../schema';
|
|
27
|
+
import { ApiError, coded } from '../api';
|
|
28
|
+
import { eventSideEffects, type SaveHooks } from '../content-service';
|
|
29
|
+
import { addWallDays, addWallMinutes, cutsAt, eventTimeZone, expandEvents, findOccurrence, formatWall, normalizeEventTimes, parseWall, partOf, seriesParts, wallMinutesBetween, type Occurrence, type SeriesPart } from './events';
|
|
30
|
+
import { cleanRrule, describeRepeat, rruleMap } from './rrule';
|
|
31
|
+
import { siteLocale } from '../dates';
|
|
32
|
+
import { shiftRrule } from './schedule';
|
|
33
|
+
import { GOOGLE_SYNC, type MoveActor } from './moves';
|
|
34
|
+
import { holdsFromEvents, roomBusy, type BusySlot } from '../booking/holds';
|
|
35
|
+
import { kickEmail } from '../email/service';
|
|
36
|
+
import { waitUntilFrom } from '../webhooks';
|
|
37
|
+
|
|
38
|
+
export interface ChangeFromInput {
|
|
39
|
+
/** the new part's first date: wall-clock in the event's zone, like `start` (YYYY-MM-DDTHH:mm, or YYYY-MM-DD all day); may come before `fromKey` */
|
|
40
|
+
start: string;
|
|
41
|
+
/** its end in the same form (all-day: the last day, inclusive); default: the length the dates have now */
|
|
42
|
+
end?: string;
|
|
43
|
+
/** its rule (RRULE without the prefix; '' = just that date); default: the rule in effect, moved to the new start's weekday or day, its COUNT what's left */
|
|
44
|
+
rrule?: string;
|
|
45
|
+
/** its rooms (Resource ids); default: the rooms in effect at `fromKey` */
|
|
46
|
+
roomIds?: string[];
|
|
47
|
+
/** its place; null = the event's; default: the place in effect at `fromKey` */
|
|
48
|
+
location?: Partial<EventLocation> | null;
|
|
49
|
+
/** a public note ("We meet on Wednesdays from January") */
|
|
50
|
+
note?: string;
|
|
51
|
+
}
|
|
52
|
+
export interface SegmentOptions {
|
|
53
|
+
/** no member push for it (a sync catching up); RSVP emails still go */
|
|
54
|
+
quiet?: boolean;
|
|
55
|
+
now?: number;
|
|
56
|
+
}
|
|
57
|
+
export interface ChangeFromResult {
|
|
58
|
+
event: CalEvent;
|
|
59
|
+
/** what else takes the new dates' rooms over the next 180 days (warnings) */
|
|
60
|
+
clashes: BusySlot[];
|
|
61
|
+
/** false: it already was so; nothing was written, sent or emailed */
|
|
62
|
+
changed: boolean;
|
|
63
|
+
/** RSVPs moved to their new date, and cancelled for want of one */
|
|
64
|
+
rsvps: { moved: number; cancelled: number };
|
|
65
|
+
/** old cancelled, skipped or moved dates (keys) dropped for want of a new date */
|
|
66
|
+
dropped: string[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Why a change was refused (error.code, with error.status): as for moves, plus previous-date (the new first date isn't after the date before the change), first-date (the change is from the series' first date: edit the event instead), bad-rule, host-request (a host: send it as a request), not-cancelled. */
|
|
70
|
+
export type SegmentRefusal = 'one-off' | 'cancelled-event' | 'not-a-date' | 'started' | 'past' | 'bad-time' | 'previous-date' | 'first-date' | 'bad-rule' | 'host-request' | 'not-cancelled';
|
|
71
|
+
const refuse = (status: number, code: SegmentRefusal, message: string) => coded(status, code, message);
|
|
72
|
+
|
|
73
|
+
const DAY = 86_400_000;
|
|
74
|
+
const NOTE_MAX = 500;
|
|
75
|
+
/** how far a date's sign-ups, cancellation or move may travel to its new date */
|
|
76
|
+
export const FOLLOW_WITHIN = 3.5 * DAY;
|
|
77
|
+
// how far ahead old and new dates are matched, and clashes looked for
|
|
78
|
+
const HORIZON = 2 * 366 * DAY;
|
|
79
|
+
const CLASH_DAYS = 180;
|
|
80
|
+
|
|
81
|
+
type Settings = Awaited<ReturnType<ContentRepository['getSettings']>>;
|
|
82
|
+
|
|
83
|
+
function requireStaff(actor: MoveActor | null, e: CalEvent) {
|
|
84
|
+
if (actor === GOOGLE_SYNC) return;
|
|
85
|
+
if (!actor) throw coded(401, 'sign-in', 'Sign in to continue.');
|
|
86
|
+
if (actor.can('content.edit')) return;
|
|
87
|
+
if (e.hostIds.includes(actor.id)) throw refuse(403, 'host-request', 'Send this change to the team as a request. They apply changes from a date on.');
|
|
88
|
+
throw coded(404, 'not-found', 'Event not found.');
|
|
89
|
+
}
|
|
90
|
+
const actorId = (actor: MoveActor) => (actor === GOOGLE_SYNC ? GOOGLE_SYNC : actor.id);
|
|
91
|
+
|
|
92
|
+
async function load(repo: ContentRepository, id: string) {
|
|
93
|
+
const e = await repo.getEvent(id);
|
|
94
|
+
if (!e || e.status === 'archived') throw coded(404, 'not-found', 'Event not found.');
|
|
95
|
+
return e;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// every date of the series as the rules make them, moves, cancellations and skips aside
|
|
99
|
+
const bare = (e: CalEvent): CalEvent => ({ ...e, movedDates: {}, cancelledDates: [], exdates: [] });
|
|
100
|
+
const datesFrom = (e: CalEvent, settings: Settings, from: number, to = from + HORIZON) =>
|
|
101
|
+
expandEvents([bare(e)], settings, { displayTz: settings.timezone || 'UTC', now: from, windowStartMs: from, windowEndMs: to });
|
|
102
|
+
|
|
103
|
+
// The date at `key` and the part of the series it's in, checked for
|
|
104
|
+
// changing. A key a segment took over from (no longer a date itself: a retry,
|
|
105
|
+
// or a change of that segment) means that segment, at the date the series
|
|
106
|
+
// had there. `replaces`: the change takes that part's place rather than
|
|
107
|
+
// starting inside it (it has no dates before `key`).
|
|
108
|
+
function changePoint(e: CalEvent, key: string, settings: Settings, now: number) {
|
|
109
|
+
if (!e.rrule) throw refuse(400, 'one-off', 'Only a repeating event can change from a date on. Change the event’s time instead.');
|
|
110
|
+
if (e.cancelled) throw refuse(409, 'cancelled-event', 'This event is cancelled.');
|
|
111
|
+
const valid = typeof key === 'string' && key.length <= 20;
|
|
112
|
+
let at = valid ? findOccurrence(bare(e), key, settings) : null;
|
|
113
|
+
let part = at ? partOf(e, at) : null;
|
|
114
|
+
if (!at && valid && e.segments.some((g) => g.from === key)) {
|
|
115
|
+
at = findOccurrence(bare({ ...e, segments: e.segments.filter((g) => g.from < key) }), key, settings);
|
|
116
|
+
part = seriesParts(e).find((p) => p.from === key) ?? null;
|
|
117
|
+
}
|
|
118
|
+
if (!at || !part) throw refuse(400, 'not-a-date', 'That date isn’t part of this event.');
|
|
119
|
+
if (at.start <= now) throw refuse(400, 'started', 'That date has already started or passed. Pick a date still to come.');
|
|
120
|
+
// the dates before it, nearest last (the new first date must come after them)
|
|
121
|
+
const before = expandEvents([bare(e)], settings, { displayTz: settings.timezone || 'UTC', now, windowStartMs: at.start - 400 * DAY, windowEndMs: at.start + FOLLOW_WITHIN })
|
|
122
|
+
.filter((o) => o.key < key);
|
|
123
|
+
const mine = (o: Occurrence) => (o.segment ?? '') === part!.from;
|
|
124
|
+
const replaces = Boolean(part.from) && (key === part.from || !before.some(mine));
|
|
125
|
+
const previous = (replaces ? before.filter((o) => !mine(o)) : before).at(-1) ?? null;
|
|
126
|
+
// the part's date the change starts from, for its rule's default: a replaced segment's own first date
|
|
127
|
+
const anchor = (replaces && findOccurrence(bare(e), part.start, settings)) || at;
|
|
128
|
+
// a replaced segment whose first date (maybe before `key`) has started can't change any more
|
|
129
|
+
const started = replaces && anchor.start <= now;
|
|
130
|
+
return { at, part, previous, replaces, anchor, started };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// a part's dates as a rule of its own, anchored at the date `at` (a cancelled part, or the default rule for a change)
|
|
134
|
+
function ruleFrom(e: CalEvent, part: SeriesPart, at: Occurrence, newStart: string, settings: Settings): string {
|
|
135
|
+
const rule = cleanRrule(part.rrule);
|
|
136
|
+
if (!rule) return '';
|
|
137
|
+
const p = rruleMap(rule);
|
|
138
|
+
const from = parseWall(at.key), to = parseWall(newStart);
|
|
139
|
+
const days = from && to ? Math.round(wallMinutesBetween({ ...from, hh: 0, mm: 0 }, { ...to, hh: 0, mm: 0 }) / 1440) : 0;
|
|
140
|
+
// the weekdays (or day of the month) follow the new start; the end date stays
|
|
141
|
+
let out = shiftRrule(rule, days, newStart);
|
|
142
|
+
if (p.UNTIL) out = out.replace(/UNTIL=[^;]*/, `UNTIL=${p.UNTIL}`);
|
|
143
|
+
if (p.COUNT) {
|
|
144
|
+
const solo: CalEvent = { ...bare(e), start: part.start, end: part.end, rrule: rule, segments: [] };
|
|
145
|
+
const left = expandEvents([solo], settings, { displayTz: settings.timezone || 'UTC', now: at.start, windowStartMs: at.start, windowEndMs: at.start + 10 * 366 * DAY }).length;
|
|
146
|
+
out = out.replace(/COUNT=\d+/, `COUNT=${Math.max(1, left)}`);
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// the new first date's times in the event's form; `end` defaults to the length the dates have now
|
|
152
|
+
function times(e: CalEvent, part: SeriesPart, input: Pick<ChangeFromInput, 'start' | 'end'>): { start: string; end: string } {
|
|
153
|
+
const s = parseWall(input.start);
|
|
154
|
+
if (!s) throw refuse(400, 'bad-time', e.allDay ? 'Pick a date (YYYY-MM-DD).' : 'Pick a date and time (YYYY-MM-DDTHH:mm).');
|
|
155
|
+
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).');
|
|
156
|
+
let end = input.end;
|
|
157
|
+
if (!end) {
|
|
158
|
+
const ps = parseWall(part.start), pe = parseWall(part.end) ?? ps;
|
|
159
|
+
const length = ps && pe ? wallMinutesBetween(ps, pe) : 60;
|
|
160
|
+
end = formatWall(e.allDay ? addWallDays(s, Math.round(length / 1440)) : addWallMinutes(s, Math.max(1, length)));
|
|
161
|
+
}
|
|
162
|
+
try { return normalizeEventTimes({ start: input.start, end, allDay: e.allDay }); }
|
|
163
|
+
catch (err) { throw refuse(400, 'bad-time', err instanceof Error ? err.message : 'Pick a valid time.'); }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const sameRooms = (a: string[], b: string[]) => a.length === b.length && [...a].sort().every((x, i) => x === [...b].sort()[i]);
|
|
167
|
+
const sameDates = (a: Occurrence[], b: Occurrence[]) => a.length === b.length && a.every((o, i) => o.key === b[i].key && o.start === b[i].start && o.end === b[i].end);
|
|
168
|
+
|
|
169
|
+
// A segment that gives exactly what the part before it would (same dates over
|
|
170
|
+
// the horizon, rooms, place, not cancelled) isn't a change: drop it. Each is
|
|
171
|
+
// checked against the parts kept before it.
|
|
172
|
+
function normalize(e: CalEvent, segments: SeriesSegment[], settings: Settings, now: number): SeriesSegment[] {
|
|
173
|
+
const kept: SeriesSegment[] = [];
|
|
174
|
+
const sorted = [...segments].sort((a, b) => (a.from < b.from ? -1 : a.from > b.from ? 1 : 0));
|
|
175
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
176
|
+
const g = sorted[i];
|
|
177
|
+
const next = sorted[i + 1]?.from ?? '';
|
|
178
|
+
const withIt = { ...e, segments: [...kept, g, ...(next ? [sorted[i + 1]] : [])] };
|
|
179
|
+
const without = { ...e, segments: [...kept, ...(next ? [sorted[i + 1]] : [])] };
|
|
180
|
+
const prev = partOf(without, { segment: kept[kept.length - 1]?.from });
|
|
181
|
+
const mine = seriesParts(withIt).find((p) => p.from === g.from)!;
|
|
182
|
+
// quick no: cancelled, other rooms or place, or a first date the earlier rule doesn't have there
|
|
183
|
+
const firstThen = findOccurrence(bare(without), g.start, settings);
|
|
184
|
+
const firstNow = findOccurrence(bare(withIt), g.start, settings);
|
|
185
|
+
const maybe = !g.cancelled && !prev.cancelled && sameRooms(mine.roomIds, prev.roomIds) && JSON.stringify(mine.location ?? null) === JSON.stringify(prev.location ?? null)
|
|
186
|
+
&& g.from === g.start && firstThen && firstNow && (firstThen.segment ?? '') === prev.from && firstThen.start === firstNow.start && firstThen.end === firstNow.end;
|
|
187
|
+
if (!maybe) { kept.push(g); continue; }
|
|
188
|
+
// then the dates themselves, over the next 400 days or to the later end date of the two rules
|
|
189
|
+
const span = Math.min(10 * 366 * DAY, Math.max(400 * DAY, untilMs(g.rrule) - firstNow.start, untilMs(prev.rrule) - firstNow.start) + 2 * DAY);
|
|
190
|
+
const a = datesFrom(withIt, settings, firstNow.start - DAY, firstNow.start + span).filter((o) => o.segment === g.from);
|
|
191
|
+
const b = datesFrom(without, settings, firstNow.start - DAY, firstNow.start + span).filter((o) => (o.segment ?? '') === prev.from && cutsAt(g.from, o.key));
|
|
192
|
+
if (!sameDates(a, b)) kept.push(g);
|
|
193
|
+
}
|
|
194
|
+
return kept;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// a date's series times as wall-clock "start|end" (to compare with a MovedDate)
|
|
198
|
+
function formatWallAt(o: Occurrence, e: CalEvent, _settings: Settings): string {
|
|
199
|
+
const w = parseWall(o.key);
|
|
200
|
+
if (!w) return '';
|
|
201
|
+
const part = partOf(e, o);
|
|
202
|
+
const ps = parseWall(part.start), pe = parseWall(part.end) ?? ps;
|
|
203
|
+
const length = ps && pe ? wallMinutesBetween(ps, pe) : 0;
|
|
204
|
+
return `${o.key}|${formatWall(e.allDay ? addWallDays(w, Math.round(length / 1440)) : addWallMinutes(w, length))}`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// a rule's UNTIL as a time (roughly: the day's end in UTC), 0 when it has none
|
|
208
|
+
function untilMs(rrule: string): number {
|
|
209
|
+
const u = rruleMap(rrule).UNTIL;
|
|
210
|
+
return u ? Date.UTC(+u.slice(0, 4), +u.slice(4, 6) - 1, +u.slice(6, 8)) + DAY : 0;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// old dates (from the change point on) → new dates: nearest within FOLLOW_WITHIN, closest pairs first, one each
|
|
214
|
+
function matchDates(old: Occurrence[], fresh: Occurrence[]): Map<string, Occurrence> {
|
|
215
|
+
const pairs: { o: Occurrence; n: Occurrence; d: number }[] = [];
|
|
216
|
+
for (const o of old) for (const n of fresh) {
|
|
217
|
+
const d = Math.abs(n.start - o.start);
|
|
218
|
+
if (d <= FOLLOW_WITHIN) pairs.push({ o, n, d });
|
|
219
|
+
}
|
|
220
|
+
pairs.sort((x, y) => x.d - y.d || x.o.start - y.o.start);
|
|
221
|
+
const out = new Map<string, Occurrence>();
|
|
222
|
+
const taken = new Set<string>();
|
|
223
|
+
for (const { o, n } of pairs) {
|
|
224
|
+
if (out.has(o.key) || taken.has(n.key)) continue;
|
|
225
|
+
out.set(o.key, n);
|
|
226
|
+
taken.add(n.key);
|
|
227
|
+
}
|
|
228
|
+
return out;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// what else takes the new part's rooms over the next CLASH_DAYS (each room checked once over the span)
|
|
232
|
+
async function clashesFor(repo: ContentRepository, next: CalEvent, from: string, settings: Settings, start: number): Promise<BusySlot[]> {
|
|
233
|
+
const resources = new Map((await repo.listResources()).map((r) => [r.id, r]));
|
|
234
|
+
const to = start + CLASH_DAYS * DAY;
|
|
235
|
+
const mine = new Set(expandEvents([next], settings, { displayTz: settings.timezone || 'UTC', now: start, windowStartMs: start - DAY, windowEndMs: to + DAY })
|
|
236
|
+
.filter((o) => o.segment === from).map((o) => o.key));
|
|
237
|
+
const holds = holdsFromEvents([next], resources, settings, start, to, { force: true }).filter((h) => mine.has(h.key));
|
|
238
|
+
if (!holds.length) return [];
|
|
239
|
+
const out: BusySlot[] = [];
|
|
240
|
+
const seen = new Set<string>();
|
|
241
|
+
for (const room of [...new Set(holds.map((h) => h.resourceId))]) {
|
|
242
|
+
const busy = await roomBusy(repo, settings, { resourceIds: [room], from: start, to, ignore: { eventId: next.id } });
|
|
243
|
+
for (const b of busy) {
|
|
244
|
+
if (!holds.some((h) => h.resourceId === room && h.start < b.end && b.start < h.end)) continue;
|
|
245
|
+
const id = `${b.resourceId}:${b.kind}:${b.bookingId ?? `${b.eventId}:${b.key}`}:${b.start}`;
|
|
246
|
+
if (!seen.has(id)) { seen.add(id); out.push(b); }
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return out.sort((a, b) => a.start - b.start);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Rewrites the series from `fromKey` with `segments` (the earlier ones kept),
|
|
254
|
+
* carrying cancelled, skipped and moved dates and RSVPs over to their new
|
|
255
|
+
* dates (see the top of this file), in one write.
|
|
256
|
+
*/
|
|
257
|
+
async function apply(repo: ContentRepository, e: CalEvent, fromKey: string, at: Occurrence, segments: SeriesSegment[], note: string, hooks: SaveHooks, settings: Settings, opts: SegmentOptions, cancelling: boolean) {
|
|
258
|
+
const now = opts.now ?? Date.now();
|
|
259
|
+
const next: CalEvent = { ...e, segments: normalize(e, segments, settings, now) };
|
|
260
|
+
// how far to look: past the last date anything is attached to (an RSVP, a cancelled, moved or skipped date), at least five weeks
|
|
261
|
+
const bookings = await repo.listBookings({ eventId: e.id, kinds: ['rsvp'], from: Math.min(at.start, now) - 4 * DAY, statuses: ['pending', 'confirmed', 'offered', 'waitlisted'] });
|
|
262
|
+
const signedUp = new Set(bookings.map((b) => b.start));
|
|
263
|
+
const roughly = (key: string) => { const ms = Date.parse(`${key.length === 10 ? `${key}T00:00` : key}:00Z`); return Number.isFinite(ms) ? ms + 2 * DAY : 0; };
|
|
264
|
+
const last = Math.max(0, ...bookings.map((b) => b.end), ...[...e.cancelledDates, ...e.exdates, ...Object.keys(e.movedDates)].map(roughly),
|
|
265
|
+
...Object.values(e.movedDates).map((m) => roughly(m.start)));
|
|
266
|
+
const startAt = Math.min(at.start, now) - 4 * DAY;
|
|
267
|
+
const endAt = Math.min(at.start + HORIZON, Math.max(at.start + 35 * DAY, last + 7 * DAY));
|
|
268
|
+
const span = (x: CalEvent) => datesFrom(x, settings, startAt, endAt);
|
|
269
|
+
// the old dates the change takes over (from `fromKey` on, and those of the segments it replaces), and the dates the new schedule makes there
|
|
270
|
+
const taken = (o: Occurrence) => cutsAt(fromKey, o.key) || (o.segment !== undefined && o.segment >= fromKey);
|
|
271
|
+
const all = span(e);
|
|
272
|
+
const old = all.filter(taken);
|
|
273
|
+
const oldKeys = new Set(old.map((o) => o.key));
|
|
274
|
+
const keepBefore = new Set(all.filter((o) => !taken(o)).map((o) => o.key));
|
|
275
|
+
const fresh = span(next).filter((o) => !keepBefore.has(o.key));
|
|
276
|
+
const match = matchDates(old, fresh);
|
|
277
|
+
const dropped: string[] = [];
|
|
278
|
+
const follow = (key: string) => (oldKeys.has(key) ? match.get(key)?.key ?? null : key);
|
|
279
|
+
// a cancelled, skipped or moved date carried over, or listed as dropped
|
|
280
|
+
const carry = (key: string) => { const k = follow(key); if (k === null) dropped.push(key); return k; };
|
|
281
|
+
const cancelledDates = [...new Set(e.cancelledDates.map(carry).filter((k): k is string => k !== null))].sort();
|
|
282
|
+
const exdates = [...new Set(e.exdates.map(carry).filter((k): k is string => k !== null))];
|
|
283
|
+
const movedDates: CalEvent['movedDates'] = {};
|
|
284
|
+
for (const [key, m] of Object.entries(e.movedDates)) {
|
|
285
|
+
// cancelling from a date on drops the moves there (a date is moved or cancelled, never both)
|
|
286
|
+
if (cancelling && oldKeys.has(key)) { dropped.push(key); continue; }
|
|
287
|
+
const k = carry(key);
|
|
288
|
+
if (k === null || cancelledDates.includes(k)) continue;
|
|
289
|
+
// a move to where its new date already is, in its rooms and place, isn't one any more
|
|
290
|
+
const there = findOccurrence({ ...next, movedDates: {}, cancelledDates: [], exdates: [] }, k, settings);
|
|
291
|
+
const plain = there && !m.roomIds && !m.location && formatWallAt(there, next, settings) === `${m.start}|${m.end}`;
|
|
292
|
+
if (!plain) movedDates[k] = m;
|
|
293
|
+
}
|
|
294
|
+
const written: CalEvent = { ...next, cancelledDates, exdates, movedDates };
|
|
295
|
+
const changed = JSON.stringify([written.segments, cancelledDates, exdates, movedDates]) !== JSON.stringify([e.segments, e.cancelledDates, e.exdates, e.movedDates]);
|
|
296
|
+
if (!changed) return { written: e, changed, dropped: [], plan: [] as { from: number; to: Occurrence | null }[] };
|
|
297
|
+
|
|
298
|
+
// RSVPs: each old date's (where it is now: a moved one keeps its time) to its match, else cancelled
|
|
299
|
+
const plan: { from: number; to: Occurrence | null }[] = [];
|
|
300
|
+
for (const o of old) {
|
|
301
|
+
const nowAt = findOccurrence(e, o.key, settings);
|
|
302
|
+
if (nowAt && !signedUp.has(nowAt.start)) continue;
|
|
303
|
+
if (!nowAt || nowAt.cancelled) continue;
|
|
304
|
+
const k = follow(o.key);
|
|
305
|
+
const to = k === null ? null : findOccurrence(written, k, settings);
|
|
306
|
+
if (to && !to.cancelled && to.start === nowAt.start && to.end === nowAt.end) continue;
|
|
307
|
+
plan.push({ from: nowAt.start, to: to && !to.cancelled ? to : null });
|
|
308
|
+
}
|
|
309
|
+
await repo.setEventDates(e.id, { segments: written.segments, cancelledDates, exdates, movedDates }, { quiet: opts.quiet });
|
|
310
|
+
return { written, changed, dropped: [...new Set(dropped)].sort(), plan };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function afterApply(repo: ContentRepository, id: string, plan: { from: number; to: Occurrence | null }[], note: string, hooks: SaveHooks) {
|
|
314
|
+
let moved = 0, cancelled = 0;
|
|
315
|
+
for (const p of plan) {
|
|
316
|
+
if (p.to) moved += await repo.moveOccurrenceRsvps(id, p.from, { start: p.to.start, end: p.to.end }, note);
|
|
317
|
+
else cancelled += await repo.cancelOccurrenceRsvps(id, p.from);
|
|
318
|
+
}
|
|
319
|
+
if (moved || cancelled) await kickEmail(repo, hooks.locals, waitUntilFrom(hooks.locals));
|
|
320
|
+
const after = (await repo.getEvent(id))!;
|
|
321
|
+
await eventSideEffects(repo, hooks, after);
|
|
322
|
+
return { after, rsvps: { moved, cancelled } };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Change a repeating event from one of its dates on: new times, weekday,
|
|
327
|
+
* rule, rooms or place, keeping every date before it as it was. See the top
|
|
328
|
+
* of this file for what follows the change. Staff and GOOGLE_SYNC only; room
|
|
329
|
+
* clashes come back as warnings. Idempotent (`changed: false`).
|
|
330
|
+
*/
|
|
331
|
+
export async function changeFromDate(repo: ContentRepository, actor: MoveActor | null, id: string, fromKey: string, input: ChangeFromInput, hooks: SaveHooks, opts: SegmentOptions = {}): Promise<ChangeFromResult> {
|
|
332
|
+
const now = opts.now ?? Date.now();
|
|
333
|
+
const e = await load(repo, id);
|
|
334
|
+
requireStaff(actor, e);
|
|
335
|
+
const settings = await repo.getSettings();
|
|
336
|
+
const { from, at, segment, first, same } = await planChange(repo, e, settings, actor!, fromKey, input, now);
|
|
337
|
+
// a retry of what's there already (whoever made it, whenever) changes nothing
|
|
338
|
+
if (same && !e.segments.some((g) => g.from > from)) return { event: e, clashes: [], changed: false, rsvps: { moved: 0, cancelled: 0 }, dropped: [] };
|
|
339
|
+
const segments = [...e.segments.filter((g) => g.from < from), segment];
|
|
340
|
+
const r = await apply(repo, e, from, at, segments, segment.note, hooks, settings, opts, false);
|
|
341
|
+
if (!r.changed) return { event: e, clashes: [], changed: false, rsvps: { moved: 0, cancelled: 0 }, dropped: [] };
|
|
342
|
+
const done = await afterApply(repo, id, r.plan, segment.note, hooks);
|
|
343
|
+
const clashes = r.written.segments.some((g) => g.from === from) ? await clashesFor(repo, done.after, from, settings, Math.max(now, first.start - DAY)) : [];
|
|
344
|
+
return { event: done.after, clashes, changed: true, rsvps: done.rsvps, dropped: r.dropped };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* What changeFromDate would write, checked but not written (a host's request
|
|
349
|
+
* is checked this way before it goes to staff): the same refusals, without
|
|
350
|
+
* the staff check. `segment` is the new part, normalized.
|
|
351
|
+
*/
|
|
352
|
+
export async function checkChangeFromDate(repo: ContentRepository, actor: MoveActor, id: string, fromKey: string, input: ChangeFromInput, opts: Pick<SegmentOptions, 'now'> = {}): Promise<{ segment: SeriesSegment; changed: boolean }> {
|
|
353
|
+
const now = opts.now ?? Date.now();
|
|
354
|
+
const e = await load(repo, id);
|
|
355
|
+
const settings = await repo.getSettings();
|
|
356
|
+
const { from, segment, same } = await planChange(repo, e, settings, actor, fromKey, input, now);
|
|
357
|
+
if (same && !e.segments.some((g) => g.from > from)) return { segment, changed: false };
|
|
358
|
+
const next = normalize(e, [...e.segments.filter((g) => g.from < from), segment], settings, now);
|
|
359
|
+
return { segment, changed: JSON.stringify(next.map(withoutWho)) !== JSON.stringify(e.segments.map(withoutWho)) };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async function planChange(repo: ContentRepository, e: CalEvent, settings: Settings, actor: MoveActor, fromKey: string, input: ChangeFromInput, now: number) {
|
|
363
|
+
const { at, part, previous, replaces, anchor, started } = changePoint(e, fromKey, settings, now);
|
|
364
|
+
if (!previous) throw refuse(400, 'first-date', 'That’s the first date of the series. Change the event’s own time and repeat instead.');
|
|
365
|
+
const t = times(e, part, input);
|
|
366
|
+
const rrule = input.rrule === undefined ? ruleFrom(e, part, anchor, t.start, settings) : cleanRrule(String(input.rrule));
|
|
367
|
+
if (input.rrule !== undefined && String(input.rrule).trim() && !rrule) throw refuse(400, 'bad-rule', 'That repeat rule isn’t one the calendar understands.');
|
|
368
|
+
|
|
369
|
+
const resources = await repo.listResources();
|
|
370
|
+
let roomIds = input.roomIds === undefined ? undefined : [...new Set(input.roomIds)].filter((r) => resources.some((x) => x.id === r));
|
|
371
|
+
if (roomIds && sameRooms(roomIds, e.roomHold.resourceIds)) roomIds = undefined;
|
|
372
|
+
if (input.roomIds === undefined && part.from) roomIds = sameRooms(part.roomIds, e.roomHold.resourceIds) ? undefined : part.roomIds;
|
|
373
|
+
let location = input.location === undefined ? part.location : input.location === null ? undefined : EventLocation.parse({ ...e.location, ...input.location });
|
|
374
|
+
if (location && !location.name.trim() && !location.address.trim()) throw new ApiError(400, 'Give the place a name or an address.');
|
|
375
|
+
if (location && JSON.stringify(location) === JSON.stringify(e.location)) location = undefined;
|
|
376
|
+
const note = String(input.note ?? '').replace(/\s+/g, ' ').trim().slice(0, NOTE_MAX);
|
|
377
|
+
|
|
378
|
+
// a change from the date a segment starts with replaces that segment (keeping where it took over)
|
|
379
|
+
const from = replaces ? part.from : fromKey;
|
|
380
|
+
const fresh: SeriesSegment = { from, start: t.start, end: t.end, rrule, ...(roomIds ? { roomIds } : {}), ...(location ? { location } : {}), cancelled: false, note, by: actorId(actor), at: now };
|
|
381
|
+
// the same change as the one there: keep that one (its by/at) as it is
|
|
382
|
+
const existing = e.segments.find((g) => g.from === from);
|
|
383
|
+
const same = Boolean(existing && JSON.stringify(withoutWho(existing)) === JSON.stringify(withoutWho(fresh)));
|
|
384
|
+
// the first date has to be one of the new rule's own dates (its DTSTART); a retry of what's there skips the time checks
|
|
385
|
+
const first = findOccurrence({ ...bare(e), start: t.start, end: t.end, rrule, segments: [] }, t.start, settings);
|
|
386
|
+
if (!first) throw refuse(400, 'bad-rule', 'The first date has to be one of the new pattern’s dates.');
|
|
387
|
+
if (!same) {
|
|
388
|
+
if (started) throw refuse(400, 'started', 'That change has already started. Change from a date still to come instead.');
|
|
389
|
+
if (first.start <= now) throw refuse(400, 'past', 'Pick a first date that hasn’t passed yet.');
|
|
390
|
+
if (first.start <= previous.start) throw refuse(400, 'previous-date', 'Pick a first date after the date before the change.');
|
|
391
|
+
}
|
|
392
|
+
return { from, at, segment: same ? existing! : fresh, first, same };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// a segment as what it does, not who made it when
|
|
396
|
+
const withoutWho = ({ by: _by, at: _at, ...g }: SeriesSegment) => ({ ...g, roomIds: g.roomIds ?? null, location: g.location ?? null });
|
|
397
|
+
|
|
398
|
+
/** Cancel every date of a repeating event from one on: they stay listed, struck through, hold no room, and their RSVPs are cancelled (emailed). Idempotent. */
|
|
399
|
+
export async function cancelFromDate(repo: ContentRepository, actor: MoveActor | null, id: string, fromKey: string, hooks: SaveHooks, opts: SegmentOptions = {}): Promise<ChangeFromResult> {
|
|
400
|
+
const now = opts.now ?? Date.now();
|
|
401
|
+
const e = await load(repo, id);
|
|
402
|
+
requireStaff(actor, e);
|
|
403
|
+
const settings = await repo.getSettings();
|
|
404
|
+
const { at, part, previous, replaces } = changePoint(e, fromKey, settings, now);
|
|
405
|
+
if (part.cancelled) return { event: e, clashes: [], changed: false, rsvps: { moved: 0, cancelled: 0 }, dropped: [] };
|
|
406
|
+
if (!previous) throw refuse(400, 'first-date', 'That’s the first date of the series. Cancel the whole event instead.');
|
|
407
|
+
const from = replaces ? part.from : fromKey;
|
|
408
|
+
const startWall = at.key;
|
|
409
|
+
const t = times(e, part, { start: startWall });
|
|
410
|
+
const own = e.segments.find((g) => g.from === from);
|
|
411
|
+
const segment: SeriesSegment = own && from === part.from
|
|
412
|
+
? { ...own, cancelled: true, by: actorId(actor!), at: now }
|
|
413
|
+
: { from, start: t.start, end: t.end, rrule: ruleFrom(e, part, at, t.start, settings), ...(part.from && !sameRooms(part.roomIds, e.roomHold.resourceIds) ? { roomIds: part.roomIds } : {}),
|
|
414
|
+
...(part.location ? { location: part.location } : {}), cancelled: true, note: '', by: actorId(actor!), at: now };
|
|
415
|
+
// every date from there on: later segments go too, and the rule in effect carries on, struck through
|
|
416
|
+
const r = await apply(repo, e, from, at, [...e.segments.filter((g) => g.from < from), segment], '', hooks, settings, opts, true);
|
|
417
|
+
if (!r.changed) return { event: e, clashes: [], changed: false, rsvps: { moved: 0, cancelled: 0 }, dropped: [] };
|
|
418
|
+
const done = await afterApply(repo, id, r.plan, '', hooks);
|
|
419
|
+
return { event: done.after, clashes: [], changed: true, rsvps: done.rsvps, dropped: r.dropped };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Undo cancelFromDate (at the date it took effect, or any date it covers): the dates are back on; their RSVPs stay cancelled, so people sign up again. Idempotent. */
|
|
423
|
+
export async function restoreFromDate(repo: ContentRepository, actor: MoveActor | null, id: string, fromKey: string, hooks: SaveHooks, opts: SegmentOptions = {}): Promise<ChangeFromResult> {
|
|
424
|
+
const now = opts.now ?? Date.now();
|
|
425
|
+
const e = await load(repo, id);
|
|
426
|
+
requireStaff(actor, e);
|
|
427
|
+
const settings = await repo.getSettings();
|
|
428
|
+
// a date the cancelled part has, or the key it took over from
|
|
429
|
+
const o = findOccurrence(bare(e), fromKey, settings);
|
|
430
|
+
const part = o ? partOf(e, o) : seriesParts(e).find((p) => p.from === fromKey) ?? null;
|
|
431
|
+
if (!part?.cancelled) return { event: e, clashes: [], changed: false, rsvps: { moved: 0, cancelled: 0 }, dropped: [] };
|
|
432
|
+
const first = findOccurrence(bare(e), part.start, settings);
|
|
433
|
+
if (!first || first.start <= now) throw refuse(400, 'started', 'Those dates have already started. Change from a date still to come instead.');
|
|
434
|
+
const segments = e.segments.map((g) => (g.from === part.from ? { ...g, cancelled: false, by: actorId(actor!), at: now } : g));
|
|
435
|
+
const at = findOccurrence(bare({ ...e, segments: e.segments.filter((g) => g.from < part.from) }), part.from, settings) ?? first;
|
|
436
|
+
const r = await apply(repo, e, part.from, at, segments, '', hooks, settings, opts, false);
|
|
437
|
+
if (!r.changed) return { event: e, clashes: [], changed: false, rsvps: { moved: 0, cancelled: 0 }, dropped: [] };
|
|
438
|
+
// nothing to move: the dates come back where they were
|
|
439
|
+
const done = await afterApply(repo, id, [], '', hooks);
|
|
440
|
+
return { event: done.after, clashes: [], changed: true, rsvps: done.rsvps, dropped: r.dropped };
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Undo a change from a date on: the segment taking over at `from` goes, and
|
|
445
|
+
* the rule before it carries on (to the next segment). Its dates' RSVPs,
|
|
446
|
+
* cancelled and moved dates follow back the same way. Idempotent.
|
|
447
|
+
*/
|
|
448
|
+
export async function revertFromDate(repo: ContentRepository, actor: MoveActor | null, id: string, from: string, hooks: SaveHooks, opts: SegmentOptions = {}): Promise<ChangeFromResult> {
|
|
449
|
+
return setSegments(repo, actor, id, from, (segments) => segments.filter((g) => g.from !== from), hooks, opts);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** Put an event's segments back to `previous` (a Schedule undo), carrying dates over from `from` on. */
|
|
453
|
+
export async function restoreSegments(repo: ContentRepository, actor: MoveActor | null, id: string, from: string, previous: SeriesSegment[], hooks: SaveHooks, opts: SegmentOptions = {}): Promise<ChangeFromResult> {
|
|
454
|
+
return setSegments(repo, actor, id, from, () => previous.map((g) => SeriesSegmentParse(g)), hooks, opts);
|
|
455
|
+
}
|
|
456
|
+
const SeriesSegmentParse = (g: SeriesSegment): SeriesSegment => ({ ...g, note: g.note ?? '', by: g.by ?? '', at: g.at ?? 0, cancelled: Boolean(g.cancelled) });
|
|
457
|
+
|
|
458
|
+
async function setSegments(repo: ContentRepository, actor: MoveActor | null, id: string, from: string, change: (segments: SeriesSegment[]) => SeriesSegment[], hooks: SaveHooks, opts: SegmentOptions) {
|
|
459
|
+
const now = opts.now ?? Date.now();
|
|
460
|
+
const e = await load(repo, id);
|
|
461
|
+
requireStaff(actor, e);
|
|
462
|
+
const settings = await repo.getSettings();
|
|
463
|
+
const segments = change(e.segments);
|
|
464
|
+
if (JSON.stringify(segments) === JSON.stringify(e.segments)) return { event: e, clashes: [], changed: false, rsvps: { moved: 0, cancelled: 0 }, dropped: [] };
|
|
465
|
+
// the date the change point stands for: the series' date there without the segments from it on
|
|
466
|
+
const at = findOccurrence(bare({ ...e, segments: e.segments.filter((g) => g.from < from) }), from, settings);
|
|
467
|
+
if (!at) throw refuse(400, 'not-a-date', 'That date isn’t part of this event.');
|
|
468
|
+
if (at.start <= now) throw refuse(400, 'started', 'That date has already started or passed.');
|
|
469
|
+
// nor may a change that goes or changes have started (its first date can come before `from`)
|
|
470
|
+
const gone = e.segments.filter((g) => g.from >= from && !segments.some((x) => JSON.stringify(x) === JSON.stringify(g)));
|
|
471
|
+
if (gone.some((g) => (findOccurrence(bare(e), g.start, settings)?.start ?? Infinity) <= now)) throw refuse(400, 'started', 'That change has already started. Change from a date still to come instead.');
|
|
472
|
+
const r = await apply(repo, e, from, at, segments, '', hooks, settings, opts, false);
|
|
473
|
+
if (!r.changed) return { event: e, clashes: [], changed: false, rsvps: { moved: 0, cancelled: 0 }, dropped: [] };
|
|
474
|
+
const done = await afterApply(repo, id, r.plan, '', hooks);
|
|
475
|
+
return { event: done.after, clashes: [], changed: true, rsvps: done.rsvps, dropped: r.dropped };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** The default rule and times a change from `fromKey` would start with (for forms): the date's own times and the rule in effect there. */
|
|
479
|
+
export async function changeFromDefaults(repo: ContentRepository, id: string, fromKey: string): Promise<{ start: string; end: string; rrule: string; roomIds: string[]; location: EventLocation | null } | null> {
|
|
480
|
+
const e = await repo.getEvent(id);
|
|
481
|
+
if (!e?.rrule) return null;
|
|
482
|
+
const settings = await repo.getSettings();
|
|
483
|
+
const at = findOccurrence(bare(e), fromKey, settings);
|
|
484
|
+
if (!at) return null;
|
|
485
|
+
const part = partOf(e, at);
|
|
486
|
+
const t = times(e, part, { start: at.key });
|
|
487
|
+
return { start: t.start, end: t.end, rrule: ruleFrom(e, part, at, t.start, settings), roomIds: part.roomIds, location: part.location ?? null };
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** A change from a date on, for the editor's Single dates panel: when it took over, the rule in words with its times, rooms and state. */
|
|
491
|
+
export interface SegmentRow {
|
|
492
|
+
from: string;
|
|
493
|
+
/** its first date: "Wed, Jan 16" */
|
|
494
|
+
when: string;
|
|
495
|
+
/** "Weekly on Wednesday · 5:00 – 6:00 PM" */
|
|
496
|
+
repeats: string;
|
|
497
|
+
rooms: string[];
|
|
498
|
+
cancelled: boolean;
|
|
499
|
+
note: string;
|
|
500
|
+
/** it has taken over already */
|
|
501
|
+
current: boolean;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** The changes from a date on that still have dates to come, in order. */
|
|
505
|
+
export function upcomingSegments(e: CalEvent, settings: Settings, roomNames: Map<string, string>, now = Date.now()): SegmentRow[] {
|
|
506
|
+
const parts = seriesParts(e).slice(1);
|
|
507
|
+
if (!parts.length) return [];
|
|
508
|
+
const tz = e.allDay ? settings.timezone || 'UTC' : eventTimeZone(e, settings);
|
|
509
|
+
const fmt = (o: Intl.DateTimeFormatOptions) => { try { return new Intl.DateTimeFormat(siteLocale(settings), { ...o, timeZone: tz }); } catch { return new Intl.DateTimeFormat('en-US', { ...o, timeZone: 'UTC' }); } };
|
|
510
|
+
const day = fmt({ weekday: 'short', month: 'short', day: 'numeric' }), time = fmt({ hour: 'numeric', minute: '2-digit' });
|
|
511
|
+
const dates = expandEvents([bare(e)], settings, { displayTz: settings.timezone || 'UTC', now, windowStartMs: now - 400 * DAY, windowEndMs: now + 400 * DAY });
|
|
512
|
+
return parts.flatMap((p) => {
|
|
513
|
+
const mine = dates.filter((o) => o.segment === p.from);
|
|
514
|
+
if (!mine.length || mine[mine.length - 1].end <= now) return [];
|
|
515
|
+
const first = mine[0];
|
|
516
|
+
const range = (() => { try { return time.formatRange(first.start, first.end); } catch { return `${time.format(first.start)} – ${time.format(first.end)}`; } })();
|
|
517
|
+
return [{
|
|
518
|
+
from: p.from, when: day.format(first.start), repeats: [describeRepeat(p.rrule, p.start), e.allDay ? '' : range].filter(Boolean).join(' · '),
|
|
519
|
+
rooms: p.roomIds.map((id) => roomNames.get(id)).filter((n): n is string => Boolean(n)), cancelled: p.cancelled, note: p.note, current: first.start <= now,
|
|
520
|
+
}];
|
|
521
|
+
});
|
|
522
|
+
}
|
|
@@ -5,7 +5,7 @@ import { ApiError } from '../api';
|
|
|
5
5
|
import { ApprovalMode, ApprovalRules, BookingMode, DateOverride, QUESTION_TYPES, TimeRange, WeeklyHours, slugify } from '../schema';
|
|
6
6
|
import { CALENDAR, bookingsEnabled } from '../calendar/config';
|
|
7
7
|
import { collectViewEvents } from '../calendar/data';
|
|
8
|
-
import { isValidTimeZone, safeColor } from '../calendar/events';
|
|
8
|
+
import { isValidTimeZone, partOf, safeColor } from '../calendar/events';
|
|
9
9
|
import { describeRepeat } from '../calendar/rrule';
|
|
10
10
|
import { zonedToUtc } from '../calendar/ics';
|
|
11
11
|
import { normalizeCalendarSrc } from '../calendar/src';
|
|
@@ -59,7 +59,7 @@ export async function schedule(c: EditorContext, q: z.infer<typeof ScheduleQuery
|
|
|
59
59
|
kind: e ? 'event' as const : 'feed' as const, id: e?.id ?? '', title: v.title,
|
|
60
60
|
start: iso(v.start), end: iso(v.end), allDay: v.allDay, status: e ? e.status : 'feed',
|
|
61
61
|
calendarId: cal?.id ?? '', color: v.color ?? (cal ? safeColor(cal.color) : '#8a8378'),
|
|
62
|
-
location: v.location, cancelled: Boolean(v.cancelled), repeat: e?.rrule ? describeRepeat(
|
|
62
|
+
location: v.location, cancelled: Boolean(v.cancelled), repeat: e?.rrule ? ((p) => describeRepeat(p.rrule, p.start))(partOf(e, { segment: v.segment })) : '',
|
|
63
63
|
resourceIds: e?.roomHold.resourceIds ?? [],
|
|
64
64
|
};
|
|
65
65
|
}),
|
|
@@ -113,6 +113,7 @@ export function occurrenceDto(o: Occurrence, resources: Map<string, Resource>) {
|
|
|
113
113
|
return { id: `${o.event.id}:${o.start}`, start: iso(o.start), end: iso(o.end), allDay: o.allDay, cancelled: o.cancelled,
|
|
114
114
|
moved: o.moved ? { from: { start: iso(o.moved.from.start), end: iso(o.moved.from.end) }, note: o.moved.note,
|
|
115
115
|
location: o.moved.location ? { name: o.moved.location.name, address: o.moved.location.address, url: o.moved.location.url } : null } : null,
|
|
116
|
+
location: o.location ? { name: o.location.name, address: o.location.address, url: o.location.url } : null,
|
|
116
117
|
rooms: roomsToJson(o.roomIds, resources) };
|
|
117
118
|
}
|
|
118
119
|
// e is already localized; publicCalendars limits which calendars are named
|
|
@@ -32,6 +32,11 @@ export const Page = ContentSummary.extend({
|
|
|
32
32
|
export const EventRoom = z.object({ slug: str, name: str, color: str.nullable() });
|
|
33
33
|
// one date moved here from `from` (null when it wasn't); location: its own place, null = the event's
|
|
34
34
|
export const OccurrenceMoved = z.object({ from: z.object({ start: date, end: date }), note: str, location: z.object({ name: str, address: str, url: str }).nullable() }).nullable();
|
|
35
|
+
// a date's own place (a moved date's, or from its event's change from a date on); null = the event's
|
|
36
|
+
export const OccurrencePlace = z.object({ name: str, address: str, url: str }).nullable();
|
|
37
|
+
// the series from one date on (CalEvent.segments): `from` is the occurrence key where it took over; location null = the event's
|
|
38
|
+
export const EventSegment = z.object({ from: str, start: str, end: str, rrule: str, repeats: str, cancelled: bool, note: str,
|
|
39
|
+
location: z.object({ name: str, address: str, url: str }).nullable(), rooms: z.array(EventRoom) });
|
|
35
40
|
export const Event = CalEvent.pick({
|
|
36
41
|
id: true, slug: true, title: true, excerpt: true, status: true, cancelled: true, featured: true, start: true, end: true,
|
|
37
42
|
allDay: true, timezone: true, rrule: true, exdates: true, cancelledDates: true, location: true, cta: true, tags: true, fields: true,
|
|
@@ -40,18 +45,20 @@ export const Event = CalEvent.pick({
|
|
|
40
45
|
rooms: z.array(EventRoom),
|
|
41
46
|
// single dates at another time, by original occurrence key (wall-clock, like start/end)
|
|
42
47
|
movedDates: z.record(z.object({ start: str, end: str, note: str })),
|
|
48
|
+
// changed from a date on, in order ([] = never); `repeats` above is the part in effect now
|
|
49
|
+
segments: z.array(EventSegment),
|
|
43
50
|
// push topics told about date, time or place changes (Admin → Mobile app → When an event changes); [] = none
|
|
44
51
|
notify: z.object({ changes: strings }),
|
|
45
52
|
});
|
|
46
53
|
export const Events = z.object({ locale, timezone: str, from: date, to: date, pagination: Pagination,
|
|
47
|
-
occurrences: z.array(z.object({ id: str, start: date, end: date, allDay: bool, cancelled: bool, moved: OccurrenceMoved, rooms: z.array(EventRoom), event: Event })),
|
|
54
|
+
occurrences: z.array(z.object({ id: str, start: date, end: date, allDay: bool, cancelled: bool, moved: OccurrenceMoved, location: OccurrencePlace, rooms: z.array(EventRoom), event: Event })),
|
|
48
55
|
});
|
|
49
56
|
const Snippet = z.object({ text: str, highlights: z.array(z.object({ start: z.number().int(), end: z.number().int() })) });
|
|
50
57
|
const ContentHit = ContentSummary.extend({ snippet: Snippet });
|
|
51
|
-
export const EventDetail = z.object({ locale, occurrence: z.object({ id: str, start: date, end: date, allDay: bool, cancelled: bool, moved: OccurrenceMoved, rooms: z.array(EventRoom) }).nullable(), event: Event });
|
|
58
|
+
export const EventDetail = z.object({ locale, occurrence: z.object({ id: str, start: date, end: date, allDay: bool, cancelled: bool, moved: OccurrenceMoved, location: OccurrencePlace, rooms: z.array(EventRoom) }).nullable(), event: Event });
|
|
52
59
|
export const SearchResults = z.object({ locale, query: str,
|
|
53
60
|
posts: z.array(ContentHit), pages: z.array(ContentHit),
|
|
54
|
-
events: z.array(z.object({ snippet: Snippet, occurrence: z.object({ start: date, end: date, allDay: bool, cancelled: bool, moved: OccurrenceMoved, rooms: z.array(EventRoom) }).nullable(), event: Event })),
|
|
61
|
+
events: z.array(z.object({ snippet: Snippet, occurrence: z.object({ start: date, end: date, allDay: bool, cancelled: bool, moved: OccurrenceMoved, location: OccurrencePlace, rooms: z.array(EventRoom) }).nullable(), event: Event })),
|
|
55
62
|
more: z.object({ posts: bool, pages: bool, events: bool }),
|
|
56
63
|
});
|
|
57
64
|
const Icon = z.object({ type: z.enum(['svg', 'image', 'emoji']), value: str }).nullable();
|