feat(ui): shadcn charts foundation with tremor-compatible wrappers (#32668)

This commit is contained in:
ryan-crabbe-berri 2026-07-09 18:18:52 -07:00 committed by GitHub
parent eec948dcb8
commit 592510ec18
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1582 additions and 129 deletions

View file

@ -1,6 +1,6 @@
{
"@typescript-eslint/no-explicit-any": 1980,
"complexity": 128,
"@typescript-eslint/no-explicit-any": 1978,
"complexity": 129,
"local/no-large-inline-object-arg": 512,
"local/no-long-condition-chain": 233,
"max-depth": 59,

View file

@ -4,6 +4,14 @@
"count": 1
}
},
"src/app/(dashboard)/admin-panel/_components/AdminPanel.tsx": {
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/api-reference/_components/APIReferenceView.tsx": {
"no-restricted-imports": {
"count": 1
@ -156,16 +164,6 @@
"count": 8
}
},
"src/app/(dashboard)/guardrails-monitor/_components/ScoreChart.test.tsx": {
"react/display-name": {
"count": 1
}
},
"src/app/(dashboard)/guardrails-monitor/_components/ScoreChart.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/hooks/accessGroups/useAccessGroupDetails.ts": {
"no-restricted-syntax": {
"count": 1
@ -373,6 +371,22 @@
"count": 1
}
},
"src/app/(dashboard)/old-usage/_components/usage.tsx": {
"no-restricted-imports": {
"count": 2
},
"react-hooks/immutability": {
"count": 1
},
"react-hooks/purity": {
"count": 1
}
},
"src/app/(dashboard)/organizations/_components/organizations.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/playground/components/chat_ui/AdditionalModelSettings.tsx": {
"no-restricted-imports": {
"count": 1
@ -649,6 +663,14 @@
"count": 1
}
},
"src/app/(dashboard)/router-settings/_components/general_settings.tsx": {
"no-nested-ternary": {
"count": 3
},
"no-restricted-imports": {
"count": 2
}
},
"src/app/(dashboard)/search-tools/_components/CreateSearchTools.tsx": {
"no-restricted-imports": {
"count": 1
@ -851,14 +873,6 @@
"count": 1
}
},
"src/app/(dashboard)/admin-panel/_components/AdminPanel.tsx": {
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/CreateUserButton.tsx": {
"no-restricted-imports": {
"count": 1
@ -1520,14 +1534,6 @@
"count": 1
}
},
"src/app/(dashboard)/router-settings/_components/general_settings.tsx": {
"no-nested-ternary": {
"count": 3
},
"no-restricted-imports": {
"count": 2
}
},
"src/components/guardrails.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
@ -2028,11 +2034,6 @@
"count": 1
}
},
"src/app/(dashboard)/organizations/_components/organizations.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/page_utils.test.ts": {
"max-nested-callbacks": {
"count": 3
@ -2371,17 +2372,6 @@
"count": 1
}
},
"src/app/(dashboard)/old-usage/_components/usage.tsx": {
"no-restricted-imports": {
"count": 2
},
"react-hooks/immutability": {
"count": 1
},
"react-hooks/purity": {
"count": 1
}
},
"src/components/user_agent_activity.tsx": {
"no-restricted-imports": {
"count": 2

View file

@ -34,6 +34,7 @@
"react-json-view-lite": "2.5.0",
"react-markdown": "9.1.0",
"react-syntax-highlighter": "15.6.6",
"recharts": "3.9.2",
"remark-gfm": "4.0.1",
"tailwind-merge": "3.4.0",
"uuid": "14.0.0"
@ -2927,6 +2928,32 @@
"npm": ">=9.5.0"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
"integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz",
@ -3284,6 +3311,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@ -3804,6 +3843,42 @@
"react-dom": ">=16.6.0"
}
},
"node_modules/@tremor/react/node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
"node_modules/@tremor/react/node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT"
},
"node_modules/@tremor/react/node_modules/recharts": {
"version": "2.15.4",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
"integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
"deprecated": "1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide",
"license": "MIT",
"dependencies": {
"clsx": "^2.0.0",
"eventemitter3": "^4.0.1",
"lodash": "^4.17.21",
"react-is": "^18.3.1",
"react-smooth": "^4.0.4",
"recharts-scale": "^0.4.4",
"tiny-invariant": "^1.3.1",
"victory-vendor": "^36.6.8"
},
"engines": {
"node": ">=14"
},
"peerDependencies": {
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tremor/react/node_modules/tailwind-merge": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz",
@ -3814,6 +3889,28 @@
"url": "https://github.com/sponsors/dcastil"
}
},
"node_modules/@tremor/react/node_modules/victory-vendor": {
"version": "36.9.2",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
"integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
@ -4069,6 +4166,12 @@
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
"license": "MIT"
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.60.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.1.tgz",
@ -6309,6 +6412,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/es-toolkit": {
"version": "1.49.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz",
"integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/esbuild": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
@ -6872,9 +6985,9 @@
}
},
"node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/expect-type": {
@ -6901,9 +7014,9 @@
"license": "MIT"
},
"node_modules/fast-equals": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
"integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.1.tgz",
"integrity": "sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
@ -7688,6 +7801,16 @@
"node": ">= 4"
}
},
"node_modules/immer": {
"version": "11.1.11",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.11.tgz",
"integrity": "sha512-qzXuyXAkPySAGYkfsAwodDPWT8Zm7/Uo5BNt4BjhMhG5WlWyZZ4wQqnWwdS8kjlQ1Cwu6gjw3A6+0gTQwlyYtw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@ -11589,7 +11712,6 @@
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT"
},
"node_modules/react-json-view-lite": {
@ -11631,6 +11753,29 @@
"react": ">=18"
}
},
"node_modules/react-redux": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-smooth": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
@ -11707,26 +11852,33 @@
}
},
"node_modules/recharts": {
"version": "2.15.4",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
"integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
"version": "3.9.2",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.9.2.tgz",
"integrity": "sha512-G4fy+Pk46RaXgwWMh+Nzhyo/lbFAVqXo9gtetlyehe6Ehge9CsgDuOTwQDD+i1+llaLktNBiNq4bhnGlDRXFtw==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"clsx": "^2.0.0",
"eventemitter3": "^4.0.1",
"lodash": "^4.17.21",
"react-is": "^18.3.1",
"react-smooth": "^4.0.4",
"recharts-scale": "^0.4.4",
"tiny-invariant": "^1.3.1",
"victory-vendor": "^36.6.8"
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^11.1.8",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.2.0",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=14"
"node": ">=18"
},
"peerDependencies": {
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/recharts-scale": {
@ -11738,12 +11890,6 @@
"decimal.js-light": "^2.4.1"
}
},
"node_modules/recharts/node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT"
},
"node_modules/redent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
@ -11758,6 +11904,21 @@
"node": ">=8"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@ -13429,9 +13590,9 @@
}
},
"node_modules/victory-vendor": {
"version": "36.9.2",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
"integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",

View file

@ -50,6 +50,7 @@
"react-json-view-lite": "2.5.0",
"react-markdown": "9.1.0",
"react-syntax-highlighter": "15.6.6",
"recharts": "3.9.2",
"remark-gfm": "4.0.1",
"tailwind-merge": "3.4.0",
"uuid": "14.0.0"

View file

@ -1,35 +1,9 @@
import React from "react";
import { describe, it, expect, vi } from "vitest";
import { describe, it, expect } from "vitest";
import { screen } from "@testing-library/react";
import { renderWithProviders } from "../../../../../tests/test-utils";
import { ScoreChart } from "./ScoreChart";
vi.mock("@tremor/react", async (importOriginal) => {
const actual = await importOriginal<typeof import("@tremor/react")>();
// Re-apply the global Button/Tooltip overrides from tests/setupTests.ts. A file-level
// vi.mock fully replaces the setup-level mock, so without this the real Tremor Button
// leaks through and its useTooltip(300) schedules a native setTimeout that can fire
// post-teardown -> "window is not defined".
return {
...actual,
BarChart: ({ data, categories }: { data: any[]; categories: string[] }) => (
<div data-testid="bar-chart">
{data.map((d, i) => (
<span key={i}>
{d.date}: {categories.map((c) => `${c}=${d[c]}`).join(", ")}
</span>
))}
</div>
),
Button: React.forwardRef<HTMLButtonElement, any>(({ children, ...props }, ref) => (
<button {...props} ref={ref}>
{children}
</button>
)),
Tooltip: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
};
});
describe("ScoreChart", () => {
it("should render the title", () => {
renderWithProviders(<ScoreChart />);
@ -55,10 +29,14 @@ describe("ScoreChart", () => {
{ date: "2026-03-02", passed: 15, blocked: 1 },
];
renderWithProviders(<ScoreChart data={data} />);
const { container } = renderWithProviders(<ScoreChart data={data} />);
expect(screen.queryByText("No chart data for this period")).not.toBeInTheDocument();
expect(screen.getByText(/2026-03-01/)).toBeInTheDocument();
expect(screen.getByText(/2026-03-02/)).toBeInTheDocument();
expect(screen.getByText("passed")).toBeInTheDocument();
expect(screen.getByText("blocked")).toBeInTheDocument();
expect(screen.getAllByText(/2026-03-01/).length).toBeGreaterThan(0);
expect(screen.getAllByText(/2026-03-02/).length).toBeGreaterThan(0);
const bars = container.querySelectorAll(".recharts-bar");
expect(bars).toHaveLength(2);
});
});

View file

@ -1,9 +1,10 @@
import { BarChart, Card, Title } from "@tremor/react";
import React from "react";
import { BarChart } from "@/components/shared/charts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
/**
* Overview chart: Request Outcomes Over Time (passed vs blocked).
* Uses Tremor BarChart with stacked data. Data from usage/overview API (chart array).
* Stacked bar chart. Data from usage/overview API (chart array).
*/
interface ScoreChartProps {
data?: Array<{ date: string; passed: number; blocked: number }>;
@ -13,26 +14,31 @@ export function ScoreChart({ data }: ScoreChartProps) {
const chartData = data && data.length > 0 ? data : [];
return (
<Card className="bg-white border border-gray-200">
<Title className="text-base font-semibold text-gray-900 mb-4">Request Outcomes Over Time</Title>
<div className="h-80 min-h-[280px]">
{chartData.length > 0 ? (
<BarChart
data={chartData}
index="date"
categories={["passed", "blocked"]}
colors={["green", "red"]}
valueFormatter={(v) => v.toLocaleString()}
yAxisWidth={48}
showLegend={true}
stack={true}
/>
) : (
<div className="flex items-center justify-center h-full text-sm text-gray-500">
No chart data for this period
</div>
)}
</div>
<Card>
<CardHeader>
<CardTitle className="text-base font-semibold">Request Outcomes Over Time</CardTitle>
</CardHeader>
<CardContent>
<div className="h-80 min-h-[280px]">
{chartData.length > 0 ? (
<BarChart
data={chartData}
index="date"
categories={["passed", "blocked"]}
colors={["green", "red"]}
valueFormatter={(v) => v.toLocaleString()}
yAxisWidth={48}
showLegend={true}
stack={true}
className="h-full"
/>
) : (
<div className="flex items-center justify-center h-full text-sm text-gray-500">
No chart data for this period
</div>
)}
</div>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,36 @@
import { render } from "@testing-library/react";
import React from "react";
import { describe, expect, it } from "vitest";
import { AreaChart } from "./area_chart";
const data = [
{ date: "2026-03-01", tokens: 100, requests: 10 },
{ date: "2026-03-02", tokens: 150, requests: 12 },
];
describe("AreaChart", () => {
it("renders one area per category with the mapped stroke colors", () => {
const { container } = render(
<AreaChart data={data} index="date" categories={["tokens", "requests"]} colors={["blue", "cyan"]} />,
);
const curves = Array.from(container.querySelectorAll("path.recharts-area-curve"));
expect(curves).toHaveLength(2);
const strokes = new Set(curves.map((curve) => curve.getAttribute("stroke")));
expect(strokes).toEqual(new Set(["var(--color-blue-500, #3b82f6)", "var(--color-cyan-500, #06b6d4)"]));
});
it("renders a fade-out gradient fill per category", () => {
const { container } = render(
<AreaChart data={data} index="date" categories={["tokens", "requests"]} colors={["blue", "cyan"]} />,
);
const gradients = container.querySelectorAll("defs linearGradient");
expect(gradients).toHaveLength(2);
const areas = Array.from(container.querySelectorAll("path.recharts-area-area"));
expect(areas).toHaveLength(2);
for (const area of areas) {
expect(area.getAttribute("fill")).toMatch(/^url\(#fill-/);
}
});
});

View file

@ -0,0 +1,92 @@
"use client";
import * as React from "react";
import { Area, AreaChart as RechartsAreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, type ChartConfig } from "@/components/ui/chart";
import { cn } from "@/lib/cva.config";
import { ValueTooltip, type ChartTooltipComponent } from "./chart_tooltip";
import { categoryFills, type ChartColor } from "./colors";
export type AreaChartProps<TDatum extends Record<string, unknown>> = {
data: readonly TDatum[];
index: string;
categories: readonly string[];
colors?: readonly ChartColor[];
valueFormatter?: (value: number) => string;
yAxisWidth?: number;
showLegend?: boolean;
showGridLines?: boolean;
showTooltip?: boolean;
customTooltip?: ChartTooltipComponent;
className?: string;
style?: React.CSSProperties;
};
export function AreaChart<TDatum extends Record<string, unknown>>({
data,
index,
categories,
colors,
valueFormatter,
yAxisWidth = 56,
showLegend = true,
showGridLines = true,
showTooltip = true,
customTooltip,
className,
style,
}: AreaChartProps<TDatum>) {
const gradientId = React.useId().replace(/:/g, "");
const fills = categoryFills(categories.length, colors);
const config: ChartConfig = Object.fromEntries(categories.map((category) => [category, { label: category }]));
const TooltipContent = customTooltip ?? ValueTooltip;
return (
<ChartContainer config={config} className={cn("aspect-auto h-80 w-full", className)} style={style}>
<RechartsAreaChart data={[...data]}>
<defs>
{categories.map((category, i) => (
<linearGradient key={category} id={`fill-${gradientId}-${i}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={fills[i]} stopOpacity={0.4} />
<stop offset="95%" stopColor={fills[i]} stopOpacity={0} />
</linearGradient>
))}
</defs>
{showGridLines && <CartesianGrid vertical={false} />}
<XAxis dataKey={index} tickLine={false} axisLine={false} minTickGap={5} interval="equidistantPreserveStart" />
<YAxis width={yAxisWidth} tickLine={false} axisLine={false} tickFormatter={valueFormatter} />
{showTooltip && (
<ChartTooltip
content={({ active, payload, label }) => (
<TooltipContent
active={active}
payload={payload}
label={label}
{...(customTooltip ? {} : { valueFormatter })}
/>
)}
/>
)}
{showLegend && (
<ChartLegend
verticalAlign="top"
content={<ChartLegendContent className="justify-end text-muted-foreground" />}
/>
)}
{categories.map((category, i) => (
<Area
key={category}
type="linear"
dataKey={category}
stroke={fills[i]}
strokeWidth={2}
fill={`url(#fill-${gradientId}-${i})`}
fillOpacity={1}
dot={false}
isAnimationActive={false}
/>
))}
</RechartsAreaChart>
</ChartContainer>
);
}

View file

@ -0,0 +1,119 @@
import { fireEvent, render, screen } from "@testing-library/react";
import React from "react";
import { describe, expect, it, vi } from "vitest";
import { BarChart } from "./bar_chart";
const data = [
{ date: "2026-03-01", passed: 10, blocked: 2 },
{ date: "2026-03-02", passed: 15, blocked: 1 },
];
describe("BarChart", () => {
it("renders one bar series per category with the mapped tremor colors", () => {
const { container } = render(
<BarChart data={data} index="date" categories={["passed", "blocked"]} colors={["green", "red"]} />,
);
const rectangles = Array.from(container.querySelectorAll("path.recharts-rectangle"));
expect(rectangles).toHaveLength(4);
const fills = new Set(rectangles.map((rect) => rect.getAttribute("fill")));
expect(fills).toEqual(new Set(["var(--color-green-500, #22c55e)", "var(--color-red-500, #ef4444)"]));
});
it("falls back to the tremor default color cycle when no colors are passed", () => {
const { container } = render(<BarChart data={data} index="date" categories={["passed", "blocked"]} />);
const fills = new Set(
Array.from(container.querySelectorAll("path.recharts-rectangle")).map((rect) => rect.getAttribute("fill")),
);
expect(fills).toEqual(new Set(["var(--color-blue-500, #3b82f6)", "var(--color-cyan-500, #06b6d4)"]));
});
it("fires onValueChange with the datum and clicked category", () => {
const onValueChange = vi.fn();
const { container } = render(
<BarChart
data={data}
index="date"
categories={["passed", "blocked"]}
colors={["green", "red"]}
onValueChange={onValueChange}
/>,
);
const firstRect = container.querySelector("path.recharts-rectangle");
expect(firstRect).not.toBeNull();
fireEvent.click(firstRect!);
expect(onValueChange).toHaveBeenCalledTimes(1);
const expectedClickItem = {
date: "2026-03-01",
passed: 10,
blocked: 2,
categoryClicked: "passed",
};
expect(onValueChange).toHaveBeenCalledWith(expectedClickItem);
});
it("renders category labels on the y axis in vertical layout", () => {
render(
<BarChart
data={[
{ key: "alpha", spend: 12 },
{ key: "beta", spend: 7 },
]}
index="key"
categories={["spend"]}
colors={["cyan"]}
layout="vertical"
yAxisWidth={120}
/>,
);
expect(screen.getAllByText("alpha").length).toBeGreaterThan(0);
expect(screen.getAllByText("beta").length).toBeGreaterThan(0);
});
it("applies valueFormatter to the value axis ticks", () => {
render(
<BarChart
data={data}
index="date"
categories={["passed"]}
colors={["green"]}
valueFormatter={(v) => `${v} req`}
/>,
);
expect(screen.getAllByText(/ req$/).length).toBeGreaterThan(0);
});
it("renders a legend by default, matching tremor, and hides it when showLegend is false", () => {
const { container, rerender } = render(
<BarChart data={data} index="date" categories={["passed"]} colors={["green"]} />,
);
expect(screen.getByText("passed")).toBeInTheDocument();
expect(container.querySelector(".recharts-legend-wrapper")).not.toBeNull();
rerender(<BarChart data={data} index="date" categories={["passed"]} colors={["green"]} showLegend={false} />);
expect(screen.queryByText("passed")).not.toBeInTheDocument();
});
it("emits no per-chart style tag; colors flow through fills, not CSS vars", () => {
const { container } = render(
<BarChart data={data} index="date" categories={["passed", "blocked"]} colors={["green", "red"]} />,
);
expect(container.querySelector("style")).toBeNull();
});
it("stacks bars into a single column per index when stack is set", () => {
const { container } = render(
<BarChart data={data} index="date" categories={["passed", "blocked"]} colors={["green", "red"]} stack={true} />,
);
const xPositions = Array.from(container.querySelectorAll("path.recharts-rectangle")).map(
(rect) => rect.getAttribute("d")?.split(",")[0],
);
expect(new Set(xPositions).size).toBe(2);
});
});

View file

@ -0,0 +1,119 @@
"use client";
import * as React from "react";
import { Bar, BarChart as RechartsBarChart, CartesianGrid, XAxis, YAxis } from "recharts";
import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, type ChartConfig } from "@/components/ui/chart";
import { cn } from "@/lib/cva.config";
import { ValueTooltip, type ChartTooltipComponent } from "./chart_tooltip";
import { categoryFills, type ChartColor } from "./colors";
export type BarChartProps<TDatum extends Record<string, unknown>> = {
data: readonly TDatum[];
index: string;
categories: readonly string[];
colors?: readonly ChartColor[];
valueFormatter?: (value: number) => string;
stack?: boolean;
layout?: "horizontal" | "vertical";
yAxisWidth?: number;
tickGap?: number;
showLegend?: boolean;
showXAxis?: boolean;
showGridLines?: boolean;
showTooltip?: boolean;
customTooltip?: ChartTooltipComponent;
onValueChange?: (item: TDatum & { categoryClicked: string }) => void;
className?: string;
style?: React.CSSProperties;
};
export function BarChart<TDatum extends Record<string, unknown>>({
data,
index,
categories,
colors,
valueFormatter,
stack = false,
layout = "horizontal",
yAxisWidth = 56,
tickGap = 5,
showLegend = true,
showXAxis = true,
showGridLines = true,
showTooltip = true,
customTooltip,
onValueChange,
className,
style,
}: BarChartProps<TDatum>) {
const fills = categoryFills(categories.length, colors);
const config: ChartConfig = Object.fromEntries(categories.map((category) => [category, { label: category }]));
const vertical = layout === "vertical";
const TooltipContent = customTooltip ?? ValueTooltip;
return (
<ChartContainer config={config} className={cn("aspect-auto h-80 w-full", className)} style={style}>
<RechartsBarChart data={[...data]} layout={layout}>
{showGridLines && <CartesianGrid horizontal={!vertical} vertical={vertical} />}
{vertical ? (
<XAxis
type="number"
hide={!showXAxis}
tickLine={false}
axisLine={false}
minTickGap={tickGap}
tickFormatter={valueFormatter}
/>
) : (
<XAxis
dataKey={index}
hide={!showXAxis}
tickLine={false}
axisLine={false}
minTickGap={tickGap}
interval="equidistantPreserveStart"
/>
)}
{vertical ? (
<YAxis type="category" dataKey={index} width={yAxisWidth} tickLine={false} axisLine={false} interval={0} />
) : (
<YAxis width={yAxisWidth} tickLine={false} axisLine={false} tickFormatter={valueFormatter} />
)}
{showTooltip && (
<ChartTooltip
content={({ active, payload, label }) => (
<TooltipContent
active={active}
payload={payload}
label={label}
{...(customTooltip ? {} : { valueFormatter })}
/>
)}
/>
)}
{showLegend && (
<ChartLegend
verticalAlign="top"
content={<ChartLegendContent className="justify-end text-muted-foreground" />}
/>
)}
{categories.map((category, i) => (
<Bar
key={category}
dataKey={category}
fill={fills[i]}
stackId={stack ? "stack" : undefined}
isAnimationActive={false}
onClick={
onValueChange
? (item: { payload?: TDatum }) => {
if (item.payload) onValueChange({ ...item.payload, categoryClicked: category });
}
: undefined
}
/>
))}
</RechartsBarChart>
</ChartContainer>
);
}

View file

@ -0,0 +1,32 @@
import { render, screen } from "@testing-library/react";
import React from "react";
import { describe, expect, it } from "vitest";
import { CustomLegend } from "./chart_legend";
describe("CustomLegend", () => {
it("renders title-cased category names without the metrics prefix", () => {
render(<CustomLegend categories={["metrics.total_tokens", "metrics.spend"]} colors={["blue", "green"]} />);
expect(screen.getByText("Total Tokens")).toBeInTheDocument();
expect(screen.getByText("Spend")).toBeInTheDocument();
});
it("matches colors to categories by index with theme-var values", () => {
const { container } = render(
<CustomLegend categories={["metrics.first", "metrics.second"]} colors={["blue", "green"]} />,
);
const dots = Array.from(container.querySelectorAll('span[style*="background-color"]'));
expect(dots[0]?.getAttribute("style")).toContain("--color-blue-500");
expect(dots[1]?.getAttribute("style")).toContain("--color-green-500");
});
it("cycles colors when there are more categories than colors", () => {
const { container } = render(
<CustomLegend categories={["metrics.a", "metrics.b", "metrics.c"]} colors={["blue", "green"]} />,
);
const dots = Array.from(container.querySelectorAll('span[style*="background-color"]'));
expect(dots[2]?.getAttribute("style")).toContain("--color-blue-500");
});
});

View file

@ -0,0 +1,25 @@
"use client";
import * as React from "react";
import { formatCategoryName } from "./chart_tooltip";
import { chartColorValue, type ChartColor } from "./colors";
export const CustomLegend = ({
categories,
colors,
}: {
categories: readonly string[];
colors: readonly ChartColor[];
}) => (
<div className="flex items-center justify-end space-x-4">
{categories.map((category, idx) => (
<div key={category} className="flex items-center space-x-2">
<span
className="h-2 w-2 shrink-0 rounded-full ring-4 ring-white"
style={{ backgroundColor: chartColorValue(colors[idx % colors.length]) }}
/>
<p className="text-sm text-muted-foreground">{formatCategoryName(category)}</p>
</div>
))}
</div>
);

View file

@ -0,0 +1,101 @@
import { render, screen } from "@testing-library/react";
import React from "react";
import { describe, expect, it } from "vitest";
import { CustomTooltip, ValueTooltip, type ChartTooltipProps } from "./chart_tooltip";
const metricsPayload = (
dataKey: string,
value: number,
color = "#3b82f6",
): NonNullable<ChartTooltipProps["payload"]>[number] =>
({
dataKey,
value,
color,
payload: {
date: "2026-01-15",
metrics: {
total_tokens: 1000,
prompt_tokens: 600,
completion_tokens: 400,
spend: 1234.567,
api_requests: 10,
},
},
}) as NonNullable<ChartTooltipProps["payload"]>[number];
describe("CustomTooltip", () => {
it("returns null when not active or payload is empty", () => {
const inactive = render(
<CustomTooltip active={false} payload={[metricsPayload("metrics.total_tokens", 1000)]} label="2026-01-15" />,
);
expect(inactive.container.firstChild).toBeNull();
const empty = render(<CustomTooltip active={true} payload={[]} label="2026-01-15" />);
expect(empty.container.firstChild).toBeNull();
});
it("renders the label and title-cased category names without the metrics prefix", () => {
render(<CustomTooltip active={true} payload={[metricsPayload("metrics.total_tokens", 1000)]} label="2026-01-15" />);
expect(screen.getByText("2026-01-15")).toBeInTheDocument();
expect(screen.getByText("Total Tokens")).toBeInTheDocument();
expect(screen.getByText("1,000")).toBeInTheDocument();
});
it("formats spend values as dollars with two decimals", () => {
render(<CustomTooltip active={true} payload={[metricsPayload("metrics.spend", 1234.567)]} label="2026-01-15" />);
expect(screen.getByText("$1,234.57")).toBeInTheDocument();
});
it("shows N/A for metrics missing from the row payload", () => {
render(<CustomTooltip active={true} payload={[metricsPayload("metrics.nonexistent", 1000)]} label="2026-01-15" />);
expect(screen.getByText("N/A")).toBeInTheDocument();
});
it("uses the series color for the indicator dot", () => {
const { container } = render(
<CustomTooltip
active={true}
payload={[metricsPayload("metrics.total_tokens", 1000, "var(--color-blue-500, #3b82f6)")]}
label="2026-01-15"
/>,
);
const dot = container.querySelector('span[style*="background-color"]');
expect(dot?.getAttribute("style")).toContain("--color-blue-500");
});
});
describe("ValueTooltip", () => {
const payload = [
{
dataKey: "passed",
name: "passed",
value: 1000,
color: "#22c55e",
payload: { date: "2026-01-15", passed: 1000 },
} as NonNullable<ChartTooltipProps["payload"]>[number],
];
it("returns null when not active", () => {
const { container } = render(<ValueTooltip active={false} payload={payload} label="2026-01-15" />);
expect(container.firstChild).toBeNull();
});
it("renders label, series name, and locale-formatted value by default", () => {
render(<ValueTooltip active={true} payload={payload} label="2026-01-15" />);
expect(screen.getByText("2026-01-15")).toBeInTheDocument();
expect(screen.getByText("passed")).toBeInTheDocument();
expect(screen.getByText("1,000")).toBeInTheDocument();
});
it("applies the valueFormatter to values", () => {
render(<ValueTooltip active={true} payload={payload} label="2026-01-15" valueFormatter={(v) => `$${v}`} />);
expect(screen.getByText("$1000")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,97 @@
"use client";
import * as React from "react";
import type { TooltipContentProps, TooltipValueType } from "recharts";
export type ChartTooltipProps = Pick<
TooltipContentProps<TooltipValueType, string | number>,
"active" | "payload" | "label"
>;
export type ChartTooltipComponent = React.ComponentType<ChartTooltipProps>;
export const formatCategoryName = (name: string): string =>
name
.replace("metrics.", "")
.replace(/_/g, " ")
.split(" ")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
export const ValueTooltip = ({
active,
payload,
label,
valueFormatter,
}: ChartTooltipProps & { valueFormatter?: (value: number) => string }) => {
if (!active || !payload || payload.length === 0) return null;
const formatValue = (value: unknown): string => {
if (typeof value === "number") return valueFormatter ? valueFormatter(value) : value.toLocaleString();
return value == null ? "" : String(value);
};
return (
<div className="min-w-32 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl">
{label != null && <p className="mb-1.5 font-medium text-foreground">{String(label)}</p>}
<div className="grid gap-1.5">
{payload.map((item, idx) => (
<div
key={String(item.dataKey ?? item.name ?? idx)}
className="flex w-full items-center justify-between gap-4"
>
<div className="flex items-center gap-1.5">
<span className="h-2.5 w-2.5 shrink-0 rounded-[2px]" style={{ backgroundColor: item.color }} />
<span className="text-muted-foreground">{String(item.name ?? item.dataKey ?? "")}</span>
</div>
<span className="font-mono font-medium tabular-nums text-foreground">{formatValue(item.value)}</span>
</div>
))}
</div>
</div>
);
};
const rawMetricValue = (row: unknown, dataKey: string): number | undefined => {
if (typeof row !== "object" || row === null || !("metrics" in row)) return undefined;
const metrics = (row as { metrics: unknown }).metrics;
if (typeof metrics !== "object" || metrics === null) return undefined;
const metricKey = dataKey.substring(dataKey.indexOf(".") + 1);
const value = (metrics as Record<string, unknown>)[metricKey];
return typeof value === "number" ? value : undefined;
};
const formatMetricValue = (rawValue: number | undefined, isSpend: boolean): string => {
if (rawValue === undefined) return "N/A";
if (isSpend) return `$${rawValue.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
return rawValue.toLocaleString();
};
export const CustomTooltip = ({ active, payload, label }: ChartTooltipProps) => {
if (!active || !payload || payload.length === 0) return null;
return (
<div className="w-56 rounded-lg border border-border/50 bg-background p-2 text-xs shadow-xl">
<p className="font-medium text-foreground">{label == null ? "" : String(label)}</p>
{payload.map((item) => {
const dataKey = item.dataKey?.toString();
if (!dataKey || !item.payload) return null;
const formattedValue = formatMetricValue(rawMetricValue(item.payload, dataKey), dataKey.includes("spend"));
return (
<div key={dataKey} className="flex items-center justify-between space-x-4">
<div className="flex items-center space-x-2">
<span
className="h-2 w-2 shrink-0 rounded-full ring-2 ring-white drop-shadow-md"
style={{ backgroundColor: item.color }}
/>
<p className="font-medium text-muted-foreground">{formatCategoryName(dataKey)}</p>
</div>
<p className="font-medium text-foreground">{formattedValue}</p>
</div>
);
})}
</div>
);
};

View file

@ -0,0 +1,58 @@
export const CHART_COLOR_HEX = {
slate: "#64748b",
gray: "#6b7280",
zinc: "#71717a",
neutral: "#737373",
stone: "#78716c",
red: "#ef4444",
orange: "#f97316",
amber: "#f59e0b",
yellow: "#eab308",
lime: "#84cc16",
green: "#22c55e",
emerald: "#10b981",
teal: "#14b8a6",
cyan: "#06b6d4",
sky: "#0ea5e9",
blue: "#3b82f6",
indigo: "#6366f1",
violet: "#8b5cf6",
purple: "#a855f7",
fuchsia: "#d946ef",
pink: "#ec4899",
rose: "#f43f5e",
} as const;
export type ChartColor = keyof typeof CHART_COLOR_HEX;
export const DEFAULT_COLOR_CYCLE: readonly ChartColor[] = [
"blue",
"cyan",
"sky",
"indigo",
"violet",
"purple",
"fuchsia",
"slate",
"gray",
"zinc",
"neutral",
"stone",
"red",
"orange",
"amber",
"yellow",
"lime",
"green",
"emerald",
"teal",
"pink",
"rose",
];
export const chartColorValue = (color: ChartColor): string => `var(--color-${color}-500, ${CHART_COLOR_HEX[color]})`;
export const categoryFills = (count: number, colors?: readonly ChartColor[]): readonly string[] => {
const cycle = colors && colors.length > 0 ? colors : DEFAULT_COLOR_CYCLE;
return Array.from({ length: count }, (_, i) => chartColorValue(cycle[i % cycle.length]));
};

View file

@ -0,0 +1,38 @@
import { render } from "@testing-library/react";
import React from "react";
import { describe, expect, it } from "vitest";
import { DonutChart } from "./donut_chart";
const data = [
{ provider: "openai", spend: 40 },
{ provider: "anthropic", spend: 30 },
{ provider: "bedrock", spend: 20 },
];
describe("DonutChart", () => {
it("renders one sector per datum, cycling the given colors", () => {
const { container } = render(
<DonutChart data={data} index="provider" category="spend" colors={["cyan", "blue"]} />,
);
const sectors = Array.from(container.querySelectorAll(".recharts-pie-sector path"));
expect(sectors).toHaveLength(3);
expect(sectors.map((sector) => sector.getAttribute("fill"))).toEqual([
"var(--color-cyan-500, #06b6d4)",
"var(--color-blue-500, #3b82f6)",
"var(--color-cyan-500, #06b6d4)",
]);
});
it("renders a full pie when variant is pie and a hollow donut otherwise", () => {
const { container: donut } = render(<DonutChart data={data} index="provider" category="spend" colors={["cyan"]} />);
const { container: pie } = render(
<DonutChart data={data} index="provider" category="spend" colors={["cyan"]} variant="pie" />,
);
const donutPath = donut.querySelector(".recharts-pie-sector path")?.getAttribute("d") ?? "";
const piePath = pie.querySelector(".recharts-pie-sector path")?.getAttribute("d") ?? "";
expect(donutPath).not.toEqual(piePath);
expect((donutPath.match(/A/g) ?? []).length).toBeGreaterThan((piePath.match(/A/g) ?? []).length);
});
});

View file

@ -0,0 +1,67 @@
"use client";
import * as React from "react";
import { Cell, Pie, PieChart } from "recharts";
import { ChartContainer, ChartTooltip, type ChartConfig } from "@/components/ui/chart";
import { cn } from "@/lib/cva.config";
import { ValueTooltip } from "./chart_tooltip";
import { categoryFills, type ChartColor } from "./colors";
export type DonutChartProps<TDatum extends Record<string, unknown>> = {
data: readonly TDatum[];
index: string;
category: string;
colors?: readonly ChartColor[];
variant?: "donut" | "pie";
valueFormatter?: (value: number) => string;
showTooltip?: boolean;
className?: string;
style?: React.CSSProperties;
};
export function DonutChart<TDatum extends Record<string, unknown>>({
data,
index,
category,
colors,
variant = "donut",
valueFormatter,
showTooltip = true,
className,
style,
}: DonutChartProps<TDatum>) {
const fills = categoryFills(data.length, colors);
const config: ChartConfig = Object.fromEntries(
data.map((datum, i) => {
const name = String(datum[index] ?? i);
return [name, { label: name }];
}),
);
return (
<ChartContainer config={config} className={cn("aspect-auto h-40 w-full", className)} style={style}>
<PieChart>
{showTooltip && (
<ChartTooltip
content={({ active, payload, label }) => (
<ValueTooltip active={active} payload={payload} label={label} valueFormatter={valueFormatter} />
)}
/>
)}
<Pie
data={[...data]}
dataKey={category}
nameKey={index}
innerRadius={variant === "pie" ? "0%" : "75%"}
outerRadius="100%"
strokeWidth={1}
isAnimationActive={false}
>
{data.map((datum, i) => (
<Cell key={String(datum[index] ?? i)} fill={fills[i]} />
))}
</Pie>
</PieChart>
</ChartContainer>
);
}

View file

@ -0,0 +1,12 @@
export { AreaChart, type AreaChartProps } from "./area_chart";
export { BarChart, type BarChartProps } from "./bar_chart";
export { CustomLegend } from "./chart_legend";
export {
CustomTooltip,
ValueTooltip,
formatCategoryName,
type ChartTooltipComponent,
type ChartTooltipProps,
} from "./chart_tooltip";
export { CHART_COLOR_HEX, DEFAULT_COLOR_CYCLE, categoryFills, chartColorValue, type ChartColor } from "./colors";
export { DonutChart, type DonutChartProps } from "./donut_chart";

View file

@ -0,0 +1,86 @@
import * as React from "react";
import { cn } from "@/lib/cva.config";
const Card = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<"div"> & { size?: "default" | "sm" }>(
({ className, size = "default", ...props }, ref) => (
<div
ref={ref}
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground shadow-xs ring-1 ring-foreground/10 [--card-spacing:--spacing(6)] has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(4)] *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className,
)}
{...props}
/>
),
);
Card.displayName = "Card";
const CardHeader = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<"div">>(
({ className, ...props }, ref) => (
<div
ref={ref}
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
className,
)}
{...props}
/>
),
);
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<"div">>(
({ className, ...props }, ref) => (
<div
ref={ref}
data-slot="card-title"
className={cn("text-base leading-normal font-medium group-data-[size=sm]/card:text-sm", className)}
{...props}
/>
),
);
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<"div">>(
({ className, ...props }, ref) => (
<div ref={ref} data-slot="card-description" className={cn("text-sm text-muted-foreground", className)} {...props} />
),
);
CardDescription.displayName = "CardDescription";
const CardAction = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<"div">>(
({ className, ...props }, ref) => (
<div
ref={ref}
data-slot="card-action"
className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
{...props}
/>
),
);
CardAction.displayName = "CardAction";
const CardContent = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<"div">>(
({ className, ...props }, ref) => (
<div ref={ref} data-slot="card-content" className={cn("px-(--card-spacing)", className)} {...props} />
),
);
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<"div">>(
({ className, ...props }, ref) => (
<div
ref={ref}
data-slot="card-footer"
className={cn("flex items-center rounded-b-xl px-(--card-spacing) [.border-t]:pt-(--card-spacing)", className)}
{...props}
/>
),
);
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };

View file

@ -0,0 +1,38 @@
import { render } from "@testing-library/react";
import * as React from "react";
import { describe, expect, it } from "vitest";
import { ChartContainer } from "./chart";
describe("ChartStyle hardening", () => {
it("sanitizes config keys and strips structural characters from color values", () => {
const { container } = render(
<ChartContainer
config={{
"metrics.total_tokens": { label: "Total Tokens", color: "var(--color-blue-500, #3b82f6)" },
"evil}key": { label: "evil", color: "red;}</style><img src=x onerror=alert(1)>" },
}}
>
<svg />
</ChartContainer>,
);
const style = container.querySelector("style");
expect(style).not.toBeNull();
const css = style!.innerHTML;
expect(css).toContain("--color-metrics_total_tokens: var(--color-blue-500, #3b82f6);");
expect(css).not.toContain("metrics.total_tokens");
expect(css).toContain("--color-evil_key:");
expect(css).not.toContain("<");
expect((css.match(/{/g) ?? []).length).toBe((css.match(/}/g) ?? []).length);
});
it("emits no style tag when no config entry has a color", () => {
const { container } = render(
<ChartContainer config={{ passed: { label: "passed" } }}>
<svg />
</ChartContainer>,
);
expect(container.querySelector("style")).toBeNull();
});
});

View file

@ -0,0 +1,324 @@
"use client";
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import type { TooltipValueType } from "recharts";
import { cn } from "@/lib/cva.config";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const;
const INITIAL_DIMENSION = { width: 320, height: 200 } as const;
type TooltipNameType = number | string;
export type ChartConfig = Record<
string,
{
label?: React.ReactNode;
icon?: React.ComponentType;
} & ({ color?: string; theme?: never } | { color?: never; theme: Record<keyof typeof THEMES, string> })
>;
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentPropsWithoutRef<"div"> & {
config: ChartConfig;
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"];
initialDimension?: {
width: number;
height: number;
};
}
>(({ id, className, children, config, initialDimension = INITIAL_DIMENSION, ...props }, ref) => {
const uniqueId = React.useId();
const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{ config }}>
<div
ref={ref}
data-slot="chart"
data-chart={chartId}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer initialDimension={initialDimension}>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
});
ChartContainer.displayName = "ChartContainer";
const cssVarName = (key: string) => key.replace(/[^a-zA-Z0-9_-]/g, "_");
const cssColorValue = (color: string) => color.replace(/[;{}<>]/g, "");
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(([, config]) => config.theme ?? config.color);
if (!colorConfig.length) {
return null;
}
return (
<style
// eslint-disable-next-line react/no-danger -- vendored shadcn template; emits per-chart CSS vars from our own config values, no user input
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ?? itemConfig.color;
return color ? ` --color-${cssVarName(key)}: ${cssColorValue(color)};` : null;
})
.join("\n")}
}
`,
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentPropsWithoutRef<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
} & Omit<RechartsPrimitive.DefaultTooltipContentProps<TooltipValueType, TooltipNameType>, "accessibilityLayer">
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref,
) => {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value = !labelKey && typeof label === "string" ? config[label]?.label ?? label : itemConfig?.label;
if (labelFormatter) {
return <div className={cn("font-medium", labelClassName)}>{labelFormatter(value, payload)}</div>;
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
ref={ref}
className={cn(
"grid min-w-32 items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color ?? item.payload?.fill ?? item.color;
const indicatorVariants = {
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent": indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
};
return (
<div
key={index}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center",
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
indicatorVariants,
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center",
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">{itemConfig?.label ?? item.name}</span>
</div>
{item.value != null && (
<span className="font-mono font-medium text-foreground tabular-nums">
{typeof item.value === "number" ? item.value.toLocaleString() : String(item.value)}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
},
);
ChartTooltipContent.displayName = "ChartTooltipContent";
const ChartLegend = RechartsPrimitive.Legend;
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentPropsWithoutRef<"div"> & {
hideIcon?: boolean;
nameKey?: string;
} & RechartsPrimitive.DefaultLegendContentProps
>(({ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey }, ref) => {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
ref={ref}
className={cn("flex items-center justify-center gap-4", verticalAlign === "top" ? "pb-3" : "pt-3", className)}
>
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey ?? item.dataKey ?? "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={index}
className={cn("flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground")}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
});
ChartLegendContent.displayName = "ChartLegendContent";
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload && typeof payload.payload === "object" && payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (key in payload && typeof payload[key as keyof typeof payload] === "string") {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
}
return configLabelKey in config ? config[configLabelKey] : config[key];
}
export { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent, ChartStyle };

View file

@ -3,6 +3,8 @@ import * as React from "react";
import { describe, expect, it } from "vitest";
import { Button } from "./button";
import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "./card";
import { ChartContainer } from "./chart";
import { Input } from "./input";
import { Label } from "./label";
import { Separator } from "./separator";
@ -47,6 +49,46 @@ describe("ui primitives forward refs to their DOM node", () => {
expect(ref.current).toBeInstanceOf(SVGSVGElement);
});
it("Card family", () => {
const card = React.createRef<HTMLDivElement>();
const header = React.createRef<HTMLDivElement>();
const title = React.createRef<HTMLDivElement>();
const description = React.createRef<HTMLDivElement>();
const action = React.createRef<HTMLDivElement>();
const content = React.createRef<HTMLDivElement>();
const footer = React.createRef<HTMLDivElement>();
render(
<Card ref={card}>
<CardHeader ref={header}>
<CardTitle ref={title}>t</CardTitle>
<CardDescription ref={description}>d</CardDescription>
<CardAction ref={action}>a</CardAction>
</CardHeader>
<CardContent ref={content}>c</CardContent>
<CardFooter ref={footer}>f</CardFooter>
</Card>,
);
expect(card.current).toBeInstanceOf(HTMLDivElement);
expect(header.current).toBeInstanceOf(HTMLDivElement);
expect(title.current).toBeInstanceOf(HTMLDivElement);
expect(description.current).toBeInstanceOf(HTMLDivElement);
expect(action.current).toBeInstanceOf(HTMLDivElement);
expect(content.current).toBeInstanceOf(HTMLDivElement);
expect(footer.current).toBeInstanceOf(HTMLDivElement);
});
it("ChartContainer", () => {
const ref = React.createRef<HTMLDivElement>();
render(
<ChartContainer ref={ref} config={{ passed: { label: "passed", color: "#22c55e" } }}>
<svg />
</ChartContainer>,
);
expect(ref.current).toBeInstanceOf(HTMLDivElement);
});
it("Table family", () => {
const table = React.createRef<HTMLTableElement>();
const caption = React.createRef<HTMLTableCaptionElement>();

View file

@ -220,10 +220,41 @@ if (!URL.revokeObjectURL) {
URL.revokeObjectURL = () => {};
}
// Mock ResizeObserver for components that use it (e.g., Tremor UI components)
// This prevents "ResizeObserver is not defined" errors in JSDOM
// Mock ResizeObserver for components that use it (recharts, Tremor UI components).
// JSDOM has no layout, so for observers inside a shadcn ChartContainer ([data-slot="chart"])
// the mock immediately reports a fixed 800x400 box; recharts renders nothing until it
// observes a size. Scoped to chart subtrees only: firing for every observer re-enters
// React mid-effect for tremor/headlessui consumers whose tests assume the old no-op
// (chart text would duplicate getByText targets, popover clicks go stale). Widen or
// drop the scoping once tremor is gone.
const MOCK_RESIZE_BOX = { inlineSize: 800, blockSize: 400 };
const MOCK_RESIZE_RECT: DOMRectReadOnly = {
width: 800,
height: 400,
top: 0,
left: 0,
bottom: 400,
right: 800,
x: 0,
y: 0,
toJSON: () => ({}),
};
global.ResizeObserver = class ResizeObserver {
observe() {}
private readonly callback: ResizeObserverCallback;
constructor(callback: ResizeObserverCallback) {
this.callback = callback;
}
observe(target: Element) {
if (!target.closest('[data-slot="chart"]')) return;
const entry: ResizeObserverEntry = {
target,
contentRect: MOCK_RESIZE_RECT,
borderBoxSize: [MOCK_RESIZE_BOX],
contentBoxSize: [MOCK_RESIZE_BOX],
devicePixelContentBoxSize: [MOCK_RESIZE_BOX],
};
this.callback([entry], this);
}
unobserve() {}
disconnect() {}
};