This commit is contained in:
Home Assistant Version Control
2026-08-13 19:52:24 +00:00
parent 2d1ba0c035
commit 457f19d210
46 changed files with 3333 additions and 2108 deletions
+188 -148
View File
@@ -1,4 +1,5 @@
"""Chore operations mixin for TaskMateCoordinator."""
from __future__ import annotations
import logging
@@ -26,10 +27,15 @@ def _add_months(d: date, months: int) -> date:
_DOW_MAP = {
'monday': 0, 'tuesday': 1, 'wednesday': 2, 'thursday': 3,
'friday': 4, 'saturday': 5, 'sunday': 6,
"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}
_MONTH_STEPS = {"monthly": 1, "every_3_months": 3, "every_6_months": 6}
class ChoresMixin:
@@ -124,7 +130,9 @@ class ChoresMixin:
# 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()]
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)
@@ -138,6 +146,7 @@ class ChoresMixin:
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")
@@ -164,20 +173,37 @@ class ChoresMixin:
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)
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:
# Stamp the dated override *and* the cached current child. The
# override is what every eligibility gate reads (#781); the cached
# field keeps the sensors/cards consistent without waiting for the
# next assignment pass.
chore.assignment_swap_child_id = req["requester_id"]
chore.assignment_swap_date = dt_util.as_local(dt_util.now()).date().isoformat()
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(),
})
# Consume the request, exactly as rejection does (#783). Nothing reads a
# request once it leaves "pending" — both readers filter on it — so
# keeping it would grow the store forever. The approval stays observable
# through the event below and the chore's own dated override. `req` is
# already captured, so the payload survives the removal.
self.storage.remove_swap_request(req_id)
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()
@@ -197,7 +223,8 @@ class ChoresMixin:
except (ValueError, TypeError):
_LOGGER.warning(
"Chore '%s' has an unparseable deadline_at %r — ignoring it",
getattr(chore, "name", ""), raw,
getattr(chore, "name", ""),
raw,
)
return None
# A naive value came from hand-edited storage; treat it as local time
@@ -237,7 +264,8 @@ class ChoresMixin:
changed = True
_LOGGER.info(
"Reactive chore '%s' expired (deadline %s)",
chore.name, getattr(chore, "deadline_at", ""),
chore.name,
getattr(chore, "deadline_at", ""),
)
self.hass.bus.async_fire(
"taskmate_chore_expired",
@@ -297,6 +325,8 @@ class ChoresMixin:
data["assignment_current_child_id"] = ""
data["skip_date"] = ""
data["skip_count"] = 0
data["assignment_swap_child_id"] = ""
data["assignment_swap_date"] = ""
data["publish_calendar_published_dates"] = []
data["disabled_for"] = []
data["enabled"] = True
@@ -352,9 +382,7 @@ class ChoresMixin:
await self.async_refresh()
return count
async def async_approve_chores_bulk(
self, completion_ids: list[str] | None = None
) -> int:
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
@@ -390,7 +418,7 @@ class ChoresMixin:
visibility_entity: str = "",
visibility_state: str = "on",
visibility_operator: str = "equals",
) -> list[Chore]:
) -> list[Chore]:
"""Add multiple chores at once with shared settings."""
chores = []
for name in chore_names:
@@ -412,7 +440,7 @@ class ChoresMixin:
visibility_entity=visibility_entity,
visibility_state=visibility_state,
visibility_operator=visibility_operator,
)
)
self.storage.add_chore(chore)
chores.append(chore)
@@ -453,7 +481,10 @@ class ChoresMixin:
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,
chore,
cleanup_entities,
today,
summary_prefixes=extra_prefixes,
)
self.storage.update_chore(chore)
# Replacing or clearing the picture orphans the old file; delete it —
@@ -464,9 +495,7 @@ class ChoresMixin:
await self.storage.async_save()
await self.async_refresh()
async def _async_release_image(
self, image_url: str, *, excluding_chore_id: str = ""
) -> None:
async def _async_release_image(self, image_url: str, *, excluding_chore_id: str = "") -> None:
"""Delete a chore image file, but only if nothing else still shows it.
`async_clone_chore` copies `image_url` straight from the source, so two
@@ -493,9 +522,7 @@ class ChoresMixin:
# Nothing sweeps taskmate_images, so the file has to go with the chore —
# unless a clone still shows it (#768).
if existing is not None and getattr(existing, "image_url", ""):
await self._async_release_image(
existing.image_url, excluding_chore_id=chore_id
)
await self._async_release_image(existing.image_url, excluding_chore_id=chore_id)
self.storage.remove_chore(chore_id)
self.storage.remove_completions_for_chore(chore_id)
self.storage.remove_last_completed_for_chore(chore_id)
@@ -503,6 +530,9 @@ class ChoresMixin:
self.storage.remove_chore_from_task_groups(chore_id)
# Drop queued scheduled changes (#675) — nothing left to apply them to.
self.storage.remove_scheduled_changes_for_chore(chore_id)
# Drop pending swap requests (#785), else they sit in the parent's
# approval queue forever showing "?" for the chore that no longer exists.
self.storage.remove_swap_requests_for_chore(chore_id)
# Remove chore from children's chore_order lists
for child in self.storage.get_children():
if chore_id in child.chore_order:
@@ -533,9 +563,7 @@ class ChoresMixin:
# 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"
)
raise ValueError("Cannot skip a sticky group follower; skip the leader chore instead")
pool = self._chore_assignment_pool(chore)
if len(pool) <= 1:
@@ -549,6 +577,12 @@ class ChoresMixin:
chore.skip_date = today_iso
chore.skip_count = 0
# A skip is a deliberate move of today's assignee, so it supersedes any
# approved swap — otherwise the swap override would keep winning and the
# skip would silently do nothing.
chore.assignment_swap_child_id = ""
chore.assignment_swap_date = ""
# Cyclical: A → B → … → unassigned → back to A.
# skip_count < pool_size → advance to next child
# skip_count == pool_size → unassigned (no child today)
@@ -612,9 +646,13 @@ class ChoresMixin:
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.
# Any previous skip or approved swap is wiped — manual start is an
# explicit reset, and a lingering swap override would outrank the
# child the parent just picked.
chore.skip_date = ""
chore.skip_count = 0
chore.assignment_swap_child_id = ""
chore.assignment_swap_date = ""
chore.assignment_current_child_id = child_id
@@ -623,7 +661,9 @@ class ChoresMixin:
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:
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
@@ -664,8 +704,8 @@ class ChoresMixin:
# 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':
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)",
@@ -676,16 +716,17 @@ class ChoresMixin:
# 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 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,
chore.name,
child.name,
)
return None
# Check recurrence window for Mode B chores
if getattr(chore, 'schedule_mode', 'specific_days') == 'recurring':
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)",
@@ -694,7 +735,7 @@ class ChoresMixin:
return None
# Check availability for one-shot chores
if getattr(chore, 'schedule_mode', 'specific_days') == 'one_shot':
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)",
@@ -718,11 +759,13 @@ class ChoresMixin:
if comp_dt.date() == today:
todays_completions_count += 1
daily_limit = getattr(chore, 'daily_limit', 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,
chore.name,
todays_completions_count,
daily_limit,
)
return None
@@ -781,7 +824,7 @@ class ChoresMixin:
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 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)
@@ -792,8 +835,11 @@ class ChoresMixin:
# 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, photo_url=completion.photo_url,
child.name,
chore.name,
chore.points,
completion_id=completion.id,
photo_url=completion.photo_url,
)
await self.async_refresh()
@@ -819,19 +865,17 @@ class ChoresMixin:
if not chore:
raise ValueError(f"Chore {chore_id} not found")
if not getattr(chore, 'enabled', True):
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"
)
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 []
assigned = getattr(chore, "assigned_to", []) or []
if assigned:
child_ids = list(assigned)
else:
@@ -860,8 +904,12 @@ class ChoresMixin:
# 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", ""):
if getattr(chore, "assignment_current_child_id", "") or getattr(chore, "assignment_swap_date", ""):
chore.assignment_current_child_id = ""
# Drop the swap override too, or it would re-point the column
# at the swapped-to child on the next assignment pass.
chore.assignment_swap_child_id = ""
chore.assignment_swap_date = ""
self.storage.update_chore(chore)
await self.storage.async_save()
@@ -912,9 +960,7 @@ class ChoresMixin:
for c in all_completions
)
if already_done:
raise ValueError(
f"Bonus sub-task '{subtask.name}' already completed today."
)
raise ValueError(f"Bonus sub-task '{subtask.name}' already completed today.")
completion = ChoreCompletion(
chore_id=chore_id,
@@ -960,19 +1006,24 @@ class ChoresMixin:
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
)
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
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,
child,
pts,
completion_date=comp_date,
skip_streak=is_bonus,
chore_id=completion.chore_id,
)
completion.approved = True
@@ -984,9 +1035,7 @@ class ChoresMixin:
# 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
)
await self.notifications.clear_approval("pending_chore_approval", completion_id)
self.hass.bus.async_fire(
"taskmate_chore_approved",
@@ -999,7 +1048,7 @@ class ChoresMixin:
)
# 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 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)
@@ -1031,13 +1080,17 @@ class ChoresMixin:
{"child_name": child.name, "child_id": child.id},
)
await self._celebrate(
child, "all_chores_done",
f"{child.name} finished every chore today!", tier=1,
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,
completion_id,
completion.chore_id,
completion.child_id,
)
return
_LOGGER.warning("Completion %s not found for approval", completion_id)
@@ -1082,9 +1135,7 @@ class ChoresMixin:
and c.child_id == completion.child_id
and not c.bonus_subtask_id
]
child.last_completion_date = (
max(remaining).isoformat() if remaining else None
)
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
@@ -1094,24 +1145,15 @@ class ChoresMixin:
if lost:
try:
milestones = self.parse_milestone_setting(
self.storage.get_setting(
"streak_milestones", self.DEFAULT_STREAK_MILESTONES
)
self.storage.get_setting("streak_milestones", self.DEFAULT_STREAK_MILESTONES)
)
except ValueError:
milestones = self.parse_milestone_setting(
self.DEFAULT_STREAK_MILESTONES
)
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
)
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,
@@ -1120,9 +1162,7 @@ class ChoresMixin:
created_at=dt_util.now(),
)
)
child.streak_milestones_achieved = sorted(
d for d in achieved if d <= child.current_streak
)
child.streak_milestones_achieved = sorted(d for d in achieved if d <= child.current_streak)
self.storage.update_child(child)
@@ -1133,7 +1173,8 @@ class ChoresMixin:
# 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
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
@@ -1151,13 +1192,11 @@ class ChoresMixin:
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
)
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 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
@@ -1168,14 +1207,10 @@ class ChoresMixin:
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
)
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
)
bonus_completions = self._reverse_completion_awards(target_completion, completions)
for bc in bonus_completions:
self.storage.remove_completion(bc.id)
@@ -1190,21 +1225,22 @@ class ChoresMixin:
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(),
})
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
)
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
@@ -1240,14 +1276,17 @@ class ChoresMixin:
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(),
})
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)."""
@@ -1278,16 +1317,16 @@ class ChoresMixin:
return False
# Check if chore is globally disabled (soft-disabled one-shot chores)
if not getattr(chore, 'enabled', True):
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', [])
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':
if getattr(chore, "assignment_mode", "everyone") != "everyone":
active = self._compute_active_children(chore)
if child_id not in active:
return False
@@ -1298,9 +1337,9 @@ class ChoresMixin:
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')
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
@@ -1318,7 +1357,7 @@ class ChoresMixin:
# 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 []
depends_on = getattr(chore, "depends_on", []) or []
if depends_on:
dep_today = dt_util.as_local(dt_util.now()).date()
completions = self._cached_completions()
@@ -1327,18 +1366,18 @@ class ChoresMixin:
c.chore_id == dep_id
and c.child_id == child_id
and c.approved
and not getattr(c, 'bonus_subtask_id', '')
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')
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 schedule_mode == "one_shot":
created_date = getattr(chore, "created_date", "")
if created_date:
today = dt_util.as_local(dt_util.now()).date()
try:
@@ -1348,25 +1387,25 @@ class ChoresMixin:
pass
return True
if schedule_mode != 'recurring':
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', '')
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,
"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')
current_iso = record.get("current")
if not current_iso:
# Never completed — a future recurrence anchor always defers
@@ -1377,7 +1416,7 @@ class ChoresMixin:
return False
except ValueError:
pass
if first_occurrence_mode == 'wait_for_first_occurrence' and recurrence_day:
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
@@ -1389,7 +1428,7 @@ class ChoresMixin:
return True
# every_2_days with anchor — check alignment
if recurrence == 'every_2_days' and recurrence_start:
if recurrence == "every_2_days" and recurrence_start:
try:
anchor = date.fromisoformat(recurrence_start)
days_since_anchor = (today - anchor).days
@@ -1402,7 +1441,7 @@ class ChoresMixin:
pass
# weekly/every_2_weeks with specific day — only available on that day
if recurrence_day and recurrence in ('weekly', 'every_2_weeks'):
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
@@ -1477,7 +1516,9 @@ class ChoresMixin:
changed = True
_LOGGER.info(
"Chore '%s' expired (expires_on %s, today %s)",
chore.name, expires_on, today.isoformat(),
chore.name,
expires_on,
today.isoformat(),
)
except ValueError:
continue
@@ -1492,11 +1533,11 @@ class ChoresMixin:
changed = False
for chore in self.storage.get_chores():
if getattr(chore, 'schedule_mode', 'specific_days') != 'one_shot':
if getattr(chore, "schedule_mode", "specific_days") != "one_shot":
continue
if not getattr(chore, 'enabled', True):
if not getattr(chore, "enabled", True):
continue
created_date = getattr(chore, 'created_date', '')
created_date = getattr(chore, "created_date", "")
if not created_date:
continue
try:
@@ -1506,7 +1547,9 @@ class ChoresMixin:
changed = True
_LOGGER.info(
"One-shot chore '%s' expired (created %s, today %s)",
chore.name, created_date, today.isoformat(),
chore.name,
created_date,
today.isoformat(),
)
except ValueError:
continue
@@ -1563,18 +1606,15 @@ class ChoresMixin:
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"
)
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}'"
)
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 [])