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; + } +}