Skip to content

Commit b21df75

Browse files
✨ feat(edit-recurrence): include start date instance if not in recurrence (#1025)
* ✨ feat(edit-recurrence): include start date instance if not in recurrence * ✨ feat(edit-recurrence): copilot suggested changes
1 parent 5889299 commit b21df75

File tree

4 files changed

+90
-44
lines changed

4 files changed

+90
-44
lines changed

packages/backend/src/event/classes/gcal.event.rrule.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@ describe("GcalEventRRule: ", () => {
4444
});
4545

4646
expect(rrule.toString()).toContain("RRULE:FREQ=DAILY");
47-
expect(rrule.toString()).toContain(`COUNT=${GCAL_MAX_RECURRENCES}`);
4847
expect(rrule.count()).toBe(GCAL_MAX_RECURRENCES);
4948
expect(rrule.all()).toHaveLength(GCAL_MAX_RECURRENCES);
5049
});

packages/backend/src/event/classes/gcal.event.rrule.ts

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
gSchema$EventBase,
1010
gSchema$EventInstance,
1111
} from "@core/types/gcal";
12-
import dayjs from "@core/util/date/dayjs";
12+
import dayjs, { Dayjs } from "@core/util/date/dayjs";
1313
import { diffRRuleOptions } from "@core/util/event/event.util";
1414
import {
1515
getGcalEventDateFormat,
@@ -22,6 +22,9 @@ export class GcalEventRRule extends RRule {
2222
#dateKey: "date" | "dateTime";
2323
#dateFormat: string;
2424
#durationMs!: number;
25+
#startDate!: Dayjs;
26+
#endDate!: Dayjs;
27+
#timezone!: string;
2528

2629
constructor(event: gSchema$EventBase, options: Partial<Options> = {}) {
2730
super(GcalEventRRule.#initOptions(event, options));
@@ -30,17 +33,18 @@ export class GcalEventRRule extends RRule {
3033
this.#isAllDay = "date" in this.#event.start!;
3134
this.#dateKey = this.#isAllDay ? "date" : "dateTime";
3235
this.#dateFormat = getGcalEventDateFormat(this.#event.start);
36+
this.#timezone = this.#event.start?.timeZone ?? dayjs.tz.guess();
3337

3438
const { start, end } = this.#event;
35-
const startDate = parseGCalEventDate(start);
36-
const endDate = parseGCalEventDate(end);
3739

38-
this.#durationMs = endDate.diff(startDate, "milliseconds");
40+
this.#startDate = parseGCalEventDate(start);
41+
this.#endDate = parseGCalEventDate(end);
42+
this.#durationMs = this.#endDate.diff(this.#startDate, "milliseconds");
3943
}
4044

4145
static #initOptions(
4246
event: gSchema$EventBase,
43-
options: Partial<Options> = {},
47+
_options: Partial<Options> = {},
4448
): Partial<Options> {
4549
const startDate = parseGCalEventDate(event.start);
4650
const dtstart = startDate.local().toDate();
@@ -49,11 +53,14 @@ export class GcalEventRRule extends RRule {
4953
const recurrence = event.recurrence?.join("\n").trim();
5054
const valid = recurrence?.length > 0;
5155
const rruleSet = valid ? rrulestr(recurrence!, opts) : { origOptions: {} };
52-
const rruleOptions = { ...rruleSet.origOptions, ...options };
53-
const rawCount = rruleOptions.count ?? GCAL_MAX_RECURRENCES;
54-
const count = Math.min(rawCount, GCAL_MAX_RECURRENCES);
56+
const rruleOptions = { ...rruleSet.origOptions, ..._options };
57+
const options = { ...rruleOptions, dtstart, tzid };
5558

56-
return { ...rruleOptions, count, dtstart, tzid };
59+
if (options.until instanceof Date) {
60+
options.count = undefined as unknown as number;
61+
}
62+
63+
return options;
5764
}
5865

5966
diffOptions(rrule: GcalEventRRule): Array<[keyof ParsedOptions, unknown]> {
@@ -64,6 +71,19 @@ export class GcalEventRRule extends RRule {
6471
return this.toString().split("\n");
6572
}
6673

74+
override all(
75+
iterator: (d: Date, len: number) => boolean = (_, index) =>
76+
index < GCAL_MAX_RECURRENCES,
77+
): Date[] {
78+
const dates = super.all(iterator);
79+
const firstInstance = dates[0];
80+
const firstInstanceStartDate = dayjs(firstInstance).tz(this.#timezone);
81+
const includesDtStart = this.#startDate.isSame(firstInstanceStartDate);
82+
const rDates = includesDtStart ? [] : [this.#startDate.toDate()];
83+
84+
return rDates.concat(dates);
85+
}
86+
6787
/**
6888
* instances
6989
*
@@ -73,9 +93,7 @@ export class GcalEventRRule extends RRule {
7393
*/
7494
instances(): gSchema$EventInstance[] {
7595
return this.all().map((date) => {
76-
const timezone = dayjs.tz.guess();
77-
const tzid = this.#event.start?.timeZone ?? timezone;
78-
const startDate = dayjs(date).tz(tzid);
96+
const startDate = dayjs(date).tz(this.#timezone);
7997
const endDate = startDate.add(this.#durationMs, "milliseconds");
8098

8199
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -85,11 +103,11 @@ export class GcalEventRRule extends RRule {
85103
recurringEventId: this.#event.id!,
86104
start: {
87105
[this.#dateKey]: startDate?.format(this.#dateFormat),
88-
timeZone: this.#event.start?.timeZone ?? timezone,
106+
timeZone: this.#timezone,
89107
},
90108
end: {
91109
[this.#dateKey]: endDate.format(this.#dateFormat),
92-
timeZone: this.#event.end?.timeZone ?? timezone,
110+
timeZone: this.#timezone,
93111
},
94112
};
95113

packages/core/src/util/event/compass.event.rrule.test.ts

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -42,23 +42,6 @@ describe("CompassEventRRule: ", () => {
4242
expect(rrule.all()).toHaveLength(count);
4343
});
4444

45-
it(`should adjust the instance COUNT to a maximum of ${GCAL_MAX_RECURRENCES}`, () => {
46-
const rruleString = "RRULE:FREQ=DAILY;COUNT=1000";
47-
48-
const baseEvent = createMockBaseEvent({
49-
recurrence: { rule: [rruleString] },
50-
});
51-
52-
const rrule = new CompassEventRRule({
53-
...baseEvent,
54-
_id: new ObjectId(baseEvent._id),
55-
});
56-
57-
expect(rrule.toString()).toContain("RRULE:FREQ=DAILY");
58-
expect(rrule.count()).toBe(GCAL_MAX_RECURRENCES);
59-
expect(rrule.all()).toHaveLength(GCAL_MAX_RECURRENCES);
60-
});
61-
6245
it("should return the rrule in system timezone", () => {
6346
const baseEvent = createMockBaseEvent();
6447
const rrule = new CompassEventRRule({
@@ -94,6 +77,40 @@ describe("CompassEventRRule: ", () => {
9477
);
9578
});
9679

80+
it("should include start dates outside the recurrence rule", () => {
81+
// Add an extra date outside the recurrence (e.g., a Friday)
82+
const startDateOnMonday = dayjs().startOf("week").add(1, "day");
83+
const startDate = startDateOnMonday.toISOString();
84+
const rule = [`RRULE:FREQ=WEEKLY;COUNT=0;BYDAY=${RRule.FR}`];
85+
const baseEvent = createMockBaseEvent({ startDate, recurrence: { rule } });
86+
const _id = new ObjectId(baseEvent._id);
87+
const rrule = new CompassEventRRule({ ...baseEvent, _id });
88+
89+
const allDates = rrule.all();
90+
91+
expect(allDates).toHaveLength(1);
92+
93+
expect(allDates.some((d) => dayjs(d).isSame(startDateOnMonday))).toBe(true);
94+
});
95+
96+
it("should correctly merge options when multiple rrules are present", () => {
97+
const rule = [
98+
"RRULE:FREQ=DAILY;COUNT=2",
99+
"RRULE:FREQ=WEEKLY;COUNT=1;BYDAY=FR",
100+
];
101+
const baseEvent = createMockBaseEvent({ recurrence: { rule } });
102+
const _id = new ObjectId(baseEvent._id);
103+
const rrule = new CompassEventRRule({ ...baseEvent, _id });
104+
105+
// Should include both daily and weekly recurrences
106+
const allDates = rrule.all();
107+
expect(allDates.length).toBeGreaterThanOrEqual(2);
108+
109+
// Options should merge both rules
110+
expect(rrule.options.freq).toBeDefined();
111+
expect(rrule.options.count).toBeDefined();
112+
});
113+
97114
describe("diffOptions", () => {
98115
it("should return the differences between two rrule options", () => {
99116
const until = dayjs();

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

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import type {
1010
Schema_Event_Recur_Instance,
1111
WithMongoId,
1212
} from "@core/types/event.types";
13-
import dayjs from "@core/util/date/dayjs";
13+
import dayjs, { Dayjs } from "@core/util/date/dayjs";
1414
import {
1515
diffRRuleOptions,
1616
getCompassEventDateFormat,
@@ -21,6 +21,9 @@ export class CompassEventRRule extends RRule {
2121
#event: WithMongoId<Omit<Schema_Event_Recur_Base, "_id">>;
2222
#dateFormat: string;
2323
#durationMs!: number;
24+
#startDate!: Dayjs;
25+
#endDate!: Dayjs;
26+
#timezone!: string;
2427

2528
constructor(
2629
event: Pick<
@@ -33,11 +36,10 @@ export class CompassEventRRule extends RRule {
3336

3437
this.#event = event;
3538
this.#dateFormat = getCompassEventDateFormat(this.#event.startDate!);
36-
37-
const startDate = parseCompassEventDate(this.#event.startDate!);
38-
const endDate = parseCompassEventDate(this.#event.endDate!);
39-
40-
this.#durationMs = endDate.diff(startDate, "milliseconds");
39+
this.#startDate = parseCompassEventDate(this.#event.startDate!);
40+
this.#endDate = parseCompassEventDate(this.#event.endDate!);
41+
this.#durationMs = this.#endDate.diff(this.#startDate, "milliseconds");
42+
this.#timezone = dayjs.tz.guess();
4143
}
4244

4345
static #initOptions(
@@ -52,9 +54,7 @@ export class CompassEventRRule extends RRule {
5254
const valid = (recurrence?.length ?? 0) > 0;
5355
const rruleSet = valid ? rrulestr(recurrence!, opts) : { origOptions: {} };
5456
const rruleOptions = { ...rruleSet.origOptions, ..._options };
55-
const rawCount = rruleOptions.count ?? GCAL_MAX_RECURRENCES;
56-
const count = Math.min(rawCount, GCAL_MAX_RECURRENCES);
57-
const options = { ...rruleOptions, count, dtstart, tzid };
57+
const options = { ...rruleOptions, dtstart, tzid };
5858

5959
if (options.until instanceof Date) {
6060
options.count = undefined as unknown as number;
@@ -92,7 +92,7 @@ export class CompassEventRRule extends RRule {
9292
return super.toString();
9393
}
9494

95-
toString(): string {
95+
override toString(): string {
9696
const untilRule = this.formatUNTIL(super.toString());
9797

9898
return this.formatCount(untilRule)
@@ -109,6 +109,19 @@ export class CompassEventRRule extends RRule {
109109
.filter((r) => !(r.startsWith("DTSTART") || r.startsWith("DTEND")));
110110
}
111111

112+
override all(
113+
iterator: (d: Date, len: number) => boolean = (_, index) =>
114+
index < GCAL_MAX_RECURRENCES,
115+
): Date[] {
116+
const dates = super.all(iterator);
117+
const firstInstance = dates[0];
118+
const firstInstanceStartDate = dayjs(firstInstance).tz(this.#timezone);
119+
const includesDtStart = this.#startDate.isSame(firstInstanceStartDate);
120+
const rDates = includesDtStart ? [] : [this.#startDate.toDate()];
121+
122+
return rDates.concat(dates);
123+
}
124+
112125
base(
113126
provider?: CalendarProvider,
114127
): WithMongoId<Omit<Schema_Event_Recur_Base, "_id">> {
@@ -136,8 +149,7 @@ export class CompassEventRRule extends RRule {
136149

137150
return this.all().map((date) => {
138151
const _id = new ObjectId();
139-
const tzid = dayjs.tz.guess();
140-
const _startDate = dayjs(date).tz(tzid);
152+
const _startDate = dayjs(date).tz(this.#timezone);
141153
const _endDate = _startDate.add(this.#durationMs, "milliseconds");
142154
const startDate = _startDate.format(this.#dateFormat);
143155
const endDate = _endDate.format(this.#dateFormat);

0 commit comments

Comments
 (0)