This commit is contained in:
Linus Rath
2026-08-26 19:29:48 +02:00
6 changed files with 89 additions and 45 deletions
+8 -25
View File
@@ -11,6 +11,7 @@ import { RecurrenceEditor, buildRecurrenceSummary, isSimpleRecurrenceRule } from
import { parseDuration, getEventColor } from "./event-card";
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
import { displayNow, getEffectiveTimeZone } from "@/lib/timezone";
import { createRecurrenceRule } from "@/lib/recurrence-rule";
import { ParticipantInput, type ParticipantInputHandle } from "./participant-input";
import {
isOrganizer,
@@ -543,25 +544,7 @@ export function EventModal({
if (recurrence === "custom" && customRule) {
data.recurrenceRules = [customRule];
} else if (recurrence !== "none" && recurrence !== "custom") {
data.recurrenceRules = [{
"@type": "RecurrenceRule",
frequency: recurrence,
interval: 1,
rscale: "gregorian",
skip: "omit",
firstDayOfWeek: "mo",
byDay: null,
byMonthDay: null,
byMonth: null,
byYearDay: null,
byWeekNo: null,
byHour: null,
byMinute: null,
bySecond: null,
bySetPosition: null,
count: null,
until: null,
}];
data.recurrenceRules = [createRecurrenceRule(recurrence)];
} else if (event && event.recurrenceRules?.length) {
data.recurrenceRules = null;
if (event.recurrenceOverrides) data.recurrenceOverrides = null;
@@ -1383,9 +1366,9 @@ export function EventModal({
>
{t("events.delete")}
</Button>
<Button
variant="ghost"
size="sm"
<Button
variant="ghost"
size="sm"
onClick={() => setShowDeleteConfirm(false)}
className="w-full"
>
@@ -1417,10 +1400,10 @@ export function EventModal({
</Button>
)}
</div>
{!showDeleteConfirm && (
<div className="flex gap-2 w-full">
<Button
<Button
variant="outline"
onClick={isEdit ? () => setMode("view") : onClose}
className="w-full"
@@ -1428,7 +1411,7 @@ export function EventModal({
{t("form.cancel")}
</Button>
<Button
onClick={handleSave}
onClick={handleSave}
disabled={!title.trim() || isSaving}
className="w-full"
>
+3 -16
View File
@@ -6,6 +6,7 @@ import { addYears, format } from "date-fns";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import type { CalendarRecurrenceRule } from "@/lib/jmap/types";
import { createRecurrenceRule } from "@/lib/recurrence-rule";
type EditorFrequency = "daily" | "weekly" | "monthly" | "yearly";
type MonthlyMode = "day" | "nth";
@@ -210,25 +211,11 @@ export function RecurrenceEditor({ rule, eventStart, onSave, onCancel }: Recurre
};
const buildRule = (): CalendarRecurrenceRule => {
const built: CalendarRecurrenceRule = {
"@type": "RecurrenceRule",
frequency,
const built: CalendarRecurrenceRule = createRecurrenceRule(frequency, {
interval: Math.max(1, interval),
rscale: "gregorian",
skip: "omit",
firstDayOfWeek: "mo",
byDay: null,
byMonthDay: null,
byMonth: null,
byYearDay: null,
byWeekNo: null,
byHour: null,
byMinute: null,
bySecond: null,
bySetPosition: null,
count: endsMode === "after" ? Math.max(1, count) : null,
until: endsMode === "on" && untilDate ? `${untilDate}T23:59:59` : null,
};
});
if (frequency === "weekly") {
const days = weekDays.length ? weekDays : [startDay];
+40
View File
@@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest';
import { createRecurrenceRule } from '@/lib/recurrence-rule';
describe('createRecurrenceRule (#805 — no default SKIP=OMIT)', () => {
it('does not emit rscale or skip (which serialise to RSCALE=GREGORIAN;SKIP=OMIT)', () => {
const rule = createRecurrenceRule('yearly');
expect('skip' in rule).toBe(false);
expect('rscale' in rule).toBe(false);
expect(rule.skip).toBeUndefined();
expect(rule.rscale).toBeUndefined();
});
it('builds a clean Gregorian default rule', () => {
expect(createRecurrenceRule('daily')).toEqual({
'@type': 'RecurrenceRule',
frequency: 'daily',
interval: 1,
firstDayOfWeek: 'mo',
byDay: null,
byMonthDay: null,
byMonth: null,
byYearDay: null,
byWeekNo: null,
byHour: null,
byMinute: null,
bySecond: null,
bySetPosition: null,
count: null,
until: null,
});
});
it('applies overrides (interval / count / until) without reintroducing skip', () => {
const rule = createRecurrenceRule('yearly', { interval: 2, count: 100 });
expect(rule.interval).toBe(2);
expect(rule.count).toBe(100);
expect(rule.until).toBeNull();
expect('skip' in rule).toBe(false);
});
});
-2
View File
@@ -161,8 +161,6 @@ export function createDemoCalendarEvents(): CalendarEvent[] {
'@type': 'RecurrenceRule',
frequency: 'weekly',
interval: 1,
rscale: 'gregorian',
skip: 'omit',
firstDayOfWeek: 'mo',
byDay: [{ day: 'mo' }],
byMonthDay: null,
+5 -2
View File
@@ -673,8 +673,11 @@ export interface CalendarRecurrenceRule {
'@type': 'RecurrenceRule';
frequency: 'yearly' | 'monthly' | 'weekly' | 'daily' | 'hourly' | 'minutely' | 'secondly';
interval: number;
rscale: string;
skip: 'omit' | 'backward' | 'forward';
// Optional and normally omitted: these RFC 7529 (RSCALE/SKIP) fields only
// apply to non-Gregorian scales / invalid-date handling. Emitting a default
// SKIP=OMIT breaks some CalDAV clients (DAVx5) - see lib/recurrence-rule.ts (#805).
rscale?: string;
skip?: 'omit' | 'backward' | 'forward';
firstDayOfWeek: 'mo' | 'tu' | 'we' | 'th' | 'fr' | 'sa' | 'su';
byDay: CalendarNDay[] | null;
byMonthDay: number[] | null;
+33
View File
@@ -0,0 +1,33 @@
import type { CalendarRecurrenceRule } from '@/lib/jmap/types';
type Frequency = CalendarRecurrenceRule['frequency'];
/**
* A plain Gregorian recurrence rule with all by-parts unset. `rscale`/`skip`
* are intentionally omitted: they only matter for non-Gregorian scales /
* invalid-date handling, and defaulting them made Stalwart serialise
* `RSCALE=GREGORIAN;SKIP=OMIT` into the RRULE, which DAVx5 rejects (#805).
*/
export function createRecurrenceRule(
frequency: Frequency,
overrides: Partial<CalendarRecurrenceRule> = {},
): CalendarRecurrenceRule {
return {
'@type': 'RecurrenceRule',
frequency,
interval: 1,
firstDayOfWeek: 'mo',
byDay: null,
byMonthDay: null,
byMonth: null,
byYearDay: null,
byWeekNo: null,
byHour: null,
byMinute: null,
bySecond: null,
bySetPosition: null,
count: null,
until: null,
...overrides,
};
}