UI-Umbau: Analyse-Bereich, Planstart, Zeitachse mit Phasen
Deploy App / deploy (push) Successful in 1m44s
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:
+47
-16
@@ -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` (v1–v5) im Ordner `Info Dateien` – diese sind ab Version 0.1 dieses Dokuments obsolet |
|
| **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` |
|
| **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 665–699.
|
|||||||
|
|
||||||
### 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. „2026–2046")
|
||||||
- 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 701–769.
|
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
|
**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;
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
Reference in New Issue
Block a user