Files
paperclip/ui/src/components/ActivityCharts.tsx
Forgotten c2709687b8 feat: server-side issue search, dashboard charts, and inbox badges
Add ILIKE-based issue search across title, identifier, description,
and comments with relevance ranking. Add assigneeUserId filter and
allow agents to return issues to creator. Show assigned issue count
in sidebar badges. Add minCount param to live-runs endpoint. Add
activity charts (run activity, priority, status, success rate) to
dashboard. Improve active agents panel with recent run cards.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 16:33:39 -06:00

264 lines
9.8 KiB
TypeScript

import type { HeartbeatRun } from "@paperclip/shared";
/* ---- Utilities ---- */
export function getLast14Days(): string[] {
return Array.from({ length: 14 }, (_, i) => {
const d = new Date();
d.setDate(d.getDate() - (13 - i));
return d.toISOString().slice(0, 10);
});
}
function formatDayLabel(dateStr: string): string {
const d = new Date(dateStr + "T12:00:00");
return `${d.getMonth() + 1}/${d.getDate()}`;
}
/* ---- Sub-components ---- */
function DateLabels({ days }: { days: string[] }) {
return (
<div className="flex gap-[3px] mt-1.5">
{days.map((day, i) => (
<div key={day} className="flex-1 text-center">
{(i === 0 || i === 6 || i === 13) ? (
<span className="text-[9px] text-muted-foreground tabular-nums">{formatDayLabel(day)}</span>
) : null}
</div>
))}
</div>
);
}
function ChartLegend({ items }: { items: { color: string; label: string }[] }) {
return (
<div className="flex flex-wrap gap-x-2.5 gap-y-0.5 mt-2">
{items.map(item => (
<span key={item.label} className="flex items-center gap-1 text-[9px] text-muted-foreground">
<span className="h-1.5 w-1.5 rounded-full shrink-0" style={{ backgroundColor: item.color }} />
{item.label}
</span>
))}
</div>
);
}
export function ChartCard({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) {
return (
<div className="border border-border rounded-lg p-4 space-y-3">
<div>
<h3 className="text-xs font-medium text-muted-foreground">{title}</h3>
{subtitle && <span className="text-[10px] text-muted-foreground/60">{subtitle}</span>}
</div>
{children}
</div>
);
}
/* ---- Chart Components ---- */
export function RunActivityChart({ runs }: { runs: HeartbeatRun[] }) {
const days = getLast14Days();
const grouped = new Map<string, { succeeded: number; failed: number; other: number }>();
for (const day of days) grouped.set(day, { succeeded: 0, failed: 0, other: 0 });
for (const run of runs) {
const day = new Date(run.createdAt).toISOString().slice(0, 10);
const entry = grouped.get(day);
if (!entry) continue;
if (run.status === "succeeded") entry.succeeded++;
else if (run.status === "failed" || run.status === "timed_out") entry.failed++;
else entry.other++;
}
const maxValue = Math.max(...Array.from(grouped.values()).map(v => v.succeeded + v.failed + v.other), 1);
const hasData = Array.from(grouped.values()).some(v => v.succeeded + v.failed + v.other > 0);
if (!hasData) return <p className="text-xs text-muted-foreground">No runs yet</p>;
return (
<div>
<div className="flex items-end gap-[3px] h-20">
{days.map(day => {
const entry = grouped.get(day)!;
const total = entry.succeeded + entry.failed + entry.other;
const heightPct = (total / maxValue) * 100;
return (
<div key={day} className="flex-1 h-full flex flex-col justify-end" title={`${day}: ${total} runs`}>
{total > 0 ? (
<div className="flex flex-col-reverse gap-px overflow-hidden" style={{ height: `${heightPct}%`, minHeight: 2 }}>
{entry.succeeded > 0 && <div className="bg-emerald-500" style={{ flex: entry.succeeded }} />}
{entry.failed > 0 && <div className="bg-red-500" style={{ flex: entry.failed }} />}
{entry.other > 0 && <div className="bg-neutral-500" style={{ flex: entry.other }} />}
</div>
) : (
<div className="bg-muted/30 rounded-sm" style={{ height: 2 }} />
)}
</div>
);
})}
</div>
<DateLabels days={days} />
</div>
);
}
const priorityColors: Record<string, string> = {
critical: "#ef4444",
high: "#f97316",
medium: "#eab308",
low: "#6b7280",
};
const priorityOrder = ["critical", "high", "medium", "low"] as const;
export function PriorityChart({ issues }: { issues: { priority: string; createdAt: Date }[] }) {
const days = getLast14Days();
const grouped = new Map<string, Record<string, number>>();
for (const day of days) grouped.set(day, { critical: 0, high: 0, medium: 0, low: 0 });
for (const issue of issues) {
const day = new Date(issue.createdAt).toISOString().slice(0, 10);
const entry = grouped.get(day);
if (!entry) continue;
if (issue.priority in entry) entry[issue.priority]++;
}
const maxValue = Math.max(...Array.from(grouped.values()).map(v => Object.values(v).reduce((a, b) => a + b, 0)), 1);
const hasData = Array.from(grouped.values()).some(v => Object.values(v).reduce((a, b) => a + b, 0) > 0);
if (!hasData) return <p className="text-xs text-muted-foreground">No issues</p>;
return (
<div>
<div className="flex items-end gap-[3px] h-20">
{days.map(day => {
const entry = grouped.get(day)!;
const total = Object.values(entry).reduce((a, b) => a + b, 0);
const heightPct = (total / maxValue) * 100;
return (
<div key={day} className="flex-1 h-full flex flex-col justify-end" title={`${day}: ${total} issues`}>
{total > 0 ? (
<div className="flex flex-col-reverse gap-px overflow-hidden" style={{ height: `${heightPct}%`, minHeight: 2 }}>
{priorityOrder.map(p => entry[p] > 0 ? (
<div key={p} style={{ flex: entry[p], backgroundColor: priorityColors[p] }} />
) : null)}
</div>
) : (
<div className="bg-muted/30 rounded-sm" style={{ height: 2 }} />
)}
</div>
);
})}
</div>
<DateLabels days={days} />
<ChartLegend items={priorityOrder.map(p => ({ color: priorityColors[p], label: p.charAt(0).toUpperCase() + p.slice(1) }))} />
</div>
);
}
const statusColors: Record<string, string> = {
todo: "#3b82f6",
in_progress: "#8b5cf6",
in_review: "#a855f7",
done: "#10b981",
blocked: "#ef4444",
cancelled: "#6b7280",
backlog: "#64748b",
};
const statusLabels: Record<string, string> = {
todo: "To Do",
in_progress: "In Progress",
in_review: "In Review",
done: "Done",
blocked: "Blocked",
cancelled: "Cancelled",
backlog: "Backlog",
};
export function IssueStatusChart({ issues }: { issues: { status: string; createdAt: Date }[] }) {
const days = getLast14Days();
const allStatuses = new Set<string>();
const grouped = new Map<string, Record<string, number>>();
for (const day of days) grouped.set(day, {});
for (const issue of issues) {
const day = new Date(issue.createdAt).toISOString().slice(0, 10);
const entry = grouped.get(day);
if (!entry) continue;
entry[issue.status] = (entry[issue.status] ?? 0) + 1;
allStatuses.add(issue.status);
}
const statusOrder = ["todo", "in_progress", "in_review", "done", "blocked", "cancelled", "backlog"].filter(s => allStatuses.has(s));
const maxValue = Math.max(...Array.from(grouped.values()).map(v => Object.values(v).reduce((a, b) => a + b, 0)), 1);
const hasData = allStatuses.size > 0;
if (!hasData) return <p className="text-xs text-muted-foreground">No issues</p>;
return (
<div>
<div className="flex items-end gap-[3px] h-20">
{days.map(day => {
const entry = grouped.get(day)!;
const total = Object.values(entry).reduce((a, b) => a + b, 0);
const heightPct = (total / maxValue) * 100;
return (
<div key={day} className="flex-1 h-full flex flex-col justify-end" title={`${day}: ${total} issues`}>
{total > 0 ? (
<div className="flex flex-col-reverse gap-px overflow-hidden" style={{ height: `${heightPct}%`, minHeight: 2 }}>
{statusOrder.map(s => (entry[s] ?? 0) > 0 ? (
<div key={s} style={{ flex: entry[s], backgroundColor: statusColors[s] ?? "#6b7280" }} />
) : null)}
</div>
) : (
<div className="bg-muted/30 rounded-sm" style={{ height: 2 }} />
)}
</div>
);
})}
</div>
<DateLabels days={days} />
<ChartLegend items={statusOrder.map(s => ({ color: statusColors[s] ?? "#6b7280", label: statusLabels[s] ?? s }))} />
</div>
);
}
export function SuccessRateChart({ runs }: { runs: HeartbeatRun[] }) {
const days = getLast14Days();
const grouped = new Map<string, { succeeded: number; total: number }>();
for (const day of days) grouped.set(day, { succeeded: 0, total: 0 });
for (const run of runs) {
const day = new Date(run.createdAt).toISOString().slice(0, 10);
const entry = grouped.get(day);
if (!entry) continue;
entry.total++;
if (run.status === "succeeded") entry.succeeded++;
}
const hasData = Array.from(grouped.values()).some(v => v.total > 0);
if (!hasData) return <p className="text-xs text-muted-foreground">No runs yet</p>;
return (
<div>
<div className="flex items-end gap-[3px] h-20">
{days.map(day => {
const entry = grouped.get(day)!;
const rate = entry.total > 0 ? entry.succeeded / entry.total : 0;
const color = entry.total === 0 ? undefined : rate >= 0.8 ? "#10b981" : rate >= 0.5 ? "#eab308" : "#ef4444";
return (
<div key={day} className="flex-1 h-full flex flex-col justify-end" title={`${day}: ${entry.total > 0 ? Math.round(rate * 100) : 0}% (${entry.succeeded}/${entry.total})`}>
{entry.total > 0 ? (
<div style={{ height: `${rate * 100}%`, minHeight: 2, backgroundColor: color }} />
) : (
<div className="bg-muted/30 rounded-sm" style={{ height: 2 }} />
)}
</div>
);
})}
</div>
<DateLabels days={days} />
</div>
);
}