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

@ -127,25 +127,49 @@ def _today() -> date:
return date.today()
def _months_between(older: str, newer: str) -> int:
"""Calendar months from `older` to `newer` (both YYYY-MM)."""
yo, mo = (int(p) for p in older.split("-"))
yn, mn = (int(p) for p in newer.split("-"))
return (yn - yo) * 12 + (mn - mo)
def _inflation_factor(months_age: int, annual_inflation_pct: float) -> float:
"""Multiplier to convert nominal £ at a point `months_age` months
in the past into today's real £. Compounds monthly so the curve
is smooth, not stair-stepped."""
if months_age <= 0:
return 1.0
monthly = (1.0 + annual_inflation_pct) ** (1.0 / 12.0)
return float(monthly ** months_age)
async def fetch_trailing_spending(
months: int,
exclude_groups: frozenset[str],
annual_inflation_pct: float = 0.03,
client_factory: ActualBudgetClient | None = None,
today: date | None = None,
) -> tuple[list[MonthSpend], Decimal]:
"""Fetch trailing-N-months spending and return (per-month breakdown,
total in £).
) -> tuple[list[MonthSpend], Decimal, Decimal]:
"""Fetch trailing-N-months spending and return (per-month
breakdown, nominal-£ total, today's-£ inflation-adjusted total).
`exclude_groups` is a set of category-group names whose spending
should NOT count towards the consumption total typical example
is "Investments and Savings", which is a wealth transfer rather
than consumption.
Months are fetched concurrently. Missing months (e.g. user joined
actualbudget mid-year) come back with empty `by_group`.
Months older than the current one are revalued to today's £ using
`annual_inflation_pct` compounded monthly. The fire-planner
simulator works entirely in REAL GBP, so the real-£ total is the
one to feed back as the "Annual spending" default.
Missing months (e.g. user joined actualbudget mid-year) come back
with empty `by_group`.
"""
cli = client_factory or ActualBudgetClient()
target_months = _trailing_months(today or _today(), months)
today_d = today or _today()
target_months = _trailing_months(today_d, months)
available_set = None # only fetch if the API surfaces fewer months than asked
async with httpx.AsyncClient() as http:
@ -159,8 +183,18 @@ async def fetch_trailing_spending(
for m in kept:
spends.append(await cli.fetch_month(http, m))
total_pence = sum(
amt for ms in spends for name, amt in ms.by_group.items() if name not in exclude_groups
)
total_gbp = (Decimal(total_pence) / Decimal(100)).quantize(Decimal("0.01"))
return spends, total_gbp
today_month = f"{today_d.year:04d}-{today_d.month:02d}"
nominal_pence = 0
real_pence_float = 0.0
for ms in spends:
age = _months_between(ms.month, today_month)
factor = _inflation_factor(age, annual_inflation_pct)
for name, amt in ms.by_group.items():
if name in exclude_groups:
continue
nominal_pence += amt
real_pence_float += amt * factor
nominal_gbp = (Decimal(nominal_pence) / Decimal(100)).quantize(Decimal("0.01"))
real_gbp = (Decimal(str(round(real_pence_float / 100, 2)))).quantize(Decimal("0.01"))
return spends, nominal_gbp, real_gbp

View file

@ -147,18 +147,28 @@ class SpendingMonth(BaseModel):
class AnnualSpending(BaseModel):
"""Aggregated trailing-N-month spending pulled from actualbudget.
`total_gbp` is what we suggest as the "Annual spending" default in
the WhatIf form: the sum of all category groups across the trailing
window, *minus* whichever groups the caller asked to exclude
(default: investments/savings transfers, which aren't consumption).
`total_gbp` is the headline figure used as the "Annual spending"
default in the WhatIf form. It is **inflation-adjusted to today's
£** (each month's nominal pence revalued forward by
`inflation_pct` compounded monthly), matching the simulator's
real-£ convention.
`nominal_total_gbp` is the same window without inflation
adjustment for transparency / comparison.
`raw_total_gbp` is the nominal sum *including* groups that were
excluded (e.g. investment transfers) useful when you want to
see your full cash outflow.
"""
months: int
window_start: str # "YYYY-MM" (oldest month included)
window_end: str # "YYYY-MM" (newest)
excluded_groups: list[str]
total_gbp: Decimal # the headline number
raw_total_gbp: Decimal # before exclusions, for transparency
by_group_total_gbp: dict[str, Decimal] # 12-mo group sums (incl. excluded)
inflation_pct: Decimal # annual rate applied
total_gbp: Decimal # inflation-adjusted, after exclusions
nominal_total_gbp: Decimal # not adjusted, after exclusions
raw_total_gbp: Decimal # nominal, before exclusions
by_group_total_gbp: dict[str, Decimal] # nominal 12-mo group sums (incl. excluded)
monthly: list[SpendingMonth]

View file

@ -41,6 +41,15 @@ async def annual_spending(
f"from the headline total. Defaults to: {','.join(DEFAULT_EXCLUDE_GROUPS)}."
),
),
inflation_pct: float = Query(
default=0.03, ge=0.0, le=0.30,
description=(
"Annual inflation rate used to revalue past months' spending "
"into today's £. The simulator runs in real GBP, so the headline "
"`total_gbp` is the inflation-adjusted (real) figure. Default ≈ "
"UK CPI 2024-26."
),
),
) -> AnnualSpending:
excluded = (
[g.strip() for g in exclude.split(",") if g.strip()]
@ -48,9 +57,10 @@ async def annual_spending(
else list(DEFAULT_EXCLUDE_GROUPS)
)
try:
spends, total_gbp = await fetch_trailing_spending(
spends, nominal_gbp, real_gbp = await fetch_trailing_spending(
months=months,
exclude_groups=frozenset(excluded),
annual_inflation_pct=inflation_pct,
)
except Exception as e:
log.exception("actualbudget unreachable")
@ -93,8 +103,10 @@ async def annual_spending(
window_start=monthly[0].month,
window_end=monthly[-1].month,
excluded_groups=excluded,
total_gbp=total_gbp,
total_gbp=real_gbp,
nominal_total_gbp=nominal_gbp,
raw_total_gbp=raw_total_gbp,
inflation_pct=Decimal(str(inflation_pct)),
by_group_total_gbp=by_group_gbp,
monthly=monthly,
)

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">

View file

@ -100,15 +100,44 @@ async def test_fetch_trailing_spending_excludes_groups(respx_mock: respx.MockRou
}
}))
spends, total_gbp = await fetch_trailing_spending(
spends, nominal_gbp, real_gbp = await fetch_trailing_spending(
months=2,
exclude_groups=frozenset(["Investments and Savings"]),
annual_inflation_pct=0.0, # no adjustment for the simple assertion
client_factory=_client(),
today=date(2025, 3, 15),
)
assert [ms.month for ms in spends] == ["2025-01", "2025-02"]
# Usual: 1000 + 2000 = 3000 pence = £30
assert total_gbp == Decimal("30.00")
assert nominal_gbp == Decimal("30.00")
assert real_gbp == Decimal("30.00") # 0% inflation → same number
@pytest.mark.asyncio
async def test_fetch_trailing_spending_inflation_adjusts(respx_mock: respx.MockRouter) -> None:
"""At 12% annual inflation, a £10 expense from 12 months ago is
worth £11.20 in today's £. Sanity-check the compounding via the
oldest month of a 12-month window (today=2025-04 2024-04 is 12mo
old)."""
# Only one month available in the window.
respx_mock.get(f"{API_URL}/v1/budgets/{SYNC_ID}/months").mock(
return_value=httpx.Response(200, json={"data": ["2024-04"]}))
respx_mock.get(f"{API_URL}/v1/budgets/{SYNC_ID}/months/2024-04").mock(
return_value=httpx.Response(200, json={
"data": {"categoryGroups": [
{"name": "Usual Expenses", "is_income": False,
"categories": [{"spent": -1000}]}, # £10
]}
}))
_, nominal_gbp, real_gbp = await fetch_trailing_spending(
months=12,
exclude_groups=frozenset(),
annual_inflation_pct=0.12,
client_factory=_client(),
today=date(2025, 4, 15),
)
assert nominal_gbp == Decimal("10.00")
assert real_gbp == Decimal("11.20")
@pytest.mark.asyncio
@ -126,11 +155,12 @@ async def test_fetch_trailing_spending_drops_unavailable_months(
"categories": [{"spent": -5000}]},
]}
}))
spends, total_gbp = await fetch_trailing_spending(
spends, nominal_gbp, _real_gbp = await fetch_trailing_spending(
months=3,
exclude_groups=frozenset(),
annual_inflation_pct=0.0,
client_factory=_client(),
today=date(2025, 3, 15),
)
assert [ms.month for ms in spends] == ["2025-02"]
assert total_gbp == Decimal("50.00")
assert nominal_gbp == Decimal("50.00")

View file

@ -69,18 +69,20 @@ async def test_spending_annual_excludes_investments_by_default(client: AsyncClie
_mock_two_months(rmock)
# The endpoint asks for 12 months by default but we mocked only 2;
# the remaining 10 are silently dropped (list_months only returns 2).
resp = await client.get("/spending/annual?months=2")
resp = await client.get("/spending/annual?months=2&inflation_pct=0")
assert resp.status_code == 200, resp.text
body = resp.json()
# Usual: 3000 + 4000 = 7000 pence = £70
# 0% inflation → real == nominal. Usual: 3000 + 4000 = 7000p = £70
assert Decimal(body["total_gbp"]) == Decimal("70.00")
# Raw includes investments: 7000 + 60000 = 67000 pence = £670
assert Decimal(body["nominal_total_gbp"]) == Decimal("70.00")
# Raw includes investments: 7000 + 60000 = 67000p = £670
assert Decimal(body["raw_total_gbp"]) == Decimal("670.00")
assert body["window_start"] == "2025-01"
assert body["window_end"] == "2025-02"
assert body["excluded_groups"] == ["Investments and Savings", "Budget Reset"]
assert body["by_group_total_gbp"]["Usual Expenses"] == "70.00"
assert body["by_group_total_gbp"]["Investments and Savings"] == "600.00"
assert Decimal(body["inflation_pct"]) == Decimal("0")
async def test_spending_annual_custom_exclude(client: AsyncClient) -> None:
@ -93,7 +95,9 @@ async def test_spending_annual_custom_exclude(client: AsyncClient) -> None:
patch("fire_planner.actualbudget._today", lambda: date(2025, 3, 15)), \
respx.mock(assert_all_called=False) as rmock:
_mock_two_months(rmock)
resp = await client.get("/spending/annual?months=2&exclude=Usual%20Expenses")
resp = await client.get(
"/spending/annual?months=2&exclude=Usual%20Expenses&inflation_pct=0"
)
assert resp.status_code == 200
body = resp.json()
# Excluding "Usual Expenses" leaves only Investments: £600