@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/calendar-service",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "iCalendar (RFC 5545) parsing, projection and recurrence expansion over the calendar store",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -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;
@@ -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
  });