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):
|
||||
scenario_id: int
|
||||
scenario_id: int | None = None
|
||||
horizon_years: int
|
||||
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):
|
||||
"""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.schemas import (
|
||||
SpendingProfilePoint,
|
||||
SpendingProfilePreviewRequest,
|
||||
SpendingProfileResponse,
|
||||
)
|
||||
from fire_planner.db import LifeEvent, McRun, ProjectionYearly, Scenario
|
||||
|
|
@ -183,3 +184,73 @@ async def get_spending_profile(
|
|||
horizon_years=horizon,
|
||||
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}`),
|
||||
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<{
|
||||
|
|
@ -210,11 +215,26 @@ export interface SpendingProfilePoint {
|
|||
}
|
||||
|
||||
export interface SpendingProfileResponse {
|
||||
scenario_id: number;
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@
|
|||
* fallback under the drawer remains for bulk edits and accessibility.
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { ParentSize } from '@visx/responsive';
|
||||
import { scaleBand, scaleLinear } from '@visx/scale';
|
||||
import { Group } from '@visx/group';
|
||||
|
|
@ -21,7 +20,6 @@ import { AxisBottom } from '@visx/axis';
|
|||
import { localPoint } from '@visx/event';
|
||||
|
||||
import {
|
||||
lifeEventsApi,
|
||||
type LifeEvent,
|
||||
type LifeEventCreateBody,
|
||||
type LifeEventPatchBody,
|
||||
|
|
@ -30,10 +28,19 @@ import {
|
|||
import { gbp } from '@/lib/format';
|
||||
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 {
|
||||
scenarioId: number;
|
||||
events: LifeEvent[];
|
||||
horizonYears: number;
|
||||
persister: GanttPersister;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
|
|
@ -77,9 +84,9 @@ export function EventGantt(props: Props) {
|
|||
}
|
||||
|
||||
function Inner({
|
||||
scenarioId,
|
||||
events,
|
||||
horizonYears,
|
||||
persister,
|
||||
width,
|
||||
height = 220,
|
||||
}: Props & { width: number }) {
|
||||
|
|
@ -112,39 +119,8 @@ function Inner({
|
|||
[sortedEvents],
|
||||
);
|
||||
|
||||
const qc = useQueryClient();
|
||||
const invalidate = () =>
|
||||
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();
|
||||
},
|
||||
});
|
||||
// Mutations are delegated to the parent via `persister` (Plan tab
|
||||
// wires to lifeEventsApi; What-If wires to local React state).
|
||||
|
||||
// Drag state lives in a ref so mousemove handlers don't re-render.
|
||||
const drag = useRef<DragState | null>(null);
|
||||
|
|
@ -192,7 +168,7 @@ function Inner({
|
|||
year_end: clamp(origYearEnd + dyears, origYearStart, horizonYears - 1),
|
||||
};
|
||||
}
|
||||
patchMut.mutate({ id: eventId, body });
|
||||
persister.patch(eventId, body);
|
||||
};
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
|
|
@ -200,7 +176,7 @@ function Inner({
|
|||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
}, [horizonYears, patchMut, xScale]);
|
||||
}, [horizonYears, persister, xScale]);
|
||||
|
||||
const startDrag = (e: React.MouseEvent, ev: LifeEvent, mode: DragMode) => {
|
||||
e.stopPropagation();
|
||||
|
|
@ -401,15 +377,15 @@ function Inner({
|
|||
state={popover}
|
||||
onClose={() => setPopover(null)}
|
||||
onCreate={(body) => {
|
||||
createMut.mutate(body);
|
||||
persister.create(body);
|
||||
setPopover(null);
|
||||
}}
|
||||
onPatch={(id, body) => {
|
||||
patchMut.mutate({ id, body });
|
||||
persister.patch(id, body);
|
||||
setPopover(null);
|
||||
}}
|
||||
onDelete={(id) => {
|
||||
deleteMut.mutate(id);
|
||||
persister.delete(id);
|
||||
setPopover(null);
|
||||
}}
|
||||
horizonYears={horizonYears}
|
||||
|
|
|
|||
|
|
@ -1,79 +1,35 @@
|
|||
/**
|
||||
* Flex-rules editor — list of {from_ath_pct, cut_discretionary_pct}
|
||||
* tiers stored on `scenario.config_json.flex_rules`. Saves on blur via
|
||||
* the existing PATCH /scenarios/:id (config_json is a free-form blob).
|
||||
* tiers. Controlled component: parent owns the rules + persistence.
|
||||
*
|
||||
* 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';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { api, type Scenario } from '@/api/client';
|
||||
|
||||
interface Rule {
|
||||
export interface FlexRule {
|
||||
from_ath_pct: number;
|
||||
cut_discretionary_pct: number;
|
||||
}
|
||||
|
||||
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.30, cut_discretionary_pct: 0.60 },
|
||||
];
|
||||
|
||||
function readRules(scen: Scenario): Rule[] {
|
||||
const blob = scen.config_json as Record<string, unknown>;
|
||||
const raw = blob?.flex_rules;
|
||||
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);
|
||||
export function FlexRulesEditor({ rules, onChange, saving, error }: Props) {
|
||||
const update = (idx: number, patch: Partial<FlexRule>) => {
|
||||
onChange(rules.map((r, i) => (i === idx ? { ...r, ...patch } : r)));
|
||||
};
|
||||
|
||||
const update = (idx: number, patch: Partial<Rule>) => {
|
||||
persist(rules.map((r, i) => (i === idx ? { ...r, ...patch } : r)));
|
||||
};
|
||||
|
||||
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);
|
||||
const remove = (idx: number) => onChange(rules.filter((_, i) => i !== idx));
|
||||
const add = () =>
|
||||
onChange([...rules, { from_ath_pct: 0.20, cut_discretionary_pct: 0.40 }]);
|
||||
const seedDefaults = () => onChange(DEFAULT_RULES);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-4">
|
||||
|
|
@ -129,11 +85,8 @@ export function FlexRulesEditor({ scenario }: Props) {
|
|||
>
|
||||
+ Add tier
|
||||
</button>
|
||||
{save.isError && (
|
||||
<p className="text-xs text-red-700 mt-2">
|
||||
{String((save.error as Error)?.message ?? save.error)}
|
||||
</p>
|
||||
)}
|
||||
{saving && <p className="text-xs text-slate-500 mt-2">Saving…</p>}
|
||||
{error && <p className="text-xs text-red-700 mt-2">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ import {
|
|||
import { ApiError } from '@/api/client';
|
||||
import { EventGantt } from '@/components/EventGantt';
|
||||
import { FanChart } from '@/components/FanChart';
|
||||
import { FlexRulesEditor } from '@/components/FlexRulesEditor';
|
||||
import { FlexRulesEditor, type FlexRule } from '@/components/FlexRulesEditor';
|
||||
import { GoalsSection } from '@/components/GoalsSection';
|
||||
import { IncomeStreamsSection } from '@/components/IncomeStreamsSection';
|
||||
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({
|
||||
mutationFn: (req: SimulateRequest) => api.simulate(req),
|
||||
});
|
||||
|
|
@ -402,13 +457,22 @@ export function ScenarioDetail() {
|
|||
</span>
|
||||
</div>
|
||||
<EventGantt
|
||||
scenarioId={id}
|
||||
events={events.data ?? []}
|
||||
horizonYears={horizonYears}
|
||||
persister={ganttPersister}
|
||||
/>
|
||||
</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 ? (
|
||||
<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`.
|
||||
*/
|
||||
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 {
|
||||
api,
|
||||
type AnnualSpending,
|
||||
type LifeEvent,
|
||||
type LifeEventCreateBody,
|
||||
type LifeEventPatchBody,
|
||||
type SimulateRequest,
|
||||
type SimulateResult,
|
||||
type SpendingCategory,
|
||||
} from '@/api/client';
|
||||
import { EventGantt, type GanttPersister } from '@/components/EventGantt';
|
||||
import { FanChart } from '@/components/FanChart';
|
||||
import { FlexRulesEditor, type FlexRule } from '@/components/FlexRulesEditor';
|
||||
import { InfoTip } from '@/components/InfoTip';
|
||||
import { SegmentedControl, type SegmentedOption } from '@/components/SegmentedControl';
|
||||
import { SpendingProfileChart } from '@/components/SpendingProfileChart';
|
||||
import { gbp, pct } from '@/lib/format';
|
||||
|
||||
const JURISDICTIONS = ['uk', 'cyprus', 'bulgaria', 'malaysia', 'thailand', 'uae', 'nomad'];
|
||||
|
|
@ -150,6 +157,72 @@ export function WhatIf() {
|
|||
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({
|
||||
mutationFn: (req: SimulateRequest) => api.simulate(req),
|
||||
});
|
||||
|
|
@ -173,10 +246,9 @@ export function WhatIf() {
|
|||
},
|
||||
});
|
||||
|
||||
const onSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const buildRequest = (): SimulateRequest => {
|
||||
const isCustom = form.strategy === 'custom';
|
||||
sim.mutate({
|
||||
return {
|
||||
...form,
|
||||
floor_gbp: form.strategy === 'vpw_floor' ? form.floor_gbp : null,
|
||||
manual_real_return_pct:
|
||||
|
|
@ -184,9 +256,60 @@ export function WhatIf() {
|
|||
annual_real_adjust_pct: isCustom ? form.annual_real_adjust_pct : '0',
|
||||
guardrail_threshold_pct: isCustom ? form.guardrail_threshold_pct : null,
|
||||
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 suggested = `${form.jurisdiction}-${form.strategy}-leave-y${form.leave_uk_year}`;
|
||||
const name = prompt('Save as scenario — name:', suggested);
|
||||
|
|
@ -264,6 +387,38 @@ export function WhatIf() {
|
|||
{sim.data && (
|
||||
<>
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue