From d2fd765fe0a927277d5189e709fbb454c3c2d1ef Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sat, 9 May 2026 22:09:43 +0000 Subject: [PATCH] frontend: scenarios list + detail pages with persisted fan chart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /scenarios — table of all scenarios with filter chips (all/cartesian/ user). Cartesian scenarios get a neutral badge; user-defined get an emerald accent. Empty-state nudges the user to run /recompute. /scenarios/:id — params summary + the latest persisted MC projection. Reuses FanChart so chart code is shared with /what-if. 404 on the projection endpoint is treated as "no run yet" (don't retry); other errors surface normally. Nav grew a Scenarios tab. typecheck + 5 tests + build pass. Co-Authored-By: Claude Opus 4.7 --- frontend/src/App.tsx | 5 + frontend/src/pages/ScenarioDetail.tsx | 137 ++++++++++++++++++++++++ frontend/src/pages/Scenarios.tsx | 144 ++++++++++++++++++++++++++ 3 files changed, 286 insertions(+) create mode 100644 frontend/src/pages/ScenarioDetail.tsx create mode 100644 frontend/src/pages/Scenarios.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1416624..2b6e31b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,6 +3,8 @@ import { NavLink, Route, Routes, Link } from 'react-router-dom'; import { api } from '@/api/client'; import { Dashboard } from '@/pages/Dashboard'; +import { ScenarioDetail } from '@/pages/ScenarioDetail'; +import { Scenarios } from '@/pages/Scenarios'; import { WhatIf } from '@/pages/WhatIf'; export function App() { @@ -18,6 +20,7 @@ export function App() { @@ -36,6 +39,8 @@ export function App() {
} /> + } /> + } /> } />
diff --git a/frontend/src/pages/ScenarioDetail.tsx b/frontend/src/pages/ScenarioDetail.tsx new file mode 100644 index 0000000..b8fd8cf --- /dev/null +++ b/frontend/src/pages/ScenarioDetail.tsx @@ -0,0 +1,137 @@ +/** + * Scenario detail — params + the latest persisted MC projection. + * + * Reuses FanChart from the What-If page. If the scenario has no MC run + * yet, prompts the user to run /recompute. + */ +import { useQuery } from '@tanstack/react-query'; +import { Link, useParams } from 'react-router-dom'; + +import { api } from '@/api/client'; +import { ApiError } from '@/api/client'; +import { FanChart } from '@/components/FanChart'; +import { gbp, pct } from '@/lib/format'; + +export function ScenarioDetail() { + const params = useParams<{ id: string }>(); + const id = Number(params.id); + + const scen = useQuery({ + queryKey: ['scenarios', id], + queryFn: () => api.scenarios.get(id), + enabled: Number.isFinite(id), + }); + const proj = useQuery({ + queryKey: ['scenarios', id, 'projection'], + queryFn: () => api.scenarios.projection(id), + enabled: Number.isFinite(id), + retry: (count, err) => { + // Don't retry the 404 — it's the "no run yet" empty state. + if (err instanceof ApiError && err.status === 404) return false; + return count < 2; + }, + }); + + if (!Number.isFinite(id)) { + return

Invalid scenario id.

; + } + + if (scen.isLoading) return

Loading…

; + if (scen.isError || !scen.data) { + return ( +
+ Couldn't load scenario {id}. +
+ ); + } + + const s = scen.data; + const projection = proj.data; + const projection404 = proj.isError && proj.error instanceof ApiError && proj.error.status === 404; + + return ( +
+
+ + ← Scenarios + +
+ +
+

{s.name ?? s.external_id}

+

+ {s.kind} · {s.jurisdiction} · {s.strategy} · leave UK y{s.leave_uk_year} ·{' '} + {s.glide_path} glide · {s.horizon_years}y horizon +

+ {s.description &&

{s.description}

} +
+ +
+ + + + +
+ + {projection ? ( + <> +
+ + + + + + + + +
+ +
+

Portfolio fan

+ +
+ + ) : projection404 ? ( +
+

No projection yet.

+

+ Run python -m fire_planner recompute-all or{' '} + POST /recompute to fill in MC projections for all scenarios. +

+
+ ) : proj.isLoading ? ( +

Loading projection…

+ ) : ( +
+ {String((proj.error as Error)?.message ?? proj.error)} +
+ )} +
+ ); +} + +function Stat({ + label, + value, + accent, +}: { + label: string; + value: string | number; + accent?: boolean; +}) { + return ( +
+
{label}
+
+ {value} +
+
+ ); +} diff --git a/frontend/src/pages/Scenarios.tsx b/frontend/src/pages/Scenarios.tsx new file mode 100644 index 0000000..dbbd3c7 --- /dev/null +++ b/frontend/src/pages/Scenarios.tsx @@ -0,0 +1,144 @@ +/** + * Scenarios list — both Cartesian (engine-generated) and user-defined. + * Filter chips at the top; each row links to a detail page. + * + * The Cartesian set is whatever the latest /recompute produced (default + * 120 scenarios). User scenarios survive recomputes. + */ +import { useQuery } from '@tanstack/react-query'; +import { Link } from 'react-router-dom'; +import { useState } from 'react'; + +import { api, type Scenario } from '@/api/client'; +import { gbp } from '@/lib/format'; + +type Filter = 'all' | 'cartesian' | 'user'; + +export function Scenarios() { + const [filter, setFilter] = useState('all'); + const scenarios = useQuery({ + queryKey: ['scenarios', filter], + queryFn: () => api.scenarios.list(filter === 'all' ? undefined : filter), + }); + + return ( +
+
+
+

Scenarios

+

+ Persisted plans. Cartesian rebuilt from /recompute; user-defined survive. +

+
+ +
+ + {scenarios.isLoading ? ( +

Loading…

+ ) : scenarios.isError ? ( + + ) : scenarios.data && scenarios.data.length > 0 ? ( + + ) : ( + + )} +
+ ); +} + +function FilterChips({ value, onChange }: { value: Filter; onChange: (v: Filter) => void }) { + const opts: { key: Filter; label: string }[] = [ + { key: 'all', label: 'All' }, + { key: 'cartesian', label: 'Cartesian' }, + { key: 'user', label: 'User' }, + ]; + return ( +
+ {opts.map((o) => ( + + ))} +
+ ); +} + +function ScenarioTable({ scenarios }: { scenarios: Scenario[] }) { + return ( +
+ + + + + + + + + + + + + + + {scenarios.map((s) => ( + + + + + + + + + + + ))} + +
Name / IDKindJurisdictionStrategyLeave UKGlideSpendingNW seed
+ + {s.name ?? s.external_id} + + {s.name && ( +
{s.external_id}
+ )} +
+ + {s.jurisdiction}{s.strategy}y{s.leave_uk_year}{s.glide_path}{gbp(s.spending_gbp)}{gbp(s.nw_seed_gbp)}
+
+ ); +} + +function KindBadge({ kind }: { kind: Scenario['kind'] }) { + const styles = kind === 'user' ? 'bg-emerald-100 text-emerald-800' : 'bg-slate-100 text-slate-600'; + return ( + {kind} + ); +} + +function EmptyState({ filter }: { filter: Filter }) { + return ( +
+

No scenarios{filter !== 'all' ? ` (${filter})` : ''}.

+

+ Run python -m fire_planner recompute-all to populate the + Cartesian set, or create a user scenario via POST /scenarios. +

+
+ ); +} + +function ErrorBox({ error }: { error: unknown }) { + return ( +
+ {String((error as Error)?.message ?? error)} +
+ ); +}