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).
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.
## 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)
- 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)
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>
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>
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>
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>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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
/adminSettings — and route the server-side all-day "9 AM local" reminder computation through it (replacing the implicitprocess.env.TZfallback), 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 thestored ?? process.env.TZ ?? Intlfallback chain (D-06, empty/blankTZtreated as unset), plusisValidIanaTimezone()(eval-freeIntl.DateTimeFormattry/catch).Files:
apps/api/src/lib/householdTimezone.ts18-02 — Admin timezone endpoints
GET/PUT /api/admin/config/timezone+POST .../seed, all underrequireAdmin. ZodisValidIanaTimezonerefine on writes; seed usesINSERT IGNOREso it never overwrites an explicit value (D-01/D-02/D-03/D-04).Files:
apps/api/src/routes/admin.ts18-03 — Broker rewire
The three all-day "9 AM local" lookups in
reminderScheduler.ts/outboxWorker.tsnow 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.ts18-04 — Admin Timezone UI
Searchable combobox in
/adminSettings: 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/setAdminTimezoneclient functions.Files:
apps/pwa/src/api/client.ts,apps/pwa/src/routes/AdminPage.tsxRequirements Addressed
Decision contract D-01 … D-07 (from
18-CONTEXT.md): stored inapp_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
18-VERIFICATION.md).18-REVIEW.md/18-REVIEW-FIX.md).18-SECURITY.md).Key Decisions
process.env.TZ/Intlresolved zone are fallbacks only when unset (D-05/D-06).INSERT IGNORE(never clobbers an admin's explicit choice), with theseededflag derived from the DB write.<input list=datalist>, which collapsed the list to one match when pre-filled.TDD / commit trail
Each behavior-adding plan followed RED → GREEN:
db0077ceaceff0f109b3c3bd6a5d94daca3c80845c3013b53(e2e)43d6689/57424e6Follow-ups: review fixes
173e06e/bda31a3/d168da7/93217b5(+ refactors692fe2a/1fb431e), UI iterationa8d6142→d6f6a5a, and1f6ad07(formatting),46d7fcc(dev-server allowed host).🤖 Generated with Claude Code
- 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- 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)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>