@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/package.json +1 -1
- package/src/build.test.ts +113 -0
- package/src/build.ts +311 -0
- package/src/errors.ts +13 -1
- package/src/expand.test.ts +10 -0
- package/src/expand.ts +221 -111
- package/src/index.ts +38 -1
- package/src/project.ts +26 -8
- package/src/put.test.ts +17 -0
- package/src/scope.test.ts +472 -0
- package/src/scope.ts +490 -0
- package/src/time.ts +66 -0
- package/src/window.test.ts +524 -0
- package/src/window.ts +232 -0
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
|
+
};
|