From 261dae59900fcaf6b8828ccde7953353817955da Mon Sep 17 00:00:00 2001 From: Ulas Date: Fri, 3 Apr 2026 12:31:29 +0200 Subject: [PATCH] fix(calendar): correct all-day DTEND handling, add DURATION support, include birthdays All-day events showed on the correct day plus the next day because ICS DTEND for VALUE=DATE is exclusive (RFC 5545) but was treated as inclusive. Multi-day events using DURATION instead of DTEND were missing entirely. Birthday calendars were explicitly filtered out during Apple Calendar sync. Closes #5 Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 8 +++++ server/services/apple-calendar.js | 58 +++++++++++++++++++++++++++---- 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c261b5..10be896 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.6] - 2026-04-03 + +### Fixed +- Fix all-day calendar events appearing on the correct day and the following day - ICS DTEND for DATE values is exclusive per RFC 5545, now correctly adjusted (fixes #5) +- Fix multi-day events not showing when using DURATION instead of DTEND - add ICS DURATION property support in CalDAV parser +- Fix birthdays from Apple Calendar not syncing - birthday calendars are no longer excluded from sync +- Fix outbound ICS builder using inclusive DTEND for all-day events - now correctly emits exclusive DTEND per RFC 5545 + ## [0.5.5] - 2026-04-03 ### Fixed diff --git a/server/services/apple-calendar.js b/server/services/apple-calendar.js index 0255104..d021d6c 100644 --- a/server/services/apple-calendar.js +++ b/server/services/apple-calendar.js @@ -146,7 +146,22 @@ function parseICS(ics) { const allDay = /^DTSTART;VALUE=DATE:/im.test(block); const dtstart = dtStartRaw ? formatICSDate(dtStartRaw, allDay) : null; - const dtend = dtEndRaw ? formatICSDate(dtEndRaw, allDay) : null; + let dtend = dtEndRaw ? formatICSDate(dtEndRaw, allDay) : null; + + // RFC 5545: DTEND for VALUE=DATE is exclusive — subtract one day + if (allDay && dtend) { + const d = new Date(dtend + 'T00:00:00'); + d.setDate(d.getDate() - 1); + dtend = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + } + + // DURATION fallback when DTEND is missing (e.g. DURATION:P3D) + if (!dtend && dtstart) { + const durMatch = /^DURATION(?:;[^:]*)?:(.*)$/im.exec(block); + if (durMatch) { + dtend = applyDuration(dtstart, durMatch[1].trim(), allDay); + } + } if (!uid || !dtstart) continue; @@ -180,6 +195,34 @@ function formatICSDate(val, allDay) { return `${y}-${mo}-${d}T${h}:${mi}:${s}${z}`; } +/** + * Berechnet ein Enddatum aus Start + ICS-DURATION (P-Format, Subset: PnW, PnD, PnDTnHnMnS). + * Für all-day Events gibt es YYYY-MM-DD zurück (inklusive, bereits um 1 Tag reduziert), + * für timed Events einen ISO-DateTime-String. + */ +function applyDuration(dtstart, dur, allDay) { + const m = /^P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/.exec(dur); + if (!m) return null; + + const weeks = parseInt(m[1] || '0', 10); + const days = parseInt(m[2] || '0', 10); + const hours = parseInt(m[3] || '0', 10); + const mins = parseInt(m[4] || '0', 10); + const secs = parseInt(m[5] || '0', 10); + + const base = new Date(dtstart.includes('T') ? dtstart : dtstart + 'T00:00:00'); + base.setDate(base.getDate() + weeks * 7 + days); + base.setHours(base.getHours() + hours, base.getMinutes() + mins, base.getSeconds() + secs); + + if (allDay) { + // Duration end is exclusive for DATE values — subtract one day for inclusive storage + base.setDate(base.getDate() - 1); + return `${base.getFullYear()}-${String(base.getMonth() + 1).padStart(2, '0')}-${String(base.getDate()).padStart(2, '0')}`; + } + + return base.toISOString().replace('.000Z', 'Z'); +} + // -------------------------------------------------------- // Minimaler ICS-Builder // -------------------------------------------------------- @@ -204,7 +247,11 @@ function buildICS(event) { if (event.all_day) { const startDate = event.start_datetime.slice(0, 10).replace(/-/g, ''); - const endDate = (event.end_datetime || event.start_datetime).slice(0, 10).replace(/-/g, ''); + // RFC 5545: DTEND for VALUE=DATE is exclusive — add one day + const endSrc = (event.end_datetime || event.start_datetime).slice(0, 10); + const endD = new Date(endSrc + 'T00:00:00'); + endD.setDate(endD.getDate() + 1); + const endDate = `${endD.getFullYear()}${String(endD.getMonth() + 1).padStart(2, '0')}${String(endD.getDate()).padStart(2, '0')}`; lines.push(`DTSTART;VALUE=DATE:${startDate}`); lines.push(`DTEND;VALUE=DATE:${endDate}`); } else { @@ -265,11 +312,8 @@ async function sync() { } const createdBy = owner.id; - // Alle Kalender synchen (außer Geburtstags-Kalender) - const syncCalendars = calendars.filter( - (c) => !c.displayName?.toLowerCase().includes('geburts') && - !c.displayName?.toLowerCase().includes('birthday') - ); + // Alle Kalender synchen (inklusive Geburtstags-Kalender) + const syncCalendars = calendars; let totalObjects = 0;