From 0e726ac64f4aa9fb8e8499d9c158bf67b42aecb5 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Wed, 30 Sep 2026 10:02:05 -0400 Subject: [PATCH] Manual timezone is the single source of local-time truth (task 7e173881) (#285) Co-authored-by: dev_agent Co-committed-by: dev_agent --- admin/src/App.test.tsx | 57 +- admin/src/App.tsx | 22 +- admin/src/api/settings.ts | 7 +- admin/src/definitions/DefinitionsTab.test.tsx | 57 +- admin/src/definitions/DefinitionsTab.tsx | 42 +- admin/src/history/HistoryTab.test.tsx | 115 ++++ admin/src/history/HistoryTab.tsx | 18 +- admin/src/history/format.ts | 45 +- admin/src/schedule/OverrideEditor.test.tsx | 4 + admin/src/schedule/ScheduleTab.test.tsx | 55 +- admin/src/schedule/ScheduleTab.tsx | 9 +- admin/src/schedule/cycle.ts | 7 - admin/src/settings/SettingsScreen.test.tsx | 54 +- admin/src/settings/SettingsScreen.tsx | 10 +- admin/src/time/AppTimezone.tsx | 93 ++++ admin/src/time/zone.test.ts | 60 +++ admin/src/time/zone.ts | 81 +++ admin/src/today/TodayTab.test.tsx | 60 +++ admin/src/today/TodayTab.tsx | 4 +- admin/src/today/format.ts | 14 +- api/host_zone.py | 62 +++ api/routes_admin.py | 40 +- api/routes_panel.py | 15 +- api/scheduler.py | 3 +- custom_components/nestquest/coordinator.py | 4 + custom_components/nestquest/core/snapshot.py | 6 + custom_components/nestquest/sensor.py | 4 + .../nestquest/www/nestquest-cards.js | 496 +++++++++--------- frontend/src/cards/party-board.ts | 12 +- frontend/src/cards/quest-log.ts | 42 +- frontend/tests/render-party-board.mjs | 25 +- tests/conftest.py | 1 + tests/test_api_admin_settings.py | 36 +- tests/test_api_admin_snapshot.py | 11 +- tests/test_api_host_zone.py | 71 +++ tests/test_api_household_clock.py | 143 ++++- tests/test_binary_sensors.py | 1 + tests/test_complete_quest_proxy.py | 1 + tests/test_coordinator.py | 32 +- tests/test_core_snapshot.py | 1 + tests/test_entity_lifecycle.py | 1 + tests/test_entity_matrix.py | 1 + tests/test_events.py | 1 + tests/test_panel_cards.py | 138 +++-- tests/test_sensors.py | 17 + tests/test_sse_subscription.py | 7 +- 46 files changed, 1556 insertions(+), 429 deletions(-) create mode 100644 admin/src/time/AppTimezone.tsx create mode 100644 admin/src/time/zone.test.ts create mode 100644 admin/src/time/zone.ts create mode 100644 api/host_zone.py create mode 100644 tests/test_api_host_zone.py diff --git a/admin/src/App.test.tsx b/admin/src/App.test.tsx index 7a38e6a..942a2fb 100644 --- a/admin/src/App.test.tsx +++ b/admin/src/App.test.tsx @@ -2,10 +2,29 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import App from "./App"; +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +let settingsResponse: () => Promise; + describe("NestQuest Admin shell", () => { beforeEach(() => { - // The Today tab fetches the admin snapshot; keep the shell tests offline. - vi.stubGlobal("fetch", vi.fn(() => new Promise(() => {}))); + 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( + "fetch", + vi.fn((url: string) => + String(url).endsWith("/api/v1/admin/settings") + ? settingsResponse() + : new Promise(() => {}), + ), + ); }); afterEach(() => { @@ -22,10 +41,10 @@ describe("NestQuest Admin shell", () => { } }); - it("starts on the Today screen and switches on tab click", () => { + it("starts on the Today screen and switches on tab click", async () => { render(); - expect(screen.getByRole("heading", { name: "Today" })).toBeTruthy(); + expect(await screen.findByRole("heading", { name: "Today" })).toBeTruthy(); fireEvent.click(screen.getByRole("tab", { name: "History" })); @@ -36,12 +55,36 @@ describe("NestQuest Admin shell", () => { ); }); - it("the Tasks tab renders the definitions screen", () => { + it("the Tasks tab renders the definitions screen", async () => { render(); fireEvent.click(screen.getByRole("tab", { name: "Tasks" })); - expect(screen.getByRole("heading", { name: "Tasks" })).toBeTruthy(); + expect(await screen.findByRole("heading", { name: "Tasks" })).toBeTruthy(); expect(screen.getByRole("tabpanel").textContent).not.toContain("placeholder"); }); -}); \ No newline at end of file + + it("renders no screen until the stored application timezone is known", async () => { + settingsResponse = () => new Promise(() => {}); + render(); + + expect(screen.getByTestId("timezone-loading")).toBeTruthy(); + expect(screen.queryByRole("heading", { name: "Today" })).toBeNull(); + }); + + it("a failed timezone load says so and retries", async () => { + settingsResponse = () => Promise.resolve(jsonResponse({ detail: "boom" }, 500)); + render(); + + await screen.findByTestId("timezone-error"); + expect(screen.queryByRole("heading", { name: "Today" })).toBeNull(); + + 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(); + }); +}); diff --git a/admin/src/App.tsx b/admin/src/App.tsx index 5f44fad..31ab78a 100644 --- a/admin/src/App.tsx +++ b/admin/src/App.tsx @@ -4,6 +4,7 @@ import { TransitionsProvider } from "./api/useTransitions"; import DefinitionsTab from "./definitions/DefinitionsTab"; import HistoryTab from "./history/HistoryTab"; import ScheduleTab from "./schedule/ScheduleTab"; +import { AppTimezoneProvider } from "./time/AppTimezone"; import TodayTab from "./today/TodayTab"; const TABS = [ @@ -32,15 +33,18 @@ export default function App() { role="tabpanel" aria-labelledby={`tab-${active}`} > - {active === "today" ? ( - - ) : active === "tasks" ? ( - - ) : active === "schedule" ? ( - - ) : ( - - )} + {/* Every screen's local time is the stored application timezone. */} + + {active === "today" ? ( + + ) : active === "tasks" ? ( + + ) : active === "schedule" ? ( + + ) : ( + + )} +