From e8acb25f101f792b63c627833e825557208823ae Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Wed, 4 Nov 2026 08:00:45 +0000 Subject: [PATCH] fix(cards): timed chores can't be started while the chore is locked --- .../taskmate/www/taskmate-child-card.js | 87 ++++++- .../taskmate-child-card-timed-locks.test.cjs | 243 ++++++++++++++++++ tests/test_chore_dependencies_frontend.py | 3 +- wiki/Chore-Scheduling.md | 2 +- wiki/Dashboard-Cards.md | 2 +- 5 files changed, 329 insertions(+), 8 deletions(-) create mode 100644 tests/frontend/taskmate-child-card-timed-locks.test.cjs diff --git a/custom_components/taskmate/www/taskmate-child-card.js b/custom_components/taskmate/www/taskmate-child-card.js index 063e1908..642b8456 100644 --- a/custom_components/taskmate/www/taskmate-child-card.js +++ b/custom_components/taskmate/www/taskmate-child-card.js @@ -1914,6 +1914,20 @@ class TaskMateChildCard extends LitElement { box-shadow: 0 6px 16px rgba(231, 76, 60, 0.5); transform: scale(1.02); } + /* A locked timed chore (#771): start/resume can't be pressed, so they + must not look pressable. After the colour rules so it wins. */ + .timer-btn:disabled, .timer-btn:disabled:hover, .timer-btn:disabled:active { + opacity: 0.45; cursor: default; box-shadow: none; transform: none; + } + /* The whole card dims like a locked standard row (#771); opacity and + filter only, so every design's own colours still show through. */ + .timed-chore-card.tm-timed-locked { opacity: 0.55; filter: grayscale(0.5); } + .tm-timed-lock-reason { + display: flex; align-items: center; gap: 4px; margin-top: 3px; + font-size: 0.78rem; font-weight: 600; + color: var(--tmd-dim, var(--secondary-text-color)); + } + .tm-timed-lock-reason ha-icon { --mdc-icon-size: 14px; } .btn-icon { font-size: 1.2rem; } .daily-cap-bar { display: flex; @@ -4707,6 +4721,55 @@ class TaskMateChildCard extends LitElement { `; } + /** + * Whether a locked row is drawn dimmed: the same mode split the standard + * rows use — recurrence, first-come and dependency locks only dim under + * their "dim" mode (under "show" they look normal but still refuse the tap); + * every other lock always dims. + */ + _lockDimmed(locks) { + return locks.notDueToday || locks.weeklyDone || locks.timeElapsed || locks.isLockedPreview || locks.setTimeLocked + || (locks.recurrenceLocked && (this.config.recurrence_done_mode || 'dim') === 'dim') + || (locks.firstComeLocked && (this.config.first_come_claimed_mode || 'hide') === 'dim') + || (locks.depBlocked && (this.config.dependency_mode || 'hide') === 'dim'); + } + + /** Why a locked chore can't be started now, in the words the standard rows use. */ + _lockReason(chore, locks) { + // Stored "17:00" times in HA's time format ("05:00 PM" on a 12-hour + // profile) once the shared clock helper is loaded (#771). + const clk = (t) => (window.__taskmate_dates && window.__taskmate_dates.clock ? window.__taskmate_dates.clock(this.hass, t) : t); + if (locks.isLockedPreview) { + if (chore._setTime) return this._t('child.chore_locked_until', { time: clk(chore._setTime.start) }); + const start = this._getPeriodHours(chore.time_category)?.[0]; + return start === undefined || start === null + ? this._t('child.chore_locked_until_generic') + : this._t('child.chore_locked_until', { time: clk(`${String(Math.floor(start)).padStart(2, '0')}:${String(Math.round((start % 1) * 60)).padStart(2, '0')}`) }); + } + if (locks.depBlocked) return this._t('child.blocked_by_dependency', { chores: (chore._dependencyNames || []).join(', ') }); + if (locks.notDueToday) return this._t('child.not_scheduled_for_today'); + if (locks.setTimeLocked) { + return chore._setTimeState === 'missed' + ? this._t('child.set_time_missed') + : chore._setTimeEntry && chore._setTimeEntry.opens + ? this._t('child.set_time_opens', { time: clk(chore._setTimeEntry.opens) }) + : ''; + } + if (locks.timeElapsed) return this._t('child.time_has_passed'); + if (locks.firstComeLocked) { + return chore._firstComeCompletedByName + ? this._t('child.claimed_by_another', { name: chore._firstComeCompletedByName }) + : this._t('child.claimed_by_another_generic'); + } + if (locks.weeklyDone) return this._t('child.weekly_target_done'); + // The translated cadence once fix/recurrence-label-i18n is in (#771). + if (locks.recurrenceLocked) { + if (this._recurrenceLabel) return this._recurrenceLabel(chore); + return chore.recurrence ? chore.recurrence.replace(/_/g, ' ') : this._t('child.recurring'); + } + return ''; + } + _renderTimedChoreCard(chore, child, pointsIcon, todaysCompletions, choreIndex) { const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || this.hass?.states?.[this.config.entity]?.attributes || {}; @@ -4719,7 +4782,17 @@ class TaskMateChildCard extends LitElement { const choreNumber = choreIndex + 1; // A blocked timed chore must not be startable under dim/show (#793). // Pause/stop stay live so a session already running can still be ended. - const depBlocked = !!chore._isDependencyBlocked; + // Start/resume obey every lock the classic row does (#771): cooldown, + // first-come claim, a later period, a passed window, not due today, a + // filled weekly target, a prerequisite, a set time that isn't open. + const timedLocks = this._choreLocks(chore, this._isChoreDone(chore, child, todaysCompletions || []).done); + const startLocked = timedLocks.locked; + // A locked chore with no session running is dimmed and says why, as the + // standard rows do (#771). A running or paused session stays at full + // strength: Pause / Done are still live on it. + const lockedIdle = startLocked && state === 'idle'; + const timedDimmed = lockedIdle && this._lockDimmed(timedLocks); + const lockReason = startLocked ? this._lockReason(chore, timedLocks) : ''; // Calculate elapsed seconds let elapsed = session ? (session.total_seconds_today || 0) : 0; @@ -4752,7 +4825,7 @@ class TaskMateChildCard extends LitElement { const nearCap = maxMin > 0 && capPct >= 85; const handleStart = (e) => { - if (depBlocked) { e.stopPropagation(); return; } + if (startLocked) { e.stopPropagation(); return; } e.stopPropagation(); if (isLoading) return; this._loading = { ...this._loading, [`timed_${chore.id}`]: true }; @@ -4798,7 +4871,8 @@ class TaskMateChildCard extends LitElement { }; return html` -
+
${this._choreNumberBadge(chore, colorClass, choreNumber)} @@ -4806,6 +4880,9 @@ class TaskMateChildCard extends LitElement {
${chore.name}
${state === 'idle' ? this._renderRejectNote(chore, child, 'tm-reject-note') : ''} + ${lockReason ? html`
+ + ${lockReason}
` : ''}
${rateLabel}
@@ -4824,7 +4901,7 @@ class TaskMateChildCard extends LitElement {
${state === 'idle' ? html` - @@ -4840,7 +4917,7 @@ class TaskMateChildCard extends LitElement { ` : ''} ${state === 'paused' ? html` - diff --git a/tests/frontend/taskmate-child-card-timed-locks.test.cjs b/tests/frontend/taskmate-child-card-timed-locks.test.cjs new file mode 100644 index 00000000..f1dcebb5 --- /dev/null +++ b/tests/frontend/taskmate-child-card-timed-locks.test.cjs @@ -0,0 +1,243 @@ +"use strict"; +const assert = require("node:assert/strict"); +const { test } = require("node:test"); + +const { loadCard, render, localize } = require("./harness.cjs"); + +// A timed chore's Start / Resume must obey every lock the classic row does +// (#771). The timed card only refused a prerequisite, so a timed chore in its +// cooldown, claimed first by a sibling, before its period, past its window, +// not due today, at its weekly target or at a closed set time could still be +// started. Pause / Done stay live so a running session can always be ended. +// Timed chores render through the same card in every design. + +// 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, + task_type: "timed", + timed_rate_minutes: 5, + timed_rate_points: 10, + time_category: "anytime", + assigned_to: [], + due_days: [], + schedule_mode: "specific_days", + daily_limit: 1, + ...extra, + }; +} + +function makeCard({ chores, completions = [], availability = {}, weekly = {}, config = {}, design, session = null }) { + const card = new ChildCard(); + card.config = { entity: ENTITY, child_id: "kid1", card_design: design, ...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", + active_timed_sessions: session ? [{ chore_id: "zz", child_id: "kid1", state: session, total_seconds_today: 60 }] : [], + }, + }, + }, + }; + card._loading = {}; + card._optimisticCompletions = {}; + card.calls = []; + card.hass.callService = (domain, service, data) => { + card.calls.push(service); + return Promise.resolve(); + }; + return card; +} + +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", task_type: undefined })], + 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 } }, + }, +]; + +const btn = (view, cls) => view.controls.find((c) => c.tag === "button" && new RegExp(`timer-btn ${cls}\\b`).test(c.attrs)); + +for (const design of DESIGNS) { + for (const { name, args } of REASONS) { + test(`${design}: a timed chore ${name} can't be started`, async () => { + const card = makeCard({ ...args, design }); + const view = render(card.render()); + assert.match(view.markup, /timed-chore-card/, "the timed card is rendered"); + const start = btn(view, "start"); + assert.ok(start, "the Start button is there"); + assert.equal(start.disabled, true, "Start must be disabled"); + await assert.rejects(start.click(), /disabled/); + assert.deepEqual(card.calls, []); + }); + + test(`${design}: a paused timed chore ${name} can't be resumed, but can be finished`, async () => { + const card = makeCard({ ...args, design, session: "paused" }); + const view = render(card.render()); + assert.equal(btn(view, "resume").disabled, true, "Resume must be disabled"); + const stop = btn(view, "stop"); + assert.equal(stop.disabled, false, "Done stays live so the session can be ended"); + await stop.click(); + assert.deepEqual(card.calls, ["stop_timed_task"]); + }); + } + + test(`${design}: a running timed chore can always be paused or finished`, async () => { + const card = makeCard({ ...REASONS[3].args, design, session: "running" }); + const view = render(card.render()); + assert.equal(btn(view, "pause").disabled, false); + assert.equal(btn(view, "stop").disabled, false); + }); + + test(`${design}: an open timed chore still starts`, async () => { + const card = makeCard({ chores: [chore({ time_category: "morning" })], config: { debug_time_period: "morning" }, design }); + const start = btn(render(card.render()), "start"); + assert.equal(start.disabled, false); + await start.click(); + assert.deepEqual(card.calls, ["start_timed_task"]); + }); +} + +test("the timed card takes its locks from _choreLocks", () => { + const src = require("node:fs").readFileSync( + require("node:path").join(__dirname, "../../custom_components/taskmate/www/taskmate-child-card.js"), + "utf8", + ); + const start = src.indexOf("\n _renderTimedChoreCard("); + assert.match(src.slice(start, src.indexOf("\n _renderBonusSubtasks(", start)), /this\._choreLocks\(/); +}); + +// ── a locked timed chore says why, and dims like a locked row ───────────── +// Greying Start alone left the card looking live with no reason (#771). + +const REASON_TEXT = { + "before its time period (locked preview)": [localize("child.chore_locked_until", { time: "17:00" }), true], + "past its time window (elapsed, dim)": [localize("child.time_has_passed"), true], + "not due today (due_days_mode dim)": [localize("child.not_scheduled_for_today"), true], + "in its recurrence cooldown": ["every 3 days", true], + "in its recurrence cooldown (recurrence_done_mode show)": ["every 3 days", false], + "waiting on a prerequisite (dependency_mode dim)": [localize("child.blocked_by_dependency", { chores: "Teeth" }), true], + "claimed first by a sibling (first-come)": [localize("child.claimed_by_another", { name: "Leo" }), true], + "already at its weekly target": [localize("child.weekly_target_done"), true], +}; + +for (const design of DESIGNS) { + for (const { name, args } of REASONS) { + const [text, dims] = REASON_TEXT[name]; + test(`${design}: a timed chore ${name} says why${dims ? " and is dimmed" : " (not dimmed under show)"}`, () => { + const { markup } = render(makeCard({ ...args, design }).render()); + const card = markup.slice(markup.indexOf('class="timed-chore-card')); + assert.match(card, /tm-timed-lock-reason/); + assert.ok(card.includes(text), `expected "${text}"`); + assert.equal(/class="timed-chore-card[^"]*tm-timed-locked/.test(markup), dims); + }); + } + + test(`${design}: a set time that isn't open says when it opens`, () => { + const card = makeCard({ chores: [chore()], design }); + const filter = card._filterAndSortChores.bind(card); + card._filterAndSortChores = (...a) => + filter(...a).map((c) => Object.assign(c, { _setTimeLocked: true, _setTimeState: "later", _setTimeEntry: { opens: "18:30" } })); + const { markup } = render(card.render()); + assert.ok(markup.includes(localize("child.set_time_opens", { time: "18:30" }))); + assert.match(markup, /tm-timed-locked/); + }); + + test(`${design}: a running session on a locked chore stays undimmed and live`, () => { + const { markup } = render(makeCard({ ...REASONS[3].args, design, session: "running" }).render()); + assert.doesNotMatch(markup, /tm-timed-locked/); + }); + + test(`${design}: an open timed chore shows no lock reason`, () => { + const { markup } = render(makeCard({ chores: [chore({ time_category: "morning" })], config: { debug_time_period: "morning" }, design }).render()); + assert.doesNotMatch(markup, /tm-timed-lock-reason|tm-timed-locked/); + }); +} + +// The opening time follows HA's clock once the shared helper is loaded (#771). +test("a locked timed chore's opening time goes through HA's clock helper", () => { + const seen = []; + const { get: get2 } = loadCard("taskmate-child-card.js", { + window: { + __taskmate_badge_id: (b) => b.badge_id, + __taskmate_dates: { args: (h, o) => [undefined, o], clock: (_h, t) => { seen.push(t); return `${t} (HA)`; } }, + }, + }); + const card = new (get2("taskmate-child-card"))(); + Object.assign(card, makeCard({ chores: [chore({ time_category: "evening" })], config: { debug_time_period: "morning" }, design: "classic" })); + card.config = { ...card.config, card_design: "classic" }; + const { markup } = render(card.render()); + assert.ok(markup.includes(localize("child.chore_locked_until", { time: "17:00 (HA)" }))); + assert.ok(seen.includes("17:00")); +}); diff --git a/tests/test_chore_dependencies_frontend.py b/tests/test_chore_dependencies_frontend.py index 915b4e45..ee67ec2f 100644 --- a/tests/test_chore_dependencies_frontend.py +++ b/tests/test_chore_dependencies_frontend.py @@ -98,7 +98,8 @@ def test_blocked_chore_is_not_completable(self): def test_timed_chores_are_gated_too(self): start = CARD.index(" _renderTimedChoreCard(chore, child, pointsIcon") timed = CARD[start : CARD.index("\n _renderBonusSubtasks(", start)] - assert "_isDependencyBlocked" in timed + # Through the same lock set the classic row uses (#771). + assert "this._choreLocks(" in timed def test_picture_mode_is_gated_too(self): start = CARD.index(" _renderPreReaderTile(chore, child, pointsIcon") diff --git a/wiki/Chore-Scheduling.md b/wiki/Chore-Scheduling.md index 53744472..af893c3e 100644 --- a/wiki/Chore-Scheduling.md +++ b/wiki/Chore-Scheduling.md @@ -258,7 +258,7 @@ Set it in the card editor under **First-Come Chores — When Claimed**, or in YA 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. +Timed-task cards honour it too: **Start** and **Resume** are disabled while the chore is claimed, and a session already running can still be paused or finished. > 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 b9e843b9..e3bafb81 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, 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`). +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, timed-task cards (Start and Resume are disabled; a running session can still be paused or finished), [pre-reader tiles](Pre-Reader-Mode.md) and all six [design styles](Card-Design-Styles.md). The same applies to `recurrence_done_mode`. ---