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: {error}
+No portfolio data available
+