"use client"; import { useMemo, useState } from "react"; import { Bar, BarChart, CartesianGrid, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; import { Tornado, X } from "lucide-react"; import { RequiredNumberField, SelectField } from "@/components/FormField"; import { InfoBubble } from "@/components/InfoBubble"; import { formatChf } from "@/lib/format"; import { computeTornado, DRIVERS, UNIT_SUFFIX, type DriverDef, type DriverId, type TornadoMetric, type TornadoResult, } from "@/lib/sensitivity"; import type { PlanInput } from "@/lib/types"; interface RangeDraft { checked: boolean; low: string; high: string; } // Bewusst OHNE Defaults: Die Balkenlänge hängt direkt von der eingegebenen Bandbreite ab. // Ein stiller Default würde nicht hinterfragt und das Ranking wäre dann eine Aussage über // unsere Vorgabe statt über den Plan (gleiche Begründung wie bei den Monte-Carlo-Mittelwerten). const emptyDraft: RangeDraft = { checked: false, low: "", high: "" }; function formatRange(low: number, high: number, unit: DriverDef["unit"]): string { const suffix = UNIT_SUFFIX[unit]; const sign = (v: number) => (unit === "abs_pct" ? `${v}` : v > 0 ? `+${v}` : `${v}`); return `${sign(low)} ${suffix} → ${sign(high)} ${suffix}`; } export function SensitivityDialog({ plan, onClose }: { plan: PlanInput; onClose: () => void }) { const available = useMemo(() => DRIVERS.filter((d) => d.applies(plan)), [plan]); const [metric, setMetric] = useState("real"); const [drafts, setDrafts] = useState>({}); const [result, setResult] = useState(null); function draftFor(id: DriverId): RangeDraft { return drafts[id] ?? emptyDraft; } function setDraft(id: DriverId, patch: Partial) { setDrafts((prev) => ({ ...prev, [id]: { ...draftFor(id), ...patch } })); setResult(null); } const checked = available.filter((d) => draftFor(d.id).checked); const incomplete = checked.filter((d) => { const dr = draftFor(d.id); return dr.low.trim() === "" || dr.high.trim() === ""; }); const degenerate = checked.filter((d) => { const dr = draftFor(d.id); return dr.low.trim() !== "" && dr.high.trim() !== "" && Number(dr.low) === Number(dr.high); }); // Mindestens zwei Treiber: Ein Tornado ist eine RANGLISTE. Mit einem einzigen Balken gibt // es nichts zu ordnen und die Grafik würde eine Aussage suggerieren, die sie nicht hat. const canRun = checked.length >= 2 && incomplete.length === 0 && degenerate.length === 0 && plan.phases.length > 0; function run() { setResult( computeTornado( plan, metric, checked.map((d) => { const dr = draftFor(d.id); return { id: d.id, low: Number(dr.low), high: Number(dr.high) }; }) ) ); } const chartData = useMemo(() => { if (!result) return []; // Von unten nach oben gezeichnet -> für die Trichterform (grösster Balken zuoberst) // muss die Reihenfolge umgekehrt in die Grafik. return [...result.bars].reverse().map((b) => ({ label: b.shortLabel, range: [b.min, b.max] as [number, number], swing: b.swing, })); }, [result]); return (
e.stopPropagation()} className="flex w-full max-w-4xl flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl" >

Einflussfaktoren (Sensitivitätsanalyse)

{/* Erklärung */}

Was ist das? Nicht «wie viel Geld habe ich am Schluss», sondern «welche meiner Annahmen entscheidet überhaupt über das Ergebnis». Bei manchen Annahmen ist es egal, ob du sie exakt triffst – bei anderen kippt eine kleine Abweichung den ganzen Plan.

Wie es funktioniert: Zuerst wird dein Plan wie erfasst gerechnet (Basisfall). Dann wird ein einziger Parameter auf seinen tiefen und seinen hohen Wert gesetzt, alle übrigen bleiben unverändert – das ergibt zwei Ergebnisse. Das für jeden Parameter wiederholt und nach Spannweite sortiert ergibt die Trichterform: längster Balken zuoberst.

Was du davon hast: Die Reihenfolge ist die Botschaft. Bei den obersten Balken lohnt sich Genauigkeit (dort exakte Zahlen beschaffen) – und sie sind meist auch die, die du selbst steuern kannst. Bei den untersten darfst du grob schätzen.

Zwei ehrliche Grenzen. Erstens hängt die Balkenlänge von den Bandbreiten ab, die du unten eingibst – wähle sie so, dass sie ähnlich plausibel sind, nicht ähnlich gross. Zweitens wird immer nur ein Parameter auf einmal variiert; Kombinationen (schlechte Renditen und hohe Ausgaben) treffen härter als die Summe der Einzelbalken – dafür ist die Monte-Carlo-Simulation zuständig.

{/* Zielgrösse */}
{ setMetric(v); setResult(null); }} options={[ { value: "real", label: "Endvermögen real (kaufkraftbereinigt)" }, { value: "nominal", label: "Endvermögen nominal" }, ]} />
{/* Parameter */}
Parameter und Bandbreiten
{available.map((d) => { const dr = draftFor(d.id); const suffix = UNIT_SUFFIX[d.unit]; const unitHint = d.unit === "abs_pct" ? "absoluter Wert in %" : d.unit === "delta_pp" ? "Verschiebung in Prozentpunkten" : d.unit === "rel_pct" ? "Abweichung vom Planwert in %" : "Verschiebung in Jahren"; return (
{dr.checked && (
setDraft(d.id, { low: v })} /> setDraft(d.id, { high: v })} />
)}
); })}

Das Pensionsalter ist bewusst nicht enthalten: Es lässt sich nicht sinnvoll variieren, ohne gleichzeitig die Phasengrenzen mitzuverschieben – sonst arbeitet die Person im Modell unverändert weiter bzw. der Pensions-Übergang entfällt ganz. Der verwandte Treiber «Lebensdauer» ist dagegen sauber abgebildet.

{checked.length < 2 && (

Bitte mindestens zwei Parameter auswählen – der Tornado ist eine Rangliste.

)} {incomplete.length > 0 && (

Bitte für jeden ausgewählten Parameter eine tiefe und eine hohe Ausprägung angeben.

)} {degenerate.length > 0 && (

Tiefer und hoher Wert sind identisch bei: {degenerate.map((d) => d.label).join(", ")}. Damit gibt es keine Spannweite.

)}
{result && }
); } function TornadoResults({ result, metric, chartData, }: { result: TornadoResult; metric: TornadoMetric; chartData: { label: string; range: [number, number]; swing: number }[]; }) { const metricLabel = metric === "real" ? "Endvermögen real" : "Endvermögen nominal"; return (
Basisfall · {metricLabel}
{formatChf(result.base)} CHF
Einflussfaktoren nach Spannweite

Jeder Balken zeigt, zwischen welchen Werten das {metricLabel} schwankt, wenn nur dieser eine Parameter innerhalb seiner Bandbreite variiert. Die senkrechte Linie ist der Basisfall.

Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} /> Array.isArray(v) ? `${formatChf(Number(v[0]))} – ${formatChf(Number(v[1]))}` : typeof v === "number" ? formatChf(v) : v } />
{result.bars.map((b) => ( ))}
Parameter Bandbreite bei «tief» bei «hoch» Spannweite
{b.label} {b.note &&
{b.note}
}
{formatRange(b.low, b.high, b.unit)} {formatChf(b.lowResult)} {formatChf(b.highResult)} {formatChf(b.swing)}

Die Reihenfolge – nicht der absolute Betrag – ist die Aussage. Sie hängt von den eingegebenen Bandbreiten ab.

); }