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

@ -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,
)