Merge pull request 'Release: admin timezone setting (0.9.3) (2026-09-26)' (#267) from release/2026-09-26-admin-timezone into main

Reviewed-on: #267
This commit was merged in pull request #267.
This commit is contained in:
2026-09-26 13:13:08 -04:00
7 changed files with 288 additions and 7 deletions
+29
View File
@@ -1,5 +1,34 @@
# NestQuest Release Notes # NestQuest Release Notes
## Version 0.9.3 — 2026-09-26
### Scope
Patch release carrying the admin timezone control onto `main`.
- **Admins set the system timezone from the PWA:** the Preferences screen gains a
Timezone field (a text input with a local datalist of common IANA zones;
`UTC` or `Area/Location`). On a fresh install the value is set AUTOMATICALLY
from the browser's IANA zone when the setting is empty on first load — no
geolocation prompt and no network lookup — and an admin can change or clear it
at any time. Only a changed value is sent, and the API's 422 for an unknown zone
is shown by the form. (PR #266.)
- This makes the household-local clock (overdue, daily rollover) configurable
from the UI, replacing the manual
`PATCH /api/v1/admin/settings {"timezone": ...}` step noted in 0.9.1. The
setting still defaults to the API host's local time when left empty.
### Upgrade note
The database schema is unchanged at version **9**; no migration or backup step is
needed. No runtime dependency is added.
### Requirements
Home Assistant 2024.6.0 or newer. Install through HACS from the
`Maniacs-Mansion/NestQuest` GitHub mirror (kept current by the Gitea push
mirror).
## Version 0.9.2 — 2026-09-26 ## Version 0.9.2 — 2026-09-26
### Scope ### Scope
+2
View File
@@ -17,6 +17,8 @@ export interface HouseholdSettings {
afternoon_reminder_enabled: boolean; afternoon_reminder_enabled: boolean;
end_of_day_report_enabled: boolean; end_of_day_report_enabled: boolean;
celebration_enabled: boolean; celebration_enabled: boolean;
/** IANA zone name (e.g. "America/New_York"); "" means the API host's local time. */
timezone: string;
} }
/** Only the supplied fields are updated; the API answers with all of them. */ /** Only the supplied fields are updated; the API answers with all of them. */
+155 -1
View File
@@ -1,3 +1,4 @@
import { StrictMode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import App from "../App"; import App from "../App";
@@ -21,6 +22,7 @@ const DEFAULT_SETTINGS: HouseholdSettings = {
afternoon_reminder_enabled: false, afternoon_reminder_enabled: false,
end_of_day_report_enabled: true, end_of_day_report_enabled: true,
celebration_enabled: false, celebration_enabled: false,
timezone: "America/Chicago",
}; };
function child( function child(
@@ -370,10 +372,11 @@ describe("SettingsScreen — preferences", () => {
fireEvent.click(within(form).getByRole("button", { name: "Save preferences" })); fireEvent.click(within(form).getByRole("button", { name: "Save preferences" }));
} }
it("renders the current value of the ten settings form fields (timezone is API-only)", async () => { it("renders the current value of all eleven settings form fields", async () => {
const { form } = await renderPreferences(); const { form } = await renderPreferences();
expect(field(form, "Planning horizon (days)").value).toBe("14"); expect(field(form, "Planning horizon (days)").value).toBe("14");
expect(field(form, "Day rollover").value).toBe("03:00"); expect(field(form, "Day rollover").value).toBe("03:00");
expect(field(form, "Timezone").value).toBe("America/Chicago");
expect(field(form, "Notify target").value).toBe("notify.family"); expect(field(form, "Notify target").value).toBe("notify.family");
expect(field(form, "Morning summary time").value).toBe("07:00"); expect(field(form, "Morning summary time").value).toBe("07:00");
expect(field(form, "Afternoon reminder time").value).toBe("15:30"); expect(field(form, "Afternoon reminder time").value).toBe("15:30");
@@ -496,6 +499,46 @@ describe("SettingsScreen — preferences", () => {
expect(writes()).toHaveLength(1); expect(writes()).toHaveLength(1);
}); });
it("a changed timezone patches exactly the timezone", async () => {
const { form } = await renderPreferences();
const input = field(form, "Timezone");
expect(input.getAttribute("list")).toBe("settings-timezones");
fireEvent.change(input, { target: { value: " Europe/London " } });
save(form);
await screen.findByText("Preferences saved.");
expect(writes()).toEqual([{ method: "PATCH", path: SETTINGS_PATH, body: { timezone: "Europe/London" } }]);
const reloaded = screen.getByRole("form", { name: "Preferences" });
expect(field(reloaded, "Timezone").value).toBe("Europe/London");
});
it.each(["New York", "America/New York", "EST5EDT?", "/Chicago"])(
"an invalid timezone %j is rejected before any request",
async (value) => {
const { form } = await renderPreferences();
fireEvent.change(field(form, "Timezone"), { target: { value } });
save(form);
expect(screen.getByTestId("preferences-message").textContent).toBe(
"Timezone must be an IANA zone like America/New_York, or UTC.",
);
expect(field(form, "Timezone").value).toBe(value);
expect(writes()).toEqual([]);
},
);
it("a 422 on an unknown timezone shows the API detail and keeps the edit", async () => {
const { form } = await renderPreferences();
fireEvent.change(field(form, "Timezone"), { target: { value: "Mars/Olympus_Mons" } });
writeResponse = () =>
jsonResponse({ detail: "timezone must be an IANA time zone name such as 'America/New_York'" }, 422);
save(form);
const message = await screen.findByTestId("preferences-message");
expect(message.textContent).toBe(
"The preferences could not be saved: timezone must be an IANA time zone name such as 'America/New_York'",
);
expect(field(form, "Timezone").value).toBe("Mars/Olympus_Mons");
expect(field(form, "Timezone").disabled).toBe(false);
});
it("a failed load shows the reason and Try again refetches", async () => { it("a failed load shows the reason and Try again refetches", async () => {
let fail = true; let fail = true;
fetchMock.mockImplementation(async (url: string, init?: RequestInit) => { fetchMock.mockImplementation(async (url: string, init?: RequestInit) => {
@@ -512,3 +555,114 @@ describe("SettingsScreen — preferences", () => {
await screen.findByRole("form", { name: "Preferences" }); await screen.findByRole("form", { name: "Preferences" });
}); });
}); });
describe("SettingsScreen — timezone auto-set", () => {
let zoneSpy: ReturnType<typeof vi.spyOn> | null;
function mockBrowserZone(timeZone: string | undefined) {
const real = Intl.DateTimeFormat.prototype.resolvedOptions;
zoneSpy = vi
.spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions")
.mockImplementation(function (this: Intl.DateTimeFormat) {
return { ...real.call(this), timeZone } as Intl.ResolvedDateTimeFormatOptions;
});
}
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
children = [child(1, "Declan", 1)];
settings = { ...DEFAULT_SETTINGS, timezone: "" };
writeResponse = null;
zoneSpy = null;
fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init));
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
cleanup();
zoneSpy?.mockRestore();
vi.unstubAllGlobals();
});
async function renderForm() {
render(<SettingsScreen onClose={vi.fn()} />);
await screen.findByTestId("settings-child-1");
return screen.findByRole("form", { name: "Preferences" });
}
it("an unset timezone is set once, on load, to the browser's zone and shown", async () => {
mockBrowserZone("America/New_York");
const form = await renderForm();
expect(field(form, "Timezone").value).toBe("America/New_York");
expect(writes()).toEqual([
{ method: "PATCH", path: SETTINGS_PATH, body: { timezone: "America/New_York" } },
]);
expect(settings.timezone).toBe("America/New_York");
// A later save of another field does not re-send the timezone.
fireEvent.change(field(form, "Planning horizon (days)"), { target: { value: "21" } });
fireEvent.click(within(form).getByRole("button", { name: "Save preferences" }));
await screen.findByText("Preferences saved.");
expect(writes()).toHaveLength(2);
expect(writes()[1].body).toEqual({ horizon_days: 21 });
});
it("StrictMode's doubled effects still auto-set exactly once", async () => {
mockBrowserZone("America/New_York");
render(
<StrictMode>
<SettingsScreen onClose={vi.fn()} />
</StrictMode>,
);
const form = await screen.findByRole("form", { name: "Preferences" });
expect(field(form, "Timezone").value).toBe("America/New_York");
expect(writes()).toEqual([
{ method: "PATCH", path: SETTINGS_PATH, body: { timezone: "America/New_York" } },
]);
});
it("an admin clearing the timezone is not re-set by the browser zone", async () => {
mockBrowserZone("America/New_York");
const form = await renderForm();
fireEvent.change(field(form, "Timezone"), { target: { value: "" } });
fireEvent.click(within(form).getByRole("button", { name: "Save preferences" }));
await screen.findByText("Preferences saved.");
expect(writes().map((w) => w.body)).toEqual([{ timezone: "America/New_York" }, { timezone: "" }]);
const reloaded = screen.getByRole("form", { name: "Preferences" });
expect(field(reloaded, "Timezone").value).toBe("");
});
it("a set timezone is shown and never overwritten by the browser zone", async () => {
mockBrowserZone("America/New_York");
settings = { ...DEFAULT_SETTINGS, timezone: "Europe/Dublin" };
const form = await renderForm();
expect(field(form, "Timezone").value).toBe("Europe/Dublin");
expect(writes()).toEqual([]);
});
it.each([
["nothing", undefined],
["an empty zone", ""],
["an unknown zone", "Etc/Unknown"],
["a non-IANA zone", "not a zone"],
])("detection returning %s leaves the field empty and sends nothing", async (_case, zone) => {
mockBrowserZone(zone);
const form = await renderForm();
expect(field(form, "Timezone").value).toBe("");
expect(screen.queryByTestId("preferences-message")).toBeNull();
expect(writes()).toEqual([]);
});
it("a rejected auto-set shows why and leaves the field empty for manual entry", async () => {
mockBrowserZone("America/New_York");
writeResponse = () => jsonResponse({ detail: "timezone is not known" }, 422);
const form = await renderForm();
expect(field(form, "Timezone").value).toBe("");
expect(field(form, "Timezone").disabled).toBe(false);
expect(screen.getByTestId("preferences-message").textContent).toBe(
"The timezone could not be set to America/New_York: timezone is not known",
);
expect(writes()).toHaveLength(1);
});
});
+99 -3
View File
@@ -5,7 +5,8 @@
* household's child profiles. Every write refetches the list. * household's child profiles. Every write refetches the list.
* *
* Preferences: the household settings (planning horizon, day rollover, * Preferences: the household settings (planning horizon, day rollover,
* notifications). Save patches only the changed fields. * timezone, notifications). Save patches only the changed fields. An unset
* timezone is set once, on load, to the browser's own zone.
* *
* One write at a time across both sections. * One write at a time across both sections.
*/ */
@@ -252,6 +253,56 @@ const NOTIFICATIONS: { label: string; time: TimeField; toggle: ToggleField }[] =
const HH_MM = /^([01]\d|2[0-3]):[0-5]\d$/; const HH_MM = /^([01]\d|2[0-3]):[0-5]\d$/;
/** "UTC" or an IANA-looking "Area/Location" (the API checks it resolves). */
const IANA_ZONE = /^(UTC|[A-Za-z]+(\/[A-Za-z0-9_+-]+)+)$/;
/** The Timezone field's suggestions; any other IANA zone can be typed. */
export const COMMON_TIMEZONES = [
"UTC",
"America/New_York",
"America/Chicago",
"America/Denver",
"America/Phoenix",
"America/Los_Angeles",
"America/Anchorage",
"Pacific/Honolulu",
"America/Halifax",
"America/St_Johns",
"America/Toronto",
"America/Vancouver",
"America/Mexico_City",
"America/Sao_Paulo",
"Europe/London",
"Europe/Dublin",
"Europe/Paris",
"Europe/Berlin",
"Europe/Madrid",
"Europe/Rome",
"Europe/Athens",
"Africa/Johannesburg",
"Asia/Dubai",
"Asia/Kolkata",
"Asia/Singapore",
"Asia/Shanghai",
"Asia/Tokyo",
"Australia/Perth",
"Australia/Sydney",
"Pacific/Auckland",
];
/**
* The browser's own IANA zone — the location-derived default for an unset
* household timezone — or "" when it is missing or not a usable zone name.
*/
export function detectTimezone(): string {
try {
const zone = Intl.DateTimeFormat().resolvedOptions().timeZone;
return typeof zone === "string" && zone !== "Etc/Unknown" && IANA_ZONE.test(zone) ? zone : "";
} catch {
return "";
}
}
/** The form's working copy: the number is text so it can be mid-edit. */ /** The form's working copy: the number is text so it can be mid-edit. */
type PreferencesDraft = Omit<HouseholdSettings, "horizon_days"> & { horizon_days: string }; type PreferencesDraft = Omit<HouseholdSettings, "horizon_days"> & { horizon_days: string };
@@ -275,11 +326,16 @@ function buildChanges(draft: PreferencesDraft, loaded: HouseholdSettings): Setti
for (const [key, label] of times) { for (const [key, label] of times) {
if (!HH_MM.test(draft[key])) return `${label} must be a time like 07:30.`; if (!HH_MM.test(draft[key])) return `${label} must be a time like 07:30.`;
} }
const timezone = draft.timezone.trim();
if (timezone && !IANA_ZONE.test(timezone)) {
return "Timezone must be an IANA zone like America/New_York, or UTC.";
}
const next: HouseholdSettings = { const next: HouseholdSettings = {
...draft, ...draft,
horizon_days: horizonDays, horizon_days: horizonDays,
notify_target: draft.notify_target.trim(), notify_target: draft.notify_target.trim(),
timezone,
}; };
const changes: SettingsChanges = {}; const changes: SettingsChanges = {};
for (const key of Object.keys(loaded) as (keyof HouseholdSettings)[]) { for (const key of Object.keys(loaded) as (keyof HouseholdSettings)[]) {
@@ -357,6 +413,25 @@ function PreferencesForm({
/> />
</label> </label>
{timeInput("day_rollover_time", "Day rollover")} {timeInput("day_rollover_time", "Day rollover")}
<label className="settings-field">
<span className="settings-label">Timezone</span>
<input
className="settings-input"
type="text"
list="settings-timezones"
placeholder="Host local time"
autoComplete="off"
spellCheck={false}
value={draft.timezone}
disabled={disabled}
onChange={(event) => update({ timezone: event.target.value })}
/>
</label>
<datalist id="settings-timezones">
{COMMON_TIMEZONES.map((zone) => (
<option key={zone} value={zone} />
))}
</datalist>
<label className="settings-field"> <label className="settings-field">
<span className="settings-label">Notify target</span> <span className="settings-label">Notify target</span>
<input <input
@@ -495,6 +570,8 @@ export default function SettingsScreen({ onClose }: { onClose: () => void }) {
} | null>(null); } | null>(null);
// A ref, not state: two taps in one frame must not both see `busy === null`. // A ref, not state: two taps in one frame must not both see `busy === null`.
const inFlight = useRef(false); const inFlight = useRef(false);
// The unset-timezone auto-set fires at most once per screen.
const timezoneAutoSet = useRef(false);
const screenRef = useRef<HTMLDivElement>(null); const screenRef = useRef<HTMLDivElement>(null);
const titleRef = useRef<HTMLHeadingElement>(null); const titleRef = useRef<HTMLHeadingElement>(null);
const addRef = useRef<HTMLButtonElement>(null); const addRef = useRef<HTMLButtonElement>(null);
@@ -539,8 +616,27 @@ export default function SettingsScreen({ onClose }: { onClose: () => void }) {
let cancelled = false; let cancelled = false;
setPreferences({ kind: "loading" }); setPreferences({ kind: "loading" });
fetchSettings() fetchSettings()
.then((settings) => { .then(async (settings) => {
if (!cancelled) setPreferences({ kind: "ready", settings }); if (cancelled) return;
// Unset: adopt the browser's zone before the form shows, so nothing
// the admin enters can be overwritten by it.
const detected = settings.timezone === "" ? detectTimezone() : "";
if (!detected || timezoneAutoSet.current) {
setPreferences({ kind: "ready", settings });
return;
}
timezoneAutoSet.current = true;
try {
const saved = await updateSettings({ timezone: detected });
if (!cancelled) setPreferences({ kind: "ready", settings: saved });
} catch (error) {
if (cancelled) return;
setPreferences({ kind: "ready", settings });
setPreferencesMessage({
kind: "error",
text: errorMessage(error, `The timezone could not be set to ${detected}`),
});
}
}) })
.catch((error: unknown) => { .catch((error: unknown) => {
if (cancelled) return; if (cancelled) return;
+1 -1
View File
@@ -15,5 +15,5 @@
"iot_class": "local_polling", "iot_class": "local_polling",
"issue_tracker": "https://github.com/Maniacs-Mansion/NestQuest/issues", "issue_tracker": "https://github.com/Maniacs-Mansion/NestQuest/issues",
"requirements": [], "requirements": [],
"version": "0.9.2" "version": "0.9.3"
} }
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "nestquest" name = "nestquest"
version = "0.9.2" version = "0.9.3"
description = "NestQuest Home Assistant Integration" description = "NestQuest Home Assistant Integration"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
Generated
+1 -1
View File
@@ -373,7 +373,7 @@ wheels = [
[[package]] [[package]]
name = "nestquest" name = "nestquest"
version = "0.9.2" version = "0.9.3"
source = { virtual = "." } source = { virtual = "." }
[package.dev-dependencies] [package.dev-dependencies]