diff --git a/apps/api/src/lib/householdTimezone.ts b/apps/api/src/lib/householdTimezone.ts index 2282ec9..214b1c5 100644 --- a/apps/api/src/lib/householdTimezone.ts +++ b/apps/api/src/lib/householdTimezone.ts @@ -16,7 +16,7 @@ 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) + * 1. process.env.TZ (only if set and non-empty — empty/whitespace is ignored, WR-01) * 2. Intl.DateTimeFormat().resolvedOptions().timeZone * * This is the D-05 single source of truth for the server-side all-day "9 AM local" @@ -31,11 +31,33 @@ export async function getHouseholdTimezone( .where(eq(appConfig.key, 'household_timezone')) .limit(1); - return ( - row?.value ?? - process.env.TZ ?? - Intl.DateTimeFormat().resolvedOptions().timeZone - ); + return resolveHouseholdTimezone(row?.value ?? null); +} + +/** + * Resolves the household timezone from an already-fetched stored value, applying + * the D-06 fallback chain. Centralizing the policy here (D-05) means callers that + * have already read the row — e.g. the GET /config/timezone handler — can reuse it + * without a second DB round-trip (IN-01), and there is exactly one place where the + * fallback rules live (IN-02). + * + * WR-01: `??` only short-circuits on null/undefined, so a set-but-empty + * `process.env.TZ` (`TZ=` or `TZ=' '`) would otherwise leak through and yield an + * invalid IANA zone that throws inside `Intl.DateTimeFormat({ timeZone })` downstream. + * Empty/whitespace-only candidate values are treated as absent so they fall through. + */ +export function resolveHouseholdTimezone(storedValue: string | null): string { + const stored = storedValue?.trim(); + if (stored) { + return stored; + } + + const envTz = process.env.TZ?.trim(); + if (envTz) { + return envTz; + } + + return Intl.DateTimeFormat().resolvedOptions().timeZone; } /** diff --git a/apps/api/tests/lib/householdTimezone.test.ts b/apps/api/tests/lib/householdTimezone.test.ts index 405b6b2..5f0f647 100644 --- a/apps/api/tests/lib/householdTimezone.test.ts +++ b/apps/api/tests/lib/householdTimezone.test.ts @@ -97,6 +97,24 @@ describe('getHouseholdTimezone', () => { const result = await getHouseholdTimezone(mockDb as never); expect(result).toBe('Europe/London'); }); + + it('treats an empty process.env.TZ as unset and falls through to the Intl zone (WR-01)', async () => { + mockDb.select.mockReturnValue(makeSelectChain([])); + process.env.TZ = ''; + + const expected = Intl.DateTimeFormat().resolvedOptions().timeZone; + const result = await getHouseholdTimezone(mockDb as never); + expect(result).toBe(expected); + }); + + it('treats a whitespace-only process.env.TZ as unset and falls through to the Intl zone (WR-01)', async () => { + mockDb.select.mockReturnValue(makeSelectChain([])); + process.env.TZ = ' '; + + const expected = Intl.DateTimeFormat().resolvedOptions().timeZone; + const result = await getHouseholdTimezone(mockDb as never); + expect(result).toBe(expected); + }); }); describe('isValidIanaTimezone', () => {