"""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/.). 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()