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.
@.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
Task 1: Add fetchAdminTimezone + setAdminTimezone to the PWA API client
apps/pwa/src/api/client.ts
- 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)
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.
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
- 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.
Client functions compile and mirror the established admin fetch wrappers.
Task 2: Add the Timezone section (searchable IANA picker + save) to AdminPage
apps/pwa/src/routes/AdminPage.tsx
- 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)
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 `` after the Shared Calendar section (give the preceding section
`marginBottom: 'var(--space-8, 32px)'`), with `
Timezone
` and the
four states (loading / error / data). Picker: a controlled `` +
`` 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).
pnpm --filter @familysync/pwa exec tsc --noEmit && pnpm --filter @familysync/pwa test -- --run && grep -q 'aria-label="Timezone"' apps/pwa/src/routes/AdminPage.tsx
- AdminPage renders a `` with the stored value pre-filled and a datalist-backed searchable input.
- A "Use detected: " 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).
Timezone section renders, saves, persists, and offers the detected zone.
Task 3: playwright-cli round-trip of the admin timezone picker
apps/pwa/src/routes/AdminPage.tsx
A Timezone section in /admin Settings: searchable IANA picker, "use detected zone" affordance, save + persist via the Plan 02 endpoints.
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.
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: " affordance fills the input with the browser zone.
Expected: the value persists across reload; no console errors; the save button disables while pending.
playwright-cli round-trip confirms the timezone saves and persists across reload, and the detected-zone affordance works.
Type "approved" or describe what rendered/persisted incorrectly.
Operator confirms the admin timezone picker saves, persists across reload, and offers the detected zone.
<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).