fix: edit or delete a single occurrence or the rest of a series instead of the whole series

This commit is contained in:
Linus Rath
2026-08-29 17:42:22 +02:00
parent 1c6aa680c5
commit 161baf1708
5 changed files with 568 additions and 33 deletions
@@ -0,0 +1,143 @@
import { describe, it, expect } from 'vitest';
import type { CalendarEvent } from '../../api/types';
import {
buildAllScopeUpdates,
buildFutureSeriesData,
buildRecurrenceOverridePatch,
isRecurringSeriesMember,
truncateRecurrenceRules,
} from '../recurrence-overrides';
const master: CalendarEvent = {
id: 'ev-1',
uid: 'uid-1',
calendarIds: { 'cal-1': true },
title: 'Standup',
start: '2026-03-02T09:00:00',
duration: 'PT30M',
timeZone: 'Europe/Berlin',
recurrenceRules: [{ frequency: 'daily', interval: 1, count: 10 }],
};
const occurrence: CalendarEvent = {
...master,
id: 'ev-1:2026-03-04T08:00:00.000Z',
originalId: 'ev-1',
start: '2026-03-04T09:00:00',
recurrenceId: '2026-03-04T08:00:00.000Z',
};
describe('buildRecurrenceOverridePatch', () => {
it('writes the whole override under one recurrenceOverrides pointer', () => {
const patch = buildRecurrenceOverridePatch(
{ title: 'Moved', start: '2026-03-04T10:00:00', calendarIds: { x: true }, uid: 'nope' },
'2026-03-04T08:00:00.000Z',
);
expect(patch).toEqual({
'recurrenceOverrides/2026-03-04T08:00:00.000Z': {
title: 'Moved',
start: '2026-03-04T10:00:00',
},
});
});
it('never leaks series-level keys or recurrenceId into the override', () => {
const patch = buildRecurrenceOverridePatch(
{
recurrenceRules: [{ frequency: 'weekly' }],
recurrenceOverrides: {},
excludedRecurrenceRules: [],
recurrenceId: '2026-03-04',
id: 'x',
'@type': 'Event',
description: 'kept',
},
'2026-03-04',
);
expect(patch['recurrenceOverrides/2026-03-04']).toEqual({ description: 'kept' });
});
});
describe('isRecurringSeriesMember', () => {
it('detects masters and expanded occurrences but not plain shared events', () => {
expect(isRecurringSeriesMember(master)).toBe(true);
expect(isRecurringSeriesMember(occurrence)).toBe(true);
expect(isRecurringSeriesMember({ recurrenceRules: [] })).toBe(false);
expect(isRecurringSeriesMember({ originalId: 'raw' } as CalendarEvent)).toBe(false);
});
});
describe('truncateRecurrenceRules', () => {
it('ends the rule one second before the occurrence slot and drops count', () => {
const rules = truncateRecurrenceRules(master.recurrenceRules, occurrence);
expect(rules).toHaveLength(1);
expect(rules[0].count).toBeUndefined();
// recurrenceId is the UTC instant; until is local wall-clock, one second earlier.
const expected = new Date('2026-03-04T08:00:00.000Z');
expected.setSeconds(expected.getSeconds() - 1);
const pad = (n: number) => String(n).padStart(2, '0');
const local =
`${expected.getFullYear()}-${pad(expected.getMonth() + 1)}-${pad(expected.getDate())}` +
`T${pad(expected.getHours())}:${pad(expected.getMinutes())}:${pad(expected.getSeconds())}`;
expect(rules[0].until).toBe(local);
expect(rules[0].frequency).toBe('daily');
});
it('treats an all-day recurrenceId as a local date', () => {
const rules = truncateRecurrenceRules(
[{ frequency: 'weekly' }],
{ recurrenceId: '2026-03-04', start: '2026-03-04T00:00:00' },
);
expect(rules[0].until).toBe('2026-03-03T23:59:59');
});
});
describe('buildAllScopeUpdates', () => {
it('drops an unchanged occurrence start so the master is not moved', () => {
const out = buildAllScopeUpdates(
{ title: 'Renamed', start: '2026-03-04T09:00:00', recurrenceId: 'x' },
occurrence,
master,
);
expect(out).toEqual({ title: 'Renamed' });
});
it('applies the delta of a moved occurrence to the master start', () => {
const out = buildAllScopeUpdates(
{ start: '2026-03-04T10:30:00' },
occurrence,
master,
);
expect(out.start).toBe('2026-03-02T10:30:00');
});
it('keeps start when editing the master itself', () => {
const out = buildAllScopeUpdates({ start: '2026-03-05T09:00:00' }, master, master);
expect(out.start).toBe('2026-03-05T09:00:00');
});
});
describe('buildFutureSeriesData', () => {
it('clones the master with the original rules, edits on top and the occurrence start', () => {
const data = buildFutureSeriesData(
master,
master.recurrenceRules ?? null,
occurrence,
{ title: 'New title' },
);
expect(data.title).toBe('New title');
expect(data.start).toBe('2026-03-04T09:00:00');
expect(data.recurrenceRules).toEqual(master.recurrenceRules);
expect(data.duration).toBe('PT30M');
expect(data.timeZone).toBe('Europe/Berlin');
expect('id' in data).toBe(false);
expect('uid' in data).toBe(false);
expect('recurrenceId' in data).toBe(false);
});
it('prefers a start the user moved', () => {
const data = buildFutureSeriesData(master, null, occurrence, { start: '2026-03-04T11:00:00' });
expect(data.start).toBe('2026-03-04T11:00:00');
expect('recurrenceRules' in data).toBe(false);
});
});
+161
View File
@@ -0,0 +1,161 @@
import { format, parseISO } from 'date-fns';
import type { CalendarEvent, RecurrenceRule } from '../api/types';
/** Identity / whole-series keys that must never go into a single-occurrence override. */
export const RECURRENCE_OVERRIDE_IMMUTABLE_KEYS = [
'id',
'uid',
'@type',
'calendarIds',
'recurrenceRules',
'recurrenceOverrides',
'excludedRecurrenceRules',
] as const;
/**
* Build the "This event only" override patch for a recurring master.
*
* Why one pointer: a JMAP nested pointer can't create a missing intermediate,
* so the override object has to be set whole at `recurrenceOverrides/<id>`, not
* field-by-field (#774). Mirrors webmail's lib/recurrence-overrides.ts.
*/
export function buildRecurrenceOverridePatch(
updates: Partial<CalendarEvent>,
recurrenceId: string,
): Record<string, unknown> {
const immutable = new Set<string>(RECURRENCE_OVERRIDE_IMMUTABLE_KEYS);
const override: Record<string, unknown> = {};
for (const [key, value] of Object.entries(updates)) {
if (immutable.has(key)) continue;
if (key === 'recurrenceId') continue;
override[key] = value;
}
return { [`recurrenceOverrides/${recurrenceId}`]: override };
}
/**
* A recurring series member is a master (has recurrenceRules) or an expanded
* occurrence (has recurrenceId) — those get the this/future/all scope dialog.
* Note: `originalId` alone is not a signal, shared/group events carry it for
* id-namespacing even when they aren't recurring.
*/
export function isRecurringSeriesMember(
event: Pick<CalendarEvent, 'recurrenceRules' | 'recurrenceId'>,
): boolean {
return !!event.recurrenceRules?.length || !!event.recurrenceId;
}
/** The original slot of an occurrence as a local wall-clock Date. */
export function occurrenceSlotDate(
event: Pick<CalendarEvent, 'recurrenceId' | 'start'>,
): Date {
// recurrenceId is either "yyyy-MM-dd" (all-day) or the occurrence instant
// as UTC ISO; parseISO treats a date-only value as local midnight, which is
// what the RRULE `until` below needs. `new Date("yyyy-MM-dd")` would read
// it as UTC and shift the day in any non-UTC zone.
return parseISO(event.recurrenceId || event.start);
}
/**
* "This and following": end the master's rules one second before the chosen
* occurrence. `count` is dropped so the truncated rule is bounded by `until`
* alone (a rule can't carry both). Mirrors webmail's truncateRecurrenceAtEvent.
*/
export function truncateRecurrenceRules(
rules: RecurrenceRule[] | undefined | null,
occurrence: Pick<CalendarEvent, 'recurrenceId' | 'start'>,
): RecurrenceRule[] {
const untilDate = occurrenceSlotDate(occurrence);
untilDate.setSeconds(untilDate.getSeconds() - 1);
const until = format(untilDate, "yyyy-MM-dd'T'HH:mm:ss");
return (rules || []).map((rule) => {
const { count: _count, ...rest } = rule;
return { ...rest, until };
});
}
/**
* Updates for the "All events" scope. The editor seeds its date fields from
* the tapped occurrence, so an unchanged `start` must not be written to the
* master (that would move the whole series onto the occurrence's date and
* make the earlier occurrences vanish). When the user did move the occurrence,
* the same delta is applied to the master's start instead.
*/
export function buildAllScopeUpdates(
updates: Partial<CalendarEvent>,
occurrence: Pick<CalendarEvent, 'start' | 'recurrenceId'>,
master: Pick<CalendarEvent, 'start'>,
): Partial<CalendarEvent> {
const out: Partial<CalendarEvent> = { ...updates };
delete (out as Record<string, unknown>).recurrenceId;
if (!out.start) return out;
if (!occurrence.recurrenceId) return out; // editing the master itself
const nextStart = parseISO(out.start);
const occurrenceStart = parseISO(occurrence.start);
const masterStart = parseISO(master.start);
if (
isNaN(nextStart.getTime()) ||
isNaN(occurrenceStart.getTime()) ||
isNaN(masterStart.getTime())
) {
delete out.start;
return out;
}
const delta = nextStart.getTime() - occurrenceStart.getTime();
if (delta === 0) {
delete out.start;
return out;
}
const shifted = new Date(masterStart.getTime() + delta);
out.start = format(shifted, "yyyy-MM-dd'T'HH:mm:ss");
return out;
}
/**
* The new series created for "This and following": the master's series-level
* fields, the original (untruncated) rules, the user's edits on top, and the
* occurrence's slot as the start unless the user moved it.
*/
export function buildFutureSeriesData(
master: CalendarEvent,
originalRules: RecurrenceRule[] | null,
occurrence: Pick<CalendarEvent, 'start' | 'recurrenceId'>,
updates: Partial<CalendarEvent>,
): Partial<CalendarEvent> {
const occurrenceStart = occurrence.start;
const data: Partial<CalendarEvent> = {
title: master.title,
description: master.description,
duration: master.duration,
timeZone: master.timeZone,
calendarIds: { ...master.calendarIds },
status: master.status,
freeBusyStatus: master.freeBusyStatus,
showWithoutTime: master.showWithoutTime,
participants: master.participants,
organizerCalendarAddress: master.organizerCalendarAddress,
alerts: master.alerts,
useDefaultAlerts: master.useDefaultAlerts,
locations: master.locations,
virtualLocations: master.virtualLocations,
recurrenceRules: originalRules ?? undefined,
...updates,
start: updates.start || occurrenceStart,
};
const raw = data as Record<string, unknown>;
delete raw.id;
delete raw.uid;
delete raw.recurrenceId;
delete raw.recurrenceOverrides;
delete raw.excludedRecurrenceRules;
delete raw.utcStart;
delete raw.utcEnd;
delete raw.originalId;
delete raw.originalCalendarIds;
delete raw.accountId;
delete raw.isShared;
for (const key of Object.keys(raw)) {
if (raw[key] === undefined) delete raw[key];
}
return data;
}
+133 -28
View File
@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { import {
Alert,
View, View,
Text, Text,
StyleSheet, StyleSheet,
@@ -55,19 +56,28 @@ import {
applySharedCalendarColors, applySharedCalendarColors,
buildEventDayIndex, buildEventDayIndex,
eventsOnDayFromIndex, eventsOnDayFromIndex,
getPrimaryCalendarId,
pickUnusedCalendarColor, pickUnusedCalendarColor,
sharedCalendarColorKey, sharedCalendarColorKey,
type EventDayIndex, type EventDayIndex,
type TimeFormat, type TimeFormat,
} from '../lib/calendar-utils'; } from '../lib/calendar-utils';
import { buildReplyTo } from '../lib/calendar-invitation'; import { buildReplyTo } from '../lib/calendar-invitation';
import {
buildAllScopeUpdates,
buildFutureSeriesData,
buildRecurrenceOverridePatch,
isRecurringSeriesMember,
truncateRecurrenceRules,
} from '../lib/recurrence-overrides';
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from '../lib/birthday-calendar'; import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from '../lib/birthday-calendar';
import { useContactsStore } from '../stores/contacts-store'; import { useContactsStore } from '../stores/contacts-store';
import type { Calendar, CalendarEvent } from '../api/types'; import { useLocaleStore } from '../stores/locale-store';
import type { Calendar, CalendarEvent, RecurrenceRule } from '../api/types';
type ViewMode = 'month' | 'week' | 'agenda'; type ViewMode = 'month' | 'week' | 'agenda';
type PendingAction = type PendingAction =
| { kind: 'edit'; event: CalendarEvent } | { kind: 'edit'; event: CalendarEvent; updates: Partial<CalendarEvent>; calendarId: string }
| { kind: 'delete'; event: CalendarEvent } | { kind: 'delete'; event: CalendarEvent }
| null; | null;
@@ -115,6 +125,7 @@ function headerTitle(
export default function CalendarScreen() { export default function CalendarScreen() {
const c = useColors(); const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]); const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const calendarDefaultView = useSettingsStore((s) => s.calendarDefaultView); const calendarDefaultView = useSettingsStore((s) => s.calendarDefaultView);
const calendarFirstDayOfWeek = useSettingsStore((s) => s.calendarFirstDayOfWeek); const calendarFirstDayOfWeek = useSettingsStore((s) => s.calendarFirstDayOfWeek);
const calendarShowWeekNumbers = useSettingsStore((s) => s.calendarShowWeekNumbers); const calendarShowWeekNumbers = useSettingsStore((s) => s.calendarShowWeekNumbers);
@@ -154,6 +165,7 @@ export default function CalendarScreen() {
const createEvent = useCalendarStore((s) => s.createEvent); const createEvent = useCalendarStore((s) => s.createEvent);
const updateEvent = useCalendarStore((s) => s.updateEvent); const updateEvent = useCalendarStore((s) => s.updateEvent);
const deleteEvent = useCalendarStore((s) => s.deleteEvent); const deleteEvent = useCalendarStore((s) => s.deleteEvent);
const getMasterEvent = useCalendarStore((s) => s.getMasterEvent);
const rsvpEvent = useCalendarStore((s) => s.rsvpEvent); const rsvpEvent = useCalendarStore((s) => s.rsvpEvent);
const importEvents = useCalendarStore((s) => s.importEvents); const importEvents = useCalendarStore((s) => s.importEvents);
const tasks = useCalendarStore((s) => s.tasks); const tasks = useCalendarStore((s) => s.tasks);
@@ -315,24 +327,13 @@ export default function CalendarScreen() {
[], [],
); );
// A recurring series member is a master (has recurrenceRules) or an expanded // Editing a series member opens the editor directly; the this/future/all
// occurrence (has recurrenceId) — those get the this/all scope dialog. Note: // scope question is asked on save (like webmail), once we know the edits.
// we must NOT key off originalId here, since shared/group events now carry it
// for id-namespacing even when they aren't recurring.
const isRecurringSeriesMember = React.useCallback(
(event: CalendarEvent) => !!event.recurrenceRules?.length || !!event.recurrenceId,
[],
);
const handleEditFromDetail = React.useCallback((event: CalendarEvent) => { const handleEditFromDetail = React.useCallback((event: CalendarEvent) => {
if (isReadOnlyEvent(event)) { setDetailEvent(null); return; } if (isReadOnlyEvent(event)) { setDetailEvent(null); return; }
setDetailEvent(null); setDetailEvent(null);
if (isRecurringSeriesMember(event)) { openEditDirect(event);
setPendingAction({ kind: 'edit', event }); }, [openEditDirect, isReadOnlyEvent]);
} else {
openEditDirect(event);
}
}, [openEditDirect, isReadOnlyEvent, isRecurringSeriesMember]);
const handleDeleteFromDetail = React.useCallback((event: CalendarEvent) => { const handleDeleteFromDetail = React.useCallback((event: CalendarEvent) => {
if (isReadOnlyEvent(event)) { setDetailEvent(null); return; } if (isReadOnlyEvent(event)) { setDetailEvent(null); return; }
@@ -342,30 +343,130 @@ export default function CalendarScreen() {
} else { } else {
void deleteEvent(event.id); void deleteEvent(event.id);
} }
}, [deleteEvent, isReadOnlyEvent, isRecurringSeriesMember]); }, [deleteEvent, isReadOnlyEvent]);
// "This and following": end the master at the occurrence and hand back the
// master plus its untouched rules so the caller can start a new series (or
// roll back). Port of webmail's truncateRecurrenceAtEvent.
const truncateRecurrenceAtEvent = React.useCallback(
async (event: CalendarEvent) => {
const master = await getMasterEvent(event);
if (!master) return null;
const originalRules = master.recurrenceRules
? (JSON.parse(JSON.stringify(master.recurrenceRules)) as RecurrenceRule[])
: null;
await updateEvent(master.id, {
recurrenceRules: truncateRecurrenceRules(master.recurrenceRules, event),
});
return { master, originalRules };
},
[getMasterEvent, updateEvent],
);
const handleScopeSelect = React.useCallback( const handleScopeSelect = React.useCallback(
async (scope: RecurrenceEditScope) => { async (scope: RecurrenceEditScope) => {
const action = pendingAction; const action = pendingAction;
setPendingAction(null); setPendingAction(null);
if (!action) return; if (!action) return;
// For the first pass, treat all scopes as "all" - proper override const { event } = action;
// handling lives in the store's update/delete path against originalId. try {
// TODO: implement "this only" via recurrenceOverrides write, and if (action.kind === 'edit') {
// "this and following" via excludedRecurrenceRules + new master. const { updates } = action;
if (action.kind === 'edit') { switch (scope) {
openEditDirect(action.event); case 'this': {
} else { if (event.recurrenceId) {
await deleteEvent(action.event.id); // Client-side expanded occurrence: write a one-shot override
// on the master instead of touching the series.
await updateEvent(event.id, buildRecurrenceOverridePatch(updates, event.recurrenceId));
} else {
await updateEvent(event.id, updates);
}
break;
}
case 'this_and_future': {
const result = await truncateRecurrenceAtEvent(event);
if (!result) throw new Error('Master event not found');
const { master, originalRules } = result;
const newEventData = buildFutureSeriesData(master, originalRules, event, updates);
delete newEventData.calendarIds;
const targetCalendarId =
action.calendarId || getPrimaryCalendarId(master) || '';
try {
await createEvent(newEventData, targetCalendarId);
} catch (createError) {
// Roll back the truncation so the series isn't left cut short.
try {
await updateEvent(master.id, { recurrenceRules: originalRules ?? [] });
} catch {
// The rollback failing is reported through the original error.
}
throw createError;
}
break;
}
case 'all': {
const master = await getMasterEvent(event);
if (!master) throw new Error('Master event not found');
await updateEvent(master.id, buildAllScopeUpdates(updates, event, master));
break;
}
}
} else {
switch (scope) {
case 'this': {
if (event.recurrenceId) {
await updateEvent(event.id, {
[`recurrenceOverrides/${event.recurrenceId}`]: { excluded: true },
});
} else {
await deleteEvent(event.id);
}
break;
}
case 'this_and_future': {
const result = await truncateRecurrenceAtEvent(event);
if (!result) throw new Error('Master event not found');
break;
}
case 'all': {
const master = await getMasterEvent(event);
if (!master) throw new Error('Master event not found');
await deleteEvent(master.id);
break;
}
}
}
} catch (err) {
Alert.alert(
t('calendar.notifications.event_error', 'Something went wrong'),
err instanceof Error ? err.message : undefined,
);
}
try {
await refresh();
} catch {
// Best effort — the next navigation refetches anyway.
} }
}, },
[pendingAction, openEditDirect, deleteEvent], [pendingAction, updateEvent, deleteEvent, createEvent, getMasterEvent, truncateRecurrenceAtEvent, refresh, t],
); );
const handleSave = React.useCallback( const handleSave = React.useCallback(
async (data: Partial<CalendarEvent>, calendarId: string) => { async (data: Partial<CalendarEvent>, calendarId: string) => {
if (modalEvent) { if (modalEvent) {
await updateEvent(modalEvent.id, data); const updates: Partial<CalendarEvent> = { ...data };
// Moving the event to another calendar: the store remaps the store id
// onto the owning account's raw calendar id.
if (calendarId && calendarId !== getPrimaryCalendarId(modalEvent)) {
updates.calendarIds = { [calendarId]: true };
}
if (isRecurringSeriesMember(modalEvent)) {
// Ask which occurrences the edit applies to; the actual write
// happens in handleScopeSelect.
setPendingAction({ kind: 'edit', event: modalEvent, updates, calendarId });
return;
}
await updateEvent(modalEvent.id, updates);
} else { } else {
await createEvent(data, calendarId); await createEvent(data, calendarId);
} }
@@ -376,6 +477,10 @@ export default function CalendarScreen() {
const handleDeleteFromModal = React.useCallback( const handleDeleteFromModal = React.useCallback(
async (event: CalendarEvent) => { async (event: CalendarEvent) => {
setModalVisible(false); setModalVisible(false);
if (isRecurringSeriesMember(event)) {
setPendingAction({ kind: 'delete', event });
return;
}
await deleteEvent(event.id); await deleteEvent(event.id);
}, },
[deleteEvent], [deleteEvent],
@@ -417,5 +417,85 @@ describe('calendar-store', () => {
expect(useCalendarStore.getState().events).toHaveLength(1); expect(useCalendarStore.getState().events).toHaveLength(1);
expect(useCalendarStore.getState().events[0].id).toBe('ev2'); expect(useCalendarStore.getState().events[0].id).toBe('ev2');
}); });
it('should drop every expanded occurrence of a destroyed master and refetch', async () => {
useCalendarStore.setState({
calendars: [{ id: 'cal-1', name: 'Personal' } as any],
loadedRange: { after: '2026-03-01T00:00:00Z', before: '2026-03-31T00:00:00Z' },
events: [
{ id: 'ev1:a', originalId: 'ev1', recurrenceId: 'a', recurrenceRules: [{ frequency: 'daily' }] } as any,
{ id: 'ev1:b', originalId: 'ev1', recurrenceId: 'b', recurrenceRules: [{ frequency: 'daily' }] } as any,
{ id: 'ev2' } as any,
],
});
mockDeleteEvents.mockResolvedValue(undefined);
mockQueryEvents.mockResolvedValue([]);
await useCalendarStore.getState().deleteEvent('ev1:a');
expect(mockDeleteEvents).toHaveBeenCalledWith(['ev1'], undefined, undefined);
// The visible range was reloaded (empty here).
expect(mockQueryEvents).toHaveBeenCalled();
});
});
describe('recurring series mutations', () => {
it('updateEvent on an occurrence patches the master and refetches the range', async () => {
useCalendarStore.setState({
calendars: [{ id: 'cal-1', name: 'Personal' } as any],
loadedRange: { after: '2026-03-01T00:00:00Z', before: '2026-03-31T00:00:00Z' },
events: [
{ id: 'ev1:a', originalId: 'ev1', recurrenceId: 'a', title: 'Old', recurrenceRules: [{ frequency: 'daily' }] } as any,
{ id: 'ev1:b', originalId: 'ev1', recurrenceId: 'b', title: 'Old', recurrenceRules: [{ frequency: 'daily' }] } as any,
],
});
mockUpdateEvent.mockResolvedValue(undefined);
mockQueryEvents.mockResolvedValue(['ev1']);
mockGetEvents.mockResolvedValue([
{ id: 'ev1', title: 'New', start: '2026-03-02T09:00:00', recurrenceRules: [{ frequency: 'daily', count: 2 }], calendarIds: { 'cal-1': true } },
]);
await useCalendarStore.getState().updateEvent('ev1:a', { 'recurrenceOverrides/a': { title: 'New' } });
expect(mockUpdateEvent).toHaveBeenCalledWith('ev1', { 'recurrenceOverrides/a': { title: 'New' } }, undefined, undefined);
// Refetched: siblings now carry the server's state.
const events = useCalendarStore.getState().events;
expect(events.length).toBeGreaterThan(0);
expect(events.every((e) => e.title === 'New')).toBe(true);
});
it('updateEvent remaps namespaced calendarIds to the raw server id', async () => {
useCalendarStore.setState({
calendars: [
{ id: 'cal-1', name: 'Personal' } as any,
{ id: 'acc-2:cal-9', originalId: 'cal-9', accountId: 'acc-2', name: 'Shared', isShared: true } as any,
],
events: [{ id: 'ev1', calendarIds: { 'cal-1': true } } as any],
});
mockUpdateEvent.mockResolvedValue(undefined);
await useCalendarStore.getState().updateEvent('ev1', { calendarIds: { 'acc-2:cal-9': true } });
expect(mockUpdateEvent).toHaveBeenCalledWith('ev1', { calendarIds: { 'cal-9': true } }, undefined, undefined);
});
it('getMasterEvent fetches the master an occurrence points at', async () => {
useCalendarStore.setState({ calendars: [], events: [] });
mockGetEvents.mockResolvedValue([{ id: 'ev1', start: '2026-03-02T09:00:00', recurrenceRules: [{ frequency: 'daily' }] }]);
const master = await useCalendarStore.getState().getMasterEvent({
id: 'ev1:a', originalId: 'ev1', recurrenceId: 'a', start: '2026-03-04T09:00:00',
} as any);
expect(mockGetEvents).toHaveBeenCalledWith(['ev1'], undefined);
expect(master?.id).toBe('ev1');
expect(master?.start).toBe('2026-03-02T09:00:00');
});
it('getMasterEvent returns a master as-is', async () => {
const ev = { id: 'ev1', recurrenceRules: [{ frequency: 'daily' }] } as any;
expect(await useCalendarStore.getState().getMasterEvent(ev)).toBe(ev);
expect(mockGetEvents).not.toHaveBeenCalled();
});
}); });
}); });
+51 -5
View File
@@ -16,6 +16,7 @@ import {
} from '../api/calendar'; } from '../api/calendar';
import { jmapClient } from '../api/jmap-client'; import { jmapClient } from '../api/jmap-client';
import { expandRecurringEvents } from '../lib/recurrence-expansion'; import { expandRecurringEvents } from '../lib/recurrence-expansion';
import { isRecurringSeriesMember } from '../lib/recurrence-overrides';
// Does the event carry attendees the server should notify over iMIP? Used to // Does the event carry attendees the server should notify over iMIP? Used to
// decide whether to set sendSchedulingMessages on create/update/delete. // decide whether to set sendSchedulingMessages on create/update/delete.
@@ -84,8 +85,13 @@ export interface CalendarState {
refresh: () => Promise<void>; refresh: () => Promise<void>;
handleStateChange: (change: StateChange) => Promise<void>; handleStateChange: (change: StateChange) => Promise<void>;
createEvent: (event: Partial<CalendarEvent>, calendarId: string) => Promise<CalendarEvent>; createEvent: (event: Partial<CalendarEvent>, calendarId: string) => Promise<CalendarEvent>;
updateEvent: (id: string, changes: Partial<CalendarEvent>) => Promise<void>; // `changes` may be a plain partial or a JMAP patch with JSON-pointer keys
// (e.g. `recurrenceOverrides/<recurrenceId>`).
updateEvent: (id: string, changes: Partial<CalendarEvent> | Record<string, unknown>) => Promise<void>;
deleteEvent: (id: string) => Promise<void>; deleteEvent: (id: string) => Promise<void>;
// Resolve a client-side expanded occurrence (or a master) to its master
// event, fetching it from the server when the expansion replaced it.
getMasterEvent: (event: CalendarEvent) => Promise<CalendarEvent | null>;
// Invitations / scheduling // Invitations / scheduling
rsvpEvent: ( rsvpEvent: (
eventId: string, eventId: string,
@@ -307,14 +313,34 @@ export const useCalendarStore = create<CalendarState>()(
// Resolve client-side expanded occurrence IDs back to the master event ID. // Resolve client-side expanded occurrence IDs back to the master event ID.
const storeEvent = get().events.find((e) => e.id === id); const storeEvent = get().events.find((e) => e.id === id);
const realId = storeEvent?.originalId || id; const realId = storeEvent?.originalId || id;
// Remap namespaced (shared-calendar) store ids in calendarIds back to the
// raw server ids the owning account knows.
const patch: Record<string, unknown> = { ...changes };
const calendarIds = patch.calendarIds as Record<string, boolean> | undefined;
if (calendarIds && typeof calendarIds === 'object') {
const remapped: Record<string, boolean> = {};
for (const [calId, v] of Object.entries(calendarIds)) {
const cal = get().calendars.find((c) => c.id === calId);
remapped[cal?.originalId || calId] = v;
}
patch.calendarIds = remapped;
}
// Notify attendees if either the stored event or the incoming changes // Notify attendees if either the stored event or the incoming changes
// carry participants. // carry participants.
const schedule = const schedule =
hasSchedulingParticipants(changes) || hasSchedulingParticipants(storeEvent); hasSchedulingParticipants(changes as Partial<CalendarEvent>) ||
await apiUpdateEvent(realId, changes, schedule ? true : undefined, storeEvent?.accountId); hasSchedulingParticipants(storeEvent);
await apiUpdateEvent(realId, patch, schedule ? true : undefined, storeEvent?.accountId);
set({ set({
events: get().events.map((e) => (e.id === id ? { ...e, ...changes } : e)), events: get().events.map((e) => (e.id === id ? { ...e, ...(changes as Partial<CalendarEvent>) } : e)),
}); });
// A series mutation (an occurrence override, a truncated/changed rule, a
// master edit) touches every expanded sibling, and the optimistic merge
// above only updated the tapped one — reload the visible range like
// webmail's refetchAfterOccurrenceMutation.
if (storeEvent && isRecurringSeriesMember(storeEvent)) {
await get().refresh();
}
}, },
deleteEvent: async (id) => { deleteEvent: async (id) => {
@@ -325,7 +351,27 @@ export const useCalendarStore = create<CalendarState>()(
hasSchedulingParticipants(storeEvent) ? true : undefined, hasSchedulingParticipants(storeEvent) ? true : undefined,
storeEvent?.accountId, storeEvent?.accountId,
); );
set({ events: get().events.filter((e) => e.id !== id) }); // Destroying a master removes every expanded occurrence of it, not just
// the tapped one.
set({ events: get().events.filter((e) => e.id !== id && (e.originalId || e.id) !== realId) });
if (storeEvent && isRecurringSeriesMember(storeEvent)) {
await get().refresh();
}
},
getMasterEvent: async (event) => {
if (event.recurrenceRules?.length && !event.recurrenceId) return event;
// Client-side expansion replaces the master with its occurrences, each
// pointing back at the master's server id through originalId.
const realId = event.originalId || event.id;
const inStore = get().events.find(
(e) => e.id === realId && !e.recurrenceId && !!e.recurrenceRules?.length,
);
if (inStore) return inStore;
const fetched = (await fetchEvents([realId], event.accountId)) ?? [];
const master = fetched[0];
if (!master) return null;
return mapServerEventToStoreEvent(master, get().calendars, event.accountId);
}, },
rsvpEvent: async (eventId, participantId, status, replyTo) => { rsvpEvent: async (eventId, participantId, status, replyTo) => {