Files
familysync/.planning/phases/03-event-write-back-pwa-install/03-12-PLAN.md
T

12 KiB

phase, plan, type, wave, depends_on, gap_closure, autonomous, requirements, files_modified, must_haves
phase plan type wave depends_on gap_closure autonomous requirements files_modified must_haves
03-event-write-back-pwa-install 12 tdd 1
true true
CAL-05
CAL-07
PWA-01
PWA-02
apps/pwa/src/components/EventForm.tsx
apps/pwa/src/components/EventForm.test.tsx
apps/pwa/src/store/calendarStore.ts
truths artifacts key_links
Opening the form in edit mode populates Title/Start/End from the cached occurrence even when the form opens before the occurrence is resolved (no blank edit form)
Editing a recurring event preselects its existing recurrence preset instead of resetting to 'none'
The edit form shows the event's original date/time consistently (no UTC-date / local-time mismatch that shifts the day), proven by a test that pins TZ so it cannot pass by coincidence on an EDT runner
Tab and Shift+Tab cycle focus within the open dialog and never reach background controls
The PWA install assets (icon-192/512, apple-touch-icon) exist so Add-to-Home-Screen installs with a real icon (PWA-01/PWA-02)
path provides
apps/pwa/src/components/EventForm.tsx occurrence-driven reset, recurrence derivation, zone-consistent parseDateTime, real focus trap
from to via pattern
EventForm reset effect occurrence from TanStack cache occurrence (or occurrence?.uid) in effect deps occurrence
Fix the PWA edit form so editing actually works and the dialog is accessible. Today the edit form can open blank (the reset effect ignores `occurrence`, which is null if the events query has not resolved yet — WR-03), it hard-resets recurrence to 'none' so editing a recurring event silently drops its series (WR-03), it shows the wrong day/time by mixing a UTC date with local-clock components (WR-05), and its claimed focus trap only focuses once on open (WR-07). This plan closes the user-facing half of the write path and carries the PWA install requirements (assets verified present).

Purpose: edit mode pre-populates correctly and the dialog is keyboard-accessible. Output: an EventForm that round-trips an existing event's fields and traps focus.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/STATE.md @.planning/phases/03-event-write-back-pwa-install/03-REVIEW.md @.planning/phases/03-event-write-back-pwa-install/03-UI-SPEC.md @apps/pwa/src/components/EventForm.tsx @apps/pwa/src/api/client.ts @apps/pwa/src/store/calendarStore.ts

<artifacts_this_phase_produces> No new exported symbols beyond exporting the existing todayIso from calendarStore.ts (see IN-03 below — it is currently a private module function, NOT yet exported). Internal changes to EventForm: reset effect deps gain occurrence, a recurrence-deriving initializer, a zone-consistent parseDateTime, and a real Tab/Shift+Tab focus-cycle handler. </artifacts_this_phase_produces>

Task 1: RED+GREEN — edit-mode population, recurrence derivation, zone-consistent dates (WR-03, WR-05, IN-03) apps/pwa/src/components/EventForm.tsx, apps/pwa/src/components/EventForm.test.tsx, apps/pwa/src/store/calendarStore.ts - apps/pwa/src/components/EventForm.tsx (occurrence IIFE lines 113-124; reset effect deps `[eventFormOpen,eventFormMode,eventFormUid]`; parseDateTime lines 84-101 — note it mixes `d.toISOString().slice(0,10)` (UTC date) with `d.getHours()/getMinutes()` (local time): THIS is the WR-05 bug; getDefaultStartDate/getDefaultEndDate lines 47-53) - apps/pwa/src/api/client.ts (CalendarOccurrence.start/end format note lines 69-73: 'YYYY-MM-DD' for allDay, ISO 8601 with IANA tz for timed) - apps/pwa/src/store/calendarStore.ts (todayIso at lines 121-124 is a PRIVATE module function — it is NOT currently exported; IN-03 requires adding `export` to it before EventForm can import it) - .planning/phases/03-event-write-back-pwa-install/03-REVIEW.md (WR-03, WR-05, IN-03) - RED (WR-03 blank): render EventForm in edit mode where the occurrence becomes available in the ['events'] cache AFTER the form opens; assert the Title input value equals the occurrence title (not empty). Fails today because the reset effect deps exclude `occurrence`. - RED (WR-03 recurrence): edit an occurrence whose recurrence is 'weekly'; assert the Repeat select value is 'weekly', not 'none'. - RED (WR-05 zone — DETERMINISTIC, TZ-pinned so it cannot pass by coincidence): pin the test runner timezone to UTC for this test file. Use the top-of-file `// @vitest-environment jsdom` already in place, and add `process.env.TZ = 'UTC'` in a `beforeAll` (set BEFORE any Date is constructed in the test) — OR, preferred, add `env: { TZ: 'UTC' }` to the pwa vitest config's `test` block so the runner zone is fixed for the whole suite. State which approach you used in a comment. With TZ pinned to UTC, feed a timed occurrence start of `'2026-06-10T23:30:00-04:00'` (i.e. UTC instant `2026-06-11T03:30:00Z`) and assert the rendered Start date and time equal the event's OWN wall-clock as derived by the fixed extraction rule (see ): the test must assert the exact strings the corrected `parseDateTime` produces for that input under TZ=UTC, and document why those values are correct regardless of the developer's machine zone. The point: the assertion is stable on a UTC CI runner AND would fail loudly if `parseDateTime` reverted to the toISOString/getHours mismatch. WR-03: add `occurrence` (or `occurrence?.uid` plus `occurrence?.start`) to the reset effect dependency array so the form re-initializes when the occurrence resolves after open. In the reset effect, derive the initial recurrence from the occurrence instead of always `setRecurrence('none')` — if the CalendarOccurrence carries a recurrence preset use it; if the occurrence shape does not expose one, extending the occurrence/expand contract is OUT OF SCOPE — read it from the cached raw recurrence if present and default to 'none' only when genuinely absent (add a comment citing WR-03 documenting that occurrence edits whose recurrence is not present in the cache default to 'none' in v1). Guard against opening edit mode before the cache is populated: keep fields blank-safe but re-run on arrival.
WR-05 (the owning fix): rewrite `parseDateTime` so date and time are derived in ONE consistent frame.
For a timed ISO with an offset/IANA suffix, build the JS Date, then extract BOTH the date and time from
the SAME accessor family — use local accessors together (`getFullYear/getMonth/getDate/getHours/getMinutes`,
zero-padded) so the date string and the time string describe the same wall clock. NEVER mix
`toISOString().slice(0,10)` (UTC date) with `getHours()` (local time). Because the WR-05 test pins TZ=UTC,
"local" == UTC in the test and the extracted wall clock is deterministic; in production the user's own
zone yields their own wall clock consistently. The all-day `^\d{4}-\d{2}-\d{2}$` branch is unchanged.

IN-03: export the existing `todayIso` from calendarStore.ts (add the `export` keyword to the function at
lines 121-124 — it is currently private), then import it into EventForm and collapse
`getDefaultStartDate`/`getDefaultEndDate` into calls to `todayIso()`; keep the separate '09:00'/'10:00'
default times at the call sites. Do not duplicate the helper — there must be exactly one `todayIso`.

Commit RED then GREEN.
cd apps/pwa && npx vitest run src/components/EventForm.test.tsx - behavior: edit form Title is populated even when occurrence resolves after open. - behavior: editing a recurring event preselects its recurrence preset. - behavior (deterministic): with the runner TZ pinned to UTC, a timed occurrence `'2026-06-10T23:30:00-04:00'` renders the wall-clock date/time the corrected parseDateTime yields under UTC, and the assertion is hard-coded to those exact strings (cannot pass by a coincidentally-EDT runner). - source: the reset effect dependency array in EventForm.tsx includes occurrence (grep for occurrence in the deps line). - source: `grep -c 'export function todayIso' apps/pwa/src/store/calendarStore.ts` returns 1 (todayIso is now exported; IN-03). - source: parseDateTime no longer mixes UTC and local accessors — `grep -c 'toISOString' apps/pwa/src/components/EventForm.tsx` does not appear inside parseDateTime's timed branch (verify by reading the function). - test-command: `cd apps/pwa && npx vitest run src/components/EventForm.test.tsx` passes. Edit mode pre-populates correctly (fields, recurrence, correct zone proven by a TZ-pinned deterministic test); duplicate date helpers collapsed to one exported todayIso. Task 2: RED+GREEN — real focus trap on the dialog (WR-07) + verify PWA install assets (PWA-01/02, IN-04) apps/pwa/src/components/EventForm.tsx, apps/pwa/src/components/EventForm.test.tsx - apps/pwa/src/components/EventForm.tsx (focus-on-open effect lines 274-278; dialog element lines 378-384; Escape handler lines 263-270) - .planning/phases/03-event-write-back-pwa-install/03-REVIEW.md (WR-07, IN-04) - .planning/phases/03-event-write-back-pwa-install/03-UI-SPEC.md (modal/focus interaction contract) - RED: with the dialog open, dispatch a Tab keydown from the last focusable control; assert focus wraps to the first focusable control inside the dialog (not to background). Shift+Tab from the first wraps to the last. Fails today (only one .focus() on open; Tab escapes the modal). WR-07: implement an actual focus trap on the role="dialog" element. On Tab/Shift+Tab keydown while open: query the dialog's focusable elements (`button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])`), and if focus is on the last element and Tab is pressed, move to the first (preventDefault); if on the first and Shift+Tab, move to the last. Keep the existing focus-on-open behavior (Title input). Keep the Escape-to-close handler. Do NOT introduce a new dependency — implement the trap inline (or extract a small local hook). Update the docblock so the "Focus trap" claim is now accurate.
IN-04 / PWA-01 / PWA-02: this gap does not change install code, but the requirement must be verified.
The assets `apps/pwa/public/icon-192.png`, `icon-512.png`, and `apple-touch-icon.png` exist (confirmed
present). Add a lightweight assertion (test or a checked note in the SUMMARY) that these three files
exist so the Add-to-Home-Screen flow installs with a real icon. No code change required if assets present.

Commit RED then GREEN.
cd apps/pwa && npx vitest run src/components/EventForm.test.tsx - behavior: Tab from the last focusable control wraps to the first inside the dialog; Shift+Tab from the first wraps to the last. - behavior: focus never lands on a background control while the dialog is open. - source: `ls apps/pwa/public/icon-192.png apps/pwa/public/icon-512.png apps/pwa/public/apple-touch-icon.png` all exist (PWA-01/PWA-02 install assets). - test-command: `cd apps/pwa && npx vitest run src/components/EventForm.test.tsx` passes. The dialog traps Tab focus as its docblock claims; PWA install icon assets are confirmed present for Gate 2. - `cd apps/pwa && npx vitest run src/components/EventForm.test.tsx` green. - `cd apps/pwa && npm run build` (tsc + vite) succeeds. - Optional: drive the create→edit→delete flow with playwright-cli per CLAUDE.md to confirm end-to-end UX in a desktop browser.

<success_criteria> Edit mode pre-populates fields/recurrence in the correct zone (proven by a TZ-pinned deterministic test), the dialog traps focus, and the PWA install assets are confirmed present. WR-03, WR-05, WR-07, IN-03, IN-04 closed; PWA-01/PWA-02 verified. </success_criteria>

Create `.planning/phases/03-event-write-back-pwa-install/03-12-SUMMARY.md` when done.