From ac60161726ae4b698fe397c5cce5ee9cfa1b7d52 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Sun, 14 Jun 2026 22:08:37 -0400 Subject: [PATCH] docs(18-01): complete household timezone accessor + IANA validator plan --- .planning/ROADMAP.md | 4 +- .planning/STATE.md | 21 +- .../18-01-SUMMARY.md | 124 +++++ .../18-PATTERNS.md | 451 ++++++++++++++++++ 4 files changed, 588 insertions(+), 12 deletions(-) create mode 100644 .planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-01-SUMMARY.md create mode 100644 .planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-PATTERNS.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index b19f11f..12247f3 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -636,12 +636,12 @@ Plans: **Goal:** Make the household timezone an explicit, stored, user-changeable setting — auto-detected from the browser at first run, changeable from the role-gated /admin Settings — and route the server-side all-day "9 AM local" reminder computation through it (replacing the implicit `process.env.TZ` fallback), without touching the already-correct browser-local display/timed-write path. **Requirements**: TBD (decision contract D-01..D-07 from 18-CONTEXT.md) **Depends on:** Phase 10 (admin role + `/admin` Settings + `app_config`); Phase 11 (all-day reminder computation this rewires). Independent of Phase 17. Phase 12 (setup wizard) not required — seeding is self-contained. -**Plans:** 4 plans (3 waves) +**Plans:** 1/4 plans executed Plans: **Wave 1** -- [ ] 18-01-PLAN.md — TDD: getHouseholdTimezone(db) accessor + isValidIanaTimezone (D-05/D-06) +- [x] 18-01-PLAN.md — TDD: getHouseholdTimezone(db) accessor + isValidIanaTimezone (D-05/D-06) **Wave 2** *(blocked on Wave 1 completion)* diff --git a/.planning/STATE.md b/.planning/STATE.md index 5484fa0..74f1da6 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -4,13 +4,13 @@ milestone: v1.1 milestone_name: Operability & Polish status: executing stopped_at: Phase 18 context gathered -last_updated: "2026-06-15T01:53:38.101Z" -last_activity: 2026-06-15 -- Phase 18 planning complete +last_updated: "2026-06-15T02:08:26.245Z" +last_activity: 2026-06-15 progress: total_phases: 23 completed_phases: 9 - total_plans: 32 - completed_plans: 32 + total_plans: 36 + completed_plans: 33 percent: 39 --- @@ -21,14 +21,14 @@ progress: See: .planning/PROJECT.md (updated 2026-06-10) **Core value:** One color-coded family calendar (shared + personal) and shared lists from a single low-friction PWA — cross-ecosystem, no app store -**Current focus:** Phase 11 — per-event-reminders +**Current focus:** Phase 18 — auto-timezone-detection-and-ability-to-change-timezone ## Current Position -Phase: 13 -Plan: Not started +Phase: 18 (auto-timezone-detection-and-ability-to-change-timezone) — EXECUTING +Plan: 2 of 4 Status: Ready to execute -Last activity: 2026-06-15 -- Phase 18 planning complete +Last activity: 2026-06-15 ### ✅ Resolved Checkpoint — Phase 15 Plan 15-03 Task 2 (human-action) @@ -107,6 +107,7 @@ _Updated after each plan completion_ | Phase 10-admin-role-settings P03 | 720 | 3 tasks | 6 files | | Phase 10-admin-role-settings P04 | 1315 | 3 tasks | 8 files | | Phase 11-per-event-reminders P11-04 | 60 | 3 tasks | 4 files | +| Phase 18-auto-timezone-detection-and-ability-to-change-timezone P01 | 2 | 2 tasks | 2 files | ## Accumulated Context @@ -252,9 +253,9 @@ Recent decisions affecting current work: ## Session Continuity -Last session: 2026-06-15T01:25:46.029Z +Last session: 2026-06-15T02:08:26.229Z Stopped at: Phase 18 context gathered -Resume file: .planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-CONTEXT.md +Resume file: None ## Operator Next Steps diff --git a/.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-01-SUMMARY.md b/.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-01-SUMMARY.md new file mode 100644 index 0000000..518ea3b --- /dev/null +++ b/.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-01-SUMMARY.md @@ -0,0 +1,124 @@ +--- +phase: 18-auto-timezone-detection-and-ability-to-change-timezone +plan: "01" +subsystem: api +tags: [timezone, iana, drizzle, vitest, tdd] + +# Dependency graph +requires: + - phase: 10-admin-role-settings + provides: appConfig table (key/value store where household_timezone key lives) +provides: + - getHouseholdTimezone(db) — single D-05 accessor for stored household timezone with D-06 fallback chain + - isValidIanaTimezone(tz) — IANA timezone validator via Intl.DateTimeFormat try/catch + +affects: + - 18-02-PLAN (broker rewire — reminderScheduler + outboxWorker import getHouseholdTimezone) + - 18-03-PLAN (admin API routes — import isValidIanaTimezone for Zod refine) + - 18-04-PLAN (PWA settings UI — consumes admin timezone API built on top of these) + +# Tech tracking +tech-stack: + added: [] + patterns: + - Drizzle single-row PK lookup (.select().from().where(eq()).limit(1)) — same pattern as requireAdmin.ts + - IANA timezone validation via try/catch on Intl.DateTimeFormat (avoids Intl.supportedValuesOf omitting 'UTC') + - TZ env save/restore in beforeEach/afterEach to prevent env state leaks between tests + +key-files: + created: + - apps/api/src/lib/householdTimezone.ts + - apps/api/tests/lib/householdTimezone.test.ts + modified: [] + +key-decisions: + - "isValidIanaTimezone uses Intl.DateTimeFormat try/catch — NOT Intl.supportedValuesOf (omits UTC per RESEARCH Pitfall 2)" + - "D-06 fallback chain verbatim: row?.value ?? process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone" + - "Literal key string 'household_timezone' in the WHERE clause (D-01)" + +patterns-established: + - "householdTimezone accessor pattern: getHouseholdTimezone(db) as the single import site for all tz reads in broker workers" + +requirements-completed: [D-05, D-06] + +# Metrics +duration: 2min +completed: 2026-06-15 +--- + +# Phase 18 Plan 01: Household Timezone Accessor + IANA Validator Summary + +**Single D-05 accessor `getHouseholdTimezone(db)` reads `household_timezone` from `app_config` with D-06 fallback chain; `isValidIanaTimezone(tz)` validates via `Intl.DateTimeFormat` try/catch (not `supportedValuesOf`)** + +## Performance + +- **Duration:** 2 min +- **Started:** 2026-06-15T02:04:31Z +- **Completed:** 2026-06-15T02:06:50Z +- **Tasks:** 2 (TDD RED + GREEN) +- **Files modified:** 2 + +## Accomplishments + +- Created `apps/api/src/lib/householdTimezone.ts` exporting `getHouseholdTimezone` and `isValidIanaTimezone` +- 11 unit tests cover all 6 required behaviors: stored row, no-row + TZ env, no-row + no-TZ, null row, valid zones, invalid zones +- D-06 fallback chain `row?.value ?? process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone` reproduced verbatim +- TypeScript typecheck (`tsc --noEmit`) clean; full 358-test suite green with no regressions +- TDD gate compliance: RED commit (`db0077c`) precedes GREEN commit (`eaceff0`) + +## Task Commits + +1. **Task 1: RED — failing unit tests** - `db0077c` (test) +2. **Task 2: GREEN — implement accessor + validator** - `eaceff0` (feat) + +**Plan metadata:** (see final docs commit below) + +## Files Created/Modified + +- `apps/api/src/lib/householdTimezone.ts` — D-05 accessor + IANA validator, exported for broker + admin route consumers +- `apps/api/tests/lib/householdTimezone.test.ts` — 11 unit tests for fallback chain and validator + +## Decisions Made + +- `isValidIanaTimezone` uses `Intl.DateTimeFormat(undefined, { timeZone: tz })` try/catch — `Intl.supportedValuesOf('timeZone')` was explicitly avoided because it omits `'UTC'` in some environments (RESEARCH Pitfall 2). +- Fallback chain matches the verbatim expression found at `reminderScheduler.ts:247` so the all-day reminder path continues to work unchanged before Plan 02 seeds a stored value. +- `db` is accepted as a parameter (not imported from `db/client.js`) to enable clean mock-based unit testing without a live MariaDB connection. + +## Deviations from Plan + +None — plan executed exactly as written. + +## Issues Encountered + +- The vitest global setup requires a MariaDB connection (`global-setup.ts`). Tests need to be run with `DB_HOST=127.0.0.1` when running locally (the `.env` sets `DB_HOST=mariadb` for Docker networking). This is a known dev environment pattern documented in `familysync-dev-stack-setup.md` and has no effect on CI (which uses the service container). + +## Known Stubs + +None — this plan creates a pure utility module with no UI stubs or placeholder data. + +## Threat Flags + +None — no new network endpoints, auth paths, file access patterns, or schema changes were introduced. The accessor is a read-only DB lookup within the trusted server process (T-18-01 disposition: accept). + +## TDD Gate Compliance + +- RED gate: `db0077c` — `test(18-01): add failing tests for household timezone accessor + IANA validator` +- GREEN gate: `eaceff0` — `feat(18-01): implement household timezone accessor + IANA validator` +- REFACTOR gate: N/A (implementation was clean on first pass) + +## Next Phase Readiness + +- `getHouseholdTimezone` and `isValidIanaTimezone` are the foundation both Plan 18-02 (broker rewire) and Plan 18-03 (admin API + Zod refine) consume. +- Plan 18-02 can import `getHouseholdTimezone` from `../lib/householdTimezone.js` to replace the bare `process.env.TZ ?? Intl…` lookups in `reminderScheduler.ts:247` and `outboxWorker.ts:501,607`. +- Plan 18-03 can import `isValidIanaTimezone` for the Zod `.refine()` on `PUT /api/admin/config/timezone`. + +## Self-Check: PASSED + +- `apps/api/src/lib/householdTimezone.ts` — FOUND +- `apps/api/tests/lib/householdTimezone.test.ts` — FOUND +- Commit `db0077c` — FOUND +- Commit `eaceff0` — FOUND + +--- +*Phase: 18-auto-timezone-detection-and-ability-to-change-timezone* +*Completed: 2026-06-15* diff --git a/.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-PATTERNS.md b/.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-PATTERNS.md new file mode 100644 index 0000000..db6b020 --- /dev/null +++ b/.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-PATTERNS.md @@ -0,0 +1,451 @@ +# Phase 18: Auto Timezone Detection and Ability to Change Timezone - Pattern Map + +**Mapped:** 2026-06-14 +**Files analyzed:** 6 (1 new lib helper, 2 broker modifications, 1 route extension, 1 client extension, 1 PWA page extension) +**Analogs found:** 6 / 6 + +--- + +## File Classification + +| New/Modified File | Role | Data Flow | Closest Analog | Match Quality | +|-------------------|------|-----------|----------------|---------------| +| `apps/api/src/lib/householdTimezone.ts` | utility | request-response (DB read) | `apps/api/src/lib/requireAdmin.ts` | role-match (both: small lib helper, single DB read, typed return) | +| `apps/api/src/routes/admin.ts` (extend) | route/controller | request-response | `apps/api/src/routes/admin.ts` (existing GET/PUT routes) | exact | +| `apps/api/src/broker/reminderScheduler.ts` (modify line 247) | broker/worker | batch | same file, all-day branch context | exact | +| `apps/api/src/broker/outboxWorker.ts` (modify lines 501, 607) | broker/worker | batch | same file, all-day branches | exact | +| `apps/pwa/src/api/client.ts` (extend) | client utility | request-response | same file, `fetchAdminCalendars` / `setSharedCalendar` | exact | +| `apps/pwa/src/routes/AdminPage.tsx` (extend) | component | request-response | same file, Shared Calendar section | exact | + +--- + +## Pattern Assignments + +### `apps/api/src/lib/householdTimezone.ts` (NEW — utility, DB read) + +**Analog:** `apps/api/src/lib/requireAdmin.ts` + +**Why this analog:** `requireAdmin.ts` is the project's only existing single-purpose lib helper that performs a single Drizzle `SELECT … .limit(1)` lookup from a DB table, matches on a PK-style key, and returns a typed result. The import style, Drizzle usage pattern, and file structure all transfer directly. + +**Imports pattern** (`requireAdmin.ts` lines 17–23): +```typescript +import '../auth/devBypass.js'; // side-effect import pattern — omit for householdTimezone.ts +import type { MiddlewareHandler } from 'hono'; +import { eq } from 'drizzle-orm'; +import { db } from '../db/client.js'; +import { users } from '../db/schema.js'; +``` + +For `householdTimezone.ts`, adapt imports to: +```typescript +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'; +``` + +**Core DB read pattern** (`requireAdmin.ts` lines 37–41): +```typescript +const [row] = await db + .select({ isAdmin: users.isAdmin }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + +if (!row?.isAdmin) { ... } +``` + +Adapt to `appConfig` PK lookup: +```typescript +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 +); +``` + +**D-06 fallback chain:** The fallback `process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone` mirrors the exact pattern currently at `reminderScheduler.ts:247` and `outboxWorker.ts:501,607`. It must be preserved verbatim as the fallback so existing tests (which pin `process.env.TZ`) continue to pass when no DB row is found. + +**IANA validator (same file):** No analog exists in the codebase — use `try/catch Intl.DateTimeFormat` (no external lib, no `Intl.supportedValuesOf` — see Research Pitfall 2 for why): +```typescript +export function isValidIanaTimezone(tz: string): boolean { + try { + Intl.DateTimeFormat(undefined, { timeZone: tz }); + return true; + } catch { + return false; + } +} +``` + +--- + +### `apps/api/src/routes/admin.ts` — extend with GET + PUT `/config/timezone` + +**Analog:** `apps/api/src/routes/admin.ts` — existing `GET /calendars` (lines 130–140) and `PUT /calendars/:id/shared` (lines 150–180). + +**Security guard pattern** (`admin.ts` lines 39–41 — DO NOT MOVE): +```typescript +// Pitfall 9: requireAdmin MUST be the first statement on the router. +// All sub-routes are protected — no path can be reached without passing this guard. +adminRouter.use('*', requireAdmin); +``` +New routes are appended **after** all existing routes. `adminRouter.use('*', requireAdmin)` already covers them positionally in Hono. + +**Simple GET pattern** (`admin.ts` lines 130–140 — exact model for GET `/config/timezone`): +```typescript +adminRouter.get('/calendars', async (c) => { + const rows = await db + .select({ + id: calendars.id, + displayName: calendars.displayName, + isShared: calendars.isShared, + }) + .from(calendars); + + return c.json({ calendars: rows }); +}); +``` + +**Validated PUT pattern** (`admin.ts` lines 150–180 — model for PUT `/config/timezone`): +```typescript +adminRouter.put('/calendars/:id/shared', async (c) => { + const targetId = parseInt(c.req.param('id'), 10); + if (isNaN(targetId)) { + return c.json({ error: 'Invalid calendar id' }, 400); + } + // ... DB write ... + return c.json({ ok: true }, 200); +}); +``` + +For the timezone PUT, use `zValidator` (already imported at line 24) instead of manual param parsing: +```typescript +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 }, 200); + }, +); +``` + +**Zod schema placement** (`admin.ts` lines 47–52 — place new schema alongside existing schemas): +```typescript +const credentialSchema = z.object({ + userId: z.number().int().positive(), + // ... +}); +``` +New `timezoneSchema` goes in the same "Zod schema" block: +```typescript +const timezoneSchema = z.object({ + timezone: z + .string() + .min(1) + .max(64) + .refine(isValidIanaTimezone, { message: 'Invalid IANA timezone identifier' }), +}); +``` + +**Import additions needed** (`admin.ts` line 28 — add `appConfig` to schema imports; add `householdTimezone.ts` exports): +```typescript +import { users, memberCredentials, calendars, appConfig } from '../db/schema.js'; +import { isValidIanaTimezone, getHouseholdTimezone } from '../lib/householdTimezone.js'; +``` + +**noEchoHook:** NOT needed for timezone routes. Timezone strings are non-sensitive (RESEARCH.md Security Domain). Standard `zValidator` without a custom hook is correct. + +--- + +### `apps/api/src/broker/reminderScheduler.ts` — modify line 247 + +**Analog:** Same file, same function (`runReminderCheck`). + +**Current pattern at line 247** (verified by research): +```typescript +const serverTz = process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone; +``` + +**Replacement pattern:** +```typescript +const serverTz = await getHouseholdTimezone(db); +``` + +The surrounding call at line 256 (`computeAlertInstantUtc(dtstartDate, leadDays, serverTz)`) is unchanged — `serverTz` remains a `string`, the contract is identical. + +**Import to add** (top of `reminderScheduler.ts`): +```typescript +import { getHouseholdTimezone } from '../lib/householdTimezone.js'; +``` + +**DB parameter:** `reminderScheduler.ts` already has `db` in scope in the `runReminderCheck` function (Drizzle queries are present elsewhere in the file). Pass it directly to `getHouseholdTimezone(db)`. + +--- + +### `apps/api/src/broker/outboxWorker.ts` — modify lines 501 and 607 + +**Analog:** Same file, same function (`runOutboxDrain`), two all-day branches. + +**Current pattern at both sites** (verified by research): +```typescript +// Line 501 (update branch): +const tz = process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone; +const leadDays = fields.reminderLeadMinutes / 1440; +allDayAlertInstantUtcUpdate = computeAlertInstantUtc(fields.start, leadDays, tz); + +// Line 607 (create branch): +const tz = process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone; +const leadDays = fields.reminderLeadMinutes / 1440; +allDayAlertInstantUtcCreate = computeAlertInstantUtc(fields.start, leadDays, tz); +``` + +**Replacement at both sites:** +```typescript +const tz = await getHouseholdTimezone(db); +``` + +Both sites are in the same `runOutboxDrain` function. If both branches can be reached in a single call, consider computing `tz` once at the top of the all-day processing block and reusing it. The surrounding calls to `computeAlertInstantUtc` are unchanged. + +**Import to add** (top of `outboxWorker.ts`): +```typescript +import { getHouseholdTimezone } from '../lib/householdTimezone.js'; +``` + +--- + +### `apps/pwa/src/api/client.ts` — extend with admin timezone functions + +**Analog:** Same file, `fetchAdminCalendars` (lines 445–454) and `setSharedCalendar` (lines 462–469). + +**GET fetch pattern** (`client.ts` lines 445–454 — exact model): +```typescript +export async function fetchAdminCalendars(): Promise { + const res = await fetch('/api/admin/calendars', { + credentials: 'include', + redirect: 'manual', + }); + + handleAuthResponse(res, 'GET /api/admin/calendars'); + + return res.json() as Promise; +} +``` + +Adapt to timezone: +```typescript +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; +} +``` + +**PUT fetch pattern** (`client.ts` lines 462–469 — model for setAdminTimezone; note `setSharedCalendar` has no body, so also borrow the body pattern from `saveCredential` at lines 429–439): +```typescript +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'); +} +``` + +**Placement:** Append in the `// ── /api/admin/* ──` section (after line 469), before the `saveMyCredential` function. + +--- + +### `apps/pwa/src/routes/AdminPage.tsx` — extend with Timezone section + +**Analog:** Same file, Shared Calendar section (lines 182–288). + +**Query pattern** (`AdminPage.tsx` lines 72–77 — exact model): +```typescript +const calendarsQuery = useQuery({ + queryKey: ['admin', 'calendars'], + queryFn: fetchAdminCalendars, + retry: false, + staleTime: 60 * 1000, +}); +``` + +Adapt: +```typescript +const timezoneQuery = useQuery({ + queryKey: ['admin', 'timezone'], + queryFn: fetchAdminTimezone, + retry: false, + staleTime: 60 * 1000, +}); +``` + +**Mutation + invalidation pattern** (`AdminPage.tsx` lines 86–94 — exact model): +```typescript +const sharedCalMutation = useMutation({ + mutationFn: (calId: number) => setSharedCalendar(calId), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['admin', 'calendars'] }); + void queryClient.invalidateQueries({ queryKey: ['events'] }); + setSelectedCalendarId(null); + }, +}); +``` + +Adapt: +```typescript +const timezoneMutation = useMutation({ + mutationFn: (tz: string) => setAdminTimezone(tz), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['admin', 'timezone'] }); + }, +}); +``` + +**Section structure** (`AdminPage.tsx` lines 182–288 — use as template): +```tsx +
+
Shared Calendar
+ {/* loading, error, empty, data states */} +
+``` + +New section follows the same four-state pattern (loading, error, empty/unset, data). Reuse `sectionLabelStyle` (defined at line 40–47) without modification. Add `marginBottom: 'var(--space-8, 32px)'` to the preceding section to separate from the new one. + +**Save button pattern** (`AdminPage.tsx` lines 244–285): +```tsx + +``` + +**IANA picker UX — no library:** Use `` + `` populated from `Intl.supportedValuesOf('timeZone')`. This is consistent with the project's hand-rolled inline-styles convention (no component library). Initial value of the input: `timezoneQuery.data?.timezone ?? ''` (shows stored value or fallback). The `isExplicitlySet` flag from the API response can display a subtle "using system default" note when `false`. + +**Import additions for AdminPage.tsx:** +```typescript +import { + fetchAdminTimezone, + setAdminTimezone, + type AdminTimezoneResponse, +} from '../api/client.js'; +``` + +--- + +## Shared Patterns + +### Admin Route Guard +**Source:** `apps/api/src/routes/admin.ts` line 41 +**Apply to:** All new routes in `admin.ts` (inherited automatically — no per-route addition needed) +```typescript +adminRouter.use('*', requireAdmin); +// This single middleware statement covers ALL routes registered on adminRouter, +// including appended routes. Do not add requireAdmin inline to individual handlers. +``` + +### Drizzle App Config Upsert (MariaDB) +**Source:** Established pattern from `admin.ts` + Drizzle mysql2 dialect (verified in RESEARCH.md) +**Apply to:** PUT `/config/timezone` handler and the seeding helper +```typescript +await db + .insert(appConfig) + .values({ key: 'household_timezone', value: timezone }) + .onDuplicateKeyUpdate({ set: { value: timezone } }); +``` +Note: `app_config.key` is the PK (`varchar(128).primaryKey()`), so this is a true upsert. `drizzle-orm@0.45.2` + `mysql2@3.22.4` support this syntax natively. + +### Drizzle Single-Row PK Lookup +**Source:** `apps/api/src/lib/requireAdmin.ts` lines 37–41 +**Apply to:** `getHouseholdTimezone` helper and GET `/config/timezone` handler +```typescript +const [row] = await db + .select({ value: appConfig.value }) + .from(appConfig) + .where(eq(appConfig.key, 'household_timezone')) + .limit(1); +// row is undefined when no row exists — use optional chaining: row?.value +``` + +### Client Auth Response Handling +**Source:** `apps/pwa/src/api/client.ts` lines 51–58 (`handleAuthResponse`) +**Apply to:** All new fetch wrappers in `client.ts` +```typescript +function handleAuthResponse(res: Response, label: string): void { + if (res.type === 'opaqueredirect' || res.status === 401) { + throw new SessionExpiredError(); + } + if (!res.ok) { + throw new Error(`${label} failed: ${res.status}`); + } +} +// Usage: call immediately after fetch, before res.json() +handleAuthResponse(res, 'GET /api/admin/config/timezone'); +``` + +### TanStack Query + Mutation + Invalidation +**Source:** `apps/pwa/src/routes/AdminPage.tsx` lines 64–94 +**Apply to:** New Timezone section in `AdminPage.tsx` +```typescript +// useQueryClient() already called at top of AdminPage — no additional call needed +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'] }), +}); +``` + +--- + +## No Analog Found + +| File | Role | Data Flow | Reason | +|------|------|-----------|--------| +| `apps/api/tests/lib/householdTimezone.test.ts` | test | — | No existing `tests/lib/` helper test exists; follow `tests/broker/reminderScheduler.test.ts` vitest structure for the unit test pattern | + +--- + +## Metadata + +**Analog search scope:** `apps/api/src/lib/`, `apps/api/src/routes/`, `apps/api/src/broker/`, `apps/pwa/src/api/`, `apps/pwa/src/routes/` +**Files read:** 6 source files +**Pattern extraction date:** 2026-06-14