Assistent als Herzstueck: Tests und Spezifikation 0.36
Deploy App / deploy (push) Successful in 2m2s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 22:33:46 +02:00
parent c440063a93
commit 42d5585969
2 changed files with 311 additions and 104 deletions
+166 -104
View File
@@ -4,10 +4,10 @@
| | |
|---|---|
| **Dokument** | Funktionale und Technische Spezifikation FPT |
| **Version** | 0.35 |
| **Version** | 0.36 |
| **Datum** | 2026-07-25 |
| **Status** | Lebendes Dokument |
| **Codestand** | Arbeitsstand nach `1865db5` inkl. Pensionierung als Eigenschaft der Person (Branch `main`) |
| **Codestand** | Arbeitsstand nach `c440063` inkl. Assistent als Herzstück (Branch `main`) |
| **Ersetzt** | `FDD_TDD_FPT.docx` (v1v5) im Ordner `Info Dateien` diese sind ab Version 0.1 dieses Dokuments obsolet |
| **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` |
@@ -17,6 +17,7 @@
| Version | Datum | Autor | Änderung |
|---|---|---|---|
| 0.36 | 2026-07-26 | Claude (Opus 5) | **Der Assistent wird das Herzstück** (neues Kapitel 3.14). Ein Umbau von Onboarding, Bildschirmaufbau und Führung. (1) **Ein Weg hinein.** Die Übersicht zeigt im leeren Zustand nur noch «Meinen ersten Finanzplan anlegen»; der geführte Start und der Beispielplan entfallen. Drei Knöpfe waren eine Wahl, die niemand treffen kann, der das Tool noch nicht kennt. Der Plan-Dialog fragt nur noch sechs Dinge: Name, Haushaltsform, Personennamen, Startjahr, Alter, Inflation. **Das Pensionsalter wird nicht mehr abgefragt** es ist kein Stammdatum, sondern der erste Entscheid der Pensionsplanung, und es erzeugt eine Phasengrenze. Bis dahin gilt das Referenzalter. (2) **Element-Stammdaten** (`FinancialElement.baseData`): Bestand bei Planbeginn und Ausgangs-Annahmen hängen neu am ELEMENT statt in Phase 1. Zwei Gründe ein Startwert ist nicht «phase-1-spezifisch», sondern schlicht der Stand am Anfang; und Elemente lassen sich damit erfassen, **bevor es Lebensphasen gibt**. Genau das braucht die Bestandsaufnahme als erster Schritt. Zugleich sind die Stammdaten die **Wurzel der Feld-Vererbung**: Phase 1 hatte bisher nichts, von dem sie hätte erben können, und fiel auf 0. (3) **Aus einem Fixpunkt werden bis zu vier je Person.** `phaseplan.ts` kannte nur das Erwerbsende. Da AHV, Pensionskasse und jedes 3a-Konto eigene Bezugsalter haben (`pkWithdrawalAge` neu), erzwingt jeder Bezugsbeginn eine Phasengrenze sonst fiele er mitten in eine Phase und rutschte auf die nächste Grenze, unter Umständen Jahre später. Die Phasendauer-Kappung zählt sie mit; Ereignisse im selben Jahr teilen sich eine Grenze. (4) **Planungshorizont in JAHREN** am Szenario (`planningHorizonYears`) statt als Endalter je Person: eine Zahl statt zweier, die bei einem Paar auseinanderlaufen könnten; die Endalter sind die Ableitung. Ersetzt `Person.planningHorizonAge` aus 0.35. (5) **Neuer Szenario-Bildschirm.** Zwei farblich getrennte Hälften: oben die Steuerung in vier Kacheln (Grundeinstellungen mit «Pensionsplanung» je Person · Kennzahlen inkl. neuem **«Vermögen heute»** · Schnellaktionen · Assistent) plus die Zeitachse über die volle Breite; unten die Matrix. Die Aktionsleiste über der Matrix ist verschwunden: **«+ Element», «+ Phase», der Nominal/Real-Umschalter und «Alle auf-/zuklappen» sitzen jetzt in der Ecke oben links der Matrix** sie steuern die Matrix und lagen vorher lose darüber wie Aktionen der ganzen Seite. (6) **Der FPT-Assistent** ersetzt die Karte «Nächste Schritte». Sieben Schritte von der Bestandsaufnahme bis zum Feinschliff. Jeder öffnet ein Popup, das **zuerst erklärt** (welche Fragen der Schritt beantwortet, was man wissen sollte) und **danach das Werkzeug** anbietet; «Selbst erledigen» überspringt beides. Der Haken ist **manuell** das Tool masst sich nicht an zu wissen, wann jemand fertig ist , aber daneben steht der **abgeleitete Stand** («0 Lebensphasen»), damit ein abgehakter Schritt ohne Substanz auffällt. Erledigte rutschen nach unten und werden blass; die Kachel ist gelb, bis alle sieben stehen, dann grün. (7) **Neue Tour**: ein grosses Popup mit einem **nachgebauten** Bildschirm und erfundenen Zahlen, schrittweise erklärt. Das frühere Spotlight lag über der echten Ansicht und hatte auf einem frisch angelegten, leeren Plan nichts hervorzuheben, also gerade dann nicht, wenn es am nötigsten war. Der Preis ist, dass die Attrappe bei UI-Änderungen nachzuführen ist. (8) Entfallen: `PlanWizard` (der Assistent führt jetzt IM Plan statt davor) und `demoplan.ts`. Neue Module `assistant.ts`, neue Komponenten `Assistant`, `AssistantStepDialog`, `AssistantSteps`; neue Endpunkte `PUT /api/elements/<id>/base` und `POST /api/scenarios/<id>/assistant`. **Keine Datenmigration** (Pläne wurden vorgängig gelöscht). 12 Tests ergänzt (322 → 334). |
| 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.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). |
@@ -302,15 +303,13 @@ die ersten 72 Byte), keine ARIA-Labels auf der Login-Maske und der Befehls-Palet
### 3.2.1 Plan erstellen
Der «+»-Knopf öffnet eine Auswahl mit drei Wegen:
Es gibt genau **einen** Weg: den Knopf «Meinen ersten Finanzplan anlegen» in der Übersicht
bzw. das «+» in der Seitenleiste. Beide öffnen denselben Dialog. Zur Begründung, warum die
frühere Auswahl aus drei Wegen entfallen ist, siehe
[3.2.8](#328-der-einstieg-ein-weg-eine-tour-ein-assistent).
| Weg | Für wen | Was passiert |
|---|---|---|
| **Geführt erstellen** (empfohlen) | neue Nutzer | der Assistent aus [3.2.8](#328-geführter-assistent-und-beispielplan) |
| **Leer starten** | geübte Nutzer | der bisherige Dialog (unten) nur Grundprofil, keine Phasen/Elemente |
| **Beispielplan ansehen** | Erkunden | legt einen fiktiven, voll ausgefüllten Plan an ([3.2.8](#328-geführter-assistent-und-beispielplan)) |
Der Dialog «Leer starten» fragt Name plus das vollständige Grundprofil:
Der Dialog fragt Name plus Grundprofil -- **ohne Pensionsalter**, das gehört in die
Pensionsplanung:
| Feld | Typ | Default | Wertebereich |
|---|---|---|---|
@@ -428,105 +427,39 @@ und Grafiken beschriften damit Jahre statt nur Alter. Die Berechnung rechnet unv
Beim Anlegen wird das laufende Jahr vorbelegt; bestehende Pläne wurden per Migration darauf
gesetzt. Kalenderjahr eines Planjahrs: `startYear + (Jahr 1)`.
### 3.2.8 Geführter Assistent und Beispielplan
### 3.2.8 Der Einstieg: ein Weg, eine Tour, ein Assistent
**Der Plan-Assistent** (Roadmap Nr. 10: «Schritt für Schritt statt leerer Matrix») fragt in
**sieben** Schritten in Alltagssprache: (1) Grundprofil, (2) Lebensphasen, (3) Einkommen und
Ausgaben plus Kontostand, (4) Vorsorge und Vermögen (**nur Bestandswerte**), (5) Sparen und
Verteilen, (6) **Deine Pensionierung**, (7) Zusammenfassung. Im **Einzelmodus** durchgehend in
**Du-Form** («Was verdienst du?»); im Paarmodus je Person bzw. «ihr».
Bis 0.35 standen im leeren Zustand **drei** Knöpfe: geführt starten, Beispielplan ansehen, leer
starten. Das ist eine Wahl, die niemand treffen kann, der das Tool noch nicht kennt -- und sie
kostete den ersten Eindruck.
**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.
Seit 0.36 gibt es genau einen Weg:
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.
```
"Meinen ersten Finanzplan anlegen"
|
v
Plan-Dialog (sechs Felder)
|
v
Basisszenario, leer -> Tour (Demo-Popup) -> FPT-Assistent
```
**Schritt 2 ist an den fixen Pensionierungszeitpunkten ausgerichtet.** Das Pensionsalter jeder
Person ist ein Fixpunkt auf der Lebenslinie; dazwischen entstehen Abschnitte mit konstantem
Erwerbsstatus (reines Modul `phaseplan.ts`, `planSegments`):
**Der Plan-Dialog** fragt nur noch: Name des Plans, Haushaltsform, Namen der Personen
(freiwillig), Startjahr, Alter, Inflation. Dasselbe Fenster öffnet auch das Plus in der
Seitenleiste.
| Abschnitt | Bedeutung | Länge |
|---|---|---|
| **Erwerb** | alle arbeiten | fest (bis zur ersten Pensionierung) |
| **Misch** | eine Person pensioniert, eine arbeitet | fest (zwischen den Pensionierungen) |
| **Pension** | alle pensioniert | **offen** (Lebensdauer frei) |
**Das Pensionsalter wird bewusst NICHT gefragt.** Es ist kein Stammdatum, sondern der erste
Entscheid der Pensionsplanung -- und es erzeugt eine Phasengrenze
([3.14.4](#3144-fixpunkte-jeder-bezugsbeginn-erzwingt-eine-phasengrenze)). Bis Schritt 2 des
Assistenten gilt das Referenzalter.
Die **Anzahl** Abschnitte wird abgeleitet, nicht vorgegeben: Einzelplan → 2 (Erwerb, Pension);
Paar mit gleichem Pensionsalter → 2 (keine Mischzeit); Paar mit unterschiedlichem Pensionsalter
→ 3. Ist eine Person bei Planbeginn bereits pensioniert, beginnt die Linie mit einem Misch- oder
Pensions-Abschnitt.
**Der frühere Plan-Assistent (`PlanWizard`) und der Beispielplan sind entfallen.** Der Wizard
führte VOR dem Plan durch ein Formular und liess einen danach mit der Matrix allein; der
Assistent führt jetzt IM Plan und bleibt dort, solange man ihn braucht
([3.14](#314-der-fpt-assistent)). Was der Beispielplan leistete -- einmal sehen, wie ein
gefüllter Plan aussieht --, übernimmt die Tour.
In jedem **fest begrenzten** Abschnitt verteilt der Nutzer beliebig viele Phasen (mit
+/Papierkorb und eigenem Namen je Phase); eine **Live-Summe** erzwingt, dass die Phasendauern
exakt der festen Länge entsprechen «Weiter» bleibt gesperrt, bis es aufgeht. Das ist zwingend:
Die Berechnung leitet den Phasentyp am Phasenbeginn ab und **kappt jede Phase am nächsten
Pensionsereignis** ([2.3](#23-phasentyp--abgeleitet-nicht-gespeichert)) eine Phase, die eine
Pensionierung überspannt, gäbe es nicht. Der frühere Assistent liess die Erwerbsphase beliebig
über das Pensionsalter hinaus setzen; das ist damit behoben. Der **offene** Pensions-Abschnitt
nimmt beliebige Dauern (Summe = geplante Restlebensdauer).
Eine **Zeitachse** zeigt den proportionalen Verlauf mit den Pensionierungs-Fixpunkten als
Flaggen; die Phasen sind nummeriert und **unter** dem Balken beschriftet, damit auch kurze
Phasen lesbar bleiben.
**Schritt 4 (Vorsorge & Vermögen)** ist bei Paaren in **Gemeinsam / Person A / Person B**
aufgeteilt. Pensionskasse und Säule 3a sind immer persönlich (personengebundene Kategorien);
Wertschriften, Wohneigentum und Schulden lassen sich gemeinsam **oder** je Person erfassen. Hier
werden nur die **heutigen Bestandswerte** erfasst (Guthaben, Kaufpreis, Hypothek, Restschuld)
die laufenden Jahresbeträge folgen in Schritt 5. Zusätzlich fragt das Wohneigentum die
**Wertsteigerung** und den **Zins-in-Ausgaben-Schalter** ab (damit Hypothekarzinsen nicht
doppelt zählen). Der Schalter **«Selbstständig ohne PK (grosse Säule 3a)»** der Säule 3a steht
in **Schritt 5** (direkt bei der 3a-Einzahlung, denn er betrifft deren Obergrenze) und hebt die
Beitrags-Obergrenze an (siehe 4.11 / Feld `selfEmployed3a`).
**Schritt 5 (Sparen & Verteilen)** bringt das Kernmodell des Tools zum Anfassen: Aus
`Nettoeinkommen Ausgaben` entsteht die **Sparquote**; der Nutzer verteilt sie auf Säule 3a,
Wertschriften, Amortisation und Schuldtilgung, und der **noch nicht verteilte Rest** steht
**prominent zwischen PK-Block und Verteilung** und bleibt auf dem Cash-Konto (live gerechnet,
negativer Rest wird gewarnt). Die Verteilung ist nach **Gemeinsam / Person A / Person B**
gruppiert. Hypothekarzinsen, die im vorigen Schritt als «noch nicht in den Ausgaben» markiert
sind, rechnet die Sparquote-Vorschau zu den Ausgaben dazu so wie der Rechenkern bei
`interestHandling: ADD`.
**Vor Schritt 1** steht ein **Willkommens-Screen** mit dem Gesamtbild der fünf Schritte (Icon,
Titel, ein Satz je Schritt); danach begleitet eine **persistente Schritt-Leiste** den ganzen
Ablauf (links im breiten Modal, auf schmalen Screens als Fortschrittsbalken): aktueller Schritt
hervorgehoben mit Kurzbeschreibung, erledigte mit Haken, kommende gedämpft. Der Nutzer weiss so
jederzeit, wo er steht und was noch folgt. Die **Zusammenfassung** ist der Abschluss und trägt
keine eigene Schritt-Nummer. Die **PK-Einzahlung** steht bewusst
in einem **eigenen** Block mit dem Hinweis, dass sie vom **Bruttolohn** bezahlt wird also
**vor** dem Nettoeinkommen und die Sparquote deshalb **nicht** schmälert. Das deckt sich exakt
mit dem Rechenkern, wo der PK-Beitrag nicht zur Quote zählt ([4.6.3](#463-pension_fund)). Der
Schritt entfällt fachlich nie, aber wenn keine Spar-/Vorsorgeposten angehakt sind, weist er nur
darauf hin, dass der ganze Betrag auf dem Cash-Konto wächst.
Zwei bewusste Entscheide bleiben:
- **Einkommen wird pro Person erfasst**, nie als «Gemeinsam» in Paar-Plänen zählt
Haushalts-Einkommen nicht für die AHV ([9.9](#99-gemeinsames-einkommen-zählt-bei-paaren-nicht-für-die-ahv));
der Assistent räumt diese Falle von Anfang an aus.
- Technisch ist der Assistent **reine Orchestrierung bestehender Endpunkte** (Plan → Phase 1 →
Elemente samt Werten → Folgephasen; die Reihenfolge stellt sicher, dass die Phasen-Route die
Folgephasen korrekt vorbelegt). Kein neuer Endpunkt, keine Berechnungsänderung. Grenze: 9.23.
**Der Beispielplan** («Beispiel: Alex Muster», `src/lib/demoplan.ts`) ist ein fiktiver, voll
ausgefüllter Plan per Ein-Klick ebenfalls reine Orchestrierung. Die Übergangs-Entscheide
bleiben **absichtlich offen**: Der neue Nutzer sieht die Ampel («N offen») in Aktion und lernt
das wichtigste Konzept am Beispiel statt aus einer Erklärung.
Nach dem ersten Öffnen eines Plans mit Phasen startet einmalig die **Tour**
([3.7.8](#378-tour-und-nächste-schritte)).
## 3.3 Lebensphasen
### 3.3.1 Phase anlegen
@@ -2172,6 +2105,135 @@ genug reichen muss.
Referenz: `src/lib/retirement-decision.ts`, `src/components/RetirementPanel.tsx`,
`src/components/RetirementFields.tsx`, `src/lib/retirement.ts` (`planHorizonChange`).
## 3.14 Der FPT-Assistent
### 3.14.1 Warum er die «Nächsten Schritte» ersetzt
Die frühere Karte leitete AB, was zu tun wäre -- und liess einen damit allein. Sie konnte
sagen «4 Übergangs-Entscheide offen», aber nicht, was ein Übergangs-Entscheid überhaupt ist
oder in welcher Reihenfolge man vorgeht.
Der Assistent führt stattdessen. Sieben Schritte, jeder mit einem eigenen Werkzeug und einer
Seite davor, die erklärt, worum es geht:
| # | Schritt | Was dabei entsteht |
|---|---|---|
| 1 | **Bestandsaufnahme** | alle Elemente mit ihrem heutigen Stand |
| 2 | **Eckdaten und Pensionsplanung** | Planungshorizont; je Person Erwerbsende und Bezugszeitpunkte |
| 3 | **Lebensphasen** | die Zeitachse, an den Fixpunkten aus Schritt 2 ausgerichtet |
| 4 | **Erwerbsjahre planen** | Sparquoten und Übergänge bis zur Pensionierung |
| 5 | **Pensionierung planen** | Rente oder Kapital, Verwendung, die Übergänge dorthin |
| 6 | **Ruhestand planen** | Bezüge aus dem Vermögen, die restlichen Übergänge |
| 7 | **Feinschliff** | reine Information: was jetzt noch möglich ist |
Die Reihenfolge ist nicht beliebig. Sie beginnt mit dem, was **feststeht** (was habe ich?),
geht dann zu dem, was man **entscheidet** (wann höre ich auf?), und erst danach zu dem, was
sich daraus **ergibt** (wie teile ich ein?). Genau deshalb steht die Bestandsaufnahme vor der
Zeitachse -- und genau deshalb brauchte es die Element-Stammdaten (3.14.3).
### 3.14.2 Der Haken ist manuell -- der Stand daneben nicht
Zwei Gestaltungsentscheide, die zusammengehören:
1. **Abgehakt wird von Hand.** Wann jemand mit einem Schritt fertig ist, ist eine Einschätzung
und keine Messgrösse. «Genug geplant» kann das Tool nicht wissen.
2. **Daneben steht der abgeleitete Stand** (`stepStatus`): «0 Lebensphasen», «2 Elemente»,
«Planungshorizont fehlt». Ein abgehakter Schritt ohne Substanz fällt damit auf, ohne dass
das Tool den Haken verweigert.
Gesperrt wird nur das **Werkzeug**, nie die Selbstauskunft (`stepBlockedReason`): Die
Phasenplanung ohne Planungshorizont wäre gegenstandslos, die Übergangs-Schritte ohne Phasen
ebenso. Der Haken bleibt trotzdem jederzeit setzbar.
Erledigte Schritte rutschen nach unten und werden blass -- oben steht immer das Nächste. Die
Kachel ist **gelb**, solange etwas offen ist, und **grün**, wenn alle sieben stehen.
### 3.14.3 Element-Stammdaten: Bestand vor Zeitachse
Bis 0.35 lagen alle Elementwerte unter `phaseValues[phaseId]`. Ohne Phase gab es keinen
Schlüssel -- eine Bestandsaufnahme als erster Schritt war damit unmöglich.
Seit 0.36 trennt `FinancialElement.baseData` zwei Dinge, die nie dasselbe waren:
| gehört zum **Element** (`baseData`) | gehört zur **Phase** (`phaseValues`) |
|---|---|
| Bestand bei Planbeginn, Kaufpreis, Anfangshypothek, Restschuld | Sparraten, Amortisation, Bezugsraten |
| Ausgangs-Annahmen: Rendite, Wertsteigerung, Zinssatz | abweichende Werte einzelner Phasen |
Das ist nicht nur ein Kunstgriff für Schritt 1. Ein Startwert war **nie** «phase-1-spezifisch»
-- er ist der Stand am Anfang der Planung, und dass er in Phase 1 stand, war eine Eigenheit
der Speicherung. Nebenbei löst der Umbau eine alte Unstimmigkeit: Die Feld-Vererbung
([3.12.4](#3124-punkt-a-aus-vorphase-übernehmen)) hatte in Phase 1 nichts, von dem sie hätte
erben können, und fiel auf 0. Die Stammdaten sind jetzt die **Wurzel** dieser Kette:
```
eigener Phasenwert -> aus der Vorphase geerbt -> Stammdaten -> 0
```
In der Matrix erscheinen Elemente ohne Lebensphasen mit ihren Stammdaten; Endwerte gibt es
erst, wenn eine Phase eine Dauer vorgibt.
### 3.14.4 Fixpunkte: jeder Bezugsbeginn erzwingt eine Phasengrenze
`phaseplan.ts` kannte bis 0.35 genau einen Fixpunkt je Person -- das Erwerbsende. Seit AHV,
Pensionskasse und jedes 3a-Konto ein eigenes Bezugsalter haben, sind es bis zu vier:
| Fixpunkt | Quelle |
|---|---|
| Erwerbsende | `Person.retirementAge` |
| AHV-Rentenbeginn | `ahvStartAge(retirementDecision)` |
| PK-Bezug | `pkWithdrawalAge` (neu in 0.36) |
| je 3a-Konto | `withdrawalAge` |
Der Grund ist derselbe wie beim Erwerbsende: Die Rechnung leitet Erwerbsstatus und Bezüge am
**Phasenbeginn** ab. Fiele ein Bezug mitten in eine Phase, rutschte er auf die nächste Grenze
-- unter Umständen Jahre später, und die Zahlen wären still falsch. `maxPhaseDuration` zählt
die Fixpunkte deshalb mit. Ereignisse im selben Jahr teilen sich **eine** Grenze und werden
dort mehrfach beschriftet.
Die Folge ist ehrlich, aber spürbar: Ein Paar mit gestaffelten Bezügen kommt schnell auf acht
bis zehn Pflichtphasen.
### 3.14.5 Der Bildschirm
Zwei farblich getrennte Hälften, damit sichtbar ist: **oben stellst du ein, unten siehst du
das Ergebnis.**
Oben vier gleichrangige Kacheln plus die Zeitachse über die volle Breite:
| Kachel | Inhalt |
|---|---|
| **Grundeinstellungen** | plan-weit (Personen, Startjahr, Inflation) und szenario-eigen (Horizont, Endjahr, Endalter, Pensionsalter). Stift zum Bearbeiten; je Person ein Knopf **«Pensionsplanung»** |
| **Kennzahlen** | **Vermögen heute** (Summe der Stammdaten -- die einzige Zahl, die schon vor jeder Zeitplanung etwas aussagt), Endvermögen nominal und real, Reichweite |
| **Schnellaktionen** | Neues Szenario · Tour · Änderungshistorie · Rechenwege · CSV-Export |
| **Assistent** | siehe 3.14.1 |
Das **Pensionsalter lässt sich in den Grundeinstellungen nicht ändern** -- es erzeugt eine
Phasengrenze und gehört deshalb in die Pensionsplanung.
Unten die Matrix. Die frühere Aktionsleiste darüber ist verschwunden: **«+ Element»,
«+ Phase», der Nominal/Real-Umschalter, die Plan/Ist-Umschaltung und «Alle auf-/zuklappen»
sitzen in der Ecke oben links der Matrix.** Sie steuern die Matrix und lagen vorher lose
darüber wie Aktionen der ganzen Seite.
### 3.14.6 Die Tour
Ein grosses Popup mit einem **nachgebauten** Bildschirm und erfundenen Zahlen, in neun
Schritten erklärt. Der letzte führt zum Assistenten.
Das frühere Spotlight legte sich über die echte Ansicht. Zwei Nachteile liessen sich nicht
beheben: Auf einem frisch angelegten, leeren Plan gab es kaum etwas hervorzuheben -- also
gerade dann nicht, wenn die Tour am nötigsten ist. Und das Ausschneiden kämpfte dauernd mit
Scroll-Containern und Z-Ebenen ([9.24](#924-tour-spotlight-ohne-engine)).
Der Preis ist bekannt und bewusst in Kauf genommen: **Die Attrappe muss bei UI-Änderungen
nachgeführt werden.** Dafür funktioniert die Tour ab der ersten Sekunde und unabhängig davon,
was im Plan schon steht.
Referenz: `src/lib/assistant.ts`, `src/components/Assistant.tsx`,
`src/components/AssistantStepDialog.tsx`, `src/components/AssistantSteps.tsx`,
`src/components/Tour.tsx`.
---
# 4. Berechnungsmodell
@@ -3923,7 +3985,7 @@ wird der Plan neu geladen; die Berechnung kommt immer vom Server.
| `SpecView` | 65 | Rendert `SPEZIFIKATION.md` (via `/api/spec`) als lesbares Dokument, inkl. Sprungmarken aus den Rechenwegen |
| `InfoBubble` | 28 | Hilfe-Tooltip |
| `ui` | ~370 | UI-Primitiven: Button, Modal, InspectorShell, Confirm, Toast, Skeleton, EmptyState ([3.7.6](#376-sprache-und-ui-primitiven)/[3.7.7](#377-inspector-panel-statt-modals)) |
| `PlanWizard` | ~560 | Geführter Plan-Assistent in fünf Schritten, abschnittsbasierte Phasenplanung ([3.2.8](#328-geführter-assistent-und-beispielplan)) |
| `Assistant` · `AssistantStepDialog` · `AssistantSteps` | ~1200 | Der FPT-Assistent: Fortschrittskachel, Erklärseiten und die Werkzeuge der sieben Schritte ([3.14](#314-der-fpt-assistent)) |
| `Tour` | ~140 | Interaktive Kurz-Tour über die Planansicht ([3.7.8](#378-tour-und-nächste-schritte)) |
| `CommandPalette` | ~130 | Befehls-Palette Ctrl/Cmd+K ([3.7.9](#379-befehls-palette-und-sparklines)) |
| `DistributionDialogs` | ~460 | Verteil-Werkzeuge für Kapital und Spar-/Verzehrquote ([3.6.10](#3610-verteil-werkzeuge)) |
@@ -4244,7 +4306,7 @@ Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-, API- oder E2E-Tests
| `migrations.test.ts` | 3 | spielt alle Migrationen gegen echtes PostgreSQL (PGlite) ein; prüft zusätzlich die V7-**Datenübernahme** (Basisszenario gewinnt, Pensionsalter bleiben szenario-eigen) |
| `rate-limit.test.ts` | 6 | Fixed-Window: erlaubt bis Limit, blockt danach, startet nach Fensterablauf neu, trennt je Schlüssel; Client-IP aus X-Forwarded-For / X-Real-IP |
| `csv.test.ts` | 7 | BOM, alle vier Blöcke, jedes Element als Zeile, Beginn-/Ende-/Übergangsspalten, Entscheid im Klartext, ein Eintrag je Planjahr, Maskierung von `;` und `"` |
| **Total** | **315** | |
| **Total** | **334** | |
## 8.2 Testfälle
+145
View File
@@ -0,0 +1,145 @@
// Der FPT-Assistent und die Element-Stammdaten (0.36).
//
// Der Assistent selbst ist UI; getestet wird hier die reine Logik dahinter -- der Fortschritt,
// der abgeleitete Stand und die Voraussetzungen je Schritt. Dazu die Eigenschaft, wegen der
// `baseData` überhaupt existiert: Ein Element muss sich erfassen lassen, BEVOR es Phasen gibt.
import { describe, it, expect } from "vitest";
import {
ASSISTANT_STEP_COUNT,
ASSISTANT_STEPS,
emptyProgress,
normalizeProgress,
stepBlockedReason,
stepStatus,
} from "@/lib/assistant";
import { computePlan } from "@/lib/calculations";
import type { PhaseData } from "@/lib/elements";
import type { PlanInput } from "@/lib/types";
function plan(over: Partial<PlanInput> = {}): PlanInput {
return {
id: "plan",
name: "T",
householdType: "SINGLE",
inflationRateDefault: 0,
initialCash: 0,
startYear: 2026,
planningHorizonYears: null,
persons: [{ id: "A", role: "PERSON_A", name: null, age: 40, retirementAge: 65 }],
phases: [],
elements: [],
...over,
};
}
function el(category: string, baseData: PhaseData = {}, phaseValues: Record<string, PhaseData> = {}) {
return {
id: `e-${category}-${Math.random().toString(36).slice(2, 7)}`,
category: category as never,
name: category,
ownerRole: "PERSON_A" as never,
orderIndex: 0,
phaseValues,
transitionValues: {},
baseData,
};
}
describe("Assistenten-Fortschritt", () => {
it("startet mit sieben offenen Schritten", () => {
expect(emptyProgress()).toHaveLength(ASSISTANT_STEP_COUNT);
expect(emptyProgress().some(Boolean)).toBe(false);
expect(ASSISTANT_STEPS).toHaveLength(ASSISTANT_STEP_COUNT);
});
it("verwirft kaputte Daten, statt daran zu scheitern", () => {
// Der Fortschritt liegt als JSON in der Datenbank -- ein alter oder manipulierter Stand
// darf die Ansicht nicht zerlegen.
expect(normalizeProgress(null)).toEqual(emptyProgress());
expect(normalizeProgress([true, false])).toEqual(emptyProgress()); // falsche Länge
expect(normalizeProgress("kaputt")).toEqual(emptyProgress());
const gut = [true, false, true, false, true, false, true];
expect(normalizeProgress(gut)).toEqual(gut);
});
});
describe("Abgeleiteter Stand neben dem Haken", () => {
// Der Haken ist bewusst manuell -- aber der Assistent soll nichts Falsches behaupten.
// Deshalb steht daneben, was tatsächlich da ist.
it("zählt die erfassten Elemente", () => {
expect(stepStatus(plan(), 0)).toBe("noch nichts erfasst");
expect(stepStatus(plan({ elements: [el("INCOME")] }), 0)).toBe("1 Element");
expect(stepStatus(plan({ elements: [el("INCOME"), el("EXPENSE")] }), 0)).toBe("2 Elemente");
});
it("meldet den fehlenden Planungshorizont", () => {
expect(stepStatus(plan(), 1)).toBe("Planungshorizont fehlt");
expect(stepStatus(plan({ planningHorizonYears: 40 }), 1)).toContain("40 Jahre");
});
it("zählt die Lebensphasen -- auch wenn es keine gibt", () => {
expect(stepStatus(plan(), 2)).toBe("0 Lebensphasen");
expect(
stepStatus(
plan({ phases: [{ id: "p1", sequenceNumber: 1, name: "p1", durationYears: 5, cashTransition: {} }] }),
2
)
).toBe("1 Lebensphase");
});
});
describe("Voraussetzungen je Schritt", () => {
it("sperrt die Phasenplanung, solange der Horizont fehlt", () => {
expect(stepBlockedReason(plan(), 2)).toContain("Planungshorizont");
expect(stepBlockedReason(plan({ planningHorizonYears: 40 }), 2)).toBeNull();
});
it("sperrt die Übergangs-Schritte, solange es keine Phasen gibt", () => {
const p = plan({ planningHorizonYears: 40 });
for (const step of [3, 4, 5]) expect(stepBlockedReason(p, step)).toContain("Lebensphasen");
});
it("lässt die Bestandsaufnahme immer zu -- sie braucht keine Zeitachse", () => {
expect(stepBlockedReason(plan(), 0)).toBeNull();
expect(stepBlockedReason(plan(), 1)).toBeNull();
});
});
describe("Element-Stammdaten (baseData)", () => {
// Der Grund für den ganzen Umbau: Eine Bestandsaufnahme ist keine Aussage über eine Phase.
it("erlaubt Elemente ohne jede Lebensphase", () => {
const p = plan({ elements: [el("OTHER_ASSET", { startValue: 100000, expectedReturn: 3 })] });
const c = computePlan(p);
expect(c.phases).toHaveLength(0);
expect(c.ruinAge).toBeNull();
});
it("dient der ersten Phase als Startwert", () => {
const p = plan({
phases: [{ id: "p1", sequenceNumber: 1, name: "p1", durationYears: 10, cashTransition: {} }],
elements: [el("OTHER_ASSET", { startValue: 100000, expectedReturn: 0 }, { p1: {} })],
});
const asset = computePlan(p).phases[0].elements.find((e) => e.category === "OTHER_ASSET")!;
expect(asset.startValue).toBe(100000);
});
it("ist die Wurzel der Vererbung -- Phase 1 erbt die Rendite von dort", () => {
// Vor 0.36 hatte Phase 1 nichts, von dem sie hätte erben können, und fiel auf 0.
const p = plan({
phases: [{ id: "p1", sequenceNumber: 1, name: "p1", durationYears: 10, cashTransition: {} }],
elements: [el("OTHER_ASSET", { startValue: 100000, expectedReturn: 5 }, { p1: {} })],
});
const asset = computePlan(p).phases[0].elements.find((e) => e.category === "OTHER_ASSET")!;
expect(asset.endValue).toBe(Math.round(100000 * Math.pow(1.05, 10)));
});
it("weicht einem erfassten Phasenwert -- der gewinnt", () => {
const p = plan({
phases: [{ id: "p1", sequenceNumber: 1, name: "p1", durationYears: 10, cashTransition: {} }],
elements: [el("OTHER_ASSET", { startValue: 100000, expectedReturn: 5 }, { p1: { expectedReturn: 0 } })],
});
const asset = computePlan(p).phases[0].elements.find((e) => e.category === "OTHER_ASSET")!;
expect(asset.endValue).toBe(100000);
});
});