From 4a2b1f379594912a25a565c42b95935fc8c3f33e Mon Sep 17 00:00:00 2001 From: Thomas Faour Date: Sat, 25 Jul 2026 04:05:36 +0000 Subject: [PATCH] Let a tasks widget have a custom on-panel name Adds TaskWidgetConfig.name (migration 19, plain column add) shown at the top of the widget on the actual panel instead of the hardcoded "Tasks" header -- e.g. "Chores" or "Mom's list". The only widget type with its own on-panel title at all, since it's the only one where "which list is this" isn't already obvious from a calendar/photo/ whiteboard's own content. Threaded through calendar_render's _draw_tasks/_build_tasks/ render_tasks/render_tasks_preview_png as a `title` param (default "Tasks", truncated to fit -- a long custom name shouldn't be able to overflow the widget's box), the config-save endpoint (tasks_name, truncated server-side to a sane header length rather than rejected), and a new "Settings" section in the tasks dialog. Verified live in the browser: the name actually renders at the top of the real composited panel (not just the dialog preview, which stays gated on having a configured source), and persists correctly on both desktop and mobile. Full suite (196 tests) passes. --- server/app/calendar_render.py | 23 ++++++---- server/app/migration.py | 13 ++++++ server/app/models.py | 6 +++ server/app/routers/api_widgets.py | 10 ++++- server/app/static/widget_dialog_tasks.js | 11 ++--- .../app/templates/_widget_dialog_tasks.html | 8 +++- server/app/widgets/tasks.py | 5 ++- server/tests/test_migrations.py | 2 + .../test_widget_config_and_queue_endpoints.py | 18 +++++++- server/tests/test_widgets_tasks.py | 44 +++++++++++++++++++ 10 files changed, 120 insertions(+), 20 deletions(-) diff --git a/server/app/calendar_render.py b/server/app/calendar_render.py index 8bef930..942c854 100644 --- a/server/app/calendar_render.py +++ b/server/app/calendar_render.py @@ -530,8 +530,12 @@ 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, - palette_rgb: list | None = None) -> None: - """A simple checklist filling `region` (x0, y0, w, h) -- a color bar + palette_rgb: list | None = None, title: str = "Tasks") -> None: + """A simple checklist filling `region` (x0, y0, w, h) -- a header + (`title`, truncated to fit -- TaskWidgetConfig.name or the "Tasks" + default; the only widget type with its own on-panel title, since + it's the only one where "which list is this" isn't obvious from its + content the way a calendar/photo/whiteboard's is), then 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 @@ -551,7 +555,7 @@ def _draw_tasks(img: Image.Image, draw: ImageDraw.ImageDraw, region: tuple[int, x0, y0, w, h = region text_x0, text_y0 = x0 + MARGIN, y0 + MARGIN text_w = w - MARGIN * 2 - draw_text(img, (text_x0, text_y0), "Tasks", title_font) + draw_text(img, (text_x0, text_y0), _truncate_to_width(draw, title or "Tasks", title_font, text_w), title_font) y = text_y0 + title_font.size + 12 draw.line([(text_x0, y), (text_x0 + text_w, y)], fill=RULE) y += 12 @@ -894,7 +898,8 @@ 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, palette_rgb: list | None = None) -> Image.Image: +def _build_tasks(tasks: list[dict], target_w: int, target_h: int, palette_rgb: list | None = None, + title: str = "Tasks") -> 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.""" @@ -903,29 +908,29 @@ def _build_tasks(tasks: list[dict], target_w: int, target_h: int, palette_rgb: l 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, palette_rgb) + _draw_tasks(img, draw, (0, 0, target_w, target_h), tasks, title_font, body_font, palette_rgb, title) return img def render_tasks(tasks: list[dict], orientation: str, palette_rgb: list | None, - manage: dict | None = None) -> bytes: + manage: dict | None = None, title: str = "Tasks") -> 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, palette_rgb) + img = _build_tasks(tasks, target_w, target_h, palette_rgb, title) 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: + manage: dict | None = None, title: str = "Tasks") -> 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, palette_rgb) + img = _build_tasks(tasks, target_w, target_h, palette_rgb, title) 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 a2999e8..653165c 100644 --- a/server/app/migration.py +++ b/server/app/migration.py @@ -488,6 +488,18 @@ def _migration_18(conn) -> None: conn.execute(text("ALTER TABLE task_widget_configs_new RENAME TO task_widget_configs")) +def _migration_19(conn) -> None: + """Optional custom on-panel name for a tasks widget (see + calendar_render._draw_tasks), replacing the default "Tasks" header + -- the only widget type with its own on-panel title at all, since + it's the only one where "which list is this" isn't already obvious + from its content. "" (the default) keeps the old hardcoded text, so + this changes no existing widget's appearance by itself. Plain + column add, no FK/index involved -- no rebuild-table dance needed + (unlike task_widget_configs' two previous migrations).""" + conn.execute(text("ALTER TABLE task_widget_configs ADD COLUMN name TEXT NOT NULL DEFAULT ''")) + + MIGRATIONS = [ (1, _migration_1), (2, _migration_2), @@ -507,6 +519,7 @@ MIGRATIONS = [ (16, _migration_16), (17, _migration_17), (18, _migration_18), + (19, _migration_19), ] diff --git a/server/app/models.py b/server/app/models.py index 74bbf10..975e0d1 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -521,6 +521,12 @@ class TaskWidgetConfig(Base): __tablename__ = "task_widget_configs" widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True) + # Shown on-panel in place of the default "Tasks" header (see + # calendar_render._draw_tasks) -- "" keeps the default. The only + # widget type with its own on-panel title at all, since it's the + # only one where "which list is this" isn't already obvious from + # its content the way a calendar/photo/whiteboard's is. + name: Mapped[str] = mapped_column(String, default="") checked_at: Mapped[float] = mapped_column(Float, default=0.0) # [{"summary", "due", "completed_at" (ISO date/datetime strings or # None), "owner_display_name", "color_index"}, ...] -- the merged diff --git a/server/app/routers/api_widgets.py b/server/app/routers/api_widgets.py index 12a08c9..9186381 100644 --- a/server/app/routers/api_widgets.py +++ b/server/app/routers/api_widgets.py @@ -62,6 +62,7 @@ router = APIRouter() MIN_QUEUE_TARGET_LEN = 5 MAX_QUEUE_TARGET_LEN = 5000 CALENDAR_COLOR_INDEX_RANGE = range(2, 6) # Yellow/Red/Blue/Green -- see PALETTE_LABELS +MAX_TASKS_NAME_LEN = 40 # a sane on-panel-header length, see calendar_render._draw_tasks def _widget_dict(w: Widget) -> dict: @@ -258,6 +259,7 @@ def api_widget_config_save( calendar_weather_enabled: bool | None = Form(None), calendar_weather_units: str | None = Form(None), # tasks + tasks_name: str | None = Form(None), tasks_show_completed: bool | None = Form(None), ): """Every field optional -- same partial-update, form-urlencoded @@ -323,6 +325,11 @@ def api_widget_config_save( ccfg.weather_units = calendar_weather_units elif widget.widget_type == "tasks": with widget_locked(db, frame.id, widget.id) as (_, _, tcfg): + if tasks_name is not None: + # Truncated, not rejected -- MAX_TASKS_NAME_LEN is a + # sane on-panel-header length, not a validation rule the + # user needs an error for. + tcfg.name = tasks_name.strip()[:MAX_TASKS_NAME_LEN] 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 @@ -640,9 +647,10 @@ def api_widget_preview_tasks( _require_widget_type(widget, "tasks") if not task_sources_for_widget(db, widget): raise HTTPException(400, "No task lists included on this widget yet") + tcfg = db.get(TaskWidgetConfig, widget.id) 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) + palette_rgb=frame.palette_rgb, title=tcfg.name or "Tasks") return Response(content=png, media_type="image/png") diff --git a/server/app/static/widget_dialog_tasks.js b/server/app/static/widget_dialog_tasks.js index 4d6ce36..80c2e9f 100644 --- a/server/app/static/widget_dialog_tasks.js +++ b/server/app/static/widget_dialog_tasks.js @@ -1,10 +1,10 @@ // 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(). +// name/recently-completed settings form, 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(). function loadTasksPreview() { document.getElementById('tasks-preview').src = `${window.FRAME_API}/preview/tasks?_=${Date.now()}`; @@ -69,6 +69,7 @@ function initTasksDialog() { document.getElementById('tasks-config-form').addEventListener('submit', async (e) => { e.preventDefault(); const body = new URLSearchParams({ + tasks_name: document.getElementById('tasks_name').value, tasks_show_completed: String(document.getElementById('tasks_show_completed').checked), }); try { diff --git a/server/app/templates/_widget_dialog_tasks.html b/server/app/templates/_widget_dialog_tasks.html index bbc72a6..f30262a 100644 --- a/server/app/templates/_widget_dialog_tasks.html +++ b/server/app/templates/_widget_dialog_tasks.html @@ -45,9 +45,13 @@
-

Recently completed

+

Settings

-
+ +

Shown at the top of this widget on the panel instead of "Tasks" -- e.g. "Chores" or "Mom's list".

+
diff --git a/server/app/widgets/tasks.py b/server/app/widgets/tasks.py index 53de251..3036e97 100644 --- a/server/app/widgets/tasks.py +++ b/server/app/widgets/tasks.py @@ -23,7 +23,7 @@ from PIL import Image from sqlalchemy.orm import Session from ..calendar_render import _build_tasks -from ..models import Frame, Widget +from ..models import Frame, TaskWidgetConfig, Widget from ..routers.common import get_or_refresh_tasks_for_widget ACTION_LABELS: dict[str, str] = {} @@ -35,7 +35,8 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i identical note; every widget type's render() shares one call signature regardless of which ones actually care.""" tasks = get_or_refresh_tasks_for_widget(db, frame, widget) - return _build_tasks(tasks, target_w, target_h, frame.palette_rgb) + cfg = db.get(TaskWidgetConfig, widget.id) + return _build_tasks(tasks, target_w, target_h, frame.palette_rgb, cfg.name or "Tasks") ACTIONS: dict = {} diff --git a/server/tests/test_migrations.py b/server/tests/test_migrations.py index 2f7bff0..d52dbd7 100644 --- a/server/tests/test_migrations.py +++ b/server/tests/test_migrations.py @@ -67,12 +67,14 @@ def test_expected_columns_exist_on_current_schema(): inspector = inspect(db_module.engine) user_columns = {c["name"] for c in inspector.get_columns("users")} frame_columns = {c["name"] for c in inspector.get_columns("frames")} + task_widget_columns = {c["name"] for c in inspector.get_columns("task_widget_configs")} assert "webdav_base_url" in user_columns # migration 15 assert "webdav_username" in user_columns # migration 14 assert "calendar_caldav_url" in user_columns assert "whiteboard_cached_image" in frame_columns # migration 14 assert "calendar_week_start_offset" in frame_columns + assert "name" in task_widget_columns # migration 19 # --- widget system backfill (migration 16 + _ensure_widgets_backfilled) --- diff --git a/server/tests/test_widget_config_and_queue_endpoints.py b/server/tests/test_widget_config_and_queue_endpoints.py index 5dd956d..bbe8afa 100644 --- a/server/tests/test_widget_config_and_queue_endpoints.py +++ b/server/tests/test_widget_config_and_queue_endpoints.py @@ -98,15 +98,31 @@ def test_config_save_updates_a_tasks_widget(client, db_session): resp = client.post( f"/api/frames/1/widgets/{widget.id}/config", - data={"tasks_show_completed": "true"}, + data={"tasks_name": "Chores", "tasks_show_completed": "true"}, headers=csrf_headers(client), ) assert resp.status_code == 200, resp.text cfg = db_session.get(TaskWidgetConfig, widget.id) + assert cfg.name == "Chores" assert cfg.show_completed is True +def test_config_save_truncates_an_overlong_tasks_name(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_name": "A" * 200}, + headers=csrf_headers(client), + ) + assert resp.status_code == 200, resp.text + + cfg = db_session.get(TaskWidgetConfig, widget.id) + assert len(cfg.name) == 40 + + 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 f5e8e3d..6e04bdc 100644 --- a/server/tests/test_widgets_tasks.py +++ b/server/tests/test_widgets_tasks.py @@ -69,6 +69,50 @@ def test_render_with_completed_tasks(db_session, monkeypatch): assert img.size == (300, 200) +def _capture_build_tasks_title(monkeypatch): + """Wraps the real _build_tasks to record the `title` it was called + with, while still rendering for real (not a bare stub) so this + keeps exercising the actual render path.""" + seen_titles = [] + real_build_tasks = widgets.tasks._build_tasks + + def spy(tasks, target_w, target_h, palette_rgb=None, title="Tasks"): + seen_titles.append(title) + return real_build_tasks(tasks, target_w, target_h, palette_rgb, title) + + monkeypatch.setattr(widgets.tasks, "_build_tasks", spy) + return seen_titles + + +def test_render_passes_custom_name_as_title(db_session, monkeypatch): + frame, widget = _make_widget(db_session, name="Chores") + monkeypatch.setattr(widgets.tasks, "get_or_refresh_tasks_for_widget", lambda db, frame, widget: []) + seen_titles = _capture_build_tasks_title(monkeypatch) + + widgets.tasks.render(db_session, frame, widget, 300, 200) + assert seen_titles == ["Chores"] + + +def test_render_falls_back_to_default_title_when_name_is_blank(db_session, monkeypatch): + frame, widget = _make_widget(db_session) # name defaults to "" + monkeypatch.setattr(widgets.tasks, "get_or_refresh_tasks_for_widget", lambda db, frame, widget: []) + seen_titles = _capture_build_tasks_title(monkeypatch) + + widgets.tasks.render(db_session, frame, widget, 300, 200) + assert seen_titles == ["Tasks"] + + +def test_render_with_a_very_long_custom_name_still_fits(db_session, monkeypatch): + """calendar_render._draw_tasks truncates the title to fit -- a name + far longer than any placed widget could display must not error or + overflow the requested size.""" + frame, widget = _make_widget(db_session, name="A" * 200) + 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_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