Compare commits

..
Author SHA1 Message Date
overseerandClaude Opus 5.5 4e0474ef71 Describe settings form test accurately: ten form fields, timezone is API-only
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 10:08:46 -04:00
overseerandClaude Opus 5.5 e287c145b0 Correct stale settings-count and API clock docstrings
The household-timezone change added an eleventh setting (timezone) and
made the API's household-local dates derive from it. Update the admin
routes' settings, missed-sweep and occurrence-preview docstrings and
core/sweep.py's docstrings to say so (empty timezone falls back to
host local). Documentation only; no behaviour change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 10:04:43 -04:00
dev_agent 7adee7c3c0 Feature review remediation: DST-correct missed-sweep scheduler delay (#257) 2026-09-26 09:58:16 -04:00
overseerandClaude Opus 5.5 59e072602b Merge remote-tracking branch 'origin/dev' into feature/916210e7
Brings the expanded task icons feature (#248) into the UI-bugs branch.
Conflicts resolved:
- tests/test_panel_cards.py: kept both the UI-bugs tests (tap-to-complete,
  Lit accessor tsconfig, crest classes/geometry, meta class) and the icon
  tile tests (registry-backed open tile, star fallback, sealed star).
- frontend/tests/render-quest-log.mjs: merged doc comment to describe both
  the tiles and tap payload fields (code auto-merged).
- custom_components/nestquest/www/nestquest-cards.js: regenerated with
  npm run build from the merged source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 09:45:29 -04:00
dev_agent 87566689aa Fix quest-log meta line losing its class when not late (#255) 2026-09-26 09:42:28 -04:00
dev_agent 744d80e645 Quest bugs: household-local overdue clock and the panel Complete no-op (#254) 2026-09-26 09:37:01 -04:00
dev_agent 8f38946c04 Update NestQuest logo on the party board (new monogram wordmark) (#253) 2026-09-26 09:18:38 -04:00
dev_agent 9916d83138 Fix the Quest Log header crest/shield to match the design (resubmission of PR #247) (#252) 2026-09-26 09:08:59 -04:00
dev_agent c5b313a5e3 Fix hidden hint text behind the party-board dock (#244) 2026-09-26 08:09:51 -04:00
33 changed files with 368 additions and 2681 deletions
-320
View File
@@ -1,325 +1,5 @@
# NestQuest Release Notes
## Version 0.9.7 — 2026-09-29
### Scope
Patch release carrying the panel-local overdue correction onto `main` (PR #279,
task fd1093b5 "Tasks are still showing over due when the local time has not
passed").
### Behaviour
- The kids' Quest Log card now decides whether an open quest is OVERDUE from
the panel's OWN local clock (the wall time where the panel physically is)
compared with the quest's due time, instead of trusting the API snapshot's
`overdue` flag. A quest therefore cannot read OVERDUE before its due time has
passed on the panel, regardless of the API host's timezone, Home Assistant's
configured `time_zone`, or whether the 0.9.6 timezone changes have been
deployed. A quest is overdue at the due minute and after (`>=`); a quest with
no due time is never overdue.
- The API's `overdue` field is unchanged; it still serves the admin plane and
automations. This is a panel display correction only.
### Requirements
- Home Assistant floor 2024.6.0 (unchanged).
### Breaking changes
- None. The database schema is unchanged at version 9: no migration and no
database backup is required for this release.
### Known limitations
- The comparison uses only wall-clock time. If the API is unreachable across
local midnight and the coordinator serves its last-good snapshot, a quest
from the previous day with a later due time can briefly read as not overdue
until that time passes again. On the normal path the API rolls the household
day over and the missed sweep removes the previous day's open quests.
- The Quest Log header clock still formats in Home Assistant's configured
`time_zone`; if HA is set to a zone other than the household's, that header
remains wrong. Set Home Assistant's own `time_zone` correctly — the admin/API
timezone setting does not affect the header clock.
### Rollback
- No schema change: rolling back to 0.9.6 requires no database restore.
## Version 0.9.6 — 2026-09-28
### Scope
Patch release carrying the household time-zone fix onto `main` (PR #275, task
6351b36d "Time zone errors"), plus its release-review correction (PR #277, task
45243478).
### Behaviour
- The Home Assistant integration now reports `hass.config.time_zone` to the API
in an `X-NestQuest-Timezone` header on every panel request.
- While the API's `timezone` setting has never been explicitly chosen
(`timezone_configured` false), the API adopts and persists the reported zone
(under the settings lock), so the panel's overdue/today calculation, the
admin plane and the missed-sweep scheduler are all household-local even when
the API host runs UTC. No manual setup is required.
- The missed-sweep scheduler re-plans immediately when the adopted zone changes
while it is sleeping, and re-reads the stored zone before sweeping, so it no
longer fires at the old (UTC) rollover.
- An admin-chosen zone — including an explicit choice of empty (API-host local)
— still wins over the reported zone. A timezone-only admin update is recorded
as an explicit choice (`timezone_configured` true); an explicitly supplied
`timezone_configured` value in the same update is honored as given. An invalid
reported zone is logged and ignored; the panel keeps serving on the stored
settings.
### Requirements
- Home Assistant floor 2024.6.0 (unchanged).
### Breaking changes
- None. The database schema is unchanged at version 9: no migration and no
database backup is required for this release.
- Deployment note: this fix needs BOTH the API service and the HA integration
upgraded. The integration supplies the zone and the API adopts it; upgrading
only one side leaves the previous behaviour. An install that sets or clears
its zone on 0.9.6 or later keeps that choice; see Known limitations for a zone
stored by an older release.
### Known limitations
- A zone stored by a release before 0.9.6 without the explicit-choice marker
(for example through the previously documented
`PATCH /api/v1/admin/settings {"timezone": ...}`) is not distinguishable from
an automatically adopted value, so 0.9.6 adopts Home Assistant's reported zone
over it once. If a zone different from Home Assistant's is intended, re-save
it in the admin Preferences, which now records the explicit choice.
- If `timezone` is empty and `timezone_configured` is true (an explicit
"host local" choice), that choice is respected and the API host's zone (UTC
on the standard deployment) still applies until an admin selects a zone in
the admin Preferences.
- A change to Home Assistant's own `time_zone` takes effect after the
integration reloads or Home Assistant restarts.
### Rollback
- No schema change: rolling back to 0.9.5 requires no database restore. The API
and the integration may be rolled back independently; rolling back only the
integration restores the previous stored-setting-only behaviour.
## Version 0.9.5 — 2026-09-27
### Scope
Patch release carrying the kids' panel Return button onto `main`.
- **Return button:** the Quest Log header now shows an explicit Return control —
a left chevron plus "The Party", 72px high and sized to its label, sitting left
of the crest — so a child
can go straight back to the main Party board instead of waiting out the 40 s
idle return. It calls the same navigation the idle return uses
(`board_path` + `location-changed`), so there is exactly one navigation path.
It is hidden when no board path is configured, appears on every header-bearing
Quest Log screen (normal, away, and "no quests today"), and does not appear on
the Quest-complete screen, which keeps its 12 s auto-return. The control meets
the panel's hard rules (72px tap target, 26px label, `:active` feedback only,
clear of the window columns and the dock), and the 40 s idle return and
crest-tap navigation are unchanged. (PR #272.)
### Upgrade note
The database schema is unchanged at version **9**; no migration or backup step is
needed. No runtime dependency is added — only the bundled panel card changed.
### 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.4 — 2026-09-26
### Scope
Patch release fixing the admin timezone control that shipped in 0.9.3.
- **Clearing the timezone now persists:** in 0.9.3 the Settings screen
re-detected the browser zone whenever the timezone field was empty, so an admin
could not leave it empty ("use the API host's local time") — closing and
reopening the screen set it again. A new persisted, API-only
`timezone_configured` marker (default `false`) now distinguishes "never set"
from "admin chose empty": the one-time auto-set runs only when the timezone is
empty AND the marker is false, and any saved timezone change — including
clearing it — sets the marker, so an explicit empty choice survives. (PRs #266,
#268.)
- Nothing else changes: the `timezone` setting, its auto-set from the browser zone
on a fresh install, the Add/Save flow, and the rest of the Settings screen are
as in 0.9.3.
### Upgrade note
The database schema is unchanged at version **9**; no migration or backup step is
needed. Settings stored before this release have no `timezone_configured` value
and read it as `false`, so an install whose timezone is empty will auto-set the
browser zone once on the next open — after which the choice persists.
### 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.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
### 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
Minor release carrying expanded, customisable task icons — Lucide, Font Awesome
Free and emoji — onto `main`.
- **Namespaced icon keys:** a quest definition's `icon` now accepts
`lucide:<kebab-name>`, `fa:<kebab-name>` and `emoji:<grapheme>`. A legacy bare
name (e.g. `dog`) is read as `lucide:dog`, so existing rows keep working with
no stored-data migration. The strict write validator rejects an unknown
namespace, a malformed name, an over-cap or markup-bearing emoji payload, and
`javascript:`. (PRs #241, #249.)
- **Shared icon registry:** one canonical set — the curated Lucide set plus a
curated Font Awesome Free solid subset — generates identical,
framework-agnostic registry modules for the admin and panel bundles, guarded by
a drift test. There is no CDN and no runtime fetch, and only the curated Font
Awesome glyphs are shipped. (PR #242.)
- **Admin PWA picker:** the definition edit sheet offers the Lucide set, the
Font Awesome subset and an emoji input; a selection stores the namespaced key
and an existing namespaced or legacy icon pre-fills correctly. The definition
list renders all three forms with a fallback glyph. Editing a definition whose
stored icon is a legacy value no longer resubmits the unchanged icon.
(PRs #243, #250.)
- **Kids' panel:** open quest cards render the definition's icon (Lucide stroked,
Font Awesome filled, emoji as text) with the existing star fallback; the sealed
card keeps its star/seal glyph. (PR #245.)
- **Docs and attribution:** ADMIN-SPEC and PANEL-SPEC describe the namespaced
model; `THIRD-PARTY-NOTICES.md` records Font Awesome Free (icons CC BY 4.0,
fonts SIL OFL 1.1, code MIT) and Lucide (ISC). (PR #246.)
### Upgrade note
The database schema is unchanged at version **9**; no migration or backup step
is needed beyond the 0.8.0 note. This release adds no runtime dependency — the
icon path data is embedded in the shipped bundles at build time.
### 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.8.0 — 2026-09-25
### Scope
+1 -27
View File
@@ -147,36 +147,10 @@ export async function updateDefinition(
return readJson<QuestDefinition>(await sendJson(`${DEFINITIONS_PATH}/${id}`, "PATCH", body));
}
/** `DELETE /quest-definitions/{id}`: exactly one of `deleted` / `retired` is true. */
export interface DefinitionDeleteResult {
definition_id: number;
deleted: boolean;
retired: boolean;
/** The retired (now inactive) definition; null when it was deleted. */
definition: QuestDefinition | null;
}
/**
* Delete a task if it has no completion history; otherwise the API retires
* (deactivates) it. Retirement preserves the definition row, schedule rule,
* assignees, windows and ALL durable completion history. Like any
* deactivation it regenerates, so future UNCOMPLETED instances are cleared
* (rolling, regenerable rows) and not recreated while inactive; durable
* history lives only in the append-only `completion_events`.
*/
export async function deleteDefinition(id: number): Promise<DefinitionDeleteResult> {
return readJson<DefinitionDeleteResult>(
await apiFetch(`${DEFINITIONS_PATH}/${id}`, { method: "DELETE" }),
);
}
export const OCCURRENCES_PREVIEW_PATH = `${DEFINITIONS_PATH}/occurrences-preview`;
export interface OccurrencesPreviewOptions {
/**
* "YYYY-MM-DD"; the API defaults to the household-local today in the
* configured `timezone` (the API host's local time when that is empty).
*/
/** "YYYY-MM-DD"; the API defaults to its host's today. */
startDate?: string;
/** 1..50; the API defaults to 10. */
count?: number;
-2
View File
@@ -17,8 +17,6 @@ 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. */
-20
View File
@@ -569,26 +569,6 @@
color: #fff;
}
.defs-save--danger {
background: var(--nq-a-danger);
}
.defs-delete {
display: block;
width: 100%;
margin-top: 10px;
padding: 11px 0;
border: 1px solid var(--nq-a-danger-bg);
border-radius: var(--nq-a-density-radius);
background: var(--nq-a-surface);
color: var(--nq-a-danger-strong);
font-family: inherit;
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
.defs-delete:disabled,
.defs-cancel:disabled,
.defs-save:disabled {
opacity: 0.6;
+3 -86
View File
@@ -121,8 +121,6 @@ function fakeApi(initial: QuestDefinition[]) {
const calls: Call[] = [];
let nextWrite: Response | null = null;
let preview: PreviewHandler = async () => jsonResponse({ dates: [] });
// Definitions with completion history: the API retires these on DELETE.
const withHistory = new Set<number>();
const fetchMock = vi.fn(async (input: RequestInfo | URL, init: RequestInit = {}) => {
const path = new URL(String(input), "http://localhost").pathname;
const method = init.method ?? "GET";
@@ -189,16 +187,6 @@ function fakeApi(initial: QuestDefinition[]) {
);
return jsonResponse(definitions.find((d) => d.id === id));
}
if (method === "DELETE" && match) {
const id = Number(match[1]);
if (withHistory.has(id)) {
definitions = definitions.map((d) => (d.id === id ? { ...d, is_active: false } : d));
const retired = definitions.find((d) => d.id === id);
return jsonResponse({ definition_id: id, deleted: false, retired: true, definition: retired });
}
definitions = definitions.filter((d) => d.id !== id);
return jsonResponse({ definition_id: id, deleted: true, retired: false, definition: null });
}
return jsonResponse({ detail: "unexpected" }, 500);
});
return {
@@ -215,9 +203,6 @@ function fakeApi(initial: QuestDefinition[]) {
failNextWrite: (response: Response) => {
nextWrite = response;
},
markHistory: (id: number) => {
withHistory.add(id);
},
};
}
@@ -593,15 +578,14 @@ describe("DefinitionsTab", () => {
fireEvent.click(screen.getByTestId("definition-10"));
const s = sheet();
const title = within(s).getByRole("textbox", { name: "Title" });
// The edit sheet's last focusable control is its Delete button.
const last = within(s).getByRole("button", { name: "Delete task" });
const save = within(s).getByRole("button", { name: "Save changes" });
last.focus();
save.focus();
fireEvent.keyDown(s, { key: "Tab" });
expect(document.activeElement).toBe(title);
fireEvent.keyDown(s, { key: "Tab", shiftKey: true });
expect(document.activeElement).toBe(last);
expect(document.activeElement).toBe(save);
});
it("Escape does not close the sheet while saving", async () => {
@@ -710,73 +694,6 @@ describe("DefinitionsTab", () => {
);
});
it("the Delete control is only offered for an existing task", async () => {
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "New" }));
expect(within(sheet()).queryByRole("button", { name: "Delete task" })).toBeNull();
fireEvent.click(within(sheet()).getByRole("button", { name: "Cancel" }));
fireEvent.click(screen.getByTestId("definition-10"));
expect(within(sheet()).getByRole("button", { name: "Delete task" })).toBeTruthy();
});
it("Delete asks first, explaining delete-vs-retire; Go back writes nothing", async () => {
await renderReady();
fireEvent.click(screen.getByTestId("definition-10"));
fireEvent.click(within(sheet()).getByRole("button", { name: "Delete task" }));
const confirm = screen.getByTestId("delete-confirm");
expect(confirm.textContent).toContain("If it has no completion history it is deleted");
expect(confirm.textContent).toContain("otherwise it is retired");
expect(api.writes()).toHaveLength(0);
fireEvent.click(within(confirm).getByRole("button", { name: "Go back" }));
expect(screen.queryByTestId("delete-confirm")).toBeNull();
expect(api.writes()).toHaveLength(0);
});
it("confirming Delete on a task without history removes its row", async () => {
await renderReady();
fireEvent.click(screen.getByTestId("definition-10"));
fireEvent.click(within(sheet()).getByRole("button", { name: "Delete task" }));
fireEvent.click(
within(screen.getByTestId("delete-confirm")).getByRole("button", { name: "Delete task" }),
);
await waitFor(() => expect(screen.queryByTestId("definition-10")).toBeNull());
expect(screen.queryByRole("dialog")).toBeNull();
expect(api.writes()).toEqual([
{ method: "DELETE", path: "/api/v1/admin/quest-definitions/10", body: null },
]);
expect(screen.getByTestId("definition-11")).toBeTruthy();
});
it("confirming Delete on a task with history shows it retired (inactive)", async () => {
await renderReady();
api.markHistory(10);
fireEvent.click(screen.getByTestId("definition-10"));
fireEvent.click(within(sheet()).getByRole("button", { name: "Delete task" }));
fireEvent.click(
within(screen.getByTestId("delete-confirm")).getByRole("button", { name: "Delete task" }),
);
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
const row = screen.getByTestId("definition-10");
expect(row.className).toContain("defs-row--inactive");
expect(meta(row)).toContain("· inactive");
expect(api.writes().map((c) => c.method)).toEqual(["DELETE"]);
});
it("a failed Delete shows the API's detail and keeps the sheet open", async () => {
await renderReady();
api.failNextWrite(jsonResponse({ detail: "Quest definition not found" }, 404));
fireEvent.click(screen.getByTestId("definition-10"));
fireEvent.click(within(sheet()).getByRole("button", { name: "Delete task" }));
fireEvent.click(
within(screen.getByTestId("delete-confirm")).getByRole("button", { name: "Delete task" }),
);
expect((await screen.findByTestId("sheet-error")).textContent).toBe(
"Quest definition not found",
);
expect(screen.getByRole("dialog")).toBeTruthy();
expect(screen.getByTestId("definition-10")).toBeTruthy();
});
it("scroll column carries the 92px bottom padding", async () => {
await renderReady();
expect(screen.getByTestId("definitions-scroll").style.paddingBottom).toBe("92px");
+10 -92
View File
@@ -13,14 +13,12 @@ import { ApiForbiddenError } from "../api/client";
import {
ApiRequestError,
createDefinition,
deleteDefinition,
fetchChildren,
fetchDefinitions,
previewOccurrences,
updateDefinition,
type AdminChild,
type DefinitionCreateBody,
type DefinitionDeleteResult,
type DefinitionEditBody,
type DefinitionRule,
type QuestDefinition,
@@ -275,12 +273,6 @@ function saveErrorMessage(error: unknown): string {
return "The task could not be saved. Check your connection and try again.";
}
function deleteErrorMessage(error: unknown): string {
if (error instanceof ApiForbiddenError) return error.message;
if (error instanceof ApiRequestError && error.detail) return error.detail;
return "The task could not be deleted. Check your connection and try again.";
}
function toggle<T>(list: T[], value: T): T[] {
return list.includes(value) ? list.filter((item) => item !== value) : [...list, value];
}
@@ -457,13 +449,11 @@ function EditSheet({
activeChildren,
onCancel,
onSaved,
onDeleted,
}: {
target: "new" | QuestDefinition;
activeChildren: AdminChild[];
onCancel: () => void;
onSaved: () => void;
onDeleted: (result: DefinitionDeleteResult) => void;
}) {
const [draft, setDraft] = useState<Draft>(() =>
target === "new" ? newDraft(activeChildren) : draftFrom(target, activeChildren),
@@ -483,7 +473,6 @@ function EditSheet({
const [pickerOpen, setPickerOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [confirming, setConfirming] = useState(false);
const [confirmingDelete, setConfirmingDelete] = useState(false);
const [error, setError] = useState<string | null>(null);
// A ref, not state: two taps in one frame must not both see `saving === false`.
const inFlight = useRef(false);
@@ -501,8 +490,8 @@ function EditSheet({
}, []);
useEffect(() => {
if (confirming || confirmingDelete) confirmRef.current?.focus();
}, [confirming, confirmingDelete]);
if (confirming) confirmRef.current?.focus();
}, [confirming]);
function onKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
if (event.key === "Escape") {
@@ -570,21 +559,6 @@ function EditSheet({
}
}
async function remove() {
if (target === "new" || inFlight.current) return;
setConfirmingDelete(false);
inFlight.current = true;
setSaving(true);
setError(null);
try {
onDeleted(await deleteDefinition(target.id));
} catch (err) {
setError(deleteErrorMessage(err));
inFlight.current = false;
setSaving(false);
}
}
return (
<div className="defs-sheet-layer">
<div
@@ -932,56 +906,15 @@ function EditSheet({
</button>
</div>
</div>
) : confirmingDelete ? (
<div
className="defs-confirm"
role="group"
aria-labelledby="defs-delete-confirm-text"
data-testid="delete-confirm"
ref={confirmRef}
tabIndex={-1}
>
<p id="defs-delete-confirm-text">
Delete this task? If it has no completion history it is deleted; otherwise it is
retired (deactivated) and its history is kept.
</p>
<div className="defs-sheet-footer">
<button
type="button"
className="defs-cancel"
onClick={() => setConfirmingDelete(false)}
>
Go back
</button>
<button type="button" className="defs-save defs-save--danger" onClick={remove}>
Delete task
</button>
</div>
</div>
) : (
<>
<div className="defs-sheet-footer">
<button type="button" className="defs-cancel" onClick={onCancel} disabled={saving}>
Cancel
</button>
<button type="button" className="defs-save" onClick={() => save()} disabled={saving}>
{saving ? "Saving…" : "Save changes"}
</button>
</div>
{target !== "new" ? (
<button
type="button"
className="defs-delete"
disabled={saving}
onClick={() => {
setError(null);
setConfirmingDelete(true);
}}
>
Delete task
</button>
) : null}
</>
<div className="defs-sheet-footer">
<button type="button" className="defs-cancel" onClick={onCancel} disabled={saving}>
Cancel
</button>
<button type="button" className="defs-save" onClick={() => save()} disabled={saving}>
{saving ? "Saving…" : "Save changes"}
</button>
</div>
)}
</div>
</div>
@@ -1105,21 +1038,6 @@ export default function DefinitionsTab() {
setSheet(null);
setAttempt((n) => n + 1);
}}
onDeleted={(result) => {
setSheet(null);
setState((current) =>
current.kind !== "ready"
? current
: {
...current,
definitions: result.definition
? current.definitions.map((d) =>
d.id === result.definition_id ? result.definition! : d,
)
: current.definitions.filter((d) => d.id !== result.definition_id),
},
);
}}
/>
) : null}
</div>
+1 -132
View File
@@ -1,4 +1,3 @@
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";
@@ -22,7 +21,6 @@ const DEFAULT_SETTINGS: HouseholdSettings = {
afternoon_reminder_enabled: false,
end_of_day_report_enabled: true,
celebration_enabled: false,
timezone: "America/Chicago",
};
function child(
@@ -372,11 +370,10 @@ describe("SettingsScreen — preferences", () => {
fireEvent.click(within(form).getByRole("button", { name: "Save preferences" }));
}
it("renders the current value of all eleven settings form fields", async () => {
it("renders the current value of the ten settings form fields (timezone is API-only)", 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");
@@ -499,48 +496,6 @@ 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) => {
@@ -557,89 +512,3 @@ describe("SettingsScreen — preferences", () => {
await screen.findByRole("form", { name: "Preferences" });
});
});
describe("SettingsScreen — no 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)];
// A fresh install: no zone stored.
settings = { ...DEFAULT_SETTINGS, timezone: "" };
writeResponse = null;
zoneSpy = null;
fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init));
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 empty timezone stays empty on load and nothing is written", async () => {
mockBrowserZone("America/New_York");
const form = await renderForm();
expect(field(form, "Timezone").value).toBe("");
expect(writes()).toEqual([]);
expect(settings.timezone).toBe("");
});
it("StrictMode's doubled effects write nothing either", 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("");
expect(writes()).toEqual([]);
});
it("a stored timezone is shown as stored, never replaced by the browser zone", async () => {
mockBrowserZone("America/New_York");
settings = { ...DEFAULT_SETTINGS, timezone: "Europe/Dublin" };
const form = await renderForm();
expect(field(form, "Timezone").value).toBe("Europe/Dublin");
expect(writes()).toEqual([]);
});
it("saving another field sends only that field, never a timezone", async () => {
mockBrowserZone("America/New_York");
const form = await renderForm();
fireEvent.change(field(form, "Planning horizon (days)"), { target: { value: "21" } });
fireEvent.click(within(form).getByRole("button", { name: "Save preferences" }));
await screen.findByText("Preferences saved.");
expect(writes().map((w) => w.body)).toEqual([{ horizon_days: 21 }]);
expect(settings.timezone).toBe("");
});
it("an admin clearing the timezone saves exactly the empty zone", async () => {
mockBrowserZone("America/New_York");
settings = { ...DEFAULT_SETTINGS, timezone: "Europe/Dublin" };
const form = await renderForm();
fireEvent.change(field(form, "Timezone"), { target: { value: "" } });
fireEvent.click(within(form).getByRole("button", { name: "Save preferences" }));
await screen.findByText("Preferences saved.");
expect(writes().map((w) => w.body)).toEqual([{ timezone: "" }]);
expect(field(screen.getByRole("form", { name: "Preferences" }), "Timezone").value).toBe("");
});
});
+1 -63
View File
@@ -5,8 +5,7 @@
* household's child profiles. Every write refetches the list.
*
* Preferences: the household settings (planning horizon, day rollover,
* timezone, notifications). Save patches only the changed fields. The
* timezone is only ever set by the admin saving it here.
* notifications). Save patches only the changed fields.
*
* One write at a time across both sections.
*/
@@ -253,43 +252,6 @@ 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 form's working copy: the number is text so it can be mid-edit. */
type PreferencesDraft = Omit<HouseholdSettings, "horizon_days"> & { horizon_days: string };
@@ -313,16 +275,11 @@ 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)[]) {
@@ -400,25 +357,6 @@ 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
+10 -81
View File
@@ -168,9 +168,9 @@ are deliberately not unified here:
- ``GET /settings`` returns the current effective settings: all eleven
documented fields (the rolling horizon, the day rollover time, the
notify target, the three notification times, the four enable
toggles, 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.
toggles and the ``timezone`` — the IANA household zone the
household-local dates derive from, empty meaning host local) — the
defaults on a fresh database.
- ``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
@@ -200,25 +200,6 @@ integration's rollover listener shares:
and the read-only-over-the-domain-tables guarantee all live in the
core; the response reports how many transitions were published.
The quest-definition DELETE route (task 3efb07b4) is the hybrid admin
delete (owner decision 2026-09-26: "delete if there's no history,
retire/deactivate if there is"):
- ``DELETE /quest-definitions/{definition_id}`` is a thin adapter over
:func:`nestquest_core.quest_definitions.delete_quest_definition`. A
definition with NO completion event on any instance is hard-deleted
(instances, assignees, windows, the row and its unreferenced rule, in
one core transaction); one WITH history is retired by deactivation.
Retirement preserves the definition row, schedule rule, assignees,
windows and ALL durable completion history; like any deactivation it
regenerates, so future UNCOMPLETED instances are cleared (rolling,
regenerable rows) and none are recreated while the definition is
inactive — the retired task stops generating and shows no open work.
Durable instance history lives only in the append-only
``completion_events``. The response reports which happened
(``deleted`` / ``retired``) and carries the retired definition, or
``null`` when deleted. An unknown id is 404.
The occurrence-preview route (task 5f843564) lets the admin PWA's
Definitions edit sheet show the next dates a rule fires on WITHOUT
reimplementing the recurrence rules client-side:
@@ -472,19 +453,6 @@ class AdminQuestDefinitionListResponse(BaseModel):
definitions: list[AdminQuestDefinitionResponse]
class AdminQuestDefinitionDeleteResponse(BaseModel):
"""The body ``DELETE /api/v1/admin/quest-definitions/{id}`` answers.
Exactly one of ``deleted`` / ``retired`` is true. ``definition`` is
the retired (now inactive) definition, or null when it was deleted.
"""
definition_id: int
deleted: bool
retired: bool
definition: AdminQuestDefinitionResponse | None
class AdminChildCreateRequest(BaseModel):
"""The body of ``POST /api/v1/admin/children``.
@@ -568,10 +536,9 @@ class AdminOccurrencesPreviewRequest(BaseModel):
``rule`` is the SAME object create/edit take. ``start_date`` is an
optional strict ``YYYY-MM-DD`` (validated by the core; default the
household-local today in the configured ``timezone``, empty falling
back to the API host's local time). ``count`` is a strict JSON
integer in 1..``_PREVIEW_MAX_COUNT`` — anything else is 422 before
any handler code runs.
API host's today). ``count`` is a strict JSON integer in
1..``_PREVIEW_MAX_COUNT`` — anything else is 422 before any handler
code runs.
"""
rule: AdminRuleRequest
@@ -1643,16 +1610,16 @@ async def admin_set_quest_definition_active(
body: AdminQuestDefinitionActiveRequest,
request: Request,
) -> AdminQuestDefinitionResponse:
"""Deactivate (or reactivate) a definition; it is never deleted here.
"""Deactivate (or reactivate) a definition; it is NEVER deleted.
Requires a valid admin JWT (the router's shared
:func:`~api.auth.require_admin` dependency — the ONE check; this
handler performs NO auth of its own). Thin adapter: ``is_active``
goes straight to
:func:`nestquest_core.quest_definitions.set_quest_definition_active`
— the row, rule, assignees and windows all survive, so completion
history keeps its references (the hybrid DELETE route is the only
hard-delete path, and only for a definition without history). An unknown definition id is mapped to 404
— deactivation is the only removal path (the row, rule, assignees
and windows all survive, so completion history keeps its
references). An unknown definition id is mapped to 404
(:func:`_raise_quest_definition_error`); the body model already
makes the core's real-bool check unreachable from this route.
"""
@@ -1666,44 +1633,6 @@ async def admin_set_quest_definition_active(
return _quest_definition_response(bundle)
@router.delete(
"/quest-definitions/{definition_id}",
summary="Delete a quest definition, or retire it if it has history",
response_model=AdminQuestDefinitionDeleteResponse,
)
async def admin_delete_quest_definition(
definition_id: int, request: Request
) -> AdminQuestDefinitionDeleteResponse:
"""Hard-delete a history-free definition; retire one with history.
Requires a valid admin JWT (the router's shared
:func:`~api.auth.require_admin` dependency — the ONE check; this
handler performs NO auth of its own). Thin adapter: the id goes
straight to
:func:`nestquest_core.quest_definitions.delete_quest_definition`,
which decides delete-vs-retire atomically in the core. An unknown
definition id is mapped to 404
(:func:`_raise_quest_definition_error`).
"""
state: DatabaseState = request.app.state.db
try:
result = await core_quest_definitions.delete_quest_definition(
state.database, definition_id
)
except ValueError as error:
_raise_quest_definition_error(error)
return AdminQuestDefinitionDeleteResponse(
definition_id=result.definition_id,
deleted=result.deleted,
retired=result.retired,
definition=(
None
if result.definition is None
else _quest_definition_response(result.definition)
),
)
@router.get(
"/children/{child_id}/presence-patterns",
summary="List a child's repeating presence patterns",
+4 -5
View File
@@ -9,8 +9,8 @@ this router inherits the check).
The snapshot route is a thin adapter over the bundled core: it performs
NO business logic. It reads the live database off ``app.state.db``,
resolves ONE timezone-aware ``now`` (the host clock re-expressed in the
household ``timezone`` the admin stored — the API host may run UTC
while the household does not), calls the pure
stored household ``timezone`` setting — the API host may run UTC while
the household does not), calls the pure
:func:`nestquest_core.snapshot.build_snapshot`, shapes each child's
instances through :func:`nestquest_core.snapshot.instance_payload` with
``include_missed=False`` (D-009: missed instances are OMITTED from the
@@ -185,9 +185,8 @@ async def _household_clock(
The API host may run UTC while the household does not, so the one
:func:`_local_now` read is re-expressed in the stored settings'
``timezone`` (:meth:`NestQuestSettings.household_now`). The zone
comes from the stored settings ONLY — nothing the request carries
can change it.
``timezone`` (:meth:`NestQuestSettings.household_now`); an unset
zone keeps the host's local time.
"""
settings = await core_settings_store.load_settings(database)
return settings, settings.household_now(_local_now())
+13 -100
View File
@@ -13,27 +13,6 @@ local date when unset) and publishes each returned
``nestquest_quest_missed`` event on the app's ONE transition publisher
(the same SSE stream every route publishes through).
A household zone changed while the loop is asleep
must not leave it waiting for the OLD zone's rollover (a UTC host would
otherwise sweep at UTC midnight — 20:00 in New York — marking quests
missed hours early). :meth:`MissedSweepScheduler.settings_changed`
wakes the sleeping loop to re-plan whenever the zone or rollover time
it planned with has moved, and the loop re-reads the settings before
sweeping and re-plans instead if they no longer match its plan.
Guarantee: no sweep runs for a plan a committed settings write has
replaced. A settings writer holds :attr:`MissedSweepScheduler.settings_lock`
across its write AND its :meth:`~MissedSweepScheduler.settings_changed`
call; the loop holds the same lock across its plan read (read, record
the plan, clear the wake flag) and across the pre-sweep re-read plus
the sweep itself. So a write commits either before a plan read
(which then sees it), after one (``settings_changed`` compares against
that exact plan and wakes the wait), or after the sweep — never between
a read and what the loop does with it. The lock is never held across
the wait, and the loop takes nothing else before it, so a waiting
writer cannot deadlock: it is delayed at most by one settings
read or one sweep.
All sweep policy lives in the core (the watermark, the
``[watermark, today)`` window, the no-completion-event rule): this
scheduler only decides WHEN. A same-night rerun — e.g. two workers,
@@ -145,14 +124,6 @@ class MissedSweepScheduler:
)
self._sleep = sleep if sleep is not None else asyncio.sleep
self._task: asyncio.Task | None = None
#: ``(timezone, day_rollover_time)`` the pending sweep was
#: planned with; ``None`` until the first plan.
self._planned: tuple[str, str] | None = None
#: Set by :meth:`settings_changed` to cut the current wait short.
self._wake = asyncio.Event()
#: Serialises settings writes with planning and sweeping (see
#: the module docstring's guarantee).
self.settings_lock = asyncio.Lock()
def start(self) -> None:
"""Start the background loop (idempotent: a running one stays)."""
@@ -171,20 +142,6 @@ class MissedSweepScheduler:
pass
self._task = None
def settings_changed(self, settings: object) -> None:
"""Re-plan the pending sweep if ``settings`` moved its rollover.
Called with freshly stored settings (e.g. an admin's zone
change), with :attr:`settings_lock` held across the write
and this call so the comparison is against the loop's current
plan, never a read still in flight. A differing ``timezone`` or
``day_rollover_time`` wakes the sleeping loop, which reloads the
settings and schedules the next sweep for the new household
rollover; an unchanged plan is left sleeping.
"""
if (settings.timezone, settings.day_rollover_time) != self._planned:
self._wake.set()
@property
def running(self) -> bool:
"""Whether the background loop task is alive."""
@@ -194,18 +151,12 @@ class MissedSweepScheduler:
"""Schedule → sweep → repeat, forever (until cancelled)."""
while True:
try:
# Under the lock no settings write can commit between this read
# and the recorded plan, so any later one wakes the wait.
async with self.settings_lock:
self._wake.clear()
settings = await core_settings_store.load_settings(
self._database
)
rollover = datetime.time.fromisoformat(
settings.day_rollover_time
)
plan = (settings.timezone, settings.day_rollover_time)
self._planned = plan
settings = await core_settings_store.load_settings(
self._database
)
rollover = datetime.time.fromisoformat(
settings.day_rollover_time
)
except Exception:
# Not silent: the failure is logged and retried after
# the backoff, so one bad settings document or a
@@ -227,30 +178,15 @@ class MissedSweepScheduler:
settings.day_rollover_time,
delay,
)
await self._sleep(delay)
try:
if not await self._sleep_unless_woken(delay):
continue
# Re-read before sweeping: a zone or rollover change the
# wake-up missed means this is not the household's
# rollover any more, so re-plan instead of sweeping. The
# lock keeps a settings write from committing between this
# check and the sweep.
async with self.settings_lock:
settings = await core_settings_store.load_settings(
self._database
)
if (
settings.timezone,
settings.day_rollover_time,
) != plan:
continue
await self._run_once(
settings.household_now(self._clock()).date()
)
await self._run_once(
settings.household_now(self._clock()).date()
)
except Exception:
# Not silent either: a failed wait or sweep is logged and
# the loop backs off before the next cycle, whose
# watermark re-read makes an already-swept day a no-op.
# Not silent either: the sweep error is logged and the
# loop backs off before the next cycle, whose watermark
# re-read makes an already-swept day a no-op.
LOGGER.exception(
"NestQuest scheduled missed sweep failed; retrying in "
"%gs",
@@ -258,29 +194,6 @@ class MissedSweepScheduler:
)
await self._sleep(_RETRY_BACKOFF_SECONDS)
async def _sleep_unless_woken(self, delay: float) -> bool:
"""Wait ``delay``; ``False`` if :meth:`settings_changed` woke it.
Both children are cancelled AND awaited before returning (or
before a cancellation propagates), so none outlives :meth:`stop`;
a sleep that failed re-raises its exception here for the loop's
retry policy.
"""
sleeper = asyncio.ensure_future(self._sleep(delay))
waker = asyncio.ensure_future(self._wake.wait())
try:
await asyncio.wait(
{sleeper, waker}, return_when=asyncio.FIRST_COMPLETED
)
finally:
slept = sleeper.done()
sleeper.cancel()
waker.cancel()
await asyncio.gather(sleeper, waker, return_exceptions=True)
if slept:
sleeper.result()
return slept
async def _run_once(self, today: datetime.date) -> None:
"""Run ONE sweep for ``today`` and publish what it built.
+2 -6
View File
@@ -217,10 +217,6 @@ class NestQuestApiClient:
self._session = session
self._timeout_seconds = float(timeout_seconds)
def _headers(self) -> dict[str, str]:
"""The Bearer credential every panel-plane request carries."""
return {"Authorization": f"Bearer {self._panel_token}"}
async def get_snapshot(self) -> dict[str, Any]:
"""Fetch and parse today's household snapshot.
@@ -279,7 +275,7 @@ class NestQuestApiClient:
cleanly and closes the underlying response in every path.
"""
url = f"{self._base_url}{EVENTS_PATH}"
headers = self._headers()
headers = {"Authorization": f"Bearer {self._panel_token}"}
# Opening the response gets ONE request timeout, exactly like
# ``_request_json``; the read loop below runs WITHOUT one — a
# body-wide timeout would kill a healthy long-lived stream.
@@ -383,7 +379,7 @@ class NestQuestApiClient:
raises typed too, carrying the status it arrived with.
"""
url = f"{self._base_url}{path}"
headers = self._headers()
headers = {"Authorization": f"Bearer {self._panel_token}"}
try:
async with asyncio.timeout(self._timeout_seconds):
async with self._session.request(
+4 -4
View File
@@ -113,10 +113,10 @@ CONF_END_OF_DAY_REPORT_ENABLED = "end_of_day_report_enabled"
CONF_CELEBRATION_ENABLED = "celebration_enabled"
DEFAULT_AUTOMATION_ENABLED = True
# The household's IANA time zone (e.g. ``America/New_York``) the API
# service reads its clock in. The empty string is the API host's own
# local time. Only an admin sets it (manually, from the admin app);
# nothing sets it automatically. The integration itself always uses
# Home Assistant's ``time_zone`` and ignores this field.
# service reads its clock in. The empty string keeps the historical
# behaviour — the API host's own local time — so an existing install is
# unchanged until the owner sets it. The integration itself always uses
# Home Assistant's configured ``time_zone`` and ignores this field.
CONF_TIMEZONE = "timezone"
DEFAULT_TIMEZONE = ""
+18 -86
View File
@@ -13,12 +13,8 @@ Guardrail mapping:
- ``schedule_rules`` deletion is delete-if-unreferenced: a rule still
referenced by any quest definition is rejected (the done-condition),
not cascaded.
- ``quest_definitions`` has exactly one delete path,
:meth:`QuestDefinitionsDao.delete_if_no_history` (owner decision
2026-09-26): a definition with NO completion event on any of its
instances is hard-deleted with its instances, assignees, windows and
(if unreferenced) rule; a definition WITH history is never deleted —
it is retired by deactivation, so history always survives.
- ``quest_definitions`` has no delete path at all: definitions are
deactivated, never hard-deleted, and history must survive.
- Assignment is multi-assignee (D-008): :meth:`add_assignee` and
:meth:`remove_assignee` change future instances only — the DAO writes
the assignee table and nothing else; assignment edits never rewrite
@@ -29,8 +25,8 @@ Guardrail mapping:
:meth:`QuestDefinitionsDao.add_assignee` (active child); no
permission checks here, that is the Feature 09 gate's job.
Concurrency: ``delete_rule_if_unreferenced``,
``delete_if_no_history`` and the assignee mutations perform their checks and writes inside one transaction under
Concurrency: ``delete_rule_if_unreferenced`` and the assignee
mutations perform their checks and writes inside one transaction under
the connection-scoped lock shared with :mod:`.dao_children`, so a
definition referencing the rule (or an assignee appearing) cannot slip
in between check and write on this connection.
@@ -612,42 +608,26 @@ class ScheduleRulesDao:
"""
async with _connection_lock(self._database):
async with self._database.transaction():
return await _delete_rule_if_unreferenced_in_transaction(
self._database, rule_id
row = await self._database.fetch_one(
"SELECT 1 FROM quest_definitions "
"WHERE schedule_rule_id = ? LIMIT 1",
(rule_id,),
)
async def _delete_rule_if_unreferenced_in_transaction(
database: NestQuestDatabase, rule_id: int
) -> bool:
"""Delete-if-unreferenced body; the CALLER holds lock + transaction.
Shared by :meth:`ScheduleRulesDao.delete_if_unreferenced` and
:meth:`QuestDefinitionsDao.delete_if_no_history`, so the rule's
reference check runs in whichever transaction removed the last
definition that could reference it.
"""
row = await database.fetch_one(
"SELECT 1 FROM quest_definitions "
"WHERE schedule_rule_id = ? LIMIT 1",
(rule_id,),
)
if row is not None:
return False
result = await database.execute(
"DELETE FROM schedule_rules WHERE id = ?",
(rule_id,),
)
return result.rowcount > 0
if row is not None:
return False
result = await self._database.execute(
"DELETE FROM schedule_rules WHERE id = ?",
(rule_id,),
)
return result.rowcount > 0
class QuestDefinitionsDao:
"""Typed async access to ``quest_definitions`` and its assignees.
The only delete method is :meth:`delete_if_no_history`: a
definition with completion history is never hard-deleted (it is
deactivated via :meth:`set_active` instead), so completion history
always keeps its references (feature guardrail, D-005). Assignment is multi-assignee
No delete method exists: definitions are deactivated via
:meth:`set_active`, never hard-deleted, so completion history keeps
its references (feature guardrail). Assignment is multi-assignee
(D-008): the definition row carries no child; assignees live in
``quest_definition_assignees`` and are managed explicitly so the
operations stay separately loggable and permission-gateable.
@@ -1160,54 +1140,6 @@ class QuestDefinitionsDao:
)
return result.rowcount
async def delete_if_no_history(self, definition_id: int) -> bool | None:
"""Hard-delete a definition that has NO completion history.
Returns None when the definition does not exist, False when any
of its instances has a completion event (nothing is touched —
the caller retires it by deactivation instead), and True when it
was deleted: its ``quest_instances``, assignee and window rows,
the definition row, and its schedule rule if no other definition
references it.
The history check and every DELETE run inside ONE transaction
under the connection lock, so a completion recorded concurrently
cannot land between the check and the delete (D-005: an instance
with a completion event is never deleted).
"""
async with _connection_lock(self._database):
async with self._database.transaction():
row = await self._database.fetch_one(
"SELECT schedule_rule_id FROM quest_definitions "
"WHERE id = ?",
(definition_id,),
)
if row is None:
return None
schedule_rule_id = row[0]
history = await self._database.fetch_one(
"SELECT 1 FROM completion_events "
"JOIN quest_instances "
"ON quest_instances.id = completion_events.instance_id "
"WHERE quest_instances.definition_id = ? LIMIT 1",
(definition_id,),
)
if history is not None:
return False
for statement in (
"DELETE FROM quest_instances WHERE definition_id = ?",
"DELETE FROM quest_definition_assignees "
"WHERE definition_id = ?",
"DELETE FROM quest_definition_windows "
"WHERE definition_id = ?",
"DELETE FROM quest_definitions WHERE id = ?",
):
await self._database.execute(statement, (definition_id,))
await _delete_rule_if_unreferenced_in_transaction(
self._database, schedule_rule_id
)
return True
async def add_assignee(
self, definition_id: int, child_id: int
) -> list[ChildRecord]:
@@ -7,9 +7,7 @@ all-or-nothing create/edit here, and never touch the DAO SQL directly.
:func:`edit_quest_definition` the edit path (which may also replace the
whole assignee set), :func:`assign_child` and
:func:`unassign_child` the single-child assignment paths, and
:func:`set_quest_definition_active` the deactivate/reactivate path,
and :func:`delete_quest_definition` the hybrid admin delete (hard-delete
when there is no completion history, retire by deactivation otherwise).
:func:`set_quest_definition_active` the deactivate/reactivate path.
There is no permission check here (that is Feature 09).
Validation policy mirrors :mod:`.children`:
@@ -82,21 +80,6 @@ class CreatedQuestDefinition:
windows: list[QuestDefinitionWindowRecord]
@dataclass(frozen=True)
class DeletedQuestDefinition:
"""What :func:`delete_quest_definition` actually did.
Exactly one of ``deleted`` / ``retired`` is True. ``definition`` is
the retired (deactivated) definition's updated view, or None when
the definition was hard-deleted and no longer exists.
"""
definition_id: int
deleted: bool
retired: bool
definition: CreatedQuestDefinition | None
def _now_stamp() -> str:
"""Return the module's strict UTC second-precision stamp."""
return datetime.datetime.now(datetime.timezone.utc).isoformat(
@@ -521,19 +504,12 @@ async def set_quest_definition_active(
) -> CreatedQuestDefinition:
"""Deactivate or reactivate a definition; returns the updated view.
Flipping the flag is the only removal path for a definition with
completion history (Feature 06 guardrail; a history-free definition
may instead be hard-deleted by :func:`delete_quest_definition`):
Flipping the flag is the only removal path (Feature 06 guardrail):
a deactivated definition stops future instance generation but its
row, schedule rule, assignees and windows all survive, and its
durable completion history (the append-only ``completion_events``)
is never touched. Like every Feature 06 config change, the flip is
followed by :func:`~.materialize.regenerate_for_definition`: the
definition's future (today onward) UNCOMPLETED instances are cleared
— they are rolling, regenerable rows — and, while the definition is
inactive, none are re-materialized. Completed and past instances
are kept. Reactivation re-enables generation, and the same
regeneration re-materializes the rolling horizon.
row, schedule rule, assignees and windows all survive, and existing
instances plus completion history are never touched. Reactivation
re-enables future generation; generating or removing instances is
Feature 07 and does not happen here.
``definition_id`` must be a plain int (bools and floats rejected,
since SQLite would bind ``True`` onto definition 1) and
@@ -586,68 +562,6 @@ async def set_quest_definition_active(
)
async def delete_quest_definition(
database: NestQuestDatabase,
definition_id: int,
*,
today: datetime.date | None = None,
horizon_days: int | None = None,
) -> DeletedQuestDefinition:
"""Delete a definition if it has no history; otherwise retire it.
Owner decision (2026-09-26): "delete if there's no history,
retire/deactivate if there is". When NO instance of the definition
has a completion event, the definition, its instances, assignees,
windows and (if no other definition references it) its schedule
rule are hard-deleted in ONE transaction by
:meth:`~.dao_rules.QuestDefinitionsDao.delete_if_no_history` — the
history check is inside that transaction, so a completion recorded
concurrently can never be deleted (D-005). When history exists,
the definition is retired through :func:`set_quest_definition_active`
(``False``). Retirement preserves the definition row, schedule
rule, assignees, windows and ALL durable completion history. Like
any deactivation it regenerates, so future UNCOMPLETED instances are
cleared (they are rolling, regenerable rows) and none are recreated
while the definition is inactive — the retired task stops generating
and no longer shows open work. Durable instance history lives only
in the append-only ``completion_events``.
``definition_id`` must be a plain int (bools and floats rejected).
Raises ValueError naming the field when the id is malformed or the
definition does not exist. ``today`` / ``horizon_days`` are threaded
into the retire path's regeneration, as in
:func:`set_quest_definition_active`.
"""
_validate_definition_id(definition_id)
deleted = await QuestDefinitionsDao(database).delete_if_no_history(
definition_id
)
if deleted is None:
raise ValueError(
f"definition_id: quest definition {definition_id} does not exist"
)
if deleted:
return DeletedQuestDefinition(
definition_id=definition_id,
deleted=True,
retired=False,
definition=None,
)
retired = await set_quest_definition_active(
database,
definition_id,
False,
today=today,
horizon_days=horizon_days,
)
return DeletedQuestDefinition(
definition_id=definition_id,
deleted=False,
retired=True,
definition=retired,
)
def _bundle_snapshot(
snapshot: QuestDefinitionSnapshot,
) -> CreatedQuestDefinition:
+5 -7
View File
@@ -314,14 +314,12 @@ class NestQuestSettings:
def household_now(self, instant: datetime.datetime) -> datetime.datetime:
"""Return the aware ``instant`` expressed in the household's zone.
The API service does not run inside Home Assistant, so its one
The API service has no Home Assistant ``time_zone``, so its one
clock read (an aware host-local ``now``) is converted here: with
:attr:`timezone` set — the admin's choice, or else the zone the
integration reports from Home Assistant — the SAME instant is
re-expressed in that zone, so ``.date()`` and ``.time()`` are the
household's wall clock even when the host runs UTC. With
:attr:`timezone` empty the instant is returned unchanged (the
host's local time).
:attr:`timezone` set the SAME instant is re-expressed in that
zone, so ``.date()`` and ``.time()`` are the household's wall
clock even when the host runs UTC. With :attr:`timezone` empty
the instant is returned unchanged (the host's local time).
"""
if not self.timezone:
return instant
@@ -18,7 +18,7 @@ API client; the two are deliberately NOT unified here, and neither
side reads the other's values.
STORED SERIALIZATION — the document at ``SETTINGS_META_KEY`` is a JSON
object holding exactly the TWELVE settings fields, keyed by field name
object holding exactly the ELEVEN settings fields, keyed by field name
(which equals the ``CONF_*`` option key for every field), complete
after the first update:
@@ -33,8 +33,7 @@ after the first update:
booleans,
- ``timezone`` — ``""`` (the API host's local time) or an IANA time
zone name such as ``America/New_York``. A document stored before
this field existed simply lacks it and resolves to ``""``. Only an
admin's explicit update ever writes it; nothing sets it automatically.
this field existed simply lacks it and resolves to ``""``.
Only values whose core validators ACCEPTED are ever written, so a
stored document always round-trips through the core's option builders.
@@ -197,27 +196,18 @@ async def update_settings(
)
async with _settings_lock(database):
current = await load_settings(database)
return await _write_merged(database, current, changes)
async def _write_merged(
database: NestQuestDatabase,
current: NestQuestSettings,
changes: Mapping[str, Any],
) -> NestQuestSettings:
"""Validate ``changes``, merge onto ``current``, persist; lock held."""
document: dict[str, Any] = {
field: getattr(current, field) for field in _FIELD_NAMES
}
for name, raw_value in changes.items():
default, validator = _RESOLUTIONS[name]
try:
document[name] = validator(
default if raw_value is None else raw_value
)
except ValueError as error:
raise ValueError(_named(name, error)) from error
await MetaStateDao(database).set(
SETTINGS_META_KEY, json.dumps(document, sort_keys=True)
)
document: dict[str, Any] = {
field: getattr(current, field) for field in _FIELD_NAMES
}
for name, raw_value in changes.items():
default, validator = _RESOLUTIONS[name]
try:
document[name] = validator(
default if raw_value is None else raw_value
)
except ValueError as error:
raise ValueError(_named(name, error)) from error
await MetaStateDao(database).set(
SETTINGS_META_KEY, json.dumps(document, sort_keys=True)
)
return NestQuestSettings(**document)
+1 -1
View File
@@ -15,5 +15,5 @@
"iot_class": "local_polling",
"issue_tracker": "https://github.com/Maniacs-Mansion/NestQuest/issues",
"requirements": [],
"version": "0.9.7"
"version": "0.8.0"
}
File diff suppressed because it is too large Load Diff
+1 -18
View File
@@ -64,8 +64,7 @@ Header `Tasks` / `7 definitions · 3 children` + gradient `New` button. Filter c
(All / one per child): active `#18181D` fill white text, inactive white with border.
Rows: glyph 17px · title 13.5px/700 · meta 11px/600 `<assignees> · <recurrence> · <windows>`
· `chevron-right`. An inactive definition renders the whole row at
`--nq-a-ink-tertiary` with `· inactive` in the meta. A definition with completion
history is never hard-deleted (glossary, D-017): Delete retires it to this inactive state.
`--nq-a-ink-tertiary` with `· inactive` in the meta — never hard-deleted (glossary).
The row being edited takes `--nq-a-selected` with brand-blue glyph, `Editing` meta and
`chevron-down`.
@@ -84,22 +83,6 @@ Bottom sheet, `--nq-a-radius-sheet`, `--nq-a-sheet-shadow`, 38×4px grabber, pad
Footer: `Cancel` (flex 1, bordered) + `Save changes` (flex 2, gradient), both 13px vertical
padding. Sheet enters over the dimmed list in `--nq-dur-modal`.
Delete (editing an existing definition only, never on `New`): a full-width bordered
`Delete task` button below the footer, danger-ink text. Tapping it swaps the footer for a
danger-tinted confirm — "Delete this task? If it has no completion history it is deleted;
otherwise it is retired (deactivated) and its history is kept." — with `Go back` and a
danger-filled `Delete task`. Confirming calls `DELETE /api/v1/admin/quest-definitions/{id}`
(admin plane only; the kids' panel has no equivalent control). Hybrid outcome (D-017): no
completion event on any instance → the definition, its instances, assignees, windows and
unreferenced rule are hard-deleted and the row leaves the list; otherwise the definition
is retired (deactivated) and the row stays, shown inactive. Retirement preserves the
definition row, schedule rule, assignees, windows and all durable completion history. Like
any deactivation it regenerates, so future uncompleted instances are cleared (they are
rolling, regenerable rows) and none are recreated while the definition is inactive — the
retired task stops generating and no longer shows open work. Durable instance history lives
only in the append-only `completion_events`. An error keeps the sheet open with the API's
detail.
### 3.3 Icon picker
Each definition carries one optional icon, shown on both surfaces. The picker offers three
sources:
-8
View File
@@ -71,14 +71,6 @@ and time only rather than removing it (the strip is part of the frame).
Reached by tapping a present crest. Header at y=56, page inset 62px:
- **Return control** first in the header, left of the crest: a `--nq-p-button-height`
72px button, radius 10px, `rgba(92,62,26,.1)` with a 2px `rgba(92,62,26,.5)` border
(the confirm dialog's **Not yet** treatment), `chevron-left` 34px + `The Party`
26px/700 Cinzel `--nq-p-ink`, 28px header gap to the crest. Tapping it takes the same
path as the idle return (§3.5) — navigate to `board_path` — immediately. Press
feedback `scale(.98)`, `--nq-dur-micro`. Not rendered when no `board_path` is
configured. The party board is the main display and has no return control; the
Quest complete screen (§5) keeps its auto-return.
- Small crest 96×110 (initial 44px/700 display).
- Title `<Name>'s Quest Log` 56px/700 display.
- Sub `<weekday>, <Month D> · N of M claimed` 20px/600 Cinzel, tracking .24em, uppercase.
+4 -81
View File
@@ -36,6 +36,7 @@ type PanelInstance = {
window: string;
due_time: string | null;
state: string;
overdue: boolean;
completed_at: string | null;
on_time: boolean | null;
};
@@ -153,17 +154,6 @@ function formatWallClock(value: string | null): string | null {
return `${hour12}:${match[2]} ${suffix}`;
}
/** Minutes past midnight of a household ``HH:MM`` wall time, or null. */
function wallClockMinutes(value: string | null): number | null {
const match = value ? /^(\d{1,2}):(\d{2})$/.exec(value.trim()) : null;
if (!match) {
return null;
}
const hours = Number(match[1]);
const minutes = Number(match[2]);
return hours > 23 || minutes > 59 ? null : hours * 60 + minutes;
}
function asString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
@@ -263,6 +253,7 @@ function toPanelInstance(value: unknown): PanelInstance | null {
window: asString(row.window).toLowerCase(),
due_time: asString(row.due_time) || null,
state,
overdue: row.overdue === true,
completed_at: asString(row.completed_at) || null,
on_time: row.on_time === true ? true : row.on_time === false ? false : null,
};
@@ -383,37 +374,6 @@ const logStyles = css`
gap: 28px;
}
button.return {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
height: var(--nq-p-button-height);
padding: 0 28px 0 20px;
border: 2px solid rgba(92, 62, 26, 0.5);
border-radius: 10px;
background: rgba(92, 62, 26, 0.1);
color: var(--nq-p-ink);
font-family: var(--nq-p-font-heading);
font-size: 26px;
font-weight: 700;
line-height: 1;
white-space: nowrap;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: transform var(--nq-dur-micro) var(--nq-ease-out);
}
button.return:active {
transform: scale(0.98);
}
button.return svg {
width: 34px;
height: 34px;
}
.crest {
position: relative;
flex: none;
@@ -1239,18 +1199,6 @@ const ICON_X = html`<svg
<path d="m6 6 12 12"></path>
</svg>`;
const ICON_CHEVRON_LEFT = html`<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="m15 18-6-6 6-6"></path>
</svg>`;
const ICON_STAR = html`<svg
viewBox="0 0 24 24"
fill="none"
@@ -1758,20 +1706,6 @@ 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;
}
private _timeZone(): string | undefined {
return hassTimeZone(this.hass);
}
@@ -1856,6 +1790,7 @@ export class NestQuestQuestLogCard extends LitElement {
continue;
}
instance.state = "completed";
instance.overdue = false;
instance.completed_at = stampedAt;
}
if (settled) {
@@ -2009,18 +1944,6 @@ export class NestQuestQuestLogCard extends LitElement {
const initial = (name?.charAt(0) || "?").toUpperCase();
return html`
<header class="header">
${this._boardPath()
? html`
<button
class="return"
type="button"
@click=${() => this._returnToBoard()}
>
${ICON_CHEVRON_LEFT}
<span>The Party</span>
</button>
`
: nothing}
<span class="crest${away ? " away" : ""}" aria-hidden="true">
<span class="crest-face">
<span class="initial">${initial}</span>
@@ -2152,7 +2075,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,
+1 -59
View File
@@ -14,14 +14,6 @@
* tap_complete: <instance id> the harness taps that card's Complete button,
* records whether the confirm dialog opened, taps the dialog's Complete, and
* reports every hass.callService call as tap: {dialog_opened, service_calls}.
* With tap_return: true the harness taps the header's return control and
* reports it as returned: {rendered, label} before the final snapshot.
* 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), and with advance_to: [<ISO instant>, ...] the clock moves to
* each instant in turn and the card's own 60 s clock-tick callbacks fire,
* one snapshot per instant reported as ticks (the top-level snapshot is
* taken before the clock moves).
* The Python panel tests drive this harness so the
* assertions run against the exact bundle HACS ships.
*/
@@ -53,33 +45,6 @@ globalThis.window = dom.window;
// jsdom Event must shadow it for the return navigation 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.
let pinned = spec.now !== undefined ? new Date(spec.now).getTime() : null;
if (pinned !== null) {
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;
}
// Record the card's 60 s clock ticks so advance_to can fire them on demand.
const clockTicks = [];
const realSetInterval = dom.window.setInterval.bind(dom.window);
dom.window.setInterval = (callback, ms, ...rest) => {
if (ms === 60000) {
clockTicks.push(callback);
}
return realSetInterval(callback, ms, ...rest);
};
await import(pathToFileURL(spec.bundle).href);
const serviceCalls = [];
@@ -110,17 +75,6 @@ if (spec.tap_complete !== undefined) {
tap = { dialog_opened: confirm !== null, service_calls: serviceCalls };
}
let returned = null;
if (spec.tap_return) {
const control = element.shadowRoot.querySelector(".header button.return");
returned = {
rendered: control !== null,
label: control?.textContent?.trim() ?? "",
};
control?.click();
await element.updateComplete;
}
const snapshot = () => ({
location: window.location.pathname,
headline: element.shadowRoot.querySelector(".title")?.textContent?.trim() ?? "",
@@ -156,18 +110,6 @@ if (spec.idle_ms !== undefined) {
await new Promise((resolve) => setTimeout(resolve, spec.idle_ms));
}
const settled = snapshot();
let ticks = null;
if (spec.advance_to !== undefined) {
ticks = [];
for (const instant of spec.advance_to) {
pinned = new Date(instant).getTime();
clockTicks.forEach((callback) => callback());
await element.updateComplete;
ticks.push(snapshot());
}
}
const payload = JSON.stringify({ ...settled, probe, tap, returned, ticks });
const payload = JSON.stringify({ ...snapshot(), probe, tap });
await new Promise((resolve) => process.stdout.write(payload, () => resolve()));
process.exit(0);
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "nestquest"
version = "0.9.7"
version = "0.8.0"
description = "NestQuest Home Assistant Integration"
readme = "README.md"
requires-python = ">=3.11"
-138
View File
@@ -817,141 +817,3 @@ async def test_set_active_malformed_body_is_422(
headers=_admin_headers(),
)
assert response.status_code == 422
# --- delete: hybrid (delete if no history, retire if there is) --------------
_dao_instances = importlib.import_module("nestquest_core.dao_instances")
def test_delete_route_lives_on_the_admin_router() -> None:
"""The DELETE route is on the admin router, which carries the ONE
require_admin dependency — no second auth check is added."""
from api.auth import require_admin
from api.routes_admin import router as admin_router
assert require_admin in [d.dependency for d in admin_router.dependencies]
matches = [
route
for route in admin_router.routes
if route.path == "/api/v1/admin/quest-definitions/{definition_id}"
and "DELETE" in route.methods
]
assert len(matches) == 1
async def test_delete_refuses_non_admin_and_absent(
admin_questdefs: SimpleNamespace,
) -> None:
"""A non-admin JWT is 403 and an absent credential 401: nothing is
deleted or retired."""
client = admin_questdefs.client
ada = (await _create_children(client, ("Ada",)))[0]
definition_id = (await _create_definition(client, [ada])).json()["id"]
non_admin = make_token(groups=("some-other-group",))
forbidden = await client.delete(
f"/api/v1/admin/quest-definitions/{definition_id}",
headers={"Authorization": f"Bearer {non_admin}"},
)
assert forbidden.status_code == 403
unauthenticated = await client.delete(
f"/api/v1/admin/quest-definitions/{definition_id}"
)
assert unauthenticated.status_code == 401
record = await _dao_rules.QuestDefinitionsDao(
admin_questdefs.database
).get(definition_id)
assert record is not None
assert record.is_active is True
async def test_delete_without_history_hard_deletes(
admin_questdefs: SimpleNamespace,
) -> None:
"""No completion history: 200 ``deleted`` and the row and its
unreferenced rule are gone."""
client = admin_questdefs.client
ada = (await _create_children(client, ("Ada",)))[0]
definition_id = (await _create_definition(client, [ada])).json()["id"]
dao = _dao_rules.QuestDefinitionsDao(admin_questdefs.database)
rule_id = (await dao.get(definition_id)).schedule_rule_id
response = await client.delete(
f"/api/v1/admin/quest-definitions/{definition_id}",
headers=_admin_headers(),
)
assert response.status_code == 200
assert response.json() == {
"definition_id": definition_id,
"deleted": True,
"retired": False,
"definition": None,
}
assert await dao.get(definition_id) is None
assert await _dao_rules.ScheduleRulesDao(
admin_questdefs.database
).get(rule_id) is None
async def test_delete_with_history_retires(
admin_questdefs: SimpleNamespace,
) -> None:
"""Completion history present: 200 ``retired`` with the now-inactive
definition; the row, instance and history all remain."""
client = admin_questdefs.client
database = admin_questdefs.database
ada = (await _create_children(client, ("Ada",)))[0]
definition_id = (await _create_definition(client, [ada])).json()["id"]
instance = await _dao_instances.QuestInstancesDao(database).upsert(
definition_id,
ada,
datetime.date.today().isoformat(),
"2026-09-14T12:00:00+00:00",
window="morning",
)
events = _dao_instances.CompletionEventsDao(database)
event = await events.append(
instance.id,
ada,
"completed",
"user",
"2026-09-14T12:00:00+00:00",
True,
actor_user_id="admin-1",
)
response = await client.delete(
f"/api/v1/admin/quest-definitions/{definition_id}",
headers=_admin_headers(),
)
assert response.status_code == 200
payload = response.json()
assert payload["definition_id"] == definition_id
assert payload["deleted"] is False
assert payload["retired"] is True
assert payload["definition"]["id"] == definition_id
assert payload["definition"]["is_active"] is False
assert payload["definition"]["title"] == "Tidy the den"
record = await _dao_rules.QuestDefinitionsDao(database).get(definition_id)
assert record is not None
assert record.is_active is False
assert await _dao_instances.QuestInstancesDao(database).get_by_id(
instance.id
) == instance
assert await events.list_by_instance(instance.id) == [event]
async def test_delete_unknown_definition_is_404(
admin_questdefs: SimpleNamespace,
) -> None:
"""A DELETE naming a definition that does not exist is 404."""
response = await admin_questdefs.client.delete(
"/api/v1/admin/quest-definitions/999",
headers=_admin_headers(),
)
assert response.status_code == 404
assert response.json()["detail"] == "Quest definition not found"
+2 -84
View File
@@ -13,8 +13,7 @@ proven against the DATABASE the routes wrote, not a parallel copy.
Proven per the done-condition, everything read back through the API:
- ``GET /api/v1/admin/settings`` returns the current effective settings
— the eleven documented fields at their defaults on a fresh database,
with no ``timezone_configured`` marker.
— the eleven documented fields at their defaults on a fresh database.
- ``PATCH /api/v1/admin/settings`` PERSISTS the supplied fields and the
NEXT read reflects them; a later partial update merges onto the
current effective settings (omitted fields keep their stored value),
@@ -141,7 +140,7 @@ async def test_update_persists_and_the_next_read_reflects_it(
assert reread.status_code == 200
assert reread.json() == expected
# ...and the DATABASE holds the merged document: the complete twelve
# ...and the DATABASE holds the merged document: the complete eleven
# fields, keyed by field name, under the well-known meta_state key.
raw = await _dao_meta.MetaStateDao(settings_client.database).get(
SETTINGS_META_KEY
@@ -223,87 +222,6 @@ async def test_null_resets_a_field_to_its_default(
assert reread.json()["horizon_days"] == 14
async def test_default_settings_carry_no_timezone_configured_marker(
settings_client: SimpleNamespace,
) -> None:
"""The removed auto-set marker is absent from the default response."""
response = await settings_client.client.get(
"/api/v1/admin/settings", headers=_admin_headers()
)
assert response.status_code == 200
assert "timezone_configured" not in response.json()
assert response.json()["timezone"] == ""
@pytest.mark.parametrize("zone", ["America/New_York", ""])
async def test_manual_timezone_patch_persists_and_is_returned(
settings_client: SimpleNamespace, zone: str
) -> None:
"""An admin's timezone PATCH is stored exactly as given and read back."""
client = settings_client.client
seeded = await client.patch(
"/api/v1/admin/settings",
json={"timezone": "Europe/Dublin"},
headers=_admin_headers(),
)
assert seeded.status_code == 200
patched = await client.patch(
"/api/v1/admin/settings",
json={"timezone": zone},
headers=_admin_headers(),
)
assert patched.status_code == 200
expected = {**DEFAULT_SETTINGS, "timezone": zone}
assert patched.json() == expected
reread = await client.get(
"/api/v1/admin/settings", headers=_admin_headers()
)
assert reread.json() == expected
raw = await _dao_meta.MetaStateDao(settings_client.database).get(
SETTINGS_META_KEY
)
assert raw is not None
stored = json.loads(raw)
assert stored["timezone"] == zone
assert "timezone_configured" not in stored
async def test_timezone_configured_is_an_unknown_field(
settings_client: SimpleNamespace,
) -> None:
"""The removed marker is rejected like any unknown field; nothing is written."""
response = await settings_client.client.patch(
"/api/v1/admin/settings",
json={"timezone_configured": True},
headers=_admin_headers(),
)
assert response.status_code == 422
assert "timezone_configured" in str(response.json()["detail"])
raw = await _dao_meta.MetaStateDao(settings_client.database).get(
SETTINGS_META_KEY
)
assert raw is None
async def test_stored_legacy_timezone_configured_key_is_ignored(
settings_client: SimpleNamespace,
) -> None:
"""A document written before the marker was removed still loads."""
await _dao_meta.MetaStateDao(settings_client.database).set(
SETTINGS_META_KEY,
json.dumps(
{"timezone": "America/Chicago", "timezone_configured": True}
),
)
response = await settings_client.client.get(
"/api/v1/admin/settings", headers=_admin_headers()
)
assert response.status_code == 200
assert response.json() == {**DEFAULT_SETTINGS, "timezone": "America/Chicago"}
# --- validation: rejected values never change the stored settings -------
-25
View File
@@ -196,29 +196,6 @@ async def test_complete_instance_posts_body_and_headers() -> None:
assert call["headers"]["Authorization"] == f"Bearer {TOKEN}"
async def test_requests_carry_only_the_bearer_header() -> None:
"""No timezone is reported to the API (task 7f685179): the Bearer
credential is the only header on every request."""
transport = StubTransport(
StubResponse(200, json.dumps(SNAPSHOT_PAYLOAD)),
StubResponse(200, '{"status": "done"}'),
)
client = make_client(transport)
await client.get_snapshot()
await client.complete_instance(42, 3)
for call in transport.calls:
assert call["headers"] == {"Authorization": f"Bearer {TOKEN}"}
def test_client_takes_no_household_timezone() -> None:
with pytest.raises(TypeError):
NestQuestApiClient(
BASE_URL, TOKEN, StubTransport(), household_timezone="UTC"
)
async def test_base_url_trailing_slash_is_normalized() -> None:
"""A base URL with a trailing slash must not double the path slash."""
transport = StubTransport(StubResponse(200, "{}"))
@@ -415,13 +392,11 @@ def test_client_from_entry_reads_config_and_shared_session(monkeypatch) -> None:
}
)
# A bare object: building the client must not read hass.config.
client = api_client.client_from_entry(object(), entry)
assert client._session is shared_session
assert client._base_url == "http://panel.lan:8443"
assert client._panel_token == "entry-token"
assert client._headers() == {"Authorization": "Bearer entry-token"}
def test_client_from_entry_unconfigured_token_raises_at_construction(
+1 -462
View File
@@ -19,8 +19,6 @@ container's zone) and sets the household zone through the real admin
An unset zone keeps the historical host-local behaviour, and the
missed-sweep scheduler waits for the rollover in household time.
Nothing sets the zone automatically: a timezone header on a panel
request never changes the stored settings (task 7f685179).
"""
from __future__ import annotations
@@ -33,7 +31,6 @@ from zoneinfo import ZoneInfo
import pytest
from api import routes_admin, routes_panel
from api import scheduler as scheduler_module
from api.database import _executor
from api.scheduler import MissedSweepScheduler, _delay_seconds
from tests.admin_jwt_harness import (
@@ -49,7 +46,6 @@ _settings = importlib.import_module("nestquest_core.settings")
_settings_store = importlib.import_module("nestquest_core.settings_store")
_core_db = importlib.import_module("nestquest_core.db")
_core_migrations = importlib.import_module("nestquest_core.migrations")
_dao_meta = importlib.import_module("nestquest_core.dao_meta")
#: The panel service token the admin harness app is configured with.
HARNESS_PANEL_TOKEN = "admin-test-panel-token"
@@ -87,12 +83,7 @@ async def household(temp_db_path: str, monkeypatch) -> SimpleNamespace:
monkeypatch.setattr(routes_panel, "_local_now", lambda: clock["now"])
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
async with runner as client:
yield SimpleNamespace(
client=client,
seed=seed,
clock=clock,
database=runner.app.state.db.database,
)
yield SimpleNamespace(client=client, seed=seed, clock=clock)
async def _set_zone(household: SimpleNamespace, zone: str) -> None:
@@ -245,16 +236,6 @@ def test_from_options_reads_and_resiliently_defaults_timezone() -> None:
)
def test_settings_have_no_timezone_configured_marker() -> None:
assert not hasattr(_settings.NestQuestSettings(), "timezone_configured")
with pytest.raises(TypeError):
_settings.NestQuestSettings(timezone_configured=True)
# A stored document still carrying the removed key loads harmlessly.
assert _settings.NestQuestSettings.from_options_resilient(
{"timezone": HOUSEHOLD_ZONE, "timezone_configured": True}
) == _settings.NestQuestSettings(timezone=HOUSEHOLD_ZONE)
# --- the missed-sweep scheduler ----------------------------------------------
@@ -326,445 +307,3 @@ def test_delay_seconds_is_true_elapsed_time_across_dst(
now: datetime.datetime, target: datetime.time, expected: float
) -> None:
assert _delay_seconds(target, now) == expected
# --- nothing sets the zone automatically (task 7f685179) --------------------
#
# The zone is only ever set by an admin's explicit settings update. A
# timezone header on a panel request — the one Home Assistant used to
# report, or any look-alike — is ignored, and no read writes settings.
#: Timezone-looking request headers a panel request might carry.
_TIMEZONE_HEADERS = [
{"X-NestQuest-Timezone": HOUSEHOLD_ZONE},
{"X-NestQuest-Time-Zone": HOUSEHOLD_ZONE},
{"X-Timezone": HOUSEHOLD_ZONE},
{"Time-Zone": HOUSEHOLD_ZONE},
]
async def _stored_document(household: SimpleNamespace) -> str | None:
"""The raw stored settings document (``None`` when never written)."""
return await _dao_meta.MetaStateDao(household.database).get(
_settings_store.SETTINGS_META_KEY
)
async def _admin_settings(household: SimpleNamespace) -> dict:
response = await household.client.get(
"/api/v1/admin/settings", headers=_admin_headers()
)
assert response.status_code == 200
return response.json()
@pytest.mark.parametrize("extra", _TIMEZONE_HEADERS)
async def test_panel_timezone_header_never_changes_settings(
household: SimpleNamespace, extra: dict[str, str]
) -> None:
"""09:59:59 EDT on a UTC host, zone unset: the header is ignored."""
household.clock["now"] = _utc(13, 59, 59)
headers = {**_panel_headers(), **extra}
snapshot = await household.client.get(
"/api/v1/panel/snapshot", headers=headers
)
assert snapshot.status_code == 200
seed = household.seed
completed = await household.client.post(
f"/api/v1/panel/instances/{seed.pack_bag_instance.id}/complete",
headers=headers,
json={"actor_child_id": seed.ada.id},
)
assert completed.status_code == 200
# Nothing was written, and the clock stayed host local (UTC): the
# 10:00 quest was overdue and the 13:59 UTC completion was late.
assert await _stored_document(household) is None
assert (await _admin_settings(household))["timezone"] == ""
_today_iso, instance = await _pack_bag(household, "admin")
assert instance["on_time"] is False
@pytest.mark.parametrize("extra", _TIMEZONE_HEADERS)
async def test_panel_timezone_header_never_replaces_the_stored_zone(
household: SimpleNamespace, extra: dict[str, str]
) -> None:
"""A stored zone stays exactly as the admin saved it."""
await _set_zone(household, "America/Los_Angeles")
before = await _stored_document(household)
household.clock["now"] = _utc(16, 59, 59) # 09:59:59 PDT, 12:59:59 EDT
response = await household.client.get(
"/api/v1/panel/snapshot",
headers={**_panel_headers(), **extra},
)
assert response.status_code == 200
assert await _stored_document(household) == before
_today_iso, instance = await _pack_bag(household, "panel")
assert instance["overdue"] is False
async def test_fresh_install_zone_stays_empty_across_reads(
household: SimpleNamespace,
) -> None:
"""Repeated panel and admin reads never write the settings document."""
for hour in (3, 13, 23):
household.clock["now"] = _utc(hour, 0)
for extra in _TIMEZONE_HEADERS:
response = await household.client.get(
"/api/v1/panel/snapshot",
headers={**_panel_headers(), **extra},
)
assert response.status_code == 200
await _pack_bag(household, "admin")
settings = await _admin_settings(household)
assert settings["timezone"] == ""
assert "timezone_configured" not in settings
assert await _stored_document(household) is None
# --- the scheduler follows a changed zone (task 6351b36d) --------------------
#
# On a UTC host with no stored zone the scheduler first plans for UTC
# midnight; a zone stored while it sleeps must move the sweep to the
# household's midnight rather than sweep at 20:00 EDT.
#: 13:00 UTC (09:00 EDT) to UTC midnight.
_UTC_MIDNIGHT_DELAY = 11 * 3600
#: 13:00 UTC (09:00 EDT) to New York midnight (04:00 UTC).
_NEW_YORK_MIDNIGHT_DELAY = 15 * 3600
async def _store_zone(scheduler, database, zone: str) -> None:
"""Store ``zone`` the way a settings writer must: under the lock."""
async with scheduler.settings_lock:
settings = await _settings_store.update_settings(
database, {"timezone": zone}
)
scheduler.settings_changed(settings)
async def test_scheduler_replans_when_zone_changes_while_sleeping(
temp_db_path: str,
) -> None:
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
async with runner:
app_state = runner.app.state
await 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(
app_state.db.database, clock=lambda: _utc(13, 0), sleep=sleep_stub
)
app_state.sweep_scheduler = scheduler
scheduler.start()
try:
async with slept:
await asyncio.wait_for(
slept.wait_for(lambda: len(delays) == 1), timeout=5
)
await _store_zone(
scheduler, app_state.db.database, HOUSEHOLD_ZONE
)
async with slept:
await asyncio.wait_for(
slept.wait_for(lambda: len(delays) == 2), timeout=5
)
finally:
await scheduler.stop()
assert delays == [
pytest.approx(_UTC_MIDNIGHT_DELAY),
pytest.approx(_NEW_YORK_MIDNIGHT_DELAY),
]
async def test_scheduler_rechecks_settings_before_sweeping(
temp_db_path: str, monkeypatch
) -> None:
"""A zone stored without a wake-up still stops the stale sweep."""
database = _core_db.NestQuestDatabase(_executor)
await database.open(temp_db_path)
try:
await _core_migrations.apply_migrations(database)
sweeps: list[datetime.date] = []
async def sweep_stub(_database, *, today):
sweeps.append(today)
return []
monkeypatch.setattr(
scheduler_module.core_sweep, "run_missed_sweep", sweep_stub
)
delays: list[float] = []
replanned = asyncio.Event()
async def sleep_stub(seconds: float) -> None:
delays.append(seconds)
if len(delays) == 1:
# The UTC rollover "arrives" after the zone was stored
# behind the scheduler's back.
await _settings_store.update_settings(
database, {"timezone": HOUSEHOLD_ZONE}
)
return
replanned.set()
await asyncio.Event().wait()
scheduler = MissedSweepScheduler(
database, clock=lambda: _utc(13, 0), sleep=sleep_stub
)
scheduler.start()
try:
await asyncio.wait_for(replanned.wait(), timeout=5)
finally:
await scheduler.stop()
assert sweeps == []
assert delays == [
pytest.approx(_UTC_MIDNIGHT_DELAY),
pytest.approx(_NEW_YORK_MIDNIGHT_DELAY),
]
finally:
await database.close()
# --- settings writes are serialised with planning and sweeping (TZ-003) ------
#
# A zone write racing the loop's own settings reads must never leave a
# stale plan behind. Each test holds the loop at one of its reads and
# issues a locked zone write there; when the scheduler's settings lock
# is held at that point the write provably cannot commit until the
# loop moves on, otherwise it is awaited to commit inside the window.
def _hold_scheduler_read(monkeypatch, scheduler, nth: int, during):
"""Run ``during(settings)`` inside the scheduler task's ``nth`` read."""
real_load = _settings_store.load_settings
reads = {"count": 0}
async def load_settings(database):
settings = await real_load(database)
if asyncio.current_task() is scheduler._task:
reads["count"] += 1
if reads["count"] == nth:
await during(settings)
return settings
monkeypatch.setattr(
scheduler_module.core_settings_store, "load_settings", load_settings
)
return real_load
async def _store_inside_read(scheduler, database, zone: str) -> asyncio.Task:
"""Start a locked write of ``zone``; let it commit unless serialised."""
write = asyncio.create_task(_store_zone(scheduler, database, zone))
if not scheduler.settings_lock.locked():
await write
return write
async def test_scheduler_follows_successive_zone_changes(
temp_db_path: str, monkeypatch
) -> None:
"""New York -> UTC -> New York while the re-plan read is in flight."""
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
async with runner:
app_state = runner.app.state
database = app_state.db.database
await 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(
database, clock=lambda: _utc(13, 0), sleep=sleep_stub
)
app_state.sweep_scheduler = scheduler
await _store_zone(scheduler, database, HOUSEHOLD_ZONE)
writes: list[asyncio.Task] = []
async def back_to_new_york(_settings) -> None:
writes.append(
await _store_inside_read(scheduler, database, HOUSEHOLD_ZONE)
)
# Read 1 plans New York; read 2 is the re-plan after UTC.
_hold_scheduler_read(monkeypatch, scheduler, 2, back_to_new_york)
scheduler.start()
try:
async with slept:
await asyncio.wait_for(
slept.wait_for(lambda: len(delays) == 1), timeout=5
)
await _store_zone(scheduler, database, "UTC")
async with slept:
await asyncio.wait_for(
slept.wait_for(lambda: len(delays) == 3), timeout=5
)
(write,) = writes
await write
finally:
await scheduler.stop()
# The in-flight read planned UTC; the New York write that followed
# it woke the wait, so the scheduler sleeps on New York midnight.
assert delays == [
pytest.approx(_NEW_YORK_MIDNIGHT_DELAY),
pytest.approx(_UTC_MIDNIGHT_DELAY),
pytest.approx(_NEW_YORK_MIDNIGHT_DELAY),
]
async def test_zone_write_after_presweep_check_cannot_precede_the_sweep(
temp_db_path: str, monkeypatch
) -> None:
"""No sweep runs once a zone write has replaced its plan."""
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
async with runner:
app_state = runner.app.state
await app_state.sweep_scheduler.stop()
delays: list[float] = []
replanned = asyncio.Event()
async def sleep_stub(seconds: float) -> None:
delays.append(seconds)
if len(delays) == 1:
return # the UTC rollover arrives
replanned.set()
await asyncio.Event().wait()
scheduler = MissedSweepScheduler(
app_state.db.database, clock=lambda: _utc(13, 0), sleep=sleep_stub
)
app_state.sweep_scheduler = scheduler
writes: list[asyncio.Task] = []
async def store_new_york(_settings) -> None:
writes.append(
await _store_inside_read(
scheduler, app_state.db.database, HOUSEHOLD_ZONE
)
)
# Read 1 plans UTC; read 2 is the pre-sweep re-check.
real_load = _hold_scheduler_read(
monkeypatch, scheduler, 2, store_new_york
)
swept_under: list[str] = []
async def sweep_stub(database, *, today):
swept_under.append((await real_load(database)).timezone)
return []
monkeypatch.setattr(
scheduler_module.core_sweep, "run_missed_sweep", sweep_stub
)
scheduler.start()
try:
await asyncio.wait_for(replanned.wait(), timeout=5)
(write,) = writes
await write
finally:
await scheduler.stop()
# The sweep ran for the UTC plan with UTC still stored, never after
# New York had been stored; the next plan is New York midnight.
assert swept_under == [""]
assert delays == [
pytest.approx(_UTC_MIDNIGHT_DELAY),
pytest.approx(_NEW_YORK_MIDNIGHT_DELAY),
]
# --- the wait's child tasks (TZ-004) ------------------------------------------
async def test_failed_sleep_is_retried_not_swept(
temp_db_path: str, monkeypatch
) -> None:
"""A sleep that raises backs off and re-plans; nothing is swept."""
database = _core_db.NestQuestDatabase(_executor)
await database.open(temp_db_path)
try:
await _core_migrations.apply_migrations(database)
sweeps: list[datetime.date] = []
async def sweep_stub(_database, *, today):
sweeps.append(today)
return []
monkeypatch.setattr(
scheduler_module.core_sweep, "run_missed_sweep", sweep_stub
)
delays: list[float] = []
replanned = asyncio.Event()
async def sleep_stub(seconds: float) -> None:
delays.append(seconds)
if len(delays) == 1:
raise RuntimeError("timer failed")
if len(delays) == 2:
return # the retry backoff
replanned.set()
await asyncio.Event().wait()
scheduler = MissedSweepScheduler(
database, clock=lambda: _utc(13, 0), sleep=sleep_stub
)
scheduler.start()
try:
await asyncio.wait_for(replanned.wait(), timeout=5)
finally:
await scheduler.stop()
assert sweeps == []
assert delays == [
pytest.approx(_UTC_MIDNIGHT_DELAY),
scheduler_module._RETRY_BACKOFF_SECONDS,
pytest.approx(_UTC_MIDNIGHT_DELAY),
]
finally:
await database.close()
async def test_stop_waits_for_the_sleep_to_finish_cancelling(
temp_db_path: str,
) -> None:
database = _core_db.NestQuestDatabase(_executor)
await database.open(temp_db_path)
try:
await _core_migrations.apply_migrations(database)
sleeping = asyncio.Event()
sleepers: list[asyncio.Task] = []
cleaned_up: list[bool] = []
async def sleep_stub(_seconds: float) -> None:
sleepers.append(asyncio.current_task())
sleeping.set()
try:
await asyncio.Event().wait()
finally:
# Cancellation cleanup that takes a loop turn.
await asyncio.sleep(0)
cleaned_up.append(True)
scheduler = MissedSweepScheduler(
database, clock=lambda: _utc(13, 0), sleep=sleep_stub
)
scheduler.start()
try:
await asyncio.wait_for(sleeping.wait(), timeout=5)
finally:
await scheduler.stop()
(sleeper,) = sleepers
assert sleeper.done()
assert cleaned_up == [True]
finally:
await database.close()
-2
View File
@@ -686,8 +686,6 @@ async def test_coordinator_configured_token_builds_real_client(
hass, entry, session_factory=_session_factory
)
assert isinstance(real_client, NestQuestApiClient)
# Home Assistant's zone is not reported to the API (task 7f685179).
assert real_client._headers() == {"Authorization": "Bearer tok"}
set_coordinator_client(entry, real_client)
assert await async_setup_entry(hass, entry) is True
+3 -6
View File
@@ -996,9 +996,8 @@ def test_definition_set_active_missing_returns_zero(tmp_path) -> None:
assert _with_db(tmp_path, "definition-set-active-missing.db")(_body) == 0
def test_definition_only_delete_method_is_history_guarded(tmp_path) -> None:
"""Guardrail: the ONLY definition delete path is the history-guarded
one (owner decision 2026-09-26); there is no unconditional delete."""
def test_definition_no_delete_method_exists(tmp_path) -> None:
"""Guardrail: definitions are deactivated, never hard-deleted."""
async def _body(database, rules, definitions, children, child):
import inspect
@@ -1009,9 +1008,7 @@ def test_definition_only_delete_method_is_history_guarded(tmp_path) -> None:
)
}
assert "delete" not in methods
assert {
name for name in methods if name.startswith("delete")
} == {"delete_if_no_history"}
assert not any(name.startswith("delete") for name in methods)
return methods
_with_db(tmp_path, "definition-no-delete.db")(_body)
+10 -143
View File
@@ -21,7 +21,6 @@ end-to-end through the quest-log harness.
from __future__ import annotations
import json
import os
import re
import subprocess
from pathlib import Path
@@ -303,15 +302,14 @@ def test_quest_log_entrances_use_the_design_timing_and_easing() -> None:
".quest.tappable:active",
"button.complete:active",
"button.confirm-button:active",
"button.return:active",
):
start = style.index(selector)
block = style[start : style.index("}", start)]
assert "transform: scale(0.98);" in block, selector
assert (
style.count("transition: transform var(--nq-dur-micro) var(--nq-ease-out);")
== 4
) # quest card, Complete button, confirm buttons, header return control
== 3
) # quest card, Complete button, confirm buttons
board_style = _style_literal(_read(PARTY_BOARD), STYLE_CONSTANTS[PARTY_BOARD])
start = board_style.index("button.plate:active")
block = board_style[start : board_style.index("}", start)]
@@ -831,10 +829,6 @@ def _render_quest_log(
idle_ms: int | None = None,
probe_ms: int | None = None,
tap_complete: int | None = None,
tap_return: bool = False,
now: str | None = None,
advance_to: list[str] | None = None,
tz: str | None = None,
) -> dict:
"""Render the built bundle's quest log and report its location.
@@ -844,10 +838,7 @@ def _render_quest_log(
segment — waits the optional probe window and captures one snapshot
of the render mid-wait, then the optional idle window, and prints
the final pathname and rendered title (plus the probe snapshot when
``probe_ms`` was given). ``now`` pins the card's clock to an ISO
instant, ``advance_to`` moves it through further instants firing the
card's clock tick (one snapshot each, under ``ticks``), and ``tz``
sets the node process's ``TZ`` — the panel browser's local zone.
``probe_ms`` was given).
"""
assert BUNDLE_PATH.is_file(), "bundle missing: run cd frontend && npm run build"
assert QUEST_LOG_HARNESS.is_file()
@@ -863,19 +854,12 @@ def _render_quest_log(
spec["probe_ms"] = probe_ms
if tap_complete is not None:
spec["tap_complete"] = tap_complete
if tap_return:
spec["tap_return"] = True
if now is not None:
spec["now"] = now
if advance_to is not None:
spec["advance_to"] = advance_to
completed = subprocess.run(
["node", str(QUEST_LOG_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"quest-log harness failed: {completed.stderr}"
@@ -939,40 +923,6 @@ def test_quest_log_idle_return_navigates_to_the_board() -> None:
assert result["location"] == "/nestquest"
def test_quest_log_return_control_navigates_to_the_board() -> None:
"""PANEL-SPEC §3: the Quest Log header carries an explicit return
control labelled for a child. Tapping it takes the same path as the
idle return — the strategy-computed board_path (the dashboard root) —
without waiting out the idle window."""
generated = _generate_dashboard(
{}, _three_child_states(), url="http://homeassistant.local/nestquest"
)
result = _render_quest_log(
generated["views"][1]["cards"][0],
_three_child_states(),
url="http://homeassistant.local/nestquest/ada",
tap_return=True,
)
assert result["headline"] == "Ada's Quest Log"
assert result["returned"] == {"rendered": True, "label": "The Party"}
assert result["location"] == "/nestquest"
def test_quest_log_return_control_follows_the_panel_hard_rules() -> None:
"""The return control is a 72px button with :active feedback only,
lives in the header (never the dock), and reuses the idle return's
_returnToBoard navigation rather than a second mechanism."""
log = _read(QUEST_LOG)
style = _style_literal(log, STYLE_CONSTANTS[QUEST_LOG])
rules = {selector.strip(): body for selector, body in _css_rules(style)}
assert "height: var(--nq-p-button-height);" in rules["button.return"]
assert "transform: scale(0.98);" in rules["button.return:active"]
header = log[log.index("private _renderHeader") : log.index("private _renderColumn")]
assert 'class="return"' in header
assert "@click=${() => this._returnToBoard()}" in header
assert log.count("this._returnToBoard()") == 4
def test_quest_complete_screen_returns_to_the_board_after_the_countdown() -> None:
"""The 1c screen renders with its countdown line and auto-returns to
the strategy-computed board path when it reaches zero. The design
@@ -1085,12 +1035,9 @@ def test_quest_log_crest_keeps_its_shield_classes() -> None:
)
header = result["html"][result["html"].index('<header class="header">') :]
crest = re.match(
r'<header class="header">\s*(?:<!--[^>]*-->\s*)*'
r'(?:<button class="return"[^>]*>.*?</button>\s*(?:<!--[^>]*-->\s*)*)?'
r'<span ([^>]*)>\s*'
r'<header class="header">\s*(?:<!--[^>]*-->\s*)*<span ([^>]*)>\s*'
r'<span class="crest-face">\s*<span class="initial">',
header,
re.DOTALL,
)
assert crest is not None, header[:600]
assert f'class="{crest_class}"' in crest.group(1), crest.group(1)
@@ -1102,13 +1049,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."""
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 +1062,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 +1077,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 +1086,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:]:
@@ -1158,83 +1102,6 @@ def test_quest_log_meta_line_keeps_its_meta_class() -> None:
assert metas["42"].strip() == 'class="meta late"', metas["42"]
def _quest_metas(html: str) -> dict[str, tuple[str, str]]:
"""Map each rendered open quest's instance id to its meta line's
attributes and text."""
metas = {}
for card in html.split('data-instance-id="')[1:]:
meta = re.search(
r'<span class="quest-title">.*?</span>'
r"\s*(?:<!--[^>]*-->\s*)*<span([^>]*)>(.*?)</span>",
card,
re.S,
)
assert meta is not None, card[:800]
text = " ".join(re.sub(r"<!--[^>]*-->", "", meta.group(2)).split())
metas[card[: card.index('"')]] = (meta.group(1).strip(), text)
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."""
generated = _generate_dashboard(
{}, _three_child_states(), url="http://homeassistant.local/nestquest"
)
def quest(instance_id: int, title: str, due_time: str | None) -> dict:
return {
"id": instance_id,
"definition_id": instance_id,
"child_id": 1,
"title": title,
"icon": None,
"window": "morning",
"due_time": due_time,
"state": "open",
"overdue": True,
"completed_at": None,
"on_time": None,
}
states = {
**_three_child_states(),
"sensor.nestquest_ada_quests_due_today": _state(
"2",
child_name="Ada",
child_id=1,
present=True,
instances=[
quest(42, "Brush Teeth", "08:15"),
quest(43, "Feed Fish", None),
],
),
}
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",
)
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 = (
_quest_metas(result["html"]),
*(_quest_metas(tick["html"]) for tick in result["ticks"]),
)
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:
"""PANEL-SPEC §3 header crest: 96x110 shield, 4px ring of
rgba(255,255,255,.22) around the --nq-p-crest face, 44px/700 display
-187
View File
@@ -22,10 +22,8 @@ from custom_components.nestquest.core.db import NestQuestDatabase
from custom_components.nestquest.core.migrations import apply_migrations
from custom_components.nestquest.core.quest_definitions import (
CreatedQuestDefinition,
DeletedQuestDefinition,
assign_child,
create_quest_definition,
delete_quest_definition,
edit_quest_definition,
list_active_definitions,
list_definitions_firing_on,
@@ -2128,188 +2126,3 @@ def test_wrappers_forward_horizon_days(tmp_path, mode) -> None:
return None
_with_db(tmp_path, f"wrapper-horizon-{mode}.db")(_body)
# ---------------------------------------------------------------------------
# delete_quest_definition: hybrid delete (owner decision 2026-09-26)
# ---------------------------------------------------------------------------
async def _count(database, sql, definition_id) -> int:
row = await database.fetch_one(sql, (definition_id,))
return row[0]
def test_delete_without_history_hard_deletes_everything(tmp_path) -> None:
"""No completion event on any instance: the definition, its
instances, assignees, windows and unreferenced rule are all gone."""
async def _body(database, children, rules, definitions, child):
created = await create_quest_definition(
database,
"Brush teeth",
_daily_rule(),
[child.id],
["morning", "evening"],
)
definition_id = created.definition.id
rule_id = created.definition.schedule_rule_id
instances_sql = (
"SELECT COUNT(*) FROM quest_instances WHERE definition_id = ?"
)
# Create materialized future instances, so the delete has
# instance rows to remove (not just an empty table).
assert await _count(database, instances_sql, definition_id) > 0
result = await delete_quest_definition(database, definition_id)
assert result == DeletedQuestDefinition(
definition_id=definition_id,
deleted=True,
retired=False,
definition=None,
)
assert await definitions.get(definition_id) is None
assert await rules.get(rule_id) is None
assert await _count(database, instances_sql, definition_id) == 0
for table in (
"quest_definition_assignees",
"quest_definition_windows",
):
assert await _count(
database,
f"SELECT COUNT(*) FROM {table} WHERE definition_id = ?",
definition_id,
) == 0
# The child itself is untouched.
assert (await children.get(child.id)) is not None
return None
_with_db(tmp_path, "delete-no-history.db")(_body)
def test_delete_without_history_keeps_a_shared_rule(tmp_path) -> None:
"""The rule is deleted only when no other definition references it."""
async def _body(database, children, rules, definitions, child):
rule = await rules.create("daily", "2026-09-14")
first = await definitions.create(
"First", rule.id, NOW, assignee_child_ids=[child.id]
)
second = await definitions.create(
"Second", rule.id, NOW, assignee_child_ids=[child.id]
)
result = await delete_quest_definition(database, first.id)
assert result.deleted is True
assert await definitions.get(first.id) is None
assert await definitions.get(second.id) is not None
assert await rules.get(rule.id) is not None
return None
_with_db(tmp_path, "delete-shared-rule.db")(_body)
def test_delete_with_history_retires_and_preserves_everything(
tmp_path,
) -> None:
"""A completion event on ANY instance: the definition is retired
(deactivated) and its row, rule, assignees, windows and completion
history all remain. The completed instance survives; only future
UNCOMPLETED instances are cleared by the deactivation's regeneration."""
async def _body(database, children, rules, definitions, child):
created = await create_quest_definition(
database, "Brush teeth", _daily_rule(), [child.id], ["morning"]
)
definition_id = created.definition.id
rule_id = created.definition.schedule_rule_id
instances = QuestInstancesDao(database)
events = CompletionEventsDao(database)
instance = await instances.upsert(
definition_id, child.id, _today_iso(), NOW, window="morning"
)
event = await events.append(
instance.id,
child.id,
"completed",
"user",
NOW,
True,
actor_user_id="admin-1",
)
result = await delete_quest_definition(database, definition_id)
assert result.deleted is False
assert result.retired is True
assert result.definition_id == definition_id
assert isinstance(result.definition, CreatedQuestDefinition)
assert result.definition.definition.is_active is False
stored = await definitions.get(definition_id)
assert stored is not None
assert stored.is_active is False
assert await rules.get(rule_id) is not None
assert [c.id for c in await definitions.list_assignees(
definition_id
)] == [child.id]
assert [w.window for w in await definitions.list_windows(
definition_id
)] == ["morning"]
assert await instances.get_by_id(instance.id) == instance
assert await events.list_by_instance(instance.id) == [event]
return None
_with_db(tmp_path, "delete-with-history.db")(_body)
def test_delete_with_uncompleted_history_still_retires(tmp_path) -> None:
"""ANY completion event counts as history — even when the latest
event is an un-completion, the definition is retired, not deleted."""
async def _body(database, children, rules, definitions, child):
created = await create_quest_definition(
database, "Brush teeth", _daily_rule(), [child.id], ["morning"]
)
definition_id = created.definition.id
instances = QuestInstancesDao(database)
events = CompletionEventsDao(database)
instance = await instances.upsert(
definition_id, child.id, _today_iso(), NOW, window="morning"
)
await events.append(
instance.id, child.id, "completed", "user", NOW, True,
actor_user_id="admin-1",
)
await events.append(
instance.id, child.id, "uncompleted", "user", NOW, None,
actor_user_id="admin-1",
)
result = await delete_quest_definition(database, definition_id)
assert result.retired is True
assert await instances.get_by_id(instance.id) == instance
return None
_with_db(tmp_path, "delete-uncompleted-history.db")(_body)
def test_delete_nonexistent_definition_names_field(tmp_path) -> None:
async def _body(database, children, rules, definitions, child):
with pytest.raises(ValueError, match=r"^definition_id:"):
await delete_quest_definition(database, 999)
return None
_with_db(tmp_path, "delete-missing.db")(_body)
@pytest.mark.parametrize("bad", [True, 1.0, "1", None])
def test_delete_rejects_non_int_definition_id(tmp_path, bad) -> None:
async def _body(database, children, rules, definitions, child):
created = await create_quest_definition(
database, "Brush teeth", _daily_rule(), [child.id], ["morning"]
)
with pytest.raises(ValueError, match="definition_id"):
await delete_quest_definition(database, bad)
assert await definitions.get(created.definition.id) is not None
return None
_with_db(tmp_path, "delete-bad-id.db")(_body)
Generated
+1 -1
View File
@@ -373,7 +373,7 @@ wheels = [
[[package]]
name = "nestquest"
version = "0.9.7"
version = "0.8.0"
source = { virtual = "." }
[package.dev-dependencies]