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