From 12fb5d2adb48d5acc381472cfbe7aa4d9d7cf88c Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Sun, 14 Jun 2026 21:39:56 -0400 Subject: [PATCH] docs(18): research phase domain --- .../18-RESEARCH.md | 732 ++++++++++++++++++ .playwright/cli.config.json | 8 - 2 files changed, 732 insertions(+), 8 deletions(-) create mode 100644 .planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-RESEARCH.md delete mode 100644 .playwright/cli.config.json diff --git a/.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-RESEARCH.md b/.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-RESEARCH.md new file mode 100644 index 0000000..ad7e8b6 --- /dev/null +++ b/.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-RESEARCH.md @@ -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 (from CONTEXT.md) + +### Locked Decisions + +- **D-01:** Single **household-wide** timezone, stored in `app_config` (key `household_timezone`, IANA string value). No per-member `users.timezone` column. +- **D-02:** Auto-detect the browser IANA timezone (`Intl.DateTimeFormat().resolvedOptions().timeZone`) and use it to **seed** the stored value during the Phase 12 setup wizard / first run. +- **D-03:** After seeding, timezone changes **only** via the settings UI. No auto-overwrite on later login/detection differences. (Optional drift notice allowed, not required.) +- **D-04:** Surface the timezone in the existing **role-gated `/admin` Settings** (Phase 10 `requireAdmin` boundary) and seed it from the **Phase 12 setup wizard**. +- **D-05:** The stored timezone becomes the **source of truth for the server-side all-day "9 AM local" reminder computation** (`reminderScheduler.ts`, `outboxWorker.ts`), replacing the bare `process.env.TZ ?? Intl…` lookup at those sites. +- **D-06:** **Fallback chain when `household_timezone` is unset**: fall back to `process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone`. +- **D-07:** Display rendering and timed-event write serialization stay **browser-local** and unchanged. Must NOT touch `eventDateTime.ts` or `hydrateEvents.ts`. + +### Claude's Discretion + +- Timezone picker UX: a searchable IANA dropdown. Validate the value is a real IANA zone before storing. +- Exact `app_config` key name and the read/cache strategy for the stored value in the scheduler/outbox (e.g. read-per-run vs cached). +- Whether to show a non-blocking "detected zone differs" notice on login (allowed per D-03, not required). + +### Deferred Ideas (OUT OF SCOPE) + +- Per-member timezones (would add `users.timezone` + per-row scheduler logic). +- Driving display/timed reminders off the stored tz (deliberately excluded, D-07). + + +--- + +## Summary + +Phase 18 is a tightly scoped wiring change: one new `app_config` key (`household_timezone`) becomes the source of truth for the server-side all-day reminder computation, replacing two bare `process.env.TZ ?? Intl…` lookups in `reminderScheduler.ts` (line 247) and `outboxWorker.ts` (lines 501 and 607). A single shared accessor helper reads this key from the DB with the D-06 fallback, ensuring both scheduler sites stay synchronized. No new npm packages are required. No schema migration is needed — `app_config` already exists and accepts arbitrary keys as additive rows. + +The admin surface (Phase 10 `adminRouter`, `requireAdmin`, `AdminPage.tsx`) already provides the exact pattern to extend: add GET + PUT endpoints to `/api/admin/config/timezone` following the same route file, client API function, TanStack Query + mutation pattern already present in `AdminPage.tsx`. The IANA validation uses a Zod `.refine()` with a `try/catch` on `Intl.DateTimeFormat` — no external library needed. + +Phase 12 (Initial Setup Wizard) has NOT been executed yet. Its status is `draft` (only a UI spec exists, no route code). The `household_timezone` seeding must therefore be planned as a Phase 18 deliverable that is additive/optional — a standalone seed endpoint (`POST /api/admin/config/timezone/seed`) or an inline seed in an early Phase 18 task — so Phase 18 is not blocked. When Phase 12 eventually executes, it calls the same write endpoint. + +**Primary recommendation:** Extract a `getHouseholdTimezone(db): Promise` helper in `apps/api/src/lib/householdTimezone.ts`, add GET/PUT endpoints on `adminRouter`, extend `AdminPage.tsx` with a new Timezone section, and wire both scheduler sites through the helper. No new dependencies. Read-per-run (not cached) for correctness. + +--- + +## Architectural Responsibility Map + +| Capability | Primary Tier | Secondary Tier | Rationale | +|------------|-------------|----------------|-----------| +| Store household timezone | Database / Storage | API | `app_config` table; PK-keyed key/value row | +| Read timezone for scheduling | API / Backend | — | `reminderScheduler.ts` + `outboxWorker.ts` run server-side; DB read at each tick | +| Validate IANA timezone string | API / Backend | Browser / Client | Server validates before write (real boundary); browser validates before submit (UX) | +| Admin read/write timezone | API / Backend | Frontend Server | `requireAdmin` is server enforced; client `isAdmin` is UX only (Phase 10 D-03) | +| Browser timezone detection | Browser / Client | — | `Intl.DateTimeFormat().resolvedOptions().timeZone` is client-side only | +| Display rendering, timed-event serialization | Browser / Client | — | Unchanged by D-07; remains browser-local | + +--- + +## Standard Stack + +### Core (all already installed — no new packages) + +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| drizzle-orm | 0.45.2 | DB read/write for `app_config` | Already in stack; `eq()` + `.select()` / `.insert().onDuplicateKeyUpdate()` for upsert | +| zod | 3.25.x | IANA string validation | Already in stack; `.refine()` with `Intl.DateTimeFormat` try/catch | +| @hono/zod-validator | 0.8.0 | Route body validation | Already wired in `adminRouter` | +| hono | 4.12.23 | Route handlers | Already in stack; extend `adminRouter` | +| @tanstack/react-query | 5.101.0 | PWA data fetch + mutation | Already in `AdminPage.tsx` | +| Native `Intl` API | Node 22 built-in | IANA validation + browser detection | No package needed | + +**No new npm installs required for this phase.** + +### Supporting + +| Library | Version | Purpose | When to Use | +|---------|---------|---------|-------------| +| `mysql2` (via drizzle) | 3.22.4 | Underlying driver | Used implicitly by drizzle; no direct use needed | + +### Alternatives Considered + +| Instead of | Could Use | Tradeoff | +|------------|-----------|----------| +| `try/catch Intl.DateTimeFormat` | `Intl.supportedValuesOf('timeZone')` membership check | `supportedValuesOf` excludes 'UTC', 'GMT', 'Etc/UTC' (verified in Node 22) — those ARE valid. The try/catch approach accepts all valid zones including UTC variants [VERIFIED: Node 22 runtime test] | +| Read-per-run DB read | In-memory TTL cache | Cache adds invalidation complexity; read-per-run means changes propagate within 60s (one scheduler tick) with no restart; PK lookup is negligible cost | +| Extend existing `adminRouter` | New router/file | The existing pattern (`adminRouter.get/put`, `requireAdmin` first, `zValidator`) is correct and established — extending is the right choice | + +--- + +## Package Legitimacy Audit + +This phase installs **no new packages**. All capabilities use packages already in the monorepo. + +**Packages removed due to [SLOP] verdict:** none +**Packages flagged as suspicious [SUS]:** none (no new installs) + +> Note: `hono` was flagged `SUS` by the registry scanner due to a recent publish date, but it is a locked stack choice from CLAUDE.md and already installed. This flag does not apply to existing dependencies. + +--- + +## Architecture Patterns + +### System Architecture Diagram + +``` +Browser (PWA) API Server MariaDB +───────────────────────────────────────────────────────────────────────────── +[AdminPage /admin] [adminRouter] [app_config] + useQuery('admin','timezone') → GET /api/admin/config/timezone → SELECT key='household_timezone' + useMutation(PUT) → PUT /api/admin/config/timezone → INSERT ... ON DUPLICATE KEY UPDATE + requireAdmin (DB check) + Intl.DateTimeFormat() zod IANA validate + .resolvedOptions().timeZone → store value + (browser detection for seed) + +[reminderScheduler.ts] [getHouseholdTimezone(db)] [app_config] + runReminderCheck() every 60s → SELECT key='household_timezone' → value | null + fallback: process.env.TZ ?? Intl… + pass tz → computeAlertInstantUtc() + +[outboxWorker.ts] [getHouseholdTimezone(db)] [app_config] + processOutboxRow() → SELECT key='household_timezone' → value | null + (allDay branch: lines 501, 607) fallback chain (same key, same fallback) + pass tz → computeAlertInstantUtc() +``` + +### Recommended Project Structure + +``` +apps/api/src/ +├── lib/ +│ └── householdTimezone.ts # NEW: getHouseholdTimezone(db) helper + IANA validator +├── routes/ +│ └── admin.ts # EXTEND: add GET + PUT /config/timezone endpoints +├── broker/ +│ ├── reminderScheduler.ts # MODIFY: line 247 — replace bare process.env.TZ lookup +│ └── outboxWorker.ts # MODIFY: lines 501, 607 — replace bare process.env.TZ lookups +apps/pwa/src/ +├── api/ +│ └── client.ts # EXTEND: add fetchAdminTimezone() + setAdminTimezone() +└── routes/ + └── AdminPage.tsx # EXTEND: add Timezone section after Shared Calendar section +``` + +### Pattern 1: Stored TZ Accessor Helper (getHouseholdTimezone) + +**What:** A single exported async function that reads `household_timezone` from `app_config` and applies the D-06 fallback chain. + +**When to use:** Called at the start of each all-day processing block in `reminderScheduler.ts` and `outboxWorker.ts`. Not called for timed events (those don't use local time). + +**Example:** +```typescript +// apps/api/src/lib/householdTimezone.ts +import { eq } from 'drizzle-orm'; +import type { MySql2Database } from 'drizzle-orm/mysql2'; +import type * as schema from '../db/schema.js'; +import { appConfig } from '../db/schema.js'; + +/** + * Read the household timezone from app_config. + * D-06 fallback: process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone + * (same fallback the bare lookups used before Phase 18). + * + * Read-per-call so timezone changes propagate within one scheduler tick + * without requiring a worker restart. + */ +export async function getHouseholdTimezone( + db: MySql2Database, +): Promise { + const [row] = await db + .select({ value: appConfig.value }) + .from(appConfig) + .where(eq(appConfig.key, 'household_timezone')) + .limit(1); + + return ( + row?.value ?? + process.env.TZ ?? + Intl.DateTimeFormat().resolvedOptions().timeZone + ); +} + +/** + * Validate that a string is an IANA timezone accepted by the JS engine. + * try/catch on Intl.DateTimeFormat covers 'UTC', 'GMT', 'Etc/UTC', and all + * 418 named IANA zones. Intl.supportedValuesOf('timeZone') is NOT used because + * it excludes 'UTC' and 'Etc/*' variants in Node 22 and Chrome. + */ +export function isValidIanaTimezone(tz: string): boolean { + try { + Intl.DateTimeFormat(undefined, { timeZone: tz }); + return true; + } catch { + return false; + } +} +``` + +**Usage in reminderScheduler.ts (line 247 replacement):** +```typescript +// BEFORE (line 247): +const serverTz = process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone; + +// AFTER: +const serverTz = await getHouseholdTimezone(db); +``` + +**Usage in outboxWorker.ts (lines 501 and 607 replacement):** +```typescript +// BEFORE (line 501): +const tz = process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone; + +// AFTER: +const tz = await getHouseholdTimezone(db); +``` + +### Pattern 2: Admin Endpoint (GET + PUT /api/admin/config/timezone) + +**What:** Two new routes on `adminRouter`, following the exact pattern of existing admin routes. GET reads the current value (with fallback), PUT validates + upserts. + +**When to use:** Admin settings UI reads/writes. + +**Example:** +```typescript +// In apps/api/src/routes/admin.ts (extend existing file) + +const timezoneSchema = z.object({ + timezone: z.string().refine(isValidIanaTimezone, { message: 'Invalid IANA timezone' }), +}); + +// GET /api/admin/config/timezone +adminRouter.get('/config/timezone', async (c) => { + const [row] = await db + .select({ value: appConfig.value }) + .from(appConfig) + .where(eq(appConfig.key, 'household_timezone')) + .limit(1); + + const timezone = + row?.value ?? + process.env.TZ ?? + Intl.DateTimeFormat().resolvedOptions().timeZone; + + return c.json({ timezone, isExplicitlySet: row?.value != null }); +}); + +// PUT /api/admin/config/timezone +adminRouter.put( + '/config/timezone', + zValidator('json', timezoneSchema), + async (c) => { + const { timezone } = c.req.valid('json'); + + await db + .insert(appConfig) + .values({ key: 'household_timezone', value: timezone }) + .onDuplicateKeyUpdate({ set: { value: timezone } }); + + return c.json({ ok: true }); + }, +); +``` + +### Pattern 3: PWA TanStack Query + Mutation (AdminPage extension) + +**What:** A new Timezone section in `AdminPage.tsx`, mirroring the existing Shared Calendar section pattern exactly. + +**Example:** +```typescript +// In apps/pwa/src/api/client.ts (extend existing file) +export interface AdminTimezoneResponse { + timezone: string; + isExplicitlySet: boolean; +} + +export async function fetchAdminTimezone(): Promise { + const res = await fetch('/api/admin/config/timezone', { + credentials: 'include', + redirect: 'manual', + }); + handleAuthResponse(res, 'GET /api/admin/config/timezone'); + return res.json() as Promise; +} + +export async function setAdminTimezone(timezone: string): Promise { + const res = await fetch('/api/admin/config/timezone', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + redirect: 'manual', + body: JSON.stringify({ timezone }), + }); + handleAuthResponse(res, 'PUT /api/admin/config/timezone'); +} +``` + +```typescript +// In AdminPage.tsx (new section, same query/mutation pattern as calendarsQuery) +const timezoneQuery = useQuery({ + queryKey: ['admin', 'timezone'], + queryFn: fetchAdminTimezone, + retry: false, + staleTime: 60 * 1000, +}); + +const timezoneMutation = useMutation({ + mutationFn: (tz: string) => setAdminTimezone(tz), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['admin', 'timezone'] }); + }, +}); +``` + +**IANA Picker UX — no new library:** A `` + filtered `` is sufficient: + +```typescript +// Browser-side IANA list for the picker (client only) +const IANA_ZONES = typeof Intl.supportedValuesOf === 'function' + ? Intl.supportedValuesOf('timeZone') + : []; +// Note: browser Intl.supportedValuesOf works in Chrome 93+, Safari 14.1+, Firefox 91+. +// The 'UTC' omission from the list is benign on the browser side — server validates with +// try/catch, so a manually-typed 'UTC' still passes. The picker's filtered ` + `` with all zone options is the lowest-friction approach for a non-technical user — they can type their city name and browser autocompletes from the datalist. + +### Pattern 4: Phase 12 Wizard Seeding (additive, non-blocking) + +**What:** A standalone seed write so Phase 18 can be executed without waiting for Phase 12. + +**When to use:** Phase 18 executor includes a Wave 0 task: add a `POST /api/admin/config/timezone/seed` endpoint (or inline in the wizard's `complete` endpoint in Phase 12). The seeding endpoint writes `household_timezone` only if it is not already set (no-overwrite-if-set guard per D-03). + +**Recommendation:** Make Phase 18 include a seeding helper in the API. Phase 12 (when executed) calls the same PUT endpoint or uses the helper directly in the `/setup/complete` handler. + +```typescript +// Seeding write (wizard or any first-run path) +// Only write if not already set (no silent overwrite per D-03) +async function seedTimezoneIfUnset(db, browserTz: string) { + const [existing] = await db + .select({ value: appConfig.value }) + .from(appConfig) + .where(eq(appConfig.key, 'household_timezone')) + .limit(1); + if (!existing?.value) { + await db + .insert(appConfig) + .values({ key: 'household_timezone', value: browserTz }) + .onDuplicateKeyUpdate({ set: { value: browserTz } }); + } +} +``` + +### Anti-Patterns to Avoid + +- **Modifying `eventDateTime.ts` or `hydrateEvents.ts`:** D-07 is a hard boundary. The browser-local write/display path was deliberately fixed in earlier phases. Any touch to these files is out of scope and risks regression. +- **Using `Intl.supportedValuesOf('timeZone')` for server-side validation:** It excludes 'UTC' and 'Etc/*' variants in Node 22 and Chrome. Use `try/catch Intl.DateTimeFormat` instead. +- **Caching the timezone value in memory:** Read-per-call in the scheduler and outbox is correct. In-memory caching requires invalidation signaling and doesn't meaningfully improve a 60s interval. +- **Installing a timezone-list package:** The project convention is hand-rolled, no new dependencies. `Intl.supportedValuesOf('timeZone')` is available in all target browsers and Node 22. +- **Auto-overwriting `household_timezone` on login:** D-03 forbids this. Only the settings UI write and the first-run seed may write this key. +- **Putting the seeding call on the `/api/me` route (or OIDC callback):** This would trigger on every login, violating D-03 (no auto-overwrite after seeding). + +--- + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| IANA timezone validation | Custom regex or static list | `try/catch Intl.DateTimeFormat()` | Engine-validated, handles all variants including UTC/Etc, zero deps | +| IANA zone list for picker | npm `moment-timezone`, `tzdata` | `Intl.supportedValuesOf('timeZone')` | Built-in to Node 22 and modern browsers, 418 zones, no package needed | +| Key/value DB upsert | Manual SELECT + conditional INSERT | Drizzle `.insert().onDuplicateKeyUpdate()` | MariaDB-compatible upsert pattern; Drizzle mysql dialect handles it correctly | + +**Key insight:** All complexity for this phase lives in the wiring, not the algorithms. `computeAlertInstantUtc` is already correct; the phase only changes what timezone string is passed to it. + +--- + +## Confirmed Code Touchpoints (verified by file inspection) + +### `reminderScheduler.ts` — line 247 (confirmed, CONTEXT hint was accurate) + +```typescript +// Line 247 (VERIFIED by grep): +const serverTz = process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone; +// Line 256: passed to computeAlertInstantUtc(dtstartDate, leadDays, serverTz) +``` + +**Contract:** `serverTz` is a `string` passed as the third argument to `computeAlertInstantUtc`. The function signature is `computeAlertInstantUtc(eventDateStr: string, leadDays: number, tz: string): Date` (`vevent.ts` line 240). The contract is tz-string-in, unchanged. Phase 18 only changes the value of `serverTz`. + +### `outboxWorker.ts` — lines 501 and 607 (confirmed, both sites) + +```typescript +// Line 501 (update branch, allDay+explicit reminder): +const tz = process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone; +const leadDays = fields.reminderLeadMinutes / 1440; +allDayAlertInstantUtcUpdate = computeAlertInstantUtc(fields.start, leadDays, tz); + +// Line 607 (create branch, allDay+reminder): +const tz = process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone; +const leadDays = fields.reminderLeadMinutes / 1440; +allDayAlertInstantUtcCreate = computeAlertInstantUtc(fields.start, leadDays, tz); +``` + +Both sites are in the `runOutboxDrain` function. Both are in all-day event branches. The CONTEXT hint (§~501, §~607) is accurate. + +### `vevent.ts` — `computeAlertInstantUtc` signature (lines 240–343, confirmed) + +```typescript +// Line 240 (VERIFIED): +export function computeAlertInstantUtc(eventDateStr: string, leadDays: number, tz: string): Date +``` + +The function is a pure computation: event date string, lead days, tz string → UTC Date. The tz argument is used via `Intl.DateTimeFormat` internally. **No change to this function.** Phase 18 only changes what is passed as `tz`. + +### `schema.ts` — `appConfig` table (line 282, confirmed) + +```typescript +// Line 282 (VERIFIED): +export const appConfig = mysqlTable('app_config', { + key: varchar('key', { length: 128 }).primaryKey(), + value: text('value'), // nullable + updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(), +}); +``` + +The `household_timezone` key is a new additive row — no migration needed. The existing `0001_famous_mad_thinker.sql` migration already created this table. + +### `admin.ts` (Phase 10 route) — confirmed extend pattern + +Current routes: `GET /members`, `POST /credentials`, `GET /calendars`, `PUT /calendars/:id/shared`. All gated by `adminRouter.use('*', requireAdmin)` as first statement. New endpoints extend the same file and inherit the guard. + +### `AdminPage.tsx` — confirmed extend pattern + +- Uses `useQuery(['admin', 'calendars'], ...)` + `useMutation` + `useQueryClient` + `invalidateQueries` pattern. +- New Timezone section follows the same structure as the Shared Calendar section. +- `sectionLabelStyle` is defined at top of file and reused — new section reuses it. + +### Phase 12 status — NOT YET EXECUTED + +Confirmed by `STATE.md` (current position is Phase 13, Phase 12 only has `12-UI-SPEC.md` under `.planning/phases/12-initial-setup-wizard/`). No setup route code exists in `apps/api/src/` or `apps/pwa/src/`. Phase 18 must be self-contained: the timezone seeding must work without Phase 12. + +--- + +## Common Pitfalls + +### Pitfall 1: Both Scheduler Sites Must Use the Same Accessor + +**What goes wrong:** Separately duplicating the DB read in both `reminderScheduler.ts` and `outboxWorker.ts`. If you add the read inline in both files separately, future changes must be made in two places, and they can drift. + +**Why it happens:** The CONTEXT.md says "both must route through the same stored-value accessor" — this is easy to forget if planning treats the two files as independent tasks. + +**How to avoid:** Define `getHouseholdTimezone(db)` in `apps/api/src/lib/householdTimezone.ts` in Wave 0 / Plan 1. Both scheduler files import from there. + +**Warning signs:** If a plan task says "add `getHouseholdTimezone`" to both `reminderScheduler.ts` and `outboxWorker.ts` without a shared lib — wrong approach. + +### Pitfall 2: Intl.supportedValuesOf Excludes 'UTC' + +**What goes wrong:** Server-side Zod validation uses `Intl.supportedValuesOf('timeZone').includes(tz)` — then a user who types 'UTC' gets a 400 error even though it is a valid timezone. + +**Why it happens:** The MDN docs say `supportedValuesOf('timeZone')` works, but the spec excludes 'UTC', 'GMT', 'Etc/UTC', and all `Etc/*` identifiers from the return value in Node 22 and Chrome (verified with runtime test: `Intl.supportedValuesOf('timeZone').includes('UTC')` → `false`). + +**How to avoid:** Use `try/catch Intl.DateTimeFormat(undefined, { timeZone: val })` in the Zod `.refine()`. This accepts all valid zones including UTC variants. + +**Warning signs:** Unit test for 'UTC' input fails with 400 from the validation endpoint. + +### Pitfall 3: requireAdmin Must Remain First Middleware Statement + +**What goes wrong:** Adding new routes before `adminRouter.use('*', requireAdmin)` or in a position where the middleware doesn't cover them. + +**Why it happens:** The middleware is positional in Hono — routes registered before `use('*', ...)` are not covered. + +**How to avoid:** New endpoints are appended after the existing routes in `admin.ts`. The `adminRouter.use('*', requireAdmin)` is already the first statement (line 41) and covers all routes registered on `adminRouter` regardless of append order in Hono. + +**Warning signs:** Test for non-admin user on new endpoint returns 200 instead of 403. + +### Pitfall 4: Drizzle `onDuplicateKeyUpdate` Syntax for MariaDB + +**What goes wrong:** Using wrong Drizzle syntax for upsert, or trying `drizzle-kit push` (forbidden — D-Task5-DDL). + +**Why it happens:** Drizzle's mysql dialect supports `.insert().onDuplicateKeyUpdate({ set: { ... } })`. The `app_config` key is the PK, so inserting with an existing key is an upsert. No migration needed for a new key; only a data write. + +**How to avoid:** Use the exact Drizzle pattern: `db.insert(appConfig).values({...}).onDuplicateKeyUpdate({ set: { value: ... } })`. Verified compatible with `drizzle-orm@0.45.2` + `mysql2@3.22.4` (MariaDB wire-compatible). + +**Warning signs:** Drizzle throws `Duplicate entry` error instead of updating. + +### Pitfall 5: Existing All-Day Scheduler Tests Pin `process.env.TZ` + +**What goes wrong:** After Phase 18 wires `getHouseholdTimezone(db)`, the existing all-day tests in `reminderScheduler.test.ts` that pin `process.env.TZ = 'America/New_York'` rely on the old bare `process.env.TZ` read. When the code switches to a DB read, the mock DB must return the expected timezone, otherwise the test reads `null` → falls back to `process.env.TZ` → still works. + +**Why it's actually safe:** The D-06 fallback chain preserves `process.env.TZ` when `household_timezone` is unset. As long as the test's mock DB returns no `household_timezone` row (which it won't, since tests mock the DB to return only event rows), the fallback to `process.env.TZ` kicks in — tests continue to pass without modification. This is the backward-compat guarantee. + +**How to verify:** Existing all-day tests pass without any modification (fallback fires). New Phase 18 tests for the DB-stored case mock the DB to also return the `app_config` row. + +**Warning signs:** Existing all-day tests fail after Phase 18 wiring — indicates fallback isn't implemented correctly. + +### Pitfall 6: Phase 12 Seeding Must Not Overwrite After First Set + +**What goes wrong:** Phase 12 (or any seeding path) unconditionally writes `household_timezone` on every setup/login, violating D-03. + +**How to avoid:** The seed write must check if the key is already set before writing. Use a SELECT + conditional INSERT pattern, or use `.onDuplicateKeyUpdate` only with a no-op set guard: `INSERT ... ON DUPLICATE KEY UPDATE value = IF(value IS NULL, VALUES(value), value)`. Simpler: SELECT first, only INSERT if `row?.value` is null. + +--- + +## Code Examples + +### IANA validation — production-safe, UTC-inclusive + +```typescript +// Source: Node 22 runtime verification (2026-06-15) +// try/catch accepts UTC, GMT, Etc/UTC, Etc/GMT, and all 418 continent/ocean zones +export function isValidIanaTimezone(tz: string): boolean { + try { + Intl.DateTimeFormat(undefined, { timeZone: tz }); + return true; + } catch { + return false; + } +} +``` + +### Zod schema for PUT /api/admin/config/timezone body + +```typescript +// Source: zod.dev official docs (.refine() pattern) + project CLAUDE.md (zod 3.24.x) +const timezoneSchema = z.object({ + timezone: z + .string() + .min(1) + .max(64) + .refine(isValidIanaTimezone, { message: 'Invalid IANA timezone identifier' }), +}); +``` + +### Drizzle upsert for app_config (MariaDB compatible) + +```typescript +// Source: drizzle-orm mysql2 dialect, verified pattern in existing codebase +import { appConfig } from '../db/schema.js'; + +await db + .insert(appConfig) + .values({ key: 'household_timezone', value: timezone }) + .onDuplicateKeyUpdate({ set: { value: timezone } }); +``` + +### Browser timezone seeding (PWA side, first-run only) + +```typescript +// Called once during Phase 12 wizard complete step (or Phase 18 standalone seed) +// D-03: only seed if not yet set (server enforces IF NOT EXISTS logic) +const browserTz = Intl.DateTimeFormat().resolvedOptions().timeZone; +await setAdminTimezone(browserTz); // Server applies no-overwrite guard +``` + +--- + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| `process.env.TZ ?? Intl…` bare lookup in scheduler | Stored `app_config.household_timezone` + fallback | Phase 18 | Timezone correct across Docker restarts where TZ env is unset | +| No user-facing timezone setting | Admin UI picker + stored value | Phase 18 | Admin can fix "9 AM local" if it fires at wrong time | + +**Deprecated/outdated:** +- Direct `process.env.TZ` reads in scheduler/outbox for all-day logic: these sites are replaced by `getHouseholdTimezone(db)` in Phase 18. + +--- + +## Phase 12 Dependency Analysis + +Phase 12 (Initial Setup Wizard) has status `draft` — only `12-UI-SPEC.md` exists. No routes, no PWA wizard route code. The Phase 12 UI spec describes a 5-step wizard where Step 5 ("Calendar Credential") calls `POST /api/setup/complete`. The timezone seeding would naturally go here but is not yet implemented. + +**Phase 18 must be self-contained.** Recommended approach: Phase 18 adds a seed-timezone endpoint or admin-writable PUT endpoint that Phase 12 can later call. The AdminPage.tsx timezone section (Phase 18 deliverable) also shows the browser-detected zone as the pre-filled value in the picker, so an admin can confirm or change it on first login — covering the seeding requirement without needing Phase 12. + +**Ordering:** Phase 18 executes before Phase 12. Phase 12 can call `PUT /api/admin/config/timezone` (or the seeding helper) during the wizard's `POST /api/setup/complete` to pre-populate from the browser. + +--- + +## Environment Availability + +This phase is code/config-only (new routes + lib helper + PWA section). No external service dependencies beyond the existing MariaDB and API server. + +| Dependency | Required By | Available | Version | Fallback | +|------------|------------|-----------|---------|----------| +| MariaDB (app_config table) | DB read/write | Already in stack | MariaDB 11 (Unraid) | — | +| Node 22 `Intl` API | IANA validation | Built-in | Node 22.22.3 (confirmed) | — | +| Browser `Intl.DateTimeFormat` | Browser detection | Chrome 93+, Safari 14.1+, Firefox 91+ | Built-in | — | + +--- + +## Validation Architecture + +> `workflow.nyquist_validation: true` in `.planning/config.json` — section required. + +### Test Framework + +| Property | Value | +|----------|-------| +| Framework (API) | Vitest 4.1.8 | +| Framework (PWA) | Vitest 4.1.8 + jsdom | +| Config file (API) | `apps/api/vitest.config.ts` | +| Config file (PWA) | `apps/pwa/vitest.config.ts` | +| Quick run command (API) | `pnpm --filter @familysync/api exec vitest run tests/lib/householdTimezone.test.ts` | +| Full suite command | `pnpm --filter @familysync/api test && pnpm --filter @familysync/pwa test` | + +**TDD mode is ON.** All new files need RED tests before implementation. + +### Phase Requirements → Test Map + +| Req ID | Behavior | Test Type | Automated Command | File Exists? | +|--------|----------|-----------|-------------------|-------------| +| D-05/D-06 | `getHouseholdTimezone` returns stored value when set | Unit | `vitest run tests/lib/householdTimezone.test.ts` | No — Wave 0 | +| D-05/D-06 | `getHouseholdTimezone` falls back to `process.env.TZ` when unset | Unit | same | No — Wave 0 | +| D-05/D-06 | `getHouseholdTimezone` falls back to `Intl` when both unset | Unit | same | No — Wave 0 | +| D-05 | `reminderScheduler` all-day branch uses stored TZ | Unit (mock DB) | `vitest run tests/broker/reminderScheduler.test.ts` | Exists (extend) | +| D-05 | `outboxWorker` all-day branch uses stored TZ | Unit (mock DB) | `vitest run tests/broker/outboxWorker.test.ts` | Exists (extend) | +| IANA validation | Invalid zone → 400 from PUT endpoint | Integration | `vitest run tests/routes/admin.test.ts` | Exists (extend) | +| IANA validation | 'UTC' accepted → 200 from PUT endpoint | Integration | same | Exists (extend) | +| requireAdmin | GET /api/admin/config/timezone → 403 non-admin | Integration | same | Exists (extend) | +| requireAdmin | PUT /api/admin/config/timezone → 403 non-admin | Integration | same | Exists (extend) | +| D-06 backward compat | Existing all-day scheduler tests still pass (fallback) | Unit (existing) | `vitest run tests/broker/reminderScheduler.test.ts` | Exists (no change) | +| End-to-end | Admin sets TZ → next 9AM reminder fires in new zone | Manual | `playwright-cli` (Chromium) | No — verify step | + +### Existing Test Infrastructure Notes + +- `tests/broker/reminderScheduler.test.ts` lines 656–729: existing all-day tests pin `process.env.TZ = 'America/New_York'` in `beforeEach`. After Phase 18 wiring, these tests mock the DB to return only event rows (not app_config rows), so `getHouseholdTimezone` finds no stored value and falls back to `process.env.TZ` — tests continue to pass **without modification** (D-06 backward compat). +- New Phase 18 all-day tests that verify stored TZ behavior: mock the DB to return `{ key: 'household_timezone', value: 'America/Chicago' }` and assert firing at 9 AM Chicago time. +- `tests/routes/admin.test.ts`: integration tests hit real MariaDB (`familysync_test` DB). New timezone tests follow same `beforeEach`/`afterEach` DB cleanup pattern. + +### Sampling Rate + +- **Per task commit:** `pnpm --filter @familysync/api exec vitest run tests/lib/householdTimezone.test.ts` +- **Per wave merge:** `pnpm --filter @familysync/api test && pnpm --filter @familysync/pwa test` +- **Phase gate:** Full suite green (all 244+ existing tests pass) + manual admin change-TZ round-trip via `playwright-cli` + +### Wave 0 Gaps + +- [ ] `apps/api/tests/lib/householdTimezone.test.ts` — covers D-05/D-06 accessor + fallback chain + IANA validator +- [ ] Extend `apps/api/tests/routes/admin.test.ts` with GET/PUT timezone endpoint tests (403 non-admin, IANA validation, round-trip) +- [ ] Extend `apps/api/tests/broker/reminderScheduler.test.ts` with stored-TZ all-day test + +--- + +## Security Domain + +> `security_enforcement: true` (default) in `.planning/config.json`. + +### Applicable ASVS Categories + +| ASVS Category | Applies | Standard Control | +|---------------|---------|-----------------| +| V2 Authentication | No | (OIDC already handled by `@hono/oidc-auth`) | +| V3 Session Management | No | (session cookie already handled) | +| V4 Access Control | Yes | `requireAdmin` middleware (DB-enforced, not client flag) | +| V5 Input Validation | Yes | Zod `.refine(isValidIanaTimezone)` on PUT body | +| V6 Cryptography | No | No new crypto; timezone is a non-sensitive plain string | + +### Known Threat Patterns for This Stack + +| Pattern | STRIDE | Standard Mitigation | +|---------|--------|---------------------| +| Non-admin sets timezone via direct API call | Elevation of Privilege | `requireAdmin` middleware is FIRST statement on `adminRouter`; server 403 before any handler | +| Invalid/malicious timezone string in PUT body | Tampering | Zod `.refine(isValidIanaTimezone)` rejects before DB write; `try/catch Intl.DateTimeFormat` is safe (no eval) | +| Timezone injection causing log pollution | Information Disclosure | IANA strings are limited to standard zone identifiers; Intl validation rejects anything else | + +**Note:** Timezone strings are non-sensitive (not credentials, not PII). No special sanitization beyond IANA validation is required. The `noEchoHook` pattern from credentials is NOT needed here. + +--- + +## Assumptions Log + +| # | Claim | Section | Risk if Wrong | +|---|-------|---------|---------------| +| A1 | Read-per-run DB lookup is negligible overhead for a 60s interval scheduler | Architecture Patterns | If DB is slow (unlikely for PK lookup), could add a few ms to each tick. Impact: none on correctness. | +| A2 | `Intl.DateTimeFormat` try/catch accepts all valid IANA zones in target browsers (iOS Safari 16.4+) | Standard Stack | iOS < 16.4 outside scope (CLAUDE.md min); tested in Node 22 [VERIFIED: runtime test] | +| A3 | Phase 12 setup wizard will call `PUT /api/admin/config/timezone` when executed | Phase 12 section | If Phase 12 uses a different mechanism, Phase 18's seeding logic may conflict. Low risk: Phase 12 spec shows a `POST /api/setup/complete` pattern that can call the helper. | + +**If this table is empty:** All claims in this research were verified or cited. + +--- + +## Open Questions + +1. **Should Phase 18 include a seed endpoint accessible during the Phase 12 wizard?** + - What we know: Phase 12 `POST /api/setup/complete` will need to write `household_timezone`. The PUT endpoint on `adminRouter` is admin-gated (require admin), which may not be available at wizard-complete time (first admin not yet set). + - What's unclear: Is `requireAdmin` already satisfied at wizard-complete time (the first user becomes admin during the wizard)? + - Recommendation: Phase 10 `auth/user.ts` line 114 shows "first user → admin" logic; if the wizard calls `setup/complete` after promoting the user to admin, the PUT endpoint is accessible. Planner should confirm the admin-promotion timing relative to the timezone seed call. If promotion happens in the same `setup/complete` handler, the PUT endpoint is available. + +2. **Should the AdminPage timezone picker pre-fill with the detected browser timezone as a hint?** + - What we know: D-02 says "auto-detect from browser at setup." The AdminPage is post-login. + - What's unclear: Whether showing a "detected: America/New_York — click to use" affordance is in scope. + - Recommendation: This is in Claude's Discretion (CONTEXT.md). The planner should treat it as optional UX polish — the base requirement is a searchable picker with save, not a detection affordance. The picker's initial value shows the current stored timezone (or the fallback zone). A pre-fill is nice but not required. + +--- + +## Sources + +### Primary (HIGH confidence — verified by direct codebase inspection) + +- `apps/api/src/broker/reminderScheduler.ts` line 247 — `serverTz` lookup confirmed; CONTEXT hint accurate +- `apps/api/src/broker/outboxWorker.ts` lines 501, 607 — two `tz` lookups confirmed; CONTEXT hints accurate +- `apps/api/src/broker/vevent.ts` line 240 — `computeAlertInstantUtc` signature confirmed; pure tz-in function +- `apps/api/src/db/schema.ts` line 282 — `appConfig` table confirmed; key/value/updatedAt structure +- `apps/api/src/routes/admin.ts` — full adminRouter pattern confirmed; `requireAdmin` first +- `apps/pwa/src/routes/AdminPage.tsx` — TanStack Query pattern confirmed; mutation + invalidation pattern +- `apps/pwa/src/api/client.ts` — API client function pattern confirmed +- `.planning/phases/12-initial-setup-wizard/12-UI-SPEC.md` — Phase 12 draft status confirmed; no code exists +- Node 22 runtime tests — `Intl.supportedValuesOf`, `isValidIanaTimezone` try/catch, UTC edge case + +### Secondary (MEDIUM confidence) + +- [Zod docs — .refine() pattern](https://zod.dev/api?id=apply) — CITED for `.refine()` + custom message syntax [CITED: zod.dev] + +### Tertiary (LOW confidence) + +- MDN + caniuse `Intl.supportedValuesOf` browser support: Chrome 93+, Safari 14.1+, Firefox 91+ [ASSUMED from training + search result summary] + +--- + +## Metadata + +**Confidence breakdown:** +- Stored TZ accessor pattern: HIGH — read directly from reminderScheduler.ts, outboxWorker.ts, schema.ts +- Admin endpoint pattern: HIGH — read directly from admin.ts, AdminPage.tsx, client.ts +- IANA validation approach: HIGH — verified by Node 22 runtime execution +- Phase 12 status: HIGH — confirmed by file listing (only 12-UI-SPEC.md, no code) +- Picker UX (no new package): HIGH — confirmed no combobox library in PWA package.json + +**Research date:** 2026-06-15 +**Valid until:** 2026-07-15 (stable; all from local codebase inspection) diff --git a/.playwright/cli.config.json b/.playwright/cli.config.json deleted file mode 100644 index 1cf0f80..0000000 --- a/.playwright/cli.config.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "browser": { - "browserName": "chromium", - "launchOptions": { - "channel": "chromium" - } - } -}