From 4b903ed179241f8af8e815acf174cf4ee30d01b4 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:50:17 +0200 Subject: [PATCH] fix: keep calendar reminders when the calendar view moves Reminders were rebuilt from the events of the window the calendar showed, and everything else was cancelled, so looking at another month dropped every pending reminder. The sync only started once the Calendar tab opened, and without a notification handler a reminder that fired while the app was open was never shown. - load the events for reminders from their own window (now to a week past the horizon), independent of the view, via `loadEventsInRange` factored out of `fetchEvents` - leave scheduled reminders alone when that window can't be loaded (offline, no session yet), cancel only ours, and cancel them all on sign-out - start the sync at launch and rerun it after sign-in or an account switch, on `Calendar`/`CalendarEvent` state changes, on in-app edits and when the app returns to the foreground; only the Calendar tab may ask for notification permission - install a foreground handler that shows our reminders; mail push is posted natively and doesn't go through expo-notifications --- App.tsx | 4 + .../__tests__/calendar-notifications.test.ts | 217 ++++++++++++++++++ src/lib/calendar-notifications.ts | 186 ++++++++++++--- src/screens/CalendarScreen.tsx | 6 +- src/stores/calendar-store.ts | 99 ++++---- 5 files changed, 429 insertions(+), 83 deletions(-) create mode 100644 src/lib/__tests__/calendar-notifications.test.ts diff --git a/App.tsx b/App.tsx index 7d60e871..b2328eec 100644 --- a/App.tsx +++ b/App.tsx @@ -11,6 +11,7 @@ import { startLiveUpdates, type LiveUpdatesHandle } from './src/api/push-stream' import { jmapClient } from './src/api/jmap-client'; import type { StateChange } from './src/api/types'; import { dispatchStateChange } from './src/lib/state-change-bus'; +import { startCalendarNotificationSync } from './src/lib/calendar-notifications'; import { sweepStaleExportFiles } from './src/lib/email-export'; import { useFilterStore } from './src/stores/filter-store'; import { useVacationStore } from './src/stores/vacation-store'; @@ -281,6 +282,9 @@ export default function App() { React.useEffect(() => { void useSettingsStore.getState().hydrate(); void useLocaleStore.getState().hydrate(); + // Calendar reminders are kept scheduled from launch, not only once the + // Calendar tab has been opened. + startCalendarNotificationSync(); return useNetworkStore.getState().init(); }, []); diff --git a/src/lib/__tests__/calendar-notifications.test.ts b/src/lib/__tests__/calendar-notifications.test.ts new file mode 100644 index 00000000..d4dc3760 --- /dev/null +++ b/src/lib/__tests__/calendar-notifications.test.ts @@ -0,0 +1,217 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +type Listener = (state: Record, prev: Record) => void; + +const h = vi.hoisted(() => ({ + pending: [] as Array<{ identifier: string; content: { data: Record } }>, + permission: { granted: true, canAskAgain: true }, + auth: { hasRestoredSession: true, isAuthenticated: true, session: null as unknown }, + settings: { hydrated: true, calendarNotificationsEnabled: true, enableCalendarTasks: false }, + calendar: { + calendars: [{ id: 'cal-1', name: 'Personal' }], + events: [] as unknown[], + tasks: [] as unknown[], + taskOnlyCalendarIds: [] as string[], + loadedRange: null as unknown, + fetchCalendars: vi.fn(async () => undefined), + fetchTasks: vi.fn(async () => undefined), + }, + client: { isConnected: true, accountId: 'acc-1' }, + loadEventsInRange: vi.fn(), + calendarListener: null as Listener | null, +})); + +vi.mock('react-native', () => ({ + Platform: { OS: 'android' }, + AppState: { addEventListener: vi.fn(() => ({ remove: () => undefined })) }, +})); + +vi.mock('expo-notifications', () => ({ + getPermissionsAsync: vi.fn(async () => ({ ...h.permission })), + requestPermissionsAsync: vi.fn(async () => ({ granted: true })), + setNotificationChannelAsync: vi.fn(async () => undefined), + getAllScheduledNotificationsAsync: vi.fn(async () => h.pending), + cancelScheduledNotificationAsync: vi.fn(async () => undefined), + scheduleNotificationAsync: vi.fn(async () => 'id'), + setNotificationHandler: vi.fn(), + AndroidImportance: { HIGH: 4 }, + IosAuthorizationStatus: { PROVISIONAL: 3 }, + SchedulableTriggerInputTypes: { DATE: 'date' }, +})); + +vi.mock('../../api/jmap-client', () => ({ jmapClient: h.client })); +vi.mock('../../stores/auth-store', () => ({ + useAuthStore: { getState: () => h.auth, subscribe: vi.fn() }, +})); +vi.mock('../../stores/settings-store', () => ({ + useSettingsStore: { getState: () => h.settings, subscribe: vi.fn() }, +})); +vi.mock('../../stores/calendar-store', () => ({ + useCalendarStore: { + getState: () => h.calendar, + subscribe: vi.fn((listener: Listener) => { h.calendarListener = listener; }), + }, + loadEventsInRange: h.loadEventsInRange, +})); +vi.mock('../capabilities', () => ({ hasCalendarCapability: () => true })); + +const NOW = new Date('2026-09-24T08:00:00Z').getTime(); +const TAG = 'bulwark-calendar-alert'; + +const upcoming = { + id: 'ev1', + title: 'Standup', + calendarIds: { 'cal-1': true }, + start: '2026-09-24T12:00:00', + timeZone: 'Europe/Berlin', + utcStart: '2026-09-24T10:00:00Z', + utcEnd: '2026-09-24T10:30:00Z', + duration: 'PT30M', + alerts: { a1: { trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' }, action: 'display' } }, +}; +const upcomingKey = `ev1:a1:${new Date('2026-09-24T09:45:00Z').getTime()}`; + +function reminder(identifier: string, key: string) { + return { identifier, content: { data: { tag: TAG, key } } }; +} + +let mod: typeof import('../calendar-notifications'); +let N: typeof import('expo-notifications'); +let bus: typeof import('../state-change-bus'); + +beforeEach(async () => { + vi.useFakeTimers({ now: NOW }); + vi.resetModules(); + vi.clearAllMocks(); + h.pending = []; + h.permission = { granted: true, canAskAgain: true }; + h.auth = { hasRestoredSession: true, isAuthenticated: true, session: null }; + h.settings = { hydrated: true, calendarNotificationsEnabled: true, enableCalendarTasks: false }; + h.calendar.events = []; + h.calendar.tasks = []; + h.calendar.loadedRange = null; + h.client.isConnected = true; + h.calendarListener = null; + h.loadEventsInRange.mockResolvedValue([upcoming]); + mod = await import('../calendar-notifications'); + N = await import('expo-notifications'); + bus = await import('../state-change-bus'); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('calendar reminders (B17)', () => { + it('schedules from an upcoming window of its own, whatever month the calendar shows', async () => { + // The calendar shows another month: nothing upcoming is in the store. + h.calendar.events = [{ id: 'old', start: '2026-03-02T09:00:00' }]; + + await mod.rescheduleCalendarNotifications(); + + expect(h.loadEventsInRange).toHaveBeenCalledTimes(1); + const [calendars, ids, after, before] = h.loadEventsInRange.mock.calls[0]; + expect(calendars).toBe(h.calendar.calendars); + expect(ids).toEqual(['cal-1']); + expect(new Date(after).getTime()).toBeLessThanOrEqual(NOW); + expect(new Date(before).getTime()).toBeGreaterThanOrEqual(NOW + 7 * 24 * 3600_000); + expect(N.scheduleNotificationAsync).toHaveBeenCalledTimes(1); + const request = vi.mocked(N.scheduleNotificationAsync).mock.calls[0][0]; + expect(request.content.data).toMatchObject({ tag: TAG, key: upcomingKey }); + }); + + it('cancels only its own reminders that are no longer wanted', async () => { + h.pending = [ + reminder('keep', upcomingKey), + reminder('stale', 'gone:a1:1'), + { identifier: 'other', content: { data: { kind: 'something-else' } } }, + ]; + + await mod.rescheduleCalendarNotifications(); + + expect(N.cancelScheduledNotificationAsync).toHaveBeenCalledTimes(1); + expect(N.cancelScheduledNotificationAsync).toHaveBeenCalledWith('stale'); + // Already pending: not scheduled twice. + expect(N.scheduleNotificationAsync).not.toHaveBeenCalled(); + }); + + it('keeps the scheduled reminders when the upcoming events cannot be loaded', async () => { + h.pending = [reminder('keep', upcomingKey)]; + h.loadEventsInRange.mockRejectedValue(new Error('offline')); + + await mod.rescheduleCalendarNotifications(); + h.client.isConnected = false; + await mod.rescheduleCalendarNotifications(); + + expect(N.cancelScheduledNotificationAsync).not.toHaveBeenCalled(); + expect(h.loadEventsInRange).toHaveBeenCalledTimes(1); + }); + + it('cancels its reminders after sign-out or when switched off', async () => { + h.pending = [ + reminder('r1', upcomingKey), + { identifier: 'other', content: { data: {} } }, + ]; + h.auth = { hasRestoredSession: true, isAuthenticated: false, session: null }; + + await mod.rescheduleCalendarNotifications(); + expect(N.cancelScheduledNotificationAsync).toHaveBeenCalledTimes(1); + expect(N.cancelScheduledNotificationAsync).toHaveBeenCalledWith('r1'); + + vi.mocked(N.cancelScheduledNotificationAsync).mockClear(); + h.auth = { hasRestoredSession: true, isAuthenticated: true, session: null }; + h.settings.calendarNotificationsEnabled = false; + await mod.rescheduleCalendarNotifications(); + expect(N.cancelScheduledNotificationAsync).toHaveBeenCalledWith('r1'); + expect(h.loadEventsInRange).not.toHaveBeenCalled(); + }); + + it('asks for permission only once the calendar allows it', async () => { + h.permission = { granted: false, canAskAgain: true }; + + await mod.rescheduleCalendarNotifications(); + expect(N.requestPermissionsAsync).not.toHaveBeenCalled(); + expect(N.scheduleNotificationAsync).not.toHaveBeenCalled(); + + mod.startCalendarNotificationSync({ askPermission: true }); + await mod.rescheduleCalendarNotifications(); + expect(N.requestPermissionsAsync).toHaveBeenCalledTimes(1); + expect(N.scheduleNotificationAsync).toHaveBeenCalledTimes(1); + }); + + it('shows its reminders in the foreground and leaves other notifications alone', async () => { + mod.startCalendarNotificationSync(); + + expect(N.setNotificationHandler).toHaveBeenCalledTimes(1); + const handler = vi.mocked(N.setNotificationHandler).mock.calls[0][0]!; + const ours = await handler.handleNotification({ + request: { content: { data: { tag: TAG, key: upcomingKey } } }, + } as never); + const other = await handler.handleNotification({ + request: { content: { data: {} } }, + } as never); + expect(ours).toMatchObject({ shouldShowBanner: true, shouldShowList: true, shouldPlaySound: true }); + expect(other).toMatchObject({ shouldShowBanner: false, shouldShowList: false, shouldPlaySound: false }); + }); + + it('runs at launch and after calendar changes on the server, not when the view moves', async () => { + mod.startCalendarNotificationSync(); + await vi.advanceTimersByTimeAsync(1500); + expect(h.loadEventsInRange).toHaveBeenCalledTimes(1); + + bus.dispatchStateChange({ '@type': 'StateChange', changed: { 'acc-1': { CalendarEvent: 's2' } } }); + await vi.advanceTimersByTimeAsync(1500); + expect(h.loadEventsInRange).toHaveBeenCalledTimes(2); + + // Navigating the calendar loads another window: no reminder changes. + const prev = { events: [], tasks: h.calendar.tasks, loadedRange: { after: 'a', before: 'b' } }; + h.calendarListener!({ ...prev, events: [{}], loadedRange: { after: 'c', before: 'd' } }, prev); + await vi.advanceTimersByTimeAsync(1500); + expect(h.loadEventsInRange).toHaveBeenCalledTimes(2); + + // An edit in the same window does. + h.calendarListener!({ ...prev, events: [{}], loadedRange: { after: 'a', before: 'b' } }, prev); + await vi.advanceTimersByTimeAsync(1500); + expect(h.loadEventsInRange).toHaveBeenCalledTimes(3); + }); +}); diff --git a/src/lib/calendar-notifications.ts b/src/lib/calendar-notifications.ts index 856833e4..6c7bf290 100644 --- a/src/lib/calendar-notifications.ts +++ b/src/lib/calendar-notifications.ts @@ -1,43 +1,64 @@ -import { Platform } from 'react-native'; +import { AppState, Platform } from 'react-native'; import * as Notifications from 'expo-notifications'; -import { useCalendarStore } from '../stores/calendar-store'; +import type { CalendarEvent } from '../api/types'; +import { jmapClient } from '../api/jmap-client'; +import { useAuthStore } from '../stores/auth-store'; +import { loadEventsInRange, useCalendarStore } from '../stores/calendar-store'; import { useSettingsStore } from '../stores/settings-store'; +import { hasCalendarCapability } from './capabilities'; +import { onStateChangeType } from './state-change-bus'; import { getUpcomingAlerts, type ScheduledAlert } from './calendar-alert-scheduler'; // Local reminders for calendar events and tasks. The webmail polls // `getPendingAlerts` every minute while a tab is open; a phone is mostly -// asleep, so instead every (re)load of the calendar schedules OS-level local -// notifications for the alerts that fall due in the next few days and -// cancels the ones that no longer apply. Honours the +// asleep, so instead we schedule OS-level local notifications for the alerts +// that fall due in the next few days and cancel the ones that no longer +// apply. The events come from their own upcoming window, not from the month +// the calendar happens to show, and the sync runs from launch, so reminders +// don't depend on the Calendar tab being open. Honours the // `calendarNotificationsEnabled` setting. const CHANNEL_ID = 'calendar-reminders'; -const HORIZON_MS = 7 * 24 * 60 * 60 * 1000; +const DAY_MS = 24 * 60 * 60 * 1000; +const HORIZON_MS = 7 * DAY_MS; +// The events loaded for the horizon: from a day back (an end-relative alert +// of an event that already started) to a week past it (alerts set up to a +// week before the start). +const LOOKBEHIND_MS = DAY_MS; +const LOOKAHEAD_MS = 7 * DAY_MS; // iOS keeps at most 64 pending local notifications per app; leave room for // the rest of the app. const MAX_SCHEDULED = 48; const DATA_TAG = 'bulwark-calendar-alert'; -let permissionGranted: boolean | null = null; +let permissionGranted = false; +let permissionRequested = false; +// Only the Calendar tab asks for notification permission; the launch-time +// sync uses what was granted and never prompts out of the blue. +let mayAskPermission = false; let channelReady = false; let rescheduleTimer: ReturnType | null = null; let rescheduling: Promise | null = null; let queued = false; let syncStarted = false; -let lastScheduledKeys: string[] = []; +let tasksLoadedFor: string | null = null; + +function isGranted(status: Notifications.NotificationPermissionsStatus): boolean { + return status.granted || status.ios?.status === Notifications.IosAuthorizationStatus.PROVISIONAL; +} async function ensurePermission(): Promise { - if (permissionGranted !== null) return permissionGranted; + if (permissionGranted) return true; try { const current = await Notifications.getPermissionsAsync(); - let granted = current.granted || current.ios?.status === Notifications.IosAuthorizationStatus.PROVISIONAL; - if (!granted && current.canAskAgain) { - const requested = await Notifications.requestPermissionsAsync(); - granted = requested.granted || requested.ios?.status === Notifications.IosAuthorizationStatus.PROVISIONAL; + let granted = isGranted(current); + if (!granted && current.canAskAgain && mayAskPermission && !permissionRequested) { + permissionRequested = true; + granted = isGranted(await Notifications.requestPermissionsAsync()); } - permissionGranted = !!granted; + permissionGranted = granted; } catch { - permissionGranted = false; + return false; } return permissionGranted; } @@ -74,7 +95,6 @@ export async function cancelAllCalendarNotifications(): Promise { } catch { // Nothing to do; the OS keeps whatever it has. } - lastScheduledKeys = []; } async function scheduleOne(alert: ScheduledAlert): Promise { @@ -93,10 +113,59 @@ async function scheduleOne(alert: ScheduledAlert): Promise { }); } +function isSignedOut(): boolean { + const { hasRestoredSession, isAuthenticated } = useAuthStore.getState(); + return hasRestoredSession && !isAuthenticated; +} + /** - * Re-derive the reminders from the store and reconcile them with what the - * OS has pending: cancel alerts that vanished (event deleted, reminder - * removed, task completed) and add the new ones. Concurrent calls coalesce. + * The events whose alerts can fire within the horizon, straight from the + * server. `null` when that can't be known (offline, no session yet, the + * request failed): the caller then leaves the scheduled reminders alone + * instead of cancelling what it can't re-derive. + */ +async function loadUpcomingEvents(now: number): Promise { + if (!jmapClient.isConnected) return null; + if (!hasCalendarCapability()) return []; + if (useCalendarStore.getState().calendars.length === 0) { + await useCalendarStore.getState().fetchCalendars(); + } + const { calendars } = useCalendarStore.getState(); + if (calendars.length === 0) return null; + try { + return await loadEventsInRange( + calendars, + calendars.map((c) => c.id), + new Date(now - LOOKBEHIND_MS).toISOString(), + new Date(now + HORIZON_MS + LOOKAHEAD_MS).toISOString(), + ); + } catch { + return null; + } +} + +// Task reminders read the store's tasks, which the Calendar tab loads. When +// it hasn't, and tasks are switched on, scan them once per account. +async function ensureTasksLoaded(): Promise { + let accountId: string; + try { + accountId = jmapClient.accountId; + } catch { + return; + } + if (tasksLoadedFor === accountId) return; + tasksLoadedFor = accountId; + const { tasks, taskOnlyCalendarIds, fetchTasks } = useCalendarStore.getState(); + if (tasks.length > 0 || taskOnlyCalendarIds.length > 0) return; + if (!useSettingsStore.getState().enableCalendarTasks) return; + await fetchTasks(); +} + +/** + * Derive the reminders from the upcoming window and reconcile them with + * what the OS has pending: cancel the reminders this code scheduled that are + * no longer wanted (event deleted or moved, reminder removed, task + * completed) and add the new ones. Concurrent calls coalesce. */ export async function rescheduleCalendarNotifications(): Promise { if (rescheduling) { @@ -105,17 +174,24 @@ export async function rescheduleCalendarNotifications(): Promise { } rescheduling = (async () => { try { - const enabled = useSettingsStore.getState().calendarNotificationsEnabled; - if (!enabled) { - if (lastScheduledKeys.length > 0) await cancelAllCalendarNotifications(); + const settings = useSettingsStore.getState(); + // Until the settings load, the switch holds its default, not the + // user's choice. + if (!settings.hydrated) return; + if (!settings.calendarNotificationsEnabled || isSignedOut()) { + await cancelAllCalendarNotifications(); return; } if (!(await ensurePermission())) return; await ensureChannel(); - const { events, tasks, calendars } = useCalendarStore.getState(); + const now = Date.now(); + const events = await loadUpcomingEvents(now); + if (!events) return; + await ensureTasksLoaded(); + const { tasks, calendars } = useCalendarStore.getState(); const wanted = getUpcomingAlerts(events, tasks, calendars, { - now: Date.now(), + now, horizonMs: HORIZON_MS, limit: MAX_SCHEDULED, }); @@ -140,7 +216,6 @@ export async function rescheduleCalendarNotifications(): Promise { // A single bad trigger must not block the rest. } } - lastScheduledKeys = [...wantedByKey.keys()]; } catch { // Notifications are best-effort. } finally { @@ -156,34 +231,69 @@ export async function rescheduleCalendarNotifications(): Promise { function scheduleSoon(): void { if (rescheduleTimer) clearTimeout(rescheduleTimer); - // Debounce: a refresh sets events and tasks in two steps. + // Debounce: a refresh sets events and tasks in two steps, and a push + // reaches both the store and the state-change bus. rescheduleTimer = setTimeout(() => { rescheduleTimer = null; void rescheduleCalendarNotifications(); }, 1500); } +// expo-notifications drops a notification that fires while the app is in +// the foreground unless a handler asks for it. Only calendar reminders go +// through expo-notifications (mail push is posted by the native messaging +// services), so show those and leave anything else as it was. +function installForegroundHandler(): void { + Notifications.setNotificationHandler({ + handleNotification: async (notification) => { + const show = alertKeyOf(notification.request) !== null; + return { shouldShowBanner: show, shouldShowList: show, shouldPlaySound: show, shouldSetBadge: false }; + }, + }); +} + /** - * Keep local reminders in sync with the calendar store and the notification - * setting for the rest of the session. Idempotent; call once the calendar - * has been shown. + * Keep local reminders in sync for the rest of the session: at launch, + * sign-in and account switch, when calendar data changes on the server or + * in the app, when the app comes back to the foreground, and when the + * setting flips. Idempotent. `askPermission` lets the sync ask for + * notification permission; the Calendar tab passes it. */ -export function startCalendarNotificationSync(): void { +export function startCalendarNotificationSync(options?: { askPermission?: boolean }): void { + if (options?.askPermission && !mayAskPermission) { + mayAskPermission = true; + if (syncStarted) scheduleSoon(); + } if (syncStarted) return; syncStarted = true; - let prev = useCalendarStore.getState(); - useCalendarStore.subscribe((state) => { - if (state.events !== prev.events || state.tasks !== prev.tasks || state.calendars !== prev.calendars) { - prev = state; + installForegroundHandler(); + // Edits in the app, a refresh after a push, tasks loading. Moving the + // calendar to another window changes no reminder. + useCalendarStore.subscribe((state, prev) => { + const sameWindow = + state.loadedRange?.after === prev.loadedRange?.after && + state.loadedRange?.before === prev.loadedRange?.before; + if (state.tasks !== prev.tasks || (state.events !== prev.events && sameWindow)) { scheduleSoon(); } }); - let prevEnabled = useSettingsStore.getState().calendarNotificationsEnabled; - useSettingsStore.subscribe((state) => { - if (state.calendarNotificationsEnabled !== prevEnabled) { - prevEnabled = state.calendarNotificationsEnabled; + useSettingsStore.subscribe((state, prev) => { + if ( + state.calendarNotificationsEnabled !== prev.calendarNotificationsEnabled || + state.hydrated !== prev.hydrated + ) { scheduleSoon(); } }); + useAuthStore.subscribe((state, prev) => { + if (state.session !== prev.session || state.isAuthenticated !== prev.isAuthenticated) { + scheduleSoon(); + } + }); + onStateChangeType('CalendarEvent', scheduleSoon); + onStateChangeType('Calendar', scheduleSoon); + AppState.addEventListener('change', (state) => { + if (state === 'active') scheduleSoon(); + }); scheduleSoon(); } diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx index 918bb00b..302833e8 100644 --- a/src/screens/CalendarScreen.tsx +++ b/src/screens/CalendarScreen.tsx @@ -340,9 +340,9 @@ export default function CalendarScreen() { React.useEffect(() => { void hydrate(); void fetchCalendarsAction(); - // Local reminders follow the store from here on (honours the - // notifications setting; cancelled/acknowledged alerts are skipped). - startCalendarNotificationSync(); + // Reminders already sync from launch; opening the calendar may also ask + // for notification permission. + startCalendarNotificationSync({ askPermission: true }); }, [hydrate, fetchCalendarsAction]); // Refresh iCal subscriptions whose interval elapsed: on mount and whenever diff --git a/src/stores/calendar-store.ts b/src/stores/calendar-store.ts index d180442f..98626ac4 100644 --- a/src/stores/calendar-store.ts +++ b/src/stores/calendar-store.ts @@ -230,6 +230,62 @@ function unionRange(loaded: LoadedRange, after: string, before: string): LoadedR }; } +/** + * The events of the given calendars (store ids) that overlap [after, before], + * mapped to store ids and with recurring series expanded over the window. + * Doesn't touch the store: fetchEvents keeps the result as the visible + * window, the reminders load their own upcoming window with it. Throws when + * the primary account fails; a failing shared account is skipped. + */ +export async function loadEventsInRange( + calendars: Calendar[], + calendarIds: string[], + after: string, + before: string, +): Promise { + // Group the requested calendars by owning account: the primary account + // (calendars without an accountId tag) plus one group per shared + // account, since CalendarEvent/query is scoped to a single account. The + // incoming ids are store ids (shared calendars are namespaced + // `${accountId}:${id}`); map them back to the raw server ids the query + // filter expects. + const byId = new Map(calendars.map((c) => [c.id, c])); + const groups = new Map(); + for (const id of calendarIds) { + const cal = byId.get(id); + const accountId = cal?.accountId; + const serverId = cal?.originalId || id; + const group = groups.get(accountId); + if (group) group.push(serverId); + else groups.set(accountId, [serverId]); + } + if (groups.size === 0) groups.set(undefined, []); + + const raw: CalendarEvent[] = []; + for (const [accountId, ids] of groups) { + if (accountId && isCalendarAccessDenied(accountId)) continue; + try { + // The window is sent as after/before so accounts with more than + // 1000 objects don't silently lose events and navigating past the + // loaded range doesn't re-download everything. + const eventIds = (await queryEvents(ids, after, before, accountId)) ?? []; + if (eventIds.length === 0) continue; + const fetched = (await fetchEvents(eventIds, accountId)) ?? []; + raw.push(...fetched.map((e) => mapServerEventToStoreEvent(e, calendars, accountId))); + } catch (err) { + // A failing shared account must not hide the user's own events; + // remember an access rejection so it isn't re-probed every fetch. + if (!accountId) throw err; + noteCalendarAccessError(accountId, err); + } + } + // Stalwart returns both Events and Tasks from CalendarEvent/query. Tasks + // (also CalDAV ones without an `@type`) are surfaced by fetchTasks so + // they don't pollute the grid. + const onlyEvents = raw.filter((e) => !isTaskLikeObject(e) && !!e.start); + return expandRecurringEvents(onlyEvents, after, before); +} + export const useCalendarStore = create()( persist( (set, get) => ({ @@ -287,48 +343,7 @@ export const useCalendarStore = create()( if (!jmapClient.isConnected) return; set({ loading: true, error: null }); try { - // Group the requested calendars by owning account: the primary account - // (calendars without an accountId tag) plus one group per shared - // account, since CalendarEvent/query is scoped to a single account. The - // incoming ids are store ids (shared calendars are namespaced - // `${accountId}:${id}`); map them back to the raw server ids the query - // filter expects. - const calendars = get().calendars; - const byId = new Map(calendars.map((c) => [c.id, c])); - const groups = new Map(); - for (const id of calendarIds) { - const cal = byId.get(id); - const accountId = cal?.accountId; - const serverId = cal?.originalId || id; - const group = groups.get(accountId); - if (group) group.push(serverId); - else groups.set(accountId, [serverId]); - } - if (groups.size === 0) groups.set(undefined, []); - - const raw: CalendarEvent[] = []; - for (const [accountId, ids] of groups) { - if (accountId && isCalendarAccessDenied(accountId)) continue; - try { - // The window is sent as after/before so accounts with more than - // 1000 objects don't silently lose events and navigating past the - // loaded range doesn't re-download everything. - const eventIds = (await queryEvents(ids, after, before, accountId)) ?? []; - if (eventIds.length === 0) continue; - const fetched = (await fetchEvents(eventIds, accountId)) ?? []; - raw.push(...fetched.map((e) => mapServerEventToStoreEvent(e, calendars, accountId))); - } catch (err) { - // A failing shared account must not hide the user's own events; - // remember an access rejection so it isn't re-probed every fetch. - if (!accountId) throw err; - noteCalendarAccessError(accountId, err); - } - } - // Stalwart returns both Events and Tasks from CalendarEvent/query. Tasks - // (also CalDAV ones without an `@type`) are surfaced by fetchTasks so - // they don't pollute the grid. - const onlyEvents = raw.filter((e) => !isTaskLikeObject(e) && !!e.start); - const events = expandRecurringEvents(onlyEvents, after, before); + const events = await loadEventsInRange(get().calendars, calendarIds, after, before); set({ events, loadedRange: { after, before }, loading: false }); } catch (err) { set({ loading: false, error: err instanceof Error ? err.message : 'Failed to load events' });