frontend: life events + retirement goals sections on scenario detail
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

Two new nested CRUD sections on /scenarios/:id, each list + add form
in one card:

- Life events: name, kind (free-text with datalist suggestions —
  retirement, kid_born, mortgage_payoff, sabbatical, inheritance...),
  year_start, optional year_end (one-time vs ranged), £/year delta.
  One-line summary per row; Delete button per item.

- Goals: name, kind (target_nw, never_run_out, inheritance,
  spending_floor), comparator (>= < etc), target amount, target year,
  success threshold (probability bar). Same list+add+delete layout.

Both wire through the existing FastAPI endpoints (POST/GET on
nested paths, DELETE on flat /life-events/{id} and /goals/{id})
already shipped in Phase 0c. Mutations invalidate per-scenario
queries so the list refreshes immediately.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Viktor Barzin 2026-05-09 22:17:04 +00:00
parent b2af5c5893
commit 18981459b3
4 changed files with 501 additions and 0 deletions

View file

@ -93,6 +93,82 @@ export interface ScenarioCreateBody {
config_json?: Record<string, unknown>;
}
// ── life events ──────────────────────────────────────────────────────
export interface LifeEvent {
id: number;
scenario_id: number;
kind: string;
name: string;
year_start: number;
year_end: number | null;
delta_gbp_per_year: string;
one_time_amount_gbp: string | null;
enabled: boolean;
payload: Record<string, unknown> | null;
created_at: string;
}
export interface LifeEventCreateBody {
kind: string;
name: string;
year_start: number;
year_end?: number | null;
delta_gbp_per_year?: string;
one_time_amount_gbp?: string | null;
enabled?: boolean;
payload?: Record<string, unknown> | null;
}
export const lifeEventsApi = {
list: (scenarioId: number) =>
request<LifeEvent[]>(`/scenarios/${scenarioId}/life-events`),
create: (scenarioId: number, body: LifeEventCreateBody) =>
request<LifeEvent>(`/scenarios/${scenarioId}/life-events`, {
method: 'POST',
body: JSON.stringify(body),
}),
delete: (eventId: number) =>
request<void>(`/life-events/${eventId}`, { method: 'DELETE' }),
};
// ── goals ────────────────────────────────────────────────────────────
export interface Goal {
id: number;
scenario_id: number;
kind: string;
name: string;
target_amount_gbp: string | null;
target_year: number | null;
comparator: string;
success_threshold: string;
enabled: boolean;
payload: Record<string, unknown> | null;
created_at: string;
}
export interface GoalCreateBody {
kind: string;
name: string;
target_amount_gbp?: string | null;
target_year?: number | null;
comparator?: string;
success_threshold?: string;
enabled?: boolean;
payload?: Record<string, unknown> | null;
}
export const goalsApi = {
list: (scenarioId: number) => request<Goal[]>(`/scenarios/${scenarioId}/goals`),
create: (scenarioId: number, body: GoalCreateBody) =>
request<Goal>(`/scenarios/${scenarioId}/goals`, {
method: 'POST',
body: JSON.stringify(body),
}),
delete: (goalId: number) => request<void>(`/goals/${goalId}`, { method: 'DELETE' }),
};
export interface Scenario {
id: number;
external_id: string;

View file

@ -0,0 +1,208 @@
/**
* Retirement goals nested under a scenario. Inline list + add form.
*/
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { goalsApi, type Goal, type GoalCreateBody } from '@/api/client';
import { gbp, pct } from '@/lib/format';
const KIND_SUGGESTIONS = ['target_nw', 'never_run_out', 'inheritance', 'spending_floor'];
const COMPARATORS = ['>=', '>', '<=', '<', '='];
const EMPTY_FORM: GoalCreateBody = {
kind: 'target_nw',
name: '',
target_amount_gbp: '2000000',
target_year: 15,
comparator: '>=',
success_threshold: '0.95',
enabled: true,
};
export function GoalsSection({ scenarioId }: { scenarioId: number }) {
const goals = useQuery({
queryKey: ['scenarios', scenarioId, 'goals'],
queryFn: () => goalsApi.list(scenarioId),
});
return (
<div className="rounded-lg border border-slate-200 bg-white p-5">
<h2 className="text-lg font-semibold mb-4">Retirement goals</h2>
{goals.isLoading ? (
<p className="text-sm text-slate-500">Loading</p>
) : goals.isError ? (
<p className="text-sm text-red-700">Failed to load goals.</p>
) : (
<GoalsList goals={goals.data ?? []} />
)}
<AddGoalForm scenarioId={scenarioId} />
</div>
);
}
function GoalsList({ goals }: { goals: Goal[] }) {
const qc = useQueryClient();
const del = useMutation({
mutationFn: (id: number) => goalsApi.delete(id),
onSettled: () =>
qc.invalidateQueries({ predicate: (q) => q.queryKey.includes('goals') }),
});
if (goals.length === 0) {
return <p className="text-sm text-slate-500 mb-4">No goals yet.</p>;
}
return (
<ul className="space-y-2 mb-5">
{goals.map((g) => (
<li
key={g.id}
className="flex items-center justify-between rounded-md border border-slate-200 bg-slate-50 px-4 py-2 text-sm"
>
<div>
<div className="font-medium">{g.name}</div>
<div className="text-xs text-slate-500">
{g.kind}
{g.target_amount_gbp ? ` · ${g.comparator} ${gbp(g.target_amount_gbp)}` : ''}
{g.target_year !== null ? ` · year ${g.target_year}` : ''}
{' · threshold '}
{pct(g.success_threshold)}
</div>
</div>
<button
type="button"
onClick={() => del.mutate(g.id)}
disabled={del.isPending}
aria-label={`Delete ${g.name}`}
className="text-xs text-slate-500 hover:text-red-700 px-2 py-1 disabled:opacity-50"
>
Delete
</button>
</li>
))}
</ul>
);
}
function AddGoalForm({ scenarioId }: { scenarioId: number }) {
const [form, setForm] = useState<GoalCreateBody>(EMPTY_FORM);
const [err, setErr] = useState<string | null>(null);
const qc = useQueryClient();
const create = useMutation({
mutationFn: (body: GoalCreateBody) => goalsApi.create(scenarioId, body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['scenarios', scenarioId, 'goals'] });
setForm(EMPTY_FORM);
setErr(null);
},
onError: (e) => setErr(String((e as Error)?.message ?? e)),
});
const update = <K extends keyof GoalCreateBody>(k: K, v: GoalCreateBody[K]) =>
setForm((f) => ({ ...f, [k]: v }));
const onSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!form.name.trim()) {
setErr('Name required');
return;
}
setErr(null);
create.mutate(form);
};
return (
<form
onSubmit={onSubmit}
className="grid grid-cols-1 md:grid-cols-6 gap-3 items-end pt-3 border-t border-slate-100"
>
<label className="md:col-span-2 text-xs">
<span className="uppercase tracking-wide text-slate-500">Name</span>
<input
type="text"
value={form.name}
onChange={(e) => update('name', e.target.value)}
placeholder="≥ £2M at 50"
className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm"
/>
</label>
<label className="text-xs">
<span className="uppercase tracking-wide text-slate-500">Kind</span>
<input
type="text"
value={form.kind}
onChange={(e) => update('kind', e.target.value)}
list="goal-kind-suggestions"
className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm"
/>
<datalist id="goal-kind-suggestions">
{KIND_SUGGESTIONS.map((k) => (
<option key={k} value={k} />
))}
</datalist>
</label>
<label className="text-xs">
<span className="uppercase tracking-wide text-slate-500">Cmp</span>
<select
value={form.comparator ?? '>='}
onChange={(e) => update('comparator', e.target.value)}
className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm"
>
{COMPARATORS.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</label>
<label className="text-xs">
<span className="uppercase tracking-wide text-slate-500">Amount £</span>
<input
type="number"
value={Number(form.target_amount_gbp ?? 0)}
step={10000}
onChange={(e) => update('target_amount_gbp', String(e.target.value))}
className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm tabular-nums"
/>
</label>
<label className="text-xs">
<span className="uppercase tracking-wide text-slate-500">Year</span>
<input
type="number"
value={form.target_year ?? ''}
min={0}
max={100}
placeholder="—"
onChange={(e) =>
update('target_year', e.target.value === '' ? null : Number(e.target.value))
}
className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm tabular-nums"
/>
</label>
<label className="text-xs">
<span className="uppercase tracking-wide text-slate-500">Threshold</span>
<input
type="number"
value={Number(form.success_threshold ?? 0.95)}
min={0}
max={1}
step={0.05}
onChange={(e) => update('success_threshold', String(e.target.value))}
className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm tabular-nums"
/>
</label>
<div className="md:col-span-6 flex items-center gap-3">
<button
type="submit"
disabled={create.isPending}
className="rounded-md bg-slate-900 text-white text-sm font-medium px-3 py-1.5 hover:bg-slate-800 disabled:opacity-60"
>
{create.isPending ? 'Adding…' : 'Add goal'}
</button>
{err && <span className="text-xs text-red-700">{err}</span>}
</div>
</form>
);
}

View file

@ -0,0 +1,212 @@
/**
* Life events nested under a scenario. Inline list + add form.
*
* Event kinds are free-text on the backend; we suggest common ones via
* a datalist but let users type anything. Year range optional leave
* year_end blank for one-time events.
*/
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import {
lifeEventsApi,
type LifeEvent,
type LifeEventCreateBody,
} from '@/api/client';
import { gbp } from '@/lib/format';
const KIND_SUGGESTIONS = [
'retirement',
'partner_retirement',
'kid_born',
'kids_leave_home',
'mortgage_payoff',
'home_purchase',
'sabbatical',
'inheritance',
'expense_range',
'one_time_income',
];
const EMPTY_FORM: LifeEventCreateBody = {
kind: 'retirement',
name: '',
year_start: 0,
year_end: null,
delta_gbp_per_year: '0',
one_time_amount_gbp: null,
enabled: true,
};
export function LifeEventsSection({ scenarioId }: { scenarioId: number }) {
const events = useQuery({
queryKey: ['scenarios', scenarioId, 'life-events'],
queryFn: () => lifeEventsApi.list(scenarioId),
});
return (
<div className="rounded-lg border border-slate-200 bg-white p-5">
<h2 className="text-lg font-semibold mb-4">Life events</h2>
{events.isLoading ? (
<p className="text-sm text-slate-500">Loading</p>
) : events.isError ? (
<p className="text-sm text-red-700">Failed to load events.</p>
) : (
<EventsList events={events.data ?? []} />
)}
<AddEventForm scenarioId={scenarioId} />
</div>
);
}
function EventsList({ events }: { events: LifeEvent[] }) {
const qc = useQueryClient();
const del = useMutation({
mutationFn: (id: number) => lifeEventsApi.delete(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ['life-events'] }),
// also invalidate the scenario-scoped list:
onSettled: () => qc.invalidateQueries({ predicate: (q) => q.queryKey.includes('life-events') }),
});
if (events.length === 0) {
return <p className="text-sm text-slate-500 mb-4">No events yet.</p>;
}
return (
<ul className="space-y-2 mb-5">
{events.map((ev) => (
<li
key={ev.id}
className="flex items-center justify-between rounded-md border border-slate-200 bg-slate-50 px-4 py-2 text-sm"
>
<div>
<div className="font-medium">{ev.name}</div>
<div className="text-xs text-slate-500">
{ev.kind} · year {ev.year_start}
{ev.year_end !== null && ev.year_end !== ev.year_start ? `${ev.year_end}` : ''}
{Number(ev.delta_gbp_per_year) !== 0 ? ` · ${gbp(ev.delta_gbp_per_year)}/y` : ''}
{ev.one_time_amount_gbp ? ` · one-time ${gbp(ev.one_time_amount_gbp)}` : ''}
</div>
</div>
<button
type="button"
onClick={() => del.mutate(ev.id)}
disabled={del.isPending}
aria-label={`Delete ${ev.name}`}
className="text-xs text-slate-500 hover:text-red-700 px-2 py-1 disabled:opacity-50"
>
Delete
</button>
</li>
))}
</ul>
);
}
function AddEventForm({ scenarioId }: { scenarioId: number }) {
const [form, setForm] = useState<LifeEventCreateBody>(EMPTY_FORM);
const [err, setErr] = useState<string | null>(null);
const qc = useQueryClient();
const create = useMutation({
mutationFn: (body: LifeEventCreateBody) => lifeEventsApi.create(scenarioId, body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['scenarios', scenarioId, 'life-events'] });
setForm(EMPTY_FORM);
setErr(null);
},
onError: (e) => setErr(String((e as Error)?.message ?? e)),
});
const update = <K extends keyof LifeEventCreateBody>(k: K, v: LifeEventCreateBody[K]) =>
setForm((f) => ({ ...f, [k]: v }));
const onSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!form.name.trim()) {
setErr('Name required');
return;
}
if (form.year_end !== null && form.year_end !== undefined && form.year_end < form.year_start) {
setErr('year_end must be ≥ year_start');
return;
}
setErr(null);
create.mutate(form);
};
return (
<form onSubmit={onSubmit} className="grid grid-cols-1 md:grid-cols-6 gap-3 items-end pt-3 border-t border-slate-100">
<label className="md:col-span-2 text-xs">
<span className="uppercase tracking-wide text-slate-500">Name</span>
<input
type="text"
value={form.name}
onChange={(e) => update('name', e.target.value)}
placeholder="Retire at 50"
className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm"
/>
</label>
<label className="text-xs">
<span className="uppercase tracking-wide text-slate-500">Kind</span>
<input
type="text"
value={form.kind}
onChange={(e) => update('kind', e.target.value)}
list="event-kind-suggestions"
className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm"
/>
<datalist id="event-kind-suggestions">
{KIND_SUGGESTIONS.map((k) => (
<option key={k} value={k} />
))}
</datalist>
</label>
<label className="text-xs">
<span className="uppercase tracking-wide text-slate-500">Year start</span>
<input
type="number"
value={form.year_start}
min={0}
max={100}
onChange={(e) => update('year_start', Number(e.target.value))}
className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm tabular-nums"
/>
</label>
<label className="text-xs">
<span className="uppercase tracking-wide text-slate-500">Year end</span>
<input
type="number"
value={form.year_end ?? ''}
min={0}
max={100}
placeholder="—"
onChange={(e) =>
update('year_end', e.target.value === '' ? null : Number(e.target.value))
}
className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm tabular-nums"
/>
</label>
<label className="text-xs">
<span className="uppercase tracking-wide text-slate-500">£ / year</span>
<input
type="number"
value={Number(form.delta_gbp_per_year ?? 0)}
step={1000}
onChange={(e) => update('delta_gbp_per_year', String(e.target.value))}
className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm tabular-nums"
/>
</label>
<div className="md:col-span-6 flex items-center gap-3">
<button
type="submit"
disabled={create.isPending}
className="rounded-md bg-slate-900 text-white text-sm font-medium px-3 py-1.5 hover:bg-slate-800 disabled:opacity-60"
>
{create.isPending ? 'Adding…' : 'Add event'}
</button>
{err && <span className="text-xs text-red-700">{err}</span>}
</div>
</form>
);
}

View file

@ -10,6 +10,8 @@ import { Link, useNavigate, useParams } from 'react-router-dom';
import { api } from '@/api/client';
import { ApiError } from '@/api/client';
import { FanChart } from '@/components/FanChart';
import { GoalsSection } from '@/components/GoalsSection';
import { LifeEventsSection } from '@/components/LifeEventsSection';
import { gbp, pct } from '@/lib/format';
export function ScenarioDetail() {
@ -141,6 +143,9 @@ export function ScenarioDetail() {
{String((proj.error as Error)?.message ?? proj.error)}
</div>
)}
<LifeEventsSection scenarioId={id} />
<GoalsSection scenarioId={id} />
</section>
);
}