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
+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"))