Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
452 lines
16 KiB
Markdown
452 lines
16 KiB
Markdown
# 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
|