From cb79118da78b8d988a9b1007421d1d7824e6fd67 Mon Sep 17 00:00:00 2001
From: Viktor Barzin
Date: Sat, 9 May 2026 22:20:21 +0000
Subject: [PATCH] frontend: run-now + save-as-scenario + edit form (CRUD
complete)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three small UX wins:
- /scenarios/:id Run now — POSTs /simulate with the scenario's params
and renders the result in a "Live preview run" card below the
persisted projection. Removes the recompute-or-wait friction.
- /what-if Save as scenario — appears once a simulation has run.
Prompts for a name (with a sensible default), POSTs the form values
to /scenarios as a user scenario, redirects to its detail page.
- /scenarios/:id/edit — PATCH form for user scenarios. Pre-fills from
current scenario; on save invalidates the scenarios query and
navigates back to detail. Backend already rejects PATCH on
cartesian; the UI also hides the Edit button for them.
api.scenarios gained patch(). 7 tests pass, typecheck + build clean.
Co-Authored-By: Claude Opus 4.7
---
frontend/src/App.tsx | 2 +
frontend/src/api/client.ts | 2 +
frontend/src/pages/ScenarioDetail.tsx | 67 ++++++-
frontend/src/pages/ScenarioEdit.tsx | 244 ++++++++++++++++++++++++++
frontend/src/pages/WhatIf.tsx | 53 +++++-
5 files changed, 359 insertions(+), 9 deletions(-)
create mode 100644 frontend/src/pages/ScenarioEdit.tsx
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index c49c6da..f1cb30d 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -5,6 +5,7 @@ import { api } from '@/api/client';
import { Compare } from '@/pages/Compare';
import { Dashboard } from '@/pages/Dashboard';
import { ScenarioDetail } from '@/pages/ScenarioDetail';
+import { ScenarioEdit } from '@/pages/ScenarioEdit';
import { ScenarioNew } from '@/pages/ScenarioNew';
import { Scenarios } from '@/pages/Scenarios';
import { WhatIf } from '@/pages/WhatIf';
@@ -44,6 +45,7 @@ export function App() {
} />
} />
} />
+ } />
} />
} />
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
index 36b27a9..dc42b8c 100644
--- a/frontend/src/api/client.ts
+++ b/frontend/src/api/client.ts
@@ -72,6 +72,8 @@ export const api = {
projection: (id: number) => request(`/scenarios/${id}/projection`),
create: (body: ScenarioCreateBody) =>
request('/scenarios', { method: 'POST', body: JSON.stringify(body) }),
+ patch: (id: number, body: Partial) =>
+ request(`/scenarios/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
delete: (id: number) => request(`/scenarios/${id}`, { method: 'DELETE' }),
},
simulate: (req: SimulateRequest) =>
diff --git a/frontend/src/pages/ScenarioDetail.tsx b/frontend/src/pages/ScenarioDetail.tsx
index 0722cbe..1699d4a 100644
--- a/frontend/src/pages/ScenarioDetail.tsx
+++ b/frontend/src/pages/ScenarioDetail.tsx
@@ -7,7 +7,7 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, useNavigate, useParams } from 'react-router-dom';
-import { api } from '@/api/client';
+import { api, type Scenario, type SimulateRequest } from '@/api/client';
import { ApiError } from '@/api/client';
import { FanChart } from '@/components/FanChart';
import { GoalsSection } from '@/components/GoalsSection';
@@ -44,11 +44,29 @@ export function ScenarioDetail() {
},
});
+ const sim = useMutation({
+ mutationFn: (req: SimulateRequest) => api.simulate(req),
+ });
+
const onDelete = () => {
if (!confirm(`Delete scenario "${scen.data?.name ?? id}"? This can't be undone.`)) return;
del.mutate();
};
+ const onRunNow = (s: Scenario) =>
+ sim.mutate({
+ jurisdiction: s.jurisdiction,
+ strategy: s.strategy,
+ leave_uk_year: s.leave_uk_year,
+ glide_path: s.glide_path,
+ spending_gbp: s.spending_gbp,
+ nw_seed_gbp: s.nw_seed_gbp,
+ savings_per_year_gbp: s.savings_per_year_gbp,
+ horizon_years: s.horizon_years,
+ n_paths: 5000,
+ seed: 42,
+ });
+
if (!Number.isFinite(id)) {
return Invalid scenario id.
;
}
@@ -83,16 +101,34 @@ export function ScenarioDetail() {
{s.description && {s.description}
}
- {s.kind === 'user' && (
+
- )}
+ {s.kind === 'user' && (
+ <>
+
+ Edit
+
+
+ >
+ )}
+
{del.isError && (
@@ -144,6 +180,23 @@ export function ScenarioDetail() {
)}
+ {sim.data && (
+
+
+
Live preview run
+
+ {sim.data.elapsed_seconds}s · 5,000 paths · success {pct(sim.data.success_rate)}
+
+
+
+
+ )}
+ {sim.isError && (
+
+ {String((sim.error as Error)?.message ?? sim.error)}
+
+ )}
+
diff --git a/frontend/src/pages/ScenarioEdit.tsx b/frontend/src/pages/ScenarioEdit.tsx
new file mode 100644
index 0000000..64ad464
--- /dev/null
+++ b/frontend/src/pages/ScenarioEdit.tsx
@@ -0,0 +1,244 @@
+/**
+ * Edit a user scenario via PATCH /scenarios/:id. Cartesian scenarios
+ * cannot be edited (the backend rejects PATCH for kind != 'user'); the
+ * UI also hides the edit affordance for them, so this page is only
+ * reachable for user scenarios.
+ */
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { useEffect, useState } from 'react';
+import { Link, useNavigate, useParams } from 'react-router-dom';
+
+import { api, type ScenarioCreateBody } from '@/api/client';
+
+const JURISDICTIONS = ['uk', 'cyprus', 'bulgaria', 'malaysia', 'thailand', 'uae', 'nomad'];
+const STRATEGIES = ['trinity', 'guyton_klinger', 'vpw', 'vpw_floor'];
+const GLIDES = ['rising', 'static_60_40'];
+
+export function ScenarioEdit() {
+ const params = useParams<{ id: string }>();
+ const id = Number(params.id);
+ const navigate = useNavigate();
+ const qc = useQueryClient();
+
+ const scen = useQuery({
+ queryKey: ['scenarios', id],
+ queryFn: () => api.scenarios.get(id),
+ enabled: Number.isFinite(id),
+ });
+
+ const [form, setForm] = useState(null);
+ useEffect(() => {
+ if (scen.data && form === null) {
+ setForm({
+ name: scen.data.name ?? '',
+ description: scen.data.description ?? '',
+ jurisdiction: scen.data.jurisdiction,
+ strategy: scen.data.strategy,
+ leave_uk_year: scen.data.leave_uk_year,
+ glide_path: scen.data.glide_path,
+ spending_gbp: scen.data.spending_gbp,
+ nw_seed_gbp: scen.data.nw_seed_gbp,
+ savings_per_year_gbp: scen.data.savings_per_year_gbp,
+ horizon_years: scen.data.horizon_years,
+ });
+ }
+ }, [scen.data, form]);
+
+ const patch = useMutation({
+ mutationFn: (body: Partial) => api.scenarios.patch(id, body),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ['scenarios'] });
+ navigate(`/scenarios/${id}`);
+ },
+ });
+
+ if (!Number.isFinite(id)) return Invalid scenario id.
;
+ if (scen.isLoading || !form) return Loading…
;
+ if (scen.isError || !scen.data)
+ return (
+
+ Couldn't load scenario {id}.
+
+ );
+ if (scen.data.kind !== 'user') {
+ return (
+
+ Cartesian scenarios are auto-generated and can't be edited.{' '}
+
+ Back to detail
+
+ .
+
+ );
+ }
+
+ const update = (key: K, value: ScenarioCreateBody[K]) =>
+ setForm((f) => (f ? { ...f, [key]: value } : f));
+
+ const onSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!form.name.trim()) return;
+ patch.mutate(form);
+ };
+
+ return (
+
+
+
+ ← {scen.data.name ?? scen.data.external_id}
+
+
+
+
+
+
+
+ );
+}
+
+function Field({ label, children }: { label: string; children: React.ReactNode }) {
+ return (
+
+ );
+}
+
+function Select({
+ value,
+ onChange,
+ options,
+}: {
+ value: string;
+ onChange: (v: string) => void;
+ options: string[];
+}) {
+ return (
+
+ );
+}
+
+function NumberInput({
+ value,
+ onChange,
+ min,
+ max,
+ step = 1,
+}: {
+ value: number;
+ onChange: (v: number) => void;
+ min?: number;
+ max?: number;
+ step?: number;
+}) {
+ return (
+ {
+ const n = Number(e.target.value);
+ if (Number.isFinite(n)) onChange(n);
+ }}
+ className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm tabular-nums focus:outline-none focus:ring-2 focus:ring-slate-400"
+ />
+ );
+}
diff --git a/frontend/src/pages/WhatIf.tsx b/frontend/src/pages/WhatIf.tsx
index 8b4ec1d..376934c 100644
--- a/frontend/src/pages/WhatIf.tsx
+++ b/frontend/src/pages/WhatIf.tsx
@@ -2,8 +2,9 @@
* What-If — interactive Monte Carlo. Form on the left, fan chart on the
* right. Hits POST /simulate (no DB write); ~1-3s for 5k paths.
*/
-import { useMutation } from '@tanstack/react-query';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
import { api, type SimulateRequest, type SimulateResult } from '@/api/client';
import { FanChart } from '@/components/FanChart';
@@ -29,10 +30,32 @@ const DEFAULTS: SimulateRequest = {
export function WhatIf() {
const [form, setForm] = useState(DEFAULTS);
+ const navigate = useNavigate();
+ const qc = useQueryClient();
+
const sim = useMutation({
mutationFn: (req: SimulateRequest) => api.simulate(req),
});
+ const save = useMutation({
+ mutationFn: (name: string) =>
+ api.scenarios.create({
+ name,
+ jurisdiction: form.jurisdiction,
+ strategy: form.strategy,
+ leave_uk_year: form.leave_uk_year,
+ glide_path: form.glide_path,
+ spending_gbp: form.spending_gbp,
+ nw_seed_gbp: form.nw_seed_gbp,
+ savings_per_year_gbp: form.savings_per_year_gbp,
+ horizon_years: form.horizon_years,
+ }),
+ onSuccess: (s) => {
+ qc.invalidateQueries({ queryKey: ['scenarios'] });
+ navigate(`/scenarios/${s.id}`);
+ },
+ });
+
const onSubmit = (e: React.FormEvent) => {
e.preventDefault();
sim.mutate({
@@ -41,6 +64,13 @@ export function WhatIf() {
});
};
+ const onSaveAs = () => {
+ const suggested = `${form.jurisdiction}-${form.strategy}-leave-y${form.leave_uk_year}`;
+ const name = prompt('Save as scenario — name:', suggested);
+ if (!name?.trim()) return;
+ save.mutate(name.trim());
+ };
+
const update = (key: K, value: SimulateRequest[K]) =>
setForm((f) => ({ ...f, [key]: value }));
@@ -164,7 +194,26 @@ export function WhatIf() {
Running Monte Carlo…
)}
- {sim.data && }
+ {sim.data && (
+ <>
+
+
+
+ {save.isError && (
+
+ {String((save.error as Error)?.message ?? save.error)}
+
+ )}
+
+ >
+ )}