fire-planner/frontend/src/api/client.ts
Viktor Barzin 4a0ef1faf6
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
fire-planner: filterable date range on the home-page history chart
User asked for a manual date range on the Dashboard chart instead of
the hard-coded 12-month window.

Backend (/networth/history):
- Read LIVE from wf_sync's daily_account_valuation JOIN accounts so the
  chart spans the broker's full daily series. The account_snapshot
  cache is only the latest snapshot — never had >2 daily points for
  charting. Falls back to the cache when wf_sync isn't wired (tests).
- Accept `from=YYYY-MM-DD` and `to=YYYY-MM-DD` query params. When `from`
  is set, the window is [from, to or today] (inclusive). Otherwise the
  legacy `days` look-back still applies. 422 when from > to.

Frontend:
- New HistoryRangePicker component: preset buttons (1m / 3m / 6m / 1y /
  3y / All) plus two date inputs for an explicit custom range.
- Dashboard wires the picker to the chart via react-query keyed on the
  selected range, so the chart re-fetches on change.

Tests:
- Renamed `respects_days_filter` → `respects_from_to_filter` and added
  inverted-window rejection. Old test asserted "days=1 returns 1 point"
  which only worked when 'today' was within the seed window — the new
  windowing is correct and explicit.

271 → 272 tests passing.
2026-05-28 09:04:58 +00:00

499 lines
14 KiB
TypeScript

/**
* Thin API client over the FastAPI backend.
*
* In dev, requests go through Vite's proxy at `/api` → http://localhost:8080.
* In prod, the SPA is served from the same origin as the API.
*/
const API_BASE = '/api';
export class ApiError extends Error {
status: number;
detail: unknown;
constructor(status: number, detail: unknown, message: string) {
super(message);
this.status = status;
this.detail = detail;
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
headers: {
'content-type': 'application/json',
...(init?.headers ?? {}),
},
...init,
});
if (!res.ok) {
let detail: unknown = null;
try {
detail = await res.json();
} catch {
detail = await res.text();
}
throw new ApiError(res.status, detail, `${res.status} ${res.statusText}`);
}
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
export const api = {
// /healthz is mounted at root by the FastAPI app (so k8s probes hit
// it without the /api prefix). Bypass API_BASE to match.
health: () =>
fetch('/healthz')
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))
.then((d) => d as { status: string; queue_depth: number }),
recompute: (body?: Record<string, unknown>) =>
request<{ status: string; depth: number }>('/recompute', {
method: 'POST',
body: JSON.stringify(body ?? {}),
}),
yearStats: (id: number, year: number) =>
request<YearStats>(`/scenarios/${id}/year-stats?year=${year}`),
progress: (id: number) => request<ProgressResponse>(`/scenarios/${id}/progress`),
cashflow: (id: number, year: number) =>
request<CashflowResponse>(`/scenarios/${id}/cashflow?year=${year}`),
spendingProfile: (id: number) =>
request<SpendingProfileResponse>(`/scenarios/${id}/spending-profile`),
spendingProfilePreview: (body: SpendingProfilePreviewRequest) =>
request<SpendingProfileResponse>(`/scenarios/spending-profile-preview`, {
method: 'POST',
body: JSON.stringify(body),
}),
networth: {
current: () =>
request<{
snapshot_date: string;
total_gbp: string;
accounts: Array<{
account_id: string;
account_name: string;
account_type: string;
currency: string;
snapshot_date: string;
market_value: string;
market_value_gbp: string;
cost_basis_gbp: string | null;
}>;
}>('/networth'),
history: (params: { days?: number; from?: string; to?: string } = {}) => {
const q = new URLSearchParams();
if (params.from) q.set('from', params.from);
if (params.to) q.set('to', params.to);
if (!params.from && !params.to) q.set('days', String(params.days ?? 365));
return request<{
points: Array<{
snapshot_date: string;
total_gbp: string;
by_account: Record<string, string>;
}>;
}>(`/networth/history?${q.toString()}`);
},
},
spending: {
annual: (months = 12) =>
request<AnnualSpending>(`/spending/annual?months=${months}`),
},
scenarios: {
list: (kind?: 'cartesian' | 'user') =>
request<Scenario[]>(`/scenarios${kind ? `?kind=${kind}` : ''}`),
get: (id: number) => request<Scenario>(`/scenarios/${id}`),
projection: (id: number) => request<ScenarioProjection>(`/scenarios/${id}/projection`),
create: (body: ScenarioCreateBody) =>
request<Scenario>('/scenarios', { method: 'POST', body: JSON.stringify(body) }),
patch: (id: number, body: Partial<ScenarioCreateBody>) =>
request<Scenario>(`/scenarios/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
delete: (id: number) => request<void>(`/scenarios/${id}`, { method: 'DELETE' }),
},
simulate: (req: SimulateRequest) =>
request<SimulateResult>('/simulate', { method: 'POST', body: JSON.stringify(req) }),
};
export interface AnnualSpending {
months: number;
window_start: string;
window_end: string;
excluded_groups: 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;
by_group: Record<string, string>;
total_gbp: string;
}>;
}
export interface ScenarioCreateBody {
name: string;
description?: string | null;
parent_scenario_id?: number | null;
jurisdiction: string;
strategy: string;
leave_uk_year: number;
glide_path: string;
spending_gbp: string;
horizon_years?: number;
nw_seed_gbp: string;
savings_per_year_gbp?: string;
config_json?: Record<string, unknown>;
}
// ── life events ──────────────────────────────────────────────────────
export type SpendingCategory = 'essential' | 'discretionary' | 'not_spending';
export interface LifeEvent {
id: number;
scenario_id: number;
kind: string;
name: string;
year_start: number;
year_end: number | null;
delta_gbp_per_year: string;
one_time_amount_gbp: string | null;
category: SpendingCategory;
enabled: boolean;
payload: Record<string, unknown> | null;
created_at: string;
}
export interface LifeEventCreateBody {
kind: string;
name: string;
year_start: number;
year_end?: number | null;
delta_gbp_per_year?: string;
one_time_amount_gbp?: string | null;
category?: SpendingCategory;
enabled?: boolean;
payload?: Record<string, unknown> | null;
}
export interface LifeEventPatchBody {
kind?: string;
name?: string;
year_start?: number;
year_end?: number | null;
delta_gbp_per_year?: string;
one_time_amount_gbp?: string | null;
category?: SpendingCategory;
enabled?: boolean;
}
export const lifeEventsApi = {
list: (scenarioId: number) =>
request<LifeEvent[]>(`/scenarios/${scenarioId}/life-events`),
create: (scenarioId: number, body: LifeEventCreateBody) =>
request<LifeEvent>(`/scenarios/${scenarioId}/life-events`, {
method: 'POST',
body: JSON.stringify(body),
}),
patch: (eventId: number, body: LifeEventPatchBody) =>
request<LifeEvent>(`/life-events/${eventId}`, {
method: 'PATCH',
body: JSON.stringify(body),
}),
delete: (eventId: number) =>
request<void>(`/life-events/${eventId}`, { method: 'DELETE' }),
};
// ── flex spending + spending profile ─────────────────────────────────
export interface FlexRule {
from_ath_pct: string;
cut_discretionary_pct: string;
}
export interface SpendingProfilePoint {
year_idx: number;
base_gbp: string;
essential_gbp: string;
discretionary_gbp: string;
not_spending_gbp: string;
flex_cut_gbp: string;
total_gbp: string;
}
export interface SpendingProfileResponse {
scenario_id: number | null;
horizon_years: number;
points: SpendingProfilePoint[];
}
export interface SpendingProfilePreviewRequest {
base_spending_gbp: string;
horizon_years: number;
life_events?: Array<{
kind?: string;
year_start: number;
year_end?: number | null;
delta_gbp_per_year?: string;
one_time_amount_gbp?: string | null;
category?: SpendingCategory;
enabled?: boolean;
}>;
flex_rules?: FlexRule[];
}
// ── goals ────────────────────────────────────────────────────────────
export interface Goal {
id: number;
scenario_id: number;
kind: string;
name: string;
target_amount_gbp: string | null;
target_year: number | null;
comparator: string;
success_threshold: string;
enabled: boolean;
payload: Record<string, unknown> | null;
created_at: string;
}
export interface GoalCreateBody {
kind: string;
name: string;
target_amount_gbp?: string | null;
target_year?: number | null;
comparator?: string;
success_threshold?: string;
enabled?: boolean;
payload?: Record<string, unknown> | null;
}
export const goalsApi = {
list: (scenarioId: number) => request<Goal[]>(`/scenarios/${scenarioId}/goals`),
create: (scenarioId: number, body: GoalCreateBody) =>
request<Goal>(`/scenarios/${scenarioId}/goals`, {
method: 'POST',
body: JSON.stringify(body),
}),
delete: (goalId: number) => request<void>(`/goals/${goalId}`, { method: 'DELETE' }),
};
// ── income streams ───────────────────────────────────────────────────
export interface IncomeStream {
id: number;
scenario_id: number;
kind: string;
name: string;
start_year: number;
end_year: number | null;
amount_gbp_per_year: string;
growth_pct: string;
tax_treatment: string;
enabled: boolean;
payload: Record<string, unknown> | null;
created_at: string;
}
export interface IncomeStreamCreateBody {
kind: string;
name: string;
start_year?: number;
end_year?: number | null;
amount_gbp_per_year?: string;
growth_pct?: string;
tax_treatment?: string;
enabled?: boolean;
payload?: Record<string, unknown> | null;
}
export const incomeStreamsApi = {
list: (scenarioId: number) =>
request<IncomeStream[]>(`/scenarios/${scenarioId}/income-streams`),
create: (scenarioId: number, body: IncomeStreamCreateBody) =>
request<IncomeStream>(`/scenarios/${scenarioId}/income-streams`, {
method: 'POST',
body: JSON.stringify(body),
}),
patch: (streamId: number, body: Partial<IncomeStreamCreateBody>) =>
request<IncomeStream>(`/income-streams/${streamId}`, {
method: 'PATCH',
body: JSON.stringify(body),
}),
delete: (streamId: number) =>
request<void>(`/income-streams/${streamId}`, { method: 'DELETE' }),
};
// ── per-year stats / progress / cashflow ─────────────────────────────
export interface YearStats {
year_idx: number;
calendar_year: number;
age: number | null;
net_worth_p50: string;
change_in_nw: string;
taxable_income: string;
taxes: string;
effective_tax_rate: string;
spending: string;
contributions: string;
investment_growth: string;
liquid_nw: string | null;
expenses: string | null;
savings_rate: string | null;
portfolio_allocations: Record<string, string> | null;
}
export interface ProgressActualPoint {
snapshot_date: string;
total_gbp: string;
}
export interface ProgressProjectedPoint {
year_idx: number;
p10_portfolio_gbp: string;
p50_portfolio_gbp: string;
p90_portfolio_gbp: string;
}
export interface ProgressVariancePoint {
year_idx: number;
actual_avg_gbp: string;
projected_p50_gbp: string;
delta_gbp: string;
}
export interface ProgressResponse {
scenario_id: number;
alignment_anchor: string;
actual: ProgressActualPoint[];
projected: ProgressProjectedPoint[];
variance: ProgressVariancePoint[];
}
export interface CashflowResponse {
scenario_id: number;
year: number;
sources: Record<string, string>;
sinks: Record<string, string>;
}
export interface GoalProbability {
goal_id: number | null;
name: string;
kind: string;
probability: string;
threshold: string;
passed: boolean;
}
export interface Scenario {
id: number;
external_id: string;
kind: 'cartesian' | 'user';
name: string | null;
description: string | null;
parent_scenario_id: number | null;
jurisdiction: string;
strategy: string;
leave_uk_year: number;
glide_path: string;
spending_gbp: string;
horizon_years: number;
nw_seed_gbp: string;
savings_per_year_gbp: string;
config_json: Record<string, unknown>;
created_at: string;
}
export interface ProjectionPoint {
year_idx: number;
p10_portfolio_gbp: string;
p25_portfolio_gbp: string;
p50_portfolio_gbp: string;
p75_portfolio_gbp: string;
p90_portfolio_gbp: string;
p50_withdrawal_gbp: string;
p50_tax_gbp: string;
survival_rate: string;
}
export interface ScenarioProjection {
scenario_id: number;
external_id: string;
mc_run_id: number;
run_at: string;
n_paths: number;
success_rate: string;
p10_ending_gbp: string;
p50_ending_gbp: string;
p90_ending_gbp: string;
median_lifetime_tax_gbp: string;
median_years_to_ruin: string | null;
yearly: ProjectionPoint[];
goals_probability?: GoalProbability[];
}
export interface SimulateRequest {
jurisdiction: string;
strategy: string;
leave_uk_year: number;
spending_gbp: string;
nw_seed_gbp: string;
savings_per_year_gbp?: string;
horizon_years?: number;
floor_gbp?: string | null;
n_paths?: number;
seed?: number;
life_events?: Array<{
year_start: number;
year_end?: number | null;
delta_gbp_per_year?: string;
one_time_amount_gbp?: string | null;
category?: SpendingCategory;
enabled?: boolean;
}>;
returns_mode?: 'shiller' | 'manual' | 'wealthfolio';
manual_real_return_pct?: string | null;
// Custom spending-plan params (only consulted when strategy='custom')
annual_real_adjust_pct?: string;
guardrail_threshold_pct?: string | null;
guardrail_cut_pct?: string;
income_streams?: Array<{
kind?: string;
start_year: number;
end_year?: number | null;
amount_gbp_per_year: string;
growth_pct?: string;
tax_treatment?: string;
enabled?: boolean;
}>;
flex_rules?: FlexRule[];
goals?: Array<{
kind: string;
name: string;
target_amount_gbp?: string | null;
target_year?: number | null;
comparator?: string;
success_threshold?: string;
enabled?: boolean;
}>;
rates_mode?: 'fixed' | 'historical' | 'advanced' | null;
inflation_pct?: string;
stocks_growth_pct?: string;
stocks_dividend_pct?: string;
bonds_growth_pct?: string;
bonds_dividend_pct?: string;
stocks_allocation?: string;
}
export interface SimulateResult {
success_rate: string;
p10_ending_gbp: string;
p50_ending_gbp: string;
p90_ending_gbp: string;
median_lifetime_tax_gbp: string;
median_years_to_ruin: string | null;
elapsed_seconds: string;
yearly: ProjectionPoint[];
goals_probability?: GoalProbability[];
}