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

39 KiB
Raw Blame History

Phase 18: Auto Timezone Detection and Ability to Change Timezone - Research

Researched: 2026-06-15 Domain: Server-side timezone configuration, Admin settings, IANA validation Confidence: HIGH (all findings grounded in actual codebase inspection)


<user_constraints>

User Constraints (from CONTEXT.md)

Locked Decisions

  • D-01: Single household-wide timezone, stored in app_config (key household_timezone, IANA string value). No per-member users.timezone column.
  • D-02: Auto-detect the browser IANA timezone (Intl.DateTimeFormat().resolvedOptions().timeZone) and use it to seed the stored value during the Phase 12 setup wizard / first run.
  • D-03: After seeding, timezone changes only via the settings UI. No auto-overwrite on later login/detection differences. (Optional drift notice allowed, not required.)
  • D-04: Surface the timezone in the existing role-gated /admin Settings (Phase 10 requireAdmin boundary) and seed it from the Phase 12 setup wizard.
  • D-05: The stored timezone becomes the source of truth for the server-side all-day "9 AM local" reminder computation (reminderScheduler.ts, outboxWorker.ts), replacing the bare process.env.TZ ?? Intl… lookup at those sites.
  • D-06: Fallback chain when household_timezone is unset: fall back to process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone.
  • D-07: Display rendering and timed-event write serialization stay browser-local and unchanged. Must NOT touch eventDateTime.ts or hydrateEvents.ts.

Claude's Discretion

  • Timezone picker UX: a searchable IANA dropdown. Validate the value is a real IANA zone before storing.
  • Exact app_config key name and the read/cache strategy for the stored value in the scheduler/outbox (e.g. read-per-run vs cached).
  • Whether to show a non-blocking "detected zone differs" notice on login (allowed per D-03, not required).

Deferred Ideas (OUT OF SCOPE)

  • Per-member timezones (would add users.timezone + per-row scheduler logic).
  • Driving display/timed reminders off the stored tz (deliberately excluded, D-07). </user_constraints>

Summary

Phase 18 is a tightly scoped wiring change: one new app_config key (household_timezone) becomes the source of truth for the server-side all-day reminder computation, replacing two bare process.env.TZ ?? Intl… lookups in reminderScheduler.ts (line 247) and outboxWorker.ts (lines 501 and 607). A single shared accessor helper reads this key from the DB with the D-06 fallback, ensuring both scheduler sites stay synchronized. No new npm packages are required. No schema migration is needed — app_config already exists and accepts arbitrary keys as additive rows.

The admin surface (Phase 10 adminRouter, requireAdmin, AdminPage.tsx) already provides the exact pattern to extend: add GET + PUT endpoints to /api/admin/config/timezone following the same route file, client API function, TanStack Query + mutation pattern already present in AdminPage.tsx. The IANA validation uses a Zod .refine() with a try/catch on Intl.DateTimeFormat — no external library needed.

Phase 12 (Initial Setup Wizard) has NOT been executed yet. Its status is draft (only a UI spec exists, no route code). The household_timezone seeding must therefore be planned as a Phase 18 deliverable that is additive/optional — a standalone seed endpoint (POST /api/admin/config/timezone/seed) or an inline seed in an early Phase 18 task — so Phase 18 is not blocked. When Phase 12 eventually executes, it calls the same write endpoint.

Primary recommendation: Extract a getHouseholdTimezone(db): Promise<string> helper in apps/api/src/lib/householdTimezone.ts, add GET/PUT endpoints on adminRouter, extend AdminPage.tsx with a new Timezone section, and wire both scheduler sites through the helper. No new dependencies. Read-per-run (not cached) for correctness.


Architectural Responsibility Map

Capability Primary Tier Secondary Tier Rationale
Store household timezone Database / Storage API app_config table; PK-keyed key/value row
Read timezone for scheduling API / Backend reminderScheduler.ts + outboxWorker.ts run server-side; DB read at each tick
Validate IANA timezone string API / Backend Browser / Client Server validates before write (real boundary); browser validates before submit (UX)
Admin read/write timezone API / Backend Frontend Server requireAdmin is server enforced; client isAdmin is UX only (Phase 10 D-03)
Browser timezone detection Browser / Client Intl.DateTimeFormat().resolvedOptions().timeZone is client-side only
Display rendering, timed-event serialization Browser / Client Unchanged by D-07; remains browser-local

Standard Stack

Core (all already installed — no new packages)

Library Version Purpose Why Standard
drizzle-orm 0.45.2 DB read/write for app_config Already in stack; eq() + .select() / .insert().onDuplicateKeyUpdate() for upsert
zod 3.25.x IANA string validation Already in stack; .refine() with Intl.DateTimeFormat try/catch
@hono/zod-validator 0.8.0 Route body validation Already wired in adminRouter
hono 4.12.23 Route handlers Already in stack; extend adminRouter
@tanstack/react-query 5.101.0 PWA data fetch + mutation Already in AdminPage.tsx
Native Intl API Node 22 built-in IANA validation + browser detection No package needed

No new npm installs required for this phase.

Supporting

Library Version Purpose When to Use
mysql2 (via drizzle) 3.22.4 Underlying driver Used implicitly by drizzle; no direct use needed

Alternatives Considered

Instead of Could Use Tradeoff
try/catch Intl.DateTimeFormat Intl.supportedValuesOf('timeZone') membership check supportedValuesOf excludes 'UTC', 'GMT', 'Etc/UTC' (verified in Node 22) — those ARE valid. The try/catch approach accepts all valid zones including UTC variants [VERIFIED: Node 22 runtime test]
Read-per-run DB read In-memory TTL cache Cache adds invalidation complexity; read-per-run means changes propagate within 60s (one scheduler tick) with no restart; PK lookup is negligible cost
Extend existing adminRouter New router/file The existing pattern (adminRouter.get/put, requireAdmin first, zValidator) is correct and established — extending is the right choice

Package Legitimacy Audit

This phase installs no new packages. All capabilities use packages already in the monorepo.

Packages removed due to [SLOP] verdict: none Packages flagged as suspicious [SUS]: none (no new installs)

Note: hono was flagged SUS by the registry scanner due to a recent publish date, but it is a locked stack choice from CLAUDE.md and already installed. This flag does not apply to existing dependencies.


Architecture Patterns

System Architecture Diagram

Browser (PWA)                    API Server                          MariaDB
─────────────────────────────────────────────────────────────────────────────
[AdminPage /admin]               [adminRouter]                  [app_config]
  useQuery('admin','timezone') → GET /api/admin/config/timezone → SELECT key='household_timezone'
  useMutation(PUT)             → PUT /api/admin/config/timezone → INSERT ... ON DUPLICATE KEY UPDATE
  <TimezonePickerSection>         requireAdmin (DB check)
  Intl.DateTimeFormat()           zod IANA validate
  .resolvedOptions().timeZone     → store value
  (browser detection for seed)

[reminderScheduler.ts]           [getHouseholdTimezone(db)]     [app_config]
  runReminderCheck() every 60s → SELECT key='household_timezone' → value | null
                                  fallback: process.env.TZ ?? Intl…
                                  pass tz → computeAlertInstantUtc()

[outboxWorker.ts]                [getHouseholdTimezone(db)]     [app_config]
  processOutboxRow()           → SELECT key='household_timezone' → value | null
  (allDay branch: lines 501, 607) fallback chain                 (same key, same fallback)
                                  pass tz → computeAlertInstantUtc()
apps/api/src/
├── lib/
│   └── householdTimezone.ts   # NEW: getHouseholdTimezone(db) helper + IANA validator
├── routes/
│   └── admin.ts               # EXTEND: add GET + PUT /config/timezone endpoints
├── broker/
│   ├── reminderScheduler.ts   # MODIFY: line 247 — replace bare process.env.TZ lookup
│   └── outboxWorker.ts        # MODIFY: lines 501, 607 — replace bare process.env.TZ lookups
apps/pwa/src/
├── api/
│   └── client.ts              # EXTEND: add fetchAdminTimezone() + setAdminTimezone()
└── routes/
    └── AdminPage.tsx          # EXTEND: add Timezone section after Shared Calendar section

Pattern 1: Stored TZ Accessor Helper (getHouseholdTimezone)

What: A single exported async function that reads household_timezone from app_config and applies the D-06 fallback chain.

When to use: Called at the start of each all-day processing block in reminderScheduler.ts and outboxWorker.ts. Not called for timed events (those don't use local time).

Example:

// apps/api/src/lib/householdTimezone.ts
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';

/**
 * Read the household timezone from app_config.
 * D-06 fallback: process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone
 * (same fallback the bare lookups used before Phase 18).
 *
 * Read-per-call so timezone changes propagate within one scheduler tick
 * without requiring a worker restart.
 */
export async function getHouseholdTimezone(
  db: MySql2Database<typeof schema>,
): Promise<string> {
  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
  );
}

/**
 * Validate that a string is an IANA timezone accepted by the JS engine.
 * try/catch on Intl.DateTimeFormat covers 'UTC', 'GMT', 'Etc/UTC', and all
 * 418 named IANA zones. Intl.supportedValuesOf('timeZone') is NOT used because
 * it excludes 'UTC' and 'Etc/*' variants in Node 22 and Chrome.
 */
export function isValidIanaTimezone(tz: string): boolean {
  try {
    Intl.DateTimeFormat(undefined, { timeZone: tz });
    return true;
  } catch {
    return false;
  }
}

Usage in reminderScheduler.ts (line 247 replacement):

// BEFORE (line 247):
const serverTz = process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone;

// AFTER:
const serverTz = await getHouseholdTimezone(db);

Usage in outboxWorker.ts (lines 501 and 607 replacement):

// BEFORE (line 501):
const tz = process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone;

// AFTER:
const tz = await getHouseholdTimezone(db);

Pattern 2: Admin Endpoint (GET + PUT /api/admin/config/timezone)

What: Two new routes on adminRouter, following the exact pattern of existing admin routes. GET reads the current value (with fallback), PUT validates + upserts.

When to use: Admin settings UI reads/writes.

Example:

// In apps/api/src/routes/admin.ts (extend existing file)

const timezoneSchema = z.object({
  timezone: z.string().refine(isValidIanaTimezone, { message: 'Invalid IANA timezone' }),
});

// GET /api/admin/config/timezone
adminRouter.get('/config/timezone', async (c) => {
  const [row] = await db
    .select({ value: appConfig.value })
    .from(appConfig)
    .where(eq(appConfig.key, 'household_timezone'))
    .limit(1);

  const timezone =
    row?.value ??
    process.env.TZ ??
    Intl.DateTimeFormat().resolvedOptions().timeZone;

  return c.json({ timezone, isExplicitlySet: row?.value != null });
});

// PUT /api/admin/config/timezone
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 });
  },
);

Pattern 3: PWA TanStack Query + Mutation (AdminPage extension)

What: A new Timezone section in AdminPage.tsx, mirroring the existing Shared Calendar section pattern exactly.

Example:

// In apps/pwa/src/api/client.ts (extend existing file)
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>;
}

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');
}
// In AdminPage.tsx (new section, same query/mutation pattern as calendarsQuery)
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'] });
  },
});

IANA Picker UX — no new library: A <select> with a <datalist> or a controlled <input> + filtered <select> using Intl.supportedValuesOf('timeZone') on the browser side. The project uses no component library (hand-rolled inline styles, project convention). A simple searchable <select> is sufficient:

// Browser-side IANA list for the picker (client only)
const IANA_ZONES = typeof Intl.supportedValuesOf === 'function'
  ? Intl.supportedValuesOf('timeZone')
  : [];
// Note: browser Intl.supportedValuesOf works in Chrome 93+, Safari 14.1+, Firefox 91+.
// The 'UTC' omission from the list is benign on the browser side — server validates with
// try/catch, so a manually-typed 'UTC' still passes. The picker's filtered <select>
// shows continent/ocean zones only; the text input allows override.

A <input type="text" list="iana-zones"> + <datalist id="iana-zones"> with all zone options is the lowest-friction approach for a non-technical user — they can type their city name and browser autocompletes from the datalist.

Pattern 4: Phase 12 Wizard Seeding (additive, non-blocking)

What: A standalone seed write so Phase 18 can be executed without waiting for Phase 12.

When to use: Phase 18 executor includes a Wave 0 task: add a POST /api/admin/config/timezone/seed endpoint (or inline in the wizard's complete endpoint in Phase 12). The seeding endpoint writes household_timezone only if it is not already set (no-overwrite-if-set guard per D-03).

Recommendation: Make Phase 18 include a seeding helper in the API. Phase 12 (when executed) calls the same PUT endpoint or uses the helper directly in the /setup/complete handler.

// Seeding write (wizard or any first-run path)
// Only write if not already set (no silent overwrite per D-03)
async function seedTimezoneIfUnset(db, browserTz: string) {
  const [existing] = await db
    .select({ value: appConfig.value })
    .from(appConfig)
    .where(eq(appConfig.key, 'household_timezone'))
    .limit(1);
  if (!existing?.value) {
    await db
      .insert(appConfig)
      .values({ key: 'household_timezone', value: browserTz })
      .onDuplicateKeyUpdate({ set: { value: browserTz } });
  }
}

Anti-Patterns to Avoid

  • Modifying eventDateTime.ts or hydrateEvents.ts: D-07 is a hard boundary. The browser-local write/display path was deliberately fixed in earlier phases. Any touch to these files is out of scope and risks regression.
  • Using Intl.supportedValuesOf('timeZone') for server-side validation: It excludes 'UTC' and 'Etc/*' variants in Node 22 and Chrome. Use try/catch Intl.DateTimeFormat instead.
  • Caching the timezone value in memory: Read-per-call in the scheduler and outbox is correct. In-memory caching requires invalidation signaling and doesn't meaningfully improve a 60s interval.
  • Installing a timezone-list package: The project convention is hand-rolled, no new dependencies. Intl.supportedValuesOf('timeZone') is available in all target browsers and Node 22.
  • Auto-overwriting household_timezone on login: D-03 forbids this. Only the settings UI write and the first-run seed may write this key.
  • Putting the seeding call on the /api/me route (or OIDC callback): This would trigger on every login, violating D-03 (no auto-overwrite after seeding).

Don't Hand-Roll

Problem Don't Build Use Instead Why
IANA timezone validation Custom regex or static list try/catch Intl.DateTimeFormat() Engine-validated, handles all variants including UTC/Etc, zero deps
IANA zone list for picker npm moment-timezone, tzdata Intl.supportedValuesOf('timeZone') Built-in to Node 22 and modern browsers, 418 zones, no package needed
Key/value DB upsert Manual SELECT + conditional INSERT Drizzle .insert().onDuplicateKeyUpdate() MariaDB-compatible upsert pattern; Drizzle mysql dialect handles it correctly

Key insight: All complexity for this phase lives in the wiring, not the algorithms. computeAlertInstantUtc is already correct; the phase only changes what timezone string is passed to it.


Confirmed Code Touchpoints (verified by file inspection)

reminderScheduler.ts — line 247 (confirmed, CONTEXT hint was accurate)

// Line 247 (VERIFIED by grep):
const serverTz = process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
// Line 256: passed to computeAlertInstantUtc(dtstartDate, leadDays, serverTz)

Contract: serverTz is a string passed as the third argument to computeAlertInstantUtc. The function signature is computeAlertInstantUtc(eventDateStr: string, leadDays: number, tz: string): Date (vevent.ts line 240). The contract is tz-string-in, unchanged. Phase 18 only changes the value of serverTz.

outboxWorker.ts — lines 501 and 607 (confirmed, both sites)

// Line 501 (update branch, allDay+explicit reminder):
const tz = process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
const leadDays = fields.reminderLeadMinutes / 1440;
allDayAlertInstantUtcUpdate = computeAlertInstantUtc(fields.start, leadDays, tz);

// Line 607 (create branch, allDay+reminder):
const tz = process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
const leadDays = fields.reminderLeadMinutes / 1440;
allDayAlertInstantUtcCreate = computeAlertInstantUtc(fields.start, leadDays, tz);

Both sites are in the runOutboxDrain function. Both are in all-day event branches. The CONTEXT hint (§~501, §~607) is accurate.

vevent.tscomputeAlertInstantUtc signature (lines 240343, confirmed)

// Line 240 (VERIFIED):
export function computeAlertInstantUtc(eventDateStr: string, leadDays: number, tz: string): Date

The function is a pure computation: event date string, lead days, tz string → UTC Date. The tz argument is used via Intl.DateTimeFormat internally. No change to this function. Phase 18 only changes what is passed as tz.

schema.tsappConfig table (line 282, confirmed)

// Line 282 (VERIFIED):
export const appConfig = mysqlTable('app_config', {
  key: varchar('key', { length: 128 }).primaryKey(),
  value: text('value'), // nullable
  updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(),
});

The household_timezone key is a new additive row — no migration needed. The existing 0001_famous_mad_thinker.sql migration already created this table.

admin.ts (Phase 10 route) — confirmed extend pattern

Current routes: GET /members, POST /credentials, GET /calendars, PUT /calendars/:id/shared. All gated by adminRouter.use('*', requireAdmin) as first statement. New endpoints extend the same file and inherit the guard.

AdminPage.tsx — confirmed extend pattern

  • Uses useQuery(['admin', 'calendars'], ...) + useMutation + useQueryClient + invalidateQueries pattern.
  • New Timezone section follows the same structure as the Shared Calendar section.
  • sectionLabelStyle is defined at top of file and reused — new section reuses it.

Phase 12 status — NOT YET EXECUTED

Confirmed by STATE.md (current position is Phase 13, Phase 12 only has 12-UI-SPEC.md under .planning/phases/12-initial-setup-wizard/). No setup route code exists in apps/api/src/ or apps/pwa/src/. Phase 18 must be self-contained: the timezone seeding must work without Phase 12.


Common Pitfalls

Pitfall 1: Both Scheduler Sites Must Use the Same Accessor

What goes wrong: Separately duplicating the DB read in both reminderScheduler.ts and outboxWorker.ts. If you add the read inline in both files separately, future changes must be made in two places, and they can drift.

Why it happens: The CONTEXT.md says "both must route through the same stored-value accessor" — this is easy to forget if planning treats the two files as independent tasks.

How to avoid: Define getHouseholdTimezone(db) in apps/api/src/lib/householdTimezone.ts in Wave 0 / Plan 1. Both scheduler files import from there.

Warning signs: If a plan task says "add getHouseholdTimezone" to both reminderScheduler.ts and outboxWorker.ts without a shared lib — wrong approach.

Pitfall 2: Intl.supportedValuesOf Excludes 'UTC'

What goes wrong: Server-side Zod validation uses Intl.supportedValuesOf('timeZone').includes(tz) — then a user who types 'UTC' gets a 400 error even though it is a valid timezone.

Why it happens: The MDN docs say supportedValuesOf('timeZone') works, but the spec excludes 'UTC', 'GMT', 'Etc/UTC', and all Etc/* identifiers from the return value in Node 22 and Chrome (verified with runtime test: Intl.supportedValuesOf('timeZone').includes('UTC')false).

How to avoid: Use try/catch Intl.DateTimeFormat(undefined, { timeZone: val }) in the Zod .refine(). This accepts all valid zones including UTC variants.

Warning signs: Unit test for 'UTC' input fails with 400 from the validation endpoint.

Pitfall 3: requireAdmin Must Remain First Middleware Statement

What goes wrong: Adding new routes before adminRouter.use('*', requireAdmin) or in a position where the middleware doesn't cover them.

Why it happens: The middleware is positional in Hono — routes registered before use('*', ...) are not covered.

How to avoid: New endpoints are appended after the existing routes in admin.ts. The adminRouter.use('*', requireAdmin) is already the first statement (line 41) and covers all routes registered on adminRouter regardless of append order in Hono.

Warning signs: Test for non-admin user on new endpoint returns 200 instead of 403.

Pitfall 4: Drizzle onDuplicateKeyUpdate Syntax for MariaDB

What goes wrong: Using wrong Drizzle syntax for upsert, or trying drizzle-kit push (forbidden — D-Task5-DDL).

Why it happens: Drizzle's mysql dialect supports .insert().onDuplicateKeyUpdate({ set: { ... } }). The app_config key is the PK, so inserting with an existing key is an upsert. No migration needed for a new key; only a data write.

How to avoid: Use the exact Drizzle pattern: db.insert(appConfig).values({...}).onDuplicateKeyUpdate({ set: { value: ... } }). Verified compatible with drizzle-orm@0.45.2 + mysql2@3.22.4 (MariaDB wire-compatible).

Warning signs: Drizzle throws Duplicate entry error instead of updating.

Pitfall 5: Existing All-Day Scheduler Tests Pin process.env.TZ

What goes wrong: After Phase 18 wires getHouseholdTimezone(db), the existing all-day tests in reminderScheduler.test.ts that pin process.env.TZ = 'America/New_York' rely on the old bare process.env.TZ read. When the code switches to a DB read, the mock DB must return the expected timezone, otherwise the test reads null → falls back to process.env.TZ → still works.

Why it's actually safe: The D-06 fallback chain preserves process.env.TZ when household_timezone is unset. As long as the test's mock DB returns no household_timezone row (which it won't, since tests mock the DB to return only event rows), the fallback to process.env.TZ kicks in — tests continue to pass without modification. This is the backward-compat guarantee.

How to verify: Existing all-day tests pass without any modification (fallback fires). New Phase 18 tests for the DB-stored case mock the DB to also return the app_config row.

Warning signs: Existing all-day tests fail after Phase 18 wiring — indicates fallback isn't implemented correctly.

Pitfall 6: Phase 12 Seeding Must Not Overwrite After First Set

What goes wrong: Phase 12 (or any seeding path) unconditionally writes household_timezone on every setup/login, violating D-03.

How to avoid: The seed write must check if the key is already set before writing. Use a SELECT + conditional INSERT pattern, or use .onDuplicateKeyUpdate only with a no-op set guard: INSERT ... ON DUPLICATE KEY UPDATE value = IF(value IS NULL, VALUES(value), value). Simpler: SELECT first, only INSERT if row?.value is null.


Code Examples

IANA validation — production-safe, UTC-inclusive

// Source: Node 22 runtime verification (2026-06-15)
// try/catch accepts UTC, GMT, Etc/UTC, Etc/GMT, and all 418 continent/ocean zones
export function isValidIanaTimezone(tz: string): boolean {
  try {
    Intl.DateTimeFormat(undefined, { timeZone: tz });
    return true;
  } catch {
    return false;
  }
}

Zod schema for PUT /api/admin/config/timezone body

// Source: zod.dev official docs (.refine() pattern) + project CLAUDE.md (zod 3.24.x)
const timezoneSchema = z.object({
  timezone: z
    .string()
    .min(1)
    .max(64)
    .refine(isValidIanaTimezone, { message: 'Invalid IANA timezone identifier' }),
});

Drizzle upsert for app_config (MariaDB compatible)

// Source: drizzle-orm mysql2 dialect, verified pattern in existing codebase
import { appConfig } from '../db/schema.js';

await db
  .insert(appConfig)
  .values({ key: 'household_timezone', value: timezone })
  .onDuplicateKeyUpdate({ set: { value: timezone } });

Browser timezone seeding (PWA side, first-run only)

// Called once during Phase 12 wizard complete step (or Phase 18 standalone seed)
// D-03: only seed if not yet set (server enforces IF NOT EXISTS logic)
const browserTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
await setAdminTimezone(browserTz); // Server applies no-overwrite guard

State of the Art

Old Approach Current Approach When Changed Impact
process.env.TZ ?? Intl… bare lookup in scheduler Stored app_config.household_timezone + fallback Phase 18 Timezone correct across Docker restarts where TZ env is unset
No user-facing timezone setting Admin UI picker + stored value Phase 18 Admin can fix "9 AM local" if it fires at wrong time

Deprecated/outdated:

  • Direct process.env.TZ reads in scheduler/outbox for all-day logic: these sites are replaced by getHouseholdTimezone(db) in Phase 18.

Phase 12 Dependency Analysis

Phase 12 (Initial Setup Wizard) has status draft — only 12-UI-SPEC.md exists. No routes, no PWA wizard route code. The Phase 12 UI spec describes a 5-step wizard where Step 5 ("Calendar Credential") calls POST /api/setup/complete. The timezone seeding would naturally go here but is not yet implemented.

Phase 18 must be self-contained. Recommended approach: Phase 18 adds a seed-timezone endpoint or admin-writable PUT endpoint that Phase 12 can later call. The AdminPage.tsx timezone section (Phase 18 deliverable) also shows the browser-detected zone as the pre-filled value in the picker, so an admin can confirm or change it on first login — covering the seeding requirement without needing Phase 12.

Ordering: Phase 18 executes before Phase 12. Phase 12 can call PUT /api/admin/config/timezone (or the seeding helper) during the wizard's POST /api/setup/complete to pre-populate from the browser.


Environment Availability

This phase is code/config-only (new routes + lib helper + PWA section). No external service dependencies beyond the existing MariaDB and API server.

Dependency Required By Available Version Fallback
MariaDB (app_config table) DB read/write Already in stack MariaDB 11 (Unraid)
Node 22 Intl API IANA validation Built-in Node 22.22.3 (confirmed)
Browser Intl.DateTimeFormat Browser detection Chrome 93+, Safari 14.1+, Firefox 91+ Built-in

Validation Architecture

workflow.nyquist_validation: true in .planning/config.json — section required.

Test Framework

Property Value
Framework (API) Vitest 4.1.8
Framework (PWA) Vitest 4.1.8 + jsdom
Config file (API) apps/api/vitest.config.ts
Config file (PWA) apps/pwa/vitest.config.ts
Quick run command (API) pnpm --filter @familysync/api exec vitest run tests/lib/householdTimezone.test.ts
Full suite command pnpm --filter @familysync/api test && pnpm --filter @familysync/pwa test

TDD mode is ON. All new files need RED tests before implementation.

Phase Requirements → Test Map

Req ID Behavior Test Type Automated Command File Exists?
D-05/D-06 getHouseholdTimezone returns stored value when set Unit vitest run tests/lib/householdTimezone.test.ts No — Wave 0
D-05/D-06 getHouseholdTimezone falls back to process.env.TZ when unset Unit same No — Wave 0
D-05/D-06 getHouseholdTimezone falls back to Intl when both unset Unit same No — Wave 0
D-05 reminderScheduler all-day branch uses stored TZ Unit (mock DB) vitest run tests/broker/reminderScheduler.test.ts Exists (extend)
D-05 outboxWorker all-day branch uses stored TZ Unit (mock DB) vitest run tests/broker/outboxWorker.test.ts Exists (extend)
IANA validation Invalid zone → 400 from PUT endpoint Integration vitest run tests/routes/admin.test.ts Exists (extend)
IANA validation 'UTC' accepted → 200 from PUT endpoint Integration same Exists (extend)
requireAdmin GET /api/admin/config/timezone → 403 non-admin Integration same Exists (extend)
requireAdmin PUT /api/admin/config/timezone → 403 non-admin Integration same Exists (extend)
D-06 backward compat Existing all-day scheduler tests still pass (fallback) Unit (existing) vitest run tests/broker/reminderScheduler.test.ts Exists (no change)
End-to-end Admin sets TZ → next 9AM reminder fires in new zone Manual playwright-cli (Chromium) No — verify step

Existing Test Infrastructure Notes

  • tests/broker/reminderScheduler.test.ts lines 656729: existing all-day tests pin process.env.TZ = 'America/New_York' in beforeEach. After Phase 18 wiring, these tests mock the DB to return only event rows (not app_config rows), so getHouseholdTimezone finds no stored value and falls back to process.env.TZ — tests continue to pass without modification (D-06 backward compat).
  • New Phase 18 all-day tests that verify stored TZ behavior: mock the DB to return { key: 'household_timezone', value: 'America/Chicago' } and assert firing at 9 AM Chicago time.
  • tests/routes/admin.test.ts: integration tests hit real MariaDB (familysync_test DB). New timezone tests follow same beforeEach/afterEach DB cleanup pattern.

Sampling Rate

  • Per task commit: pnpm --filter @familysync/api exec vitest run tests/lib/householdTimezone.test.ts
  • Per wave merge: pnpm --filter @familysync/api test && pnpm --filter @familysync/pwa test
  • Phase gate: Full suite green (all 244+ existing tests pass) + manual admin change-TZ round-trip via playwright-cli

Wave 0 Gaps

  • apps/api/tests/lib/householdTimezone.test.ts — covers D-05/D-06 accessor + fallback chain + IANA validator
  • Extend apps/api/tests/routes/admin.test.ts with GET/PUT timezone endpoint tests (403 non-admin, IANA validation, round-trip)
  • Extend apps/api/tests/broker/reminderScheduler.test.ts with stored-TZ all-day test

Security Domain

security_enforcement: true (default) in .planning/config.json.

Applicable ASVS Categories

ASVS Category Applies Standard Control
V2 Authentication No (OIDC already handled by @hono/oidc-auth)
V3 Session Management No (session cookie already handled)
V4 Access Control Yes requireAdmin middleware (DB-enforced, not client flag)
V5 Input Validation Yes Zod .refine(isValidIanaTimezone) on PUT body
V6 Cryptography No No new crypto; timezone is a non-sensitive plain string

Known Threat Patterns for This Stack

Pattern STRIDE Standard Mitigation
Non-admin sets timezone via direct API call Elevation of Privilege requireAdmin middleware is FIRST statement on adminRouter; server 403 before any handler
Invalid/malicious timezone string in PUT body Tampering Zod .refine(isValidIanaTimezone) rejects before DB write; try/catch Intl.DateTimeFormat is safe (no eval)
Timezone injection causing log pollution Information Disclosure IANA strings are limited to standard zone identifiers; Intl validation rejects anything else

Note: Timezone strings are non-sensitive (not credentials, not PII). No special sanitization beyond IANA validation is required. The noEchoHook pattern from credentials is NOT needed here.


Assumptions Log

# Claim Section Risk if Wrong
A1 Read-per-run DB lookup is negligible overhead for a 60s interval scheduler Architecture Patterns If DB is slow (unlikely for PK lookup), could add a few ms to each tick. Impact: none on correctness.
A2 Intl.DateTimeFormat try/catch accepts all valid IANA zones in target browsers (iOS Safari 16.4+) Standard Stack iOS < 16.4 outside scope (CLAUDE.md min); tested in Node 22 [VERIFIED: runtime test]
A3 Phase 12 setup wizard will call PUT /api/admin/config/timezone when executed Phase 12 section If Phase 12 uses a different mechanism, Phase 18's seeding logic may conflict. Low risk: Phase 12 spec shows a POST /api/setup/complete pattern that can call the helper.

If this table is empty: All claims in this research were verified or cited.


Open Questions

  1. Should Phase 18 include a seed endpoint accessible during the Phase 12 wizard?

    • What we know: Phase 12 POST /api/setup/complete will need to write household_timezone. The PUT endpoint on adminRouter is admin-gated (require admin), which may not be available at wizard-complete time (first admin not yet set).
    • What's unclear: Is requireAdmin already satisfied at wizard-complete time (the first user becomes admin during the wizard)?
    • Recommendation: Phase 10 auth/user.ts line 114 shows "first user → admin" logic; if the wizard calls setup/complete after promoting the user to admin, the PUT endpoint is accessible. Planner should confirm the admin-promotion timing relative to the timezone seed call. If promotion happens in the same setup/complete handler, the PUT endpoint is available.
  2. Should the AdminPage timezone picker pre-fill with the detected browser timezone as a hint?

    • What we know: D-02 says "auto-detect from browser at setup." The AdminPage is post-login.
    • What's unclear: Whether showing a "detected: America/New_York — click to use" affordance is in scope.
    • Recommendation: This is in Claude's Discretion (CONTEXT.md). The planner should treat it as optional UX polish — the base requirement is a searchable picker with save, not a detection affordance. The picker's initial value shows the current stored timezone (or the fallback zone). A pre-fill is nice but not required.

Sources

Primary (HIGH confidence — verified by direct codebase inspection)

  • apps/api/src/broker/reminderScheduler.ts line 247 — serverTz lookup confirmed; CONTEXT hint accurate
  • apps/api/src/broker/outboxWorker.ts lines 501, 607 — two tz lookups confirmed; CONTEXT hints accurate
  • apps/api/src/broker/vevent.ts line 240 — computeAlertInstantUtc signature confirmed; pure tz-in function
  • apps/api/src/db/schema.ts line 282 — appConfig table confirmed; key/value/updatedAt structure
  • apps/api/src/routes/admin.ts — full adminRouter pattern confirmed; requireAdmin first
  • apps/pwa/src/routes/AdminPage.tsx — TanStack Query pattern confirmed; mutation + invalidation pattern
  • apps/pwa/src/api/client.ts — API client function pattern confirmed
  • .planning/phases/12-initial-setup-wizard/12-UI-SPEC.md — Phase 12 draft status confirmed; no code exists
  • Node 22 runtime tests — Intl.supportedValuesOf, isValidIanaTimezone try/catch, UTC edge case

Secondary (MEDIUM confidence)

Tertiary (LOW confidence)

  • MDN + caniuse Intl.supportedValuesOf browser support: Chrome 93+, Safari 14.1+, Firefox 91+ [ASSUMED from training + search result summary]

Metadata

Confidence breakdown:

  • Stored TZ accessor pattern: HIGH — read directly from reminderScheduler.ts, outboxWorker.ts, schema.ts
  • Admin endpoint pattern: HIGH — read directly from admin.ts, AdminPage.tsx, client.ts
  • IANA validation approach: HIGH — verified by Node 22 runtime execution
  • Phase 12 status: HIGH — confirmed by file listing (only 12-UI-SPEC.md, no code)
  • Picker UX (no new package): HIGH — confirmed no combobox library in PWA package.json

Research date: 2026-06-15 Valid until: 2026-07-15 (stable; all from local codebase inspection)