From cd26ac59c4afccffd6ed5ef9e2e32586f43b90f8 Mon Sep 17 00:00:00 2001 From: Lidizz Date: Fri, 27 Feb 2026 22:32:26 +0100 Subject: [PATCH 1/4] 13a: Add count-up animation on metric cards New hook: useCountUp(target, duration) animates numbers from 0 to target over 800ms using requestAnimationFrame with ease-out cubic easing. Respects prefers-reduced-motion for accessibility. Applied to all 5 metric cards in SimulationResults: Total Invested, Current Value, Absolute Gain, Percent Return, and CAGR. Holdings table values remain static (no animation) for readability. Test setup: added matchMedia mock defaulting to prefers-reduced-motion so useCountUp resolves immediately in jsdom all 90 tests pass. --- frontend/src/components/SimulationResults.tsx | 18 +++++-- frontend/src/hooks/useCountUp.ts | 52 +++++++++++++++++++ frontend/src/test/setup.ts | 16 ++++++ 3 files changed, 81 insertions(+), 5 deletions(-) create mode 100644 frontend/src/hooks/useCountUp.ts diff --git a/frontend/src/components/SimulationResults.tsx b/frontend/src/components/SimulationResults.tsx index d52cf6e..5b3c3de 100644 --- a/frontend/src/components/SimulationResults.tsx +++ b/frontend/src/components/SimulationResults.tsx @@ -1,5 +1,6 @@ import { PortfolioChart } from './PortfolioChart'; import { PortfolioHeader } from './PortfolioHeader'; +import { useCountUp } from '../hooks/useCountUp'; import type { SimulationResponse, InvestmentItem } from '../types/api.types'; import './SimulationResults.css'; @@ -45,6 +46,13 @@ export function SimulationResults({ results, investments }: SimulationResultsPro }).format(value); }; + // Animate metric values from 0 to target on render + const animatedTotalInvested = useCountUp(results.totalInvested); + const animatedCurrentValue = useCountUp(results.currentValue); + const animatedAbsoluteGain = useCountUp(results.absoluteGain); + const animatedPercentReturn = useCountUp(results.percentReturn); + const animatedCagr = useCountUp(results.cagr); + const isProfit = results.absoluteGain >= 0; return ( @@ -53,34 +61,34 @@ export function SimulationResults({ results, investments }: SimulationResultsPro
Total Invested
-
{formatCurrency(results.totalInvested)}
+
{formatCurrency(animatedTotalInvested)}
Current Value
- {formatCurrency(results.currentValue)} + {formatCurrency(animatedCurrentValue)}
Absolute Gain
- {formatCurrency(results.absoluteGain)} + {formatCurrency(animatedAbsoluteGain)}
Percent Return
- {formatPercent(results.percentReturn)} + {formatPercent(animatedPercentReturn)}
CAGR
- {formatPercent(results.cagr)} + {formatPercent(animatedCagr)}
Compound Annual Growth Rate
diff --git a/frontend/src/hooks/useCountUp.ts b/frontend/src/hooks/useCountUp.ts new file mode 100644 index 0000000..7817f9c --- /dev/null +++ b/frontend/src/hooks/useCountUp.ts @@ -0,0 +1,52 @@ +import { useState, useEffect } from 'react'; + +/** + * Animates a number from 0 to the target value over a duration. + * + * Uses requestAnimationFrame with ease-out cubic easing for a natural + * deceleration feel. Respects `prefers-reduced-motion` for accessibility + * — skips animation and returns the target value immediately. + * + * @param target The final number to animate towards. + * @param duration Animation duration in milliseconds (default 800ms). + * @returns The current animated value (use with formatCurrency/formatPercent). + */ +export function useCountUp(target: number, duration = 800): number { + const [current, setCurrent] = useState(0); + + useEffect(() => { + // Accessibility: skip animation when user prefers reduced motion + const prefersReducedMotion = + typeof window !== 'undefined' && + window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; + + if (prefersReducedMotion || duration <= 0) { + setCurrent(target); + return; + } + + let startTime: number | null = null; + let rafId: number; + + const animate = (timestamp: number) => { + if (startTime === null) startTime = timestamp; + const elapsed = timestamp - startTime; + const progress = Math.min(elapsed / duration, 1); + + // Ease-out cubic — fast start, gentle landing + const eased = 1 - Math.pow(1 - progress, 3); + + setCurrent(target * eased); + + if (progress < 1) { + rafId = requestAnimationFrame(animate); + } + }; + + rafId = requestAnimationFrame(animate); + + return () => cancelAnimationFrame(rafId); + }, [target, duration]); + + return current; +} diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts index f65ae47..eee25d0 100644 --- a/frontend/src/test/setup.ts +++ b/frontend/src/test/setup.ts @@ -6,6 +6,22 @@ import * as matchers from '@testing-library/jest-dom/matchers'; // Extend Vitest's expect with jest-dom matchers expect.extend(matchers); +// Mock matchMedia for jsdom — default to prefers-reduced-motion so +// animations (e.g. useCountUp) resolve immediately in tests. +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: (query: string) => ({ + matches: query === '(prefers-reduced-motion: reduce)', + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }), +}); + // Cleanup after each test afterEach(() => { cleanup(); From 22430f08d22343e32a621489693b5cf12d677222 Mon Sep 17 00:00:00 2001 From: Lidizz Date: Fri, 27 Feb 2026 22:35:27 +0100 Subject: [PATCH 2/4] 13b: Fix individual-view tooltip and add chart watermark Tooltip fix: in individual view, the crosshair now shows ALL holding values at the cursor position with colored dots matching the legend, instead of only showing the first series value. Tooltip redesign: entries display as symbol + value rows with color indicators, separated from the date by a subtle border. Works for both combined (single 'Portfolio' entry) and individual (per-holding). Watermark: added subtle 'Moshimo' text centered in chart area at 4% opacity, pointer-events: none so it doesn't interfere with interaction. --- frontend/src/components/PortfolioChart.css | 48 +++++++++++++- frontend/src/components/PortfolioChart.tsx | 75 +++++++++++++++------- 2 files changed, 97 insertions(+), 26 deletions(-) diff --git a/frontend/src/components/PortfolioChart.css b/frontend/src/components/PortfolioChart.css index d6470d4..9d2c35e 100644 --- a/frontend/src/components/PortfolioChart.css +++ b/frontend/src/components/PortfolioChart.css @@ -124,6 +124,22 @@ position: relative; } +/* Watermark */ +.portfolio-chart__watermark { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: 3rem; + font-weight: 800; + color: var(--text-primary); + opacity: 0.04; + pointer-events: none; + z-index: 1; + letter-spacing: 0.05em; + user-select: none; +} + /* Custom Tooltip */ .portfolio-chart__tooltip { position: absolute; @@ -138,16 +154,42 @@ min-width: 140px; } +.portfolio-chart__tooltip-entry { + display: flex; + align-items: center; + gap: 0.375rem; + margin-bottom: 0.25rem; +} + +.portfolio-chart__tooltip-entry:last-of-type { + margin-bottom: 0.5rem; +} + +.portfolio-chart__tooltip-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.portfolio-chart__tooltip-symbol { + font-size: 0.8125rem; + font-weight: 600; + color: var(--text-secondary); + margin-right: auto; +} + .portfolio-chart__tooltip-value { - font-size: 1rem; + font-size: 0.9375rem; font-weight: 700; - color: var(--accent); - margin-bottom: 0.25rem; + color: var(--text-primary); } .portfolio-chart__tooltip-date { font-size: 0.8125rem; color: var(--text-secondary); + border-top: 1px solid var(--border-color); + padding-top: 0.375rem; } /* Legend for individual holdings view */ diff --git a/frontend/src/components/PortfolioChart.tsx b/frontend/src/components/PortfolioChart.tsx index 98f6c22..77ec804 100644 --- a/frontend/src/components/PortfolioChart.tsx +++ b/frontend/src/components/PortfolioChart.tsx @@ -65,9 +65,15 @@ export function PortfolioChart({ const [currentTheme, setCurrentTheme] = useState<'light' | 'dark'>(() => { return (document.documentElement.getAttribute('data-theme') as 'light' | 'dark') || 'light'; }); - const [tooltipData, setTooltipData] = useState<{visible: boolean; value: string; date: string; x: number; y: number}>({ + const [tooltipData, setTooltipData] = useState<{ + visible: boolean; + entries: Array<{ symbol: string; value: string; color: string }>; + date: string; + x: number; + y: number; + }>({ visible: false, - value: '', + entries: [], date: '', x: 0, y: 0, @@ -263,36 +269,48 @@ export function PortfolioChart({ param.point.x < 0 || param.point.y < 0 ) { - setTooltipData({ visible: false, value: '', date: '', x: 0, y: 0 }); + setTooltipData({ visible: false, entries: [], date: '', x: 0, y: 0 }); return; } const activeSeries = viewMode === 'combined' ? seriesRef.current : null; if (!activeSeries && viewMode === 'combined') { - setTooltipData({ visible: false, value: '', date: '', x: 0, y: 0 }); + setTooltipData({ visible: false, entries: [], date: '', x: 0, y: 0 }); return; } - let price = 0; + const formatUSD = (price: number) => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(price); + + let entries: Array<{ symbol: string; value: string; color: string }> = []; + if (viewMode === 'combined' && activeSeries) { const data = param.seriesData.get(activeSeries); - price = (data as any)?.value ?? 0; + const price = (data as any)?.value ?? 0; + entries = [{ symbol: 'Portfolio', value: formatUSD(price), color: themeColors.accent }]; } else if (viewMode === 'individual') { - // For individual view, show first series value as an example - const firstSeries = holdingSeriesRefs.current.values().next().value; - if (firstSeries) { - const data = param.seriesData.get(firstSeries); - price = (data as any)?.value ?? 0; - } + // Collect values from ALL holding series at the crosshair + const symbols = [...holdingSeriesRefs.current.keys()]; + symbols.forEach((symbol, index) => { + const series = holdingSeriesRefs.current.get(symbol); + if (!series) return; + const data = param.seriesData.get(series); + const price = (data as any)?.value; + if (price != null) { + entries.push({ + symbol, + value: formatUSD(price), + color: holdingColors[index % holdingColors.length], + }); + } + }); } - const formattedValue = new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }).format(price); - const dateStr = new Date(param.time as string).toLocaleDateString('en-US', { year: 'numeric', month: 'short', @@ -300,8 +318,8 @@ export function PortfolioChart({ }); setTooltipData({ - visible: true, - value: formattedValue, + visible: entries.length > 0, + entries, date: dateStr, x: param.point.x, y: param.point.y, @@ -385,7 +403,9 @@ export function PortfolioChart({
Portfolio Value (USD)
-
+
+
Moshimo
+
{/* Custom Tooltip */} {tooltipData.visible && ( @@ -396,7 +416,16 @@ export function PortfolioChart({ top: `${tooltipData.y}px`, }} > -
{tooltipData.value}
+ {tooltipData.entries.map((entry) => ( +
+ + {entry.symbol} + {entry.value} +
+ ))}
{tooltipData.date}
)} From a5c04d4dd9fe040a1b29e96030d789e2b92febcf Mon Sep 17 00:00:00 2001 From: Lidizz Date: Fri, 27 Feb 2026 22:41:31 +0100 Subject: [PATCH 3/4] 13c: Mobile responsive card layout and sticky summary bar Holdings table converts to card layout at 768px breakpoint: - Table headers hidden, each row becomes a bordered card - data-label attributes on elements render as left-aligned labels via CSS ::before pseudo-elements - Asset name spans full width with bottom border separator Sticky summary bar: shows Current Value and Return % at the top of the viewport when scrolling on mobile (<768px). Hidden on desktop. Additional 640px breakpoint: full-width holdings cards (no border radius, flush to edges) for narrow phone screens. Updated test to scope percentage assertion with within() to avoid duplicate match from the new mobile summary element. --- frontend/src/components/SimulationResults.css | 139 ++++++++++++++++-- frontend/src/components/SimulationResults.tsx | 28 +++- frontend/src/test/SimulationResults.test.tsx | 7 +- 3 files changed, 150 insertions(+), 24 deletions(-) diff --git a/frontend/src/components/SimulationResults.css b/frontend/src/components/SimulationResults.css index 73162db..7561aa3 100644 --- a/frontend/src/components/SimulationResults.css +++ b/frontend/src/components/SimulationResults.css @@ -273,36 +273,131 @@ } } -@media (max-width: 640px) { +/* Mobile sticky summary bar — hidden on desktop */ +.simulation-results__mobile-summary { + display: none; +} + +@media (max-width: 768px) { + /* Sticky summary bar */ + .simulation-results__mobile-summary { + display: flex; + justify-content: space-around; + align-items: center; + position: sticky; + top: 0; + z-index: 50; + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 0.625rem 1rem; + margin-bottom: 1rem; + box-shadow: var(--shadow-sm); + } + + .mobile-summary__item { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.125rem; + } + + .mobile-summary__label { + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-secondary); + } + + .mobile-summary__value { + font-size: 1.125rem; + font-weight: 700; + color: var(--text-primary); + } + + /* Holdings table → card layout */ .holdings-table { padding: 1rem; margin-top: 1.5rem; - margin-left: -1rem; - margin-right: -1rem; - border-radius: 0; - border-left: none; - border-right: none; } .holdings-table__container { - margin: 0 -1rem; - overflow-x: auto; - -webkit-overflow-scrolling: touch; + overflow-x: visible; + } + + .holdings-table__table, + .holdings-table__table thead, + .holdings-table__table tbody, + .holdings-table__table tr, + .holdings-table__table th, + .holdings-table__table td { + display: block; } .holdings-table__table { - font-size: 0.8125rem; - min-width: 500px; + min-width: 0; + } + + .holdings-table__table thead { + display: none; + } + + .holdings-table__table tbody tr { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 0.875rem 1rem; + margin-bottom: 0.75rem; + } + + .holdings-table__table tbody tr:hover { + background: var(--bg-secondary); + } + + .holdings-table__table tbody tr:last-child { + margin-bottom: 0; } - .holdings-table__table th, .holdings-table__table td { - padding: 0.75rem 0.5rem; - white-space: nowrap; + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.25rem 0; + border-bottom: none; + text-align: right; + white-space: normal; + } + + .holdings-table__table td::before { + content: attr(data-label); + font-size: 0.8125rem; + font-weight: 600; + color: var(--text-secondary); + text-align: left; + flex-shrink: 0; + margin-right: 0.75rem; + } + + /* Asset name cell — full width, no data-label */ + .holdings-table__table td:first-child { + margin-bottom: 0.375rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--border-color); + } + + .holdings-table__table td:first-child::before { + display: none; + } + + .holdings-table__asset { + flex-direction: row; + align-items: center; + gap: 0.5rem; } .holdings-table__symbol { - font-size: 0.875rem; + font-size: 1rem; } .metric-card__value { @@ -310,6 +405,20 @@ } } +@media (max-width: 640px) { + .holdings-table { + margin-left: -1rem; + margin-right: -1rem; + border-radius: 0; + border-left: none; + border-right: none; + } + + .metric-card__value { + font-size: 1.375rem; + } +} + /* Data Freshness Disclaimer - moved from top to after results */ .data-disclaimer { display: flex; diff --git a/frontend/src/components/SimulationResults.tsx b/frontend/src/components/SimulationResults.tsx index 5b3c3de..d3b3e44 100644 --- a/frontend/src/components/SimulationResults.tsx +++ b/frontend/src/components/SimulationResults.tsx @@ -94,6 +94,20 @@ export function SimulationResults({ results, investments }: SimulationResultsPro
+ {/* Mobile sticky summary — visible only on small screens */} +
+
+ Value + {formatCurrency(results.currentValue)} +
+
+ Return + + {formatPercent(results.percentReturn)} + +
+
+ {/* Portfolio Header - Shows date range */} {investments && investments.length > 0 && ( {holding.name} - {formatCurrency(holding.invested)} - {formatShares(holding.shares)} - {formatCurrency(holding.purchasePrice)} - {formatCurrency(holding.currentPrice)} - {formatCurrency(holding.currentValue)} - + {formatCurrency(holding.invested)} + {formatShares(holding.shares)} + {formatCurrency(holding.purchasePrice)} + {formatCurrency(holding.currentPrice)} + {formatCurrency(holding.currentValue)} + {formatCurrency(holding.absoluteGain)} - + {formatPercent(holding.percentReturn)} diff --git a/frontend/src/test/SimulationResults.test.tsx b/frontend/src/test/SimulationResults.test.tsx index daf3d53..254bcd5 100644 --- a/frontend/src/test/SimulationResults.test.tsx +++ b/frontend/src/test/SimulationResults.test.tsx @@ -119,10 +119,13 @@ describe('SimulationResults', () => { it('displays formatted percentage values', () => { render(); + const metrics = document.querySelector('.simulation-results__metrics')!; + const metricsEl = within(metrics as HTMLElement); + // Percent Return: +50.00% - expect(screen.getByText('+50.00%')).toBeInTheDocument(); + expect(metricsEl.getByText('+50.00%')).toBeInTheDocument(); // CAGR: +14.87% - expect(screen.getByText('+14.87%')).toBeInTheDocument(); + expect(metricsEl.getByText('+14.87%')).toBeInTheDocument(); }); }); From 1f98945b06813e457d90526e017a3df1ef2fcb3e Mon Sep 17 00:00:00 2001 From: Lidizz Date: Fri, 27 Feb 2026 22:46:11 +0100 Subject: [PATCH 4/4] 13d: Add SVG donut allocation chart for portfolio holdings New component: AllocationChart pure SVG donut chart showing each holding's percentage of total portfolio value. No external dependencies. Features: - Color-coded ring segments matching PortfolioChart holding colors - Center text showing total portfolio value - Legend with symbol, percentage, and dollar value per holding - Only rendered when there are 2+ holdings - Responsive: stacks vertically on mobile (<640px) Positioned above the holdings table in SimulationResults for a visual overview before the detailed breakdown. Updated test to scope holdings row assertions with within() to avoid duplicate matches from the allocation chart legend. --- frontend/src/components/AllocationChart.css | 107 ++++++++++++++ frontend/src/components/AllocationChart.tsx | 133 ++++++++++++++++++ frontend/src/components/SimulationResults.tsx | 6 + frontend/src/test/SimulationResults.test.tsx | 11 +- 4 files changed, 253 insertions(+), 4 deletions(-) create mode 100644 frontend/src/components/AllocationChart.css create mode 100644 frontend/src/components/AllocationChart.tsx diff --git a/frontend/src/components/AllocationChart.css b/frontend/src/components/AllocationChart.css new file mode 100644 index 0000000..0db6c1c --- /dev/null +++ b/frontend/src/components/AllocationChart.css @@ -0,0 +1,107 @@ +.allocation-chart { + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 1.75rem; + box-shadow: var(--shadow-sm); + margin-top: 2rem; +} + +.allocation-chart__title { + font-size: 1.25rem; + font-weight: 700; + color: var(--text-primary); + margin: 0 0 1.5rem 0; + letter-spacing: var(--letter-spacing-tight); +} + +.allocation-chart__content { + display: flex; + align-items: center; + gap: 2.5rem; +} + +/* Donut */ +.allocation-chart__donut { + flex-shrink: 0; + width: 200px; + height: 200px; +} + +.allocation-chart__svg { + width: 100%; + height: 100%; +} + +.allocation-chart__center-label { + font-size: 0.875rem; + font-weight: 600; + fill: var(--text-secondary); +} + +.allocation-chart__center-value { + font-size: 1.125rem; + font-weight: 700; + fill: var(--text-primary); +} + +/* Legend */ +.allocation-chart__legend { + display: flex; + flex-direction: column; + gap: 0.625rem; + flex: 1; + min-width: 0; +} + +.allocation-chart__legend-item { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.allocation-chart__legend-dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; +} + +.allocation-chart__legend-symbol { + font-size: 0.9375rem; + font-weight: 700; + color: var(--text-primary); + min-width: 4rem; +} + +.allocation-chart__legend-pct { + font-size: 0.875rem; + font-weight: 600; + color: var(--text-secondary); + min-width: 3rem; + text-align: right; +} + +.allocation-chart__legend-value { + font-size: 0.875rem; + color: var(--text-secondary); + margin-left: auto; +} + +/* Mobile: stack vertically */ +@media (max-width: 640px) { + .allocation-chart { + padding: 1.25rem; + } + + .allocation-chart__content { + flex-direction: column; + gap: 1.5rem; + } + + .allocation-chart__donut { + width: 180px; + height: 180px; + margin: 0 auto; + } +} diff --git a/frontend/src/components/AllocationChart.tsx b/frontend/src/components/AllocationChart.tsx new file mode 100644 index 0000000..fbdfa4a --- /dev/null +++ b/frontend/src/components/AllocationChart.tsx @@ -0,0 +1,133 @@ +import type { HoldingInfo } from '../types/api.types'; +import './AllocationChart.css'; + +interface AllocationChartProps { + holdings: HoldingInfo[]; +} + +/** + * Palette of distinct colors for donut segments. + * Matches the holding colors from PortfolioChart for consistency. + */ +const SEGMENT_COLORS = [ + 'var(--accent)', // Emerald + 'var(--warning)', // Amber + 'var(--info)', // Slate + '#64b5a6', // Teal + '#9d7cc7', // Purple + '#f87171', // Rose + '#a3a3a3', // Gray + '#fb923c', // Orange +]; + +/** + * SVG donut chart showing each holding's percentage of the total portfolio value. + * Renders as a ring with a centered total value. + */ +export function AllocationChart({ holdings }: AllocationChartProps) { + const totalValue = holdings.reduce((sum, h) => sum + h.currentValue, 0); + + if (totalValue <= 0 || holdings.length === 0) return null; + + // Donut geometry + const size = 200; + const cx = size / 2; + const cy = size / 2; + const radius = 75; + const strokeWidth = 28; + + // Build arc segments + const circumference = 2 * Math.PI * radius; + let offset = 0; + + const segments = holdings.map((holding, index) => { + const fraction = holding.currentValue / totalValue; + const dashLength = fraction * circumference; + // Small gap between segments (1px visual gap) + const gap = holdings.length > 1 ? 2 : 0; + const segment = { + holding, + fraction, + color: SEGMENT_COLORS[index % SEGMENT_COLORS.length], + dashArray: `${Math.max(dashLength - gap, 0)} ${circumference - Math.max(dashLength - gap, 0)}`, + dashOffset: -offset, + }; + offset += dashLength; + return segment; + }); + + const formatCurrency = (value: number) => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(value); + + const formatPercent = (fraction: number) => + `${(fraction * 100).toFixed(1)}%`; + + return ( +
+

Portfolio Allocation

+
+ {/* SVG Donut */} +
+ + {segments.map((seg) => ( + + ))} + {/* Center text */} + + Total + + + {formatCurrency(totalValue)} + + +
+ + {/* Legend */} +
+ {segments.map((seg) => ( +
+ + {seg.holding.symbol} + {formatPercent(seg.fraction)} + + {formatCurrency(seg.holding.currentValue)} + +
+ ))} +
+
+
+ ); +} diff --git a/frontend/src/components/SimulationResults.tsx b/frontend/src/components/SimulationResults.tsx index d3b3e44..9c3a404 100644 --- a/frontend/src/components/SimulationResults.tsx +++ b/frontend/src/components/SimulationResults.tsx @@ -1,5 +1,6 @@ import { PortfolioChart } from './PortfolioChart'; import { PortfolioHeader } from './PortfolioHeader'; +import { AllocationChart } from './AllocationChart'; import { useCountUp } from '../hooks/useCountUp'; import type { SimulationResponse, InvestmentItem } from '../types/api.types'; import './SimulationResults.css'; @@ -128,6 +129,11 @@ export function SimulationResults({ results, investments }: SimulationResultsPro holdingsTimelines={results.holdingsTimelines} /> + {/* Allocation Donut Chart */} + {results.holdings.length > 1 && ( + + )} + {/* Holdings Table */}

Holdings Breakdown

diff --git a/frontend/src/test/SimulationResults.test.tsx b/frontend/src/test/SimulationResults.test.tsx index 254bcd5..a2ed397 100644 --- a/frontend/src/test/SimulationResults.test.tsx +++ b/frontend/src/test/SimulationResults.test.tsx @@ -150,10 +150,13 @@ describe('SimulationResults', () => { it('renders a row for each holding', () => { render(); - expect(screen.getByText('AAPL')).toBeInTheDocument(); - expect(screen.getByText('Apple Inc.')).toBeInTheDocument(); - expect(screen.getByText('MSFT')).toBeInTheDocument(); - expect(screen.getByText('Microsoft Corporation')).toBeInTheDocument(); + const table = document.querySelector('.holdings-table')!; + const tableEl = within(table as HTMLElement); + + expect(tableEl.getByText('AAPL')).toBeInTheDocument(); + expect(tableEl.getByText('Apple Inc.')).toBeInTheDocument(); + expect(tableEl.getByText('MSFT')).toBeInTheDocument(); + expect(tableEl.getByText('Microsoft Corporation')).toBeInTheDocument(); }); it('formats share counts with 4 decimal places', () => {