c2fb82b0de
Deploy App / deploy (push) Successful in 1m1s
Rein an der Oberflaeche -- Berechnung, Datenmodell und API-Semantik unveraendert (103 Tests unveraendert gruen). Paket A (Fundament): - durchgehend Du-Form und echte Umlaute in allen sichtbaren Texten, inkl. API-Fehlermeldungen (vorher Mix aus Sie/Du und ae/oe/ue) - neue UI-Primitiven (ui.tsx): Button, Modal mit ESC/Fokus-Falle/Animation, Bestaetigungs-Dialog statt window.confirm, Toasts statt alert, Skeleton-Loader, EmptyState - eigene Attention-Farbe (Amber) fuer offene Entscheide, getrennt vom Akzent - Micro-Interactions mit prefers-reduced-motion-Fallback Paket B (Onboarding, Roadmap Nr. 10): - gefuehrter Plan-Assistent in 5 Schritten; Einkommen bewusst pro Person (raeumt die 9.9-AHV-Falle aus); reine Orchestrierung bestehender Endpunkte - Beispielplan mit einem Klick; Uebergaenge absichtlich offen - interaktive Tour ueber die Planansicht (localStorage, jederzeit neu startbar) - abgeleitete "Naechste Schritte"-Karte (offene Entscheide, fehlende Elemente, fehlende Pensionsphase, Ruin -> Einflussfaktoren) Paket C (Struktur): - Inspector-Panel rechts statt Modals fuer alle Einzel-Bearbeitungen; Matrix bleibt sichtbar, Zellklick wechselt den Inhalt - Phasenkopf auf vier Kern-Infos entschlackt (Rest in der 0.11-Detailansicht) - Matrix mit eigenem Scrollbereich, Koepfe beidachsig fixiert - Sidebar-Gruppen "Meine Plaene" / "Wissen"; "So rechnet FPT" statt SPEZIFIKATION; "Szenario-Profil" statt "Plan-Einstellungen" - Aktions-Icons ohne Hover sichtbar (Touch) Paket D (Extras): - Sparklines je Element-Zeile aus den 0.11-Verlaufswerten - Befehls-Palette (Ctrl/Cmd+K) - Ruin-Banner verlinkt auf die Einflussfaktoren Nebenbei: der ProfileMenu-Lint-Fehler und der Selection-Rest (9.17) sind behoben -- npm run lint laeuft erstmals fehlerfrei. SPEZIFIKATION auf 0.13: neue Kapitel 3.2.8, 3.7.6-3.7.9, 9.23, 9.24; 3.6.3 und 3.7.1 ueberarbeitet, 9.17 bereinigt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
320 lines
14 KiB
TypeScript
320 lines
14 KiB
TypeScript
"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<TornadoMetric>("real");
|
||
const [drafts, setDrafts] = useState<Record<string, RangeDraft>>({});
|
||
const [result, setResult] = useState<TornadoResult | null>(null);
|
||
|
||
function draftFor(id: DriverId): RangeDraft {
|
||
return drafts[id] ?? emptyDraft;
|
||
}
|
||
function setDraft(id: DriverId, patch: Partial<RangeDraft>) {
|
||
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 (
|
||
<div className="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="flex w-full max-w-4xl 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">
|
||
<Tornado className="h-5 w-5 text-accent" /> Einflussfaktoren (Sensitivitätsanalyse)
|
||
</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>
|
||
|
||
{/* Erklärung */}
|
||
<div className="rounded-xl border border-border bg-surface-2 p-4 text-xs leading-relaxed text-muted">
|
||
<p className="mb-2">
|
||
<strong className="text-fg">Was ist das?</strong> Nicht «wie viel Geld habe ich am Schluss», sondern
|
||
<strong className="text-fg"> «welche meiner Annahmen entscheidet überhaupt über das Ergebnis»</strong>. Bei
|
||
manchen Annahmen ist es egal, ob du sie exakt triffst – bei anderen kippt eine kleine Abweichung den ganzen Plan.
|
||
</p>
|
||
<p className="mb-2">
|
||
<strong className="text-fg">Wie es funktioniert:</strong> Zuerst wird dein Plan wie erfasst gerechnet
|
||
(Basisfall). Dann wird <em>ein einziger</em> 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
|
||
<strong className="text-fg"> Spannweite</strong> sortiert ergibt die Trichterform: längster Balken zuoberst.
|
||
</p>
|
||
<p className="mb-2">
|
||
<strong className="text-fg">Was du davon hast:</strong> 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.
|
||
</p>
|
||
<p>
|
||
<strong className="text-fg">Zwei ehrliche Grenzen.</strong> Erstens hängt die Balkenlänge von den
|
||
Bandbreiten ab, die du unten eingibst – wähle sie so, dass sie <em>ähnlich plausibel</em> sind, nicht ähnlich
|
||
gross. Zweitens wird immer nur ein Parameter auf einmal variiert; <em>Kombinationen</em> (schlechte Renditen
|
||
<em> und</em> hohe Ausgaben) treffen härter als die Summe der Einzelbalken – dafür ist die
|
||
Monte-Carlo-Simulation zuständig.
|
||
</p>
|
||
</div>
|
||
|
||
{/* Zielgrösse */}
|
||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||
<SelectField
|
||
label="Zielgrösse"
|
||
help="Woran die Wirkung gemessen wird: das Endvermögen der letzten Lebensphase. «Real» ist kaufkraftbereinigt auf den Planbeginn und damit die ehrlichere Grösse."
|
||
value={metric}
|
||
onChange={(v: TornadoMetric) => {
|
||
setMetric(v);
|
||
setResult(null);
|
||
}}
|
||
options={[
|
||
{ value: "real", label: "Endvermögen real (kaufkraftbereinigt)" },
|
||
{ value: "nominal", label: "Endvermögen nominal" },
|
||
]}
|
||
/>
|
||
</div>
|
||
|
||
{/* Parameter */}
|
||
<div>
|
||
<div className="mb-2 flex items-center text-xs font-semibold uppercase tracking-wide text-faint">
|
||
Parameter und Bandbreiten
|
||
<InfoBubble text="Hake an, welche Parameter in die Analyse einfliessen sollen, und gib je Parameter eine tiefe und eine hohe Ausprägung an. Es gibt bewusst keine Vorgabewerte: Die Balkenlänge hängt direkt von diesen Bandbreiten ab, deshalb sollst du sie bewusst setzen. Die Hilfe je Parameter nennt plausible Grössenordnungen." />
|
||
</div>
|
||
<div className="flex flex-col gap-2">
|
||
{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 (
|
||
<div
|
||
key={d.id}
|
||
className={`rounded-xl border p-3 ${dr.checked ? "border-accent bg-accent-soft/30" : "border-border bg-surface-2"}`}
|
||
>
|
||
<label className="flex items-center gap-2 text-sm">
|
||
<input type="checkbox" checked={dr.checked} onChange={(e) => setDraft(d.id, { checked: e.target.checked })} />
|
||
<span className="font-medium text-fg">{d.label}</span>
|
||
<InfoBubble text={d.help} />
|
||
<span className="text-[11px] text-faint">({unitHint})</span>
|
||
</label>
|
||
{dr.checked && (
|
||
<div className="mt-2 grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||
<RequiredNumberField
|
||
label="tief"
|
||
value={dr.low}
|
||
suffix={suffix}
|
||
step={d.unit === "delta_years" ? 1 : 0.1}
|
||
onChange={(v) => setDraft(d.id, { low: v })}
|
||
/>
|
||
<RequiredNumberField
|
||
label="hoch"
|
||
value={dr.high}
|
||
suffix={suffix}
|
||
step={d.unit === "delta_years" ? 1 : 0.1}
|
||
onChange={(v) => setDraft(d.id, { high: v })}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
<p className="mt-2 text-[11px] text-faint">
|
||
Das <strong>Pensionsalter</strong> 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.
|
||
</p>
|
||
</div>
|
||
|
||
{checked.length < 2 && (
|
||
<p className="text-xs text-danger">Bitte mindestens zwei Parameter auswählen – der Tornado ist eine Rangliste.</p>
|
||
)}
|
||
{incomplete.length > 0 && (
|
||
<p className="text-xs text-danger">Bitte für jeden ausgewählten Parameter eine tiefe und eine hohe Ausprägung angeben.</p>
|
||
)}
|
||
{degenerate.length > 0 && (
|
||
<p className="text-xs text-danger">
|
||
Tiefer und hoher Wert sind identisch bei: {degenerate.map((d) => d.label).join(", ")}. Damit gibt es keine Spannweite.
|
||
</p>
|
||
)}
|
||
|
||
<div>
|
||
<button
|
||
type="button"
|
||
disabled={!canRun}
|
||
onClick={run}
|
||
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-fg shadow-sm hover:bg-accent-hover disabled:opacity-50"
|
||
>
|
||
Einflussfaktoren berechnen
|
||
</button>
|
||
</div>
|
||
|
||
{result && <TornadoResults result={result} metric={metric} chartData={chartData} />}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="flex flex-col gap-4 border-t border-border pt-4">
|
||
<div className="rounded-xl border border-border bg-surface-2 px-4 py-3">
|
||
<div className="text-xs text-muted">Basisfall · {metricLabel}</div>
|
||
<div className="mt-0.5 text-xl font-semibold text-accent">{formatChf(result.base)} CHF</div>
|
||
</div>
|
||
|
||
<div>
|
||
<div className="mb-1 text-xs font-semibold text-fg">Einflussfaktoren nach Spannweite</div>
|
||
<p className="mb-2 text-[11px] text-muted">
|
||
Jeder Balken zeigt, zwischen welchen Werten das {metricLabel} schwankt, wenn nur dieser eine Parameter
|
||
innerhalb seiner Bandbreite variiert. Die senkrechte Linie ist der Basisfall.
|
||
</p>
|
||
<div className="w-full" style={{ height: Math.max(200, chartData.length * 46 + 60) }}>
|
||
<ResponsiveContainer width="100%" height="100%">
|
||
<BarChart data={chartData} layout="vertical" margin={{ top: 8, right: 24, left: 8, bottom: 8 }}>
|
||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||
<XAxis
|
||
type="number"
|
||
tick={{ fontSize: 11 }}
|
||
tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)}
|
||
/>
|
||
<YAxis type="category" dataKey="label" tick={{ fontSize: 11 }} width={130} />
|
||
<Tooltip
|
||
formatter={(v) =>
|
||
Array.isArray(v)
|
||
? `${formatChf(Number(v[0]))} – ${formatChf(Number(v[1]))}`
|
||
: typeof v === "number"
|
||
? formatChf(v)
|
||
: v
|
||
}
|
||
/>
|
||
<ReferenceLine x={result.base} stroke="var(--muted)" strokeDasharray="4 3" />
|
||
<Bar dataKey="range" name={metricLabel} fill="var(--accent)" radius={[3, 3, 3, 3]} isAnimationActive={false} />
|
||
</BarChart>
|
||
</ResponsiveContainer>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="overflow-x-auto rounded-xl border border-border">
|
||
<table className="w-full border-collapse text-sm">
|
||
<thead>
|
||
<tr className="bg-surface-2 text-xs text-faint">
|
||
<th className="px-3 py-2 text-left font-semibold">Parameter</th>
|
||
<th className="px-3 py-2 text-left font-semibold">Bandbreite</th>
|
||
<th className="px-3 py-2 text-right font-semibold">bei «tief»</th>
|
||
<th className="px-3 py-2 text-right font-semibold">bei «hoch»</th>
|
||
<th className="px-3 py-2 text-right font-semibold">Spannweite</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{result.bars.map((b) => (
|
||
<tr key={b.id} className="border-t border-border">
|
||
<td className="px-3 py-2 font-medium text-fg">
|
||
{b.label}
|
||
{b.note && <div className="mt-0.5 text-[11px] font-normal text-muted">{b.note}</div>}
|
||
</td>
|
||
<td className="px-3 py-2 text-muted">{formatRange(b.low, b.high, b.unit)}</td>
|
||
<td className="px-3 py-2 text-right text-muted">{formatChf(b.lowResult)}</td>
|
||
<td className="px-3 py-2 text-right text-muted">{formatChf(b.highResult)}</td>
|
||
<td className="px-3 py-2 text-right font-semibold text-fg">{formatChf(b.swing)}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<p className="text-[11px] text-faint">
|
||
Die Reihenfolge – nicht der absolute Betrag – ist die Aussage. Sie hängt von den eingegebenen Bandbreiten ab.
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|