core/homeassistant/components/habitica/calendar.py

406 lines
14 KiB
Python

"""Calendar platform for Habitica integration."""
from __future__ import annotations
from datetime import date, datetime, timedelta
from enum import StrEnum
from dateutil.rrule import rrule
from homeassistant.components.calendar import (
CalendarEntity,
CalendarEntityDescription,
CalendarEvent,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import dt as dt_util
from . import HabiticaConfigEntry
from .coordinator import HabiticaDataUpdateCoordinator
from .entity import HabiticaBase
from .types import HabiticaTaskType
from .util import build_rrule, get_recurrence_rule
class HabiticaCalendar(StrEnum):
"""Habitica calendars."""
DAILIES = "dailys"
TODOS = "todos"
TODO_REMINDERS = "todo_reminders"
DAILY_REMINDERS = "daily_reminders"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HabiticaConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the calendar platform."""
coordinator = config_entry.runtime_data
async_add_entities(
[
HabiticaTodosCalendarEntity(coordinator),
HabiticaDailiesCalendarEntity(coordinator),
HabiticaTodoRemindersCalendarEntity(coordinator),
HabiticaDailyRemindersCalendarEntity(coordinator),
]
)
class HabiticaCalendarEntity(HabiticaBase, CalendarEntity):
"""Base Habitica calendar entity."""
def __init__(
self,
coordinator: HabiticaDataUpdateCoordinator,
) -> None:
"""Initialize calendar entity."""
super().__init__(coordinator, self.entity_description)
class HabiticaTodosCalendarEntity(HabiticaCalendarEntity):
"""Habitica todos calendar entity."""
entity_description = CalendarEntityDescription(
key=HabiticaCalendar.TODOS,
translation_key=HabiticaCalendar.TODOS,
)
def dated_todos(
self, start_date: datetime, end_date: datetime | None = None
) -> list[CalendarEvent]:
"""Get all dated todos."""
events = []
for task in self.coordinator.data.tasks:
if not (
task["type"] == HabiticaTaskType.TODO
and not task["completed"]
and task.get("date") # only if has due date
):
continue
start = dt_util.start_of_local_day(datetime.fromisoformat(task["date"]))
end = start + timedelta(days=1)
# return current and upcoming events or events within the requested range
if end < start_date:
# Event ends before date range
continue
if end_date and start > end_date:
# Event starts after date range
continue
events.append(
CalendarEvent(
start=start.date(),
end=end.date(),
summary=task["text"],
description=task["notes"],
uid=task["id"],
)
)
return sorted(
events,
key=lambda event: (
event.start,
self.coordinator.data.user["tasksOrder"]["todos"].index(event.uid),
),
)
@property
def event(self) -> CalendarEvent | None:
"""Return the current or next upcoming event."""
return next(iter(self.dated_todos(dt_util.now())), None)
async def async_get_events(
self, hass: HomeAssistant, start_date: datetime, end_date: datetime
) -> list[CalendarEvent]:
"""Return calendar events within a datetime range."""
return self.dated_todos(start_date, end_date)
class HabiticaDailiesCalendarEntity(HabiticaCalendarEntity):
"""Habitica dailies calendar entity."""
entity_description = CalendarEntityDescription(
key=HabiticaCalendar.DAILIES,
translation_key=HabiticaCalendar.DAILIES,
)
@property
def today(self) -> datetime:
"""Habitica daystart."""
return dt_util.start_of_local_day(
datetime.fromisoformat(self.coordinator.data.user["lastCron"])
)
def end_date(self, recurrence: datetime, end: datetime | None = None) -> date:
"""Calculate the end date for a yesterdaily.
The enddates of events from yesterday move forward to the end
of the current day (until the cron resets the dailies) to show them
as still active events on the calendar state entity (state: on).
Events in the calendar view will show all-day events on their due day
"""
if end:
return recurrence.date() + timedelta(days=1)
return (
dt_util.start_of_local_day() if recurrence == self.today else recurrence
).date() + timedelta(days=1)
def get_recurrence_dates(
self, recurrences: rrule, start_date: datetime, end_date: datetime | None = None
) -> list[datetime]:
"""Calculate recurrence dates based on start_date and end_date."""
if end_date:
return recurrences.between(
start_date, end_date - timedelta(days=1), inc=True
)
# if no end_date is given, return only the next recurrence
return [recurrences.after(self.today, inc=True)]
def due_dailies(
self, start_date: datetime, end_date: datetime | None = None
) -> list[CalendarEvent]:
"""Get dailies and recurrences for a given period or the next upcoming."""
# we only have dailies for today and future recurrences
if end_date and end_date < self.today:
return []
start_date = max(start_date, self.today)
events = []
for task in self.coordinator.data.tasks:
# only dailies that that are not 'grey dailies'
if not (task["type"] == HabiticaTaskType.DAILY and task["everyX"]):
continue
recurrences = build_rrule(task)
recurrence_dates = self.get_recurrence_dates(
recurrences, start_date, end_date
)
for recurrence in recurrence_dates:
is_future_event = recurrence > self.today
is_current_event = recurrence <= self.today and not task["completed"]
if not (is_future_event or is_current_event):
continue
events.append(
CalendarEvent(
start=recurrence.date(),
end=self.end_date(recurrence, end_date),
summary=task["text"],
description=task["notes"],
uid=task["id"],
rrule=get_recurrence_rule(recurrences),
)
)
return sorted(
events,
key=lambda event: (
event.start,
self.coordinator.data.user["tasksOrder"]["dailys"].index(event.uid),
),
)
@property
def event(self) -> CalendarEvent | None:
"""Return the next upcoming event."""
return next(iter(self.due_dailies(self.today)), None)
async def async_get_events(
self, hass: HomeAssistant, start_date: datetime, end_date: datetime
) -> list[CalendarEvent]:
"""Return calendar events within a datetime range."""
return self.due_dailies(start_date, end_date)
@property
def extra_state_attributes(self) -> dict[str, bool | None] | None:
"""Return entity specific state attributes."""
return {
"yesterdaily": self.event.start < self.today.date() if self.event else None
}
class HabiticaTodoRemindersCalendarEntity(HabiticaCalendarEntity):
"""Habitica to-do reminders calendar entity."""
entity_description = CalendarEntityDescription(
key=HabiticaCalendar.TODO_REMINDERS,
translation_key=HabiticaCalendar.TODO_REMINDERS,
)
def reminders(
self, start_date: datetime, end_date: datetime | None = None
) -> list[CalendarEvent]:
"""Reminders for todos."""
events = []
for task in self.coordinator.data.tasks:
if task["type"] != HabiticaTaskType.TODO or task["completed"]:
continue
for reminder in task.get("reminders", []):
# reminders are returned by the API in local time but with wrong
# timezone (UTC) and arbitrary added seconds/microseconds. When
# creating reminders in Habitica only hours and minutes can be defined.
start = datetime.fromisoformat(reminder["time"]).replace(
tzinfo=dt_util.DEFAULT_TIME_ZONE, second=0, microsecond=0
)
end = start + timedelta(hours=1)
if end < start_date:
# Event ends before date range
continue
if end_date and start > end_date:
# Event starts after date range
continue
events.append(
CalendarEvent(
start=start,
end=end,
summary=task["text"],
description=task["notes"],
uid=f"{task["id"]}_{reminder["id"]}",
)
)
return sorted(
events,
key=lambda event: event.start,
)
@property
def event(self) -> CalendarEvent | None:
"""Return the next upcoming event."""
return next(iter(self.reminders(dt_util.now())), None)
async def async_get_events(
self, hass: HomeAssistant, start_date: datetime, end_date: datetime
) -> list[CalendarEvent]:
"""Return calendar events within a datetime range."""
return self.reminders(start_date, end_date)
class HabiticaDailyRemindersCalendarEntity(HabiticaCalendarEntity):
"""Habitica daily reminders calendar entity."""
entity_description = CalendarEntityDescription(
key=HabiticaCalendar.DAILY_REMINDERS,
translation_key=HabiticaCalendar.DAILY_REMINDERS,
)
def start(self, reminder_time: str, reminder_date: date) -> datetime:
"""Generate reminder times for dailies.
Reminders for dailies have a datetime but the date part is arbitrary,
only the time part is evaluated. The dates for the reminders are the
dailies' due dates.
"""
return datetime.combine(
reminder_date,
datetime.fromisoformat(reminder_time)
.replace(
second=0,
microsecond=0,
)
.time(),
tzinfo=dt_util.DEFAULT_TIME_ZONE,
)
@property
def today(self) -> datetime:
"""Habitica daystart."""
return dt_util.start_of_local_day(
datetime.fromisoformat(self.coordinator.data.user["lastCron"])
)
def get_recurrence_dates(
self, recurrences: rrule, start_date: datetime, end_date: datetime | None = None
) -> list[datetime]:
"""Calculate recurrence dates based on start_date and end_date."""
if end_date:
return recurrences.between(
start_date, end_date - timedelta(days=1), inc=True
)
# if no end_date is given, return only the next recurrence
return [recurrences.after(self.today, inc=True)]
def reminders(
self, start_date: datetime, end_date: datetime | None = None
) -> list[CalendarEvent]:
"""Reminders for dailies."""
events = []
if end_date and end_date < self.today:
return []
start_date = max(start_date, self.today)
for task in self.coordinator.data.tasks:
if not (task["type"] == HabiticaTaskType.DAILY and task["everyX"]):
continue
recurrences = build_rrule(task)
recurrences_start = self.today
recurrence_dates = self.get_recurrence_dates(
recurrences, recurrences_start, end_date
)
for recurrence in recurrence_dates:
is_future_event = recurrence > self.today
is_current_event = recurrence <= self.today and not task["completed"]
if not is_future_event and not is_current_event:
continue
for reminder in task.get("reminders", []):
start = self.start(reminder["time"], recurrence)
end = start + timedelta(hours=1)
if end < start_date:
# Event ends before date range
continue
if end_date and start > end_date:
# Event starts after date range
continue
events.append(
CalendarEvent(
start=start,
end=end,
summary=task["text"],
description=task["notes"],
uid=f"{task["id"]}_{reminder["id"]}",
)
)
return sorted(
events,
key=lambda event: event.start,
)
@property
def event(self) -> CalendarEvent | None:
"""Return the next upcoming event."""
return next(iter(self.reminders(dt_util.now())), None)
async def async_get_events(
self, hass: HomeAssistant, start_date: datetime, end_date: datetime
) -> list[CalendarEvent]:
"""Return calendar events within a datetime range."""
return self.reminders(start_date, end_date)