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

Edit scenario

+
+ +
+ + update('name', e.target.value)} + className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400" + /> + + + update('description', e.target.value)} + className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400" + /> + + + update('strategy', v)} options={STRATEGIES} /> + + + onChange(e.target.value)} + className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400" + > + {options.map((o) => ( + + ))} + + ); +} + +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)} + + )} +
+ + )}