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
33 changes: 31 additions & 2 deletions custom_components/taskmate/www/taskmate-kiosk-card.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1813,6 +1840,8 @@ class TaskMateKioskCard extends LitElement {
} else if (state === "todo") {
action = html`<button class="km-done" ?disabled=${!!this._busy} @click=${() => this._complete(child, chore)}>
<ha-icon icon="mdi:check-bold"></ha-icon> ${this._t("child.done")}</button>`;
} else if (state === "later" && row.opensAt) {
action = html`<div class="km-done is-elsewhere"><ha-icon icon="mdi:lock-clock"></ha-icon><span>${this._t("child.chore_locked_until", { time: window.__taskmate_dates && window.__taskmate_dates.clock ? window.__taskmate_dates.clock(this.hass, row.opensAt) : row.opensAt })}</span></div>`;
} else if (state === "later") {
action = html`<div class="km-done is-elsewhere"><ha-icon icon="mdi:lock-clock"></ha-icon><span>${this._t("child.set_time_opens", { time: row.setTime.entry.opens })}</span></div>`;
} else if (state === "done") {
Expand Down
74 changes: 72 additions & 2 deletions tests/frontend/taskmate-kiosk-card.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
Expand All @@ -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]);
});

Expand Down Expand Up @@ -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" },
Expand Down
2 changes: 2 additions & 0 deletions wiki/Kiosk-Mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading