348 files

This commit is contained in:
Home Assistant Version Control
2026-07-23 19:02:12 +00:00
parent 0ed1687503
commit 016af2e013
348 changed files with 12368 additions and 11689 deletions
+106 -3
View File
@@ -63,6 +63,8 @@ class ChoresMixin:
publish_calendar_entities: list[str] | None = None,
require_availability: bool = False,
manual_start_child_id: str = "",
deadline_at: str = "",
speed_bonus_points: int = 0,
) -> Chore:
"""Add a new chore."""
# One-shot chores: force daily_limit=1, set created_date to today
@@ -112,6 +114,8 @@ class ChoresMixin:
assignment_rotation_anchor=assignment_rotation_anchor,
publish_calendar_entities=list(publish_calendar_entities or []),
require_availability=require_availability,
deadline_at=deadline_at,
speed_bonus_points=max(0, int(speed_bonus_points or 0)),
)
# Cache today's active child so the card can show it immediately
active = self._compute_active_children(chore, today)
@@ -183,6 +187,82 @@ class ChoresMixin:
await self.storage.async_save()
await self.async_refresh()
def chore_deadline(self, chore) -> datetime | None:
"""A reactive chore's deadline as an aware datetime, or None if unset."""
raw = (getattr(chore, "deadline_at", "") or "").strip()
if not raw:
return None
try:
parsed = datetime.fromisoformat(raw)
except (ValueError, TypeError):
_LOGGER.warning(
"Chore '%s' has an unparseable deadline_at %r — ignoring it",
getattr(chore, "name", ""), raw,
)
return None
# A naive value came from hand-edited storage; treat it as local time
# rather than silently comparing across timezones.
if parsed.tzinfo is None:
return dt_util.as_local(parsed.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE))
return dt_util.as_local(parsed)
def chore_deadline_passed(self, chore, now: datetime | None = None) -> bool:
"""True when this chore had a deadline and it has already gone."""
deadline = self.chore_deadline(chore)
if deadline is None:
return False
return dt_util.as_local(now or dt_util.now()) > deadline
async def _async_expire_deadline_chores(self, refresh: bool = True) -> None:
"""Soft-disable reactive chores whose deadline has passed.
Runs on the 30s poll as well as at midnight — a 30-minute "empty the
washing machine" chore must not sit on the card until tomorrow.
``refresh=False`` is required when calling this from inside
``_async_update_data``: that *is* the refresh, and asking the
coordinator to refresh re-entrantly deadlocks it. The caller there
builds its snapshot after us anyway, and our save has already bumped
``data_version``, so the disabled chore lands in the same tick.
"""
now = dt_util.now()
changed = False
for chore in self.storage.get_chores():
if not getattr(chore, "enabled", True):
continue
if not self.chore_deadline_passed(chore, now):
continue
chore.enabled = False
self.storage.update_chore(chore)
changed = True
_LOGGER.info(
"Reactive chore '%s' expired (deadline %s)",
chore.name, getattr(chore, "deadline_at", ""),
)
self.hass.bus.async_fire(
"taskmate_chore_expired",
{
"chore_id": chore.id,
"chore_name": chore.name,
"deadline_at": getattr(chore, "deadline_at", ""),
"timestamp": now.isoformat(),
},
)
if changed:
await self.storage.async_save()
if refresh:
await self.async_refresh()
def _apply_speed_bonus(self, chore, base: int, completed_at) -> int:
"""Add a reactive chore's speed bonus when its deadline was beaten."""
bonus = int(getattr(chore, "speed_bonus_points", 0) or 0)
if bonus <= 0:
return base
deadline = self.chore_deadline(chore)
if deadline is None or dt_util.as_local(completed_at) > deadline:
return base
return base + bonus
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 ""
@@ -389,6 +469,8 @@ class ChoresMixin:
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)
# Drop queued scheduled changes (#675) — nothing left to apply them to.
self.storage.remove_scheduled_changes_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:
@@ -623,8 +705,14 @@ class ChoresMixin:
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
effective_points = self._apply_roulette_multiplier(
chore,
child_id,
self._apply_speed_bonus(
chore,
self._apply_time_adjustment(chore, self.effective_chore_points(chore), now),
now,
),
)
completion = ChoreCompletion(
@@ -671,7 +759,10 @@ 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)
await self._async_notify_pending_approval(
child.name, chore.name, chore.points,
completion_id=completion.id, photo_url=completion.photo_url,
)
await self.async_refresh()
@@ -1181,6 +1272,18 @@ class ChoresMixin:
if not self._is_visibility_entity_active(visibility_entity, visibility_state, visibility_operator):
return False
# Weather gate (#673): an outdoor chore stays hidden while the chosen
# weather entity reports unsuitable conditions. Because this sits in the
# availability path, a rained-off chore also stops counting as a
# mandatory miss and can't break a streak.
if not self._is_weather_ok_for_chore(chore):
return False
# Reactive chores (#674): once the deadline passes the chore is gone,
# even before the next sweep soft-disables it.
if self.chore_deadline_passed(chore):
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 []