Files
HomeAssistantVS/custom_components/taskmate/calendar.py
T

261 lines
10 KiB
Python

"""Calendar platform for TaskMate.
Exposes one read-only ``calendar.taskmate_<child>`` entity per child. Events are
derived live from existing TaskMate data — there is no second store and nothing
to keep in sync:
* chore occurrences come from the recurrence/assignment engine
(``_is_chore_scheduled_for_date`` + ``_compute_active_children``), rendered
as timed events inside the configured time-of-day window or as all-day
events when the chore is "anytime";
* away / unavailable blocks come from ``_is_child_on_vacation`` (#525),
coalesced into multi-day all-day events. On an away day the child's chores
are hidden, mirroring the rest of the integration.
Read-only for now: completing a chore from the calendar is intentionally not
supported.
"""
from __future__ import annotations
import logging
from datetime import date, datetime, timedelta
from homeassistant.components.calendar import CalendarEntity, CalendarEvent
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util
from .const import DOMAIN
from .coordinator import TaskMateCoordinator
from .entity import taskmate_device_info
from .models import Child, Chore
_LOGGER = logging.getLogger(__name__)
# How far ahead the `event` (next-up) property scans for the soonest event.
_NEXT_EVENT_HORIZON_DAYS = 60
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up one calendar entity per child, adding more as children are created."""
coordinator: TaskMateCoordinator = hass.data[DOMAIN][entry.entry_id]
tracked: set[str] = set()
def _new_entities() -> list[TaskMateCalendar]:
out: list[TaskMateCalendar] = []
for child in coordinator.data.get("children", []):
if child.id in tracked:
continue
tracked.add(child.id)
out.append(TaskMateCalendar(coordinator, entry, child))
return out
async_add_entities(_new_entities())
@callback
def _async_add_new() -> None:
new = _new_entities()
if new:
async_add_entities(new)
coordinator.async_add_listener(_async_add_new)
def _chore_applies_to_child(coordinator: TaskMateCoordinator, chore: Chore, child_id: str, day: date) -> bool:
"""True if ``chore`` is scheduled for ``child_id`` on ``day``.
Combines the recurrence schedule with the assignment engine so the calendar
matches who would actually see the chore — without consulting completion
state (the calendar projects the schedule, not today's done/not-done).
"""
if not getattr(chore, "enabled", True):
return False
if getattr(chore, "assignment_mode", "everyone") == "unassigned":
return False
if not coordinator._is_chore_scheduled_for_date(chore, day):
return False
active = coordinator._compute_active_children(chore, day)
if active:
return child_id in active
# Empty active set means an unrestricted "everyone" chore (no assigned_to).
return not chore.assigned_to
def _chore_description(chore: Chore) -> str:
"""Compact one-line description for a chore event."""
parts = ["TaskMate chore"]
pts = getattr(chore, "points", 0) or 0
parts.append(f"{pts} pts")
cat = getattr(chore, "time_category", "anytime") or "anytime"
if cat != "anytime":
parts.append(cat)
return " · ".join(parts)
def _as_local_dt(value: date | datetime) -> datetime:
"""Normalise a CalendarEvent start/end to an aware local datetime for sorting."""
if isinstance(value, datetime):
if value.tzinfo is None:
return value.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE)
return value
return datetime(value.year, value.month, value.day, tzinfo=dt_util.DEFAULT_TIME_ZONE)
class TaskMateCalendar(CoordinatorEntity, CalendarEntity):
"""A read-only calendar of one child's chores and away periods."""
_attr_icon = "mdi:calendar-account"
def __init__(
self,
coordinator: TaskMateCoordinator,
entry: ConfigEntry,
child: Child,
) -> None:
super().__init__(coordinator)
self._entry = entry
self._child_id = child.id
self._attr_unique_id = f"{entry.entry_id}_{child.id}_calendar"
self._attr_name = f"TaskMate {child.name}"
self._events_cache: list[CalendarEvent] | None = None
self._events_key: tuple | None = None
@property
def device_info(self) -> DeviceInfo:
return taskmate_device_info(self._entry.entry_id)
@property
def _child(self) -> Child | None:
return self.coordinator.storage.get_child(self._child_id)
@property
def available(self) -> bool:
return self._child is not None and super().available
@property
def event(self) -> CalendarEvent | None:
"""The current or next upcoming event (HA shows this as the entity state).
Home Assistant reads this twice per state write (once for the state,
once for the state attributes), and writes on every coordinator
refresh — so the horizon projection is memoized against the same key
the sensors use: the data snapshot plus the external-entity version
that covers availability/visibility flips (#823). Only the cheap
"which one is next" filter re-runs.
"""
child = self._child
if not child:
return None
now = dt_util.now()
today = now.date()
events = self._cached_horizon(child, today)
upcoming = [e for e in events if _as_local_dt(e.end) > now]
upcoming.sort(key=lambda e: _as_local_dt(e.start))
return upcoming[0] if upcoming else None
def _cached_horizon(self, child: Child, today: date) -> list[CalendarEvent]:
"""The next-up horizon for ``today``, rebuilt only when inputs change."""
key = (
today,
id(self.coordinator.data),
getattr(self.coordinator, "external_state_version", 0),
)
cached = getattr(self, "_events_cache", None)
if cached is not None and getattr(self, "_events_key", None) == key:
return cached
events = self._build_events(child, today, today + timedelta(days=_NEXT_EVENT_HORIZON_DAYS))
self._events_cache = events
self._events_key = key
return events
async def async_get_events(
self, hass: HomeAssistant, start_date: datetime, end_date: datetime
) -> list[CalendarEvent]:
"""Return all events overlapping the requested range."""
child = self._child
if not child:
return []
return self._build_events(child, start_date.date(), end_date.date())
def _build_events(self, child: Child, start_day: date, end_day: date) -> list[CalendarEvent]:
with self.coordinator.availability_build_scope():
return self._build_events_locked(child, start_day, end_day)
def _build_events_locked(self, child: Child, start_day: date, end_day: date) -> list[CalendarEvent]:
"""Project the range. Must run inside ``availability_build_scope`` so the
rotation pool and balanced-mode grouping resolve from the scope cache
instead of rebuilding every stored chore/child per (chore, day) (#823).
"""
coord = self.coordinator
events: list[CalendarEvent] = []
# ---- away blocks: coalesce consecutive away days into one all-day event ----
run_start: date | None = None
run_label: str | None = None
day = start_day
while day <= end_day:
if coord._is_child_on_vacation(child, day):
if run_start is None:
period = coord.active_vacation(day)
run_label = (period or {}).get("name") or ""
run_start = day
elif run_start is not None:
events.append(_away_event(run_start, day, run_label))
run_start = None
day += timedelta(days=1)
if run_start is not None:
events.append(_away_event(run_start, end_day + timedelta(days=1), run_label))
# ---- chore occurrences (skipped on away days) ----
chores = coord._cached_chores()
tz = dt_util.DEFAULT_TIME_ZONE
day = start_day
while day <= end_day:
if not coord._is_child_on_vacation(child, day):
for chore in chores:
if not _chore_applies_to_child(coord, chore, child.id, day):
continue
window = coord._time_category_window(getattr(chore, "time_category", "anytime"), day)
desc = _chore_description(chore)
if window is None:
events.append(
CalendarEvent(
start=day,
end=day + timedelta(days=1),
summary=chore.name,
description=desc,
)
)
else:
start_dt, end_dt = window
events.append(
CalendarEvent(
start=start_dt.replace(tzinfo=tz),
end=end_dt.replace(tzinfo=tz),
summary=chore.name,
description=desc,
)
)
day += timedelta(days=1)
return events
def _away_event(start_day: date, end_day_exclusive: date, label: str | None) -> CalendarEvent:
"""Build a coalesced all-day 'away' event over [start, end)."""
summary = f"Away — {label}" if label else "Away"
return CalendarEvent(
start=start_day,
end=end_day_exclusive,
summary=summary,
description="Unavailable — streak paused and chores hidden.",
)