import type { HeartbeatRun } from "@paperclipai/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 (
{days.map((day, i) => (
{(i === 0 || i === 6 || i === 13) ? ( {formatDayLabel(day)} ) : null}
))}
); } function ChartLegend({ items }: { items: { color: string; label: string }[] }) { return (
{items.map(item => ( {item.label} ))}
); } export function ChartCard({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) { return (

{title}

{subtitle && {subtitle}}
{children}
); } /* ---- Chart Components ---- */ export function RunActivityChart({ runs }: { runs: HeartbeatRun[] }) { const days = getLast14Days(); const grouped = new Map(); 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

No runs yet

; return (
{days.map(day => { const entry = grouped.get(day)!; const total = entry.succeeded + entry.failed + entry.other; const heightPct = (total / maxValue) * 100; return (
{total > 0 ? (
{entry.succeeded > 0 &&
} {entry.failed > 0 &&
} {entry.other > 0 &&
}
) : (
)}
); })}
); } const priorityColors: Record = { 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>(); 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

No issues

; return (
{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 (
{total > 0 ? (
{priorityOrder.map(p => entry[p] > 0 ? (
) : null)}
) : (
)}
); })}
({ color: priorityColors[p], label: p.charAt(0).toUpperCase() + p.slice(1) }))} />
); } const statusColors: Record = { todo: "#3b82f6", in_progress: "#8b5cf6", in_review: "#a855f7", done: "#10b981", blocked: "#ef4444", cancelled: "#6b7280", backlog: "#64748b", }; const statusLabels: Record = { 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(); const grouped = new Map>(); 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

No issues

; return (
{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 (
{total > 0 ? (
{statusOrder.map(s => (entry[s] ?? 0) > 0 ? (
) : null)}
) : (
)}
); })}
({ color: statusColors[s] ?? "#6b7280", label: statusLabels[s] ?? s }))} />
); } export function SuccessRateChart({ runs }: { runs: HeartbeatRun[] }) { const days = getLast14Days(); const grouped = new Map(); 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

No runs yet

; return (
{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 (
0 ? Math.round(rate * 100) : 0}% (${entry.succeeded}/${entry.total})`}> {entry.total > 0 ? (
) : (
)}
); })}
); }