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`
`;
} 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