Compare commits
10
Commits
d18aba7816
...
156bdf057b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
156bdf057b | ||
|
|
ff06a8479b | ||
|
|
c86020ac21 | ||
|
|
9bc6c7274c | ||
|
|
a3aec2d21d | ||
|
|
a04c76b823 | ||
|
|
401591374a | ||
|
|
7d94afb2d8 | ||
|
|
30b8c9643a | ||
|
|
bc605e6a42 |
@@ -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)_
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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-01–WR-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.
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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={{
|
||||
|
||||
Reference in New Issue
Block a user