From 8e1d03c7464e9438bcd6fa9972cfcd26a23a1058 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sat, 29 Aug 2026 18:41:06 +0200
Subject: [PATCH] feat: fire calendar and task reminders as local notifications
---
docs/parity/05-calendar.md | 22 +-
src/api/types.ts | 11 +-
.../calendar-alert-scheduler.test.ts | 106 ++++++++++
src/lib/calendar-alert-scheduler.ts | 179 +++++++++++++++++
src/lib/calendar-notifications.ts | 189 ++++++++++++++++++
src/screens/CalendarScreen.tsx | 4 +
6 files changed, 498 insertions(+), 13 deletions(-)
create mode 100644 src/lib/__tests__/calendar-alert-scheduler.test.ts
create mode 100644 src/lib/calendar-alert-scheduler.ts
create mode 100644 src/lib/calendar-notifications.ts
diff --git a/docs/parity/05-calendar.md b/docs/parity/05-calendar.md
index 17db775c..ab4d26a8 100644
--- a/docs/parity/05-calendar.md
+++ b/docs/parity/05-calendar.md
@@ -125,7 +125,7 @@ RN has a solid read path (month/week/agenda, shared-calendar namespacing, per-vi
- What RN does: `calendars.find(cal => !cal.myRights || cal.myRights.mayWrite !== false)` (`src/components/email/CalendarInvitationBanner.tsx:96`) and `parseCalendarBlob(attachment.blobId)` on `jmapClient.accountId` (`:59`, `src/api/calendar.ts:355-372`). RN shows only the active account's mailboxes, so #847/#867 are largely N/A; the "first writable" pick is wrong when a subscription calendar sorts first.
- Fix hint: prefer `cal.isDefault && !cal.isShared`, exclude subscription/birthday calendars; pass `email.accountId` (if RN exposes shared mailboxes later) to `parseCalendarBlob`.
-- [ ] **Organizer not identified in the participant list; no "invited by", status counts or cancelled/tentative rendering** — `P3` — `partial`
+- [x] **Organizer not identified in the participant list; no "invited by", status counts or cancelled/tentative rendering** — `P3` — `partial` — fixed in 5dc4070 + 0f80e98 (organizer label, cancelled strike-through in sheet/card/month chips, tentative badge on cards; week-view blocks are untouched)
- What WEB does: `getParticipantList` marks the organizer (also via `organizerCalendarAddress`, #731), the popover shows "(organizer)", strikes through `status === 'cancelled'` and badges `tentative` (ref `lib/calendar-participants.ts:126-157`, `components/calendar/event-detail-popover.tsx:299-312, 445-449`, changelog 1.7.2 #572).
- What RN does: participants listed with status dots only (`src/components/calendar/EventDetailSheet.tsx:298-325`); no cancelled/tentative styling in sheet, `EventCard` or views.
- Fix hint: port `getParticipantList`/`getStatusCounts`; add `line-through` when `event.status === 'cancelled'` in `EventCard`, `WeekView`, `EventDetailSheet`.
@@ -142,7 +142,7 @@ RN has a solid read path (month/week/agenda, shared-calendar namespacing, per-vi
- What RN does: `canRecolor = !!onSetColor && !!cal.isShared` (`CalendarSidebarDrawer.tsx:209`).
- Fix hint: for `!cal.isShared` call the new `updateCalendar(id, { color })`.
-- [ ] **Shared-calendar colour key differs from WEB (not portable, but internally consistent)** — `P3` — `partial`
+- [x] **Shared-calendar colour key differs from WEB (not portable, but internally consistent)** — `P3` — `partial` — fixed in 0f80e98
- What WEB does: key = `${localAccountId}|${accountId}|${originalId}` (ref `lib/shared-calendar-colors.ts:33-40`).
- What RN does: key = `${accountId}|${id}` where `id` is already the namespaced `${accountId}:${raw}` (`src/lib/calendar-utils.ts:474-478`). Works, but the account id is duplicated in the key and would break if the namespacing format changes.
- Fix hint: use `cal.originalId ?? cal.id` like WEB.
@@ -174,42 +174,42 @@ RN has a solid read path (month/week/agenda, shared-calendar namespacing, per-vi
### Views / navigation / locale
-- [ ] **Entire calendar UI is hard-coded English although RN has i18n** — `P2` — `missing`
+- [ ] **Entire calendar UI is hard-coded English although RN has i18n** — `P2` — `missing` — partial in 0f80e98: screen, month/week/agenda views, event card/sheet/modal, drawer, scope dialog, settings, banner and calendar/share sheets use `t()` + date-fns locales; deferred: TasksSheet, ICalImportSheet, ICalSubscriptionSheet, RecurrenceEditor and `formatReminder` still hard-code English
- What WEB does: everything through `next-intl` (`t('calendar.*')`), month/day names and popover dates localized (changelog 1.6.x "Localize event start date in detail popover and event modal").
- What RN does: `src/i18n/index.ts` + `useLocaleStore` are used by 18 other files, but none of `src/components/calendar/*`, `src/screens/CalendarScreen.tsx` or `CalendarSettings.tsx` import it; all labels ("Today", "No events", "Does not repeat", "Going?", RSVP labels, drawer titles, etc.) and `date-fns` `format()` calls without a `locale` are English (`CalendarScreen.tsx:408, 448, 504, 640-641`, `EventModal.tsx:60-67, 326, ...`, `MonthView.tsx:25-26`, `AgendaView.tsx:33-37`).
- Fix hint: add a `calendar.*` namespace to `locales/*.json` (WEB's keys can be copied) and pass a date-fns locale from the locale store into `format()`.
-- [ ] **First day of week limited to Monday/Sunday; no Saturday, no Jalali (#490)** — `P3` — `missing`
+- [ ] **First day of week limited to Monday/Sunday; no Saturday, no Jalali (#490)** — `P3` — `missing` — Saturday (0|1|6) done in 0f80e98; deferred: Jalali month grid needs a port of `lib/jalali-utils.ts` + `useCalendarLocale`
- What WEB does: `firstDayOfWeek` 0/1/6 with Saturday default for Persian and a full Jalali month grid / headers (ref `hooks/use-calendar-locale.ts:35-120`, `lib/jalali-utils.ts:161-169`, changelog 1.8.0 #490).
- What RN does: `FirstDayOfWeek = 0 | 1` (`src/stores/settings-store.ts:76`), `MonthView`/`WeekView` typed `0 | 1` (`MonthView.tsx:34`, `WeekView.tsx:40`), no Jalali.
- Fix hint: widen the type to `0|1|6`, add Saturday to `CalendarSettings`; Jalali is a larger port of `jalali-utils.ts` + a `useCalendarLocale` equivalent (only if the fa locale is added to RN).
-- [ ] **Week numbers are not ISO when the week starts on Monday; no week numbers in week view** — `P3` — `rn-only-bug`
+- [x] **Week numbers are not ISO when the week starts on Monday; no week numbers in week view** — `P3` — `rn-only-bug` — fixed in 0f80e98 (month view; the week view header has no room for it)
- What WEB does: `getISOWeek` when `weekStartsOn === 1`, else `getWeek(..., { weekStartsOn: 0 })` (ref `components/calendar/mini-calendar.tsx:74-79`).
- What RN does: `getWeek(row[0], { weekStartsOn })` (`src/components/calendar/MonthView.tsx:137`) — without `firstWeekContainsDate: 4` this is not ISO numbering and can be off by one around New Year. WeekView has no week number.
- Fix hint: use `getISOWeek` for Monday start; optionally show the number in the WeekView header row.
-- [ ] **"Show time in month view" setting is ignored (#666)** — `P3` — `bugfix-parity`
+- [x] **"Show time in month view" setting is ignored (#666)** — `P3` — `bugfix-parity` — fixed in 0f80e98
- What WEB does: on mobile, `showChips = !isMobile || showTimeInMonthView` renders event chips with times instead of dots (ref `components/calendar/calendar-month-view.tsx:49-52`, `event-card.tsx:80, 178`, changelog 1.8.0 #666).
- What RN does: setting exists (`src/stores/settings-store.ts:169`, `CalendarSettings.tsx:435-443`) but `MonthView` always draws up to 3 dots (`src/components/calendar/MonthView.tsx:83-86`). RN affected.
- Fix hint: pass the setting into `MonthView` and render a compact chip (title + start time) per event when on.
-- [ ] **Hover-preview setting exposed on mobile** — `P3` — `rn-only-bug`
+- [x] **Hover-preview setting exposed on mobile** — `P3` — `rn-only-bug` — fixed in 0f80e98 (store key kept for settings-sync compatibility)
- What WEB does: hover preview is a desktop pointer feature.
- What RN does: `CalendarSettings` shows "Hover preview" with delay options and nothing consumes `calendarHoverPreview` (`src/components/settings/CalendarSettings.tsx:87-102`; no other reference in `src/`).
- Fix hint: remove the setting row (keep the store key for settings-sync compatibility or drop it).
-- [ ] **No day view (falls back to agenda); tapping a month day does not open the day** — `P3` — `partial`
+- [x] **No day view (falls back to agenda); tapping a month day does not open the day** — `P3` — `partial` — fixed in 0f80e98 ("Day" hidden from the mobile default-view options; a synced "day" shows as Agenda)
- What WEB does: day view with time grid; on mobile, tapping a date in month view switches to day view and offers "back to month" (ref `components/calendar/calendar-app.tsx:461-470`, `calendar-day-view.tsx`).
- What RN does: `calendarDefaultView === 'day'` maps to agenda (`src/screens/CalendarScreen.tsx:131-137`); month view shows a list below the grid instead. Acceptable UX, but the settings option "Day" is misleading.
- Fix hint: either reuse `WeekView` with a single-day `weekDays` array as a day grid, or hide "Day" from the RN default-view options.
-- [ ] **Week view: event block time label ignores 12h setting; timed grid has no long-press-at-minute precision** — `P3` — `partial`
+- [x] **Week view: event block time label ignores 12h setting; timed grid has no long-press-at-minute precision** — `P3` — `partial` — fixed in 0f80e98
- What WEB does: `formatSnapTime(minutes, timeFormat)` (ref `lib/calendar-utils.ts:278`).
- What RN does: `minutesToTimeLabel` always `HH:mm` (`src/components/calendar/WeekView.tsx:315-319`) while the gutter honours 12h (`:229-231`). Long-press creates at the whole hour (`:123-128`), fine for mobile.
- Fix hint: use `timePattern(timeFormat)` with `format()`.
-- [ ] **EventCard shows the description under a location (MapPin) icon and never shows the location** — `P3` — `rn-only-bug`
+- [x] **EventCard shows the description under a location (MapPin) icon and never shows the location** — `P3` — `rn-only-bug` — fixed in 0f80e98
- What WEB does: card shows time, location and calendar (ref `components/calendar/event-card.tsx`).
- What RN does: `event.description` rendered with `` (`src/components/calendar/EventCard.tsx:65-72`); `locations` is not read.
- Fix hint: show `Object.values(event.locations)[0]?.name` with MapPin; drop or move the description.
@@ -272,7 +272,7 @@ RN has a solid read path (month/week/agenda, shared-calendar namespacing, per-vi
### Settings
-- [ ] **No custom time-zone setting (#755); no birthday-calendar colour; no "Day" view on mobile (see above)** — `P3` — `missing`
+- [ ] **No custom time-zone setting (#755); no birthday-calendar colour; no "Day" view on mobile (see above)** — `P3` — `missing` — time-zone setting (`calendarTimeZone`, used for saved events and JMAP queries) done in 58a6f45 + 0f80e98; deferred: birthday-calendar colour setting
- What WEB does: `timeZone: 'auto' | IANA` overriding browser detection, used for display (`displayNow`, `toDisplayDate`) and for the `timeZone` on saved events (ref `lib/timezone.ts:29-75`, `stores/settings-store.ts:317, 553`, changelog 1.9.2 #755); `birthdayCalendarColor` (`settings-store.ts:377, 609`).
- What RN does: device zone only (`src/api/calendar.ts:15-21`); birthday colour fixed (`src/lib/birthday-calendar.ts:7`).
- Fix hint: add `timeZone` to the settings store (synced with WEB's key so it round-trips through settings sync), pass it as the JMAP `timeZone` arg and into saved events; convert display via `Intl` like WEB's `getWallClock`.
diff --git a/src/api/types.ts b/src/api/types.ts
index 6cd2b96c..3ce782c0 100644
--- a/src/api/types.ts
+++ b/src/api/types.ts
@@ -464,8 +464,11 @@ export interface RecurrenceRule {
}
export interface Alert {
- trigger: { '@type': string; offset?: string; when?: string };
+ '@type'?: 'Alert';
+ trigger: { '@type': string; offset?: string; when?: string; relativeTo?: 'start' | 'end' | string };
action?: string;
+ // UTCDateTime the user acknowledged the alert; null/absent when pending.
+ acknowledged?: string | null;
}
export interface CalendarEvent {
@@ -509,7 +512,8 @@ export interface CalendarEvent {
updated?: string;
// Task-only (RFC 8984 JSTask) fields.
progress?: 'needs-action' | 'in-process' | 'completed' | 'failed' | 'cancelled' | string;
- due?: string;
+ progressUpdated?: string;
+ due?: string | null;
priority?: number;
percentComplete?: number;
color?: string;
@@ -546,6 +550,9 @@ export interface Calendar {
myRights?: CalendarRights;
// Principals this (owned) calendar is shared with and their rights.
shareWith?: Record | null;
+ // Alerts applied to events that set `useDefaultAlerts` (RFC 8620 calendars).
+ defaultAlertsWithTime?: Record | null;
+ defaultAlertsWithoutTime?: Record | null;
// Client-only: JMAP account the calendar was fetched from. Absent for the
// primary account; set for calendars shared with the user.
accountId?: string;
diff --git a/src/lib/__tests__/calendar-alert-scheduler.test.ts b/src/lib/__tests__/calendar-alert-scheduler.test.ts
new file mode 100644
index 00000000..2601145a
--- /dev/null
+++ b/src/lib/__tests__/calendar-alert-scheduler.test.ts
@@ -0,0 +1,106 @@
+import { describe, it, expect } from 'vitest';
+import type { Calendar, CalendarEvent } from '../../api/types';
+import {
+ computeFireTime,
+ computeTaskFireTime,
+ getEffectiveAlerts,
+ getUpcomingAlerts,
+ parseAlertOffset,
+} from '../calendar-alert-scheduler';
+
+const now = new Date('2026-03-01T08:00:00Z').getTime();
+const HOUR = 3600_000;
+
+const event: CalendarEvent = {
+ id: 'ev1',
+ uid: 'u1',
+ title: 'Standup',
+ calendarIds: { 'cal-1': true },
+ start: '2026-03-01T10:00:00',
+ utcStart: '2026-03-01T09:00:00Z',
+ utcEnd: '2026-03-01T09:30:00Z',
+ duration: 'PT30M',
+ alerts: {
+ a1: { trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' }, action: 'display' },
+ a2: { trigger: { '@type': 'OffsetTrigger', offset: '-PT5M', relativeTo: 'end' }, action: 'display' },
+ a3: { trigger: { '@type': 'AbsoluteTrigger', when: '2026-03-01T08:30:00Z' }, action: 'email' },
+ },
+};
+
+describe('parseAlertOffset / computeFireTime', () => {
+ it('parses signed durations', () => {
+ expect(parseAlertOffset('-PT15M')).toBe(-15 * 60_000);
+ expect(parseAlertOffset('P1D')).toBe(24 * HOUR);
+ expect(parseAlertOffset('PT0S')).toBe(0);
+ expect(parseAlertOffset('nope')).toBeNull();
+ });
+
+ it('fires relative to utcStart, utcEnd or an absolute instant', () => {
+ expect(computeFireTime(event, event.alerts!.a1.trigger)).toBe(new Date('2026-03-01T08:45:00Z').getTime());
+ expect(computeFireTime(event, event.alerts!.a2.trigger)).toBe(new Date('2026-03-01T09:25:00Z').getTime());
+ expect(computeFireTime(event, event.alerts!.a3.trigger)).toBe(new Date('2026-03-01T08:30:00Z').getTime());
+ });
+
+ it('computes an end-relative alert from start + duration when utcEnd is missing', () => {
+ const noUtc = { ...event, utcStart: undefined, utcEnd: undefined };
+ const startLocal = new Date('2026-03-01T10:00:00').getTime();
+ expect(computeFireTime(noUtc, { '@type': 'OffsetTrigger', offset: '-PT5M', relativeTo: 'end' })).toBe(
+ startLocal + 30 * 60_000 - 5 * 60_000,
+ );
+ });
+
+ it('fires task alerts relative to due', () => {
+ const task = { ...event, due: '2026-03-02T09:00:00', alerts: undefined };
+ expect(computeTaskFireTime(task, { '@type': 'OffsetTrigger', offset: '-PT1H' })).toBe(
+ new Date('2026-03-02T09:00:00').getTime() - HOUR,
+ );
+ expect(computeTaskFireTime({ ...task, due: null }, { '@type': 'OffsetTrigger', offset: '-PT1H' })).toBeNull();
+ });
+});
+
+describe('getEffectiveAlerts', () => {
+ const calendars: Calendar[] = [{
+ id: 'cal-1',
+ name: 'Personal',
+ defaultAlertsWithTime: { d1: { trigger: { '@type': 'OffsetTrigger', offset: '-PT10M' }, action: 'display' } },
+ defaultAlertsWithoutTime: { d2: { trigger: { '@type': 'OffsetTrigger', offset: '-PT9H' }, action: 'display' } },
+ }];
+
+ it('uses the calendar defaults for useDefaultAlerts events', () => {
+ expect(Object.keys(getEffectiveAlerts({ ...event, useDefaultAlerts: true, alerts: undefined }, calendars) ?? {})).toEqual(['d1']);
+ expect(Object.keys(getEffectiveAlerts({ ...event, useDefaultAlerts: true, showWithoutTime: true }, calendars) ?? {})).toEqual(['d2']);
+ expect(Object.keys(getEffectiveAlerts(event, calendars) ?? {})).toEqual(['a1', 'a2', 'a3']);
+ });
+});
+
+describe('getUpcomingAlerts', () => {
+ it('lists display alerts inside the window, soonest first, skipping cancelled and acknowledged', () => {
+ const cancelled = { ...event, id: 'ev2', status: 'cancelled' };
+ const acknowledged = {
+ ...event,
+ id: 'ev3',
+ alerts: { a1: { trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' }, action: 'display', acknowledged: '2026-03-01T00:00:00Z' } },
+ };
+ const task = {
+ ...event,
+ id: 't1',
+ due: '2026-03-01T12:00:00Z',
+ alerts: { x: { trigger: { '@type': 'OffsetTrigger', offset: 'PT0S' }, action: 'display' } },
+ };
+ const doneTask = { ...task, id: 't2', progress: 'completed' };
+
+ const alerts = getUpcomingAlerts([event, cancelled, acknowledged], [task, doneTask], [], { now, horizonMs: 24 * HOUR });
+ expect(alerts.map((a) => a.eventId + '/' + a.alertId)).toEqual(['ev1/a1', 'ev1/a2', 't1/x']);
+ expect(alerts[0].key).toBe(`ev1:a1:${new Date('2026-03-01T08:45:00Z').getTime()}`);
+ expect(alerts[0].body).toBe('Starts in 15 min');
+ expect(alerts[2].kind).toBe('task');
+ });
+
+ it('drops alerts already in the past or beyond the horizon and honours the limit', () => {
+ const past = { ...event, id: 'p', utcStart: '2026-03-01T07:00:00Z', utcEnd: '2026-03-01T07:30:00Z' };
+ const far = { ...event, id: 'f', utcStart: '2026-03-05T09:00:00Z', utcEnd: '2026-03-05T09:30:00Z' };
+ const alerts = getUpcomingAlerts([past, far, event], [], [], { now, horizonMs: 24 * HOUR, limit: 1 });
+ expect(alerts).toHaveLength(1);
+ expect(alerts[0].eventId).toBe('ev1');
+ });
+});
diff --git a/src/lib/calendar-alert-scheduler.ts b/src/lib/calendar-alert-scheduler.ts
new file mode 100644
index 00000000..7125abb3
--- /dev/null
+++ b/src/lib/calendar-alert-scheduler.ts
@@ -0,0 +1,179 @@
+import { parseISO } from 'date-fns';
+import type { Alert, Calendar, CalendarEvent } from '../api/types';
+import { parseDuration } from './calendar-utils';
+
+// Pure alert maths for calendar reminders. Port of the webmail's
+// lib/calendar-alerts.ts (computeFireTime / getEffectiveAlerts /
+// getPendingAlerts) adapted to schedule *future* local notifications
+// instead of polling for alerts that just fired.
+
+export interface ScheduledAlert {
+ /** `${eventId}:${alertId}:${fireTimeMs}` — stable across refetches. */
+ key: string;
+ eventId: string;
+ alertId: string;
+ fireTimeMs: number;
+ title: string;
+ body: string;
+ kind: 'event' | 'task';
+}
+
+const DURATION_RE = /^(-?)P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/;
+
+/** ISO 8601 duration offset ("-PT15M") to signed milliseconds. */
+export function parseAlertOffset(offset: string): number | null {
+ const match = DURATION_RE.exec(offset);
+ if (!match) return null;
+ const negative = match[1] === '-';
+ const weeks = parseInt(match[2] || '0', 10);
+ const days = parseInt(match[3] || '0', 10);
+ const hours = parseInt(match[4] || '0', 10);
+ const minutes = parseInt(match[5] || '0', 10);
+ const seconds = parseInt(match[6] || '0', 10);
+ const ms = ((weeks * 7 * 24 * 60 * 60) + (days * 24 * 60 * 60) + (hours * 60 * 60) + (minutes * 60) + seconds) * 1000;
+ return negative ? -ms : ms;
+}
+
+export function computeFireTime(event: CalendarEvent, trigger: Alert['trigger']): number | null {
+ if (trigger['@type'] === 'AbsoluteTrigger') {
+ if (!trigger.when) return null;
+ const t = new Date(trigger.when).getTime();
+ return Number.isNaN(t) ? null : t;
+ }
+ if (trigger.offset === undefined) return null;
+ const offsetMs = parseAlertOffset(trigger.offset);
+ if (offsetMs === null) return null;
+
+ let baseTime: number;
+ if (trigger.relativeTo === 'end') {
+ if (event.utcEnd) {
+ baseTime = new Date(event.utcEnd).getTime();
+ } else {
+ const startMs = parseISO(event.start).getTime();
+ if (Number.isNaN(startMs)) return null;
+ baseTime = startMs + parseDuration(event.duration);
+ }
+ } else {
+ baseTime = event.utcStart
+ ? new Date(event.utcStart).getTime()
+ : parseISO(event.start).getTime();
+ }
+ if (Number.isNaN(baseTime)) return null;
+ return baseTime + offsetMs;
+}
+
+export function computeTaskFireTime(task: CalendarEvent, trigger: Alert['trigger']): number | null {
+ if (trigger['@type'] === 'AbsoluteTrigger') {
+ if (!trigger.when) return null;
+ const t = new Date(trigger.when).getTime();
+ return Number.isNaN(t) ? null : t;
+ }
+ if (trigger.offset === undefined) return null;
+ const offsetMs = parseAlertOffset(trigger.offset);
+ if (offsetMs === null) return null;
+ if (!task.due) return null;
+ const baseTime = parseISO(task.due).getTime();
+ if (Number.isNaN(baseTime)) return null;
+ return baseTime + offsetMs;
+}
+
+/** The event's own alerts, or the calendar defaults when `useDefaultAlerts` is set. */
+export function getEffectiveAlerts(
+ event: CalendarEvent,
+ calendars: Calendar[],
+): Record | null {
+ if (!event.useDefaultAlerts) return event.alerts ?? null;
+ const calendarId = Object.keys(event.calendarIds ?? {})[0];
+ if (!calendarId) return null;
+ const calendar = calendars.find((c) => c.id === calendarId);
+ if (!calendar) return null;
+ return (event.showWithoutTime ? calendar.defaultAlertsWithoutTime : calendar.defaultAlertsWithTime) ?? null;
+}
+
+export function buildAlertKey(eventId: string, alertId: string, fireTimeMs: number): string {
+ return `${eventId}:${alertId}:${fireTimeMs}`;
+}
+
+export interface AlertWindow {
+ /** Only alerts firing after this instant are scheduled. */
+ now: number;
+ /** ...and before now + horizonMs (the OS caps pending notifications). */
+ horizonMs: number;
+ /** Hard cap on the number of alerts returned (soonest first). */
+ limit?: number;
+}
+
+function formatWhen(fireTimeMs: number, startMs: number): string {
+ const diffMin = Math.round((startMs - fireTimeMs) / 60000);
+ if (diffMin <= 0) return 'now';
+ if (diffMin < 60) return `in ${diffMin} min`;
+ if (diffMin < 24 * 60) {
+ const h = Math.round(diffMin / 60);
+ return `in ${h} h`;
+ }
+ const d = Math.round(diffMin / (24 * 60));
+ return `in ${d} d`;
+}
+
+/**
+ * Every display alert of the given events/tasks that fires inside the
+ * window, soonest first. Cancelled events, completed tasks and acknowledged
+ * alerts are skipped (#572). Expanded occurrences carry their own utcStart,
+ * so each occurrence yields its own alert.
+ */
+export function getUpcomingAlerts(
+ events: CalendarEvent[],
+ tasks: CalendarEvent[],
+ calendars: Calendar[],
+ window: AlertWindow,
+): ScheduledAlert[] {
+ const out: ScheduledAlert[] = [];
+ const until = window.now + window.horizonMs;
+
+ for (const event of events) {
+ if (event.status === 'cancelled') continue;
+ const alerts = getEffectiveAlerts(event, calendars);
+ if (!alerts) continue;
+ const startMs = event.utcStart ? new Date(event.utcStart).getTime() : parseISO(event.start).getTime();
+ for (const [alertId, alert] of Object.entries(alerts)) {
+ if (!alert?.trigger) continue;
+ if (alert.action && alert.action !== 'display') continue;
+ if (alert.acknowledged) continue;
+ const fireTimeMs = computeFireTime(event, alert.trigger);
+ if (fireTimeMs === null || fireTimeMs <= window.now || fireTimeMs > until) continue;
+ out.push({
+ key: buildAlertKey(event.id, alertId, fireTimeMs),
+ eventId: event.id,
+ alertId,
+ fireTimeMs,
+ title: event.title || '(No title)',
+ body: Number.isNaN(startMs) ? '' : `Starts ${formatWhen(fireTimeMs, startMs)}`,
+ kind: 'event',
+ });
+ }
+ }
+
+ for (const task of tasks) {
+ if (!task.alerts) continue;
+ if (task.progress === 'completed' || task.progress === 'cancelled') continue;
+ for (const [alertId, alert] of Object.entries(task.alerts)) {
+ if (!alert?.trigger) continue;
+ if (alert.action && alert.action !== 'display') continue;
+ if (alert.acknowledged) continue;
+ const fireTimeMs = computeTaskFireTime(task, alert.trigger);
+ if (fireTimeMs === null || fireTimeMs <= window.now || fireTimeMs > until) continue;
+ out.push({
+ key: buildAlertKey(task.id, alertId, fireTimeMs),
+ eventId: task.id,
+ alertId,
+ fireTimeMs,
+ title: task.title || '(No title)',
+ body: 'Task due',
+ kind: 'task',
+ });
+ }
+ }
+
+ out.sort((a, b) => a.fireTimeMs - b.fireTimeMs);
+ return window.limit ? out.slice(0, window.limit) : out;
+}
diff --git a/src/lib/calendar-notifications.ts b/src/lib/calendar-notifications.ts
new file mode 100644
index 00000000..856833e4
--- /dev/null
+++ b/src/lib/calendar-notifications.ts
@@ -0,0 +1,189 @@
+import { Platform } from 'react-native';
+import * as Notifications from 'expo-notifications';
+import { useCalendarStore } from '../stores/calendar-store';
+import { useSettingsStore } from '../stores/settings-store';
+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
+// `calendarNotificationsEnabled` setting.
+
+const CHANNEL_ID = 'calendar-reminders';
+const HORIZON_MS = 7 * 24 * 60 * 60 * 1000;
+// 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 channelReady = false;
+let rescheduleTimer: ReturnType | null = null;
+let rescheduling: Promise | null = null;
+let queued = false;
+let syncStarted = false;
+let lastScheduledKeys: string[] = [];
+
+async function ensurePermission(): Promise {
+ if (permissionGranted !== null) return permissionGranted;
+ 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;
+ }
+ permissionGranted = !!granted;
+ } catch {
+ permissionGranted = false;
+ }
+ return permissionGranted;
+}
+
+async function ensureChannel(): Promise {
+ if (channelReady || Platform.OS !== 'android') return;
+ try {
+ await Notifications.setNotificationChannelAsync(CHANNEL_ID, {
+ name: 'Calendar reminders',
+ importance: Notifications.AndroidImportance.HIGH,
+ sound: 'default',
+ vibrationPattern: [0, 250, 250, 250],
+ });
+ } catch {
+ // Channel creation failing only degrades to the default channel.
+ }
+ channelReady = true;
+}
+
+function alertKeyOf(request: Notifications.NotificationRequest): string | null {
+ const data = request.content.data as { tag?: string; key?: string } | undefined;
+ return data?.tag === DATA_TAG && typeof data.key === 'string' ? data.key : null;
+}
+
+/** Cancel every calendar reminder this app scheduled. */
+export async function cancelAllCalendarNotifications(): Promise {
+ try {
+ const pending = await Notifications.getAllScheduledNotificationsAsync();
+ await Promise.all(
+ pending
+ .filter((r) => alertKeyOf(r) !== null)
+ .map((r) => Notifications.cancelScheduledNotificationAsync(r.identifier)),
+ );
+ } catch {
+ // Nothing to do; the OS keeps whatever it has.
+ }
+ lastScheduledKeys = [];
+}
+
+async function scheduleOne(alert: ScheduledAlert): Promise {
+ await Notifications.scheduleNotificationAsync({
+ content: {
+ title: alert.title,
+ body: alert.body,
+ sound: 'default',
+ data: { tag: DATA_TAG, key: alert.key, eventId: alert.eventId, kind: alert.kind },
+ },
+ trigger: {
+ type: Notifications.SchedulableTriggerInputTypes.DATE,
+ date: new Date(alert.fireTimeMs),
+ ...(Platform.OS === 'android' ? { channelId: CHANNEL_ID } : {}),
+ },
+ });
+}
+
+/**
+ * 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.
+ */
+export async function rescheduleCalendarNotifications(): Promise {
+ if (rescheduling) {
+ queued = true;
+ return rescheduling;
+ }
+ rescheduling = (async () => {
+ try {
+ const enabled = useSettingsStore.getState().calendarNotificationsEnabled;
+ if (!enabled) {
+ if (lastScheduledKeys.length > 0) await cancelAllCalendarNotifications();
+ return;
+ }
+ if (!(await ensurePermission())) return;
+ await ensureChannel();
+
+ const { events, tasks, calendars } = useCalendarStore.getState();
+ const wanted = getUpcomingAlerts(events, tasks, calendars, {
+ now: Date.now(),
+ horizonMs: HORIZON_MS,
+ limit: MAX_SCHEDULED,
+ });
+ const wantedByKey = new Map(wanted.map((a) => [a.key, a]));
+
+ const pending = await Notifications.getAllScheduledNotificationsAsync();
+ const present = new Set();
+ for (const request of pending) {
+ const key = alertKeyOf(request);
+ if (key === null) continue;
+ if (wantedByKey.has(key)) {
+ present.add(key);
+ } else {
+ await Notifications.cancelScheduledNotificationAsync(request.identifier);
+ }
+ }
+ for (const alert of wanted) {
+ if (present.has(alert.key)) continue;
+ try {
+ await scheduleOne(alert);
+ } catch {
+ // A single bad trigger must not block the rest.
+ }
+ }
+ lastScheduledKeys = [...wantedByKey.keys()];
+ } catch {
+ // Notifications are best-effort.
+ } finally {
+ rescheduling = null;
+ if (queued) {
+ queued = false;
+ void rescheduleCalendarNotifications();
+ }
+ }
+ })();
+ return rescheduling;
+}
+
+function scheduleSoon(): void {
+ if (rescheduleTimer) clearTimeout(rescheduleTimer);
+ // Debounce: a refresh sets events and tasks in two steps.
+ rescheduleTimer = setTimeout(() => {
+ rescheduleTimer = null;
+ void rescheduleCalendarNotifications();
+ }, 1500);
+}
+
+/**
+ * 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.
+ */
+export function startCalendarNotificationSync(): void {
+ 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;
+ scheduleSoon();
+ }
+ });
+ let prevEnabled = useSettingsStore.getState().calendarNotificationsEnabled;
+ useSettingsStore.subscribe((state) => {
+ if (state.calendarNotificationsEnabled !== prevEnabled) {
+ prevEnabled = state.calendarNotificationsEnabled;
+ scheduleSoon();
+ }
+ });
+ scheduleSoon();
+}
diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx
index 9a49f885..0fba9766 100644
--- a/src/screens/CalendarScreen.tsx
+++ b/src/screens/CalendarScreen.tsx
@@ -80,6 +80,7 @@ import { useContactsStore } from '../stores/contacts-store';
import { useLocaleStore } from '../stores/locale-store';
import { useUserCalendarAddresses } from '../lib/calendar-user-addresses';
import { useCalendarSubscriptionsStore } from '../stores/calendar-subscriptions-store';
+import { startCalendarNotificationSync } from '../lib/calendar-notifications';
import type { Calendar, CalendarEvent, RecurrenceRule } from '../api/types';
type ViewMode = 'month' | 'week' | 'agenda';
@@ -297,6 +298,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();
}, [hydrate, fetchCalendarsAction]);
// Refresh iCal subscriptions whose interval elapsed: on mount and whenever