187 files
This commit is contained in:
@@ -9,9 +9,15 @@ from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from . import images, photos
|
||||
from . import images, notify_strings, photos
|
||||
from .chore_undo import child_can_undo, undo_window_seconds
|
||||
from .const import CHORE_NOTE_MAX_LENGTH, CHORE_SUGGESTED_POINTS_MAX, DAILY_PROGRESS_KEEP_DAYS, QUALITY_RATINGS
|
||||
from .const import (
|
||||
CHORE_NOTE_MAX_LENGTH,
|
||||
CHORE_SUGGESTED_POINTS_MAX,
|
||||
DAILY_PROGRESS_KEEP_DAYS,
|
||||
QUALITY_RATINGS,
|
||||
recurrence_interval_days,
|
||||
)
|
||||
from .coord_rejections import clean_reject_reason
|
||||
from .coord_teamwork import teamwork_config_error
|
||||
from .models import Chore, ChoreCompletion, PointsTransaction
|
||||
@@ -592,6 +598,7 @@ class ChoresMixin:
|
||||
self.storage.remove_chore(chore_id)
|
||||
self.storage.remove_completions_for_chore(chore_id)
|
||||
self.storage.remove_last_completed_for_chore(chore_id)
|
||||
self.storage.remove_weekly_target_bonuses(chore_id=chore_id)
|
||||
# Strip chore from any task group it belonged to.
|
||||
self.storage.remove_chore_from_task_groups(chore_id)
|
||||
# Drop queued scheduled changes (#675) — nothing left to apply them to.
|
||||
@@ -1025,6 +1032,7 @@ class ChoresMixin:
|
||||
completion.approved_at = dt_util.now()
|
||||
completion.points_awarded = total_awarded
|
||||
self.storage.update_completion(completion)
|
||||
await self._async_pay_weekly_target_bonus(chore, child, completion)
|
||||
|
||||
self.hass.bus.async_fire(
|
||||
"taskmate_chore_completed",
|
||||
@@ -1356,6 +1364,7 @@ class ChoresMixin:
|
||||
self.storage.update_completion(completion)
|
||||
if getattr(completion, "bounty_id", ""):
|
||||
self._bounty_on_approved(completion)
|
||||
await self._async_pay_weekly_target_bonus(chore, child, completion)
|
||||
|
||||
# Dismiss the mobile approval push now this completion is
|
||||
# reviewed (covers single approve AND "approve all", which
|
||||
@@ -1410,7 +1419,7 @@ class ChoresMixin:
|
||||
await self._celebrate(
|
||||
child,
|
||||
"all_chores_done",
|
||||
f"{child.name} finished every chore today!",
|
||||
notify_strings.render(self.hass, "celebrate_all_chores_done", {"child_name": child.name}),
|
||||
tier=1,
|
||||
)
|
||||
else:
|
||||
@@ -1499,6 +1508,8 @@ class ChoresMixin:
|
||||
)
|
||||
)
|
||||
child.streak_milestones_achieved = sorted(d for d in achieved if d <= child.current_streak)
|
||||
# The weekly target bonus this completion helped earn (#1044).
|
||||
self._reverse_weekly_target_bonus(completion, child)
|
||||
|
||||
bonus_completions: list = []
|
||||
is_parent = not target_completion.bonus_subtask_id
|
||||
@@ -1823,11 +1834,8 @@ class ChoresMixin:
|
||||
now = dt_util.now()
|
||||
today = dt_util.as_local(now).date()
|
||||
|
||||
window_days = {
|
||||
"every_2_days": 2,
|
||||
"weekly": 7,
|
||||
"every_2_weeks": 14,
|
||||
}.get(recurrence, 7)
|
||||
interval_days = recurrence_interval_days(recurrence)
|
||||
window_days = interval_days or {"weekly": 7, "every_2_weeks": 14}.get(recurrence, 7)
|
||||
|
||||
record = self.storage.get_last_completed(chore.id, child_id)
|
||||
current_iso = record.get("current")
|
||||
@@ -1863,14 +1871,14 @@ class ChoresMixin:
|
||||
# from, so moving one week's chore doesn't shift every week after it.
|
||||
last_dt = self.occurrence_origin(chore, last_dt)
|
||||
|
||||
# every_2_days with anchor — check alignment
|
||||
if recurrence == "every_2_days" and recurrence_start:
|
||||
# every_<N>_days with anchor — check alignment
|
||||
if interval_days and recurrence_start:
|
||||
try:
|
||||
anchor = date.fromisoformat(recurrence_start)
|
||||
days_since_anchor = (today - anchor).days
|
||||
if days_since_anchor < 0:
|
||||
return False
|
||||
if days_since_anchor % 2 != 0:
|
||||
if days_since_anchor % interval_days != 0:
|
||||
return False
|
||||
return last_dt < today
|
||||
except ValueError:
|
||||
@@ -1920,6 +1928,125 @@ class ChoresMixin:
|
||||
return False
|
||||
return self.weekly_completion_count(chore.id, child_id) >= target
|
||||
|
||||
@staticmethod
|
||||
def _week_key(when) -> str:
|
||||
"""Monday (ISO date) of the local week ``when`` falls in."""
|
||||
day = dt_util.as_local(when).date()
|
||||
return (day - timedelta(days=day.weekday())).isoformat()
|
||||
|
||||
def _weekly_approved_count(self, chore_id: str, child_id: str, week: str, exclude_id: str = "") -> int:
|
||||
"""Approved, non-bonus completions of a chore by a child in ``week``.
|
||||
|
||||
Unlike ``weekly_completion_count`` this ignores pending work: the target
|
||||
bonus (#1044) is points, and points are only paid on approval.
|
||||
"""
|
||||
count = 0
|
||||
for comp in self.storage.get_completions():
|
||||
if (
|
||||
comp.id == exclude_id
|
||||
or comp.chore_id != chore_id
|
||||
or comp.child_id != child_id
|
||||
or not comp.approved
|
||||
or comp.bonus_subtask_id
|
||||
):
|
||||
continue
|
||||
try:
|
||||
if self._week_key(comp.completed_at) == week:
|
||||
count += 1
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
continue
|
||||
return count
|
||||
|
||||
async def _async_pay_weekly_target_bonus(self, chore, child, completion) -> None:
|
||||
"""Pay the chore's weekly target bonus once its week's quota is approved (#1044).
|
||||
|
||||
Paid once per chore, child and week — the week the completions belong
|
||||
to, so approving last Sunday's work on a Monday still settles last
|
||||
week. ``_reverse_weekly_target_bonus`` takes it back if the week drops
|
||||
below the target again.
|
||||
"""
|
||||
bonus = int(getattr(chore, "weekly_target_bonus", 0) or 0)
|
||||
target = int(getattr(chore, "weekly_target", 0) or 0)
|
||||
if bonus <= 0 or target <= 0 or completion.bonus_subtask_id:
|
||||
return
|
||||
week = self._week_key(completion.completed_at)
|
||||
paid = dict(self.storage.get_weekly_target_bonus(chore.id, child.id))
|
||||
if week in paid:
|
||||
return
|
||||
if self._weekly_approved_count(chore.id, child.id, week) < target:
|
||||
return
|
||||
# Claim the week before anything below can suspend, so two approvals
|
||||
# landing together can't both pay it. Only the last few weeks are kept.
|
||||
paid[week] = bonus
|
||||
self.storage.set_weekly_target_bonus(chore.id, child.id, dict(sorted(paid.items())[-4:]))
|
||||
child.points += bonus
|
||||
child.total_points_earned += bonus
|
||||
child.career_score = child.total_points_earned - child.total_penalties_received
|
||||
self.storage.add_points_transaction(
|
||||
PointsTransaction(
|
||||
child_id=child.id,
|
||||
points=bonus,
|
||||
reason=f"Weekly target bonus: {chore.name}",
|
||||
created_at=dt_util.now(),
|
||||
)
|
||||
)
|
||||
await self._maybe_level_up(child)
|
||||
self.storage.update_child(child)
|
||||
_LOGGER.info("Weekly target for '%s' reached by %s (+%d)", chore.name, child.name, bonus)
|
||||
self.hass.bus.async_fire(
|
||||
"taskmate_weekly_target_reached",
|
||||
{
|
||||
"child_id": child.id,
|
||||
"child_name": child.name,
|
||||
"chore_id": chore.id,
|
||||
"chore_name": chore.name,
|
||||
"target": target,
|
||||
"bonus": bonus,
|
||||
"timestamp": dt_util.now().isoformat(),
|
||||
},
|
||||
)
|
||||
await self._celebrate(
|
||||
child,
|
||||
"weekly_target_reached",
|
||||
notify_strings.render(
|
||||
self.hass,
|
||||
"celebrate_weekly_target",
|
||||
{"child_name": child.name, "chore_name": chore.name, "bonus": bonus},
|
||||
),
|
||||
tier=2,
|
||||
extra={"chore_id": chore.id, "bonus": bonus},
|
||||
)
|
||||
|
||||
def _reverse_weekly_target_bonus(self, completion, child) -> None:
|
||||
"""Refund a paid weekly target bonus the approved ``completion`` no
|
||||
longer leaves standing (#1044). The caller syncs the career score."""
|
||||
if completion.bonus_subtask_id or not child:
|
||||
return
|
||||
week = self._week_key(completion.completed_at)
|
||||
paid = dict(self.storage.get_weekly_target_bonus(completion.chore_id, child.id))
|
||||
if week not in paid:
|
||||
return
|
||||
chore = self.get_chore(completion.chore_id)
|
||||
target = int(getattr(chore, "weekly_target", 0) or 0)
|
||||
if target > 0 and (
|
||||
self._weekly_approved_count(completion.chore_id, child.id, week, exclude_id=completion.id) >= target
|
||||
):
|
||||
return
|
||||
refund = int(paid.pop(week) or 0)
|
||||
self.storage.set_weekly_target_bonus(completion.chore_id, child.id, paid or None)
|
||||
if refund <= 0:
|
||||
return
|
||||
child.points = max(0, child.points - refund)
|
||||
child.total_points_earned = max(0, child.total_points_earned - refund)
|
||||
self.storage.add_points_transaction(
|
||||
PointsTransaction(
|
||||
child_id=child.id,
|
||||
points=-refund,
|
||||
reason=f"Weekly target bonus reversed: {getattr(chore, 'name', '')}".rstrip(": "),
|
||||
created_at=dt_util.now(),
|
||||
)
|
||||
)
|
||||
|
||||
def _is_chore_completable_by_child(self, chore, child_id: str) -> bool:
|
||||
"""Whether ``child_id`` may complete ``chore`` right now (card parity).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user