---
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"
---
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.
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
@.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 `
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.
## 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). |
- `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.
- 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).