Assistent: Ueberblicksschritt Pensionierung mit gerechneter Vorschau
Deploy App / deploy (push) Successful in 1m6s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 14:19:31 +02:00
parent 0f656f4070
commit cc61a120ee
2 changed files with 287 additions and 17 deletions
+19 -4
View File
@@ -17,7 +17,7 @@
| Version | Datum | Autor | Änderung | | Version | Datum | Autor | Änderung |
|---|---|---|---| |---|---|---|---|
| 0.35 | 2026-07-26 | Claude (Opus 5) | **Die Pensionierung ist eine Eigenschaft der PERSON, nicht der Zeitachse** (neues Kapitel 3.13). Der grösste Eingriff seit V7. Bisher hing jeder Bezugs-Entscheid an `transitionValues[phaseId]` am Schlüssel Element × Phasen-ID. Daraus folgte fast alles, was an der Pensionsplanung störte: Entscheide, die inhaltlich **eine** Frage sind, lagen in drei weit auseinander liegenden Matrix-Zellen; das Alter zu ändern war ein struktureller Eingriff, bei dem Entscheide über `mergeTransition` verlustbehaftet von Grenze zu Grenze gerettet werden mussten; ein Szenario nur für ein anderes Pensionsalter hiess, alles neu zu entscheiden; und der Ziel-Solver (Roadmap Nr. 21) hätte nichts zum Anfassen gehabt. **Neu liegt der Entscheid am ELEMENT** (`FinancialElement.retirementDecision`, ohne Phasenbezug) und überlebt damit jede Verschiebung der Zeitachse. (1) **Neuer Pensionierungs-Bildschirm** gleichrangig neben der Matrix, mit der **Rentenlücke** als Leitzahl keine neue Rechnung, sondern die Verzehrquote im ersten voll pensionierten Jahr; sie fehlte bisher nur als Begriff. Gerechnet im Rechenkern (`PlanComputed.retirement`), damit Bildschirm und PDF-Bericht nicht auseinanderlaufen. Die Matrix-Zellen am Pensions-Übergang bleiben bedienbar und nutzen **dieselbe Komponente** (`RetirementFields`) zwei Ansichten auf ein Objekt, kein Duplikat. (2) **AHV-Vorbezug und -Aufschub** werden gerechnet (Kap. 4.4.7 neu geschrieben): Kürzung 6,8 %/Jahr, Zuschlag +5,2/10,8/17,1/24,0/31,5 % nach 15 Jahren, Teilbezug 2080 %. Bis 0.34 startete die Rente **immer** mit 65 wer mit 62 aufhörte, bekam die ungekürzte Rente drei Jahre später, wer bis 68 arbeitete, verschenkte den Zuschlag. Dabei wurde eine fachliche Trennung eingeführt, die es vorher gar nicht gab: **Rentenbeginn und Beitragspflicht sind zwei verschiedene Alter.** Wer mit 62 aufhört und ab 63 vorbezieht, bezieht ab 63 **und** zahlt bis 65 weiter als Nichterwerbstätige(r). (3) **Pensionskasse: ein Regler statt eines Modus.** `payoutMode` (`PENSION`/`CAPITAL`/`COMBI`) und der absolute `capitalAmount` entfallen zugunsten von `capitalSharePct` (0100 %). Als Quote, weil sich das Guthaben mit dem Pensionsalter ändert ein fixer Betrag bedeutete beim Verschieben still ein anderes Verhältnis. (4) **Säule 3a: wählbares Bezugsalter** (6070) statt starr am Pensions-Übergang. Ein Konto lässt sich nur ganz auflösen, und alle Bezüge desselben Jahres werden steuerlich zusammengezählt gestaffelt wird deshalb über Konten und Jahre. Gezogen wird an der ersten Phasengrenze bei oder nach dem Wunschalter. (5) **Planungshorizont** (`Person.planningHorizonAge`): Bisher ergab sich das Planende stillschweigend aus der Summe der Phasendauern zwei Szenarien konnten unbemerkt verschieden weit rechnen und waren nicht vergleichbar. Neue Funktion `planHorizonChange`, neuer Endpunkt `POST /api/scenarios/<id>/horizon`. (6) **Ampel mit drei Zuständen** (Kap. 3.5.3 neu): `unbeantwortet` · `auf Vorgabe` · `bestätigt`. Mit durchgängigen Vorgaben bewusst, damit niemand am Anfang Fragen beantworten muss, die er erst am Ende beantworten kann entstand ein Zustand, den das Modell nicht kannte: Das System **hat** eine Antwort, nur nicht die des Benutzers. Eine Vorgabe wie «volle Rente statt Kapitalbezug» als beantwortet zu zählen hiesse, sie unbemerkt durchgehen zu lassen. Sie zählt deshalb mit, aber getrennt benannt: «2 offene Entscheide · 3 Vorgaben ungeprüft», bestätigt wird je Säule. (7) **Drei neue Treiber** in Tornado und Live-Simulation: PK-Kapitalanteil, AHV-Vorbezug/Aufschub (in Monaten, neue Einheit `delta_months`) und das bestehende Pensionsalter wird endlich **korrekt**, weil der AHV-Beginn jetzt mitzieht. (8) Nebenbei zwei Vereinfachungen: Die Beitragskarriere vor Planbeginn lag an **zwei** Orten (Übergangszelle bzw. Phasenzelle für bereits Pensionierte) mit zwei Codepfaden jetzt an einem. Und Szenario-Kopie, Versionierung und Diff tragen den Entscheid mit; ohne das wäre die Kopie genau für den Zweck unbrauchbar, für den man sie am häufigsten anlegt. **Keine Datenmigration** (Testdaten wurden vorgängig gelöscht); alte Werte in `transitionValues` werden ignoriert, betroffene Elemente erscheinen als «Vorgabe ungeprüft». Neues Modul `retirement-decision.ts`, neue Komponenten `RetirementPanel` und `RetirementFields`. 23 Tests ergänzt (292 → 315). | | 0.35 | 2026-07-26 | Claude (Opus 5) | **Die Pensionierung ist eine Eigenschaft der PERSON, nicht der Zeitachse** (neues Kapitel 3.13). Der grösste Eingriff seit V7. Bisher hing jeder Bezugs-Entscheid an `transitionValues[phaseId]` am Schlüssel Element × Phasen-ID. Daraus folgte fast alles, was an der Pensionsplanung störte: Entscheide, die inhaltlich **eine** Frage sind, lagen in drei weit auseinander liegenden Matrix-Zellen; das Alter zu ändern war ein struktureller Eingriff, bei dem Entscheide über `mergeTransition` verlustbehaftet von Grenze zu Grenze gerettet werden mussten; ein Szenario nur für ein anderes Pensionsalter hiess, alles neu zu entscheiden; und der Ziel-Solver (Roadmap Nr. 21) hätte nichts zum Anfassen gehabt. **Neu liegt der Entscheid am ELEMENT** (`FinancialElement.retirementDecision`, ohne Phasenbezug) und überlebt damit jede Verschiebung der Zeitachse. (1) **Neuer Pensionierungs-Bildschirm** gleichrangig neben der Matrix, mit der **Rentenlücke** als Leitzahl keine neue Rechnung, sondern die Verzehrquote im ersten voll pensionierten Jahr; sie fehlte bisher nur als Begriff. Gerechnet im Rechenkern (`PlanComputed.retirement`), damit Bildschirm und PDF-Bericht nicht auseinanderlaufen. Die Matrix-Zellen am Pensions-Übergang bleiben bedienbar und nutzen **dieselbe Komponente** (`RetirementFields`) zwei Ansichten auf ein Objekt, kein Duplikat. (2) **AHV-Vorbezug und -Aufschub** werden gerechnet (Kap. 4.4.7 neu geschrieben): Kürzung 6,8 %/Jahr, Zuschlag +5,2/10,8/17,1/24,0/31,5 % nach 15 Jahren, Teilbezug 2080 %. Bis 0.34 startete die Rente **immer** mit 65 wer mit 62 aufhörte, bekam die ungekürzte Rente drei Jahre später, wer bis 68 arbeitete, verschenkte den Zuschlag. Dabei wurde eine fachliche Trennung eingeführt, die es vorher gar nicht gab: **Rentenbeginn und Beitragspflicht sind zwei verschiedene Alter.** Wer mit 62 aufhört und ab 63 vorbezieht, bezieht ab 63 **und** zahlt bis 65 weiter als Nichterwerbstätige(r). (3) **Pensionskasse: ein Regler statt eines Modus.** `payoutMode` (`PENSION`/`CAPITAL`/`COMBI`) und der absolute `capitalAmount` entfallen zugunsten von `capitalSharePct` (0100 %). Als Quote, weil sich das Guthaben mit dem Pensionsalter ändert ein fixer Betrag bedeutete beim Verschieben still ein anderes Verhältnis. (4) **Säule 3a: wählbares Bezugsalter** (6070) statt starr am Pensions-Übergang. Ein Konto lässt sich nur ganz auflösen, und alle Bezüge desselben Jahres werden steuerlich zusammengezählt gestaffelt wird deshalb über Konten und Jahre. Gezogen wird an der ersten Phasengrenze bei oder nach dem Wunschalter. (5) **Planungshorizont** (`Person.planningHorizonAge`): Bisher ergab sich das Planende stillschweigend aus der Summe der Phasendauern zwei Szenarien konnten unbemerkt verschieden weit rechnen und waren nicht vergleichbar. Neue Funktion `planHorizonChange`, neuer Endpunkt `POST /api/scenarios/<id>/horizon`. (6) **Ampel mit drei Zuständen** (Kap. 3.5.3 neu): `unbeantwortet` · `auf Vorgabe` · `bestätigt`. Mit durchgängigen Vorgaben bewusst, damit niemand am Anfang Fragen beantworten muss, die er erst am Ende beantworten kann entstand ein Zustand, den das Modell nicht kannte: Das System **hat** eine Antwort, nur nicht die des Benutzers. Eine Vorgabe wie «volle Rente statt Kapitalbezug» als beantwortet zu zählen hiesse, sie unbemerkt durchgehen zu lassen. Sie zählt deshalb mit, aber getrennt benannt: «2 offene Entscheide · 3 Vorgaben ungeprüft», bestätigt wird je Säule. (7) **Drei neue Treiber** in Tornado und Live-Simulation: PK-Kapitalanteil, AHV-Vorbezug/Aufschub (in Monaten, neue Einheit `delta_months`) und das bestehende Pensionsalter wird endlich **korrekt**, weil der AHV-Beginn jetzt mitzieht. (8) Nebenbei zwei Vereinfachungen: Die Beitragskarriere vor Planbeginn lag an **zwei** Orten (Übergangszelle bzw. Phasenzelle für bereits Pensionierte) mit zwei Codepfaden jetzt an einem. Und Szenario-Kopie, Versionierung und Diff tragen den Entscheid mit; ohne das wäre die Kopie genau für den Zweck unbrauchbar, für den man sie am häufigsten anlegt. **Keine Datenmigration** (Testdaten wurden vorgängig gelöscht); alte Werte in `transitionValues` werden ignoriert, betroffene Elemente erscheinen als «Vorgabe ungeprüft». (9) **Assistent:** neuer Überblicksschritt «Deine Pensionierung» (Kap. 3.2.8) er ZEIGT Rentenlücke und Reichweite, statt Fragen zu stellen, die zu diesem Zeitpunkt niemand beantworten kann. Die Vorschau wird gerechnet, bevor der Plan existiert; Vorschau und Anlage speisen sich aus EINER Element-Liste, damit sie nicht auseinanderlaufen. Neues Modul `retirement-decision.ts`, neue Komponenten `RetirementPanel` und `RetirementFields`. 23 Tests ergänzt (292 → 315). |
| 0.34 | 2026-07-25 | Claude (Opus 5) | **Modul-Review 4, Nachbesserungen: die Übergangs-Entscheide bis ans Ende durchgezogen.** (1) **Zuordnung überall dort, wo Elemente über ihren Namen angeboten werden.** Zwei Personen nennen ihre Guthaben typischerweise gleich («Säule 3a», «ETF»); ohne die Person wählt man im Dropdown blind. Betroffen waren das **Ziel der Anlage-Quote** beim Kapitalbezug (dort mit hoher Folgewirkung: Ein Fehlgriff leitet das Alterskapital in das Depot der falschen Person) und die Zeilen im Dialog **«Kapital verteilen»**. Die Klartext-Zuordnung liegt neu als `ownerLabel` in `src/lib/elements.ts` und wird von allen drei Stellen genutzt. (2) **Herkunft des umgeleiteten Alterskapitals wird ausgewiesen.** Fliessen PK **und** 3a in dasselbe Vermögens-Element, stand dort bisher nur eine Summe ob wirklich beide angekommen sind, liess sich nicht prüfen. `Carry` und `ElementPhaseComputed` führen neu `capitalInSources` bzw. `capitalFromTransferSources` mit: Betrag **je Quelle**, benannt mit Element **und** Person. Sichtbar am Ziel-Element und im Dialog «Kapital verteilen». Das Feld heisst neu **«Zusatzinvestition aus Kapitalbezug»** (vorher «Davon aus Kapitalbezug (PK/3a)» irreführend, weil es kein Anteil an der manuell erfassten Zusatzinvestition ist, sondern ein zweiter, davon unabhängiger Betrag). (3) **Der Dialog «Kapital verteilen» zeigt das bereits Zugeteilte.** Vorher stand dort eine **0**, obwohl die Quote geflossen war das Feld führt nur den manuell erfassten Teil. Neu erscheint darüber eine read-only Zeile mit dem aus dem Bezugs-Entscheid stammenden Betrag samt Aufschlüsselung, darunter das editierbare Feld und die Summe beider. (4) **Bezogene Vorsorge-Guthaben werden in beiden Verteil-Dialogen nicht mehr angeboten.** Nach der Pensionierung ignoriert die Rechnung Beiträge und Zusatzeinlagen in PK und Säule 3a die Dialoge boten sie trotzdem an, inklusive eines aus der Vorphase geerbten 3a-Beitrags, der dort als aktive Rate erschien. Der Filter prüfte nur den `status` (`ACTIVE`), und der bleibt nach dem Bezug bestehen. Neu setzt die Rechnung selbst das Kennzeichen `acceptsCapital: false`; die Dialoge lesen es, statt die Regel ein zweites Mal nachzubauen. 4 Tests ergänzt (288 → 292). | | 0.34 | 2026-07-25 | Claude (Opus 5) | **Modul-Review 4, Nachbesserungen: die Übergangs-Entscheide bis ans Ende durchgezogen.** (1) **Zuordnung überall dort, wo Elemente über ihren Namen angeboten werden.** Zwei Personen nennen ihre Guthaben typischerweise gleich («Säule 3a», «ETF»); ohne die Person wählt man im Dropdown blind. Betroffen waren das **Ziel der Anlage-Quote** beim Kapitalbezug (dort mit hoher Folgewirkung: Ein Fehlgriff leitet das Alterskapital in das Depot der falschen Person) und die Zeilen im Dialog **«Kapital verteilen»**. Die Klartext-Zuordnung liegt neu als `ownerLabel` in `src/lib/elements.ts` und wird von allen drei Stellen genutzt. (2) **Herkunft des umgeleiteten Alterskapitals wird ausgewiesen.** Fliessen PK **und** 3a in dasselbe Vermögens-Element, stand dort bisher nur eine Summe ob wirklich beide angekommen sind, liess sich nicht prüfen. `Carry` und `ElementPhaseComputed` führen neu `capitalInSources` bzw. `capitalFromTransferSources` mit: Betrag **je Quelle**, benannt mit Element **und** Person. Sichtbar am Ziel-Element und im Dialog «Kapital verteilen». Das Feld heisst neu **«Zusatzinvestition aus Kapitalbezug»** (vorher «Davon aus Kapitalbezug (PK/3a)» irreführend, weil es kein Anteil an der manuell erfassten Zusatzinvestition ist, sondern ein zweiter, davon unabhängiger Betrag). (3) **Der Dialog «Kapital verteilen» zeigt das bereits Zugeteilte.** Vorher stand dort eine **0**, obwohl die Quote geflossen war das Feld führt nur den manuell erfassten Teil. Neu erscheint darüber eine read-only Zeile mit dem aus dem Bezugs-Entscheid stammenden Betrag samt Aufschlüsselung, darunter das editierbare Feld und die Summe beider. (4) **Bezogene Vorsorge-Guthaben werden in beiden Verteil-Dialogen nicht mehr angeboten.** Nach der Pensionierung ignoriert die Rechnung Beiträge und Zusatzeinlagen in PK und Säule 3a die Dialoge boten sie trotzdem an, inklusive eines aus der Vorphase geerbten 3a-Beitrags, der dort als aktive Rate erschien. Der Filter prüfte nur den `status` (`ACTIVE`), und der bleibt nach dem Bezug bestehen. Neu setzt die Rechnung selbst das Kennzeichen `acceptsCapital: false`; die Dialoge lesen es, statt die Regel ein zweites Mal nachzubauen. 4 Tests ergänzt (288 → 292). |
| 0.33 | 2026-07-25 | Claude (Opus 5) | **Modul-Review 4 (Matrix: Phasen und Elemente).** (1) **Kapitalverwendung neu am Vorsorge-Element** (Punkt C aus Roadmap Nr. 44): Die Prozent-Aufteilung des bezogenen Alterskapitals hing am **Cash-Übergang** dem falschen Ort, denn mit zwei Guthaben (PK und 3a) liess sie sich dort gar nicht getrennt beantworten. Sie steht jetzt beim **Bezugs-Entscheid** der Pensionskasse (nur bei Kapitalbezug) bzw. der **Säule 3a**. Beide Dialoge führen neu **brutto → Steuersatz → netto** und darunter die Verteilung. Der zugeteilte Betrag fliesst über den regulären Weg (`Carry.capitalIn` → Zusatzeinlage der Folgephase) und ist damit **überall sichtbar**: am Ziel-Element, in der Cash-Brücke als Investition und im «Kapital verteilen»-Dialog. Vorher erhöhte er still den Bestand, weshalb Element und Dialog eine **0** zeigten. Die **Säule 3a** ist am Pensions-Übergang neu ein **offener Entscheid** (Steuersatz und Verwendung); vorher galt sie als automatisch beantwortet. (2) **Phasendauer: die Folgephase gleicht aus** (Kap. 3.3.2). Bis 0.32 prüfte die Kappung nur die **bearbeitete** Phase wurde Phase 1 von 10 auf 12 Jahre verlängert, überspannte danach Phase 2 die Pensionierung, und die tragende Invariante aus Roadmap Nr. 44 kippte. Neu trägt die Folgephase die Differenz (Gesamtdauer bleibt gleich, wie beim Verschieben des Pensionsalters); passt sie nicht, wird blockiert; vorher erscheint eine Rückfrage. Neue reine Funktion `planDurationChange`. (3) **Element und Phase direkt bedienbar:** In der Matrix tragen Element-Zeile und Phasenkopf neu **Stift** (umbenennen, beim Element inkl. **Zuordnung**) und **Papierkorb**; das Expand-Symbol ist **immer** sichtbar statt nur bei Mouseover. `PATCH /api/elements/<id>` nimmt dafür neu auch `ownerRole` (bleibt für AHV/PK/3a personengebunden). (4) **Hilfetexte** werden über ein **Portal** gezeichnet in scrollenden Dialogen schnitt der Container sie vorher ab; sie klappen nach oben, wenn unten kein Platz ist. (5) **Verteil-Dialoge:** Zeilen zeigen die **Zuordnung** (Person A/B/Gemeinsam) und sind nach **«vom Cash»/«ins Cash»** gruppiert; die Vorbelegung nutzt neu den **effektiven** Wert inklusive Vererbung aus der Vorphase ein geerbter 3a-Beitrag erschien vorher als 0. (6) **Matrix:** alle Phasenspalten **gleich breit**, bei vielen Phasen wird horizontal gescrollt; **«Alle auf-/zuklappen»**; eine zugeklappte Kategorie zeigt je Phase die **Summe** ihrer Elemente. (7) **Phasen-Detailansicht** nutzt die neue Aufteilungs-Grafik (Fläche + Ring) statt der alten Balken. (8) **Übersicht:** «Leer starten» steht neu auch im leeren Zustand zur Wahl. (9) Nebenbei: dritte vom Umlaut-Sweep verstümmelte Hex-Farbe (`#7c3äd`) repariert, das Phasen-Panel nutzt den eigenen Bestätigungs-Dialog statt `window.confirm`. 10 Tests ergänzt (278 → 288). | | 0.33 | 2026-07-25 | Claude (Opus 5) | **Modul-Review 4 (Matrix: Phasen und Elemente).** (1) **Kapitalverwendung neu am Vorsorge-Element** (Punkt C aus Roadmap Nr. 44): Die Prozent-Aufteilung des bezogenen Alterskapitals hing am **Cash-Übergang** dem falschen Ort, denn mit zwei Guthaben (PK und 3a) liess sie sich dort gar nicht getrennt beantworten. Sie steht jetzt beim **Bezugs-Entscheid** der Pensionskasse (nur bei Kapitalbezug) bzw. der **Säule 3a**. Beide Dialoge führen neu **brutto → Steuersatz → netto** und darunter die Verteilung. Der zugeteilte Betrag fliesst über den regulären Weg (`Carry.capitalIn` → Zusatzeinlage der Folgephase) und ist damit **überall sichtbar**: am Ziel-Element, in der Cash-Brücke als Investition und im «Kapital verteilen»-Dialog. Vorher erhöhte er still den Bestand, weshalb Element und Dialog eine **0** zeigten. Die **Säule 3a** ist am Pensions-Übergang neu ein **offener Entscheid** (Steuersatz und Verwendung); vorher galt sie als automatisch beantwortet. (2) **Phasendauer: die Folgephase gleicht aus** (Kap. 3.3.2). Bis 0.32 prüfte die Kappung nur die **bearbeitete** Phase wurde Phase 1 von 10 auf 12 Jahre verlängert, überspannte danach Phase 2 die Pensionierung, und die tragende Invariante aus Roadmap Nr. 44 kippte. Neu trägt die Folgephase die Differenz (Gesamtdauer bleibt gleich, wie beim Verschieben des Pensionsalters); passt sie nicht, wird blockiert; vorher erscheint eine Rückfrage. Neue reine Funktion `planDurationChange`. (3) **Element und Phase direkt bedienbar:** In der Matrix tragen Element-Zeile und Phasenkopf neu **Stift** (umbenennen, beim Element inkl. **Zuordnung**) und **Papierkorb**; das Expand-Symbol ist **immer** sichtbar statt nur bei Mouseover. `PATCH /api/elements/<id>` nimmt dafür neu auch `ownerRole` (bleibt für AHV/PK/3a personengebunden). (4) **Hilfetexte** werden über ein **Portal** gezeichnet in scrollenden Dialogen schnitt der Container sie vorher ab; sie klappen nach oben, wenn unten kein Platz ist. (5) **Verteil-Dialoge:** Zeilen zeigen die **Zuordnung** (Person A/B/Gemeinsam) und sind nach **«vom Cash»/«ins Cash»** gruppiert; die Vorbelegung nutzt neu den **effektiven** Wert inklusive Vererbung aus der Vorphase ein geerbter 3a-Beitrag erschien vorher als 0. (6) **Matrix:** alle Phasenspalten **gleich breit**, bei vielen Phasen wird horizontal gescrollt; **«Alle auf-/zuklappen»**; eine zugeklappte Kategorie zeigt je Phase die **Summe** ihrer Elemente. (7) **Phasen-Detailansicht** nutzt die neue Aufteilungs-Grafik (Fläche + Ring) statt der alten Balken. (8) **Übersicht:** «Leer starten» steht neu auch im leeren Zustand zur Wahl. (9) Nebenbei: dritte vom Umlaut-Sweep verstümmelte Hex-Farbe (`#7c3äd`) repariert, das Phasen-Panel nutzt den eigenen Bestätigungs-Dialog statt `window.confirm`. 10 Tests ergänzt (278 → 288). |
| 0.32 | 2026-07-25 | Claude (Opus 5) | **Modul-Review 3, Nachbesserungen darunter ein gravierender Rechenfehler bei den effektiven Werten.** (1) **Immobilien-Bugfix (Kap. 3.9):** Der Ist-Wizard belegte den Immobilienwert mit dem **Eigenkapital** vor (`ElementYearPoint.value`), während Erfassung und Rechenkern den **Verkehrswert** erwarten. Der Rechenkern setzte den vorbelegten Wert als Verkehrswert ein, liess die Hypothek aber stehen das Eigenkapital brach im Ist-Jahr schlagartig ein, typischerweise ins Negative. Sichtbar wurde das als **negative Gesamt-Abweichung, obwohl nur ein Lohn erhöht** wurde, und als «wegbrechendes» Wohneigentum in der Vermögensaufteilung. Neu wird `propertyValue` vorbelegt; das Feld ist als «Verkehrswert + Restschuld» beschriftet. Drei Regressionstests. (2) **Ist-Datensätze bearbeitbar:** Ein Klick auf die Zeile (oder «Bearbeiten») öffnet den erfassten Satz erneut; neuer Endpunkt `PUT /api/plans/<id>/actuals/<setId>`. Beim Bearbeiten überschreiben die Planwerte die erfassten Zahlen nicht mehr. (3) **Ring-Klick in der Vermögensaufteilung repariert:** Recharts 3 reicht im Klick-Parameter **kein `activePayload`** mehr durch (nur noch `activeIndex`) der Handler feuerte nie, der Ring zeigte immer das Planende. (4) **Seitenleiste sauber dreistufig:** Ebene 1 Pläne, Ebene 2 die vier Bereiche (Szenarien, Effektive Werte, Analysen, Berichte) mit **bündigen Symbolen**, Ebene 3 nur die Szenarien verschachtelt nach Herkunft. (5) Die **Szenario-Liste** zeigt neben der Version deren **Kommentar**. | | 0.32 | 2026-07-25 | Claude (Opus 5) | **Modul-Review 3, Nachbesserungen darunter ein gravierender Rechenfehler bei den effektiven Werten.** (1) **Immobilien-Bugfix (Kap. 3.9):** Der Ist-Wizard belegte den Immobilienwert mit dem **Eigenkapital** vor (`ElementYearPoint.value`), während Erfassung und Rechenkern den **Verkehrswert** erwarten. Der Rechenkern setzte den vorbelegten Wert als Verkehrswert ein, liess die Hypothek aber stehen das Eigenkapital brach im Ist-Jahr schlagartig ein, typischerweise ins Negative. Sichtbar wurde das als **negative Gesamt-Abweichung, obwohl nur ein Lohn erhöht** wurde, und als «wegbrechendes» Wohneigentum in der Vermögensaufteilung. Neu wird `propertyValue` vorbelegt; das Feld ist als «Verkehrswert + Restschuld» beschriftet. Drei Regressionstests. (2) **Ist-Datensätze bearbeitbar:** Ein Klick auf die Zeile (oder «Bearbeiten») öffnet den erfassten Satz erneut; neuer Endpunkt `PUT /api/plans/<id>/actuals/<setId>`. Beim Bearbeiten überschreiben die Planwerte die erfassten Zahlen nicht mehr. (3) **Ring-Klick in der Vermögensaufteilung repariert:** Recharts 3 reicht im Klick-Parameter **kein `activePayload`** mehr durch (nur noch `activeIndex`) der Handler feuerte nie, der Ring zeigte immer das Planende. (4) **Seitenleiste sauber dreistufig:** Ebene 1 Pläne, Ebene 2 die vier Bereiche (Szenarien, Effektive Werte, Analysen, Berichte) mit **bündigen Symbolen**, Ebene 3 nur die Szenarien verschachtelt nach Herkunft. (5) Die **Szenario-Liste** zeigt neben der Version deren **Kommentar**. |
@@ -431,10 +431,25 @@ gesetzt. Kalenderjahr eines Planjahrs: `startYear + (Jahr 1)`.
### 3.2.8 Geführter Assistent und Beispielplan ### 3.2.8 Geführter Assistent und Beispielplan
**Der Plan-Assistent** (Roadmap Nr. 10: «Schritt für Schritt statt leerer Matrix») fragt in **Der Plan-Assistent** (Roadmap Nr. 10: «Schritt für Schritt statt leerer Matrix») fragt in
**sechs** Schritten in Alltagssprache: (1) Grundprofil, (2) Lebensphasen, (3) Einkommen und **sieben** Schritten in Alltagssprache: (1) Grundprofil, (2) Lebensphasen, (3) Einkommen und
Ausgaben plus Kontostand, (4) Vorsorge und Vermögen (**nur Bestandswerte**), (5) Sparen und Ausgaben plus Kontostand, (4) Vorsorge und Vermögen (**nur Bestandswerte**), (5) Sparen und
Verteilen, (6) Zusammenfassung. Im **Einzelmodus** durchgehend in **Du-Form** («Was verdienst Verteilen, (6) **Deine Pensionierung**, (7) Zusammenfassung. Im **Einzelmodus** durchgehend in
du?»); im Paarmodus je Person bzw. «ihr». **Du-Form** («Was verdienst du?»); im Paarmodus je Person bzw. «ihr».
**Schritt 6 ist ein Überblick, kein Erfassungsschritt.** An dieser Stelle weiss niemand, wie
hoch seine PK-Rente sein wird danach zu fragen hiesse, eine unbeantwortbare Frage zu stellen.
Die Antwort zu *zeigen* ist dagegen der stärkste Moment im ganzen Onboarding: Der Schritt weist
**Rentenlücke** und **Kapitalreichweite** aus und darunter die Vorgaben zu AHV, Pensionskasse
und Säule 3a ([3.13](#313-pensionierung)) über exakt dieselben Bausteine wie der
Pensionierungs-Bildschirm. Ändern kann man sie hier, muss aber nicht.
Gerechnet wird die Vorschau, **bevor der Plan existiert**: `computePlan` ist rein und läuft im
Browser in Bruchteilen einer Millisekunde. Damit Vorschau und erzeugter Plan nicht auseinander-
laufen können, speist **eine einzige Element-Liste** (`elementSpecs`) beides die Vorschau und
die Anlage über die API. Zwei Aufbauten desselben Plans wären garantiert irgendwann verschieden,
und die Vorschau zeigte dann Zahlen, die der erzeugte Plan nie hat. Die im Schritt getroffenen
Entscheide werden **nach** dem Anlegen geschrieben; vorher gibt es keine Element-Ids, an denen
sie hängen könnten.
**Schritt 2 ist an den fixen Pensionierungszeitpunkten ausgerichtet.** Das Pensionsalter jeder **Schritt 2 ist an den fixen Pensionierungszeitpunkten ausgerichtet.** Das Pensionsalter jeder
Person ist ein Fixpunkt auf der Lebenslinie; dazwischen entstehen Abschnitte mit konstantem Person ist ein Fixpunkt auf der Lebenslinie; dazwischen entstehen Abschnitte mit konstantem
+267 -12
View File
@@ -25,7 +25,21 @@ import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/compo
import { api } from "@/lib/api-client"; import { api } from "@/lib/api-client";
import { planSegments, defaultOpenDuration, type PlanSegment, type SegmentType } from "@/lib/phaseplan"; import { planSegments, defaultOpenDuration, type PlanSegment, type SegmentType } from "@/lib/phaseplan";
import { PILLAR_3A_MAX_ANNUAL, PILLAR_3A_MAX_SELF_EMPLOYED } from "@/lib/constants"; import { PILLAR_3A_MAX_ANNUAL, PILLAR_3A_MAX_SELF_EMPLOYED } from "@/lib/constants";
import type { PhaseData } from "@/lib/elements"; import { computePlan } from "@/lib/calculations";
import { formatChf } from "@/lib/format";
import { RetirementFields } from "@/components/RetirementFields";
import { RETIREMENT_CATEGORIES, type RetirementDecision } from "@/lib/retirement-decision";
import type { ElementCategory, PhaseData } from "@/lib/elements";
import type { ElementInput, PlanInput } from "@/lib/types";
// Ein anzulegendes Element. Dient zugleich als Bauplan für die rechenbare Vorschau, damit
// Vorschau und erzeugter Plan nicht auseinanderlaufen können.
interface ElementSpec {
category: string;
name: string;
ownerRole: string;
values?: PhaseData;
}
interface PhaseDraft { interface PhaseDraft {
name: string; name: string;
@@ -38,6 +52,7 @@ const STEP_TITLES = [
"Einkommen & Ausgaben", "Einkommen & Ausgaben",
"Vorsorge & Vermögen", "Vorsorge & Vermögen",
"Sparen & Verteilen", "Sparen & Verteilen",
"Deine Pensionierung",
"Fertig", "Fertig",
]; ];
@@ -49,6 +64,7 @@ const WIZARD_OVERVIEW: { icon: React.ComponentType<{ className?: string }>; titl
{ icon: Wallet, title: "Einkommen & Ausgaben", desc: "Was reinkommt, was rausgeht, was auf dem Konto liegt." }, { icon: Wallet, title: "Einkommen & Ausgaben", desc: "Was reinkommt, was rausgeht, was auf dem Konto liegt." },
{ icon: PiggyBank, title: "Vorsorge & Vermögen", desc: "Was du heute besitzt PK, 3a, Wertschriften, Wohneigentum." }, { icon: PiggyBank, title: "Vorsorge & Vermögen", desc: "Was du heute besitzt PK, 3a, Wertschriften, Wohneigentum." },
{ icon: Coins, title: "Sparen & Verteilen", desc: "Deine Sparquote auf die Sparziele aufteilen." }, { icon: Coins, title: "Sparen & Verteilen", desc: "Deine Sparquote auf die Sparziele aufteilen." },
{ icon: Flag, title: "Deine Pensionierung", desc: "Was am Ende herauskommt und was du daran drehen kannst." },
]; ];
const SEGMENT_META: Record<SegmentType, { label: string; phase: string; def: string; color: string; soft: string }> = { const SEGMENT_META: Record<SegmentType, { label: string; phase: string; def: string; color: string; soft: string }> = {
@@ -185,9 +201,14 @@ export function PlanWizard({
return seg.fixedYears === null ? segSums[i] >= 1 : segSums[i] === seg.fixedYears; return seg.fixedYears === null ? segSums[i] >= 1 : segSums[i] === seg.fixedYears;
}); });
// Pensionierungs-Entscheide je Person, im Assistenten getroffen. Sie werden erst NACH dem
// Anlegen geschrieben -- vorher gibt es keine Element-Ids, an denen sie hängen könnten.
const [retireDraft, setRetireDraft] = useState<Record<string, RetirementDecision>>({});
// Flache, geordnete Phasenliste (für Vorschau, Timeline und Anlegen). // Flache, geordnete Phasenliste (für Vorschau, Timeline und Anlegen).
const flatPhases: PhaseDraft[] = segPhases.flat(); const flatPhases: PhaseDraft[] = segPhases.flat();
function updatePhase(segIdx: number, phaseIdx: number, patch: Partial<PhaseDraft>) { function updatePhase(segIdx: number, phaseIdx: number, patch: Partial<PhaseDraft>) {
setSegPhases((prev) => setSegPhases((prev) =>
prev.map((phs, i) => (i === segIdx ? phs.map((p, j) => (j === phaseIdx ? { ...p, ...patch } : p)) : phs)) prev.map((phs, i) => (i === segIdx ? phs.map((p, j) => (j === phaseIdx ? { ...p, ...patch } : p)) : phs))
@@ -235,16 +256,18 @@ export function PlanWizard({
ownerRole, ownerRole,
}); });
if (values) await api.put(`/api/elements/${element.id}/phase/${firstPhaseId}`, values); if (values) await api.put(`/api/elements/${element.id}/phase/${firstPhaseId}`, values);
return element.id;
} }
for (const p of profile.persons) { // Genau die Liste, aus der auch die Vorschau gerechnet wurde.
const amount = incomes[p.role] ?? 0; for (const spec of elementSpecs) {
// 1 % nominale Lohnerhöhung als dezenter Default -- 0 % liesse den Lohn real verlieren. const id = await addEl(spec.category, spec.name, spec.ownerRole, spec.values);
if (amount > 0) await addEl("INCOME", `Lohn ${personLabel(p.role)}`, p.role, { amount, teuerungsausgleich: 1 }); // Pensionierungs-Entscheid nur dort, wo er hingehört -- und nur, wenn der Nutzer im
// Überblicksschritt etwas verändert hat.
const rd = retireDraft[spec.ownerRole];
if (id && rd && Object.keys(rd).length > 0 && RETIREMENT_CATEGORIES.includes(spec.category as ElementCategory)) {
await api.put(`/api/elements/${id}/retirement`, rd);
} }
if (expenses > 0) await addEl("EXPENSE", "Lebenshaltung", "HOUSEHOLD", { amount: expenses, teuerungsausgleich: 0 });
for (const p of profile.persons) {
await addEl("AHV", isCouple ? `AHV ${personLabel(p.role)}` : "AHV", p.role, { gapYears: 0 });
} }
// Vermögen/Vorsorge je Eigentümer-Bereich anlegen. // Vermögen/Vorsorge je Eigentümer-Bereich anlegen.
@@ -331,6 +354,139 @@ export function PlanWizard({
return n + sc.cats.filter((c) => a[c].on).length; return n + sc.cats.filter((c) => a[c].on).length;
}, 0); }, 0);
// Die anzulegenden Elemente -- EINE Liste, die sowohl die Pensionierungs-Vorschau als auch
// das Anlegen speist. Zwei Aufbauten desselben Plans würden garantiert auseinanderlaufen,
// und die Vorschau zeigte dann Zahlen, die der erzeugte Plan nie hat.
const elementSpecs = useMemo<ElementSpec[]>(() => {
const out: ElementSpec[] = [];
for (const person of profile.persons) {
const amount = incomes[person.role] ?? 0;
// 1 % nominale Lohnerhöhung als dezenter Default -- 0 % liesse den Lohn real verlieren.
if (amount > 0)
out.push({
category: "INCOME",
name: `Lohn ${personLabel(person.role)}`,
ownerRole: person.role,
values: { amount, teuerungsausgleich: 1 },
});
}
if (expenses > 0)
out.push({
category: "EXPENSE",
name: "Lebenshaltung",
ownerRole: "HOUSEHOLD",
values: { amount: expenses, teuerungsausgleich: 0 },
});
for (const person of profile.persons)
out.push({
category: "AHV",
name: isCouple ? `AHV ${personLabel(person.role)}` : "AHV",
ownerRole: person.role,
values: { gapYears: 0 },
});
for (const scope of renderedScopes) {
const a = assets[scope.id];
const suffix = scope.nameSuffix;
if (scope.showVorsorge) {
if (a.pk.on)
out.push({
category: "PENSION_FUND",
name: `Pensionskasse${suffix}`,
ownerRole: scope.vorsorgeRole!,
values: { currentValue: a.pk.value, annualContribution: a.pk.contribution, expectedReturn: a.pk.ret },
});
if (a.p3a.on)
out.push({
category: "PILLAR_3A",
name: `Säule 3a${suffix}`,
ownerRole: scope.vorsorgeRole!,
values: {
currentValue: a.p3a.value,
annualContribution: Math.min(a.p3a.contribution, max3aFor(scope.id)),
expectedReturn: a.p3a.ret,
selfEmployed3a: a.p3a.selfEmployed,
},
});
}
if (a.etf.on)
out.push({
category: "OTHER_ASSET",
name: `Wertschriften${suffix}`,
ownerRole: scope.otherRole,
values: { startValue: a.etf.value, annualContribution: a.etf.contribution, expectedReturn: a.etf.ret },
});
if (a.re.on)
out.push({
category: "REAL_ESTATE",
name: `Wohneigentum${suffix}`,
ownerRole: scope.otherRole,
values: {
purchasePrice: a.re.price,
mortgage: a.re.mortgage,
amortization: a.re.amortization,
interestRate: a.re.interest,
valueGrowth: a.re.growth,
interestHandling: a.re.interestIncluded ? "INCLUDED" : "ADD",
},
});
if (a.debt.on)
out.push({
category: "OTHER_DEBT",
name: `Schulden${suffix}`,
ownerRole: scope.otherRole,
values: { startValue: a.debt.value, annualRepayment: a.debt.repayment },
});
}
return out;
// eslint-disable-next-line react-hooks/exhaustive-deps -- renderedScopes/max3aFor folgen denselben Zuständen
}, [profile, incomes, expenses, assets, isCouple]);
// Rechenbare Vorschau OHNE dass der Plan existiert. computePlan ist rein und läuft im
// Browser in Bruchteilen einer Millisekunde -- genau dieselbe Rechnung wie später.
const previewPlan: PlanInput = {
id: "preview",
name: planName.trim() || "Meine Planung",
householdType: profile.householdType,
inflationRateDefault: profile.inflationRateDefault,
initialCash,
startYear: profile.startYear,
persons: profile.persons.map((x) => ({
id: x.role,
role: x.role,
name: x.name.trim() || null,
age: x.age,
retirementAge: x.retirementAge,
planningHorizonAge: null,
})),
phases: flatPhases.map((ph, i) => ({
id: `ph${i}`,
sequenceNumber: i + 1,
name: ph.name,
durationYears: ph.durationYears,
cashTransition: {},
})),
elements: elementSpecs.map((spec, i) => ({
id: `el${i}`,
category: spec.category as ElementCategory,
name: spec.name,
ownerRole: spec.ownerRole as ElementInput["ownerRole"],
orderIndex: i,
// Werte gelten für Phase 1; die Folgephasen erben live (Punkt A).
phaseValues: { ph0: spec.values ?? {} },
transitionValues: {},
retirementDecision: retireDraft[spec.ownerRole] ?? {},
})),
};
// Eine unvollständige Zwischeneingabe darf den Assistenten nicht abstürzen lassen.
let previewComputed: ReturnType<typeof computePlan> | null = null;
try {
previewComputed = computePlan(previewPlan);
} catch {
previewComputed = null;
}
// --- Sparquote (Schritt "Sparen & Verteilen") --- // --- Sparquote (Schritt "Sparen & Verteilen") ---
// Die Sparquote entsteht aus Nettoeinkommen minus Ausgaben. PK-Beiträge kommen VOM // Die Sparquote entsteht aus Nettoeinkommen minus Ausgaben. PK-Beiträge kommen VOM
// Bruttolohn (vor dem Netto) und reduzieren sie deshalb NICHT -- genau wie im Rechenkern. // Bruttolohn (vor dem Netto) und reduzieren sie deshalb NICHT -- genau wie im Rechenkern.
@@ -404,7 +560,7 @@ export function PlanWizard({
return ( return (
<Modal <Modal
title="Plan erstellen geführt" title="Plan erstellen geführt"
subtitle={step < 5 ? `Schritt ${step + 1} von 5: ${STEP_TITLES[step]}` : "Zusammenfassung"} subtitle={step < 6 ? `Schritt ${step + 1} von 6: ${STEP_TITLES[step]}` : "Zusammenfassung"}
onClose={onClose} onClose={onClose}
xwide xwide
> >
@@ -441,12 +597,12 @@ export function PlanWizard({
<div className="mt-1 flex items-center gap-2 rounded-lg px-2 py-1.5 text-xs"> <div className="mt-1 flex items-center gap-2 rounded-lg px-2 py-1.5 text-xs">
<span <span
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full ${ className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full ${
step === 5 ? "bg-accent text-accent-fg" : "border border-border text-faint" step === 6 ? "bg-accent text-accent-fg" : "border border-border text-faint"
}`} }`}
> >
<Flag className="h-3 w-3" /> <Check className="h-3 w-3" />
</span> </span>
<span className={step === 5 ? "font-semibold text-fg" : "text-faint"}>Fertig</span> <span className={step === 6 ? "font-semibold text-fg" : "text-faint"}>Fertig</span>
</div> </div>
</nav> </nav>
@@ -817,7 +973,106 @@ export function PlanWizard({
</div> </div>
)} )}
{/* Überblick statt Erfassung: Zu diesem Zeitpunkt weiss niemand, wie hoch seine PK-Rente
sein wird -- danach zu fragen hiesse, eine unbeantwortbare Frage zu stellen. Die
Antwort zu ZEIGEN ist dagegen der stärkste Moment im ganzen Onboarding. Was hier
steht, sind die Vorgaben; ändern kann man sie, muss aber nicht. */}
{step === 5 && ( {step === 5 && (
<div className="flex flex-col gap-4">
<p className="text-sm text-muted">
So sieht deine Pensionierung mit den bisherigen Angaben aus. Das Tool rechnet mit sinnvollen Vorgaben
du kannst sie jetzt anpassen oder später jederzeit im Bildschirm «Pensionierung».
</p>
{previewComputed && previewComputed.retirement.gapAnnual !== null ? (
<>
<div className="rounded-xl border border-border bg-surface-2 p-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-1 text-sm">
{previewComputed.retirement.perPerson.map((r) => (
<div key={r.role} className="flex items-baseline justify-between gap-3">
<span className="text-muted">
{personLabel(r.role)} · AHV + PK
<span className="ml-1 text-xs text-faint">(ab {r.retirementAge})</span>
</span>
<span className="tabular-nums text-fg">{formatChf(r.ahvAnnual + r.pkPensionAnnual)}</span>
</div>
))}
<div className="my-1 border-t border-border" />
<div className="flex items-baseline justify-between gap-3">
<span className="font-semibold text-fg">Renteneinkommen</span>
<span className="tabular-nums font-semibold text-fg">
{formatChf(previewComputed.retirement.pensionIncome ?? 0)}
</span>
</div>
<div className="flex items-baseline justify-between gap-3">
<span className="text-muted">Ausgaben</span>
<span className="tabular-nums text-fg">
{formatChf(previewComputed.retirement.expenses ?? 0)}
</span>
</div>
</div>
<div className="flex flex-col justify-center gap-3">
<div>
<div className="flex items-center text-xs uppercase tracking-wide text-faint">
Rentenlücke
<InfoBubble text="Renteneinkommen minus Ausgaben im ersten Jahr, in dem niemand mehr arbeitet. Eine Lücke ist normal sie wird aus dem Vermögen gedeckt. Entscheidend ist, wie lange das trägt." />
</div>
<div
className={`text-2xl font-semibold tabular-nums ${
(previewComputed.retirement.gapAnnual ?? 0) < 0 ? "text-danger" : "text-success"
}`}
>
{formatChf(previewComputed.retirement.gapAnnual ?? 0)} / Jahr
</div>
</div>
<div>
<div className="text-xs uppercase tracking-wide text-faint">Vermögen reicht</div>
<div
className={`text-lg font-semibold ${
previewComputed.ruinAge === null ? "text-success" : "text-danger"
}`}
>
{previewComputed.ruinAge === null
? "über die ganze Planung"
: `bis Alter ${previewComputed.ruinAge}`}
</div>
</div>
</div>
</div>
</div>
{/* Dieselben Bausteine wie im Pensionierungs-Bildschirm -- eine Implementierung. */}
<div className="flex flex-col gap-2">
{previewPlan.elements
.filter((e) => RETIREMENT_CATEGORIES.includes(e.category))
.map((e) => (
<RetirementFields
key={e.id}
plan={previewPlan}
el={e}
patch={(_id, patch) =>
setRetireDraft((prev) => ({
...prev,
[e.ownerRole ?? "HOUSEHOLD"]: { ...(prev[e.ownerRole ?? "HOUSEHOLD"] ?? {}), ...patch },
}))
}
summary={previewComputed.retirement.perPerson.find((x) => x.role === e.ownerRole)}
/>
))}
</div>
</>
) : (
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-sm text-muted">
Für eine Vorschau fehlen noch Angaben lege mindestens eine Lebensphase nach der Pensionierung sowie
Einkommen und Ausgaben an. Du kannst den Plan trotzdem erstellen und alles später ergänzen.
</p>
)}
</div>
)}
{step === 6 && (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<p className="text-sm text-muted">Das wird angelegt:</p> <p className="text-sm text-muted">Das wird angelegt:</p>
<ul className="flex flex-col gap-1.5 rounded-xl border border-border bg-surface-2 p-4 text-sm text-fg"> <ul className="flex flex-col gap-1.5 rounded-xl border border-border bg-surface-2 p-4 text-sm text-fg">