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
+21 -15
View File
@@ -1,9 +1,10 @@
# Widget system
A frame's panel isn't one fixed "mode" anymore -- it holds N independently
placed/sized widgets (photos/calendar/whiteboard), like arranging icons on
an Android home screen. A frame can hold several widgets of the same type
(e.g. two photo widgets pointed at different Immich albums side by side).
placed/sized widgets (photos/calendar/whiteboard/tasks), like arranging
icons on an Android home screen. A frame can hold several widgets of the
same type (e.g. two photo widgets pointed at different Immich albums side
by side).
This replaced an earlier design where `Frame.mode` picked exactly one
full-panel renderer; that column (and the other now-dead per-mode `Frame`
columns it left behind -- `album_id`, `calendar_*`, `whiteboard_*`, etc.)
@@ -19,17 +20,21 @@ a button press does.
## Data model
- `Widget` (`server/app/models.py`): `id`, `frame_id`, `widget_type`
(`"photos"` | `"calendar"` | `"whiteboard"`), `x`/`y`/`w`/`h` (grid
cells), `sort_order`. Widgets never overlap (enforced server-side in
(`"photos"` | `"calendar"` | `"whiteboard"` | `"tasks"`), `x`/`y`/`w`/`h`
(grid cells), `sort_order`. Widgets never overlap (enforced server-side in
`routers/api_widgets.py`, re-validated regardless of what the client
already checked) -- that's what keeps compositing simple: no z-order,
no blending, just N independent regions pasted onto one shared canvas.
- Per-type 1:1 extension tables -- `PhotoWidgetConfig`,
`CalendarWidgetConfig`, `WhiteboardWidgetConfig`, each keyed by
`widget_id` with `ondelete="CASCADE"` -- rather than one wide table with
every type's mostly-irrelevant columns. `PhotoWidgetConfig` mirrors
`app/photo_queue.py`'s attribute names exactly, so that module's
`CalendarWidgetConfig`, `WhiteboardWidgetConfig`, `TaskWidgetConfig`,
each keyed by `widget_id` with `ondelete="CASCADE"` -- rather than one
wide table with every type's mostly-irrelevant columns. `PhotoWidgetConfig`
mirrors `app/photo_queue.py`'s attribute names exactly, so that module's
advance/back/queue logic ports across widget instances unchanged.
`TaskWidgetConfig` used to be a handful of `tasks_*` columns bolted onto
`CalendarWidgetConfig` (a week-view-only task list); split into its own
widget type (migration 17) so a task list can be placed and sized
independent of any calendar's view/footprint.
- `FrameCalendar` is keyed by `widget_id` (not `frame_id`) since a frame
can now have more than one independent calendar widget, each with its
own set of included calendars.
@@ -50,16 +55,16 @@ orientation change rather than trying to remap coordinates.
Each widget type has a minimum grid footprint (`grid.MIN_FOOTPRINT`):
photos 1x1, calendar 3x2 (a crammed calendar is illegible regardless of
size-tier scaling), whiteboard 2x2. Enforced both client-side (UX, in the
Layout tab's drag/resize canvas -- `static/frame_layout.js`) and
server-side (`routers/api_widgets.py`) -- the client is never trusted
size-tier scaling), whiteboard 2x2, tasks 2x2. Enforced both client-side
(UX, in the Layout tab's drag/resize canvas -- `static/frame_layout.js`)
and server-side (`routers/api_widgets.py`) -- the client is never trusted
alone.
## Rendering: one shared compositor
`app/widgets/` is the render/action registry -- one module per
`widget_type` (`photos.py`, `calendar.py`, `whiteboard.py`), each
exposing:
`widget_type` (`photos.py`, `calendar.py`, `whiteboard.py`, `tasks.py`),
each exposing:
- `render(db, frame, widget, target_w, target_h, is_normal_wake) -> Image`:
an unquantized RGB image exactly `target_w x target_h`, the widget's
@@ -69,7 +74,8 @@ exposing:
bad moment doesn't blank the whole panel.
- `ACTIONS: dict[str, Callable]` -- named button actions this type
supports (`"advance"`/`"back"` for photos and calendar, `"check_now"`
for whiteboard).
for whiteboard). Empty for tasks -- a passive checklist on the same
throttled-refresh cadence as weather, nothing to advance/back/force.
- `ACTION_LABELS: dict[str, str]` -- human labels for the button-
assignment UI.
+78 -57
View File
@@ -1,7 +1,11 @@
"""Renders calendar frame mode's three views (agenda/week/month) into the
panel's packed format, following image_pipeline.render_placeholder's own
precedent: build an RGB canvas with ImageDraw/ImageFont, then the same
_quantize/_transpose_and_pack every other renderer ends on.
"""Renders calendar frame mode's three views (agenda/week/month), and the
separate standalone tasks widget (see models.TaskWidgetConfig -- a task
list used to be a calendar-widget-only week-view slot, split out into
its own widget type so it isn't tied to a calendar's view/footprint),
into the panel's packed format, following image_pipeline.
render_placeholder's own precedent: build an RGB canvas with
ImageDraw/ImageFont, then the same _quantize/_transpose_and_pack every
other renderer ends on.
Event dicts here are calendar_feed.py's shape: {"summary", "start", "end"
(ISO 8601 strings), "all_day", "sources": [{"owner_display_name",
@@ -523,33 +527,24 @@ def _draw_agenda_day(img: Image.Image, draw: ImageDraw.ImageDraw, day: date, eve
def _draw_tasks(img: Image.Image, draw: ImageDraw.ImageDraw, region: tuple[int, int, int, int],
tasks: list[dict], title_font: ImageFont.ImageFont, body_font: ImageFont.ImageFont,
margin: int = MARGIN) -> None:
tasks: list[dict], title_font: ImageFont.ImageFont, body_font: ImageFont.ImageFont) -> None:
"""A simple checklist filling `region` (x0, y0, w, h) -- unchecked-box
glyph + due date (if any) + summary per outstanding task, same
header/rule/row-cap/truncation shape as _draw_agenda_day's event
list so the "week view, one slot replaced by tasks instead of a day"
layout (see _build_week) reads as one consistent design rather than
two different widgets bolted together. Reuses _draw_mixed_line so a
task summary with emoji in it renders the same way an event
title's does.
`margin` defaults to the module-wide MARGIN (vertical layout's
stacked bands are as wide as the whole content region, same as
_draw_agenda_day's own sections) but a narrow horizontal-layout
column passes a much smaller one -- MARGIN on both sides of an
already-cramped ~150px week column left almost nothing for the
title text itself."""
list so the standalone tasks widget (see _build_tasks) reads as the
same consistent design as everything else on-panel, not a
bolted-together look. Reuses _draw_mixed_line so a task summary with
emoji in it renders the same way an event title's does."""
x0, y0, w, h = region
text_x0, text_y0 = x0 + margin, y0 + margin
text_w = w - margin * 2
text_x0, text_y0 = x0 + MARGIN, y0 + MARGIN
text_w = w - MARGIN * 2
draw_text(img, (text_x0, text_y0), "Tasks", title_font)
y = text_y0 + title_font.size + 12
draw.line([(text_x0, y), (text_x0 + text_w, y)], fill=RULE)
y += 12
row_h = body_font.size + 14
max_rows = max(0, (y0 + h - margin - y) // row_h)
max_rows = max(0, (y0 + h - MARGIN - y) // row_h)
if not tasks:
draw_text(img, (text_x0, y), "Nothing outstanding", body_font, MUTED)
@@ -642,16 +637,13 @@ _WEEK_HORIZONTAL_FONTS = {"large": (18, 14, 12), "medium": (14, 12, 10), "small"
def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
week_start: int, palette_rgb: list | None = None,
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
days: int = 7, layout: str = "horizontal", tasks: list[dict] | None = None,
days: int = 7, layout: str = "horizontal",
start_offset: int = 0) -> Image.Image:
"""`days` (2-10, see routers/api_widgets.py's clamp) side-by-side
columns (layout="horizontal", the original fixed-at-7 behavior
generalized) or stacked bands (layout="vertical", reusing
_draw_agenda_day the same way _build_today_tomorrow does, just for
an arbitrary day count instead of a hardcoded 2). `tasks` (see
routers/common.py's get_or_refresh_tasks), if not None, takes the
LAST slot instead of adding an extra one -- "N days" always means N
slots total, whether they're all days or N-1 days plus a task list.
an arbitrary day count instead of a hardcoded 2).
At the default 7 days, the view anchors to week_start (a fixed
weekday, "start on the most recent Monday") exactly like before --
@@ -668,7 +660,6 @@ def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
week_first_day = today - timedelta(days=days_since_start) + timedelta(days=days * browse_offset)
else:
week_first_day = today + timedelta(days=start_offset) + timedelta(days=days * browse_offset)
day_count = days - 1 if tasks is not None else days
owners_seen: list[str] = []
if layout == "vertical":
@@ -677,7 +668,7 @@ def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
body_font = ImageFont.load_default(size=max(11, body_base - days))
weather_font = ImageFont.load_default(size=max(9, weather_base - days))
section_h = target_h // days
for i in range(day_count):
for i in range(days):
section_y0 = i * section_h
if i > 0:
draw.line([(MARGIN, section_y0), (target_w - MARGIN, section_y0)], fill=RULE)
@@ -685,11 +676,6 @@ def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
_draw_agenda_day(img, draw, day, events, tz, (0, section_y0, target_w, section_h),
title_font, body_font, owners_seen, palette_rgb,
weather_cities, weather_font, weather_units)
if tasks is not None:
section_y0 = day_count * section_h
if day_count > 0:
draw.line([(MARGIN, section_y0), (target_w - MARGIN, section_y0)], fill=RULE)
_draw_tasks(img, draw, (0, section_y0, target_w, section_h), tasks, title_font, body_font)
return img
header_size, chip_size, weather_size = _WEEK_HORIZONTAL_FONTS[tier]
@@ -699,7 +685,7 @@ def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
col_w = (target_w - MARGIN * 2) // days
header_h = 44
for col in range(day_count):
for col in range(days):
day = week_first_day + timedelta(days=col)
x0 = MARGIN + col * col_w
if col > 0:
@@ -735,13 +721,6 @@ def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
chip_font, col_w - 20 - prefix_w)
y += row_h
if tasks is not None:
col = day_count
x0 = MARGIN + col * col_w
if col > 0:
draw.line([(x0, MARGIN), (x0, target_h - MARGIN)], fill=RULE)
_draw_tasks(img, draw, (x0, 0, col_w, target_h), tasks, header_font, chip_font, margin=6)
return img
@@ -814,7 +793,7 @@ _BUILDERS = {"agenda": _build_agenda, "today_tomorrow": _build_today_tomorrow, "
def _build(events: list[dict], view: str, browse_offset: int, target_w: int, target_h: int, timezone: str,
fetch_summary: str, week_start: int, palette_rgb: list | None = None,
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
week_days: int = 7, week_layout: str = "horizontal", tasks: list[dict] | None = None,
week_days: int = 7, week_layout: str = "horizontal",
week_start_offset: int = 0) -> Image.Image:
tz = ZoneInfo(timezone) if timezone else ZoneInfo("UTC")
effective_view = view
@@ -829,15 +808,14 @@ def _build(events: list[dict], view: str, browse_offset: int, target_w: int, tar
weather_cities, weather_units)
elif effective_view == "week":
img = _build_week(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb,
weather_cities, weather_units, week_days, week_layout, tasks, week_start_offset)
weather_cities, weather_units, week_days, week_layout, week_start_offset)
elif effective_view == "month":
# Never given weather or tasks -- no room for either at typical
# month-cell size, same reasoning that already keeps this view
# to density dots instead of literal event text (see
# _build_month's own docstring). Colors are still passed
# through, though -- that's a different concern (legibility of
# individual events) than needing a whole extra strip/slot of
# content.
# Never given weather -- no room for it at typical month-cell
# size, same reasoning that already keeps this view to density
# dots instead of literal event text (see _build_month's own
# docstring). Colors are still passed through, though -- that's
# a different concern (legibility of individual events) than
# needing a whole extra strip of content.
img = _build_month(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb)
else:
img = _build_agenda(events, browse_offset, target_w, target_h, tz, palette_rgb,
@@ -855,18 +833,16 @@ def render_calendar(events: list[dict], view: str, browse_offset: int, orientati
fetch_summary: str = "", manage: dict | None = None, week_start: int = 0,
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
week_days: int = 7, week_layout: str = "horizontal",
tasks: list[dict] | None = None, week_start_offset: int = 0) -> bytes:
week_start_offset: int = 0) -> bytes:
"""Renders one of CALENDAR_VIEWS full-panel to the panel's packed
format. Always returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes, same
invariant every other renderer honors. weather_cities is
routers/common.py's get_or_refresh_weather() cache, or None/[] to
omit the weather strip entirely (also always omitted for view ==
"month"). tasks is get_or_refresh_tasks()'s cache, or None to omit
the task list entirely -- only ever drawn for view == "week", see
_build_week."""
"month")."""
target_w, target_h = logical_render_size(orientation)
img = _build(events, view, browse_offset, target_w, target_h, timezone, fetch_summary, week_start,
palette_rgb, weather_cities, weather_units, week_days, week_layout, tasks, week_start_offset)
palette_rgb, weather_cities, weather_units, week_days, week_layout, week_start_offset)
img = _apply_manage_overlay(img, manage)
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
return _transpose_and_pack(quantized, orientation)
@@ -877,13 +853,58 @@ def render_calendar_preview_png(events: list[dict], view: str, browse_offset: in
fetch_summary: str = "", manage: dict | None = None, week_start: int = 0,
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
week_days: int = 7, week_layout: str = "horizontal",
tasks: list[dict] | None = None, week_start_offset: int = 0) -> bytes:
week_start_offset: int = 0) -> bytes:
"""Same pipeline as render_calendar, but a normal browser-viewable
PNG in logical (upright) orientation -- mirrors
image_pipeline.render_preview_png's relationship to render_frame."""
target_w, target_h = logical_render_size(orientation)
img = _build(events, view, browse_offset, target_w, target_h, timezone, fetch_summary, week_start,
palette_rgb, weather_cities, weather_units, week_days, week_layout, tasks, week_start_offset)
palette_rgb, weather_cities, weather_units, week_days, week_layout, week_start_offset)
img = _apply_manage_overlay(img, manage)
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
buf = io.BytesIO()
quantized.convert("RGB").save(buf, format="PNG")
return buf.getvalue()
# --- Standalone tasks widget (split out of the old calendar-widget-only
# week-view task list -- see models.TaskWidgetConfig) -----------------
_TASKS_FONTS = {"large": (24, 18), "medium": (20, 16), "small": (16, 13)}
def _build_tasks(tasks: list[dict], target_w: int, target_h: int) -> Image.Image:
"""A tasks widget's entire region is the checklist -- unlike the old
week-view slot, there's no day columns/header to share space with,
so this is just _draw_tasks over the whole box."""
img = Image.new("RGB", (target_w, target_h), BG)
draw = ImageDraw.Draw(img)
title_size, body_size = _TASKS_FONTS[_size_tier(target_w, target_h)]
title_font = ImageFont.load_default(size=title_size)
body_font = ImageFont.load_default(size=body_size)
_draw_tasks(img, draw, (0, 0, target_w, target_h), tasks, title_font, body_font)
return img
def render_tasks(tasks: list[dict], orientation: str, palette_rgb: list | None,
manage: dict | None = None) -> bytes:
"""Renders the tasks widget full-panel to the panel's packed format.
Always returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes, same invariant
every other renderer honors."""
target_w, target_h = logical_render_size(orientation)
img = _build_tasks(tasks, target_w, target_h)
img = _apply_manage_overlay(img, manage)
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
return _transpose_and_pack(quantized, orientation)
def render_tasks_preview_png(tasks: list[dict], orientation: str, palette_rgb: list | None,
manage: dict | None = None) -> bytes:
"""Same pipeline as render_tasks, but a normal browser-viewable PNG
in logical (upright) orientation -- mirrors render_calendar_preview_
png's relationship to render_calendar."""
target_w, target_h = logical_render_size(orientation)
img = _build_tasks(tasks, target_w, target_h)
img = _apply_manage_overlay(img, manage)
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
buf = io.BytesIO()
+4 -3
View File
@@ -92,9 +92,10 @@ def frame_locked(db: Session, frame_id: int) -> Iterator[Frame]:
def widget_locked(db: Session, frame_id: int, widget_id: int) -> Iterator[tuple[Frame, Widget, object]]:
"""Same lock/refresh/commit dance as frame_locked, additionally
resolving and refreshing the widget's own per-type config row
(PhotoWidgetConfig/CalendarWidgetConfig/WhiteboardWidgetConfig, see
models.WIDGET_CONFIG_MODELS). Deliberately still locks at *frame*
granularity -- the exact same per-frame threading.Lock frame_locked
(PhotoWidgetConfig/CalendarWidgetConfig/WhiteboardWidgetConfig/
TaskWidgetConfig, see models.WIDGET_CONFIG_MODELS). Deliberately
still locks at *frame* granularity -- the exact same per-frame
threading.Lock frame_locked
uses, not a separate per-widget lock -- simplest, avoids a new class
of multi-lock deadlock bugs, and this project's actual concurrency
needs are tiny (a handful of users per household frame).
+4 -1
View File
@@ -22,11 +22,14 @@ GRID_SHORT = 5
# the placement UI and server-side (routers/api_widgets.py). A calendar
# widget crammed into 1x1 would be illegible regardless of size-tier
# scaling (see calendar_render.py); whiteboard needs enough room to be
# worth looking at; photos can go as small as a single cell.
# worth looking at; photos can go as small as a single cell; tasks needs
# enough width for a due-date prefix plus a couple words of summary
# without truncating on every row.
MIN_FOOTPRINT: dict[str, tuple[int, int]] = {
"photos": (1, 1),
"calendar": (3, 2),
"whiteboard": (2, 2),
"tasks": (2, 2),
}
Rect = tuple[int, int, int, int] # (x, y, w, h)
+158 -5
View File
@@ -27,6 +27,7 @@ from .models import (
FrameButtonAction,
PhotoWidgetConfig,
ServerSettings,
TaskWidgetConfig,
WhiteboardWidgetConfig,
Widget,
)
@@ -333,6 +334,110 @@ def _migration_16(conn) -> None:
))
def _migration_17(conn) -> None:
"""Splits the calendar widget's old week-view-only task list out into
its own standalone widget type (see models.TaskWidgetConfig,
app/widgets/tasks.py) -- a task list is no longer tied to a
calendar's view or footprint, and can be placed/sized on its own.
Every calendar_widget_configs row that still has a task source
configured gets a new sibling `tasks` widget carrying that source
over, auto-placed in whatever open grid space is left on its frame
(same find_open_rect logic a manual "add widget" uses; if truly none
is left, the source is dropped and logged -- rare enough, and with
no interactive way to ask during a boot-time migration, that this is
an acceptable edge case). calendar_widget_configs then drops its now
-dead tasks_* columns -- this project's usual same-migration-drop
convention (see docs/widgets.md's Known Gaps for the one deliberate,
much-larger-blast-radius exception)."""
conn.execute(text(
"CREATE TABLE task_widget_configs ("
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
"user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, "
"calendar_key TEXT, "
"checked_at REAL NOT NULL DEFAULT 0.0, "
"cached TEXT)"
))
rows = conn.execute(text(
"SELECT cwc.widget_id, w.frame_id, f.orientation, "
"cwc.tasks_user_id, cwc.tasks_calendar_key, cwc.tasks_checked_at, cwc.tasks_cached "
"FROM calendar_widget_configs cwc "
"JOIN widgets w ON w.id = cwc.widget_id "
"JOIN frames f ON f.id = w.frame_id "
"WHERE cwc.tasks_calendar_key IS NOT NULL"
)).mappings().all()
skipped = 0
now = time.time()
for row in rows:
existing = conn.execute(text(
"SELECT x, y, w, h FROM widgets WHERE frame_id = :frame_id"
), {"frame_id": row["frame_id"]}).all()
min_w, min_h = grid.MIN_FOOTPRINT["tasks"]
rect = grid.find_open_rect(row["orientation"], [tuple(r) for r in existing], min_w, min_h)
if rect is None:
skipped += 1
continue
x, y, w, h = rect
max_sort = conn.execute(text(
"SELECT COALESCE(MAX(sort_order), 0) FROM widgets WHERE frame_id = :frame_id"
), {"frame_id": row["frame_id"]}).scalar()
result = conn.execute(text(
"INSERT INTO widgets (frame_id, widget_type, x, y, w, h, sort_order, created_at) "
"VALUES (:frame_id, 'tasks', :x, :y, :w, :h, :sort_order, :created_at)"
), {"frame_id": row["frame_id"], "x": x, "y": y, "w": w, "h": h,
"sort_order": max_sort + 1, "created_at": now})
new_widget_id = result.lastrowid
conn.execute(text(
"INSERT INTO task_widget_configs (widget_id, user_id, calendar_key, checked_at, cached) "
"VALUES (:widget_id, :user_id, :calendar_key, :checked_at, :cached)"
), {"widget_id": new_widget_id, "user_id": row["tasks_user_id"],
"calendar_key": row["tasks_calendar_key"], "checked_at": row["tasks_checked_at"],
"cached": row["tasks_cached"]})
if skipped:
logger.warning(
"%d calendar widget(s) had a task list configured but no open grid space for a "
"standalone tasks widget -- their task source was dropped", skipped
)
# Rebuild calendar_widget_configs without the now-dead tasks_*
# columns -- SQLite can't drop tasks_user_id directly (it's part of
# an FK constraint), same situation frame_calendars hit in
# _migration_9, same rebuild-create-copy-drop-rename fix.
conn.execute(text(
"CREATE TABLE calendar_widget_configs_new ("
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
"view TEXT NOT NULL DEFAULT 'agenda', "
"week_start INTEGER NOT NULL DEFAULT 0, "
"browse_offset INTEGER NOT NULL DEFAULT 0, "
"checked_at REAL NOT NULL DEFAULT 0.0, "
"cached_events TEXT, "
"fetch_summary TEXT NOT NULL DEFAULT '', "
"weather_enabled INTEGER NOT NULL DEFAULT 0, "
"weather_units TEXT NOT NULL DEFAULT 'fahrenheit', "
"weather_cities TEXT, "
"weather_checked_at REAL NOT NULL DEFAULT 0.0, "
"weather_cached TEXT, "
"week_days INTEGER NOT NULL DEFAULT 7, "
"week_layout TEXT NOT NULL DEFAULT 'horizontal', "
"week_start_offset INTEGER NOT NULL DEFAULT 0)"
))
conn.execute(text(
"INSERT INTO calendar_widget_configs_new "
"(widget_id, view, week_start, browse_offset, checked_at, cached_events, fetch_summary, "
"weather_enabled, weather_units, weather_cities, weather_checked_at, weather_cached, "
"week_days, week_layout, week_start_offset) "
"SELECT widget_id, view, week_start, browse_offset, checked_at, cached_events, fetch_summary, "
"weather_enabled, weather_units, weather_cities, weather_checked_at, weather_cached, "
"week_days, week_layout, week_start_offset "
"FROM calendar_widget_configs"
))
conn.execute(text("DROP TABLE calendar_widget_configs"))
conn.execute(text("ALTER TABLE calendar_widget_configs_new RENAME TO calendar_widget_configs"))
MIGRATIONS = [
(1, _migration_1),
(2, _migration_2),
@@ -350,6 +455,7 @@ MIGRATIONS = [
(14, _migration_14),
(15, _migration_15),
(16, _migration_16),
(17, _migration_17),
]
@@ -518,11 +624,26 @@ def _calendar_config_from_frame(frame: Frame, widget_id: int) -> CalendarWidgetC
week_days=frame.calendar_week_days,
week_layout=frame.calendar_week_layout,
week_start_offset=frame.calendar_week_start_offset,
tasks_enabled=frame.calendar_tasks_enabled,
tasks_user_id=frame.calendar_tasks_user_id,
tasks_calendar_key=frame.calendar_tasks_calendar_key,
tasks_checked_at=frame.calendar_tasks_checked_at,
tasks_cached=list(frame.calendar_tasks_cached) if frame.calendar_tasks_cached else None,
# tasks_* deliberately not carried over -- see _task_config_from_frame,
# a sibling standalone widget now, not part of this config.
)
def _task_config_from_frame(frame: Frame, widget_id: int) -> TaskWidgetConfig:
"""Only ever called for a frame whose legacy calendar_tasks_* columns
(see Frame's own docstring on those -- a dead pre-widget-system
field set, same status as calendar_photo_inlay below) still carry a
configured source -- i.e. a database jumping straight from before
the widget system existed to after tasks became its own widget type
in a single upgrade, skipping the intermediate period where it would
have lived on CalendarWidgetConfig instead (see _migration_17's own
extraction of *that* case)."""
return TaskWidgetConfig(
widget_id=widget_id,
user_id=frame.calendar_tasks_user_id,
calendar_key=frame.calendar_tasks_calendar_key,
checked_at=frame.calendar_tasks_checked_at,
cached=list(frame.calendar_tasks_cached) if frame.calendar_tasks_cached else None,
)
@@ -556,6 +677,33 @@ def _default_button_actions(frame_id: int, widget_id: int, widget_type: str) ->
]
def _maybe_add_legacy_tasks_widget(db, frame: Frame, existing: list[grid.Rect], next_sort_order: int) -> None:
"""Only relevant for a database jumping straight from before the
widget system existed to after tasks became its own widget type in
one upgrade (see _task_config_from_frame) -- frame.calendar_tasks_*
is the dead legacy field set otherwise. Auto-placed in whatever open
space is left after the widget(s) above it in _backfill_frame_
widgets claimed theirs, same find_open_rect logic a manual "add
widget" uses; silently dropped (logged) if none fits, same as this
migration having nowhere else to put it either."""
if not frame.calendar_tasks_calendar_key:
return
min_w, min_h = grid.MIN_FOOTPRINT["tasks"]
rect = grid.find_open_rect(frame.orientation, existing, min_w, min_h)
if rect is None:
logger.warning(
"Frame %d had a legacy task list configured but no open grid space for a "
"standalone tasks widget during backfill -- its task source was dropped", frame.id
)
return
x, y, w, h = rect
task_widget = Widget(frame_id=frame.id, widget_type="tasks", x=x, y=y, w=w, h=h,
sort_order=next_sort_order, created_at=time.time())
db.add(task_widget)
db.flush()
db.add(_task_config_from_frame(frame, task_widget.id))
def _backfill_frame_widgets(db, frame: Frame) -> None:
cols, rows = grid.grid_dims(frame.orientation)
mode = frame.mode if frame.mode in ("photos", "calendar", "whiteboard") else "photos"
@@ -575,6 +723,9 @@ def _backfill_frame_widgets(db, frame: Frame) -> None:
db.add(_calendar_config_from_frame(frame, cal_widget.id))
db.add(_photo_config_from_frame(frame, photo_widget.id))
db.add_all(_default_button_actions(frame.id, cal_widget.id, "calendar"))
_maybe_add_legacy_tasks_widget(
db, frame, [(0, 0, cols - half, rows), (cols - half, 0, half, rows)], next_sort_order=2
)
return
widget = Widget(frame_id=frame.id, widget_type=mode, x=0, y=0, w=cols, h=rows,
@@ -588,6 +739,8 @@ def _backfill_frame_widgets(db, frame: Frame) -> None:
elif mode == "whiteboard":
db.add(_whiteboard_config_from_frame(frame, widget.id))
db.add_all(_default_button_actions(frame.id, widget.id, mode))
if mode == "calendar":
_maybe_add_legacy_tasks_widget(db, frame, [(0, 0, cols, rows)], next_sort_order=1)
def _ensure_widgets_backfilled() -> None:
+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,
}
+41 -27
View File
@@ -1,8 +1,8 @@
"""Everything scoped to one specific widget rather than "the frame":
placement CRUD (backing the Layout tab's canvas, static/frame_layout.js)
plus every setting/action that used to assume a frame had at most one
widget of a given type -- photo queue, calendar inclusion/color/tasks,
whiteboard source, and their preview endpoints. Split out of
widget of a given type -- photo queue, calendar inclusion/color, tasks
source, whiteboard source, and their preview endpoints. Split out of
api_frames.py (which keeps frame-wide settings: orientation, quiet
hours, palette, firmware, stats) once a frame could hold more than one
widget of the same type, at which point "the frame's calendar settings"
@@ -35,6 +35,7 @@ from ..models import (
Frame,
FrameCalendar,
PhotoWidgetConfig,
TaskWidgetConfig,
WhiteboardWidgetConfig,
WIDGET_CONFIG_MODELS,
Widget,
@@ -254,7 +255,6 @@ def api_widget_config_save(
calendar_week_start_offset: int | None = Form(None),
calendar_weather_enabled: bool | None = Form(None),
calendar_weather_units: str | None = Form(None),
calendar_tasks_enabled: bool | None = Form(None),
):
"""Every field optional -- same partial-update, form-urlencoded
convention as the old frame-level api_config_save, now scoped to one
@@ -317,8 +317,6 @@ def api_widget_config_save(
# new unit label.
ccfg.weather_checked_at = 0.0
ccfg.weather_units = calendar_weather_units
if calendar_tasks_enabled is not None:
ccfg.tasks_enabled = calendar_tasks_enabled
with frame_locked(db, frame.id) as cfg:
cfg.stats_config_saves += 1
return {"status": "saved"}
@@ -506,7 +504,7 @@ def api_widget_preview_rendered(
return Response(content=png, media_type="image/png")
# --- Calendar: inclusion/color/tasks/weather/preview --------------------
# --- Calendar: inclusion/color/weather/preview ---------------------------
class CalendarSelectRequest(BaseModel):
user_id: int
@@ -609,21 +607,37 @@ def api_widget_preview_calendar(
ccfg = db.get(CalendarWidgetConfig, widget.id)
events, summary = get_or_refresh_calendar_events_for_widget(db, frame, widget)
weather_cities = get_or_refresh_weather_for_widget(db, frame, widget) if ccfg.weather_enabled else None
tasks = (
get_or_refresh_tasks_for_widget(db, frame, widget)
if (ccfg.view == "week" and ccfg.tasks_enabled) else None
)
png = calendar_render.render_calendar_preview_png(
events, view=ccfg.view, browse_offset=ccfg.browse_offset, orientation=frame.orientation,
palette_rgb=frame.palette_rgb, timezone=frame.timezone, fetch_summary=summary,
week_start=ccfg.week_start,
weather_cities=weather_cities, weather_units=ccfg.weather_units,
week_days=ccfg.week_days, week_layout=ccfg.week_layout, tasks=tasks,
week_days=ccfg.week_days, week_layout=ccfg.week_layout,
week_start_offset=ccfg.week_start_offset,
)
return Response(content=png, media_type="image/png")
# --- Tasks: source/preview ------------------------------------------------
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/preview/tasks")
def api_widget_preview_tasks(
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), db: Session = Depends(get_db)
):
"""The same cached task list a live device render would use, same
"reflects what's currently saved" convention as the other preview
endpoints."""
frame, widget = frame_widget
_require_widget_type(widget, "tasks")
tcfg = db.get(TaskWidgetConfig, widget.id)
if not tcfg.calendar_key or not tcfg.user_id:
raise HTTPException(400, "No task list configured on this widget yet")
tasks = get_or_refresh_tasks_for_widget(db, frame, widget)
png = calendar_render.render_tasks_preview_png(tasks, orientation=frame.orientation,
palette_rgb=frame.palette_rgb)
return Response(content=png, media_type="image/png")
class TasksSourceRequest(BaseModel):
calendar_key: str | None # None clears the source
@@ -634,27 +648,27 @@ def api_widget_tasks_source(
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view),
db: Session = Depends(get_db),
):
"""Points this widget's week-view task list at one of the calling
user's own CalDAV calendars -- same owner-controls-their-own-data
permission split as calendar-select's included=True, since this is
volunteering personal calendar data, not a display setting a
controller should get to pick on someone else's behalf. None clears
the source; clearing (unlike setting) isn't ownership-gated -- like
muting a shared calendar, anyone linked to the frame can turn off a
task list they'd rather not see, but only its owner can point the
widget at one of their calendars to begin with."""
"""Points this tasks widget at one of the calling user's own CalDAV
calendars -- same owner-controls-their-own-data permission split as
calendar-select's included=True, since this is volunteering personal
calendar data, not a display setting a controller should get to pick
on someone else's behalf. None clears the source; clearing (unlike
setting) isn't ownership-gated -- like muting a shared calendar,
anyone linked to the frame can turn off a task list they'd rather
not see, but only its owner can point the widget at one of their
calendars to begin with."""
frame, widget = frame_widget
_require_widget_type(widget, "calendar")
_require_widget_type(widget, "tasks")
user = require_user_api(request, db)
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
if body.calendar_key is None:
cfg.tasks_user_id = None
cfg.tasks_calendar_key = None
cfg.tasks_cached = None
cfg.user_id = None
cfg.calendar_key = None
cfg.cached = None
else:
cfg.tasks_user_id = user.id
cfg.tasks_calendar_key = body.calendar_key
cfg.tasks_checked_at = 0.0 # pick up the change promptly
cfg.user_id = user.id
cfg.calendar_key = body.calendar_key
cfg.checked_at = 0.0 # pick up the change promptly
return {"status": "saved", "calendar_key": body.calendar_key}
+17 -16
View File
@@ -27,6 +27,7 @@ from ..models import (
FrameButtonAction,
FrameCalendar,
PhotoWidgetConfig,
TaskWidgetConfig,
User,
Widget,
WhiteboardWidgetConfig,
@@ -566,33 +567,33 @@ def get_or_refresh_weather_for_widget(db: Session, frame: Frame, widget: Widget)
def get_or_refresh_tasks_for_widget(db: Session, frame: Frame, widget: Widget) -> list[dict]:
"""Throttled task-list cache (calendar_feed.CHECK_INTERVAL_S, same
cadence as event merging), reading/writing CalendarWidgetConfig (see
app/widgets/calendar.py). [] if tasks are off, no source is set, or
the source user's CalDAV credentials/calendar_key have gone missing
(e.g. they unlinked their account). A refetch failure keeps the
last-known list rather than going blank for one bad cycle, same
reasoning as get_or_refresh_weather_for_widget."""
cfg = db.get(CalendarWidgetConfig, widget.id)
if not cfg.tasks_enabled or not cfg.tasks_calendar_key or not cfg.tasks_user_id:
cadence as event merging), reading/writing TaskWidgetConfig (see
app/widgets/tasks.py). [] if no source is set, or the source user's
CalDAV credentials/calendar_key have gone missing (e.g. they
unlinked their account). A refetch failure keeps the last-known list
rather than going blank for one bad cycle, same reasoning as
get_or_refresh_weather_for_widget."""
cfg = db.get(TaskWidgetConfig, widget.id)
if not cfg.calendar_key or not cfg.user_id:
return []
now = time.time()
if cfg.tasks_cached is not None and now - cfg.tasks_checked_at < calendar_feed.CHECK_INTERVAL_S:
return cfg.tasks_cached
if cfg.cached is not None and now - cfg.checked_at < calendar_feed.CHECK_INTERVAL_S:
return cfg.cached
user = db.get(User, cfg.tasks_user_id)
key = cfg.tasks_calendar_key
user = db.get(User, cfg.user_id)
key = cfg.calendar_key
if user is None or not user.calendar_caldav_username or not key.startswith("caldav:"):
return cfg.tasks_cached or []
return cfg.cached or []
href = key[len("caldav:"):]
try:
tasks = caldav_client.fetch_tasks(href, user.calendar_caldav_username, user.calendar_caldav_password)
except caldav_client.CalDavError as e:
logger.warning("Could not refresh tasks for widget %d: %s", widget.id, e)
return cfg.tasks_cached or []
return cfg.cached or []
with widget_locked(db, frame.id, widget.id) as (_, _, locked_cfg):
locked_cfg.tasks_cached = tasks
locked_cfg.tasks_checked_at = now
locked_cfg.cached = tasks
locked_cfg.checked_at = now
return tasks
+19 -12
View File
@@ -31,6 +31,7 @@ from ..models import (
Frame,
FrameCalendar,
PhotoWidgetConfig,
TaskWidgetConfig,
User,
UserFrame,
WhiteboardWidgetConfig,
@@ -136,22 +137,22 @@ def _calendar_users_for_widget(db: Session, frame_id: int, widget_id: int, viewe
return result
def _tasks_source_info(db: Session, calendar_cfg: CalendarWidgetConfig) -> dict | None:
"""Whose CalDAV calendar this widget's week-view task list currently
pulls from, and its label -- for showing "using <name>'s Chores
list" to everyone linked, not just whoever set it. None if no
source is configured."""
if not calendar_cfg.tasks_user_id or not calendar_cfg.tasks_calendar_key:
def _tasks_source_info(db: Session, task_cfg: TaskWidgetConfig) -> dict | None:
"""Whose CalDAV calendar this tasks widget currently pulls from, and
its label -- for showing "using <name>'s Chores list" to everyone
linked, not just whoever set it. None if no source is configured."""
if not task_cfg.user_id or not task_cfg.calendar_key:
return None
user = db.get(User, calendar_cfg.tasks_user_id)
user = db.get(User, task_cfg.user_id)
if user is None:
return None
label = calendar_cfg.tasks_calendar_key
label = task_cfg.calendar_key
for c in (user.calendar_caldav_calendars or []):
if f"caldav:{c['href']}" == calendar_cfg.tasks_calendar_key:
if f"caldav:{c['href']}" == task_cfg.calendar_key:
label = c.get("display_name") or label
break
return {"user_id": user.id, "display_name": user.display_name or user.username, "label": label}
return {"user_id": user.id, "display_name": user.display_name or user.username, "label": label,
"calendar_key": task_cfg.calendar_key}
def _whiteboard_source_info(db: Session, whiteboard_cfg: WhiteboardWidgetConfig) -> dict | None:
@@ -195,7 +196,6 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session =
if widget.widget_type == "calendar":
calendar_cfg = db.get(CalendarWidgetConfig, widget.id)
viewer_task_calendars = [c for c in _user_available_calendars(user) if c["key"].startswith("caldav:")]
return templates.TemplateResponse("_widget_dialog_calendar.html", {
"request": request, "frame": frame, "widget": widget, "calendar_cfg": calendar_cfg, "user": user,
"calendar_views": CALENDAR_VIEW_LABELS,
@@ -204,8 +204,15 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session =
"calendar_color_labels": PALETTE_LABELS,
"default_palette_rgb": DEFAULT_PALETTE_RGB,
"palette_to_hex": palette_to_hex,
})
if widget.widget_type == "tasks":
task_cfg = db.get(TaskWidgetConfig, widget.id)
viewer_task_calendars = [c for c in _user_available_calendars(user) if c["key"].startswith("caldav:")]
return templates.TemplateResponse("_widget_dialog_tasks.html", {
"request": request, "frame": frame, "widget": widget, "user": user,
"viewer_task_calendars": viewer_task_calendars,
"tasks_source": _tasks_source_info(db, calendar_cfg),
"tasks_source": _tasks_source_info(db, task_cfg),
})
if widget.widget_type == "whiteboard":
+1 -1
View File
@@ -60,7 +60,7 @@
// Shared display names for widget_type, everywhere one shows up in the
// UI (Layout canvas, Add-a-widget buttons, button-assignment dropdowns).
const WIDGET_LABELS = { photos: 'Photos', calendar: 'Calendar', whiteboard: 'Whiteboard (alpha)' };
const WIDGET_LABELS = { photos: 'Photos', calendar: 'Calendar', whiteboard: 'Whiteboard (alpha)', tasks: 'Tasks' };
function showStatus(ok, message) {
// While a <dialog> is open, its own .dialog-result container gets the
+9 -3
View File
@@ -280,12 +280,18 @@ window.addEventListener('resize', () => {
// a frame can now have several widgets of the same type, so "the
// Calendar tab" stopped meaning anything unambiguous.
// widget_dialog_{photos,calendar,whiteboard}.js each define an
// widget_dialog_{photos,calendar,whiteboard,tasks}.js each define an
// init<Type>Dialog()/close<Type>Dialog() pair (loaded unconditionally by
// frame_layout.html, since which one runs depends on which widget's gear
// icon was clicked).
const DIALOG_INIT = { photos: initPhotosDialog, calendar: initCalendarDialog, whiteboard: initWhiteboardDialog };
const DIALOG_CLOSE = { photos: closePhotosDialog, calendar: closeCalendarDialog, whiteboard: closeWhiteboardDialog };
const DIALOG_INIT = {
photos: initPhotosDialog, calendar: initCalendarDialog, whiteboard: initWhiteboardDialog,
tasks: initTasksDialog,
};
const DIALOG_CLOSE = {
photos: closePhotosDialog, calendar: closeCalendarDialog, whiteboard: closeWhiteboardDialog,
tasks: closeTasksDialog,
};
let openDialogWidgetType = null;
+1 -87
View File
@@ -1,5 +1,5 @@
// Calendar widget dialog: view/week-start settings, per-user opt-in,
// weather, tasks, and the rendered preview. Not a page-load script --
// weather, and the rendered preview. Not a page-load script --
// frame_layout.js fetches this widget's dialog HTML fragment, injects
// it into the shared <dialog>, points window.FRAME_API at this specific
// widget (/api/frames/{id}/widgets/{widget_id}), then calls
@@ -64,48 +64,6 @@ async function removeWeatherCity(e) {
}
}
// Rewrites #tasks-current-source in place instead of telling the user
// to reload -- the API always assigns a successful "set" to the caller
// (see api_widget_tasks_source), so after either action we already know
// exactly what the new state is without asking the server again.
function renderTasksCurrentSource(label) {
const container = document.getElementById('tasks-current-source');
container.innerHTML = '';
if (!label) return; // matches the template's no-tasks_source branch: nothing rendered
const p = document.createElement('p');
p.className = 'sub';
p.style.marginTop = '10px';
p.append('Currently using your ');
const labelEl = document.createElement('strong');
labelEl.textContent = label;
p.append(labelEl, ' list. ');
const clearBtn = document.createElement('button');
clearBtn.type = 'button';
clearBtn.className = 'btn-inline secondary';
clearBtn.id = 'tasks-source-clear';
clearBtn.textContent = 'Clear';
clearBtn.addEventListener('click', clearTasksSource);
p.append(clearBtn);
container.append(p);
}
async function clearTasksSource() {
try {
const resp = await fetch(`${window.FRAME_API}/tasks-source`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ calendar_key: null }),
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Task list cleared.');
renderTasksCurrentSource(null);
document.querySelectorAll('.tasks-source-choice').forEach((r) => { r.checked = false; });
loadCalendarPreview();
} catch (e) {
showStatus(false, e.message);
}
}
function loadCalendarPreview() {
document.getElementById('calendar-preview').src = `${window.FRAME_API}/preview/calendar?_=${Date.now()}`;
}
@@ -237,50 +195,6 @@ function initCalendarDialog() {
}
});
document.getElementById('tasks_enabled').addEventListener('change', async (e) => {
const el = e.target;
try {
const resp = await fetch(`${window.FRAME_API}/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ calendar_tasks_enabled: String(el.checked) }),
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Saved.');
loadCalendarPreview();
} catch (e) {
el.checked = !el.checked;
showStatus(false, e.message);
}
});
// Choosing one of your own CalDAV task lists as this widget's source --
// owner-only (see api_widget_tasks_source), so these radios only ever
// render for the viewer's own calendars anyway.
document.querySelectorAll('.tasks-source-choice').forEach((el) => {
el.addEventListener('change', async () => {
try {
const resp = await fetch(`${window.FRAME_API}/tasks-source`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ calendar_key: el.dataset.key }),
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Task list saved.');
const labelEl = el.closest('li').querySelector('label');
renderTasksCurrentSource(labelEl ? labelEl.textContent.trim() : '');
loadCalendarPreview();
} catch (e) {
showStatus(false, e.message);
}
});
});
const tasksSourceClear = document.getElementById('tasks-source-clear');
if (tasksSourceClear) {
tasksSourceClear.addEventListener('click', clearTasksSource);
}
document.getElementById('calendar-preview-refresh').addEventListener('click', loadCalendarPreview);
loadCalendarPreview();
}
+91
View File
@@ -0,0 +1,91 @@
// Tasks widget dialog: pick one of the viewer's own CalDAV task lists as
// this widget's source, plus the rendered preview. Not a page-load
// script -- frame_layout.js fetches this widget's dialog HTML fragment,
// injects it into the shared <dialog>, points window.FRAME_API at this
// specific widget (/api/frames/{id}/widgets/{widget_id}), then calls
// initTasksDialog().
// Rewrites #tasks-current-source in place instead of telling the user to
// reload -- the API always assigns a successful "set" to the caller
// (see api_widget_tasks_source), so after either action we already know
// exactly what the new state is without asking the server again.
function renderTasksCurrentSource(label) {
const container = document.getElementById('tasks-current-source');
container.innerHTML = '';
if (!label) {
container.innerHTML = '<p class="sub" style="margin-top: 10px;">No task list configured yet.</p>';
return;
}
const p = document.createElement('p');
p.className = 'sub';
p.style.marginTop = '10px';
p.append('Currently using your ');
const labelEl = document.createElement('strong');
labelEl.textContent = label;
p.append(labelEl, ' list. ');
const clearBtn = document.createElement('button');
clearBtn.type = 'button';
clearBtn.className = 'btn-inline secondary';
clearBtn.id = 'tasks-source-clear';
clearBtn.textContent = 'Clear';
clearBtn.addEventListener('click', clearTasksSource);
p.append(clearBtn);
container.append(p);
}
async function clearTasksSource() {
try {
const resp = await fetch(`${window.FRAME_API}/tasks-source`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ calendar_key: null }),
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Task list cleared.');
renderTasksCurrentSource(null);
document.querySelectorAll('.tasks-source-choice').forEach((r) => { r.checked = false; });
loadTasksPreview();
} catch (e) {
showStatus(false, e.message);
}
}
function loadTasksPreview() {
document.getElementById('tasks-preview').src = `${window.FRAME_API}/preview/tasks?_=${Date.now()}`;
}
function initTasksDialog() {
// Choosing one of your own CalDAV task lists as this widget's source --
// owner-only (see api_widget_tasks_source), so these radios only ever
// render for the viewer's own calendars anyway.
document.querySelectorAll('.tasks-source-choice').forEach((el) => {
el.addEventListener('change', async () => {
try {
const resp = await fetch(`${window.FRAME_API}/tasks-source`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ calendar_key: el.dataset.key }),
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Task list saved.');
const labelEl = el.closest('li').querySelector('label');
renderTasksCurrentSource(labelEl ? labelEl.textContent.trim() : '');
loadTasksPreview();
} catch (e) {
showStatus(false, e.message);
}
});
});
const tasksSourceClear = document.getElementById('tasks-source-clear');
if (tasksSourceClear) {
tasksSourceClear.addEventListener('click', clearTasksSource);
}
document.getElementById('tasks-preview-refresh').addEventListener('click', loadTasksPreview);
loadTasksPreview();
}
function closeTasksDialog() {
// Nothing to tear down -- no poll interval, unlike the photos dialog.
}
@@ -127,44 +127,6 @@
</div>
</section>
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Tasks</h2>
<p class="sub">Week view only -- takes the place of one day slot
instead of adding an extra one.</p>
<div class="checkbox-row" id="tasks-enabled-row">
<input type="checkbox" id="tasks_enabled" {% if calendar_cfg.tasks_enabled %}checked{% endif %}>
<label for="tasks_enabled">Show a task list</label>
</div>
<div id="tasks-current-source">
{% if tasks_source %}
<p class="sub" style="margin-top: 10px;">
Currently using <strong>{{ tasks_source.display_name }}</strong>'s
<strong>{{ tasks_source.label }}</strong> list.
<button type="button" class="btn-inline secondary" id="tasks-source-clear">Clear</button>
</p>
{% endif %}
</div>
{% if viewer_task_calendars %}
<p class="sub" style="margin-top: 10px;">Use one of your own CalDAV task lists:</p>
<ul class="calendar-user-list">
{% for c in viewer_task_calendars %}
<li class="checkbox-row" style="margin-top: 6px;">
<input type="radio" name="tasks-source-choice" class="tasks-source-choice" data-key="{{ c.key }}"
{% if tasks_source and tasks_source.user_id == user.id and calendar_cfg.tasks_calendar_key == c.key %}checked{% endif %}>
<label style="margin: 0; font-weight: normal;">{{ c.label }}</label>
</li>
{% endfor %}
</ul>
{% else %}
<p class="sub" style="margin-top: 10px;">You don't have any CalDAV
task lists available -- set up a CalDAV account in
<a href="/settings">Settings</a> first (a plain ICS subscription
doesn't carry tasks).</p>
{% endif %}
</section>
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Preview</h2>
<p class="sub">How this widget currently renders.</p>
@@ -0,0 +1,44 @@
<h2 class="dialog-title">Tasks widget</h2>
<section class="card">
<h2 class="card-title">Task list source</h2>
<p class="sub">A simple outstanding-task checklist. CalDAV only -- a
task list is a VTODO collection, which a plain ICS subscription
doesn't carry.</p>
<div id="tasks-current-source">
{% if tasks_source %}
<p class="sub" style="margin-top: 10px;">
Currently using <strong>{{ tasks_source.display_name }}</strong>'s
<strong>{{ tasks_source.label }}</strong> list.
<button type="button" class="btn-inline secondary" id="tasks-source-clear">Clear</button>
</p>
{% else %}
<p class="sub" style="margin-top: 10px;">No task list configured yet.</p>
{% endif %}
</div>
{% if viewer_task_calendars %}
<p class="sub" style="margin-top: 10px;">Use one of your own CalDAV task lists:</p>
<ul class="calendar-user-list">
{% for c in viewer_task_calendars %}
<li class="checkbox-row" style="margin-top: 6px;">
<input type="radio" name="tasks-source-choice" class="tasks-source-choice" data-key="{{ c.key }}"
{% if tasks_source and tasks_source.user_id == user.id and tasks_source.calendar_key == c.key %}checked{% endif %}>
<label style="margin: 0; font-weight: normal;">{{ c.label }}</label>
</li>
{% endfor %}
</ul>
{% else %}
<p class="sub" style="margin-top: 10px;">You don't have any CalDAV
task lists available -- set up a CalDAV account in
<a href="/settings">Settings</a> first.</p>
{% endif %}
</section>
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Preview</h2>
<p class="sub">How this widget currently renders.</p>
<img class="preview-img" id="tasks-preview" alt="Tasks preview">
<button type="button" class="secondary" id="tasks-preview-refresh">Refresh now</button>
</section>
+1
View File
@@ -58,5 +58,6 @@
<script src="/static/queue.js"></script>
<script src="/static/widget_dialog_calendar.js"></script>
<script src="/static/widget_dialog_whiteboard.js"></script>
<script src="/static/widget_dialog_tasks.js"></script>
<script src="/static/frame_layout.js"></script>
{% endblock %}
+2 -1
View File
@@ -36,10 +36,11 @@ Each module in this package exposes:
from __future__ import annotations
from . import calendar, photos, whiteboard
from . import calendar, photos, tasks, whiteboard
WIDGET_TYPES = {
"photos": photos,
"calendar": calendar,
"whiteboard": whiteboard,
"tasks": tasks,
}
+1 -6
View File
@@ -24,7 +24,6 @@ from ..db import widget_locked
from ..models import CalendarWidgetConfig, Frame, Widget
from ..routers.common import (
get_or_refresh_calendar_events_for_widget,
get_or_refresh_tasks_for_widget,
get_or_refresh_weather_for_widget,
)
@@ -47,16 +46,12 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i
cfg = db.get(CalendarWidgetConfig, widget.id)
events, fetch_summary = get_or_refresh_calendar_events_for_widget(db, frame, widget)
weather_cities = get_or_refresh_weather_for_widget(db, frame, widget) if cfg.weather_enabled else None
tasks = (
get_or_refresh_tasks_for_widget(db, frame, widget)
if (cfg.view == "week" and cfg.tasks_enabled) else None
)
return _build(
events, view=cfg.view, browse_offset=cfg.browse_offset, target_w=target_w, target_h=target_h,
timezone=frame.timezone, fetch_summary=fetch_summary, week_start=cfg.week_start,
palette_rgb=frame.palette_rgb, weather_cities=weather_cities, weather_units=cfg.weather_units,
week_days=cfg.week_days, week_layout=cfg.week_layout, tasks=tasks,
week_days=cfg.week_days, week_layout=cfg.week_layout,
week_start_offset=cfg.week_start_offset,
)
+36
View File
@@ -0,0 +1,36 @@
"""Tasks widget: a simple outstanding-task checklist in its own region --
split out of the calendar widget's old week-view-only task list (see
models.TaskWidgetConfig) so a task list can be placed and sized on its
own, independent of any calendar's view/footprint.
No "enabled" concept and no button actions: the widget's mere presence
on the grid is the on/off switch (same as every other widget type), and
its cache refreshes on the same throttled schedule as weather -- nothing
here to advance/back/force."""
from __future__ import annotations
from PIL import Image
from sqlalchemy.orm import Session
from ..calendar_render import _build_tasks
from ..models import Frame, TaskWidgetConfig, Widget
from ..routers.common import get_or_refresh_tasks_for_widget
from ._shared import placeholder_image
ACTION_LABELS: dict[str, str] = {}
def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: int,
is_normal_wake: bool = True) -> Image.Image:
"""is_normal_wake is unused here -- see app/widgets/photos.py's
identical note; every widget type's render() shares one call
signature regardless of which ones actually care."""
cfg = db.get(TaskWidgetConfig, widget.id)
if not cfg.calendar_key or not cfg.user_id:
return placeholder_image(target_w, target_h, ["Tasks widget", "not configured yet"])
tasks = get_or_refresh_tasks_for_widget(db, frame, widget)
return _build_tasks(tasks, target_w, target_h)
ACTIONS: dict = {}
+89 -2
View File
@@ -20,6 +20,7 @@ from app.models import (
FrameCalendar,
PhotoWidgetConfig,
ServerSettings,
TaskWidgetConfig,
Widget,
WhiteboardWidgetConfig,
)
@@ -179,7 +180,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",
for table in ("frame_button_actions", "whiteboard_widget_configs", "task_widget_configs",
"calendar_widget_configs", "photo_widget_configs", "widgets"):
conn.execute(text(f"DROP TABLE {table}"))
conn.execute(text("DROP TABLE frame_calendars"))
@@ -233,6 +234,92 @@ 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."""
with db_module.engine.begin() as conn:
conn.execute(text("DROP TABLE task_widget_configs"))
conn.execute(text("DROP TABLE calendar_widget_configs"))
conn.execute(text(
"CREATE TABLE calendar_widget_configs ("
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
"view TEXT NOT NULL DEFAULT 'agenda', "
"week_start INTEGER NOT NULL DEFAULT 0, "
"browse_offset INTEGER NOT NULL DEFAULT 0, "
"checked_at REAL NOT NULL DEFAULT 0.0, "
"cached_events TEXT, "
"fetch_summary TEXT NOT NULL DEFAULT '', "
"weather_enabled INTEGER NOT NULL DEFAULT 0, "
"weather_units TEXT NOT NULL DEFAULT 'fahrenheit', "
"weather_cities TEXT, "
"weather_checked_at REAL NOT NULL DEFAULT 0.0, "
"weather_cached TEXT, "
"week_days INTEGER NOT NULL DEFAULT 7, "
"week_layout TEXT NOT NULL DEFAULT 'horizontal', "
"week_start_offset INTEGER NOT NULL DEFAULT 0, "
"tasks_enabled INTEGER NOT NULL DEFAULT 0, "
"tasks_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, "
"tasks_calendar_key TEXT, "
"tasks_checked_at REAL NOT NULL DEFAULT 0.0, "
"tasks_cached TEXT)"
))
conn.execute(text("UPDATE schema_version SET version = 16"))
user = make_user(db_session, "task-owner")
user_id = user.id
# Frame #1's auto-migrated widget is a full-panel "photos" one --
# remove it to free up grid space for the calendar widget below (and
# for the tasks widget this migration is expected to carve out).
db_session.query(Widget).filter_by(frame_id=1, widget_type="photos").delete()
db_session.commit()
calendar_widget = Widget(frame_id=1, widget_type="calendar", x=0, y=0, w=3, h=2,
sort_order=1, created_at=time.time())
db_session.add(calendar_widget)
db_session.flush()
calendar_widget_id = calendar_widget.id
db_session.commit()
with db_module.engine.begin() as conn:
conn.execute(text(
"INSERT INTO calendar_widget_configs "
"(widget_id, tasks_enabled, tasks_user_id, tasks_calendar_key, tasks_checked_at, tasks_cached) "
"VALUES (:widget_id, 1, :user_id, 'caldav:/some/tasks/', 123.0, '[{\"summary\": \"Buy milk\"}]')"
), {"widget_id": calendar_widget_id, "user_id": user_id})
run_migrations()
with db_module.engine.begin() as conn:
version = conn.execute(text("SELECT version FROM schema_version")).scalar()
assert version == MIGRATIONS[-1][0]
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_widgets = db_session.scalars(
select(Widget).where(Widget.frame_id == 1, Widget.widget_type == "tasks")
).all()
assert len(task_widgets) == 1
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"}]
# Auto-placed without overlapping the calendar widget it was split from.
cal = db_session.get(Widget, calendar_widget_id)
assert not grid.overlaps((cal.x, cal.y, cal.w, cal.h),
(task_widget.x, task_widget.y, task_widget.w, task_widget.h))
def test_frame_calendars_rekey_attaches_existing_rows_to_their_calendar_widget(db_session):
"""A frame whose mode was "calendar" (not "photos") gets a calendar
widget from _ensure_widgets_backfilled -- _ensure_frame_calendars_
@@ -243,7 +330,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",
for table in ("frame_button_actions", "whiteboard_widget_configs", "task_widget_configs",
"calendar_widget_configs", "photo_widget_configs", "widgets"):
conn.execute(text(f"DROP TABLE {table}"))
conn.execute(text("DROP TABLE frame_calendars"))
+20 -9
View File
@@ -13,6 +13,7 @@ from app.models import (
CalendarWidgetConfig,
Frame,
FrameCalendar,
TaskWidgetConfig,
User,
Widget,
WhiteboardWidgetConfig,
@@ -62,6 +63,16 @@ def _add_calendar_widget(db_session, frame: Frame) -> Widget:
return widget
def _add_tasks_widget(db_session, frame: Frame) -> Widget:
widget = Widget(frame_id=frame.id, 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
# --- whiteboard-source ---
def test_whiteboard_source_owner_can_set_it(client, db_session):
@@ -158,7 +169,7 @@ def test_whiteboard_source_400s_when_widget_is_not_a_whiteboard(client, db_sessi
def test_tasks_source_set_always_targets_the_caller(client, db_session):
frame = _setup_two_linked_users(client, db_session)
widget = _add_calendar_widget(db_session, frame)
widget = _add_tasks_widget(db_session, frame)
client.cookies.clear()
login(client, "bob")
@@ -167,14 +178,14 @@ def test_tasks_source_set_always_targets_the_caller(client, db_session):
assert resp.status_code == 200, resp.text
bob_row = db_session.query(User).filter_by(username="bob").one()
cfg = db_session.get(CalendarWidgetConfig, widget.id)
assert cfg.tasks_user_id == bob_row.id
assert cfg.tasks_calendar_key == "caldav:/some/tasks/"
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_calendar_widget(db_session, frame)
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))
@@ -184,9 +195,9 @@ def test_tasks_source_anyone_linked_can_clear(client, db_session):
headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
cfg = db_session.get(CalendarWidgetConfig, widget.id)
assert cfg.tasks_user_id is None
assert cfg.tasks_calendar_key is None
cfg = db_session.get(TaskWidgetConfig, widget.id)
assert cfg.user_id is None
assert cfg.calendar_key is None
def test_tasks_source_404s_for_a_widget_id_that_does_not_exist(client, db_session):
@@ -196,7 +207,7 @@ def test_tasks_source_404s_for_a_widget_id_that_does_not_exist(client, db_sessio
assert resp.status_code == 404
def test_tasks_source_400s_when_widget_is_not_a_calendar(client, db_session):
def test_tasks_source_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",
+22 -4
View File
@@ -10,7 +10,7 @@ from __future__ import annotations
import pytest
from PIL import Image
from app.calendar_render import CALENDAR_VIEWS, render_calendar
from app.calendar_render import CALENDAR_VIEWS, render_calendar, render_tasks
from app.grid import cell_to_pixels, full_panel_rect, grid_dims
from app.image_pipeline import EPD_HEIGHT, EPD_WIDTH, render_panel, render_placeholder
@@ -55,11 +55,10 @@ def test_calendar_render_size_empty_events():
assert len(data) == EXPECTED_BYTES
def test_calendar_render_size_with_fetch_summary_and_tasks():
tasks = [{"summary": "Buy milk", "completed": False}, {"summary": "Walk the dog", "completed": True}]
def test_calendar_render_size_with_fetch_summary():
data = render_calendar(_SAMPLE_EVENTS, "week", browse_offset=0, orientation="landscape",
palette_rgb=None, timezone="UTC", fetch_summary="1 of 2 calendars unavailable",
week_days=5, week_layout="vertical", tasks=tasks)
week_days=5, week_layout="vertical")
assert len(data) == EXPECTED_BYTES
@@ -69,6 +68,25 @@ def test_calendar_render_size_with_week_start_offset():
assert len(data) == EXPECTED_BYTES
# --- tasks widget (split out of the calendar widget's old week-view-only task list) ---
_SAMPLE_TASKS = [
{"summary": "Buy milk", "due": "2026-08-02"},
{"summary": "Walk the dog", "due": None},
]
@pytest.mark.parametrize("orientation", ORIENTATIONS)
def test_tasks_render_size_across_orientations(orientation):
data = render_tasks(_SAMPLE_TASKS, orientation=orientation, palette_rgb=None)
assert len(data) == EXPECTED_BYTES
def test_tasks_render_size_empty():
data = render_tasks([], orientation="landscape", palette_rgb=None)
assert len(data) == EXPECTED_BYTES
# --- render_panel (the widget-system compositor) ---
@@ -66,7 +66,7 @@ def test_config_save_updates_a_calendar_widget(client, db_session):
f"/api/frames/1/widgets/{widget.id}/config",
data={"calendar_view": "week", "calendar_week_start": "1", "calendar_week_days": "5",
"calendar_week_layout": "vertical", "calendar_weather_enabled": "true",
"calendar_weather_units": "celsius", "calendar_tasks_enabled": "true"},
"calendar_weather_units": "celsius"},
headers=csrf_headers(client),
)
assert resp.status_code == 200, resp.text
@@ -78,7 +78,6 @@ def test_config_save_updates_a_calendar_widget(client, db_session):
assert cfg.week_layout == "vertical"
assert cfg.weather_enabled is True
assert cfg.weather_units == "celsius"
assert cfg.tasks_enabled is True
def test_config_save_only_partially_updates_provided_fields(client, db_session):
+1 -19
View File
@@ -25,13 +25,11 @@ def _make_widget(db_session, **cfg_kwargs) -> tuple[Frame, Widget]:
return frame, widget
def _stub_fetches(monkeypatch, events=None, weather=None, tasks=None):
def _stub_fetches(monkeypatch, events=None, weather=None):
monkeypatch.setattr(widgets.calendar, "get_or_refresh_calendar_events_for_widget",
lambda db, frame, widget: (events or [], ""))
monkeypatch.setattr(widgets.calendar, "get_or_refresh_weather_for_widget",
lambda db, frame, widget: weather or [])
monkeypatch.setattr(widgets.calendar, "get_or_refresh_tasks_for_widget",
lambda db, frame, widget: tasks or [])
def test_render_produces_a_correctly_sized_image(db_session, monkeypatch):
@@ -93,27 +91,11 @@ def test_weather_only_fetched_when_enabled(db_session, monkeypatch):
lambda db, frame, widget: ([], ""))
monkeypatch.setattr(widgets.calendar, "get_or_refresh_weather_for_widget",
lambda db, frame, widget: calls.append(1) or [])
monkeypatch.setattr(widgets.calendar, "get_or_refresh_tasks_for_widget",
lambda db, frame, widget: [])
widgets.calendar.render(db_session, frame, widget, 400, 300)
assert calls == []
def test_tasks_only_fetched_for_week_view_when_enabled(db_session, monkeypatch):
frame, widget = _make_widget(db_session, view="agenda", tasks_enabled=True) # not week view
calls = []
monkeypatch.setattr(widgets.calendar, "get_or_refresh_calendar_events_for_widget",
lambda db, frame, widget: ([], ""))
monkeypatch.setattr(widgets.calendar, "get_or_refresh_weather_for_widget",
lambda db, frame, widget: [])
monkeypatch.setattr(widgets.calendar, "get_or_refresh_tasks_for_widget",
lambda db, frame, widget: calls.append(1) or [])
widgets.calendar.render(db_session, frame, widget, 400, 300)
assert calls == [] # agenda view -- tasks never shown, so never fetched
def test_advance_action_increments_browse_offset(db_session):
frame, widget = _make_widget(db_session, view="week", browse_offset=0)
widgets.calendar.ACTIONS["advance"](db_session, frame, widget)
+72
View File
@@ -0,0 +1,72 @@
"""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."""
from __future__ import annotations
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)
widget = Widget(frame_id=frame.id, widget_type="tasks", x=0, y=0, w=2, h=2,
sort_order=0, created_at=time.time())
db_session.add(widget)
db_session.flush()
db_session.add(TaskWidgetConfig(widget_id=widget.id, **cfg_kwargs))
db_session.commit()
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}])
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: [])
img = widgets.tasks.render(db_session, frame, widget, 300, 200)
assert img.size == (300, 200)
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)
monkeypatch.setattr(widgets.tasks, "get_or_refresh_tasks_for_widget", lambda db, frame, widget: [])
img = widgets.tasks.render(db_session, frame, widget, 200, 192)
assert img.size == (200, 192)
def test_no_button_actions():
"""A passive checklist on the same throttled-refresh cadence as
weather -- nothing to advance/back/force."""
assert widgets.tasks.ACTIONS == {}
assert widgets.tasks.ACTION_LABELS == {}