Compare commits

..
Author SHA1 Message Date
overseerandClaude Opus 5.5 6e6e25f7d3 Fix the missing shield crest in the child's Quest Log header
Lit removes an attribute entirely when any interpolated part of it is
`nothing`, so `class="crest${away ? " away" : nothing}"` rendered a
present child's crest as a bare, unstyled span: an 11x51 sliver with a
16px black initial instead of the 96x110 brand-gradient shield. Use an
empty string for the present case, and widen the ring from 3px to the
spec's 4px.

Adds a jsdom test on the rendered crest classes (present and away) and
a stylesheet test pinning the PANEL-SPEC crest geometry, and rebuilds
the committed bundle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 08:27:05 -04:00
dev_agent c5b313a5e3 Fix hidden hint text behind the party-board dock (#244) 2026-09-26 08:09:51 -04:00
orchestrator-agent fb9907ae7f Merge pull request Reconcile dev with main after the 0.8.0 release 2026-09-25 14:53:18 -04:00
overseer dbf410ada3 Merge pull request 'Release: multi-pattern presence, Sunday-first week and admin fixes (0.8.0)' (#239) from release/2026-09-25-presence-patterns-and-start-date into main
Reviewed-on: #239
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-09-25 12:52:31 -04:00
overseer 2906da7ed1 release: clarify the 0.8.0 scope wording 2026-09-25 07:42:04 -04:00
overseer 4f609d7bb9 release: advance version to 0.8.0 and add the 0.8.0 release notes 2026-09-25 07:39:18 -04:00
dev_agent 39e0d715fc Feature: definition sheet start-date field (#238)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-25 07:38:14 -04:00
dev_agent d248b9cb32 Fix: create quest definition materializes its instances (#236)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-25 07:26:33 -04:00
dev_agent 64a56389ad Feature docs: multi-pattern presence, Sunday-first week and the migration-9 rollback (#234)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-24 20:41:06 -04:00
17 changed files with 333 additions and 29 deletions
+36
View File
@@ -1,5 +1,41 @@
# NestQuest Release Notes
## Version 0.8.0 — 2026-09-25
### Scope
Minor release carrying the multi-pattern presence model (schema 9), the
Sunday-first calendar week, and the create-materializes fix, plus the
definition start-date field, onto `main`.
- **Multiple presence patterns per child (schema 9):** a child now has zero or
more presence patterns instead of at most one. Each pattern has a name, a kind
(`home` or `away`), its own cycle length (1–4 weeks), its own anchor date and a
per-week weekday set. Evaluation is first-match-wins — overrides, then away,
then home, then absent when the child has at least one home pattern, else
present. Migration 9 copies each existing schedule into a `home` pattern named
"Home schedule" and drops the old `presence_schedules` table. (PRs #231, #232.)
- **Sunday-first calendar week:** the admin Schedule month grid and the
Definitions weekday picker now render weeks Sunday-first; the stored/API
weekday numbering is unchanged (Monday=0..Sunday=6). (PR #230.)
- **Fix — newly created tasks materialize immediately:** creating a quest
definition now regenerates its future instances over the rolling horizon, so a
new task reaches the panel without waiting for an unrelated write. (PRs #235,
#236.)
- **Definition start date:** the definition edit sheet gains a Start date field
so a task can be scheduled to begin on a future date. (PRs #237, #238.)
- **Docs:** the multi-pattern decision (D-016), the migration-9 rollback step in
the runbook, and the ADMIN-SPEC Schedule section. (PRs #233, #234.)
### Upgrade note
This release runs schema version **9**. Version 0.7.1 refuses a version-9
database, so take a copy of the SQLite file (or note the Litestream generation)
before the first start on 0.8.0 if you need a rollback path.
### Requirements
## Version 0.7.1 — 2026-09-24
### Scope
@@ -1118,3 +1118,95 @@ describe("DefinitionsTab icon picker", () => {
expect(rowIcon(screen.getByTestId("definition-20"))).toBeNull();
});
});
/* ── Start date ─────────────────────────────────────────────────────── */
function startDateInput(s: HTMLElement): HTMLInputElement {
return within(s).getByLabelText("Start date", { selector: "input" }) as HTMLInputElement;
}
describe("DefinitionsTab start date", () => {
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.useRealTimers();
});
it("a new task defaults to today and POSTs the chosen start date", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date(2026, 8, 23, 9, 0)); // Wed 23 Sep 2026, local
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "New" }));
const s = sheet();
const input = startDateInput(s);
expect(input.type).toBe("date");
expect(input.value).toBe("2026-09-23");
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
target: { value: "Feed the cat" },
});
fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ }));
fireEvent.click(within(s).getByRole("checkbox", { name: "Declan" }));
fireEvent.click(within(s).getByRole("button", { name: "Evening" }));
fireEvent.change(input, { target: { value: "2026-10-15" } });
await act(async () => {
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
});
expect(api.writes()).toHaveLength(1);
expect(api.writes()[0].method).toBe("POST");
expect((api.writes()[0].body as { rule: DefinitionRule }).rule.start_date).toBe("2026-10-15");
});
it("an edit pre-fills the definition's start date and PATCHes a changed one", async () => {
await renderReady();
fireEvent.click(screen.getByTestId("definition-10"));
const s = sheet();
const input = startDateInput(s);
expect(input.value).toBe("2026-01-01");
fireEvent.change(input, { target: { value: "2026-11-02" } });
await act(async () => {
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
});
expect(api.writes()).toEqual([
expect.objectContaining({
method: "PATCH",
path: "/api/v1/admin/quest-definitions/10",
body: expect.objectContaining({ rule: rule({ start_date: "2026-11-02" }) }),
}),
]);
});
it("the occurrence preview requests the chosen start date", async () => {
await renderReady();
fireEvent.click(screen.getByTestId("definition-10"));
const s = sheet();
await waitFor(() => expect(api.previews()).toHaveLength(1));
fireEvent.change(startDateInput(s), { target: { value: "2027-03-01" } });
await settleDebounce();
await waitFor(() => expect(api.previews()).toHaveLength(2));
expect((api.previews()[1].body as { rule: DefinitionRule }).rule.start_date).toBe(
"2027-03-01",
);
});
it("a cleared start date is not submitted", async () => {
await renderReady();
fireEvent.click(screen.getByTestId("definition-10"));
const s = sheet();
fireEvent.change(startDateInput(s), { target: { value: "" } });
await act(async () => {
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
});
expect(api.writes()).toHaveLength(0);
expect(within(s).getByText("Pick a start date.")).toBeTruthy();
});
});
+11
View File
@@ -177,6 +177,7 @@ function parseWhole(value: string, min: number, max: number): number | null {
function buildRule(draft: Draft): DefinitionRule | string {
const interval = parseWhole(draft.interval, 1, 999);
if (interval === null) return "Interval must be a whole number of at least 1.";
if (draft.startDate === "") return "Pick a start date.";
const rule: DefinitionRule = {
rule_type: "daily",
interval,
@@ -740,6 +741,16 @@ function EditSheet({
<OccurrencePreview ruleKey={previewKey} />
</Field>
<Field label="Start date" id="defs-label-start">
<input
className="defs-input"
type="date"
aria-labelledby="defs-label-start"
value={draft.startDate}
onChange={(e) => update({ startDate: e.target.value })}
/>
</Field>
<Field label="Due time" id="defs-label-due">
{orderedWindows.length === 0 ? (
<p className="defs-hint">Pick a window to set its due time.</p>
@@ -235,6 +235,11 @@ async def create_quest_definition(
transaction, so the returned bundle is a consistent creation
snapshot. Raises ValueError on any rejected argument; a rejected or
failed create persists nothing.
After the write, :func:`~.materialize.regenerate_for_definition`
materializes the new definition's instances over the rolling horizon
(host clock, :data:`~.const.DEFAULT_HORIZON_DAYS`), so it reaches the
board without waiting for an unrelated write.
"""
name = _validate_text(title, "title", required=True)
assert name is not None
@@ -269,6 +274,9 @@ async def create_quest_definition(
) from error
raise
decoded_rule = schedule_rule_from_storage(storage)
await regenerate_for_definition(database, snapshot.definition.id)
return CreatedQuestDefinition(
definition=snapshot.definition,
rule=decoded_rule,
+1 -1
View File
@@ -15,5 +15,5 @@
"iot_class": "local_polling",
"issue_tracker": "https://github.com/Maniacs-Mansion/NestQuest/issues",
"requirements": [],
"version": "0.7.1"
"version": "0.8.0"
}
@@ -906,11 +906,12 @@ const $t = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", $e = "polygon(50%
.hint {
position: absolute;
top: 930px;
top: 896px;
left: 0;
right: 0;
margin: 0;
font-family: var(--nq-p-font-body);
font-size: 24px;
font-size: 27px;
font-weight: 600;
line-height: 1.4;
text-align: center;
@@ -1764,7 +1765,7 @@ const Nt = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Ye = "polygon(50%
flex: none;
width: 96px;
height: 110px;
padding: 3px;
padding: 4px;
clip-path: ${v(Nt)};
background: rgba(255, 255, 255, 0.22);
}
@@ -3090,7 +3091,7 @@ class un extends A {
const e = this._childSlug(), n = t.kind === "away", i = n ? t.name : this._childName(e) ?? (e ? Y(e) : null), { due: r, completed: o } = this._childCounts(e), l = this._remaining(), a = this._partyCounts(), p = i ? `${i}'s Quest Log` : "Quest Log", u = (i?.charAt(0) || "?").toUpperCase();
return d`
<header class="header">
<span class="crest${n ? " away" : g}" aria-hidden="true">
<span class="crest${n ? " away" : ""}" aria-hidden="true">
<span class="crest-face">
<span class="initial">${u}</span>
</span>
+3 -2
View File
@@ -497,11 +497,12 @@ const boardStyles = css`
.hint {
position: absolute;
top: 930px;
top: 896px;
left: 0;
right: 0;
margin: 0;
font-family: var(--nq-p-font-body);
font-size: 24px;
font-size: 27px;
font-weight: 600;
line-height: 1.4;
text-align: center;
+2 -2
View File
@@ -378,7 +378,7 @@ const logStyles = css`
flex: none;
width: 96px;
height: 110px;
padding: 3px;
padding: 4px;
clip-path: ${unsafeCSS(SHIELD_CLIP)};
background: rgba(255, 255, 255, 0.22);
}
@@ -1938,7 +1938,7 @@ export class NestQuestQuestLogCard extends LitElement {
const initial = (name?.charAt(0) || "?").toUpperCase();
return html`
<header class="header">
<span class="crest${away ? " away" : nothing}" aria-hidden="true">
<span class="crest${away ? " away" : ""}" aria-hidden="true">
<span class="crest-face">
<span class="initial">${initial}</span>
</span>
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "nestquest"
version = "0.7.1"
version = "0.8.0"
description = "NestQuest Home Assistant Integration"
readme = "README.md"
requires-python = ">=3.11"
+12 -10
View File
@@ -514,8 +514,10 @@ async def test_regenerate_household_never_duplicates(
client = admin_household.client
database = admin_household.database
before = await _instance_count(database)
assert before > 0 # the seed already materialized a few days
# The seed's creates materialized the horizon BEFORE Ada's custody
# pattern existed, so the first household walk may legitimately
# drop her away-day rows; only the second run must be stable.
assert await _instance_count(database) > 0
first = await client.post(
"/api/v1/admin/regenerate", json={}, headers=_admin_headers()
@@ -525,9 +527,8 @@ async def test_regenerate_household_never_duplicates(
assert body["scope"] == "household"
assert body["definition_id"] is None
assert body["child_id"] is None
assert body["count"] >= before
assert body["count"] > 0
after_first = await _instance_count(database)
assert after_first >= before
second = await client.post(
"/api/v1/admin/regenerate", json={}, headers=_admin_headers()
@@ -544,17 +545,18 @@ async def test_regenerate_definition_scope_rematerializes(
) -> None:
"""The definition scope rebuilds that definition's horizon, twice, stably.
Creating a definition through the admin route materializes NOTHING
(the core's create path does not regenerate); the scoped regenerate
call is what materializes its instances, and re-running it leaves
the row count exactly where the first run left it.
Creating a definition through the admin route already materializes
its horizon (the core's create path regenerates); the scoped
regenerate call rebuilds the same rows, and re-running it leaves
the row count exactly where the create left it.
"""
client = admin_api.client
database = admin_api.database
ada = await _create_child(client, "Ada")
definition_id = await _create_daily_definition(client, [ada])
assert await _instance_count(database) == 0
created_count = await _instance_count(database)
assert created_count > 0
first = await client.post(
"/api/v1/admin/regenerate",
@@ -568,7 +570,7 @@ async def test_regenerate_definition_scope_rematerializes(
assert body["child_id"] is None
assert body["count"] > 0
after_first = await _instance_count(database)
assert after_first == body["count"]
assert after_first == body["count"] == created_count
second = await client.post(
"/api/v1/admin/regenerate",
+7 -2
View File
@@ -151,12 +151,17 @@ async def _seed_missed_household(
database, yesterday_iso, yesterday_iso, today=yesterday
)
# Today's quest: created after the yesterday walk so it has no
# past-due instance, then materialized for today only.
# past-due instance; ``end_date`` keeps it (and create's horizon
# regeneration) to today only.
await _quest_definitions.create_quest_definition(
database,
"Recoverable chore",
_recurrence.ScheduleRule.from_dict(
{"rule_type": "daily", "start_date": today_iso}
{
"rule_type": "daily",
"start_date": today_iso,
"end_date": today_iso,
}
),
[ada.id],
[("afternoon", "17:00")],
+8 -3
View File
@@ -149,13 +149,18 @@ async def _seed_household(
(today + datetime.timedelta(days=3)).isoformat(),
today=today,
)
# The stale quest is created AFTER the today walk, so its only
# instance is the yesterday one written below.
# The stale quest's rule ends YESTERDAY, so create's own
# regeneration writes nothing and its only instance is the
# yesterday one written below.
await _quest_definitions.create_quest_definition(
database,
"Stale chore",
_recurrence.ScheduleRule.from_dict(
{"rule_type": "daily", "start_date": yesterday_iso}
{
"rule_type": "daily",
"start_date": yesterday_iso,
"end_date": yesterday_iso,
}
),
[ada.id],
[("morning", "09:00")],
+8 -3
View File
@@ -102,15 +102,20 @@ async def _seed_missed_household(
database, "Old chore", rule, [ada.id], [("evening", "18:30")]
)
await materialize(database, yesterday_iso, yesterday_iso, today=yesterday)
# A quest due TODAY (created after the yesterday walk so it has
# no past-due instance): the post-downtime catch-up run's
# A quest due TODAY only (created after the yesterday walk so it
# has no past-due instance; ``end_date`` keeps create's horizon
# regeneration to today): the post-downtime catch-up run's
# target — inside [watermark=today, later_today) but not
# inside the day-one sweep's window.
await create_quest_definition(
database,
"Recoverable chore",
ScheduleRule.from_dict(
{"rule_type": "daily", "start_date": today_iso}
{
"rule_type": "daily",
"start_date": today_iso,
"end_date": today_iso,
}
),
[ada.id],
[("afternoon", "17:00")],
+4
View File
@@ -468,7 +468,11 @@ async def _materialize_with_change(
proceeds with the snapped presence). This deterministically
exercises the snapshot-to-insert gap that a config edit can
interleave into.
``create_quest_definition`` already materializes its horizon, so the
table is cleared first: the gated walk must be the only writer.
"""
await database.execute("DELETE FROM quest_instances")
original_upsert = QuestInstancesDao.upsert_if_valid
entered = asyncio.Event()
release = asyncio.Event()
+98
View File
@@ -411,6 +411,36 @@ def test_panel_hard_rules_hold_in_the_card_sources() -> None:
assert "@click" not in sealed_template
def test_party_board_hint_clears_the_dock() -> None:
"""PANEL-SPEC §2/§2.3: the hint line reads fully above the dock at
1920x1080. The hint's line box (top + font-size x line-height) must
end above the dock's top edge (1080 - bottom inset - dock height)
with a visible gap, at the 27px/600 design size. The hint is a <p>,
so its user-agent 1em top margin must be reset or it shifts the box
down by a whole font-size."""
style = _style_literal(_read(PARTY_BOARD), STYLE_CONSTANTS[PARTY_BOARD])
rules = {selector.strip(): body for selector, body in _css_rules(style)}
sizes = _size_tokens(_read(REWARD_TOKENS))
def px(body: str, prop: str) -> float:
declared = re.search(rf"(?<![-\w]){prop}:\s*([^;]+);", body)
assert declared is not None, prop
value = _font_size_px(declared.group(1), sizes)
assert value is not None, f"{prop}: {declared.group(1)}"
return value
board, hint, dock = rules[".board"], rules[".hint"], rules[".dock"]
assert "margin: 0;" in hint
line_height = float(re.search(r"line-height:\s*([\d.]+);", hint).group(1))
hint_bottom = px(hint, "top") + px(hint, "font-size") * line_height
dock_top = px(board, "height") - px(dock, "bottom") - px(dock, "height")
assert px(dock, "height") == 84
assert px(dock, "bottom") == 46
assert hint_bottom + 12 <= dock_top, (hint_bottom, dock_top)
assert px(hint, "font-size") == 27
assert "font-weight: 600;" in hint
def test_panel_text_sizes_respect_the_22px_floor() -> None:
"""PANEL-SPEC §1: no body text below 22px. Every font-size in the
panel styles resolves against the panel size tokens or an explicit
@@ -886,3 +916,71 @@ def test_quest_complete_screen_returns_to_the_board_after_the_countdown() -> Non
assert result["probe"]["countdown"] == "Returning to The Party in 1 seconds"
# …and at zero the card has returned to the party board.
assert result["location"] == "/nestquest"
def test_quest_log_crest_keeps_its_shield_classes() -> None:
"""PANEL-SPEC §3 header crest: a present child's crest renders as the
styled shield, not a bare span. Lit removes an attribute outright when
any interpolated part of it is ``nothing``, so a class written as
``crest${away ? " away" : nothing}`` lost ``crest`` for every present
child and the header showed an unstyled sliver. The rendered markup
must carry ``class="crest"`` (present) and ``class="crest away"``
(away), each wrapping the face and initial."""
generated = _generate_dashboard(
{}, _three_child_states(), url="http://homeassistant.local/nestquest"
)
log_config = generated["views"][1]["cards"][0]
for slug, crest_class in (("ada", "crest"), ("bo", "crest away")):
result = _render_quest_log(
log_config,
_three_child_states(),
url=f"http://homeassistant.local/nestquest/{slug}",
)
header = result["html"][result["html"].index('<header class="header">') :]
crest = re.match(
r'<header class="header">\s*(?:<!--[^>]*-->\s*)*<span ([^>]*)>\s*'
r'<span class="crest-face">\s*<span class="initial">',
header,
)
assert crest is not None, header[:600]
assert f'class="{crest_class}"' in crest.group(1), crest.group(1)
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
initial in full-opacity white; away swaps only the face gradient.
jsdom cannot lay out, so this pins the stylesheet the rendered test
above shows is applied."""
source = QUEST_LOG.read_text(encoding="utf-8")
def rule(selector: str) -> str:
found = re.search(
r"\n " + re.escape(selector) + r" \{(.*?)\n \}", source, re.DOTALL
)
assert found is not None, selector
return found.group(1)
crest = rule(".crest")
for decl in (
"width: 96px;",
"height: 110px;",
"padding: 4px;",
"clip-path: ${unsafeCSS(SHIELD_CLIP)};",
"background: rgba(255, 255, 255, 0.22);",
):
assert decl in crest, decl
face = rule(".crest-face")
assert "background: var(--nq-p-crest);" in face
assert "clip-path: ${unsafeCSS(SHIELD_CLIP)};" in face
initial = rule(".crest .initial")
for decl in (
"font-family: var(--nq-p-font-display);",
"font-size: 44px;",
"font-weight: 700;",
"color: #ffffff;",
):
assert decl in initial, decl
assert "opacity" not in initial
away = rule(".crest.away .crest-face")
assert away.strip() == "background: var(--nq-p-crest-away);"
+36
View File
@@ -168,6 +168,42 @@ def test_create_several_children_several_windows_with_due_times(
_with_db(tmp_path, "create-many.db")(_body)
def test_create_materializes_future_instances(tmp_path) -> None:
"""A fresh create regenerates its rolling horizon immediately.
Without this the new definition has no ``quest_instances`` rows until
some unrelated write regenerates it, so it never reaches the board.
"""
assert DEFAULT_HORIZON_DAYS >= 7
async def _body(database, children, rules, definitions, child):
today = _today_iso()
created = await create_quest_definition(
database,
"Take out bins",
ScheduleRule(
rule_type=RuleType.WEEKLY,
weekday_set={0},
start_date=today,
),
[child.id],
["morning"],
)
rows = await database.fetch_all(
"SELECT child_id, due_date FROM quest_instances "
"WHERE definition_id = ?",
(created.definition.id,),
)
assert rows, "create materialized no instances"
assert {row[0] for row in rows} == {child.id}
for _child_id, due_date in rows:
assert due_date >= today
assert datetime.date.fromisoformat(due_date).weekday() == 0
return None
_with_db(tmp_path, "create-materializes.db")(_body)
# ---------------------------------------------------------------------------
# create_quest_definition: argument rejections
# ---------------------------------------------------------------------------
Generated
+1 -1
View File
@@ -373,7 +373,7 @@ wheels = [
[[package]]
name = "nestquest"
version = "0.7.1"
version = "0.8.0"
source = { virtual = "." }
[package.dev-dependencies]