Raten ueber Lebensphasen uebernehmen + Rate in der Verlaufsgrafik
Deploy App / deploy (push) Successful in 1m6s

(1) Beim Aendern eines Ratenfelds fragt das Panel nach der Reichweite:
nur diese Phase (Vorgabe), diese + folgende, alle Phasen. Gilt fuer
expectedReturn, valueGrowth, interestRate und teuerungsausgleich.

Inline statt Modal -- das Zahlenfeld loest je Tastendruck aus. Die
Zielphasen behalten ihre uebrigen Werte (der Endpunkt ersetzt den ganzen
Satz; ein Kopieren des Entwurfs haette dort Betraege geloescht).

(2) ElementYearPoint fuehrt neu `rate` mit -- additiv, nur durchgereicht.
Verlaufsgrafik zeigt sie auf zweiter Y-Achse als Stufenlinie.

Golden Tests unveraendert. Spezifikation 0.20, 17 Tests (164 -> 181).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 22:36:05 +02:00
parent d023534a03
commit 4343d50aa4
7 changed files with 482 additions and 11 deletions
+59 -3
View File
@@ -4,7 +4,7 @@
| | | | | |
|---|---| |---|---|
| **Dokument** | Funktionale und Technische Spezifikation FPT | | **Dokument** | Funktionale und Technische Spezifikation FPT |
| **Version** | 0.19 | | **Version** | 0.20 |
| **Datum** | 2026-07-18 | | **Datum** | 2026-07-18 |
| **Status** | Lebendes Dokument | | **Status** | Lebendes Dokument |
| **Codestand** | Arbeitsstand nach `1d046e9` inkl. zwei Monte-Carlo-Fragestellungen (Branch `main`) | | **Codestand** | Arbeitsstand nach `1d046e9` inkl. zwei Monte-Carlo-Fragestellungen (Branch `main`) |
@@ -17,6 +17,7 @@
| Version | Datum | Autor | Änderung | | Version | Datum | Autor | Änderung |
|---|---|---|---| |---|---|---|---|
| 0.20 | 2026-07-19 | Claude (Opus 4.8) | **Raten über Lebensphasen übernehmen** (neues Kapitel 3.6.11) und **Rate in der Verlaufsgrafik**. (1) Ändert man ein **Ratenfeld**, fragt das Bearbeitungspanel neu nach der Reichweite: **nur diese Phase** (Vorgabe, bisheriges Verhalten), **diese + folgende** oder **alle Phasen**. Anlass war, dass eine geänderte Rendite bisher nur für die eine Phase galt und viermal eingetippt werden musste. Als Ratenfelder gelten `expectedReturn` (PK, 3a, Sonstiges Vermögen), `valueGrowth` und `interestRate` (Immobilie) sowie `teuerungsausgleich` (Einkommen, Ausgaben); AHV und Schulden haben keine. Die Rückfrage erscheint **inline und erst beim Speichern wirksam**, nicht als Modal das Zahlenfeld löst bei jedem Tastendruck aus, ein Dialog erschiene bei «5.2» viermal. Sie erscheint nur bei **tatsächlich veränderten** Raten («nicht gesetzt» und 0 gelten als gleich). Beim Übertragen bleiben die **übrigen Werte der Zielphasen erhalten** der Endpunkt ersetzt den ganzen Werte-Satz, ein blosses Kopieren des Entwurfs hätte dort Beträge, Sparraten und Bezüge gelöscht (durch Test abgesichert). Phasen, in denen der Wert schon stimmt, werden übersprungen. Kein neuer Schreibpfad: ein PUT je Zielphase über den bestehenden Endpunkt, alle in einer Bearbeitungssitzung und damit in **einer** Nebenversion. (2) `ElementYearPoint` führt neu ein Feld **`rate`** mit additiv, es wird nur durchgereicht, was die Rechnung ohnehin benutzt; die 43 Golden Tests laufen unverändert. Die Verlaufsgrafik der Element-Detailansicht zeigt die Rate damit auf einer **zweiten Y-Achse rechts** in Prozent, als **Stufenlinie** (innerhalb einer Phase konstant, Sprung an der Phasengrenze). Neues Modul `ratefields.ts`; 17 Tests ergänzt (164 → 181). Keine DB- oder API-Änderung. |
| 0.19 | 2026-07-19 | Claude (Opus 4.8) | **Versionierung und Änderungshistorie je Szenario** (neues Kapitel 3.8). Jedes Szenario trägt eine Version **A.B**: **B** entsteht automatisch, **A** manuell mit Pflichtkommentar. **Der zentrale Entwurfsentscheid:** FPT hat keinen Speichern-Knopf jede Änderung schreibt sofort, ein Assistenten-Durchlauf macht ~14 Schreibvorgänge, ein Verteil-Klick einen je Zielelement. Eine Version je Schreibvorgang wäre ein Tastenprotokoll gewesen; stattdessen werden alle Schreibvorgänge innerhalb von **10 Minuten zu einer** Nebenversion zusammengefasst, inhaltlich unveränderte Stände erzeugen gar keine, und verschiedene Benutzer laufen nie in einer Version zusammen. Eine Version hält den **vollständigen** Zustand als JSON in der Form `PlanInput` dadurch ist die **Versionsauswahl in allen vier Analysewerkzeugen** (Grafiken, Live-Simulation, Monte-Carlo, Einflussfaktoren) fast kostenlos; bei Monte-Carlo **je Szenario einzeln**, weil dort mehrere gleichzeitig laufen. **Wiederherstellen** ist ungefährlich gebaut: Es legt den zurückgesetzten Stand selbst als neue Version an («Wiederhergestellt aus A.B»), löscht also nichts, und **erhält die IDs** von Phasen und Elementen sonst verlören alle Kind-Szenarien ihre Diff-Basis und zeigten schlagartig alles als «neu». Wo ein Bezug trotzdem bricht (der alte Stand kannte das Element noch nicht), **warnt der Dialog vorher namentlich**. Die destruktive Logik liegt als reine Funktion `planRestore` vor und ist dort getestet; `versioning-db.ts` führt sie nur aus. Ein **statischer Wächter-Test** liest alle Route-Dateien und verlangt, dass jeder schreibende Endpunkt eine Version auslöst eine vergessene Stelle wäre eine stille Lücke. Neue Tabelle `ScenarioVersion` + `Scenario.currentMajor` (Migration gegen echtes Postgres verifiziert), neue Endpunkte unter `/api/scenarios/<id>/versions`. Neue Kapitel 3.8 und 9.28; 25 Tests ergänzt (139 → 164). | | 0.19 | 2026-07-19 | Claude (Opus 4.8) | **Versionierung und Änderungshistorie je Szenario** (neues Kapitel 3.8). Jedes Szenario trägt eine Version **A.B**: **B** entsteht automatisch, **A** manuell mit Pflichtkommentar. **Der zentrale Entwurfsentscheid:** FPT hat keinen Speichern-Knopf jede Änderung schreibt sofort, ein Assistenten-Durchlauf macht ~14 Schreibvorgänge, ein Verteil-Klick einen je Zielelement. Eine Version je Schreibvorgang wäre ein Tastenprotokoll gewesen; stattdessen werden alle Schreibvorgänge innerhalb von **10 Minuten zu einer** Nebenversion zusammengefasst, inhaltlich unveränderte Stände erzeugen gar keine, und verschiedene Benutzer laufen nie in einer Version zusammen. Eine Version hält den **vollständigen** Zustand als JSON in der Form `PlanInput` dadurch ist die **Versionsauswahl in allen vier Analysewerkzeugen** (Grafiken, Live-Simulation, Monte-Carlo, Einflussfaktoren) fast kostenlos; bei Monte-Carlo **je Szenario einzeln**, weil dort mehrere gleichzeitig laufen. **Wiederherstellen** ist ungefährlich gebaut: Es legt den zurückgesetzten Stand selbst als neue Version an («Wiederhergestellt aus A.B»), löscht also nichts, und **erhält die IDs** von Phasen und Elementen sonst verlören alle Kind-Szenarien ihre Diff-Basis und zeigten schlagartig alles als «neu». Wo ein Bezug trotzdem bricht (der alte Stand kannte das Element noch nicht), **warnt der Dialog vorher namentlich**. Die destruktive Logik liegt als reine Funktion `planRestore` vor und ist dort getestet; `versioning-db.ts` führt sie nur aus. Ein **statischer Wächter-Test** liest alle Route-Dateien und verlangt, dass jeder schreibende Endpunkt eine Version auslöst eine vergessene Stelle wäre eine stille Lücke. Neue Tabelle `ScenarioVersion` + `Scenario.currentMajor` (Migration gegen echtes Postgres verifiziert), neue Endpunkte unter `/api/scenarios/<id>/versions`. Neue Kapitel 3.8 und 9.28; 25 Tests ergänzt (139 → 164). |
| 0.18 | 2026-07-19 | Claude (Opus 4.8) | **Live-Simulation** (Roadmap Nr. 22). Neuer Button und Dialog als Zweispalter: links Schieberegler, rechts eine wählbare Grafik, darüber eine Kennzahlenleiste. Dreht man an einem Regler, wird der Plan **sofort** neu gerechnet ohne für jede Variante eine Szenario-Kopie anzulegen. **Keine eigene Rechenlogik:** Die Regler benutzen dieselben Transformationen wie der Tornado (`applyDriver`), können also gar nicht etwas anderes zeigen als die Einflussfaktoren-Analyse. Neu ist nur `applyElementDriver` dieselbe Verschiebung auf ein **einzelnes** Element statt auf eine ganze Kategorie: Standardmässig gibt es einen Sammelregler «Rendite», ein Klick auf «Renditen einzeln aufschlüsseln» ersetzt ihn durch je einen Regler pro Anlage (der Sammelregler wird dabei **entfernt**, nicht ergänzt, sonst zählte eine Bewegung doppelt; ein Test sichert ab, dass beide Ansichten denselben Plan beschreiben). Der unveränderte Plan wird als **Referenzlinie** mitgezeichnet, und die Kennzahlenleiste weist Endvermögen nominal/real **mit Differenz zum Plan** aus sowie als eigene Karte ob das Kapital reicht; ein gekippter Plan ist einer Verlaufslinie sonst nicht anzusehen. Gemessene Laufzeit von `computePlan`: **0.2 ms** auf einem 60-Jahres-Plan mit 10 Elementen, also rund 1 % des 16-ms-Frame-Budgets deshalb wird synchron gerechnet, **ohne Debounce und ohne Worker**. Anders als der Tornado haben die Regler **Standardbereiche** (Begründung des scheinbaren Widerspruchs zu 9.18: neues Kapitel 9.27), beide Enden editierbar. **Das Pensionsalter fehlt weiterhin** (9.18, eigener Roadmap-Punkt); «Als Szenario speichern» ist bewusst zurückgestellt, ersatzweise zeigt der Dialog die aktive Einstellung als lesbare Zeile. Die Vermögensaufteilung wurde als `AllocationChart` aus dem Dashboard herausgelöst, damit beide sie nutzen. Neue Kapitel 4.15 und 9.27; 15 Tests ergänzt (124 → 139). Keine API-, DB- oder Schreib-Änderung das Feature liest ausschliesslich. Nebenbei dieselbe vom Umlaut-Sweep verstümmelte Hex-Farbe (`#7c3aed`) wie in 0.17, diesmal im Dashboard. | | 0.18 | 2026-07-19 | Claude (Opus 4.8) | **Live-Simulation** (Roadmap Nr. 22). Neuer Button und Dialog als Zweispalter: links Schieberegler, rechts eine wählbare Grafik, darüber eine Kennzahlenleiste. Dreht man an einem Regler, wird der Plan **sofort** neu gerechnet ohne für jede Variante eine Szenario-Kopie anzulegen. **Keine eigene Rechenlogik:** Die Regler benutzen dieselben Transformationen wie der Tornado (`applyDriver`), können also gar nicht etwas anderes zeigen als die Einflussfaktoren-Analyse. Neu ist nur `applyElementDriver` dieselbe Verschiebung auf ein **einzelnes** Element statt auf eine ganze Kategorie: Standardmässig gibt es einen Sammelregler «Rendite», ein Klick auf «Renditen einzeln aufschlüsseln» ersetzt ihn durch je einen Regler pro Anlage (der Sammelregler wird dabei **entfernt**, nicht ergänzt, sonst zählte eine Bewegung doppelt; ein Test sichert ab, dass beide Ansichten denselben Plan beschreiben). Der unveränderte Plan wird als **Referenzlinie** mitgezeichnet, und die Kennzahlenleiste weist Endvermögen nominal/real **mit Differenz zum Plan** aus sowie als eigene Karte ob das Kapital reicht; ein gekippter Plan ist einer Verlaufslinie sonst nicht anzusehen. Gemessene Laufzeit von `computePlan`: **0.2 ms** auf einem 60-Jahres-Plan mit 10 Elementen, also rund 1 % des 16-ms-Frame-Budgets deshalb wird synchron gerechnet, **ohne Debounce und ohne Worker**. Anders als der Tornado haben die Regler **Standardbereiche** (Begründung des scheinbaren Widerspruchs zu 9.18: neues Kapitel 9.27), beide Enden editierbar. **Das Pensionsalter fehlt weiterhin** (9.18, eigener Roadmap-Punkt); «Als Szenario speichern» ist bewusst zurückgestellt, ersatzweise zeigt der Dialog die aktive Einstellung als lesbare Zeile. Die Vermögensaufteilung wurde als `AllocationChart` aus dem Dashboard herausgelöst, damit beide sie nutzen. Neue Kapitel 4.15 und 9.27; 15 Tests ergänzt (124 → 139). Keine API-, DB- oder Schreib-Änderung das Feature liest ausschliesslich. Nebenbei dieselbe vom Umlaut-Sweep verstümmelte Hex-Farbe (`#7c3aed`) wie in 0.17, diesmal im Dashboard. |
| 0.17 | 2026-07-19 | Claude (Opus 4.8) | **Monte-Carlo: zwei Welten, vier Fälle.** Behebt einen Darstellungs-Widerspruch: Zuvor konnte «Planung 69 % erreicht» neben «Ziel 3 Mio nur 41 %» stehen, obwohl 3 Mio unter dem Plan-Endbetrag von 3.7 Mio lag die beiden Zahlen stammten aus **verschiedenen simulierten Welten**. Neu läuft die Simulation **immer zweimal** (historische Renditen / geplante Werte, gemeinsamer Seed) und liest aus **jeder** Verteilung **beide** Schwellen ab: Plan-Endbetrag und Zielbetrag. Fall 1 und Fall 3 stammen damit aus derselben Verteilung, wodurch ein tieferes Ziel **nie** unwahrscheinlicher sein kann als ein höheres der Widerspruch ist strukturell ausgeschlossen (Test). Zweite Korrektur: Der Nullpunkt für das Urteil ist **nicht 50 %**, sondern **Fall 2** (derselbe Schwellwert in der eigenen geplanten Welt); durch den Volatilitäts-Drag liegt der je nach Streuung bei 2748 %. Verglichen wird Fall 1 gegen Fall 2 mit ± 5 pp Toleranzband → «zurückhaltend / realistisch / zu optimistisch». Darstellung: Fall 1 prominent mit Urteil, Fall 3+4 als Satzpaar untergeordnet, Fall 2 und die Mediane klein als Referenz. Der Drei-Wege-Umschalter aus 0.16 entfällt; historische Mittelwerte **und** Zielbetrag sind jetzt beide Pflicht. Technisch: `MonteCarloResult.finalWealthSorted` (alle Endvermögen sortiert) plus neuer Helfer `probabilityAtLeast` (Binärsuche) vier Zahlen aus zwei Läufen statt vier Läufen. Kapitel 4.12.7 und 9.26 neu gefasst; 3 Tests ergänzt (121 → 124). Keine Änderung am Rechenkern. | | 0.17 | 2026-07-19 | Claude (Opus 4.8) | **Monte-Carlo: zwei Welten, vier Fälle.** Behebt einen Darstellungs-Widerspruch: Zuvor konnte «Planung 69 % erreicht» neben «Ziel 3 Mio nur 41 %» stehen, obwohl 3 Mio unter dem Plan-Endbetrag von 3.7 Mio lag die beiden Zahlen stammten aus **verschiedenen simulierten Welten**. Neu läuft die Simulation **immer zweimal** (historische Renditen / geplante Werte, gemeinsamer Seed) und liest aus **jeder** Verteilung **beide** Schwellen ab: Plan-Endbetrag und Zielbetrag. Fall 1 und Fall 3 stammen damit aus derselben Verteilung, wodurch ein tieferes Ziel **nie** unwahrscheinlicher sein kann als ein höheres der Widerspruch ist strukturell ausgeschlossen (Test). Zweite Korrektur: Der Nullpunkt für das Urteil ist **nicht 50 %**, sondern **Fall 2** (derselbe Schwellwert in der eigenen geplanten Welt); durch den Volatilitäts-Drag liegt der je nach Streuung bei 2748 %. Verglichen wird Fall 1 gegen Fall 2 mit ± 5 pp Toleranzband → «zurückhaltend / realistisch / zu optimistisch». Darstellung: Fall 1 prominent mit Urteil, Fall 3+4 als Satzpaar untergeordnet, Fall 2 und die Mediane klein als Referenz. Der Drei-Wege-Umschalter aus 0.16 entfällt; historische Mittelwerte **und** Zielbetrag sind jetzt beide Pflicht. Technisch: `MonteCarloResult.finalWealthSorted` (alle Endvermögen sortiert) plus neuer Helfer `probabilityAtLeast` (Binärsuche) vier Zahlen aus zwei Läufen statt vier Läufen. Kapitel 4.12.7 und 9.26 neu gefasst; 3 Tests ergänzt (121 → 124). Keine Änderung am Rechenkern. |
@@ -978,7 +979,7 @@ Jede Element-Zeile und jeder Phasenkopf trägt oben rechts ein **Expand-Icon** (
| Reiter | Inhalt | | Reiter | Inhalt |
|---|---| |---|---|
| Verlauf | Liniendiagramm über **alle Planjahre**, nicht nur die Phasengrenzen. Bei einer Immobilie drei Reihen (Eigenkapital, Verkehrswert, Restschuld). Darunter eine Tabelle Beginn/Ende je Lebensphase. | | Verlauf | Liniendiagramm über **alle Planjahre**, nicht nur die Phasengrenzen. Bei einer Immobilie drei Reihen (Eigenkapital, Verkehrswert, Restschuld). Zusätzlich die wirksame **Rate** auf einer zweiten Y-Achse rechts in Prozent als **Stufenlinie**, weil sie innerhalb einer Phase konstant ist und an der Phasengrenze springt; eine interpolierte Kurve würde einen gleitenden Übergang suggerieren, den die Berechnung nicht macht. Die Achse erscheint nur, wenn das Element überhaupt eine Rate trägt. Darunter eine Tabelle Beginn/Ende je Lebensphase. |
| Rechenweg | Die Herleitung je Phase und je Übergang (siehe [4.14](#414-verlaufswerte-brücken-und-rechenwege)) | | Rechenweg | Die Herleitung je Phase und je Übergang (siehe [4.14](#414-verlaufswerte-brücken-und-rechenwege)) |
**Lebensphase** drei Reiter: **Lebensphase** drei Reiter:
@@ -1081,6 +1082,59 @@ wenn Folgephasen existieren.
Referenz: `src/components/DistributionDialogs.tsx`, `src/lib/distribution.ts`. Referenz: `src/components/DistributionDialogs.tsx`, `src/lib/distribution.ts`.
### 3.6.11 Raten über Lebensphasen übernehmen
Werte liegen **je Lebensphase** vor. Bei Beträgen ist das richtig das Einkommen ändert sich,
der Vermögensstand ohnehin. Bei **Raten** ist es meist nicht gemeint: Wer die erwartete Rendite
seines ETF auf 5 % setzt, meint fast nie «nur in Phase 2». Ohne Übernahme muss derselbe Wert
vier- oder fünfmal eingetippt werden, und dabei wird zuverlässig eine Phase übersehen.
Ändert man ein Ratenfeld, erscheint deshalb im Bearbeitungspanel eine Rückfrage mit drei
Möglichkeiten:
| Auswahl | Wirkung |
|---|---|
| **Nur diese Phase** (Vorgabe) | bisheriges Verhalten, andere Phasen bleiben unberührt |
| **Diese + folgende** | ab der bearbeiteten Phase vorwärts Vergangenes bleibt stehen |
| **Alle Phasen** | der Wert gilt für den ganzen Plan |
Als **Ratenfelder** gelten:
| Feld | Kategorien |
|---|---|
| `expectedReturn` | Pensionskasse, Säule 3a, Sonstiges Vermögen |
| `valueGrowth` | Immobilie (Wertsteigerung) |
| `interestRate` | Immobilie (Hypothekarzins) |
| `teuerungsausgleich` | Einkommen (Lohnentwicklung), Ausgaben (reale Mehrausgaben) |
**AHV und Schulden haben keine**: Die AHV-Rente folgt der amtlichen Formel
([4.4](#44-ahv-rente)), Schulden tragen ihren Zins nicht als eigenes Feld.
**Vier Entwurfsentscheide:**
**Die Rückfrage erscheint beim Bearbeiten, nicht als Modal.** Das Zahlenfeld löst bei *jedem
Tastendruck* aus ein Dialog erschiene bei der Eingabe «5.2» viermal. Stattdessen taucht die
Auswahl inline unter den Feldern auf, sobald sich eine Rate tatsächlich vom gespeicherten Wert
unterscheidet, und wird beim **Speichern** ausgeführt.
**Nur veränderte Raten lösen sie aus.** Wer bloss einen Betrag anpasst, bekommt keine
Rückfrage. «Nicht gesetzt» und «0» gelten dabei als gleich sonst meldete schon das Öffnen
eines Panels mit leerem Feld eine Änderung.
**Die Zielphasen behalten ihre übrigen Werte.** Der Endpunkt ersetzt den *ganzen* Werte-Satz
einer Phase. Würde man den Entwurf der bearbeiteten Phase einfach hinüberkopieren, verlöre jede
andere Phase ihre Beträge, Sparraten und Bezüge. Übertragen wird deshalb ausschliesslich das
geänderte Ratenfeld, in die bestehenden Werte hineingemischt. Ein Test sichert genau das ab.
**Phasen, in denen der Wert schon stimmt, werden übersprungen** das spart Schreibvorgänge und
verhindert eine Version, obwohl sich inhaltlich nichts geändert hat.
Technisch ist die Übernahme ein Schreibvorgang **je Zielphase** über den bestehenden Endpunkt;
es gibt keinen neuen Schreibpfad. Alle fallen in dieselbe Bearbeitungssitzung und ergeben
deshalb **eine** Nebenversion, nicht eine je Phase ([3.8.1](#381-eine-version-je-sitzung--nicht-je-änderung)).
Referenz: `src/lib/ratefields.ts`, `src/components/ElementDetail.tsx`.
## 3.7 Bedienoberfläche ## 3.7 Bedienoberfläche
### 3.7.1 Layout ### 3.7.1 Layout
@@ -2546,6 +2600,7 @@ PlanComputed ← an den Client geliefert
| `constants.ts` | Schweizer Systemparameter | | `constants.ts` | Schweizer Systemparameter |
| `montecarlo.ts` | Monte-Carlo-Simulation (Sampler + Treiber), Szenario-Vergleich und Element-Gruppierung über die Herkunfts-Kette. Keine I/O, läuft im Browser. | | `montecarlo.ts` | Monte-Carlo-Simulation (Sampler + Treiber), Szenario-Vergleich und Element-Gruppierung über die Herkunfts-Kette. Keine I/O, läuft im Browser. |
| `sensitivity.ts` | Sensitivitätsanalyse / Tornado: Treiber-Katalog, Parameter-Transformationen, `computeTornado`; zusätzlich `applyElementDriver` / `tunableElements` für die einzeln regelbaren Element-Renditen. Rein, läuft im Browser. | | `sensitivity.ts` | Sensitivitätsanalyse / Tornado: Treiber-Katalog, Parameter-Transformationen, `computeTornado`; zusätzlich `applyElementDriver` / `tunableElements` für die einzeln regelbaren Element-Renditen. Rein, läuft im Browser. |
| `ratefields.ts` | Ratenfelder je Kategorie, Reichweite der Übernahme über Lebensphasen, Aufbau der Schreibvorgänge (Kap. 3.6.11). Rein. |
| `versioning.ts` | Versionierung: Nummerierung A.B, Zusammenfassung je Sitzung, Bauplan des Wiederherstellens, Auswirkung auf Kind-Szenarien. Rein, ohne I/O. | | `versioning.ts` | Versionierung: Nummerierung A.B, Zusammenfassung je Sitzung, Bauplan des Wiederherstellens, Auswirkung auf Kind-Szenarien. Rein, ohne I/O. |
| `versioning-db.ts` | Datenbank-Anbindung der Versionierung: Snapshot festhalten, Hauptversion, Wiederherstellen. Führt den Bauplan aus `versioning.ts` nur aus. | | `versioning-db.ts` | Datenbank-Anbindung der Versionierung: Snapshot festhalten, Hauptversion, Wiederherstellen. Führt den Bauplan aus `versioning.ts` nur aus. |
| `livesim.ts` | Live-Simulation: Reglerkatalog mit Standardbereichen, Anwenden mehrerer Regler, Kennzahlen (Kap. 4.15). Rein, läuft im Browser. | | `livesim.ts` | Live-Simulation: Reglerkatalog mit Standardbereichen, Anwenden mehrerer Regler, Kennzahlen (Kap. 4.15). Rein, läuft im Browser. |
@@ -3081,6 +3136,7 @@ Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-, API- oder E2E-Tests
| `explain.test.ts` | 12 | Verlaufswerte je Element, Vollständigkeit beider Wasserfall-Zerlegungen, Rechenweg-Protokoll, Gültigkeit der Spezifikations-Verweise | | `explain.test.ts` | 12 | Verlaufswerte je Element, Vollständigkeit beider Wasserfall-Zerlegungen, Rechenweg-Protokoll, Gültigkeit der Spezifikations-Verweise |
| `montecarlo.test.ts` | 18 | Determinismus, Volatilität/Vol-Drag, Böden, Reproduzierbarkeit; Element-Gruppierung, Szenario-Vergleich, Inflation je Szenario, plannedReturnOf; Schwellen-Ablesung (probabilityAtLeast), Monotonie über Schwellen, Fall 2 systematisch unter 50 % | | `montecarlo.test.ts` | 18 | Determinismus, Volatilität/Vol-Drag, Böden, Reproduzierbarkeit; Element-Gruppierung, Szenario-Vergleich, Inflation je Szenario, plannedReturnOf; Schwellen-Ablesung (probabilityAtLeast), Monotonie über Schwellen, Fall 2 systematisch unter 50 % |
| `distribution.test.ts` | 8 | Kapitaltopf und Quoten-Zerlegung gegen die Cash-Brücke; Entwurfswerte anwenden ohne Verlust bestehender Felder | | `distribution.test.ts` | 8 | Kapitaltopf und Quoten-Zerlegung gegen die Cash-Brücke; Entwurfswerte anwenden ohne Verlust bestehender Felder |
| `ratefields.test.ts` | 17 | Erkennung geänderter Raten, Reichweite (diese/folgende/alle), Erhalt der übrigen Werte in den Zielphasen |
| `versioning.test.ts` | 22 | Nummerierung und Zusammenfassung je Sitzung, unveränderte Stände, Benutzertrennung, Bauplan des Wiederherstellens, Auswirkung auf Kind-Szenarien | | `versioning.test.ts` | 22 | Nummerierung und Zusammenfassung je Sitzung, unveränderte Stände, Benutzertrennung, Bauplan des Wiederherstellens, Auswirkung auf Kind-Szenarien |
| `versioning-coverage.test.ts` | 3 | statischer Wächter: jeder schreibende Endpunkt löst eine Version aus | | `versioning-coverage.test.ts` | 3 | statischer Wächter: jeder schreibende Endpunkt löst eine Version aus |
| `livesim.test.ts` | 15 | Element-Regler bewegt genau ein Element; Aufschlüsselung deckt sich mit dem Sammelregler; neutrale Stellung verändert den Plan nicht; Ruinmeldung | | `livesim.test.ts` | 15 | Element-Regler bewegt genau ein Element; Aufschlüsselung deckt sich mit dem Sammelregler; neutrale Stellung verändert den Plan nicht; Ruinmeldung |
@@ -3088,7 +3144,7 @@ Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-, API- oder E2E-Tests
| `bridges.test.ts` | 10 | Vermögens- und Cash-Brücke gehen über sieben Plankonstellationen ohne Restgrösse auf; Umbuchungen bleiben aus der Vermögensbrücke heraus | | `bridges.test.ts` | 10 | Vermögens- und Cash-Brücke gehen über sieben Plankonstellationen ohne Restgrösse auf; Umbuchungen bleiben aus der Vermögensbrücke heraus |
| `diff.test.ts` | 9 | Abweichungs-Erkennung gegen das Eltern-Szenario | | `diff.test.ts` | 9 | Abweichungs-Erkennung gegen das Eltern-Szenario |
| `migrations.test.ts` | 1 | spielt alle Migrationen gegen echtes PostgreSQL (PGlite) ein | | `migrations.test.ts` | 1 | spielt alle Migrationen gegen echtes PostgreSQL (PGlite) ein |
| **Total** | **164** | | | **Total** | **181** | |
## 8.2 Testfälle ## 8.2 Testfälle
+49 -5
View File
@@ -396,6 +396,17 @@ export function ElementDetailDialog({
? "Eigenkapital" ? "Eigenkapital"
: "Wert"; : "Wert";
// Zweite Achse nur, wenn dieses Element überhaupt eine Rate trägt (AHV und Schulden nicht).
const rateLabel =
category === "REAL_ESTATE"
? "Wertsteigerung"
: isFlow
? category === "INCOME"
? "Lohnentwicklung"
: "Reale Mehrausgaben"
: "Erwartete Rendite";
const hasRate = points.some((p) => typeof p.rate === "number");
const verlauf = ( const verlauf = (
<> <>
{points.length === 0 ? ( {points.length === 0 ? (
@@ -406,16 +417,49 @@ export function ElementDetailDialog({
<LineChart data={points} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}> <LineChart data={points} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" /> <CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="age" type="number" domain={["dataMin", "dataMax"]} tick={{ fontSize: 11 }} tickFormatter={(v) => `${v} J.`} /> <XAxis dataKey="age" type="number" domain={["dataMin", "dataMax"]} tick={{ fontSize: 11 }} tickFormatter={(v) => `${v} J.`} />
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} /> <YAxis yAxisId="chf" tick={{ fontSize: 11 }} tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} />
<Tooltip formatter={(v, n) => [typeof v === "number" ? formatChf(v) : v, n]} labelFormatter={(v) => `Alter ${v}`} /> {hasRate && (
<YAxis
yAxisId="rate"
orientation="right"
tick={{ fontSize: 11 }}
tickFormatter={(v) => `${v} %`}
// Etwas Luft nach oben und unten, damit eine konstante Rate nicht als
// Linie direkt auf der Achse klebt.
domain={([min, max]: readonly [number, number]) => [Math.min(0, min - 1), max + 1]}
/>
)}
<Tooltip
formatter={(v, n) =>
typeof v !== "number" ? [v, n] : n === rateLabel ? [`${v} %`, n] : [formatChf(v), n]
}
labelFormatter={(v) => `Alter ${v}`}
/>
<Legend wrapperStyle={{ fontSize: 12 }} /> <Legend wrapperStyle={{ fontSize: 12 }} />
<Line dataKey="value" name={valueLabel} stroke="var(--accent)" strokeWidth={2} dot={false} isAnimationActive={false} /> <Line yAxisId="chf" dataKey="value" name={valueLabel} stroke="var(--accent)" strokeWidth={2} dot={false} isAnimationActive={false} />
{MULTI_SERIES.includes(category) && ( {MULTI_SERIES.includes(category) && (
<> <>
<Line dataKey="propertyValue" name="Verkehrswert" stroke="#0ea5e9" strokeWidth={1.5} dot={false} isAnimationActive={false} /> <Line yAxisId="chf" dataKey="propertyValue" name="Verkehrswert" stroke="#0ea5e9" strokeWidth={1.5} dot={false} isAnimationActive={false} />
<Line dataKey="mortgage" name="Restschuld" stroke="#dc2626" strokeWidth={1.5} strokeDasharray="5 3" dot={false} isAnimationActive={false} /> <Line yAxisId="chf" dataKey="mortgage" name="Restschuld" stroke="#dc2626" strokeWidth={1.5} strokeDasharray="5 3" dot={false} isAnimationActive={false} />
</> </>
)} )}
{hasRate && (
// Stufenlinie, nicht interpoliert: Die Rate ist innerhalb einer Phase
// konstant und springt an der Phasengrenze. Eine weiche Kurve würde einen
// gleitenden Übergang suggerieren, den die Berechnung nicht macht.
<Line
yAxisId="rate"
type="stepAfter"
dataKey="rate"
name={rateLabel}
stroke="#d97706"
strokeWidth={1.5}
strokeDasharray="4 2"
dot={false}
connectNulls
isAnimationActive={false}
/>
)}
</LineChart> </LineChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
+75 -1
View File
@@ -6,6 +6,14 @@ import { AlertTriangle } from "lucide-react";
import { FieldLabel, MoneyField, NumberField, SelectField, TextField } from "@/components/FormField"; import { FieldLabel, MoneyField, NumberField, SelectField, TextField } from "@/components/FormField";
import { formatChf } from "@/lib/format"; import { formatChf } from "@/lib/format";
import { api } from "@/lib/api-client"; import { api } from "@/lib/api-client";
import {
buildRateWrites,
changedRateFields,
scopeQuestion,
SCOPE_OPTIONS,
type PhaseRef,
type RateScope,
} from "@/lib/ratefields";
import { ahvAnnualPension, ahvMdje, type AhvCareer } from "@/lib/calculations"; import { ahvAnnualPension, ahvMdje, type AhvCareer } from "@/lib/calculations";
import { CATEGORY_LABELS, num } from "@/lib/elements"; import { CATEGORY_LABELS, num } from "@/lib/elements";
import { import {
@@ -49,6 +57,10 @@ interface Props {
context: CellContext; context: CellContext;
phaseData: PhaseData; phaseData: PhaseData;
transitionData: TransitionData; transitionData: TransitionData;
// Alle Phasen des Szenarios und die dort bereits erfassten Werte -- Grundlage für die
// Übernahme einer geänderten Rate auf weitere Phasen (siehe SPEZIFIKATION 3.6.11).
allPhases?: PhaseRef[];
phaseDataByPhase?: Record<string, PhaseData>;
onSaved: () => void; onSaved: () => void;
onDeleteElement: () => void; onDeleteElement: () => void;
} }
@@ -886,14 +898,29 @@ export function ElementTransitionFields({
} }
} }
export function ElementDetail({ element, context, phaseData, transitionData, onSaved, onDeleteElement }: Props) { export function ElementDetail({
element,
context,
phaseData,
transitionData,
allPhases = [],
phaseDataByPhase = {},
onSaved,
onDeleteElement,
}: Props) {
const [pd, setPd] = useState<PhaseData>({ ...phaseData }); const [pd, setPd] = useState<PhaseData>({ ...phaseData });
const [td, setTd] = useState<TransitionData>({ ...transitionData }); const [td, setTd] = useState<TransitionData>({ ...transitionData });
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Reichweite einer geänderten Rate. Vorgabe ist das bisherige Verhalten: nur diese Phase.
const [scope, setScope] = useState<RateScope>("THIS");
const isTransition = context.kind === "transition"; const isTransition = context.kind === "transition";
// Welche Ratenfelder hat der Nutzer verändert? Nur dann wird überhaupt gefragt.
const changedRates = isTransition ? [] : changedRateFields(element.category, phaseData, pd);
const showScope = changedRates.length > 0 && allPhases.length > 1;
async function save() { async function save() {
setSaving(true); setSaving(true);
setError(null); setError(null);
@@ -914,6 +941,21 @@ export function ElementDetail({ element, context, phaseData, transitionData, onS
delete payload.amount; delete payload.amount;
} }
await api.put(`/api/elements/${element.id}/phase/${context.phaseId}`, payload); await api.put(`/api/elements/${element.id}/phase/${context.phaseId}`, payload);
// Geänderte Raten auf die gewählten weiteren Phasen übertragen. Die bestehenden
// Werte der Zielphasen bleiben erhalten -- übernommen wird ausschliesslich die Rate.
// Alle Schreibvorgänge fallen in dieselbe Bearbeitungssitzung und ergeben daher
// EINE Nebenversion, nicht eine je Phase (siehe 3.8.1).
for (const w of buildRateWrites(
allPhases,
context.phaseId,
scope,
changedRates,
pd,
phaseDataByPhase
)) {
await api.put(`/api/elements/${element.id}/phase/${w.phaseId}`, w.data);
}
} }
onSaved(); onSaved();
} catch (e) { } catch (e) {
@@ -959,6 +1001,38 @@ export function ElementDetail({ element, context, phaseData, transitionData, onS
)} )}
</div> </div>
{/* Rückfrage zur Reichweite. Erscheint erst, wenn eine Rate tatsächlich verändert
wurde -- und bewusst hier statt als Modal beim Tippen: Das Zahlenfeld löst bei
jedem Tastendruck aus, ein Dialog erschiene bei «5.2» viermal. */}
{showScope && (
<div className="rounded-xl border border-attention bg-attention-soft p-3">
<p className="mb-2 text-xs text-attention-soft-fg">
{scopeQuestion(changedRates, allPhases.length)}
</p>
<div className="inline-flex flex-wrap gap-0.5 rounded-lg border border-border bg-surface p-0.5 text-xs">
{SCOPE_OPTIONS.map((o) => (
<button
key={o.value}
type="button"
onClick={() => setScope(o.value)}
className={`rounded-md px-2.5 py-1 font-medium transition-colors ${
scope === o.value ? "bg-accent text-accent-fg" : "text-muted hover:text-fg"
}`}
>
{o.label}
</button>
))}
</div>
<p className="mt-1.5 text-[11px] text-attention-soft-fg/80">
{scope === "THIS"
? "Andere Phasen bleiben unverändert."
: `Wird beim Speichern in ${
buildRateWrites(allPhases, context.phaseId, scope, changedRates, pd, phaseDataByPhase).length
} weitere Phase(n) übertragen. Beträge und Raten dort bleiben erhalten nur der geänderte Wert wird gesetzt.`}
</p>
</div>
)}
{error && <p className="text-sm text-danger">{error}</p>} {error && <p className="text-sm text-danger">{error}</p>}
<div> <div>
<button <button
+4
View File
@@ -863,6 +863,10 @@ export function PlanView({
element={element} element={element}
context={buildPhaseContext(phase, element)} context={buildPhaseContext(phase, element)}
phaseData={element.phaseValues[phase.id] ?? {}} phaseData={element.phaseValues[phase.id] ?? {}}
// Für die Übernahme von Raten über Phasen hinweg: alle Phasen und die dort
// bereits erfassten Werte (sie müssen beim Übertragen erhalten bleiben).
allPhases={plan.phases.map((p) => ({ id: p.id, sequenceNumber: p.sequenceNumber }))}
phaseDataByPhase={element.phaseValues}
transitionData={{}} transitionData={{}}
onSaved={closeAndRefresh} onSaved={closeAndRefresh}
onDeleteElement={() => { onDeleteElement={() => {
+14 -2
View File
@@ -62,6 +62,11 @@ export interface ElementYearPoint {
value: number; // Haupt-Kennzahl (Saldo, Eigenkapital, Flow, Rente) value: number; // Haupt-Kennzahl (Saldo, Eigenkapital, Flow, Rente)
propertyValue?: number; // nur REAL_ESTATE: Verkehrswert der Liegenschaft propertyValue?: number; // nur REAL_ESTATE: Verkehrswert der Liegenschaft
mortgage?: number; // nur REAL_ESTATE: Restschuld mortgage?: number; // nur REAL_ESTATE: Restschuld
// Die in DIESEM Jahr wirksame Rate in Prozent -- erwartete Rendite (PK/3a/Vermögen),
// Wertsteigerung (Immobilie) bzw. jährliche Anpassung (Einkommen/Ausgaben). Rein für die
// Darstellung; die Rechnung benutzt denselben Wert, der hier nur mitgeführt wird.
// Innerhalb einer Phase konstant, an der Phasengrenze springt sie.
rate?: number;
} }
export interface ElementPhaseComputed { export interface ElementPhaseComputed {
@@ -794,16 +799,22 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
const age = personA.age + yearsBefore + t; const age = personA.age + yearsBefore + t;
const yr = yearsBefore + t; const yr = yearsBefore + t;
for (const inc of incomes) { for (const inc of incomes) {
inc.ec.yearly.push({ year: yr, age, value: Math.round(inc.basis * Math.pow(1 + inc.idx / 100, t - 1)) }); inc.ec.yearly.push({
year: yr,
age,
value: Math.round(inc.basis * Math.pow(1 + inc.idx / 100, t - 1)),
rate: inc.idx,
});
} }
for (const exp of expenses) { for (const exp of expenses) {
exp.ec.yearly.push({ exp.ec.yearly.push({
year: yr, year: yr,
age, age,
value: Math.round(exp.basis * Math.pow(1 + exp.idx / 100, t - 1) * inflFactor), value: Math.round(exp.basis * Math.pow(1 + exp.idx / 100, t - 1) * inflFactor),
rate: exp.idx,
}); });
} }
for (const a of assets) a.ec.yearly.push({ year: yr, age, value: Math.round(a.value) }); for (const a of assets) a.ec.yearly.push({ year: yr, age, value: Math.round(a.value), rate: a.r });
for (const re of realEstates) { for (const re of realEstates) {
re.ec.yearly.push({ re.ec.yearly.push({
year: yr, year: yr,
@@ -811,6 +822,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
value: Math.round(re.value - re.mortgage), // Eigenkapital value: Math.round(re.value - re.mortgage), // Eigenkapital
propertyValue: Math.round(re.value), propertyValue: Math.round(re.value),
mortgage: Math.round(re.mortgage), mortgage: Math.round(re.mortgage),
rate: re.growth,
}); });
} }
for (const d of debts) d.ec.yearly.push({ year: yr, age, value: -Math.round(d.owed) }); for (const d of debts) d.ec.yearly.push({ year: yr, age, value: -Math.round(d.owed) });
+154
View File
@@ -0,0 +1,154 @@
import { describe, it, expect } from "vitest";
import {
buildRateWrites,
changedRateFields,
rateFieldsOf,
targetPhases,
type PhaseRef,
} from "@/lib/ratefields";
import type { PhaseData } from "@/lib/elements";
const PHASES: PhaseRef[] = [
{ id: "p1", sequenceNumber: 1 },
{ id: "p2", sequenceNumber: 2 },
{ id: "p3", sequenceNumber: 3 },
];
describe("rateFieldsOf", () => {
it("kennt die Ratenfelder je Kategorie", () => {
expect(rateFieldsOf("OTHER_ASSET")).toEqual(["expectedReturn"]);
expect(rateFieldsOf("REAL_ESTATE")).toEqual(["valueGrowth", "interestRate"]);
expect(rateFieldsOf("INCOME")).toEqual(["teuerungsausgleich"]);
});
it("hat für AHV und Schulden keine dort gibt es keine frei wählbare Rate", () => {
expect(rateFieldsOf("AHV")).toEqual([]);
expect(rateFieldsOf("OTHER_DEBT")).toEqual([]);
});
});
describe("targetPhases", () => {
it("«nur diese» trifft genau die bearbeitete Phase", () => {
expect(targetPhases(PHASES, "p2", "THIS").map((p) => p.id)).toEqual(["p2"]);
});
it("«diese + folgende» lässt frühere Phasen unangetastet", () => {
// Der Punkt der Option: Vergangenes soll nicht rückwirkend umgeschrieben werden.
expect(targetPhases(PHASES, "p2", "FOLLOWING").map((p) => p.id)).toEqual(["p2", "p3"]);
});
it("«alle» trifft alle, aufsteigend sortiert", () => {
const unsorted: PhaseRef[] = [
{ id: "p3", sequenceNumber: 3 },
{ id: "p1", sequenceNumber: 1 },
{ id: "p2", sequenceNumber: 2 },
];
expect(targetPhases(unsorted, "p2", "ALL").map((p) => p.id)).toEqual(["p1", "p2", "p3"]);
});
it("liefert nichts, wenn die bearbeitete Phase unbekannt ist", () => {
expect(targetPhases(PHASES, "weg", "ALL")).toEqual([]);
});
});
describe("changedRateFields", () => {
it("meldet nur tatsächlich veränderte Raten", () => {
const changed = changedRateFields("OTHER_ASSET", { expectedReturn: 4, startValue: 1000 }, { expectedReturn: 5, startValue: 1000 });
expect(changed).toEqual(["expectedReturn"]);
});
it("meldet nichts, wenn nur ein Betrag geändert wurde", () => {
// Sonst käme die Rückfrage bei jeder Betragsänderung reiner Lärm.
const changed = changedRateFields("OTHER_ASSET", { expectedReturn: 4, startValue: 1000 }, { expectedReturn: 4, startValue: 2000 });
expect(changed).toEqual([]);
});
it("behandelt «nicht gesetzt» und 0 als gleich", () => {
// Ein leeres Feld wird im Formular auf 0 vorbelegt. Ohne diese Gleichsetzung würde das
// blosse Öffnen eines Dialogs eine Änderung melden.
expect(changedRateFields("OTHER_ASSET", {}, { expectedReturn: 0 })).toEqual([]);
expect(changedRateFields("OTHER_ASSET", {}, { expectedReturn: 3 })).toEqual(["expectedReturn"]);
});
it("erfasst bei Immobilien beide Raten unabhängig", () => {
const changed = changedRateFields(
"REAL_ESTATE",
{ valueGrowth: 1, interestRate: 2 },
{ valueGrowth: 1, interestRate: 2.5 }
);
expect(changed).toEqual(["interestRate"]);
});
});
describe("buildRateWrites", () => {
const existing: Record<string, PhaseData> = {
p1: { expectedReturn: 4, startValue: 200000, annualContribution: 6000 },
p2: { expectedReturn: 4, annualContribution: 6000 },
p3: { expectedReturn: 4, annualWithdrawal: 30000 },
};
it("schreibt bei «nur diese Phase» gar nichts zusätzlich", () => {
expect(buildRateWrites(PHASES, "p2", "THIS", ["expectedReturn"], { expectedReturn: 5 }, existing)).toEqual([]);
});
it("lässt die bearbeitete Phase aus die speichert der Dialog selbst", () => {
const writes = buildRateWrites(PHASES, "p2", "ALL", ["expectedReturn"], { expectedReturn: 5 }, existing);
expect(writes.map((w) => w.phaseId)).toEqual(["p1", "p3"]);
});
it("ERHÄLT die übrigen Werte der Zielphase", () => {
// Der wichtigste Test: Der Endpunkt ersetzt den ganzen Werte-Satz einer Phase. Würde man
// den Entwurf kopieren statt zu mischen, verlöre p3 seine Bezugsrate und p1 seinen
// Startwert -- ein stiller Datenverlust.
const writes = buildRateWrites(PHASES, "p2", "ALL", ["expectedReturn"], { expectedReturn: 5, annualContribution: 9999 }, existing);
const p1 = writes.find((w) => w.phaseId === "p1")!;
expect(p1.data).toEqual({ expectedReturn: 5, startValue: 200000, annualContribution: 6000 });
const p3 = writes.find((w) => w.phaseId === "p3")!;
expect(p3.data).toEqual({ expectedReturn: 5, annualWithdrawal: 30000 });
// Der Beitrag aus dem Entwurf darf NICHT mitwandern -- nur die Rate war gemeint.
expect(p3.data.annualContribution).toBeUndefined();
});
it("überspringt Phasen, in denen der Wert schon stimmt", () => {
// Spart Schreibvorgänge und verhindert eine Version ohne inhaltliche Änderung.
const alreadyRight: Record<string, PhaseData> = {
p1: { expectedReturn: 5 },
p2: { expectedReturn: 4 },
p3: { expectedReturn: 4 },
};
const writes = buildRateWrites(PHASES, "p2", "ALL", ["expectedReturn"], { expectedReturn: 5 }, alreadyRight);
expect(writes.map((w) => w.phaseId)).toEqual(["p3"]);
});
it("schreibt bei «folgende» nicht in frühere Phasen", () => {
const writes = buildRateWrites(PHASES, "p2", "FOLLOWING", ["expectedReturn"], { expectedReturn: 5 }, existing);
expect(writes.map((w) => w.phaseId)).toEqual(["p3"]);
});
it("überträgt mehrere Raten gleichzeitig", () => {
const re: Record<string, PhaseData> = {
p1: { valueGrowth: 1, interestRate: 2, purchasePrice: 800000 },
p2: { valueGrowth: 1, interestRate: 2 },
p3: { valueGrowth: 1, interestRate: 2 },
};
const writes = buildRateWrites(
PHASES,
"p1",
"ALL",
["valueGrowth", "interestRate"],
{ valueGrowth: 2, interestRate: 3 },
re
);
expect(writes).toHaveLength(2);
expect(writes[0].data.valueGrowth).toBe(2);
expect(writes[0].data.interestRate).toBe(3);
});
it("legt Werte auch in Phasen an, die bisher keinen Satz hatten", () => {
const writes = buildRateWrites(PHASES, "p1", "ALL", ["expectedReturn"], { expectedReturn: 5 }, { p1: {} });
expect(writes.map((w) => w.phaseId)).toEqual(["p2", "p3"]);
expect(writes[0].data).toEqual({ expectedReturn: 5 });
});
});
+127
View File
@@ -0,0 +1,127 @@
// Ratenfelder und ihre Übernahme über Lebensphasen hinweg.
//
// Hintergrund: Werte liegen je Lebensphase vor. Bei Beträgen ist das richtig (das Einkommen
// ändert sich), bei RATEN meist nicht: Wer die erwartete Rendite seines ETF auf 5 % setzt,
// meint fast nie "nur in Phase 2". Ohne Übernahme muss man denselben Wert vier- oder fünfmal
// eintippen -- und übersieht dabei zuverlässig eine Phase.
//
// Reines Modul ohne I/O: Es entscheidet nur, WAS wohin geschrieben wird. Die Schreibvorgänge
// selbst löst der Dialog aus (ein PUT je Zielphase, über die bestehenden Endpunkte).
import type { ElementCategory, PhaseData } from "@/lib/elements";
// Ein Feld, das eine Rate beschreibt -- im Gegensatz zu einem Betrag.
export type RateField = "expectedReturn" | "valueGrowth" | "interestRate" | "teuerungsausgleich";
// Welche Ratenfelder hat eine Kategorie? Nur diese lösen die Rückfrage aus.
export const RATE_FIELDS_BY_CATEGORY: Record<ElementCategory, RateField[]> = {
PENSION_FUND: ["expectedReturn"],
PILLAR_3A: ["expectedReturn"],
OTHER_ASSET: ["expectedReturn"],
REAL_ESTATE: ["valueGrowth", "interestRate"],
INCOME: ["teuerungsausgleich"],
EXPENSE: ["teuerungsausgleich"],
AHV: [], // die Rente folgt der amtlichen Formel, es gibt keine frei wählbare Rate
OTHER_DEBT: [], // Schulden tragen ihren Zins nicht als eigenes Feld
};
export const RATE_FIELD_LABELS: Record<RateField, string> = {
expectedReturn: "Erwartete Rendite",
valueGrowth: "Wertsteigerung",
interestRate: "Hypothekarzins",
teuerungsausgleich: "Jährliche Anpassung",
};
export function rateFieldsOf(category: ElementCategory): RateField[] {
return RATE_FIELDS_BY_CATEGORY[category] ?? [];
}
// Reichweite der Übernahme. Vorgabe ist bewusst THIS -- das ist das bisherige Verhalten,
// und eine Rückfrage darf nie stillschweigend mehr verändern als bisher.
export type RateScope = "THIS" | "FOLLOWING" | "ALL";
export const SCOPE_OPTIONS: { value: RateScope; label: string }[] = [
{ value: "THIS", label: "Nur diese Phase" },
{ value: "FOLLOWING", label: "Diese + folgende" },
{ value: "ALL", label: "Alle Phasen" },
];
export interface PhaseRef {
id: string;
sequenceNumber: number;
}
// Welche Phasen sind Ziel der Übernahme? Die bearbeitete Phase ist immer dabei -- sie wird
// ohnehin gespeichert.
export function targetPhases(phases: PhaseRef[], currentPhaseId: string, scope: RateScope): PhaseRef[] {
const current = phases.find((p) => p.id === currentPhaseId);
if (!current) return [];
const sorted = [...phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber);
switch (scope) {
case "THIS":
return [current];
case "FOLLOWING":
return sorted.filter((p) => p.sequenceNumber >= current.sequenceNumber);
case "ALL":
return sorted;
}
}
// Welche Ratenfelder hat der Nutzer tatsächlich verändert? Nur dafür wird gefragt -- eine
// Rückfrage zu einem unveränderten Feld wäre Lärm.
export function changedRateFields(
category: ElementCategory,
original: PhaseData,
draft: PhaseData
): RateField[] {
return rateFieldsOf(category).filter((f) => {
const a = original[f];
const b = draft[f];
// Nicht gesetzt und 0 sind für eine Rate dasselbe -- sonst meldet das Öffnen eines
// Dialogs, in dem ein leeres Feld auf 0 vorbelegt wird, fälschlich eine Änderung.
return (a ?? 0) !== (b ?? 0);
});
}
export interface RateWrite {
phaseId: string;
data: PhaseData;
}
// Baut die Schreibvorgänge für die ÜBRIGEN Phasen (die bearbeitete speichert der Dialog
// selbst). Die bestehenden Werte der Zielphase bleiben erhalten -- übernommen werden
// ausschliesslich die veränderten Ratenfelder.
//
// Das ist wesentlich: Der Endpunkt ersetzt den ganzen Werte-Satz einer Phase. Würde man hier
// den Entwurf der bearbeiteten Phase einfach kopieren, überschriebe man in allen anderen
// Phasen auch Beträge, Sparraten und Bezüge.
export function buildRateWrites(
phases: PhaseRef[],
currentPhaseId: string,
scope: RateScope,
fields: RateField[],
draft: PhaseData,
existingByPhase: Record<string, PhaseData>
): RateWrite[] {
if (scope === "THIS" || fields.length === 0) return [];
const patch: Partial<PhaseData> = {};
for (const f of fields) patch[f] = draft[f] ?? 0;
return targetPhases(phases, currentPhaseId, scope)
.filter((p) => p.id !== currentPhaseId)
.map((p) => ({ phaseId: p.id, data: { ...(existingByPhase[p.id] ?? {}), ...patch } }))
// Phasen, in denen sich nichts ändert, gar nicht erst anfassen: spart Schreibvorgänge
// und verhindert, dass eine Version entsteht, obwohl inhaltlich nichts passiert ist.
.filter((w) => fields.some((f) => (existingByPhase[w.phaseId]?.[f] ?? 0) !== (draft[f] ?? 0)));
}
// Text für die Rückfrage im Dialog.
export function scopeQuestion(fields: RateField[], phaseCount: number): string {
const names = fields.map((f) => RATE_FIELD_LABELS[f].toLowerCase());
const list =
names.length === 1
? names[0]
: `${names.slice(0, -1).join(", ")} und ${names[names.length - 1]}`;
return `Du hast ${names.length === 1 ? "die" : "die"} ${list} geändert. Für welche der ${phaseCount} Lebensphasen soll der Wert gelten?`;
}