diff --git a/CHANGELOG.md b/CHANGELOG.md index 26f8719..cf4ed70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,23 @@ former name, "Daybook".) _Nothing yet._ +## [0.4.0] — 2026-09-04 + +### Added +- **Live feed updates.** New artifacts now appear at the top of the feed + automatically, without a manual refresh — the browser polls a lightweight + `GET /feed/updates` every 5s (paused while the tab is hidden, one immediate + poll on refocus). Owner-scoped and **respects the active source/label/kind/q + filters**, so a new post that doesn't match your current view won't show. The + currently selected/previewed artifact stays put and visibly selected. Fresh + rows display "now" and age into their real time after a few minutes. + (Chosen over SSE/WebSockets deliberately: zero new infrastructure on the + sync-WSGI + SQLite stack; see `planning/next_steps/` for the SSE upgrade path.) + +### Changed +- Extracted the feed row markup into a reusable `_artifact_rows.html` partial + (shared by the day-group render and the live-updates endpoint). + ## [0.3.2] — 2026-09-04 ### Added @@ -138,7 +155,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.3.2...HEAD +[Unreleased]: https://git.cygnus.systems/peter/daybook/compare/v0.4.0...HEAD +[0.4.0]: https://git.cygnus.systems/peter/daybook/compare/v0.3.2...v0.4.0 [0.3.2]: https://git.cygnus.systems/peter/daybook/compare/v0.3.1...v0.3.2 [0.3.1]: https://git.cygnus.systems/peter/daybook/compare/v0.3.0...v0.3.1 [0.3.0]: https://git.cygnus.systems/peter/daybook/compare/v0.2.1...v0.3.0 diff --git a/artifacts/urls.py b/artifacts/urls.py index 008ceb5..cc1f03c 100644 --- a/artifacts/urls.py +++ b/artifacts/urls.py @@ -6,6 +6,7 @@ from . import views urlpatterns = [ path("", views.feed, name="feed"), path("feed/older", views.feed_older, name="feed-older"), + path("feed/updates", views.feed_updates, name="feed-updates"), path("a//read", views.mark_artifact_read, name="artifact-mark-read"), path("a//keep", views.toggle_artifact_keep, name="artifact-toggle-keep"), path("a//share", views.toggle_artifact_share, name="artifact-toggle-share"), diff --git a/artifacts/views.py b/artifacts/views.py index d82eed9..1a6afc3 100644 --- a/artifacts/views.py +++ b/artifacts/views.py @@ -22,9 +22,10 @@ from django.db import OperationalError, transaction from django.db.models import Count, Q, Sum from django.http import FileResponse, Http404, HttpResponse, HttpResponseForbidden, JsonResponse from django.shortcuts import get_object_or_404, redirect, render -from django.template.defaultfilters import slugify +from django.template.defaultfilters import date as date_filter, slugify +from django.template.loader import render_to_string from django.urls import reverse -from django.utils.dateparse import parse_date +from django.utils.dateparse import parse_date, parse_datetime from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.clickjacking import xframe_options_sameorigin @@ -573,6 +574,8 @@ def feed(request): "day_groups": day_groups, "older_count": older_count, "window_start": window_start, + # Marker for live polling: newest artifact shown, else now. + "feed_since": (window_artifacts[0].created_at if window_artifacts else timezone.now()).isoformat(), "source_counts": source_counts, "total_count": source_scoped.filter(read_at__isnull=True).count(), "all_labels": labels_with_colors_list(all_labels, overrides), @@ -666,6 +669,58 @@ def feed_older(request): ) +@login_required +def feed_updates(request): + """New artifacts since a timestamp, as row HTML for the feed to prepend live. + Owner-scoped and filter-aware — respects the source/label/kind/q the browser + is currently viewing, so a new post that doesn't match won't appear.""" + after = parse_datetime(request.GET.get("after", "") or "") + if after is None: + return JsonResponse({"count": 0}) + + selected_source = request.GET.get("source", "") + selected_label = request.GET.get("label", "") + selected_kind = request.GET.get("kind", "") + title_query = request.GET.get("q", "") + artifacts = apply_feed_filters( + Artifact.objects.filter(owner=request.user), + selected_source, + selected_label, + selected_kind, + title_query, + ).filter(created_at__gt=after).order_by("-created_at") + + new = list(artifacts[:100]) + if not new: + return JsonResponse({"count": 0}) + + overrides = tag_color_overrides() + for artifact in new: + artifact.label_chips = tags_with_colors(artifact.tags, overrides) + + html = render_to_string( + "artifacts/_artifact_rows.html", + { + "artifacts": new, + "selected_artifact": None, + "selected_source": selected_source, + "selected_label": selected_label, + "selected_kind": selected_kind, + "title_query": title_query, + "selected_view": "raw" if request.GET.get("view") == "raw" else "rendered", + }, + request=request, + ) + return JsonResponse( + { + "count": len(new), + "newest": new[0].created_at.isoformat(), + "today_label": date_filter(timezone.localdate(), "D, M j Y"), + "html": html, + } + ) + + @login_required @xframe_options_sameorigin def artifact_view(request, artifact_id): diff --git a/daybook_project/__init__.py b/daybook_project/__init__.py index 47770b1..bc935aa 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.3.2" +__version__ = "0.4.0" diff --git a/static/agentfeed.css b/static/agentfeed.css index 805b76b..865c1a0 100644 --- a/static/agentfeed.css +++ b/static/agentfeed.css @@ -1045,6 +1045,16 @@ kbd { color: var(--muted); } +/* Newly live-inserted feed rows briefly highlight, then settle. */ +@keyframes agentfeed-row-arrive { + from { background: var(--surface-alt); } + to { background: transparent; } +} + +.artifact-row.just-arrived { + animation: agentfeed-row-arrive 1.5s ease-out; +} + .api-key-create { align-items: end; display: flex; diff --git a/templates/artifacts/_artifact_rows.html b/templates/artifacts/_artifact_rows.html new file mode 100644 index 0000000..1125e01 --- /dev/null +++ b/templates/artifacts/_artifact_rows.html @@ -0,0 +1,34 @@ +{% for artifact in artifacts %} + + + + {{ artifact.title }} + {% if artifact.kind == "image" %}image{% elif artifact.kind == "pdf" %}pdf{% elif artifact.kind == "markdown" %}markdown{% elif artifact.kind == "deck" %}deck{% elif artifact.kind == "binary" %}binary{% else %}html{% endif %} + K + 🔗 + {% if artifact.label_chips %} + + {% for label in artifact.label_chips %} + {{ label.name }} + {% endfor %} + + {% endif %} + + {{ artifact.source }} + +{% endfor %} diff --git a/templates/artifacts/_day_groups.html b/templates/artifacts/_day_groups.html index 111fb86..63971f4 100644 --- a/templates/artifacts/_day_groups.html +++ b/templates/artifacts/_day_groups.html @@ -1,43 +1,10 @@ {% for group in day_groups %} -
+ {% endfor %} {% if has_more %} diff --git a/templates/artifacts/feed.html b/templates/artifacts/feed.html index 55bfdb7..a240ebc 100644 --- a/templates/artifacts/feed.html +++ b/templates/artifacts/feed.html @@ -55,7 +55,7 @@ {{ artifacts|length }} artifact{{ artifacts|length|pluralize }}
-
+
{% if day_groups %} {% include "artifacts/_day_groups.html" with has_more=older_count next_before=window_start|date:"Y-m-d" %} {% else %} @@ -552,6 +552,95 @@ observeSentinel(); + // --- Live updates: poll for new artifacts and prepend them at the top. --- + // Cheap: a small "anything since X?" fetch every few seconds, paused while + // the tab is hidden. New rows are inserted without touching the selected + // row or the preview, so what you're reading stays put. Respects the + // active source/label/kind/q filters (passed through below). + const LIVE_POLL_MS = 5000; + const NOW_TTL_MS = 3 * 60 * 1000; // show "now", then age into the real time + let liveSince = artifactList.dataset.since || null; + let livePolling = false; + + const buildUpdatesUrl = () => { + const url = new URL("{% url 'feed-updates' %}", window.location.origin); + const p = url.searchParams; + if (liveSince) p.set("after", liveSince); + p.set("view", feed.dataset.selectedView || "rendered"); + if (feed.dataset.selectedSource) p.set("source", feed.dataset.selectedSource); + if (feed.dataset.selectedLabel) p.set("label", feed.dataset.selectedLabel); + if (feed.dataset.selectedKind) p.set("kind", feed.dataset.selectedKind); + if (feed.dataset.titleQuery) p.set("q", feed.dataset.titleQuery); + return url; + }; + + const ensureTodayGroup = (todayLabel) => { + let group = artifactList.querySelector(".day-group[data-today]"); + if (group) return group; + const empty = artifactList.querySelector(".empty"); + if (empty) empty.remove(); + group = document.createElement("div"); + group.className = "day-group"; + group.setAttribute("data-today", "1"); + group.innerHTML = + `
${escapeHtml(todayLabel)} Today` + + `0
`; + artifactList.insertBefore(group, artifactList.firstChild); + return group; + }; + + const markArrived = (rows) => { + rows.forEach((row) => { + const timeEl = row.querySelector(".artifact-time"); + if (timeEl) { + timeEl.dataset.realtime = timeEl.textContent; + timeEl.textContent = "now"; + setTimeout(() => { + if (timeEl.dataset.realtime) timeEl.textContent = timeEl.dataset.realtime; + }, NOW_TTL_MS); + } + row.classList.add("just-arrived"); + setTimeout(() => row.classList.remove("just-arrived"), 1500); + }); + }; + + const pollUpdates = async () => { + if (livePolling || document.hidden || !liveSince) return; + livePolling = true; + try { + const response = await fetch(buildUpdatesUrl(), {headers: {"X-Requested-With": "fetch"}}); + if (!response.ok) return; + const data = await response.json(); + if (!data.count) return; + const group = ensureTodayGroup(data.today_label); + const separator = group.querySelector(".day-separator"); + // Newest-first batch goes immediately below the separator, above prior rows. + separator.insertAdjacentHTML("afterend", data.html); + const inserted = []; + let node = separator.nextElementSibling; + while (node && inserted.length < data.count) { + if (node.classList.contains("artifact-row")) inserted.push(node); + node = node.nextElementSibling; + } + markArrived(inserted); + liveSince = data.newest; + const dayCount = group.querySelector(".day-separator-count"); + if (dayCount) dayCount.textContent = String((parseInt(dayCount.textContent, 10) || 0) + data.count); + const header = document.querySelector(".artifact-sidebar .pane-heading span"); + if (header) { + const total = (parseInt(header.textContent, 10) || 0) + data.count; + header.textContent = `${total} artifact${total === 1 ? "" : "s"}`; + } + } catch { + // ignore; retry next tick + } finally { + livePolling = false; + } + }; + + setInterval(pollUpdates, LIVE_POLL_MS); + document.addEventListener("visibilitychange", () => { if (!document.hidden) pollUpdates(); }); + // Populate the details panel for a server-preselected artifact so the first // "Details" click shows content immediately. const preselected = selectedRow(); diff --git a/tests/views/test_feed_updates.py b/tests/views/test_feed_updates.py new file mode 100644 index 0000000..e1610af --- /dev/null +++ b/tests/views/test_feed_updates.py @@ -0,0 +1,75 @@ +from datetime import timedelta + +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import reverse +from django.utils import timezone + +from artifacts.models import Artifact + +User = get_user_model() + + +class FeedUpdatesViewTestCase(TestCase): + def setUp(self): + self.user = User.objects.create_user(username="alice", password="pw") + self.other = User.objects.create_user(username="bob", password="pw") + self.client.force_login(self.user) + self._n = 0 + + def make(self, owner=None, title="Note", source="agent"): + self._n += 1 + return Artifact.objects.create( + id=f"upd{self._n:05d}", + owner=owner or self.user, + source=source, + title=title, + kind=Artifact.MARKDOWN, + day=timezone.localdate(), + path=f"{source}/x.html", + original_kind=Artifact.MARKDOWN, + size_bytes=1, + ) + + def marker(self, seconds_ago=60): + return (timezone.now() - timedelta(seconds=seconds_ago)).isoformat() + + def get(self, **params): + return self.client.get(reverse("feed-updates"), params) + + def test_requires_login(self): + self.client.logout() + response = self.get(after=self.marker()) + self.assertEqual(response.status_code, 302) + + def test_returns_new_since_marker(self): + self.make(title="Fresh Post") + response = self.get(after=self.marker()) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data["count"], 1) + self.assertIn("Fresh Post", data["html"]) + self.assertIn("newest", data) + self.assertIn("today_label", data) + + def test_nothing_newer_than_now(self): + self.make() + response = self.get(after=self.marker(seconds_ago=-60)) # 60s in the future + self.assertEqual(response.json()["count"], 0) + + def test_missing_marker_returns_zero(self): + self.make() + self.assertEqual(self.get().json()["count"], 0) + + def test_owner_scoped(self): + self.make(owner=self.other, title="Not mine") + data = self.get(after=self.marker()).json() + self.assertEqual(data["count"], 0) + + def test_respects_source_filter(self): + self.make(title="Hermes post", source="hermes") + self.make(title="Janus post", source="janus") + data = self.get(after=self.marker(), source="hermes").json() + self.assertEqual(data["count"], 1) + self.assertIn("Hermes post", data["html"]) + self.assertNotIn("Janus post", data["html"])