---
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.
