Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions custom_components/taskmate/www/taskmate-child-card.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
}}
>
<div class="pre-tile-icon">
${v.kind === "image"
Expand Down
213 changes: 213 additions & 0 deletions tests/frontend/taskmate-child-card-prereader-locks.test.cjs
Original file line number Diff line number Diff line change
@@ -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/);
});
5 changes: 3 additions & 2 deletions tests/test_chore_dependencies_frontend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
11 changes: 6 additions & 5 deletions tests/test_recurrence_availability.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion wiki/Chore-Scheduling.md
Original file line number Diff line number Diff line change
Expand Up @@ -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."*

Expand Down
2 changes: 1 addition & 1 deletion wiki/Dashboard-Cards.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).

---

Expand Down
1 change: 1 addition & 0 deletions wiki/Pre-Reader-Mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading