Phase 18: Auto timezone detection and ability to change timezone #21

Merged
luckberg merged 38 commits from gsd/phase-18-auto-timezone-detection-and-ability-to-change-timezone into main 2026-06-15 09:55:53 -04:00
Showing only changes of commit eaceff0295 - Show all commits
+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;
}
}