From eaceff0295c45f6dd5802a1e8e0c34ce63898d6b Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Sun, 14 Jun 2026 22:07:16 -0400 Subject: [PATCH] feat(18-01): implement household timezone accessor + IANA validator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getHouseholdTimezone(db): selects household_timezone from app_config - D-06 fallback chain: stored value → process.env.TZ → Intl.DateTimeFormat().resolvedOptions().timeZone - isValidIanaTimezone: try/catch Intl.DateTimeFormat (no Intl.supportedValuesOf per RESEARCH Pitfall 2) - Exports match D-05 single-accessor contract for reminderScheduler + outboxWorker - All 11 unit tests pass; 358/358 total suite green; tsc --noEmit clean --- apps/api/src/lib/householdTimezone.ts | 55 +++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 apps/api/src/lib/householdTimezone.ts diff --git a/apps/api/src/lib/householdTimezone.ts b/apps/api/src/lib/householdTimezone.ts new file mode 100644 index 0000000..2282ec9 --- /dev/null +++ b/apps/api/src/lib/householdTimezone.ts @@ -0,0 +1,55 @@ +/** + * householdTimezone — shared accessor for the stored household timezone (D-05). + * + * D-05: single accessor so reminderScheduler.ts and outboxWorker.ts cannot drift. + * D-06: fallback chain when household_timezone is unset: + * stored value → process.env.TZ → Intl.DateTimeFormat().resolvedOptions().timeZone + * + * Do NOT use Intl.supportedValuesOf for IANA validation — it omits 'UTC' (RESEARCH Pitfall 2). + * Use try/catch on Intl.DateTimeFormat construction instead. + */ + +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'; + +/** + * Returns the stored household timezone from app_config, or falls back to: + * 1. process.env.TZ (if set and non-empty) + * 2. Intl.DateTimeFormat().resolvedOptions().timeZone + * + * This is the D-05 single source of truth for the server-side all-day "9 AM local" + * reminder computation in reminderScheduler.ts and outboxWorker.ts. + */ +export async function getHouseholdTimezone( + db: MySql2Database, +): Promise { + 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 + ); +} + +/** + * Returns true if `tz` is a valid IANA timezone identifier recognized by the + * runtime's Intl implementation, false otherwise. + * + * Uses try/catch on Intl.DateTimeFormat construction — NOT Intl.supportedValuesOf, + * which omits 'UTC' in some environments (RESEARCH Pitfall 2). + */ +export function isValidIanaTimezone(tz: string): boolean { + try { + Intl.DateTimeFormat(undefined, { timeZone: tz }); + return true; + } catch { + return false; + } +}