@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/package.json
CHANGED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { CalendarEventStatus, CalendarTransparency } from "@remit/domain-enums";
|
|
4
|
+
import { buildEventCalendar, type CalendarEventFields } from "./build.js";
|
|
5
|
+
import { expandCalendar } from "./expand.js";
|
|
6
|
+
import { parseCalendar } from "./parse.js";
|
|
7
|
+
|
|
8
|
+
const fields = (
|
|
9
|
+
overrides: Partial<CalendarEventFields> = {},
|
|
10
|
+
): CalendarEventFields => ({
|
|
11
|
+
summary: "Stand-up",
|
|
12
|
+
description: "",
|
|
13
|
+
location: "",
|
|
14
|
+
start: "2026-10-15T09:00:00+02:00",
|
|
15
|
+
end: "2026-10-15T10:00:00+02:00",
|
|
16
|
+
allDay: false,
|
|
17
|
+
timeZone: "",
|
|
18
|
+
status: CalendarEventStatus.Confirmed,
|
|
19
|
+
transparency: CalendarTransparency.Opaque,
|
|
20
|
+
recurrenceRule: "",
|
|
21
|
+
...overrides,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const build = async (overrides: Partial<CalendarEventFields> = {}) =>
|
|
25
|
+
buildEventCalendar(
|
|
26
|
+
fields(overrides),
|
|
27
|
+
"new@reader.remit",
|
|
28
|
+
new Date("2026-08-29T00:00:00Z"),
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
describe("buildEventCalendar", () => {
|
|
32
|
+
it("writes an event with no zone as the instant it names", async () => {
|
|
33
|
+
const built = await build();
|
|
34
|
+
|
|
35
|
+
assert.ok(built.ok, JSON.stringify(built));
|
|
36
|
+
assert.ok(built.value.includes("DTSTART:20261015T070000Z"));
|
|
37
|
+
assert.ok(built.value.includes("DTEND:20261015T080000Z"));
|
|
38
|
+
assert.ok(built.value.includes("UID:new@reader.remit"));
|
|
39
|
+
assert.ok(built.value.includes("SUMMARY:Stand-up"));
|
|
40
|
+
assert.equal(
|
|
41
|
+
built.value.includes("DESCRIPTION"),
|
|
42
|
+
false,
|
|
43
|
+
"an empty field is left out rather than written blank",
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("anchors an event in the zone it names, not in the offset it arrived with", async () => {
|
|
48
|
+
const built = await build({ timeZone: "Europe/Amsterdam" });
|
|
49
|
+
|
|
50
|
+
assert.ok(built.ok);
|
|
51
|
+
assert.ok(
|
|
52
|
+
built.value.includes("DTSTART;TZID=Europe/Amsterdam:20261015T090000"),
|
|
53
|
+
);
|
|
54
|
+
assert.ok(
|
|
55
|
+
built.value.includes("DTEND;TZID=Europe/Amsterdam:20261015T100000"),
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("keeps a series at its local hour across a DST transition", async () => {
|
|
60
|
+
const built = await build({
|
|
61
|
+
timeZone: "Europe/Amsterdam",
|
|
62
|
+
recurrenceRule: "FREQ=WEEKLY;COUNT=4",
|
|
63
|
+
});
|
|
64
|
+
assert.ok(built.ok);
|
|
65
|
+
|
|
66
|
+
const parsed = await parseCalendar(built.value);
|
|
67
|
+
assert.ok(parsed.ok);
|
|
68
|
+
const expansion = expandCalendar(parsed.value, "Europe/Amsterdam");
|
|
69
|
+
|
|
70
|
+
assert.deepEqual(
|
|
71
|
+
expansion.occurrences.map((occurrence) => occurrence.startAt),
|
|
72
|
+
[
|
|
73
|
+
"2026-10-15T07:00:00Z",
|
|
74
|
+
"2026-10-22T07:00:00Z",
|
|
75
|
+
"2026-10-29T08:00:00Z",
|
|
76
|
+
"2026-11-05T08:00:00Z",
|
|
77
|
+
],
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("writes an all-day event as civil dates with an exclusive end", async () => {
|
|
82
|
+
const built = await build({
|
|
83
|
+
allDay: true,
|
|
84
|
+
start: "2026-10-15",
|
|
85
|
+
end: "2026-10-17",
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
assert.ok(built.ok);
|
|
89
|
+
assert.ok(built.value.includes("DTSTART;VALUE=DATE:20261015"));
|
|
90
|
+
assert.ok(built.value.includes("DTEND;VALUE=DATE:20261017"));
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("refuses a date-time with no zone offset", async () => {
|
|
94
|
+
const built = await build({ start: "2026-10-15T09:00:00" });
|
|
95
|
+
|
|
96
|
+
assert.ok(!built.ok);
|
|
97
|
+
assert.equal(built.error.code, "InvalidDateTime");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("refuses a zone this server cannot resolve", async () => {
|
|
101
|
+
const built = await build({ timeZone: "W. Europe Standard Time" });
|
|
102
|
+
|
|
103
|
+
assert.ok(!built.ok);
|
|
104
|
+
assert.equal(built.error.code, "UnknownTimeZone");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("refuses a recurrence rule it cannot read", async () => {
|
|
108
|
+
const built = await build({ recurrenceRule: "EVERY OTHER TUESDAY" });
|
|
109
|
+
|
|
110
|
+
assert.ok(!built.ok);
|
|
111
|
+
assert.equal(built.error.code, "InvalidRecurrenceRule");
|
|
112
|
+
});
|
|
113
|
+
});
|
package/src/build.ts
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import type { CalendarObjectItem } from "@remit/data-ports";
|
|
2
|
+
import { CalendarEventStatus, CalendarTransparency } from "@remit/domain-enums";
|
|
3
|
+
import ICAL from "ical.js";
|
|
4
|
+
import { type CalendarResult, calendarFailure } from "./errors.js";
|
|
5
|
+
import { serializeCalendar } from "./parse.js";
|
|
6
|
+
import {
|
|
7
|
+
civilInZone,
|
|
8
|
+
dtEndTzid,
|
|
9
|
+
dtStartTzid,
|
|
10
|
+
isResolvableZone,
|
|
11
|
+
resolveTime,
|
|
12
|
+
} from "./time.js";
|
|
13
|
+
|
|
14
|
+
/** Identifies this server in the PRODID of every VCALENDAR it writes. */
|
|
15
|
+
export const CALENDAR_PRODID = "-//Remit//Reader Calendar//EN";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* An event as a person describes it, before it is iCalendar.
|
|
19
|
+
*
|
|
20
|
+
* Deliberately not a subset of a VEVENT: a client should not have to know that
|
|
21
|
+
* an all-day end is exclusive, that a duration and an end are the same fact
|
|
22
|
+
* written twice, or that a zone lives in a parameter rather than in the value.
|
|
23
|
+
* The server owns iCalendar; this is the shape the API takes and the shape a
|
|
24
|
+
* patch is expressed in.
|
|
25
|
+
*/
|
|
26
|
+
export interface CalendarEventFields {
|
|
27
|
+
summary: string;
|
|
28
|
+
description: string;
|
|
29
|
+
location: string;
|
|
30
|
+
/** ISO 8601 with a zone offset, or `YYYY-MM-DD` when `allDay`. */
|
|
31
|
+
start: string;
|
|
32
|
+
/** Same form as `start`. Exclusive for an all-day event. */
|
|
33
|
+
end: string;
|
|
34
|
+
allDay: boolean;
|
|
35
|
+
/** IANA zone the event is anchored in. `""` anchors it in UTC. */
|
|
36
|
+
timeZone: string;
|
|
37
|
+
status: CalendarObjectItem["status"];
|
|
38
|
+
transparency: CalendarObjectItem["transparency"];
|
|
39
|
+
/** RRULE value without the property name. `""` for a single event. */
|
|
40
|
+
recurrenceRule: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const STATUS_TO_ICAL: Record<CalendarObjectItem["status"], string> = {
|
|
44
|
+
[CalendarEventStatus.Confirmed]: "CONFIRMED",
|
|
45
|
+
[CalendarEventStatus.Tentative]: "TENTATIVE",
|
|
46
|
+
[CalendarEventStatus.Cancelled]: "CANCELLED",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const TRANSPARENCY_TO_ICAL: Record<CalendarObjectItem["transparency"], string> =
|
|
50
|
+
{
|
|
51
|
+
[CalendarTransparency.Opaque]: "OPAQUE",
|
|
52
|
+
[CalendarTransparency.Transparent]: "TRANSPARENT",
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const DATE_ONLY = /^\d{4}-\d{2}-\d{2}/;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* An offset is required on a date-time. A wall time with no offset names no
|
|
59
|
+
* instant — a client sending `2026-03-29T02:30:00` on the morning the clocks
|
|
60
|
+
* move is asking for a time that happens twice, or not at all — and guessing
|
|
61
|
+
* one is how a calendar quietly puts an event an hour out.
|
|
62
|
+
*/
|
|
63
|
+
const OFFSET_BEARING = /(Z|[+-]\d{2}:\d{2})$/;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Reads one API date-time into the iCalendar time the resource will carry.
|
|
67
|
+
*
|
|
68
|
+
* An all-day value becomes a DATE, which is a civil date and has no zone at
|
|
69
|
+
* all. A timed value becomes the wall time it reads as in `timeZone`, so the
|
|
70
|
+
* resource records the zone the event is anchored in rather than the offset
|
|
71
|
+
* that zone happened to be at — which is the whole difference between a weekly
|
|
72
|
+
* 09:00 meeting and a meeting that moves to 08:00 every autumn.
|
|
73
|
+
*/
|
|
74
|
+
export const readEventTime = (
|
|
75
|
+
value: string,
|
|
76
|
+
allDay: boolean,
|
|
77
|
+
timeZone: string,
|
|
78
|
+
): CalendarResult<ICAL.Time> => {
|
|
79
|
+
if (allDay) {
|
|
80
|
+
const date = DATE_ONLY.exec(value)?.[0];
|
|
81
|
+
if (!date) {
|
|
82
|
+
return calendarFailure(
|
|
83
|
+
"InvalidDateTime",
|
|
84
|
+
`an all-day event needs a YYYY-MM-DD date, and this one carries "${value}"`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
const [year, month, day] = date.split("-").map(Number) as [
|
|
88
|
+
number,
|
|
89
|
+
number,
|
|
90
|
+
number,
|
|
91
|
+
];
|
|
92
|
+
return {
|
|
93
|
+
ok: true,
|
|
94
|
+
value: ICAL.Time.fromData({ year, month, day, isDate: true }),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!OFFSET_BEARING.test(value)) {
|
|
99
|
+
return calendarFailure(
|
|
100
|
+
"InvalidDateTime",
|
|
101
|
+
`a date-time needs an explicit zone offset, and this one carries "${value}"`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
const instantMs = Date.parse(value);
|
|
105
|
+
if (Number.isNaN(instantMs)) {
|
|
106
|
+
return calendarFailure(
|
|
107
|
+
"InvalidDateTime",
|
|
108
|
+
`"${value}" is not a date-time this server can read`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
if (timeZone !== "" && !isResolvableZone(timeZone)) {
|
|
112
|
+
return calendarFailure(
|
|
113
|
+
"UnknownTimeZone",
|
|
114
|
+
`"${timeZone}" is not a time zone this server can resolve`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const civil = civilInZone(instantMs, timeZone === "" ? "UTC" : timeZone);
|
|
119
|
+
return {
|
|
120
|
+
ok: true,
|
|
121
|
+
value: ICAL.Time.fromData(
|
|
122
|
+
{
|
|
123
|
+
year: civil.year,
|
|
124
|
+
month: civil.month,
|
|
125
|
+
day: civil.day,
|
|
126
|
+
hour: civil.hour,
|
|
127
|
+
minute: civil.minute,
|
|
128
|
+
second: civil.second,
|
|
129
|
+
isDate: false,
|
|
130
|
+
},
|
|
131
|
+
timeZone === "" ? ICAL.Timezone.utcTimezone : ICAL.Timezone.localTimezone,
|
|
132
|
+
),
|
|
133
|
+
};
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const setTimeProperty = (
|
|
137
|
+
event: ICAL.Component,
|
|
138
|
+
name: string,
|
|
139
|
+
time: ICAL.Time,
|
|
140
|
+
timeZone: string,
|
|
141
|
+
): void => {
|
|
142
|
+
event.removeAllProperties(name);
|
|
143
|
+
const property = new ICAL.Property(name);
|
|
144
|
+
event.addProperty(property);
|
|
145
|
+
property.setValue(time);
|
|
146
|
+
if (!time.isDate && timeZone !== "") {
|
|
147
|
+
property.setParameter("tzid", timeZone);
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const setTextProperty = (
|
|
152
|
+
event: ICAL.Component,
|
|
153
|
+
name: string,
|
|
154
|
+
value: string,
|
|
155
|
+
): void => {
|
|
156
|
+
event.removeAllProperties(name);
|
|
157
|
+
if (value === "") return;
|
|
158
|
+
event.addPropertyWithValue(name, value);
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
/** The time fields a stored VEVENT already carries, in the API's own form. */
|
|
162
|
+
export const eventTimeFields = (
|
|
163
|
+
component: ICAL.Component,
|
|
164
|
+
collectionTimezone: string,
|
|
165
|
+
): Pick<CalendarEventFields, "start" | "end" | "allDay" | "timeZone"> => {
|
|
166
|
+
const event = new ICAL.Event(component);
|
|
167
|
+
const startTzid = dtStartTzid(component);
|
|
168
|
+
const start = resolveTime(event.startDate, startTzid, collectionTimezone);
|
|
169
|
+
const end = resolveTime(
|
|
170
|
+
event.endDate,
|
|
171
|
+
dtEndTzid(component),
|
|
172
|
+
collectionTimezone,
|
|
173
|
+
);
|
|
174
|
+
return {
|
|
175
|
+
start: start.isoOffset,
|
|
176
|
+
end: end.isoOffset,
|
|
177
|
+
allDay: start.isDate,
|
|
178
|
+
timeZone: startTzid,
|
|
179
|
+
};
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* ical.js's recurrence parser in a promise, so an unreadable rule arrives as a
|
|
184
|
+
* value to branch on rather than a synchronous throw.
|
|
185
|
+
*
|
|
186
|
+
* A rule with no FREQ is refused rather than stored. ical.js reads text it
|
|
187
|
+
* finds no parts in as a rule with a null frequency instead of failing, and
|
|
188
|
+
* storing that would give somebody an event marked as recurring that produces
|
|
189
|
+
* nothing — the failure a client cannot see and cannot fix.
|
|
190
|
+
*/
|
|
191
|
+
export const readRecurrenceRule = (
|
|
192
|
+
recurrenceRule: string,
|
|
193
|
+
): Promise<CalendarResult<ICAL.Recur>> =>
|
|
194
|
+
new Promise<ICAL.Recur>((resolve) => {
|
|
195
|
+
resolve(ICAL.Recur.fromString(recurrenceRule));
|
|
196
|
+
}).then(
|
|
197
|
+
(value) =>
|
|
198
|
+
value.freq
|
|
199
|
+
? ({ ok: true, value } as const)
|
|
200
|
+
: calendarFailure<ICAL.Recur>(
|
|
201
|
+
"InvalidRecurrenceRule",
|
|
202
|
+
`"${recurrenceRule}" names no FREQ, so it is not a recurrence rule`,
|
|
203
|
+
),
|
|
204
|
+
(error: unknown) =>
|
|
205
|
+
calendarFailure<ICAL.Recur>(
|
|
206
|
+
"InvalidRecurrenceRule",
|
|
207
|
+
error instanceof Error
|
|
208
|
+
? error.message
|
|
209
|
+
: `"${recurrenceRule}" is not a recurrence rule this server can read`,
|
|
210
|
+
),
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Writes a patch onto a VEVENT.
|
|
215
|
+
*
|
|
216
|
+
* A field the patch does not carry is untouched, so renaming an event moves
|
|
217
|
+
* nothing. The time fields are the exception: zone, all-day-ness, start and end
|
|
218
|
+
* are one fact between them, so touching any of them rewrites DTSTART and DTEND
|
|
219
|
+
* together from the values the event would then have. Rewriting only the one
|
|
220
|
+
* the caller named is what produces an event that starts in one zone and ends
|
|
221
|
+
* in another.
|
|
222
|
+
*/
|
|
223
|
+
export const applyEventFields = async (
|
|
224
|
+
component: ICAL.Component,
|
|
225
|
+
patch: Partial<CalendarEventFields>,
|
|
226
|
+
collectionTimezone: string,
|
|
227
|
+
): Promise<CalendarResult<null>> => {
|
|
228
|
+
if (patch.summary !== undefined) {
|
|
229
|
+
setTextProperty(component, "summary", patch.summary);
|
|
230
|
+
}
|
|
231
|
+
if (patch.description !== undefined) {
|
|
232
|
+
setTextProperty(component, "description", patch.description);
|
|
233
|
+
}
|
|
234
|
+
if (patch.location !== undefined) {
|
|
235
|
+
setTextProperty(component, "location", patch.location);
|
|
236
|
+
}
|
|
237
|
+
if (patch.status !== undefined) {
|
|
238
|
+
setTextProperty(component, "status", STATUS_TO_ICAL[patch.status]);
|
|
239
|
+
}
|
|
240
|
+
if (patch.transparency !== undefined) {
|
|
241
|
+
setTextProperty(
|
|
242
|
+
component,
|
|
243
|
+
"transp",
|
|
244
|
+
TRANSPARENCY_TO_ICAL[patch.transparency],
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (patch.recurrenceRule !== undefined) {
|
|
249
|
+
component.removeAllProperties("rrule");
|
|
250
|
+
if (patch.recurrenceRule !== "") {
|
|
251
|
+
const rule = await readRecurrenceRule(patch.recurrenceRule);
|
|
252
|
+
if (!rule.ok) return rule;
|
|
253
|
+
component.addPropertyWithValue("rrule", rule.value);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const touchesTime =
|
|
258
|
+
patch.start !== undefined ||
|
|
259
|
+
patch.end !== undefined ||
|
|
260
|
+
patch.allDay !== undefined ||
|
|
261
|
+
patch.timeZone !== undefined;
|
|
262
|
+
if (!touchesTime) return { ok: true, value: null };
|
|
263
|
+
|
|
264
|
+
// A VEVENT being built from nothing has no times to read back, and every one
|
|
265
|
+
// of them is in the patch. The empty start then refuses a create that left
|
|
266
|
+
// one out, which is the same answer reading it back would have given.
|
|
267
|
+
const current = component.hasProperty("dtstart")
|
|
268
|
+
? eventTimeFields(component, collectionTimezone)
|
|
269
|
+
: { start: "", end: "", allDay: false, timeZone: "" };
|
|
270
|
+
const allDay = patch.allDay ?? current.allDay;
|
|
271
|
+
const timeZone = patch.timeZone ?? current.timeZone;
|
|
272
|
+
const start = readEventTime(patch.start ?? current.start, allDay, timeZone);
|
|
273
|
+
if (!start.ok) return start;
|
|
274
|
+
const end = readEventTime(patch.end ?? current.end, allDay, timeZone);
|
|
275
|
+
if (!end.ok) return end;
|
|
276
|
+
|
|
277
|
+
// A duration says the same thing DTEND does, and leaving one behind beside a
|
|
278
|
+
// rewritten DTEND leaves two answers to how long the event is.
|
|
279
|
+
component.removeAllProperties("duration");
|
|
280
|
+
setTimeProperty(component, "dtstart", start.value, timeZone);
|
|
281
|
+
setTimeProperty(component, "dtend", end.value, timeZone);
|
|
282
|
+
return { ok: true, value: null };
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Builds the VCALENDAR text for a new event.
|
|
287
|
+
*
|
|
288
|
+
* The output goes straight to the single write path, which parses it again to
|
|
289
|
+
* validate and project it. That second read is not waste: it is the same gate
|
|
290
|
+
* every other writer passes, so an event this function got wrong is refused
|
|
291
|
+
* here rather than stored.
|
|
292
|
+
*/
|
|
293
|
+
export const buildEventCalendar = async (
|
|
294
|
+
fields: CalendarEventFields,
|
|
295
|
+
uid: string,
|
|
296
|
+
now: Date = new Date(),
|
|
297
|
+
): Promise<CalendarResult<string>> => {
|
|
298
|
+
const calendar = new ICAL.Component("vcalendar");
|
|
299
|
+
calendar.addPropertyWithValue("version", "2.0");
|
|
300
|
+
calendar.addPropertyWithValue("prodid", CALENDAR_PRODID);
|
|
301
|
+
|
|
302
|
+
const event = new ICAL.Component("vevent");
|
|
303
|
+
calendar.addSubcomponent(event);
|
|
304
|
+
event.addPropertyWithValue("uid", uid);
|
|
305
|
+
event.addPropertyWithValue("dtstamp", ICAL.Time.fromJSDate(now, true));
|
|
306
|
+
|
|
307
|
+
const applied = await applyEventFields(event, fields, fields.timeZone);
|
|
308
|
+
if (!applied.ok) return applied;
|
|
309
|
+
|
|
310
|
+
return { ok: true, value: serializeCalendar(calendar) };
|
|
311
|
+
};
|
package/src/errors.ts
CHANGED
|
@@ -22,7 +22,19 @@ export type CalendarValidationCode =
|
|
|
22
22
|
/** A VEVENT with no DTSTART. */
|
|
23
23
|
| "MissingDtStart"
|
|
24
24
|
/** A VEVENT whose end precedes its start. */
|
|
25
|
-
| "BackwardsEnd"
|
|
25
|
+
| "BackwardsEnd"
|
|
26
|
+
/** A start or end that is not a date-time this server can read. */
|
|
27
|
+
| "InvalidDateTime"
|
|
28
|
+
/** A time zone name nothing on this platform resolves. */
|
|
29
|
+
| "UnknownTimeZone"
|
|
30
|
+
/** An RRULE value ical.js could not read. */
|
|
31
|
+
| "InvalidRecurrenceRule"
|
|
32
|
+
/** A scoped write that names one occurrence without saying which. */
|
|
33
|
+
| "MissingRecurrenceId"
|
|
34
|
+
/** A per-occurrence scope against an event that has one occurrence. */
|
|
35
|
+
| "NotRecurring"
|
|
36
|
+
/** A RECURRENCE-ID naming no occurrence this series produces. */
|
|
37
|
+
| "UnknownOccurrence";
|
|
26
38
|
|
|
27
39
|
export interface CalendarValidationError {
|
|
28
40
|
code: CalendarValidationCode;
|
package/src/expand.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
|
+
import { CalendarEventStatus, CalendarTransparency } from "@remit/domain-enums";
|
|
3
4
|
import {
|
|
4
5
|
CALENDAR_EXPANSION_HORIZON_DAYS,
|
|
5
6
|
CALENDAR_EXPANSION_MAX_OCCURRENCES,
|
|
@@ -40,6 +41,9 @@ describe("expandCalendar", () => {
|
|
|
40
41
|
startAt: "2026-08-26T09:00:00Z",
|
|
41
42
|
endAt: "2026-08-26T10:00:00Z",
|
|
42
43
|
allDay: false,
|
|
44
|
+
summary: "",
|
|
45
|
+
status: CalendarEventStatus.Confirmed,
|
|
46
|
+
transparency: CalendarTransparency.Opaque,
|
|
43
47
|
},
|
|
44
48
|
]);
|
|
45
49
|
assert.equal(expansion.expandedThrough, "");
|
|
@@ -272,12 +276,18 @@ describe("expandCalendar", () => {
|
|
|
272
276
|
startAt: "2026-08-25T22:00:00Z",
|
|
273
277
|
endAt: "2026-08-26T22:00:00Z",
|
|
274
278
|
allDay: true,
|
|
279
|
+
summary: "",
|
|
280
|
+
status: CalendarEventStatus.Confirmed,
|
|
281
|
+
transparency: CalendarTransparency.Opaque,
|
|
275
282
|
},
|
|
276
283
|
{
|
|
277
284
|
recurrenceId: "2026-08-26T22:00:00Z",
|
|
278
285
|
startAt: "2026-08-26T22:00:00Z",
|
|
279
286
|
endAt: "2026-08-27T22:00:00Z",
|
|
280
287
|
allDay: true,
|
|
288
|
+
summary: "",
|
|
289
|
+
status: CalendarEventStatus.Confirmed,
|
|
290
|
+
transparency: CalendarTransparency.Opaque,
|
|
281
291
|
},
|
|
282
292
|
]);
|
|
283
293
|
});
|