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
5 changes: 4 additions & 1 deletion custom_components/taskmate/coord_rewards.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,10 +101,12 @@ async def async_add_reward(
expires_at: str | None = None,
refresh: bool = True,
template_id: str = "",
screen_minutes: int = 0,
) -> Reward:
"""Add a new reward. ``refresh=False`` defers the refresh to a batch.

``template_id`` names the 📚 library entry it came from, if any.
``template_id`` names the 📚 library entry it came from, if any;
``screen_minutes`` makes it a screen-time reward.
"""
reward = Reward(
name=name,
Expand All @@ -117,6 +119,7 @@ async def async_add_reward(
quantity=quantity,
expires_at=expires_at,
template_id=clean_template_id(template_id),
screen_minutes=max(0, int(screen_minutes or 0)),
)
self.storage.add_reward(reward)
await self.storage.async_save()
Expand Down
6 changes: 6 additions & 0 deletions custom_components/taskmate/coord_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from .const import AGE_GROUPS
from .coord_birthdays import normalize_birthday
from .models import clean_template_id
from .setup_catalogue import library_screen_minutes

_LOGGER = logging.getLogger(__name__)

Expand Down Expand Up @@ -200,6 +201,11 @@ def resolve(who: str) -> str:
icon=spec.get("icon") or "mdi:gift",
refresh=False,
template_id=spec.get("template_id", ""),
# A library screen-time reward fills the bank only while the
# bank is on; otherwise it stays a reward a parent hands over.
screen_minutes=(
library_screen_minutes(spec.get("template_id", "")) if self.screen_time_enabled() else 0
),
)

await self.async_refresh()
Expand Down
19 changes: 18 additions & 1 deletion custom_components/taskmate/setup_catalogue.py
Original file line number Diff line number Diff line change
Expand Up @@ -376,7 +376,16 @@ def _top_pick(s: dict) -> dict:
# Starter rewards in three tiers. ``picked`` ones are ticked when the wizard opens.
REWARD_TIERS: tuple[str, ...] = ("small", "saving", "big")
STARTER_REWARDS: list[dict] = [
{"id": "screen_time", "tier": "small", "cost": 15, "icon": "mdi:television-play", "picked": True},
# ``screen_minutes``: what the reward adds to the screen-time bank, when the
# bank is on.
{
"id": "screen_time",
"tier": "small",
"cost": 15,
"icon": "mdi:television-play",
"picked": True,
"screen_minutes": 15,
},
{"id": "pick_pudding", "tier": "small", "cost": 10, "icon": "mdi:cupcake", "picked": False},
{"id": "car_music", "tier": "small", "cost": 10, "icon": "mdi:music", "picked": False},
{"id": "choose_dinner", "tier": "saving", "cost": 40, "icon": "mdi:pizza", "picked": True},
Expand Down Expand Up @@ -481,3 +490,11 @@ def library_catalogue() -> dict[str, Any]:
for t in BUILT_IN_TEMPLATES
],
}


def library_screen_minutes(template_id: str) -> int:
"""Minutes a library reward adds to the screen-time bank; 0 for most."""
for r in (*STARTER_REWARDS, *LIBRARY_EXTRA_REWARDS):
if r["id"] == template_id:
return int(r.get("screen_minutes", 0) or 0)
return 0
38 changes: 29 additions & 9 deletions custom_components/taskmate/www/taskmate-kiosk-card.js
Original file line number Diff line number Diff line change
Expand Up @@ -469,9 +469,25 @@ class TaskMateKioskCard extends LitElement {
const all = this._rows(child).filter(r => !(missedMode === "hide" && r.missed));
const counted = all.filter(r => !r.phone && !(missedMode === "dim" && r.missed));
const shown = r => !(hideDone && isDone(r));
// A chore with set times counts once, in either layout, and only once
// every time is done (waiting for a grown-up counts as done).
let done = 0;
let total = 0;
const setTimeChores = new Set();
for (const r of counted) {
if (r.setTime) {
if (setTimeChores.has(r.chore._baseId)) continue;
setTimeChores.add(r.chore._baseId);
total += 1;
if (r.setTime.all.every(e => e.state === "done" || e.state === "pending")) done += 1;
continue;
}
total += 1;
if (isDone(r)) done += 1;
}
return {
done: counted.filter(isDone).length,
total: counted.length,
done,
total,
rows: all.filter(r => !r.phone && shown(r)),
phoneRows: all.filter(r => r.phone && shown(r)),
};
Expand Down Expand Up @@ -541,16 +557,23 @@ class TaskMateKioskCard extends LitElement {
// not a parent action: choose_avatar runs the same linked-child check as
// complete_chore.

_headAvatar(child, tone) {
// The season badge wraps only the face: the picker must position against
// .km-head, not against a relatively positioned wrapper round the face.
_headAvatar(child, tone, season) {
const opts = child.avatar_options || [];
const canChange = this.config.allow_avatar_change !== false && opts.filter(o => o.unlocked).length > 1;
if (!canChange) return this._face(child, tone, "head");
const face = html`
<div class="km-face-wrap">
${this._face(child, tone, "head")}
${season ? html`<span class="tm-season-badge" aria-hidden="true">${season.emoji}</span>` : ""}
</div>`;
if (!canChange) return face;
return html`
<div class="km-av">
<button class="km-av-btn" title="${this._t("child.avatar_change")}" aria-label="${this._t("child.avatar_change")}"
aria-expanded="${this._avatarPicker ? "true" : "false"}"
@click=${() => { this._avatarPicker = !this._avatarPicker; }}>
${this._face(child, tone, "head")}
${face}
<span class="km-av-dot"><ha-icon icon="mdi:pencil"></ha-icon></span>
</button>
${this._avatarPicker ? html`
Expand Down Expand Up @@ -1589,10 +1612,7 @@ class TaskMateKioskCard extends LitElement {
return html`
<div class="km-child" style="--kc:${tone}">
<div class="km-head" style="${season ? `background:${season.grad}` : ""}">
<div class="km-face-wrap">
${this._headAvatar(child, tone)}
${season ? html`<span class="tm-season-badge" aria-hidden="true">${season.emoji}</span>` : ""}
</div>
${this._headAvatar(child, tone, season)}
<div class="km-grow">
<div class="km-head-name">${child.name}</div>
${showPoints && child.level ? html`
Expand Down
54 changes: 37 additions & 17 deletions custom_components/taskmate/www/taskmate-panel.js
Original file line number Diff line number Diff line change
Expand Up @@ -1929,6 +1929,8 @@ class TaskMatePanel extends HTMLElement {
const chore = c && choreById[c.chore_id];
const bounty = c && c.bounty_id && (this._state.bounties || []).find(b => b.id === c.bounty_id);
name = (chore && chore.name) || (bounty && bounty.title) || this._t("panel.activity_deleted_chore");
const t = chore && c.time_id && (chore.times || []).find(x => x.id === c.time_id);
if (t) name = `${chore.name} (${t.name || t.start})`;
} else {
const c = (this._state.pending_reward_claims || []).find(x => x.id === id);
const reward = c && rewardById[c.reward_id];
Expand Down Expand Up @@ -5303,6 +5305,10 @@ class TaskMatePanel extends HTMLElement {
if (c.bonus_subtask_id && chore) {
const sub = (chore.bonus_subtasks || []).find(b => b.id === c.bonus_subtask_id);
if (sub) { choreName = `${chore.name} › ${sub.name}`; chorePoints = sub.points; }
} else if (c.time_id && chore) {
// Set times: name the time, as notifications and the sensor do.
const t = (chore.times || []).find(x => x.id === c.time_id);
if (t) { choreName = `${chore.name} (${t.name || t.start})`; chorePoints = Number(t.points) || 0; }
} else if (chore && chore.task_type === "timed" && (c.timed_duration_seconds || 0) > 0) {
// Timed chores accrue points by elapsed-time rate, so the actual
// earned points can be lower than chore.points. Mirror sensor.py.
Expand Down Expand Up @@ -5854,7 +5860,7 @@ class TaskMatePanel extends HTMLElement {
</div>
<div class="tm-setting-row">
<div class="tm-setting-label">${this._t("panel.screen_time_jar_opens")}<small>${this._t("panel.screen_time_jar_opens_hint")}</small></div>
<div class="tm-st-opens">
<div class="tm-scr-opens">
<select class="tm-select" data-setting="screen_time_jar_open_day">
${[0, 1, 2, 3, 4, 5, 6].map(d => `<option value="${d}" ${d === openDay ? "selected" : ""}>${this._esc(this._weekdayName(d))}</option>`).join("")}
</select>
Expand All @@ -5866,13 +5872,13 @@ class TaskMatePanel extends HTMLElement {
const field = `_list:screen_time_devices:${c.id}`;
const jar = Number(row.jar) || 0;
return `
<div class="tm-setting-row tm-setting-top tm-st-kid">
<div class="tm-setting-label"><span class="tm-st-name">${this._childAvatar(c)} ${this._esc(c.name)}</span></div>
<div class="tm-setting-row tm-setting-top tm-scr-kid">
<div class="tm-setting-label"><span class="tm-scr-name">${this._childAvatar(c)} ${this._esc(c.name)}</span></div>
<div class="tm-list-body">
<div class="tm-st-head">
<span class="tm-st-balance ${row.active ? "live" : ""}">📺 ${this._esc(this._screenMinutes(row.minutes))}${row.active ? ` · ${this._t("panel.screen_time_watching")}` : ""}${Number(row.allowance) > 0 ? ` · ${this._t("panel.screen_time_allowance_today", { time: this._esc(this._screenMinutes(row.allowance)) })}` : ""}</span>
${s.screen_time_jar_enabled || jar ? `<span class="tm-st-jar">🫙 ${this._esc(this._screenMinutes(jar))}${jar ? ` <button type="button" class="tm-btn tm-btn-sm" data-act="st-jar-open" data-child="${this._esc(c.id)}">${this._t("panel.screen_time_jar_open_now")}</button>` : ""}</span>` : ""}
<span class="tm-st-adjust">
<div class="tm-scr-head">
<span class="tm-scr-balance ${row.active ? "live" : ""}">📺 ${this._esc(this._screenMinutes(row.minutes))}${row.active ? ` · ${this._t("panel.screen_time_watching")}` : ""}${Number(row.allowance) > 0 ? ` · ${this._t("panel.screen_time_allowance_today", { time: this._esc(this._screenMinutes(row.allowance)) })}` : ""}</span>
${s.screen_time_jar_enabled || jar ? `<span class="tm-scr-jar">🫙 ${this._esc(this._screenMinutes(jar))}${jar ? ` <button type="button" class="tm-btn tm-btn-sm" data-act="st-jar-open" data-child="${this._esc(c.id)}">${this._t("panel.screen_time_jar_open_now")}</button>` : ""}</span>` : ""}
<span class="tm-scr-adjust">
${[-15, 15, 30].map(m => `<button type="button" class="tm-btn tm-btn-sm" data-act="st-adjust" data-child="${this._esc(c.id)}" data-minutes="${m}">${m > 0 ? "+" : "−"}${Math.abs(m)}</button>`).join("")}
</span>
</div>
Expand Down Expand Up @@ -5965,7 +5971,7 @@ class TaskMatePanel extends HTMLElement {
</div>
${kids.length < 2 ? `<span class="tm-field-hint">${this._t("panel.sibling_check_need_two")}</span>` : kids.map(checker => `
<div class="tm-setting-row tm-setting-top">
<div class="tm-setting-label"><span class="tm-st-name">${this._childAvatar(checker)} ${this._t("panel.sibling_check_can_check", { name: this._esc(checker.name) })}</span></div>
<div class="tm-setting-label"><span class="tm-scr-name">${this._childAvatar(checker)} ${this._t("panel.sibling_check_can_check", { name: this._esc(checker.name) })}</span></div>
<div class="tm-chip-row">
${kids.filter(c => c.id !== checker.id).map(c => {
const on = Array.isArray(pairs[checker.id]) && pairs[checker.id].includes(c.id);
Expand Down Expand Up @@ -8924,8 +8930,12 @@ class TaskMatePanel extends HTMLElement {
.tm-lib-more small { display: block; color: var(--tm-text-muted); margin-top: 2px; }
.tm-lib-more .tm-btn ha-icon { --mdc-icon-size: 16px; }
.tm-toolbar .tm-btn ha-icon { --mdc-icon-size: 16px; vertical-align: -3px; margin-inline-end: 4px; }
/* Full screen like every other dialog at this width, so it covers the
phone's bottom bar instead of leaving it dimmed underneath. */
@media (max-width: 900px) {
.tm-dialog.tm-lib { height: 100vh; }
}
@media (max-width: 760px) {
.tm-dialog.tm-lib { height: calc(100vh - 40px); }
.tm-lib-main { flex-direction: column; }
.tm-lib-rail { width: auto; display: flex; gap: 4px; overflow-x: auto; border-inline-end: 0; border-bottom: 1px solid var(--tm-border-soft); flex: none; }
.tm-lib-cat { width: auto; white-space: nowrap; }
Expand Down Expand Up @@ -16786,13 +16796,13 @@ class TaskMatePanel extends HTMLElement {
.tm-list-body .tm-allowlist { margin-top: 0; }
.tm-list-body .tm-allow-chip { font-family: inherit; }
.tm-list-body .tm-field { margin: 0; }
.tm-st-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.tm-st-name { font-weight: 600; display: inline-flex; align-items: center; gap: 6px; }
.tm-st-balance { background: #e9f6fb; color: #0f4a60; border-radius: 999px; padding: 3px 10px; font-weight: 600; font-size: .85rem; }
.tm-st-balance.live { background: #fdecec; color: #7a1f1f; }
.tm-st-adjust { margin-inline-start: auto; display: inline-flex; gap: 4px; }
.tm-st-jar { display: inline-flex; align-items: center; gap: 6px; font-weight: 600; font-size: .85rem; }
.tm-st-opens { display: flex; gap: 8px; flex-wrap: wrap; }
.tm-scr-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.tm-scr-name { font-weight: 600; display: inline-flex; align-items: center; gap: 6px; }
.tm-scr-balance { background: #e9f6fb; color: #0f4a60; border-radius: 999px; padding: 3px 10px; font-weight: 600; font-size: .85rem; }
.tm-scr-balance.live { background: #fdecec; color: #7a1f1f; }
.tm-scr-adjust { margin-inline-start: auto; display: inline-flex; gap: 4px; }
.tm-scr-jar { display: inline-flex; align-items: center; gap: 6px; font-weight: 600; font-size: .85rem; }
.tm-scr-opens { display: flex; gap: 8px; flex-wrap: wrap; }
.tm-reward-screen { margin-top: 14px; }
.tm-bin-preview { display: flex; flex-direction: column; align-items: flex-start; }
.tm-bin-preview ul { margin: 8px 0 0; padding-inline-start: 18px; line-height: 1.6; }
Expand Down Expand Up @@ -16923,7 +16933,17 @@ class TaskMatePanel extends HTMLElement {
.tm-st-d.st-pending { background: var(--tm-warning); border-color: var(--tm-warning); }
.tm-st-d.st-missed { background: var(--tm-danger-soft, #fde3e3); border-color: var(--tm-danger, #c4282e); }
.tm-st-d.st-now { border-color: var(--tm-accent); }
@media (max-width: 560px) { .tm-st-row { grid-template-columns: minmax(0, 1fr) minmax(104px, 1fr) minmax(104px, 1fr) 52px 28px; } }
/* Phone: name, points and remove on the first line; From and Until
share the second line, half each, wide enough for a 12-hour "07:00 AM".
Every cell has a fixed place, so the layout can't reflow wrongly. */
@media (max-width: 560px) {
.tm-st-row { grid-template-columns: calc(50% - 3px) minmax(0, 1fr) 56px 32px; }
.tm-st-row > :nth-child(1) { grid-area: 1 / 1 / 2 / 3; }
.tm-st-row > :nth-child(4) { grid-area: 1 / 3 / 2 / 4; }
.tm-st-row > :nth-child(5) { grid-area: 1 / 4 / 2 / 5; }
.tm-st-row > :nth-child(2) { grid-area: 2 / 1 / 3 / 2; }
.tm-st-row > :nth-child(3) { grid-area: 2 / 2 / 3 / 5; }
}

.tm-check-row {
display: flex; align-items: flex-start; gap: 14px;
Expand Down
2 changes: 2 additions & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ nav:
- "Chores":
- "Chores": "Chores.md"
- "Chore Scheduling": "Chore-Scheduling.md"
- "Chores at Set Times": "Set-Times.md"
- "Chore & Reward Library": "Chore-Library.md"
- "Mandatory Chores": "Mandatory-Chores.md"
- "Difficulty Tiers": "Difficulty-Tiers.md"
- "Timed Tasks": "Timed-Tasks.md"
Expand Down
32 changes: 32 additions & 0 deletions tests/frontend/taskmate-kiosk-card.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -791,3 +791,35 @@ test("the editor offers both list filters, 'show' by default, and stores only a
const back = editor.dispatched.at(-1).detail.config;
assert.equal("completed_mode" in back || "elapsed_time_mode" in back, false);
});

// ── set times: the ring counts the chore once ─────────────────────────────

const SET_TIME_CHORES = [
chore("dishes"),
chore("hands", { times: [{ id: "am", name: "Breakfast", start: "07:00", end: "09:00", points: 2 }, { id: "pm", name: "Dinner", start: "17:00", end: "19:00", points: 2 }] }),
];

function withSetTimes(layout, states, fn) {
const before = cardWindow.__taskmate_set_times;
cardWindow.__taskmate_set_times = {
states: (c) => c.times.map((t, i) => ({ time: t, state: states[i], opens: t.start })),
focus: (entries) => entries.find((e) => e.state === "now") || entries[0],
layout: () => layout,
nowMinutes: () => 0,
};
return fn().finally(() => { cardWindow.__taskmate_set_times = before; });
}

for (const layout of ["rows", "pills"]) {
test(`set times count once in the done ring (${layout})`, () => withSetTimes(layout, ["done", "now"], async () => {
const card = await childView({ chores: SET_TIME_CHORES, status: phoneStatus(["dishes", "hands"]) });
const { done, total } = card._progress(card._entry("vaiha").child);
assert.deepEqual([done, total], [0, 2], "one of two times done is not the chore done");
}));

test(`set times count as done once every time is done or waiting (${layout})`, () => withSetTimes(layout, ["done", "pending"], async () => {
const card = await childView({ chores: SET_TIME_CHORES, status: phoneStatus(["dishes", "hands"]) });
const { done, total } = card._progress(card._entry("vaiha").child);
assert.deepEqual([done, total], [1, 2]);
}));
}
16 changes: 16 additions & 0 deletions tests/test_library.py
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,22 @@ def test_library_add_keeps_the_template_ids_with_one_refresh(coord):
coord.async_refresh.assert_awaited_once()


@pytest.mark.parametrize(("bank_on", "minutes"), [(True, 15), (False, 0)])
def test_library_screen_time_reward_fills_the_bank_only_while_it_is_on(coord, bank_on, minutes):
coord.storage.set_setting("screen_time_enabled", bank_on)
run(
coord.async_apply_setup_wizard(
rewards=[
{"name": "15 min extra screen time", "cost": 15, "template_id": "screen_time"},
{"name": "Sleepover", "cost": 250, "template_id": "sleepover"},
],
)
)
rewards = {r.template_id: r for r in coord.storage.get_rewards()}
assert rewards["screen_time"].screen_minutes == minutes
assert rewards["sleepover"].screen_minutes == 0


def test_already_added_by_template_even_after_a_rename(coord):
coord.storage.add_chore(Chore(name="Bed!", template_id="make_bed"))
coord.storage.add_reward(Reward(name="Big sleepover", cost=300, template_id="sleepover"))
Expand Down
Loading
Loading