diff --git a/fire_planner/actualbudget.py b/fire_planner/actualbudget.py index 2a741d6..286aa75 100644 --- a/fire_planner/actualbudget.py +++ b/fire_planner/actualbudget.py @@ -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 diff --git a/fire_planner/api/schemas.py b/fire_planner/api/schemas.py index 8a09940..84a7cb9 100644 --- a/fire_planner/api/schemas.py +++ b/fire_planner/api/schemas.py @@ -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] diff --git a/fire_planner/api/spending.py b/fire_planner/api/spending.py index 09005c6..20f82a1 100644 --- a/fire_planner/api/spending.py +++ b/fire_planner/api/spending.py @@ -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, ) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 25b96a3..8d91761 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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; monthly: Array<{ month: string; diff --git a/frontend/src/components/FanChart.tsx b/frontend/src/components/FanChart.tsx index 7db9d41..b6b12e1 100644 --- a/frontend/src/components/FanChart.tsx +++ b/frontend/src/components/FanChart.tsx @@ -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: [ { diff --git a/frontend/src/pages/WhatIf.tsx b/frontend/src/pages/WhatIf.tsx index 21ac8a6..bd5f0fa 100644 --- a/frontend/src/pages/WhatIf.tsx +++ b/frontend/src/pages/WhatIf.tsx @@ -98,38 +98,57 @@ const DEFAULTS: SimulateRequest = { export function WhatIf() { const [form, setForm] = useState(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() {
- + { + setNwUserEdited(true); + update('nw_seed_gbp', v); + }} + onSpendingChange={(v) => { + setSpendingUserEdited(true); + update('spending_gbp', v); + }} + nwUserEdited={nwUserEdited} + spendingUserEdited={spendingUserEdited} + onResetNw={resetNw} + onResetSpending={resetSpending} + /> @@ -259,28 +295,58 @@ function AnchorNumbers({ form, update, spending, + nwSnapshotDate, + onNwChange, + onSpendingChange, + nwUserEdited, + spendingUserEdited, + onResetNw, + onResetSpending, }: { form: SimulateRequest; update: (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 (
- update('nw_seed_gbp', v)} - /> +
+ + ) : undefined} + /> + {nwSnapshotDate && ( +

+ wealthfolio · as of {nwSnapshotDate} + {nwUserEdited && · edited} +

+ )} +
update('spending_gbp', v)} + onChange={onSpendingChange} + trailing={spendingUserEdited ? ( + + ) : undefined} /> - {spending && } + {spending && ( + + )}
void }) { + return ( + + ); +} + +function SpendingProvenance({ + data, + edited, +}: { + data: AnnualSpending; + edited: boolean; +}) { + const inflPct = (Number(data.inflation_pct) * 100).toFixed(1); return (

- 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 && · edited}

); } @@ -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 (