Let a tasks widget merge multiple task lists, checkbox+color like calendar
Build and push server image / test (push) Successful in 27s
Build and push server image / build-and-push (push) Successful in 2m8s
Build and push server image / deploy (push) Successful in 59s

Tasks widgets could only ever point at one CalDAV task list (a radio-
button picker, owner-only). Now they merge any number of included task
lists across every linked user, same checkbox-inclusion + optional
pinned-color shape a calendar widget already has for its calendars --
FrameTaskList mirrors FrameCalendar exactly, down to the same owner-
adds/anyone-mutes permission split (api_widget_task_list_select/
api_widget_task_list_color). Reused calendar_render._event_colors/
_draw_color_bar as-is for the per-task color bar -- a task dict's
owner_display_name/color_index is exactly that function's single-
source fallback shape.

Also added an opt-in "show tasks completed in the last 24 hours"
toggle (TaskWidgetConfig.show_completed): caldav_client.fetch_tasks
now accepts a completed_since cutoff and returns completed VTODOs
(with their completion time) instead of silently dropping them, and
_draw_tasks gives a completed task a filled checkbox + muted text
instead of the normal empty-box/due-date row.

Migration 18 splits the single-source TaskWidgetConfig columns
(added by 17, splitting tasks out of the calendar widget in the first
place) into frame_task_lists, carrying forward each widget's existing
single source as its first included list -- same shape migration 9
used carrying forward frame_calendars' old single opt-in.

Verified live in the browser (desktop + mobile): the new "Included
task lists" + "Recently completed" dialog sections, the show_completed
toggle actually persisting through a real HTTP round-trip, and no
regression in the calendar widget's own "Included calendars" dialog.
Full suite (192 tests, including new merge_tasks/config_save/migration
coverage) passes.
This commit is contained in:
Thomas Faour
2026-07-25 03:39:26 +00:00
parent b5c52004c8
commit 14c47aa2a0
16 changed files with 884 additions and 326 deletions
+95
View File
@@ -0,0 +1,95 @@
"""caldav_client.merge_tasks -- pure-function coverage (sort, per-source
color/owner tagging, partial-failure handling), monkeypatching
fetch_tasks itself rather than the caldav package's DAVClient/Calendar
-- this project has no CalDAV test server fixture (fetch_calendar_events'
own caldav branch is likewise only exercised this way, never against a
real server -- see test_calendar_feed.py, which only covers the ICS
path). fetch_tasks' own VTODO-parsing internals are trusted to the
icalendar library, same posture calendar_feed.py already takes for
VEVENT parsing."""
from __future__ import annotations
from datetime import datetime, timezone
from app.caldav_client import CalDavError, TaskSource, merge_tasks
def test_single_source_tasks_pass_through_tagged(monkeypatch):
monkeypatch.setattr(
"app.caldav_client.fetch_tasks",
lambda url, username, password, completed_since=None: [
{"summary": "Buy milk", "due": "2026-08-01", "completed_at": None},
],
)
sources = [TaskSource("Alice", "https://example.com/tasks", "alice", "pw", color_index=3)]
tasks, summary = merge_tasks(sources)
assert summary == ""
assert tasks == [
{"summary": "Buy milk", "due": "2026-08-01", "completed_at": None,
"owner_display_name": "Alice", "color_index": 3},
]
def test_outstanding_tasks_sort_by_due_date_none_last(monkeypatch):
def fake_fetch(url, username, password, completed_since=None):
by_url = {
"a": [{"summary": "No due date", "due": None, "completed_at": None}],
"b": [{"summary": "Due soonest", "due": "2026-08-01", "completed_at": None}],
}
return by_url[url]
monkeypatch.setattr("app.caldav_client.fetch_tasks", fake_fetch)
sources = [TaskSource("Alice", "a", "alice", "pw"), TaskSource("Bob", "b", "bob", "pw")]
tasks, _ = merge_tasks(sources)
assert [t["summary"] for t in tasks] == ["Due soonest", "No due date"]
def test_completed_tasks_sort_after_outstanding_most_recent_first(monkeypatch):
def fake_fetch(url, username, password, completed_since=None):
return [
{"summary": "Outstanding", "due": "2026-08-05", "completed_at": None},
{"summary": "Done yesterday", "due": None, "completed_at": "2026-08-01T09:00:00+00:00"},
{"summary": "Done today", "due": None, "completed_at": "2026-08-02T09:00:00+00:00"},
]
monkeypatch.setattr("app.caldav_client.fetch_tasks", fake_fetch)
tasks, _ = merge_tasks([TaskSource("Alice", "a", "alice", "pw")])
assert [t["summary"] for t in tasks] == ["Outstanding", "Done today", "Done yesterday"]
def test_one_broken_source_does_not_blank_others(monkeypatch):
def fake_fetch(url, username, password, completed_since=None):
if url == "broken":
raise CalDavError("nope")
return [{"summary": "Buy milk", "due": None, "completed_at": None}]
monkeypatch.setattr("app.caldav_client.fetch_tasks", fake_fetch)
sources = [TaskSource("Alice", "ok", "alice", "pw"), TaskSource("Bob", "broken", "bob", "pw")]
tasks, summary = merge_tasks(sources)
assert len(tasks) == 1
assert summary == "1 of 2 task lists unavailable"
def test_all_sources_unreachable(monkeypatch):
monkeypatch.setattr(
"app.caldav_client.fetch_tasks",
lambda url, username, password, completed_since=None: (_ for _ in ()).throw(CalDavError("nope")),
)
sources = [TaskSource("Alice", "a", "alice", "pw"), TaskSource("Bob", "b", "bob", "pw")]
tasks, summary = merge_tasks(sources)
assert tasks == []
assert summary == "2 of 2 task lists unavailable"
def test_completed_since_is_forwarded_to_fetch_tasks(monkeypatch):
seen = []
def fake_fetch(url, username, password, completed_since=None):
seen.append(completed_since)
return []
monkeypatch.setattr("app.caldav_client.fetch_tasks", fake_fetch)
cutoff = datetime(2026, 8, 1, tzinfo=timezone.utc)
merge_tasks([TaskSource("Alice", "a", "alice", "pw")], completed_since=cutoff)
assert seen == [cutoff]
+28 -12
View File
@@ -18,6 +18,7 @@ from app.models import (
Frame,
FrameButtonAction,
FrameCalendar,
FrameTaskList,
PhotoWidgetConfig,
ServerSettings,
TaskWidgetConfig,
@@ -180,7 +181,7 @@ def test_migration_16_raw_sql_path_applies_to_an_existing_pre_widget_database(db
_ensure_frame_calendars_rekeyed has a real frame_id-shaped table to
migrate."""
with db_module.engine.begin() as conn:
for table in ("frame_button_actions", "whiteboard_widget_configs", "task_widget_configs",
for table in ("frame_button_actions", "whiteboard_widget_configs", "task_widget_configs", "frame_task_lists",
"calendar_widget_configs", "photo_widget_configs", "widgets"):
conn.execute(text(f"DROP TABLE {table}"))
conn.execute(text("DROP TABLE frame_calendars"))
@@ -234,16 +235,22 @@ def test_migration_16_raw_sql_path_applies_to_an_existing_pre_widget_database(db
assert config.queue == ["legacy-asset", "next-asset"]
def test_migration_17_extracts_tasks_into_a_standalone_widget(db_session):
"""Exercises _migration_17's actual data-extraction SQL (the real
"existing widget-system database upgrading past this migration"
scenario): a calendar_widget_configs row in its pre-17 shape (tasks_*
columns still present, still holding a configured task source) should
come out the other side as a sibling `tasks` widget carrying that
source, with calendar_widget_configs no longer having tasks_*
columns at all."""
def test_migration_17_and_18_extract_tasks_into_a_standalone_multi_list_widget(db_session):
"""Exercises _migration_17 and _migration_18's actual data-extraction
SQL back to back (the real "existing widget-system database
upgrading past both migrations" scenario, since both apply in the
same run_migrations() call here): a calendar_widget_configs row in
its pre-17 shape (tasks_* columns still present, still holding a
configured task source) should come out the other side as a sibling
`tasks` widget with that source carried forward as its first
FrameTaskList row (migration 17's extraction, then migration 18's
further extraction of the single-source TaskWidgetConfig it produces
into FrameTaskList), with calendar_widget_configs no longer having
tasks_* columns and TaskWidgetConfig no longer having user_id/
calendar_key columns either."""
with db_module.engine.begin() as conn:
conn.execute(text("DROP TABLE task_widget_configs"))
conn.execute(text("DROP TABLE frame_task_lists"))
conn.execute(text("DROP TABLE calendar_widget_configs"))
conn.execute(text(
"CREATE TABLE calendar_widget_configs ("
@@ -301,6 +308,9 @@ def test_migration_17_extracts_tasks_into_a_standalone_widget(db_session):
columns = {c["name"] for c in inspect(db_module.engine).get_columns("calendar_widget_configs")}
assert not any(c.startswith("tasks_") for c in columns)
task_cfg_columns = {c["name"] for c in inspect(db_module.engine).get_columns("task_widget_configs")}
assert "user_id" not in task_cfg_columns and "calendar_key" not in task_cfg_columns
assert "show_completed" in task_cfg_columns
task_widgets = db_session.scalars(
select(Widget).where(Widget.frame_id == 1, Widget.widget_type == "tasks")
@@ -309,10 +319,16 @@ def test_migration_17_extracts_tasks_into_a_standalone_widget(db_session):
task_widget = task_widgets[0]
cfg = db_session.get(TaskWidgetConfig, task_widget.id)
assert cfg.user_id == user_id
assert cfg.calendar_key == "caldav:/some/tasks/"
assert cfg.checked_at == 123.0
assert cfg.cached == [{"summary": "Buy milk"}]
assert cfg.show_completed is False
task_list = db_session.scalars(
select(FrameTaskList).where(FrameTaskList.widget_id == task_widget.id)
).one()
assert task_list.user_id == user_id
assert task_list.calendar_key == "caldav:/some/tasks/"
assert task_list.included is True
# Auto-placed without overlapping the calendar widget it was split from.
cal = db_session.get(Widget, calendar_widget_id)
@@ -330,7 +346,7 @@ def test_frame_calendars_rekey_attaches_existing_rows_to_their_calendar_widget(d
the widget backfill, not as a numbered migration racing ahead of
it (see _ensure_frame_calendars_rekeyed's own docstring)."""
with db_module.engine.begin() as conn:
for table in ("frame_button_actions", "whiteboard_widget_configs", "task_widget_configs",
for table in ("frame_button_actions", "whiteboard_widget_configs", "task_widget_configs", "frame_task_lists",
"calendar_widget_configs", "photo_widget_configs", "widgets"):
conn.execute(text(f"DROP TABLE {table}"))
conn.execute(text("DROP TABLE frame_calendars"))
+123 -35
View File
@@ -1,8 +1,9 @@
"""The "owner controls adding their own data; anyone linked can mute it"
permission pattern, repeated across calendar-select, tasks-source, and
whiteboard-source -- exercised at the HTTP layer (not just unit-level)
permission pattern, repeated across calendar-select, task-list-select,
and whiteboard-source (plus the owner-only, no-mute-split calendar-color/
task-list-color) -- exercised at the HTTP layer (not just unit-level)
since the whole point is verifying the *endpoint's* authorization check,
not just a helper function's logic. All three now live under
not just a helper function's logic. All of these live under
/api/frames/{id}/widgets/{widget_id}/... (see routers/api_widgets.py)."""
from __future__ import annotations
@@ -13,6 +14,7 @@ from app.models import (
CalendarWidgetConfig,
Frame,
FrameCalendar,
FrameTaskList,
TaskWidgetConfig,
User,
Widget,
@@ -165,53 +167,139 @@ def test_whiteboard_source_400s_when_widget_is_not_a_whiteboard(client, db_sessi
assert resp.status_code == 400
# --- tasks-source ---
# --- task-list-select ---
def test_tasks_source_set_always_targets_the_caller(client, db_session):
def test_task_list_select_bob_cannot_add_alices_list(client, db_session):
frame = _setup_two_linked_users(client, db_session)
widget = _add_tasks_widget(db_session, frame)
client.cookies.clear()
login(client, "bob")
resp = client.post(f"/api/frames/1/widgets/{widget.id}/tasks-source",
json={"calendar_key": "caldav:/some/tasks/"}, headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
bob_row = db_session.query(User).filter_by(username="bob").one()
cfg = db_session.get(TaskWidgetConfig, widget.id)
assert cfg.user_id == bob_row.id
assert cfg.calendar_key == "caldav:/some/tasks/"
def test_tasks_source_anyone_linked_can_clear(client, db_session):
frame = _setup_two_linked_users(client, db_session)
widget = _add_tasks_widget(db_session, frame)
client.post(f"/api/frames/1/widgets/{widget.id}/tasks-source",
json={"calendar_key": "caldav:/alice/tasks/"}, headers=csrf_headers(client))
alice_id = db_session.query(User).filter_by(username="alice").one().id
client.cookies.clear()
login(client, "bob")
resp = client.post(f"/api/frames/1/widgets/{widget.id}/tasks-source", json={"calendar_key": None},
headers=csrf_headers(client))
resp = client.post(f"/api/frames/1/widgets/{widget.id}/task-list-select", json={
"user_id": alice_id, "calendar_key": "caldav:/alice/tasks/", "included": True,
}, headers=csrf_headers(client))
assert resp.status_code == 403
def test_task_list_select_owner_can_add_their_own(client, db_session):
frame = _setup_two_linked_users(client, db_session)
widget = _add_tasks_widget(db_session, frame)
alice_id = db_session.query(User).filter_by(username="alice").one().id
resp = client.post(f"/api/frames/1/widgets/{widget.id}/task-list-select", json={
"user_id": alice_id, "calendar_key": "caldav:/alice/tasks/", "included": True,
}, headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
cfg = db_session.get(TaskWidgetConfig, widget.id)
assert cfg.user_id is None
assert cfg.calendar_key is None
row = db_session.query(FrameTaskList).filter_by(
widget_id=widget.id, user_id=alice_id, calendar_key="caldav:/alice/tasks/"
).one()
assert row.included is True
def test_tasks_source_404s_for_a_widget_id_that_does_not_exist(client, db_session):
_setup_two_linked_users(client, db_session)
resp = client.post("/api/frames/1/widgets/999999/tasks-source",
json={"calendar_key": "caldav:/some/tasks/"}, headers=csrf_headers(client))
def test_task_list_select_bob_can_mute_alices_list(client, db_session):
"""Muting is a display-preference veto anyone linked gets, unlike
adding -- the one-sided half of this endpoint's permission split."""
frame = _setup_two_linked_users(client, db_session)
widget = _add_tasks_widget(db_session, frame)
alice_id = db_session.query(User).filter_by(username="alice").one().id
client.post(f"/api/frames/1/widgets/{widget.id}/task-list-select", json={
"user_id": alice_id, "calendar_key": "caldav:/alice/tasks/", "included": True,
}, headers=csrf_headers(client))
client.cookies.clear()
login(client, "bob")
resp = client.post(f"/api/frames/1/widgets/{widget.id}/task-list-select", json={
"user_id": alice_id, "calendar_key": "caldav:/alice/tasks/", "included": False,
}, headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
row = db_session.query(FrameTaskList).filter_by(
widget_id=widget.id, user_id=alice_id, calendar_key="caldav:/alice/tasks/"
).one()
assert row.included is False
def test_task_list_select_cannot_mute_a_list_that_was_never_added(client, db_session):
frame = _setup_two_linked_users(client, db_session)
widget = _add_tasks_widget(db_session, frame)
alice_id = db_session.query(User).filter_by(username="alice").one().id
client.cookies.clear()
login(client, "bob")
resp = client.post(f"/api/frames/1/widgets/{widget.id}/task-list-select", json={
"user_id": alice_id, "calendar_key": "caldav:/alice/tasks/", "included": False,
}, headers=csrf_headers(client))
assert resp.status_code == 404
def test_tasks_source_400s_when_widget_is_not_tasks(client, db_session):
def test_task_list_select_404s_for_a_widget_id_that_does_not_exist(client, db_session):
_setup_two_linked_users(client, db_session)
resp = client.post("/api/frames/1/widgets/999999/task-list-select", json={
"user_id": 1, "calendar_key": "caldav:/some/tasks/", "included": True,
}, headers=csrf_headers(client))
assert resp.status_code == 404
def test_task_list_select_400s_when_widget_is_not_tasks(client, db_session):
frame = _setup_two_linked_users(client, db_session)
photo_widget_id = _photo_widget_id(db_session, frame)
resp = client.post(f"/api/frames/1/widgets/{photo_widget_id}/tasks-source",
json={"calendar_key": "caldav:/some/tasks/"}, headers=csrf_headers(client))
alice_id = db_session.query(User).filter_by(username="alice").one().id
resp = client.post(f"/api/frames/1/widgets/{photo_widget_id}/task-list-select", json={
"user_id": alice_id, "calendar_key": "caldav:/alice/tasks/", "included": True,
}, headers=csrf_headers(client))
assert resp.status_code == 400
# --- task-list-color ---
def test_task_list_color_owner_can_pin_it(client, db_session):
frame = _setup_two_linked_users(client, db_session)
widget = _add_tasks_widget(db_session, frame)
alice_id = db_session.query(User).filter_by(username="alice").one().id
client.post(f"/api/frames/1/widgets/{widget.id}/task-list-select", json={
"user_id": alice_id, "calendar_key": "caldav:/alice/tasks/", "included": True,
}, headers=csrf_headers(client))
resp = client.post(f"/api/frames/1/widgets/{widget.id}/task-list-color", json={
"calendar_key": "caldav:/alice/tasks/", "color_index": 3,
}, headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
row = db_session.query(FrameTaskList).filter_by(
widget_id=widget.id, user_id=alice_id, calendar_key="caldav:/alice/tasks/"
).one()
assert row.color_index == 3
def test_task_list_color_bob_cannot_recolor_alices_list(client, db_session):
frame = _setup_two_linked_users(client, db_session)
widget = _add_tasks_widget(db_session, frame)
alice_id = db_session.query(User).filter_by(username="alice").one().id
client.post(f"/api/frames/1/widgets/{widget.id}/task-list-select", json={
"user_id": alice_id, "calendar_key": "caldav:/alice/tasks/", "included": True,
}, headers=csrf_headers(client))
client.cookies.clear()
login(client, "bob")
resp = client.post(f"/api/frames/1/widgets/{widget.id}/task-list-color", json={
"calendar_key": "caldav:/alice/tasks/", "color_index": 3,
}, headers=csrf_headers(client))
assert resp.status_code == 404 # bob has no row for alice's list to recolor
def test_task_list_color_rejects_out_of_range_index(client, db_session):
frame = _setup_two_linked_users(client, db_session)
widget = _add_tasks_widget(db_session, frame)
alice_id = db_session.query(User).filter_by(username="alice").one().id
client.post(f"/api/frames/1/widgets/{widget.id}/task-list-select", json={
"user_id": alice_id, "calendar_key": "caldav:/alice/tasks/", "included": True,
}, headers=csrf_headers(client))
resp = client.post(f"/api/frames/1/widgets/{widget.id}/task-list-color", json={
"calendar_key": "caldav:/alice/tasks/", "color_index": 99,
}, headers=csrf_headers(client))
assert resp.status_code == 400
@@ -9,7 +9,7 @@ while the (unmodified, copied-over) dialog JS posts form-urlencoded data
from __future__ import annotations
from app.models import CalendarWidgetConfig, Frame, PhotoWidgetConfig, Widget
from app.models import CalendarWidgetConfig, Frame, PhotoWidgetConfig, TaskWidgetConfig, Widget
from .conftest import csrf_headers
@@ -32,6 +32,18 @@ def _add_calendar_widget(db_session) -> Widget:
return widget
def _add_tasks_widget(db_session) -> Widget:
import time
widget = Widget(frame_id=1, widget_type="tasks", x=0, y=0, w=2, h=2,
sort_order=1, created_at=time.time())
db_session.add(widget)
db_session.flush()
db_session.add(TaskWidgetConfig(widget_id=widget.id))
db_session.commit()
return widget
def _mock_immich(monkeypatch):
monkeypatch.setattr("app.routers.api_widgets.immich_client_for", lambda frame: object())
monkeypatch.setattr("app.routers.api_widgets.list_assets", lambda client, album_id: _ASSETS)
@@ -80,6 +92,21 @@ def test_config_save_updates_a_calendar_widget(client, db_session):
assert cfg.weather_units == "celsius"
def test_config_save_updates_a_tasks_widget(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_tasks_widget(db_session)
resp = client.post(
f"/api/frames/1/widgets/{widget.id}/config",
data={"tasks_show_completed": "true"},
headers=csrf_headers(client),
)
assert resp.status_code == 200, resp.text
cfg = db_session.get(TaskWidgetConfig, widget.id)
assert cfg.show_completed is True
def test_config_save_only_partially_updates_provided_fields(client, db_session):
"""Fields not present in the POST are left untouched -- the whole
point of the partial-update convention (each dialog's own form only
+39 -24
View File
@@ -1,8 +1,11 @@
"""app.widgets.tasks -- unit-level, no HTTP: constructs Widget/
TaskWidgetConfig rows directly and monkeypatches the underlying fetch
call (get_or_refresh_tasks_for_widget, throttle/CalDAV-fetch logic
covered separately). Split out of the old calendar widget's week-view-
only task list -- see models.TaskWidgetConfig."""
call (get_or_refresh_tasks_for_widget, throttle/multi-list-merge/CalDAV
-fetch logic covered separately -- see test_widget_config_and_queue_
endpoints.py and test_permission_boundaries.py for the FrameTaskList
inclusion/color HTTP-layer coverage). Split out of the old calendar
widget's week-view-only, single-list task list -- see
models.TaskWidgetConfig/FrameTaskList."""
from __future__ import annotations
@@ -11,8 +14,6 @@ import time
from app import widgets
from app.models import Frame, TaskWidgetConfig, Widget
from .conftest import make_user
def _make_widget(db_session, **cfg_kwargs) -> tuple[Frame, Widget]:
frame = db_session.get(Frame, 1)
@@ -25,30 +26,44 @@ def _make_widget(db_session, **cfg_kwargs) -> tuple[Frame, Widget]:
return frame, widget
def _make_configured_widget(db_session) -> tuple[Frame, Widget]:
user = make_user(db_session, "task-owner")
return _make_widget(db_session, user_id=user.id, calendar_key="caldav:/some/tasks/")
def test_render_unconfigured_widget_returns_correctly_sized_placeholder(db_session):
frame, widget = _make_widget(db_session) # no user_id/calendar_key set
img = widgets.tasks.render(db_session, frame, widget, 300, 200)
assert img.size == (300, 200)
def test_render_configured_widget_produces_a_correctly_sized_image(db_session, monkeypatch):
frame, widget = _make_configured_widget(db_session)
monkeypatch.setattr(widgets.tasks, "get_or_refresh_tasks_for_widget",
lambda db, frame, widget: [{"summary": "Buy milk", "due": None}])
def test_render_produces_a_correctly_sized_image_with_no_lists_included(db_session, monkeypatch):
"""No placeholder for "nothing included yet" -- same posture as
app/widgets/calendar.py (see tasks.py's own module docstring):
get_or_refresh_tasks_for_widget already returns [] with nothing
included, and render() draws straight through that."""
frame, widget = _make_widget(db_session)
monkeypatch.setattr(widgets.tasks, "get_or_refresh_tasks_for_widget", lambda db, frame, widget: [])
img = widgets.tasks.render(db_session, frame, widget, 300, 200)
assert img.size == (300, 200)
assert img.mode == "RGB"
def test_render_configured_but_empty_task_list_still_renders(db_session, monkeypatch):
frame, widget = _make_configured_widget(db_session)
monkeypatch.setattr(widgets.tasks, "get_or_refresh_tasks_for_widget", lambda db, frame, widget: [])
def test_render_produces_a_correctly_sized_image_with_tasks(db_session, monkeypatch):
frame, widget = _make_widget(db_session)
monkeypatch.setattr(widgets.tasks, "get_or_refresh_tasks_for_widget",
lambda db, frame, widget: [
{"summary": "Buy milk", "due": None, "completed_at": None,
"owner_display_name": "Alice", "color_index": None},
])
img = widgets.tasks.render(db_session, frame, widget, 300, 200)
assert img.size == (300, 200)
assert img.mode == "RGB"
def test_render_with_completed_tasks(db_session, monkeypatch):
"""show_completed's merged result includes completed tasks too (see
caldav_client.fetch_tasks' completed_since) -- render() must handle
that shape (a non-None completed_at) without error."""
frame, widget = _make_widget(db_session)
monkeypatch.setattr(widgets.tasks, "get_or_refresh_tasks_for_widget",
lambda db, frame, widget: [
{"summary": "Walk the dog", "due": "2026-08-01", "completed_at": None,
"owner_display_name": "Alice", "color_index": 2},
{"summary": "Buy milk", "due": None, "completed_at": "2026-08-01T10:00:00+00:00",
"owner_display_name": "Alice", "color_index": 2},
])
img = widgets.tasks.render(db_session, frame, widget, 300, 200)
assert img.size == (300, 200)
@@ -58,7 +73,7 @@ def test_render_at_minimum_grid_footprint(db_session, monkeypatch):
"""grid.MIN_FOOTPRINT["tasks"] is (2, 2) cells -- on an 8x5 grid
against a full 800x480 panel that's a 200x192 box, the smallest a
tasks widget can actually be placed at."""
frame, widget = _make_configured_widget(db_session)
frame, widget = _make_widget(db_session)
monkeypatch.setattr(widgets.tasks, "get_or_refresh_tasks_for_widget", lambda db, frame, widget: [])
img = widgets.tasks.render(db_session, frame, widget, 200, 192)