Publish the API's effective timezone and tolerate an older snapshot payload
TZ-002: with no zone stored the API reads its clock in the host's local time, but the panel fell back to Home Assistant's zone and the admin app to the browser's, so dates could disagree near midnight. The API now names the zone it actually uses (api/host_zone.py: the stored zone, else the host's own IANA zone from TZ, /etc/localtime or /etc/timezone) and publishes it as the panel snapshot's `timezone` and the admin settings' read-only `effective_timezone`. Both cards and the admin app format in that one zone; Home Assistant's or the browser's zone applies only when none is published. TZ-003: snapshot_from_api_payload reads `timezone` with a "" default, so an integration upgraded ahead of its API keeps refreshing against the pre-change payload. Task 7e173881-fb99-426f-a2f0-2678439ffc39 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,7 +13,8 @@ let settingsResponse: () => Promise<Response>;
|
|||||||
|
|
||||||
describe("NestQuest Admin shell", () => {
|
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();
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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", ""),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 = ""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 -------
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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) ------
|
||||||
|
|||||||
@@ -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 == ""
|
||||||
|
|||||||
@@ -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"
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user