diff --git a/CHANGELOG.md b/CHANGELOG.md
index cf4ed70..5e1ed0f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,16 @@ former name, "Daybook".)
_Nothing yet._
+## [0.4.1] — 2026-09-05
+
+### Changed
+- Live feed updates now also keep the **source sidebar** current: as new posts
+ arrive, per-source and total unread counts bump, and a source posting for the
+ first time is added to the list (alphabetically) — all derived client-side
+ from the polled rows, no extra request. Sources added live stay
+ keyboard-navigable. Caveat: while filtered to a single source, *other* new
+ sources still only appear on reload (the poll is scoped to the selected view).
+
## [0.4.0] — 2026-09-04
### Added
@@ -155,7 +165,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.4.0...HEAD
+[Unreleased]: https://git.cygnus.systems/peter/daybook/compare/v0.4.1...HEAD
+[0.4.1]: https://git.cygnus.systems/peter/daybook/compare/v0.4.0...v0.4.1
[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
diff --git a/daybook_project/__init__.py b/daybook_project/__init__.py
index bc935aa..53f5709 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.4.1"
diff --git a/templates/artifacts/feed.html b/templates/artifacts/feed.html
index a240ebc..78764fe 100644
--- a/templates/artifacts/feed.html
+++ b/templates/artifacts/feed.html
@@ -195,7 +195,8 @@
const artifactList = document.querySelector("[data-artifact-list]");
// Rows grow as older pages load, so always read them live rather than caching an array.
const allRows = () => Array.from(document.querySelectorAll(".artifact-row"));
- const sourceLinks = Array.from(document.querySelectorAll(".source-link"));
+ // Live-queried so sources added by live updates are keyboard-navigable too.
+ const sourceLinks = () => Array.from(document.querySelectorAll(".source-link"));
const csrfToken = document.querySelector("[name=csrfmiddlewaretoken]")?.value || "";
const escapeHtml = (value) => value.replace(/[&<>"]/g, (character) => ({
@@ -383,6 +384,66 @@
});
};
+ const findSourceLink = (name) =>
+ sourceLinks().find((link) => link.querySelector(".source-name")?.textContent.trim() === name);
+
+ const bumpSourceBadge = (link, delta) => {
+ let badge = link.querySelector(".source-count");
+ if (!badge) {
+ badge = document.createElement("span");
+ badge.className = "source-count";
+ badge.textContent = "0";
+ link.appendChild(badge);
+ }
+ badge.textContent = String((Number(badge.textContent) || 0) + delta);
+ };
+
+ const addSourceLink = (name, count) => {
+ const params = new URLSearchParams();
+ params.set("source", name);
+ if (feed.dataset.selectedLabel) params.set("label", feed.dataset.selectedLabel);
+ if (feed.dataset.selectedKind) params.set("kind", feed.dataset.selectedKind);
+ if (feed.dataset.titleQuery) params.set("q", feed.dataset.titleQuery);
+ params.set("view", feed.dataset.selectedView || "rendered");
+ params.set("preview", "none");
+
+ const link = document.createElement("a");
+ link.className = "source-link";
+ link.href = `?${params.toString()}`;
+ link.innerHTML =
+ `${escapeHtml(name)}` +
+ `${count}`;
+
+ // Insert alphabetically among the per-source links (after "All sources").
+ const list = document.querySelector(".source-list");
+ const others = sourceLinks().filter(
+ (l) => l.querySelector(".source-name")?.textContent.trim() !== "All sources",
+ );
+ const after = others.find(
+ (l) => (l.querySelector(".source-name")?.textContent.trim() || "") > name,
+ );
+ list.insertBefore(link, after || null);
+ };
+
+ // From the newly-arrived rows: bump each source's unread count (adding a
+ // sidebar entry for a source seen for the first time) plus the total.
+ const registerNewSources = (rows) => {
+ const counts = {};
+ rows.forEach((row) => {
+ const source = row.dataset.source;
+ if (source) counts[source] = (counts[source] || 0) + 1;
+ });
+ let total = 0;
+ Object.entries(counts).forEach(([source, n]) => {
+ total += n;
+ const existing = findSourceLink(source);
+ if (existing) bumpSourceBadge(existing, n);
+ else addSourceLink(source, n);
+ });
+ const allLink = findSourceLink("All sources");
+ if (allLink && total) bumpSourceBadge(allLink, total);
+ };
+
const renderPreview = (row, pushState = true) => {
navigationMode = "artifacts";
markRead(row);
@@ -438,12 +499,13 @@
};
const moveSource = (direction) => {
- if (!sourceLinks.length) return;
+ const links = sourceLinks();
+ if (!links.length) return;
navigationMode = "sources";
const current = document.querySelector(".source-link.selected");
- const currentIndex = current ? sourceLinks.indexOf(current) : 0;
- const nextIndex = Math.max(0, Math.min(sourceLinks.length - 1, currentIndex + direction));
- const nextSource = sourceLinks[nextIndex];
+ const currentIndex = current ? links.indexOf(current) : 0;
+ const nextIndex = Math.max(0, Math.min(links.length - 1, currentIndex + direction));
+ const nextSource = links[nextIndex];
if (nextSource && nextSource !== current) window.location.href = nextSource.href;
};
@@ -623,6 +685,7 @@
node = node.nextElementSibling;
}
markArrived(inserted);
+ registerNewSources(inserted); // update sidebar: counts + any new source
liveSince = data.newest;
const dayCount = group.querySelector(".day-separator-count");
if (dayCount) dayCount.textContent = String((parseInt(dayCount.textContent, 10) || 0) + data.count);