Panel: de-dupe live notifications and expose recovery states on the board

Notification bell: the websocket stream was rendered with a plain filter, so
a stream replay after a reconnect surfaced — and counted — the same
notification twice, inflating the unread badge. De-duplicate by
notification_id (newest copy wins, arrival order preserved) and key the list
on the id instead of array index.

PM kanban: the board had no columns for the recovery states (paused,
needs-revision, awaiting-CEO-approval, cancelled), so a human had no way to
drive a wedged task into them from the UI. Add those columns; dragging a card
issues an admin status override, giving the CEO direct recovery from the board.
This commit is contained in:
Renn F
2026-06-08 04:47:35 +02:00
parent 9bbbd6627f
commit 69da8de4b5
3 changed files with 33 additions and 4 deletions
+22 -3
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useRef, useState, useCallback } from "react";
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
import {
WebSocketConnection,
getWebSocketUrl
@@ -185,8 +185,27 @@ export function useNotificationStream() {
true
);
// Filter to only notification events
const notifications = messages.filter((m) => m.type === "notification");
// Filter to notification events, de-duplicated by notification_id so a
// stream replay (e.g. after a websocket reconnect) does not surface — or
// count — the same notification twice. Walk newest→oldest keeping the most
// recent copy of each id, then restore arrival order. Events without an id
// (older payloads) are always kept.
const notifications = useMemo(() => {
const seen = new Set<string>();
const deduped: NotificationMessage[] = [];
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i];
if (m.type !== "notification") continue;
const id = m.notification_id;
if (id) {
if (seen.has(id)) continue;
seen.add(id);
}
deduped.push(m);
}
deduped.reverse();
return deduped;
}, [messages]);
return {
state,