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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 })}
|
||||
|
||||
@@ -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 – 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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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?",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user