Split the tasks feature out of the calendar widget into its own widget type
Build and push server image / test (push) Successful in 24s
Build and push server image / build-and-push (push) Successful in 2m1s
Build and push server image / deploy (push) Successful in 56s

Task lists used to be a week-view-only sub-feature bolted onto calendar
widgets (CalendarWidgetConfig.tasks_*), so a task list could only exist
tied to a calendar's view and only inside its footprint. Tasks are now
a standalone widget type (TaskWidgetConfig, app/widgets/tasks.py) that
can be placed and sized independently, same as photos/calendar/
whiteboard -- no separate "enabled" flag either, since being on the
grid at all is the on/off switch, matching every other widget type.

Migration 17 creates task_widget_configs, extracts any existing
calendar widget's configured task source into a new sibling tasks
widget (auto-placed in open grid space, source dropped+logged if truly
none is left), then drops calendar_widget_configs' now-dead tasks_*
columns in the same migration -- this project's usual same-migration-
drop convention. Also handles the rarer case of a database jumping
straight from before the widget system existed to after this split in
one boot, via the legacy Frame.calendar_tasks_* columns.

Verified live in the browser at desktop and mobile widths: adding a
Tasks widget, its own dialog (task-list source picker + preview), and
confirming the calendar widget's dialog no longer mentions tasks at
all. Full test suite (180 tests, including new coverage for the widget
render/actions, the migration's data-extraction path, and the
permission-boundary shape for tasks-source) passes.
This commit is contained in:
Thomas Faour
2026-07-25 02:11:45 +00:00
parent 9f3f4b6f62
commit b5c52004c8
25 changed files with 770 additions and 319 deletions
+37 -11
View File
@@ -407,7 +407,7 @@ class Widget(Base):
id: Mapped[int] = mapped_column(primary_key=True)
frame_id: Mapped[int] = mapped_column(ForeignKey("frames.id", ondelete="CASCADE"))
widget_type: Mapped[str] = mapped_column(String) # "photos" | "calendar" | "whiteboard"
widget_type: Mapped[str] = mapped_column(String) # "photos" | "calendar" | "whiteboard" | "tasks"
x: Mapped[int] = mapped_column(Integer)
y: Mapped[int] = mapped_column(Integer)
w: Mapped[int] = mapped_column(Integer)
@@ -450,9 +450,12 @@ class CalendarWidgetConfig(Base):
fields that used to live as calendar_* columns directly on Frame,
minus calendar_photo_inlay (dropped: arbitrary widget placement
subsumes what a fixed 50/50 inlay split did, so it's not a special
case anymore, just place a photo widget alongside). "Included
calendars" is its own table (FrameCalendar), widget_id-keyed so each
calendar widget on a frame has its own independent set."""
case anymore, just place a photo widget alongside) and minus
tasks_* (also dropped: split out into its own standalone widget
type, see TaskWidgetConfig, so a task list isn't tied to a
calendar's week view/footprint anymore). "Included calendars" is
its own table (FrameCalendar), widget_id-keyed so each calendar
widget on a frame has its own independent set."""
__tablename__ = "calendar_widget_configs"
@@ -471,13 +474,35 @@ class CalendarWidgetConfig(Base):
week_days: Mapped[int] = mapped_column(Integer, default=7)
week_layout: Mapped[str] = mapped_column(String, default="horizontal")
week_start_offset: Mapped[int] = mapped_column(Integer, default=0)
tasks_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
tasks_user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
tasks_calendar_key: Mapped[str | None] = mapped_column(String, nullable=True)
tasks_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
tasks_cached: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
class TaskWidgetConfig(Base):
"""One tasks widget's settings + cached-fetch state -- split out of
CalendarWidgetConfig (which used to carry these as tasks_* columns,
a week-view-only task list bolted onto a calendar widget) so a task
list can be placed and sized on its own, independent of any
calendar's view/footprint. No separate "enabled" flag -- unlike the
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 [])."""
__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.
cached: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
class WhiteboardWidgetConfig(Base):
@@ -500,6 +525,7 @@ WIDGET_CONFIG_MODELS: dict[str, type] = {
"photos": PhotoWidgetConfig,
"calendar": CalendarWidgetConfig,
"whiteboard": WhiteboardWidgetConfig,
"tasks": TaskWidgetConfig,
}