Live-Simulation: Was-waere-wenn-Regler (Roadmap 22)
Deploy App / deploy (push) Successful in 54s

Zweispalter: links Regler, rechts waehlbare Grafik, oben Kennzahlen mit
Differenz zum unveraenderten Plan. Keine eigene Rechenlogik -- die Regler
nutzen dieselben Transformationen wie der Tornado.

Neu: applyElementDriver / tunableElements fuer einzeln regelbare
Element-Renditen; livesim.ts; AllocationChart aus dem Dashboard geloest.

computePlan misst 0.2 ms auf 60 Jahren -> synchron, ohne Debounce.
Spezifikation 0.18 (4.15 und 9.27 neu), 15 Tests (124 -> 139).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 21:22:49 +02:00
parent 50e98122cf
commit d04e07fdfb
9 changed files with 1009 additions and 62 deletions
+77
View File
@@ -0,0 +1,77 @@
"use client";
import { useMemo } from "react";
import { Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { formatChf } from "@/lib/format";
import type { PlanComputed } from "@/lib/calculations";
export const CHART_PALETTE = ["#4f46e5", "#0ea5e9", "#16a34a", "#d97706", "#dc2626", "#7c3aed"];
const ASSET_CATS = ["PENSION_FUND", "PILLAR_3A", "REAL_ESTATE", "OTHER_ASSET"];
// Gestapelte Vermögensaufteilung je Phase (Beginn und Ende). Eigene Komponente, weil sie
// sowohl im Grafiken-Dialog als auch in der Live-Simulation gebraucht wird.
export function AllocationChart({ computed, height = 288 }: { computed: PlanComputed; height?: number }) {
// Asset-Elemente (nach id, damit gleiche Namen nicht kollidieren), die irgendwann einen
// positiven Wert haben -- in Reihenfolge ihres ersten Auftretens.
const assetEls = useMemo(() => {
const info = new Map<string, { name: string; any: boolean }>();
for (const phase of computed.phases) {
for (const el of phase.elements) {
if (!ASSET_CATS.includes(el.category)) continue;
const cur = info.get(el.elementId) ?? { name: el.name, any: false };
cur.name = el.name;
if (el.startValue > 0 || el.endValue > 0) cur.any = true;
info.set(el.elementId, cur);
}
}
return [...info.entries()].filter(([, v]) => v.any).map(([id, v]) => ({ id, name: v.name }));
}, [computed]);
// Je Phase zwei Kategorien auf der x-Achse: Beginn und Ende.
const barData = useMemo(
() =>
computed.phases.flatMap((phase) => {
const beginn: Record<string, number | string> = { label: `${phase.name} · Beginn` };
const ende: Record<string, number | string> = { label: `${phase.name} · Ende` };
for (const el of phase.elements) {
if (!ASSET_CATS.includes(el.category)) continue;
beginn[el.elementId] = Math.max(0, Math.round(el.startValue));
ende[el.elementId] = Math.max(0, Math.round(el.endValue));
}
return [beginn, ende];
}),
[computed]
);
if (assetEls.length === 0) {
return <p className="text-sm text-muted">Dieser Plan enthält keine Vermögenselemente.</p>;
}
return (
<div className="w-full" style={{ height }}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={barData} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="label" tick={{ fontSize: 10 }} interval={0} angle={-30} textAnchor="end" height={70} />
<YAxis
tick={{ fontSize: 11 }}
tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)}
/>
<Tooltip formatter={(v) => (typeof v === "number" ? formatChf(v) : v)} />
<Legend wrapperStyle={{ fontSize: 12 }} />
{assetEls.map((el, i) => (
<Bar
key={el.id}
dataKey={el.id}
name={el.name}
stackId="a"
fill={CHART_PALETTE[i % CHART_PALETTE.length]}
isAnimationActive={false}
/>
))}
</BarChart>
</ResponsiveContainer>
</div>
);
}
+10
View File
@@ -24,6 +24,7 @@ import { PlanView } from "@/components/PlanView";
import { Dashboard } from "@/components/Dashboard";
import { MonteCarloDialog } from "@/components/MonteCarloDialog";
import { SensitivityDialog } from "@/components/SensitivityDialog";
import { LiveSimDialog } from "@/components/LiveSimDialog";
import { SpecView } from "@/components/SpecView";
import { SystemParametersView } from "@/components/SystemParametersView";
import { PlanTraceDialog } from "@/components/DetailView";
@@ -85,6 +86,7 @@ function AppShellInner({ username }: { username: string }) {
const [showCharts, setShowCharts] = useState(false);
const [showMonteCarlo, setShowMonteCarlo] = useState(false);
const [showSensitivity, setShowSensitivity] = useState(false);
const [showLiveSim, setShowLiveSim] = useState(false);
const [showSystemParams, setShowSystemParams] = useState(false);
const [showPlanTraces, setShowPlanTraces] = useState(false);
const [showPalette, setShowPalette] = useState(false);
@@ -421,6 +423,10 @@ function AppShellInner({ username }: { username: string }) {
<BarChart3 className="h-4 w-4" />
Grafiken
</Button>
<Button variant="secondary" onClick={() => setShowLiveSim(true)}>
<SlidersHorizontal className="h-4 w-4" />
Live-Simulation
</Button>
<Button variant="secondary" onClick={() => setShowMonteCarlo(true)}>
<Dices className="h-4 w-4" />
Monte-Carlo-Simulation
@@ -530,6 +536,10 @@ function AppShellInner({ username }: { username: string }) {
<SensitivityDialog plan={detail.plan} onClose={() => setShowSensitivity(false)} />
)}
{showLiveSim && detail && (
<LiveSimDialog plan={detail.plan} onClose={() => setShowLiveSim(false)} />
)}
{showPlanTraces && detail && (
<PlanTraceDialog
computed={computePlan(detail.plan, undefined, { explain: true })}
+4 -58
View File
@@ -1,8 +1,8 @@
"use client";
import { useMemo, useState } from "react";
import { Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { BarChart3, Download, LineChart as LineChartIcon } from "lucide-react";
import { AllocationChart, CHART_PALETTE } from "@/components/AllocationChart";
import { WealthChart, type TimelineSeries } from "@/components/WealthChart";
import { SparquoteChart } from "@/components/SparquoteChart";
import { api } from "@/lib/api-client";
@@ -10,8 +10,6 @@ import { formatChf } from "@/lib/format";
import type { PlanComputed } from "@/lib/calculations";
import type { PlanInput } from "@/lib/types";
const PALETTE = ["#4f46e5", "#0ea5e9", "#16a34a", "#d97706", "#dc2626", "#7c3äd"];
interface PlanListItem {
id: string;
name: string;
@@ -43,51 +41,15 @@ export function Dashboard({
}
const series: TimelineSeries[] = useMemo(() => {
const result: TimelineSeries[] = [{ label: plan.name, color: PALETTE[0], computed }];
const result: TimelineSeries[] = [{ label: plan.name, color: CHART_PALETTE[0], computed }];
compareIds.forEach((id, i) => {
const c = compareData[id];
const name = siblings.find((p) => p.id === id)?.name ?? id;
if (c) result.push({ label: name, color: PALETTE[(i + 1) % PALETTE.length], computed: c });
if (c) result.push({ label: name, color: CHART_PALETTE[(i + 1) % CHART_PALETTE.length], computed: c });
});
return result;
}, [plan.name, computed, compareIds, compareData, siblings]);
const ASSET_CATS = ["PENSION_FUND", "PILLAR_3A", "REAL_ESTATE", "OTHER_ASSET"];
// Asset-Elemente (nach id, damit gleiche Namen nicht kollidieren), die irgendwann einen
// positiven Wert haben -- in Reihenfolge ihres ersten Auftretens.
const assetEls = useMemo(() => {
const info = new Map<string, { name: string; any: boolean }>();
for (const phase of computed.phases) {
for (const el of phase.elements) {
if (!ASSET_CATS.includes(el.category)) continue;
const cur = info.get(el.elementId) ?? { name: el.name, any: false };
cur.name = el.name;
if (el.startValue > 0 || el.endValue > 0) cur.any = true;
info.set(el.elementId, cur);
}
}
return [...info.entries()].filter(([, v]) => v.any).map(([id, v]) => ({ id, name: v.name }));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [computed]);
// Je Phase zwei Kategorien auf der x-Achse: Beginn und Ende. Werte je Asset-Element.
const barData = useMemo(
() =>
computed.phases.flatMap((phase) => {
const beginn: Record<string, number | string> = { label: `${phase.name} · Beginn` };
const ende: Record<string, number | string> = { label: `${phase.name} · Ende` };
for (const el of phase.elements) {
if (!ASSET_CATS.includes(el.category)) continue;
beginn[el.elementId] = Math.max(0, Math.round(el.startValue));
ende[el.elementId] = Math.max(0, Math.round(el.endValue));
}
return [beginn, ende];
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[computed]
);
const otherPlans = siblings;
const lastPhase = computed.phases[computed.phases.length - 1];
@@ -151,23 +113,7 @@ export function Dashboard({
Je Phase links die Aufteilung zu Beginn, rechts am Ende. Das Ende einer Phase entspricht im
Gesamtvolumen dem Beginn der nächsten &ndash; die Aufteilung kann durch Umschichtung abweichen.
</p>
<div className="h-72 w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={barData} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="label" tick={{ fontSize: 10 }} interval={0} angle={-30} textAnchor="end" height={70} />
<YAxis
tick={{ fontSize: 11 }}
tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)}
/>
<Tooltip formatter={(v) => (typeof v === "number" ? formatChf(v) : v)} />
<Legend wrapperStyle={{ fontSize: 12 }} />
{assetEls.map((el, i) => (
<Bar key={el.id} dataKey={el.id} name={el.name} stackId="a" fill={PALETTE[i % PALETTE.length]} />
))}
</BarChart>
</ResponsiveContainer>
</div>
<AllocationChart computed={computed} />
</section>
</div>
);
+337
View File
@@ -0,0 +1,337 @@
"use client";
import { useMemo, useState } from "react";
import { ChevronDown, ChevronRight, RotateCcw, SlidersHorizontal, X } from "lucide-react";
import { AllocationChart } from "@/components/AllocationChart";
import { SparquoteChart } from "@/components/SparquoteChart";
import { WealthChart } from "@/components/WealthChart";
import { InfoBubble } from "@/components/InfoBubble";
import { computePlan } from "@/lib/calculations";
import { formatChf } from "@/lib/format";
import {
buildSliders,
describeSettings,
isNeutral,
kpisOf,
runLive,
type SliderDef,
type SliderValues,
} from "@/lib/livesim";
import { UNIT_SUFFIX } from "@/lib/sensitivity";
import type { PlanInput } from "@/lib/types";
type ChartId = "wealth" | "allocation" | "cashflow";
const CHARTS: { id: ChartId; label: string; hint: string }[] = [
{
id: "wealth",
label: "Vermögensverlauf",
hint: "Gesamtvermögen über das Alter. Die blasse Linie ist dein unveränderter Plan.",
},
{
id: "allocation",
label: "Vermögensaufteilung",
hint: "Wie sich das Vermögen zu Beginn und am Ende jeder Phase auf die Anlagen verteilt.",
},
{
id: "cashflow",
label: "Einkommen vs. Ausgaben",
hint: "Die Fläche dazwischen ist die Spar- bzw. Verzehrquote.",
},
];
const BASE_COLOR = "#9ca3af";
const LIVE_COLOR = "#4f46e5";
export function LiveSimDialog({ plan, onClose }: { plan: PlanInput; onClose: () => void }) {
const [expandReturns, setExpandReturns] = useState(false);
const [values, setValues] = useState<SliderValues>({});
// Vom Nutzer überschriebene Reglerbereiche (Schlüssel -> [min, max]).
const [bounds, setBounds] = useState<Record<string, [number, number]>>({});
const [chart, setChart] = useState<ChartId>("wealth");
const [showRanges, setShowRanges] = useState(false);
const sliders = useMemo(() => {
const base = buildSliders(plan, expandReturns);
return base.map((s) => {
const b = bounds[s.key];
return b ? { ...s, min: b[0], max: b[1] } : s;
});
}, [plan, expandReturns, bounds]);
// Referenz: der unveränderte Plan. Wird nur neu gerechnet, wenn sich der Plan ändert.
const base = useMemo(() => {
const computed = computePlan(plan);
return { computed, kpis: kpisOf(computed) };
}, [plan]);
// Der Kern: bei JEDER Reglerbewegung synchron neu rechnen (~0.2 ms, siehe livesim.ts).
const live = useMemo(() => runLive(plan, sliders, values), [plan, sliders, values]);
const neutral = isNeutral(sliders, values);
const settings = describeSettings(sliders, values);
function setValue(key: string, v: number) {
setValues((prev) => ({ ...prev, [key]: v }));
}
function reset() {
setValues({});
}
// Beim Auf-/Zuklappen die Rendite-Regler zurücksetzen: Sammel- und Element-Regler bewegen
// dieselben Werte, ein Übertrag würde doppelt zählen.
function toggleExpand() {
setValues((prev) => {
const next: SliderValues = {};
for (const [k, v] of Object.entries(prev)) {
if (k === "d:returns" || k === "d:propertyGrowth" || k.startsWith("e:")) continue;
next[k] = v;
}
return next;
});
setExpandReturns((v) => !v);
}
return (
<div className="ui-fade fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={onClose}>
<div
onClick={(e) => e.stopPropagation()}
className="ui-pop flex w-full max-w-6xl flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl"
>
<div className="flex items-center justify-between">
<h2 className="flex items-center gap-2 text-base font-semibold text-fg">
<SlidersHorizontal className="h-5 w-5 text-accent" /> Live-Simulation
</h2>
<button type="button" onClick={onClose} aria-label="Schliessen" className="rounded-md p-1 text-faint hover:bg-surface-2">
<X className="h-4 w-4" />
</button>
</div>
<p className="rounded-xl border border-border bg-surface-2 p-3 text-xs leading-relaxed text-muted">
Dreh an den Reglern und sieh sofort, was passiert. <strong className="text-fg">Nichts davon wird
gespeichert</strong> dein Plan bleibt unverändert, du brauchst für kein Durchspielen eine
Szenario-Kopie. Die Regler benutzen dieselben Umrechnungen wie die Einflussfaktoren-Analyse.
</p>
{/* Kennzahlenleiste: die eigentliche Antwort. Die Grafik zeigt WANN, das hier WIE VIEL. */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<DeltaCard label="Endvermögen (nominal)" base={base.kpis.endNominal} live={live.kpis.endNominal} />
<DeltaCard label="Endvermögen (real)" base={base.kpis.endReal} live={live.kpis.endReal} />
<RuinCard baseAge={base.kpis.ruinAge} liveAge={live.kpis.ruinAge} />
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[320px_1fr]">
{/* --- Links: Regler --- */}
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="flex items-center text-xs font-semibold uppercase tracking-wide text-faint">
Parameter
<InfoBubble text="Es erscheinen nur Regler, die in diesem Plan überhaupt etwas bewegen. Die Standardbereiche sind Vorschläge du kannst beide Enden anpassen." />
</span>
<button
type="button"
onClick={reset}
disabled={neutral}
className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-[11px] font-medium text-muted transition-colors hover:bg-surface-2 hover:text-fg disabled:opacity-40"
>
<RotateCcw className="h-3 w-3" /> Zurücksetzen
</button>
</div>
<button
type="button"
onClick={toggleExpand}
className="flex items-center gap-1.5 self-start rounded-lg border border-dashed border-border px-2.5 py-1 text-[11px] font-medium text-muted transition-colors hover:bg-surface-2 hover:text-fg"
>
{expandReturns ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
{expandReturns ? "Renditen wieder zusammenfassen" : "Renditen einzeln aufschlüsseln"}
</button>
<label className="flex items-center gap-1.5 text-[11px] text-muted">
<input type="checkbox" checked={showRanges} onChange={() => setShowRanges((v) => !v)} />
Bereiche anpassen
</label>
<div className="flex max-h-[46vh] flex-col gap-3 overflow-y-auto pr-1">
{sliders.map((s) => (
<Slider
key={s.key}
def={s}
value={values[s.key] ?? s.neutral}
showRange={showRanges}
onChange={(v) => setValue(s.key, v)}
onBounds={(min, max) => setBounds((prev) => ({ ...prev, [s.key]: [min, max] }))}
/>
))}
{sliders.length === 0 && (
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-xs text-muted">
Dieser Plan enthält noch keine Elemente, an denen sich etwas regeln liesse.
</p>
)}
</div>
{settings.length > 0 && (
<div className="rounded-lg border border-border bg-surface-2 p-2.5">
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wide text-faint">
Aktive Einstellung
</div>
<p className="text-[11px] leading-relaxed text-muted">{settings.join(" · ")}</p>
</div>
)}
</div>
{/* --- Rechts: Grafik --- */}
<div className="flex min-w-0 flex-col gap-2">
<div className="inline-flex flex-wrap gap-0.5 self-start rounded-lg border border-border bg-surface-2 p-0.5 text-xs">
{CHARTS.map((c) => (
<button
key={c.id}
type="button"
onClick={() => setChart(c.id)}
className={`rounded-md px-2.5 py-1 font-medium transition-colors ${
chart === c.id ? "bg-accent text-accent-fg" : "text-muted hover:text-fg"
}`}
>
{c.label}
</button>
))}
</div>
<p className="text-[11px] text-muted">{CHARTS.find((c) => c.id === chart)!.hint}</p>
<div className="min-w-0 rounded-xl border border-border bg-surface p-3">
{chart === "wealth" && (
<WealthChart
series={[
// Referenz zuerst, damit die Live-Linie darüber liegt.
{ label: "Dein Plan", color: BASE_COLOR, computed: base.computed },
{ label: "Simuliert", color: LIVE_COLOR, computed: live.computed },
]}
/>
)}
{chart === "allocation" && <AllocationChart computed={live.computed} />}
{chart === "cashflow" && <SparquoteChart computed={live.computed} />}
</div>
{chart !== "wealth" && (
<p className="text-[11px] text-faint">
Diese Grafik zeigt nur den simulierten Stand. Die Gegenüberstellung mit deinem
unveränderten Plan liefert der Vermögensverlauf und die Zahlen oben.
</p>
)}
</div>
</div>
</div>
</div>
);
}
// Ein Regler. Die Zahl steht bewusst neben dem Schieber und ist auch direkt eingebbar --
// mit der Maus trifft man 5.2 % nicht zuverlässig.
function Slider({
def,
value,
showRange,
onChange,
onBounds,
}: {
def: SliderDef;
value: number;
showRange: boolean;
onChange: (v: number) => void;
onBounds: (min: number, max: number) => void;
}) {
const suffix = UNIT_SUFFIX[def.unit];
const touched = value !== def.neutral;
return (
<div className={`rounded-lg border p-2.5 ${touched ? "border-accent bg-accent-soft/20" : "border-border bg-surface-2"}`}>
<div className="mb-1.5 flex items-center justify-between gap-2">
<span className="flex min-w-0 items-center text-xs font-medium text-fg">
<span className="truncate">{def.label}</span>
<InfoBubble text={def.help} />
</span>
<span className="flex shrink-0 items-center gap-1">
<input
type="number"
value={value}
step={def.step}
onChange={(e) => onChange(Number(e.target.value))}
className="w-16 rounded border border-border bg-surface px-1 py-0.5 text-right text-xs text-fg"
/>
<span className="text-[10px] text-faint">{suffix}</span>
</span>
</div>
<input
type="range"
min={def.min}
max={def.max}
step={def.step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="w-full accent-[var(--accent)]"
/>
{showRange ? (
<div className="mt-1 flex items-center gap-1.5 text-[10px] text-faint">
<input
type="number"
value={def.min}
step={def.step}
onChange={(e) => onBounds(Number(e.target.value), def.max)}
className="w-14 rounded border border-border bg-surface px-1 py-0.5 text-right text-fg"
/>
<span>bis</span>
<input
type="number"
value={def.max}
step={def.step}
onChange={(e) => onBounds(def.min, Number(e.target.value))}
className="w-14 rounded border border-border bg-surface px-1 py-0.5 text-right text-fg"
/>
<span className="ml-auto">neutral: {def.neutral} {suffix}</span>
</div>
) : (
<div className="mt-0.5 flex justify-between text-[10px] text-faint">
<span>{def.min}</span>
<span>{def.max}</span>
</div>
)}
</div>
);
}
function DeltaCard({ label, base, live }: { label: string; base: number; live: number }) {
const diff = live - base;
const tone = diff === 0 ? "text-fg" : diff > 0 ? "text-success" : "text-danger";
return (
<div className="rounded-xl border border-border bg-surface-2 p-3">
<div className="text-[10px] font-semibold uppercase tracking-wide text-faint">{label}</div>
<div className="mt-0.5 text-lg font-semibold text-fg">{formatChf(live)}</div>
<div className="text-[11px] text-muted">
Plan: {formatChf(base)}
{diff !== 0 && (
<span className={`ml-1.5 font-semibold ${tone}`}>
{diff > 0 ? "+" : ""}
{formatChf(Math.abs(diff))}
</span>
)}
</div>
</div>
);
}
// Ein gekippter Plan ist die wichtigste Einzelinformation -- einer Verlaufslinie sieht man
// nicht zuverlässig an, dass das Kapital zwischendurch unter null gefallen ist.
function RuinCard({ baseAge, liveAge }: { baseAge: number | null; liveAge: number | null }) {
const broken = liveAge !== null;
return (
<div className={`rounded-xl border p-3 ${broken ? "border-danger bg-danger-soft" : "border-border bg-surface-2"}`}>
<div className="text-[10px] font-semibold uppercase tracking-wide text-faint">Kapital reicht</div>
<div className={`mt-0.5 text-lg font-semibold ${broken ? "text-danger" : "text-success"}`}>
{broken ? `aufgebraucht mit ${liveAge}` : "bis Planende"}
</div>
<div className="text-[11px] text-muted">
{baseAge === null ? "Plan: reicht bis Planende" : `Plan: aufgebraucht mit ${baseAge}`}
</div>
</div>
);
}
+1 -1
View File
@@ -47,7 +47,7 @@ const STEPS: TourStep[] = [
{
target: "analysen",
title: "Analysen",
text: "Grafiken, Monte-Carlo-Simulation und Einflussfaktoren: Wie sicher ist dein Plan, und welche Annahme entscheidet wirklich?",
text: "Grafiken, Live-Simulation, Monte-Carlo und Einflussfaktoren: Was passiert, wenn ich hier drehe wie sicher ist mein Plan und welche Annahme entscheidet wirklich?",
},
];