feat(FN-3786): add reports dashboard view and comparison tooling

Adds a Reports dashboard view (`ReportsView`) to the reports plugin with a full supporting stack of components (`ReportComparisonDrawer`, `ReportDetailPanel`, `ReportFiltersBar`, `ReportListItem`, `ReportEmptyState`), hooks (`useReports`, `useReportPreview`, `useReportSectionDiff`, `useViewportMode`

Fusion-Task-Id: FN-3786
This commit is contained in:
Fusion
2026-05-10 12:27:36 -07:00
committed by gsxdsm
parent 097653eceb
commit 43d3ac2c18
33 changed files with 753 additions and 6 deletions

View File

@@ -136,3 +136,14 @@ Emitted events:
- `report:deleted`
This archive is the source of truth for downstream report HTML rendering (FN-3785) and dashboard report list/detail flows (FN-3786).
## Dashboard view
The plugin registers a primary dashboard view (`Reports`) via `dashboardViews` with `componentPath: "./dashboard-view"`.
The view provides:
- History list of reports with filters (cadence, status, period date range, title search, agent filter)
- Embedded detail preview using sandboxed iframe + preview HTML endpoint
- Section quick-jump navigation by stable `data-section` markers
- Side-by-side comparison drawer for two reports with section-level diff summary
- Standalone HTML download action wired to the export endpoint

View File

@@ -5,6 +5,16 @@
"description": "Generates beautiful HTML system-activity reports with multi-agent review.",
"author": "Fusion Team",
"fusionVersion": ">=0.1.0",
"dashboardViews": [
{
"viewId": "reports",
"label": "Reports",
"componentPath": "./dashboard-view",
"icon": "FileText",
"placement": "primary",
"order": 35
}
],
"settingsSchema": {
"dailyEnabled": { "type": "boolean", "label": "Enable Daily Reports", "description": "Generate daily reports on schedule.", "group": "Schedules", "defaultValue": true },
"dailyCron": { "type": "string", "label": "Daily Schedule (cron)", "description": "Cron expression for daily report generation.", "group": "Schedules", "required": true, "defaultValue": "0 8 * * *" },

View File

@@ -12,6 +12,10 @@
"./render": {
"types": "./src/render/index.ts",
"import": "./src/render/index.ts"
},
"./dashboard-view": {
"types": "./src/dashboard-view.tsx",
"import": "./src/dashboard-view.tsx"
}
},
"scripts": {
@@ -20,9 +24,17 @@
},
"dependencies": {
"@fusion/core": "workspace:*",
"@fusion/plugin-sdk": "workspace:*"
"@fusion/dashboard": "workspace:*",
"@fusion/plugin-sdk": "workspace:*",
"lucide-react": "^0.542.0",
"react": "^19.0.0",
"react-dom": "^19.2.4"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^19.0.0",
"@types/node": "^25.5.2",
"typescript": "^5.7.0",
"vitest": "^3.2.4"

View File

@@ -42,6 +42,20 @@ describe("reports plugin manifest", () => {
expect(plugin.manifest.fusionVersion).toBe(manifest.fusionVersion);
});
it("registers dashboard view", () => {
expect(plugin.dashboardViews).toEqual([
{
viewId: "reports",
label: "Reports",
componentPath: "./dashboard-view",
icon: "FileText",
placement: "primary",
order: 35,
},
]);
expect(manifest.dashboardViews).toEqual(plugin.dashboardViews);
});
it("includes full settings schema", () => {
expect(plugin.manifest.settingsSchema).toBeDefined();
for (const key of expectedKeys) {

View File

@@ -0,0 +1,6 @@
import type { PluginDashboardViewContext } from "@fusion/dashboard/app/plugins/types";
import { ReportsView } from "./dashboard/ReportsView.js";
export function ReportsDashboardView({ context }: { context?: PluginDashboardViewContext }) {
return <ReportsView projectId={context?.projectId} addToast={context?.addToast ?? (() => undefined)} />;
}

View File

@@ -0,0 +1,78 @@
.reports-view {
display: flex;
flex-direction: column;
gap: var(--space-md);
padding: var(--space-lg);
}
.reports-view-header { display: flex; justify-content: space-between; align-items: center; }
.reports-filters { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: var(--space-sm); }
.reports-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); gap: var(--space-md); }
.reports-list { display: flex; flex-direction: column; gap: var(--space-sm); }
.reports-list-item {
text-align: left;
border: var(--btn-border-width) solid var(--border);
transition: border-color var(--transition-fast), background var(--transition-fast), box-shadow var(--transition-fast);
}
.reports-list-item[data-selected="true"] {
border-color: var(--todo);
background: var(--card-hover);
box-shadow: var(--focus-ring-strong);
}
.reports-detail { display: flex; flex-direction: column; gap: var(--space-sm); }
.reports-detail-header { display: flex; justify-content: space-between; align-items: center; gap: var(--space-sm); }
.reports-detail-body { display: flex; flex-direction: column; gap: var(--space-sm); }
.reports-detail-meta {
color: var(--text-muted);
font-size: 0.875rem;
}
.reports-detail-sections {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
}
.reports-detail iframe, .reports-compare iframe { width: 100%; min-height: 20rem; border: var(--btn-border-width) solid var(--border); border-radius: var(--radius-md); background: var(--surface); }
.reports-empty {
align-items: center;
display: flex;
flex-direction: column;
gap: var(--space-sm);
justify-content: center;
min-height: calc(var(--space-2xl) * 6);
text-align: center;
}
.reports-empty p {
color: var(--text-muted);
margin: 0;
}
.reports-compare {
width: min(100%, 64rem);
max-height: min(100%, calc(100vh - var(--space-2xl)));
overflow: auto;
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.reports-compare-header {
position: sticky;
top: 0;
background: var(--card);
z-index: 1;
}
.reports-compare-pickers {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-sm);
}
.reports-compare-frames { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-sm); }
@media (max-width: 1024px) {
.reports-compare-pickers,
.reports-compare-frames { grid-template-columns: minmax(0, 1fr); }
}
@media (max-width: 768px) {
.reports-filters { grid-template-columns: minmax(0, 1fr); }
.reports-layout { grid-template-columns: minmax(0, 1fr); }
.reports-compare {
width: min(100%, calc(100vw - var(--space-lg)));
max-height: min(100%, calc(100vh - var(--space-lg)));
}
}

View File

@@ -0,0 +1,24 @@
import "./ReportsView.css";
import { ReportComparisonDrawer } from "./components/ReportComparisonDrawer.js";
import { ReportDetailPanel } from "./components/ReportDetailPanel.js";
import { ReportEmptyState } from "./components/ReportEmptyState.js";
import { ReportFiltersBar } from "./components/ReportFiltersBar.js";
import { ReportListItem } from "./components/ReportListItem.js";
import type { ToastType } from "./types.js";
import { useReports } from "./useReports.js";
import { useViewportMode } from "./useViewportMode.js";
export function ReportsView({ projectId, addToast }: { projectId?: string; addToast: (message: string, type?: ToastType) => void }) {
const model = useReports({ projectId, addToast });
const { mobile } = useViewportMode();
const agents = [...new Set(model.reports.flatMap((r) => ((r.metadata?.agentIds as string[] | undefined) ?? [])))];
return <div className="reports-view">
<div className="reports-view-header"><h2>Reports</h2><button className="btn btn-sm" onClick={model.enterCompareMode}>Compare</button></div>
<ReportFiltersBar filters={model.filters} onChange={model.setFilters} agents={agents} />
<div className="reports-layout" data-mobile={mobile ? "true" : "false"}>
<div className="reports-list">{model.reports.length === 0 ? <ReportEmptyState /> : model.reports.map((report) => <ReportListItem key={report.id} report={report} selected={model.selectedId === report.id} onSelect={model.selectId} />)}</div>
<ReportDetailPanel report={model.selectedReport} projectId={projectId} />
</div>
{model.compareMode ? <ReportComparisonDrawer reports={model.reports} leftId={model.compareA} rightId={model.compareB} onPick={model.setCompareSlot} onClose={model.closeCompareMode} projectId={projectId} /> : null}
</div>;
}

View File

@@ -0,0 +1,12 @@
import { render } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import * as preview from "../useReportPreview.js";
import { ReportComparisonDrawer } from "../components/ReportComparisonDrawer.js";
describe("ReportComparisonDrawer", () => {
it("renders compare ui", () => {
vi.spyOn(preview, "useReportPreview").mockReturnValue({ html: "<article />", loading: false, error: null });
const { getByText } = render(<ReportComparisonDrawer reports={[{ id: "R-1", title: "A" }, { id: "R-2", title: "B" }] as never} leftId="R-1" rightId="R-2" onPick={vi.fn()} onClose={vi.fn()} />);
expect(getByText("Compare reports")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,12 @@
import { render } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import * as preview from "../useReportPreview.js";
import { ReportDetailPanel } from "../components/ReportDetailPanel.js";
describe("ReportDetailPanel", () => {
it("renders report", () => {
vi.spyOn(preview, "useReportPreview").mockReturnValue({ html: "<article />", loading: false, error: null });
const { getByText } = render(<ReportDetailPanel report={{ id: "R-1", title: "Report", cadence: "daily", status: "published", periodStart: "2026-01-01", periodEnd: "2026-01-02" } as never} />);
expect(getByText("Report")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,14 @@
import { fireEvent, render, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { ReportFiltersBar } from "../components/ReportFiltersBar.js";
const filters = { cadence: "all", status: "all", from: "", to: "", q: "", agentId: "" } as const;
describe("ReportFiltersBar", () => {
it("emits changes", async () => {
const onChange = vi.fn();
const { getByPlaceholderText } = render(<ReportFiltersBar filters={{ ...filters }} onChange={onChange} agents={[]} />);
fireEvent.change(getByPlaceholderText("Search title"), { target: { value: "hello" } });
await waitFor(() => expect(onChange).toBeCalled());
});
});

View File

@@ -0,0 +1,27 @@
import { fireEvent, render } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import * as reportsHook from "../useReports.js";
import { ReportsView } from "../ReportsView.js";
describe("ReportsView", () => {
it("renders list and compare toggle", () => {
vi.spyOn(reportsHook, "useReports").mockReturnValue({
filters: { cadence: "all", status: "all", from: "", to: "", q: "", agentId: "" },
setFilters: vi.fn(),
reports: [{ id: "R-1", title: "A", cadence: "daily", status: "published", periodStart: "2026-01-01", periodEnd: "2026-01-02", metadata: {} }],
loading: false,
selectedId: "R-1",
selectedReport: { id: "R-1", title: "A", cadence: "daily", status: "published", periodStart: "2026-01-01", periodEnd: "2026-01-02", metadata: {} },
selectId: vi.fn(),
compareMode: false,
compareA: undefined,
compareB: undefined,
enterCompareMode: vi.fn(),
closeCompareMode: vi.fn(),
setCompareSlot: vi.fn(),
} as never);
const { getByText } = render(<ReportsView addToast={vi.fn()} />);
fireEvent.click(getByText("Compare"));
expect(getByText("Reports")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,19 @@
import { describe, expect, it, vi } from "vitest";
import { getReportExportUrl, getReportPreviewHtml, listReports } from "../api.js";
describe("api", () => {
it("lists reports", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => ({ reports: [{ id: "R-1" }] }) }));
const reports = await listReports();
expect(reports).toHaveLength(1);
});
it("reads preview html", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, text: async () => "<article/>" }));
await expect(getReportPreviewHtml("R-1")).resolves.toContain("article");
});
it("builds export url", () => {
expect(getReportExportUrl("R-1")).toContain("/reports/R-1/export.html");
});
});

View File

@@ -0,0 +1,11 @@
import { describe, expect, it } from "vitest";
import { diffReportSections } from "../useReportSectionDiff.js";
describe("diffReportSections", () => {
it("classifies changed sections", () => {
const a = { metadata: { wins: ["a"] } } as never;
const b = { metadata: { wins: ["b"] } } as never;
const diff = diffReportSections(a, b);
expect(diff.changed.find((item) => item.id === "system-wins")).toBeTruthy();
});
});

View File

@@ -0,0 +1,13 @@
import { renderHook, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import * as api from "../api.js";
import { useReports } from "../useReports.js";
describe("useReports", () => {
it("loads reports", async () => {
vi.spyOn(api, "listReports").mockResolvedValue([{ id: "R-1", title: "A" } as never]);
vi.spyOn(api, "getReport").mockResolvedValue({ id: "R-1", title: "A" } as never);
const { result } = renderHook(() => useReports({ addToast: vi.fn() }));
await waitFor(() => expect(result.current.reports).toHaveLength(1));
});
});

View File

@@ -0,0 +1,53 @@
import type { ReportRecord } from "./types.js";
const BASE = "/api/plugins/reports";
interface ListReportsParams {
cadence?: string;
status?: string;
from?: string;
to?: string;
q?: string;
agentId?: string;
projectId?: string;
}
function qp(params: Record<string, string | undefined>): string {
const entries = Object.entries(params).filter(([, value]) => typeof value === "string" && value.length > 0) as Array<[string, string]>;
if (entries.length === 0) return "";
return `?${entries.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&")}`;
}
async function request<T>(path: string, init?: RequestInit, responseType: "json" | "text" = "json"): Promise<T> {
const response = await fetch(`${BASE}${path}`, init);
if (!response.ok) {
let message = `${response.status} ${response.statusText}`;
try {
const data = (await response.json()) as { error?: string };
if (data.error) message = data.error;
} catch {
// ignore
}
throw new Error(message);
}
if (responseType === "text") return (await response.text()) as T;
return (await response.json()) as T;
}
export async function listReports(params: ListReportsParams = {}): Promise<ReportRecord[]> {
const data = await request<{ reports: ReportRecord[] }>(`/reports${qp({ ...params })}`);
return data.reports;
}
export async function getReport(id: string, projectId?: string): Promise<ReportRecord> {
const data = await request<{ report: ReportRecord }>(`/reports/${encodeURIComponent(id)}${qp({ projectId })}`);
return data.report;
}
export function getReportPreviewHtml(id: string, projectId?: string): Promise<string> {
return request<string>(`/reports/${encodeURIComponent(id)}/preview.html${qp({ projectId })}`, undefined, "text");
}
export function getReportExportUrl(id: string, projectId?: string): string {
return `${BASE}/reports/${encodeURIComponent(id)}/export.html${qp({ projectId })}`;
}

View File

@@ -0,0 +1,21 @@
import { useMemo } from "react";
import type { ReportRecord } from "../types.js";
import { useReportPreview } from "../useReportPreview.js";
import { useReportSectionDiff } from "../useReportSectionDiff.js";
export function ReportComparisonDrawer({ reports, leftId, rightId, onPick, onClose, projectId }: { reports: ReportRecord[]; leftId?: string; rightId?: string; onPick: (slot: "a" | "b", id: string) => void; onClose: () => void; projectId?: string }) {
const left = useMemo(() => reports.find((r) => r.id === leftId), [reports, leftId]);
const right = useMemo(() => reports.find((r) => r.id === rightId), [reports, rightId]);
const leftPreview = useReportPreview(leftId, projectId);
const rightPreview = useReportPreview(rightId, projectId);
const diff = useReportSectionDiff(left, right);
return <div className="modal-overlay open reports-compare-overlay" role="dialog" aria-modal="true" aria-label="Compare reports">
<div className="modal modal-lg reports-compare">
<div className="modal-header reports-compare-header"><h3>Compare reports</h3><button className="btn btn-sm" onClick={onClose}>Close</button></div>
<div className="reports-compare-pickers"><select className="select" value={leftId ?? ""} onChange={(e) => onPick("a", e.target.value)}>{reports.map((r) => <option key={r.id} value={r.id}>{r.title}</option>)}</select>
<select className="select" value={rightId ?? ""} onChange={(e) => onPick("b", e.target.value)}>{reports.map((r) => <option key={r.id} value={r.id}>{r.title}</option>)}</select></div>
<div className="reports-compare-frames"><iframe sandbox="allow-same-origin" srcDoc={leftPreview.html} title="Report A" /><iframe sandbox="allow-same-origin" srcDoc={rightPreview.html} title="Report B" /></div>
<div>Changed: {diff.changed.map((s) => s.id).join(", ")}</div>
</div>
</div>;
}

View File

@@ -0,0 +1,23 @@
import { useMemo, useRef } from "react";
import { getReportExportUrl } from "../api.js";
import { useReportPreview } from "../useReportPreview.js";
import type { ReportRecord } from "../types.js";
const SECTION_IDS = ["summary", "system-wins", "system-highlights", "system-lowlights", "system-proposals", "system-deep-dives", "agent-card", "data-coverage", "review-panel"];
export function ReportDetailPanel({ report, projectId }: { report?: ReportRecord; projectId?: string }) {
const frameRef = useRef<HTMLIFrameElement | null>(null);
const { html, loading, error } = useReportPreview(report?.id, projectId);
const sections = useMemo(() => SECTION_IDS, []);
if (!report) return <div className="reports-detail card">Select a report.</div>;
return <div className="reports-detail card">
<div className="reports-detail-header"><h3>{report.title}</h3><a className="btn btn-sm" href={getReportExportUrl(report.id, projectId)} download>Download standalone HTML</a></div>
<div className="reports-detail-meta">{report.cadence} {report.status} {report.periodStart} {report.periodEnd}</div>
<div className="reports-detail-body">
<nav className="reports-detail-sections">{sections.map((section) => <button key={section} className="btn btn-sm" onClick={() => frameRef.current?.contentWindow?.document.querySelector(`[data-section="${section}"]`)?.scrollIntoView()}>{section}</button>)}</nav>
{loading ? <div>Loading preview...</div> : null}
{error ? <div>{error}</div> : null}
<iframe ref={frameRef} sandbox="allow-same-origin" srcDoc={html} title="Report preview" />
</div>
</div>;
}

View File

@@ -0,0 +1,3 @@
export function ReportEmptyState() {
return <div className="reports-empty card"><h3>No reports found</h3><p>Adjust filters or enable schedules in settings.</p></div>;
}

View File

@@ -0,0 +1,19 @@
import { useEffect, useState } from "react";
import type { ReportFilters } from "../types.js";
export function ReportFiltersBar({ filters, onChange, agents }: { filters: ReportFilters; onChange: (next: ReportFilters) => void; agents: string[] }) {
const [query, setQuery] = useState(filters.q);
useEffect(() => {
const timeout = setTimeout(() => onChange({ ...filters, q: query }), 250);
return () => clearTimeout(timeout);
}, [query]);
return <div className="reports-filters">
<select className="select" value={filters.cadence} onChange={(e) => onChange({ ...filters, cadence: e.target.value as ReportFilters["cadence"] })}><option value="all">All cadence</option><option value="daily">Daily</option><option value="weekly">Weekly</option></select>
<select className="select" value={filters.status} onChange={(e) => onChange({ ...filters, status: e.target.value as ReportFilters["status"] })}><option value="all">All status</option><option value="generating">Generating</option><option value="review_pending">Review pending</option><option value="review_in_progress">Review in progress</option><option value="review_complete">Review complete</option><option value="approved">Approved</option><option value="published">Published</option><option value="failed">Failed</option></select>
<input className="input" type="date" value={filters.from} onChange={(e) => onChange({ ...filters, from: e.target.value })} />
<input className="input" type="date" value={filters.to} onChange={(e) => onChange({ ...filters, to: e.target.value })} />
<input className="input" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search title" />
<select className="select" value={filters.agentId} onChange={(e) => onChange({ ...filters, agentId: e.target.value })}><option value="">All agents</option>{agents.map((agent) => <option key={agent} value={agent}>{agent}</option>)}</select>
</div>;
}

View File

@@ -0,0 +1,8 @@
import type { ReportListItemVm } from "../types.js";
export function ReportListItem({ report, selected, onSelect }: { report: ReportListItemVm; selected: boolean; onSelect: (id: string) => void }) {
return <button className="card reports-list-item" data-selected={selected ? "true" : "false"} onClick={() => onSelect(report.id)}>
<div className="card-header"><span className="card-title">{report.title}</span></div>
<div className="card-meta"><span>{report.cadence}</span><span>{report.status}</span><span>{report.periodStart} {report.periodEnd}</span></div>
</button>;
}

View File

@@ -0,0 +1,18 @@
import "@testing-library/jest-dom/vitest";
import { vi } from "vitest";
if (typeof window !== "undefined") {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
}

View File

@@ -0,0 +1,22 @@
import type { Report, ReportCadence, ReportStatus } from "../store/report-types.js";
export type ToastType = "success" | "error" | "info" | "warning";
export interface ReportFilters {
cadence: "all" | ReportCadence;
status: "all" | ReportStatus;
from: string;
to: string;
q: string;
agentId: string;
}
export interface SectionRef {
id: string;
label: string;
hash: string;
}
export type ReportRecord = Report;
export type ReportListItemVm = Pick<ReportRecord, "id" | "title" | "cadence" | "status" | "periodStart" | "periodEnd" | "createdAt" | "metadata">;

View File

@@ -0,0 +1,44 @@
import { useEffect, useState } from "react";
import { getReportPreviewHtml } from "./api.js";
const cache = new Map<string, string>();
export function useReportPreview(id?: string, projectId?: string) {
const [html, setHtml] = useState<string>("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!id) {
setHtml("");
setError(null);
return;
}
const key = `${projectId ?? ""}:${id}`;
const cached = cache.get(key);
if (cached) {
setHtml(cached);
setError(null);
return;
}
const controller = new AbortController();
setLoading(true);
setError(null);
getReportPreviewHtml(id, projectId)
.then((nextHtml) => {
if (controller.signal.aborted) return;
cache.set(key, nextHtml);
setHtml(nextHtml);
})
.catch((err: unknown) => {
if (controller.signal.aborted) return;
setError(err instanceof Error ? err.message : "Failed to load preview");
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
return () => controller.abort();
}, [id, projectId]);
return { html, loading, error };
}

View File

@@ -0,0 +1,59 @@
import { useMemo } from "react";
import type { ReportRecord, SectionRef } from "./types.js";
export interface SectionDiff {
added: SectionRef[];
removed: SectionRef[];
changed: SectionRef[];
unchanged: SectionRef[];
}
const ALL_SECTIONS: Array<{ id: string; label: string }> = [
{ id: "summary", label: "Summary" },
{ id: "system-wins", label: "System wins" },
{ id: "system-highlights", label: "System highlights" },
{ id: "system-lowlights", label: "System lowlights" },
{ id: "system-proposals", label: "System proposals" },
{ id: "system-deep-dives", label: "System deep dives" },
{ id: "agent-card", label: "Per-agent" },
{ id: "data-coverage", label: "Data coverage" },
{ id: "review-panel", label: "Review panel" },
];
function extractValue(report: ReportRecord | undefined, id: string): unknown {
if (!report) return undefined;
switch (id) {
case "summary": return report.metadata?.summary;
case "system-wins": return report.metadata?.wins;
case "system-highlights": return report.metadata?.highlights;
case "system-lowlights": return report.metadata?.lowlights;
case "system-proposals": return report.metadata?.proposals;
case "system-deep-dives": return report.metadata?.deepDives;
case "agent-card": return report.metadata?.perAgent;
case "data-coverage": return report.metadata?.dataCoverage;
case "review-panel": return report.combinedReview;
default: return undefined;
}
}
export function diffReportSections(a?: ReportRecord, b?: ReportRecord): SectionDiff {
const added: SectionRef[] = [];
const removed: SectionRef[] = [];
const changed: SectionRef[] = [];
const unchanged: SectionRef[] = [];
for (const section of ALL_SECTIONS) {
const left = extractValue(a, section.id);
const right = extractValue(b, section.id);
const ref: SectionRef = { id: section.id, label: section.label, hash: section.id };
if (left == null && right != null) added.push(ref);
else if (left != null && right == null) removed.push(ref);
else if (JSON.stringify(left) !== JSON.stringify(right)) changed.push(ref);
else unchanged.push(ref);
}
return { added, removed, changed, unchanged };
}
export function useReportSectionDiff(a?: ReportRecord, b?: ReportRecord): SectionDiff {
return useMemo(() => diffReportSections(a, b), [a, b]);
}

View File

@@ -0,0 +1,71 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { getReport, listReports } from "./api.js";
import type { ReportFilters, ReportRecord, ToastType } from "./types.js";
const DEFAULT_FILTERS: ReportFilters = { cadence: "all", status: "all", from: "", to: "", q: "", agentId: "" };
export function useReports({ projectId, addToast }: { projectId?: string; addToast: (message: string, type?: ToastType) => void }) {
const [filters, setFilters] = useState<ReportFilters>(DEFAULT_FILTERS);
const [reports, setReports] = useState<ReportRecord[]>([]);
const [loading, setLoading] = useState(false);
const [selectedId, setSelectedId] = useState<string | undefined>();
const [selectedReport, setSelectedReport] = useState<ReportRecord | undefined>();
const [compareMode, setCompareMode] = useState(false);
const [compareA, setCompareA] = useState<string | undefined>();
const [compareB, setCompareB] = useState<string | undefined>();
useEffect(() => {
const controller = new AbortController();
setLoading(true);
listReports({
cadence: filters.cadence === "all" ? undefined : filters.cadence,
status: filters.status === "all" ? undefined : filters.status,
from: filters.from || undefined,
to: filters.to || undefined,
q: filters.q || undefined,
agentId: filters.agentId || undefined,
projectId,
}).then((items) => {
if (controller.signal.aborted) return;
setReports(items);
if (!selectedId && items[0]) setSelectedId(items[0].id);
}).catch((err: unknown) => {
if (controller.signal.aborted) return;
addToast(err instanceof Error ? err.message : "Failed to load reports", "error");
}).finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
return () => controller.abort();
}, [filters, projectId, addToast, selectedId]);
useEffect(() => {
if (!selectedId) return;
getReport(selectedId, projectId).then(setSelectedReport).catch((err: unknown) => {
addToast(err instanceof Error ? err.message : "Failed to load report", "error");
});
}, [selectedId, projectId, addToast]);
const selectId = useCallback((id: string) => setSelectedId(id), []);
const enterCompareMode = useCallback(() => setCompareMode(true), []);
const closeCompareMode = useCallback(() => setCompareMode(false), []);
const setCompareSlot = useCallback((slot: "a" | "b", id: string) => {
if (slot === "a") setCompareA(id);
else setCompareB(id);
}, []);
return useMemo(() => ({
filters,
setFilters,
reports,
loading,
selectedId,
selectedReport,
selectId,
compareMode,
compareA,
compareB,
enterCompareMode,
closeCompareMode,
setCompareSlot,
}), [filters, reports, loading, selectedId, selectedReport, selectId, compareMode, compareA, compareB, enterCompareMode, closeCompareMode, setCompareSlot]);
}

View File

@@ -0,0 +1,13 @@
import { useEffect, useState } from "react";
export function useViewportMode() {
const [mobile, setMobile] = useState(false);
useEffect(() => {
const mq = window.matchMedia("(max-width: 768px)");
const onChange = () => setMobile(mq.matches);
onChange();
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, []);
return { mobile };
}

View File

@@ -3,10 +3,12 @@ import { definePlugin } from "@fusion/plugin-sdk";
import { runReviewPanel } from "./review-panel.js";
import { ensureReportSchema } from "./report-schema.js";
import { createReportExportRoutes } from "./routes/report-export-routes.js";
import { createReportListRoutes } from "./routes/report-list-routes.js";
import type { CombinedReview, ReviewPanelMember, RunReviewPanelInput } from "./review-types.js";
import type { ReportCadence, ReportCreateInput } from "./store/report-types.js";
import { ReportStore } from "./store/report-store.js";
import { settingsSchema } from "./settings.js";
export { ReportsDashboardView } from "./dashboard-view.js";
const plugin = definePlugin({
manifest: {
@@ -22,7 +24,17 @@ const plugin = definePlugin({
hooks: {
onSchemaInit: ensureReportSchema,
},
routes: createReportExportRoutes(),
routes: [...createReportListRoutes(), ...createReportExportRoutes()],
dashboardViews: [
{
viewId: "reports",
label: "Reports",
componentPath: "./dashboard-view",
icon: "FileText",
placement: "primary",
order: 35,
},
],
});
export interface RunGeneratedReportReviewInput {

View File

@@ -0,0 +1,72 @@
import type { PluginContext, PluginRouteDefinition, PluginRouteResponse } from "@fusion/core";
import { ReportStore } from "../store/report-store.js";
interface RouteRequest {
params: Record<string, string>;
query?: Record<string, string | string[] | undefined>;
}
const reportStoreCache = new WeakMap<object, ReportStore>();
function getStore(ctx: PluginContext): ReportStore {
const key = ctx.taskStore as object;
const cached = reportStoreCache.get(key);
if (cached) return cached;
const store = new ReportStore(ctx.taskStore.getDatabase());
reportStoreCache.set(key, store);
return store;
}
function badRequest(message: string): PluginRouteResponse {
return { status: 400, body: { error: message } };
}
export function createReportListRoutes(): PluginRouteDefinition[] {
return [
{
method: "GET",
path: "/reports",
handler: async (req: unknown, ctx: PluginContext): Promise<PluginRouteResponse> => {
const request = req as RouteRequest;
const query = request.query ?? {};
const cadence = typeof query.cadence === "string" && query.cadence.length > 0 ? query.cadence : undefined;
const status = typeof query.status === "string" && query.status.length > 0 ? query.status : undefined;
const periodStartFrom = typeof query.from === "string" && query.from.length > 0 ? query.from : undefined;
const periodStartTo = typeof query.to === "string" && query.to.length > 0 ? query.to : undefined;
const q = typeof query.q === "string" && query.q.length > 0 ? query.q.toLowerCase() : undefined;
const agent = typeof query.agentId === "string" && query.agentId.length > 0 ? query.agentId.toLowerCase() : undefined;
const store = getStore(ctx);
const reports = store.listReports({
cadence: cadence as never,
status: status as never,
periodStartFrom,
periodStartTo,
orderBy: "periodStart",
orderDir: "desc",
limit: 500,
});
const filtered = reports.filter((report) => {
if (q && !report.title.toLowerCase().includes(q)) return false;
if (agent) {
const agentIds = ((report.metadata?.agentIds as string[] | undefined) ?? []).map((id) => id.toLowerCase());
if (!agentIds.some((id) => id.includes(agent))) return false;
}
return true;
});
return { status: 200, body: { reports: filtered } };
},
},
{
method: "GET",
path: "/reports/:id",
handler: async (req: unknown, ctx: PluginContext): Promise<PluginRouteResponse> => {
const request = req as RouteRequest;
const report = getStore(ctx).getReport(request.params.id);
if (!report) return { status: 404, body: { error: `Report ${request.params.id} not found` } };
return { status: 200, body: { report } };
},
},
];
}

View File

@@ -3,7 +3,8 @@
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"jsx": "react-jsx",
"types": ["node", "vitest/globals"]
},
"include": ["src/**/*"]
"include": ["src/**/*.ts", "src/**/*.tsx"]
}

View File

@@ -8,12 +8,17 @@ export default defineConfig({
resolve: {
alias: {
"@fusion/core": fileURLToPath(new URL("../../packages/core/src/index.ts", import.meta.url)),
"@fusion/dashboard": fileURLToPath(new URL("../../packages/dashboard/src/index.ts", import.meta.url)),
"@fusion/plugin-sdk": fileURLToPath(new URL("../../packages/plugin-sdk/src/index.ts", import.meta.url)),
},
},
test: {
include: ["src/**/__tests__/**/*.test.{ts,tsx}", "src/**/*.test.{ts,tsx}"],
setupFiles: [fileURLToPath(new URL("../../packages/core/src/__test-utils__/vitest-setup.ts", import.meta.url))],
environmentMatchGlobs: [["src/dashboard/**", "jsdom"]],
setupFiles: [
fileURLToPath(new URL("../../packages/core/src/__test-utils__/vitest-setup.ts", import.meta.url)),
fileURLToPath(new URL("./src/dashboard/test-setup.ts", import.meta.url)),
],
globalSetup: [fileURLToPath(new URL("../../packages/core/src/__test-utils__/vitest-teardown.ts", import.meta.url))],
pool: "threads",
maxWorkers,