From c8b2334a8e550bfb6110c069b377fca5d5f3a711 Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Sun, 18 Oct 2026 13:31:32 +0000 Subject: [PATCH] feat(chores): "Parent did it" for one child of a shared chore (#1096) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A chore shared by several children can now be marked done by a parent for just one of them. The chore's ⋮ menu in the admin panel lists "Parent did it for everyone" plus "Parent did it for " for each assigned child. parent_complete_chore takes an optional child_id. The completion is still a zero-point "__parent__" record, now with for_child_id, and only that child's recurrence window moves on. Points, streaks, badges and stats already ignore parent completions, so only the places that read one as "done for the pool" (daily quota, due list, today board, child card, sticker chart) needed to honour the new field. Limited to everyone-mode chores, because rotations and first-come have one shared slot. Closes #1096 --- .../taskmate/coord_assignments.py | 5 +- custom_components/taskmate/coord_chores.py | 37 ++++-- custom_components/taskmate/models.py | 6 + custom_components/taskmate/sensor.py | 4 + custom_components/taskmate/websocket.py | 4 +- .../taskmate/www/locales/da.json | 3 + .../taskmate/www/locales/de.json | 3 + .../taskmate/www/locales/en-GB.json | 3 + .../taskmate/www/locales/en.json | 3 + .../taskmate/www/locales/fr.json | 3 + .../taskmate/www/locales/nb.json | 3 + .../taskmate/www/locales/nl.json | 3 + .../taskmate/www/locales/nn.json | 3 + .../taskmate/www/locales/pl.json | 3 + .../taskmate/www/locales/pt-BR.json | 3 + .../taskmate/www/locales/pt.json | 3 + .../taskmate/www/taskmate-child-card.js | 17 ++- .../taskmate/www/taskmate-panel.js | 31 +++-- .../www/taskmate-sticker-chart-card.js | 7 +- tests/test_parent_complete.py | 116 ++++++++++++++++++ wiki/Admin-Panel.md | 2 +- 21 files changed, 237 insertions(+), 25 deletions(-) diff --git a/custom_components/taskmate/coord_assignments.py b/custom_components/taskmate/coord_assignments.py index 893289db..22356e81 100644 --- a/custom_components/taskmate/coord_assignments.py +++ b/custom_components/taskmate/coord_assignments.py @@ -673,8 +673,9 @@ def _is_rotation_done_today_uncached(self, chore) -> bool: completed_bonus_ids_today.add(bonus_id) continue # Parent completions count toward the quota. A `__parent__` - # sentinel covers the whole rotation for today. - if comp.child_id in pool or comp.child_id == "__parent__": + # sentinel covers the whole rotation for today — unless it was + # for one child only (#1096), which a rotation never carries. + if comp.child_id in pool or (comp.child_id == "__parent__" and not getattr(comp, "for_child_id", "")): completions_today += 1 # first_come is a single-winner race: clamp any mis-configured quota to 1. if getattr(chore, "assignment_mode", "everyone") == "first_come": diff --git a/custom_components/taskmate/coord_chores.py b/custom_components/taskmate/coord_chores.py index 17a79746..a1be9ec7 100644 --- a/custom_components/taskmate/coord_chores.py +++ b/custom_components/taskmate/coord_chores.py @@ -882,7 +882,7 @@ async def async_complete_chore( continue if ( comp.chore_id == chore_id - and self._counts_toward_child_quota(chore, comp.child_id, child_id) + and self._counts_toward_child_quota(chore, comp.child_id, child_id, getattr(comp, "for_child_id", "")) and not comp.bonus_subtask_id ): comp_dt = comp.completed_at @@ -1350,8 +1350,12 @@ def week_progress_on(self, day: date) -> dict[str, dict[str, int]]: progress.setdefault(child.id, {})[chore.id] = count return progress - async def async_parent_complete_chore(self, chore_id: str) -> ChoreCompletion: - """Mark a chore as completed by the parent — zero points, advances recurrence.""" + async def async_parent_complete_chore(self, chore_id: str, child_id: str | None = None) -> ChoreCompletion: + """Mark a chore as completed by the parent — zero points, advances recurrence. + + ``child_id`` dismisses it for that one child only (#1096); without it + the chore is dismissed for every child it's assigned to. + """ chore = self.get_chore(chore_id) if not chore: raise ValueError(f"Chore {chore_id} not found") @@ -1372,6 +1376,16 @@ async def async_parent_complete_chore(self, chore_id: str) -> ChoreCompletion: if not child_ids: raise ValueError(f"Chore '{chore.name}' has no children to suppress") + if child_id: + # Only an everyone-mode chore gives each child their own copy; a + # rotation or race has one shared slot, so "done for one child" + # means nothing there. + if getattr(chore, "assignment_mode", "everyone") != "everyone": + raise ValueError(f"Chore '{chore.name}' is shared by a rotation and can only be done for everyone") + if child_id not in child_ids: + raise ValueError(f"Chore '{chore.name}' is not assigned to that child") + child_ids = [child_id] + completion = ChoreCompletion( chore_id=chore_id, child_id="__parent__", @@ -1380,6 +1394,7 @@ async def async_parent_complete_chore(self, chore_id: str) -> ChoreCompletion: approved_at=now, points_awarded=0, submitted_points=0, + for_child_id=child_id or "", ) self.storage.add_completion(completion) @@ -2390,17 +2405,21 @@ def _reverse_weekly_target_bonus(self, completion, child) -> None: ) @staticmethod - def _counts_toward_child_quota(chore, completion_child_id: str, child_id: str) -> bool: + def _counts_toward_child_quota(chore, completion_child_id: str, child_id: str, for_child_id: str = "") -> bool: """Whether a completion counts toward ``child_id``'s daily limit for ``chore``. A parent completion (``__parent__``) closes the chore for the whole pool, so it counts for every child — the same rule the child card applies (#1082). first_come is the exception: there the parent completion fills the shared race quota instead (``_is_rotation_done_today``), and nobody won it. + A parent completion with ``for_child_id`` set closes it for that one + child only (#1096). """ if completion_child_id == child_id: return True - return completion_child_id == "__parent__" and getattr(chore, "assignment_mode", "everyone") != "first_come" + if completion_child_id != "__parent__" or getattr(chore, "assignment_mode", "everyone") == "first_come": + return False + return not for_child_id or for_child_id == child_id def _weekly_target_refund_owed(self, completion, child) -> int: """What ``_reverse_weekly_target_bonus`` would take back, without doing it.""" @@ -2469,7 +2488,9 @@ def get_due_chores_for_child(self, child_id: str) -> list: limit = getattr(chore, "daily_limit", 1) or 1 done = 0 for c in completions: - if c.chore_id != chore.id or not self._counts_toward_child_quota(chore, c.child_id, child_id): + if c.chore_id != chore.id or not self._counts_toward_child_quota( + chore, c.child_id, child_id, getattr(c, "for_child_id", "") + ): continue if getattr(c, "bonus_subtask_id", ""): continue @@ -2517,7 +2538,9 @@ def get_today_board(self) -> dict: continue except (AttributeError, TypeError, ValueError): continue - done_today.setdefault((c.child_id, c.chore_id), []).append(bool(c.approved)) + # A parent completion for one child (#1096) is that child's alone. + owner = getattr(c, "for_child_id", "") or c.child_id + done_today.setdefault((owner, c.chore_id), []).append(bool(c.approved)) chores = self.storage.get_chores() for child in self.storage.get_children(): items = [] diff --git a/custom_components/taskmate/models.py b/custom_components/taskmate/models.py index ac9dbe69..2d93a3e1 100644 --- a/custom_components/taskmate/models.py +++ b/custom_components/taskmate/models.py @@ -1175,6 +1175,9 @@ class ChoreCompletion: # True when nobody reviewed it in time and the auto-approve timeout # approved it instead. auto_approved: bool = False + # Set on a parent completion (child_id "__parent__") that dismisses the + # chore for this one child only (#1096). Empty = the whole pool. + for_child_id: str = "" @classmethod def from_dict(cls, data: dict[str, Any]) -> ChoreCompletion: @@ -1202,6 +1205,7 @@ def from_dict(cls, data: dict[str, Any]) -> ChoreCompletion: backdated=data.get("backdated") is True, quantity=_clamp_int(data.get("quantity"), 0, MAX_QUANTITY), auto_approved=data.get("auto_approved") is True, + for_child_id=str(data.get("for_child_id", "") or ""), ) def to_dict(self) -> dict[str, Any]: @@ -1234,6 +1238,8 @@ def to_dict(self) -> dict[str, Any]: data["quantity"] = self.quantity if self.auto_approved: data["auto_approved"] = True + if self.for_child_id: + data["for_child_id"] = self.for_child_id return data diff --git a/custom_components/taskmate/sensor.py b/custom_components/taskmate/sensor.py index cba6138b..26769599 100644 --- a/custom_components/taskmate/sensor.py +++ b/custom_components/taskmate/sensor.py @@ -598,6 +598,9 @@ def _build_todays_completions(common: dict) -> list[dict]: rec["timed_duration_seconds"] = timed_secs if getattr(comp, "quantity", 0): rec["quantity"] = comp.quantity + # Parent did it for one child only (#1096); absent = the whole pool. + if getattr(comp, "for_child_id", ""): + rec["for_child_id"] = comp.for_child_id # Child undo (#918): child_undo_pending / child_undo_until, present only # on a completion the child may still take back, so the attribute # stays small (and empty whenever the window is 0). @@ -800,6 +803,7 @@ def _build_recent_completions(common: dict, limit: int = 35) -> list[dict]: else str(comp.completed_at), # Star rating the parent gave at approval (#927) — only when rated. **({"rating": comp.quality_rating} if getattr(comp, "quality_rating", 0) else {}), + **({"for_child_id": comp.for_child_id} if getattr(comp, "for_child_id", "") else {}), } ) return out diff --git a/custom_components/taskmate/websocket.py b/custom_components/taskmate/websocket.py index 19ad3334..770a53df 100644 --- a/custom_components/taskmate/websocket.py +++ b/custom_components/taskmate/websocket.py @@ -2507,12 +2507,14 @@ async def _ws_reject_reward(hass, connection, msg, coordinator): { vol.Required("type"): WS_PARENT_COMPLETE_CHORE, vol.Required("chore_id"): str, + # Done for this one child only (#1096); omitted = everyone assigned. + vol.Optional("child_id"): str, } ) @websocket_api.async_response @_admin_only async def _ws_parent_complete_chore(hass, connection, msg, coordinator): - await coordinator.async_parent_complete_chore(msg["chore_id"]) + await coordinator.async_parent_complete_chore(msg["chore_id"], msg.get("child_id") or None) connection.send_result(msg["id"], {"ok": True}) diff --git a/custom_components/taskmate/www/locales/da.json b/custom_components/taskmate/www/locales/da.json index ea5205d8..6f5bdae9 100644 --- a/custom_components/taskmate/www/locales/da.json +++ b/custom_components/taskmate/www/locales/da.json @@ -1337,6 +1337,9 @@ "panel.parent_complete_failed": "Mislykkedes: {error}", "panel.parent_complete_success": "Markeret som klaret af forælder", "panel.parent_complete_tooltip": "Forælder klarede den (ingen point)", + "panel.parent_complete_everyone": "Forælder klarede den for alle (ingen point)", + "panel.parent_complete_for_child": "Forælder klarede den for {name} (ingen point)", + "panel.parent_complete_for_child_confirm": "Markér denne opgave som klaret kun for {name}? Der gives ingen point.", "panel.penalty_apply_text": "Vælg hvilket barn der skal have straffen (trækker {points} point).", "panel.penalty_empty_description": "Tilføj en straf for at trække point for dårlig opførsel.", "panel.penalty_empty_title": "Ingen straffe endnu", diff --git a/custom_components/taskmate/www/locales/de.json b/custom_components/taskmate/www/locales/de.json index 17bd7fbd..6679a767 100644 --- a/custom_components/taskmate/www/locales/de.json +++ b/custom_components/taskmate/www/locales/de.json @@ -1285,6 +1285,9 @@ "panel.parent_complete_failed": "Fehlgeschlagen: {error}", "panel.parent_complete_success": "Als von Eltern erledigt markiert", "panel.parent_complete_tooltip": "Eltern haben es erledigt (keine Punkte)", + "panel.parent_complete_everyone": "Eltern haben es für alle erledigt (keine Punkte)", + "panel.parent_complete_for_child": "Eltern haben es für {name} erledigt (keine Punkte)", + "panel.parent_complete_for_child_confirm": "Diese Aufgabe nur für {name} als erledigt markieren? Es werden keine Punkte vergeben.", "panel.penalty_apply_text": "Wähle aus, welchem Kind diese Strafe zugewiesen wird ({points} Punkte werden abgezogen).", "panel.penalty_empty_description": "Füge eine Strafe hinzu, um Punkte für Fehlverhalten abzuziehen.", "panel.penalty_empty_title": "Noch keine Strafen", diff --git a/custom_components/taskmate/www/locales/en-GB.json b/custom_components/taskmate/www/locales/en-GB.json index dda6fda4..ba20f279 100644 --- a/custom_components/taskmate/www/locales/en-GB.json +++ b/custom_components/taskmate/www/locales/en-GB.json @@ -1337,6 +1337,9 @@ "panel.parent_complete_failed": "Failed: {error}", "panel.parent_complete_success": "Marked as parent completed", "panel.parent_complete_tooltip": "Parent did it (no points)", + "panel.parent_complete_everyone": "Parent did it for everyone (no points)", + "panel.parent_complete_for_child": "Parent did it for {name} (no points)", + "panel.parent_complete_for_child_confirm": "Mark this chore as done for {name} only? No points will be awarded.", "panel.penalty_apply_text": "Choose which child to apply this penalty to (will deduct {points} points).", "panel.penalty_empty_description": "Add a penalty to deduct points for misbehaviour.", "panel.penalty_empty_title": "No penalties yet", diff --git a/custom_components/taskmate/www/locales/en.json b/custom_components/taskmate/www/locales/en.json index dda6fda4..ba20f279 100644 --- a/custom_components/taskmate/www/locales/en.json +++ b/custom_components/taskmate/www/locales/en.json @@ -1337,6 +1337,9 @@ "panel.parent_complete_failed": "Failed: {error}", "panel.parent_complete_success": "Marked as parent completed", "panel.parent_complete_tooltip": "Parent did it (no points)", + "panel.parent_complete_everyone": "Parent did it for everyone (no points)", + "panel.parent_complete_for_child": "Parent did it for {name} (no points)", + "panel.parent_complete_for_child_confirm": "Mark this chore as done for {name} only? No points will be awarded.", "panel.penalty_apply_text": "Choose which child to apply this penalty to (will deduct {points} points).", "panel.penalty_empty_description": "Add a penalty to deduct points for misbehaviour.", "panel.penalty_empty_title": "No penalties yet", diff --git a/custom_components/taskmate/www/locales/fr.json b/custom_components/taskmate/www/locales/fr.json index 815f8c81..8b11847a 100644 --- a/custom_components/taskmate/www/locales/fr.json +++ b/custom_components/taskmate/www/locales/fr.json @@ -1285,6 +1285,9 @@ "panel.parent_complete_failed": "Échec : {error}", "panel.parent_complete_success": "Marqué comme complété par le parent", "panel.parent_complete_tooltip": "Fait par le parent (pas de points)", + "panel.parent_complete_everyone": "Fait par le parent pour tous (pas de points)", + "panel.parent_complete_for_child": "Fait par le parent pour {name} (pas de points)", + "panel.parent_complete_for_child_confirm": "Marquer cette tâche comme faite pour {name} uniquement ? Aucun point ne sera attribué.", "panel.penalty_apply_text": "Choisissez l'enfant à qui appliquer cette pénalité ({points} points seront déduits).", "panel.penalty_empty_description": "Ajoutez une pénalité pour retirer des points en cas de mauvais comportement.", "panel.penalty_empty_title": "Aucune pénalité", diff --git a/custom_components/taskmate/www/locales/nb.json b/custom_components/taskmate/www/locales/nb.json index 099d08ea..19ee294b 100644 --- a/custom_components/taskmate/www/locales/nb.json +++ b/custom_components/taskmate/www/locales/nb.json @@ -1285,6 +1285,9 @@ "panel.parent_complete_failed": "Feilet: {error}", "panel.parent_complete_success": "Markert som fullført av forelder", "panel.parent_complete_tooltip": "Forelder gjorde det (ingen poeng)", + "panel.parent_complete_everyone": "Forelder gjorde det for alle (ingen poeng)", + "panel.parent_complete_for_child": "Forelder gjorde det for {name} (ingen poeng)", + "panel.parent_complete_for_child_confirm": "Merke denne oppgaven som gjort bare for {name}? Ingen poeng blir gitt.", "panel.penalty_apply_text": "Velg hvilket barn som skal få denne straffen (trekker {points} poeng).", "panel.penalty_empty_description": "Legg til en straff for å trekke poeng ved dårlig oppførsel.", "panel.penalty_empty_title": "Ingen straffer ennå", diff --git a/custom_components/taskmate/www/locales/nl.json b/custom_components/taskmate/www/locales/nl.json index 1184e88b..54737ce9 100644 --- a/custom_components/taskmate/www/locales/nl.json +++ b/custom_components/taskmate/www/locales/nl.json @@ -1337,6 +1337,9 @@ "panel.parent_complete_failed": "Mislukt: {error}", "panel.parent_complete_success": "Als door ouder gedaan gemarkeerd", "panel.parent_complete_tooltip": "Ouder deed het (geen punten)", + "panel.parent_complete_everyone": "Ouder deed het voor iedereen (geen punten)", + "panel.parent_complete_for_child": "Ouder deed het voor {name} (geen punten)", + "panel.parent_complete_for_child_confirm": "Deze taak alleen voor {name} als gedaan markeren? Er worden geen punten gegeven.", "panel.penalty_apply_text": "Kies welk kind deze puntenaftrek krijgt (er gaan {points} punten af).", "panel.penalty_empty_description": "Voeg puntenaftrek toe om punten af te trekken bij wangedrag.", "panel.penalty_empty_title": "Nog geen puntenaftrek", diff --git a/custom_components/taskmate/www/locales/nn.json b/custom_components/taskmate/www/locales/nn.json index b07271e1..412667dd 100644 --- a/custom_components/taskmate/www/locales/nn.json +++ b/custom_components/taskmate/www/locales/nn.json @@ -1285,6 +1285,9 @@ "panel.parent_complete_failed": "Feila: {error}", "panel.parent_complete_success": "Merkt som fullført av forelder", "panel.parent_complete_tooltip": "Forelder gjorde det (ingen poeng)", + "panel.parent_complete_everyone": "Forelder gjorde det for alle (ingen poeng)", + "panel.parent_complete_for_child": "Forelder gjorde det for {name} (ingen poeng)", + "panel.parent_complete_for_child_confirm": "Merke denne oppgåva som gjord berre for {name}? Ingen poeng blir gjevne.", "panel.penalty_apply_text": "Vel kva born som skal få denne straffa (trekk {points} poeng).", "panel.penalty_empty_description": "Legg til ei straff for å trekke poeng for dårleg oppførsel.", "panel.penalty_empty_title": "Ingen straffar enno", diff --git a/custom_components/taskmate/www/locales/pl.json b/custom_components/taskmate/www/locales/pl.json index 335a01ac..67cde070 100644 --- a/custom_components/taskmate/www/locales/pl.json +++ b/custom_components/taskmate/www/locales/pl.json @@ -1337,6 +1337,9 @@ "panel.parent_complete_failed": "Niepowodzenie: {error}", "panel.parent_complete_success": "Oznaczono jako wykonane przez rodzica", "panel.parent_complete_tooltip": "Rodzic to zrobił (bez punktów)", + "panel.parent_complete_everyone": "Rodzic zrobił to za wszystkich (bez punktów)", + "panel.parent_complete_for_child": "Rodzic zrobił to za {name} (bez punktów)", + "panel.parent_complete_for_child_confirm": "Oznaczyć to zadanie jako wykonane tylko dla {name}? Punkty nie zostaną przyznane.", "panel.penalty_apply_text": "Wybierz dziecko, na które nałożyć tę karę (odejmie {points} pkt).", "panel.penalty_empty_description": "Dodaj karę, aby odejmować punkty za złe zachowanie.", "panel.penalty_empty_title": "Brak kar", diff --git a/custom_components/taskmate/www/locales/pt-BR.json b/custom_components/taskmate/www/locales/pt-BR.json index 65860b46..e94a4764 100644 --- a/custom_components/taskmate/www/locales/pt-BR.json +++ b/custom_components/taskmate/www/locales/pt-BR.json @@ -1285,6 +1285,9 @@ "panel.parent_complete_failed": "Falhou: {error}", "panel.parent_complete_success": "Marcado como concluído pelo responsável", "panel.parent_complete_tooltip": "Responsável fez (sem pontos)", + "panel.parent_complete_everyone": "Responsável fez por todos (sem pontos)", + "panel.parent_complete_for_child": "Responsável fez por {name} (sem pontos)", + "panel.parent_complete_for_child_confirm": "Marcar esta tarefa como feita só para {name}? Nenhum ponto será concedido.", "panel.penalty_apply_text": "Escolha qual criança receberá esta penalidade ({points} pontos serão deduzidos).", "panel.penalty_empty_description": "Adicione uma penalidade para deduzir pontos por mau comportamento.", "panel.penalty_empty_title": "Nenhuma penalidade ainda", diff --git a/custom_components/taskmate/www/locales/pt.json b/custom_components/taskmate/www/locales/pt.json index ad955157..717bcbd5 100644 --- a/custom_components/taskmate/www/locales/pt.json +++ b/custom_components/taskmate/www/locales/pt.json @@ -1290,6 +1290,9 @@ "panel.parent_complete_failed": "Falhou: {error}", "panel.parent_complete_success": "Marcado como concluído pelo responsável", "panel.parent_complete_tooltip": "Responsável fez (sem pontos)", + "panel.parent_complete_everyone": "Responsável fez por todos (sem pontos)", + "panel.parent_complete_for_child": "Responsável fez por {name} (sem pontos)", + "panel.parent_complete_for_child_confirm": "Marcar esta tarefa como feita só para {name}? Não serão atribuídos pontos.", "panel.penalty_apply_text": "Escolha a criança a penalizar (será deduzido {points} pontos).", "panel.penalty_empty_description": "Adicione uma penalização para deduzir pontos por mau comportamento.", "panel.penalty_empty_title": "Ainda sem penalizações", diff --git a/custom_components/taskmate/www/taskmate-child-card.js b/custom_components/taskmate/www/taskmate-child-card.js index 424f84ae..83c21f50 100644 --- a/custom_components/taskmate/www/taskmate-child-card.js +++ b/custom_components/taskmate/www/taskmate-child-card.js @@ -2698,6 +2698,13 @@ class TaskMateChildCard extends LitElement { return Math.min(limit, countToday + Math.max(0, target - doneThisWeek)); } + // A "Parent did it" completion covers this child: either the whole pool, + // or this child alone when for_child_id is set (#1096). + _parentDidItFor(comp, childId) { + return comp.child_id === "__parent__" + && (!comp.for_child_id || String(comp.for_child_id) === String(childId)); + } + _isChoreDone(chore, child, todaysCompletions) { // async_parent_complete_chore writes child_id="__parent__" to dismiss a // chore for the WHOLE pool with zero points — it never credits a specific @@ -2710,7 +2717,7 @@ class TaskMateChildCard extends LitElement { const isFirstCome = chore.assignment_mode === "first_come"; const childCompletionsToday = (todaysCompletions || []).filter( (comp) => comp.chore_id === chore.id - && (comp.child_id === child.id || (!isFirstCome && comp.child_id === "__parent__")) + && (comp.child_id === child.id || (!isFirstCome && this._parentDidItFor(comp, child.id))) && !comp.bonus_subtask_id ); let count = childCompletionsToday.length; @@ -3455,7 +3462,7 @@ class TaskMateChildCard extends LitElement { // state, no way to undo. The mode is about the days *after* that. const completedToday = allCompletionsToday.some( (comp) => comp.chore_id === chore.id - && (String(comp.child_id) === childId || comp.child_id === '__parent__') + && (String(comp.child_id) === childId || this._parentDidItFor(comp, childId)) && !comp.bonus_subtask_id ); chore._isRecurrenceLocked = chore._isRecurring && !availableNow && !completedToday; @@ -3904,7 +3911,7 @@ class TaskMateChildCard extends LitElement { // place too, and a parent completing on behalf lands under "__parent__". const childCompletionsToday = todaysCompletions.filter( (comp) => comp.chore_id === chore.id - && (comp.child_id === child.id || comp.child_id === "__parent__") + && (comp.child_id === child.id || this._parentDidItFor(comp, child.id)) && !comp.bonus_subtask_id ); const isDone = childCompletionsToday.length >= dailyLimit; @@ -3992,7 +3999,7 @@ class TaskMateChildCard extends LitElement { // everyone behavior, which is intentional there. const isFirstCome = chore.assignment_mode === "first_come"; const childCompletionsToday = todaysCompletions.filter( - (comp) => comp.chore_id === chore.id && (comp.child_id === child.id || (!isFirstCome && comp.child_id === "__parent__")) && !comp.bonus_subtask_id + (comp) => comp.chore_id === chore.id && (comp.child_id === child.id || (!isFirstCome && this._parentDidItFor(comp, child.id))) && !comp.bonus_subtask_id ); let completionsToday = childCompletionsToday.length; // Clamped for first_come the same way the filter and the backend clamp it: a @@ -4355,7 +4362,7 @@ class TaskMateChildCard extends LitElement { // Check if parent chore is completed today (including optimistic) const parentCompletions = todaysCompletions.filter( - c => c.chore_id === chore.id && (c.child_id === child.id || c.child_id === "__parent__") && !c.bonus_subtask_id + c => c.chore_id === chore.id && (c.child_id === child.id || this._parentDidItFor(c, child.id)) && !c.bonus_subtask_id ); const optimisticKey = `${chore.id}_${child.id}`; const hasOptimistic = this._optimisticCompletions && this._optimisticCompletions[optimisticKey]; diff --git a/custom_components/taskmate/www/taskmate-panel.js b/custom_components/taskmate/www/taskmate-panel.js index 25668057..3236af06 100644 --- a/custom_components/taskmate/www/taskmate-panel.js +++ b/custom_components/taskmate/www/taskmate-panel.js @@ -703,7 +703,7 @@ class TaskMatePanel extends HTMLElement { this._doBulk("reassign", v === "__all__" ? [] : [v]); return; } if (act === "delete-chore") { this._confirmDelete("chore", t.dataset.id); return; } - if (act === "parent-complete-chore") { this._doParentComplete(t.dataset.id); return; } + if (act === "parent-complete-chore") { this._doParentComplete(t.dataset.id, t.dataset.child || ""); return; } if (act === "skip-chore") { this._doSkipChore(t.dataset.id); return; } if (act === "toggle-chore-active") { this._doToggleChoreActive(t.dataset.id); return; } if (act === "save-chore") { this._doSaveChore(); return; } @@ -1452,9 +1452,16 @@ class TaskMatePanel extends HTMLElement { } // ---- Parent complete -------------------------------------------------- - async _doParentComplete(choreId) { - if (!confirm(this._t("panel.parent_complete_confirm"))) return; - const { ok, err } = await this._callWS({ type: "taskmate/parent_complete_chore", chore_id: choreId }); + // childId set = done for that one child only (#1096). + async _doParentComplete(choreId, childId = "") { + const child = childId ? (this._state.children || []).find(c => c.id === childId) : null; + const question = child + ? this._t("panel.parent_complete_for_child_confirm", {name: child.name}) + : this._t("panel.parent_complete_confirm"); + if (!confirm(question)) return; + const msg = { type: "taskmate/parent_complete_chore", chore_id: choreId }; + if (childId) msg.child_id = childId; + const { ok, err } = await this._callWS(msg); if (!ok) { this._showToast("err", this._t("panel.parent_complete_failed", {error: err})); return; } await this._fetchState(); this._showToast("ok", this._t("panel.parent_complete_success")); @@ -11669,8 +11676,18 @@ class TaskMatePanel extends HTMLElement { if (!c) return; const esc = this._esc.bind(this); const items = []; - if (this._state.parent_completable && this._state.parent_completable[id]) - items.push({ act: "parent-complete-chore", icon: "👤✓", label: this._t("panel.parent_complete_tooltip") }); + if (this._state.parent_completable && this._state.parent_completable[id]) { + // An everyone-mode chore shared by several children can be done for + // just one of them (#1096); rotations have a single shared slot. + const kids = this._state.children || []; + const assigned = c.assigned_to || []; + const pool = assigned.length ? kids.filter(k => assigned.includes(k.id)) : kids; + const perChild = (c.assignment_mode || "everyone") === "everyone" && pool.length > 1; + items.push({ act: "parent-complete-chore", icon: "👤✓", + label: this._t(perChild ? "panel.parent_complete_everyone" : "panel.parent_complete_tooltip") }); + if (perChild) pool.forEach(k => items.push({ act: "parent-complete-chore", icon: "👤✓", child: k.id, + label: this._t("panel.parent_complete_for_child", {name: k.name}) })); + } if (["alternating", "random", "balanced"].includes(c.assignment_mode)) items.push({ act: "skip-chore", icon: "⏭", label: this._t("panel.btn_skip_chore") }); if (["alternating", "random", "balanced", "first_come"].includes(c.assignment_mode)) @@ -11686,7 +11703,7 @@ class TaskMatePanel extends HTMLElement { menu.setAttribute("role", "menu"); menu.style.visibility = "hidden"; menu.innerHTML = items.map(it => - ``).join(""); this.appendChild(menu); diff --git a/custom_components/taskmate/www/taskmate-sticker-chart-card.js b/custom_components/taskmate/www/taskmate-sticker-chart-card.js index ac4754b1..660bf090 100644 --- a/custom_components/taskmate/www/taskmate-sticker-chart-card.js +++ b/custom_components/taskmate/www/taskmate-sticker-chart-card.js @@ -184,12 +184,15 @@ class TaskMateStickerChartCard extends LitElement { /** * This child's completions of a chore today. "__parent__" is a parent * dismissing it for the whole pool, so it counts for whoever is looking — - * except first-come, which is a race the child didn't win. + * except first-come, which is a race the child didn't win — or for one + * child only when for_child_id is set (#1096). */ _mine(childId, chore, completions) { const firstCome = (chore.assignment_mode || "everyone") === "first_come"; return completions.filter(c => c.chore_id === chore.id - && (String(c.child_id) === String(childId) || (c.child_id === "__parent__" && !firstCome))); + && (String(c.child_id) === String(childId) + || (c.child_id === "__parent__" && !firstCome + && (!c.for_child_id || String(c.for_child_id) === String(childId))))); } _t(key, params) { diff --git a/tests/test_parent_complete.py b/tests/test_parent_complete.py index 57902632..c6374a21 100644 --- a/tests/test_parent_complete.py +++ b/tests/test_parent_complete.py @@ -434,3 +434,119 @@ def test_today_board_shows_parent_completed_chore_as_done(self): items = board["children"][0]["chores"] assert items == [{"chore_id": chore.id, "status": "done", "count": 1, "limit": 1}] + + +class TestParentCompleteForOneChild: + """ "Parent did it" for one child of a shared chore (#1096).""" + + def _shared_chore(self, coord, _mod, now, **kwargs): + days = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"] + opts = {"schedule_mode": "specific_days", "due_days": days} + opts.update(kwargs) + with patch.object(_mod.dt_util, "now", return_value=now): + alice = run(coord.async_add_child("Alice")) + bob = run(coord.async_add_child("Bob")) + chore = run( + coord.async_add_chore( + "Tidy room", points=5, requires_approval=False, assigned_to=[alice.id, bob.id], **opts + ) + ) + return alice, bob, chore + + def test_records_a_parent_completion_for_that_child(self): + coord, storage, _mod = _make_system() + now = _now() + alice, bob, chore = self._shared_chore(coord, _mod, now) + + with patch.object(_mod.dt_util, "now", return_value=now): + run(coord.async_parent_complete_chore(chore.id, alice.id)) + + [comp] = storage.get_completions() + assert comp.child_id == "__parent__" + assert comp.for_child_id == alice.id + assert comp.points_awarded == 0 + assert storage.get_child(alice.id).points == 0 + + def test_closes_the_chore_for_that_child_only(self): + coord, storage, _mod = _make_system() + now = _now() + alice, bob, chore = self._shared_chore(coord, _mod, now) + + with patch.object(_mod.dt_util, "now", return_value=now): + run(coord.async_parent_complete_chore(chore.id, alice.id)) + assert coord.get_due_chores_for_child(alice.id) == [] + assert [c.id for c in coord.get_due_chores_for_child(bob.id)] == [chore.id] + assert run(coord.async_complete_chore(chore.id, alice.id)) is None + assert run(coord.async_complete_chore(chore.id, bob.id)) is not None + + assert storage.get_child(bob.id).points == 5 + + def test_today_board_marks_only_that_child_done(self): + coord, storage, _mod = _make_system() + now = _now() + alice, bob, chore = self._shared_chore(coord, _mod, now) + + with patch.object(_mod.dt_util, "now", return_value=now): + run(coord.async_parent_complete_chore(chore.id, alice.id)) + board = coord.get_today_board() + + status = {c["child_id"]: c["chores"][0]["status"] for c in board["children"]} + assert status == {alice.id: "done", bob.id: "todo"} + + def test_recurring_chore_advances_only_that_childs_window(self): + coord, storage, _mod = _make_system() + now = _now() + alice, bob, chore = self._shared_chore(coord, _mod, now, schedule_mode="recurring", recurrence="weekly") + + with patch.object(_mod.dt_util, "now", return_value=now): + run(coord.async_parent_complete_chore(chore.id, alice.id)) + assert coord.is_chore_available_for_child(chore, alice.id) is False + assert coord.is_chore_available_for_child(chore, bob.id) is True + + assert storage.get_last_completed(chore.id, alice.id).get("current") == now.isoformat() + assert not storage.get_last_completed(chore.id, bob.id).get("current") + + def test_rejects_a_child_the_chore_is_not_assigned_to(self): + coord, storage, _mod = _make_system() + now = _now() + alice, bob, chore = self._shared_chore(coord, _mod, now) + with patch.object(_mod.dt_util, "now", return_value=now): + carol = run(coord.async_add_child("Carol")) + with pytest.raises(ValueError, match="not assigned"): + run(coord.async_parent_complete_chore(chore.id, carol.id)) + assert storage.get_completions() == [] + + @pytest.mark.parametrize("mode", ["alternating", "first_come"]) + def test_rejects_rotation_and_race_chores(self, mode): + coord, storage, _mod = _make_system() + now = _now() + alice, bob, chore = self._shared_chore(coord, _mod, now, assignment_mode=mode) + with patch.object(_mod.dt_util, "now", return_value=now): + with pytest.raises(ValueError, match="rotation"): + run(coord.async_parent_complete_chore(chore.id, alice.id)) + assert storage.get_completions() == [] + + def test_round_trips_through_storage_and_the_sensor(self): + from custom_components.taskmate.models import ChoreCompletion + from custom_components.taskmate.sensor import _build_todays_completions + + coord, storage, _mod = _make_system() + now = _now() + alice, bob, chore = self._shared_chore(coord, _mod, now) + with patch.object(_mod.dt_util, "now", return_value=now): + run(coord.async_parent_complete_chore(chore.id, alice.id)) + run(coord.async_parent_complete_chore(chore.id)) + + per_child, whole_pool = storage.get_completions() + assert ChoreCompletion.from_dict(per_child.to_dict()).for_child_id == alice.id + assert "for_child_id" not in whole_pool.to_dict() + + common = { + "child_lookup": {c.id: c for c in storage.get_children()}, + "chore_lookup": {c.id: c for c in storage.get_chores()}, + "all_completions": storage.get_completions(), + } + with patch("custom_components.taskmate.sensor.dt_util.now", return_value=now): + recs = {r["completion_id"]: r for r in _build_todays_completions(common)} + assert recs[per_child.id]["for_child_id"] == alice.id + assert "for_child_id" not in recs[whole_pool.id] diff --git a/wiki/Admin-Panel.md b/wiki/Admin-Panel.md index 9d35d294..21597699 100644 --- a/wiki/Admin-Panel.md +++ b/wiki/Admin-Panel.md @@ -101,7 +101,7 @@ Its fields are grouped under five headings: **Basics**, **Who does it**, **When* The **Chores** table collapses each row's per-row actions into a **⋮ kebab menu**. **Edit** stays inline; everything else lives in the menu: -- *Parent did it (no points)* +- *Parent did it (no points)*: marks the chore done with no points awarded. On a chore shared by several children (everyone mode), the menu shows *Parent did it for everyone* and a *Parent did it for * entry for each child, so you can clear it for one child and leave it open for the others. - *Auction an occurrence* (v6.0.0+, see [Chore Auctions](Chore-Auctions.md)) - *Deactivate / Activate chore* - *Skip* (rotation modes)