fix: save all-day events with the right length, a time zone and explicit nulls for cleared fields (#805)

This commit is contained in:
Linus Rath
2026-08-29 17:45:43 +02:00
parent feb4affea6
commit 58a6f45c15
6 changed files with 140 additions and 33 deletions
+3 -3
View File
@@ -7,17 +7,17 @@ RN has a solid read path (month/week/agenda, shared-calendar namespacing, per-vi
### Recurrence ### Recurrence
- [ ] **Recurrence scope dialog is a stub: "This event" / "This and following" behave as "All events"** — `P1` — `missing` - [x] **Recurrence scope dialog is a stub: "This event" / "This and following" behave as "All events"** — `P1` — `missing` — fixed in 161baf1
- What WEB does: `handleScopeSelect` implements all three scopes: "this" writes a `recurrenceOverrides/<recurrenceId>` patch (or the synthetic id on Stalwart >= 0.16.20), "this_and_future" truncates the master with `until` and creates a new series, "all" patches the master; delete does the mirror (exclude / truncate / destroy) and refetches the range (ref `components/calendar/calendar-app.tsx:842-998`, `lib/recurrence-overrides.ts:21-32`, changelog 1.9.0 "Edit a single recurring occurrence via a one-shot override patch"). - What WEB does: `handleScopeSelect` implements all three scopes: "this" writes a `recurrenceOverrides/<recurrenceId>` patch (or the synthetic id on Stalwart >= 0.16.20), "this_and_future" truncates the master with `until` and creates a new series, "all" patches the master; delete does the mirror (exclude / truncate / destroy) and refetches the range (ref `components/calendar/calendar-app.tsx:842-998`, `lib/recurrence-overrides.ts:21-32`, changelog 1.9.0 "Edit a single recurring occurrence via a one-shot override patch").
- What RN does: `handleScopeSelect` ignores the chosen scope with a TODO ("treat all scopes as 'all'") and either opens the edit modal on the occurrence or deletes `action.event.id`, which the store resolves to the master via `originalId` (ref `src/screens/CalendarScreen.tsx:347-363`, `src/stores/calendar-store.ts:306-329`). Choosing "This event" on delete destroys the entire series. - What RN does: `handleScopeSelect` ignores the chosen scope with a TODO ("treat all scopes as 'all'") and either opens the edit modal on the occurrence or deletes `action.event.id`, which the store resolves to the master via `originalId` (ref `src/screens/CalendarScreen.tsx:347-363`, `src/stores/calendar-store.ts:306-329`). Choosing "This event" on delete destroys the entire series.
- Fix hint: port WEB's `handleScopeSelect`/`truncateRecurrenceAtEvent`/`findMasterEvent` into CalendarScreen; add `buildRecurrenceOverridePatch` (WEB `lib/recurrence-overrides.ts`) and `RECURRENCE_OVERRIDE_IMMUTABLE_KEYS`; for "this" write `{ [`recurrenceOverrides/${recurrenceId}`]: override }` on `originalId`; for delete-this write `{ excluded: true }`; refresh after. - Fix hint: port WEB's `handleScopeSelect`/`truncateRecurrenceAtEvent`/`findMasterEvent` into CalendarScreen; add `buildRecurrenceOverridePatch` (WEB `lib/recurrence-overrides.ts`) and `RECURRENCE_OVERRIDE_IMMUTABLE_KEYS`; for "this" write `{ [`recurrenceOverrides/${recurrenceId}`]: override }` on `originalId`; for delete-this write `{ excluded: true }`; refresh after.
- [ ] **Editing an expanded occurrence rewrites the master's `start` to the occurrence date (series shifts / earlier occurrences vanish)** — `P1` — `bug` - [x] **Editing an expanded occurrence rewrites the master's `start` to the occurrence date (series shifts / earlier occurrences vanish)** — `P1` — `bug` — fixed in 161baf1
- What WEB does: for scope "all" the updates go to the master but `start` from an occurrence is never applied to the master unless the user changed it; scope "this" goes into an override (ref `components/calendar/calendar-app.tsx:930-943`). - What WEB does: for scope "all" the updates go to the master but `start` from an occurrence is never applied to the master unless the user changed it; scope "this" goes into an override (ref `components/calendar/calendar-app.tsx:930-943`).
- What RN does: `EventModal` seeds `start` from the occurrence (`getEventStartDate(event)`, `src/components/calendar/EventModal.tsx:197`) and `handleSave` always sends `start` + `duration` (`EventModal.tsx:262-281`); `updateEvent` resolves the occurrence to `originalId` and PATCHes the master (`src/stores/calendar-store.ts:306-318`). Any occurrence edit moves the whole series to that occurrence's date. - What RN does: `EventModal` seeds `start` from the occurrence (`getEventStartDate(event)`, `src/components/calendar/EventModal.tsx:197`) and `handleSave` always sends `start` + `duration` (`EventModal.tsx:262-281`); `updateEvent` resolves the occurrence to `originalId` and PATCHes the master (`src/stores/calendar-store.ts:306-318`). Any occurrence edit moves the whole series to that occurrence's date.
- Fix hint: part of the scope work above; when scope is "all", drop `start` from the patch unless the user actually changed the date/time relative to the occurrence, or compute the delta and apply it to the master's `start`. - Fix hint: part of the scope work above; when scope is "all", drop `start` from the patch unless the user actually changed the date/time relative to the occurrence, or compute the delta and apply it to the master's `start`.
- [ ] **Store's optimistic merge after an occurrence edit only updates one occurrence** — `P2` — `bug` - [x] **Store's optimistic merge after an occurrence edit only updates one occurrence** — `P2` — `bug` — fixed in 161baf1
- What WEB does: after any occurrence or master mutation with expanded occurrences in view it refetches the visible range (`stores/calendar-store.ts:186-206, 749-751`). - What WEB does: after any occurrence or master mutation with expanded occurrences in view it refetches the visible range (`stores/calendar-store.ts:186-206, 749-751`).
- What RN does: `updateEvent` maps only `e.id === id` (`src/stores/calendar-store.ts:315-317`), so sibling occurrences keep the old title/time until a pull-to-refresh; `deleteEvent` removes only the tapped occurrence from state while the server destroyed the master (`:320-329`). - What RN does: `updateEvent` maps only `e.id === id` (`src/stores/calendar-store.ts:315-317`), so sibling occurrences keep the old title/time until a pull-to-refresh; `deleteEvent` removes only the tapped occurrence from state while the server destroyed the master (`:320-329`).
- Fix hint: after mutating a `recurrenceId`/`recurrenceRules` event call `get().refresh()` (RN already has it) like WEB's `refetchAfterOccurrenceMutation`. - Fix hint: after mutating a `recurrenceId`/`recurrenceRules` event call `get().refresh()` (RN already has it) like WEB's `refetchAfterOccurrenceMutation`.
+15 -12
View File
@@ -452,30 +452,33 @@ export interface CalendarEvent {
description?: string; description?: string;
start: string; start: string;
duration?: string; duration?: string;
timeZone?: string; // `null` on write clears a field the server holds (JSON drops `undefined`,
// so an omitted key keeps the old value). All-day events carry a null zone.
timeZone?: string | null;
showWithoutTime?: boolean; showWithoutTime?: boolean;
utcStart?: string; utcStart?: string;
utcEnd?: string; utcEnd?: string;
status?: string; status?: string;
freeBusyStatus?: string; freeBusyStatus?: string;
participants?: Record<string, Participant>; participants?: Record<string, Participant> | null;
// Scheduling (iTIP/iMIP). `replyTo` tells the server where RSVP replies go; // Scheduling (iTIP/iMIP). Stalwart derives the iCalendar ORGANIZER solely
// Stalwart additionally exposes the organizer as `organizerCalendarAddress`. // from `organizerCalendarAddress` (a `mailto:` URI); RFC 8984's `replyTo` is
replyTo?: Record<string, string>; // retired in jscalendarbis and only kept here so old values round-trip.
organizerCalendarAddress?: string; replyTo?: Record<string, string> | null;
organizerCalendarAddress?: string | null;
sequence?: number; sequence?: number;
recurrenceRules?: RecurrenceRule[]; recurrenceRules?: RecurrenceRule[] | null;
recurrenceOverrides?: Record<string, Partial<CalendarEvent>>; recurrenceOverrides?: Record<string, Partial<CalendarEvent>> | null;
excludedRecurrenceRules?: RecurrenceRule[]; excludedRecurrenceRules?: RecurrenceRule[] | null;
recurrenceId?: string; recurrenceId?: string;
originalId?: string; originalId?: string;
// Client-only: the event's server-side calendarIds (raw, unprefixed), kept // Client-only: the event's server-side calendarIds (raw, unprefixed), kept
// when `calendarIds` is remapped to namespaced store ids for shared events. // when `calendarIds` is remapped to namespaced store ids for shared events.
originalCalendarIds?: Record<string, boolean>; originalCalendarIds?: Record<string, boolean>;
useDefaultAlerts?: boolean; useDefaultAlerts?: boolean;
alerts?: Record<string, Alert>; alerts?: Record<string, Alert> | null;
locations?: Record<string, EventLocation>; locations?: Record<string, EventLocation> | null;
virtualLocations?: Record<string, VirtualLocation>; virtualLocations?: Record<string, VirtualLocation> | null;
links?: Record<string, { href: string; rel?: string }>; links?: Record<string, { href: string; rel?: string }>;
created?: string; created?: string;
updated?: string; updated?: string;
+50 -16
View File
@@ -39,10 +39,12 @@ import { useColors } from '../../theme/colors';
import { import {
buildAllDayDuration, buildAllDayDuration,
getCalendarColor, getCalendarColor,
getEventDisplayEndDate,
getEventEndDate, getEventEndDate,
getEventStartDate, getEventStartDate,
getPrimaryCalendarId, getPrimaryCalendarId,
} from '../../lib/calendar-utils'; } from '../../lib/calendar-utils';
import { getEffectiveTimeZone } from '../../lib/calendar-timezone';
import { import {
alertsToReminders, alertsToReminders,
remindersToAlerts, remindersToAlerts,
@@ -195,7 +197,10 @@ export function EventModal({
setDescription(event.description || ''); setDescription(event.description || '');
setAllDay(!!event.showWithoutTime); setAllDay(!!event.showWithoutTime);
setStart(getEventStartDate(event)); setStart(getEventStartDate(event));
setEnd(getEventEndDate(event)); // All-day events store an exclusive end (start + P1D = next day 00:00);
// the editor works with the inclusive last day, otherwise every re-save
// grows the event by one day.
setEnd(event.showWithoutTime ? getEventDisplayEndDate(event) : getEventEndDate(event));
setCalendarId(getPrimaryCalendarId(event) || calendars[0]?.id || ''); setCalendarId(getPrimaryCalendarId(event) || calendars[0]?.id || '');
setParticipants(event.participants || {}); setParticipants(event.participants || {});
const detected = detectRecurrence(event); const detected = detectRecurrence(event);
@@ -230,11 +235,13 @@ export function EventModal({
React.useEffect(() => { React.useEffect(() => {
if (allDay) { if (allDay) {
// `end` is the inclusive last day for all-day events, so a one-day
// event has end === start (not the next day).
const s = new Date(start); const s = new Date(start);
s.setHours(0, 0, 0, 0); s.setHours(0, 0, 0, 0);
const e = new Date(end); const e = new Date(end);
e.setHours(0, 0, 0, 0); e.setHours(0, 0, 0, 0);
if (e <= s) e.setDate(s.getDate() + 1); if (e < s) e.setTime(s.getTime());
setStart(s); setStart(s);
setEnd(e); setEnd(e);
} }
@@ -244,11 +251,13 @@ export function EventModal({
const updateStart = (next: Date) => { const updateStart = (next: Date) => {
const diff = end.getTime() - start.getTime(); const diff = end.getTime() - start.getTime();
setStart(next); setStart(next);
setEnd(new Date(next.getTime() + Math.max(diff, 15 * 60_000))); setEnd(new Date(next.getTime() + (allDay ? Math.max(diff, 0) : Math.max(diff, 15 * 60_000))));
}; };
const updateEnd = (next: Date) => { const updateEnd = (next: Date) => {
if (next <= start) { if (allDay) {
setEnd(next < start ? new Date(start) : next);
} else if (next <= start) {
setEnd(addMinutes(start, 15)); setEnd(addMinutes(start, 15));
} else { } else {
setEnd(next); setEnd(next);
@@ -259,26 +268,51 @@ export function EventModal({
if (!title.trim() || !calendarId) return; if (!title.trim() || !calendarId) return;
setSaving(true); setSaving(true);
try { try {
// JSON drops `undefined`, so a cleared field must be sent as an explicit
// `null` when editing or the server keeps the old value.
const had = (key: keyof CalendarEvent): boolean => {
const v = event?.[key];
return !!v && (typeof v !== 'object' || Object.keys(v as object).length > 0);
};
const clearedOr = <T,>(value: T | undefined, key: keyof CalendarEvent): T | null | undefined =>
value !== undefined ? value : isEdit && had(key) ? null : undefined;
const recurrenceRules =
recurrence === 'custom'
? customRule
? [customRule]
: undefined
: recurrenceFromOption(recurrence);
const data: Partial<CalendarEvent> = { const data: Partial<CalendarEvent> = {
title: title.trim(), title: title.trim(),
description: description.trim() || undefined, description: description.trim() || (isEdit && event?.description ? '' : undefined),
showWithoutTime: allDay || undefined, showWithoutTime: allDay || (isEdit ? false : undefined),
start: allDay start: allDay
? format(start, "yyyy-MM-dd'T'00:00:00") ? format(start, "yyyy-MM-dd'T'00:00:00")
: format(start, "yyyy-MM-dd'T'HH:mm:ss"), : format(start, "yyyy-MM-dd'T'HH:mm:ss"),
duration: allDay ? buildAllDayDuration(start, end) : buildDuration(start, end), duration: allDay ? buildAllDayDuration(start, end) : buildDuration(start, end),
participants: Object.keys(participants).length > 0 ? participants : undefined, // A floating (zone-less) start renders at a different instant for
recurrenceRules: // every viewer; label the wall-clock with the zone it was entered in.
recurrence === 'custom' // All-day events are date-only and carry no zone.
? customRule timeZone: allDay ? null : getEffectiveTimeZone(),
? [customRule] participants: clearedOr(
: undefined Object.keys(participants).length > 0 ? participants : undefined,
: recurrenceFromOption(recurrence), 'participants',
alerts: remindersToAlerts(reminders), ),
recurrenceRules: clearedOr(recurrenceRules, 'recurrenceRules'),
alerts: clearedOr(remindersToAlerts(reminders), 'alerts'),
useDefaultAlerts: reminders.length > 0 ? false : undefined, useDefaultAlerts: reminders.length > 0 ? false : undefined,
locations: buildLocations(location), locations: clearedOr(buildLocations(location), 'locations'),
virtualLocations: buildVirtualLocations(videoUrl), virtualLocations: clearedOr(buildVirtualLocations(videoUrl), 'virtualLocations'),
}; };
if (data.recurrenceRules === null) {
// Dropping the rule leaves per-occurrence data dangling.
if (had('recurrenceOverrides')) data.recurrenceOverrides = null;
if (had('excludedRecurrenceRules')) data.excludedRecurrenceRules = null;
}
for (const key of Object.keys(data) as (keyof CalendarEvent)[]) {
if (data[key] === undefined) delete data[key];
}
await onSave(data, calendarId); await onSave(data, calendarId);
onClose(); onClose();
} finally { } finally {
+65
View File
@@ -0,0 +1,65 @@
/**
* The IANA time zone the calendar works in.
*
* Timed events must be saved with a `timeZone` so other clients, CalDAV
* syncs and invitees resolve the wall-clock correctly; a floating DTSTART
* shifts whenever the viewer's zone differs from the author's. Mirrors the
* webmail's lib/timezone.ts: the user's override when set (#755), otherwise
* the device zone.
*/
import { useSettingsStore } from '../stores/settings-store';
/** Sentinel for "follow the device" — the default. */
export const AUTO_TIME_ZONE = 'auto';
/** The zone the device reports; `UTC` when detection fails. */
export function getDeviceTimeZone(): string {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
} catch {
return 'UTC';
}
}
const validityCache = new Map<string, boolean>();
/** True when `Intl` accepts `tz` as a time zone identifier. */
export function isValidTimeZone(tz: unknown): tz is string {
if (typeof tz !== 'string' || !tz) return false;
let valid = validityCache.get(tz);
if (valid === undefined) {
try {
new Intl.DateTimeFormat('en-US', { timeZone: tz });
valid = true;
} catch {
valid = false;
}
validityCache.set(tz, valid);
}
return valid;
}
/**
* Resolve a stored `timeZone` setting to a concrete IANA zone. `auto`, empty
* or unknown values (a zone synced from a client with a newer tz database)
* fall back to the device zone instead of throwing later inside Intl.
*/
export function resolveTimeZone(
setting: string | null | undefined,
deviceTimeZone = getDeviceTimeZone(),
): string {
if (!setting || setting === AUTO_TIME_ZONE) return deviceTimeZone;
return isValidTimeZone(setting) ? setting : deviceTimeZone;
}
/** The zone new timed events are saved in and JMAP queries are interpreted in. */
export function getEffectiveTimeZone(): string {
let setting: string | null | undefined;
try {
setting = useSettingsStore.getState().calendarTimeZone;
} catch {
setting = undefined;
}
return resolveTimeZone(setting);
}
+3 -2
View File
@@ -217,8 +217,9 @@ export function buildRuleFromEditorValue(
'@type': 'RecurrenceRule', '@type': 'RecurrenceRule',
frequency: value.frequency, frequency: value.frequency,
interval: Math.max(1, value.interval), interval: Math.max(1, value.interval),
rscale: 'gregorian', // No rscale/skip: Stalwart serialises them into the RRULE as
skip: 'omit', // RSCALE=GREGORIAN;SKIP=OMIT, which DAVx5 rejects and which breaks
// Android CalDAV sync for the whole calendar (#805).
firstDayOfWeek: 'mo', firstDayOfWeek: 'mo',
}; };
+4
View File
@@ -169,6 +169,9 @@ interface PersistedSettings {
calendarShowTimeInMonth: boolean; calendarShowTimeInMonth: boolean;
calendarShowWeekNumbers: boolean; calendarShowWeekNumbers: boolean;
calendarHoverPreview: CalendarHoverPreview; calendarHoverPreview: CalendarHoverPreview;
// IANA zone the calendar works in, or 'auto' to follow the device (#755).
// Same key semantics as the webmail's `timeZone` setting.
calendarTimeZone: string;
showBirthdayCalendar: boolean; showBirthdayCalendar: boolean;
enableCalendarTasks: boolean; enableCalendarTasks: boolean;
showTasksOnCalendar: boolean; showTasksOnCalendar: boolean;
@@ -281,6 +284,7 @@ const DEFAULT_PERSISTED: PersistedSettings = {
calendarShowTimeInMonth: true, calendarShowTimeInMonth: true,
calendarShowWeekNumbers: false, calendarShowWeekNumbers: false,
calendarHoverPreview: 'delay-500ms', calendarHoverPreview: 'delay-500ms',
calendarTimeZone: 'auto',
showBirthdayCalendar: true, showBirthdayCalendar: true,
enableCalendarTasks: false, enableCalendarTasks: false,
showTasksOnCalendar: true, showTasksOnCalendar: true,