From 4cf3254803973093145e0cb2de50d339dada0e15 Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Wed, 4 Nov 2026 08:21:16 +0000 Subject: [PATCH] fix(kiosk): a chore for a later part of the day isn't tickable yet --- .../taskmate/www/taskmate-kiosk-card.js | 33 ++++++++- tests/frontend/taskmate-kiosk-card.test.cjs | 74 ++++++++++++++++++- wiki/Kiosk-Mode.md | 2 + 3 files changed, 105 insertions(+), 4 deletions(-) diff --git a/custom_components/taskmate/www/taskmate-kiosk-card.js b/custom_components/taskmate/www/taskmate-kiosk-card.js index c767449b..16c0eb6b 100644 --- a/custom_components/taskmate/www/taskmate-kiosk-card.js +++ b/custom_components/taskmate/www/taskmate-kiosk-card.js @@ -36,7 +36,9 @@ * dims or hides chores whose part of the day has passed, as on the child * card. Both default to "show". A dimmed missed chore can't be ticked off * here (the child card refuses the tap too), so dimmed or hidden it leaves - * the count — or a child could never reach all done. + * the count — or a child could never reach all done. A chore for a later + * part of the day isn't open yet (#771): like the child card's locked + * preview it shows "Available at HH:MM" instead of a Done button. * * PINs are set by a parent in the admin panel and checked on the server * (taskmate/kiosk/verify_pin, rate-limited); the card never sees a PIN or its @@ -420,12 +422,17 @@ class TaskMateKioskCard extends LitElement { const joined = team && Array.isArray(team.joined) ? team.joined.map(String) : []; if (!due && !comp) continue; const phone = !!(chore.require_photo || chore.open_ended || chore.task_type === "timed" || chore.quantity_unit); + // A chore for a later part of the day isn't open yet (#771): the child + // card shows it as a locked "Available at …" preview, so the tablet + // does too, instead of a live Done button at any hour. + const opensAt = !comp && due ? this._opensAt(chore) : null; let state; if (due && team && joined.includes(id)) state = "team"; + else if (opensAt) state = "later"; else if (due) state = phone ? "elsewhere" : "todo"; else state = comp.approved ? "done" : "wait"; const missed = !comp && this._isMissed(chore); - rows.push({ chore, state, phone, missed, completion: comp || null, team: team ? { size: Number(team.size) || 0, joined: joined.length } : null }); + rows.push({ chore, state, phone, missed, opensAt, completion: comp || null, team: team ? { size: Number(team.size) || 0, joined: joined.length } : null }); } return rows; } @@ -545,6 +552,26 @@ class TaskMateKioskCard extends LitElement { return !(now >= start && now < Math.min(1440, end + allowance)); } + /** + * "HH:MM" when the chore's part of the day starts later today, else null — + * the child card's locked-preview rule (_isChorePreviewLocked): the chore's + * period comes after the current one. "anytime" chores are always open. + */ + _opensAt(chore) { + const cat = chore.time_category; + if (!cat || cat === "anytime") return null; + const periods = this._timePeriods(); + const own = periods.findIndex(p => p.id === cat); + if (own < 0) return null; + const now = this._nowMinutes(); + // Before the first period starts, it's still the first one (as on the child card). + let current = 0; + periods.forEach((p, i) => { if (now >= p.start) current = i; }); + if (own <= current) return null; + const start = periods[own].start; + return `${String(Math.floor(start / 60)).padStart(2, "0")}:${String(start % 60).padStart(2, "0")}`; + } + _pointsFor(chore, childId) { const base = chore.effective_points ?? chore.points ?? 0; const m = this._boostMult(chore, childId); @@ -1813,6 +1840,8 @@ class TaskMateKioskCard extends LitElement { } else if (state === "todo") { action = html``; + } else if (state === "later" && row.opensAt) { + action = html`
${this._t("child.chore_locked_until", { time: window.__taskmate_dates && window.__taskmate_dates.clock ? window.__taskmate_dates.clock(this.hass, row.opensAt) : row.opensAt })}
`; } else if (state === "later") { action = html`
${this._t("child.set_time_opens", { time: row.setTime.entry.opens })}
`; } else if (state === "done") { diff --git a/tests/frontend/taskmate-kiosk-card.test.cjs b/tests/frontend/taskmate-kiosk-card.test.cjs index ca1222b4..fb7f2a66 100644 --- a/tests/frontend/taskmate-kiosk-card.test.cjs +++ b/tests/frontend/taskmate-kiosk-card.test.cjs @@ -688,7 +688,9 @@ const counts = (card) => { const { done, total } = card._progress(card._entry("v test("by default a missed morning chore still shows with its Done button and counts", async () => { const card = await timedView(); - assert.equal(doneButtons(card), 3); + // Bed (missed, shown) and bins (anytime); the evening chore isn't open + // until 17:00 (#771). + assert.equal(doneButtons(card), 2); assert.deepEqual(counts(card), [0, 3]); assert.doesNotMatch(view(card).markup, /km-row missed/); }); @@ -698,7 +700,7 @@ test("elapsed_time_mode: dim greys a missed chore out, refuses the tap and leave const { markup } = view(card); assert.equal((markup.match(/km-row missed/g) || []).length, 1); assert.match(markup, new RegExp(localize("kiosk.missed"))); - assert.equal(doneButtons(card), 2, "only the evening and anytime chores can be ticked off"); + assert.equal(doneButtons(card), 1, "only the anytime chore can be ticked off: evening opens at 17:00 (#771)"); assert.deepEqual(counts(card), [0, 2]); }); @@ -738,6 +740,74 @@ test("missed follows the family's own time periods when the sensor has them", as assert.equal(card._isMissed(chore("homework", { time_category: "school" })), false); }); +// ── a later part of the day isn't open yet (#771) ───────────────────────── +// The child card shows a chore for a later period as a locked "Available at" +// preview; the tablet used to offer a live Done button for it at any hour. + +test("a chore for a later part of the day shows when it opens, with no Done button", async () => { + const card = await timedView({ now: 8 * 60, due: ["dishes"] }); + const { markup, controls } = view(card); + assert.match(markup, /km-row later/); + assert.match(markup, new RegExp(localize("child.chore_locked_until", { time: "17:00" }))); + assert.equal(controls.filter((c) => c.attrs.includes('class="km-done"')).length, 0, "nothing to tap"); + assert.deepEqual(counts(card), [0, 1], "still owed today, so it stays in the count"); +}); + +test("the same chore gets its Done button once its part of the day starts", async () => { + const card = await timedView({ now: 17 * 60 + 5, due: ["dishes"] }); + const done = view(card).controls.find((c) => c.attrs.includes('class="km-done"')); + assert.ok(done, "Done is offered from 17:00"); + await done.click(); + assert.equal(card.calls.at(-1).service, "complete_chore"); +}); + +test("a later-period chore's opening time goes through HA's clock helper", async () => { + const card = await timedView({ now: 8 * 60, due: ["dishes"] }); + cardWindow.__taskmate_dates = { clock: (_h, t) => `${t} (HA)` }; + try { + assert.match(view(card).markup, new RegExp(localize("child.chore_locked_until", { time: "17:00 \\(HA\\)" }))); + } finally { + delete cardWindow.__taskmate_dates; + } +}); + +test("opensAt follows the child card's preview rule", async () => { + const card = await timedView({ now: 8 * 60 }); + assert.equal(card._opensAt(chore("x", { time_category: "evening" })), "17:00"); + assert.equal(card._opensAt(chore("x", { time_category: "morning" })), null, "its own period is open"); + assert.equal(card._opensAt(chore("x", { time_category: "anytime" })), null); + assert.equal(card._opensAt(chore("x")), null, "no time of day"); + card._nowMinutes = () => 3 * 60; + assert.equal(card._opensAt(chore("x", { time_category: "morning" })), null, "before 06:00 is still the first period"); + assert.equal(card._opensAt(chore("x", { time_category: "afternoon" })), "12:00"); + card.hass.states[ENTITY].attributes.time_periods = [ + { id: "morning", start: "05:00", end: "09:00" }, + { id: "school", start: "09:30", end: "15:00" }, + ]; + card._nowMinutes = () => 7 * 60; + assert.equal(card._opensAt(chore("x", { time_category: "school" })), "09:30", "the family's own periods"); +}); + +test("a later chore already done keeps its done state", async () => { + const card = await timedView({ + now: 8 * 60, + due: [], + completions: [{ completion_id: "c1", chore_id: "dishes", child_id: "vaiha", approved: true, completed_at: new Date().toISOString() }], + }); + const { markup } = view(card); + assert.match(markup, /km-row done/); + assert.doesNotMatch(markup, /km-row later/); +}); + +for (const design of DESIGNS) { + test(`${design}: a later-period chore has no Done button on the kiosk`, async () => { + const card = await timedView({ now: 8 * 60, due: ["dishes"], config: { card_design: design } }); + const { markup, controls } = view(card); + assert.match(markup, new RegExp(localize("child.chore_locked_until", { time: "17:00" }))); + assert.equal(controls.filter((c) => c.attrs.includes('class="km-done"')).length, 0); + }); +} + test("a completed chore is never missed: it keeps its done state", async () => { const card = await timedView({ config: { elapsed_time_mode: "hide" }, diff --git a/wiki/Kiosk-Mode.md b/wiki/Kiosk-Mode.md index 67d4d4a0..91140785 100644 --- a/wiki/Kiosk-Mode.md +++ b/wiki/Kiosk-Mode.md @@ -146,6 +146,8 @@ A morning, afternoon, evening or night chore that wasn't done once that part of A dimmed or hidden missed chore leaves the day's total, so a child can still reach "all done". +**Chores for a later part of the day** aren't open yet. As on the child card, an evening chore seen in the morning shows **Available at 17:00** (the start of that part of the day) instead of a **Done** button, and gets its button once the period starts. It stays in the day's total, because it's still owed today. + ```yaml type: custom:taskmate-kiosk-card entity: sensor.taskmate_overview