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._ _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
@ -155,8 +138,7 @@ 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.4.0...HEAD [Unreleased]: https://git.cygnus.systems/peter/daybook/compare/v0.3.2...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,7 +6,6 @@ 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,10 +22,9 @@ 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 date as date_filter, slugify from django.template.defaultfilters import 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, parse_datetime from django.utils.dateparse import parse_date
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
@ -574,8 +573,6 @@ 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),
@ -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 @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.4.0" __version__ = "0.3.2"

View file

@ -1045,16 +1045,6 @@ 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

@ -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 %} {% 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"> <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>
{% 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> </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 data-since="{{ feed_since }}"> <div class="artifact-list{% if selected_source %} no-source-col{% endif %}" data-artifact-list>
{% 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,95 +552,6 @@
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

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