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.