diff --git a/docs/widgets.md b/docs/widgets.md index 5fc4347..6286cbf 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -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. diff --git a/server/app/calendar_render.py b/server/app/calendar_render.py index e261f8d..ff794f3 100644 --- a/server/app/calendar_render.py +++ b/server/app/calendar_render.py @@ -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() diff --git a/server/app/db.py b/server/app/db.py index ed8a695..80c582b 100644 --- a/server/app/db.py +++ b/server/app/db.py @@ -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). diff --git a/server/app/grid.py b/server/app/grid.py index 6fc2e7c..da2f1fa 100644 --- a/server/app/grid.py +++ b/server/app/grid.py @@ -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) diff --git a/server/app/migration.py b/server/app/migration.py index 7c3de2b..905950a 100644 --- a/server/app/migration.py +++ b/server/app/migration.py @@ -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: diff --git a/server/app/models.py b/server/app/models.py index 25640d5..36e0612 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -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, } diff --git a/server/app/routers/api_widgets.py b/server/app/routers/api_widgets.py index 8a179c7..f99d868 100644 --- a/server/app/routers/api_widgets.py +++ b/server/app/routers/api_widgets.py @@ -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} diff --git a/server/app/routers/common.py b/server/app/routers/common.py index ab42d47..ce06e29 100644 --- a/server/app/routers/common.py +++ b/server/app/routers/common.py @@ -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 diff --git a/server/app/routers/frame_pages.py b/server/app/routers/frame_pages.py index 9029cb5..0fdac88 100644 --- a/server/app/routers/frame_pages.py +++ b/server/app/routers/frame_pages.py @@ -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 '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 '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": diff --git a/server/app/static/common.js b/server/app/static/common.js index 0ed31ea..dbccb26 100644 --- a/server/app/static/common.js +++ b/server/app/static/common.js @@ -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 is open, its own .dialog-result container gets the diff --git a/server/app/static/frame_layout.js b/server/app/static/frame_layout.js index fe15fc6..ccf5788 100644 --- a/server/app/static/frame_layout.js +++ b/server/app/static/frame_layout.js @@ -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 // initDialog()/closeDialog() 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; diff --git a/server/app/static/widget_dialog_calendar.js b/server/app/static/widget_dialog_calendar.js index 93e3a8d..1a30ca8 100644 --- a/server/app/static/widget_dialog_calendar.js +++ b/server/app/static/widget_dialog_calendar.js @@ -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 , 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(); } diff --git a/server/app/static/widget_dialog_tasks.js b/server/app/static/widget_dialog_tasks.js new file mode 100644 index 0000000..f957bd3 --- /dev/null +++ b/server/app/static/widget_dialog_tasks.js @@ -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 , 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) => { + 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. +} diff --git a/server/app/templates/_widget_dialog_calendar.html b/server/app/templates/_widget_dialog_calendar.html index 2dfd5d9..23386a7 100644 --- a/server/app/templates/_widget_dialog_calendar.html +++ b/server/app/templates/_widget_dialog_calendar.html @@ -127,44 +127,6 @@ -
-

Tasks

-

Week view only -- takes the place of one day slot - instead of adding an extra one.

-
- - -
- -
- {% if tasks_source %} -

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

- {% 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 (a plain ICS subscription - doesn't carry tasks).

- {% endif %} -
-

Preview

How this widget currently renders.

diff --git a/server/app/templates/_widget_dialog_tasks.html b/server/app/templates/_widget_dialog_tasks.html new file mode 100644 index 0000000..41fe5a6 --- /dev/null +++ b/server/app/templates/_widget_dialog_tasks.html @@ -0,0 +1,44 @@ +

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.

+ +
+ {% 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 %} +
+ +
+

Preview

+

How this widget currently renders.

+ Tasks preview + +
diff --git a/server/app/templates/frame_layout.html b/server/app/templates/frame_layout.html index 5414b85..2412066 100644 --- a/server/app/templates/frame_layout.html +++ b/server/app/templates/frame_layout.html @@ -58,5 +58,6 @@ + {% endblock %} diff --git a/server/app/widgets/__init__.py b/server/app/widgets/__init__.py index a3e1315..c4674a9 100644 --- a/server/app/widgets/__init__.py +++ b/server/app/widgets/__init__.py @@ -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, } diff --git a/server/app/widgets/calendar.py b/server/app/widgets/calendar.py index 244f48d..445b87e 100644 --- a/server/app/widgets/calendar.py +++ b/server/app/widgets/calendar.py @@ -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, ) diff --git a/server/app/widgets/tasks.py b/server/app/widgets/tasks.py new file mode 100644 index 0000000..f208cf1 --- /dev/null +++ b/server/app/widgets/tasks.py @@ -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 = {} diff --git a/server/tests/test_migrations.py b/server/tests/test_migrations.py index 1a141da..c7ac8bc 100644 --- a/server/tests/test_migrations.py +++ b/server/tests/test_migrations.py @@ -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")) diff --git a/server/tests/test_permission_boundaries.py b/server/tests/test_permission_boundaries.py index 6b02694..aa7cafb 100644 --- a/server/tests/test_permission_boundaries.py +++ b/server/tests/test_permission_boundaries.py @@ -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", diff --git a/server/tests/test_render_size_invariants.py b/server/tests/test_render_size_invariants.py index ecaed58..f3123ba 100644 --- a/server/tests/test_render_size_invariants.py +++ b/server/tests/test_render_size_invariants.py @@ -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) --- diff --git a/server/tests/test_widget_config_and_queue_endpoints.py b/server/tests/test_widget_config_and_queue_endpoints.py index baee6b2..ba10220 100644 --- a/server/tests/test_widget_config_and_queue_endpoints.py +++ b/server/tests/test_widget_config_and_queue_endpoints.py @@ -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): diff --git a/server/tests/test_widgets_calendar.py b/server/tests/test_widgets_calendar.py index 4f4d3be..74be407 100644 --- a/server/tests/test_widgets_calendar.py +++ b/server/tests/test_widgets_calendar.py @@ -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) diff --git a/server/tests/test_widgets_tasks.py b/server/tests/test_widgets_tasks.py new file mode 100644 index 0000000..1ba8e5d --- /dev/null +++ b/server/tests/test_widgets_tasks.py @@ -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 == {}