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
42 changes: 38 additions & 4 deletions custom_components/taskmate/coord_chores.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand All @@ -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)",
Expand Down Expand Up @@ -2370,15 +2370,18 @@ 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.

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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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``.

Expand All @@ -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:
Expand Down
35 changes: 27 additions & 8 deletions custom_components/taskmate/feedback.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"


Expand Down
2 changes: 1 addition & 1 deletion custom_components/taskmate/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,5 @@
"iot_class": "calculated",
"issue_tracker": "https://github.com/tempus2016/taskmate/issues",
"requirements": [],
"version": "7.8.2"
"version": "7.8.3"
}
4 changes: 4 additions & 0 deletions custom_components/taskmate/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
3 changes: 3 additions & 0 deletions custom_components/taskmate/www/taskmate-routine-card.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
45 changes: 45 additions & 0 deletions tests/frontend/taskmate-routine-card-assigned.test.cjs
Original file line number Diff line number Diff line change
@@ -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"]);
});
99 changes: 99 additions & 0 deletions tests/test_assigned_availability_1240.py
Original file line number Diff line number Diff line change
@@ -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
28 changes: 28 additions & 0 deletions tests/test_feedback_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
Loading
Loading