feat(18-01): implement household timezone accessor + IANA validator

- 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
This commit is contained in:
Lucas Berger
2026-06-14 22:07:16 -04:00
parent db0077c3c3
commit eaceff0295
+55
View File
@@ -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<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
);
}
/**
* 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;
}
}