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
+43 -14
View File
@@ -360,8 +360,8 @@ class FrameCalendar(Base):
Keyed by widget_id, not frame_id -- a frame can hold more than one
independent calendar widget (see Widget), each with its own included-
calendars set; "included on this frame" stopped being unambiguous
the moment that became possible (see migration.py's _migration_17,
which re-keyed this table)."""
the moment that became possible (see migration.py's
_ensure_frame_calendars_rekeyed, which re-keyed this table)."""
__tablename__ = "frame_calendars"
@@ -386,6 +386,32 @@ class FrameCalendar(Base):
)
class FrameTaskList(Base):
"""One CalDAV task list included on one tasks widget -- calendar_key
is "caldav:<href>" (an entry in User.calendar_caldav_calendars; no
"ics" variant, unlike FrameCalendar -- a plain ICS subscription has
no VTODO collection). Same owner-controls-their-own-data shape as
FrameCalendar in every other respect: a row is only ever created by
its own owner, but any user linked to the frame may flip included
back to False, and only the owner may flip it back to True or set
color_index. See routers/api_widgets.py's api_widget_task_list_select/
api_widget_task_list_color."""
__tablename__ = "frame_task_lists"
id: Mapped[int] = mapped_column(primary_key=True)
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"))
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
calendar_key: Mapped[str] = mapped_column(String)
calendar_label: Mapped[str] = mapped_column(String, default="")
included: Mapped[bool] = mapped_column(Boolean, default=True)
color_index: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None)
__table_args__ = (
Index("ix_frame_task_lists_unique", "widget_id", "user_id", "calendar_key", unique=True),
)
class Widget(Base):
"""One placed/sized content item on a frame's panel -- the unit the
widget system replaces the old single Frame.mode with (see
@@ -485,24 +511,27 @@ class TaskWidgetConfig(Base):
old bolted-on version, the widget's mere presence on the grid is the
on/off switch, same as every other widget type.
CalDAV only (a task list is a VTODO collection, not something a
plain ICS subscription meaningfully has); source is one specific
linked user's own CalDAV calendar, same owner-controls-their-own-
data permission split as FrameCalendar. user_id SET NULL on the
user's deletion clears the source rather than leaving a dangling
reference (checked_at isn't reset by that, but the next refresh
attempt finds no source and just returns [])."""
Which task lists feed this widget lives in FrameTaskList, not here
-- a widget can merge more than one person's list, mirroring
CalendarWidgetConfig/FrameCalendar exactly (this used to be a single
user_id/calendar_key pair here, one list only; migration 18 carried
each widget's existing single source forward as its first
FrameTaskList row when splitting this out)."""
__tablename__ = "task_widget_configs"
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True)
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
calendar_key: Mapped[str | None] = mapped_column(String, nullable=True)
checked_at: Mapped[float] = mapped_column(Float, default=0.0)
# [{"summary", "due" (ISO date/datetime string or None)}, ...],
# already filtered to outstanding (not-completed) tasks and sorted
# by due date -- see caldav_client.fetch_tasks.
# [{"summary", "due", "completed_at" (ISO date/datetime strings or
# None), "owner_display_name", "color_index"}, ...] -- the merged
# multi-list result, same general shape as CalendarWidgetConfig.
# cached_events. See caldav_client.merge_tasks.
cached: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
# Also include tasks completed in the last 24h (drawn checked-box +
# muted, see calendar_render._draw_tasks) rather than just
# outstanding ones -- off by default, same "opt into more" posture
# as calendar_weather_enabled.
show_completed: Mapped[bool] = mapped_column(Boolean, default=False)
class WhiteboardWidgetConfig(Base):