diff --git a/SPEZIFIKATION.md b/SPEZIFIKATION.md index 823f687..1f28935 100644 --- a/SPEZIFIKATION.md +++ b/SPEZIFIKATION.md @@ -4,7 +4,7 @@ | | | |---|---| | **Dokument** | Funktionale und Technische Spezifikation FPT | -| **Version** | 0.21 | +| **Version** | 0.22 | | **Datum** | 2026-07-18 | | **Status** | Lebendes Dokument | | **Codestand** | Arbeitsstand nach `1d046e9` inkl. zwei Monte-Carlo-Fragestellungen (Branch `main`) | @@ -17,6 +17,7 @@ | Version | Datum | Autor | Änderung | |---|---|---|---| +| 0.22 | 2026-07-20 | Claude (Opus 4.8) | **Fehlerbehebung: Cash-Vorbelegung im Ist-Wizard.** Der Wizard für die effektiven Werte zeigte als geplanten Cash-Bestand den Stand am **Phasenende** statt am gewählten Stichtag -- in einer Phase von 2026 bis 2036 also für 2031 den Wert von 2036. Ursache: Der Dialog las `cashBridge.cashEnd`, weil `computePlan` den Cash-Bestand bisher nur **je Phase** auswies. `YearPoint` trägt neu ein Feld `cash` (Stand am Jahresende), analog zu `wealthNominal`; der Wizard liest daraus. Die Vorbelegung der ELEMENTE war nie betroffen -- die stammte schon immer aus dem Jahresverlauf. Zwei Regressionstests decken den gemeldeten Fall ab (208 -> 210). Rein additiv, die 43 Golden Tests laufen unverändert. | | 0.21 | 2026-07-20 | Claude (Opus 4.8) | **Effektive Werte / Plan-Ist-Vergleich** (Roadmap Nr. 5, neue Kapitel 3.9 und 9.29). Macht aus dem Planer ein Monitoring-Werkzeug. Neuer Knopf **«Effektive Werte»** auf Plan-Ebene: Liste der Erfassungen plus Wizard in zwei Schritten (Stichtag, dann alle Elemente **aller** Szenarien inkl. **Cash**, Einkommen und Ausgaben, vorbelegt mit dem Planwert für dieses Jahr). Ein Ist-Satz hängt am **Plan**, nicht am Szenario – die Wirklichkeit ist dieselbe, egal wogegen man sie hält; die Zuordnung läuft über die Herkunfts-Kette `sourceElementId`. Das exakte Datum steht in Liste und Zeitachse, für die Rechnung zählt nur die **Jahreszahl**. **Zweiter Rechenlauf:** `computePlan` nimmt neu `{ actuals }`; die Werte schnappen in **jedem** erfassten Jahr auf die Realität und laufen von dort planmässig weiter (Lücken fallen auf die Plandaten zurück). Ohne die Option verhält sich die Funktion exakt wie bisher – die 43 Golden Tests laufen unverändert. Der Sprung wird als eigene Brückenposition `actualsCorrection` geführt (Vermögens- **und** Cash-Brücke): Eine Planabweichung ist keine Rendite, und ohne diese Zeile ginge die Zerlegung im Ist-Jahr nicht mehr auf. **Matrix:** zweiter Umschalter «Plan» / «Effektiv»; im Ist-Modus steht neben dem Wert die **Abweichung** zum Plan, farbig – bewusst kein «beide», das wären mit nominal/real acht Zahlen je Zelle (Begründung 9.29). **Zeitachse:** Marker je erfasstem Jahr, der jüngste farbig, ältere blass. **Alle vier Analysewerkzeuge** erhalten eine einheitliche Leiste (nominal/real als **Einfach**auswahl, Plan/Effektiv); im Vermögensverlauf kommt die Planlinie **gestrichelt** als Referenz dazu, max. vier Serien. **Monte-Carlo:** Der Zielbetrag dreht mit real/nominal mit und wird entsprechend beschriftet; eine Zeile weist aus, ab welchem Jahr simuliert wird – die Jahre davor sind durch Ist-Werte belegt und werden nicht gewürfelt. Das Startjahr ist **abgeleitet, nicht eingebbar**: Ein frei gesetztes Jahr würde Jahre als sicher behandeln, die nie erfasst wurden. Ein Ist-Satz erzeugt **keine** Szenario-Version – er ist eine Beobachtung, keine Planänderung. Neue Tabelle `ActualsSet` (Migration gegen echtes Postgres verifiziert), neue Endpunkte unter `/api/plans//actuals`, neue Module `actuals.ts` und `dataview.ts`; 27 Tests ergänzt (181 → 208). | | 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//versions`. Neue Kapitel 3.8 und 9.28; 25 Tests ergänzt (139 → 164). | @@ -3276,7 +3277,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 | | `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 | -| `actuals.test.ts` | 20 | Zuordnung über Kopie-Ketten, Sprung im Ist-Jahr, Weiterrechnen ab dem Ist-Wert, geschlossene Brücken, Fluss-Rückrechnung | +| `actuals.test.ts` | 22 | Zuordnung über Kopie-Ketten, Sprung im Ist-Jahr, Weiterrechnen ab dem Ist-Wert, geschlossene Brücken, Fluss-Rückrechnung | | `dataview.test.ts` | 7 | beide Sichten in einem Zug, Rückfall ohne Ist-Werte, Abweichungsmessung | | `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 | @@ -3286,7 +3287,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 | | `diff.test.ts` | 9 | Abweichungs-Erkennung gegen das Eltern-Szenario | | `migrations.test.ts` | 1 | spielt alle Migrationen gegen echtes PostgreSQL (PGlite) ein | -| **Total** | **208** | | +| **Total** | **210** | | ## 8.2 Testfälle diff --git a/src/components/ActualsDialog.tsx b/src/components/ActualsDialog.tsx index d12b297..6befa61 100644 --- a/src/components/ActualsDialog.tsx +++ b/src/components/ActualsDialog.tsx @@ -178,11 +178,10 @@ export function ActualsDialog({ if (!base) return 0; const planYear = toPlanYear(year, base.plan.startYear); if (planYear === null) return 0; + // Der Stand am Ende GENAU DIESES Jahres -- nicht am Ende der Phase. In einer Phase über + // zehn Jahre, in der sich das Cash-Konto füllt, sind das zwei völlig verschiedene Zahlen. const computed = computePlan(base.plan); - const ph = computed.phases.find( - (p) => planYear <= computed.phases.slice(0, p.sequenceNumber).reduce((s, x) => s + x.durationYears, 0) - ); - return Math.round(ph?.cashBridge.cashEnd ?? 0); + return computed.yearly.find((y) => y.year === planYear)?.cash ?? 0; }, [base, year]); function startWizard() { diff --git a/src/lib/actuals.test.ts b/src/lib/actuals.test.ts index 020a88f..55de2d6 100644 --- a/src/lib/actuals.test.ts +++ b/src/lib/actuals.test.ts @@ -220,6 +220,64 @@ describe("Berechnung mit Ist-Werten", () => { }); }); +describe("Cash-Bestand je Jahr", () => { + // Genau der gemeldete Fall: EINE lange Phase, in der sich das Cash-Konto kontinuierlich + // füllt. Die Vorbelegung im Wizard muss den Stand des GEWÄHLTEN Jahres zeigen -- nicht den + // Stand am Phasenende. Zuvor las der Dialog `cashBridge.cashEnd` und zeigte in der + // Phasenmitte den Endwert an. + function savingPlan(): PlanInput { + return { + id: "s", + name: "T", + householdType: "SINGLE", + inflationRateDefault: 0, + initialCash: 100, + startYear: 2026, + persons: [{ id: "A", role: "PERSON_A", name: null, age: 40, retirementAge: 70 }], + phases: [{ id: "p1", sequenceNumber: 1, name: "Erwerb", durationYears: 10, cashTransition: {} }], + elements: [ + { + id: "inc", + category: "INCOME", + name: "Lohn", + ownerRole: "HOUSEHOLD", + orderIndex: 1, + phaseValues: { p1: { amount: 100000 } }, + transitionValues: {}, + sourceElementId: null, + }, + { + id: "exp", + category: "EXPENSE", + name: "Leben", + ownerRole: "HOUSEHOLD", + orderIndex: 2, + phaseValues: { p1: { amount: 90000 } }, + transitionValues: {}, + sourceElementId: null, + }, + ], + } as unknown as PlanInput; + } + + it("weist den Cash-Stand für JEDES Jahr aus, nicht nur je Phase", () => { + const computed = computePlan(savingPlan()); + // 10'000 Überschuss pro Jahr auf 100 Startguthaben. + expect(computed.yearly.find((y) => y.year === 1)!.cash).toBe(10100); + expect(computed.yearly.find((y) => y.year === 5)!.cash).toBe(50100); + expect(computed.yearly.find((y) => y.year === 10)!.cash).toBe(100100); + }); + + it("liegt in der Phasenmitte deutlich unter dem Phasen-Endwert", () => { + // Die eigentliche Regression: Mitte und Ende dürfen nicht dieselbe Zahl sein. + const computed = computePlan(savingPlan()); + const mid = computed.yearly.find((y) => y.year === 5)!.cash; + const phaseEnd = computed.phases[0].cashBridge.cashEnd; + expect(phaseEnd).toBe(computed.yearly.find((y) => y.year === 10)!.cash); + expect(mid).toBeLessThan(phaseEnd); + }); +}); + describe("rebaseFlow", () => { it("trifft im Ist-Jahr genau den erfassten Betrag", () => { // Basis so, dass basis * (1+idx)^(t-1) === Ist-Wert. diff --git a/src/lib/calculations.ts b/src/lib/calculations.ts index 9ac51a9..d4949c1 100644 --- a/src/lib/calculations.ts +++ b/src/lib/calculations.ts @@ -198,6 +198,11 @@ export interface YearPoint { // Vermögensverlauf über ALLE Jahre statt nur über die Phasengrenzen. wealthNominal: number; wealthReal: number; + // Cash-Bestand am Jahresende. Die Brücken führen Cash nur je PHASE (Anfang/Ende) -- für + // eine Aussage zu einem einzelnen Jahr (Vorbelegung der effektiven Werte, Kap. 3.9.2) + // reicht das nicht: In einer Phase über zehn Jahre ist der Phasen-Endwert etwas ganz + // anderes als der Stand in der Mitte. + cash: number; } export interface PlanComputed { @@ -751,6 +756,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp expenseReal: Math.round(expenseReal), wealthNominal: 0, wealthReal: 0, + cash: 0, }; yearly.push(yearPoint); @@ -907,6 +913,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp for (const d of debts) total += -d.owed; yearPoint.wealthNominal = Math.round(total); yearPoint.wealthReal = Math.round(total / (cumInfl[yearsBefore + t] || 1)); + yearPoint.cash = Math.round(cash); if (ruinAge === null && total < 0) ruinAge = personA.age + yearsBefore + t; }