[w9-2] Add 90d window, time-window selector, and chart/table toggle (#528)

Backend: widen usage _PeriodType to 24h/7d/30d/90d and add a 90d branch to
_parse_period (daily buckets already cover it). TestParsePeriod pins the
contract per window.

Frontend: UsagePeriod += 90d with a scaleFor helper (replacing 6 inline
ternaries) and 90 daily mock points. One generic SegmentedControl primitive
(reuses Radix Tabs) drives both the metrics time-window selector
(24h/7d/30d/90d) and the per-chart Chart/Table view toggle — one file, two
roles. The Token Usage & Costs tab drops 8 hardcoded '24h' hooks for a
single period state + selector; the stale '(24h)' cost-card parenthetical
goes too. The Performance landing tab gains a TaskStatusChart donut fed by
the status counts already on the page (no new hook). Agent/team bar charts
gain an inline table view.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-15 04:33:50 +02:00
committed by GitHub
co-authored by Renn F
parent 533ea97d01
commit d9084eeb07
12 changed files with 456 additions and 52 deletions
@@ -0,0 +1,41 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { TaskStatusChart } from "../task-status-chart";
describe("TaskStatusChart", () => {
it("renders the card title", () => {
render(
<TaskStatusChart
slices={[
{ name: "Completed", value: 5 },
{ name: "Pending", value: 0 },
]}
/>,
);
expect(
screen.getByText("Task Status Distribution"),
).toBeInTheDocument();
});
it("shows an empty state when every slice is zero", () => {
render(
<TaskStatusChart
slices={[
{ name: "Pending", value: 0 },
{ name: "Completed", value: 0 },
]}
/>,
);
expect(screen.getByText("No tasks")).toBeInTheDocument();
});
it("does not show the empty state while loading", () => {
render(
<TaskStatusChart
slices={[{ name: "Pending", value: 0 }]}
isLoading
/>,
);
expect(screen.queryByText("No tasks")).not.toBeInTheDocument();
});
});
@@ -1,5 +1,6 @@
"use client";
import { useState } from "react";
import {
BarChart,
Bar,
@@ -11,6 +12,7 @@ import {
} from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { SegmentedControl } from "@/components/ui/segmented-control";
import { useIsMobile } from "@/hooks/use-is-mobile";
import type { AgentUsageRow } from "@/types";
@@ -24,8 +26,14 @@ function fmtK(n: number): string {
return String(n);
}
const VIEW_OPTIONS = [
{ value: "chart", label: "Chart" },
{ value: "table", label: "Table" },
];
export function AgentUsageChart({ data, isLoading }: AgentUsageChartProps) {
const isMobile = useIsMobile();
const [view, setView] = useState<"chart" | "table">("chart");
// Fewer bars on a phone — 10 labels at ~30deg rotation still overlap below
// ~400px, so cap the label density instead of shrinking text further.
const chartData = [...(data ?? [])]
@@ -35,15 +43,51 @@ export function AgentUsageChart({ data, isLoading }: AgentUsageChartProps) {
name: row.agent_slug,
Tokens: row.total_tokens,
}));
const tableRows = [...(data ?? [])].sort(
(a, b) => b.total_tokens - a.total_tokens,
);
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Agent Tokens Today</CardTitle>
<div className="flex items-center justify-between gap-2">
<CardTitle className="text-base">Agent Tokens</CardTitle>
<SegmentedControl
options={VIEW_OPTIONS}
value={view}
onValueChange={(v) => setView(v as "chart" | "table")}
aria-label="Agent tokens view"
/>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<Skeleton className="h-52 w-full" />
) : view === "table" ? (
<div className="max-h-52 overflow-y-auto">
<table className="w-full text-sm">
<thead className="text-muted-foreground text-xs">
<tr>
<th className="text-left font-medium py-1">Agent</th>
<th className="text-right font-medium py-1">Tokens</th>
<th className="text-right font-medium py-1">%</th>
</tr>
</thead>
<tbody>
{tableRows.map((row) => (
<tr key={row.agent_slug} className="border-t">
<td className="py-1 truncate">{row.agent_slug}</td>
<td className="py-1 text-right tabular-nums">
{row.total_tokens.toLocaleString()}
</td>
<td className="py-1 text-right tabular-nums">
{row.pct_of_total.toFixed(1)}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<ResponsiveContainer width="100%" height={208}>
<BarChart
+1
View File
@@ -2,4 +2,5 @@ export { UsageTimeSeriesChart } from "./usage-time-series-chart";
export { ModelUsageDonut } from "./model-usage-donut";
export { AgentUsageChart } from "./agent-usage-chart";
export { TeamUsageChart } from "./team-usage-chart";
export { TaskStatusChart } from "./task-status-chart";
export { SessionsTable } from "./sessions-table";
@@ -0,0 +1,86 @@
"use client";
import {
PieChart,
Pie,
Cell,
Tooltip,
ResponsiveContainer,
Legend,
} from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
interface TaskStatusSlice {
name: string;
value: number;
}
interface TaskStatusChartProps {
slices: TaskStatusSlice[];
isLoading?: boolean;
}
const CHART_COLORS = [
"var(--chart-1)",
"var(--chart-2)",
"var(--chart-3)",
"var(--chart-4)",
"var(--chart-5)",
];
/**
* Task-status distribution donut for the Performance landing tab — the one
* chart that tab was missing (W9-2). Fed by the status counts already
* computed on the page; no extra hook.
*/
export function TaskStatusChart({ slices, isLoading }: TaskStatusChartProps) {
const chartData = slices.filter((s) => s.value > 0);
const total = chartData.reduce((sum, s) => sum + s.value, 0);
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Task Status Distribution</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<Skeleton className="h-52 w-full" />
) : total === 0 ? (
<p className="text-sm text-muted-foreground text-center py-12">
No tasks
</p>
) : (
<ResponsiveContainer width="100%" height={208}>
<PieChart>
<Pie
data={chartData}
cx="50%"
cy="50%"
innerRadius={52}
outerRadius={80}
dataKey="value"
paddingAngle={3}
>
{chartData.map((_, idx) => (
<Cell
key={idx}
fill={CHART_COLORS[idx % CHART_COLORS.length]}
/>
))}
</Pie>
<Tooltip
formatter={(value, name) => [
`${typeof value === "number" ? value : 0} tasks`,
name,
]}
contentStyle={{ fontSize: 12 }}
/>
<Legend wrapperStyle={{ fontSize: 11 }} />
</PieChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
);
}
@@ -1,5 +1,6 @@
"use client";
import { useState } from "react";
import {
BarChart,
Bar,
@@ -11,6 +12,7 @@ import {
} from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { SegmentedControl } from "@/components/ui/segmented-control";
import { useIsMobile } from "@/hooks/use-is-mobile";
import type { TeamUsageRow } from "@/types";
@@ -24,23 +26,67 @@ function fmtK(n: number): string {
return String(n);
}
const VIEW_OPTIONS = [
{ value: "chart", label: "Chart" },
{ value: "table", label: "Table" },
];
export function TeamUsageChart({ data, isLoading }: TeamUsageChartProps) {
const isMobile = useIsMobile();
const [view, setView] = useState<"chart" | "table">("chart");
const chartData = [...(data ?? [])]
.sort((a, b) => b.total_tokens - a.total_tokens)
.map((row) => ({
name: row.team.replace(/_/g, " "),
Tokens: row.total_tokens,
}));
const tableRows = [...(data ?? [])].sort(
(a, b) => b.total_tokens - a.total_tokens,
);
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Team Tokens</CardTitle>
<div className="flex items-center justify-between gap-2">
<CardTitle className="text-base">Team Tokens</CardTitle>
<SegmentedControl
options={VIEW_OPTIONS}
value={view}
onValueChange={(v) => setView(v as "chart" | "table")}
aria-label="Team tokens view"
/>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<Skeleton className="h-52 w-full" />
) : view === "table" ? (
<div className="max-h-52 overflow-y-auto">
<table className="w-full text-sm">
<thead className="text-muted-foreground text-xs">
<tr>
<th className="text-left font-medium py-1">Team</th>
<th className="text-right font-medium py-1">Tokens</th>
<th className="text-right font-medium py-1">%</th>
</tr>
</thead>
<tbody>
{tableRows.map((row) => (
<tr key={row.team} className="border-t">
<td className="py-1 capitalize">
{row.team.replace(/_/g, " ")}
</td>
<td className="py-1 text-right tabular-nums">
{row.total_tokens.toLocaleString()}
</td>
<td className="py-1 text-right tabular-nums">
{row.pct_of_total.toFixed(1)}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<ResponsiveContainer width="100%" height={208}>
<BarChart
@@ -0,0 +1,61 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { SegmentedControl } from "../segmented-control";
const WINDOWS = [
{ value: "24h", label: "24h" },
{ value: "7d", label: "7d" },
{ value: "30d", label: "30d" },
{ value: "90d", label: "90d" },
];
describe("SegmentedControl", () => {
it("renders every option as a tab", () => {
render(
<SegmentedControl
options={WINDOWS}
value="24h"
onValueChange={() => {}}
aria-label="Time window"
/>,
);
for (const opt of WINDOWS) {
expect(screen.getByRole("tab", { name: opt.label })).toBeInTheDocument();
}
});
it("marks only the selected option active", () => {
render(
<SegmentedControl
options={WINDOWS}
value="7d"
onValueChange={() => {}}
aria-label="Time window"
/>,
);
expect(screen.getByRole("tab", { name: "7d" })).toHaveAttribute(
"data-state",
"active",
);
expect(screen.getByRole("tab", { name: "24h" })).toHaveAttribute(
"data-state",
"inactive",
);
});
it("calls onValueChange with the clicked option's value", async () => {
const onChange = vi.fn();
const user = userEvent.setup();
render(
<SegmentedControl
options={WINDOWS}
value="24h"
onValueChange={onChange}
aria-label="Time window"
/>,
);
await user.click(screen.getByRole("tab", { name: "90d" }));
expect(onChange).toHaveBeenCalledWith("90d");
});
});
@@ -0,0 +1,47 @@
"use client";
import * as React from "react";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
export interface SegmentedOption {
value: string;
label: string;
}
interface SegmentedControlProps {
options: SegmentedOption[];
value: string;
onValueChange: (value: string) => void;
"aria-label"?: string;
className?: string;
}
/**
* Generic segmented control — a value + N mutually-exclusive options.
* Built on the Radix Tabs primitive (value/onValueChange, no panels).
* Used for the metrics time-window selector (24h/7d/30d/90d) and the
* chart/table view toggle — one primitive, two roles.
*/
export function SegmentedControl({
options,
value,
onValueChange,
className,
...rest
}: SegmentedControlProps) {
return (
<Tabs
value={value}
onValueChange={onValueChange}
className={className}
>
<TabsList {...rest}>
{options.map((opt) => (
<TabsTrigger key={opt.value} value={opt.value}>
{opt.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
);
}