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", () => { describe("NestQuest Admin shell", () => {
beforeEach(() => { 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 // The shell loads the stored application timezone; every tab's own
// requests stay pending so the shell tests remain offline. // requests stay pending so the shell tests remain offline.
vi.stubGlobal( vi.stubGlobal(
@@ -78,7 +79,10 @@ describe("NestQuest Admin shell", () => {
await screen.findByTestId("timezone-error"); await screen.findByTestId("timezone-error");
expect(screen.queryByRole("heading", { name: "Today" })).toBeNull(); 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" })); fireEvent.click(screen.getByRole("button", { name: "Try again" }));
expect(await screen.findByRole("heading", { name: "Today" })).toBeTruthy(); expect(await screen.findByRole("heading", { name: "Today" })).toBeTruthy();
+6 -1
View File
@@ -19,10 +19,15 @@ export interface HouseholdSettings {
celebration_enabled: boolean; celebration_enabled: boolean;
/** IANA zone name (e.g. "America/New_York"); "" means the API host's local time. */ /** IANA zone name (e.g. "America/New_York"); "" means the API host's local time. */
timezone: string; 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. */ /** 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"; 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", () => { 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]); api = fakeApi([BRUSH]);
vi.stubGlobal( vi.stubGlobal(
"fetch", "fetch",
vi.fn((input: RequestInfo | URL, init?: RequestInit) => vi.fn((input: RequestInfo | URL, init?: RequestInit) =>
String(input).endsWith("/api/v1/admin/settings") String(input).endsWith("/api/v1/admin/settings")
? Promise.resolve(jsonResponse({ timezone })) ? Promise.resolve(jsonResponse({ timezone, effective_timezone }))
: api.fetchMock(input, init), : 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"); expect(await newSheetStartDate("Pacific/Auckland")).toBe("2026-09-24");
}); });
it("an empty stored zone defaults to the browser's local today", async () => { it("an empty stored zone defaults to today in the API host's published zone", async () => {
expect(await newSheetStartDate("")).toBe("2026-09-23"); 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"); .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) => { fetchMock = vi.fn((url: string) => {
const parsed = new URL(url, "http://x"); const parsed = new URL(url, "http://x");
if (parsed.pathname.endsWith("/api/v1/admin/settings")) { 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 filter = parsed.searchParams.get("filter");
const rows = filter === "all" ? [EVENING_NZ, LATE_NZ] : []; const rows = filter === "all" ? [EVENING_NZ, LATE_NZ] : [];
@@ -707,8 +707,11 @@ describe("HistoryTab in the stored application timezone", () => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
it("today, day grouping, clock and lateness follow the stored zone", async () => { it.each([
await renderWithZone("Pacific/Auckland"); ["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()) { for (const url of historyRequests()) {
expect(url.searchParams.get("end")).toBe("2026-09-24"); 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"]); expect(times(yesterday)).toEqual(["10:00 PM"]);
}); });
it("an empty stored zone keeps the browser's local day and clock", async () => { it("only an API that publishes no zone leaves the browser's local day and clock", async () => {
await renderWithZone(""); await renderWithZone("", "");
for (const url of historyRequests()) { for (const url of historyRequests()) {
expect(url.searchParams.get("end")).toBe("2026-09-23"); 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])); overrides = overrides.filter((o) => o.id !== Number(deleteMatch[1]));
return jsonResponse({ status: "ok" }); return jsonResponse({ status: "ok" });
} }
// The shell loads the stored application timezone first ("" = unset). // The shell loads the application timezone first ("" = none published).
if (path.endsWith("/admin/settings")) return jsonResponse({ timezone: "" }); if (path.endsWith("/admin/settings")) {
return jsonResponse({ timezone: "", effective_timezone: "" });
}
if (path.endsWith("/admin/children")) return jsonResponse({ children: CHILDREN }); if (path.endsWith("/admin/children")) return jsonResponse({ children: CHILDREN });
const patternsMatch = /\/children\/(\d+)\/presence-patterns$/.exec(path); const patternsMatch = /\/children\/(\d+)\/presence-patterns$/.exec(path);
if (patternsMatch) { if (patternsMatch) {
+6 -6
View File
@@ -658,10 +658,10 @@ describe("ScheduleTab", () => {
}); });
describe("ScheduleTab today in the stored application timezone", () => { 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) => fetchMock = vi.fn(async (url: string, init?: RequestInit) =>
String(url).endsWith("/api/v1/admin/settings") String(url).endsWith("/api/v1/admin/settings")
? jsonResponse({ timezone }) ? jsonResponse({ timezone, effective_timezone })
: routeFetch(url, init), : routeFetch(url, init),
); );
vi.stubGlobal("fetch", fetchMock); 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"); expect(day("2026-09-23").className).not.toContain("schedule-day--today");
}); });
it("an empty stored zone marks the browser's local today", async () => { it("an empty stored zone marks today in the API host's published zone", async () => {
await renderWithZone(""); await renderWithZone("", "Pacific/Auckland");
expect(day("2026-09-23").className).toContain("schedule-day--today"); expect(day("2026-09-24").className).toContain("schedule-day--today");
expect(day("2026-09-24").className).not.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, end_of_day_report_enabled: true,
celebration_enabled: false, celebration_enabled: false,
timezone: "America/Chicago", 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( function child(
id: number, id: number,
display_name: string, display_name: string,
@@ -83,10 +92,10 @@ async function routeFetch(url: string, init: RequestInit = {}): Promise<Response
return respond(); return respond();
} }
if (path === CHILDREN_PATH && method === "GET") return jsonResponse({ children }); 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") { if (path === SETTINGS_PATH && method === "PATCH") {
settings = { ...settings, ...body }; settings = { ...settings, ...body };
return jsonResponse(settings); return jsonResponse(settingsBody());
} }
if (path === CHILDREN_PATH && method === "POST") { if (path === CHILDREN_PATH && method === "POST") {
const created = child(9, body.display_name, body.sort_order ?? 0, { const created = child(9, body.display_name, body.sort_order ?? 0, {
@@ -821,7 +830,8 @@ describe("SettingsScreen — the saved zone applies app-wide", () => {
</AppTimezoneProvider>, </AppTimezoneProvider>,
); );
const form = await screen.findByRole("form", { name: "Preferences" }); 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" }), { fireEvent.change(within(form).getByRole("combobox", { name: "Timezone" }), {
target: { value: "America/New_York" }, target: { value: "America/New_York" },
+2 -2
View File
@@ -639,7 +639,7 @@ export default function SettingsScreen({ onClose }: { onClose: () => void }) {
.then((settings) => { .then((settings) => {
if (cancelled) return; if (cancelled) return;
setPreferences({ kind: "ready", settings }); setPreferences({ kind: "ready", settings });
publishTimezone(settings.timezone); publishTimezone(settings.effective_timezone);
}) })
.catch((error: unknown) => { .catch((error: unknown) => {
if (cancelled) return; if (cancelled) return;
@@ -733,7 +733,7 @@ export default function SettingsScreen({ onClose }: { onClose: () => void }) {
try { try {
const settings = await updateSettings(changes); const settings = await updateSettings(changes);
setPreferences({ kind: "ready", settings }); setPreferences({ kind: "ready", settings });
publishTimezone(settings.timezone); publishTimezone(settings.effective_timezone);
setPreferencesMessage({ kind: "info", text: "Preferences saved." }); setPreferencesMessage({ kind: "info", text: "Preferences saved." });
} catch (error) { } catch (error) {
setPreferencesMessage({ 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 * <AppTimezoneProvider> fetches the household settings and renders its
* screens only once the zone is known (a failed load says so and offers a * 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" }); setState({ kind: "loading" });
fetchSettings() fetchSettings()
.then((settings) => { .then((settings) => {
if (!cancelled) setState({ kind: "ready", timeZone: settings.timezone }); if (!cancelled) setState({ kind: "ready", timeZone: settings.effective_timezone });
}) })
.catch((error: unknown) => { .catch((error: unknown) => {
if (cancelled) return; if (cancelled) return;
@@ -79,12 +81,12 @@ export function AppTimezoneProvider({ children }: { children: ReactNode }) {
return <AppTimezoneContext.Provider value={hub}>{children}</AppTimezoneContext.Provider>; 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 { export function useAppTimezone(): string {
return useContext(AppTimezoneContext)?.timeZone ?? ""; 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 { export function usePublishAppTimezone(): (timeZone: string) => void {
const hub = useContext(AppTimezoneContext); const hub = useContext(AppTimezoneContext);
return (timeZone) => hub?.publish(timeZone); return (timeZone) => hub?.publish(timeZone);
+5 -3
View File
@@ -2,12 +2,14 @@
* Local time in the application timezone. * Local time in the application timezone.
* *
* The admin-saved zone (settings `timezone`, an IANA name) is the single * 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 * source of local-time truth for the whole application; the API publishes it
* stored, and only then does the browser's own zone apply. Every helper takes * 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). * 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 { export function zoneOption(timeZone: string): string | undefined {
return timeZone === "" ? undefined : timeZone; 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", () => { describe("TodayTab in the stored application timezone", () => {
function renderWithZone(timezone: string) { function renderWithZone(timezone: string, effective_timezone = timezone) {
fetchMock.mockImplementation((url: string) => fetchMock.mockImplementation((url: string) =>
Promise.resolve( Promise.resolve(
String(url).endsWith("/api/v1/admin/settings") String(url).endsWith("/api/v1/admin/settings")
? jsonResponse({ timezone }) ? jsonResponse({ timezone, effective_timezone })
: jsonResponse(SNAPSHOT), : 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(); expect(screen.getByText("Wednesday, September 23 · cycle day 5 of 14")).toBeTruthy();
}); });
it("an empty stored zone keeps the browser's local clock", async () => { it("an empty stored zone shows the clock in the API host's published zone", async () => {
renderWithZone(""); 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"); const completed = await screen.findByTestId("attention-12");
expect(within(completed).getByText("Completed 11:04 AM")).toBeTruthy(); expect(within(completed).getByText("Completed 11:04 AM")).toBeTruthy();
}); });
+62
View File
@@ -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()
+16 -5
View File
@@ -170,7 +170,10 @@ are deliberately not unified here:
notify target, the three notification times, the four enable notify target, the three notification times, the four enable
toggles, and the ``timezone`` — the IANA household zone the toggles, and the ``timezone`` — the IANA household zone the
household-local dates derive from, empty meaning host local; only an 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 - ``PATCH /settings`` updates ONLY the supplied fields: the body is a
JSON object of settings field names to new values, passed STRAIGHT JSON object of settings field names to new values, passed STRAIGHT
to :func:`nestquest_core.settings_store.update_settings` — the to :func:`nestquest_core.settings_store.update_settings` — the
@@ -349,6 +352,7 @@ from pydantic import (
model_validator, model_validator,
) )
from api import host_zone
from api import transitions as api_transitions from api import transitions as api_transitions
from api.auth import require_admin from api.auth import require_admin
from api.database import DatabaseState from api.database import DatabaseState
@@ -920,9 +924,14 @@ class AdminSettingsResponse(BaseModel):
afternoon_reminder_enabled: bool afternoon_reminder_enabled: bool
end_of_day_report_enabled: bool end_of_day_report_enabled: bool
celebration_enabled: bool celebration_enabled: bool
#: The household IANA time zone the API reads its clock in; ``""`` #: The household IANA time zone the admin stored; ``""`` means the
#: means the API host's local time. #: API host's local time.
timezone: str 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): class AdminMissedSweepResponse(BaseModel):
@@ -2179,8 +2188,9 @@ def _settings_response(
"""Serialize one core NestQuestSettings into the documented payload. """Serialize one core NestQuestSettings into the documented payload.
Explicit field-by-field (not ``dataclasses.asdict``) so the payload Explicit field-by-field (not ``dataclasses.asdict``) so the payload
stays pinned to the eleven documented fields even if the core stays pinned to the eleven documented fields (plus the read-only
dataclass later grows another one. ``effective_timezone``) even if the core dataclass later grows
another one.
""" """
return AdminSettingsResponse( return AdminSettingsResponse(
horizon_days=settings.horizon_days, horizon_days=settings.horizon_days,
@@ -2194,6 +2204,7 @@ def _settings_response(
end_of_day_report_enabled=settings.end_of_day_report_enabled, end_of_day_report_enabled=settings.end_of_day_report_enabled,
celebration_enabled=settings.celebration_enabled, celebration_enabled=settings.celebration_enabled,
timezone=settings.timezone, timezone=settings.timezone,
effective_timezone=host_zone.effective_timezone(settings.timezone),
) )
+9 -6
View File
@@ -47,8 +47,10 @@ active children ``ORDER BY sort_order, id``); ``state`` is the
documented panel spelling ``open`` | ``completed`` (§1 of documented panel spelling ``open`` | ``completed`` (§1 of
design/ENTITIES-AND-SERVICES.md); ``cycle_day`` is today's 1-based day 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); in the first scheduled child's custody cycle (0 when no child has one);
``timezone`` is the application timezone the admin stored (``""`` when ``timezone`` is the effective application timezone — the zone the admin
unset), which the panel cards format their visible date and clock in. 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 The same :class:`PanelSnapshotResponse` Pydantic model is the route's
``response_model``, so the OpenAPI document carries the shape too. ``response_model``, so the OpenAPI document carries the shape too.
""" """
@@ -69,6 +71,7 @@ from api.nestquest_core import (
core_settings_store, core_settings_store,
core_snapshot, core_snapshot,
) )
from api import host_zone
from api import transitions as api_transitions from api import transitions as api_transitions
from api.sse import transition_event_stream 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 ``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 1-based day in the first scheduled child's custody cycle (0 when no
child has a schedule); ``timezone`` is the stored application child has a schedule); ``timezone`` is the effective application
timezone (``""`` when unset); ``children`` is in the household's timezone (:func:`api.host_zone.effective_timezone`); ``children`` is
sort order. in the household's sort order.
""" """
today_iso: str today_iso: str
@@ -223,7 +226,7 @@ async def panel_snapshot(request: Request) -> PanelSnapshotResponse:
return PanelSnapshotResponse( return PanelSnapshotResponse(
today_iso=snapshot.today_iso, today_iso=snapshot.today_iso,
cycle_day=snapshot.cycle_day, cycle_day=snapshot.cycle_day,
timezone=snapshot.timezone, timezone=host_zone.effective_timezone(snapshot.timezone),
children=[ children=[
PanelChildPayload( PanelChildPayload(
child_id=child.child_id, child_id=child.child_id,
+4 -3
View File
@@ -111,8 +111,9 @@ def snapshot_from_api_payload(payload: dict[str, Any]) -> NestQuestSnapshot:
through verbatim; through verbatim;
- ``present`` and ``next_present`` pass through verbatim (the API - ``present`` and ``next_present`` pass through verbatim (the API
resolved them through the same presence engine server-side); resolved them through the same presence engine server-side);
- ``timezone`` (the stored application timezone, ``""`` when unset) - ``timezone`` (the API's effective application timezone) passes
passes through verbatim for the cards' date and clock; 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 - the instance list is rebuilt IN ORDER, so the panel payload the
sensors emit matches the route's order byte for byte. 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"], today_iso=payload["today_iso"],
children=tuple(children), children=tuple(children),
cycle_day=payload["cycle_day"], cycle_day=payload["cycle_day"],
timezone=payload["timezone"], timezone=payload.get("timezone", ""),
) )
+3 -2
View File
@@ -97,8 +97,9 @@ class NestQuestSnapshot:
children: tuple[ChildDaySnapshot, ...] children: tuple[ChildDaySnapshot, ...]
cycle_day: int cycle_day: int
#: The application timezone the admin stored (``""`` when unset — #: The application timezone the admin stored (``""`` when unset —
#: the host's local time). The panel cards format their visible #: the host's local time; the API publishes its effective zone in
#: date and clock in it; it never re-resolves the day itself. #: 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 = "" timezone: str = ""
+3 -2
View File
@@ -441,8 +441,9 @@ class NestQuestHouseholdDueTodaySensor(_NestQuestHouseholdSensor):
for child in data.children for child in data.children
}, },
"child_roster": _child_roster(data.children), "child_roster": _child_roster(data.children),
# The stored application timezone ("" when unset): the # The API's effective application timezone ("" only when
# panel cards format their visible date and clock in it. # it could not name one): the panel cards format their
# visible date and clock in it.
"timezone": data.timezone, "timezone": data.timezone,
} }
@@ -1243,10 +1243,12 @@ const Te = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Ee = "M50 3 44.5 2
</div> </div>
`; `;
} }
/** The zone the visible date and clock render in: the application /** The zone the visible date and clock render in: the API's effective
* timezone the admin stored (published on the household rollup by * application timezone — the stored zone, or the API host's when none
* custom_components/nestquest/sensor.py — ``timezone``). Only when * is stored (published on the household rollup by
* none is stored does Home Assistant's zone (or the browser's) apply. */ * custom_components/nestquest/sensor.py — ``timezone``). Only when the
* rollup carries none does Home Assistant's zone (or the browser's)
* apply. */
_timeZone() { _timeZone() {
const e = this._state("sensor.nestquest_household_quests_due_today")?.attributes?.timezone; const e = this._state("sensor.nestquest_household_quests_due_today")?.attributes?.timezone;
return typeof e == "string" && e.trim() ? e.trim() : kt(this.hass); 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%
</div> </div>
`; `;
} }
/** The zone the visible date and clock render in: the application /** The zone the visible date and clock render in: the API's effective
* timezone the admin stored (published on the household rollup by * application timezone — the stored zone, or the API host's when none
* custom_components/nestquest/sensor.py — ``timezone``). Only when * is stored (published on the household rollup by
* none is stored does Home Assistant's zone (or the browser's) apply. */ * custom_components/nestquest/sensor.py — ``timezone``). Only when the
* rollup carries none does Home Assistant's zone (or the browser's)
* apply. */
_timeZone() { _timeZone() {
const e = this._state("sensor.nestquest_household_quests_due_today")?.attributes?.timezone; const e = this._state("sensor.nestquest_household_quests_due_today")?.attributes?.timezone;
return typeof e == "string" && e.trim() ? e.trim() : rn(this.hass); return typeof e == "string" && e.trim() ? e.trim() : rn(this.hass);
+6 -4
View File
@@ -937,10 +937,12 @@ export class NestQuestPartyBoardCard extends LitElement {
`; `;
} }
/** The zone the visible date and clock render in: the application /** The zone the visible date and clock render in: the API's effective
* timezone the admin stored (published on the household rollup by * application timezone — the stored zone, or the API host's when none
* custom_components/nestquest/sensor.py — ``timezone``). Only when * is stored (published on the household rollup by
* none is stored does Home Assistant's zone (or the browser's) apply. */ * 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 { private _timeZone(): string | undefined {
const stored = this._state("sensor.nestquest_household_quests_due_today") const stored = this._state("sensor.nestquest_household_quests_due_today")
?.attributes?.timezone; ?.attributes?.timezone;
+6 -4
View File
@@ -1749,10 +1749,12 @@ export class NestQuestQuestLogCard extends LitElement {
`; `;
} }
/** The zone the visible date and clock render in: the application /** The zone the visible date and clock render in: the API's effective
* timezone the admin stored (published on the household rollup by * application timezone — the stored zone, or the API host's when none
* custom_components/nestquest/sensor.py — ``timezone``). Only when * is stored (published on the household rollup by
* none is stored does Home Assistant's zone (or the browser's) apply. */ * 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 { private _timeZone(): string | undefined {
const stored = this._state("sensor.nestquest_household_quests_due_today") const stored = this._state("sensor.nestquest_household_quests_due_today")
?.attributes?.timezone; ?.attributes?.timezone;
+28 -8
View File
@@ -41,6 +41,7 @@ from types import SimpleNamespace
import pytest import pytest
from api import host_zone
from tests.admin_jwt_harness import ( from tests.admin_jwt_harness import (
ADMIN_KEY, ADMIN_KEY,
KID, KID,
@@ -60,9 +61,14 @@ _dao_meta = importlib.import_module("nestquest_core.dao_meta")
#: independent of the core constant. #: independent of the core constant.
SETTINGS_META_KEY = "settings" 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 #: The all-defaults effective settings a fresh database reports — the
#: documented eleven fields, asserted literally so the payload contract is #: documented eleven fields plus the read-only ``effective_timezone``,
#: pinned here, independent of the core constants. #: asserted literally so the payload contract is pinned here, independent
#: of the core constants.
DEFAULT_SETTINGS = { DEFAULT_SETTINGS = {
"horizon_days": 14, "horizon_days": 14,
"day_rollover_time": "00:00", "day_rollover_time": "00:00",
@@ -75,22 +81,34 @@ DEFAULT_SETTINGS = {
"end_of_day_report_enabled": True, "end_of_day_report_enabled": True,
"celebration_enabled": True, "celebration_enabled": True,
"timezone": "", "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]: def _admin_headers() -> dict[str, str]:
"""The Authorization header of a valid nestquest-admins JWT.""" """The Authorization header of a valid nestquest-admins JWT."""
return {"Authorization": f"Bearer {make_token()}"} return {"Authorization": f"Bearer {make_token()}"}
@pytest.fixture @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. """An admin-plane app client plus its live database.
The same runner the children tests use (stubbed JWKS, no network); The same runner the children tests use (stubbed JWKS, no network);
the fixture also exposes the app's ONE database connection so tests 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)) runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
async with runner as client: async with runner as client:
yield SimpleNamespace( yield SimpleNamespace(
@@ -147,7 +165,9 @@ async def test_update_persists_and_the_next_read_reflects_it(
SETTINGS_META_KEY SETTINGS_META_KEY
) )
assert raw is not None 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( 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(), headers=_admin_headers(),
) )
assert patched.status_code == 200 assert patched.status_code == 200
expected = {**DEFAULT_SETTINGS, "timezone": zone} expected = _with_zone(zone)
assert patched.json() == expected assert patched.json() == expected
reread = await client.get( reread = await client.get(
@@ -285,7 +305,7 @@ async def test_timezone_alone_sticks_on_a_fresh_database(
reread = await client.get( reread = await client.get(
"/api/v1/admin/settings", headers=_admin_headers() "/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( 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() "/api/v1/admin/settings", headers=_admin_headers()
) )
assert response.status_code == 200 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 ------- # --- validation: rejected values never change the stored settings -------
+7 -4
View File
@@ -37,7 +37,7 @@ from zoneinfo import ZoneInfo
import pytest 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 api.nestquest_core import core_snapshot
from tests.admin_jwt_harness import ( from tests.admin_jwt_harness import (
ADMIN_KEY, ADMIN_KEY,
@@ -101,6 +101,8 @@ async def snapshot_household(
monkeypatch.setattr(routes_admin, "_local_now", _admin_now) monkeypatch.setattr(routes_admin, "_local_now", _admin_now)
monkeypatch.setattr(routes_panel, "_local_now", lambda: pinned_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)) runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
async with runner as client: async with runner as client:
yield SimpleNamespace( 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 Every field is asserted as an explicit literal, with ONE clock
read for the request. With no missed view in the real snapshot, read for the request. With no missed view in the real snapshot,
the admin body equals the panel body for the same household — the the admin body equals the panel body for the same household — the
panel body additionally carries the stored ``timezone`` its cards panel body additionally carries the effective ``timezone`` its cards
format the visible date and clock in (unset here). format the visible date and clock in (none stored here: the UTC
host's own zone).
""" """
client = snapshot_household.client client = snapshot_household.client
seed = snapshot_household.seed seed = snapshot_household.seed
@@ -243,7 +246,7 @@ async def test_admin_jwt_returns_documented_snapshot(
"/api/v1/panel/snapshot", headers=_panel_headers() "/api/v1/panel/snapshot", headers=_panel_headers()
) )
assert panel.status_code == 200 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( async def test_admin_keeps_missed_instance_panel_omits_it(
+71
View File
@@ -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"
+38 -15
View File
@@ -32,7 +32,7 @@ from zoneinfo import ZoneInfo
import pytest 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 import scheduler as scheduler_module
from api.database import _executor from api.database import _executor
from api.scheduler import MissedSweepScheduler, _delay_seconds 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) assert delays[1] == pytest.approx(299)
async def test_panel_snapshot_publishes_the_stored_zone( async def test_empty_zone_publishes_the_api_host_zone_to_panel_and_admin(
household: SimpleNamespace, household: SimpleNamespace, monkeypatch
) -> None: ) -> None:
"""The panel snapshot carries the stored application zone verbatim so """With no zone stored, the API reads the host's local time — and the
the cards format their visible date and clock in it — ``""`` until the panel snapshot and the admin settings both publish that host's own
admin saves one, then exactly the saved zone.""" zone, so the cards and the admin app format in the API's zone, never
async def panel_zone() -> str: Home Assistant's or the browser's.
response = await household.client.get(
"/api/v1/panel/snapshot", headers=_panel_headers()
)
assert response.status_code == 200
return response.json()["timezone"]
assert await panel_zone() == "" The host runs Pacific/Auckland: 12:30 UTC on the 26th is already
await _set_zone(household, "Pacific/Auckland") 00:30 on the 27th there, so the API's household day is the 27th while
assert await panel_zone() == "Pacific/Auckland" 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) ------ # --- settings writes are serialised with planning and sweeping (TZ-003) ------
+24
View File
@@ -804,3 +804,27 @@ async def test_snapshot_from_api_payload_empty_household() -> None:
assert snapshot.children == () assert snapshot.children == ()
assert snapshot.cycle_day == 0 assert snapshot.cycle_day == 0
assert snapshot.today_iso == "2026-09-23" 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 == ""
+5 -3
View File
@@ -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: def test_panel_date_and_clock_follow_the_stored_application_timezone() -> None:
"""The stored application timezone is the single source of local-time """The stored application timezone is the single source of local-time
truth for the panel's visible date and clock too: the household rollup 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 — 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 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 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 is not None, log
assert sub.group(1) == "Wednesday, Sep 30" assert sub.group(1) == "Wednesday, Sep 30"
# With no stored zone the documented fallback applies: Home # With no zone stored the API publishes its host's own zone, so the
# Assistant's zone (UTC here) formats the same instant. # 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"][ del states["sensor.nestquest_household_quests_due_today"]["attributes"][
"timezone" "timezone"
] ]