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:
committed by
Kristian Wind
co-authored by
Claude Opus 5
parent
d37d240625
commit
f15c440942
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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(() => {});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user