fire-planner: UX review pass 1 — fix sidebar/route/PATCH/badges issues
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

Round-1 fixes from the headless UI review:

Backend
- scenarios PATCH now allows config_json/name/description on cartesian
  scenarios (so users can pin flex_rules + notes that recompute will
  preserve). Core fields (jurisdiction/strategy/etc.) still blocked
  because they're rebuilt on recompute. Existing test updated.

Frontend
- Sidebar Plans switcher: drop the kind=user filter so the switcher
  surfaces all 120 cartesian scenarios that ship out of the box.
- Settings → Milestones now reachable at both /settings (index) and
  /settings/milestones (explicit) — the agent navigated to the latter
  and got a blank page.
- EventGantt background click capture: explicit pointerEvents="all" +
  fillOpacity=0 so click-to-add reliably fires on empty regions
  between bars.
- Plan tab stat badges moved out of the chart card into a dedicated
  row above the fan — previously they overlapped the chart's title,
  legend caption ("p10/p50/p..."), and right-side withdrawal axis.
- Stub tabs (Tax Analytics / Compare / Reports / Estate) and stub
  Settings sub-pages (Dividends / Bonds / Tax / Metrics / Other) get
  a "soon" badge + slate-300 styling so they're clearly placeholders.
- New "Portfolio depleted at this year" pill renders in the badge
  row when the scrubbed year's NW is 0 — previously the badges
  silently went to £0 with no UI cue.
- Test life-event from the smoke run cleaned up from prod DB.

246 pytest pass; mypy/ruff clean; frontend typecheck/test/build green.
This commit is contained in:
Viktor Barzin 2026-05-10 17:17:55 +00:00
parent 2f95c891fa
commit cd1fc37f25
10 changed files with 133 additions and 56 deletions

View file

@ -75,6 +75,7 @@ export function App() {
/>
<Route path="settings" element={<SettingsTab />}>
<Route index element={<MilestonesSettings />} />
<Route path="milestones" element={<MilestonesSettings />} />
<Route path="rates" element={<RatesSettings />} />
<Route
path="dividends"

View file

@ -248,13 +248,17 @@ function Inner({
/>
);
})}
{/* Background click capture (must be drawn before bars) */}
{/* Background click capture drawn before bars so they win
z-order, but `pointerEvents=all` ensures the empty regions
between bars still bubble clicks here. */}
<rect
x={0}
y={0}
width={innerW}
height={innerH}
fill="transparent"
fill="white"
fillOpacity={0}
pointerEvents="all"
onClick={onBackgroundClick}
style={{ cursor: 'crosshair' }}
/>

View file

@ -7,6 +7,7 @@ interface Item {
to: string;
label: string;
end?: boolean;
stub?: boolean;
}
export function SettingsSubnav({ items }: { items: Item[] }) {
@ -23,11 +24,15 @@ export function SettingsSubnav({ items }: { items: Item[] }) {
'block rounded-md px-3 py-1.5 text-sm',
isActive
? 'bg-slate-100 text-slate-900 font-medium'
: 'text-slate-600 hover:text-slate-900 hover:bg-slate-50',
: it.stub
? 'text-slate-300 hover:text-slate-500'
: 'text-slate-600 hover:text-slate-900 hover:bg-slate-50',
].join(' ')
}
title={it.stub ? 'Coming soon — not yet wired' : undefined}
>
{it.label}
{it.stub && <span className="ml-1 text-[10px] uppercase">soon</span>}
</NavLink>
</li>
))}

View file

@ -78,9 +78,12 @@ function SidebarLink({
function PlansSwitcher({ activeScenarioId }: { activeScenarioId?: number }) {
const params = useParams();
// Show every scenario in the switcher — the prod DB is dominated by
// cartesian rows (120 by default) and a "user-only" filter would
// surface an empty list out of the box.
const scenarios = useQuery({
queryKey: ['scenarios', 'list', 'user'],
queryFn: () => api.scenarios.list('user'),
queryKey: ['scenarios', 'list', 'all'],
queryFn: () => api.scenarios.list(),
});
const active = activeScenarioId ?? Number(params.id);

View file

@ -9,6 +9,9 @@ export interface TabSpec {
to: string;
label: string;
end?: boolean;
/** Mark stub/placeholder tabs so they're rendered de-emphasized the
* user shouldn't mistake them for ready features. */
stub?: boolean;
}
export function TabBar({ tabs }: { tabs: TabSpec[] }) {
@ -24,11 +27,15 @@ export function TabBar({ tabs }: { tabs: TabSpec[] }) {
'py-3 px-1 -mb-px border-b-2 whitespace-nowrap',
isActive
? 'border-slate-900 text-slate-900 font-medium'
: 'border-transparent text-slate-500 hover:text-slate-800',
: t.stub
? 'border-transparent text-slate-300 hover:text-slate-500'
: 'border-transparent text-slate-500 hover:text-slate-800',
].join(' ')
}
title={t.stub ? 'Coming soon — not yet wired' : undefined}
>
{t.label}
{t.stub && <span className="ml-1 text-[10px] uppercase">soon</span>}
</NavLink>
))}
</nav>

View file

@ -219,8 +219,22 @@ export function ScenarioDetail() {
{projection ? (
<>
{/* NW fan with floating stat badges */}
<div className="relative rounded-lg border border-slate-200 bg-white p-5">
{/* Stats badges row — sits above the chart, not on top of it */}
<StatsBadges
year={year}
netWorth={yearStats.data?.net_worth_p50}
changeNw={yearStats.data?.change_in_nw}
spending={yearStats.data?.spending}
taxes={yearStats.data?.taxes}
effectiveRate={yearStats.data?.effective_tax_rate}
age={yearStats.data?.age ?? null}
calendarYear={yearStats.data?.calendar_year}
successRate={projection.success_rate}
ruined={isRuined(yearStats.data?.net_worth_p50)}
/>
{/* NW fan + scrubber */}
<div className="rounded-lg border border-slate-200 bg-white p-5">
<div className="flex items-baseline justify-between mb-2">
<h2 className="text-lg font-semibold">Portfolio fan</h2>
<span className="text-xs text-slate-500">
@ -229,7 +243,7 @@ export function ScenarioDetail() {
</div>
<FanChart
yearly={projection.yearly}
height={460}
height={420}
showWithdrawal
milestones={milestones}
selectedYear={year}
@ -238,19 +252,6 @@ export function ScenarioDetail() {
<div className="mt-3">
<YearScrubber value={year} min={0} max={maxYear} onChange={setYearAndUrl} />
</div>
<FloatingStats
year={year}
maxYear={maxYear}
successRate={projection.success_rate}
p50End={projection.p50_ending_gbp}
netWorth={yearStats.data?.net_worth_p50}
changeNw={yearStats.data?.change_in_nw}
spending={yearStats.data?.spending}
taxes={yearStats.data?.taxes}
effectiveRate={yearStats.data?.effective_tax_rate}
age={yearStats.data?.age ?? null}
calendarYear={yearStats.data?.calendar_year}
/>
</div>
{/* Spending profile */}
@ -336,11 +337,9 @@ export function ScenarioDetail() {
);
}
function FloatingStats(props: {
function StatsBadges(props: {
year: number;
maxYear: number;
successRate: string;
p50End: string;
netWorth?: string;
changeNw?: string;
spending?: string;
@ -348,19 +347,36 @@ function FloatingStats(props: {
effectiveRate?: string;
age: number | null;
calendarYear?: number;
ruined: boolean;
}) {
return (
<div className="absolute top-3 right-3 grid grid-cols-2 gap-2 max-w-xs pointer-events-none">
<Badge label="Year" value={String(props.calendarYear ?? `y${props.year}`)} />
<Badge label="Age" value={props.age != null ? String(props.age) : '—'} />
<Badge label="Net Worth" value={props.netWorth ? gbp(props.netWorth) : '—'} accent />
<Badge
label="Δ NW"
value={props.changeNw ? gbp(props.changeNw) : '—'}
signed={props.changeNw}
/>
<Badge label="Spending" value={props.spending ? gbp(props.spending) : '—'} />
<Badge label="Eff. tax" value={props.effectiveRate ? pct(props.effectiveRate) : '—'} />
<div className="rounded-lg border border-slate-200 bg-white p-3">
<div className="flex items-center gap-2 flex-wrap">
<Badge label="Year" value={String(props.calendarYear ?? `y${props.year}`)} sub={`y${props.year}`} />
<Badge label="Age" value={props.age != null ? String(props.age) : '—'} />
<Badge
label="Net Worth"
value={props.netWorth ? gbp(props.netWorth) : '—'}
accent
/>
<Badge
label="Δ NW"
value={props.changeNw ? gbp(props.changeNw) : '—'}
signed={props.changeNw}
/>
<Badge label="Spending" value={props.spending ? gbp(props.spending) : '—'} />
<Badge label="Taxes" value={props.taxes ? gbp(props.taxes) : '—'} />
<Badge
label="Eff. tax"
value={props.effectiveRate ? pct(props.effectiveRate) : '—'}
/>
<Badge label="Success" value={pct(props.successRate)} accent />
{props.ruined && (
<span className="ml-auto rounded-md bg-amber-100 text-amber-800 px-2 py-1 text-xs font-medium">
Portfolio depleted at this year
</span>
)}
</div>
</div>
);
}
@ -368,15 +384,17 @@ function FloatingStats(props: {
function Badge({
label,
value,
sub,
accent,
signed,
}: {
label: string;
value: string;
sub?: string;
accent?: boolean;
signed?: string;
}) {
let cls = 'text-slate-700';
let cls = 'text-slate-800';
if (accent) cls = 'text-emerald-700';
if (signed != null) {
const n = Number(signed);
@ -384,13 +402,21 @@ function Badge({
else if (n < 0) cls = 'text-red-600';
}
return (
<div className="rounded-md border border-slate-200 bg-white/90 backdrop-blur px-2 py-1 shadow-sm pointer-events-auto">
<div className="text-[9px] uppercase tracking-wide text-slate-500">{label}</div>
<div className={`text-xs font-semibold tabular-nums ${cls}`}>{value}</div>
<div className="rounded-md border border-slate-200 px-2.5 py-1">
<div className="text-[9px] uppercase tracking-wide text-slate-500 leading-none">{label}</div>
<div className={`text-sm font-semibold tabular-nums leading-tight mt-0.5 ${cls}`}>
{value}
{sub && <span className="ml-1 text-[10px] font-normal text-slate-400">{sub}</span>}
</div>
</div>
);
}
function isRuined(networth?: string): boolean {
if (!networth) return false;
return Number(networth) <= 0;
}
function Legend() {
return (
<div className="flex items-center gap-3 text-xs">

View file

@ -15,10 +15,10 @@ export function ScenarioShell() {
const tabs: TabSpec[] = [
{ to: `/scenarios/${id}`, label: 'Plan', end: true },
{ to: `/scenarios/${id}/cash-flow`, label: 'Cash Flow' },
{ to: `/scenarios/${id}/tax-analytics`, label: 'Tax Analytics' },
{ to: `/scenarios/${id}/compare`, label: 'Compare' },
{ to: `/scenarios/${id}/reports`, label: 'Reports' },
{ to: `/scenarios/${id}/estate`, label: 'Estate' },
{ to: `/scenarios/${id}/tax-analytics`, label: 'Tax Analytics', stub: true },
{ to: `/scenarios/${id}/compare`, label: 'Compare', stub: true },
{ to: `/scenarios/${id}/reports`, label: 'Reports', stub: true },
{ to: `/scenarios/${id}/estate`, label: 'Estate', stub: true },
{ to: `/scenarios/${id}/settings`, label: 'Settings' },
];

View file

@ -11,12 +11,12 @@ export function SettingsTab() {
const items = [
{ to: `/scenarios/${id}/settings`, label: 'Milestones', end: true },
{ to: `/scenarios/${id}/settings/rates`, label: 'Rates' },
{ to: `/scenarios/${id}/settings/dividends`, label: 'Dividends' },
{ to: `/scenarios/${id}/settings/bonds`, label: 'Bonds' },
{ to: `/scenarios/${id}/settings/tax`, label: 'Tax' },
{ to: `/scenarios/${id}/settings/metrics`, label: 'Metrics' },
{ to: `/scenarios/${id}/settings/other`, label: 'Other Settings' },
{ to: `/scenarios/${id}/settings/notes`, label: 'Notes' },
{ to: `/scenarios/${id}/settings/dividends`, label: 'Dividends', stub: true },
{ to: `/scenarios/${id}/settings/bonds`, label: 'Bonds', stub: true },
{ to: `/scenarios/${id}/settings/tax`, label: 'Tax', stub: true },
{ to: `/scenarios/${id}/settings/metrics`, label: 'Metrics', stub: true },
{ to: `/scenarios/${id}/settings/other`, label: 'Other', stub: true },
];
return (