fix(18): WR-01 treat empty/blank TZ as unset in household timezone fallback

The D-06 fallback used row?.value ?? process.env.TZ ?? Intl..., but ??
only short-circuits on null/undefined. A set-but-empty TZ ('' or '   ')
leaked through and yielded an invalid IANA zone that throws inside
Intl.DateTimeFormat({ timeZone }) downstream, silently dropping the
all-day reminder. Extract resolveHouseholdTimezone() which trims and
treats empty/whitespace candidate values (stored value and TZ) as
absent so they fall through to the Intl resolved zone. Adds RED->GREEN
unit tests for empty and whitespace-only TZ.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucas Berger
2026-06-15 07:38:39 -04:00
co-authored by Claude Opus 4.8
parent ea93089b74
commit d168da71cf
2 changed files with 46 additions and 6 deletions
+28 -6
View File
@@ -16,7 +16,7 @@ import { appConfig } from '../db/schema.js';
/** /**
* Returns the stored household timezone from app_config, or falls back to: * 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 * 2. Intl.DateTimeFormat().resolvedOptions().timeZone
* *
* This is the D-05 single source of truth for the server-side all-day "9 AM local" * 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')) .where(eq(appConfig.key, 'household_timezone'))
.limit(1); .limit(1);
return ( return resolveHouseholdTimezone(row?.value ?? null);
row?.value ?? }
process.env.TZ ??
Intl.DateTimeFormat().resolvedOptions().timeZone /**
); * 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;
} }
/** /**
@@ -97,6 +97,24 @@ describe('getHouseholdTimezone', () => {
const result = await getHouseholdTimezone(mockDb as never); const result = await getHouseholdTimezone(mockDb as never);
expect(result).toBe('Europe/London'); 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', () => { describe('isValidIanaTimezone', () => {