From 678c896862c9a3b4b18f3948822df623277f71ac Mon Sep 17 00:00:00 2001 From: Ulas Date: Fri, 3 Apr 2026 12:47:18 +0200 Subject: [PATCH] fix(calendar): expand recurring multi-day events and support YEARLY frequency Root causes: 1. parseRRule did not strip the "RRULE:" prefix stored by the ICS parser, causing all recurrence rules from CalDAV sync to silently fail parsing 2. YEARLY frequency (used by birthday events) was not supported 3. expandRecurringEvents filtered instances only by start date, missing multi-day events that start before the view window but span into it 4. All-day recurring instances got datetime end values instead of date-only Fixes #5 (follow-up from @tschig) Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 8 ++++++++ package.json | 2 +- server/routes/calendar.js | 26 ++++++++++++++++++++++---- server/services/recurrence.js | 15 +++++++++++++-- 4 files changed, 44 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 10be896..f34b4f3 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.7] - 2026-04-03 + +### Fixed +- Fix recurring calendar events not expanding - RRULE parser now strips the `RRULE:` prefix used by ICS/CalDAV, which previously caused all recurrence rules to be silently ignored +- Fix recurring multi-day events not appearing when their start date falls before the view window but the event spans into it +- Fix all-day recurring event instances getting datetime end values instead of date-only format +- Add YEARLY recurrence frequency support for birthday and anniversary events + ## [0.5.6] - 2026-04-03 ### Fixed diff --git a/package.json b/package.json index ede924e..5c23267 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "oikos", - "version": "0.5.5", + "version": "0.5.7", "description": "Selbstgehosteter Familienplaner — Kalender, Aufgaben, Einkauf, Essensplan, Budget und mehr. Privat, offen, ohne Abo.", "main": "server/index.js", "engines": { diff --git a/server/routes/calendar.js b/server/routes/calendar.js index 197d60b..ef03359 100644 --- a/server/routes/calendar.js +++ b/server/routes/calendar.js @@ -43,6 +43,9 @@ function expandRecurringEvents(events, from, to) { const startMs = new Date(event.start_datetime).getTime(); const endMs = event.end_datetime ? new Date(event.end_datetime).getTime() : null; const durationMs = endMs !== null ? endMs - startMs : null; + // Duration in days for all-day events (for date-only end calculation) + const isAllDay = !!event.all_day; + const durationDays = isAllDay && durationMs !== null ? Math.round(durationMs / 86400000) : 0; // Original-Zeit-Teil erhalten (z.B. 'T14:30:00' oder '' bei All-Day) const timeSuffix = event.start_datetime.slice(10); @@ -54,13 +57,28 @@ function expandRecurringEvents(events, from, to) { while (currentDate <= to && iterations < MAX_ITER) { iterations++; - if (currentDate >= from) { + // For multi-day events, check if the instance end reaches into [from, to] + let instanceEnd = currentDate; + if (isAllDay && durationDays > 0) { + const d = new Date(currentDate + 'T00:00:00'); + d.setDate(d.getDate() + durationDays); + instanceEnd = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + } + + if (currentDate >= from || instanceEnd >= from) { const newStart = currentDate + timeSuffix; let newEnd = event.end_datetime; if (durationMs !== null) { - newEnd = new Date(new Date(newStart).getTime() + durationMs) - .toISOString() - .replace('.000Z', 'Z'); + if (isAllDay) { + // Keep date-only format for all-day events + const d = new Date(currentDate + 'T00:00:00'); + d.setDate(d.getDate() + durationDays); + newEnd = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + } else { + newEnd = new Date(new Date(newStart).getTime() + durationMs) + .toISOString() + .replace('.000Z', 'Z'); + } } result.push({ diff --git a/server/services/recurrence.js b/server/services/recurrence.js index 038a914..a4ae8fb 100644 --- a/server/services/recurrence.js +++ b/server/services/recurrence.js @@ -17,8 +17,10 @@ const DAY_MAP = { MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6, SU: 0 }; */ function parseRRule(rule) { if (!rule) return null; + // Strip "RRULE:" prefix if present (ICS stores rules as "RRULE:FREQ=...") + const raw = rule.startsWith('RRULE:') ? rule.slice(6) : rule; const parts = {}; - for (const segment of rule.split(';')) { + for (const segment of raw.split(';')) { const eq = segment.indexOf('='); if (eq === -1) continue; parts[segment.slice(0, eq).toUpperCase()] = segment.slice(eq + 1); @@ -31,7 +33,7 @@ function parseRRule(rule) { .filter((d) => d !== undefined); const until = parts.UNTIL ? parseUntilDate(parts.UNTIL) : null; - if (!['DAILY', 'WEEKLY', 'MONTHLY'].includes(freq)) return null; + if (!['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY'].includes(freq)) return null; return { freq, interval, byday, until }; } @@ -88,6 +90,15 @@ function nextOccurrence(baseDateStr, rrule) { // Monatsüberlauf korrigieren (z.B. 31. März + 1 Monat → 30. April) const lastDay = new Date(Date.UTC(next.getUTCFullYear(), next.getUTCMonth() + 1, 0)).getUTCDate(); next.setUTCDate(Math.min(targetDay, lastDay)); + + } else if (freq === 'YEARLY') { + const targetMonth = base.getUTCMonth(); + const targetDay = base.getUTCDate(); + next.setUTCFullYear(next.getUTCFullYear() + interval); + // Feb 29 in non-leap year → Feb 28 + next.setUTCMonth(targetMonth); + const lastDay = new Date(Date.UTC(next.getUTCFullYear(), targetMonth + 1, 0)).getUTCDate(); + next.setUTCDate(Math.min(targetDay, lastDay)); } // UNTIL-Grenze prüfen