Merge remote-tracking branch 'origin/main' into gsd/phase-12-initial-setup-wizard

# Conflicts:
#	.planning/ROADMAP.md
#	.planning/STATE.md
This commit is contained in:
Lucas Berger
2026-06-15 13:30:32 -04:00
34 changed files with 4528 additions and 41 deletions
@@ -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"
---
<objective>
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.
</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-RESEARCH.md
@.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-PATTERNS.md
</context>
<tasks>
<task type="tdd" tdd="true">
<name>Task 1: RED — failing unit tests for getHouseholdTimezone fallback chain + isValidIanaTimezone</name>
<files>apps/api/tests/lib/householdTimezone.test.ts</files>
<read_first>
- 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)
</read_first>
<behavior>
- 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'.
</behavior>
<action>
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`.
</action>
<verify>
<automated>pnpm --filter @familysync/api exec vitest run tests/lib/householdTimezone.test.ts 2>&1 | grep -qi "fail\|error\|cannot find" && echo RED-OK</automated>
</verify>
<acceptance_criteria>
- 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.
</acceptance_criteria>
<done>RED gate met: failing test committed, fails for the right reason (no implementation).</done>
</task>
<task type="tdd" tdd="true">
<name>Task 2: GREEN — implement getHouseholdTimezone + isValidIanaTimezone</name>
<files>apps/api/src/lib/householdTimezone.ts</files>
<read_first>
- 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)
</read_first>
<action>
Create `apps/api/src/lib/householdTimezone.ts`. Export `async getHouseholdTimezone(db: MySql2Database<typeof schema>): Promise<string>` 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`.
</action>
<verify>
<automated>pnpm --filter @familysync/api exec vitest run tests/lib/householdTimezone.test.ts</automated>
</verify>
<acceptance_criteria>
- `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.
</acceptance_criteria>
<done>GREEN gate met: implementation passes all unit tests; typecheck clean.</done>
</task>
</tasks>
<threat_model>
## 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. |
</threat_model>
<verification>
- `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).
</verification>
<success_criteria>
- `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.
</success_criteria>
<output>
Create `.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-01-SUMMARY.md` when done.
</output>
@@ -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*
@@ -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"
---
<objective>
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.
</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-RESEARCH.md
@.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-PATTERNS.md
@apps/api/src/lib/householdTimezone.ts
</context>
<tasks>
<task type="tdd" tdd="true">
<name>Task 1: RED — failing integration tests for the three timezone endpoints</name>
<files>apps/api/tests/routes/admin.test.ts</files>
<read_first>
- 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)
</read_first>
<behavior>
- 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).
</behavior>
<action>
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`.
</action>
<verify>
<automated>pnpm --filter @familysync/api exec vitest run tests/routes/admin.test.ts 2>&1 | grep -qi "fail\|404" && echo RED-OK</automated>
</verify>
<acceptance_criteria>
- 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.
</acceptance_criteria>
<done>RED gate met for the endpoint contract.</done>
</task>
<task type="tdd" tdd="true">
<name>Task 2: GREEN — implement GET/PUT/seed timezone endpoints on adminRouter</name>
<files>apps/api/src/routes/admin.ts</files>
<read_first>
- 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)
</read_first>
<action>
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: <bool> }, 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`.
</action>
<verify>
<automated>pnpm --filter @familysync/api exec vitest run tests/routes/admin.test.ts</automated>
</verify>
<acceptance_criteria>
- 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.
</acceptance_criteria>
<done>GREEN gate met: endpoints enforce requireAdmin + IANA validation + no-overwrite seed.</done>
</task>
</tasks>
<threat_model>
## 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). |
</threat_model>
<verification>
- `pnpm --filter @familysync/api exec vitest run tests/routes/admin.test.ts` green.
- `pnpm --filter @familysync/api test -- --run` green (no regression).
</verification>
<success_criteria>
- 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.
</success_criteria>
<output>
Create `.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-02-SUMMARY.md` when done.
</output>
@@ -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*
@@ -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\\)"
---
<objective>
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.
</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-RESEARCH.md
@.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-PATTERNS.md
@apps/api/src/lib/householdTimezone.ts
</context>
<tasks>
<task type="tdd" tdd="true">
<name>Task 1: RED — failing stored-TZ all-day tests for scheduler + outbox</name>
<files>apps/api/tests/broker/reminderScheduler.test.ts, apps/api/tests/broker/outboxWorker.test.ts</files>
<read_first>
- apps/api/tests/broker/reminderScheduler.test.ts (lines ~656729: 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)
</read_first>
<behavior>
- 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).
</behavior>
<action>
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`.
</action>
<verify>
<automated>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</automated>
</verify>
<acceptance_criteria>
- 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.
</acceptance_criteria>
<done>RED gate met: stored-TZ tests fail because the call sites are not yet rewired.</done>
</task>
<task type="tdd" tdd="true">
<name>Task 2: GREEN — rewire all three all-day TZ call sites through getHouseholdTimezone(db)</name>
<files>apps/api/src/broker/reminderScheduler.ts, apps/api/src/broker/outboxWorker.ts</files>
<read_first>
- 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)
</read_first>
<action>
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`.
</action>
<verify>
<automated>pnpm --filter @familysync/api exec vitest run tests/broker/reminderScheduler.test.ts tests/broker/outboxWorker.test.ts</automated>
</verify>
<acceptance_criteria>
- `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.
</acceptance_criteria>
<done>GREEN gate met: stored TZ drives all three all-day sites; D-06 fallback + D-07 boundary intact.</done>
</task>
</tasks>
<threat_model>
## 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). |
</threat_model>
<verification>
- `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.
</verification>
<success_criteria>
- 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.
</success_criteria>
<output>
Create `.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-03-SUMMARY.md` when done.
</output>
@@ -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*
@@ -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"
---
<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>
@@ -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 <input type="text" list="..."> — 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; `<input type="text" list="iana-zones">` + `<datalist>` from `Intl.supportedValuesOf('timeZone')` (guarded); "Using system default" notice when `isExplicitlySet=false`; "Use detected: {zone}" affordance (D-02); disabled Save when pending or unchanged; `timezoneMutation.mutate(tz)` on save; `invalidateQueries(['admin','timezone'])` on success
- Created `apps/pwa/e2e/timezone-verify.spec.ts` with 6 desktop Playwright tests — all 6 pass against the live 18-02 API endpoints, including the save + persist across reload flow
## Task Commits
1. **Task 1: Add fetchAdminTimezone + setAdminTimezone to PWA API client** - `57424e6` (feat)
2. **Task 2: Add Timezone section to AdminPage** - `43d6689` (feat)
3. **Task 3: Playwright timezone round-trip e2e spec** - `3013b53` (test)
## Files Created/Modified
- `apps/pwa/src/api/client.ts` — added `AdminTimezoneResponse` interface + `fetchAdminTimezone()` + `setAdminTimezone()` in the `/api/admin/*` section
- `apps/pwa/src/routes/AdminPage.tsx` — added `timezoneQuery`, `timezoneMutation`, `timezoneInput` state, `detectedTz`, computed values, `<section aria-label="Timezone">` with IANA picker + affordances + save button
- `apps/pwa/e2e/timezone-verify.spec.ts` — new 6-test Playwright spec verifying the full round-trip
## Decisions Made
- `AdminTimezoneResponse` type import removed from `AdminPage.tsx``@typescript-eslint/no-unused-vars` flagged it (type is inferred from `useQuery` return value, not used as an explicit annotation). ESLint clean.
- `<input type="text" list="iana-zones">` has ARIA `combobox` role (not `textbox`) in Chromium — discovered via Playwright page snapshot. Updated e2e locators to `getByRole('combobox')`.
- The `timezoneInput` state variable is `null` when the user hasn't typed anything; `effectiveTimezoneInput = timezoneInput ?? storedTimezone` ensures the input shows the stored value on load without the save button enabling prematurely.
- After mutation success, `setTimezoneInput(null)` resets the local override so the save button re-disables (effectiveTimezoneInput collapses back to the now-updated storedTimezone).
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] Removed unused `AdminTimezoneResponse` import**
- **Found during:** Task 2 (ESLint check)
- **Issue:** `type AdminTimezoneResponse` was imported but not used as an explicit type annotation; ESLint `no-unused-vars` reported it as an error
- **Fix:** Removed from the import line; the type is inferred via `useQuery`
- **Files modified:** `apps/pwa/src/routes/AdminPage.tsx`
- **Commit:** `43d6689`
**2. [Rule 3 - Blocking] API dev container serving stale compiled code**
- **Found during:** Task 3 (playwright verification)
- **Issue:** The Docker container running the API had the pre-Plan-18-02 `dist/routes/admin.js` baked in — the timezone endpoints returned 404. The `--watch dist/index.js` watcher only restarts on `index.js` change, not sub-module changes.
- **Fix:** Rebuilt the API (`pnpm --filter @familysync/api build`), then `docker cp` the updated `dist/routes/admin.js`, `dist/lib/householdTimezone.js`, and `dist/index.js` into the container to trigger a restart.
- **Files modified:** container-only (not tracked in git)
- **Commit:** N/A (operational, not code)
## Known Stubs
None — the Timezone section is fully wired to the real `GET /api/admin/config/timezone` and `PUT /api/admin/config/timezone` endpoints (Plan 18-02). No placeholder data.
## Threat Flags
No new threat surface beyond the plan's threat model. T-18-11 (client isAdmin gate is UX only), T-18-12 (server validates IANA via Plan 02 Zod refine), T-18-13 (plain-text JSX children, no dangerouslySetInnerHTML).
## Self-Check: PASSED
- `apps/pwa/src/api/client.ts` — FOUND (modified, exports fetchAdminTimezone + setAdminTimezone + AdminTimezoneResponse)
- `apps/pwa/src/routes/AdminPage.tsx` — FOUND (modified, contains `aria-label="Timezone"`)
- `apps/pwa/e2e/timezone-verify.spec.ts` — FOUND (created, 6 desktop tests all pass)
- Commit `57424e6` — FOUND
- Commit `43d6689` — FOUND
- Commit `3013b53` — FOUND
---
*Phase: 18-auto-timezone-detection-and-ability-to-change-timezone*
*Completed: 2026-06-15*
@@ -0,0 +1,451 @@
# Phase 18: Auto Timezone Detection and Ability to Change Timezone - Pattern Map
**Mapped:** 2026-06-14
**Files analyzed:** 6 (1 new lib helper, 2 broker modifications, 1 route extension, 1 client extension, 1 PWA page extension)
**Analogs found:** 6 / 6
---
## File Classification
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|-------------------|------|-----------|----------------|---------------|
| `apps/api/src/lib/householdTimezone.ts` | utility | request-response (DB read) | `apps/api/src/lib/requireAdmin.ts` | role-match (both: small lib helper, single DB read, typed return) |
| `apps/api/src/routes/admin.ts` (extend) | route/controller | request-response | `apps/api/src/routes/admin.ts` (existing GET/PUT routes) | exact |
| `apps/api/src/broker/reminderScheduler.ts` (modify line 247) | broker/worker | batch | same file, all-day branch context | exact |
| `apps/api/src/broker/outboxWorker.ts` (modify lines 501, 607) | broker/worker | batch | same file, all-day branches | exact |
| `apps/pwa/src/api/client.ts` (extend) | client utility | request-response | same file, `fetchAdminCalendars` / `setSharedCalendar` | exact |
| `apps/pwa/src/routes/AdminPage.tsx` (extend) | component | request-response | same file, Shared Calendar section | exact |
---
## Pattern Assignments
### `apps/api/src/lib/householdTimezone.ts` (NEW — utility, DB read)
**Analog:** `apps/api/src/lib/requireAdmin.ts`
**Why this analog:** `requireAdmin.ts` is the project's only existing single-purpose lib helper that performs a single Drizzle `SELECT … .limit(1)` lookup from a DB table, matches on a PK-style key, and returns a typed result. The import style, Drizzle usage pattern, and file structure all transfer directly.
**Imports pattern** (`requireAdmin.ts` lines 1723):
```typescript
import '../auth/devBypass.js'; // side-effect import pattern — omit for householdTimezone.ts
import type { MiddlewareHandler } from 'hono';
import { eq } from 'drizzle-orm';
import { db } from '../db/client.js';
import { users } from '../db/schema.js';
```
For `householdTimezone.ts`, adapt imports to:
```typescript
import { eq } from 'drizzle-orm';
import type { MySql2Database } from 'drizzle-orm/mysql2';
import type * as schema from '../db/schema.js';
import { appConfig } from '../db/schema.js';
```
**Core DB read pattern** (`requireAdmin.ts` lines 3741):
```typescript
const [row] = await db
.select({ isAdmin: users.isAdmin })
.from(users)
.where(eq(users.id, userId))
.limit(1);
if (!row?.isAdmin) { ... }
```
Adapt to `appConfig` PK lookup:
```typescript
const [row] = await db
.select({ value: appConfig.value })
.from(appConfig)
.where(eq(appConfig.key, 'household_timezone'))
.limit(1);
return (
row?.value ??
process.env.TZ ??
Intl.DateTimeFormat().resolvedOptions().timeZone
);
```
**D-06 fallback chain:** The fallback `process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone` mirrors the exact pattern currently at `reminderScheduler.ts:247` and `outboxWorker.ts:501,607`. It must be preserved verbatim as the fallback so existing tests (which pin `process.env.TZ`) continue to pass when no DB row is found.
**IANA validator (same file):** No analog exists in the codebase — use `try/catch Intl.DateTimeFormat` (no external lib, no `Intl.supportedValuesOf` — see Research Pitfall 2 for why):
```typescript
export function isValidIanaTimezone(tz: string): boolean {
try {
Intl.DateTimeFormat(undefined, { timeZone: tz });
return true;
} catch {
return false;
}
}
```
---
### `apps/api/src/routes/admin.ts` — extend with GET + PUT `/config/timezone`
**Analog:** `apps/api/src/routes/admin.ts` — existing `GET /calendars` (lines 130140) and `PUT /calendars/:id/shared` (lines 150180).
**Security guard pattern** (`admin.ts` lines 3941 — DO NOT MOVE):
```typescript
// Pitfall 9: requireAdmin MUST be the first statement on the router.
// All sub-routes are protected — no path can be reached without passing this guard.
adminRouter.use('*', requireAdmin);
```
New routes are appended **after** all existing routes. `adminRouter.use('*', requireAdmin)` already covers them positionally in Hono.
**Simple GET pattern** (`admin.ts` lines 130140 — exact model for GET `/config/timezone`):
```typescript
adminRouter.get('/calendars', async (c) => {
const rows = await db
.select({
id: calendars.id,
displayName: calendars.displayName,
isShared: calendars.isShared,
})
.from(calendars);
return c.json({ calendars: rows });
});
```
**Validated PUT pattern** (`admin.ts` lines 150180 — model for PUT `/config/timezone`):
```typescript
adminRouter.put('/calendars/:id/shared', async (c) => {
const targetId = parseInt(c.req.param('id'), 10);
if (isNaN(targetId)) {
return c.json({ error: 'Invalid calendar id' }, 400);
}
// ... DB write ...
return c.json({ ok: true }, 200);
});
```
For the timezone PUT, use `zValidator` (already imported at line 24) instead of manual param parsing:
```typescript
adminRouter.put(
'/config/timezone',
zValidator('json', timezoneSchema),
async (c) => {
const { timezone } = c.req.valid('json');
await db
.insert(appConfig)
.values({ key: 'household_timezone', value: timezone })
.onDuplicateKeyUpdate({ set: { value: timezone } });
return c.json({ ok: true }, 200);
},
);
```
**Zod schema placement** (`admin.ts` lines 4752 — place new schema alongside existing schemas):
```typescript
const credentialSchema = z.object({
userId: z.number().int().positive(),
// ...
});
```
New `timezoneSchema` goes in the same "Zod schema" block:
```typescript
const timezoneSchema = z.object({
timezone: z
.string()
.min(1)
.max(64)
.refine(isValidIanaTimezone, { message: 'Invalid IANA timezone identifier' }),
});
```
**Import additions needed** (`admin.ts` line 28 — add `appConfig` to schema imports; add `householdTimezone.ts` exports):
```typescript
import { users, memberCredentials, calendars, appConfig } from '../db/schema.js';
import { isValidIanaTimezone, getHouseholdTimezone } from '../lib/householdTimezone.js';
```
**noEchoHook:** NOT needed for timezone routes. Timezone strings are non-sensitive (RESEARCH.md Security Domain). Standard `zValidator` without a custom hook is correct.
---
### `apps/api/src/broker/reminderScheduler.ts` — modify line 247
**Analog:** Same file, same function (`runReminderCheck`).
**Current pattern at line 247** (verified by research):
```typescript
const serverTz = process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
```
**Replacement pattern:**
```typescript
const serverTz = await getHouseholdTimezone(db);
```
The surrounding call at line 256 (`computeAlertInstantUtc(dtstartDate, leadDays, serverTz)`) is unchanged — `serverTz` remains a `string`, the contract is identical.
**Import to add** (top of `reminderScheduler.ts`):
```typescript
import { getHouseholdTimezone } from '../lib/householdTimezone.js';
```
**DB parameter:** `reminderScheduler.ts` already has `db` in scope in the `runReminderCheck` function (Drizzle queries are present elsewhere in the file). Pass it directly to `getHouseholdTimezone(db)`.
---
### `apps/api/src/broker/outboxWorker.ts` — modify lines 501 and 607
**Analog:** Same file, same function (`runOutboxDrain`), two all-day branches.
**Current pattern at both sites** (verified by research):
```typescript
// Line 501 (update branch):
const tz = process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
const leadDays = fields.reminderLeadMinutes / 1440;
allDayAlertInstantUtcUpdate = computeAlertInstantUtc(fields.start, leadDays, tz);
// Line 607 (create branch):
const tz = process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
const leadDays = fields.reminderLeadMinutes / 1440;
allDayAlertInstantUtcCreate = computeAlertInstantUtc(fields.start, leadDays, tz);
```
**Replacement at both sites:**
```typescript
const tz = await getHouseholdTimezone(db);
```
Both sites are in the same `runOutboxDrain` function. If both branches can be reached in a single call, consider computing `tz` once at the top of the all-day processing block and reusing it. The surrounding calls to `computeAlertInstantUtc` are unchanged.
**Import to add** (top of `outboxWorker.ts`):
```typescript
import { getHouseholdTimezone } from '../lib/householdTimezone.js';
```
---
### `apps/pwa/src/api/client.ts` — extend with admin timezone functions
**Analog:** Same file, `fetchAdminCalendars` (lines 445454) and `setSharedCalendar` (lines 462469).
**GET fetch pattern** (`client.ts` lines 445454 — exact model):
```typescript
export async function fetchAdminCalendars(): Promise<AdminCalendarsResponse> {
const res = await fetch('/api/admin/calendars', {
credentials: 'include',
redirect: 'manual',
});
handleAuthResponse(res, 'GET /api/admin/calendars');
return res.json() as Promise<AdminCalendarsResponse>;
}
```
Adapt to timezone:
```typescript
export interface AdminTimezoneResponse {
timezone: string;
isExplicitlySet: boolean;
}
export async function fetchAdminTimezone(): Promise<AdminTimezoneResponse> {
const res = await fetch('/api/admin/config/timezone', {
credentials: 'include',
redirect: 'manual',
});
handleAuthResponse(res, 'GET /api/admin/config/timezone');
return res.json() as Promise<AdminTimezoneResponse>;
}
```
**PUT fetch pattern** (`client.ts` lines 462469 — model for setAdminTimezone; note `setSharedCalendar` has no body, so also borrow the body pattern from `saveCredential` at lines 429439):
```typescript
export async function setAdminTimezone(timezone: string): Promise<void> {
const res = await fetch('/api/admin/config/timezone', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
redirect: 'manual',
body: JSON.stringify({ timezone }),
});
handleAuthResponse(res, 'PUT /api/admin/config/timezone');
}
```
**Placement:** Append in the `// ── /api/admin/* ──` section (after line 469), before the `saveMyCredential` function.
---
### `apps/pwa/src/routes/AdminPage.tsx` — extend with Timezone section
**Analog:** Same file, Shared Calendar section (lines 182288).
**Query pattern** (`AdminPage.tsx` lines 7277 — exact model):
```typescript
const calendarsQuery = useQuery({
queryKey: ['admin', 'calendars'],
queryFn: fetchAdminCalendars,
retry: false,
staleTime: 60 * 1000,
});
```
Adapt:
```typescript
const timezoneQuery = useQuery({
queryKey: ['admin', 'timezone'],
queryFn: fetchAdminTimezone,
retry: false,
staleTime: 60 * 1000,
});
```
**Mutation + invalidation pattern** (`AdminPage.tsx` lines 8694 — exact model):
```typescript
const sharedCalMutation = useMutation({
mutationFn: (calId: number) => setSharedCalendar(calId),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['admin', 'calendars'] });
void queryClient.invalidateQueries({ queryKey: ['events'] });
setSelectedCalendarId(null);
},
});
```
Adapt:
```typescript
const timezoneMutation = useMutation({
mutationFn: (tz: string) => setAdminTimezone(tz),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['admin', 'timezone'] });
},
});
```
**Section structure** (`AdminPage.tsx` lines 182288 — use as template):
```tsx
<section aria-label="Shared Calendar">
<div style={sectionLabelStyle}>Shared Calendar</div>
{/* loading, error, empty, data states */}
</section>
```
New section follows the same four-state pattern (loading, error, empty/unset, data). Reuse `sectionLabelStyle` (defined at line 4047) without modification. Add `marginBottom: 'var(--space-8, 32px)'` to the preceding section to separate from the new one.
**Save button pattern** (`AdminPage.tsx` lines 244285):
```tsx
<button
type="button"
disabled={saveDisabled}
onClick={() => { /* mutate */ }}
style={{
background: saveDisabled ? 'var(--color-border, #E2E4E9)' : 'var(--color-member-0, #4A90D9)',
color: '#ffffff',
border: 'none',
cursor: saveDisabled ? 'default' : 'pointer',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
minHeight: '44px',
minWidth: '44px',
padding: '0 var(--space-6, 24px)',
borderRadius: 'var(--space-1, 4px)',
fontFamily: 'var(--font-family-base)',
transition: 'background 0.15s ease',
}}
>
{mutation.isPending ? 'Saving…' : 'Save'}
</button>
```
**IANA picker UX — no library:** Use `<input type="text" list="iana-zones">` + `<datalist>` populated from `Intl.supportedValuesOf('timeZone')`. This is consistent with the project's hand-rolled inline-styles convention (no component library). Initial value of the input: `timezoneQuery.data?.timezone ?? ''` (shows stored value or fallback). The `isExplicitlySet` flag from the API response can display a subtle "using system default" note when `false`.
**Import additions for AdminPage.tsx:**
```typescript
import {
fetchAdminTimezone,
setAdminTimezone,
type AdminTimezoneResponse,
} from '../api/client.js';
```
---
## Shared Patterns
### Admin Route Guard
**Source:** `apps/api/src/routes/admin.ts` line 41
**Apply to:** All new routes in `admin.ts` (inherited automatically — no per-route addition needed)
```typescript
adminRouter.use('*', requireAdmin);
// This single middleware statement covers ALL routes registered on adminRouter,
// including appended routes. Do not add requireAdmin inline to individual handlers.
```
### Drizzle App Config Upsert (MariaDB)
**Source:** Established pattern from `admin.ts` + Drizzle mysql2 dialect (verified in RESEARCH.md)
**Apply to:** PUT `/config/timezone` handler and the seeding helper
```typescript
await db
.insert(appConfig)
.values({ key: 'household_timezone', value: timezone })
.onDuplicateKeyUpdate({ set: { value: timezone } });
```
Note: `app_config.key` is the PK (`varchar(128).primaryKey()`), so this is a true upsert. `drizzle-orm@0.45.2` + `mysql2@3.22.4` support this syntax natively.
### Drizzle Single-Row PK Lookup
**Source:** `apps/api/src/lib/requireAdmin.ts` lines 3741
**Apply to:** `getHouseholdTimezone` helper and GET `/config/timezone` handler
```typescript
const [row] = await db
.select({ value: appConfig.value })
.from(appConfig)
.where(eq(appConfig.key, 'household_timezone'))
.limit(1);
// row is undefined when no row exists — use optional chaining: row?.value
```
### Client Auth Response Handling
**Source:** `apps/pwa/src/api/client.ts` lines 5158 (`handleAuthResponse`)
**Apply to:** All new fetch wrappers in `client.ts`
```typescript
function handleAuthResponse(res: Response, label: string): void {
if (res.type === 'opaqueredirect' || res.status === 401) {
throw new SessionExpiredError();
}
if (!res.ok) {
throw new Error(`${label} failed: ${res.status}`);
}
}
// Usage: call immediately after fetch, before res.json()
handleAuthResponse(res, 'GET /api/admin/config/timezone');
```
### TanStack Query + Mutation + Invalidation
**Source:** `apps/pwa/src/routes/AdminPage.tsx` lines 6494
**Apply to:** New Timezone section in `AdminPage.tsx`
```typescript
// useQueryClient() already called at top of AdminPage — no additional call needed
const timezoneQuery = useQuery({ queryKey: ['admin', 'timezone'], queryFn: fetchAdminTimezone, retry: false, staleTime: 60 * 1000 });
const timezoneMutation = useMutation({
mutationFn: (tz: string) => setAdminTimezone(tz),
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['admin', 'timezone'] }),
});
```
---
## No Analog Found
| File | Role | Data Flow | Reason |
|------|------|-----------|--------|
| `apps/api/tests/lib/householdTimezone.test.ts` | test | — | No existing `tests/lib/` helper test exists; follow `tests/broker/reminderScheduler.test.ts` vitest structure for the unit test pattern |
---
## Metadata
**Analog search scope:** `apps/api/src/lib/`, `apps/api/src/routes/`, `apps/api/src/broker/`, `apps/pwa/src/api/`, `apps/pwa/src/routes/`
**Files read:** 6 source files
**Pattern extraction date:** 2026-06-14
@@ -0,0 +1,76 @@
# Phase 18 — Plan Index & Coverage Audit
**Created:** 2026-06-15
**Plans:** 4 (3 waves)
**Mode:** standard · TDD mode ON · MVP off
---
## Wave Structure
| Wave | Plans | Autonomous | Files (disjoint per wave) |
|------|-------|------------|----------------------------|
| 1 | 18-01 (accessor + IANA validator, TDD) | yes | `lib/householdTimezone.ts` (+test) |
| 2 | 18-02 (admin endpoints, TDD), 18-03 (broker wiring, TDD) | yes, yes | 02: `routes/admin.ts` (+test); 03: `broker/reminderScheduler.ts` + `broker/outboxWorker.ts` (+tests) — no overlap with 02 |
| 3 | 18-04 (PWA picker UI) | no (human-verify checkpoint) | `pwa/api/client.ts`, `pwa/routes/AdminPage.tsx` |
Dependency rationale: 02 and 03 both depend only on the Wave-1 accessor and touch disjoint files →
parallel in Wave 2. 04 depends on the 02 endpoint contract → Wave 3.
---
## Artifacts This Phase Produces (MANDATORY)
| Symbol / Artifact | Kind | File | Plan |
|-------------------|------|------|------|
| `getHouseholdTimezone(db)` | function (async, → Promise<string>) | `apps/api/src/lib/householdTimezone.ts` | 01 |
| `isValidIanaTimezone(tz)` | function (→ boolean) | `apps/api/src/lib/householdTimezone.ts` | 01 |
| `'household_timezone'` | app_config key string (new additive row, no migration) | `apps/api/src/db/schema.ts` appConfig (existing table) | 01/02 |
| `GET /api/admin/config/timezone` | route | `apps/api/src/routes/admin.ts` | 02 |
| `PUT /api/admin/config/timezone` | route | `apps/api/src/routes/admin.ts` | 02 |
| `POST /api/admin/config/timezone/seed` | route (no-overwrite seed, D-02/D-03) | `apps/api/src/routes/admin.ts` | 02 |
| `timezoneSchema` | Zod schema | `apps/api/src/routes/admin.ts` | 02 |
| `fetchAdminTimezone()` | client fn | `apps/pwa/src/api/client.ts` | 04 |
| `setAdminTimezone(timezone)` | client fn | `apps/pwa/src/api/client.ts` | 04 |
| `AdminTimezoneResponse` | interface | `apps/pwa/src/api/client.ts` | 04 |
| Timezone section (`aria-label="Timezone"`) | UI component/section | `apps/pwa/src/routes/AdminPage.tsx` | 04 |
| `apps/api/tests/lib/householdTimezone.test.ts` | new test file | — | 01 |
Test files extended (not new): `apps/api/tests/routes/admin.test.ts` (02),
`apps/api/tests/broker/reminderScheduler.test.ts` + `apps/api/tests/broker/outboxWorker.test.ts` (03).
---
## Multi-Source Coverage Audit
### GOAL (ROADMAP Phase 18 goal)
"Auto-detect the household timezone and allow changing it" → COVERED: detection seed (D-02, Plans 02 seed
endpoint + 04 detected-zone affordance); change (D-04, Plans 02 PUT + 04 picker); behavioral wiring
(D-05, Plan 03).
### REQ (phase_req_ids from REQUIREMENTS.md)
No REQ-IDs are mapped to Phase 18 in ROADMAP.md (confirmed: "Requirements: TBD"). Per the planning
context, the D-01..D-07 decision set is the coverage contract — see CONTEXT below. Not a gap.
### RESEARCH (18-RESEARCH.md features/constraints)
- Single accessor `getHouseholdTimezone` + IANA validator → Plan 01.
- GET/PUT endpoints on adminRouter, no new router → Plan 02.
- Seed endpoint, additive/non-blocking (Phase 12 not yet executed) → Plan 02.
- Rewire reminderScheduler:247 + outboxWorker:501,607 → Plan 03.
- PWA picker, no new package, Intl-based → Plan 04.
- No new npm installs (Package Legitimacy Audit: zero) → honored across all plans; T-18-SC = no-op.
### CONTEXT (D-01..D-07 — the trackable decision contract)
| Decision | Covered by | Where |
|----------|-----------|-------|
| D-01 single household app_config key `household_timezone` | Plan 01 (key literal), Plan 02 (upsert) | accessor + endpoints |
| D-02 browser-detect + seed at first run | Plan 02 (seed endpoint), Plan 04 (detected-zone affordance) | endpoints + UI |
| D-03 no auto-overwrite after seed | Plan 02 (seed writes only if unset) | seed endpoint |
| D-04 change via role-gated /admin Settings | Plan 02 (requireAdmin endpoints), Plan 04 (Timezone section) | endpoints + UI |
| D-05 stored TZ = source of truth, single accessor at both scheduler sites | Plan 01 (accessor), Plan 03 (both sites route through it) | accessor + broker |
| D-06 fallback chain when unset | Plan 01 (verbatim fallback), Plan 03 (existing tests stay green) | accessor + broker |
| D-07 do not touch display/timed-write path | Plan 03 + Plan 04 (boundary as acceptance criterion) | broker + UI |
**No unplanned items.** Deferred Ideas (per-member timezones; driving display/timed off stored TZ) are
correctly absent from all plans.
@@ -0,0 +1,732 @@
# Phase 18: Auto Timezone Detection and Ability to Change Timezone - Research
**Researched:** 2026-06-15
**Domain:** Server-side timezone configuration, Admin settings, IANA validation
**Confidence:** HIGH (all findings grounded in actual codebase inspection)
---
<user_constraints>
## User Constraints (from CONTEXT.md)
### Locked Decisions
- **D-01:** Single **household-wide** timezone, stored in `app_config` (key `household_timezone`, IANA string value). No per-member `users.timezone` column.
- **D-02:** Auto-detect the browser IANA timezone (`Intl.DateTimeFormat().resolvedOptions().timeZone`) and use it to **seed** the stored value during the Phase 12 setup wizard / first run.
- **D-03:** After seeding, timezone changes **only** via the settings UI. No auto-overwrite on later login/detection differences. (Optional drift notice allowed, not required.)
- **D-04:** Surface the timezone in the existing **role-gated `/admin` Settings** (Phase 10 `requireAdmin` boundary) and seed it from the **Phase 12 setup wizard**.
- **D-05:** The stored timezone becomes the **source of truth for the server-side all-day "9 AM local" reminder computation** (`reminderScheduler.ts`, `outboxWorker.ts`), replacing the bare `process.env.TZ ?? Intl…` lookup at those sites.
- **D-06:** **Fallback chain when `household_timezone` is unset**: fall back to `process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone`.
- **D-07:** Display rendering and timed-event write serialization stay **browser-local** and unchanged. Must NOT touch `eventDateTime.ts` or `hydrateEvents.ts`.
### Claude's Discretion
- Timezone picker UX: a searchable IANA dropdown. Validate the value is a real IANA zone before storing.
- Exact `app_config` key name and the read/cache strategy for the stored value in the scheduler/outbox (e.g. read-per-run vs cached).
- Whether to show a non-blocking "detected zone differs" notice on login (allowed per D-03, not required).
### Deferred Ideas (OUT OF SCOPE)
- Per-member timezones (would add `users.timezone` + per-row scheduler logic).
- Driving display/timed reminders off the stored tz (deliberately excluded, D-07).
</user_constraints>
---
## Summary
Phase 18 is a tightly scoped wiring change: one new `app_config` key (`household_timezone`) becomes the source of truth for the server-side all-day reminder computation, replacing two bare `process.env.TZ ?? Intl…` lookups in `reminderScheduler.ts` (line 247) and `outboxWorker.ts` (lines 501 and 607). A single shared accessor helper reads this key from the DB with the D-06 fallback, ensuring both scheduler sites stay synchronized. No new npm packages are required. No schema migration is needed — `app_config` already exists and accepts arbitrary keys as additive rows.
The admin surface (Phase 10 `adminRouter`, `requireAdmin`, `AdminPage.tsx`) already provides the exact pattern to extend: add GET + PUT endpoints to `/api/admin/config/timezone` following the same route file, client API function, TanStack Query + mutation pattern already present in `AdminPage.tsx`. The IANA validation uses a Zod `.refine()` with a `try/catch` on `Intl.DateTimeFormat` — no external library needed.
Phase 12 (Initial Setup Wizard) has NOT been executed yet. Its status is `draft` (only a UI spec exists, no route code). The `household_timezone` seeding must therefore be planned as a Phase 18 deliverable that is additive/optional — a standalone seed endpoint (`POST /api/admin/config/timezone/seed`) or an inline seed in an early Phase 18 task — so Phase 18 is not blocked. When Phase 12 eventually executes, it calls the same write endpoint.
**Primary recommendation:** Extract a `getHouseholdTimezone(db): Promise<string>` helper in `apps/api/src/lib/householdTimezone.ts`, add GET/PUT endpoints on `adminRouter`, extend `AdminPage.tsx` with a new Timezone section, and wire both scheduler sites through the helper. No new dependencies. Read-per-run (not cached) for correctness.
---
## Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| Store household timezone | Database / Storage | API | `app_config` table; PK-keyed key/value row |
| Read timezone for scheduling | API / Backend | — | `reminderScheduler.ts` + `outboxWorker.ts` run server-side; DB read at each tick |
| Validate IANA timezone string | API / Backend | Browser / Client | Server validates before write (real boundary); browser validates before submit (UX) |
| Admin read/write timezone | API / Backend | Frontend Server | `requireAdmin` is server enforced; client `isAdmin` is UX only (Phase 10 D-03) |
| Browser timezone detection | Browser / Client | — | `Intl.DateTimeFormat().resolvedOptions().timeZone` is client-side only |
| Display rendering, timed-event serialization | Browser / Client | — | Unchanged by D-07; remains browser-local |
---
## Standard Stack
### Core (all already installed — no new packages)
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| drizzle-orm | 0.45.2 | DB read/write for `app_config` | Already in stack; `eq()` + `.select()` / `.insert().onDuplicateKeyUpdate()` for upsert |
| zod | 3.25.x | IANA string validation | Already in stack; `.refine()` with `Intl.DateTimeFormat` try/catch |
| @hono/zod-validator | 0.8.0 | Route body validation | Already wired in `adminRouter` |
| hono | 4.12.23 | Route handlers | Already in stack; extend `adminRouter` |
| @tanstack/react-query | 5.101.0 | PWA data fetch + mutation | Already in `AdminPage.tsx` |
| Native `Intl` API | Node 22 built-in | IANA validation + browser detection | No package needed |
**No new npm installs required for this phase.**
### Supporting
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| `mysql2` (via drizzle) | 3.22.4 | Underlying driver | Used implicitly by drizzle; no direct use needed |
### Alternatives Considered
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| `try/catch Intl.DateTimeFormat` | `Intl.supportedValuesOf('timeZone')` membership check | `supportedValuesOf` excludes 'UTC', 'GMT', 'Etc/UTC' (verified in Node 22) — those ARE valid. The try/catch approach accepts all valid zones including UTC variants [VERIFIED: Node 22 runtime test] |
| Read-per-run DB read | In-memory TTL cache | Cache adds invalidation complexity; read-per-run means changes propagate within 60s (one scheduler tick) with no restart; PK lookup is negligible cost |
| Extend existing `adminRouter` | New router/file | The existing pattern (`adminRouter.get/put`, `requireAdmin` first, `zValidator`) is correct and established — extending is the right choice |
---
## Package Legitimacy Audit
This phase installs **no new packages**. All capabilities use packages already in the monorepo.
**Packages removed due to [SLOP] verdict:** none
**Packages flagged as suspicious [SUS]:** none (no new installs)
> Note: `hono` was flagged `SUS` by the registry scanner due to a recent publish date, but it is a locked stack choice from CLAUDE.md and already installed. This flag does not apply to existing dependencies.
---
## Architecture Patterns
### System Architecture Diagram
```
Browser (PWA) API Server MariaDB
─────────────────────────────────────────────────────────────────────────────
[AdminPage /admin] [adminRouter] [app_config]
useQuery('admin','timezone') → GET /api/admin/config/timezone → SELECT key='household_timezone'
useMutation(PUT) → PUT /api/admin/config/timezone → INSERT ... ON DUPLICATE KEY UPDATE
<TimezonePickerSection> requireAdmin (DB check)
Intl.DateTimeFormat() zod IANA validate
.resolvedOptions().timeZone → store value
(browser detection for seed)
[reminderScheduler.ts] [getHouseholdTimezone(db)] [app_config]
runReminderCheck() every 60s → SELECT key='household_timezone' → value | null
fallback: process.env.TZ ?? Intl…
pass tz → computeAlertInstantUtc()
[outboxWorker.ts] [getHouseholdTimezone(db)] [app_config]
processOutboxRow() → SELECT key='household_timezone' → value | null
(allDay branch: lines 501, 607) fallback chain (same key, same fallback)
pass tz → computeAlertInstantUtc()
```
### Recommended Project Structure
```
apps/api/src/
├── lib/
│ └── householdTimezone.ts # NEW: getHouseholdTimezone(db) helper + IANA validator
├── routes/
│ └── admin.ts # EXTEND: add GET + PUT /config/timezone endpoints
├── broker/
│ ├── reminderScheduler.ts # MODIFY: line 247 — replace bare process.env.TZ lookup
│ └── outboxWorker.ts # MODIFY: lines 501, 607 — replace bare process.env.TZ lookups
apps/pwa/src/
├── api/
│ └── client.ts # EXTEND: add fetchAdminTimezone() + setAdminTimezone()
└── routes/
└── AdminPage.tsx # EXTEND: add Timezone section after Shared Calendar section
```
### Pattern 1: Stored TZ Accessor Helper (getHouseholdTimezone)
**What:** A single exported async function that reads `household_timezone` from `app_config` and applies the D-06 fallback chain.
**When to use:** Called at the start of each all-day processing block in `reminderScheduler.ts` and `outboxWorker.ts`. Not called for timed events (those don't use local time).
**Example:**
```typescript
// apps/api/src/lib/householdTimezone.ts
import { eq } from 'drizzle-orm';
import type { MySql2Database } from 'drizzle-orm/mysql2';
import type * as schema from '../db/schema.js';
import { appConfig } from '../db/schema.js';
/**
* Read the household timezone from app_config.
* D-06 fallback: process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone
* (same fallback the bare lookups used before Phase 18).
*
* Read-per-call so timezone changes propagate within one scheduler tick
* without requiring a worker restart.
*/
export async function getHouseholdTimezone(
db: MySql2Database<typeof schema>,
): Promise<string> {
const [row] = await db
.select({ value: appConfig.value })
.from(appConfig)
.where(eq(appConfig.key, 'household_timezone'))
.limit(1);
return (
row?.value ??
process.env.TZ ??
Intl.DateTimeFormat().resolvedOptions().timeZone
);
}
/**
* Validate that a string is an IANA timezone accepted by the JS engine.
* try/catch on Intl.DateTimeFormat covers 'UTC', 'GMT', 'Etc/UTC', and all
* 418 named IANA zones. Intl.supportedValuesOf('timeZone') is NOT used because
* it excludes 'UTC' and 'Etc/*' variants in Node 22 and Chrome.
*/
export function isValidIanaTimezone(tz: string): boolean {
try {
Intl.DateTimeFormat(undefined, { timeZone: tz });
return true;
} catch {
return false;
}
}
```
**Usage in reminderScheduler.ts (line 247 replacement):**
```typescript
// BEFORE (line 247):
const serverTz = process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
// AFTER:
const serverTz = await getHouseholdTimezone(db);
```
**Usage in outboxWorker.ts (lines 501 and 607 replacement):**
```typescript
// BEFORE (line 501):
const tz = process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
// AFTER:
const tz = await getHouseholdTimezone(db);
```
### Pattern 2: Admin Endpoint (GET + PUT /api/admin/config/timezone)
**What:** Two new routes on `adminRouter`, following the exact pattern of existing admin routes. GET reads the current value (with fallback), PUT validates + upserts.
**When to use:** Admin settings UI reads/writes.
**Example:**
```typescript
// In apps/api/src/routes/admin.ts (extend existing file)
const timezoneSchema = z.object({
timezone: z.string().refine(isValidIanaTimezone, { message: 'Invalid IANA timezone' }),
});
// GET /api/admin/config/timezone
adminRouter.get('/config/timezone', async (c) => {
const [row] = await db
.select({ value: appConfig.value })
.from(appConfig)
.where(eq(appConfig.key, 'household_timezone'))
.limit(1);
const timezone =
row?.value ??
process.env.TZ ??
Intl.DateTimeFormat().resolvedOptions().timeZone;
return c.json({ timezone, isExplicitlySet: row?.value != null });
});
// PUT /api/admin/config/timezone
adminRouter.put(
'/config/timezone',
zValidator('json', timezoneSchema),
async (c) => {
const { timezone } = c.req.valid('json');
await db
.insert(appConfig)
.values({ key: 'household_timezone', value: timezone })
.onDuplicateKeyUpdate({ set: { value: timezone } });
return c.json({ ok: true });
},
);
```
### Pattern 3: PWA TanStack Query + Mutation (AdminPage extension)
**What:** A new Timezone section in `AdminPage.tsx`, mirroring the existing Shared Calendar section pattern exactly.
**Example:**
```typescript
// In apps/pwa/src/api/client.ts (extend existing file)
export interface AdminTimezoneResponse {
timezone: string;
isExplicitlySet: boolean;
}
export async function fetchAdminTimezone(): Promise<AdminTimezoneResponse> {
const res = await fetch('/api/admin/config/timezone', {
credentials: 'include',
redirect: 'manual',
});
handleAuthResponse(res, 'GET /api/admin/config/timezone');
return res.json() as Promise<AdminTimezoneResponse>;
}
export async function setAdminTimezone(timezone: string): Promise<void> {
const res = await fetch('/api/admin/config/timezone', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
redirect: 'manual',
body: JSON.stringify({ timezone }),
});
handleAuthResponse(res, 'PUT /api/admin/config/timezone');
}
```
```typescript
// In AdminPage.tsx (new section, same query/mutation pattern as calendarsQuery)
const timezoneQuery = useQuery({
queryKey: ['admin', 'timezone'],
queryFn: fetchAdminTimezone,
retry: false,
staleTime: 60 * 1000,
});
const timezoneMutation = useMutation({
mutationFn: (tz: string) => setAdminTimezone(tz),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['admin', 'timezone'] });
},
});
```
**IANA Picker UX — no new library:** A `<select>` with a `<datalist>` or a controlled `<input>` + filtered `<select>` using `Intl.supportedValuesOf('timeZone')` on the browser side. The project uses no component library (hand-rolled inline styles, project convention). A simple searchable `<select>` is sufficient:
```typescript
// Browser-side IANA list for the picker (client only)
const IANA_ZONES = typeof Intl.supportedValuesOf === 'function'
? Intl.supportedValuesOf('timeZone')
: [];
// Note: browser Intl.supportedValuesOf works in Chrome 93+, Safari 14.1+, Firefox 91+.
// The 'UTC' omission from the list is benign on the browser side — server validates with
// try/catch, so a manually-typed 'UTC' still passes. The picker's filtered <select>
// shows continent/ocean zones only; the text input allows override.
```
A `<input type="text" list="iana-zones">` + `<datalist id="iana-zones">` with all zone options is the lowest-friction approach for a non-technical user — they can type their city name and browser autocompletes from the datalist.
### Pattern 4: Phase 12 Wizard Seeding (additive, non-blocking)
**What:** A standalone seed write so Phase 18 can be executed without waiting for Phase 12.
**When to use:** Phase 18 executor includes a Wave 0 task: add a `POST /api/admin/config/timezone/seed` endpoint (or inline in the wizard's `complete` endpoint in Phase 12). The seeding endpoint writes `household_timezone` only if it is not already set (no-overwrite-if-set guard per D-03).
**Recommendation:** Make Phase 18 include a seeding helper in the API. Phase 12 (when executed) calls the same PUT endpoint or uses the helper directly in the `/setup/complete` handler.
```typescript
// Seeding write (wizard or any first-run path)
// Only write if not already set (no silent overwrite per D-03)
async function seedTimezoneIfUnset(db, browserTz: string) {
const [existing] = await db
.select({ value: appConfig.value })
.from(appConfig)
.where(eq(appConfig.key, 'household_timezone'))
.limit(1);
if (!existing?.value) {
await db
.insert(appConfig)
.values({ key: 'household_timezone', value: browserTz })
.onDuplicateKeyUpdate({ set: { value: browserTz } });
}
}
```
### Anti-Patterns to Avoid
- **Modifying `eventDateTime.ts` or `hydrateEvents.ts`:** D-07 is a hard boundary. The browser-local write/display path was deliberately fixed in earlier phases. Any touch to these files is out of scope and risks regression.
- **Using `Intl.supportedValuesOf('timeZone')` for server-side validation:** It excludes 'UTC' and 'Etc/*' variants in Node 22 and Chrome. Use `try/catch Intl.DateTimeFormat` instead.
- **Caching the timezone value in memory:** Read-per-call in the scheduler and outbox is correct. In-memory caching requires invalidation signaling and doesn't meaningfully improve a 60s interval.
- **Installing a timezone-list package:** The project convention is hand-rolled, no new dependencies. `Intl.supportedValuesOf('timeZone')` is available in all target browsers and Node 22.
- **Auto-overwriting `household_timezone` on login:** D-03 forbids this. Only the settings UI write and the first-run seed may write this key.
- **Putting the seeding call on the `/api/me` route (or OIDC callback):** This would trigger on every login, violating D-03 (no auto-overwrite after seeding).
---
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| IANA timezone validation | Custom regex or static list | `try/catch Intl.DateTimeFormat()` | Engine-validated, handles all variants including UTC/Etc, zero deps |
| IANA zone list for picker | npm `moment-timezone`, `tzdata` | `Intl.supportedValuesOf('timeZone')` | Built-in to Node 22 and modern browsers, 418 zones, no package needed |
| Key/value DB upsert | Manual SELECT + conditional INSERT | Drizzle `.insert().onDuplicateKeyUpdate()` | MariaDB-compatible upsert pattern; Drizzle mysql dialect handles it correctly |
**Key insight:** All complexity for this phase lives in the wiring, not the algorithms. `computeAlertInstantUtc` is already correct; the phase only changes what timezone string is passed to it.
---
## Confirmed Code Touchpoints (verified by file inspection)
### `reminderScheduler.ts` — line 247 (confirmed, CONTEXT hint was accurate)
```typescript
// Line 247 (VERIFIED by grep):
const serverTz = process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
// Line 256: passed to computeAlertInstantUtc(dtstartDate, leadDays, serverTz)
```
**Contract:** `serverTz` is a `string` passed as the third argument to `computeAlertInstantUtc`. The function signature is `computeAlertInstantUtc(eventDateStr: string, leadDays: number, tz: string): Date` (`vevent.ts` line 240). The contract is tz-string-in, unchanged. Phase 18 only changes the value of `serverTz`.
### `outboxWorker.ts` — lines 501 and 607 (confirmed, both sites)
```typescript
// Line 501 (update branch, allDay+explicit reminder):
const tz = process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
const leadDays = fields.reminderLeadMinutes / 1440;
allDayAlertInstantUtcUpdate = computeAlertInstantUtc(fields.start, leadDays, tz);
// Line 607 (create branch, allDay+reminder):
const tz = process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
const leadDays = fields.reminderLeadMinutes / 1440;
allDayAlertInstantUtcCreate = computeAlertInstantUtc(fields.start, leadDays, tz);
```
Both sites are in the `runOutboxDrain` function. Both are in all-day event branches. The CONTEXT hint (§~501, §~607) is accurate.
### `vevent.ts` — `computeAlertInstantUtc` signature (lines 240343, confirmed)
```typescript
// Line 240 (VERIFIED):
export function computeAlertInstantUtc(eventDateStr: string, leadDays: number, tz: string): Date
```
The function is a pure computation: event date string, lead days, tz string → UTC Date. The tz argument is used via `Intl.DateTimeFormat` internally. **No change to this function.** Phase 18 only changes what is passed as `tz`.
### `schema.ts` — `appConfig` table (line 282, confirmed)
```typescript
// Line 282 (VERIFIED):
export const appConfig = mysqlTable('app_config', {
key: varchar('key', { length: 128 }).primaryKey(),
value: text('value'), // nullable
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(),
});
```
The `household_timezone` key is a new additive row — no migration needed. The existing `0001_famous_mad_thinker.sql` migration already created this table.
### `admin.ts` (Phase 10 route) — confirmed extend pattern
Current routes: `GET /members`, `POST /credentials`, `GET /calendars`, `PUT /calendars/:id/shared`. All gated by `adminRouter.use('*', requireAdmin)` as first statement. New endpoints extend the same file and inherit the guard.
### `AdminPage.tsx` — confirmed extend pattern
- Uses `useQuery(['admin', 'calendars'], ...)` + `useMutation` + `useQueryClient` + `invalidateQueries` pattern.
- New Timezone section follows the same structure as the Shared Calendar section.
- `sectionLabelStyle` is defined at top of file and reused — new section reuses it.
### Phase 12 status — NOT YET EXECUTED
Confirmed by `STATE.md` (current position is Phase 13, Phase 12 only has `12-UI-SPEC.md` under `.planning/phases/12-initial-setup-wizard/`). No setup route code exists in `apps/api/src/` or `apps/pwa/src/`. Phase 18 must be self-contained: the timezone seeding must work without Phase 12.
---
## Common Pitfalls
### Pitfall 1: Both Scheduler Sites Must Use the Same Accessor
**What goes wrong:** Separately duplicating the DB read in both `reminderScheduler.ts` and `outboxWorker.ts`. If you add the read inline in both files separately, future changes must be made in two places, and they can drift.
**Why it happens:** The CONTEXT.md says "both must route through the same stored-value accessor" — this is easy to forget if planning treats the two files as independent tasks.
**How to avoid:** Define `getHouseholdTimezone(db)` in `apps/api/src/lib/householdTimezone.ts` in Wave 0 / Plan 1. Both scheduler files import from there.
**Warning signs:** If a plan task says "add `getHouseholdTimezone`" to both `reminderScheduler.ts` and `outboxWorker.ts` without a shared lib — wrong approach.
### Pitfall 2: Intl.supportedValuesOf Excludes 'UTC'
**What goes wrong:** Server-side Zod validation uses `Intl.supportedValuesOf('timeZone').includes(tz)` — then a user who types 'UTC' gets a 400 error even though it is a valid timezone.
**Why it happens:** The MDN docs say `supportedValuesOf('timeZone')` works, but the spec excludes 'UTC', 'GMT', 'Etc/UTC', and all `Etc/*` identifiers from the return value in Node 22 and Chrome (verified with runtime test: `Intl.supportedValuesOf('timeZone').includes('UTC')``false`).
**How to avoid:** Use `try/catch Intl.DateTimeFormat(undefined, { timeZone: val })` in the Zod `.refine()`. This accepts all valid zones including UTC variants.
**Warning signs:** Unit test for 'UTC' input fails with 400 from the validation endpoint.
### Pitfall 3: requireAdmin Must Remain First Middleware Statement
**What goes wrong:** Adding new routes before `adminRouter.use('*', requireAdmin)` or in a position where the middleware doesn't cover them.
**Why it happens:** The middleware is positional in Hono — routes registered before `use('*', ...)` are not covered.
**How to avoid:** New endpoints are appended after the existing routes in `admin.ts`. The `adminRouter.use('*', requireAdmin)` is already the first statement (line 41) and covers all routes registered on `adminRouter` regardless of append order in Hono.
**Warning signs:** Test for non-admin user on new endpoint returns 200 instead of 403.
### Pitfall 4: Drizzle `onDuplicateKeyUpdate` Syntax for MariaDB
**What goes wrong:** Using wrong Drizzle syntax for upsert, or trying `drizzle-kit push` (forbidden — D-Task5-DDL).
**Why it happens:** Drizzle's mysql dialect supports `.insert().onDuplicateKeyUpdate({ set: { ... } })`. The `app_config` key is the PK, so inserting with an existing key is an upsert. No migration needed for a new key; only a data write.
**How to avoid:** Use the exact Drizzle pattern: `db.insert(appConfig).values({...}).onDuplicateKeyUpdate({ set: { value: ... } })`. Verified compatible with `drizzle-orm@0.45.2` + `mysql2@3.22.4` (MariaDB wire-compatible).
**Warning signs:** Drizzle throws `Duplicate entry` error instead of updating.
### Pitfall 5: Existing All-Day Scheduler Tests Pin `process.env.TZ`
**What goes wrong:** After Phase 18 wires `getHouseholdTimezone(db)`, the existing all-day tests in `reminderScheduler.test.ts` that pin `process.env.TZ = 'America/New_York'` rely on the old bare `process.env.TZ` read. When the code switches to a DB read, the mock DB must return the expected timezone, otherwise the test reads `null` → falls back to `process.env.TZ` → still works.
**Why it's actually safe:** The D-06 fallback chain preserves `process.env.TZ` when `household_timezone` is unset. As long as the test's mock DB returns no `household_timezone` row (which it won't, since tests mock the DB to return only event rows), the fallback to `process.env.TZ` kicks in — tests continue to pass without modification. This is the backward-compat guarantee.
**How to verify:** Existing all-day tests pass without any modification (fallback fires). New Phase 18 tests for the DB-stored case mock the DB to also return the `app_config` row.
**Warning signs:** Existing all-day tests fail after Phase 18 wiring — indicates fallback isn't implemented correctly.
### Pitfall 6: Phase 12 Seeding Must Not Overwrite After First Set
**What goes wrong:** Phase 12 (or any seeding path) unconditionally writes `household_timezone` on every setup/login, violating D-03.
**How to avoid:** The seed write must check if the key is already set before writing. Use a SELECT + conditional INSERT pattern, or use `.onDuplicateKeyUpdate` only with a no-op set guard: `INSERT ... ON DUPLICATE KEY UPDATE value = IF(value IS NULL, VALUES(value), value)`. Simpler: SELECT first, only INSERT if `row?.value` is null.
---
## Code Examples
### IANA validation — production-safe, UTC-inclusive
```typescript
// Source: Node 22 runtime verification (2026-06-15)
// try/catch accepts UTC, GMT, Etc/UTC, Etc/GMT, and all 418 continent/ocean zones
export function isValidIanaTimezone(tz: string): boolean {
try {
Intl.DateTimeFormat(undefined, { timeZone: tz });
return true;
} catch {
return false;
}
}
```
### Zod schema for PUT /api/admin/config/timezone body
```typescript
// Source: zod.dev official docs (.refine() pattern) + project CLAUDE.md (zod 3.24.x)
const timezoneSchema = z.object({
timezone: z
.string()
.min(1)
.max(64)
.refine(isValidIanaTimezone, { message: 'Invalid IANA timezone identifier' }),
});
```
### Drizzle upsert for app_config (MariaDB compatible)
```typescript
// Source: drizzle-orm mysql2 dialect, verified pattern in existing codebase
import { appConfig } from '../db/schema.js';
await db
.insert(appConfig)
.values({ key: 'household_timezone', value: timezone })
.onDuplicateKeyUpdate({ set: { value: timezone } });
```
### Browser timezone seeding (PWA side, first-run only)
```typescript
// Called once during Phase 12 wizard complete step (or Phase 18 standalone seed)
// D-03: only seed if not yet set (server enforces IF NOT EXISTS logic)
const browserTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
await setAdminTimezone(browserTz); // Server applies no-overwrite guard
```
---
## State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| `process.env.TZ ?? Intl…` bare lookup in scheduler | Stored `app_config.household_timezone` + fallback | Phase 18 | Timezone correct across Docker restarts where TZ env is unset |
| No user-facing timezone setting | Admin UI picker + stored value | Phase 18 | Admin can fix "9 AM local" if it fires at wrong time |
**Deprecated/outdated:**
- Direct `process.env.TZ` reads in scheduler/outbox for all-day logic: these sites are replaced by `getHouseholdTimezone(db)` in Phase 18.
---
## Phase 12 Dependency Analysis
Phase 12 (Initial Setup Wizard) has status `draft` — only `12-UI-SPEC.md` exists. No routes, no PWA wizard route code. The Phase 12 UI spec describes a 5-step wizard where Step 5 ("Calendar Credential") calls `POST /api/setup/complete`. The timezone seeding would naturally go here but is not yet implemented.
**Phase 18 must be self-contained.** Recommended approach: Phase 18 adds a seed-timezone endpoint or admin-writable PUT endpoint that Phase 12 can later call. The AdminPage.tsx timezone section (Phase 18 deliverable) also shows the browser-detected zone as the pre-filled value in the picker, so an admin can confirm or change it on first login — covering the seeding requirement without needing Phase 12.
**Ordering:** Phase 18 executes before Phase 12. Phase 12 can call `PUT /api/admin/config/timezone` (or the seeding helper) during the wizard's `POST /api/setup/complete` to pre-populate from the browser.
---
## Environment Availability
This phase is code/config-only (new routes + lib helper + PWA section). No external service dependencies beyond the existing MariaDB and API server.
| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| MariaDB (app_config table) | DB read/write | Already in stack | MariaDB 11 (Unraid) | — |
| Node 22 `Intl` API | IANA validation | Built-in | Node 22.22.3 (confirmed) | — |
| Browser `Intl.DateTimeFormat` | Browser detection | Chrome 93+, Safari 14.1+, Firefox 91+ | Built-in | — |
---
## Validation Architecture
> `workflow.nyquist_validation: true` in `.planning/config.json` — section required.
### Test Framework
| Property | Value |
|----------|-------|
| Framework (API) | Vitest 4.1.8 |
| Framework (PWA) | Vitest 4.1.8 + jsdom |
| Config file (API) | `apps/api/vitest.config.ts` |
| Config file (PWA) | `apps/pwa/vitest.config.ts` |
| Quick run command (API) | `pnpm --filter @familysync/api exec vitest run tests/lib/householdTimezone.test.ts` |
| Full suite command | `pnpm --filter @familysync/api test && pnpm --filter @familysync/pwa test` |
**TDD mode is ON.** All new files need RED tests before implementation.
### Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| D-05/D-06 | `getHouseholdTimezone` returns stored value when set | Unit | `vitest run tests/lib/householdTimezone.test.ts` | No — Wave 0 |
| D-05/D-06 | `getHouseholdTimezone` falls back to `process.env.TZ` when unset | Unit | same | No — Wave 0 |
| D-05/D-06 | `getHouseholdTimezone` falls back to `Intl` when both unset | Unit | same | No — Wave 0 |
| D-05 | `reminderScheduler` all-day branch uses stored TZ | Unit (mock DB) | `vitest run tests/broker/reminderScheduler.test.ts` | Exists (extend) |
| D-05 | `outboxWorker` all-day branch uses stored TZ | Unit (mock DB) | `vitest run tests/broker/outboxWorker.test.ts` | Exists (extend) |
| IANA validation | Invalid zone → 400 from PUT endpoint | Integration | `vitest run tests/routes/admin.test.ts` | Exists (extend) |
| IANA validation | 'UTC' accepted → 200 from PUT endpoint | Integration | same | Exists (extend) |
| requireAdmin | GET /api/admin/config/timezone → 403 non-admin | Integration | same | Exists (extend) |
| requireAdmin | PUT /api/admin/config/timezone → 403 non-admin | Integration | same | Exists (extend) |
| D-06 backward compat | Existing all-day scheduler tests still pass (fallback) | Unit (existing) | `vitest run tests/broker/reminderScheduler.test.ts` | Exists (no change) |
| End-to-end | Admin sets TZ → next 9AM reminder fires in new zone | Manual | `playwright-cli` (Chromium) | No — verify step |
### Existing Test Infrastructure Notes
- `tests/broker/reminderScheduler.test.ts` lines 656729: existing all-day tests pin `process.env.TZ = 'America/New_York'` in `beforeEach`. After Phase 18 wiring, these tests mock the DB to return only event rows (not app_config rows), so `getHouseholdTimezone` finds no stored value and falls back to `process.env.TZ` — tests continue to pass **without modification** (D-06 backward compat).
- New Phase 18 all-day tests that verify stored TZ behavior: mock the DB to return `{ key: 'household_timezone', value: 'America/Chicago' }` and assert firing at 9 AM Chicago time.
- `tests/routes/admin.test.ts`: integration tests hit real MariaDB (`familysync_test` DB). New timezone tests follow same `beforeEach`/`afterEach` DB cleanup pattern.
### Sampling Rate
- **Per task commit:** `pnpm --filter @familysync/api exec vitest run tests/lib/householdTimezone.test.ts`
- **Per wave merge:** `pnpm --filter @familysync/api test && pnpm --filter @familysync/pwa test`
- **Phase gate:** Full suite green (all 244+ existing tests pass) + manual admin change-TZ round-trip via `playwright-cli`
### Wave 0 Gaps
- [ ] `apps/api/tests/lib/householdTimezone.test.ts` — covers D-05/D-06 accessor + fallback chain + IANA validator
- [ ] Extend `apps/api/tests/routes/admin.test.ts` with GET/PUT timezone endpoint tests (403 non-admin, IANA validation, round-trip)
- [ ] Extend `apps/api/tests/broker/reminderScheduler.test.ts` with stored-TZ all-day test
---
## Security Domain
> `security_enforcement: true` (default) in `.planning/config.json`.
### Applicable ASVS Categories
| ASVS Category | Applies | Standard Control |
|---------------|---------|-----------------|
| V2 Authentication | No | (OIDC already handled by `@hono/oidc-auth`) |
| V3 Session Management | No | (session cookie already handled) |
| V4 Access Control | Yes | `requireAdmin` middleware (DB-enforced, not client flag) |
| V5 Input Validation | Yes | Zod `.refine(isValidIanaTimezone)` on PUT body |
| V6 Cryptography | No | No new crypto; timezone is a non-sensitive plain string |
### Known Threat Patterns for This Stack
| Pattern | STRIDE | Standard Mitigation |
|---------|--------|---------------------|
| Non-admin sets timezone via direct API call | Elevation of Privilege | `requireAdmin` middleware is FIRST statement on `adminRouter`; server 403 before any handler |
| Invalid/malicious timezone string in PUT body | Tampering | Zod `.refine(isValidIanaTimezone)` rejects before DB write; `try/catch Intl.DateTimeFormat` is safe (no eval) |
| Timezone injection causing log pollution | Information Disclosure | IANA strings are limited to standard zone identifiers; Intl validation rejects anything else |
**Note:** Timezone strings are non-sensitive (not credentials, not PII). No special sanitization beyond IANA validation is required. The `noEchoHook` pattern from credentials is NOT needed here.
---
## Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | Read-per-run DB lookup is negligible overhead for a 60s interval scheduler | Architecture Patterns | If DB is slow (unlikely for PK lookup), could add a few ms to each tick. Impact: none on correctness. |
| A2 | `Intl.DateTimeFormat` try/catch accepts all valid IANA zones in target browsers (iOS Safari 16.4+) | Standard Stack | iOS < 16.4 outside scope (CLAUDE.md min); tested in Node 22 [VERIFIED: runtime test] |
| A3 | Phase 12 setup wizard will call `PUT /api/admin/config/timezone` when executed | Phase 12 section | If Phase 12 uses a different mechanism, Phase 18's seeding logic may conflict. Low risk: Phase 12 spec shows a `POST /api/setup/complete` pattern that can call the helper. |
**If this table is empty:** All claims in this research were verified or cited.
---
## Open Questions
1. **Should Phase 18 include a seed endpoint accessible during the Phase 12 wizard?**
- What we know: Phase 12 `POST /api/setup/complete` will need to write `household_timezone`. The PUT endpoint on `adminRouter` is admin-gated (require admin), which may not be available at wizard-complete time (first admin not yet set).
- What's unclear: Is `requireAdmin` already satisfied at wizard-complete time (the first user becomes admin during the wizard)?
- Recommendation: Phase 10 `auth/user.ts` line 114 shows "first user → admin" logic; if the wizard calls `setup/complete` after promoting the user to admin, the PUT endpoint is accessible. Planner should confirm the admin-promotion timing relative to the timezone seed call. If promotion happens in the same `setup/complete` handler, the PUT endpoint is available.
2. **Should the AdminPage timezone picker pre-fill with the detected browser timezone as a hint?**
- What we know: D-02 says "auto-detect from browser at setup." The AdminPage is post-login.
- What's unclear: Whether showing a "detected: America/New_York — click to use" affordance is in scope.
- Recommendation: This is in Claude's Discretion (CONTEXT.md). The planner should treat it as optional UX polish — the base requirement is a searchable picker with save, not a detection affordance. The picker's initial value shows the current stored timezone (or the fallback zone). A pre-fill is nice but not required.
---
## Sources
### Primary (HIGH confidence — verified by direct codebase inspection)
- `apps/api/src/broker/reminderScheduler.ts` line 247 — `serverTz` lookup confirmed; CONTEXT hint accurate
- `apps/api/src/broker/outboxWorker.ts` lines 501, 607 — two `tz` lookups confirmed; CONTEXT hints accurate
- `apps/api/src/broker/vevent.ts` line 240 — `computeAlertInstantUtc` signature confirmed; pure tz-in function
- `apps/api/src/db/schema.ts` line 282 — `appConfig` table confirmed; key/value/updatedAt structure
- `apps/api/src/routes/admin.ts` — full adminRouter pattern confirmed; `requireAdmin` first
- `apps/pwa/src/routes/AdminPage.tsx` — TanStack Query pattern confirmed; mutation + invalidation pattern
- `apps/pwa/src/api/client.ts` — API client function pattern confirmed
- `.planning/phases/12-initial-setup-wizard/12-UI-SPEC.md` — Phase 12 draft status confirmed; no code exists
- Node 22 runtime tests — `Intl.supportedValuesOf`, `isValidIanaTimezone` try/catch, UTC edge case
### Secondary (MEDIUM confidence)
- [Zod docs — .refine() pattern](https://zod.dev/api?id=apply) — CITED for `.refine()` + custom message syntax [CITED: zod.dev]
### Tertiary (LOW confidence)
- MDN + caniuse `Intl.supportedValuesOf` browser support: Chrome 93+, Safari 14.1+, Firefox 91+ [ASSUMED from training + search result summary]
---
## Metadata
**Confidence breakdown:**
- Stored TZ accessor pattern: HIGH — read directly from reminderScheduler.ts, outboxWorker.ts, schema.ts
- Admin endpoint pattern: HIGH — read directly from admin.ts, AdminPage.tsx, client.ts
- IANA validation approach: HIGH — verified by Node 22 runtime execution
- Phase 12 status: HIGH — confirmed by file listing (only 12-UI-SPEC.md, no code)
- Picker UX (no new package): HIGH — confirmed no combobox library in PWA package.json
**Research date:** 2026-06-15
**Valid until:** 2026-07-15 (stable; all from local codebase inspection)
@@ -0,0 +1,92 @@
---
phase: 18-auto-timezone-detection-and-ability-to-change-timezone
fixed_at: 2026-06-15T07:43:30Z
review_path: .planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-REVIEW.md
iteration: 1
findings_in_scope: 5
fixed: 5
skipped: 0
status: all_fixed
---
# Phase 18: Code Review Fix Report
**Fixed at:** 2026-06-15T07:43:30Z
**Source review:** .planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-REVIEW.md
**Iteration:** 1
**Summary:**
- Findings in scope: 5 (2 Warning + 3 Info; fix_scope = all)
- Fixed: 5
- Skipped: 0
All in-scope findings were fixed. The full API test suite (375 tests across 28
files) and `tsc --noEmit` pass cleanly. No PWA files were touched, so PWA tests
were not run.
## Fixed Issues
### WR-01: Empty-but-set `process.env.TZ` defeats the D-06 fallback and yields an invalid zone
**Files modified:** `apps/api/src/lib/householdTimezone.ts`, `apps/api/tests/lib/householdTimezone.test.ts`
**Commit:** d168da7
**Applied fix:** Extracted the D-06 fallback into a new `resolveHouseholdTimezone(storedValue)`
helper that `.trim()`s candidate values and treats empty/whitespace-only values
(both the stored value and `process.env.TZ`) as absent so they fall through to the
`Intl` resolved zone, instead of relying on `??` which only short-circuits on
null/undefined. `getHouseholdTimezone` now delegates to it. Added RED→GREEN unit
tests for `TZ=''` and `TZ=' '` proving fall-through to the Intl zone. Updated the
doc comment to reflect the now-enforced "non-empty" guarantee.
### WR-02: Seed `seeded` flag can misreport under a real concurrent race
**Files modified:** `apps/api/src/routes/admin.ts`, `apps/api/tests/routes/admin.test.ts`
**Commit:** 93217b5
**Applied fix:** Replaced the pre-flight `SELECT` + conditional
`onDuplicateKeyUpdate` with a single `INSERT IGNORE` and derive `seeded` from the
result's `affectedRows`. **Note (deviation from the review's literal suggestion):**
the review proposed `seeded: insertResult.affectedRows === 1` against the existing
`onDuplicateKeyUpdate(value=value)`. I empirically probed this MariaDB and found
`onDuplicateKeyUpdate(value=value)` returns `affectedRows: 1` for BOTH a fresh insert
and a no-op duplicate, so it cannot distinguish them. `INSERT IGNORE` reliably returns
`affectedRows: 1` on insert and `0` when the row already exists (ignored, value
preserved — D-03), which is what makes the derived flag accurate under a concurrent
race. The `timezone` is interpolated via drizzle's parameterized `sql` template (bound
param, not string concatenation) and is already IANA-validated by `timezoneSchema`.
Corrected the overstated in-code comment. Added a test asserting `seeded:false` for a
row pre-inserted directly (bypassing the endpoint), which only an INSERT-derived flag
can satisfy.
### IN-01: `getHouseholdTimezone` re-runs the same `app_config` SELECT the GET handler just issued
**Files modified:** `apps/api/src/routes/admin.ts`
**Commit:** 692fe2a
**Applied fix:** The `GET /config/timezone` handler now reuses the row it already
SELECTed by calling `resolveHouseholdTimezone(row?.value ?? null)` instead of
`getHouseholdTimezone(db)`, removing the redundant second `app_config` round-trip on
the unset path. Behavior unchanged.
### IN-02: D-06 fallback policy is duplicated between the accessor and the GET handler
**Files modified:** `apps/api/src/lib/householdTimezone.ts`, `apps/api/src/routes/admin.ts`
**Commit:** d168da7 (accessor), 692fe2a (handler)
**Applied fix:** Centralized the fallback policy in the new `resolveHouseholdTimezone`
helper (single source of truth, D-05 intent). The GET handler derives
`isExplicitlySet` from row presence and routes the value through the same helper, so
the WR-01 empty-`TZ` guard lives in exactly one place and the two sites cannot drift.
### IN-03: `outboxWorker` may read the stored timezone twice within one drain cycle
**Files modified:** `apps/api/src/broker/outboxWorker.ts`
**Commit:** 1fb431e
**Applied fix:** Added a lazy per-drain-cycle `TimezoneResolver` (mirroring the
existing `clientCache` thread-through, IN-01) created in `runOutboxDrain` and passed
into `dispatchRow`. The UPDATE and CREATE all-day branches now share a single
`app_config` read. The read stays lazy — cycles with no all-day work never touch the
DB. Behavior unchanged; all 39 outboxWorker tests pass.
---
_Fixed: 2026-06-15T07:43:30Z_
_Fixer: Claude (gsd-code-fixer)_
_Iteration: 1_
@@ -0,0 +1,100 @@
---
phase: 18-auto-timezone-detection-and-ability-to-change-timezone
reviewed: 2026-06-15T04:30:00Z
depth: standard
files_reviewed: 8
files_reviewed_list:
- apps/api/src/lib/householdTimezone.ts
- apps/api/src/routes/admin.ts
- apps/api/src/broker/reminderScheduler.ts
- apps/api/src/broker/outboxWorker.ts
- apps/pwa/src/api/client.ts
- apps/pwa/src/routes/AdminPage.tsx
- apps/api/tests/lib/householdTimezone.test.ts
- apps/api/tests/routes/admin.test.ts
findings:
critical: 0
warning: 0
info: 0
total: 0
status: clean
---
# Phase 18: Code Review Report
**Reviewed:** 2026-06-15T04:30:00Z
**Depth:** standard
**Files Reviewed:** 8
**Status:** clean
## Summary
Re-review (iteration 2 of the --auto fix loop) of Phase 18 timezone changes after fixes for
WR-01 (empty/whitespace `process.env.TZ` fallback guard), WR-02 (`seeded` derived from
`INSERT IGNORE` affectedRows), and IN-01/02/03 (reuse fetched row on GET unset path; centralized
D-06 fallback; per-drain-cycle timezone memoization). All previously-raised findings are resolved.
No new Critical or Warning defects were introduced.
## Narrative Findings (AI reviewer)
No Critical, Warning, or actionable Info findings remain. Verification notes below.
### Verification of applied fixes
- **WR-01 — empty/whitespace TZ fallthrough (`apps/api/src/lib/householdTimezone.ts:49-61`).**
Correct. `resolveHouseholdTimezone` trims the stored value first; a set-but-blank stored value
falls through, then `process.env.TZ?.trim()` rejects `''`/`' '` and falls through to the Intl
zone. The normal stored-value path (`stored` truthy after trim) and the unset path are both
preserved. D-05 (single accessor — both `reminderScheduler.ts:250` and `outboxWorker.ts:391`
route through `getHouseholdTimezone`) and D-06 (stored → env.TZ → Intl chain) still hold. New
unit tests pin both empty and whitespace cases (`householdTimezone.test.ts:101-117`).
- **WR-02 — `seeded` derived from affectedRows (`apps/api/src/routes/admin.ts:251-269`).**
Correct. The endpoint runs a single `INSERT IGNORE` and derives `seeded` from
`affectedRows === 1`. On MariaDB an ignored duplicate yields `affectedRows === 0`, so the flag
is accurate even under a genuine concurrent race — only the racer whose row actually wrote gets
`seeded:true`. D-03 no-overwrite is preserved (duplicate is silently ignored, value untouched).
The IANA value is validated by `timezoneSchema.refine(isValidIanaTimezone)` before the handler
runs and is bound as a parameterized value via drizzle's `sql` template (not string-
concatenated); column identifiers use `sql.identifier` — no injection. `seeded:false` accuracy
is pinned by the pre-inserted-row test (`admin.test.ts:821-846`) and the idempotent-race test
(`:783-815`).
- **IN-01/02 — GET unset path reuses the fetched row (`apps/api/src/routes/admin.ts:200-213`).**
Correct. The GET handler SELECTs once, computes `isExplicitlySet` from `row?.value != null`, and
passes the same `row?.value ?? null` to the centralized `resolveHouseholdTimezone`. No second
app_config round-trip; the D-06 fallback policy lives in exactly one function. Semantics
unchanged: unset → fallback timezone + `isExplicitlySet:false`; set → stored value + `true`
(`admin.test.ts:631-663`).
- **IN-03 — per-drain-cycle timezone memoization
(`apps/api/src/broker/outboxWorker.ts:385-395, 774`).** Correct. `makeTimezoneResolver` lazily
caches the `getHouseholdTimezone(db)` promise so multiple all-day rows in one drain cycle share a
single app_config read; cycles with no all-day work never touch the DB. The resolver is created
per-cycle and discarded at cycle end, so a transient DB failure caching for one cycle is retried
fresh next cycle, and a rejected resolve surfaces through the existing per-row catch as correct
pending/transient behavior. No double-read regression. Both create (`:634`) and update (`:528`)
all-day branches consume the shared resolver.
### Other checks (no regressions)
- **Access control.** `adminRouter.use('*', requireAdmin)` remains the first router statement; all
three timezone routes (GET/PUT/POST-seed) sit behind it. 403 coverage exists for GET, PUT, and
seed (`admin.test.ts:603-625, 711-720`).
- **IANA validation.** Both PUT and seed share `timezoneSchema` with the try/catch-based
`isValidIanaTimezone` (not `Intl.supportedValuesOf`, so `UTC` is accepted — Pitfall 2). Invalid
input returns 400 and writes nothing (`admin.test.ts:684-701`).
- **Broker async correctness.** `getHouseholdTimezone` is awaited before the all-day loop in
`reminderScheduler.ts:250`; the outbox resolver is awaited inside each all-day branch. No
un-awaited promises or new timer/handle leaks.
- **D-07 boundary guard.** No changes to `eventDateTime.ts` / `hydrateEvents.ts`; the browser-local
write/display path is untouched. AdminPage uses `Intl…resolvedOptions().timeZone` only for the
"Use detected" affordance and never auto-writes (D-03 respected).
All reviewed files meet quality standards. No actionable issues remain.
---
_Reviewed: 2026-06-15T04:30:00Z_
_Reviewer: Claude (gsd-code-reviewer)_
_Depth: standard_
@@ -0,0 +1,71 @@
---
phase: 18-auto-timezone-detection-and-ability-to-change-timezone
audited: 2026-06-15
status: secured
asvs_level: 1
block_on: high
register_authored_at_plan_time: true
threats_total: 13
threats_closed: 13
threats_open: 0
threats_accepted: 4
supply_chain_checks: 4
---
# Phase 18 — Auto Timezone Detection & Change Timezone: Security Audit
**Audited:** 2026-06-15
**ASVS Level:** 1
**block_on:** high
**Compared against:** origin/main..HEAD
**Status:** SECURED — 13/13 threats closed (8 mitigate verified, 5 accept documented), 4× T-18-SC supply-chain verified
This audit verifies each declared threat mitigation EXISTS in the implemented code. It does not scan for new vulnerabilities. Implementation files were not modified.
## Threat Verification
| Threat ID | Category | Disposition | Status | Evidence |
|-----------|----------|-------------|--------|----------|
| T-18-01 | Tampering | mitigate | CLOSED | `householdTimezone.ts:25-35` reads only; never returns an unvalidated forwarded write. `resolveHouseholdTimezone` (`:49-61`) guarantees non-empty fallback (trim guard). All writes go through Plan 02 IANA-validated path. |
| T-18-02 | DoS | accept | CLOSED | Accepted risk logged below. Single PK lookup per scheduler tick (`reminderScheduler.ts:250`). |
| T-18-03 | Elevation of Privilege | mitigate | CLOSED | `admin.ts:42` `adminRouter.use('*', requireAdmin)` is the FIRST router statement, before all routes. GET (`:200`), PUT (`:224`), POST seed (`:251`) all appended after it → inherit the guard. 403 tests exist (`admin.test.ts:603-625, 711-720`). |
| T-18-04 | Tampering | mitigate | CLOSED | `admin.ts:56-62` `timezoneSchema` uses `.refine(isValidIanaTimezone)`; PUT (`:224`) and seed (`:251`) both bind it via `zValidator('json', timezoneSchema)`. `isValidIanaTimezone` (`householdTimezone.ts:70-77`) is eval-free try/catch on `Intl.DateTimeFormat`. Invalid → 400 before any DB write. |
| T-18-05 | Tampering | mitigate | CLOSED | Seed (`admin.ts:262-266`) uses `INSERT IGNORE`; an existing row is silently ignored (value preserved, D-03 no-overwrite). `seeded` derived from `affectedRows === 1`. Cannot overwrite an existing value. |
| T-18-06 | Information Disclosure | accept | CLOSED | Accepted risk logged below. Grep confirms no `console.log` of request bodies in `admin.ts` (only a T-10-10 comment reference). Timezone is non-sensitive. |
| T-18-07 | Injection | mitigate | CLOSED | PUT upsert (`admin.ts:227-230`) uses Drizzle `.insert().onDuplicateKeyUpdate` — parameterized, key is hard-coded literal `'household_timezone'`. Seed (`:262-263`) uses a `sql` template where `INSERT IGNORE` is a literal keyword, column identifiers via `sql.identifier`, and `${timezone}` is a bound parameter (not string-concatenated) and IANA-validated upstream. |
| T-18-08 | Tampering | mitigate | CLOSED | Broker sites consume `getHouseholdTimezone(db)` only (`reminderScheduler.ts:250`, `outboxWorker.ts:391`). `resolveHouseholdTimezone` (`householdTimezone.ts:49-61`) trims stored value and `process.env.TZ`; empty/blank falls through to a valid Intl zone — fallback can never return `''`/invalid. |
| T-18-09 | Tampering (regression) | mitigate | CLOSED | `git diff --name-only origin/main..HEAD` excludes `eventDateTime.ts` and `hydrateEvents.ts` (D-07 boundary intact). No timed-write/display path touched. |
| T-18-10 | DoS | accept | CLOSED | Accepted risk logged below. Per-drain-cycle memoization (`outboxWorker.ts:385-395`, created `:774`) shares one app_config read across both all-day branches (`:527`, `:634`). |
| T-18-11 | Elevation of Privilege | accept | CLOSED | Accepted risk logged below. Client gate is UX-only; server `requireAdmin` (`admin.ts:42`) is the real control. Client (`client.ts:498`) documents server enforcement. |
| T-18-12 | Tampering | mitigate | CLOSED | Client `setAdminTimezone` (`client.ts:501-511`) sends raw input to server; server `timezoneSchema.refine` (`admin.ts:56-62`) is authoritative (400 on invalid). Free-text input (`AdminPage.tsx:387-406`) is not the security boundary. |
| T-18-13 | Information Disclosure | mitigate | CLOSED | Timezone rendered as plain-text JSX (`AdminPage.tsx:431` `Use detected: {detectedTz}`) and as controlled input `value={effectiveTimezoneInput}` (`:390`). Grep confirms NO `dangerouslySetInnerHTML` in AdminPage.tsx. |
| T-18-SC (×4, plans 01-04) | Supply chain | mitigate | CLOSED | `git diff origin/main..HEAD` against all `package.json` / `pnpm-lock.yaml` returns EMPTY — zero new dependencies. PWA picker uses built-in `Intl`. |
## Accepted Risks Log
- **T-18-02 (DoS — DB read per scheduler tick):** Single primary-key lookup on `app_config` per 60s scheduler interval. Negligible load; read-per-run chosen so timezone changes propagate within one tick without a worker restart. Accepted.
- **T-18-06 (Information Disclosure — log/echo of submitted timezone):** Timezone identifiers are non-sensitive (not credentials or PII). No `noEchoHook` required; verified no body logging in handlers. Accepted.
- **T-18-10 (DoS — extra DB read at 3 call sites):** Mitigated in practice by per-drain-cycle memoization; PK lookups on a 60s interval are negligible. Accepted.
- **T-18-11 (Elevation of Privilege — client renders admin UI from isAdmin flag):** The client `isAdmin` gate is a UX convenience only. A forged request still hits server-side `requireAdmin` → 403 (T-18-03). The UI gate is not relied upon as a security control. Accepted.
## Unregistered Flags
None. All four plan SUMMARY `## Threat Flags` sections declare "No new threat surface beyond the plan's threat model." No new endpoints, auth paths, file-access patterns, or schema changes appeared during implementation that lack a mapped threat ID.
## Notes
- Post-review fixes (18-REVIEW-FIX.md: WR-01, WR-02, IN-01/02/03) were verified in code, not accepted on documentation alone:
- WR-01 empty/blank TZ guard present at `householdTimezone.ts:50-58` (relevant to T-18-08).
- WR-02 `INSERT IGNORE` + affectedRows-derived `seeded` present at `admin.ts:262-266` (relevant to T-18-05).
- D-07 boundary independently confirmed via `git diff --name-only`.
- Zero-dependency claim independently confirmed via empty manifest/lockfile diff.
## Security Audit 2026-06-15
| Metric | Count |
|--------|-------|
| Threats found | 13 |
| Closed | 13 |
| Open | 0 |
| Accepted risks | 4 |
| Supply-chain checks | 4 |
@@ -0,0 +1,83 @@
---
phase: 18
slug: auto-timezone-detection-and-ability-to-change-timezone
status: draft
nyquist_compliant: false
wave_0_complete: false
created: 2026-06-14
---
# Phase 18 — Validation Strategy
> Per-phase validation contract for feedback sampling during execution.
---
## Test Infrastructure
| Property | Value |
|----------|-------|
| **Framework** | vitest |
| **Config file** | apps/api/vitest.config.ts (backend), apps/pwa/vitest.config.ts (frontend) |
| **Quick run command** | `pnpm --filter @familysync/api test -- --run` |
| **Full suite command** | `pnpm -r test -- --run` |
| **Estimated runtime** | ~30 seconds |
---
## Sampling Rate
- **After every task commit:** Run `pnpm --filter @familysync/api test -- --run`
- **After every plan wave:** Run `pnpm -r test -- --run`
- **Before `/gsd-verify-work`:** Full suite must be green
- **Max feedback latency:** 60 seconds
---
## Per-Task Verification Map
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
| {N}-01-01 | 01 | 1 | D-05/D-06 | — | tz accessor returns stored value, falls back to process.env.TZ ?? Intl when unset | unit | `pnpm --filter @familysync/api test -- --run` | ❌ W0 | ⬜ pending |
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
> Planner fills this table per task. Key automated coverage targets from RESEARCH.md "Validation Architecture":
> - stored-tz accessor + D-06 fallback chain (unit)
> - IANA validation (try/catch on `Intl.DateTimeFormat`, accepts UTC) (unit)
> - `computeAlertInstantUtc` fed the stored tz vs fallback produces correct 9 AM-local instant (unit)
> - admin change-tz read/write round-trip (integration)
> - all-day reminder fires at 9 AM in the stored zone under a non-UTC value (integration/E2E)
---
## Wave 0 Requirements
- [ ] Backend unit-test stubs for the stored-tz accessor + fallback chain
- [ ] Backend unit-test stubs for IANA validation
- [ ] vitest already present — no framework install needed
*Existing all-day scheduler tests remain green unmodified (mock DB returns no app_config rows → D-06 fallback path).*
---
## Manual-Only Verifications
| Behavior | Requirement | Why Manual | Test Instructions |
|----------|-------------|------------|-------------------|
| Searchable IANA picker UX in /admin Settings | D-04 | Visual/interaction quality | Drive with `playwright-cli`: open /admin Settings, search a zone, save, reload, confirm persisted |
*iOS-Safari standalone behavior is out of scope for this phase.*
---
## Validation Sign-Off
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
- [ ] Wave 0 covers all MISSING references
- [ ] No watch-mode flags
- [ ] Feedback latency < 60s
- [ ] `nyquist_compliant: true` set in frontmatter
**Approval:** pending
@@ -0,0 +1,164 @@
---
phase: 18-auto-timezone-detection-and-ability-to-change-timezone
verified: 2026-06-14T23:05:00Z
status: passed
score: 7/7
overrides_applied: 0
browser_verification:
- test: "Admin timezone picker round-trip in browser"
result: "PASSED — timezone-verify.spec.ts (6 tests, desktop/Chromium) re-run against the live dev stack (DEV_AUTH_BYPASS=true) after rebuilding the API container. Confirmed: Timezone section renders, input pre-filled, first-run Save enabled (WR-01 fix), changing input enables Save, save persists across reload with the 'Using system default' note disappearing, and 'Use detected' pre-fills the browser zone."
note: "Surfaced and fixed a test-determinism gap: e2e global-setup did not clear app_config.household_timezone, leaking a prior run's value. Fixed in commit ddeb87c (clear the key in global-setup + align the stale Save-disabled assertion with the WR-01 first-run behaviour)."
---
# Phase 18: Auto Timezone Detection and Ability to Change Timezone — Verification Report
**Phase 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.
**Decision contract:** D-01 stored in app_config; D-02 auto-detect/seed from browser at first run; D-03 seed must NOT overwrite an explicit value; D-04 changeable from role-gated /admin; D-05 single shared accessor is the source of truth; D-06 fallback chain (stored ?? process.env.TZ ?? Intl resolved zone) when unset; D-07 do NOT touch the browser-local display/timed-write path.
**Verified:** 2026-06-14T23:05:00Z
**Status:** passed (all automated checks pass; browser round-trip re-confirmed via Playwright e2e against the live stack)
**Re-verification:** No — initial verification
---
## Goal Achievement
### Observable Truths (Decision Contract)
| # | Decision | Truth | Status | Evidence |
|---|----------|-------|--------|----------|
| 1 | D-01 | Household timezone stored as `household_timezone` key in `app_config` | VERIFIED | `admin.ts:229``db.insert(appConfig).values({ key: 'household_timezone', value: timezone }).onDuplicateKeyUpdate(...)` |
| 2 | D-02 | Browser-detected zone seeded at first run via POST /api/admin/config/timezone/seed | VERIFIED | `admin.ts:248-273` — seed endpoint; `AdminPage.tsx:108``detectedTz = Intl.DateTimeFormat().resolvedOptions().timeZone`; seed endpoint called from PWA |
| 3 | D-03 | Seed does NOT overwrite an explicit value | VERIFIED | `admin.ts:257-271` — SELECT-before-INSERT with `onDuplicateKeyUpdate({ set: { value: sql\`value\` } })` (WR-02 fix). Tests at `admin.test.ts:753-806` confirm no-overwrite and idempotency under race. |
| 4 | D-04 | Timezone changeable from role-gated /admin only | VERIFIED | `admin.ts:42``adminRouter.use('*', requireAdmin)` is first statement; all three timezone routes registered after line 42 inherit the guard. Tests at `admin.test.ts:603-625` assert 403 for non-admin on GET and PUT. POST seed 403 test at line 711. |
| 5 | D-05 | Single shared accessor `getHouseholdTimezone(db)` is the only TZ read site for brokers | VERIFIED | `reminderScheduler.ts:51,250` — import + `await getHouseholdTimezone(db)`. `outboxWorker.ts:46,504,612` — import + two call sites. No bare `process.env.TZ ?? Intl` expression remains at any call site (only in comments). No stragglers confirmed by grep returning zero non-comment hits. |
| 6 | D-06 | Fallback chain: stored ?? process.env.TZ ?? Intl resolved zone | VERIFIED | `householdTimezone.ts:34-38``row?.value ?? process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone`. 11 unit tests in `householdTimezone.test.ts` cover all four branches (stored / TZ env / Intl / null-value fall-through). |
| 7 | D-07 | `eventDateTime.ts` and `hydrateEvents.ts` NOT modified | VERIFIED | `git diff --name-only origin/main..HEAD` does not list either file. Output confirmed: "CONFIRMED: Neither file appears in branch diff". |
**Score:** 7/7 truths verified (automated)
---
### Required Artifacts
| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `apps/api/src/lib/householdTimezone.ts` | Shared TZ accessor + IANA validator (D-05, D-06) | VERIFIED | Exists, 55 lines, exports `getHouseholdTimezone` and `isValidIanaTimezone`. Verbatim D-06 fallback chain. No `Intl.supportedValuesOf` (avoids UTC-omission pitfall). |
| `apps/api/tests/lib/householdTimezone.test.ts` | Unit tests for accessor fallback chain + validator | VERIFIED | Exists. 11 tests. Covers stored row, no-row + TZ env, no-row + no-TZ, null-value fall-through, valid zones (incl. 'UTC'), and invalid zones. |
| `apps/api/src/routes/admin.ts` | GET + PUT + seed timezone endpoints under requireAdmin | VERIFIED | Exists. `timezoneSchema` with `isValidIanaTimezone` refine. GET at line 200, PUT at 224, seed at 248. All after line-42 `requireAdmin.use('*', ...)`. WR-02 fix applied (onDuplicateKeyUpdate in seed). |
| `apps/api/tests/routes/admin.test.ts` | Integration tests: 403 non-admin, IANA 400/200, round-trip, seed no-overwrite | VERIFIED | Exists. 8 original timezone cases + 3 WR-02 cases added post-review (403 on seed, seeded:true on first, idempotent second). |
| `apps/api/src/broker/reminderScheduler.ts` | All-day TZ rewired to getHouseholdTimezone (D-05) | VERIFIED | Line 51: import. Line 250: `const serverTz = await getHouseholdTimezone(db)`. Line 249 comment references D-06. |
| `apps/api/src/broker/outboxWorker.ts` | Both all-day TZ sites rewired to getHouseholdTimezone (D-05) | VERIFIED | Line 46: import. Line 504: CREATE branch `const tz = await getHouseholdTimezone(db)`. Line 612: UPDATE branch same. Both in comment references D-06. |
| `apps/pwa/src/api/client.ts` | `fetchAdminTimezone()` + `setAdminTimezone()` + `AdminTimezoneResponse` | VERIFIED | Lines 476-511. Interface defined at 476. GET wrapper at 485 with `credentials: 'include', redirect: 'manual', handleAuthResponse`. PUT wrapper at 501 with Content-Type header and body. |
| `apps/pwa/src/routes/AdminPage.tsx` | Timezone section with searchable IANA picker, save, detected-zone affordance | VERIFIED | `<section aria-label="Timezone">` at line 342. `timezoneQuery` at line 93 with 60s staleTime. `timezoneMutation` at line 100. `detectedTz` at line 108. WR-01 fix at lines 114-128 (`isExplicit && ...` guard). "Use detected" affordance at line 415. "Using system default" notice at line 372. |
| `apps/pwa/src/routes/AdminPage.timezone.test.ts` | Unit tests for WR-01 save-enabled logic | VERIFIED | Exists. Tests at line 44 and 57 cover first-run (isExplicitlySet: false) save-enabled case. |
| `apps/pwa/e2e/timezone-verify.spec.ts` | Playwright e2e for browser round-trip | VERIFIED (exists) | 6 test cases: section visible, combobox pre-filled, save disabled when unchanged, changing enables save, persist across reload, "Use detected" affordance. Ran green at execution time against live stack. Cannot re-run without live Docker stack. |
---
### Key Link Verification
| From | To | Via | Status | Details |
|------|----|-----|--------|---------|
| `householdTimezone.ts` | `app_config` row `household_timezone` | Drizzle select with `eq(appConfig.key, 'household_timezone')` | VERIFIED | Line 29-32: `.select({ value: appConfig.value }).from(appConfig).where(eq(appConfig.key, 'household_timezone')).limit(1)` |
| `admin.ts` | `householdTimezone.ts` | `import { isValidIanaTimezone, getHouseholdTimezone }` | VERIFIED | Line 30 of admin.ts: import confirmed |
| `admin.ts` PUT | `app_config` | `db.insert(appConfig).values(...).onDuplicateKeyUpdate(...)` | VERIFIED | Lines 227-231: upsert on `household_timezone` key |
| `admin.ts` seed | `app_config` | SELECT-before-conditional-INSERT with no-op onDuplicateKeyUpdate | VERIFIED | Lines 251-271: SELECT exists; INSERT only when `!alreadySet`; onDuplicateKeyUpdate preserves existing (WR-02) |
| `reminderScheduler.ts` | `householdTimezone.ts` | `import { getHouseholdTimezone }` + `await getHouseholdTimezone(db)` | VERIFIED | Import at line 51; call at line 250 |
| `outboxWorker.ts` | `householdTimezone.ts` | `import { getHouseholdTimezone }` + two `await getHouseholdTimezone(db)` calls | VERIFIED | Import at line 46; calls at lines 504 and 612 |
| `AdminPage.tsx` | `/api/admin/config/timezone` | `useQuery(fetchAdminTimezone)` + `useMutation(setAdminTimezone)` | VERIFIED | Lines 93 and 100 of AdminPage.tsx |
| `client.ts` | `PUT /api/admin/config/timezone` | `fetch('/api/admin/config/timezone', { method: 'PUT', ... })` | VERIFIED | Line 502 of client.ts |
---
### Data-Flow Trace (Level 4)
| Artifact | Data Variable | Source | Produces Real Data | Status |
|----------|---------------|--------|--------------------|--------|
| `AdminPage.tsx` Timezone section | `timezoneQuery.data?.timezone` | `fetchAdminTimezone()` → GET `/api/admin/config/timezone` → DB `app_config` SELECT | Yes — `admin.ts:201-212` performs real SELECT, returns stored or fallback | FLOWING |
| `reminderScheduler.ts` all-day branch | `serverTz` | `getHouseholdTimezone(db)` → DB SELECT on `app_config` | Yes — stored value or D-06 fallback | FLOWING |
| `outboxWorker.ts` CREATE branch | `tz` (line 504) | `getHouseholdTimezone(db)` → DB SELECT | Yes | FLOWING |
| `outboxWorker.ts` UPDATE branch | `tz` (line 612) | `getHouseholdTimezone(db)` → DB SELECT | Yes | FLOWING |
---
### Behavioral Spot-Checks
| Behavior | Command | Result | Status |
|----------|---------|--------|--------|
| API test suite (372 tests) | `cd apps/api && npm test` | 372 passed (28 test files) | PASS |
| PWA unit test suite (213 tests) | `cd apps/pwa && npm test` | 213 passed (18 test files) | PASS |
| No bare `process.env.TZ ?? Intl` at broker call sites | `grep -c "process\.env\.TZ" reminderScheduler.ts` | 1 (comment only, line 249) | PASS |
| No bare `process.env.TZ ?? Intl` at broker call sites | `grep -c "process\.env\.TZ" outboxWorker.ts` | 2 (comments only, lines 503, 611) | PASS |
| `getHouseholdTimezone(db)` called in reminderScheduler | `grep -c "getHouseholdTimezone(db)" reminderScheduler.ts` | 1 | PASS |
| `getHouseholdTimezone(db)` called in outboxWorker (both branches) | `grep -c "getHouseholdTimezone(db)" outboxWorker.ts` | 2 | PASS |
| D-07: eventDateTime.ts not in phase diff | `git diff --name-only origin/main..HEAD -- apps/pwa/src/lib/eventDateTime.ts` | (empty) | PASS |
| D-07: hydrateEvents.ts not in phase diff | `git diff --name-only origin/main..HEAD -- apps/pwa/src/lib/hydrateEvents.ts` | (empty) | PASS |
---
### Requirements Coverage
| Requirement | Source Plan | Description | Status | Evidence |
|-------------|------------|-------------|--------|----------|
| D-01 | 18-02 | Single household-wide timezone in `app_config` | SATISFIED | `admin.ts:229` upserts `household_timezone` key |
| D-02 | 18-02, 18-04 | Browser IANA timezone seeded at first run | SATISFIED | `admin.ts:248-273` seed endpoint; `AdminPage.tsx:108` detectedTz; POST seed called in UI |
| D-03 | 18-02 | Seed never overwrites an explicit value | SATISFIED | SELECT-before-INSERT + onDuplicateKeyUpdate no-op; 4 tests |
| D-04 | 18-02, 18-04 | Changeable via role-gated /admin Settings | SATISFIED | requireAdmin at line 42 covers all TZ routes; Timezone section in AdminPage.tsx |
| D-05 | 18-01, 18-03 | Single shared accessor for broker TZ reads | SATISFIED | `getHouseholdTimezone(db)` is the only TZ read at all three broker sites; no duplication |
| D-06 | 18-01 | Fallback chain: stored ?? TZ env ?? Intl | SATISFIED | `householdTimezone.ts:34-38`; 11 unit tests |
| D-07 | 18-03 | Browser-local display/write path untouched | SATISFIED | Neither `eventDateTime.ts` nor `hydrateEvents.ts` in branch diff |
---
### Anti-Patterns Found
| File | Line | Pattern | Severity | Impact |
|------|------|---------|----------|--------|
| `outboxWorker.ts` | 787 | "not yet done" in log message | Info | Existing code; pre-dates this phase; contextual log text in business-logic comment, not a debt marker |
| `outboxWorker.ts` | 827 | "not yet applied" in comment | Info | Same as above; pre-existing natural-language description |
| `AdminPage.tsx` | 392 | `placeholder="e.g. America/Chicago"` | Info | Input placeholder text; not a stub implementation indicator |
No TBD / FIXME / XXX / unreferenced debt markers found in any of the six Phase 18 production files.
---
### Human Verification Required
#### 1. Admin Timezone Picker Browser Round-Trip
**Test:** With the dev stack running and DEV_AUTH_BYPASS user flagged as admin, navigate to `/admin`. Observe the Timezone section.
Steps:
1. Confirm "Timezone" section is visible, showing current household timezone (or "Using system default" note).
2. Type a city (e.g. "Chicago"), pick "America/Chicago" from the datalist, click Save.
3. Reload `/admin` and confirm the section shows "America/Chicago" with no "system default" note.
4. Confirm the "Use detected: `<zone>`" affordance appears when the input does not match the browser zone.
5. Click "Use detected: `<zone>`" — confirm it fills the input.
6. Confirm save button is disabled when the input matches the stored value and the value is already explicit.
7. Confirm save button is ENABLED when `isExplicitlySet: false` even if the input matches the displayed default (WR-01 fix).
**Expected:** Value persists across reload; no console errors; save button disables correctly; first-run scenario allows saving the displayed default to make it explicit.
**Why human:** Playwright e2e spec (`timezone-verify.spec.ts`, 6 tests) was run against the live stack at execution time and passed. Re-running requires the full Docker stack with DEV_AUTH_BYPASS=true and an admin-flagged user, which cannot be driven from this verification process. iOS/Safari behavior is also out of scope for playwright-cli.
---
### Gaps Summary
No automated gaps found. All seven decision-contract truths are VERIFIED against the codebase.
**Post-review fixes applied and confirmed:**
- WR-01 (`173e06e`): `timezoneSaveDisabled` now allows saving when `isExplicitlySet: false` even if the input matches the displayed default. Unit tests at `AdminPage.timezone.test.ts:44,57` cover this.
- WR-02 (`bda31a3`): Seed endpoint uses `onDuplicateKeyUpdate({ set: { value: sql\`value\` } })` to prevent duplicate-key 500 under concurrent race. Four tests in `admin.test.ts:711-806` cover the 403, seeded:true, no-overwrite, and idempotent-race cases.
**Accepted review item:**
- WR-03 (GET handler issues a second DB round-trip on unset path via `getHouseholdTimezone(db)` after already reading the row): accepted in 18-REVIEW.md as a minor inefficiency, not a correctness issue. No impact on goal achievement.
The only pending item is the human browser verification of the admin timezone picker UI (Task 3 of Plan 18-04), which was executed and passed at phase execution time but cannot be re-run without the live stack.
---
_Verified: 2026-06-14T23:05:00Z_
_Verifier: Claude (gsd-verifier)_