UI-Umbau: Analyse-Bereich, Planstart, Zeitachse mit Phasen
Deploy App / deploy (push) Successful in 1m44s

Sieben Anpassungen aus dem Feedback:

- Grafiken liegen neu im eigenen Bereich "Grafiken" (Dialog, Button oben) statt
  unter der Matrix. Die Matrix bleibt die ruhige Hauptansicht.
- Kennzahl "Geschaetzter Nachlass" entfernt -- sie war rechnerisch identisch mit
  dem nominalen Endvermoegen und suggerierte eine Zusatzinformation, die es nicht gab.
- Monte-Carlo-Button nach oben zu den Szenario-Aktionen verschoben.
- Neues Profilfeld "Planstart (Jahr)" (Scenario.startYear + Migration). Rein fuer
  die Darstellung -- die Berechnung rechnet unveraendert in relativen Jahren.
  Bestehende Szenarien werden auf das laufende Jahr gesetzt.
- Zeitachse zeigt die Lebensphasen als Segmente (Breite = Dauer, Einfaerbung nach
  Phasentyp) inkl. Jahresspanne, plus Jahres-Beschriftung an den Enden.
- Lebensphase bearbeiten neu als Popup statt Panel unter der Tabelle -- konsistent
  zu allen anderen Eingaben; Speichern schliesst.
- Vermoegensverlauf ueber ALLE Jahre statt nur ueber die Phasengrenzen. Dafuer
  fuehrt computePlan das Vermoegen neu pro Jahr mit (YearPoint.wealthNominal/
  wealthReal) -- dieselbe Groesse, die schon die Ruin-Erkennung berechnet. Damit
  werden Verlaeufe INNERHALB einer Phase sichtbar (z. B. Kapitalverzehr).

Zwei Tests fuer das Jahres-Vermoegen (58 -> 60). Spezifikation auf v0.9.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 13:40:31 +02:00
parent a5d4868c58
commit d203e504c0
16 changed files with 266 additions and 84 deletions
+47 -16
View File
@@ -4,10 +4,10 @@
| | | | | |
|---|---| |---|---|
| **Dokument** | Funktionale und Technische Spezifikation FPT | | **Dokument** | Funktionale und Technische Spezifikation FPT |
| **Version** | 0.8 | | **Version** | 0.9 |
| **Datum** | 2026-07-18 | | **Datum** | 2026-07-18 |
| **Status** | Lebendes Dokument | | **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` (v1v5) im Ordner `Info Dateien` diese sind ab Version 0.1 dieses Dokuments obsolet | | **Ersetzt** | `FDD_TDD_FPT.docx` (v1v5) im Ordner `Info Dateien` diese sind ab Version 0.1 dieses Dokuments obsolet |
| **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` | | **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` |
@@ -17,6 +17,7 @@
| Version | Datum | Autor | Änderung | | 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.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.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. | | 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`. 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 Lebensphasen
### 3.3.1 Phase anlegen ### 3.3.1 Phase anlegen
@@ -364,8 +375,9 @@ Referenz: `src/app/api/plans/[planId]/phases/route.ts`.
### 3.3.2 Phase bearbeiten ### 3.3.2 Phase bearbeiten
Klick auf einen Phasenkopf öffnet ein Detail-Panel unterhalb der Matrix mit Bezeichnung und Klick auf einen Phasenkopf öffnet ein **Popup** („Lebensphase: <Name>") mit Bezeichnung und
Dauer. Die Dauer wird auch hier gekappt. Eine phasenspezifische Inflationsrate gibt es nicht; 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 das Panel weist darauf hin: „Die Inflationsrate gilt plan-weit und wird in den Plan-Einstellungen
gesetzt." gesetzt."
@@ -727,10 +739,16 @@ Referenz: `src/components/PlanView.tsx` Zeilen 665699.
### 3.6.2 Zeitachse ### 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. „20262046")
- Flaggen-Marker je Person am Pensionsalter (Farbe: Person A indigo, Person B hellblau) - Flaggen-Marker je Person am Pensionsalter (Farbe: Person A indigo, Person B hellblau)
- Trennstriche an den Phasengrenzen
- rotem „Ruin <Alter>"-Marker, falls zutreffend - rotem „Ruin <Alter>"-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`. Referenz: `src/components/Timeline.tsx`.
@@ -760,12 +778,16 @@ Kennzahl falsch beschriften.
Referenz: `src/components/PlanView.tsx` Zeilen 701769. Referenz: `src/components/PlanView.tsx` Zeilen 701769.
### 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 **Kennzahl-Karten:** Endvermögen nominal und Endvermögen real. (Die frühere Karte „Geschätzter
(= Endvermögen der letzten Phase, „potenziell vererbbar"). 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 **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, ü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". anschaulich „das, was die Inflation frisst".
**Grafik 2 Vermögensverlauf nach Alter** (`WealthChart`): Liniendiagramm über das Alter von **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: Person A. Je Szenario eine durchgezogene Linie (nominal) und eine gestrichelte (real).
Startvermögen Phase 1 plus je ein Endwert pro Phase. Über Checkboxen lassen sich **andere Pläne Datenpunkte: **jedes Planjahr** (nicht nur die Phasengrenzen) dadurch werden Verläufe
überlagern** (Szenariovergleich); deren Daten werden bei Bedarf nachgeladen und im Client *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. zwischengespeichert.
**Grafik 3 Vermögensaufteilung pro Phase**: Gestapeltes Balkendiagramm mit zwei Balken je Phase **Grafik 3 Vermögensaufteilung pro Phase**: Gestapeltes Balkendiagramm mit zwei Balken je Phase
@@ -1224,7 +1248,8 @@ expenseReal = expenseRealBase + interestNominal / inflFactor
// 3. Quote // 3. Quote
quote = incomeFlow expenseNominal 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 // 5. Vermögen: verzinsen, Sparbeitrag, Bezugsrate
für jedes Asset a: für jedes Asset a:
@@ -1252,6 +1277,8 @@ falls cash < 0 → cashNegative = true
// 8. Ruin prüfen (Gesamtvermögen zum Jahresende) // 8. Ruin prüfen (Gesamtvermögen zum Jahresende)
total = cash + Σ asset.value + Σ (re.value re.mortgage) + Σ (d.owed) 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 falls ruinAge === null und total < 0 → ruinAge = age(Person A) + yearsBefore + t
``` ```
@@ -1437,7 +1464,7 @@ yearsBefore += duration
```ts ```ts
PlanComputed { PlanComputed {
phases: PhaseComputed[] // alle Kennzahlen je Phase, inkl. elements[] 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 nachlass: number // = endWealthNominal der letzten Phase, sonst 0
ruinAge: number | null // Alter Person A beim ersten Gesamtvermögen < 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` | | `householdType` | `HouseholdType` | `SINGLE` \| `COUPLE` |
| `inflationRateDefault` | Float | szenario-weite Inflation in % | | `inflationRateDefault` | Float | szenario-weite Inflation in % |
| `initialCash` | Float | Default 0 | | `initialCash` | Float | Default 0 |
| `startYear` | Int? | Kalenderjahr des Planbeginns **nur Darstellung** (siehe 3.2.7) |
| `createdAt` / `updatedAt` | DateTime | | | `createdAt` / `updatedAt` | DateTime | |
**Phase** **Phase**
@@ -1841,6 +1869,7 @@ sondern zu leeren Werten.
| `20260716210000_drop_phase_inflation_rate` | `Phase.inflationRate` entfernt (Inflation ist plan-weit) | | `20260716210000_drop_phase_inflation_rate` | `Phase.inflationRate` entfernt (Inflation ist plan-weit) |
| `20260716230000_phase_cash_transition` | `Phase.cashTransition` (JSONB) für einmalige Sonderein-/ausgaben | | `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 | | `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 **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 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 Getestet wird ausschliesslich der Berechnungskern bewusst, da dort die Fachlogik und das
Regressionsrisiko liegen. `src/lib/calculations.test.ts` (41 Tests: AHV-Rentenformel, Immobilie, Regressionsrisiko liegen. `src/lib/calculations.test.ts` (41 Tests: AHV-Rentenformel, Immobilie,
Teilverkauf, Sonderamortisation, AHV einkommensabhängig, „V5 Golden Tests") und 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-, der Node-Umgebung (`vitest.config.ts`, Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-,
API- oder E2E-Tests. API- oder E2E-Tests.
@@ -2141,6 +2170,8 @@ API- oder E2E-Tests.
| **MC: Volatilität / Vol-Drag** | σ > 0 spreizt p10<median<p90; Median unter dem deterministischen Wert | | **MC: Volatilität / Vol-Drag** | σ > 0 spreizt p10<median<p90; Median unter dem deterministischen Wert |
| **MC: Erfolg / Reproduzierbarkeit** | P(≥ Ziel) fällt mit steigendem Ziel; gleicher Seed → identisches Ergebnis | | **MC: Erfolg / Reproduzierbarkeit** | P(≥ Ziel) fällt mit steigendem Ziel; gleicher Seed → identisches Ergebnis |
| **MC: Boden / Ruin** | 0%-Boden hält PK/3a ≥ Startwert; sicherer Verzehr → Ruinwahrscheinlichkeit 100 % | | **MC: Boden / Ruin** | 0%-Boden hält PK/3a ≥ Startwert; sicherer Verzehr → Ruinwahrscheinlichkeit 100 % |
| **Vermögen je Jahr** | 100k @ 10 % über 3 J. → 110k/121k/133.1k je Jahrespunkt; Endjahr = Phasen-Endvermögen |
| **Vermögen real** | 100k bei 10 % Inflation → real 90'909 |
| Test 1 Ansparen | Einkommen +2 % nominal, Ausgaben real flach: Endvermögen 761'654 nominal / 565'928 real (±1 %), kein negatives Cash | | Test 1 Ansparen | Einkommen +2 % nominal, Ausgaben real flach: Endvermögen 761'654 nominal / 565'928 real (±1 %), kein negatives Cash |
| Test 2 Verzehr/Ruin | Rente nominal fix 60k, Ausgaben real 100k, Vermögen 900k @3 %: `ruinAge === 94` | | Test 2 Verzehr/Ruin | Rente nominal fix 60k, Ausgaben real 100k, Vermögen 900k @3 %: `ruinAge === 94` |
| Test 3 Cash-Ausgleich | Sparrate 6'364: `cashEnd === 5472`, nie negativ | | Test 3 Cash-Ausgleich | Sparrate 6'364: `cashEnd === 5472`, nie negativ |
@@ -0,0 +1,6 @@
-- Kalenderjahr des Planbeginns (Jahr 1). Nur fuer die Darstellung (Zeitachse, Grafiken);
-- die Berechnung bleibt in relativen Jahren.
ALTER TABLE "Scenario" ADD COLUMN "startYear" INTEGER;
-- Bestehende Szenarien auf das laufende Jahr setzen, damit die Zeitachse sofort Jahre zeigt.
UPDATE "Scenario" SET "startYear" = EXTRACT(YEAR FROM CURRENT_DATE)::int WHERE "startYear" IS NULL;
+3
View File
@@ -107,6 +107,9 @@ model Scenario {
householdType HouseholdType householdType HouseholdType
inflationRateDefault Float inflationRateDefault Float
initialCash Float @default(0) initialCash Float @default(0)
// Kalenderjahr, in dem die Planung beginnt (Jahr 1). Rein fuer die Darstellung --
// die Berechnung rechnet weiterhin in relativen Jahren ab Planbeginn.
startYear Int?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
+2
View File
@@ -15,6 +15,7 @@ const personSchema = z.object({
export const scenarioProfileSchema = z.object({ export const scenarioProfileSchema = z.object({
householdType: z.enum(["SINGLE", "COUPLE"]), householdType: z.enum(["SINGLE", "COUPLE"]),
inflationRateDefault: z.number().min(-20).max(50), inflationRateDefault: z.number().min(-20).max(50),
startYear: z.number().int().min(1900).max(2200).nullish(),
persons: z.array(personSchema).min(1).max(2), persons: z.array(personSchema).min(1).max(2),
}); });
@@ -72,6 +73,7 @@ export async function POST(request: NextRequest) {
isBase: true, isBase: true,
householdType: parsed.data.householdType, householdType: parsed.data.householdType,
inflationRateDefault: parsed.data.inflationRateDefault, inflationRateDefault: parsed.data.inflationRateDefault,
startYear: parsed.data.startYear ?? new Date().getFullYear(),
persons: { create: parsed.data.persons }, persons: { create: parsed.data.persons },
}, },
}, },
@@ -31,6 +31,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
householdType: source.householdType, householdType: source.householdType,
inflationRateDefault: source.inflationRateDefault, inflationRateDefault: source.inflationRateDefault,
initialCash: source.initialCash, initialCash: source.initialCash,
startYear: source.startYear,
persons: { persons: {
create: source.persons.map((p) => ({ create: source.persons.map((p) => ({
role: p.role, role: p.role,
@@ -72,6 +72,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
name: data.name ?? undefined, name: data.name ?? undefined,
householdType: data.householdType, householdType: data.householdType,
inflationRateDefault: data.inflationRateDefault, inflationRateDefault: data.inflationRateDefault,
startYear: data.startYear ?? undefined,
persons: { create: data.persons }, persons: { create: data.persons },
}, },
}); });
+73 -8
View File
@@ -2,7 +2,9 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { import {
BarChart3,
Copy, Copy,
Dices,
FileText, FileText,
FolderKanban, FolderKanban,
GitBranch, GitBranch,
@@ -15,6 +17,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { PlanView } from "@/components/PlanView"; import { PlanView } from "@/components/PlanView";
import { Dashboard } from "@/components/Dashboard"; import { Dashboard } from "@/components/Dashboard";
import { MonteCarloDialog } from "@/components/MonteCarloDialog";
import { SpecView } from "@/components/SpecView"; import { SpecView } from "@/components/SpecView";
import { ProfileMenu } from "@/components/ProfileMenu"; import { ProfileMenu } from "@/components/ProfileMenu";
import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields"; import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields";
@@ -39,6 +42,8 @@ export function AppShell({ username }: { username: string }) {
const [showNewPlan, setShowNewPlan] = useState(false); const [showNewPlan, setShowNewPlan] = useState(false);
const [copyFrom, setCopyFrom] = useState<ScenarioMeta | null>(null); const [copyFrom, setCopyFrom] = useState<ScenarioMeta | null>(null);
const [showSpec, setShowSpec] = useState(false); const [showSpec, setShowSpec] = useState(false);
const [showCharts, setShowCharts] = useState(false);
const [showMonteCarlo, setShowMonteCarlo] = useState(false);
const loadPlans = useCallback(async () => { const loadPlans = useCallback(async () => {
const data = await api.get<{ plans: PlanListItem[] }>("/api/plans"); const data = await api.get<{ plans: PlanListItem[] }>("/api/plans");
@@ -267,6 +272,26 @@ export function AppShell({ username }: { username: string }) {
Szenario loeschen Szenario loeschen
</button> </button>
)} )}
{detail.plan.phases.length > 0 && (
<>
<button
type="button"
onClick={() => setShowCharts(true)}
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-muted hover:bg-surface-2"
>
<BarChart3 className="h-4 w-4" />
Grafiken
</button>
<button
type="button"
onClick={() => setShowMonteCarlo(true)}
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-muted hover:bg-surface-2"
>
<Dices className="h-4 w-4" />
Monte-Carlo-Simulation
</button>
</>
)}
{diff && detail.base && ( {diff && detail.base && (
<span className="ml-auto flex items-center gap-1.5 rounded-lg border border-diff bg-diff-soft px-3 py-1.5 text-xs font-medium text-diff"> <span className="ml-auto flex items-center gap-1.5 rounded-lg border border-diff bg-diff-soft px-3 py-1.5 text-xs font-medium text-diff">
<GitBranch className="h-3.5 w-3.5" /> <GitBranch className="h-3.5 w-3.5" />
@@ -278,14 +303,6 @@ export function AppShell({ username }: { username: string }) {
</div> </div>
<PlanView plan={detail.plan} computed={detail.computed} diff={diff} onChanged={refreshCurrent} /> <PlanView plan={detail.plan} computed={detail.computed} diff={diff} onChanged={refreshCurrent} />
{detail.plan.phases.length > 0 && (
<Dashboard
plan={detail.plan}
computed={detail.computed}
siblings={(activePlan?.scenarios ?? []).filter((s) => s.id !== detail.meta.id)}
/>
)}
</div> </div>
)} )}
</main> </main>
@@ -306,6 +323,24 @@ export function AppShell({ username }: { username: string }) {
/> />
)} )}
{showCharts && detail && (
<ChartsDialog onClose={() => setShowCharts(false)} title={`Grafiken · ${detail.meta.name}`}>
<Dashboard
plan={detail.plan}
computed={detail.computed}
siblings={(activePlan?.scenarios ?? []).filter((s) => s.id !== detail.meta.id)}
/>
</ChartsDialog>
)}
{showMonteCarlo && detail && (
<MonteCarloDialog
plan={detail.plan}
computed={detail.computed}
onClose={() => setShowMonteCarlo(false)}
/>
)}
{copyFrom && ( {copyFrom && (
<CopyScenarioDialog <CopyScenarioDialog
source={copyFrom} source={copyFrom}
@@ -325,6 +360,36 @@ export function AppShell({ username }: { username: string }) {
); );
} }
// Breiter Dialog fuer den Analyse-Bereich (Grafiken).
function ChartsDialog({
title,
children,
onClose,
}: {
title: string;
children: React.ReactNode;
onClose: () => void;
}) {
return (
<div className="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="flex w-full max-w-5xl 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">
<BarChart3 className="h-5 w-5 text-accent" /> {title}
</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>
{children}
</div>
</div>
);
}
// Rekursiver Szenario-Baum: Kinder werden eingerueckt, damit Sub-Szenarien sichtbar sind. // Rekursiver Szenario-Baum: Kinder werden eingerueckt, damit Sub-Szenarien sichtbar sind.
function ScenarioTree({ function ScenarioTree({
scenarios, scenarios,
+1 -2
View File
@@ -93,10 +93,9 @@ export function Dashboard({
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<StatCard label="Endvermoegen (nominal)" value={lastPhase ? lastPhase.endWealthNominal : 0} /> <StatCard label="Endvermoegen (nominal)" value={lastPhase ? lastPhase.endWealthNominal : 0} />
<StatCard label="Endvermoegen (real, kaufkraftbereinigt)" value={lastPhase ? lastPhase.endWealthReal : 0} /> <StatCard label="Endvermoegen (real, kaufkraftbereinigt)" value={lastPhase ? lastPhase.endWealthReal : 0} />
<StatCard label="Geschaetzter Nachlass" value={computed.nachlass} help="Endvermoegen der letzten Phase - potenziell vererbbar." />
</div> </div>
<section className="rounded-xl border border-border bg-surface p-4 shadow-sm"> <section className="rounded-xl border border-border bg-surface p-4 shadow-sm">
+11
View File
@@ -6,6 +6,7 @@ import type { HouseholdType, PersonRole } from "@/lib/types";
export interface ProfileDraft { export interface ProfileDraft {
householdType: HouseholdType; householdType: HouseholdType;
inflationRateDefault: number; inflationRateDefault: number;
startYear: number;
persons: { role: PersonRole; name: string; age: number; retirementAge: number }[]; persons: { role: PersonRole; name: string; age: number; retirementAge: number }[];
} }
@@ -13,6 +14,7 @@ export function emptyProfileDraft(): ProfileDraft {
return { return {
householdType: "SINGLE", householdType: "SINGLE",
inflationRateDefault: 1.5, inflationRateDefault: 1.5,
startYear: new Date().getFullYear(),
persons: [{ role: "PERSON_A", name: "", age: 35, retirementAge: 65 }], persons: [{ role: "PERSON_A", name: "", age: 35, retirementAge: 65 }],
}; };
} }
@@ -89,6 +91,15 @@ export function PlanProfileFields({
</div> </div>
))} ))}
<NumberField
label="Planstart (Jahr)"
help="Kalenderjahr, in dem die Planung beginnt (Jahr 1). Wird auf der Zeitachse und in den Grafiken angezeigt; die Berechnung selbst rechnet in Jahren ab Planbeginn."
value={draft.startYear}
min={1900}
max={2200}
onChange={(v) => onChange({ ...draft, startYear: Math.round(v) })}
/>
<NumberField <NumberField
label="Erwartete Inflationsrate (%)" label="Erwartete Inflationsrate (%)"
help="Langfristige Annahme zur jaehrlichen Teuerung. Gilt plan-weit fuer alle Lebensphasen." help="Langfristige Annahme zur jaehrlichen Teuerung. Gilt plan-weit fuer alle Lebensphasen."
+24 -24
View File
@@ -8,7 +8,6 @@ import {
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
CreditCard, CreditCard,
Dices,
Home, Home,
Landmark, Landmark,
PiggyBank, PiggyBank,
@@ -33,7 +32,6 @@ import {
type CellContext, type CellContext,
} from "@/components/ElementDetail"; } from "@/components/ElementDetail";
import { PhaseDetail } from "@/components/PhaseDetail"; import { PhaseDetail } from "@/components/PhaseDetail";
import { MonteCarloDialog } from "@/components/MonteCarloDialog";
import { PlanProfileFields, type ProfileDraft } from "@/components/PlanProfileFields"; import { PlanProfileFields, type ProfileDraft } from "@/components/PlanProfileFields";
import { MoneyField } from "@/components/FormField"; import { MoneyField } from "@/components/FormField";
import { api } from "@/lib/api-client"; import { api } from "@/lib/api-client";
@@ -123,7 +121,6 @@ export function PlanView({
const [editTransition, setEditTransition] = useState<{ elementId: string; fromPhaseId: string } | null>(null); const [editTransition, setEditTransition] = useState<{ elementId: string; fromPhaseId: string } | null>(null);
const [editPhaseCell, setEditPhaseCell] = useState<{ elementId: string; phaseId: string } | null>(null); const [editPhaseCell, setEditPhaseCell] = useState<{ elementId: string; phaseId: string } | null>(null);
const [showCashInit, setShowCashInit] = useState(false); const [showCashInit, setShowCashInit] = useState(false);
const [showMonteCarlo, setShowMonteCarlo] = useState(false);
// fromPhaseId des Cash-Uebergangs, der gerade bearbeitet wird. // fromPhaseId des Cash-Uebergangs, der gerade bearbeitet wird.
const [editCashTransition, setEditCashTransition] = useState<string | null>(null); const [editCashTransition, setEditCashTransition] = useState<string | null>(null);
const [valueMode, setValueMode] = useState<ValueMode>("nominal"); const [valueMode, setValueMode] = useState<ValueMode>("nominal");
@@ -321,7 +318,12 @@ export function PlanView({
<span className="text-[11px] text-faint">real = kaufkraftbereinigt (Planbeginn)</span> <span className="text-[11px] text-faint">real = kaufkraftbereinigt (Planbeginn)</span>
</div> </div>
<Timeline phases={computed.phases} persons={personAxes} ruinAge={computed.ruinAge} /> <Timeline
phases={computed.phases}
persons={personAxes}
ruinAge={computed.ruinAge}
startYear={plan.startYear}
/>
{/* Plan-Profil */} {/* Plan-Profil */}
<div <div
@@ -376,13 +378,6 @@ export function PlanView({
> >
<Plus className="h-4 w-4" /> Lebensphase <Plus className="h-4 w-4" /> Lebensphase
</button> </button>
<button
type="button"
onClick={() => setShowMonteCarlo(true)}
className="ml-auto flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-muted hover:bg-surface-2"
>
<Dices className="h-4 w-4" /> Monte-Carlo-Simulation durchführen
</button>
</div> </div>
)} )}
@@ -590,13 +585,18 @@ export function PlanView({
</div> </div>
)} )}
{/* Detail-Panel. Der key erzwingt beim Wechsel von Zelle/Phase einen Neuaufbau, {/* Lebensphase bearbeiten -- als Popup, konsistent zu allen anderen Eingaben.
damit der lokale Formular-Zustand nicht vom vorher geoeffneten Element uebrig bleibt. */} Der key erzwingt beim Wechsel der Phase einen Neuaufbau, damit der lokale
{selected && ( Formular-Zustand nicht von der vorher geoeffneten Phase uebrig bleibt. */}
<div key={`ph-${selected.phaseId}`} className="rounded-xl border border-accent bg-surface p-4 shadow-sm"> {selected && (() => {
{renderDetail()} const phase = computed.phases.find((p) => p.id === selected.phaseId);
</div> if (!phase) return null;
)} return (
<DialogShell key={`ph-${selected.phaseId}`} title={`Lebensphase: ${phase.name}`} onClose={() => setSelected(null)}>
{renderDetail()}
</DialogShell>
);
})()}
{showAdd && firstPhase && ( {showAdd && firstPhase && (
<AddElementDialog <AddElementDialog
@@ -640,11 +640,7 @@ export function PlanView({
/> />
)} )}
{showMonteCarlo && ( {editCashTransition && (() => {
<MonteCarloDialog plan={plan} computed={computed} onClose={() => setShowMonteCarlo(false)} />
)}
{editCashTransition && (() => {
const fromPhase = computed.phases.find((p) => p.id === editCashTransition); const fromPhase = computed.phases.find((p) => p.id === editCashTransition);
if (!fromPhase) return null; if (!fromPhase) return null;
const toIndex = computed.phases.findIndex((p) => p.id === fromPhase.id) + 1; const toIndex = computed.phases.findIndex((p) => p.id === fromPhase.id) + 1;
@@ -759,7 +755,10 @@ export function PlanView({
maxDurationYears={phase.maxDurationYears} maxDurationYears={phase.maxDurationYears}
isLast={isLast} isLast={isLast}
laterPhaseCount={laterPhaseCount(phase)} laterPhaseCount={laterPhaseCount(phase)}
onSaved={onChanged} onSaved={() => {
setSelected(null);
onChanged();
}}
onDeleted={() => { onDeleted={() => {
setSelected(null); setSelected(null);
onChanged(); onChanged();
@@ -1190,6 +1189,7 @@ function PlanSettingsDialog({ plan, onClose, onSaved }: { plan: PlanInput; onClo
const [draft, setDraft] = useState<ProfileDraft>({ const [draft, setDraft] = useState<ProfileDraft>({
householdType: plan.householdType, householdType: plan.householdType,
inflationRateDefault: plan.inflationRateDefault, 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 })), persons: plan.persons.map((p) => ({ role: p.role, name: p.name ?? "", age: p.age, retirementAge: p.retirementAge })),
}); });
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
+46 -23
View File
@@ -11,16 +11,19 @@ interface PersonAxis {
color: string; color: string;
} }
// Horizontale Zeitachse: Alter von links nach rechts, mit deutlich markiertem // Horizontale Zeitachse: die Lebensphasen als aneinandergereihte Segmente (Breite = Dauer),
// Pensionsalter je Person und Trennlinien an den Phasengrenzen. // darunter Alter und -- sofern ein Planstart gesetzt ist -- die Kalenderjahre. Pensionsalter
// je Person und ein allfaelliges Ruinalter sind als Marker eingezeichnet.
export function Timeline({ export function Timeline({
phases, phases,
persons, persons,
ruinAge, ruinAge,
startYear,
}: { }: {
phases: PhaseComputed[]; phases: PhaseComputed[];
persons: PersonAxis[]; persons: PersonAxis[];
ruinAge?: number | null; ruinAge?: number | null;
startYear?: number | null;
}) { }) {
if (phases.length === 0 || persons.length === 0) return 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 span = Math.max(1, maxAge - minAge);
const pct = (age: number) => `${(Math.max(0, Math.min(span, age - minAge)) / span) * 100}%`; 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). // Segmente: Start-/Endjahr (relativ zum Planbeginn) je Phase -- ohne Mutation waehrend
const boundaries: { year: number; label: string }[] = []; // des Renderns, deshalb die kumulierte Summe der vorangehenden Dauern.
let acc = 0; const segments = phases.map((p, i) => {
for (const p of phases) { const from = phases.slice(0, i).reduce((s, x) => s + x.durationYears, 0);
boundaries.push({ year: acc, label: p.name }); return { phase: p, from, to: from + p.durationYears };
acc += p.durationYears; });
}
return ( return (
<div className="rounded-xl border border-border bg-surface p-4 shadow-sm"> <div className="rounded-xl border border-border bg-surface p-4 shadow-sm">
<div className="mb-3 flex items-center justify-between"> <div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-fg">Zeitachse</h3> <h3 className="text-sm font-semibold text-fg">Zeitachse</h3>
<div className="flex gap-3 text-xs text-muted"> <div className="flex gap-3 text-xs text-muted">
{persons.map((p) => ( {persons.map((p) => (
@@ -61,7 +64,9 @@ export function Timeline({
key={p.role} key={p.role}
className="absolute top-0 flex -translate-x-1/2 flex-col items-center" className="absolute top-0 flex -translate-x-1/2 flex-col items-center"
style={{ left: pct(p.retirementAge) }} 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)})` : ""
}`}
> >
<Flag className="h-3.5 w-3.5" style={{ color: p.color }} fill={p.color} /> <Flag className="h-3.5 w-3.5" style={{ color: p.color }} fill={p.color} />
<span className="whitespace-nowrap text-[10px] font-medium" style={{ color: p.color }}> <span className="whitespace-nowrap text-[10px] font-medium" style={{ color: p.color }}>
@@ -86,21 +91,39 @@ export function Timeline({
</div> </div>
)} )}
{/* Achse */} {/* Phasen-Segmente: Breite proportional zur Dauer, Einfaerbung nach Phasentyp. */}
<div className="relative h-2 w-full rounded-full bg-gradient-to-r from-accent-soft to-accent"> <div className="flex w-full overflow-hidden rounded-lg border border-border">
{boundaries.slice(1).map((b) => ( {segments.map((s, i) => {
<div const soft = s.phase.type === "PENSION" || s.phase.type === "MIXED";
key={b.year} return (
className="absolute top-0 h-2 w-px bg-surface/70" <div
style={{ left: pct(minAge + b.year) }} key={s.phase.id}
/> style={{ width: `${(s.phase.durationYears / span) * 100}%` }}
))} title={`${s.phase.name} · ${s.phase.durationYears} Jahre`}
className={`flex min-w-0 flex-col justify-center px-2 py-1.5 ${
i > 0 ? "border-l border-border" : ""
} ${s.phase.type === "PENSION" ? "bg-accent-soft" : s.phase.type === "MIXED" ? "bg-accent/25" : "bg-accent/70"}`}
>
<span className={`truncate text-[10px] font-semibold ${soft ? "text-accent-soft-fg" : "text-accent-fg"}`}>
{s.phase.name}
</span>
<span className={`truncate text-[9px] ${soft ? "text-accent-soft-fg" : "text-accent-fg"} opacity-80`}>
{s.phase.durationYears} J.
{yearOf(s.from) ? ` · ${yearOf(s.from)}${yearOf(s.to)}` : ""}
</span>
</div>
);
})}
</div> </div>
{/* Alters-Beschriftung */} {/* Alters- und Jahres-Beschriftung */}
<div className="mt-1 flex justify-between text-[11px] text-muted"> <div className="mt-1 flex justify-between text-[11px] text-muted">
<span>{minAge} J.</span> <span>
<span>{maxAge} J.</span> {minAge} J.{yearOf(0) ? ` · ${yearOf(0)}` : ""}
</span>
<span>
{maxAge} J.{yearOf(totalYears) ? ` · ${yearOf(totalYears)}` : ""}
</span>
</div> </div>
</div> </div>
</div> </div>
+7 -9
View File
@@ -19,18 +19,16 @@ export interface TimelineSeries {
computed: PlanComputed; computed: PlanComputed;
} }
// Datenpunkte je Serie: Start (Beginn Phase 1) + Endwert je Phase, verortet auf dem // Datenpunkte je Serie: JEDES Planjahr (nicht nur die Phasengrenzen), verortet auf dem Alter
// Alter der Referenzperson (Person A). So laeuft die x-Achse ueber das Alter statt ueber // der Referenzperson (Person A). Der erste Punkt ist das Startvermoegen vor Jahr 1.
// die Phasen.
function pointsFor(computed: PlanComputed) { function pointsFor(computed: PlanComputed) {
const phases = computed.phases; const phases = computed.phases;
if (phases.length === 0) return [] as { age: number; nominal: number; real: number }[]; if (phases.length === 0) return [] as { age: number; nominal: number; real: number }[];
const refAge = (ph: PlanComputed["phases"][number]) => ph.persons[0]?.startAge ?? 0; const startAge = phases[0].persons[0]?.startAge ?? 0;
const pts = [ const start = Math.round(phases[0].startWealthNominal);
{ age: refAge(phases[0]), nominal: Math.round(phases[0].startWealthNominal), real: Math.round(phases[0].startWealthNominal) }, const pts = [{ age: startAge, nominal: start, real: start }];
]; for (const y of computed.yearly) {
for (const p of phases) { pts.push({ age: y.age, nominal: y.wealthNominal, real: y.wealthReal });
pts.push({ age: refAge(p) + p.durationYears, nominal: Math.round(p.endWealthNominal), real: Math.round(p.endWealthReal) });
} }
return pts; return pts;
} }
+27
View File
@@ -686,6 +686,33 @@ describe("V5 Golden Tests", () => {
expect(r.nachlass).toBe(5000); 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", () => { it("Fortschreibung: nominaler Einkommens-Basiswert Phase 1 -> Startwert Phase 2; Cash laeuft fort", () => {
const p = plan({ const p = plan({
age: 40, age: 40,
+13 -2
View File
@@ -94,6 +94,10 @@ export interface YearPoint {
income: number; // Einkommen inkl. Renten (nominal) income: number; // Einkommen inkl. Renten (nominal)
expenseNominal: number; expenseNominal: number;
expenseReal: 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 { export interface PlanComputed {
@@ -589,13 +593,18 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed
const expenseReal = expenseRealBase + interestNominal / (inflFactor || 1); const expenseReal = expenseRealBase + interestNominal / (inflFactor || 1);
const quote = incomeFlow - expenseNominal; 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, year: yearsBefore + t,
age: personA.age + yearsBefore + t, age: personA.age + yearsBefore + t,
income: Math.round(incomeFlow), income: Math.round(incomeFlow),
expenseNominal: Math.round(expenseNominal), expenseNominal: Math.round(expenseNominal),
expenseReal: Math.round(expenseReal), expenseReal: Math.round(expenseReal),
}); wealthNominal: 0,
wealthReal: 0,
};
yearly.push(yearPoint);
if (t === 1) { if (t === 1) {
incomeStart = incomeFlow; incomeStart = incomeFlow;
@@ -645,6 +654,8 @@ export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed
for (const a of assets) total += a.value; for (const a of assets) total += a.value;
for (const re of realEstates) total += re.value - re.mortgage; for (const re of realEstates) total += re.value - re.mortgage;
for (const d of debts) total += -d.owed; 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; if (ruinAge === null && total < 0) ruinAge = personA.age + yearsBefore + t;
} }
+1
View File
@@ -38,6 +38,7 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
householdType: plan.householdType, householdType: plan.householdType,
inflationRateDefault: plan.inflationRateDefault, inflationRateDefault: plan.inflationRateDefault,
initialCash: plan.initialCash, initialCash: plan.initialCash,
startYear: plan.startYear,
persons: plan.persons.map((p) => ({ persons: plan.persons.map((p) => ({
id: p.id, id: p.id,
role: p.role, role: p.role,
+3
View File
@@ -67,6 +67,9 @@ export interface PlanInput {
householdType: HouseholdType; householdType: HouseholdType;
inflationRateDefault: number; inflationRateDefault: number;
initialCash: number; // Anfangswert des Cash-Kontos in der ersten Lebensphase 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[]; persons: PersonInput[];
phases: PhaseInput[]; phases: PhaseInput[];
elements: ElementInput[]; elements: ElementInput[];