V4-Rechenmodell: Flow-Indexierung, Cash-Ausgleichstopf, Ruin-Erkennung
Deploy App / deploy (push) Successful in 3m6s

Behebt die Nominal/Real-Inkonsistenz (Bug #1): Einkommen/Ausgaben werden neu Jahr fuer
Jahr indexiert (eigenes Feld teuerungsausgleich je Element, Default = Phaseninflation;
Renten nominal fix = 0%). Vermoegen verzinst weiterhin nominal.

Cash: neues systemseitiges, immer sichtbares Element (0% Verzinsung, kein DB-Row -
synthetisch in computePlan). Ist der Ausgleichstopf = "verfuegbares Kapital fuer
Investments": cash_delta(t) = quote(t) - geplante flache Jahresraten (3a/Vermoegen/Amort./
Tilgung); Cash laeuft ueber Phasen fort, darf negativ werden (rot). Der Verteilzwang und
die harten Sparraten-Caps entfallen.

Ruin: Gesamtvermoegen (inkl. Cash) je Jahr; erstes Unterschreiten von 0 -> Ruin-Alter
(Person A), Anzeige als Banner + Zeitachsen-Marker.

Phasenkopf neu: Einkommen/Ausgaben Start->Ende, Quote Beginn/Ende, Cash Start->Ende,
Vermoegen inkl. Cash, Realwert. Inflation-Deflator neu pro Jahr (Math.pow ^Dauer).

Golden Tests (vitest) 1-4 + Renten-0% + Fortschreibung gruen (Test1 761'654/565'928,
Test2 Ruin Alter 94).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 09:24:13 +02:00
parent 9506f43b67
commit 5e17ce4afb
10 changed files with 1349 additions and 299 deletions
+57 -33
View File
@@ -141,20 +141,17 @@ export function PlanView({
return !!before?.working && !!after && !after.working;
}
// Baut den Kontext (inkl. Live-Caps) fuer eine Phasenzelle.
function phaseInflationFor(phaseId: string): number {
return plan.phases.find((p) => p.id === phaseId)?.inflationRate ?? plan.inflationRateDefault;
}
// Baut den Kontext fuer eine Phasenzelle.
function buildPhaseContext(phase: PhaseComputed, element: ElementInput): CellContext {
const ce = computedElement(phase.id, element.id);
const ownerWorking =
element.ownerRole && element.ownerRole !== "HOUSEHOLD"
? phase.persons.find((p) => p.role === element.ownerRole)?.working ?? false
: phase.type !== "PENSION";
const otherQuota = phase.quotaAllocated - (ce?.quotaUse ?? 0);
const quotaRateMax = Math.max(0, Math.abs(phase.quota) - otherQuota);
const capitalMax =
phase.availableCapital == null
? undefined
: Math.max(0, phase.availableCapital - (phase.availableCapitalUsed - (ce?.capitalUse ?? 0)));
const derivedStart = (ce?.startValue ?? 0) - (ce?.capitalUse ?? 0);
return {
kind: "phase",
phaseId: phase.id,
@@ -164,9 +161,8 @@ export function PlanView({
isRetirementTransition: false,
carriedEndValue: ce?.endValue ?? 0,
carried: ce?.carried ?? false,
derivedStart,
quotaRateMax,
capitalMax,
derivedStart: ce?.baseValue ?? 0,
phaseInflation: phaseInflationFor(phase.id),
};
}
@@ -182,8 +178,7 @@ export function PlanView({
carriedEndValue: ce?.endValue ?? 0,
carried: ce?.carried ?? false,
derivedStart: 0,
quotaRateMax: 0,
capitalMax: undefined,
phaseInflation: phaseInflationFor(fromPhase.id),
};
}
@@ -242,7 +237,7 @@ export function PlanView({
return (
<div className="flex flex-col gap-5">
<Timeline phases={computed.phases} persons={personAxes} />
<Timeline phases={computed.phases} persons={personAxes} ruinAge={computed.ruinAge} />
{/* Plan-Profil */}
<div className="flex flex-wrap items-center gap-3 rounded-xl border border-border bg-surface px-4 py-3 text-sm shadow-sm">
@@ -294,6 +289,12 @@ export function PlanView({
</div>
)}
{computed.ruinAge !== null && (
<div className="flex items-center gap-2 rounded-xl border border-danger bg-danger-soft px-4 py-2 text-sm font-medium text-danger">
<AlertCircle className="h-4 w-4 shrink-0" /> Kapital aufgebraucht mit Alter {computed.ruinAge} das Gesamtvermoegen (inkl. Cash) faellt danach unter 0.
</div>
)}
{/* Matrix */}
{hasPhases && (
<div className="overflow-x-auto rounded-xl border border-border bg-surface shadow-sm">
@@ -323,6 +324,33 @@ export function PlanView({
</tr>
</thead>
<tbody>
{/* Cash-Zeile (systemseitig, immer sichtbar, read-only). */}
<tr className="bg-surface-2/50">
<td className="sticky left-0 z-10 border-b border-r border-border bg-surface px-3 py-1.5">
<span className="flex items-center gap-1.5 text-xs font-semibold text-fg">
<Wallet className="h-4 w-4 text-accent" /> Cash
</span>
<span className="text-[10px] text-faint">verfuegbares Kapital</span>
</td>
{columns.map((col) =>
col.kind === "phase" ? (
<td
key={`cash-${col.phase.id}`}
className={`border-b border-r border-border px-2 py-1.5 text-center text-xs ${
col.phase.cashNegative ? "font-semibold text-danger" : "text-fg"
}`}
>
<span className="whitespace-nowrap">
{formatChf(col.phase.cashStart)} <span className="text-faint"></span> {formatChf(col.phase.cashEnd)}
</span>
</td>
) : (
<td key={`cash-t-${col.fromPhase.id}`} className="border-b border-r border-border px-2 py-1.5 text-center text-[11px] text-faint">
</td>
)
)}
</tr>
{CATEGORY_ORDER.map((cat) => {
const els = elementsByCategory.get(cat)!;
if (els.length === 0) return null;
@@ -584,10 +612,12 @@ export function PlanView({
}
// Zellinhalt: Start- UND Zielwert fuer wertbehaftete Elemente, sonst die Kennzahl.
const START_END_CATEGORIES: ElementCategory[] = [...VALUE_CATEGORIES, "INCOME", "EXPENSE"];
function phaseCellContent(ce: ReturnType<PhaseComputed["elements"]["find"]> | undefined): React.ReactNode {
if (!ce) return "";
if (ce.note) return ce.note;
if (VALUE_CATEGORIES.includes(ce.category) && (ce.startValue !== 0 || ce.endValue !== 0)) {
if (START_END_CATEGORIES.includes(ce.category) && (ce.startValue !== 0 || ce.endValue !== 0)) {
return (
<span className="whitespace-nowrap">
{formatChf(ce.startValue)} <span className="text-faint"></span> {formatChf(ce.endValue)}
@@ -608,20 +638,17 @@ function PhaseHeader({
onClick: () => void;
active: boolean;
}) {
const quotaLabel = phase.isConsumption ? "Verzehr" : "Sparquote";
const quotaTarget = Math.abs(phase.quota);
const quotaPct = quotaTarget > 0 ? Math.round((phase.quotaAllocated / quotaTarget) * 100) : 100;
const distributedWord = phase.isConsumption ? "gedeckt" : "verteilt";
const quotaLabel = phase.isConsumption ? "Verzehr" : "Quote";
return (
<th
onClick={onClick}
className={`min-w-40 cursor-pointer border-b border-r border-border px-2 py-2 text-left align-top ${
className={`min-w-44 cursor-pointer border-b border-r border-border px-2 py-2 text-left align-top ${
active ? "bg-accent-soft" : "bg-surface"
}`}
>
<div className="flex items-center gap-1">
<span className="truncate text-xs font-semibold text-fg">{phase.name}</span>
{phase.incomplete ? (
{phase.cashNegative ? (
<AlertCircle className="h-3.5 w-3.5 shrink-0 text-danger" />
) : (
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-success" />
@@ -639,20 +666,18 @@ function PhaseHeader({
{personLabel(p.role)} {p.startAge} {p.endAge}
</div>
))}
<div>Einkommen {formatChf(phase.incomeTotal)}</div>
<div>Ausgaben {formatChf(phase.expenseTotal)}</div>
<div className={phase.quotaComplete ? "text-success" : "text-danger"}>
{quotaLabel} {formatChf(quotaTarget)} ({quotaPct}% {distributedWord})
<div>Einkommen {formatChf(phase.incomeStart)} {formatChf(phase.incomeEnd)}</div>
<div>Ausgaben {formatChf(phase.expenseStart)} {formatChf(phase.expenseEnd)}</div>
<div>
{quotaLabel} {formatChf(phase.quotaStart)} {formatChf(phase.quotaEnd)}
</div>
<div className={phase.cashNegative ? "text-danger font-medium" : ""}>
Cash {formatChf(phase.cashStart)} {formatChf(phase.cashEnd)}
</div>
<div>
Vermoegen {formatChf(phase.startWealthNominal)} {formatChf(phase.endWealthNominal)}
</div>
<div className={phase.availableCapitalComplete ? "" : "text-danger"}>
Kapital {phase.availableCapital === null ? "n.a." : formatChf(phase.availableCapital)}
{phase.availableCapital !== null && !phase.availableCapitalComplete && (
<span> · offen {formatChf(Math.max(0, phase.availableCapitalRemaining))}</span>
)}
</div>
<div className="text-faint">real {formatChf(phase.endWealthReal)}</div>
</div>
</th>
);
@@ -745,8 +770,7 @@ function AddElementDialog({
carriedEndValue: 0,
carried: false,
derivedStart: 0,
quotaRateMax: Math.max(0, Math.abs(firstPhase.quota) - firstPhase.quotaAllocated),
capitalMax: firstPhase.availableCapital == null ? undefined : Math.max(0, firstPhase.availableCapital - firstPhase.availableCapitalUsed),
phaseInflation: plan.phases.find((p) => p.id === firstPhase.id)?.inflationRate ?? plan.inflationRateDefault,
};
async function create() {