diff --git a/custom_components/taskmate/coord_chores.py b/custom_components/taskmate/coord_chores.py index a63bbd58..543b811f 100644 --- a/custom_components/taskmate/coord_chores.py +++ b/custom_components/taskmate/coord_chores.py @@ -982,7 +982,7 @@ async def async_complete_chore( # Check recurrence window for Mode B chores if getattr(chore, "schedule_mode", "specific_days") == "recurring" and not self.is_chore_available_for_child( - chore, child_id + chore, child_id, ignore_assignment=as_parent ): _LOGGER.debug( "complete_chore no-op: '%s' not available yet (recurrence window)", @@ -992,7 +992,7 @@ async def async_complete_chore( # Check availability for one-shot chores if getattr(chore, "schedule_mode", "specific_days") == "one_shot" and not self.is_chore_available_for_child( - chore, child_id + chore, child_id, ignore_assignment=as_parent ): _LOGGER.debug( "complete_chore no-op: '%s' not available (one-shot done or expired)", @@ -2370,7 +2370,7 @@ def unmet_dependencies(self, chore, child_id: str) -> list[str]: ) ] - def is_chore_available_for_child(self, chore, child_id: str) -> bool: + def is_chore_available_for_child(self, chore, child_id: str, *, ignore_assignment: bool = False) -> bool: """Check if a recurring chore is available for a child to complete. Mode A (specific_days): day filtering is handled by the child card. @@ -2378,7 +2378,10 @@ def is_chore_available_for_child(self, chore, child_id: str) -> bool: Mode B (recurring): checks rolling window from last completion date (midnight-rounded). Window lengths in days per recurrence type. - Both modes also check visibility_entity if configured. + Both modes also check visibility_entity if configured, and a chore + with a non-empty ``assigned_to`` is unavailable to every child not in it. + ``ignore_assignment`` skips only that last check, for a parent completing + on behalf of a child outside the list (the schedule window still applies). """ # Vacation / pause mode: while away, all chores are hidden. Covers the @@ -2412,6 +2415,15 @@ def is_chore_available_for_child(self, chore, child_id: str) -> bool: if self.open_redemption_for(chore.id, child_id) is not None: return True + # Assignment (#1240): a chore assigned to named children is never + # available to anyone else, whatever its mode. Rotation modes already + # draw their pool from assigned_to, but an "everyone" chore used to be + # available to every child, so the chore_availability matrix offered a + # sibling's chore to any card that trusted it (the Routine card did). + assigned = getattr(chore, "assigned_to", []) or [] + if assigned and child_id not in assigned and not ignore_assignment: + return False + # Dynamic assignment — only the active child(ren) see alternating/random chores if getattr(chore, "assignment_mode", "everyone") != "everyone": active = self._compute_active_children(chore) @@ -2473,6 +2485,24 @@ def is_chore_available_for_child(self, chore, child_id: str) -> bool: return self._recurrence_open_on(chore, child_id, dt_util.as_local(dt_util.now()).date(), override) + def _first_come_last_completed(self, chore, own: dict) -> dict: + """The latest last_completed record across a first_come chore's pool. + + A child's completion only stamps their own record, so without this a + weekly first_come chore stayed open for the rest of the pool the day + after one of them did it (#1239). A parent's completion already stamps + every pool member. Rejecting or undoing a completion rewinds that + child's own record, so the shared window reopens with it. + """ + best = own or {} + best_dt = parse_datetime(best.get("current") or "") + for cid in self._chore_assignment_pool(chore): + rec = self.storage.get_last_completed(chore.id, cid) or {} + rec_dt = parse_datetime(rec.get("current") or "") + if rec_dt is not None and (best_dt is None or rec_dt > best_dt): + best, best_dt = rec, rec_dt + return best + def _recurrence_open_on(self, chore, child_id: str, today: date, override) -> bool: """Whether a recurring chore's window is open for ``child_id`` on ``today``. @@ -2489,6 +2519,10 @@ def _recurrence_open_on(self, chore, child_id: str, today: date, override) -> bo window_days = interval_days or {"weekly": 7, "every_2_weeks": 14}.get(recurrence, 7) record = self.storage.get_last_completed(chore.id, child_id) + if getattr(chore, "assignment_mode", "everyone") == "first_come": + # One shared job (#1239): whoever in the pool did it last closes the + # window for everyone, not just for themselves. + record = self._first_come_last_completed(chore, record) current_iso = record.get("current") if override is True: diff --git a/custom_components/taskmate/feedback.py b/custom_components/taskmate/feedback.py index 1794319d..60125f73 100644 --- a/custom_components/taskmate/feedback.py +++ b/custom_components/taskmate/feedback.py @@ -47,24 +47,43 @@ async def _installation_type(hass: HomeAssistant) -> tuple[str, str]: return _INSTALL_TYPES.get(raw, "unknown"), raw +def _store_repositories(hass: HomeAssistant): + """The Marketplace's or HACS's repository list, or None when neither is loaded. + + Home Assistant 2026.11 replaced the HACS custom integration with the built-in + Marketplace, which keeps the same repository list on its config entry's + runtime_data. Older installs still have HACS in hass.data["hacs"]. + """ + if "marketplace" in hass.config.components: + for entry in hass.config_entries.async_entries("marketplace"): + manager = getattr(entry, "runtime_data", None) + if manager is not None: + return manager.repositories + raise LookupError("Marketplace is loaded but has no runtime data") + if "hacs" in hass.config.components: + return hass.data.get("hacs").repositories + return None + + def _install_method(hass: HomeAssistant) -> str: """How TaskMate itself was installed: hacs_default, hacs_custom, manual or unknown. - Reads HACS's in-memory repository list. That is HACS's internal API, so - any surprise in its shape degrades to "unknown" rather than failing. + hacs_default/hacs_custom cover both the Marketplace and HACS (the panel + labels them "Marketplace / HACS"). Both expose only internal APIs, so any + surprise in their shape degrades to "unknown" rather than failing. """ - if "hacs" not in hass.config.components: - return "manual" try: - hacs = hass.data.get("hacs") - repo = hacs.repositories.get_by_full_name(REPO_FULL_NAME) + repositories = _store_repositories(hass) + if repositories is None: + return "manual" + repo = repositories.get_by_full_name(REPO_FULL_NAME) if repo is None: return "manual" if not getattr(repo.data, "installed", True): return "manual" - return "hacs_default" if hacs.repositories.is_default(str(repo.data.id)) else "hacs_custom" + return "hacs_default" if repositories.is_default(str(repo.data.id)) else "hacs_custom" except Exception: # noqa: BLE001 - _LOGGER.debug("TaskMate feedback: could not read HACS repositories", exc_info=True) + _LOGGER.debug("TaskMate feedback: could not read Marketplace/HACS repositories", exc_info=True) return "unknown" diff --git a/custom_components/taskmate/manifest.json b/custom_components/taskmate/manifest.json index b50199e8..d5dc36ee 100644 --- a/custom_components/taskmate/manifest.json +++ b/custom_components/taskmate/manifest.json @@ -17,5 +17,5 @@ "iot_class": "calculated", "issue_tracker": "https://github.com/tempus2016/taskmate/issues", "requirements": [], - "version": "7.8.2" + "version": "7.8.3" } diff --git a/custom_components/taskmate/sensor.py b/custom_components/taskmate/sensor.py index e1727b99..dbc31504 100644 --- a/custom_components/taskmate/sensor.py +++ b/custom_components/taskmate/sensor.py @@ -683,6 +683,10 @@ def _build_chore_next_open(coordinator: TaskMateCoordinator, common: dict, avail for child in common["children"]: if (availability.get(c.id) or {}).get(child.id, True): continue + # Not this child's chore at all (#1240): it never "opens" for them. + assigned = getattr(c, "assigned_to", []) or [] + if assigned and child.id not in assigned: + continue day = coordinator.recurrence_next_open(c, child.id) if day is not None: per_child[child.id] = day.isoformat() diff --git a/custom_components/taskmate/www/taskmate-routine-card.js b/custom_components/taskmate/www/taskmate-routine-card.js index efe3afa1..65c1eea0 100644 --- a/custom_components/taskmate/www/taskmate-routine-card.js +++ b/custom_components/taskmate/www/taskmate-routine-card.js @@ -235,6 +235,9 @@ class TaskMateRoutineCard extends LitElement { // Anything finished in this run stays visible as a completed step. if (this._runCompleted.has(id)) return true; if (!availability[id] || availability[id][childId] !== true) return false; + // Only this child's chores (#1240), as the child card filters. + const assigned = Array.isArray(chore.assigned_to) ? chore.assigned_to.map(String) : []; + if (assigned.length && !assigned.includes(childId)) return false; if (completedToday.has(id)) return false; if (period !== "all" && chore.time_category !== period) return false; return true; diff --git a/tests/frontend/taskmate-routine-card-assigned.test.cjs b/tests/frontend/taskmate-routine-card-assigned.test.cjs new file mode 100644 index 00000000..8ca8d29b --- /dev/null +++ b/tests/frontend/taskmate-routine-card-assigned.test.cjs @@ -0,0 +1,45 @@ +const assert = require("node:assert/strict"); +const { test } = require("node:test"); + +const { loadCard } = require("./harness.cjs"); + +// #1240: the Routine card listed chores assigned to other children, because it +// trusted chore_availability alone and that said "available" for every child +// on an "everyone" chore. It now applies the child card's assigned_to filter. + +const CustomEvent = class { constructor(type, init) { this.type = type; this.detail = init?.detail; } }; +const RoutineCard = loadCard("taskmate-routine-card.js", { sandbox: { CustomEvent } }).get("taskmate-routine-card"); +const ENTITY = "sensor.taskmate_overview"; + +const chore = (id, assigned_to) => ({ id, name: id, points: 5, time_category: "morning", assigned_to }); + +function routineFor(childId, chores) { + const card = new RoutineCard(); + card.setConfig({ entity: ENTITY, child_id: childId, time_category: "morning" }); + const availability = Object.fromEntries(chores.map((c) => [c.id, { a: true, b: true }])); + card.hass = { + states: { + [ENTITY]: { + state: "ok", + attributes: { + children: [{ id: "a", name: "Ava", chore_order: [] }, { id: "b", name: "Ben", chore_order: [] }], + chores, + chore_availability: availability, + todays_completions: [], + }, + }, + }, + }; + return card; +} + +test("a sibling's chore is left out even when availability says true", () => { + const chores = [chore("shower", ["a"]), chore("teeth", ["b"]), chore("dress", [])]; + assert.deepEqual(routineFor("b", chores)._tasks().map((c) => c.id), ["dress", "teeth"]); + assert.deepEqual(routineFor("a", chores)._tasks().map((c) => c.id), ["dress", "shower"]); +}); + +test("a chore with no assigned_to list belongs to everyone", () => { + const chores = [{ ...chore("dress", []), assigned_to: undefined }]; + assert.deepEqual(routineFor("b", chores)._tasks().map((c) => c.id), ["dress"]); +}); diff --git a/tests/test_assigned_availability_1240.py b/tests/test_assigned_availability_1240.py new file mode 100644 index 00000000..805471ae --- /dev/null +++ b/tests/test_assigned_availability_1240.py @@ -0,0 +1,99 @@ +"""A chore assigned to named children is never available to anyone else (#1240). + +``is_chore_available_for_child`` only checked ``assigned_to`` for rotation +modes, so an "everyone" chore assigned to one child showed as available to +every child in the ``chore_availability`` matrix. The Routine card trusted that +matrix and offered a sibling's chore; roulette could hand one out too. +""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from custom_components.taskmate import sensor as sensor_mod +from custom_components.taskmate.models import Chore + +from .test_completion_eligibility import _make_system, _now, _two_children, run + + +def _system(): + coord, storage, mod = _make_system() + with patch.object(mod.dt_util, "now", return_value=_now()): + alice, bob = _two_children(coord) + return coord, storage, mod, alice, bob + + +def _at(mod): + return patch.object(mod.dt_util, "now", return_value=_now()) + + +@pytest.mark.parametrize("schedule_mode", ["specific_days", "recurring", "one_shot"]) +def test_everyone_chore_is_unavailable_to_a_child_not_assigned(schedule_mode): + coord, storage, mod, alice, bob = _system() + chore = Chore(name="Shower", schedule_mode=schedule_mode, recurrence="weekly", assigned_to=[alice.id]) + storage.add_chore(chore) + with _at(mod): + assert coord.is_chore_available_for_child(chore, alice.id) is True + assert coord.is_chore_available_for_child(chore, bob.id) is False + # A parent completing on behalf skips only the assignment check. + assert coord.is_chore_available_for_child(chore, bob.id, ignore_assignment=True) is True + + +def test_an_unassigned_chore_stays_available_to_everyone(): + coord, storage, mod, alice, bob = _system() + chore = Chore(name="Tidy up", assigned_to=[]) + storage.add_chore(chore) + with _at(mod): + assert coord.is_chore_available_for_child(chore, alice.id) is True + assert coord.is_chore_available_for_child(chore, bob.id) is True + + +def test_an_open_make_it_right_offer_still_wins(): + coord, storage, mod, alice, bob = _system() + chore = Chore(name="Shower", assigned_to=[alice.id]) + storage.add_chore(chore) + with _at(mod), patch.object(coord, "open_redemption_for", return_value={"id": "offer"}): + assert coord.is_chore_available_for_child(chore, bob.id) is True + + +def test_the_availability_matrix_marks_a_siblings_chore_false(): + coord, storage, mod, alice, bob = _system() + chore = Chore(name="Shower", assigned_to=[alice.id]) + storage.add_chore(chore) + common = {"chores": [chore], "children": [alice, bob]} + with _at(mod): + matrix = sensor_mod._build_chore_availability(coord, common) + assert matrix == {chore.id: {alice.id: True, bob.id: False}} + + +def test_next_open_is_not_published_for_a_child_not_assigned(): + coord, storage, mod, alice, bob = _system() + chore = Chore(name="Water plants", schedule_mode="recurring", recurrence="every_3_days", assigned_to=[alice.id]) + storage.add_chore(chore) + common = {"chores": [chore], "children": [alice, bob]} + with _at(mod): + availability = sensor_mod._build_chore_availability(coord, common) + out = sensor_mod._build_chore_next_open(coord, common, availability) + # Alice's window is open, Bob's chore it isn't: nothing to publish for either. + assert out == {} + + +def test_roulette_never_picks_a_siblings_chore(): + coord, storage, mod, alice, bob = _system() + mine = Chore(name="Feed the cat", assigned_to=[bob.id]) + theirs = Chore(name="Shower", assigned_to=[alice.id]) + storage.add_chore(mine) + storage.add_chore(theirs) + with _at(mod): + picks = [c.id for c in coord._roulette_candidates(bob.id)] + assert picks == [mine.id] + + +def test_a_child_still_cannot_complete_a_siblings_chore(): + coord, storage, mod, alice, bob = _system() + with _at(mod): + chore = run(coord.async_add_chore("Shower", points=5, requires_approval=False, assigned_to=[alice.id])) + assert run(coord.async_complete_chore(chore.id, bob.id)) is None + assert storage.get_child(bob.id).points == 0 diff --git a/tests/test_feedback_info.py b/tests/test_feedback_info.py index 90c050ce..a3ed89a3 100644 --- a/tests/test_feedback_info.py +++ b/tests/test_feedback_info.py @@ -139,6 +139,34 @@ def test_hacs_internals_changing_shape_is_unknown(): assert feedback._install_method(_hass(["hacs"], {})) == "unknown" +def _marketplace_hass(store, *, data=None): + hass = _hass(["marketplace"], data) + entry = SimpleNamespace(runtime_data=store) + hass.config_entries.async_entries.return_value = [entry] + return hass + + +def test_install_method_reads_the_marketplace(): + # HA 2026.11+: HACS is gone, the Marketplace keeps the list on runtime_data. + assert feedback._install_method(_marketplace_hass(_hacs(default=True))) == "hacs_default" + assert feedback._install_method(_marketplace_hass(_hacs(default=False))) == "hacs_custom" + assert feedback._install_method(_marketplace_hass(_hacs(found=False))) == "manual" + assert feedback._install_method(_marketplace_hass(_hacs(installed=False))) == "manual" + + +def test_marketplace_wins_over_a_leftover_hacs(): + hass = _marketplace_hass(_hacs(default=True), data={"hacs": _hacs(default=False)}) + hass.config.components.add("hacs") + assert feedback._install_method(hass) == "hacs_default" + + +def test_marketplace_internals_changing_shape_is_unknown(): + assert feedback._install_method(_marketplace_hass(object())) == "unknown" + hass = _hass(["marketplace"]) + hass.config_entries.async_entries.return_value = [] + assert feedback._install_method(hass) == "unknown" + + def test_lovelace_mode_dataclass_and_legacy_dict(): assert feedback._lovelace_mode(_hass(data={"lovelace": SimpleNamespace(resource_mode="storage")})) == "storage" # Before the rename the dataclass called it `mode`. diff --git a/tests/test_first_come_weekly_pool.py b/tests/test_first_come_weekly_pool.py new file mode 100644 index 00000000..d6325b5a --- /dev/null +++ b/tests/test_first_come_weekly_pool.py @@ -0,0 +1,121 @@ +"""A weekly first_come chore stays done for the whole pool (#1239). + +first_come shares one job across its pool. The daily check only counted +today's completions, and a child's completion only stamped that child's own +recurrence record, so a weekly first_come chore closed for the child who did +it but came back for everyone else the next day. A parent's completion already +stamped the whole pool, so the two paths disagreed. + +The recurrence window now reads the latest completion across the pool. +""" + +from __future__ import annotations + +import datetime as dt +from unittest.mock import AsyncMock, patch + +from .test_first_come_shared_limit import _make_system, _now, run + +DAY = dt.timedelta(days=1) + + +def _at(mod, when): + return patch.object(mod.dt_util, "now", return_value=when) + + +def _coop(coord, mod, *, mode="first_come", recurrence="weekly", requires_approval=False): + coord.notifications = AsyncMock() + with _at(mod, _now()): + a = run(coord.async_add_child("Ava")) + b = run(coord.async_add_child("Ben")) + chore = run( + coord.async_add_chore( + "Clean the coop", + points=5, + requires_approval=requires_approval, + assignment_mode=mode, + assigned_to=[a.id, b.id], + schedule_mode="recurring", + recurrence=recurrence, + ) + ) + return chore, a, b + + +def _complete(coord, mod, when, chore, child_id): + with _at(mod, when): + return run(coord.async_complete_chore(chore.id, child_id)) + + +def _available(coord, mod, when, chore, child_id): + with _at(mod, when): + return coord.is_chore_available_for_child(chore, child_id) + + +def test_one_childs_completion_closes_the_week_for_the_pool(): + coord, _storage, mod = _make_system() + chore, a, b = _coop(coord, mod) + assert _complete(coord, mod, _now(), chore, a.id) is not None + + assert _available(coord, mod, _now() + DAY, chore, a.id) is False + assert _available(coord, mod, _now() + DAY, chore, b.id) is False + assert _available(coord, mod, _now() + 6 * DAY, chore, b.id) is False + assert _complete(coord, mod, _now() + DAY, chore, b.id) is None + + +def test_it_reopens_for_everyone_when_the_window_ends(): + coord, _storage, mod = _make_system() + chore, a, b = _coop(coord, mod) + _complete(coord, mod, _now(), chore, a.id) + + assert _available(coord, mod, _now() + 7 * DAY, chore, a.id) is True + assert _available(coord, mod, _now() + 7 * DAY, chore, b.id) is True + + +def test_every_two_weeks_shares_its_fortnight(): + coord, _storage, mod = _make_system() + chore, a, b = _coop(coord, mod, recurrence="every_2_weeks") + _complete(coord, mod, _now(), chore, a.id) + + assert _available(coord, mod, _now() + 13 * DAY, chore, b.id) is False + assert _available(coord, mod, _now() + 14 * DAY, chore, b.id) is True + + +def test_a_completion_awaiting_approval_counts(): + coord, _storage, mod = _make_system() + chore, a, b = _coop(coord, mod, requires_approval=True) + completion = _complete(coord, mod, _now(), chore, a.id) + assert completion is not None and not completion.approved + + assert _available(coord, mod, _now() + DAY, chore, b.id) is False + + +def test_rejecting_the_completion_reopens_it_for_the_pool(): + coord, _storage, mod = _make_system() + chore, a, b = _coop(coord, mod, requires_approval=True) + completion = _complete(coord, mod, _now(), chore, a.id) + + with _at(mod, _now() + DAY): + run(coord.async_reject_chore(completion.id)) + assert _available(coord, mod, _now() + DAY, chore, b.id) is True + assert _available(coord, mod, _now() + DAY, chore, a.id) is True + + +def test_the_latest_completion_in_the_pool_sets_the_window(): + """Ben did it two weeks ago; Ava did it yesterday. Ava's counts.""" + coord, storage, mod = _make_system() + chore, a, b = _coop(coord, mod) + storage.set_last_completed(chore.id, b.id, (_now() - 14 * DAY).isoformat()) + storage.set_last_completed(chore.id, a.id, (_now() - DAY).isoformat()) + + assert _available(coord, mod, _now(), chore, b.id) is False + assert _available(coord, mod, _now() + 6 * DAY, chore, b.id) is True + + +def test_everyone_mode_keeps_its_own_window_per_child(): + coord, _storage, mod = _make_system() + chore, a, b = _coop(coord, mod, mode="everyone") + _complete(coord, mod, _now(), chore, a.id) + + assert _available(coord, mod, _now() + DAY, chore, a.id) is False + assert _available(coord, mod, _now() + DAY, chore, b.id) is True diff --git a/wiki/Chore-Scheduling.md b/wiki/Chore-Scheduling.md index af893c3e..bb33c929 100644 --- a/wiki/Chore-Scheduling.md +++ b/wiki/Chore-Scheduling.md @@ -217,6 +217,7 @@ The parent dashboard applies the same filters. - **Shared daily limit** — from **v7.6.0** a first-come chore's daily limit is shared by the whole pool. A limit of `2` means two completions in total today, whoever makes them: one each, or both by the same child. Every completion pays the child who made it, and once the limit is used up the chore locks for everyone. A pool-wide **Parent did it** from the admin panel uses one of the slots. - **Race losers** — if another child tries to complete it after the limit is used up, the attempt is rejected with *"Chore '…' was already completed by another child."* No points are awarded to the loser. - **Daily reset** — the lock is per-day; the chore opens up to the pool again the next day. +- **Recurring first-come chores** — with a **Recurring** schedule (for example weekly or every 2 weeks), one completion closes the chore for the **whole pool** until the recurrence window ends, whoever did it. A weekly chore Ava does on Saturday stays off everyone's list until the next Saturday. A completion still waiting for approval counts; rejecting or undoing it reopens the chore for everyone. For example, to have the dishwasher emptied twice a day by whichever child gets there first, set the chore up in the admin panel's chore dialog like this: diff --git a/wiki/Help-and-Feedback.md b/wiki/Help-and-Feedback.md index 0832fbfc..9ce5a1fe 100644 --- a/wiki/Help-and-Feedback.md +++ b/wiki/Help-and-Feedback.md @@ -64,7 +64,7 @@ The number of children you use TaskMate with is filled in for you. |--------|---------------------| | TaskMate and Home Assistant versions | The integration | | Home Assistant installation | Home Assistant's system info (OS, Supervised, Container or Core) | -| How TaskMate was installed | HACS, if HACS is installed and knows about TaskMate; otherwise manual | +| How TaskMate was installed | The Marketplace (or HACS before Home Assistant 2026.11), if it knows about TaskMate; otherwise manual | | Dashboard (Lovelace) mode | Storage (edited in the UI) or YAML | | Family | How many children, adults, chores and rewards. **Counts only** | | Browser | A summary like "Chrome 141 on Windows", never the full browser string |