456 files
This commit is contained in:
@@ -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",
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user