# 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 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)
