"""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() child_obj = self.storage.get_child(child_id) for d_str in last_week_dates: day = date.fromisoformat(d_str) # A birthday day off is never a required day (#924). if self.is_birthday_day_off(child_obj, day): continue 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, child: Child | None = None) -> 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, and so are days one of the child's streak-freeze tokens has already covered (#925), as is the child's birthday when it's a day off (#924). """ return bool(self._unprotected_missed_days(last_date_str, today, child)) def _unprotected_missed_days(self, last_date_str: str, today: date, child: Child | None = None) -> list[date]: """Days strictly between the last completion and today that nothing covers. A day is covered by a vacation period, a birthday day off (#924) or by a streak-freeze token the child already spent on it. Anything left is a genuinely missed day. """ try: last_date = date.fromisoformat(last_date_str) except (TypeError, ValueError): return [] frozen = set(getattr(child, "streak_freeze_dates", None) or []) if child is not None else set() missed = [] d = last_date + timedelta(days=1) while d < today: if not self.is_vacation_day(d) and d.isoformat() not in frozen and not self.is_birthday_day_off(child, d): missed.append(d) d += timedelta(days=1) return missed # ── Streak freeze tokens (#925) ────────────────────────────────────── def _gap_bridged_by_freeze(self, child: Child, last_date: date, effective_date: date) -> bool: """True when a streak freeze covered the gap before ``effective_date``. Every day between the last completion and this one must be covered — by a token, or by a vacation day alongside one — and at least one of them by a token. A gap with no token in it keeps its old handling. """ frozen = set(getattr(child, "streak_freeze_dates", None) or []) if not frozen or last_date >= effective_date - timedelta(days=1): return False used = False d = last_date + timedelta(days=1) while d < effective_date: if d.isoformat() in frozen: used = True elif not self.is_vacation_day(d) and not self.is_birthday_day_off(child, d): return False d += timedelta(days=1) return used def _setting_int(self, key: str, default: int) -> int: try: return max(0, int(float(self.storage.get_setting(key, default)))) except (TypeError, ValueError): return default def streak_freeze_max(self) -> int: """How many streak-freeze tokens a child may hold; 0 turns the feature off.""" return self._setting_int("streak_freeze_max", 2) def streak_freeze_earn_every(self) -> int: """Earn a token every N consecutive streak days; 0 means never.""" return self._setting_int("streak_freeze_earn_every", 7) def _log_streak_freeze(self, child: Child, reason: str) -> None: """Record a token movement in the activity log. Tokens are not points, so the entry carries 0 points; the reason is what the activity feed shows (and translates). "Streak freeze" is on the undo deny-list, so nobody can "undo" a zero-point row and expect the token to come back. """ self.storage.add_points_transaction( PointsTransaction(child_id=child.id, points=0, reason=reason, created_at=dt_util.now()) ) def _maybe_earn_streak_freeze(self, child: Child) -> bool: """Hand out a token when the streak lands on a multiple of earn_every. Called only when the streak has just advanced. Records the streak length that earned it (0 when the child was already at the cap), so an undo of this very completion can take back exactly what it gave. """ cap = self.streak_freeze_max() every = self.streak_freeze_earn_every() streak = child.current_streak or 0 if cap <= 0 or every <= 0 or streak <= 0 or streak % every: return False if (child.streak_freezes or 0) >= cap: child.streak_freeze_earned_at = 0 return False child.streak_freezes = (child.streak_freezes or 0) + 1 child.streak_freeze_earned_at = streak self._log_streak_freeze(child, f"Streak freeze earned ({streak} day streak!)") _LOGGER.info("%s earned a streak freeze at a %d-day streak", child.name, streak) return True def _reverse_streak_freeze_earn(self, child: Child, streak_before: int) -> None: """Take back the token a now-undone completion earned, if it earned one.""" earned_at = getattr(child, "streak_freeze_earned_at", 0) or 0 if not earned_at or streak_before != earned_at or (child.current_streak or 0) >= earned_at: return child.streak_freeze_earned_at = 0 if (child.streak_freezes or 0) > 0: child.streak_freezes -= 1 self._log_streak_freeze(child, "Streak freeze reversed") async def async_adjust_streak_freezes(self, child_id: str, amount: int) -> int: """Grant (positive) or remove (negative) streak-freeze tokens. A parent grant respects the cap like every other way of getting one; removal floors at zero. Returns the child's new token count. """ child = self.get_child(child_id) if not child: raise ValueError(f"Child {child_id} not found") if amount == 0: raise ValueError("amount must not be 0") cap = self.streak_freeze_max() if cap <= 0 and amount > 0: raise ValueError("Streak freezes are turned off (streak_freeze_max is 0)") before = child.streak_freezes or 0 after = max(0, before + amount) if amount > 0: after = min(after, max(cap, before)) if after == before: if amount > 0: raise ValueError(f"{child.name} already holds the maximum of {cap} streak freezes") return before child.streak_freezes = after delta = after - before self._log_streak_freeze(child, f"Streak freezes adjusted ({delta:+d})") self.storage.update_child(child) await self.storage.async_save() await self.async_refresh() return after async def _async_use_streak_freeze(self, child: Child, day: date) -> None: """Spend one token on ``day``: log it, tell the family, fire an event.""" child.streak_freezes -= 1 day_str = day.isoformat() # Keep a month of covered days: enough for any gap the streak paths # walk (an undo can move last_completion_date back), bounded so the # child record can't grow without limit. child.streak_freeze_dates = sorted(set(child.streak_freeze_dates or []) | {day_str})[-31:] self._log_streak_freeze(child, f"Streak freeze used ({day_str})") _LOGGER.info("Streak freeze used for %s on %s (%d left)", child.name, day_str, child.streak_freezes) self.hass.bus.async_fire( "taskmate_streak_freeze_used", { "child_id": child.id, "child_name": child.name, "date": day_str, "streak": child.current_streak, "freezes_left": child.streak_freezes, "timestamp": dt_util.now().isoformat(), }, ) if getattr(self, "notifications", None): await self.notifications.fire( "streak_freeze_used", { "child_name": child.name, "child_id": child.id, "date": day_str, "streak": child.current_streak, "freezes_left": child.streak_freezes, }, ) def _gap_is_birthday_days_off(self, child, last_date: date, today: date) -> bool: """True when every day strictly between ``last_date`` and ``today`` was the child's birthday day off — the only gap a completion bridges (#924).""" d = last_date + timedelta(days=1) if d >= today: return False while d < today: if not self.is_birthday_day_off(child, d): return False d += timedelta(days=1) return True 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 Before either happens, each missed day spends one of the child's streak-freeze tokens (#925) while they last — the streak then carries on as if the day had been kept. A day already forgiven (vacation, the child's own unavailability, a streak already frozen/paused) never costs a token: there is nothing to protect. """ today = dt_util.now().date() streak_mode = self.storage.get_setting("streak_reset_mode", "reset") freezes_on = self.streak_freeze_max() > 0 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. missed = self._unprotected_missed_days(last_date_str, today, child) if not missed: continue # Spend a token per missed day while they last. Only a live, # unpaused streak is at risk; a paused one resumes on its own. if freezes_on and (child.current_streak or 0) > 0 and not getattr(child, "streak_paused", False): while missed and (child.streak_freezes or 0) > 0: await self._async_use_streak_freeze(child, missed.pop(0)) self.storage.update_child(child) changed = True if not missed: 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", # Zero-point token movements (#925): nothing to reverse in points. "Streak freeze", # Wishlist (#932): taking points back out and removing a pledge are # the undo for these; reversing a row alone would desync the wish. "Wish savings", "Wish refund", "Wish pledge", "Wish redeemed", ) 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) # An inspection's pass bonus: take the pass back with it (#996). self._inspection_on_bonus_undone(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() birthday_off = self.is_birthday_day_off(self.storage.get_child(child_id), day) 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") # Won at auction (#982): that day's occurrence is the winner's # alone — due for them in any mode, and for nobody else. winner = self.auction_winner(chore, day) if winner: if winner != child_id: continue elif mode not in ("", "everyone"): if not include_rotation: continue if child_id not in self._compute_active_children(chore, day): continue # Weekly target (#883): the child picks the days, so "every chore # due today" can't include it — it's owed by Sunday, not by any one # evening. if int(getattr(chore, "weekly_target", 0) or 0) > 0: continue # Birthday day off (#924): only mandatory chores are still owed. if not getattr(chore, "mandatory", False) and birthday_off: 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 + birthday 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)) # ── Birthday multiplier (#924) ────────────────────────────────────── # Applied after the weekend bonus, so a birthday doubles whatever the # chore would otherwise have paid that day. # (Named "celebration_*" rather than after the birthday so CodeQL's # personal-data heuristics don't read a points multiplier as PII.) celebration_bonus = 0 if self.is_birthday(child, effective_date): celebration_multiplier = self.birthday_multiplier() if celebration_multiplier > 1.0: celebration_bonus = round((points + weekend_bonus) * (celebration_multiplier - 1.0)) total_points = points + weekend_bonus + celebration_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, ) # Deliberately NOT written as its own transaction. The bonus is part # of what `total_points` pays, and the completion records that in # points_awarded — so an extra row here would be the same points # counted twice by anything summing completions and transactions # together (the graph, weekly and leaderboard cards all do). # Households upgrading from an earlier version still have these # rows in their history, so the "Weekend bonus" reason stays in # _UNDO_DENY_PREFIXES and in the locales. # ── 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") and 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) # A birthday day off in between doesn't break the run (#924). if ( last_date == yesterday or self._gap_bridged_by_freeze(child, last_date, effective_date) or (last_date < yesterday and self._gap_is_birthday_days_off(child, last_date, effective_date)) ): 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 if child.current_streak > streak_before: self._maybe_earn_streak_freeze(child) 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(), }, )