Files
2026-06-18 22:21:38 -04:00

16 KiB
Raw Blame History

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):

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 3741):

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 130140) and PUT /calendars/:id/shared (lines 150180).

Security guard pattern (admin.ts lines 3941 — 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 130140 — 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 150180 — 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 4752 — 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 445454) and setSharedCalendar (lines 462469).

GET fetch pattern (client.ts lines 445454 — 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 462469 — model for setAdminTimezone; note setSharedCalendar has no body, so also borrow the body pattern from saveCredential at lines 429439):

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):

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 8694 — 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 182288 — 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 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):

<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 3741 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 5158 (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 6494 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