@remit/calendar-service 0.0.16 → 0.0.17

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.16",
3
+ "version": "0.0.17",
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",
@@ -58,6 +58,7 @@ const suggestionOf = (
58
58
  summary: "Quarterly review",
59
59
  dtStart: "2026-09-01T08:00:00+00:00",
60
60
  dtEnd: "2026-09-01T09:00:00+00:00",
61
+ endsAtUtc: "2026-09-01T09:00:00Z",
61
62
  allDay: false,
62
63
  location: "",
63
64
  organizer: "organizer@example.test",
@@ -273,11 +273,17 @@ export class MemoryCalendarStore implements ICalendarUnitOfWork {
273
273
  listByState: async (
274
274
  accountConfigId: string,
275
275
  state: CalendarSuggestionItem["state"],
276
+ options: {
277
+ endsAfter: string;
278
+ limit?: number;
279
+ continuationToken?: string;
280
+ },
276
281
  ) => ({
277
282
  items: [...this.suggestions.values()].filter(
278
283
  (suggestion) =>
279
284
  suggestion.accountConfigId === accountConfigId &&
280
- suggestion.state === state,
285
+ suggestion.state === state &&
286
+ suggestion.endsAtUtc >= options.endsAfter,
281
287
  ),
282
288
  continuationToken: undefined,
283
289
  }),
@@ -71,9 +71,32 @@ describe("projectSuggestion", () => {
71
71
  assert.equal(projected.value.organizer, "organizer@example.test");
72
72
  assert.equal(projected.value.dtStart, "2026-09-01T10:00:00+02:00");
73
73
  assert.equal(projected.value.dtEnd, "2026-09-01T11:00:00+02:00");
74
+ assert.equal(projected.value.endsAtUtc, "2026-09-01T09:00:00Z");
74
75
  assert.equal(projected.value.allDay, false);
75
76
  });
76
77
 
78
+ const withRule = (rule: string): string => invitation({ extra: [rule] });
79
+
80
+ it("ends a bounded series with its last occurrence", async () => {
81
+ const projected = await projectSuggestion(
82
+ withRule("RRULE:FREQ=WEEKLY;COUNT=3"),
83
+ "Europe/Amsterdam",
84
+ );
85
+
86
+ assert.ok(projected.ok);
87
+ assert.equal(projected.value.endsAtUtc, "2026-09-15T09:00:00Z");
88
+ });
89
+
90
+ it("never ends an open-ended series", async () => {
91
+ const projected = await projectSuggestion(
92
+ withRule("RRULE:FREQ=WEEKLY"),
93
+ "Europe/Amsterdam",
94
+ );
95
+
96
+ assert.ok(projected.ok);
97
+ assert.equal(projected.value.endsAtUtc, "9999-12-31T23:59:59Z");
98
+ });
99
+
77
100
  it("reads a cancellation as a cancellation", async () => {
78
101
  const projected = await projectSuggestion(
79
102
  invitation({ method: "CANCEL", sequence: 2 }),
@@ -289,10 +312,12 @@ describe("recordCalendarSuggestion", () => {
289
312
  listByState: async (
290
313
  accountConfigId: string,
291
314
  state: CalendarSuggestionItem["state"],
315
+ options: { endsAfter: string },
292
316
  ) => {
293
317
  const page = await store.calendarSuggestion.listByState(
294
318
  accountConfigId,
295
319
  state,
320
+ options,
296
321
  );
297
322
  // The user accepts, right here.
298
323
  await store.calendarSuggestion.settle(ACCOUNT_CONFIG_ID, target, {
package/src/suggest.ts CHANGED
@@ -9,8 +9,14 @@ import {
9
9
  } from "@remit/domain-enums";
10
10
  import type ICAL from "ical.js";
11
11
  import type { CalendarResult } from "./errors.js";
12
+ import { expandCalendar } from "./expand.js";
13
+ import type { ParsedCalendar } from "./parse.js";
12
14
  import { parseCalendar } from "./parse.js";
13
15
  import { projectCalendar, readString } from "./project.js";
16
+ import { toUtcIso } from "./time.js";
17
+
18
+ const OPEN_ENDED_SERIES = "9999-12-31T23:59:59Z";
19
+ const EARLIEST_INSTANT = new Date(0).toISOString();
14
20
 
15
21
  const METHOD_BY_ICAL: Record<string, CalendarSuggestionItem["method"]> = {
16
22
  REQUEST: CalendarInviteMethod.Request,
@@ -48,12 +54,25 @@ export type CalendarSuggestionProjection = Pick<
48
54
  | "summary"
49
55
  | "dtStart"
50
56
  | "dtEnd"
57
+ | "endsAtUtc"
51
58
  | "allDay"
52
59
  | "location"
53
60
  | "organizer"
54
61
  | "zoneCertainty"
55
62
  >;
56
63
 
64
+ const endsAtUtcOf = (
65
+ calendar: ParsedCalendar,
66
+ timezone: string,
67
+ dtEnd: string,
68
+ ): string => {
69
+ const expansion = expandCalendar(calendar, timezone);
70
+ if (expansion.expandedThrough !== "") return OPEN_ENDED_SERIES;
71
+ const ends = expansion.occurrences.map((occurrence) => occurrence.endAt);
72
+ if (ends.length === 0) return toUtcIso(Date.parse(dtEnd));
73
+ return ends.reduce((latest, end) => (end > latest ? end : latest));
74
+ };
75
+
57
76
  /**
58
77
  * Reads a message's `text/calendar` bytes into the facts a card is drawn from.
59
78
  *
@@ -80,6 +99,7 @@ export const projectSuggestion = async (
80
99
  summary: projection.value.summary,
81
100
  dtStart: projection.value.dtStart,
82
101
  dtEnd: projection.value.dtEnd,
102
+ endsAtUtc: endsAtUtcOf(parsed.value, timezone, projection.value.dtEnd),
83
103
  allDay: projection.value.allDay,
84
104
  location: readString(parsed.value.master, "location"),
85
105
  organizer: mailAddressOf(readString(parsed.value.master, "organizer")),
@@ -125,6 +145,7 @@ const listInState = async (
125
145
  let continuationToken: string | undefined;
126
146
  do {
127
147
  const page = await repo.listByState(accountConfigId, state, {
148
+ endsAfter: EARLIEST_INSTANT,
128
149
  continuationToken,
129
150
  });
130
151
  items.push(...page.items);