---
phase: 03-event-write-back-pwa-install
plan: 12
type: tdd
wave: 1
depends_on: []
gap_closure: true
autonomous: true
requirements: [CAL-05, CAL-07, PWA-01, PWA-02]
files_modified:
  - apps/pwa/src/components/EventForm.tsx
  - apps/pwa/src/components/EventForm.test.tsx
  - apps/pwa/src/store/calendarStore.ts
must_haves:
  truths:
    - '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)'
  artifacts:
    - path: apps/pwa/src/components/EventForm.tsx
      provides: 'occurrence-driven reset, recurrence derivation, zone-consistent parseDateTime, real focus trap'
  key_links:
    - from: 'EventForm reset effect'
      to: 'occurrence from TanStack cache'
      via: 'occurrence (or occurrence?.uid) in effect deps'
      pattern: 'occurrence'
---

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

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

<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
</context>

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

<tasks>

<task type="tdd" tdd="true">
  <name>Task 1: RED+GREEN — edit-mode population, recurrence derivation, zone-consistent dates (WR-03, WR-05, IN-03)</name>
  <files>apps/pwa/src/components/EventForm.tsx, apps/pwa/src/components/EventForm.test.tsx, apps/pwa/src/store/calendarStore.ts</files>
  <read_first>
    - 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)
  </read_first>
  <behavior>
    - 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 <action>): 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.
  </behavior>
  <action>
    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.

  </action>
  <verify>
    <automated>cd apps/pwa && npx vitest run src/components/EventForm.test.tsx</automated>
  </verify>
  <acceptance_criteria>
    - 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.
  </acceptance_criteria>
  <done>Edit mode pre-populates correctly (fields, recurrence, correct zone proven by a TZ-pinned deterministic test); duplicate date helpers collapsed to one exported todayIso.</done>
</task>

<task type="tdd" tdd="true">
  <name>Task 2: RED+GREEN — real focus trap on the dialog (WR-07) + verify PWA install assets (PWA-01/02, IN-04)</name>
  <files>apps/pwa/src/components/EventForm.tsx, apps/pwa/src/components/EventForm.test.tsx</files>
  <read_first>
    - 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)
  </read_first>
  <behavior>
    - 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).
  </behavior>
  <action>
    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.

  </action>
  <verify>
    <automated>cd apps/pwa && npx vitest run src/components/EventForm.test.tsx</automated>
  </verify>
  <acceptance_criteria>
    - 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.
  </acceptance_criteria>
  <done>The dialog traps Tab focus as its docblock claims; PWA install icon assets are confirmed present for Gate 2.</done>
</task>

</tasks>

<verification>
- `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.
</verification>

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

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