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
87 changes: 82 additions & 5 deletions custom_components/taskmate/www/taskmate-child-card.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 || {};
Expand All @@ -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;
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -4798,14 +4871,18 @@ class TaskMateChildCard extends LitElement {
};

return html`
<div class="timed-chore-card ${state} ${isLoading ? 'loading' : ''}">
<div class="timed-chore-card ${state} ${isLoading ? 'loading' : ''} ${timedDimmed ? 'tm-timed-locked' : ''}"
title="${lockReason}">
<div class="timed-top-row">
<div class="chore-number-wrapper">
${this._choreNumberBadge(chore, colorClass, choreNumber)}
</div>
<div class="chore-details">
<div class="chore-name">${chore.name}</div>
${state === 'idle' ? this._renderRejectNote(chore, child, 'tm-reject-note') : ''}
${lockReason ? html`<div class="tm-timed-lock-reason">
<ha-icon icon="${timedLocks.isLockedPreview || timedLocks.setTimeLocked ? 'mdi:lock-clock' : 'mdi:lock-outline'}"></ha-icon>
${lockReason}</div>` : ''}
</div>
<div class="timed-rate">${rateLabel}</div>
</div>
Expand All @@ -4824,7 +4901,7 @@ class TaskMateChildCard extends LitElement {

<div class="timer-actions">
${state === 'idle' ? html`
<button class="timer-btn start" @click="${handleStart}" ?disabled="${isLoading || depBlocked}">
<button class="timer-btn start" @click="${handleStart}" ?disabled="${isLoading || startLocked}">
<span class="btn-icon">&#9654;</span>
${this._t('child.start') || 'START'}
</button>
Expand All @@ -4840,7 +4917,7 @@ class TaskMateChildCard extends LitElement {
</button>
` : ''}
${state === 'paused' ? html`
<button class="timer-btn resume" @click="${handleStart}" ?disabled="${isLoading || depBlocked}">
<button class="timer-btn resume" @click="${handleStart}" ?disabled="${isLoading || startLocked}">
<span class="btn-icon">&#9654;</span>
${this._t('child.resume') || 'RESUME'}
</button>
Expand Down
243 changes: 243 additions & 0 deletions tests/frontend/taskmate-child-card-timed-locks.test.cjs
Original file line number Diff line number Diff line change
@@ -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"));
});
3 changes: 2 additions & 1 deletion tests/test_chore_dependencies_frontend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading
Loading