From b2af5c5893c089512b6d9d736c737c6f36516d17 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sat, 9 May 2026 22:14:51 +0000 Subject: [PATCH] frontend: compare mode (overlay 2-5 scenarios on one fan chart) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-select on /scenarios — checkbox per row, capped at 5. When ≥2 are picked, a "Compare N" button appears that navigates to /compare?ids=1,2,3. /compare page pulls each scenario + its latest projection in parallel via useQueries. Each scenario gets a distinct hue (5 in the palette); median lines drawn solid, p10 + p90 dashed at 60% opacity. Stats table below shows success / p10 / p50 / p90 endings + median lifetime tax per scenario, with inline "no run yet" rows for scenarios missing a projection (404 from /projection treated as soft state, not error). 7 tests pass (was 5). Bundle still ~470 KB gz. Co-Authored-By: Claude Opus 4.7 --- frontend/src/App.tsx | 2 + frontend/src/components/OverlayChart.test.tsx | 42 +++++ frontend/src/components/OverlayChart.tsx | 117 +++++++++++++ frontend/src/pages/Compare.tsx | 163 ++++++++++++++++++ frontend/src/pages/Scenarios.tsx | 49 +++++- 5 files changed, 370 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/OverlayChart.test.tsx create mode 100644 frontend/src/components/OverlayChart.tsx create mode 100644 frontend/src/pages/Compare.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 341ef33..c49c6da 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query'; import { NavLink, Route, Routes, Link } from 'react-router-dom'; import { api } from '@/api/client'; +import { Compare } from '@/pages/Compare'; import { Dashboard } from '@/pages/Dashboard'; import { ScenarioDetail } from '@/pages/ScenarioDetail'; import { ScenarioNew } from '@/pages/ScenarioNew'; @@ -43,6 +44,7 @@ export function App() { } /> } /> } /> + } /> } /> diff --git a/frontend/src/components/OverlayChart.test.tsx b/frontend/src/components/OverlayChart.test.tsx new file mode 100644 index 0000000..a1adb0c --- /dev/null +++ b/frontend/src/components/OverlayChart.test.tsx @@ -0,0 +1,42 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; + +import { OverlayChart } from './OverlayChart'; +import type { ProjectionPoint } from '@/api/client'; + +vi.mock('echarts-for-react', () => ({ + default: ({ option }: { option: { series?: unknown[] } }) => ( +
+ ), +})); + +const point = (year: number, p50: number): ProjectionPoint => ({ + year_idx: year, + p10_portfolio_gbp: String(p50 * 0.7), + p25_portfolio_gbp: String(p50 * 0.85), + p50_portfolio_gbp: String(p50), + p75_portfolio_gbp: String(p50 * 1.15), + p90_portfolio_gbp: String(p50 * 1.3), + p50_withdrawal_gbp: '60000', + p50_tax_gbp: '8000', + survival_rate: '1.0', +}); + +describe('OverlayChart', () => { + it('shows empty state with no series', () => { + render(); + expect(screen.getByText('Pick at least one scenario.')).toBeInTheDocument(); + }); + + it('renders 3 lines per scenario (median + p10 + p90)', () => { + render( + , + ); + expect(screen.getByTestId('chart').dataset.seriesCount).toBe('6'); + }); +}); diff --git a/frontend/src/components/OverlayChart.tsx b/frontend/src/components/OverlayChart.tsx new file mode 100644 index 0000000..310bcb2 --- /dev/null +++ b/frontend/src/components/OverlayChart.tsx @@ -0,0 +1,117 @@ +/** + * Overlay multiple scenarios' projections on a single fan chart. + * + * Each scenario gets a distinct hue. By default we draw the median + * solid + dashed p10/p90 bounds (no fill — fills overlap badly with 3+ + * series). The hovered legend item is highlighted; ECharts handles that + * via series.emphasis. + */ +import { useMemo } from 'react'; +import ReactECharts from 'echarts-for-react'; +import type { EChartsOption } from 'echarts'; + +import type { ProjectionPoint } from '@/api/client'; +import { gbpCompact } from '@/lib/format'; + +export interface OverlaySeries { + name: string; + yearly: ProjectionPoint[]; +} + +interface Props { + series: OverlaySeries[]; + height?: number; +} + +const HUES = [ + 'rgb(40, 70, 200)', + 'rgb(190, 90, 60)', + 'rgb(40, 150, 90)', + 'rgb(170, 70, 170)', + 'rgb(200, 150, 30)', +]; + +export function OverlayChart({ series, height = 420 }: Props) { + const option = useMemo(() => buildOption(series), [series]); + if (series.length === 0) { + return

Pick at least one scenario.

; + } + return ; +} + +function buildOption(series: OverlaySeries[]): EChartsOption { + const horizon = Math.max(...series.map((s) => s.yearly.length)); + const xAxis = Array.from({ length: horizon }, (_, i) => i); + + const lines: EChartsOption['series'] = []; + series.forEach((s, idx) => { + const color = HUES[idx % HUES.length]!; + const data = (key: keyof ProjectionPoint) => + xAxis.map((i) => { + const point = s.yearly[i]; + if (!point) return null; + const v = point[key]; + return typeof v === 'string' ? Number(v) : v; + }); + + lines.push({ + name: `${s.name} · median`, + type: 'line', + data: data('p50_portfolio_gbp'), + lineStyle: { width: 2, color }, + itemStyle: { color }, + symbol: 'none', + smooth: true, + emphasis: { focus: 'series' }, + z: 10 - idx, + }); + lines.push({ + name: `${s.name} · p10`, + type: 'line', + data: data('p10_portfolio_gbp'), + lineStyle: { width: 1, color, type: 'dashed', opacity: 0.6 }, + symbol: 'none', + smooth: true, + emphasis: { focus: 'series' }, + tooltip: { show: false }, + }); + lines.push({ + name: `${s.name} · p90`, + type: 'line', + data: data('p90_portfolio_gbp'), + lineStyle: { width: 1, color, type: 'dashed', opacity: 0.6 }, + symbol: 'none', + smooth: true, + emphasis: { focus: 'series' }, + tooltip: { show: false }, + }); + }); + + return { + grid: { left: 60, right: 24, top: 30, bottom: 60 }, + tooltip: { + trigger: 'axis', + valueFormatter: (v) => gbpCompact(typeof v === 'number' ? v : 0), + }, + legend: { + bottom: 0, + type: 'scroll', + data: series.map((s) => `${s.name} · median`), + }, + xAxis: { + type: 'category', + data: xAxis, + name: 'years', + nameLocation: 'middle', + nameGap: 24, + }, + yAxis: { + type: 'value', + name: 'portfolio (real GBP)', + nameLocation: 'middle', + nameGap: 50, + axisLabel: { formatter: (v: number) => gbpCompact(v) }, + }, + series: lines, + }; +} diff --git a/frontend/src/pages/Compare.tsx b/frontend/src/pages/Compare.tsx new file mode 100644 index 0000000..20f7500 --- /dev/null +++ b/frontend/src/pages/Compare.tsx @@ -0,0 +1,163 @@ +/** + * Compare 2-5 scenarios overlaid on one chart. + * + * Reads `?ids=1,2,3` from the URL. For each id we pull the scenario + * + its latest projection in parallel; if any scenario has no + * projection we still show the rest, with a row in the stats table + * marking it "no run". + */ +import { useQueries } from '@tanstack/react-query'; +import { Link, useSearchParams } from 'react-router-dom'; + +import { ApiError, api, type Scenario, type ScenarioProjection } from '@/api/client'; +import { OverlayChart, type OverlaySeries } from '@/components/OverlayChart'; +import { gbp, pct } from '@/lib/format'; + +interface Combined { + id: number; + scenario: Scenario | null; + projection: ScenarioProjection | null; + noRun: boolean; + error: string | null; +} + +export function Compare() { + const [search] = useSearchParams(); + const ids = parseIds(search.get('ids')); + + const queries = useQueries({ + queries: ids.flatMap((id) => [ + { queryKey: ['scenarios', id], queryFn: () => api.scenarios.get(id) }, + { + queryKey: ['scenarios', id, 'projection'], + queryFn: () => api.scenarios.projection(id), + retry: (count: number, err: Error) => + err instanceof ApiError && err.status === 404 ? false : count < 2, + }, + ]), + }); + + if (ids.length < 2) { + return ( +
+

Compare

+

+ Pick 2-5 scenarios from the scenarios list + {' '}and click "Compare selected". +

+
+ ); + } + + const combined: Combined[] = ids.map((id, i) => { + const sQ = queries[i * 2]!; + const pQ = queries[i * 2 + 1]!; + const noRun = pQ.error instanceof ApiError && pQ.error.status === 404; + return { + id, + scenario: (sQ.data as Scenario | undefined) ?? null, + projection: noRun ? null : ((pQ.data as ScenarioProjection | undefined) ?? null), + noRun, + error: !noRun && pQ.isError ? String((pQ.error as Error)?.message ?? pQ.error) : null, + }; + }); + + const series: OverlaySeries[] = combined + .filter((c) => c.projection !== null) + .map((c) => ({ + name: c.scenario?.name ?? c.scenario?.external_id ?? `#${c.id}`, + yearly: c.projection!.yearly, + })); + + const anyLoading = queries.some((q) => q.isLoading); + + return ( +
+
+

Compare

+

{ids.length} scenarios overlaid

+
+ +
+

Median portfolio fan

+ {anyLoading ? ( +

Loading…

+ ) : ( + + )} +
+ +
+ + + + + + + + + + + + + + {combined.map((c) => ( + + + + {c.projection ? ( + <> + + + + + + + ) : c.noRun ? ( + + ) : c.error ? ( + + ) : ( + + )} + + ))} + +
ScenarioStrategySuccessP10 endingMedian endingP90 endingMedian lifetime tax
+ + {c.scenario?.name ?? c.scenario?.external_id ?? `#${c.id}`} + + + {c.scenario + ? `${c.scenario.jurisdiction} · ${c.scenario.strategy}` + : '—'} + + {pct(c.projection.success_rate)} + + {gbp(c.projection.p10_ending_gbp)} + + {gbp(c.projection.p50_ending_gbp)} + + {gbp(c.projection.p90_ending_gbp)} + + {gbp(c.projection.median_lifetime_tax_gbp)} + + No run yet — recompute first. + + {c.error} + + Loading… +
+
+
+ ); +} + +function parseIds(raw: string | null): number[] { + if (!raw) return []; + return raw + .split(',') + .map((s) => Number(s.trim())) + .filter((n) => Number.isFinite(n) && n > 0) + .slice(0, 5); +} diff --git a/frontend/src/pages/Scenarios.tsx b/frontend/src/pages/Scenarios.tsx index d634393..83eb978 100644 --- a/frontend/src/pages/Scenarios.tsx +++ b/frontend/src/pages/Scenarios.tsx @@ -6,7 +6,7 @@ * 120 scenarios). User scenarios survive recomputes. */ import { useQuery } from '@tanstack/react-query'; -import { Link } from 'react-router-dom'; +import { Link, useNavigate } from 'react-router-dom'; import { useState } from 'react'; import { api, type Scenario } from '@/api/client'; @@ -16,11 +16,27 @@ type Filter = 'all' | 'cartesian' | 'user'; export function Scenarios() { const [filter, setFilter] = useState('all'); + const [selected, setSelected] = useState>(new Set()); + const navigate = useNavigate(); + const scenarios = useQuery({ queryKey: ['scenarios', filter], queryFn: () => api.scenarios.list(filter === 'all' ? undefined : filter), }); + const toggle = (id: number) => + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else if (next.size < 5) next.add(id); + return next; + }); + + const onCompare = () => { + const ids = [...selected].join(','); + navigate(`/compare?ids=${ids}`); + }; + return (
@@ -32,6 +48,15 @@ export function Scenarios() {
+ {selected.size >= 2 && ( + + )} ) : scenarios.data && scenarios.data.length > 0 ? ( - + ) : ( )} @@ -80,12 +105,21 @@ function FilterChips({ value, onChange }: { value: Filter; onChange: (v: Filter) ); } -function ScenarioTable({ scenarios }: { scenarios: Scenario[] }) { +function ScenarioTable({ + scenarios, + selected, + onToggle, +}: { + scenarios: Scenario[]; + selected: Set; + onToggle: (id: number) => void; +}) { return (
+ @@ -99,6 +133,15 @@ function ScenarioTable({ scenarios }: { scenarios: Scenario[] }) { {scenarios.map((s) => ( +
Name / ID Kind Jurisdiction
+ onToggle(s.id)} + disabled={!selected.has(s.id) && selected.size >= 5} + aria-label={`Select ${s.name ?? s.external_id}`} + /> + {s.name ?? s.external_id}