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