# 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<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 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<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 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
<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 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
<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 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
