mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
fix: save all-day events with the right length, a time zone and explicit nulls for cleared fields (#805)
This commit is contained in:
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user