fix(caldav): expand recurring events in range

The iCal parser returned master VEVENT components with their
original start dates instead of expanding recurrences. Events
from months ago appeared in today's feed.

parseICalEvents now accepts an optional timeRange. When set,
recurring events are expanded via ical.js iterator and only
occurrences overlapping the range are returned. Exception
overrides (RECURRENCE-ID) are applied during expansion.

Co-authored-by: Ona <no-reply@ona.com>
This commit is contained in:
2026-03-03 01:30:49 +00:00
parent caf48484bf
commit a4baef521f
9 changed files with 359 additions and 14 deletions

View File

@@ -0,0 +1,12 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test//Test//EN
BEGIN:VEVENT
UID:daily-allday-001@test
DTSTART;VALUE=DATE:20260112
DTEND;VALUE=DATE:20260113
SUMMARY:Daily Reminder
RRULE:FREQ=DAILY;COUNT=7
STATUS:CONFIRMED
END:VEVENT
END:VCALENDAR

View File

@@ -0,0 +1,20 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test//Test//EN
BEGIN:VEVENT
UID:weekly-exc-001@test
DTSTART:20260101T140000Z
DTEND:20260101T150000Z
SUMMARY:Standup
RRULE:FREQ=WEEKLY;BYDAY=TH;COUNT=8
STATUS:CONFIRMED
END:VEVENT
BEGIN:VEVENT
UID:weekly-exc-001@test
RECURRENCE-ID:20260115T140000Z
DTSTART:20260115T160000Z
DTEND:20260115T170000Z
SUMMARY:Standup (rescheduled)
STATUS:CONFIRMED
END:VEVENT
END:VCALENDAR

View File

@@ -0,0 +1,13 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test//Test//EN
BEGIN:VEVENT
UID:weekly-001@test
DTSTART:20260101T100000Z
DTEND:20260101T110000Z
SUMMARY:Weekly Team Meeting
RRULE:FREQ=WEEKLY;BYDAY=TH;COUNT=10
LOCATION:Room B
STATUS:CONFIRMED
END:VEVENT
END:VCALENDAR

View File

@@ -3,8 +3,13 @@
* *
* Usage: * Usage:
* bun run test-live.ts * bun run test-live.ts
*
* Writes feed items (with slots) to scripts/.cache/feed-items.json for inspection.
*/ */
import { mkdirSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { Context } from "@aris/core" import { Context } from "@aris/core"
import { CalDavSource } from "../src/index.ts" import { CalDavSource } from "../src/index.ts"
@@ -51,6 +56,9 @@ for (const item of items) {
console.log(` Status: ${item.data.status ?? "(none)"}`) console.log(` Status: ${item.data.status ?? "(none)"}`)
console.log(` Urgency: ${item.signals?.urgency}`) console.log(` Urgency: ${item.signals?.urgency}`)
console.log(` Relevance: ${item.signals?.timeRelevance}`) console.log(` Relevance: ${item.signals?.timeRelevance}`)
if (item.slots) {
console.log(` Slots: ${Object.keys(item.slots).join(", ")}`)
}
if (item.data.attendees.length > 0) { if (item.data.attendees.length > 0) {
console.log(` Attendees: ${item.data.attendees.map((a) => a.name ?? a.email).join(", ")}`) console.log(` Attendees: ${item.data.attendees.map((a) => a.name ?? a.email).join(", ")}`)
} }
@@ -62,3 +70,11 @@ for (const item of items) {
if (items.length === 0) { if (items.length === 0) {
console.log("(no events found in the time window)") console.log("(no events found in the time window)")
} }
// Write feed items to .cache for slot testing
const cacheDir = join(import.meta.dir, ".cache")
mkdirSync(cacheDir, { recursive: true })
const outPath = join(cacheDir, "feed-items.json")
writeFileSync(outPath, JSON.stringify(items, null, 2))
console.log(`\nFeed items written to ${outPath}`)

View File

@@ -208,7 +208,7 @@ describe("CalDavSource", () => {
expect(items[0]!.data.calendarName).toBeNull() expect(items[0]!.data.calendarName).toBeNull()
}) })
test("handles recurring events with exceptions", async () => { test("expands recurring events within the time range", async () => {
const objects: Record<string, CalDavDAVObject[]> = { const objects: Record<string, CalDavDAVObject[]> = {
"/cal/work": [ "/cal/work": [
{ {
@@ -218,21 +218,42 @@ describe("CalDavSource", () => {
], ],
} }
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects) const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
// lookAheadDays=0 → range is Jan 15 only
const source = createSource(client) const source = createSource(client)
const items = await source.fetchItems(createContext(new Date("2026-01-15T08:00:00Z"))) const items = await source.fetchItems(createContext(new Date("2026-01-15T08:00:00Z")))
expect(items).toHaveLength(2) // Only the Jan 15 occurrence falls in the single-day window
expect(items).toHaveLength(1)
expect(items[0]!.data.title).toBe("Weekly Sync")
expect(items[0]!.data.startDate).toEqual(new Date("2026-01-15T09:00:00Z"))
})
const base = items.find((i) => i.data.title === "Weekly Sync") test("includes exception overrides when they fall in range", async () => {
const objects: Record<string, CalDavDAVObject[]> = {
"/cal/work": [
{
url: "/cal/work/recurring.ics",
data: loadFixture("recurring-event.ics"),
},
],
}
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
// lookAheadDays=8 → range covers Jan 15 through Jan 23, includes the Jan 22 exception
const source = createSource(client, 8)
const items = await source.fetchItems(createContext(new Date("2026-01-15T08:00:00Z")))
const base = items.filter((i) => i.data.title === "Weekly Sync")
const exception = items.find((i) => i.data.title === "Weekly Sync (moved)") const exception = items.find((i) => i.data.title === "Weekly Sync (moved)")
expect(base).toBeDefined() // Jan 15 base occurrence
expect(base!.data.recurrenceId).toBeNull() expect(base.length).toBeGreaterThanOrEqual(1)
// Jan 22 exception replaces the base occurrence
expect(exception).toBeDefined() expect(exception).toBeDefined()
expect(exception!.data.recurrenceId).not.toBeNull() expect(exception!.data.startDate).toEqual(new Date("2026-01-22T10:00:00Z"))
expect(exception!.id).toContain("-") expect(exception!.data.endDate).toEqual(new Date("2026-01-22T10:30:00Z"))
}) })
test("caches events within the same refresh cycle", async () => { test("caches events within the same refresh cycle", async () => {
@@ -512,3 +533,5 @@ describe("computeSignals", () => {
expect(computeSignals(event, now, "Asia/Tokyo").urgency).toBe(0.2) expect(computeSignals(event, now, "Asia/Tokyo").urgency).toBe(0.2)
}) })
}) })

View File

@@ -184,7 +184,7 @@ export class CalDavSource implements FeedSource<CalDavFeedItem> {
for (const obj of objects) { for (const obj of objects) {
if (typeof obj.data !== "string") continue if (typeof obj.data !== "string") continue
const events = parseICalEvents(obj.data, calendarName) const events = parseICalEvents(obj.data, calendarName, { start, end })
for (const event of events) { for (const event of events) {
allEvents.push(event) allEvents.push(event)
} }

View File

@@ -105,3 +105,94 @@ describe("parseICalEvents", () => {
expect(events[0]!.status).toBe("cancelled") expect(events[0]!.status).toBe("cancelled")
}) })
}) })
describe("parseICalEvents with timeRange (recurrence expansion)", () => {
test("expands weekly recurring event into occurrences within range", () => {
// weekly-recurring.ics: DTSTART 2026-01-01 (Thu), FREQ=WEEKLY;BYDAY=TH;COUNT=10
// Occurrences: Jan 1, 8, 15, 22, 29, Feb 5, 12, 19, 26, Mar 5
// Query window: Jan 14 Jan 23 → should get Jan 15 and Jan 22
const events = parseICalEvents(loadFixture("weekly-recurring.ics"), "Work", {
start: new Date("2026-01-14T00:00:00Z"),
end: new Date("2026-01-23T00:00:00Z"),
})
expect(events).toHaveLength(2)
expect(events[0]!.startDate).toEqual(new Date("2026-01-15T10:00:00Z"))
expect(events[0]!.endDate).toEqual(new Date("2026-01-15T11:00:00Z"))
expect(events[1]!.startDate).toEqual(new Date("2026-01-22T10:00:00Z"))
expect(events[1]!.endDate).toEqual(new Date("2026-01-22T11:00:00Z"))
// All occurrences share the same UID and metadata
for (const event of events) {
expect(event.uid).toBe("weekly-001@test")
expect(event.title).toBe("Weekly Team Meeting")
expect(event.location).toBe("Room B")
expect(event.calendarName).toBe("Work")
}
})
test("returns empty array when no occurrences fall in range", () => {
// Query window: Dec 2025 — before the first occurrence
const events = parseICalEvents(loadFixture("weekly-recurring.ics"), null, {
start: new Date("2025-12-01T00:00:00Z"),
end: new Date("2025-12-31T00:00:00Z"),
})
expect(events).toHaveLength(0)
})
test("applies exception overrides during expansion", () => {
// weekly-recurring-with-exception.ics:
// Master: DTSTART 2026-01-01 (Thu) 14:00, FREQ=WEEKLY;BYDAY=TH;COUNT=8
// Exception: RECURRENCE-ID 2026-01-15T14:00 → moved to 16:00-17:00, title changed
// Query window: Jan 14 Jan 16 → should get the exception occurrence for Jan 15
const events = parseICalEvents(loadFixture("weekly-recurring-with-exception.ics"), "Work", {
start: new Date("2026-01-14T00:00:00Z"),
end: new Date("2026-01-16T00:00:00Z"),
})
expect(events).toHaveLength(1)
expect(events[0]!.title).toBe("Standup (rescheduled)")
expect(events[0]!.startDate).toEqual(new Date("2026-01-15T16:00:00Z"))
expect(events[0]!.endDate).toEqual(new Date("2026-01-15T17:00:00Z"))
})
test("expands recurring all-day events", () => {
// daily-recurring-allday.ics: DTSTART 2026-01-12, FREQ=DAILY;COUNT=7
// Occurrences: Jan 12, 13, 14, 15, 16, 17, 18
// Query window: Jan 14 Jan 17 → should get Jan 14, 15, 16
const events = parseICalEvents(loadFixture("daily-recurring-allday.ics"), null, {
start: new Date("2026-01-14T00:00:00Z"),
end: new Date("2026-01-17T00:00:00Z"),
})
expect(events).toHaveLength(3)
for (const event of events) {
expect(event.isAllDay).toBe(true)
expect(event.title).toBe("Daily Reminder")
}
})
test("non-recurring events are filtered by range", () => {
// single-event.ics: 2026-01-15T14:00 15:00
// Query window that includes it
const included = parseICalEvents(loadFixture("single-event.ics"), null, {
start: new Date("2026-01-15T00:00:00Z"),
end: new Date("2026-01-16T00:00:00Z"),
})
expect(included).toHaveLength(1)
// Query window that excludes it
const excluded = parseICalEvents(loadFixture("single-event.ics"), null, {
start: new Date("2026-01-16T00:00:00Z"),
end: new Date("2026-01-17T00:00:00Z"),
})
expect(excluded).toHaveLength(0)
})
test("without timeRange, recurring events return raw VEVENTs (legacy)", () => {
// Legacy behavior: no expansion, just returns the VEVENT components as-is
const events = parseICalEvents(loadFixture("recurring-event.ics"), "Team")
expect(events).toHaveLength(2)
})
})

View File

@@ -9,23 +9,193 @@ import {
type CalDavEventData, type CalDavEventData,
} from "./types.ts" } from "./types.ts"
export interface ICalTimeRange {
start: Date
end: Date
}
/** /**
* Parses a raw iCalendar string and extracts all VEVENT components * Safety cap to prevent runaway iteration on pathological recurrence rules.
* Each iteration is pure date math (no I/O), so a high cap is fine.
* 10,000 covers a daily event with DTSTART ~27 years in the past.
*/
const MAX_RECURRENCE_ITERATIONS = 10_000
/**
* Parses a raw iCalendar string and extracts VEVENT components
* into CalDavEventData objects. * into CalDavEventData objects.
* *
* When a timeRange is provided, recurring events are expanded into
* individual occurrences within that range. Without a timeRange,
* each VEVENT component is returned as-is (legacy behavior).
*
* @param icsData - Raw iCalendar string from a CalDAV response * @param icsData - Raw iCalendar string from a CalDAV response
* @param calendarName - Display name of the calendar this event belongs to * @param calendarName - Display name of the calendar this event belongs to
* @param timeRange - When set, expand recurrences and filter to this window
*/ */
export function parseICalEvents(icsData: string, calendarName: string | null): CalDavEventData[] { export function parseICalEvents(
icsData: string,
calendarName: string | null,
timeRange?: ICalTimeRange,
): CalDavEventData[] {
const jcal = ICAL.parse(icsData) const jcal = ICAL.parse(icsData)
const comp = new ICAL.Component(jcal) const comp = new ICAL.Component(jcal)
const vevents = comp.getAllSubcomponents("vevent") const vevents = comp.getAllSubcomponents("vevent")
if (!timeRange) {
return vevents.map((vevent: InstanceType<typeof ICAL.Component>) => return vevents.map((vevent: InstanceType<typeof ICAL.Component>) =>
parseVEvent(vevent, calendarName), parseVEvent(vevent, calendarName),
) )
} }
// Group VEVENTs by UID: master + exceptions
const byUid = new Map<
string,
{
master: InstanceType<typeof ICAL.Component> | null
exceptions: InstanceType<typeof ICAL.Component>[]
}
>()
for (const vevent of vevents as InstanceType<typeof ICAL.Component>[]) {
const uid = vevent.getFirstPropertyValue("uid") as string | null
if (!uid) continue
const hasRecurrenceId = vevent.getFirstPropertyValue("recurrence-id") !== null
let group = byUid.get(uid)
if (!group) {
group = { master: null, exceptions: [] }
byUid.set(uid, group)
}
if (hasRecurrenceId) {
group.exceptions.push(vevent)
} else {
group.master = vevent
}
}
const results: CalDavEventData[] = []
const rangeStart = ICAL.Time.fromJSDate(timeRange.start, true)
const rangeEnd = ICAL.Time.fromJSDate(timeRange.end, true)
for (const group of byUid.values()) {
if (!group.master) {
// Orphan exceptions — parse them directly if they fall in range
for (const exc of group.exceptions) {
const parsed = parseVEvent(exc, calendarName)
if (overlapsRange(parsed, timeRange)) {
results.push(parsed)
}
}
continue
}
const masterEvent = new ICAL.Event(group.master)
// Register exceptions so getOccurrenceDetails resolves them
for (const exc of group.exceptions) {
masterEvent.relateException(exc)
}
if (!masterEvent.isRecurring()) {
const parsed = parseVEvent(group.master, calendarName)
if (overlapsRange(parsed, timeRange)) {
results.push(parsed)
}
// Also include standalone exceptions for non-recurring events
for (const exc of group.exceptions) {
const parsedExc = parseVEvent(exc, calendarName)
if (overlapsRange(parsedExc, timeRange)) {
results.push(parsedExc)
}
}
continue
}
// Expand recurring event occurrences within the time range.
// The iterator must start from DTSTART (not rangeStart) because
// ical.js needs to walk the recurrence rule grid from the original
// anchor. We cap iterations to avoid runaway expansion on
// pathological rules.
const iter = masterEvent.iterator()
let next: InstanceType<typeof ICAL.Time> | null = iter.next()
let iterations = 0
while (next) {
if (++iterations > MAX_RECURRENCE_ITERATIONS) {
console.warn(
`[aris.caldav] Recurrence expansion for "${masterEvent.uid}" hit iteration limit (${MAX_RECURRENCE_ITERATIONS}), stopping`,
)
break
}
// Stop once we're past the range end
if (next.compare(rangeEnd) >= 0) break
const details = masterEvent.getOccurrenceDetails(next)
const occEnd = details.endDate
// Skip occurrences that end before the range starts
if (occEnd.compare(rangeStart) <= 0) {
next = iter.next()
continue
}
const occEvent = details.item
const occComponent = occEvent.component
const parsed = parseVEventWithDates(
occComponent,
calendarName,
details.startDate.toJSDate(),
details.endDate.toJSDate(),
details.recurrenceId ? details.recurrenceId.toString() : null,
)
results.push(parsed)
next = iter.next()
}
}
return results
}
function overlapsRange(event: CalDavEventData, range: ICalTimeRange): boolean {
return event.startDate < range.end && event.endDate > range.start
}
/**
* Parse a VEVENT component, overriding start/end/recurrenceId with
* values from recurrence expansion.
*/
function parseVEventWithDates(
vevent: InstanceType<typeof ICAL.Component>,
calendarName: string | null,
startDate: Date,
endDate: Date,
recurrenceId: string | null,
): CalDavEventData {
const event = new ICAL.Event(vevent)
return {
uid: event.uid ?? "",
title: event.summary ?? "",
startDate,
endDate,
isAllDay: event.startDate?.isDate ?? false,
location: event.location ?? null,
description: event.description ?? null,
calendarName,
status: parseStatus(asStringOrNull(vevent.getFirstPropertyValue("status"))),
url: asStringOrNull(vevent.getFirstPropertyValue("url")),
organizer: parseOrganizer(asStringOrNull(event.organizer), vevent),
attendees: parseAttendees(Array.isArray(event.attendees) ? event.attendees : []),
alarms: parseAlarms(vevent),
recurrenceId,
}
}
function parseVEvent( function parseVEvent(
vevent: InstanceType<typeof ICAL.Component>, vevent: InstanceType<typeof ICAL.Component>,
calendarName: string | null, calendarName: string | null,

View File

@@ -1,6 +1,6 @@
export { CalDavCalendarKey, type CalendarContext } from "./calendar-context.ts" export { CalDavCalendarKey, type CalendarContext } from "./calendar-context.ts"
export { CalDavSource, type CalDavSourceOptions } from "./caldav-source.ts" export { CalDavSource, type CalDavSourceOptions } from "./caldav-source.ts"
export { parseICalEvents } from "./ical-parser.ts" export { parseICalEvents, type ICalTimeRange } from "./ical-parser.ts"
export { export {
AttendeeRole, AttendeeRole,
AttendeeStatus, AttendeeStatus,