v0.4.0: live feed updates (auto-insert new posts)

New artifacts appear at the top of the feed without a manual refresh.

- GET /feed/updates (session-authed): new artifacts since a timestamp marker,
  owner-scoped and filter-aware (source/label/kind/q), returned as row HTML.
- Feed JS polls every 5s, paused while the tab is hidden (immediate poll on
  refocus). Prepends new rows into today's group without touching the selected
  row or the preview iframe — what you're reading stays put and selected.
- Fresh rows show "now", aging into their real time after ~3 min; brief
  highlight on arrival.
- Extracted _artifact_rows.html partial, shared by the day-group render and the
  updates endpoint, so live rows match exactly.
- Polling chosen over SSE/WebSockets: zero infra on sync-WSGI + SQLite (see
  planning/next_steps for the SSE upgrade path). 142 tests pass; feed page +
  endpoint verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
peter 2026-09-04 23:02:07 +01:00
parent f6f004c689
commit cae414c307
9 changed files with 289 additions and 40 deletions

View file

@ -9,6 +9,23 @@ former name, "Daybook".)
_Nothing yet._ _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 ## [0.3.2] — 2026-09-04
### Added ### Added
@ -138,7 +155,8 @@ First tagged release.
- Dockerised (Gunicorn + whitenoise), SQLite, `git`-based deploy with a - Dockerised (Gunicorn + whitenoise), SQLite, `git`-based deploy with a
runbook (`deploy/cloud_agent_deploy.md`) and health check. 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.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.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 [0.3.0]: https://git.cygnus.systems/peter/daybook/compare/v0.2.1...v0.3.0

View file

@ -6,6 +6,7 @@ from . import views
urlpatterns = [ urlpatterns = [
path("", views.feed, name="feed"), path("", views.feed, name="feed"),
path("feed/older", views.feed_older, name="feed-older"), path("feed/older", views.feed_older, name="feed-older"),
path("feed/updates", views.feed_updates, name="feed-updates"),
path("a/<str:artifact_id>/read", views.mark_artifact_read, name="artifact-mark-read"), path("a/<str:artifact_id>/read", views.mark_artifact_read, name="artifact-mark-read"),
path("a/<str:artifact_id>/keep", views.toggle_artifact_keep, name="artifact-toggle-keep"), path("a/<str:artifact_id>/keep", views.toggle_artifact_keep, name="artifact-toggle-keep"),
path("a/<str:artifact_id>/share", views.toggle_artifact_share, name="artifact-toggle-share"), path("a/<str:artifact_id>/share", views.toggle_artifact_share, name="artifact-toggle-share"),

View file

@ -22,9 +22,10 @@ from django.db import OperationalError, transaction
from django.db.models import Count, Q, Sum from django.db.models import Count, Q, Sum
from django.http import FileResponse, Http404, HttpResponse, HttpResponseForbidden, JsonResponse from django.http import FileResponse, Http404, HttpResponse, HttpResponseForbidden, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render 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.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 import timezone
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.decorators.clickjacking import xframe_options_sameorigin from django.views.decorators.clickjacking import xframe_options_sameorigin
@ -573,6 +574,8 @@ def feed(request):
"day_groups": day_groups, "day_groups": day_groups,
"older_count": older_count, "older_count": older_count,
"window_start": window_start, "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, "source_counts": source_counts,
"total_count": source_scoped.filter(read_at__isnull=True).count(), "total_count": source_scoped.filter(read_at__isnull=True).count(),
"all_labels": labels_with_colors_list(all_labels, overrides), "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 @login_required
@xframe_options_sameorigin @xframe_options_sameorigin
def artifact_view(request, artifact_id): def artifact_view(request, artifact_id):

View file

@ -1,4 +1,4 @@
# Single source of truth for the app version. Bump this, commit, then tag the # 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 # commit `vX.Y.Z` and deploy. Surfaced on Settings -> About and in
# GET /api/capabilities ("version"). # GET /api/capabilities ("version").
__version__ = "0.3.2" __version__ = "0.4.0"

View file

@ -1045,6 +1045,16 @@ kbd {
color: var(--muted); 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 { .api-key-create {
align-items: end; align-items: end;
display: flex; display: flex;

View file

@ -0,0 +1,34 @@
{% for artifact in artifacts %}
<a class="artifact-row {% if selected_artifact.id == artifact.id %}selected{% endif %} {% if artifact.is_unread %}unread{% endif %} {% if artifact.is_kept %}kept{% endif %} {% if artifact.is_shared %}shared{% endif %}"
href="?{% if selected_source %}source={{ selected_source|urlencode }}&amp;{% endif %}{% if selected_label %}label={{ selected_label|urlencode }}&amp;{% endif %}{% if selected_kind %}kind={{ selected_kind|urlencode }}&amp;{% endif %}{% if title_query %}q={{ title_query|urlencode }}&amp;{% endif %}artifact={{ artifact.id }}&amp;view={{ selected_view }}"
data-artifact-id="{{ artifact.id }}"
data-title="{{ artifact.title }}"
data-kind="{{ artifact.kind }}"
data-source="{{ artifact.source }}"
data-source-type="{{ artifact.source_type }}"
data-source-name="{{ artifact.source_name }}"
data-created="{{ artifact.created_at|date:'M j Y, H:i' }}"
data-size="{{ artifact.size_bytes|filesizeformat }}"
data-kept="{{ artifact.is_kept|yesno:'1,0' }}"
data-shared="{{ artifact.is_shared|yesno:'1,0' }}"
data-share-url="{% if artifact.is_shared %}{{ request.scheme }}://{{ request.get_host }}{% url 'artifact-shared' artifact.share_token %}{% endif %}"
data-tags="{{ artifact.tags|join:', ' }}"
data-rendered-url="{% url 'artifact' artifact.id %}"
data-raw-url="{% url 'artifact' artifact.id %}?raw=1">
<time class="artifact-time" datetime="{{ artifact.created_at|date:'c' }}">{{ artifact.created_at|date:"H:i" }}</time>
<span class="artifact-row-main">
<span class="artifact-title">{{ artifact.title }}</span>
<span class="artifact-kind-text">{% 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 %}</span>
<span class="kept-badge" data-kept-badge title="Kept — never auto-deleted"{% if not artifact.is_kept %} hidden{% endif %}>K</span>
<span class="shared-badge" data-shared-badge title="Shared — anyone with the link can view"{% if not artifact.is_shared %} hidden{% endif %}>🔗</span>
{% if artifact.label_chips %}
<span class="tag-chips">
{% for label in artifact.label_chips %}
<span class="tag-chip" style="background:{{ label.color }}">{{ label.name }}</span>
{% endfor %}
</span>
{% endif %}
</span>
<span class="artifact-source">{{ artifact.source }}</span>
</a>
{% endfor %}

View file

@ -1,43 +1,10 @@
{% for group in day_groups %} {% for group in day_groups %}
<div class="day-group"> <div class="day-group"{% if group.day == today %} data-today="1"{% endif %}>
<div class="day-separator"> <div class="day-separator">
<span>{{ group.day|date:"D, M j Y" }}{% if group.day == today %} <em class="today-badge">Today</em>{% endif %}</span> <span>{{ group.day|date:"D, M j Y" }}{% if group.day == today %} <em class="today-badge">Today</em>{% endif %}</span>
<span class="day-separator-count">{{ group.artifacts|length }}</span> <span class="day-separator-count">{{ group.artifacts|length }}</span>
</div> </div>
{% for artifact in group.artifacts %} {% include "artifacts/_artifact_rows.html" with artifacts=group.artifacts %}
<a class="artifact-row {% if selected_artifact.id == artifact.id %}selected{% endif %} {% if artifact.is_unread %}unread{% endif %} {% if artifact.is_kept %}kept{% endif %} {% if artifact.is_shared %}shared{% endif %}"
href="?{% if selected_source %}source={{ selected_source|urlencode }}&amp;{% endif %}{% if selected_label %}label={{ selected_label|urlencode }}&amp;{% endif %}{% if selected_kind %}kind={{ selected_kind|urlencode }}&amp;{% endif %}{% if title_query %}q={{ title_query|urlencode }}&amp;{% endif %}artifact={{ artifact.id }}&amp;view={{ selected_view }}"
data-artifact-id="{{ artifact.id }}"
data-title="{{ artifact.title }}"
data-kind="{{ artifact.kind }}"
data-source="{{ artifact.source }}"
data-source-type="{{ artifact.source_type }}"
data-source-name="{{ artifact.source_name }}"
data-created="{{ artifact.created_at|date:'M j Y, H:i' }}"
data-size="{{ artifact.size_bytes|filesizeformat }}"
data-kept="{{ artifact.is_kept|yesno:'1,0' }}"
data-shared="{{ artifact.is_shared|yesno:'1,0' }}"
data-share-url="{% if artifact.is_shared %}{{ request.scheme }}://{{ request.get_host }}{% url 'artifact-shared' artifact.share_token %}{% endif %}"
data-tags="{{ artifact.tags|join:', ' }}"
data-rendered-url="{% url 'artifact' artifact.id %}"
data-raw-url="{% url 'artifact' artifact.id %}?raw=1">
<time class="artifact-time" datetime="{{ artifact.created_at|date:'c' }}">{{ artifact.created_at|date:"H:i" }}</time>
<span class="artifact-row-main">
<span class="artifact-title">{{ artifact.title }}</span>
<span class="artifact-kind-text">{% 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 %}</span>
<span class="kept-badge" data-kept-badge title="Kept — never auto-deleted"{% if not artifact.is_kept %} hidden{% endif %}>K</span>
<span class="shared-badge" data-shared-badge title="Shared — anyone with the link can view"{% if not artifact.is_shared %} hidden{% endif %}>🔗</span>
{% if artifact.label_chips %}
<span class="tag-chips">
{% for label in artifact.label_chips %}
<span class="tag-chip" style="background:{{ label.color }}">{{ label.name }}</span>
{% endfor %}
</span>
{% endif %}
</span>
<span class="artifact-source">{{ artifact.source }}</span>
</a>
{% endfor %}
</div> </div>
{% endfor %} {% endfor %}
{% if has_more %} {% if has_more %}

View file

@ -55,7 +55,7 @@
<span>{{ artifacts|length }} artifact{{ artifacts|length|pluralize }}</span> <span>{{ artifacts|length }} artifact{{ artifacts|length|pluralize }}</span>
</div> </div>
<div class="artifact-list{% if selected_source %} no-source-col{% endif %}" data-artifact-list> <div class="artifact-list{% if selected_source %} no-source-col{% endif %}" data-artifact-list data-since="{{ feed_since }}">
{% if day_groups %} {% if day_groups %}
{% include "artifacts/_day_groups.html" with has_more=older_count next_before=window_start|date:"Y-m-d" %} {% include "artifacts/_day_groups.html" with has_more=older_count next_before=window_start|date:"Y-m-d" %}
{% else %} {% else %}
@ -552,6 +552,95 @@
observeSentinel(); 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 =
`<div class="day-separator"><span>${escapeHtml(todayLabel)} <em class="today-badge">Today</em></span>` +
`<span class="day-separator-count">0</span></div>`;
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 // Populate the details panel for a server-preselected artifact so the first
// "Details" click shows content immediately. // "Details" click shows content immediately.
const preselected = selectedRow(); const preselected = selectedRow();

View file

@ -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"])