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