# 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 (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). --- ## 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` 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 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, ): Promise { 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 { const res = await fetch('/api/admin/config/timezone', { credentials: 'include', redirect: 'manual', }); handleAuthResponse(res, 'GET /api/admin/config/timezone'); return res.json() as Promise; } export async function setAdminTimezone(timezone: string): Promise { 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 `` + filtered `` 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 ` + `` 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 240–343, 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 656–729: 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)