From e117e5a4e5baa8d2dc9624963b3564d4920cb619 Mon Sep 17 00:00:00 2001
From: kelle
Date: Mon, 20 Jul 2026 20:35:07 +0200
Subject: [PATCH] Effektive Werte: Plan-Ist-Vergleich (Roadmap 5)
Neuer Knopf auf Plan-Ebene: Liste plus Wizard in zwei Schritten. Ein
Ist-Satz haengt am PLAN, nicht am Szenario -- die Zuordnung laeuft ueber
die Herkunfts-Kette sourceElementId.
computePlan nimmt neu { actuals }: Die Werte schnappen in jedem erfassten
Jahr auf die Realitaet und laufen von dort planmaessig weiter. Luecken
fallen auf die Plandaten zurueck. Ohne die Option unveraendert -- die 43
Golden Tests laufen durch.
Der Sprung ist keine Rendite: eigene Brueckenposition actualsCorrection
in Vermoegens- und Cash-Bruecke, sonst ginge die Zerlegung nicht auf.
Matrix: Umschalter Plan/Effektiv, im Ist-Modus mit farbiger Abweichung
statt acht Zahlen je Zelle. Zeitachse: Marker je Jahr, juengster farbig.
Vier Analysewerkzeuge mit einheitlicher Leiste (nominal/real als
Einfachauswahl, Plan/Effektiv). MC: Zielbetrag dreht mit, Startjahr
abgeleitet statt eingebbar.
Neue Tabelle ActualsSet (gegen echtes Postgres verifiziert), Module
actuals.ts und dataview.ts. Spezifikation 0.21, 27 Tests (181 -> 208).
Co-Authored-By: Claude Opus 4.8
---
SPEZIFIKATION.md | 183 ++++++-
.../20260720090000_actuals/migration.sql | 34 ++
prisma/schema.prisma | 31 ++
.../plans/[planId]/actuals/[setId]/route.ts | 22 +
src/app/api/plans/[planId]/actuals/route.ts | 82 +++
src/app/api/scenarios/[scenarioId]/route.ts | 23 +
src/components/ActualsDialog.tsx | 508 ++++++++++++++++++
src/components/AnalysisControls.tsx | 150 ++++++
src/components/AppShell.tsx | 52 +-
src/components/Dashboard.tsx | 34 +-
src/components/LiveSimDialog.tsx | 40 +-
src/components/MonteCarloDialog.tsx | 33 +-
src/components/PlanView.tsx | 82 ++-
src/components/SensitivityDialog.tsx | 42 +-
src/components/Timeline.tsx | 42 +-
src/components/WealthChart.tsx | 32 +-
src/lib/actuals.test.ts | 239 ++++++++
src/lib/actuals.ts | 151 ++++++
src/lib/calculations.ts | 75 ++-
src/lib/dataview.test.ts | 95 ++++
src/lib/dataview.ts | 67 +++
src/lib/livesim.ts | 10 +-
src/lib/migrations.test.ts | 19 +
src/lib/sensitivity.ts | 16 +-
src/lib/versioning-coverage.test.ts | 3 +
25 files changed, 1991 insertions(+), 74 deletions(-)
create mode 100644 prisma/migrations/20260720090000_actuals/migration.sql
create mode 100644 src/app/api/plans/[planId]/actuals/[setId]/route.ts
create mode 100644 src/app/api/plans/[planId]/actuals/route.ts
create mode 100644 src/components/ActualsDialog.tsx
create mode 100644 src/components/AnalysisControls.tsx
create mode 100644 src/lib/actuals.test.ts
create mode 100644 src/lib/actuals.ts
create mode 100644 src/lib/dataview.test.ts
create mode 100644 src/lib/dataview.ts
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 (
+
+ 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.
+
+ 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.
+
+ 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.
+
+ );
+}
+
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
+ )}
+ >
+ )}
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", () => {