feat(tracker-web): data-viz overview with charts + KpiCards (UX-4)

Add @bytelyst/charts + @bytelyst/data-viz as workspace:* deps (minimal
importer link entries in the lockfile). Replace the badge-pill breakdowns
on /dashboard with KpiCards (total/open/in-progress/done) and a dynamically
imported chart surface: Donut for By Status + By Type (centered total) and
a BarChart for By Priority, coloured from the bridged --bl-chart-* palette.

Pure transforms live in src/lib/overview-charts.ts (finite-coerced, no NaN
reaches the SVG); the heavy chart surface is split into overview-charts.tsx
and loaded via next/dynamic (ssr:false) to keep it out of the route bundle.
Add overview-charts.test.tsx rendering the surface with mocked stats via
react-dom/server (no NaN in paths) + transform unit tests; dedupe react in
vitest so the SSR render uses a single React instance.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
saravanakumardb1 2026-05-29 06:51:39 -07:00
parent 95084d38b3
commit f2dfddf944
7 changed files with 264 additions and 49 deletions

View File

@ -26,9 +26,11 @@
"@azure/keyvault-secrets": "^4.10.0",
"@bytelyst/api-client": "workspace:*",
"@bytelyst/auth-ui": "workspace:*",
"@bytelyst/charts": "workspace:*",
"@bytelyst/config": "workspace:*",
"@bytelyst/dashboard-components": "workspace:*",
"@bytelyst/data-table": "workspace:*",
"@bytelyst/data-viz": "workspace:*",
"@bytelyst/design-tokens": "workspace:*",
"@bytelyst/errors": "workspace:*",
"@bytelyst/notifications-ui": "workspace:*",

View File

@ -0,0 +1,71 @@
/**
* UX-4.3 overview data-viz transforms + render safety.
*
* Renders the dashboard overview chart surface with a mocked `getStats`
* payload through react-dom/server (no DOM/jsdom needed) and asserts the
* produced SVG markup contains no `NaN` path data. Also unit-tests the pure
* transforms in `@/lib/overview-charts` for finite, correctly-mapped output.
*
* @see docs/roadmaps/UX_INTEGRATION_BYTELYST.md (UX-4)
*/
import { describe, it, expect } from 'vitest';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import OverviewCharts from '@/components/overview-charts';
import { statusSlices, typeSlices, priorityBars, overviewKpis } from '@/lib/overview-charts';
import type { TrackerStats } from '@/lib/tracker-client';
const STATS: TrackerStats = {
productId: 'tracker-test',
total: 42,
byType: { bug: 10, feature: 30, task: 2 },
byStatus: { open: 20, in_progress: 12, done: 10 },
byPriority: { critical: 2, high: 10, medium: 20, low: 10 },
};
const EMPTY_STATS: TrackerStats = {
productId: 'tracker-test',
total: 0,
byType: {},
byStatus: {},
byPriority: {},
};
describe('overview-charts transforms', () => {
it('maps status/type entries to donut slices with finite values', () => {
const slices = [...statusSlices(STATS), ...typeSlices(STATS)];
expect(slices.length).toBe(6);
for (const s of slices) {
expect(Number.isFinite(s.value)).toBe(true);
expect(s.value).toBeGreaterThanOrEqual(0);
expect(s.color).toMatch(/var\(--bl-chart-/);
}
expect(statusSlices(STATS).find(s => s.id === 'open')?.value).toBe(20);
});
it('orders priority bars critical → low with finite values', () => {
const bars = priorityBars(STATS);
expect(bars.map(b => b.id)).toEqual(['critical', 'high', 'medium', 'low']);
for (const b of bars) expect(Number.isFinite(b.value)).toBe(true);
});
it('coerces missing / non-finite KPIs to 0', () => {
expect(overviewKpis(STATS)).toEqual({ total: 42, open: 20, inProgress: 12, done: 10 });
expect(overviewKpis(EMPTY_STATS)).toEqual({ total: 0, open: 0, inProgress: 0, done: 0 });
});
});
describe('OverviewCharts render safety', () => {
it('renders mocked stats without NaN in the SVG paths', () => {
const html = renderToStaticMarkup(createElement(OverviewCharts, { stats: STATS }));
expect(html).toContain('<svg');
expect(html).not.toContain('NaN');
expect(html).toContain('42');
});
it('renders empty stats without NaN (empty donut ring)', () => {
const html = renderToStaticMarkup(createElement(OverviewCharts, { stats: EMPTY_STATS }));
expect(html).not.toContain('NaN');
});
});

View File

@ -1,46 +1,19 @@
'use client';
import { useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import { PageHeader, LoadingSpinner } from '@bytelyst/dashboard-components';
import { MetricCard, AlertBanner } from '@/components/ui/Primitives';
import { KpiCard } from '@bytelyst/data-viz';
import { AlertBanner, Skeleton } from '@/components/ui/Primitives';
import { useAuth } from '@/lib/auth-context';
import { getStats, type TrackerStats } from '@/lib/tracker-client';
import { overviewKpis } from '@/lib/overview-charts';
const STAT_COLORS: Record<string, string> = {
bug: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300',
feature: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
task: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300',
open: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300',
in_progress: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300',
done: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300',
closed: 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300',
wont_fix: 'bg-gray-100 text-gray-600 dark:bg-gray-900/30 dark:text-gray-400',
critical: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300',
high: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300',
medium: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300',
low: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300',
};
function StatCard({ title, entries }: { title: string; entries: Record<string, number> }) {
return (
<div className="rounded-xl border border-border bg-card p-5">
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wider text-muted-foreground">
{title}
</h3>
<div className="flex flex-wrap gap-2">
{Object.entries(entries).map(([key, count]) => (
<span
key={key}
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm font-medium ${STAT_COLORS[key] || 'bg-muted text-muted-foreground'}`}
>
{key.replace(/_/g, ' ')}
<span className="font-bold">{count}</span>
</span>
))}
</div>
</div>
);
}
// Heavy SVG chart surface — kept out of the initial route bundle (UX-4 / CC.7).
const OverviewCharts = dynamic(() => import('@/components/overview-charts'), {
ssr: false,
loading: () => <Skeleton className="h-64 w-full" />,
});
export default function DashboardOverview() {
const { token } = useAuth();
@ -54,6 +27,8 @@ export default function DashboardOverview() {
.catch(err => setError(err.message));
}, [token]);
const kpis = stats ? overviewKpis(stats) : null;
return (
<div className="space-y-6">
<PageHeader title="Dashboard" />
@ -65,21 +40,18 @@ export default function DashboardOverview() {
</AlertBanner>
)}
{stats ? (
{stats && kpis ? (
<div className="space-y-4">
{/* Total count */}
<MetricCard
label={`Total items for ${stats.productId}`}
value={stats.total}
helper="All tracked items"
/>
{/* Breakdown cards */}
<div className="grid gap-4 md:grid-cols-3">
<StatCard title="By Type" entries={stats.byType} />
<StatCard title="By Status" entries={stats.byStatus} />
<StatCard title="By Priority" entries={stats.byPriority} />
{/* KPI row */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<KpiCard label={`Total · ${stats.productId}`} value={kpis.total} />
<KpiCard label="Open" value={kpis.open} />
<KpiCard label="In Progress" value={kpis.inProgress} />
<KpiCard label="Done" value={kpis.done} />
</div>
{/* Charts */}
<OverviewCharts stats={stats} />
</div>
) : !error ? (
<div className="flex justify-center py-10">

View File

@ -0,0 +1,78 @@
'use client';
import { Donut, BarChart } from '@bytelyst/charts';
import { Card, CardHeader, CardTitle } from '@/components/ui/Primitives';
import { statusSlices, typeSlices, priorityBars } from '@/lib/overview-charts';
import type { TrackerStats } from '@/lib/tracker-client';
/**
* Heavy chart surface for the dashboard overview (UX-4.2). Imported via
* next/dynamic from the overview page so the SVG chart code stays out of the
* initial route bundle.
*/
export default function OverviewCharts({ stats }: { stats: TrackerStats }) {
const statuses = statusSlices(stats);
const types = typeSlices(stats);
const priorities = priorityBars(stats);
return (
<div className="grid gap-4 md:grid-cols-3">
<Card className="flex flex-col items-center p-5">
<CardHeader className="w-full">
<CardTitle className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
By Status
</CardTitle>
</CardHeader>
<Donut
slices={statuses}
size={180}
ariaLabel="Items by status"
centerContent={<div className="text-2xl font-bold text-foreground">{stats.total}</div>}
/>
<ChartLegend slices={statuses} />
</Card>
<Card className="flex flex-col items-center p-5">
<CardHeader className="w-full">
<CardTitle className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
By Type
</CardTitle>
</CardHeader>
<Donut slices={types} size={180} ariaLabel="Items by type" />
<ChartLegend slices={types} />
</Card>
<Card className="p-5">
<CardHeader>
<CardTitle className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
By Priority
</CardTitle>
</CardHeader>
<BarChart
data={priorities}
width={360}
height={200}
ariaLabel="Items by priority"
className="w-full"
/>
</Card>
</div>
);
}
function ChartLegend({ slices }: { slices: { id: string; label?: string; color?: string }[] }) {
return (
<ul className="mt-4 flex w-full flex-wrap justify-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
{slices.map(s => (
<li key={s.id} className="flex items-center gap-1.5">
<span
aria-hidden="true"
className="h-2 w-2 rounded-full"
style={{ background: s.color }}
/>
{s.label ?? s.id}
</li>
))}
</ul>
);
}

View File

@ -0,0 +1,82 @@
/**
* Pure helpers that map TrackerStats into @bytelyst/charts + @bytelyst/data-viz
* data shapes for the dashboard overview (UX-4).
*
* Kept separate from the page component so the transforms are unit-testable
* (no DOM needed) and guaranteed to emit only finite numbers protecting the
* SVG charts from NaN path data.
*
* @see docs/roadmaps/UX_INTEGRATION_BYTELYST.md (UX-4)
*/
import type { DonutSlice, BarDatum } from '@bytelyst/charts';
import type { TrackerStats } from './tracker-client';
/** Cycle the bridged --bl-chart-* palette (maps onto tracker's --chart-1..5). */
export const CHART_PALETTE = [
'var(--bl-chart-1)',
'var(--bl-chart-2)',
'var(--bl-chart-3)',
'var(--bl-chart-4)',
'var(--bl-chart-5)',
] as const;
const LABELS: Record<string, string> = {
open: 'Open',
in_progress: 'In Progress',
done: 'Done',
closed: 'Closed',
wont_fix: "Won't Fix",
bug: 'Bug',
feature: 'Feature',
task: 'Task',
critical: 'Critical',
high: 'High',
medium: 'Medium',
low: 'Low',
};
/** Coerce any value to a finite, non-negative number (no NaN reaches the SVG). */
const safe = (v: number | undefined): number =>
typeof v === 'number' && Number.isFinite(v) ? Math.max(0, v) : 0;
function toSlices(entries: Record<string, number>): DonutSlice[] {
return Object.entries(entries).map(([key, value], i) => ({
id: key,
value: safe(value),
label: LABELS[key] ?? key,
color: CHART_PALETTE[i % CHART_PALETTE.length],
}));
}
export const statusSlices = (stats: TrackerStats): DonutSlice[] => toSlices(stats.byStatus);
export const typeSlices = (stats: TrackerStats): DonutSlice[] => toSlices(stats.byType);
/** Priority bars in a fixed, meaningful order (critical → low). */
export function priorityBars(stats: TrackerStats): BarDatum[] {
const order = ['critical', 'high', 'medium', 'low'];
return order
.filter(key => key in stats.byPriority)
.map((key, i) => ({
id: key,
value: safe(stats.byPriority[key]),
label: LABELS[key] ?? key,
color: CHART_PALETTE[i % CHART_PALETTE.length],
}));
}
export interface OverviewKpis {
total: number;
open: number;
inProgress: number;
done: number;
}
export function overviewKpis(stats: TrackerStats): OverviewKpis {
return {
total: safe(stats.total),
open: safe(stats.byStatus.open),
inProgress: safe(stats.byStatus.in_progress),
done: safe(stats.byStatus.done),
};
}

View File

@ -32,5 +32,9 @@ export default defineConfig({
alias: {
'@': path.resolve(__dirname, './src'),
},
// Workspace packages (e.g. @bytelyst/charts) can resolve their own React
// copy via the pnpm store; dedupe so SSR render tests use a single React
// instance (avoids the "Invalid hook call" dual-package hazard).
dedupe: ['react', 'react-dom'],
},
});

6
pnpm-lock.yaml generated
View File

@ -246,6 +246,9 @@ importers:
'@bytelyst/auth-ui':
specifier: workspace:*
version: link:../../packages/auth-ui
'@bytelyst/charts':
specifier: workspace:*
version: link:../../packages/charts
'@bytelyst/config':
specifier: workspace:*
version: link:../../packages/config
@ -255,6 +258,9 @@ importers:
'@bytelyst/data-table':
specifier: workspace:*
version: link:../../packages/data-table
'@bytelyst/data-viz':
specifier: workspace:*
version: link:../../packages/data-viz
'@bytelyst/design-tokens':
specifier: workspace:*
version: link:../../packages/design-tokens