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
+149 -119
View File
@@ -1,4 +1,5 @@
"""Points operations mixin for TaskMateCoordinator."""
from __future__ import annotations
import logging
@@ -72,7 +73,7 @@ class PointsMixin:
return req
for child in children:
awarded_weeks = list(getattr(child, 'awarded_perfect_weeks', None) or [])
awarded_weeks = list(getattr(child, "awarded_perfect_weeks", None) or [])
# Skip if already awarded for this week
if week_key in awarded_weeks:
@@ -100,9 +101,7 @@ class PointsMixin:
# counts only when EVERY chore due that day was done — not just one.
if all_mode and derived_dates:
satisfied = all(
self._all_due_chores_done(
child.id, date.fromisoformat(d), include_rotation=False
)
self._all_due_chores_done(child.id, date.fromisoformat(d), include_rotation=False)
for d in derived_dates
)
else:
@@ -113,9 +112,7 @@ class PointsMixin:
child.points += perfect_week_bonus
child.total_points_earned += perfect_week_bonus
child.career_score = child.total_points_earned - child.total_penalties_received
self.storage.append_career_score_snapshot(
child.id, today.isoformat(), child.career_score
)
self.storage.append_career_score_snapshot(child.id, today.isoformat(), child.career_score)
self.storage.update_child(child)
transaction = PointsTransaction(
@@ -140,13 +137,17 @@ class PointsMixin:
if getattr(self, "badges", None):
await self.badges.evaluate_for_child(child.id, "perfect_week")
await self._celebrate(
child, "perfect_week",
child,
"perfect_week",
f"{child.name} earned a perfect week — +{perfect_week_bonus}!",
tier=3, extra={"bonus": perfect_week_bonus},
tier=3,
extra={"bonus": perfect_week_bonus},
)
_LOGGER.info(
"Perfect week bonus (%d pts) awarded to %s for week of %s",
perfect_week_bonus, child.name, week_key,
perfect_week_bonus,
child.name,
week_key,
)
if changed:
@@ -213,17 +214,13 @@ class PointsMixin:
if streak_mode == "pause" or getattr(child, "streak_paused", False):
child.streak_paused = True
_LOGGER.info(
"Streak paused for %s (last completion: %s, mode=%s)",
child.name, last_date_str, streak_mode
"Streak paused for %s (last completion: %s, mode=%s)", child.name, last_date_str, streak_mode
)
else:
# Default: reset to 0
child.current_streak = 0
child.streak_paused = False
_LOGGER.info(
"Streak reset for %s (last completion: %s, mode=reset)",
child.name, last_date_str
)
_LOGGER.info("Streak reset for %s (last completion: %s, mode=reset)", child.name, last_date_str)
self.storage.update_child(child)
changed = True
@@ -243,9 +240,7 @@ class PointsMixin:
child.career_score = child.total_points_earned - child.total_penalties_received
await self._maybe_level_up(child)
self.storage.update_child(child)
self.storage.append_career_score_snapshot(
child_id, date.today().isoformat(), child.career_score
)
self.storage.append_career_score_snapshot(child_id, date.today().isoformat(), child.career_score)
# Log the manual transaction
transaction = PointsTransaction(
child_id=child_id,
@@ -279,9 +274,7 @@ class PointsMixin:
if reason.startswith("Penalty: "):
child.total_penalties_received += actual_deducted
child.career_score = child.total_points_earned - child.total_penalties_received
self.storage.append_career_score_snapshot(
child_id, date.today().isoformat(), child.career_score
)
self.storage.append_career_score_snapshot(child_id, date.today().isoformat(), child.career_score)
self.storage.update_child(child)
# Log the manual transaction (negative points)
transaction = PointsTransaction(
@@ -338,9 +331,7 @@ class PointsMixin:
if reason.startswith("Gift to ") or reason.startswith("Gift from "):
link_id = getattr(target, "link_id", "") or ""
if not link_id:
raise ValueError(
"This gift predates undo support and can't be reversed automatically."
)
raise ValueError("This gift predates undo support and can't be reversed automatically.")
for leg in [t for t in txns if (getattr(t, "link_id", "") or "") == link_id]:
leg_child = self.get_child(leg.child_id)
if leg_child:
@@ -368,9 +359,7 @@ class PointsMixin:
# points, so nothing else to reverse.
child.career_score = child.total_points_earned - child.total_penalties_received
self.storage.update_child(child)
self.storage.append_career_score_snapshot(
child.id, dt_util.now().date().isoformat(), child.career_score
)
self.storage.append_career_score_snapshot(child.id, dt_util.now().date().isoformat(), child.career_score)
self.storage.remove_points_transaction(transaction_id)
await self.storage.async_save()
await self.async_refresh()
@@ -394,9 +383,7 @@ class PointsMixin:
lvl = xp // step + 1
return {"level": lvl, "progress": xp - (lvl - 1) * step, "target": step}
async def _celebrate(
self, child, kind: str, message: str, tier: int = 1, extra: dict | None = None
) -> None:
async def _celebrate(self, child, kind: str, message: str, tier: int = 1, extra: dict | None = None) -> None:
"""Central celebration funnel for notable moments.
Always fires a single ``taskmate_celebration`` event carrying a ``tier``
@@ -452,17 +439,30 @@ class PointsMixin:
if new < old:
return # earned total dropped (e.g. undo); resync quietly
for lvl in range(old + 1, new + 1):
self.hass.bus.async_fire("taskmate_level_up", {
"child_id": child.id, "child_name": child.name,
"level": lvl, "timestamp": dt_util.now().isoformat(),
})
self.hass.bus.async_fire(
"taskmate_level_up",
{
"child_id": child.id,
"child_name": child.name,
"level": lvl,
"timestamp": dt_util.now().isoformat(),
},
)
if getattr(self, "notifications", None):
await self.notifications.fire("level_up", {
"child_name": child.name, "child_id": child.id, "level": lvl,
})
await self.notifications.fire(
"level_up",
{
"child_name": child.name,
"child_id": child.id,
"level": lvl,
},
)
await self._celebrate(
child, "level_up", f"{child.name} reached level {lvl}!",
tier=3 if lvl % 5 == 0 else 2, extra={"level": lvl},
child,
"level_up",
f"{child.name} reached level {lvl}!",
tier=3 if lvl % 5 == 0 else 2,
extra={"level": lvl},
)
async def async_gift_points(self, from_child_id: str, to_child_id: str, points: int) -> None:
@@ -481,9 +481,7 @@ class PointsMixin:
if not sender or not recipient:
raise ValueError("Sender or recipient not found")
if (sender.points or 0) < points:
raise ValueError(
f"Not enough points: {sender.name} has {sender.points}, gift {points}"
)
raise ValueError(f"Not enough points: {sender.name} has {sender.points}, gift {points}")
now = dt_util.now()
sender.points -= points
recipient.points += points
@@ -491,19 +489,35 @@ class PointsMixin:
self.storage.update_child(recipient)
# Shared link_id so undo can reverse both legs together.
gift_link = generate_id()
self.storage.add_points_transaction(PointsTransaction(
child_id=sender.id, points=-points,
reason=f"Gift to {recipient.name}", created_at=now, link_id=gift_link,
))
self.storage.add_points_transaction(PointsTransaction(
child_id=recipient.id, points=points,
reason=f"Gift from {sender.name}", created_at=now, link_id=gift_link,
))
self.hass.bus.async_fire("taskmate_points_gifted", {
"from_child_id": sender.id, "from_child_name": sender.name,
"to_child_id": recipient.id, "to_child_name": recipient.name,
"points": points, "timestamp": now.isoformat(),
})
self.storage.add_points_transaction(
PointsTransaction(
child_id=sender.id,
points=-points,
reason=f"Gift to {recipient.name}",
created_at=now,
link_id=gift_link,
)
)
self.storage.add_points_transaction(
PointsTransaction(
child_id=recipient.id,
points=points,
reason=f"Gift from {sender.name}",
created_at=now,
link_id=gift_link,
)
)
self.hass.bus.async_fire(
"taskmate_points_gifted",
{
"from_child_id": sender.id,
"from_child_name": sender.name,
"to_child_id": recipient.id,
"to_child_name": recipient.name,
"points": points,
"timestamp": now.isoformat(),
},
)
await self.storage.async_save()
await self.async_refresh()
@@ -526,10 +540,7 @@ class PointsMixin:
return
period = self.storage.get_setting("points_decay_period", "monthly")
today = dt_util.now().date()
due = (
(period == "weekly" and today.weekday() == 0)
or (period == "monthly" and today.day == 1)
)
due = (period == "weekly" and today.weekday() == 0) or (period == "monthly" and today.day == 1)
if not due:
return
if self.storage.get_setting("points_decay_last", "") == today.isoformat():
@@ -544,14 +555,23 @@ class PointsMixin:
continue
child.points = max(0, child.points - loss)
self.storage.update_child(child)
self.storage.add_points_transaction(PointsTransaction(
child_id=child.id, points=-loss,
reason=f"Points decay (-{pct:.0f}%)", created_at=now,
))
self.hass.bus.async_fire("taskmate_points_decay", {
"child_id": child.id, "child_name": child.name,
"points": loss, "timestamp": now.isoformat(),
})
self.storage.add_points_transaction(
PointsTransaction(
child_id=child.id,
points=-loss,
reason=f"Points decay (-{pct:.0f}%)",
created_at=now,
)
)
self.hass.bus.async_fire(
"taskmate_points_decay",
{
"child_id": child.id,
"child_name": child.name,
"points": loss,
"timestamp": now.isoformat(),
},
)
changed = True
self.storage.set_setting("points_decay_last", today.isoformat())
if changed:
@@ -576,10 +596,7 @@ class PointsMixin:
return
period = self.storage.get_setting("interest_period", "weekly")
today = dt_util.now().date()
due = (
(period == "weekly" and today.weekday() == 0)
or (period == "monthly" and today.day == 1)
)
due = (period == "weekly" and today.weekday() == 0) or (period == "monthly" and today.day == 1)
if not due:
return
if self.storage.get_setting("interest_last", "") == today.isoformat():
@@ -592,10 +609,15 @@ class PointsMixin:
if interest <= 0:
continue
await self.async_add_points(child.id, interest, reason=f"Savings interest (+{pct:.0f}%)")
self.hass.bus.async_fire("taskmate_interest_paid", {
"child_id": child.id, "child_name": child.name,
"points": interest, "timestamp": dt_util.now().isoformat(),
})
self.hass.bus.async_fire(
"taskmate_interest_paid",
{
"child_id": child.id,
"child_name": child.name,
"points": interest,
"timestamp": dt_util.now().isoformat(),
},
)
self.storage.set_setting("interest_last", today.isoformat())
await self.storage.async_save()
@@ -613,17 +635,13 @@ class PointsMixin:
if not part:
continue
if ":" not in part:
raise ValueError(
f"Invalid format '{part}' — use 'days:points' pairs, e.g. '7:10, 14:20'"
)
raise ValueError(f"Invalid format '{part}' — use 'days:points' pairs, e.g. '7:10, 14:20'")
days_str, points_str = part.split(":", 1)
try:
days = int(days_str.strip())
points = int(points_str.strip())
except ValueError as err:
raise ValueError(
f"Invalid numbers in '{part}' — days and points must be whole numbers"
) from err
raise ValueError(f"Invalid numbers in '{part}' — days and points must be whole numbers") from err
if days < 1:
raise ValueError(f"Days must be at least 1, got {days}")
if points < 1:
@@ -718,7 +736,7 @@ class PointsMixin:
today = now.date()
effective_date = completion_date or today
effective_date_str = effective_date.isoformat()
last_date_str = getattr(child, 'last_completion_date', None)
last_date_str = getattr(child, "last_completion_date", None)
# ── Weekend multiplier ──────────────────────────────────────────────
# Applied to base chore points only, based on completion date
@@ -740,7 +758,10 @@ class PointsMixin:
if weekend_bonus > 0:
_LOGGER.info(
"Weekend multiplier (%.1fx) applied for %s: +%d bonus on top of %d",
multiplier, child.name, weekend_bonus, points,
multiplier,
child.name,
weekend_bonus,
points,
)
# Log weekend bonus as a separate transaction for activity history
transaction = PointsTransaction(
@@ -758,9 +779,7 @@ class PointsMixin:
# day is done. The in-flight completion (chore_id) is counted as done
# since it may not be persisted yet at this point in the flow.
if advance_streak and self._setting_enabled("streak_requires_all_chores"):
if not self._all_due_chores_done(
child.id, effective_date, include_rotation=True, extra_done=chore_id
):
if not self._all_due_chores_done(child.id, effective_date, include_rotation=True, extra_done=chore_id):
advance_streak = False
if advance_streak:
streak_mode = self.storage.get_setting("streak_reset_mode", "reset")
@@ -815,9 +834,7 @@ class PointsMixin:
milestones_enabled = self.storage.get_setting("streak_milestones_enabled", "true") == "true"
if advance_streak and milestones_enabled and child.current_streak > 0:
# Parse custom milestone config
milestone_setting = self.storage.get_setting(
"streak_milestones", self.DEFAULT_STREAK_MILESTONES
)
milestone_setting = self.storage.get_setting("streak_milestones", self.DEFAULT_STREAK_MILESTONES)
try:
milestones = self.parse_milestone_setting(milestone_setting)
except ValueError:
@@ -836,7 +853,9 @@ class PointsMixin:
reached_milestones.append((days, bonus_pts))
_LOGGER.info(
"Streak milestone %d days reached for %s: +%d bonus",
days, child.name, bonus_pts,
days,
child.name,
bonus_pts,
)
child.streak_milestones_achieved = sorted(achieved)
@@ -853,9 +872,7 @@ class PointsMixin:
)
self.storage.add_points_transaction(transaction)
self.storage.append_career_score_snapshot(
child.id, effective_date.isoformat(), child.career_score
)
self.storage.append_career_score_snapshot(child.id, effective_date.isoformat(), child.career_score)
await self._maybe_level_up(child)
self.storage.update_child(child)
@@ -877,9 +894,11 @@ class PointsMixin:
# A big streak is a celebration moment too — epic at 30+ days.
for days, _bonus_pts in reached_milestones:
await self._celebrate(
child, "streak_milestone",
child,
"streak_milestone",
f"{child.name} hit a {days}-day streak!",
tier=3 if days >= 30 else 2, extra={"days": days},
tier=3 if days >= 30 else 2,
extra={"days": days},
)
return total_points
@@ -890,10 +909,7 @@ class PointsMixin:
before = len(all_completions)
# Keep completions newer than cutoff OR unapproved (pending)
to_keep = [
c for c in all_completions
if c.completed_at >= cutoff or not c.approved
]
to_keep = [c for c in all_completions if c.completed_at >= cutoff or not c.approved]
if len(to_keep) < before:
kept_ids = {c.id for c in to_keep}
@@ -906,10 +922,7 @@ class PointsMixin:
if c.id not in kept_ids and getattr(c, "photo_url", ""):
await photos.async_delete_photo(self.hass, c.photo_url)
await self.async_refresh()
_LOGGER.info(
"Pruned %d completions older than %d days",
before - len(to_keep), days
)
_LOGGER.info("Pruned %d completions older than %d days", before - len(to_keep), days)
# Penalty operations
async def async_add_penalty(
@@ -954,12 +967,17 @@ class PointsMixin:
if not child:
raise ValueError(f"Child {child_id} not found")
await self.async_remove_points(child_id, penalty.points, reason=f"Penalty: {penalty.name}")
self.hass.bus.async_fire("taskmate_penalty_applied", {
"child_id": child.id, "child_name": child.name,
"penalty_id": penalty.id, "penalty_name": penalty.name,
"points": penalty.points,
"timestamp": dt_util.now().isoformat(),
})
self.hass.bus.async_fire(
"taskmate_penalty_applied",
{
"child_id": child.id,
"child_name": child.name,
"penalty_id": penalty.id,
"penalty_name": penalty.name,
"points": penalty.points,
"timestamp": dt_util.now().isoformat(),
},
)
# Bonus operations
async def async_add_bonus(
@@ -1004,16 +1022,25 @@ class PointsMixin:
if not child:
raise ValueError(f"Child {child_id} not found")
await self.async_add_points(child_id, bonus.points, reason=f"Bonus: {bonus.name}")
self.hass.bus.async_fire("taskmate_bonus_applied", {
"child_id": child.id, "child_name": child.name,
"bonus_id": bonus.id, "bonus_name": bonus.name,
"points": bonus.points,
"timestamp": dt_util.now().isoformat(),
})
self.hass.bus.async_fire(
"taskmate_bonus_applied",
{
"child_id": child.id,
"child_name": child.name,
"bonus_id": bonus.id,
"bonus_name": bonus.name,
"points": bonus.points,
"timestamp": dt_util.now().isoformat(),
},
)
async def _async_notify_pending_approval(
self, child_name: str, chore_name: str, points: int,
completion_id: str | None = None, photo_url: str = "",
self,
child_name: str,
chore_name: str,
points: int,
completion_id: str | None = None,
photo_url: str = "",
) -> None:
await self.notifications.fire(
"pending_chore_approval",
@@ -1030,7 +1057,10 @@ class PointsMixin:
)
async def _async_notify_pending_reward_claim(
self, child_name: str, reward_name: str, cost: int,
self,
child_name: str,
reward_name: str,
cost: int,
claim_id: str | None = None,
) -> None:
await self.notifications.fire(