diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index 0284a6b..8304cd5 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -430,6 +430,7 @@ At ≤767px (`window.matchMedia('(max-width: 767px)')` in `apps/pwa/src/App.tsx`
| 15. Doc-Only CI Skip + MD Lint | v1.1 | 3/3 | Complete | 2026-06-12 |
| 16. CI Dep Audit, Sec & Img Hyg | v1.1 | 6/6 | Complete | 2026-06-13 |
| 17. UI Optimization & Polish | v1.1 | 0/? | Not started | - |
+| 18. Auto Timezone Detection | v1.1 | 4/4 | Complete | 2026-06-14 |
## Backlog
@@ -648,14 +649,26 @@ Plans:
### Phase 18: Auto timezone detection and ability to change timezone
-**Goal:** [To be planned]
-**Requirements**: TBD
-**Depends on:** Phase 17
-**Plans:** 0 plans
+**Goal:** Make the household timezone an explicit, stored, user-changeable setting — auto-detected from the browser at first run, changeable from the role-gated /admin Settings — and route the server-side all-day "9 AM local" reminder computation through it (replacing the implicit `process.env.TZ` fallback), without touching the already-correct browser-local display/timed-write path.
+**Requirements**: TBD (decision contract D-01..D-07 from 18-CONTEXT.md)
+**Depends on:** Phase 10 (admin role + `/admin` Settings + `app_config`); Phase 11 (all-day reminder computation this rewires). Independent of Phase 17. Phase 12 (setup wizard) not required — seeding is self-contained.
+**Plans:** 4/4 plans complete
Plans:
+**Wave 1**
-- [ ] TBD (run /gsd-plan-phase 18 to break down)
+- [x] 18-01-PLAN.md — TDD: getHouseholdTimezone(db) accessor + isValidIanaTimezone (D-05/D-06)
+
+**Wave 2** *(blocked on Wave 1 completion)*
+
+- [x] 18-02-PLAN.md — TDD: admin GET/PUT/seed timezone endpoints on adminRouter, requireAdmin + IANA validation + no-overwrite seed (D-01/D-02/D-03/D-04)
+- [x] 18-03-PLAN.md — TDD: route all-day reminder TZ at reminderScheduler:247 + outboxWorker:501,607 through the accessor (D-05/D-06/D-07)
+
+**Wave 3** *(blocked on Wave 2 completion)*
+
+- [x] 18-04-PLAN.md — PWA Timezone section in /admin Settings (searchable IANA picker + detected-zone seed) + client fns (D-02/D-04)
+
+**UI hint**: yes
### Phase 19: Local Auth (No-OIDC Mode)
diff --git a/.planning/STATE.md b/.planning/STATE.md
index 6bf1122..61aa991 100644
--- a/.planning/STATE.md
+++ b/.planning/STATE.md
@@ -2,15 +2,15 @@
gsd_state_version: 1.0
milestone: v1.1
milestone_name: Operability & Polish
-status: executing
-stopped_at: Phase 12 UI-SPEC approved
-last_updated: "2026-06-15T15:49:47.164Z"
-last_activity: 2026-06-15 -- Phase 12 planning complete
+status: "Phase 18 shipped — PR #21"
+stopped_at: Phase 18 Plan 03 complete — broker rewire done; plan 4 of 4 is next
+last_updated: "2026-06-15T13:15:22.076Z"
+last_activity: 2026-06-15
progress:
total_phases: 23
completed_phases: 9
- total_plans: 36
- completed_plans: 32
+ total_plans: 37
+ completed_plans: 36
percent: 39
---
@@ -21,14 +21,14 @@ progress:
See: .planning/PROJECT.md (updated 2026-06-10)
**Core value:** One color-coded family calendar (shared + personal) and shared lists from a single low-friction PWA — cross-ecosystem, no app store
-**Current focus:** Phase 11 — per-event-reminders
+**Current focus:** Phase 18 — auto-timezone-detection-and-ability-to-change-timezone
## Current Position
-Phase: 13
-Plan: Not started
-Status: Ready to execute
-Last activity: 2026-06-15 -- Phase 12 planning complete
+Phase: 18 — COMPLETE
+Plan: 4 of 4
+Status: Phase 18 shipped — PR #21
+Last activity: 2026-06-15
### ✅ Resolved Checkpoint — Phase 15 Plan 15-03 Task 2 (human-action)
@@ -107,6 +107,10 @@ _Updated after each plan completion_
| Phase 10-admin-role-settings P03 | 720 | 3 tasks | 6 files |
| Phase 10-admin-role-settings P04 | 1315 | 3 tasks | 8 files |
| Phase 11-per-event-reminders P11-04 | 60 | 3 tasks | 4 files |
+| Phase 18-auto-timezone-detection-and-ability-to-change-timezone P01 | 2 | 2 tasks | 2 files |
+| Phase 18 P02 | 3 | 2 tasks | 2 files |
+| Phase 18 P03 | 28 | 2 tasks | 4 files |
+| Phase 18 P04 | 15 | 3 tasks | 3 files |
## Accumulated Context
@@ -184,6 +188,7 @@ Recent decisions affecting current work:
- [Phase ?]: D-CLIENT-TYPES: reminderLeadMinutes required on CalendarOccurrence, optional on CreateEventPayload (absent=no-change D-08)
- [Phase ?]: D-PAYLOAD-ABSENT: __custom__ unchanged → field omitted from payload; server hasOwnProperty check preserves original VALARM (D-08)
- [Phase ?]: D-NULL-FALLBACK: occurrence.reminderLeadMinutes===null mapped to None; occurrence cannot distinguish absolute/multi-VALARM from no-reminder; rely on server-side preserve (absent payload)
+- [Phase ?]: D-05/18-03: three all-day broker sites now route through getHouseholdTimezone(db)
### Roadmap Evolution
@@ -195,7 +200,6 @@ Recent decisions affecting current work:
- **Phase 16 added (2026-06-12, /gsd-phase):** CI Dependency Audit, Security Checks & Image Hygiene — extend the Phase 8 Gitea CI workflow with outdated-dependency reporting (`pnpm outdated`), a vulnerability audit (`pnpm audit` at a chosen severity), and a baseline of additional security checks (secret scan / image scan). User requested a 16 integer phase (not a decimal insert) — they've been running independent/CI phases ahead of the admin chain. **Depends on Phase 8; independent of the admin chain (10–12).** Scope still needs definition — run /gsd-discuss-phase 16. Milestone window now Phases 7–16.
- **Backlog 999.17 folded into Phase 16 + removed (2026-06-12, /gsd-phase):** the dev/prod image-boundary item (confine `DEV_AUTH_BYPASS` to dev via build/boot-time enforcement; ensure no `.env`/secrets/encryption keys/`DEV_USER` seed/family data ship in published images; CI assertion in the publish job) was pulled into Phase 16 — shared CI surface and overlapping secret/image scanning made a separate phase redundant. The 999.17 backlog entry + its phase dir were **deleted** (not retained-for-history) since the scope now lives in an active phase; this also clears the recycled-number collision with Phase 15's historical "promoted from 999.17" provenance (the markdown-lint item that became Phase 15 had reused 999.17 first).
- **Phase 18 added (2026-06-13, /gsd-phase):** Auto timezone detection and ability to change timezone — let the app auto-detect the household timezone and allow changing it. User invoked `/gsd-phase --insert 18` but Phase 18 didn't exist (17 was the last integer phase), so after confirmation it was added as an integer phase at the end of the milestone, not a decimal insert. Motivated by the Phase 11 all-day-reminder dependency on a correct server `TZ` (all-day reminders fire at 9 AM local, computed from `process.env.TZ`). Scope still needs definition — run /gsd-discuss-phase 18. Milestone window now Phases 7–18.
-- **Phase 19 added (2026-06-15, /gsd-phase):** Local Auth (No-OIDC Mode) — run FamilySync entirely on local DB users with no OIDC, optionally wiring OIDC in later. Deferred from the Phase 12 discuss-phase (the operator runs it this way and wants no-OIDC as a first-class mode). **Depends on Phase 12** — builds on the pre-OIDC local-user foundation introduced there (nullable `oidc_iss`/`oidc_sub` + claimed marker + first-login-claims merge). Scope still needs definition — run /gsd-discuss-phase 19. Milestone window now Phases 7–19.
### Pending Todos
@@ -253,9 +257,9 @@ Recent decisions affecting current work:
## Session Continuity
-Last session: 2026-06-15T15:15:40.328Z
-Stopped at: Phase 12 UI-SPEC approved
-Resume file: .planning/phases/12-initial-setup-wizard/12-UI-SPEC.md
+Last session: 2026-06-15T02:46:09.800Z
+Stopped at: Phase 18 Plan 03 complete — broker rewire done; plan 4 of 4 is next
+Resume file: None
## Operator Next Steps
diff --git a/.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-01-PLAN.md b/.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-01-PLAN.md
new file mode 100644
index 0000000..dd92628
--- /dev/null
+++ b/.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-01-PLAN.md
@@ -0,0 +1,158 @@
+---
+phase: 18-auto-timezone-detection-and-ability-to-change-timezone
+plan: 01
+type: tdd
+wave: 1
+depends_on: []
+files_modified:
+ - apps/api/src/lib/householdTimezone.ts
+ - apps/api/tests/lib/householdTimezone.test.ts
+autonomous: true
+requirements: [D-05, D-06]
+must_haves:
+ truths:
+ - "getHouseholdTimezone(db) returns the stored household_timezone value when the app_config row is present (D-05)"
+ - "getHouseholdTimezone(db) falls back to process.env.TZ when no row is stored, then to Intl.DateTimeFormat().resolvedOptions().timeZone when TZ is also unset (D-06)"
+ - "isValidIanaTimezone accepts real IANA zones including 'UTC', 'Etc/UTC', and 'America/Chicago', and rejects garbage like 'Not/AZone'"
+ artifacts:
+ - path: "apps/api/src/lib/householdTimezone.ts"
+ provides: "Shared stored-timezone accessor (D-05 single accessor) + IANA validator"
+ exports: ["getHouseholdTimezone", "isValidIanaTimezone"]
+ - path: "apps/api/tests/lib/householdTimezone.test.ts"
+ provides: "RED→GREEN unit coverage for accessor fallback chain + validator"
+ key_links:
+ - from: "apps/api/src/lib/householdTimezone.ts"
+ to: "app_config row key='household_timezone'"
+ via: "drizzle select on appConfig"
+ pattern: "appConfig.*household_timezone"
+---
+
+
+Create the single shared accessor and IANA validator that every other Phase 18 task
+depends on. `getHouseholdTimezone(db)` reads the `household_timezone` key from `app_config`
+and applies the D-06 fallback chain; `isValidIanaTimezone(tz)` validates an IANA string via
+`Intl.DateTimeFormat` try/catch.
+
+Purpose: D-05 mandates ONE accessor so `reminderScheduler.ts` and `outboxWorker.ts` cannot
+drift; D-06 mandates the exact `process.env.TZ ?? Intl…` fallback so existing all-day tests
+stay green with no modification. This plan is the foundation both later waves consume.
+Output: `apps/api/src/lib/householdTimezone.ts` exporting `getHouseholdTimezone` + `isValidIanaTimezone`, fully unit-tested.
+
+
+
+@$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-RESEARCH.md
+@.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-PATTERNS.md
+
+
+
+
+
+ Task 1: RED — failing unit tests for getHouseholdTimezone fallback chain + isValidIanaTimezone
+ apps/api/tests/lib/householdTimezone.test.ts
+
+ - apps/api/tests/lib/requireAdmin.test.ts (vitest structure + how a mock Drizzle db is built for a lib helper)
+ - apps/api/tests/broker/reminderScheduler.test.ts (how existing tests pin process.env.TZ in beforeEach/afterEach — mirror the env save/restore)
+ - apps/api/src/db/schema.ts §282 (appConfig key/value/updatedAt shape — the row this accessor reads)
+ - .planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-RESEARCH.md (Pitfall 2 = why 'UTC' must pass; Validation Architecture → Test Map)
+
+
+ - getHouseholdTimezone: when the mocked db returns a row { value: 'America/Chicago' } for key 'household_timezone' → resolves to 'America/Chicago'.
+ - getHouseholdTimezone: when the mocked db returns no row (empty array) AND process.env.TZ='America/New_York' → resolves to 'America/New_York'.
+ - getHouseholdTimezone: when no row AND process.env.TZ is deleted → resolves to Intl.DateTimeFormat().resolvedOptions().timeZone (assert it equals that exact runtime value, not a hardcoded string).
+ - getHouseholdTimezone: when the row exists but value is null → falls through to the process.env.TZ branch (null is treated as unset).
+ - isValidIanaTimezone: returns true for 'UTC', 'Etc/UTC', 'America/Chicago', 'Europe/London'.
+ - isValidIanaTimezone: returns false for 'Not/AZone', '', 'Mars/Phobos'.
+
+
+ Create the test file mocking the Drizzle query chain the same way requireAdmin.test.ts mocks
+ `db` (select→from→where→limit returning a Promise of an array). Save and restore
+ `process.env.TZ` around each test (capture original in beforeEach, restore in afterEach) so the
+ fallback-branch tests do not leak env state. Import the not-yet-existing symbols
+ `getHouseholdTimezone` and `isValidIanaTimezone` from `../../src/lib/householdTimezone.js`.
+ Run the suite and confirm it FAILS because the module does not exist yet (import/resolution
+ error is the expected RED). Commit as `test(18-01): add failing tests for household timezone accessor + IANA validator`.
+
+
+ pnpm --filter @familysync/api exec vitest run tests/lib/householdTimezone.test.ts 2>&1 | grep -qi "fail\|error\|cannot find" && echo RED-OK
+
+
+ - File `apps/api/tests/lib/householdTimezone.test.ts` exists with the six behavior cases above.
+ - The test run fails (RED) prior to any implementation — failure is module-not-found / missing-export, not a syntax error in the test.
+ - A `test(18-01): ...` commit precedes the implementation commit.
+
+ RED gate met: failing test committed, fails for the right reason (no implementation).
+
+
+
+ Task 2: GREEN — implement getHouseholdTimezone + isValidIanaTimezone
+ apps/api/src/lib/householdTimezone.ts
+
+ - apps/api/src/lib/requireAdmin.ts (analog: single-purpose lib helper, drizzle select→from→where→limit(1), typed return — copy import + query style)
+ - apps/api/src/db/schema.ts §282 (appConfig — import { appConfig } from '../db/schema.js')
+ - apps/api/src/broker/reminderScheduler.ts §247 (the EXACT fallback expression `process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone` to reproduce verbatim per D-06)
+ - .planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-PATTERNS.md (householdTimezone.ts section — full target shape)
+
+
+ Create `apps/api/src/lib/householdTimezone.ts`. Export `async getHouseholdTimezone(db: MySql2Database): Promise` that selects `{ value: appConfig.value }` from `appConfig`
+ where `eq(appConfig.key, 'household_timezone')` `.limit(1)`, destructures `const [row]`, and returns
+ `row?.value ?? process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone` — the D-06 fallback
+ reproduced verbatim from the bare lookups. Use the literal key string `'household_timezone'` (D-01).
+ Export `isValidIanaTimezone(tz: string): boolean` that returns true if `Intl.DateTimeFormat(undefined, { timeZone: tz })` does not throw, false on catch — do NOT use `Intl.supportedValuesOf` (it omits 'UTC', RESEARCH Pitfall 2).
+ Import types via `import type { MySql2Database } from 'drizzle-orm/mysql2'` and `import type * as schema from '../db/schema.js'`.
+ Run the test; iterate until GREEN. Commit as `feat(18-01): implement household timezone accessor + IANA validator`.
+
+
+ pnpm --filter @familysync/api exec vitest run tests/lib/householdTimezone.test.ts
+
+
+ - `apps/api/src/lib/householdTimezone.ts` exports `getHouseholdTimezone` and `isValidIanaTimezone`.
+ - Source contains the literal key `'household_timezone'` and the verbatim fallback `process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone`.
+ - Source does NOT reference `Intl.supportedValuesOf` (assert: `grep -L "supportedValuesOf" apps/api/src/lib/householdTimezone.ts`).
+ - All six behavior cases pass (GREEN).
+ - `pnpm --filter @familysync/api exec tsc --noEmit` passes for the new file.
+ - A `feat(18-01): ...` commit follows the RED commit.
+
+ GREEN gate met: implementation passes all unit tests; typecheck clean.
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| scheduler/outbox → DB | trusted server process reads a stored config value; the value was written through the validated admin/seed path (Plan 02) |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-18-01 | Tampering | stored household_timezone consumed by computeAlertInstantUtc | mitigate | This plan only READS the value; all writes go through Plan 02's Zod `isValidIanaTimezone` refine, so a garbage zone can never be stored to break the scheduler. The accessor additionally never passes an unvalidated value (write path is the gate). |
+| T-18-02 | Denial of Service | getHouseholdTimezone DB read per scheduler tick | accept | Single PK lookup on a 60s interval; negligible. Read-per-run chosen for correctness (changes propagate within one tick) — RESEARCH A1. |
+| T-18-SC | Tampering | npm/pip/cargo installs | mitigate | No new packages installed this plan (RESEARCH Package Legitimacy Audit: zero installs). No legitimacy checkpoint needed. |
+
+
+
+- `pnpm --filter @familysync/api exec vitest run tests/lib/householdTimezone.test.ts` green.
+- `pnpm --filter @familysync/api test -- --run` still green (no regression in existing suites).
+
+
+
+- `getHouseholdTimezone` + `isValidIanaTimezone` exist, exported, unit-tested.
+- D-06 fallback verbatim; D-05 single accessor established for both broker sites to import.
+- RED then GREEN commits present.
+
+
+
diff --git a/.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-01-SUMMARY.md b/.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-01-SUMMARY.md
new file mode 100644
index 0000000..518ea3b
--- /dev/null
+++ b/.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-01-SUMMARY.md
@@ -0,0 +1,124 @@
+---
+phase: 18-auto-timezone-detection-and-ability-to-change-timezone
+plan: "01"
+subsystem: api
+tags: [timezone, iana, drizzle, vitest, tdd]
+
+# Dependency graph
+requires:
+ - phase: 10-admin-role-settings
+ provides: appConfig table (key/value store where household_timezone key lives)
+provides:
+ - getHouseholdTimezone(db) — single D-05 accessor for stored household timezone with D-06 fallback chain
+ - isValidIanaTimezone(tz) — IANA timezone validator via Intl.DateTimeFormat try/catch
+
+affects:
+ - 18-02-PLAN (broker rewire — reminderScheduler + outboxWorker import getHouseholdTimezone)
+ - 18-03-PLAN (admin API routes — import isValidIanaTimezone for Zod refine)
+ - 18-04-PLAN (PWA settings UI — consumes admin timezone API built on top of these)
+
+# Tech tracking
+tech-stack:
+ added: []
+ patterns:
+ - Drizzle single-row PK lookup (.select().from().where(eq()).limit(1)) — same pattern as requireAdmin.ts
+ - IANA timezone validation via try/catch on Intl.DateTimeFormat (avoids Intl.supportedValuesOf omitting 'UTC')
+ - TZ env save/restore in beforeEach/afterEach to prevent env state leaks between tests
+
+key-files:
+ created:
+ - apps/api/src/lib/householdTimezone.ts
+ - apps/api/tests/lib/householdTimezone.test.ts
+ modified: []
+
+key-decisions:
+ - "isValidIanaTimezone uses Intl.DateTimeFormat try/catch — NOT Intl.supportedValuesOf (omits UTC per RESEARCH Pitfall 2)"
+ - "D-06 fallback chain verbatim: row?.value ?? process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone"
+ - "Literal key string 'household_timezone' in the WHERE clause (D-01)"
+
+patterns-established:
+ - "householdTimezone accessor pattern: getHouseholdTimezone(db) as the single import site for all tz reads in broker workers"
+
+requirements-completed: [D-05, D-06]
+
+# Metrics
+duration: 2min
+completed: 2026-06-15
+---
+
+# Phase 18 Plan 01: Household Timezone Accessor + IANA Validator Summary
+
+**Single D-05 accessor `getHouseholdTimezone(db)` reads `household_timezone` from `app_config` with D-06 fallback chain; `isValidIanaTimezone(tz)` validates via `Intl.DateTimeFormat` try/catch (not `supportedValuesOf`)**
+
+## Performance
+
+- **Duration:** 2 min
+- **Started:** 2026-06-15T02:04:31Z
+- **Completed:** 2026-06-15T02:06:50Z
+- **Tasks:** 2 (TDD RED + GREEN)
+- **Files modified:** 2
+
+## Accomplishments
+
+- Created `apps/api/src/lib/householdTimezone.ts` exporting `getHouseholdTimezone` and `isValidIanaTimezone`
+- 11 unit tests cover all 6 required behaviors: stored row, no-row + TZ env, no-row + no-TZ, null row, valid zones, invalid zones
+- D-06 fallback chain `row?.value ?? process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone` reproduced verbatim
+- TypeScript typecheck (`tsc --noEmit`) clean; full 358-test suite green with no regressions
+- TDD gate compliance: RED commit (`db0077c`) precedes GREEN commit (`eaceff0`)
+
+## Task Commits
+
+1. **Task 1: RED — failing unit tests** - `db0077c` (test)
+2. **Task 2: GREEN — implement accessor + validator** - `eaceff0` (feat)
+
+**Plan metadata:** (see final docs commit below)
+
+## Files Created/Modified
+
+- `apps/api/src/lib/householdTimezone.ts` — D-05 accessor + IANA validator, exported for broker + admin route consumers
+- `apps/api/tests/lib/householdTimezone.test.ts` — 11 unit tests for fallback chain and validator
+
+## Decisions Made
+
+- `isValidIanaTimezone` uses `Intl.DateTimeFormat(undefined, { timeZone: tz })` try/catch — `Intl.supportedValuesOf('timeZone')` was explicitly avoided because it omits `'UTC'` in some environments (RESEARCH Pitfall 2).
+- Fallback chain matches the verbatim expression found at `reminderScheduler.ts:247` so the all-day reminder path continues to work unchanged before Plan 02 seeds a stored value.
+- `db` is accepted as a parameter (not imported from `db/client.js`) to enable clean mock-based unit testing without a live MariaDB connection.
+
+## Deviations from Plan
+
+None — plan executed exactly as written.
+
+## Issues Encountered
+
+- The vitest global setup requires a MariaDB connection (`global-setup.ts`). Tests need to be run with `DB_HOST=127.0.0.1` when running locally (the `.env` sets `DB_HOST=mariadb` for Docker networking). This is a known dev environment pattern documented in `familysync-dev-stack-setup.md` and has no effect on CI (which uses the service container).
+
+## Known Stubs
+
+None — this plan creates a pure utility module with no UI stubs or placeholder data.
+
+## Threat Flags
+
+None — no new network endpoints, auth paths, file access patterns, or schema changes were introduced. The accessor is a read-only DB lookup within the trusted server process (T-18-01 disposition: accept).
+
+## TDD Gate Compliance
+
+- RED gate: `db0077c` — `test(18-01): add failing tests for household timezone accessor + IANA validator`
+- GREEN gate: `eaceff0` — `feat(18-01): implement household timezone accessor + IANA validator`
+- REFACTOR gate: N/A (implementation was clean on first pass)
+
+## Next Phase Readiness
+
+- `getHouseholdTimezone` and `isValidIanaTimezone` are the foundation both Plan 18-02 (broker rewire) and Plan 18-03 (admin API + Zod refine) consume.
+- Plan 18-02 can import `getHouseholdTimezone` from `../lib/householdTimezone.js` to replace the bare `process.env.TZ ?? Intl…` lookups in `reminderScheduler.ts:247` and `outboxWorker.ts:501,607`.
+- Plan 18-03 can import `isValidIanaTimezone` for the Zod `.refine()` on `PUT /api/admin/config/timezone`.
+
+## Self-Check: PASSED
+
+- `apps/api/src/lib/householdTimezone.ts` — FOUND
+- `apps/api/tests/lib/householdTimezone.test.ts` — FOUND
+- Commit `db0077c` — FOUND
+- Commit `eaceff0` — FOUND
+
+---
+*Phase: 18-auto-timezone-detection-and-ability-to-change-timezone*
+*Completed: 2026-06-15*
diff --git a/.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-02-PLAN.md b/.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-02-PLAN.md
new file mode 100644
index 0000000..23f8f8e
--- /dev/null
+++ b/.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-02-PLAN.md
@@ -0,0 +1,170 @@
+---
+phase: 18-auto-timezone-detection-and-ability-to-change-timezone
+plan: 02
+type: tdd
+wave: 2
+depends_on: ["18-01"]
+files_modified:
+ - apps/api/src/routes/admin.ts
+ - apps/api/tests/routes/admin.test.ts
+autonomous: true
+requirements: [D-01, D-02, D-03, D-04]
+must_haves:
+ truths:
+ - "GET /api/admin/config/timezone returns the current timezone + isExplicitlySet flag, gated by requireAdmin (D-04)"
+ - "PUT /api/admin/config/timezone validates the IANA string server-side and upserts household_timezone in app_config (D-01, D-04)"
+ - "A non-admin authenticated user gets 403 on both GET and PUT (server is the real boundary — Phase 10 D-03)"
+ - "PUT with an invalid IANA string returns 400 and writes nothing; PUT with 'UTC' returns 200"
+ - "POST /api/admin/config/timezone/seed writes household_timezone ONLY if currently unset (D-02 seed, D-03 no-overwrite)"
+ artifacts:
+ - path: "apps/api/src/routes/admin.ts"
+ provides: "GET + PUT + seed timezone endpoints on the existing requireAdmin-gated adminRouter"
+ - path: "apps/api/tests/routes/admin.test.ts"
+ provides: "Integration coverage: 403 non-admin, IANA 400/200, round-trip, seed no-overwrite"
+ key_links:
+ - from: "apps/api/src/routes/admin.ts"
+ to: "apps/api/src/lib/householdTimezone.ts"
+ via: "import isValidIanaTimezone + getHouseholdTimezone"
+ pattern: "isValidIanaTimezone|getHouseholdTimezone"
+ - from: "PUT /api/admin/config/timezone"
+ to: "app_config key='household_timezone'"
+ via: "drizzle insert().onDuplicateKeyUpdate"
+ pattern: "household_timezone.*onDuplicateKeyUpdate"
+---
+
+
+Extend the existing Phase 10 `adminRouter` with three timezone endpoints — GET (read current +
+isExplicitlySet), PUT (validate + upsert), and a seed endpoint (write only if unset) — all behind
+the established `requireAdmin` guard.
+
+Purpose: D-04 puts the timezone behind the role-gated admin surface; D-01 stores it as the single
+`household_timezone` app_config row; D-02/D-03 add a first-run seed path that never overwrites an
+already-set value (so Phase 18 is self-contained without Phase 12, and silent drift is impossible).
+Output: three new routes on `adminRouter` + integration tests proving the access-control and
+validation boundaries.
+
+
+
+@$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-RESEARCH.md
+@.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-PATTERNS.md
+@apps/api/src/lib/householdTimezone.ts
+
+
+
+
+
+ Task 1: RED — failing integration tests for the three timezone endpoints
+ apps/api/tests/routes/admin.test.ts
+
+ - apps/api/tests/routes/admin.test.ts (existing: how a non-admin 403 case is set up; how admin requests are authed; beforeEach/afterEach DB cleanup against familysync_test)
+ - apps/api/src/routes/admin.ts (existing GET /calendars + PUT /calendars/:id/shared handlers; requireAdmin at line 41; zValidator import at line 24)
+ - apps/api/src/lib/householdTimezone.ts (the validator + accessor this route consumes)
+ - .planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-RESEARCH.md (Pitfall 2 'UTC' must pass; Pitfall 3 requireAdmin coverage; Pitfall 6 seed no-overwrite; Security Domain → no noEchoHook needed)
+
+
+ - GET /api/admin/config/timezone as a non-admin authenticated user → 403.
+ - PUT /api/admin/config/timezone as a non-admin authenticated user → 403.
+ - GET as admin with no stored row → 200 with { isExplicitlySet: false } and a non-empty timezone string (the fallback).
+ - PUT as admin { timezone: 'America/Chicago' } → 200; subsequent GET → { timezone: 'America/Chicago', isExplicitlySet: true }.
+ - PUT as admin { timezone: 'UTC' } → 200 (UTC must be accepted — Pitfall 2).
+ - PUT as admin { timezone: 'Not/AZone' } → 400; app_config has no household_timezone change as a result.
+ - POST /api/admin/config/timezone/seed { timezone: 'Europe/London' } when unset → 200 and stored value becomes 'Europe/London'.
+ - POST seed { timezone: 'Asia/Tokyo' } when already set to 'America/Chicago' → 200 (or 409 if chosen) but stored value REMAINS 'America/Chicago' (no overwrite, D-03).
+
+
+ Append a `describe('admin timezone config', ...)` block to the existing integration test file,
+ reusing its established admin-auth and DB-cleanup helpers. For the no-overwrite seed test, first
+ PUT (or directly insert) 'America/Chicago', then POST seed 'Asia/Tokyo', then GET and assert the
+ value is still 'America/Chicago'. Ensure each test cleans the household_timezone row in
+ afterEach so cases do not bleed. Run the suite; confirm the new cases FAIL (routes return 404 —
+ not yet implemented). Commit as `test(18-02): add failing integration tests for admin timezone endpoints`.
+
+
+ pnpm --filter @familysync/api exec vitest run tests/routes/admin.test.ts 2>&1 | grep -qi "fail\|404" && echo RED-OK
+
+
+ - New describe block covers all eight behavior cases above, including both 403 cases and the seed no-overwrite case.
+ - Run is RED because the endpoints do not exist yet (404), not because of a test bug.
+ - `test(18-02): ...` commit precedes implementation.
+
+ RED gate met for the endpoint contract.
+
+
+
+ Task 2: GREEN — implement GET/PUT/seed timezone endpoints on adminRouter
+ apps/api/src/routes/admin.ts
+
+ - apps/api/src/routes/admin.ts (FULL file — append after existing routes so requireAdmin at line 41 covers them; mirror GET /calendars and the zValidator PUT shape)
+ - apps/api/src/lib/householdTimezone.ts (import isValidIanaTimezone + getHouseholdTimezone)
+ - .planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-PATTERNS.md (admin.ts section: exact schema placement, import additions, upsert pattern)
+ - apps/api/src/db/schema.ts §282 (appConfig — add to the schema import on line 28)
+
+
+ Extend `apps/api/src/routes/admin.ts`. Add `appConfig` to the `'../db/schema.js'` import and
+ `import { isValidIanaTimezone, getHouseholdTimezone } from '../lib/householdTimezone.js'`.
+ Define `timezoneSchema = z.object({ timezone: z.string().min(1).max(64).refine(isValidIanaTimezone, { message: 'Invalid IANA timezone identifier' }) })`
+ in the Zod schema block (NO noEchoHook — timezone is non-sensitive, RESEARCH Security Domain).
+ Append three routes AFTER the existing ones (so the line-41 `requireAdmin` covers them):
+ • `adminRouter.get('/config/timezone', ...)` → select value where key='household_timezone' limit 1; return `c.json({ timezone: row?.value ?? process.env.TZ ?? Intl…, isExplicitlySet: row?.value != null })`. (Reuse the exact fallback; you may call getHouseholdTimezone(db) for the value and compute isExplicitlySet from a separate row read or a single read.)
+ • `adminRouter.put('/config/timezone', zValidator('json', timezoneSchema), ...)` → upsert via `db.insert(appConfig).values({ key: 'household_timezone', value: timezone }).onDuplicateKeyUpdate({ set: { value: timezone } })`; return `c.json({ ok: true }, 200)`.
+ • `adminRouter.post('/config/timezone/seed', zValidator('json', timezoneSchema), ...)` → SELECT existing; if `row?.value` is null/undefined, insert the value; else no-op. Always return `c.json({ ok: true, seeded: }, 200)` (D-03 no-overwrite). Do NOT use a bare onDuplicateKeyUpdate that would overwrite.
+ Run the integration tests; iterate to GREEN. Commit as `feat(18-02): admin timezone GET/PUT/seed endpoints`.
+
+
+ pnpm --filter @familysync/api exec vitest run tests/routes/admin.test.ts
+
+
+ - admin.ts registers `/config/timezone` (GET, PUT) and `/config/timezone/seed` (POST) AFTER line 41 so requireAdmin covers them (both 403 tests pass).
+ - PUT body validated by `timezoneSchema` using `isValidIanaTimezone`; 'UTC' → 200, 'Not/AZone' → 400.
+ - Upsert uses `onDuplicateKeyUpdate` on key `'household_timezone'`; seed path conditionally writes only when unset (no-overwrite test passes).
+ - admin.ts does NOT add a noEchoHook to the timezone routes (timezone is non-sensitive).
+ - All eight RED cases now pass (GREEN); `tsc --noEmit` clean.
+ - `feat(18-02): ...` commit follows RED commit.
+
+ GREEN gate met: endpoints enforce requireAdmin + IANA validation + no-overwrite seed.
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| client → /api/admin/config/timezone | untrusted authenticated request crosses into a privileged config write |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-18-03 | Elevation of Privilege | non-admin sets/reads household timezone | mitigate | `adminRouter.use('*', requireAdmin)` (line 41) is the FIRST statement; new routes appended after inherit it. Integration tests assert 403 for a non-admin authenticated user on both GET and PUT (Pitfall 3). Client `isAdmin` is UX-only (Phase 10 D-03); the DB-backed requireAdmin is the real boundary. |
+| T-18-04 | Tampering | arbitrary/garbage timezone string in PUT body breaks scheduler | mitigate | Zod `.refine(isValidIanaTimezone)` rejects non-IANA strings with 400 before any DB write; `try/catch Intl.DateTimeFormat` is eval-free. Test asserts 'Not/AZone' → 400 and no stored change. |
+| T-18-05 | Tampering | unauthenticated/first-run seed clobbers an already-set value (silent drift) | mitigate | seed endpoint reads existing value and writes ONLY when unset (D-03); it is also behind requireAdmin. Test asserts a second seed with a different zone leaves the stored value unchanged. |
+| T-18-06 | Information Disclosure | log/echo of submitted value | accept | Timezone strings are non-sensitive (not credentials/PII — RESEARCH Security Domain); noEchoHook not required. No console.log of bodies added. |
+| T-18-07 | Injection | app_config key/value write | mitigate | Drizzle parameterizes the insert/upsert; the key is a hard-coded literal `'household_timezone'`; the value is IANA-validated. No string-concatenated SQL. |
+| T-18-SC | Tampering | npm/pip/cargo installs | mitigate | No new packages this plan (RESEARCH Package Legitimacy Audit: zero installs). |
+
+
+
+- `pnpm --filter @familysync/api exec vitest run tests/routes/admin.test.ts` green.
+- `pnpm --filter @familysync/api test -- --run` green (no regression).
+
+
+
+- Three admin-gated timezone endpoints exist with server-side IANA validation and a no-overwrite seed.
+- D-01/D-02/D-03/D-04 enforced and tested.
+- RED then GREEN commits present.
+
+
+
diff --git a/.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-02-SUMMARY.md b/.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-02-SUMMARY.md
new file mode 100644
index 0000000..160fa63
--- /dev/null
+++ b/.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-02-SUMMARY.md
@@ -0,0 +1,123 @@
+---
+phase: 18-auto-timezone-detection-and-ability-to-change-timezone
+plan: "02"
+subsystem: api
+tags: [timezone, iana, drizzle, vitest, tdd, admin, hono]
+
+# Dependency graph
+requires:
+ - phase: 18-01
+ provides: isValidIanaTimezone + getHouseholdTimezone (consumed by timezoneSchema + GET handler)
+ - phase: 10-admin-role-settings
+ provides: adminRouter + requireAdmin + appConfig table
+
+provides:
+ - GET /api/admin/config/timezone — reads stored household timezone with isExplicitlySet flag
+ - PUT /api/admin/config/timezone — validates IANA string + upserts household_timezone in app_config
+ - POST /api/admin/config/timezone/seed — seeds only when unset (D-03 no-overwrite)
+
+affects:
+ - 18-03-PLAN (broker rewire — reminderScheduler + outboxWorker import getHouseholdTimezone)
+ - 18-04-PLAN (PWA settings UI — consumes GET + PUT endpoints built here)
+
+# Tech tracking
+tech-stack:
+ added: []
+ patterns:
+ - zValidator('json', schema) without noEchoHook for non-sensitive config (timezone is not a credential)
+ - Drizzle onDuplicateKeyUpdate upsert for config PUT (appConfig PK = key)
+ - SELECT-before-INSERT pattern for no-overwrite seed (D-03 — DO NOT use onDuplicateKeyUpdate for seed)
+ - requireAdmin positional coverage: new routes appended after line-41 adminRouter.use('*', requireAdmin)
+
+key-files:
+ created: []
+ modified:
+ - apps/api/src/routes/admin.ts
+ - apps/api/tests/routes/admin.test.ts
+
+key-decisions:
+ - "timezoneSchema uses isValidIanaTimezone (from 18-01) in Zod .refine() — no noEchoHook needed (T-18-06: timezone is non-sensitive)"
+ - "GET /config/timezone reads row directly (not via getHouseholdTimezone) to compute isExplicitlySet from row?.value != null, then uses getHouseholdTimezone for the fallback value"
+ - "seed endpoint uses SELECT-then-INSERT (not onDuplicateKeyUpdate) to ensure D-03 no-overwrite is an explicit code path, not a silent race"
+
+requirements-completed: [D-01, D-02, D-03, D-04]
+
+# Metrics
+duration: 3min
+completed: 2026-06-15
+---
+
+# Phase 18 Plan 02: Admin Timezone API Endpoints Summary
+
+**Three role-gated admin endpoints (GET + PUT + seed) added to adminRouter with server-side IANA validation via isValidIanaTimezone and D-03 no-overwrite seed semantics enforced by SELECT-before-INSERT**
+
+## Performance
+
+- **Duration:** 3 min
+- **Started:** 2026-06-15T02:09:38Z
+- **Completed:** 2026-06-15T02:12:39Z
+- **Tasks:** 2 (TDD RED + GREEN)
+- **Files modified:** 2
+
+## Accomplishments
+
+- Added `describe('admin timezone config')` to `admin.test.ts` with 8 test cases covering all boundary conditions
+- Implemented `GET /api/admin/config/timezone`, `PUT /api/admin/config/timezone`, and `POST /api/admin/config/timezone/seed` on the existing `adminRouter`
+- All routes inherit the line-41 `requireAdmin` guard (Pitfall 9 / T-18-03) — no per-route auth addition needed
+- `timezoneSchema` uses `isValidIanaTimezone` from Plan 18-01 in a Zod `.refine()` — no `noEchoHook` (T-18-06)
+- `PUT` uses `onDuplicateKeyUpdate` for a true upsert; `seed` uses SELECT-then-INSERT to enforce D-03 no-overwrite
+- 25/25 `admin.test.ts` tests pass; 366/366 full API suite green; `tsc --noEmit` clean
+
+## Task Commits
+
+1. **Task 1: RED — failing integration tests** - `f109b3c` (test)
+2. **Task 2: GREEN — implement GET/PUT/seed timezone endpoints** - `3bd6a5d` (feat)
+
+## Files Created/Modified
+
+- `apps/api/src/routes/admin.ts` — added appConfig + householdTimezone imports, timezoneSchema, and three new route handlers
+- `apps/api/tests/routes/admin.test.ts` — added appConfig import + `describe('admin timezone config')` with 8 test cases + per-test afterEach cleanup
+
+## Decisions Made
+
+- `timezoneSchema` does NOT use `noEchoHook` because timezone strings are non-sensitive (not credentials/PII); standard `zValidator` error responses are safe (T-18-06 accepted disposition).
+- `GET /config/timezone` does a direct single-row read (not `getHouseholdTimezone`) so the handler can compute `isExplicitlySet` from `row?.value != null` before deciding whether to invoke the fallback chain — using `getHouseholdTimezone` would discard the "was it stored?" signal.
+- `POST /api/admin/config/timezone/seed` uses a SELECT-then-INSERT (not `onDuplicateKeyUpdate`) so that D-03 no-overwrite is an explicit code branch, not an implicit race. The test asserts the stored value after a second seed attempt remains unchanged.
+
+## Deviations from Plan
+
+None — plan executed exactly as written.
+
+## Issues Encountered
+
+- Running `pnpm --filter @familysync/api exec vitest run` requires `DB_ROOT_PASSWORD` from `.env` sourced into the shell (the global-setup provisions `familysync_test` using the root credential). This is the established pattern from quick 260613-ndv and documented in `familysync-dev-stack-setup.md`.
+
+## Known Stubs
+
+None — all three endpoints are fully wired to the database. No placeholder data.
+
+## Threat Flags
+
+No new threat surface beyond the plan's threat model. All three endpoints are behind `requireAdmin` (T-18-03). IANA validation enforced by `isValidIanaTimezone` (T-18-04). No SQL injection surface — key is a hard-coded literal, value is IANA-validated, Drizzle parameterizes the insert/upsert (T-18-07).
+
+## TDD Gate Compliance
+
+- RED gate: `f109b3c` — `test(18-02): add failing integration tests for admin timezone endpoints`
+- GREEN gate: `3bd6a5d` — `feat(18-02): admin timezone GET/PUT/seed endpoints`
+- REFACTOR gate: N/A (implementation was clean on first pass)
+
+## Next Phase Readiness
+
+- Plan 18-03 (broker rewire) can import `getHouseholdTimezone(db)` from `../lib/householdTimezone.js` to replace the bare `process.env.TZ ?? Intl…` lookups in `reminderScheduler.ts:247` and `outboxWorker.ts:501,607`.
+- Plan 18-04 (PWA settings UI) can wire to `GET /api/admin/config/timezone` and `PUT /api/admin/config/timezone`. The seed endpoint is also available for the Phase 12 wizard auto-detect flow.
+
+## Self-Check: PASSED
+
+- `apps/api/src/routes/admin.ts` — FOUND (modified)
+- `apps/api/tests/routes/admin.test.ts` — FOUND (modified)
+- Commit `f109b3c` — FOUND
+- Commit `3bd6a5d` — FOUND
+
+---
+*Phase: 18-auto-timezone-detection-and-ability-to-change-timezone*
+*Completed: 2026-06-15*
diff --git a/.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-03-PLAN.md b/.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-03-PLAN.md
new file mode 100644
index 0000000..c2851db
--- /dev/null
+++ b/.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-03-PLAN.md
@@ -0,0 +1,175 @@
+---
+phase: 18-auto-timezone-detection-and-ability-to-change-timezone
+plan: 03
+type: tdd
+wave: 2
+depends_on: ["18-01"]
+files_modified:
+ - apps/api/src/broker/reminderScheduler.ts
+ - apps/api/src/broker/outboxWorker.ts
+ - apps/api/tests/broker/reminderScheduler.test.ts
+ - apps/api/tests/broker/outboxWorker.test.ts
+autonomous: true
+requirements: [D-05, D-06, D-07]
+must_haves:
+ truths:
+ - "reminderScheduler.ts all-day branch computes the 9 AM-local alert instant using the stored household_timezone when set (D-05)"
+ - "outboxWorker.ts both all-day branches (create + update) compute the alert instant using the stored household_timezone when set (D-05)"
+ - "When household_timezone is unset, all three sites fall back to process.env.TZ ?? Intl — existing all-day tests pass unmodified (D-06)"
+ - "Both files route through the single getHouseholdTimezone accessor — the read/fallback logic is NOT duplicated at the call sites (D-05)"
+ artifacts:
+ - path: "apps/api/src/broker/reminderScheduler.ts"
+ provides: "all-day TZ now sourced from getHouseholdTimezone(db) (line ~247 rewire)"
+ - path: "apps/api/src/broker/outboxWorker.ts"
+ provides: "all-day TZ now sourced from getHouseholdTimezone(db) (lines ~501, ~607 rewire)"
+ key_links:
+ - from: "apps/api/src/broker/reminderScheduler.ts"
+ to: "apps/api/src/lib/householdTimezone.ts"
+ via: "import { getHouseholdTimezone }"
+ pattern: "getHouseholdTimezone\\(db\\)"
+ - from: "apps/api/src/broker/outboxWorker.ts"
+ to: "apps/api/src/lib/householdTimezone.ts"
+ via: "import { getHouseholdTimezone }"
+ pattern: "getHouseholdTimezone\\(db\\)"
+---
+
+
+Rewire the three all-day "9 AM local" timezone lookups — one in `reminderScheduler.ts` (line ~247)
+and two in `outboxWorker.ts` (lines ~501 and ~607) — to read the stored timezone through the
+single `getHouseholdTimezone(db)` accessor, replacing the bare
+`process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone` reads.
+
+Purpose: D-05 makes the stored `household_timezone` the source of truth for the all-day reminder
+computation, and mandates ONE shared accessor at both sites (no duplicated read/fallback). D-06
+keeps the exact `process.env.TZ ?? Intl` behavior when nothing is stored, so the existing all-day
+tests pass unmodified. D-07 is the hard boundary: this plan must NOT touch display/timed-write paths.
+Output: three rewired call sites + new stored-TZ tests; existing all-day tests green unchanged.
+
+
+
+@$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-RESEARCH.md
+@.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-PATTERNS.md
+@apps/api/src/lib/householdTimezone.ts
+
+
+
+
+
+ Task 1: RED — failing stored-TZ all-day tests for scheduler + outbox
+ apps/api/tests/broker/reminderScheduler.test.ts, apps/api/tests/broker/outboxWorker.test.ts
+
+ - apps/api/tests/broker/reminderScheduler.test.ts (lines ~656–729: existing all-day tests that pin process.env.TZ='America/New_York' — DO NOT modify them; they must keep passing via D-06 fallback)
+ - apps/api/tests/broker/outboxWorker.test.ts (how its mock db chain is built for the all-day branches)
+ - apps/api/src/broker/vevent.ts §240 (computeAlertInstantUtc(eventDateStr, leadDays, tz): Date — contract unchanged; assert the 9 AM-local instant for the stored zone)
+ - apps/api/src/lib/householdTimezone.ts (the accessor whose stored-value path you are now exercising)
+ - .planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-RESEARCH.md (Pitfall 5: why existing tests stay green; new tests mock db to return the app_config row)
+
+
+ - reminderScheduler all-day: when the mock db ALSO returns { key: 'household_timezone', value: 'America/Chicago' }, the computed alert instant equals 9 AM America/Chicago for the event date (a different UTC instant than 9 AM America/New_York would give).
+ - outboxWorker all-day (create branch): with stored 'America/Chicago', the create-branch alert instant is 9 AM Chicago for the event date.
+ - outboxWorker all-day (update branch): with stored 'America/Chicago', the update-branch alert instant is 9 AM Chicago for the event date.
+ - Backward-compat (assert, do not modify): the pre-existing all-day tests that pin process.env.TZ and return NO app_config row still pass (fallback fires).
+
+
+ Add new stored-TZ test cases alongside the existing all-day tests. Where the existing tests mock
+ the Drizzle query for events, extend the mock so the household_timezone SELECT resolves to a row
+ with value 'America/Chicago' (match the accessor's query shape so getHouseholdTimezone returns it).
+ Assert the resulting UTC instant equals 9 AM Chicago for the event date (compute the expected UTC
+ via a fixed reference, e.g. using computeAlertInstantUtc with 'America/Chicago' directly, or an
+ explicit ISO instant). Do NOT alter the existing process.env.TZ-pinned cases. Run the two suites;
+ confirm the NEW cases FAIL (code still reads process.env.TZ, so stored 'America/Chicago' has no
+ effect). Commit as `test(18-03): add failing stored-TZ all-day tests for scheduler + outbox`.
+
+
+ pnpm --filter @familysync/api exec vitest run tests/broker/reminderScheduler.test.ts tests/broker/outboxWorker.test.ts 2>&1 | grep -qi "fail" && echo RED-OK
+
+
+ - New stored-TZ cases exist in both test files and FAIL before the rewire (code still reads process.env.TZ).
+ - The existing process.env.TZ-pinned all-day cases are unchanged (no edits to those test bodies).
+ - `test(18-03): ...` commit precedes the rewire commit.
+
+ RED gate met: stored-TZ tests fail because the call sites are not yet rewired.
+
+
+
+ Task 2: GREEN — rewire all three all-day TZ call sites through getHouseholdTimezone(db)
+ apps/api/src/broker/reminderScheduler.ts, apps/api/src/broker/outboxWorker.ts
+
+ - apps/api/src/broker/reminderScheduler.ts (line ~247 serverTz lookup; line ~256 computeAlertInstantUtc call; db already imported line 46)
+ - apps/api/src/broker/outboxWorker.ts (lines ~501 + ~607 tz lookups; runOutboxDrain line ~710; db already imported line 31)
+ - apps/api/src/lib/householdTimezone.ts (import getHouseholdTimezone)
+ - .planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-PATTERNS.md (reminderScheduler + outboxWorker sections: exact replacement lines)
+
+
+ In `reminderScheduler.ts`: add `import { getHouseholdTimezone } from '../lib/householdTimezone.js'`
+ and replace the line-247 `const serverTz = process.env.TZ ?? Intl…` with
+ `const serverTz = await getHouseholdTimezone(db);` (the enclosing function is already async / awaits
+ DB queries; `db` is the module import on line 46). Leave the line-256 computeAlertInstantUtc call
+ unchanged — serverTz is still a string.
+ In `outboxWorker.ts`: add the same import. Replace BOTH bare `const tz = process.env.TZ ?? Intl…`
+ lookups (~501 and ~607) with `const tz = await getHouseholdTimezone(db);`. `db` is the module import
+ on line 31; `runOutboxDrain` is async. (If both branches are reachable in one pass and lint allows,
+ you may hoist a single `const tz = await getHouseholdTimezone(db)` to the top of the all-day block
+ and reuse it — but do NOT duplicate the read/fallback inline; route through the accessor only.)
+ HARD BOUNDARY (D-07): do not open, import from, or modify `apps/pwa/src/lib/eventDateTime.ts` or
+ `apps/pwa/src/lib/hydrateEvents.ts`, and do not change any timed-event serialization or display path.
+ Run both suites; iterate to GREEN. Confirm the existing process.env.TZ-pinned tests still pass.
+ Commit as `feat(18-03): route all-day reminder TZ through stored household_timezone`.
+
+
+ pnpm --filter @familysync/api exec vitest run tests/broker/reminderScheduler.test.ts tests/broker/outboxWorker.test.ts
+
+
+ - `grep -c "getHouseholdTimezone(db)" apps/api/src/broker/reminderScheduler.ts` ≥ 1 and same for outboxWorker.ts ≥ 1.
+ - No bare `process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone` remains at the rewired all-day sites (assert: `grep -v '^\s*\*' apps/api/src/broker/reminderScheduler.ts | grep -c "process.env.TZ ?? Intl"` → 0; same for outboxWorker.ts). The fallback now lives only inside getHouseholdTimezone.
+ - D-07 boundary: `git diff --name-only` for this plan does NOT include `apps/pwa/src/lib/eventDateTime.ts` or `apps/pwa/src/lib/hydrateEvents.ts` (assert they are absent from the changed-files list).
+ - New stored-TZ tests GREEN; existing process.env.TZ all-day tests still GREEN (unmodified).
+ - `pnpm --filter @familysync/api test -- --run` fully green; `tsc --noEmit` clean.
+ - `feat(18-03): ...` commit follows the RED commit.
+
+ GREEN gate met: stored TZ drives all three all-day sites; D-06 fallback + D-07 boundary intact.
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| scheduler/outbox → DB | trusted server worker reads the validated stored timezone to compute fire times |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-18-08 | Tampering | stored TZ fed to computeAlertInstantUtc could be garbage | mitigate | The value can only have been written via Plan 02's IANA-validated PUT/seed; the accessor returns either a validated stored string or the D-06 fallback. No unvalidated value reaches computeAlertInstantUtc. |
+| T-18-09 | Tampering (regression) | accidental change to display/timed-write path | mitigate | D-07 boundary enforced as an acceptance criterion: changed-files list must exclude eventDateTime.ts + hydrateEvents.ts; no timed-event serialization touched. Blast radius confined to the all-day branches. |
+| T-18-10 | Denial of Service | extra DB read per tick at 3 call sites | accept | PK lookups on a 60s interval; negligible (RESEARCH A1). Read-per-run keeps changes propagating within one tick without a worker restart. |
+| T-18-SC | Tampering | npm/pip/cargo installs | mitigate | No new packages this plan (RESEARCH Package Legitimacy Audit: zero installs). |
+
+
+
+- `pnpm --filter @familysync/api exec vitest run tests/broker/reminderScheduler.test.ts tests/broker/outboxWorker.test.ts` green.
+- `pnpm --filter @familysync/api test -- --run` green (all 244+ existing tests, including unmodified all-day cases).
+- D-07: changed-files exclude the two PWA display-path files.
+
+
+
+- All three all-day TZ sites route through getHouseholdTimezone(db); no duplicated read/fallback.
+- D-05 source-of-truth, D-06 backward-compat, D-07 boundary all satisfied.
+- RED then GREEN commits present.
+
+
+
diff --git a/.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-03-SUMMARY.md b/.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-03-SUMMARY.md
new file mode 100644
index 0000000..9406ee9
--- /dev/null
+++ b/.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-03-SUMMARY.md
@@ -0,0 +1,136 @@
+---
+phase: 18-auto-timezone-detection-and-ability-to-change-timezone
+plan: "03"
+subsystem: api
+tags: [timezone, broker, reminder-scheduler, outbox-worker, tdd, drizzle]
+
+# Dependency graph
+requires:
+ - phase: 18-auto-timezone-detection-and-ability-to-change-timezone
+ plan: "01"
+ provides: getHouseholdTimezone(db) — the accessor imported at all three rewired sites
+provides:
+ - reminderScheduler.ts all-day branch reads stored household_timezone via getHouseholdTimezone(db)
+ - outboxWorker.ts UPDATE all-day branch reads stored household_timezone via getHouseholdTimezone(db)
+ - outboxWorker.ts CREATE all-day branch reads stored household_timezone via getHouseholdTimezone(db)
+
+affects:
+ - 18-04-PLAN (PWA settings UI — all three rewired sites now respect the timezone set via Plan 18-02 admin API)
+
+# Tech tracking
+tech-stack:
+ added: []
+ patterns:
+ - getHouseholdTimezone(db) imported and awaited at three all-day broker sites (replacing bare process.env.TZ ?? Intl)
+ - Mock chain extension: makeAppConfigSelectMock for where().limit() chain (different from makeSelectMock which handles innerJoin chains)
+ - mockTwoQueries updated to mock the new third db.select() call, keeping existing tests green via D-06 fallback
+ - wireMockChain() extended in outboxWorker.test.ts to handle app_config table with where().limit() returning []
+
+key-files:
+ created: []
+ modified:
+ - apps/api/src/broker/reminderScheduler.ts
+ - apps/api/src/broker/outboxWorker.ts
+ - apps/api/tests/broker/reminderScheduler.test.ts
+ - apps/api/tests/broker/outboxWorker.test.ts
+
+key-decisions:
+ - "D-05 wired: three all-day sites replaced bare process.env.TZ ?? Intl with await getHouseholdTimezone(db)"
+ - "D-06 preserved: getHouseholdTimezone falls back to process.env.TZ → Intl when no app_config row exists; existing tests pin process.env.TZ and pass unchanged"
+ - "D-07 enforced: eventDateTime.ts and hydrateEvents.ts not modified (verified via git diff)"
+ - "mockTwoQueries extended to emit a third mockReturnValueOnce for the new app_config SELECT (no-row → D-06 fallback)"
+ - "mockReset() added in mockTwoQueries/mockThreeQueries to clear unconsumed once-queue entries across vi.resetModules() cycles (Vitest caches mock factory instances)"
+
+requirements-completed: [D-05, D-06, D-07]
+
+# Metrics
+duration: 28min
+completed: 2026-06-15
+---
+
+# Phase 18 Plan 03: Broker Rewire — All-Day TZ Sites Summary
+
+**Three all-day "9 AM local" reminder call sites in reminderScheduler.ts and outboxWorker.ts now route through `getHouseholdTimezone(db)` instead of the bare `process.env.TZ ?? Intl` expression, making the stored household_timezone the source of truth for all-day reminder fire times (D-05/D-06/D-07)**
+
+## Performance
+
+- **Duration:** 28 min
+- **Started:** 2026-06-15T02:04:00Z
+- **Completed:** 2026-06-15T02:32:01Z
+- **Tasks:** 2 (TDD RED + GREEN)
+- **Files modified:** 4
+
+## Accomplishments
+
+- Rewired `reminderScheduler.ts` line 247: `const serverTz = await getHouseholdTimezone(db);`
+- Rewired `outboxWorker.ts` UPDATE branch (~line 501): `const tz = await getHouseholdTimezone(db);`
+- Rewired `outboxWorker.ts` CREATE branch (~line 607): `const tz = await getHouseholdTimezone(db);`
+- Added 4 new RED tests (2 scheduler + 2 outbox) verifying stored 'America/Chicago' drives the 9 AM alert instant; all correctly FAIL before the rewire
+- Extended test mock infrastructure: `makeAppConfigSelectMock`, `mockThreeQueries`, updated `mockTwoQueries` to add third call for D-06 fallback path, updated `wireMockChain()` in outboxWorker.test.ts for app_config table
+- All 76 broker tests pass after GREEN commit; TypeScript typecheck (`tsc --noEmit`) clean
+- D-07 boundary confirmed: `eventDateTime.ts` and `hydrateEvents.ts` not in changed-files list
+
+## Task Commits
+
+1. **Task 1: RED — failing stored-TZ all-day tests** - `94daca3` (test)
+2. **Task 2: GREEN — rewire all three all-day TZ call sites** - `c80845c` (feat)
+
+## Files Created/Modified
+
+- `apps/api/src/broker/reminderScheduler.ts` — import added + line 247 rewired to `await getHouseholdTimezone(db)`
+- `apps/api/src/broker/outboxWorker.ts` — import added + lines ~501 and ~607 rewired to `await getHouseholdTimezone(db)`
+- `apps/api/tests/broker/reminderScheduler.test.ts` — `makeAppConfigSelectMock`, `mockThreeQueries` helpers added; `mockTwoQueries` extended to mock the new third db.select(); Plan 18-03 describe block with 2 stored-TZ tests
+- `apps/api/tests/broker/outboxWorker.test.ts` — `wireMockChain()` extended for app_config table; `wireMockChainWithTz()` helper for D-05 stored-TZ tests; Plan 18-03 describe block with 2 stored-TZ tests (create + update branch)
+
+## Decisions Made
+
+- `mockTwoQueries` was extended (not renamed) to avoid updating 20+ call sites. The third mock call returns an empty app_config row (no stored TZ → D-06 fallback), which is transparent to all existing tests that pin `process.env.TZ`.
+- `mockReset()` added inside `mockTwoQueries` and `mockThreeQueries` to flush any unconsumed `mockReturnValueOnce` entries. Vitest caches mock factory instances across `vi.resetModules()` cycles, so the pending third entry from one test bleeds into the next test's queue without an explicit reset.
+- The outbox UPDATE branch test uses `mockWhereCalEvents.mockResolvedValue([{etag: 'W/"abc"'}])` after `wireMockChainWithTz` to drive the freshEtagRows path into the all-day VALARM branch (no rawVevent → falls through to the explicit-reminder/allDay condition).
+
+## Deviations from Plan
+
+### Auto-fixed Issues
+
+**1. [Rule 1 - Bug] vi.fn() queue contamination across tests with vi.resetModules()**
+- **Found during:** Task 1 (RED) — second scheduler test crashed with "innerJoin is not a function"
+- **Issue:** `vi.clearAllMocks()` does not flush `mockReturnValueOnce` queues. Vitest's mock factory for `vi.mock()` returns the SAME `vi.fn()` instance across module resets, so an unconsumed third entry from the first test bled into the first call slot of the second test — causing `makeAppConfigSelectMock` to be returned where `makeSelectMock` was expected.
+- **Fix:** Added `db.select.mockReset()` at the start of both `mockTwoQueries` and `mockThreeQueries` to explicitly clear the queue before configuring new once-values.
+- **Files modified:** `apps/api/tests/broker/reminderScheduler.test.ts`
+
+**2. [Rule 2 - Missing critical functionality] wireMockChain() in outboxWorker.test.ts didn't handle app_config**
+- **Found during:** Task 2 (GREEN) — the existing CAL-13 all-day test would have broken after the rewire because `app_config` fell through to `{ where: mockWherePending }`, and `mockWherePending(...)` returns a Promise; calling `.limit(1)` on a Promise throws "limit is not a function"
+- **Fix:** Extended `wireMockChain()` to handle `app_config` table with a `{ where: fn → {limit: fn} }` chain returning empty rows (D-06 fallback), matching the `getHouseholdTimezone` SELECT chain
+- **Files modified:** `apps/api/tests/broker/outboxWorker.test.ts`
+
+**3. [Rule 2 - Missing critical functionality] mockTwoQueries needed a third mock for the new db.select() call**
+- **Found during:** Task 2 (GREEN) — all 20 existing scheduler tests that call `mockTwoQueries` started failing because `runReminderCheck` now issues a third `db.select()` for `getHouseholdTimezone`; the undefined return caused crashes
+- **Fix:** Updated `mockTwoQueries` to add a third `mockReturnValueOnce(makeAppConfigSelectMock(null))` returning no row, keeping the D-06 fallback path for all existing tests
+- **Files modified:** `apps/api/tests/broker/reminderScheduler.test.ts`
+
+## Known Stubs
+
+None — all three rewired sites now read from the actual DB through `getHouseholdTimezone(db)`. No placeholder values.
+
+## Threat Flags
+
+None — no new network endpoints, auth paths, file access patterns, or schema changes. The three rewired sites are read-only DB lookups within the trusted server process (T-18-08 disposition: mitigate → validated values only reach computeAlertInstantUtc via the Plan 01 accessor that enforces the D-06 fallback chain).
+
+## TDD Gate Compliance
+
+- RED gate: `94daca3` — `test(18-03): add failing stored-TZ all-day tests for scheduler + outbox`
+- GREEN gate: `c80845c` — `feat(18-03): route all-day reminder TZ through stored household_timezone`
+- REFACTOR gate: N/A (implementation was clean on first pass; deviations were auto-fixed inline)
+
+## Self-Check: PASSED
+
+- `apps/api/src/broker/reminderScheduler.ts` — FOUND
+- `apps/api/src/broker/outboxWorker.ts` — FOUND
+- `apps/api/tests/broker/reminderScheduler.test.ts` — FOUND
+- `apps/api/tests/broker/outboxWorker.test.ts` — FOUND
+- Commit `94daca3` — FOUND
+- Commit `c80845c` — FOUND
+
+---
+*Phase: 18-auto-timezone-detection-and-ability-to-change-timezone*
+*Completed: 2026-06-15*
diff --git a/.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-04-PLAN.md b/.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-04-PLAN.md
new file mode 100644
index 0000000..1bf6f0e
--- /dev/null
+++ b/.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-04-PLAN.md
@@ -0,0 +1,187 @@
+---
+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 `` 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 `` +
+ `
+
+ 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).
+
+
+
diff --git a/.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-04-SUMMARY.md b/.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-04-SUMMARY.md
new file mode 100644
index 0000000..717c3cb
--- /dev/null
+++ b/.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-04-SUMMARY.md
@@ -0,0 +1,126 @@
+---
+phase: 18-auto-timezone-detection-and-ability-to-change-timezone
+plan: "04"
+subsystem: pwa
+tags: [timezone, iana, admin, react, tanstack-query, playwright, ui]
+
+# Dependency graph
+requires:
+ - phase: 18-02
+ provides: GET/PUT /api/admin/config/timezone (consumed by fetchAdminTimezone/setAdminTimezone)
+
+provides:
+ - fetchAdminTimezone() — GET /api/admin/config/timezone in PWA API client
+ - setAdminTimezone(tz) — PUT /api/admin/config/timezone in PWA API client
+ - AdminTimezoneResponse interface (timezone: string, isExplicitlySet: boolean)
+ - Timezone section in AdminPage (/admin) — searchable IANA picker, save, detected-zone affordance
+
+affects:
+ - apps/pwa/src/api/client.ts
+ - apps/pwa/src/routes/AdminPage.tsx
+ - apps/pwa/e2e/timezone-verify.spec.ts (new verification spec)
+
+# Tech tracking
+tech-stack:
+ added: []
+ patterns:
+ - Intl.supportedValuesOf('timeZone') guarded for runtime availability (datalist population)
+ - ARIA combobox role for — Playwright locator uses getByRole('combobox') not getByRole('textbox')
+ - useQuery + useMutation with invalidateQueries on success (same pattern as calendars section)
+
+key-files:
+ created:
+ - apps/pwa/e2e/timezone-verify.spec.ts
+ modified:
+ - apps/pwa/src/api/client.ts
+ - apps/pwa/src/routes/AdminPage.tsx
+
+key-decisions:
+ - "Removed AdminTimezoneResponse from AdminPage.tsx import list — ESLint no-unused-vars; type is inferred from useQuery return"
+ - "Input with list= attribute has ARIA combobox role in Chromium, not textbox — Playwright e2e uses getByRole('combobox')"
+ - "timezoneInput state is null when the user has not interacted — effectiveTimezoneInput = timezoneInput ?? storedTimezone preserves stored value as pre-fill"
+ - "setTimezoneInput(null) on mutation success resets local override so save button re-disables to match new stored value"
+
+requirements-completed: [D-02, D-04]
+
+# Metrics
+duration: 15min
+completed: 2026-06-15
+---
+
+# Phase 18 Plan 04: Admin Timezone UI Summary
+
+**Searchable IANA timezone picker in /admin Settings — fetchAdminTimezone/setAdminTimezone in client.ts + Timezone section in AdminPage.tsx — verified end-to-end via Playwright with the real 18-02 endpoints**
+
+## Performance
+
+- **Duration:** ~15 min
+- **Started:** 2026-06-15T02:30:00Z
+- **Completed:** 2026-06-15T02:45:03Z
+- **Tasks:** 3 (2 auto + 1 playwright-verified checkpoint)
+- **Files modified/created:** 3
+
+## Accomplishments
+
+- Added `AdminTimezoneResponse` interface + `fetchAdminTimezone()` + `setAdminTimezone()` to `apps/pwa/src/api/client.ts`, following the exact pattern of `fetchAdminCalendars` / `setSharedCalendar` (credentials: include, redirect: manual, handleAuthResponse)
+- Added Timezone section to `AdminPage.tsx` after the Shared Calendar section (with `marginBottom` on the preceding section for spacing)
+- Timezone section features: 4-state loading/error/data pattern; `` + `