From f15c440942cdf9bf203c79b158fa69ad156a513d Mon Sep 17 00:00:00 2001 From: Kristian Wind Date: Mon, 17 Aug 2026 17:12:53 +0200 Subject: [PATCH] feat(realtime): keep projects and labels current too, not only tasks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/httpapi/filters_handlers.go | 3 ++ internal/httpapi/projects_handlers.go | 3 ++ internal/httpapi/realtime_handlers.go | 13 ++++++ web/src/lib/components/Sidebar.svelte | 4 ++ web/src/lib/stores.svelte.js | 61 +++++++++++++++++++++++++-- 5 files changed, 81 insertions(+), 3 deletions(-) diff --git a/internal/httpapi/filters_handlers.go b/internal/httpapi/filters_handlers.go index c70e2d1..de855fc 100644 --- a/internal/httpapi/filters_handlers.go +++ b/internal/httpapi/filters_handlers.go @@ -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) } diff --git a/internal/httpapi/projects_handlers.go b/internal/httpapi/projects_handlers.go index 78cc74e..c99ec10 100644 --- a/internal/httpapi/projects_handlers.go +++ b/internal/httpapi/projects_handlers.go @@ -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) } diff --git a/internal/httpapi/realtime_handlers.go b/internal/httpapi/realtime_handlers.go index e7ac0f4..d5c66e9 100644 --- a/internal/httpapi/realtime_handlers.go +++ b/internal/httpapi/realtime_handlers.go @@ -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()) diff --git a/web/src/lib/components/Sidebar.svelte b/web/src/lib/components/Sidebar.svelte index 99374ae..dfcbabe 100644 --- a/web/src/lib/components/Sidebar.svelte +++ b/web/src/lib/components/Sidebar.svelte @@ -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(() => {}); }); diff --git a/web/src/lib/stores.svelte.js b/web/src/lib/stores.svelte.js index 34c8af9..7879a87 100644 --- a/web/src/lib/stores.svelte.js +++ b/web/src/lib/stores.svelte.js @@ -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));