Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c84f26d979 | ||
|
|
47966af6aa | ||
|
|
0e726ac64f | ||
|
|
69db92b7b8 | ||
|
|
2db780e3ee | ||
|
|
6e25c9e42c | ||
|
|
427f82a8bd | ||
|
|
94a8dbd8c9 | ||
|
|
62ff614a1e | ||
|
|
48eae05e1f | ||
|
|
55b9ce915b | ||
|
|
80f25b2faf | ||
|
|
29674f7138 | ||
|
|
8a5872c728 | ||
|
|
b792ba50c1 | ||
|
|
49f40d99b6 | ||
|
|
37d66ce5d9 | ||
|
|
40d0e5d8f6 |
@@ -1,5 +1,113 @@
|
||||
# NestQuest Release Notes
|
||||
|
||||
## Version 0.9.7 — 2026-09-29
|
||||
|
||||
### Scope
|
||||
|
||||
Patch release carrying the panel-local overdue correction onto `main` (PR #279,
|
||||
task fd1093b5 "Tasks are still showing over due when the local time has not
|
||||
passed").
|
||||
|
||||
### Behaviour
|
||||
|
||||
- The kids' Quest Log card now decides whether an open quest is OVERDUE from
|
||||
the panel's OWN local clock (the wall time where the panel physically is)
|
||||
compared with the quest's due time, instead of trusting the API snapshot's
|
||||
`overdue` flag. A quest therefore cannot read OVERDUE before its due time has
|
||||
passed on the panel, regardless of the API host's timezone, Home Assistant's
|
||||
configured `time_zone`, or whether the 0.9.6 timezone changes have been
|
||||
deployed. A quest is overdue at the due minute and after (`>=`); a quest with
|
||||
no due time is never overdue.
|
||||
- The API's `overdue` field is unchanged; it still serves the admin plane and
|
||||
automations. This is a panel display correction only.
|
||||
|
||||
### Requirements
|
||||
|
||||
- Home Assistant floor 2024.6.0 (unchanged).
|
||||
|
||||
### Breaking changes
|
||||
|
||||
- None. The database schema is unchanged at version 9: no migration and no
|
||||
database backup is required for this release.
|
||||
|
||||
### Known limitations
|
||||
|
||||
- The comparison uses only wall-clock time. If the API is unreachable across
|
||||
local midnight and the coordinator serves its last-good snapshot, a quest
|
||||
from the previous day with a later due time can briefly read as not overdue
|
||||
until that time passes again. On the normal path the API rolls the household
|
||||
day over and the missed sweep removes the previous day's open quests.
|
||||
- The Quest Log header clock still formats in Home Assistant's configured
|
||||
`time_zone`; if HA is set to a zone other than the household's, that header
|
||||
remains wrong. Set Home Assistant's own `time_zone` correctly — the admin/API
|
||||
timezone setting does not affect the header clock.
|
||||
|
||||
### Rollback
|
||||
|
||||
- No schema change: rolling back to 0.9.6 requires no database restore.
|
||||
|
||||
## Version 0.9.6 — 2026-09-28
|
||||
|
||||
### Scope
|
||||
|
||||
Patch release carrying the household time-zone fix onto `main` (PR #275, task
|
||||
6351b36d "Time zone errors"), plus its release-review correction (PR #277, task
|
||||
45243478).
|
||||
|
||||
### Behaviour
|
||||
|
||||
- The Home Assistant integration now reports `hass.config.time_zone` to the API
|
||||
in an `X-NestQuest-Timezone` header on every panel request.
|
||||
- While the API's `timezone` setting has never been explicitly chosen
|
||||
(`timezone_configured` false), the API adopts and persists the reported zone
|
||||
(under the settings lock), so the panel's overdue/today calculation, the
|
||||
admin plane and the missed-sweep scheduler are all household-local even when
|
||||
the API host runs UTC. No manual setup is required.
|
||||
- The missed-sweep scheduler re-plans immediately when the adopted zone changes
|
||||
while it is sleeping, and re-reads the stored zone before sweeping, so it no
|
||||
longer fires at the old (UTC) rollover.
|
||||
- An admin-chosen zone — including an explicit choice of empty (API-host local)
|
||||
— still wins over the reported zone. A timezone-only admin update is recorded
|
||||
as an explicit choice (`timezone_configured` true); an explicitly supplied
|
||||
`timezone_configured` value in the same update is honored as given. An invalid
|
||||
reported zone is logged and ignored; the panel keeps serving on the stored
|
||||
settings.
|
||||
|
||||
### Requirements
|
||||
|
||||
- Home Assistant floor 2024.6.0 (unchanged).
|
||||
|
||||
### Breaking changes
|
||||
|
||||
- None. The database schema is unchanged at version 9: no migration and no
|
||||
database backup is required for this release.
|
||||
- Deployment note: this fix needs BOTH the API service and the HA integration
|
||||
upgraded. The integration supplies the zone and the API adopts it; upgrading
|
||||
only one side leaves the previous behaviour. An install that sets or clears
|
||||
its zone on 0.9.6 or later keeps that choice; see Known limitations for a zone
|
||||
stored by an older release.
|
||||
|
||||
### Known limitations
|
||||
|
||||
- A zone stored by a release before 0.9.6 without the explicit-choice marker
|
||||
(for example through the previously documented
|
||||
`PATCH /api/v1/admin/settings {"timezone": ...}`) is not distinguishable from
|
||||
an automatically adopted value, so 0.9.6 adopts Home Assistant's reported zone
|
||||
over it once. If a zone different from Home Assistant's is intended, re-save
|
||||
it in the admin Preferences, which now records the explicit choice.
|
||||
- If `timezone` is empty and `timezone_configured` is true (an explicit
|
||||
"host local" choice), that choice is respected and the API host's zone (UTC
|
||||
on the standard deployment) still applies until an admin selects a zone in
|
||||
the admin Preferences.
|
||||
- A change to Home Assistant's own `time_zone` takes effect after the
|
||||
integration reloads or Home Assistant restarts.
|
||||
|
||||
### Rollback
|
||||
|
||||
- No schema change: rolling back to 0.9.5 requires no database restore. The API
|
||||
and the integration may be rolled back independently; rolling back only the
|
||||
integration restores the previous stored-setting-only behaviour.
|
||||
|
||||
## Version 0.9.5 — 2026-09-27
|
||||
|
||||
### Scope
|
||||
|
||||
+50
-7
@@ -2,10 +2,29 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import App from "./App";
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
let settingsResponse: () => Promise<Response>;
|
||||
|
||||
describe("NestQuest Admin shell", () => {
|
||||
beforeEach(() => {
|
||||
// The Today tab fetches the admin snapshot; keep the shell tests offline.
|
||||
vi.stubGlobal("fetch", vi.fn(() => new Promise<Response>(() => {})));
|
||||
settingsResponse = () =>
|
||||
Promise.resolve(jsonResponse({ timezone: "", effective_timezone: "Europe/London" }));
|
||||
// The shell loads the stored application timezone; every tab's own
|
||||
// requests stay pending so the shell tests remain offline.
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn((url: string) =>
|
||||
String(url).endsWith("/api/v1/admin/settings")
|
||||
? settingsResponse()
|
||||
: new Promise<Response>(() => {}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -22,10 +41,10 @@ describe("NestQuest Admin shell", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("starts on the Today screen and switches on tab click", () => {
|
||||
it("starts on the Today screen and switches on tab click", async () => {
|
||||
render(<App />);
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Today" })).toBeTruthy();
|
||||
expect(await screen.findByRole("heading", { name: "Today" })).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "History" }));
|
||||
|
||||
@@ -36,12 +55,36 @@ describe("NestQuest Admin shell", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("the Tasks tab renders the definitions screen", () => {
|
||||
it("the Tasks tab renders the definitions screen", async () => {
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Tasks" }));
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Tasks" })).toBeTruthy();
|
||||
expect(await screen.findByRole("heading", { name: "Tasks" })).toBeTruthy();
|
||||
expect(screen.getByRole("tabpanel").textContent).not.toContain("placeholder");
|
||||
});
|
||||
});
|
||||
|
||||
it("renders no screen until the stored application timezone is known", async () => {
|
||||
settingsResponse = () => new Promise<Response>(() => {});
|
||||
render(<App />);
|
||||
|
||||
expect(screen.getByTestId("timezone-loading")).toBeTruthy();
|
||||
expect(screen.queryByRole("heading", { name: "Today" })).toBeNull();
|
||||
});
|
||||
|
||||
it("a failed timezone load says so and retries", async () => {
|
||||
settingsResponse = () => Promise.resolve(jsonResponse({ detail: "boom" }, 500));
|
||||
render(<App />);
|
||||
|
||||
await screen.findByTestId("timezone-error");
|
||||
expect(screen.queryByRole("heading", { name: "Today" })).toBeNull();
|
||||
|
||||
settingsResponse = () =>
|
||||
Promise.resolve(
|
||||
jsonResponse({ timezone: "Pacific/Auckland", effective_timezone: "Pacific/Auckland" }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Today" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
+13
-9
@@ -4,6 +4,7 @@ import { TransitionsProvider } from "./api/useTransitions";
|
||||
import DefinitionsTab from "./definitions/DefinitionsTab";
|
||||
import HistoryTab from "./history/HistoryTab";
|
||||
import ScheduleTab from "./schedule/ScheduleTab";
|
||||
import { AppTimezoneProvider } from "./time/AppTimezone";
|
||||
import TodayTab from "./today/TodayTab";
|
||||
|
||||
const TABS = [
|
||||
@@ -32,15 +33,18 @@ export default function App() {
|
||||
role="tabpanel"
|
||||
aria-labelledby={`tab-${active}`}
|
||||
>
|
||||
{active === "today" ? (
|
||||
<TodayTab />
|
||||
) : active === "tasks" ? (
|
||||
<DefinitionsTab />
|
||||
) : active === "schedule" ? (
|
||||
<ScheduleTab />
|
||||
) : (
|
||||
<HistoryTab />
|
||||
)}
|
||||
{/* Every screen's local time is the stored application timezone. */}
|
||||
<AppTimezoneProvider>
|
||||
{active === "today" ? (
|
||||
<TodayTab />
|
||||
) : active === "tasks" ? (
|
||||
<DefinitionsTab />
|
||||
) : active === "schedule" ? (
|
||||
<ScheduleTab />
|
||||
) : (
|
||||
<HistoryTab />
|
||||
)}
|
||||
</AppTimezoneProvider>
|
||||
</main>
|
||||
<nav className="admin-tabbar" aria-label="Admin sections">
|
||||
{TABS.map((tab) => (
|
||||
|
||||
@@ -20,15 +20,14 @@ export interface HouseholdSettings {
|
||||
/** IANA zone name (e.g. "America/New_York"); "" means the API host's local time. */
|
||||
timezone: string;
|
||||
/**
|
||||
* True once the admin explicitly chose `timezone` (even ""); false on a
|
||||
* fresh install, where the screen auto-sets the browser's zone. API-only:
|
||||
* never shown in the form.
|
||||
* 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).
|
||||
*/
|
||||
timezone_configured: boolean;
|
||||
effective_timezone: string;
|
||||
}
|
||||
|
||||
/** Only the supplied fields are updated; the API answers with all of them. */
|
||||
export type SettingsChanges = Partial<HouseholdSettings>;
|
||||
export type SettingsChanges = Partial<Omit<HouseholdSettings, "effective_timezone">>;
|
||||
|
||||
export const SETTINGS_PATH = "/api/v1/admin/settings";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import DefinitionsTab from "./DefinitionsTab";
|
||||
import {
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "./definitionIcons";
|
||||
import type { AdminChild, DefinitionRule, QuestDefinition } from "../api/definitions";
|
||||
import { storeTokens } from "../auth/oidc";
|
||||
import { AppTimezoneProvider } from "../time/AppTimezone";
|
||||
|
||||
function child(id: number, name: string, overrides: Partial<AdminChild> = {}): AdminChild {
|
||||
return {
|
||||
@@ -1723,3 +1724,57 @@ describe("DefinitionsTab start date", () => {
|
||||
expect(within(s).getByText("Pick a start date.")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DefinitionsTab start date in the stored application timezone", () => {
|
||||
async function newSheetStartDate(
|
||||
timezone: string,
|
||||
effective_timezone = timezone,
|
||||
): Promise<string> {
|
||||
api = fakeApi([BRUSH]);
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn((input: RequestInfo | URL, init?: RequestInit) =>
|
||||
String(input).endsWith("/api/v1/admin/settings")
|
||||
? Promise.resolve(jsonResponse({ timezone, effective_timezone }))
|
||||
: api.fetchMock(input, init),
|
||||
),
|
||||
);
|
||||
render(
|
||||
<AppTimezoneProvider>
|
||||
<DefinitionsTab />
|
||||
</AppTimezoneProvider>,
|
||||
);
|
||||
await screen.findByText("Brush teeth");
|
||||
fireEvent.click(screen.getByRole("button", { name: "New" }));
|
||||
return startDateInput(sheet()).value;
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubEnv("TZ", "UTC");
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
// Wed 23 Sep 13:00 UTC is already Thu 24 Sep 01:00 in Auckland.
|
||||
vi.useFakeTimers({ toFake: ["Date"], now: new Date("2026-09-23T13:00:00Z") });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("a new task defaults to today in the stored zone", async () => {
|
||||
expect(await newSheetStartDate("Pacific/Auckland")).toBe("2026-09-24");
|
||||
});
|
||||
|
||||
it("an empty stored zone defaults to today in the API host's published zone", async () => {
|
||||
expect(await newSheetStartDate("", "Pacific/Auckland")).toBe("2026-09-24");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,6 +57,9 @@ import {
|
||||
normalizeDefinitionIconName,
|
||||
} from "./definitionIcons";
|
||||
import { GlyphSvg } from "../icons/GlyphSvg";
|
||||
import { weekdayOf } from "../schedule/cycle";
|
||||
import { useAppTimezone } from "../time/AppTimezone";
|
||||
import { todayIn } from "../time/zone";
|
||||
import "./DefinitionsTab.css";
|
||||
|
||||
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
|
||||
@@ -121,20 +124,13 @@ type LoadState =
|
||||
/** `null` = closed, `"new"` = creating, a definition = editing it. */
|
||||
type SheetTarget = null | "new" | QuestDefinition;
|
||||
|
||||
function pad2(n: number): string {
|
||||
return String(n).padStart(2, "0");
|
||||
}
|
||||
|
||||
function localIsoDate(date: Date): string {
|
||||
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
|
||||
}
|
||||
|
||||
function emptyDueTimes(): Record<WindowName, string> {
|
||||
return { morning: "", afternoon: "", evening: "" };
|
||||
}
|
||||
|
||||
function newDraft(activeChildren: AdminChild[]): Draft {
|
||||
const today = new Date();
|
||||
/** Draft defaults take `today` ("YYYY-MM-DD") in the application timezone. */
|
||||
function newDraft(activeChildren: AdminChild[], today: string): Draft {
|
||||
const [, month, day] = today.split("-").map(Number);
|
||||
return {
|
||||
title: "",
|
||||
icon: null,
|
||||
@@ -142,22 +138,22 @@ function newDraft(activeChildren: AdminChild[]): Draft {
|
||||
assigneeIds: activeChildren.length === 1 ? [activeChildren[0].id] : [],
|
||||
repeats: "daily",
|
||||
interval: "1",
|
||||
weekdays: [(today.getDay() + 6) % 7],
|
||||
dayOfMonth: String(today.getDate()),
|
||||
month: String(today.getMonth() + 1),
|
||||
weekdays: [weekdayOf(today)],
|
||||
dayOfMonth: String(day),
|
||||
month: String(month),
|
||||
monthlyWeekday: null,
|
||||
windows: [],
|
||||
dueTimes: emptyDueTimes(),
|
||||
// Mirrors the API's create default: no instance on an away day.
|
||||
skipOnAway: true,
|
||||
startDate: localIsoDate(today),
|
||||
startDate: today,
|
||||
endDate: null,
|
||||
};
|
||||
}
|
||||
|
||||
function draftFrom(definition: QuestDefinition, activeChildren: AdminChild[]): Draft {
|
||||
function draftFrom(definition: QuestDefinition, activeChildren: AdminChild[], today: string): Draft {
|
||||
const { rule } = definition;
|
||||
const today = new Date();
|
||||
const [, month, day] = today.split("-").map(Number);
|
||||
const active = new Set(activeChildren.map((child) => child.id));
|
||||
const dueTimes = emptyDueTimes();
|
||||
for (const w of definition.windows) dueTimes[w.window] = w.due_time ?? "";
|
||||
@@ -172,9 +168,9 @@ function draftFrom(definition: QuestDefinition, activeChildren: AdminChild[]): D
|
||||
weekdays:
|
||||
rule.rule_type === "weekly" || rule.rule_type === "custom_days"
|
||||
? [...(rule.weekday_set ?? [])]
|
||||
: [(today.getDay() + 6) % 7],
|
||||
dayOfMonth: String(rule.day_of_month ?? today.getDate()),
|
||||
month: String(rule.month ?? today.getMonth() + 1),
|
||||
: [weekdayOf(today)],
|
||||
dayOfMonth: String(rule.day_of_month ?? day),
|
||||
month: String(rule.month ?? month),
|
||||
monthlyWeekday:
|
||||
rule.rule_type === "monthly_weekday"
|
||||
? { nth: rule.nth_weekday ?? 1, weekday: rule.nth_weekday_weekday ?? 0 }
|
||||
@@ -465,9 +461,11 @@ function EditSheet({
|
||||
onSaved: () => void;
|
||||
onDeleted: (result: DefinitionDeleteResult) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<Draft>(() =>
|
||||
target === "new" ? newDraft(activeChildren) : draftFrom(target, activeChildren),
|
||||
);
|
||||
const timeZone = useAppTimezone();
|
||||
const [draft, setDraft] = useState<Draft>(() => {
|
||||
const today = todayIn(timeZone);
|
||||
return target === "new" ? newDraft(activeChildren, today) : draftFrom(target, activeChildren, today);
|
||||
});
|
||||
// The active selection as loaded: while it is unchanged, the PATCH omits
|
||||
// assignee_child_ids, because the API replaces the whole set and inactive
|
||||
// children cannot be sent back.
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { HistoryFilter, HistoryRow } from "../api/history";
|
||||
import { storeTokens } from "../auth/oidc";
|
||||
import { TransitionsProvider } from "../api/useTransitions";
|
||||
import { frame, testStream, type TestStream } from "../api/testStream";
|
||||
import { AppTimezoneProvider } from "../time/AppTimezone";
|
||||
|
||||
const TODAY = "2026-09-23";
|
||||
|
||||
@@ -629,3 +630,117 @@ describe("HistoryTab live updates", () => {
|
||||
expect(screen.getAllByTestId("history-row")).toHaveLength(ROWS.all.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HistoryTab in the stored application timezone", () => {
|
||||
// 13:00 UTC on Sep 23 is 01:00 on Sep 24 in Auckland (NZST, UTC+12).
|
||||
const NOW = new Date("2026-09-23T13:00:00Z");
|
||||
// Due 00:10 Auckland on Sep 24, done 00:30 Auckland (12:30 UTC on Sep 23).
|
||||
const LATE_NZ = row({
|
||||
instance_id: 7,
|
||||
quest_title: "Feed cat",
|
||||
due_date: "2026-09-24",
|
||||
due_time: "00:10",
|
||||
occurred_at: "2026-09-23T12:30:00Z",
|
||||
was_on_time: false,
|
||||
});
|
||||
// 22:00 Auckland on Sep 23; 10:00 UTC.
|
||||
const EVENING_NZ = row({ instance_id: 8, occurred_at: "2026-09-23T10:00:00Z" });
|
||||
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
function historyRequests(): URL[] {
|
||||
return fetchMock.mock.calls
|
||||
.map(([url]) => new URL(String(url), "http://x"))
|
||||
.filter((url) => url.pathname === "/api/v1/admin/history");
|
||||
}
|
||||
|
||||
async function renderWithZone(timezone: string, effective_timezone = timezone) {
|
||||
fetchMock = vi.fn((url: string) => {
|
||||
const parsed = new URL(url, "http://x");
|
||||
if (parsed.pathname.endsWith("/api/v1/admin/settings")) {
|
||||
return Promise.resolve(jsonResponse({ timezone, effective_timezone }));
|
||||
}
|
||||
const filter = parsed.searchParams.get("filter");
|
||||
const rows = filter === "all" ? [EVENING_NZ, LATE_NZ] : [];
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
filter,
|
||||
start: parsed.searchParams.get("start"),
|
||||
end: parsed.searchParams.get("end"),
|
||||
count: rows.length,
|
||||
rows,
|
||||
}),
|
||||
);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
render(
|
||||
<AppTimezoneProvider>
|
||||
<HistoryTab />
|
||||
</AppTimezoneProvider>,
|
||||
);
|
||||
await screen.findByTestId("stat-on-time");
|
||||
}
|
||||
|
||||
function times(section: HTMLElement): string[] {
|
||||
return Array.from(section.querySelectorAll(".history-event-time")).map(
|
||||
(el) => el.textContent ?? "",
|
||||
);
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubEnv("TZ", "UTC");
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
vi.useFakeTimers({ toFake: ["Date"], now: NOW });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["the stored zone", "Pacific/Auckland"],
|
||||
["the API host's published zone when none is stored", ""],
|
||||
])("today, day grouping, clock and lateness follow %s", async (_label, stored) => {
|
||||
await renderWithZone(stored, "Pacific/Auckland");
|
||||
|
||||
for (const url of historyRequests()) {
|
||||
expect(url.searchParams.get("end")).toBe("2026-09-24");
|
||||
expect(url.searchParams.get("start")).toBe("2026-09-11");
|
||||
}
|
||||
const today = screen.getByTestId("day-2026-09-24");
|
||||
expect(within(today).getByText("Today")).toBeTruthy();
|
||||
expect(times(today)).toEqual(["12:30 AM"]);
|
||||
expect(metas(today)).toEqual(["completed · panel (Declan) · late 20 min"]);
|
||||
|
||||
const yesterday = screen.getByTestId("day-2026-09-23");
|
||||
expect(within(yesterday).getByText("Yesterday")).toBeTruthy();
|
||||
expect(times(yesterday)).toEqual(["10:00 PM"]);
|
||||
});
|
||||
|
||||
it("only an API that publishes no zone leaves the browser's local day and clock", async () => {
|
||||
await renderWithZone("", "");
|
||||
|
||||
for (const url of historyRequests()) {
|
||||
expect(url.searchParams.get("end")).toBe("2026-09-23");
|
||||
}
|
||||
expect(screen.queryByTestId("day-2026-09-24")).toBeNull();
|
||||
const today = screen.getByTestId("day-2026-09-23");
|
||||
expect(within(today).getByText("Today")).toBeTruthy();
|
||||
expect(times(today)).toEqual(["12:30 PM", "10:00 AM"]);
|
||||
// Due 00:10 UTC on Sep 24 is after the 12:30 UTC completion: no minutes.
|
||||
expect(metas(today)).toEqual([
|
||||
"completed · panel (Declan) · late",
|
||||
"completed · panel (Declan) · on time",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,9 +9,10 @@ import { ApiForbiddenError } from "../api/client";
|
||||
import type { TransitionEvent } from "../api/events";
|
||||
import { useTransitions } from "../api/useTransitions";
|
||||
import { exportHistoryCsv, fetchHistory, type HistoryFilter, type HistoryRow } from "../api/history";
|
||||
import { localTodayIso } from "../schedule/cycle";
|
||||
import { dayLabel, groupByDay, rowMeta, rowTime, shiftIso } from "./format";
|
||||
import { DownloadGlyph, LockGlyph } from "./glyphs";
|
||||
import { useAppTimezone } from "../time/AppTimezone";
|
||||
import { todayIn } from "../time/zone";
|
||||
import "./HistoryTab.css";
|
||||
|
||||
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
|
||||
@@ -129,7 +130,7 @@ function StatRow({ stats }: { stats: HistoryStats }) {
|
||||
);
|
||||
}
|
||||
|
||||
function EventRow({ row }: { row: HistoryRow }) {
|
||||
function EventRow({ row, timeZone }: { row: HistoryRow; timeZone: string }) {
|
||||
return (
|
||||
<li className="history-event" data-testid="history-row">
|
||||
<span
|
||||
@@ -142,10 +143,10 @@ function EventRow({ row }: { row: HistoryRow }) {
|
||||
{row.quest_title} · {row.child_name}
|
||||
</div>
|
||||
<div className="history-event-meta" data-testid="history-meta">
|
||||
{rowMeta(row)}
|
||||
{rowMeta(row, timeZone)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="history-event-time">{rowTime(row)}</span>
|
||||
<span className="history-event-time">{rowTime(row, timeZone)}</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -161,7 +162,10 @@ function FooterNote() {
|
||||
);
|
||||
}
|
||||
|
||||
export default function HistoryTab({ today = localTodayIso() }: { today?: string }) {
|
||||
/** `today` ("YYYY-MM-DD") defaults to today in the application timezone. */
|
||||
export default function HistoryTab({ today: todayProp }: { today?: string }) {
|
||||
const timeZone = useAppTimezone();
|
||||
const today = todayProp ?? todayIn(timeZone);
|
||||
const [filter, setFilter] = useState<HistoryFilter>("all");
|
||||
const [state, setState] = useState<LoadState>({ kind: "loading" });
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
@@ -244,7 +248,7 @@ export default function HistoryTab({ today = localTodayIso() }: { today?: string
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
const groups = groupByDay(shown.data.rows);
|
||||
const groups = groupByDay(shown.data.rows, timeZone);
|
||||
body =
|
||||
groups.length > 0 ? (
|
||||
groups.map((group) => (
|
||||
@@ -252,7 +256,7 @@ export default function HistoryTab({ today = localTodayIso() }: { today?: string
|
||||
<h3 className="history-section-label">{dayLabel(group.day, today)}</h3>
|
||||
<ul className="history-card history-list">
|
||||
{group.rows.map((row, index) => (
|
||||
<EventRow key={`${row.instance_id}-${row.event_type}-${row.occurred_at ?? ""}-${index}`} row={row} />
|
||||
<EventRow key={`${row.instance_id}-${row.event_type}-${row.occurred_at ?? ""}-${index}`} row={row} timeZone={timeZone} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
+21
-24
@@ -4,12 +4,13 @@
|
||||
* Calendar dates ("YYYY-MM-DD") are handled by their components (never
|
||||
* `new Date("YYYY-MM-DD")`, which is UTC midnight and reads as the previous
|
||||
* day in negative-offset zones); event stamps are UTC and shown in the
|
||||
* viewer's local time.
|
||||
* application timezone (`timeZone`: the stored IANA zone, "" = browser).
|
||||
*/
|
||||
import type { HistoryRow } from "../api/history";
|
||||
import { dayNumber, isoFromDayNumber, localTodayIso } from "../schedule/cycle";
|
||||
import { dayNumber, isoFromDayNumber } from "../schedule/cycle";
|
||||
import { shortDate } from "../schedule/format";
|
||||
import { formatClockTime, formatDueTime } from "../today/format";
|
||||
import { isoDateIn, wallClockInstant } from "../time/zone";
|
||||
|
||||
const MS_PER_MINUTE = 60_000;
|
||||
|
||||
@@ -19,25 +20,21 @@ export function shiftIso(iso: string, days: number): string {
|
||||
}
|
||||
|
||||
/** The local calendar day an event belongs to (due_date for missed rows). */
|
||||
export function rowDay(row: HistoryRow): string {
|
||||
return row.occurred_at ? localTodayIso(new Date(row.occurred_at)) : row.due_date;
|
||||
export function rowDay(row: HistoryRow, timeZone: string): string {
|
||||
return row.occurred_at ? isoDateIn(new Date(row.occurred_at), timeZone) : row.due_date;
|
||||
}
|
||||
|
||||
/** The row's due moment as local wall-clock time, or null without a due time. */
|
||||
function dueMoment(row: HistoryRow): Date | null {
|
||||
/** The row's due moment (epoch ms) as local wall-clock time, or null without a due time. */
|
||||
function dueMoment(row: HistoryRow, timeZone: string): number | null {
|
||||
const match = /^(\d{2}):(\d{2})/.exec(row.due_time ?? "");
|
||||
if (!match) return null;
|
||||
const [year, month, day] = row.due_date.split("-").map(Number);
|
||||
return new Date(year, month - 1, day, Number(match[1]), Number(match[2]));
|
||||
return wallClockInstant(row.due_date, Number(match[1]), Number(match[2]), timeZone);
|
||||
}
|
||||
|
||||
/** Newest first; a missed row sorts at its due moment (or the end of its day). */
|
||||
function sortKey(row: HistoryRow): number {
|
||||
function sortKey(row: HistoryRow, timeZone: string): number {
|
||||
if (row.occurred_at) return Date.parse(row.occurred_at);
|
||||
const due = dueMoment(row);
|
||||
if (due) return due.getTime();
|
||||
const [year, month, day] = row.due_date.split("-").map(Number);
|
||||
return new Date(year, month - 1, day, 23, 59).getTime();
|
||||
return dueMoment(row, timeZone) ?? wallClockInstant(row.due_date, 23, 59, timeZone);
|
||||
}
|
||||
|
||||
export interface DayGroup {
|
||||
@@ -46,10 +43,10 @@ export interface DayGroup {
|
||||
}
|
||||
|
||||
/** Rows grouped by day, newest day first and newest row first within a day. */
|
||||
export function groupByDay(rows: HistoryRow[]): DayGroup[] {
|
||||
export function groupByDay(rows: HistoryRow[], timeZone: string): DayGroup[] {
|
||||
const groups = new Map<string, HistoryRow[]>();
|
||||
for (const row of rows) {
|
||||
const day = rowDay(row);
|
||||
const day = rowDay(row, timeZone);
|
||||
groups.set(day, [...(groups.get(day) ?? []), row]);
|
||||
}
|
||||
return [...groups.entries()]
|
||||
@@ -57,7 +54,7 @@ export function groupByDay(rows: HistoryRow[]): DayGroup[] {
|
||||
.map(([day, dayRows]) => ({
|
||||
day,
|
||||
// Stable sort: rows at the same moment keep the API's append order.
|
||||
rows: [...dayRows].sort((a, b) => sortKey(b) - sortKey(a)),
|
||||
rows: [...dayRows].sort((a, b) => sortKey(b, timeZone) - sortKey(a, timeZone)),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -72,23 +69,23 @@ export function dayLabel(day: string, today: string): string {
|
||||
}
|
||||
|
||||
/** "on time", "late 22 min", or "late" when the lateness is not derivable. */
|
||||
function onTimeFlag(row: HistoryRow): string | null {
|
||||
function onTimeFlag(row: HistoryRow, timeZone: string): string | null {
|
||||
if (row.event_type !== "completed" || row.was_on_time === null) return null;
|
||||
if (row.was_on_time) return "on time";
|
||||
const due = dueMoment(row);
|
||||
if (!due || !row.occurred_at) return "late";
|
||||
const minutes = Math.round((Date.parse(row.occurred_at) - due.getTime()) / MS_PER_MINUTE);
|
||||
const due = dueMoment(row, timeZone);
|
||||
if (due === null || !row.occurred_at) return "late";
|
||||
const minutes = Math.round((Date.parse(row.occurred_at) - due) / MS_PER_MINUTE);
|
||||
return minutes > 0 ? `late ${minutes} min` : "late";
|
||||
}
|
||||
|
||||
/** `<event type> · <actor>` plus the on-time flag for completions. */
|
||||
export function rowMeta(row: HistoryRow): string {
|
||||
const flag = onTimeFlag(row);
|
||||
export function rowMeta(row: HistoryRow, timeZone: string): string {
|
||||
const flag = onTimeFlag(row, timeZone);
|
||||
return [row.event_type, row.actor, flag].filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
/** The event's local clock time; a missed row shows its due time, if any. */
|
||||
export function rowTime(row: HistoryRow): string {
|
||||
if (row.occurred_at) return formatClockTime(row.occurred_at);
|
||||
export function rowTime(row: HistoryRow, timeZone: string): string {
|
||||
if (row.occurred_at) return formatClockTime(row.occurred_at, timeZone);
|
||||
return row.due_time ? formatDueTime(row.due_time) : "";
|
||||
}
|
||||
|
||||
@@ -74,6 +74,10 @@ async function routeFetch(url: string, init: RequestInit = {}): Promise<Response
|
||||
overrides = overrides.filter((o) => o.id !== Number(deleteMatch[1]));
|
||||
return jsonResponse({ status: "ok" });
|
||||
}
|
||||
// The shell loads the application timezone first ("" = none published).
|
||||
if (path.endsWith("/admin/settings")) {
|
||||
return jsonResponse({ timezone: "", effective_timezone: "" });
|
||||
}
|
||||
if (path.endsWith("/admin/children")) return jsonResponse({ children: CHILDREN });
|
||||
const patternsMatch = /\/children\/(\d+)\/presence-patterns$/.exec(path);
|
||||
if (patternsMatch) {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import ScheduleTab from "./ScheduleTab";
|
||||
import type { AdminChild } from "../api/definitions";
|
||||
import type { PresenceOverride, PresencePattern } from "../api/presence";
|
||||
import { storeTokens } from "../auth/oidc";
|
||||
import { AppTimezoneProvider } from "../time/AppTimezone";
|
||||
import { cycleWeekIndex, monthDates, weekdayOf } from "./cycle";
|
||||
|
||||
function child(id: number, display_name: string, sort_order: number, is_active = true): AdminChild {
|
||||
@@ -655,3 +656,55 @@ describe("ScheduleTab", () => {
|
||||
expect(await screen.findByTestId("pattern-1")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ScheduleTab today in the stored application timezone", () => {
|
||||
async function renderWithZone(timezone: string, effective_timezone = timezone) {
|
||||
fetchMock = vi.fn(async (url: string, init?: RequestInit) =>
|
||||
String(url).endsWith("/api/v1/admin/settings")
|
||||
? jsonResponse({ timezone, effective_timezone })
|
||||
: routeFetch(url, init),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
render(
|
||||
<AppTimezoneProvider>
|
||||
<ScheduleTab />
|
||||
</AppTimezoneProvider>,
|
||||
);
|
||||
await screen.findByTestId("pattern-1");
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubEnv("TZ", "UTC");
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
patterns = { 1: [DECLAN_HOME], 2: [MAEVE_THU_FRI, MAEVE_WEEKENDS], 3: [] };
|
||||
writeFailure = null;
|
||||
// 13:00 UTC on Sep 23 is already 01:00 on Sep 24 in Auckland.
|
||||
vi.useFakeTimers({ toFake: ["Date"], now: new Date("2026-09-23T13:00:00Z") });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("marks today in the stored zone", async () => {
|
||||
await renderWithZone("Pacific/Auckland");
|
||||
expect(day("2026-09-24").className).toContain("schedule-day--today");
|
||||
expect(day("2026-09-23").className).not.toContain("schedule-day--today");
|
||||
});
|
||||
|
||||
it("an empty stored zone marks today in the API host's published zone", async () => {
|
||||
await renderWithZone("", "Pacific/Auckland");
|
||||
expect(day("2026-09-24").className).toContain("schedule-day--today");
|
||||
expect(day("2026-09-23").className).not.toContain("schedule-day--today");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
import {
|
||||
cycleLabel,
|
||||
dayState,
|
||||
localTodayIso,
|
||||
monthDates,
|
||||
monthStart,
|
||||
patternDaysLabel,
|
||||
@@ -38,6 +37,8 @@ import {
|
||||
} from "./glyphs";
|
||||
import OverrideEditor from "./OverrideEditor";
|
||||
import PatternEditor from "./PatternEditor";
|
||||
import { useAppTimezone } from "../time/AppTimezone";
|
||||
import { todayIn } from "../time/zone";
|
||||
import "./ScheduleTab.css";
|
||||
|
||||
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
|
||||
@@ -324,11 +325,13 @@ function OverrideRow({ override, childName, deletion, onAskDelete, onKeep, onDel
|
||||
}
|
||||
|
||||
export interface ScheduleTabProps {
|
||||
/** "YYYY-MM-DD"; defaults to the viewer's local date. */
|
||||
/** "YYYY-MM-DD"; defaults to today in the application timezone. */
|
||||
today?: string;
|
||||
}
|
||||
|
||||
export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProps) {
|
||||
export default function ScheduleTab({ today: todayProp }: ScheduleTabProps) {
|
||||
const timeZone = useAppTimezone();
|
||||
const today = todayProp ?? todayIn(timeZone);
|
||||
const [state, setState] = useState<LoadState>({ kind: "loading" });
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
|
||||
@@ -55,13 +55,6 @@ export function cycleWeekIndex(anchorDate: string, date: string, cycleLengthWeek
|
||||
return mod(Math.floor(days / 7), cycleLengthWeeks);
|
||||
}
|
||||
|
||||
/** The viewer's local calendar date. */
|
||||
export function localTodayIso(now: Date = new Date()): string {
|
||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(now.getDate()).padStart(2, "0");
|
||||
return `${now.getFullYear()}-${month}-${day}`;
|
||||
}
|
||||
|
||||
/** "YYYY-MM-01" of the month containing `iso`, shifted by `delta` months. */
|
||||
export function monthStart(iso: string, delta = 0): string {
|
||||
const [year, month] = iso.split("-").map(Number);
|
||||
|
||||
@@ -258,6 +258,12 @@
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.settings-hint {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.settings-input {
|
||||
width: 100%;
|
||||
min-height: var(--nq-a-tap-min);
|
||||
|
||||
@@ -6,6 +6,7 @@ import SettingsScreen, { SCROLL_BOTTOM_PADDING } from "./SettingsScreen";
|
||||
import type { AdminChild } from "../api/definitions";
|
||||
import type { HouseholdSettings } from "../api/settings";
|
||||
import { storeTokens } from "../auth/oidc";
|
||||
import { AppTimezoneProvider, useAppTimezone } from "../time/AppTimezone";
|
||||
|
||||
const CHILDREN_PATH = "/api/v1/admin/children";
|
||||
const REORDER_PATH = `${CHILDREN_PATH}/reorder`;
|
||||
@@ -23,9 +24,17 @@ const DEFAULT_SETTINGS: HouseholdSettings = {
|
||||
end_of_day_report_enabled: true,
|
||||
celebration_enabled: false,
|
||||
timezone: "America/Chicago",
|
||||
timezone_configured: true,
|
||||
effective_timezone: "America/Chicago",
|
||||
};
|
||||
|
||||
/** The API host's own zone: the effective zone while none is stored. */
|
||||
const API_HOST_ZONE = "Europe/London";
|
||||
|
||||
/** The settings as the API answers: `effective_timezone` derived like the API's. */
|
||||
function settingsBody(): HouseholdSettings {
|
||||
return { ...settings, effective_timezone: settings.timezone || API_HOST_ZONE };
|
||||
}
|
||||
|
||||
function child(
|
||||
id: number,
|
||||
display_name: string,
|
||||
@@ -83,10 +92,10 @@ async function routeFetch(url: string, init: RequestInit = {}): Promise<Response
|
||||
return respond();
|
||||
}
|
||||
if (path === CHILDREN_PATH && method === "GET") return jsonResponse({ children });
|
||||
if (path === SETTINGS_PATH && method === "GET") return jsonResponse(settings);
|
||||
if (path === SETTINGS_PATH && method === "GET") return jsonResponse(settingsBody());
|
||||
if (path === SETTINGS_PATH && method === "PATCH") {
|
||||
settings = { ...settings, ...body };
|
||||
return jsonResponse(settings);
|
||||
return jsonResponse(settingsBody());
|
||||
}
|
||||
if (path === CHILDREN_PATH && method === "POST") {
|
||||
const created = child(9, body.display_name, body.sort_order ?? 0, {
|
||||
@@ -500,37 +509,37 @@ describe("SettingsScreen — preferences", () => {
|
||||
expect(writes()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("a changed timezone patches exactly the timezone", async () => {
|
||||
it("a timezone picked from the list patches exactly the timezone", async () => {
|
||||
const { form } = await renderPreferences();
|
||||
const input = field(form, "Timezone");
|
||||
expect(input.getAttribute("list")).toBe("settings-timezones");
|
||||
fireEvent.change(input, { target: { value: " Europe/London " } });
|
||||
fireEvent.change(field(form, "Timezone"), { target: { value: "America/New_York" } });
|
||||
save(form);
|
||||
await screen.findByText("Preferences saved.");
|
||||
expect(writes()).toEqual([
|
||||
{ method: "PATCH", path: SETTINGS_PATH, body: { timezone: "Europe/London", timezone_configured: true } },
|
||||
{ method: "PATCH", path: SETTINGS_PATH, body: { timezone: "America/New_York" } },
|
||||
]);
|
||||
const reloaded = screen.getByRole("form", { name: "Preferences" });
|
||||
expect(field(reloaded, "Timezone").value).toBe("Europe/London");
|
||||
expect(field(reloaded, "Timezone").value).toBe("America/New_York");
|
||||
});
|
||||
|
||||
it.each(["New York", "America/New York", "EST5EDT?", "/Chicago"])(
|
||||
"an invalid timezone %j is rejected before any request",
|
||||
"an invalid other timezone %j is rejected before any request",
|
||||
async (value) => {
|
||||
const { form } = await renderPreferences();
|
||||
fireEvent.change(field(form, "Timezone"), { target: { value } });
|
||||
fireEvent.change(field(form, "Timezone"), { target: { value: "__other__" } });
|
||||
fireEvent.change(field(form, "Other timezone"), { target: { value } });
|
||||
save(form);
|
||||
expect(screen.getByTestId("preferences-message").textContent).toBe(
|
||||
"Timezone must be an IANA zone like America/New_York, or UTC.",
|
||||
);
|
||||
expect(field(form, "Timezone").value).toBe(value);
|
||||
expect(field(form, "Other timezone").value).toBe(value);
|
||||
expect(writes()).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it("a 422 on an unknown timezone shows the API detail and keeps the edit", async () => {
|
||||
const { form } = await renderPreferences();
|
||||
fireEvent.change(field(form, "Timezone"), { target: { value: "Mars/Olympus_Mons" } });
|
||||
fireEvent.change(field(form, "Timezone"), { target: { value: "__other__" } });
|
||||
fireEvent.change(field(form, "Other timezone"), { target: { value: "Mars/Olympus_Mons" } });
|
||||
writeResponse = () =>
|
||||
jsonResponse({ detail: "timezone must be an IANA time zone name such as 'America/New_York'" }, 422);
|
||||
save(form);
|
||||
@@ -538,8 +547,9 @@ describe("SettingsScreen — preferences", () => {
|
||||
expect(message.textContent).toBe(
|
||||
"The preferences could not be saved: timezone must be an IANA time zone name such as 'America/New_York'",
|
||||
);
|
||||
expect(field(form, "Timezone").value).toBe("Mars/Olympus_Mons");
|
||||
expect(field(form, "Timezone").disabled).toBe(false);
|
||||
expect(field(form, "Other timezone").value).toBe("Mars/Olympus_Mons");
|
||||
expect(field(form, "Other timezone").disabled).toBe(false);
|
||||
expect(settings.timezone).toBe("America/Chicago");
|
||||
});
|
||||
|
||||
it("a failed load shows the reason and Try again refetches", async () => {
|
||||
@@ -559,7 +569,7 @@ describe("SettingsScreen — preferences", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("SettingsScreen — timezone auto-set", () => {
|
||||
describe("SettingsScreen — no timezone auto-set", () => {
|
||||
let zoneSpy: ReturnType<typeof vi.spyOn> | null;
|
||||
|
||||
function mockBrowserZone(timeZone: string | undefined) {
|
||||
@@ -575,8 +585,8 @@ describe("SettingsScreen — timezone auto-set", () => {
|
||||
sessionStorage.clear();
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
children = [child(1, "Declan", 1)];
|
||||
// A fresh install: no zone, and the admin never chose one.
|
||||
settings = { ...DEFAULT_SETTINGS, timezone: "", timezone_configured: false };
|
||||
// A fresh install: no zone stored.
|
||||
settings = { ...DEFAULT_SETTINGS, timezone: "" };
|
||||
writeResponse = null;
|
||||
zoneSpy = null;
|
||||
fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init));
|
||||
@@ -595,25 +605,15 @@ describe("SettingsScreen — timezone auto-set", () => {
|
||||
return screen.findByRole("form", { name: "Preferences" });
|
||||
}
|
||||
|
||||
it("an unset timezone is set once, on load, to the browser's zone and shown", async () => {
|
||||
it("an empty timezone stays empty on load and nothing is written", async () => {
|
||||
mockBrowserZone("America/New_York");
|
||||
const form = await renderForm();
|
||||
expect(field(form, "Timezone").value).toBe("America/New_York");
|
||||
expect(writes()).toEqual([
|
||||
{ method: "PATCH", path: SETTINGS_PATH, body: { timezone: "America/New_York", timezone_configured: true } },
|
||||
]);
|
||||
expect(settings.timezone).toBe("America/New_York");
|
||||
expect(settings.timezone_configured).toBe(true);
|
||||
|
||||
// A later save of another field does not re-send the timezone.
|
||||
fireEvent.change(field(form, "Planning horizon (days)"), { target: { value: "21" } });
|
||||
fireEvent.click(within(form).getByRole("button", { name: "Save preferences" }));
|
||||
await screen.findByText("Preferences saved.");
|
||||
expect(writes()).toHaveLength(2);
|
||||
expect(writes()[1].body).toEqual({ horizon_days: 21 });
|
||||
expect(field(form, "Timezone").value).toBe("");
|
||||
expect(writes()).toEqual([]);
|
||||
expect(settings.timezone).toBe("");
|
||||
});
|
||||
|
||||
it("StrictMode's doubled effects still auto-set exactly once", async () => {
|
||||
it("StrictMode's doubled effects write nothing either", async () => {
|
||||
mockBrowserZone("America/New_York");
|
||||
render(
|
||||
<StrictMode>
|
||||
@@ -621,70 +621,223 @@ describe("SettingsScreen — timezone auto-set", () => {
|
||||
</StrictMode>,
|
||||
);
|
||||
const form = await screen.findByRole("form", { name: "Preferences" });
|
||||
expect(field(form, "Timezone").value).toBe("America/New_York");
|
||||
expect(writes()).toEqual([
|
||||
{ method: "PATCH", path: SETTINGS_PATH, body: { timezone: "America/New_York", timezone_configured: true } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("an admin clearing the timezone is not re-set by the browser zone, even after a reopen", async () => {
|
||||
mockBrowserZone("America/New_York");
|
||||
const form = await renderForm();
|
||||
fireEvent.change(field(form, "Timezone"), { target: { value: "" } });
|
||||
fireEvent.click(within(form).getByRole("button", { name: "Save preferences" }));
|
||||
await screen.findByText("Preferences saved.");
|
||||
expect(writes().map((w) => w.body)).toEqual([
|
||||
{ timezone: "America/New_York", timezone_configured: true },
|
||||
{ timezone: "", timezone_configured: true },
|
||||
]);
|
||||
const reloaded = screen.getByRole("form", { name: "Preferences" });
|
||||
expect(field(reloaded, "Timezone").value).toBe("");
|
||||
|
||||
// Close and reopen: the screen re-fetches "" but the choice persisted.
|
||||
cleanup();
|
||||
const reopened = await renderForm();
|
||||
expect(field(reopened, "Timezone").value).toBe("");
|
||||
expect(writes()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("an empty timezone the admin chose is never auto-set", async () => {
|
||||
mockBrowserZone("America/New_York");
|
||||
settings = { ...settings, timezone_configured: true };
|
||||
const form = await renderForm();
|
||||
expect(field(form, "Timezone").value).toBe("");
|
||||
expect(writes()).toEqual([]);
|
||||
});
|
||||
|
||||
it("a set timezone is shown and never overwritten by the browser zone", async () => {
|
||||
it("a stored timezone is shown as stored, never replaced by the browser zone", async () => {
|
||||
mockBrowserZone("America/New_York");
|
||||
settings = { ...DEFAULT_SETTINGS, timezone: "Europe/Dublin", timezone_configured: false };
|
||||
settings = { ...DEFAULT_SETTINGS, timezone: "Europe/Dublin" };
|
||||
const form = await renderForm();
|
||||
expect(field(form, "Timezone").value).toBe("Europe/Dublin");
|
||||
expect(writes()).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["nothing", undefined],
|
||||
["an empty zone", ""],
|
||||
["an unknown zone", "Etc/Unknown"],
|
||||
["a non-IANA zone", "not a zone"],
|
||||
])("detection returning %s leaves the field empty and sends nothing", async (_case, zone) => {
|
||||
mockBrowserZone(zone);
|
||||
it("saving another field sends only that field, never a timezone", async () => {
|
||||
mockBrowserZone("America/New_York");
|
||||
const form = await renderForm();
|
||||
expect(field(form, "Timezone").value).toBe("");
|
||||
expect(screen.queryByTestId("preferences-message")).toBeNull();
|
||||
fireEvent.change(field(form, "Planning horizon (days)"), { target: { value: "21" } });
|
||||
fireEvent.click(within(form).getByRole("button", { name: "Save preferences" }));
|
||||
await screen.findByText("Preferences saved.");
|
||||
expect(writes().map((w) => w.body)).toEqual([{ horizon_days: 21 }]);
|
||||
expect(settings.timezone).toBe("");
|
||||
});
|
||||
|
||||
it("an admin clearing the timezone saves exactly the empty zone", async () => {
|
||||
mockBrowserZone("America/New_York");
|
||||
settings = { ...DEFAULT_SETTINGS, timezone: "Europe/Dublin" };
|
||||
const form = await renderForm();
|
||||
fireEvent.change(field(form, "Timezone"), { target: { value: "" } });
|
||||
fireEvent.click(within(form).getByRole("button", { name: "Save preferences" }));
|
||||
await screen.findByText("Preferences saved.");
|
||||
expect(writes().map((w) => w.body)).toEqual([{ timezone: "" }]);
|
||||
expect(field(screen.getByRole("form", { name: "Preferences" }), "Timezone").value).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SettingsScreen — manual timezone control", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
children = [child(1, "Declan", 1)];
|
||||
settings = { ...DEFAULT_SETTINGS, timezone: "" };
|
||||
writeResponse = null;
|
||||
fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
async function renderForm() {
|
||||
render(<SettingsScreen onClose={vi.fn()} />);
|
||||
await screen.findByTestId("settings-child-1");
|
||||
return screen.findByRole("form", { name: "Preferences" });
|
||||
}
|
||||
|
||||
function save(form: HTMLElement) {
|
||||
fireEvent.click(within(form).getByRole("button", { name: "Save preferences" }));
|
||||
}
|
||||
|
||||
/** The Timezone control, which must be a real select, not a free-text box. */
|
||||
function picker(form: HTMLElement): HTMLSelectElement {
|
||||
const control = within(form).getByRole("combobox", { name: "Timezone" });
|
||||
expect(control).toBeInstanceOf(HTMLSelectElement);
|
||||
return control as HTMLSelectElement;
|
||||
}
|
||||
|
||||
it("is a picker of curated zones with host-local and other-zone choices", async () => {
|
||||
const form = await renderForm();
|
||||
const select = picker(form);
|
||||
const values = Array.from(select.options).map((o) => o.value);
|
||||
expect(values[0]).toBe("");
|
||||
expect(select.options[0].textContent).toBe("API host local time (no zone set)");
|
||||
for (const zone of [
|
||||
"UTC",
|
||||
"America/New_York",
|
||||
"America/Chicago",
|
||||
"America/Denver",
|
||||
"America/Phoenix",
|
||||
"America/Los_Angeles",
|
||||
"America/Anchorage",
|
||||
"Pacific/Honolulu",
|
||||
]) {
|
||||
expect(values).toContain(zone);
|
||||
}
|
||||
expect(select.options[select.options.length - 1].textContent).toBe("Other IANA zone…");
|
||||
expect(select.value).toBe("");
|
||||
expect(within(form).queryByLabelText("Other timezone")).toBeNull();
|
||||
});
|
||||
|
||||
it("explains that it is the application timezone for every surface", async () => {
|
||||
const form = await renderForm();
|
||||
const help = document.getElementById(picker(form).getAttribute("aria-describedby") ?? "");
|
||||
expect(help?.textContent).toMatch(/application timezone/);
|
||||
expect(help?.textContent).toMatch(/panel, the scheduler and this admin app/);
|
||||
expect(help?.textContent).toMatch(/API host's local time/);
|
||||
});
|
||||
|
||||
it("a picked zone saves via PATCH and is shown after a reload", async () => {
|
||||
const form = await renderForm();
|
||||
expect(writes()).toEqual([]);
|
||||
fireEvent.change(picker(form), { target: { value: "America/New_York" } });
|
||||
save(form);
|
||||
await screen.findByText("Preferences saved.");
|
||||
expect(writes()).toEqual([
|
||||
{ method: "PATCH", path: SETTINGS_PATH, body: { timezone: "America/New_York" } },
|
||||
]);
|
||||
cleanup();
|
||||
const reloaded = await renderForm();
|
||||
expect(picker(reloaded).value).toBe("America/New_York");
|
||||
expect(writes()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("an other zone typed by hand saves via PATCH and is shown after a reload", async () => {
|
||||
const form = await renderForm();
|
||||
fireEvent.change(picker(form), { target: { value: "__other__" } });
|
||||
const entry = within(form).getByLabelText("Other timezone") as HTMLInputElement;
|
||||
expect(entry.value).toBe("");
|
||||
fireEvent.change(entry, { target: { value: " America/Boise " } });
|
||||
save(form);
|
||||
await screen.findByText("Preferences saved.");
|
||||
expect(writes()).toEqual([
|
||||
{ method: "PATCH", path: SETTINGS_PATH, body: { timezone: "America/Boise" } },
|
||||
]);
|
||||
cleanup();
|
||||
const reloaded = await renderForm();
|
||||
expect(picker(reloaded).value).toBe("__other__");
|
||||
expect((within(reloaded).getByLabelText("Other timezone") as HTMLInputElement).value).toBe(
|
||||
"America/Boise",
|
||||
);
|
||||
});
|
||||
|
||||
it("a saved zone outside the list is shown in the other-zone entry on load", async () => {
|
||||
settings = { ...DEFAULT_SETTINGS, timezone: "Asia/Kathmandu" };
|
||||
const form = await renderForm();
|
||||
expect(picker(form).value).toBe("__other__");
|
||||
expect((within(form).getByLabelText("Other timezone") as HTMLInputElement).value).toBe(
|
||||
"Asia/Kathmandu",
|
||||
);
|
||||
expect(writes()).toEqual([]);
|
||||
});
|
||||
|
||||
it("a rejected auto-set shows why and leaves the field empty for manual entry", async () => {
|
||||
mockBrowserZone("America/New_York");
|
||||
writeResponse = () => jsonResponse({ detail: "timezone is not known" }, 422);
|
||||
it("choosing API host local time clears the zone and persists after a reload", async () => {
|
||||
settings = { ...DEFAULT_SETTINGS, timezone: "America/Denver" };
|
||||
const form = await renderForm();
|
||||
expect(field(form, "Timezone").value).toBe("");
|
||||
expect(field(form, "Timezone").disabled).toBe(false);
|
||||
expect(screen.getByTestId("preferences-message").textContent).toBe(
|
||||
"The timezone could not be set to America/New_York: timezone is not known",
|
||||
expect(picker(form).value).toBe("America/Denver");
|
||||
fireEvent.change(picker(form), { target: { value: "" } });
|
||||
save(form);
|
||||
await screen.findByText("Preferences saved.");
|
||||
expect(writes().map((w) => w.body)).toEqual([{ timezone: "" }]);
|
||||
cleanup();
|
||||
const reloaded = await renderForm();
|
||||
expect(picker(reloaded).value).toBe("");
|
||||
});
|
||||
|
||||
it("picking a zone and then the saved one again sends nothing", async () => {
|
||||
settings = { ...DEFAULT_SETTINGS, timezone: "UTC" };
|
||||
const form = await renderForm();
|
||||
fireEvent.change(picker(form), { target: { value: "Europe/Paris" } });
|
||||
fireEvent.change(picker(form), { target: { value: "UTC" } });
|
||||
save(form);
|
||||
expect(screen.getByTestId("preferences-message").textContent).toBe("No changes to save.");
|
||||
expect(writes()).toEqual([]);
|
||||
});
|
||||
|
||||
it("never sends a timezone_configured field", async () => {
|
||||
const form = await renderForm();
|
||||
fireEvent.change(picker(form), { target: { value: "America/Los_Angeles" } });
|
||||
save(form);
|
||||
await screen.findByText("Preferences saved.");
|
||||
const again = screen.getByRole("form", { name: "Preferences" });
|
||||
fireEvent.change(picker(again), { target: { value: "" } });
|
||||
save(again);
|
||||
await waitFor(() => expect(writes()).toHaveLength(2));
|
||||
for (const write of writes()) {
|
||||
expect(Object.keys(write.body as object)).toEqual(["timezone"]);
|
||||
expect(write.body).not.toHaveProperty("timezone_configured");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("SettingsScreen — the saved zone applies app-wide", () => {
|
||||
function ZoneProbe() {
|
||||
return <output data-testid="app-zone">{useAppTimezone() || "(browser)"}</output>;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
children = [child(1, "Declan", 1)];
|
||||
settings = { ...DEFAULT_SETTINGS, timezone: "" };
|
||||
writeResponse = null;
|
||||
fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("a saved zone becomes the application timezone at once", async () => {
|
||||
render(
|
||||
<AppTimezoneProvider>
|
||||
<ZoneProbe />
|
||||
<SettingsScreen onClose={vi.fn()} />
|
||||
</AppTimezoneProvider>,
|
||||
);
|
||||
expect(writes()).toHaveLength(1);
|
||||
const form = await screen.findByRole("form", { name: "Preferences" });
|
||||
// No zone stored: the application runs in the API host's zone.
|
||||
expect(screen.getByTestId("app-zone").textContent).toBe(API_HOST_ZONE);
|
||||
|
||||
fireEvent.change(within(form).getByRole("combobox", { name: "Timezone" }), {
|
||||
target: { value: "America/New_York" },
|
||||
});
|
||||
fireEvent.click(within(form).getByRole("button", { name: "Save preferences" }));
|
||||
await screen.findByText("Preferences saved.");
|
||||
expect(screen.getByTestId("app-zone").textContent).toBe("America/New_York");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
* household's child profiles. Every write refetches the list.
|
||||
*
|
||||
* Preferences: the household settings (planning horizon, day rollover,
|
||||
* timezone, notifications). Save patches only the changed fields. A timezone
|
||||
* the admin never chose is set once, on load, to the browser's own zone;
|
||||
* saving the timezone (even clearing it) marks it chosen, so it sticks.
|
||||
* timezone, notifications). Save patches only the changed fields. The
|
||||
* timezone — the application's source of local time — is only ever set by
|
||||
* the admin picking or typing a zone here and saving it; every zone loaded
|
||||
* or saved here is published app-wide (time/AppTimezone.tsx).
|
||||
*
|
||||
* One write at a time across both sections.
|
||||
*/
|
||||
@@ -35,6 +36,7 @@ import {
|
||||
type SettingsChanges,
|
||||
} from "../api/settings";
|
||||
import { FOCUSABLE, inertOutside } from "../schedule/OverrideEditor";
|
||||
import { usePublishAppTimezone } from "../time/AppTimezone";
|
||||
import { ArrowDownGlyph, ArrowUpGlyph, ChevronLeftGlyph, PlusGlyph } from "./glyphs";
|
||||
import "./SettingsScreen.css";
|
||||
|
||||
@@ -257,7 +259,7 @@ const HH_MM = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||
/** "UTC" or an IANA-looking "Area/Location" (the API checks it resolves). */
|
||||
const IANA_ZONE = /^(UTC|[A-Za-z]+(\/[A-Za-z0-9_+-]+)+)$/;
|
||||
|
||||
/** The Timezone field's suggestions; any other IANA zone can be typed. */
|
||||
/** The Timezone picker's zones; any other IANA zone can be typed via "Other". */
|
||||
export const COMMON_TIMEZONES = [
|
||||
"UTC",
|
||||
"America/New_York",
|
||||
@@ -291,18 +293,8 @@ export const COMMON_TIMEZONES = [
|
||||
"Pacific/Auckland",
|
||||
];
|
||||
|
||||
/**
|
||||
* The browser's own IANA zone — the location-derived default for an unset
|
||||
* household timezone — or "" when it is missing or not a usable zone name.
|
||||
*/
|
||||
export function detectTimezone(): string {
|
||||
try {
|
||||
const zone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
return typeof zone === "string" && zone !== "Etc/Unknown" && IANA_ZONE.test(zone) ? zone : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
/** The picker's "Other IANA zone…" option; never sent to the API. */
|
||||
const OTHER_ZONE = "__other__";
|
||||
|
||||
/** The form's working copy: the number is text so it can be mid-edit. */
|
||||
type PreferencesDraft = Omit<HouseholdSettings, "horizon_days"> & { horizon_days: string };
|
||||
@@ -342,9 +334,6 @@ function buildChanges(draft: PreferencesDraft, loaded: HouseholdSettings): Setti
|
||||
for (const key of Object.keys(loaded) as (keyof HouseholdSettings)[]) {
|
||||
if (next[key] !== loaded[key]) (changes as Record<string, unknown>)[key] = next[key];
|
||||
}
|
||||
// An explicit choice — including "" (the API host's local time) — must
|
||||
// persist, or the next load would auto-set the browser's zone over it.
|
||||
if ("timezone" in changes) changes.timezone_configured = true;
|
||||
if (Object.keys(changes).length === 0) return "No changes to save.";
|
||||
return changes;
|
||||
}
|
||||
@@ -365,6 +354,10 @@ function PreferencesForm({
|
||||
}) {
|
||||
const [draft, setDraft] = useState<PreferencesDraft>(() => preferencesDraft(settings));
|
||||
const update = (patch: Partial<PreferencesDraft>) => setDraft((d) => ({ ...d, ...patch }));
|
||||
// A saved zone outside the curated list opens in the free-text entry.
|
||||
const [otherZone, setOtherZone] = useState(
|
||||
() => settings.timezone !== "" && !COMMON_TIMEZONES.includes(settings.timezone),
|
||||
);
|
||||
const disabled = saving || locked;
|
||||
|
||||
function submit(event: FormEvent) {
|
||||
@@ -417,25 +410,49 @@ function PreferencesForm({
|
||||
/>
|
||||
</label>
|
||||
{timeInput("day_rollover_time", "Day rollover")}
|
||||
<label className="settings-field">
|
||||
<span className="settings-label">Timezone</span>
|
||||
<input
|
||||
<div className="settings-field">
|
||||
<label className="settings-label" htmlFor="settings-timezone">
|
||||
Timezone
|
||||
</label>
|
||||
<select
|
||||
id="settings-timezone"
|
||||
className="settings-input"
|
||||
type="text"
|
||||
list="settings-timezones"
|
||||
placeholder="Host local time"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={draft.timezone}
|
||||
aria-describedby="settings-timezone-help"
|
||||
value={otherZone ? OTHER_ZONE : draft.timezone}
|
||||
disabled={disabled}
|
||||
onChange={(event) => update({ timezone: event.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<datalist id="settings-timezones">
|
||||
{COMMON_TIMEZONES.map((zone) => (
|
||||
<option key={zone} value={zone} />
|
||||
))}
|
||||
</datalist>
|
||||
onChange={(event) => {
|
||||
const choice = event.target.value;
|
||||
setOtherZone(choice === OTHER_ZONE);
|
||||
if (choice !== OTHER_ZONE) update({ timezone: choice });
|
||||
}}
|
||||
>
|
||||
<option value="">API host local time (no zone set)</option>
|
||||
{COMMON_TIMEZONES.map((zone) => (
|
||||
<option key={zone} value={zone}>
|
||||
{zone}
|
||||
</option>
|
||||
))}
|
||||
<option value={OTHER_ZONE}>Other IANA zone…</option>
|
||||
</select>
|
||||
{otherZone ? (
|
||||
<input
|
||||
className="settings-input"
|
||||
type="text"
|
||||
aria-label="Other timezone"
|
||||
placeholder="Area/Location, e.g. America/Boise"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={draft.timezone}
|
||||
disabled={disabled}
|
||||
onChange={(event) => update({ timezone: event.target.value })}
|
||||
/>
|
||||
) : null}
|
||||
<p className="settings-hint" id="settings-timezone-help">
|
||||
The application timezone: the source of local time for the panel, the scheduler and this
|
||||
admin app. It changes only when you save it here; with no zone set, the API host's
|
||||
local time is used.
|
||||
</p>
|
||||
</div>
|
||||
<label className="settings-field">
|
||||
<span className="settings-label">Notify target</span>
|
||||
<input
|
||||
@@ -574,12 +591,11 @@ export default function SettingsScreen({ onClose }: { onClose: () => void }) {
|
||||
} | null>(null);
|
||||
// A ref, not state: two taps in one frame must not both see `busy === null`.
|
||||
const inFlight = useRef(false);
|
||||
// The never-chosen-timezone auto-set fires at most once per screen.
|
||||
const timezoneAutoSet = useRef(false);
|
||||
const screenRef = useRef<HTMLDivElement>(null);
|
||||
const titleRef = useRef<HTMLHeadingElement>(null);
|
||||
const addRef = useRef<HTMLButtonElement>(null);
|
||||
const formWasOpen = useRef(false);
|
||||
const publishTimezone = usePublishAppTimezone();
|
||||
|
||||
// Modal focus: move in on open, keep the background out of reach, and hand
|
||||
// focus back to the opener (the Settings button) on close.
|
||||
@@ -620,29 +636,10 @@ export default function SettingsScreen({ onClose }: { onClose: () => void }) {
|
||||
let cancelled = false;
|
||||
setPreferences({ kind: "loading" });
|
||||
fetchSettings()
|
||||
.then(async (settings) => {
|
||||
.then((settings) => {
|
||||
if (cancelled) return;
|
||||
// Never chosen: adopt the browser's zone before the form shows, so
|
||||
// nothing the admin enters can be overwritten by it. An empty zone the
|
||||
// admin chose (timezone_configured) is left alone.
|
||||
const unchosen = settings.timezone === "" && !settings.timezone_configured;
|
||||
const detected = unchosen ? detectTimezone() : "";
|
||||
if (!detected || timezoneAutoSet.current) {
|
||||
setPreferences({ kind: "ready", settings });
|
||||
return;
|
||||
}
|
||||
timezoneAutoSet.current = true;
|
||||
try {
|
||||
const saved = await updateSettings({ timezone: detected, timezone_configured: true });
|
||||
if (!cancelled) setPreferences({ kind: "ready", settings: saved });
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
setPreferences({ kind: "ready", settings });
|
||||
setPreferencesMessage({
|
||||
kind: "error",
|
||||
text: errorMessage(error, `The timezone could not be set to ${detected}`),
|
||||
});
|
||||
}
|
||||
setPreferences({ kind: "ready", settings });
|
||||
publishTimezone(settings.effective_timezone);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (cancelled) return;
|
||||
@@ -736,6 +733,7 @@ export default function SettingsScreen({ onClose }: { onClose: () => void }) {
|
||||
try {
|
||||
const settings = await updateSettings(changes);
|
||||
setPreferences({ kind: "ready", settings });
|
||||
publishTimezone(settings.effective_timezone);
|
||||
setPreferencesMessage({ kind: "info", text: "Preferences saved." });
|
||||
} catch (error) {
|
||||
setPreferencesMessage({
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* The effective application timezone, loaded once per signed-in shell: the
|
||||
* API's `effective_timezone` — the stored zone, or the API host's own zone
|
||||
* when none is stored — so every screen's local time matches the API's.
|
||||
*
|
||||
* <AppTimezoneProvider> fetches the household settings and renders its
|
||||
* screens only once the zone is known (a failed load says so and offers a
|
||||
* retry), so no screen ever computes local time in a zone it will not keep.
|
||||
* The Settings screen publishes every zone it loads or saves, so a change
|
||||
* applies app-wide at once.
|
||||
*
|
||||
* Without a provider (a screen rendered on its own, e.g. in a test)
|
||||
* useAppTimezone() is "" — the documented unset value, i.e. browser-local.
|
||||
*/
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
|
||||
import { ApiForbiddenError } from "../api/client";
|
||||
import { fetchSettings } from "../api/settings";
|
||||
|
||||
interface ZoneHub {
|
||||
timeZone: string;
|
||||
publish(timeZone: string): void;
|
||||
}
|
||||
|
||||
const AppTimezoneContext = createContext<ZoneHub | null>(null);
|
||||
|
||||
type LoadState =
|
||||
| { kind: "loading" }
|
||||
| { kind: "error"; message: string }
|
||||
| { kind: "ready"; timeZone: string };
|
||||
|
||||
export function AppTimezoneProvider({ children }: { children: ReactNode }) {
|
||||
const [state, setState] = useState<LoadState>({ kind: "loading" });
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setState({ kind: "loading" });
|
||||
fetchSettings()
|
||||
.then((settings) => {
|
||||
if (!cancelled) setState({ kind: "ready", timeZone: settings.effective_timezone });
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (cancelled) return;
|
||||
setState({
|
||||
kind: "error",
|
||||
message:
|
||||
error instanceof ApiForbiddenError
|
||||
? error.message
|
||||
: "The application timezone could not be loaded. Check your connection and try again.",
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [attempt]);
|
||||
|
||||
if (state.kind === "loading") {
|
||||
return (
|
||||
<div className="admin-loading" role="status" data-testid="timezone-loading">
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (state.kind === "error") {
|
||||
return (
|
||||
<div className="admin-error" role="alert" data-testid="timezone-error">
|
||||
<p>{state.message}</p>
|
||||
<button type="button" onClick={() => setAttempt((n) => n + 1)}>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const hub: ZoneHub = {
|
||||
timeZone: state.timeZone,
|
||||
publish: (timeZone) =>
|
||||
setState((prev) =>
|
||||
prev.kind === "ready" && prev.timeZone === timeZone ? prev : { kind: "ready", timeZone },
|
||||
),
|
||||
};
|
||||
return <AppTimezoneContext.Provider value={hub}>{children}</AppTimezoneContext.Provider>;
|
||||
}
|
||||
|
||||
/** The effective application timezone: an IANA name, or "" (browser-local). */
|
||||
export function useAppTimezone(): string {
|
||||
return useContext(AppTimezoneContext)?.timeZone ?? "";
|
||||
}
|
||||
|
||||
/** Publish an effective zone just loaded or saved; a no-op without a provider. */
|
||||
export function usePublishAppTimezone(): (timeZone: string) => void {
|
||||
const hub = useContext(AppTimezoneContext);
|
||||
return (timeZone) => hub?.publish(timeZone);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { isoDateIn, todayIn, wallClockInstant, zoneOption } from "./zone";
|
||||
import { formatClockTime } from "../today/format";
|
||||
|
||||
// 13:00 UTC on Sep 23 is already 01:00 on Sep 24 in Auckland (NZST, UTC+12).
|
||||
const NOW = new Date("2026-09-23T13:00:00Z");
|
||||
|
||||
describe("application timezone helpers (process in UTC)", () => {
|
||||
beforeAll(() => {
|
||||
vi.stubEnv("TZ", "UTC");
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("a stored zone decides the calendar date; empty means the browser's zone", () => {
|
||||
expect(todayIn("Pacific/Auckland", NOW)).toBe("2026-09-24");
|
||||
expect(isoDateIn(NOW, "Pacific/Auckland")).toBe("2026-09-24");
|
||||
expect(todayIn("", NOW)).toBe("2026-09-23");
|
||||
});
|
||||
|
||||
it("wall-clock time converts in the stored zone, across its DST change", () => {
|
||||
// NZST (UTC+12) before 27 Sep 2026, NZDT (UTC+13) after.
|
||||
expect(wallClockInstant("2026-09-24", 0, 10, "Pacific/Auckland")).toBe(
|
||||
Date.parse("2026-09-23T12:10:00Z"),
|
||||
);
|
||||
expect(wallClockInstant("2026-09-28", 7, 30, "Pacific/Auckland")).toBe(
|
||||
Date.parse("2026-09-27T18:30:00Z"),
|
||||
);
|
||||
expect(wallClockInstant("2026-09-24", 0, 10, "")).toBe(Date.parse("2026-09-24T00:10:00Z"));
|
||||
});
|
||||
|
||||
it("the completion clock reads in the stored zone", () => {
|
||||
expect(formatClockTime("2026-09-23T11:04:00Z", "Pacific/Auckland")).toBe("11:04 PM");
|
||||
expect(formatClockTime("2026-09-23T11:04:00Z", "")).toBe("11:04 AM");
|
||||
});
|
||||
|
||||
it("an invalid stored zone fails fast", () => {
|
||||
expect(zoneOption("")).toBeUndefined();
|
||||
expect(() => todayIn("Not/AZone", NOW)).toThrow(RangeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("empty zone keeps browser-local behavior", () => {
|
||||
beforeAll(() => {
|
||||
vi.stubEnv("TZ", "America/Los_Angeles");
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("matches the browser's own calendar and wall clock", () => {
|
||||
// 13:00 UTC is 06:00 PDT on Sep 23.
|
||||
expect(todayIn("", NOW)).toBe("2026-09-23");
|
||||
expect(wallClockInstant("2026-09-23", 7, 30, "")).toBe(new Date(2026, 8, 23, 7, 30).getTime());
|
||||
expect(formatClockTime("2026-09-23T14:04:00Z", "")).toBe("7:04 AM");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Local time in the application timezone.
|
||||
*
|
||||
* The admin-saved zone (settings `timezone`, an IANA name) is the single
|
||||
* source of local-time truth for the whole application; the API publishes it
|
||||
* as `effective_timezone`, naming its host's own zone when none is stored.
|
||||
* "" means the API published no zone, and only then does the browser's own
|
||||
* zone apply. Every helper takes
|
||||
* that stored value as-is; an invalid zone name throws (Intl's RangeError).
|
||||
*/
|
||||
|
||||
/** The `timeZone` option for Intl: the zone, or undefined (browser) for "". */
|
||||
export function zoneOption(timeZone: string): string | undefined {
|
||||
return timeZone === "" ? undefined : timeZone;
|
||||
}
|
||||
|
||||
interface WallClock {
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
hour: number;
|
||||
minute: number;
|
||||
second: number;
|
||||
}
|
||||
|
||||
function wallClock(instant: number, timeZone: string): WallClock {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: zoneOption(timeZone),
|
||||
hourCycle: "h23",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
}).formatToParts(instant);
|
||||
const part = (type: Intl.DateTimeFormatPartTypes) =>
|
||||
Number(parts.find((p) => p.type === type)?.value);
|
||||
return {
|
||||
year: part("year"),
|
||||
month: part("month"),
|
||||
day: part("day"),
|
||||
hour: part("hour"),
|
||||
minute: part("minute"),
|
||||
second: part("second"),
|
||||
};
|
||||
}
|
||||
|
||||
/** The "YYYY-MM-DD" calendar date of `instant` in the zone. */
|
||||
export function isoDateIn(instant: Date, timeZone: string): string {
|
||||
const { year, month, day } = wallClock(instant.getTime(), timeZone);
|
||||
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** Today's calendar date in the zone. */
|
||||
export function todayIn(timeZone: string, now: Date = new Date()): string {
|
||||
return isoDateIn(now, timeZone);
|
||||
}
|
||||
|
||||
/** The zone's UTC offset in ms at `instant` (wall clock minus UTC). */
|
||||
function offsetAt(instant: number, timeZone: string): number {
|
||||
const c = wallClock(instant, timeZone);
|
||||
const wholeSeconds = Math.floor(instant / 1000) * 1000;
|
||||
return Date.UTC(c.year, c.month - 1, c.day, c.hour, c.minute, c.second) - wholeSeconds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Epoch ms at which the zone's wall clock reads `hour:minute` on the
|
||||
* "YYYY-MM-DD" date. Two passes settle the offset across a DST change.
|
||||
*/
|
||||
export function wallClockInstant(
|
||||
iso: string,
|
||||
hour: number,
|
||||
minute: number,
|
||||
timeZone: string,
|
||||
): number {
|
||||
const [year, month, day] = iso.split("-").map(Number);
|
||||
const asUtc = Date.UTC(year, month - 1, day, hour, minute);
|
||||
const guess = asUtc - offsetAt(asUtc, timeZone);
|
||||
return asUtc - offsetAt(guess, timeZone);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type { AdminSnapshot, SnapshotInstance } from "../api/snapshot";
|
||||
import { TransitionsProvider } from "../api/useTransitions";
|
||||
import { frame, testStream, type TestStream } from "../api/testStream";
|
||||
import { storeTokens } from "../auth/oidc";
|
||||
import { AppTimezoneProvider } from "../time/AppTimezone";
|
||||
|
||||
function instance(overrides: Partial<SnapshotInstance>): SnapshotInstance {
|
||||
return {
|
||||
@@ -406,3 +407,62 @@ describe("TodayTab Undo and live updates", () => {
|
||||
await waitFor(() => expect(streams[0].cancelled()).toBe(true));
|
||||
});
|
||||
});
|
||||
|
||||
describe("TodayTab in the stored application timezone", () => {
|
||||
function renderWithZone(timezone: string, effective_timezone = timezone) {
|
||||
fetchMock.mockImplementation((url: string) =>
|
||||
Promise.resolve(
|
||||
String(url).endsWith("/api/v1/admin/settings")
|
||||
? jsonResponse({ timezone, effective_timezone })
|
||||
: jsonResponse(SNAPSHOT),
|
||||
),
|
||||
);
|
||||
render(
|
||||
<AppTimezoneProvider>
|
||||
<TodayTab />
|
||||
</AppTimezoneProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
// The browser runs in UTC; the household lives in Auckland (UTC+12 on Sep 23).
|
||||
vi.stubEnv("TZ", "UTC");
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("shows the completion clock in the stored zone, not the browser's", async () => {
|
||||
renderWithZone("Pacific/Auckland");
|
||||
const completed = await screen.findByTestId("attention-12");
|
||||
// 11:04 UTC is 11:04 PM in Auckland.
|
||||
expect(within(completed).getByText("Completed 11:04 PM")).toBeTruthy();
|
||||
// The header's day is the API's household-local today_iso, as-is.
|
||||
expect(screen.getByText("Wednesday, September 23 · cycle day 5 of 14")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("an empty stored zone shows the clock in the API host's published zone", async () => {
|
||||
renderWithZone("", "Pacific/Auckland");
|
||||
const completed = await screen.findByTestId("attention-12");
|
||||
expect(within(completed).getByText("Completed 11:04 PM")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("only an API that publishes no zone leaves the browser's local clock", async () => {
|
||||
renderWithZone("", "");
|
||||
const completed = await screen.findByTestId("attention-12");
|
||||
expect(within(completed).getByText("Completed 11:04 AM")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import { ApiRequestError } from "../api/definitions";
|
||||
import { uncompleteInstance } from "../api/instances";
|
||||
import { useTransitions } from "../api/useTransitions";
|
||||
import SettingsScreen from "../settings/SettingsScreen";
|
||||
import { useAppTimezone } from "../time/AppTimezone";
|
||||
import {
|
||||
formatClockTime,
|
||||
formatDueTime,
|
||||
@@ -165,6 +166,7 @@ function AttentionRow({ item, undo }: { item: AttentionItem; undo: UndoControl }
|
||||
const { instance, childName } = item;
|
||||
const overdue = isOverdueOpen(instance);
|
||||
const undoing = undo.pendingId === instance.id;
|
||||
const timeZone = useAppTimezone();
|
||||
// TODO(e57facc2): wire "Mark done". The admin plane has no complete route
|
||||
// yet, so that action is inert here.
|
||||
return (
|
||||
@@ -187,7 +189,7 @@ function AttentionRow({ item, undo }: { item: AttentionItem; undo: UndoControl }
|
||||
) : (
|
||||
// The snapshot carries no completion actor, so "from panel" is not printed.
|
||||
<div className="today-event-meta">
|
||||
Completed {formatClockTime(instance.completed_at as string)}
|
||||
Completed {formatClockTime(instance.completed_at as string, timeZone)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
* Calendar dates ("YYYY-MM-DD") are parsed by their components and formatted
|
||||
* in UTC, so the printed day never shifts with the viewer's offset — unlike
|
||||
* `new Date("YYYY-MM-DD")`, which is UTC midnight and reads as the previous
|
||||
* day in negative-offset zones.
|
||||
* day in negative-offset zones. Instants are shown in the application
|
||||
* timezone (time/zone.ts).
|
||||
*/
|
||||
import { zoneOption } from "../time/zone";
|
||||
|
||||
const LOCALE = "en-US";
|
||||
|
||||
@@ -44,7 +46,11 @@ export function formatDueTime(hhmm: string): string {
|
||||
return format(date, { hour: "numeric", minute: "2-digit", timeZone: "UTC" });
|
||||
}
|
||||
|
||||
/** A UTC ISO timestamp shown as the viewer's local time, "7:04 AM". */
|
||||
export function formatClockTime(isoTimestamp: string): string {
|
||||
return format(new Date(isoTimestamp), { hour: "numeric", minute: "2-digit" });
|
||||
/** A UTC ISO timestamp shown as wall-clock time in `timeZone` ("" = browser), "7:04 AM". */
|
||||
export function formatClockTime(isoTimestamp: string, timeZone: string): string {
|
||||
return format(new Date(isoTimestamp), {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
timeZone: zoneOption(timeZone),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
+37
-18
@@ -165,13 +165,15 @@ integration's Home-Assistant config-entry options remain a temporary,
|
||||
SEPARATE source until Feature 18 wires the client to the API — the two
|
||||
are deliberately not unified here:
|
||||
|
||||
- ``GET /settings`` returns the current effective settings: all twelve
|
||||
- ``GET /settings`` returns the current effective settings: all eleven
|
||||
documented fields (the rolling horizon, the day rollover time, the
|
||||
notify target, the three notification times, the four enable
|
||||
toggles, the ``timezone`` — the IANA household zone the
|
||||
household-local dates derive from, empty meaning host local — and
|
||||
``timezone_configured``, whether the admin explicitly chose that
|
||||
zone) — the defaults on a fresh database.
|
||||
toggles, and the ``timezone`` — the IANA household zone the
|
||||
household-local dates derive from, empty meaning host local; only an
|
||||
admin's ``PATCH`` ever sets it) — the defaults on a fresh database —
|
||||
plus the read-only ``effective_timezone`` (:mod:`api.host_zone`): the
|
||||
zone the API actually reads its clock in, which the admin app formats
|
||||
local time in.
|
||||
- ``PATCH /settings`` updates ONLY the supplied fields: the body is a
|
||||
JSON object of settings field names to new values, passed STRAIGHT
|
||||
to :func:`nestquest_core.settings_store.update_settings` — the
|
||||
@@ -350,6 +352,7 @@ from pydantic import (
|
||||
model_validator,
|
||||
)
|
||||
|
||||
from api import host_zone
|
||||
from api import transitions as api_transitions
|
||||
from api.auth import require_admin
|
||||
from api.database import DatabaseState
|
||||
@@ -906,7 +909,7 @@ class AdminSettingsResponse(BaseModel):
|
||||
"""The effective settings as the admin plane serializes them.
|
||||
|
||||
Mirrors the core :class:`~nestquest_core.settings.NestQuestSettings`
|
||||
— all TWELVE documented fields, resolved (every absent stored field
|
||||
— all ELEVEN documented fields, resolved (every absent stored field
|
||||
already fell back to its default in the core). The same shape both
|
||||
the GET and the PATCH route answer with.
|
||||
"""
|
||||
@@ -921,12 +924,14 @@ class AdminSettingsResponse(BaseModel):
|
||||
afternoon_reminder_enabled: bool
|
||||
end_of_day_report_enabled: bool
|
||||
celebration_enabled: bool
|
||||
#: The household IANA time zone the API reads its clock in; ``""``
|
||||
#: means the API host's local time.
|
||||
#: The household IANA time zone the admin stored; ``""`` means the
|
||||
#: API host's local time.
|
||||
timezone: str
|
||||
#: Whether the admin explicitly chose ``timezone`` (even ``""``);
|
||||
#: ``False`` lets the admin UI auto-set the browser's zone once.
|
||||
timezone_configured: bool
|
||||
#: 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):
|
||||
@@ -2183,8 +2188,9 @@ def _settings_response(
|
||||
"""Serialize one core NestQuestSettings into the documented payload.
|
||||
|
||||
Explicit field-by-field (not ``dataclasses.asdict``) so the payload
|
||||
stays pinned to the twelve documented fields even if the core
|
||||
dataclass later grows another one.
|
||||
stays pinned to the eleven documented fields (plus the read-only
|
||||
``effective_timezone``) even if the core dataclass later grows
|
||||
another one.
|
||||
"""
|
||||
return AdminSettingsResponse(
|
||||
horizon_days=settings.horizon_days,
|
||||
@@ -2198,7 +2204,7 @@ def _settings_response(
|
||||
end_of_day_report_enabled=settings.end_of_day_report_enabled,
|
||||
celebration_enabled=settings.celebration_enabled,
|
||||
timezone=settings.timezone,
|
||||
timezone_configured=settings.timezone_configured,
|
||||
effective_timezone=host_zone.effective_timezone(settings.timezone),
|
||||
)
|
||||
|
||||
|
||||
@@ -2296,7 +2302,7 @@ async def admin_history_csv(
|
||||
response_model=AdminSettingsResponse,
|
||||
)
|
||||
async def admin_get_settings(request: Request) -> AdminSettingsResponse:
|
||||
"""Return the current effective settings (all twelve documented fields).
|
||||
"""Return the current effective settings (all eleven documented fields).
|
||||
|
||||
Requires a valid admin JWT (the router's shared
|
||||
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
||||
@@ -2335,12 +2341,25 @@ async def admin_update_settings(
|
||||
every value has validated — so a rejected update never changes the
|
||||
stored settings. Every core ``ValueError`` is 422
|
||||
(:func:`_raise_settings_error`); anything else re-raises.
|
||||
|
||||
The write holds the missed-sweep scheduler's ``settings_lock`` and
|
||||
then tells it (:meth:`~api.scheduler.MissedSweepScheduler.settings_changed`),
|
||||
so a saved ``timezone`` or ``day_rollover_time`` re-plans a sleeping
|
||||
scheduler (an app without one — no lifespan — just writes).
|
||||
"""
|
||||
state: DatabaseState = request.app.state.db
|
||||
scheduler = getattr(request.app.state, "sweep_scheduler", None)
|
||||
try:
|
||||
settings = await core_settings_store.update_settings(
|
||||
state.database, changes
|
||||
)
|
||||
if scheduler is None:
|
||||
settings = await core_settings_store.update_settings(
|
||||
state.database, changes
|
||||
)
|
||||
else:
|
||||
async with scheduler.settings_lock:
|
||||
settings = await core_settings_store.update_settings(
|
||||
state.database, changes
|
||||
)
|
||||
scheduler.settings_changed(settings)
|
||||
except ValueError as error:
|
||||
_raise_settings_error(error)
|
||||
return _settings_response(settings)
|
||||
|
||||
+23
-48
@@ -9,9 +9,8 @@ this router inherits the check).
|
||||
The snapshot route is a thin adapter over the bundled core: it performs
|
||||
NO business logic. It reads the live database off ``app.state.db``,
|
||||
resolves ONE timezone-aware ``now`` (the host clock re-expressed in the
|
||||
household ``timezone`` — the API host may run UTC while the household
|
||||
does not; until the admin chooses a zone it is the Home Assistant zone
|
||||
the integration reports in :data:`PANEL_TIMEZONE_HEADER`), calls the pure
|
||||
household ``timezone`` the admin stored — the API host may run UTC
|
||||
while the household does not), calls the pure
|
||||
:func:`nestquest_core.snapshot.build_snapshot`, shapes each child's
|
||||
instances through :func:`nestquest_core.snapshot.instance_payload` with
|
||||
``include_missed=False`` (D-009: missed instances are OMITTED from the
|
||||
@@ -22,6 +21,7 @@ panel payload), and returns the documented JSON shape:
|
||||
{
|
||||
"today_iso": "2026-09-22",
|
||||
"cycle_day": 2,
|
||||
"timezone": "Pacific/Auckland",
|
||||
"children": [
|
||||
{
|
||||
"child_id": 1,
|
||||
@@ -46,7 +46,11 @@ panel payload), and returns the documented JSON shape:
|
||||
active children ``ORDER BY sort_order, id``); ``state`` is the
|
||||
documented panel spelling ``open`` | ``completed`` (§1 of
|
||||
design/ENTITIES-AND-SERVICES.md); ``cycle_day`` is today's 1-based day
|
||||
in the first scheduled child's custody cycle (0 when no child has one).
|
||||
in the first scheduled child's custody cycle (0 when no child has one);
|
||||
``timezone`` is the effective application timezone — the zone the admin
|
||||
stored, or the API host's own zone when none is (``""`` only when the
|
||||
host's zone cannot be named; :mod:`api.host_zone`) — which the panel
|
||||
cards format their visible date and clock in.
|
||||
The same :class:`PanelSnapshotResponse` Pydantic model is the route's
|
||||
``response_model``, so the OpenAPI document carries the shape too.
|
||||
"""
|
||||
@@ -54,7 +58,6 @@ from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import importlib
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
@@ -64,20 +67,14 @@ from api.database import DatabaseState
|
||||
from api.dependencies import require_panel_token
|
||||
from api.nestquest_core import (
|
||||
core_completion,
|
||||
core_const,
|
||||
core_events,
|
||||
core_settings_store,
|
||||
core_snapshot,
|
||||
)
|
||||
from api import host_zone
|
||||
from api import transitions as api_transitions
|
||||
from api.sse import transition_event_stream
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
#: The request header the integration reports Home Assistant's
|
||||
#: configured IANA ``time_zone`` in.
|
||||
PANEL_TIMEZONE_HEADER = core_const.PANEL_TIMEZONE_HEADER
|
||||
|
||||
#: The instance DAO, imported on the app's core copy for the
|
||||
#: child-mismatch guard on the complete route.
|
||||
_dao_instances = importlib.import_module("nestquest_core.dao_instances")
|
||||
@@ -135,12 +132,14 @@ class PanelSnapshotResponse(BaseModel):
|
||||
|
||||
``today_iso`` anchors the payload's day; ``cycle_day`` is today's
|
||||
1-based day in the first scheduled child's custody cycle (0 when no
|
||||
child has a schedule); ``children`` is in the household's sort
|
||||
order.
|
||||
child has a schedule); ``timezone`` is the effective application
|
||||
timezone (:func:`api.host_zone.effective_timezone`); ``children`` is
|
||||
in the household's sort order.
|
||||
"""
|
||||
|
||||
today_iso: str
|
||||
cycle_day: int
|
||||
timezone: str
|
||||
children: list[PanelChildPayload]
|
||||
|
||||
|
||||
@@ -188,42 +187,17 @@ def _local_now() -> datetime.datetime:
|
||||
|
||||
|
||||
async def _household_clock(
|
||||
database: object, request: Request
|
||||
database: object,
|
||||
) -> tuple[object, datetime.datetime]:
|
||||
"""Return the settings and ONE household-local clock read.
|
||||
"""Return the stored settings and ONE household-local clock read.
|
||||
|
||||
The API host may run UTC while the household does not, so the one
|
||||
:func:`_local_now` read is re-expressed in the settings' ``timezone``
|
||||
(:meth:`NestQuestSettings.household_now`). The integration reports
|
||||
Home Assistant's ``time_zone`` in :data:`PANEL_TIMEZONE_HEADER`;
|
||||
until the admin chooses a zone it is adopted and persisted
|
||||
(:func:`nestquest_core.settings_store.adopt_reported_timezone`), so
|
||||
the admin plane follows it too, and the already-sleeping missed-sweep
|
||||
scheduler is told (:meth:`MissedSweepScheduler.settings_changed`) so
|
||||
it re-plans for the household rollover — under the scheduler's
|
||||
``settings_lock``, so the adoption never lands between its settings
|
||||
check and its sweep. An invalid
|
||||
reported zone is logged and ignored — the panel keeps serving on
|
||||
the stored settings.
|
||||
:func:`_local_now` read is re-expressed in the stored settings'
|
||||
``timezone`` (:meth:`NestQuestSettings.household_now`). The zone
|
||||
comes from the stored settings ONLY — nothing the request carries
|
||||
can change it.
|
||||
"""
|
||||
reported = request.headers.get(PANEL_TIMEZONE_HEADER, "").strip()
|
||||
if reported:
|
||||
scheduler = request.app.state.sweep_scheduler
|
||||
try:
|
||||
async with scheduler.settings_lock:
|
||||
settings = await core_settings_store.adopt_reported_timezone(
|
||||
database, reported
|
||||
)
|
||||
scheduler.settings_changed(settings)
|
||||
except ValueError:
|
||||
LOGGER.warning(
|
||||
"Ignoring invalid %s header value %r",
|
||||
PANEL_TIMEZONE_HEADER,
|
||||
reported,
|
||||
)
|
||||
settings = await core_settings_store.load_settings(database)
|
||||
else:
|
||||
settings = await core_settings_store.load_settings(database)
|
||||
settings = await core_settings_store.load_settings(database)
|
||||
return settings, settings.household_now(_local_now())
|
||||
|
||||
|
||||
@@ -245,13 +219,14 @@ async def panel_snapshot(request: Request) -> PanelSnapshotResponse:
|
||||
:class:`PanelSnapshotResponse`.
|
||||
"""
|
||||
state: DatabaseState = request.app.state.db
|
||||
settings, now = await _household_clock(state.database, request)
|
||||
settings, now = await _household_clock(state.database)
|
||||
snapshot = await core_snapshot.build_snapshot(
|
||||
state.database, settings, now
|
||||
)
|
||||
return PanelSnapshotResponse(
|
||||
today_iso=snapshot.today_iso,
|
||||
cycle_day=snapshot.cycle_day,
|
||||
timezone=host_zone.effective_timezone(snapshot.timezone),
|
||||
children=[
|
||||
PanelChildPayload(
|
||||
child_id=child.child_id,
|
||||
@@ -332,7 +307,7 @@ async def panel_complete_instance(
|
||||
detail="Quest instance not found for this child",
|
||||
)
|
||||
|
||||
_settings, now = await _household_clock(database, request)
|
||||
_settings, now = await _household_clock(database)
|
||||
try:
|
||||
result = await core_completion.complete_instance(
|
||||
database,
|
||||
|
||||
+11
-10
@@ -13,7 +13,7 @@ local date when unset) and publishes each returned
|
||||
``nestquest_quest_missed`` event on the app's ONE transition publisher
|
||||
(the same SSE stream every route publishes through).
|
||||
|
||||
A household zone adopted from Home Assistant while the loop is asleep
|
||||
A household zone changed while the loop is asleep
|
||||
must not leave it waiting for the OLD zone's rollover (a UTC host would
|
||||
otherwise sweep at UTC midnight — 20:00 in New York — marking quests
|
||||
missed hours early). :meth:`MissedSweepScheduler.settings_changed`
|
||||
@@ -21,17 +21,18 @@ wakes the sleeping loop to re-plan whenever the zone or rollover time
|
||||
it planned with has moved, and the loop re-reads the settings before
|
||||
sweeping and re-plans instead if they no longer match its plan.
|
||||
|
||||
Guarantee: no sweep runs for a plan a committed adoption has replaced.
|
||||
An adopting writer holds :attr:`MissedSweepScheduler.settings_lock`
|
||||
Guarantee: no sweep runs for a plan a committed settings write has
|
||||
replaced. The settings writer — the admin ``PATCH /api/v1/admin/settings``
|
||||
route, the only one — holds :attr:`MissedSweepScheduler.settings_lock`
|
||||
across its write AND its :meth:`~MissedSweepScheduler.settings_changed`
|
||||
call; the loop holds the same lock across its plan read (read, record
|
||||
the plan, clear the wake flag) and across the pre-sweep re-read plus
|
||||
the sweep itself. So an adoption commits either before a plan read
|
||||
the sweep itself. So a write commits either before a plan read
|
||||
(which then sees it), after one (``settings_changed`` compares against
|
||||
that exact plan and wakes the wait), or after the sweep — never between
|
||||
a read and what the loop does with it. The lock is never held across
|
||||
the wait, and the loop takes nothing else before it, so a waiting
|
||||
panel request cannot deadlock: it is delayed at most by one settings
|
||||
writer cannot deadlock: it is delayed at most by one settings
|
||||
read or one sweep.
|
||||
|
||||
All sweep policy lives in the core (the watermark, the
|
||||
@@ -150,7 +151,7 @@ class MissedSweepScheduler:
|
||||
self._planned: tuple[str, str] | None = None
|
||||
#: Set by :meth:`settings_changed` to cut the current wait short.
|
||||
self._wake = asyncio.Event()
|
||||
#: Serialises settings adoption with planning and sweeping (see
|
||||
#: Serialises settings writes with planning and sweeping (see
|
||||
#: the module docstring's guarantee).
|
||||
self.settings_lock = asyncio.Lock()
|
||||
|
||||
@@ -174,8 +175,8 @@ class MissedSweepScheduler:
|
||||
def settings_changed(self, settings: object) -> None:
|
||||
"""Re-plan the pending sweep if ``settings`` moved its rollover.
|
||||
|
||||
Called with freshly stored settings (e.g. a zone adopted from
|
||||
Home Assistant), with :attr:`settings_lock` held across the write
|
||||
Called with freshly stored settings (e.g. an admin's zone
|
||||
change), with :attr:`settings_lock` held across the write
|
||||
and this call so the comparison is against the loop's current
|
||||
plan, never a read still in flight. A differing ``timezone`` or
|
||||
``day_rollover_time`` wakes the sleeping loop, which reloads the
|
||||
@@ -194,7 +195,7 @@ class MissedSweepScheduler:
|
||||
"""Schedule → sweep → repeat, forever (until cancelled)."""
|
||||
while True:
|
||||
try:
|
||||
# Under the lock no adoption can commit between this read
|
||||
# Under the lock no settings write can commit between this read
|
||||
# and the recorded plan, so any later one wakes the wait.
|
||||
async with self.settings_lock:
|
||||
self._wake.clear()
|
||||
@@ -233,7 +234,7 @@ class MissedSweepScheduler:
|
||||
# Re-read before sweeping: a zone or rollover change the
|
||||
# wake-up missed means this is not the household's
|
||||
# rollover any more, so re-plan instead of sweeping. The
|
||||
# lock keeps an adoption from committing between this
|
||||
# lock keeps a settings write from committing between this
|
||||
# check and the sweep.
|
||||
async with self.settings_lock:
|
||||
settings = await core_settings_store.load_settings(
|
||||
|
||||
@@ -6,9 +6,6 @@ speaks the API's panel plane — ``GET /api/v1/panel/snapshot`` (today's
|
||||
household snapshot) and ``POST /api/v1/panel/instances/{id}/complete``
|
||||
(completion as the tapped child profile) — authenticated with the
|
||||
static panel service token as a Bearer credential on every request.
|
||||
Every request also reports Home Assistant's configured ``time_zone`` in
|
||||
the ``X-NestQuest-Timezone`` header, so the API (which may run UTC)
|
||||
reads the household's clock until the admin chooses a zone there.
|
||||
|
||||
Transport-injected by construction: the constructor takes the base URL,
|
||||
the panel token, and the HTTP session to use. The session speaks the
|
||||
@@ -57,7 +54,6 @@ from .const import (
|
||||
CONF_PANEL_TOKEN,
|
||||
DEFAULT_API_BASE_URL,
|
||||
DEFAULT_PANEL_TOKEN,
|
||||
PANEL_TIMEZONE_HEADER,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -160,8 +156,7 @@ def client_from_entry(hass: Any, entry: Any) -> NestQuestApiClient:
|
||||
"""Build the production client for a config entry.
|
||||
|
||||
Reads the configured base URL and panel token off the entry
|
||||
(:func:`resolve_api_config`), reports ``hass.config.time_zone`` as
|
||||
the household zone, and hands the client Home Assistant's
|
||||
(:func:`resolve_api_config`) and hands the client Home Assistant's
|
||||
SHARED aiohttp session (``homeassistant.helpers.aiohttp_client
|
||||
.async_get_clientsession``) so panel calls ride HA's one connection
|
||||
pool. The helper import is deferred into this function on purpose:
|
||||
@@ -173,10 +168,7 @@ def client_from_entry(hass: Any, entry: Any) -> NestQuestApiClient:
|
||||
|
||||
base_url, panel_token = resolve_api_config(entry)
|
||||
return NestQuestApiClient(
|
||||
base_url,
|
||||
panel_token,
|
||||
async_get_clientsession(hass),
|
||||
household_timezone=hass.config.time_zone,
|
||||
base_url, panel_token, async_get_clientsession(hass)
|
||||
)
|
||||
|
||||
|
||||
@@ -197,14 +189,9 @@ class NestQuestApiClient:
|
||||
session: aiohttp.ClientSession,
|
||||
*,
|
||||
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
|
||||
household_timezone: str | None = None,
|
||||
) -> None:
|
||||
"""Store the connection settings; the session is injected.
|
||||
|
||||
``household_timezone`` is Home Assistant's configured IANA
|
||||
``time_zone``; when given it rides every request in the
|
||||
``X-NestQuest-Timezone`` header.
|
||||
|
||||
``session`` is an aiohttp ``ClientSession`` in production and a
|
||||
stub transport in tests. ``base_url`` must be a full http(s)
|
||||
URL and ``panel_token`` must be non-empty — a client built
|
||||
@@ -229,14 +216,10 @@ class NestQuestApiClient:
|
||||
self._panel_token = panel_token
|
||||
self._session = session
|
||||
self._timeout_seconds = float(timeout_seconds)
|
||||
self._household_timezone = household_timezone
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
"""The Bearer credential plus the reported household zone."""
|
||||
headers = {"Authorization": f"Bearer {self._panel_token}"}
|
||||
if self._household_timezone:
|
||||
headers[PANEL_TIMEZONE_HEADER] = self._household_timezone
|
||||
return headers
|
||||
"""The Bearer credential every panel-plane request carries."""
|
||||
return {"Authorization": f"Bearer {self._panel_token}"}
|
||||
|
||||
async def get_snapshot(self) -> dict[str, Any]:
|
||||
"""Fetch and parse today's household snapshot.
|
||||
|
||||
@@ -9,7 +9,7 @@ snapshot (the same ChildDaySnapshot the day sensors read):
|
||||
celebration automation must never fire on an empty day (the
|
||||
day-complete event carries the same rule, Feature 10 task 7).
|
||||
- ``binary_sensor.nestquest_<child>_present_today`` — ON when the
|
||||
presence engine resolves the child present on the HA-local date
|
||||
presence engine resolves the child present on the household-local date
|
||||
(schedules and overrides, D-004 anchor-date arithmetic).
|
||||
|
||||
Naming and registry identity follow the day sensors' contract: names
|
||||
|
||||
@@ -111,6 +111,9 @@ def snapshot_from_api_payload(payload: dict[str, Any]) -> NestQuestSnapshot:
|
||||
through verbatim;
|
||||
- ``present`` and ``next_present`` pass through verbatim (the API
|
||||
resolved them through the same presence engine server-side);
|
||||
- ``timezone`` (the API's effective application timezone) passes
|
||||
through verbatim for the cards' date and clock; an API older than
|
||||
the field omits it, which reads as ``""`` (the cards' fallback);
|
||||
- the instance list is rebuilt IN ORDER, so the panel payload the
|
||||
sensors emit matches the route's order byte for byte.
|
||||
|
||||
@@ -161,6 +164,7 @@ def snapshot_from_api_payload(payload: dict[str, Any]) -> NestQuestSnapshot:
|
||||
today_iso=payload["today_iso"],
|
||||
children=tuple(children),
|
||||
cycle_day=payload["cycle_day"],
|
||||
timezone=payload.get("timezone", ""),
|
||||
)
|
||||
|
||||
|
||||
@@ -372,10 +376,7 @@ def coordinator_client_from_entry(
|
||||
return _UnconfiguredApiClient()
|
||||
if session_factory is not None:
|
||||
return NestQuestApiClient(
|
||||
base_url,
|
||||
panel_token,
|
||||
session_factory(hass),
|
||||
household_timezone=hass.config.time_zone,
|
||||
base_url, panel_token, session_factory(hass)
|
||||
)
|
||||
from custom_components.nestquest.api_client import client_from_entry
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ un-completing (no-op if already open) is :func:`uncomplete_instance`.
|
||||
Instance current state is derived from the latest event via
|
||||
:func:`instance_state` (``open`` / ``done`` / ``missed``); there is
|
||||
no status column. ``missed`` is derived for past-due open instances
|
||||
(``due_date`` before HA-local ``today``) and is never stored as an
|
||||
(``due_date`` before household-local ``today``) and is never stored as an
|
||||
event_type.
|
||||
|
||||
Actor policy matches the schema CHECKs (D-008): ``actor_source`` is
|
||||
@@ -71,10 +71,10 @@ def _completion_moment(
|
||||
now: datetime.datetime | None,
|
||||
today: datetime.date | None,
|
||||
) -> tuple[datetime.date, datetime.time | None]:
|
||||
"""Return the HA-local completion date and optional wall-clock time.
|
||||
"""Return the household-local completion date and optional wall-clock time.
|
||||
|
||||
``today`` is the HA-local calendar date when threaded (Feature 07
|
||||
style). ``now`` is a timezone-aware HA-local datetime; its wall
|
||||
``today`` is the household-local calendar date when threaded (Feature 07
|
||||
style). ``now`` is a timezone-aware household-local datetime; its wall
|
||||
clock is used as-is and is never converted to UTC. When ``today``
|
||||
is omitted the date comes from ``now``. When both are omitted the
|
||||
host calendar date is the Feature 07 fallback — never the UTC
|
||||
@@ -98,7 +98,7 @@ def _completion_moment(
|
||||
|
||||
|
||||
def _resolve_today(today: datetime.date | None) -> datetime.date:
|
||||
"""Return the threaded HA-local date, or the host calendar date."""
|
||||
"""Return the threaded household-local date, or the host calendar date."""
|
||||
if today is None:
|
||||
return datetime.date.today()
|
||||
return today
|
||||
@@ -138,7 +138,7 @@ def derive_state(
|
||||
Thin wrapper over :func:`_derived_state`: ``open``, ``done``, or
|
||||
``missed`` from the latest event plus the due date (``missed`` is
|
||||
derived, never an event_type). ``today`` is the caller-resolved
|
||||
HA-local date.
|
||||
household-local date.
|
||||
"""
|
||||
return _derived_state(instance, latest, today)
|
||||
|
||||
@@ -345,7 +345,7 @@ async def instance_state(
|
||||
"""Return ``open``, ``done``, or ``missed`` from the latest event.
|
||||
|
||||
No events, or a latest event of uncompleted, is ``open`` unless
|
||||
``due_date`` is before ``today`` (HA-local, threaded), in which
|
||||
``due_date`` is before ``today`` (household-local, threaded), in which
|
||||
case it is ``missed``. A latest event of completed is ``done``
|
||||
even when past due. ``missed`` is never stored as an event_type.
|
||||
Unknown ``instance_id`` raises ValueError naming the field.
|
||||
@@ -414,9 +414,9 @@ async def complete_instance(
|
||||
latest-event, and the append run under one connection lock so a
|
||||
concurrent complete cannot double-append.
|
||||
|
||||
``was_on_time`` is computed from the HA-local completion moment
|
||||
``was_on_time`` is computed from the household-local completion moment
|
||||
(threaded ``now`` / ``today``) against the instance ``due_date``
|
||||
and optional ``due_time``. ``now`` is the timezone-aware HA-local
|
||||
and optional ``due_time``. ``now`` is the timezone-aware household-local
|
||||
datetime; its wall clock is never converted to UTC. Same-day
|
||||
``due_time`` comparison requires ``now``.
|
||||
"""
|
||||
@@ -471,7 +471,7 @@ async def uncomplete_instance(
|
||||
"""Append an uncompleted event unless the instance is already open.
|
||||
|
||||
Returns the derived state ``open``, or ``missed`` when the instance
|
||||
is past due (``due_date`` before HA-local ``today``); ``appended``
|
||||
is past due (``due_date`` before household-local ``today``); ``appended``
|
||||
reports whether THIS call appended (False on an already-open
|
||||
no-op). Unknown ``instance_id`` raises ValueError naming the
|
||||
field. Existence, latest-event, and the append run under one
|
||||
@@ -529,7 +529,7 @@ async def list_missed_for_child(
|
||||
"""Return the child's missed instances due in the closed date range.
|
||||
|
||||
Missed means the latest event state is open and ``due_date`` is
|
||||
before ``today`` (HA-local, threaded). Uses
|
||||
before ``today`` (household-local, threaded). Uses
|
||||
:meth:`QuestInstancesDao.list_by_date_range` plus derived state.
|
||||
Never appends a missed event. Instances and latest events are
|
||||
read inside the connection lock so the list is one snapshot.
|
||||
|
||||
@@ -114,24 +114,15 @@ CONF_CELEBRATION_ENABLED = "celebration_enabled"
|
||||
DEFAULT_AUTOMATION_ENABLED = True
|
||||
# The household's IANA time zone (e.g. ``America/New_York``) the API
|
||||
# service reads its clock in. The empty string is the API host's own
|
||||
# local time. While the admin has not chosen a zone
|
||||
# (``timezone_configured`` is ``False``) the API adopts the zone the
|
||||
# integration reports from Home Assistant's configured ``time_zone``
|
||||
# (:data:`PANEL_TIMEZONE_HEADER`), so the default is the household's
|
||||
# zone without any manual setup. The integration itself always uses
|
||||
# Home Assistant's ``time_zone`` and ignores this field.
|
||||
# local time. Only an admin sets it (manually, from the admin app);
|
||||
# nothing sets it automatically. The integration relays the API's
|
||||
# effective application timezone (the coordinator carries it and the
|
||||
# household sensor publishes it for the panel cards) and never derives
|
||||
# the application clock from Home Assistant's configuration; the Home
|
||||
# Assistant / browser zone applies only when the API publishes no zone
|
||||
# (e.g. an older API).
|
||||
CONF_TIMEZONE = "timezone"
|
||||
DEFAULT_TIMEZONE = ""
|
||||
# Whether the admin has made an explicit ``timezone`` choice. ``False``
|
||||
# (a fresh install) lets the API follow Home Assistant's reported zone
|
||||
# and the admin UI auto-set the browser's zone once while none is
|
||||
# stored; ``True`` pins the stored value — including an explicitly-chosen
|
||||
# empty ``timezone`` (the API host's local time) — so neither changes it.
|
||||
CONF_TIMEZONE_CONFIGURED = "timezone_configured"
|
||||
DEFAULT_TIMEZONE_CONFIGURED = False
|
||||
# The panel-plane request header carrying Home Assistant's configured
|
||||
# IANA ``time_zone`` from the integration to the API service.
|
||||
PANEL_TIMEZONE_HEADER = "X-NestQuest-Timezone"
|
||||
|
||||
# Panel-plane API client (Feature 18): the integration reaches the
|
||||
# NestQuest API service (Feature 16) over HTTP instead of the database.
|
||||
|
||||
@@ -68,7 +68,7 @@ def _resolve_today(today: datetime.date | None) -> datetime.date:
|
||||
around midnight, so when the caller has already resolved the local
|
||||
date (``today``) that anchor is authoritative; ``None`` falls back to
|
||||
the host clock (:func:`_today`), which is exactly the previous
|
||||
behavior for callers with no HA-local date.
|
||||
behavior for callers with no household-local date.
|
||||
"""
|
||||
return _today() if today is None else today
|
||||
|
||||
@@ -225,7 +225,7 @@ class QuestInstancesDao:
|
||||
- ``due_date`` must be TODAY or later: instances are never
|
||||
generated in the past (Feature 07). The caller's
|
||||
materialization horizon is future-dated by definition.
|
||||
``today`` optionally pins the resolved HA-local date the
|
||||
``today`` optionally pins the resolved household-local date the
|
||||
guard compares against (falling back to the host clock when
|
||||
omitted) so a household time zone behind the host around
|
||||
midnight is not misread as "past".
|
||||
@@ -248,7 +248,7 @@ class QuestInstancesDao:
|
||||
# check runs after the lock is acquired, because a caller can
|
||||
# wait on the lock across midnight — a date that was "today"
|
||||
# at call time may be "yesterday" by the time the INSERT runs.
|
||||
# A caller-supplied ``today`` (HA-local) is used for BOTH
|
||||
# A caller-supplied ``today`` (household-local) is used for BOTH
|
||||
# checks and never re-read, since it is already the anchor the
|
||||
# batch runs against.
|
||||
today_date = _resolve_today(today)
|
||||
@@ -257,7 +257,7 @@ class QuestInstancesDao:
|
||||
async with self._database.transaction():
|
||||
# Re-read "today" under the lock: this is the date the
|
||||
# insert actually executes on, so the no-past rule
|
||||
# holds across midnight rollovers. A pinned HA-local
|
||||
# holds across midnight rollovers. A pinned household-local
|
||||
# anchor is exempt from the re-read (see above).
|
||||
if today is None:
|
||||
today_date = _today()
|
||||
@@ -364,7 +364,7 @@ class QuestInstancesDao:
|
||||
rather than raising, so the rest of the batch is unaffected. The
|
||||
no-past rule (instances are never generated in the past) is
|
||||
preserved from :meth:`upsert` and still raises ValueError; ``today``
|
||||
optionally pins the HA-local anchor it compares against. An
|
||||
optionally pins the household-local anchor it compares against. An
|
||||
instance that ALREADY has a completion event is also skipped
|
||||
(returns ``None``) rather than raised: it is immutable, so the
|
||||
walk leaves it untouched instead of failing the batch — the
|
||||
@@ -504,7 +504,7 @@ class QuestInstancesDao:
|
||||
append-only contract: an instance that was ever completed is
|
||||
never reported missed, even when later reversed — history keeps
|
||||
both rows and the sweep never mutates any). The watermark is
|
||||
the HA-local date the sweep last ran, and the window is
|
||||
the household-local date the sweep last ran, and the window is
|
||||
``[watermark, today)``: the nightly run entering day D+1 sweeps
|
||||
exactly day D's instances, so every day's quests are announced
|
||||
once — at the midnight after their due date — and a same-night
|
||||
@@ -544,9 +544,10 @@ class QuestInstancesDao:
|
||||
rolling horizon from now), so a caller cannot use this to
|
||||
rewrite past open instances either — the day-rollover missed
|
||||
sweep (Feature 11) handles past-due instances instead. ``today``
|
||||
optionally pins the HA-local anchor the clamp compares against
|
||||
(falling back to the host clock when omitted). Any instance with
|
||||
a completion event is never touched, regardless of its date.
|
||||
optionally pins the household-local anchor the clamp compares
|
||||
against (falling back to the host clock when omitted). Any
|
||||
instance with a completion event is never touched, regardless of
|
||||
its date.
|
||||
Returns the number deleted.
|
||||
"""
|
||||
_validate_date(cutoff_date, "cutoff_date")
|
||||
@@ -581,8 +582,9 @@ class QuestInstancesDao:
|
||||
one transaction — an instance that gains a completion event
|
||||
mid-flight cannot be deleted. The cutoff cannot be backdated below
|
||||
today, for the same reason as the definition-scoped method (see its
|
||||
docstring). ``today`` optionally pins the HA-local anchor the clamp
|
||||
compares against (falling back to the host clock when omitted).
|
||||
docstring). ``today`` optionally pins the household-local anchor
|
||||
the clamp compares against (falling back to the host clock when
|
||||
omitted).
|
||||
Any instance with a completion event is never touched, regardless
|
||||
of its date.
|
||||
|
||||
|
||||
@@ -39,10 +39,11 @@ ISO-8601 timestamp. Completion payloads additionally carry
|
||||
``was_on_time``. No other personal data: names and titles are the
|
||||
household's own records, and nothing beyond them leaks.
|
||||
|
||||
Timezone policy: the day-complete check needs the HA-local "today",
|
||||
which the caller passes in as a plain :class:`datetime.date`. Nothing
|
||||
here reads ``hass.config.time_zone`` — the integration owns HA
|
||||
timezone, the core owns the calendar arithmetic.
|
||||
Timezone policy: the day-complete check needs the household-local
|
||||
"today", which the caller passes in as a plain :class:`datetime.date`.
|
||||
Nothing here reads a timezone — the API owns the clock (the
|
||||
admin-stored application timezone, or the API host's local zone when
|
||||
none is stored), the core owns the calendar arithmetic.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -147,10 +148,10 @@ async def build_child_day_complete_event(
|
||||
completion of TODAY'S instance evaluates it: completing a
|
||||
back-dated or future instance must not announce a cleared day.
|
||||
|
||||
``today`` is the HA-local calendar date the day-complete rule is
|
||||
evaluated against; the integration reads it from
|
||||
``hass.config.time_zone`` and passes it in, so this module never
|
||||
touches HA config.
|
||||
``today`` is the household-local calendar date the day-complete
|
||||
rule is evaluated against; the API resolves it in the application
|
||||
timezone and passes it in, so this module never reads a clock or a
|
||||
timezone of its own.
|
||||
"""
|
||||
# Only a completion of TODAY'S instance evaluates the day-complete
|
||||
# rule: completing a back-dated or future instance must not
|
||||
|
||||
@@ -153,7 +153,7 @@ def _validate_range(start_date: str, end_date: str) -> tuple[str, str]:
|
||||
|
||||
|
||||
def _resolve_today(today: datetime.date | None) -> datetime.date:
|
||||
"""Return the threaded HA-local date, or the host calendar date."""
|
||||
"""Return the threaded household-local date, or the host calendar date."""
|
||||
if today is None:
|
||||
return datetime.date.today()
|
||||
return today
|
||||
@@ -283,7 +283,7 @@ async def query_history(
|
||||
|
||||
Rows are ordered deterministically by ``due_date``, then instance,
|
||||
then event id (the per-instance append order, so a completion and
|
||||
its reversal keep their order). ``today`` pins the HA-local date
|
||||
its reversal keep their order). ``today`` pins the household-local date
|
||||
the missed derivation compares due dates against (the host calendar
|
||||
date when omitted). The whole read runs under the task-reentrant
|
||||
connection lock — :func:`list_missed_for_child` re-enters it — so
|
||||
|
||||
@@ -255,12 +255,12 @@ async def materialize(
|
||||
after a presence change) cannot touch any unrelated child's rows.
|
||||
When omitted (or None) the walk covers every assignee as before.
|
||||
|
||||
``today`` optionally pins the resolved HA-local date the walk's
|
||||
``today`` optionally pins the resolved household-local date the walk's
|
||||
no-past guard compares against; it is threaded into every
|
||||
:meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid` write. When
|
||||
omitted the host clock (``datetime.date.today``) is used, which is the
|
||||
historical default. A caller that computed the horizon in Home
|
||||
Assistant's configured time zone MUST pass that same date here so a
|
||||
historical default. A caller that computed the horizon in the
|
||||
household's application time zone MUST pass that same date here so a
|
||||
time zone behind the host around midnight is not rejected as "past".
|
||||
|
||||
``start_date`` is clamped up to the resolved "today" (the same
|
||||
@@ -298,8 +298,8 @@ async def materialize(
|
||||
start = datetime.datetime.strptime(start_date, "%Y-%m-%d").date()
|
||||
end = datetime.datetime.strptime(end_date, "%Y-%m-%d").date()
|
||||
|
||||
# Clamp a past start up to today (the HA-local anchor when the caller
|
||||
# pinned one, else the host clock) so the walk can never emit a
|
||||
# Clamp a past start up to today (the household-local anchor when the
|
||||
# caller pinned one, else the host clock) so the walk can never emit a
|
||||
# past-dated instance; the DAO's no-past guard remains the backstop.
|
||||
# A range entirely in the past therefore yields zero instances.
|
||||
today_date = _resolve_today(today)
|
||||
@@ -345,7 +345,7 @@ async def regenerate_for_definition(
|
||||
:func:`~.dao_instances._today` — so the delete cutoff and the
|
||||
re-materialization range share one anchor, and the walk's
|
||||
``upsert_if_valid`` no-past guard never rejects the regenerated
|
||||
range. ``today`` optionally pins a caller-resolved HA-local date
|
||||
range. ``today`` optionally pins a caller-resolved household-local date
|
||||
(threaded into both the delete cutoff and the re-materialization)
|
||||
so a household time zone behind the host around midnight stays
|
||||
consistent. ``horizon_days`` sizes the re-materialization window;
|
||||
@@ -413,9 +413,9 @@ async def regenerate_for_child(
|
||||
:meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted_for_child`),
|
||||
so the delete cutoff and the re-materialization window share ONE
|
||||
execution-day anchor even when the call is queued across midnight.
|
||||
``today`` optionally pins a caller-resolved HA-local date, threaded
|
||||
``today`` optionally pins a caller-resolved household-local date, threaded
|
||||
into the delete cutoff and the re-materialization for the same
|
||||
host-vs-HA time-zone consistency. ``horizon_days`` sizes the
|
||||
host-vs-household time-zone consistency. ``horizon_days`` sizes the
|
||||
re-materialization window; when omitted it falls back to
|
||||
:data:`~.const.DEFAULT_HORIZON_DAYS`. A bool, non-int, or sub-1 value
|
||||
raises ValueError BEFORE any delete. Returns the number of instances
|
||||
|
||||
@@ -345,7 +345,7 @@ async def edit_quest_definition(
|
||||
future instances only; no ``quest_instances`` or
|
||||
``completion_events`` row is touched.
|
||||
|
||||
``today`` optionally pins the caller-resolved HA-local date the
|
||||
``today`` optionally pins the caller-resolved household-local date the
|
||||
regeneration's no-past guard and horizon are anchored to (threaded
|
||||
into :func:`~.materialize.regenerate_for_definition`); it is a plain
|
||||
``datetime.date`` with no HA import here — the Feature 09 service
|
||||
@@ -449,7 +449,7 @@ async def assign_child(
|
||||
``quest_definition_assignees`` link is written; existing instances
|
||||
and completion history are never touched.
|
||||
|
||||
``today`` optionally pins the caller-resolved HA-local date threaded
|
||||
``today`` optionally pins the caller-resolved household-local date threaded
|
||||
into the assignment's regeneration (see :func:`edit_quest_definition`).
|
||||
``horizon_days`` optionally sizes the regeneration's re-materialization
|
||||
window the same way.
|
||||
@@ -491,7 +491,7 @@ async def unassign_child(
|
||||
argument. Only the ``quest_definition_assignees`` link is written;
|
||||
existing instances and completion history are never touched.
|
||||
|
||||
``today`` optionally pins the caller-resolved HA-local date threaded
|
||||
``today`` optionally pins the caller-resolved household-local date threaded
|
||||
into the unassignment's regeneration (see :func:`edit_quest_definition`).
|
||||
``horizon_days`` optionally sizes the regeneration's re-materialization
|
||||
window the same way.
|
||||
@@ -548,7 +548,7 @@ async def set_quest_definition_active(
|
||||
connection-scoped lock, so the returned bundle is a consistent
|
||||
snapshot that a concurrent mutation cannot race.
|
||||
|
||||
``today`` optionally pins the caller-resolved HA-local date threaded
|
||||
``today`` optionally pins the caller-resolved household-local date threaded
|
||||
into the activation change's regeneration (see
|
||||
:func:`edit_quest_definition`). ``horizon_days`` optionally sizes the
|
||||
regeneration's re-materialization window the same way.
|
||||
|
||||
@@ -49,7 +49,6 @@ from .const import (
|
||||
CONF_MORNING_SUMMARY_TIME,
|
||||
CONF_NOTIFY_TARGET,
|
||||
CONF_TIMEZONE,
|
||||
CONF_TIMEZONE_CONFIGURED,
|
||||
DEFAULT_AFTERNOON_REMINDER_TIME,
|
||||
DEFAULT_AUTOMATION_ENABLED,
|
||||
DEFAULT_DAY_ROLLOVER_TIME,
|
||||
@@ -57,7 +56,6 @@ from .const import (
|
||||
DEFAULT_HORIZON_DAYS,
|
||||
DEFAULT_MORNING_SUMMARY_TIME,
|
||||
DEFAULT_TIMEZONE,
|
||||
DEFAULT_TIMEZONE_CONFIGURED,
|
||||
LOGGER,
|
||||
TIME_PATTERN,
|
||||
)
|
||||
@@ -243,12 +241,6 @@ _FIELDS: tuple[tuple[str, str, Any, Callable[[Any], Any]], ...] = (
|
||||
_resolve_bool,
|
||||
),
|
||||
("timezone", CONF_TIMEZONE, DEFAULT_TIMEZONE, _resolve_timezone),
|
||||
(
|
||||
"timezone_configured",
|
||||
CONF_TIMEZONE_CONFIGURED,
|
||||
DEFAULT_TIMEZONE_CONFIGURED,
|
||||
_resolve_bool,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -279,10 +271,6 @@ class NestQuestSettings:
|
||||
end_of_day_report_enabled: bool = DEFAULT_AUTOMATION_ENABLED
|
||||
celebration_enabled: bool = DEFAULT_AUTOMATION_ENABLED
|
||||
timezone: str = DEFAULT_TIMEZONE
|
||||
#: ``True`` once the admin explicitly chose :attr:`timezone` (even
|
||||
#: the empty host-local value); ``False`` on a fresh install, where
|
||||
#: the admin UI may auto-set the browser's zone.
|
||||
timezone_configured: bool = DEFAULT_TIMEZONE_CONFIGURED
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
# Validate STRICTLY at construction so a hand-built instance
|
||||
@@ -315,7 +303,6 @@ class NestQuestSettings:
|
||||
"afternoon_reminder_enabled",
|
||||
"end_of_day_report_enabled",
|
||||
"celebration_enabled",
|
||||
"timezone_configured",
|
||||
):
|
||||
value = getattr(self, name)
|
||||
if not isinstance(value, bool):
|
||||
@@ -329,12 +316,11 @@ class NestQuestSettings:
|
||||
|
||||
The API service does not run inside Home Assistant, so its one
|
||||
clock read (an aware host-local ``now``) is converted here: with
|
||||
:attr:`timezone` set — the admin's choice, or else the zone the
|
||||
integration reports from Home Assistant — the SAME instant is
|
||||
re-expressed in that zone, so ``.date()`` and ``.time()`` are the
|
||||
household's wall clock even when the host runs UTC. With
|
||||
:attr:`timezone` empty the instant is returned unchanged (the
|
||||
host's local time).
|
||||
:attr:`timezone` set — only ever by an admin, from the admin
|
||||
app — the SAME instant is re-expressed in that zone, so
|
||||
``.date()`` and ``.time()`` are the household's wall clock even
|
||||
when the host runs UTC. With :attr:`timezone` empty the instant
|
||||
is returned unchanged (the host's local time).
|
||||
"""
|
||||
if not self.timezone:
|
||||
return instant
|
||||
@@ -354,7 +340,7 @@ class NestQuestSettings:
|
||||
"""Return the rolling horizon window ``[today, today + horizon_days]``.
|
||||
|
||||
``today`` is REQUIRED (there is no host-clock default): the
|
||||
integration passes the HA-local ``hass.config.time_zone`` date so
|
||||
API passes the household-local date (:meth:`household_now`) so
|
||||
the horizon tracks the household's own day, and a silent
|
||||
host-clock fallback would be a wrong-day footgun (a test or a
|
||||
caller that forgets to pass ``today`` would otherwise materialize
|
||||
|
||||
@@ -33,12 +33,8 @@ after the first update:
|
||||
booleans,
|
||||
- ``timezone`` — ``""`` (the API host's local time) or an IANA time
|
||||
zone name such as ``America/New_York``. A document stored before
|
||||
this field existed simply lacks it and resolves to ``""``,
|
||||
- ``timezone_configured`` — a real boolean: ``True`` once the admin
|
||||
explicitly chose ``timezone`` (including the empty host-local value),
|
||||
so neither Home Assistant's reported zone
|
||||
(:func:`adopt_reported_timezone`) nor the admin UI changes it again. A document stored before
|
||||
this field existed lacks it and resolves to ``False``.
|
||||
this field existed simply lacks it and resolves to ``""``. Only an
|
||||
admin's explicit update ever writes it; nothing sets it automatically.
|
||||
|
||||
Only values whose core validators ACCEPTED are ever written, so a
|
||||
stored document always round-trips through the core's option builders.
|
||||
@@ -180,7 +176,7 @@ async def update_settings(
|
||||
update can never leave a half-applied document behind.
|
||||
|
||||
The validated changes are merged onto the current effective
|
||||
settings and the merged (complete, twelve-field) document is persisted
|
||||
settings and the merged (complete, eleven-field) document is persisted
|
||||
in one atomic upsert; the updated settings are returned. The whole
|
||||
read → merge → write span runs inside the per-database settings
|
||||
lock, so concurrent updates queue instead of interleaving.
|
||||
@@ -204,28 +200,6 @@ async def update_settings(
|
||||
return await _write_merged(database, current, changes)
|
||||
|
||||
|
||||
async def adopt_reported_timezone(
|
||||
database: NestQuestDatabase, zone: str
|
||||
) -> NestQuestSettings:
|
||||
"""Adopt Home Assistant's reported ``zone`` unless the admin chose one.
|
||||
|
||||
While ``timezone_configured`` is ``False`` the stored ``timezone``
|
||||
follows the zone the integration reports, so the API's clock is the
|
||||
household's without any manual setup; ``timezone_configured`` stays
|
||||
``False`` (the value is automatic, not an admin choice). Once the
|
||||
admin has chosen — including an explicitly-chosen empty zone — the
|
||||
stored settings are returned untouched. The check and the write
|
||||
share the settings lock, so a concurrent admin choice is never
|
||||
overwritten. An invalid ``zone`` raises a ``ValueError`` naming
|
||||
``timezone`` and writes nothing.
|
||||
"""
|
||||
async with _settings_lock(database):
|
||||
current = await load_settings(database)
|
||||
if current.timezone_configured or current.timezone == zone:
|
||||
return current
|
||||
return await _write_merged(database, current, {"timezone": zone})
|
||||
|
||||
|
||||
async def _write_merged(
|
||||
database: NestQuestDatabase,
|
||||
current: NestQuestSettings,
|
||||
|
||||
@@ -18,7 +18,7 @@ the panel payload shaper (:func:`instance_payload`), and the async
|
||||
service's panel snapshot route share ONE implementation of both the
|
||||
snapshot assembly and the entity-facing attribute payload shape.
|
||||
Nothing here imports :mod:`homeassistant`; the builder takes the
|
||||
database, the settings object, and the HA-local ``now`` (a single
|
||||
database, the settings object, and the household-local ``now`` (a single
|
||||
clock read the caller already resolved, from which it derives
|
||||
``today``) and returns the snapshot, mirroring exactly what the
|
||||
coordinator used to do inline.
|
||||
@@ -96,6 +96,11 @@ class NestQuestSnapshot:
|
||||
today_iso: str
|
||||
children: tuple[ChildDaySnapshot, ...]
|
||||
cycle_day: int
|
||||
#: The application timezone the admin stored (``""`` when unset —
|
||||
#: the host's local time; the API publishes its effective zone in
|
||||
#: its place, api/host_zone.py). The panel cards format their
|
||||
#: visible date and clock in it; it never re-resolves the day itself.
|
||||
timezone: str = ""
|
||||
|
||||
|
||||
def _cycle_day(
|
||||
@@ -182,7 +187,7 @@ async def build_snapshot(
|
||||
:func:`instance_payload` layer — this builder produces the shared
|
||||
shape both consumers read.
|
||||
|
||||
``now`` is the HA-local timezone-aware datetime the caller already
|
||||
``now`` is the household-local timezone-aware datetime the caller already
|
||||
resolved (a SINGLE clock read); the overdue check compares
|
||||
``now.time()`` against the instance's ``due_time`` for today's open
|
||||
instances. The builder performs NO internal clock read — it never
|
||||
@@ -319,4 +324,5 @@ async def build_snapshot(
|
||||
today_iso=today_iso,
|
||||
children=tuple(snapshots),
|
||||
cycle_day=_cycle_day(patterns, children, today),
|
||||
timezone=settings.timezone,
|
||||
)
|
||||
|
||||
@@ -39,9 +39,10 @@ lets exactly one of them win the claim — the loser returns [] and
|
||||
announces nothing.
|
||||
|
||||
The caller supplies ``today`` as a plain :class:`datetime.date`: the
|
||||
household-local calendar date, read from ``hass.config.time_zone`` by
|
||||
the integration or from the configured household timezone by the API.
|
||||
This module never reads a timezone database or config of its own.
|
||||
household-local calendar date, which the API reads in the
|
||||
admin-stored application timezone (the API host's local zone when none
|
||||
is stored). This module never reads a timezone database or config of
|
||||
its own.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -97,9 +98,9 @@ async def run_missed_sweep(
|
||||
"""Build one missed event per unswept past-due open instance.
|
||||
|
||||
``today`` is the household-local calendar date the sweep runs for
|
||||
(the caller owns the clock: the integration reads it from
|
||||
``hass.config.time_zone``, the API from the configured household
|
||||
timezone, falling back to its host's local time when unset).
|
||||
(the caller owns the clock: the API reads it in the admin-stored
|
||||
application timezone, falling back to its host's local time when
|
||||
unset).
|
||||
Returns the ``(event_type, payload)`` tuples to announce, EMPTY
|
||||
when the watermark already covers ``today`` (a same-night rerun) —
|
||||
the caller decides how to publish them and announces nothing on
|
||||
|
||||
@@ -15,5 +15,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"issue_tracker": "https://github.com/Maniacs-Mansion/NestQuest/issues",
|
||||
"requirements": [],
|
||||
"version": "0.9.5"
|
||||
"version": "0.9.7"
|
||||
}
|
||||
|
||||
@@ -441,6 +441,10 @@ class NestQuestHouseholdDueTodaySensor(_NestQuestHouseholdSensor):
|
||||
for child in data.children
|
||||
},
|
||||
"child_roster": _child_roster(data.children),
|
||||
# The API's effective application timezone ("" only when
|
||||
# it could not name one): the panel cards format their
|
||||
# visible date and clock in it.
|
||||
"timezone": data.timezone,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ const { is: nt, defineProperty: it, getOwnPropertyDescriptor: st, getOwnProperty
|
||||
return t;
|
||||
} }, Ze = (i, e) => !nt(i, e), ye = { attribute: !0, type: String, converter: ae, reflect: !1, useDefault: !1, hasChanged: Ze };
|
||||
Symbol.metadata ??= /* @__PURE__ */ Symbol("metadata"), K.litPropertyMetadata ??= /* @__PURE__ */ new WeakMap();
|
||||
let T = class extends HTMLElement {
|
||||
let z = class extends HTMLElement {
|
||||
static addInitializer(e) {
|
||||
this._$Ei(), (this.l ??= []).push(e);
|
||||
}
|
||||
@@ -246,7 +246,7 @@ let T = class extends HTMLElement {
|
||||
firstUpdated(e) {
|
||||
}
|
||||
};
|
||||
T.elementStyles = [], T.shadowRootOptions = { mode: "open" }, T[I("elementProperties")] = /* @__PURE__ */ new Map(), T[I("finalized")] = /* @__PURE__ */ new Map(), ct?.({ ReactiveElement: T }), (K.reactiveElementVersions ??= []).push("2.1.2");
|
||||
z.elementStyles = [], z.shadowRootOptions = { mode: "open" }, z[I("elementProperties")] = /* @__PURE__ */ new Map(), z[I("finalized")] = /* @__PURE__ */ new Map(), ct?.({ ReactiveElement: z }), (K.reactiveElementVersions ??= []).push("2.1.2");
|
||||
const pe = globalThis, be = (i) => i, V = pe.trustedTypes, ve = V ? V.createPolicy("lit-html", { createHTML: (i) => i }) : void 0, Qe = "$lit$", k = `lit$${Math.random().toFixed(9).slice(2)}$`, We = "?" + k, dt = `<${We}>`, A = document, P = () => A.createComment(""), H = (i) => i === null || typeof i != "object" && typeof i != "function", he = Array.isArray, pt = (i) => he(i) || typeof i?.[Symbol.iterator] == "function", Y = `[
|
||||
\f\r]`, L = /<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g, xe = /-->/g, we = />/g, $ = RegExp(`>|${Y}(?:([^\\s"'>=/]+)(${Y}*=${Y}*(?:[^
|
||||
\f\r"'\`<>=]|("|')|))|$)`, "g"), ke = /'/g, $e = /"/g, Ge = /^(?:script|style|textarea|title)$/i, Ke = (i) => (e, ...t) => ({ _$litType$: i, strings: e, values: t }), d = Ke(1), qe = Ke(2), S = /* @__PURE__ */ Symbol.for("lit-noChange"), g = /* @__PURE__ */ Symbol.for("lit-nothing"), Me = /* @__PURE__ */ new WeakMap(), M = A.createTreeWalker(A, 129);
|
||||
@@ -303,11 +303,11 @@ class R {
|
||||
return n.innerHTML = e, n;
|
||||
}
|
||||
}
|
||||
function z(i, e, t = i, n) {
|
||||
function T(i, e, t = i, n) {
|
||||
if (e === S) return e;
|
||||
let s = n !== void 0 ? t._$Co?.[n] : t._$Cl;
|
||||
const a = H(e) ? void 0 : e._$litDirective$;
|
||||
return s?.constructor !== a && (s?._$AO?.(!1), a === void 0 ? s = void 0 : (s = new a(i), s._$AT(i, t, n)), n !== void 0 ? (t._$Co ??= [])[n] = s : t._$Cl = s), s !== void 0 && (e = z(i, s._$AS(i, e.values), s, n)), e;
|
||||
return s?.constructor !== a && (s?._$AO?.(!1), a === void 0 ? s = void 0 : (s = new a(i), s._$AT(i, t, n)), n !== void 0 ? (t._$Co ??= [])[n] = s : t._$Cl = s), s !== void 0 && (e = T(i, s._$AS(i, e.values), s, n)), e;
|
||||
}
|
||||
class ut {
|
||||
constructor(e, t) {
|
||||
@@ -356,7 +356,7 @@ class N {
|
||||
return this._$AB;
|
||||
}
|
||||
_$AI(e, t = this) {
|
||||
e = z(this, e, t), H(e) ? e === g || e == null || e === "" ? (this._$AH !== g && this._$AR(), this._$AH = g) : e !== this._$AH && e !== S && this._(e) : e._$litType$ !== void 0 ? this.$(e) : e.nodeType !== void 0 ? this.T(e) : pt(e) ? this.k(e) : this._(e);
|
||||
e = T(this, e, t), H(e) ? e === g || e == null || e === "" ? (this._$AH !== g && this._$AR(), this._$AH = g) : e !== this._$AH && e !== S && this._(e) : e._$litType$ !== void 0 ? this.$(e) : e.nodeType !== void 0 ? this.T(e) : pt(e) ? this.k(e) : this._(e);
|
||||
}
|
||||
O(e) {
|
||||
return this._$AA.parentNode.insertBefore(e, this._$AB);
|
||||
@@ -409,11 +409,11 @@ class J {
|
||||
_$AI(e, t = this, n, s) {
|
||||
const a = this.strings;
|
||||
let r = !1;
|
||||
if (a === void 0) e = z(this, e, t, 0), r = !H(e) || e !== this._$AH && e !== S, r && (this._$AH = e);
|
||||
if (a === void 0) e = T(this, e, t, 0), r = !H(e) || e !== this._$AH && e !== S, r && (this._$AH = e);
|
||||
else {
|
||||
const l = e;
|
||||
let o, p;
|
||||
for (e = a[0], o = 0; o < a.length - 1; o++) p = z(this, l[n + o], t, o), p === S && (p = this._$AH[o]), r ||= !H(p) || p !== this._$AH[o], p === g ? e = g : e !== g && (e += (p ?? "") + a[o + 1]), this._$AH[o] = p;
|
||||
for (e = a[0], o = 0; o < a.length - 1; o++) p = T(this, l[n + o], t, o), p === S && (p = this._$AH[o]), r ||= !H(p) || p !== this._$AH[o], p === g ? e = g : e !== g && (e += (p ?? "") + a[o + 1]), this._$AH[o] = p;
|
||||
}
|
||||
r && !s && this.j(e);
|
||||
}
|
||||
@@ -442,7 +442,7 @@ class gt extends J {
|
||||
super(e, t, n, s, a), this.type = 5;
|
||||
}
|
||||
_$AI(e, t = this) {
|
||||
if ((e = z(this, e, t, 0) ?? g) === S) return;
|
||||
if ((e = T(this, e, t, 0) ?? g) === S) return;
|
||||
const n = this._$AH, s = e === g && n !== g || e.capture !== n.capture || e.once !== n.once || e.passive !== n.passive, a = e !== g && (n === g || s);
|
||||
s && this.element.removeEventListener(this.name, this, n), a && this.element.addEventListener(this.name, this, e), this._$AH = e;
|
||||
}
|
||||
@@ -458,7 +458,7 @@ class _t {
|
||||
return this._$AM._$AU;
|
||||
}
|
||||
_$AI(e) {
|
||||
z(this, e);
|
||||
T(this, e);
|
||||
}
|
||||
}
|
||||
const yt = { I: N }, bt = pe.litHtmlPolyfillSupport;
|
||||
@@ -473,7 +473,7 @@ const vt = (i, e, t) => {
|
||||
return s._$AI(i), s;
|
||||
};
|
||||
const ue = globalThis;
|
||||
let C = class extends T {
|
||||
let C = class extends z {
|
||||
constructor() {
|
||||
super(...arguments), this.renderOptions = { host: this }, this._$Do = void 0;
|
||||
}
|
||||
@@ -592,7 +592,7 @@ function Ct(i) {
|
||||
function Se(i) {
|
||||
return Number.isFinite(i) ? Math.min(100, Math.max(0, i)) : 0;
|
||||
}
|
||||
function Te(i) {
|
||||
function ze(i) {
|
||||
return Math.max(0, Math.round(i));
|
||||
}
|
||||
function At(i) {
|
||||
@@ -620,7 +620,7 @@ function St(i, e) {
|
||||
);
|
||||
return Number.isFinite(s) ? s - i : 0;
|
||||
}
|
||||
function Tt(i, e) {
|
||||
function zt(i, e) {
|
||||
if (typeof i != "string")
|
||||
return null;
|
||||
const t = i.split("-");
|
||||
@@ -636,14 +636,14 @@ function Tt(i, e) {
|
||||
const r = Date.UTC(n, s - 1, a), l = new Date(r - St(r, e));
|
||||
return Number.isNaN(l.getTime()) ? null : l;
|
||||
}
|
||||
function zt(i) {
|
||||
function Tt(i) {
|
||||
const e = qt[i];
|
||||
return e || i.charAt(0).toUpperCase() + i.slice(1);
|
||||
}
|
||||
function Et(i, e) {
|
||||
return Mt[i] ?? e;
|
||||
}
|
||||
const ze = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Ee = "M50 3 44.5 24.1 50 26 55.5 24.1Z M100 50 75.9 44.5 74 50 75.9 55.5Z M50 97 55.5 75.9 50 74 44.5 75.9Z M0 50 24.1 55.5 26 50 24.1 44.5Z M29.1 29.1 29.7 36.3 36.3 29.7Z M70.9 29.1 63.7 29.7 70.3 36.3Z M70.9 70.9 70.3 63.7 63.7 70.3Z M29.1 70.9 36.3 70.3 29.7 63.7Z", Nt = "M50 26.5 70.4 38.2 70.4 61.8 50 73.5 29.6 61.8 29.6 38.2Z", Lt = "M29.6 38.2H70.4M29.6 61.8H70.4M50 26.5 36.5 38.2M50 26.5 63.5 38.2M50 73.5 36.5 61.8M50 73.5 63.5 61.8M36.5 38.2V61.8M63.5 38.2V61.8M29.6 38.2 36.5 61.8M36.5 38.2 29.6 61.8M70.4 38.2 63.5 61.8M63.5 38.2 70.4 61.8", Ot = d`<svg
|
||||
const Te = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Ee = "M50 3 44.5 24.1 50 26 55.5 24.1Z M100 50 75.9 44.5 74 50 75.9 55.5Z M50 97 55.5 75.9 50 74 44.5 75.9Z M0 50 24.1 55.5 26 50 24.1 44.5Z M29.1 29.1 29.7 36.3 36.3 29.7Z M70.9 29.1 63.7 29.7 70.3 36.3Z M70.9 70.9 70.3 63.7 63.7 70.3Z M29.1 70.9 36.3 70.3 29.7 63.7Z", Nt = "M50 26.5 70.4 38.2 70.4 61.8 50 73.5 29.6 61.8 29.6 38.2Z", Lt = "M29.6 38.2H70.4M29.6 61.8H70.4M50 26.5 36.5 38.2M50 26.5 63.5 38.2M50 73.5 36.5 61.8M50 73.5 63.5 61.8M36.5 38.2V61.8M63.5 38.2V61.8M29.6 38.2 36.5 61.8M36.5 38.2 29.6 61.8M70.4 38.2 63.5 61.8M63.5 38.2 70.4 61.8", Ot = d`<svg
|
||||
class="monogram"
|
||||
viewBox="0 0 100 100"
|
||||
aria-hidden="true"
|
||||
@@ -850,7 +850,7 @@ const ze = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Ee = "M50 3 44.5 2
|
||||
width: 188px;
|
||||
height: 214px;
|
||||
padding: 4px;
|
||||
clip-path: ${x(ze)};
|
||||
clip-path: ${x(Te)};
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
}
|
||||
|
||||
@@ -861,7 +861,7 @@ const ze = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Ee = "M50 3 44.5 2
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-bottom: 70px;
|
||||
clip-path: ${x(ze)};
|
||||
clip-path: ${x(Te)};
|
||||
background: var(--nq-p-crest);
|
||||
}
|
||||
|
||||
@@ -1243,8 +1243,15 @@ const ze = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Ee = "M50 3 44.5 2
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
/** The zone the visible date and clock render in: the API's effective
|
||||
* application timezone — the stored zone, or the API host's when none
|
||||
* is stored (published on the household rollup by
|
||||
* custom_components/nestquest/sensor.py — ``timezone``). Only when the
|
||||
* rollup carries none does Home Assistant's zone (or the browser's)
|
||||
* apply. */
|
||||
_timeZone() {
|
||||
return kt(this.hass);
|
||||
const e = this._state("sensor.nestquest_household_quests_due_today")?.attributes?.timezone;
|
||||
return typeof e == "string" && e.trim() ? e.trim() : kt(this.hass);
|
||||
}
|
||||
_state(e) {
|
||||
const n = this.hass?.states?.[e];
|
||||
@@ -1280,7 +1287,7 @@ const ze = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Ee = "M50 3 44.5 2
|
||||
`sensor.nestquest_${e}_completion_pct_today`
|
||||
), r = this._state(
|
||||
`binary_sensor.nestquest_${e}_present_today`
|
||||
), l = n?.attributes ?? {}, o = Te(te(n?.state, 0)), p = Te(te(s?.state, 0)), u = a ? te(a.state, Number.NaN) : Number.NaN, c = Number.isFinite(u) ? Se(u) : o > 0 ? Se(p / o * 100) : 0;
|
||||
), l = n?.attributes ?? {}, o = ze(te(n?.state, 0)), p = ze(te(s?.state, 0)), u = a ? te(a.state, Number.NaN) : Number.NaN, c = Number.isFinite(u) ? Se(u) : o > 0 ? Se(p / o * 100) : 0;
|
||||
let f = !0;
|
||||
const h = l.present;
|
||||
typeof h == "boolean" ? f = h : r && (f = String(r.state ?? "").trim().toLowerCase() === "on");
|
||||
@@ -1293,7 +1300,7 @@ const ze = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Ee = "M50 3 44.5 2
|
||||
typeof X == "string" && X.trim() && (m = X.trim());
|
||||
}
|
||||
m || (m = At(e));
|
||||
const v = Tt(
|
||||
const v = zt(
|
||||
r?.attributes?.next_present,
|
||||
this._timeZone()
|
||||
), U = v ? `Returns ${ee(v, this._timeZone())}` : null;
|
||||
@@ -1439,7 +1446,7 @@ const ze = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Ee = "M50 3 44.5 2
|
||||
<span class="clock">${$t(this._now, this._timeZone())}</span>
|
||||
</div>
|
||||
`;
|
||||
const t = zt(e.condition), n = Et(e.condition, t), s = e.temperature === null ? g : d`<span class="temp">${Math.round(e.temperature)}°</span>`, a = e.high === null || e.low === null ? null : `${Math.round(e.high)}° / ${Math.round(e.low)}°`;
|
||||
const t = Tt(e.condition), n = Et(e.condition, t), s = e.temperature === null ? g : d`<span class="temp">${Math.round(e.temperature)}°</span>`, a = e.high === null || e.low === null ? null : `${Math.round(e.high)}° / ${Math.round(e.low)}°`;
|
||||
return d`
|
||||
<div class="dock">
|
||||
${Dt}
|
||||
@@ -3501,11 +3508,11 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", yn = "polygon(50%
|
||||
icon: qn,
|
||||
headline: "No adventurer chosen",
|
||||
body: "Open The Party and tap your crest to open your quest log."
|
||||
}, Tn = {
|
||||
}, zn = {
|
||||
icon: Mn,
|
||||
headline: "NestQuest is not set up yet",
|
||||
body: "A parent needs to finish setting up NestQuest."
|
||||
}, zn = {
|
||||
}, Tn = {
|
||||
icon: Cn,
|
||||
headline: "The records cannot be reached",
|
||||
body: "The party's records are quiet right now. NestQuest will return shortly."
|
||||
@@ -3597,9 +3604,9 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", yn = "polygon(50%
|
||||
case "no-adventurer":
|
||||
return this._renderNotice(Sn);
|
||||
case "not-set-up":
|
||||
return this._renderNotice(Tn);
|
||||
case "unreachable":
|
||||
return this._renderNotice(zn);
|
||||
case "unreachable":
|
||||
return this._renderNotice(Tn);
|
||||
case "away": {
|
||||
const t = ["The quest log unlocks when they return."];
|
||||
return e.returns && t.push(`Returns ${e.returns}`), d`
|
||||
@@ -3727,8 +3734,15 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", yn = "polygon(50%
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
/** The zone the visible date and clock render in: the API's effective
|
||||
* application timezone — the stored zone, or the API host's when none
|
||||
* is stored (published on the household rollup by
|
||||
* custom_components/nestquest/sensor.py — ``timezone``). Only when the
|
||||
* rollup carries none does Home Assistant's zone (or the browser's)
|
||||
* apply. */
|
||||
_timeZone() {
|
||||
return rn(this.hass);
|
||||
const e = this._state("sensor.nestquest_household_quests_due_today")?.attributes?.timezone;
|
||||
return typeof e == "string" && e.trim() ? e.trim() : rn(this.hass);
|
||||
}
|
||||
_state(e) {
|
||||
const n = this.hass?.states?.[e];
|
||||
|
||||
@@ -99,6 +99,38 @@ A lone `home` pattern answers exactly as the old schedule did, so no
|
||||
child's presence changes. The drop is one-way and 0.7.1 refuses a
|
||||
version-9 database; see the rollback step in `deploy/RUNBOOK.md` §11.
|
||||
|
||||
### Application timezone (manual, admin-set)
|
||||
|
||||
The application timezone is set **manually by an admin from the admin
|
||||
app** — the Settings screen's timezone picker, which reads and writes the
|
||||
`timezone` field of `GET`/`PATCH /api/v1/admin/settings`. Nothing sets it
|
||||
automatically: there is no Home Assistant zone adoption, no browser-zone
|
||||
auto-detect, and no auto-save when a screen loads. Only an admin's `PATCH`
|
||||
ever changes the stored value.
|
||||
|
||||
The stored value is the **single source of local-time truth for the whole
|
||||
application.** The API reads its one clock and re-expresses it in that zone
|
||||
(`NestQuestSettings.household_now`), so every local date and time comes
|
||||
from it:
|
||||
|
||||
- the panel snapshot's today and overdue state, and both panel cards'
|
||||
visible dates and clocks (the snapshot publishes the zone as `timezone`,
|
||||
which the integration exposes on the household rollup sensor);
|
||||
- completion timestamps (the local completion date and the on-time check);
|
||||
- the missed-sweep scheduler's household rollover;
|
||||
- every admin read, and the admin app's local-time formatting.
|
||||
|
||||
**Empty means the API host's local zone.** While `timezone` is empty the
|
||||
API reads its clock in the host's local time, and publishes that zone as
|
||||
the effective zone (`api/host_zone.py`: the `effective_timezone` field of
|
||||
the admin settings response and the snapshot's `timezone`) until an admin
|
||||
sets one. No code fills the empty value in.
|
||||
|
||||
**A save re-plans the sweep.** An admin save that changes `timezone` or
|
||||
`day_rollover_time` notifies the running missed-sweep scheduler
|
||||
(`MissedSweepScheduler.settings_changed`, under its settings lock), which
|
||||
re-plans its next sweep for the new household rollover.
|
||||
|
||||
## 4. Authentication: two planes
|
||||
|
||||
The kid panel and the admin PWA have opposite trust levels, so they use
|
||||
|
||||
@@ -937,8 +937,18 @@ export class NestQuestPartyBoardCard extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
/** The zone the visible date and clock render in: the API's effective
|
||||
* application timezone — the stored zone, or the API host's when none
|
||||
* is stored (published on the household rollup by
|
||||
* custom_components/nestquest/sensor.py — ``timezone``). Only when the
|
||||
* rollup carries none does Home Assistant's zone (or the browser's)
|
||||
* apply. */
|
||||
private _timeZone(): string | undefined {
|
||||
return hassTimeZone(this.hass);
|
||||
const stored = this._state("sensor.nestquest_household_quests_due_today")
|
||||
?.attributes?.timezone;
|
||||
return typeof stored === "string" && stored.trim()
|
||||
? stored.trim()
|
||||
: hassTimeZone(this.hass);
|
||||
}
|
||||
|
||||
private _state(entityId: string): StateObject | null {
|
||||
|
||||
@@ -1749,8 +1749,18 @@ export class NestQuestQuestLogCard extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
/** The zone the visible date and clock render in: the API's effective
|
||||
* application timezone — the stored zone, or the API host's when none
|
||||
* is stored (published on the household rollup by
|
||||
* custom_components/nestquest/sensor.py — ``timezone``). Only when the
|
||||
* rollup carries none does Home Assistant's zone (or the browser's)
|
||||
* apply. */
|
||||
private _timeZone(): string | undefined {
|
||||
return hassTimeZone(this.hass);
|
||||
const stored = this._state("sensor.nestquest_household_quests_due_today")
|
||||
?.attributes?.timezone;
|
||||
return typeof stored === "string" && stored.trim()
|
||||
? stored.trim()
|
||||
: hassTimeZone(this.hass);
|
||||
}
|
||||
|
||||
private _state(entityId: string): StateObject | null {
|
||||
|
||||
@@ -3,13 +3,15 @@
|
||||
*
|
||||
* Usage: node render-party-board.mjs < spec.json
|
||||
*
|
||||
* The spec JSON carries {bundle, tag, config, hass, url?, click_plate?};
|
||||
* The spec JSON carries {bundle, tag, config, hass, url?, click_plate?, now?};
|
||||
* stdout receives one JSON object {plates, tappable, location, html} where
|
||||
* plates is the ordered plate list the shadow DOM renders (name +
|
||||
* present/away/unknown kind), tappable the plate names rendered as tappable
|
||||
* <button> plates, and location the window pathname after the optional plate
|
||||
* tap. The Python panel tests drive this harness so the assertions run
|
||||
* against the exact bundle HACS ships.
|
||||
* tap. With now: <ISO instant> the card's clock is pinned there
|
||||
* (argument-less new Date() and Date.now() return it; the process TZ
|
||||
* decides its local wall time). The Python panel tests drive this harness
|
||||
* so the assertions run against the exact bundle HACS ships.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { pathToFileURL } from "node:url";
|
||||
@@ -39,6 +41,23 @@ globalThis.window = dom.window;
|
||||
// jsdom Event must shadow it for the dispatched event to be delivered.
|
||||
globalThis.Event = dom.window.Event;
|
||||
|
||||
// Pin the clock: argument-less construction and Date.now() read `pinned`,
|
||||
// every other Date use is the real class.
|
||||
if (spec.now !== undefined) {
|
||||
const pinned = new Date(spec.now).getTime();
|
||||
const RealDate = Date;
|
||||
class PinnedDate extends RealDate {
|
||||
constructor(...args) {
|
||||
super(...(args.length === 0 ? [pinned] : args));
|
||||
}
|
||||
static now() {
|
||||
return pinned;
|
||||
}
|
||||
}
|
||||
globalThis.Date = PinnedDate;
|
||||
dom.window.Date = PinnedDate;
|
||||
}
|
||||
|
||||
await import(pathToFileURL(spec.bundle).href);
|
||||
|
||||
const element = document.createElement(spec.tag);
|
||||
|
||||
@@ -16,6 +16,12 @@
|
||||
* reports every hass.callService call as tap: {dialog_opened, service_calls}.
|
||||
* With tap_return: true the harness taps the header's return control and
|
||||
* reports it as returned: {rendered, label} before the final snapshot.
|
||||
* With now: <ISO instant> the card's clock is pinned there (argument-less
|
||||
* new Date() and Date.now() return it; the process TZ decides its local
|
||||
* wall time), and with advance_to: [<ISO instant>, ...] the clock moves to
|
||||
* each instant in turn and the card's own 60 s clock-tick callbacks fire,
|
||||
* one snapshot per instant reported as ticks (the top-level snapshot is
|
||||
* taken before the clock moves).
|
||||
* The Python panel tests drive this harness so the
|
||||
* assertions run against the exact bundle HACS ships.
|
||||
*/
|
||||
@@ -47,6 +53,33 @@ globalThis.window = dom.window;
|
||||
// jsdom Event must shadow it for the return navigation to be delivered.
|
||||
globalThis.Event = dom.window.Event;
|
||||
|
||||
// Pin the clock: argument-less construction and Date.now() read `pinned`,
|
||||
// every other Date use is the real class.
|
||||
let pinned = spec.now !== undefined ? new Date(spec.now).getTime() : null;
|
||||
if (pinned !== null) {
|
||||
const RealDate = Date;
|
||||
class PinnedDate extends RealDate {
|
||||
constructor(...args) {
|
||||
super(...(args.length === 0 ? [pinned] : args));
|
||||
}
|
||||
static now() {
|
||||
return pinned;
|
||||
}
|
||||
}
|
||||
globalThis.Date = PinnedDate;
|
||||
dom.window.Date = PinnedDate;
|
||||
}
|
||||
|
||||
// Record the card's 60 s clock ticks so advance_to can fire them on demand.
|
||||
const clockTicks = [];
|
||||
const realSetInterval = dom.window.setInterval.bind(dom.window);
|
||||
dom.window.setInterval = (callback, ms, ...rest) => {
|
||||
if (ms === 60000) {
|
||||
clockTicks.push(callback);
|
||||
}
|
||||
return realSetInterval(callback, ms, ...rest);
|
||||
};
|
||||
|
||||
await import(pathToFileURL(spec.bundle).href);
|
||||
|
||||
const serviceCalls = [];
|
||||
@@ -123,6 +156,18 @@ if (spec.idle_ms !== undefined) {
|
||||
await new Promise((resolve) => setTimeout(resolve, spec.idle_ms));
|
||||
}
|
||||
|
||||
const payload = JSON.stringify({ ...snapshot(), probe, tap, returned });
|
||||
const settled = snapshot();
|
||||
let ticks = null;
|
||||
if (spec.advance_to !== undefined) {
|
||||
ticks = [];
|
||||
for (const instant of spec.advance_to) {
|
||||
pinned = new Date(instant).getTime();
|
||||
clockTicks.forEach((callback) => callback());
|
||||
await element.updateComplete;
|
||||
ticks.push(snapshot());
|
||||
}
|
||||
}
|
||||
|
||||
const payload = JSON.stringify({ ...settled, probe, tap, returned, ticks });
|
||||
await new Promise((resolve) => process.stdout.write(payload, () => resolve()));
|
||||
process.exit(0);
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "nestquest"
|
||||
version = "0.9.5"
|
||||
version = "0.9.7"
|
||||
description = "NestQuest Home Assistant Integration"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
+3
-2
@@ -910,6 +910,7 @@ def coordinator_client_from_test_override():
|
||||
{
|
||||
"today_iso": datetime.date.today().isoformat(),
|
||||
"cycle_day": 0,
|
||||
"timezone": "",
|
||||
"children": [],
|
||||
}
|
||||
)
|
||||
@@ -1016,8 +1017,8 @@ def make_hass() -> tuple:
|
||||
hass.bus = EventBus()
|
||||
hass.http = HttpRegistry()
|
||||
hass.extra_js_urls: list[str] = []
|
||||
# A valid IANA time zone (the day-rollover listener reads it to
|
||||
# compute "today" in HA local time).
|
||||
# A valid IANA time zone for the fake Home Assistant config (the
|
||||
# NestQuest application timezone is the admin-set setting, not this).
|
||||
hass.config.time_zone = "UTC"
|
||||
|
||||
# The minimal entity registry: entities self-register through
|
||||
|
||||
@@ -13,7 +13,8 @@ proven against the DATABASE the routes wrote, not a parallel copy.
|
||||
Proven per the done-condition, everything read back through the API:
|
||||
|
||||
- ``GET /api/v1/admin/settings`` returns the current effective settings
|
||||
— the twelve documented fields at their defaults on a fresh database.
|
||||
— the eleven documented fields at their defaults on a fresh database,
|
||||
with no ``timezone_configured`` marker.
|
||||
- ``PATCH /api/v1/admin/settings`` PERSISTS the supplied fields and the
|
||||
NEXT read reflects them; a later partial update merges onto the
|
||||
current effective settings (omitted fields keep their stored value),
|
||||
@@ -40,6 +41,7 @@ from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from api import host_zone
|
||||
from tests.admin_jwt_harness import (
|
||||
ADMIN_KEY,
|
||||
KID,
|
||||
@@ -59,9 +61,14 @@ _dao_meta = importlib.import_module("nestquest_core.dao_meta")
|
||||
#: independent of the core constant.
|
||||
SETTINGS_META_KEY = "settings"
|
||||
|
||||
#: The API host's own zone, pinned for every test (:func:`settings_client`):
|
||||
#: the ``effective_timezone`` while no zone is stored.
|
||||
API_HOST_ZONE = "Australia/Perth"
|
||||
|
||||
#: The all-defaults effective settings a fresh database reports — the
|
||||
#: documented twelve fields, asserted literally so the payload contract is
|
||||
#: pinned here, independent of the core constants.
|
||||
#: documented eleven fields plus the read-only ``effective_timezone``,
|
||||
#: asserted literally so the payload contract is pinned here, independent
|
||||
#: of the core constants.
|
||||
DEFAULT_SETTINGS = {
|
||||
"horizon_days": 14,
|
||||
"day_rollover_time": "00:00",
|
||||
@@ -74,23 +81,34 @@ DEFAULT_SETTINGS = {
|
||||
"end_of_day_report_enabled": True,
|
||||
"celebration_enabled": True,
|
||||
"timezone": "",
|
||||
"timezone_configured": False,
|
||||
"effective_timezone": API_HOST_ZONE,
|
||||
}
|
||||
|
||||
|
||||
def _with_zone(zone: str) -> dict:
|
||||
"""The default settings with ``zone`` stored, as the API reports them."""
|
||||
return {
|
||||
**DEFAULT_SETTINGS,
|
||||
"timezone": zone,
|
||||
"effective_timezone": zone or API_HOST_ZONE,
|
||||
}
|
||||
|
||||
|
||||
def _admin_headers() -> dict[str, str]:
|
||||
"""The Authorization header of a valid nestquest-admins JWT."""
|
||||
return {"Authorization": f"Bearer {make_token()}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def settings_client(temp_db_path: str) -> SimpleNamespace:
|
||||
async def settings_client(temp_db_path: str, monkeypatch) -> SimpleNamespace:
|
||||
"""An admin-plane app client plus its live database.
|
||||
|
||||
The same runner the children tests use (stubbed JWKS, no network);
|
||||
the fixture also exposes the app's ONE database connection so tests
|
||||
can read (or corrupt) the stored settings document itself.
|
||||
can read (or corrupt) the stored settings document itself. The API
|
||||
host's zone is pinned to :data:`API_HOST_ZONE`.
|
||||
"""
|
||||
monkeypatch.setattr(host_zone, "host_timezone", lambda: API_HOST_ZONE)
|
||||
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
|
||||
async with runner as client:
|
||||
yield SimpleNamespace(
|
||||
@@ -105,7 +123,7 @@ async def settings_client(temp_db_path: str) -> SimpleNamespace:
|
||||
async def test_get_settings_returns_defaults_on_a_fresh_database(
|
||||
settings_client: SimpleNamespace,
|
||||
) -> None:
|
||||
"""GET returns the twelve documented fields at their defaults."""
|
||||
"""GET returns the eleven documented fields at their defaults."""
|
||||
response = await settings_client.client.get(
|
||||
"/api/v1/admin/settings", headers=_admin_headers()
|
||||
)
|
||||
@@ -147,7 +165,9 @@ async def test_update_persists_and_the_next_read_reflects_it(
|
||||
SETTINGS_META_KEY
|
||||
)
|
||||
assert raw is not None
|
||||
assert json.loads(raw) == expected
|
||||
stored = dict(expected)
|
||||
del stored["effective_timezone"] # derived on read, never stored
|
||||
assert json.loads(raw) == stored
|
||||
|
||||
|
||||
async def test_partial_update_merges_onto_the_current_settings(
|
||||
@@ -223,27 +243,38 @@ async def test_null_resets_a_field_to_its_default(
|
||||
assert reread.json()["horizon_days"] == 14
|
||||
|
||||
|
||||
async def test_timezone_configured_round_trips_with_an_explicit_empty_zone(
|
||||
async def test_default_settings_carry_no_timezone_configured_marker(
|
||||
settings_client: SimpleNamespace,
|
||||
) -> None:
|
||||
"""An explicit empty (host-local) zone persists as configured.
|
||||
"""The removed auto-set marker is absent from the default response."""
|
||||
response = await settings_client.client.get(
|
||||
"/api/v1/admin/settings", headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "timezone_configured" not in response.json()
|
||||
assert response.json()["timezone"] == ""
|
||||
|
||||
The admin clearing the zone PATCHes ``timezone: ""`` together with
|
||||
``timezone_configured: true``; the NEXT read — a reopened settings
|
||||
screen — sees the marker, so the empty value is a persisted choice
|
||||
rather than a fresh install to auto-set.
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize("zone", ["America/New_York", ""])
|
||||
async def test_manual_timezone_patch_persists_and_is_returned(
|
||||
settings_client: SimpleNamespace, zone: str
|
||||
) -> None:
|
||||
"""An admin's timezone PATCH is stored exactly as given and read back."""
|
||||
client = settings_client.client
|
||||
fresh = await client.get("/api/v1/admin/settings", headers=_admin_headers())
|
||||
assert fresh.json()["timezone_configured"] is False
|
||||
seeded = await client.patch(
|
||||
"/api/v1/admin/settings",
|
||||
json={"timezone": "Europe/Dublin"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert seeded.status_code == 200
|
||||
|
||||
patched = await client.patch(
|
||||
"/api/v1/admin/settings",
|
||||
json={"timezone": "", "timezone_configured": True},
|
||||
json={"timezone": zone},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert patched.status_code == 200
|
||||
expected = {**DEFAULT_SETTINGS, "timezone_configured": True}
|
||||
expected = _with_zone(zone)
|
||||
assert patched.json() == expected
|
||||
|
||||
reread = await client.get(
|
||||
@@ -254,7 +285,61 @@ async def test_timezone_configured_round_trips_with_an_explicit_empty_zone(
|
||||
SETTINGS_META_KEY
|
||||
)
|
||||
assert raw is not None
|
||||
assert json.loads(raw)["timezone_configured"] is True
|
||||
stored = json.loads(raw)
|
||||
assert stored["timezone"] == zone
|
||||
assert "timezone_configured" not in stored
|
||||
|
||||
|
||||
@pytest.mark.parametrize("zone", ["America/Los_Angeles", ""])
|
||||
async def test_timezone_alone_sticks_on_a_fresh_database(
|
||||
settings_client: SimpleNamespace, zone: str
|
||||
) -> None:
|
||||
"""A lone ``timezone`` PATCH needs no other field to persist."""
|
||||
client = settings_client.client
|
||||
patched = await client.patch(
|
||||
"/api/v1/admin/settings",
|
||||
json={"timezone": zone},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert patched.status_code == 200
|
||||
reread = await client.get(
|
||||
"/api/v1/admin/settings", headers=_admin_headers()
|
||||
)
|
||||
assert reread.json() == _with_zone(zone)
|
||||
|
||||
|
||||
async def test_timezone_configured_is_an_unknown_field(
|
||||
settings_client: SimpleNamespace,
|
||||
) -> None:
|
||||
"""The removed marker is rejected like any unknown field; nothing is written."""
|
||||
response = await settings_client.client.patch(
|
||||
"/api/v1/admin/settings",
|
||||
json={"timezone_configured": True},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert "timezone_configured" in str(response.json()["detail"])
|
||||
raw = await _dao_meta.MetaStateDao(settings_client.database).get(
|
||||
SETTINGS_META_KEY
|
||||
)
|
||||
assert raw is None
|
||||
|
||||
|
||||
async def test_stored_legacy_timezone_configured_key_is_ignored(
|
||||
settings_client: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A document written before the marker was removed still loads."""
|
||||
await _dao_meta.MetaStateDao(settings_client.database).set(
|
||||
SETTINGS_META_KEY,
|
||||
json.dumps(
|
||||
{"timezone": "America/Chicago", "timezone_configured": True}
|
||||
),
|
||||
)
|
||||
response = await settings_client.client.get(
|
||||
"/api/v1/admin/settings", headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == _with_zone("America/Chicago")
|
||||
|
||||
|
||||
# --- validation: rejected values never change the stored settings -------
|
||||
@@ -273,8 +358,9 @@ async def test_timezone_configured_round_trips_with_an_explicit_empty_zone(
|
||||
({"morning_summary_enabled": "yes"}, "morning_summary_enabled"),
|
||||
({"celebration_enabled": 1}, "celebration_enabled"),
|
||||
({"notify_target": 5}, "notify_target"),
|
||||
({"timezone_configured": "true"}, "timezone_configured"),
|
||||
({"timezone_configured": 1}, "timezone_configured"),
|
||||
({"timezone": "Mars/Olympus_Mons"}, "timezone"),
|
||||
({"timezone": "America/New York"}, "timezone"),
|
||||
({"timezone": 5}, "timezone"),
|
||||
],
|
||||
)
|
||||
async def test_invalid_value_is_rejected_and_stored_settings_unchanged(
|
||||
@@ -429,6 +515,13 @@ async def test_settings_routes_refuse_non_admin_and_absent_credentials(
|
||||
)
|
||||
assert absent_patch.status_code == 401
|
||||
|
||||
forbidden_zone = await client.patch(
|
||||
"/api/v1/admin/settings",
|
||||
json={"timezone": "America/New_York"},
|
||||
headers={"Authorization": f"Bearer {non_admin}"},
|
||||
)
|
||||
assert forbidden_zone.status_code == 403
|
||||
|
||||
# No refused request changed anything: the settings are untouched.
|
||||
after = await client.get(
|
||||
"/api/v1/admin/settings", headers=_admin_headers()
|
||||
|
||||
@@ -37,7 +37,7 @@ from zoneinfo import ZoneInfo
|
||||
|
||||
import pytest
|
||||
|
||||
from api import routes_admin, routes_panel
|
||||
from api import host_zone, routes_admin, routes_panel
|
||||
from api.nestquest_core import core_snapshot
|
||||
from tests.admin_jwt_harness import (
|
||||
ADMIN_KEY,
|
||||
@@ -101,6 +101,8 @@ async def snapshot_household(
|
||||
|
||||
monkeypatch.setattr(routes_admin, "_local_now", _admin_now)
|
||||
monkeypatch.setattr(routes_panel, "_local_now", lambda: pinned_now)
|
||||
# The API host runs UTC, the zone of the pinned clock.
|
||||
monkeypatch.setattr(host_zone, "host_timezone", lambda: "UTC")
|
||||
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
|
||||
async with runner as client:
|
||||
yield SimpleNamespace(
|
||||
@@ -165,7 +167,10 @@ async def test_admin_jwt_returns_documented_snapshot(
|
||||
|
||||
Every field is asserted as an explicit literal, with ONE clock
|
||||
read for the request. With no missed view in the real snapshot,
|
||||
the admin body equals the panel body for the same household.
|
||||
the admin body equals the panel body for the same household — the
|
||||
panel body additionally carries the effective ``timezone`` its cards
|
||||
format the visible date and clock in (none stored here: the UTC
|
||||
host's own zone).
|
||||
"""
|
||||
client = snapshot_household.client
|
||||
seed = snapshot_household.seed
|
||||
@@ -241,7 +246,7 @@ async def test_admin_jwt_returns_documented_snapshot(
|
||||
"/api/v1/panel/snapshot", headers=_panel_headers()
|
||||
)
|
||||
assert panel.status_code == 200
|
||||
assert panel.json() == body
|
||||
assert panel.json() == {**body, "timezone": "UTC"}
|
||||
|
||||
|
||||
async def test_admin_keeps_missed_instance_panel_omits_it(
|
||||
|
||||
+17
-22
@@ -196,25 +196,27 @@ async def test_complete_instance_posts_body_and_headers() -> None:
|
||||
assert call["headers"]["Authorization"] == f"Bearer {TOKEN}"
|
||||
|
||||
|
||||
async def test_household_timezone_rides_every_request_header() -> None:
|
||||
"""Home Assistant's zone is reported so the API reads the household
|
||||
clock (task 6351b36d); it is sent only when the client has one."""
|
||||
async def test_requests_carry_only_the_bearer_header() -> None:
|
||||
"""No timezone is reported to the API (task 7f685179): the Bearer
|
||||
credential is the only header on every request."""
|
||||
transport = StubTransport(
|
||||
StubResponse(200, json.dumps(SNAPSHOT_PAYLOAD)),
|
||||
StubResponse(200, '{"status": "done"}'),
|
||||
)
|
||||
client = NestQuestApiClient(
|
||||
BASE_URL, TOKEN, transport, household_timezone="America/New_York"
|
||||
)
|
||||
client = make_client(transport)
|
||||
|
||||
await client.get_snapshot()
|
||||
await client.complete_instance(42, 3)
|
||||
|
||||
for call in transport.calls:
|
||||
assert call["headers"] == {
|
||||
"Authorization": f"Bearer {TOKEN}",
|
||||
"X-NestQuest-Timezone": "America/New_York",
|
||||
}
|
||||
assert call["headers"] == {"Authorization": f"Bearer {TOKEN}"}
|
||||
|
||||
|
||||
def test_client_takes_no_household_timezone() -> None:
|
||||
with pytest.raises(TypeError):
|
||||
NestQuestApiClient(
|
||||
BASE_URL, TOKEN, StubTransport(), household_timezone="UTC"
|
||||
)
|
||||
|
||||
|
||||
async def test_base_url_trailing_slash_is_normalized() -> None:
|
||||
@@ -413,20 +415,13 @@ def test_client_from_entry_reads_config_and_shared_session(monkeypatch) -> None:
|
||||
}
|
||||
)
|
||||
|
||||
hass = types.SimpleNamespace(
|
||||
config=types.SimpleNamespace(time_zone="America/New_York")
|
||||
)
|
||||
|
||||
client = api_client.client_from_entry(hass, entry)
|
||||
# A bare object: building the client must not read hass.config.
|
||||
client = api_client.client_from_entry(object(), entry)
|
||||
|
||||
assert client._session is shared_session
|
||||
assert client._base_url == "http://panel.lan:8443"
|
||||
assert client._panel_token == "entry-token"
|
||||
assert client._household_timezone == "America/New_York"
|
||||
|
||||
|
||||
#: A minimal hass stand-in carrying the configured time zone.
|
||||
_HASS_UTC = types.SimpleNamespace(config=types.SimpleNamespace(time_zone="UTC"))
|
||||
assert client._headers() == {"Authorization": "Bearer entry-token"}
|
||||
|
||||
|
||||
def test_client_from_entry_unconfigured_token_raises_at_construction(
|
||||
@@ -449,12 +444,12 @@ def test_client_from_entry_unconfigured_token_raises_at_construction(
|
||||
assert resolve_api_config(entry)[1] == DEFAULT_PANEL_TOKEN == ""
|
||||
|
||||
with pytest.raises(ValueError, match="panel_token must be a non-empty"):
|
||||
api_client.client_from_entry(_HASS_UTC, entry)
|
||||
api_client.client_from_entry(object(), entry)
|
||||
|
||||
# Same for a whitespace-only stored token: whitespace is not a token.
|
||||
entry_ws = make_config_entry(options={CONF_PANEL_TOKEN: " "})
|
||||
with pytest.raises(ValueError, match="panel_token must be a non-empty"):
|
||||
api_client.client_from_entry(_HASS_UTC, entry_ws)
|
||||
api_client.client_from_entry(object(), entry_ws)
|
||||
|
||||
|
||||
async def test_client_logs_nothing() -> None:
|
||||
|
||||
@@ -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"
|
||||
+272
-168
@@ -19,6 +19,8 @@ container's zone) and sets the household zone through the real admin
|
||||
|
||||
An unset zone keeps the historical host-local behaviour, and the
|
||||
missed-sweep scheduler waits for the rollover in household time.
|
||||
Nothing sets the zone automatically: a timezone header on a panel
|
||||
request never changes the stored settings (task 7f685179).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -30,7 +32,7 @@ from zoneinfo import ZoneInfo
|
||||
|
||||
import pytest
|
||||
|
||||
from api import routes_admin, routes_panel
|
||||
from api import host_zone, routes_admin, routes_panel
|
||||
from api import scheduler as scheduler_module
|
||||
from api.database import _executor
|
||||
from api.scheduler import MissedSweepScheduler, _delay_seconds
|
||||
@@ -47,6 +49,7 @@ _settings = importlib.import_module("nestquest_core.settings")
|
||||
_settings_store = importlib.import_module("nestquest_core.settings_store")
|
||||
_core_db = importlib.import_module("nestquest_core.db")
|
||||
_core_migrations = importlib.import_module("nestquest_core.migrations")
|
||||
_dao_meta = importlib.import_module("nestquest_core.dao_meta")
|
||||
|
||||
#: The panel service token the admin harness app is configured with.
|
||||
HARNESS_PANEL_TOKEN = "admin-test-panel-token"
|
||||
@@ -84,7 +87,13 @@ async def household(temp_db_path: str, monkeypatch) -> SimpleNamespace:
|
||||
monkeypatch.setattr(routes_panel, "_local_now", lambda: clock["now"])
|
||||
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
|
||||
async with runner as client:
|
||||
yield SimpleNamespace(client=client, seed=seed, clock=clock)
|
||||
yield SimpleNamespace(
|
||||
client=client,
|
||||
seed=seed,
|
||||
clock=clock,
|
||||
database=runner.app.state.db.database,
|
||||
app=runner.app,
|
||||
)
|
||||
|
||||
|
||||
async def _set_zone(household: SimpleNamespace, zone: str) -> None:
|
||||
@@ -237,23 +246,14 @@ def test_from_options_reads_and_resiliently_defaults_timezone() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_settings_timezone_configured_defaults_false_and_is_strict() -> None:
|
||||
assert _settings.NestQuestSettings().timezone_configured is False
|
||||
assert _settings.NestQuestSettings.from_options({}).timezone_configured is False
|
||||
assert (
|
||||
_settings.NestQuestSettings.from_options(
|
||||
{"timezone_configured": True}
|
||||
).timezone_configured
|
||||
is True
|
||||
)
|
||||
for value in ("true", 1, None):
|
||||
with pytest.raises(ValueError, match="timezone_configured"):
|
||||
_settings.NestQuestSettings(timezone_configured=value)
|
||||
if value is not None:
|
||||
with pytest.raises(ValueError):
|
||||
_settings.NestQuestSettings.from_options(
|
||||
{"timezone_configured": value}
|
||||
)
|
||||
def test_settings_have_no_timezone_configured_marker() -> None:
|
||||
assert not hasattr(_settings.NestQuestSettings(), "timezone_configured")
|
||||
with pytest.raises(TypeError):
|
||||
_settings.NestQuestSettings(timezone_configured=True)
|
||||
# A stored document still carrying the removed key loads harmlessly.
|
||||
assert _settings.NestQuestSettings.from_options_resilient(
|
||||
{"timezone": HOUSEHOLD_ZONE, "timezone_configured": True}
|
||||
) == _settings.NestQuestSettings(timezone=HOUSEHOLD_ZONE)
|
||||
|
||||
|
||||
# --- the missed-sweep scheduler ----------------------------------------------
|
||||
@@ -329,37 +329,29 @@ def test_delay_seconds_is_true_elapsed_time_across_dst(
|
||||
assert _delay_seconds(target, now) == expected
|
||||
|
||||
|
||||
# --- the Home Assistant-reported zone (task 6351b36d) ------------------------
|
||||
# --- nothing sets the zone automatically (task 7f685179) --------------------
|
||||
#
|
||||
# The deployed household never set ``timezone``, so the UTC API host marked
|
||||
# morning quests overdue. The integration reports Home Assistant's
|
||||
# ``time_zone`` on every panel request; while the admin has not chosen a
|
||||
# zone the API adopts it (persisted, so the scheduler and the admin plane
|
||||
# follow too), and an explicit admin choice still wins.
|
||||
# The zone is only ever set by an admin's explicit settings update. A
|
||||
# timezone header on a panel request — the one Home Assistant used to
|
||||
# report, or any look-alike — is ignored, and no read writes settings.
|
||||
|
||||
#: Timezone-looking request headers a panel request might carry.
|
||||
_TIMEZONE_HEADERS = [
|
||||
{"X-NestQuest-Timezone": HOUSEHOLD_ZONE},
|
||||
{"X-NestQuest-Time-Zone": HOUSEHOLD_ZONE},
|
||||
{"X-Timezone": HOUSEHOLD_ZONE},
|
||||
{"Time-Zone": HOUSEHOLD_ZONE},
|
||||
]
|
||||
|
||||
|
||||
def _reporting_panel_headers(zone: str) -> dict[str, str]:
|
||||
return {**_panel_headers(), "X-NestQuest-Timezone": zone}
|
||||
|
||||
|
||||
async def _panel_pack_bag(
|
||||
household: SimpleNamespace, zone: str
|
||||
) -> tuple[str, dict]:
|
||||
response = await household.client.get(
|
||||
"/api/v1/panel/snapshot", headers=_reporting_panel_headers(zone)
|
||||
async def _stored_document(household: SimpleNamespace) -> str | None:
|
||||
"""The raw stored settings document (``None`` when never written)."""
|
||||
return await _dao_meta.MetaStateDao(household.database).get(
|
||||
_settings_store.SETTINGS_META_KEY
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
ada = next(
|
||||
child
|
||||
for child in body["children"]
|
||||
if child["child_id"] == household.seed.ada.id
|
||||
)
|
||||
(instance,) = ada["instances"]
|
||||
return body["today_iso"], instance
|
||||
|
||||
|
||||
async def _stored_settings(household: SimpleNamespace) -> dict:
|
||||
async def _admin_settings(household: SimpleNamespace) -> dict:
|
||||
response = await household.client.get(
|
||||
"/api/v1/admin/settings", headers=_admin_headers()
|
||||
)
|
||||
@@ -367,99 +359,73 @@ async def _stored_settings(household: SimpleNamespace) -> dict:
|
||||
return response.json()
|
||||
|
||||
|
||||
async def test_unset_zone_adopts_reported_zone_for_overdue(
|
||||
household: SimpleNamespace,
|
||||
@pytest.mark.parametrize("extra", _TIMEZONE_HEADERS)
|
||||
async def test_panel_timezone_header_never_changes_settings(
|
||||
household: SimpleNamespace, extra: dict[str, str]
|
||||
) -> None:
|
||||
"""09:59:59 EDT on a UTC host, zone never set: NOT overdue."""
|
||||
"""09:59:59 EDT on a UTC host, zone unset: the header is ignored."""
|
||||
household.clock["now"] = _utc(13, 59, 59)
|
||||
today_iso, instance = await _panel_pack_bag(household, HOUSEHOLD_ZONE)
|
||||
assert today_iso == TODAY.isoformat()
|
||||
assert instance["overdue"] is False
|
||||
# Persisted as the automatic (not admin-chosen) household zone, so
|
||||
# the admin plane and the missed-sweep scheduler read it too.
|
||||
stored = await _stored_settings(household)
|
||||
assert stored["timezone"] == HOUSEHOLD_ZONE
|
||||
assert stored["timezone_configured"] is False
|
||||
_today, admin_instance = await _pack_bag(household, "admin")
|
||||
assert admin_instance["overdue"] is False
|
||||
|
||||
|
||||
async def test_unset_zone_adopts_reported_zone_for_today(
|
||||
household: SimpleNamespace,
|
||||
) -> None:
|
||||
"""23:30 EDT is tomorrow in UTC; the household day is still today."""
|
||||
household.clock["now"] = _utc(3, 30, day=27)
|
||||
today_iso, _instance = await _panel_pack_bag(household, HOUSEHOLD_ZONE)
|
||||
assert today_iso == TODAY.isoformat()
|
||||
|
||||
|
||||
async def test_reported_zone_completion_is_on_time(
|
||||
household: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A 09:30 EDT tap with the zone never set is on time."""
|
||||
household.clock["now"] = _utc(13, 30)
|
||||
headers = {**_panel_headers(), **extra}
|
||||
snapshot = await household.client.get(
|
||||
"/api/v1/panel/snapshot", headers=headers
|
||||
)
|
||||
assert snapshot.status_code == 200
|
||||
seed = household.seed
|
||||
response = await household.client.post(
|
||||
completed = await household.client.post(
|
||||
f"/api/v1/panel/instances/{seed.pack_bag_instance.id}/complete",
|
||||
headers=_reporting_panel_headers(HOUSEHOLD_ZONE),
|
||||
headers=headers,
|
||||
json={"actor_child_id": seed.ada.id},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert completed.status_code == 200
|
||||
# Nothing was written, and the clock stayed host local (UTC): the
|
||||
# 10:00 quest was overdue and the 13:59 UTC completion was late.
|
||||
assert await _stored_document(household) is None
|
||||
assert (await _admin_settings(household))["timezone"] == ""
|
||||
_today_iso, instance = await _pack_bag(household, "admin")
|
||||
assert instance["on_time"] is True
|
||||
assert instance["on_time"] is False
|
||||
|
||||
|
||||
async def test_reported_zone_follows_home_assistant_changes(
|
||||
household: SimpleNamespace,
|
||||
@pytest.mark.parametrize("extra", _TIMEZONE_HEADERS)
|
||||
async def test_panel_timezone_header_never_replaces_the_stored_zone(
|
||||
household: SimpleNamespace, extra: dict[str, str]
|
||||
) -> None:
|
||||
"""An automatically adopted zone tracks a later Home Assistant change."""
|
||||
await _panel_pack_bag(household, "America/Chicago")
|
||||
await _panel_pack_bag(household, HOUSEHOLD_ZONE)
|
||||
assert (await _stored_settings(household))["timezone"] == HOUSEHOLD_ZONE
|
||||
|
||||
|
||||
async def _adopt_new_york(household: SimpleNamespace) -> None:
|
||||
"""Prove automatic adoption first, so later assertions can't pass
|
||||
on code that ignores the reported zone altogether."""
|
||||
await _panel_pack_bag(household, HOUSEHOLD_ZONE)
|
||||
stored = await _stored_settings(household)
|
||||
assert stored["timezone"] == HOUSEHOLD_ZONE
|
||||
assert stored["timezone_configured"] is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("chosen", ["America/Los_Angeles", ""])
|
||||
async def test_admin_chosen_zone_wins_over_reported_zone(
|
||||
household: SimpleNamespace, chosen: str
|
||||
) -> None:
|
||||
await _adopt_new_york(household)
|
||||
response = await household.client.patch(
|
||||
"/api/v1/admin/settings",
|
||||
json={"timezone": chosen, "timezone_configured": True},
|
||||
headers=_admin_headers(),
|
||||
"""A stored zone stays exactly as the admin saved it."""
|
||||
await _set_zone(household, "America/Los_Angeles")
|
||||
before = await _stored_document(household)
|
||||
household.clock["now"] = _utc(16, 59, 59) # 09:59:59 PDT, 12:59:59 EDT
|
||||
response = await household.client.get(
|
||||
"/api/v1/panel/snapshot",
|
||||
headers={**_panel_headers(), **extra},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
# A conflicting report is no longer adopted.
|
||||
await _panel_pack_bag(household, "America/Chicago")
|
||||
stored = await _stored_settings(household)
|
||||
assert stored["timezone"] == chosen
|
||||
assert stored["timezone_configured"] is True
|
||||
assert await _stored_document(household) == before
|
||||
_today_iso, instance = await _pack_bag(household, "panel")
|
||||
assert instance["overdue"] is False
|
||||
|
||||
|
||||
async def test_invalid_reported_zone_is_ignored(
|
||||
async def test_fresh_install_zone_stays_empty_across_reads(
|
||||
household: SimpleNamespace,
|
||||
) -> None:
|
||||
await _adopt_new_york(household)
|
||||
household.clock["now"] = _utc(13, 59, 59)
|
||||
_today_iso, instance = await _panel_pack_bag(household, "Mars/Olympus_Mons")
|
||||
# Still read in the adopted zone: 09:59:59 EDT is not overdue.
|
||||
assert instance["overdue"] is False
|
||||
assert (await _stored_settings(household))["timezone"] == HOUSEHOLD_ZONE
|
||||
"""Repeated panel and admin reads never write the settings document."""
|
||||
for hour in (3, 13, 23):
|
||||
household.clock["now"] = _utc(hour, 0)
|
||||
for extra in _TIMEZONE_HEADERS:
|
||||
response = await household.client.get(
|
||||
"/api/v1/panel/snapshot",
|
||||
headers={**_panel_headers(), **extra},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
await _pack_bag(household, "admin")
|
||||
settings = await _admin_settings(household)
|
||||
assert settings["timezone"] == ""
|
||||
assert "timezone_configured" not in settings
|
||||
assert await _stored_document(household) is None
|
||||
|
||||
|
||||
# --- the scheduler follows an adopted zone (task 6351b36d) -------------------
|
||||
# --- the scheduler follows a changed zone (task 6351b36d) --------------------
|
||||
#
|
||||
# On a UTC host with no stored zone the scheduler first plans for UTC
|
||||
# midnight; a zone adopted while it sleeps must move the sweep to the
|
||||
# midnight; a zone stored while it sleeps must move the sweep to the
|
||||
# household's midnight rather than sweep at 20:00 EDT.
|
||||
|
||||
#: 13:00 UTC (09:00 EDT) to UTC midnight.
|
||||
@@ -468,11 +434,20 @@ _UTC_MIDNIGHT_DELAY = 11 * 3600
|
||||
_NEW_YORK_MIDNIGHT_DELAY = 15 * 3600
|
||||
|
||||
|
||||
async def test_scheduler_replans_when_panel_adopts_zone_while_sleeping(
|
||||
async def _store_zone(scheduler, database, zone: str) -> None:
|
||||
"""Store ``zone`` the way a settings writer must: under the lock."""
|
||||
async with scheduler.settings_lock:
|
||||
settings = await _settings_store.update_settings(
|
||||
database, {"timezone": zone}
|
||||
)
|
||||
scheduler.settings_changed(settings)
|
||||
|
||||
|
||||
async def test_scheduler_replans_when_zone_changes_while_sleeping(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
|
||||
async with runner as client:
|
||||
async with runner:
|
||||
app_state = runner.app.state
|
||||
await app_state.sweep_scheduler.stop()
|
||||
delays: list[float] = []
|
||||
@@ -494,11 +469,9 @@ async def test_scheduler_replans_when_panel_adopts_zone_while_sleeping(
|
||||
await asyncio.wait_for(
|
||||
slept.wait_for(lambda: len(delays) == 1), timeout=5
|
||||
)
|
||||
response = await client.get(
|
||||
"/api/v1/panel/snapshot",
|
||||
headers=_reporting_panel_headers(HOUSEHOLD_ZONE),
|
||||
await _store_zone(
|
||||
scheduler, app_state.db.database, HOUSEHOLD_ZONE
|
||||
)
|
||||
assert response.status_code == 200
|
||||
async with slept:
|
||||
await asyncio.wait_for(
|
||||
slept.wait_for(lambda: len(delays) == 2), timeout=5
|
||||
@@ -536,8 +509,8 @@ async def test_scheduler_rechecks_settings_before_sweeping(
|
||||
if len(delays) == 1:
|
||||
# The UTC rollover "arrives" after the zone was stored
|
||||
# behind the scheduler's back.
|
||||
await _settings_store.adopt_reported_timezone(
|
||||
database, HOUSEHOLD_ZONE
|
||||
await _settings_store.update_settings(
|
||||
database, {"timezone": HOUSEHOLD_ZONE}
|
||||
)
|
||||
return
|
||||
replanned.set()
|
||||
@@ -560,12 +533,152 @@ async def test_scheduler_rechecks_settings_before_sweeping(
|
||||
await database.close()
|
||||
|
||||
|
||||
# --- adoption is serialised with planning and sweeping (TZ-003) --------------
|
||||
# --- the admin settings route is the scheduler's writer (task 7e173881) ------
|
||||
#
|
||||
# An adoption racing the loop's own settings reads must never leave a
|
||||
# ``PATCH /api/v1/admin/settings`` is the only settings writer: it holds
|
||||
# the scheduler's settings lock across the write and tells the scheduler,
|
||||
# so a saved zone or rollover re-plans a sleeping scheduler.
|
||||
|
||||
|
||||
async def _sleeping_scheduler(household: SimpleNamespace):
|
||||
"""Swap in a scheduler on the pinned clock; return it and its delays.
|
||||
|
||||
Waits until it sleeps on its first plan.
|
||||
"""
|
||||
await household.app.state.sweep_scheduler.stop()
|
||||
delays: list[float] = []
|
||||
slept = asyncio.Condition()
|
||||
|
||||
async def sleep_stub(seconds: float) -> None:
|
||||
async with slept:
|
||||
delays.append(seconds)
|
||||
slept.notify_all()
|
||||
await asyncio.Event().wait()
|
||||
|
||||
scheduler = MissedSweepScheduler(
|
||||
household.database,
|
||||
clock=lambda: household.clock["now"],
|
||||
sleep=sleep_stub,
|
||||
)
|
||||
household.app.state.sweep_scheduler = scheduler
|
||||
scheduler.start()
|
||||
|
||||
async def wait_for_sleeps(count: int) -> None:
|
||||
async with slept:
|
||||
await asyncio.wait_for(
|
||||
slept.wait_for(lambda: len(delays) == count), timeout=5
|
||||
)
|
||||
|
||||
await wait_for_sleeps(1)
|
||||
return scheduler, delays, wait_for_sleeps
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("changes", "replanned_delay"),
|
||||
[
|
||||
# 13:00 UTC is 09:00 EDT: New York midnight is 15 hours away.
|
||||
({"timezone": HOUSEHOLD_ZONE}, _NEW_YORK_MIDNIGHT_DELAY),
|
||||
# Still UTC: 13:00 to a 14:30 rollover is 90 minutes.
|
||||
({"day_rollover_time": "14:30"}, 90 * 60),
|
||||
],
|
||||
ids=["timezone", "day_rollover_time"],
|
||||
)
|
||||
async def test_admin_settings_patch_replans_a_sleeping_scheduler(
|
||||
household: SimpleNamespace, changes: dict, replanned_delay: float
|
||||
) -> None:
|
||||
household.clock["now"] = _utc(13, 0)
|
||||
scheduler, delays, wait_for_sleeps = await _sleeping_scheduler(household)
|
||||
try:
|
||||
response = await household.client.patch(
|
||||
"/api/v1/admin/settings", json=changes, headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 200
|
||||
await wait_for_sleeps(2)
|
||||
finally:
|
||||
await scheduler.stop()
|
||||
assert delays == [
|
||||
pytest.approx(_UTC_MIDNIGHT_DELAY),
|
||||
pytest.approx(replanned_delay),
|
||||
]
|
||||
|
||||
|
||||
async def test_admin_saved_zone_is_authoritative_across_surfaces(
|
||||
household: SimpleNamespace,
|
||||
) -> None:
|
||||
"""ONE instant, read by every surface in exactly the admin's zone.
|
||||
|
||||
Asia/Kolkata (UTC+05:30) matches neither the UTC host nor any other
|
||||
zone in play. 04:30:01 UTC is 10:00:01 there: the 10:00 quest is
|
||||
overdue on both snapshots (it would not be in UTC, 04:30), the
|
||||
household day is the 26th, and the scheduler sleeps the 299 s to a
|
||||
10:05 rollover (UTC would be 5h35m).
|
||||
"""
|
||||
household.clock["now"] = _utc(4, 30, 1)
|
||||
scheduler, delays, wait_for_sleeps = await _sleeping_scheduler(household)
|
||||
try:
|
||||
response = await household.client.patch(
|
||||
"/api/v1/admin/settings",
|
||||
json={"timezone": "Asia/Kolkata", "day_rollover_time": "10:05"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
await wait_for_sleeps(2)
|
||||
for plane in ("panel", "admin"):
|
||||
today_iso, instance = await _pack_bag(household, plane)
|
||||
assert today_iso == TODAY.isoformat(), plane
|
||||
assert instance["overdue"] is True, plane
|
||||
finally:
|
||||
await scheduler.stop()
|
||||
assert delays[1] == pytest.approx(299)
|
||||
|
||||
|
||||
async def test_empty_zone_publishes_the_api_host_zone_to_panel_and_admin(
|
||||
household: SimpleNamespace, monkeypatch
|
||||
) -> None:
|
||||
"""With no zone stored, the API reads the host's local time — and the
|
||||
panel snapshot and the admin settings both publish that host's own
|
||||
zone, so the cards and the admin app format in the API's zone, never
|
||||
Home Assistant's or the browser's.
|
||||
|
||||
The host runs Pacific/Auckland: 12:30 UTC on the 26th is already
|
||||
00:30 on the 27th there, so the API's household day is the 27th while
|
||||
a UTC Home Assistant or browser would still read the 26th. Once the
|
||||
admin saves a zone, both publish exactly that zone.
|
||||
"""
|
||||
host = "Pacific/Auckland"
|
||||
monkeypatch.setattr(host_zone, "host_timezone", lambda: host)
|
||||
household.clock["now"] = _utc(12, 30).astimezone(ZoneInfo(host))
|
||||
|
||||
panel = await household.client.get(
|
||||
"/api/v1/panel/snapshot", headers=_panel_headers()
|
||||
)
|
||||
assert panel.status_code == 200
|
||||
settings = await _admin_settings(household)
|
||||
assert settings["timezone"] == ""
|
||||
assert panel.json()["timezone"] == settings["effective_timezone"] == host
|
||||
# The published zone reproduces the API's own household day.
|
||||
assert panel.json()["today_iso"] == "2026-09-27"
|
||||
assert (
|
||||
_utc(12, 30).astimezone(ZoneInfo(host)).date().isoformat()
|
||||
== panel.json()["today_iso"]
|
||||
)
|
||||
|
||||
await _set_zone(household, "America/Chicago")
|
||||
panel = await household.client.get(
|
||||
"/api/v1/panel/snapshot", headers=_panel_headers()
|
||||
)
|
||||
settings = await _admin_settings(household)
|
||||
assert settings["timezone"] == "America/Chicago"
|
||||
assert panel.json()["timezone"] == settings["effective_timezone"]
|
||||
assert settings["effective_timezone"] == "America/Chicago"
|
||||
|
||||
|
||||
# --- settings writes are serialised with planning and sweeping (TZ-003) ------
|
||||
#
|
||||
# A zone write racing the loop's own settings reads must never leave a
|
||||
# stale plan behind. Each test holds the loop at one of its reads and
|
||||
# issues a real panel adoption there; when the scheduler's settings lock
|
||||
# is held at that point the adoption provably cannot commit until the
|
||||
# issues a locked zone write there; when the scheduler's settings lock
|
||||
# is held at that point the write provably cannot commit until the
|
||||
# loop moves on, otherwise it is awaited to commit inside the window.
|
||||
|
||||
|
||||
@@ -588,24 +701,12 @@ def _hold_scheduler_read(monkeypatch, scheduler, nth: int, during):
|
||||
return real_load
|
||||
|
||||
|
||||
async def _adopt_via(client, zone: str) -> None:
|
||||
response = await client.get(
|
||||
"/api/v1/panel/snapshot", headers=_reporting_panel_headers(zone)
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
async def _adopt_inside_read(client, scheduler, zone: str) -> asyncio.Task:
|
||||
"""Start a panel adoption of ``zone``; let it commit unless serialised."""
|
||||
adoption = asyncio.create_task(
|
||||
client.get(
|
||||
"/api/v1/panel/snapshot", headers=_reporting_panel_headers(zone)
|
||||
)
|
||||
)
|
||||
lock = getattr(scheduler, "settings_lock", None)
|
||||
if lock is None or not lock.locked():
|
||||
await adoption
|
||||
return adoption
|
||||
async def _store_inside_read(scheduler, database, zone: str) -> asyncio.Task:
|
||||
"""Start a locked write of ``zone``; let it commit unless serialised."""
|
||||
write = asyncio.create_task(_store_zone(scheduler, database, zone))
|
||||
if not scheduler.settings_lock.locked():
|
||||
await write
|
||||
return write
|
||||
|
||||
|
||||
async def test_scheduler_follows_successive_zone_changes(
|
||||
@@ -613,8 +714,9 @@ async def test_scheduler_follows_successive_zone_changes(
|
||||
) -> None:
|
||||
"""New York -> UTC -> New York while the re-plan read is in flight."""
|
||||
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
|
||||
async with runner as client:
|
||||
async with runner:
|
||||
app_state = runner.app.state
|
||||
database = app_state.db.database
|
||||
await app_state.sweep_scheduler.stop()
|
||||
delays: list[float] = []
|
||||
slept = asyncio.Condition()
|
||||
@@ -626,15 +728,15 @@ async def test_scheduler_follows_successive_zone_changes(
|
||||
await asyncio.Event().wait()
|
||||
|
||||
scheduler = MissedSweepScheduler(
|
||||
app_state.db.database, clock=lambda: _utc(13, 0), sleep=sleep_stub
|
||||
database, clock=lambda: _utc(13, 0), sleep=sleep_stub
|
||||
)
|
||||
app_state.sweep_scheduler = scheduler
|
||||
await _adopt_via(client, HOUSEHOLD_ZONE)
|
||||
adoptions: list[asyncio.Task] = []
|
||||
await _store_zone(scheduler, database, HOUSEHOLD_ZONE)
|
||||
writes: list[asyncio.Task] = []
|
||||
|
||||
async def back_to_new_york(_settings) -> None:
|
||||
adoptions.append(
|
||||
await _adopt_inside_read(client, scheduler, HOUSEHOLD_ZONE)
|
||||
writes.append(
|
||||
await _store_inside_read(scheduler, database, HOUSEHOLD_ZONE)
|
||||
)
|
||||
|
||||
# Read 1 plans New York; read 2 is the re-plan after UTC.
|
||||
@@ -645,16 +747,16 @@ async def test_scheduler_follows_successive_zone_changes(
|
||||
await asyncio.wait_for(
|
||||
slept.wait_for(lambda: len(delays) == 1), timeout=5
|
||||
)
|
||||
await _adopt_via(client, "UTC")
|
||||
await _store_zone(scheduler, database, "UTC")
|
||||
async with slept:
|
||||
await asyncio.wait_for(
|
||||
slept.wait_for(lambda: len(delays) == 3), timeout=5
|
||||
)
|
||||
(adoption,) = adoptions
|
||||
assert (await adoption).status_code == 200
|
||||
(write,) = writes
|
||||
await write
|
||||
finally:
|
||||
await scheduler.stop()
|
||||
# The in-flight read planned UTC; the New York adoption that followed
|
||||
# The in-flight read planned UTC; the New York write that followed
|
||||
# it woke the wait, so the scheduler sleeps on New York midnight.
|
||||
assert delays == [
|
||||
pytest.approx(_NEW_YORK_MIDNIGHT_DELAY),
|
||||
@@ -663,12 +765,12 @@ async def test_scheduler_follows_successive_zone_changes(
|
||||
]
|
||||
|
||||
|
||||
async def test_adoption_after_presweep_check_cannot_precede_the_sweep(
|
||||
async def test_zone_write_after_presweep_check_cannot_precede_the_sweep(
|
||||
temp_db_path: str, monkeypatch
|
||||
) -> None:
|
||||
"""No sweep runs once an adoption has replaced its plan."""
|
||||
"""No sweep runs once a zone write has replaced its plan."""
|
||||
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
|
||||
async with runner as client:
|
||||
async with runner:
|
||||
app_state = runner.app.state
|
||||
await app_state.sweep_scheduler.stop()
|
||||
delays: list[float] = []
|
||||
@@ -685,16 +787,18 @@ async def test_adoption_after_presweep_check_cannot_precede_the_sweep(
|
||||
app_state.db.database, clock=lambda: _utc(13, 0), sleep=sleep_stub
|
||||
)
|
||||
app_state.sweep_scheduler = scheduler
|
||||
adoptions: list[asyncio.Task] = []
|
||||
writes: list[asyncio.Task] = []
|
||||
|
||||
async def adopt_new_york(_settings) -> None:
|
||||
adoptions.append(
|
||||
await _adopt_inside_read(client, scheduler, HOUSEHOLD_ZONE)
|
||||
async def store_new_york(_settings) -> None:
|
||||
writes.append(
|
||||
await _store_inside_read(
|
||||
scheduler, app_state.db.database, HOUSEHOLD_ZONE
|
||||
)
|
||||
)
|
||||
|
||||
# Read 1 plans UTC; read 2 is the pre-sweep re-check.
|
||||
real_load = _hold_scheduler_read(
|
||||
monkeypatch, scheduler, 2, adopt_new_york
|
||||
monkeypatch, scheduler, 2, store_new_york
|
||||
)
|
||||
swept_under: list[str] = []
|
||||
|
||||
@@ -708,12 +812,12 @@ async def test_adoption_after_presweep_check_cannot_precede_the_sweep(
|
||||
scheduler.start()
|
||||
try:
|
||||
await asyncio.wait_for(replanned.wait(), timeout=5)
|
||||
(adoption,) = adoptions
|
||||
assert (await adoption).status_code == 200
|
||||
(write,) = writes
|
||||
await write
|
||||
finally:
|
||||
await scheduler.stop()
|
||||
# The sweep ran for the UTC plan with UTC still stored, never after
|
||||
# New York had been adopted; the next plan is New York midnight.
|
||||
# New York had been stored; the next plan is New York midnight.
|
||||
assert swept_under == [""]
|
||||
assert delays == [
|
||||
pytest.approx(_UTC_MIDNIGHT_DELAY),
|
||||
|
||||
@@ -60,6 +60,7 @@ def _payload(children) -> dict:
|
||||
return {
|
||||
"today_iso": "2026-09-23",
|
||||
"cycle_day": 0,
|
||||
"timezone": "",
|
||||
"children": list(children),
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ def _empty_snapshot() -> dict:
|
||||
return {
|
||||
"today_iso": "2026-09-23",
|
||||
"cycle_day": 0,
|
||||
"timezone": "",
|
||||
"children": [],
|
||||
}
|
||||
|
||||
|
||||
@@ -834,7 +834,7 @@ def test_complete_instance_no_due_time_after_due_date_is_late(
|
||||
|
||||
|
||||
def test_complete_instance_ha_local_wall_clock_not_utc(tmp_path) -> None:
|
||||
"""HA-local 11:00 with due_time 12:00 is on time even when UTC is 19:00.
|
||||
"""Household-local 11:00, due 12:00, is on time even when UTC is 19:00.
|
||||
|
||||
The helper's zone is UTC-8, so 11:00 local is 19:00 UTC. Converting
|
||||
to UTC before comparing would mark this late.
|
||||
|
||||
@@ -93,6 +93,7 @@ class FakeMonotonic:
|
||||
FIXTURE_SNAPSHOT = {
|
||||
"today_iso": _today_iso(),
|
||||
"cycle_day": 3,
|
||||
"timezone": "",
|
||||
"children": [
|
||||
{
|
||||
"child_id": 1,
|
||||
@@ -686,8 +687,8 @@ async def test_coordinator_configured_token_builds_real_client(
|
||||
hass, entry, session_factory=_session_factory
|
||||
)
|
||||
assert isinstance(real_client, NestQuestApiClient)
|
||||
# Home Assistant's zone is reported to the API (task 6351b36d).
|
||||
assert real_client._household_timezone == hass.config.time_zone
|
||||
# Home Assistant's zone is not reported to the API (task 7f685179).
|
||||
assert real_client._headers() == {"Authorization": "Bearer tok"}
|
||||
|
||||
set_coordinator_client(entry, real_client)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
@@ -793,8 +794,37 @@ async def test_snapshot_from_api_payload_empty_household() -> None:
|
||||
"""An empty household (no children) is a valid snapshot: empty
|
||||
children tuple, cycle day passes through."""
|
||||
snapshot = snapshot_from_api_payload(
|
||||
{"today_iso": "2026-09-23", "cycle_day": 0, "children": []}
|
||||
{
|
||||
"today_iso": "2026-09-23",
|
||||
"cycle_day": 0,
|
||||
"timezone": "",
|
||||
"children": [],
|
||||
}
|
||||
)
|
||||
assert snapshot.children == ()
|
||||
assert snapshot.cycle_day == 0
|
||||
assert snapshot.today_iso == "2026-09-23"
|
||||
|
||||
|
||||
#: The panel snapshot exactly as an API from before the ``timezone`` field
|
||||
#: serves it: an integration upgraded ahead of its API still reads this.
|
||||
LEGACY_SNAPSHOT = {
|
||||
key: value for key, value in FIXTURE_SNAPSHOT.items() if key != "timezone"
|
||||
}
|
||||
|
||||
|
||||
async def test_coordinator_reads_a_legacy_payload_without_timezone(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""An older API omits ``timezone``: every refresh still builds the
|
||||
snapshot (the cards then fall back to Home Assistant's zone)."""
|
||||
assert "timezone" not in LEGACY_SNAPSHOT
|
||||
client = StubSnapshotClient(LEGACY_SNAPSHOT)
|
||||
_entry, coordinator = await _wire_entry(hass, make_entry, client)
|
||||
assert coordinator.last_update_success is True
|
||||
|
||||
snapshot = await coordinator._async_update_data()
|
||||
assert snapshot.timezone == ""
|
||||
assert snapshot.cycle_day == 3
|
||||
assert [child.child_name for child in snapshot.children] == ["Ada", "Bo"]
|
||||
assert snapshot_from_api_payload(LEGACY_SNAPSHOT).timezone == ""
|
||||
|
||||
@@ -136,7 +136,7 @@ async def test_build_snapshot_explicit_values(hass, make_entry, tmp_path) -> Non
|
||||
|
||||
# Single clock read for the seed/snapshot anchor (no midnight skew);
|
||||
# the coordinator no longer owns a clock read (the API serves the
|
||||
# snapshot), so the test resolves the HA-local zone directly.
|
||||
# snapshot), so the test resolves its own local zone directly.
|
||||
time_zone = ZoneInfo(hass.config.time_zone)
|
||||
today = datetime.datetime.now(time_zone).date()
|
||||
# Pin the build instant at 12:00 local: past Ada's 10:00 due time
|
||||
@@ -332,6 +332,7 @@ async def test_build_snapshot_explicit_values(hass, make_entry, tmp_path) -> Non
|
||||
return {
|
||||
"today_iso": built.today_iso,
|
||||
"cycle_day": built.cycle_day,
|
||||
"timezone": built.timezone,
|
||||
"children": [
|
||||
{
|
||||
"child_id": child.child_id,
|
||||
|
||||
@@ -67,6 +67,7 @@ def _payload(children) -> dict:
|
||||
return {
|
||||
"today_iso": "2026-09-23",
|
||||
"cycle_day": 0,
|
||||
"timezone": "",
|
||||
"children": list(children),
|
||||
}
|
||||
|
||||
|
||||
@@ -76,6 +76,7 @@ def _payload(children, cycle_day=0) -> dict:
|
||||
return {
|
||||
"today_iso": "2026-09-23",
|
||||
"cycle_day": cycle_day,
|
||||
"timezone": "",
|
||||
"children": list(children),
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ def _payload(children) -> dict:
|
||||
return {
|
||||
"today_iso": "2026-09-23",
|
||||
"cycle_day": 0,
|
||||
"timezone": "",
|
||||
"children": list(children),
|
||||
}
|
||||
|
||||
|
||||
@@ -940,14 +940,15 @@ def test_materialize_rejects_malformed_or_inverted_bounds(tmp_path) -> None:
|
||||
|
||||
|
||||
def test_materialize_accepts_ha_local_today_behind_host(tmp_path) -> None:
|
||||
"""The clamp and no-past guard compare against a caller-supplied HA-local today.
|
||||
"""The clamp and no-past guard compare against a caller-supplied today.
|
||||
|
||||
A household time zone behind the host clock around midnight resolves a
|
||||
HA-local "today" one calendar day earlier than the host's
|
||||
household-local "today" one calendar day earlier than the host's
|
||||
``date.today()``. The walk clamps its start to that anchor: without
|
||||
a pinned HA-local date the effective start is the host's today (one
|
||||
day after ha_today), so the now-past day is skipped rather than
|
||||
rejected, and only the host-today-onward days materialize.
|
||||
a pinned household-local date the effective start is the host's
|
||||
today (one day after ha_today), so the now-past day is skipped
|
||||
rather than rejected, and only the host-today-onward days
|
||||
materialize.
|
||||
"""
|
||||
async def _body(database):
|
||||
children = ChildrenDao(database)
|
||||
@@ -963,9 +964,9 @@ def test_materialize_accepts_ha_local_today_behind_host(tmp_path) -> None:
|
||||
["morning"],
|
||||
)
|
||||
|
||||
# Without a pinned HA-local today, the walk clamps its start to the
|
||||
# host's today (one day after ha_today); the past day is dropped and
|
||||
# the 14 host-today-onward days materialize.
|
||||
# Without a pinned household-local today, the walk clamps its start
|
||||
# to the host's today (one day after ha_today); the past day is
|
||||
# dropped and the 14 host-today-onward days materialize.
|
||||
without_pin = await materialize(database, start_iso, end_iso)
|
||||
assert without_pin == 14
|
||||
|
||||
|
||||
+169
-3
@@ -21,6 +21,7 @@ end-to-end through the quest-log harness.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
@@ -566,6 +567,8 @@ def _render_party_board(
|
||||
states: dict,
|
||||
url: str | None = None,
|
||||
click_plate: str | None = None,
|
||||
now: str | None = None,
|
||||
tz: str | None = None,
|
||||
) -> dict:
|
||||
"""Render the built bundle's party board and return its plates.
|
||||
|
||||
@@ -574,7 +577,9 @@ def _render_party_board(
|
||||
mounts the card with the given config and hass snapshot, optionally
|
||||
taps the named plate, and prints the ordered plates its shadow DOM
|
||||
renders, which of them are tappable buttons, and the pathname after
|
||||
the optional tap.
|
||||
the optional tap. ``now`` pins the card's clock to an ISO instant
|
||||
and ``tz`` sets the node process's ``TZ`` — the panel browser's
|
||||
local zone.
|
||||
"""
|
||||
assert BUNDLE_PATH.is_file(), "bundle missing: run cd frontend && npm run build"
|
||||
assert RENDER_HARNESS.is_file()
|
||||
@@ -588,12 +593,15 @@ def _render_party_board(
|
||||
spec["url"] = url
|
||||
if click_plate is not None:
|
||||
spec["click_plate"] = click_plate
|
||||
if now is not None:
|
||||
spec["now"] = now
|
||||
completed = subprocess.run(
|
||||
["node", str(RENDER_HARNESS)],
|
||||
input=json.dumps(spec),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
env={**os.environ, "TZ": tz} if tz is not None else None,
|
||||
)
|
||||
assert completed.returncode == 0, (
|
||||
f"render harness failed: {completed.stderr}"
|
||||
@@ -831,6 +839,9 @@ def _render_quest_log(
|
||||
probe_ms: int | None = None,
|
||||
tap_complete: int | None = None,
|
||||
tap_return: bool = False,
|
||||
now: str | None = None,
|
||||
advance_to: list[str] | None = None,
|
||||
tz: str | None = None,
|
||||
) -> dict:
|
||||
"""Render the built bundle's quest log and report its location.
|
||||
|
||||
@@ -840,7 +851,10 @@ def _render_quest_log(
|
||||
segment — waits the optional probe window and captures one snapshot
|
||||
of the render mid-wait, then the optional idle window, and prints
|
||||
the final pathname and rendered title (plus the probe snapshot when
|
||||
``probe_ms`` was given).
|
||||
``probe_ms`` was given). ``now`` pins the card's clock to an ISO
|
||||
instant, ``advance_to`` moves it through further instants firing the
|
||||
card's clock tick (one snapshot each, under ``ticks``), and ``tz``
|
||||
sets the node process's ``TZ`` — the panel browser's local zone.
|
||||
"""
|
||||
assert BUNDLE_PATH.is_file(), "bundle missing: run cd frontend && npm run build"
|
||||
assert QUEST_LOG_HARNESS.is_file()
|
||||
@@ -858,12 +872,17 @@ def _render_quest_log(
|
||||
spec["tap_complete"] = tap_complete
|
||||
if tap_return:
|
||||
spec["tap_return"] = True
|
||||
if now is not None:
|
||||
spec["now"] = now
|
||||
if advance_to is not None:
|
||||
spec["advance_to"] = advance_to
|
||||
completed = subprocess.run(
|
||||
["node", str(QUEST_LOG_HARNESS)],
|
||||
input=json.dumps(spec),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
env={**os.environ, "TZ": tz} if tz is not None else None,
|
||||
)
|
||||
assert completed.returncode == 0, (
|
||||
f"quest-log harness failed: {completed.stderr}"
|
||||
@@ -1090,7 +1109,7 @@ def test_quest_log_meta_line_keeps_its_meta_class() -> None:
|
||||
``meta ${late ? "late" : nothing}``, Lit dropped the whole class
|
||||
attribute for every quest that was not late, leaving the due line
|
||||
unstyled. The rendered markup must carry ``class="meta"`` on time and
|
||||
``class="meta late"`` overdue."""
|
||||
``class="meta late"`` overdue — overdue as the API snapshot says."""
|
||||
generated = _generate_dashboard(
|
||||
{}, _three_child_states(), url="http://homeassistant.local/nestquest"
|
||||
)
|
||||
@@ -1143,6 +1162,153 @@ def test_quest_log_meta_line_keeps_its_meta_class() -> None:
|
||||
assert metas["42"].strip() == 'class="meta late"', metas["42"]
|
||||
|
||||
|
||||
def _quest_metas(html: str) -> dict[str, tuple[str, str]]:
|
||||
"""Map each rendered open quest's instance id to its meta line's
|
||||
attributes and text."""
|
||||
metas = {}
|
||||
for card in html.split('data-instance-id="')[1:]:
|
||||
meta = re.search(
|
||||
r'<span class="quest-title">.*?</span>'
|
||||
r"\s*(?:<!--[^>]*-->\s*)*<span([^>]*)>(.*?)</span>",
|
||||
card,
|
||||
re.S,
|
||||
)
|
||||
assert meta is not None, card[:800]
|
||||
text = " ".join(re.sub(r"<!--[^>]*-->", "", meta.group(2)).split())
|
||||
metas[card[: card.index('"')]] = (meta.group(1).strip(), text)
|
||||
return metas
|
||||
|
||||
|
||||
def test_quest_log_overdue_follows_the_api_snapshot_not_the_panel_clock() -> None:
|
||||
"""The stored application timezone is the single source of local-time
|
||||
truth: the API computes each instance's ``overdue`` in that zone and
|
||||
the card renders exactly that, whatever the panel browser's own clock
|
||||
or zone says. Here the browser runs in Pacific/Auckland and its clock
|
||||
is pinned (and later ticked) to instants where every due time has, or
|
||||
has not yet, arrived on its local wall clock — the card must still
|
||||
show the snapshot's state: the ``overdue: true`` 08:15 quest reads
|
||||
OVERDUE even though the browser reads long before 08:15, and the
|
||||
``overdue: false`` 07:00 quest stays due even though the browser reads
|
||||
long after 07:00."""
|
||||
generated = _generate_dashboard(
|
||||
{}, _three_child_states(), url="http://homeassistant.local/nestquest"
|
||||
)
|
||||
|
||||
def quest(instance_id: int, title: str, due_time: str, overdue: bool) -> dict:
|
||||
return {
|
||||
"id": instance_id,
|
||||
"definition_id": instance_id,
|
||||
"child_id": 1,
|
||||
"title": title,
|
||||
"icon": None,
|
||||
"window": "morning",
|
||||
"due_time": due_time,
|
||||
"state": "open",
|
||||
"overdue": overdue,
|
||||
"completed_at": None,
|
||||
"on_time": None,
|
||||
}
|
||||
|
||||
states = {
|
||||
**_three_child_states(),
|
||||
"sensor.nestquest_ada_quests_due_today": _state(
|
||||
"2",
|
||||
child_name="Ada",
|
||||
child_id=1,
|
||||
present=True,
|
||||
instances=[
|
||||
quest(42, "Brush Teeth", "08:15", overdue=True),
|
||||
quest(43, "Feed Fish", "07:00", overdue=False),
|
||||
],
|
||||
),
|
||||
}
|
||||
# 2026-09-29T17:00Z is 06:00 NZDT on the 30th: before both due times.
|
||||
# 2026-09-29T23:00Z is 12:00 NZDT: after both.
|
||||
result = _render_quest_log(
|
||||
generated["views"][1]["cards"][0],
|
||||
states,
|
||||
url="http://homeassistant.local/nestquest/ada",
|
||||
now="2026-09-29T17:00:00Z",
|
||||
advance_to=["2026-09-29T23:00:00Z"],
|
||||
tz="Pacific/Auckland",
|
||||
)
|
||||
overdue = ('class="meta late"', "Overdue · due 8:15 AM")
|
||||
due_by = ('class="meta"', "Due by 7:00 AM")
|
||||
before, (after,) = (
|
||||
_quest_metas(result["html"]),
|
||||
[_quest_metas(tick["html"]) for tick in result["ticks"]],
|
||||
)
|
||||
assert before == {"42": overdue, "43": due_by}, before
|
||||
assert after == {"42": overdue, "43": due_by}, after
|
||||
|
||||
|
||||
def test_panel_date_and_clock_follow_the_stored_application_timezone() -> None:
|
||||
"""The stored application timezone is the single source of local-time
|
||||
truth for the panel's visible date and clock too: the household rollup
|
||||
publishes it (``timezone`` — the API's effective zone) and both cards
|
||||
format in it — the party
|
||||
board's kicker and dock date and clock, the quest log's header date —
|
||||
even though Home Assistant's zone and the panel browser's zone are both
|
||||
UTC. At 2026-09-29T23:30Z it is still Tuesday 11:30 PM in UTC but
|
||||
already Wednesday 12:30 PM in Pacific/Auckland (NZDT)."""
|
||||
generated = _generate_dashboard(
|
||||
{}, _three_child_states(), url="http://homeassistant.local/nestquest"
|
||||
)
|
||||
states = _three_child_states()
|
||||
states["sensor.nestquest_household_quests_due_today"]["attributes"][
|
||||
"timezone"
|
||||
] = "Pacific/Auckland"
|
||||
now = "2026-09-29T23:30:00Z"
|
||||
|
||||
def text(html: str) -> str:
|
||||
return re.sub(r"<!--.*?-->", "", html).replace("\u202f", " ")
|
||||
|
||||
def board_date_and_clock(html: str) -> tuple[str, str, str]:
|
||||
found = re.search(
|
||||
r'<p class="kicker">The Party · (.*?)</p>.*?'
|
||||
r'<span class="date">(.*?)</span>.*?<span class="clock">(.*?)</span>',
|
||||
text(html),
|
||||
re.DOTALL,
|
||||
)
|
||||
assert found is not None, html
|
||||
return found.group(1), found.group(2), found.group(3)
|
||||
|
||||
board = _render_party_board(
|
||||
generated["views"][0]["cards"][0], states, now=now, tz="UTC"
|
||||
)["html"]
|
||||
assert board_date_and_clock(board) == (
|
||||
"Wednesday, Sep 30",
|
||||
"Wednesday, Sep 30",
|
||||
"12:30 PM",
|
||||
)
|
||||
|
||||
log = _render_quest_log(
|
||||
generated["views"][1]["cards"][0],
|
||||
states,
|
||||
url="http://homeassistant.local/nestquest/ada",
|
||||
now=now,
|
||||
tz="UTC",
|
||||
)["html"]
|
||||
sub = re.search(r'<p class="sub">\s*(.*?) ·', text(log), re.DOTALL)
|
||||
assert sub is not None, log
|
||||
assert sub.group(1) == "Wednesday, Sep 30"
|
||||
|
||||
# With no zone stored the API publishes its host's own zone, so the
|
||||
# rollup always carries one; only a rollup without it (an API older
|
||||
# than the field) falls back to Home Assistant's zone (UTC here).
|
||||
del states["sensor.nestquest_household_quests_due_today"]["attributes"][
|
||||
"timezone"
|
||||
]
|
||||
fallback = _render_party_board(
|
||||
generated["views"][0]["cards"][0], states, now=now, tz="UTC"
|
||||
)["html"]
|
||||
assert board_date_and_clock(fallback) == (
|
||||
"Tuesday, Sep 29",
|
||||
"Tuesday, Sep 29",
|
||||
"11:30 PM",
|
||||
)
|
||||
|
||||
|
||||
def test_quest_log_crest_matches_the_spec_geometry() -> None:
|
||||
"""PANEL-SPEC §3 header crest: 96x110 shield, 4px ring of
|
||||
rgba(255,255,255,.22) around the --nq-p-crest face, 44px/700 display
|
||||
|
||||
@@ -76,6 +76,7 @@ def _payload(children) -> dict:
|
||||
return {
|
||||
"today_iso": _today_iso(),
|
||||
"cycle_day": 0,
|
||||
"timezone": "",
|
||||
"children": list(children),
|
||||
}
|
||||
|
||||
@@ -569,6 +570,22 @@ async def test_household_due_sensor_publishes_the_ordered_child_roster(
|
||||
}
|
||||
|
||||
|
||||
async def test_household_due_sensor_publishes_the_stored_zone(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""The rollup carries the snapshot's stored application zone verbatim
|
||||
(the panel cards format their date and clock in it); ``""`` — no zone
|
||||
stored — passes through unchanged for the cards' documented fallback."""
|
||||
zoned = {**_seeded_payload(), "timezone": "Pacific/Auckland"}
|
||||
entry, coordinator, _client = await _setup_entry(
|
||||
hass, make_entry, zoned, _seeded_payload()
|
||||
)
|
||||
rollup = hass.entities["nestquest_household_quests_due_today"]
|
||||
assert rollup.extra_state_attributes["timezone"] == "Pacific/Auckland"
|
||||
await coordinator.async_refresh()
|
||||
assert rollup.extra_state_attributes["timezone"] == ""
|
||||
|
||||
|
||||
async def test_household_roster_tracks_the_snapshot_on_refresh(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
|
||||
@@ -151,7 +151,12 @@ DOCUMENTED_FIELDS = {
|
||||
|
||||
#: A minimal panel snapshot payload for the full-setup wiring tests
|
||||
#: (the shape ``snapshot_from_api_payload`` consumes; no children).
|
||||
MINIMAL_SNAPSHOT = {"today_iso": "2026-09-23", "cycle_day": 1, "children": []}
|
||||
MINIMAL_SNAPSHOT = {
|
||||
"today_iso": "2026-09-23",
|
||||
"cycle_day": 1,
|
||||
"timezone": "",
|
||||
"children": [],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -351,24 +356,6 @@ async def test_stream_events_yields_documented_frames_and_sends_bearer() -> None
|
||||
assert call["json"] is None
|
||||
|
||||
|
||||
async def test_stream_events_reports_household_timezone() -> None:
|
||||
"""The SSE GET carries Home Assistant's zone beside the Bearer token
|
||||
(task 6351b36d), like the snapshot and completion requests."""
|
||||
frame = _frame_text(*DOCUMENTED_FRAMES[0])
|
||||
transport = _StreamTransport(_StreamingResponse(200, [frame]))
|
||||
client = NestQuestApiClient(
|
||||
BASE_URL, TOKEN, transport, household_timezone="America/New_York"
|
||||
)
|
||||
|
||||
frames = [frame async for frame in client.stream_events()]
|
||||
|
||||
assert frames == [DOCUMENTED_FRAMES[0]]
|
||||
assert transport.calls[0]["headers"] == {
|
||||
"Authorization": f"Bearer {TOKEN}",
|
||||
"X-NestQuest-Timezone": "America/New_York",
|
||||
}
|
||||
|
||||
|
||||
async def test_stream_events_buffers_lines_across_chunks_and_joins_data() -> None:
|
||||
"""Chunk-split lines parse, and multi-line data joins per SSE rules."""
|
||||
first_type, first_payload = DOCUMENTED_FRAMES[0]
|
||||
|
||||
Reference in New Issue
Block a user