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)