spending: prefill annual £ from actualbudget trailing 12mo
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Adds a thin read-only client for the actualbudget HTTP API
(`fire_planner/actualbudget.py`) and a `GET /spending/annual` endpoint
that returns trailing-N-month spending broken out by category group.
Default exclusions ("Investments and Savings", "Budget Reset") strip
out wealth transfers so the headline number reflects actual
consumption — for Viktor's data, ~£41k/yr instead of the raw £210k
total. Caller can pass `?exclude=...` to override.
Frontend uses the headline `total_gbp` to autofill the Annual spending
input (same pattern as nw_seed from networth), with a small
provenance line below the input showing the window + which groups
were excluded.
Auth: 3 new env vars (ACTUALBUDGET_API_URL/KEY/SYNC_ID) sourced from
Vault `secret/fire-planner` via the existing ExternalSecret —
infra/stacks/fire-planner applied separately. Backend silently keeps
the hardcoded default if the upstream is unreachable.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
2c51954790
commit
3bfa46ad4f
8 changed files with 617 additions and 8 deletions
|
|
@ -70,6 +70,10 @@ export const api = {
|
|||
}>;
|
||||
}>(`/networth/history?days=${days}`),
|
||||
},
|
||||
spending: {
|
||||
annual: (months = 12) =>
|
||||
request<AnnualSpending>(`/spending/annual?months=${months}`),
|
||||
},
|
||||
scenarios: {
|
||||
list: (kind?: 'cartesian' | 'user') =>
|
||||
request<Scenario[]>(`/scenarios${kind ? `?kind=${kind}` : ''}`),
|
||||
|
|
@ -85,6 +89,21 @@ export const api = {
|
|||
request<SimulateResult>('/simulate', { method: 'POST', body: JSON.stringify(req) }),
|
||||
};
|
||||
|
||||
export interface AnnualSpending {
|
||||
months: number;
|
||||
window_start: string;
|
||||
window_end: string;
|
||||
excluded_groups: string[];
|
||||
total_gbp: string;
|
||||
raw_total_gbp: string;
|
||||
by_group_total_gbp: Record<string, string>;
|
||||
monthly: Array<{
|
||||
month: string;
|
||||
by_group: Record<string, string>;
|
||||
total_gbp: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ScenarioCreateBody {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
|
|
|
|||
|
|
@ -16,7 +16,12 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { api, type SimulateRequest, type SimulateResult } from '@/api/client';
|
||||
import {
|
||||
api,
|
||||
type AnnualSpending,
|
||||
type SimulateRequest,
|
||||
type SimulateResult,
|
||||
} from '@/api/client';
|
||||
import { FanChart } from '@/components/FanChart';
|
||||
import { InfoTip } from '@/components/InfoTip';
|
||||
import { SegmentedControl, type SegmentedOption } from '@/components/SegmentedControl';
|
||||
|
|
@ -94,6 +99,7 @@ const DEFAULTS: SimulateRequest = {
|
|||
export function WhatIf() {
|
||||
const [form, setForm] = useState<SimulateRequest>(DEFAULTS);
|
||||
const [nwAutoFilled, setNwAutoFilled] = useState(false);
|
||||
const [spendingAutoFilled, setSpendingAutoFilled] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
|
||||
|
|
@ -108,6 +114,23 @@ export function WhatIf() {
|
|||
setNwAutoFilled(true);
|
||||
}, [nw.data, nwAutoFilled]);
|
||||
|
||||
// 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,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (spendingAutoFilled || !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 sim = useMutation({
|
||||
mutationFn: (req: SimulateRequest) => api.simulate(req),
|
||||
});
|
||||
|
|
@ -169,7 +192,7 @@ 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} />
|
||||
<AnchorNumbers form={form} update={update} spending={spending.data} />
|
||||
|
||||
<PlanCard form={form} update={update} strategy={strategy} />
|
||||
|
||||
|
|
@ -235,9 +258,11 @@ export function WhatIf() {
|
|||
function AnchorNumbers({
|
||||
form,
|
||||
update,
|
||||
spending,
|
||||
}: {
|
||||
form: SimulateRequest;
|
||||
update: <K extends keyof SimulateRequest>(k: K, v: SimulateRequest[K]) => void;
|
||||
spending: AnnualSpending | undefined;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-5 shadow-sm">
|
||||
|
|
@ -248,12 +273,15 @@ function AnchorNumbers({
|
|||
value={form.nw_seed_gbp}
|
||||
onChange={(v) => update('nw_seed_gbp', v)}
|
||||
/>
|
||||
<BigNumber
|
||||
label="Annual spending"
|
||||
prefix="£"
|
||||
value={form.spending_gbp}
|
||||
onChange={(v) => update('spending_gbp', v)}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<BigNumber
|
||||
label="Annual spending"
|
||||
prefix="£"
|
||||
value={form.spending_gbp}
|
||||
onChange={(v) => update('spending_gbp', v)}
|
||||
/>
|
||||
{spending && <SpendingProvenance data={spending} />}
|
||||
</div>
|
||||
<BigNumber
|
||||
label="Horizon"
|
||||
suffix="yrs"
|
||||
|
|
@ -266,6 +294,19 @@ function AnchorNumbers({
|
|||
);
|
||||
}
|
||||
|
||||
function SpendingProvenance({ data }: { data: AnnualSpending }) {
|
||||
return (
|
||||
<p className="mt-1 text-[10px] text-slate-500 leading-tight">
|
||||
from budget · {data.window_start}→{data.window_end}
|
||||
{data.excluded_groups.length > 0 && (
|
||||
<>
|
||||
{' '}· excl. {data.excluded_groups.join(', ')}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanCard({
|
||||
form,
|
||||
update,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue