fire-planner: UX review pass 1 — fix sidebar/route/PATCH/badges issues
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
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:
parent
2f95c891fa
commit
cd1fc37f25
10 changed files with 133 additions and 56 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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' }}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue