678c896862
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 <noreply@anthropic.com>
111 lines
3.9 KiB
JavaScript
111 lines
3.9 KiB
JavaScript
/**
|
|
* Modul: Wiederholungsregeln (Recurrence)
|
|
* Zweck: RRULE-Subset-Parser (FREQ=DAILY/WEEKLY/MONTHLY, BYDAY, INTERVAL, UNTIL)
|
|
* + Berechnung des nächsten Fälligkeitsdatums für wiederkehrende Aufgaben
|
|
* Abhängigkeiten: keine
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const DAY_MAP = { MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6, SU: 0 };
|
|
|
|
/**
|
|
* Parsed einen RRULE-String in ein Objekt.
|
|
* Beispiel: "FREQ=WEEKLY;BYDAY=MO,TH;INTERVAL=1"
|
|
* @param {string} rule
|
|
* @returns {{ freq, interval, byday, until }|null}
|
|
*/
|
|
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 raw.split(';')) {
|
|
const eq = segment.indexOf('=');
|
|
if (eq === -1) continue;
|
|
parts[segment.slice(0, eq).toUpperCase()] = segment.slice(eq + 1);
|
|
}
|
|
|
|
const freq = parts.FREQ ?? null;
|
|
const interval = parseInt(parts.INTERVAL ?? '1', 10) || 1;
|
|
const byday = (parts.BYDAY ?? '').split(',')
|
|
.map((d) => DAY_MAP[d.trim().toUpperCase()])
|
|
.filter((d) => d !== undefined);
|
|
const until = parts.UNTIL ? parseUntilDate(parts.UNTIL) : null;
|
|
|
|
if (!['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY'].includes(freq)) return null;
|
|
|
|
return { freq, interval, byday, until };
|
|
}
|
|
|
|
function parseUntilDate(str) {
|
|
// Akzeptiert YYYYMMDD oder YYYYMMDDTHHmmssZ
|
|
const clean = str.replace(/[TZ]/g, '');
|
|
const y = parseInt(clean.slice(0, 4), 10);
|
|
const m = parseInt(clean.slice(4, 6), 10) - 1;
|
|
const d = parseInt(clean.slice(6, 8), 10);
|
|
return new Date(Date.UTC(y, m, d));
|
|
}
|
|
|
|
/**
|
|
* Berechnet das nächste Fälligkeitsdatum nach dem gegebenen Basisdatum.
|
|
* @param {string} baseDateStr - ISO-Datums-String (YYYY-MM-DD)
|
|
* @param {string} rrule - RRULE-String
|
|
* @returns {string|null} - Nächstes Datum als YYYY-MM-DD oder null (Ende der Serie)
|
|
*/
|
|
function nextOccurrence(baseDateStr, rrule) {
|
|
const parsed = parseRRule(rrule);
|
|
if (!parsed || !baseDateStr) return null;
|
|
|
|
const base = new Date(baseDateStr + 'T00:00:00Z');
|
|
if (isNaN(base.getTime())) return null;
|
|
|
|
const { freq, interval, byday, until } = parsed;
|
|
const next = new Date(base);
|
|
|
|
if (freq === 'DAILY') {
|
|
next.setUTCDate(next.getUTCDate() + interval);
|
|
|
|
} else if (freq === 'WEEKLY') {
|
|
if (byday.length === 0) {
|
|
// Kein BYDAY → selber Wochentag, nächste Woche
|
|
next.setUTCDate(next.getUTCDate() + 7 * interval);
|
|
} else {
|
|
// Finde den nächsten passenden Wochentag (nach heute)
|
|
const currentDay = base.getUTCDay();
|
|
const sorted = [...byday].sort((a, b) => {
|
|
const da = (a - currentDay + 7) % 7 || 7;
|
|
const db = (b - currentDay + 7) % 7 || 7;
|
|
return da - db;
|
|
});
|
|
// Tage bis zum nächsten Vorkommen (mind. 1, damit nicht derselbe Tag)
|
|
let daysUntil = (sorted[0] - currentDay + 7) % 7;
|
|
if (daysUntil === 0) daysUntil = 7 * interval;
|
|
next.setUTCDate(next.getUTCDate() + daysUntil);
|
|
}
|
|
|
|
} else if (freq === 'MONTHLY') {
|
|
const targetDay = base.getUTCDate();
|
|
next.setUTCMonth(next.getUTCMonth() + interval);
|
|
// 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
|
|
if (until && next > until) return null;
|
|
|
|
return next.toISOString().slice(0, 10); // YYYY-MM-DD
|
|
}
|
|
|
|
module.exports = { parseRRule, nextOccurrence };
|