From 89936ebf5850f19705e21e36e2715ea47e0a78ae Mon Sep 17 00:00:00 2001 From: peter Date: Mon, 7 Sep 2026 22:26:48 +0100 Subject: [PATCH] v0.7.0: mark all as read by clicking the unread badge - Click a source's unread count in the sidebar to mark all of that source's messages read (clears the badge + reduces the All-sources total); click the All-sources total to mark everything read. - POST /feed/mark-read (session, owner-scoped, excludes trashed). Visible feed rows drop their unread state too. Badge is keyboard-activatable (Enter/Space). 156 tests pass (4 new); inline JS re-validated with node --check. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 11 ++++++- artifacts/urls.py | 1 + artifacts/views.py | 15 +++++++++ daybook_project/__init__.py | 2 +- static/agentfeed.css | 12 +++++++ templates/artifacts/feed.html | 59 +++++++++++++++++++++++++++++++++-- tests/views/test_trash.py | 28 +++++++++++++++++ 7 files changed, 123 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 318a2a1..6ff33af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ former name, "Daybook".) _Nothing yet._ +## [0.7.0] — 2026-09-07 + +### Added +- **Mark all as read.** Click a source's unread badge in the sidebar to mark + all of that source's messages read (clears the badge); click the **All + sources** total to mark everything read. Owner-scoped, excludes trashed; any + visible feed rows update too. Backed by `POST /feed/mark-read`. + ## [0.6.3] — 2026-09-07 ### Changed @@ -235,7 +243,8 @@ First tagged release. - Dockerised (Gunicorn + whitenoise), SQLite, `git`-based deploy with a runbook (`deploy/cloud_agent_deploy.md`) and health check. -[Unreleased]: https://git.cygnus.systems/peter/daybook/compare/v0.6.3...HEAD +[Unreleased]: https://git.cygnus.systems/peter/daybook/compare/v0.7.0...HEAD +[0.7.0]: https://git.cygnus.systems/peter/daybook/compare/v0.6.3...v0.7.0 [0.6.3]: https://git.cygnus.systems/peter/daybook/compare/v0.6.2...v0.6.3 [0.6.2]: https://git.cygnus.systems/peter/daybook/compare/v0.6.1...v0.6.2 [0.6.1]: https://git.cygnus.systems/peter/daybook/compare/v0.6.0...v0.6.1 diff --git a/artifacts/urls.py b/artifacts/urls.py index 158b3d1..44bab7f 100644 --- a/artifacts/urls.py +++ b/artifacts/urls.py @@ -13,6 +13,7 @@ urlpatterns = [ path("a//delete", views.delete_artifact, name="artifact-delete"), path("a//undelete", views.undelete_artifact, name="artifact-undelete"), path("feed/empty-trash", views.empty_trash, name="feed-empty-trash"), + path("feed/mark-read", views.mark_all_read, name="feed-mark-read"), path("a/", views.artifact_view, name="artifact"), path("s/", views.shared_artifact_view, name="artifact-shared"), path("assets/", views.asset_view, name="asset"), diff --git a/artifacts/views.py b/artifacts/views.py index 055c861..172b8c7 100644 --- a/artifacts/views.py +++ b/artifacts/views.py @@ -696,6 +696,21 @@ def empty_trash(request): return JsonResponse({"purged": count}) +@login_required +@require_POST +def mark_all_read(request): + """Mark all of the user's unread (non-trashed) messages as read, optionally + scoped to one source. Backs the sidebar 'Mark all as read' action.""" + unread = Artifact.objects.filter( + owner=request.user, deleted_at__isnull=True, read_at__isnull=True + ) + source = request.POST.get("source", "") + if source: + unread = unread.filter(source=source) + marked = unread.update(read_at=timezone.now()) + return JsonResponse({"marked": marked, "source": source}) + + @login_required def feed_older(request): """Return the next page of older day-groups as an HTML fragment for infinite scroll.""" diff --git a/daybook_project/__init__.py b/daybook_project/__init__.py index b040cb3..f00cfaa 100644 --- a/daybook_project/__init__.py +++ b/daybook_project/__init__.py @@ -1,4 +1,4 @@ # Single source of truth for the app version. Bump this, commit, then tag the # commit `vX.Y.Z` and deploy. Surfaced on Settings -> About and in # GET /api/capabilities ("version"). -__version__ = "0.6.3" +__version__ = "0.7.0" diff --git a/static/agentfeed.css b/static/agentfeed.css index ca19527..7c8110d 100644 --- a/static/agentfeed.css +++ b/static/agentfeed.css @@ -514,6 +514,18 @@ h1 { font-size: 0.7rem; } +/* The unread badge doubles as a "mark all as read" button. */ +.source-count[data-mark-read] { + cursor: pointer; + border-radius: 8px; + padding: 0 0.35rem; +} + +.source-count[data-mark-read]:hover { + background: rgba(74, 110, 168, 0.28); + color: var(--text); +} + .pane-heading span, .artifact-time { color: var(--muted); diff --git a/templates/artifacts/feed.html b/templates/artifacts/feed.html index daa2182..4dfe047 100644 --- a/templates/artifacts/feed.html +++ b/templates/artifacts/feed.html @@ -37,12 +37,12 @@ @@ -620,6 +620,59 @@ openRowMenu(row, event.clientX, event.clientY); }); + // Click a source's unread badge to mark all of that source's messages read. + const markSourceRead = async (badge) => { + const link = badge.closest(".source-link"); + if (!link) return; + const isAll = link.classList.contains("all-sources-link"); + const source = link.dataset.source || ""; + const body = new URLSearchParams(); + if (!isAll && source) body.set("source", source); + try { + const response = await fetch("{% url 'feed-mark-read' %}", { + method: "POST", + headers: {"X-CSRFToken": csrfToken, "Content-Type": "application/x-www-form-urlencoded"}, + body: body.toString(), + }); + if (!response.ok) return; + } catch (e) { return; } + if (isAll) { + document.querySelectorAll(".source-list .source-count").forEach((b) => b.remove()); + artifactList.querySelectorAll(".artifact-row.unread").forEach((r) => r.classList.remove("unread")); + } else { + const n = parseInt(badge.textContent, 10) || 0; + badge.remove(); + const allBadge = document.querySelector(".all-sources-link .source-count"); + if (allBadge) { + const t = (parseInt(allBadge.textContent, 10) || 0) - n; + if (t > 0) allBadge.textContent = String(t); + else allBadge.remove(); + } + artifactList + .querySelectorAll(`.artifact-row.unread[data-source="${CSS.escape(source)}"]`) + .forEach((r) => r.classList.remove("unread")); + } + }; + + const sourceListEl = document.querySelector(".source-list"); + if (sourceListEl) { + const badgeFromEvent = (event) => event.target.closest("[data-mark-read]"); + sourceListEl.addEventListener("click", (event) => { + const badge = badgeFromEvent(event); + if (!badge) return; + event.preventDefault(); + event.stopPropagation(); + markSourceRead(badge); + }); + sourceListEl.addEventListener("keydown", (event) => { + if (event.key !== "Enter" && event.key !== " ") return; + const badge = badgeFromEvent(event); + if (!badge) return; + event.preventDefault(); + markSourceRead(badge); + }); + } + const renderPreview = (row, pushState = true) => { navigationMode = "artifacts"; markRead(row); diff --git a/tests/views/test_trash.py b/tests/views/test_trash.py index e04601e..28faf70 100644 --- a/tests/views/test_trash.py +++ b/tests/views/test_trash.py @@ -128,3 +128,31 @@ class TrashKeepViewTestCase(TestCase): self.client.post(reverse("artifact-delete", args=[fresh.id])) self.assertFalse(Artifact.objects.filter(pk=old.id).exists()) self.assertTrue(Artifact.objects.filter(pk=fresh.id, deleted_at__isnull=False).exists()) + + def test_mark_all_read_across_sources(self): + self.make(source="a") + self.make(source="b") + r = self.client.post(reverse("feed-mark-read")) + self.assertEqual(r.status_code, 200) + self.assertEqual(r.json()["marked"], 2) + self.assertFalse(Artifact.objects.filter(owner=self.user, read_at__isnull=True).exists()) + + def test_mark_all_read_scoped_to_source(self): + a = self.make(source="hermes") + b = self.make(source="janus") + self.assertEqual(self.client.post(reverse("feed-mark-read"), {"source": "hermes"}).json()["marked"], 1) + a.refresh_from_db() + b.refresh_from_db() + self.assertIsNotNone(a.read_at) + self.assertIsNone(b.read_at) + + def test_mark_all_read_owner_scoped(self): + theirs = self.make(owner=self.other) + self.client.post(reverse("feed-mark-read")) + theirs.refresh_from_db() + self.assertIsNone(theirs.read_at) + + def test_mark_all_read_excludes_trashed(self): + a = self.make() + Artifact.objects.filter(pk=a.id).update(deleted_at=timezone.now()) + self.assertEqual(self.client.post(reverse("feed-mark-read")).json()["marked"], 0)