Compare commits

..
Author SHA1 Message Date
overseerandClaude Opus 5.5 77cc8428d0 Admin Settings: edit the household timezone and auto-set it from the browser zone when unset
Adds timezone to the HouseholdSettings client type and a Timezone field
(text input with a datalist of common IANA zones) to the Preferences form.
Save validates the value as UTC or an Area/Location name and patches it
only when changed. When the loaded timezone is empty, the screen patches
the browser's Intl zone once before showing the form, so nothing the admin
enters can be overwritten; a missing or unusable zone leaves the field empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 12:59:55 -04:00
orchestrator-agent 33fa3b7c17 Merge pull request 'Reconcile dev with main after the 0.9.2 release' (#265) from main into dev 2026-09-26 12:56:13 -04:00
overseer a329d10c0a Merge pull request 'Release: admin delete/retire a task (0.9.2) (2026-09-26)' (#264) from release/2026-09-26-delete-task into main
Reviewed-on: #264
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-09-26 12:54:49 -04:00
orchestrator-agent 773bee6191 release: advance version to 0.9.2 and add the 0.9.2 release notes 2026-09-26 16:51:03 +00:00
orchestrator-agent 1bf2122a4a Merge pull request 'Reconcile dev with main after the 0.9.1 release' (#263) from main into dev 2026-09-26 12:49:37 -04:00
dev_agent 02f4d8c8aa Admin hybrid delete/retire of a quest definition (Delete Task) (#262)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-26 12:47:34 -04:00
overseer 305b03e0b1 Merge pull request 'Release: UI bug fixes (0.9.1) (2026-09-26)' (#261) from release/2026-09-26-ui-bugs into main
Reviewed-on: #261
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-09-26 12:32:03 -04:00
orchestrator-agent 0d07fb4f46 release: advance version to 0.9.1 and add the 0.9.1 release notes 2026-09-26 12:20:35 -04:00
7 changed files with 337 additions and 7 deletions
+78
View File
@@ -1,5 +1,83 @@
# NestQuest Release Notes
## Version 0.9.2 — 2026-09-26
### Scope
Patch release carrying the admin "Delete task" capability onto `main`.
- **Delete a task in the admin PWA:** the definition edit sheet gains an
admin-only Delete action with hybrid semantics (D-017, owner decision "delete
if there's no history, retire/deactivate if there is"). If the definition has
**no completion history**, it is hard-deleted together with its instances,
assignees, windows and its schedule rule (when unreferenced), all in one
transaction under the connection lock. If it has **any completion history** (a
completion or an un-completion), nothing is deleted: it is retired by
deactivation instead, preserving the definition, rule, assignees, windows and
all history, with future uncompleted instances cleared as with any
deactivation. (PR #262.)
- **API:** `DELETE /api/v1/admin/quest-definitions/{id}` lives on the admin plane
(`require_admin`; a non-admin is refused and an unknown id is 404). The response
reports `deleted`/`retired` and carries the retired definition (`null` when
deleted). The kids' panel gains no control, and `QuestDefinitionsDao` now has
exactly one, history-guarded, delete method.
### Upgrade note
The database schema is unchanged at version **9**; no migration or backup step is
needed beyond the 0.8.0 note. 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.1 — 2026-09-26
### Scope
Patch release carrying the owner-reported UI-bug fixes for the kids' panel, plus a
household-timezone correction, onto `main`.
- **Household-local time:** a new `timezone` setting (an IANA name such as
`America/New_York`; empty keeps the API host's local time) is threaded into the
API's clock reads, so overdue and the daily rollover use the household's day
rather than a UTC host's. **Set it once after upgrading** (see the Upgrade
note); the admin UI has no field for it yet. (PR #254.)
- **Overdue and rollover correctness:** quests are no longer marked overdue
before their due time, and the missed-sweep scheduler fires at the configured
rollover across DST transitions — its delay is now a true elapsed-time
difference, with documented handling of nonexistent/ambiguous local times.
(PRs #254, #257.)
- **Complete works on the panel:** a TypeScript ES2022 `useDefineForClassFields`
default emitted native class fields that shadowed Lit's reactive accessors, so
the confirm dialog never re-rendered and a tap appeared to do nothing; the
panel now opens the confirm and completes. (PR #254.)
- **Panel polish:** the party-board hint line no longer hides behind the dock
(#244); the Quest Log header crest renders with its shield classes (#252); the
party-board wordmark is the new compass-rose monogram plus Cinzel Decorative
text, replacing the d20 dice (#253); and the quest meta line keeps its `meta`
class when not late (#255).
### Upgrade note
The database schema is unchanged at version **9**. After upgrading, set the
household timezone once so date, overdue and rollover behaviour match the house
(otherwise it falls back to the API host's local time, which may be UTC):
```
PATCH /api/v1/admin/settings {"timezone": "America/New_York"}
```
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.0 — 2026-09-26
### Scope
+2
View File
@@ -17,6 +17,8 @@ export interface HouseholdSettings {
afternoon_reminder_enabled: boolean;
end_of_day_report_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. */
+155 -1
View File
@@ -1,3 +1,4 @@
import { StrictMode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import App from "../App";
@@ -21,6 +22,7 @@ const DEFAULT_SETTINGS: HouseholdSettings = {
afternoon_reminder_enabled: false,
end_of_day_report_enabled: true,
celebration_enabled: false,
timezone: "America/Chicago",
};
function child(
@@ -370,10 +372,11 @@ describe("SettingsScreen — 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();
expect(field(form, "Planning horizon (days)").value).toBe("14");
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, "Morning summary time").value).toBe("07:00");
expect(field(form, "Afternoon reminder time").value).toBe("15:30");
@@ -496,6 +499,46 @@ describe("SettingsScreen — preferences", () => {
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 () => {
let fail = true;
fetchMock.mockImplementation(async (url: string, init?: RequestInit) => {
@@ -512,3 +555,114 @@ describe("SettingsScreen — 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.
*
* 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.
*/
@@ -252,6 +253,56 @@ const NOTIFICATIONS: { label: string; time: TimeField; toggle: ToggleField }[] =
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. */
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) {
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 = {
...draft,
horizon_days: horizonDays,
notify_target: draft.notify_target.trim(),
timezone,
};
const changes: SettingsChanges = {};
for (const key of Object.keys(loaded) as (keyof HouseholdSettings)[]) {
@@ -357,6 +413,25 @@ function PreferencesForm({
/>
</label>
{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">
<span className="settings-label">Notify target</span>
<input
@@ -495,6 +570,8 @@ export default function SettingsScreen({ onClose }: { onClose: () => void }) {
} | null>(null);
// A ref, not state: two taps in one frame must not both see `busy === null`.
const inFlight = useRef(false);
// The unset-timezone auto-set fires at most once per screen.
const timezoneAutoSet = useRef(false);
const screenRef = useRef<HTMLDivElement>(null);
const titleRef = useRef<HTMLHeadingElement>(null);
const addRef = useRef<HTMLButtonElement>(null);
@@ -539,8 +616,27 @@ export default function SettingsScreen({ onClose }: { onClose: () => void }) {
let cancelled = false;
setPreferences({ kind: "loading" });
fetchSettings()
.then((settings) => {
if (!cancelled) setPreferences({ kind: "ready", settings });
.then(async (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) => {
if (cancelled) return;
+1 -1
View File
@@ -15,5 +15,5 @@
"iot_class": "local_polling",
"issue_tracker": "https://github.com/Maniacs-Mansion/NestQuest/issues",
"requirements": [],
"version": "0.9.0"
"version": "0.9.2"
}
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "nestquest"
version = "0.9.0"
version = "0.9.2"
description = "NestQuest Home Assistant Integration"
readme = "README.md"
requires-python = ">=3.11"
Generated
+1 -1
View File
@@ -373,7 +373,7 @@ wheels = [
[[package]]
name = "nestquest"
version = "0.9.0"
version = "0.9.2"
source = { virtual = "." }
[package.dev-dependencies]