Files
HomeAssistantVS/custom_components/taskmate/coord_calendar.py
T

625 lines
26 KiB
Python

"""Calendar operations mixin for TaskMateCoordinator."""
from __future__ import annotations
import asyncio
import logging
from calendar import monthrange
from datetime import date, datetime, time, timedelta
from typing import TYPE_CHECKING
from homeassistant.exceptions import ServiceValidationError
from homeassistant.util import dt as dt_util
from .const import (
DEFAULT_CALENDAR_PROJECTION_DAYS,
DEFAULT_TIME_PERIODS,
DOMAIN,
MAX_CALENDAR_PROJECTION_DAYS,
MIN_CALENDAR_PROJECTION_DAYS,
TIME_CATEGORY_ICONS,
)
from .models import Chore
if TYPE_CHECKING:
pass
_LOGGER = logging.getLogger(__name__)
class CalendarMixin:
"""Mixin providing calendar projection and event publishing logic."""
_SCHEDULE_DOW = ("monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday")
@staticmethod
def _parse_hhmm(value: str) -> time | None:
"""Parse an HH:MM string, returning None when invalid."""
try:
h, m = (int(x) for x in str(value).split(":"))
return time(h, m)
except (ValueError, TypeError):
return None
def get_time_periods(self) -> list[dict]:
"""Resolve the user-defined time-of-day periods, ordered by start.
Fallback chain: `time_periods` setting → legacy `time_<cat>_start/end`
keys → DEFAULT_TIME_PERIODS. "anytime" is implicit and never listed.
"""
raw = self.storage.get_setting("time_periods", None)
if isinstance(raw, list) and raw:
periods = []
for entry in raw:
if not isinstance(entry, dict):
continue
start = self._parse_hhmm(entry.get("start"))
end = self._parse_hhmm(entry.get("end"))
pid = str(entry.get("id") or "").strip()
if not pid or pid == "anytime" or start is None or end is None:
continue
periods.append(
{
"id": pid,
"label": str(entry.get("label") or "").strip(),
"start": start.strftime("%H:%M"),
"end": end.strftime("%H:%M"),
"icon": str(entry.get("icon") or "") or TIME_CATEGORY_ICONS.get(pid, "mdi:clock-outline"),
}
)
if periods:
return sorted(periods, key=lambda p: p["start"])
# Legacy flat keys (pre-time_periods installs); these already default
# to the built-in boundaries, so this also covers fresh installs.
periods = []
for default in DEFAULT_TIME_PERIODS:
pid = default["id"]
start_str = self.storage.get_setting(f"time_{pid}_start", default["start"])
end_str = self.storage.get_setting(f"time_{pid}_end", default["end"])
start = self._parse_hhmm(start_str) or self._parse_hhmm(default["start"])
end = self._parse_hhmm(end_str) or self._parse_hhmm(default["end"])
periods.append(
{
"id": pid,
"label": "",
"start": start.strftime("%H:%M"),
"end": end.strftime("%H:%M"),
"icon": default["icon"],
}
)
return sorted(periods, key=lambda p: p["start"])
def _get_time_boundaries(self) -> dict[str, tuple[time, time] | None]:
"""Build time boundaries from the resolved periods."""
result: dict[str, tuple[time, time] | None] = {"anytime": None}
for period in self.get_time_periods():
result[period["id"]] = (
self._parse_hhmm(period["start"]),
self._parse_hhmm(period["end"]),
)
return result
def _time_category_window(self, category: str, today: date) -> tuple[datetime, datetime] | None:
"""Return (start, end) datetimes for a time_category, or None for all-day."""
boundaries = self._get_time_boundaries()
window = boundaries.get(category or "anytime")
if window is None:
return None
start_t, end_t = window
return datetime.combine(today, start_t), datetime.combine(today, end_t)
# A chore with a due_time shows as a short event starting then (#993).
_DUE_TIME_EVENT_MINUTES = 30
def _chore_event_window(self, chore: Chore, day: date) -> tuple[datetime, datetime] | None:
"""Return the (start, end) a chore's event covers on ``day``, or None for all-day.
A valid due_time wins: the event starts at it and lasts
``_DUE_TIME_EVENT_MINUTES`` (possibly past midnight). Without one, the
event spans the chore's time-of-day period, or the whole day for anytime.
"""
due = self._parse_hhmm(getattr(chore, "due_time", "") or "")
if due is not None:
start = datetime.combine(day, due)
return start, start + timedelta(minutes=self._DUE_TIME_EVENT_MINUTES)
return self._time_category_window(getattr(chore, "time_category", "anytime"), day)
def _chore_event_marker(self, chore: Chore) -> str:
"""Marker stitched into the event description so we can find our own events."""
return f"taskmate:chore:{chore.id}"
def _calendar_projection_days(self) -> int:
"""Return the configured projection horizon, clamped to the allowed range."""
try:
raw = int(
float(self.storage.get_setting("calendar_projection_days", str(DEFAULT_CALENDAR_PROJECTION_DAYS)))
)
except (TypeError, ValueError):
raw = DEFAULT_CALENDAR_PROJECTION_DAYS
return max(MIN_CALENDAR_PROJECTION_DAYS, min(MAX_CALENDAR_PROJECTION_DAYS, raw))
@staticmethod
def occurrence_override(chore: Chore, day: date) -> bool | None:
"""How a calendar move/removal (#977) changes ``day`` for ``chore``.
True: an occurrence was moved onto ``day``. False: the occurrence that
belonged to ``day`` was moved away or removed. None: nothing was
changed on ``day`` and the regular schedule decides.
"""
moved = getattr(chore, "moved_occurrences", None) or {}
if not moved:
return None
iso = day.isoformat()
if iso in moved.values():
return True
if iso in moved:
return False
return None
@staticmethod
def occurrence_origin(chore: Chore, day: date) -> date:
"""The scheduled date the occurrence held on ``day`` belongs to.
A moved occurrence keeps the identity of the day it was moved from, so
a recurrence window measures from the original date and the calendar
event keeps the same uid wherever it is moved to.
"""
moved = getattr(chore, "moved_occurrences", None) or {}
iso = day.isoformat()
for src, dst in moved.items():
if dst == iso:
try:
return date.fromisoformat(src)
except ValueError:
break
return day
def _is_chore_scheduled_for_date(self, chore: Chore, day: date) -> bool:
"""Return True if the chore is due on `day`, calendar moves included.
Every caller that asks "is this chore on this day" — the calendar
entity, the ICS feed, calendar publishing, streaks, perfect weeks and
mandatory misses — goes through here, so a moved or removed occurrence
(#977) is honoured everywhere at once.
"""
if not getattr(chore, "enabled", True):
return False
override = self.occurrence_override(chore, day)
if override is not None:
return override
return self._is_chore_base_scheduled_for_date(chore, day)
def _is_chore_base_scheduled_for_date(self, chore: Chore, day: date) -> bool:
"""Return True if the chore's regular schedule places it on `day`.
Mirrors the client-side `_isChoreScheduledOn` in taskmate-calendar-card.js
so the HA calendar projection matches what the card shows. Does not
consult completion state or calendar moves — this is purely the
recurrence/schedule math.
"""
if not getattr(chore, "enabled", True):
return False
schedule_mode = getattr(chore, "schedule_mode", "specific_days")
created_iso = getattr(chore, "created_date", "") or ""
if schedule_mode == "one_shot":
return bool(created_iso) and created_iso == day.isoformat()
if created_iso:
try:
if day < date.fromisoformat(created_iso):
return False
except ValueError:
pass
if schedule_mode == "specific_days":
due_days = list(getattr(chore, "due_days", []) or [])
if not due_days:
return True
return self._SCHEDULE_DOW[day.weekday()] in due_days
if schedule_mode != "recurring":
return False
recurrence = getattr(chore, "recurrence", "weekly")
recurrence_day = (getattr(chore, "recurrence_day", "") or "").lower()
recurrence_start = getattr(chore, "recurrence_start", "") or ""
# For interval recurrences, fall back to created_date as the cadence
# anchor when no explicit recurrence_start is set — otherwise these
# chores never project onto the calendar at all (ERR-2).
anchor_iso = recurrence_start or created_iso
day_dow = self._SCHEDULE_DOW[day.weekday()]
if recurrence_day and recurrence in ("weekly", "every_2_weeks"):
if recurrence_day != day_dow:
return False
if recurrence == "every_2_weeks" and recurrence_start:
try:
anchor = date.fromisoformat(recurrence_start)
diff = (day - anchor).days
if diff < 0 or (diff // 7) % 2 != 0:
return False
except ValueError:
pass
return True
if recurrence == "every_2_days" and anchor_iso:
try:
anchor = date.fromisoformat(anchor_iso)
diff = (day - anchor).days
return diff >= 0 and diff % 2 == 0
except ValueError:
return False
month_steps = {"monthly": 1, "every_3_months": 3, "every_6_months": 6}.get(recurrence)
if month_steps and anchor_iso:
try:
anchor = date.fromisoformat(anchor_iso)
if day < anchor:
return False
months_diff = (day.year - anchor.year) * 12 + (day.month - anchor.month)
if months_diff % month_steps != 0:
return False
# Clamp the anchor day for shorter months (e.g. 31st → Feb 28th)
target_day = min(anchor.day, monthrange(day.year, day.month)[1])
return day.day == target_day
except ValueError:
return False
# Weekly/every_2_weeks without an explicit day: same weekday as today,
# matching the card's fallback for loosely-scheduled recurrences.
if recurrence in ("weekly", "every_2_weeks"):
today = dt_util.as_local(dt_util.now()).date()
return day.weekday() == today.weekday()
return False
def _build_event_payload(self, chore: Chore, day: date, summary: str) -> dict:
"""Build the calendar.create_event payload for one (chore, day)."""
description = self._chore_event_marker(chore)
window = self._chore_event_window(chore, day)
if window is None:
return {
"summary": summary,
"description": description,
"start_date": day.isoformat(),
"end_date": (day + timedelta(days=1)).isoformat(),
}
start_dt, end_dt = window
return {
"summary": summary,
"description": description,
"start_date_time": start_dt.isoformat(),
"end_date_time": end_dt.isoformat(),
}
def _child_name_for_day(self, chore: Chore, day: date) -> str:
"""Return the display name for the chore's assignee on `day`."""
active = self._compute_active_children(chore, day)
if not active:
return "Everyone"
child = self.storage.get_child(active[0])
return child.name if child else "Everyone"
async def _publish_chore_to_calendars(self, chore: Chore, today: date | None = None) -> None:
"""Publish assignments for `chore` across the configured projection horizon.
For every date in [today, today + horizon) that the schedule covers and
hasn't already been published, computes that date's active child and
writes an event to each configured calendar. Past entries are pruned
from `publish_calendar_published_dates` so the list doesn't grow
unbounded. Fan-out is via asyncio.gather so N entities x M missing
days scale with the slowest single service call.
"""
entities = list(getattr(chore, "publish_calendar_entities", []) or [])
if not entities:
return
if today is None:
today = dt_util.as_local(dt_util.now()).date()
horizon = self._calendar_projection_days()
published = set(getattr(chore, "publish_calendar_published_dates", []) or [])
# Prune stale entries (strictly before today) so the list stays small.
published = {iso for iso in published if iso >= today.isoformat()}
pending: list[tuple[date, dict]] = []
for offset in range(horizon):
day = today + timedelta(days=offset)
day_iso = day.isoformat()
if day_iso in published:
continue
if not self._is_chore_scheduled_for_date(chore, day):
continue
summary = f"{chore.name} — {self._child_name_for_day(chore, day)}"
pending.append((day, self._build_event_payload(chore, day, summary)))
if not pending and not published.symmetric_difference(
getattr(chore, "publish_calendar_published_dates", []) or []
):
# Nothing to publish and nothing pruned — leave the chore untouched
# so the caller doesn't write the record out for no reason.
return
async def _call(entity_id: str, payload: dict) -> None:
try:
await self.hass.services.async_call(
"calendar",
"create_event",
{"entity_id": entity_id, **payload},
blocking=True,
)
except Exception as err: # noqa: BLE001 - HA service errors vary
_LOGGER.warning(
"TaskMate: failed to publish chore %s to calendar %s: %s",
chore.name,
entity_id,
err,
)
tasks = [_call(e, payload) for day, payload in pending for e in entities]
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
for day, _ in pending:
published.add(day.isoformat())
chore.publish_calendar_published_dates = sorted(published)
async def _cleanup_chore_from_calendars(
self,
chore: Chore,
entities: list[str] | None = None,
today: date | None = None,
summary_prefixes: list[str] | None = None,
) -> None:
"""Best-effort delete of the chore's own events from each configured calendar.
Uses a description marker (`taskmate:chore:<id>`) stitched in at create
time to re-identify events. Covers today through today+horizon+7 so the
full projection range is cleaned up on edit/delete. Failure modes
(unavailable calendar, integration without delete_event support,
response service disabled) are caught and logged; cleanup never blocks
the caller.
"""
ents = list(entities if entities is not None else getattr(chore, "publish_calendar_entities", []) or [])
if not ents:
return
if today is None:
today = dt_util.as_local(dt_util.now()).date()
window_days = max(30, self._calendar_projection_days() + 7)
window_start = datetime.combine(today, time(0, 0)).isoformat()
window_end = datetime.combine(today + timedelta(days=window_days), time(0, 0)).isoformat()
marker = self._chore_event_marker(chore)
# Fallback summary prefixes: covers integrations whose get_events
# response omits or strips the description field. Default to the
# current chore's name so deletes still work for unedited chores.
prefixes = list(summary_prefixes or [f"{chore.name} — "])
def _matches(event: dict) -> bool:
if marker in (event.get("description") or ""):
return True
summary = event.get("summary") or ""
return any(summary.startswith(p) for p in prefixes if p)
async def _purge(entity_id: str) -> None:
try:
response = await self.hass.services.async_call(
"calendar",
"get_events",
{
"entity_id": entity_id,
"start_date_time": window_start,
"end_date_time": window_end,
},
blocking=True,
return_response=True,
)
except Exception as err: # noqa: BLE001
_LOGGER.debug(
"TaskMate: could not list events on %s for cleanup: %s",
entity_id,
err,
)
return
events = []
if isinstance(response, dict):
bucket = response.get(entity_id, response)
if isinstance(bucket, dict):
events = bucket.get("events", []) or []
elif isinstance(bucket, list):
events = bucket
for event in events:
if not _matches(event):
continue
uid = event.get("uid") or event.get("recurrence_id") or event.get("id")
if not uid:
continue
try:
await self.hass.services.async_call(
"calendar",
"delete_event",
{"entity_id": entity_id, "uid": uid},
blocking=True,
)
except Exception as err: # noqa: BLE001
_LOGGER.warning(
"TaskMate: failed to delete event %s from %s: %s",
uid,
entity_id,
err,
)
await asyncio.gather(*(_purge(e) for e in ents), return_exceptions=True)
chore.publish_calendar_published_dates = []
# ── Two-way calendar edits (#977) ─────────────────────────────────────
# How long a spent move/removal is kept. Long enough that a recurrence
# window after a moved occurrence still measures from its original date,
# short enough that the map can't grow without bound.
_MOVED_OCCURRENCE_RETENTION_DAYS = 62
@staticmethod
def _calendar_error(key: str, **placeholders: str) -> ServiceValidationError:
"""A translated, user-facing refusal for a calendar edit."""
return ServiceValidationError(
translation_domain=DOMAIN,
translation_key=key,
translation_placeholders=placeholders or None,
)
def _period_for_time(self, value: time) -> str:
"""The time-of-day period containing ``value``, or "anytime"."""
for period in self.get_time_periods():
start = self._parse_hhmm(period["start"])
end = self._parse_hhmm(period["end"])
if start is not None and end is not None and start <= value < end:
return period["id"]
return "anytime"
def _occurrence_start_time(self, chore: Chore, day: date) -> time | None:
"""The start time the calendar shows for ``chore`` on ``day`` (None = all-day)."""
window = self._chore_event_window(chore, day)
return window[0].time() if window else None
def _prune_moved_occurrences(self, moved: dict[str, str], today: date) -> dict[str, str]:
"""Drop moves/removals that lie entirely in the distant past."""
cutoff = (today - timedelta(days=self._MOVED_OCCURRENCE_RETENTION_DAYS)).isoformat()
return {src: dst for src, dst in moved.items() if src >= cutoff or (dst and dst >= cutoff)}
async def async_calendar_add_chore(
self,
child_id: str,
name: str,
day: date,
*,
description: str = "",
start_time: time | None = None,
) -> Chore:
"""Create a one-off chore for ``child_id`` on ``day`` from a calendar event.
Goes through ``async_add_chore`` like the ``add_chore`` service, with
its default points. A timed event sets the chore's due time and files
it under the time-of-day period that contains it.
"""
name = (name or "").strip()
if not name:
raise self._calendar_error("calendar_empty_title")
today = dt_util.as_local(dt_util.now()).date()
if day < today:
raise self._calendar_error("calendar_past_date")
if not self.storage.get_child(child_id):
raise ValueError(f"Unknown child: {child_id}")
return await self.async_add_chore(
name=name,
description=(description or "").strip(),
assigned_to=[child_id],
schedule_mode="one_shot",
created_date=day.isoformat(),
time_category=self._period_for_time(start_time) if start_time else "anytime",
due_time=start_time.strftime("%H:%M") if start_time else "",
)
async def async_move_chore_occurrence(
self,
chore_id: str,
origin: date,
new_day: date,
*,
start_time: time | None = None,
all_day: bool | None = None,
summary: str | None = None,
) -> Chore:
"""Move one occurrence of a chore to ``new_day``.
``origin`` is the occurrence's scheduled date (its identity, which a
move never changes). A one-off chore simply takes the new date — and a
new title or time if the event changed them. A repeating chore moves
only this occurrence; its title and time of day belong to the whole
series, so changing those here is refused.
"""
chore = self.storage.get_chore(chore_id)
if not chore:
raise self._calendar_error("calendar_unknown_event")
today = dt_util.as_local(dt_util.now()).date()
current = self._occurrence_day(chore, origin)
if new_day < today or current < today:
raise self._calendar_error("calendar_past_date")
old_start = self._occurrence_start_time(chore, current)
time_changed = all_day is not None and (
(all_day and old_start is not None) or (not all_day and start_time != old_start)
)
title = (summary or "").strip()
if getattr(chore, "schedule_mode", "specific_days") == "one_shot":
if title:
chore.name = title
chore.created_date = new_day.isoformat()
if time_changed:
chore.time_category = self._period_for_time(start_time) if start_time else "anytime"
chore.due_time = start_time.strftime("%H:%M") if start_time else ""
await self.async_update_chore(chore)
return chore
if title and title != chore.name:
raise self._calendar_error("calendar_rename_series")
if time_changed:
raise self._calendar_error("calendar_time_follows_chore")
if new_day == current:
return chore
if self._is_chore_scheduled_for_date(chore, new_day):
raise self._calendar_error("calendar_already_scheduled", chore=chore.name, date=new_day.isoformat())
moved = dict(getattr(chore, "moved_occurrences", None) or {})
if new_day == origin:
moved.pop(origin.isoformat(), None)
else:
moved[origin.isoformat()] = new_day.isoformat()
chore.moved_occurrences = self._prune_moved_occurrences(moved, today)
await self.async_update_chore(chore)
return chore
async def async_remove_chore_occurrence(self, chore_id: str, origin: date) -> Chore:
"""Remove one occurrence of a chore — that day only.
A repeating chore records the removal and carries on as normal on every
other day. A one-off chore has only the one occurrence, so it is
switched off, exactly as if it had expired.
"""
chore = self.storage.get_chore(chore_id)
if not chore:
raise self._calendar_error("calendar_unknown_event")
today = dt_util.as_local(dt_util.now()).date()
if self._occurrence_day(chore, origin) < today:
raise self._calendar_error("calendar_past_date")
if getattr(chore, "schedule_mode", "specific_days") == "one_shot":
chore.enabled = False
else:
moved = dict(getattr(chore, "moved_occurrences", None) or {})
moved[origin.isoformat()] = ""
chore.moved_occurrences = self._prune_moved_occurrences(moved, today)
await self.async_update_chore(chore)
return chore
@staticmethod
def _occurrence_day(chore: Chore, origin: date) -> date:
"""Where the occurrence scheduled for ``origin`` currently sits."""
if getattr(chore, "schedule_mode", "specific_days") == "one_shot":
try:
return date.fromisoformat(getattr(chore, "created_date", "") or "")
except ValueError:
return origin
dst = (getattr(chore, "moved_occurrences", None) or {}).get(origin.isoformat())
if dst:
try:
return date.fromisoformat(dst)
except ValueError:
pass
return origin