diff --git a/.changeset/rare-pens-notice.md b/.changeset/rare-pens-notice.md new file mode 100644 index 00000000..6376635a --- /dev/null +++ b/.changeset/rare-pens-notice.md @@ -0,0 +1,5 @@ +--- +'@when/worker': patch +--- + +better support for caldav all day busy events when calculating availability diff --git a/packages/calendar/src/parse.test.ts b/packages/calendar/src/parse.test.ts index b8a78951..5538ecdf 100644 --- a/packages/calendar/src/parse.test.ts +++ b/packages/calendar/src/parse.test.ts @@ -168,3 +168,58 @@ END:VEVENT END:VCALENDAR`; expect(parseBusyEvents(cancelled)).toEqual([]); }); + +test('parses all-day event with explicit DTEND', () => { + const alldayWithEnd = `BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//test//EN +BEGIN:VEVENT +UID:allday-end@test +DTSTAMP:20260101T000000Z +DTSTART;VALUE=DATE:20260415 +DTEND;VALUE=DATE:20260416 +SUMMARY:All day with DTEND +END:VEVENT +END:VCALENDAR`; + const busy = parseBusyEvents(alldayWithEnd); + expect(busy).toHaveLength(1); + expect(busy[0].uid).toBe('allday-end@test'); + expect(busy[0].start.toString()).toBe('2026-04-15T00:00:00Z'); + expect(busy[0].end.toString()).toBe('2026-04-16T00:00:00Z'); +}); + +test('parses single-day all-day event without DTEND (RFC 5545 default 1 day duration)', () => { + const alldayNoEnd = `BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//test//EN +BEGIN:VEVENT +UID:allday-noend@test +DTSTAMP:20260101T000000Z +DTSTART;VALUE=DATE:20260415 +SUMMARY:All day single day +END:VEVENT +END:VCALENDAR`; + const busy = parseBusyEvents(alldayNoEnd); + expect(busy).toHaveLength(1); + expect(busy[0].uid).toBe('allday-noend@test'); + expect(busy[0].start.toString()).toBe('2026-04-15T00:00:00Z'); + expect(busy[0].end.toString()).toBe('2026-04-16T00:00:00Z'); +}); + +test('parses timed event without DTEND or DURATION (0 duration)', () => { + const timedNoEnd = `BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//test//EN +BEGIN:VEVENT +UID:timed-noend@test +DTSTAMP:20260101T000000Z +DTSTART:20260415T140000Z +SUMMARY:Zero duration +END:VEVENT +END:VCALENDAR`; + const busy = parseBusyEvents(timedNoEnd); + expect(busy).toHaveLength(1); + expect(busy[0].uid).toBe('timed-noend@test'); + expect(busy[0].start.toString()).toBe('2026-04-15T14:00:00Z'); + expect(busy[0].end.toString()).toBe('2026-04-15T14:00:00Z'); +}); diff --git a/packages/calendar/src/parse.ts b/packages/calendar/src/parse.ts index b1ab7dce..e224dc36 100644 --- a/packages/calendar/src/parse.ts +++ b/packages/calendar/src/parse.ts @@ -30,7 +30,7 @@ function toBusy(event: IcsEvent): BusyEvent | null { try { const startMs = event.start.date.getTime(); - const endMs = getEventEnd(event).getTime(); + const endMs = eventEnd(event).getTime(); return { uid: event.uid, start: Temporal.Instant.fromEpochMilliseconds(startMs), @@ -48,3 +48,20 @@ function toBusy(event: IcsEvent): BusyEvent | null { return null; } } + +function eventEnd(event: IcsEvent): Date { + if (event.end?.date) { + return event.end.date; + } + if (event.duration) { + return getEventEnd(event); + } + if (event.start.type === 'DATE') { + // All-day event without DTEND/DURATION lasts 1 full day per RFC 5545 §3.6.1 + const d = new Date(event.start.date.getTime()); + d.setUTCDate(d.getUTCDate() + 1); + return d; + } + // DATE-TIME event without DTEND/DURATION has 0 duration per RFC 5545 §3.6.1 + return event.start.date; +}