"""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(), }, )