From d851f0a4ccb89c71668e0ac7b6684550e9932405 Mon Sep 17 00:00:00 2001 From: Gogo-Eng <“progressgogochinda@gmail.com”> Date: Mon, 1 Jun 2026 00:18:02 +0100 Subject: [PATCH] feat(frontend): add Portfolio Chart Widget component and unit tests --- .../PortfolioChart/PortfolioChart.css | 138 ++++++++ .../PortfolioChart/PortfolioChart.jsx | 294 ++++++++++++++++++ .../PortfolioChart/PortfolioChart.test.jsx | 109 +++++++ .../src/components/PortfolioChart/index.js | 1 + 4 files changed, 542 insertions(+) create mode 100644 frontend/src/components/PortfolioChart/PortfolioChart.css create mode 100644 frontend/src/components/PortfolioChart/PortfolioChart.jsx create mode 100644 frontend/src/components/PortfolioChart/PortfolioChart.test.jsx create mode 100644 frontend/src/components/PortfolioChart/index.js diff --git a/frontend/src/components/PortfolioChart/PortfolioChart.css b/frontend/src/components/PortfolioChart/PortfolioChart.css new file mode 100644 index 0000000..7cbcb58 --- /dev/null +++ b/frontend/src/components/PortfolioChart/PortfolioChart.css @@ -0,0 +1,138 @@ +.portfolio-chart { + position: relative; + padding: 20px; + background: #ffffff; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; +} + +.portfolio-chart.mobile-view { + padding: 10px; +} + +.chart-title { + margin: 0 0 20px 0; + font-size: 1.25rem; + font-weight: 600; + color: #333; +} + +.range-picker { + display: flex; + gap: 10px; + margin-bottom: 20px; + justify-content: flex-end; +} + +.range-picker button { + padding: 5px 12px; + background: #f5f5f5; + border: 1px solid #ddd; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s; +} + +.range-picker button.active { + background: #4CAF50; + color: white; + border-color: #4CAF50; +} + +.chart-container { + position: relative; + margin-bottom: 20px; +} + +.chart-container svg { + display: block; + margin: 0 auto; +} + +.chart-legend { + display: flex; + justify-content: center; + gap: 20px; + margin-top: 20px; +} + +.legend-item { + display: flex; + align-items: center; + gap: 8px; +} + +.legend-color { + width: 12px; + height: 12px; + background: #4CAF50; + border-radius: 2px; +} + +.chart-tooltip { + position: absolute; + background: rgba(0, 0, 0, 0.8); + color: white; + padding: 8px 12px; + border-radius: 4px; + font-size: 12px; + pointer-events: none; + z-index: 1000; + white-space: nowrap; +} + +.loading-spinner { + width: 40px; + height: 40px; + margin: 0 auto 10px; + border: 3px solid #f3f3f3; + border-top: 3px solid #4CAF50; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +.portfolio-chart.loading, +.portfolio-chart.error, +.portfolio-chart.empty { + text-align: center; + padding: 60px 20px; + color: #666; +} + +.portfolio-chart.error { + color: #f44336; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; +} + +/* Mobile responsive */ +@media (max-width: 768px) { + .portfolio-chart { + padding: 10px; + } + + .range-picker button { + padding: 4px 8px; + font-size: 12px; + } + + .chart-tooltip { + font-size: 10px; + padding: 4px 8px; + } +} \ No newline at end of file diff --git a/frontend/src/components/PortfolioChart/PortfolioChart.jsx b/frontend/src/components/PortfolioChart/PortfolioChart.jsx new file mode 100644 index 0000000..73d9d9e --- /dev/null +++ b/frontend/src/components/PortfolioChart/PortfolioChart.jsx @@ -0,0 +1,294 @@ +import React, { useState, useEffect, useMemo, useCallback } from 'react'; +import './PortfolioChart.css'; + +// Note: This is a simplified chart component +// In a real implementation, you might use Recharts, Chart.js, or D3 +// For testing purposes, we'll create a basic SVG-based chart + +const PortfolioChart = ({ + data = [], + title = 'Portfolio Performance', + loading = false, + error = null, + currency = 'USD', + interactive = true, + showLegend = true, + onDataPointClick = null, + onRangeChange = null, + ariaLabel = 'Portfolio performance chart', + height = 400, + width = '100%', + responsive = true, + keyboardNavigation = true, + announcementRegion = true, + dateRangePicker = false +}) => { + const [activePoint, setActivePoint] = useState(null); + const [selectedRange, setSelectedRange] = useState('1Y'); + const [isMobile, setIsMobile] = useState(false); + + // Check for mobile viewport + useEffect(() => { + if (!responsive) return; + + const checkMobile = () => { + setIsMobile(window.innerWidth < 768); + }; + + checkMobile(); + window.addEventListener('resize', checkMobile); + return () => window.removeEventListener('resize', checkMobile); + }, [responsive]); + + // Format currency values + const formatCurrency = useCallback((value) => { + if (currency === 'USD') { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 0, + maximumFractionDigits: 0 + }).format(value); + } + return `$${value.toLocaleString()}`; + }, [currency]); + + // Find min and max values for scaling + const { minValue, maxValue } = useMemo(() => { + if (!data || data.length === 0) return { minValue: 0, maxValue: 100 }; + const values = data.map(d => d.value); + return { + minValue: Math.min(...values, 0), + maxValue: Math.max(...values, 100) + }; + }, [data]); + + // Calculate chart points for SVG rendering + const chartPoints = useMemo(() => { + if (!data || data.length === 0) return []; + + const width = isMobile ? 300 : 600; + const height_val = isMobile ? 200 : 300; + const padding = 40; + const chartWidth = width - 2 * padding; + const chartHeight = height_val - 2 * padding; + + return data.map((point, index) => { + const x = padding + (index / (data.length - 1)) * chartWidth; + const y = padding + chartHeight - + ((point.value - minValue) / (maxValue - minValue)) * chartHeight; + return { x, y, value: point.value, date: point.date, original: point }; + }); + }, [data, minValue, maxValue, isMobile]); + + // Handle keyboard navigation + const handleKeyDown = useCallback((e) => { + if (!keyboardNavigation || !interactive) return; + + if (e.key === 'ArrowRight' && activePoint !== null && activePoint < chartPoints.length - 1) { + setActivePoint(activePoint + 1); + if (announcementRegion) { + const announcement = `Data point ${activePoint + 2}: ${formatCurrency(chartPoints[activePoint + 1].value)}`; + const liveRegion = document.getElementById('sr-announcements'); + if (liveRegion) liveRegion.textContent = announcement; + } + } else if (e.key === 'ArrowLeft' && activePoint !== null && activePoint > 0) { + setActivePoint(activePoint - 1); + if (announcementRegion) { + const announcement = `Data point ${activePoint}: ${formatCurrency(chartPoints[activePoint - 1].value)}`; + const liveRegion = document.getElementById('sr-announcements'); + if (liveRegion) liveRegion.textContent = announcement; + } + } else if (e.key === 'ArrowRight' && activePoint === null && chartPoints.length > 0) { + setActivePoint(0); + } + }, [keyboardNavigation, interactive, activePoint, chartPoints, formatCurrency, announcementRegion]); + + // Handle range selection + const handleRangeChange = useCallback((range) => { + setSelectedRange(range); + if (onRangeChange) { + onRangeChange(range); + } + }, [onRangeChange]); + + // Loading state + if (loading) { + return ( +
+
+

Loading chart data...

+
+ ); + } + + // Error state + if (error) { + return ( +
+

Error: {error}

+
+ ); + } + + // Empty state + if (!data || data.length === 0) { + return ( +
+

No portfolio data available

+
+ ); + } + + return ( +
+ {title &&

{title}

} + + {/* Date Range Picker */} + {dateRangePicker && ( +
+ + + + +
+ )} + + {/* Chart Container */} +
+ + {/* Grid lines */} + + {[0, 25, 50, 75, 100].map((percent, i) => ( + + ))} + + + {/* Chart line */} + `${p.x},${p.y}`).join(' ')} + fill="none" + stroke="#4CAF50" + strokeWidth="3" + /> + + {/* Data points */} + {chartPoints.map((point, index) => ( + interactive && setActivePoint(index)} + onMouseLeave={() => interactive && setActivePoint(null)} + onClick={() => onDataPointClick && onDataPointClick(point.original)} + /> + ))} + + {/* Labels */} + + {chartPoints.map((point, index) => ( + + {point.date} + + ))} + + +
+ + {/* Legend */} + {showLegend && ( +
+
+ + Portfolio Value +
+
+ )} + + {/* Screen reader announcements */} + {announcementRegion && ( +
+ )} + + {/* Tooltip for active point */} + {activePoint !== null && chartPoints[activePoint] && interactive && ( +
+ {chartPoints[activePoint].date} +
+ {formatCurrency(chartPoints[activePoint].value)} +
+ )} +
+ ); +}; + +export default PortfolioChart; \ No newline at end of file diff --git a/frontend/src/components/PortfolioChart/PortfolioChart.test.jsx b/frontend/src/components/PortfolioChart/PortfolioChart.test.jsx new file mode 100644 index 0000000..e902386 --- /dev/null +++ b/frontend/src/components/PortfolioChart/PortfolioChart.test.jsx @@ -0,0 +1,109 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { axe, toHaveNoViolations } from 'jest-axe'; +import PortfolioChart from './PortfolioChart'; + +expect.extend(toHaveNoViolations); + +// Mock data +const mockData = [ + { date: 'Jan', value: 1000 }, + { date: 'Feb', value: 1500 }, + { date: 'Mar', value: 1200 }, + { date: 'Apr', value: 1800 }, +]; + +describe('PortfolioChart Widget', () => { + describe('Rendering Tests', () => { + test('renders without crashing', () => { + render(); + expect(screen.getByTestId('portfolio-chart')).toBeInTheDocument(); + }); + + test('shows loading state when loading prop is true', () => { + render(); + expect(screen.getByRole('status')).toBeInTheDocument(); + expect(screen.getByText(/loading chart data/i)).toBeInTheDocument(); + }); + + test('shows error message when error occurs', () => { + const errorMessage = 'Failed to load portfolio data'; + render(); + expect(screen.getByText(`Error: ${errorMessage}`)).toBeInTheDocument(); + expect(screen.getByRole('alert')).toBeInTheDocument(); + }); + + test('shows empty state when no data is provided', () => { + render(); + expect(screen.getByText(/no portfolio data available/i)).toBeInTheDocument(); + }); + + test('displays title when title prop is provided', () => { + const title = 'Portfolio Performance 2024'; + render(); + expect(screen.getByText(title)).toBeInTheDocument(); + }); + }); + + describe('Interactive Behavior Tests', () => { + test('tooltip shows on hover', async () => { + render(); + const dataPoint = screen.getByTestId('data-point-0'); + + fireEvent.mouseEnter(dataPoint); + + await waitFor(() => { + const tooltip = screen.getByTestId('chart-tooltip'); + expect(tooltip).toBeInTheDocument(); + }); + }); + + test('click handler works when provided', async () => { + const onDataPointClick = jest.fn(); + render(); + + const dataPoint = screen.getByTestId('data-point-0'); + await userEvent.click(dataPoint); + + expect(onDataPointClick).toHaveBeenCalledTimes(1); + expect(onDataPointClick).toHaveBeenCalledWith(expect.objectContaining({ + value: 1000, + date: 'Jan' + })); + }); + + test('range picker buttons work', async () => { + const onRangeChange = jest.fn(); + render(); + + const rangeButton = screen.getByText('6M'); + await userEvent.click(rangeButton); + + expect(onRangeChange).toHaveBeenCalledWith('6M'); + }); + }); + + describe('Accessibility Tests', () => { + test('has no accessibility violations', async () => { + const { container } = render(); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + test('provides ARIA labels', () => { + render(); + expect(screen.getByRole('figure')).toBeInTheDocument(); + }); + }); + + describe('Responsive Behavior Tests', () => { + test('adds mobile-view class on small screens', () => { + window.innerWidth = 375; + window.dispatchEvent(new Event('resize')); + + render(); + expect(screen.getByTestId('portfolio-chart')).toHaveClass('mobile-view'); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/PortfolioChart/index.js b/frontend/src/components/PortfolioChart/index.js new file mode 100644 index 0000000..1208c45 --- /dev/null +++ b/frontend/src/components/PortfolioChart/index.js @@ -0,0 +1 @@ +export { default } from './PortfolioChart'; \ No newline at end of file