Release: manual, admin-authoritative timezone (0.9.8) (2026-09-30) #287

Merged
overseer merged 6 commits from release/2026-09-30-manual-timezone into main 2026-09-30 14:46:00 -04:00
67 changed files with 2247 additions and 1066 deletions
+64
View File
@@ -1,5 +1,69 @@
# NestQuest Release Notes
## Version 0.9.8 — 2026-09-30
### Scope
Owner-directed change: the application's timezone is now **manual-only and
admin-authoritative**. Nothing sets it automatically any more; an admin sets it
from the admin app, and the saved value is the single source of local time for
the whole application. This reverses the automatic Home Assistant zone adoption
shipped in 0.9.6 and the admin app's browser-zone auto-set, and supersedes
decision D-018 with D-019. (PRs #282, #283, #285, #286; feature
`a3cd8e27` "Manual application timezone control".)
### Behaviour
- **No automatic timezone setting.** Removed: the API's adoption of Home
Assistant's reported zone (the `X-NestQuest-Timezone` request header and the
`adopt_reported_timezone` path), the integration's reporting of
`hass.config.time_zone`, the admin app's browser-zone auto-detect and
automatic save, and the `timezone_configured` marker.
- **The admin sets it manually**, in the admin app's Preferences: a curated IANA
zone picker plus a free "Other IANA zone" entry, with an explicit choice for
empty ("API host local time"). Nothing is auto-filled or auto-saved.
- **The saved value is authoritative across the whole application**: the panel
snapshot's overdue/today derivation, both panel cards' dates and clocks,
completion timestamps, the missed-sweep scheduler, and the admin reads all use
it. The API publishes the effective zone (`api/host_zone.effective_timezone`:
the saved value, else the API host's own zone) on the panel snapshot and the
admin settings response, so the panel and admin app render in the same zone.
- **The kids' panel now follows the application zone, not the browser.** This
supersedes the panel-local overdue behaviour from 0.9.7; a quest is overdue
when the application clock says so, not when the panel device's clock does.
- **An admin save of the timezone or the day-rollover time re-plans the running
missed-sweep scheduler.**
- The integration tolerates an API snapshot without the timezone field, so the
integration and the API may be upgraded in either order.
### Requirements
- Home Assistant floor 2024.6.0 (unchanged).
### Breaking changes
- None to the database. The schema is unchanged at version 9: no migration and
no database backup is required for this release.
- Behavioural reversal: if a timezone had been auto-set by 0.9.6–0.9.7, the
stored value persists after upgrading (it is simply no longer changed
automatically), so no action is needed unless a different zone is wanted.
- With no timezone saved, the API host's local zone applies (UTC on the standard
deployment). Set the timezone in the admin Preferences for correct
household-local behaviour. A previously auto-set value counts as saved and
will be kept.
### Known limitations
- If no timezone has been saved, the API host zone applies; on the standard
UTC-host deployment, overdue and day boundaries will be wrong until an admin
sets the zone.
- The admin app's timezone picker replaces any zone outside its curated list via
the "Other IANA zone" entry; the value is validated as an IANA name.
### Rollback
- No schema change: rolling back to 0.9.7 requires no database restore.
## Version 0.9.7 — 2026-09-29
### Scope
+50 -7
View File
@@ -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
View File
@@ -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) => (
+4 -5
View File
@@ -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";
+56 -1
View File
@@ -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");
});
});
+20 -22
View File
@@ -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.
+115
View File
@@ -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",
]);
});
});
+11 -7
View File
@@ -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
View File
@@ -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) {
+54 -1
View File
@@ -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");
});
});
+6 -3
View File
@@ -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);
-7
View File
@@ -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);
+6
View File
@@ -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);
+235 -82
View File
@@ -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");
});
});
+58 -60
View File
@@ -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&apos;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({
+93
View File
@@ -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);
}
+60
View File
@@ -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");
});
});
+81
View File
@@ -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);
}
+60
View File
@@ -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();
});
});
+3 -1
View File
@@ -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>
+10 -4
View File
@@ -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),
});
}
+62
View File
@@ -0,0 +1,62 @@
"""The API's effective application timezone (task 7e173881).
The admin-stored ``timezone`` setting is the single source of local-time
truth; stored empty, the API reads its clock in the API host's local
time. :func:`effective_timezone` names the zone the API actually uses —
the stored zone, or else the host's own IANA zone — so the panel cards
and the admin app format local time in exactly the API's zone instead of
guessing with Home Assistant's or the browser's. ``""`` only when the
host's zone genuinely cannot be named.
"""
from __future__ import annotations
import os
from collections.abc import Mapping
from pathlib import Path
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
def _valid_zone(name: str) -> str:
"""Return ``name`` if it is a loadable IANA zone key, else ``""``."""
name = name.strip().removeprefix(":")
if not name:
return ""
try:
ZoneInfo(name)
except (ZoneInfoNotFoundError, ValueError):
return ""
return name
def host_timezone(
environ: Mapping[str, str] = os.environ,
timezone_file: Path = Path("/etc/timezone"),
localtime: Path = Path("/etc/localtime"),
) -> str:
"""Name the API host's local zone as an IANA key, or ``""``.
In the order the C library honours them: the ``TZ`` environment
variable, then the ``/etc/localtime`` zoneinfo link (with Debian's
``/etc/timezone`` naming it when the link is a copy). A host with
none of them configured runs UTC. A setting that names no IANA
zone (a POSIX rule string, a copied file) is ``""``.
"""
configured = environ.get("TZ")
if configured is not None:
# An empty TZ is UTC to the C library.
return _valid_zone(configured) if configured.strip() else "UTC"
if localtime.is_symlink():
target = str(localtime.resolve())
marker = "/zoneinfo/"
if marker in target:
return _valid_zone(target.split(marker, 1)[1])
if timezone_file.is_file():
return _valid_zone(timezone_file.read_text(encoding="utf-8"))
if not localtime.exists():
return "UTC"
return ""
def effective_timezone(stored: str) -> str:
"""The zone the API reads its clock in: ``stored``, else the host's."""
return stored or host_timezone()
+37 -18
View File
@@ -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
View File
@@ -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
View File
@@ -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(
+4 -21
View File
@@ -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.
+1 -1
View File
@@ -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
+5 -4
View File
@@ -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
+11 -11
View File
@@ -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.
+7 -16
View File
@@ -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.
+9 -8
View File
@@ -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
+2 -2
View File
@@ -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.
+6 -20
View File
@@ -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,15 +176,10 @@ 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.
Supplying ``timezone`` without ``timezone_configured`` records the
zone as the admin's explicit choice (``timezone_configured`` is
persisted ``True`` in the same write); an explicitly supplied
``timezone_configured`` is kept exactly as given.
"""
if not isinstance(changes, Mapping):
raise ValueError(
@@ -204,37 +195,11 @@ async def update_settings(
raise ValueError(
f"unknown settings field(s): {listed}; known fields: {known}"
)
if "timezone" in changes and "timezone_configured" not in changes:
# An admin-supplied zone is an explicit choice, so Home
# Assistant's reported zone must no longer replace it.
changes = {**changes, "timezone_configured": True}
async with _settings_lock(database):
current = await load_settings(database)
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,
+8 -2
View File
@@ -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,
)
+7 -6
View File
@@ -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
+1 -1
View File
@@ -15,5 +15,5 @@
"iot_class": "local_polling",
"issue_tracker": "https://github.com/Maniacs-Mansion/NestQuest/issues",
"requirements": [],
"version": "0.9.7"
"version": "0.9.8"
}
+4
View File
@@ -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,
}
File diff suppressed because it is too large Load Diff
+32
View File
@@ -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
+11 -1
View File
@@ -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 {
+15 -27
View File
@@ -36,6 +36,7 @@ type PanelInstance = {
window: string;
due_time: string | null;
state: string;
overdue: boolean;
completed_at: string | null;
on_time: boolean | null;
};
@@ -153,17 +154,6 @@ function formatWallClock(value: string | null): string | null {
return `${hour12}:${match[2]} ${suffix}`;
}
/** Minutes past midnight of a household ``HH:MM`` wall time, or null. */
function wallClockMinutes(value: string | null): number | null {
const match = value ? /^(\d{1,2}):(\d{2})$/.exec(value.trim()) : null;
if (!match) {
return null;
}
const hours = Number(match[1]);
const minutes = Number(match[2]);
return hours > 23 || minutes > 59 ? null : hours * 60 + minutes;
}
function asString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
@@ -263,6 +253,7 @@ function toPanelInstance(value: unknown): PanelInstance | null {
window: asString(row.window).toLowerCase(),
due_time: asString(row.due_time) || null,
state,
overdue: row.overdue === true,
completed_at: asString(row.completed_at) || null,
on_time: row.on_time === true ? true : row.on_time === false ? false : null,
};
@@ -1758,22 +1749,18 @@ export class NestQuestQuestLogCard extends LitElement {
`;
}
/** Overdue by the panel's own clock: the browser runs on the
household's real local time, so an open quest turns overdue once its
``due_time`` wall time has arrived there — whatever zone the API or
Home Assistant computed the API's ``overdue`` flag in. With no due
time it is never overdue (the API's rule too). ``_now`` is reactive
and ticks every 60 s, so the line flips on the minute it falls due. */
private _isOverdue(instance: PanelInstance): boolean {
const due = wallClockMinutes(instance.due_time);
if (instance.state !== "open" || due === null) {
return false;
}
return this._now.getHours() * 60 + this._now.getMinutes() >= due;
}
/** 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 {
@@ -1856,6 +1843,7 @@ export class NestQuestQuestLogCard extends LitElement {
continue;
}
instance.state = "completed";
instance.overdue = false;
instance.completed_at = stampedAt;
}
if (settled) {
@@ -2152,7 +2140,7 @@ export class NestQuestQuestLogCard extends LitElement {
`;
}
const dueAt = formatWallClock(instance.due_time);
const meta = this._isOverdue(instance)
const meta = instance.overdue
? {
text: dueAt ? `Overdue · due ${dueAt}` : "Overdue",
late: true,
+22 -3
View File
@@ -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);
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "nestquest"
version = "0.9.7"
version = "0.9.8"
description = "NestQuest Home Assistant Integration"
readme = "README.md"
requires-python = ">=3.11"
+3 -2
View File
@@ -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
+115 -22
View File
@@ -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()
+8 -3
View File
@@ -37,7 +37,7 @@ from zoneinfo import ZoneInfo
import pytest
from api import routes_admin, routes_panel
from api import host_zone, routes_admin, routes_panel
from api.nestquest_core import core_snapshot
from tests.admin_jwt_harness import (
ADMIN_KEY,
@@ -101,6 +101,8 @@ async def snapshot_household(
monkeypatch.setattr(routes_admin, "_local_now", _admin_now)
monkeypatch.setattr(routes_panel, "_local_now", lambda: pinned_now)
# The API host runs UTC, the zone of the pinned clock.
monkeypatch.setattr(host_zone, "host_timezone", lambda: "UTC")
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
async with runner as client:
yield SimpleNamespace(
@@ -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
View File
@@ -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:
+71
View File
@@ -0,0 +1,71 @@
"""The API host's zone and the effective application timezone (task 7e173881).
With no zone stored the API reads its clock in the host's local time, so
:func:`api.host_zone.effective_timezone` names that host zone — resolved
the way the C library resolves local time — for the panel and admin app
to format in. Every host configuration here is a temporary file tree.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from api import host_zone
def _host(tmp_path: Path, *, link: str | None = None, name: str | None = None):
"""A fake ``/etc``: ``localtime`` linked into a zoneinfo tree at
``link``, and/or an ``/etc/timezone`` file holding ``name``."""
localtime = tmp_path / "etc" / "localtime"
timezone_file = tmp_path / "etc" / "timezone"
localtime.parent.mkdir(parents=True)
if link is not None:
target = tmp_path / "usr" / "share" / "zoneinfo" / link
target.parent.mkdir(parents=True)
target.write_bytes(b"TZif")
localtime.symlink_to(target)
if name is not None:
timezone_file.write_text(f"{name}\n", encoding="utf-8")
return {"timezone_file": timezone_file, "localtime": localtime}
def test_tz_environment_variable_wins(tmp_path: Path) -> None:
paths = _host(tmp_path, link="Europe/Paris")
assert host_zone.host_timezone({"TZ": "Asia/Kolkata"}, **paths) == "Asia/Kolkata"
assert host_zone.host_timezone({"TZ": ":Asia/Kolkata"}, **paths) == "Asia/Kolkata"
assert host_zone.host_timezone({"TZ": ""}, **paths) == "UTC"
def test_localtime_link_names_the_zone(tmp_path: Path) -> None:
paths = _host(tmp_path, link="America/Argentina/Buenos_Aires")
assert host_zone.host_timezone({}, **paths) == "America/Argentina/Buenos_Aires"
def test_etc_timezone_names_a_copied_localtime(tmp_path: Path) -> None:
paths = _host(tmp_path, name="Pacific/Auckland")
paths["localtime"].write_bytes(b"TZif")
assert host_zone.host_timezone({}, **paths) == "Pacific/Auckland"
def test_unconfigured_host_runs_utc(tmp_path: Path) -> None:
assert host_zone.host_timezone({}, **_host(tmp_path)) == "UTC"
@pytest.mark.parametrize("tz", ["EST+5", "Not/AZone", ":/etc/localtime"])
def test_an_unnamed_host_zone_is_empty(tmp_path: Path, tz: str) -> None:
assert host_zone.host_timezone({"TZ": tz}, **_host(tmp_path)) == ""
def test_unnamed_copied_localtime_is_empty(tmp_path: Path) -> None:
paths = _host(tmp_path)
paths["localtime"].write_bytes(b"TZif")
assert host_zone.host_timezone({}, **paths) == ""
def test_effective_timezone_is_the_stored_zone_else_the_host_zone(
monkeypatch,
) -> None:
monkeypatch.setattr(host_zone, "host_timezone", lambda: "Europe/Dublin")
assert host_zone.effective_timezone("America/Chicago") == "America/Chicago"
assert host_zone.effective_timezone("") == "Europe/Dublin"
+275 -209
View File
@@ -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,137 +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
# --- a timezone-only admin update is an explicit choice (task 45243478) ------
@pytest.mark.parametrize("chosen", ["America/Los_Angeles", ""])
async def test_timezone_only_admin_update_is_an_explicit_choice(
household: SimpleNamespace, chosen: str
) -> None:
"""``PATCH {"timezone": ...}`` alone marks the zone admin-chosen."""
await _adopt_new_york(household)
response = await household.client.patch(
"/api/v1/admin/settings",
json={"timezone": chosen},
headers=_admin_headers(),
)
assert response.status_code == 200
assert response.json()["timezone_configured"] is True
# 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
async def test_explicit_timezone_configured_false_is_respected(
household: SimpleNamespace,
) -> None:
"""A supplied ``timezone_configured`` is persisted exactly as given."""
response = await household.client.patch(
"/api/v1/admin/settings",
json={"timezone": "America/Chicago", "timezone_configured": False},
headers=_admin_headers(),
)
assert response.status_code == 200
assert response.json()["timezone_configured"] is False
await _panel_pack_bag(household, HOUSEHOLD_ZONE)
assert (await _stored_settings(household))["timezone"] == HOUSEHOLD_ZONE
async def test_invalid_reported_zone_is_ignored(
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 await _stored_document(household) == before
_today_iso, instance = await _pack_bag(household, "panel")
assert instance["overdue"] is False
assert (await _stored_settings(household))["timezone"] == HOUSEHOLD_ZONE
# --- the scheduler follows an adopted zone (task 6351b36d) -------------------
async def test_fresh_install_zone_stays_empty_across_reads(
household: SimpleNamespace,
) -> None:
"""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 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.
@@ -506,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] = []
@@ -532,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
@@ -574,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()
@@ -598,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.
@@ -626,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(
@@ -651,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()
@@ -664,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.
@@ -683,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),
@@ -701,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] = []
@@ -723,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] = []
@@ -746,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),
+1
View File
@@ -60,6 +60,7 @@ def _payload(children) -> dict:
return {
"today_iso": "2026-09-23",
"cycle_day": 0,
"timezone": "",
"children": list(children),
}
+1
View File
@@ -55,6 +55,7 @@ def _empty_snapshot() -> dict:
return {
"today_iso": "2026-09-23",
"cycle_day": 0,
"timezone": "",
"children": [],
}
+1 -1
View File
@@ -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.
+33 -3
View File
@@ -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 == ""
+2 -1
View File
@@ -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,
+1
View File
@@ -67,6 +67,7 @@ def _payload(children) -> dict:
return {
"today_iso": "2026-09-23",
"cycle_day": 0,
"timezone": "",
"children": list(children),
}
+1
View File
@@ -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),
}
+1
View File
@@ -70,6 +70,7 @@ def _payload(children) -> dict:
return {
"today_iso": "2026-09-23",
"cycle_day": 0,
"timezone": "",
"children": list(children),
}
+9 -8
View File
@@ -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
+106 -32
View File
@@ -567,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.
@@ -575,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()
@@ -589,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}"
@@ -1102,13 +1109,12 @@ 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 — overdue by the panel's pinned clock
(07:15 local: the 07:00 quest is late, the 08:15 one is not)."""
``class="meta late"`` overdue — overdue as the API snapshot says."""
generated = _generate_dashboard(
{}, _three_child_states(), url="http://homeassistant.local/nestquest"
)
def quest(instance_id: int, title: str, due_time: str) -> dict:
def quest(instance_id: int, title: str, overdue: bool) -> dict:
return {
"id": instance_id,
"definition_id": instance_id,
@@ -1116,9 +1122,9 @@ def test_quest_log_meta_line_keeps_its_meta_class() -> None:
"title": title,
"icon": None,
"window": "morning",
"due_time": due_time,
"due_time": "08:15",
"state": "open",
"overdue": False,
"overdue": overdue,
"completed_at": None,
"on_time": None,
}
@@ -1131,8 +1137,8 @@ def test_quest_log_meta_line_keeps_its_meta_class() -> None:
child_id=1,
present=True,
instances=[
quest(41, "Make Bed", "08:15"),
quest(42, "Brush Teeth", "07:00"),
quest(41, "Make Bed", overdue=False),
quest(42, "Brush Teeth", overdue=True),
],
),
}
@@ -1140,8 +1146,6 @@ def test_quest_log_meta_line_keeps_its_meta_class() -> None:
generated["views"][1]["cards"][0],
states,
url="http://homeassistant.local/nestquest/ada",
now="2026-09-29T11:15:00Z",
tz="America/New_York",
)
metas = {}
for card in result["html"].split('data-instance-id="')[1:]:
@@ -1175,19 +1179,22 @@ def _quest_metas(html: str) -> dict[str, tuple[str, str]]:
return metas
def test_quest_log_overdue_follows_the_panel_local_clock() -> None:
"""Overdue is decided by the panel's own local clock, not the API's
``overdue`` flag. The owner saw 8:15 AM quests read OVERDUE at 7:15 AM
America/New_York: the API's flag had been computed on a UTC wall clock
(11:15). Here the API still says ``overdue: true`` and Home
Assistant reports ``time_zone`` UTC, yet at 07:15 local the 08:15 quest
is due, at 08:15 (and 08:16, via the card's own clock tick) it is
overdue, and a quest with no due time is never overdue."""
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 | None) -> dict:
def quest(instance_id: int, title: str, due_time: str, overdue: bool) -> dict:
return {
"id": instance_id,
"definition_id": instance_id,
@@ -1197,7 +1204,7 @@ def test_quest_log_overdue_follows_the_panel_local_clock() -> None:
"window": "morning",
"due_time": due_time,
"state": "open",
"overdue": True,
"overdue": overdue,
"completed_at": None,
"on_time": None,
}
@@ -1210,29 +1217,96 @@ def test_quest_log_overdue_follows_the_panel_local_clock() -> None:
child_id=1,
present=True,
instances=[
quest(42, "Brush Teeth", "08:15"),
quest(43, "Feed Fish", None),
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-29T11:15:00Z",
advance_to=["2026-09-29T12:15:00Z", "2026-09-29T12:16:00Z"],
tz="America/New_York",
now="2026-09-29T17:00:00Z",
advance_to=["2026-09-29T23:00:00Z"],
tz="Pacific/Auckland",
)
due_by = ('class="meta"', "Due by 8:15 AM")
overdue = ('class="meta late"', "Overdue · due 8:15 AM")
not_due = ('class="meta"', "Due today")
at_0715, at_0815, at_0816 = (
due_by = ('class="meta"', "Due by 7:00 AM")
before, (after,) = (
_quest_metas(result["html"]),
*(_quest_metas(tick["html"]) for tick in result["ticks"]),
[_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",
)
assert at_0715 == {"42": due_by, "43": not_due}, at_0715
assert at_0815 == {"42": overdue, "43": not_due}, at_0815
assert at_0816 == {"42": overdue, "43": not_due}, at_0816
def test_quest_log_crest_matches_the_spec_geometry() -> None:
+17
View File
@@ -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:
+6 -19
View File
@@ -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]
Generated
+1 -1
View File
@@ -373,7 +373,7 @@ wheels = [
[[package]]
name = "nestquest"
version = "0.9.7"
version = "0.9.8"
source = { virtual = "." }
[package.dev-dependencies]