mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -4,16 +4,26 @@ import { useState } from "react";
|
||||
import { TaskStatus, Team } from "@/types";
|
||||
import { KanbanBoard } from "../core/kanban-board";
|
||||
|
||||
// The management board carries a column for every lifecycle state a human
|
||||
// overseer may need to drive a task into — including the recovery states
|
||||
// (paused / needs-revision / awaiting-CEO / cancelled) that automatic flow
|
||||
// never lands on. Dragging a card issues an admin status override, so the CEO
|
||||
// can recover a wedged task straight from the board. (verifying is omitted: a
|
||||
// transient dev-internal self-check state, not a destination a human sets.)
|
||||
const PM_COLUMNS = [
|
||||
{ id: "backlog", status: TaskStatus.BACKLOG, title: "Backlog", color: "bg-slate-50 dark:bg-slate-900" },
|
||||
{ id: "incoming", status: TaskStatus.PENDING, title: "Pending", color: "bg-gray-100 dark:bg-gray-800" },
|
||||
{ id: "assigned", status: TaskStatus.CLAIMED, title: "Assigned", color: "bg-blue-50 dark:bg-blue-950" },
|
||||
{ id: "in-progress", status: TaskStatus.IN_PROGRESS, title: "In Progress", color: "bg-blue-100 dark:bg-blue-900" },
|
||||
{ id: "blocked", status: TaskStatus.BLOCKED, title: "Blocked", color: "bg-red-100 dark:bg-red-900" },
|
||||
{ id: "paused", status: TaskStatus.PAUSED, title: "Paused", color: "bg-amber-50 dark:bg-amber-950" },
|
||||
{ id: "qa", status: TaskStatus.AWAITING_QA, title: "In QA", color: "bg-yellow-50 dark:bg-yellow-950" },
|
||||
{ id: "needs-revision", status: TaskStatus.NEEDS_REVISION, title: "Needs Revision", color: "bg-rose-50 dark:bg-rose-950" },
|
||||
{ id: "docs", status: TaskStatus.AWAITING_DOCUMENTATION, title: "In Docs", color: "bg-purple-50 dark:bg-purple-950" },
|
||||
{ id: "pm-review", status: TaskStatus.AWAITING_PM_REVIEW, title: "PM Review", color: "bg-orange-50 dark:bg-orange-950" },
|
||||
{ id: "ceo-approval", status: TaskStatus.AWAITING_CEO_APPROVAL, title: "CEO Approval", color: "bg-indigo-50 dark:bg-indigo-950" },
|
||||
{ id: "done", status: TaskStatus.COMPLETED, title: "Done", color: "bg-green-50 dark:bg-green-950" },
|
||||
{ id: "cancelled", status: TaskStatus.CANCELLED, title: "Cancelled", color: "bg-zinc-100 dark:bg-zinc-900" },
|
||||
];
|
||||
|
||||
interface PmKanbanProps {
|
||||
|
||||
@@ -58,7 +58,7 @@ export function NotificationBell() {
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{notifications.slice(-10).reverse().map((notification, i) => (
|
||||
<div
|
||||
key={i}
|
||||
key={notification.notification_id ?? `anon-${i}`}
|
||||
className="p-2 rounded bg-muted hover:bg-muted/80 cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user