diff --git a/custom_components/taskmate/www/taskmate-child-card.js b/custom_components/taskmate/www/taskmate-child-card.js index ce139367..391fe751 100644 --- a/custom_components/taskmate/www/taskmate-child-card.js +++ b/custom_components/taskmate/www/taskmate-child-card.js @@ -4365,8 +4365,12 @@ class TaskMateChildCard extends LitElement { ? this._isChoreDone(chore, child, todaysCompletions).done : childCompletionsToday.length >= dailyLimit; const isLoading = this._loading[chore.id]; - const available = chore._isAvailableForChild !== false && !chore._isLockedPreview - && !chore._isDependencyBlocked; + // The same locks the classic row refuses a tap for — a recurring chore in + // its cooldown, a later period, a missed window, a sibling's first-come + // claim, a filled weekly quota, a prerequisite, a set time that isn't + // open. The tile used to read a flag nothing ever set, so a chore in its + // cooldown stayed tappable here (#771). + const available = !this._choreLocks(chore, isDone).locked; // Stars, not digits: a 4-year-old can count pictures, not read "+3". // The count is deliberately lossy — it rounds and clamps to 1..5 — which @@ -4386,9 +4390,11 @@ class TaskMateChildCard extends LitElement { ?disabled=${isLoading || !available} aria-label="${chore.name}" title="${chore.name}" - @click=${() => (isDone - ? this._handleUndo(chore, child, childCompletionsToday) - : this._handleComplete(chore, child))} + @click=${() => { + if (isLoading || !available) return; + if (isDone) this._handleUndo(chore, child, childCompletionsToday); + else this._handleComplete(chore, child); + }} >
${v.kind === "image" diff --git a/tests/frontend/taskmate-child-card-prereader-locks.test.cjs b/tests/frontend/taskmate-child-card-prereader-locks.test.cjs new file mode 100644 index 00000000..4c5f9c80 --- /dev/null +++ b/tests/frontend/taskmate-child-card-prereader-locks.test.cjs @@ -0,0 +1,213 @@ +"use strict"; +const assert = require("node:assert/strict"); +const { test } = require("node:test"); + +const { loadCard, render } = require("./harness.cjs"); + +// Pre-reader picture tiles (`pre_reader: true`) must refuse a tap whenever the +// classic row does (#771). The tile read chore._isAvailableForChild, which +// nothing ever set, so a recurring chore in its cooldown — and a sibling's +// first-come claim, a filled weekly quota, a missed window, a closed set time — +// stayed tappable. These render the whole card through the real filter pass, +// in every design, and press the tile. + +// graphite reuses the cleanpro layout (LAYOUT_ALIAS in taskmate-design.js). +const ALIAS = { graphite: "cleanpro" }; +const { get } = loadCard("taskmate-child-card.js", { + window: { + __taskmate_badge_id: (b) => b.badge_id, + __taskmate_design: { + apply: (_card, _hass, config) => { + const d = config?.card_design || "classic"; + return ALIAS[d] || d; + }, + styles: () => null, + editorOptions: () => [], + }, + }, +}); +const ChildCard = get("taskmate-child-card"); + +const ENTITY = "sensor.taskmate_overview"; +const DESIGNS = ["classic", "playroom", "console", "cleanpro", "accessible", "graphite"]; + +function chore(extra = {}) { + return { + id: "zz", + name: "ZZ chore", + points: 5, + time_category: "anytime", + assigned_to: [], + due_days: [], + schedule_mode: "specific_days", + daily_limit: 1, + ...extra, + }; +} + +function makeCard({ chores, completions = [], availability = {}, weekly = {}, config = {}, design }) { + const card = new ChildCard(); + card.config = { entity: ENTITY, child_id: "kid1", card_design: design, pre_reader: true, ...config }; + const mia = { id: "kid1", name: "Mia", chore_order: [], weekly_chore_progress: weekly }; + const leo = { id: "kid2", name: "Leo", chore_order: [], weekly_chore_progress: {} }; + card.hass = { + states: { + [ENTITY]: { + state: "ok", + attributes: { + children: [mia, leo], + chores, + todays_completions: completions, + chore_availability: availability, + today_day_of_week: "wednesday", + }, + }, + }, + }; + card._loading = {}; + card._optimisticCompletions = {}; + card.completed = []; + card._handleComplete = (c) => card.completed.push(c.id); + return card; +} + +/** The picture tile for the chore under test. */ +function tile(view) { + return view.controls.find((c) => c.tag === "button" && /class="pre-tile\b/.test(c.attrs) && /aria-label="ZZ chore"/.test(c.attrs)); +} + +async function assertBlocked(card, design) { + const view = render(card.render()); + assert.match(view.markup, /pre-reader-grid/, `${design}: picture tiles are what's rendered`); + const target = tile(view); + assert.ok(target, `${design}: the tile should be on the card`); + assert.equal(target.disabled, true, `${design}: the tile must be disabled`); + assert.match(target.attrs, /\blocked\b/, `${design}: and styled as locked`); + await assert.rejects(target.click(), /disabled/); + assert.deepEqual(card.completed, [], `${design}: the tap must not complete the chore`); +} + +async function assertLive(card, design) { + const view = render(card.render()); + const target = tile(view); + assert.ok(target, `${design}: the tile should be on the card`); + assert.equal(target.disabled, false, `${design}: the tile should be live`); + await target.click(); + assert.deepEqual(card.completed, ["zz"], `${design}: the tap completes the chore`); +} + +const REASONS = [ + { + name: "before its time period (locked preview)", + args: { chores: [chore({ time_category: "evening" })], config: { debug_time_period: "morning" } }, + }, + { + name: "past its time window (elapsed, dim)", + args: { chores: [chore({ time_category: "morning" })], config: { debug_time_period: "evening", time_category: "all" } }, + }, + { + name: "not due today (due_days_mode dim)", + args: { chores: [chore({ due_days: ["monday"] })], config: { due_days_mode: "dim" } }, + }, + { + name: "in its recurrence cooldown", + args: { + chores: [chore({ schedule_mode: "recurring", recurrence: "every_3_days" })], + availability: { zz: { kid1: false } }, + }, + }, + { + name: "in its recurrence cooldown (recurrence_done_mode show)", + args: { + chores: [chore({ schedule_mode: "recurring", recurrence: "every_3_days" })], + availability: { zz: { kid1: false } }, + config: { recurrence_done_mode: "show" }, + }, + }, + { + name: "waiting on a prerequisite (dependency_mode dim)", + args: { + chores: [chore({ depends_on: ["teeth"] }), chore({ id: "teeth", name: "Teeth", time_category: "night" })], + config: { dependency_mode: "dim", debug_time_period: "morning", time_category: "morning" }, + }, + }, + { + name: "claimed first by a sibling (first-come)", + args: { + chores: [chore({ assignment_mode: "first_come", assigned_to: ["kid1", "kid2"] })], + completions: [{ chore_id: "zz", child_id: "kid2", approved: true, completed_at: new Date().toISOString() }], + config: { first_come_claimed_mode: "dim" }, + }, + }, + { + name: "already at its weekly target", + args: { chores: [chore({ weekly_target: 2 })], weekly: { zz: 2 } }, + }, +]; + +for (const design of DESIGNS) { + for (const { name, args } of REASONS) { + test(`pre-reader ${design}: a chore ${name} can't be tapped`, async () => { + await assertBlocked(makeCard({ ...args, design }), design); + }); + } + + // A set time that hasn't opened (or closed undone). The set-times helper + // annotates the row, so the flag is applied the way it would set it. + test(`pre-reader ${design}: a set time that isn't open can't be tapped`, async () => { + const card = makeCard({ chores: [chore()], design }); + const filter = card._filterAndSortChores.bind(card); + card._filterAndSortChores = (...a) => filter(...a).map((c) => Object.assign(c, { _setTimeLocked: true })); + await assertBlocked(card, design); + }); + + test(`pre-reader ${design}: an open chore with nothing in the way is still tappable`, async () => { + await assertLive(makeCard({ chores: [chore({ time_category: "morning" })], config: { debug_time_period: "morning" }, design }), design); + }); + + test(`pre-reader ${design}: a matrix-available recurring chore is still tappable`, async () => { + const card = makeCard({ + chores: [chore({ schedule_mode: "recurring", recurrence: "every_3_days" })], + availability: { zz: { kid1: true } }, + design, + }); + await assertLive(card, design); + }); + + // Ticking a recurring chore makes the matrix report it unavailable at once; + // the tile must keep its undo rather than lock the child out of it. + test(`pre-reader ${design}: a recurring chore ticked today can still be undone`, async () => { + const card = makeCard({ + chores: [chore({ schedule_mode: "recurring", recurrence: "every_3_days" })], + availability: { zz: { kid1: false } }, + completions: [{ chore_id: "zz", child_id: "kid1", approved: false, completed_at: new Date().toISOString() }], + design, + }); + let undone = 0; + card._handleUndo = () => { undone += 1; }; + const target = tile(render(card.render())); + assert.ok(target, "the done tile should be rendered"); + assert.match(target.attrs, /\bdone\b/); + assert.equal(target.disabled, false); + await target.click(); + assert.equal(undone, 1); + assert.deepEqual(card.completed, []); + }); +} + +// Pre-reader tiles take their locks from the same helper as the classic row, +// and the flag nothing ever set is gone. +test("pre-reader tiles take their locks from _choreLocks", () => { + const src = require("node:fs").readFileSync( + require("node:path").join(__dirname, "../../custom_components/taskmate/www/taskmate-child-card.js"), + "utf8", + ); + const body = (name) => { + const start = src.indexOf(`\n ${name}(`); + assert.ok(start > 0, `${name} should exist`); + return src.slice(start, src.indexOf("\n }\n", start)); + }; + assert.match(body("_renderPreReaderTile"), /this\._choreLocks\(/); + assert.match(body("_renderChoreCard"), /this\._choreLocks\(/); + assert.doesNotMatch(src, /_isAvailableForChild/); +}); diff --git a/tests/test_chore_dependencies_frontend.py b/tests/test_chore_dependencies_frontend.py index 8bd692ef..915b4e45 100644 --- a/tests/test_chore_dependencies_frontend.py +++ b/tests/test_chore_dependencies_frontend.py @@ -102,8 +102,9 @@ def test_timed_chores_are_gated_too(self): def test_picture_mode_is_gated_too(self): start = CARD.index(" _renderPreReaderTile(chore, child, pointsIcon") - tile = CARD[start : CARD.index("_renderChoreCard(chore, child, pointsIcon", start)] - assert "_isDependencyBlocked" in tile + tile = CARD[start : CARD.index("\n _choreNumberBadge(", start)] + # Through the same lock set the classic row uses (#771). + assert "this._choreLocks(chore, isDone)" in tile class TestDependencyMode: diff --git a/tests/test_recurrence_availability.py b/tests/test_recurrence_availability.py index 5be00314..af878d29 100644 --- a/tests/test_recurrence_availability.py +++ b/tests/test_recurrence_availability.py @@ -48,13 +48,14 @@ def test_is_recurring_is_assigned(self): assert "_isRecurring =" in _filter_source(), "the card reads chore._isRecurring but never assigns it" def test_lock_uses_a_dedicated_field(self): - """Deliberately NOT chore._isAvailableForChild. That field is also read - by the picture-mode tile, where a matrix-unavailable chore would become - an undisablable tile — including losing undo. Reviving it globally is a - separate question from this fix.""" + """Deliberately NOT a raw availability flag: _isRecurrenceLocked lifts + once the chore is ticked today, so the picture-mode tile keeps its + undo. The tile used to read chore._isAvailableForChild, which nothing + ever set, so a chore in its cooldown stayed tappable there (#771). It + now takes the same locks as the classic row, and the dead flag is gone.""" src = _filter_source() assert "_isRecurrenceLocked =" in src - assert "_isAvailableForChild =" not in src + assert "_isAvailableForChild" not in CARD def test_availability_comes_from_the_backend_matrix(self): """Not recomputed client-side: last_completed isn't exposed and the diff --git a/wiki/Chore-Scheduling.md b/wiki/Chore-Scheduling.md index fde39566..53744472 100644 --- a/wiki/Chore-Scheduling.md +++ b/wiki/Chore-Scheduling.md @@ -256,7 +256,9 @@ While a shared limit still has slots left, every child in the pool sees the chor Set it in the card editor under **First-Come Chores — When Claimed**, or in YAML — see [Dashboard Cards](Dashboard-Cards.md#child-card). -> **Not covered:** timed-task cards and pre-reader tiles don't check this lock, so a claimed first-come chore stays live and tappable in those two views. A tap there is still rejected by the backend as a race loser. +Pre-reader tiles honour it too: a claimed chore is a locked tile that does nothing when tapped. + +> **Not covered:** timed-task cards don't check this lock, so a claimed first-come chore stays live and startable there. A tap is still rejected by the backend as a race loser. > The badge/short label shown in the panel is "First come"; the full mode description reads *"First come — first child to finish wins; it hides for the rest."* diff --git a/wiki/Dashboard-Cards.md b/wiki/Dashboard-Cards.md index 947f9445..b9e843b9 100644 --- a/wiki/Dashboard-Cards.md +++ b/wiki/Dashboard-Cards.md @@ -229,7 +229,7 @@ header_color: "#9b59b6" # leave unset to use the child's own colour | `dim` | The row stays, greyed out and not tappable, labelled with who claimed it | | `show` | The row stays at full opacity, still not tappable, labelled with who claimed it | -A pool-wide dismissal from the admin panel has no specific winner, so every child sees *"Already claimed today"* instead of a name. Honoured by the standard chore row and all six [design styles](Card-Design-Styles.md) — but **not** by timed-task cards or pre-reader tiles, which stay live and tappable regardless (the same gap applies to `recurrence_done_mode`). +A pool-wide dismissal from the admin panel has no specific winner, so every child sees *"Already claimed today"* instead of a name. Honoured by the standard chore row, all six [design styles](Card-Design-Styles.md) and [pre-reader tiles](Pre-Reader-Mode.md) — but **not** by timed-task cards, which stay live and tappable regardless (the same gap applies to `recurrence_done_mode`). --- diff --git a/wiki/Pre-Reader-Mode.md b/wiki/Pre-Reader-Mode.md index 2b9ad87f..b5cce2f5 100644 --- a/wiki/Pre-Reader-Mode.md +++ b/wiki/Pre-Reader-Mode.md @@ -27,6 +27,7 @@ It is a mode of the standard child card, not a separate card, so everything else - **Points are shown as stars, not digits** — one to five, scaled from the chore's value. A four-year-old can count pictures long before they can read "15 points". Set `pre_reader_points: true` to show the number instead — see below. - Tiles are at least **128 px** with a large tap target. - **A done tile stays tappable**, so a mis-tap can be undone, exactly as on the standard card. +- **A chore that can't be done yet is a locked tile.** It is greyed out and does nothing when tapped, for the same reasons the standard row refuses a tap: a recurring chore waiting to reset, a chore for a later part of the day or one whose window has passed, a chore not due today (`due_days_mode: dim`), one waiting on a prerequisite, a first-come chore a sibling already claimed, a filled weekly target, or a set time that isn't open. - **Opt-in.** An existing dashboard never turns itself into pictures, and names stay off unless you ask for them with `pre_reader_labels`. - Works under **all six** [card design styles](Card-Design-Styles.md) — the tiles sit inside whichever design the card is using. Pairing it with **Accessible** is a sensible combination for a child who needs both.