diff --git a/homeassistant/components/habitica/calendar.py b/homeassistant/components/habitica/calendar.py index 5a0470c3440..be4433cb355 100644 --- a/homeassistant/components/habitica/calendar.py +++ b/homeassistant/components/habitica/calendar.py @@ -28,6 +28,8 @@ class HabiticaCalendar(StrEnum): DAILIES = "dailys" TODOS = "todos" + TODO_REMINDERS = "todo_reminders" + DAILY_REMINDERS = "daily_reminders" async def async_setup_entry( @@ -42,6 +44,8 @@ async def async_setup_entry( [ HabiticaTodosCalendarEntity(coordinator), HabiticaDailiesCalendarEntity(coordinator), + HabiticaTodoRemindersCalendarEntity(coordinator), + HabiticaDailyRemindersCalendarEntity(coordinator), ] ) @@ -225,3 +229,177 @@ class HabiticaDailiesCalendarEntity(HabiticaCalendarEntity): 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) diff --git a/homeassistant/components/habitica/icons.json b/homeassistant/components/habitica/icons.json index ca0ae604f14..d4ca5dba10d 100644 --- a/homeassistant/components/habitica/icons.json +++ b/homeassistant/components/habitica/icons.json @@ -64,6 +64,12 @@ }, "dailys": { "default": "mdi:calendar-multiple" + }, + "todo_reminders": { + "default": "mdi:reminder" + }, + "daily_reminders": { + "default": "mdi:reminder" } }, "sensor": { diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index d32e4a048c7..08809ee05a6 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -109,6 +109,12 @@ } } } + }, + "todo_reminders": { + "name": "To-do reminders" + }, + "daily_reminders": { + "name": "Daily reminders" } }, "sensor": { diff --git a/tests/components/habitica/fixtures/tasks.json b/tests/components/habitica/fixtures/tasks.json index 2e8305283d0..7784b9c7f49 100644 --- a/tests/components/habitica/fixtures/tasks.json +++ b/tests/components/habitica/fixtures/tasks.json @@ -345,7 +345,12 @@ "daysOfMonth": [], "weeksOfMonth": [], "checklist": [], - "reminders": [], + "reminders": [ + { + "id": "1491d640-6b21-4d0c-8940-0b7aa61c8836", + "time": "2024-09-22T20:00:00.0000Z" + } + ], "createdAt": "2024-07-07T17:51:53.266Z", "updatedAt": "2024-09-21T22:51:41.756Z", "userId": "5f359083-ef78-4af0-985a-0b2c6d05797c", diff --git a/tests/components/habitica/snapshots/test_calendar.ambr b/tests/components/habitica/snapshots/test_calendar.ambr index 7325e125470..c2f9c8e83c9 100644 --- a/tests/components/habitica/snapshots/test_calendar.ambr +++ b/tests/components/habitica/snapshots/test_calendar.ambr @@ -577,6 +577,266 @@ }), ]) # --- +# name: test_api_events[calendar.test_user_daily_reminders] + list([ + dict({ + 'description': 'Klicke um Deinen Terminplan festzulegen!', + 'end': dict({ + 'dateTime': '2024-09-21T21:00:00+02:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2024-09-21T20:00:00+02:00', + }), + 'summary': '5 Minuten ruhig durchatmen', + 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', + }), + dict({ + 'description': 'Klicke um Deinen Terminplan festzulegen!', + 'end': dict({ + 'dateTime': '2024-09-22T21:00:00+02:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2024-09-22T20:00:00+02:00', + }), + 'summary': '5 Minuten ruhig durchatmen', + 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', + }), + dict({ + 'description': 'Klicke um Deinen Terminplan festzulegen!', + 'end': dict({ + 'dateTime': '2024-09-23T21:00:00+02:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2024-09-23T20:00:00+02:00', + }), + 'summary': '5 Minuten ruhig durchatmen', + 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', + }), + dict({ + 'description': 'Klicke um Deinen Terminplan festzulegen!', + 'end': dict({ + 'dateTime': '2024-09-24T21:00:00+02:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2024-09-24T20:00:00+02:00', + }), + 'summary': '5 Minuten ruhig durchatmen', + 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', + }), + dict({ + 'description': 'Klicke um Deinen Terminplan festzulegen!', + 'end': dict({ + 'dateTime': '2024-09-25T21:00:00+02:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2024-09-25T20:00:00+02:00', + }), + 'summary': '5 Minuten ruhig durchatmen', + 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', + }), + dict({ + 'description': 'Klicke um Deinen Terminplan festzulegen!', + 'end': dict({ + 'dateTime': '2024-09-26T21:00:00+02:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2024-09-26T20:00:00+02:00', + }), + 'summary': '5 Minuten ruhig durchatmen', + 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', + }), + dict({ + 'description': 'Klicke um Deinen Terminplan festzulegen!', + 'end': dict({ + 'dateTime': '2024-09-27T21:00:00+02:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2024-09-27T20:00:00+02:00', + }), + 'summary': '5 Minuten ruhig durchatmen', + 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', + }), + dict({ + 'description': 'Klicke um Deinen Terminplan festzulegen!', + 'end': dict({ + 'dateTime': '2024-09-28T21:00:00+02:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2024-09-28T20:00:00+02:00', + }), + 'summary': '5 Minuten ruhig durchatmen', + 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', + }), + dict({ + 'description': 'Klicke um Deinen Terminplan festzulegen!', + 'end': dict({ + 'dateTime': '2024-09-29T21:00:00+02:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2024-09-29T20:00:00+02:00', + }), + 'summary': '5 Minuten ruhig durchatmen', + 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', + }), + dict({ + 'description': 'Klicke um Deinen Terminplan festzulegen!', + 'end': dict({ + 'dateTime': '2024-09-30T21:00:00+02:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2024-09-30T20:00:00+02:00', + }), + 'summary': '5 Minuten ruhig durchatmen', + 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', + }), + dict({ + 'description': 'Klicke um Deinen Terminplan festzulegen!', + 'end': dict({ + 'dateTime': '2024-10-01T21:00:00+02:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2024-10-01T20:00:00+02:00', + }), + 'summary': '5 Minuten ruhig durchatmen', + 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', + }), + dict({ + 'description': 'Klicke um Deinen Terminplan festzulegen!', + 'end': dict({ + 'dateTime': '2024-10-02T21:00:00+02:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2024-10-02T20:00:00+02:00', + }), + 'summary': '5 Minuten ruhig durchatmen', + 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', + }), + dict({ + 'description': 'Klicke um Deinen Terminplan festzulegen!', + 'end': dict({ + 'dateTime': '2024-10-03T21:00:00+02:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2024-10-03T20:00:00+02:00', + }), + 'summary': '5 Minuten ruhig durchatmen', + 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', + }), + dict({ + 'description': 'Klicke um Deinen Terminplan festzulegen!', + 'end': dict({ + 'dateTime': '2024-10-04T21:00:00+02:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2024-10-04T20:00:00+02:00', + }), + 'summary': '5 Minuten ruhig durchatmen', + 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', + }), + dict({ + 'description': 'Klicke um Deinen Terminplan festzulegen!', + 'end': dict({ + 'dateTime': '2024-10-05T21:00:00+02:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2024-10-05T20:00:00+02:00', + }), + 'summary': '5 Minuten ruhig durchatmen', + 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', + }), + dict({ + 'description': 'Klicke um Deinen Terminplan festzulegen!', + 'end': dict({ + 'dateTime': '2024-10-06T21:00:00+02:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2024-10-06T20:00:00+02:00', + }), + 'summary': '5 Minuten ruhig durchatmen', + 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', + }), + dict({ + 'description': 'Klicke um Deinen Terminplan festzulegen!', + 'end': dict({ + 'dateTime': '2024-10-07T21:00:00+02:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2024-10-07T20:00:00+02:00', + }), + 'summary': '5 Minuten ruhig durchatmen', + 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', + }), + ]) +# --- +# name: test_api_events[calendar.test_user_to_do_reminders] + list([ + dict({ + 'description': 'Strom- und Internetrechnungen rechtzeitig überweisen.', + 'end': dict({ + 'dateTime': '2024-09-22T03:00:00+02:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2024-09-22T02:00:00+02:00', + }), + 'summary': 'Rechnungen bezahlen', + 'uid': '2f6fcabc-f670-4ec3-ba65-817e8deea490_91c09432-10ac-4a49-bd20-823081ec29ed', + }), + ]) +# --- # name: test_api_events[calendar.test_user_to_do_s] list([ dict({ @@ -676,6 +936,110 @@ 'state': 'on', }) # --- +# name: test_calendar_platform[calendar.test_user_daily_reminders-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_daily_reminders', + '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': 'Daily reminders', + 'platform': 'habitica', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '00000000-0000-0000-0000-000000000000_daily_reminders', + 'unit_of_measurement': None, + }) +# --- +# name: test_calendar_platform[calendar.test_user_daily_reminders-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'all_day': False, + 'description': 'Klicke um Deinen Terminplan festzulegen!', + 'end_time': '2024-09-21 21:00:00', + 'friendly_name': 'test-user Daily reminders', + 'location': '', + 'message': '5 Minuten ruhig durchatmen', + 'start_time': '2024-09-21 20:00:00', + }), + 'context': , + 'entity_id': 'calendar.test_user_daily_reminders', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_calendar_platform[calendar.test_user_to_do_reminders-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_reminders', + '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 reminders', + 'platform': 'habitica', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '00000000-0000-0000-0000-000000000000_todo_reminders', + 'unit_of_measurement': None, + }) +# --- +# name: test_calendar_platform[calendar.test_user_to_do_reminders-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'all_day': False, + 'description': 'Strom- und Internetrechnungen rechtzeitig überweisen.', + 'end_time': '2024-09-22 03:00:00', + 'friendly_name': 'test-user To-do reminders', + 'location': '', + 'message': 'Rechnungen bezahlen', + 'start_time': '2024-09-22 02:00:00', + }), + 'context': , + 'entity_id': 'calendar.test_user_to_do_reminders', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_calendar_platform[calendar.test_user_to_do_s-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/habitica/test_calendar.py b/tests/components/habitica/test_calendar.py index 7c0a2686038..a6cdb1a9306 100644 --- a/tests/components/habitica/test_calendar.py +++ b/tests/components/habitica/test_calendar.py @@ -55,6 +55,8 @@ async def test_calendar_platform( [ "calendar.test_user_to_do_s", "calendar.test_user_dailies", + "calendar.test_user_daily_reminders", + "calendar.test_user_to_do_reminders", ], ) @pytest.mark.freeze_time("2024-09-20T22:00:00.000Z")