diff --git a/CHANGELOG.md b/CHANGELOG.md index cf4ed70..26f8719 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,23 +9,6 @@ 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 @@ -155,8 +138,7 @@ 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.4.0...HEAD -[0.4.0]: https://git.cygnus.systems/peter/daybook/compare/v0.3.2...v0.4.0 +[Unreleased]: https://git.cygnus.systems/peter/daybook/compare/v0.3.2...HEAD [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 cc1f03c..008ceb5 100644 --- a/artifacts/urls.py +++ b/artifacts/urls.py @@ -6,7 +6,6 @@ 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 1a6afc3..d82eed9 100644 --- a/artifacts/views.py +++ b/artifacts/views.py @@ -22,10 +22,9 @@ 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 date as date_filter, slugify -from django.template.loader import render_to_string +from django.template.defaultfilters import slugify from django.urls import reverse -from django.utils.dateparse import parse_date, parse_datetime +from django.utils.dateparse import parse_date from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.clickjacking import xframe_options_sameorigin @@ -574,8 +573,6 @@ 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), @@ -669,58 +666,6 @@ 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 bc935aa..47770b1 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.4.0" +__version__ = "0.3.2" diff --git a/static/agentfeed.css b/static/agentfeed.css index 865c1a0..805b76b 100644 --- a/static/agentfeed.css +++ b/static/agentfeed.css @@ -1045,16 +1045,6 @@ 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 deleted file mode 100644 index 1125e01..0000000 --- a/templates/artifacts/_artifact_rows.html +++ /dev/null @@ -1,34 +0,0 @@ -{% 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 63971f4..111fb86 100644 --- a/templates/artifacts/_day_groups.html +++ b/templates/artifacts/_day_groups.html @@ -1,10 +1,43 @@ {% for group in day_groups %} -
+ {% endfor %} {% if has_more %} diff --git a/templates/artifacts/feed.html b/templates/artifacts/feed.html index a240ebc..55bfdb7 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,95 +552,6 @@ 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 deleted file mode 100644 index e1610af..0000000 --- a/tests/views/test_feed_updates.py +++ /dev/null @@ -1,75 +0,0 @@ -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"])