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:
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user