456 files

This commit is contained in:
Home Assistant Version Control
2026-10-01 05:38:21 +00:00
parent 4192ecbaae
commit 5d6d845ebf
456 changed files with 64951 additions and 3828 deletions
+238 -12
View File
@@ -57,8 +57,12 @@ class PointsMixin:
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:
@@ -154,23 +158,188 @@ class PointsMixin:
await self.storage.async_save()
await self.async_refresh()
def _streak_breaks_after_gap(self, last_date_str: str, today: date) -> bool:
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.
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 False
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):
return True
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 False
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.
@@ -178,10 +347,17 @@ class PointsMixin:
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
@@ -204,9 +380,20 @@ class PointsMixin:
# 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):
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)
@@ -307,6 +494,14 @@ class PointsMixin:
"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:
@@ -361,6 +556,8 @@ class PointsMixin:
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()
@@ -667,6 +864,7 @@ class PointsMixin:
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
@@ -676,7 +874,13 @@ class PointsMixin:
if child_id in getattr(chore, "disabled_for", []):
continue
mode = getattr(chore, "assignment_mode", "everyone")
if mode not in ("", "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):
@@ -686,6 +890,9 @@ class PointsMixin:
# 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)
@@ -731,8 +938,8 @@ class PointsMixin:
) -> 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).
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).
@@ -754,7 +961,18 @@ class PointsMixin:
if effective_date.weekday() in (5, 6) and multiplier > 1.0:
weekend_bonus = round(points * (multiplier - 1.0))
total_points = points + weekend_bonus
# ── 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
@@ -803,7 +1021,12 @@ class PointsMixin:
try:
last_date = date.fromisoformat(last_date_str)
yesterday = effective_date - timedelta(days=1)
if last_date == yesterday:
# 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:
@@ -827,6 +1050,9 @@ class PointsMixin:
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",
{