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 */}
+
+
+
+
+ {/* 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)
-
+
{/* 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();