feat(08-01): install Recharts and create tested data aggregation functions

- Install recharts@3.8.1 as dependency
- Create chart-utils.ts with 4 exported pure functions: aggregateRunsByDay, aggregateCostsByDay, aggregateAgentWorkload, formatShortDate
- Create dashboard-charts.test.ts with 16 unit tests covering all aggregation behaviors
- All tests pass: 14-day bucketing with zero-fill, timed_out/interrupted as failed, null cost handling, top-8 + Others grouping, name truncation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 4.6 committed 2026-03-27 08:44:49 -04:00
1 parent c1de05a7f1
commit a45e6bc10c
4 files changed
+714 -2

No files matched your search

@@ -0,0 +1,142 @@
import type { RunRecord, CostRecord, AgentRecord } from "@shared/types";
export interface DailyRunData {
date: string; // "YYYY-MM-DD"
total: number;
succeeded: number;
failed: number;
}
export interface DailyCostData {
date: string; // "YYYY-MM-DD"
total: number; // USD amount (NOT cents)
}
export interface AgentWorkloadData {
name: string; // agent name, truncated to 10 chars
runs: number;
}
/**
* Aggregate runs into daily buckets for chart consumption.
* Returns `days` entries sorted ascending by date (oldest first).
* Days with no runs have {total:0, succeeded:0, failed:0}.
*/
export function aggregateRunsByDay(runs: RunRecord[], days: number): DailyRunData[] {
const now = new Date();
const buckets = new Map<string, { total: number; succeeded: number; failed: number }>();
// Initialize all day buckets with zero values
for (let i = 0; i < days; i++) {
const date = new Date(now.getTime() - i * 24 * 60 * 60 * 1000);
const key = date.toISOString().slice(0, 10);
buckets.set(key, { total: 0, succeeded: 0, failed: 0 });
}
// Fill buckets from actual data
for (const run of runs) {
const day = run.createdAt.slice(0, 10);
const bucket = buckets.get(day);
if (!bucket) continue;
bucket.total++;
if (run.status === "succeeded") bucket.succeeded++;
if (run.status === "failed" || run.status === "timed_out" || run.status === "interrupted") {
bucket.failed++;
}
}
// Return sorted ascending by date
return Array.from(buckets.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, data]) => ({ date, ...data }));
}
/**
* Aggregate costs into daily buckets for chart consumption.
* Returns `days` entries sorted ascending by date.
* Days with no costs have {total:0}. Null amountUsd is treated as 0.
*/
export function aggregateCostsByDay(costs: CostRecord[], days: number): DailyCostData[] {
const now = new Date();
const buckets = new Map<string, number>();
// Initialize all day buckets with zero
for (let i = 0; i < days; i++) {
const date = new Date(now.getTime() - i * 24 * 60 * 60 * 1000);
const key = date.toISOString().slice(0, 10);
buckets.set(key, 0);
}
// Sum costs per day
for (const cost of costs) {
const day = cost.createdAt.slice(0, 10);
const current = buckets.get(day);
if (current === undefined) continue;
buckets.set(day, current + (cost.amountUsd ?? 0));
}
// Return sorted ascending by date
return Array.from(buckets.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, total]) => ({ date, total }));
}
/**
* Group runs by agent and return workload data sorted by run count descending.
* If more than maxAgents, keep top maxAgents and aggregate rest into "Others".
* Agent names longer than 10 chars are truncated with ellipsis.
*/
export function aggregateAgentWorkload(
runs: RunRecord[],
agents: AgentRecord[],
maxAgents: number = 8,
): AgentWorkloadData[] {
// Build name lookup from agents array
const nameMap = new Map<string, string>();
for (const agent of agents) {
nameMap.set(agent.id, agent.name);
}
// Count runs per agent
const counts = new Map<string, number>();
for (const run of runs) {
counts.set(run.agentId, (counts.get(run.agentId) ?? 0) + 1);
}
// Sort by count descending
const sorted = Array.from(counts.entries())
.sort(([, a], [, b]) => b - a);
const truncateName = (name: string): string =>
name.length > 10 ? name.slice(0, 10) + "..." : name;
// If within limit, return all
if (sorted.length <= maxAgents) {
return sorted.map(([agentId, runCount]) => ({
name: truncateName(nameMap.get(agentId) ?? agentId),
runs: runCount,
}));
}
// Split into top agents and "Others"
const top = sorted.slice(0, maxAgents);
const rest = sorted.slice(maxAgents);
const othersRuns = rest.reduce((sum, [, count]) => sum + count, 0);
const result: AgentWorkloadData[] = top.map(([agentId, runCount]) => ({
name: truncateName(nameMap.get(agentId) ?? agentId),
runs: runCount,
}));
result.push({ name: "Others", runs: othersRuns });
return result;
}
/**
* Format a YYYY-MM-DD date string as "MMM dd" (e.g., "Mar 15").
* Uses T00:00:00 suffix to avoid timezone shift.
*/
export function formatShortDate(dateStr: string): string {
const date = new Date(dateStr + "T00:00:00");
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
}