All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
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.
499 lines
14 KiB
TypeScript
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[];
|
|
}
|