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

@ -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}