From 01f79afaf3729d2f0fd35b2facc8f3579a37f241 Mon Sep 17 00:00:00 2001
From: saravanakumardb1
Date: Fri, 29 May 2026 13:46:28 -0700
Subject: [PATCH] feat(admin-web): migrate recharts to @bytelyst/charts +
data-viz, drop recharts (UX-2)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Replace all 5 direct recharts usages with the shared, token-themed SVG
primitives, lazy-loaded for bundle savings:
- dashboard, usage, users/[id], ops/client-logs, extraction/entity-chart
now render AreaChart/BarChart/Donut from @bytelyst/charts.
- new src/components/charts: next/dynamic wrappers (own chunk, ssr:false)
that dynamic-import a local static re-export (primitives.tsx) — the chart
packages declare only an `import` export condition, so a direct
import('@bytelyst/charts') trips Next's resolver.
- new src/lib/chart-data.ts: pure, finite-safe data mappers (unit-tested).
- recharts removed from package.json + the admin-web lockfile importer entry
(now fully unused). Lockfile delta is importer-only (+charts/+data-viz as
workspace:*, -recharts); no monorepo re-normalization; --frozen-lockfile clean.
- vitest.config: inline @bytelyst/{charts,data-viz} + dedupe react so the
SSR no-NaN render tests use a single React copy.
Fidelity notes (charts are single-series/vertical; StackedBar is charts 0.2.x):
stacked severity chart -> single bars colored by dominant severity; pie charts
-> Donut; horizontal bars -> vertical.
Verify: typecheck+lint+build green (123 routes); vitest 18 files / 159 tests
(+19); format:check no new failures; e2e 11 passed / 80 failed (unchanged vs
UX-1 baseline — failures are environmental, no backend).
Generated with [Devin](https://cli.devin.ai/docs)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
.../docs/roadmaps/UX_INTEGRATION_ADMIN.md | 19 +-
dashboards/admin-web/package.json | 5 +-
.../src/__tests__/charts-migration.test.tsx | 89 +++++++++
.../app/(dashboard)/ops/client-logs/page.tsx | 99 +++------
.../admin-web/src/app/(dashboard)/page.tsx | 68 ++-----
.../src/app/(dashboard)/usage/page.tsx | 189 +++++-------------
.../src/app/(dashboard)/users/[id]/page.tsx | 45 +----
.../admin-web/src/components/charts/index.tsx | 53 +++++
.../src/components/charts/primitives.tsx | 13 ++
.../components/extraction/entity-chart.tsx | 94 +++------
dashboards/admin-web/src/lib/chart-data.ts | 50 +++++
dashboards/admin-web/vitest.config.ts | 12 ++
pnpm-lock.yaml | 9 +-
13 files changed, 371 insertions(+), 374 deletions(-)
create mode 100644 dashboards/admin-web/src/__tests__/charts-migration.test.tsx
create mode 100644 dashboards/admin-web/src/components/charts/index.tsx
create mode 100644 dashboards/admin-web/src/components/charts/primitives.tsx
create mode 100644 dashboards/admin-web/src/lib/chart-data.ts
diff --git a/dashboards/admin-web/docs/roadmaps/UX_INTEGRATION_ADMIN.md b/dashboards/admin-web/docs/roadmaps/UX_INTEGRATION_ADMIN.md
index 35be59bc..31e30045 100644
--- a/dashboards/admin-web/docs/roadmaps/UX_INTEGRATION_ADMIN.md
+++ b/dashboards/admin-web/docs/roadmaps/UX_INTEGRATION_ADMIN.md
@@ -128,9 +128,20 @@ pnpm --filter @bytelyst/admin-web test:e2e # Playwright + @axe-core (no new fa
the e2e baseline below. — **DONE** `df72199c` · test **17 files / 140 tests** (+1 file / +29 vs
baseline 16 / 111); typecheck+lint+build green; e2e 11✅/80❌ (unchanged baseline); format:check
no new failures (29 pre-existing).
-- [ ] **UX-2 — Charts:** migrate the ~6 `recharts` usages to `@bytelyst/charts` (+ `@bytelyst/data-viz`
- `KpiCard`/`Sparkline` for stat tiles), lazy-loaded; render tests (no NaN in SVG). Drop `recharts`
- if fully unused afterward.
+- [x] **UX-2 — Charts:** migrate the `recharts` usages to `@bytelyst/charts` (+ `@bytelyst/data-viz`),
+ lazy-loaded; render tests (no NaN in SVG). Drop `recharts` if fully unused afterward.
+ — **DONE** `` · Migrated all **5** recharts files (page, usage, users/[id],
+ ops/client-logs, extraction/entity-chart) → `AreaChart`/`BarChart`/`Donut` via a lazy
+ `src/components/charts` seam (`next/dynamic`, own chunk; dynamic-imports a local static
+ re-export `primitives.tsx` because the packages declare only an `import` export condition).
+ Added pure finite-safe mappers `src/lib/chart-data.ts`. **`recharts` dropped** from
+ `package.json` + lockfile importer (now fully unused; 33 orphaned pkgs pruned). Stacked
+ severity chart → single-series bars colored by dominant severity; pie charts → `Donut`;
+ horizontal bars → vertical (charts are vertical/single-series; StackedBar deferred to charts
+ 0.2.x). Lockfile change = **+6/−3 importer lines only** (no re-normalization; `workspace:*`;
+ `--frozen-lockfile` clean). Vitest config: inline + `dedupe` react for SSR render tests.
+ test **18 files / 159 tests** (+1 file / +19); typecheck+lint+build green (123 routes);
+ format:check no new failures; e2e unchanged (see below).
- [ ] **UX-3 — Command palette:** add `@bytelyst/command-palette`; mount `CommandRegistryProvider` +
`CommandPalette` (⌘K, lazy) in `(dashboard)/layout.tsx`; register navigate commands for the major
surfaces (Users, Billing, Flags, Broadcasts, Audit, Experiments, Subscriptions, Licenses, Ops, …) + theme toggle + sign out; Vitest test palette opens on ⌘K.
@@ -155,7 +166,7 @@ pnpm --filter @bytelyst/admin-web test:e2e # Playwright + @axe-core (no new fa
```
Setup : UX-1 ✅
-Adopt : UX-2 ⬜ UX-3 ⬜ UX-4 ⬜ UX-5 ⬜ UX-6 ⬜(cond)
+Adopt : UX-2 ✅ UX-3 ⬜ UX-4 ⬜ UX-5 ⬜ UX-6 ⬜(cond)
Cross : CC.1 ⬜ CC.2 ⬜ CC.3 ⬜ CC.4 ⬜ CC.5 ⬜ CC.6 ⬜
```
diff --git a/dashboards/admin-web/package.json b/dashboards/admin-web/package.json
index 6ce72bd3..2a5134dd 100644
--- a/dashboards/admin-web/package.json
+++ b/dashboards/admin-web/package.json
@@ -26,9 +26,11 @@
"@azure/keyvault-secrets": "^4.10.0",
"@bytelyst/api-client": "workspace:*",
"@bytelyst/auth": "workspace:*",
+ "@bytelyst/charts": "workspace:*",
"@bytelyst/config": "workspace:*",
- "@bytelyst/dashboard-components": "workspace:*",
"@bytelyst/cosmos": "workspace:*",
+ "@bytelyst/dashboard-components": "workspace:*",
+ "@bytelyst/data-viz": "workspace:*",
"@bytelyst/datastore": "workspace:*",
"@bytelyst/design-tokens": "workspace:*",
"@bytelyst/devops": "workspace:*",
@@ -52,7 +54,6 @@
"react-dom": "19.2.3",
"react-markdown": "^10.1.0",
"redis": "^4.7.0",
- "recharts": "^3.7.0",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.4.0"
},
diff --git a/dashboards/admin-web/src/__tests__/charts-migration.test.tsx b/dashboards/admin-web/src/__tests__/charts-migration.test.tsx
new file mode 100644
index 00000000..677f9eb2
--- /dev/null
+++ b/dashboards/admin-web/src/__tests__/charts-migration.test.tsx
@@ -0,0 +1,89 @@
+import { describe, expect, it } from 'vitest';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { createElement } from 'react';
+import { AreaChart, BarChart, Donut } from '@bytelyst/charts';
+import { finite, seriesValues, dateBars, donutSlices } from '@/lib/chart-data';
+
+/**
+ * UX-2 — charts migration guard.
+ *
+ * Two layers:
+ * 1. the pure data-shaping helpers always produce finite output, and
+ * 2. the migrated `@bytelyst/charts` primitives render valid SVG with no
+ * literal `NaN` in any coordinate — including the degenerate inputs that
+ * used to silently break recharts (empty series, single point, all-zero,
+ * and non-finite values).
+ */
+
+describe('chart-data helpers', () => {
+ it('coerces non-finite values to 0', () => {
+ expect(finite(5)).toBe(5);
+ expect(finite('7')).toBe(7);
+ expect(finite(NaN)).toBe(0);
+ expect(finite(Infinity)).toBe(0);
+ expect(finite(null)).toBe(0);
+ expect(finite(undefined)).toBe(0);
+ });
+
+ it('maps rows to a finite numeric series', () => {
+ const rows = [{ v: 1 }, { v: 2 }, { v: Number.NaN }];
+ expect(seriesValues(rows, 'v')).toEqual([1, 2, 0]);
+ });
+
+ it('labels only every Nth dated bar but keeps every bar', () => {
+ const rows = Array.from({ length: 7 }, (_, i) => ({ date: `2026-05-0${i + 1}`, n: i }));
+ const bars = dateBars(rows, 'n', 5);
+ expect(bars).toHaveLength(7);
+ expect(bars[0].label).toBe('05-01');
+ expect(bars[1].label).toBe('');
+ expect(bars[5].label).toBe('05-06');
+ expect(bars.every(b => Number.isFinite(b.value))).toBe(true);
+ });
+
+ it('drops non-positive donut slices', () => {
+ const rows = [
+ { k: 'a', v: 3 },
+ { k: 'b', v: 0 },
+ { k: 'c', v: -1 },
+ ];
+ const slices = donutSlices(rows, 'k', 'v');
+ expect(slices.map(s => s.id)).toEqual(['a']);
+ });
+});
+
+describe('migrated charts render finite SVG', () => {
+ const series: number[][] = [
+ [10, 20, 15, 30, 25],
+ [], // empty
+ [42], // single point
+ [0, 0, 0], // all-zero
+ [Number.NaN, 5, Number.POSITIVE_INFINITY, 8], // non-finite mixed
+ ];
+
+ it.each(series.map((s, i) => [i, s] as const))('AreaChart case %i renders no NaN', (_i, s) => {
+ const html = renderToStaticMarkup(createElement(AreaChart, { values: s, ariaLabel: 'a' }));
+ expect(html).toContain('
) : (
-
-
-
-
-
- [
- (value ?? 0).toLocaleString(),
- 'Events',
- ]}
- />
-
-
-
+
({
+ id: g.countryCode,
+ value: g.count,
+ label: g.countryCode,
+ }))}
+ width={720}
+ height={240}
+ className="h-auto w-full"
+ ariaLabel="Telemetry events by country over the last 7 days"
+ />
diff --git a/dashboards/admin-web/src/app/(dashboard)/page.tsx b/dashboards/admin-web/src/app/(dashboard)/page.tsx
index 28df8d0b..680a6287 100644
--- a/dashboards/admin-web/src/app/(dashboard)/page.tsx
+++ b/dashboards/admin-web/src/app/(dashboard)/page.tsx
@@ -34,17 +34,8 @@ import {
type ApiUsageRecord,
type RevenueAnalytics,
} from '@/lib/api';
-import {
- AreaChart,
- Area,
- XAxis,
- YAxis,
- CartesianGrid,
- Tooltip,
- ResponsiveContainer,
- BarChart,
- Bar,
-} from 'recharts';
+import { AreaChart, BarChart } from '@/components/charts';
+import { seriesValues, dateBars } from '@/lib/chart-data';
function buildKpiCards(stats: typeof mockSummaryStats, revenue?: RevenueAnalytics | null) {
const fmt = (n: number) => (n >= 0 ? `+${n}%` : `${n}%`);
@@ -361,32 +352,13 @@ export default function DashboardPage() {
Daily Active Users (30 days)
-
-
-
-
-
-
-
-
-
- v.slice(5)} className="text-xs" />
-
-
-
-
-
+
@@ -396,21 +368,13 @@ export default function DashboardPage() {
Daily Revenue (30 days)
-
-
-
- v.slice(5)} className="text-xs" />
- `$${v}`} />
- formatCurrency(Number(value))}
- contentStyle={{
- borderRadius: '8px',
- border: '1px solid hsl(var(--border))',
- }}
- />
-
-
-
+
diff --git a/dashboards/admin-web/src/app/(dashboard)/usage/page.tsx b/dashboards/admin-web/src/app/(dashboard)/usage/page.tsx
index 49856300..c5b183d7 100644
--- a/dashboards/admin-web/src/app/(dashboard)/usage/page.tsx
+++ b/dashboards/admin-web/src/app/(dashboard)/usage/page.tsx
@@ -40,27 +40,11 @@ import {
type RetentionCohort,
} from '@/lib/api';
import { Separator } from '@/components/ui/separator';
-import {
- AreaChart,
- Area,
- XAxis,
- YAxis,
- CartesianGrid,
- Tooltip,
- ResponsiveContainer,
- BarChart,
- Bar,
- PieChart,
- Pie,
- Cell,
-} from 'recharts';
+import { AreaChart, BarChart, Donut } from '@/components/charts';
+import { seriesValues, dateBars, donutSlices } from '@/lib/chart-data';
-const COLORS = [
- 'hsl(var(--chart-1))',
- 'hsl(var(--chart-2))',
- 'hsl(var(--chart-4))',
- 'hsl(var(--chart-5))',
-];
+// Categorical palette drawn from admin's shadcn chart tokens (no literals).
+const COLORS = ['var(--chart-1)', 'var(--chart-2)', 'var(--chart-4)', 'var(--chart-5)'];
function usageRecordsToDailyMetrics(records: ApiUsageRecord[]): DailyMetric[] {
const byDate = new Map();
@@ -311,34 +295,13 @@ export default function UsagePage() {
Token Consumption (30 days)
-
-
-
-
-
-
-
-
-
- v.slice(5)} className="text-xs" />
- formatNumber(v)} />
- formatNumber(Number(value))}
- contentStyle={{
- borderRadius: '8px',
- border: '1px solid hsl(var(--border))',
- }}
- />
-
-
-
+
@@ -347,26 +310,13 @@ export default function UsagePage() {
API Requests (30 days)
-
-
-
- v.slice(5)} className="text-xs" />
- formatNumber(v)} />
- formatNumber(Number(value))}
- contentStyle={{
- borderRadius: '8px',
- border: '1px solid hsl(var(--border))',
- }}
- />
-
-
-
+
@@ -378,33 +328,15 @@ export default function UsagePage() {
Model Distribution by Cost
-
-
-
- `${name} (${((percent ?? 0) * 100).toFixed(0)}%)`}
- >
- {modelUsage.map((_, idx) => (
- |
- ))}
-
- formatCurrency(Number(value))}
- contentStyle={{
- borderRadius: '8px',
- border: '1px solid hsl(var(--border))',
- }}
- />
-
-
+
+ ({
+ ...s,
+ color: COLORS[idx % COLORS.length],
+ }))}
+ size={260}
+ ariaLabel="Model distribution by cost"
+ />
@@ -543,33 +475,15 @@ export default function UsagePage() {
Usage by Platform
-
-
-
- `${name} (${((percent ?? 0) * 100).toFixed(0)}%)`}
- >
- {sourceUsage.map((_, idx) => (
- |
- ))}
-
- formatNumber(Number(value))}
- contentStyle={{
- borderRadius: '8px',
- border: '1px solid hsl(var(--border))',
- }}
- />
-
-
+
+ ({
+ ...s,
+ color: COLORS[idx % COLORS.length],
+ }))}
+ size={240}
+ ariaLabel="Token usage by platform"
+ />
@@ -643,25 +557,18 @@ export default function UsagePage() {
Tokens by Product
-
-
-
- formatNumber(v)} className="text-xs" />
-
- formatNumber(Number(value))}
- contentStyle={{
- borderRadius: '8px',
- border: '1px solid hsl(var(--border))',
- }}
- />
-
- {productUsage.map((_, idx) => (
- |
- ))}
-
-
-
+ ({
+ id: p.productId,
+ value: p.tokens,
+ label: p.productId,
+ color: COLORS[idx % COLORS.length],
+ }))}
+ width={640}
+ height={280}
+ className="h-auto w-full"
+ ariaLabel="Tokens by product"
+ />
diff --git a/dashboards/admin-web/src/app/(dashboard)/users/[id]/page.tsx b/dashboards/admin-web/src/app/(dashboard)/users/[id]/page.tsx
index 093036fa..01158d91 100644
--- a/dashboards/admin-web/src/app/(dashboard)/users/[id]/page.tsx
+++ b/dashboards/admin-web/src/app/(dashboard)/users/[id]/page.tsx
@@ -29,15 +29,8 @@ import {
} from '@/components/ui/table';
import { apiGetUserView, type UserViewResponse, type ApiUsageRecord } from '@/lib/api';
import { formatNumber, formatCurrency, formatDate } from '@/lib/mock-data';
-import {
- AreaChart,
- Area,
- XAxis,
- YAxis,
- CartesianGrid,
- Tooltip,
- ResponsiveContainer,
-} from 'recharts';
+import { AreaChart } from '@/components/charts';
+import { seriesValues } from '@/lib/chart-data';
const planColors: Record = {
free: '',
@@ -272,33 +265,13 @@ export default function UserDetailPage() {
Daily Dictation Activity
-
-
-
-
-
-
-
-
-
- v.slice(5)} className="text-xs" />
-
-
-
-
-
+
)}
diff --git a/dashboards/admin-web/src/components/charts/index.tsx b/dashboards/admin-web/src/components/charts/index.tsx
new file mode 100644
index 00000000..a3dbd192
--- /dev/null
+++ b/dashboards/admin-web/src/components/charts/index.tsx
@@ -0,0 +1,53 @@
+'use client';
+
+/**
+ * Lazy, code-split wrappers around the shared `@bytelyst/charts` /
+ * `@bytelyst/data-viz` SVG primitives (UX-2 / CC.5). The chart code is pulled
+ * into its own async chunk via `next/dynamic` so it never weighs down the
+ * initial bundle of the (heavy) dashboard surfaces that embed it. The
+ * primitives are pure SVG and token-themed (`--bl-*`, bridged in `globals.css`),
+ * so they inherit admin's light/dark palette automatically.
+ *
+ * Replaces the previous direct `recharts` usage on the dashboard, usage,
+ * per-user, client-logs and extraction-entity surfaces.
+ */
+import dynamic from 'next/dynamic';
+
+function ChartFallback() {
+ return (
+
+ );
+}
+
+export const AreaChart = dynamic(
+ () => import('./primitives').then(m => ({ default: m.AreaChart })),
+ { ssr: false, loading: ChartFallback }
+);
+
+export const BarChart = dynamic(() => import('./primitives').then(m => ({ default: m.BarChart })), {
+ ssr: false,
+ loading: ChartFallback,
+});
+
+export const LineChart = dynamic(
+ () => import('./primitives').then(m => ({ default: m.LineChart })),
+ { ssr: false, loading: ChartFallback }
+);
+
+export const Donut = dynamic(() => import('./primitives').then(m => ({ default: m.Donut })), {
+ ssr: false,
+ loading: ChartFallback,
+});
+
+export const Sparkline = dynamic(
+ () => import('./primitives').then(m => ({ default: m.Sparkline })),
+ { ssr: false, loading: ChartFallback }
+);
+
+export const KpiCard = dynamic(() => import('./primitives').then(m => ({ default: m.KpiCard })), {
+ ssr: false,
+ loading: ChartFallback,
+});
diff --git a/dashboards/admin-web/src/components/charts/primitives.tsx b/dashboards/admin-web/src/components/charts/primitives.tsx
new file mode 100644
index 00000000..11cbbaa6
--- /dev/null
+++ b/dashboards/admin-web/src/components/charts/primitives.tsx
@@ -0,0 +1,13 @@
+'use client';
+
+/**
+ * Static re-export seam for the shared SVG-chart packages. This module is the
+ * dynamic-import target of `./index.tsx` — `next/dynamic` splits it into its
+ * own chunk, while the *static* `export ... from` lines below resolve the
+ * `@bytelyst/charts` / `@bytelyst/data-viz` package paths exactly like the
+ * other shared `@bytelyst/*` packages do (a direct `import('@bytelyst/charts')`
+ * trips Next's package-`exports` resolver because those packages only declare
+ * an `import` condition).
+ */
+export { AreaChart, BarChart, LineChart, Donut } from '@bytelyst/charts';
+export { Sparkline, KpiCard } from '@bytelyst/data-viz';
diff --git a/dashboards/admin-web/src/components/extraction/entity-chart.tsx b/dashboards/admin-web/src/components/extraction/entity-chart.tsx
index 94a09590..37b4dbe6 100644
--- a/dashboards/admin-web/src/components/extraction/entity-chart.tsx
+++ b/dashboards/admin-web/src/components/extraction/entity-chart.tsx
@@ -1,18 +1,6 @@
'use client';
-import {
- BarChart,
- Bar,
- XAxis,
- YAxis,
- CartesianGrid,
- Tooltip,
- ResponsiveContainer,
- PieChart,
- Pie,
- Cell,
- Legend,
-} from 'recharts';
+import { BarChart, Donut } from '@/components/charts';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
interface ExtractionEntity {
@@ -27,11 +15,11 @@ interface EntityChartProps {
}
const COLORS = [
- 'hsl(var(--chart-1))',
- 'hsl(var(--chart-2))',
- 'hsl(var(--chart-3))',
- 'hsl(var(--chart-4))',
- 'hsl(var(--chart-5))',
+ 'var(--chart-1)',
+ 'var(--chart-2)',
+ 'var(--chart-3)',
+ 'var(--chart-4)',
+ 'var(--chart-5)',
];
export function EntityBarChart({ extractions, title = 'Entities by Class' }: EntityChartProps) {
@@ -55,28 +43,13 @@ export function EntityBarChart({ extractions, title = 'Entities by Class' }: Ent
{title}
-
-
-
-
-
-
-
-
-
+ ({ id: d.name, value: d.count, label: d.name }))}
+ width={640}
+ height={250}
+ className="h-auto w-full"
+ ariaLabel={title}
+ />
);
@@ -102,36 +75,17 @@ export function EntityPieChart({ extractions, title = 'Class Distribution' }: En
{title}
-
-
-
- `${name} (${((percent ?? 0) * 100).toFixed(0)}%)`}
- labelLine={false}
- fontSize={11}
- >
- {data.map((_, idx) => (
- |
- ))}
-
-
-
-
-
+
+ ({
+ id: d.name,
+ label: d.name,
+ value: d.value,
+ color: COLORS[idx % COLORS.length],
+ }))}
+ size={220}
+ ariaLabel={title}
+ />
);
diff --git a/dashboards/admin-web/src/lib/chart-data.ts b/dashboards/admin-web/src/lib/chart-data.ts
new file mode 100644
index 00000000..bb4afc17
--- /dev/null
+++ b/dashboards/admin-web/src/lib/chart-data.ts
@@ -0,0 +1,50 @@
+/**
+ * Pure data-shaping helpers for the `@bytelyst/charts` / `@bytelyst/data-viz`
+ * migration (UX-2). Kept framework-free so they are unit-testable in the node
+ * vitest env and guarantee finite, NaN-free output before it ever reaches an
+ * SVG chart (the chart primitives also filter non-finite values defensively).
+ */
+import type { BarDatum } from '@bytelyst/charts';
+import type { DonutSlice } from '@bytelyst/charts';
+
+/** Coerce to a finite number, falling back to 0 for null/NaN/Infinity. */
+export function finite(n: unknown): number {
+ const v = typeof n === 'number' ? n : Number(n);
+ return Number.isFinite(v) ? v : 0;
+}
+
+/** Map a list of rows to a finite numeric series (X = array index). */
+export function seriesValues(rows: readonly T[], key: keyof T): number[] {
+ return rows.map(r => finite(r[key]));
+}
+
+/**
+ * Map dated rows to `BarDatum[]`, showing an X label only every `labelEvery`
+ * bars (date `MM-DD`) so dense 30/90-day series don't overlap. Empty string
+ * suppresses a label without dropping the bar.
+ */
+export function dateBars(
+ rows: readonly T[],
+ valueKey: keyof T,
+ labelEvery = 5
+): BarDatum[] {
+ return rows.map((r, i) => ({
+ id: r.date,
+ value: finite(r[valueKey]),
+ label: i % labelEvery === 0 ? String(r.date).slice(5) : '',
+ }));
+}
+
+/**
+ * Map breakdown rows to `DonutSlice[]`, dropping non-positive slices (a Donut
+ * of all-zero data renders an empty muted ring rather than NaN arcs).
+ */
+export function donutSlices(
+ rows: readonly T[],
+ idKey: keyof T,
+ valueKey: keyof T
+): DonutSlice[] {
+ return rows
+ .map(r => ({ id: String(r[idKey]), label: String(r[idKey]), value: finite(r[valueKey]) }))
+ .filter(s => s.value > 0);
+}
diff --git a/dashboards/admin-web/vitest.config.ts b/dashboards/admin-web/vitest.config.ts
index 75d821d3..d133e4d7 100644
--- a/dashboards/admin-web/vitest.config.ts
+++ b/dashboards/admin-web/vitest.config.ts
@@ -6,6 +6,16 @@ export default defineConfig({
environment: 'node',
globals: true,
exclude: ['e2e/**', 'node_modules/**'],
+ // Inline the workspace SVG-chart packages so Vitest transforms them and
+ // resolves their `react` import through the dedupe below. Without this the
+ // chart dist (linked from a sibling repo's pnpm store) loads a second
+ // physical React copy and `renderToStaticMarkup` throws "Invalid hook call".
+ // The real Next/webpack build already dedupes these to admin-web's React.
+ server: {
+ deps: {
+ inline: [/@bytelyst\/(charts|data-viz)/],
+ },
+ },
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
@@ -32,5 +42,7 @@ export default defineConfig({
alias: {
'@': path.resolve(__dirname, './src'),
},
+ // Force a single physical React/React-DOM copy for SSR chart render tests.
+ dedupe: ['react', 'react-dom'],
},
});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d71b9f33..d8b420d4 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -89,6 +89,9 @@ importers:
'@bytelyst/auth':
specifier: workspace:*
version: link:../../packages/auth
+ '@bytelyst/charts':
+ specifier: workspace:*
+ version: link:../../packages/charts
'@bytelyst/config':
specifier: workspace:*
version: link:../../packages/config
@@ -98,6 +101,9 @@ importers:
'@bytelyst/dashboard-components':
specifier: workspace:*
version: link:../../packages/dashboard-components
+ '@bytelyst/data-viz':
+ specifier: workspace:*
+ version: link:../../packages/data-viz
'@bytelyst/datastore':
specifier: workspace:*
version: link:../../packages/datastore
@@ -164,9 +170,6 @@ importers:
react-markdown:
specifier: ^10.1.0
version: 10.1.0(@types/react@19.2.14)(react@19.2.3)
- recharts:
- specifier: ^3.7.0
- version: 3.7.0(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react-is@18.3.1)(react@19.2.3)(redux@5.0.1)
redis:
specifier: ^4.7.0
version: 4.7.1