Add five SVG dashboard charts to homepage

AgentWorkloadChart (bar), BudgetUtilizationChart (stacked bar),
RunActivityChart (7-day bar), SuccessRateChart (ring gauge), and
CostTrendChart (7-day line). Pure SVG, no external charting library.
Charts render from snapshot data in the DashboardCharts module.
This commit is contained in:
ZacharyZhang-NY committed 2026-06-07 18:07:13 -04:00
1 parent dfc45f5fde
commit 2c12f68b4e
2 files changed
+190 -1

No files matched your search

@@ -12,6 +12,7 @@ import type {
TaskRunRecord,
} from "../../../shared/domain";
import { NewTaskDialog } from "./NewTaskDialog";
import { AgentWorkloadChart, BudgetUtilizationChart, CostTrendChart, RunActivityChart, SuccessRateChart } from "./home/DashboardCharts";
import type { PageProps } from "./types";
import { activeOrgId, formatRelative, formatTime, money, requireActiveEmployee, requireActiveOrganization } from "./types";
@@ -37,6 +38,7 @@ export function HomePage({ snapshot, actions, navigate }: PageProps) {
const taskRuns = useMemo(() => snapshot.taskRuns.filter((run) => run.orgId === orgId), [snapshot.taskRuns, orgId]);
const chatMessages = useMemo(() => snapshot.chatMessages.filter((m) => m.orgId === orgId), [snapshot.chatMessages, orgId]);
const chatSpaces = useMemo(() => snapshot.chatSpaces.filter((s) => s.orgId === orgId), [snapshot.chatSpaces, orgId]);
const providerRequests = useMemo(() => snapshot.providerRequests.filter((r) => r.orgId === orgId), [snapshot.providerRequests, orgId]);
const [newTaskOpen, setNewTaskOpen] = useState(false);
const pendingApprovals = approvals.filter((approval) => approval.state === "pending");
@@ -82,8 +84,16 @@ export function HomePage({ snapshot, actions, navigate }: PageProps) {
<GoalCard goal={primaryGoal} progress={goalProgress} organizationName={organization.name} />
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(5, minmax(0, 1fr))", gap: 12, marginTop: 12 }}>
<AgentWorkloadChart agents={agents} tasks={tasks} />
<BudgetUtilizationChart agents={agents} providers={providers} />
<RunActivityChart taskRuns={taskRuns} />
<SuccessRateChart taskRuns={taskRuns} />
<CostTrendChart providerRequests={providerRequests} />
</div>
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 1fr) minmax(0, 1fr)", gap: 12, marginTop: 12 }}>
<RunActivityCard taskRuns={taskRuns} tasks={tasks} agents={agents} onViewAll={() => navigate("work")} />
<RunActivityCard taskRuns={taskRuns} tasks={tasks} agents={agents} onViewAll={() => navigate("runs")} />
<RecentMessagesCard messages={chatMessages} agents={agents} employees={[employee]} spaces={chatSpaces} onViewAll={() => navigate("chat")} />
</div>
@@ -0,0 +1,179 @@
import type { AgentRecord, ProviderRecord, TaskRecord, TaskRunRecord } from "../../../../shared/domain";
import { TASK_STATUS } from "../../../../shared/domain";
const CHART_H = 120;
const CHART_W = 260;
const BAR_GAP = 4;
export function AgentWorkloadChart({ agents, tasks }: { agents: AgentRecord[]; tasks: TaskRecord[] }) {
const data = agents.slice(0, 8).map((a) => ({
name: a.name.split(/\s+/)[0],
count: tasks.filter((t) => t.assigneeAgentId === a.id && t.status !== TASK_STATUS.DONE).length,
}));
const max = Math.max(1, ...data.map((d) => d.count));
const barW = data.length > 0 ? (CHART_W - (data.length - 1) * BAR_GAP) / data.length : 0;
return (
<ChartCard title="Agent workload">
<svg viewBox={`0 0 ${CHART_W} ${CHART_H}`} style={{ width: "100%", height: CHART_H }}>
{data.map((d, i) => {
const h = (d.count / max) * (CHART_H - 20);
const x = i * (barW + BAR_GAP);
return (
<g key={d.name}>
<rect x={x} y={CHART_H - h - 16} width={barW} height={h} rx={3} fill="var(--accent)" opacity={0.7 + (d.count / max) * 0.3} />
<text x={x + barW / 2} y={CHART_H - 2} textAnchor="middle" fontSize="9" fill="var(--text-3)">{d.name}</text>
</g>
);
})}
</svg>
</ChartCard>
);
}
export function BudgetUtilizationChart({ agents, providers }: { agents: AgentRecord[]; providers: ProviderRecord[] }) {
const connected = providers.filter((p) => p.enabled);
const data = connected.slice(0, 6).map((p) => {
const allocated = agents.filter((a) => a.modelProviderId === p.id).reduce((s, a) => s + a.budgetCents, 0);
return { label: p.label.split(/\s+/)[0], cents: allocated };
});
const total = Math.max(1, data.reduce((s, d) => s + d.cents, 0));
return (
<ChartCard title="Budget utilization">
<svg viewBox={`0 0 ${CHART_W} ${CHART_H}`} style={{ width: "100%", height: CHART_H }}>
{(() => {
let cumX = 0;
return data.map((d, i) => {
const w = (d.cents / total) * CHART_W;
const x = cumX;
cumX += w;
const colors = ["var(--accent)", "var(--green)", "var(--amber)", "var(--violet)", "var(--red)", "var(--text-3)"];
return (
<g key={d.label}>
<rect x={x} y={20} width={Math.max(1, w - 2)} height={CHART_H - 40} rx={4} fill={colors[i % colors.length]} opacity={0.8} />
<text x={x + w / 2} y={CHART_H - 4} textAnchor="middle" fontSize="9" fill="var(--text-3)">{d.label}</text>
<text x={x + w / 2} y={14} textAnchor="middle" fontSize="9" fill="var(--text-2)">${Math.round(d.cents / 100)}</text>
</g>
);
});
})()}
</svg>
</ChartCard>
);
}
export function RunActivityChart({ taskRuns }: { taskRuns: TaskRunRecord[] }) {
const buckets = last7DayBuckets(taskRuns.map((r) => r.createdAt));
const max = Math.max(1, ...buckets.map((b) => b.count));
const barW = (CHART_W - 6 * BAR_GAP) / 7;
return (
<ChartCard title="Run activity (7d)">
<svg viewBox={`0 0 ${CHART_W} ${CHART_H}`} style={{ width: "100%", height: CHART_H }}>
{buckets.map((b, i) => {
const h = (b.count / max) * (CHART_H - 24);
const x = i * (barW + BAR_GAP);
return (
<g key={b.label}>
<rect x={x} y={CHART_H - h - 16} width={barW} height={h} rx={3} fill="var(--text-1)" opacity={0.6 + (b.count / max) * 0.4} />
<text x={x + barW / 2} y={CHART_H - 2} textAnchor="middle" fontSize="8" fill="var(--text-3)">{b.label}</text>
</g>
);
})}
</svg>
</ChartCard>
);
}
export function SuccessRateChart({ taskRuns }: { taskRuns: TaskRunRecord[] }) {
const completed = taskRuns.filter((r) => r.state === "completed").length;
const failed = taskRuns.filter((r) => r.state === "failed").length;
const total = completed + failed;
const rate = total > 0 ? Math.round((completed / total) * 100) : 0;
const circumference = 2 * Math.PI * 42;
const offset = circumference - (rate / 100) * circumference;
return (
<ChartCard title="Success rate">
<svg viewBox="0 0 100 100" style={{ width: 100, height: 100, margin: "0 auto", display: "block" }}>
<circle cx="50" cy="50" r="42" fill="none" stroke="var(--panel-3)" strokeWidth="8" />
<circle cx="50" cy="50" r="42" fill="none" stroke="var(--green)" strokeWidth="8"
strokeDasharray={circumference} strokeDashoffset={offset}
strokeLinecap="round" transform="rotate(-90 50 50)" />
<text x="50" y="50" textAnchor="middle" dominantBaseline="central" fontSize="18" fontWeight="600" fill="var(--text-1)">{rate}%</text>
</svg>
<div style={{ textAlign: "center", fontSize: 11, color: "var(--text-3)", marginTop: 4 }}>
{completed} passed · {failed} failed
</div>
</ChartCard>
);
}
export function CostTrendChart({ providerRequests }: { providerRequests: Array<{ createdAt: string; inputCharCount: number; outputCharCount: number }> }) {
const buckets = last7DayBuckets(providerRequests.map((r) => r.createdAt));
const charBuckets = buckets.map((b, i) => {
const dayStart = dayStartMs(i);
const dayEnd = dayStart + 86400000;
const dayRequests = providerRequests.filter((r) => {
const t = new Date(r.createdAt).getTime();
return t >= dayStart && t < dayEnd;
});
return { ...b, chars: dayRequests.reduce((s, r) => s + r.inputCharCount + r.outputCharCount, 0) };
});
const max = Math.max(1, ...charBuckets.map((b) => b.chars));
const points = charBuckets.map((b, i) => {
const x = (i / 6) * (CHART_W - 10) + 5;
const y = CHART_H - 20 - (b.chars / max) * (CHART_H - 30);
return `${x},${y}`;
}).join(" ");
return (
<ChartCard title="Token usage (7d)">
<svg viewBox={`0 0 ${CHART_W} ${CHART_H}`} style={{ width: "100%", height: CHART_H }}>
<polyline points={points} fill="none" stroke="var(--accent)" strokeWidth="2" strokeLinejoin="round" />
{charBuckets.map((b, i) => {
const x = (i / 6) * (CHART_W - 10) + 5;
const y = CHART_H - 20 - (b.chars / max) * (CHART_H - 30);
return <circle key={b.label} cx={x} cy={y} r="3" fill="var(--accent)" />;
})}
{charBuckets.map((b, i) => {
const x = (i / 6) * (CHART_W - 10) + 5;
return <text key={`l-${b.label}`} x={x} y={CHART_H - 2} textAnchor="middle" fontSize="8" fill="var(--text-3)">{b.label}</text>;
})}
</svg>
</ChartCard>
);
}
function ChartCard({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="card" style={{ padding: 14 }}>
<div className="card-label" style={{ marginBottom: 10 }}>{title}</div>
{children}
</section>
);
}
function last7DayBuckets(dates: string[]): Array<{ label: string; count: number }> {
const now = Date.now();
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
return Array.from({ length: 7 }, (_, i) => {
const offset = 6 - i;
const start = new Date(now - offset * 86400000);
start.setHours(0, 0, 0, 0);
const end = new Date(start.getTime() + 86400000);
const count = dates.filter((d) => {
const t = new Date(d).getTime();
return t >= start.getTime() && t < end.getTime();
}).length;
return { label: dayNames[start.getDay()], count };
});
}
function dayStartMs(bucketIndex: number): number {
const offset = 6 - bucketIndex;
const start = new Date(Date.now() - offset * 86400000);
start.setHours(0, 0, 0, 0);
return start.getTime();
}