Files
2026-07-08 10:43:39 -04:00

345 lines
14 KiB
Python

"""Mandatory-chore detection, scheduling, and resolution (#532)."""
from __future__ import annotations
import logging
from datetime import date, datetime, timedelta
from datetime import time as dt_time
from homeassistant.core import callback
from homeassistant.helpers.event import (
async_track_time_change,
async_track_time_interval,
)
from homeassistant.util import dt as dt_util
from .const import (
NOTIF_TYPE_MANDATORY_PARENT_ALERT,
NOTIF_TYPE_MANDATORY_REMINDER,
)
from .models import MandatoryMiss, parse_datetime
# How often the escalation ladder is re-evaluated (FEAT-6).
_ESCALATION_INTERVAL = timedelta(minutes=5)
_LOGGER = logging.getLogger(__name__)
# Assignment modes where every assigned child owes the chore. Rotation modes
# point to a single current child via assignment_current_child_id.
_SHARED_MODES = {"everyone", "first_come", "unassigned"}
class MandatoryMixin:
"""Detection + scheduling + resolution for mandatory chores."""
def _ensure_mandatory_state(self) -> None:
if not hasattr(self, "mandatory_postpone"):
self.mandatory_postpone = {}
def _effective_period_for(self, chore, child_id: str, day: date) -> str:
"""The chore's period for this child/day, honoring any postpone override."""
self._ensure_mandatory_state()
key = f"{chore.id}:{child_id}:{day.isoformat()}"
return self.mandatory_postpone.get(key) or (chore.time_category or "anytime")
def _mandatory_owers(self, chore) -> list[str]:
"""Child IDs responsible for this chore right now."""
mode = getattr(chore, "assignment_mode", "everyone")
assigned = list(getattr(chore, "assigned_to", []) or [])
if mode in _SHARED_MODES:
if assigned:
return assigned
return [c.id for c in self.storage.get_children()]
cur = getattr(chore, "assignment_current_child_id", "") or ""
return [cur] if cur else []
def _child_completed_today(self, chore_id: str, child_id: str, day: date) -> bool:
for comp in self.storage.get_completions():
if comp.chore_id != chore_id or comp.child_id != child_id:
continue
if comp.bonus_subtask_id:
continue
if dt_util.as_local(comp.completed_at).date() == day:
return True
return False
async def async_detect_mandatory_misses(self, period_id: str, day: date) -> int:
"""Create misses for due+incomplete mandatory chores in `period_id`."""
existing = {
(m.chore_id, m.child_id, m.due_date)
for m in self.storage.get_mandatory_misses()
}
created = 0
for chore in self.storage.get_chores():
if not getattr(chore, "mandatory", False):
continue
if not getattr(chore, "enabled", True):
continue
if not self._is_chore_scheduled_for_date(chore, day):
continue
for child_id in self._mandatory_owers(chore):
if self._effective_period_for(chore, child_id, day) != period_id:
continue
if child_id in (getattr(chore, "disabled_for", []) or []):
continue
if self._child_completed_today(chore.id, child_id, day):
continue
if (chore.id, child_id, day.isoformat()) in existing:
continue
miss = MandatoryMiss(
chore_id=chore.id,
child_id=child_id,
due_date=day.isoformat(),
period_id=period_id,
penalty_points=int(getattr(chore, "mandatory_penalty_points", 0) or 0),
)
self.storage.add_mandatory_miss(miss)
created += 1
self.hass.bus.async_fire("taskmate_mandatory_missed", {
"miss_id": miss.id, "chore_id": chore.id, "child_id": child_id,
"period_id": period_id, "penalty_points": miss.penalty_points,
"timestamp": dt_util.now().isoformat(),
})
if created:
await self.storage.async_save()
await self.async_refresh()
return created
# ---- resolution actions ------------------------------------------------
def _get_miss(self, miss_id: str) -> MandatoryMiss | None:
for m in self.storage.get_mandatory_misses():
if m.id == miss_id:
return m
return None
def _next_period_after(self, period_id: str, now: datetime) -> str | None:
"""Next period whose end is still in the future today, by start order."""
cur_now = now.time()
for p in self.get_time_periods():
try:
eh, em = [int(x) for x in p["end"].split(":")]
except (ValueError, KeyError):
continue
if dt_time(eh, em) > cur_now and p["id"] != period_id:
return p["id"]
return None
async def async_apply_mandatory_penalty(self, miss_id: str) -> None:
miss = self._get_miss(miss_id)
if miss is None:
return
chore = next((c for c in self.storage.get_chores() if c.id == miss.chore_id), None)
name = getattr(chore, "name", "chore")
if miss.penalty_points > 0:
await self.async_remove_points(
miss.child_id, miss.penalty_points,
reason=f"Penalty: {name} (missed mandatory)",
)
self.storage.remove_mandatory_miss(miss_id)
await self.storage.async_save()
self.hass.bus.async_fire("taskmate_mandatory_penalty_applied", {
"miss_id": miss_id, "chore_id": miss.chore_id, "child_id": miss.child_id,
"points": miss.penalty_points, "timestamp": dt_util.now().isoformat(),
})
await self.async_refresh()
async def async_postpone_mandatory_chore(self, miss_id: str) -> None:
miss = self._get_miss(miss_id)
if miss is None:
return
self._ensure_mandatory_state()
now = dt_util.now()
nxt = self._next_period_after(miss.period_id, now)
if nxt:
key = f"{miss.chore_id}:{miss.child_id}:{miss.due_date}"
self.mandatory_postpone[key] = nxt
# else: no window left today -> let normal scheduling resurface tomorrow
self.storage.remove_mandatory_miss(miss_id)
await self.storage.async_save()
self.hass.bus.async_fire("taskmate_mandatory_postponed", {
"miss_id": miss_id, "chore_id": miss.chore_id, "child_id": miss.child_id,
"next_period": nxt or "", "timestamp": dt_util.now().isoformat(),
})
await self.async_refresh()
async def async_dismiss_mandatory_chore(self, miss_id: str) -> None:
miss = self._get_miss(miss_id)
if miss is None:
return
self.storage.remove_mandatory_miss(miss_id)
await self.storage.async_save()
self.hass.bus.async_fire("taskmate_mandatory_dismissed", {
"miss_id": miss_id, "chore_id": miss.chore_id, "child_id": miss.child_id,
"timestamp": dt_util.now().isoformat(),
})
await self.async_refresh()
# ---- escalation (FEAT-6) ----------------------------------------------
async def async_escalate_mandatory_misses(self, now: datetime | None = None) -> int:
"""Walk today's open mandatory misses and advance each escalation stage.
Ladder driven by minutes elapsed since the miss was created:
stage 1 (nudge) immediately -> mandatory_reminder (child)
stage 2 (reminder) after reminder_minutes -> mandatory_reminder
stage 3 (parent alert) after parent_minutes -> mandatory_parent_alert
A miss whose chore has since been completed by that child is skipped (it
will be resolved by the parent / next-day prune). Each notification is
still gated by its own master switch + routes inside ``fire()``; stages
advance regardless so a disabled type is not retro-fired when re-enabled.
Returns the number of misses whose stage advanced.
"""
now = now or dt_util.now()
today_iso = now.date().isoformat()
reminder_minutes = self.storage.get_escalation_reminder_minutes()
parent_minutes = self.storage.get_escalation_parent_minutes()
chores = {c.id: c for c in self.storage.get_chores()}
children = {c.id: c for c in self.storage.get_children()}
advanced = 0
for miss in self.storage.get_mandatory_misses():
if miss.due_date != today_iso:
continue # only escalate same-day misses (skips midnight backfill)
if self._child_completed_today(miss.chore_id, miss.child_id, now.date()):
continue
created = parse_datetime(miss.created_at)
if created is None:
continue
elapsed_min = (now - created).total_seconds() / 60.0
target = 1
if elapsed_min >= reminder_minutes:
target = 2
if elapsed_min >= parent_minutes:
target = 3
if target <= miss.escalation_stage:
continue
chore = chores.get(miss.chore_id)
child = children.get(miss.child_id)
ctx = {
"child_id": miss.child_id,
"child_name": getattr(child, "name", ""),
"chore_name": getattr(chore, "name", "chore"),
}
for stage in range(miss.escalation_stage + 1, target + 1):
if stage in (1, 2):
await self.notifications.fire(
NOTIF_TYPE_MANDATORY_REMINDER, ctx,
only_recipients={f"child:{miss.child_id}"},
)
elif stage == 3:
await self.notifications.fire(
NOTIF_TYPE_MANDATORY_PARENT_ALERT, ctx,
)
miss.escalation_stage = target
self.storage.update_mandatory_miss(miss)
advanced += 1
if advanced:
await self.storage.async_save()
return advanced
# ---- scheduling --------------------------------------------------------
def _mandatory_period_end_times(self) -> list[tuple[int, int, str]]:
"""Distinct (hour, minute, period_id) from each period's end time."""
out: list[tuple[int, int, str]] = []
for p in self.get_time_periods():
try:
h, m = [int(x) for x in p["end"].split(":")]
except (ValueError, KeyError):
continue
out.append((h, m, p["id"]))
return out
def arm_mandatory_schedules(self) -> None:
"""Register a callback at each period's end time + the escalation tick."""
self._ensure_mandatory_state()
self._unsub_mandatory = getattr(self, "_unsub_mandatory", [])
for hour, minute, period_id in self._mandatory_period_end_times():
unsub = async_track_time_change(
self.hass,
self._make_mandatory_period_cb(period_id),
hour=hour, minute=minute, second=10,
)
self._unsub_mandatory.append(unsub)
# Reminder escalation ladder (FEAT-6) — re-evaluate open misses on a tick.
self._unsub_mandatory.append(
async_track_time_interval(
self.hass, self._escalation_tick, _ESCALATION_INTERVAL,
)
)
@callback
def _escalation_tick(self, now: datetime) -> None:
self.hass.async_create_task(self.async_escalate_mandatory_misses(now))
def _make_mandatory_period_cb(self, period_id: str):
@callback
def _cb(now: datetime) -> None:
self.hass.async_create_task(
self.async_detect_mandatory_misses(period_id, dt_util.now().date())
)
return _cb
def disarm_mandatory_schedules(self) -> None:
for unsub in getattr(self, "_unsub_mandatory", []):
unsub()
self._unsub_mandatory = []
async def async_rearm_mandatory_schedules(self) -> None:
"""Re-arm after the time_periods setting changes."""
self.disarm_mandatory_schedules()
self.arm_mandatory_schedules()
async def async_catchup_mandatory_misses(self) -> None:
"""On startup, detect misses for any period that already ended today.
The period-end callbacks only fire going forward, so a restart after a
boundary would otherwise miss that period for the rest of the day. The
detector's existing-item guard keeps this idempotent.
"""
now = dt_util.now()
today = now.date()
cur = now.time()
for hour, minute, period_id in self._mandatory_period_end_times():
if dt_time(hour, minute) <= cur:
await self.async_detect_mandatory_misses(period_id, today)
async def async_detect_anytime_mandatory_misses(self) -> None:
"""Midnight: raise misses for incomplete mandatory 'anytime' chores for the
day that just ended, then prune the postpone override map (new day starts clean)."""
self._ensure_mandatory_state()
yesterday = (dt_util.now() - timedelta(days=1)).date()
await self.async_detect_mandatory_misses("anytime", yesterday)
self.mandatory_postpone = {}
# ---- maintenance -------------------------------------------------------
async def async_prune_orphan_misses(self) -> int:
"""Drop misses whose chore is gone, disabled, or no longer mandatory."""
chores = {c.id: c for c in self.storage.get_chores()}
removed = 0
for m in self.storage.get_mandatory_misses():
chore = chores.get(m.chore_id)
if chore is None or not getattr(chore, "mandatory", False) or not getattr(chore, "enabled", True):
self.storage.remove_mandatory_miss(m.id)
removed += 1
if removed:
await self.storage.async_save()
await self.async_refresh()
return removed
def mandatory_misses_state(self) -> list[dict]:
"""Misses enriched with chore/child display names for the UI."""
chores = {c.id: c for c in self.storage.get_chores()}
children = {c.id: c for c in self.storage.get_children()}
out = []
for m in self.storage.get_mandatory_misses():
d = m.to_dict()
d["chore_name"] = getattr(chores.get(m.chore_id), "name", "")
d["child_name"] = getattr(children.get(m.child_id), "name", "")
out.append(d)
return out