diff --git a/homeassistant/components/habitica/__init__.py b/homeassistant/components/habitica/__init__.py index 21938aa06a67bc..e4036d8858cbb1 100644 --- a/homeassistant/components/habitica/__init__.py +++ b/homeassistant/components/habitica/__init__.py @@ -57,7 +57,13 @@ type HabiticaConfigEntry = ConfigEntry[HabiticaDataUpdateCoordinator] -PLATFORMS = [Platform.BUTTON, Platform.SENSOR, Platform.SWITCH, Platform.TODO] +PLATFORMS = [ + Platform.BUTTON, + Platform.CALENDAR, + Platform.SENSOR, + Platform.SWITCH, + Platform.TODO, +] SERVICE_API_CALL_SCHEMA = vol.Schema( diff --git a/homeassistant/components/habitica/calendar.py b/homeassistant/components/habitica/calendar.py new file mode 100644 index 00000000000000..cdbb2186fb3c1b --- /dev/null +++ b/homeassistant/components/habitica/calendar.py @@ -0,0 +1,197 @@ +"""Calendar platform for Habitica integration.""" + +from __future__ import annotations + +from datetime import date, datetime, timedelta +from enum import StrEnum + +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, to_date + + +class HabiticaCalendar(StrEnum): + """Habitica calendars.""" + + DAILIES = "dailys" + TODOS = "todos" + + +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), + ] + ) + + +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, + ) + + @property + def event(self) -> CalendarEvent | None: + """Return the next upcoming event.""" + + events = [ + CalendarEvent( + start=start, + end=start + timedelta(days=1), + summary=task["text"], + description=task["notes"], + uid=task["id"], + ) + for task in self.coordinator.data.tasks + if task["type"] == HabiticaTaskType.TODO + and not task["completed"] + and task.get("date") + and (start := to_date(task["date"])) + and start >= dt_util.now().date() + ] + events_sorted = sorted( + events, + key=lambda event: ( + event.start, + self.coordinator.data.user["tasksOrder"]["todos"].index(event.uid), + ), + ) + + return next(iter(events_sorted), 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 [ + CalendarEvent( + start=start, + end=start + timedelta(days=1), + summary=task["text"], + description=task["notes"], + uid=task["id"], + ) + for task in self.coordinator.data.tasks + if task["type"] == HabiticaTaskType.TODO + and not task["completed"] + and task.get("date") + and (start := to_date(task["date"])) + and (start_date.date() <= start < end_date.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 datetime.fromisoformat(self.coordinator.data.user["lastCron"]) + + def calculate_end_date(self, next_recurrence: datetime) -> date: + """Calculate the end date for a yesterdaily.""" + return ( + dt_util.start_of_local_day() + if next_recurrence == self.today + else next_recurrence + ).date() + timedelta(days=1) + + @property + def event(self) -> CalendarEvent | None: + """Return the next upcoming event.""" + + events = [ + CalendarEvent( + start=next_recurrence.date(), + end=self.calculate_end_date(next_recurrence), + summary=task["text"], + description=task["notes"], + uid=task["id"], + ) + for task in self.coordinator.data.tasks + if task["type"] == HabiticaTaskType.DAILY and task["everyX"] + if ( + next_recurrence := self.today + if not task["completed"] and task["isDue"] + else build_rrule(task).after(self.today, inc=True) + ) + ] + events_sorted = sorted( + events, + key=lambda event: ( + event.start, + self.coordinator.data.user["tasksOrder"]["dailys"].index(event.uid), + ), + ) + + return next(iter(events_sorted), None) + + async def async_get_events( + self, hass: HomeAssistant, start_date: datetime, end_date: datetime + ) -> list[CalendarEvent]: + """Return calendar events within a datetime range.""" + + # returns only todays and future dailies. + # If a daily is completed it will not be shown for today but still future recurrences + # If the cron hasn't run, not completed dailies are yesterdailies and displayed yesterday + return [ + CalendarEvent( + start=start, + end=start + timedelta(days=1), + summary=task["text"], + description=task["notes"], + uid=task["id"], + rrule=get_recurrence_rule(task), + ) + for task in self.coordinator.data.tasks + if task["type"] == HabiticaTaskType.DAILY and task["everyX"] + for recurrence in build_rrule(task).between(start_date, end_date, inc=True) + if (start := recurrence.date()) > self.today.date() + or (start == self.today.date() and not task["completed"] and task["isDue"]) + ] + + @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 + } diff --git a/homeassistant/components/habitica/icons.json b/homeassistant/components/habitica/icons.json index 544c28e4b9d3fb..316991a17a9ebb 100644 --- a/homeassistant/components/habitica/icons.json +++ b/homeassistant/components/habitica/icons.json @@ -22,6 +22,14 @@ "default": "mdi:grave-stone" } }, + "calendar": { + "todos": { + "default": "mdi:calendar-check" + }, + "dailys": { + "default": "mdi:calendar-multiple" + } + }, "sensor": { "display_name": { "default": "mdi:account-circle" diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index 824b3ab3457507..cd09b714006503 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -48,6 +48,23 @@ "name": "Revive from death" } }, + "calendar": { + "todos": { + "name": "To-Do's" + }, + "dailys": { + "name": "Dailies", + "state_attributes": { + "yesterdaily": { + "name": "Yester-Daily", + "state": { + "true": "[%key:common::state::yes%]", + "false": "[%key:common::state::no%]" + } + } + } + } + }, "sensor": { "display_name": { "name": "Display name" diff --git a/homeassistant/components/habitica/todo.py b/homeassistant/components/habitica/todo.py index ae739d4726295e..6a8ddbee312db3 100644 --- a/homeassistant/components/habitica/todo.py +++ b/homeassistant/components/habitica/todo.py @@ -25,6 +25,7 @@ from .const import ASSETS_URL, DOMAIN from .coordinator import HabiticaDataUpdateCoordinator from .entity import HabiticaBase +from .types import HabiticaTaskType from .util import next_due_date @@ -37,15 +38,6 @@ class HabiticaTodoList(StrEnum): REWARDS = "rewards" -class HabiticaTaskType(StrEnum): - """Habitica Entities.""" - - HABIT = "habit" - DAILY = "daily" - TODO = "todo" - REWARD = "reward" - - async def async_setup_entry( hass: HomeAssistant, config_entry: HabiticaConfigEntry, diff --git a/homeassistant/components/habitica/types.py b/homeassistant/components/habitica/types.py new file mode 100644 index 00000000000000..b009338fe32785 --- /dev/null +++ b/homeassistant/components/habitica/types.py @@ -0,0 +1,12 @@ +"""Types for Habitica integration.""" + +from enum import StrEnum + + +class HabiticaTaskType(StrEnum): + """Habitica Entities.""" + + HABIT = "habit" + DAILY = "daily" + TODO = "todo" + REWARD = "reward" diff --git a/homeassistant/components/habitica/util.py b/homeassistant/components/habitica/util.py index 26549e29cb0578..639e0b2ba8ec4b 100644 --- a/homeassistant/components/habitica/util.py +++ b/homeassistant/components/habitica/util.py @@ -5,6 +5,21 @@ import datetime from typing import TYPE_CHECKING, Any +from dateutil.rrule import ( + DAILY, + FR, + MO, + MONTHLY, + SA, + SU, + TH, + TU, + WE, + WEEKLY, + YEARLY, + rrule, +) + from homeassistant.components.automation import automations_with_entity from homeassistant.components.script import scripts_with_entity from homeassistant.core import HomeAssistant @@ -62,3 +77,65 @@ def entity_used_in(hass: HomeAssistant, entity_id: str) -> list[str]: used_in = automations_with_entity(hass, entity_id) used_in += scripts_with_entity(hass, entity_id) return used_in + + +FREQUENCY_MAP = {"daily": DAILY, "weekly": WEEKLY, "monthly": MONTHLY, "yearly": YEARLY} +WEEKDAY_MAP = {"m": MO, "t": TU, "w": WE, "th": TH, "f": FR, "s": SA, "su": SU} + + +def build_rrule(task: dict[str, Any]) -> rrule: + """Build rrule string.""" + + rrule_frequency = FREQUENCY_MAP.get(task["frequency"], DAILY) + weekdays = [ + WEEKDAY_MAP[day] for day, is_active in task["repeat"].items() if is_active + ] + bymonthday = ( + task["daysOfMonth"] + if rrule_frequency == MONTHLY and task["daysOfMonth"] + else None + ) + + bysetpos = None + if rrule_frequency == MONTHLY and task["weeksOfMonth"]: + bysetpos = task["weeksOfMonth"] + weekdays = weekdays if weekdays else [MO] + + return rrule( + freq=rrule_frequency, + interval=task["everyX"], + dtstart=dt_util.as_local(datetime.datetime.fromisoformat(task["startDate"])), + byweekday=weekdays if rrule_frequency in [WEEKLY, MONTHLY] else None, + bymonthday=bymonthday, + bysetpos=bysetpos, + ) + + +def get_recurrence_rule(task: dict[str, Any]) -> str: + r"""Return the recurrence rules of an RRULE object from a task. + + This function takes a task dictionary, builds the RRULE string using + the `build_rrule` function, and returns the recurrence rule part. The + string representation of the RRULE has the following format: + + 'DTSTART:YYYYMMDDTHHMMSS\nRRULE:FREQ=YEARLY;INTERVAL=2' + + Parameters + ---------- + task : dict of {str : Any} + A dictionary containing task details. + + Returns + ------- + str + The recurrence rule portion of the RRULE string, starting with 'FREQ='. + + Example + ------- + >>> rule = get_recurrence_rule(task) + >>> print(rule) + 'FREQ=YEARLY;INTERVAL=2' + + """ + recurrence = build_rrule(task) + return str(recurrence).split("RRULE:")[1] diff --git a/tests/components/habitica/fixtures/tasks.json b/tests/components/habitica/fixtures/tasks.json index a62280cb475d13..0d6ffba0732dbe 100644 --- a/tests/components/habitica/fixtures/tasks.json +++ b/tests/components/habitica/fixtures/tasks.json @@ -444,7 +444,12 @@ "completedBy": {}, "assignedUsers": [] }, - "reminders": [], + "reminders": [ + { + "id": "91c09432-10ac-4a49-bd20-823081ec29ed", + "time": "2024-09-22T02:00:00.0000Z" + } + ], "byHabitica": false, "createdAt": "2024-09-21T22:17:19.513Z", "updatedAt": "2024-09-21T22:19:35.576Z", @@ -477,7 +482,7 @@ }, { "_id": "86ea2475-d1b5-4020-bdcc-c188c7996afa", - "date": "2024-09-26T22:15:00.000Z", + "date": "2024-09-21T22:00:00.000Z", "completed": false, "collapseChecklist": false, "checklist": [], diff --git a/tests/components/habitica/fixtures/user.json b/tests/components/habitica/fixtures/user.json index 810e43511075b2..50d7b74178462a 100644 --- a/tests/components/habitica/fixtures/user.json +++ b/tests/components/habitica/fixtures/user.json @@ -18,6 +18,24 @@ "sleep": false, "automaticAllocation": true }, + "tasksOrder": { + "rewards": ["5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b"], + "todos": [ + "88de7cd9-af2b-49ce-9afd-bf941d87336b", + "2f6fcabc-f670-4ec3-ba65-817e8deea490", + "1aa3137e-ef72-4d1f-91ee-41933602f438", + "86ea2475-d1b5-4020-bdcc-c188c7996afa" + ], + "dailys": [ + "f21fa608-cfc6-4413-9fc7-0eb1b48ca43a", + "bc1d1855-b2b8-4663-98ff-62e7b763dfc4", + "e97659e0-2c42-4599-a7bb-00282adc410d", + "564b9ac9-c53d-4638-9e7f-1cd96fe19baa", + "f2c85972-1a19-4426-bc6d-ce3337b9d99f", + "2c6d136c-a1c3-4bef-b7c4-fa980784b1e1" + ], + "habits": ["1d147de6-5c02-4740-8e2f-71d3015a37f4"] + }, "needsCron": true, "lastCron": "2024-09-21T22:01:55.586Z" } diff --git a/tests/components/habitica/snapshots/test_calendar.ambr b/tests/components/habitica/snapshots/test_calendar.ambr new file mode 100644 index 00000000000000..4beb9a87709690 --- /dev/null +++ b/tests/components/habitica/snapshots/test_calendar.ambr @@ -0,0 +1,744 @@ +# serializer version: 1 +# name: test_api_events[calendar.test_user_dailies] + list([ + dict({ + 'description': 'Klicke um Änderungen zu machen!', + 'end': dict({ + 'date': '2024-09-23', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA,SU', + 'start': dict({ + 'date': '2024-09-22', + }), + 'summary': 'Zahnseide benutzen', + 'uid': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', + }), + dict({ + 'description': 'Klicke um Änderungen zu machen!', + 'end': dict({ + 'date': '2024-09-24', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA,SU', + 'start': dict({ + 'date': '2024-09-23', + }), + 'summary': 'Zahnseide benutzen', + 'uid': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', + }), + dict({ + 'description': 'Klicke um Änderungen zu machen!', + 'end': dict({ + 'date': '2024-09-25', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA,SU', + 'start': dict({ + 'date': '2024-09-24', + }), + 'summary': 'Zahnseide benutzen', + 'uid': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', + }), + dict({ + 'description': 'Klicke um Änderungen zu machen!', + 'end': dict({ + 'date': '2024-09-26', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA,SU', + 'start': dict({ + 'date': '2024-09-25', + }), + 'summary': 'Zahnseide benutzen', + 'uid': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', + }), + dict({ + 'description': 'Klicke um Änderungen zu machen!', + 'end': dict({ + 'date': '2024-09-27', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA,SU', + 'start': dict({ + 'date': '2024-09-26', + }), + 'summary': 'Zahnseide benutzen', + 'uid': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', + }), + dict({ + 'description': 'Klicke um Änderungen zu machen!', + 'end': dict({ + 'date': '2024-09-28', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA,SU', + 'start': dict({ + 'date': '2024-09-27', + }), + 'summary': 'Zahnseide benutzen', + 'uid': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', + }), + dict({ + 'description': 'Klicke um Änderungen zu machen!', + 'end': dict({ + 'date': '2024-09-29', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA,SU', + 'start': dict({ + 'date': '2024-09-28', + }), + 'summary': 'Zahnseide benutzen', + 'uid': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', + }), + dict({ + 'description': 'Klicke um Änderungen zu machen!', + 'end': dict({ + 'date': '2024-09-30', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA,SU', + 'start': dict({ + 'date': '2024-09-29', + }), + 'summary': 'Zahnseide benutzen', + 'uid': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', + }), + dict({ + 'description': 'Klicke um Änderungen zu machen!', + 'end': dict({ + 'date': '2024-10-01', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA,SU', + 'start': dict({ + 'date': '2024-09-30', + }), + 'summary': 'Zahnseide benutzen', + 'uid': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', + }), + dict({ + 'description': 'Klicke um Änderungen zu machen!', + 'end': dict({ + 'date': '2024-10-02', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA,SU', + 'start': dict({ + 'date': '2024-10-01', + }), + 'summary': 'Zahnseide benutzen', + 'uid': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', + }), + dict({ + 'description': 'Klicke um Änderungen zu machen!', + 'end': dict({ + 'date': '2024-10-03', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA,SU', + 'start': dict({ + 'date': '2024-10-02', + }), + 'summary': 'Zahnseide benutzen', + 'uid': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', + }), + dict({ + 'description': 'Klicke um Änderungen zu machen!', + 'end': dict({ + 'date': '2024-10-04', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA,SU', + 'start': dict({ + 'date': '2024-10-03', + }), + 'summary': 'Zahnseide benutzen', + 'uid': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', + }), + dict({ + 'description': 'Klicke um Änderungen zu machen!', + 'end': dict({ + 'date': '2024-10-05', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA,SU', + 'start': dict({ + 'date': '2024-10-04', + }), + 'summary': 'Zahnseide benutzen', + 'uid': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', + }), + dict({ + 'description': 'Klicke um Änderungen zu machen!', + 'end': dict({ + 'date': '2024-10-06', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA,SU', + 'start': dict({ + 'date': '2024-10-05', + }), + 'summary': 'Zahnseide benutzen', + 'uid': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', + }), + dict({ + 'description': 'Klicke um Änderungen zu machen!', + 'end': dict({ + 'date': '2024-10-07', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA,SU', + 'start': dict({ + 'date': '2024-10-06', + }), + 'summary': 'Zahnseide benutzen', + 'uid': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', + }), + dict({ + 'description': 'Klicke um Änderungen zu machen!', + 'end': dict({ + 'date': '2024-10-08', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA,SU', + 'start': dict({ + 'date': '2024-10-07', + }), + 'summary': 'Zahnseide benutzen', + 'uid': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', + }), + dict({ + 'description': 'Klicke um Änderungen zu machen!', + 'end': dict({ + 'date': '2024-10-09', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA,SU', + 'start': dict({ + 'date': '2024-10-08', + }), + 'summary': 'Zahnseide benutzen', + 'uid': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', + }), + dict({ + 'description': 'Klicke um Deinen Terminplan festzulegen!', + 'end': dict({ + 'date': '2024-09-22', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA,SU', + 'start': dict({ + 'date': '2024-09-21', + }), + 'summary': '5 Minuten ruhig durchatmen', + 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', + }), + dict({ + 'description': 'Klicke um Deinen Terminplan festzulegen!', + 'end': dict({ + 'date': '2024-09-23', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA,SU', + 'start': dict({ + 'date': '2024-09-22', + }), + 'summary': '5 Minuten ruhig durchatmen', + 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', + }), + dict({ + 'description': 'Klicke um Deinen Terminplan festzulegen!', + 'end': dict({ + 'date': '2024-09-24', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA,SU', + 'start': dict({ + 'date': '2024-09-23', + }), + 'summary': '5 Minuten ruhig durchatmen', + 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', + }), + dict({ + 'description': 'Klicke um Deinen Terminplan festzulegen!', + 'end': dict({ + 'date': '2024-09-25', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA,SU', + 'start': dict({ + 'date': '2024-09-24', + }), + 'summary': '5 Minuten ruhig durchatmen', + 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', + }), + dict({ + 'description': 'Klicke um Deinen Terminplan festzulegen!', + 'end': dict({ + 'date': '2024-09-26', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA,SU', + 'start': dict({ + 'date': '2024-09-25', + }), + 'summary': '5 Minuten ruhig durchatmen', + 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', + }), + dict({ + 'description': 'Klicke um Deinen Terminplan festzulegen!', + 'end': dict({ + 'date': '2024-09-27', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA,SU', + 'start': dict({ + 'date': '2024-09-26', + }), + 'summary': '5 Minuten ruhig durchatmen', + 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', + }), + dict({ + 'description': 'Klicke um Deinen Terminplan festzulegen!', + 'end': dict({ + 'date': '2024-09-28', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA,SU', + 'start': dict({ + 'date': '2024-09-27', + }), + 'summary': '5 Minuten ruhig durchatmen', + 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', + }), + dict({ + 'description': 'Klicke um Deinen Terminplan festzulegen!', + 'end': dict({ + 'date': '2024-09-29', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA,SU', + 'start': dict({ + 'date': '2024-09-28', + }), + 'summary': '5 Minuten ruhig durchatmen', + 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', + }), + dict({ + 'description': 'Klicke um Deinen Terminplan festzulegen!', + 'end': dict({ + 'date': '2024-09-30', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA,SU', + 'start': dict({ + 'date': '2024-09-29', + }), + 'summary': '5 Minuten ruhig durchatmen', + 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', + }), + dict({ + 'description': 'Klicke um Deinen Terminplan festzulegen!', + 'end': dict({ + 'date': '2024-10-01', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA,SU', + 'start': dict({ + 'date': '2024-09-30', + }), + 'summary': '5 Minuten ruhig durchatmen', + 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', + }), + dict({ + 'description': 'Klicke um Deinen Terminplan festzulegen!', + 'end': dict({ + 'date': '2024-10-02', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA,SU', + 'start': dict({ + 'date': '2024-10-01', + }), + 'summary': '5 Minuten ruhig durchatmen', + 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', + }), + dict({ + 'description': 'Klicke um Deinen Terminplan festzulegen!', + 'end': dict({ + 'date': '2024-10-03', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA,SU', + 'start': dict({ + 'date': '2024-10-02', + }), + 'summary': '5 Minuten ruhig durchatmen', + 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', + }), + dict({ + 'description': 'Klicke um Deinen Terminplan festzulegen!', + 'end': dict({ + 'date': '2024-10-04', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA,SU', + 'start': dict({ + 'date': '2024-10-03', + }), + 'summary': '5 Minuten ruhig durchatmen', + 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', + }), + dict({ + 'description': 'Klicke um Deinen Terminplan festzulegen!', + 'end': dict({ + 'date': '2024-10-05', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA,SU', + 'start': dict({ + 'date': '2024-10-04', + }), + 'summary': '5 Minuten ruhig durchatmen', + 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', + }), + dict({ + 'description': 'Klicke um Deinen Terminplan festzulegen!', + 'end': dict({ + 'date': '2024-10-06', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA,SU', + 'start': dict({ + 'date': '2024-10-05', + }), + 'summary': '5 Minuten ruhig durchatmen', + 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', + }), + dict({ + 'description': 'Klicke um Deinen Terminplan festzulegen!', + 'end': dict({ + 'date': '2024-10-07', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA,SU', + 'start': dict({ + 'date': '2024-10-06', + }), + 'summary': '5 Minuten ruhig durchatmen', + 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', + }), + dict({ + 'description': 'Klicke um Deinen Terminplan festzulegen!', + 'end': dict({ + 'date': '2024-10-08', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA,SU', + 'start': dict({ + 'date': '2024-10-07', + }), + 'summary': '5 Minuten ruhig durchatmen', + 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', + }), + dict({ + 'description': 'Klicke um Deinen Terminplan festzulegen!', + 'end': dict({ + 'date': '2024-10-09', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA,SU', + 'start': dict({ + 'date': '2024-10-08', + }), + 'summary': '5 Minuten ruhig durchatmen', + 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', + }), + dict({ + 'description': 'Ein einstündiges Workout im Fitnessstudio absolvieren.', + 'end': dict({ + 'date': '2024-09-23', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': 'FREQ=WEEKLY;BYDAY=WE,SA,SU', + 'start': dict({ + 'date': '2024-09-22', + }), + 'summary': 'Fitnessstudio besuchen', + 'uid': '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', + }), + dict({ + 'description': 'Ein einstündiges Workout im Fitnessstudio absolvieren.', + 'end': dict({ + 'date': '2024-09-26', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': 'FREQ=WEEKLY;BYDAY=WE,SA,SU', + 'start': dict({ + 'date': '2024-09-25', + }), + 'summary': 'Fitnessstudio besuchen', + 'uid': '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', + }), + dict({ + 'description': 'Ein einstündiges Workout im Fitnessstudio absolvieren.', + 'end': dict({ + 'date': '2024-09-29', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': 'FREQ=WEEKLY;BYDAY=WE,SA,SU', + 'start': dict({ + 'date': '2024-09-28', + }), + 'summary': 'Fitnessstudio besuchen', + 'uid': '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', + }), + dict({ + 'description': 'Ein einstündiges Workout im Fitnessstudio absolvieren.', + 'end': dict({ + 'date': '2024-09-30', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': 'FREQ=WEEKLY;BYDAY=WE,SA,SU', + 'start': dict({ + 'date': '2024-09-29', + }), + 'summary': 'Fitnessstudio besuchen', + 'uid': '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', + }), + dict({ + 'description': 'Ein einstündiges Workout im Fitnessstudio absolvieren.', + 'end': dict({ + 'date': '2024-10-03', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': 'FREQ=WEEKLY;BYDAY=WE,SA,SU', + 'start': dict({ + 'date': '2024-10-02', + }), + 'summary': 'Fitnessstudio besuchen', + 'uid': '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', + }), + dict({ + 'description': 'Ein einstündiges Workout im Fitnessstudio absolvieren.', + 'end': dict({ + 'date': '2024-10-06', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': 'FREQ=WEEKLY;BYDAY=WE,SA,SU', + 'start': dict({ + 'date': '2024-10-05', + }), + 'summary': 'Fitnessstudio besuchen', + 'uid': '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', + }), + dict({ + 'description': 'Ein einstündiges Workout im Fitnessstudio absolvieren.', + 'end': dict({ + 'date': '2024-10-07', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': 'FREQ=WEEKLY;BYDAY=WE,SA,SU', + 'start': dict({ + 'date': '2024-10-06', + }), + 'summary': 'Fitnessstudio besuchen', + 'uid': '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', + }), + ]) +# --- +# name: test_api_events[calendar.test_user_to_do_s] + list([ + dict({ + 'description': 'Das Buch, das du angefangen hast, bis zum Wochenende fertig lesen.', + 'end': dict({ + 'date': '2024-09-29', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'date': '2024-09-28', + }), + 'summary': 'Buch zu Ende lesen', + 'uid': '88de7cd9-af2b-49ce-9afd-bf941d87336b', + }), + dict({ + 'description': 'Strom- und Internetrechnungen rechtzeitig überweisen.', + 'end': dict({ + 'date': '2024-09-02', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'date': '2024-09-01', + }), + 'summary': 'Rechnungen bezahlen', + 'uid': '2f6fcabc-f670-4ec3-ba65-817e8deea490', + }), + dict({ + 'description': 'Den Ausflug für das kommende Wochenende organisieren.', + 'end': dict({ + 'date': '2024-09-23', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'date': '2024-09-22', + }), + 'summary': 'Wochenendausflug planen', + 'uid': '86ea2475-d1b5-4020-bdcc-c188c7996afa', + }), + ]) +# --- +# name: test_calendar_platform[calendar.test_user_dailies-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'calendar', + 'entity_category': None, + 'entity_id': 'calendar.test_user_dailies', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Dailies', + 'platform': 'habitica', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '00000000-0000-0000-0000-000000000000_dailys', + 'unit_of_measurement': None, + }) +# --- +# name: test_calendar_platform[calendar.test_user_dailies-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'all_day': True, + 'description': 'Klicke um Deinen Terminplan festzulegen!', + 'end_time': '2024-09-23 00:00:00', + 'friendly_name': 'test-user Dailies', + 'location': '', + 'message': '5 Minuten ruhig durchatmen', + 'start_time': '2024-09-21 00:00:00', + 'yesterdaily': False, + }), + 'context': , + 'entity_id': 'calendar.test_user_dailies', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_calendar_platform[calendar.test_user_to_do_s-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'calendar', + 'entity_category': None, + 'entity_id': 'calendar.test_user_to_do_s', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': "To-Do's", + 'platform': 'habitica', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '00000000-0000-0000-0000-000000000000_todos', + 'unit_of_measurement': None, + }) +# --- +# name: test_calendar_platform[calendar.test_user_to_do_s-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'all_day': True, + 'description': 'Den Ausflug für das kommende Wochenende organisieren.', + 'end_time': '2024-09-23 00:00:00', + 'friendly_name': "test-user To-Do's", + 'location': '', + 'message': 'Wochenendausflug planen', + 'start_time': '2024-09-22 00:00:00', + }), + 'context': , + 'entity_id': 'calendar.test_user_to_do_s', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/habitica/test_calendar.py b/tests/components/habitica/test_calendar.py new file mode 100644 index 00000000000000..95bc7633615c0c --- /dev/null +++ b/tests/components/habitica/test_calendar.py @@ -0,0 +1,81 @@ +"""Tests for the Habitica calendar platform.""" + +from collections.abc import Generator +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform +from tests.typing import ClientSessionGenerator + + +@pytest.fixture(autouse=True) +def calendar_only() -> Generator[None]: + """Enable only the calendar platform.""" + with patch( + "homeassistant.components.habitica.PLATFORMS", + [Platform.CALENDAR], + ): + yield + + +@pytest.fixture(autouse=True) +async def set_tz(hass: HomeAssistant) -> None: + """Fixture to set timezone.""" + await hass.config.async_set_time_zone("Europe/Berlin") + + +@pytest.mark.usefixtures("mock_habitica") +@pytest.mark.freeze_time("2024-09-22 00:00:00") +async def test_calendar_platform( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test setup of the Habitica calendar platform.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.parametrize( + ("entity"), + [ + "calendar.test_user_to_do_s", + "calendar.test_user_dailies", + ], +) +@pytest.mark.freeze_time("2024-09-22 00:00:00") +@pytest.mark.usefixtures("mock_habitica") +async def test_api_events( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + config_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, + entity: str, +) -> None: + """Test calendar event.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + client = await hass_client() + response = await client.get( + f"/api/calendars/{entity}?start=2024-08-29&end=2024-10-08" + ) + + events = await response.json() + assert events == snapshot diff --git a/tests/components/habitica/test_util.py b/tests/components/habitica/test_util.py new file mode 100644 index 00000000000000..242f3cc662ba4d --- /dev/null +++ b/tests/components/habitica/test_util.py @@ -0,0 +1,36 @@ +"""Tests for Habitica utility functions.""" + +import pytest + +from homeassistant.components.habitica.util import get_recurrence_rule +from homeassistant.core import HomeAssistant + + +@pytest.fixture(autouse=True) +async def set_tz(hass: HomeAssistant) -> None: + """Fixture to set timezone.""" + await hass.config.async_set_time_zone("Europe/Berlin") + + +async def test_get_recurrence_rule() -> None: + """Test next_due_date function.""" + + task = { + "frequency": "weekly", + "everyX": 1, + "repeat": { + "m": True, + "t": False, + "w": True, + "f": False, + "s": True, + "su": False, + }, + "type": "daily", + "startDate": "2024-07-06T22:00:00.000Z", + "daysOfMonth": [], + "weeksOfMonth": [], + } + + result = get_recurrence_rule(task) + assert result == "FREQ=WEEKLY;BYDAY=MO,WE,SA"