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
10 changed files with 250 additions and 11 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>
+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"
+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);"
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]