Files
familysync/.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-RESEARCH.md
T

733 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)