fix: JSCalendar 2.0 recurrenceRule single-object compatibility 116

This commit is contained in:
Linus Rath
2026-03-31 00:11:04 +02:00
parent aaa283357e
commit 1cce5c3c8a
6 changed files with 986 additions and 146 deletions
@@ -45,4 +45,43 @@ describe('calendar event normalization', () => {
showWithoutTime: true,
});
});
describe('recurrenceRule normalization (JSCalendar 2.0 singular→plural)', () => {
it('wraps a single recurrenceRule object in an array', () => {
const raw = {
...makeEvent({ showWithoutTime: false, duration: 'PT1H', start: '2026-03-16T09:00:00' }),
recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'weekly' },
} as Record<string, unknown>;
const result = normalizeCalendarEventLike(raw as Partial<CalendarEvent>);
expect(result.recurrenceRules).toEqual([{ '@type': 'RecurrenceRule', frequency: 'weekly' }]);
expect((result as Record<string, unknown>).recurrenceRule).toBeUndefined();
});
it('passes through recurrenceRule when already an array', () => {
const raw = {
...makeEvent({ showWithoutTime: false, duration: 'PT1H', start: '2026-03-16T09:00:00' }),
recurrenceRule: [{ '@type': 'RecurrenceRule', frequency: 'daily' }],
} as Record<string, unknown>;
const result = normalizeCalendarEventLike(raw as Partial<CalendarEvent>);
expect(result.recurrenceRules).toEqual([{ '@type': 'RecurrenceRule', frequency: 'daily' }]);
});
it('passes through null recurrenceRule as-is', () => {
const raw = {
...makeEvent({ showWithoutTime: false, duration: 'PT1H', start: '2026-03-16T09:00:00' }),
recurrenceRule: null,
} as Record<string, unknown>;
const result = normalizeCalendarEventLike(raw as Partial<CalendarEvent>);
expect(result.recurrenceRules).toBeNull();
});
it('wraps a single excludedRecurrenceRule object in an array', () => {
const raw = {
...makeEvent({ showWithoutTime: false, duration: 'PT1H', start: '2026-03-16T09:00:00' }),
excludedRecurrenceRule: { '@type': 'RecurrenceRule', frequency: 'daily' },
} as Record<string, unknown>;
const result = normalizeCalendarEventLike(raw as Partial<CalendarEvent>);
expect(result.excludedRecurrenceRules).toEqual([{ '@type': 'RecurrenceRule', frequency: 'daily' }]);
});
});
});
+379
View File
@@ -0,0 +1,379 @@
import { describe, it, expect } from 'vitest';
import { expandRecurringEvents } from '../recurrence-expansion';
import type { CalendarEvent } from '@/lib/jmap/types';
/** Helper: create a minimal CalendarEvent for testing recurrence */
function makeEvent(overrides: Partial<CalendarEvent> = {}): CalendarEvent {
return {
id: 'evt1',
uid: 'uid1',
calendarIds: { cal1: true },
start: '2025-01-06T09:00:00', // Monday
duration: 'PT1H',
title: 'Test Event',
showWithoutTime: false,
recurrenceRules: null,
recurrenceOverrides: null,
excludedRecurrenceRules: null,
...overrides,
} as CalendarEvent;
}
function expand(event: CalendarEvent, rangeStart: string, rangeEnd: string) {
return expandRecurringEvents([event], rangeStart, rangeEnd);
}
function starts(events: CalendarEvent[]) {
return events.map(e => e.start);
}
describe('expandRecurringEvents', () => {
it('passes through non-recurring events unchanged', () => {
const event = makeEvent();
const result = expand(event, '2025-01-01T00:00:00', '2025-02-01T00:00:00');
expect(result).toHaveLength(1);
expect(result[0].id).toBe('evt1');
});
// -----------------------------------------------------------------------
// Daily
// -----------------------------------------------------------------------
describe('daily frequency', () => {
it('expands daily events within range', () => {
const event = makeEvent({
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any],
});
const result = expand(event, '2025-01-06T00:00:00', '2025-01-09T00:00:00');
expect(starts(result)).toEqual([
'2025-01-06T09:00:00',
'2025-01-07T09:00:00',
'2025-01-08T09:00:00',
]);
});
it('respects interval', () => {
const event = makeEvent({
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily', interval: 2 } as any],
});
const result = expand(event, '2025-01-06T00:00:00', '2025-01-12T00:00:00');
expect(starts(result)).toEqual([
'2025-01-06T09:00:00',
'2025-01-08T09:00:00',
'2025-01-10T09:00:00',
]);
});
it('respects count', () => {
const event = makeEvent({
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily', count: 3 } as any],
});
const result = expand(event, '2025-01-06T00:00:00', '2025-12-31T00:00:00');
expect(result).toHaveLength(3);
});
it('respects until', () => {
const event = makeEvent({
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily', until: '2025-01-08T09:00:00' } as any],
});
const result = expand(event, '2025-01-06T00:00:00', '2025-12-31T00:00:00');
expect(result).toHaveLength(3);
});
});
// -----------------------------------------------------------------------
// Weekly
// -----------------------------------------------------------------------
describe('weekly frequency', () => {
it('expands weekly with implicit byDay (same weekday as start)', () => {
const event = makeEvent({
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' } as any],
});
// Jan 6 is Monday, so every Monday
const result = expand(event, '2025-01-06T00:00:00', '2025-01-28T00:00:00');
expect(starts(result)).toEqual([
'2025-01-06T09:00:00',
'2025-01-13T09:00:00',
'2025-01-20T09:00:00',
'2025-01-27T09:00:00',
]);
});
it('expands weekly with explicit byDay (MWF)', () => {
const event = makeEvent({
recurrenceRules: [{
'@type': 'RecurrenceRule',
frequency: 'weekly',
byDay: [{ day: 'mo' }, { day: 'we' }, { day: 'fr' }],
} as any],
});
const result = expand(event, '2025-01-06T00:00:00', '2025-01-13T00:00:00');
expect(starts(result)).toEqual([
'2025-01-06T09:00:00',
'2025-01-08T09:00:00',
'2025-01-10T09:00:00',
]);
});
it('expands weekly with interval=2', () => {
const event = makeEvent({
recurrenceRules: [{
'@type': 'RecurrenceRule',
frequency: 'weekly',
interval: 2,
byDay: [{ day: 'mo' }],
} as any],
});
const result = expand(event, '2025-01-06T00:00:00', '2025-02-03T00:00:00');
expect(starts(result)).toEqual([
'2025-01-06T09:00:00',
'2025-01-20T09:00:00',
]);
});
});
// -----------------------------------------------------------------------
// Monthly
// -----------------------------------------------------------------------
describe('monthly frequency', () => {
it('expands monthly with implicit byMonthDay', () => {
const event = makeEvent({
start: '2025-01-15T10:00:00',
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'monthly' } as any],
});
const result = expand(event, '2025-01-01T00:00:00', '2025-04-01T00:00:00');
expect(starts(result)).toEqual([
'2025-01-15T10:00:00',
'2025-02-15T10:00:00',
'2025-03-15T10:00:00',
]);
});
it('expands monthly with byMonthDay', () => {
const event = makeEvent({
start: '2025-01-01T08:00:00',
recurrenceRules: [{
'@type': 'RecurrenceRule',
frequency: 'monthly',
byMonthDay: [1, 15],
} as any],
});
const result = expand(event, '2025-01-01T00:00:00', '2025-02-28T00:00:00');
expect(starts(result)).toEqual([
'2025-01-01T08:00:00',
'2025-01-15T08:00:00',
'2025-02-01T08:00:00',
'2025-02-15T08:00:00',
]);
});
it('expands monthly with negative byMonthDay (-1 = last day)', () => {
const event = makeEvent({
start: '2025-01-31T08:00:00',
recurrenceRules: [{
'@type': 'RecurrenceRule',
frequency: 'monthly',
byMonthDay: [-1],
} as any],
});
const result = expand(event, '2025-01-01T00:00:00', '2025-04-01T00:00:00');
const days = result.map(e => e.start.substring(0, 10));
expect(days).toEqual(['2025-01-31', '2025-02-28', '2025-03-31']);
});
it('expands monthly with byDay + nthOfPeriod (2nd Tuesday)', () => {
const event = makeEvent({
start: '2025-01-14T09:00:00', // 2nd Tuesday
recurrenceRules: [{
'@type': 'RecurrenceRule',
frequency: 'monthly',
byDay: [{ day: 'tu', nthOfPeriod: 2 }],
} as any],
});
const result = expand(event, '2025-01-01T00:00:00', '2025-04-01T00:00:00');
const days = result.map(e => e.start.substring(0, 10));
expect(days).toEqual(['2025-01-14', '2025-02-11', '2025-03-11']);
});
it('expands monthly with byDay nthOfPeriod=-1 (last Friday)', () => {
const event = makeEvent({
start: '2025-01-31T09:00:00', // last Friday of Jan
recurrenceRules: [{
'@type': 'RecurrenceRule',
frequency: 'monthly',
byDay: [{ day: 'fr', nthOfPeriod: -1 }],
} as any],
});
const result = expand(event, '2025-01-01T00:00:00', '2025-04-01T00:00:00');
const days = result.map(e => e.start.substring(0, 10));
expect(days).toEqual(['2025-01-31', '2025-02-28', '2025-03-28']);
});
});
// -----------------------------------------------------------------------
// Yearly
// -----------------------------------------------------------------------
describe('yearly frequency', () => {
it('expands yearly on the same date', () => {
const event = makeEvent({
start: '2023-03-15T12:00:00',
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'yearly' } as any],
});
const result = expand(event, '2023-01-01T00:00:00', '2026-01-01T00:00:00');
expect(starts(result)).toEqual([
'2023-03-15T12:00:00',
'2024-03-15T12:00:00',
'2025-03-15T12:00:00',
]);
});
it('expands yearly with byMonth and byDay (last Friday of November = Thanksgiving-ish)', () => {
const event = makeEvent({
start: '2025-11-28T09:00:00', // last Friday of Nov 2025
recurrenceRules: [{
'@type': 'RecurrenceRule',
frequency: 'yearly',
byMonth: ['11'],
byDay: [{ day: 'fr', nthOfPeriod: -1 }],
} as any],
});
const result = expand(event, '2025-01-01T00:00:00', '2028-01-01T00:00:00');
const days = result.map(e => e.start.substring(0, 10));
// Last Friday of November: 2025-11-28, 2026-11-27, 2027-11-26
expect(days).toEqual(['2025-11-28', '2026-11-27', '2027-11-26']);
});
it('expands yearly with byMonth + byMonthDay', () => {
const event = makeEvent({
start: '2025-07-04T00:00:00',
showWithoutTime: true,
recurrenceRules: [{
'@type': 'RecurrenceRule',
frequency: 'yearly',
byMonth: ['7'],
byMonthDay: [4],
} as any],
});
const result = expand(event, '2025-01-01T00:00:00', '2028-01-01T00:00:00');
expect(result).toHaveLength(3);
});
});
// -----------------------------------------------------------------------
// bySetPosition
// -----------------------------------------------------------------------
describe('bySetPosition', () => {
it('selects first and last from monthly byDay expansion', () => {
const event = makeEvent({
start: '2025-01-06T10:00:00',
recurrenceRules: [{
'@type': 'RecurrenceRule',
frequency: 'monthly',
byDay: [{ day: 'mo' }, { day: 'tu' }, { day: 'we' }, { day: 'th' }, { day: 'fr' }],
bySetPosition: [1, -1], // first and last weekday of month
} as any],
});
const result = expand(event, '2025-01-01T00:00:00', '2025-03-01T00:00:00');
const days = result.map(e => e.start.substring(0, 10));
// Jan: first weekday = Jan 1 (Wed), last weekday = Jan 31 (Fri)
// Feb: first weekday = Feb 3 (Mon), last weekday = Feb 28 (Fri)
// But event starts Jan 6, so Jan 1 is before start → filtered out
expect(days).toContain('2025-01-31');
expect(days).toContain('2025-02-03');
expect(days).toContain('2025-02-28');
});
});
// -----------------------------------------------------------------------
// Recurrence overrides
// -----------------------------------------------------------------------
describe('recurrenceOverrides', () => {
it('applies overrides to matching occurrences', () => {
const event = makeEvent({
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any],
recurrenceOverrides: {
'2025-01-07T09:00:00': { title: 'Modified' },
},
});
const result = expand(event, '2025-01-06T00:00:00', '2025-01-09T00:00:00');
const modified = result.find(e => e.recurrenceId === '2025-01-07T09:00:00');
expect(modified?.title).toBe('Modified');
});
it('excludes occurrences marked as excluded', () => {
const event = makeEvent({
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any],
recurrenceOverrides: {
'2025-01-07T09:00:00': { excluded: true } as any,
},
});
const result = expand(event, '2025-01-06T00:00:00', '2025-01-09T00:00:00');
expect(result).toHaveLength(2);
expect(starts(result)).toEqual(['2025-01-06T09:00:00', '2025-01-08T09:00:00']);
});
it('adds RDATE-style overrides not generated by rules', () => {
const event = makeEvent({
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' } as any],
recurrenceOverrides: {
'2025-01-08T09:00:00': { title: 'Extra Wednesday' }, // Not a Monday
},
});
const result = expand(event, '2025-01-06T00:00:00', '2025-01-14T00:00:00');
expect(result.some(e => e.recurrenceId === '2025-01-08T09:00:00')).toBe(true);
});
});
// -----------------------------------------------------------------------
// All-day events
// -----------------------------------------------------------------------
describe('all-day events', () => {
it('expands all-day weekly events', () => {
const event = makeEvent({
start: '2025-01-06T00:00:00',
showWithoutTime: true,
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' } as any],
});
const result = expand(event, '2025-01-06T00:00:00', '2025-01-28T00:00:00');
expect(result).toHaveLength(4); // 4 Mondays: 6, 13, 20, 27
});
});
// -----------------------------------------------------------------------
// Edge cases
// -----------------------------------------------------------------------
describe('edge cases', () => {
it('does not exceed 500 occurrences', () => {
const event = makeEvent({
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any],
});
const result = expand(event, '2025-01-01T00:00:00', '2030-01-01T00:00:00');
expect(result.length).toBeLessThanOrEqual(500);
});
it('handles invalid start date gracefully', () => {
const event = makeEvent({
start: 'invalid',
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any],
});
const result = expand(event, '2025-01-01T00:00:00', '2025-02-01T00:00:00');
expect(result).toHaveLength(0);
});
it('generates synthetic IDs for occurrences', () => {
const event = makeEvent({
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any],
});
const result = expand(event, '2025-01-06T00:00:00', '2025-01-08T00:00:00');
expect(result[0].id).toBe('evt1:2025-01-06T09:00:00');
expect(result[1].id).toBe('evt1:2025-01-07T09:00:00');
});
it('preserves originalId pointing to master', () => {
const event = makeEvent({
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any],
});
const result = expand(event, '2025-01-06T00:00:00', '2025-01-08T00:00:00');
expect(result[0].originalId).toBe('evt1');
});
});
});
+46 -4
View File
@@ -74,15 +74,57 @@ export function isAllDayEventLike(event: Pick<Partial<CalendarEvent>, 'start' |
&& end.getMilliseconds() === 0;
}
/**
* Stalwart returns "recurrenceRule" (singular) instead of RFC 8984 "recurrenceRules" (plural).
* Normalize server responses to match the client's internal type.
*/
function normalizeStalwartPropertyNames<T extends Partial<CalendarEvent>>(event: T): T {
const raw = event as Record<string, unknown>;
let patched = false;
const updates: Partial<CalendarEvent> = {};
if ('recurrenceRule' in raw && !('recurrenceRules' in raw)) {
// JSCalendar 2.0 (jscalendarbis-15) defines recurrenceRule as a single object,
// but Stalwart may also return it as an array (for JMAP-created events).
// Normalize both forms to our internal array type.
const val = raw.recurrenceRule;
if (val != null && !Array.isArray(val) && typeof val === 'object') {
updates.recurrenceRules = [val] as CalendarEvent['recurrenceRules'];
} else {
updates.recurrenceRules = val as CalendarEvent['recurrenceRules'];
}
patched = true;
}
if ('excludedRecurrenceRule' in raw && !('excludedRecurrenceRules' in raw)) {
const val = raw.excludedRecurrenceRule;
if (val != null && !Array.isArray(val) && typeof val === 'object') {
updates.excludedRecurrenceRules = [val] as CalendarEvent['excludedRecurrenceRules'];
} else {
updates.excludedRecurrenceRules = val as CalendarEvent['excludedRecurrenceRules'];
}
patched = true;
}
if (!patched) return event;
const result = { ...event, ...updates } as T;
delete (result as Record<string, unknown>).recurrenceRule;
delete (result as Record<string, unknown>).excludedRecurrenceRule;
return result;
}
export function normalizeCalendarEventLike<T extends Partial<CalendarEvent>>(event: T): T {
if (!isAllDayEventLike(event)) {
return event;
// First normalize Stalwart's singular property names to RFC 8984 plural forms
const normalized = normalizeStalwartPropertyNames(event);
if (!isAllDayEventLike(normalized)) {
return normalized;
}
return {
...event,
...normalized,
showWithoutTime: true,
duration: normalizeAllDayDurationValue(event.duration),
duration: normalizeAllDayDurationValue(normalized.duration),
} as T;
}
+41 -5
View File
@@ -145,9 +145,9 @@ const CALENDAR_EVENT_PROPERTIES = [
'hideAttendees',
'recurrenceId',
'recurrenceIdTimeZone',
'recurrenceRules',
'recurrenceRule',
'recurrenceOverrides',
'excludedRecurrenceRules',
'excludedRecurrenceRule',
'useDefaultAlerts',
'alerts',
'locations',
@@ -183,14 +183,49 @@ const CALENDAR_TASK_PROPERTIES = [
'color',
'keywords',
'categories',
'recurrenceRules',
'recurrenceRule',
'recurrenceOverrides',
'excludedRecurrenceRules',
'excludedRecurrenceRule',
'useDefaultAlerts',
'alerts',
'relatedTo',
] as const;
/**
* Stalwart's calcard crate uses singular property names ("recurrenceRule")
* instead of the RFC 8984 plural forms ("recurrenceRules").
* JSCalendar 2.0 (jscalendarbis-15) defines recurrenceRule as a single object,
* not an array. This function converts our internal array form to a single
* object, cleans null values, and renames the properties.
*/
function cleanRecurrenceRules(event: Record<string, unknown>): void {
const keyMap: Record<string, string> = {
recurrenceRules: 'recurrenceRule',
excludedRecurrenceRules: 'excludedRecurrenceRule',
};
for (const [pluralKey, singularKey] of Object.entries(keyMap)) {
const rules = event[pluralKey];
if (rules === undefined) continue;
delete event[pluralKey];
if (!Array.isArray(rules)) {
// null means "remove recurrence" — pass through with the correct key
event[singularKey] = rules;
continue;
}
if (rules.length === 0) {
event[singularKey] = null;
continue;
}
// JSCalendar 2.0: recurrenceRule is a single object, use first rule
const rule = rules[0] as Record<string, unknown>;
const cleaned: Record<string, unknown> = {};
for (const [k, v] of Object.entries(rule)) {
if (v !== null) cleaned[k] = v;
}
event[singularKey] = cleaned;
}
}
function getCalendarEventDebugSnapshot(event: Partial<CalendarEvent> | null | undefined): Record<string, unknown> | null {
if (!event) {
return null;
@@ -3324,12 +3359,12 @@ export class JMAPClient implements IJMAPClient {
// Strip client-only shared fields before sending to JMAP
const { originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...cleanEvent } = event as CalendarEvent;
cleanRecurrenceRules(cleanEvent as unknown as Record<string, unknown>);
debug.group('CalendarEvent/create');
debug.log('CalendarEvent/create outgoing payload', {
accountId,
sendSchedulingMessages,
event: getCalendarEventDebugSnapshot(cleanEvent),
eventKeys: Object.keys(cleanEvent),
});
@@ -3480,6 +3515,7 @@ export class JMAPClient implements IJMAPClient {
// Strip client-only shared fields before sending to JMAP
const { originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...cleanUpdates } = updates as CalendarEvent;
cleanRecurrenceRules(cleanUpdates as unknown as Record<string, unknown>);
const setArgs: Record<string, unknown> = {
accountId,
+463 -136
View File
@@ -1,6 +1,10 @@
/**
* Client-side recurrence expansion for JSCalendar events.
*
* Implements JSCalendar 2.0 (draft-ietf-calext-jscalendarbis-15) §3.3.3.1
* recurrence rule interpretation algorithm with full byX filtering,
* implicit byX property addition, and bySetPosition support.
*
* Stalwart does not yet support mutations on the synthetic IDs produced by
* CalendarEvent/query?expandRecurrences=true, so we fetch raw events (with
* real, mutable IDs) and expand recurring series into individual occurrences
@@ -8,9 +12,10 @@
*/
import { parseISO, format, addDays, addWeeks, addMonths, addYears } from 'date-fns';
import type { CalendarEvent, CalendarRecurrenceRule } from '@/lib/jmap/types';
import type { CalendarEvent, CalendarRecurrenceRule, CalendarNDay } from '@/lib/jmap/types';
const DAY_INDEX: Record<string, number> = { su: 0, mo: 1, tu: 2, we: 3, th: 4, fr: 5, sa: 6 };
const INDEX_TO_DAY: string[] = ['su', 'mo', 'tu', 'we', 'th', 'fr', 'sa'];
/**
* Given a master event and a date range, return an array of "virtual" occurrence
@@ -32,12 +37,10 @@ export function expandRecurringEvents(
for (const event of events) {
if (!event.recurrenceRules?.length) {
// Non-recurring event — pass through unchanged
result.push(event);
continue;
}
// Expand each recurrence rule
const occurrences = expandEvent(event, start, end);
result.push(...occurrences);
}
@@ -58,7 +61,6 @@ function expandEvent(
const occurrences: CalendarEvent[] = [];
const seenDates = new Set<string>();
// Process each rule
for (const rule of rules) {
const dates = generateDates(eventStart, rule, rangeStart, rangeEnd);
for (const date of dates) {
@@ -76,12 +78,11 @@ function expandEvent(
const override = overrides[recurrenceId] as (Partial<CalendarEvent> & { excluded?: boolean }) | undefined;
if (override?.excluded) continue;
const occurrence = createOccurrence(master, date, recurrenceId, override);
occurrences.push(occurrence);
occurrences.push(createOccurrence(master, date, recurrenceId, override));
}
}
// Also add any overrides that add new dates (not generated by rules)
// Add overrides that define new dates not generated by rules (RDATE equivalent)
for (const [recurrenceId, rawOverride] of Object.entries(overrides)) {
const override = rawOverride as Partial<CalendarEvent> & { excluded?: boolean };
if (override.excluded) continue;
@@ -95,8 +96,7 @@ function expandEvent(
if (seenDates.has(dateKey)) continue;
seenDates.add(dateKey);
const occurrence = createOccurrence(master, overrideDate, recurrenceId, override);
occurrences.push(occurrence);
occurrences.push(createOccurrence(master, overrideDate, recurrenceId, override));
}
return occurrences;
@@ -121,117 +121,370 @@ function createOccurrence(
calendarIds: master.calendarIds,
start: (override?.start) || startStr,
recurrenceId,
// Remove recurrence rules from instances — only the master has them
recurrenceRules: master.recurrenceRules,
recurrenceOverrides: master.recurrenceOverrides,
excludedRecurrenceRules: master.excludedRecurrenceRules,
};
}
/**
* Generate occurrence dates for a single recurrence rule within a range.
* Capped at 500 occurrences to prevent runaway loops.
*/
// ---------------------------------------------------------------------------
// §3.3.3.1 — Implicit byX property addition
// ---------------------------------------------------------------------------
function addImplicitByX(
rule: CalendarRecurrenceRule,
eventStart: Date,
): CalendarRecurrenceRule {
const r = { ...rule };
const freq = r.frequency;
// bySecond
if (freq !== 'secondly' && !r.bySecond?.length) {
r.bySecond = [eventStart.getSeconds()];
}
// byMinute
if (freq !== 'secondly' && freq !== 'minutely' && !r.byMinute?.length) {
r.byMinute = [eventStart.getMinutes()];
}
// byHour
if (freq !== 'secondly' && freq !== 'minutely' && freq !== 'hourly' && !r.byHour?.length) {
r.byHour = [eventStart.getHours()];
}
// weekly: implicit byDay
if (freq === 'weekly' && !r.byDay?.length) {
r.byDay = [{ day: INDEX_TO_DAY[eventStart.getDay()] }];
}
// monthly: implicit byMonthDay
if (freq === 'monthly' && !r.byDay?.length && !r.byMonthDay?.length) {
r.byMonthDay = [eventStart.getDate()];
}
// yearly: implicit byMonth / byMonthDay / byDay
if (freq === 'yearly' && !r.byYearDay?.length) {
if (!r.byMonth?.length && !r.byWeekNo?.length && (r.byMonthDay?.length || !r.byDay?.length)) {
r.byMonth = [String(eventStart.getMonth() + 1)];
}
if (!r.byMonthDay?.length && !r.byWeekNo?.length && !r.byDay?.length) {
r.byMonthDay = [eventStart.getDate()];
}
if (r.byWeekNo?.length && !r.byMonthDay?.length && !r.byDay?.length) {
r.byDay = [{ day: INDEX_TO_DAY[eventStart.getDay()] }];
}
}
return r;
}
// ---------------------------------------------------------------------------
// §3.3.3.1 — Generate occurrence dates
// ---------------------------------------------------------------------------
function generateDates(
eventStart: Date,
rule: CalendarRecurrenceRule,
rawRule: CalendarRecurrenceRule,
rangeStart: Date,
rangeEnd: Date,
): Date[] {
const rule = addImplicitByX(rawRule, eventStart);
const dates: Date[] = [];
const interval = rule.interval || 1;
const limit = rule.count || 500;
const countLimit = rule.count || Infinity;
const until = rule.until ? parseISO(rule.until) : null;
let count = 0;
let totalCount = 0;
let current = new Date(eventStart);
// Safety: don't generate more than 500 occurrences
const maxIterations = 2000;
const maxOccurrences = 500;
let iterations = 0;
while (iterations++ < maxIterations) {
if (count >= limit) break;
if (totalCount >= countLimit || totalCount >= maxOccurrences) break;
if (until && current > until) break;
if (current >= rangeEnd) break;
// For frequencies that produce one candidate per iteration at a time,
// we can stop when we pass rangeEnd. But for frequencies that expand
// into multiple candidates per period, we need the candidate generation.
if (current >= rangeEnd && rule.frequency !== 'yearly' && rule.frequency !== 'monthly'
&& rule.frequency !== 'weekly') break;
// For weekly rules with byDay, expand into multiple days per week
if (rule.frequency === 'weekly' && rule.byDay?.length) {
const weekDates = expandByDay(current, rule.byDay, eventStart);
for (const wd of weekDates) {
if (until && wd > until) break;
if (count >= limit) break;
if (wd >= rangeEnd) break;
count++;
if (wd >= rangeStart) {
dates.push(wd);
}
}
} else if (rule.frequency === 'monthly' && rule.byDay?.length) {
const monthDates = expandByDayMonthly(current, rule.byDay);
for (const md of monthDates) {
if (until && md > until) break;
if (count >= limit) break;
if (md >= rangeEnd) break;
count++;
if (md >= rangeStart) {
dates.push(md);
}
}
} else if (rule.frequency === 'monthly' && rule.byMonthDay?.length) {
for (const day of rule.byMonthDay) {
const d = new Date(current);
d.setDate(day);
if (d.getMonth() !== current.getMonth()) continue; // skip invalid days (e.g., Feb 30)
if (until && d > until) break;
if (count >= limit) break;
count++;
if (d >= rangeStart && d < rangeEnd) {
dates.push(d);
}
}
} else if (rule.frequency === 'yearly' && rule.byMonth?.length) {
for (const monthStr of rule.byMonth) {
const month = parseInt(monthStr, 10) - 1; // JSCalendar months are 1-indexed
const d = new Date(current);
d.setMonth(month);
if (rule.byMonthDay?.length) {
for (const day of rule.byMonthDay) {
const dd = new Date(d);
dd.setDate(day);
if (dd.getMonth() !== month) continue;
if (until && dd > until) break;
if (count >= limit) break;
count++;
if (dd >= rangeStart && dd < rangeEnd) {
dates.push(dd);
}
}
} else {
if (until && d > until) break;
if (count >= limit) break;
count++;
if (d >= rangeStart && d < rangeEnd) {
dates.push(d);
}
}
}
} else {
count++;
if (current >= rangeStart && current < rangeEnd) {
dates.push(new Date(current));
// Generate candidates for the current period, then filter
const candidates = generateCandidatesForPeriod(current, rule, eventStart);
// Apply bySetPosition if present
const filtered = rule.bySetPosition?.length
? applyBySetPosition(candidates, rule.bySetPosition)
: candidates;
for (const d of filtered) {
if (until && d > until) break;
if (totalCount >= countLimit || totalCount >= maxOccurrences) break;
// Spec rule 4: eliminate dates before event start
if (d < eventStart) continue;
totalCount++;
if (d >= rangeStart && d < rangeEnd) {
dates.push(d);
}
if (d >= rangeEnd) break;
}
current = advanceDate(current, rule.frequency, interval);
if (totalCount >= countLimit || totalCount >= maxOccurrences) break;
current = advancePeriod(current, rule.frequency, interval, rule.firstDayOfWeek || 'mo');
if (current <= eventStart && iterations === 1) {
// Safety: ensure we don't go backward
current = advancePeriod(eventStart, rule.frequency, interval, rule.firstDayOfWeek || 'mo');
}
}
// Spec rule 1: the initial start date-time is ALWAYS the first occurrence
if (dates.length > 0 && dates[0].getTime() !== eventStart.getTime()) {
if (eventStart >= rangeStart && eventStart < rangeEnd) {
// Check it's not already in the list
if (!dates.some(d => d.getTime() === eventStart.getTime())) {
dates.unshift(eventStart);
}
}
}
return dates;
}
function advanceDate(
// ---------------------------------------------------------------------------
// Generate all candidates within one period, filtered by byX properties
// ---------------------------------------------------------------------------
function generateCandidatesForPeriod(
periodStart: Date,
rule: CalendarRecurrenceRule,
eventStart: Date,
): Date[] {
const freq = rule.frequency;
let candidates: Date[];
// Step 1: Generate base candidates based on frequency + expansion byX
switch (freq) {
case 'yearly':
candidates = expandYearly(periodStart, rule, eventStart);
break;
case 'monthly':
candidates = expandMonthly(periodStart, rule, eventStart);
break;
case 'weekly':
candidates = expandWeekly(periodStart, rule, eventStart);
break;
case 'daily':
case 'hourly':
case 'minutely':
case 'secondly':
candidates = [new Date(periodStart)];
break;
default:
candidates = [new Date(periodStart)];
}
// Step 2: Filter candidates by all applicable byX constraints
candidates = candidates.filter(d => matchesByX(d, rule));
candidates.sort((a, b) => a.getTime() - b.getTime());
return candidates;
}
// ---------------------------------------------------------------------------
// Yearly expansion
// ---------------------------------------------------------------------------
function expandYearly(
periodStart: Date,
rule: CalendarRecurrenceRule,
eventStart: Date,
): Date[] {
const year = periodStart.getFullYear();
const h = eventStart.getHours();
const m = eventStart.getMinutes();
const s = eventStart.getSeconds();
let dates: Date[] = [];
// Determine which months to iterate
const months = rule.byMonth?.length
? rule.byMonth.map(ms => parseInt(ms.replace('L', ''), 10) - 1)
: [eventStart.getMonth()];
if (rule.byWeekNo?.length) {
// Expand by ISO week numbers
for (const wn of rule.byWeekNo) {
const weekDates = datesInISOWeek(year, wn, rule.firstDayOfWeek || 'mo');
dates.push(...weekDates.map(d => { d.setHours(h, m, s, 0); return d; }));
}
} else if (rule.byYearDay?.length) {
// Expand by day-of-year
for (const yd of rule.byYearDay) {
const d = dayOfYear(year, yd);
if (d) { d.setHours(h, m, s, 0); dates.push(d); }
}
} else if (rule.byDay?.length && rule.byMonthDay?.length) {
// Both byDay and byMonthDay: expand byMonthDay in each month, then byDay filters later
for (const mo of months) {
for (const md of rule.byMonthDay) {
const d = resolveMonthDay(year, mo, md);
if (d) { d.setHours(h, m, s, 0); dates.push(d); }
}
}
} else if (rule.byDay?.length) {
// byDay with nthOfPeriod in yearly context = nth weekday of year or month
for (const mo of months) {
const expanded = expandByDayInMonth(year, mo, rule.byDay, h, m, s);
dates.push(...expanded);
}
} else if (rule.byMonthDay?.length) {
for (const mo of months) {
for (const md of rule.byMonthDay) {
const d = resolveMonthDay(year, mo, md);
if (d) { d.setHours(h, m, s, 0); dates.push(d); }
}
}
} else {
// Simple yearly: same date each year
for (const mo of months) {
const d = new Date(year, mo, eventStart.getDate(), h, m, s, 0);
if (d.getMonth() === mo) dates.push(d);
}
}
return dates;
}
// ---------------------------------------------------------------------------
// Monthly expansion
// ---------------------------------------------------------------------------
function expandMonthly(
periodStart: Date,
rule: CalendarRecurrenceRule,
eventStart: Date,
): Date[] {
const year = periodStart.getFullYear();
const month = periodStart.getMonth();
const h = eventStart.getHours();
const m = eventStart.getMinutes();
const s = eventStart.getSeconds();
const dates: Date[] = [];
if (rule.byDay?.length) {
const expanded = expandByDayInMonth(year, month, rule.byDay, h, m, s);
dates.push(...expanded);
} else if (rule.byMonthDay?.length) {
for (const md of rule.byMonthDay) {
const d = resolveMonthDay(year, month, md);
if (d) { d.setHours(h, m, s, 0); dates.push(d); }
}
} else {
// Implicit byMonthDay already added, but as fallback:
const d = new Date(year, month, eventStart.getDate(), h, m, s, 0);
if (d.getMonth() === month) dates.push(d);
}
return dates;
}
// ---------------------------------------------------------------------------
// Weekly expansion
// ---------------------------------------------------------------------------
function expandWeekly(
periodStart: Date,
rule: CalendarRecurrenceRule,
eventStart: Date,
): Date[] {
const dates: Date[] = [];
const baseDay = periodStart.getDay();
const byDay = rule.byDay?.length ? rule.byDay : [{ day: INDEX_TO_DAY[eventStart.getDay()] }];
for (const { day } of byDay) {
const targetDay = DAY_INDEX[day];
if (targetDay === undefined) continue;
let diff = targetDay - baseDay;
if (diff < 0) diff += 7;
const d = addDays(periodStart, diff);
d.setHours(eventStart.getHours(), eventStart.getMinutes(), eventStart.getSeconds(), 0);
dates.push(d);
}
return dates.sort((a, b) => a.getTime() - b.getTime());
}
// ---------------------------------------------------------------------------
// byX matching (Step 2 of §3.3.3.1)
// ---------------------------------------------------------------------------
function matchesByX(date: Date, rule: CalendarRecurrenceRule): boolean {
if (rule.byMonth?.length) {
const month = String(date.getMonth() + 1);
if (!rule.byMonth.some(m => m.replace('L', '') === month)) return false;
}
if (rule.byWeekNo?.length) {
const wn = getISOWeekNumber(date);
const weeksInYear = getISOWeeksInYear(date.getFullYear());
if (!rule.byWeekNo.some(w => (w > 0 ? w : weeksInYear + 1 + w) === wn)) return false;
}
if (rule.byYearDay?.length) {
const yd = getDayOfYear(date);
const daysInYear = isLeapYear(date.getFullYear()) ? 366 : 365;
if (!rule.byYearDay.some(d => (d > 0 ? d : daysInYear + 1 + d) === yd)) return false;
}
if (rule.byMonthDay?.length) {
const md = date.getDate();
const daysInMonth = new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
if (!rule.byMonthDay.some(d => (d > 0 ? d : daysInMonth + 1 + d) === md)) return false;
}
if (rule.byDay?.length) {
const dayName = INDEX_TO_DAY[date.getDay()];
const freq = rule.frequency;
if (!rule.byDay.some(nd => {
if (nd.day !== dayName) return false;
if (nd.nthOfPeriod == null) return true;
if (freq === 'monthly') {
return nd.nthOfPeriod === nthWeekdayInMonth(date, nd.nthOfPeriod);
}
if (freq === 'yearly') {
// When byMonth is present, nthOfPeriod scopes to the month (iCalendar semantics)
if (rule.byMonth?.length) {
return nd.nthOfPeriod === nthWeekdayInMonth(date, nd.nthOfPeriod);
}
return nd.nthOfPeriod === nthWeekdayInYear(date, nd.nthOfPeriod);
}
return true;
})) return false;
}
if (rule.byHour?.length) {
if (!rule.byHour.includes(date.getHours())) return false;
}
if (rule.byMinute?.length) {
if (!rule.byMinute.includes(date.getMinutes())) return false;
}
if (rule.bySecond?.length) {
if (!rule.bySecond.includes(date.getSeconds())) return false;
}
return true;
}
// ---------------------------------------------------------------------------
// bySetPosition (Step 3 of §3.3.3.1)
// ---------------------------------------------------------------------------
function applyBySetPosition(dates: Date[], positions: number[]): Date[] {
if (!dates.length) return dates;
const result: Date[] = [];
const len = dates.length;
for (const pos of positions) {
const idx = pos > 0 ? pos - 1 : len + pos;
if (idx >= 0 && idx < len) {
result.push(dates[idx]);
}
}
return result.sort((a, b) => a.getTime() - b.getTime());
}
// ---------------------------------------------------------------------------
// Advance to the start of the next period
// ---------------------------------------------------------------------------
function advancePeriod(
date: Date,
frequency: CalendarRecurrenceRule['frequency'],
interval: number,
_firstDayOfWeek: string,
): Date {
switch (frequency) {
case 'daily': return addDays(date, interval);
@@ -245,65 +498,43 @@ function advanceDate(
}
}
function expandByDay(
weekStart: Date,
byDay: { day: string; nthOfPeriod?: number }[],
eventStart: Date,
// ---------------------------------------------------------------------------
// Helper: expand byDay within a specific month (monthly or yearly context)
// ---------------------------------------------------------------------------
function expandByDayInMonth(
year: number,
month: number,
byDay: CalendarNDay[],
h: number, m: number, s: number,
): Date[] {
const dates: Date[] = [];
const baseDay = weekStart.getDay();
for (const { day } of byDay) {
const targetDay = DAY_INDEX[day];
if (targetDay === undefined) continue;
let diff = targetDay - baseDay;
if (diff < 0) diff += 7;
const d = addDays(weekStart, diff);
// Preserve time from the original event
d.setHours(eventStart.getHours(), eventStart.getMinutes(), eventStart.getSeconds());
dates.push(d);
}
return dates.sort((a, b) => a.getTime() - b.getTime());
}
function expandByDayMonthly(
monthStart: Date,
byDay: { day: string; nthOfPeriod?: number }[],
): Date[] {
const dates: Date[] = [];
const year = monthStart.getFullYear();
const month = monthStart.getMonth();
for (const { day, nthOfPeriod } of byDay) {
const targetDay = DAY_INDEX[day];
if (targetDay === undefined) continue;
if (nthOfPeriod) {
// Find the nth occurrence of this weekday in the month
if (nthOfPeriod != null && nthOfPeriod !== 0) {
const d = nthWeekdayOfMonth(year, month, targetDay, nthOfPeriod);
if (d) {
d.setHours(monthStart.getHours(), monthStart.getMinutes(), monthStart.getSeconds());
dates.push(d);
}
if (d) { d.setHours(h, m, s, 0); dates.push(d); }
} else {
// Every occurrence of this weekday in the month
let d = new Date(year, month, 1);
while (d.getDay() !== targetDay) {
d = addDays(d, 1);
}
while (d.getDay() !== targetDay) d = addDays(d, 1);
while (d.getMonth() === month) {
const occ = new Date(d);
occ.setHours(monthStart.getHours(), monthStart.getMinutes(), monthStart.getSeconds());
occ.setHours(h, m, s, 0);
dates.push(occ);
d = addDays(d, 7);
}
}
}
return dates.sort((a, b) => a.getTime() - b.getTime());
return dates;
}
// ---------------------------------------------------------------------------
// Helper: find nth weekday of month
// ---------------------------------------------------------------------------
function nthWeekdayOfMonth(
year: number,
month: number,
@@ -311,22 +542,118 @@ function nthWeekdayOfMonth(
nth: number,
): Date | null {
if (nth > 0) {
// Forward from start of month
let d = new Date(year, month, 1);
while (d.getDay() !== weekday) {
d = addDays(d, 1);
}
while (d.getDay() !== weekday) d = addDays(d, 1);
d = addDays(d, (nth - 1) * 7);
return d.getMonth() === month ? d : null;
} else {
// Backward from end of month
let d = new Date(year, month + 1, 0); // last day of month
while (d.getDay() !== weekday) {
d = addDays(d, -1);
}
if (nth < -1) {
d = addDays(d, (nth + 1) * 7);
}
while (d.getDay() !== weekday) d = addDays(d, -1);
if (nth < -1) d = addDays(d, (nth + 1) * 7);
return d.getMonth() === month ? d : null;
}
}
// ---------------------------------------------------------------------------
// Helper: check if date is the nth (or nth-last) weekday in its month
// ---------------------------------------------------------------------------
function nthWeekdayInMonth(date: Date, nth: number): number {
if (nth > 0) {
// Count from start: which occurrence of this weekday is it?
return Math.floor((date.getDate() - 1) / 7) + 1;
} else {
// Count from end
const daysInMonth = new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
return -(Math.floor((daysInMonth - date.getDate()) / 7) + 1);
}
}
// ---------------------------------------------------------------------------
// Helper: check if date is the nth weekday in its year
// ---------------------------------------------------------------------------
function nthWeekdayInYear(date: Date, nth: number): number {
const yd = getDayOfYear(date);
const weekday = date.getDay();
if (nth > 0) {
// Find first occurrence of this weekday in the year
const jan1 = new Date(date.getFullYear(), 0, 1);
let first = jan1;
while (first.getDay() !== weekday) first = addDays(first, 1);
const firstYd = getDayOfYear(first);
return Math.floor((yd - firstYd) / 7) + 1;
} else {
const daysInYear = isLeapYear(date.getFullYear()) ? 366 : 365;
const dec31 = new Date(date.getFullYear(), 11, 31);
let last = dec31;
while (last.getDay() !== weekday) last = addDays(last, -1);
const lastYd = getDayOfYear(last);
return -(Math.floor((lastYd - yd) / 7) + 1);
}
}
// ---------------------------------------------------------------------------
// Helper: resolve negative/positive byMonthDay to a real date
// ---------------------------------------------------------------------------
function resolveMonthDay(year: number, month: number, day: number): Date | null {
const daysInMonth = new Date(year, month + 1, 0).getDate();
const actualDay = day > 0 ? day : daysInMonth + 1 + day;
if (actualDay < 1 || actualDay > daysInMonth) return null;
return new Date(year, month, actualDay);
}
// ---------------------------------------------------------------------------
// Helper: day of year (1-indexed)
// ---------------------------------------------------------------------------
function getDayOfYear(date: Date): number {
const start = new Date(date.getFullYear(), 0, 0);
const diff = date.getTime() - start.getTime();
return Math.floor(diff / 86400000);
}
// ---------------------------------------------------------------------------
// Helper: day-of-year to Date
// ---------------------------------------------------------------------------
function dayOfYear(year: number, yd: number): Date | null {
const daysInYear = isLeapYear(year) ? 366 : 365;
const actual = yd > 0 ? yd : daysInYear + 1 + yd;
if (actual < 1 || actual > daysInYear) return null;
const d = new Date(year, 0, actual);
return d;
}
function isLeapYear(year: number): boolean {
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
}
// ---------------------------------------------------------------------------
// ISO week helpers
// ---------------------------------------------------------------------------
function getISOWeekNumber(date: Date): number {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
}
function getISOWeeksInYear(year: number): number {
const dec28 = new Date(Date.UTC(year, 11, 28));
return getISOWeekNumber(dec28);
}
function datesInISOWeek(year: number, weekNo: number, _firstDayOfWeek: string): Date[] {
const weeksInYear = getISOWeeksInYear(year);
const actual = weekNo > 0 ? weekNo : weeksInYear + 1 + weekNo;
if (actual < 1 || actual > weeksInYear) return [];
// Find Monday of ISO week 1
const jan4 = new Date(year, 0, 4);
const dayOfWeek = jan4.getDay() || 7; // Monday=1 ... Sunday=7
const week1Monday = addDays(jan4, 1 - dayOfWeek);
const targetMonday = addDays(week1Monday, (actual - 1) * 7);
const dates: Date[] = [];
for (let i = 0; i < 7; i++) {
dates.push(addDays(targetMonday, i));
}
return dates;
}
+18 -1
View File
@@ -321,7 +321,24 @@ export const useCalendarStore = create<CalendarStore>()(
}
await client.updateCalendarEvent(realId, cleanUpdates, sendSchedulingMessages, targetAccountId);
set((state) => ({
events: state.events.map(e => e.id === id ? { ...e, ...cleanUpdates } : e),
events: state.events.map(e => {
if (e.id !== id) return e;
const merged = { ...e, ...cleanUpdates };
// When start changes, shift utcStart/utcEnd by the same delta so the
// event renders at the new position immediately (optimistic update).
if (cleanUpdates.start && e.start && e.utcStart) {
const oldStart = new Date(e.start).getTime();
const newStart = new Date(cleanUpdates.start).getTime();
const delta = newStart - oldStart;
if (delta !== 0) {
merged.utcStart = new Date(new Date(e.utcStart).getTime() + delta).toISOString();
if (e.utcEnd) {
merged.utcEnd = new Date(new Date(e.utcEnd).getTime() + delta).toISOString();
}
}
}
return merged;
}),
}));
} catch (error) {
debug.error('Failed to update event:', error);