frontend: compare mode (overlay 2-5 scenarios on one fan chart)
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was canceled
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:
parent
60c275cd05
commit
b2af5c5893
5 changed files with 370 additions and 3 deletions
|
|
@ -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>
|
||||
|
|
|
|||
42
frontend/src/components/OverlayChart.test.tsx
Normal file
42
frontend/src/components/OverlayChart.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
117
frontend/src/components/OverlayChart.tsx
Normal file
117
frontend/src/components/OverlayChart.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
163
frontend/src/pages/Compare.tsx
Normal file
163
frontend/src/pages/Compare.tsx
Normal 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);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue