Publish the API's effective timezone and tolerate an older snapshot payload

TZ-002: with no zone stored the API reads its clock in the host's local
time, but the panel fell back to Home Assistant's zone and the admin app
to the browser's, so dates could disagree near midnight. The API now
names the zone it actually uses (api/host_zone.py: the stored zone, else
the host's own IANA zone from TZ, /etc/localtime or /etc/timezone) and
publishes it as the panel snapshot's `timezone` and the admin settings'
read-only `effective_timezone`. Both cards and the admin app format in
that one zone; Home Assistant's or the browser's zone applies only when
none is published.

TZ-003: snapshot_from_api_payload reads `timezone` with a "" default, so
an integration upgraded ahead of its API keeps refreshing against the
pre-change payload.

Task 7e173881-fb99-426f-a2f0-2678439ffc39

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 09:50:25 -04:00
co-authored by Claude Opus 5.5
parent cf170214a6
commit 474c84bf35
26 changed files with 368 additions and 101 deletions
+6 -2
View File
@@ -13,7 +13,8 @@ let settingsResponse: () => Promise<Response>;
describe("NestQuest Admin shell", () => {
beforeEach(() => {
settingsResponse = () => Promise.resolve(jsonResponse({ timezone: "" }));
settingsResponse = () =>
Promise.resolve(jsonResponse({ timezone: "", effective_timezone: "Europe/London" }));
// The shell loads the stored application timezone; every tab's own
// requests stay pending so the shell tests remain offline.
vi.stubGlobal(
@@ -78,7 +79,10 @@ describe("NestQuest Admin shell", () => {
await screen.findByTestId("timezone-error");
expect(screen.queryByRole("heading", { name: "Today" })).toBeNull();
settingsResponse = () => Promise.resolve(jsonResponse({ timezone: "Pacific/Auckland" }));
settingsResponse = () =>
Promise.resolve(
jsonResponse({ timezone: "Pacific/Auckland", effective_timezone: "Pacific/Auckland" }),
);
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
expect(await screen.findByRole("heading", { name: "Today" })).toBeTruthy();
+6 -1
View File
@@ -19,10 +19,15 @@ export interface HouseholdSettings {
celebration_enabled: boolean;
/** IANA zone name (e.g. "America/New_York"); "" means the API host's local time. */
timezone: string;
/**
* Read-only: the zone the API reads its clock in — `timezone`, or the API
* host's own zone when that is "" ("" only when the host's zone has no name).
*/
effective_timezone: string;
}
/** Only the supplied fields are updated; the API answers with all of them. */
export type SettingsChanges = Partial<HouseholdSettings>;
export type SettingsChanges = Partial<Omit<HouseholdSettings, "effective_timezone">>;
export const SETTINGS_PATH = "/api/v1/admin/settings";
@@ -1726,13 +1726,16 @@ describe("DefinitionsTab start date", () => {
});
describe("DefinitionsTab start date in the stored application timezone", () => {
async function newSheetStartDate(timezone: string): Promise<string> {
async function newSheetStartDate(
timezone: string,
effective_timezone = timezone,
): Promise<string> {
api = fakeApi([BRUSH]);
vi.stubGlobal(
"fetch",
vi.fn((input: RequestInfo | URL, init?: RequestInit) =>
String(input).endsWith("/api/v1/admin/settings")
? Promise.resolve(jsonResponse({ timezone }))
? Promise.resolve(jsonResponse({ timezone, effective_timezone }))
: api.fetchMock(input, init),
),
);
@@ -1771,7 +1774,7 @@ describe("DefinitionsTab start date in the stored application timezone", () => {
expect(await newSheetStartDate("Pacific/Auckland")).toBe("2026-09-24");
});
it("an empty stored zone defaults to the browser's local today", async () => {
expect(await newSheetStartDate("")).toBe("2026-09-23");
it("an empty stored zone defaults to today in the API host's published zone", async () => {
expect(await newSheetStartDate("", "Pacific/Auckland")).toBe("2026-09-24");
});
});
+9 -6
View File
@@ -654,11 +654,11 @@ describe("HistoryTab in the stored application timezone", () => {
.filter((url) => url.pathname === "/api/v1/admin/history");
}
async function renderWithZone(timezone: string) {
async function renderWithZone(timezone: string, effective_timezone = timezone) {
fetchMock = vi.fn((url: string) => {
const parsed = new URL(url, "http://x");
if (parsed.pathname.endsWith("/api/v1/admin/settings")) {
return Promise.resolve(jsonResponse({ timezone }));
return Promise.resolve(jsonResponse({ timezone, effective_timezone }));
}
const filter = parsed.searchParams.get("filter");
const rows = filter === "all" ? [EVENING_NZ, LATE_NZ] : [];
@@ -707,8 +707,11 @@ describe("HistoryTab in the stored application timezone", () => {
vi.unstubAllGlobals();
});
it("today, day grouping, clock and lateness follow the stored zone", async () => {
await renderWithZone("Pacific/Auckland");
it.each([
["the stored zone", "Pacific/Auckland"],
["the API host's published zone when none is stored", ""],
])("today, day grouping, clock and lateness follow %s", async (_label, stored) => {
await renderWithZone(stored, "Pacific/Auckland");
for (const url of historyRequests()) {
expect(url.searchParams.get("end")).toBe("2026-09-24");
@@ -724,8 +727,8 @@ describe("HistoryTab in the stored application timezone", () => {
expect(times(yesterday)).toEqual(["10:00 PM"]);
});
it("an empty stored zone keeps the browser's local day and clock", async () => {
await renderWithZone("");
it("only an API that publishes no zone leaves the browser's local day and clock", async () => {
await renderWithZone("", "");
for (const url of historyRequests()) {
expect(url.searchParams.get("end")).toBe("2026-09-23");
+4 -2
View File
@@ -74,8 +74,10 @@ async function routeFetch(url: string, init: RequestInit = {}): Promise<Response
overrides = overrides.filter((o) => o.id !== Number(deleteMatch[1]));
return jsonResponse({ status: "ok" });
}
// The shell loads the stored application timezone first ("" = unset).
if (path.endsWith("/admin/settings")) return jsonResponse({ timezone: "" });
// The shell loads the application timezone first ("" = none published).
if (path.endsWith("/admin/settings")) {
return jsonResponse({ timezone: "", effective_timezone: "" });
}
if (path.endsWith("/admin/children")) return jsonResponse({ children: CHILDREN });
const patternsMatch = /\/children\/(\d+)\/presence-patterns$/.exec(path);
if (patternsMatch) {
+6 -6
View File
@@ -658,10 +658,10 @@ describe("ScheduleTab", () => {
});
describe("ScheduleTab today in the stored application timezone", () => {
async function renderWithZone(timezone: string) {
async function renderWithZone(timezone: string, effective_timezone = timezone) {
fetchMock = vi.fn(async (url: string, init?: RequestInit) =>
String(url).endsWith("/api/v1/admin/settings")
? jsonResponse({ timezone })
? jsonResponse({ timezone, effective_timezone })
: routeFetch(url, init),
);
vi.stubGlobal("fetch", fetchMock);
@@ -702,9 +702,9 @@ describe("ScheduleTab today in the stored application timezone", () => {
expect(day("2026-09-23").className).not.toContain("schedule-day--today");
});
it("an empty stored zone marks the browser's local today", async () => {
await renderWithZone("");
expect(day("2026-09-23").className).toContain("schedule-day--today");
expect(day("2026-09-24").className).not.toContain("schedule-day--today");
it("an empty stored zone marks today in the API host's published zone", async () => {
await renderWithZone("", "Pacific/Auckland");
expect(day("2026-09-24").className).toContain("schedule-day--today");
expect(day("2026-09-23").className).not.toContain("schedule-day--today");
});
});
+13 -3
View File
@@ -24,8 +24,17 @@ const DEFAULT_SETTINGS: HouseholdSettings = {
end_of_day_report_enabled: true,
celebration_enabled: false,
timezone: "America/Chicago",
effective_timezone: "America/Chicago",
};
/** The API host's own zone: the effective zone while none is stored. */
const API_HOST_ZONE = "Europe/London";
/** The settings as the API answers: `effective_timezone` derived like the API's. */
function settingsBody(): HouseholdSettings {
return { ...settings, effective_timezone: settings.timezone || API_HOST_ZONE };
}
function child(
id: number,
display_name: string,
@@ -83,10 +92,10 @@ async function routeFetch(url: string, init: RequestInit = {}): Promise<Response
return respond();
}
if (path === CHILDREN_PATH && method === "GET") return jsonResponse({ children });
if (path === SETTINGS_PATH && method === "GET") return jsonResponse(settings);
if (path === SETTINGS_PATH && method === "GET") return jsonResponse(settingsBody());
if (path === SETTINGS_PATH && method === "PATCH") {
settings = { ...settings, ...body };
return jsonResponse(settings);
return jsonResponse(settingsBody());
}
if (path === CHILDREN_PATH && method === "POST") {
const created = child(9, body.display_name, body.sort_order ?? 0, {
@@ -821,7 +830,8 @@ describe("SettingsScreen — the saved zone applies app-wide", () => {
</AppTimezoneProvider>,
);
const form = await screen.findByRole("form", { name: "Preferences" });
expect(screen.getByTestId("app-zone").textContent).toBe("(browser)");
// No zone stored: the application runs in the API host's zone.
expect(screen.getByTestId("app-zone").textContent).toBe(API_HOST_ZONE);
fireEvent.change(within(form).getByRole("combobox", { name: "Timezone" }), {
target: { value: "America/New_York" },
+2 -2
View File
@@ -639,7 +639,7 @@ export default function SettingsScreen({ onClose }: { onClose: () => void }) {
.then((settings) => {
if (cancelled) return;
setPreferences({ kind: "ready", settings });
publishTimezone(settings.timezone);
publishTimezone(settings.effective_timezone);
})
.catch((error: unknown) => {
if (cancelled) return;
@@ -733,7 +733,7 @@ export default function SettingsScreen({ onClose }: { onClose: () => void }) {
try {
const settings = await updateSettings(changes);
setPreferences({ kind: "ready", settings });
publishTimezone(settings.timezone);
publishTimezone(settings.effective_timezone);
setPreferencesMessage({ kind: "info", text: "Preferences saved." });
} catch (error) {
setPreferencesMessage({
+6 -4
View File
@@ -1,5 +1,7 @@
/**
* The stored application timezone, loaded once per signed-in shell.
* The effective application timezone, loaded once per signed-in shell: the
* API's `effective_timezone` — the stored zone, or the API host's own zone
* when none is stored — so every screen's local time matches the API's.
*
* <AppTimezoneProvider> fetches the household settings and renders its
* screens only once the zone is known (a failed load says so and offers a
@@ -35,7 +37,7 @@ export function AppTimezoneProvider({ children }: { children: ReactNode }) {
setState({ kind: "loading" });
fetchSettings()
.then((settings) => {
if (!cancelled) setState({ kind: "ready", timeZone: settings.timezone });
if (!cancelled) setState({ kind: "ready", timeZone: settings.effective_timezone });
})
.catch((error: unknown) => {
if (cancelled) return;
@@ -79,12 +81,12 @@ export function AppTimezoneProvider({ children }: { children: ReactNode }) {
return <AppTimezoneContext.Provider value={hub}>{children}</AppTimezoneContext.Provider>;
}
/** The stored application timezone: an IANA name, or "" (browser-local). */
/** The effective application timezone: an IANA name, or "" (browser-local). */
export function useAppTimezone(): string {
return useContext(AppTimezoneContext)?.timeZone ?? "";
}
/** Publish a zone just loaded or saved; a no-op without a provider. */
/** Publish an effective zone just loaded or saved; a no-op without a provider. */
export function usePublishAppTimezone(): (timeZone: string) => void {
const hub = useContext(AppTimezoneContext);
return (timeZone) => hub?.publish(timeZone);
+5 -3
View File
@@ -2,12 +2,14 @@
* Local time in the application timezone.
*
* The admin-saved zone (settings `timezone`, an IANA name) is the single
* source of local-time truth for the whole application. "" means no zone is
* stored, and only then does the browser's own zone apply. Every helper takes
* source of local-time truth for the whole application; the API publishes it
* as `effective_timezone`, naming its host's own zone when none is stored.
* "" means the API published no zone, and only then does the browser's own
* zone apply. Every helper takes
* that stored value as-is; an invalid zone name throws (Intl's RangeError).
*/
/** The `timeZone` option for Intl: the stored zone, or undefined (browser) for "". */
/** The `timeZone` option for Intl: the zone, or undefined (browser) for "". */
export function zoneOption(timeZone: string): string | undefined {
return timeZone === "" ? undefined : timeZone;
}
+10 -4
View File
@@ -409,11 +409,11 @@ describe("TodayTab Undo and live updates", () => {
});
describe("TodayTab in the stored application timezone", () => {
function renderWithZone(timezone: string) {
function renderWithZone(timezone: string, effective_timezone = timezone) {
fetchMock.mockImplementation((url: string) =>
Promise.resolve(
String(url).endsWith("/api/v1/admin/settings")
? jsonResponse({ timezone })
? jsonResponse({ timezone, effective_timezone })
: jsonResponse(SNAPSHOT),
),
);
@@ -454,8 +454,14 @@ describe("TodayTab in the stored application timezone", () => {
expect(screen.getByText("Wednesday, September 23 · cycle day 5 of 14")).toBeTruthy();
});
it("an empty stored zone keeps the browser's local clock", async () => {
renderWithZone("");
it("an empty stored zone shows the clock in the API host's published zone", async () => {
renderWithZone("", "Pacific/Auckland");
const completed = await screen.findByTestId("attention-12");
expect(within(completed).getByText("Completed 11:04 PM")).toBeTruthy();
});
it("only an API that publishes no zone leaves the browser's local clock", async () => {
renderWithZone("", "");
const completed = await screen.findByTestId("attention-12");
expect(within(completed).getByText("Completed 11:04 AM")).toBeTruthy();
});