diff --git a/apps/web/src/components/overview/overview-agent-distribution.test.tsx b/apps/web/src/components/overview/overview-agent-distribution.test.tsx new file mode 100644 index 0000000..c10c9aa --- /dev/null +++ b/apps/web/src/components/overview/overview-agent-distribution.test.tsx @@ -0,0 +1,45 @@ +import { cleanup, fireEvent, render, screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import type { DashboardAgentStat } from "../../lib/api"; +import { OverviewAgentDistribution } from "./overview-agent-distribution"; + +const perAgent: DashboardAgentStat[] = [ + { + name: "codex", + displayName: "Codex", + icon: "/icon/agent/codex.svg", + sessions: 3, + messages: 30, + tokens: 3_000, + cost: 4, + }, + { + name: "claudecode", + displayName: "Claude Code", + icon: "/icon/agent/claudecode.svg", + sessions: 2, + messages: 20, + tokens: 2_000, + cost: 2, + }, +]; + +afterEach(cleanup); + +describe("OverviewAgentDistribution", () => { + it("browses agent columns and announces their values from the keyboard", () => { + render(); + const region = screen.getByRole("region", { name: "Agents" }); + const chart = within(region).getByRole("listbox", { name: "Agent distribution chart" }); + const options = within(chart).getAllByRole("option"); + const liveSummary = within(region).getByRole("status"); + + fireEvent.focus(options[0]!); + expect(liveSummary.textContent).toBe("Codex: $4.00, 3 sessions"); + + fireEvent.keyDown(options[0]!, { key: "ArrowRight" }); + expect(document.activeElement).toBe(options[1]); + expect(liveSummary.textContent).toBe("Claude Code: $2.00, 2 sessions"); + }); +}); diff --git a/apps/web/src/components/overview/overview-agent-distribution.tsx b/apps/web/src/components/overview/overview-agent-distribution.tsx index 1658b19..da97703 100644 --- a/apps/web/src/components/overview/overview-agent-distribution.tsx +++ b/apps/web/src/components/overview/overview-agent-distribution.tsx @@ -21,18 +21,23 @@ const BAR_COLORS = ["var(--brand)"]; export function OverviewAgentDistribution({ perAgent }: { perAgent: DashboardAgentStat[] }) { const [hover, setHover] = useState(null); - const { byCost, visible, values, axisMax } = useMemo(() => { + const { byCost, visible, values, axisMax, itemLabels } = useMemo(() => { const byCost = perAgent.some((agent) => agent.cost > 0); const weightOf = (agent: DashboardAgentStat) => (byCost ? agent.cost : agent.sessions); const visible = [...perAgent].sort((a, b) => weightOf(b) - weightOf(a)).slice(0, AGENT_LIMIT); const values = visible.map((agent) => [weightOf(agent)]); + const itemLabels = visible.map((agent) => + byCost + ? `${agent.displayName}: ${formatUsd(agent.cost)}, ${formatInt(agent.sessions)} sessions` + : `${agent.displayName}: ${formatInt(agent.sessions)} sessions`, + ); // The leader touches the top: with the figures printed under every bar // there is no axis to round to, and rounded headroom would just be blank. - return { byCost, visible, values, axisMax: Math.max(...values.flat(), 0) }; + return { byCost, visible, values, axisMax: Math.max(...values.flat(), 0), itemLabels }; }, [perAgent]); return ( - +
{ expect(screen.getByText("$10.00")).toBeTruthy(); expect(screen.getByText("80%")).toBeTruthy(); expect(screen.getByText("20%")).toBeTruthy(); + expect(screen.getByRole("region", { name: "Cost by Model" })).toBeTruthy(); + }); + + it("browses model slices and announces their values from the keyboard", () => { + render( + , + ); + const chart = screen.getByRole("listbox", { name: "Cost by Model chart" }); + const options = within(chart).getAllByRole("option"); + const liveSummary = screen.getByRole("status"); + + fireEvent.focus(options[0]!); + expect(liveSummary.textContent).toBe("sonnet: 80%, $8.00"); + + fireEvent.keyDown(options[0]!, { key: "ArrowRight" }); + expect(document.activeElement).toBe(options[1]); + expect(liveSummary.textContent).toBe("haiku: 20%, $2.00"); }); it("collapses a four-figure total so it fits the ring", () => { diff --git a/apps/web/src/components/overview/overview-cost-breakdown.tsx b/apps/web/src/components/overview/overview-cost-breakdown.tsx index a9bbfb3..172585c 100644 --- a/apps/web/src/components/overview/overview-cost-breakdown.tsx +++ b/apps/web/src/components/overview/overview-cost-breakdown.tsx @@ -93,9 +93,13 @@ export function OverviewCostBreakdown({ ); const title = byCost ? "Cost by Model" : "Models"; + const itemLabels = entries.map( + (entry) => + `${entry.label}: ${formatPercent(ringTotal > 0 ? entry.value / ringTotal : 0)}, ${entry.display}`, + ); return ( - + {entries.length === 0 ? ( @@ -108,6 +112,8 @@ export function OverviewCostBreakdown({ hovered={hovered} onHover={setHovered} size={DONUT_SIZE} + ariaLabel={`${title} chart`} + itemLabels={itemLabels} > {byCost ? formatUsdCompact(totals.cost) : formatCompact(totals.tokens)} diff --git a/apps/web/src/components/overview/overview-usage-chart.test.tsx b/apps/web/src/components/overview/overview-usage-chart.test.tsx index 368a8f8..8e3d86e 100644 --- a/apps/web/src/components/overview/overview-usage-chart.test.tsx +++ b/apps/web/src/components/overview/overview-usage-chart.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, render, screen, within } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen, within } from "@testing-library/react"; import { afterEach, describe, expect, it } from "vitest"; import type { DashboardDailyBucket } from "../../lib/api"; import { OverviewUsageChart } from "./overview-usage-chart"; @@ -62,4 +62,26 @@ describe("OverviewUsageChart", () => { expect(screen.getByText("No usage data")).toBeTruthy(); expect(screen.queryByText("Daily cost")).toBeNull(); }); + + it("moves the tooltip and live summary with the keyboard", () => { + render(); + const chart = screen.getByRole("listbox", { name: "Daily usage chart" }); + const options = within(chart).getAllByRole("option"); + const liveSummary = screen.getByRole("status"); + expect(options.map((option) => option.tabIndex)).toEqual([0, -1]); + + fireEvent.focus(options[0]!); + expect(screen.getByText("2 sessions · 30 messages")).toBeTruthy(); + expect(liveSummary.textContent).toContain("01-01: 1.0k tokens"); + + fireEvent.keyDown(options[0]!, { key: "ArrowRight" }); + expect(document.activeElement).toBe(options[1]); + expect(options.map((option) => option.tabIndex)).toEqual([-1, 0]); + expect(screen.getByText("3 sessions · 45 messages")).toBeTruthy(); + expect(liveSummary.textContent).toContain("01-02: 100 tokens"); + + fireEvent.keyDown(options[1]!, { key: "Escape" }); + expect(screen.queryByText("3 sessions · 45 messages")).toBeNull(); + expect(liveSummary.textContent).toBe(""); + }); }); diff --git a/apps/web/src/components/overview/overview-usage-chart.tsx b/apps/web/src/components/overview/overview-usage-chart.tsx index 1ce4df6..fe03c75 100644 --- a/apps/web/src/components/overview/overview-usage-chart.tsx +++ b/apps/web/src/components/overview/overview-usage-chart.tsx @@ -44,6 +44,10 @@ function bucketTokens(bucket: DashboardDailyBucket): number { return bucket.input + bucket.output + bucket.cache_read + bucket.cache_create; } +function bucketSummary(bucket: DashboardDailyBucket): string { + return `${formatMonthDay(bucket.date)}: ${formatCompact(bucketTokens(bucket))} tokens, ${formatUsd(bucket.cost)}, ${formatInt(bucket.sessions)} sessions, ${formatInt(bucket.messages)} messages, input ${formatCompact(bucket.input)}, output ${formatCompact(bucket.output)}, cache read ${formatCompact(bucket.cache_read)}, cache write ${formatCompact(bucket.cache_create)}`; +} + function CostArea({ daily }: { daily: DashboardDailyBucket[] }) { const costs = useMemo(() => daily.map((bucket) => bucket.cost), [daily]); const labels = useMemo(() => daily.map((bucket) => formatMonthDay(bucket.date)), [daily]); @@ -112,6 +116,7 @@ export function OverviewUsageChart({ daily }: { daily: DashboardDailyBucket[] }) () => daily.map((bucket) => TOKEN_SERIES.map((series) => bucket[series.key])), [daily], ); + const itemLabels = useMemo(() => daily.map(bucketSummary), [daily]); const axisMax = niceMax(daily.reduce((peak, bucket) => Math.max(peak, bucketTokens(bucket)), 0)); const first = daily[0]; @@ -164,6 +169,8 @@ export function OverviewUsageChart({ daily }: { daily: DashboardDailyBucket[] }) layout={BAR_LAYOUT} height={BAR_HEIGHT} formatTick={formatCompact} + ariaLabel="Daily usage chart" + itemLabels={itemLabels} /> {hovered && hover ? (
diff --git a/apps/web/src/components/ui/chart-keyboard-list.tsx b/apps/web/src/components/ui/chart-keyboard-list.tsx new file mode 100644 index 0000000..6cfa256 --- /dev/null +++ b/apps/web/src/components/ui/chart-keyboard-list.tsx @@ -0,0 +1,88 @@ +import { useRef, useState, type KeyboardEvent } from "react"; + +import { cn } from "../../lib/utils"; + +function nextItemIndex(key: string, index: number, lastIndex: number): number | null { + if (key === "ArrowLeft") return Math.max(0, index - 1); + if (key === "ArrowRight") return Math.min(lastIndex, index + 1); + if (key === "Home") return 0; + if (key === "End") return lastIndex; + return null; +} + +export function ChartKeyboardList({ + label, + itemLabels, + activeIndex, + onActiveIndexChange, + layout, +}: { + label: string; + itemLabels: readonly string[]; + activeIndex: number | null; + onActiveIndexChange: (index: number | null) => void; + layout: "columns" | "surface"; +}) { + const itemRefs = useRef>([]); + const [rovingIndex, setRovingIndex] = useState(0); + const lastIndex = itemLabels.length - 1; + const tabIndex = Math.min(rovingIndex, Math.max(0, lastIndex)); + + const handleKeyDown = (event: KeyboardEvent, index: number) => { + if (event.key === "Escape") { + event.preventDefault(); + onActiveIndexChange(null); + return; + } + const nextIndex = nextItemIndex(event.key, index, lastIndex); + if (nextIndex === null) return; + + event.preventDefault(); + setRovingIndex(nextIndex); + itemRefs.current[nextIndex]?.focus(); + }; + + return ( + <> +
{ + if (!event.currentTarget.contains(event.relatedTarget)) onActiveIndexChange(null); + }} + > + {itemLabels.map((itemLabel, index) => ( +
{ + itemRefs.current[index] = element; + }} + role="option" + aria-label={itemLabel} + aria-selected={activeIndex === index} + tabIndex={index === tabIndex ? 0 : -1} + className={cn( + "pointer-events-none focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[var(--brand)]", + layout === "columns" + ? "h-full min-w-0 flex-1 rounded-sm" + : "absolute inset-0 rounded-full", + )} + onFocus={() => { + setRovingIndex(index); + onActiveIndexChange(index); + }} + onKeyDown={(event) => handleKeyDown(event, index)} + /> + ))} +
+ + {activeIndex === null ? "" : itemLabels[activeIndex]} + + + ); +} diff --git a/apps/web/src/components/ui/tile-bar-plot.tsx b/apps/web/src/components/ui/tile-bar-plot.tsx index 79513ca..e11275a 100644 --- a/apps/web/src/components/ui/tile-bar-plot.tsx +++ b/apps/web/src/components/ui/tile-bar-plot.tsx @@ -15,6 +15,7 @@ import { } from "../../hooks/useBarField"; import { usePrefersReducedMotion } from "../../hooks/usePrefersReducedMotion"; import { cn } from "../../lib/utils"; +import { ChartKeyboardList } from "./chart-keyboard-list"; const TICK_FRACTIONS = [1, 0.75, 0.5, 0.25, 0] as const; @@ -31,6 +32,8 @@ export function TileBarPlot({ layout, height, formatTick, + ariaLabel, + itemLabels, className, }: { /** `[column][band]`; a plain bar chart is one band per column. */ @@ -44,6 +47,8 @@ export function TileBarPlot({ height: number; /** Provide to render a value axis on the left; omit for a bare plot. */ formatTick?: (value: number) => string; + ariaLabel: string; + itemLabels: readonly string[]; className?: string; }) { const canvasRef = useRef(null); @@ -94,6 +99,13 @@ export function TileBarPlot({ /> ))} + onHover(column === null ? null : { column, band: null })} + layout="columns" + />
); diff --git a/apps/web/src/components/ui/tile-donut.tsx b/apps/web/src/components/ui/tile-donut.tsx index cf01022..c4baa14 100644 --- a/apps/web/src/components/ui/tile-donut.tsx +++ b/apps/web/src/components/ui/tile-donut.tsx @@ -6,6 +6,7 @@ import { useRef, type ReactNode } from "react"; import { useDonutRing } from "../../hooks/useDonutRing"; import { usePrefersReducedMotion } from "../../hooks/usePrefersReducedMotion"; +import { ChartKeyboardList } from "./chart-keyboard-list"; export function TileDonut({ shares, @@ -13,6 +14,8 @@ export function TileDonut({ hovered, onHover, size, + ariaLabel, + itemLabels, children, }: { /** Fractions of the ring, in draw order; they should sum to 1. */ @@ -21,6 +24,8 @@ export function TileDonut({ hovered: number | null; onHover: (index: number | null) => void; size: number; + ariaLabel: string; + itemLabels: readonly string[]; /** Centre content, e.g. the total. */ children?: ReactNode; }) { @@ -39,6 +44,13 @@ export function TileDonut({
{children}
+
); }