Phase 18: Auto timezone detection and ability to change timezone #21

Merged
luckberg merged 38 commits from gsd/phase-18-auto-timezone-detection-and-ability-to-change-timezone into main 2026-06-15 09:55:53 -04:00
Owner

Summary

Phase 18: Auto timezone detection & ability to change timezone
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.
Status: Verified ✓ · Reviewed (clean) ✓ · Threat-secured ✓

Adds a single shared household-timezone accessor with a safe fallback chain, three role-gated admin endpoints (read / set / first-run seed), rewires the broker's all-day reminder math to the stored zone, and ships an admin UI to view and change it — a searchable timezone combobox with a one-tap "use detected" shortcut.

Changes

18-01 — Shared accessor + IANA validator

getHouseholdTimezone(db) (single source of truth, D-05) with the stored ?? process.env.TZ ?? Intl fallback chain (D-06, empty/blank TZ treated as unset), plus isValidIanaTimezone() (eval-free Intl.DateTimeFormat try/catch).
Files: apps/api/src/lib/householdTimezone.ts

18-02 — Admin timezone endpoints

GET / PUT /api/admin/config/timezone + POST .../seed, all under requireAdmin. Zod isValidIanaTimezone refine on writes; seed uses INSERT IGNORE so it never overwrites an explicit value (D-01/D-02/D-03/D-04).
Files: apps/api/src/routes/admin.ts

18-03 — Broker rewire

The three all-day "9 AM local" lookups in reminderScheduler.ts / outboxWorker.ts now resolve through the shared accessor (memoized per outbox drain cycle). Browser-local display/timed-write path untouched (D-07, confirmed via diff).
Files: apps/api/src/broker/reminderScheduler.ts, apps/api/src/broker/outboxWorker.ts

18-04 — Admin Timezone UI

Searchable combobox in /admin Settings: focus shows the full zone list, typing filters it (case-insensitive, underscores ignored), arrow/Enter/click to select, plus a one-tap "Use detected" shortcut. fetchAdminTimezone / setAdminTimezone client functions.
Files: apps/pwa/src/api/client.ts, apps/pwa/src/routes/AdminPage.tsx

Requirements Addressed

Decision contract D-01 … D-07 (from 18-CONTEXT.md): stored in app_config (D-01); browser-detect/seed at first run (D-02); seed never overwrites explicit value (D-03); changeable only via role-gated /admin (D-04); single shared accessor (D-05); fallback chain when unset (D-06); browser-local display/timed-write path left untouched (D-07).

Verification

  • Goal-backward verification: 7/7 decision-contract truths confirmed in code + tests (18-VERIFICATION.md).
  • Browser round-trip re-confirmed via Playwright e2e against the live stack (render, save, persist-across-reload, use-detected, type-to-search).
  • Code review: clean — prior findings (WR-01 first-run save, WR-02 seed race) fixed and re-reviewed (18-REVIEW.md / 18-REVIEW-FIX.md).
  • Security: SECURED — 13/13 STRIDE threats closed, 0 open (18-SECURITY.md).
  • CI gate (local): lint, format:check, md:lint, typecheck all clean; API 375/375, PWA 213/213, e2e 7/7.

Key Decisions

  • Stored value is the source of truth; process.env.TZ / Intl resolved zone are fallbacks only when unset (D-05/D-06).
  • Seed is idempotent via INSERT IGNORE (never clobbers an admin's explicit choice), with the seeded flag derived from the DB write.
  • Timezone picker is a custom accessible combobox (role=combobox + role=listbox) rather than <input list=datalist>, which collapsed the list to one match when pre-filled.

TDD / commit trail

Each behavior-adding plan followed RED → GREEN:

Plan RED (test) GREEN (impl)
18-01 db0077c eaceff0
18-02 f109b3c 3bd6a5d
18-03 94daca3 c80845c
18-04 3013b53 (e2e) 43d6689 / 57424e6

Follow-ups: review fixes 173e06e/bda31a3/d168da7/93217b5 (+ refactors 692fe2a/1fb431e), UI iteration a8d6142d6f6a5a, and 1f6ad07 (formatting), 46d7fcc (dev-server allowed host).

🤖 Generated with Claude Code

## Summary **Phase 18: Auto timezone detection & ability to change timezone** **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. **Status:** Verified ✓ · Reviewed (clean) ✓ · Threat-secured ✓ Adds a single shared household-timezone accessor with a safe fallback chain, three role-gated admin endpoints (read / set / first-run seed), rewires the broker's all-day reminder math to the stored zone, and ships an admin UI to view and change it — a searchable timezone combobox with a one-tap "use detected" shortcut. ## Changes ### 18-01 — Shared accessor + IANA validator `getHouseholdTimezone(db)` (single source of truth, D-05) with the `stored ?? process.env.TZ ?? Intl` fallback chain (D-06, empty/blank `TZ` treated as unset), plus `isValidIanaTimezone()` (eval-free `Intl.DateTimeFormat` try/catch). **Files:** `apps/api/src/lib/householdTimezone.ts` ### 18-02 — Admin timezone endpoints `GET` / `PUT /api/admin/config/timezone` + `POST .../seed`, all under `requireAdmin`. Zod `isValidIanaTimezone` refine on writes; seed uses `INSERT IGNORE` so it never overwrites an explicit value (D-01/D-02/D-03/D-04). **Files:** `apps/api/src/routes/admin.ts` ### 18-03 — Broker rewire The three all-day "9 AM local" lookups in `reminderScheduler.ts` / `outboxWorker.ts` now resolve through the shared accessor (memoized per outbox drain cycle). Browser-local display/timed-write path untouched (D-07, confirmed via diff). **Files:** `apps/api/src/broker/reminderScheduler.ts`, `apps/api/src/broker/outboxWorker.ts` ### 18-04 — Admin Timezone UI Searchable combobox in `/admin` Settings: focus shows the full zone list, typing filters it (case-insensitive, underscores ignored), arrow/Enter/click to select, plus a one-tap "Use detected" shortcut. `fetchAdminTimezone` / `setAdminTimezone` client functions. **Files:** `apps/pwa/src/api/client.ts`, `apps/pwa/src/routes/AdminPage.tsx` ## Requirements Addressed Decision contract **D-01 … D-07** (from `18-CONTEXT.md`): stored in `app_config` (D-01); browser-detect/seed at first run (D-02); seed never overwrites explicit value (D-03); changeable only via role-gated `/admin` (D-04); single shared accessor (D-05); fallback chain when unset (D-06); browser-local display/timed-write path left untouched (D-07). ## Verification - [x] Goal-backward verification: **7/7 decision-contract truths confirmed** in code + tests (`18-VERIFICATION.md`). - [x] Browser round-trip re-confirmed via Playwright e2e against the live stack (render, save, persist-across-reload, use-detected, type-to-search). - [x] Code review: clean — prior findings (WR-01 first-run save, WR-02 seed race) fixed and re-reviewed (`18-REVIEW.md` / `18-REVIEW-FIX.md`). - [x] Security: SECURED — 13/13 STRIDE threats closed, 0 open (`18-SECURITY.md`). - [x] CI gate (local): lint, format:check, md:lint, typecheck all clean; API 375/375, PWA 213/213, e2e 7/7. ## Key Decisions - Stored value is the source of truth; `process.env.TZ` / `Intl` resolved zone are fallbacks only when unset (D-05/D-06). - Seed is idempotent via `INSERT IGNORE` (never clobbers an admin's explicit choice), with the `seeded` flag derived from the DB write. - Timezone picker is a custom accessible combobox (role=combobox + role=listbox) rather than `<input list=datalist>`, which collapsed the list to one match when pre-filled. ## TDD / commit trail Each behavior-adding plan followed RED → GREEN: | Plan | RED (test) | GREEN (impl) | |---|---|---| | 18-01 | `db0077c` | `eaceff0` | | 18-02 | `f109b3c` | `3bd6a5d` | | 18-03 | `94daca3` | `c80845c` | | 18-04 | `3013b53` (e2e) | `43d6689` / `57424e6` | Follow-ups: review fixes `173e06e`/`bda31a3`/`d168da7`/`93217b5` (+ refactors `692fe2a`/`1fb431e`), UI iteration `a8d6142`→`d6f6a5a`, and `1f6ad07` (formatting), `46d7fcc` (dev-server allowed host). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
luckberg added 35 commits 2026-06-15 09:15:10 -04:00
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- RED gate: tests for getHouseholdTimezone fallback chain (stored → TZ env → Intl)
- Tests for null row value falling through to TZ env branch
- Tests for isValidIanaTimezone (UTC, Etc/UTC, America/Chicago, Europe/London pass; garbage fails)
- Mock Drizzle select chain follows requireAdmin.test.ts pattern
- Saves/restores process.env.TZ in beforeEach/afterEach to prevent env state leaks
- getHouseholdTimezone(db): selects household_timezone from app_config
- D-06 fallback chain: stored value → process.env.TZ → Intl.DateTimeFormat().resolvedOptions().timeZone
- isValidIanaTimezone: try/catch Intl.DateTimeFormat (no Intl.supportedValuesOf per RESEARCH Pitfall 2)
- Exports match D-05 single-accessor contract for reminderScheduler + outboxWorker
- All 11 unit tests pass; 358/358 total suite green; tsc --noEmit clean
- describe('admin timezone config') covers 8 cases:
  - GET and PUT 403 for non-admin authenticated user (T-18-03)
  - GET with no stored row returns 200 with isExplicitlySet: false
  - PUT America/Chicago then GET round-trip with isExplicitlySet: true
  - PUT UTC returns 200 (Pitfall 2)
  - PUT Not/AZone returns 400 and does not write to app_config (T-18-04)
  - POST seed when unset stores the value (D-02)
  - POST seed when already set does NOT overwrite (D-03)
- appConfig imported from db/schema for per-test cleanup
- afterEach removes household_timezone row to prevent test bleed
- 6 new cases FAIL (404 — endpoints not yet implemented); 19 existing pass
- Add appConfig + getHouseholdTimezone/isValidIanaTimezone imports to admin.ts
- Add timezoneSchema: z.object({ timezone: z.string().min(1).max(64).refine(isValidIanaTimezone) })
  No noEchoHook — timezone strings are non-sensitive (T-18-06)
- GET /api/admin/config/timezone: returns { timezone, isExplicitlySet } using D-06 fallback
- PUT /api/admin/config/timezone: validates via timezoneSchema + upserts via onDuplicateKeyUpdate
- POST /api/admin/config/timezone/seed: SELECT-then-INSERT (no onDuplicateKeyUpdate) to enforce D-03 no-overwrite
- All three routes appended AFTER existing routes so line-41 requireAdmin covers them (T-18-03)
- All 25 admin.test.ts tests pass; 366/366 full API suite green; tsc --noEmit clean
- reminderScheduler: new describe block with mockThreeQueries helper that
  extends mockTwoQueries to mock getHouseholdTimezone app_config SELECT
  (select({value}).from(appConfig).where(...).limit(1) chain)
- reminderScheduler: D-05 test expects dispatch at 14:00 UTC (Chicago CDT)
  when stored zone is America/Chicago; fails RED (code still reads process.env.TZ=America/New_York)
- reminderScheduler: D-05 NOT-fire test expects no dispatch at 13:00 UTC (NY time)
  when stored zone overrides to Chicago; fails RED (code fires at NY time)
- outboxWorker: new describe block with wireMockChainWithTz that extends
  mockFromFn to handle app_config table via where().limit() chain
- outboxWorker: D-05 create-branch test expects VALARM TRIGGER 20260619T140000Z
  (Chicago CDT); fails RED (code emits 20260619T130000Z using UTC fallback)
- outboxWorker: D-05 update-branch test same assertion, also fails RED
- Existing process.env.TZ-pinned all-day tests untouched; all 72 pass
- reminderScheduler.ts: add import { getHouseholdTimezone } from '../lib/householdTimezone.js'
  and replace bare process.env.TZ ?? Intl... at line 247 with await getHouseholdTimezone(db)
- outboxWorker.ts: add same import and replace BOTH bare tz lookups at the update-branch
  (~line 501) and create-branch (~line 607) with await getHouseholdTimezone(db)
- D-05 satisfied: all three all-day sites now read from the single stored accessor
- D-06 satisfied: getHouseholdTimezone falls back to process.env.TZ → Intl when unset;
  existing process.env.TZ-pinned tests pass unchanged
- D-07 satisfied: eventDateTime.ts and hydrateEvents.ts are not modified
- outboxWorker.test.ts: update wireMockChain() to handle app_config table with where().limit()
  chain returning empty rows (D-06 fallback), so existing CAL-13 all-day test stays green
- reminderScheduler.test.ts: update mockTwoQueries to mock the new third db.select() call
  (getHouseholdTimezone) returning no row (D-06 fallback), keeping all 37 existing tests green
- All 76 broker tests pass; tsc --noEmit clean
- Export AdminTimezoneResponse interface (timezone: string, isExplicitlySet: boolean)
- fetchAdminTimezone(): GET /api/admin/config/timezone with credentials/redirect pattern
- setAdminTimezone(timezone): PUT /api/admin/config/timezone with JSON body
- Both wrappers call handleAuthResponse (same auth handling as sibling admin calls)
- Import fetchAdminTimezone + setAdminTimezone from api/client.js
- timezoneQuery: useQuery(['admin','timezone'], fetchAdminTimezone, retry:false, staleTime:60s)
- timezoneMutation: useMutation(setAdminTimezone) with invalidateQueries on success
- Timezone <section aria-label="Timezone"> after Shared Calendar (with marginBottom on preceding section)
- Searchable <input type=text list=iana-zones> + <datalist> from Intl.supportedValuesOf (guarded)
- 'Use detected: <zone>' affordance for D-02 one-tap seed
- 'Using system default' note when isExplicitlySet === false (D-06)
- Save button disabled while pending or when input equals stored value
- No touch to eventDateTime.ts / hydrateEvents.ts / other sections (D-07)
- 6 desktop tests covering the full 18-04 acceptance criteria:
  timezone section visible, combobox pre-filled, save disabled when
  unchanged, save enables on change, persists across reload, use-detected
  affordance sets browser zone
- All 6 pass against the real 18-02 API endpoints
- Derive isExplicit from timezoneQuery.data?.isExplicitlySet
- Apply the input===stored no-op guard only when isExplicit is true
- Keep pending and empty-input guards unconditional
- Add unit tests (AdminPage.timezone.test.ts) verifying first-run Save is
  enabled when isExplicitlySet:false and input matches stored fallback value
- Import sql from drizzle-orm in admin.ts
- Add onDuplicateKeyUpdate({ set: { value: sql\`value\` } }) to the
  conditional INSERT in POST /config/timezone/seed so a concurrent seed
  (or seed racing a PUT) cannot 500 on the app_config.key PK constraint
- Existing value is preserved per D-03 no-overwrite (no-op ODKU)
- seeded flag still reflects the pre-flight SELECT (winner: true, loser: false)
- Add tests: 403 access control, seeded:true on first seed, seeded:false
  on second seed without throw (WR-02 idempotent race)
- WR-01: resolved (first-run save enabled — AdminPage.tsx + unit tests)
- WR-02: resolved (seed endpoint idempotent — admin.ts + integration tests)
- WR-03: accepted/not-fixing (redundant GET SELECT, low priority)
- Info items remain as-is (no action required)
The timezone-verify spec assumed a first-run (unset) starting state, but
e2e global-setup truncated only the list/event tables — never app_config —
so a prior run's saved household_timezone leaked across runs. Clear that key
in global-setup so the spec always starts from isExplicitlySet:false.

Also repurpose the stale "Save disabled when unchanged" assertion: after the
WR-01 fix, first-run Save is correctly ENABLED when the input matches the
displayed default (saving confirms the detected zone). The disabled-when-
unchanged-and-explicit case remains covered by the persist-across-reload test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase-goal verification: 7/7 decision-contract truths confirmed (D-01..D-07).
Browser round-trip re-confirmed via Playwright e2e against the live stack.
Mark phase 18 complete in STATE.md and ROADMAP.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The D-06 fallback used row?.value ?? process.env.TZ ?? Intl..., but ??
only short-circuits on null/undefined. A set-but-empty TZ ('' or '   ')
leaked through and yielded an invalid IANA zone that throws inside
Intl.DateTimeFormat({ timeZone }) downstream, silently dropping the
all-day reminder. Extract resolveHouseholdTimezone() which trims and
treats empty/whitespace candidate values (stored value and TZ) as
absent so they fall through to the Intl resolved zone. Adds RED->GREEN
unit tests for empty and whitespace-only TZ.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The GET /config/timezone handler SELECTed app_config then, on the unset
path, called getHouseholdTimezone(db) which re-issued the identical
SELECT before falling back (IN-01). The fallback decision also lived in
two places (IN-02). Route the handler through the centralized
resolveHouseholdTimezone(row?.value) added for WR-01: no redundant
round-trip, single source for the D-06 policy. Behavior unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The seed handler computed seeded from a pre-flight SELECT then returned
seeded:!alreadySet. Under a genuine concurrent race both requests can
SELECT the empty table, both enter the insert branch, and both return
seeded:true though only one row was actually written. Replace the
SELECT + conditional onDuplicateKeyUpdate with a single INSERT IGNORE
and derive seeded from affectedRows (1 = inserted, 0 = ignored/existing
row preserved, D-03). On MariaDB onDuplicateKeyUpdate(value=value)
reports affectedRows 1 for both insert and no-op, so it cannot
distinguish them; INSERT IGNORE can. timezone is bound via a
parameterized sql template and is already IANA-validated by zod. Adds a
test asserting seeded:false for a directly-pre-inserted row.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The UPDATE and CREATE all-day branches each called getHouseholdTimezone(db)
independently, so a drain processing both an all-day create row and an
all-day update row issued two identical app_config SELECTs. Add a lazy
per-cycle TimezoneResolver (mirroring the existing clientCache thread-through)
created in runOutboxDrain and passed into dispatchRow. The read stays lazy —
cycles with no all-day work never touch the DB — but is shared across all
all-day rows in a cycle. Behavior unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Re-review after fixes: status clean (0 Critical/Warning). All 5 findings
from the prior pass resolved across 4 atomic fix commits:
- WR-01: treat empty/blank TZ as unset in the D-06 fallback chain
- WR-02: derive seed `seeded` flag from INSERT IGNORE affectedRows (accurate
  under concurrent race; D-03 no-overwrite preserved)
- IN-01/02: reuse fetched row on GET unset path; centralize D-06 fallback
- IN-03: memoize household timezone per outbox drain cycle

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
gsd-security-auditor verified all 13 plan-time STRIDE threats against the
implementation: 8 mitigate confirmed in code (file:line evidence), 5 accept
documented, 4 supply-chain checks (zero new deps). threats_open: 0. ASVS L1,
block_on high — no high-severity gaps. Post-review fixes (WR-01 blank-TZ guard,
WR-02 INSERT IGNORE) verified in code; D-07 boundary confirmed via git diff.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The IANA picker was an <input list=datalist>, which filters the dropdown by
whatever text is already in the field — so with the stored zone pre-filled a
user only saw a single option and had to erase the value (undiscoverable) to
browse. datalist is also unreliable in iOS Safari.

Replace it with a native <select> grouped by region (<optgroup>): tapping
shows the whole list with no typing/erasing, and it renders as the native
wheel picker on iOS. The "Use detected" one-tap shortcut still covers the
common case. Option labels are shortened (region stripped, underscores → spaces)
while values remain full IANA ids. e2e updated from fill() to selectOption().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add .bergerhouse.net (apex + subdomains) to server.allowedHosts so the dev
PWA is reachable through the reverse proxy / tunnel (e.g.
familysync-dev.bergerhouse.net). Dev-server only; production builds ignore it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the picker with an accessible combobox (role=combobox + role=listbox):
focusing shows the full zone list (no typing/erasing needed), typing filters it
case-insensitively (underscores ignored, so "york" matches America/New_York),
with arrow-key navigation, Enter/click to select, and Escape to close. Fixes the
datalist limitation where a pre-filled value collapsed the dropdown to one match.
e2e updated to type+click options and a type-to-search case added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
style(18): prettier-format household timezone accessor + outbox test
CI / changes (pull_request) Successful in 4s
CI / fast-checks (pull_request) Successful in 1m31s
CI / api (pull_request) Successful in 1m5s
CI / harness (pull_request) Failing after 6m48s
CI / security (pull_request) Successful in 40s
CI / gate (pull_request) Failing after 1s
1f6ad076c1
These two files (from the WR-01 / IN-03 review fixes) had formatting that
failed `pnpm format:check`. No logic change — whitespace/wrapping only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
luckberg added 1 commit 2026-06-15 09:15:25 -04:00
docs(18): ship phase 18 — PR #21
CI / changes (pull_request) Successful in 2s
CI / fast-checks (pull_request) Successful in 1m28s
CI / api (pull_request) Successful in 1m5s
CI / harness (pull_request) Failing after 6m41s
CI / security (pull_request) Successful in 40s
CI / gate (pull_request) Failing after 1s
e7c55787e0
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
luckberg added 1 commit 2026-06-15 09:21:05 -04:00
Merge remote-tracking branch 'origin/main' into gsd/phase-18-auto-timezone-detection-and-ability-to-change-timezone
CI / changes (pull_request) Successful in 3s
CI / fast-checks (pull_request) Successful in 1m27s
CI / api (pull_request) Successful in 1m5s
CI / harness (pull_request) Failing after 6m44s
CI / security (pull_request) Successful in 39s
CI / gate (pull_request) Failing after 1s
c5b892c3a5
# Conflicts:
#	apps/pwa/vite.config.ts
luckberg added 1 commit 2026-06-15 09:30:59 -04:00
test(18): scope timezone e2e to desktop profile (fix harness cross-profile leak)
CI / changes (pull_request) Successful in 2s
CI / fast-checks (pull_request) Successful in 1m28s
CI / api (pull_request) Successful in 1m6s
CI / harness (pull_request) Successful in 4m22s
CI / security (pull_request) Successful in 41s
CI / gate (pull_request) Successful in 1s
1b4ff3cf93
The timezone spec mutates the single household_timezone row, but e2e global-setup
resets it only once per run. Running on all three device profiles (iphone/pixel/
desktop) let one profile's "Save persists" write leak into another profile's
first-run assertions, failing the harness job in CI (workers=1, serial). The admin
timezone UI is desktop-focused, so skip the spec on non-desktop profiles — matching
the layout.spec.ts desktop-only pattern. Full harness: 107 passed, 19 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
luckberg merged commit e454353941 into main 2026-06-15 09:55:53 -04:00
luckberg deleted branch gsd/phase-18-auto-timezone-detection-and-ability-to-change-timezone 2026-06-15 09:55:54 -04:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: luckberg/familysync#21