diff --git a/docs/widgets.md b/docs/widgets.md index 6286cbf..5e15aea 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -32,12 +32,17 @@ a button press does. 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. + `CalendarWidgetConfig` (a week-view-only, single-list 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, then (migration + 18) given the same multi-source shape a calendar widget already has. +- `FrameCalendar`/`FrameTaskList` are keyed by `widget_id` (not + `frame_id`) since a frame can now have more than one independent + calendar/tasks widget, each with its own included set. Identical + shape and permission model (owner-added, anyone-linked-can-mute, see + "Per-widget config UI" below) -- `FrameTaskList` just has no `"ics"` + calendar_key variant, since a plain ICS subscription has no VTODO + (task) collection. - `FrameButtonAction` (`id`, `frame_id`, `button` [`"next"`|`"back"`], `widget_id`, `action`, `sort_order`) -- see "Button actions" below. @@ -123,9 +128,10 @@ button behavior -- see `migration.py`'s `_default_button_actions`. ## Per-widget config UI Each widget has a gear-icon button on the Layout canvas that opens a -`` with that widget's own settings (album, calendar inclusion, -whiteboard source, etc.) -- not a per-frame tab, since a frame can now -have several widgets of the same type with independent settings. The +`` with that widget's own settings (album, calendar/task-list +inclusion, whiteboard source, etc.) -- not a per-frame tab, since a +frame can now have several widgets of the same type with independent +settings. The dialog HTML is injected server-rendered (`routers/frame_pages.py`'s `widget_dialog`, dispatching on `widget.widget_type`); its JS is a top-level, always-loaded file (`static/widget_dialog_*.js`) exposing diff --git a/server/app/caldav_client.py b/server/app/caldav_client.py index 5b7020a..811cb10 100644 --- a/server/app/caldav_client.py +++ b/server/app/caldav_client.py @@ -26,6 +26,7 @@ calendar and an ICS subscription behave identically once fetched. from __future__ import annotations import logging +from dataclasses import dataclass from datetime import date, datetime import caldav @@ -120,10 +121,18 @@ def fetch_calendar_events(calendar_url: str, username: str, password: str, return events -def fetch_tasks(calendar_url: str, username: str, password: str) -> list[dict]: - """Outstanding (not-completed) VTODOs from one CalDAV task list, - sorted by due date (tasks with no due date sort last). - {"summary", "due" (ISO date/datetime string, or None)}, ... +def fetch_tasks(calendar_url: str, username: str, password: str, + completed_since: datetime | None = None) -> list[dict]: + """Outstanding VTODOs from one CalDAV task list, plus -- when + completed_since is given -- ones completed at or after that cutoff + (see routers/common.py's get_or_refresh_tasks_for_widget, which + passes "now - 24h" when TaskWidgetConfig.show_completed is on; + None, the default, means completed tasks are dropped entirely, the + original behavior). {"summary", "due" (ISO date/datetime string or + None), "completed_at" (ISO datetime string, or None for an + outstanding task)}, ... . Outstanding tasks sort first (by due date, + no-due-date last), any included completed ones after (most recently + completed first). Fetches every task including completed ones and filters/sorts client-side rather than trusting get_todos()'s own @@ -138,7 +147,8 @@ def fetch_tasks(calendar_url: str, username: str, password: str) -> list[dict]: except Exception as e: raise CalDavError(str(e)) from e - tasks: list[dict] = [] + outstanding: list[dict] = [] + completed: list[dict] = [] for obj in objects: try: ical = icalendar.Calendar.from_ical(obj.data) @@ -147,12 +157,69 @@ def fetch_tasks(calendar_url: str, username: str, password: str) -> list[dict]: continue for component in ical.walk("VTODO"): status = str(component.get("STATUS") or "NEEDS-ACTION").upper() + summary = str(component.get("SUMMARY") or "(untitled)") if status == "COMPLETED": - continue - due = component.get("DUE") - tasks.append({ - "summary": str(component.get("SUMMARY") or "(untitled)"), - "due": due.dt.isoformat() if due is not None else None, + completed_prop = component.get("COMPLETED") + completed_dt = completed_prop.dt if completed_prop is not None else None + if completed_since is None or completed_dt is None or completed_dt < completed_since: + continue + completed.append({"summary": summary, "due": None, "completed_at": completed_dt.isoformat()}) + else: + due = component.get("DUE") + outstanding.append({ + "summary": summary, + "due": due.dt.isoformat() if due is not None else None, + "completed_at": None, + }) + outstanding.sort(key=lambda t: (t["due"] is None, t["due"] or "")) + completed.sort(key=lambda t: t["completed_at"], reverse=True) + return outstanding + completed + + +@dataclass +class TaskSource: + """One task list to merge in -- CalDAV only, no ICS variant (a plain + ICS subscription has no VTODO collection to speak of). + owner_display_name tags every task pulled from this source so a + merged checklist can show whose task is whose; color_index (2-5, + into image_pipeline.DEFAULT_PALETTE_RGB) is this list's manually + pinned color, or None for calendar_render.py's auto-cycle-by-owner- + name fallback -- see models.FrameTaskList.""" + + owner_display_name: str + url: str + username: str + password: str + color_index: int | None = None + + +def merge_tasks(sources: list[TaskSource], completed_since: datetime | None = None) -> tuple[list[dict], str]: + """Fetches each source independently -- one broken list never blanks + another's tasks. Returns (merged_tasks, fetch_summary); fetch_summary + is "" when every source succeeded, else "N of M task lists + unavailable" (same no-naming-names posture as calendar_feed. + merge_events). No cross-list duplicate collapsing (unlike + merge_events) -- a task synced to two lists at once is rare enough, + and lower-stakes than a duplicated calendar event, not to be worth + the same de-dup machinery.""" + merged: list[dict] = [] + failures = 0 + for source in sources: + try: + tasks = fetch_tasks(source.url, source.username, source.password, completed_since=completed_since) + except CalDavError: + failures += 1 + continue + for task in tasks: + merged.append({ + **task, + "owner_display_name": source.owner_display_name, + "color_index": source.color_index, }) - tasks.sort(key=lambda t: (t["due"] is None, t["due"] or "")) - return tasks + + outstanding = [t for t in merged if t["completed_at"] is None] + completed = [t for t in merged if t["completed_at"] is not None] + outstanding.sort(key=lambda t: (t["due"] is None, t["due"] or "")) + completed.sort(key=lambda t: t["completed_at"], reverse=True) + summary = f"{failures} of {len(sources)} task lists unavailable" if failures else "" + return outstanding + completed, summary diff --git a/server/app/calendar_render.py b/server/app/calendar_render.py index ff794f3..8bef930 100644 --- a/server/app/calendar_render.py +++ b/server/app/calendar_render.py @@ -272,7 +272,7 @@ def _split_emoji_runs(text: str) -> list[tuple[str, bool]]: def _draw_mixed_line(img: Image.Image, draw: ImageDraw.ImageDraw, xy: tuple[int, int], text: str, - text_font: ImageFont.ImageFont, max_width: int) -> None: + text_font: ImageFont.ImageFont, max_width: int, fill: tuple[int, int, int] = FG) -> None: """Draws `text` left-to-right, switching between text_font (normal characters) and an emoji glyph image (actual emoji runs, per _split_emoji_runs/_emoji_glyph) so emoji visibly render instead of a @@ -281,8 +281,9 @@ def _draw_mixed_line(img: Image.Image, draw: ImageDraw.ImageDraw, xy: tuple[int, across mixed fonts/images, so it works run-by-run instead (and can't partially truncate an emoji run the way it can a text run -- one that doesn't fit just isn't drawn). Fine for the short single-line - strings this draws (event titles), not meant as a general rich-text - layout engine.""" + strings this draws (event/task titles), not meant as a general + rich-text layout engine. `fill` only affects text runs -- emoji + glyphs are already their own color.""" x, y = xy cursor = x # A little taller than text_font's own size so glyphs don't look @@ -304,10 +305,11 @@ def _draw_mixed_line(img: Image.Image, draw: ImageDraw.ImageDraw, xy: tuple[int, else: run_w = draw.textlength(run_text, font=text_font) if run_w <= remaining: - draw_text(img, (round(cursor), y), run_text, text_font) + draw_text(img, (round(cursor), y), run_text, text_font, fill) cursor += run_w else: - draw_text(img, (round(cursor), y), _truncate_to_width(draw, run_text, text_font, remaining), text_font) + draw_text(img, (round(cursor), y), _truncate_to_width(draw, run_text, text_font, remaining), + text_font, fill) break @@ -527,14 +529,25 @@ 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) -> 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 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.""" + tasks: list[dict], title_font: ImageFont.ImageFont, body_font: ImageFont.ImageFont, + palette_rgb: list | None = None) -> None: + """A simple checklist filling `region` (x0, y0, w, h) -- a color bar + (reusing _event_colors/_draw_color_bar as-is: a task dict's + top-level owner_display_name/color_index is exactly _event_colors' + single-source fallback shape, since caldav_client.merge_tasks + doesn't cross-list-dedup tasks into a "sources" list the way + merge_events dedups events) + checkbox glyph + due date (if any) + + summary per task, same header/rule/row-cap/truncation shape as + _draw_agenda_day's event 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. + + Outstanding tasks get an empty checkbox; completed ones (only ever + present when TaskWidgetConfig.show_completed is on -- see + caldav_client.fetch_tasks' completed_since) get a filled one and + muted text, no due-date prefix (irrelevant once done).""" x0, y0, w, h = region text_x0, text_y0 = x0 + MARGIN, y0 + MARGIN text_w = w - MARGIN * 2 @@ -549,21 +562,29 @@ def _draw_tasks(img: Image.Image, draw: ImageDraw.ImageDraw, region: tuple[int, if not tasks: draw_text(img, (text_x0, y), "Nothing outstanding", body_font, MUTED) return + owners_seen: list[str] = [] for i, task in enumerate(tasks): if i >= max_rows: draw_text(img, (text_x0, y), f"+{len(tasks) - max_rows} more", body_font, MUTED) break + done = task.get("completed_at") is not None + colors = _event_colors(task, owners_seen, palette_rgb) + _draw_color_bar(draw, text_x0, y + 2, text_x0 + 10, y + row_h - 7, colors, radius=3) box = body_font.size - 6 + box_x = text_x0 + 18 box_y = y + (row_h - box) // 2 - 5 - draw.rectangle([text_x0, box_y, text_x0 + box, box_y + box], outline=FG, width=2) - text_x = text_x0 + box + 10 - due_str = _fmt_task_due(task.get("due")) + if done: + draw.rectangle([box_x, box_y, box_x + box, box_y + box], fill=FG) + else: + draw.rectangle([box_x, box_y, box_x + box, box_y + box], outline=FG, width=2) + text_x = box_x + box + 10 + due_str = None if done else _fmt_task_due(task.get("due")) prefix = f"{due_str} " if due_str else "" if prefix: draw_text(img, (text_x, y), prefix, body_font, MUTED) prefix_w = draw.textlength(prefix, font=body_font) if prefix else 0 _draw_mixed_line(img, draw, (round(text_x + prefix_w), y), task["summary"], - body_font, text_w - box - 10 - prefix_w) + body_font, text_w - (text_x - text_x0) - prefix_w, fill=MUTED if done else FG) y += row_h @@ -873,7 +894,7 @@ def render_calendar_preview_png(events: list[dict], view: str, browse_offset: in _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: +def _build_tasks(tasks: list[dict], target_w: int, target_h: int, palette_rgb: list | None = None) -> 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.""" @@ -882,7 +903,7 @@ def _build_tasks(tasks: list[dict], target_w: int, target_h: int) -> Image.Image 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) + _draw_tasks(img, draw, (0, 0, target_w, target_h), tasks, title_font, body_font, palette_rgb) return img @@ -892,7 +913,7 @@ def render_tasks(tasks: list[dict], orientation: str, palette_rgb: list | None, 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 = _build_tasks(tasks, target_w, target_h, palette_rgb) img = _apply_manage_overlay(img, manage) quantized = _quantize(img, palette_rgb, dither_strength=1.0) return _transpose_and_pack(quantized, orientation) @@ -904,7 +925,7 @@ def render_tasks_preview_png(tasks: list[dict], orientation: str, palette_rgb: l 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 = _build_tasks(tasks, target_w, target_h, palette_rgb) img = _apply_manage_overlay(img, manage) quantized = _quantize(img, palette_rgb, dither_strength=1.0) buf = io.BytesIO() diff --git a/server/app/migration.py b/server/app/migration.py index 905950a..a2999e8 100644 --- a/server/app/migration.py +++ b/server/app/migration.py @@ -25,6 +25,7 @@ from .models import ( CalendarWidgetConfig, Frame, FrameButtonAction, + FrameTaskList, PhotoWidgetConfig, ServerSettings, TaskWidgetConfig, @@ -438,6 +439,55 @@ def _migration_17(conn) -> None: conn.execute(text("ALTER TABLE calendar_widget_configs_new RENAME TO calendar_widget_configs")) +def _migration_18(conn) -> None: + """A tasks widget can now merge more than one person's CalDAV task + list, checkbox-included with an optional pinned color each -- same + multi-source shape calendar widgets already have (models. + FrameCalendar), rather than the single user_id/calendar_key pair + migration 17 gave TaskWidgetConfig when tasks first became their own + widget type. Also adds show_completed (see caldav_client. + fetch_tasks' completed_since -- off by default, so this migration + changes no widget's on-panel appearance by itself). + + Each task_widget_configs row's existing single source, if any, + carries forward as that widget's first frame_task_lists row + (included) before the now-dead user_id/calendar_key columns are + dropped -- same "carry forward the old single opt-in as a row before + dropping the column" shape _migration_9 used for frame_calendars.""" + conn.execute(text( + "CREATE TABLE frame_task_lists (" + "id INTEGER PRIMARY KEY, " + "widget_id INTEGER NOT NULL REFERENCES widgets(id) ON DELETE CASCADE, " + "user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, " + "calendar_key TEXT NOT NULL, " + "calendar_label TEXT NOT NULL DEFAULT '', " + "included INTEGER NOT NULL DEFAULT 1, " + "color_index INTEGER)" + )) + conn.execute(text( + "CREATE UNIQUE INDEX ix_frame_task_lists_unique ON frame_task_lists (widget_id, user_id, calendar_key)" + )) + conn.execute(text( + "INSERT INTO frame_task_lists (widget_id, user_id, calendar_key, included) " + "SELECT widget_id, user_id, calendar_key, 1 FROM task_widget_configs " + "WHERE calendar_key IS NOT NULL AND user_id IS NOT NULL" + )) + + conn.execute(text( + "CREATE TABLE task_widget_configs_new (" + "widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, " + "checked_at REAL NOT NULL DEFAULT 0.0, " + "cached TEXT, " + "show_completed INTEGER NOT NULL DEFAULT 0)" + )) + conn.execute(text( + "INSERT INTO task_widget_configs_new (widget_id, checked_at, cached) " + "SELECT widget_id, checked_at, cached FROM task_widget_configs" + )) + conn.execute(text("DROP TABLE task_widget_configs")) + conn.execute(text("ALTER TABLE task_widget_configs_new RENAME TO task_widget_configs")) + + MIGRATIONS = [ (1, _migration_1), (2, _migration_2), @@ -456,6 +506,7 @@ MIGRATIONS = [ (15, _migration_15), (16, _migration_16), (17, _migration_17), + (18, _migration_18), ] @@ -624,27 +675,36 @@ 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_* deliberately not carried over -- see _task_config_from_frame, - # a sibling standalone widget now, not part of this config. + # tasks_* deliberately not carried over -- see + # _task_config_and_list_from_frame, a sibling standalone widget + # now, not part of this config. ) -def _task_config_from_frame(frame: Frame, widget_id: int) -> TaskWidgetConfig: +def _task_config_and_list_from_frame(frame: Frame, widget_id: int) -> tuple[TaskWidgetConfig, FrameTaskList]: """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( + the widget system existed to after tasks became their own + multi-list widget type in a single upgrade, skipping both + intermediate periods where it would have lived on + CalendarWidgetConfig (_migration_17's extraction) and then a + single-source TaskWidgetConfig (_migration_18's extraction) instead. + Reproduces the same shape those two migrations arrive at directly: + a bare cache-state config plus one included FrameTaskList row.""" + cfg = 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, ) + task_list = FrameTaskList( + widget_id=widget_id, + user_id=frame.calendar_tasks_user_id, + calendar_key=frame.calendar_tasks_calendar_key, + included=True, + ) + return cfg, task_list def _whiteboard_config_from_frame(frame: Frame, widget_id: int) -> WhiteboardWidgetConfig: @@ -679,14 +739,16 @@ 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: + widget system existed to after tasks became their own widget type + in one upgrade (see _task_config_and_list_from_frame) -- + frame.calendar_tasks_* is the dead legacy field set otherwise. + Requires both calendar_key and user_id (FrameTaskList.user_id is + NOT NULL) -- same guard _migration_18's own SQL extraction uses. + 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 or not frame.calendar_tasks_user_id: return min_w, min_h = grid.MIN_FOOTPRINT["tasks"] rect = grid.find_open_rect(frame.orientation, existing, min_w, min_h) @@ -701,7 +763,9 @@ def _maybe_add_legacy_tasks_widget(db, frame: Frame, existing: list[grid.Rect], 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)) + cfg, task_list = _task_config_and_list_from_frame(frame, task_widget.id) + db.add(cfg) + db.add(task_list) def _backfill_frame_widgets(db, frame: Frame) -> None: diff --git a/server/app/models.py b/server/app/models.py index 36e0612..74bbf10 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -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:" (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): diff --git a/server/app/routers/api_widgets.py b/server/app/routers/api_widgets.py index f99d868..12a08c9 100644 --- a/server/app/routers/api_widgets.py +++ b/server/app/routers/api_widgets.py @@ -2,7 +2,7 @@ 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 -source, whiteboard source, and their preview endpoints. Split out of +inclusion/color, 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" @@ -34,6 +34,7 @@ from ..models import ( CalendarWidgetConfig, Frame, FrameCalendar, + FrameTaskList, PhotoWidgetConfig, TaskWidgetConfig, WhiteboardWidgetConfig, @@ -51,6 +52,7 @@ from .common import ( immich_client_for, immich_creds, list_assets, + task_sources_for_widget, valid_http_url, webdav_creds_for, ) @@ -255,6 +257,8 @@ 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), + # tasks + tasks_show_completed: 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,6 +321,11 @@ def api_widget_config_save( # new unit label. ccfg.weather_checked_at = 0.0 ccfg.weather_units = calendar_weather_units + elif widget.widget_type == "tasks": + with widget_locked(db, frame.id, widget.id) as (_, _, tcfg): + if tasks_show_completed is not None and tasks_show_completed != tcfg.show_completed: + tcfg.show_completed = tasks_show_completed + tcfg.checked_at = 0.0 # pick up the change promptly with frame_locked(db, frame.id) as cfg: cfg.stats_config_saves += 1 return {"status": "saved"} @@ -618,58 +627,107 @@ def api_widget_preview_calendar( return Response(content=png, media_type="image/png") -# --- Tasks: source/preview ------------------------------------------------ +# --- Tasks: inclusion/color/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.""" + """The same cached merged 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") + if not task_sources_for_widget(db, widget): + raise HTTPException(400, "No task lists included 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 +class TaskListSelectRequest(BaseModel): + user_id: int + calendar_key: str + calendar_label: str = "" + included: bool -@router.post("/api/frames/{frame_id}/widgets/{widget_id}/tasks-source") -def api_widget_tasks_source( - body: TasksSourceRequest, request: Request, - frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), +@router.post("/api/frames/{frame_id}/widgets/{widget_id}/task-list-select") +def api_widget_task_list_select( + body: TaskListSelectRequest, request: Request, + frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), # view access only -- NOT control db: Session = Depends(get_db), ): - """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.""" + """Include/exclude one CalDAV task list (calendar_key "caldav:", + see FrameTaskList) on this tasks widget -- same one-sided permission + split as api_widget_calendar_select: turning a list ON requires being + its owner (nobody can add someone else's task list to a shared frame + for them), turning one OFF only requires being linked to the frame at + all, so anyone sharing the display can mute a list they'd rather not + see there even if they don't own it. Deliberately not + require_widget_control for the same reason.""" frame, widget = frame_widget _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.user_id = None - cfg.calendar_key = None - cfg.cached = None - else: - 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} + if body.included and body.user_id != user.id: + raise HTTPException(403, "Only a task list's owner can add it to a frame") + row = db.execute( + select(FrameTaskList).where( + FrameTaskList.widget_id == widget.id, + FrameTaskList.user_id == body.user_id, + FrameTaskList.calendar_key == body.calendar_key, + ) + ).scalar_one_or_none() + if row is None: + if not body.included: + raise HTTPException(404, "Not currently included on this widget") + row = FrameTaskList(widget_id=widget.id, user_id=body.user_id, calendar_key=body.calendar_key) + db.add(row) + row.included = body.included + if body.calendar_label: + row.calendar_label = body.calendar_label + # Force this widget's merged cache to pick up the change promptly + # rather than waiting out the throttle. + db.get(TaskWidgetConfig, widget.id).checked_at = 0.0 + db.commit() + return {"status": "saved", "included": row.included} + + +class TaskListColorRequest(BaseModel): + calendar_key: str + color_index: int | None # None clears the pin, reverting to auto-cycle + + +@router.post("/api/frames/{frame_id}/widgets/{widget_id}/task-list-color") +def api_widget_task_list_color( + body: TaskListColorRequest, request: Request, + frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), + db: Session = Depends(get_db), +): + """Pins a specific panel color to one of your own included task + lists (models.FrameTaskList.color_index) -- always owner-only, same + as api_widget_calendar_color. None clears the pin, reverting + calendar_render.py to its auto-cycle-by-owner-name behavior for this + list.""" + frame, widget = frame_widget + _require_widget_type(widget, "tasks") + user = require_user_api(request, db) + if body.color_index is not None and body.color_index not in CALENDAR_COLOR_INDEX_RANGE: + raise HTTPException(400, "color_index must be 2-5 (the panel's non-black/white colors)") + row = db.execute( + select(FrameTaskList).where( + FrameTaskList.widget_id == widget.id, + FrameTaskList.user_id == user.id, + FrameTaskList.calendar_key == body.calendar_key, + ) + ).scalar_one_or_none() + if row is None: + raise HTTPException(404, "Not included on this widget") + row.color_index = body.color_index + db.get(TaskWidgetConfig, widget.id).checked_at = 0.0 + db.commit() + return {"status": "saved", "color_index": row.color_index} class WeatherCityAddRequest(BaseModel): @@ -736,9 +794,9 @@ def api_widget_whiteboard_source( """Points this widget at one of the calling user's own WebDAV (or reused-CalDAV, see User.webdav_reuse_caldav_creds) credentials -- same owner-controls-their-own-data permission split as - api_widget_tasks_source: only the account owner can set the widget to - use it, but anyone linked to the frame can clear it, same as muting a - shared calendar.""" + api_widget_task_list_select: only the account owner can set the + widget to use it, but anyone linked to the frame can clear it, same + as muting a shared calendar.""" frame, widget = frame_widget _require_widget_type(widget, "whiteboard") user = require_user_api(request, db) diff --git a/server/app/routers/common.py b/server/app/routers/common.py index ce06e29..54ad68a 100644 --- a/server/app/routers/common.py +++ b/server/app/routers/common.py @@ -7,7 +7,7 @@ import logging import os import statistics import time -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from urllib.parse import urlparse import httpx @@ -26,6 +26,7 @@ from ..models import ( Frame, FrameButtonAction, FrameCalendar, + FrameTaskList, PhotoWidgetConfig, TaskWidgetConfig, User, @@ -565,31 +566,54 @@ def get_or_refresh_weather_for_widget(db: Session, frame: Frame, widget: Widget) return result +TASKS_COMPLETED_WINDOW_HOURS = 24 # how far back TaskWidgetConfig.show_completed looks + + +def task_sources_for_widget(db: Session, widget: Widget) -> list[caldav_client.TaskSource]: + """Every task list included on this tasks widget (FrameTaskList. + included) -- the exact set caldav_client.merge_tasks needs. CalDAV + only (calendar_key is always "caldav:" -- no "ics" variant, a + plain ICS subscription has no VTODO collection), resolved against + the owning user's CalDAV account credentials.""" + rows = db.execute( + select(FrameTaskList, User) + .join(User, User.id == FrameTaskList.user_id) + .where(FrameTaskList.widget_id == widget.id, FrameTaskList.included == True) # noqa: E712 + ).all() + sources = [] + for ftl, u in rows: + if not ftl.calendar_key.startswith("caldav:") or not u.calendar_caldav_username: + continue + href = ftl.calendar_key[len("caldav:"):] + sources.append(caldav_client.TaskSource( + u.display_name or u.username, href, u.calendar_caldav_username, u.calendar_caldav_password, + color_index=ftl.color_index, + )) + return sources + + 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 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.""" + """Throttled multi-list merge-fetch cache (calendar_feed. + CHECK_INTERVAL_S, same cadence as event merging), reading/writing + TaskWidgetConfig (see app/widgets/tasks.py). [] if no list is + included yet. Same posture as get_or_refresh_calendar_events_for_ + widget (which this otherwise mirrors closely), not weather's own + per-city stale-cache fallback: a broken list just contributes + nothing to this cycle's merge (logged in fetch_summary) rather than + silently keeping its last-known tasks around.""" cfg = db.get(TaskWidgetConfig, widget.id) - if not cfg.calendar_key or not cfg.user_id: - return [] now = time.time() if cfg.cached is not None and now - cfg.checked_at < calendar_feed.CHECK_INTERVAL_S: return cfg.cached - 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.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.cached or [] + sources = task_sources_for_widget(db, widget) + if not sources: + return [] + completed_since = datetime.now(timezone.utc) - timedelta(hours=TASKS_COMPLETED_WINDOW_HOURS) \ + if cfg.show_completed else None + tasks, summary = caldav_client.merge_tasks(sources, completed_since=completed_since) + if summary: + logger.warning("Could not refresh tasks for widget %d: %s", widget.id, summary) with widget_locked(db, frame.id, widget.id) as (_, _, locked_cfg): locked_cfg.cached = tasks diff --git a/server/app/routers/frame_pages.py b/server/app/routers/frame_pages.py index 0fdac88..94acefe 100644 --- a/server/app/routers/frame_pages.py +++ b/server/app/routers/frame_pages.py @@ -30,6 +30,7 @@ from ..models import ( CalendarWidgetConfig, Frame, FrameCalendar, + FrameTaskList, PhotoWidgetConfig, TaskWidgetConfig, User, @@ -137,22 +138,41 @@ def _calendar_users_for_widget(db: Session, frame_id: int, widget_id: int, viewe return result -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 '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, task_cfg.user_id) - if user is None: - return None - label = task_cfg.calendar_key - for c in (user.calendar_caldav_calendars or []): - 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, - "calendar_key": task_cfg.calendar_key} +def _task_users_for_widget(db: Session, frame_id: int, widget_id: int, viewer_id: int | None) -> list[dict]: + """Per-linked-user task-list list for the tasks dialog's "Included + task lists" section -- same shape as _calendar_users_for_widget, + restricted to CalDAV calendars only (no "ics" option: a plain ICS + subscription has no VTODO collection to speak of, see + caldav_client.fetch_tasks).""" + users = db.execute( + select(User).join(UserFrame, UserFrame.user_id == User.id) + .where(UserFrame.frame_id == frame_id).order_by(User.username) + ).scalars().all() + included_by_user: dict[int, list[FrameTaskList]] = {} + for ftl in db.execute(select(FrameTaskList).where(FrameTaskList.widget_id == widget_id)).scalars().all(): + included_by_user.setdefault(ftl.user_id, []).append(ftl) + + result = [] + for u in users: + is_self = u.id == viewer_id + available = [c for c in _user_available_calendars(u) if c["key"].startswith("caldav:")] + if is_self: + own_rows = {ftl.calendar_key: ftl for ftl in included_by_user.get(u.id, [])} + task_lists = [ + {**c, "included": own_rows[c["key"]].included if c["key"] in own_rows else False, + "color_index": own_rows[c["key"]].color_index if c["key"] in own_rows else None} + for c in available + ] + else: + task_lists = [ + {"key": ftl.calendar_key, "label": ftl.calendar_label, "included": True} + for ftl in included_by_user.get(u.id, []) if ftl.included + ] + result.append({ + "user_id": u.id, "display_name": u.display_name or u.username, + "is_self": is_self, "task_lists": task_lists, + }) + return result def _whiteboard_source_info(db: Session, whiteboard_cfg: WhiteboardWidgetConfig) -> dict | None: @@ -208,11 +228,12 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session = 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, task_cfg), + "request": request, "frame": frame, "widget": widget, "task_cfg": task_cfg, "user": user, + "task_users": _task_users_for_widget(db, frame.id, widget.id, user.id), + "task_color_labels": PALETTE_LABELS, + "default_palette_rgb": DEFAULT_PALETTE_RGB, + "palette_to_hex": palette_to_hex, }) if widget.widget_type == "whiteboard": diff --git a/server/app/static/widget_dialog_tasks.js b/server/app/static/widget_dialog_tasks.js index f957bd3..4d6ce36 100644 --- a/server/app/static/widget_dialog_tasks.js +++ b/server/app/static/widget_dialog_tasks.js @@ -1,86 +1,89 @@ -// 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 +// Tasks widget dialog: per-user included-task-list checkboxes + color +// pins (same shape as the calendar widget's "Included calendars"), the +// recently-completed toggle, and the rendered preview. Not a page-load // script -- frame_layout.js fetches this widget's dialog HTML fragment, // injects it into the shared , 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 = '

No task list configured yet.

'; - 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) => { + // Each task list's own include/mute toggle -- auto-saves on change, + // not batched into the form below, since it's a data-sharing choice + // (see api_widget_task_list_select), not a widget-wide setting. Works + // the same element for your own lists (full add/remove) and other + // people's (mute only) -- the server enforces which direction is + // allowed and this just reverts the checkbox with an error message if + // rejected. + document.querySelectorAll('.task-list-toggle').forEach((el) => { el.addEventListener('change', async () => { try { - const resp = await fetch(`${window.FRAME_API}/tasks-source`, { + const resp = await fetch(`${window.FRAME_API}/task-list-select`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ calendar_key: el.dataset.key }), + body: JSON.stringify({ + user_id: Number(el.dataset.userId), + calendar_key: el.dataset.key, + calendar_label: el.dataset.label, + included: el.checked, + }), }); 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() : ''); + showStatus(true, el.checked ? 'Task list included on this widget.' : 'Task list removed from this widget.'); loadTasksPreview(); } catch (e) { + el.checked = !el.checked; showStatus(false, e.message); } }); }); - const tasksSourceClear = document.getElementById('tasks-source-clear'); - if (tasksSourceClear) { - tasksSourceClear.addEventListener('click', clearTasksSource); - } + // Per-task-list color pin -- owner-only (the server enforces it; + // these buttons only ever render for the viewer's own lists anyway). + document.querySelectorAll('.task-list-color-picker').forEach((picker) => { + const key = picker.dataset.key; + picker.querySelectorAll('.color-swatch').forEach((btn) => { + btn.addEventListener('click', async () => { + const colorIndex = btn.dataset.index === '' ? null : Number(btn.dataset.index); + try { + const resp = await fetch(`${window.FRAME_API}/task-list-color`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ calendar_key: key, color_index: colorIndex }), + }); + if (!resp.ok) throw new Error(await apiError(resp)); + picker.querySelectorAll('.color-swatch').forEach((el) => el.classList.remove('selected')); + btn.classList.add('selected'); + showStatus(true, 'Color saved.'); + loadTasksPreview(); + } catch (e) { + showStatus(false, e.message); + } + }); + }); + }); + + document.getElementById('tasks-config-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const body = new URLSearchParams({ + tasks_show_completed: String(document.getElementById('tasks_show_completed').checked), + }); + try { + const resp = await fetch(`${window.FRAME_API}/config`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }); + if (!resp.ok) throw new Error(await apiError(resp)); + showStatus(true, 'Saved.'); + loadTasksPreview(); + } catch (e) { + showStatus(false, e.message); + } + }); document.getElementById('tasks-preview-refresh').addEventListener('click', loadTasksPreview); loadTasksPreview(); diff --git a/server/app/templates/_widget_dialog_tasks.html b/server/app/templates/_widget_dialog_tasks.html index 41fe5a6..bbc72a6 100644 --- a/server/app/templates/_widget_dialog_tasks.html +++ b/server/app/templates/_widget_dialog_tasks.html @@ -1,39 +1,58 @@

Tasks widget

-

Task list source

-

A simple outstanding-task checklist. CalDAV only -- a - task list is a VTODO collection, which a plain ICS subscription - doesn't carry.

+

Included task lists

+

Each linked person adds their own CalDAV task lists (set + up in Settings -- a plain ICS subscription + has no task list) -- being linked here doesn't include anything + automatically. Anyone linked to this frame can mute a list they'd + rather not see here, even one they don't own; only its owner can add + it back.

+
    + {% for u in task_users %} +
  • +

    {{ u.display_name }}{% if u.is_self %} (you){% endif %}

    + {% if u.task_lists %} + {% set current_palette = frame.palette_rgb or default_palette_rgb %} + {% set current_hex = palette_to_hex(current_palette) %} + {% for c in u.task_lists %} +
    + + + {% if u.is_self %} + + {% for idx in range(2, 6) %} + + {% endfor %} + + + {% endif %} +
    + {% endfor %} + {% elif u.is_self %} +

    No CalDAV task lists set up yet -- add a CalDAV account in Settings.

    + {% else %} +

    No task lists included.

    + {% endif %} +
  • + {% endfor %} +
+
-
- {% if tasks_source %} -

- Currently using {{ tasks_source.display_name }}'s - {{ tasks_source.label }} list. - -

- {% else %} -

No task list configured yet.

- {% endif %} -
- - {% if viewer_task_calendars %} -

Use one of your own CalDAV task lists:

-
    - {% for c in viewer_task_calendars %} -
  • - - -
  • - {% endfor %} -
- {% else %} -

You don't have any CalDAV - task lists available -- set up a CalDAV account in - Settings first.

- {% endif %} +
+

Recently completed

+
+
+ + +
+ +
diff --git a/server/app/widgets/tasks.py b/server/app/widgets/tasks.py index f208cf1..53de251 100644 --- a/server/app/widgets/tasks.py +++ b/server/app/widgets/tasks.py @@ -1,12 +1,21 @@ -"""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. +"""Tasks widget: a simple outstanding-task checklist merged from one or +more of its linked users' CalDAV task lists, in its own region -- split +out of the calendar widget's old week-view-only, single-list task list +(see models.TaskWidgetConfig) so a task list can be placed and sized on +its own, independent of any calendar's view/footprint, and can merge +more than one person's list the same way a calendar widget merges more +than one person's calendar (see models.FrameTaskList). 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.""" +here to advance/back/force. No placeholder for "nothing included yet" +either -- same posture as app/widgets/calendar.py, which this otherwise +mirrors closely: render() always draws through get_or_refresh_tasks_ +for_widget's result even when it's [], showing "Nothing outstanding" +rather than a distinct not-configured state (the dialog's preview +endpoint is what actually 400s for that case, same asymmetry calendar +already has).""" from __future__ import annotations @@ -14,9 +23,8 @@ from PIL import Image from sqlalchemy.orm import Session from ..calendar_render import _build_tasks -from ..models import Frame, TaskWidgetConfig, Widget +from ..models import Frame, Widget from ..routers.common import get_or_refresh_tasks_for_widget -from ._shared import placeholder_image ACTION_LABELS: dict[str, str] = {} @@ -26,11 +34,8 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i """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) + return _build_tasks(tasks, target_w, target_h, frame.palette_rgb) ACTIONS: dict = {} diff --git a/server/tests/test_caldav_client.py b/server/tests/test_caldav_client.py new file mode 100644 index 0000000..a42eede --- /dev/null +++ b/server/tests/test_caldav_client.py @@ -0,0 +1,95 @@ +"""caldav_client.merge_tasks -- pure-function coverage (sort, per-source +color/owner tagging, partial-failure handling), monkeypatching +fetch_tasks itself rather than the caldav package's DAVClient/Calendar +-- this project has no CalDAV test server fixture (fetch_calendar_events' +own caldav branch is likewise only exercised this way, never against a +real server -- see test_calendar_feed.py, which only covers the ICS +path). fetch_tasks' own VTODO-parsing internals are trusted to the +icalendar library, same posture calendar_feed.py already takes for +VEVENT parsing.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from app.caldav_client import CalDavError, TaskSource, merge_tasks + + +def test_single_source_tasks_pass_through_tagged(monkeypatch): + monkeypatch.setattr( + "app.caldav_client.fetch_tasks", + lambda url, username, password, completed_since=None: [ + {"summary": "Buy milk", "due": "2026-08-01", "completed_at": None}, + ], + ) + sources = [TaskSource("Alice", "https://example.com/tasks", "alice", "pw", color_index=3)] + tasks, summary = merge_tasks(sources) + assert summary == "" + assert tasks == [ + {"summary": "Buy milk", "due": "2026-08-01", "completed_at": None, + "owner_display_name": "Alice", "color_index": 3}, + ] + + +def test_outstanding_tasks_sort_by_due_date_none_last(monkeypatch): + def fake_fetch(url, username, password, completed_since=None): + by_url = { + "a": [{"summary": "No due date", "due": None, "completed_at": None}], + "b": [{"summary": "Due soonest", "due": "2026-08-01", "completed_at": None}], + } + return by_url[url] + + monkeypatch.setattr("app.caldav_client.fetch_tasks", fake_fetch) + sources = [TaskSource("Alice", "a", "alice", "pw"), TaskSource("Bob", "b", "bob", "pw")] + tasks, _ = merge_tasks(sources) + assert [t["summary"] for t in tasks] == ["Due soonest", "No due date"] + + +def test_completed_tasks_sort_after_outstanding_most_recent_first(monkeypatch): + def fake_fetch(url, username, password, completed_since=None): + return [ + {"summary": "Outstanding", "due": "2026-08-05", "completed_at": None}, + {"summary": "Done yesterday", "due": None, "completed_at": "2026-08-01T09:00:00+00:00"}, + {"summary": "Done today", "due": None, "completed_at": "2026-08-02T09:00:00+00:00"}, + ] + + monkeypatch.setattr("app.caldav_client.fetch_tasks", fake_fetch) + tasks, _ = merge_tasks([TaskSource("Alice", "a", "alice", "pw")]) + assert [t["summary"] for t in tasks] == ["Outstanding", "Done today", "Done yesterday"] + + +def test_one_broken_source_does_not_blank_others(monkeypatch): + def fake_fetch(url, username, password, completed_since=None): + if url == "broken": + raise CalDavError("nope") + return [{"summary": "Buy milk", "due": None, "completed_at": None}] + + monkeypatch.setattr("app.caldav_client.fetch_tasks", fake_fetch) + sources = [TaskSource("Alice", "ok", "alice", "pw"), TaskSource("Bob", "broken", "bob", "pw")] + tasks, summary = merge_tasks(sources) + assert len(tasks) == 1 + assert summary == "1 of 2 task lists unavailable" + + +def test_all_sources_unreachable(monkeypatch): + monkeypatch.setattr( + "app.caldav_client.fetch_tasks", + lambda url, username, password, completed_since=None: (_ for _ in ()).throw(CalDavError("nope")), + ) + sources = [TaskSource("Alice", "a", "alice", "pw"), TaskSource("Bob", "b", "bob", "pw")] + tasks, summary = merge_tasks(sources) + assert tasks == [] + assert summary == "2 of 2 task lists unavailable" + + +def test_completed_since_is_forwarded_to_fetch_tasks(monkeypatch): + seen = [] + + def fake_fetch(url, username, password, completed_since=None): + seen.append(completed_since) + return [] + + monkeypatch.setattr("app.caldav_client.fetch_tasks", fake_fetch) + cutoff = datetime(2026, 8, 1, tzinfo=timezone.utc) + merge_tasks([TaskSource("Alice", "a", "alice", "pw")], completed_since=cutoff) + assert seen == [cutoff] diff --git a/server/tests/test_migrations.py b/server/tests/test_migrations.py index c7ac8bc..2f7bff0 100644 --- a/server/tests/test_migrations.py +++ b/server/tests/test_migrations.py @@ -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")) diff --git a/server/tests/test_permission_boundaries.py b/server/tests/test_permission_boundaries.py index aa7cafb..dc9884d 100644 --- a/server/tests/test_permission_boundaries.py +++ b/server/tests/test_permission_boundaries.py @@ -1,8 +1,9 @@ """The "owner controls adding their own data; anyone linked can mute it" -permission pattern, repeated across calendar-select, tasks-source, and -whiteboard-source -- exercised at the HTTP layer (not just unit-level) +permission pattern, repeated across calendar-select, task-list-select, +and whiteboard-source (plus the owner-only, no-mute-split calendar-color/ +task-list-color) -- exercised at the HTTP layer (not just unit-level) since the whole point is verifying the *endpoint's* authorization check, -not just a helper function's logic. All three now live under +not just a helper function's logic. All of these live under /api/frames/{id}/widgets/{widget_id}/... (see routers/api_widgets.py).""" from __future__ import annotations @@ -13,6 +14,7 @@ from app.models import ( CalendarWidgetConfig, Frame, FrameCalendar, + FrameTaskList, TaskWidgetConfig, User, Widget, @@ -165,53 +167,139 @@ def test_whiteboard_source_400s_when_widget_is_not_a_whiteboard(client, db_sessi assert resp.status_code == 400 -# --- tasks-source --- +# --- task-list-select --- -def test_tasks_source_set_always_targets_the_caller(client, db_session): +def test_task_list_select_bob_cannot_add_alices_list(client, db_session): frame = _setup_two_linked_users(client, db_session) widget = _add_tasks_widget(db_session, frame) - client.cookies.clear() - login(client, "bob") - - resp = client.post(f"/api/frames/1/widgets/{widget.id}/tasks-source", - json={"calendar_key": "caldav:/some/tasks/"}, headers=csrf_headers(client)) - assert resp.status_code == 200, resp.text - - bob_row = db_session.query(User).filter_by(username="bob").one() - cfg = db_session.get(TaskWidgetConfig, widget.id) - assert cfg.user_id == bob_row.id - assert cfg.calendar_key == "caldav:/some/tasks/" - - -def test_tasks_source_anyone_linked_can_clear(client, db_session): - frame = _setup_two_linked_users(client, db_session) - widget = _add_tasks_widget(db_session, frame) - client.post(f"/api/frames/1/widgets/{widget.id}/tasks-source", - json={"calendar_key": "caldav:/alice/tasks/"}, headers=csrf_headers(client)) + alice_id = db_session.query(User).filter_by(username="alice").one().id client.cookies.clear() login(client, "bob") - resp = client.post(f"/api/frames/1/widgets/{widget.id}/tasks-source", json={"calendar_key": None}, - headers=csrf_headers(client)) + resp = client.post(f"/api/frames/1/widgets/{widget.id}/task-list-select", json={ + "user_id": alice_id, "calendar_key": "caldav:/alice/tasks/", "included": True, + }, headers=csrf_headers(client)) + assert resp.status_code == 403 + + +def test_task_list_select_owner_can_add_their_own(client, db_session): + frame = _setup_two_linked_users(client, db_session) + widget = _add_tasks_widget(db_session, frame) + alice_id = db_session.query(User).filter_by(username="alice").one().id + + resp = client.post(f"/api/frames/1/widgets/{widget.id}/task-list-select", json={ + "user_id": alice_id, "calendar_key": "caldav:/alice/tasks/", "included": True, + }, headers=csrf_headers(client)) assert resp.status_code == 200, resp.text - cfg = db_session.get(TaskWidgetConfig, widget.id) - assert cfg.user_id is None - assert cfg.calendar_key is None + row = db_session.query(FrameTaskList).filter_by( + widget_id=widget.id, user_id=alice_id, calendar_key="caldav:/alice/tasks/" + ).one() + assert row.included is True -def test_tasks_source_404s_for_a_widget_id_that_does_not_exist(client, db_session): - _setup_two_linked_users(client, db_session) - resp = client.post("/api/frames/1/widgets/999999/tasks-source", - json={"calendar_key": "caldav:/some/tasks/"}, headers=csrf_headers(client)) +def test_task_list_select_bob_can_mute_alices_list(client, db_session): + """Muting is a display-preference veto anyone linked gets, unlike + adding -- the one-sided half of this endpoint's permission split.""" + frame = _setup_two_linked_users(client, db_session) + widget = _add_tasks_widget(db_session, frame) + alice_id = db_session.query(User).filter_by(username="alice").one().id + client.post(f"/api/frames/1/widgets/{widget.id}/task-list-select", json={ + "user_id": alice_id, "calendar_key": "caldav:/alice/tasks/", "included": True, + }, headers=csrf_headers(client)) + + client.cookies.clear() + login(client, "bob") + resp = client.post(f"/api/frames/1/widgets/{widget.id}/task-list-select", json={ + "user_id": alice_id, "calendar_key": "caldav:/alice/tasks/", "included": False, + }, headers=csrf_headers(client)) + assert resp.status_code == 200, resp.text + + row = db_session.query(FrameTaskList).filter_by( + widget_id=widget.id, user_id=alice_id, calendar_key="caldav:/alice/tasks/" + ).one() + assert row.included is False + + +def test_task_list_select_cannot_mute_a_list_that_was_never_added(client, db_session): + frame = _setup_two_linked_users(client, db_session) + widget = _add_tasks_widget(db_session, frame) + alice_id = db_session.query(User).filter_by(username="alice").one().id + + client.cookies.clear() + login(client, "bob") + resp = client.post(f"/api/frames/1/widgets/{widget.id}/task-list-select", json={ + "user_id": alice_id, "calendar_key": "caldav:/alice/tasks/", "included": False, + }, headers=csrf_headers(client)) assert resp.status_code == 404 -def test_tasks_source_400s_when_widget_is_not_tasks(client, db_session): +def test_task_list_select_404s_for_a_widget_id_that_does_not_exist(client, db_session): + _setup_two_linked_users(client, db_session) + resp = client.post("/api/frames/1/widgets/999999/task-list-select", json={ + "user_id": 1, "calendar_key": "caldav:/some/tasks/", "included": True, + }, headers=csrf_headers(client)) + assert resp.status_code == 404 + + +def test_task_list_select_400s_when_widget_is_not_tasks(client, db_session): frame = _setup_two_linked_users(client, db_session) photo_widget_id = _photo_widget_id(db_session, frame) - resp = client.post(f"/api/frames/1/widgets/{photo_widget_id}/tasks-source", - json={"calendar_key": "caldav:/some/tasks/"}, headers=csrf_headers(client)) + alice_id = db_session.query(User).filter_by(username="alice").one().id + resp = client.post(f"/api/frames/1/widgets/{photo_widget_id}/task-list-select", json={ + "user_id": alice_id, "calendar_key": "caldav:/alice/tasks/", "included": True, + }, headers=csrf_headers(client)) + assert resp.status_code == 400 + + +# --- task-list-color --- + +def test_task_list_color_owner_can_pin_it(client, db_session): + frame = _setup_two_linked_users(client, db_session) + widget = _add_tasks_widget(db_session, frame) + alice_id = db_session.query(User).filter_by(username="alice").one().id + client.post(f"/api/frames/1/widgets/{widget.id}/task-list-select", json={ + "user_id": alice_id, "calendar_key": "caldav:/alice/tasks/", "included": True, + }, headers=csrf_headers(client)) + + resp = client.post(f"/api/frames/1/widgets/{widget.id}/task-list-color", json={ + "calendar_key": "caldav:/alice/tasks/", "color_index": 3, + }, headers=csrf_headers(client)) + assert resp.status_code == 200, resp.text + + row = db_session.query(FrameTaskList).filter_by( + widget_id=widget.id, user_id=alice_id, calendar_key="caldav:/alice/tasks/" + ).one() + assert row.color_index == 3 + + +def test_task_list_color_bob_cannot_recolor_alices_list(client, db_session): + frame = _setup_two_linked_users(client, db_session) + widget = _add_tasks_widget(db_session, frame) + alice_id = db_session.query(User).filter_by(username="alice").one().id + client.post(f"/api/frames/1/widgets/{widget.id}/task-list-select", json={ + "user_id": alice_id, "calendar_key": "caldav:/alice/tasks/", "included": True, + }, headers=csrf_headers(client)) + + client.cookies.clear() + login(client, "bob") + resp = client.post(f"/api/frames/1/widgets/{widget.id}/task-list-color", json={ + "calendar_key": "caldav:/alice/tasks/", "color_index": 3, + }, headers=csrf_headers(client)) + assert resp.status_code == 404 # bob has no row for alice's list to recolor + + +def test_task_list_color_rejects_out_of_range_index(client, db_session): + frame = _setup_two_linked_users(client, db_session) + widget = _add_tasks_widget(db_session, frame) + alice_id = db_session.query(User).filter_by(username="alice").one().id + client.post(f"/api/frames/1/widgets/{widget.id}/task-list-select", json={ + "user_id": alice_id, "calendar_key": "caldav:/alice/tasks/", "included": True, + }, headers=csrf_headers(client)) + + resp = client.post(f"/api/frames/1/widgets/{widget.id}/task-list-color", json={ + "calendar_key": "caldav:/alice/tasks/", "color_index": 99, + }, headers=csrf_headers(client)) assert resp.status_code == 400 diff --git a/server/tests/test_widget_config_and_queue_endpoints.py b/server/tests/test_widget_config_and_queue_endpoints.py index ba10220..5dd956d 100644 --- a/server/tests/test_widget_config_and_queue_endpoints.py +++ b/server/tests/test_widget_config_and_queue_endpoints.py @@ -9,7 +9,7 @@ while the (unmodified, copied-over) dialog JS posts form-urlencoded data from __future__ import annotations -from app.models import CalendarWidgetConfig, Frame, PhotoWidgetConfig, Widget +from app.models import CalendarWidgetConfig, Frame, PhotoWidgetConfig, TaskWidgetConfig, Widget from .conftest import csrf_headers @@ -32,6 +32,18 @@ def _add_calendar_widget(db_session) -> Widget: return widget +def _add_tasks_widget(db_session) -> Widget: + import time + + widget = Widget(frame_id=1, widget_type="tasks", x=0, y=0, w=2, h=2, + sort_order=1, created_at=time.time()) + db_session.add(widget) + db_session.flush() + db_session.add(TaskWidgetConfig(widget_id=widget.id)) + db_session.commit() + return widget + + def _mock_immich(monkeypatch): monkeypatch.setattr("app.routers.api_widgets.immich_client_for", lambda frame: object()) monkeypatch.setattr("app.routers.api_widgets.list_assets", lambda client, album_id: _ASSETS) @@ -80,6 +92,21 @@ def test_config_save_updates_a_calendar_widget(client, db_session): assert cfg.weather_units == "celsius" +def test_config_save_updates_a_tasks_widget(client, db_session): + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + widget = _add_tasks_widget(db_session) + + resp = client.post( + f"/api/frames/1/widgets/{widget.id}/config", + data={"tasks_show_completed": "true"}, + headers=csrf_headers(client), + ) + assert resp.status_code == 200, resp.text + + cfg = db_session.get(TaskWidgetConfig, widget.id) + assert cfg.show_completed is True + + def test_config_save_only_partially_updates_provided_fields(client, db_session): """Fields not present in the POST are left untouched -- the whole point of the partial-update convention (each dialog's own form only diff --git a/server/tests/test_widgets_tasks.py b/server/tests/test_widgets_tasks.py index 1ba8e5d..f5e8e3d 100644 --- a/server/tests/test_widgets_tasks.py +++ b/server/tests/test_widgets_tasks.py @@ -1,8 +1,11 @@ """app.widgets.tasks -- unit-level, no HTTP: constructs Widget/ TaskWidgetConfig rows directly and monkeypatches the underlying fetch -call (get_or_refresh_tasks_for_widget, throttle/CalDAV-fetch logic -covered separately). Split out of the old calendar widget's week-view- -only task list -- see models.TaskWidgetConfig.""" +call (get_or_refresh_tasks_for_widget, throttle/multi-list-merge/CalDAV +-fetch logic covered separately -- see test_widget_config_and_queue_ +endpoints.py and test_permission_boundaries.py for the FrameTaskList +inclusion/color HTTP-layer coverage). Split out of the old calendar +widget's week-view-only, single-list task list -- see +models.TaskWidgetConfig/FrameTaskList.""" from __future__ import annotations @@ -11,8 +14,6 @@ import time from app import widgets from app.models import Frame, TaskWidgetConfig, Widget -from .conftest import make_user - def _make_widget(db_session, **cfg_kwargs) -> tuple[Frame, Widget]: frame = db_session.get(Frame, 1) @@ -25,30 +26,44 @@ def _make_widget(db_session, **cfg_kwargs) -> tuple[Frame, Widget]: return frame, widget -def _make_configured_widget(db_session) -> tuple[Frame, Widget]: - user = make_user(db_session, "task-owner") - return _make_widget(db_session, user_id=user.id, calendar_key="caldav:/some/tasks/") - - -def test_render_unconfigured_widget_returns_correctly_sized_placeholder(db_session): - frame, widget = _make_widget(db_session) # no user_id/calendar_key set - img = widgets.tasks.render(db_session, frame, widget, 300, 200) - assert img.size == (300, 200) - - -def test_render_configured_widget_produces_a_correctly_sized_image(db_session, monkeypatch): - frame, widget = _make_configured_widget(db_session) - monkeypatch.setattr(widgets.tasks, "get_or_refresh_tasks_for_widget", - lambda db, frame, widget: [{"summary": "Buy milk", "due": None}]) +def test_render_produces_a_correctly_sized_image_with_no_lists_included(db_session, monkeypatch): + """No placeholder for "nothing included yet" -- same posture as + app/widgets/calendar.py (see tasks.py's own module docstring): + get_or_refresh_tasks_for_widget already returns [] with nothing + included, and render() draws straight through that.""" + frame, widget = _make_widget(db_session) + monkeypatch.setattr(widgets.tasks, "get_or_refresh_tasks_for_widget", lambda db, frame, widget: []) img = widgets.tasks.render(db_session, frame, widget, 300, 200) assert img.size == (300, 200) assert img.mode == "RGB" -def test_render_configured_but_empty_task_list_still_renders(db_session, monkeypatch): - frame, widget = _make_configured_widget(db_session) - monkeypatch.setattr(widgets.tasks, "get_or_refresh_tasks_for_widget", lambda db, frame, widget: []) +def test_render_produces_a_correctly_sized_image_with_tasks(db_session, monkeypatch): + frame, widget = _make_widget(db_session) + monkeypatch.setattr(widgets.tasks, "get_or_refresh_tasks_for_widget", + lambda db, frame, widget: [ + {"summary": "Buy milk", "due": None, "completed_at": None, + "owner_display_name": "Alice", "color_index": None}, + ]) + + img = widgets.tasks.render(db_session, frame, widget, 300, 200) + assert img.size == (300, 200) + assert img.mode == "RGB" + + +def test_render_with_completed_tasks(db_session, monkeypatch): + """show_completed's merged result includes completed tasks too (see + caldav_client.fetch_tasks' completed_since) -- render() must handle + that shape (a non-None completed_at) without error.""" + frame, widget = _make_widget(db_session) + monkeypatch.setattr(widgets.tasks, "get_or_refresh_tasks_for_widget", + lambda db, frame, widget: [ + {"summary": "Walk the dog", "due": "2026-08-01", "completed_at": None, + "owner_display_name": "Alice", "color_index": 2}, + {"summary": "Buy milk", "due": None, "completed_at": "2026-08-01T10:00:00+00:00", + "owner_display_name": "Alice", "color_index": 2}, + ]) img = widgets.tasks.render(db_session, frame, widget, 300, 200) assert img.size == (300, 200) @@ -58,7 +73,7 @@ def test_render_at_minimum_grid_footprint(db_session, monkeypatch): """grid.MIN_FOOTPRINT["tasks"] is (2, 2) cells -- on an 8x5 grid against a full 800x480 panel that's a 200x192 box, the smallest a tasks widget can actually be placed at.""" - frame, widget = _make_configured_widget(db_session) + frame, widget = _make_widget(db_session) monkeypatch.setattr(widgets.tasks, "get_or_refresh_tasks_for_widget", lambda db, frame, widget: []) img = widgets.tasks.render(db_session, frame, widget, 200, 192)