Skip to content

Commit f10aa9f

Browse files
committed
✨ feat(edit-recurrence): add compass rrule parser
1 parent e82a131 commit f10aa9f

File tree

5 files changed

+342
-2
lines changed

5 files changed

+342
-2
lines changed
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import { ObjectId } from "mongodb";
2+
import { faker } from "@faker-js/faker";
3+
import { GCAL_MAX_RECURRENCES } from "@core/constants/core.constants";
4+
import dayjs from "@core/util/date/dayjs";
5+
import { parseCompassEventDate } from "@core/util/event/event.util";
6+
import {
7+
createMockBaseEvent,
8+
generateCompassEventDates,
9+
} from "@core/util/test/ccal.event.factory";
10+
import { CompassEventRRule } from "@backend/event/classes/compass.event.rrule";
11+
12+
describe("CompassEventRRule: ", () => {
13+
it(`should return the correct number of events based on rrule count`, () => {
14+
const count = faker.number.int({ min: 1, max: GCAL_MAX_RECURRENCES });
15+
const rruleString = `RRULE:FREQ=DAILY;COUNT=${count}`;
16+
17+
const baseEvent = createMockBaseEvent({
18+
recurrence: { rule: [rruleString] },
19+
});
20+
21+
const rrule = new CompassEventRRule({
22+
...baseEvent,
23+
_id: new ObjectId(baseEvent._id),
24+
});
25+
26+
expect(rrule.toString()).toContain("RRULE:FREQ=DAILY");
27+
expect(rrule.toString()).toContain(`COUNT=${count}`);
28+
expect(rrule.count()).toBe(count);
29+
expect(rrule.all()).toHaveLength(count);
30+
});
31+
32+
it(`should adjust the COUNT in the rrule string to a maximum of ${GCAL_MAX_RECURRENCES}`, () => {
33+
const rruleString = "RRULE:FREQ=DAILY;COUNT=1000";
34+
35+
const baseEvent = createMockBaseEvent({
36+
recurrence: { rule: [rruleString] },
37+
});
38+
39+
const rrule = new CompassEventRRule({
40+
...baseEvent,
41+
_id: new ObjectId(baseEvent._id),
42+
});
43+
44+
expect(rrule.toString()).toContain("RRULE:FREQ=DAILY");
45+
expect(rrule.toString()).toContain(`COUNT=${GCAL_MAX_RECURRENCES}`);
46+
expect(rrule.count()).toBe(GCAL_MAX_RECURRENCES);
47+
expect(rrule.all()).toHaveLength(GCAL_MAX_RECURRENCES);
48+
});
49+
50+
it("should return the rrule in system timezone", () => {
51+
const baseEvent = createMockBaseEvent();
52+
const rrule = new CompassEventRRule({
53+
...baseEvent,
54+
_id: new ObjectId(baseEvent._id),
55+
});
56+
const startDate = parseCompassEventDate(baseEvent.startDate!);
57+
const events = rrule.all();
58+
59+
expect(rrule.options.dtstart.toISOString()).toEqual(
60+
startDate.toISOString(),
61+
);
62+
63+
expect(events).toEqual(expect.arrayContaining([startDate.toDate()]));
64+
});
65+
66+
it("should return the rrule without DTSTART and DTEND", () => {
67+
const baseEvent = createMockBaseEvent();
68+
const rrule = new CompassEventRRule({
69+
...baseEvent,
70+
_id: new ObjectId(baseEvent._id),
71+
});
72+
73+
expect(rrule.toString().includes("DTSTART")).toEqual(false);
74+
expect(rrule.toString().includes("DTEND")).toEqual(false);
75+
76+
expect(
77+
rrule.toRecurrence().some((rule) => rule.includes("DTSTART")),
78+
).toEqual(false);
79+
80+
expect(rrule.toRecurrence().some((rule) => rule.includes("DTEND"))).toEqual(
81+
false,
82+
);
83+
});
84+
85+
describe("base", () => {
86+
it("should return the recurrence string as an array", () => {
87+
const baseEvent = createMockBaseEvent();
88+
const rrule = new CompassEventRRule({
89+
...baseEvent,
90+
_id: new ObjectId(baseEvent._id),
91+
});
92+
const recurrence = rrule.toRecurrence();
93+
94+
expect(recurrence).toBeInstanceOf(Array);
95+
expect(recurrence.length).toBeGreaterThan(0);
96+
97+
expect(recurrence.some((rule) => rule.startsWith("RRULE:"))).toEqual(
98+
true,
99+
);
100+
});
101+
});
102+
103+
describe("instances", () => {
104+
it(`should return a maximum of ${GCAL_MAX_RECURRENCES} compass instances if no count is supplied in the recurrence`, () => {
105+
const rule = ["RRULE:FREQ=DAILY"];
106+
const baseEvent = createMockBaseEvent({ recurrence: { rule } });
107+
const rrule = new CompassEventRRule({
108+
...baseEvent,
109+
_id: new ObjectId(baseEvent._id),
110+
});
111+
const instances = rrule.instances();
112+
113+
expect(instances).toBeInstanceOf(Array);
114+
expect(instances).toHaveLength(GCAL_MAX_RECURRENCES);
115+
});
116+
117+
it(`should return a maximum of ${GCAL_MAX_RECURRENCES} compass instances if count exceeds maximum recurrence`, () => {
118+
const rule = ["RRULE:FREQ=DAILY;COUNT=1000"];
119+
const baseEvent = createMockBaseEvent({ recurrence: { rule } });
120+
const rrule = new CompassEventRRule({
121+
...baseEvent,
122+
_id: new ObjectId(baseEvent._id),
123+
});
124+
const instances = rrule.instances();
125+
126+
expect(instances).toBeInstanceOf(Array);
127+
expect(instances).toHaveLength(GCAL_MAX_RECURRENCES);
128+
});
129+
130+
it("should return the correct number of compass instances based on rrule count", () => {
131+
const count = faker.number.int({ min: 1, max: GCAL_MAX_RECURRENCES });
132+
const rule = [`RRULE:FREQ=DAILY;COUNT=${count}`];
133+
const baseEvent = createMockBaseEvent({ recurrence: { rule } });
134+
const rrule = new CompassEventRRule({
135+
...baseEvent,
136+
_id: new ObjectId(baseEvent._id),
137+
});
138+
const instances = rrule.instances();
139+
140+
expect(instances).toBeInstanceOf(Array);
141+
expect(instances).toHaveLength(count);
142+
});
143+
144+
it("should return compass instances with the correct date format and timezone for an ALLDAY base event", () => {
145+
const rule = ["RRULE:FREQ=DAILY;COUNT=10"];
146+
const date = dayjs().startOf("year"); // specific date for testing
147+
const dates = generateCompassEventDates({ date, allDay: true });
148+
const baseEvent = createMockBaseEvent({ ...dates, recurrence: { rule } });
149+
const rrule = new CompassEventRRule({
150+
...baseEvent,
151+
_id: new ObjectId(baseEvent._id),
152+
});
153+
const instances = rrule.instances();
154+
const startDate = parseCompassEventDate(baseEvent.startDate!);
155+
const endDate = parseCompassEventDate(baseEvent.endDate!);
156+
const dateFormat = dayjs.DateFormat.YEAR_MONTH_DAY_FORMAT;
157+
158+
instances.forEach((instance, index) => {
159+
expect(instance.startDate).toBeDefined();
160+
expect(instance.endDate).toBeDefined();
161+
162+
expect(instance.startDate).toEqual(
163+
startDate.add(index, "day").format(dateFormat),
164+
);
165+
166+
expect(instance.endDate).toEqual(
167+
endDate.add(index, "day").format(dateFormat),
168+
);
169+
});
170+
});
171+
172+
it("should return compass instances with the correct date format and timezone for a TIMED base event", () => {
173+
const rule = ["RRULE:FREQ=DAILY;COUNT=10"];
174+
const date = dayjs().startOf("year"); // specific date for testing
175+
const dates = generateCompassEventDates({ date });
176+
const baseEvent = createMockBaseEvent({ ...dates, recurrence: { rule } });
177+
const rrule = new CompassEventRRule({
178+
...baseEvent,
179+
_id: new ObjectId(baseEvent._id),
180+
});
181+
const instances = rrule.instances();
182+
const startDate = parseCompassEventDate(baseEvent.startDate!);
183+
const endDate = parseCompassEventDate(baseEvent.endDate!);
184+
const dateFormat = dayjs.DateFormat.RFC3339_OFFSET;
185+
186+
instances.forEach((instance, index) => {
187+
expect(instance.startDate).toBeDefined();
188+
expect(instance.endDate).toBeDefined();
189+
190+
expect(instance.startDate).toEqual(
191+
startDate.add(index, "day").format(dateFormat),
192+
);
193+
194+
expect(instance.endDate).toEqual(
195+
endDate.add(index, "day").format(dateFormat),
196+
);
197+
});
198+
});
199+
});
200+
});
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { ObjectId, WithId } from "mongodb";
2+
import { Options, RRule, RRuleStrOptions, rrulestr } from "rrule";
3+
import { GCAL_MAX_RECURRENCES } from "@core/constants/core.constants";
4+
import {
5+
Schema_Event,
6+
Schema_Event_Recur_Instance,
7+
} from "@core/types/event.types";
8+
import dayjs from "@core/util/date/dayjs";
9+
import {
10+
getCompassEventDateFormat,
11+
parseCompassEventDate,
12+
} from "@core/util/event/event.util";
13+
14+
export class CompassEventRRule extends RRule {
15+
#event: WithId<Omit<Schema_Event, "_id">>;
16+
#dateFormat: string;
17+
#durationMs!: number;
18+
19+
constructor(
20+
event: WithId<Omit<Schema_Event, "_id">>,
21+
options: Partial<Options> = {},
22+
) {
23+
super(CompassEventRRule.#initOptions(event, options));
24+
25+
this.#event = event;
26+
this.#dateFormat = getCompassEventDateFormat(this.#event.startDate!);
27+
28+
const startDate = parseCompassEventDate(this.#event.startDate!);
29+
const endDate = parseCompassEventDate(this.#event.endDate!);
30+
31+
this.#durationMs = endDate.diff(startDate, "milliseconds");
32+
}
33+
34+
static #initOptions(
35+
event: WithId<Omit<Schema_Event, "_id">>,
36+
options: Partial<Options> = {},
37+
): Partial<Options> {
38+
const startDate = parseCompassEventDate(event.startDate!);
39+
const dtstart = startDate.local().toDate();
40+
const tzid = dayjs.tz.guess();
41+
const opts: Partial<RRuleStrOptions> = { dtstart, tzid };
42+
const recurrence = event.recurrence?.rule?.join("\n").trim();
43+
const valid = (recurrence?.length ?? 0) > 0;
44+
const rruleSet = valid ? rrulestr(recurrence!, opts) : { origOptions: {} };
45+
const rruleOptions = { ...rruleSet.origOptions, ...options };
46+
const rawCount = rruleOptions.count ?? GCAL_MAX_RECURRENCES;
47+
const count = Math.min(rawCount, GCAL_MAX_RECURRENCES);
48+
49+
return { ...rruleOptions, count, dtstart, tzid };
50+
}
51+
52+
toString(): string {
53+
return super
54+
.toString()
55+
.split("\n")
56+
.filter((r) => !(r.startsWith("DTSTART") || r.startsWith("DTEND")))
57+
.join("\n");
58+
}
59+
60+
toRecurrence(): string[] {
61+
return super
62+
.toString()
63+
.split("\n")
64+
.filter((r) => !(r.startsWith("DTSTART") || r.startsWith("DTEND")));
65+
}
66+
67+
base(): WithId<Omit<Schema_Event, "_id">> {
68+
return {
69+
...this.#event,
70+
_id: this.#event._id ?? new ObjectId(),
71+
recurrence: { rule: this.toRecurrence() },
72+
};
73+
}
74+
75+
/**
76+
* instances
77+
*
78+
* @memberof GcalEventRRule
79+
* @description Returns all instances of the event based on the recurrence rule.
80+
* @note **This is a test-only method for now, it is not to be used in production.**
81+
*/
82+
instances(): WithId<Omit<Schema_Event_Recur_Instance, "_id">>[] {
83+
return this.all().map((date) => {
84+
const timezone = dayjs.tz.guess();
85+
const startDate = dayjs(date).tz(timezone);
86+
const endDate = startDate.add(this.#durationMs, "milliseconds");
87+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
88+
const { order, allDayOrder, ...baseData } = this.#event;
89+
90+
return {
91+
...baseData,
92+
_id: new ObjectId(),
93+
startDate: startDate.format(this.#dateFormat),
94+
endDate: endDate.format(this.#dateFormat),
95+
recurrence: { eventId: this.base()._id.toString() },
96+
};
97+
});
98+
}
99+
}

packages/core/src/util/date/dayjs-compass.plugin.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import type { Dayjs, PluginFunc } from "dayjs";
22
import dayjs from "dayjs";
33
import winston from "winston";
4-
import { Logger } from "@core/logger/winston.logger";
54

65
enum DateFormatEnum {
76
RFC5545 = "YYYYMMDD[T]HHmmss[Z]",
@@ -180,7 +179,6 @@ export const dayjsCompassPlugin: PluginFunc<never> = (...params) => {
180179
dayjsClass.prototype.toRRuleDTSTARTString = toRRuleDTSTARTString;
181180
dayjsClass.prototype.weekMonthRange = weekMonthRange;
182181

183-
dayjsStatic.logger = Logger("core.util.date.dayjs");
184182
dayjsStatic.DateFormat = DateFormatEnum;
185183

186184
dayjsStatic.monthFromZeroIndex = monthFromZeroIndex.bind(dayjsStatic);

packages/core/src/util/event/event.util.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
Schema_Event_Recur_Instance,
66
} from "@core/types/event.types";
77
import { UserMetadata } from "@core/types/user.types";
8+
import dayjs, { Dayjs } from "@core/util/date/dayjs";
89
import { Event_API } from "@backend/common/types/backend.event.types";
910

1011
/** Event utilities for Compass events */
@@ -113,3 +114,24 @@ export const shouldImportGCal = (metadata: UserMetadata): boolean => {
113114
return true;
114115
}
115116
};
117+
118+
export const getCompassEventDateFormat = (
119+
date: Exclude<Schema_Event["startDate"], undefined>,
120+
): string => {
121+
const allday = isAllDay({ startDate: date, endDate: date });
122+
const { YEAR_MONTH_DAY_FORMAT, RFC3339_OFFSET } = dayjs.DateFormat;
123+
const format = allday ? YEAR_MONTH_DAY_FORMAT : RFC3339_OFFSET;
124+
125+
return format;
126+
};
127+
128+
export const parseCompassEventDate = (
129+
date: Exclude<Schema_Event["startDate"], undefined>,
130+
): Dayjs => {
131+
if (!date) throw new Error("`date` or `dateTime` must be defined");
132+
133+
const format = getCompassEventDateFormat(date);
134+
const timezone = dayjs.tz.guess();
135+
136+
return dayjs(date, format).tz(timezone);
137+
};

packages/core/src/util/test/ccal.event.factory.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,3 +125,24 @@ export const createMockInstances = (
125125

126126
return instances;
127127
};
128+
129+
export const generateCompassEventDates = ({
130+
date,
131+
allDay = false,
132+
value = 1,
133+
unit = "hours",
134+
}: {
135+
date?: dayjs.ConfigType;
136+
value?: number;
137+
unit?: dayjs.ManipulateType;
138+
allDay?: boolean;
139+
timezone?: string;
140+
} = {}): Pick<Schema_Event, "startDate" | "endDate"> => {
141+
const timeZone = dayjs.tz.guess();
142+
const start = dayjs.tz(date ?? faker.date.future(), timeZone);
143+
const end = start.add(value, unit);
144+
const { YEAR_MONTH_DAY_FORMAT, RFC3339_OFFSET } = dayjs.DateFormat;
145+
const format = allDay ? YEAR_MONTH_DAY_FORMAT : RFC3339_OFFSET;
146+
147+
return { startDate: start.format(format), endDate: end.format(format) };
148+
};

0 commit comments

Comments
 (0)