diff --git a/fire_planner/api/scenarios.py b/fire_planner/api/scenarios.py index 1df9cdc..197d4c4 100644 --- a/fire_planner/api/scenarios.py +++ b/fire_planner/api/scenarios.py @@ -196,10 +196,22 @@ async def patch_scenario( scen = await session.get(Scenario, scenario_id) if scen is None: raise HTTPException(status_code=404, detail="Scenario not found") - if scen.kind != "user": - raise HTTPException(status_code=400, - detail="Cannot patch cartesian scenarios — they're auto-generated") updates = payload.model_dump(exclude_unset=True) + if scen.kind != "user": + # Cartesian scenarios are rebuilt on every recompute — most core + # fields would be wiped by the next run, so we only allow updates + # to free-form metadata that we want to preserve across recomputes + # (notes, flex_rules, rate overrides). Hard-block edits to the + # parameters that define the scenario shape. + allowed_for_cartesian = {"config_json", "name", "description"} + bad = set(updates) - allowed_for_cartesian + if bad: + raise HTTPException( + status_code=400, + detail=("Cannot patch cartesian scenario fields {sorted(bad)} — " + "they're auto-generated. Only config_json/name/description " + "may be updated."), + ) for k, v in updates.items(): setattr(scen, k, v) await session.commit() diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8d14aaf..6a680f8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -75,6 +75,7 @@ export function App() { /> }> } /> + } /> } /> ); })} - {/* Background click capture (must be drawn before bars) */} + {/* Background click capture — drawn before bars so they win + z-order, but `pointerEvents=all` ensures the empty regions + between bars still bubble clicks here. */} diff --git a/frontend/src/components/SettingsSubnav.tsx b/frontend/src/components/SettingsSubnav.tsx index fa0c7fd..3d84818 100644 --- a/frontend/src/components/SettingsSubnav.tsx +++ b/frontend/src/components/SettingsSubnav.tsx @@ -7,6 +7,7 @@ interface Item { to: string; label: string; end?: boolean; + stub?: boolean; } export function SettingsSubnav({ items }: { items: Item[] }) { @@ -23,11 +24,15 @@ export function SettingsSubnav({ items }: { items: Item[] }) { 'block rounded-md px-3 py-1.5 text-sm', isActive ? 'bg-slate-100 text-slate-900 font-medium' - : 'text-slate-600 hover:text-slate-900 hover:bg-slate-50', + : it.stub + ? 'text-slate-300 hover:text-slate-500' + : 'text-slate-600 hover:text-slate-900 hover:bg-slate-50', ].join(' ') } + title={it.stub ? 'Coming soon — not yet wired' : undefined} > {it.label} + {it.stub && soon} ))} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 150e77f..80ee4a9 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -78,9 +78,12 @@ function SidebarLink({ function PlansSwitcher({ activeScenarioId }: { activeScenarioId?: number }) { const params = useParams(); + // Show every scenario in the switcher — the prod DB is dominated by + // cartesian rows (120 by default) and a "user-only" filter would + // surface an empty list out of the box. const scenarios = useQuery({ - queryKey: ['scenarios', 'list', 'user'], - queryFn: () => api.scenarios.list('user'), + queryKey: ['scenarios', 'list', 'all'], + queryFn: () => api.scenarios.list(), }); const active = activeScenarioId ?? Number(params.id); diff --git a/frontend/src/components/TabBar.tsx b/frontend/src/components/TabBar.tsx index 15d1358..16b0edf 100644 --- a/frontend/src/components/TabBar.tsx +++ b/frontend/src/components/TabBar.tsx @@ -9,6 +9,9 @@ export interface TabSpec { to: string; label: string; end?: boolean; + /** Mark stub/placeholder tabs so they're rendered de-emphasized — the + * user shouldn't mistake them for ready features. */ + stub?: boolean; } export function TabBar({ tabs }: { tabs: TabSpec[] }) { @@ -24,11 +27,15 @@ export function TabBar({ tabs }: { tabs: TabSpec[] }) { 'py-3 px-1 -mb-px border-b-2 whitespace-nowrap', isActive ? 'border-slate-900 text-slate-900 font-medium' - : 'border-transparent text-slate-500 hover:text-slate-800', + : t.stub + ? 'border-transparent text-slate-300 hover:text-slate-500' + : 'border-transparent text-slate-500 hover:text-slate-800', ].join(' ') } + title={t.stub ? 'Coming soon — not yet wired' : undefined} > {t.label} + {t.stub && soon} ))} diff --git a/frontend/src/pages/ScenarioDetail.tsx b/frontend/src/pages/ScenarioDetail.tsx index f56323c..99b02fc 100644 --- a/frontend/src/pages/ScenarioDetail.tsx +++ b/frontend/src/pages/ScenarioDetail.tsx @@ -219,8 +219,22 @@ export function ScenarioDetail() { {projection ? ( <> - {/* NW fan with floating stat badges */} -
+ {/* Stats badges row — sits above the chart, not on top of it */} + + + {/* NW fan + scrubber */} +

Portfolio fan

@@ -229,7 +243,7 @@ export function ScenarioDetail() {
-
{/* Spending profile */} @@ -336,11 +337,9 @@ export function ScenarioDetail() { ); } -function FloatingStats(props: { +function StatsBadges(props: { year: number; - maxYear: number; successRate: string; - p50End: string; netWorth?: string; changeNw?: string; spending?: string; @@ -348,19 +347,36 @@ function FloatingStats(props: { effectiveRate?: string; age: number | null; calendarYear?: number; + ruined: boolean; }) { return ( -
- - - - - - +
+
+ + + + + + + + + {props.ruined && ( + + Portfolio depleted at this year + + )} +
); } @@ -368,15 +384,17 @@ function FloatingStats(props: { function Badge({ label, value, + sub, accent, signed, }: { label: string; value: string; + sub?: string; accent?: boolean; signed?: string; }) { - let cls = 'text-slate-700'; + let cls = 'text-slate-800'; if (accent) cls = 'text-emerald-700'; if (signed != null) { const n = Number(signed); @@ -384,13 +402,21 @@ function Badge({ else if (n < 0) cls = 'text-red-600'; } return ( -
-
{label}
-
{value}
+
+
{label}
+
+ {value} + {sub && {sub}} +
); } +function isRuined(networth?: string): boolean { + if (!networth) return false; + return Number(networth) <= 0; +} + function Legend() { return (
diff --git a/frontend/src/pages/ScenarioShell.tsx b/frontend/src/pages/ScenarioShell.tsx index acbdaaa..d29e491 100644 --- a/frontend/src/pages/ScenarioShell.tsx +++ b/frontend/src/pages/ScenarioShell.tsx @@ -15,10 +15,10 @@ export function ScenarioShell() { const tabs: TabSpec[] = [ { to: `/scenarios/${id}`, label: 'Plan', end: true }, { to: `/scenarios/${id}/cash-flow`, label: 'Cash Flow' }, - { to: `/scenarios/${id}/tax-analytics`, label: 'Tax Analytics' }, - { to: `/scenarios/${id}/compare`, label: 'Compare' }, - { to: `/scenarios/${id}/reports`, label: 'Reports' }, - { to: `/scenarios/${id}/estate`, label: 'Estate' }, + { to: `/scenarios/${id}/tax-analytics`, label: 'Tax Analytics', stub: true }, + { to: `/scenarios/${id}/compare`, label: 'Compare', stub: true }, + { to: `/scenarios/${id}/reports`, label: 'Reports', stub: true }, + { to: `/scenarios/${id}/estate`, label: 'Estate', stub: true }, { to: `/scenarios/${id}/settings`, label: 'Settings' }, ]; diff --git a/frontend/src/pages/SettingsTab.tsx b/frontend/src/pages/SettingsTab.tsx index da2643e..d47db96 100644 --- a/frontend/src/pages/SettingsTab.tsx +++ b/frontend/src/pages/SettingsTab.tsx @@ -11,12 +11,12 @@ export function SettingsTab() { const items = [ { to: `/scenarios/${id}/settings`, label: 'Milestones', end: true }, { to: `/scenarios/${id}/settings/rates`, label: 'Rates' }, - { to: `/scenarios/${id}/settings/dividends`, label: 'Dividends' }, - { to: `/scenarios/${id}/settings/bonds`, label: 'Bonds' }, - { to: `/scenarios/${id}/settings/tax`, label: 'Tax' }, - { to: `/scenarios/${id}/settings/metrics`, label: 'Metrics' }, - { to: `/scenarios/${id}/settings/other`, label: 'Other Settings' }, { to: `/scenarios/${id}/settings/notes`, label: 'Notes' }, + { to: `/scenarios/${id}/settings/dividends`, label: 'Dividends', stub: true }, + { to: `/scenarios/${id}/settings/bonds`, label: 'Bonds', stub: true }, + { to: `/scenarios/${id}/settings/tax`, label: 'Tax', stub: true }, + { to: `/scenarios/${id}/settings/metrics`, label: 'Metrics', stub: true }, + { to: `/scenarios/${id}/settings/other`, label: 'Other', stub: true }, ]; return ( diff --git a/tests/test_api_scenarios.py b/tests/test_api_scenarios.py index 65d72a1..3c9e29f 100644 --- a/tests/test_api_scenarios.py +++ b/tests/test_api_scenarios.py @@ -153,11 +153,30 @@ async def test_patch_user_scenario(client: AsyncClient) -> None: assert body["leave_uk_year"] == 2 -async def test_patch_cartesian_blocked(client: AsyncClient, session: AsyncSession) -> None: +async def test_patch_cartesian_core_fields_blocked( + client: AsyncClient, session: AsyncSession, +) -> None: + """Cartesian scenarios reject edits to fields that get rebuilt by + recompute (jurisdiction/strategy/etc), but allow free-form metadata + (config_json/name/description) so users can pin notes + flex_rules.""" cart = await _seed(session) - resp = await client.patch(f"/scenarios/{cart.id}", json={"name": "Renamed"}) - assert resp.status_code == 400 - assert "cartesian" in resp.json()["detail"] + + # Core field — still blocked. + bad = await client.patch(f"/scenarios/{cart.id}", + json={"jurisdiction": "uae"}) + assert bad.status_code == 400 + assert "cartesian" in bad.json()["detail"] + + # config_json and name — allowed (preserves user edits). + ok = await client.patch( + f"/scenarios/{cart.id}", + json={"config_json": {"flex_rules": [{"from_ath_pct": 0.2, + "cut_discretionary_pct": 0.5}]}, + "name": "Renamed"}, + ) + assert ok.status_code == 200, ok.text + assert ok.json()["name"] == "Renamed" + assert ok.json()["config_json"]["flex_rules"][0]["from_ath_pct"] == 0.2 async def test_delete_user_scenario(client: AsyncClient) -> None: