Files
2026-06-18 22:21:38 -04:00

188 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 ~413469: fetchAdminMembers / saveCredential / fetchAdminCalendars / setSharedCalendar — exact GET + PUT-with-body patterns; handleAuthResponse at lines ~5158; 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 ~183288: section structure, sectionLabelStyle at ~40, calendarsQuery at ~72, sharedCalMutation at ~86, save-button style ~244285)
- 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>