whatif: live data refresh, inflation-adjusted spending, legend fix
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
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:
parent
3bfa46ad4f
commit
e12e8f9290
8 changed files with 263 additions and 68 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue