16 KiB
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):
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:
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):
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:
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):
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):
// 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):
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):
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:
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):
const credentialSchema = z.object({
userId: z.number().int().positive(),
// ...
});
New timezoneSchema goes in the same "Zod schema" block:
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):
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):
const serverTz = process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
Replacement pattern:
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):
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):
// 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:
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):
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):
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:
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):
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):
const calendarsQuery = useQuery({
queryKey: ['admin', 'calendars'],
queryFn: fetchAdminCalendars,
retry: false,
staleTime: 60 * 1000,
});
Adapt:
const timezoneQuery = useQuery({
queryKey: ['admin', 'timezone'],
queryFn: fetchAdminTimezone,
retry: false,
staleTime: 60 * 1000,
});
Mutation + invalidation pattern (AdminPage.tsx lines 86–94 — exact model):
const sharedCalMutation = useMutation({
mutationFn: (calId: number) => setSharedCalendar(calId),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['admin', 'calendars'] });
void queryClient.invalidateQueries({ queryKey: ['events'] });
setSelectedCalendarId(null);
},
});
Adapt:
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):
<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):
<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:
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)
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
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
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
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
// 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