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