Skip to content

Commit 41f32d7

Browse files
snomiaoclaude
andcommitted
fix(calendar): RECURRENCE-ID handling — overrides replace their instance
Google exports every edited instance of a recurring event as a separate VEVENT with the SAME UID plus RECURRENCE-ID naming the original occurrence start. The parser treated those as independent events, duplicating each edited occurrence (and breaking uid uniqueness — the false-alarm diff from the move/resize QA). parseIcs is now two-pass: protos collected, then overrides grouped per UID and substituted for the matching expanded instance — moved/renamed instances replace the original slot, STATUS:CANCELLED overrides delete it, and overrides whose master never expanded (outside horizon, unsupported rule, partial export) emit standalone rather than vanishing. Instance uid keys stay unique (#i for plain instances, #r<recurrence-id> for overrides). RANGE=THISANDFUTURE remains out of scope (treated as a single-instance override). Note: previously imported calendars carry the old duplicates in localStorage — re-importing the .ics cleans them. Co-Authored-By: Claude Fable 5 <[email protected]>
1 parent 3358a41 commit 41f32d7

2 files changed

Lines changed: 142 additions & 33 deletions

File tree

src/calendar/ics.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,60 @@ describe("parseIcs / serializeIcs", () => {
133133
expect(a.startMs).toBe(Date.UTC(2026, 6, 21, 13));
134134
});
135135

136+
test("RECURRENCE-ID overrides replace their instance (Google-style)", () => {
137+
const text = [
138+
"BEGIN:VCALENDAR",
139+
"BEGIN:VEVENT",
140+
"UID:std@x",
141+
"DTSTART:20260706T090000Z",
142+
"DTEND:20260706T093000Z",
143+
"RRULE:FREQ=DAILY;COUNT=4",
144+
"SUMMARY:Standup",
145+
"END:VEVENT",
146+
"BEGIN:VEVENT", // moved + renamed 2nd instance
147+
"UID:std@x",
148+
"RECURRENCE-ID:20260707T090000Z",
149+
"DTSTART:20260707T140000Z",
150+
"DTEND:20260707T143000Z",
151+
"SUMMARY:Standup (moved)",
152+
"END:VEVENT",
153+
"BEGIN:VEVENT", // cancelled 3rd instance
154+
"UID:std@x",
155+
"RECURRENCE-ID:20260708T090000Z",
156+
"DTSTART:20260708T090000Z",
157+
"STATUS:CANCELLED",
158+
"SUMMARY:Standup",
159+
"END:VEVENT",
160+
"END:VCALENDAR",
161+
].join("\r\n");
162+
const cal = parseIcs(text, { fromMs: 0, toMs: Date.UTC(2027, 0, 1) });
163+
const starts = cal.events.map((e) => new Date(e.startMs).toISOString().slice(0, 13)).sort();
164+
// 4 instances − 1 cancelled = 3; the moved one sits at 14:00, no 09:00 dupe
165+
expect(cal.events.length).toBe(3);
166+
expect(starts).toEqual(["2026-07-06T09", "2026-07-07T14", "2026-07-09T09"]);
167+
const moved = cal.events.find((e) => e.summary === "Standup (moved)")!;
168+
expect(moved.endMs - moved.startMs).toBe(1800_000);
169+
// uid keys stay unique
170+
expect(new Set(cal.events.map((e) => e.uid)).size).toBe(3);
171+
});
172+
173+
test("orphan overrides (master missing) emit standalone", () => {
174+
const text = [
175+
"BEGIN:VCALENDAR",
176+
"BEGIN:VEVENT",
177+
"UID:orphan@x",
178+
"RECURRENCE-ID:20260701T100000Z",
179+
"DTSTART:20260701T110000Z",
180+
"DTEND:20260701T113000Z",
181+
"SUMMARY:Rescheduled thing",
182+
"END:VEVENT",
183+
"END:VCALENDAR",
184+
].join("\r\n");
185+
const cal = parseIcs(text, { fromMs: 0, toMs: Date.UTC(2027, 0, 1) });
186+
expect(cal.events.length).toBe(1);
187+
expect(cal.events[0]!.startMs).toBe(Date.UTC(2026, 6, 1, 11));
188+
});
189+
136190
test("long summaries fold at 74 chars and unfold back", () => {
137191
const long = "x".repeat(200);
138192
const text = serializeIcs([

src/calendar/ics.ts

Lines changed: 88 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,15 @@ export function expandRrule(
224224

225225
// ── document parse / serialize ────────────────────────────────────────────
226226

227+
type ProtoEvent = Partial<IcsEvent> & {
228+
rrule?: string;
229+
durMs?: number;
230+
exdates?: Set<number>;
231+
/** RECURRENCE-ID: this VEVENT overrides ONE instance of the same-UID series */
232+
recurrenceId?: number;
233+
cancelled?: boolean;
234+
};
235+
227236
export function parseIcs(
228237
text: string,
229238
opts: { fromMs?: number; toMs?: number } = {},
@@ -232,51 +241,28 @@ export function parseIcs(
232241
const toMs = opts.toMs ?? Date.now() + 2 * 365 * DAY_MS;
233242
const lines = unfoldLines(text);
234243
const cal: IcsCalendar = { events: [] };
235-
let ev: Partial<IcsEvent> & { rrule?: string; durMs?: number; exdates?: Set<number> } | null = null;
236-
let uidSeq = 0;
244+
245+
// pass 1 — collect every VEVENT as a proto record
246+
const protos: ProtoEvent[] = [];
247+
let ev: ProtoEvent | null = null;
237248
for (const line of lines) {
238249
const p = parseLine(line);
239250
if (!p) continue;
240251
if (p.name === "X-WR-CALNAME") cal.name = unescapeText(p.value).trim();
241252
else if (p.name === "BEGIN" && p.value.toUpperCase() === "VEVENT") ev = {};
242253
else if (p.name === "END" && p.value.toUpperCase() === "VEVENT") {
243-
if (ev && ev.startMs !== undefined) {
244-
const allDay = ev.allDay ?? false;
245-
const endMs =
246-
ev.endMs ??
247-
(ev.durMs != null ? ev.startMs + ev.durMs : ev.startMs + (allDay ? DAY_MS : HOUR_MS));
248-
const base: IcsEvent = {
249-
uid: ev.uid ?? `ics-${++uidSeq}`,
250-
summary: ev.summary ?? "(untitled)",
251-
location: ev.location,
252-
description: ev.description,
253-
startMs: ev.startMs,
254-
endMs,
255-
allDay,
256-
};
257-
if (ev.rrule) {
258-
const starts = expandRrule(ev.rrule, base.startMs, fromMs, toMs);
259-
if (starts === null) {
260-
cal.events.push({ ...base, recurring: true });
261-
} else {
262-
const dur = base.endMs - base.startMs;
263-
let i = 0;
264-
for (const s of starts) {
265-
if (ev.exdates?.has(s)) continue;
266-
cal.events.push({ ...base, uid: `${base.uid}#${i++}`, startMs: s, endMs: s + dur });
267-
}
268-
}
269-
} else if (!ev.exdates?.has(base.startMs)) {
270-
cal.events.push(base);
271-
}
272-
}
254+
if (ev && ev.startMs !== undefined) protos.push(ev);
273255
ev = null;
274256
} else if (ev) {
275257
if (p.name === "UID") ev.uid = p.value.trim();
276258
else if (p.name === "SUMMARY") ev.summary = unescapeText(p.value).trim();
277259
else if (p.name === "LOCATION") ev.location = unescapeText(p.value).trim();
278260
else if (p.name === "DESCRIPTION") ev.description = unescapeText(p.value).trim();
279-
else if (p.name === "DTSTART") {
261+
else if (p.name === "STATUS") ev.cancelled = p.value.trim().toUpperCase() === "CANCELLED";
262+
else if (p.name === "RECURRENCE-ID") {
263+
const d = parseIcsDate(p.value.trim(), p.params);
264+
if (d) ev.recurrenceId = d.ms;
265+
} else if (p.name === "DTSTART") {
280266
const d = parseIcsDate(p.value.trim(), p.params);
281267
if (d) {
282268
ev.startMs = d.ms;
@@ -296,6 +282,75 @@ export function parseIcs(
296282
}
297283
}
298284
}
285+
286+
// pass 2 — emit. Overrides (same UID + RECURRENCE-ID) REPLACE the matching
287+
// expanded instance of their series (Google exports every edited instance
288+
// of a recurring event this way); STATUS:CANCELLED overrides delete it;
289+
// overrides whose master never expanded (outside horizon, unsupported
290+
// rule, partial export) emit standalone rather than vanishing.
291+
let uidSeq = 0;
292+
const finish = (p: ProtoEvent, uid: string): IcsEvent => {
293+
const allDay = p.allDay ?? false;
294+
const endMs =
295+
p.endMs ??
296+
(p.durMs != null ? p.startMs! + p.durMs : p.startMs! + (allDay ? DAY_MS : HOUR_MS));
297+
return {
298+
uid,
299+
summary: p.summary ?? "(untitled)",
300+
location: p.location,
301+
description: p.description,
302+
startMs: p.startMs!,
303+
endMs,
304+
allDay,
305+
};
306+
};
307+
const ovByUid = new Map<string, Map<number, ProtoEvent>>();
308+
for (const p of protos) {
309+
if (p.recurrenceId === undefined || !p.uid) continue;
310+
let m = ovByUid.get(p.uid);
311+
if (!m) ovByUid.set(p.uid, (m = new Map()));
312+
m.set(p.recurrenceId, p);
313+
}
314+
const consumed = new Set<ProtoEvent>();
315+
for (const p of protos) {
316+
if (p.recurrenceId !== undefined) continue; // overrides emit via masters
317+
const uid = p.uid ?? `ics-${++uidSeq}`;
318+
const overrides = p.uid ? ovByUid.get(p.uid) : undefined;
319+
if (p.rrule) {
320+
const starts = expandRrule(p.rrule, p.startMs!, fromMs, toMs);
321+
if (starts === null) {
322+
if (!p.cancelled) cal.events.push({ ...finish(p, uid), recurring: true });
323+
continue;
324+
}
325+
const dur = (p.endMs ?? p.startMs! + (p.durMs ?? (p.allDay ? DAY_MS : HOUR_MS))) - p.startMs!;
326+
let i = 0;
327+
for (const s of starts) {
328+
if (p.exdates?.has(s)) continue;
329+
const ov = overrides?.get(s);
330+
if (ov) {
331+
consumed.add(ov);
332+
if (!ov.cancelled) cal.events.push(finish(ov, `${uid}#r${s}`));
333+
continue;
334+
}
335+
cal.events.push({ ...finish(p, `${uid}#${i++}`), startMs: s, endMs: s + dur });
336+
}
337+
continue;
338+
}
339+
if (p.cancelled || p.exdates?.has(p.startMs!)) continue;
340+
// even a non-recurring master can be overridden (degenerate but legal)
341+
const ov = overrides?.get(p.startMs!);
342+
if (ov) {
343+
consumed.add(ov);
344+
if (!ov.cancelled) cal.events.push(finish(ov, `${uid}#r${p.startMs}`));
345+
continue;
346+
}
347+
cal.events.push(finish(p, uid));
348+
}
349+
for (const p of protos) {
350+
if (p.recurrenceId === undefined || consumed.has(p) || p.cancelled) continue;
351+
const uid = p.uid ?? `ics-${++uidSeq}`;
352+
cal.events.push(finish(p, `${uid}#r${p.recurrenceId}`)); // orphan override
353+
}
299354
return cal;
300355
}
301356

0 commit comments

Comments
 (0)