V5-Modell: Einkommen nominal, Ausgaben real->nominal, Inflation plan-weit, Sparquoten-Grafik
Deploy App / deploy (push) Successful in 57s

- Einkommen: nominale Basis + nominale Lohnerhoehung (Default 0). Real read-only als Info.
- Ausgaben: REALE Basis (heutige Kaufkraft) + reale Mehrausgaben (Default 0); nominal =
  real x plan-weite Inflation, read-only. Loest die "nominale Ausgaben verwirren"-Problematik.
- Inflation nur noch plan-weit (Phasen-Override entfernt).
- Engine liefert flowDeflatorEnd (Flow-Realwerte) + yearly-Serie (Jahr/Alter/Einkommen/
  Ausgabe nominal/real). Nominal/Real-Umschalter nutzt Flow- vs. Bestands-Deflatoren.
- Neue Grafik (Dashboard): Einkommen vs. nominale Ausgabe mit eingefaerbter Sparquoten-
  Flaeche (gruen/rot) + reale Ausgabe als Referenzlinie (Inflationskeil).
- In-Tool-Erklaerungen in den Einkommen/Ausgaben-Popups.
- Golden Tests aufs neue Modell hergeleitet (8 gruen, u.a. Ausgaben real->nominal, Ruin 94).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 22:20:06 +02:00
parent 310ebc66fb
commit 6402583e0c
7 changed files with 212 additions and 80 deletions
+13
View File
@@ -4,6 +4,7 @@ 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 { WealthChart, type TimelineSeries } from "@/components/WealthChart";
import { SparquoteChart } from "@/components/SparquoteChart";
import { api } from "@/lib/api-client";
import { formatChf } from "@/lib/format";
import type { PlanComputed } from "@/lib/calculations";
@@ -97,6 +98,18 @@ export function Dashboard({
<StatCard label="Geschaetzter Nachlass" value={computed.nachlass} help="Endvermoegen der letzten Phase - potenziell vererbbar." />
</div>
<section className="rounded-xl border border-border bg-surface p-4 shadow-sm">
<h3 className="mb-1 flex items-center gap-1.5 text-sm font-semibold text-fg">
<LineChartIcon className="h-4 w-4 text-accent" />
Einkommen vs. Ausgaben pro Jahr
</h3>
<p className="mb-3 text-xs text-muted">
Die Flaeche zwischen Einkommen und nominalen Ausgaben ist die Spar- (gruen) bzw. Verzehrquote (rot).
Die blasse Linie sind die realen Ausgaben &ndash; der Abstand zur nominalen Linie ist der Inflationsanteil.
</p>
<SparquoteChart computed={computed} />
</section>
<section className="rounded-xl border border-border bg-surface p-4 shadow-sm">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h3 className="flex items-center gap-1.5 text-sm font-semibold text-fg">
+30 -15
View File
@@ -24,7 +24,8 @@ export interface CellContext {
carriedEndValue: number; // Endwert des Elements in der (Vor-)Phase, fuer Bezugs-Maxima
carried: boolean; // Phase >= 2: Basiswert wird aus der Vorphase fortgeschrieben
derivedStart: number; // fortgeschriebener Basiswert (read-only Anzeige)
phaseInflation: number; // Default fuer Teuerungsausgleich
phaseInflation: number; // Plan-Inflationsrate (Info)
deflatorStart: number; // Kaufkraft-Deflator zu Phasenbeginn (real <-> nominal, erstes Jahr)
}
interface Props {
@@ -136,28 +137,42 @@ export function ElementPhaseFields({
case "INCOME":
case "EXPENSE": {
const isIncome = element.category === "INCOME";
const teuerung = (
<NumberField
label="Teuerungsausgleich (%/Jahr)"
help="Indexiert den Betrag ueber die Phasenjahre (Jahr t = Basis x (1+Satz)^(t-1)). Default = Phaseninflation. 0% = nominal flach (real sinkend)."
step={0.1}
value={num(pd.teuerungsausgleich, context.phaseInflation)}
onChange={(v) => setP({ teuerungsausgleich: v })}
/>
);
// Basiswert (erstes Jahr der Phase). Ab Phase 2 mit dem fortgeschriebenen Endwert der
// Vorphase vorbelegt, aber bewusst aenderbar (Teilzeit, Beförderung, Jobwechsel …).
const baseLabel = isIncome ? "Jahreseinkommen (CHF, erstes Jahr)" : "Jahresausgaben (CHF, erstes Jahr)";
// Basiswert (erstes Jahr). Ab Phase 2 mit dem fortgeschriebenen Wert der Vorphase
// vorbelegt, aber bewusst aenderbar (Teilzeit, Befoerderung, Jobwechsel …).
const baseValue = typeof pd.amount === "number" ? pd.amount : carried ? context.derivedStart : 0;
const rate = num(pd.teuerungsausgleich, 0);
const d = context.deflatorStart || 1;
// Info-Gegenwert im ersten Jahr: Einkommen -> real; Ausgaben -> nominal.
const otherValue = isIncome ? Math.round(baseValue / d) : Math.round(baseValue * d);
return (
<>
<p className="col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
{isIncome
? "Einkommen wird NOMINAL erfasst (die Zahl auf dem Lohnausweis). Der reale Wert (heutige Kaufkraft) wird nur zur Info angezeigt."
: "Ausgaben werden REAL erfasst (in heutiger Kaufkraft). Die Inflation (plan-weit) rechnet daraus automatisch die nominalen Ausgaben nur zur Info."}
</p>
<MoneyField
label={baseLabel}
label={isIncome ? "Jahreseinkommen NOMINAL (erstes Jahr)" : "Jahresausgaben REAL (erstes Jahr, heutige Kaufkraft)"}
help={carried ? "Vorbelegt mit dem fortgeschriebenen Wert der Vorphase; hier bewusst aenderbar." : undefined}
value={baseValue}
onChange={(v) => setP({ amount: v })}
/>
{teuerung}
<DerivedField
label={isIncome ? "≈ real (heutige Kaufkraft)" : "≈ nominal (in diesem Jahr)"}
value={otherValue}
help="Nur zur Info, wird automatisch berechnet."
/>
<NumberField
label={isIncome ? "Nominale Lohnerhoehung (%/Jahr)" : "Reale Mehrausgaben (%/Jahr)"}
help={
isIncome
? "Geschaetzte jaehrliche Lohnerhoehung. 0% = Lohn bleibt nominal gleich und verliert real an Kaufkraft."
: "Zusaetzliche reale Mehrausgaben pro Jahr (z. B. weil man sich mehr goennt). Die Inflation kommt separat automatisch dazu. 0% = gleicher Lebensstandard."
}
step={0.1}
value={rate}
onChange={(v) => setP({ teuerungsausgleich: v })}
/>
</>
);
}
+3 -12
View File
@@ -10,20 +10,17 @@ export function PhaseDetail({
phase,
maxDurationYears,
isLast,
inflationDefault,
onSaved,
onDeleted,
}: {
phase: PhaseInput;
maxDurationYears: number | null;
isLast: boolean;
inflationDefault: number;
onSaved: () => void;
onDeleted: () => void;
}) {
const [name, setName] = useState(phase.name);
const [durationYears, setDurationYears] = useState(phase.durationYears);
const [inflationRate, setInflationRate] = useState<number | null>(phase.inflationRate);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -33,7 +30,7 @@ export function PhaseDetail({
setSaving(true);
setError(null);
try {
await api.put(`/api/phases/${phase.id}`, { name, durationYears, inflationRate });
await api.put(`/api/phases/${phase.id}`, { name, durationYears });
onSaved();
} catch (e) {
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
@@ -69,7 +66,7 @@ export function PhaseDetail({
)}
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<TextField label="Bezeichnung" value={name} onChange={setName} />
<NumberField
label={`Dauer (Jahre)${cap != null ? ` · max. ${cap}` : ""}`}
@@ -79,14 +76,8 @@ export function PhaseDetail({
max={cap ?? undefined}
onChange={(v) => setDurationYears(cap != null ? Math.min(v, cap) : v)}
/>
<NumberField
label="Inflationsrate (%)"
help="Ueberschreibt die Standardannahme aus dem Plan-Grundprofil."
value={inflationRate ?? inflationDefault}
step={0.1}
onChange={setInflationRate}
/>
</div>
<p className="text-xs text-faint">Die Inflationsrate gilt plan-weit und wird in den Plan-Einstellungen gesetzt.</p>
{error && <p className="text-sm text-danger">{error}</p>}
<div>
+11 -7
View File
@@ -176,6 +176,7 @@ export function PlanView({
carried: ce?.carried ?? false,
derivedStart: ce?.baseValue ?? 0,
phaseInflation: phaseInflationFor(phase.id),
deflatorStart: phase.cumulativeInflationStart,
};
}
@@ -192,6 +193,7 @@ export function PlanView({
carried: ce?.carried ?? false,
derivedStart: 0,
phaseInflation: phaseInflationFor(fromPhase.id),
deflatorStart: fromPhase.cumulativeInflationStart,
};
}
@@ -617,7 +619,6 @@ export function PlanView({
phase={phaseInput}
maxDurationYears={phase.maxDurationYears}
isLast={isLast}
inflationDefault={plan.inflationRateDefault}
onSaved={onChanged}
onDeleted={() => {
setSelected(null);
@@ -681,8 +682,9 @@ function phaseCellContent(
): React.ReactNode {
if (!ce) return "";
if (ce.note) return ce.note;
const isFlow = ce.category === "INCOME" || ce.category === "EXPENSE";
const dS = phase.cumulativeInflationStart;
const dE = phase.cumulativeInflationEnd;
const dE = isFlow ? phase.flowDeflatorEnd : phase.cumulativeInflationEnd;
if (START_END_CATEGORIES.includes(ce.category) && (ce.startValue !== 0 || ce.endValue !== 0)) {
return (
<span className="whitespace-nowrap">
@@ -711,7 +713,8 @@ function PhaseHeader({
}) {
const quotaLabel = phase.isConsumption ? "Verzehr" : "Quote";
const dS = phase.cumulativeInflationStart;
const dE = phase.cumulativeInflationEnd;
const dE = phase.cumulativeInflationEnd; // Bestandswerte (Cash, Vermoegen)
const dF = phase.flowDeflatorEnd; // Flow-Werte (Einkommen, Ausgaben, Quote)
return (
<th
onClick={onClick}
@@ -739,10 +742,10 @@ function PhaseHeader({
{personLabel(p.role)} {p.startAge} {p.endAge}
</div>
))}
<div>Einkommen {valStr(phase.incomeStart, dS, mode)} {valStr(phase.incomeEnd, dE, mode)}</div>
<div>Ausgaben {valStr(phase.expenseStart, dS, mode)} {valStr(phase.expenseEnd, dE, mode)}</div>
<div>Einkommen {valStr(phase.incomeStart, dS, mode)} {valStr(phase.incomeEnd, dF, mode)}</div>
<div>Ausgaben {valStr(phase.expenseStart, dS, mode)} {valStr(phase.expenseEnd, dF, mode)}</div>
<div>
{quotaLabel} {valStr(phase.quotaStart, dS, mode)} {valStr(phase.quotaEnd, dE, mode)}
{quotaLabel} {valStr(phase.quotaStart, dS, mode)} {valStr(phase.quotaEnd, dF, mode)}
</div>
<div className={phase.cashNegative ? "text-danger font-medium" : ""}>
Cash {valStr(phase.cashStart, dS, mode)} {valStr(phase.cashEnd, dE, mode)}
@@ -842,7 +845,8 @@ function AddElementDialog({
carriedEndValue: 0,
carried: false,
derivedStart: 0,
phaseInflation: plan.phases.find((p) => p.id === firstPhase.id)?.inflationRate ?? plan.inflationRateDefault,
phaseInflation: plan.inflationRateDefault,
deflatorStart: firstPhase.cumulativeInflationStart,
};
async function create() {
+64
View File
@@ -0,0 +1,64 @@
"use client";
import {
Area,
CartesianGrid,
ComposedChart,
Legend,
Line,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { formatChf } from "@/lib/format";
import type { PlanComputed } from "@/lib/calculations";
const INCOME_COLOR = "#16a34a";
const EXPENSE_COLOR = "#dc2626";
const REAL_COLOR = "#9ca3af";
// Verlauf pro Jahr: Einkommen (nominal, inkl. Renten) vs. nominale Ausgaben; die Flaeche
// dazwischen ist die Spar-/Verzehrquote (gruen = Sparen, rot = Verzehr). Reale Ausgaben als
// blasse Referenzlinie -> der Keil zu den nominalen Ausgaben ist "das, was die Inflation frisst".
export function SparquoteChart({ computed }: { computed: PlanComputed }) {
if (computed.yearly.length === 0) return <p className="text-sm text-muted">Noch keine Phasen vorhanden.</p>;
const data = computed.yearly.map((y) => {
const surplus = y.income >= y.expenseNominal;
return {
year: y.year,
income: y.income,
expenseNominal: y.expenseNominal,
expenseReal: y.expenseReal,
// Range-Flaechen [unten, oben]; nur dort gesetzt, wo zutreffend (sonst Luecke).
sparBand: surplus ? [y.expenseNominal, y.income] : undefined,
verzehrBand: surplus ? undefined : [y.income, y.expenseNominal],
};
});
return (
<div className="h-80 w-full">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={data} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="year" tick={{ fontSize: 11 }} tickFormatter={(v) => `J. ${v}`} />
<YAxis
tick={{ fontSize: 11 }}
tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)}
/>
<Tooltip
formatter={(v, name) => [typeof v === "number" ? formatChf(v) : v, name]}
labelFormatter={(v) => `Jahr ${v}`}
/>
<Legend wrapperStyle={{ fontSize: 12 }} />
<Area dataKey="sparBand" name="Sparquote" stroke="none" fill={INCOME_COLOR} fillOpacity={0.18} connectNulls={false} isAnimationActive={false} legendType="none" />
<Area dataKey="verzehrBand" name="Verzehr" stroke="none" fill={EXPENSE_COLOR} fillOpacity={0.18} connectNulls={false} isAnimationActive={false} legendType="none" />
<Line dataKey="income" name="Einkommen (nominal)" stroke={INCOME_COLOR} strokeWidth={2} dot={false} isAnimationActive={false} />
<Line dataKey="expenseNominal" name="Ausgaben (nominal)" stroke={EXPENSE_COLOR} strokeWidth={2} dot={false} isAnimationActive={false} />
<Line dataKey="expenseReal" name="Ausgaben (real)" stroke={REAL_COLOR} strokeWidth={1.5} strokeDasharray="5 3" dot={false} isAnimationActive={false} />
</ComposedChart>
</ResponsiveContainer>
</div>
);
}