Format the panel and admin date and clock in the stored application timezone

The panel snapshot now carries the admin-saved `timezone` ("" when unset);
the coordinator threads it onto the household due-today sensor, and the
Party Board and Quest Log cards format their kicker, header and dock
date and clock in it, falling back to Home Assistant's zone only when
none is stored. The admin app loads the stored zone once
(AppTimezoneProvider) and uses it for completion clocks, History day
grouping, clocks and lateness, and "today" in Schedule, History and new
task defaults; Settings publishes a saved zone app-wide at once.

Resolves review finding TZ-001 (task 7e173881).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 08:17:26 -04:00
co-authored by Claude Opus 5.5
parent b3542b0551
commit bb748c3647
40 changed files with 910 additions and 126 deletions
+46 -7
View File
@@ -2,10 +2,28 @@ 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: "" }));
// 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 +40,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 +54,33 @@ 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" }));
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
expect(await screen.findByRole("heading", { name: "Today" })).toBeTruthy();
});
});
+13 -9
View File
@@ -4,6 +4,7 @@ import { TransitionsProvider } from "./api/useTransitions";
import DefinitionsTab from "./definitions/DefinitionsTab";
import HistoryTab from "./history/HistoryTab";
import ScheduleTab from "./schedule/ScheduleTab";
import { AppTimezoneProvider } from "./time/AppTimezone";
import TodayTab from "./today/TodayTab";
const TABS = [
@@ -32,15 +33,18 @@ export default function App() {
role="tabpanel"
aria-labelledby={`tab-${active}`}
>
{active === "today" ? (
<TodayTab />
) : active === "tasks" ? (
<DefinitionsTab />
) : active === "schedule" ? (
<ScheduleTab />
) : (
<HistoryTab />
)}
{/* Every screen's local time is the stored application timezone. */}
<AppTimezoneProvider>
{active === "today" ? (
<TodayTab />
) : active === "tasks" ? (
<DefinitionsTab />
) : active === "schedule" ? (
<ScheduleTab />
) : (
<HistoryTab />
)}
</AppTimezoneProvider>
</main>
<nav className="admin-tabbar" aria-label="Admin sections">
{TABS.map((tab) => (
+53 -1
View File
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import DefinitionsTab from "./DefinitionsTab";
import {
@@ -12,6 +12,7 @@ import {
} from "./definitionIcons";
import type { AdminChild, DefinitionRule, QuestDefinition } from "../api/definitions";
import { storeTokens } from "../auth/oidc";
import { AppTimezoneProvider } from "../time/AppTimezone";
function child(id: number, name: string, overrides: Partial<AdminChild> = {}): AdminChild {
return {
@@ -1723,3 +1724,54 @@ 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): 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 }))
: 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 the browser's local today", async () => {
expect(await newSheetStartDate("")).toBe("2026-09-23");
});
});
+20 -22
View File
@@ -57,6 +57,9 @@ import {
normalizeDefinitionIconName,
} from "./definitionIcons";
import { GlyphSvg } from "../icons/GlyphSvg";
import { weekdayOf } from "../schedule/cycle";
import { useAppTimezone } from "../time/AppTimezone";
import { todayIn } from "../time/zone";
import "./DefinitionsTab.css";
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
@@ -121,20 +124,13 @@ type LoadState =
/** `null` = closed, `"new"` = creating, a definition = editing it. */
type SheetTarget = null | "new" | QuestDefinition;
function pad2(n: number): string {
return String(n).padStart(2, "0");
}
function localIsoDate(date: Date): string {
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
}
function emptyDueTimes(): Record<WindowName, string> {
return { morning: "", afternoon: "", evening: "" };
}
function newDraft(activeChildren: AdminChild[]): Draft {
const today = new Date();
/** Draft defaults take `today` ("YYYY-MM-DD") in the application timezone. */
function newDraft(activeChildren: AdminChild[], today: string): Draft {
const [, month, day] = today.split("-").map(Number);
return {
title: "",
icon: null,
@@ -142,22 +138,22 @@ function newDraft(activeChildren: AdminChild[]): Draft {
assigneeIds: activeChildren.length === 1 ? [activeChildren[0].id] : [],
repeats: "daily",
interval: "1",
weekdays: [(today.getDay() + 6) % 7],
dayOfMonth: String(today.getDate()),
month: String(today.getMonth() + 1),
weekdays: [weekdayOf(today)],
dayOfMonth: String(day),
month: String(month),
monthlyWeekday: null,
windows: [],
dueTimes: emptyDueTimes(),
// Mirrors the API's create default: no instance on an away day.
skipOnAway: true,
startDate: localIsoDate(today),
startDate: today,
endDate: null,
};
}
function draftFrom(definition: QuestDefinition, activeChildren: AdminChild[]): Draft {
function draftFrom(definition: QuestDefinition, activeChildren: AdminChild[], today: string): Draft {
const { rule } = definition;
const today = new Date();
const [, month, day] = today.split("-").map(Number);
const active = new Set(activeChildren.map((child) => child.id));
const dueTimes = emptyDueTimes();
for (const w of definition.windows) dueTimes[w.window] = w.due_time ?? "";
@@ -172,9 +168,9 @@ function draftFrom(definition: QuestDefinition, activeChildren: AdminChild[]): D
weekdays:
rule.rule_type === "weekly" || rule.rule_type === "custom_days"
? [...(rule.weekday_set ?? [])]
: [(today.getDay() + 6) % 7],
dayOfMonth: String(rule.day_of_month ?? today.getDate()),
month: String(rule.month ?? today.getMonth() + 1),
: [weekdayOf(today)],
dayOfMonth: String(rule.day_of_month ?? day),
month: String(rule.month ?? month),
monthlyWeekday:
rule.rule_type === "monthly_weekday"
? { nth: rule.nth_weekday ?? 1, weekday: rule.nth_weekday_weekday ?? 0 }
@@ -465,9 +461,11 @@ function EditSheet({
onSaved: () => void;
onDeleted: (result: DefinitionDeleteResult) => void;
}) {
const [draft, setDraft] = useState<Draft>(() =>
target === "new" ? newDraft(activeChildren) : draftFrom(target, activeChildren),
);
const timeZone = useAppTimezone();
const [draft, setDraft] = useState<Draft>(() => {
const today = todayIn(timeZone);
return target === "new" ? newDraft(activeChildren, today) : draftFrom(target, activeChildren, today);
});
// The active selection as loaded: while it is unchanged, the PATCH omits
// assignee_child_ids, because the API replaces the whole set and inactive
// children cannot be sent back.
+112
View File
@@ -5,6 +5,7 @@ import type { HistoryFilter, HistoryRow } from "../api/history";
import { storeTokens } from "../auth/oidc";
import { TransitionsProvider } from "../api/useTransitions";
import { frame, testStream, type TestStream } from "../api/testStream";
import { AppTimezoneProvider } from "../time/AppTimezone";
const TODAY = "2026-09-23";
@@ -629,3 +630,114 @@ 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) {
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 }));
}
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("today, day grouping, clock and lateness follow the stored zone", async () => {
await renderWithZone("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("an empty stored zone keeps the browser's local day and clock", async () => {
await renderWithZone("");
for (const url of historyRequests()) {
expect(url.searchParams.get("end")).toBe("2026-09-23");
}
expect(screen.queryByTestId("day-2026-09-24")).toBeNull();
const today = screen.getByTestId("day-2026-09-23");
expect(within(today).getByText("Today")).toBeTruthy();
expect(times(today)).toEqual(["12:30 PM", "10:00 AM"]);
// Due 00:10 UTC on Sep 24 is after the 12:30 UTC completion: no minutes.
expect(metas(today)).toEqual([
"completed · panel (Declan) · late",
"completed · panel (Declan) · on time",
]);
});
});
+11 -7
View File
@@ -9,9 +9,10 @@ import { ApiForbiddenError } from "../api/client";
import type { TransitionEvent } from "../api/events";
import { useTransitions } from "../api/useTransitions";
import { exportHistoryCsv, fetchHistory, type HistoryFilter, type HistoryRow } from "../api/history";
import { localTodayIso } from "../schedule/cycle";
import { dayLabel, groupByDay, rowMeta, rowTime, shiftIso } from "./format";
import { DownloadGlyph, LockGlyph } from "./glyphs";
import { useAppTimezone } from "../time/AppTimezone";
import { todayIn } from "../time/zone";
import "./HistoryTab.css";
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
@@ -129,7 +130,7 @@ function StatRow({ stats }: { stats: HistoryStats }) {
);
}
function EventRow({ row }: { row: HistoryRow }) {
function EventRow({ row, timeZone }: { row: HistoryRow; timeZone: string }) {
return (
<li className="history-event" data-testid="history-row">
<span
@@ -142,10 +143,10 @@ function EventRow({ row }: { row: HistoryRow }) {
{row.quest_title} · {row.child_name}
</div>
<div className="history-event-meta" data-testid="history-meta">
{rowMeta(row)}
{rowMeta(row, timeZone)}
</div>
</div>
<span className="history-event-time">{rowTime(row)}</span>
<span className="history-event-time">{rowTime(row, timeZone)}</span>
</li>
);
}
@@ -161,7 +162,10 @@ function FooterNote() {
);
}
export default function HistoryTab({ today = localTodayIso() }: { today?: string }) {
/** `today` ("YYYY-MM-DD") defaults to today in the application timezone. */
export default function HistoryTab({ today: todayProp }: { today?: string }) {
const timeZone = useAppTimezone();
const today = todayProp ?? todayIn(timeZone);
const [filter, setFilter] = useState<HistoryFilter>("all");
const [state, setState] = useState<LoadState>({ kind: "loading" });
const [attempt, setAttempt] = useState(0);
@@ -244,7 +248,7 @@ export default function HistoryTab({ today = localTodayIso() }: { today?: string
</div>
);
} else {
const groups = groupByDay(shown.data.rows);
const groups = groupByDay(shown.data.rows, timeZone);
body =
groups.length > 0 ? (
groups.map((group) => (
@@ -252,7 +256,7 @@ export default function HistoryTab({ today = localTodayIso() }: { today?: string
<h3 className="history-section-label">{dayLabel(group.day, today)}</h3>
<ul className="history-card history-list">
{group.rows.map((row, index) => (
<EventRow key={`${row.instance_id}-${row.event_type}-${row.occurred_at ?? ""}-${index}`} row={row} />
<EventRow key={`${row.instance_id}-${row.event_type}-${row.occurred_at ?? ""}-${index}`} row={row} timeZone={timeZone} />
))}
</ul>
</section>
+21 -24
View File
@@ -4,12 +4,13 @@
* Calendar dates ("YYYY-MM-DD") are handled by their components (never
* `new Date("YYYY-MM-DD")`, which is UTC midnight and reads as the previous
* day in negative-offset zones); event stamps are UTC and shown in the
* viewer's local time.
* application timezone (`timeZone`: the stored IANA zone, "" = browser).
*/
import type { HistoryRow } from "../api/history";
import { dayNumber, isoFromDayNumber, localTodayIso } from "../schedule/cycle";
import { dayNumber, isoFromDayNumber } from "../schedule/cycle";
import { shortDate } from "../schedule/format";
import { formatClockTime, formatDueTime } from "../today/format";
import { isoDateIn, wallClockInstant } from "../time/zone";
const MS_PER_MINUTE = 60_000;
@@ -19,25 +20,21 @@ export function shiftIso(iso: string, days: number): string {
}
/** The local calendar day an event belongs to (due_date for missed rows). */
export function rowDay(row: HistoryRow): string {
return row.occurred_at ? localTodayIso(new Date(row.occurred_at)) : row.due_date;
export function rowDay(row: HistoryRow, timeZone: string): string {
return row.occurred_at ? isoDateIn(new Date(row.occurred_at), timeZone) : row.due_date;
}
/** The row's due moment as local wall-clock time, or null without a due time. */
function dueMoment(row: HistoryRow): Date | null {
/** The row's due moment (epoch ms) as local wall-clock time, or null without a due time. */
function dueMoment(row: HistoryRow, timeZone: string): number | null {
const match = /^(\d{2}):(\d{2})/.exec(row.due_time ?? "");
if (!match) return null;
const [year, month, day] = row.due_date.split("-").map(Number);
return new Date(year, month - 1, day, Number(match[1]), Number(match[2]));
return wallClockInstant(row.due_date, Number(match[1]), Number(match[2]), timeZone);
}
/** Newest first; a missed row sorts at its due moment (or the end of its day). */
function sortKey(row: HistoryRow): number {
function sortKey(row: HistoryRow, timeZone: string): number {
if (row.occurred_at) return Date.parse(row.occurred_at);
const due = dueMoment(row);
if (due) return due.getTime();
const [year, month, day] = row.due_date.split("-").map(Number);
return new Date(year, month - 1, day, 23, 59).getTime();
return dueMoment(row, timeZone) ?? wallClockInstant(row.due_date, 23, 59, timeZone);
}
export interface DayGroup {
@@ -46,10 +43,10 @@ export interface DayGroup {
}
/** Rows grouped by day, newest day first and newest row first within a day. */
export function groupByDay(rows: HistoryRow[]): DayGroup[] {
export function groupByDay(rows: HistoryRow[], timeZone: string): DayGroup[] {
const groups = new Map<string, HistoryRow[]>();
for (const row of rows) {
const day = rowDay(row);
const day = rowDay(row, timeZone);
groups.set(day, [...(groups.get(day) ?? []), row]);
}
return [...groups.entries()]
@@ -57,7 +54,7 @@ export function groupByDay(rows: HistoryRow[]): DayGroup[] {
.map(([day, dayRows]) => ({
day,
// Stable sort: rows at the same moment keep the API's append order.
rows: [...dayRows].sort((a, b) => sortKey(b) - sortKey(a)),
rows: [...dayRows].sort((a, b) => sortKey(b, timeZone) - sortKey(a, timeZone)),
}));
}
@@ -72,23 +69,23 @@ export function dayLabel(day: string, today: string): string {
}
/** "on time", "late 22 min", or "late" when the lateness is not derivable. */
function onTimeFlag(row: HistoryRow): string | null {
function onTimeFlag(row: HistoryRow, timeZone: string): string | null {
if (row.event_type !== "completed" || row.was_on_time === null) return null;
if (row.was_on_time) return "on time";
const due = dueMoment(row);
if (!due || !row.occurred_at) return "late";
const minutes = Math.round((Date.parse(row.occurred_at) - due.getTime()) / MS_PER_MINUTE);
const due = dueMoment(row, timeZone);
if (due === null || !row.occurred_at) return "late";
const minutes = Math.round((Date.parse(row.occurred_at) - due) / MS_PER_MINUTE);
return minutes > 0 ? `late ${minutes} min` : "late";
}
/** `<event type> · <actor>` plus the on-time flag for completions. */
export function rowMeta(row: HistoryRow): string {
const flag = onTimeFlag(row);
export function rowMeta(row: HistoryRow, timeZone: string): string {
const flag = onTimeFlag(row, timeZone);
return [row.event_type, row.actor, flag].filter(Boolean).join(" · ");
}
/** The event's local clock time; a missed row shows its due time, if any. */
export function rowTime(row: HistoryRow): string {
if (row.occurred_at) return formatClockTime(row.occurred_at);
export function rowTime(row: HistoryRow, timeZone: string): string {
if (row.occurred_at) return formatClockTime(row.occurred_at, timeZone);
return row.due_time ? formatDueTime(row.due_time) : "";
}
@@ -74,6 +74,8 @@ 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 stored application timezone first ("" = unset).
if (path.endsWith("/admin/settings")) return jsonResponse({ timezone: "" });
if (path.endsWith("/admin/children")) return jsonResponse({ children: CHILDREN });
const patternsMatch = /\/children\/(\d+)\/presence-patterns$/.exec(path);
if (patternsMatch) {
+54 -1
View File
@@ -1,9 +1,10 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import ScheduleTab from "./ScheduleTab";
import type { AdminChild } from "../api/definitions";
import type { PresenceOverride, PresencePattern } from "../api/presence";
import { storeTokens } from "../auth/oidc";
import { AppTimezoneProvider } from "../time/AppTimezone";
import { cycleWeekIndex, monthDates, weekdayOf } from "./cycle";
function child(id: number, display_name: string, sort_order: number, is_active = true): AdminChild {
@@ -655,3 +656,55 @@ describe("ScheduleTab", () => {
expect(await screen.findByTestId("pattern-1")).toBeTruthy();
});
});
describe("ScheduleTab today in the stored application timezone", () => {
async function renderWithZone(timezone: string) {
fetchMock = vi.fn(async (url: string, init?: RequestInit) =>
String(url).endsWith("/api/v1/admin/settings")
? jsonResponse({ 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 the browser's local today", async () => {
await renderWithZone("");
expect(day("2026-09-23").className).toContain("schedule-day--today");
expect(day("2026-09-24").className).not.toContain("schedule-day--today");
});
});
+6 -3
View File
@@ -20,7 +20,6 @@ import {
import {
cycleLabel,
dayState,
localTodayIso,
monthDates,
monthStart,
patternDaysLabel,
@@ -38,6 +37,8 @@ import {
} from "./glyphs";
import OverrideEditor from "./OverrideEditor";
import PatternEditor from "./PatternEditor";
import { useAppTimezone } from "../time/AppTimezone";
import { todayIn } from "../time/zone";
import "./ScheduleTab.css";
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
@@ -324,11 +325,13 @@ function OverrideRow({ override, childName, deletion, onAskDelete, onKeep, onDel
}
export interface ScheduleTabProps {
/** "YYYY-MM-DD"; defaults to the viewer's local date. */
/** "YYYY-MM-DD"; defaults to today in the application timezone. */
today?: string;
}
export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProps) {
export default function ScheduleTab({ today: todayProp }: ScheduleTabProps) {
const timeZone = useAppTimezone();
const today = todayProp ?? todayIn(timeZone);
const [state, setState] = useState<LoadState>({ kind: "loading" });
const [attempt, setAttempt] = useState(0);
const [selectedId, setSelectedId] = useState<number | null>(null);
-7
View File
@@ -55,13 +55,6 @@ export function cycleWeekIndex(anchorDate: string, date: string, cycleLengthWeek
return mod(Math.floor(days / 7), cycleLengthWeeks);
}
/** The viewer's local calendar date. */
export function localTodayIso(now: Date = new Date()): string {
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
return `${now.getFullYear()}-${month}-${day}`;
}
/** "YYYY-MM-01" of the month containing `iso`, shifted by `delta` months. */
export function monthStart(iso: string, delta = 0): string {
const [year, month] = iso.split("-").map(Number);
@@ -6,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`;
@@ -791,3 +792,42 @@ 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" });
expect(screen.getByTestId("app-zone").textContent).toBe("(browser)");
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");
});
});
+8 -2
View File
@@ -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.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.timezone);
setPreferencesMessage({ kind: "info", text: "Preferences saved." });
} catch (error) {
setPreferencesMessage({
+91
View File
@@ -0,0 +1,91 @@
/**
* The stored application timezone, loaded once per signed-in shell.
*
* <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.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 stored application timezone: an IANA name, or "" (browser-local). */
export function useAppTimezone(): string {
return useContext(AppTimezoneContext)?.timeZone ?? "";
}
/** Publish a zone just loaded or saved; a no-op without a provider. */
export function usePublishAppTimezone(): (timeZone: string) => void {
const hub = useContext(AppTimezoneContext);
return (timeZone) => hub?.publish(timeZone);
}
+60
View File
@@ -0,0 +1,60 @@
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { isoDateIn, todayIn, wallClockInstant, zoneOption } from "./zone";
import { formatClockTime } from "../today/format";
// 13:00 UTC on Sep 23 is already 01:00 on Sep 24 in Auckland (NZST, UTC+12).
const NOW = new Date("2026-09-23T13:00:00Z");
describe("application timezone helpers (process in UTC)", () => {
beforeAll(() => {
vi.stubEnv("TZ", "UTC");
});
afterAll(() => {
vi.unstubAllEnvs();
});
it("a stored zone decides the calendar date; empty means the browser's zone", () => {
expect(todayIn("Pacific/Auckland", NOW)).toBe("2026-09-24");
expect(isoDateIn(NOW, "Pacific/Auckland")).toBe("2026-09-24");
expect(todayIn("", NOW)).toBe("2026-09-23");
});
it("wall-clock time converts in the stored zone, across its DST change", () => {
// NZST (UTC+12) before 27 Sep 2026, NZDT (UTC+13) after.
expect(wallClockInstant("2026-09-24", 0, 10, "Pacific/Auckland")).toBe(
Date.parse("2026-09-23T12:10:00Z"),
);
expect(wallClockInstant("2026-09-28", 7, 30, "Pacific/Auckland")).toBe(
Date.parse("2026-09-27T18:30:00Z"),
);
expect(wallClockInstant("2026-09-24", 0, 10, "")).toBe(Date.parse("2026-09-24T00:10:00Z"));
});
it("the completion clock reads in the stored zone", () => {
expect(formatClockTime("2026-09-23T11:04:00Z", "Pacific/Auckland")).toBe("11:04 PM");
expect(formatClockTime("2026-09-23T11:04:00Z", "")).toBe("11:04 AM");
});
it("an invalid stored zone fails fast", () => {
expect(zoneOption("")).toBeUndefined();
expect(() => todayIn("Not/AZone", NOW)).toThrow(RangeError);
});
});
describe("empty zone keeps browser-local behavior", () => {
beforeAll(() => {
vi.stubEnv("TZ", "America/Los_Angeles");
});
afterAll(() => {
vi.unstubAllEnvs();
});
it("matches the browser's own calendar and wall clock", () => {
// 13:00 UTC is 06:00 PDT on Sep 23.
expect(todayIn("", NOW)).toBe("2026-09-23");
expect(wallClockInstant("2026-09-23", 7, 30, "")).toBe(new Date(2026, 8, 23, 7, 30).getTime());
expect(formatClockTime("2026-09-23T14:04:00Z", "")).toBe("7:04 AM");
});
});
+79
View File
@@ -0,0 +1,79 @@
/**
* 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. "" means no zone is
* stored, 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 stored 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);
}
+54
View File
@@ -5,6 +5,7 @@ import type { AdminSnapshot, SnapshotInstance } from "../api/snapshot";
import { TransitionsProvider } from "../api/useTransitions";
import { frame, testStream, type TestStream } from "../api/testStream";
import { storeTokens } from "../auth/oidc";
import { AppTimezoneProvider } from "../time/AppTimezone";
function instance(overrides: Partial<SnapshotInstance>): SnapshotInstance {
return {
@@ -406,3 +407,56 @@ 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) {
fetchMock.mockImplementation((url: string) =>
Promise.resolve(
String(url).endsWith("/api/v1/admin/settings")
? jsonResponse({ 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 keeps the browser's local clock", async () => {
renderWithZone("");
const completed = await screen.findByTestId("attention-12");
expect(within(completed).getByText("Completed 11:04 AM")).toBeTruthy();
});
});
+3 -1
View File
@@ -13,6 +13,7 @@ import { ApiRequestError } from "../api/definitions";
import { uncompleteInstance } from "../api/instances";
import { useTransitions } from "../api/useTransitions";
import SettingsScreen from "../settings/SettingsScreen";
import { useAppTimezone } from "../time/AppTimezone";
import {
formatClockTime,
formatDueTime,
@@ -165,6 +166,7 @@ function AttentionRow({ item, undo }: { item: AttentionItem; undo: UndoControl }
const { instance, childName } = item;
const overdue = isOverdueOpen(instance);
const undoing = undo.pendingId === instance.id;
const timeZone = useAppTimezone();
// TODO(e57facc2): wire "Mark done". The admin plane has no complete route
// yet, so that action is inert here.
return (
@@ -187,7 +189,7 @@ function AttentionRow({ item, undo }: { item: AttentionItem; undo: UndoControl }
) : (
// The snapshot carries no completion actor, so "from panel" is not printed.
<div className="today-event-meta">
Completed {formatClockTime(instance.completed_at as string)}
Completed {formatClockTime(instance.completed_at as string, timeZone)}
</div>
)}
</div>
+10 -4
View File
@@ -4,8 +4,10 @@
* Calendar dates ("YYYY-MM-DD") are parsed by their components and formatted
* in UTC, so the printed day never shifts with the viewer's offset — unlike
* `new Date("YYYY-MM-DD")`, which is UTC midnight and reads as the previous
* day in negative-offset zones.
* day in negative-offset zones. Instants are shown in the application
* timezone (time/zone.ts).
*/
import { zoneOption } from "../time/zone";
const LOCALE = "en-US";
@@ -44,7 +46,11 @@ export function formatDueTime(hhmm: string): string {
return format(date, { hour: "numeric", minute: "2-digit", timeZone: "UTC" });
}
/** A UTC ISO timestamp shown as the viewer's local time, "7:04 AM". */
export function formatClockTime(isoTimestamp: string): string {
return format(new Date(isoTimestamp), { hour: "numeric", minute: "2-digit" });
/** A UTC ISO timestamp shown as wall-clock time in `timeZone` ("" = browser), "7:04 AM". */
export function formatClockTime(isoTimestamp: string, timeZone: string): string {
return format(new Date(isoTimestamp), {
hour: "numeric",
minute: "2-digit",
timeZone: zoneOption(timeZone),
});
}