-
Notifications
You must be signed in to change notification settings - Fork 7
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add API for expanding recurring Todo items (#281)
* Add view for repeated todo items * Revert mypy changes and add gitignore * Address lint errors * Simplify handling of combining todo items with the same uid * Update test coverage for start validation and improve types * Update test coverage for updating the entire series * Add Todo test coverage * Update computed duration checking * Simplify todo list view to improve test coverage
- Loading branch information
1 parent
368ba48
commit d7f1ca1
Showing
8 changed files
with
447 additions
and
23 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -127,3 +127,5 @@ dmypy.json | |
|
||
# Pyre type checker | ||
.pyre/ | ||
|
||
.DS_Store |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,113 @@ | ||
"""A List is a set of objects on a calendar. | ||
A List is used to iterate over all objects, including expanded recurring | ||
objects. A List is similar to a Timeline, except it does not repeat recurring | ||
objects on the list and they are only shown once. A list does not repeat | ||
forever. | ||
""" | ||
|
||
import datetime | ||
from collections.abc import Generator, Iterable | ||
import logging | ||
|
||
from .todo import Todo | ||
from .iter import ( | ||
LazySortableItem, | ||
MergedIterable, | ||
RecurIterable, | ||
SortableItem, | ||
SortableItemValue, | ||
) | ||
from .types.recur import RecurrenceId | ||
|
||
|
||
_LOGGER = logging.getLogger(__name__) | ||
_SortableTodoItem = SortableItem[datetime.datetime | datetime.date | None, Todo] | ||
|
||
|
||
class RecurAdapter: | ||
"""An adapter that expands an Todo instance for a recurrence rule. | ||
This adapter is given an todo, then invoked with a specific date/time instance | ||
that the todo is due from a recurrence rule. The todo is copied with | ||
necessary updated fields to act as a flattened instance of the todo item. | ||
""" | ||
|
||
def __init__(self, todo: Todo, tzinfo: datetime.tzinfo | None = None): | ||
"""Initialize the RecurAdapter.""" | ||
self._todo = todo | ||
self._duration = todo.computed_duration | ||
self._tzinfo = tzinfo | ||
|
||
def get(self, dtstart: datetime.datetime | datetime.date) -> _SortableTodoItem: | ||
"""Return a lazy sortable item.""" | ||
|
||
recur_id_dt = dtstart | ||
# Make recurrence_id floating time to avoid dealing with serializing | ||
# TZID. This value will still be unique within the series and is in | ||
# the context of dtstart which may have a timezone. | ||
if isinstance(recur_id_dt, datetime.datetime) and recur_id_dt.tzinfo: | ||
recur_id_dt = recur_id_dt.replace(tzinfo=None) | ||
recurrence_id = RecurrenceId.__parse_property_value__(recur_id_dt) | ||
|
||
def build() -> Todo: | ||
updates = { | ||
"dtstart": dtstart, | ||
"recurrence_id": recurrence_id, | ||
} | ||
if self._todo.due and self._duration: | ||
updates["due"] = dtstart + self._duration | ||
return self._todo.copy(update=updates) | ||
|
||
return LazySortableItem(dtstart, build) | ||
|
||
|
||
def _todos_by_uid(todos: list[Todo]) -> dict[str, list[Todo]]: | ||
todos_by_uid: dict[str, list[Todo]] = {} | ||
for todo in todos: | ||
if todo.uid is None: | ||
raise ValueError("Todo must have a UID") | ||
if todo.uid not in todos_by_uid: | ||
todos_by_uid[todo.uid] = [] | ||
todos_by_uid[todo.uid].append(todo) | ||
return todos_by_uid | ||
|
||
|
||
def _pick_todo(todos: list[Todo], tzinfo: datetime.tzinfo) -> Todo: | ||
"""Pick a todo to return in a list from a list of recurring todos. | ||
The items passed in must all be for the same original todo (either a | ||
single todo or instance of a recurring todo including any edits). An | ||
edited instance of a recurring todo has a recurrence-id that is | ||
different from the original todo. This function will return the | ||
next todo that is incomplete and has the latest due date. | ||
""" | ||
# For a recurring todo, the dtstart is after the last due date. Therefore | ||
# we can stort items by dtstart and pick the last one that hasn't happened | ||
iters: list[Iterable[_SortableTodoItem]] = [] | ||
for todo in todos: | ||
if not (recur := todo.as_rrule()): | ||
iters.append([SortableItemValue(todo.dtstart, todo)]) | ||
continue | ||
iters.append(RecurIterable(RecurAdapter(todo, tzinfo=tzinfo).get, recur)) | ||
|
||
root_iter = MergedIterable(iters) | ||
|
||
# Pick the first todo that hasn't started yet based on its dtstart | ||
now = datetime.datetime.now(tzinfo) | ||
it = iter(root_iter) | ||
last = next(it) | ||
while cur := next(it, None): | ||
if cur.item.start_datetime is None or cur.item.start_datetime > now: | ||
break | ||
last = cur | ||
return last.item | ||
|
||
|
||
def todo_list_view( | ||
todos: list[Todo], tzinfo: datetime.tzinfo | ||
) -> Generator[Todo, None, None]: | ||
"""Create a list view for todos on a calendar, including recurrence.""" | ||
todos_by_uid = _todos_by_uid(todos) | ||
for todos in todos_by_uid.values(): | ||
yield _pick_todo(todos, tzinfo=tzinfo) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,107 @@ | ||
"""Tests for list view of todo items.""" | ||
|
||
import datetime | ||
|
||
import freezegun | ||
import pytest | ||
|
||
from ical.list import todo_list_view | ||
from ical.todo import Todo | ||
from ical.types.recur import Recur | ||
|
||
|
||
def test_empty_list() -> None: | ||
"""Test an empty list.""" | ||
view = todo_list_view([], tzinfo=datetime.timezone.utc) | ||
assert list(view) == [] | ||
|
||
|
||
@pytest.mark.parametrize( | ||
("status"), | ||
[ | ||
("NEEDS-ACTION"), | ||
("IN-PROCESS"), | ||
], | ||
) | ||
def test_daily_recurring_item_due_today_incomplete(status: str) -> None: | ||
"""Test a daily recurring item that is due today .""" | ||
with freezegun.freeze_time("2024-01-10T10:05:00-05:00"): | ||
todo = Todo( | ||
dtstart=datetime.date.today() - datetime.timedelta(days=1), | ||
summary="Daily incomplete", | ||
due=datetime.date.today(), | ||
rrule=Recur.from_rrule("FREQ=DAILY"), | ||
status=status, | ||
) | ||
view = list(todo_list_view([todo], tzinfo=datetime.timezone.utc)) | ||
|
||
assert len(view) == 1 | ||
assert view[0].summary == todo.summary | ||
assert view[0].dtstart == datetime.date(2024, 1, 10) | ||
assert view[0].due == datetime.date(2024, 1, 11) | ||
assert view[0].recurrence_id == "20240110" | ||
|
||
|
||
@pytest.mark.parametrize( | ||
("status"), | ||
[ | ||
("NEEDS-ACTION"), | ||
("IN-PROCESS"), | ||
], | ||
) | ||
def test_daily_recurring_item_due_tomorrow(status: str) -> None: | ||
"""Test a daily recurring item that is due tomorrow.""" | ||
with freezegun.freeze_time("2024-01-10T10:05:00-05:00"): | ||
todo = Todo( | ||
dtstart=datetime.date.today(), | ||
summary="Daily incomplete", | ||
due=datetime.date.today() + datetime.timedelta(days=1), | ||
rrule=Recur.from_rrule("FREQ=DAILY"), | ||
status=status, | ||
) | ||
view = list(todo_list_view([todo], tzinfo=datetime.timezone.utc)) | ||
|
||
assert len(view) == 1 | ||
assert view[0].summary == todo.summary | ||
assert view[0].dtstart == datetime.date(2024, 1, 10) | ||
assert view[0].due == datetime.date(2024, 1, 11) | ||
assert view[0].recurrence_id == "20240110" | ||
|
||
|
||
@pytest.mark.parametrize( | ||
("status"), | ||
[ | ||
("NEEDS-ACTION"), | ||
("IN-PROCESS"), | ||
], | ||
) | ||
def test_daily_recurring_item_due_yesterday(status: str) -> None: | ||
"""Test a daily recurring item that is due yesterday .""" | ||
|
||
with freezegun.freeze_time("2024-01-10T10:05:00-05:00"): | ||
todo = Todo( | ||
dtstart=datetime.date.today() - datetime.timedelta(days=1), | ||
summary="Daily incomplete", | ||
due=datetime.date.today(), | ||
rrule=Recur.from_rrule("FREQ=DAILY"), | ||
status=status, | ||
) | ||
view = list(todo_list_view([todo], tzinfo=datetime.timezone.utc)) | ||
|
||
# The item should be returned with a recurrence_id of today | ||
assert len(view) == 1 | ||
assert view[0].summary == todo.summary | ||
assert view[0].dtstart == datetime.date(2024, 1, 10) | ||
assert view[0].due == datetime.date(2024, 1, 11) | ||
assert view[0].recurrence_id == "20240110" | ||
assert view[0].status == status | ||
|
||
with freezegun.freeze_time("2024-01-11T08:05:00-05:00"): | ||
view = list(todo_list_view([todo], tzinfo=datetime.timezone.utc)) | ||
|
||
assert len(view) == 1 | ||
assert view[0].summary == todo.summary | ||
assert view[0].dtstart == datetime.date(2024, 1, 11) | ||
assert view[0].due == datetime.date(2024, 1, 12) | ||
assert view[0].recurrence_id == "20240111" | ||
assert view[0].status == status |
Oops, something went wrong.