190 lines
16 KiB
Markdown
190 lines
16 KiB
Markdown
---
|
|
phase: 11-per-event-reminders
|
|
plan: 04
|
|
type: execute
|
|
wave: 3
|
|
depends_on: [11-03]
|
|
files_modified:
|
|
- apps/pwa/src/api/client.ts
|
|
- apps/pwa/src/components/EventForm.tsx
|
|
autonomous: false
|
|
requirements: [CAL-13, CAL-14]
|
|
must_haves:
|
|
truths:
|
|
- "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"
|
|
artifacts:
|
|
- path: "apps/pwa/src/components/EventForm.tsx"
|
|
provides: "Reminder <select> (allDay-aware swap, edit pre-population, Custom-kept handling)"
|
|
contains: "event-reminder"
|
|
- path: "apps/pwa/src/api/client.ts"
|
|
provides: "reminderLeadMinutes on CreateEventPayload + CalendarOccurrence"
|
|
contains: "reminderLeadMinutes"
|
|
key_links:
|
|
- from: "EventForm reminder select"
|
|
to: "CreateEventPayload.reminderLeadMinutes"
|
|
via: "submit handler maps picker value → number | null | absent"
|
|
pattern: "reminderLeadMinutes"
|
|
- from: "edit-mode load"
|
|
to: "occurrence.reminderLeadMinutes"
|
|
via: "preset/offlist/custom classification on mount"
|
|
pattern: "reminderLeadMinutes"
|
|
---
|
|
|
|
<objective>
|
|
Add the reminder `<select>` 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.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
|
@$HOME/.claude/gsd-core/templates/summary.md
|
|
</execution_context>
|
|
|
|
<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
|
|
</context>
|
|
|
|
<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>
|
|
|
|
<tasks>
|
|
|
|
<task type="execute">
|
|
<name>Task 1: Client types — reminderLeadMinutes on CreateEventPayload + CalendarOccurrence</name>
|
|
<files>apps/pwa/src/api/client.ts</files>
|
|
<read_first>
|
|
- 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)
|
|
</read_first>
|
|
<action>
|
|
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.
|
|
</action>
|
|
<verify>
|
|
<automated>pnpm --filter @familysync/pwa exec tsc --noEmit</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- 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).
|
|
</acceptance_criteria>
|
|
<done>Frontend types carry reminderLeadMinutes with the absent/null/0/positive contract mirroring the server.</done>
|
|
</task>
|
|
|
|
<task type="execute">
|
|
<name>Task 2: Reminder picker in EventForm (swap, default None, reset-on-toggle, edit pre-population, Custom-kept, payload mapping)</name>
|
|
<files>apps/pwa/src/components/EventForm.tsx, apps/pwa/src/components/EventForm.test.tsx</files>
|
|
<read_first>
|
|
- 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)
|
|
</read_first>
|
|
<action>
|
|
Insert a reminder field after the Recurrence picker block (~line 885), styled identically (`<div style={fieldStyle}>`, `<label htmlFor="event-reminder" style={labelStyle}>Reminder</label>`, `<select id="event-reminder" style={{...inputStyle, padding:'0 var(--space-3)', cursor:'pointer'}}>`). 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 `<option>` 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` `<option value="__custom__">Custom (kept)</option>` 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: <number>`; 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.
|
|
</action>
|
|
<verify>
|
|
<automated>pnpm --filter @familysync/pwa exec vitest run src/components/EventForm.test.tsx</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- 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.
|
|
</acceptance_criteria>
|
|
<done>Reminder picker matches the UI-SPEC: swap, default None, reset-on-toggle, edit pre-population, Custom-kept preserve via omitted payload field.</done>
|
|
</task>
|
|
|
|
<task type="checkpoint:human-verify" gate="blocking">
|
|
<name>Task 3: Playwright smoke — picker swap + edit-mode load (Wave 0 gap)</name>
|
|
<files>apps/pwa/tests/ (playwright-cli smoke spec for the reminder picker)</files>
|
|
<read_first>
|
|
- .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
|
|
</read_first>
|
|
<what-built>
|
|
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.
|
|
</what-built>
|
|
<how-to-verify>
|
|
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.
|
|
</how-to-verify>
|
|
<action>
|
|
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.
|
|
</action>
|
|
<verify>
|
|
<automated>cd apps/pwa && playwright-cli run smoke for the reminder picker (swap + edit-load) against the dev stack; capture pass/fail + screenshot</automated>
|
|
<human-check>Confirm the playwright-cli smoke passed (picker swap + edit-mode labels) or report the failing assertion.</human-check>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- 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.
|
|
</acceptance_criteria>
|
|
<resume-signal>Type "approved" once the playwright-cli smoke passes, or describe the failing assertion.</resume-signal>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<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>
|
|
|
|
<verification>
|
|
- `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).
|
|
</verification>
|
|
|
|
<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>
|
|
|
|
<output>
|
|
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.
|
|
</output>
|