feat(realtime): keep projects and labels current too, not only tasks

The socket carried task events and nothing else, so a project or a label
created anywhere — another tab, another device, Claude through the
connector — did not appear until a reload. Tasks updated live and the
sidebar did not, which reads as the sidebar being broken rather than as a
gap in what is published.

Projects and labels go to the *user* rather than to a project. A project
that has just been created has no subscribers, because nobody was
watching a thing that did not exist a moment ago.

Labels are announced as "something changed" and re-read, rather than sent
as a payload. The sidebar wants each label's task count, which no single
event carries and which moves whenever a task gains or loses a label —
including through quick add, where "@regnskab" invents the label inside a
task write and no label event is sent at all. Comparing the label lists
on a task change is what catches that, without re-reading on every ticked
checkbox.

And projects now go through an upsert, because the same row arrives twice
— once as the response to the request that created it, once over the
socket. Appending both puts two ids into a keyed {#each}, which throws
and stops the sidebar rendering. The tasks already had that guard; adding
the project event without it reintroduced the identical bug, and the
smoke test caught it on the first run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-17 17:27:18 +02:00
committed by Kristian Wind
co-authored by Claude Opus 5
parent d37d240625
commit f15c440942
5 changed files with 81 additions and 3 deletions
+3
View File
@@ -245,6 +245,7 @@ func (s *Server) handleCreateLabel(w http.ResponseWriter, r *http.Request) {
s.internal(w, "create label", err)
return
}
s.publishToOwner(r, "label.changed", labelJSON{l.ID, l.Name, l.Color, l.SortOrder, 0})
writeJSON(w, http.StatusCreated, labelJSON{l.ID, l.Name, l.Color, l.SortOrder, 0})
}
@@ -263,6 +264,7 @@ func (s *Server) handleUpdateLabel(w http.ResponseWriter, r *http.Request) {
s.storeError(w, "update label", err)
return
}
s.publishToOwner(r, "label.changed", nil)
w.WriteHeader(http.StatusNoContent)
}
@@ -272,5 +274,6 @@ func (s *Server) handleDeleteLabel(w http.ResponseWriter, r *http.Request) {
s.storeError(w, "delete label", err)
return
}
s.publishToOwner(r, "label.changed", nil)
w.WriteHeader(http.StatusNoContent)
}
+3
View File
@@ -133,6 +133,7 @@ func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
return
}
s.activity(r, p.ID, "", "project.created", map[string]any{"name": p.Name})
s.publishToOwner(r, "project.created", toProjectJSON(*p))
writeJSON(w, http.StatusCreated, toProjectJSON(*p))
}
@@ -168,6 +169,7 @@ func (s *Server) handleUpdateProject(w http.ResponseWriter, r *http.Request) {
s.storeError(w, "get project", err)
return
}
s.publishToOwner(r, "project.updated", toProjectJSON(*p))
writeJSON(w, http.StatusOK, toProjectJSON(*p))
}
@@ -182,6 +184,7 @@ func (s *Server) handleDeleteProject(w http.ResponseWriter, r *http.Request) {
return
}
s.activity(r, projectID, "", "project.deleted", nil)
s.publishToOwner(r, "project.deleted", map[string]string{"id": projectID})
w.WriteHeader(http.StatusNoContent)
}
+13
View File
@@ -28,6 +28,19 @@ func (s *Server) publish(projectID, event string, payload any) {
s.hub.Publish(projectID, event, payload)
}
// publishToOwner sends a change that belongs to a person rather than to a
// project: their projects, their labels, their saved filters.
//
// Project membership is what the socket subscribes to, and a project that has
// just been created has no subscribers — the person is not watching a thing that
// did not exist a moment ago. So these go to the user instead, which also
// reaches their other devices.
func (s *Server) publishToOwner(r *http.Request, event string, payload any) {
if user := userFrom(r.Context()); user != nil {
s.hub.PublishToUser(user.ID, event, payload)
}
}
// handleWebSocket is the live-sync connection.
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
+4
View File
@@ -27,7 +27,11 @@
let filters = $state([]);
let labels = $state([]);
// Reads app.labelsChanged so the effect re-runs when a label is created,
// renamed or deleted anywhere — including in another tab, or by Claude
// through the connector.
$effect(() => {
app.labelsChanged;
api.listFilters().then((r) => (filters = r.filters)).catch(() => {});
api.listLabels().then((r) => (labels = r.labels)).catch(() => {});
});
+58 -3
View File
@@ -29,6 +29,16 @@ class AppState {
*/
detailId = $state(null);
/**
* Bumped whenever a label changes anywhere.
*
* A counter rather than the labels themselves: the sidebar wants each label's
* task count, which no single event carries and which changes whenever a task
* gains or loses one. Whoever is showing labels re-reads them; that is one
* small request against modelling a count in two places.
*/
labelsChanged = $state(0);
#socket = null;
#reconnectDelay = 1000;
@@ -110,6 +120,25 @@ class AppState {
case 'task.deleted':
this.tasks = this.tasks.filter((t) => t.id !== task?.id);
break;
// Projects and labels belong to a person rather than to a project, so
// they arrive on the user's own channel — a project that has just been
// created has no subscribers, because nobody was watching a thing that
// did not exist a moment ago.
case 'project.created':
case 'project.updated':
this.upsertProject(task);
break;
case 'project.deleted':
this.projects = this.projects.filter((p) => p.id !== task?.id);
break;
// The payload is not always the label — a rename says only that
// something changed — and the sidebar wants counts anyway, which no
// single event carries. Asking is cheaper than modelling it.
case 'label.changed':
this.labelsChanged++;
break;
}
}
@@ -136,6 +165,13 @@ class AppState {
* copy is not worth a moment's thought next to the class of bug it removes.
*/
replace(id, next) {
// A task can invent a label just by mentioning one — "@regnskab" in quick
// add creates it — and no label event is sent for that, because it happened
// inside a task write. Comparing the two lists is what keeps the sidebar
// honest without re-reading the labels on every completed checkbox.
const before = this.tasks.find((t) => t.id === id)?.labels ?? [];
if ((next?.labels ?? []).join() !== before.join()) this.labelsChanged++;
this.tasks = this.tasks.map((t) => (t.id === id ? next : t));
}
@@ -151,14 +187,33 @@ class AppState {
*/
upsert(task) {
if (!task?.id) return;
if (this.tasks.some((t) => t.id === task.id)) this.replace(task.id, task);
else this.tasks = [...this.tasks, task];
if (this.tasks.some((t) => t.id === task.id)) {
this.replace(task.id, task);
return;
}
if (task.labels?.length) this.labelsChanged++;
this.tasks = [...this.tasks, task];
}
get(id) {
return this.tasks.find((t) => t.id === id);
}
/**
* Adds a project, or replaces it if the list already has it.
*
* The same guard the tasks need, and for the same reason: a project arrives
* twice, once as the response to the request that created it and once over
* the socket. Appending both puts two rows with one id into a keyed `{#each}`,
* which throws and stops the sidebar rendering.
*/
upsertProject(project) {
if (!project?.id) return;
this.projects = this.projects.some((p) => p.id === project.id)
? this.projects.map((p) => (p.id === project.id ? project : p))
: [...this.projects, project];
}
/**
* Ticks a task off. The row is struck through and gone before the request
* leaves — which is the single most-repeated action in the app, and the one
@@ -233,7 +288,7 @@ class AppState {
async createProject(name) {
try {
const project = await api.createProject({ name });
this.projects = [...this.projects, project];
this.upsertProject(project);
return project;
} catch (e) {
this.toast(humanMessage(e));