"""Calendar platform for TaskMate. Exposes one read-only ``calendar.taskmate_`` 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.", )