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/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}
)} 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 d52cf6e..9c3a404 100644 --- a/frontend/src/components/SimulationResults.tsx +++ b/frontend/src/components/SimulationResults.tsx @@ -1,5 +1,7 @@ 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'; @@ -45,6 +47,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,39 +62,53 @@ 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
+ {/* 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 && ( + {/* Allocation Donut Chart */} + {results.holdings.length > 1 && ( + + )} + {/* Holdings Table */}

Holdings Breakdown

@@ -135,15 +163,15 @@ export function SimulationResults({ results, investments }: SimulationResultsPro {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/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/SimulationResults.test.tsx b/frontend/src/test/SimulationResults.test.tsx index daf3d53..a2ed397 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(); }); }); @@ -147,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', () => { 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();