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
4 changed files with 588 additions and 12 deletions
Showing only changes of commit ac60161726 - Show all commits
+2 -2
View File
@@ -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. **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) **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. **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: Plans:
**Wave 1** **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)* **Wave 2** *(blocked on Wave 1 completion)*
+11 -10
View File
@@ -4,13 +4,13 @@ milestone: v1.1
milestone_name: Operability & Polish milestone_name: Operability & Polish
status: executing status: executing
stopped_at: Phase 18 context gathered stopped_at: Phase 18 context gathered
last_updated: "2026-06-15T01:53:38.101Z" last_updated: "2026-06-15T02:08:26.245Z"
last_activity: 2026-06-15 -- Phase 18 planning complete last_activity: 2026-06-15
progress: progress:
total_phases: 23 total_phases: 23
completed_phases: 9 completed_phases: 9
total_plans: 32 total_plans: 36
completed_plans: 32 completed_plans: 33
percent: 39 percent: 39
--- ---
@@ -21,14 +21,14 @@ progress:
See: .planning/PROJECT.md (updated 2026-06-10) 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 **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 11per-event-reminders **Current focus:** Phase 18auto-timezone-detection-and-ability-to-change-timezone
## Current Position ## Current Position
Phase: 13 Phase: 18 (auto-timezone-detection-and-ability-to-change-timezone) — EXECUTING
Plan: Not started Plan: 2 of 4
Status: Ready to execute 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) ### ✅ 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 P03 | 720 | 3 tasks | 6 files |
| Phase 10-admin-role-settings P04 | 1315 | 3 tasks | 8 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 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 ## Accumulated Context
@@ -252,9 +253,9 @@ Recent decisions affecting current work:
## Session Continuity ## Session Continuity
Last session: 2026-06-15T01:25:46.029Z Last session: 2026-06-15T02:08:26.229Z
Stopped at: Phase 18 context gathered 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 ## Operator Next Steps
@@ -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*
@@ -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 1723):
```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 3741):
```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 130140) and `PUT /calendars/:id/shared` (lines 150180).
**Security guard pattern** (`admin.ts` lines 3941 — 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 130140 — 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 150180 — 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 4752 — 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 445454) and `setSharedCalendar` (lines 462469).
**GET fetch pattern** (`client.ts` lines 445454 — exact model):
```typescript
export async function fetchAdminCalendars(): Promise<AdminCalendarsResponse> {
const res = await fetch('/api/admin/calendars', {
credentials: 'include',
redirect: 'manual',
});
handleAuthResponse(res, 'GET /api/admin/calendars');
return res.json() as Promise<AdminCalendarsResponse>;
}
```
Adapt to timezone:
```typescript
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>;
}
```
**PUT fetch pattern** (`client.ts` lines 462469 — model for setAdminTimezone; note `setSharedCalendar` has no body, so also borrow the body pattern from `saveCredential` at lines 429439):
```typescript
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');
}
```
**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 182288).
**Query pattern** (`AdminPage.tsx` lines 7277 — 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 8694 — 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 182288 — use as template):
```tsx
<section aria-label="Shared Calendar">
<div style={sectionLabelStyle}>Shared Calendar</div>
{/* loading, error, empty, data states */}
</section>
```
New section follows the same four-state pattern (loading, error, empty/unset, data). Reuse `sectionLabelStyle` (defined at line 4047) without modification. Add `marginBottom: 'var(--space-8, 32px)'` to the preceding section to separate from the new one.
**Save button pattern** (`AdminPage.tsx` lines 244285):
```tsx
<button
type="button"
disabled={saveDisabled}
onClick={() => { /* mutate */ }}
style={{
background: saveDisabled ? 'var(--color-border, #E2E4E9)' : 'var(--color-member-0, #4A90D9)',
color: '#ffffff',
border: 'none',
cursor: saveDisabled ? 'default' : 'pointer',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
minHeight: '44px',
minWidth: '44px',
padding: '0 var(--space-6, 24px)',
borderRadius: 'var(--space-1, 4px)',
fontFamily: 'var(--font-family-base)',
transition: 'background 0.15s ease',
}}
>
{mutation.isPending ? 'Saving…' : 'Save'}
</button>
```
**IANA picker UX — no library:** Use `<input type="text" list="iana-zones">` + `<datalist>` 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 3741
**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 5158 (`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 6494
**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