frontend: compare mode (overlay 2-5 scenarios on one fan chart)
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was canceled

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 <noreply@anthropic.com>
This commit is contained in:
Viktor Barzin 2026-05-09 22:14:51 +00:00
parent 60c275cd05
commit b2af5c5893
5 changed files with 370 additions and 3 deletions

View file

@ -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() {
<Route path="/scenarios" element={<Scenarios />} />
<Route path="/scenarios/new" element={<ScenarioNew />} />
<Route path="/scenarios/:id" element={<ScenarioDetail />} />
<Route path="/compare" element={<Compare />} />
<Route path="/what-if" element={<WhatIf />} />
</Routes>
</main>

View file

@ -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[] } }) => (
<div data-testid="chart" data-series-count={option.series?.length ?? 0} />
),
}));
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(<OverlayChart series={[]} />);
expect(screen.getByText('Pick at least one scenario.')).toBeInTheDocument();
});
it('renders 3 lines per scenario (median + p10 + p90)', () => {
render(
<OverlayChart
series={[
{ name: 'A', yearly: [point(0, 1_000_000), point(1, 1_050_000)] },
{ name: 'B', yearly: [point(0, 1_000_000), point(1, 1_080_000)] },
]}
/>,
);
expect(screen.getByTestId('chart').dataset.seriesCount).toBe('6');
});
});

View file

@ -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<EChartsOption>(() => buildOption(series), [series]);
if (series.length === 0) {
return <p className="text-sm text-slate-500">Pick at least one scenario.</p>;
}
return <ReactECharts option={option} style={{ height, width: '100%' }} notMerge lazyUpdate />;
}
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,
};
}

View file

@ -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 (
<section className="space-y-4">
<h1 className="text-3xl font-semibold tracking-tight">Compare</h1>
<p className="text-sm text-slate-500">
Pick 2-5 scenarios from <Link to="/scenarios" className="text-blue-700 hover:underline">the scenarios list</Link>
{' '}and click "Compare selected".
</p>
</section>
);
}
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 (
<section className="space-y-6">
<header>
<h1 className="text-3xl font-semibold tracking-tight">Compare</h1>
<p className="text-sm text-slate-500">{ids.length} scenarios overlaid</p>
</header>
<div className="rounded-lg border border-slate-200 bg-white p-5">
<h2 className="text-lg font-semibold mb-3">Median portfolio fan</h2>
{anyLoading ? (
<p className="text-slate-500">Loading</p>
) : (
<OverlayChart series={series} height={460} />
)}
</div>
<div className="rounded-lg border border-slate-200 bg-white overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-slate-50 text-slate-600 text-xs uppercase tracking-wide">
<tr>
<th className="text-left px-4 py-2">Scenario</th>
<th className="text-left px-4 py-2">Strategy</th>
<th className="text-right px-4 py-2">Success</th>
<th className="text-right px-4 py-2">P10 ending</th>
<th className="text-right px-4 py-2">Median ending</th>
<th className="text-right px-4 py-2">P90 ending</th>
<th className="text-right px-4 py-2">Median lifetime tax</th>
</tr>
</thead>
<tbody>
{combined.map((c) => (
<tr key={c.id} className="border-t border-slate-100">
<td className="px-4 py-2">
<Link to={`/scenarios/${c.id}`} className="text-blue-700 hover:underline">
{c.scenario?.name ?? c.scenario?.external_id ?? `#${c.id}`}
</Link>
</td>
<td className="px-4 py-2">
{c.scenario
? `${c.scenario.jurisdiction} · ${c.scenario.strategy}`
: '—'}
</td>
{c.projection ? (
<>
<td className="px-4 py-2 text-right tabular-nums">
{pct(c.projection.success_rate)}
</td>
<td className="px-4 py-2 text-right tabular-nums">
{gbp(c.projection.p10_ending_gbp)}
</td>
<td className="px-4 py-2 text-right tabular-nums">
{gbp(c.projection.p50_ending_gbp)}
</td>
<td className="px-4 py-2 text-right tabular-nums">
{gbp(c.projection.p90_ending_gbp)}
</td>
<td className="px-4 py-2 text-right tabular-nums">
{gbp(c.projection.median_lifetime_tax_gbp)}
</td>
</>
) : c.noRun ? (
<td colSpan={5} className="px-4 py-2 text-slate-500 italic">
No run yet recompute first.
</td>
) : c.error ? (
<td colSpan={5} className="px-4 py-2 text-red-700">
{c.error}
</td>
) : (
<td colSpan={5} className="px-4 py-2 text-slate-500">
Loading
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
</section>
);
}
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);
}

View file

@ -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<Filter>('all');
const [selected, setSelected] = useState<Set<number>>(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 (
<section className="space-y-6">
<header className="flex items-center justify-between">
@ -32,6 +48,15 @@ export function Scenarios() {
</div>
<div className="flex items-center gap-3">
<FilterChips value={filter} onChange={setFilter} />
{selected.size >= 2 && (
<button
type="button"
onClick={onCompare}
className="rounded-md border border-slate-300 bg-white text-slate-900 text-sm font-medium px-4 py-2 hover:bg-slate-50"
>
Compare {selected.size}
</button>
)}
<Link
to="/scenarios/new"
className="rounded-md bg-slate-900 text-white text-sm font-medium px-4 py-2 hover:bg-slate-800"
@ -46,7 +71,7 @@ export function Scenarios() {
) : scenarios.isError ? (
<ErrorBox error={scenarios.error} />
) : scenarios.data && scenarios.data.length > 0 ? (
<ScenarioTable scenarios={scenarios.data} />
<ScenarioTable scenarios={scenarios.data} selected={selected} onToggle={toggle} />
) : (
<EmptyState filter={filter} />
)}
@ -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<number>;
onToggle: (id: number) => void;
}) {
return (
<div className="rounded-lg border border-slate-200 bg-white overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-slate-50 text-slate-600 text-xs uppercase tracking-wide">
<tr>
<th className="px-3 py-2 w-8"></th>
<th className="text-left px-4 py-2">Name / ID</th>
<th className="text-left px-4 py-2">Kind</th>
<th className="text-left px-4 py-2">Jurisdiction</th>
@ -99,6 +133,15 @@ function ScenarioTable({ scenarios }: { scenarios: Scenario[] }) {
<tbody>
{scenarios.map((s) => (
<tr key={s.id} className="border-t border-slate-100 hover:bg-slate-50">
<td className="px-3 py-2">
<input
type="checkbox"
checked={selected.has(s.id)}
onChange={() => onToggle(s.id)}
disabled={!selected.has(s.id) && selected.size >= 5}
aria-label={`Select ${s.name ?? s.external_id}`}
/>
</td>
<td className="px-4 py-2">
<Link to={`/scenarios/${s.id}`} className="text-blue-700 hover:underline">
{s.name ?? s.external_id}