feat(FN-3315): add test coverage for archived insights in three test suites
Adds test coverage for archived insights across the core store, `InsightsView` component, and `useInsights` hook — 156 lines of new tests covering FN-3315. Fusion-Task-Id: FN-3315
This commit is contained in:
@@ -120,7 +120,7 @@ const INSIGHT_CATEGORIES: InsightCategory[] = [
|
||||
"trends",
|
||||
];
|
||||
|
||||
const INSIGHT_STATUSES: InsightStatus[] = ["generated", "confirmed", "stale", "dismissed"];
|
||||
const INSIGHT_STATUSES: InsightStatus[] = ["generated", "confirmed", "stale", "dismissed", "archived"];
|
||||
const INSIGHT_RUN_STATUSES: InsightRunStatus[] = ["pending", "running", "completed", "failed", "cancelled"];
|
||||
const INSIGHT_RUN_TRIGGERS: InsightRunTrigger[] = ["schedule", "manual", "task_completion", "merge_event", "api"];
|
||||
|
||||
|
||||
@@ -173,10 +173,15 @@ describe("InsightStore", () => {
|
||||
it("filters by status", () => {
|
||||
store.createInsight("proj", { title: "A", category: "quality", status: "confirmed", provenance: createProvenance() });
|
||||
store.createInsight("proj", { title: "B", category: "quality", status: "generated", provenance: createProvenance() });
|
||||
store.createInsight("proj", { title: "C", category: "quality", status: "archived", provenance: createProvenance() });
|
||||
|
||||
const list = store.listInsights({ projectId: "proj", status: "confirmed" });
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0].title).toBe("A");
|
||||
|
||||
const archived = store.listInsights({ projectId: "proj", status: "archived" });
|
||||
expect(archived).toHaveLength(1);
|
||||
expect(archived[0].title).toBe("C");
|
||||
});
|
||||
|
||||
it("supports pagination with limit and offset", () => {
|
||||
@@ -250,6 +255,18 @@ describe("InsightStore", () => {
|
||||
expect(updated!.updatedAt >= original.createdAt).toBe(true);
|
||||
});
|
||||
|
||||
it("updates status to archived", () => {
|
||||
const original = store.createInsight("proj", {
|
||||
title: "Archive me",
|
||||
category: "quality",
|
||||
status: "confirmed",
|
||||
provenance: createProvenance(),
|
||||
});
|
||||
|
||||
const updated = store.updateInsight(original.id, { status: "archived" });
|
||||
expect(updated?.status).toBe("archived");
|
||||
});
|
||||
|
||||
it("returns undefined for non-existent insight", () => {
|
||||
const result = store.updateInsight("INS-NOTFOUND", { title: "X" });
|
||||
expect(result).toBeUndefined();
|
||||
|
||||
@@ -60,11 +60,15 @@ export type InsightCategory =
|
||||
* generated → confirmed → stale
|
||||
* ↓
|
||||
* dismissed
|
||||
* ↕
|
||||
* archived
|
||||
*
|
||||
* A "stale" insight has been superseded or is no longer relevant.
|
||||
* A "dismissed" insight was manually rejected by a reviewer.
|
||||
* An "archived" insight was actioned or intentionally hidden, and can be
|
||||
* unarchived back to "confirmed".
|
||||
*/
|
||||
export type InsightStatus = "generated" | "confirmed" | "stale" | "dismissed";
|
||||
export type InsightStatus = "generated" | "confirmed" | "stale" | "dismissed" | "archived";
|
||||
|
||||
// ── Provenance Metadata ───────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -8257,6 +8257,24 @@ export function dismissInsight(id: string, projectId?: string): Promise<Insight>
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive an insight (set status to archived).
|
||||
*/
|
||||
export function archiveInsight(id: string, projectId?: string): Promise<Insight> {
|
||||
return api<Insight>(withProjectId(`/insights/${encodeURIComponent(id)}/archive`, projectId), {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Unarchive an insight (set status back to confirmed).
|
||||
*/
|
||||
export function unarchiveInsight(id: string, projectId?: string): Promise<Insight> {
|
||||
return api<Insight>(withProjectId(`/insights/${encodeURIComponent(id)}/unarchive`, projectId), {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a manual insight generation run.
|
||||
*/
|
||||
|
||||
@@ -419,6 +419,25 @@
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.insight-item-status--archived {
|
||||
background: color-mix(in srgb, var(--text-dim) 15%, transparent);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.insight-item--archived {
|
||||
opacity: 0.6;
|
||||
border-left-color: var(--text-dim);
|
||||
}
|
||||
|
||||
.insight-item--archived .insight-item-title {
|
||||
text-decoration: line-through;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.insight-item--archived .insight-item-content {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.insight-item-date {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -539,6 +558,13 @@
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.insights-show-archived-toggle {
|
||||
max-width: 180px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.insights-view-close {
|
||||
min-height: 36px;
|
||||
min-width: 36px;
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
TrendingUp,
|
||||
ExternalLink,
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
Clock,
|
||||
} from "lucide-react";
|
||||
import { useInsights, type InsightSection } from "../hooks/useInsights";
|
||||
@@ -63,9 +64,16 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask }: Ins
|
||||
runInsights,
|
||||
dismiss,
|
||||
createTask: createTaskFromInsight,
|
||||
archive = async () => {},
|
||||
unarchive = async () => {},
|
||||
toggleShowArchived = () => {},
|
||||
dismissStates,
|
||||
createTaskStates,
|
||||
archiveStates = new Map(),
|
||||
unarchiveStates = new Map(),
|
||||
totalCount,
|
||||
archivedCount = 0,
|
||||
showArchived = false,
|
||||
} = useInsights(projectId);
|
||||
|
||||
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
||||
@@ -137,6 +145,44 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask }: Ins
|
||||
[dismiss, addToast],
|
||||
);
|
||||
|
||||
const handleArchive = useCallback(
|
||||
async (id: string, title: string) => {
|
||||
try {
|
||||
setStatusMessage(`Archiving "${title}"...`);
|
||||
setStatusType("info");
|
||||
await archive(id);
|
||||
setStatusMessage(`Archived "${title}"`);
|
||||
setStatusType("success");
|
||||
addToast(`Insight archived: ${title}`, "success");
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to archive insight";
|
||||
setStatusMessage(message);
|
||||
setStatusType("error");
|
||||
addToast(message, "error");
|
||||
}
|
||||
},
|
||||
[archive, addToast],
|
||||
);
|
||||
|
||||
const handleUnarchive = useCallback(
|
||||
async (id: string, title: string) => {
|
||||
try {
|
||||
setStatusMessage(`Unarchiving "${title}"...`);
|
||||
setStatusType("info");
|
||||
await unarchive(id);
|
||||
setStatusMessage(`Unarchived "${title}"`);
|
||||
setStatusType("success");
|
||||
addToast(`Insight unarchived: ${title}`, "success");
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to unarchive insight";
|
||||
setStatusMessage(message);
|
||||
setStatusType("error");
|
||||
addToast(message, "error");
|
||||
}
|
||||
},
|
||||
[unarchive, addToast],
|
||||
);
|
||||
|
||||
const handleCreateTask = useCallback(
|
||||
async (id: string, title: string) => {
|
||||
try {
|
||||
@@ -213,31 +259,61 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask }: Ins
|
||||
{activeSection.items.map((insight) => {
|
||||
const dismissState = dismissStates.get(insight.id);
|
||||
const createState = createTaskStates.get(insight.id);
|
||||
const archiveState = archiveStates.get(insight.id);
|
||||
const unarchiveState = unarchiveStates.get(insight.id);
|
||||
const isDismissInFlight = dismissState?.running ?? false;
|
||||
const isCreateInFlight = createState?.running ?? false;
|
||||
const isArchiveInFlight = archiveState?.running ?? false;
|
||||
const isUnarchiveInFlight = unarchiveState?.running ?? false;
|
||||
const isArchived = insight.status === "archived";
|
||||
const isAnyActionInFlight = activeSection.items.some(
|
||||
(item) => dismissStates.get(item.id)?.running || createTaskStates.get(item.id)?.running,
|
||||
(item) =>
|
||||
dismissStates.get(item.id)?.running ||
|
||||
createTaskStates.get(item.id)?.running ||
|
||||
archiveStates.get(item.id)?.running ||
|
||||
unarchiveStates.get(item.id)?.running,
|
||||
);
|
||||
|
||||
return (
|
||||
<li key={insight.id} className="insight-item" data-insight-id={insight.id}>
|
||||
<li key={insight.id} className={`insight-item${isArchived ? " insight-item--archived" : ""}`} data-insight-id={insight.id}>
|
||||
<div className="insight-item-header">
|
||||
<h4 className="insight-item-title">{insight.title}</h4>
|
||||
<div className="insight-item-actions">
|
||||
<button
|
||||
className="insight-item-action-btn"
|
||||
onClick={() => void handleCreateTask(insight.id, insight.title)}
|
||||
disabled={isCreateInFlight || isAnyActionInFlight}
|
||||
title="Create task from this insight"
|
||||
aria-label="Create task from this insight"
|
||||
data-testid={`create-task-${insight.id}`}
|
||||
>
|
||||
{isCreateInFlight ? (
|
||||
<RefreshCw size={20} className="spin" />
|
||||
) : (
|
||||
<Plus size={20} />
|
||||
)}
|
||||
</button>
|
||||
{isArchived ? (
|
||||
<button
|
||||
className="insight-item-action-btn"
|
||||
onClick={() => void handleUnarchive(insight.id, insight.title)}
|
||||
disabled={isUnarchiveInFlight || isAnyActionInFlight}
|
||||
title="Unarchive this insight"
|
||||
aria-label="Unarchive this insight"
|
||||
data-testid={`unarchive-${insight.id}`}
|
||||
>
|
||||
{isUnarchiveInFlight ? <RefreshCw size={20} className="spin" /> : <ArchiveRestore size={20} />}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
className="insight-item-action-btn"
|
||||
onClick={() => void handleCreateTask(insight.id, insight.title)}
|
||||
disabled={isCreateInFlight || isAnyActionInFlight}
|
||||
title="Create task from this insight"
|
||||
aria-label="Create task from this insight"
|
||||
data-testid={`create-task-${insight.id}`}
|
||||
>
|
||||
{isCreateInFlight ? <RefreshCw size={20} className="spin" /> : <Plus size={20} />}
|
||||
</button>
|
||||
<button
|
||||
className="insight-item-action-btn"
|
||||
onClick={() => void handleArchive(insight.id, insight.title)}
|
||||
disabled={isArchiveInFlight || isAnyActionInFlight}
|
||||
title="Archive this insight"
|
||||
aria-label="Archive this insight"
|
||||
data-testid={`archive-${insight.id}`}
|
||||
>
|
||||
{isArchiveInFlight ? <RefreshCw size={20} className="spin" /> : <Archive size={20} />}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className="insight-item-action-btn"
|
||||
onClick={() => void handleDismiss(insight.id, insight.title)}
|
||||
@@ -299,6 +375,17 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask }: Ins
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
{archivedCount > 0 && (
|
||||
<button
|
||||
className="btn btn-sm insights-show-archived-toggle"
|
||||
onClick={toggleShowArchived}
|
||||
aria-label={showArchived ? "Hide archived insights" : "Show archived insights"}
|
||||
data-testid="toggle-archived-insights"
|
||||
>
|
||||
<Archive size={14} />
|
||||
{showArchived ? "Hide Archived" : `Show Archived (${archivedCount})`}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => void refresh()}
|
||||
|
||||
@@ -63,6 +63,9 @@ vi.mock("lucide-react", () => ({
|
||||
Archive: ({ size = 24, className = "" }: { size?: number; className?: string }) => (
|
||||
<span data-testid="archive-icon" className={className}>{`Archive-${size}`}</span>
|
||||
),
|
||||
ArchiveRestore: ({ size = 24, className = "" }: { size?: number; className?: string }) => (
|
||||
<span data-testid="archive-restore-icon" className={className}>{`ArchiveRestore-${size}`}</span>
|
||||
),
|
||||
Clock: ({ size = 24, className = "" }: { size?: number; className?: string }) => (
|
||||
<span data-testid="clock-icon" className={className}>{`Clock-${size}`}</span>
|
||||
),
|
||||
@@ -100,10 +103,17 @@ describe("InsightsView", () => {
|
||||
runInsights: vi.fn(),
|
||||
dismiss: vi.fn(),
|
||||
createTask: vi.fn(),
|
||||
archive: vi.fn(),
|
||||
unarchive: vi.fn(),
|
||||
toggleShowArchived: vi.fn(),
|
||||
dismissStates: new Map(),
|
||||
createTaskStates: new Map(),
|
||||
archiveStates: new Map(),
|
||||
unarchiveStates: new Map(),
|
||||
totalCount: 0,
|
||||
dismissedCount: 0,
|
||||
archivedCount: 0,
|
||||
showArchived: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1014,6 +1024,66 @@ describe("InsightsView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("archived insights", () => {
|
||||
it("renders archived insights with archived class and unarchive button", () => {
|
||||
const sectionsWithArchived = [
|
||||
{
|
||||
category: "features" as const,
|
||||
label: "Features",
|
||||
items: [
|
||||
{
|
||||
id: "INS-ARCH",
|
||||
projectId: "test",
|
||||
title: "Archived Insight",
|
||||
content: "Archived content",
|
||||
category: "features" as const,
|
||||
status: "archived" as const,
|
||||
fingerprint: "fp-arch",
|
||||
provenance: { trigger: "manual" as const },
|
||||
lastRunId: null,
|
||||
createdAt: "2024-01-01T00:00:00Z",
|
||||
updatedAt: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
},
|
||||
...mockSections.slice(1),
|
||||
];
|
||||
|
||||
mockUseInsights.mockReturnValue({
|
||||
sections: sectionsWithArchived,
|
||||
loading: false,
|
||||
error: null,
|
||||
latestRun: null,
|
||||
isRunInFlight: false,
|
||||
runError: null,
|
||||
refresh: vi.fn(),
|
||||
runInsights: vi.fn(),
|
||||
dismiss: vi.fn(),
|
||||
createTask: vi.fn(),
|
||||
archive: vi.fn(),
|
||||
unarchive: vi.fn(),
|
||||
toggleShowArchived: vi.fn(),
|
||||
dismissStates: new Map(),
|
||||
createTaskStates: new Map(),
|
||||
archiveStates: new Map(),
|
||||
unarchiveStates: new Map(),
|
||||
totalCount: 1,
|
||||
dismissedCount: 0,
|
||||
archivedCount: 1,
|
||||
showArchived: true,
|
||||
});
|
||||
|
||||
render(<InsightsView {...defaultProps} />);
|
||||
|
||||
const item = screen.getByText("Archived Insight").closest("li");
|
||||
expect(item?.className).toContain("insight-item--archived");
|
||||
expect(screen.getByTestId("unarchive-INS-ARCH")).toBeTruthy();
|
||||
expect(screen.getByTestId("toggle-archived-insights")).toHaveTextContent("Hide Archived");
|
||||
});
|
||||
});
|
||||
|
||||
describe("in-flight disable behavior", () => {
|
||||
it("should disable dismiss button only for insight being dismissed", async () => {
|
||||
const sectionsWithInsight = [
|
||||
|
||||
@@ -11,6 +11,8 @@ import type { InsightCategory, InsightStatus } from "@fusion/core";
|
||||
vi.mock("../../api", () => ({
|
||||
fetchInsights: vi.fn(),
|
||||
dismissInsight: vi.fn(),
|
||||
archiveInsight: vi.fn(),
|
||||
unarchiveInsight: vi.fn(),
|
||||
triggerInsightRun: vi.fn(),
|
||||
fetchInsightRuns: vi.fn(),
|
||||
getInsightCreateTaskData: vi.fn(),
|
||||
@@ -40,6 +42,8 @@ vi.mock("lucide-react", () => ({
|
||||
import {
|
||||
fetchInsights,
|
||||
dismissInsight,
|
||||
archiveInsight,
|
||||
unarchiveInsight,
|
||||
triggerInsightRun,
|
||||
fetchInsightRuns,
|
||||
getInsightCreateTaskData,
|
||||
@@ -47,6 +51,8 @@ import {
|
||||
|
||||
const mockFetchInsights = vi.mocked(fetchInsights);
|
||||
const mockDismissInsight = vi.mocked(dismissInsight);
|
||||
const mockArchiveInsight = vi.mocked(archiveInsight);
|
||||
const mockUnarchiveInsight = vi.mocked(unarchiveInsight);
|
||||
const mockTriggerInsightRun = vi.mocked(triggerInsightRun);
|
||||
const mockFetchInsightRuns = vi.mocked(fetchInsightRuns);
|
||||
const mockGetInsightCreateTaskData = vi.mocked(getInsightCreateTaskData);
|
||||
@@ -453,6 +459,7 @@ describe("useInsights", () => {
|
||||
});
|
||||
|
||||
expect(mockGetInsightCreateTaskData).toHaveBeenCalledWith("INS-1", "project-1");
|
||||
expect(mockArchiveInsight).toHaveBeenCalledWith("INS-1", "project-1");
|
||||
expect(taskData).toEqual({
|
||||
title: "Implement Feature X",
|
||||
description: "Detailed description of the feature",
|
||||
@@ -492,6 +499,74 @@ describe("useInsights", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("archive lifecycle", () => {
|
||||
it("archives and unarchives an insight", async () => {
|
||||
const insight = {
|
||||
id: "INS-1",
|
||||
projectId: "project-1",
|
||||
title: "Keep me",
|
||||
content: "Content",
|
||||
category: "features" as InsightCategory,
|
||||
status: "confirmed" as InsightStatus,
|
||||
fingerprint: "fp1",
|
||||
provenance: { trigger: "manual" },
|
||||
lastRunId: null,
|
||||
createdAt: "2024-01-01T00:00:00Z",
|
||||
updatedAt: "2024-01-01T00:00:00Z",
|
||||
};
|
||||
mockFetchInsights.mockResolvedValue({ insights: [insight], count: 1 });
|
||||
mockFetchInsightRuns.mockResolvedValue({ runs: [] });
|
||||
mockArchiveInsight.mockResolvedValue({ ...insight, status: "archived" as InsightStatus });
|
||||
mockUnarchiveInsight.mockResolvedValue({ ...insight, status: "confirmed" as InsightStatus });
|
||||
|
||||
const { result } = renderHook(() => useInsights("project-1"));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.archive("INS-1");
|
||||
});
|
||||
expect(mockArchiveInsight).toHaveBeenCalledWith("INS-1", "project-1");
|
||||
expect(result.current.archivedCount).toBe(1);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.unarchive("INS-1");
|
||||
});
|
||||
expect(mockUnarchiveInsight).toHaveBeenCalledWith("INS-1", "project-1");
|
||||
expect(result.current.archivedCount).toBe(0);
|
||||
});
|
||||
|
||||
it("toggleShowArchived hides and shows archived insights", async () => {
|
||||
const archivedInsight = {
|
||||
id: "INS-A",
|
||||
projectId: "project-1",
|
||||
title: "Archived insight",
|
||||
content: "Hidden by default",
|
||||
category: "features" as InsightCategory,
|
||||
status: "archived" as InsightStatus,
|
||||
fingerprint: "fpA",
|
||||
provenance: { trigger: "manual" },
|
||||
lastRunId: null,
|
||||
createdAt: "2024-01-01T00:00:00Z",
|
||||
updatedAt: "2024-01-01T00:00:00Z",
|
||||
};
|
||||
mockFetchInsights.mockResolvedValue({ insights: [archivedInsight], count: 1 });
|
||||
mockFetchInsightRuns.mockResolvedValue({ runs: [] });
|
||||
|
||||
const { result } = renderHook(() => useInsights("project-1"));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.archivedCount).toBe(1);
|
||||
expect(result.current.totalCount).toBe(0);
|
||||
|
||||
act(() => {
|
||||
result.current.toggleShowArchived();
|
||||
});
|
||||
|
||||
expect(result.current.showArchived).toBe(true);
|
||||
expect(result.current.totalCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("refresh", () => {
|
||||
it("should reload insights data", async () => {
|
||||
mockFetchInsights
|
||||
|
||||
@@ -13,6 +13,8 @@ import type { Insight, InsightCategory, InsightStatus, InsightRun } from "@fusio
|
||||
import {
|
||||
fetchInsights,
|
||||
dismissInsight,
|
||||
archiveInsight,
|
||||
unarchiveInsight,
|
||||
triggerInsightRun,
|
||||
fetchInsightRuns,
|
||||
getInsightCreateTaskData,
|
||||
@@ -63,6 +65,7 @@ export const STATUS_LABELS: Record<InsightStatus, string> = {
|
||||
confirmed: "Confirmed",
|
||||
stale: "Stale",
|
||||
dismissed: "Dismissed",
|
||||
archived: "Archived",
|
||||
};
|
||||
|
||||
// Section data structure
|
||||
@@ -99,14 +102,21 @@ export interface UseInsightsResult {
|
||||
runInsights: () => Promise<void>;
|
||||
dismiss: (id: string) => Promise<void>;
|
||||
createTask: (id: string) => Promise<{ title: string; description: string } | null>;
|
||||
archive: (id: string) => Promise<void>;
|
||||
unarchive: (id: string) => Promise<void>;
|
||||
toggleShowArchived: () => void;
|
||||
|
||||
// Per-insight action states
|
||||
dismissStates: Map<string, InsightActionState>;
|
||||
createTaskStates: Map<string, InsightActionState>;
|
||||
archiveStates: Map<string, InsightActionState>;
|
||||
unarchiveStates: Map<string, InsightActionState>;
|
||||
|
||||
// Dismissed/filtered counts
|
||||
totalCount: number;
|
||||
dismissedCount: number;
|
||||
archivedCount: number;
|
||||
showArchived: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -116,6 +126,8 @@ export interface UseInsightsResult {
|
||||
* @returns Insights data and action handlers
|
||||
*/
|
||||
export function useInsights(projectId?: string): UseInsightsResult {
|
||||
const [allInsights, setAllInsights] = useState<Insight[]>([]);
|
||||
|
||||
// Section items (keyed by category)
|
||||
const [sections, setSections] = useState<InsightSection[]>(() =>
|
||||
INSIGHT_CATEGORIES.map((category) => ({
|
||||
@@ -141,6 +153,9 @@ export function useInsights(projectId?: string): UseInsightsResult {
|
||||
// Per-insight action states
|
||||
const [dismissStates, setDismissStates] = useState<Map<string, InsightActionState>>(new Map());
|
||||
const [createTaskStates, setCreateTaskStates] = useState<Map<string, InsightActionState>>(new Map());
|
||||
const [archiveStates, setArchiveStates] = useState<Map<string, InsightActionState>>(new Map());
|
||||
const [unarchiveStates, setUnarchiveStates] = useState<Map<string, InsightActionState>>(new Map());
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
|
||||
// Refresh function
|
||||
const refresh = useCallback(async () => {
|
||||
@@ -154,32 +169,7 @@ export function useInsights(projectId?: string): UseInsightsResult {
|
||||
projectId,
|
||||
);
|
||||
|
||||
// Group by category
|
||||
const grouped = new Map<InsightCategory, Insight[]>();
|
||||
|
||||
// Initialize all categories
|
||||
for (const category of INSIGHT_CATEGORIES) {
|
||||
grouped.set(category, []);
|
||||
}
|
||||
|
||||
// Group non-dismissed insights
|
||||
for (const insight of response.insights) {
|
||||
if (insight.status !== "dismissed") {
|
||||
const existing = grouped.get(insight.category) ?? [];
|
||||
grouped.set(insight.category, [...existing, insight]);
|
||||
}
|
||||
}
|
||||
|
||||
// Update sections
|
||||
setSections(
|
||||
INSIGHT_CATEGORIES.map((category) => ({
|
||||
category,
|
||||
label: CATEGORY_LABELS[category] ?? category,
|
||||
items: grouped.get(category) ?? [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})),
|
||||
);
|
||||
setAllInsights(response.insights.filter((insight) => insight.status !== "dismissed"));
|
||||
|
||||
// Fetch latest run
|
||||
const runsResponse = await fetchInsightRuns(projectId);
|
||||
@@ -194,6 +184,10 @@ export function useInsights(projectId?: string): UseInsightsResult {
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const toggleShowArchived = useCallback(() => {
|
||||
setShowArchived((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
// Run insights generation
|
||||
const runInsights = useCallback(async () => {
|
||||
setIsRunInFlight(true);
|
||||
@@ -230,12 +224,7 @@ export function useInsights(projectId?: string): UseInsightsResult {
|
||||
await dismissInsight(id, projectId);
|
||||
|
||||
// Update local state
|
||||
setSections((prev) =>
|
||||
prev.map((section) => ({
|
||||
...section,
|
||||
items: section.items.filter((item) => item.id !== id),
|
||||
})),
|
||||
);
|
||||
setAllInsights((prev) => prev.filter((item) => item.id !== id));
|
||||
|
||||
setDismissStates((prev) => {
|
||||
const next = new Map(prev);
|
||||
@@ -266,6 +255,16 @@ export function useInsights(projectId?: string): UseInsightsResult {
|
||||
|
||||
try {
|
||||
const data = await getInsightCreateTaskData(id, projectId);
|
||||
await archiveInsight(id, projectId);
|
||||
setAllInsights((prev) => prev.map((insight) =>
|
||||
insight.id === id
|
||||
? {
|
||||
...insight,
|
||||
status: "archived",
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
: insight,
|
||||
));
|
||||
setCreateTaskStates((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(id, { running: false, error: null });
|
||||
@@ -288,15 +287,106 @@ export function useInsights(projectId?: string): UseInsightsResult {
|
||||
[projectId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const grouped = new Map<InsightCategory, Insight[]>();
|
||||
|
||||
for (const category of INSIGHT_CATEGORIES) {
|
||||
grouped.set(category, []);
|
||||
}
|
||||
|
||||
for (const insight of allInsights) {
|
||||
if (!showArchived && insight.status === "archived") {
|
||||
continue;
|
||||
}
|
||||
const existing = grouped.get(insight.category) ?? [];
|
||||
grouped.set(insight.category, [...existing, insight]);
|
||||
}
|
||||
|
||||
setSections(
|
||||
INSIGHT_CATEGORIES.map((category) => ({
|
||||
category,
|
||||
label: CATEGORY_LABELS[category] ?? category,
|
||||
items: grouped.get(category) ?? [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})),
|
||||
);
|
||||
}, [allInsights, showArchived]);
|
||||
|
||||
const archive = useCallback(
|
||||
async (id: string) => {
|
||||
setArchiveStates((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(id, { running: true, error: null });
|
||||
return next;
|
||||
});
|
||||
|
||||
try {
|
||||
await archiveInsight(id, projectId);
|
||||
setAllInsights((prev) => prev.map((insight) =>
|
||||
insight.id === id ? { ...insight, status: "archived", updatedAt: new Date().toISOString() } : insight,
|
||||
));
|
||||
setArchiveStates((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(id, { running: false, error: null });
|
||||
return next;
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to archive insight";
|
||||
setArchiveStates((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(id, { running: false, error: message });
|
||||
return next;
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[projectId],
|
||||
);
|
||||
|
||||
const unarchive = useCallback(
|
||||
async (id: string) => {
|
||||
setUnarchiveStates((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(id, { running: true, error: null });
|
||||
return next;
|
||||
});
|
||||
|
||||
try {
|
||||
await unarchiveInsight(id, projectId);
|
||||
setAllInsights((prev) => prev.map((insight) =>
|
||||
insight.id === id ? { ...insight, status: "confirmed", updatedAt: new Date().toISOString() } : insight,
|
||||
));
|
||||
setUnarchiveStates((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(id, { running: false, error: null });
|
||||
return next;
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to unarchive insight";
|
||||
setUnarchiveStates((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(id, { running: false, error: message });
|
||||
return next;
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[projectId],
|
||||
);
|
||||
|
||||
// Computed counts
|
||||
const totalCount = useMemo(() => {
|
||||
return sections.reduce((sum, section) => sum + section.items.length, 0);
|
||||
}, [sections]);
|
||||
|
||||
const dismissedCount = useMemo(() => {
|
||||
// This would need to be tracked separately if needed
|
||||
return 0;
|
||||
}, [sections]);
|
||||
}, []);
|
||||
|
||||
const archivedCount = useMemo(() => {
|
||||
return allInsights.filter((insight) => insight.status === "archived").length;
|
||||
}, [allInsights]);
|
||||
|
||||
// Initial load - intentionally runs once on mount
|
||||
useEffect(() => {
|
||||
@@ -314,9 +404,16 @@ export function useInsights(projectId?: string): UseInsightsResult {
|
||||
runInsights,
|
||||
dismiss,
|
||||
createTask,
|
||||
archive,
|
||||
unarchive,
|
||||
toggleShowArchived,
|
||||
dismissStates,
|
||||
createTaskStates,
|
||||
archiveStates,
|
||||
unarchiveStates,
|
||||
totalCount,
|
||||
dismissedCount,
|
||||
archivedCount,
|
||||
showArchived,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ const VALID_CATEGORIES: InsightCategory[] = [
|
||||
];
|
||||
|
||||
// Valid insight statuses
|
||||
const VALID_STATUSES: InsightStatus[] = ["generated", "confirmed", "stale", "dismissed"];
|
||||
const VALID_STATUSES: InsightStatus[] = ["generated", "confirmed", "stale", "dismissed", "archived"];
|
||||
|
||||
// Valid run triggers
|
||||
const VALID_TRIGGERS: InsightRunTrigger[] = ["schedule", "manual", "task_completion", "merge_event", "api"];
|
||||
@@ -593,6 +593,34 @@ export function createInsightsRouter(store: TaskStore): Router {
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/:id/archive", (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = String(req.params.id);
|
||||
const store = getInsightStore();
|
||||
const insight = store.updateInsight(id, { status: "archived" });
|
||||
if (!insight) {
|
||||
throw notFound(`Insight not found: ${id}`);
|
||||
}
|
||||
res.json(insight);
|
||||
} catch (error) {
|
||||
rethrowAsApiError(error, "Failed to archive insight");
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/:id/unarchive", (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = String(req.params.id);
|
||||
const store = getInsightStore();
|
||||
const insight = store.updateInsight(id, { status: "confirmed" });
|
||||
if (!insight) {
|
||||
throw notFound(`Insight not found: ${id}`);
|
||||
}
|
||||
res.json(insight);
|
||||
} catch (error) {
|
||||
rethrowAsApiError(error, "Failed to unarchive insight");
|
||||
}
|
||||
});
|
||||
|
||||
// ── Create Task from Insight ────────────────────────────────────────────
|
||||
|
||||
router.post("/:id/create-task", (req: Request, res: Response) => {
|
||||
|
||||
Reference in New Issue
Block a user