1046 lines
45 KiB
Python
1046 lines
45 KiB
Python
"""Points operations mixin for TaskMateCoordinator."""
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from datetime import date, timedelta
|
||
from typing import TYPE_CHECKING
|
||
|
||
from homeassistant.util import dt as dt_util
|
||
|
||
from . import photos
|
||
from .models import Bonus, Child, Penalty, PointsTransaction, generate_id
|
||
|
||
if TYPE_CHECKING:
|
||
pass
|
||
|
||
_LOGGER = logging.getLogger(__name__)
|
||
|
||
|
||
class PointsMixin:
|
||
"""Mixin providing points, streaks, penalties, bonuses, and notifications."""
|
||
|
||
async def _async_check_perfect_week(self) -> None:
|
||
"""Award perfect week bonus to children who completed at least one chore every day last week."""
|
||
perfect_week_enabled = self.storage.get_setting("perfect_week_enabled", "true") == "true"
|
||
if not perfect_week_enabled:
|
||
return
|
||
|
||
try:
|
||
perfect_week_bonus = int(self.storage.get_setting("perfect_week_bonus", "50"))
|
||
except (ValueError, TypeError):
|
||
perfect_week_bonus = 50
|
||
|
||
# #563: require every chore due each day (not just one) to count the day.
|
||
all_mode = self._setting_enabled("perfect_week_requires_all_chores")
|
||
|
||
today = dt_util.now().date()
|
||
# Should only run on Monday — last week is Mon(today-7) to Sun(today-1)
|
||
if today.weekday() != 0:
|
||
_LOGGER.debug("Perfect week check skipped (not Monday)")
|
||
return
|
||
|
||
last_monday = today - timedelta(days=7)
|
||
last_week_dates = {(last_monday + timedelta(days=i)).isoformat() for i in range(7)}
|
||
week_key = last_monday.isoformat()
|
||
|
||
all_completions = self.storage.get_completions()
|
||
children = self.storage.get_children()
|
||
chores = [c for c in self.storage.get_chores() if getattr(c, "enabled", True)]
|
||
changed = False
|
||
|
||
def _required_dates(child_id: str) -> set[str]:
|
||
"""Days last week on which the child had at least one scheduled chore.
|
||
|
||
A perfect week shouldn't demand completions on days with nothing
|
||
due (e.g. weekends for weekday-only chores). Rotation-mode chores
|
||
are skipped — who was active on a past day can't be reconstructed.
|
||
"""
|
||
req: set[str] = set()
|
||
for d_str in last_week_dates:
|
||
day = date.fromisoformat(d_str)
|
||
for chore in chores:
|
||
assigned = chore.assigned_to or []
|
||
if assigned and child_id not in assigned:
|
||
continue
|
||
if child_id in getattr(chore, "disabled_for", []):
|
||
continue
|
||
if getattr(chore, "assignment_mode", "everyone") not in ("", "everyone"):
|
||
continue
|
||
if self._is_chore_scheduled_for_date(chore, day):
|
||
req.add(d_str)
|
||
break
|
||
return req
|
||
|
||
for child in children:
|
||
awarded_weeks = list(getattr(child, 'awarded_perfect_weeks', None) or [])
|
||
|
||
# Skip if already awarded for this week
|
||
if week_key in awarded_weeks:
|
||
continue
|
||
|
||
# Get all days this child had at least one completion last week
|
||
# (count both approved and pending — don't penalise for slow parent approval)
|
||
completed_days = set()
|
||
for comp in all_completions:
|
||
if comp.child_id != child.id:
|
||
continue
|
||
try:
|
||
comp_local = dt_util.as_local(comp.completed_at)
|
||
comp_date_str = comp_local.date().isoformat()
|
||
if comp_date_str in last_week_dates:
|
||
completed_days.add(comp_date_str)
|
||
except (ValueError, TypeError, AttributeError):
|
||
continue
|
||
|
||
# If no scheduled days can be derived (e.g. rotation-only setups),
|
||
# fall back to requiring a completion on all 7 days as before
|
||
derived_dates = _required_dates(child.id)
|
||
required_dates = derived_dates or last_week_dates
|
||
# #563: when enabled (and the required days could be derived), a day
|
||
# 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
|
||
)
|
||
for d in derived_dates
|
||
)
|
||
else:
|
||
satisfied = completed_days >= required_dates
|
||
if satisfied:
|
||
# Perfect week!
|
||
child.awarded_perfect_weeks = awarded_weeks + [week_key]
|
||
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.update_child(child)
|
||
|
||
transaction = PointsTransaction(
|
||
child_id=child.id,
|
||
points=perfect_week_bonus,
|
||
reason=f"Perfect week bonus! ({last_monday.strftime('%d %b')} – {(today - timedelta(days=1)).strftime('%d %b')})",
|
||
created_at=dt_util.now(),
|
||
)
|
||
self.storage.add_points_transaction(transaction)
|
||
changed = True
|
||
|
||
self.hass.bus.async_fire(
|
||
"taskmate_perfect_week",
|
||
{
|
||
"child_id": child.id,
|
||
"week_key": week_key,
|
||
"bonus": perfect_week_bonus,
|
||
"timestamp": dt_util.now().isoformat(),
|
||
},
|
||
)
|
||
|
||
if getattr(self, "badges", None):
|
||
await self.badges.evaluate_for_child(child.id, "perfect_week")
|
||
await self._celebrate(
|
||
child, "perfect_week",
|
||
f"{child.name} earned a perfect week — +{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,
|
||
)
|
||
|
||
if changed:
|
||
await self.storage.async_save()
|
||
await self.async_refresh()
|
||
|
||
def _streak_breaks_after_gap(self, last_date_str: str, today: date) -> bool:
|
||
"""True if a genuine (non-vacation) expected day was missed.
|
||
|
||
Walks each day strictly between the last completion and today; if any
|
||
is not a vacation day the child missed it and the streak breaks.
|
||
Days inside a vacation period are forgiven.
|
||
"""
|
||
try:
|
||
last_date = date.fromisoformat(last_date_str)
|
||
except (TypeError, ValueError):
|
||
return False
|
||
d = last_date + timedelta(days=1)
|
||
while d < today:
|
||
if not self.is_vacation_day(d):
|
||
return True
|
||
d += timedelta(days=1)
|
||
return False
|
||
|
||
async def _async_check_streaks(self) -> None:
|
||
"""Check all children's streaks and reset/pause if they missed yesterday.
|
||
|
||
Behaviour depends on streak_reset_mode setting:
|
||
- "reset" (default): streak goes back to 0 on missed day
|
||
- "pause": streak is preserved but not incremented until they complete again
|
||
"""
|
||
today = dt_util.now().date()
|
||
|
||
streak_mode = self.storage.get_setting("streak_reset_mode", "reset")
|
||
|
||
children = self.storage.get_children()
|
||
changed = False
|
||
|
||
for child in children:
|
||
# Frozen while away — static vacation range, the global family
|
||
# calendar, or this child's own availability sensor. Mark the streak
|
||
# paused so it resumes intact on return in BOTH reset and pause
|
||
# modes (the resume path in _award_points honours streak_paused).
|
||
if self._is_child_on_vacation(child, today):
|
||
if (child.current_streak or 0) > 0 and not getattr(child, "streak_paused", False):
|
||
child.streak_paused = True
|
||
self.storage.update_child(child)
|
||
changed = True
|
||
continue
|
||
|
||
last_date_str = getattr(child, "last_completion_date", None)
|
||
if last_date_str is None:
|
||
continue # No completions yet, nothing to do
|
||
|
||
# Streak is fine unless a genuine, non-vacation day was missed
|
||
# between the last completion and today.
|
||
if not self._streak_breaks_after_gap(last_date_str, today):
|
||
continue
|
||
|
||
# They missed a (non-vacation) day
|
||
if (child.current_streak or 0) > 0:
|
||
# A streak already marked paused (e.g. frozen during an absence)
|
||
# resumes rather than resets, even in reset mode.
|
||
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
|
||
)
|
||
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
|
||
)
|
||
self.storage.update_child(child)
|
||
changed = True
|
||
|
||
if changed:
|
||
await self.storage.async_save()
|
||
await self.async_refresh()
|
||
|
||
async def async_add_points(self, child_id: str, points: int, reason: str = "") -> None:
|
||
"""Add points to a child (bonus)."""
|
||
if points < 0:
|
||
raise ValueError("points must be non-negative")
|
||
child = self.get_child(child_id)
|
||
if not child:
|
||
raise ValueError(f"Child {child_id} not found")
|
||
child.points += points
|
||
child.total_points_earned += points
|
||
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
|
||
)
|
||
# Log the manual transaction
|
||
transaction = PointsTransaction(
|
||
child_id=child_id,
|
||
points=points,
|
||
reason=reason,
|
||
created_at=dt_util.now(),
|
||
)
|
||
self.storage.add_points_transaction(transaction)
|
||
await self.storage.async_save()
|
||
await self.async_refresh()
|
||
|
||
# Trigger badge evaluation. Skip if this credit came from awarding a badge bonus
|
||
# itself, otherwise we'd recurse back into evaluate_for_child.
|
||
if getattr(self, "badges", None) and not (reason or "").startswith("Badge"):
|
||
await self.badges.evaluate_for_child(child_id, "points_changed")
|
||
|
||
async def async_remove_points(self, child_id: str, points: int, reason: str = "") -> None:
|
||
"""Remove points from a child (penalty)."""
|
||
if points < 0:
|
||
raise ValueError("points must be non-negative")
|
||
child = self.get_child(child_id)
|
||
if not child:
|
||
raise ValueError(f"Child {child_id} not found")
|
||
# Negative-balance policy (FEAT-7): floor at zero unless opted in.
|
||
if self._setting_enabled("allow_negative_balance"):
|
||
actual_deducted = points
|
||
child.points = child.points - points
|
||
else:
|
||
actual_deducted = min(points, child.points) # Can't go below 0
|
||
child.points = max(0, child.points - points)
|
||
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.update_child(child)
|
||
# Log the manual transaction (negative points)
|
||
transaction = PointsTransaction(
|
||
child_id=child_id,
|
||
points=-actual_deducted,
|
||
reason=reason,
|
||
created_at=dt_util.now(),
|
||
)
|
||
self.storage.add_points_transaction(transaction)
|
||
await self.storage.async_save()
|
||
await self.async_refresh()
|
||
|
||
# Trigger badge evaluation. Skip if this deduction came from a badge revoke
|
||
# path (reason starts with "Badge") to avoid recursion.
|
||
if getattr(self, "badges", None) and not (reason or "").startswith("Badge"):
|
||
await self.badges.evaluate_for_child(child_id, "points_changed")
|
||
|
||
# Reason prefixes for *derived* transactions — automatic consequences of
|
||
# other actions. Reversing one in isolation would desync related state, so
|
||
# undo refuses them. NOTE: this is a deny-list because manual add/remove
|
||
# carry arbitrary (or empty) reasons and can't be told apart from free-text
|
||
# by an allow-list. Any NEW derived transaction reason added elsewhere in
|
||
# this file MUST be added here, or it will become wrongly undoable.
|
||
_UNDO_DENY_PREFIXES = (
|
||
"Weekend bonus",
|
||
"Streak milestone bonus",
|
||
"Perfect week bonus",
|
||
"Allocated to pool:",
|
||
"Pool refund",
|
||
"Points decay",
|
||
"Savings interest",
|
||
"Badge",
|
||
)
|
||
|
||
async def async_undo_transaction(self, transaction_id: str) -> None:
|
||
"""Reverse a discrete points transaction (the inverse of an admin action).
|
||
|
||
Reversible: penalties, bonuses, manual add/remove adjustments, and gifts
|
||
(both legs, via ``link_id``). Refused: derived/automatic transactions
|
||
(see ``_UNDO_DENY_PREFIXES``) — reversing them alone would desync state.
|
||
"""
|
||
txns = self.storage.get_points_transactions()
|
||
target = next((t for t in txns if t.id == transaction_id), None)
|
||
if not target:
|
||
raise ValueError(f"Transaction {transaction_id} not found")
|
||
reason = target.reason or ""
|
||
|
||
if reason.startswith(self._UNDO_DENY_PREFIXES):
|
||
raise ValueError("This action can't be undone")
|
||
|
||
# Gifts move spendable balance between two children — reverse every leg
|
||
# sharing the gift's link_id (neither child's earned/penalty totals
|
||
# changed at gift time, so only `points` is adjusted).
|
||
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."
|
||
)
|
||
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:
|
||
leg_child.points = max(0, leg_child.points - leg.points)
|
||
self.storage.update_child(leg_child)
|
||
self.storage.remove_points_transaction(leg.id)
|
||
await self.storage.async_save()
|
||
await self.async_refresh()
|
||
return
|
||
|
||
child = self.get_child(target.child_id)
|
||
if not child:
|
||
raise ValueError(f"Child {target.child_id} not found")
|
||
|
||
pts = target.points # signed: removals < 0, additions > 0
|
||
is_penalty = reason.startswith("Penalty: ")
|
||
child.points = max(0, child.points - pts)
|
||
if is_penalty:
|
||
# Penalty removed points AND raised total_penalties_received.
|
||
child.total_penalties_received = max(0, child.total_penalties_received - abs(pts))
|
||
elif pts > 0:
|
||
# Bonus / manual add raised total_points_earned alongside points.
|
||
child.total_points_earned = max(0, child.total_points_earned - pts)
|
||
# else: a plain (non-penalty) manual removal only touched spendable
|
||
# 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.remove_points_transaction(transaction_id)
|
||
await self.storage.async_save()
|
||
await self.async_refresh()
|
||
|
||
# ── Levels / XP ──────────────────────────────────────────────────────
|
||
def _xp_per_level(self) -> int:
|
||
try:
|
||
v = int(float(self.storage.get_setting("level_xp_step", "100")))
|
||
except (ValueError, TypeError):
|
||
v = 100
|
||
return max(1, v)
|
||
|
||
def level_for_xp(self, xp: int) -> int:
|
||
"""Level derived from cumulative earned points (XP). Level 1 at 0 XP."""
|
||
return max(1, int(xp or 0) // self._xp_per_level() + 1)
|
||
|
||
def level_info(self, child) -> dict:
|
||
"""{level, progress, target} for a child's current XP."""
|
||
step = self._xp_per_level()
|
||
xp = int(getattr(child, "total_points_earned", 0) or 0)
|
||
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:
|
||
"""Central celebration funnel for notable moments.
|
||
|
||
Always fires a single ``taskmate_celebration`` event carrying a ``tier``
|
||
(1=small, 2=medium, 3=epic) so one automation can scale its reaction
|
||
(lights, sound, TTS) to the size of the moment. Also fires the opt-in
|
||
``celebration`` notification when enabled and the moment meets the
|
||
configured minimum tier.
|
||
"""
|
||
payload = {
|
||
"child_id": child.id,
|
||
"child_name": child.name,
|
||
"kind": kind,
|
||
"tier": int(tier),
|
||
"message": message,
|
||
"timestamp": dt_util.now().isoformat(),
|
||
}
|
||
if extra:
|
||
payload.update(extra)
|
||
self.hass.bus.async_fire("taskmate_celebration", payload)
|
||
|
||
if not getattr(self, "notifications", None):
|
||
return
|
||
enabled = self.storage.get_setting("celebration_notify", False)
|
||
if not (enabled is True or str(enabled).lower() == "true"):
|
||
return
|
||
try:
|
||
min_tier = int(self.storage.get_setting("celebration_notify_min_tier", "2"))
|
||
except (ValueError, TypeError):
|
||
min_tier = 2
|
||
if int(tier) < min_tier:
|
||
return
|
||
await self.notifications.fire(
|
||
"celebration",
|
||
{
|
||
"child_name": child.name,
|
||
"child_id": child.id,
|
||
"kind": kind,
|
||
"tier": int(tier),
|
||
"message": message,
|
||
},
|
||
)
|
||
|
||
async def _maybe_level_up(self, child) -> None:
|
||
"""Sync child.level to its XP; fire level-up events on an increase.
|
||
|
||
Does not save — the caller persists ``child`` afterwards.
|
||
"""
|
||
new = self.level_for_xp(getattr(child, "total_points_earned", 0))
|
||
old = getattr(child, "level", 1) or 1
|
||
if new == old:
|
||
return
|
||
child.level = new
|
||
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(),
|
||
})
|
||
if getattr(self, "notifications", None):
|
||
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},
|
||
)
|
||
|
||
async def async_gift_points(self, from_child_id: str, to_child_id: str, points: int) -> None:
|
||
"""Transfer spendable points from one child to another (parent-controlled).
|
||
|
||
Moves spendable balance only — neither child's career score / earned
|
||
total changes (the recipient didn't earn it). Logged on both sides.
|
||
"""
|
||
points = int(points)
|
||
if points <= 0:
|
||
raise ValueError("Gift amount must be positive")
|
||
if from_child_id == to_child_id:
|
||
raise ValueError("Cannot gift to the same child")
|
||
sender = self.get_child(from_child_id)
|
||
recipient = self.get_child(to_child_id)
|
||
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}"
|
||
)
|
||
now = dt_util.now()
|
||
sender.points -= points
|
||
recipient.points += points
|
||
self.storage.update_child(sender)
|
||
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(),
|
||
})
|
||
await self.storage.async_save()
|
||
await self.async_refresh()
|
||
|
||
async def _async_decay_points(self) -> None:
|
||
"""Periodically decay each child's spendable points (anti-hoarding).
|
||
|
||
Opt-in via ``points_decay_enabled``. On the period boundary
|
||
(weekly=Mon, monthly=1st) reduces each child's ``points`` by
|
||
``points_decay_percent`` and logs it. Does not touch career score —
|
||
decay is a spendable-balance reduction, not a penalty.
|
||
"""
|
||
enabled = self.storage.get_setting("points_decay_enabled", False)
|
||
if not (enabled is True or str(enabled).lower() == "true"):
|
||
return
|
||
try:
|
||
pct = float(self.storage.get_setting("points_decay_percent", "10"))
|
||
except (ValueError, TypeError):
|
||
pct = 10.0
|
||
if pct <= 0:
|
||
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)
|
||
)
|
||
if not due:
|
||
return
|
||
if self.storage.get_setting("points_decay_last", "") == today.isoformat():
|
||
return
|
||
now = dt_util.now()
|
||
changed = False
|
||
for child in self.storage.get_children():
|
||
if (child.points or 0) <= 0:
|
||
continue
|
||
loss = round(child.points * pct / 100.0)
|
||
if loss <= 0:
|
||
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(),
|
||
})
|
||
changed = True
|
||
self.storage.set_setting("points_decay_last", today.isoformat())
|
||
if changed:
|
||
await self.storage.async_save()
|
||
await self.async_refresh()
|
||
|
||
async def _async_apply_interest(self) -> None:
|
||
"""Pay periodic interest on each child's spendable balance (savings reward).
|
||
|
||
Opt-in via ``interest_enabled``. On the period boundary (weekly=Mon,
|
||
monthly=1st) credits ``interest_percent`` of the current balance via the
|
||
normal points path (counts as earned, so it also feeds XP).
|
||
"""
|
||
enabled = self.storage.get_setting("interest_enabled", False)
|
||
if not (enabled is True or str(enabled).lower() == "true"):
|
||
return
|
||
try:
|
||
pct = float(self.storage.get_setting("interest_percent", "5"))
|
||
except (ValueError, TypeError):
|
||
pct = 5.0
|
||
if pct <= 0:
|
||
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)
|
||
)
|
||
if not due:
|
||
return
|
||
if self.storage.get_setting("interest_last", "") == today.isoformat():
|
||
return
|
||
for child in self.storage.get_children():
|
||
bal = child.points or 0
|
||
if bal <= 0:
|
||
continue
|
||
interest = round(bal * pct / 100.0)
|
||
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.storage.set_setting("interest_last", today.isoformat())
|
||
await self.storage.async_save()
|
||
|
||
# ── Bonus points constants ────────────────────────────────────────────────
|
||
DEFAULT_STREAK_MILESTONES = "3:5, 7:10, 14:20, 30:50, 60:100, 100:200"
|
||
|
||
@staticmethod
|
||
def parse_milestone_setting(value: str) -> dict[int, int]:
|
||
"""Parse 'days:points, days:points' string into {days: points} dict."""
|
||
if not value or not value.strip():
|
||
return {}
|
||
result = {}
|
||
for part in value.split(","):
|
||
part = part.strip()
|
||
if not part:
|
||
continue
|
||
if ":" not in part:
|
||
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
|
||
if days < 1:
|
||
raise ValueError(f"Days must be at least 1, got {days}")
|
||
if points < 1:
|
||
raise ValueError(f"Points must be at least 1, got {points}")
|
||
if days in result:
|
||
raise ValueError(f"Duplicate milestone for {days} days")
|
||
result[days] = points
|
||
return result
|
||
|
||
def _setting_enabled(self, key: str, default: str = "false") -> bool:
|
||
"""Read a boolean setting that may be stored as a real bool or a
|
||
"true"/"false" string (the panel sends bools; defaults are strings)."""
|
||
v = self.storage.get_setting(key, default)
|
||
return v is True or str(v).lower() == "true"
|
||
|
||
def _due_chore_ids_for_child(self, child_id: str, day: date, *, include_rotation: bool) -> set[str]:
|
||
"""Chore IDs *due* for ``child_id`` on ``day`` (#563).
|
||
|
||
A chore is due when it is enabled, assigned to the child (empty
|
||
``assigned_to`` = everyone), not disabled for them, and scheduled for
|
||
that date. Rotation-mode chores only count when it's the child's turn —
|
||
and only when ``include_rotation`` is True (past days can't reconstruct
|
||
who was active, so the perfect-week path passes False).
|
||
"""
|
||
out: set[str] = set()
|
||
for chore in self.storage.get_chores():
|
||
if not getattr(chore, "enabled", True):
|
||
continue
|
||
assigned = chore.assigned_to or []
|
||
if assigned and child_id not in assigned:
|
||
continue
|
||
if child_id in getattr(chore, "disabled_for", []):
|
||
continue
|
||
mode = getattr(chore, "assignment_mode", "everyone")
|
||
if mode not in ("", "everyone"):
|
||
if not include_rotation:
|
||
continue
|
||
if child_id not in self._compute_active_children(chore, day):
|
||
continue
|
||
if not self._is_chore_scheduled_for_date(chore, day):
|
||
continue
|
||
out.add(chore.id)
|
||
return out
|
||
|
||
def _completed_chore_ids_for_child(self, child_id: str, day: date) -> set[str]:
|
||
"""Chore IDs the child has a (non-bonus) completion for on ``day`` —
|
||
approved or pending, so slow parent approval doesn't break the day."""
|
||
out: set[str] = set()
|
||
for c in self.storage.get_completions():
|
||
if c.child_id != child_id or getattr(c, "bonus_subtask_id", ""):
|
||
continue
|
||
try:
|
||
if dt_util.as_local(c.completed_at).date() == day:
|
||
out.add(c.chore_id)
|
||
except (ValueError, TypeError, AttributeError):
|
||
continue
|
||
return out
|
||
|
||
def _all_due_chores_done(
|
||
self, child_id: str, day: date, *, include_rotation: bool, extra_done: str | None = None
|
||
) -> bool:
|
||
"""True when every chore due for the child on ``day`` has a completion.
|
||
|
||
A day with nothing due is satisfied. ``extra_done`` lets the streak path
|
||
count an in-flight completion that hasn't been persisted yet.
|
||
"""
|
||
due = self._due_chore_ids_for_child(child_id, day, include_rotation=include_rotation)
|
||
if not due:
|
||
return True
|
||
done = self._completed_chore_ids_for_child(child_id, day)
|
||
if extra_done:
|
||
done = done | {extra_done}
|
||
return due <= done
|
||
|
||
async def _award_points(
|
||
self,
|
||
child: Child,
|
||
points: int,
|
||
completion_date: date | None = None,
|
||
skip_streak: bool = False,
|
||
chore_id: str | None = None,
|
||
) -> int:
|
||
"""Award points to a child, update streak, and apply bonus systems.
|
||
|
||
Returns the total points awarded (base + weekend bonus), excluding
|
||
milestone bonuses (which are logged as separate transactions).
|
||
|
||
If skip_streak is True, streak tracking is skipped (used for bonus
|
||
sub-task completions where the parent already counted).
|
||
"""
|
||
now = dt_util.now()
|
||
today = now.date()
|
||
effective_date = completion_date or today
|
||
effective_date_str = effective_date.isoformat()
|
||
last_date_str = getattr(child, 'last_completion_date', None)
|
||
|
||
# ── Weekend multiplier ──────────────────────────────────────────────
|
||
# Applied to base chore points only, based on completion date
|
||
try:
|
||
multiplier = float(self.storage.get_setting("weekend_multiplier", "2.0"))
|
||
except (ValueError, TypeError):
|
||
multiplier = 2.0
|
||
|
||
weekend_bonus = 0
|
||
if effective_date.weekday() in (5, 6) and multiplier > 1.0:
|
||
weekend_bonus = round(points * (multiplier - 1.0))
|
||
|
||
total_points = points + weekend_bonus
|
||
child.points += total_points
|
||
child.total_points_earned += total_points
|
||
child.total_chores_completed += 1
|
||
child.career_score = child.total_points_earned - child.total_penalties_received
|
||
|
||
if weekend_bonus > 0:
|
||
_LOGGER.info(
|
||
"Weekend multiplier (%.1fx) applied for %s: +%d bonus on top of %d",
|
||
multiplier, child.name, weekend_bonus, points,
|
||
)
|
||
# Log weekend bonus as a separate transaction for activity history
|
||
transaction = PointsTransaction(
|
||
child_id=child.id,
|
||
points=weekend_bonus,
|
||
reason=f"Weekend bonus (×{multiplier:.0f})",
|
||
created_at=now,
|
||
)
|
||
self.storage.add_points_transaction(transaction)
|
||
|
||
# ── Streak tracking ─────────────────────────────────────────────────
|
||
streak_reset_occurred = False
|
||
advance_streak = not skip_streak
|
||
# #563: when enabled, the streak only advances once EVERY chore due that
|
||
# 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
|
||
):
|
||
advance_streak = False
|
||
if advance_streak:
|
||
streak_mode = self.storage.get_setting("streak_reset_mode", "reset")
|
||
streak_paused = getattr(child, "streak_paused", False)
|
||
streak_before = child.current_streak or 0
|
||
|
||
if last_date_str is None:
|
||
child.current_streak = 1
|
||
child.streak_paused = False
|
||
elif last_date_str == effective_date_str:
|
||
pass # Already completed on this date — streak unchanged
|
||
else:
|
||
try:
|
||
last_date = date.fromisoformat(last_date_str)
|
||
yesterday = effective_date - timedelta(days=1)
|
||
if last_date == yesterday:
|
||
child.current_streak = streak_before + 1
|
||
child.streak_paused = False
|
||
elif streak_mode == "pause" or streak_paused:
|
||
# Resume from a paused gap: today is a completed day, so
|
||
# it must count toward the streak (ERR-3 — previously the
|
||
# streak resumed at its frozen value, dropping today).
|
||
child.current_streak = streak_before + 1
|
||
child.streak_paused = False
|
||
_LOGGER.debug("Streak resumed for %s at %d", child.name, child.current_streak)
|
||
else:
|
||
child.current_streak = 1
|
||
child.streak_paused = False
|
||
streak_reset_occurred = True
|
||
except (ValueError, TypeError):
|
||
child.current_streak = 1
|
||
child.streak_paused = False
|
||
streak_reset_occurred = True
|
||
|
||
child.last_completion_date = effective_date_str
|
||
|
||
if child.current_streak > (child.best_streak or 0):
|
||
child.best_streak = child.current_streak
|
||
|
||
self.hass.bus.async_fire(
|
||
"taskmate_streak_updated",
|
||
{
|
||
"child_id": child.id,
|
||
"current_streak": child.current_streak,
|
||
"best_streak": child.best_streak,
|
||
"timestamp": dt_util.now().isoformat(),
|
||
},
|
||
)
|
||
|
||
# ── Streak milestone bonus ──────────────────────────────────────────
|
||
reached_milestones: list[tuple[int, int]] = []
|
||
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
|
||
)
|
||
try:
|
||
milestones = self.parse_milestone_setting(milestone_setting)
|
||
except ValueError:
|
||
milestones = self.parse_milestone_setting(self.DEFAULT_STREAK_MILESTONES)
|
||
|
||
# Clear milestones on reset so kids can re-earn them
|
||
if streak_reset_occurred:
|
||
child.streak_milestones_achieved = []
|
||
|
||
achieved = set(child.streak_milestones_achieved or [])
|
||
milestone_bonus = 0
|
||
for days, bonus_pts in milestones.items():
|
||
if child.current_streak >= days and days not in achieved:
|
||
milestone_bonus += bonus_pts
|
||
achieved.add(days)
|
||
reached_milestones.append((days, bonus_pts))
|
||
_LOGGER.info(
|
||
"Streak milestone %d days reached for %s: +%d bonus",
|
||
days, child.name, bonus_pts,
|
||
)
|
||
|
||
child.streak_milestones_achieved = sorted(achieved)
|
||
|
||
if milestone_bonus > 0:
|
||
child.points += milestone_bonus
|
||
child.total_points_earned += milestone_bonus
|
||
child.career_score = child.total_points_earned - child.total_penalties_received
|
||
transaction = PointsTransaction(
|
||
child_id=child.id,
|
||
points=milestone_bonus,
|
||
reason=f"Streak milestone bonus ({child.current_streak} day streak!)",
|
||
created_at=now,
|
||
)
|
||
self.storage.add_points_transaction(transaction)
|
||
|
||
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)
|
||
|
||
# Notify on each newly reached streak milestone (off by default; opt-in).
|
||
if reached_milestones and getattr(self, "notifications", None):
|
||
points_name = self.storage.get_points_name()
|
||
for days, bonus_pts in reached_milestones:
|
||
await self.notifications.fire(
|
||
"streak_milestone",
|
||
{
|
||
"child_name": child.name,
|
||
"child_id": child.id,
|
||
"days": days,
|
||
"streak": child.current_streak,
|
||
"points": bonus_pts,
|
||
"points_name": points_name,
|
||
},
|
||
)
|
||
# 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",
|
||
f"{child.name} hit a {days}-day streak!",
|
||
tier=3 if days >= 30 else 2, extra={"days": days},
|
||
)
|
||
return total_points
|
||
|
||
async def async_prune_history(self, days: int = 90) -> None:
|
||
"""Prune completion history older than specified days."""
|
||
cutoff = dt_util.now() - timedelta(days=days)
|
||
all_completions = self.storage.get_completions()
|
||
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
|
||
]
|
||
|
||
if len(to_keep) < before:
|
||
kept_ids = {c.id for c in to_keep}
|
||
self.storage.replace_completions(to_keep)
|
||
await self.storage.async_save()
|
||
# Delete evidence photos belonging to the pruned completions so the
|
||
# photos dir doesn't grow forever (best-effort; foreign/blank URLs
|
||
# are ignored by async_delete_photo).
|
||
for c in all_completions:
|
||
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
|
||
)
|
||
|
||
# Penalty operations
|
||
async def async_add_penalty(
|
||
self,
|
||
name: str,
|
||
points: int,
|
||
description: str = "",
|
||
icon: str = "mdi:alert-circle-outline",
|
||
assigned_to: list | None = None,
|
||
):
|
||
"""Create a new penalty definition."""
|
||
penalty = Penalty(
|
||
name=name,
|
||
points=points,
|
||
description=description,
|
||
icon=icon,
|
||
assigned_to=assigned_to or [],
|
||
)
|
||
self.storage.add_penalty(penalty)
|
||
await self.storage.async_save()
|
||
await self.async_refresh()
|
||
return penalty
|
||
|
||
async def async_update_penalty(self, penalty) -> None:
|
||
"""Update an existing penalty definition."""
|
||
self.storage.update_penalty(penalty)
|
||
await self.storage.async_save()
|
||
await self.async_refresh()
|
||
|
||
async def async_remove_penalty(self, penalty_id: str) -> None:
|
||
"""Delete a penalty definition."""
|
||
self.storage.remove_penalty(penalty_id)
|
||
await self.storage.async_save()
|
||
await self.async_refresh()
|
||
|
||
async def async_apply_penalty(self, penalty_id: str, child_id: str) -> None:
|
||
"""Apply a penalty — deducts the penalty's points from the child."""
|
||
penalty = self.storage.get_penalty(penalty_id)
|
||
if not penalty:
|
||
raise ValueError(f"Penalty {penalty_id} not found")
|
||
child = self.get_child(child_id)
|
||
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(),
|
||
})
|
||
|
||
# Bonus operations
|
||
async def async_add_bonus(
|
||
self,
|
||
name: str,
|
||
points: int,
|
||
description: str = "",
|
||
icon: str = "mdi:star-circle-outline",
|
||
assigned_to: list | None = None,
|
||
):
|
||
"""Create a new bonus definition."""
|
||
bonus = Bonus(
|
||
name=name,
|
||
points=points,
|
||
description=description,
|
||
icon=icon,
|
||
assigned_to=assigned_to or [],
|
||
)
|
||
self.storage.add_bonus(bonus)
|
||
await self.storage.async_save()
|
||
await self.async_refresh()
|
||
return bonus
|
||
|
||
async def async_update_bonus(self, bonus) -> None:
|
||
"""Update an existing bonus definition."""
|
||
self.storage.update_bonus(bonus)
|
||
await self.storage.async_save()
|
||
await self.async_refresh()
|
||
|
||
async def async_remove_bonus(self, bonus_id: str) -> None:
|
||
"""Delete a bonus definition."""
|
||
self.storage.remove_bonus(bonus_id)
|
||
await self.storage.async_save()
|
||
await self.async_refresh()
|
||
|
||
async def async_apply_bonus(self, bonus_id: str, child_id: str) -> None:
|
||
"""Apply a bonus — awards the bonus's points to the child."""
|
||
bonus = self.storage.get_bonus(bonus_id)
|
||
if not bonus:
|
||
raise ValueError(f"Bonus {bonus_id} not found")
|
||
child = self.get_child(child_id)
|
||
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(),
|
||
})
|
||
|
||
async def _async_notify_pending_approval(
|
||
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",
|
||
{
|
||
"entry_id": completion_id,
|
||
"child_name": child_name,
|
||
"chore_name": chore_name,
|
||
"points": points,
|
||
"points_name": self.storage.get_points_name(),
|
||
# Evidence photo (#686). Signed here rather than in the
|
||
# notifier so the notifier stays free of photo specifics.
|
||
"photo_url": photos.sign_photo_url(self.hass, photo_url) if photo_url else "",
|
||
},
|
||
)
|
||
|
||
async def _async_notify_pending_reward_claim(
|
||
self, child_name: str, reward_name: str, cost: int,
|
||
claim_id: str | None = None,
|
||
) -> None:
|
||
await self.notifications.fire(
|
||
"pending_reward_claim",
|
||
{
|
||
"entry_id": claim_id,
|
||
"child_name": child_name,
|
||
"reward_name": reward_name,
|
||
"cost": cost,
|
||
"points_name": self.storage.get_points_name(),
|
||
},
|
||
)
|