diff --git a/SPEZIFIKATION.md b/SPEZIFIKATION.md index af66705..bfdb97f 100644 --- a/SPEZIFIKATION.md +++ b/SPEZIFIKATION.md @@ -4,10 +4,10 @@ | | | |---|---| | **Dokument** | Funktionale und Technische Spezifikation FPT | -| **Version** | 0.8 | +| **Version** | 0.9 | | **Datum** | 2026-07-18 | | **Status** | Lebendes Dokument | -| **Codestand** | Arbeitsstand nach `71137f7` inkl. Szenario-Hierarchie (V6) (Branch `main`) | +| **Codestand** | Arbeitsstand nach `a5d4868` inkl. UI-Umbau und Planstart (Branch `main`) | | **Ersetzt** | `FDD_TDD_FPT.docx` (v1–v5) im Ordner `Info Dateien` – diese sind ab Version 0.1 dieses Dokuments obsolet | | **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` | @@ -17,6 +17,7 @@ | Version | Datum | Autor | Änderung | |---|---|---|---| +| 0.9 | 2026-07-18 | Claude (Opus 4.8) | **UI-Umbau und Planstart.** (1) Die Grafiken liegen neu im eigenen Bereich **Grafiken** (Dialog, Button oben) statt unter der Matrix. (2) Kennzahl **Geschätzter Nachlass** entfernt – sie war identisch mit dem nominalen Endvermögen. (3) **Monte-Carlo-Button** nach oben zu den Szenario-Aktionen verschoben. (4) Neues Profilfeld **Planstart (Jahr)** (`Scenario.startYear`, Migration; reine Anzeige, Berechnung bleibt in relativen Jahren) – erscheint auf der Zeitachse. (5) Zeitachse zeigt neu die **Lebensphasen als Segmente** (Breite = Dauer, Einfärbung nach Phasentyp, Jahresspanne). (6) **Lebensphase bearbeiten** neu als Popup statt Panel unter der Tabelle. (7) **Vermögensverlauf** über **alle Jahre** statt nur über die Phasengrenzen – dafür führt `computePlan` das Vermögen neu pro Jahr mit (`YearPoint.wealthNominal/wealthReal`). Zwei Tests ergänzt (58 → 60). | | 0.8 | 2026-07-18 | Claude (Opus 4.8) | **Szenario-Hierarchie (V6)** – grösste Umstrukturierung bisher. Der **Plan** ist neu ein schlanker Behälter ohne Finanzdaten; die berechenbare Einheit ist das **Szenario**, das Grundprofil (inkl. **Pensionsalter** → Frühpensionierungs-Szenarien), Phasen und Elemente trägt. Jeder Plan erhält beim Anlegen automatisch ein **Basisszenario**; weitere Szenarien entstehen als vollständige Kopie eines beliebigen Szenarios und hängen als **Baum** darunter (Sidebar zeigt die Verschachtelung). Kopierte Phasen/Elemente tragen Herkunfts-Verweise (`sourcePhaseId`, `sourceElementId`) – darauf beruht die **Abweichungs-Markierung**: geändert = gelb, neu = grün, entfernt = graue Geisterzeile (eigene Theme-Tokens für alle drei Farbschemata). Charts vergleichen neu die Geschwister-Szenarien. Datenmodell: neue Tabelle `Plan`, bisheriger `Plan` → `Scenario` (IDs erhalten), `planId` → `scenarioId` in Person/Phase/FinancialElement. API neu unter `/api/scenarios/*`. Neue Kapitel 2.1, 3.2, 4.13; 9 Diff-Tests + Migrations-Test (48 → 58). **Migration mit echtem Postgres (PGlite) verifiziert**, inkl. verschachtelter Szenarien und Cascade. | | 0.7 | 2026-07-17 | Claude (Opus 4.8) | **Monte-Carlo-Simulation** (Roadmap Nr. 19, Stufe A). Button in der Planansicht → Dialog mit Erklärung, Eingaben und Ergebnis. Statt einer festen Rendite/Inflation werden tausende Zufallspfade gerechnet; ausgewiesen werden **Ruinwahrscheinlichkeit**, **Erfolgswahrscheinlichkeit** (P(Endvermögen ≥ Zielbetrag)) und ein **Fächer** (10 %/Median/90 %) plus die deterministische Linie. Läuft komplett im Browser (`computePlan` ist rein). Modell: fettschwänzige Verteilung (Student-t, ν=5), gemeinsamer Marktschock (ρ=0.7), Böden 0 % für PK/3a und −100 % sonst. Pro renditetragendem Element und für die Inflation je: historischer Ø (Pflicht), Streuungsstufe, σ. Neue Datei `montecarlo.ts` + optionaler `sample`-Parameter in `computePlan` (Inflation neu als kumulatives Array; deterministisch identisch). Neues Kapitel 4.12; 7 MC-Tests + Refactor-Absicherung (41 → 48). Keine Verhaltensänderung, keine DB-Änderung. | | 0.6 | 2026-07-17 | Claude (Opus 4.8) | **Teilverkauf** von Sonstigem Vermögen (Roadmap Nr. 42) und **Sonderamortisation** der Hypothek (Roadmap Nr. 15). `OTHER_ASSET` am Übergang neu: Halten / Verkaufen / **Teilverkauf** – ein Betrag fliesst ins Cash (erscheint als „Kapitalzufluss" im Phasenkopf), der Rest bleibt investiert. `REAL_ESTATE` im Halten-Fall neu mit **Einmaltilgung** aus dem Cash (analog zur Sofort-Tilgung bei Schulden; erscheint als „Kapitalinvestition"). Damit lässt sich die indirekte Amortisation via 3a mechanisch nachbilden. Die eigentliche Roadmap Nr. 15 (Steuerwirkung der indirekten Amortisation) bleibt mit dem Steuer-Bündel 13/14/23 zurückgestellt – siehe 9.14. Kapitel 4.9.3/4.9.4 ergänzt. Fünf Regressionstests (36 → 41). Keine Verhaltensänderung für bestehende Pläne. | @@ -325,6 +326,16 @@ fester Gelbwert würde im Dunkelschema unbrauchbar aussehen. Referenz: `src/lib/diff.ts`. +### 3.2.7 Planstart (Kalenderjahr) + +Das Grundprofil enthält das Feld **Planstart (Jahr)** – das Kalenderjahr, in dem Jahr 1 der +Planung liegt (`Scenario.startYear`). Es dient **ausschliesslich der Darstellung**: Zeitachse +und Grafiken beschriften damit Jahre statt nur Alter. Die Berechnung rechnet unverändert in +**relativen** Jahren ab Planbeginn – `startYear` fliesst in keine Formel ein. + +Beim Anlegen wird das laufende Jahr vorbelegt; bestehende Szenarien wurden per Migration darauf +gesetzt. Kalenderjahr eines Planjahrs: `startYear + (Jahr − 1)`. + ## 3.3 Lebensphasen ### 3.3.1 Phase anlegen @@ -364,8 +375,9 @@ Referenz: `src/app/api/plans/[planId]/phases/route.ts`. ### 3.3.2 Phase bearbeiten -Klick auf einen Phasenkopf öffnet ein Detail-Panel unterhalb der Matrix mit Bezeichnung und -Dauer. Die Dauer wird auch hier gekappt. Eine phasenspezifische Inflationsrate gibt es nicht; +Klick auf einen Phasenkopf öffnet ein **Popup** („Lebensphase: ") mit Bezeichnung und +Dauer – konsistent zu allen anderen Eingaben (Element-Zellen, Übergänge). Speichern schliesst +das Popup. Die Dauer wird auch hier gekappt. Eine phasenspezifische Inflationsrate gibt es nicht; das Panel weist darauf hin: „Die Inflationsrate gilt plan-weit und wird in den Plan-Einstellungen gesetzt." @@ -727,10 +739,16 @@ Referenz: `src/components/PlanView.tsx` Zeilen 665–699. ### 3.6.2 Zeitachse -Horizontaler Balken über das Alter (von jüngster Person bis Planende) mit: +Horizontale Achse über das Alter (von jüngster Person bis Planende) mit: +- den **Lebensphasen als Segmente**: Breite proportional zur Dauer, Einfärbung nach Phasentyp + (Erwerb kräftig, Misch mittel, Pension hell), beschriftet mit Name, Dauer und – sofern ein + Planstart gesetzt ist – der **Jahresspanne** (z. B. „2026–2046") - Flaggen-Marker je Person am Pensionsalter (Farbe: Person A indigo, Person B hellblau) -- Trennstriche an den Phasengrenzen - rotem „Ruin "-Marker, falls zutreffend +- Alters- **und Jahres**-Beschriftung an beiden Enden + +Die Kalenderjahre stammen aus dem Profilfeld **Planstart** (`Scenario.startYear`). Ist es nicht +gesetzt, zeigt die Achse nur Alter – die Berechnung ist davon nie betroffen (siehe 3.2.7). Referenz: `src/components/Timeline.tsx`. @@ -760,12 +778,16 @@ Kennzahl falsch beschriften. Referenz: `src/components/PlanView.tsx` Zeilen 701–769. -### 3.6.4 Dashboard +### 3.6.4 Analyse-Bereich „Grafiken" -Erscheint unterhalb der Matrix, sobald mindestens eine Phase existiert. +Die Auswertungen liegen **nicht** unter der Matrix, sondern in einem eigenen Bereich: Der Button +**Grafiken** in der oberen Aktionsleiste (neben „Neues Szenario aus diesem" und +„Monte-Carlo-Simulation") öffnet sie als breiten Dialog. So bleibt die Matrix die ruhige +Hauptansicht. -**Kennzahl-Karten:** Endvermögen nominal, Endvermögen real, Geschätzter Nachlass -(= Endvermögen der letzten Phase, „potenziell vererbbar"). +**Kennzahl-Karten:** Endvermögen nominal und Endvermögen real. (Die frühere Karte „Geschätzter +Nachlass" ist entfallen – sie war rechnerisch identisch mit dem nominalen Endvermögen und +suggerierte eine zusätzliche Information, die es nicht gab.) **Grafik 1 – Einkommen vs. Ausgaben pro Jahr** (`SparquoteChart`): Ein Datenpunkt pro Jahr über alle Phasen. Grüne Linie = Einkommen nominal (inkl. Renten), rote Linie = Ausgaben nominal, @@ -774,9 +796,11 @@ ist grün (Sparquote) oder rot (Verzehr) eingefärbt. Der Keil zwischen roter un anschaulich „das, was die Inflation frisst". **Grafik 2 – Vermögensverlauf nach Alter** (`WealthChart`): Liniendiagramm über das Alter von -Person A. Je Plan eine durchgezogene Linie (nominal) und eine gestrichelte (real). Datenpunkte: -Startvermögen Phase 1 plus je ein Endwert pro Phase. Über Checkboxen lassen sich **andere Pläne -überlagern** (Szenariovergleich); deren Daten werden bei Bedarf nachgeladen und im Client +Person A. Je Szenario eine durchgezogene Linie (nominal) und eine gestrichelte (real). +Datenpunkte: **jedes Planjahr** (nicht nur die Phasengrenzen) – dadurch werden Verläufe +*innerhalb* einer Phase sichtbar, etwa das Abschmelzen im Kapitalverzehr. Grundlage ist +`YearPoint.wealthNominal/wealthReal` (4.10). Über Checkboxen lassen sich die **Geschwister- +Szenarien überlagern**; deren Daten werden bei Bedarf nachgeladen und im Client zwischengespeichert. **Grafik 3 – Vermögensaufteilung pro Phase**: Gestapeltes Balkendiagramm mit zwei Balken je Phase @@ -1224,7 +1248,8 @@ expenseReal = expenseRealBase + interestNominal / inflFactor // 3. Quote quote = incomeFlow − expenseNominal -// 4. YearPoint für die Grafik anfügen (year, age Person A, income, expenseNominal, expenseReal) +// 4. YearPoint anlegen (year, age Person A, income, expenseNominal, expenseReal); +// wealthNominal/wealthReal werden nach Schritt 8 nachgetragen // 5. Vermögen: verzinsen, Sparbeitrag, Bezugsrate für jedes Asset a: @@ -1252,6 +1277,8 @@ falls cash < 0 → cashNegative = true // 8. Ruin prüfen (Gesamtvermögen zum Jahresende) total = cash + Σ asset.value + Σ (re.value − re.mortgage) + Σ (−d.owed) +yearPoint.wealthNominal = round(total) // Grundlage des Verlaufs je Jahr +yearPoint.wealthReal = round(total / cumInfl[Jahr]) falls ruinAge === null und total < 0 → ruinAge = age(Person A) + yearsBefore + t ``` @@ -1437,7 +1464,7 @@ yearsBefore += duration ```ts PlanComputed { phases: PhaseComputed[] // alle Kennzahlen je Phase, inkl. elements[] - yearly: YearPoint[] // ein Punkt pro Jahr über alle Phasen (Grafik) + yearly: YearPoint[] // ein Punkt pro Jahr über alle Phasen (inkl. Vermögen je Jahr) nachlass: number // = endWealthNominal der letzten Phase, sonst 0 ruinAge: number | null // Alter Person A beim ersten Gesamtvermögen < 0 } @@ -1692,6 +1719,7 @@ PlanComputed ← an den Client geliefert | `householdType` | `HouseholdType` | `SINGLE` \| `COUPLE` | | `inflationRateDefault` | Float | szenario-weite Inflation in % | | `initialCash` | Float | Default 0 | +| `startYear` | Int? | Kalenderjahr des Planbeginns – **nur Darstellung** (siehe 3.2.7) | | `createdAt` / `updatedAt` | DateTime | | **Phase** @@ -1841,6 +1869,7 @@ sondern zu leeren Werten. | `20260716210000_drop_phase_inflation_rate` | `Phase.inflationRate` entfernt (Inflation ist plan-weit) | | `20260716230000_phase_cash_transition` | `Phase.cashTransition` (JSONB) für einmalige Sonderein-/ausgaben | | `20260718090000_plan_scenario_hierarchy` | **V6**: `Plan` → `Scenario` (IDs erhalten), neuer Behälter `Plan`, `planId` → `scenarioId`, Herkunfts-Verweise | +| `20260718140000_scenario_start_year` | `Scenario.startYear` (Kalenderjahr des Planbeginns), bestehende auf das laufende Jahr gesetzt | **Zur V6-Migration:** Sie benennt die bisherige `Plan`-Tabelle in `Scenario` um – dadurch bleiben alle IDs und damit sämtliche Kind-Fremdschlüssel gültig. Für jedes bisherige @@ -2112,7 +2141,7 @@ Zielumgebung: Hetzner CX23, Traefik als Reverse Proxy, Domain `fpt.aicds.ch`, Gi Getestet wird ausschliesslich der Berechnungskern – bewusst, da dort die Fachlogik und das Regressionsrisiko liegen. `src/lib/calculations.test.ts` (41 Tests: AHV-Rentenformel, Immobilie, Teilverkauf, Sonderamortisation, AHV einkommensabhängig, „V5 Golden Tests") und -`src/lib/montecarlo.test.ts` (7 Tests), `src/lib/diff.test.ts` (9 Tests) und `src/lib/migrations.test.ts` (1 Test, spielt alle Migrationen gegen echtes PostgreSQL ein) ergeben zusammen **58 Tests**, ausgeführt mit Vitest in +`src/lib/montecarlo.test.ts` (7 Tests), `src/lib/diff.test.ts` (9 Tests) und `src/lib/migrations.test.ts` (1 Test, spielt alle Migrationen gegen echtes PostgreSQL ein) ergeben zusammen **60 Tests**, ausgeführt mit Vitest in der Node-Umgebung (`vitest.config.ts`, Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-, API- oder E2E-Tests. @@ -2141,6 +2170,8 @@ API- oder E2E-Tests. | **MC: Volatilität / Vol-Drag** | σ > 0 spreizt p10 ({ role: p.role, diff --git a/src/app/api/scenarios/[scenarioId]/route.ts b/src/app/api/scenarios/[scenarioId]/route.ts index 28dc290..3bf6d53 100644 --- a/src/app/api/scenarios/[scenarioId]/route.ts +++ b/src/app/api/scenarios/[scenarioId]/route.ts @@ -72,6 +72,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< name: data.name ?? undefined, householdType: data.householdType, inflationRateDefault: data.inflationRateDefault, + startYear: data.startYear ?? undefined, persons: { create: data.persons }, }, }); diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index b99be0d..9b5d7bf 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -2,7 +2,9 @@ import { useCallback, useEffect, useState } from "react"; import { + BarChart3, Copy, + Dices, FileText, FolderKanban, GitBranch, @@ -15,6 +17,7 @@ import { } from "lucide-react"; import { PlanView } from "@/components/PlanView"; import { Dashboard } from "@/components/Dashboard"; +import { MonteCarloDialog } from "@/components/MonteCarloDialog"; import { SpecView } from "@/components/SpecView"; import { ProfileMenu } from "@/components/ProfileMenu"; import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields"; @@ -39,6 +42,8 @@ export function AppShell({ username }: { username: string }) { const [showNewPlan, setShowNewPlan] = useState(false); const [copyFrom, setCopyFrom] = useState(null); const [showSpec, setShowSpec] = useState(false); + const [showCharts, setShowCharts] = useState(false); + const [showMonteCarlo, setShowMonteCarlo] = useState(false); const loadPlans = useCallback(async () => { const data = await api.get<{ plans: PlanListItem[] }>("/api/plans"); @@ -267,6 +272,26 @@ export function AppShell({ username }: { username: string }) { Szenario loeschen )} + {detail.plan.phases.length > 0 && ( + <> + + + + )} {diff && detail.base && ( @@ -278,14 +303,6 @@ export function AppShell({ username }: { username: string }) { - - {detail.plan.phases.length > 0 && ( - s.id !== detail.meta.id)} - /> - )} )} @@ -306,6 +323,24 @@ export function AppShell({ username }: { username: string }) { /> )} + {showCharts && detail && ( + setShowCharts(false)} title={`Grafiken · ${detail.meta.name}`}> + s.id !== detail.meta.id)} + /> + + )} + + {showMonteCarlo && detail && ( + setShowMonteCarlo(false)} + /> + )} + {copyFrom && ( void; +}) { + return ( +
+
e.stopPropagation()} + className="flex w-full max-w-5xl flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl" + > +
+

+ {title} +

+ +
+ {children} +
+
+ ); +} + // Rekursiver Szenario-Baum: Kinder werden eingerueckt, damit Sub-Szenarien sichtbar sind. function ScenarioTree({ scenarios, diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index 5e5785c..0afc109 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -93,10 +93,9 @@ export function Dashboard({ return (
-
+
-
diff --git a/src/components/PlanProfileFields.tsx b/src/components/PlanProfileFields.tsx index 3ef89d6..b19f0ea 100644 --- a/src/components/PlanProfileFields.tsx +++ b/src/components/PlanProfileFields.tsx @@ -6,6 +6,7 @@ import type { HouseholdType, PersonRole } from "@/lib/types"; export interface ProfileDraft { householdType: HouseholdType; inflationRateDefault: number; + startYear: number; persons: { role: PersonRole; name: string; age: number; retirementAge: number }[]; } @@ -13,6 +14,7 @@ export function emptyProfileDraft(): ProfileDraft { return { householdType: "SINGLE", inflationRateDefault: 1.5, + startYear: new Date().getFullYear(), persons: [{ role: "PERSON_A", name: "", age: 35, retirementAge: 65 }], }; } @@ -89,6 +91,15 @@ export function PlanProfileFields({
))} + onChange({ ...draft, startYear: Math.round(v) })} + /> + (null); const [editPhaseCell, setEditPhaseCell] = useState<{ elementId: string; phaseId: string } | null>(null); const [showCashInit, setShowCashInit] = useState(false); - const [showMonteCarlo, setShowMonteCarlo] = useState(false); // fromPhaseId des Cash-Uebergangs, der gerade bearbeitet wird. const [editCashTransition, setEditCashTransition] = useState(null); const [valueMode, setValueMode] = useState("nominal"); @@ -321,7 +318,12 @@ export function PlanView({ real = kaufkraftbereinigt (Planbeginn)
- + {/* Plan-Profil */}
Lebensphase -
)} @@ -590,13 +585,18 @@ export function PlanView({ )} - {/* Detail-Panel. Der key erzwingt beim Wechsel von Zelle/Phase einen Neuaufbau, - damit der lokale Formular-Zustand nicht vom vorher geoeffneten Element uebrig bleibt. */} - {selected && ( -
- {renderDetail()} -
- )} + {/* Lebensphase bearbeiten -- als Popup, konsistent zu allen anderen Eingaben. + Der key erzwingt beim Wechsel der Phase einen Neuaufbau, damit der lokale + Formular-Zustand nicht von der vorher geoeffneten Phase uebrig bleibt. */} + {selected && (() => { + const phase = computed.phases.find((p) => p.id === selected.phaseId); + if (!phase) return null; + return ( + setSelected(null)}> + {renderDetail()} + + ); + })()} {showAdd && firstPhase && ( )} - {showMonteCarlo && ( - setShowMonteCarlo(false)} /> - )} - - {editCashTransition && (() => { + {editCashTransition && (() => { const fromPhase = computed.phases.find((p) => p.id === editCashTransition); if (!fromPhase) return null; const toIndex = computed.phases.findIndex((p) => p.id === fromPhase.id) + 1; @@ -759,7 +755,10 @@ export function PlanView({ maxDurationYears={phase.maxDurationYears} isLast={isLast} laterPhaseCount={laterPhaseCount(phase)} - onSaved={onChanged} + onSaved={() => { + setSelected(null); + onChanged(); + }} onDeleted={() => { setSelected(null); onChanged(); @@ -1190,6 +1189,7 @@ function PlanSettingsDialog({ plan, onClose, onSaved }: { plan: PlanInput; onClo const [draft, setDraft] = useState({ householdType: plan.householdType, inflationRateDefault: plan.inflationRateDefault, + startYear: plan.startYear ?? new Date().getFullYear(), persons: plan.persons.map((p) => ({ role: p.role, name: p.name ?? "", age: p.age, retirementAge: p.retirementAge })), }); const [saving, setSaving] = useState(false); diff --git a/src/components/Timeline.tsx b/src/components/Timeline.tsx index c616002..bc9dd79 100644 --- a/src/components/Timeline.tsx +++ b/src/components/Timeline.tsx @@ -11,16 +11,19 @@ interface PersonAxis { color: string; } -// Horizontale Zeitachse: Alter von links nach rechts, mit deutlich markiertem -// Pensionsalter je Person und Trennlinien an den Phasengrenzen. +// Horizontale Zeitachse: die Lebensphasen als aneinandergereihte Segmente (Breite = Dauer), +// darunter Alter und -- sofern ein Planstart gesetzt ist -- die Kalenderjahre. Pensionsalter +// je Person und ein allfaelliges Ruinalter sind als Marker eingezeichnet. export function Timeline({ phases, persons, ruinAge, + startYear, }: { phases: PhaseComputed[]; persons: PersonAxis[]; ruinAge?: number | null; + startYear?: number | null; }) { if (phases.length === 0 || persons.length === 0) return null; @@ -30,18 +33,18 @@ export function Timeline({ const span = Math.max(1, maxAge - minAge); const pct = (age: number) => `${(Math.max(0, Math.min(span, age - minAge)) / span) * 100}%`; + const yearOf = (yearsFromStart: number) => (startYear ? startYear + yearsFromStart : null); - // Phasengrenzen (kumulierte Jahre). - const boundaries: { year: number; label: string }[] = []; - let acc = 0; - for (const p of phases) { - boundaries.push({ year: acc, label: p.name }); - acc += p.durationYears; - } + // Segmente: Start-/Endjahr (relativ zum Planbeginn) je Phase -- ohne Mutation waehrend + // des Renderns, deshalb die kumulierte Summe der vorangehenden Dauern. + const segments = phases.map((p, i) => { + const from = phases.slice(0, i).reduce((s, x) => s + x.durationYears, 0); + return { phase: p, from, to: from + p.durationYears }; + }); return (
-
+

Zeitachse

{persons.map((p) => ( @@ -61,7 +64,9 @@ export function Timeline({ key={p.role} className="absolute top-0 flex -translate-x-1/2 flex-col items-center" style={{ left: pct(p.retirementAge) }} - title={`${p.label}: Pensionierung mit ${p.retirementAge}`} + title={`${p.label}: Pensionierung mit ${p.retirementAge}${ + yearOf(p.retirementAge - minAge) ? ` (${yearOf(p.retirementAge - minAge)})` : "" + }`} > @@ -86,21 +91,39 @@ export function Timeline({
)} - {/* Achse */} -
- {boundaries.slice(1).map((b) => ( -
- ))} + {/* Phasen-Segmente: Breite proportional zur Dauer, Einfaerbung nach Phasentyp. */} +
+ {segments.map((s, i) => { + const soft = s.phase.type === "PENSION" || s.phase.type === "MIXED"; + return ( +
0 ? "border-l border-border" : "" + } ${s.phase.type === "PENSION" ? "bg-accent-soft" : s.phase.type === "MIXED" ? "bg-accent/25" : "bg-accent/70"}`} + > + + {s.phase.name} + + + {s.phase.durationYears} J. + {yearOf(s.from) ? ` · ${yearOf(s.from)}–${yearOf(s.to)}` : ""} + +
+ ); + })}
- {/* Alters-Beschriftung */} + {/* Alters- und Jahres-Beschriftung */}
- {minAge} J. - {maxAge} J. + + {minAge} J.{yearOf(0) ? ` · ${yearOf(0)}` : ""} + + + {maxAge} J.{yearOf(totalYears) ? ` · ${yearOf(totalYears)}` : ""} +
diff --git a/src/components/WealthChart.tsx b/src/components/WealthChart.tsx index eb64380..ddbdca4 100644 --- a/src/components/WealthChart.tsx +++ b/src/components/WealthChart.tsx @@ -19,18 +19,16 @@ export interface TimelineSeries { computed: PlanComputed; } -// Datenpunkte je Serie: Start (Beginn Phase 1) + Endwert je Phase, verortet auf dem -// Alter der Referenzperson (Person A). So laeuft die x-Achse ueber das Alter statt ueber -// die Phasen. +// Datenpunkte je Serie: JEDES Planjahr (nicht nur die Phasengrenzen), verortet auf dem Alter +// der Referenzperson (Person A). Der erste Punkt ist das Startvermoegen vor Jahr 1. function pointsFor(computed: PlanComputed) { const phases = computed.phases; if (phases.length === 0) return [] as { age: number; nominal: number; real: number }[]; - const refAge = (ph: PlanComputed["phases"][number]) => ph.persons[0]?.startAge ?? 0; - const pts = [ - { age: refAge(phases[0]), nominal: Math.round(phases[0].startWealthNominal), real: Math.round(phases[0].startWealthNominal) }, - ]; - for (const p of phases) { - pts.push({ age: refAge(p) + p.durationYears, nominal: Math.round(p.endWealthNominal), real: Math.round(p.endWealthReal) }); + const startAge = phases[0].persons[0]?.startAge ?? 0; + const start = Math.round(phases[0].startWealthNominal); + const pts = [{ age: startAge, nominal: start, real: start }]; + for (const y of computed.yearly) { + pts.push({ age: y.age, nominal: y.wealthNominal, real: y.wealthReal }); } return pts; } diff --git a/src/lib/calculations.test.ts b/src/lib/calculations.test.ts index 309fd4f..8ff2846 100644 --- a/src/lib/calculations.test.ts +++ b/src/lib/calculations.test.ts @@ -686,6 +686,33 @@ describe("V5 Golden Tests", () => { expect(r.nachlass).toBe(5000); }); + it("Vermoegen wird pro JAHR mitgefuehrt (Grundlage des Verlaufs ueber alle Jahre)", () => { + const p = plan({ + age: 40, retirementAge: 70, inflation: 0, initialCash: 0, + phases: [{ id: "p1", durationYears: 3 }], + elements: [el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 100000, expectedReturn: 10, annualContribution: 0 } })], + }); + const r = computePlan(p); + expect(r.yearly).toHaveLength(3); // ein Punkt je Jahr, nicht nur je Phase + expect(r.yearly[0].wealthNominal).toBe(110000); + expect(r.yearly[1].wealthNominal).toBe(121000); + expect(r.yearly[2].wealthNominal).toBe(133100); + // Ohne Inflation sind real und nominal identisch; Endjahr = Phasen-Endvermoegen. + expect(r.yearly[2].wealthReal).toBe(133100); + expect(r.yearly[2].wealthNominal).toBe(r.phases[0].endWealthNominal); + }); + + it("Vermoegen real ist bei Inflation kaufkraftbereinigt", () => { + const p = plan({ + age: 40, retirementAge: 70, inflation: 10, initialCash: 100000, + phases: [{ id: "p1", durationYears: 1 }], + elements: [], + }); + const y = computePlan(p).yearly[0]; + expect(y.wealthNominal).toBe(100000); + expect(y.wealthReal).toBe(Math.round(100000 / 1.1)); // 90'909 + }); + it("Fortschreibung: nominaler Einkommens-Basiswert Phase 1 -> Startwert Phase 2; Cash laeuft fort", () => { const p = plan({ age: 40, diff --git a/src/lib/calculations.ts b/src/lib/calculations.ts index 3f7db1a..d28ef28 100644 --- a/src/lib/calculations.ts +++ b/src/lib/calculations.ts @@ -94,6 +94,10 @@ export interface YearPoint { income: number; // Einkommen inkl. Renten (nominal) expenseNominal: number; expenseReal: number; + // Gesamtvermoegen am Jahresende (inkl. Cash, abzgl. Schulden) -- fuer den + // Vermoegensverlauf ueber ALLE Jahre statt nur ueber die Phasengrenzen. + wealthNominal: number; + wealthReal: number; } export interface PlanComputed { @@ -589,13 +593,18 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed const expenseReal = expenseRealBase + interestNominal / (inflFactor || 1); const quote = incomeFlow - expenseNominal; - yearly.push({ + // Der Vermoegenswert wird erst nach Verzinsung/Cash-Fortschreibung bekannt und weiter + // unten nachgetragen (gleiche Groesse, die auch die Ruin-Erkennung verwendet). + const yearPoint: YearPoint = { year: yearsBefore + t, age: personA.age + yearsBefore + t, income: Math.round(incomeFlow), expenseNominal: Math.round(expenseNominal), expenseReal: Math.round(expenseReal), - }); + wealthNominal: 0, + wealthReal: 0, + }; + yearly.push(yearPoint); if (t === 1) { incomeStart = incomeFlow; @@ -645,6 +654,8 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed for (const a of assets) total += a.value; for (const re of realEstates) total += re.value - re.mortgage; for (const d of debts) total += -d.owed; + yearPoint.wealthNominal = Math.round(total); + yearPoint.wealthReal = Math.round(total / (cumInfl[yearsBefore + t] || 1)); if (ruinAge === null && total < 0) ruinAge = personA.age + yearsBefore + t; } diff --git a/src/lib/queries.ts b/src/lib/queries.ts index f239a96..688a2f3 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -38,6 +38,7 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput { householdType: plan.householdType, inflationRateDefault: plan.inflationRateDefault, initialCash: plan.initialCash, + startYear: plan.startYear, persons: plan.persons.map((p) => ({ id: p.id, role: p.role, diff --git a/src/lib/types.ts b/src/lib/types.ts index 2433757..7f76ac9 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -67,6 +67,9 @@ export interface PlanInput { householdType: HouseholdType; inflationRateDefault: number; initialCash: number; // Anfangswert des Cash-Kontos in der ersten Lebensphase + // Kalenderjahr des Planbeginns (Jahr 1) -- nur fuer die Darstellung, nicht fuer die + // Berechnung. Optional, damit Berechnungs-Fixtures es nicht setzen muessen. + startYear?: number | null; persons: PersonInput[]; phases: PhaseInput[]; elements: ElementInput[];