mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +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 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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user