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
2 changed files with 46 additions and 6 deletions
Showing only changes of commit d168da71cf - Show all commits
+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:
* 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;
}
/**
@@ -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', () => {