mirror of
https://github.com/kennethnym/aris.git
synced 2026-03-20 00:51:20 +00:00
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:
@@ -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
|
||||
@@ -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
|
||||
13
packages/aris-source-caldav/fixtures/weekly-recurring.ics
Normal file
13
packages/aris-source-caldav/fixtures/weekly-recurring.ics
Normal 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
|
||||
@@ -3,8 +3,13 @@
|
||||
*
|
||||
* Usage:
|
||||
* 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 { CalDavSource } from "../src/index.ts"
|
||||
@@ -51,6 +56,9 @@ for (const item of items) {
|
||||
console.log(` Status: ${item.data.status ?? "(none)"}`)
|
||||
console.log(` Urgency: ${item.signals?.urgency}`)
|
||||
console.log(` Relevance: ${item.signals?.timeRelevance}`)
|
||||
if (item.slots) {
|
||||
console.log(` Slots: ${Object.keys(item.slots).join(", ")}`)
|
||||
}
|
||||
if (item.data.attendees.length > 0) {
|
||||
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) {
|
||||
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}`)
|
||||
|
||||
@@ -208,7 +208,7 @@ describe("CalDavSource", () => {
|
||||
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[]> = {
|
||||
"/cal/work": [
|
||||
{
|
||||
@@ -218,21 +218,42 @@ describe("CalDavSource", () => {
|
||||
],
|
||||
}
|
||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
||||
// lookAheadDays=0 → range is Jan 15 only
|
||||
const source = createSource(client)
|
||||
|
||||
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)")
|
||||
|
||||
expect(base).toBeDefined()
|
||||
expect(base!.data.recurrenceId).toBeNull()
|
||||
// Jan 15 base occurrence
|
||||
expect(base.length).toBeGreaterThanOrEqual(1)
|
||||
|
||||
// Jan 22 exception replaces the base occurrence
|
||||
expect(exception).toBeDefined()
|
||||
expect(exception!.data.recurrenceId).not.toBeNull()
|
||||
expect(exception!.id).toContain("-")
|
||||
expect(exception!.data.startDate).toEqual(new Date("2026-01-22T10:00:00Z"))
|
||||
expect(exception!.data.endDate).toEqual(new Date("2026-01-22T10:30:00Z"))
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -184,7 +184,7 @@ export class CalDavSource implements FeedSource<CalDavFeedItem> {
|
||||
for (const obj of objects) {
|
||||
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) {
|
||||
allEvents.push(event)
|
||||
}
|
||||
|
||||
@@ -105,3 +105,94 @@ describe("parseICalEvents", () => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,21 +9,172 @@ import {
|
||||
type CalDavEventData,
|
||||
} from "./types.ts"
|
||||
|
||||
export interface ICalTimeRange {
|
||||
start: Date
|
||||
end: Date
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a raw iCalendar string and extracts all VEVENT components
|
||||
* Parses a raw iCalendar string and extracts VEVENT components
|
||||
* 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 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 comp = new ICAL.Component(jcal)
|
||||
const vevents = comp.getAllSubcomponents("vevent")
|
||||
|
||||
return vevents.map((vevent: InstanceType<typeof ICAL.Component>) =>
|
||||
parseVEvent(vevent, calendarName),
|
||||
)
|
||||
if (!timeRange) {
|
||||
return vevents.map((vevent: InstanceType<typeof ICAL.Component>) =>
|
||||
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
|
||||
const iter = masterEvent.iterator()
|
||||
let next: InstanceType<typeof ICAL.Time> | null = iter.next()
|
||||
|
||||
while (next) {
|
||||
// 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(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export { CalDavCalendarKey, type CalendarContext } from "./calendar-context.ts"
|
||||
export { CalDavSource, type CalDavSourceOptions } from "./caldav-source.ts"
|
||||
export { parseICalEvents } from "./ical-parser.ts"
|
||||
export { parseICalEvents, type ICalTimeRange } from "./ical-parser.ts"
|
||||
export {
|
||||
AttendeeRole,
|
||||
AttendeeStatus,
|
||||
|
||||
Reference in New Issue
Block a user