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 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.5.5] - 2026-04-03
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -146,7 +146,22 @@ function parseICS(ics) {
|
|||||||
|
|
||||||
const allDay = /^DTSTART;VALUE=DATE:/im.test(block);
|
const allDay = /^DTSTART;VALUE=DATE:/im.test(block);
|
||||||
const dtstart = dtStartRaw ? formatICSDate(dtStartRaw, allDay) : null;
|
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;
|
if (!uid || !dtstart) continue;
|
||||||
|
|
||||||
@@ -180,6 +195,34 @@ function formatICSDate(val, allDay) {
|
|||||||
return `${y}-${mo}-${d}T${h}:${mi}:${s}${z}`;
|
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
|
// Minimaler ICS-Builder
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
@@ -204,7 +247,11 @@ function buildICS(event) {
|
|||||||
|
|
||||||
if (event.all_day) {
|
if (event.all_day) {
|
||||||
const startDate = event.start_datetime.slice(0, 10).replace(/-/g, '');
|
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(`DTSTART;VALUE=DATE:${startDate}`);
|
||||||
lines.push(`DTEND;VALUE=DATE:${endDate}`);
|
lines.push(`DTEND;VALUE=DATE:${endDate}`);
|
||||||
} else {
|
} else {
|
||||||
@@ -265,11 +312,8 @@ async function sync() {
|
|||||||
}
|
}
|
||||||
const createdBy = owner.id;
|
const createdBy = owner.id;
|
||||||
|
|
||||||
// Alle Kalender synchen (außer Geburtstags-Kalender)
|
// Alle Kalender synchen (inklusive Geburtstags-Kalender)
|
||||||
const syncCalendars = calendars.filter(
|
const syncCalendars = calendars;
|
||||||
(c) => !c.displayName?.toLowerCase().includes('geburts') &&
|
|
||||||
!c.displayName?.toLowerCase().includes('birthday')
|
|
||||||
);
|
|
||||||
|
|
||||||
let totalObjects = 0;
|
let totalObjects = 0;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user