Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add calendar platform to Habitica integration #128248

Open
wants to merge 16 commits into
base: dev
Choose a base branch
from
Open
8 changes: 7 additions & 1 deletion homeassistant/components/habitica/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
197 changes: 197 additions & 0 deletions homeassistant/components/habitica/calendar.py
Original file line number Diff line number Diff line change
@@ -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 events_sorted[0] if events_sorted else None
tr4nt0r marked this conversation as resolved.
Show resolved Hide resolved

async def async_get_events(
tr4nt0r marked this conversation as resolved.
Show resolved Hide resolved
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) -> date:
tr4nt0r marked this conversation as resolved.
Show resolved Hide resolved
"""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),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should always be next_recurrence.date() + timedelta(days=1)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The intention is, if there still are unresolved tasks from yesterday, they are still the active tasks, so the enddate is the end of today so the state of the entity stays on. Until these aren't resolved by either completing or skipping them and then starting the new day, the tasks for today are still not the active or next upcoming tasks, they are calculated "future" recurrences. When the daily tasks are reset, the state will advance to the next task that is due.
I know it sounds a bit complicated or I may not be the best in explaining. Maybe this is more helpful https://habitica.fandom.com/wiki/Cron

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Are you saying the start time is also incorrect?
  • What are you expecting duration to be?

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 events_sorted[0] if events_sorted else None
tr4nt0r marked this conversation as resolved.
Show resolved Hide resolved

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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This also is not meeting the get_event spec. get_events should work for any start/end date range. A calendar can render events from the past as well, and triggers work based on past events.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In Habitica the start of the day can be set to a different time (in my account it is 4:00 am for example. If at that time all dailies that were due have been completed the cron runs at 4 and resets all dailies, marking the start of the day. If there are incompleted dailies from the past day (called yesterdailies), the user gets a last chance to mark tasks as completed (in case the task was done but forgotten to mark as completed) and then the user presses the button 'start my day', which then marks the start of the day. That's what I want to reflect here.
That's also why it doesn't make sense to render events from the past, dailies are reset every day.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also there is no data about past dailies. I could generate events based on the current recurrence settings but that would be a wild guess, because they can be changed anytime and I don't know if they were possibly different in the past

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, though completed tasks should probably still be returned here, consistent with how other todos on calendar show up. If needed, we can add more rfc5545 attributes to calendar events and render them different in the UI in the future.

I don't really see that we need to bring in the cron stuff here, rather than ignoring it and letting it show up on the calendar how it shows up on the calendar. (t's very hard to reason about given it seems independent of the recurrence rule and not on a single timeline, so maybe i'm missing it). It feels like its trying to implement todo status tracking and i think we should just add support for todo status tracking if we want that.

I'm worried getting fancy here and not following the spec is going to break triggers/automations since it expects this to be confirming

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aren't the triggers based on the calendar entities on/off state?
I checked the Todoist integration, it also provides a calendar for tasks with due dates. If a task is completed, it is not shown in the calendar view anymore. That's as far as I can see the only other integration that implements a calendar for tasks. Maybe in the future we could render completed tasks in the calendar in grey, that would be a cool feature, but for now I would prefer to do it like the Todoist integration does it already.

I can't ignore the cron. I need it to determine on what day to render the tasks. That's the only way to know if the task is from yesterday or from today.

Copy link
Contributor

@allenporter allenporter Oct 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calendar event Triggers are based on scanning the time range. (discussion)

I see logic in the "select best task" for skipping completed upcoming tasks. But in the calendar view, is that also happening? I saw a difference in the logic that gets the events https://github.com/home-assistant/core/blob/4cbac3a864e0724ad353aa3f4fc159cc8f402ae8/homeassistant/components/todoist/calendar.py#L664C15-L664C31

Regarding the cron are you saying the start date returned from the API is wrong?

If tasks have specific start/end times that are not aligned with date boundaries, then just set start/end times rather than dates and this would all just work how its supposed to naturally.

# 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"]
tr4nt0r marked this conversation as resolved.
Show resolved Hide resolved
for recurrence in build_rrule(task).between(start_date, end_date, inc=True)
if (start := recurrence.date()) > self.today.date()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

given between above, is this greater than check needed?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it is, to differentiate todays active tasks from the future calculated recurrences.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not following why this matters. The start and end date set the range, and so if there are dates within the range they should be returned.

or (start == self.today.date() and not task["completed"] and task["isDue"])
]

@property
def extra_state_attributes(self) -> dict[str, bool] | None:
"""Return entity specific state attributes."""
if event := self.event:
return {"yesterdaily": event.start < date.today()}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Attributes should always be returned, but may have a None value. See home-assistant/architecture#680

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll change that, no problem. But the default state attributes itself aren't returned either if there is no upcoming task. So Home Assistant seems inconsistent here.

image

return None

Check warning on line 197 in homeassistant/components/habitica/calendar.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/habitica/calendar.py#L197

Added line #L197 was not covered by tests
8 changes: 8 additions & 0 deletions homeassistant/components/habitica/icons.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
17 changes: 17 additions & 0 deletions homeassistant/components/habitica/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
10 changes: 1 addition & 9 deletions homeassistant/components/habitica/todo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions homeassistant/components/habitica/types.py
Original file line number Diff line number Diff line change
@@ -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"
77 changes: 77 additions & 0 deletions homeassistant/components/habitica/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -62,3 +77,65 @@
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}
tr4nt0r marked this conversation as resolved.
Show resolved Hide resolved
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]

Check warning on line 102 in homeassistant/components/habitica/util.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/habitica/util.py#L101-L102

Added lines #L101 - L102 were not covered by tests

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]
9 changes: 7 additions & 2 deletions tests/components/habitica/fixtures/tasks.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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": [],
Expand Down
Loading