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