diff --git a/src/components/dashboard/AdvancedDashboard.tsx b/src/components/dashboard/AdvancedDashboard.tsx new file mode 100644 index 00000000..10291988 --- /dev/null +++ b/src/components/dashboard/AdvancedDashboard.tsx @@ -0,0 +1,246 @@ +/** + * AdvancedDashboard Component + * Main Advanced Data Visualization Dashboard. + * Features: drag-and-drop panel layout, multiple chart types, real-time updates, + * data filtering, drill-down, and dashboard sharing. + */ + +'use client'; + +import React, { useState } from 'react'; +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + DragEndEvent, +} from '@dnd-kit/core'; +import { + SortableContext, + sortableKeyboardCoordinates, + rectSortingStrategy, + useSortable, +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { motion } from 'framer-motion'; +import { Share2, Download, GripVertical, BarChart2, CheckCheck } from 'lucide-react'; +import toast, { Toaster } from 'react-hot-toast'; + +import { DashboardFilters } from './DashboardFilters'; +import { InteractiveCharts } from './InteractiveCharts'; +import { RealTimeUpdater } from './RealTimeUpdater'; +import { useDashboardData } from '@/hooks/useDashboardData'; +import type { DashboardPanel } from '@/hooks/useDashboardData'; + +// ─── Sortable Panel Wrapper ─────────────────────────────────────────────────── + +interface SortablePanelProps { + panel: DashboardPanel; + children: React.ReactNode; +} + +const SortablePanel: React.FC = ({ panel, children }) => { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: panel.id, + }); + + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + zIndex: isDragging ? 50 : undefined, + opacity: isDragging ? 0.75 : 1, + }; + + return ( +
+ {/* Drag handle */} + + {children} +
+ ); +}; + +// ─── Main Component ─────────────────────────────────────────────────────────── + +export interface AdvancedDashboardProps { + className?: string; +} + +export const AdvancedDashboard: React.FC = ({ className = '' }) => { + const { + panels, + filters, + setFilters, + resetFilters, + setPanelChartType, + drillDown, + clearDrillDown, + reorderPanels, + generateShareURL, + exportPanel, + } = useDashboardData(); + + const [shareSuccess, setShareSuccess] = useState(false); + + // DnD sensors + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 5 }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) return; + + const fromIndex = panels.findIndex((p) => p.id === active.id); + const toIndex = panels.findIndex((p) => p.id === over.id); + if (fromIndex !== -1 && toIndex !== -1) { + reorderPanels(fromIndex, toIndex); + } + }; + + const handleShare = async () => { + const url = generateShareURL(); + try { + await navigator.clipboard.writeText(url); + setShareSuccess(true); + toast.success('Dashboard link copied to clipboard!', { duration: 2500 }); + setTimeout(() => setShareSuccess(false), 2500); + } catch { + toast.error('Could not copy to clipboard'); + } + }; + + const handleExportAll = () => { + panels.forEach((panel) => { + if (panel.id !== 'realtime') { + exportPanel(panel.id, 'csv'); + } + }); + toast.success('Panels exported as CSV'); + }; + + return ( +
+ + + {/* Page header */} +
+
+
+
+
+

+ Analytics Dashboard +

+

+ Interactive data visualization & real-time insights +

+
+
+ +
+ {/* Export all */} + + + {/* Share */} + +
+
+ + {/* Filters */} + + + {/* Drag-and-drop grid */} + + p.id)} strategy={rectSortingStrategy}> +
+ {panels.map((panel, idx) => ( + + + {/* Panel header */} +
+

+ {panel.title} +

+ +
+ + {/* Panel content */} + {panel.id === 'realtime' ? ( + + ) : ( + setPanelChartType(panel.id, type)} + onDrillDown={(index) => drillDown(panel.id, index)} + onClearDrillDown={() => clearDrillDown(panel.id)} + /> + )} +
+
+ ))} +
+
+
+
+ ); +}; diff --git a/src/components/dashboard/DashboardFilters.tsx b/src/components/dashboard/DashboardFilters.tsx new file mode 100644 index 00000000..05efe818 --- /dev/null +++ b/src/components/dashboard/DashboardFilters.tsx @@ -0,0 +1,252 @@ +/** + * DashboardFilters Component + * Collapsible filter panel for the Advanced Data Visualization Dashboard. + * Supports time range, categories, metric, and aggregation type selectors. + */ + +'use client'; + +import React, { useState } from 'react'; +import { Filter, X, RotateCcw } from 'lucide-react'; +import { TimeRange, AggregationType, CHART_COLOR_PALETTE } from '@/utils/visualizationUtils'; +import type { DashboardFiltersState } from '@/hooks/useDashboardData'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface DashboardFiltersProps { + filters: DashboardFiltersState; + onFiltersChange: (partial: Partial) => void; + onReset: () => void; + categories?: string[]; + metrics?: string[]; + className?: string; +} + +// ─── Constants ──────────────────────────────────────────────────────────────── + +const TIME_RANGE_OPTIONS: { value: TimeRange; label: string }[] = [ + { value: '7d', label: 'Last 7 Days' }, + { value: '30d', label: 'Last 30 Days' }, + { value: '90d', label: 'Last 90 Days' }, + { value: '1y', label: 'Last Year' }, + { value: 'all', label: 'All Time' }, +]; + +const AGGREGATION_OPTIONS: { value: AggregationType; label: string }[] = [ + { value: 'sum', label: 'Sum' }, + { value: 'average', label: 'Average' }, + { value: 'count', label: 'Count' }, + { value: 'min', label: 'Min' }, + { value: 'max', label: 'Max' }, +]; + +const DEFAULT_CATEGORIES = ['Web Dev', 'Data Science', 'Design', 'Marketing', 'Mobile']; +const DEFAULT_METRICS = ['enrollments', 'revenue', 'completions', 'views']; + +// ─── Component ──────────────────────────────────────────────────────────────── + +export const DashboardFilters: React.FC = ({ + filters, + onFiltersChange, + onReset, + categories = DEFAULT_CATEGORIES, + metrics = DEFAULT_METRICS, + className = '', +}) => { + const [isOpen, setIsOpen] = useState(false); + + const toggleCategory = (cat: string) => { + const next = filters.categories.includes(cat) + ? filters.categories.filter((c) => c !== cat) + : [...filters.categories, cat]; + onFiltersChange({ categories: next }); + }; + + const removeCategory = (cat: string) => { + onFiltersChange({ categories: filters.categories.filter((c) => c !== cat) }); + }; + + const activeFilterCount = + (filters.timeRange !== '30d' ? 1 : 0) + + filters.categories.length + + (filters.metric !== 'enrollments' ? 1 : 0) + + (filters.aggregation !== 'sum' ? 1 : 0); + + return ( +
+ {/* Header bar */} +
+
+ + + {/* Active filter badges */} +
+ {filters.timeRange !== '30d' && ( + + {TIME_RANGE_OPTIONS.find((o) => o.value === filters.timeRange)?.label} + + + )} + {filters.categories.map((cat, i) => ( + + {cat} + + + ))} +
+
+ + {activeFilterCount > 0 && ( + + )} +
+ + {/* Expandable panel */} + {isOpen && ( +
+ {/* Time Range */} +
+ + +
+ + {/* Aggregation */} +
+ + +
+ + {/* Metric */} +
+ + Metric + +
+ {metrics.map((m) => ( + + ))} +
+
+ + {/* Categories */} +
+ + Categories + +
+ {categories.map((cat, i) => { + const isActive = filters.categories.includes(cat); + return ( + + ); + })} +
+
+
+ )} +
+ ); +}; diff --git a/src/components/dashboard/InteractiveCharts.tsx b/src/components/dashboard/InteractiveCharts.tsx new file mode 100644 index 00000000..a3de35b0 --- /dev/null +++ b/src/components/dashboard/InteractiveCharts.tsx @@ -0,0 +1,164 @@ +/** + * InteractiveCharts Component + * Chart panel with multiple chart type switcher and drill-down capability. + * Wraps the existing InteractiveChartLibrary with dashboard-specific interactions. + */ + +'use client'; + +import React from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + TrendingUp, + BarChart3, + AreaChart, + PieChart, + ScatterChart, + Radar, + ChevronRight, + ArrowLeft, +} from 'lucide-react'; +import { InteractiveChartLibrary } from '@/components/visualization/InteractiveChartLibrary'; +import { getDrillDownData } from '@/utils/chartUtils'; +import type { ChartData, ChartType } from '@/utils/visualizationUtils'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface InteractiveChartsProps { + panelId: string; + data: ChartData; + chartType: ChartType; + title: string; + drillDownIndex: number | null; + onChartTypeChange: (type: ChartType) => void; + onDrillDown: (index: number) => void; + onClearDrillDown: () => void; + className?: string; +} + +// ─── Chart type toolbar config ──────────────────────────────────────────────── + +const CHART_TYPE_BUTTONS: { type: ChartType; Icon: React.ElementType; label: string }[] = [ + { type: 'line', Icon: TrendingUp, label: 'Line chart' }, + { type: 'bar', Icon: BarChart3, label: 'Bar chart' }, + { type: 'area', Icon: AreaChart, label: 'Area chart' }, + { type: 'pie', Icon: PieChart, label: 'Pie chart' }, + { type: 'scatter', Icon: ScatterChart, label: 'Scatter chart' }, + { type: 'radar', Icon: Radar, label: 'Radar chart' }, +]; + +// ─── Component ──────────────────────────────────────────────────────────────── + +export const InteractiveCharts: React.FC = ({ + panelId, + data, + chartType, + title, + drillDownIndex, + onChartTypeChange, + onDrillDown, + onClearDrillDown, + className = '', +}) => { + const isDrillDown = drillDownIndex !== null; + const drillDownData = isDrillDown ? getDrillDownData(data, drillDownIndex) : null; + const drillDownLabel = isDrillDown ? data.labels[drillDownIndex] ?? 'Selected' : null; + + return ( +
+ {/* Chart type toolbar */} +
+ {CHART_TYPE_BUTTONS.map(({ type, Icon, label }) => ( + + ))} +
+ + {/* Main chart */} + + {!isDrillDown ? ( + + + onDrillDown(data?.activeTooltipIndex ?? data?.index ?? 0) + } + /> + + ) : ( + + {/* Breadcrumb */} +
+ +
+ + {/* Drill-down chart */} + {drillDownData && ( + + )} + + {/* Back button */} + +
+ )} +
+
+ ); +}; diff --git a/src/components/dashboard/RealTimeUpdater.tsx b/src/components/dashboard/RealTimeUpdater.tsx new file mode 100644 index 00000000..dd550f48 --- /dev/null +++ b/src/components/dashboard/RealTimeUpdater.tsx @@ -0,0 +1,252 @@ +/** + * RealTimeUpdater Component + * Live data stream panel for the Advanced Data Visualization Dashboard. + * Uses the existing useDataVisualization hook for WebSocket + simulation support. + */ + +'use client'; + +import React, { useEffect, useState } from 'react'; +import { Wifi, WifiOff, Activity, Pause, Play, RefreshCw } from 'lucide-react'; +import { InteractiveChartLibrary } from '@/components/visualization/InteractiveChartLibrary'; +import { useDataVisualization } from '@/hooks/useDataVisualization'; +import type { ChartType } from '@/utils/visualizationUtils'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface RealTimeUpdaterProps { + title?: string; + chartType?: ChartType; + websocketUrl?: string; + updateInterval?: number; + maxDataPoints?: number; + className?: string; +} + +const SPEED_OPTIONS = [ + { label: '0.5s', value: 500 }, + { label: '1s', value: 1000 }, + { label: '2s', value: 2000 }, + { label: '5s', value: 5000 }, +]; + +// ─── Component ──────────────────────────────────────────────────────────────── + +export const RealTimeUpdater: React.FC = ({ + title = 'Live Activity', + chartType = 'line', + websocketUrl, + updateInterval: initialInterval = 2000, + maxDataPoints = 20, + className = '', +}) => { + const [isPaused, setIsPaused] = useState(false); + const [interval, setIntervalValue] = useState(initialInterval); + const simulationEnabled = !websocketUrl; + + const { data, isConnected, isLoading, error, updateData, addDataPoint, config, calculateStats } = + useDataVisualization({ + initialData: { + labels: [], + datasets: [ + { + label: 'Live Data', + data: [], + borderColor: '#3b82f6', + backgroundColor: 'rgba(59,130,246,0.1)', + borderWidth: 2, + }, + ], + }, + config: { + chartType, + realTimeEnabled: !!websocketUrl, + animated: true, + showLegend: true, + showGrid: true, + }, + websocketUrl, + }); + + // Simulate real-time data when no WebSocket URL provided + useEffect(() => { + if (!simulationEnabled || isPaused) return; + + const timer = setInterval(() => { + const timeLabel = new Date().toLocaleTimeString(); + const value = Math.floor(Math.random() * 100); + addDataPoint(0, value, timeLabel); + + if (data && data.labels.length > maxDataPoints) { + updateData({ + labels: data.labels.slice(-maxDataPoints), + datasets: data.datasets.map((ds) => ({ + ...ds, + data: ds.data.slice(-maxDataPoints), + })), + }); + } + }, interval); + + return () => clearInterval(timer); + }, [simulationEnabled, isPaused, interval, maxDataPoints, addDataPoint, data, updateData]); + + const stats = calculateStats(); + + const statusText = isPaused + ? 'Paused' + : isConnected + ? 'Connected' + : simulationEnabled + ? 'Simulating' + : 'Disconnected'; + + const statusColor = isPaused + ? 'text-yellow-600 dark:text-yellow-400' + : isConnected || simulationEnabled + ? 'text-green-600 dark:text-green-400' + : 'text-red-600 dark:text-red-400'; + + const handleReset = () => { + updateData({ + labels: [], + datasets: [ + { + label: 'Live Data', + data: [], + borderColor: '#3b82f6', + backgroundColor: 'rgba(59,130,246,0.1)', + borderWidth: 2, + }, + ], + }); + }; + + return ( +
+ {/* Status bar */} +
+
+ {/* Connection status */} +
+ {(isConnected || simulationEnabled) && !isPaused ? ( +
+ + {/* Data point count */} + {data && data.labels.length > 0 && ( +
+
+ )} +
+ + {/* Controls */} +
+ {/* Speed selector */} + + + + {/* Pause / Resume */} + + + {/* Reset */} + +
+
+ + {/* Error */} + {error && ( +

+ {error} +

+ )} + + {/* Stats bar */} + {stats && ( +
+
+
Mean
+
+ {stats.mean.toFixed(1)} +
+
+
+
Median
+
+ {stats.median.toFixed(1)} +
+
+
+
Trend
+
+ {stats.trend.direction === 'up' ? '↑' : stats.trend.direction === 'down' ? '↓' : '→'} + {stats.trend.percentage.toFixed(1)}% +
+
+
+ )} + + {/* Chart or empty state */} + {data && data.labels.length > 0 ? ( + + ) : ( +
+
+ )} +
+ ); +}; diff --git a/src/components/dashboard/__tests__/AdvancedDashboard.test.tsx b/src/components/dashboard/__tests__/AdvancedDashboard.test.tsx new file mode 100644 index 00000000..f52224e6 --- /dev/null +++ b/src/components/dashboard/__tests__/AdvancedDashboard.test.tsx @@ -0,0 +1,145 @@ +// @vitest-environment jsdom +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { AdvancedDashboard } from '../AdvancedDashboard'; + +// ─── Mocks ──────────────────────────────────────────────────────────────────── + +global.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} +}; + +// Mock child components so we can assert presence without full rendering overhead +vi.mock('../DashboardFilters', () => ({ + DashboardFilters: ({ onFiltersChange }: { onFiltersChange: () => void }) => ( +
+ ), +})); + +vi.mock('../InteractiveCharts', () => ({ + InteractiveCharts: ({ title }: { title: string }) => ( +
{title}
+ ), +})); + +vi.mock('../RealTimeUpdater', () => ({ + RealTimeUpdater: ({ title }: { title?: string }) => ( +
{title}
+ ), +})); + +// Mock react-hot-toast +vi.mock('react-hot-toast', () => ({ + default: { + success: vi.fn(), + error: vi.fn(), + }, + Toaster: () => null, +})); + +// Mock @dnd-kit to avoid pointer event / PointerSensor issues in jsdom +vi.mock('@dnd-kit/core', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + DndContext: ({ children }: { children: React.ReactNode }) => <>{children}, + useSensor: vi.fn(() => ({})), + useSensors: vi.fn((...sensors) => sensors), + PointerSensor: vi.fn(), + KeyboardSensor: vi.fn(), + closestCenter: vi.fn(), + }; +}); + +vi.mock('@dnd-kit/sortable', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + SortableContext: ({ children }: { children: React.ReactNode }) => <>{children}, + useSortable: () => ({ + attributes: {}, + listeners: {}, + setNodeRef: vi.fn(), + transform: null, + transition: undefined, + isDragging: false, + }), + rectSortingStrategy: vi.fn(), + sortableKeyboardCoordinates: vi.fn(), + }; +}); + +// Mock clipboard +const writeTextMock = vi.fn().mockResolvedValue(undefined); +Object.defineProperty(navigator, 'clipboard', { + writable: true, + value: { writeText: writeTextMock }, +}); + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('AdvancedDashboard', () => { + beforeEach(() => { + vi.clearAllMocks(); + Object.defineProperty(window, 'location', { + writable: true, + value: { search: '', origin: 'http://localhost', pathname: '/dashboard' }, + }); + }); + + it('renders without crashing', () => { + render(); + expect(screen.getByText(/analytics dashboard/i)).toBeInTheDocument(); + }); + + it('renders the DashboardFilters component', () => { + render(); + expect(screen.getByTestId('dashboard-filters')).toBeInTheDocument(); + }); + + it('renders all 3 InteractiveCharts panels', () => { + render(); + const charts = screen.getAllByTestId('interactive-charts'); + expect(charts.length).toBe(3); + }); + + it('renders the RealTimeUpdater panel', () => { + render(); + expect(screen.getByTestId('realtime-updater')).toBeInTheDocument(); + }); + + it('renders the Share button', () => { + render(); + expect( + screen.getByRole('button', { name: /copy shareable dashboard link/i }), + ).toBeInTheDocument(); + }); + + it('renders the Export All button', () => { + render(); + expect(screen.getByRole('button', { name: /export all panels as csv/i })).toBeInTheDocument(); + }); + + it('calls clipboard.writeText when Share is clicked', async () => { + render(); + const shareBtn = screen.getByRole('button', { name: /copy shareable dashboard link/i }); + fireEvent.click(shareBtn); + // writeText is async; give it a tick + await Promise.resolve(); + expect(writeTextMock).toHaveBeenCalledTimes(1); + const url: string = writeTextMock.mock.calls[0][0]; + expect(url).toContain('timeRange'); + }); + + it('renders panel titles in headings', () => { + render(); + const headings = screen.getAllByRole('heading', { level: 2 }); + const headingTexts = headings.map((h) => h.textContent); + expect(headingTexts).toContain('Course Enrollments'); + expect(headingTexts).toContain('Revenue'); + expect(headingTexts).toContain('Completions'); + }); +}); diff --git a/src/components/dashboard/__tests__/DashboardFilters.test.tsx b/src/components/dashboard/__tests__/DashboardFilters.test.tsx new file mode 100644 index 00000000..eb8072bb --- /dev/null +++ b/src/components/dashboard/__tests__/DashboardFilters.test.tsx @@ -0,0 +1,105 @@ +// @vitest-environment jsdom +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { DashboardFilters } from '../DashboardFilters'; +import type { DashboardFiltersState } from '@/hooks/useDashboardData'; + +// ─── Default props ──────────────────────────────────────────────────────────── + +const defaultFilters: DashboardFiltersState = { + timeRange: '30d', + categories: [], + metric: 'enrollments', + aggregation: 'sum', +}; + +const defaultProps = { + filters: defaultFilters, + onFiltersChange: vi.fn(), + onReset: vi.fn(), +}; + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('DashboardFilters', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders the filter toggle button', () => { + render(); + expect(screen.getByRole('button', { name: /show filters/i })).toBeInTheDocument(); + }); + + it('does not show the filter panel by default', () => { + render(); + expect(screen.queryByLabelText(/time range/i)).not.toBeInTheDocument(); + }); + + it('shows the filter panel when toggle is clicked', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /show filters/i })); + expect(screen.getByLabelText(/time range/i)).toBeInTheDocument(); + }); + + it('hides the filter panel on second toggle click', () => { + render(); + const btn = screen.getByRole('button', { name: /show filters/i }); + fireEvent.click(btn); + expect(screen.getByLabelText(/time range/i)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /hide filters/i })); + expect(screen.queryByLabelText(/time range/i)).not.toBeInTheDocument(); + }); + + it('calls onFiltersChange with updated timeRange when select changes', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /show filters/i })); + const select = screen.getByLabelText(/time range/i); + fireEvent.change(select, { target: { value: '7d' } }); + expect(defaultProps.onFiltersChange).toHaveBeenCalledWith({ timeRange: '7d' }); + }); + + it('calls onFiltersChange when a category chip is toggled', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /show filters/i })); + fireEvent.click(screen.getByRole('button', { name: /web dev/i })); + expect(defaultProps.onFiltersChange).toHaveBeenCalledWith({ + categories: ['Web Dev'], + }); + }); + + it('calls onFiltersChange removing a category when chip toggled off', () => { + const filters = { ...defaultFilters, categories: ['Web Dev'] }; + render( + , + ); + fireEvent.click(screen.getByRole('button', { name: /show filters/i })); + // The chip inside the filter panel is in the group labelled "Category filters" + const group = screen.getByRole('group', { name: /category filters/i }); + const chipBtn = group.querySelector('button[aria-pressed="true"]') as HTMLElement; + fireEvent.click(chipBtn); + expect(defaultProps.onFiltersChange).toHaveBeenCalledWith({ categories: [] }); + }); + + it('shows active filter badge for non-default time range', () => { + render(); + expect(screen.getByText('Last 7 Days')).toBeInTheDocument(); + }); + + it('shows Reset All button when there are active filters', () => { + render(); + expect(screen.getByRole('button', { name: /reset all filters/i })).toBeInTheDocument(); + }); + + it('calls onReset when Reset All is clicked', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /reset all filters/i })); + expect(defaultProps.onReset).toHaveBeenCalledTimes(1); + }); + + it('does not show Reset All button when no active filters', () => { + render(); + expect(screen.queryByRole('button', { name: /reset all/i })).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/dashboard/__tests__/InteractiveCharts.test.tsx b/src/components/dashboard/__tests__/InteractiveCharts.test.tsx new file mode 100644 index 00000000..a0c7a88c --- /dev/null +++ b/src/components/dashboard/__tests__/InteractiveCharts.test.tsx @@ -0,0 +1,122 @@ +// @vitest-environment jsdom +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { InteractiveCharts } from '../InteractiveCharts'; +import type { ChartData } from '@/utils/visualizationUtils'; + +// ─── Mocks ──────────────────────────────────────────────────────────────────── + +// Recharts uses ResizeObserver; provide a minimal stub +global.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} +}; + +// Mock InteractiveChartLibrary to avoid Recharts SVG rendering complexity in jsdom +vi.mock('@/components/visualization/InteractiveChartLibrary', () => ({ + InteractiveChartLibrary: ({ + title, + onDataPointClick, + }: { + title?: string; + onDataPointClick?: (data: unknown) => void; + }) => ( +
+ +
+ ), +})); + +// ─── Sample data ────────────────────────────────────────────────────────────── + +const sampleData: ChartData = { + labels: ['Jan', 'Feb', 'Mar'], + datasets: [{ label: 'Enrollments', data: [10, 20, 30] }], +}; + +const baseProps = { + panelId: 'enrollments', + data: sampleData, + chartType: 'line' as const, + title: 'Course Enrollments', + drillDownIndex: null, + onChartTypeChange: vi.fn(), + onDrillDown: vi.fn(), + onClearDrillDown: vi.fn(), +}; + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('InteractiveCharts', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders without crashing', () => { + render(); + expect(screen.getByTestId('mock-chart')).toBeInTheDocument(); + }); + + it('renders chart type switcher toolbar', () => { + render(); + expect(screen.getByRole('toolbar', { name: /chart type selector/i })).toBeInTheDocument(); + }); + + it('renders buttons for each chart type', () => { + render(); + expect(screen.getByRole('button', { name: /line chart/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /bar chart/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /area chart/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /pie chart/i })).toBeInTheDocument(); + }); + + it('marks the active chart type button as pressed', () => { + render(); + const barBtn = screen.getByRole('button', { name: /bar chart/i }); + expect(barBtn).toHaveAttribute('aria-pressed', 'true'); + const lineBtn = screen.getByRole('button', { name: /line chart/i }); + expect(lineBtn).toHaveAttribute('aria-pressed', 'false'); + }); + + it('calls onChartTypeChange when a chart type button is clicked', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /bar chart/i })); + expect(baseProps.onChartTypeChange).toHaveBeenCalledWith('bar'); + }); + + it('does not show drill-down panel when drillDownIndex is null', () => { + render(); + expect(screen.queryByText(/all data/i)).not.toBeInTheDocument(); + }); + + it('shows drill-down panel and breadcrumb when drillDownIndex is set', () => { + render(); + expect(screen.getByText(/all data/i)).toBeInTheDocument(); + expect(screen.getByText('Feb')).toBeInTheDocument(); // label at index 1 + }); + + it('calls onDrillDown when a data point is clicked', () => { + render(); + fireEvent.click(screen.getByTestId('mock-data-point')); + expect(baseProps.onDrillDown).toHaveBeenCalledWith(2); + }); + + it('calls onClearDrillDown when back button is clicked', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /back to overview/i })); + expect(baseProps.onClearDrillDown).toHaveBeenCalledTimes(1); + }); + + it('calls onClearDrillDown when breadcrumb "All Data" link is clicked', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /back to all data/i })); + expect(baseProps.onClearDrillDown).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/dashboard/__tests__/RealTimeUpdater.test.tsx b/src/components/dashboard/__tests__/RealTimeUpdater.test.tsx new file mode 100644 index 00000000..a0d5f7be --- /dev/null +++ b/src/components/dashboard/__tests__/RealTimeUpdater.test.tsx @@ -0,0 +1,103 @@ +// @vitest-environment jsdom +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { RealTimeUpdater } from '../RealTimeUpdater'; + +// ─── Mocks ──────────────────────────────────────────────────────────────────── + +global.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} +}; + +vi.mock('@/components/visualization/InteractiveChartLibrary', () => ({ + InteractiveChartLibrary: ({ title }: { title?: string }) => ( +
{title}
+ ), +})); + +// Mock socket.io-client to prevent actual connections +vi.mock('socket.io-client', () => ({ + io: vi.fn(() => ({ + on: vi.fn(), + disconnect: vi.fn(), + emit: vi.fn(), + })), +})); + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('RealTimeUpdater', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it('renders without crashing', () => { + render(); + // Status bar should be present + expect(screen.getByText(/simulating/i)).toBeInTheDocument(); + }); + + it('shows "Simulating" status by default (no websocketUrl)', () => { + render(); + expect(screen.getByText(/simulating/i)).toBeInTheDocument(); + }); + + it('shows empty state initially', () => { + render(); + expect(screen.getByText(/waiting for data/i)).toBeInTheDocument(); + }); + + it('renders Pause button initially', () => { + render(); + expect(screen.getByRole('button', { name: /pause live updates/i })).toBeInTheDocument(); + }); + + it('switches to Play button and shows Paused status when paused', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /pause live updates/i })); + expect(screen.getByRole('button', { name: /resume live updates/i })).toBeInTheDocument(); + expect(screen.getByText(/paused/i)).toBeInTheDocument(); + }); + + it('resumes when Play button clicked after pause', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /pause live updates/i })); + fireEvent.click(screen.getByRole('button', { name: /resume live updates/i })); + expect(screen.getByRole('button', { name: /pause live updates/i })).toBeInTheDocument(); + expect(screen.getByText(/simulating/i)).toBeInTheDocument(); + }); + + it('renders speed selector', () => { + render(); + expect(screen.getByLabelText(/update speed/i)).toBeInTheDocument(); + }); + + it('renders reset button', () => { + render(); + expect(screen.getByRole('button', { name: /reset data/i })).toBeInTheDocument(); + }); + + it('streams data points after timer fires', async () => { + render(); + // Initially no data points shown + expect(screen.queryByText(/pts/i)).not.toBeInTheDocument(); + + // Advance timer past one interval + await act(async () => { + vi.advanceTimersByTime(1100); + }); + + // After data comes in, the chart replaces the empty state + // (we may see the mock-chart or still be in empty state depending on timing) + // Just verify no errors were thrown; component is still mounted + expect(screen.getByText(/simulating/i)).toBeInTheDocument(); + }); +}); diff --git a/src/hooks/__tests__/useDashboardData.test.tsx b/src/hooks/__tests__/useDashboardData.test.tsx new file mode 100644 index 00000000..75f83d00 --- /dev/null +++ b/src/hooks/__tests__/useDashboardData.test.tsx @@ -0,0 +1,214 @@ +// @vitest-environment jsdom +import React, { useEffect } from 'react'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { createRoot } from 'react-dom/client'; +import { act } from 'react-dom/test-utils'; +import { useDashboardData } from '../useDashboardData'; +import type { UseDashboardDataReturn } from '../useDashboardData'; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +const TestHarness: React.FC<{ onReady: (api: UseDashboardDataReturn) => void }> = ({ onReady }) => { + const api = useDashboardData(); + useEffect(() => { + onReady(api); + }); + return null; +}; + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('useDashboardData', () => { + let container: HTMLDivElement; + let root: ReturnType; + + beforeEach(() => { + // Stub window.location.search to empty for deterministic URL parsing + Object.defineProperty(window, 'location', { + writable: true, + value: { ...window.location, search: '', origin: 'http://localhost', pathname: '/dashboard' }, + }); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + }); + + it('initializes with 4 default panels', async () => { + let api: UseDashboardDataReturn | undefined; + await act(async () => { + root.render( + { + api = a; + }} + />, + ); + }); + expect(api!.panels.length).toBe(4); + expect(api!.panels.map((p) => p.id)).toContain('enrollments'); + expect(api!.panels.map((p) => p.id)).toContain('revenue'); + expect(api!.panels.map((p) => p.id)).toContain('completions'); + expect(api!.panels.map((p) => p.id)).toContain('realtime'); + }); + + it('initializes with default filters', async () => { + let api: UseDashboardDataReturn | undefined; + await act(async () => { + root.render( + { + api = a; + }} + />, + ); + }); + expect(api!.filters.timeRange).toBe('30d'); + expect(api!.filters.metric).toBe('enrollments'); + expect(api!.filters.aggregation).toBe('sum'); + expect(api!.filters.categories).toEqual([]); + }); + + it('setFilters updates filter state', async () => { + let api: UseDashboardDataReturn | undefined; + await act(async () => { + root.render( + { + api = a; + }} + />, + ); + }); + await act(async () => { + api!.setFilters({ timeRange: '7d', metric: 'revenue' }); + }); + expect(api!.filters.timeRange).toBe('7d'); + expect(api!.filters.metric).toBe('revenue'); + }); + + it('resetFilters restores defaults and clears shareURL', async () => { + let api: UseDashboardDataReturn | undefined; + await act(async () => { + root.render( + { + api = a; + }} + />, + ); + }); + await act(async () => { + api!.setFilters({ timeRange: '1y', metric: 'views' }); + }); + await act(async () => { + api!.resetFilters(); + }); + expect(api!.filters.timeRange).toBe('30d'); + expect(api!.filters.metric).toBe('enrollments'); + expect(api!.shareURL).toBeNull(); + }); + + it('setPanelChartType updates the correct panel', async () => { + let api: UseDashboardDataReturn | undefined; + await act(async () => { + root.render( + { + api = a; + }} + />, + ); + }); + await act(async () => { + api!.setPanelChartType('enrollments', 'bar'); + }); + const panel = api!.panels.find((p) => p.id === 'enrollments'); + expect(panel?.chartType).toBe('bar'); + }); + + it('drillDown sets drillDownIndex on the correct panel', async () => { + let api: UseDashboardDataReturn | undefined; + await act(async () => { + root.render( + { + api = a; + }} + />, + ); + }); + await act(async () => { + api!.drillDown('revenue', 3); + }); + const panel = api!.panels.find((p) => p.id === 'revenue'); + expect(panel?.drillDownIndex).toBe(3); + }); + + it('clearDrillDown resets drillDownIndex to null', async () => { + let api: UseDashboardDataReturn | undefined; + await act(async () => { + root.render( + { + api = a; + }} + />, + ); + }); + await act(async () => { + api!.drillDown('completions', 5); + }); + await act(async () => { + api!.clearDrillDown('completions'); + }); + const panel = api!.panels.find((p) => p.id === 'completions'); + expect(panel?.drillDownIndex).toBeNull(); + }); + + it('reorderPanels swaps panel positions', async () => { + let api: UseDashboardDataReturn | undefined; + await act(async () => { + root.render( + { + api = a; + }} + />, + ); + }); + const firstId = api!.panels[0].id; + const secondId = api!.panels[1].id; + await act(async () => { + api!.reorderPanels(0, 1); + }); + expect(api!.panels[0].id).toBe(secondId); + expect(api!.panels[1].id).toBe(firstId); + }); + + it('generateShareURL returns a URL string', async () => { + let api: UseDashboardDataReturn | undefined; + await act(async () => { + root.render( + { + api = a; + }} + />, + ); + }); + let url = ''; + await act(async () => { + url = api!.generateShareURL(); + }); + expect(typeof url).toBe('string'); + expect(url).toContain('timeRange'); + expect(url).toContain('metric'); + }); +}); diff --git a/src/hooks/useDashboardData.tsx b/src/hooks/useDashboardData.tsx new file mode 100644 index 00000000..da4928eb --- /dev/null +++ b/src/hooks/useDashboardData.tsx @@ -0,0 +1,207 @@ +/** + * useDashboardData Hook + * Central state manager for the Advanced Data Visualization Dashboard. + * Manages panels, filters, drag-and-drop ordering, drill-down, and sharing. + */ + +import { useState, useCallback } from 'react'; +import { + ChartType, + TimeRange, + AggregationType, + exportToCSV, + exportToJSON, +} from '@/utils/visualizationUtils'; +import { + generateDashboardSampleData, + generateShareableURL, + parseDashboardURL, + getDrillDownData, + DashboardShareConfig, +} from '@/utils/chartUtils'; +import type { ChartData } from '@/utils/visualizationUtils'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface DashboardFiltersState { + timeRange: TimeRange; + categories: string[]; + metric: string; + aggregation: AggregationType; +} + +export interface DashboardPanel { + id: string; + title: string; + chartType: ChartType; + data: ChartData; + drillDownIndex: number | null; + position: number; +} + +export interface UseDashboardDataReturn { + panels: DashboardPanel[]; + filters: DashboardFiltersState; + shareURL: string | null; + setFilters: (filters: Partial) => void; + resetFilters: () => void; + setPanelChartType: (id: string, chartType: ChartType) => void; + drillDown: (id: string, index: number) => void; + clearDrillDown: (id: string) => void; + reorderPanels: (fromIndex: number, toIndex: number) => void; + generateShareURL: () => string; + exportPanel: (id: string, format: 'csv' | 'json') => void; +} + +// ─── Defaults ───────────────────────────────────────────────────────────────── + +const DEFAULT_FILTERS: DashboardFiltersState = { + timeRange: '30d', + categories: [], + metric: 'enrollments', + aggregation: 'sum', +}; + +const buildDefaultPanels = (timeRange: TimeRange): DashboardPanel[] => [ + { + id: 'enrollments', + title: 'Course Enrollments', + chartType: 'line', + data: generateDashboardSampleData('enrollments', timeRange), + drillDownIndex: null, + position: 0, + }, + { + id: 'revenue', + title: 'Revenue', + chartType: 'bar', + data: generateDashboardSampleData('revenue', timeRange), + drillDownIndex: null, + position: 1, + }, + { + id: 'completions', + title: 'Completions', + chartType: 'area', + data: generateDashboardSampleData('completions', timeRange), + drillDownIndex: null, + position: 2, + }, + { + id: 'realtime', + title: 'Live Activity', + chartType: 'line', + data: { labels: [], datasets: [] }, + drillDownIndex: null, + position: 3, + }, +]; + +// ─── Hook ───────────────────────────────────────────────────────────────────── + +export const useDashboardData = (): UseDashboardDataReturn => { + const [filters, setFiltersState] = useState(() => { + if (typeof window !== 'undefined') { + const parsed = parseDashboardURL(window.location.search); + return { ...DEFAULT_FILTERS, ...parsed }; + } + return DEFAULT_FILTERS; + }); + + const [panels, setPanels] = useState(() => + buildDefaultPanels(filters.timeRange), + ); + + const [shareURL, setShareURL] = useState(null); + + // Update filters and regenerate data for non-realtime panels + const setFilters = useCallback((partial: Partial) => { + setFiltersState((prev) => { + const next = { ...prev, ...partial }; + // Regenerate data if time range changed + if (partial.timeRange && partial.timeRange !== prev.timeRange) { + setPanels((prevPanels) => + prevPanels.map((panel) => + panel.id === 'realtime' + ? panel + : { + ...panel, + data: generateDashboardSampleData(panel.id, next.timeRange), + drillDownIndex: null, + }, + ), + ); + } + return next; + }); + }, []); + + const resetFilters = useCallback(() => { + setFiltersState(DEFAULT_FILTERS); + setPanels(buildDefaultPanels(DEFAULT_FILTERS.timeRange)); + setShareURL(null); + }, []); + + const setPanelChartType = useCallback((id: string, chartType: ChartType) => { + setPanels((prev) => prev.map((p) => (p.id === id ? { ...p, chartType } : p))); + }, []); + + const drillDown = useCallback((id: string, index: number) => { + setPanels((prev) => prev.map((p) => (p.id === id ? { ...p, drillDownIndex: index } : p))); + }, []); + + const clearDrillDown = useCallback((id: string) => { + setPanels((prev) => prev.map((p) => (p.id === id ? { ...p, drillDownIndex: null } : p))); + }, []); + + const reorderPanels = useCallback((fromIndex: number, toIndex: number) => { + setPanels((prev) => { + const sorted = [...prev].sort((a, b) => a.position - b.position); + const [moved] = sorted.splice(fromIndex, 1); + sorted.splice(toIndex, 0, moved); + return sorted.map((p, i) => ({ ...p, position: i })); + }); + }, []); + + const generateShareURL = useCallback((): string => { + const sortedPanels = [...panels].sort((a, b) => a.position - b.position); + const config: DashboardShareConfig = { + timeRange: filters.timeRange, + categories: filters.categories, + metric: filters.metric, + aggregation: filters.aggregation, + panelOrder: sortedPanels.map((p) => p.id), + }; + const url = generateShareableURL(config); + setShareURL(url); + return url; + }, [panels, filters]); + + const exportPanel = useCallback( + (id: string, format: 'csv' | 'json') => { + const panel = panels.find((p) => p.id === id); + if (!panel) return; + const filename = `${panel.title.replace(/\s+/g, '-').toLowerCase()}-${Date.now()}`; + if (format === 'csv') { + exportToCSV(panel.data, filename); + } else { + exportToJSON(panel.data, filename); + } + }, + [panels], + ); + + return { + panels: [...panels].sort((a, b) => a.position - b.position), + filters, + shareURL, + setFilters, + resetFilters, + setPanelChartType, + drillDown, + clearDrillDown, + reorderPanels, + generateShareURL, + exportPanel, + }; +}; diff --git a/src/utils/chartUtils.ts b/src/utils/chartUtils.ts new file mode 100644 index 00000000..0f3d5744 --- /dev/null +++ b/src/utils/chartUtils.ts @@ -0,0 +1,186 @@ +/** + * chartUtils.ts + * Dashboard-specific utility helpers for the Advanced Data Visualization Dashboard. + * Builds on top of visualizationUtils.ts for shared types and base helpers. + */ + +import { + ChartData, + TimeRange, + AggregationType, + generateDateLabels, + generateSampleData, +} from '@/utils/visualizationUtils'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export type DashboardMetricType = 'currency' | 'percent' | 'count'; + +export interface DashboardShareConfig { + timeRange: TimeRange; + categories: string[]; + metric: string; + aggregation: AggregationType; + panelOrder: string[]; +} + +// ─── Sample data generators ─────────────────────────────────────────────────── + +const PANEL_DATA_CONFIG: Record< + string, + { label: string; color: string; bgColor: string; min: number; max: number } +> = { + enrollments: { + label: 'Course Enrollments', + color: '#3b82f6', + bgColor: 'rgba(59,130,246,0.15)', + min: 20, + max: 120, + }, + revenue: { + label: 'Revenue ($)', + color: '#10b981', + bgColor: 'rgba(16,185,129,0.15)', + min: 500, + max: 4000, + }, + completions: { + label: 'Completions', + color: '#8b5cf6', + bgColor: 'rgba(139,92,246,0.15)', + min: 10, + max: 90, + }, + views: { + label: 'Course Views', + color: '#f59e0b', + bgColor: 'rgba(245,158,11,0.15)', + min: 100, + max: 800, + }, +}; + +/** + * Generate deterministic-looking sample data for a given panel ID. + * Falls back to generic random data for unknown panel IDs. + */ +export const generateDashboardSampleData = ( + panelId: string, + timeRange: TimeRange = '30d', +): ChartData => { + const cfg = PANEL_DATA_CONFIG[panelId] ?? { + label: 'Metric', + color: '#6366f1', + bgColor: 'rgba(99,102,241,0.15)', + min: 0, + max: 100, + }; + + const labels = generateDateLabels(timeRange); + const data = generateSampleData(labels.length, cfg.min, cfg.max); + + return { + labels, + datasets: [ + { + label: cfg.label, + data, + borderColor: cfg.color, + backgroundColor: cfg.bgColor, + borderWidth: 2, + }, + ], + }; +}; + +// ─── Formatting ─────────────────────────────────────────────────────────────── + +/** + * Format a numeric value for display on dashboard KPI cards. + */ +export const formatDashboardMetric = ( + value: number, + type: DashboardMetricType = 'count', +): string => { + switch (type) { + case 'currency': + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + maximumFractionDigits: 0, + }).format(value); + case 'percent': + return `${value.toFixed(1)}%`; + case 'count': + default: + if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; + if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`; + return String(Math.round(value)); + } +}; + +// ─── Share URL helpers ──────────────────────────────────────────────────────── + +/** + * Encode dashboard configuration into a URL search string. + * Example output: ?timeRange=30d&metric=enrollments&categories=Web,React&panelOrder=enrollments,revenue + */ +export const generateShareableURL = (config: DashboardShareConfig): string => { + if (typeof window === 'undefined') return ''; + + const params = new URLSearchParams(); + params.set('timeRange', config.timeRange); + params.set('metric', config.metric); + params.set('aggregation', config.aggregation); + if (config.categories.length > 0) { + params.set('categories', config.categories.join(',')); + } + if (config.panelOrder.length > 0) { + params.set('panelOrder', config.panelOrder.join(',')); + } + + const { origin, pathname } = window.location; + return `${origin}${pathname}?${params.toString()}`; +}; + +/** + * Parse a URL search string back into a partial DashboardShareConfig. + */ +export const parseDashboardURL = (search: string): Partial => { + const params = new URLSearchParams(search); + const result: Partial = {}; + + const timeRange = params.get('timeRange'); + if (timeRange) result.timeRange = timeRange as TimeRange; + + const metric = params.get('metric'); + if (metric) result.metric = metric; + + const aggregation = params.get('aggregation'); + if (aggregation) result.aggregation = aggregation as AggregationType; + + const categories = params.get('categories'); + if (categories) result.categories = categories.split(',').filter(Boolean); + + const panelOrder = params.get('panelOrder'); + if (panelOrder) result.panelOrder = panelOrder.split(',').filter(Boolean); + + return result; +}; + +// ─── Drill-down helpers ─────────────────────────────────────────────────────── + +/** + * Return a new ChartData that contains only the single data point at `labelIndex`. + * Used to render the drill-down detail panel in InteractiveCharts. + */ +export const getDrillDownData = (data: ChartData, labelIndex: number): ChartData => { + const label = data.labels[labelIndex] ?? 'Selected'; + return { + labels: [label], + datasets: data.datasets.map((ds) => ({ + ...ds, + data: [ds.data[labelIndex] ?? 0], + })), + }; +};