Files
familysync/.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-PATTERNS.md
T

452 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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