Files
2026-07-08 10:43:39 -04:00

1484 lines
66 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Chore operations mixin for TaskMateCoordinator."""
from __future__ import annotations
import logging
from calendar import monthrange
from datetime import date, datetime
from typing import TYPE_CHECKING
from homeassistant.util import dt as dt_util
from . import photos
from .models import Chore, ChoreCompletion, PointsTransaction
if TYPE_CHECKING:
pass
_LOGGER = logging.getLogger(__name__)
def _add_months(d: date, months: int) -> date:
"""Step a date forward by calendar months, clamping to the month's last day."""
month_index = d.month - 1 + months
year = d.year + month_index // 12
month = month_index % 12 + 1
return date(year, month, min(d.day, monthrange(year, month)[1]))
_DOW_MAP = {
'monday': 0, 'tuesday': 1, 'wednesday': 2, 'thursday': 3,
'friday': 4, 'saturday': 5, 'sunday': 6,
}
_MONTH_STEPS = {'monthly': 1, 'every_3_months': 3, 'every_6_months': 6}
class ChoresMixin:
"""Mixin providing chore CRUD and completion logic."""
async def async_add_chore(
self,
name: str,
points: int = 10,
description: str = "",
assigned_to: list[str] | None = None,
depends_on: list[str] | None = None,
requires_approval: bool = True,
time_category: str = "anytime",
claim_allowance_minutes: int = 0,
daily_limit: int = 1,
completion_sound: str = "coin",
difficulty: str = "medium",
schedule_mode: str = "specific_days",
due_days: list[str] | None = None,
recurrence: str = "weekly",
recurrence_day: str = "",
recurrence_start: str = "",
first_occurrence_mode: str = "available_immediately",
visibility_entity: str = "",
visibility_state: str = "on",
visibility_operator: str = "equals",
created_date: str = "",
assignment_mode: str = "everyone",
assignment_rotation_anchor: str = "",
publish_calendar_entities: list[str] | None = None,
require_availability: bool = False,
manual_start_child_id: str = "",
) -> Chore:
"""Add a new chore."""
# One-shot chores: force daily_limit=1, set created_date to today
if schedule_mode == "one_shot":
daily_limit = 1
if not created_date:
created_date = dt_util.as_local(dt_util.now()).date().isoformat()
resolved_mode = (
assignment_mode
if assignment_mode in ("everyone", "alternating", "random", "balanced", "first_come", "unassigned")
else "everyone"
)
today = dt_util.as_local(dt_util.now()).date()
# Apply manual start: for alternating, reorder pool + reset anchor so the
# chosen child is day-0 of the rotation. For random/balanced, we pin the
# cached assignment below after the chore is constructed.
pool = list(assigned_to or [])
if manual_start_child_id and resolved_mode == "alternating" and manual_start_child_id in pool:
pool = [manual_start_child_id] + [c for c in pool if c != manual_start_child_id]
assignment_rotation_anchor = today.isoformat()
chore = Chore(
name=name,
points=points,
description=description,
assigned_to=pool,
depends_on=depends_on or [],
requires_approval=requires_approval,
time_category=time_category,
claim_allowance_minutes=max(0, int(claim_allowance_minutes or 0)),
daily_limit=daily_limit,
completion_sound=completion_sound,
difficulty=difficulty if difficulty in ("easy", "medium", "hard") else "medium",
schedule_mode=schedule_mode,
due_days=due_days or [],
recurrence=recurrence,
recurrence_day=recurrence_day,
recurrence_start=recurrence_start,
first_occurrence_mode=first_occurrence_mode,
visibility_entity=visibility_entity,
visibility_state=visibility_state,
visibility_operator=visibility_operator,
created_date=created_date,
assignment_mode=resolved_mode,
assignment_rotation_anchor=assignment_rotation_anchor,
publish_calendar_entities=list(publish_calendar_entities or []),
require_availability=require_availability,
)
# Cache today's active child so the card can show it immediately
active = self._compute_active_children(chore, today)
if active and chore.assignment_mode not in ("everyone", "first_come"):
chore.assignment_current_child_id = active[0]
# For random/balanced manual-start, override today's cached child so
# the parent sees the chosen child immediately.
if manual_start_child_id and resolved_mode in ("random", "balanced"):
resolved_pool = self._chore_assignment_pool(chore) if chore.assigned_to else [c.id for c in self.storage.get_children()]
if manual_start_child_id in resolved_pool:
chore.assignment_current_child_id = manual_start_child_id
self.storage.add_chore(chore)
# Publish to any configured calendars ASAP — before save so we persist the last_date stamp
await self._publish_chore_to_calendars(chore, today)
await self.storage.async_save()
await self.async_refresh()
return chore
# ── Sibling chore swaps ──────────────────────────────────────────────
async def async_request_swap(self, chore_id: str, requester_id: str) -> str:
"""A child requests to take over today's rotation assignment of a chore."""
from .models import generate_id
chore = self.get_chore(chore_id)
if not chore:
raise ValueError(f"Chore {chore_id} not found")
if getattr(chore, "assignment_mode", "everyone") in ("everyone", "unassigned"):
raise ValueError("Only rotation chores can be swapped")
requester = self.get_child(requester_id)
if not requester:
raise ValueError(f"Child {requester_id} not found")
current = getattr(chore, "assignment_current_child_id", "") or ""
if current == requester_id:
raise ValueError("Chore is already assigned to that child today")
req = {
"id": generate_id(),
"chore_id": chore_id,
"requester_id": requester_id,
"from_child_id": current,
"created_at": dt_util.now().isoformat(),
"status": "pending",
}
self.storage.add_swap_request(req)
await self.storage.async_save()
await self.async_refresh()
return req["id"]
async def async_approve_swap(self, req_id: str) -> None:
"""Approve a swap — reassign today's chore to the requester."""
req = next((r for r in self.storage.get_swap_requests()
if r.get("id") == req_id and r.get("status") == "pending"), None)
if not req:
raise ValueError(f"Swap request {req_id} not found")
chore = self.get_chore(req["chore_id"])
if chore:
chore.assignment_current_child_id = req["requester_id"]
self.storage.update_chore(chore)
self.storage.update_swap_request(req_id, status="approved")
self.hass.bus.async_fire("taskmate_swap_approved", {
"chore_id": req["chore_id"], "requester_id": req["requester_id"],
"from_child_id": req.get("from_child_id", ""),
"timestamp": dt_util.now().isoformat(),
})
await self.storage.async_save()
await self.async_refresh()
async def async_reject_swap(self, req_id: str) -> None:
"""Reject (and remove) a pending swap request."""
self.storage.remove_swap_request(req_id)
await self.storage.async_save()
await self.async_refresh()
def _apply_time_adjustment(self, chore, base: int, completed_at) -> int:
"""Apply a chore's early-bonus / late-penalty based on completion time."""
due = getattr(chore, "due_time", "") or ""
if not due:
return base
try:
dh, dm = (int(x) for x in due.split(":"))
except (ValueError, AttributeError):
return base
t = dt_util.as_local(completed_at)
if (t.hour * 60 + t.minute) <= (dh * 60 + dm):
return base + int(getattr(chore, "early_bonus", 0) or 0)
return max(0, base - int(getattr(chore, "late_penalty", 0) or 0))
async def async_clone_chore(self, chore_id: str) -> Chore:
"""Duplicate an existing chore (config only), returning the new chore.
The clone copies every configuration field but gets a fresh id, a
"(copy)" name, fresh bonus-subtask ids, and a clean runtime state
(no skip/assignment/calendar-publish carry-over, enabled, nothing
disabled-for).
"""
source = self.get_chore(chore_id)
if not source:
raise ValueError(f"Chore {chore_id} not found")
data = source.to_dict()
data.pop("id", None)
for sub in data.get("bonus_subtasks", []) or []:
sub.pop("id", None) # fresh ids for the copied subtasks
# Reset per-run / ephemeral state so the clone starts clean.
data["assignment_current_child_id"] = ""
data["skip_date"] = ""
data["skip_count"] = 0
data["publish_calendar_published_dates"] = []
data["disabled_for"] = []
data["enabled"] = True
today = dt_util.as_local(dt_util.now()).date()
if data.get("schedule_mode") == "one_shot":
data["created_date"] = today.isoformat()
clone = Chore.from_dict(data)
clone.name = f"{source.name} (copy)"
self.storage.add_chore(clone)
# Cache today's active child so the card shows it immediately.
active = self._compute_active_children(clone, today)
if active and clone.assignment_mode not in ("everyone", "first_come"):
clone.assignment_current_child_id = active[0]
await self._publish_chore_to_calendars(clone, today)
await self.storage.async_save()
await self.async_refresh()
return clone
async def async_bulk_chore_action(
self, action: str, chore_ids: list[str], assigned_to: list[str] | None = None
) -> int:
"""Apply a bulk action to several chores at once. Returns count affected.
action: "delete" | "enable" | "disable" | "reassign".
"""
ids = list(dict.fromkeys(chore_ids or []))
if not ids:
return 0
if action not in ("delete", "enable", "disable", "reassign"):
raise ValueError(f"Unknown bulk action {action}")
count = 0
for cid in ids:
chore = self.storage.get_chore(cid)
if not chore:
continue
if action == "delete":
self.storage.remove_chore(cid)
elif action == "enable":
chore.enabled = True
chore.disabled_for = []
self.storage.update_chore(chore)
elif action == "disable":
chore.enabled = False
self.storage.update_chore(chore)
elif action == "reassign":
chore.assigned_to = list(assigned_to or [])
self.storage.update_chore(chore)
count += 1
await self.storage.async_save()
await self.async_refresh()
return count
async def async_approve_chores_bulk(
self, completion_ids: list[str] | None = None
) -> int:
"""Approve several pending chore completions at once. Returns count approved.
If completion_ids is given, only those (still-pending) completions are
approved. If None/empty, every currently-unapproved completion is approved.
Reuses async_approve_chore so all award/badge/quest/celebration side-effects
match single approval exactly.
"""
pending = {c.id for c in self.storage.get_completions() if not c.approved}
if completion_ids:
# Preserve caller order, drop dupes and anything not currently pending.
targets = [cid for cid in dict.fromkeys(completion_ids) if cid in pending]
else:
targets = [c.id for c in self.storage.get_completions() if not c.approved]
count = 0
for cid in targets:
await self.async_approve_chore(cid)
count += 1
return count
async def async_add_chores_bulk(
self,
chore_names: list[str],
points: int = 10,
icon: str = "mdi:broom",
due_days: list[str] | None = None,
assigned_to: list[str] | None = None,
requires_approval: bool = True,
time_category: str = "anytime",
claim_allowance_minutes: int = 0,
daily_limit: int = 1,
schedule_mode: str = "specific_days",
completion_sound: str = "coin",
visibility_entity: str = "",
visibility_state: str = "on",
visibility_operator: str = "equals",
) -> list[Chore]:
"""Add multiple chores at once with shared settings."""
chores = []
for name in chore_names:
name = name.strip()
if not name:
continue
chore = Chore(
name=name,
points=points,
description="",
due_days=due_days or [],
assigned_to=assigned_to or [],
requires_approval=requires_approval,
time_category=time_category,
claim_allowance_minutes=max(0, int(claim_allowance_minutes or 0)),
daily_limit=daily_limit,
schedule_mode=schedule_mode,
completion_sound=completion_sound,
visibility_entity=visibility_entity,
visibility_state=visibility_state,
visibility_operator=visibility_operator,
)
self.storage.add_chore(chore)
chores.append(chore)
if chores:
await self.storage.async_save()
await self.async_refresh()
return chores
async def async_update_chore(self, chore: Chore) -> None:
"""Update a chore."""
today = dt_util.as_local(dt_util.now()).date()
# Capture pre-update state before storage is mutated below.
existing = self.storage.get_chore(chore.id)
prev_entities = list(getattr(existing, "publish_calendar_entities", []) or []) if existing else []
prev_name = (existing.name if existing else "") or ""
# Persist the incoming chore so _compute_daily_assignments sees the
# latest pool / mode / etc. when applying group policies.
self.storage.update_chore(chore)
daily = self._compute_daily_assignments(today)
if getattr(chore, "assignment_mode", "everyone") != "everyone":
chore.assignment_current_child_id = daily.get(chore.id, "")
else:
chore.assignment_current_child_id = ""
# Any edit to a chore with calendar publishing can shift which dates
# project to which child (new assignment_mode, anchor, due_days,
# recurrence, rename, time_category, entity list, etc.), so purge the
# entire horizon from both the old and new calendar sets and let the
# following publish pass re-write the projection.
new_entities = list(chore.publish_calendar_entities or [])
cleanup_entities = list({*prev_entities, *new_entities})
if cleanup_entities:
# Pass both the previous and current names so cleanup can fall back
# to summary-prefix matching when an integration's get_events
# response omits the description marker we use as primary key.
extra_prefixes = []
if prev_name and prev_name != chore.name:
extra_prefixes.append(f"{prev_name} — ")
extra_prefixes.append(f"{chore.name} — ")
await self._cleanup_chore_from_calendars(
chore, cleanup_entities, today, summary_prefixes=extra_prefixes,
)
self.storage.update_chore(chore)
await self._publish_chore_to_calendars(chore, today)
await self.storage.async_save()
await self.async_refresh()
async def async_remove_chore(self, chore_id: str) -> None:
"""Remove a chore and all associated data."""
existing = self.storage.get_chore(chore_id)
if existing is not None and getattr(existing, "publish_calendar_entities", []):
await self._cleanup_chore_from_calendars(existing)
self.storage.remove_chore(chore_id)
self.storage.remove_completions_for_chore(chore_id)
self.storage.remove_last_completed_for_chore(chore_id)
# Strip chore from any task group it belonged to.
self.storage.remove_chore_from_task_groups(chore_id)
# Remove chore from children's chore_order lists
for child in self.storage.get_children():
if chore_id in child.chore_order:
child.chore_order.remove(chore_id)
self.storage.update_child(child)
await self.storage.async_save()
await self.async_refresh()
def get_chore(self, chore_id: str) -> Chore | None:
"""Get a chore by ID."""
return self.storage.get_chore(chore_id)
async def async_skip_chore(self, chore_id: str) -> Chore:
"""Advance today's rotation pointer past the current assignee.
Ephemeral: the skip applies only today. Tomorrow's midnight refresh
clears `skip_date` / `skip_count` so the rotation resumes its normal
schedule. Skip creates no completion record; the pool-wide daily_limit
count is unaffected.
"""
chore = self.storage.get_chore(chore_id)
if not chore:
raise ValueError(f"Unknown chore: {chore_id}")
mode = getattr(chore, "assignment_mode", "everyone")
if mode == "everyone":
raise ValueError("Skip is not supported for 'everyone' assignment mode")
# Reject skipping a sticky group follower — the group would drift.
group = self.storage.get_task_group_for_chore(chore_id)
if group and group.policy == "sticky" and group.chore_ids and group.chore_ids[0] != chore_id:
raise ValueError(
"Cannot skip a sticky group follower; skip the leader chore instead"
)
pool = self._chore_assignment_pool(chore)
if len(pool) <= 1:
raise ValueError("Skip requires a rotation pool of 2 or more children")
today = dt_util.as_local(dt_util.now()).date()
today_iso = today.isoformat()
# Reset stale skip state.
if getattr(chore, "skip_date", "") != today_iso:
chore.skip_date = today_iso
chore.skip_count = 0
# Cyclical: A → B → … → unassigned → back to A.
# skip_count < pool_size → advance to next child
# skip_count == pool_size → unassigned (no child today)
# skip_count > pool_size → reset to 0, back to original assignee
if chore.skip_count >= len(pool):
chore.skip_count = 0
else:
chore.skip_count += 1
self.storage.update_chore(chore)
if chore.skip_count == len(pool):
chore.assignment_current_child_id = ""
else:
daily = self._compute_daily_assignments(today)
chore.assignment_current_child_id = daily.get(chore_id, "")
self.storage.update_chore(chore)
# Propagate to sticky followers (their cached current_child_id shifts
# when the leader shifts).
if group and group.policy == "sticky" and group.chore_ids and group.chore_ids[0] == chore_id:
for follower_id in group.chore_ids[1:]:
follower = self.storage.get_chore(follower_id)
if not follower:
continue
desired = daily.get(follower_id, "")
if getattr(follower, "assignment_current_child_id", "") != desired:
follower.assignment_current_child_id = desired
self.storage.update_chore(follower)
await self.storage.async_save()
await self.async_refresh()
return chore
async def async_set_chore_manual_start(self, chore_id: str, child_id: str) -> Chore:
"""Set the chore's rotation to start with the given child today.
- alternating: reorder `assigned_to` so the chosen child is day-0, and
reset the rotation anchor to today.
- random / balanced: override today's cached assignment only; the
deterministic hash takes over tomorrow.
"""
chore = self.storage.get_chore(chore_id)
if not chore:
raise ValueError(f"Unknown chore: {chore_id}")
mode = getattr(chore, "assignment_mode", "everyone")
if mode == "everyone":
raise ValueError("Manual start is not supported for 'everyone' assignment mode")
pool = self._chore_assignment_pool(chore)
if child_id not in pool:
raise ValueError(f"Child {child_id} is not in this chore's pool")
today = dt_util.as_local(dt_util.now()).date()
if mode == "alternating":
# Reorder only when assigned_to is explicit; fallback pool ordering
# matches storage.get_children() order, which we preserve by
# materializing assigned_to to the full pool here.
chore.assigned_to = [child_id] + [c for c in pool if c != child_id]
chore.assignment_rotation_anchor = today.isoformat()
# Any previous skip is wiped — manual start is an explicit reset.
chore.skip_date = ""
chore.skip_count = 0
chore.assignment_current_child_id = child_id
self.storage.update_chore(chore)
await self.storage.async_save()
await self.async_refresh()
return chore
async def async_complete_chore(self, chore_id: str, child_id: str, as_parent: bool = False, photo_url: str = "") -> ChoreCompletion | None:
"""Mark a chore as completed by a child.
When ``as_parent`` is True the completion auto-approves (the parent is the
approver) and points are awarded immediately, even for approval-required
chores. All eligibility validations below still apply.
Expected "soft" rejections (daily limit reached, first-come race lost,
rotation not-your-turn / already done today, chore not available yet) are
normal control flow — they log at debug and return ``None`` rather than
raising, so they don't spam the HA log at ERROR. Only genuinely bad input
(unknown chore/child) raises.
"""
chore = self.get_chore(chore_id)
if not chore:
raise ValueError(f"Chore {chore_id} not found")
child = self.get_child(child_id)
if not child:
raise ValueError(f"Child {child_id} not found")
# Security: photo_url must be one of OUR upload URLs
# (/api/taskmate/photo/<uuid>.<ext>). The service accepts it as a free
# string, so reject foreign/crafted values (e.g. "javascript:..." or an
# external https URL) before they are stored and later rendered into an
# href/src in the parent's approval views. An invalid value is dropped to
# "" — which also means it can't satisfy a require_photo chore below.
if photo_url and not photos.is_taskmate_photo_url(photo_url):
_LOGGER.warning(
"Ignoring invalid photo_url for chore %s (not a TaskMate photo URL)",
chore_id,
)
photo_url = ""
now = dt_util.now()
today = dt_util.as_local(now).date()
# Rotation modes (first_come / alternating / random / balanced) share a
# SINGLE daily quota across the whole pool. Enforce it here at completion
# time, not just in the card UI — otherwise a caller can award every pool
# member by completing the chore once per child_id (one call each).
assignment_mode = getattr(chore, 'assignment_mode', 'everyone')
if assignment_mode != 'everyone':
if self._is_rotation_done_today(chore):
_LOGGER.debug(
"complete_chore no-op: '%s' already completed today (rotation quota filled)",
chore.name,
)
return None
# A child may only complete the chore when they are today's active
# assignee. Parents (as_parent) may complete on behalf of any pool
# member — e.g. ticking it off for the off-rotation child. first_come
# keeps its competitive semantics (every pool member may race).
if not as_parent and assignment_mode != 'first_come':
if child_id not in self._compute_active_children(chore):
_LOGGER.debug(
"complete_chore no-op: '%s' not assigned to %s today",
chore.name, child.name,
)
return None
# Check recurrence window for Mode B chores
if getattr(chore, 'schedule_mode', 'specific_days') == 'recurring':
if not self.is_chore_available_for_child(chore, child_id):
_LOGGER.debug(
"complete_chore no-op: '%s' not available yet (recurrence window)",
chore.name,
)
return None
# Check availability for one-shot chores
if getattr(chore, 'schedule_mode', 'specific_days') == 'one_shot':
if not self.is_chore_available_for_child(chore, child_id):
_LOGGER.debug(
"complete_chore no-op: '%s' not available (one-shot done or expired)",
chore.name,
)
return None
# Check daily limit (only count parent completions, not bonus sub-tasks)
all_completions = self.storage.get_completions()
todays_completions_count = 0
for comp in all_completions:
if comp.chore_id == chore_id and comp.child_id == child_id and not comp.bonus_subtask_id:
comp_dt = comp.completed_at
if isinstance(comp_dt, str):
try:
comp_dt = datetime.fromisoformat(comp_dt)
except (ValueError, TypeError):
continue
if isinstance(comp_dt, datetime):
comp_dt = dt_util.as_local(comp_dt)
if comp_dt.date() == today:
todays_completions_count += 1
daily_limit = getattr(chore, 'daily_limit', 1)
if todays_completions_count >= daily_limit:
_LOGGER.debug(
"complete_chore no-op: daily limit reached for '%s' (%d/%d today)",
chore.name, todays_completions_count, daily_limit,
)
return None
# A photo-required chore always goes through parent approval (unless a
# parent is completing on behalf), so the evidence gets reviewed.
requires_photo = bool(getattr(chore, "require_photo", False))
# A child cannot complete a photo-required chore without attaching a photo.
# Enforced here (not just in the card) so the per-chore button entity,
# automations and Dev Tools can't bypass it. Parents-on-behalf are exempt
# — they are the reviewer. ValueError is surfaced as a clean
# ServiceValidationError by the service layer.
if requires_photo and not as_parent and not (photo_url or "").strip():
raise ValueError("This chore requires a photo as evidence.")
auto_approve = as_parent or (not chore.requires_approval and not requires_photo)
effective_points = self._apply_time_adjustment(
chore, self.effective_chore_points(chore), now
)
completion = ChoreCompletion(
chore_id=chore_id,
child_id=child_id,
completed_at=now,
approved=auto_approve,
points_awarded=effective_points if auto_approve else 0,
photo_url=photo_url or "",
)
if auto_approve:
total_awarded = await self._award_points(child, effective_points, chore_id=chore_id)
completion.approved = True
completion.approved_at = dt_util.now()
completion.points_awarded = total_awarded
self.storage.add_completion(completion)
self.hass.bus.async_fire(
"taskmate_chore_completed",
{
"child_id": child.id,
"child_name": child.name,
"chore_id": chore.id,
"chore_name": chore.name,
"points": effective_points,
"difficulty": getattr(chore, "difficulty", "medium"),
"timestamp": dt_util.now().isoformat(),
},
)
# Update last_completed store (window starts at completion time, midnight-rounded)
self.storage.set_last_completed(chore_id, child_id, now.isoformat())
# One-shot: if auto-approved, disable for this child immediately
if getattr(chore, 'schedule_mode', 'specific_days') == 'one_shot' and auto_approve:
if child_id not in chore.disabled_for:
chore.disabled_for.append(child_id)
self._check_one_shot_fully_disabled(chore)
self.storage.update_chore(chore)
await self.storage.async_save()
# Fire approval notification only if it stays pending
if not auto_approve:
await self._async_notify_pending_approval(child.name, chore.name, chore.points, completion_id=completion.id)
await self.async_refresh()
# Trigger badge evaluation for anything that auto-approved
if auto_approve and getattr(self, "badges", None):
await self.badges.evaluate_for_child(child_id, "manual")
# Auto-approved completions skip the parent-approval path, so they must
# run the same post-approval progression hooks here — otherwise quest
# steps and challenges only advance for approval-required chores (#558).
if auto_approve:
if hasattr(self, "_async_advance_quests"):
await self._async_advance_quests(child_id, chore_id)
if hasattr(self, "_async_evaluate_challenges"):
await self._async_evaluate_challenges(child_id)
return completion
async def async_parent_complete_chore(self, chore_id: str) -> ChoreCompletion:
"""Mark a chore as completed by the parent — zero points, advances recurrence."""
chore = self.get_chore(chore_id)
if not chore:
raise ValueError(f"Chore {chore_id} not found")
if not getattr(chore, 'enabled', True):
raise ValueError(f"Chore '{chore.name}' is disabled")
schedule_mode = getattr(chore, 'schedule_mode', 'specific_days')
if schedule_mode == 'one_shot':
raise ValueError(
f"Chore '{chore.name}' is a one-shot chore and cannot be parent-completed"
)
now = dt_util.now()
# Determine child pool — empty assigned_to means all children
assigned = getattr(chore, 'assigned_to', []) or []
if assigned:
child_ids = list(assigned)
else:
child_ids = [c.id for c in self.storage.get_children()]
if not child_ids:
raise ValueError(f"Chore '{chore.name}' has no children to suppress")
completion = ChoreCompletion(
chore_id=chore_id,
child_id="__parent__",
completed_at=now,
approved=True,
approved_at=now,
points_awarded=0,
)
self.storage.add_completion(completion)
# Update last_completed for ALL children in the pool so the chore
# disappears from their views until the next recurrence window.
for cid in child_ids:
self.storage.set_last_completed(chore_id, cid, now.isoformat())
# Clear today's cached current assignee for rotation-mode chores so
# the "Current" column / child-stats card stops pointing at the
# original child. The pointer recomputes at the next midnight refresh.
if getattr(chore, "assignment_mode", "everyone") != "everyone":
if getattr(chore, "assignment_current_child_id", ""):
chore.assignment_current_child_id = ""
self.storage.update_chore(chore)
await self.storage.async_save()
await self.async_refresh()
return completion
async def async_complete_bonus_subtask(
self, chore_id: str, bonus_subtask_id: str, child_id: str
) -> ChoreCompletion:
"""Complete a bonus sub-task (only available after parent chore is completed today)."""
chore = self.get_chore(chore_id)
if not chore:
raise ValueError(f"Chore {chore_id} not found")
subtask = next((b for b in chore.bonus_subtasks if b.id == bonus_subtask_id), None)
if not subtask:
raise ValueError(f"Bonus sub-task {bonus_subtask_id} not found on chore '{chore.name}'")
child = self.get_child(child_id)
if not child:
raise ValueError(f"Child {child_id} not found")
now = dt_util.now()
today = dt_util.as_local(now).date()
# Gate check: parent chore must be completed today
all_completions = self.storage.get_completions()
parent_done_today = any(
c.chore_id == chore_id
and c.child_id == child_id
and not c.bonus_subtask_id
and dt_util.as_local(c.completed_at).date() == today
for c in all_completions
)
if not parent_done_today:
raise ValueError(
f"Cannot complete bonus sub-task '{subtask.name}' — "
f"parent chore '{chore.name}' must be completed first today."
)
# Duplicate check: bonus sub-task not already completed today
already_done = any(
c.chore_id == chore_id
and c.child_id == child_id
and c.bonus_subtask_id == bonus_subtask_id
and dt_util.as_local(c.completed_at).date() == today
for c in all_completions
)
if already_done:
raise ValueError(
f"Bonus sub-task '{subtask.name}' already completed today."
)
completion = ChoreCompletion(
chore_id=chore_id,
child_id=child_id,
completed_at=now,
approved=not chore.requires_approval,
points_awarded=subtask.points if not chore.requires_approval else 0,
bonus_subtask_id=bonus_subtask_id,
)
if not chore.requires_approval:
total_awarded = await self._award_points(child, subtask.points, skip_streak=True)
completion.approved = True
completion.approved_at = dt_util.now()
completion.points_awarded = total_awarded
self.storage.add_completion(completion)
await self.storage.async_save()
if chore.requires_approval:
await self._async_notify_pending_approval(
child.name, f"{chore.name} {subtask.name}", subtask.points, completion_id=completion.id
)
await self.async_refresh()
return completion
async def async_approve_chore(self, completion_id: str) -> None:
"""Approve a chore completion."""
completions = self.storage.get_completions()
for completion in completions:
if completion.id == completion_id:
if completion.approved:
_LOGGER.debug(
"Completion %s already approved, ignoring duplicate approval",
completion_id,
)
return
chore = self.get_chore(completion.chore_id)
child = self.get_child(completion.child_id)
if chore and child:
comp_date = dt_util.as_local(completion.completed_at).date()
is_bonus = bool(completion.bonus_subtask_id)
if is_bonus:
subtask = next(
(b for b in chore.bonus_subtasks if b.id == completion.bonus_subtask_id), None
)
pts = subtask.points if subtask else 0
elif completion.timed_duration_seconds > 0 and chore.task_type == "timed":
rate_seconds = chore.timed_rate_minutes * 60
pts = (completion.timed_duration_seconds // rate_seconds) * chore.timed_rate_points if rate_seconds > 0 else 0
else:
pts = self._apply_time_adjustment(
chore, self.effective_chore_points(chore), completion.completed_at
)
total_awarded = await self._award_points(
child, pts, completion_date=comp_date, skip_streak=is_bonus,
chore_id=completion.chore_id,
)
completion.approved = True
completion.approved_at = dt_util.now()
completion.points_awarded = total_awarded
self.storage.update_completion(completion)
# Dismiss the mobile approval push now this completion is
# reviewed (covers single approve AND "approve all", which
# reuses this method per completion).
if getattr(self, "notifications", None):
await self.notifications.clear_approval(
"pending_chore_approval", completion_id
)
self.hass.bus.async_fire(
"taskmate_chore_approved",
{
"child_id": completion.child_id,
"chore_id": completion.chore_id,
"completion_id": completion.id,
"timestamp": dt_util.now().isoformat(),
},
)
# One-shot: disable for this child on approval (parent completions only)
if not is_bonus and getattr(chore, 'schedule_mode', 'specific_days') == 'one_shot':
if completion.child_id not in chore.disabled_for:
chore.disabled_for.append(completion.child_id)
self._check_one_shot_fully_disabled(chore)
self.storage.update_chore(chore)
await self.storage.async_save()
await self.async_refresh()
# Trigger badge evaluation after approval awards points/chore count/streak
if getattr(self, "badges", None):
await self.badges.evaluate_for_child(completion.child_id, "manual")
# Advance any chore-chain quests (parent completions only)
if not is_bonus and hasattr(self, "_async_advance_quests"):
await self._async_advance_quests(completion.child_id, completion.chore_id)
# Evaluate daily/weekly challenges (parent completions only)
if not is_bonus and hasattr(self, "_async_evaluate_challenges"):
await self._async_evaluate_challenges(completion.child_id)
# All-chores-done celebration — fire once per child per day
today_iso = dt_util.now().date().isoformat()
flag_key = f"all_done_{child.id}_{today_iso}"
flags = self.storage._data.setdefault("all_done_flags", {})
if flag_key not in flags and not self.notifications._has_outstanding_chores_today(child.id):
flags[flag_key] = True
await self.notifications.fire(
"all_chores_done",
{"child_name": child.name, "child_id": child.id},
)
await self._celebrate(
child, "all_chores_done",
f"{child.name} finished every chore today!", tier=1,
)
else:
_LOGGER.warning(
"Cannot approve completion %s: chore (%s) or child (%s) not found",
completion_id, completion.chore_id, completion.child_id,
)
return
_LOGGER.warning("Completion %s not found for approval", completion_id)
def _reverse_completion_awards(self, target_completion, completions) -> list:
"""Reverse every point/streak/counter award granted by an approved completion.
Shared by ``async_reject_chore`` (which then deletes the record) and
``async_undo_chore_approval`` (which reverts it to pending). Performs the
child-stat reversal, the bonus-subtask cascade, the recurrence-window
reset and one-shot re-enable, and **returns the cascaded bonus-subtask
completions** so the caller can dispose of them as it sees fit. It does
NOT remove or modify the completion records themselves.
"""
completion = target_completion
if completion.points_awarded > 0:
child = self.get_child(completion.child_id)
if child:
child.points = max(0, child.points - completion.points_awarded)
child.total_points_earned = max(0, child.total_points_earned - completion.points_awarded)
child.total_chores_completed = max(0, child.total_chores_completed - 1)
# Only reverse streak for parent completions, and only
# when this was the child's sole completion that day —
# streaks are day-based, not per-completion
if not completion.bonus_subtask_id:
reject_date = dt_util.as_local(completion.completed_at).date()
other_same_day = any(
c.id != completion.id
and c.child_id == completion.child_id
and not c.bonus_subtask_id
and dt_util.as_local(c.completed_at).date() == reject_date
for c in completions
)
if not other_same_day:
child.current_streak = max(0, child.current_streak - 1)
if getattr(child, "last_completion_date", None) == reject_date.isoformat():
remaining = [
dt_util.as_local(c.completed_at).date()
for c in completions
if c.id != completion.id
and c.child_id == completion.child_id
and not c.bonus_subtask_id
]
child.last_completion_date = (
max(remaining).isoformat() if remaining else None
)
# Reverse any streak milestones this completion unlocked.
# Milestone bonuses are logged as separate transactions
# (not part of points_awarded), so dropping the streak
# below a previously-achieved milestone must refund it.
achieved = list(child.streak_milestones_achieved or [])
lost = [d for d in achieved if d > child.current_streak]
if lost:
try:
milestones = self.parse_milestone_setting(
self.storage.get_setting(
"streak_milestones", self.DEFAULT_STREAK_MILESTONES
)
)
except ValueError:
milestones = self.parse_milestone_setting(
self.DEFAULT_STREAK_MILESTONES
)
refund = sum(milestones.get(d, 0) for d in lost)
if refund > 0:
child.points = max(0, child.points - refund)
child.total_points_earned = max(
0, child.total_points_earned - refund
)
child.career_score = (
child.total_points_earned
- child.total_penalties_received
)
self.storage.add_points_transaction(
PointsTransaction(
child_id=child.id,
points=-refund,
reason="Streak milestone reversed",
created_at=dt_util.now(),
)
)
child.streak_milestones_achieved = sorted(
d for d in achieved if d <= child.current_streak
)
self.storage.update_child(child)
bonus_completions: list = []
is_parent = not target_completion.bonus_subtask_id
if is_parent:
# Cascade: reverse any bonus sub-task completions for the same
# chore/child on the same day (caller disposes of the records).
comp_date = dt_util.as_local(target_completion.completed_at).date()
bonus_completions = [
c for c in completions
if c.chore_id == target_completion.chore_id
and c.child_id == target_completion.child_id
and c.bonus_subtask_id
and c.id != target_completion.id
and dt_util.as_local(c.completed_at).date() == comp_date
]
if bonus_completions:
child = self.get_child(target_completion.child_id)
for bc in bonus_completions:
if bc.points_awarded > 0 and child:
child.points = max(0, child.points - bc.points_awarded)
child.total_points_earned = max(0, child.total_points_earned - bc.points_awarded)
child.total_chores_completed = max(0, child.total_chores_completed - 1)
if child:
self.storage.update_child(child)
# Undo last_completed store so recurrence window resets correctly
self.storage.undo_last_completed(
target_completion.chore_id, target_completion.child_id
)
# One-shot: re-enable for this child
chore = self.get_chore(target_completion.chore_id)
if chore and getattr(chore, 'schedule_mode', 'specific_days') == 'one_shot':
if target_completion.child_id in chore.disabled_for:
chore.disabled_for.remove(target_completion.child_id)
chore.enabled = True
self.storage.update_chore(chore)
return bonus_completions
async def async_reject_chore(self, completion_id: str) -> None:
"""Reject a chore completion and fully reverse all awards if already granted."""
completions = self.storage.get_completions()
target_completion = next(
(c for c in completions if c.id == completion_id), None
)
if target_completion:
bonus_completions = self._reverse_completion_awards(
target_completion, completions
)
for bc in bonus_completions:
self.storage.remove_completion(bc.id)
self.storage.remove_completion(completion_id)
# The completion record is gone, so its evidence photo is now orphaned —
# delete it (best-effort; foreign/blank URLs are ignored).
if target_completion and getattr(target_completion, "photo_url", ""):
await photos.async_delete_photo(self.hass, target_completion.photo_url)
await self.storage.async_save()
await self.async_refresh()
if target_completion:
child = self.get_child(target_completion.child_id)
chore = self.get_chore(target_completion.chore_id)
self.hass.bus.async_fire("taskmate_chore_rejected", {
"child_id": target_completion.child_id,
"child_name": getattr(child, "name", ""),
"chore_id": target_completion.chore_id,
"chore_name": getattr(chore, "name", ""),
"completion_id": completion_id,
"timestamp": dt_util.now().isoformat(),
})
# Dismiss the mobile approval push for this reviewed completion. Also
# covers undoing an already-approved chore (whose push was cleared at
# approval): re-clearing a stale tag is a harmless no-op.
if getattr(self, "notifications", None):
await self.notifications.clear_approval(
"pending_chore_approval", completion_id
)
async def async_undo_chore_approval(self, completion_id: str) -> None:
"""Undo an accidental approval: reverse the awards and return the
completion to *pending* so it re-enters the approval queue.
Distinct from reject (which deletes the child's submission): undo treats
the completion as still valid, just not-yet-approved. It does NOT notify
the child — it is a parent correcting their own slip. Derived
weekend/streak bonuses and earned badges are not separately reversed
(same limitation as reject — see audit ERR-3).
"""
completions = self.storage.get_completions()
target = next((c for c in completions if c.id == completion_id), None)
if not target:
raise ValueError(f"Completion {completion_id} not found")
if not target.approved:
raise ValueError("Completion is not approved; nothing to undo")
bonus_completions = self._reverse_completion_awards(target, completions)
for bc in bonus_completions:
bc.approved = False
bc.approved_at = None
bc.points_awarded = 0
self.storage.update_completion(bc)
target.approved = False
target.approved_at = None
target.points_awarded = 0
self.storage.update_completion(target)
await self.storage.async_save()
await self.async_refresh()
child = self.get_child(target.child_id)
chore = self.get_chore(target.chore_id)
self.hass.bus.async_fire("taskmate_chore_approval_undone", {
"child_id": target.child_id,
"child_name": getattr(child, "name", ""),
"chore_id": target.chore_id,
"chore_name": getattr(chore, "name", ""),
"completion_id": completion_id,
"timestamp": dt_util.now().isoformat(),
})
def _check_one_shot_fully_disabled(self, chore) -> None:
"""Check if a one-shot chore should be fully disabled (all children done)."""
if chore.assigned_to:
target_children = set(chore.assigned_to)
else:
# assigned_to=[] means all children
target_children = {c.id for c in self.storage.get_children()}
if target_children and target_children.issubset(set(chore.disabled_for)):
chore.enabled = False
def is_chore_available_for_child(self, chore, child_id: str) -> 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.
"""
# Vacation / pause mode: while away, all chores are hidden. Covers the
# global static range, the family vacation calendar, and this child's
# own availability sensor (per-child vacation).
if self._is_child_on_vacation(self.storage.get_child(child_id)):
return False
# Check if chore is globally disabled (soft-disabled one-shot chores)
if not getattr(chore, 'enabled', True):
return False
# Check per-child disabling (one-shot chores completed by this child)
disabled_for = getattr(chore, 'disabled_for', [])
if child_id in disabled_for:
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)
if child_id not in active:
return False
# Once anyone in the rotation pool has completed it today (e.g. a
# parent ticked it off for the off-rotation child), the chore is
# done for the whole pool — including today's active child.
if self._is_rotation_done_today(chore):
return False
# Check visibility entity first — if not visible, chore is not available
visibility_entity = getattr(chore, 'visibility_entity', '')
visibility_state = getattr(chore, 'visibility_state', 'on')
visibility_operator = getattr(chore, 'visibility_operator', 'equals')
if not self._is_visibility_entity_active(visibility_entity, visibility_state, visibility_operator):
return False
# Chore dependencies (FEAT-1): this chore unlocks only once every chore
# it depends on has an approved completion today by this same child.
depends_on = getattr(chore, 'depends_on', []) or []
if depends_on:
dep_today = dt_util.as_local(dt_util.now()).date()
completions = self._cached_completions()
for dep_id in depends_on:
satisfied = any(
c.chore_id == dep_id
and c.child_id == child_id
and c.approved
and not getattr(c, 'bonus_subtask_id', '')
and dt_util.as_local(c.completed_at).date() == dep_today
for c in completions
)
if not satisfied:
return False
schedule_mode = getattr(chore, 'schedule_mode', 'specific_days')
# One-shot chores: only available on the day they were created
if schedule_mode == 'one_shot':
created_date = getattr(chore, 'created_date', '')
if created_date:
today = dt_util.as_local(dt_util.now()).date()
try:
if date.fromisoformat(created_date) != today:
return False
except ValueError:
pass
return True
if schedule_mode != 'recurring':
return True
recurrence = getattr(chore, 'recurrence', 'weekly')
first_occurrence_mode = getattr(chore, 'first_occurrence_mode', 'available_immediately')
recurrence_day = getattr(chore, 'recurrence_day', '')
recurrence_start = getattr(chore, 'recurrence_start', '')
now = dt_util.now()
today = dt_util.as_local(now).date()
window_days = {
'every_2_days': 2,
'weekly': 7,
'every_2_weeks': 14,
}.get(recurrence, 7)
record = self.storage.get_last_completed(chore.id, child_id)
current_iso = record.get('current')
if not current_iso:
# Never completed — a future recurrence anchor always defers
# availability, regardless of first_occurrence_mode
if recurrence_start:
try:
if date.fromisoformat(recurrence_start) > today:
return False
except ValueError:
pass
if first_occurrence_mode == 'wait_for_first_occurrence' and recurrence_day:
target_dow = _DOW_MAP.get(recurrence_day.lower())
if target_dow is not None and today.weekday() != target_dow:
return False
return True
try:
last_dt = date.fromisoformat(current_iso[:10])
except (ValueError, TypeError):
return True
# every_2_days with anchor — check alignment
if recurrence == 'every_2_days' and recurrence_start:
try:
anchor = date.fromisoformat(recurrence_start)
days_since_anchor = (today - anchor).days
if days_since_anchor < 0:
return False
if days_since_anchor % 2 != 0:
return False
return last_dt < today
except ValueError:
pass
# weekly/every_2_weeks with specific day — only available on that day
if recurrence_day and recurrence in ('weekly', 'every_2_weeks'):
target_dow = _DOW_MAP.get(recurrence_day.lower())
if target_dow is not None and today.weekday() != target_dow:
return False
# Month-based recurrences use real calendar months so availability
# matches the calendar projection (a "monthly" chore re-opens on the
# same day next month, not after a fixed 30 days)
month_steps = _MONTH_STEPS.get(recurrence)
if month_steps:
return today >= _add_months(last_dt, month_steps)
days_since = (today - last_dt).days
return days_since >= window_days
def get_due_chores_for_child(self, child_id: str) -> list:
"""Chores this child should still act on today (their outstanding to-dos).
The single backend definition matching what the child card surfaces:
assigned to the child, scheduled/available today, and not yet completed
up to the daily limit. ``is_chore_available_for_child`` already covers
enabled/vacation/disabled_for/rotation/visibility/recurrence; on top of
that this adds the everyone-mode ``assigned_to`` membership, the
``specific_days`` ``due_days`` filter, and the completed-today cap.
Used by the todo platform (and reusable by cards/automations).
"""
today = dt_util.as_local(dt_util.now()).date()
dow = ("monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday")[today.weekday()]
out = []
with self.availability_build_scope():
completions = self._cached_completions()
for chore in self.storage.get_chores():
if not self.is_chore_available_for_child(chore, child_id):
continue
assigned = getattr(chore, "assigned_to", []) or []
if assigned and child_id not in assigned:
continue
if getattr(chore, "schedule_mode", "specific_days") == "specific_days":
due_days = getattr(chore, "due_days", []) or []
if due_days and dow not in due_days:
continue
limit = getattr(chore, "daily_limit", 1) or 1
done = 0
for c in completions:
if c.chore_id != chore.id or c.child_id != child_id:
continue
if getattr(c, "bonus_subtask_id", ""):
continue
try:
if dt_util.as_local(c.completed_at).date() == today:
done += 1
except (AttributeError, TypeError, ValueError):
continue
if done >= limit:
continue
out.append(chore)
return out
async def _async_expire_dated_chores(self) -> None:
"""Soft-disable any enabled chore whose expires_on date has passed."""
today = dt_util.as_local(dt_util.now()).date()
changed = False
for chore in self.storage.get_chores():
if not getattr(chore, "enabled", True):
continue
expires_on = getattr(chore, "expires_on", "")
if not expires_on:
continue
try:
if date.fromisoformat(expires_on) < today:
chore.enabled = False
self.storage.update_chore(chore)
changed = True
_LOGGER.info(
"Chore '%s' expired (expires_on %s, today %s)",
chore.name, expires_on, today.isoformat(),
)
except ValueError:
continue
if changed:
await self.storage.async_save()
await self.async_refresh()
async def _async_expire_one_shot_chores(self) -> None:
"""Soft-disable one-shot chores whose created_date is before today."""
today = dt_util.as_local(dt_util.now()).date()
changed = False
for chore in self.storage.get_chores():
if getattr(chore, 'schedule_mode', 'specific_days') != 'one_shot':
continue
if not getattr(chore, 'enabled', True):
continue
created_date = getattr(chore, 'created_date', '')
if not created_date:
continue
try:
if date.fromisoformat(created_date) < today:
chore.enabled = False
self.storage.update_chore(chore)
changed = True
_LOGGER.info(
"One-shot chore '%s' expired (created %s, today %s)",
chore.name, created_date, today.isoformat(),
)
except ValueError:
continue
if changed:
await self.storage.async_save()
await self.async_refresh()
async def async_set_chore_order(self, child_id: str, chore_order: list[str]) -> None:
"""Set the chore order for a child."""
child = self.get_child(child_id)
if not child:
raise ValueError(f"Child {child_id} not found")
child.chore_order = chore_order
self.storage.update_child(child)
await self.storage.async_save()
await self.async_refresh()
async def async_set_global_chore_order(self, chore_order: list[str]) -> None:
"""Set the global chore display order."""
self.storage.set_chore_display_order(chore_order)
await self.storage.async_save()
await self.async_refresh()
# Task group operations
def get_task_groups(self):
"""Return all task groups."""
return self.storage.get_task_groups()
def get_task_group(self, group_id: str):
"""Return a task group by ID."""
return self.storage.get_task_group(group_id)
def get_task_group_for_chore(self, chore_id: str):
"""Return the (at most one) task group containing the given chore."""
return self.storage.get_task_group_for_chore(chore_id)
def _validate_task_group_members(self, chore_ids: list[str], exclude_group_id: str = "") -> None:
"""Raise ValueError if any chore can't legally join a group.
Rules:
- chore must exist.
- chore.assignment_mode must be a rotation mode (not 'everyone').
- chore must not already belong to a different group.
"""
seen: set[str] = set()
for chore_id in chore_ids:
if chore_id in seen:
raise ValueError(f"Duplicate chore in group: {chore_id}")
seen.add(chore_id)
chore = self.storage.get_chore(chore_id)
if not chore:
raise ValueError(f"Unknown chore: {chore_id}")
if getattr(chore, "assignment_mode", "everyone") == "everyone":
raise ValueError(
f"Chore '{chore.name}' uses 'everyone' mode and cannot join a group"
)
existing_group = self.storage.get_task_group_for_chore(chore_id)
if existing_group and existing_group.id != exclude_group_id:
raise ValueError(
f"Chore '{chore.name}' already belongs to group '{existing_group.name}'"
)
async def async_add_task_group(self, name: str, policy: str, chore_ids: list[str] | None = None):
"""Create a task group."""
from .models import TaskGroup
if policy not in ("sticky", "spread"):
raise ValueError(f"Unknown task group policy: {policy}")
chore_ids = list(chore_ids or [])
self._validate_task_group_members(chore_ids)
group = TaskGroup(name=name, policy=policy, chore_ids=chore_ids)
self.storage.add_task_group(group)
await self.storage.async_save()
await self.async_refresh()
return group
async def async_update_task_group(
self,
group_id: str,
name: str | None = None,
policy: str | None = None,
chore_ids: list[str] | None = None,
):
"""Update an existing task group."""
group = self.storage.get_task_group(group_id)
if not group:
raise ValueError(f"Unknown task group: {group_id}")
if policy is not None:
if policy not in ("sticky", "spread"):
raise ValueError(f"Unknown task group policy: {policy}")
group.policy = policy
if name is not None:
group.name = name
if chore_ids is not None:
new_chore_ids = list(chore_ids)
self._validate_task_group_members(new_chore_ids, exclude_group_id=group_id)
group.chore_ids = new_chore_ids
self.storage.update_task_group(group)
await self.storage.async_save()
await self.async_refresh()
return group
async def async_remove_task_group(self, group_id: str) -> None:
"""Delete a task group."""
self.storage.remove_task_group(group_id)
await self.storage.async_save()
await self.async_refresh()