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:
parent
95084d38b3
commit
f2dfddf944
@ -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:*",
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
@ -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">
|
||||
|
||||
78
dashboards/tracker-web/src/components/overview-charts.tsx
Normal file
78
dashboards/tracker-web/src/components/overview-charts.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
dashboards/tracker-web/src/lib/overview-charts.ts
Normal file
82
dashboards/tracker-web/src/lib/overview-charts.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
@ -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
6
pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user