16 KiB
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 |
|
|
false |
|
|
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 | nullonCreateEventPayload— client.tsreminderLeadMinutes: number | nullonCalendarOccurrence(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>
`, `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>
<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>