diff --git a/SPEZIFIKATION.md b/SPEZIFIKATION.md index 3757940..823f687 100644 --- a/SPEZIFIKATION.md +++ b/SPEZIFIKATION.md @@ -4,7 +4,7 @@ | | | |---|---| | **Dokument** | Funktionale und Technische Spezifikation FPT | -| **Version** | 0.20 | +| **Version** | 0.21 | | **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.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). | | 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. | @@ -1357,6 +1358,126 @@ Referenz: `src/lib/versioning.ts` (reine Logik), `src/lib/versioning-db.ts` (Dat `src/components/VersionHistoryDialog.tsx`, `src/components/VersionMatrix.tsx`, `src/components/VersionPicker.tsx`. +## 3.9 Effektive Werte (Plan-/Ist-Vergleich) + +Roadmap Nr. 5. Macht aus dem Planer ein **Monitoring-Werkzeug**: Was ist tatsächlich +eingetreten, und was heisst das für den Rest der Planung? + +Knopf **«Effektive Werte»** auf Plan-Ebene → Liste der bisherigen Erfassungen → Wizard in zwei +Schritten. + +### 3.9.1 Ein Ist-Satz gehört zum Plan, nicht zum Szenario + +Das tatsächliche PK-Guthaben am 18.8.2026 ist **eine Zahl** – unabhängig davon, gegen welches +Szenario man sie hält. Ein Ist-Satz hängt deshalb am `Plan` und wird über dieselbe +Herkunfts-Kette (`sourceElementId`) auf die szenario-eigenen Element-IDs abgebildet, die auch +der Diff ([3.2.6](#326-abweichungs-markierung-diff)) und die Monte-Carlo-Gruppierung benutzen. +Erfasst wird also je **Wurzel-Element**. + +### 3.9.2 Der Wizard + +**Schritt 1 – Stichtag.** Exaktes Datum (z. B. 18. August 2026) plus optionale Notiz. Das +Datum erscheint in der Liste und auf der Zeitachse; für die Rechnung zählt **nur die +Jahreszahl**, weil der Rechenkern in ganzen Jahren ab Planbeginn arbeitet. Der Dialog sagt +das ausdrücklich. + +**Schritt 2 – Werte.** Alle Elemente **aller Szenarien** dieses Plans, zusammengefasst auf +ihre Wurzel, dazu das **Cash-Konto**. Vorbelegt mit dem Stand, den der Plan für dieses Jahr +vorsieht (aus dem Basisszenario; fehlt das Element dort, aus dem erstbesten Szenario, das es +kennt). Der Nutzer überschreibt nur, was tatsächlich abweicht. + +Zwei Arten von Werten, die sich verschieden verhalten: + +| Art | Elemente | Wirkung | +|---|---|---| +| **Bestand** | PK, 3a, Sonstiges Vermögen, Schulden, Cash | ersetzt den laufenden Stand | +| **Verkehrswert + Schuld** | Immobilie | zwei Felder: Wert und Resthypothek getrennt | +| **Fluss** | Einkommen, Ausgaben | nominaler **Jahresbetrag**; ersetzt die Basis für alle Folgejahre | + +**Die AHV erscheint nur, wenn die Rente zum Stichtag bereits läuft.** Vorher gibt es keinen +Stand, den man ablesen könnte – die Rente folgt der amtlichen Formel aus der Beitragskarriere +([4.4](#44-ahv-rente)). + +**Ist-Werte erfassen Werte, keine Entscheide.** Wenn der Plan die Immobilie verkauft, du sie +aber behalten hast, lässt sich das hier nicht ausdrücken – dafür ist ein Szenario da. + +### 3.9.3 Die zweite Berechnung + +Der Plan-Lauf bleibt **unangetastet**. Parallel läuft ein zweiter mit derselben Mechanik, aber +korrigierter Ausgangsbasis: In jedem Jahr, für das ein Ist-Satz erfasst wurde, schnappen die +Werte auf die Realität und laufen von dort planmässig weiter. **Alle** Sätze gehen ein, nicht +nur der jüngste. + +Beispiel aus der Anforderung: Fonds startet 2020 mit 100'000 bei 5 %. Ohne Ist-Daten steht +2022 rechnerisch 115'763. Wird für 2022 ein Ist-Wert von 120'000 erfasst, rechnet die Ist-Sicht +ab dort weiter und steht 2024 bei 132'300. Kommt für 2024 ein Wert von 140'000 dazu, springt +sie erneut. Genau das ist als Test hinterlegt. + +**Lücken fallen auf die Plandaten zurück.** Ein Element ohne erfassten Ist-Wert läuft +unverändert auf seiner Planlinie weiter – man muss nicht alles wissen, um etwas zu erfassen. + +Technisch: `computePlan(plan, sample?, { actuals })`. Ohne die Option verhält sich die +Funktion exakt wie bisher; die 43 Golden Tests laufen unverändert. + +**Der Sprung ist keine Rendite.** Die Differenz zwischen Plan und Wirklichkeit wird als eigene +Brückenposition `actualsCorrection` geführt (Vermögens- **und** Cash-Brücke). Würde man sie den +Kapitalerträgen zuschlagen, erschiene ein Planrückstand als Anlageverlust – und die Zerlegung +ginge im Ist-Jahr nicht mehr auf ([3.6.8](#367-detailansichten-je-element-und-je-lebensphase)). + +### 3.9.4 Anzeige in der Matrix + +Ein zweiter Umschalter neben nominal/real/beide, aber mit nur **zwei** Möglichkeiten: + +| Auswahl | Wirkung | +|---|---| +| **Plan** | wie bisher | +| **Effektiv** | die Ist-Zahlen, jeweils mit der **Abweichung** zum Plan daneben | + +Warum keine dritte Möglichkeit «beide»: Plan und Ist als Rohwerte nebeneinander wären bei +zusätzlich aktivem nominal/real **acht Zahlen je Zelle**. Stattdessen zeigt die Ist-Ansicht den +Wert und daneben klein die Differenz, grün oder rot ([9.29](#929-acht-zahlen-je-zelle--und-wie-wir-sie-vermeiden)). +Der Umschalter erscheint nur, wenn überhaupt Ist-Werte erfasst sind. + +**Nur die Abweichung trägt Farbe.** Die Beträge selbst bleiben neutral – sonst wird die Matrix +zum Ampelteppich, in dem nichts mehr heraussticht. + +### 3.9.5 Zeitachse + +Je erfasstem Jahr ein Marker. Der **jüngste** ist farbig, ältere blass – sie sind überholt, +aber nicht bedeutungslos. Ohne gesetztes Planstartjahr entfallen die Marker, weil es dann +keinen Kalenderbezug gibt. + +### 3.9.6 Die vier Analysewerkzeuge + +Grafiken, Live-Simulation, Monte-Carlo und Einflussfaktoren erhalten dieselbe Leiste: +**Werte** (nominal/real) und **Grundlage** (Plan/Effektiv), dazu die schon bestehende +Versionswahl. «Effektiv» ist deaktiviert, solange nichts erfasst ist. + +**Nominal/real ist in den Werkzeugen eine Einfachauswahl**, kein «beide» – anders als in der +Matrix. Begründung siehe [9.29](#929-acht-zahlen-je-zelle--und-wie-wir-sie-vermeiden). + +Besonderheiten: + +- **Vermögensverlauf:** Im Ist-Modus kommt die reine Planlinie **gestrichelt** als Referenz + dazu. Maximal vier Serien. +- **Monte-Carlo:** Der Zielbetrag **dreht mit** der gewählten Grösse (real/nominal) und wird + entsprechend beschriftet – sonst prüft man einen nominalen Zielbetrag gegen ein reales + Endvermögen. Ausserdem eine Zeile: *Simuliert ab ‹Jahr›; die Jahre davor sind durch deine + effektiven 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. +- **Einflussfaktoren:** Mit Ist-Werten wirken die Treiber nur noch auf die nicht belegten + Jahre. Die Balken fallen dadurch zu Recht kürzer aus. + +### 3.9.7 Verhältnis zur Versionierung + +Ein Ist-Satz ist eine **Beobachtung, keine Planänderung**: Er erzeugt **keine** Szenario-Version +([3.8](#38-versionierung-und-änderungshistorie)), und es gibt kein Wiederherstellen. Löschen +entfernt ihn ersatzlos; der Plan bleibt unberührt. Die beiden Historien bleiben getrennt. + +Referenz: `src/lib/actuals.ts`, `src/lib/dataview.ts`, `src/components/ActualsDialog.tsx`, +`src/components/AnalysisControls.tsx`. + --- # 4. Berechnungsmodell @@ -2559,7 +2680,7 @@ Referenz: `src/lib/livesim.ts`, `src/lib/sensitivity.ts` (`applyElementDriver`, FPT/ ├── prisma/ │ ├── schema.prisma Datenmodell -│ └── migrations/ 13 Migrationen (chronologisch, siehe 5.4.6) +│ └── migrations/ 14 Migrationen (chronologisch, siehe 5.4.6) ├── src/ │ ├── app/ │ │ ├── api/ Route Handlers (siehe Kapitel 6) @@ -2600,6 +2721,8 @@ PlanComputed ← an den Client geliefert | `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. | | `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. | +| `actuals.ts` | Effektive Werte: Zuordnung auf die Szenario-Elemente über die Herkunfts-Kette, Einspielen in den Rechenkern, Bestand/Fluss. Rein. | +| `dataview.ts` | Bündelt Plan-Sicht und Ist-Sicht für Matrix, Grafiken und Analysewerkzeuge. Rein. | | `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-db.ts` | Datenbank-Anbindung der Versionierung: Snapshot festhalten, Hauptversion, Wiederherstellen. Führt den Bauplan aus `versioning.ts` nur aus. | @@ -2815,6 +2938,7 @@ sondern zu leeren Werten. | `20260718090000_plan_scenario_hierarchy` | **V6**: `Plan` → `Scenario` (IDs erhalten), neuer Behälter `Plan`, `planId` → `scenarioId`, Herkunfts-Verweise | | `20260718140000_scenario_start_year` | `Scenario.startYear` (Kalenderjahr des Planbeginns), bestehende auf das laufende Jahr gesetzt | | `20260719210000_scenario_versioning` | Tabelle `ScenarioVersion` (Snapshot als JSONB, A.B eindeutig je Szenario) und `Scenario.currentMajor` | +| `20260720090000_actuals` | Tabelle `ActualsSet` (effektive Werte je Plan, Werte als JSONB, Cash separat) | **Zur V6-Migration:** Sie benennt die bisherige `Plan`-Tabelle in `Scenario` um – dadurch bleiben alle IDs und damit sämtliche Kind-Fremdschlüssel gültig. Für jedes bisherige @@ -2968,7 +3092,23 @@ SINGLE=1 / COUPLE=2 Personen. Legt Plan **und Basisszenario** an. `{ name }` – der Plan trägt nur noch den Namen. → 200 `{ plan: { id, name } }` ### `DELETE /api/plans/` -→ 200 `{ ok: true }`, Cascade über alle Szenarien. +→ 200 `{ ok: true }`, Cascade über alle Szenarien (inkl. Versionen und Ist-Sätzen). + +### `GET /api/plans//actuals` +Alle erfassten Ist-Sätze, neueste zuerst ([3.9](#39-effektive-werte-plan-ist-vergleich)): +```json +{ "sets": [ { "id", "recordedOn": "2026-08-18", "year": 2026, "comment", + "cash", "values": { "": { "value", "mortgage" } }, + "author", "createdAt" } ] } +``` + +### `POST /api/plans//actuals` +`{ recordedOn: "JJJJ-MM-TT", comment?, cash?, values }` – `year` wird aus dem Datum abgeleitet. +→ 201 `{ set: { id, year } }` + +### `DELETE /api/plans//actuals/` +→ 200 `{ ok: true }`. Ein Ist-Satz ist eine Beobachtung – es gibt weder Versionierung noch +Wiederherstellung. ## 6.3 Szenarien @@ -3136,6 +3276,8 @@ 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 | +| `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 | | `versioning-coverage.test.ts` | 3 | statischer Wächter: jeder schreibende Endpunkt löst eine Version aus | @@ -3144,7 +3286,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** | **181** | | +| **Total** | **208** | | ## 8.2 Testfälle @@ -3639,6 +3781,39 @@ Benutzer werden nie in einer Version zusammengefasst). verschwindet seine Historie mit ihm (Cascade). Das ist gewollt: Eine Historie ohne das Objekt, das sie beschreibt, wäre nicht wiederherstellbar. +## 9.29 Acht Zahlen je Zelle – und wie wir sie vermeiden + +Mit den effektiven Werten ([3.9](#39-effektive-werte-plan-ist-vergleich)) bekommt die Matrix +eine zweite Achse. Naiv kombiniert ergibt das je Zelle: nominal **und** real, Plan **und** Ist, +Phasenbeginn **und** Phasenende – **acht Zahlen**. Das ist keine Tabelle mehr, das ist ein +Zahlenfeld. + +Zwei Entscheide halten es lesbar, und sie fallen an den zwei Orten **verschieden** aus. + +**In der Matrix: Wert und Abweichung statt zweier Rohwerte.** Es gibt nur «Plan» oder +«Effektiv», kein «beide». Im Ist-Modus steht der Ist-Wert und daneben klein die Differenz zum +Plan, grün oder rot. Das beantwortet auch die bessere Frage: nicht «wie lauteten die zwei +Zahlen», sondern «wie weit bin ich weg». Nominal/real bleibt dort bei drei Möglichkeiten – es +sind Zahlen in einer Zelle, keine Linien in einem Bild. + +**Nur die Abweichung trägt Farbe.** Würde man die Beträge selbst einfärben, entstünde ein +Ampelteppich, in dem die eigentliche Aussage untergeht. + +**In den Grafiken: nominal/real wird zur Einfachauswahl.** Der Vermögensverlauf zeichnete +bisher je Serie **zwei** Linien (nominal durchgezogen, real gestrichelt). Mit Plan/Ist wären es +vier, bei zwei Szenarien acht. Das Stilbudget geht deshalb an die **wichtigere** Unterscheidung: +Plan gestrichelt, Ist durchgezogen – genau die «Plan-Linie vs. Ist-Linie», die die Roadmap +verlangt. Wer real sehen will, schaltet um, statt eine zweite Linie dazuzubekommen. + +**Nicht jede Grafik verträgt beides.** «Plan und Ist gleichzeitig» gibt es nur beim +Vermögensverlauf. Die Vermögensaufteilung zeigt schon Beginn **und** Ende je Phase als +gestapelte Balken – Plan und Ist daneben vervierfachte sie. Und die Grafik «Einkommen vs. +Ausgaben» lebt vom Band zwischen zwei Linien; ein zweites Paar darüber macht genau diese +Aussage unkenntlich. Beide zeigen deshalb nur die gewählte Grundlage. + +**Serienobergrenze vier.** Szenario mal Version mal Datenquelle wächst schnell; darüber hinaus +hilft keine Farbpalette mehr. + --- # 10. Glossar diff --git a/prisma/migrations/20260720090000_actuals/migration.sql b/prisma/migrations/20260720090000_actuals/migration.sql new file mode 100644 index 0000000..3d33d62 --- /dev/null +++ b/prisma/migrations/20260720090000_actuals/migration.sql @@ -0,0 +1,34 @@ +-- Effektive (Ist-)Werte, Roadmap Nr. 5 (SPEZIFIKATION 3.9). +-- +-- Ein Ist-Satz gehoert zum PLAN, nicht zum Szenario: Die Realitaet ist dieselbe, egal gegen +-- welches Szenario man sie haelt. Die Zuordnung auf die szenario-eigenen Element-IDs laeuft +-- ueber die Herkunfts-Kette (sourceElementId) in der Applikationsschicht. + +CREATE TABLE "ActualsSet" ( + "id" TEXT NOT NULL, + "planId" TEXT NOT NULL, + -- Exaktes Erfassungsdatum: erscheint in der Liste und auf der Zeitachse. + "recordedOn" DATE NOT NULL, + -- Kalenderjahr. NUR dieses geht in die Berechnung ein (der Rechenkern arbeitet in + -- ganzen Jahren ab Planbeginn). + "year" INTEGER NOT NULL, + "comment" TEXT, + -- Effektiver Cash-Bestand. Eigenes Feld, weil Cash kein FinancialElement ist. + "cash" DOUBLE PRECISION, + -- Werte je WURZEL-Element: { "": { "value": 120000, "mortgage": 400000 } } + "values" JSONB NOT NULL DEFAULT '{}', + "createdById" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ActualsSet_pkey" PRIMARY KEY ("id") +); + +-- Die Saetze werden immer nach Jahr sortiert gelesen (aeltester zuerst fuer die Rechnung). +CREATE INDEX "ActualsSet_planId_year_idx" ON "ActualsSet"("planId", "year"); + +ALTER TABLE "ActualsSet" ADD CONSTRAINT "ActualsSet_planId_fkey" + FOREIGN KEY ("planId") REFERENCES "Plan"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "ActualsSet" ADD CONSTRAINT "ActualsSet_createdById_fkey" + FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 885fffc..20fed9f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -34,6 +34,7 @@ model User { plans Plan[] versions ScenarioVersion[] + actuals ActualsSet[] } enum HouseholdType { @@ -88,6 +89,36 @@ model Plan { updatedAt DateTime @updatedAt scenarios Scenario[] + actuals ActualsSet[] +} + +// Ein erfasster Stand der WIRKLICHKEIT zu einem Stichtag (Roadmap Nr. 5). +// +// Haengt am PLAN, nicht am Szenario: Das tatsaechliche PK-Guthaben am 18.8.2026 ist eine +// Zahl, unabhaengig davon, gegen welches Szenario man sie haelt. Die Zuordnung auf die +// szenario-eigenen Element-IDs erfolgt ueber die Herkunfts-Kette (lib/actuals.ts). +model ActualsSet { + id String @id @default(cuid()) + planId String + plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade) + + // Exaktes Datum fuer Liste und Zeitachse; fuer die Rechnung zaehlt nur `year`. + recordedOn DateTime @db.Date + year Int + comment String? + + // Effektiver Cash-Bestand (Cash ist kein FinancialElement). + cash Float? + // Werte je Wurzel-Element: Record + values Json @default("{}") + + createdById String + createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([planId, year]) } // Die berechenbare Einheit: Grundprofil + Phasenkette + Elemente. Genau ein Szenario je diff --git a/src/app/api/plans/[planId]/actuals/[setId]/route.ts b/src/app/api/plans/[planId]/actuals/[setId]/route.ts new file mode 100644 index 0000000..049e624 --- /dev/null +++ b/src/app/api/plans/[planId]/actuals/[setId]/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { getCurrentUserId } from "@/lib/session"; + +// Löscht einen Ist-Satz. Ein Ist-Satz ist eine Beobachtung, keine Planänderung -- deshalb +// gibt es hier weder Versionierung noch Wiederherstellung. +export async function DELETE( + _request: NextRequest, + { params }: { params: Promise<{ planId: string; setId: string }> } +) { + const userId = await getCurrentUserId(); + if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); + const { planId, setId } = await params; + + const set = await prisma.actualsSet.findFirst({ + where: { id: setId, planId, plan: { userId } }, + }); + if (!set) return NextResponse.json({ error: "Datensatz nicht gefunden." }, { status: 404 }); + + await prisma.actualsSet.delete({ where: { id: setId } }); + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/plans/[planId]/actuals/route.ts b/src/app/api/plans/[planId]/actuals/route.ts new file mode 100644 index 0000000..d307476 --- /dev/null +++ b/src/app/api/plans/[planId]/actuals/route.ts @@ -0,0 +1,82 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { prisma } from "@/lib/db"; +import { getCurrentUserId } from "@/lib/session"; + +// Ein Ist-Wert je Wurzel-Element. Beide Felder optional: Wer eine Zahl nicht kennt, lässt sie +// weg -- die Lücke fällt in der Berechnung auf die Plandaten zurück. +const valueSchema = z.object({ + value: z.number().min(-1_000_000_000).max(1_000_000_000).optional(), + mortgage: z.number().min(0).max(1_000_000_000).optional(), +}); + +const createSchema = z.object({ + recordedOn: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Datum im Format JJJJ-MM-TT"), + comment: z.string().max(500).optional(), + cash: z.number().min(-1_000_000_000).max(1_000_000_000).nullable().optional(), + values: z.record(z.string(), valueSchema), +}); + +async function ownedPlan(planId: string, userId: string) { + return prisma.plan.findFirst({ where: { id: planId, userId } }); +} + +// Alle Ist-Sätze eines Plans, neueste zuerst. +export async function GET(_request: NextRequest, { params }: { params: Promise<{ planId: string }> }) { + const userId = await getCurrentUserId(); + if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); + const { planId } = await params; + + const plan = await ownedPlan(planId, userId); + if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 }); + + const rows = await prisma.actualsSet.findMany({ + where: { planId }, + orderBy: [{ year: "desc" }, { recordedOn: "desc" }], + include: { createdBy: { select: { username: true } } }, + }); + + return NextResponse.json({ + sets: rows.map((r) => ({ + id: r.id, + recordedOn: r.recordedOn.toISOString().slice(0, 10), + year: r.year, + comment: r.comment, + cash: r.cash, + values: r.values, + author: r.createdBy.username, + createdAt: r.createdAt, + })), + }); +} + +export async function POST(request: NextRequest, { params }: { params: Promise<{ planId: string }> }) { + const userId = await getCurrentUserId(); + if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); + const { planId } = await params; + + const plan = await ownedPlan(planId, userId); + if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 }); + + const parsed = createSchema.safeParse(await request.json()); + if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 }); + + const { recordedOn, comment, cash, values } = parsed.data; + // Für die Berechnung zählt nur die Jahreszahl -- der Rechenkern arbeitet in ganzen Jahren + // ab Planbeginn. Das exakte Datum bleibt für Liste und Zeitachse erhalten. + const year = Number(recordedOn.slice(0, 4)); + + const created = await prisma.actualsSet.create({ + data: { + planId, + recordedOn: new Date(`${recordedOn}T00:00:00.000Z`), + year, + comment: comment?.trim() || null, + cash: typeof cash === "number" ? cash : null, + values, + createdById: userId, + }, + }); + + return NextResponse.json({ set: { id: created.id, year: created.year } }, { status: 201 }); +} diff --git a/src/app/api/scenarios/[scenarioId]/route.ts b/src/app/api/scenarios/[scenarioId]/route.ts index 1243146..9fbecf6 100644 --- a/src/app/api/scenarios/[scenarioId]/route.ts +++ b/src/app/api/scenarios/[scenarioId]/route.ts @@ -26,10 +26,33 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{ if (parent) base = toPlanInput(parent); } + // Ist-Werte des Plans plus die Element-Herkunft ALLER Szenarien: Nur damit lässt sich die + // auf Wurzel-IDs erfasste Realität auf dieses Szenario abbilden (siehe lib/actuals.ts). + // Die Ist-Rechnung selbst passiert im Browser -- computePlan ist rein. + const [actualsRows, siblings] = await Promise.all([ + prisma.actualsSet.findMany({ + where: { planId: scenario.planId }, + orderBy: [{ year: "asc" }, { recordedOn: "asc" }], + }), + prisma.scenario.findMany({ + where: { planId: scenario.planId }, + select: { elements: { select: { id: true, sourceElementId: true } } }, + }), + ]); + return NextResponse.json({ plan: planInput, computed, base, + actuals: actualsRows.map((r) => ({ + id: r.id, + recordedOn: r.recordedOn.toISOString().slice(0, 10), + year: r.year, + comment: r.comment, + cash: r.cash, + values: r.values, + })), + elementOrigins: siblings.flatMap((s) => s.elements), meta: { id: scenario.id, planId: scenario.planId, diff --git a/src/components/ActualsDialog.tsx b/src/components/ActualsDialog.tsx new file mode 100644 index 0000000..d12b297 --- /dev/null +++ b/src/components/ActualsDialog.tsx @@ -0,0 +1,508 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { ArrowLeft, ArrowRight, CalendarClock, Plus, Trash2, X } from "lucide-react"; +import { InfoBubble } from "@/components/InfoBubble"; +import { Button, useConfirm, useToast } from "@/components/ui"; +import { api } from "@/lib/api-client"; +import { computePlan } from "@/lib/calculations"; +import { CATEGORY_LABELS, CATEGORY_ORDER } from "@/lib/elements"; +import { formatChf } from "@/lib/format"; +import { actualKindOf, resolveActuals, toPlanYear, type ActualsSetInput } from "@/lib/actuals"; +import { resolveRootElementId } from "@/lib/montecarlo"; +import type { ElementCategory } from "@/lib/elements"; +import type { PlanInput } from "@/lib/types"; + +interface StoredSet extends ActualsSetInput { + author: string; +} + +// Ein Eingabefeld im Wizard -- je Wurzel-Element eines oder (bei Immobilien) zwei. +interface Row { + rootId: string; + name: string; + category: ElementCategory; + scenarioNames: string[]; + planValue: number; // Vorbelegung aus dem Basisszenario für das gewählte Jahr + planMortgage?: number; + kind: ReturnType; +} + +const dt = (iso: string) => + new Date(`${iso}T00:00:00Z`).toLocaleDateString("de-CH", { day: "2-digit", month: "long", year: "numeric" }); + +interface LoadedScenario { + id: string; + name: string; + isBase: boolean; + plan: PlanInput; +} + +export function ActualsDialog({ + planId, + planName, + scenarioMetas, + initial, + onClose, + onChanged, +}: { + planId: string; + planName: string; + // Alle Szenarien dieses Plans (nur Kopfdaten) -- die Pläne werden hier nachgeladen. + scenarioMetas: { id: string; name: string; isBase: boolean }[]; + // Das bereits geöffnete Szenario, damit der Dialog sofort etwas anzeigen kann. + initial: LoadedScenario; + onClose: () => void; + onChanged: () => void; +}) { + const [loaded, setLoaded] = useState>(() => ({ [initial.id]: initial })); + + // Die übrigen Szenarien nachladen: Ein Ist-Satz gilt für ALLE, also müssen auch Elemente + // erscheinen, die es nur in einem Nebenszenario gibt. + useEffect(() => { + const missing = scenarioMetas.filter((m) => m.id !== initial.id); + if (missing.length === 0) return; + let cancelled = false; + (async () => { + try { + const entries = await Promise.all( + missing.map(async (m) => { + const data = await api.get<{ plan: PlanInput }>(`/api/scenarios/${m.id}`); + return [m.id, { id: m.id, name: m.name, isBase: m.isBase, plan: data.plan }] as const; + }) + ); + if (!cancelled) setLoaded((prev) => ({ ...prev, ...Object.fromEntries(entries) })); + } catch { + // Fehlende Nebenszenarien sind verschmerzbar -- der Wizard zeigt dann weniger Zeilen. + } + })(); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [scenarioMetas.map((m) => m.id).join(","), initial.id]); + + const scenarios = useMemo( + () => scenarioMetas.map((m) => loaded[m.id]).filter((s): s is LoadedScenario => !!s), + [scenarioMetas, loaded] + ); + const [sets, setSets] = useState(null); + const [error, setError] = useState(null); + const [mode, setMode] = useState<"list" | "wizard">("list"); + const [step, setStep] = useState<1 | 2>(1); + const [busy, setBusy] = useState(false); + const confirm = useConfirm(); + const toast = useToast(); + + // Schritt 1 + const [recordedOn, setRecordedOn] = useState(() => new Date().toISOString().slice(0, 10)); + const [comment, setComment] = useState(""); + // Schritt 2 + const [values, setValues] = useState>({}); + const [cash, setCash] = useState(0); + + const base = scenarios.find((s) => s.isBase) ?? scenarios[0]; + const year = Number(recordedOn.slice(0, 4)); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const data = await api.get<{ sets: StoredSet[] }>(`/api/plans/${planId}/actuals`); + if (!cancelled) setSets(data.sets); + } catch (e) { + if (!cancelled) setError(e instanceof Error ? e.message : "Konnte nicht geladen werden."); + } + })(); + return () => { + cancelled = true; + }; + }, [planId]); + + // Herkunft aller Elemente über alle Szenarien -- Grundlage der Wurzel-Auflösung. + const origins = useMemo( + () => scenarios.flatMap((s) => s.plan.elements.map((e) => ({ id: e.id, sourceElementId: e.sourceElementId ?? null }))), + [scenarios] + ); + + // Alle Elemente ALLER Szenarien, zusammengefasst auf ihre Wurzel. Vorbelegt mit dem + // berechneten Stand des Basisszenarios im gewählten Jahr; fehlt das Element dort, wird es + // aus dem erstbesten Szenario geholt, das es kennt. + const rows: Row[] = useMemo(() => { + const sourceById = new Map(origins.map((o) => [o.id, o.sourceElementId])); + const byRoot = new Map(); + + const ordered = [base, ...scenarios.filter((s) => s.id !== base?.id)].filter(Boolean); + for (const sc of ordered) { + const planYear = toPlanYear(year, sc.plan.startYear); + const computed = computePlan(sc.plan); + for (const el of sc.plan.elements) { + const kind = actualKindOf(el.category); + const root = resolveRootElementId(el.id, sourceById); + + // Jahresstand aus dem Verlauf: genau der Wert, den der Plan für dieses Jahr vorsieht. + const yearly = computed.phases + .flatMap((p) => p.elements.filter((e) => e.elementId === el.id).flatMap((e) => e.yearly)) + .find((y) => y.year === planYear); + + // Die AHV ist nur erfassbar, wenn die Rente zum Stichtag bereits läuft -- vorher gibt + // es keinen Stand, den man ablesen könnte. + if (el.category === "AHV" && !(yearly && yearly.value > 0)) continue; + + const existing = byRoot.get(root); + if (existing) { + if (!existing.scenarioNames.includes(sc.name)) existing.scenarioNames.push(sc.name); + continue; + } + byRoot.set(root, { + rootId: root, + name: el.name, + category: el.category, + scenarioNames: [sc.name], + planValue: Math.round(Math.abs(yearly?.value ?? 0)), + planMortgage: kind === "PROPERTY" ? Math.round(yearly?.mortgage ?? 0) : undefined, + kind, + }); + } + } + + return [...byRoot.values()].sort((a, b) => { + const ca = CATEGORY_ORDER.indexOf(a.category); + const cb = CATEGORY_ORDER.indexOf(b.category); + return ca !== cb ? ca - cb : a.name.localeCompare(b.name, "de-CH"); + }); + }, [scenarios, base, origins, year]); + + // Der geplante Cash-Bestand im gewählten Jahr -- Vorbelegung für das Cash-Feld. + const planCash = useMemo(() => { + if (!base) return 0; + const planYear = toPlanYear(year, base.plan.startYear); + if (planYear === null) return 0; + 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); + }, [base, year]); + + function startWizard() { + setValues({}); + setCash(planCash); + setComment(""); + setStep(1); + setMode("wizard"); + } + + // Beim Wechsel auf Schritt 2 mit den Planwerten vorbelegen -- der Nutzer überschreibt nur, + // was tatsächlich abweicht. + function goToStep2() { + const prefill: Record = {}; + for (const r of rows) { + prefill[r.rootId] = + r.kind === "PROPERTY" ? { value: r.planValue, mortgage: r.planMortgage ?? 0 } : { value: r.planValue }; + } + setValues(prefill); + setCash(planCash); + setStep(2); + } + + async function reload() { + const data = await api.get<{ sets: StoredSet[] }>(`/api/plans/${planId}/actuals`); + setSets(data.sets); + } + + async function save() { + setBusy(true); + try { + await api.post(`/api/plans/${planId}/actuals`, { recordedOn, comment: comment.trim() || undefined, cash, values }); + toast("success", `Effektive Werte für ${dt(recordedOn)} erfasst.`); + await reload(); + setMode("list"); + onChanged(); + } catch (e) { + toast("error", e instanceof Error ? e.message : "Speichern fehlgeschlagen."); + } finally { + setBusy(false); + } + } + + async function remove(set: StoredSet) { + const ok = await confirm({ + title: "Datensatz löschen?", + message: `Die effektiven Werte vom ${dt(set.recordedOn)} werden entfernt. Der Plan selbst bleibt unverändert.`, + confirmLabel: "Löschen", + danger: true, + }); + if (!ok) return; + try { + await api.delete(`/api/plans/${planId}/actuals/${set.id}`); + await reload(); + onChanged(); + toast("success", "Datensatz gelöscht."); + } catch (e) { + toast("error", e instanceof Error ? e.message : "Löschen fehlgeschlagen."); + } + } + + // Wie viele Elemente weichen ab? Kleine Orientierungshilfe in der Liste. + const deviationCount = (set: ActualsSetInput) => { + const resolved = resolveActuals([set], base?.plan ?? scenarios[0].plan, origins); + return resolved.length === 0 ? 0 : Object.keys(resolved[0].byElementId).length; + }; + + const setRow = (rootId: string, patch: { value?: number; mortgage?: number }) => + setValues((prev) => ({ ...prev, [rootId]: { ...prev[rootId], ...patch } })); + + return ( +
+
e.stopPropagation()} className="ui-pop flex w-full max-w-4xl flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl"> +
+
+

+ Effektive Werte +

+

+ Plan «{planName}». Was tatsächlich eingetreten ist – der Plan selbst bleibt unverändert. +

+
+ +
+ + {mode === "list" && ( + <> +
+ Ein Datensatz hält fest, wie es an einem Stichtag wirklich aussah. + Die Berechnung läuft dann ein zweites Mal: gleiche Mechanik, aber ab jedem erfassten Jahr mit den + echten Zahlen. Werte, die du weglässt, laufen unverändert auf ihrer Planlinie weiter. + Ein Datensatz gilt für alle Szenarien dieses Plans – die + Wirklichkeit ist dieselbe, egal wogegen man sie hält. +
+ + {error &&

{error}

} + {!sets && !error &&

Wird geladen…

} + + {sets && sets.length === 0 && ( +

+ Noch keine effektiven Werte erfasst. +

+ )} + + {sets && sets.length > 0 && ( +
+ {sets.map((s, i) => ( +
+
+
+ {dt(s.recordedOn)} + {i === 0 && ( + + aktuellster + + )} +
+
+ rechnet ab {s.year} · {deviationCount(s)} Werte · {s.author} + {typeof s.cash === "number" && ` · Cash ${formatChf(s.cash)}`} +
+ {s.comment &&
«{s.comment}»
} +
+ +
+ ))} +
+ )} + +
+ +
+ + )} + + {mode === "wizard" && step === 1 && ( + <> +
Schritt 1 von 2 · Stichtag
+
+
+ + setRecordedOn(e.target.value)} + className="w-full rounded-lg border border-border bg-surface px-2.5 py-1.5 text-sm text-fg" + /> +
+
+ + setComment(e.target.value)} + placeholder="z. B. «nach Jahresabschluss»" + className="w-full rounded-lg border border-border bg-surface px-2.5 py-1.5 text-sm text-fg" + /> +
+
+

+ Gerechnet wird ab dem Jahr {year}. Die Vorbelegung im nächsten + Schritt zeigt, was dein Plan für dieses Jahr vorsieht – du überschreibst nur, was tatsächlich anders ist. +

+
+ + +
+ + )} + + {mode === "wizard" && step === 2 && ( + <> +
+ Schritt 2 von 2 · Werte per {dt(recordedOn)} +
+ +
+ + + + + + + + + + + + + + + + + + + {rows.map((r, i) => { + const header = i === 0 || rows[i - 1].category !== r.category ? r.category : null; + const v = values[r.rootId] ?? {}; + return ( + setRow(r.rootId, patch)} + /> + ); + })} + +
ElementLaut Plan {year}Effektiv
+ Cash +
Cash-Konto{formatChf(planCash)} + +
+
+ +

+ Einkommen und Ausgaben bitte als Jahresbetrag erfassen (nominal, wie tatsächlich + geflossen). Bei Immobilien zählt der Verkehrswert und die Restschuld getrennt. Was du auf dem + Planwert stehen lässt, wird als «keine Abweichung» gewertet. +

+ +
+ + +
+ + )} +
+
+ ); +} + +function FragmentRow({ + header, + row, + value, + onChange, +}: { + header: ElementCategory | null; + row: Row; + value: { value?: number; mortgage?: number }; + onChange: (patch: { value?: number; mortgage?: number }) => void; +}) { + return ( + <> + {header && ( + + + {CATEGORY_LABELS[header]} + + + )} + + +
{row.name}
+ {row.scenarioNames.length > 1 && ( +
gilt für {row.scenarioNames.join(", ")}
+ )} + {row.kind === "FLOW" &&
Jahresbetrag
} + + + {formatChf(row.planValue)} + {row.kind === "PROPERTY" && ( +
Hypothek {formatChf(row.planMortgage ?? 0)}
+ )} + + + onChange({ value: v })} /> + {row.kind === "PROPERTY" && ( +
+ onChange({ mortgage: v })} + label="Hypothek" + /> +
+ )} + + + + ); +} + +function NumInput({ value, onChange, label }: { value: number; onChange: (v: number) => void; label?: string }) { + return ( +
+ {label && {label}} + onChange(Math.round(Number(e.target.value) || 0))} + className="w-32 rounded border border-border bg-surface px-2 py-1 text-right text-sm text-fg" + /> +
+ ); +} + diff --git a/src/components/AnalysisControls.tsx b/src/components/AnalysisControls.tsx new file mode 100644 index 0000000..beb169e --- /dev/null +++ b/src/components/AnalysisControls.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { InfoBubble } from "@/components/InfoBubble"; +import { computePlan } from "@/lib/calculations"; +import { + resolveActuals, + latestPlanYear, + type ActualsSetInput, + type ElementOrigin, + type ResolvedActuals, +} from "@/lib/actuals"; +import { DATA_SOURCE_OPTIONS, type DataSource } from "@/lib/dataview"; +import type { PlanComputed } from "@/lib/calculations"; +import type { PlanInput } from "@/lib/types"; + +// In den Analysewerkzeugen ist nominal/real eine EINFACHauswahl, kein "beide". +// +// Grund: Der Vermögensverlauf zeichnet je Serie ohnehin schon eine Linie; mit "beide" wären +// es zwei, mit Plan/Ist vier und bei zwei Szenarien acht. Das Stilbudget wird stattdessen +// für Plan (gestrichelt) gegen Ist (durchgezogen) ausgegeben -- das ist der Vergleich, um +// den es geht (siehe SPEZIFIKATION 9.29). +export type Metric = "nominal" | "real"; + +export const METRIC_OPTIONS: { value: Metric; label: string }[] = [ + { value: "nominal", label: "Nominal" }, + { value: "real", label: "Real" }, +]; + +export interface AnalysisBasis { + metric: Metric; + setMetric: (m: Metric) => void; + source: DataSource; + setSource: (s: DataSource) => void; + // Die für die gewählte Quelle massgebende Rechnung. + computed: PlanComputed; + // Immer die reine Plan-Rechnung -- Referenzlinie in den Grafiken. + planComputed: PlanComputed; + // Ist-Rechnung, null solange nichts erfasst ist. + actualComputed: PlanComputed | null; + hasActuals: boolean; + // Auf dieses Szenario aufgeloeste Ist-Saetze -- fuer Werkzeuge, die selbst rechnen. + resolvedActuals: ResolvedActuals[]; + // Erstes Jahr, das nicht mehr durch Ist-Werte belegt ist (Monte-Carlo-Startpunkt). + simStartPlanYear: number; + simStartCalendarYear: number | null; +} + +export function useAnalysisBasis( + plan: PlanInput, + actuals: ActualsSetInput[], + origins: ElementOrigin[], + initialMetric: Metric = "nominal" +): AnalysisBasis { + const [metric, setMetric] = useState(initialMetric); + const [source, setSource] = useState("PLAN"); + + const resolved = useMemo(() => resolveActuals(actuals, plan, origins), [actuals, plan, origins]); + const planComputed = useMemo(() => computePlan(plan), [plan]); + const actualComputed = useMemo( + () => (resolved.length === 0 ? null : computePlan(plan, undefined, { actuals: resolved })), + [plan, resolved] + ); + + const hasActuals = actualComputed !== null; + const effectiveSource: DataSource = hasActuals ? source : "PLAN"; + const latest = latestPlanYear(resolved); + + return { + metric, + setMetric, + resolvedActuals: effectiveSource === "ACTUAL" ? resolved : [], + source: effectiveSource, + setSource, + computed: effectiveSource === "ACTUAL" && actualComputed ? actualComputed : planComputed, + planComputed, + actualComputed, + hasActuals, + // Im Ist-Modus beginnt die Simulation NACH dem jüngsten erfassten Jahr: Was erfasst ist, + // ist bekannt und darf nicht gewürfelt werden. + simStartPlanYear: effectiveSource === "ACTUAL" && latest !== null ? latest + 1 : 1, + simStartCalendarYear: + plan.startYear == null + ? null + : plan.startYear + (effectiveSource === "ACTUAL" && latest !== null ? latest : 0), + }; +} + +// Einheitliche Leiste für alle vier Werkzeuge, damit die Bedienung überall dieselbe ist. +export function AnalysisBar({ + basis, + onChange, +}: { + basis: AnalysisBasis; + // Wird nach jeder Umstellung gerufen -- die Werkzeuge verwerfen damit alte Ergebnisse. + onChange?: () => void; +}) { + const pick = (fn: () => void) => () => { + fn(); + onChange?.(); + }; + + return ( +
+ + + +
+ ); +} diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 8bec43f..5f9178f 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -8,6 +8,7 @@ import { Dices, FileText, FolderKanban, + CalendarClock, GitBranch, History, LayoutDashboard, @@ -26,6 +27,9 @@ import { Dashboard } from "@/components/Dashboard"; import { MonteCarloDialog } from "@/components/MonteCarloDialog"; import { SensitivityDialog } from "@/components/SensitivityDialog"; import { LiveSimDialog } from "@/components/LiveSimDialog"; +import { ActualsDialog } from "@/components/ActualsDialog"; +import { buildViews } from "@/lib/dataview"; +import type { ActualsSetInput, ElementOrigin } from "@/lib/actuals"; import { VersionHistoryDialog } from "@/components/VersionHistoryDialog"; import { SpecView } from "@/components/SpecView"; import { SystemParametersView } from "@/components/SystemParametersView"; @@ -56,6 +60,10 @@ interface ScenarioDetail { computed: PlanComputed; base: PlanInput | null; // Eltern-Szenario als Vergleichsbasis meta: ScenarioMeta & { planName: string }; + // Effektive Werte des PLANS plus die Element-Herkunft aller Szenarien -- daraus entsteht + // im Browser der zweite Rechenlauf (siehe lib/dataview.ts). + actuals: ActualsSetInput[]; + elementOrigins: ElementOrigin[]; } // Die Provider (Toast, Bestätigung) müssen UM die Shell liegen, damit deren Hooks @@ -90,6 +98,7 @@ function AppShellInner({ username }: { username: string }) { const [showSensitivity, setShowSensitivity] = useState(false); const [showLiveSim, setShowLiveSim] = useState(false); const [showHistory, setShowHistory] = useState(false); + const [showActuals, setShowActuals] = useState(false); const [showSystemParams, setShowSystemParams] = useState(false); const [showPlanTraces, setShowPlanTraces] = useState(false); const [showPalette, setShowPalette] = useState(false); @@ -210,6 +219,14 @@ function AppShellInner({ username }: { username: string }) { const activePlan = plans.find((p) => p.scenarios.some((s) => s.id === selectedScenarioId)) ?? null; + // Plan-Sicht und Ist-Sicht in einem Zug. Ohne erfasste Ist-Werte bleibt `actual` null und + // die Oberflaeche verhaelt sich exakt wie bisher. + const views = useMemo( + () => + detail ? buildViews(detail.plan, detail.actuals ?? [], detail.elementOrigins ?? []) : null, + [detail] + ); + // Aktionen der Befehls-Palette -- kontextabhängig (Analysen nur bei offenem Szenario). const paletteActions = useMemo(() => { const base: PaletteAction[] = [ @@ -426,6 +443,10 @@ function AppShellInner({ username }: { username: string }) { Grafiken + + + + {/* Startpunkt: bewusst abgeleitet statt eingebbar. Ein frei gesetztes Jahr würde + Jahre als sicher behandeln, die nie erfasst wurden. */} +

+ {basis.source === "ACTUAL" && basis.simStartCalendarYear + ? `Simuliert ab ${basis.simStartCalendarYear}. Die Jahre davor sind durch deine effektiven Werte belegt und werden nicht gewürfelt.` + : plan.startYear + ? `Simuliert ab ${plan.startYear} (Planbeginn).` + : "Simuliert ab Planbeginn."} +

+ {/* Erklärung */}

@@ -419,8 +438,18 @@ export function MonteCarloDialog({ {/* Zielbetrag */}

{ setManualTarget(v); diff --git a/src/components/PlanView.tsx b/src/components/PlanView.tsx index 3d18812..977d23c 100644 --- a/src/components/PlanView.tsx +++ b/src/components/PlanView.tsx @@ -46,6 +46,7 @@ import { PlanProfileFields, type ProfileDraft } from "@/components/PlanProfileFi import { MoneyField } from "@/components/FormField"; import { api } from "@/lib/api-client"; import { formatChf } from "@/lib/format"; +import { DATA_SOURCE_OPTIONS, type DataSource } from "@/lib/dataview"; import { CATEGORY_LABELS, CATEGORY_ORDER, @@ -107,7 +108,9 @@ type Panel = export function PlanView({ plan, - computed, + computed: planComputed, + actualComputed = null, + actualYears = [], diff, onChanged, onOpenSpec, @@ -115,12 +118,32 @@ export function PlanView({ }: { plan: PlanInput; computed: PlanComputed; + // Zweiter Rechenlauf mit den effektiven Werten; null, wenn keine erfasst sind. + actualComputed?: PlanComputed | null; + // Kalenderjahre mit Ist-Satz (Marker auf der Zeitachse). + actualYears?: number[]; // Abweichungen gegenüber dem Eltern-Szenario; null im Basisszenario (nichts zu markieren). diff: ScenarioDiff | null; onChanged: () => void; onOpenSpec?: (anchor: string) => void; onOpenSensitivity?: () => void; }) { + // Planzahlen oder effektive Zahlen (inkl. Abweichung). Bewusst zwei Möglichkeiten statt + // dreier: Plan UND Ist als Rohwerte nebeneinander wären mit nominal/real acht Zahlen je + // Zelle (siehe SPEZIFIKATION 9.29). + const [dataSource, setDataSource] = useState("PLAN"); + const hasActuals = actualComputed !== null; + const computed = dataSource === "ACTUAL" && actualComputed ? actualComputed : planComputed; + + // Planwert derselben Zelle -- Grundlage der Abweichung. In der Plan-Sicht null, dann zeigt + // die Zelle gar keine Abweichung an (statt eine von 0 zu behaupten). + const showDeviation = dataSource === "ACTUAL" && actualComputed !== null; + const planEndOf = (elementId: string | undefined, phaseId: string): number | null => { + if (!showDeviation || !elementId) return null; + const ph = planComputed.phases.find((p) => p.id === phaseId); + const el = ph?.elements.find((e) => e.elementId === elementId); + return el ? el.endValue : null; + }; const confirmDialog = useConfirm(); const toast = useToast(); // Markierungs-Klassen: geändert = gelb, neu = grün, entfernt = grau. @@ -366,6 +389,32 @@ export function PlanView({ ))}
real = kaufkraftbereinigt (Planbeginn) + + {/* Zweite Achse: Datenquelle. Erscheint nur, wenn es überhaupt Ist-Werte gibt -- + sonst wäre es ein Umschalter ohne Gegenstück. */} + {hasActuals && ( + <> + Zahlen +
+ {DATA_SOURCE_OPTIONS.map((o) => ( + + ))} +
+ {dataSource === "ACTUAL" && ( + Abweichung gegenüber Plan farbig + )} + + )}
@@ -373,6 +422,7 @@ export function PlanView({ phases={computed.phases} persons={personAxes} ruinAge={computed.ruinAge} + actualYears={actualYears} startYear={plan.startYear} />
@@ -629,7 +679,7 @@ export function PlanView({ ce?.locked ? "text-faint" : "text-fg" } ${cellDiff(el.id, col.phase.id)}`} > - {phaseCellContent(ce, col.phase, valueMode)} + {phaseCellContent(ce, col.phase, valueMode, planEndOf(ce?.elementId, col.phase.id))} ); } @@ -992,12 +1042,16 @@ function ValuePair({ deflatorStart, deflatorEnd, mode, + devEnd, }: { start: number; end: number; deflatorStart: number; deflatorEnd: number; mode: ValueMode; + // Abweichung des ENDwerts gegenüber dem Plan (nur in der Ist-Sicht gesetzt). Bewusst nur + // ein Wert statt Start und Ende: Die Zelle soll nicht zur zweiten Tabelle werden. + devEnd?: number | null; }) { const arrow = ; return ( @@ -1005,16 +1059,31 @@ function ValuePair({ {mode === "real" ? formatChf(realOf(start, deflatorStart)) : formatChf(start)} {arrow}{" "} {mode === "real" ? formatChf(realOf(end, deflatorEnd)) : formatChf(end)} + {mode === "both" && ( ({formatChf(realOf(start, deflatorStart))}) {arrow} ({formatChf(realOf(end, deflatorEnd))}) + )} ); } +// Die Abweichung trägt als einziges Element Farbe -- würde man die Beträge selbst einfärben, +// entstünde ein Ampelteppich, in dem nichts mehr heraussticht. +function Deviation({ value, deflator = 1 }: { value?: number | null; deflator?: number }) { + if (value == null || Math.round(value / (deflator || 1)) === 0) return null; + const v = Math.round(value / (deflator || 1)); + return ( + 0 ? "text-success" : "text-danger"}`}> + {v > 0 ? "▲ +" : "▼ −"} + {formatChf(Math.abs(v))} + + ); +} + // Einzelwert, gleiche Konvention. function ValueSingle({ value, deflator, mode }: { value: number; deflator: number; mode: ValueMode }) { return ( @@ -1032,15 +1101,20 @@ function ValueSingle({ value, deflator, mode }: { value: number; deflator: numbe function phaseCellContent( ce: ReturnType | undefined, phase: PhaseComputed, - mode: ValueMode + mode: ValueMode, + // Endwert desselben Elements in derselben Phase laut PLAN -- null in der Plan-Sicht. + planEnd?: number | null ): React.ReactNode { if (!ce) return "–"; if (ce.note) return ce.note; const isFlow = ce.category === "INCOME" || ce.category === "EXPENSE"; const dS = phase.cumulativeInflationStart; const dE = isFlow ? phase.flowDeflatorEnd : phase.cumulativeInflationEnd; + const devEnd = planEnd == null ? null : ce.endValue - planEnd; if (START_END_CATEGORIES.includes(ce.category) && (ce.startValue !== 0 || ce.endValue !== 0)) { - return ; + return ( + + ); } if ((ce.category === "AHV" || ce.category === "PENSION_FUND") && ce.startValue !== 0) { return ( diff --git a/src/components/SensitivityDialog.tsx b/src/components/SensitivityDialog.tsx index 59f424a..a2873c9 100644 --- a/src/components/SensitivityDialog.tsx +++ b/src/components/SensitivityDialog.tsx @@ -3,9 +3,11 @@ import { useMemo, useState } from "react"; import { Bar, BarChart, CartesianGrid, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; import { Tornado, X } from "lucide-react"; -import { RequiredNumberField, SelectField } from "@/components/FormField"; +import { RequiredNumberField } from "@/components/FormField"; import { InfoBubble } from "@/components/InfoBubble"; import { useVersionedPlan, VersionBar } from "@/components/VersionPicker"; +import { AnalysisBar, useAnalysisBasis } from "@/components/AnalysisControls"; +import type { ActualsSetInput, ElementOrigin } from "@/lib/actuals"; import { formatChf } from "@/lib/format"; import { computeTornado, @@ -35,12 +37,23 @@ function formatRange(low: number, high: number, unit: DriverDef["unit"]): string return `${sign(low)} ${suffix} → ${sign(high)} ${suffix}`; } -export function SensitivityDialog({ plan: currentPlan, onClose }: { plan: PlanInput; onClose: () => void }) { +export function SensitivityDialog({ + plan: currentPlan, + actuals = [], + origins = [], + onClose, +}: { + plan: PlanInput; + actuals?: ActualsSetInput[]; + origins?: ElementOrigin[]; + onClose: () => void; +}) { // Gerechnet wird wahlweise auf dem Arbeitsstand oder auf einer festgehaltenen Version. const { versionId, setVersionId, plan, loading: versionLoading } = useVersionedPlan(currentPlan.id, currentPlan); const available = useMemo(() => DRIVERS.filter((d) => d.applies(plan)), [plan]); - const [metric, setMetric] = useState("real"); + const basis = useAnalysisBasis(plan, actuals, origins, "real"); + const metric: TornadoMetric = basis.metric; const [drafts, setDrafts] = useState>({}); const [result, setResult] = useState(null); @@ -75,7 +88,10 @@ export function SensitivityDialog({ plan: currentPlan, onClose }: { plan: PlanIn checked.map((d) => { const dr = draftFor(d.id); return { id: d.id, low: Number(dr.low), high: Number(dr.high) }; - }) + }), + // Mit Ist-Werten wirken die Treiber nur noch auf die NICHT belegten Jahre -- was + // erfasst ist, steht fest. Die Balken fallen dadurch zu Recht kuerzer aus. + basis.resolvedActuals ) ); } @@ -143,22 +159,8 @@ export function SensitivityDialog({ plan: currentPlan, onClose }: { plan: PlanIn

- {/* Zielgrösse */} -
- { - setMetric(v); - setResult(null); - }} - options={[ - { value: "real", label: "Endvermögen real (kaufkraftbereinigt)" }, - { value: "nominal", label: "Endvermögen nominal" }, - ]} - /> -
+ {/* Zielgrösse und Datengrundlage */} + setResult(null)} /> {/* Parameter */}
diff --git a/src/components/Timeline.tsx b/src/components/Timeline.tsx index cb612f2..b375c5b 100644 --- a/src/components/Timeline.tsx +++ b/src/components/Timeline.tsx @@ -1,6 +1,6 @@ "use client"; -import { Flag } from "lucide-react"; +import { CalendarCheck, Flag } from "lucide-react"; import type { PhaseComputed } from "@/lib/calculations"; interface PersonAxis { @@ -19,11 +19,15 @@ export function Timeline({ persons, ruinAge, startYear, + actualYears = [], }: { phases: PhaseComputed[]; persons: PersonAxis[]; ruinAge?: number | null; startYear?: number | null; + // Kalenderjahre, für die effektive Werte erfasst sind (aufsteigend). Der jüngste Satz + // wird hervorgehoben, ältere bleiben blass -- sie sind überholt, aber nicht bedeutungslos. + actualYears?: number[]; }) { if (phases.length === 0 || persons.length === 0) return null; @@ -91,6 +95,42 @@ export function Timeline({
)} + {/* Marker für erfasste effektive Werte. Nur mit bekanntem Planstartjahr platzierbar -- + ohne Kalenderbezug gäbe es keine Position auf der Achse. */} + {startYear && + actualYears.map((y) => { + const age = minAge + (y - startYear); + if (age < minAge || age > maxAge) return null; + const isLatest = y === actualYears[actualYears.length - 1]; + return ( +
+ + + {y} + +
+
+ ); + })} + {/* Phasen-Segmente: Breite proportional zur Dauer, Einfärbung nach Phasentyp. */}
{segments.map((s, i) => { diff --git a/src/components/WealthChart.tsx b/src/components/WealthChart.tsx index 52ea651..34e9ec6 100644 --- a/src/components/WealthChart.tsx +++ b/src/components/WealthChart.tsx @@ -17,6 +17,9 @@ export interface TimelineSeries { label: string; color: string; computed: PlanComputed; + // Plandaten werden gestrichelt gezeichnet, effektive Daten durchgezogen. Das Stilbudget + // geht bewusst an Plan/Ist statt an nominal/real (siehe SPEZIFIKATION 9.29). + dashed?: boolean; } // Datenpunkte je Serie: JEDES Planjahr (nicht nur die Phasengrenzen), verortet auf dem Alter @@ -35,7 +38,13 @@ function pointsFor(computed: PlanComputed) { // Liniendiagramm: Gesamtvermögen (nominal + real) über das Alter. Unterstützt mehrere // überlagerte Pläne für den Szenario-Vergleich. -export function WealthChart({ series }: { series: TimelineSeries[] }) { +export function WealthChart({ + series, + metric = "nominal", +}: { + series: TimelineSeries[]; + metric?: "nominal" | "real"; +}) { if (series.length === 0 || series[0].computed.phases.length === 0) { return

Noch keine Phasen vorhanden.

; } @@ -47,8 +56,7 @@ export function WealthChart({ series }: { series: TimelineSeries[] }) { const row: Record = { age }; for (const s of withPoints) { const pt = s.points.find((p) => p.age === age); - row[`${s.label} (nominal)`] = pt ? pt.nominal : null; - row[`${s.label} (real)`] = pt ? pt.real : null; + row[s.label] = pt ? (metric === "real" ? pt.real : pt.nominal) : null; } return row; }); @@ -76,25 +84,15 @@ export function WealthChart({ series }: { series: TimelineSeries[] }) { {withPoints.map((s) => ( - ))} - {withPoints.map((s) => ( - ))} diff --git a/src/lib/actuals.test.ts b/src/lib/actuals.test.ts new file mode 100644 index 0000000..020a88f --- /dev/null +++ b/src/lib/actuals.test.ts @@ -0,0 +1,239 @@ +import { describe, it, expect } from "vitest"; +import { computePlan } from "@/lib/calculations"; +import { + actualKindOf, + actualsForYear, + latestPlanYear, + rebaseFlow, + resolveActuals, + toPlanYear, + type ActualsSetInput, +} from "@/lib/actuals"; +import type { PlanInput } from "@/lib/types"; + +// Plan ab 2020: ein Fonds mit 100'000 und 5 % Rendite, keine Zu-/Abflüsse. Das ist bewusst +// das Beispiel aus der Anforderung, nur in ganzen Franken. +function fundPlan(): PlanInput { + return { + id: "s", + name: "T", + householdType: "SINGLE", + inflationRateDefault: 0, + initialCash: 0, + startYear: 2020, + persons: [{ id: "A", role: "PERSON_A", name: null, age: 40, retirementAge: 65 }], + phases: [{ id: "p1", sequenceNumber: 1, name: "Erwerb", durationYears: 10, cashTransition: {} }], + elements: [ + { + id: "fonds", + category: "OTHER_ASSET", + name: "Fonds", + ownerRole: "HOUSEHOLD", + orderIndex: 1, + phaseValues: { p1: { startValue: 100000, expectedReturn: 5 } }, + transitionValues: {}, + sourceElementId: null, + }, + ], + } as unknown as PlanInput; +} + +const valueAt = (computed: ReturnType, elementId: string, year: number) => + computed.phases + .flatMap((p) => p.elements.filter((e) => e.elementId === elementId).flatMap((e) => e.yearly)) + .find((y) => y.year === year)?.value ?? null; + +const setAt = (year: number, values: Record, cash?: number): ActualsSetInput => ({ + id: `set-${year}`, + recordedOn: `${year}-08-18`, + year, + cash: cash ?? null, + values, +}); + +describe("toPlanYear", () => { + it("rechnet das Kalenderjahr auf das Planjahr um", () => { + expect(toPlanYear(2020, 2020)).toBe(1); + expect(toPlanYear(2026, 2020)).toBe(7); + }); + + it("liefert nichts ohne Planstartjahr", () => { + expect(toPlanYear(2026, null)).toBeNull(); + }); +}); + +describe("actualKindOf", () => { + it("trennt Bestände von Flüssen", () => { + expect(actualKindOf("OTHER_ASSET")).toBe("STOCK"); + expect(actualKindOf("PENSION_FUND")).toBe("STOCK"); + expect(actualKindOf("REAL_ESTATE")).toBe("PROPERTY"); + expect(actualKindOf("INCOME")).toBe("FLOW"); + expect(actualKindOf("EXPENSE")).toBe("FLOW"); + }); +}); + +describe("resolveActuals", () => { + const plan = fundPlan(); + + it("bildet Wurzel-IDs auf die Elemente des Szenarios ab", () => { + const res = resolveActuals([setAt(2022, { fonds: { value: 120000 } })], plan, plan.elements); + expect(res).toHaveLength(1); + expect(res[0].planYear).toBe(3); + expect(res[0].byElementId.fonds.value).toBe(120000); + }); + + it("findet das Element auch über eine Kopie-Kette hinweg", () => { + // Kind-Szenario: eigene Element-ID, zeigt über sourceElementId auf das Original. + const child: PlanInput = { + ...plan, + id: "child", + elements: [{ ...plan.elements[0], id: "fonds-kopie", sourceElementId: "fonds" }], + } as unknown as PlanInput; + + // Der Ist-Satz ist auf die WURZEL-ID erfasst -- er muss trotzdem greifen. + const res = resolveActuals([setAt(2022, { fonds: { value: 120000 } })], child, [...plan.elements, ...child.elements]); + expect(res[0].byElementId["fonds-kopie"].value).toBe(120000); + }); + + it("verwirft Sätze ausserhalb des Planzeitraums", () => { + const res = resolveActuals( + [setAt(2019, { fonds: { value: 1 } }), setAt(2099, { fonds: { value: 1 } })], + plan, + plan.elements + ); + expect(res).toEqual([]); + }); + + it("lässt Elemente ohne Ist-Wert weg -- sie laufen auf der Planlinie weiter", () => { + const res = resolveActuals([setAt(2022, {})], plan, plan.elements); + expect(res[0].byElementId).toEqual({}); + }); + + it("sortiert nach Planjahr", () => { + const res = resolveActuals( + [setAt(2024, { fonds: { value: 140000 } }), setAt(2022, { fonds: { value: 120000 } })], + plan, + plan.elements + ); + expect(res.map((r) => r.year)).toEqual([2022, 2024]); + }); +}); + +describe("actualsForYear / latestPlanYear", () => { + const plan = fundPlan(); + const res = resolveActuals( + [setAt(2022, { fonds: { value: 120000 } }), setAt(2024, { fonds: { value: 140000 } })], + plan, + plan.elements + ); + + it("findet den Satz des Jahres", () => { + expect(actualsForYear(res, 3)!.year).toBe(2022); + expect(actualsForYear(res, 4)).toBeNull(); + }); + + it("kennt das jüngste erfasste Planjahr", () => { + expect(latestPlanYear(res)).toBe(5); + expect(latestPlanYear([])).toBeNull(); + }); +}); + +describe("Berechnung mit Ist-Werten", () => { + const plan = fundPlan(); + + it("lässt die Plan-Sicht unangetastet", () => { + // Der wichtigste Test überhaupt: Ohne actuals-Option muss auf die Zahl dasselbe + // herauskommen wie vorher. + const a = computePlan(plan); + const b = computePlan(plan, undefined, {}); + expect(a.phases[0].endWealthNominal).toBe(b.phases[0].endWealthNominal); + }); + + it("springt im Ist-Jahr auf den erfassten Wert", () => { + const actuals = resolveActuals([setAt(2022, { fonds: { value: 120000 } })], plan, plan.elements); + const computed = computePlan(plan, undefined, { actuals }); + + // Planwert 2022 wäre 100'000 x 1.05^3 = 115'762. Erfasst sind 120'000. + expect(valueAt(computePlan(plan), "fonds", 3)).toBe(115763); + expect(valueAt(computed, "fonds", 3)).toBe(120000); + }); + + it("rechnet ab dem Ist-Wert planmässig weiter", () => { + const actuals = resolveActuals([setAt(2022, { fonds: { value: 120000 } })], plan, plan.elements); + const computed = computePlan(plan, undefined, { actuals }); + + // Zwei Jahre nach dem Sprung: 120'000 x 1.05^2 = 132'300 -- genau die Erwartung aus der + // Anforderung ("in 2024 wäre man dann bei ca. 132"). + expect(valueAt(computed, "fonds", 5)).toBe(132300); + }); + + it("springt bei mehreren Sätzen an jeder erfassten Stelle", () => { + const actuals = resolveActuals( + [setAt(2022, { fonds: { value: 120000 } }), setAt(2024, { fonds: { value: 140000 } })], + plan, + plan.elements + ); + const computed = computePlan(plan, undefined, { actuals }); + + expect(valueAt(computed, "fonds", 3)).toBe(120000); + expect(valueAt(computed, "fonds", 5)).toBe(140000); // statt 132'300 + expect(valueAt(computed, "fonds", 6)).toBe(147000); // 140'000 x 1.05 + }); + + it("führt den Sprung als eigene Position, nicht als Rendite", () => { + // Sonst erschiene eine Planabweichung als Anlageerfolg -- und die Brücke ginge nicht auf. + const actuals = resolveActuals([setAt(2022, { fonds: { value: 120000 } })], plan, plan.elements); + const withActuals = computePlan(plan, undefined, { actuals }).phases[0].wealthBridge; + const planOnly = computePlan(plan).phases[0].wealthBridge; + + // Planwert 2022 exakt: 100'000 x 1.05^3 = 115'762.50 -> Korrektur 4'237.50, gerundet 4'238. + expect(withActuals.actualsCorrection).toBe(4238); + // Die Rendite selbst bleibt unberührt: Der Sprung ist KEIN Anlageerfolg. Sie ist nur + // grösser, weil ab 2022 auf einem höheren Kapital verzinst wird. + expect(withActuals.investmentReturn).toBeGreaterThan(planOnly.investmentReturn); + expect(planOnly.actualsCorrection).toBe(0); + }); + + it("hält die Vermögensbrücke auch bei mehreren Sprüngen geschlossen", () => { + const actuals = resolveActuals( + [setAt(2022, { fonds: { value: 120000 } }), setAt(2024, { fonds: { value: 90000 } })], + plan, + plan.elements + ); + const bridge = computePlan(plan, undefined, { actuals }).phases[0].wealthBridge; + // Der zweite Sprung geht nach UNTEN -- die Korrektur muss negativ sein können. + expect(bridge.actualsCorrection).toBeLessThan(0); + // Der Restposten bleibt reine Rundung. Die Toleranz entspricht der, ab der die + // Detailansicht eine Fehlermeldung zeigt (ResidualNote: > 2 Franken). + expect(Math.abs(bridge.residual)).toBeLessThanOrEqual(2); + }); + + it("übernimmt den effektiven Cash-Bestand und hält die Cash-Brücke geschlossen", () => { + const actuals = resolveActuals([setAt(2022, {}, 55000)], plan, plan.elements); + const computed = computePlan(plan, undefined, { actuals }); + const cb = computed.phases[0].cashBridge; + + expect(cb.actualsCorrection).not.toBe(0); + expect(Math.abs(cb.residual)).toBeLessThanOrEqual(2); + // Der Cash-Bestand am Phasenende trägt den Sprung wirklich mit. + expect(computed.phases[0].cashBridge.cashEnd).toBe(55000); + }); +}); + +describe("rebaseFlow", () => { + it("trifft im Ist-Jahr genau den erfassten Betrag", () => { + // Basis so, dass basis * (1+idx)^(t-1) === Ist-Wert. + const basis = rebaseFlow(120000, 2, 5); + expect(basis * Math.pow(1.02, 4)).toBeCloseTo(120000, 6); + }); + + it("berücksichtigt bei Ausgaben zusätzlich die Teuerung", () => { + const basis = rebaseFlow(50000, 1, 3, 1.1); + expect(basis * Math.pow(1.01, 2) * 1.1).toBeCloseTo(50000, 6); + }); + + it("weicht nicht auf NaN aus, wenn kein Wachstum vorliegt", () => { + expect(rebaseFlow(1000, 0, 1)).toBe(1000); + expect(rebaseFlow(1000, -100, 3)).toBe(1000); + }); +}); diff --git a/src/lib/actuals.ts b/src/lib/actuals.ts new file mode 100644 index 0000000..466a128 --- /dev/null +++ b/src/lib/actuals.ts @@ -0,0 +1,151 @@ +// Effektive (Ist-)Werte -- Roadmap Nr. 5, Plan-/Ist-Vergleich. +// +// Grundgedanke: Der Plan bleibt unangetastet. Parallel dazu läuft eine ZWEITE Berechnung mit +// derselben Mechanik, aber korrigierter Ausgangsbasis: In jedem Jahr, für das ein Ist-Satz +// erfasst wurde, schnappen die Werte auf die Realität und laufen von dort planmässig weiter. +// +// Beispiel: Fonds startet 2020 mit 100 bei 5 % Rendite. Ohne Ist-Daten steht 2022 rechnerisch +// 110 da. Wird für 2022 ein Ist-Wert von 120 erfasst, rechnet die Ist-Sicht ab 2022 mit 120 +// weiter und steht 2024 bei ~132. Kommt für 2024 ein Ist-Wert von 140 dazu, springt sie dort +// erneut. +// +// Ein Ist-Satz gehört zum PLAN, nicht zum Szenario: Die Realität ist dieselbe, egal gegen +// welches Szenario man sie hält. Die Zuordnung auf die szenario-eigenen Element-IDs läuft +// über dieselbe Herkunfts-Kette (`sourceElementId`), die auch der Diff und die +// Monte-Carlo-Gruppierung benutzen. + +import { resolveRootElementId } from "@/lib/montecarlo"; +import type { ElementCategory } from "@/lib/elements"; +import type { PlanInput } from "@/lib/types"; + +// Was für ein Ist-Wert je Element erfasst werden kann. Bestände und Flüsse verhalten sich +// verschieden: Ein Bestand ersetzt den laufenden Stand, ein Fluss die Basis für alle +// Folgejahre. +export type ActualKind = "STOCK" | "FLOW" | "PROPERTY" | "NONE"; + +export function actualKindOf(category: ElementCategory): ActualKind { + switch (category) { + case "PENSION_FUND": + case "PILLAR_3A": + case "OTHER_ASSET": + return "STOCK"; + case "OTHER_DEBT": + return "STOCK"; // Restschuld + case "REAL_ESTATE": + return "PROPERTY"; // Verkehrswert UND Resthypothek + case "INCOME": + case "EXPENSE": + return "FLOW"; + case "AHV": + // Die Rente folgt der amtlichen Formel aus der Beitragskarriere. Erfassbar ist sie nur, + // wenn sie zum Stichtag bereits LÄUFT -- vorher gibt es keinen Stand. Das entscheidet + // sich am Plan, nicht an der Kategorie (siehe `actualFieldsFor`). + return "FLOW"; + } +} + +// Ein erfasster Wert je Element. +export interface ActualElementValue { + // Bestand bzw. Verkehrswert bei Immobilien; bei Flüssen der NOMINALE Jahresbetrag + // (Einkommen: was aufs Konto kam; Ausgaben: was tatsächlich ausgegeben wurde). + value?: number; + // Nur Immobilie: tatsächliche Restschuld. + mortgage?: number; +} + +// Ein Ist-Satz, wie er gespeichert wird. Schlüssel sind WURZEL-Element-IDs. +export interface ActualsSetInput { + id: string; + recordedOn: string; // exaktes Datum (ISO) -- nur für Liste und Zeitachse + year: number; // Kalenderjahr; nur dieses geht in die Rechnung ein + comment?: string | null; + cash?: number | null; + values: Record; +} + +// Auf ein konkretes Szenario aufgelöster Ist-Satz: Schlüssel sind die Element-IDs DIESES +// Szenarios, und das Kalenderjahr ist in ein Planjahr (1-basiert) umgerechnet. +export interface ResolvedActuals { + planYear: number; // 1 = erstes Planjahr + year: number; // Kalenderjahr (für Anzeige) + cash?: number; + byElementId: Record; +} + +// Kalenderjahr -> Planjahr. `startYear` ist das Kalenderjahr des ersten Planjahres. +export function toPlanYear(year: number, startYear: number | null | undefined): number | null { + if (!startYear) return null; + return year - startYear + 1; +} + +// Herkunft eines Elements: die lose Referenz auf sein Gegenstück im Eltern-Szenario. +export interface ElementOrigin { + id: string; + sourceElementId?: string | null; +} + +// Bildet die gespeicherten Ist-Sätze auf ein Szenario ab. +// +// `origins` enthält die Elemente ALLER Szenarien des Plans -- nur so löst sich die +// Herkunfts-Kette auch über ein übersprungenes Zwischen-Szenario hinweg auf. +export function resolveActuals( + sets: ActualsSetInput[], + scenario: PlanInput, + origins: ElementOrigin[] +): ResolvedActuals[] { + const sourceById = new Map(); + for (const o of origins) sourceById.set(o.id, o.sourceElementId ?? null); + // Sicherheitsnetz: Elemente des betrachteten Szenarios sind immer dabei. + for (const e of scenario.elements) if (!sourceById.has(e.id)) sourceById.set(e.id, e.sourceElementId ?? null); + + const totalYears = scenario.phases.reduce((s, p) => s + p.durationYears, 0); + + return sets + .flatMap((set) => { + const planYear = toPlanYear(set.year, scenario.startYear); + if (planYear === null) return []; + const byElementId: Record = {}; + for (const e of scenario.elements) { + const root = resolveRootElementId(e.id, sourceById); + const v = set.values[root]; + // Lücken fallen bewusst auf die Plandaten zurück: Ein Element ohne Ist-Wert läuft + // unverändert auf seiner Planlinie weiter. + if (v && (typeof v.value === "number" || typeof v.mortgage === "number")) { + byElementId[e.id] = v; + } + } + const out: ResolvedActuals = { planYear, year: set.year, byElementId }; + if (typeof set.cash === "number") out.cash = set.cash; + return [out]; + }) + // Ausserhalb des Plans liegende Sätze werden ignoriert -- sie hätten keinen Angriffspunkt. + .filter((r) => r.planYear >= 1 && r.planYear <= totalYears) + // Bei zwei Sätzen im selben Planjahr gewinnt der zuletzt erfasste. + .sort((a, b) => a.planYear - b.planYear); +} + +// Nachschlagen im Rechenkern: Gibt es für dieses Planjahr einen Ist-Satz? +export function actualsForYear(list: ResolvedActuals[], planYear: number): ResolvedActuals | null { + // Rückwärts, damit bei mehreren Sätzen im selben Jahr der letzte gewinnt. + for (let i = list.length - 1; i >= 0; i--) if (list[i].planYear === planYear) return list[i]; + return null; +} + +// Das jüngste erfasste Planjahr -- Startpunkt der Monte-Carlo-Simulation im Ist-Modus und +// Grundlage der Hervorhebung auf der Zeitachse. +export function latestPlanYear(list: ResolvedActuals[]): number | null { + return list.length === 0 ? null : Math.max(...list.map((r) => r.planYear)); +} + +// Rechnet einen Ist-Fluss auf die Basis zurück, mit der der Rechenkern arbeitet. +// +// Einkommen laufen als `basis * (1 + idx/100)^(t-1)`. Ist für Jahr t ein Ist-Wert erfasst, +// muss die Basis so gesetzt werden, dass die Formel in genau diesem Jahr den Ist-Wert trifft +// -- danach wächst sie planmässig weiter. Das ist gleichbedeutend damit, den Bezugspunkt der +// Reihe auf das Ist-Jahr zu legen, kommt aber ohne Eingriff in die Struktur aus. +export function rebaseFlow(actualValue: number, idxPercent: number, tInPhase: number, deflator = 1): number { + const growth = Math.pow(1 + idxPercent / 100, tInPhase - 1); + const divisor = growth * (deflator || 1); + if (!Number.isFinite(divisor) || divisor === 0) return actualValue; + return actualValue / divisor; +} diff --git a/src/lib/calculations.ts b/src/lib/calculations.ts index 2706d0e..9ac51a9 100644 --- a/src/lib/calculations.ts +++ b/src/lib/calculations.ts @@ -11,6 +11,7 @@ import { DEFAULT_PROPERTY_GAINS_TAX_RATE, } from "@/lib/constants"; import { num } from "@/lib/elements"; +import { actualsForYear, rebaseFlow, type ResolvedActuals } from "@/lib/actuals"; import type { ElementCategory } from "@/lib/elements"; import type { PersonRole, PlanInput } from "@/lib/types"; @@ -50,6 +51,9 @@ export interface ComputeOptions { // Standardmässig aus: die Monte-Carlo-Simulation ruft computePlan zehntausendfach auf // und darf von der Protokollierung nichts merken. explain?: boolean; + // Effektive (Ist-)Werte, auf DIESES Szenario aufgelöst (siehe `actuals.ts`). Ohne sie + // rechnet die Funktion exakt wie bisher -- die Plan-Sicht bleibt unangetastet. + actuals?: ResolvedActuals[]; } // Ein Datenpunkt pro Jahr JE ELEMENT -- Grundlage der Detailansicht (Roadmap Nr. 43). @@ -111,6 +115,10 @@ export interface WealthBridge { investmentReturn: number; // Rendite auf PK/3a/Sonstigem Vermögen propertyAppreciation: number; // Wertsteigerung der Liegenschaft pensionFundContribution: number; // PK-Beiträge: erhöhen das Vermögen, ohne Cash zu kosten + // Sprung auf die erfassten Ist-Werte (nur in der Ist-Sicht, sonst 0). Bewusst als eigene + // Position: Die Differenz zwischen Plan und Wirklichkeit ist KEINE Rendite und darf nicht + // als solche erscheinen -- ohne diese Zeile ginge die Brücke im Ist-Jahr nicht auf. + actualsCorrection: number; endWealth: number; // = endWealthNominal residual: number; // Rundungsdifferenz (Kontrollgrösse, sollte nahe 0 sein) } @@ -127,6 +135,7 @@ export interface CashBridge { savingRates: number; // 3a + Sparbeiträge (Abgang) debtRates: number; // Amortisationen + Tilgungen (Abgang) withdrawals: number; // Bezugsraten aus Sonstigem Vermögen (Zugang) + actualsCorrection: number; // Sprung auf den erfassten Ist-Cashbestand (sonst 0) cashEnd: number; residual: number; } @@ -332,6 +341,8 @@ function pct(v: number): string { export function computePlan(plan: PlanInput, sample?: PlanSample, options?: ComputeOptions): PlanComputed { const explain = options?.explain === true; + // Ohne Ist-Werte verhält sich die Funktion exakt wie bisher (die Golden Tests belegen es). + const actuals = options?.actuals; const phases = [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber); const persons = plan.persons; const personA = persons.find((p) => p.role === "PERSON_A") ?? persons[0]; @@ -703,6 +714,10 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp let savingRatesTotal = 0; let debtRatesTotal = 0; let withdrawalsTotal = 0; + // Sprung auf die Ist-Werte. Ohne Ist-Daten bleiben beide 0 und die Brücken rechnen + // exakt wie bisher. + let actualsCorrectionTotal = 0; + let actualsCashCorrectionTotal = 0; for (let t = 1; t <= duration; t++) { // Einkommen: nominal (Basis x (1+Lohnerhöhung)^(t-1)) + Renten (nominal fix). @@ -787,6 +802,58 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp if (t === 1) plannedSaveRate = fixedRatesTotal + debtRates; cash += quote - fixedRatesTotal - debtRates + cashFromWithdraw; + + // --- Effektive Werte einspielen (Roadmap Nr. 5) -------------------------------------- + // Bewusst NACH Verzinsung, Tilgung und Cash-Fortschreibung: Der erfasste Wert ist der + // Stand AM ENDE des Ist-Jahres. Rechnet man 2022 mit 120 und wieder 2024 mit 140, so + // liegen dazwischen genau zwei Wachstumsjahre -- das entspricht der Erwartung. + // + // Die Differenz wird als eigene Grösse geführt und NICHT den Renditen zugeschlagen: + // Ein Rückstand gegenüber dem Plan ist keine negative Rendite, sondern eine Korrektur. + const act = actuals ? actualsForYear(actuals, yearsBefore + t) : null; + if (act) { + for (const a of assets) { + const v = act.byElementId[a.ec.elementId]?.value; + if (typeof v !== "number") continue; // Lücke -> Planlinie läuft weiter + actualsCorrectionTotal += v - a.value; + a.value = v; + } + for (const re of realEstates) { + const av = act.byElementId[re.ec.elementId]; + if (typeof av?.value === "number") { + actualsCorrectionTotal += av.value - re.value; + re.value = av.value; + } + if (typeof av?.mortgage === "number") { + // Eine höhere Restschuld mindert das Vermögen -- Vorzeichen umgekehrt. + actualsCorrectionTotal -= av.mortgage - re.mortgage; + re.mortgage = av.mortgage; + } + } + for (const d of debts) { + const v = act.byElementId[d.ec.elementId]?.value; + if (typeof v !== "number") continue; + actualsCorrectionTotal -= v - d.owed; + d.owed = v; + } + // Flüsse: Der erfasste Betrag gilt für DIESES Jahr; die Basis wird so zurückgerechnet, + // dass die Reihe hier den Ist-Wert trifft und danach planmässig weiterwächst. + for (const inc of incomes) { + const v = act.byElementId[inc.ec.elementId]?.value; + if (typeof v === "number") inc.basis = rebaseFlow(v, inc.idx, t); + } + for (const exp of expenses) { + const v = act.byElementId[exp.ec.elementId]?.value; + // Ausgaben werden real geführt, erfasst wird der nominale Ist-Betrag. + if (typeof v === "number") exp.basis = rebaseFlow(v, exp.idx, t, inflFactor); + } + if (typeof act.cash === "number") { + actualsCashCorrectionTotal += act.cash - cash; + actualsCorrectionTotal += act.cash - cash; + cash = act.cash; + } + } + if (cash < 0) cashNegative = true; quotaTotal += quote; @@ -1203,6 +1270,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp investmentReturn: Math.round(investmentReturnTotal), propertyAppreciation: Math.round(propertyAppreciationTotal), pensionFundContribution: Math.round(pensionFundContributionTotal), + actualsCorrection: Math.round(actualsCorrectionTotal), endWealth: endWealthNominal, residual: endWealthNominal - @@ -1215,7 +1283,8 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp Math.round(quotaTotal) + Math.round(investmentReturnTotal) + Math.round(propertyAppreciationTotal) + - Math.round(pensionFundContributionTotal)), + Math.round(pensionFundContributionTotal) + + Math.round(actualsCorrectionTotal)), }, cashBridge: { openingCash: isFirstPhase ? Math.round(plan.initialCash || 0) : previousCashEnd, @@ -1229,6 +1298,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp savingRates: Math.round(savingRatesTotal), debtRates: Math.round(debtRatesTotal), withdrawals: Math.round(withdrawalsTotal), + actualsCorrection: Math.round(actualsCashCorrectionTotal), cashEnd, residual: cashEnd - @@ -1236,7 +1306,8 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp Math.round(quotaTotal) - Math.round(savingRatesTotal) - Math.round(debtRatesTotal) + - Math.round(withdrawalsTotal)), + Math.round(withdrawalsTotal) + + Math.round(actualsCashCorrectionTotal)), }, traces: explain ? phaseTraces : undefined, }); diff --git a/src/lib/dataview.test.ts b/src/lib/dataview.test.ts new file mode 100644 index 0000000..45b7053 --- /dev/null +++ b/src/lib/dataview.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from "vitest"; +import { buildViews, deviation, viewFor } from "@/lib/dataview"; +import type { ActualsSetInput } from "@/lib/actuals"; +import type { PlanInput } from "@/lib/types"; + +function plan(): PlanInput { + return { + id: "s", + name: "T", + householdType: "SINGLE", + inflationRateDefault: 0, + initialCash: 0, + startYear: 2020, + persons: [{ id: "A", role: "PERSON_A", name: null, age: 40, retirementAge: 65 }], + phases: [{ id: "p1", sequenceNumber: 1, name: "Erwerb", durationYears: 10, cashTransition: {} }], + elements: [ + { + id: "fonds", + category: "OTHER_ASSET", + name: "Fonds", + ownerRole: "HOUSEHOLD", + orderIndex: 1, + phaseValues: { p1: { startValue: 100000, expectedReturn: 5 } }, + transitionValues: {}, + sourceElementId: null, + }, + ], + } as unknown as PlanInput; +} + +const set: ActualsSetInput = { + id: "a1", + recordedOn: "2022-08-18", + year: 2022, + cash: null, + values: { fonds: { value: 120000 } }, +}; + +const endOf = (c: { phases: { endWealthNominal: number }[] }) => c.phases[c.phases.length - 1].endWealthNominal; + +describe("buildViews", () => { + it("liefert ohne Ist-Werte gar keine Ist-Sicht", () => { + // Wichtig für die Oberfläche: Der Umschalter erscheint dann gar nicht erst. + const v = buildViews(plan(), [], plan().elements); + expect(v.actual).toBeNull(); + expect(v.actualYears).toEqual([]); + expect(v.latestActualPlanYear).toBeNull(); + }); + + it("rechnet beide Sichten und lässt die Plan-Sicht unberührt", () => { + const p = plan(); + const v = buildViews(p, [set], p.elements); + const planOnly = buildViews(p, [], p.elements); + + expect(v.actual).not.toBeNull(); + expect(endOf(v.plan)).toBe(endOf(planOnly.plan)); + expect(endOf(v.actual!)).toBeGreaterThan(endOf(v.plan)); + }); + + it("merkt sich die erfassten Jahre für die Zeitachse", () => { + const p = plan(); + const v = buildViews(p, [set, { ...set, id: "a2", recordedOn: "2024-01-05", year: 2024 }], p.elements); + expect(v.actualYears).toEqual([2022, 2024]); + expect(v.latestActualPlanYear).toBe(5); + }); +}); + +describe("viewFor", () => { + it("fällt ohne Ist-Sicht auf den Plan zurück, statt leer zu bleiben", () => { + const v = buildViews(plan(), [], plan().elements); + expect(viewFor(v, "ACTUAL")).toBe(v.plan); + }); + + it("liefert mit Ist-Werten die Ist-Sicht", () => { + const p = plan(); + const v = buildViews(p, [set], p.elements); + expect(viewFor(v, "ACTUAL")).toBe(v.actual); + expect(viewFor(v, "PLAN")).toBe(v.plan); + }); +}); + +describe("deviation", () => { + it("behauptet ohne Ist-Sicht keine Abweichung von 0", () => { + const v = buildViews(plan(), [], plan().elements); + expect(deviation(v, endOf)).toBeNull(); + }); + + it("misst die Abweichung Ist gegenüber Plan", () => { + const p = plan(); + const v = buildViews(p, [set], p.elements); + const d = deviation(v, endOf)!; + expect(d).toBeGreaterThan(0); + expect(d).toBe(endOf(v.actual!) - endOf(v.plan)); + }); +}); diff --git a/src/lib/dataview.ts b/src/lib/dataview.ts new file mode 100644 index 0000000..5623d3e --- /dev/null +++ b/src/lib/dataview.ts @@ -0,0 +1,67 @@ +// Datensicht: Plandaten oder effektive (Ist-)Daten. +// +// Der Plan-Lauf bleibt unangetastet -- die Ist-Sicht ist ein ZWEITER Lauf derselben Mechanik +// mit korrigierter Ausgangsbasis (siehe `actuals.ts`). Dieses Modul bündelt, was beide +// Sichten gemeinsam brauchen, damit sich Matrix, Grafiken und die drei Analysewerkzeuge +// identisch verhalten. + +import { computePlan } from "@/lib/calculations"; +import { resolveActuals, latestPlanYear, type ActualsSetInput, type ElementOrigin } from "@/lib/actuals"; +import type { PlanComputed } from "@/lib/calculations"; +import type { PlanInput } from "@/lib/types"; + +// In der Matrix stehen genau zwei Möglichkeiten zur Wahl: die reinen Planzahlen oder die +// Ist-Zahlen MIT Abweichung. Ein Nebeneinander beider Rohwerte wäre bei zusätzlich +// nominal/real eine Zelle mit acht Zahlen (siehe SPEZIFIKATION 9.29). +export type DataSource = "PLAN" | "ACTUAL"; + +export const DATA_SOURCE_OPTIONS: { value: DataSource; label: string; short: string }[] = [ + { value: "PLAN", label: "Planzahlen", short: "Plan" }, + { value: "ACTUAL", label: "Effektive Zahlen inkl. Abweichung", short: "Effektiv" }, +]; + +export interface DataViews { + plan: PlanComputed; + // Null, solange für diesen Plan keine verwertbaren Ist-Werte erfasst sind. + actual: PlanComputed | null; + // Jüngstes erfasstes Planjahr -- Startpunkt der Monte-Carlo-Simulation im Ist-Modus und + // Grundlage der Hervorhebung auf der Zeitachse. + latestActualPlanYear: number | null; + // Kalenderjahre mit Ist-Satz, aufsteigend (für die Marker auf der Zeitachse). + actualYears: number[]; +} + +// Beide Sichten in einem Zug. `computePlan` ist rein und kostet rund 0.2 ms -- zwei Läufe +// sind billiger als jede Zwischenspeicherung. +export function buildViews( + plan: PlanInput, + sets: ActualsSetInput[], + origins: ElementOrigin[] +): DataViews { + const planComputed = computePlan(plan); + const resolved = resolveActuals(sets, plan, origins); + + if (resolved.length === 0) { + return { plan: planComputed, actual: null, latestActualPlanYear: null, actualYears: [] }; + } + + return { + plan: planComputed, + actual: computePlan(plan, undefined, { actuals: resolved }), + latestActualPlanYear: latestPlanYear(resolved), + actualYears: [...new Set(resolved.map((r) => r.year))].sort((a, b) => a - b), + }; +} + +// Die für die gewählte Quelle massgebende Berechnung. Fehlen Ist-Werte, bleibt es beim Plan -- +// eine leere Ansicht wäre die schlechtere Antwort als eine ehrliche Rückfallebene. +export function viewFor(views: DataViews, source: DataSource): PlanComputed { + return source === "ACTUAL" ? views.actual ?? views.plan : views.plan; +} + +// Abweichung Ist gegenüber Plan. `null`, wenn es keine Ist-Sicht gibt -- dann zeigt die +// Oberfläche gar keine Abweichung an, statt eine von 0 zu behaupten. +export function deviation(views: DataViews, pick: (c: PlanComputed) => number): number | null { + if (!views.actual) return null; + return pick(views.actual) - pick(views.plan); +} diff --git a/src/lib/livesim.ts b/src/lib/livesim.ts index f647247..15750dd 100644 --- a/src/lib/livesim.ts +++ b/src/lib/livesim.ts @@ -17,6 +17,7 @@ import { applyDriver, applyElementDriver, driverById, DRIVERS, tunableElements } import type { DriverId, DriverUnit } from "@/lib/sensitivity"; import type { PlanComputed } from "@/lib/calculations"; import type { PlanInput } from "@/lib/types"; +import type { ResolvedActuals } from "@/lib/actuals"; // Ein Regler: entweder ein plan-weiter Treiber oder die Rendite eines einzelnen Elements. export type SliderRef = { kind: "driver"; id: DriverId } | { kind: "element"; id: string }; @@ -147,9 +148,14 @@ export interface LiveResult { kpis: LiveKpis; } -export function runLive(plan: PlanInput, sliders: SliderDef[], values: SliderValues): LiveResult { +export function runLive( + plan: PlanInput, + sliders: SliderDef[], + values: SliderValues, + actuals?: ResolvedActuals[] +): LiveResult { const tuned = applySliders(plan, sliders, values); - const computed = computePlan(tuned); + const computed = computePlan(tuned, undefined, actuals ? { actuals } : undefined); return { plan: tuned, computed, kpis: kpisOf(computed) }; } diff --git a/src/lib/migrations.test.ts b/src/lib/migrations.test.ts index 12cbf98..8a7c344 100644 --- a/src/lib/migrations.test.ts +++ b/src/lib/migrations.test.ts @@ -86,5 +86,24 @@ describe("Datenbank-Migrationen", () => { ) ).rows.map((r) => r.indexname); expect(idx).toContain("ScenarioVersion_scenarioId_major_minor_key"); + + // --- Effektive Werte --- + expect(tables, "Tabelle ActualsSet fehlt").toContain("ActualsSet"); + const actCols = await cols("ActualsSet"); + for (const c of ["planId", "recordedOn", "year", "comment", "cash", "values", "createdById"]) { + expect(actCols, `ActualsSet.${c} fehlt`).toContain(c); + } + + // Der Ist-Satz hängt am PLAN, nicht am Szenario -- sonst wäre die Realität pro Szenario + // verschieden erfasst. + expect(actCols).not.toContain("scenarioId"); + + // `cash` muss leer bleiben dürfen: Wer den Kontostand nicht kennt, soll den Satz trotzdem + // erfassen können (Lücken fallen auf die Plandaten zurück). + const cashCol = await db.query<{ is_nullable: string }>( + `SELECT is_nullable FROM information_schema.columns + WHERE table_name='ActualsSet' AND column_name='cash'` + ); + expect(cashCol.rows[0].is_nullable).toBe("YES"); }, 60000); }); diff --git a/src/lib/sensitivity.ts b/src/lib/sensitivity.ts index 6f2563d..04f2e82 100644 --- a/src/lib/sensitivity.ts +++ b/src/lib/sensitivity.ts @@ -18,6 +18,7 @@ import { computePlan } from "@/lib/calculations"; import { num } from "@/lib/elements"; import type { ElementCategory, PhaseData } from "@/lib/elements"; import type { ElementInput, PlanInput } from "@/lib/types"; +import type { ResolvedActuals } from "@/lib/actuals"; export type DriverId = | "inflation" @@ -292,8 +293,8 @@ export function ineffectiveReason(plan: PlanInput, id: DriverId): string { } // Zielgrösse: Endvermögen der letzten Phase, real (kaufkraftbereinigt) oder nominal. -export function planMetric(plan: PlanInput, metric: TornadoMetric): number { - const computed = computePlan(plan); +export function planMetric(plan: PlanInput, metric: TornadoMetric, actuals?: ResolvedActuals[]): number { + const computed = computePlan(plan, undefined, actuals ? { actuals } : undefined); const last = computed.phases[computed.phases.length - 1]; if (!last) return 0; return Math.round(metric === "real" ? last.endWealthReal : last.endWealthNominal); @@ -302,14 +303,17 @@ export function planMetric(plan: PlanInput, metric: TornadoMetric): number { export function computeTornado( plan: PlanInput, metric: TornadoMetric, - inputs: TornadoInput[] + inputs: TornadoInput[], + // Effektive Werte: Die Treiber wirken dann nur noch auf die NICHT belegten Jahre -- was + // erfasst ist, steht fest. Die Balken fallen dadurch zu Recht kuerzer aus. + actuals?: ResolvedActuals[] ): TornadoResult { - const base = planMetric(plan, metric); + const base = planMetric(plan, metric, actuals); const bars: TornadoBar[] = inputs.map((input) => { const def = driverById(input.id); - const lowResult = planMetric(applyDriver(plan, input.id, input.low), metric); - const highResult = planMetric(applyDriver(plan, input.id, input.high), metric); + const lowResult = planMetric(applyDriver(plan, input.id, input.low), metric, actuals); + const highResult = planMetric(applyDriver(plan, input.id, input.high), metric, actuals); // Die Richtung kann sich umkehren (tiefe Ausgaben -> hohes Vermögen). Der Balken spannt // deshalb über min..max; welche Eingabe zu welchem Ende gehört, zeigt die Tabelle. const swing = Math.abs(highResult - lowResult); diff --git a/src/lib/versioning-coverage.test.ts b/src/lib/versioning-coverage.test.ts index ac63826..a4dba39 100644 --- a/src/lib/versioning-coverage.test.ts +++ b/src/lib/versioning-coverage.test.ts @@ -29,6 +29,9 @@ const EXEMPT: Record = { "plans/[planId]": "ändert nur den Plan-Namen bzw. löscht den ganzen Plan – kein Szenario-Inhalt", "scenarios/[scenarioId]/versions": "erzeugt Versionen selbst (Hauptversion / Wiederherstellen)", "scenarios/[scenarioId]/versions/[versionId]": "erzeugt Versionen selbst", + "plans/[planId]/actuals": + "Ist-Werte sind eine Beobachtung, keine Planänderung – sie verändern kein Szenario", + "plans/[planId]/actuals/[setId]": "dito (Löschen eines Ist-Satzes)", }; describe("Versionierung: Abdeckung der Schreibpfade", () => {