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:
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user