diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md index 64f417a..2f42a1b 100644 --- a/.planning/PROJECT.md +++ b/.planning/PROJECT.md @@ -37,6 +37,7 @@ Deferred to backlog: self-service provider onboarding (999.5) and provider abstr - [x] Live list sync so co-edits appear without manual refresh — **Validated in Phase 4**: scoped SSE fan-out over Pangolin (transport smoke-tested), bounded-backoff reconnect, co-edits land within seconds. - [x] Web Push notifications for event reminders and list changes — **Validated in Phase 5 (web-push-notifications)**: VAPID push for reminders, event-change, and coalesced list alerts; on-device UAT 1/2/5 PASS (iOS reminder delivery, iOS push, coalescing). Android event-change on-device confirmation + iOS standalone spinner remain device-only spot-checks at go-live. - [x] Faster write-back so edits reach Fastmail in ~1–2s instead of ~15s (CAL-15) — **Validated in Phase 9 (faster-write-back)**: event-driven outbox drain via a zero-dependency in-process EventEmitter (`outboxTrigger.ts`); a committed enqueue publishes a fire-and-forget `signalOutboxDrain()` that funnels through the existing `isDraining`-guarded drain with a `drainRequested` trailing-re-drain, preserving optimistic-202, create-before-delete on moves, exactly-once per uid, and the 15s `setInterval` fallback. 5/5 success criteria verified; trigger-wiring tests assert SC-1/D-05/D-07. +- [x] Per-event reminders — choose a reminder lead per event (None / 5m / 10m / 15m / 30m / 1h / 2h / 1d / 2d, all-day → day-granularity + 9 AM fire), serialized as a VALARM, with a variable-lead scheduler that honors each event's lead (CAL-13/CAL-14, NOTIF-04/05/06) — **Validated in Phase 11 (per-event-reminders)**: pure VALARM serialization/classification layer (`buildTimedValarm`/`buildAllDayValarm`/`classifyValarms`/`extractValarms`/`computeAlertInstantUtc`); variable-lead scheduler with `uid:dtstartMs` dedup, dropped fixed-15-min/shared-only restriction, all-day 9 AM-local branch; `reminderLeadMinutes` threaded end-to-end with preserve-on-no-change (D-08); allDay-aware reminder picker with edit pre-population. Gap-closure (Plan 11-05) fixed two code-review blockers — custom/other-client VALARMs are now preserved on edit via a surfaced `reminderIsCustom` signal (CAL-14 / Pitfall 1), and the all-day push body no longer reads "Starts in 0 min" — plus post-event-trigger classification, a server-side max bound, and helper-text gating. 5/5 must-haves verified; 347 API + 206 PWA tests green. **Deferred:** live Fastmail VALARM round-trip + on-device push fire (untestable in dev — no provider connected; backlog 999.19). - [x] Admin role + role-gated settings surface to rotate member Fastmail app passwords and designate the shared calendar (ADMIN-01/02/03) — **Validated in Phase 10 (admin-role-settings)**: v1.1 DB foundation (`users.is_admin`, `member_credentials.provider_type`+`unique(user_id)`, `calendar_events.reminder_lead_minutes`, `app_config`) via an additive generate+migrate migration; DB-backed `requireAdmin` gating all `/api/admin/*` (client `isAdmin` UX-only, server 403 the real boundary, D-03); one shared `validateEncryptAndStoreCredential` helper for admin rotation + member self-service `/api/me/credential` (400-no-echo, session-userId only); exclusive shared-calendar designation made transactional + 404-guarded (CR-01 fix); gated `/admin` PWA route + conditional nav + `SetupBanner`. 12/12 must-haves verified; admin route-guard/nav-gating green in real Chromium (e2e 5/5). Deferred follow-ups: WR-01 bootstrap-race (Phase 12 reworks the bootstrap), broker `credentialSync.ts`/`CredentialSheet.tsx` crypto re-audit under full read access. ### Active @@ -121,4 +122,4 @@ This document evolves at phase transitions and milestone boundaries. --- -_Last updated: 2026-06-13 — Phase 10 (Admin Role & Settings) complete; ADMIN-01/02/03 validated_ +_Last updated: 2026-06-14 — Phase 11 (Per-Event Reminders) complete; CAL-13/14 + NOTIF-04/05/06 validated (live round-trip deferred, 999.19)_ diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index 5591e51..0f2ec49 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -12,15 +12,15 @@ Each requirement maps to exactly one roadmap phase (see Traceability). ### Calendar — Per-event reminders & write-back latency -- [ ] **CAL-13**: User can choose a reminder lead time when creating or editing an event from a preset list (None / 5m / 10m / 15m / 30m / 1h / 2h / 1d / 2d), with **"None" as the default**; the choice is serialized as a VALARM on the event written back to Fastmail. -- [ ] **CAL-14**: Editing an event **preserves any existing reminder/VALARM** set in another client (Fastmail or native) — reminders are never silently stripped on round-trip. +- [x] **CAL-13**: User can choose a reminder lead time when creating or editing an event from a preset list (None / 5m / 10m / 15m / 30m / 1h / 2h / 1d / 2d), with **"None" as the default**; the choice is serialized as a VALARM on the event written back to Fastmail. +- [x] **CAL-14**: Editing an event **preserves any existing reminder/VALARM** set in another client (Fastmail or native) — reminders are never silently stripped on round-trip. - [x] **CAL-15**: A created, edited, or deleted event reaches Fastmail within ~2 seconds (event-driven outbox drain) instead of up to ~15s, while preserving the optimistic-202 accept and all outbox durability guarantees (create-before-delete ordering, drain concurrency guard, fresh-etag-before-PUT, per-uid exactly-once). ### Notifications — Variable-lead reminder scheduling -- [ ] **NOTIF-04**: An event reminder push fires at the event's **chosen lead time**, not a hardcoded 15-minute lead. -- [ ] **NOTIF-05**: An event with **no reminder set produces no reminder push** (no default 15-min fire). -- [ ] **NOTIF-06**: An all-day event's reminder fires at a sensible local time (9 AM on the alert day), not at midnight, and reminder delivery remains exactly-once across catch-up scans and rescheduled events. +- [x] **NOTIF-04**: An event reminder push fires at the event's **chosen lead time**, not a hardcoded 15-minute lead. +- [x] **NOTIF-05**: An event with **no reminder set produces no reminder push** (no default 15-min fire). +- [x] **NOTIF-06**: An all-day event's reminder fires at a sensible local time (9 AM on the alert day), not at midnight, and reminder delivery remains exactly-once across catch-up scans and rescheduled events. ### Administration — Settings section (role-gated) @@ -79,11 +79,11 @@ Maps each REQ-ID to its phase. v1.1 phases continue v1.0 numbering (v1.0 ended a | ADMIN-01 | Phase 10 (Admin Role & Settings) | Complete | | ADMIN-02 | Phase 10 (Admin Role & Settings) | Complete | | ADMIN-03 | Phase 10 (Admin Role & Settings) | Complete | -| CAL-13 | Phase 11 (Per-Event Reminders) | Pending | -| CAL-14 | Phase 11 (Per-Event Reminders) | Pending | -| NOTIF-04 | Phase 11 (Per-Event Reminders) | Pending | -| NOTIF-05 | Phase 11 (Per-Event Reminders) | Pending | -| NOTIF-06 | Phase 11 (Per-Event Reminders) | Pending | +| CAL-13 | Phase 11 (Per-Event Reminders) | Complete | +| CAL-14 | Phase 11 (Per-Event Reminders) | Complete | +| NOTIF-04 | Phase 11 (Per-Event Reminders) | Complete | +| NOTIF-05 | Phase 11 (Per-Event Reminders) | Complete | +| NOTIF-06 | Phase 11 (Per-Event Reminders) | Complete | | SETUP-01 | Phase 12 (Initial Setup Wizard) | Pending | | SETUP-02 | Phase 12 (Initial Setup Wizard) | Pending | | SETUP-03 | Phase 12 (Initial Setup Wizard) | Pending | diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 7b824e5..5627321 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -29,7 +29,7 @@ Make FamilySync configurable, administrable, and maintainable for real multi-mem - [x] **Phase 8: Gitea CI** - Full regression on PR to main (lint/typecheck/unit/API-integration vs a MariaDB service container **+ the Phase 7 mobile harness as a UI-regression step against a CI-hosted dev stack**) + Docker image publish on merge (completed 2026-06-11) - [x] **Phase 9: Faster Write-Back** - Event-driven outbox drain so edits land in ~1-2s instead of ~15s, preserving every outbox durability guarantee (completed 2026-06-12) - [x] **Phase 10: Admin Role & Settings** - DB foundation (is_admin / reminder_lead / app_config) + role-gated admin UI to rotate app passwords and designate the shared calendar (completed 2026-06-13) -- [ ] **Phase 11: Per-Event Reminders** - Reminder selector on the event form (incl. "None") serialized as VALARM, with a variable-lead scheduler that honors each event's choice +- [x] **Phase 11: Per-Event Reminders** - Reminder selector on the event form (incl. "None") serialized as VALARM, with a variable-lead scheduler that honors each event's choice (completed 2026-06-14) - [ ] **Phase 12: Initial Setup Wizard** - First-run validated bootstrap of env/VAPID/DB/OIDC + first app password, reusing the admin route surface - [x] **Phase 13: Real Lint Gate (ESLint)** - Wire ESLint flat config (typescript-eslint + React) across both apps so the Phase 8 CI lint slot actually fails on violations instead of no-op'ing (completed 2026-06-12) - [x] **Phase 14: Desktop E2E Coverage** - Add a Desktop Chrome Playwright profile + make the mobile-authored specs desktop-safe so the Phase 8 regression gate validates desktop, not just mobile (completed 2026-06-12) @@ -216,16 +216,16 @@ Plans: Plans: **Wave 1** -- [ ] 11-01-PLAN.md — VALARM builders + classifier + extractor + computeAlertInstantUtc (vevent.ts, TDD) +- [x] 11-01-PLAN.md — VALARM builders + classifier + extractor + computeAlertInstantUtc (vevent.ts, TDD) **Wave 2** *(blocked on Wave 1 completion)* -- [ ] 11-02-PLAN.md — Variable-lead scheduler: uid:dtstartMs dedup, drop isShared, all-day 9 AM, humanized body (TDD) -- [ ] 11-03-PLAN.md — Backend plumbing: schema field, outbox preserve-on-edit, sync upsert, occurrence surfacing +- [x] 11-02-PLAN.md — Variable-lead scheduler: uid:dtstartMs dedup, drop isShared, all-day 9 AM, humanized body (TDD) +- [x] 11-03-PLAN.md — Backend plumbing: schema field, outbox preserve-on-edit, sync upsert, occurrence surfacing **Wave 3** *(blocked on Wave 2 completion)* -- [ ] 11-04-PLAN.md — EventForm reminder picker (allDay swap, edit pre-population) + client types + Playwright smoke +- [x] 11-04-PLAN.md — EventForm reminder picker (allDay swap, edit pre-population) + client types + Playwright smoke **UI hint**: yes @@ -408,7 +408,7 @@ At ≤767px (`window.matchMedia('(max-width: 767px)')` in `apps/pwa/src/App.tsx` | 8. Gitea CI | v1.1 | 4/4 | Complete | 2026-06-11 | | 9. Faster Write-Back | v1.1 | 2/2 | Complete | 2026-06-12 | | 10. Admin Role & Settings | v1.1 | 4/4 | Complete | 2026-06-13 | -| 11. Per-Event Reminders | v1.1 | 0/? | Not started | - | +| 11. Per-Event Reminders | v1.1 | 5/5 | Complete | 2026-06-14 | | 12. Initial Setup Wizard | v1.1 | 0/? | Not started | - | | 13. Real Lint Gate (ESLint) | v1.1 | 3/3 | Complete | 2026-06-12 | | 14. Desktop E2E Coverage | v1.1 | 1/1 | Complete | 2026-06-12 | @@ -422,7 +422,7 @@ At ≤767px (`window.matchMedia('(max-width: 767px)')` in `apps/pwa/src/App.tsx` **Goal:** [Captured for future planning] Abstract the calendar backend behind a provider interface so Fastmail/CalDAV is one implementation among potentially many. Shipping with a single provider is fine, but the broker, sync, and event-expansion layers should be structured so additional providers (e.g. other CalDAV hosts, Google Calendar, generic ICS feeds) can be added without rework. Captures the "provider" seam as an explicit architectural concern. **Requirements:** TBD -**Plans:** 4/4 plans complete +**Plans:** 5/5 plans complete Plans: @@ -618,6 +618,19 @@ Plans: - [ ] TBD (promote with /gsd-review-backlog when ready) +### Phase 999.19: Dev user exercises full app functionality without syncing to a real calendar (BACKLOG) + +**Goal:** [Captured for future planning] Let the `DEV_AUTH_BYPASS` dev user (currently hardcoded `DEV_USER` id 1 in `apps/api/src/auth/devBypass.ts`) exercise the full app — create/edit/delete events, set per-event reminders, manage lists — against a local/in-app calendar store, WITHOUT requiring a connected Fastmail/CalDAV provider and WITHOUT writing anything to a real calendar. Today the dev user has no `member_credentials` row and no `calendars`, so `writable-calendars` is empty and `POST /api/events/create` returns `422 "No writable calendar found for user"` — making hands-on UAT of event/reminder features impossible in dev. Options to explore: seed the dev user a fake local calendar + short-circuit the outbox/CalDAV write path under dev-bypass (no Fastmail round-trip), or a dev-only in-memory calendar provider. Must stay strictly dev-only (same hard `NODE_ENV !== 'production'` guard) and never ship in production images. + +**Context:** Captured 2026-06-14 during Phase 11 (Per-Event Reminders) UAT. The reminder picker and backend were verified via automated tests + a route-mocked playwright smoke, but the operator could not manually create an event to see reminders end-to-end because no provider is connected in the dev DB (`needsProviderSetup: true`). This is a recurring dev-testability friction (see MEMORY: "Dev user 1 has no calendars"). Tags: dev-tooling, dev-bypass, testability, calendars, outbox, uat. + +**Requirements:** TBD +**Plans:** 0 plans + +Plans: + +- [ ] TBD (promote with /gsd-review-backlog when ready) + ### Phase 18: Auto timezone detection and ability to change timezone **Goal:** [To be planned] diff --git a/.planning/STATE.md b/.planning/STATE.md index 2249c7c..a247483 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -3,15 +3,15 @@ gsd_state_version: 1.0 milestone: v1.1 milestone_name: Operability & Polish status: executing -stopped_at: Phase 11 UI-SPEC approved -last_updated: "2026-06-14T01:26:07.775Z" -last_activity: "2026-06-13 - Completed quick task 260613-ndv: isolated local apps/api tests to familysync_test (dev DB no longer polluted)" +stopped_at: Completed 11-04-PLAN.md +last_updated: "2026-06-14T12:31:06.011Z" +last_activity: 2026-06-14 progress: - total_phases: 21 - completed_phases: 8 - total_plans: 27 - completed_plans: 27 - percent: 38 + total_phases: 23 + completed_phases: 9 + total_plans: 32 + completed_plans: 32 + percent: 39 --- # Project State @@ -21,14 +21,14 @@ progress: See: .planning/PROJECT.md (updated 2026-06-10) **Core value:** One color-coded family calendar (shared + personal) and shared lists from a single low-friction PWA — cross-ecosystem, no app store -**Current focus:** Phase 10 — admin-role-settings +**Current focus:** Phase 11 — per-event-reminders ## Current Position Phase: 13 Plan: Not started Status: Ready to execute -Last activity: 2026-06-13 - Completed quick task 260613-ndv: isolated local apps/api tests to familysync_test (dev DB no longer polluted) +Last activity: 2026-06-14 ### ✅ Resolved Checkpoint — Phase 15 Plan 15-03 Task 2 (human-action) @@ -38,7 +38,7 @@ Done 2026-06-12. Gitea branch protection on `main` now requires EXACTLY `CI / fa **Velocity:** -- Total plans completed: 43 +- Total plans completed: 48 - Average duration: - - Total execution time: 0 hours @@ -55,6 +55,7 @@ Done 2026-06-12. Gitea branch protection on `main` now requires EXACTLY `CI / fa | 09 | 2 | - | - | | 16 | 6 | - | - | | 10 | 4 | - | - | +| 11 | 5 | - | - | **Recent Trend:** @@ -105,6 +106,7 @@ _Updated after each plan completion_ | Phase 10-admin-role-settings P02 | 700 | 3 tasks | 6 files | | Phase 10-admin-role-settings P03 | 720 | 3 tasks | 6 files | | Phase 10-admin-role-settings P04 | 1315 | 3 tasks | 8 files | +| Phase 11-per-event-reminders P11-04 | 60 | 3 tasks | 4 files | ## Accumulated Context @@ -179,6 +181,9 @@ Recent decisions affecting current work: - [Phase ?]: isAdmin drives nav visibility; real boundary is server-side - [Phase ?]: Single bottom sheet component handles all credential entry flows - [Phase ?]: No X button on SetupBanner; cleared by needsProviderSetup=false from /api/me refetch +- [Phase ?]: D-CLIENT-TYPES: reminderLeadMinutes required on CalendarOccurrence, optional on CreateEventPayload (absent=no-change D-08) +- [Phase ?]: D-PAYLOAD-ABSENT: __custom__ unchanged → field omitted from payload; server hasOwnProperty check preserves original VALARM (D-08) +- [Phase ?]: D-NULL-FALLBACK: occurrence.reminderLeadMinutes===null mapped to None; occurrence cannot distinguish absolute/multi-VALARM from no-reminder; rely on server-side preserve (absent payload) ### Roadmap Evolution @@ -247,9 +252,9 @@ Recent decisions affecting current work: ## Session Continuity -Last session: 2026-06-14T00:53:26.422Z -Stopped at: Phase 11 UI-SPEC approved -Resume file: .planning/phases/11-per-event-reminders/11-UI-SPEC.md +Last session: 2026-06-14T10:57:47.923Z +Stopped at: Completed 11-04-PLAN.md +Resume file: None ## Operator Next Steps diff --git a/.planning/phases/11-per-event-reminders/11-01-SUMMARY.md b/.planning/phases/11-per-event-reminders/11-01-SUMMARY.md new file mode 100644 index 0000000..3d90edb --- /dev/null +++ b/.planning/phases/11-per-event-reminders/11-01-SUMMARY.md @@ -0,0 +1,147 @@ +--- +phase: 11-per-event-reminders +plan: "01" +subsystem: api/broker +tags: [valarm, ical.js, tdd, reminders, dst, calendar] +dependency_graph: + requires: [] + provides: + - buildTimedValarm + - buildAllDayValarm + - classifyValarms + - extractValarms + - computeAlertInstantUtc + - AlarmClassification + - PRESET_MINUTES + - NewEventParams.reminderLeadMinutes + - NewEventParams.valarms + - NewEventParams.allDayAlertInstantUtc + affects: + - apps/api/src/broker/outboxWorker.ts (Plan 03 consumer) + - apps/api/src/broker/reminderScheduler.ts (Plan 02 consumer) +tech_stack: + added: [] + patterns: + - resetType('duration') + setValue(ICAL.Duration) to prevent VALUE=TEXT on TRIGGER + - resetType('date-time') + setValue(ICAL.Time) for absolute DATE-TIME VALARM trigger + - Intl.DateTimeFormat-based UTC offset probe at 9 AM (not midnight) for DST-correct computation + - ICAL.parse try/catch safe-default pattern for T-11-01 tamper mitigation +key_files: + created: [] + modified: + - apps/api/src/broker/vevent.ts + - apps/api/tests/broker/vevent.test.ts +decisions: + - "D-VALARM-PROBE: computeAlertInstantUtc probes UTC offset at naive-9AM-UTC (not midnight) so DST transitions before 9 AM (spring-forward at 2 AM) use the post-transition offset — single-pass Intl computation, no iteration needed" + - "D-ICAL-INSTANCEOF: classifyValarms uses instanceof ICAL.Time (not getParameter('value')) to distinguish absolute vs relative TRIGGER — more robust per A3/A4 assumption log since getParameter returns undefined for default-type DURATION triggers" +metrics: + duration_minutes: 7 + completed_date: "2026-06-14" + tasks_completed: 3 + files_modified: 2 +--- + +# Phase 11 Plan 01: VALARM Serialization + Classification Layer Summary + +VALARM pure-function layer: five exported units covering timed DURATION trigger, all-day absolute DATE-TIME trigger, none/preset/offlist/custom classification, component extraction for preserve-on-edit, and DST-correct 9 AM-local→UTC computation — all TDD RED-first, 37/37 tests green. + +## Tasks Completed + +| Task | Description | Commit | +|------|-------------|--------| +| RED | Failing tests for all 5 units + buildVeventString VALARM emission | 860c741 | +| GREEN | Implementation: buildTimedValarm, buildAllDayValarm, classifyValarms, extractValarms, computeAlertInstantUtc, VALARM emission in buildVeventString, extended NewEventParams | d9eb5c1 | + +## New Exported Symbols + +| Symbol | File | Description | +|--------|------|-------------| +| `buildTimedValarm(leadMinutes)` | vevent.ts | VALARM with DURATION trigger `-PTNmM`; never emits VALUE=TEXT (resetType('duration')) | +| `buildAllDayValarm(alertInstantUtc)` | vevent.ts | VALARM with absolute DATE-TIME trigger `VALUE=DATE-TIME:YYYYMMDDTHHMMSSz` | +| `classifyValarms(rawVevent)` | vevent.ts | Returns `AlarmClassification`: none/preset/offlist/custom; safe on parse failure | +| `extractValarms(rawVevent)` | vevent.ts | Returns live `ICAL.Component[]` for preserve-on-edit re-attachment; safe on parse failure | +| `computeAlertInstantUtc(dateStr, leadDays, tz)` | vevent.ts | DST-correct 9 AM-local→UTC; probes offset at 9 AM to handle transitions before 9 AM | +| `AlarmClassification` | vevent.ts | Union type: `{ kind:'none' } \| { kind:'preset'; leadMinutes } \| { kind:'offlist'; leadMinutes } \| { kind:'custom' }` | +| `PRESET_MINUTES` | vevent.ts | `Set([0,5,10,15,30,60,120,1440,2880,10080])` — D-01/D-02 preset list | + +## Interface Extensions + +`NewEventParams` in `vevent.ts` (after existing `dtstamp` field): + +```typescript +reminderLeadMinutes?: number | null; // null = no VALARM; 0 = same-day all-day; positive = timed lead +valarms?: ICAL.Component[]; // pre-parsed preserve-on-edit components (D-08/CAL-14) +allDayAlertInstantUtc?: Date; // 9 AM local on alert day in UTC (for buildAllDayValarm) +``` + +## Key TRIGGER Assertions in Tests + +- `buildTimedValarm(30)` → ICS contains `TRIGGER:-PT30M`, does NOT contain `VALUE=TEXT` +- `buildTimedValarm(120)` → ICS matches `/TRIGGER:-P(?:T2H|T120M)/`, no VALUE=TEXT +- `buildAllDayValarm(new Date('2026-06-14T13:00:00Z'))` → ICS contains `20260614T130000Z` + `VALUE=DATE-TIME` +- `buildVeventString({allDay:false, reminderLeadMinutes:15})` → ICS contains `TRIGGER:-PT15M`, no VALUE=TEXT +- `buildVeventString({allDay:false, reminderLeadMinutes:0})` → NO `BEGIN:VALARM` (timed 0 = None, D-06) +- `buildVeventString({allDay:true, reminderLeadMinutes:1440, allDayAlertInstantUtc:...})` → `VALUE=DATE-TIME` trigger +- `buildVeventString({reminderLeadMinutes:null})` → NO `BEGIN:VALARM` +- `buildVeventString({valarms:[60minAlarm], reminderLeadMinutes:15})` → only 60-min VALARM present (preserve wins) + +## buildVeventString VALARM Branch Order + +1. `params.valarms?.length > 0` → re-attach each via `addSubcomponent`; ignore `reminderLeadMinutes` +2. `params.reminderLeadMinutes != null && allDay && allDayAlertInstantUtc` → `buildAllDayValarm` +3. `params.reminderLeadMinutes != null && !allDay && reminderLeadMinutes > 0` → `buildTimedValarm` +4. Everything else → no VALARM + +## computeAlertInstantUtc DST Tests + +| Input | Expected | Notes | +|-------|----------|-------| +| `('2026-06-15', 0, 'America/New_York')` | `2026-06-15T13:00:00.000Z` | 9 AM EDT (UTC-4) | +| `('2026-06-15', 1, 'America/New_York')` | `2026-06-14T13:00:00.000Z` | 1 day before, EDT | +| `('2026-01-15', 0, 'America/New_York')` | `2026-01-15T14:00:00.000Z` | 9 AM EST (UTC-5) | +| `('2026-03-08', 0, 'America/New_York')` | `2026-03-08T13:00:00.000Z` | Spring-forward day, post-transition EDT | +| `('2026-11-01', 0, 'America/New_York')` | `2026-11-01T14:00:00.000Z` | Fall-back day, post-transition EST | + +## Verification Results + +- `pnpm --filter @familysync/api exec vitest run tests/broker/vevent.test.ts`: 37/37 PASS +- `pnpm --filter @familysync/api exec vitest run` (full suite): 296/296 PASS +- `pnpm --filter @familysync/api exec tsc --noEmit`: CLEAN (0 errors) + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 1 - Bug] ical.js TRIGGER duration normalization in round-trip test** +- **Found during:** Task 2 GREEN +- **Issue:** Test expected `TRIGGER:-PT1H` but ical.js re-serializes a parsed `-PT60M` duration as `-PT60M` (it does not normalize to hours). Both are RFC-valid. +- **Fix:** Updated test to match `/TRIGGER:-P(?:T60M|T1H)/` accepting both forms. +- **Files modified:** apps/api/tests/broker/vevent.test.ts +- **Commit:** d9eb5c1 + +**2. [Rule 1 - Bug] DST probe at midnight vs at 9 AM** +- **Found during:** Task 3 GREEN — spring-forward (2026-03-08) and fall-back (2026-11-01) tests failed +- **Issue:** Original `getUtcOffsetMsForDate` computed the UTC offset at alertDate midnight. On spring-forward day (DST change at 2 AM), midnight is still in EST (UTC-5), but 9 AM is in EDT (UTC-4). Using the midnight offset gave the wrong UTC instant. +- **Fix:** Replaced the midnight-based offset computation with a probe at `alertDateUtcMs + 9h` (naive 9 AM UTC), then asked Intl what local date+time that corresponds to and computed the adjustment. The probe is inherently near 9 AM so it captures the post-transition offset on DST days. +- **Files modified:** apps/api/src/broker/vevent.ts +- **Commit:** d9eb5c1 + +## Known Stubs + +None. All five units are fully implemented and tested. No placeholder values or TODO markers in the produced code. + +## Threat Flags + +None. This plan adds no new network endpoints, auth paths, or schema changes. The only external-input path (classifyValarms/extractValarms parsing stored rawVevent) is wrapped in try/catch per T-11-01 mitigation, matching the existing extractRruleString idiom. + +## Self-Check: PASSED + +Files exist: +- FOUND: apps/api/src/broker/vevent.ts +- FOUND: apps/api/tests/broker/vevent.test.ts + +Commits exist: +- 860c741: RED commit (test(11-01)) +- d9eb5c1: GREEN commit (feat(11-01)) + +Exports verified: `grep -n 'export function\|export const\|export type' apps/api/src/broker/vevent.ts` confirms all 7 symbols exported. diff --git a/.planning/phases/11-per-event-reminders/11-02-SUMMARY.md b/.planning/phases/11-per-event-reminders/11-02-SUMMARY.md new file mode 100644 index 0000000..a448e45 --- /dev/null +++ b/.planning/phases/11-per-event-reminders/11-02-SUMMARY.md @@ -0,0 +1,164 @@ +--- +phase: 11-per-event-reminders +plan: "02" +subsystem: api/broker +tags: [scheduler, tdd, reminders, variable-lead, dedup, humanize, all-day, notif-04, notif-05, notif-06] +dependency_graph: + requires: + - computeAlertInstantUtc (Plan 11-01, vevent.ts) + provides: + - humanizeLeadMinutes + - runReminderCheck (variable-lead, uid:dtstartMs dedup, all-day branch) + - startReminderScheduler (unchanged) + affects: + - apps/api/src/broker/reminderScheduler.ts (Plan 11-03+ consumer if any) +tech_stack: + added: [] + patterns: + - Two-query split (timed vs all-day) to avoid mixing SQL filter semantics + - Per-event JS fire-time check within a wide pre-filter SQL window (T-11-04 cap) + - uid:dtstartMs compound dedup key — identity stable across consecutive ticks, re-fires on reschedule + - pruneMs separate from dtstartMs — all-day events need end-of-event-day as prune boundary + - humanizeLeadMinutes branch order: check < 120 before hours division (90-min = 1 hour, not 2) +key_files: + created: [] + modified: + - apps/api/src/broker/reminderScheduler.ts + - apps/api/tests/broker/reminderScheduler.test.ts +decisions: + - "D-PRUNE-SPLIT: Introduced separate pruneMs field alongside dtstartMs in the byKey map. For timed events pruneMs = dtstartUtc (prune when event starts). For all-day events pruneMs = start-of-next-day UTC (prune after event date), because UTC midnight of the event date is always before the 9 AM fire time — storing dtstartMs as the prune value caused immediate eviction after tick 1." + - "D-TWO-QUERY: Split the single DB query into timed + all-day separate queries. This avoids ambiguous WHERE predicates (e.g. reminder_lead_minutes > 0 is wrong for all-day where 0 = same-day) and keeps SQL pre-filter logic readable per event type." + - "D-WIDE-PREFILTER: SQL pre-filter uses wide window (now + MAX_LEAD_MINUTES / MAX_ALLDAY_LEAD_DAYS); per-event JS check narrows to exact 60s catch-up window. Avoids complex MariaDB timezone arithmetic for all-day, keeps correctness in JS." +metrics: + duration_minutes: 13 + completed_date: "2026-06-14" + tasks_completed: 3 + files_modified: 2 +--- + +# Phase 11 Plan 02: Variable-Lead Reminder Scheduler Summary + +Generalized the reminder scheduler from a fixed shared-event 15-min scan to a per-event variable-lead scheduler: uid:dtstartMs compound dedup, dropped isShared restriction, all-day 9 AM-local branch (via computeAlertInstantUtc from Plan 11-01), NULL-vs-0 guard, and humanized push body. + +## Tasks Completed + +| Task | Description | Commit | +|------|-------------|--------| +| RED | Failing tests: variable-lead, uid:dtstartMs dedup, NULL-vs-0, personal calendar | 9635aa9 | +| GREEN Task 1 | Variable-lead window, uid:dtstartMs dedup, drop isShared/allDay restrictions, timed-0 skip | 62d3f58 | +| Task 2 | humanizeLeadMinutes tests (8 bucket cases) + body dispatch assertion | 57f9d67 | +| Task 3 | All-day 9 AM-local tests + all-day prune-boundary fix (NOTIF-06) | 0dc227a | + +## New Exported Symbols + +| Symbol | File | Description | +|--------|------|-------------| +| `humanizeLeadMinutes(leadMinutes)` | reminderScheduler.ts | Maps minutes → human string: `< 60` → `N min`; `< 120` → `1 hour`; `< 1440` → `N hours`; `< 2880` → `1 day`; else `N days`. Branch order prevents 90-min rounding to 2 hours. | + +## Key Changes to runReminderCheck + +### SQL Query: Two queries replacing one + +**Before:** Single query with `isShared=true`, `allDay=false`, fixed `(now, now+16min]` window. + +**After (timed query):** +- Removed `eq(calendars.isShared, true)` — personal events fire (NOTIF-05) +- Removed `eq(calendarEvents.allDay, false)` — handled separately +- Added `reminderLeadMinutes IS NOT NULL` (NOTIF-05) +- Changed window to `(now, now + MAX_LEAD_MINUTES]` (2880 min) as a pre-filter + +**After (all-day query):** +- `allDay=true`, `reminderLeadMinutes IS NOT NULL`, `dtstartDate <= today + 7 days` +- Alert time computed in JS via `computeAlertInstantUtc(dtstartDate, leadDays, serverTz)` + +### JS Filter: Per-event fire-time check + +- **Timed:** `fireTime = dtstartUtc - lead * 60s`. Fire if `fireTime ∈ (now - 60s, now]`. Skip if `lead === 0` (D-06). +- **All-day:** `alertInstant = computeAlertInstantUtc(dtstartDate, lead/1440, serverTz)`. Fire if `alertInstant ∈ (now - 60s, now]`. + +### Dedup Key: uid → uid:dtstartMs + +- Key format: `` `${uid}:${dtstartMs}` `` +- Timed events: `dtstartMs = dtstartUtc.getTime()` +- All-day events: `dtstartMs = Date.UTC(y, m-1, d)` (UTC midnight of event date) +- Reschedule detection: same uid with new dtstart gets a new compound key → re-fires + +### Prune Boundary (New Field: pruneMs) + +- Timed: `pruneMs = dtstartMs` (same as before — prune when event starts) +- All-day: `pruneMs = Date.UTC(y, m-1, d+1)` (end-of-event-day) — avoids immediate prune since UTC midnight of event date is before the 9 AM fire instant + +### Body: humanizeLeadMinutes + +``` +body: humanizeLeadMinutes(event.reminderLeadMinutes) +``` +Driven by the DB-stored configured lead (D-09 ground truth), not the live minutes-to-start delta. + +## humanizeLeadMinutes Bucket Table + +| Input (min) | Output | +|-------------|--------| +| 5–59 | `Starts in N min` | +| 60–119 | `Starts in 1 hour` | +| 120–1439 | `Starts in N hours` | +| 1440–2879 | `Starts in 1 day` | +| 2880+ | `Starts in N days` | + +90 min → `Starts in 1 hour` (not 2 hours — the `< 120` check comes before the hours division). + +## Requirements Satisfied + +| Req ID | Behavior | Test | +|--------|----------|------| +| NOTIF-04 | Fires at T-lead for 30-min lead event; not-fired outside window | `NOTIF-04: dispatches a timed event when now is inside the lead-driven fire window (30-min lead)` | +| NOTIF-05 | NULL lead → no push; timed 0-lead → no push; personal → dispatch | 3 tests in `variable-lead, NULL-vs-0, personal calendar` | +| NOTIF-06 | uid:dtstartMs dedup (once/3 ticks); reschedule re-fires; all-day 9 AM | 5 tests covering dedup + all-day | +| D-09 | Humanized body: 1440-min lead → "Starts in 1 day" | `dispatched notification body is humanized from configured lead` | + +## Verification Results + +- `pnpm --filter @familysync/api exec vitest run tests/broker/reminderScheduler.test.ts`: 28/28 PASS +- `pnpm --filter @familysync/api exec vitest run` (full suite): 314/314 PASS +- `pnpm --filter @familysync/api exec tsc --noEmit`: CLEAN (0 errors) +- `grep 'setInterval' reminderScheduler.ts`: retained (no node-cron) + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 1 - Bug] All-day dedup immediate prune via UTC-midnight dtstartMs** +- **Found during:** Task 3 GREEN — all-day dedup test failed: dispatched twice across 2 ticks +- **Issue:** `sentReminders` stored `dtstartMs = Date.UTC(y, m-1, d)` (UTC midnight of event date) as the prune value. By the time a 9 AM reminder fires, this value is already `<= now`, so the CR-01 prune loop evicted the entry in the same tick. The next tick re-entered the fire window and dispatched again. +- **Fix:** Introduced separate `pruneMs` field. For all-day events, `pruneMs = Date.UTC(y, m-1, d+1)` (start-of-next-day), ensuring the entry persists through the full event date. For timed events, `pruneMs = dtstartMs` (unchanged behavior). +- **Files modified:** apps/api/src/broker/reminderScheduler.ts +- **Commit:** 0dc227a + +**2. [Rule 3 - Blocking] Test mock needed two-query support** +- **Found during:** Task 1 GREEN — existing `makeSelectMock` assumed two `innerJoin` calls (calendars + pushSubscriptions). New implementation uses a single `innerJoin` per query but makes two queries. +- **Fix:** Replaced `vi.mocked(db.select).mockReturnValue(...)` pattern with `mockTwoQueries(db, timedRows, allDayRows)` that sequences two `mockReturnValueOnce` calls to correctly simulate the timed vs all-day query split. +- **Files modified:** apps/api/tests/broker/reminderScheduler.test.ts +- **Commit:** 62d3f58 + +## Known Stubs + +None. All implemented functions are fully wired and produce real output. No placeholder values or TODO markers. + +## Threat Flags + +None. No new network endpoints, auth paths, file access patterns, or schema changes introduced. The only behavioral expansion (personal-calendar reminders) matches T-11-03 (accepted risk per threat register — authorized requirement NOTIF-05 corollary, body carries only event title + relative time). + +## Self-Check: PASSED + +Files exist: +- FOUND: apps/api/src/broker/reminderScheduler.ts +- FOUND: apps/api/tests/broker/reminderScheduler.test.ts +- FOUND: .planning/phases/11-per-event-reminders/11-02-SUMMARY.md + +Commits exist: +- 9635aa9: RED test commit (test(11-02)) +- 62d3f58: GREEN Task 1 (feat(11-02)) +- 57f9d67: Task 2 (feat(11-02)) +- 0dc227a: Task 3 (feat(11-02)) + +Exports verified: `humanizeLeadMinutes` exported from reminderScheduler.ts, `computeAlertInstantUtc` imported from vevent.ts (Plan 11-01 artifact). diff --git a/.planning/phases/11-per-event-reminders/11-03-SUMMARY.md b/.planning/phases/11-per-event-reminders/11-03-SUMMARY.md new file mode 100644 index 0000000..1f839a0 --- /dev/null +++ b/.planning/phases/11-per-event-reminders/11-03-SUMMARY.md @@ -0,0 +1,161 @@ +--- +phase: 11-per-event-reminders +plan: "03" +subsystem: api/broker +tags: [valarm, reminderLeadMinutes, tdd, schema, outbox, sync, expand, cal-13, cal-14] +dependency_graph: + requires: + - "Plan 11-01 (buildTimedValarm, buildAllDayValarm, classifyValarms, extractValarms, computeAlertInstantUtc, NewEventParams extensions)" + provides: + - "reminderLeadMinutes in eventFieldsSchema (ingress validation)" + - "reminderLeadMinutes in outboxPayloadSchema (drain re-validation)" + - "hasExplicitReminder preserve-on-edit path in outboxWorker UPDATE branch" + - "VALARM wiring in buildVeventString calls (both UPDATE and CREATE branches)" + - "reminderLeadMinutesValue derivation + upsert in sync.ts" + - "reminderLeadMinutes on CalendarOccurrence (expand.ts)" + - "reminderLeadMinutes in GET /api/events select" + affects: + - "apps/api/src/routes/events.ts" + - "apps/api/src/broker/outboxWorker.ts" + - "apps/api/src/broker/sync.ts" + - "apps/api/src/broker/expand.ts" + - "Plan 11-02 (reminderScheduler — scheduler reads reminderLeadMinutes from DB)" +tech_stack: + added: [] + patterns: + - "hasExplicitReminder sentinel mirrors hasExplicitRecurrence WR-01 pattern" + - "Object.prototype.hasOwnProperty.call(fields, 'reminderLeadMinutes') for absent-vs-null distinction (D-08)" + - "classifyValarms(rawVevent) for series-level reminderLeadMinutes derivation in expand.ts" + - "computeAlertInstantUtc(start, leadDays, tz) for all-day absolute DATE-TIME trigger" + - "extractValarms(rawVevent) preserve-on-edit re-attachment via addSubcomponent" +key_files: + created: [] + modified: + - apps/api/src/routes/events.ts + - apps/api/src/broker/outboxWorker.ts + - apps/api/src/broker/sync.ts + - apps/api/src/broker/expand.ts + - apps/api/tests/broker/outboxWorker.test.ts + - apps/api/tests/broker/sync.test.ts + - apps/api/tests/broker/expand.test.ts +decisions: + - "D-REMIND-ABSENT: absent field (not in payload) = no-change path (D-08); Object.prototype.hasOwnProperty.call distinguishes absent from null — mirrors WR-01 for VALARM preservation" + - "D-REMIND-EXPAND: reminderLeadMinutes derived inside expandOccurrences via classifyValarms(rawVevent) — self-contained; consistent with sync.ts derivation (both consume the same VEVENT source)" + - "D-REMIND-ALLDAY-LEADDAYS: all-day leadDays = reminderLeadMinutes / 1440 (consistent with D-05 mapping); computeAlertInstantUtc called at drain time (not enqueue) for correct DST" +metrics: + duration_minutes: 8 + completed_date: "2026-06-14" + tasks_completed: 3 + files_modified: 7 +--- + +# Phase 11 Plan 03: reminderLeadMinutes End-to-End Plumbing Summary + +`reminderLeadMinutes` round-trips end-to-end: `eventFieldsSchema` ingress validation → outbox payload drain → `buildVeventString` VALARM emission → Fastmail PUT; sync.ts parses native VALARMs into the DB column (scheduler ground truth); `CalendarOccurrence` surfaces the value for edit-mode picker pre-population. + +## Tasks Completed + +| Task | Description | RED Commit | GREEN Commit | +|------|-------------|------------|--------------| +| 1 | Schema field + outbox worker preserve-on-edit + buildVeventString wiring | 79f6871 | 4f42b75 | +| 2 | sync.ts VALARM → reminderLeadMinutes upsert | cdca930 | 1cc0d72 | +| 3 | Surface reminderLeadMinutes on CalendarOccurrence + GET select | 7df11d2 | e171431 | + +## Schema Field (eventFieldsSchema + outboxPayloadSchema) + +Both schemas now have: +```typescript +reminderLeadMinutes: z.number().int().min(0).nullable().optional() +``` + +Four-state semantics (D-08): +- `absent` — field not present in payload; UPDATE branch preserves existing VALARM verbatim (D-08) +- `null` — explicit "None" → VALARM cleared on write-back +- `0` — same-day all-day (9 AM on event date); timed 0 = None (D-06) +- `positive` — N minutes before event start (timed) or N/1440 days before (all-day) + +## hasExplicitReminder Preserve-on-Edit Path (CAL-14) + +Pattern mirrors the WR-01 `hasExplicitRecurrence` + `_preservedRrule` preserve path: + +UPDATE branch computes: +- `const hasExplicitReminder = Object.prototype.hasOwnProperty.call(fields, 'reminderLeadMinutes')` +- `!hasExplicitReminder` + rawVevent has VALARMs → `valarmsToPreserve = extractValarms(rawVevent)` (preserve verbatim via addSubcomponent) +- `hasExplicitReminder` + allDay + value → `allDayAlertInstantUtcUpdate = computeAlertInstantUtc(start, lead/1440, tz)` +- `hasExplicitReminder` + null → clear (no valarms, reminderLeadMinutes=null passed to buildVeventString) + +buildVeventString call extended with: `reminderLeadMinutes: hasExplicitReminder ? fields.reminderLeadMinutes : undefined`, `valarms: valarmsToPreserve`, `allDayAlertInstantUtc: allDayAlertInstantUtcUpdate`. + +CREATE branch: no preserve path (new event always carries explicit picker value). Computes `allDayAlertInstantUtcCreate` from `reminderLeadMinutes / 1440` when allDay. + +## sync.ts Derivation Rule + +```typescript +import { classifyValarms } from './vevent.js'; +const alarmClass = classifyValarms(obj.data as string); +const reminderLeadMinutesValue: number | null = + alarmClass.kind === 'preset' || alarmClass.kind === 'offlist' + ? alarmClass.leadMinutes + : null; +``` + +Written to both `.values({...})` and `.onDuplicateKeyUpdate({ set: {...} })`. Classification rules: +- `preset` or `offlist` → `leadMinutes` (scheduler ground truth) +- `custom` (absolute DATE-TIME or multiple VALARMs) → `null` (D-07/NOTIF-05) +- `none` → `null` (no VALARM) + +No schema DDL change — `reminder_lead_minutes` column was added by Phase 10 migration. + +## CalendarOccurrence Propagation (D-10) + +```typescript +// CalendarOccurrence interface: +reminderLeadMinutes: number | null; // after hasRrule +``` + +Derived once per VEVENT in `expandOccurrences` via `classifyValarms(rawVevent)` (series-level, D-10). All occurrences inherit the master's value. Added to both non-recurring and recurring occurrence construction branches. GET `/api/events` select also includes `calendarEvents.reminderLeadMinutes` for edit-mode pre-population. + +## Verification Results + +- `pnpm --filter @familysync/api exec vitest run tests/broker/ tests/broker/vevent.test.ts`: **132/132 PASS** +- `pnpm --filter @familysync/api exec tsc --noEmit`: **CLEAN (0 errors)** +- No `drizzle-kit push` introduced; no schema.ts DDL change +- 13 new tests added: 4 (Task 1 outboxWorker), 5 (Task 2 sync), 4 (Task 3 expand) + +## Deviations from Plan + +None — plan executed exactly as written. + +## Known Stubs + +None. All wiring is complete end-to-end. No placeholder values or TODO markers. + +## Threat Flags + +No new threat surface. All new fields are bounded integers validated by Zod at both ingress (eventFieldsSchema) and drain (outboxPayloadSchema) — T-11-06 mitigated. `classifyValarms` is try/catch safe — T-11-07 mitigated. The VALARM preserve path (`extractValarms`) rides the existing CR-02 scoped query on the writing member's calendar — T-11-08 unchanged. + +## Self-Check: PASSED + +Files exist: +- FOUND: apps/api/src/routes/events.ts +- FOUND: apps/api/src/broker/outboxWorker.ts +- FOUND: apps/api/src/broker/sync.ts +- FOUND: apps/api/src/broker/expand.ts +- FOUND: apps/api/tests/broker/outboxWorker.test.ts +- FOUND: apps/api/tests/broker/sync.test.ts +- FOUND: apps/api/tests/broker/expand.test.ts + +Commits exist: +- 79f6871: test(11-03) RED Task 1 +- 4f42b75: feat(11-03) GREEN Task 1 +- cdca930: test(11-03) RED Task 2 +- 1cc0d72: feat(11-03) GREEN Task 2 +- 7df11d2: test(11-03) RED Task 3 +- e171431: feat(11-03) GREEN Task 3 + +Key exports verified: +- reminderLeadMinutes in eventFieldsSchema: CONFIRMED (grep: `reminderLeadMinutes: z.number()`) +- hasExplicitReminder in outboxWorker.ts: CONFIRMED +- reminderLeadMinutes on CalendarOccurrence: CONFIRMED +- classifyValarms import in sync.ts: CONFIRMED +- classifyValarms import in expand.ts: CONFIRMED diff --git a/.planning/phases/11-per-event-reminders/11-04-SUMMARY.md b/.planning/phases/11-per-event-reminders/11-04-SUMMARY.md new file mode 100644 index 0000000..cfa9bb8 --- /dev/null +++ b/.planning/phases/11-per-event-reminders/11-04-SUMMARY.md @@ -0,0 +1,191 @@ +--- +phase: 11-per-event-reminders +plan: "04" +subsystem: ui +tags: [reminder, picker, EventForm, allDay, VALARM, client-types, playwright, cal-13, cal-14] +dependency_graph: + requires: + - "Plan 11-03 (CalendarOccurrence.reminderLeadMinutes, GET /api/events surfaces the field, eventFieldsSchema accepts reminderLeadMinutes)" + provides: + - "Reminder ` in EventForm with edit-mode pre-population, Custom-kept preserve path, and payload mapping (null/integer/absent) wired to CreateEventPayload** + +## Performance + +- **Duration:** ~60 min (Tasks 1+2 implementation) + playwright-cli smoke (Task 3) +- **Started:** 2026-06-14 +- **Completed:** 2026-06-14 +- **Tasks:** 3 (Tasks 1+2 autonomous; Task 3 checkpoint:human-verify — APPROVED) +- **Files modified:** 4 (client.ts, EventForm.tsx, EventForm.test.tsx, EventDetailPopover.test.tsx) + 7 prettier-only (style commit) + +## Accomplishments + +- `reminderLeadMinutes: number | null` added to `CalendarOccurrence`; `reminderLeadMinutes?: number | null` added to `CreateEventPayload` — four-state contract (absent/null/0/positive) mirrors the server schema (D-08) +- Reminder ``, allDay swap, edit pre-population, payload mapping | VERIFIED | `id="event-reminder"` at line 953; allDay conditional option set swap at lines 962–1014; `handleAllDayToggle` resets `setReminderValue('__none__')` at line 359; `reminderPayload` assembled and spread into `payload` at line 494; `deriveReminderValue` drives edit pre-population from `occurrence.reminderLeadMinutes` | +| `apps/pwa/src/components/EventForm.test.tsx` | Component tests: default None, allDay swap + reset, edit pre-population, payload mapping | VERIFIED | 54 PWA tests pass including Phase 11 describe block: D-01 default None, D-02/D-03 allDay swap + reset, edit pre-population (30 → "30 minutes before", 1440 all-day → "1 day before (9 AM)", off-list 45 → synthetic), payload mapping (None→null, preset→integer, Custom-kept→field absent) | + +--- + +### Key Link Verification + +| From | To | Via | Status | Details | +|---|---|---|---|---| +| EventForm reminder select | `CreateEventPayload.reminderLeadMinutes` | submit handler maps `reminderValue` → `reminderPayload` → spread into `payload` | WIRED | `reminderPayload = { reminderLeadMinutes: null \| parsed }` assembled at lines 467–477; spread at line 494 | +| `edit-mode load` | `occurrence.reminderLeadMinutes` | `deriveReminderValue` called on mount at line 321 | WIRED | `setReminderValue(deriveReminderValue(occurrence?.reminderLeadMinutes ?? null, occAllDay))` | +| `outboxWorker` UPDATE branch | `extractValarms(rawVevent)` | `hasExplicitReminder` gate at line 493 | WIRED | `if (!hasExplicitReminder && freshEtagRows.length > 0 && freshEtagRows[0].rawVevent) { valarmsToPreserve = extractValarms(...) }` | +| `sync.ts` upsert | `classifyValarms(rawVevent)` | `reminderLeadMinutesValue` derivation at line 137 | WIRED | `const alarmClass = classifyValarms(obj.data as string)` → written to both values() and onDuplicateKeyUpdate() | +| `GET /api/events` select | `expandOccurrences → CalendarOccurrence.reminderLeadMinutes` | `calendarEvents.reminderLeadMinutes` in select + `classifyValarms(rawVevent)` in expandOccurrences | WIRED | Select at events.ts line 181; derivation in expand.ts line 245 | +| `runReminderCheck` timed query | `calendarEvents.reminderLeadMinutes` | SQL `WHERE reminder_lead_minutes IS NOT NULL` | WIRED | `sql\`${calendarEvents.reminderLeadMinutes} IS NOT NULL\`` at reminderScheduler.ts line 143 | +| `notification.body` | `humanizeLeadMinutes` | Function call replacing hardcoded string | WIRED | `body: humanizeLeadMinutes(event.reminderLeadMinutes)` at reminderScheduler.ts line 292 | +| `startReminderScheduler` | `index.ts` server startup | Import + call inside `isMainModule` guard | WIRED | `import { startReminderScheduler }` at index.ts:18; `startReminderScheduler()` at index.ts:149 | + +--- + +### Data-Flow Trace (Level 4) + +| Artifact | Data Variable | Source | Produces Real Data | Status | +|---|---|---|---|---| +| `EventForm.tsx` reminder select | `reminderValue` (state) | `deriveReminderValue(occurrence.reminderLeadMinutes)` on mount; user interaction | Yes — from DB-backed occurrence or user selection | FLOWING | +| `reminderScheduler.ts` `runReminderCheck` | `calendarEvents.reminderLeadMinutes` | DB column (populated by sync.ts upsert from native VALARMs, or set by outboxWorker on create/edit) | Yes — real DB query with `IS NOT NULL` filter | FLOWING | +| `outboxWorker.ts` preserve path | `valarmsToPreserve` | `extractValarms(freshEtagRows[0].rawVevent)` — reads live rawVevent from CalDAV GET | Yes — live VALARM components re-attached verbatim | FLOWING | + +--- + +### Behavioral Spot-Checks + +| Behavior | Command | Result | Status | +|---|---|---|---| +| `buildTimedValarm(30)` produces `TRIGGER:-PT30M`, no `VALUE=TEXT` | Asserted in vevent.test.ts line 208–212 (vitest run 37/37) | PASS | PASS | +| `classifyValarms` returns `{kind:'offlist', leadMinutes:45}` for `TRIGGER:-PT45M` | Asserted in vevent.test.ts line 348 (vitest run) | PASS | PASS | +| `computeAlertInstantUtc('2026-06-15', 0, 'America/New_York')` → `2026-06-15T13:00:00.000Z` | Asserted in vevent.test.ts DST tests (vitest run) | PASS | PASS | +| Full API suite (327 tests) | `DB_HOST=127.0.0.1 pnpm --filter @familysync/api exec vitest run` | 327/327 PASS | PASS | +| Full PWA suite (201 tests) | `pnpm --filter @familysync/pwa exec vitest run` | 201/201 PASS | PASS | +| API typecheck | `pnpm --filter @familysync/api exec tsc --noEmit` | 0 errors | PASS | +| PWA typecheck | `pnpm --filter @familysync/pwa exec tsc --noEmit` | 0 errors | PASS | +| No `node-cron` in reminderScheduler | `grep node-cron reminderScheduler.ts` | no match | PASS | +| `startReminderScheduler` wired in index.ts | `grep startReminderScheduler apps/api/src/index.ts` | lines 18, 149 | PASS | + +--- + +### Probe Execution + +No probe scripts declared or applicable for this phase. + +--- + +### Requirements Coverage + +| REQ-ID | Source Plan | Description | Status | Evidence | +|---|---|---|---|---| +| CAL-13 | 11-01, 11-03, 11-04 | User picks reminder lead; choice serialized as VALARM on event written to Fastmail | SATISFIED | `eventFieldsSchema` field; outboxWorker CREATE/UPDATE wiring; `buildTimedValarm`/`buildAllDayValarm`; EventForm picker; 327 tests pass | +| CAL-14 | 11-01, 11-03, 11-04 | Editing an event preserves existing VALARM — never silently stripped | SATISFIED | `hasExplicitReminder` absent-vs-null sentinel; `extractValarms` preserve path; CAL-14 test passes; `__custom__` → field omitted from payload → server preserves | +| NOTIF-04 | 11-02 | Reminder fires at event's chosen lead time, not hardcoded 15-min | SATISFIED | Per-event `fireTime = dtstartUtc - lead * 60s`; `NOTIF-04` test passes; `humanizeLeadMinutes` body from DB lead | +| NOTIF-05 | 11-02 | No reminder set → no push | SATISFIED | `IS NOT NULL` SQL filter; timed-0 skip `if (lead === 0) continue`; `NOTIF-05` NULL and timed-0 tests pass; personal calendar restriction dropped | +| NOTIF-06 | 11-01, 11-02 | All-day fires at 9 AM local; exactly-once across catch-up and reschedule | SATISFIED | `computeAlertInstantUtc` (DST-correct); `uid:dtstartMs` dedup; `pruneMs = start-of-next-day` for all-day; all-day 9 AM and reschedule tests pass | + +No orphaned requirements for Phase 11. REQUIREMENTS.md traceability table shows CAL-13, CAL-14, NOTIF-04, NOTIF-05, NOTIF-06 all mapped to Phase 11; all plans' `requirements` fields cover these IDs completely with no gaps or extras. + +--- + +### Anti-Patterns Found + +| File | Line | Pattern | Severity | Impact | +|---|---|---|---|---| +| `apps/pwa/src/components/EventForm.tsx` | 701, 1086, 1121, 1135 | `placeholder=` | Info | HTML input placeholder attributes (not stub indicators). Normal UI copy. No impact. | + +No `TBD`, `FIXME`, `XXX`, `return null`, empty handlers, or stub patterns found in any Phase 11 modified file. + +--- + +### Human Verification Required + +#### 1. Live end-to-end reminder round-trip (Fastmail + push) + +**Test:** Using a household member account with a connected Fastmail provider (not the dev-bypass user 1), create a timed event with a 30-minute reminder. Open the event in the PWA to confirm the reminder shows "30 minutes before". Wait for the push notification to fire at T-30. Then open the same event in Fastmail Web or Apple Calendar and confirm the VALARM is present. + +**Expected:** (a) PWA edit form shows "30 minutes before" pre-populated. (b) A push notification with body "Starts in 30 min" arrives ~30 minutes before event start. (c) Fastmail / Apple Calendar shows a reminder on the event. + +**Why human:** Dev-bypass user (id 1) has no Fastmail provider configured (`needsProviderSetup=true`, empty `member_credentials`/`calendars`). A live CalDAV PUT (outbox → Fastmail) and a real VAPID push to a subscribed device cannot be exercised without a provisioned provider. All server-side paths are validated by 327 automated tests and a route-mocked Playwright smoke. Only the live Fastmail round-trip and real device push require a human + live device. Tracked in backlog 999.19. + +--- + +### Gaps Summary + +No gaps. All 5 ROADMAP success criteria are verified (1 with an authorized override for the deliberate all-day behavior evolution from "disabled" to "preset swap"). All 327 API tests and 201 PWA tests pass. All typechecks clean. No debt markers. The one item in the `human_verification` section is a dev-environment caveat (no live Fastmail provider in dev), not an implementation gap. + +--- + +_Verified: 2026-06-14T07:38:00Z_ +_Verifier: Claude (gsd-verifier)_ diff --git a/apps/api/src/broker/expand.ts b/apps/api/src/broker/expand.ts index d535674..e09a8ab 100644 --- a/apps/api/src/broker/expand.ts +++ b/apps/api/src/broker/expand.ts @@ -19,6 +19,7 @@ */ import ICAL from 'ical.js'; +import { classifyValarms } from './vevent.js'; /** * A concrete calendar event occurrence ready for UI consumption. @@ -33,6 +34,11 @@ import ICAL from 'ical.js'; * Color routing (D-06, CAL-02): * The client routes calendarId for Schedule-X as: isShared ? 'shared' : String(ownerUserId) * The DB calendarId is also present for reference but NOT used as the Schedule-X calendarId. + * + * Phase 11 Plan 03 (D-10): reminderLeadMinutes is a series-level property — all occurrences + * of a recurring master inherit the master's value. Derived from the VEVENT's VALARM via + * classifyValarms (same logic as sync.ts so the scheduler and the GET response agree). + * NULL-vs-0 is preserved (D-06): null = no reminder; 0 = same-day all-day; positive = lead. */ export interface CalendarOccurrence { /** `ev--` — Schedule-X-safe stable id (see makeOccurrenceId) */ @@ -66,6 +72,21 @@ export interface CalendarOccurrence { description: string | null; /** True when this occurrence belongs to a recurring series (has RRULE). False for single events. */ hasRrule: boolean; + /** + * Per-event reminder lead in minutes (Phase 11, CAL-13, D-06). + * Series-level: all occurrences inherit the master's value (D-10). + * null — no reminder / custom/absolute VALARM not reducible to a single lead + * 0 — same-day all-day (fire at 9 AM on event date) + * positive — N minutes before event start (timed) or N/1440 days before (all-day) + */ + reminderLeadMinutes: number | null; + /** + * True when the master event's alarm is a custom/absolute/multi-VALARM not reducible + * to a single before-event lead (CAL-14, CR-01, Phase 11 Plan 05). + * When true, reminderLeadMinutes is always null and the edit form must initialize the + * picker to '__custom__' to emit an absent payload field and preserve the original VALARM. + */ + reminderIsCustom: boolean; } /** @@ -223,6 +244,19 @@ export function expandOccurrences( // Capture once — used in both the non-recurring and recurring branches to populate hasRrule. const isRecurring = event.isRecurring(); + // Phase 11 Plan 03 (D-10): series-level reminderLeadMinutes — derived once from the master + // event's VALARM via classifyValarms. All occurrences inherit this value (series-level, D-10). + // preset/offlist → specific leadMinutes; custom/none → null (D-07/NOTIF-05). + // classifyValarms wraps ICAL.parse in try/catch (T-11-07 safe); safe on parse failure → null. + // + // Phase 11 Plan 05 (CR-01): reminderIsCustom — surface the 'custom' classification so the + // edit form can initialize the picker to '__custom__' and emit an absent payload field, + // preserving the original VALARM via the outboxWorker D-08 preserve path. + const alarmClass = classifyValarms(rawVevent); + const reminderLeadMinutes: number | null = + alarmClass.kind === 'preset' || alarmClass.kind === 'offlist' ? alarmClass.leadMinutes : null; + const reminderIsCustom: boolean = alarmClass.kind === 'custom'; + // --- 4. Non-recurring event: single occurrence check --- if (!isRecurring) { if (dtstart.compare(rangeStart) >= 0 && dtstart.compare(rangeEnd) < 0) { @@ -258,6 +292,8 @@ export function expandOccurrences( location: event.location ?? null, description: event.description ?? null, hasRrule: isRecurring, // always false in the non-recurring branch + reminderLeadMinutes, // series-level (D-10) + reminderIsCustom, // CR-01 (Plan 05): true when alarm is absolute/multi-VALARM }); } return occurrences; @@ -305,6 +341,8 @@ export function expandOccurrences( location: event.location ?? null, description: event.description ?? null, hasRrule: isRecurring, // always true in the recurring branch + reminderLeadMinutes, // series-level — all occurrences inherit the master's value (D-10) + reminderIsCustom, // CR-01 (Plan 05): true when alarm is absolute/multi-VALARM }); } diff --git a/apps/api/src/broker/outboxWorker.ts b/apps/api/src/broker/outboxWorker.ts index 608167e..0aced4d 100644 --- a/apps/api/src/broker/outboxWorker.ts +++ b/apps/api/src/broker/outboxWorker.ts @@ -26,6 +26,7 @@ * Source: poller.ts pattern (runPoll/startBrokerPoller) */ import { z } from 'zod'; +import ICAL from 'ical.js'; import { and, eq, lte } from 'drizzle-orm'; import { db } from '../db/client.js'; import { calendarEvents, calendarOutbox, calendars, memberCredentials } from '../db/schema.js'; @@ -33,7 +34,13 @@ import { createFastmailClient } from './client.js'; import { decryptPassword } from './crypto.js'; import { syncCalendar } from './sync.js'; import { createCalendarEvent, updateCalendarEvent, deleteCalendarEvent } from './write.js'; -import { buildVeventString, extractRruleString, RRULE_PRESETS } from './vevent.js'; +import { + buildVeventString, + extractRruleString, + extractValarms, + computeAlertInstantUtc, + RRULE_PRESETS, +} from './vevent.js'; import type { FastmailClient } from './client.js'; import { dispatchEventChange } from '../lib/eventChangeDispatcher.js'; import { onOutboxDrain } from '../lib/outboxTrigger.js'; @@ -91,6 +98,12 @@ const outboxPayloadSchema = z .regex(/^\d{4}-\d{2}-\d{2}$/) .optional(), // 'YYYY-MM-DD' → RRULE UNTIL recurrenceCount: z.number().int().min(1).optional(), // integer ≥ 1 → RRULE COUNT + // Phase 11: per-event reminder lead in minutes (CAL-13/CAL-14, D-08). + // absent — field not present; preserve existing VALARM verbatim (no-change path, D-08) + // null — explicit "None" → clear the VALARM on write-back + // 0 — same-day all-day reminder (fire 9 AM on event date); timed 0 = None (D-06) + // positive int — N minutes before event start (timed) or N/1440 days before (all-day) + reminderLeadMinutes: z.number().int().min(0).max(10080).nullable().optional(), }) .passthrough(); @@ -423,6 +436,11 @@ async function dispatchRow(row: OutboxRow): Promise { // deliberate user change. Read rawVevent in the same scoped query as the fresh etag. let preservedRrule: string | undefined; const hasExplicitRecurrence = Object.prototype.hasOwnProperty.call(fields, 'recurrence'); + // CAL-14: mirrors the WR-01 hasExplicitRecurrence pattern for VALARM preservation. + // When the payload omits `reminderLeadMinutes` entirely (no-change, D-08), we preserve + // the existing VALARM verbatim from rawVevent via extractValarms. An explicit null clears + // the VALARM; an explicit value (timed or all-day) replaces it. + const hasExplicitReminder = Object.prototype.hasOwnProperty.call(fields, 'reminderLeadMinutes'); const rruleFromPayload = fields.recurrence && fields.recurrence !== 'none' ? RRULE_PRESETS[fields.recurrence as string] @@ -465,6 +483,26 @@ async function dispatchRow(row: OutboxRow): Promise { preservedRrule = extractRruleString(freshEtagRows[0].rawVevent); } + // CAL-14: resolve VALARM for the UPDATE branch. + // absent (no hasExplicitReminder) + rawVevent has VALARMs → preserve verbatim (D-08) + // explicit null → clear (no VALARM emitted by buildVeventString) + // explicit value + allDay + valid start → compute 9 AM absolute DATE-TIME trigger (D-04) + // explicit value + timed → pass through to buildTimedValarm + let valarmsToPreserve: ICAL.Component[] | undefined; + let allDayAlertInstantUtcUpdate: Date | undefined; + if (!hasExplicitReminder && freshEtagRows.length > 0 && freshEtagRows[0].rawVevent) { + valarmsToPreserve = extractValarms(freshEtagRows[0].rawVevent); + } else if ( + hasExplicitReminder && + fields.reminderLeadMinutes != null && + fields.allDay && + fields.start + ) { + const tz = process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone; + const leadDays = fields.reminderLeadMinutes / 1440; + allDayAlertInstantUtcUpdate = computeAlertInstantUtc(fields.start, leadDays, tz); + } + // D-06: assemble the final RRULE string, combining the preset or preserved RRULE // with an optional UNTIL/COUNT bound from the payload. // Pitfall 3: on series edit with bound change only (no new preset), parse the preserved @@ -489,6 +527,10 @@ async function dispatchRow(row: OutboxRow): Promise { location: fields.location, description: fields.description, rruleString: finalRruleString, + // CAL-13/CAL-14: VALARM wiring — absent preserves, null clears, value replaces + reminderLeadMinutes: hasExplicitReminder ? fields.reminderLeadMinutes : undefined, + valarms: valarmsToPreserve, + allDayAlertInstantUtc: allDayAlertInstantUtcUpdate, }); response = await updateCalendarEvent(client, row.calendarObjectUrl, icsString, etagForPut); @@ -558,6 +600,15 @@ async function dispatchRow(row: OutboxRow): Promise { preservedRrule, ); + // CAL-13: VALARM wiring for CREATE branch (no rawVevent source — new event always + // carries an explicit picker value or no reminder at all; no preserve path needed). + let allDayAlertInstantUtcCreate: Date | undefined; + if (fields.reminderLeadMinutes != null && fields.allDay && fields.start) { + const tz = process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone; + const leadDays = fields.reminderLeadMinutes / 1440; + allDayAlertInstantUtcCreate = computeAlertInstantUtc(fields.start, leadDays, tz); + } + const { icsString } = buildVeventString({ uid: row.uid, summary: fields.title, @@ -567,6 +618,9 @@ async function dispatchRow(row: OutboxRow): Promise { location: fields.location, description: fields.description, rruleString: finalRruleString, + // CAL-13: per-event reminder — pass through; null=clear, value=set, absent=no VALARM + reminderLeadMinutes: fields.reminderLeadMinutes, + allDayAlertInstantUtc: allDayAlertInstantUtcCreate, }); // Build a minimal DAVCalendar for the write wrapper (only url is needed) const davCalendar = { url: row.calendarUrl } as Parameters[1]; diff --git a/apps/api/src/broker/reminderScheduler.ts b/apps/api/src/broker/reminderScheduler.ts index 0e53b2c..39b4233 100644 --- a/apps/api/src/broker/reminderScheduler.ts +++ b/apps/api/src/broker/reminderScheduler.ts @@ -1,50 +1,67 @@ /** - * Reminder scheduler — shared-timed-event 15-min reminder scan. + * Reminder scheduler — per-event variable-lead reminder scan. * * Fires every minute via setInterval. Each tick calls runReminderCheck() which: * (node-cron 4.2.1 silently skipped scheduled executions in the long-running server * process; setInterval fires reliably in the same process — replaced to fix the silent skip.) - * 1. Queries shared (isShared=true) timed (allDay=false) events whose dtstartUtc - * falls in (now, now+16min] — strictly after now (future only; excludes - * already-started events) and at most 16 min out. Because the lower bound - * is `now` and not `now+14min`, a missed/late cron tick is recovered on the - * next scan: as long as the event hasn't started, it re-appears in the window. - * 2. Cross-joins with ALL push_subscriptions (shared reminder → every member). - * 3. Deduplicates on uid ALONE so a recovered reminder fires EXACTLY ONCE - * no matter how many consecutive ticks the event spends in the window. - * CAVEAT: if an event's dtstart is rescheduled earlier after a reminder - * already fired, it will not re-fire in v1 (acceptable for household use). - * 4. Calls dispatchPush once per subscription per deduped event. + * + * 1. Queries all events (shared AND personal) with a non-null reminderLeadMinutes + * whose dtstartUtc falls in the pre-filter window (now, now + MAX_LEAD_MINUTES]. + * allDay events are also included; their alert time is computed in JS. + * + * 2. For each event, computes the fire time: + * - TIMED (allDay=false): fireTime = dtstartUtc - reminderLeadMinutes minutes. + * if reminderLeadMinutes === 0: skip (D-06: 0 on timed = None, same as NULL). + * Fires when fireTime is in the catch-up window (now - 60s, now]. + * - ALL-DAY (allDay=true): handled by Plan 11-02 Task 3 (imported computeAlertInstantUtc). + * + * 3. Cross-joins with ALL push_subscriptions (reminder → every member, member-count-agnostic). + * + * 4. Deduplicates on uid:dtstartMs compound key so: + * - The same event fires EXACTLY ONCE regardless of consecutive ticks in window. + * - A rescheduled event (same uid, new dtstartMs) fires again (new compound key). + * NOTIF-06: uid:dtstartMs dedup + mark-sent-after-dispatch (WR-01) preserved. + * + * 5. Calls dispatchPush once per subscription per deduped event. * * Decisions enforced here: - * D-05 — isShared=true in the SQL WHERE (not in copy); personal events excluded. - * D-06 — fixed 16-min catch-up window; no per-event customisation. - * D-07 — allDay=false in the SQL WHERE; all-day events are silently excluded. + * NOTIF-04 — fire at event's chosen lead time, not hardcoded 15-min lead. + * NOTIF-05 — NULL guard (IS NOT NULL) + timed-0 skip (no push when no reminder set). + * isShared restriction DROPPED — personal events fire too. + * NOTIF-06 — uid:dtstartMs dedup; all-day 9 AM fire branch (Task 3). + * D-06 — timed events with reminderLeadMinutes=0 are treated as None (skipped). + * D-09 — humanized push body via humanizeLeadMinutes(reminderLeadMinutes). * D-11 — every push goes through dispatchPush which always sends a visible notification. * D-12 — in-memory Map; no Redis; single-process deployment. - * Key: bare event uid. Value: event's dtstart in ms (for pruning). + * Key: `uid:dtstartMs` (compound). Value: dtstart in ms (for pruning). * Acceptable data loss on process restart for a two-person household. - * D-16 — empty shared-calendar set (Family calendar not yet created) produces zero - * sends and no crash (cross-join on empty table returns no rows). * * Threat mitigations: - * T-05-17 — D-05 enforced in QUERY (WHERE isShared=true), not in copy. - * T-05-18 — in-memory dedup Map keyed by uid; per-event try/catch. + * T-11-04 — Window capped at MAX_LEAD_MINUTES (2880 min); JS per-event fire-time filter. + * T-11-05 — uid:dtstartMs dedup + mark-sent-after-dispatch (WR-01); prune prevents growth. * T-05-19 — per-subscription try/catch; dispatchPush already swallows 410/404. */ -import { and, eq, gt, lte, sql } from 'drizzle-orm'; +import { and, gt, lte, sql } from 'drizzle-orm'; import { db } from '../db/client.js'; -import { calendars, calendarEvents, pushSubscriptions } from '../db/schema.js'; +import { calendarEvents, pushSubscriptions } from '../db/schema.js'; import { dispatchPush } from '../lib/pushDispatcher.js'; +import { computeAlertInstantUtc } from './vevent.js'; import type { NotificationPayload } from '../lib/pushDispatcher.js'; +// Maximum lead time preset (2880 min = 2 days for timed; 7 days for all-day is handled separately) +// Pre-filter window upper bound for timed events: fetch events starting up to MAX_LEAD_MINUTES out. +// JS-side per-event filter then checks exact fire time. +const MAX_LEAD_MINUTES = 2880; + +// Maximum look-ahead for all-day events: 7 days (10080 min lead) +const MAX_ALLDAY_LEAD_DAYS = 7; + // ── In-memory dedup (D-12: single-process, no Redis) ───────────────────────── -// Key: event uid (bare string — no minuteBucket suffix). +// Key: `uid:dtstartMs` — compound key prevents re-fire on same event across ticks, +// while allowing re-fire when the same uid is rescheduled to a new dtstart. // Value: event's dtstart in ms — used by CR-01 pruning to drop started events. -// Prevents double-fire when the same event sits in the catch-up window across -// multiple consecutive ticks (cross-tick exactly-once guarantee). -// Acceptable data loss on process restart for a two-person household. +// NOTIF-06: exactly-once guarantee per uid:dtstartMs pair. const sentReminders = new Map(); // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -59,6 +76,33 @@ function yyyyMmDd(d: Date): string { return `${y}-${m}-${day}`; } +/** + * Humanize a lead time in minutes to a human-readable string. + * + * CR-02 (Phase 11 Plan 05): added isAllDay parameter. All-day events use day-granularity + * wording ("Today", "Tomorrow", "In N days/1 week") because the lead is day-based + * and "Starts in 0 min" for a same-day all-day event (lead=0) is factually wrong. + * Timed events keep the existing "Starts in N min/hours/days" wording (default isAllDay=false). + * + * D-09: "Starts in 30 min" / "Starts in 1 hour" / "Starts in 1 day" etc. + * Branch order is important — check < 120 before the hours calculation to + * prevent Math.round(90/60)=2 erroneously giving "2 hours" for a 90-min lead. + */ +export function humanizeLeadMinutes(leadMinutes: number, isAllDay = false): string { + if (isAllDay) { + // Day-granularity wording for all-day event reminders + if (leadMinutes === 0) return 'Today'; + if (leadMinutes <= 1440) return 'Tomorrow'; + if (leadMinutes === 10080) return 'In 1 week'; + return `In ${Math.round(leadMinutes / 1440)} days`; + } + if (leadMinutes < 60) return `Starts in ${leadMinutes} min`; + if (leadMinutes < 120) return 'Starts in 1 hour'; + if (leadMinutes < 1440) return `Starts in ${Math.round(leadMinutes / 60)} hours`; + if (leadMinutes < 2880) return 'Starts in 1 day'; + return `Starts in ${Math.round(leadMinutes / 1440)} days`; +} + // ── Core scan ───────────────────────────────────────────────────────────────── /** @@ -69,25 +113,35 @@ function yyyyMmDd(d: Date): string { * default = new Date() (current wall-clock time). */ export async function runReminderCheck(now = new Date()): Promise { - const windowEnd = new Date(now.getTime() + 16 * 60 * 1000); + // Pre-filter window for TIMED events: fetch events whose dtstartUtc is in + // (now, now + MAX_LEAD_MINUTES]. JS-side per-event filter then checks + // the exact fire time: dtstartUtc - reminderLeadMinutes MINUTES ∈ (now - 60s, now]. + // T-11-04: window capped at max preset lead (2880 min). + const timedWindowEnd = new Date(now.getTime() + MAX_LEAD_MINUTES * 60 * 1000); - // Single query: events (isShared, timed, in catch-up window) cross-joined with ALL - // push_subscriptions. The cross-join (sql`1=1`) fans every matching event out - // to every subscriber — shared reminder, every member notified (D-05, member-agnostic). + // Pre-filter window for ALL-DAY events: fetch all-day events with non-null lead + // whose dtstartDate is within the next MAX_ALLDAY_LEAD_DAYS days. + // JS-side: compute the 9 AM local alert instant and check the catch-up window. + const allDayWindowEnd = new Date(now.getTime() + MAX_ALLDAY_LEAD_DAYS * 24 * 60 * 60 * 1000); + + // Catch-up window: fire time must fall in (now - 60s, now] to handle one missed tick + const catchUpStart = new Date(now.getTime() - 60 * 1000); + + // ── TIMED events query ──────────────────────────────────────────────────── + // Drops: eq(calendars.isShared, true) [NOTIF-05: personal events fire too] + // eq(calendarEvents.allDay, false) [all-day handled separately below] + // Adds: reminderLeadMinutes IS NOT NULL [NOTIF-05: no reminder = no push] + // reminderLeadMinutes to .select() [needed for per-event fire time + body] // - // Window: (now, now+16min] — strictly greater-than now excludes already-started events; - // lte now+16min is the upper bound. Catching up: if the ideal 15-min tick was missed, - // the event is still > now on the next tick and will be found. - // - // With an empty push_subscriptions table (no subscribers yet) the INNER JOIN - // on sql`1=1` returns no rows — zero sends, no crash (D-16 empty-calendar analogue). - const rows = await db + // Cross-join with ALL push_subscriptions fans every event to every subscriber. + // With an empty push_subscriptions table the cross-join returns no rows (D-16 analogue). + const timedRows = await db .select({ uid: calendarEvents.uid, title: calendarEvents.title, dtstartUtc: calendarEvents.dtstartUtc, allDay: calendarEvents.allDay, - isShared: calendars.isShared, + reminderLeadMinutes: calendarEvents.reminderLeadMinutes, subId: pushSubscriptions.id, subUserId: pushSubscriptions.userId, subEndpoint: pushSubscriptions.endpoint, @@ -95,39 +149,91 @@ export async function runReminderCheck(now = new Date()): Promise { subAuth: pushSubscriptions.auth, }) .from(calendarEvents) - .innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id)) .innerJoin(pushSubscriptions, sql`1=1`) .where( and( - eq(calendars.isShared, true), // D-05: shared calendar only (in QUERY, not copy) - eq(calendarEvents.allDay, false), // D-07: timed events only - gt(calendarEvents.dtstartUtc, now), // strictly future (excludes already-started) - lte(calendarEvents.dtstartUtc, windowEnd), + sql`${calendarEvents.reminderLeadMinutes} IS NOT NULL`, // NOTIF-05: skip NULL (no reminder) + sql`${calendarEvents.allDay} = false`, // timed-only in this query + gt(calendarEvents.dtstartUtc, now), // strictly future (not already started) + lte(calendarEvents.dtstartUtc, timedWindowEnd), // within max-lead pre-filter window ), ); - // Group flat (event, subscription) rows by event uid so we can: - // a) dedup per event (not per (event, sub) pair), and - // b) fan out to ALL subscriptions for a deduped event in one pass. + // ── ALL-DAY events query ────────────────────────────────────────────────── + // Fetch all-day events with non-null reminder_lead_minutes whose dtstartDate + // is within the next MAX_ALLDAY_LEAD_DAYS days. Alert time computed in JS. + // D-06: reminderLeadMinutes=0 on all-day is VALID (same-day 9 AM) — do NOT skip 0. + const allDayRows = await db + .select({ + uid: calendarEvents.uid, + title: calendarEvents.title, + dtstartDate: calendarEvents.dtstartDate, + allDay: calendarEvents.allDay, + reminderLeadMinutes: calendarEvents.reminderLeadMinutes, + subId: pushSubscriptions.id, + subUserId: pushSubscriptions.userId, + subEndpoint: pushSubscriptions.endpoint, + subP256dh: pushSubscriptions.p256dh, + subAuth: pushSubscriptions.auth, + }) + .from(calendarEvents) + .innerJoin(pushSubscriptions, sql`1=1`) + .where( + and( + sql`${calendarEvents.reminderLeadMinutes} IS NOT NULL`, // NOTIF-05: skip NULL + sql`${calendarEvents.allDay} = true`, // all-day only + sql`${calendarEvents.dtstartDate} IS NOT NULL`, + // dtstartDate within next MAX_ALLDAY_LEAD_DAYS days (string comparison safe for ISO dates) + sql`${calendarEvents.dtstartDate} <= ${allDayWindowEnd.toISOString().slice(0, 10)}`, + ), + ); + + // ── Group flat (event, subscription) rows by uid:dtstartMs ─────────────── type SubRow = { id: number; userId: number; endpoint: string; p256dh: string; auth: string }; - const byUid = new Map< + const byKey = new Map< string, - { uid: string; title: string | null; dtstartUtc: Date; subs: SubRow[] } + { + uid: string; + title: string | null; + dtstartMs: number; // key component — used for compound dedup key + pruneMs: number; // when to prune: dtstartUtc for timed; end-of-event-date for all-day + reminderLeadMinutes: number; + isAllDay: boolean; // CR-02 (Plan 05): needed for allDay-aware humanizeLeadMinutes + dateStr: string; + subs: SubRow[]; + } >(); - for (const row of rows) { - if (!byUid.has(row.uid)) { - byUid.set(row.uid, { + // Process TIMED events + for (const row of timedRows) { + const dtstartUtc = row.dtstartUtc as Date; + const lead = row.reminderLeadMinutes as number; + + // D-06: skip timed events with reminderLeadMinutes=0 (0 on timed = None) + if (lead === 0) continue; + + // Per-event fire time check: fireTime = dtstartUtc - lead minutes + // Fire if fireTime ∈ (catchUpStart, now] — catch-up window of 60s + const fireTimeMs = dtstartUtc.getTime() - lead * 60 * 1000; + if (fireTimeMs <= catchUpStart.getTime() || fireTimeMs > now.getTime()) continue; + + const dtstartMs = dtstartUtc.getTime(); + const dedupKey = `${row.uid}:${dtstartMs}`; + + if (!byKey.has(dedupKey)) { + byKey.set(dedupKey, { uid: row.uid, title: row.title ?? null, - // dtstartUtc is guaranteed non-null by the allDay=false + gt/lte WHERE - dtstartUtc: row.dtstartUtc as Date, + dtstartMs, + pruneMs: dtstartMs, // timed: prune when event has started (dtstartUtc <= now) + reminderLeadMinutes: lead, + isAllDay: false, // CR-02: timed events use the existing "Starts in N..." wording + dateStr: yyyyMmDd(dtstartUtc), subs: [], }); } - // subId is undefined when cross-join produces no subscriptions row (empty table) if (row.subId != null) { - byUid.get(row.uid)!.subs.push({ + byKey.get(dedupKey)!.subs.push({ id: row.subId, userId: row.subUserId, endpoint: row.subEndpoint, @@ -137,26 +243,71 @@ export async function runReminderCheck(now = new Date()): Promise { } } - // Dedup and dispatch - for (const [uid, event] of byUid) { + // Process ALL-DAY events + const serverTz = process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone; + for (const row of allDayRows) { + // Drizzle's date() column type is Date|null in TS but mysql2 returns ISO string at runtime + const dtstartDate = row.dtstartDate as unknown as string; // 'YYYY-MM-DD' + const lead = row.reminderLeadMinutes as number; + // D-06: all-day 0 is valid (same-day 9 AM) — do NOT skip + + // Compute 9 AM local alert instant for (dtstartDate - lead/1440 days) + const leadDays = lead / 1440; + const alertInstant = computeAlertInstantUtc(dtstartDate, leadDays, serverTz); + + // Fire if alertInstant ∈ (catchUpStart, now] + const alertMs = alertInstant.getTime(); + if (alertMs <= catchUpStart.getTime() || alertMs > now.getTime()) continue; + + // Dedup key: uid + UTC-midnight ms of dtstartDate (stable across ticks, consistent) + const [y, m, d] = dtstartDate.split('-').map(Number) as [number, number, number]; + const dtstartMs = Date.UTC(y, m - 1, d); // UTC midnight of event date + const dedupKey = `${row.uid}:${dtstartMs}`; + + // Prune value for all-day events: start-of-next-day UTC. + // Using UTC midnight of dtstartDate would be pruned immediately (it's in the past by 9 AM); + // using end-of-event-day ensures the entry persists through the full fire window. + const pruneMs = Date.UTC(y, m - 1, d + 1); // UTC midnight of event date + 1 day + + if (!byKey.has(dedupKey)) { + byKey.set(dedupKey, { + uid: row.uid, + title: row.title ?? null, + dtstartMs, + pruneMs, + reminderLeadMinutes: lead, + isAllDay: true, // CR-02: all-day events use day-granularity wording + dateStr: dtstartDate, + subs: [], + }); + } + if (row.subId != null) { + byKey.get(dedupKey)!.subs.push({ + id: row.subId, + userId: row.subUserId, + endpoint: row.subEndpoint, + p256dh: row.subP256dh, + auth: row.subAuth, + }); + } + } + + // ── Dedup and dispatch ──────────────────────────────────────────────────── + for (const [dedupKey, event] of byKey) { try { - // Per-uid exactly-once dedup (D-12): keyed on bare uid, no minuteBucket. - // Same event fires at most once regardless of how many ticks it sits in window. - // CAVEAT: rescheduling dtstart earlier after a reminder fired will not re-fire (v1). - if (sentReminders.has(uid)) continue; - - const dateStr = yyyyMmDd(event.dtstartUtc); - - // Lead-accurate body: compute actual minutes to start, guarded to minimum 1. - const minutes = Math.max(1, Math.round((event.dtstartUtc.getTime() - now.getTime()) / 60000)); + // uid:dtstartMs exactly-once dedup (NOTIF-06, D-12). + // Same compound key fires at most once regardless of consecutive ticks in window. + // A new dtstartMs (rescheduled event) produces a new key and fires again. + if (sentReminders.has(dedupKey)) continue; const notification: NotificationPayload = { - // Null-safe title fallback (D-02 / NOTIF-01): title column may be NULL on rows - // created before the Plan 05-07 sync update. Use the uid rather than "undefined". - title: event.title ?? uid, - body: `Starts in ${minutes} min`, - tag: `reminder-${uid}`, - navigate: `/calendar?date=${dateStr}&event=${uid}`, + // Null-safe title fallback (D-02 / NOTIF-01): use uid if title is NULL. + title: event.title ?? event.uid, + // D-09: humanized body driven by the configured lead (DB ground truth), not live delta. + // CR-02 (Plan 05): pass isAllDay so all-day events get day-granularity wording. + body: humanizeLeadMinutes(event.reminderLeadMinutes, event.isAllDay), + tag: `reminder-${event.uid}`, + navigate: `/calendar?date=${event.dateStr}&event=${event.uid}`, }; // Fan out to every subscriber (member-count-agnostic) @@ -165,10 +316,8 @@ export async function runReminderCheck(now = new Date()): Promise { await dispatchPush(sub, notification); } catch (err) { // Per-subscription error isolation (T-05-19): one bad sub never aborts the cycle. - // dispatchPush itself never throws (it resolves after logging), so this outer - // catch is a belt-and-suspenders guard for unexpected errors. console.error( - `[broker/reminderScheduler] Error dispatching reminder to sub id=${sub.id} for event uid=${uid}:`, + `[broker/reminderScheduler] Error dispatching reminder to sub id=${sub.id} for event uid=${event.uid}:`, err instanceof Error ? err.message : String(err), ); } @@ -176,23 +325,26 @@ export async function runReminderCheck(now = new Date()): Promise { // WR-01: mark sent AFTER all dispatches have been attempted. Pre-marking before // dispatch prevents retry when dispatchPush throws — at-least-once delivery - // requires not pre-marking the uid. - sentReminders.set(uid, event.dtstartUtc.getTime()); + // requires not pre-marking. + // Store pruneMs (not dtstartMs) so the CR-01 prune removes the entry at the right time: + // - timed: pruneMs = dtstartUtc → pruned when event starts + // - all-day: pruneMs = start-of-next-day → pruned after the event date + sentReminders.set(dedupKey, event.pruneMs); } catch (err) { // Per-event error isolation (T-05-18): one bad event never aborts remaining events. console.error( - `[broker/reminderScheduler] Error processing event uid=${uid}:`, + `[broker/reminderScheduler] Error processing event uid=${event.uid}:`, err instanceof Error ? err.message : String(err), ); } } // CR-01: Prune started events from sentReminders to prevent unbounded growth. - // Any entry whose stored dtstart is <= now means the event has already started; - // it can be removed safely (it will never re-enter the (now, now+16min] window). - for (const [uid, dtstartMs] of sentReminders) { + // Any entry whose stored dtstartMs is <= now means the event has started; + // remove it safely (it will never re-enter any fire window). + for (const [key, dtstartMs] of sentReminders) { if (dtstartMs <= now.getTime()) { - sentReminders.delete(uid); + sentReminders.delete(key); } } } diff --git a/apps/api/src/broker/sync.ts b/apps/api/src/broker/sync.ts index b8b6cd1..f1f6312 100644 --- a/apps/api/src/broker/sync.ts +++ b/apps/api/src/broker/sync.ts @@ -22,6 +22,7 @@ import { and, eq, notInArray } from 'drizzle-orm'; import { db } from '../db/client.js'; import { calendars, calendarEvents } from '../db/schema.js'; import type { EventChange } from '../lib/eventChangeDispatcher.js'; +import { classifyValarms } from './vevent.js'; /** * Fetches all calendar objects for a given DAVCalendar, parses VEVENTs with ical.js, @@ -128,6 +129,15 @@ export async function syncCalendar( const locationValue: string | null = (vevent.getFirstPropertyValue('location') as string | null) ?? null; + // Phase 11: derive reminderLeadMinutes from the VALARM classification (D-07, NOTIF-05). + // classifyValarms wraps ICAL.parse in try/catch (T-11-07 mitigated). + // preset/offlist → single relative lead in minutes (scheduler ground truth). + // custom (absolute DATE-TIME or multiple VALARMs) → null (can't resolve a single lead). + // none → null (no VALARM present). + const alarmClass = classifyValarms(obj.data as string); + const reminderLeadMinutesValue: number | null = + alarmClass.kind === 'preset' || alarmClass.kind === 'offlist' ? alarmClass.leadMinutes : null; + // NOTIF-03: look up the existing row so we can classify add vs update. // One indexed lookup on (calendarId, uid) — cheap, covered by uniq_calendar_uid. // D-13: this read is from MariaDB cache, not Fastmail. @@ -154,6 +164,8 @@ export async function syncCalendar( dtstartDate: dtstartDateValue, allDay, hasRrule: isRecurring, + // Phase 11: ground-truth VALARM lead for the scheduler (D-07/NOTIF-05, T-11-07) + reminderLeadMinutes: reminderLeadMinutesValue, }) .onDuplicateKeyUpdate({ set: { @@ -165,6 +177,8 @@ export async function syncCalendar( dtstartDate: dtstartDateValue, allDay, hasRrule: isRecurring, + // Phase 11: keep reminderLeadMinutes current on re-sync (native client may change VALARM) + reminderLeadMinutes: reminderLeadMinutesValue, updatedAt: new Date(), }, }); diff --git a/apps/api/src/broker/vevent.ts b/apps/api/src/broker/vevent.ts index 38609d4..77de856 100644 --- a/apps/api/src/broker/vevent.ts +++ b/apps/api/src/broker/vevent.ts @@ -30,8 +30,54 @@ export interface NewEventParams { description?: string; rruleString?: string; // e.g. 'FREQ=WEEKLY;BYDAY=MO' — omit for non-recurring (CAL-07) dtstamp?: Date; // omit = now() + /** + * Per-event reminder lead in minutes (Phase 11, CAL-13). + * null — explicit "None" → no VALARM emitted + * 0 — same-day all-day (fire 9 AM on event date); 0 on timed = None (D-06) + * positive — N minutes before event start (timed) or N/1440 days before (all-day) + * undefined — field absent; use valarms preserve path if provided + */ + reminderLeadMinutes?: number | null; + /** + * Pre-parsed VALARM components from rawVevent (preserve-on-edit, D-08 / CAL-14). + * When non-empty, these are re-attached verbatim and reminderLeadMinutes is ignored. + */ + valarms?: ICAL.Component[]; + /** + * UTC instant for all-day event absolute DATE-TIME trigger (9 AM local on alert day). + * Computed by caller (outboxWorker / computeAlertInstantUtc) for the specific alert date. + * Required when allDay=true and reminderLeadMinutes != null. + */ + allDayAlertInstantUtc?: Date; } +/** + * Preset reminder lead times (minutes) that map to distinct UI options. + * Includes both timed (5–120 min) and all-day (0, 1440, 2880, 10080) presets. + * Used by classifyValarms to distinguish preset from offlist single-DURATION triggers. + * + * D-01/D-02: timed presets = 0,5,10,15,30,60,120; all-day = 0,1440,2880,10080. + * 0 is included for "Same day" all-day; it is also the "None" sentinel for timed (D-06). + */ +export const PRESET_MINUTES = new Set([0, 5, 10, 15, 30, 60, 120, 1440, 2880, 10080]); + +/** + * Classification of VALARM components found in a stored raw VEVENT (CAL-14, D-07). + * + * none — no VALARM sub-component present + * preset — single relative DURATION trigger whose lead is in PRESET_MINUTES + * offlist — single relative DURATION trigger with a lead NOT in PRESET_MINUTES + * custom — absolute DATE-TIME trigger OR multiple VALARM sub-components + * + * Off-list alarms get a synthetic read-only option in the picker showing the real minutes. + * Custom alarms get a disabled "Custom (kept)" entry. + */ +export type AlarmClassification = + | { kind: 'none' } + | { kind: 'preset'; leadMinutes: number } + | { kind: 'offlist'; leadMinutes: number } + | { kind: 'custom' }; + /** * Simple RRULE preset strings for whole-series recurring events (D-11 / CAL-07). * "weekly on Monday" example: 'FREQ=WEEKLY;BYDAY=MO' (not a key in this map; compose manually). @@ -53,6 +99,242 @@ export const RRULE_PRESETS: Record = { yearly: 'FREQ=YEARLY', }; +/** + * Build a VALARM sub-component with a relative DURATION trigger for timed events. + * + * Uses resetType('duration') + setValue(ICAL.Duration) to guarantee the correct + * jCal value type — preventing the VALUE=TEXT pitfall that occurs when using + * addPropertyWithValue('trigger', '-PTNmM') (Pitfall 2). + * + * @param leadMinutes — minutes before event start (e.g. 15 → TRIGGER:-PT15M) + */ +export function buildTimedValarm(leadMinutes: number): ICAL.Component { + const valarm = new ICAL.Component('valarm'); + valarm.addPropertyWithValue('action', 'DISPLAY'); + valarm.addPropertyWithValue('description', 'Reminder'); + + const triggerProp = new ICAL.Property('trigger'); + // resetType('duration') ensures the jCal value type is 'duration', not 'text' + triggerProp.resetType('duration'); + triggerProp.setValue(ICAL.Duration.fromSeconds(-leadMinutes * 60)); + valarm.addProperty(triggerProp); + + return valarm; +} + +/** + * Build a VALARM sub-component with an absolute DATE-TIME trigger for all-day events. + * + * RFC 5545 §3.8.6.3: TRIGGER;VALUE=DATE-TIME: — an absolute UTC instant. + * Uses resetType('date-time') + setValue(ICAL.Time) to guarantee VALUE=DATE-TIME, no DURATION. + * + * @param alertInstantUtc — the UTC instant (9 AM local on the computed alert day) + */ +export function buildAllDayValarm(alertInstantUtc: Date): ICAL.Component { + const valarm = new ICAL.Component('valarm'); + valarm.addPropertyWithValue('action', 'DISPLAY'); + valarm.addPropertyWithValue('description', 'Reminder'); + + const triggerProp = new ICAL.Property('trigger'); + // resetType('date-time') ensures VALUE=DATE-TIME absolute trigger + triggerProp.resetType('date-time'); + // useUTC=true → Z suffix (UTC instant, not local) + triggerProp.setValue(ICAL.Time.fromJSDate(alertInstantUtc, true)); + valarm.addProperty(triggerProp); + + return valarm; +} + +/** + * Classify the VALARM(s) in a stored raw VEVENT string (CAL-14, D-07). + * + * Safe on parse failure — returns { kind: 'none' } for any unparseable input. + * This matches the threat-model mitigation T-11-01: no eval, no string splicing; + * ical.js handles line-folding + escaping. + * + * @param rawVevent — full VCALENDAR or VEVENT iCalendar string + */ +export function classifyValarms(rawVevent: string): AlarmClassification { + let parsed: ReturnType; + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- ical.js parse() returns 'any' + parsed = ICAL.parse(rawVevent); + } catch { + return { kind: 'none' }; + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- ical.js parse() output is 'any' + const comp = new ICAL.Component(parsed); + const vevent = comp.getFirstSubcomponent('vevent'); + if (!vevent) return { kind: 'none' }; + + const valarms = vevent.getAllSubcomponents('valarm'); + if (valarms.length === 0) return { kind: 'none' }; + if (valarms.length > 1) return { kind: 'custom' }; + + const alarm = valarms[0]; + const triggerProp = alarm.getFirstProperty('trigger'); + if (!triggerProp) return { kind: 'none' }; + + // getFirstValue() returns ICAL.Duration for relative triggers and ICAL.Time for absolute. + // The 'instanceof ICAL.Time' check distinguishes the two without relying on VALUE param. + const firstValue = triggerProp.getFirstValue() as unknown; + if (firstValue instanceof ICAL.Time) return { kind: 'custom' }; + + // Relative DURATION trigger — extract lead minutes. + const dur = firstValue as ICAL.Duration; + if (!dur || typeof dur.toSeconds !== 'function') return { kind: 'custom' }; + + const seconds = dur.toSeconds(); + // WR-01 (Phase 11 Plan 05): positive seconds = alarm fires AFTER the event start + // (RFC 5545 TRIGGER:+PT15M or TRIGGER;RELATED=END:PT15M). This is a post-event alarm + // and cannot be expressed as a before-event lead. Classify as custom so the preserve + // path keeps the original VALARM rather than inverting the alarm direction. + if (seconds > 0) return { kind: 'custom' }; + + const leadMinutes = Math.round(-seconds / 60); + return PRESET_MINUTES.has(leadMinutes) + ? { kind: 'preset', leadMinutes } + : { kind: 'offlist', leadMinutes }; +} + +/** + * Extract all VALARM sub-components from a stored raw VEVENT string (CAL-14, D-08). + * + * Returns live ICAL.Component objects that can be re-attached to a new VEVENT via + * addSubcomponent() — no re-serialization/re-parsing, preserving exact encoding. + * + * Safe on parse failure — returns [] for any unparseable input (T-11-01). + * + * @param rawVevent — full VCALENDAR or VEVENT iCalendar string + */ +export function extractValarms(rawVevent: string): ICAL.Component[] { + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- ical.js parse() returns 'any' + const parsed = ICAL.parse(rawVevent); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- ical.js parse() output is 'any' + const comp = new ICAL.Component(parsed); + const vevent = comp.getFirstSubcomponent('vevent'); + if (!vevent) return []; + return vevent.getAllSubcomponents('valarm'); + } catch { + return []; + } +} + +/** + * Compute the UTC instant for 9:00 AM local time on the alert day (Phase 11, NOTIF-06). + * + * The alert day = eventDate minus leadDays. The UTC offset is computed for 9 AM + * specifically on the alert date — not midnight — so DST transitions that occur before + * 9 AM on the alert date (e.g. spring-forward at 2 AM) use the post-transition offset. + * + * D-04: all-day reminders fire at 9 AM local on the alert day. + * Assumption A2/A5: caller passes the server/household timezone (process.env.TZ or + * Intl.DateTimeFormat().resolvedOptions().timeZone). + * + * @param eventDateStr — event start date as 'YYYY-MM-DD' + * @param leadDays — days before event date (0 = same day, 1 = 1 day before, etc.) + * @param tz — IANA timezone identifier (e.g. 'America/New_York') + */ +export function computeAlertInstantUtc(eventDateStr: string, leadDays: number, tz: string): Date { + // Parse event date as UTC midnight, then subtract lead days to get alert date + const [y, m, d] = eventDateStr.split('-').map(Number) as [number, number, number]; + const alertDateUtcMs = Date.UTC(y, m - 1, d - leadDays); + const alertDateStr = new Date(alertDateUtcMs).toISOString().slice(0, 10); // 'YYYY-MM-DD' + + // Strategy: compute the UTC offset at 9 AM on the alert date (not at midnight). + // This correctly handles DST transitions that occur before 9 AM (e.g. spring-forward + // at 2 AM on the alert date means 9 AM uses the post-transition offset). + // + // Algorithm: + // 1. Take a "naive" 9 AM UTC estimate: alertDateUtcMs + 9h (as if tz=UTC). + // 2. Ask Intl: what local date+time is this naive instant in `tz`? + // 3. The difference between the desired 9 AM local and the actual local time at the + // naive instant tells us the UTC offset. + // 4. Adjust: target_utc = naive_9am_utc - offset_ms. + // + // This is a one-pass computation that works across DST boundaries because we probe + // the offset near 9 AM, not at midnight. + + const naive9amUtcMs = alertDateUtcMs + 9 * 3600 * 1000; + + const formatter = new Intl.DateTimeFormat('en-US', { + timeZone: tz, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); + + // What local time does our naive UTC instant correspond to in tz? + const parts = formatter.formatToParts(new Date(naive9amUtcMs)); + const get = (t: string) => parts.find((p) => p.type === t)?.value ?? '0'; + + const localYear = Number(get('year')); + const localMonth = Number(get('month')); + const localDay = Number(get('day')); + const localHour = Number(get('hour')); // 0–23 (hour12:false) + const localMin = Number(get('minute')); + const localSec = Number(get('second')); + + // The local time at naive_9am_utc in tz. We want this to be 09:00:00 on alertDate. + // Compute how many ms the local time deviates from 09:00:00 on alertDate. + // deviation = (local - desired) in ms, where desired = alertDate at 09:00:00 + + // Build the local "desired" as a date string for comparison + const [ay, am, ad] = alertDateStr.split('-').map(Number) as [number, number, number]; + const isSameDay = localYear === ay && localMonth === am && localDay === ad; + + // Total ms since start of local day at the naive instant + const localMsSinceMidnight = (localHour * 3600 + localMin * 60 + localSec) * 1000; + + // Desired ms since start of local day = 9 AM = 9 * 3600 * 1000 + const desired9amMs = 9 * 3600 * 1000; + + let deviationMs: number; + if (isSameDay) { + // Same local date: deviation = localMsSinceMidnight - desired9amMs + deviationMs = localMsSinceMidnight - desired9amMs; + } else { + // Different local day: the local clock is on the previous or next day. + // Since we're probing near 9 AM UTC+0 and typical offsets are ±12h, the local + // time is either earlier in the day (offset negative = west) or earlier the prev day. + // Day-before case (west of UTC, e.g. UTC-12): local shows the previous day. + // deviation = -(24h - localMsSinceMidnight) - desired9amMs ... complex + // Simpler: compute deviation as the ms difference between naive_9am_utc and the + // UTC instant for the desired 9 AM local. + // Since we know local is offset: local_midnight_utc = naive_9am_utc - localMsSinceMidnight + // + (isSameDay ? 0 : 24h) if local day is next day (east of UTC past 15h offset) + // In practice for UTC-12 to UTC+14, the offset is ±14h. At naive 9 AM UTC: + // UTC-12 → local = 9 AM - 12h = previous day 9 PM → 21h = local midnight +21h + // UTC+14 → local = 9 AM + 14h = next day 23 AM → not relevant for this use case + // Household timezones are typically within ±12h. Handle common case: + // If local date is the PREVIOUS day relative to alertDate, local is west of UTC + // (negative offset). The local time displayed (e.g. 21:00 for UTC-12) means we + // are 21h into the previous local day. The local midnight of alertDate is at + // naive_9am_utc - localMsSinceMidnight + (we haven't reached alertDate midnight yet) + // Actually: deviation = localMsSinceMidnight + 24h - desired9amMs when day is before alert + // deviation = localMsSinceMidnight - 24h - desired9amMs when day is after alert + + // Check if local date is day before or day after alertDate + const localDateMs = Date.UTC(localYear, localMonth - 1, localDay); + const alertDateMsForCompare = Date.UTC(ay, am - 1, ad); + const dayDiffDays = (alertDateMsForCompare - localDateMs) / (24 * 3600 * 1000); + + // dayDiff > 0: local day is before alert date (west of UTC, negative offset) + // dayDiff < 0: local day is after alert date (east of UTC, large positive offset) + deviationMs = localMsSinceMidnight + dayDiffDays * 24 * 3600 * 1000 - desired9amMs; + } + + // target_utc = naive_9am_utc - deviation + // (deviation > 0: local is ahead of desired, shift target earlier in UTC) + return new Date(naive9amUtcMs - deviationMs); +} + /** * WR-01: Extract the existing RRULE string from a stored VCALENDAR/VEVENT, so the * outbox worker can preserve recurrence on an edit whose payload omits it (the PWA @@ -153,6 +435,34 @@ export function buildVeventString(params: NewEventParams): { uid: string; icsStr vevent.addProperty(rruleProp); } + // Optional: VALARM (Phase 11, CAL-13/CAL-14 — per-event reminders) + // + // Branch order: + // 1. Preserve path (D-08/CAL-14): if valarms[] non-empty, re-attach verbatim. + // reminderLeadMinutes is ignored — preserve wins over synthesize. + // 2. All-day new alarm: if allDay + reminderLeadMinutes != null + allDayAlertInstantUtc. + // Emits absolute DATE-TIME trigger (RFC 5545 §3.8.6.3). + // 3. Timed new alarm: if !allDay + reminderLeadMinutes > 0. + // Emits relative DURATION trigger. 0 on timed = None (D-06). + // + // No VALARM is emitted when: reminderLeadMinutes is null/undefined, or + // timed with leadMinutes=0, or no valarms and no explicit reminder. + if (params.valarms && params.valarms.length > 0) { + // Preserve path: re-attach existing VALARMs from rawVevent verbatim (D-08) + for (const alarm of params.valarms) { + vevent.addSubcomponent(alarm); + } + } else if (params.reminderLeadMinutes != null) { + if (params.allDay && params.allDayAlertInstantUtc) { + // All-day: absolute DATE-TIME trigger at 9 AM local on the computed alert day + vevent.addSubcomponent(buildAllDayValarm(params.allDayAlertInstantUtc)); + } else if (!params.allDay && params.reminderLeadMinutes > 0) { + // Timed: relative DURATION trigger; 0 = None (D-06) + vevent.addSubcomponent(buildTimedValarm(params.reminderLeadMinutes)); + } + // allDay=false && reminderLeadMinutes===0: no VALARM (timed 0 = None per D-06) + } + // Optional fields — included only when provided (no empty properties in ICS) if (params.location) { vevent.addPropertyWithValue('location', params.location); diff --git a/apps/api/src/routes/events.ts b/apps/api/src/routes/events.ts index ce8d899..cc9b978 100644 --- a/apps/api/src/routes/events.ts +++ b/apps/api/src/routes/events.ts @@ -117,6 +117,12 @@ const eventFieldsSchema = z.object({ .regex(/^\d{4}-\d{2}-\d{2}$/) .optional(), // 'YYYY-MM-DD' → RRULE UNTIL recurrenceCount: z.number().int().min(1).optional(), // integer ≥ 1 → RRULE COUNT + // Phase 11: per-event reminder lead in minutes (CAL-13/CAL-14, D-08). + // absent — field not present; outbox worker preserves existing VALARM (no-change, D-08) + // null — explicit "None" → clear the VALARM on write-back + // 0 — same-day all-day reminder (9 AM on event date); timed 0 = None (D-06) + // positive int — N minutes before event start (timed) or N/1440 days before (all-day) + reminderLeadMinutes: z.number().int().min(0).max(10080).nullable().optional(), }); /** sync-status query params. */ @@ -171,6 +177,8 @@ eventsRouter.get('/', zValidator('query', eventsQuerySchema), async (c) => { userId: users.id, userColor: users.color, ownerName: users.displayName, + // Phase 11 Plan 03: surface reminderLeadMinutes for edit-mode pre-population (D-06/D-10) + reminderLeadMinutes: calendarEvents.reminderLeadMinutes, }) .from(calendarEvents) .innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id)) diff --git a/apps/api/tests/broker/expand.test.ts b/apps/api/tests/broker/expand.test.ts index 1495fe3..1788df5 100644 --- a/apps/api/tests/broker/expand.test.ts +++ b/apps/api/tests/broker/expand.test.ts @@ -310,6 +310,85 @@ describe('expandOccurrences', () => { }); }); + describe('reminderIsCustom — CR-01 (Phase 11 Plan 05)', () => { + it('absolute DATE-TIME VALARM trigger yields reminderIsCustom:true and reminderLeadMinutes:null', () => { + // Apple Calendar default all-day alarm style: TRIGGER;VALUE=DATE-TIME:... + // Before the fix, expand.ts maps {kind:'custom'} to null and does NOT set reminderIsCustom. + // This test MUST FAIL before the fix (no reminderIsCustom field on CalendarOccurrence). + const rawVevent = loadFixture('absolute-alarm.ics'); + const windowStart = new Date('2026-12-01T00:00:00Z'); + const windowEnd = new Date('2027-01-01T00:00:00Z'); + + const occurrences = expandOccurrences( + rawVevent, + windowStart, + windowEnd, + 1, + 'My Calendar', + 1, + null, + '#4A90D9', + false, + ); + + expect(occurrences.length).toBe(1); + const occ = occurrences[0]; + // reminderIsCustom MUST be true (absolute DATE-TIME trigger) + expect((occ as Record).reminderIsCustom).toBe(true); + // reminderLeadMinutes MUST be null (cannot reduce custom to a lead) + expect(occ.reminderLeadMinutes).toBeNull(); + }); + + it('multiple VALARMs yield reminderIsCustom:true and reminderLeadMinutes:null', () => { + // Outlook/Apple sometimes produce two VALARMs — classifyValarms returns {kind:'custom'} + const rawVevent = loadFixture('multi-alarm.ics'); + const windowStart = new Date('2026-12-01T00:00:00Z'); + const windowEnd = new Date('2027-01-01T00:00:00Z'); + + const occurrences = expandOccurrences( + rawVevent, + windowStart, + windowEnd, + 1, + 'My Calendar', + 1, + null, + '#4A90D9', + false, + ); + + expect(occurrences.length).toBe(1); + const occ = occurrences[0]; + expect((occ as Record).reminderIsCustom).toBe(true); + expect(occ.reminderLeadMinutes).toBeNull(); + }); + + it('single relative preset VALARM yields reminderIsCustom:false', () => { + // A normal -PT15M DURATION trigger should NOT be flagged as custom + const rawVevent = loadFixture('weekly-dst.ics'); + const windowStart = new Date('2026-03-01T00:00:00Z'); + const windowEnd = new Date('2026-03-15T00:00:00Z'); + + const occurrences = expandOccurrences( + rawVevent, + windowStart, + windowEnd, + 1, + 'My Calendar', + 1, + null, + '#4A90D9', + false, + ); + + // weekly-dst.ics has no VALARM — reminderIsCustom should be false + expect(occurrences.length).toBeGreaterThan(0); + for (const occ of occurrences) { + expect((occ as Record).reminderIsCustom).toBe(false); + } + }); + }); + describe('Cross-contract: expand output → Temporal.ZonedDateTime.from (regression guard)', () => { it('timed event start/end strings from weekly-dst.ics parse via Temporal.ZonedDateTime.from without throwing', () => { // This is the integration test that was missing. It takes the actual serializeTime output @@ -350,3 +429,144 @@ describe('expandOccurrences', () => { }); }); }); + +// ─── Phase 11 Plan 03 Task 3: reminderLeadMinutes on CalendarOccurrence ───────── +// D-06/D-10: reminderLeadMinutes is a series-level property — all occurrences of a +// recurring master inherit the master's lead. NULL-vs-0-vs-positive must survive +// through expansion. + +describe('expandOccurrences — reminderLeadMinutes propagation (Phase 11 Plan 03 Task 3)', () => { + // Helper: minimal VCALENDAR/VEVENT string for tests + function makeVevent(overrides: { + uid?: string; + allDay?: boolean; + reminderMinutes?: number | 'absolute' | 'none'; + rrule?: string; + }): string { + const uid = overrides.uid ?? 'test-uid@test'; + const lines: string[] = ['BEGIN:VCALENDAR', 'VERSION:2.0']; + + if (!overrides.allDay) { + lines.push('BEGIN:VEVENT'); + lines.push(`UID:${uid}`); + lines.push('SUMMARY:Test event'); + lines.push('DTSTART:20260615T140000Z'); + lines.push('DTEND:20260615T150000Z'); + } else { + lines.push('BEGIN:VEVENT'); + lines.push(`UID:${uid}`); + lines.push('SUMMARY:All-day test'); + lines.push('DTSTART;VALUE=DATE:20260615'); + lines.push('DTEND;VALUE=DATE:20260616'); + } + + if (overrides.rrule) { + lines.push(`RRULE:${overrides.rrule}`); + } + + if (overrides.reminderMinutes === 'absolute') { + lines.push('BEGIN:VALARM'); + lines.push('ACTION:DISPLAY'); + lines.push('DESCRIPTION:Reminder'); + lines.push('TRIGGER;VALUE=DATE-TIME:20260615T120000Z'); + lines.push('END:VALARM'); + } else if (overrides.reminderMinutes !== 'none' && overrides.reminderMinutes !== undefined) { + lines.push('BEGIN:VALARM'); + lines.push('ACTION:DISPLAY'); + lines.push('DESCRIPTION:Reminder'); + lines.push(`TRIGGER:-PT${overrides.reminderMinutes}M`); + lines.push('END:VALARM'); + } + + lines.push('END:VEVENT'); + lines.push('END:VCALENDAR'); + return lines.join('\r\n'); + } + + const WINDOW_START = new Date('2026-06-01T00:00:00Z'); + const WINDOW_END = new Date('2026-07-01T00:00:00Z'); + + // Non-recurring event with reminderLeadMinutes=30 → occurrence carries 30 + it('non-recurring event: occurrence carries reminderLeadMinutes from master (30 minutes)', () => { + const raw = makeVevent({ reminderMinutes: 30 }); + + const occurrences = expandOccurrences( + raw, + WINDOW_START, + WINDOW_END, + 1, + 'My Calendar', + 1, + null, + '#4A90D9', + false, + ); + + expect(occurrences).toHaveLength(1); + expect(occurrences[0].reminderLeadMinutes).toBe(30); + }); + + // NULL-vs-0: master with 0-minute all-day lead → occurrence carries 0, not null + it('non-recurring all-day event: occurrence carries reminderLeadMinutes=0 (same-day, D-06 NULL-vs-0)', () => { + // Use a 0-minute trigger (same-day all-day) + const raw = makeVevent({ allDay: true, reminderMinutes: 0 }); + + const occurrences = expandOccurrences( + raw, + new Date('2026-06-01T00:00:00Z'), + new Date('2026-07-01T00:00:00Z'), + 1, + 'My Calendar', + 1, + null, + '#4A90D9', + false, + ); + + expect(occurrences).toHaveLength(1); + // 0-minute all-day trigger → preset 0 → reminderLeadMinutes=0 (not null) + expect(occurrences[0].reminderLeadMinutes).toBe(0); + }); + + // NULL: no VALARM in master → occurrence carries null + it('non-recurring event with no VALARM: occurrence carries reminderLeadMinutes=null', () => { + const raw = makeVevent({ reminderMinutes: 'none' }); + + const occurrences = expandOccurrences( + raw, + WINDOW_START, + WINDOW_END, + 1, + 'My Calendar', + 1, + null, + '#4A90D9', + false, + ); + + expect(occurrences).toHaveLength(1); + expect(occurrences[0].reminderLeadMinutes).toBeNull(); + }); + + // D-10 series-level: recurring event with reminderLeadMinutes=60 → all occurrences carry 60 + it('D-10 series-level: all recurring occurrences inherit the master reminderLeadMinutes=60', () => { + const raw = makeVevent({ reminderMinutes: 60, rrule: 'FREQ=WEEKLY;COUNT=3' }); + + const occurrences = expandOccurrences( + raw, + WINDOW_START, + WINDOW_END, + 1, + 'My Calendar', + 1, + null, + '#4A90D9', + false, + ); + + expect(occurrences.length).toBeGreaterThan(0); + for (const occ of occurrences) { + expect(occ.reminderLeadMinutes).toBe(60); + } + }); +}); diff --git a/apps/api/tests/broker/outboxWorker.test.ts b/apps/api/tests/broker/outboxWorker.test.ts index 7223409..34293c3 100644 --- a/apps/api/tests/broker/outboxWorker.test.ts +++ b/apps/api/tests/broker/outboxWorker.test.ts @@ -864,3 +864,233 @@ describe('scheduleOutboxDrain — trigger wiring (D-09)', () => { } }); }); + +// ─── Phase 11 Plan 03: reminderLeadMinutes schema + VALARM wiring ─────────────── +// CAL-13: reminderLeadMinutes round-trips end-to-end through outbox payload → +// buildVeventString → emitted ICS. +// CAL-14: UPDATE row with no reminderLeadMinutes in payload preserves existing +// VALARM verbatim from rawVevent (mirrors WR-01 _preservedRrule pattern). + +describe('runOutboxDrain — reminderLeadMinutes VALARM wiring (CAL-13/CAL-14, Phase 11 Plan 03)', () => { + beforeEach(() => { + vi.resetAllMocks(); + mockPendingRows = []; + wireMockChain(); + }); + + // CAL-14: UPDATE row with NO reminderLeadMinutes field, but rawVevent has a VALARM → + // emitted ICS must still contain BEGIN:VALARM (preserve path, mirrors _preservedRrule WR-01). + it('CAL-14 preserve: UPDATE with no reminderLeadMinutes field preserves existing VALARM from rawVevent', async () => { + const { updateCalendarEvent } = await import('../../src/broker/write.js'); + let capturedIcsString: unknown = null; + vi.mocked(updateCalendarEvent).mockImplementation(async (_client, _url, icsString, _etag) => { + capturedIcsString = icsString; + return makeResponse(204); + }); + + // rawVevent that already has a VALARM (TRIGGER:-PT30M) + const rawVeventWithValarm = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'BEGIN:VEVENT', + 'UID:test-uid@familysync', + 'SUMMARY:Team meeting', + 'DTSTART:20260610T120000Z', + 'DTEND:20260610T130000Z', + 'BEGIN:VALARM', + 'ACTION:DISPLAY', + 'DESCRIPTION:Reminder', + 'TRIGGER:-PT30M', + 'END:VALARM', + 'END:VEVENT', + 'END:VCALENDAR', + ].join('\r\n'); + + // Payload has NO reminderLeadMinutes key (absent = no-change, D-08) + const updatePayload = JSON.stringify({ + title: 'Team meeting', + allDay: false, + start: '2026-06-10T12:00:00.000Z', + end: '2026-06-10T13:00:00.000Z', + }); + + mockPendingRows = [ + makeRow({ + operation: 'update', + calendarObjectUrl: 'https://caldav.fastmail.com/dav/calendars/user/test/uid.ics', + payload: updatePayload, + }), + ]; + + // Simulate freshEtagRows returning rawVevent that has a VALARM + mockWhereCalEvents.mockResolvedValue([{ etag: '"fresh"', rawVevent: rawVeventWithValarm }]); + + await runOutboxDrain(); + + expect(typeof capturedIcsString).toBe('string'); + // The emitted ICS must contain the preserved VALARM + expect(capturedIcsString as string).toContain('BEGIN:VALARM'); + expect(capturedIcsString as string).toContain('TRIGGER:-PT30M'); + }); + + // CAL-13: CREATE row with reminderLeadMinutes=15 → emitted ICS contains TRIGGER:-PT15M + it('CAL-13 timed: CREATE row with reminderLeadMinutes=15 emits TRIGGER:-PT15M', async () => { + const { createCalendarEvent } = await import('../../src/broker/write.js'); + let capturedIcsString: unknown = null; + vi.mocked(createCalendarEvent).mockImplementation(async (_client, _cal, _uid, icsString) => { + capturedIcsString = icsString; + return makeResponse(201); + }); + + const payload = JSON.stringify({ + title: 'Doctor appointment', + allDay: false, + start: '2026-06-15T14:00:00.000Z', + end: '2026-06-15T15:00:00.000Z', + reminderLeadMinutes: 15, + }); + + mockPendingRows = [makeRow({ payload })]; + + await runOutboxDrain(); + + expect(typeof capturedIcsString).toBe('string'); + expect(capturedIcsString as string).toContain('BEGIN:VALARM'); + expect(capturedIcsString as string).toContain('TRIGGER:-PT15M'); + }); + + // CAL-13 clear: UPDATE row with reminderLeadMinutes=null → emitted ICS has no VALARM + it('CAL-13 clear: UPDATE row with reminderLeadMinutes=null emits no VALARM (explicit clear)', async () => { + const { updateCalendarEvent } = await import('../../src/broker/write.js'); + let capturedIcsString: unknown = null; + vi.mocked(updateCalendarEvent).mockImplementation(async (_client, _url, icsString, _etag) => { + capturedIcsString = icsString; + return makeResponse(204); + }); + + const updatePayload = JSON.stringify({ + title: 'No reminder event', + allDay: false, + start: '2026-06-15T14:00:00.000Z', + end: '2026-06-15T15:00:00.000Z', + reminderLeadMinutes: null, + }); + + mockPendingRows = [ + makeRow({ + operation: 'update', + calendarObjectUrl: 'https://caldav.fastmail.com/dav/calendars/user/test/uid.ics', + payload: updatePayload, + }), + ]; + + await runOutboxDrain(); + + expect(typeof capturedIcsString).toBe('string'); + expect(capturedIcsString as string).not.toContain('BEGIN:VALARM'); + }); + + // CAL-13 all-day: CREATE row with allDay=true and reminderLeadMinutes=1440 → + // emitted ICS contains VALUE=DATE-TIME absolute trigger (not DURATION trigger). + it('CAL-13 all-day: CREATE row with allDay=true and reminderLeadMinutes=1440 emits VALUE=DATE-TIME trigger', async () => { + const { createCalendarEvent } = await import('../../src/broker/write.js'); + let capturedIcsString: unknown = null; + vi.mocked(createCalendarEvent).mockImplementation(async (_client, _cal, _uid, icsString) => { + capturedIcsString = icsString; + return makeResponse(201); + }); + + const payload = JSON.stringify({ + title: 'Birthday party', + allDay: true, + start: '2026-06-20', + end: '2026-06-20', + reminderLeadMinutes: 1440, // 1 day before = leadDays = 1440/1440 = 1 + }); + + mockPendingRows = [makeRow({ payload })]; + + await runOutboxDrain(); + + expect(typeof capturedIcsString).toBe('string'); + expect(capturedIcsString as string).toContain('BEGIN:VALARM'); + // Must use VALUE=DATE-TIME absolute trigger for all-day (not DURATION) + expect(capturedIcsString as string).toContain('VALUE=DATE-TIME'); + }); +}); + +// ─── Phase 11 Plan 05 WR-02: .max(10080) on reminderLeadMinutes in outboxPayloadSchema ── +// A payload with reminderLeadMinutes=10081 exceeds the 1-week UI cap (10080 min). +// The outboxPayloadSchema must reject it so the row is hard-failed rather than +// letting an out-of-range value silently flow into the VALARM trigger. + +describe('runOutboxDrain — WR-02: reminderLeadMinutes max(10080) in outboxPayloadSchema', () => { + beforeEach(() => { + vi.resetAllMocks(); + mockPendingRows = []; + wireMockChain(); + }); + + it('WR-02: payload with reminderLeadMinutes=10081 is hard-failed (validation error)', async () => { + const { createCalendarEvent } = await import('../../src/broker/write.js'); + vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(201)); + + const payload = JSON.stringify({ + title: 'Over-cap reminder', + allDay: false, + start: '2026-12-15T10:00:00Z', + end: '2026-12-15T11:00:00Z', + reminderLeadMinutes: 10081, // 1 min over the 1-week cap + }); + mockPendingRows = [makeRow({ payload })]; + + await runOutboxDrain(); + + // Must NOT dispatch to CalDAV — validation must fire before ICS assembly + expect(createCalendarEvent).not.toHaveBeenCalled(); + const setArg = mockUpdateSet.mock.calls[0]?.[0] as { status?: string; lastError?: string }; + expect(setArg?.status).toBe('failed'); + expect(setArg?.lastError).toMatch(/validation/i); + }); + + it('WR-02: payload with reminderLeadMinutes=10080 (boundary) passes validation and dispatches', async () => { + const { createCalendarEvent } = await import('../../src/broker/write.js'); + vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(201)); + + const payload = JSON.stringify({ + title: 'Max-cap reminder', + allDay: false, + start: '2026-12-15T10:00:00Z', + end: '2026-12-15T11:00:00Z', + reminderLeadMinutes: 10080, // exactly 1 week — must be allowed + }); + mockPendingRows = [makeRow({ payload })]; + + await runOutboxDrain(); + + // Row is valid — CalDAV write must have been dispatched + expect(createCalendarEvent).toHaveBeenCalledTimes(1); + const setArg = mockUpdateSet.mock.calls[0]?.[0] as { status?: string }; + expect(setArg?.status).toBe('done'); + }); + + it('WR-02: payload with reminderLeadMinutes=null (explicit clear) passes validation', async () => { + const { createCalendarEvent } = await import('../../src/broker/write.js'); + vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(201)); + + const payload = JSON.stringify({ + title: 'Clear reminder', + allDay: false, + start: '2026-12-15T10:00:00Z', + end: '2026-12-15T11:00:00Z', + reminderLeadMinutes: null, + }); + mockPendingRows = [makeRow({ payload })]; + + await runOutboxDrain(); + + // null (explicit clear) must pass .nullable() + const setArg = mockUpdateSet.mock.calls[0]?.[0] as { status?: string }; + expect(setArg?.status).not.toBe('failed'); + }); +}); diff --git a/apps/api/tests/broker/reminderScheduler.test.ts b/apps/api/tests/broker/reminderScheduler.test.ts index 2a8896b..b86946d 100644 --- a/apps/api/tests/broker/reminderScheduler.test.ts +++ b/apps/api/tests/broker/reminderScheduler.test.ts @@ -16,6 +16,12 @@ * - D-16: empty push_subscriptions -> zero sends / no crash * - T-05-19: per-subscription error isolation * + * NEW (Plan 11-02): + * - NOTIF-04: fires at T-reminderLeadMinutes (variable per-event lead), not fixed 16-min window + * - NOTIF-05: NULL lead → no push; timed 0-lead → no push; personal events DO dispatch + * - NOTIF-06: uid:dtstartMs compound dedup; rescheduled dtstart re-fires; all-day 9 AM branch + * - D-09: humanized push body (humanizeLeadMinutes) + * * Run: pnpm --filter @familysync/api exec vitest run tests/broker/reminderScheduler.test.ts */ @@ -35,22 +41,49 @@ vi.mock('../../src/lib/pushDispatcher.js', () => ({ // ── Helpers ─────────────────────────────────────────────────────────────────── +/** + * Make a select mock chain that resolves `.where()` with the given rows. + * Supports both one-join and two-join chains (from().innerJoin[.innerJoin]().where()). + */ function makeSelectMock(rows: unknown[]) { + const whereResolve = vi.fn().mockResolvedValue(rows); + const innerJoinLevel2 = { + where: whereResolve, + innerJoin: vi.fn().mockReturnValue({ where: whereResolve }), + }; return { from: vi.fn().mockReturnValue({ - innerJoin: vi.fn().mockReturnValue({ - innerJoin: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue(rows), - }), - }), + innerJoin: vi.fn().mockReturnValue(innerJoinLevel2), }), } as never; } +/** + * Setup the db.select mock so the FIRST call (timed query) returns `timedRows` + * and the SECOND call (all-day query) returns `allDayRows` (default empty). + * + * runReminderCheck() issues two sequential db.select() calls: + * 1st: timed events query + * 2nd: all-day events query + */ +function mockTwoQueries( + db: { select: ReturnType }, + timedRows: unknown[], + allDayRows: unknown[] = [], +) { + db.select + .mockReturnValueOnce(makeSelectMock(timedRows)) + .mockReturnValueOnce(makeSelectMock(allDayRows)); +} + function makeEventRow(overrides: { uid?: string; title?: string; - dtstartUtc: Date; + dtstartUtc?: Date | null; + dtstartDate?: string | null; + allDay?: boolean; + isShared?: boolean; + reminderLeadMinutes?: number | null; subId?: number | null; subUserId?: number | null; subEndpoint?: string; @@ -60,9 +93,12 @@ function makeEventRow(overrides: { return { uid: overrides.uid ?? 'test-uid-1', title: overrides.title ?? 'Test Event', - dtstartUtc: overrides.dtstartUtc, - allDay: false, - isShared: true, + dtstartUtc: overrides.dtstartUtc !== undefined ? overrides.dtstartUtc : null, + dtstartDate: overrides.dtstartDate !== undefined ? overrides.dtstartDate : null, + allDay: overrides.allDay ?? false, + isShared: overrides.isShared ?? true, + reminderLeadMinutes: + overrides.reminderLeadMinutes !== undefined ? overrides.reminderLeadMinutes : 15, subId: overrides.subId ?? 1, subUserId: overrides.subUserId ?? 1, subEndpoint: overrides.subEndpoint ?? 'https://push.example.com/1', @@ -71,9 +107,9 @@ function makeEventRow(overrides: { }; } -// ── Filtering tests (D-05 / D-07) ──────────────────────────────────────────── +// ── Filtering tests (NOTIF-04/05: variable lead, NULL-vs-0, personal calendar) ──────── -describe('reminderScheduler — shared+timed event filtering (D-05/D-07)', () => { +describe('reminderScheduler — variable-lead, NULL-vs-0, personal calendar (NOTIF-04/05)', () => { beforeEach(() => { vi.useFakeTimers(); vi.resetModules(); @@ -84,7 +120,9 @@ describe('reminderScheduler — shared+timed event filtering (D-05/D-07)', () => vi.clearAllMocks(); }); - it('does not dispatch reminders for all-day events (D-07)', async () => { + it('NOTIF-04: dispatches a timed event when now is inside the lead-driven fire window (30-min lead)', async () => { + // Fire time = dtstartUtc - 30min. now = dtstartUtc - 30min (exactly at fire time). + // The event MUST dispatch. It would NOT dispatch under the old fixed 16-min window. const now = new Date('2026-06-15T10:00:00Z'); vi.setSystemTime(now); @@ -92,15 +130,38 @@ describe('reminderScheduler — shared+timed event filtering (D-05/D-07)', () => const { dispatchPush } = await import('../../src/lib/pushDispatcher.js'); const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js'); - // allDay=true events are excluded by the SQL WHERE; simulate by returning empty rows - vi.mocked(db.select).mockReturnValue(makeSelectMock([])); + const dtstartUtc = new Date('2026-06-15T10:30:00Z'); // 30 min from now + const row = makeEventRow({ + uid: 'notif04-30min-uid', + dtstartUtc, + reminderLeadMinutes: 30, + isShared: true, + }); - await runReminderCheck(); + mockTwoQueries(vi.mocked(db), [row]); + await runReminderCheck(now); + + expect(vi.mocked(dispatchPush)).toHaveBeenCalledOnce(); + }); + + it('NOTIF-04: does NOT dispatch a 30-min lead event when now is 5 min before start (fire time already passed)', async () => { + // Fire time = dtstartUtc - 30min = 09:30. now = 10:25 (event in 5 min, past fire time). + // SQL should not return this row (fire time check). Simulate with empty mock. + const now = new Date('2026-06-15T10:25:00Z'); + vi.setSystemTime(now); + + const { db } = await import('../../src/db/client.js'); + const { dispatchPush } = await import('../../src/lib/pushDispatcher.js'); + const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js'); + + // Simulate: event would have been in old 16-min window but already past its fire time + mockTwoQueries(vi.mocked(db), []); + await runReminderCheck(now); expect(vi.mocked(dispatchPush)).not.toHaveBeenCalled(); }); - it('does not dispatch reminders for non-shared (personal) calendar events (D-05)', async () => { + it('NOTIF-05: a personal (isShared=false) timed event with non-null lead DOES dispatch (restriction dropped)', async () => { const now = new Date('2026-06-15T10:00:00Z'); vi.setSystemTime(now); @@ -108,10 +169,55 @@ describe('reminderScheduler — shared+timed event filtering (D-05/D-07)', () => const { dispatchPush } = await import('../../src/lib/pushDispatcher.js'); const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js'); - // isShared=false events are excluded by the SQL WHERE; simulate by returning empty rows - vi.mocked(db.select).mockReturnValue(makeSelectMock([])); + const dtstartUtc = new Date('2026-06-15T10:15:00Z'); + const row = makeEventRow({ + uid: 'personal-event-uid', + dtstartUtc, + reminderLeadMinutes: 15, + isShared: false, // personal calendar — must still dispatch + }); - await runReminderCheck(); + mockTwoQueries(vi.mocked(db), [row]); + await runReminderCheck(now); + + // Personal event must dispatch (isShared restriction dropped per NOTIF-05) + expect(vi.mocked(dispatchPush)).toHaveBeenCalledOnce(); + }); + + it('NOTIF-05: an event with reminderLeadMinutes=NULL produces zero dispatches', async () => { + const now = new Date('2026-06-15T10:00:00Z'); + vi.setSystemTime(now); + + const { db } = await import('../../src/db/client.js'); + const { dispatchPush } = await import('../../src/lib/pushDispatcher.js'); + const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js'); + + // NULL lead events are excluded by SQL WHERE (reminder_lead_minutes IS NOT NULL) + mockTwoQueries(vi.mocked(db), []); + await runReminderCheck(now); + + expect(vi.mocked(dispatchPush)).not.toHaveBeenCalled(); + }); + + it('NOTIF-05: a timed event with reminderLeadMinutes=0 produces zero dispatches (D-06: 0 on timed = None)', async () => { + const now = new Date('2026-06-15T10:00:00Z'); + vi.setSystemTime(now); + + const { db } = await import('../../src/db/client.js'); + const { dispatchPush } = await import('../../src/lib/pushDispatcher.js'); + const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js'); + + const dtstartUtc = new Date('2026-06-15T10:01:00Z'); + const row = makeEventRow({ + uid: 'timed-zero-uid', + dtstartUtc, + reminderLeadMinutes: 0, // D-06: 0 on timed = None (skip) + allDay: false, + }); + + // Even if the query returned a timed-0 event, JS must skip it + mockTwoQueries(vi.mocked(db), [row]); + await runReminderCheck(now); expect(vi.mocked(dispatchPush)).not.toHaveBeenCalled(); }); @@ -125,7 +231,7 @@ describe('reminderScheduler — shared+timed event filtering (D-05/D-07)', () => const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js'); // gt(dtstartUtc, now) excludes already-started events; simulate by returning empty rows - vi.mocked(db.select).mockReturnValue(makeSelectMock([])); + mockTwoQueries(vi.mocked(db), []); await runReminderCheck(now); @@ -154,8 +260,8 @@ describe('reminderScheduler — D-16: empty push_subscriptions', () => { const { dispatchPush } = await import('../../src/lib/pushDispatcher.js'); const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js'); - // Cross-join with empty push_subscriptions returns no rows - vi.mocked(db.select).mockReturnValue(makeSelectMock([])); + // Cross-join with empty push_subscriptions returns no rows for both queries + mockTwoQueries(vi.mocked(db), []); await expect(runReminderCheck(now)).resolves.toBeUndefined(); expect(vi.mocked(dispatchPush)).not.toHaveBeenCalled(); @@ -175,13 +281,14 @@ describe('reminderScheduler — catch-up window and missed-tick recovery', () => vi.clearAllMocks(); }); - it('SINGLE-FIRE: dispatches exactly once across three consecutive ticks while event is in window', async () => { + it('SINGLE-FIRE (uid:dtstartMs): dispatches exactly once across three consecutive ticks while event is in window', async () => { + // NOTIF-06: dedup key is uid:dtstartMs — same compound key fires once across 3 ticks const { db } = await import('../../src/db/client.js'); const { dispatchPush } = await import('../../src/lib/pushDispatcher.js'); const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js'); const t0 = new Date('2026-06-15T10:00:00Z'); - // Event is 15 min out from t0; still in (now, now+16min] at t0+1min (14 min out) and t0+2min (13 min out) + // Event is 15 min out from t0. Fire window (now-60s, now] catches it at t0. const eventDtstart = new Date('2026-06-15T10:15:00Z'); const sub = { @@ -196,7 +303,7 @@ describe('reminderScheduler — catch-up window and missed-tick recovery', () => return makeEventRow({ uid: 'single-fire-uid', dtstartUtc: eventDtstart, - ...sub, + reminderLeadMinutes: 15, subId: sub.id, subUserId: sub.userId, subEndpoint: sub.endpoint, @@ -205,57 +312,95 @@ describe('reminderScheduler — catch-up window and missed-tick recovery', () => }); } - // Tick at t0 (event 15 min out) + // Tick at t0 — fire time is exactly now (dtstartUtc - 15min = t0); dispatches once vi.setSystemTime(t0); - vi.mocked(db.select).mockReturnValue(makeSelectMock([rowForNow()])); + mockTwoQueries(vi.mocked(db), [rowForNow()]); await runReminderCheck(t0); - // Tick at t0+1min (event 14 min out — still in window, same uid) + // Tick at t0+1min — same uid:dtstartMs already in sentReminders; must NOT re-dispatch const t1 = new Date(t0.getTime() + 60 * 1000); vi.setSystemTime(t1); - vi.mocked(db.select).mockReturnValue(makeSelectMock([rowForNow()])); + mockTwoQueries(vi.mocked(db), [rowForNow()]); await runReminderCheck(t1); - // Tick at t0+2min (event 13 min out — still in window, same uid) + // Tick at t0+2min — still deduped const t2 = new Date(t0.getTime() + 2 * 60 * 1000); vi.setSystemTime(t2); - vi.mocked(db.select).mockReturnValue(makeSelectMock([rowForNow()])); + mockTwoQueries(vi.mocked(db), [rowForNow()]); await runReminderCheck(t2); - // Exactly one dispatch total: uid dedup prevents re-fire on ticks 2 and 3 + // Exactly one dispatch total: uid:dtstartMs dedup prevents re-fire on ticks 2 and 3 expect(vi.mocked(dispatchPush).mock.calls.length).toBe(1); }); - it('MISSED-TICK-RECOVERY: fires when scan runs 8 min before event after ideal tick was skipped', async () => { + it('RESCHEDULE (uid:dtstartMs): a rescheduled event (same uid, new dtstart) fires again', async () => { + // NOTIF-06: compound key uid:dtstartMs — new dtstartMs means new key → re-fires const { db } = await import('../../src/db/client.js'); const { dispatchPush } = await import('../../src/lib/pushDispatcher.js'); const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js'); - // Event at 10:15:00Z. The ideal 15-min scan (10:00:00Z) was missed. - // Call at 10:07:00Z — event is 8 min out, still > now and <= now+16min. + const now = new Date('2026-06-15T10:00:00Z'); + vi.setSystemTime(now); + + const originalDtstart = new Date('2026-06-15T10:15:00Z'); + + const row = makeEventRow({ + uid: 'reschedule-uid', + dtstartUtc: originalDtstart, + reminderLeadMinutes: 15, + }); + + // First tick — fires + mockTwoQueries(vi.mocked(db), [row]); + await runReminderCheck(now); + expect(vi.mocked(dispatchPush).mock.calls.length).toBe(1); + + // Advance time so original dtstart is pruned; event rescheduled to a new dtstart + const futureNow = new Date('2026-06-15T10:20:00Z'); // past original dtstart + vi.setSystemTime(futureNow); + mockTwoQueries(vi.mocked(db), []); // empty (original started) + await runReminderCheck(futureNow); // prune old entry + + // Now event has new dtstart (same uid, different dtstartMs) — must fire again + const rescheduledNow = new Date('2026-06-15T11:00:00Z'); + const rescheduledDtstart = new Date('2026-06-15T11:15:00Z'); + vi.setSystemTime(rescheduledNow); + const rescheduledRow = makeEventRow({ + uid: 'reschedule-uid', + dtstartUtc: rescheduledDtstart, + reminderLeadMinutes: 15, + }); + mockTwoQueries(vi.mocked(db), [rescheduledRow]); + await runReminderCheck(rescheduledNow); + + // Must fire again — new compound key uid:rescheduledDtstartMs + expect(vi.mocked(dispatchPush).mock.calls.length).toBe(2); + }); + + it('MISSED-TICK-RECOVERY: fires when scan runs inside the 60s catch-up window after ideal tick was skipped', async () => { + const { db } = await import('../../src/db/client.js'); + const { dispatchPush } = await import('../../src/lib/pushDispatcher.js'); + const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js'); + + // Event at 10:15:00Z, 15-min lead → fire time 10:00:00Z. + // Ideal scan at 10:00Z was missed. Call at 10:00:45Z — still in (fire-60s, fire] window. const eventDtstart = new Date('2026-06-15T10:15:00Z'); - const recoveryNow = new Date('2026-06-15T10:07:00Z'); + const recoveryNow = new Date('2026-06-15T10:00:45Z'); // 45s after ideal fire time vi.setSystemTime(recoveryNow); - const sub = { - id: 1, - userId: 1, - endpoint: 'https://push.example.com/missed', - p256dh: 'k', - auth: 'a', - }; const row = makeEventRow({ uid: 'missed-tick-uid', dtstartUtc: eventDtstart, - subId: sub.id, - subUserId: sub.userId, - subEndpoint: sub.endpoint, - subP256dh: sub.p256dh, - subAuth: sub.auth, + reminderLeadMinutes: 15, + subId: 1, + subUserId: 1, + subEndpoint: 'https://push.example.com/missed', + subP256dh: 'k', + subAuth: 'a', }); - vi.mocked(db.select).mockReturnValue(makeSelectMock([row])); + mockTwoQueries(vi.mocked(db), [row]); await runReminderCheck(recoveryNow); // Reminder must fire even though the ideal-mark tick was skipped @@ -276,6 +421,7 @@ describe('reminderScheduler — catch-up window and missed-tick recovery', () => makeEventRow({ uid: 'fanout-uid', dtstartUtc, + reminderLeadMinutes: 15, subId: 1, subUserId: 1, subEndpoint: 'https://push.example.com/1', @@ -285,6 +431,7 @@ describe('reminderScheduler — catch-up window and missed-tick recovery', () => makeEventRow({ uid: 'fanout-uid', dtstartUtc, + reminderLeadMinutes: 15, subId: 2, subUserId: 2, subEndpoint: 'https://push.example.com/2', @@ -293,7 +440,7 @@ describe('reminderScheduler — catch-up window and missed-tick recovery', () => }), ]; - vi.mocked(db.select).mockReturnValue(makeSelectMock(rows)); + mockTwoQueries(vi.mocked(db), rows); await runReminderCheck(now); // One dispatch per subscriber @@ -336,6 +483,7 @@ describe('reminderScheduler — WR-01: mark-sent after dispatch', () => { uid: 'wr01-dedup-uid', title: 'WR-01 dedup event', dtstartUtc: new Date('2026-06-15T10:15:00Z'), + reminderLeadMinutes: 15, subId: sub.id, subUserId: sub.userId, subEndpoint: sub.endpoint, @@ -345,13 +493,13 @@ describe('reminderScheduler — WR-01: mark-sent after dispatch', () => { vi.mocked(dispatchPush).mockResolvedValue(undefined); - // First run — dispatch succeeds; uid is recorded after the loop - vi.mocked(db.select).mockReturnValue(makeSelectMock([eventRow])); + // First run — dispatch succeeds; uid:dtstartMs is recorded after the loop + mockTwoQueries(vi.mocked(db), [eventRow]); await runReminderCheck(now); expect(vi.mocked(dispatchPush).mock.calls.length).toBe(1); // dispatched once - // Second run with same now — uid is in sentReminders; should NOT re-dispatch - vi.mocked(db.select).mockReturnValue(makeSelectMock([eventRow])); + // Second run with same now — uid:dtstartMs is in sentReminders; should NOT re-dispatch + mockTwoQueries(vi.mocked(db), [eventRow]); await runReminderCheck(now); expect(vi.mocked(dispatchPush).mock.calls.length).toBe(1); // still 1 — deduped }); @@ -389,6 +537,7 @@ describe('reminderScheduler — CR-01: sentReminders Map pruning', () => { uid, title: 'Prune test event', dtstartUtc: eventDtstart, + reminderLeadMinutes: 15, subId: sub.id, subUserId: sub.userId, subEndpoint: sub.endpoint, @@ -399,26 +548,265 @@ describe('reminderScheduler — CR-01: sentReminders Map pruning', () => { // Tick at t0 (now=10:00): event 15 min out — fires const t0 = new Date('2026-06-15T10:00:00Z'); vi.setSystemTime(t0); - vi.mocked(db.select).mockReturnValue(makeSelectMock([eventRow])); + mockTwoQueries(vi.mocked(db), [eventRow]); await runReminderCheck(t0); expect(vi.mocked(dispatchPush).mock.calls.length).toBe(1); // fired - // Same t0 — uid still in Map — must NOT re-fire - vi.mocked(db.select).mockReturnValue(makeSelectMock([eventRow])); + // Same t0 — uid:dtstartMs still in Map — must NOT re-fire + mockTwoQueries(vi.mocked(db), [eventRow]); await runReminderCheck(t0); expect(vi.mocked(dispatchPush).mock.calls.length).toBe(1); // still 1 - // Advance past dtstart (now=10:20): event has started; CR-01 prunes the uid entry. + // Advance past dtstart (now=10:20): event has started; CR-01 prunes the uid:dtstartMs entry. // The SQL WHERE gt(dtstartUtc, now) would return no rows, so simulate empty. const tPast = new Date('2026-06-15T10:20:00Z'); vi.setSystemTime(tPast); - vi.mocked(db.select).mockReturnValue(makeSelectMock([])); + mockTwoQueries(vi.mocked(db), []); await runReminderCheck(tPast); // Pruning fires; dispatch count stays at 1 expect(vi.mocked(dispatchPush).mock.calls.length).toBe(1); }); }); +// ── D-09: humanizeLeadMinutes body formatter ───────────────────────────────── + +describe('humanizeLeadMinutes — D-09 humanized push body formatter', () => { + it('maps 30 min → "Starts in 30 min"', async () => { + const { humanizeLeadMinutes } = await import('../../src/broker/reminderScheduler.js'); + expect(humanizeLeadMinutes(30)).toBe('Starts in 30 min'); + }); + + it('maps 59 min → "Starts in 59 min" (< 60 bucket)', async () => { + const { humanizeLeadMinutes } = await import('../../src/broker/reminderScheduler.js'); + expect(humanizeLeadMinutes(59)).toBe('Starts in 59 min'); + }); + + it('maps 60 min → "Starts in 1 hour"', async () => { + const { humanizeLeadMinutes } = await import('../../src/broker/reminderScheduler.js'); + expect(humanizeLeadMinutes(60)).toBe('Starts in 1 hour'); + }); + + it('maps 90 min → "Starts in 1 hour" (60–119 bucket, not 2 hours)', async () => { + // Math.round(90/60)=2 would give "2 hours" — branch ordering must prevent this + const { humanizeLeadMinutes } = await import('../../src/broker/reminderScheduler.js'); + expect(humanizeLeadMinutes(90)).toBe('Starts in 1 hour'); + }); + + it('maps 120 min → "Starts in 2 hours"', async () => { + const { humanizeLeadMinutes } = await import('../../src/broker/reminderScheduler.js'); + expect(humanizeLeadMinutes(120)).toBe('Starts in 2 hours'); + }); + + it('maps 1440 min → "Starts in 1 day"', async () => { + const { humanizeLeadMinutes } = await import('../../src/broker/reminderScheduler.js'); + expect(humanizeLeadMinutes(1440)).toBe('Starts in 1 day'); + }); + + it('maps 2880 min → "Starts in 2 days"', async () => { + const { humanizeLeadMinutes } = await import('../../src/broker/reminderScheduler.js'); + expect(humanizeLeadMinutes(2880)).toBe('Starts in 2 days'); + }); + + it('maps 10080 min → "Starts in 7 days"', async () => { + const { humanizeLeadMinutes } = await import('../../src/broker/reminderScheduler.js'); + expect(humanizeLeadMinutes(10080)).toBe('Starts in 7 days'); + }); +}); + +describe('reminderScheduler — D-09: humanized push body in dispatch', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.resetModules(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it('dispatched notification body is humanized from configured lead (not live minutes-to-start)', async () => { + // D-09: body driven by event.reminderLeadMinutes (DB ground truth), not by (dtstart - now) + // A 1440-min (1 day) lead event should produce "Starts in 1 day", not "Starts in 30 min" + const now = new Date('2026-06-15T10:00:00Z'); + vi.setSystemTime(now); + + const { db } = await import('../../src/db/client.js'); + const { dispatchPush } = await import('../../src/lib/pushDispatcher.js'); + const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js'); + + // Event exactly 1440 min (1 day) from now → fire time = dtstartUtc - 1440min = now + const dtstartUtc = new Date('2026-06-16T10:00:00Z'); // tomorrow 10:00Z + const row = makeEventRow({ + uid: 'humanize-body-uid', + title: 'Meeting Tomorrow', + dtstartUtc, + reminderLeadMinutes: 1440, + }); + + mockTwoQueries(vi.mocked(db), [row]); + await runReminderCheck(now); + + expect(vi.mocked(dispatchPush)).toHaveBeenCalledOnce(); + const [, payload] = vi.mocked(dispatchPush).mock.calls[0]!; + // Body must be "Starts in 1 day" (configured lead), not "Starts in 1470 min" (live delta) + expect((payload as { body: string }).body).toBe('Starts in 1 day'); + }); +}); + +// ── NOTIF-06: All-day 9 AM-local fire branch ───────────────────────────────── + +describe('reminderScheduler — NOTIF-06: all-day 9 AM-local fire branch', () => { + // All-day tests use the server timezone (America/New_York = UTC-4 in summer). + // computeAlertInstantUtc('2026-06-15', 0, 'America/New_York') = 2026-06-15T13:00:00Z. + // Tests set `now` to the expected alert UTC to trigger the fire window. + // Pin TZ explicitly so these assertions are deterministic regardless of the host/CI + // timezone (CI runs UTC; the scheduler reads process.env.TZ at call time, D-04). + let prevTz: string | undefined; + + beforeEach(() => { + prevTz = process.env.TZ; + process.env.TZ = 'America/New_York'; + vi.useFakeTimers(); + vi.resetModules(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + if (prevTz === undefined) delete process.env.TZ; + else process.env.TZ = prevTz; + }); + + it('all-day 0-lead fires at 9 AM local (not midnight) on event date', async () => { + // 2026-06-15 all-day, 0-lead → alert = computeAlertInstantUtc('2026-06-15', 0, tz) + // America/New_York summer (EDT, UTC-4) → 9 AM EDT = 2026-06-15T13:00:00Z + const alertUtc = new Date('2026-06-15T13:00:00Z'); + vi.setSystemTime(alertUtc); // now = alert time → fire window triggers + + const { db } = await import('../../src/db/client.js'); + const { dispatchPush } = await import('../../src/lib/pushDispatcher.js'); + const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js'); + + const allDayRow = makeEventRow({ + uid: 'allday-0lead-uid', + title: 'All-Day Event', + dtstartDate: '2026-06-15', + dtstartUtc: null, + allDay: true, + reminderLeadMinutes: 0, + }); + + // Second query (all-day) returns this row; timed query empty + mockTwoQueries(vi.mocked(db), [], [allDayRow]); + await runReminderCheck(alertUtc); + + // Must dispatch at 13:00 UTC (9 AM EDT) — NOT at midnight + expect(vi.mocked(dispatchPush)).toHaveBeenCalledOnce(); + }); + + it('all-day 0-lead does NOT fire at midnight (2026-06-15T00:00:00Z)', async () => { + // Midnight UTC is NOT 9 AM local → must not dispatch + const midnight = new Date('2026-06-15T00:00:00Z'); + vi.setSystemTime(midnight); + + const { db } = await import('../../src/db/client.js'); + const { dispatchPush } = await import('../../src/lib/pushDispatcher.js'); + const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js'); + + const allDayRow = makeEventRow({ + uid: 'allday-midnight-uid', + dtstartDate: '2026-06-15', + dtstartUtc: null, + allDay: true, + reminderLeadMinutes: 0, + }); + + mockTwoQueries(vi.mocked(db), [], [allDayRow]); + await runReminderCheck(midnight); + + // Alert time is 13:00 UTC, not midnight → no dispatch + expect(vi.mocked(dispatchPush)).not.toHaveBeenCalled(); + }); + + it('all-day 1440-lead fires at 9 AM local the day before (2026-06-14T13:00:00Z)', async () => { + // 2026-06-15 all-day, 1440-lead → alert day = 2026-06-14 + // 9 AM EDT on 2026-06-14 = 2026-06-14T13:00:00Z + const alertUtc = new Date('2026-06-14T13:00:00Z'); + vi.setSystemTime(alertUtc); + + const { db } = await import('../../src/db/client.js'); + const { dispatchPush } = await import('../../src/lib/pushDispatcher.js'); + const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js'); + + const allDayRow = makeEventRow({ + uid: 'allday-1440lead-uid', + dtstartDate: '2026-06-15', + dtstartUtc: null, + allDay: true, + reminderLeadMinutes: 1440, + }); + + mockTwoQueries(vi.mocked(db), [], [allDayRow]); + await runReminderCheck(alertUtc); + + expect(vi.mocked(dispatchPush)).toHaveBeenCalledOnce(); + }); + + it('all-day 10080-lead fires at 9 AM local 7 days before the event', async () => { + // 2026-06-22 all-day, 10080-lead (7 days) → alert day = 2026-06-15 + // 9 AM EDT on 2026-06-15 = 2026-06-15T13:00:00Z + const alertUtc = new Date('2026-06-15T13:00:00Z'); + vi.setSystemTime(alertUtc); + + const { db } = await import('../../src/db/client.js'); + const { dispatchPush } = await import('../../src/lib/pushDispatcher.js'); + const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js'); + + const allDayRow = makeEventRow({ + uid: 'allday-10080lead-uid', + dtstartDate: '2026-06-22', + dtstartUtc: null, + allDay: true, + reminderLeadMinutes: 10080, + }); + + mockTwoQueries(vi.mocked(db), [], [allDayRow]); + await runReminderCheck(alertUtc); + + expect(vi.mocked(dispatchPush)).toHaveBeenCalledOnce(); + }); + + it('all-day dedup: same uid:dtstartMs fires exactly once across consecutive ticks', async () => { + // NOTIF-06: uid:dtstartDate-midnight-ms dedup for all-day events + const alertUtc = new Date('2026-06-15T13:00:00Z'); // fire time for '2026-06-15', 0-lead, EDT + + const { db } = await import('../../src/db/client.js'); + const { dispatchPush } = await import('../../src/lib/pushDispatcher.js'); + const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js'); + + const allDayRow = makeEventRow({ + uid: 'allday-dedup-uid', + dtstartDate: '2026-06-15', + dtstartUtc: null, + allDay: true, + reminderLeadMinutes: 0, + }); + + // Tick 1: at alert time — fires + vi.setSystemTime(alertUtc); + mockTwoQueries(vi.mocked(db), [], [allDayRow]); + await runReminderCheck(alertUtc); + expect(vi.mocked(dispatchPush).mock.calls.length).toBe(1); + + // Tick 2: 30s later — same uid:dtstartMs in sentReminders → no re-dispatch + const tick2 = new Date(alertUtc.getTime() + 30 * 1000); + vi.setSystemTime(tick2); + mockTwoQueries(vi.mocked(db), [], [allDayRow]); + await runReminderCheck(tick2); + expect(vi.mocked(dispatchPush).mock.calls.length).toBe(1); // still 1 + }); +}); + // ── T-05-19: per-subscription error isolation ──────────────────────────────── describe('reminderScheduler — T-05-19: per-subscription error isolation', () => { @@ -445,6 +833,7 @@ describe('reminderScheduler — T-05-19: per-subscription error isolation', () = makeEventRow({ uid: 'iso-uid', dtstartUtc, + reminderLeadMinutes: 15, subId: 1, subUserId: 1, subEndpoint: 'https://push.example.com/1', @@ -454,6 +843,7 @@ describe('reminderScheduler — T-05-19: per-subscription error isolation', () = makeEventRow({ uid: 'iso-uid', dtstartUtc, + reminderLeadMinutes: 15, subId: 2, subUserId: 2, subEndpoint: 'https://push.example.com/2', @@ -462,7 +852,7 @@ describe('reminderScheduler — T-05-19: per-subscription error isolation', () = }), ]; - vi.mocked(db.select).mockReturnValue(makeSelectMock(rows)); + mockTwoQueries(vi.mocked(db), rows); // First subscriber throws; second should still be attempted vi.mocked(dispatchPush) .mockRejectedValueOnce(new Error('network error')) @@ -472,3 +862,52 @@ describe('reminderScheduler — T-05-19: per-subscription error isolation', () = expect(vi.mocked(dispatchPush).mock.calls.length).toBe(2); // both attempted }); }); + +// ── CR-02: all-day-aware push body (Phase 11 Plan 05) ──────────────────────── + +describe('humanizeLeadMinutes — CR-02 all-day-aware push body', () => { + // CR-02: humanizeLeadMinutes(0) produces "Starts in 0 min" for timed events, + // but for all-day same-day events (lead=0) the correct body is "Today". + // The function must accept an isAllDay flag to return sensible all-day wording. + // These tests MUST FAIL before the fix — humanizeLeadMinutes takes only one arg. + + it('CR-02: all-day same-day (lead=0) body is NOT "Starts in 0 min" (isAllDay=true)', async () => { + const { humanizeLeadMinutes } = await import('../../src/broker/reminderScheduler.js'); + // Before fix: humanizeLeadMinutes(0) → 'Starts in 0 min' (wrong for all-day same-day) + // After fix: humanizeLeadMinutes(0, true) → 'Today' + expect(humanizeLeadMinutes(0, true)).not.toBe('Starts in 0 min'); + }); + + it('CR-02: all-day same-day (lead=0, isAllDay=true) body is "Today"', async () => { + const { humanizeLeadMinutes } = await import('../../src/broker/reminderScheduler.js'); + expect(humanizeLeadMinutes(0, true)).toBe('Today'); + }); + + it('CR-02: all-day 1-day lead (1440 min, isAllDay=true) body is "Tomorrow"', async () => { + const { humanizeLeadMinutes } = await import('../../src/broker/reminderScheduler.js'); + expect(humanizeLeadMinutes(1440, true)).toBe('Tomorrow'); + }); + + it('CR-02: all-day 2-day lead (2880 min, isAllDay=true) body is "In 2 days"', async () => { + const { humanizeLeadMinutes } = await import('../../src/broker/reminderScheduler.js'); + expect(humanizeLeadMinutes(2880, true)).toBe('In 2 days'); + }); + + it('CR-02: all-day 1-week lead (10080 min, isAllDay=true) body is "In 1 week"', async () => { + const { humanizeLeadMinutes } = await import('../../src/broker/reminderScheduler.js'); + expect(humanizeLeadMinutes(10080, true)).toBe('In 1 week'); + }); + + it('CR-02: timed event body unchanged — humanizeLeadMinutes(0, false) is "Starts in 0 min"', async () => { + const { humanizeLeadMinutes } = await import('../../src/broker/reminderScheduler.js'); + // Timed event 0-lead: treated as None by the scheduler, but the formatter + // should still return the existing "Starts in 0 min" for completeness + expect(humanizeLeadMinutes(0, false)).toBe('Starts in 0 min'); + }); + + it('CR-02: timed event body unchanged — humanizeLeadMinutes(30) still "Starts in 30 min"', async () => { + const { humanizeLeadMinutes } = await import('../../src/broker/reminderScheduler.js'); + // Default isAllDay=false: existing timed behavior must be unchanged + expect(humanizeLeadMinutes(30)).toBe('Starts in 30 min'); + }); +}); diff --git a/apps/api/tests/broker/sync.test.ts b/apps/api/tests/broker/sync.test.ts index 10b9858..09fd59f 100644 --- a/apps/api/tests/broker/sync.test.ts +++ b/apps/api/tests/broker/sync.test.ts @@ -429,3 +429,213 @@ describe('syncCalendar', () => { expect(collectedChanges[1]).toMatchObject({ uid: 'uid-to-delete-2', operation: 'delete' }); }); }); + +// ─── Phase 11 Plan 03 Task 2: reminderLeadMinutes derived from VALARM on sync ── +// CAL-13: sync.ts must derive reminderLeadMinutes from the native VALARM and write it +// to the DB so the scheduler has ground truth for native-client alarms (D-07/NOTIF-05). + +describe('syncCalendar — reminderLeadMinutes from VALARM (Phase 11 Plan 03 Task 2)', () => { + const MOCK_DAV_CAL = { + url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/', + displayName: 'Test Calendar', + ctag: 'ctag-v1', + syncToken: null, + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockOnDuplicateKeyUpdate.mockResolvedValue([{ insertId: 1 }]); + mockValues.mockReturnValue({ onDuplicateKeyUpdate: mockOnDuplicateKeyUpdate }); + mockInsert.mockReturnValue({ values: mockValues }); + mockLimit.mockResolvedValue([{ id: 42 }]); + mockWhere.mockReturnValue({ limit: mockLimit }); + mockFrom.mockReturnValue({ where: mockWhere }); + mockSelect.mockReturnValue({ from: mockFrom }); + mockDeleteWhere.mockResolvedValue([]); + mockDelete.mockReturnValue({ where: mockDeleteWhere }); + }); + + // A single preset TRIGGER:-PT30M → reminderLeadMinutes=30 + it('writes reminderLeadMinutes=30 when VCALENDAR has a single TRIGGER:-PT30M VALARM', async () => { + const { syncCalendar } = await import('../../src/broker/sync.js'); + + const rawVeventWithValarm = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'BEGIN:VEVENT', + 'UID:uid-with-valarm@test', + 'SUMMARY:Meeting with reminder', + 'DTSTART:20260615T140000Z', + 'DTEND:20260615T150000Z', + 'BEGIN:VALARM', + 'ACTION:DISPLAY', + 'DESCRIPTION:Reminder', + 'TRIGGER:-PT30M', + 'END:VALARM', + 'END:VEVENT', + 'END:VCALENDAR', + ].join('\r\n'); + + const mockClient = { + fetchCalendarObjects: vi + .fn() + .mockResolvedValue([ + { data: rawVeventWithValarm, etag: '"etag-valarm"', url: '/cal/valarm.ics' }, + ]), + }; + + await syncCalendar(mockClient as never, MOCK_DAV_CAL as never, 1); + + const eventValuesArg = mockValues.mock.calls[1][0]; + expect(eventValuesArg.reminderLeadMinutes).toBe(30); + }); + + // No VALARM → reminderLeadMinutes=null + it('writes reminderLeadMinutes=null when VCALENDAR has no VALARM', async () => { + const { syncCalendar } = await import('../../src/broker/sync.js'); + + const rawVeventNoValarm = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'BEGIN:VEVENT', + 'UID:uid-no-valarm@test', + 'SUMMARY:Event without reminder', + 'DTSTART:20260615T140000Z', + 'DTEND:20260615T150000Z', + 'END:VEVENT', + 'END:VCALENDAR', + ].join('\r\n'); + + const mockClient = { + fetchCalendarObjects: vi + .fn() + .mockResolvedValue([ + { data: rawVeventNoValarm, etag: '"etag-no-valarm"', url: '/cal/no-valarm.ics' }, + ]), + }; + + await syncCalendar(mockClient as never, MOCK_DAV_CAL as never, 1); + + const eventValuesArg = mockValues.mock.calls[1][0]; + expect(eventValuesArg.reminderLeadMinutes).toBeNull(); + }); + + // Absolute DATE-TIME trigger → reminderLeadMinutes=null (custom kind, D-07/NOTIF-05) + it('writes reminderLeadMinutes=null when VALARM has absolute DATE-TIME trigger (custom → null)', async () => { + const { syncCalendar } = await import('../../src/broker/sync.js'); + + const rawVeventAbsoluteValarm = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'BEGIN:VEVENT', + 'UID:uid-absolute-valarm@test', + 'SUMMARY:Event with absolute VALARM', + 'DTSTART:20260615T140000Z', + 'DTEND:20260615T150000Z', + 'BEGIN:VALARM', + 'ACTION:DISPLAY', + 'DESCRIPTION:Reminder', + 'TRIGGER;VALUE=DATE-TIME:20260615T120000Z', + 'END:VALARM', + 'END:VEVENT', + 'END:VCALENDAR', + ].join('\r\n'); + + const mockClient = { + fetchCalendarObjects: vi.fn().mockResolvedValue([ + { + data: rawVeventAbsoluteValarm, + etag: '"etag-abs"', + url: '/cal/abs.ics', + }, + ]), + }; + + await syncCalendar(mockClient as never, MOCK_DAV_CAL as never, 1); + + const eventValuesArg = mockValues.mock.calls[1][0]; + // Absolute DATE-TIME trigger → classifyValarms returns 'custom' → null + expect(eventValuesArg.reminderLeadMinutes).toBeNull(); + }); + + // Two VALARMs → reminderLeadMinutes=null (custom, multiple alarms not resolvable to one lead) + it('writes reminderLeadMinutes=null when VCALENDAR has two VALARMs (multiple → custom → null)', async () => { + const { syncCalendar } = await import('../../src/broker/sync.js'); + + const rawVeventTwoValarms = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'BEGIN:VEVENT', + 'UID:uid-two-valarms@test', + 'SUMMARY:Event with two alarms', + 'DTSTART:20260615T140000Z', + 'DTEND:20260615T150000Z', + 'BEGIN:VALARM', + 'ACTION:DISPLAY', + 'DESCRIPTION:First Reminder', + 'TRIGGER:-PT30M', + 'END:VALARM', + 'BEGIN:VALARM', + 'ACTION:DISPLAY', + 'DESCRIPTION:Second Reminder', + 'TRIGGER:-PT15M', + 'END:VALARM', + 'END:VEVENT', + 'END:VCALENDAR', + ].join('\r\n'); + + const mockClient = { + fetchCalendarObjects: vi.fn().mockResolvedValue([ + { + data: rawVeventTwoValarms, + etag: '"etag-two"', + url: '/cal/two.ics', + }, + ]), + }; + + await syncCalendar(mockClient as never, MOCK_DAV_CAL as never, 1); + + const eventValuesArg = mockValues.mock.calls[1][0]; + // Multiple VALARMs → classifyValarms returns 'custom' → null + expect(eventValuesArg.reminderLeadMinutes).toBeNull(); + }); + + // Ensure the onDuplicateKeyUpdate ALSO sets reminderLeadMinutes (upsert column must be current) + it('sets reminderLeadMinutes in onDuplicateKeyUpdate set (re-sync keeps column current)', async () => { + const { syncCalendar } = await import('../../src/broker/sync.js'); + + const rawVeventWithValarm = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'BEGIN:VEVENT', + 'UID:uid-upsert@test', + 'SUMMARY:Recurring meeting', + 'DTSTART:20260615T100000Z', + 'DTEND:20260615T110000Z', + 'BEGIN:VALARM', + 'ACTION:DISPLAY', + 'DESCRIPTION:Reminder', + 'TRIGGER:-PT15M', + 'END:VALARM', + 'END:VEVENT', + 'END:VCALENDAR', + ].join('\r\n'); + + const mockClient = { + fetchCalendarObjects: vi + .fn() + .mockResolvedValue([ + { data: rawVeventWithValarm, etag: '"etag-upsert"', url: '/cal/upsert.ics' }, + ]), + }; + + await syncCalendar(mockClient as never, MOCK_DAV_CAL as never, 1); + + // The onDuplicateKeyUpdate `set` object must also contain reminderLeadMinutes + const upsertSetArg = mockOnDuplicateKeyUpdate.mock.calls[1]?.[0] as { + set?: Record; + }; + expect(upsertSetArg?.set).toHaveProperty('reminderLeadMinutes', 15); + }); +}); diff --git a/apps/api/tests/broker/vevent.test.ts b/apps/api/tests/broker/vevent.test.ts index 568a04b..529bb44 100644 --- a/apps/api/tests/broker/vevent.test.ts +++ b/apps/api/tests/broker/vevent.test.ts @@ -1,5 +1,5 @@ /** - * RED test scaffold: broker/vevent.ts — VEVENT builder (CAL-04, CAL-07) + * RED test scaffold: broker/vevent.ts — VEVENT builder (CAL-04, CAL-07, CAL-13, CAL-14) * * Behaviors under test: * 1. buildVeventString produces a VCALENDAR with a VEVENT for a timed event @@ -7,17 +7,22 @@ * 2. buildVeventString produces a VCALENDAR with DTSTART as a DATE value * (no time component, no TZID) for all-day events (D-13, Pitfall 3) * 3. buildVeventString with rruleString produces a VCALENDAR with an RRULE property (CAL-07) - * - * These tests FAIL (RED) because broker/vevent.ts does not exist yet. - * They will turn GREEN in Plan 03-02 when the implementation is added. + * 4. buildTimedValarm / buildAllDayValarm VALARM builders (CAL-13, Phase 11 Plan 01) + * 5. classifyValarms / extractValarms (CAL-14, Phase 11 Plan 01) + * 6. computeAlertInstantUtc DST-correct 9 AM-local→UTC (NOTIF-06, Phase 11 Plan 01) */ +import ICAL from 'ical.js'; import { describe, it, expect } from 'vitest'; -// This import fails (RED) — broker/vevent.ts does not exist yet. -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore intentional RED import -import { buildVeventString } from '../../src/broker/vevent.js'; +import { + buildVeventString, + buildTimedValarm, + buildAllDayValarm, + classifyValarms, + extractValarms, + computeAlertInstantUtc, +} from '../../src/broker/vevent.js'; describe('buildVeventString', () => { it('produces a VCALENDAR string containing a VEVENT for a timed event', () => { @@ -196,3 +201,306 @@ describe('buildVeventString — D-13 form-parsed contract', () => { expect(dtendMatch?.[1]).not.toBe(dtstartMatch?.[1]); }); }); + +// ─── Phase 11 Plan 01: VALARM builders (CAL-13) ────────────────────────────── + +describe('buildTimedValarm', () => { + it('returns a VALARM component with TRIGGER:-PT30M and no VALUE=TEXT', () => { + const valarm = buildTimedValarm(30); + const ics = valarm.toString(); + expect(ics).toContain('TRIGGER:-PT30M'); + expect(ics).not.toContain('VALUE=TEXT'); + expect(ics).toContain('ACTION:DISPLAY'); + expect(ics).toContain('DESCRIPTION:Reminder'); + }); + + it('returns a VALARM with TRIGGER:-PT2H (or -PT120M) for 120 minutes and no VALUE=TEXT', () => { + const valarm = buildTimedValarm(120); + const ics = valarm.toString(); + // ical.js may emit -PT2H or -PT120M — either is RFC-valid + expect(ics).toMatch(/TRIGGER:-P(?:T2H|T120M)/); + expect(ics).not.toContain('VALUE=TEXT'); + }); +}); + +describe('buildAllDayValarm', () => { + it('returns a VALARM with absolute DATE-TIME trigger in UTC (Z suffix, no DURATION)', () => { + const alertInstant = new Date('2026-06-14T13:00:00Z'); + const valarm = buildAllDayValarm(alertInstant); + const ics = valarm.toString(); + // Must contain the absolute UTC datetime + expect(ics).toContain('20260614T130000Z'); + // Must carry VALUE=DATE-TIME + expect(ics).toContain('VALUE=DATE-TIME'); + // Must NOT be a DURATION trigger + expect(ics).not.toMatch(/TRIGGER:-PT/); + expect(ics).toContain('ACTION:DISPLAY'); + expect(ics).toContain('DESCRIPTION:Reminder'); + }); +}); + +describe('buildVeventString — VALARM emission (CAL-13)', () => { + it('emits exactly one VALARM with TRIGGER:-PT15M for a timed event with reminderLeadMinutes=15', () => { + const result = buildVeventString({ + summary: 'Meeting', + allDay: false, + dtstart: new Date('2026-06-15T14:00:00Z'), + dtend: new Date('2026-06-15T15:00:00Z'), + reminderLeadMinutes: 15, + }); + expect(result.icsString).toContain('BEGIN:VALARM'); + expect(result.icsString).toContain('TRIGGER:-PT15M'); + expect(result.icsString).not.toContain('VALUE=TEXT'); + // Only one VALARM block + const count = (result.icsString.match(/BEGIN:VALARM/g) ?? []).length; + expect(count).toBe(1); + }); + + it('emits NO VALARM for a timed event with reminderLeadMinutes=0 (D-06: 0 on timed = None)', () => { + const result = buildVeventString({ + summary: 'Meeting', + allDay: false, + dtstart: new Date('2026-06-15T14:00:00Z'), + dtend: new Date('2026-06-15T15:00:00Z'), + reminderLeadMinutes: 0, + }); + expect(result.icsString).not.toContain('BEGIN:VALARM'); + }); + + it('emits one VALARM with absolute DATE-TIME trigger for an all-day event with reminderLeadMinutes=1440', () => { + const alertInstant = new Date('2026-06-14T13:00:00Z'); + const result = buildVeventString({ + summary: 'Anniversary', + allDay: true, + dtstart: '2026-06-15', + dtend: '2026-06-15', + reminderLeadMinutes: 1440, + allDayAlertInstantUtc: alertInstant, + }); + expect(result.icsString).toContain('BEGIN:VALARM'); + expect(result.icsString).toContain('VALUE=DATE-TIME'); + expect(result.icsString).toContain('20260614T130000Z'); + expect(result.icsString).not.toContain('VALUE=TEXT'); + }); + + it('emits NO VALARM when reminderLeadMinutes is null', () => { + const result = buildVeventString({ + summary: 'No reminder event', + allDay: false, + dtstart: new Date('2026-06-15T14:00:00Z'), + dtend: new Date('2026-06-15T15:00:00Z'), + reminderLeadMinutes: null, + }); + expect(result.icsString).not.toContain('BEGIN:VALARM'); + }); + + it('emits the supplied VALARM verbatim via params.valarms (preserve path) even when reminderLeadMinutes is also set', () => { + const preservedValarm = buildTimedValarm(60); + const result = buildVeventString({ + summary: 'Preserved alarm', + allDay: false, + dtstart: new Date('2026-06-15T14:00:00Z'), + dtend: new Date('2026-06-15T15:00:00Z'), + valarms: [preservedValarm], + reminderLeadMinutes: 15, // should be ignored — preserve wins + }); + // The preserved 60-min alarm is present + expect(result.icsString).toContain('TRIGGER:-PT1H'); + // The 15-min alarm is NOT additionally synthesized + expect(result.icsString).not.toContain('TRIGGER:-PT15M'); + // Exactly one VALARM block + const count = (result.icsString.match(/BEGIN:VALARM/g) ?? []).length; + expect(count).toBe(1); + }); +}); + +// ─── Phase 11 Plan 01: VALARM classifier + extractor (CAL-14) ──────────────── + +// Minimal ICS shell wrapping a VEVENT — used in classifier tests +function makeIcs(veventBody: string): string { + return [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//Test//Test//EN', + 'BEGIN:VEVENT', + 'UID:test@test', + 'SUMMARY:Test', + 'DTSTART:20260615T140000Z', + 'DTEND:20260615T150000Z', + veventBody, + 'END:VEVENT', + 'END:VCALENDAR', + ].join('\r\n'); +} + +describe('classifyValarms', () => { + it('returns { kind: "none" } for an ICS with no VALARM', () => { + expect(classifyValarms(makeIcs(''))).toEqual({ kind: 'none' }); + }); + + it('returns { kind: "preset", leadMinutes: 15 } for a single TRIGGER:-PT15M', () => { + const ics = makeIcs( + 'BEGIN:VALARM\r\nTRIGGER:-PT15M\r\nACTION:DISPLAY\r\nDESCRIPTION:Reminder\r\nEND:VALARM', + ); + expect(classifyValarms(ics)).toEqual({ kind: 'preset', leadMinutes: 15 }); + }); + + it('returns { kind: "offlist", leadMinutes: 45 } for a single TRIGGER:-PT45M', () => { + const ics = makeIcs( + 'BEGIN:VALARM\r\nTRIGGER:-PT45M\r\nACTION:DISPLAY\r\nDESCRIPTION:Reminder\r\nEND:VALARM', + ); + expect(classifyValarms(ics)).toEqual({ kind: 'offlist', leadMinutes: 45 }); + }); + + it('returns { kind: "custom" } for an absolute DATE-TIME trigger', () => { + const ics = makeIcs( + 'BEGIN:VALARM\r\nTRIGGER;VALUE=DATE-TIME:20260615T130000Z\r\nACTION:DISPLAY\r\nDESCRIPTION:Reminder\r\nEND:VALARM', + ); + expect(classifyValarms(ics)).toEqual({ kind: 'custom' }); + }); + + it('returns { kind: "custom" } for two VALARM blocks', () => { + const ics = makeIcs( + [ + 'BEGIN:VALARM', + 'TRIGGER:-PT15M', + 'ACTION:DISPLAY', + 'DESCRIPTION:Reminder', + 'END:VALARM', + 'BEGIN:VALARM', + 'TRIGGER:-PT30M', + 'ACTION:DISPLAY', + 'DESCRIPTION:Reminder', + 'END:VALARM', + ].join('\r\n'), + ); + expect(classifyValarms(ics)).toEqual({ kind: 'custom' }); + }); + + it('returns { kind: "none" } for unparseable input (safe default)', () => { + expect(classifyValarms('not valid ics')).toEqual({ kind: 'none' }); + }); + + it('classifies 1440 (1 day) as preset', () => { + const ics = makeIcs( + 'BEGIN:VALARM\r\nTRIGGER:-P1D\r\nACTION:DISPLAY\r\nDESCRIPTION:Reminder\r\nEND:VALARM', + ); + const result = classifyValarms(ics); + expect(result.kind).toBe('preset'); + if (result.kind === 'preset') expect(result.leadMinutes).toBe(1440); + }); + + it('classifies 10080 (1 week) as preset', () => { + const ics = makeIcs( + 'BEGIN:VALARM\r\nTRIGGER:-P7D\r\nACTION:DISPLAY\r\nDESCRIPTION:Reminder\r\nEND:VALARM', + ); + const result = classifyValarms(ics); + expect(result.kind).toBe('preset'); + if (result.kind === 'preset') expect(result.leadMinutes).toBe(10080); + }); +}); + +describe('extractValarms', () => { + it('returns an array of length 1 for an ICS with one VALARM', () => { + const ics = makeIcs( + 'BEGIN:VALARM\r\nTRIGGER:-PT30M\r\nACTION:DISPLAY\r\nDESCRIPTION:Reminder\r\nEND:VALARM', + ); + const result = extractValarms(ics); + expect(result).toHaveLength(1); + expect(result[0]).toBeInstanceOf(ICAL.Component); + }); + + it('returns an empty array for an ICS with no VALARM', () => { + expect(extractValarms(makeIcs(''))).toHaveLength(0); + }); + + it('returns an empty array for garbage input', () => { + expect(extractValarms('garbage ics')).toHaveLength(0); + }); + + it('returns a live ICAL.Component re-attachable via addSubcomponent (round-trip)', () => { + const ics = makeIcs( + 'BEGIN:VALARM\r\nTRIGGER:-PT60M\r\nACTION:DISPLAY\r\nDESCRIPTION:Reminder\r\nEND:VALARM', + ); + const [alarm] = extractValarms(ics); + // Should be usable as a component — attach it to a new vevent + const vevent = new ICAL.Component('vevent'); + expect(() => vevent.addSubcomponent(alarm)).not.toThrow(); + // ical.js may normalize -PT60M to -PT1H or keep -PT60M — both are RFC-valid + const serialized = vevent.toString(); + expect(serialized).toMatch(/TRIGGER:-P(?:T60M|T1H)/); + }); +}); + +// ─── Phase 11 Plan 01: computeAlertInstantUtc (NOTIF-06) ───────────────────── + +describe('computeAlertInstantUtc', () => { + it('same day (leadDays=0), America/New_York summer → 2026-06-15T13:00:00.000Z (9 AM EDT = UTC-4)', () => { + const result = computeAlertInstantUtc('2026-06-15', 0, 'America/New_York'); + expect(result.toISOString()).toBe('2026-06-15T13:00:00.000Z'); + }); + + it('1 day before (leadDays=1), America/New_York summer → 2026-06-14T13:00:00.000Z', () => { + const result = computeAlertInstantUtc('2026-06-15', 1, 'America/New_York'); + expect(result.toISOString()).toBe('2026-06-14T13:00:00.000Z'); + }); + + it('winter (leadDays=0), America/New_York → 2026-01-15T14:00:00.000Z (9 AM EST = UTC-5)', () => { + const result = computeAlertInstantUtc('2026-01-15', 0, 'America/New_York'); + expect(result.toISOString()).toBe('2026-01-15T14:00:00.000Z'); + }); + + it('spring-forward DST (US 2026-03-08) — 9 AM local uses the post-transition offset (-4 → UTC+13:00:00Z)', () => { + // 2026-03-08 is the spring-forward day in the US; after 2 AM clocks move +1h + // 9 AM on 2026-03-08 in America/New_York = 13:00 UTC (EDT, UTC-4) + const result = computeAlertInstantUtc('2026-03-08', 0, 'America/New_York'); + expect(result.toISOString()).toBe('2026-03-08T13:00:00.000Z'); + }); + + it('fall-back DST (US 2026-11-01) — 9 AM local uses the post-transition offset (-5 → UTC+14:00:00Z)', () => { + // 2026-11-01 is the fall-back day; clocks move back 1h at 2 AM + // 9 AM on 2026-11-01 in America/New_York = 14:00 UTC (EST, UTC-5) + const result = computeAlertInstantUtc('2026-11-01', 0, 'America/New_York'); + expect(result.toISOString()).toBe('2026-11-01T14:00:00.000Z'); + }); +}); + +// ─── Phase 11 Plan 05 WR-01: positive-duration TRIGGER classifies as custom ── + +describe('classifyValarms — WR-01: positive-duration trigger (fires after event)', () => { + // RFC 5545 allows TRIGGER:+PT15M — fires 15 min AFTER event start. + // Before the fix: Math.abs() discards the sign and classifies it as preset/offlist 15. + // After the fix: positive duration → {kind:'custom'} (preserve as-is). + + it('WR-01: TRIGGER:+PT15M classifies as custom, NOT preset', () => { + // TRIGGER:+PT15M is a post-event alarm; must not be mis-read as a 15-min-before lead. + // This test MUST FAIL before the fix. + const ics = makeIcs( + 'BEGIN:VALARM\r\nTRIGGER;RELATED=END:PT15M\r\nACTION:DISPLAY\r\nDESCRIPTION:Reminder\r\nEND:VALARM', + ); + expect(classifyValarms(ics)).toEqual({ kind: 'custom' }); + }); + + it('WR-01: TRIGGER:+PT30M classifies as custom, NOT offlist', () => { + // 30 min is in PRESET_MINUTES — without the sign check, Math.abs would give preset/30. + const ics = makeIcs( + 'BEGIN:VALARM\r\nTRIGGER:PT30M\r\nACTION:DISPLAY\r\nDESCRIPTION:Reminder\r\nEND:VALARM', + ); + expect(classifyValarms(ics)).toEqual({ kind: 'custom' }); + }); + + it('WR-01: negative TRIGGER:-PT15M still classifies as preset (unchanged behavior)', () => { + // Regression guard: negative triggers must not be affected by the fix. + const ics = makeIcs( + 'BEGIN:VALARM\r\nTRIGGER:-PT15M\r\nACTION:DISPLAY\r\nDESCRIPTION:Reminder\r\nEND:VALARM', + ); + expect(classifyValarms(ics)).toEqual({ kind: 'preset', leadMinutes: 15 }); + }); + + it('WR-01: negative off-list TRIGGER:-PT45M still classifies as offlist', () => { + const ics = makeIcs( + 'BEGIN:VALARM\r\nTRIGGER:-PT45M\r\nACTION:DISPLAY\r\nDESCRIPTION:Reminder\r\nEND:VALARM', + ); + expect(classifyValarms(ics)).toEqual({ kind: 'offlist', leadMinutes: 45 }); + }); +}); diff --git a/apps/api/tests/fixtures/absolute-alarm.ics b/apps/api/tests/fixtures/absolute-alarm.ics new file mode 100644 index 0000000..874fe80 --- /dev/null +++ b/apps/api/tests/fixtures/absolute-alarm.ics @@ -0,0 +1,14 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Calendar//NONSGML Version 1//EN +BEGIN:VEVENT +UID:absolute-alarm-test@familysync.test +DTSTART;VALUE=DATE:20261215 +SUMMARY:Holiday Party +BEGIN:VALARM +ACTION:DISPLAY +DESCRIPTION:Reminder +TRIGGER;VALUE=DATE-TIME:20261214T140000Z +END:VALARM +END:VEVENT +END:VCALENDAR diff --git a/apps/api/tests/fixtures/multi-alarm.ics b/apps/api/tests/fixtures/multi-alarm.ics new file mode 100644 index 0000000..6730c85 --- /dev/null +++ b/apps/api/tests/fixtures/multi-alarm.ics @@ -0,0 +1,20 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Outlook//NONSGML Version 1//EN +BEGIN:VEVENT +UID:multi-alarm-test@familysync.test +DTSTART:20261215T100000Z +DTEND:20261215T110000Z +SUMMARY:Team Meeting +BEGIN:VALARM +ACTION:DISPLAY +DESCRIPTION:Reminder 1 +TRIGGER:-PT15M +END:VALARM +BEGIN:VALARM +ACTION:DISPLAY +DESCRIPTION:Reminder 2 +TRIGGER:-PT5M +END:VALARM +END:VEVENT +END:VCALENDAR diff --git a/apps/api/tests/routes/events.test.ts b/apps/api/tests/routes/events.test.ts index f257899..18e727b 100644 --- a/apps/api/tests/routes/events.test.ts +++ b/apps/api/tests/routes/events.test.ts @@ -930,3 +930,77 @@ describe('GET /api/events/writable-calendars', () => { expect(body.calendars.length).toBe(1); }); }); + +// --------------------------------------------------------------------------- +// WR-02: eventFieldsSchema must reject reminderLeadMinutes > 10080 (Phase 11 Plan 05) +// 10080 = 1 week in minutes; the UI cap prevents accidental over-wide lead values. +// --------------------------------------------------------------------------- +describe('WR-02: reminderLeadMinutes .max(10080) in eventFieldsSchema (Phase 11 Plan 05)', () => { + beforeEach(() => { + // Wire a calendar row so POST /create reaches schema validation (not 403) + mockDbRows = [ + { + id: 1, + url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/', + displayName: 'Default', + color: '#4A90D9', + userId: 1, + isShared: false, + }, + ]; + const mockSimpleWhere = vi.fn().mockImplementation(() => Promise.resolve(mockDbRows)); + mockFromFn.mockReturnValue({ where: mockSimpleWhere }); + mockSelectFn.mockReturnValue({ from: mockFromFn }); + }); + + it('returns 400 for reminderLeadMinutes=10081 on POST /create', async () => { + const { app } = await import('../../src/index.js'); + const res = await app.request('/api/events/create', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: 'Over-cap', + allDay: false, + start: '2026-12-15T10:00:00Z', + end: '2026-12-15T11:00:00Z', + calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/', + reminderLeadMinutes: 10081, + }), + }); + expect(res.status).toBe(400); + }); + + it('returns 202 for reminderLeadMinutes=10080 (boundary) on POST /create', async () => { + const { app } = await import('../../src/index.js'); + const res = await app.request('/api/events/create', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: 'Max-cap', + allDay: false, + start: '2026-12-15T10:00:00Z', + end: '2026-12-15T11:00:00Z', + calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/', + reminderLeadMinutes: 10080, + }), + }); + expect(res.status).toBe(202); + }); + + it('returns 202 for reminderLeadMinutes=null (explicit clear) on POST /create', async () => { + const { app } = await import('../../src/index.js'); + const res = await app.request('/api/events/create', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: 'Clear reminder', + allDay: false, + start: '2026-12-15T10:00:00Z', + end: '2026-12-15T11:00:00Z', + calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/', + reminderLeadMinutes: null, + }), + }); + expect(res.status).toBe(202); + }); +}); diff --git a/apps/pwa/src/api/client.ts b/apps/pwa/src/api/client.ts index 145816e..707a5e7 100644 --- a/apps/pwa/src/api/client.ts +++ b/apps/pwa/src/api/client.ts @@ -131,6 +131,22 @@ export interface CalendarOccurrence { * stay in sync with the server type (Pitfall 4 — atomic mirror, Plan 06-05). */ hasRrule: boolean; + /** + * Per-event reminder lead in minutes. NULL = no reminder. 0 = same-day all-day + * (fire 9 AM on event date). Positive integer = N minutes before event start. + * D-06: NULL and 0 are semantically distinct. + * Mirrors CalendarOccurrence.reminderLeadMinutes in apps/api/src/broker/expand.ts + * (atomic mirror, Plan 11-03). + */ + reminderLeadMinutes: number | null; + /** + * True when the event's alarm is custom/absolute/multi-VALARM (not reducible to a + * single before-event lead). When true, reminderLeadMinutes is always null and the + * form must initialize to '__custom__' to preserve the VALARM on edit (CR-01, Plan 11-05). + * Mirrors CalendarOccurrence.reminderIsCustom in apps/api/src/broker/expand.ts + * (atomic mirror, Plan 11-05). + */ + reminderIsCustom: boolean; } export interface OccurrencesResponse { @@ -194,6 +210,14 @@ export interface CreateEventPayload { recurrenceCount?: number; location?: string; description?: string; + /** + * Per-event reminder lead in minutes. + * absent/undefined = no-change (edit omits field so server preserves existing VALARM, D-08) + * null = explicit "None" (clear any VALARM) + * 0 = same-day all-day (fire 9 AM on event date, D-05) + * positive integer = N minutes before event start + */ + reminderLeadMinutes?: number | null; calendarUrl?: string; // omit to use the member's default writable calendar (D-01) } diff --git a/apps/pwa/src/components/EventDetailPopover.test.tsx b/apps/pwa/src/components/EventDetailPopover.test.tsx index a5ac191..f00dcb5 100644 --- a/apps/pwa/src/components/EventDetailPopover.test.tsx +++ b/apps/pwa/src/components/EventDetailPopover.test.tsx @@ -59,6 +59,8 @@ const TIMED_OCCURRENCE: CalendarOccurrence = { location: 'Conference Room B', description: 'Daily team sync meeting', hasRrule: false, + reminderLeadMinutes: null, + reminderIsCustom: false, }; const OCCURRENCE_WITH_HTML: CalendarOccurrence = { @@ -86,6 +88,8 @@ const ALLDAY_OCCURRENCE: CalendarOccurrence = { location: null, description: null, hasRrule: false, + reminderLeadMinutes: null, + reminderIsCustom: false, }; // ── Import component (after mocks are declared) ─────────────────────────────── diff --git a/apps/pwa/src/components/EventForm.test.tsx b/apps/pwa/src/components/EventForm.test.tsx index dae8039..b7ee23f 100644 --- a/apps/pwa/src/components/EventForm.test.tsx +++ b/apps/pwa/src/components/EventForm.test.tsx @@ -124,6 +124,8 @@ const EDIT_OCCURRENCE: CalendarOccurrence = { location: 'Office', description: 'Weekly sync', hasRrule: false, + reminderLeadMinutes: null, + reminderIsCustom: false, }; // ── Import component (after mocks) ──────────────────────────────────────────── @@ -238,7 +240,10 @@ describe('EventForm', () => { it('renders recurrence options: None, Daily, Weekly, Monthly, Yearly', () => { renderForm(); - expect(screen.getByText('None')).toBeDefined(); + // Use getAllByText since "None" now appears in both the recurrence picker and the + // reminder picker (Phase 11 — two setReminderValue(e.target.value)} + style={{ + ...inputStyle, + padding: '0 var(--space-3)', + cursor: 'pointer', + }} + > + {allDay ? ( + // D-02: all-day presets (day-granularity) + <> + + + + + + {/* D-07: synthetic option for off-list all-day value */} + {reminderValue !== '__none__' && + reminderValue !== '__custom__' && + !ALLDAY_REMINDER_PRESETS.has(parseInt(reminderValue, 10)) && + Number.isFinite(parseInt(reminderValue, 10)) && ( + + )} + {/* D-07: read-only Custom (kept) for absolute/multi alarm */} + {reminderValue === '__custom__' && ( + + )} + + ) : ( + // D-01: timed presets + <> + + + + + + + + + + {/* D-07: synthetic option for off-list timed value */} + {reminderValue !== '__none__' && + reminderValue !== '__custom__' && + !TIMED_REMINDER_PRESETS.has(parseInt(reminderValue, 10)) && + Number.isFinite(parseInt(reminderValue, 10)) && ( + + )} + {/* D-07: read-only Custom (kept) for absolute/multi alarm */} + {reminderValue === '__custom__' && ( + + )} + + )} + + {/* Helper text: shown in edit mode when value is __custom__ or synthetic off-list (D-07) */} + {/* WR-03: gate on the ACTIVE preset set only — not both — so a timed event with a + value that happens to be in the allDay set still shows the off-list helper text */} + {eventFormMode === 'edit' && + (reminderValue === '__custom__' || + (reminderValue !== '__none__' && + !(allDay ? ALLDAY_REMINDER_PRESETS : TIMED_REMINDER_PRESETS).has( + parseInt(reminderValue, 10), + ) && + Number.isFinite(parseInt(reminderValue, 10)))) && ( +
+ {/* Plain text — XSS guard (T-11-10 / T-03-15) */} + Custom reminder kept — select a preset to replace it. +
+ )} + + {/* D-06: Recurrence bound control — shown only when recurrence ≠ 'none' in create mode */} {eventFormMode !== 'edit' && recurrence !== 'none' && (