whatif: live data refresh, inflation-adjusted spending, legend fix
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

Three follow-ups to the actualbudget integration:

**Always-fresh autofill.** Drop the one-shot `*AutoFilled` boolean
gates; replace with `nwUserEdited` / `spendingUserEdited` flags. Until
the user types into either field, every refetch (mount, window
focus) updates the form value. Once they edit, we leave it alone. A
small ↻ button next to each anchor input flips the edited flag back
off so the user can re-snap to live data on demand. React Query
configured with staleTime=0 + refetchOnMount='always' +
refetchOnWindowFocus=true so the cache never serves stale numbers.
NW provenance shows the snapshot date.

**Inflation-adjusted spending.** Backend now revalues each trailing
month's nominal pence forward to today's £ using monthly compounding
of `inflation_pct` (default 0.03 ≈ UK CPI 2024-26). Headline
`total_gbp` is the real-£ figure — matches the simulator's
real-GBP convention. Response also includes `nominal_total_gbp` and
`inflation_pct` for transparency. New /spending/annual?inflation_pct=
override param. 10/10 actualbudget tests pass.

**FanChart legend.** The bottom-anchored legend was overlapping the
x-axis label. Moved to top: 8 with itemGap=18 + type=scroll for
narrow viewports; bumped grid top→48 / bottom→56 + xAxis nameGap→28
so nothing collides.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Viktor Barzin 2026-05-10 11:27:22 +00:00
parent 3bfa46ad4f
commit e12e8f9290
8 changed files with 263 additions and 68 deletions

View file

@ -94,8 +94,10 @@ export interface AnnualSpending {
window_start: string;
window_end: string;
excluded_groups: string[];
total_gbp: string;
raw_total_gbp: string;
inflation_pct: string;
total_gbp: string; // real, after exclusions (the autofill default)
nominal_total_gbp: string; // not adjusted, after exclusions
raw_total_gbp: string; // nominal, before exclusions
by_group_total_gbp: Record<string, string>;
monthly: Array<{
month: string;

View file

@ -135,7 +135,9 @@ function buildFan(yearly: ProjectionPoint[], showWithdrawal: boolean): EChartsOp
}
return {
grid: { left: 60, right: showWithdrawal ? 70 : 24, top: 30, bottom: 40 },
// Grid: leave room above for the legend (top:48) and below for the
// x-axis name label so neither collides with the chart area.
grid: { left: 60, right: showWithdrawal ? 70 : 24, top: 48, bottom: 56 },
tooltip: {
trigger: 'axis',
formatter: (params) => {
@ -157,7 +159,16 @@ function buildFan(yearly: ProjectionPoint[], showWithdrawal: boolean): EChartsOp
},
},
legend: {
bottom: 0,
// Above the chart, centred — out of the way of the x-axis label.
// `type: 'scroll'` lets ECharts paginate items on narrow viewports
// instead of wrapping them into the chart area.
top: 8,
left: 'center',
type: 'scroll',
itemGap: 18,
itemWidth: 14,
itemHeight: 10,
textStyle: { fontSize: 11, color: '#475569' },
data: [
'median',
'p10',
@ -171,7 +182,8 @@ function buildFan(yearly: ProjectionPoint[], showWithdrawal: boolean): EChartsOp
data: years,
name: 'years from now',
nameLocation: 'middle',
nameGap: 24,
nameGap: 28,
nameTextStyle: { color: '#64748b' },
},
yAxis: [
{

View file

@ -98,38 +98,57 @@ const DEFAULTS: SimulateRequest = {
export function WhatIf() {
const [form, setForm] = useState<SimulateRequest>(DEFAULTS);
const [nwAutoFilled, setNwAutoFilled] = useState(false);
const [spendingAutoFilled, setSpendingAutoFilled] = useState(false);
// Track which anchor numbers the user has *manually* edited. Until
// they touch a field, we keep mirroring whatever the backend says is
// current — so opening /what-if a week later picks up fresh net
// worth + spending without forcing a full refresh. Once the user
// types, we stop clobbering. The ↻ button next to each input flips
// the flag back off so the user can re-snap to live data on demand.
const [nwUserEdited, setNwUserEdited] = useState(false);
const [spendingUserEdited, setSpendingUserEdited] = useState(false);
const navigate = useNavigate();
const qc = useQueryClient();
// Pre-fill NW seed from the latest Wealthfolio snapshot the first
// time it loads, so opening /what-if always starts from real numbers.
// The user can still edit; we won't clobber their input on later refetches.
const nw = useQuery({ queryKey: ['networth', 'current'], queryFn: api.networth.current });
// Always-fresh queries: refetchOnMount + refetchOnWindowFocus mean
// the page reflects the latest snapshot whenever the tab regains
// focus. staleTime=0 ensures every mount triggers a network fetch.
const nw = useQuery({
queryKey: ['networth', 'current'],
queryFn: api.networth.current,
staleTime: 0,
refetchOnMount: 'always',
refetchOnWindowFocus: true,
});
useEffect(() => {
if (nwAutoFilled || !nw.data || nw.data.accounts.length === 0) return;
if (nwUserEdited || !nw.data || nw.data.accounts.length === 0) return;
const rounded = String(Math.round(Number(nw.data.total_gbp)));
setForm((f) => ({ ...f, nw_seed_gbp: rounded }));
setNwAutoFilled(true);
}, [nw.data, nwAutoFilled]);
setForm((f) => (f.nw_seed_gbp === rounded ? f : { ...f, nw_seed_gbp: rounded }));
}, [nw.data, nwUserEdited]);
// Pre-fill annual spending from actualbudget trailing 12 months,
// excluding investment/savings transfers (the default exclusion on
// the backend). Fails silently if the upstream is down — we keep
// the hardcoded DEFAULTS value in that case.
const spending = useQuery({
queryKey: ['spending', 'annual', 12],
queryFn: () => api.spending.annual(12),
retry: false,
staleTime: 0,
refetchOnMount: 'always',
refetchOnWindowFocus: true,
});
useEffect(() => {
if (spendingAutoFilled || !spending.data) return;
if (spendingUserEdited || !spending.data) return;
const total = Number(spending.data.total_gbp);
if (!Number.isFinite(total) || total <= 0) return;
setForm((f) => ({ ...f, spending_gbp: String(Math.round(total)) }));
setSpendingAutoFilled(true);
}, [spending.data, spendingAutoFilled]);
const rounded = String(Math.round(total));
setForm((f) => (f.spending_gbp === rounded ? f : { ...f, spending_gbp: rounded }));
}, [spending.data, spendingUserEdited]);
const resetNw = () => {
setNwUserEdited(false);
void nw.refetch();
};
const resetSpending = () => {
setSpendingUserEdited(false);
void spending.refetch();
};
const sim = useMutation({
mutationFn: (req: SimulateRequest) => api.simulate(req),
@ -192,7 +211,24 @@ export function WhatIf() {
<div className="grid grid-cols-1 lg:grid-cols-[480px_1fr] gap-6 items-start">
<form onSubmit={onSubmit} className="space-y-4 sticky top-4">
<AnchorNumbers form={form} update={update} spending={spending.data} />
<AnchorNumbers
form={form}
update={update}
spending={spending.data}
nwSnapshotDate={nw.data?.snapshot_date}
onNwChange={(v) => {
setNwUserEdited(true);
update('nw_seed_gbp', v);
}}
onSpendingChange={(v) => {
setSpendingUserEdited(true);
update('spending_gbp', v);
}}
nwUserEdited={nwUserEdited}
spendingUserEdited={spendingUserEdited}
onResetNw={resetNw}
onResetSpending={resetSpending}
/>
<PlanCard form={form} update={update} strategy={strategy} />
@ -259,28 +295,58 @@ function AnchorNumbers({
form,
update,
spending,
nwSnapshotDate,
onNwChange,
onSpendingChange,
nwUserEdited,
spendingUserEdited,
onResetNw,
onResetSpending,
}: {
form: SimulateRequest;
update: <K extends keyof SimulateRequest>(k: K, v: SimulateRequest[K]) => void;
spending: AnnualSpending | undefined;
nwSnapshotDate: string | undefined;
onNwChange: (v: string) => void;
onSpendingChange: (v: string) => void;
nwUserEdited: boolean;
spendingUserEdited: boolean;
onResetNw: () => void;
onResetSpending: () => void;
}) {
return (
<div className="rounded-lg border border-slate-200 bg-white p-5 shadow-sm">
<div className="grid grid-cols-[1.4fr_1.2fr_0.7fr] gap-3">
<BigNumber
label="NW seed"
prefix="£"
value={form.nw_seed_gbp}
onChange={(v) => update('nw_seed_gbp', v)}
/>
<div className="min-w-0">
<BigNumber
label="NW seed"
prefix="£"
value={form.nw_seed_gbp}
onChange={onNwChange}
trailing={nwUserEdited ? (
<ResetButton title="Snap back to live Wealthfolio NW" onClick={onResetNw} />
) : undefined}
/>
{nwSnapshotDate && (
<p className="mt-1 text-[10px] text-slate-500 leading-tight">
wealthfolio · as of {nwSnapshotDate}
{nwUserEdited && <span className="text-amber-700"> · edited</span>}
</p>
)}
</div>
<div className="min-w-0">
<BigNumber
label="Annual spending"
prefix="£"
value={form.spending_gbp}
onChange={(v) => update('spending_gbp', v)}
onChange={onSpendingChange}
trailing={spendingUserEdited ? (
<ResetButton title="Snap back to live budget total" onClick={onResetSpending} />
) : undefined}
/>
{spending && <SpendingProvenance data={spending} />}
{spending && (
<SpendingProvenance data={spending} edited={spendingUserEdited} />
)}
</div>
<BigNumber
label="Horizon"
@ -294,15 +360,35 @@ function AnchorNumbers({
);
}
function SpendingProvenance({ data }: { data: AnnualSpending }) {
function ResetButton({ title, onClick }: { title: string; onClick: () => void }) {
return (
<button
type="button"
title={title}
aria-label={title}
onClick={onClick}
className="inline-flex h-5 w-5 items-center justify-center rounded-full text-slate-400 hover:text-slate-900 hover:bg-slate-100 focus:outline-none focus:ring-2 focus:ring-slate-400"
>
<span className="text-xs leading-none"></span>
</button>
);
}
function SpendingProvenance({
data,
edited,
}: {
data: AnnualSpending;
edited: boolean;
}) {
const inflPct = (Number(data.inflation_pct) * 100).toFixed(1);
return (
<p className="mt-1 text-[10px] text-slate-500 leading-tight">
from budget · {data.window_start}{data.window_end}
budget · {data.window_start}{data.window_end} · real £ (+{inflPct}%/yr)
{data.excluded_groups.length > 0 && (
<>
{' '}· excl. {data.excluded_groups.join(', ')}
</>
<> · excl. {data.excluded_groups.join(', ')}</>
)}
{edited && <span className="text-amber-700"> · edited</span>}
</p>
);
}
@ -545,6 +631,7 @@ function BigNumber({
prefix,
suffix,
step = 1,
trailing,
}: {
label: string;
value: string;
@ -552,12 +639,16 @@ function BigNumber({
prefix?: string;
suffix?: string;
step?: number;
trailing?: React.ReactNode;
}) {
return (
<label className="block min-w-0">
<span className="text-[11px] uppercase tracking-wide text-slate-500 font-medium">
{label}
</span>
<div className="flex items-center justify-between gap-1">
<span className="text-[11px] uppercase tracking-wide text-slate-500 font-medium">
{label}
</span>
{trailing}
</div>
<div className="mt-1 relative">
{prefix && (
<span className="absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-400 text-base pointer-events-none">