feat: fire calendar and task reminders as local notifications

This commit is contained in:
Linus Rath
2026-08-29 18:41:06 +02:00
parent a222e89611
commit 8e1d03c746
6 changed files with 498 additions and 13 deletions
+11 -11
View File
@@ -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. - 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`. - 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 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. - 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`. - 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`). - What RN does: `canRecolor = !!onSetColor && !!cal.isShared` (`CalendarSidebarDrawer.tsx:209`).
- Fix hint: for `!cal.isShared` call the new `updateCalendar(id, { color })`. - 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 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. - 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. - 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 ### 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 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`). - 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()`. - 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 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. - 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). - 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 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. - 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. - 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 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. - 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. - 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 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/`). - 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). - 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 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. - 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. - 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 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. - 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()`. - 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 WEB does: card shows time, location and calendar (ref `components/calendar/event-card.tsx`).
- What RN does: `event.description` rendered with `<MapPin/>` (`src/components/calendar/EventCard.tsx:65-72`); `locations` is not read. - What RN does: `event.description` rendered with `<MapPin/>` (`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. - 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 ### 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 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`). - 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`. - 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`.
+9 -2
View File
@@ -464,8 +464,11 @@ export interface RecurrenceRule {
} }
export interface Alert { 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; action?: string;
// UTCDateTime the user acknowledged the alert; null/absent when pending.
acknowledged?: string | null;
} }
export interface CalendarEvent { export interface CalendarEvent {
@@ -509,7 +512,8 @@ export interface CalendarEvent {
updated?: string; updated?: string;
// Task-only (RFC 8984 JSTask) fields. // Task-only (RFC 8984 JSTask) fields.
progress?: 'needs-action' | 'in-process' | 'completed' | 'failed' | 'cancelled' | string; progress?: 'needs-action' | 'in-process' | 'completed' | 'failed' | 'cancelled' | string;
due?: string; progressUpdated?: string;
due?: string | null;
priority?: number; priority?: number;
percentComplete?: number; percentComplete?: number;
color?: string; color?: string;
@@ -546,6 +550,9 @@ export interface Calendar {
myRights?: CalendarRights; myRights?: CalendarRights;
// Principals this (owned) calendar is shared with and their rights. // Principals this (owned) calendar is shared with and their rights.
shareWith?: Record<string, CalendarRights> | null; shareWith?: Record<string, CalendarRights> | null;
// Alerts applied to events that set `useDefaultAlerts` (RFC 8620 calendars).
defaultAlertsWithTime?: Record<string, Alert> | null;
defaultAlertsWithoutTime?: Record<string, Alert> | null;
// Client-only: JMAP account the calendar was fetched from. Absent for the // Client-only: JMAP account the calendar was fetched from. Absent for the
// primary account; set for calendars shared with the user. // primary account; set for calendars shared with the user.
accountId?: string; accountId?: string;
@@ -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');
});
});
+179
View File
@@ -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<string, Alert> | 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;
}
+189
View File
@@ -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<typeof setTimeout> | null = null;
let rescheduling: Promise<void> | null = null;
let queued = false;
let syncStarted = false;
let lastScheduledKeys: string[] = [];
async function ensurePermission(): Promise<boolean> {
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<void> {
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<void> {
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<void> {
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<void> {
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<string>();
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();
}
+4
View File
@@ -80,6 +80,7 @@ import { useContactsStore } from '../stores/contacts-store';
import { useLocaleStore } from '../stores/locale-store'; import { useLocaleStore } from '../stores/locale-store';
import { useUserCalendarAddresses } from '../lib/calendar-user-addresses'; import { useUserCalendarAddresses } from '../lib/calendar-user-addresses';
import { useCalendarSubscriptionsStore } from '../stores/calendar-subscriptions-store'; import { useCalendarSubscriptionsStore } from '../stores/calendar-subscriptions-store';
import { startCalendarNotificationSync } from '../lib/calendar-notifications';
import type { Calendar, CalendarEvent, RecurrenceRule } from '../api/types'; import type { Calendar, CalendarEvent, RecurrenceRule } from '../api/types';
type ViewMode = 'month' | 'week' | 'agenda'; type ViewMode = 'month' | 'week' | 'agenda';
@@ -297,6 +298,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
// notifications setting; cancelled/acknowledged alerts are skipped).
startCalendarNotificationSync();
}, [hydrate, fetchCalendarsAction]); }, [hydrate, fetchCalendarsAction]);
// Refresh iCal subscriptions whose interval elapsed: on mount and whenever // Refresh iCal subscriptions whose interval elapsed: on mount and whenever