Files
familysync/.planning/phases/11-per-event-reminders/11-04-PLAN.md
T

16 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
11-per-event-reminders 04 execute 3
11-03
apps/pwa/src/api/client.ts
apps/pwa/src/components/EventForm.tsx
false
CAL-13
CAL-14
truths artifacts key_links
When creating/editing a timed event the user picks a reminder lead from the timed preset list (default None)
When the event is all-day the picker swaps to day-granularity presets (None / Same day / 1d / 2d / 1wk), default None
Toggling all-day resets the picker to None (no carry-over between preset sets)
Edit mode pre-populates the picker from the occurrence: none→None, preset→matching option, off-list single→synthetic option, absolute/multi→read-only Custom (kept)
Leaving a Custom (kept) selection on save preserves the original VALARM (absent reminderLeadMinutes on the payload)
Selecting None sends explicit null; selecting a preset sends the integer
path provides contains
apps/pwa/src/components/EventForm.tsx Reminder <select> (allDay-aware swap, edit pre-population, Custom-kept handling) event-reminder
path provides contains
apps/pwa/src/api/client.ts reminderLeadMinutes on CreateEventPayload + CalendarOccurrence reminderLeadMinutes
from to via pattern
EventForm reminder select CreateEventPayload.reminderLeadMinutes submit handler maps picker value → number | null | absent reminderLeadMinutes
from to via pattern
edit-mode load occurrence.reminderLeadMinutes preset/offlist/custom classification on mount reminderLeadMinutes
Add the reminder `` to `EventForm.tsx` per the UI-SPEC: allDay-aware preset swap (D-02/D-03), default None (D-01), reset-on-allDay-toggle, and edit-mode pre-population (none / preset / synthetic off-list / read-only "Custom (kept)") driven by `occurrence.reminderLeadMinutes`. Extend `CreateEventPayload` and `CalendarOccurrence` in `client.ts` with `reminderLeadMinutes`, and map the picker value to the payload (None→null, preset→integer, Custom-kept/unchanged→omit field for server-side preserve, D-08). Verify with a Playwright smoke (Wave 0 gap).

Purpose: Deliver the user-facing reminder choice (CAL-13) and the preserve-on-no-change behavior at the UI layer (CAL-14). Consumes the occurrence shape from Plan 11-03. Output: Reminder picker + payload mapping; extended client types; Playwright smoke spec/run.

<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/11-per-event-reminders/11-UI-SPEC.md @.planning/phases/11-per-event-reminders/11-PATTERNS.md @.planning/phases/11-per-event-reminders/11-VALIDATION.md @.planning/phases/11-per-event-reminders/11-03-SUMMARY.md @apps/pwa/src/components/EventForm.tsx @apps/pwa/src/api/client.ts

<artifacts_this_phase_produces> This plan creates (exclude from drift checks — NEW):

  • Reminder <select id="event-reminder"> block + its local state + onChange handler — EventForm.tsx
  • allDay-toggle reset of the reminder state — EventForm.tsx
  • edit-mode classification → picker value (synthetic off-list option, "Custom (kept)" disabled option, helper text) — EventForm.tsx
  • reminderLeadMinutes?: number | null on CreateEventPayload — client.ts
  • reminderLeadMinutes: number | null on CalendarOccurrence (atomic mirror of expand.ts) — client.ts
  • Playwright smoke spec for the picker swap + edit-mode load — apps/pwa (Wave 0 gap) </artifacts_this_phase_produces>
Task 1: Client types — reminderLeadMinutes on CreateEventPayload + CalendarOccurrence apps/pwa/src/api/client.ts - apps/pwa/src/api/client.ts lines 108-134 (CalendarOccurrence, hasRrule mirror note) and 175-198 (CreateEventPayload) - apps/api/src/broker/expand.ts CalendarOccurrence (Plan 11-03: the field this mirrors) - 11-PATTERNS.md § client.ts (exact field additions + the atomic-mirror comment style) Add `reminderLeadMinutes: number | null` to `CalendarOccurrence` (after `hasRrule`) with a comment noting it mirrors expand.ts (atomic mirror, NULL=no reminder / 0=same-day all-day / positive=lead). Add `reminderLeadMinutes?: number | null` to `CreateEventPayload` (after `description`) documenting the four states: absent/undefined=no-change (edit omits → server preserves, D-08), null=explicit None (clear), 0=same-day all-day, positive=lead. Do not change createEvent/editEvent call signatures — they already spread CreateEventPayload. pnpm --filter @familysync/pwa exec tsc --noEmit - client.ts CalendarOccurrence has a required `reminderLeadMinutes: number | null`; CreateEventPayload has an optional `reminderLeadMinutes?: number | null`. - `pnpm --filter @familysync/pwa exec tsc --noEmit` exits 0 (any occurrence consumer that destructures the type still compiles, or is updated). Frontend types carry reminderLeadMinutes with the absent/null/0/positive contract mirroring the server. Task 2: Reminder picker in EventForm (swap, default None, reset-on-toggle, edit pre-population, Custom-kept, payload mapping) apps/pwa/src/components/EventForm.tsx, apps/pwa/src/components/EventForm.test.tsx - apps/pwa/src/components/EventForm.tsx lines 848-885 (Recurrence picker — the exact structural template) and the existing allDay state + allDay onChange handler + the form submit/payload-assembly site - 11-UI-SPEC.md (Component Inventory: option tables, Copywriting Contract, Interaction Contract state machine, helper text style/copy, accessibility) - 11-PATTERNS.md § EventForm.tsx (allDay-conditional option swap, reset pattern, differences from Recurrence picker) Insert a reminder field after the Recurrence picker block (~line 885), styled identically (`
`, `Reminder`, `
`). The select is NOT disabled in edit mode (reminders are editable, unlike Repeat). Render options conditionally on the existing `allDay` state: timed presets (None=`__none__`, 5/10/15/30/60/120/1440/2880 with the exact labels in the Copywriting Contract) when `!allDay`; all-day presets (None, 0=`Same day (9 AM)`, 1440=`1 day before (9 AM)`, 2880=`2 days before (9 AM)`, 10080=`1 week before (9 AM)`) when `allDay`. Default selected value `__none__` (D-01). Add a `reminderValue` state (string). In the existing allDay onChange handler, also `setReminderValue('__none__')` so toggling all-day resets the picker (D-03 — no carry-over). Edit-mode pre-population on mount/when the occurrence loads, from `occurrence.reminderLeadMinutes`: null → `__none__`; a value matching a preset for the current allDay set → that option; a positive value NOT in the preset set → append a synthetic `` whose label is humanized ("N min before" / "N hours before" using the UI-SPEC thresholds) and select it (off-list single, D-07); for the absolute/multi "Custom (kept)" case the occurrence cannot express a single lead → represent it with a special sentinel value `__custom__` rendered as a read-only `disabled` `Custom (kept)` selected by default, with the select itself still enabled. Show the helper text `Custom reminder kept — select a preset to replace it.` (style: fontSize var(--text-label-size), color var(--color-text-secondary), marginTop var(--space-1)) only in edit mode when the value is `__custom__` (or the synthetic off-list option). Selecting any real preset removes the synthetic/custom option from selection. Payload mapping in the submit handler: `__none__` → `reminderLeadMinutes: null` (explicit clear); a numeric preset/synthetic value → `reminderLeadMinutes: `; if the selection is still `__custom__` (unchanged) → OMIT `reminderLeadMinutes` from the payload entirely so the server preserves the original VALARM (D-08 — absent = no-change). Render all option labels as plain-text JSX children (XSS guard T-03-15). Determining "Custom (kept)" vs off-list synthetic from the occurrence: since the occurrence only carries a number-or-null, the frontend treats null as None and any number as preset-or-offlist; the `__custom__` state is reached only when the occurrence signals an unresolvable alarm — if the occurrence reminderLeadMinutes is null but the event is known to carry a kept custom alarm, follow the UI-SPEC: with only number|null available, map null→None and rely on the server-side preserve (absent payload) — document this limitation in the SUMMARY. pnpm --filter @familysync/pwa exec vitest run src/components/EventForm.test.tsx - A component test asserts: in create mode with allDay=false the picker shows the timed preset labels and defaults to None; toggling allDay swaps to the day-granularity labels and resets selection to None. - test asserts edit mode with occurrence.reminderLeadMinutes=30 (timed) selects "30 minutes before"; =1440 all-day selects "1 day before (9 AM)"; =45 (off-list) shows a synthetic "45 min before" option selected. - test asserts the submitted payload: None→reminderLeadMinutes:null; a preset→the integer; an unchanged Custom-kept→field omitted (Object.prototype.hasOwnProperty is false). - pwa vitest + `tsc --noEmit` exit 0. Reminder picker matches the UI-SPEC: swap, default None, reset-on-toggle, edit pre-population, Custom-kept preserve via omitted payload field. Task 3: Playwright smoke — picker swap + edit-mode load (Wave 0 gap) apps/pwa/tests/ (playwright-cli smoke spec for the reminder picker) - .claude/skills/playwright-cli/SKILL.md (browser-driving usage; project convention: prefer playwright-cli over manual human verification for desktop/Chromium checks) - 11-VALIDATION.md § Wave 0 Requirements (Playwright smoke) and § Manual-Only Verifications (device-only items NOT covered here) - MEMORY: dev stack bring-up (dev compose exposes 3306, API `dev` needs separate build, DEV_AUTH_BYPASS=true → Dev User id 1); Dev user 1 has no calendars — verify form-rendering/picker behavior, not live event create A Playwright smoke (driven via the playwright-cli skill, Chromium/desktop) that: opens the New Event form, asserts the Reminder select defaults to None and shows timed presets; toggles All-day and asserts the option set swaps to the day-granularity presets and selection resets to None. Plus an edit-mode load assertion against a route-mocked occurrence carrying reminderLeadMinutes (e.g. 30 → "30 minutes before"; 1440 all-day → "1 day before (9 AM)"), since dev user 1 has no real calendars. Automate first: bring up the host-side dev stack per the documented command (DEV_AUTH_BYPASS=true, DB_HOST=localhost), build the API dev bundle, then run the smoke headlessly. This is autonomous via playwright-cli — the human only confirms the run results if the harness cannot self-assert. 1. Start the dev stack (two terminals or background): API dev (with DEV_AUTH_BYPASS=true DB_HOST=localhost, env-sourced) + PWA dev server. 2. Drive Chromium via playwright-cli to the calendar, open New Event, assert: Reminder select present (id=event-reminder), value None, timed option labels visible. 3. Toggle All-day; assert the swapped day-granularity labels are present and the selection is None. 4. With a page.route mock returning an occurrence whose reminderLeadMinutes=30 (timed) and a second whose reminderLeadMinutes=1440 (all-day), open edit and assert the picker shows the matching labels. 5. Confirm no console errors and the screenshot shows the picker rendered per the UI-SPEC. Automate the verification end-to-end via the playwright-cli skill before pausing for human confirmation: (1) bring up the host-side dev stack per docs/deployment.md (env-sourced, DEV_AUTH_BYPASS=true DB_HOST=localhost; rebuild the API dev bundle since dist can be stale) plus the PWA dev server; (2) drive Chromium with playwright-cli to open the New Event form and assert the Reminder select (id=event-reminder) defaults to None and shows the timed preset labels; (3) toggle All-day and assert the option set swaps to the day-granularity labels and the selection resets to None; (4) use page.route to mock occurrences with reminderLeadMinutes=30 (timed) and =1440 (all-day) and assert edit mode loads "30 minutes before" and "1 day before (9 AM)" respectively (dev user 1 has no real calendars, so mock rather than create live); (5) capture a screenshot and the pass/fail of each assertion. Only after the smoke runs do you surface the result for human sign-off. Do NOT ask the human to perform steps the playwright-cli skill can drive. cd apps/pwa && playwright-cli run smoke for the reminder picker (swap + edit-load) against the dev stack; capture pass/fail + screenshot Confirm the playwright-cli smoke passed (picker swap + edit-mode labels) or report the failing assertion. - playwright-cli smoke asserts the timed→all-day option swap and reset-to-None (CAL-13 browser row in 11-VALIDATION.md Per-Task Verification Map). - playwright-cli smoke asserts edit-mode picker loads the correct label for a route-mocked occurrence (30→"30 minutes before", 1440 all-day→"1 day before (9 AM)"). - run completes with zero console errors; screenshot attached to the SUMMARY. Type "approved" once the playwright-cli smoke passes, or describe the failing assertion.

<threat_model>

Trust Boundaries

Boundary Description
EventForm select value → payload User-chosen value mapped to a bounded integer / null / omitted; re-validated server-side (Plan 03 eventFieldsSchema)

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-11-09 Tampering reminder picker → payload mitigate Client emits only null / a preset integer / omitted field; the server is the real boundary (eventFieldsSchema validates z.number().int().min(0).nullable().optional()); client trust is not relied upon
T-11-10 XSS option labels + helper text mitigate All labels/helper text rendered as plain-text JSX children, no dangerouslySetInnerHTML (T-03-15 precedent)
T-11-SC Tampering npm installs accept No new frontend npm dependencies (UI-SPEC: "Phase 11 adds no new npm dependencies on the frontend")

No new security surface on the frontend: the picker is a native <select> emitting bounded values; validation authority remains server-side. </threat_model>

- `pnpm --filter @familysync/pwa exec vitest run` green; `pnpm --filter @familysync/pwa exec tsc --noEmit` clean. - Playwright smoke (picker swap + edit-mode load) passes via playwright-cli. - Full CI fast-checks parity locally before declaring done: lint + typecheck + test + format:check + md:lint (MEMORY: CI fast-checks runs the whole gate, not just lint/typecheck/test).

<success_criteria>

  • Reminder picker present and behaves per UI-SPEC (swap, default None, reset-on-toggle, edit pre-population, Custom-kept preserve).
  • Payload mapping yields null / integer / omitted correctly.
  • Playwright smoke green; no new frontend dependencies. </success_criteria>
Create `.planning/phases/11-per-event-reminders/11-04-SUMMARY.md` when done. Record the picker value→payload mapping, the edit-mode classification handling, the Custom-kept limitation note, and the playwright-cli smoke result + screenshot path.