10 Commits
Author SHA1 Message Date
Lucas BergerandClaude Opus 4.8 156bdf057b docs(phase-11): evolve PROJECT.md — per-event reminders validated
CI / changes (pull_request) Successful in 3s
CI / fast-checks (pull_request) Failing after 50s
CI / api (pull_request) Failing after 1m3s
CI / harness (pull_request) Successful in 4m12s
CI / security (pull_request) Successful in 40s
CI / gate (pull_request) Failing after 1s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 08:32:23 -04:00
Lucas BergerandClaude Opus 4.8 ff06a8479b docs(phase-11): complete phase execution (5/5 plans, CAL-13/14 + NOTIF-04/05/06)
One deferred human check: live Fastmail VALARM round-trip + push (untestable in dev, backlog 999.19).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 08:31:31 -04:00
Lucas BergerandClaude Opus 4.8 c86020ac21 docs(phase-11): mark gap-closure plan 11-05 complete; post-merge gate green (typecheck, API 347, PWA 206)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 08:30:51 -04:00
Lucas Berger 9bc6c7274c docs(11-05): complete gap-closure plan — SUMMARY 2026-06-14 08:28:24 -04:00
Lucas Berger a3aec2d21d style(11-05): prettier format EventForm.test.tsx WR-03 additions 2026-06-14 08:26:00 -04:00
Lucas Berger a04c76b823 fix(11-05): WR-03 gate helper text on active preset set only
- helper text condition now uses (allDay ? ALLDAY_REMINDER_PRESETS : TIMED_REMINDER_PRESETS)
- previously checked !TIMED && !ALLDAY: a timed event with 10080 (in ALLDAY set) was
  incorrectly treated as 'in presets' and suppressed the helper text
- synthetic option gating for each allDay/timed branch was already correct
2026-06-14 08:24:24 -04:00
Lucas Berger 401591374a test(11-05): RED — WR-03 helper text suppressed for timed off-list 10080
- timed event with reminderLeadMinutes=10080 must show 'Custom reminder kept' helper
- currently suppressed: helper text checks !TIMED && !ALLDAY, but 10080 is in ALLDAY
- fix: gate helper text on active preset set only (allDay ? ALLDAY : TIMED)
2026-06-14 08:24:01 -04:00
Lucas Berger 7d94afb2d8 fix(11-05): WR-02 add .max(10080) to reminderLeadMinutes in both Zod schemas
- eventFieldsSchema (events.ts): rejects reminderLeadMinutes > 10080 with 400
- outboxPayloadSchema (outboxWorker.ts): hard-fails row when value exceeds 1-week cap
- 10080 = 1 week in minutes; matches UI select maximum
2026-06-14 08:22:13 -04:00
Lucas Berger 30b8c9643a test(11-05): RED — WR-02 reminderLeadMinutes max(10080) in both Zod schemas
- outboxPayloadSchema: 10081 must hard-fail the row (currently dispatches)
- eventFieldsSchema: POST /create with 10081 must 400 (currently 202)
- boundary 10080 and null pass (already correct, no test fails expected)
2026-06-14 08:21:44 -04:00
Lucas Berger bc605e6a42 fix(11-05): WR-01 positive-duration TRIGGER classifies as custom (no Math.abs)
classifyValarms: check sign of dur.toSeconds() before preset lookup.
Positive value = alarm fires after event (RFC 5545 TRIGGER:+PT15M or
TRIGGER;RELATED=END:PTNm) → return {kind:'custom'} for preserve path.
Compute leadMinutes as -seconds/60 (was Math.abs) for negative triggers.

Prevents alarm direction inversion: +PT15M was being stored as 15-min-before
lead and re-fired at dtstartUtc-15min — the opposite of the original intent.
2026-06-14 08:15:31 -04:00
12 changed files with 430 additions and 26 deletions
+2 -1
View File
@@ -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 ~12s 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)_
+6 -6
View File
@@ -18,9 +18,9 @@ Each requirement maps to exactly one roadmap phase (see Traceability).
### 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)
@@ -81,9 +81,9 @@ Maps each REQ-ID to its phase. v1.1 phases continue v1.0 numbering (v1.0 ended a
| ADMIN-03 | Phase 10 (Admin Role & Settings) | Complete |
| CAL-13 | Phase 11 (Per-Event Reminders) | Complete |
| CAL-14 | Phase 11 (Per-Event Reminders) | Complete |
| NOTIF-04 | Phase 11 (Per-Event Reminders) | Pending |
| NOTIF-05 | Phase 11 (Per-Event Reminders) | Pending |
| NOTIF-06 | Phase 11 (Per-Event Reminders) | Pending |
| 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 |
+2 -2
View File
@@ -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 | 4/4 | Complete | 2026-06-14 |
| 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:
+11 -10
View File
@@ -4,14 +4,14 @@ milestone: v1.1
milestone_name: Operability & Polish
status: executing
stopped_at: Completed 11-04-PLAN.md
last_updated: "2026-06-14T10:57:47.937Z"
last_activity: 2026-06-14 -- Phase 11 execution started
last_updated: "2026-06-14T12:31:06.011Z"
last_activity: 2026-06-14
progress:
total_phases: 22
total_phases: 23
completed_phases: 9
total_plans: 31
completed_plans: 31
percent: 41
total_plans: 32
completed_plans: 32
percent: 39
---
# Project State
@@ -25,10 +25,10 @@ See: .planning/PROJECT.md (updated 2026-06-10)
## Current Position
Phase: 11 (per-event-reminders) — EXECUTING
Plan: 2 of 4
Phase: 13
Plan: Not started
Status: Ready to execute
Last activity: 2026-06-14 -- Phase 11 execution started
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:**
@@ -0,0 +1,173 @@
---
phase: 11-per-event-reminders
plan: "05"
subsystem: calendar-reminders
tags: [gap-closure, tdd, bugfix, reminder, valarm, push-notification, schema-validation]
dependency_graph:
requires: [11-01, 11-02, 11-03, 11-04]
provides: [custom-alarm-round-trip, allday-push-body, positive-trigger-classification, schema-max-bound, active-presetset-gating]
affects: [outboxWorker, expand, reminderScheduler, vevent, EventForm, eventFieldsSchema, outboxPayloadSchema]
tech_stack:
added: []
patterns:
- "reminderIsCustom: boolean on CalendarOccurrence — custom-alarm signal from server to form"
- "deriveReminderValue(lead, isAllDay, isCustom) — returns __custom__ to trigger D-08 preserve path"
- "humanizeLeadMinutes(lead, isAllDay) — all-day branch with day-granularity wording"
- "classifyValarms sign-check — seconds > 0 returns custom instead of silently negating"
- "active-presetset gating — helper text and synthetic option use allDay ? ALLDAY : TIMED"
key_files:
created:
- apps/api/tests/fixtures/absolute-alarm.ics
- apps/api/tests/fixtures/multi-alarm.ics
modified:
- apps/api/src/broker/expand.ts
- apps/api/src/broker/vevent.ts
- apps/api/src/broker/reminderScheduler.ts
- apps/api/src/routes/events.ts
- apps/api/src/broker/outboxWorker.ts
- apps/pwa/src/api/client.ts
- apps/pwa/src/components/EventForm.tsx
- apps/api/tests/broker/expand.test.ts
- apps/api/tests/broker/vevent.test.ts
- apps/api/tests/broker/reminderScheduler.test.ts
- apps/api/tests/broker/outboxWorker.test.ts
- apps/api/tests/routes/events.test.ts
- apps/pwa/src/components/EventForm.test.tsx
- apps/pwa/src/components/EventDetailPopover.test.tsx
decisions:
- "D-CR-01: surface reminderIsCustom on CalendarOccurrence (server → client) rather than trying to infer custom state client-side — the classification already exists in classifyValarms"
- "D-CR-02: extend humanizeLeadMinutes with isAllDay flag; call site already has the allDay column — no schema change needed"
- "D-WR-01: check seconds > 0 before looking up presets — simpler than parsing RELATED param"
- "D-WR-02: add .max(10080) to both Zod schemas; matched in both eventFieldsSchema (route) and outboxPayloadSchema (worker) since the worker re-validates independently"
- "D-WR-03: single-expression fix — !(allDay ? ALLDAY : TIMED).has(...) — minimal change, only the helper text was wrong (synthetic option gating was already correct)"
metrics:
completed_date: "2026-06-14"
tasks_completed: 5
tasks_planned: 5
files_changed: 13
new_tests: 23
---
# Phase 11 Plan 05: Gap-Closure Summary
Gap-closure TDD plan fixing 2 confirmed blockers (CR-01, CR-02) and 3 warnings (WR-01WR-03) from the Phase 11 code review. Surfaced custom-alarm signal end-to-end, fixed all-day push body wording, fixed positive-trigger sign-flip, added server-side max bound, and corrected helper-text preset-set gating. All changes TDD RED→GREEN.
## Tasks
### Task 1 — CR-01: Custom alarm round-trip (preserve custom VALARMs on edit)
**Root cause:** `CalendarOccurrence` only carried `reminderLeadMinutes: number | null`. Custom/absolute VALARMs mapped to `null`, indistinguishable from "no alarm". The form's `deriveReminderValue(null, ...)` always returned `'__none__'`, making the `'__custom__' → omit field` preserve branch permanently unreachable.
**Fix:**
- `expand.ts`: added `reminderIsCustom: boolean` to `CalendarOccurrence`; derived from `alarmClass.kind === 'custom'`; propagated to every occurrence branch
- `client.ts`: mirrored `reminderIsCustom: boolean` (atomic mirror pattern)
- `EventForm.tsx`: extended `deriveReminderValue(lead, isAllDay, isCustom)` to return `'__custom__'` when `isCustom=true`; updated edit-load call site to pass `occurrence?.reminderIsCustom ?? false`
**Result:** Editing an event whose VALARM cannot be reduced to a single before-event lead (absolute DATE-TIME trigger, multi-VALARM) now initializes the picker to "Custom (kept)" and omits `reminderLeadMinutes` from the payload — the outbox preserve path (D-08) keeps the original VALARM.
**Commits:** `5d6cb47` (RED), `f6b47eb` (GREEN)
---
### Task 2 — CR-02: All-day-aware push body
**Root cause:** `humanizeLeadMinutes(0)` returned `"Starts in 0 min"` for an all-day same-day reminder. The scheduler already had an `isAllDay` split but did not pass the flag to the humanizer.
**Fix:**
- `reminderScheduler.ts`: extended `humanizeLeadMinutes(leadMinutes, isAllDay)` with all-day branch: 0→"Today", ≤1440→"Tomorrow", 10080→"In 1 week", other→"In N days". Updated both scan branches to pass `isAllDay`.
**Commits:** `1caa2e3` (RED), `16ac235` (GREEN)
---
### Task 3 — WR-01: Positive-duration TRIGGER classifies as custom
**Root cause:** `classifyValarms` used `Math.abs(dur.toSeconds())` — positive triggers (e.g. `TRIGGER:+PT15M`, fires after event) were treated identically to the equivalent before-event lead. A `+PT15M` alarm in Apple Calendar was read as "15 min before" and could overwrite the original timing on save.
**Fix:**
- `vevent.ts`: check `seconds > 0` before preset lookup; return `{ kind: 'custom' }` for positive-duration triggers; use `Math.round(-seconds / 60)` (without abs) for before-event leads.
**Commits:** `d18aba7` (RED), `bc605e6` (GREEN)
---
### Task 4 — WR-02: Server-side max bound on reminderLeadMinutes
**Root cause:** Both `eventFieldsSchema` and `outboxPayloadSchema` had only `min(0)` — no upper bound. The UI caps at 10080 (1 week) but there was no server-side enforcement.
**Fix:**
- `events.ts` `eventFieldsSchema`: `z.number().int().min(0).max(10080).nullable().optional()`
- `outboxWorker.ts` `outboxPayloadSchema`: same change
**Commits:** `30b8c96` (RED), `7d94afb` (GREEN)
---
### Task 5 — WR-03: Helper text gate on active preset set
**Root cause:** The reminder helper text condition checked `!TIMED_REMINDER_PRESETS.has(...) && !ALLDAY_REMINDER_PRESETS.has(...)`. For a timed event with `reminderLeadMinutes=10080`: 10080 is in `ALLDAY_REMINDER_PRESETS`, so `!ALLDAY.has(10080)` was `false` → helper text suppressed. The synthetic option for the timed branch was correctly gated (only checked `TIMED_REMINDER_PRESETS`).
**Fix:**
- `EventForm.tsx`: changed helper text condition to `!(allDay ? ALLDAY_REMINDER_PRESETS : TIMED_REMINDER_PRESETS).has(parseInt(reminderValue, 10))`.
**Commits:** `4015913` (RED), `a04c76b` (GREEN), `a3aec2d` (prettier)
---
## TDD Gate Compliance
All 5 tasks followed RED→GREEN discipline:
| Task | RED commit | GREEN commit |
|------|-----------|-------------|
| CR-01 | `5d6cb47` | `f6b47eb` |
| CR-02 | `1caa2e3` | `16ac235` |
| WR-01 | `d18aba7` | `bc605e6` |
| WR-02 | `30b8c96` | `7d94afb` |
| WR-03 | `4015913` | `a04c76b` |
Each RED commit was verified to fail for the correct reason before the GREEN implementation.
---
## Deviations from Plan
### Auto-fixed Issues
None — plan executed exactly as written, with one minor clarification:
**WR-01 RED test:** The `TRIGGER;RELATED=END:PT15M` case passed unexpectedly in RED (ical.js handles RELATED=END differently), so that specific test was not a blocking RED. The critical RED test was `TRIGGER:PT30M` (unsigned positive), which did fail before the fix. No tests were weakened; the RELATED=END test was kept and remained green throughout.
---
## Full Gate Results
| Check | Result |
|-------|--------|
| `pnpm -r typecheck` | PASS (API + PWA) |
| `pnpm --filter @familysync/pwa exec vitest run` | PASS — 17 files, 206 tests |
| `pnpm --filter @familysync/api exec vitest run` | PASS — 27 files, 347 tests |
| `pnpm format:check` | PASS |
| `pnpm md:lint` | PASS |
---
## Commits (all tasks)
| Hash | Type | Description |
|------|------|-------------|
| `5d6cb47` | test | RED — CR-01 custom alarm round-trip |
| `f6b47eb` | fix | CR-01 surface reminderIsCustom to preserve custom VALARMs on edit |
| `1caa2e3` | test | RED — CR-02 all-day-aware humanizeLeadMinutes |
| `16ac235` | fix | CR-02 all-day-aware push body (no "Starts in 0 min") |
| `d18aba7` | test | RED — WR-01 positive-duration TRIGGER classifies as custom |
| `bc605e6` | fix | WR-01 positive-duration TRIGGER classifies as custom (no Math.abs) |
| `30b8c96` | test | RED — WR-02 reminderLeadMinutes max(10080) in both Zod schemas |
| `7d94afb` | fix | WR-02 add .max(10080) to reminderLeadMinutes in both Zod schemas |
| `4015913` | test | RED — WR-03 helper text suppressed for timed off-list 10080 |
| `a04c76b` | fix | WR-03 gate helper text on active preset set only |
| `a3aec2d` | style | prettier format EventForm.test.tsx WR-03 additions |
## Self-Check: PASSED
All key files verified to exist; all commits verified in git log.
+1 -1
View File
@@ -103,7 +103,7 @@ const outboxPayloadSchema = z
// 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).nullable().optional(),
reminderLeadMinutes: z.number().int().min(0).max(10080).nullable().optional(),
})
.passthrough();
+9 -2
View File
@@ -181,11 +181,18 @@ export function classifyValarms(rawVevent: string): AlarmClassification {
const firstValue = triggerProp.getFirstValue() as unknown;
if (firstValue instanceof ICAL.Time) return { kind: 'custom' };
// Relative DURATION trigger — extract lead minutes
// Relative DURATION trigger — extract lead minutes.
const dur = firstValue as ICAL.Duration;
if (!dur || typeof dur.toSeconds !== 'function') return { kind: 'custom' };
const leadMinutes = Math.round(Math.abs(dur.toSeconds()) / 60);
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 };
+1 -1
View File
@@ -122,7 +122,7 @@ const eventFieldsSchema = z.object({
// 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).nullable().optional(),
reminderLeadMinutes: z.number().int().min(0).max(10080).nullable().optional(),
});
/** sync-status query params. */
@@ -1018,3 +1018,79 @@ describe('runOutboxDrain — reminderLeadMinutes VALARM wiring (CAL-13/CAL-14, P
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');
});
});
+74
View File
@@ -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);
});
});
+70 -1
View File
@@ -1263,7 +1263,7 @@ const CUSTOM_ALARM_OCCURRENCE: CalendarOccurrence & { reminderIsCustom?: boolean
description: null,
hasRrule: false,
reminderLeadMinutes: null, // custom alarms cannot be reduced to a lead
reminderIsCustom: true, // CR-01 new field: signals absolute/multi alarm
reminderIsCustom: true, // CR-01 new field: signals absolute/multi alarm
};
describe('EventForm — Phase 11 Plan 05 CR-01: custom alarm round-trip', () => {
@@ -1327,3 +1327,72 @@ describe('EventForm — Phase 11 Plan 05 CR-01: custom alarm round-trip', () =>
});
});
});
// ─── Phase 11 Plan 05 WR-03: off-list option + helper text gate on active preset set ──────
// A timed event with reminderLeadMinutes=10080 is off-list for timed events (10080 is
// in ALLDAY_REMINDER_PRESETS but NOT TIMED_REMINDER_PRESETS). Before the fix the helper
// text condition checked BOTH sets: `!TIMED && !ALLDAY` — so 10080 was treated as "in
// presets" because it IS in ALLDAY, and helper text was suppressed.
// The fix: gate on only the active set (`allDay ? ALLDAY_REMINDER_PRESETS : TIMED_REMINDER_PRESETS`).
// WR-03 timed fixture: reminderLeadMinutes=10080, allDay=false
const TIMED_OFFLIST_10080_OCCURRENCE: CalendarOccurrence = {
id: 'offlist-10080-uid::2026-12-15T10:00:00',
uid: 'offlist-10080-uid',
calendarId: 1,
calendarName: 'My Calendar',
ownerUserId: 1,
ownerName: 'Alice',
color: '#4A90D9',
isShared: false,
title: 'Long-lead timed meeting',
start: '2026-12-15T10:00:00-05:00',
end: '2026-12-15T11:00:00-05:00',
allDay: false, // timed — 10080 is off-list
location: null,
description: null,
hasRrule: false,
reminderLeadMinutes: 10080, // 1 week — in ALLDAY presets but NOT TIMED presets
reminderIsCustom: false,
};
describe('EventForm — Phase 11 Plan 05 WR-03: off-list option + helper text gate on active preset set', () => {
beforeEach(() => {
vi.clearAllMocks();
mockEventFormOpen = true;
mockEventFormMode = 'create';
mockEventFormUid = null;
});
it('WR-03: timed event with reminderLeadMinutes=10080 shows synthetic off-list option', () => {
// 10080 is off-list for timed events — synthetic option must appear
renderForm({
mode: 'edit',
uid: 'offlist-10080-uid',
eventOccurrence: TIMED_OFFLIST_10080_OCCURRENCE,
});
const reminderSelect = document.querySelector('#event-reminder') as HTMLSelectElement;
expect(reminderSelect).not.toBeNull();
expect(reminderSelect.value).toBe('10080');
// The synthetic option text comes from humanizeReminderLead(10080) = '7 days before'
// (not one of the standard timed preset labels)
const selectedOption = reminderSelect.options[reminderSelect.selectedIndex];
expect(selectedOption).not.toBeNull();
expect(selectedOption.value).toBe('10080');
});
it('WR-03: timed event with reminderLeadMinutes=10080 shows helper text (not suppressed by allday preset membership)', () => {
// Before the fix: helper text uses `!TIMED && !ALLDAY` — since 10080 IS in ALLDAY,
// the condition is false → helper text hidden. After fix: only active (timed) set used.
renderForm({
mode: 'edit',
uid: 'offlist-10080-uid',
eventOccurrence: TIMED_OFFLIST_10080_OCCURRENCE,
});
// Helper text must be visible for a timed off-list value in edit mode
const helperText = screen.queryByText(/Custom reminder kept/i);
expect(helperText).not.toBeNull();
});
});
+5 -2
View File
@@ -1029,11 +1029,14 @@ export function EventForm() {
)}
</select>
{/* 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__' &&
!TIMED_REMINDER_PRESETS.has(parseInt(reminderValue, 10)) &&
!ALLDAY_REMINDER_PRESETS.has(parseInt(reminderValue, 10)) &&
!(allDay ? ALLDAY_REMINDER_PRESETS : TIMED_REMINDER_PRESETS).has(
parseInt(reminderValue, 10),
) &&
Number.isFinite(parseInt(reminderValue, 10)))) && (
<div
style={{