V4-Rechenmodell: Flow-Indexierung, Cash-Ausgleichstopf, Ruin-Erkennung
Deploy App / deploy (push) Successful in 3m6s
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:
+57
-33
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user