fire-planner: What-If gains the chart-first scenario editor
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
The Plan-tab editors (interactive Gantt for life events, flex spending
rules) are now available in What-If too — with local state instead of
DB persistence so users can tweak before committing to a scenario.
Architecture refactor:
- EventGantt is now a controlled component. The `scenarioId` prop +
internal useMutation/useQueryClient hooks went away; the component
takes a `persister: { create, patch, delete }` prop and delegates
every mutation through it. Plan tab wires it to lifeEventsApi +
cache invalidation; What-If wires it to React local state with
negative ids for new events.
- FlexRulesEditor is similarly controlled. Takes `rules + onChange`
instead of a `scenario` object. Plan tab wraps it with the PATCH
/scenarios/:id mutation; What-If wraps it with setFlexRules.
Backend:
- New stateless POST /scenarios/spending-profile-preview endpoint
takes base_spending_gbp + horizon + life_events + flex_rules in the
body and returns the same SpendingProfileResponse shape as the
read-only /scenarios/{id}/spending-profile endpoint. Used by
What-If to render the stacked-area chart against unsaved events.
- SpendingProfileResponse.scenario_id loosened to int | None to
support the preview variant.
Frontend:
- What-If page gains `events` + `flexRules` local state, an
EventGantt + FlexRulesEditor wired through them, and a Visx
spending-profile chart fed by /spending-profile-preview.
- Sim auto-refresh: a 600ms debounced effect re-fires /simulate
whenever the form / events / flex rules change. Manual "Run
simulation" button stays as an immediate trigger.
- "Save as scenario" still works — preserves the scenario params but
not yet the life events / flex rules (a Wave-3 follow-up could
POST them after the scenario is created).
247 pytest pass; mypy + ruff + frontend typecheck/test/build all clean.
This commit is contained in:
parent
eb0dd3ddbf
commit
70101c836c
7 changed files with 372 additions and 118 deletions
|
|
@ -427,11 +427,26 @@ class SpendingProfilePoint(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class SpendingProfileResponse(BaseModel):
|
class SpendingProfileResponse(BaseModel):
|
||||||
scenario_id: int
|
scenario_id: int | None = None
|
||||||
horizon_years: int
|
horizon_years: int
|
||||||
points: list[SpendingProfilePoint]
|
points: list[SpendingProfilePoint]
|
||||||
|
|
||||||
|
|
||||||
|
class SpendingProfilePreviewRequest(BaseModel):
|
||||||
|
"""Stateless spending-profile preview — used by the What-If page where
|
||||||
|
the user is editing in-memory life events that aren't persisted yet.
|
||||||
|
|
||||||
|
No scenario_id needed; the caller supplies the baseline spending,
|
||||||
|
horizon, and any flex rules. Flex-cut estimation against a portfolio
|
||||||
|
fan is skipped (caller already has a /simulate response to pair
|
||||||
|
with this if they want flex visualisation).
|
||||||
|
"""
|
||||||
|
base_spending_gbp: Decimal = Field(ge=0)
|
||||||
|
horizon_years: int = Field(ge=1, le=100)
|
||||||
|
life_events: list[LifeEventInput] = Field(default_factory=list)
|
||||||
|
flex_rules: list[FlexRule] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class SimulateRequest(BaseModel):
|
class SimulateRequest(BaseModel):
|
||||||
"""Sync, non-persisted simulate. Used by the React UI for what-if.
|
"""Sync, non-persisted simulate. Used by the React UI for what-if.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from fire_planner.api.dependencies import get_session
|
from fire_planner.api.dependencies import get_session
|
||||||
from fire_planner.api.schemas import (
|
from fire_planner.api.schemas import (
|
||||||
SpendingProfilePoint,
|
SpendingProfilePoint,
|
||||||
|
SpendingProfilePreviewRequest,
|
||||||
SpendingProfileResponse,
|
SpendingProfileResponse,
|
||||||
)
|
)
|
||||||
from fire_planner.db import LifeEvent, McRun, ProjectionYearly, Scenario
|
from fire_planner.db import LifeEvent, McRun, ProjectionYearly, Scenario
|
||||||
|
|
@ -183,3 +184,73 @@ async def get_spending_profile(
|
||||||
horizon_years=horizon,
|
horizon_years=horizon,
|
||||||
points=points,
|
points=points,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/spending-profile-preview", response_model=SpendingProfileResponse)
|
||||||
|
async def preview_spending_profile(
|
||||||
|
req: SpendingProfilePreviewRequest,
|
||||||
|
) -> SpendingProfileResponse:
|
||||||
|
"""Stateless variant — used by the What-If page where the user is
|
||||||
|
tweaking in-memory life events that aren't persisted yet."""
|
||||||
|
points: list[SpendingProfilePoint] = []
|
||||||
|
base = Decimal(str(req.base_spending_gbp))
|
||||||
|
# Flex rules are accepted for forward-compat but not applied here:
|
||||||
|
# without a portfolio path we can't decide drawdown depth. The
|
||||||
|
# paired /simulate call gives the exact cut; this endpoint just
|
||||||
|
# gives the pre-cut profile for the stacked-area chart.
|
||||||
|
_ = req.flex_rules
|
||||||
|
|
||||||
|
for year_idx in range(req.horizon_years):
|
||||||
|
essential = Decimal("0")
|
||||||
|
discretionary = Decimal("0")
|
||||||
|
not_spending = Decimal("0")
|
||||||
|
ess_inflow = Decimal("0")
|
||||||
|
disc_inflow = Decimal("0")
|
||||||
|
for ev in req.life_events:
|
||||||
|
if not ev.enabled:
|
||||||
|
continue
|
||||||
|
if year_idx < ev.year_start:
|
||||||
|
continue
|
||||||
|
end = ev.year_end if ev.year_end is not None else ev.year_start
|
||||||
|
if year_idx > end:
|
||||||
|
continue
|
||||||
|
delta = Decimal(str(ev.delta_gbp_per_year or 0))
|
||||||
|
if ev.category == "not_spending":
|
||||||
|
not_spending += abs(delta)
|
||||||
|
continue
|
||||||
|
if delta < 0:
|
||||||
|
if ev.category == "essential":
|
||||||
|
essential += -delta
|
||||||
|
elif ev.category == "discretionary":
|
||||||
|
discretionary += -delta
|
||||||
|
elif delta > 0:
|
||||||
|
if ev.category == "essential":
|
||||||
|
ess_inflow += delta
|
||||||
|
elif ev.category == "discretionary":
|
||||||
|
disc_inflow += delta
|
||||||
|
if year_idx == ev.year_start and ev.one_time_amount_gbp is not None:
|
||||||
|
ot = Decimal(str(ev.one_time_amount_gbp))
|
||||||
|
if ot < 0:
|
||||||
|
if ev.category == "essential":
|
||||||
|
essential += -ot
|
||||||
|
elif ev.category == "discretionary":
|
||||||
|
discretionary += -ot
|
||||||
|
net_base = base - ess_inflow - disc_inflow
|
||||||
|
if net_base < 0:
|
||||||
|
net_base = Decimal("0")
|
||||||
|
total = net_base + essential + discretionary
|
||||||
|
points.append(
|
||||||
|
SpendingProfilePoint(
|
||||||
|
year_idx=year_idx,
|
||||||
|
base_gbp=net_base,
|
||||||
|
essential_gbp=essential,
|
||||||
|
discretionary_gbp=discretionary,
|
||||||
|
not_spending_gbp=not_spending,
|
||||||
|
flex_cut_gbp=Decimal("0"),
|
||||||
|
total_gbp=total,
|
||||||
|
))
|
||||||
|
return SpendingProfileResponse(
|
||||||
|
scenario_id=None,
|
||||||
|
horizon_years=req.horizon_years,
|
||||||
|
points=points,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,11 @@ export const api = {
|
||||||
request<CashflowResponse>(`/scenarios/${id}/cashflow?year=${year}`),
|
request<CashflowResponse>(`/scenarios/${id}/cashflow?year=${year}`),
|
||||||
spendingProfile: (id: number) =>
|
spendingProfile: (id: number) =>
|
||||||
request<SpendingProfileResponse>(`/scenarios/${id}/spending-profile`),
|
request<SpendingProfileResponse>(`/scenarios/${id}/spending-profile`),
|
||||||
|
spendingProfilePreview: (body: SpendingProfilePreviewRequest) =>
|
||||||
|
request<SpendingProfileResponse>(`/scenarios/spending-profile-preview`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
networth: {
|
networth: {
|
||||||
current: () =>
|
current: () =>
|
||||||
request<{
|
request<{
|
||||||
|
|
@ -210,11 +215,26 @@ export interface SpendingProfilePoint {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SpendingProfileResponse {
|
export interface SpendingProfileResponse {
|
||||||
scenario_id: number;
|
scenario_id: number | null;
|
||||||
horizon_years: number;
|
horizon_years: number;
|
||||||
points: SpendingProfilePoint[];
|
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 ────────────────────────────────────────────────────────────
|
// ── goals ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface Goal {
|
export interface Goal {
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@
|
||||||
* fallback under the drawer remains for bulk edits and accessibility.
|
* fallback under the drawer remains for bulk edits and accessibility.
|
||||||
*/
|
*/
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { ParentSize } from '@visx/responsive';
|
import { ParentSize } from '@visx/responsive';
|
||||||
import { scaleBand, scaleLinear } from '@visx/scale';
|
import { scaleBand, scaleLinear } from '@visx/scale';
|
||||||
import { Group } from '@visx/group';
|
import { Group } from '@visx/group';
|
||||||
|
|
@ -21,7 +20,6 @@ import { AxisBottom } from '@visx/axis';
|
||||||
import { localPoint } from '@visx/event';
|
import { localPoint } from '@visx/event';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
lifeEventsApi,
|
|
||||||
type LifeEvent,
|
type LifeEvent,
|
||||||
type LifeEventCreateBody,
|
type LifeEventCreateBody,
|
||||||
type LifeEventPatchBody,
|
type LifeEventPatchBody,
|
||||||
|
|
@ -30,10 +28,19 @@ import {
|
||||||
import { gbp } from '@/lib/format';
|
import { gbp } from '@/lib/format';
|
||||||
import { emojiFor } from '@/lib/milestone';
|
import { emojiFor } from '@/lib/milestone';
|
||||||
|
|
||||||
|
/** Persistence hooks — parent decides what create/patch/delete do.
|
||||||
|
* Plan tab wires these to the lifeEventsApi; What-If wires them to
|
||||||
|
* local React state. */
|
||||||
|
export interface GanttPersister {
|
||||||
|
create: (body: LifeEventCreateBody) => void;
|
||||||
|
patch: (id: number, body: LifeEventPatchBody) => void;
|
||||||
|
delete: (id: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
scenarioId: number;
|
|
||||||
events: LifeEvent[];
|
events: LifeEvent[];
|
||||||
horizonYears: number;
|
horizonYears: number;
|
||||||
|
persister: GanttPersister;
|
||||||
height?: number;
|
height?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -77,9 +84,9 @@ export function EventGantt(props: Props) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function Inner({
|
function Inner({
|
||||||
scenarioId,
|
|
||||||
events,
|
events,
|
||||||
horizonYears,
|
horizonYears,
|
||||||
|
persister,
|
||||||
width,
|
width,
|
||||||
height = 220,
|
height = 220,
|
||||||
}: Props & { width: number }) {
|
}: Props & { width: number }) {
|
||||||
|
|
@ -112,39 +119,8 @@ function Inner({
|
||||||
[sortedEvents],
|
[sortedEvents],
|
||||||
);
|
);
|
||||||
|
|
||||||
const qc = useQueryClient();
|
// Mutations are delegated to the parent via `persister` (Plan tab
|
||||||
const invalidate = () =>
|
// wires to lifeEventsApi; What-If wires to local React state).
|
||||||
qc.invalidateQueries({
|
|
||||||
queryKey: ['scenarios', scenarioId, 'life-events'],
|
|
||||||
});
|
|
||||||
const invalidateProfile = () =>
|
|
||||||
qc.invalidateQueries({
|
|
||||||
queryKey: ['spending-profile', scenarioId],
|
|
||||||
});
|
|
||||||
|
|
||||||
const patchMut = useMutation({
|
|
||||||
mutationFn: ({ id, body }: { id: number; body: LifeEventPatchBody }) =>
|
|
||||||
lifeEventsApi.patch(id, body),
|
|
||||||
onSuccess: () => {
|
|
||||||
invalidate();
|
|
||||||
invalidateProfile();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const createMut = useMutation({
|
|
||||||
mutationFn: (body: LifeEventCreateBody) =>
|
|
||||||
lifeEventsApi.create(scenarioId, body),
|
|
||||||
onSuccess: () => {
|
|
||||||
invalidate();
|
|
||||||
invalidateProfile();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const deleteMut = useMutation({
|
|
||||||
mutationFn: (id: number) => lifeEventsApi.delete(id),
|
|
||||||
onSuccess: () => {
|
|
||||||
invalidate();
|
|
||||||
invalidateProfile();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Drag state lives in a ref so mousemove handlers don't re-render.
|
// Drag state lives in a ref so mousemove handlers don't re-render.
|
||||||
const drag = useRef<DragState | null>(null);
|
const drag = useRef<DragState | null>(null);
|
||||||
|
|
@ -192,7 +168,7 @@ function Inner({
|
||||||
year_end: clamp(origYearEnd + dyears, origYearStart, horizonYears - 1),
|
year_end: clamp(origYearEnd + dyears, origYearStart, horizonYears - 1),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
patchMut.mutate({ id: eventId, body });
|
persister.patch(eventId, body);
|
||||||
};
|
};
|
||||||
window.addEventListener('mousemove', onMove);
|
window.addEventListener('mousemove', onMove);
|
||||||
window.addEventListener('mouseup', onUp);
|
window.addEventListener('mouseup', onUp);
|
||||||
|
|
@ -200,7 +176,7 @@ function Inner({
|
||||||
window.removeEventListener('mousemove', onMove);
|
window.removeEventListener('mousemove', onMove);
|
||||||
window.removeEventListener('mouseup', onUp);
|
window.removeEventListener('mouseup', onUp);
|
||||||
};
|
};
|
||||||
}, [horizonYears, patchMut, xScale]);
|
}, [horizonYears, persister, xScale]);
|
||||||
|
|
||||||
const startDrag = (e: React.MouseEvent, ev: LifeEvent, mode: DragMode) => {
|
const startDrag = (e: React.MouseEvent, ev: LifeEvent, mode: DragMode) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
@ -401,15 +377,15 @@ function Inner({
|
||||||
state={popover}
|
state={popover}
|
||||||
onClose={() => setPopover(null)}
|
onClose={() => setPopover(null)}
|
||||||
onCreate={(body) => {
|
onCreate={(body) => {
|
||||||
createMut.mutate(body);
|
persister.create(body);
|
||||||
setPopover(null);
|
setPopover(null);
|
||||||
}}
|
}}
|
||||||
onPatch={(id, body) => {
|
onPatch={(id, body) => {
|
||||||
patchMut.mutate({ id, body });
|
persister.patch(id, body);
|
||||||
setPopover(null);
|
setPopover(null);
|
||||||
}}
|
}}
|
||||||
onDelete={(id) => {
|
onDelete={(id) => {
|
||||||
deleteMut.mutate(id);
|
persister.delete(id);
|
||||||
setPopover(null);
|
setPopover(null);
|
||||||
}}
|
}}
|
||||||
horizonYears={horizonYears}
|
horizonYears={horizonYears}
|
||||||
|
|
|
||||||
|
|
@ -1,79 +1,35 @@
|
||||||
/**
|
/**
|
||||||
* Flex-rules editor — list of {from_ath_pct, cut_discretionary_pct}
|
* Flex-rules editor — list of {from_ath_pct, cut_discretionary_pct}
|
||||||
* tiers stored on `scenario.config_json.flex_rules`. Saves on blur via
|
* tiers. Controlled component: parent owns the rules + persistence.
|
||||||
* the existing PATCH /scenarios/:id (config_json is a free-form blob).
|
*
|
||||||
|
* Plan tab wraps this with a PATCH /scenarios/:id call. What-If wraps
|
||||||
|
* it with local React state.
|
||||||
*/
|
*/
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
export interface FlexRule {
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import { api, type Scenario } from '@/api/client';
|
|
||||||
|
|
||||||
interface Rule {
|
|
||||||
from_ath_pct: number;
|
from_ath_pct: number;
|
||||||
cut_discretionary_pct: number;
|
cut_discretionary_pct: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
scenario: Scenario;
|
rules: FlexRule[];
|
||||||
|
onChange: (next: FlexRule[]) => void;
|
||||||
|
saving?: boolean;
|
||||||
|
error?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_RULES: Rule[] = [
|
const DEFAULT_RULES: FlexRule[] = [
|
||||||
{ from_ath_pct: 0.10, cut_discretionary_pct: 0.20 },
|
{ from_ath_pct: 0.10, cut_discretionary_pct: 0.20 },
|
||||||
{ from_ath_pct: 0.30, cut_discretionary_pct: 0.60 },
|
{ from_ath_pct: 0.30, cut_discretionary_pct: 0.60 },
|
||||||
];
|
];
|
||||||
|
|
||||||
function readRules(scen: Scenario): Rule[] {
|
export function FlexRulesEditor({ rules, onChange, saving, error }: Props) {
|
||||||
const blob = scen.config_json as Record<string, unknown>;
|
const update = (idx: number, patch: Partial<FlexRule>) => {
|
||||||
const raw = blob?.flex_rules;
|
onChange(rules.map((r, i) => (i === idx ? { ...r, ...patch } : r)));
|
||||||
if (!Array.isArray(raw)) return [];
|
|
||||||
return raw
|
|
||||||
.filter((r): r is { from_ath_pct: unknown; cut_discretionary_pct: unknown } =>
|
|
||||||
typeof r === 'object' && r !== null,
|
|
||||||
)
|
|
||||||
.map((r) => ({
|
|
||||||
from_ath_pct: Number((r as { from_ath_pct: unknown }).from_ath_pct ?? 0),
|
|
||||||
cut_discretionary_pct: Number(
|
|
||||||
(r as { cut_discretionary_pct: unknown }).cut_discretionary_pct ?? 0,
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FlexRulesEditor({ scenario }: Props) {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
const [rules, setRules] = useState<Rule[]>(() => readRules(scenario));
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setRules(readRules(scenario));
|
|
||||||
}, [scenario.id, scenario.config_json]);
|
|
||||||
|
|
||||||
const save = useMutation({
|
|
||||||
mutationFn: (next: Rule[]) =>
|
|
||||||
api.scenarios.patch(scenario.id, {
|
|
||||||
config_json: {
|
|
||||||
...((scenario.config_json as Record<string, unknown>) ?? {}),
|
|
||||||
flex_rules: next,
|
|
||||||
},
|
|
||||||
} as never),
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ['scenarios', scenario.id] });
|
|
||||||
qc.invalidateQueries({
|
|
||||||
queryKey: ['spending-profile', scenario.id],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const persist = (next: Rule[]) => {
|
|
||||||
setRules(next);
|
|
||||||
save.mutate(next);
|
|
||||||
};
|
};
|
||||||
|
const remove = (idx: number) => onChange(rules.filter((_, i) => i !== idx));
|
||||||
const update = (idx: number, patch: Partial<Rule>) => {
|
const add = () =>
|
||||||
persist(rules.map((r, i) => (i === idx ? { ...r, ...patch } : r)));
|
onChange([...rules, { from_ath_pct: 0.20, cut_discretionary_pct: 0.40 }]);
|
||||||
};
|
const seedDefaults = () => onChange(DEFAULT_RULES);
|
||||||
|
|
||||||
const remove = (idx: number) => persist(rules.filter((_, i) => i !== idx));
|
|
||||||
const add = () => persist([...rules, { from_ath_pct: 0.20, cut_discretionary_pct: 0.40 }]);
|
|
||||||
const seedDefaults = () => persist(DEFAULT_RULES);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-slate-200 bg-white p-4">
|
<div className="rounded-lg border border-slate-200 bg-white p-4">
|
||||||
|
|
@ -129,11 +85,8 @@ export function FlexRulesEditor({ scenario }: Props) {
|
||||||
>
|
>
|
||||||
+ Add tier
|
+ Add tier
|
||||||
</button>
|
</button>
|
||||||
{save.isError && (
|
{saving && <p className="text-xs text-slate-500 mt-2">Saving…</p>}
|
||||||
<p className="text-xs text-red-700 mt-2">
|
{error && <p className="text-xs text-red-700 mt-2">{error}</p>}
|
||||||
{String((save.error as Error)?.message ?? save.error)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ import {
|
||||||
import { ApiError } from '@/api/client';
|
import { ApiError } from '@/api/client';
|
||||||
import { EventGantt } from '@/components/EventGantt';
|
import { EventGantt } from '@/components/EventGantt';
|
||||||
import { FanChart } from '@/components/FanChart';
|
import { FanChart } from '@/components/FanChart';
|
||||||
import { FlexRulesEditor } from '@/components/FlexRulesEditor';
|
import { FlexRulesEditor, type FlexRule } from '@/components/FlexRulesEditor';
|
||||||
import { GoalsSection } from '@/components/GoalsSection';
|
import { GoalsSection } from '@/components/GoalsSection';
|
||||||
import { IncomeStreamsSection } from '@/components/IncomeStreamsSection';
|
import { IncomeStreamsSection } from '@/components/IncomeStreamsSection';
|
||||||
import { LifeEventsSection } from '@/components/LifeEventsSection';
|
import { LifeEventsSection } from '@/components/LifeEventsSection';
|
||||||
|
|
@ -95,6 +95,61 @@ export function ScenarioDetail() {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// API-backed persisters for the chart-first editors. Each invalidates
|
||||||
|
// the queries that drive the Plan tab so the spending profile + fan
|
||||||
|
// re-fetch after the mutation lands.
|
||||||
|
const invalidateScenarioCaches = () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['scenarios', id, 'life-events'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['spending-profile', id] });
|
||||||
|
};
|
||||||
|
const lifeEventCreate = useMutation({
|
||||||
|
mutationFn: (body: Parameters<typeof lifeEventsApi.create>[1]) =>
|
||||||
|
lifeEventsApi.create(id, body),
|
||||||
|
onSuccess: invalidateScenarioCaches,
|
||||||
|
});
|
||||||
|
const lifeEventPatch = useMutation({
|
||||||
|
mutationFn: ({ eid, body }: { eid: number; body: Parameters<typeof lifeEventsApi.patch>[1] }) =>
|
||||||
|
lifeEventsApi.patch(eid, body),
|
||||||
|
onSuccess: invalidateScenarioCaches,
|
||||||
|
});
|
||||||
|
const lifeEventDelete = useMutation({
|
||||||
|
mutationFn: (eid: number) => lifeEventsApi.delete(eid),
|
||||||
|
onSuccess: invalidateScenarioCaches,
|
||||||
|
});
|
||||||
|
const ganttPersister = useMemo(
|
||||||
|
() => ({
|
||||||
|
create: (body: Parameters<typeof lifeEventsApi.create>[1]) =>
|
||||||
|
lifeEventCreate.mutate(body),
|
||||||
|
patch: (eid: number, body: Parameters<typeof lifeEventsApi.patch>[1]) =>
|
||||||
|
lifeEventPatch.mutate({ eid, body }),
|
||||||
|
delete: (eid: number) => lifeEventDelete.mutate(eid),
|
||||||
|
}),
|
||||||
|
// mutate is stable across renders by useMutation contract
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const flexRulesSave = useMutation({
|
||||||
|
mutationFn: (next: FlexRule[]) =>
|
||||||
|
api.scenarios.patch(id, {
|
||||||
|
config_json: {
|
||||||
|
...((scen.data?.config_json as Record<string, unknown>) ?? {}),
|
||||||
|
flex_rules: next,
|
||||||
|
},
|
||||||
|
} as never),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['scenarios', id] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['spending-profile', id] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const persistedFlexRules: FlexRule[] = useMemo(
|
||||||
|
() => (scen.data ? readFlexRules(scen.data).map((r) => ({
|
||||||
|
from_ath_pct: Number(r.from_ath_pct),
|
||||||
|
cut_discretionary_pct: Number(r.cut_discretionary_pct),
|
||||||
|
})) : []),
|
||||||
|
[scen.data],
|
||||||
|
);
|
||||||
|
|
||||||
const sim = useMutation({
|
const sim = useMutation({
|
||||||
mutationFn: (req: SimulateRequest) => api.simulate(req),
|
mutationFn: (req: SimulateRequest) => api.simulate(req),
|
||||||
});
|
});
|
||||||
|
|
@ -402,13 +457,22 @@ export function ScenarioDetail() {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<EventGantt
|
<EventGantt
|
||||||
scenarioId={id}
|
|
||||||
events={events.data ?? []}
|
events={events.data ?? []}
|
||||||
horizonYears={horizonYears}
|
horizonYears={horizonYears}
|
||||||
|
persister={ganttPersister}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FlexRulesEditor scenario={s} />
|
<FlexRulesEditor
|
||||||
|
rules={persistedFlexRules}
|
||||||
|
onChange={(next) => flexRulesSave.mutate(next)}
|
||||||
|
saving={flexRulesSave.isPending}
|
||||||
|
error={
|
||||||
|
flexRulesSave.isError
|
||||||
|
? String((flexRulesSave.error as Error)?.message ?? flexRulesSave.error)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
) : projection404 ? (
|
) : projection404 ? (
|
||||||
<div className="rounded-lg border border-slate-200 bg-white p-8 text-center text-slate-500">
|
<div className="rounded-lg border border-slate-200 bg-white p-8 text-center text-slate-500">
|
||||||
|
|
|
||||||
|
|
@ -13,18 +13,25 @@
|
||||||
* `api/simulate.py::_project`.
|
* `api/simulate.py::_project`.
|
||||||
*/
|
*/
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
type AnnualSpending,
|
type AnnualSpending,
|
||||||
|
type LifeEvent,
|
||||||
|
type LifeEventCreateBody,
|
||||||
|
type LifeEventPatchBody,
|
||||||
type SimulateRequest,
|
type SimulateRequest,
|
||||||
type SimulateResult,
|
type SimulateResult,
|
||||||
|
type SpendingCategory,
|
||||||
} from '@/api/client';
|
} from '@/api/client';
|
||||||
|
import { EventGantt, type GanttPersister } from '@/components/EventGantt';
|
||||||
import { FanChart } from '@/components/FanChart';
|
import { FanChart } from '@/components/FanChart';
|
||||||
|
import { FlexRulesEditor, type FlexRule } from '@/components/FlexRulesEditor';
|
||||||
import { InfoTip } from '@/components/InfoTip';
|
import { InfoTip } from '@/components/InfoTip';
|
||||||
import { SegmentedControl, type SegmentedOption } from '@/components/SegmentedControl';
|
import { SegmentedControl, type SegmentedOption } from '@/components/SegmentedControl';
|
||||||
|
import { SpendingProfileChart } from '@/components/SpendingProfileChart';
|
||||||
import { gbp, pct } from '@/lib/format';
|
import { gbp, pct } from '@/lib/format';
|
||||||
|
|
||||||
const JURISDICTIONS = ['uk', 'cyprus', 'bulgaria', 'malaysia', 'thailand', 'uae', 'nomad'];
|
const JURISDICTIONS = ['uk', 'cyprus', 'bulgaria', 'malaysia', 'thailand', 'uae', 'nomad'];
|
||||||
|
|
@ -150,6 +157,72 @@ export function WhatIf() {
|
||||||
void spending.refetch();
|
void spending.refetch();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Local life-events + flex rules. Editing these never hits the DB —
|
||||||
|
// What-If is for tweaking before committing to a scenario.
|
||||||
|
const [events, setEvents] = useState<LifeEvent[]>([]);
|
||||||
|
const [flexRules, setFlexRules] = useState<FlexRule[]>([]);
|
||||||
|
const nextLocalId = useRef(-1);
|
||||||
|
const allocLocalId = () => {
|
||||||
|
const n = nextLocalId.current;
|
||||||
|
nextLocalId.current -= 1;
|
||||||
|
return n;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ganttPersister: GanttPersister = useMemo(
|
||||||
|
() => ({
|
||||||
|
create: (body: LifeEventCreateBody) => {
|
||||||
|
const ev: LifeEvent = {
|
||||||
|
id: allocLocalId(),
|
||||||
|
scenario_id: 0,
|
||||||
|
kind: body.kind,
|
||||||
|
name: body.name,
|
||||||
|
year_start: body.year_start,
|
||||||
|
year_end: body.year_end ?? null,
|
||||||
|
delta_gbp_per_year: String(body.delta_gbp_per_year ?? '0'),
|
||||||
|
one_time_amount_gbp: body.one_time_amount_gbp ?? null,
|
||||||
|
category: (body.category as SpendingCategory) ?? 'essential',
|
||||||
|
enabled: body.enabled ?? true,
|
||||||
|
payload: body.payload ?? null,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
setEvents((prev) => [...prev, ev]);
|
||||||
|
},
|
||||||
|
patch: (id: number, body: LifeEventPatchBody) => {
|
||||||
|
setEvents((prev) =>
|
||||||
|
prev.map((e) =>
|
||||||
|
e.id === id
|
||||||
|
? {
|
||||||
|
...e,
|
||||||
|
...(body.name !== undefined && { name: body.name }),
|
||||||
|
...(body.kind !== undefined && { kind: body.kind }),
|
||||||
|
...(body.year_start !== undefined && {
|
||||||
|
year_start: body.year_start,
|
||||||
|
}),
|
||||||
|
...(body.year_end !== undefined && {
|
||||||
|
year_end: body.year_end,
|
||||||
|
}),
|
||||||
|
...(body.delta_gbp_per_year !== undefined && {
|
||||||
|
delta_gbp_per_year: body.delta_gbp_per_year,
|
||||||
|
}),
|
||||||
|
...(body.one_time_amount_gbp !== undefined && {
|
||||||
|
one_time_amount_gbp: body.one_time_amount_gbp,
|
||||||
|
}),
|
||||||
|
...(body.category !== undefined && {
|
||||||
|
category: body.category,
|
||||||
|
}),
|
||||||
|
...(body.enabled !== undefined && { enabled: body.enabled }),
|
||||||
|
}
|
||||||
|
: e,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
delete: (id: number) => {
|
||||||
|
setEvents((prev) => prev.filter((e) => e.id !== id));
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const sim = useMutation({
|
const sim = useMutation({
|
||||||
mutationFn: (req: SimulateRequest) => api.simulate(req),
|
mutationFn: (req: SimulateRequest) => api.simulate(req),
|
||||||
});
|
});
|
||||||
|
|
@ -173,10 +246,9 @@ export function WhatIf() {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = (e: React.FormEvent) => {
|
const buildRequest = (): SimulateRequest => {
|
||||||
e.preventDefault();
|
|
||||||
const isCustom = form.strategy === 'custom';
|
const isCustom = form.strategy === 'custom';
|
||||||
sim.mutate({
|
return {
|
||||||
...form,
|
...form,
|
||||||
floor_gbp: form.strategy === 'vpw_floor' ? form.floor_gbp : null,
|
floor_gbp: form.strategy === 'vpw_floor' ? form.floor_gbp : null,
|
||||||
manual_real_return_pct:
|
manual_real_return_pct:
|
||||||
|
|
@ -184,9 +256,60 @@ export function WhatIf() {
|
||||||
annual_real_adjust_pct: isCustom ? form.annual_real_adjust_pct : '0',
|
annual_real_adjust_pct: isCustom ? form.annual_real_adjust_pct : '0',
|
||||||
guardrail_threshold_pct: isCustom ? form.guardrail_threshold_pct : null,
|
guardrail_threshold_pct: isCustom ? form.guardrail_threshold_pct : null,
|
||||||
guardrail_cut_pct: isCustom ? form.guardrail_cut_pct : '0.10',
|
guardrail_cut_pct: isCustom ? form.guardrail_cut_pct : '0.10',
|
||||||
});
|
life_events: events.map((e) => ({
|
||||||
|
year_start: e.year_start,
|
||||||
|
year_end: e.year_end,
|
||||||
|
delta_gbp_per_year: e.delta_gbp_per_year,
|
||||||
|
one_time_amount_gbp: e.one_time_amount_gbp,
|
||||||
|
category: e.category,
|
||||||
|
enabled: e.enabled,
|
||||||
|
})),
|
||||||
|
flex_rules: flexRules.map((r) => ({
|
||||||
|
from_ath_pct: String(r.from_ath_pct),
|
||||||
|
cut_discretionary_pct: String(r.cut_discretionary_pct),
|
||||||
|
})),
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
sim.mutate(buildRequest());
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-refresh sim whenever the form / events / flex rules change.
|
||||||
|
// Debounced 600ms so rapid edits (drag, slider) don't fire 10x.
|
||||||
|
const signature = JSON.stringify({ form, events, flexRules });
|
||||||
|
useEffect(() => {
|
||||||
|
const t = window.setTimeout(() => {
|
||||||
|
sim.mutate(buildRequest());
|
||||||
|
}, 600);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [signature]);
|
||||||
|
|
||||||
|
// Spending profile preview — recomputes on every signature change.
|
||||||
|
const profile = useQuery({
|
||||||
|
queryKey: ['spending-profile-preview', signature],
|
||||||
|
queryFn: () =>
|
||||||
|
api.spendingProfilePreview({
|
||||||
|
base_spending_gbp: form.spending_gbp,
|
||||||
|
horizon_years: form.horizon_years ?? 60,
|
||||||
|
life_events: events.map((e) => ({
|
||||||
|
year_start: e.year_start,
|
||||||
|
year_end: e.year_end,
|
||||||
|
delta_gbp_per_year: e.delta_gbp_per_year,
|
||||||
|
one_time_amount_gbp: e.one_time_amount_gbp,
|
||||||
|
category: e.category,
|
||||||
|
enabled: e.enabled,
|
||||||
|
})),
|
||||||
|
flex_rules: flexRules.map((r) => ({
|
||||||
|
from_ath_pct: String(r.from_ath_pct),
|
||||||
|
cut_discretionary_pct: String(r.cut_discretionary_pct),
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
|
||||||
const onSaveAs = () => {
|
const onSaveAs = () => {
|
||||||
const suggested = `${form.jurisdiction}-${form.strategy}-leave-y${form.leave_uk_year}`;
|
const suggested = `${form.jurisdiction}-${form.strategy}-leave-y${form.leave_uk_year}`;
|
||||||
const name = prompt('Save as scenario — name:', suggested);
|
const name = prompt('Save as scenario — name:', suggested);
|
||||||
|
|
@ -264,6 +387,38 @@ export function WhatIf() {
|
||||||
{sim.data && (
|
{sim.data && (
|
||||||
<>
|
<>
|
||||||
<Results result={sim.data} horizon={form.horizon_years ?? 60} />
|
<Results result={sim.data} horizon={form.horizon_years ?? 60} />
|
||||||
|
|
||||||
|
{/* Spending profile (stacked area) */}
|
||||||
|
{profile.data && (
|
||||||
|
<div className="rounded-lg border border-slate-200 bg-white p-5">
|
||||||
|
<div className="flex items-baseline justify-between mb-2">
|
||||||
|
<h2 className="text-lg font-semibold">Spending profile</h2>
|
||||||
|
<span className="text-xs text-slate-500">
|
||||||
|
base · essential · discretionary
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<SpendingProfileChart points={profile.data.points} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Interactive Gantt — chart-as-SoT for life events */}
|
||||||
|
<div className="rounded-lg border border-slate-200 bg-white p-5">
|
||||||
|
<div className="flex items-baseline justify-between mb-2">
|
||||||
|
<h2 className="text-lg font-semibold">Life events</h2>
|
||||||
|
<span className="text-xs text-slate-500">
|
||||||
|
Click empty space to add · drag bars to move · drag edges to resize
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<EventGantt
|
||||||
|
events={events}
|
||||||
|
horizonYears={form.horizon_years ?? 60}
|
||||||
|
persister={ganttPersister}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Flex spending rules */}
|
||||||
|
<FlexRulesEditor rules={flexRules} onChange={setFlexRules} />
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue