Compare commits

..

No commits in common. "v0.4.0" and "v0.3.2" have entirely different histories.

9 changed files with 40 additions and 289 deletions

View file

@ -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

View file

@ -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/<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>/share", views.toggle_artifact_share, name="artifact-toggle-share"),

View file

@ -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):

View file

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

View file

@ -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;

View file

@ -1,34 +0,0 @@
{% 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,10 +1,43 @@
{% for group in day_groups %}
<div class="day-group"{% if group.day == today %} data-today="1"{% endif %}>
<div class="day-group">
<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 class="day-separator-count">{{ group.artifacts|length }}</span>
</div>
{% include "artifacts/_artifact_rows.html" with artifacts=group.artifacts %}
{% for artifact in 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>
{% endfor %}
{% if has_more %}

View file

@ -55,7 +55,7 @@
<span>{{ artifacts|length }} artifact{{ artifacts|length|pluralize }}</span>
</div>
<div class="artifact-list{% if selected_source %} no-source-col{% endif %}" data-artifact-list data-since="{{ feed_since }}">
<div class="artifact-list{% if selected_source %} no-source-col{% endif %}" data-artifact-list>
{% 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 =
`<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
// "Details" click shows content immediately.
const preselected = selectedRow();

View file

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