diff --git a/dashboards/tracker-web/package.json b/dashboards/tracker-web/package.json index a0965390..bd25c674 100644 --- a/dashboards/tracker-web/package.json +++ b/dashboards/tracker-web/package.json @@ -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:*", diff --git a/dashboards/tracker-web/src/__tests__/overview-charts.test.tsx b/dashboards/tracker-web/src/__tests__/overview-charts.test.tsx new file mode 100644 index 00000000..07a17ebb --- /dev/null +++ b/dashboards/tracker-web/src/__tests__/overview-charts.test.tsx @@ -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(' { + const html = renderToStaticMarkup(createElement(OverviewCharts, { stats: EMPTY_STATS })); + expect(html).not.toContain('NaN'); + }); +}); diff --git a/dashboards/tracker-web/src/app/dashboard/page.tsx b/dashboards/tracker-web/src/app/dashboard/page.tsx index 1ca883f7..868a68ed 100644 --- a/dashboards/tracker-web/src/app/dashboard/page.tsx +++ b/dashboards/tracker-web/src/app/dashboard/page.tsx @@ -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 = { - 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 }) { - return ( -
-

- {title} -

-
- {Object.entries(entries).map(([key, count]) => ( - - {key.replace(/_/g, ' ')} - {count} - - ))} -
-
- ); -} +// 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: () => , +}); 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 (
@@ -65,21 +40,18 @@ export default function DashboardOverview() { )} - {stats ? ( + {stats && kpis ? (
- {/* Total count */} - - - {/* Breakdown cards */} -
- - - + {/* KPI row */} +
+ + + +
+ + {/* Charts */} +
) : !error ? (
diff --git a/dashboards/tracker-web/src/components/overview-charts.tsx b/dashboards/tracker-web/src/components/overview-charts.tsx new file mode 100644 index 00000000..ad13aade --- /dev/null +++ b/dashboards/tracker-web/src/components/overview-charts.tsx @@ -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 ( +
+ + + + By Status + + + {stats.total}
} + /> + + + + + + + By Type + + + + + + + + + + By Priority + + + + +
+ ); +} + +function ChartLegend({ slices }: { slices: { id: string; label?: string; color?: string }[] }) { + return ( +
    + {slices.map(s => ( +
  • +
  • + ))} +
+ ); +} diff --git a/dashboards/tracker-web/src/lib/overview-charts.ts b/dashboards/tracker-web/src/lib/overview-charts.ts new file mode 100644 index 00000000..ead4231b --- /dev/null +++ b/dashboards/tracker-web/src/lib/overview-charts.ts @@ -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 = { + 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): 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), + }; +} diff --git a/dashboards/tracker-web/vitest.config.ts b/dashboards/tracker-web/vitest.config.ts index 40370b4a..a0cef323 100644 --- a/dashboards/tracker-web/vitest.config.ts +++ b/dashboards/tracker-web/vitest.config.ts @@ -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'], }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0ea5095a..40bb3a0a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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