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
This commit is contained in:
Linus Rath
2026-09-24 19:12:02 +02:00
parent 6a975b822c
commit 4b903ed179
5 changed files with 429 additions and 83 deletions
+4
View File
@@ -11,6 +11,7 @@ import { startLiveUpdates, type LiveUpdatesHandle } from './src/api/push-stream'
import { jmapClient } from './src/api/jmap-client'; import { jmapClient } from './src/api/jmap-client';
import type { StateChange } from './src/api/types'; import type { StateChange } from './src/api/types';
import { dispatchStateChange } from './src/lib/state-change-bus'; import { dispatchStateChange } from './src/lib/state-change-bus';
import { startCalendarNotificationSync } from './src/lib/calendar-notifications';
import { sweepStaleExportFiles } from './src/lib/email-export'; import { sweepStaleExportFiles } from './src/lib/email-export';
import { useFilterStore } from './src/stores/filter-store'; import { useFilterStore } from './src/stores/filter-store';
import { useVacationStore } from './src/stores/vacation-store'; import { useVacationStore } from './src/stores/vacation-store';
@@ -281,6 +282,9 @@ export default function App() {
React.useEffect(() => { React.useEffect(() => {
void useSettingsStore.getState().hydrate(); void useSettingsStore.getState().hydrate();
void useLocaleStore.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(); return useNetworkStore.getState().init();
}, []); }, []);
@@ -0,0 +1,217 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
type Listener = (state: Record<string, unknown>, prev: Record<string, unknown>) => void;
const h = vi.hoisted(() => ({
pending: [] as Array<{ identifier: string; content: { data: Record<string, unknown> } }>,
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);
});
});
+148 -38
View File
@@ -1,43 +1,64 @@
import { Platform } from 'react-native'; import { AppState, Platform } from 'react-native';
import * as Notifications from 'expo-notifications'; 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 { useSettingsStore } from '../stores/settings-store';
import { hasCalendarCapability } from './capabilities';
import { onStateChangeType } from './state-change-bus';
import { getUpcomingAlerts, type ScheduledAlert } from './calendar-alert-scheduler'; import { getUpcomingAlerts, type ScheduledAlert } from './calendar-alert-scheduler';
// Local reminders for calendar events and tasks. The webmail polls // Local reminders for calendar events and tasks. The webmail polls
// `getPendingAlerts` every minute while a tab is open; a phone is mostly // `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 // asleep, so instead we schedule OS-level local notifications for the alerts
// notifications for the alerts that fall due in the next few days and // that fall due in the next few days and cancel the ones that no longer
// cancels the ones that no longer apply. Honours the // 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. // `calendarNotificationsEnabled` setting.
const CHANNEL_ID = 'calendar-reminders'; 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 // iOS keeps at most 64 pending local notifications per app; leave room for
// the rest of the app. // the rest of the app.
const MAX_SCHEDULED = 48; const MAX_SCHEDULED = 48;
const DATA_TAG = 'bulwark-calendar-alert'; 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 channelReady = false;
let rescheduleTimer: ReturnType<typeof setTimeout> | null = null; let rescheduleTimer: ReturnType<typeof setTimeout> | null = null;
let rescheduling: Promise<void> | null = null; let rescheduling: Promise<void> | null = null;
let queued = false; let queued = false;
let syncStarted = 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<boolean> { async function ensurePermission(): Promise<boolean> {
if (permissionGranted !== null) return permissionGranted; if (permissionGranted) return true;
try { try {
const current = await Notifications.getPermissionsAsync(); const current = await Notifications.getPermissionsAsync();
let granted = current.granted || current.ios?.status === Notifications.IosAuthorizationStatus.PROVISIONAL; let granted = isGranted(current);
if (!granted && current.canAskAgain) { if (!granted && current.canAskAgain && mayAskPermission && !permissionRequested) {
const requested = await Notifications.requestPermissionsAsync(); permissionRequested = true;
granted = requested.granted || requested.ios?.status === Notifications.IosAuthorizationStatus.PROVISIONAL; granted = isGranted(await Notifications.requestPermissionsAsync());
} }
permissionGranted = !!granted; permissionGranted = granted;
} catch { } catch {
permissionGranted = false; return false;
} }
return permissionGranted; return permissionGranted;
} }
@@ -74,7 +95,6 @@ export async function cancelAllCalendarNotifications(): Promise<void> {
} catch { } catch {
// Nothing to do; the OS keeps whatever it has. // Nothing to do; the OS keeps whatever it has.
} }
lastScheduledKeys = [];
} }
async function scheduleOne(alert: ScheduledAlert): Promise<void> { async function scheduleOne(alert: ScheduledAlert): Promise<void> {
@@ -93,10 +113,59 @@ async function scheduleOne(alert: ScheduledAlert): Promise<void> {
}); });
} }
function isSignedOut(): boolean {
const { hasRestoredSession, isAuthenticated } = useAuthStore.getState();
return hasRestoredSession && !isAuthenticated;
}
/** /**
* Re-derive the reminders from the store and reconcile them with what the * The events whose alerts can fire within the horizon, straight from the
* OS has pending: cancel alerts that vanished (event deleted, reminder * server. `null` when that can't be known (offline, no session yet, the
* removed, task completed) and add the new ones. Concurrent calls coalesce. * 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<CalendarEvent[] | null> {
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<void> {
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<void> { export async function rescheduleCalendarNotifications(): Promise<void> {
if (rescheduling) { if (rescheduling) {
@@ -105,17 +174,24 @@ export async function rescheduleCalendarNotifications(): Promise<void> {
} }
rescheduling = (async () => { rescheduling = (async () => {
try { try {
const enabled = useSettingsStore.getState().calendarNotificationsEnabled; const settings = useSettingsStore.getState();
if (!enabled) { // Until the settings load, the switch holds its default, not the
if (lastScheduledKeys.length > 0) await cancelAllCalendarNotifications(); // user's choice.
if (!settings.hydrated) return;
if (!settings.calendarNotificationsEnabled || isSignedOut()) {
await cancelAllCalendarNotifications();
return; return;
} }
if (!(await ensurePermission())) return; if (!(await ensurePermission())) return;
await ensureChannel(); 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, { const wanted = getUpcomingAlerts(events, tasks, calendars, {
now: Date.now(), now,
horizonMs: HORIZON_MS, horizonMs: HORIZON_MS,
limit: MAX_SCHEDULED, limit: MAX_SCHEDULED,
}); });
@@ -140,7 +216,6 @@ export async function rescheduleCalendarNotifications(): Promise<void> {
// A single bad trigger must not block the rest. // A single bad trigger must not block the rest.
} }
} }
lastScheduledKeys = [...wantedByKey.keys()];
} catch { } catch {
// Notifications are best-effort. // Notifications are best-effort.
} finally { } finally {
@@ -156,34 +231,69 @@ export async function rescheduleCalendarNotifications(): Promise<void> {
function scheduleSoon(): void { function scheduleSoon(): void {
if (rescheduleTimer) clearTimeout(rescheduleTimer); 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 = setTimeout(() => {
rescheduleTimer = null; rescheduleTimer = null;
void rescheduleCalendarNotifications(); void rescheduleCalendarNotifications();
}, 1500); }, 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 * Keep local reminders in sync for the rest of the session: at launch,
* setting for the rest of the session. Idempotent; call once the calendar * sign-in and account switch, when calendar data changes on the server or
* has been shown. * 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; if (syncStarted) return;
syncStarted = true; syncStarted = true;
let prev = useCalendarStore.getState(); installForegroundHandler();
useCalendarStore.subscribe((state) => { // Edits in the app, a refresh after a push, tasks loading. Moving the
if (state.events !== prev.events || state.tasks !== prev.tasks || state.calendars !== prev.calendars) { // calendar to another window changes no reminder.
prev = state; 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(); scheduleSoon();
} }
}); });
let prevEnabled = useSettingsStore.getState().calendarNotificationsEnabled; useSettingsStore.subscribe((state, prev) => {
useSettingsStore.subscribe((state) => { if (
if (state.calendarNotificationsEnabled !== prevEnabled) { state.calendarNotificationsEnabled !== prev.calendarNotificationsEnabled ||
prevEnabled = state.calendarNotificationsEnabled; state.hydrated !== prev.hydrated
) {
scheduleSoon(); 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(); scheduleSoon();
} }
+3 -3
View File
@@ -340,9 +340,9 @@ export default function CalendarScreen() {
React.useEffect(() => { React.useEffect(() => {
void hydrate(); void hydrate();
void fetchCalendarsAction(); void fetchCalendarsAction();
// Local reminders follow the store from here on (honours the // Reminders already sync from launch; opening the calendar may also ask
// notifications setting; cancelled/acknowledged alerts are skipped). // for notification permission.
startCalendarNotificationSync(); startCalendarNotificationSync({ askPermission: true });
}, [hydrate, fetchCalendarsAction]); }, [hydrate, fetchCalendarsAction]);
// Refresh iCal subscriptions whose interval elapsed: on mount and whenever // Refresh iCal subscriptions whose interval elapsed: on mount and whenever
+57 -42
View File
@@ -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<CalendarEvent[]> {
// 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<string | undefined, string[]>();
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<CalendarState>()( export const useCalendarStore = create<CalendarState>()(
persist( persist(
(set, get) => ({ (set, get) => ({
@@ -287,48 +343,7 @@ export const useCalendarStore = create<CalendarState>()(
if (!jmapClient.isConnected) return; if (!jmapClient.isConnected) return;
set({ loading: true, error: null }); set({ loading: true, error: null });
try { try {
// Group the requested calendars by owning account: the primary account const events = await loadEventsInRange(get().calendars, calendarIds, after, before);
// (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<string | undefined, string[]>();
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);
set({ events, loadedRange: { after, before }, loading: false }); set({ events, loadedRange: { after, before }, loading: false });
} catch (err) { } catch (err) {
set({ loading: false, error: err instanceof Error ? err.message : 'Failed to load events' }); set({ loading: false, error: err instanceof Error ? err.message : 'Failed to load events' });