Manual timezone is the single source of local-time truth (task 7e173881) #285
@@ -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(
|
|
||||||
|
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()
|
"/api/v1/panel/snapshot", headers=_panel_headers()
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert panel.status_code == 200
|
||||||
return response.json()["timezone"]
|
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"]
|
||||||
|
)
|
||||||
|
|
||||||
assert await panel_zone() == ""
|
await _set_zone(household, "America/Chicago")
|
||||||
await _set_zone(household, "Pacific/Auckland")
|
panel = await household.client.get(
|
||||||
assert await panel_zone() == "Pacific/Auckland"
|
"/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