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
+
+
+
+
+
+
+
+
+
+
+
+
+ {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.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 (
+
+
+
+
+ | Name / ID |
+ Kind |
+ Jurisdiction |
+ Strategy |
+ Leave UK |
+ Glide |
+ Spending |
+ NW seed |
+
+
+
+ {scenarios.map((s) => (
+
+ |
+
+ {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)}
+
+ );
+}