diff --git a/admin/src/App.test.tsx b/admin/src/App.test.tsx index 442f2ee..942a2fb 100644 --- a/admin/src/App.test.tsx +++ b/admin/src/App.test.tsx @@ -13,7 +13,8 @@ let settingsResponse: () => Promise; 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(); diff --git a/admin/src/api/settings.ts b/admin/src/api/settings.ts index b72d035..09ef4a1 100644 --- a/admin/src/api/settings.ts +++ b/admin/src/api/settings.ts @@ -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; +export type SettingsChanges = Partial>; export const SETTINGS_PATH = "/api/v1/admin/settings"; diff --git a/admin/src/definitions/DefinitionsTab.test.tsx b/admin/src/definitions/DefinitionsTab.test.tsx index 1337f13..72e4023 100644 --- a/admin/src/definitions/DefinitionsTab.test.tsx +++ b/admin/src/definitions/DefinitionsTab.test.tsx @@ -1726,13 +1726,16 @@ describe("DefinitionsTab start date", () => { }); describe("DefinitionsTab start date in the stored application timezone", () => { - async function newSheetStartDate(timezone: string): Promise { + async function newSheetStartDate( + timezone: string, + effective_timezone = timezone, + ): Promise { 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"); }); }); diff --git a/admin/src/history/HistoryTab.test.tsx b/admin/src/history/HistoryTab.test.tsx index 5ab8aa0..650bc83 100644 --- a/admin/src/history/HistoryTab.test.tsx +++ b/admin/src/history/HistoryTab.test.tsx @@ -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"); diff --git a/admin/src/schedule/OverrideEditor.test.tsx b/admin/src/schedule/OverrideEditor.test.tsx index 8e57c6c..2763079 100644 --- a/admin/src/schedule/OverrideEditor.test.tsx +++ b/admin/src/schedule/OverrideEditor.test.tsx @@ -74,8 +74,10 @@ async function routeFetch(url: string, init: RequestInit = {}): Promise 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) { diff --git a/admin/src/schedule/ScheduleTab.test.tsx b/admin/src/schedule/ScheduleTab.test.tsx index 087c0b8..79874dc 100644 --- a/admin/src/schedule/ScheduleTab.test.tsx +++ b/admin/src/schedule/ScheduleTab.test.tsx @@ -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"); }); }); diff --git a/admin/src/settings/SettingsScreen.test.tsx b/admin/src/settings/SettingsScreen.test.tsx index 3be1e5d..cebc087 100644 --- a/admin/src/settings/SettingsScreen.test.tsx +++ b/admin/src/settings/SettingsScreen.test.tsx @@ -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 { , ); 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" }, diff --git a/admin/src/settings/SettingsScreen.tsx b/admin/src/settings/SettingsScreen.tsx index 6a176ee..50c78ec 100644 --- a/admin/src/settings/SettingsScreen.tsx +++ b/admin/src/settings/SettingsScreen.tsx @@ -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({ diff --git a/admin/src/time/AppTimezone.tsx b/admin/src/time/AppTimezone.tsx index b31a440..dcbadb8 100644 --- a/admin/src/time/AppTimezone.tsx +++ b/admin/src/time/AppTimezone.tsx @@ -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. * * 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 {children}; } -/** 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); diff --git a/admin/src/time/zone.ts b/admin/src/time/zone.ts index ea8b1de..e4edc6e 100644 --- a/admin/src/time/zone.ts +++ b/admin/src/time/zone.ts @@ -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; } diff --git a/admin/src/today/TodayTab.test.tsx b/admin/src/today/TodayTab.test.tsx index 9fd54c9..7df60ff 100644 --- a/admin/src/today/TodayTab.test.tsx +++ b/admin/src/today/TodayTab.test.tsx @@ -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(); }); diff --git a/api/host_zone.py b/api/host_zone.py new file mode 100644 index 0000000..21d45e0 --- /dev/null +++ b/api/host_zone.py @@ -0,0 +1,62 @@ +"""The API's effective application timezone (task 7e173881). + +The admin-stored ``timezone`` setting is the single source of local-time +truth; stored empty, the API reads its clock in the API host's local +time. :func:`effective_timezone` names the zone the API actually uses — +the stored zone, or else the host's own IANA zone — so the panel cards +and the admin app format local time in exactly the API's zone instead of +guessing with Home Assistant's or the browser's. ``""`` only when the +host's zone genuinely cannot be named. +""" +from __future__ import annotations + +import os +from collections.abc import Mapping +from pathlib import Path +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + + +def _valid_zone(name: str) -> str: + """Return ``name`` if it is a loadable IANA zone key, else ``""``.""" + name = name.strip().removeprefix(":") + if not name: + return "" + try: + ZoneInfo(name) + except (ZoneInfoNotFoundError, ValueError): + return "" + return name + + +def host_timezone( + environ: Mapping[str, str] = os.environ, + timezone_file: Path = Path("/etc/timezone"), + localtime: Path = Path("/etc/localtime"), +) -> str: + """Name the API host's local zone as an IANA key, or ``""``. + + In the order the C library honours them: the ``TZ`` environment + variable, then the ``/etc/localtime`` zoneinfo link (with Debian's + ``/etc/timezone`` naming it when the link is a copy). A host with + none of them configured runs UTC. A setting that names no IANA + zone (a POSIX rule string, a copied file) is ``""``. + """ + configured = environ.get("TZ") + if configured is not None: + # An empty TZ is UTC to the C library. + return _valid_zone(configured) if configured.strip() else "UTC" + if localtime.is_symlink(): + target = str(localtime.resolve()) + marker = "/zoneinfo/" + if marker in target: + return _valid_zone(target.split(marker, 1)[1]) + if timezone_file.is_file(): + return _valid_zone(timezone_file.read_text(encoding="utf-8")) + if not localtime.exists(): + return "UTC" + return "" + + +def effective_timezone(stored: str) -> str: + """The zone the API reads its clock in: ``stored``, else the host's.""" + return stored or host_timezone() diff --git a/api/routes_admin.py b/api/routes_admin.py index 2876028..053c098 100644 --- a/api/routes_admin.py +++ b/api/routes_admin.py @@ -170,7 +170,10 @@ are deliberately not unified here: notify target, the three notification times, the four enable toggles, and the ``timezone`` — the IANA household zone the household-local dates derive from, empty meaning host local; only an - admin's ``PATCH`` ever sets it) — the defaults on a fresh database. + admin's ``PATCH`` ever sets it) — the defaults on a fresh database — + plus the read-only ``effective_timezone`` (:mod:`api.host_zone`): the + zone the API actually reads its clock in, which the admin app formats + local time in. - ``PATCH /settings`` updates ONLY the supplied fields: the body is a JSON object of settings field names to new values, passed STRAIGHT to :func:`nestquest_core.settings_store.update_settings` — the @@ -349,6 +352,7 @@ from pydantic import ( model_validator, ) +from api import host_zone from api import transitions as api_transitions from api.auth import require_admin from api.database import DatabaseState @@ -920,9 +924,14 @@ class AdminSettingsResponse(BaseModel): afternoon_reminder_enabled: bool end_of_day_report_enabled: bool celebration_enabled: bool - #: The household IANA time zone the API reads its clock in; ``""`` - #: means the API host's local time. + #: The household IANA time zone the admin stored; ``""`` means the + #: API host's local time. timezone: str + #: The zone the API actually reads its clock in — ``timezone``, or + #: the API host's own zone when that is ``""`` (``""`` only when the + #: host's zone cannot be named). Read-only; the admin app formats + #: local time in it. + effective_timezone: str class AdminMissedSweepResponse(BaseModel): @@ -2179,8 +2188,9 @@ def _settings_response( """Serialize one core NestQuestSettings into the documented payload. Explicit field-by-field (not ``dataclasses.asdict``) so the payload - stays pinned to the eleven documented fields even if the core - dataclass later grows another one. + stays pinned to the eleven documented fields (plus the read-only + ``effective_timezone``) even if the core dataclass later grows + another one. """ return AdminSettingsResponse( horizon_days=settings.horizon_days, @@ -2194,6 +2204,7 @@ def _settings_response( end_of_day_report_enabled=settings.end_of_day_report_enabled, celebration_enabled=settings.celebration_enabled, timezone=settings.timezone, + effective_timezone=host_zone.effective_timezone(settings.timezone), ) diff --git a/api/routes_panel.py b/api/routes_panel.py index 54dde57..cbe9c34 100644 --- a/api/routes_panel.py +++ b/api/routes_panel.py @@ -47,8 +47,10 @@ active children ``ORDER BY sort_order, id``); ``state`` is the documented panel spelling ``open`` | ``completed`` (§1 of design/ENTITIES-AND-SERVICES.md); ``cycle_day`` is today's 1-based day in the first scheduled child's custody cycle (0 when no child has one); -``timezone`` is the application timezone the admin stored (``""`` when -unset), which the panel cards format their visible date and clock in. +``timezone`` is the effective application timezone — the zone the admin +stored, or the API host's own zone when none is (``""`` only when the +host's zone cannot be named; :mod:`api.host_zone`) — which the panel +cards format their visible date and clock in. The same :class:`PanelSnapshotResponse` Pydantic model is the route's ``response_model``, so the OpenAPI document carries the shape too. """ @@ -69,6 +71,7 @@ from api.nestquest_core import ( core_settings_store, core_snapshot, ) +from api import host_zone from api import transitions as api_transitions from api.sse import transition_event_stream @@ -129,9 +132,9 @@ class PanelSnapshotResponse(BaseModel): ``today_iso`` anchors the payload's day; ``cycle_day`` is today's 1-based day in the first scheduled child's custody cycle (0 when no - child has a schedule); ``timezone`` is the stored application - timezone (``""`` when unset); ``children`` is in the household's - sort order. + child has a schedule); ``timezone`` is the effective application + timezone (:func:`api.host_zone.effective_timezone`); ``children`` is + in the household's sort order. """ today_iso: str @@ -223,7 +226,7 @@ async def panel_snapshot(request: Request) -> PanelSnapshotResponse: return PanelSnapshotResponse( today_iso=snapshot.today_iso, cycle_day=snapshot.cycle_day, - timezone=snapshot.timezone, + timezone=host_zone.effective_timezone(snapshot.timezone), children=[ PanelChildPayload( child_id=child.child_id, diff --git a/custom_components/nestquest/coordinator.py b/custom_components/nestquest/coordinator.py index 1f26b27..9e152f4 100644 --- a/custom_components/nestquest/coordinator.py +++ b/custom_components/nestquest/coordinator.py @@ -111,8 +111,9 @@ def snapshot_from_api_payload(payload: dict[str, Any]) -> NestQuestSnapshot: through verbatim; - ``present`` and ``next_present`` pass through verbatim (the API resolved them through the same presence engine server-side); - - ``timezone`` (the stored application timezone, ``""`` when unset) - passes through verbatim for the cards' date and clock; + - ``timezone`` (the API's effective application timezone) passes + through verbatim for the cards' date and clock; an API older than + the field omits it, which reads as ``""`` (the cards' fallback); - the instance list is rebuilt IN ORDER, so the panel payload the sensors emit matches the route's order byte for byte. @@ -163,7 +164,7 @@ def snapshot_from_api_payload(payload: dict[str, Any]) -> NestQuestSnapshot: today_iso=payload["today_iso"], children=tuple(children), cycle_day=payload["cycle_day"], - timezone=payload["timezone"], + timezone=payload.get("timezone", ""), ) diff --git a/custom_components/nestquest/core/snapshot.py b/custom_components/nestquest/core/snapshot.py index 5e438ee..7e91c8b 100644 --- a/custom_components/nestquest/core/snapshot.py +++ b/custom_components/nestquest/core/snapshot.py @@ -97,8 +97,9 @@ class NestQuestSnapshot: children: tuple[ChildDaySnapshot, ...] cycle_day: int #: The application timezone the admin stored (``""`` when unset — - #: the host's local time). The panel cards format their visible - #: date and clock in it; it never re-resolves the day itself. + #: the host's local time; the API publishes its effective zone in + #: its place, api/host_zone.py). The panel cards format their + #: visible date and clock in it; it never re-resolves the day itself. timezone: str = "" diff --git a/custom_components/nestquest/sensor.py b/custom_components/nestquest/sensor.py index 2475dda..8c95a55 100644 --- a/custom_components/nestquest/sensor.py +++ b/custom_components/nestquest/sensor.py @@ -441,8 +441,9 @@ class NestQuestHouseholdDueTodaySensor(_NestQuestHouseholdSensor): for child in data.children }, "child_roster": _child_roster(data.children), - # The stored application timezone ("" when unset): the - # panel cards format their visible date and clock in it. + # The API's effective application timezone ("" only when + # it could not name one): the panel cards format their + # visible date and clock in it. "timezone": data.timezone, } diff --git a/custom_components/nestquest/www/nestquest-cards.js b/custom_components/nestquest/www/nestquest-cards.js index 594df65..6630144 100644 --- a/custom_components/nestquest/www/nestquest-cards.js +++ b/custom_components/nestquest/www/nestquest-cards.js @@ -1243,10 +1243,12 @@ const Te = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Ee = "M50 3 44.5 2 `; } - /** The zone the visible date and clock render in: the application - * timezone the admin stored (published on the household rollup by - * custom_components/nestquest/sensor.py — ``timezone``). Only when - * none is stored does Home Assistant's zone (or the browser's) apply. */ + /** The zone the visible date and clock render in: the API's effective + * application timezone — the stored zone, or the API host's when none + * is stored (published on the household rollup by + * custom_components/nestquest/sensor.py — ``timezone``). Only when the + * rollup carries none does Home Assistant's zone (or the browser's) + * apply. */ _timeZone() { const e = this._state("sensor.nestquest_household_quests_due_today")?.attributes?.timezone; return typeof e == "string" && e.trim() ? e.trim() : kt(this.hass); @@ -3732,10 +3734,12 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", yn = "polygon(50% `; } - /** The zone the visible date and clock render in: the application - * timezone the admin stored (published on the household rollup by - * custom_components/nestquest/sensor.py — ``timezone``). Only when - * none is stored does Home Assistant's zone (or the browser's) apply. */ + /** The zone the visible date and clock render in: the API's effective + * application timezone — the stored zone, or the API host's when none + * is stored (published on the household rollup by + * custom_components/nestquest/sensor.py — ``timezone``). Only when the + * rollup carries none does Home Assistant's zone (or the browser's) + * apply. */ _timeZone() { const e = this._state("sensor.nestquest_household_quests_due_today")?.attributes?.timezone; return typeof e == "string" && e.trim() ? e.trim() : rn(this.hass); diff --git a/frontend/src/cards/party-board.ts b/frontend/src/cards/party-board.ts index c28c977..f3defff 100644 --- a/frontend/src/cards/party-board.ts +++ b/frontend/src/cards/party-board.ts @@ -937,10 +937,12 @@ export class NestQuestPartyBoardCard extends LitElement { `; } - /** The zone the visible date and clock render in: the application - * timezone the admin stored (published on the household rollup by - * custom_components/nestquest/sensor.py — ``timezone``). Only when - * none is stored does Home Assistant's zone (or the browser's) apply. */ + /** The zone the visible date and clock render in: the API's effective + * application timezone — the stored zone, or the API host's when none + * is stored (published on the household rollup by + * custom_components/nestquest/sensor.py — ``timezone``). Only when the + * rollup carries none does Home Assistant's zone (or the browser's) + * apply. */ private _timeZone(): string | undefined { const stored = this._state("sensor.nestquest_household_quests_due_today") ?.attributes?.timezone; diff --git a/frontend/src/cards/quest-log.ts b/frontend/src/cards/quest-log.ts index d3e5882..721e3bc 100644 --- a/frontend/src/cards/quest-log.ts +++ b/frontend/src/cards/quest-log.ts @@ -1749,10 +1749,12 @@ export class NestQuestQuestLogCard extends LitElement { `; } - /** The zone the visible date and clock render in: the application - * timezone the admin stored (published on the household rollup by - * custom_components/nestquest/sensor.py — ``timezone``). Only when - * none is stored does Home Assistant's zone (or the browser's) apply. */ + /** The zone the visible date and clock render in: the API's effective + * application timezone — the stored zone, or the API host's when none + * is stored (published on the household rollup by + * custom_components/nestquest/sensor.py — ``timezone``). Only when the + * rollup carries none does Home Assistant's zone (or the browser's) + * apply. */ private _timeZone(): string | undefined { const stored = this._state("sensor.nestquest_household_quests_due_today") ?.attributes?.timezone; diff --git a/tests/test_api_admin_settings.py b/tests/test_api_admin_settings.py index b934f96..7648b6d 100644 --- a/tests/test_api_admin_settings.py +++ b/tests/test_api_admin_settings.py @@ -41,6 +41,7 @@ from types import SimpleNamespace import pytest +from api import host_zone from tests.admin_jwt_harness import ( ADMIN_KEY, KID, @@ -60,9 +61,14 @@ _dao_meta = importlib.import_module("nestquest_core.dao_meta") #: independent of the core constant. SETTINGS_META_KEY = "settings" +#: The API host's own zone, pinned for every test (:func:`settings_client`): +#: the ``effective_timezone`` while no zone is stored. +API_HOST_ZONE = "Australia/Perth" + #: The all-defaults effective settings a fresh database reports — the -#: documented eleven fields, asserted literally so the payload contract is -#: pinned here, independent of the core constants. +#: documented eleven fields plus the read-only ``effective_timezone``, +#: asserted literally so the payload contract is pinned here, independent +#: of the core constants. DEFAULT_SETTINGS = { "horizon_days": 14, "day_rollover_time": "00:00", @@ -75,22 +81,34 @@ DEFAULT_SETTINGS = { "end_of_day_report_enabled": True, "celebration_enabled": True, "timezone": "", + "effective_timezone": API_HOST_ZONE, } +def _with_zone(zone: str) -> dict: + """The default settings with ``zone`` stored, as the API reports them.""" + return { + **DEFAULT_SETTINGS, + "timezone": zone, + "effective_timezone": zone or API_HOST_ZONE, + } + + def _admin_headers() -> dict[str, str]: """The Authorization header of a valid nestquest-admins JWT.""" return {"Authorization": f"Bearer {make_token()}"} @pytest.fixture -async def settings_client(temp_db_path: str) -> SimpleNamespace: +async def settings_client(temp_db_path: str, monkeypatch) -> SimpleNamespace: """An admin-plane app client plus its live database. The same runner the children tests use (stubbed JWKS, no network); the fixture also exposes the app's ONE database connection so tests - can read (or corrupt) the stored settings document itself. + can read (or corrupt) the stored settings document itself. The API + host's zone is pinned to :data:`API_HOST_ZONE`. """ + monkeypatch.setattr(host_zone, "host_timezone", lambda: API_HOST_ZONE) runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID)) async with runner as client: yield SimpleNamespace( @@ -147,7 +165,9 @@ async def test_update_persists_and_the_next_read_reflects_it( SETTINGS_META_KEY ) assert raw is not None - assert json.loads(raw) == expected + stored = dict(expected) + del stored["effective_timezone"] # derived on read, never stored + assert json.loads(raw) == stored async def test_partial_update_merges_onto_the_current_settings( @@ -254,7 +274,7 @@ async def test_manual_timezone_patch_persists_and_is_returned( headers=_admin_headers(), ) assert patched.status_code == 200 - expected = {**DEFAULT_SETTINGS, "timezone": zone} + expected = _with_zone(zone) assert patched.json() == expected reread = await client.get( @@ -285,7 +305,7 @@ async def test_timezone_alone_sticks_on_a_fresh_database( reread = await client.get( "/api/v1/admin/settings", headers=_admin_headers() ) - assert reread.json() == {**DEFAULT_SETTINGS, "timezone": zone} + assert reread.json() == _with_zone(zone) async def test_timezone_configured_is_an_unknown_field( @@ -319,7 +339,7 @@ async def test_stored_legacy_timezone_configured_key_is_ignored( "/api/v1/admin/settings", headers=_admin_headers() ) assert response.status_code == 200 - assert response.json() == {**DEFAULT_SETTINGS, "timezone": "America/Chicago"} + assert response.json() == _with_zone("America/Chicago") # --- validation: rejected values never change the stored settings ------- diff --git a/tests/test_api_admin_snapshot.py b/tests/test_api_admin_snapshot.py index f976dac..fb0995c 100644 --- a/tests/test_api_admin_snapshot.py +++ b/tests/test_api_admin_snapshot.py @@ -37,7 +37,7 @@ from zoneinfo import ZoneInfo import pytest -from api import routes_admin, routes_panel +from api import host_zone, routes_admin, routes_panel from api.nestquest_core import core_snapshot from tests.admin_jwt_harness import ( ADMIN_KEY, @@ -101,6 +101,8 @@ async def snapshot_household( monkeypatch.setattr(routes_admin, "_local_now", _admin_now) monkeypatch.setattr(routes_panel, "_local_now", lambda: pinned_now) + # The API host runs UTC, the zone of the pinned clock. + monkeypatch.setattr(host_zone, "host_timezone", lambda: "UTC") runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID)) async with runner as client: yield SimpleNamespace( @@ -166,8 +168,9 @@ async def test_admin_jwt_returns_documented_snapshot( Every field is asserted as an explicit literal, with ONE clock read for the request. With no missed view in the real snapshot, the admin body equals the panel body for the same household — the - panel body additionally carries the stored ``timezone`` its cards - format the visible date and clock in (unset here). + panel body additionally carries the effective ``timezone`` its cards + format the visible date and clock in (none stored here: the UTC + host's own zone). """ client = snapshot_household.client seed = snapshot_household.seed @@ -243,7 +246,7 @@ async def test_admin_jwt_returns_documented_snapshot( "/api/v1/panel/snapshot", headers=_panel_headers() ) assert panel.status_code == 200 - assert panel.json() == {**body, "timezone": ""} + assert panel.json() == {**body, "timezone": "UTC"} async def test_admin_keeps_missed_instance_panel_omits_it( diff --git a/tests/test_api_host_zone.py b/tests/test_api_host_zone.py new file mode 100644 index 0000000..18d8424 --- /dev/null +++ b/tests/test_api_host_zone.py @@ -0,0 +1,71 @@ +"""The API host's zone and the effective application timezone (task 7e173881). + +With no zone stored the API reads its clock in the host's local time, so +:func:`api.host_zone.effective_timezone` names that host zone — resolved +the way the C library resolves local time — for the panel and admin app +to format in. Every host configuration here is a temporary file tree. +""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +from api import host_zone + + +def _host(tmp_path: Path, *, link: str | None = None, name: str | None = None): + """A fake ``/etc``: ``localtime`` linked into a zoneinfo tree at + ``link``, and/or an ``/etc/timezone`` file holding ``name``.""" + localtime = tmp_path / "etc" / "localtime" + timezone_file = tmp_path / "etc" / "timezone" + localtime.parent.mkdir(parents=True) + if link is not None: + target = tmp_path / "usr" / "share" / "zoneinfo" / link + target.parent.mkdir(parents=True) + target.write_bytes(b"TZif") + localtime.symlink_to(target) + if name is not None: + timezone_file.write_text(f"{name}\n", encoding="utf-8") + return {"timezone_file": timezone_file, "localtime": localtime} + + +def test_tz_environment_variable_wins(tmp_path: Path) -> None: + paths = _host(tmp_path, link="Europe/Paris") + assert host_zone.host_timezone({"TZ": "Asia/Kolkata"}, **paths) == "Asia/Kolkata" + assert host_zone.host_timezone({"TZ": ":Asia/Kolkata"}, **paths) == "Asia/Kolkata" + assert host_zone.host_timezone({"TZ": ""}, **paths) == "UTC" + + +def test_localtime_link_names_the_zone(tmp_path: Path) -> None: + paths = _host(tmp_path, link="America/Argentina/Buenos_Aires") + assert host_zone.host_timezone({}, **paths) == "America/Argentina/Buenos_Aires" + + +def test_etc_timezone_names_a_copied_localtime(tmp_path: Path) -> None: + paths = _host(tmp_path, name="Pacific/Auckland") + paths["localtime"].write_bytes(b"TZif") + assert host_zone.host_timezone({}, **paths) == "Pacific/Auckland" + + +def test_unconfigured_host_runs_utc(tmp_path: Path) -> None: + assert host_zone.host_timezone({}, **_host(tmp_path)) == "UTC" + + +@pytest.mark.parametrize("tz", ["EST+5", "Not/AZone", ":/etc/localtime"]) +def test_an_unnamed_host_zone_is_empty(tmp_path: Path, tz: str) -> None: + assert host_zone.host_timezone({"TZ": tz}, **_host(tmp_path)) == "" + + +def test_unnamed_copied_localtime_is_empty(tmp_path: Path) -> None: + paths = _host(tmp_path) + paths["localtime"].write_bytes(b"TZif") + assert host_zone.host_timezone({}, **paths) == "" + + +def test_effective_timezone_is_the_stored_zone_else_the_host_zone( + monkeypatch, +) -> None: + monkeypatch.setattr(host_zone, "host_timezone", lambda: "Europe/Dublin") + assert host_zone.effective_timezone("America/Chicago") == "America/Chicago" + assert host_zone.effective_timezone("") == "Europe/Dublin" diff --git a/tests/test_api_household_clock.py b/tests/test_api_household_clock.py index 6fd2961..4bd6632 100644 --- a/tests/test_api_household_clock.py +++ b/tests/test_api_household_clock.py @@ -32,7 +32,7 @@ from zoneinfo import ZoneInfo import pytest -from api import routes_admin, routes_panel +from api import host_zone, routes_admin, routes_panel from api import scheduler as scheduler_module from api.database import _executor from api.scheduler import MissedSweepScheduler, _delay_seconds @@ -632,22 +632,45 @@ async def test_admin_saved_zone_is_authoritative_across_surfaces( assert delays[1] == pytest.approx(299) -async def test_panel_snapshot_publishes_the_stored_zone( - household: SimpleNamespace, +async def test_empty_zone_publishes_the_api_host_zone_to_panel_and_admin( + household: SimpleNamespace, monkeypatch ) -> None: - """The panel snapshot carries the stored application zone verbatim so - the cards format their visible date and clock in it — ``""`` until the - admin saves one, then exactly the saved zone.""" - async def panel_zone() -> str: - response = await household.client.get( - "/api/v1/panel/snapshot", headers=_panel_headers() - ) - assert response.status_code == 200 - return response.json()["timezone"] + """With no zone stored, the API reads the host's local time — and the + panel snapshot and the admin settings both publish that host's own + zone, so the cards and the admin app format in the API's zone, never + Home Assistant's or the browser's. - assert await panel_zone() == "" - await _set_zone(household, "Pacific/Auckland") - assert await panel_zone() == "Pacific/Auckland" + The host runs Pacific/Auckland: 12:30 UTC on the 26th is already + 00:30 on the 27th there, so the API's household day is the 27th while + a UTC Home Assistant or browser would still read the 26th. Once the + admin saves a zone, both publish exactly that zone. + """ + host = "Pacific/Auckland" + monkeypatch.setattr(host_zone, "host_timezone", lambda: host) + household.clock["now"] = _utc(12, 30).astimezone(ZoneInfo(host)) + + panel = await household.client.get( + "/api/v1/panel/snapshot", headers=_panel_headers() + ) + assert panel.status_code == 200 + settings = await _admin_settings(household) + assert settings["timezone"] == "" + assert panel.json()["timezone"] == settings["effective_timezone"] == host + # The published zone reproduces the API's own household day. + assert panel.json()["today_iso"] == "2026-09-27" + assert ( + _utc(12, 30).astimezone(ZoneInfo(host)).date().isoformat() + == panel.json()["today_iso"] + ) + + await _set_zone(household, "America/Chicago") + panel = await household.client.get( + "/api/v1/panel/snapshot", headers=_panel_headers() + ) + settings = await _admin_settings(household) + assert settings["timezone"] == "America/Chicago" + assert panel.json()["timezone"] == settings["effective_timezone"] + assert settings["effective_timezone"] == "America/Chicago" # --- settings writes are serialised with planning and sweeping (TZ-003) ------ diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index 2fdb440..346e79a 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -804,3 +804,27 @@ async def test_snapshot_from_api_payload_empty_household() -> None: assert snapshot.children == () assert snapshot.cycle_day == 0 assert snapshot.today_iso == "2026-09-23" + + +#: The panel snapshot exactly as an API from before the ``timezone`` field +#: serves it: an integration upgraded ahead of its API still reads this. +LEGACY_SNAPSHOT = { + key: value for key, value in FIXTURE_SNAPSHOT.items() if key != "timezone" +} + + +async def test_coordinator_reads_a_legacy_payload_without_timezone( + hass, make_entry +) -> None: + """An older API omits ``timezone``: every refresh still builds the + snapshot (the cards then fall back to Home Assistant's zone).""" + assert "timezone" not in LEGACY_SNAPSHOT + client = StubSnapshotClient(LEGACY_SNAPSHOT) + _entry, coordinator = await _wire_entry(hass, make_entry, client) + assert coordinator.last_update_success is True + + snapshot = await coordinator._async_update_data() + assert snapshot.timezone == "" + assert snapshot.cycle_day == 3 + assert [child.child_name for child in snapshot.children] == ["Ada", "Bo"] + assert snapshot_from_api_payload(LEGACY_SNAPSHOT).timezone == "" diff --git a/tests/test_panel_cards.py b/tests/test_panel_cards.py index f65af8e..a47fcb9 100644 --- a/tests/test_panel_cards.py +++ b/tests/test_panel_cards.py @@ -1245,7 +1245,8 @@ def test_quest_log_overdue_follows_the_api_snapshot_not_the_panel_clock() -> Non def test_panel_date_and_clock_follow_the_stored_application_timezone() -> None: """The stored application timezone is the single source of local-time truth for the panel's visible date and clock too: the household rollup - publishes it (``timezone``) and both cards format in it — the party + publishes it (``timezone`` — the API's effective zone) and both cards + format in it — the party board's kicker and dock date and clock, the quest log's header date — even though Home Assistant's zone and the panel browser's zone are both UTC. At 2026-09-29T23:30Z it is still Tuesday 11:30 PM in UTC but @@ -1292,8 +1293,9 @@ def test_panel_date_and_clock_follow_the_stored_application_timezone() -> None: assert sub is not None, log assert sub.group(1) == "Wednesday, Sep 30" - # With no stored zone the documented fallback applies: Home - # Assistant's zone (UTC here) formats the same instant. + # With no zone stored the API publishes its host's own zone, so the + # rollup always carries one; only a rollup without it (an API older + # than the field) falls back to Home Assistant's zone (UTC here). del states["sensor.nestquest_household_quests_due_today"]["attributes"][ "timezone" ]