@remit/calendar-service 0.0.1 → 0.0.2

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/src/window.ts ADDED
@@ -0,0 +1,232 @@
1
+ import type {
2
+ CalendarCollectionItem,
3
+ CalendarObjectItem,
4
+ CalendarOccurrenceInput,
5
+ ICalendarEventIndexRepository,
6
+ ICalendarObjectRepository,
7
+ } from "@remit/data-ports";
8
+ import { CalendarEventStatus, CalendarTransparency } from "@remit/domain-enums";
9
+ import { expandCalendarWindow } from "./expand.js";
10
+ import { parseCalendar } from "./parse.js";
11
+ import { toOffsetIso, toUtcIso } from "./time.js";
12
+
13
+ /**
14
+ * How far before the window occurrences are read, so one that started earlier
15
+ * and runs into it is still returned.
16
+ *
17
+ * The occurrence index is keyed by start, which is the only key a range read
18
+ * can use, so an event that began before the window is invisible to it. A
19
+ * bounded lookback buys back the events people actually have — a meeting that
20
+ * started last night, a week away, a fortnight of leave — without turning a
21
+ * day's read into a read of the whole calendar. An all-day block longer than
22
+ * this that began before the window is the one thing it misses.
23
+ */
24
+ export const CALENDAR_WINDOW_LOOKBACK_DAYS = 31;
25
+
26
+ const LOOKBACK_MS = CALENDAR_WINDOW_LOOKBACK_DAYS * 24 * 60 * 60 * 1000;
27
+
28
+ /** One occurrence, in the form a client renders. */
29
+ export interface CalendarInstance {
30
+ calendarId: string;
31
+ calendarObjectId: string;
32
+ recurrenceId: string;
33
+ icalUid: string;
34
+ summary: string;
35
+ /** ISO 8601 with the collection's own offset — what a client draws. */
36
+ start: string;
37
+ end: string;
38
+ /** ISO 8601 UTC instant — what sorts and compares. */
39
+ startAt: string;
40
+ endAt: string;
41
+ allDay: boolean;
42
+ status: CalendarObjectItem["status"];
43
+ transparency: CalendarObjectItem["transparency"];
44
+ zoneCertainty: CalendarObjectItem["zoneCertainty"];
45
+ etag: string;
46
+ hasRecurrence: boolean;
47
+ }
48
+
49
+ export interface CalendarWindowRepositories {
50
+ calendarObject: Pick<
51
+ ICalendarObjectRepository,
52
+ "find" | "listIncompleteExpansions"
53
+ >;
54
+ calendarEventIndex: Pick<ICalendarEventIndexRepository, "listByStartRange">;
55
+ }
56
+
57
+ export interface CalendarWindow {
58
+ /** ISO 8601 UTC instant, inclusive. */
59
+ from: string;
60
+ /** ISO 8601 UTC instant, exclusive. */
61
+ to: string;
62
+ }
63
+
64
+ const instanceOf = (
65
+ collection: CalendarCollectionItem,
66
+ object: CalendarObjectItem,
67
+ occurrence: CalendarOccurrenceInput,
68
+ ): CalendarInstance => ({
69
+ calendarId: collection.calendarId,
70
+ calendarObjectId: object.calendarObjectId,
71
+ recurrenceId: occurrence.recurrenceId,
72
+ icalUid: object.icalUid,
73
+ summary: occurrence.summary,
74
+ start: toOffsetIso(Date.parse(occurrence.startAt), collection.timezone),
75
+ end: toOffsetIso(Date.parse(occurrence.endAt), collection.timezone),
76
+ startAt: occurrence.startAt,
77
+ endAt: occurrence.endAt,
78
+ allDay: occurrence.allDay,
79
+ status: occurrence.status,
80
+ transparency: occurrence.transparency,
81
+ zoneCertainty: object.zoneCertainty,
82
+ etag: object.etag,
83
+ hasRecurrence: object.hasRecurrence,
84
+ });
85
+
86
+ /**
87
+ * Every occurrence in a window, across the collections given.
88
+ *
89
+ * Served from the stored occurrence index, except for the series the index
90
+ * does not reach: those are expanded live for this window and nothing is
91
+ * written to serve the read. A series is served from one source or the other,
92
+ * never merged from both, so an occurrence cannot appear twice.
93
+ */
94
+ export const listCalendarInstances = async (
95
+ repositories: CalendarWindowRepositories,
96
+ collections: CalendarCollectionItem[],
97
+ window: CalendarWindow,
98
+ ): Promise<CalendarInstance[]> => {
99
+ const fromMs = Date.parse(window.from);
100
+ const toMs = Date.parse(window.to);
101
+ const lookbackFrom = toUtcIso(fromMs - LOOKBACK_MS);
102
+
103
+ const instances: CalendarInstance[] = [];
104
+ for (const collection of collections) {
105
+ // Both reads are bounded by the window: the occurrence rows that start in
106
+ // it, and the handful of series whose index stops short of it. Neither
107
+ // grows with the size of the calendar.
108
+ const live = await repositories.calendarObject.listIncompleteExpansions(
109
+ collection.calendarId,
110
+ window.to,
111
+ );
112
+ const liveIds = new Set(live.map((object) => object.calendarObjectId));
113
+ const rows = await repositories.calendarEventIndex.listByStartRange(
114
+ collection.calendarId,
115
+ lookbackFrom,
116
+ window.to,
117
+ );
118
+
119
+ // One read per resource that actually appears in the window, cached across
120
+ // its own occurrences.
121
+ const byId = new Map<string, CalendarObjectItem | null>();
122
+ for (const row of rows) {
123
+ if (liveIds.has(row.calendarObjectId)) continue;
124
+ if (!byId.has(row.calendarObjectId)) {
125
+ byId.set(
126
+ row.calendarObjectId,
127
+ await repositories.calendarObject.find(
128
+ collection.calendarId,
129
+ row.calendarObjectId,
130
+ ),
131
+ );
132
+ }
133
+ const object = byId.get(row.calendarObjectId);
134
+ if (!object) continue;
135
+ instances.push(instanceOf(collection, object, row));
136
+ }
137
+
138
+ for (const object of live) {
139
+ const parsed = await parseCalendar(object.icalData);
140
+ if (!parsed.ok) {
141
+ throw new Error(
142
+ `stored calendar object ${object.calendarObjectId} no longer parses: ${parsed.error.message}`,
143
+ );
144
+ }
145
+ const occurrences = expandCalendarWindow(
146
+ parsed.value,
147
+ collection.timezone,
148
+ { fromMs: fromMs - LOOKBACK_MS, toMs },
149
+ );
150
+ for (const occurrence of occurrences) {
151
+ instances.push(instanceOf(collection, object, occurrence));
152
+ }
153
+ }
154
+ }
155
+
156
+ return instances
157
+ .filter((instance) => overlapsWindow(instance, fromMs, toMs))
158
+ .sort((left, right) => left.startAt.localeCompare(right.startAt));
159
+ };
160
+
161
+ const overlapsWindow = (
162
+ instance: CalendarInstance,
163
+ fromMs: number,
164
+ toMs: number,
165
+ ): boolean => {
166
+ const startMs = Date.parse(instance.startAt);
167
+ if (startMs >= toMs) return false;
168
+ if (startMs >= fromMs) return true;
169
+ return Date.parse(instance.endAt) > fromMs;
170
+ };
171
+
172
+ /** A stretch of time somebody is busy in, as instants. */
173
+ export interface BusySpan {
174
+ startMs: number;
175
+ endMs: number;
176
+ }
177
+
178
+ /**
179
+ * Whether an occurrence consumes free/busy time. A cancelled event is not
180
+ * something to work around, and one marked TRANSP:TRANSPARENT was written
181
+ * precisely to say so.
182
+ */
183
+ export const isBusy = (instance: CalendarInstance): boolean =>
184
+ instance.transparency === CalendarTransparency.Opaque &&
185
+ instance.status !== CalendarEventStatus.Cancelled;
186
+
187
+ /**
188
+ * Collapses overlapping and touching spans into the stretches they cover.
189
+ *
190
+ * Touching counts: two meetings back to back are one stretch of being busy, and
191
+ * reporting them separately invites a caller to offer the zero-length gap
192
+ * between them as free.
193
+ */
194
+ export const mergeBusySpans = (spans: BusySpan[]): BusySpan[] => {
195
+ const sorted = [...spans].sort((left, right) => left.startMs - right.startMs);
196
+ const merged: BusySpan[] = [];
197
+ for (const span of sorted) {
198
+ if (span.endMs <= span.startMs) continue;
199
+ const last = merged[merged.length - 1];
200
+ if (last && span.startMs <= last.endMs) {
201
+ last.endMs = Math.max(last.endMs, span.endMs);
202
+ continue;
203
+ }
204
+ merged.push({ ...span });
205
+ }
206
+ return merged;
207
+ };
208
+
209
+ /**
210
+ * The busy stretches in a window, merged across every collection given, and
211
+ * clipped to the window so a span never claims time outside what was asked
212
+ * for.
213
+ */
214
+ export const listBusySpans = async (
215
+ repositories: CalendarWindowRepositories,
216
+ collections: CalendarCollectionItem[],
217
+ window: CalendarWindow,
218
+ ): Promise<BusySpan[]> => {
219
+ const fromMs = Date.parse(window.from);
220
+ const toMs = Date.parse(window.to);
221
+ const instances = await listCalendarInstances(
222
+ repositories,
223
+ collections,
224
+ window,
225
+ );
226
+ return mergeBusySpans(
227
+ instances.filter(isBusy).map((instance) => ({
228
+ startMs: Math.max(Date.parse(instance.startAt), fromMs),
229
+ endMs: Math.min(Date.parse(instance.endAt), toMs),
230
+ })),
231
+ );
232
+ };