---
phase: 18-auto-timezone-detection-and-ability-to-change-timezone
plan: 04
type: execute
wave: 3
depends_on: ["18-02"]
files_modified:
  - apps/pwa/src/api/client.ts
  - apps/pwa/src/routes/AdminPage.tsx
autonomous: false
requirements: [D-02, D-04]
must_haves:
  truths:
    - "An admin sees a Timezone section in /admin Settings showing the current household timezone (D-04)"
    - "The admin can search/pick an IANA zone and save it; the change persists across reload (D-04)"
    - "The picker pre-offers the browser-detected zone (Intl.DateTimeFormat().resolvedOptions().timeZone) so first-run seeding is one tap (D-02)"
    - "The section indicates when the timezone is using the system default (isExplicitlySet=false)"
  artifacts:
    - path: "apps/pwa/src/api/client.ts"
      provides: "fetchAdminTimezone() + setAdminTimezone() + AdminTimezoneResponse"
      exports: ["fetchAdminTimezone", "setAdminTimezone", "AdminTimezoneResponse"]
    - path: "apps/pwa/src/routes/AdminPage.tsx"
      provides: "Timezone section (searchable IANA picker + save) after Shared Calendar section"
  key_links:
    - from: "apps/pwa/src/routes/AdminPage.tsx"
      to: "/api/admin/config/timezone"
      via: "useQuery(fetchAdminTimezone) + useMutation(setAdminTimezone)"
      pattern: "fetchAdminTimezone|setAdminTimezone"
    - from: "apps/pwa/src/api/client.ts"
      to: "PUT /api/admin/config/timezone"
      via: "fetch with JSON body"
      pattern: "PUT.*config/timezone"
---

<objective>
Add the admin-facing Timezone UI: a searchable IANA picker in the existing `/admin` Settings page,
backed by two new client functions that call the Plan 02 endpoints. The picker pre-offers the
browser-detected zone so an admin (or the Phase 12 wizard, later) can seed the household timezone in
one tap.

Purpose: D-04 surfaces the change-timezone control in the role-gated admin Settings; D-02 wires the
browser detection (`Intl.DateTimeFormat().resolvedOptions().timeZone`) as the suggested value. This
is UI + glue against the contract Plan 02 already established and tested.
Output: `fetchAdminTimezone`/`setAdminTimezone` in client.ts + a Timezone section in AdminPage.tsx,
verified end-to-end with playwright-cli.
</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/18-auto-timezone-detection-and-ability-to-change-timezone/18-CONTEXT.md
@.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-PATTERNS.md
@apps/pwa/src/api/client.ts
@apps/pwa/src/routes/AdminPage.tsx
</context>

<tasks>

<task type="auto">
  <name>Task 1: Add fetchAdminTimezone + setAdminTimezone to the PWA API client</name>
  <files>apps/pwa/src/api/client.ts</files>
  <read_first>
    - apps/pwa/src/api/client.ts (lines ~413–469: fetchAdminMembers / saveCredential / fetchAdminCalendars / setSharedCalendar — exact GET + PUT-with-body patterns; handleAuthResponse at lines ~51–58; the `// ── /api/admin/* ──` section header at line 355)
    - .planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-PATTERNS.md (client.ts section: AdminTimezoneResponse interface + both function bodies)
  </read_first>
  <action>
    In the `// ── /api/admin/* ──` section of client.ts, export `interface AdminTimezoneResponse { timezone: string; isExplicitlySet: boolean }`.
    Add `fetchAdminTimezone()` — GET `/api/admin/config/timezone` with `credentials: 'include', redirect: 'manual'`,
    call `handleAuthResponse(res, 'GET /api/admin/config/timezone')`, return `res.json()` as `AdminTimezoneResponse`.
    Add `setAdminTimezone(timezone: string)` — PUT `/api/admin/config/timezone` with `Content-Type: application/json`,
    `credentials: 'include', redirect: 'manual'`, body `JSON.stringify({ timezone })`, then
    `handleAuthResponse(res, 'PUT /api/admin/config/timezone')`. Match the existing wrappers' style exactly.
  </action>
  <verify>
    <automated>pnpm --filter @familysync/pwa exec tsc --noEmit && grep -q "fetchAdminTimezone" apps/pwa/src/api/client.ts && grep -q "PUT" apps/pwa/src/api/client.ts</automated>
  </verify>
  <acceptance_criteria>
    - client.ts exports `fetchAdminTimezone`, `setAdminTimezone`, and `AdminTimezoneResponse`.
    - Both wrappers call `handleAuthResponse` and use `credentials: 'include', redirect: 'manual'` (same auth handling as sibling admin calls).
    - `pnpm --filter @familysync/pwa exec tsc --noEmit` passes.
  </acceptance_criteria>
  <done>Client functions compile and mirror the established admin fetch wrappers.</done>
</task>

<task type="auto">
  <name>Task 2: Add the Timezone section (searchable IANA picker + save) to AdminPage</name>
  <files>apps/pwa/src/routes/AdminPage.tsx</files>
  <read_first>
    - apps/pwa/src/routes/AdminPage.tsx (Shared Calendar section lines ~183–288: section structure, sectionLabelStyle at ~40, calendarsQuery at ~72, sharedCalMutation at ~86, save-button style ~244–285)
    - apps/pwa/src/api/client.ts (the fetchAdminTimezone / setAdminTimezone / AdminTimezoneResponse added in Task 1)
    - .planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-PATTERNS.md (AdminPage.tsx section: query/mutation/section template, datalist picker UX, import additions)
    - CLAUDE.md (Browser-based verification convention — use playwright-cli, NOT @playwright/test, for the manual check)
  </read_first>
  <action>
    Import `fetchAdminTimezone, setAdminTimezone, type AdminTimezoneResponse` from `../api/client.js`.
    Add `timezoneQuery = useQuery({ queryKey: ['admin','timezone'], queryFn: fetchAdminTimezone, retry: false, staleTime: 60*1000 })`
    and `timezoneMutation = useMutation({ mutationFn: (tz: string) => setAdminTimezone(tz), onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['admin','timezone'] }) })`.
    Add a `<section aria-label="Timezone">` after the Shared Calendar section (give the preceding section
    `marginBottom: 'var(--space-8, 32px)'`), with `<div style={sectionLabelStyle}>Timezone</div>` and the
    four states (loading / error / data). Picker: a controlled `<input type="text" list="iana-zones">` +
    `<datalist id="iana-zones">` populated from `Intl.supportedValuesOf('timeZone')` (guard for absence →
    empty list). Initialize the input from `timezoneQuery.data?.timezone`. Compute
    `const detectedTz = Intl.DateTimeFormat().resolvedOptions().timeZone` and render a small
    "Use detected: {detectedTz}" affordance that sets the input to detectedTz (D-02 one-tap seed). When
    `timezoneQuery.data?.isExplicitlySet === false`, show a subtle "using system default" note. Save button
    (reuse the existing save-button style; disabled while pending or when the input equals the stored value)
    calls `timezoneMutation.mutate(inputValue)`; label `timezoneMutation.isPending ? 'Saving…' : 'Save'`.
    Do NOT touch any other section or the display/timed-event path (D-07).
  </action>
  <verify>
    <automated>pnpm --filter @familysync/pwa exec tsc --noEmit && pnpm --filter @familysync/pwa test -- --run && grep -q 'aria-label="Timezone"' apps/pwa/src/routes/AdminPage.tsx</automated>
  </verify>
  <acceptance_criteria>
    - AdminPage renders a `<section aria-label="Timezone">` with the stored value pre-filled and a datalist-backed searchable input.
    - A "Use detected: <zone>" affordance sets the input to `Intl.DateTimeFormat().resolvedOptions().timeZone` (D-02).
    - Save calls setAdminTimezone and invalidates the ['admin','timezone'] query on success; the section shows the "system default" note when isExplicitlySet is false.
    - `pnpm --filter @familysync/pwa exec tsc --noEmit` and `pnpm --filter @familysync/pwa test -- --run` pass.
    - No change to eventDateTime.ts / hydrateEvents.ts / any other AdminPage section (D-07).
  </acceptance_criteria>
  <done>Timezone section renders, saves, persists, and offers the detected zone.</done>
</task>

<task type="checkpoint:human-verify" gate="blocking">
  <name>Task 3: playwright-cli round-trip of the admin timezone picker</name>
  <files>apps/pwa/src/routes/AdminPage.tsx</files>
  <what-built>A Timezone section in /admin Settings: searchable IANA picker, "use detected zone" affordance, save + persist via the Plan 02 endpoints.</what-built>
  <action>
    Drive the verification with the global `playwright-cli` binary (NOT @playwright/test) against the
    host-side dev stack with DEV_AUTH_BYPASS (the dev-bypass user must be admin — see the MEMORY
    dev-bypass note; unlock admin if the section is not visible). This is an automation-first check the
    executor runs, then presents the result to the operator for sign-off.
  </action>
  <how-to-verify>
    1. Open the PWA, navigate to /admin Settings.
    2. Confirm a "Timezone" section shows the current household timezone (or "system default").
    3. Type a city (e.g. "Chicago"), pick "America/Chicago" from the datalist, click Save.
    4. Reload /admin and confirm the section now shows "America/Chicago" with no "system default" note.
    5. Confirm the "Use detected: <zone>" affordance fills the input with the browser zone.
    Expected: the value persists across reload; no console errors; the save button disables while pending.
  </how-to-verify>
  <verify>
    <human-check>playwright-cli round-trip confirms the timezone saves and persists across reload, and the detected-zone affordance works.</human-check>
  </verify>
  <resume-signal>Type "approved" or describe what rendered/persisted incorrectly.</resume-signal>
  <done>Operator confirms the admin timezone picker saves, persists across reload, and offers the detected zone.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| browser PWA → /api/admin/config/timezone | the UI is a convenience; the server requireAdmin + IANA validation (Plan 02) is the real boundary |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-18-11 | Elevation of Privilege | client renders admin UI based on isAdmin flag | accept | Client `isAdmin` is UX-only (Phase 10 D-03); a non-admin who forges the request still hits server-side requireAdmin → 403 (Plan 02 T-18-03). The UI gate is not a security control and is not relied upon as one. |
| T-18-12 | Tampering | user types a non-IANA string into the free-text input | mitigate | The datalist offers only valid zones; a manually-typed invalid string is rejected by the server's Zod `isValidIanaTimezone` refine (Plan 02) → 400 surfaced to the user. Client may additionally pre-check before enabling Save, but the server is authoritative. |
| T-18-13 | Information Disclosure | XSS via rendered timezone value | mitigate | The value is rendered as plain-text JSX children (no dangerouslySetInnerHTML), consistent with the existing AdminPage convention. IANA strings are constrained anyway. |
| T-18-SC | Tampering | npm/pip/cargo installs | mitigate | No new packages this plan (RESEARCH Package Legitimacy Audit: zero installs; picker uses built-in Intl). |
</threat_model>

<verification>
- `pnpm --filter @familysync/pwa exec tsc --noEmit` green.
- `pnpm --filter @familysync/pwa test -- --run` green.
- playwright-cli round-trip (Task 3) confirms save + persist + detected-zone affordance.
</verification>

<success_criteria>
- Admin Timezone section reads/writes the household timezone and persists across reload (D-04).
- Browser-detected zone offered for one-tap seeding (D-02).
- No regression to other sections or the display/timed-write path (D-07).
</success_criteria>

<output>
Create `.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-04-SUMMARY.md` when done.
</output>
