mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 00:06:17 +00:00
fix: edit or delete a single occurrence or the rest of a series instead of the whole series
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Alert,
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
@@ -55,19 +56,28 @@ import {
|
||||
applySharedCalendarColors,
|
||||
buildEventDayIndex,
|
||||
eventsOnDayFromIndex,
|
||||
getPrimaryCalendarId,
|
||||
pickUnusedCalendarColor,
|
||||
sharedCalendarColorKey,
|
||||
type EventDayIndex,
|
||||
type TimeFormat,
|
||||
} from '../lib/calendar-utils';
|
||||
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 { 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 PendingAction =
|
||||
| { kind: 'edit'; event: CalendarEvent }
|
||||
| { kind: 'edit'; event: CalendarEvent; updates: Partial<CalendarEvent>; calendarId: string }
|
||||
| { kind: 'delete'; event: CalendarEvent }
|
||||
| null;
|
||||
|
||||
@@ -115,6 +125,7 @@ function headerTitle(
|
||||
export default function CalendarScreen() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const calendarDefaultView = useSettingsStore((s) => s.calendarDefaultView);
|
||||
const calendarFirstDayOfWeek = useSettingsStore((s) => s.calendarFirstDayOfWeek);
|
||||
const calendarShowWeekNumbers = useSettingsStore((s) => s.calendarShowWeekNumbers);
|
||||
@@ -154,6 +165,7 @@ export default function CalendarScreen() {
|
||||
const createEvent = useCalendarStore((s) => s.createEvent);
|
||||
const updateEvent = useCalendarStore((s) => s.updateEvent);
|
||||
const deleteEvent = useCalendarStore((s) => s.deleteEvent);
|
||||
const getMasterEvent = useCalendarStore((s) => s.getMasterEvent);
|
||||
const rsvpEvent = useCalendarStore((s) => s.rsvpEvent);
|
||||
const importEvents = useCalendarStore((s) => s.importEvents);
|
||||
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
|
||||
// occurrence (has recurrenceId) — those get the this/all scope dialog. Note:
|
||||
// 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,
|
||||
[],
|
||||
);
|
||||
|
||||
// Editing a series member opens the editor directly; the this/future/all
|
||||
// scope question is asked on save (like webmail), once we know the edits.
|
||||
const handleEditFromDetail = React.useCallback((event: CalendarEvent) => {
|
||||
if (isReadOnlyEvent(event)) { setDetailEvent(null); return; }
|
||||
setDetailEvent(null);
|
||||
if (isRecurringSeriesMember(event)) {
|
||||
setPendingAction({ kind: 'edit', event });
|
||||
} else {
|
||||
openEditDirect(event);
|
||||
}
|
||||
}, [openEditDirect, isReadOnlyEvent, isRecurringSeriesMember]);
|
||||
openEditDirect(event);
|
||||
}, [openEditDirect, isReadOnlyEvent]);
|
||||
|
||||
const handleDeleteFromDetail = React.useCallback((event: CalendarEvent) => {
|
||||
if (isReadOnlyEvent(event)) { setDetailEvent(null); return; }
|
||||
@@ -342,30 +343,130 @@ export default function CalendarScreen() {
|
||||
} else {
|
||||
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(
|
||||
async (scope: RecurrenceEditScope) => {
|
||||
const action = pendingAction;
|
||||
setPendingAction(null);
|
||||
if (!action) return;
|
||||
// For the first pass, treat all scopes as "all" - proper override
|
||||
// handling lives in the store's update/delete path against originalId.
|
||||
// TODO: implement "this only" via recurrenceOverrides write, and
|
||||
// "this and following" via excludedRecurrenceRules + new master.
|
||||
if (action.kind === 'edit') {
|
||||
openEditDirect(action.event);
|
||||
} else {
|
||||
await deleteEvent(action.event.id);
|
||||
const { event } = action;
|
||||
try {
|
||||
if (action.kind === 'edit') {
|
||||
const { updates } = action;
|
||||
switch (scope) {
|
||||
case 'this': {
|
||||
if (event.recurrenceId) {
|
||||
// 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(
|
||||
async (data: Partial<CalendarEvent>, calendarId: string) => {
|
||||
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 {
|
||||
await createEvent(data, calendarId);
|
||||
}
|
||||
@@ -376,6 +477,10 @@ export default function CalendarScreen() {
|
||||
const handleDeleteFromModal = React.useCallback(
|
||||
async (event: CalendarEvent) => {
|
||||
setModalVisible(false);
|
||||
if (isRecurringSeriesMember(event)) {
|
||||
setPendingAction({ kind: 'delete', event });
|
||||
return;
|
||||
}
|
||||
await deleteEvent(event.id);
|
||||
},
|
||||
[deleteEvent],
|
||||
|
||||
@@ -417,5 +417,85 @@ describe('calendar-store', () => {
|
||||
expect(useCalendarStore.getState().events).toHaveLength(1);
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from '../api/calendar';
|
||||
import { jmapClient } from '../api/jmap-client';
|
||||
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
|
||||
// decide whether to set sendSchedulingMessages on create/update/delete.
|
||||
@@ -84,8 +85,13 @@ export interface CalendarState {
|
||||
refresh: () => Promise<void>;
|
||||
handleStateChange: (change: StateChange) => Promise<void>;
|
||||
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>;
|
||||
// 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
|
||||
rsvpEvent: (
|
||||
eventId: string,
|
||||
@@ -307,14 +313,34 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
// Resolve client-side expanded occurrence IDs back to the master event ID.
|
||||
const storeEvent = get().events.find((e) => e.id === 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
|
||||
// carry participants.
|
||||
const schedule =
|
||||
hasSchedulingParticipants(changes) || hasSchedulingParticipants(storeEvent);
|
||||
await apiUpdateEvent(realId, changes, schedule ? true : undefined, storeEvent?.accountId);
|
||||
hasSchedulingParticipants(changes as Partial<CalendarEvent>) ||
|
||||
hasSchedulingParticipants(storeEvent);
|
||||
await apiUpdateEvent(realId, patch, schedule ? true : undefined, storeEvent?.accountId);
|
||||
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) => {
|
||||
@@ -325,7 +351,27 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
hasSchedulingParticipants(storeEvent) ? true : undefined,
|
||||
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) => {
|
||||
|
||||
Reference in New Issue
Block a user