Manual timezone is the single source of local-time truth (task 7e173881) (#285)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com> Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
This commit was merged in pull request #285.
This commit is contained in:
+49
-6
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,6 +33,8 @@ export default function App() {
|
||||
role="tabpanel"
|
||||
aria-labelledby={`tab-${active}`}
|
||||
>
|
||||
{/* Every screen's local time is the stored application timezone. */}
|
||||
<AppTimezoneProvider>
|
||||
{active === "today" ? (
|
||||
<TodayTab />
|
||||
) : active === "tasks" ? (
|
||||
@@ -41,6 +44,7 @@ export default function App() {
|
||||
) : (
|
||||
<HistoryTab />
|
||||
)}
|
||||
</AppTimezoneProvider>
|
||||
</main>
|
||||
<nav className="admin-tabbar" aria-label="Admin sections">
|
||||
{TABS.map((tab) => (
|
||||
|
||||
@@ -19,10 +19,15 @@ export interface HouseholdSettings {
|
||||
celebration_enabled: boolean;
|
||||
/** IANA zone name (e.g. "America/New_York"); "" means the API host's local time. */
|
||||
timezone: string;
|
||||
/**
|
||||
* Read-only: the zone the API reads its clock in — `timezone`, or the API
|
||||
* host's own zone when that is "" ("" only when the host's zone has no name).
|
||||
*/
|
||||
effective_timezone: string;
|
||||
}
|
||||
|
||||
/** Only the supplied fields are updated; the API answers with all of them. */
|
||||
export type SettingsChanges = Partial<HouseholdSettings>;
|
||||
export type SettingsChanges = Partial<Omit<HouseholdSettings, "effective_timezone">>;
|
||||
|
||||
export const SETTINGS_PATH = "/api/v1/admin/settings";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import DefinitionsTab from "./DefinitionsTab";
|
||||
import {
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "./definitionIcons";
|
||||
import type { AdminChild, DefinitionRule, QuestDefinition } from "../api/definitions";
|
||||
import { storeTokens } from "../auth/oidc";
|
||||
import { AppTimezoneProvider } from "../time/AppTimezone";
|
||||
|
||||
function child(id: number, name: string, overrides: Partial<AdminChild> = {}): AdminChild {
|
||||
return {
|
||||
@@ -1723,3 +1724,57 @@ describe("DefinitionsTab start date", () => {
|
||||
expect(within(s).getByText("Pick a start date.")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DefinitionsTab start date in the stored application timezone", () => {
|
||||
async function newSheetStartDate(
|
||||
timezone: string,
|
||||
effective_timezone = timezone,
|
||||
): Promise<string> {
|
||||
api = fakeApi([BRUSH]);
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn((input: RequestInfo | URL, init?: RequestInit) =>
|
||||
String(input).endsWith("/api/v1/admin/settings")
|
||||
? Promise.resolve(jsonResponse({ timezone, effective_timezone }))
|
||||
: api.fetchMock(input, init),
|
||||
),
|
||||
);
|
||||
render(
|
||||
<AppTimezoneProvider>
|
||||
<DefinitionsTab />
|
||||
</AppTimezoneProvider>,
|
||||
);
|
||||
await screen.findByText("Brush teeth");
|
||||
fireEvent.click(screen.getByRole("button", { name: "New" }));
|
||||
return startDateInput(sheet()).value;
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubEnv("TZ", "UTC");
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
// Wed 23 Sep 13:00 UTC is already Thu 24 Sep 01:00 in Auckland.
|
||||
vi.useFakeTimers({ toFake: ["Date"], now: new Date("2026-09-23T13:00:00Z") });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("a new task defaults to today in the stored zone", async () => {
|
||||
expect(await newSheetStartDate("Pacific/Auckland")).toBe("2026-09-24");
|
||||
});
|
||||
|
||||
it("an empty stored zone defaults to today in the API host's published zone", async () => {
|
||||
expect(await newSheetStartDate("", "Pacific/Auckland")).toBe("2026-09-24");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,6 +57,9 @@ import {
|
||||
normalizeDefinitionIconName,
|
||||
} from "./definitionIcons";
|
||||
import { GlyphSvg } from "../icons/GlyphSvg";
|
||||
import { weekdayOf } from "../schedule/cycle";
|
||||
import { useAppTimezone } from "../time/AppTimezone";
|
||||
import { todayIn } from "../time/zone";
|
||||
import "./DefinitionsTab.css";
|
||||
|
||||
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
|
||||
@@ -121,20 +124,13 @@ type LoadState =
|
||||
/** `null` = closed, `"new"` = creating, a definition = editing it. */
|
||||
type SheetTarget = null | "new" | QuestDefinition;
|
||||
|
||||
function pad2(n: number): string {
|
||||
return String(n).padStart(2, "0");
|
||||
}
|
||||
|
||||
function localIsoDate(date: Date): string {
|
||||
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
|
||||
}
|
||||
|
||||
function emptyDueTimes(): Record<WindowName, string> {
|
||||
return { morning: "", afternoon: "", evening: "" };
|
||||
}
|
||||
|
||||
function newDraft(activeChildren: AdminChild[]): Draft {
|
||||
const today = new Date();
|
||||
/** Draft defaults take `today` ("YYYY-MM-DD") in the application timezone. */
|
||||
function newDraft(activeChildren: AdminChild[], today: string): Draft {
|
||||
const [, month, day] = today.split("-").map(Number);
|
||||
return {
|
||||
title: "",
|
||||
icon: null,
|
||||
@@ -142,22 +138,22 @@ function newDraft(activeChildren: AdminChild[]): Draft {
|
||||
assigneeIds: activeChildren.length === 1 ? [activeChildren[0].id] : [],
|
||||
repeats: "daily",
|
||||
interval: "1",
|
||||
weekdays: [(today.getDay() + 6) % 7],
|
||||
dayOfMonth: String(today.getDate()),
|
||||
month: String(today.getMonth() + 1),
|
||||
weekdays: [weekdayOf(today)],
|
||||
dayOfMonth: String(day),
|
||||
month: String(month),
|
||||
monthlyWeekday: null,
|
||||
windows: [],
|
||||
dueTimes: emptyDueTimes(),
|
||||
// Mirrors the API's create default: no instance on an away day.
|
||||
skipOnAway: true,
|
||||
startDate: localIsoDate(today),
|
||||
startDate: today,
|
||||
endDate: null,
|
||||
};
|
||||
}
|
||||
|
||||
function draftFrom(definition: QuestDefinition, activeChildren: AdminChild[]): Draft {
|
||||
function draftFrom(definition: QuestDefinition, activeChildren: AdminChild[], today: string): Draft {
|
||||
const { rule } = definition;
|
||||
const today = new Date();
|
||||
const [, month, day] = today.split("-").map(Number);
|
||||
const active = new Set(activeChildren.map((child) => child.id));
|
||||
const dueTimes = emptyDueTimes();
|
||||
for (const w of definition.windows) dueTimes[w.window] = w.due_time ?? "";
|
||||
@@ -172,9 +168,9 @@ function draftFrom(definition: QuestDefinition, activeChildren: AdminChild[]): D
|
||||
weekdays:
|
||||
rule.rule_type === "weekly" || rule.rule_type === "custom_days"
|
||||
? [...(rule.weekday_set ?? [])]
|
||||
: [(today.getDay() + 6) % 7],
|
||||
dayOfMonth: String(rule.day_of_month ?? today.getDate()),
|
||||
month: String(rule.month ?? today.getMonth() + 1),
|
||||
: [weekdayOf(today)],
|
||||
dayOfMonth: String(rule.day_of_month ?? day),
|
||||
month: String(rule.month ?? month),
|
||||
monthlyWeekday:
|
||||
rule.rule_type === "monthly_weekday"
|
||||
? { nth: rule.nth_weekday ?? 1, weekday: rule.nth_weekday_weekday ?? 0 }
|
||||
@@ -465,9 +461,11 @@ function EditSheet({
|
||||
onSaved: () => void;
|
||||
onDeleted: (result: DefinitionDeleteResult) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<Draft>(() =>
|
||||
target === "new" ? newDraft(activeChildren) : draftFrom(target, activeChildren),
|
||||
);
|
||||
const timeZone = useAppTimezone();
|
||||
const [draft, setDraft] = useState<Draft>(() => {
|
||||
const today = todayIn(timeZone);
|
||||
return target === "new" ? newDraft(activeChildren, today) : draftFrom(target, activeChildren, today);
|
||||
});
|
||||
// The active selection as loaded: while it is unchanged, the PATCH omits
|
||||
// assignee_child_ids, because the API replaces the whole set and inactive
|
||||
// children cannot be sent back.
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { HistoryFilter, HistoryRow } from "../api/history";
|
||||
import { storeTokens } from "../auth/oidc";
|
||||
import { TransitionsProvider } from "../api/useTransitions";
|
||||
import { frame, testStream, type TestStream } from "../api/testStream";
|
||||
import { AppTimezoneProvider } from "../time/AppTimezone";
|
||||
|
||||
const TODAY = "2026-09-23";
|
||||
|
||||
@@ -629,3 +630,117 @@ describe("HistoryTab live updates", () => {
|
||||
expect(screen.getAllByTestId("history-row")).toHaveLength(ROWS.all.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HistoryTab in the stored application timezone", () => {
|
||||
// 13:00 UTC on Sep 23 is 01:00 on Sep 24 in Auckland (NZST, UTC+12).
|
||||
const NOW = new Date("2026-09-23T13:00:00Z");
|
||||
// Due 00:10 Auckland on Sep 24, done 00:30 Auckland (12:30 UTC on Sep 23).
|
||||
const LATE_NZ = row({
|
||||
instance_id: 7,
|
||||
quest_title: "Feed cat",
|
||||
due_date: "2026-09-24",
|
||||
due_time: "00:10",
|
||||
occurred_at: "2026-09-23T12:30:00Z",
|
||||
was_on_time: false,
|
||||
});
|
||||
// 22:00 Auckland on Sep 23; 10:00 UTC.
|
||||
const EVENING_NZ = row({ instance_id: 8, occurred_at: "2026-09-23T10:00:00Z" });
|
||||
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
function historyRequests(): URL[] {
|
||||
return fetchMock.mock.calls
|
||||
.map(([url]) => new URL(String(url), "http://x"))
|
||||
.filter((url) => url.pathname === "/api/v1/admin/history");
|
||||
}
|
||||
|
||||
async function renderWithZone(timezone: string, effective_timezone = timezone) {
|
||||
fetchMock = vi.fn((url: string) => {
|
||||
const parsed = new URL(url, "http://x");
|
||||
if (parsed.pathname.endsWith("/api/v1/admin/settings")) {
|
||||
return Promise.resolve(jsonResponse({ timezone, effective_timezone }));
|
||||
}
|
||||
const filter = parsed.searchParams.get("filter");
|
||||
const rows = filter === "all" ? [EVENING_NZ, LATE_NZ] : [];
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
filter,
|
||||
start: parsed.searchParams.get("start"),
|
||||
end: parsed.searchParams.get("end"),
|
||||
count: rows.length,
|
||||
rows,
|
||||
}),
|
||||
);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
render(
|
||||
<AppTimezoneProvider>
|
||||
<HistoryTab />
|
||||
</AppTimezoneProvider>,
|
||||
);
|
||||
await screen.findByTestId("stat-on-time");
|
||||
}
|
||||
|
||||
function times(section: HTMLElement): string[] {
|
||||
return Array.from(section.querySelectorAll(".history-event-time")).map(
|
||||
(el) => el.textContent ?? "",
|
||||
);
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubEnv("TZ", "UTC");
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
vi.useFakeTimers({ toFake: ["Date"], now: NOW });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["the stored zone", "Pacific/Auckland"],
|
||||
["the API host's published zone when none is stored", ""],
|
||||
])("today, day grouping, clock and lateness follow %s", async (_label, stored) => {
|
||||
await renderWithZone(stored, "Pacific/Auckland");
|
||||
|
||||
for (const url of historyRequests()) {
|
||||
expect(url.searchParams.get("end")).toBe("2026-09-24");
|
||||
expect(url.searchParams.get("start")).toBe("2026-09-11");
|
||||
}
|
||||
const today = screen.getByTestId("day-2026-09-24");
|
||||
expect(within(today).getByText("Today")).toBeTruthy();
|
||||
expect(times(today)).toEqual(["12:30 AM"]);
|
||||
expect(metas(today)).toEqual(["completed · panel (Declan) · late 20 min"]);
|
||||
|
||||
const yesterday = screen.getByTestId("day-2026-09-23");
|
||||
expect(within(yesterday).getByText("Yesterday")).toBeTruthy();
|
||||
expect(times(yesterday)).toEqual(["10:00 PM"]);
|
||||
});
|
||||
|
||||
it("only an API that publishes no zone leaves the browser's local day and clock", async () => {
|
||||
await renderWithZone("", "");
|
||||
|
||||
for (const url of historyRequests()) {
|
||||
expect(url.searchParams.get("end")).toBe("2026-09-23");
|
||||
}
|
||||
expect(screen.queryByTestId("day-2026-09-24")).toBeNull();
|
||||
const today = screen.getByTestId("day-2026-09-23");
|
||||
expect(within(today).getByText("Today")).toBeTruthy();
|
||||
expect(times(today)).toEqual(["12:30 PM", "10:00 AM"]);
|
||||
// Due 00:10 UTC on Sep 24 is after the 12:30 UTC completion: no minutes.
|
||||
expect(metas(today)).toEqual([
|
||||
"completed · panel (Declan) · late",
|
||||
"completed · panel (Declan) · on time",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,9 +9,10 @@ import { ApiForbiddenError } from "../api/client";
|
||||
import type { TransitionEvent } from "../api/events";
|
||||
import { useTransitions } from "../api/useTransitions";
|
||||
import { exportHistoryCsv, fetchHistory, type HistoryFilter, type HistoryRow } from "../api/history";
|
||||
import { localTodayIso } from "../schedule/cycle";
|
||||
import { dayLabel, groupByDay, rowMeta, rowTime, shiftIso } from "./format";
|
||||
import { DownloadGlyph, LockGlyph } from "./glyphs";
|
||||
import { useAppTimezone } from "../time/AppTimezone";
|
||||
import { todayIn } from "../time/zone";
|
||||
import "./HistoryTab.css";
|
||||
|
||||
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
|
||||
@@ -129,7 +130,7 @@ function StatRow({ stats }: { stats: HistoryStats }) {
|
||||
);
|
||||
}
|
||||
|
||||
function EventRow({ row }: { row: HistoryRow }) {
|
||||
function EventRow({ row, timeZone }: { row: HistoryRow; timeZone: string }) {
|
||||
return (
|
||||
<li className="history-event" data-testid="history-row">
|
||||
<span
|
||||
@@ -142,10 +143,10 @@ function EventRow({ row }: { row: HistoryRow }) {
|
||||
{row.quest_title} · {row.child_name}
|
||||
</div>
|
||||
<div className="history-event-meta" data-testid="history-meta">
|
||||
{rowMeta(row)}
|
||||
{rowMeta(row, timeZone)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="history-event-time">{rowTime(row)}</span>
|
||||
<span className="history-event-time">{rowTime(row, timeZone)}</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -161,7 +162,10 @@ function FooterNote() {
|
||||
);
|
||||
}
|
||||
|
||||
export default function HistoryTab({ today = localTodayIso() }: { today?: string }) {
|
||||
/** `today` ("YYYY-MM-DD") defaults to today in the application timezone. */
|
||||
export default function HistoryTab({ today: todayProp }: { today?: string }) {
|
||||
const timeZone = useAppTimezone();
|
||||
const today = todayProp ?? todayIn(timeZone);
|
||||
const [filter, setFilter] = useState<HistoryFilter>("all");
|
||||
const [state, setState] = useState<LoadState>({ kind: "loading" });
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
@@ -244,7 +248,7 @@ export default function HistoryTab({ today = localTodayIso() }: { today?: string
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
const groups = groupByDay(shown.data.rows);
|
||||
const groups = groupByDay(shown.data.rows, timeZone);
|
||||
body =
|
||||
groups.length > 0 ? (
|
||||
groups.map((group) => (
|
||||
@@ -252,7 +256,7 @@ export default function HistoryTab({ today = localTodayIso() }: { today?: string
|
||||
<h3 className="history-section-label">{dayLabel(group.day, today)}</h3>
|
||||
<ul className="history-card history-list">
|
||||
{group.rows.map((row, index) => (
|
||||
<EventRow key={`${row.instance_id}-${row.event_type}-${row.occurred_at ?? ""}-${index}`} row={row} />
|
||||
<EventRow key={`${row.instance_id}-${row.event_type}-${row.occurred_at ?? ""}-${index}`} row={row} timeZone={timeZone} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
+21
-24
@@ -4,12 +4,13 @@
|
||||
* Calendar dates ("YYYY-MM-DD") are handled by their components (never
|
||||
* `new Date("YYYY-MM-DD")`, which is UTC midnight and reads as the previous
|
||||
* day in negative-offset zones); event stamps are UTC and shown in the
|
||||
* viewer's local time.
|
||||
* application timezone (`timeZone`: the stored IANA zone, "" = browser).
|
||||
*/
|
||||
import type { HistoryRow } from "../api/history";
|
||||
import { dayNumber, isoFromDayNumber, localTodayIso } from "../schedule/cycle";
|
||||
import { dayNumber, isoFromDayNumber } from "../schedule/cycle";
|
||||
import { shortDate } from "../schedule/format";
|
||||
import { formatClockTime, formatDueTime } from "../today/format";
|
||||
import { isoDateIn, wallClockInstant } from "../time/zone";
|
||||
|
||||
const MS_PER_MINUTE = 60_000;
|
||||
|
||||
@@ -19,25 +20,21 @@ export function shiftIso(iso: string, days: number): string {
|
||||
}
|
||||
|
||||
/** The local calendar day an event belongs to (due_date for missed rows). */
|
||||
export function rowDay(row: HistoryRow): string {
|
||||
return row.occurred_at ? localTodayIso(new Date(row.occurred_at)) : row.due_date;
|
||||
export function rowDay(row: HistoryRow, timeZone: string): string {
|
||||
return row.occurred_at ? isoDateIn(new Date(row.occurred_at), timeZone) : row.due_date;
|
||||
}
|
||||
|
||||
/** The row's due moment as local wall-clock time, or null without a due time. */
|
||||
function dueMoment(row: HistoryRow): Date | null {
|
||||
/** The row's due moment (epoch ms) as local wall-clock time, or null without a due time. */
|
||||
function dueMoment(row: HistoryRow, timeZone: string): number | null {
|
||||
const match = /^(\d{2}):(\d{2})/.exec(row.due_time ?? "");
|
||||
if (!match) return null;
|
||||
const [year, month, day] = row.due_date.split("-").map(Number);
|
||||
return new Date(year, month - 1, day, Number(match[1]), Number(match[2]));
|
||||
return wallClockInstant(row.due_date, Number(match[1]), Number(match[2]), timeZone);
|
||||
}
|
||||
|
||||
/** Newest first; a missed row sorts at its due moment (or the end of its day). */
|
||||
function sortKey(row: HistoryRow): number {
|
||||
function sortKey(row: HistoryRow, timeZone: string): number {
|
||||
if (row.occurred_at) return Date.parse(row.occurred_at);
|
||||
const due = dueMoment(row);
|
||||
if (due) return due.getTime();
|
||||
const [year, month, day] = row.due_date.split("-").map(Number);
|
||||
return new Date(year, month - 1, day, 23, 59).getTime();
|
||||
return dueMoment(row, timeZone) ?? wallClockInstant(row.due_date, 23, 59, timeZone);
|
||||
}
|
||||
|
||||
export interface DayGroup {
|
||||
@@ -46,10 +43,10 @@ export interface DayGroup {
|
||||
}
|
||||
|
||||
/** Rows grouped by day, newest day first and newest row first within a day. */
|
||||
export function groupByDay(rows: HistoryRow[]): DayGroup[] {
|
||||
export function groupByDay(rows: HistoryRow[], timeZone: string): DayGroup[] {
|
||||
const groups = new Map<string, HistoryRow[]>();
|
||||
for (const row of rows) {
|
||||
const day = rowDay(row);
|
||||
const day = rowDay(row, timeZone);
|
||||
groups.set(day, [...(groups.get(day) ?? []), row]);
|
||||
}
|
||||
return [...groups.entries()]
|
||||
@@ -57,7 +54,7 @@ export function groupByDay(rows: HistoryRow[]): DayGroup[] {
|
||||
.map(([day, dayRows]) => ({
|
||||
day,
|
||||
// Stable sort: rows at the same moment keep the API's append order.
|
||||
rows: [...dayRows].sort((a, b) => sortKey(b) - sortKey(a)),
|
||||
rows: [...dayRows].sort((a, b) => sortKey(b, timeZone) - sortKey(a, timeZone)),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -72,23 +69,23 @@ export function dayLabel(day: string, today: string): string {
|
||||
}
|
||||
|
||||
/** "on time", "late 22 min", or "late" when the lateness is not derivable. */
|
||||
function onTimeFlag(row: HistoryRow): string | null {
|
||||
function onTimeFlag(row: HistoryRow, timeZone: string): string | null {
|
||||
if (row.event_type !== "completed" || row.was_on_time === null) return null;
|
||||
if (row.was_on_time) return "on time";
|
||||
const due = dueMoment(row);
|
||||
if (!due || !row.occurred_at) return "late";
|
||||
const minutes = Math.round((Date.parse(row.occurred_at) - due.getTime()) / MS_PER_MINUTE);
|
||||
const due = dueMoment(row, timeZone);
|
||||
if (due === null || !row.occurred_at) return "late";
|
||||
const minutes = Math.round((Date.parse(row.occurred_at) - due) / MS_PER_MINUTE);
|
||||
return minutes > 0 ? `late ${minutes} min` : "late";
|
||||
}
|
||||
|
||||
/** `<event type> · <actor>` plus the on-time flag for completions. */
|
||||
export function rowMeta(row: HistoryRow): string {
|
||||
const flag = onTimeFlag(row);
|
||||
export function rowMeta(row: HistoryRow, timeZone: string): string {
|
||||
const flag = onTimeFlag(row, timeZone);
|
||||
return [row.event_type, row.actor, flag].filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
/** The event's local clock time; a missed row shows its due time, if any. */
|
||||
export function rowTime(row: HistoryRow): string {
|
||||
if (row.occurred_at) return formatClockTime(row.occurred_at);
|
||||
export function rowTime(row: HistoryRow, timeZone: string): string {
|
||||
if (row.occurred_at) return formatClockTime(row.occurred_at, timeZone);
|
||||
return row.due_time ? formatDueTime(row.due_time) : "";
|
||||
}
|
||||
|
||||
@@ -74,6 +74,10 @@ async function routeFetch(url: string, init: RequestInit = {}): Promise<Response
|
||||
overrides = overrides.filter((o) => o.id !== Number(deleteMatch[1]));
|
||||
return jsonResponse({ status: "ok" });
|
||||
}
|
||||
// The shell loads the application timezone first ("" = none published).
|
||||
if (path.endsWith("/admin/settings")) {
|
||||
return jsonResponse({ timezone: "", effective_timezone: "" });
|
||||
}
|
||||
if (path.endsWith("/admin/children")) return jsonResponse({ children: CHILDREN });
|
||||
const patternsMatch = /\/children\/(\d+)\/presence-patterns$/.exec(path);
|
||||
if (patternsMatch) {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import ScheduleTab from "./ScheduleTab";
|
||||
import type { AdminChild } from "../api/definitions";
|
||||
import type { PresenceOverride, PresencePattern } from "../api/presence";
|
||||
import { storeTokens } from "../auth/oidc";
|
||||
import { AppTimezoneProvider } from "../time/AppTimezone";
|
||||
import { cycleWeekIndex, monthDates, weekdayOf } from "./cycle";
|
||||
|
||||
function child(id: number, display_name: string, sort_order: number, is_active = true): AdminChild {
|
||||
@@ -655,3 +656,55 @@ describe("ScheduleTab", () => {
|
||||
expect(await screen.findByTestId("pattern-1")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ScheduleTab today in the stored application timezone", () => {
|
||||
async function renderWithZone(timezone: string, effective_timezone = timezone) {
|
||||
fetchMock = vi.fn(async (url: string, init?: RequestInit) =>
|
||||
String(url).endsWith("/api/v1/admin/settings")
|
||||
? jsonResponse({ timezone, effective_timezone })
|
||||
: routeFetch(url, init),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
render(
|
||||
<AppTimezoneProvider>
|
||||
<ScheduleTab />
|
||||
</AppTimezoneProvider>,
|
||||
);
|
||||
await screen.findByTestId("pattern-1");
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubEnv("TZ", "UTC");
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
patterns = { 1: [DECLAN_HOME], 2: [MAEVE_THU_FRI, MAEVE_WEEKENDS], 3: [] };
|
||||
writeFailure = null;
|
||||
// 13:00 UTC on Sep 23 is already 01:00 on Sep 24 in Auckland.
|
||||
vi.useFakeTimers({ toFake: ["Date"], now: new Date("2026-09-23T13:00:00Z") });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("marks today in the stored zone", async () => {
|
||||
await renderWithZone("Pacific/Auckland");
|
||||
expect(day("2026-09-24").className).toContain("schedule-day--today");
|
||||
expect(day("2026-09-23").className).not.toContain("schedule-day--today");
|
||||
});
|
||||
|
||||
it("an empty stored zone marks today in the API host's published zone", async () => {
|
||||
await renderWithZone("", "Pacific/Auckland");
|
||||
expect(day("2026-09-24").className).toContain("schedule-day--today");
|
||||
expect(day("2026-09-23").className).not.toContain("schedule-day--today");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
import {
|
||||
cycleLabel,
|
||||
dayState,
|
||||
localTodayIso,
|
||||
monthDates,
|
||||
monthStart,
|
||||
patternDaysLabel,
|
||||
@@ -38,6 +37,8 @@ import {
|
||||
} from "./glyphs";
|
||||
import OverrideEditor from "./OverrideEditor";
|
||||
import PatternEditor from "./PatternEditor";
|
||||
import { useAppTimezone } from "../time/AppTimezone";
|
||||
import { todayIn } from "../time/zone";
|
||||
import "./ScheduleTab.css";
|
||||
|
||||
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
|
||||
@@ -324,11 +325,13 @@ function OverrideRow({ override, childName, deletion, onAskDelete, onKeep, onDel
|
||||
}
|
||||
|
||||
export interface ScheduleTabProps {
|
||||
/** "YYYY-MM-DD"; defaults to the viewer's local date. */
|
||||
/** "YYYY-MM-DD"; defaults to today in the application timezone. */
|
||||
today?: string;
|
||||
}
|
||||
|
||||
export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProps) {
|
||||
export default function ScheduleTab({ today: todayProp }: ScheduleTabProps) {
|
||||
const timeZone = useAppTimezone();
|
||||
const today = todayProp ?? todayIn(timeZone);
|
||||
const [state, setState] = useState<LoadState>({ kind: "loading" });
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
|
||||
@@ -55,13 +55,6 @@ export function cycleWeekIndex(anchorDate: string, date: string, cycleLengthWeek
|
||||
return mod(Math.floor(days / 7), cycleLengthWeeks);
|
||||
}
|
||||
|
||||
/** The viewer's local calendar date. */
|
||||
export function localTodayIso(now: Date = new Date()): string {
|
||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(now.getDate()).padStart(2, "0");
|
||||
return `${now.getFullYear()}-${month}-${day}`;
|
||||
}
|
||||
|
||||
/** "YYYY-MM-01" of the month containing `iso`, shifted by `delta` months. */
|
||||
export function monthStart(iso: string, delta = 0): string {
|
||||
const [year, month] = iso.split("-").map(Number);
|
||||
|
||||
@@ -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,8 +24,17 @@ const DEFAULT_SETTINGS: HouseholdSettings = {
|
||||
end_of_day_report_enabled: true,
|
||||
celebration_enabled: false,
|
||||
timezone: "America/Chicago",
|
||||
effective_timezone: "America/Chicago",
|
||||
};
|
||||
|
||||
/** The API host's own zone: the effective zone while none is stored. */
|
||||
const API_HOST_ZONE = "Europe/London";
|
||||
|
||||
/** The settings as the API answers: `effective_timezone` derived like the API's. */
|
||||
function settingsBody(): HouseholdSettings {
|
||||
return { ...settings, effective_timezone: settings.timezone || API_HOST_ZONE };
|
||||
}
|
||||
|
||||
function child(
|
||||
id: number,
|
||||
display_name: string,
|
||||
@@ -82,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, {
|
||||
@@ -791,3 +801,43 @@ describe("SettingsScreen — manual timezone control", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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>,
|
||||
);
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
* Preferences: the household settings (planning horizon, day rollover,
|
||||
* 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.
|
||||
* 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";
|
||||
|
||||
@@ -593,6 +595,7 @@ export default function SettingsScreen({ onClose }: { onClose: () => void }) {
|
||||
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.
|
||||
@@ -634,7 +637,9 @@ export default function SettingsScreen({ onClose }: { onClose: () => void }) {
|
||||
setPreferences({ kind: "loading" });
|
||||
fetchSettings()
|
||||
.then((settings) => {
|
||||
if (!cancelled) setPreferences({ kind: "ready", settings });
|
||||
if (cancelled) return;
|
||||
setPreferences({ kind: "ready", settings });
|
||||
publishTimezone(settings.effective_timezone);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (cancelled) return;
|
||||
@@ -728,6 +733,7 @@ export default function SettingsScreen({ onClose }: { onClose: () => void }) {
|
||||
try {
|
||||
const settings = await updateSettings(changes);
|
||||
setPreferences({ kind: "ready", settings });
|
||||
publishTimezone(settings.effective_timezone);
|
||||
setPreferencesMessage({ kind: "info", text: "Preferences saved." });
|
||||
} catch (error) {
|
||||
setPreferencesMessage({
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* The effective application timezone, loaded once per signed-in shell: the
|
||||
* API's `effective_timezone` — the stored zone, or the API host's own zone
|
||||
* when none is stored — so every screen's local time matches the API's.
|
||||
*
|
||||
* <AppTimezoneProvider> fetches the household settings and renders its
|
||||
* screens only once the zone is known (a failed load says so and offers a
|
||||
* retry), so no screen ever computes local time in a zone it will not keep.
|
||||
* The Settings screen publishes every zone it loads or saves, so a change
|
||||
* applies app-wide at once.
|
||||
*
|
||||
* Without a provider (a screen rendered on its own, e.g. in a test)
|
||||
* useAppTimezone() is "" — the documented unset value, i.e. browser-local.
|
||||
*/
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
|
||||
import { ApiForbiddenError } from "../api/client";
|
||||
import { fetchSettings } from "../api/settings";
|
||||
|
||||
interface ZoneHub {
|
||||
timeZone: string;
|
||||
publish(timeZone: string): void;
|
||||
}
|
||||
|
||||
const AppTimezoneContext = createContext<ZoneHub | null>(null);
|
||||
|
||||
type LoadState =
|
||||
| { kind: "loading" }
|
||||
| { kind: "error"; message: string }
|
||||
| { kind: "ready"; timeZone: string };
|
||||
|
||||
export function AppTimezoneProvider({ children }: { children: ReactNode }) {
|
||||
const [state, setState] = useState<LoadState>({ kind: "loading" });
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setState({ kind: "loading" });
|
||||
fetchSettings()
|
||||
.then((settings) => {
|
||||
if (!cancelled) setState({ kind: "ready", timeZone: settings.effective_timezone });
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (cancelled) return;
|
||||
setState({
|
||||
kind: "error",
|
||||
message:
|
||||
error instanceof ApiForbiddenError
|
||||
? error.message
|
||||
: "The application timezone could not be loaded. Check your connection and try again.",
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [attempt]);
|
||||
|
||||
if (state.kind === "loading") {
|
||||
return (
|
||||
<div className="admin-loading" role="status" data-testid="timezone-loading">
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (state.kind === "error") {
|
||||
return (
|
||||
<div className="admin-error" role="alert" data-testid="timezone-error">
|
||||
<p>{state.message}</p>
|
||||
<button type="button" onClick={() => setAttempt((n) => n + 1)}>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const hub: ZoneHub = {
|
||||
timeZone: state.timeZone,
|
||||
publish: (timeZone) =>
|
||||
setState((prev) =>
|
||||
prev.kind === "ready" && prev.timeZone === timeZone ? prev : { kind: "ready", timeZone },
|
||||
),
|
||||
};
|
||||
return <AppTimezoneContext.Provider value={hub}>{children}</AppTimezoneContext.Provider>;
|
||||
}
|
||||
|
||||
/** The effective application timezone: an IANA name, or "" (browser-local). */
|
||||
export function useAppTimezone(): string {
|
||||
return useContext(AppTimezoneContext)?.timeZone ?? "";
|
||||
}
|
||||
|
||||
/** Publish an effective zone just loaded or saved; a no-op without a provider. */
|
||||
export function usePublishAppTimezone(): (timeZone: string) => void {
|
||||
const hub = useContext(AppTimezoneContext);
|
||||
return (timeZone) => hub?.publish(timeZone);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { isoDateIn, todayIn, wallClockInstant, zoneOption } from "./zone";
|
||||
import { formatClockTime } from "../today/format";
|
||||
|
||||
// 13:00 UTC on Sep 23 is already 01:00 on Sep 24 in Auckland (NZST, UTC+12).
|
||||
const NOW = new Date("2026-09-23T13:00:00Z");
|
||||
|
||||
describe("application timezone helpers (process in UTC)", () => {
|
||||
beforeAll(() => {
|
||||
vi.stubEnv("TZ", "UTC");
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("a stored zone decides the calendar date; empty means the browser's zone", () => {
|
||||
expect(todayIn("Pacific/Auckland", NOW)).toBe("2026-09-24");
|
||||
expect(isoDateIn(NOW, "Pacific/Auckland")).toBe("2026-09-24");
|
||||
expect(todayIn("", NOW)).toBe("2026-09-23");
|
||||
});
|
||||
|
||||
it("wall-clock time converts in the stored zone, across its DST change", () => {
|
||||
// NZST (UTC+12) before 27 Sep 2026, NZDT (UTC+13) after.
|
||||
expect(wallClockInstant("2026-09-24", 0, 10, "Pacific/Auckland")).toBe(
|
||||
Date.parse("2026-09-23T12:10:00Z"),
|
||||
);
|
||||
expect(wallClockInstant("2026-09-28", 7, 30, "Pacific/Auckland")).toBe(
|
||||
Date.parse("2026-09-27T18:30:00Z"),
|
||||
);
|
||||
expect(wallClockInstant("2026-09-24", 0, 10, "")).toBe(Date.parse("2026-09-24T00:10:00Z"));
|
||||
});
|
||||
|
||||
it("the completion clock reads in the stored zone", () => {
|
||||
expect(formatClockTime("2026-09-23T11:04:00Z", "Pacific/Auckland")).toBe("11:04 PM");
|
||||
expect(formatClockTime("2026-09-23T11:04:00Z", "")).toBe("11:04 AM");
|
||||
});
|
||||
|
||||
it("an invalid stored zone fails fast", () => {
|
||||
expect(zoneOption("")).toBeUndefined();
|
||||
expect(() => todayIn("Not/AZone", NOW)).toThrow(RangeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("empty zone keeps browser-local behavior", () => {
|
||||
beforeAll(() => {
|
||||
vi.stubEnv("TZ", "America/Los_Angeles");
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("matches the browser's own calendar and wall clock", () => {
|
||||
// 13:00 UTC is 06:00 PDT on Sep 23.
|
||||
expect(todayIn("", NOW)).toBe("2026-09-23");
|
||||
expect(wallClockInstant("2026-09-23", 7, 30, "")).toBe(new Date(2026, 8, 23, 7, 30).getTime());
|
||||
expect(formatClockTime("2026-09-23T14:04:00Z", "")).toBe("7:04 AM");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Local time in the application timezone.
|
||||
*
|
||||
* The admin-saved zone (settings `timezone`, an IANA name) is the single
|
||||
* source of local-time truth for the whole application; the API publishes it
|
||||
* as `effective_timezone`, naming its host's own zone when none is stored.
|
||||
* "" means the API published no zone, and only then does the browser's own
|
||||
* zone apply. Every helper takes
|
||||
* that stored value as-is; an invalid zone name throws (Intl's RangeError).
|
||||
*/
|
||||
|
||||
/** The `timeZone` option for Intl: the zone, or undefined (browser) for "". */
|
||||
export function zoneOption(timeZone: string): string | undefined {
|
||||
return timeZone === "" ? undefined : timeZone;
|
||||
}
|
||||
|
||||
interface WallClock {
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
hour: number;
|
||||
minute: number;
|
||||
second: number;
|
||||
}
|
||||
|
||||
function wallClock(instant: number, timeZone: string): WallClock {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: zoneOption(timeZone),
|
||||
hourCycle: "h23",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
}).formatToParts(instant);
|
||||
const part = (type: Intl.DateTimeFormatPartTypes) =>
|
||||
Number(parts.find((p) => p.type === type)?.value);
|
||||
return {
|
||||
year: part("year"),
|
||||
month: part("month"),
|
||||
day: part("day"),
|
||||
hour: part("hour"),
|
||||
minute: part("minute"),
|
||||
second: part("second"),
|
||||
};
|
||||
}
|
||||
|
||||
/** The "YYYY-MM-DD" calendar date of `instant` in the zone. */
|
||||
export function isoDateIn(instant: Date, timeZone: string): string {
|
||||
const { year, month, day } = wallClock(instant.getTime(), timeZone);
|
||||
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** Today's calendar date in the zone. */
|
||||
export function todayIn(timeZone: string, now: Date = new Date()): string {
|
||||
return isoDateIn(now, timeZone);
|
||||
}
|
||||
|
||||
/** The zone's UTC offset in ms at `instant` (wall clock minus UTC). */
|
||||
function offsetAt(instant: number, timeZone: string): number {
|
||||
const c = wallClock(instant, timeZone);
|
||||
const wholeSeconds = Math.floor(instant / 1000) * 1000;
|
||||
return Date.UTC(c.year, c.month - 1, c.day, c.hour, c.minute, c.second) - wholeSeconds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Epoch ms at which the zone's wall clock reads `hour:minute` on the
|
||||
* "YYYY-MM-DD" date. Two passes settle the offset across a DST change.
|
||||
*/
|
||||
export function wallClockInstant(
|
||||
iso: string,
|
||||
hour: number,
|
||||
minute: number,
|
||||
timeZone: string,
|
||||
): number {
|
||||
const [year, month, day] = iso.split("-").map(Number);
|
||||
const asUtc = Date.UTC(year, month - 1, day, hour, minute);
|
||||
const guess = asUtc - offsetAt(asUtc, timeZone);
|
||||
return asUtc - offsetAt(guess, timeZone);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type { AdminSnapshot, SnapshotInstance } from "../api/snapshot";
|
||||
import { TransitionsProvider } from "../api/useTransitions";
|
||||
import { frame, testStream, type TestStream } from "../api/testStream";
|
||||
import { storeTokens } from "../auth/oidc";
|
||||
import { AppTimezoneProvider } from "../time/AppTimezone";
|
||||
|
||||
function instance(overrides: Partial<SnapshotInstance>): SnapshotInstance {
|
||||
return {
|
||||
@@ -406,3 +407,62 @@ describe("TodayTab Undo and live updates", () => {
|
||||
await waitFor(() => expect(streams[0].cancelled()).toBe(true));
|
||||
});
|
||||
});
|
||||
|
||||
describe("TodayTab in the stored application timezone", () => {
|
||||
function renderWithZone(timezone: string, effective_timezone = timezone) {
|
||||
fetchMock.mockImplementation((url: string) =>
|
||||
Promise.resolve(
|
||||
String(url).endsWith("/api/v1/admin/settings")
|
||||
? jsonResponse({ timezone, effective_timezone })
|
||||
: jsonResponse(SNAPSHOT),
|
||||
),
|
||||
);
|
||||
render(
|
||||
<AppTimezoneProvider>
|
||||
<TodayTab />
|
||||
</AppTimezoneProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
// The browser runs in UTC; the household lives in Auckland (UTC+12 on Sep 23).
|
||||
vi.stubEnv("TZ", "UTC");
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("shows the completion clock in the stored zone, not the browser's", async () => {
|
||||
renderWithZone("Pacific/Auckland");
|
||||
const completed = await screen.findByTestId("attention-12");
|
||||
// 11:04 UTC is 11:04 PM in Auckland.
|
||||
expect(within(completed).getByText("Completed 11:04 PM")).toBeTruthy();
|
||||
// The header's day is the API's household-local today_iso, as-is.
|
||||
expect(screen.getByText("Wednesday, September 23 · cycle day 5 of 14")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("an empty stored zone shows the clock in the API host's published zone", async () => {
|
||||
renderWithZone("", "Pacific/Auckland");
|
||||
const completed = await screen.findByTestId("attention-12");
|
||||
expect(within(completed).getByText("Completed 11:04 PM")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("only an API that publishes no zone leaves the browser's local clock", async () => {
|
||||
renderWithZone("", "");
|
||||
const completed = await screen.findByTestId("attention-12");
|
||||
expect(within(completed).getByText("Completed 11:04 AM")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import { ApiRequestError } from "../api/definitions";
|
||||
import { uncompleteInstance } from "../api/instances";
|
||||
import { useTransitions } from "../api/useTransitions";
|
||||
import SettingsScreen from "../settings/SettingsScreen";
|
||||
import { useAppTimezone } from "../time/AppTimezone";
|
||||
import {
|
||||
formatClockTime,
|
||||
formatDueTime,
|
||||
@@ -165,6 +166,7 @@ function AttentionRow({ item, undo }: { item: AttentionItem; undo: UndoControl }
|
||||
const { instance, childName } = item;
|
||||
const overdue = isOverdueOpen(instance);
|
||||
const undoing = undo.pendingId === instance.id;
|
||||
const timeZone = useAppTimezone();
|
||||
// TODO(e57facc2): wire "Mark done". The admin plane has no complete route
|
||||
// yet, so that action is inert here.
|
||||
return (
|
||||
@@ -187,7 +189,7 @@ function AttentionRow({ item, undo }: { item: AttentionItem; undo: UndoControl }
|
||||
) : (
|
||||
// The snapshot carries no completion actor, so "from panel" is not printed.
|
||||
<div className="today-event-meta">
|
||||
Completed {formatClockTime(instance.completed_at as string)}
|
||||
Completed {formatClockTime(instance.completed_at as string, timeZone)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
* Calendar dates ("YYYY-MM-DD") are parsed by their components and formatted
|
||||
* in UTC, so the printed day never shifts with the viewer's offset — unlike
|
||||
* `new Date("YYYY-MM-DD")`, which is UTC midnight and reads as the previous
|
||||
* day in negative-offset zones.
|
||||
* day in negative-offset zones. Instants are shown in the application
|
||||
* timezone (time/zone.ts).
|
||||
*/
|
||||
import { zoneOption } from "../time/zone";
|
||||
|
||||
const LOCALE = "en-US";
|
||||
|
||||
@@ -44,7 +46,11 @@ export function formatDueTime(hhmm: string): string {
|
||||
return format(date, { hour: "numeric", minute: "2-digit", timeZone: "UTC" });
|
||||
}
|
||||
|
||||
/** A UTC ISO timestamp shown as the viewer's local time, "7:04 AM". */
|
||||
export function formatClockTime(isoTimestamp: string): string {
|
||||
return format(new Date(isoTimestamp), { hour: "numeric", minute: "2-digit" });
|
||||
/** A UTC ISO timestamp shown as wall-clock time in `timeZone` ("" = browser), "7:04 AM". */
|
||||
export function formatClockTime(isoTimestamp: string, timeZone: string): string {
|
||||
return format(new Date(isoTimestamp), {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
timeZone: zoneOption(timeZone),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""The API's effective application timezone (task 7e173881).
|
||||
|
||||
The admin-stored ``timezone`` setting is the single source of local-time
|
||||
truth; stored empty, the API reads its clock in the API host's local
|
||||
time. :func:`effective_timezone` names the zone the API actually uses —
|
||||
the stored zone, or else the host's own IANA zone — so the panel cards
|
||||
and the admin app format local time in exactly the API's zone instead of
|
||||
guessing with Home Assistant's or the browser's. ``""`` only when the
|
||||
host's zone genuinely cannot be named.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
|
||||
def _valid_zone(name: str) -> str:
|
||||
"""Return ``name`` if it is a loadable IANA zone key, else ``""``."""
|
||||
name = name.strip().removeprefix(":")
|
||||
if not name:
|
||||
return ""
|
||||
try:
|
||||
ZoneInfo(name)
|
||||
except (ZoneInfoNotFoundError, ValueError):
|
||||
return ""
|
||||
return name
|
||||
|
||||
|
||||
def host_timezone(
|
||||
environ: Mapping[str, str] = os.environ,
|
||||
timezone_file: Path = Path("/etc/timezone"),
|
||||
localtime: Path = Path("/etc/localtime"),
|
||||
) -> str:
|
||||
"""Name the API host's local zone as an IANA key, or ``""``.
|
||||
|
||||
In the order the C library honours them: the ``TZ`` environment
|
||||
variable, then the ``/etc/localtime`` zoneinfo link (with Debian's
|
||||
``/etc/timezone`` naming it when the link is a copy). A host with
|
||||
none of them configured runs UTC. A setting that names no IANA
|
||||
zone (a POSIX rule string, a copied file) is ``""``.
|
||||
"""
|
||||
configured = environ.get("TZ")
|
||||
if configured is not None:
|
||||
# An empty TZ is UTC to the C library.
|
||||
return _valid_zone(configured) if configured.strip() else "UTC"
|
||||
if localtime.is_symlink():
|
||||
target = str(localtime.resolve())
|
||||
marker = "/zoneinfo/"
|
||||
if marker in target:
|
||||
return _valid_zone(target.split(marker, 1)[1])
|
||||
if timezone_file.is_file():
|
||||
return _valid_zone(timezone_file.read_text(encoding="utf-8"))
|
||||
if not localtime.exists():
|
||||
return "UTC"
|
||||
return ""
|
||||
|
||||
|
||||
def effective_timezone(stored: str) -> str:
|
||||
"""The zone the API reads its clock in: ``stored``, else the host's."""
|
||||
return stored or host_timezone()
|
||||
+29
-5
@@ -170,7 +170,10 @@ are deliberately not unified here:
|
||||
notify target, the three notification times, the four enable
|
||||
toggles, and the ``timezone`` — the IANA household zone the
|
||||
household-local dates derive from, empty meaning host local; only an
|
||||
admin's ``PATCH`` ever sets it) — the defaults on a fresh database.
|
||||
admin's ``PATCH`` ever sets it) — the defaults on a fresh database —
|
||||
plus the read-only ``effective_timezone`` (:mod:`api.host_zone`): the
|
||||
zone the API actually reads its clock in, which the admin app formats
|
||||
local time in.
|
||||
- ``PATCH /settings`` updates ONLY the supplied fields: the body is a
|
||||
JSON object of settings field names to new values, passed STRAIGHT
|
||||
to :func:`nestquest_core.settings_store.update_settings` — the
|
||||
@@ -349,6 +352,7 @@ from pydantic import (
|
||||
model_validator,
|
||||
)
|
||||
|
||||
from api import host_zone
|
||||
from api import transitions as api_transitions
|
||||
from api.auth import require_admin
|
||||
from api.database import DatabaseState
|
||||
@@ -920,9 +924,14 @@ class AdminSettingsResponse(BaseModel):
|
||||
afternoon_reminder_enabled: bool
|
||||
end_of_day_report_enabled: bool
|
||||
celebration_enabled: bool
|
||||
#: The household IANA time zone the API reads its clock in; ``""``
|
||||
#: means the API host's local time.
|
||||
#: The household IANA time zone the admin stored; ``""`` means the
|
||||
#: API host's local time.
|
||||
timezone: str
|
||||
#: The zone the API actually reads its clock in — ``timezone``, or
|
||||
#: the API host's own zone when that is ``""`` (``""`` only when the
|
||||
#: host's zone cannot be named). Read-only; the admin app formats
|
||||
#: local time in it.
|
||||
effective_timezone: str
|
||||
|
||||
|
||||
class AdminMissedSweepResponse(BaseModel):
|
||||
@@ -2179,8 +2188,9 @@ def _settings_response(
|
||||
"""Serialize one core NestQuestSettings into the documented payload.
|
||||
|
||||
Explicit field-by-field (not ``dataclasses.asdict``) so the payload
|
||||
stays pinned to the eleven documented fields even if the core
|
||||
dataclass later grows another one.
|
||||
stays pinned to the eleven documented fields (plus the read-only
|
||||
``effective_timezone``) even if the core dataclass later grows
|
||||
another one.
|
||||
"""
|
||||
return AdminSettingsResponse(
|
||||
horizon_days=settings.horizon_days,
|
||||
@@ -2194,6 +2204,7 @@ def _settings_response(
|
||||
end_of_day_report_enabled=settings.end_of_day_report_enabled,
|
||||
celebration_enabled=settings.celebration_enabled,
|
||||
timezone=settings.timezone,
|
||||
effective_timezone=host_zone.effective_timezone(settings.timezone),
|
||||
)
|
||||
|
||||
|
||||
@@ -2330,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:
|
||||
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)
|
||||
|
||||
+12
-3
@@ -21,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,
|
||||
@@ -45,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.
|
||||
"""
|
||||
@@ -66,6 +71,7 @@ from api.nestquest_core import (
|
||||
core_settings_store,
|
||||
core_snapshot,
|
||||
)
|
||||
from api import host_zone
|
||||
from api import transitions as api_transitions
|
||||
from api.sse import transition_event_stream
|
||||
|
||||
@@ -126,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]
|
||||
|
||||
|
||||
@@ -218,6 +226,7 @@ async def panel_snapshot(request: Request) -> PanelSnapshotResponse:
|
||||
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,
|
||||
|
||||
+2
-1
@@ -22,7 +22,8 @@ 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 settings write has
|
||||
replaced. A settings writer holds :attr:`MissedSweepScheduler.settings_lock`
|
||||
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
|
||||
|
||||
@@ -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", ""),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -910,6 +910,7 @@ def coordinator_client_from_test_override():
|
||||
{
|
||||
"today_iso": datetime.date.today().isoformat(),
|
||||
"cycle_day": 0,
|
||||
"timezone": "",
|
||||
"children": [],
|
||||
}
|
||||
)
|
||||
|
||||
@@ -41,6 +41,7 @@ from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from api import host_zone
|
||||
from tests.admin_jwt_harness import (
|
||||
ADMIN_KEY,
|
||||
KID,
|
||||
@@ -60,9 +61,14 @@ _dao_meta = importlib.import_module("nestquest_core.dao_meta")
|
||||
#: independent of the core constant.
|
||||
SETTINGS_META_KEY = "settings"
|
||||
|
||||
#: The API host's own zone, pinned for every test (:func:`settings_client`):
|
||||
#: the ``effective_timezone`` while no zone is stored.
|
||||
API_HOST_ZONE = "Australia/Perth"
|
||||
|
||||
#: The all-defaults effective settings a fresh database reports — the
|
||||
#: documented eleven fields, asserted literally so the payload contract is
|
||||
#: pinned here, independent of the core constants.
|
||||
#: documented eleven fields plus the read-only ``effective_timezone``,
|
||||
#: asserted literally so the payload contract is pinned here, independent
|
||||
#: of the core constants.
|
||||
DEFAULT_SETTINGS = {
|
||||
"horizon_days": 14,
|
||||
"day_rollover_time": "00:00",
|
||||
@@ -75,6 +81,16 @@ DEFAULT_SETTINGS = {
|
||||
"end_of_day_report_enabled": True,
|
||||
"celebration_enabled": True,
|
||||
"timezone": "",
|
||||
"effective_timezone": API_HOST_ZONE,
|
||||
}
|
||||
|
||||
|
||||
def _with_zone(zone: str) -> dict:
|
||||
"""The default settings with ``zone`` stored, as the API reports them."""
|
||||
return {
|
||||
**DEFAULT_SETTINGS,
|
||||
"timezone": zone,
|
||||
"effective_timezone": zone or API_HOST_ZONE,
|
||||
}
|
||||
|
||||
|
||||
@@ -84,13 +100,15 @@ def _admin_headers() -> dict[str, str]:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def settings_client(temp_db_path: str) -> SimpleNamespace:
|
||||
async def settings_client(temp_db_path: str, monkeypatch) -> SimpleNamespace:
|
||||
"""An admin-plane app client plus its live database.
|
||||
|
||||
The same runner the children tests use (stubbed JWKS, no network);
|
||||
the fixture also exposes the app's ONE database connection so tests
|
||||
can read (or corrupt) the stored settings document itself.
|
||||
can read (or corrupt) the stored settings document itself. The API
|
||||
host's zone is pinned to :data:`API_HOST_ZONE`.
|
||||
"""
|
||||
monkeypatch.setattr(host_zone, "host_timezone", lambda: API_HOST_ZONE)
|
||||
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
|
||||
async with runner as client:
|
||||
yield SimpleNamespace(
|
||||
@@ -147,7 +165,9 @@ async def test_update_persists_and_the_next_read_reflects_it(
|
||||
SETTINGS_META_KEY
|
||||
)
|
||||
assert raw is not None
|
||||
assert json.loads(raw) == expected
|
||||
stored = dict(expected)
|
||||
del stored["effective_timezone"] # derived on read, never stored
|
||||
assert json.loads(raw) == stored
|
||||
|
||||
|
||||
async def test_partial_update_merges_onto_the_current_settings(
|
||||
@@ -254,7 +274,7 @@ async def test_manual_timezone_patch_persists_and_is_returned(
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert patched.status_code == 200
|
||||
expected = {**DEFAULT_SETTINGS, "timezone": zone}
|
||||
expected = _with_zone(zone)
|
||||
assert patched.json() == expected
|
||||
|
||||
reread = await client.get(
|
||||
@@ -285,7 +305,7 @@ async def test_timezone_alone_sticks_on_a_fresh_database(
|
||||
reread = await client.get(
|
||||
"/api/v1/admin/settings", headers=_admin_headers()
|
||||
)
|
||||
assert reread.json() == {**DEFAULT_SETTINGS, "timezone": zone}
|
||||
assert reread.json() == _with_zone(zone)
|
||||
|
||||
|
||||
async def test_timezone_configured_is_an_unknown_field(
|
||||
@@ -319,7 +339,7 @@ async def test_stored_legacy_timezone_configured_key_is_ignored(
|
||||
"/api/v1/admin/settings", headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {**DEFAULT_SETTINGS, "timezone": "America/Chicago"}
|
||||
assert response.json() == _with_zone("America/Chicago")
|
||||
|
||||
|
||||
# --- validation: rejected values never change the stored settings -------
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""The API host's zone and the effective application timezone (task 7e173881).
|
||||
|
||||
With no zone stored the API reads its clock in the host's local time, so
|
||||
:func:`api.host_zone.effective_timezone` names that host zone — resolved
|
||||
the way the C library resolves local time — for the panel and admin app
|
||||
to format in. Every host configuration here is a temporary file tree.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from api import host_zone
|
||||
|
||||
|
||||
def _host(tmp_path: Path, *, link: str | None = None, name: str | None = None):
|
||||
"""A fake ``/etc``: ``localtime`` linked into a zoneinfo tree at
|
||||
``link``, and/or an ``/etc/timezone`` file holding ``name``."""
|
||||
localtime = tmp_path / "etc" / "localtime"
|
||||
timezone_file = tmp_path / "etc" / "timezone"
|
||||
localtime.parent.mkdir(parents=True)
|
||||
if link is not None:
|
||||
target = tmp_path / "usr" / "share" / "zoneinfo" / link
|
||||
target.parent.mkdir(parents=True)
|
||||
target.write_bytes(b"TZif")
|
||||
localtime.symlink_to(target)
|
||||
if name is not None:
|
||||
timezone_file.write_text(f"{name}\n", encoding="utf-8")
|
||||
return {"timezone_file": timezone_file, "localtime": localtime}
|
||||
|
||||
|
||||
def test_tz_environment_variable_wins(tmp_path: Path) -> None:
|
||||
paths = _host(tmp_path, link="Europe/Paris")
|
||||
assert host_zone.host_timezone({"TZ": "Asia/Kolkata"}, **paths) == "Asia/Kolkata"
|
||||
assert host_zone.host_timezone({"TZ": ":Asia/Kolkata"}, **paths) == "Asia/Kolkata"
|
||||
assert host_zone.host_timezone({"TZ": ""}, **paths) == "UTC"
|
||||
|
||||
|
||||
def test_localtime_link_names_the_zone(tmp_path: Path) -> None:
|
||||
paths = _host(tmp_path, link="America/Argentina/Buenos_Aires")
|
||||
assert host_zone.host_timezone({}, **paths) == "America/Argentina/Buenos_Aires"
|
||||
|
||||
|
||||
def test_etc_timezone_names_a_copied_localtime(tmp_path: Path) -> None:
|
||||
paths = _host(tmp_path, name="Pacific/Auckland")
|
||||
paths["localtime"].write_bytes(b"TZif")
|
||||
assert host_zone.host_timezone({}, **paths) == "Pacific/Auckland"
|
||||
|
||||
|
||||
def test_unconfigured_host_runs_utc(tmp_path: Path) -> None:
|
||||
assert host_zone.host_timezone({}, **_host(tmp_path)) == "UTC"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tz", ["EST+5", "Not/AZone", ":/etc/localtime"])
|
||||
def test_an_unnamed_host_zone_is_empty(tmp_path: Path, tz: str) -> None:
|
||||
assert host_zone.host_timezone({"TZ": tz}, **_host(tmp_path)) == ""
|
||||
|
||||
|
||||
def test_unnamed_copied_localtime_is_empty(tmp_path: Path) -> None:
|
||||
paths = _host(tmp_path)
|
||||
paths["localtime"].write_bytes(b"TZif")
|
||||
assert host_zone.host_timezone({}, **paths) == ""
|
||||
|
||||
|
||||
def test_effective_timezone_is_the_stored_zone_else_the_host_zone(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(host_zone, "host_timezone", lambda: "Europe/Dublin")
|
||||
assert host_zone.effective_timezone("America/Chicago") == "America/Chicago"
|
||||
assert host_zone.effective_timezone("") == "Europe/Dublin"
|
||||
@@ -32,7 +32,7 @@ from zoneinfo import ZoneInfo
|
||||
|
||||
import pytest
|
||||
|
||||
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
|
||||
@@ -92,6 +92,7 @@ async def household(temp_db_path: str, monkeypatch) -> SimpleNamespace:
|
||||
seed=seed,
|
||||
clock=clock,
|
||||
database=runner.app.state.db.database,
|
||||
app=runner.app,
|
||||
)
|
||||
|
||||
|
||||
@@ -532,6 +533,146 @@ async def test_scheduler_rechecks_settings_before_sweeping(
|
||||
await database.close()
|
||||
|
||||
|
||||
# --- the admin settings route is the scheduler's writer (task 7e173881) ------
|
||||
#
|
||||
# ``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
|
||||
|
||||
@@ -60,6 +60,7 @@ def _payload(children) -> dict:
|
||||
return {
|
||||
"today_iso": "2026-09-23",
|
||||
"cycle_day": 0,
|
||||
"timezone": "",
|
||||
"children": list(children),
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ def _empty_snapshot() -> dict:
|
||||
return {
|
||||
"today_iso": "2026-09-23",
|
||||
"cycle_day": 0,
|
||||
"timezone": "",
|
||||
"children": [],
|
||||
}
|
||||
|
||||
|
||||
@@ -93,6 +93,7 @@ class FakeMonotonic:
|
||||
FIXTURE_SNAPSHOT = {
|
||||
"today_iso": _today_iso(),
|
||||
"cycle_day": 3,
|
||||
"timezone": "",
|
||||
"children": [
|
||||
{
|
||||
"child_id": 1,
|
||||
@@ -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 == ""
|
||||
|
||||
@@ -332,6 +332,7 @@ async def test_build_snapshot_explicit_values(hass, make_entry, tmp_path) -> Non
|
||||
return {
|
||||
"today_iso": built.today_iso,
|
||||
"cycle_day": built.cycle_day,
|
||||
"timezone": built.timezone,
|
||||
"children": [
|
||||
{
|
||||
"child_id": child.child_id,
|
||||
|
||||
@@ -67,6 +67,7 @@ def _payload(children) -> dict:
|
||||
return {
|
||||
"today_iso": "2026-09-23",
|
||||
"cycle_day": 0,
|
||||
"timezone": "",
|
||||
"children": list(children),
|
||||
}
|
||||
|
||||
|
||||
@@ -76,6 +76,7 @@ def _payload(children, cycle_day=0) -> dict:
|
||||
return {
|
||||
"today_iso": "2026-09-23",
|
||||
"cycle_day": cycle_day,
|
||||
"timezone": "",
|
||||
"children": list(children),
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ def _payload(children) -> dict:
|
||||
return {
|
||||
"today_iso": "2026-09-23",
|
||||
"cycle_day": 0,
|
||||
"timezone": "",
|
||||
"children": list(children),
|
||||
}
|
||||
|
||||
|
||||
+106
-32
@@ -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:
|
||||
|
||||
@@ -76,6 +76,7 @@ def _payload(children) -> dict:
|
||||
return {
|
||||
"today_iso": _today_iso(),
|
||||
"cycle_day": 0,
|
||||
"timezone": "",
|
||||
"children": list(children),
|
||||
}
|
||||
|
||||
@@ -569,6 +570,22 @@ async def test_household_due_sensor_publishes_the_ordered_child_roster(
|
||||
}
|
||||
|
||||
|
||||
async def test_household_due_sensor_publishes_the_stored_zone(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""The rollup carries the snapshot's stored application zone verbatim
|
||||
(the panel cards format their date and clock in it); ``""`` — no zone
|
||||
stored — passes through unchanged for the cards' documented fallback."""
|
||||
zoned = {**_seeded_payload(), "timezone": "Pacific/Auckland"}
|
||||
entry, coordinator, _client = await _setup_entry(
|
||||
hass, make_entry, zoned, _seeded_payload()
|
||||
)
|
||||
rollup = hass.entities["nestquest_household_quests_due_today"]
|
||||
assert rollup.extra_state_attributes["timezone"] == "Pacific/Auckland"
|
||||
await coordinator.async_refresh()
|
||||
assert rollup.extra_state_attributes["timezone"] == ""
|
||||
|
||||
|
||||
async def test_household_roster_tracks_the_snapshot_on_refresh(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
|
||||
@@ -151,7 +151,12 @@ DOCUMENTED_FIELDS = {
|
||||
|
||||
#: A minimal panel snapshot payload for the full-setup wiring tests
|
||||
#: (the shape ``snapshot_from_api_payload`` consumes; no children).
|
||||
MINIMAL_SNAPSHOT = {"today_iso": "2026-09-23", "cycle_day": 1, "children": []}
|
||||
MINIMAL_SNAPSHOT = {
|
||||
"today_iso": "2026-09-23",
|
||||
"cycle_day": 1,
|
||||
"timezone": "",
|
||||
"children": [],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user