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

Merged
luckberg merged 38 commits from gsd/phase-18-auto-timezone-detection-and-ability-to-change-timezone into main 2026-06-15 09:55:53 -04:00
2 changed files with 732 additions and 8 deletions
Showing only changes of commit 12fb5d2adb - Show all commits
@@ -0,0 +1,732 @@
# 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)
-8
View File
@@ -1,8 +0,0 @@
{
"browser": {
"browserName": "chromium",
"launchOptions": {
"channel": "chromium"
}
}
}