# 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