diff --git a/src/lib/__tests__/recurrence-overrides.test.ts b/src/lib/__tests__/recurrence-overrides.test.ts new file mode 100644 index 00000000..1fcc0e80 --- /dev/null +++ b/src/lib/__tests__/recurrence-overrides.test.ts @@ -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); + }); +}); diff --git a/src/lib/recurrence-overrides.ts b/src/lib/recurrence-overrides.ts new file mode 100644 index 00000000..92943327 --- /dev/null +++ b/src/lib/recurrence-overrides.ts @@ -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/`, not + * field-by-field (#774). Mirrors webmail's lib/recurrence-overrides.ts. + */ +export function buildRecurrenceOverridePatch( + updates: Partial, + recurrenceId: string, +): Record { + const immutable = new Set(RECURRENCE_OVERRIDE_IMMUTABLE_KEYS); + const override: Record = {}; + 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, +): boolean { + return !!event.recurrenceRules?.length || !!event.recurrenceId; +} + +/** The original slot of an occurrence as a local wall-clock Date. */ +export function occurrenceSlotDate( + event: Pick, +): 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, +): 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, + occurrence: Pick, + master: Pick, +): Partial { + const out: Partial = { ...updates }; + delete (out as Record).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, + updates: Partial, +): Partial { + const occurrenceStart = occurrence.start; + const data: Partial = { + 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; + 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; +} diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx index 8207bf8e..a98bae7d 100644 --- a/src/screens/CalendarScreen.tsx +++ b/src/screens/CalendarScreen.tsx @@ -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; 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, calendarId: string) => { if (modalEvent) { - await updateEvent(modalEvent.id, data); + const updates: Partial = { ...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], diff --git a/src/stores/__tests__/calendar-store.test.ts b/src/stores/__tests__/calendar-store.test.ts index 3f6f4f9d..bec13c4c 100644 --- a/src/stores/__tests__/calendar-store.test.ts +++ b/src/stores/__tests__/calendar-store.test.ts @@ -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(); + }); }); }); diff --git a/src/stores/calendar-store.ts b/src/stores/calendar-store.ts index 06eaf28f..4f61dc0e 100644 --- a/src/stores/calendar-store.ts +++ b/src/stores/calendar-store.ts @@ -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; handleStateChange: (change: StateChange) => Promise; createEvent: (event: Partial, calendarId: string) => Promise; - updateEvent: (id: string, changes: Partial) => Promise; + // `changes` may be a plain partial or a JMAP patch with JSON-pointer keys + // (e.g. `recurrenceOverrides/`). + updateEvent: (id: string, changes: Partial | Record) => Promise; deleteEvent: (id: string) => Promise; + // 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; // Invitations / scheduling rsvpEvent: ( eventId: string, @@ -307,14 +313,34 @@ export const useCalendarStore = create()( // 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 = { ...changes }; + const calendarIds = patch.calendarIds as Record | undefined; + if (calendarIds && typeof calendarIds === 'object') { + const remapped: Record = {}; + 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) || + 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) } : 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()( 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) => {