Effektive Werte: Plan-Ist-Vergleich (Roadmap 5)
Deploy App / deploy (push) Successful in 1m57s

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 20:35:07 +02:00
parent 4343d50aa4
commit e117e5a4e5
25 changed files with 1991 additions and 74 deletions
+179 -4
View File
@@ -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/<id>/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/<id>/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/<planId>`
→ 200 `{ ok: true }`, Cascade über alle Szenarien.
→ 200 `{ ok: true }`, Cascade über alle Szenarien (inkl. Versionen und Ist-Sätzen).
### `GET /api/plans/<planId>/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": { "<rootElementId>": { "value", "mortgage" } },
"author", "createdAt" } ] }
```
### `POST /api/plans/<planId>/actuals`
`{ recordedOn: "JJJJ-MM-TT", comment?, cash?, values }` `year` wird aus dem Datum abgeleitet.
→ 201 `{ set: { id, year } }`
### `DELETE /api/plans/<planId>/actuals/<setId>`
→ 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
@@ -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: { "<rootElementId>": { "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;
+31
View File
@@ -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<rootElementId, { value?, mortgage? }>
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
@@ -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 });
}
@@ -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 });
}
@@ -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,
+508
View File
@@ -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<typeof actualKindOf>;
}
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<Record<string, LoadedScenario>>(() => ({ [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<StoredSet[] | null>(null);
const [error, setError] = useState<string | null>(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<Record<string, { value?: number; mortgage?: number }>>({});
const [cash, setCash] = useState<number>(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<string, Row>();
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<string, { value?: number; mortgage?: number }> = {};
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 (
<div className="ui-fade fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={onClose}>
<div onClick={(e) => 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">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="flex items-center gap-2 text-base font-semibold text-fg">
<CalendarClock className="h-5 w-5 text-accent" /> Effektive Werte
</h2>
<p className="mt-0.5 text-xs text-muted">
Plan «{planName}». Was tatsächlich eingetreten ist der Plan selbst bleibt unverändert.
</p>
</div>
<button type="button" onClick={onClose} aria-label="Schliessen" className="rounded-md p-1 text-faint hover:bg-surface-2">
<X className="h-4 w-4" />
</button>
</div>
{mode === "list" && (
<>
<div className="rounded-xl border border-border bg-surface-2 p-3 text-xs leading-relaxed text-muted">
Ein Datensatz hält fest, wie es an einem Stichtag <strong className="text-fg">wirklich</strong> 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 <strong className="text-fg">alle Szenarien</strong> dieses Plans die
Wirklichkeit ist dieselbe, egal wogegen man sie hält.
</div>
{error && <p className="text-xs text-danger">{error}</p>}
{!sets && !error && <p className="text-xs text-muted">Wird geladen</p>}
{sets && sets.length === 0 && (
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-4 text-xs text-muted">
Noch keine effektiven Werte erfasst.
</p>
)}
{sets && sets.length > 0 && (
<div className="flex flex-col gap-2">
{sets.map((s, i) => (
<div
key={s.id}
className={`flex flex-wrap items-center gap-3 rounded-xl border p-3 ${
i === 0 ? "border-accent bg-accent-soft/20" : "border-border bg-surface-2"
}`}
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-sm font-medium text-fg">
{dt(s.recordedOn)}
{i === 0 && (
<span className="rounded bg-accent px-1.5 py-0.5 text-[10px] font-semibold text-accent-fg">
aktuellster
</span>
)}
</div>
<div className="text-[11px] text-muted">
rechnet ab {s.year} · {deviationCount(s)} Werte · {s.author}
{typeof s.cash === "number" && ` · Cash ${formatChf(s.cash)}`}
</div>
{s.comment && <div className="mt-0.5 truncate text-xs text-fg">«{s.comment}»</div>}
</div>
<button
type="button"
onClick={() => remove(s)}
className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-xs text-muted hover:border-danger hover:bg-danger-soft hover:text-danger"
>
<Trash2 className="h-3.5 w-3.5" /> Löschen
</button>
</div>
))}
</div>
)}
<div>
<Button onClick={startWizard}>
<Plus className="h-4 w-4" /> Effektive Werte erfassen
</Button>
</div>
</>
)}
{mode === "wizard" && step === 1 && (
<>
<div className="text-xs font-semibold uppercase tracking-wide text-faint">Schritt 1 von 2 · Stichtag</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="mb-1 flex items-center text-xs font-medium text-muted">
Datum der Erfassung
<InfoBubble text="Das exakte Datum erscheint in der Liste und auf der Zeitachse. Für die Berechnung zählt nur die Jahreszahl der Rechenkern arbeitet in ganzen Jahren ab Planbeginn." />
</label>
<input
type="date"
value={recordedOn}
onChange={(e) => setRecordedOn(e.target.value)}
className="w-full rounded-lg border border-border bg-surface px-2.5 py-1.5 text-sm text-fg"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-muted">Notiz (optional)</label>
<input
value={comment}
onChange={(e) => 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"
/>
</div>
</div>
<p className="rounded-lg border border-border bg-surface-2 px-3 py-2 text-xs text-muted">
Gerechnet wird ab dem Jahr <strong className="text-fg">{year}</strong>. Die Vorbelegung im nächsten
Schritt zeigt, was dein Plan für dieses Jahr vorsieht du überschreibst nur, was tatsächlich anders ist.
</p>
<div className="flex items-center gap-2">
<Button variant="secondary" onClick={() => setMode("list")}>
<ArrowLeft className="h-4 w-4" /> Zurück
</Button>
<Button onClick={goToStep2}>
Weiter <ArrowRight className="h-4 w-4" />
</Button>
</div>
</>
)}
{mode === "wizard" && step === 2 && (
<>
<div className="text-xs font-semibold uppercase tracking-wide text-faint">
Schritt 2 von 2 · Werte per {dt(recordedOn)}
</div>
<div className="max-h-[52vh] overflow-y-auto rounded-xl border border-border">
<table className="w-full border-collapse text-sm">
<thead className="sticky top-0 z-10 bg-surface-2">
<tr className="text-xs text-faint">
<th className="px-3 py-2 text-left font-semibold">Element</th>
<th className="px-3 py-2 text-right font-semibold">Laut Plan {year}</th>
<th className="px-3 py-2 text-right font-semibold">Effektiv</th>
</tr>
</thead>
<tbody>
<tr className="border-t border-border bg-surface-2/60">
<td colSpan={3} className="px-3 py-1 text-[11px] font-semibold uppercase tracking-wide text-faint">
Cash
</td>
</tr>
<tr className="border-t border-border">
<td className="px-3 py-2 font-medium text-fg">Cash-Konto</td>
<td className="px-3 py-2 text-right text-muted">{formatChf(planCash)}</td>
<td className="px-3 py-2">
<NumInput value={cash} onChange={setCash} />
</td>
</tr>
{rows.map((r, i) => {
const header = i === 0 || rows[i - 1].category !== r.category ? r.category : null;
const v = values[r.rootId] ?? {};
return (
<FragmentRow
key={r.rootId}
header={header}
row={r}
value={v}
onChange={(patch) => setRow(r.rootId, patch)}
/>
);
})}
</tbody>
</table>
</div>
<p className="text-[11px] text-faint">
Einkommen und Ausgaben bitte als <strong>Jahresbetrag</strong> 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.
</p>
<div className="flex items-center gap-2">
<Button variant="secondary" onClick={() => setStep(1)}>
<ArrowLeft className="h-4 w-4" /> Zurück
</Button>
<Button disabled={busy} onClick={save}>
{busy ? "Speichern…" : "Speichern"}
</Button>
</div>
</>
)}
</div>
</div>
);
}
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 && (
<tr className="border-t border-border bg-surface-2/60">
<td colSpan={3} className="px-3 py-1 text-[11px] font-semibold uppercase tracking-wide text-faint">
{CATEGORY_LABELS[header]}
</td>
</tr>
)}
<tr className="border-t border-border">
<td className="px-3 py-2">
<div className="font-medium text-fg">{row.name}</div>
{row.scenarioNames.length > 1 && (
<div className="text-[10px] text-faint">gilt für {row.scenarioNames.join(", ")}</div>
)}
{row.kind === "FLOW" && <div className="text-[10px] text-faint">Jahresbetrag</div>}
</td>
<td className="px-3 py-2 text-right text-muted">
{formatChf(row.planValue)}
{row.kind === "PROPERTY" && (
<div className="text-[11px] text-faint">Hypothek {formatChf(row.planMortgage ?? 0)}</div>
)}
</td>
<td className="px-3 py-2">
<NumInput value={value.value ?? 0} onChange={(v) => onChange({ value: v })} />
{row.kind === "PROPERTY" && (
<div className="mt-1">
<NumInput
value={value.mortgage ?? 0}
onChange={(v) => onChange({ mortgage: v })}
label="Hypothek"
/>
</div>
)}
</td>
</tr>
</>
);
}
function NumInput({ value, onChange, label }: { value: number; onChange: (v: number) => void; label?: string }) {
return (
<div className="flex items-center justify-end gap-1">
{label && <span className="text-[10px] text-faint">{label}</span>}
<input
type="number"
value={Number.isFinite(value) ? value : 0}
onChange={(e) => 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"
/>
</div>
);
}
+150
View File
@@ -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<Metric>(initialMetric);
const [source, setSource] = useState<DataSource>("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 (
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-xl border border-border bg-surface-2 px-3 py-2">
<label className="flex items-center gap-1.5 text-xs text-muted">
<span className="font-medium">Werte</span>
<div className="inline-flex rounded-lg border border-border bg-surface p-0.5">
{METRIC_OPTIONS.map((o) => (
<button
key={o.value}
type="button"
onClick={pick(() => basis.setMetric(o.value))}
className={`rounded-md px-2 py-0.5 font-medium ${
basis.metric === o.value ? "bg-accent text-accent-fg" : "text-muted hover:text-fg"
}`}
>
{o.label}
</button>
))}
</div>
<InfoBubble text="Nominal = Frankenbeträge im jeweiligen Jahr. Real = kaufkraftbereinigt auf den Planbeginn. In den Grafiken ist das bewusst eine Entweder-oder-Wahl: Beides gleichzeitig verdoppelt die Linien." />
</label>
<label className="flex items-center gap-1.5 text-xs text-muted">
<span className="font-medium">Grundlage</span>
<div className="inline-flex rounded-lg border border-border bg-surface p-0.5">
{DATA_SOURCE_OPTIONS.map((o) => (
<button
key={o.value}
type="button"
disabled={o.value === "ACTUAL" && !basis.hasActuals}
onClick={pick(() => basis.setSource(o.value))}
title={
o.value === "ACTUAL" && !basis.hasActuals
? "Für diesen Plan sind noch keine effektiven Werte erfasst."
: o.label
}
className={`rounded-md px-2 py-0.5 font-medium disabled:opacity-40 ${
basis.source === o.value ? "bg-accent text-accent-fg" : "text-muted hover:text-fg"
}`}
>
{o.short}
</button>
))}
</div>
<InfoBubble text="Planzahlen rechnen rein mit deinen Annahmen. Effektiv rechnet dieselbe Mechanik, springt aber in jedem Jahr, für das du Ist-Werte erfasst hast, auf die Realität." />
</label>
</div>
);
}
+50 -2
View File
@@ -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<PaletteAction[]>(() => {
const base: PaletteAction[] = [
@@ -426,6 +443,10 @@ function AppShellInner({ username }: { username: string }) {
<BarChart3 className="h-4 w-4" />
Grafiken
</Button>
<Button variant="secondary" onClick={() => setShowActuals(true)}>
<CalendarClock className="h-4 w-4" />
Effektive Werte
</Button>
<Button variant="secondary" onClick={() => setShowHistory(true)}>
<History className="h-4 w-4" />
Änderungshistorie
@@ -465,6 +486,8 @@ function AppShellInner({ username }: { username: string }) {
<PlanView
plan={detail.plan}
computed={detail.computed}
actualComputed={views?.actual ?? null}
actualYears={views?.actualYears ?? []}
diff={diff}
onChanged={refreshCurrent}
onOpenSpec={openSpecAt}
@@ -525,6 +548,8 @@ function AppShellInner({ username }: { username: string }) {
plan={detail.plan}
computed={detail.computed}
siblings={(activePlan?.scenarios ?? []).filter((s) => s.id !== detail.meta.id)}
actuals={detail.actuals ?? []}
origins={detail.elementOrigins ?? []}
/>
</ChartsDialog>
)}
@@ -535,16 +560,39 @@ function AppShellInner({ username }: { username: string }) {
computed={detail.computed}
meta={detail.meta}
scenarios={activePlan?.scenarios ?? [detail.meta]}
actuals={detail.actuals ?? []}
origins={detail.elementOrigins ?? []}
onClose={() => setShowMonteCarlo(false)}
/>
)}
{showSensitivity && detail && (
<SensitivityDialog plan={detail.plan} onClose={() => setShowSensitivity(false)} />
<SensitivityDialog
plan={detail.plan}
actuals={detail.actuals ?? []}
origins={detail.elementOrigins ?? []}
onClose={() => setShowSensitivity(false)}
/>
)}
{showLiveSim && detail && (
<LiveSimDialog plan={detail.plan} onClose={() => setShowLiveSim(false)} />
<LiveSimDialog
plan={detail.plan}
actuals={detail.actuals ?? []}
origins={detail.elementOrigins ?? []}
onClose={() => setShowLiveSim(false)}
/>
)}
{showActuals && detail && activePlan && (
<ActualsDialog
planId={detail.meta.planId}
planName={detail.meta.planName}
scenarioMetas={activePlan.scenarios}
initial={{ id: detail.meta.id, name: detail.meta.name, isBase: detail.meta.isBase, plan: detail.plan }}
onClose={() => setShowActuals(false)}
onChanged={refreshCurrent}
/>
)}
{showHistory && detail && (
+27 -7
View File
@@ -4,7 +4,8 @@ import { useMemo, useState } from "react";
import { BarChart3, Download, LineChart as LineChartIcon } from "lucide-react";
import { AllocationChart, CHART_PALETTE } from "@/components/AllocationChart";
import { useVersionedPlan, VersionBar } from "@/components/VersionPicker";
import { computePlan } from "@/lib/calculations";
import { AnalysisBar, useAnalysisBasis } from "@/components/AnalysisControls";
import type { ActualsSetInput, ElementOrigin } from "@/lib/actuals";
import { WealthChart, type TimelineSeries } from "@/components/WealthChart";
import { SparquoteChart } from "@/components/SparquoteChart";
import { api } from "@/lib/api-client";
@@ -21,20 +22,26 @@ export function Dashboard({
plan: currentPlan,
computed: currentComputed,
siblings,
actuals = [],
origins = [],
}: {
plan: PlanInput;
computed: PlanComputed;
// Die übrigen Szenarien desselben Plans -- nur die sind sinnvoll vergleichbar.
siblings: PlanListItem[];
actuals?: ActualsSetInput[];
origins?: ElementOrigin[];
}) {
// Gezeigt wird wahlweise der Arbeitsstand oder eine festgehaltene Version.
const { versionId, setVersionId, plan, loading: versionLoading } = useVersionedPlan(currentPlan.id, currentPlan);
// Der Snapshot ist ein PlanInput -- das Gerechnete entsteht hier lokal (computePlan ist rein).
const basis = useAnalysisBasis(plan, actuals, origins);
// Version UND Datenquelle bestimmen, was gezeigt wird. Beim Arbeitsstand mit Plandaten
// bleibt es beim bereits vom Server Gerechneten -- kein zweiter Lauf nötig.
const computed = useMemo(
() => (versionId === "current" ? currentComputed : computePlan(plan)),
[versionId, currentComputed, plan]
() => (versionId === "current" && basis.source === "PLAN" ? currentComputed : basis.computed),
[versionId, currentComputed, basis.source, basis.computed]
);
const [compareIds, setCompareIds] = useState<string[]>([]);
const [compareData, setCompareData] = useState<Record<string, PlanComputed>>({});
@@ -52,13 +59,24 @@ export function Dashboard({
const series: TimelineSeries[] = useMemo(() => {
const result: TimelineSeries[] = [{ label: plan.name, color: CHART_PALETTE[0], computed }];
// Im Ist-Modus die reine Planlinie als gestrichelte Referenz daneben -- ohne Anker sagt
// eine Ist-Linie allein nichts aus.
if (basis.source === "ACTUAL") {
result.push({
label: `${plan.name} (Plan)`,
color: CHART_PALETTE[0],
computed: basis.planComputed,
dashed: true,
});
}
compareIds.forEach((id, i) => {
const c = compareData[id];
const name = siblings.find((p) => p.id === id)?.name ?? id;
if (c) result.push({ label: name, color: CHART_PALETTE[(i + 1) % CHART_PALETTE.length], computed: c });
});
return result;
}, [plan.name, computed, compareIds, compareData, siblings]);
// Vier Serien sind die Grenze der Lesbarkeit -- darüber hilft keine Farbpalette mehr.
return result.slice(0, 4);
}, [plan.name, computed, compareIds, compareData, siblings, basis.source, basis.planComputed]);
const otherPlans = siblings;
const lastPhase = computed.phases[computed.phases.length - 1];
@@ -72,6 +90,8 @@ export function Dashboard({
loading={versionLoading}
/>
<AnalysisBar basis={basis} />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<StatCard label="Endvermögen (nominal)" value={lastPhase ? lastPhase.endWealthNominal : 0} />
<StatCard label="Endvermögen (real, kaufkraftbereinigt)" value={lastPhase ? lastPhase.endWealthReal : 0} />
@@ -125,7 +145,7 @@ export function Dashboard({
))}
</div>
)}
<WealthChart series={series} />
<WealthChart series={series} metric={basis.metric} />
</section>
<section className="rounded-xl border border-border bg-surface p-4 shadow-sm">
+33 -7
View File
@@ -7,7 +7,9 @@ import { SparquoteChart } from "@/components/SparquoteChart";
import { WealthChart } from "@/components/WealthChart";
import { InfoBubble } from "@/components/InfoBubble";
import { useVersionedPlan, VersionBar } from "@/components/VersionPicker";
import { computePlan } from "@/lib/calculations";
import { AnalysisBar, useAnalysisBasis } from "@/components/AnalysisControls";
import type { ActualsSetInput, ElementOrigin } from "@/lib/actuals";
import { formatChf } from "@/lib/format";
import {
buildSliders,
@@ -44,9 +46,20 @@ const CHARTS: { id: ChartId; label: string; hint: string }[] = [
const BASE_COLOR = "#9ca3af";
const LIVE_COLOR = "#4f46e5";
export function LiveSimDialog({ plan: currentPlan, onClose }: { plan: PlanInput; onClose: () => void }) {
export function LiveSimDialog({
plan: currentPlan,
actuals = [],
origins = [],
onClose,
}: {
plan: PlanInput;
actuals?: ActualsSetInput[];
origins?: ElementOrigin[];
onClose: () => void;
}) {
// Geregelt wird wahlweise am Arbeitsstand oder an einer festgehaltenen Version.
const { versionId, setVersionId, plan, loading: versionLoading } = useVersionedPlan(currentPlan.id, currentPlan);
const basis = useAnalysisBasis(plan, actuals, origins);
const [expandReturns, setExpandReturns] = useState(false);
const [values, setValues] = useState<SliderValues>({});
// Vom Nutzer überschriebene Reglerbereiche (Schlüssel -> [min, max]).
@@ -64,12 +77,15 @@ export function LiveSimDialog({ plan: currentPlan, onClose }: { plan: PlanInput;
// Referenz: der unveränderte Plan. Wird nur neu gerechnet, wenn sich der Plan ändert.
const base = useMemo(() => {
const computed = computePlan(plan);
const computed = basis.computed;
return { computed, kpis: kpisOf(computed) };
}, [plan]);
}, [basis.computed]);
// Der Kern: bei JEDER Reglerbewegung synchron neu rechnen (~0.2 ms, siehe livesim.ts).
const live = useMemo(() => runLive(plan, sliders, values), [plan, sliders, values]);
const live = useMemo(
() => runLive(plan, sliders, values, basis.resolvedActuals),
[plan, sliders, values, basis.resolvedActuals]
);
const neutral = isNeutral(sliders, values);
const settings = describeSettings(sliders, values);
@@ -121,6 +137,8 @@ export function LiveSimDialog({ plan: currentPlan, onClose }: { plan: PlanInput;
loading={versionLoading}
/>
<AnalysisBar basis={basis} onChange={() => setValues({})} />
<p className="rounded-xl border border-border bg-surface-2 p-3 text-xs leading-relaxed text-muted">
Dreh an den Reglern und sieh sofort, was passiert. <strong className="text-fg">Nichts davon wird
gespeichert</strong> dein Plan bleibt unverändert, du brauchst für kein Durchspielen eine
@@ -129,8 +147,16 @@ export function LiveSimDialog({ plan: currentPlan, onClose }: { plan: PlanInput;
{/* Kennzahlenleiste: die eigentliche Antwort. Die Grafik zeigt WANN, das hier WIE VIEL. */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<DeltaCard label="Endvermögen (nominal)" base={base.kpis.endNominal} live={live.kpis.endNominal} />
<DeltaCard label="Endvermögen (real)" base={base.kpis.endReal} live={live.kpis.endReal} />
<DeltaCard
label={basis.metric === "real" ? "Endvermögen (real)" : "Endvermögen (nominal)"}
base={basis.metric === "real" ? base.kpis.endReal : base.kpis.endNominal}
live={basis.metric === "real" ? live.kpis.endReal : live.kpis.endNominal}
/>
<DeltaCard
label={basis.metric === "real" ? "Endvermögen (nominal)" : "Endvermögen (real)"}
base={basis.metric === "real" ? base.kpis.endNominal : base.kpis.endReal}
live={basis.metric === "real" ? live.kpis.endNominal : live.kpis.endReal}
/>
<RuinCard baseAge={base.kpis.ruinAge} liveAge={live.kpis.ruinAge} />
</div>
+31 -2
View File
@@ -6,6 +6,8 @@ import { Dices, X } from "lucide-react";
import { NumberField, SelectField, MoneyField, RequiredNumberField } from "@/components/FormField";
import { InfoBubble } from "@/components/InfoBubble";
import { CURRENT, loadVersionPlan, VersionSelect } from "@/components/VersionPicker";
import { AnalysisBar, useAnalysisBasis } from "@/components/AnalysisControls";
import type { ActualsSetInput, ElementOrigin } from "@/lib/actuals";
import { computePlan } from "@/lib/calculations";
import { api } from "@/lib/api-client";
import { formatChf } from "@/lib/format";
@@ -111,11 +113,15 @@ export function MonteCarloDialog({
computed,
meta,
scenarios,
actuals = [],
origins = [],
onClose,
}: {
plan: PlanInput;
computed: PlanComputed;
meta: ScenarioMeta;
actuals?: ActualsSetInput[];
origins?: ElementOrigin[];
// Alle Szenarien dieses Plans (auch nicht ausgewählte) -- sie werden geladen, damit sich
// die Herkunfts-Kette der Elemente auch über übersprungene Zwischen-Szenarien auflöst.
scenarios: ScenarioMeta[];
@@ -124,6 +130,7 @@ export function MonteCarloDialog({
const [loaded, setLoaded] = useState<Record<string, LoadedScenario>>(() => ({
[meta.id]: { id: meta.id, name: meta.name, plan, computed },
}));
const basis = useAnalysisBasis(plan, actuals, origins);
const [loading, setLoading] = useState(scenarios.some((s) => s.id !== meta.id));
const [loadError, setLoadError] = useState<string | null>(null);
const [selectedIds, setSelectedIds] = useState<string[]>([meta.id]);
@@ -349,6 +356,18 @@ export function MonteCarloDialog({
</button>
</div>
<AnalysisBar basis={basis} onChange={clearResults} />
{/* Startpunkt: bewusst abgeleitet statt eingebbar. Ein frei gesetztes Jahr würde
Jahre als sicher behandeln, die nie erfasst wurden. */}
<p className="text-[11px] text-muted">
{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."}
</p>
{/* Erklärung */}
<div className="rounded-xl border border-border bg-surface-2 p-4 text-xs leading-relaxed text-muted">
<p className="mb-2">
@@ -419,8 +438,18 @@ export function MonteCarloDialog({
{/* Zielbetrag */}
<div className="sm:max-w-xs">
<MoneyField
label="Zielbetrag (Endvermögen)"
help="Dein frei gewähltes Ziel, nominal. Geprüft wird, wie oft es in beiden Welten mindestens erreicht wird. Gilt für alle ausgewählten Szenarien."
// Das Feld dreht mit der gewählten Grösse mit -- sonst prüft man einen nominalen
// Zielbetrag gegen ein reales Endvermögen und vergleicht Äpfel mit Birnen.
label={
basis.metric === "real"
? "Zielbetrag (Endvermögen, REAL)"
: "Zielbetrag (Endvermögen, NOMINAL)"
}
help={
basis.metric === "real"
? "Dein frei gewähltes Ziel in HEUTIGER Kaufkraft. Geprüft wird gegen das reale Endvermögen."
: "Dein frei gewähltes Ziel in Franken des Zieljahres (nominal). Geprüft wird gegen das nominale Endvermögen."
}
value={manualTarget}
onChange={(v) => {
setManualTarget(v);
+78 -4
View File
@@ -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<DataSource>("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({
))}
</div>
<span className="text-[11px] text-faint">real = kaufkraftbereinigt (Planbeginn)</span>
{/* Zweite Achse: Datenquelle. Erscheint nur, wenn es überhaupt Ist-Werte gibt --
sonst wäre es ein Umschalter ohne Gegenstück. */}
{hasActuals && (
<>
<span className="ml-2 text-xs text-muted">Zahlen</span>
<div className="inline-flex rounded-lg border border-border bg-surface p-0.5 text-xs">
{DATA_SOURCE_OPTIONS.map((o) => (
<button
key={o.value}
type="button"
onClick={() => setDataSource(o.value)}
title={o.label}
className={`rounded-md px-2.5 py-1 font-medium ${
dataSource === o.value ? "bg-accent text-accent-fg" : "text-muted hover:bg-surface-2"
}`}
>
{o.short}
</button>
))}
</div>
{dataSource === "ACTUAL" && (
<span className="text-[11px] text-faint">Abweichung gegenüber Plan farbig</span>
)}
</>
)}
</div>
<div data-tour="timeline">
@@ -373,6 +422,7 @@ export function PlanView({
phases={computed.phases}
persons={personAxes}
ruinAge={computed.ruinAge}
actualYears={actualYears}
startYear={plan.startYear}
/>
</div>
@@ -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))}
</td>
);
}
@@ -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 = <span className="text-faint"></span>;
return (
@@ -1005,16 +1059,31 @@ function ValuePair({
<span className="whitespace-nowrap tabular-nums">
{mode === "real" ? formatChf(realOf(start, deflatorStart)) : formatChf(start)} {arrow}{" "}
{mode === "real" ? formatChf(realOf(end, deflatorEnd)) : formatChf(end)}
<Deviation value={devEnd} deflator={mode === "real" ? deflatorEnd : 1} />
</span>
{mode === "both" && (
<span className="whitespace-nowrap tabular-nums text-faint">
({formatChf(realOf(start, deflatorStart))}) {arrow} ({formatChf(realOf(end, deflatorEnd))})
<Deviation value={devEnd} deflator={deflatorEnd} />
</span>
)}
</span>
);
}
// 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 (
<span className={`ml-1.5 font-semibold ${v > 0 ? "text-success" : "text-danger"}`}>
{v > 0 ? "▲ +" : "▼ "}
{formatChf(Math.abs(v))}
</span>
);
}
// 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<PhaseComputed["elements"]["find"]> | 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 <ValuePair start={ce.startValue} end={ce.endValue} deflatorStart={dS} deflatorEnd={dE} mode={mode} />;
return (
<ValuePair start={ce.startValue} end={ce.endValue} deflatorStart={dS} deflatorEnd={dE} mode={mode} devEnd={devEnd} />
);
}
if ((ce.category === "AHV" || ce.category === "PENSION_FUND") && ce.startValue !== 0) {
return (
+22 -20
View File
@@ -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<TornadoMetric>("real");
const basis = useAnalysisBasis(plan, actuals, origins, "real");
const metric: TornadoMetric = basis.metric;
const [drafts, setDrafts] = useState<Record<string, RangeDraft>>({});
const [result, setResult] = useState<TornadoResult | null>(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
</p>
</div>
{/* Zielgrösse */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<SelectField
label="Zielgrösse"
help="Woran die Wirkung gemessen wird: das Endvermögen der letzten Lebensphase. «Real» ist kaufkraftbereinigt auf den Planbeginn und damit die ehrlichere Grösse."
value={metric}
onChange={(v: TornadoMetric) => {
setMetric(v);
setResult(null);
}}
options={[
{ value: "real", label: "Endvermögen real (kaufkraftbereinigt)" },
{ value: "nominal", label: "Endvermögen nominal" },
]}
/>
</div>
{/* Zielgrösse und Datengrundlage */}
<AnalysisBar basis={basis} onChange={() => setResult(null)} />
{/* Parameter */}
<div>
+41 -1
View File
@@ -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({
</div>
)}
{/* 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 (
<div
key={y}
className="absolute top-0 flex -translate-x-1/2 flex-col items-center"
style={{ left: pct(age), opacity: isLatest ? 1 : 0.35 }}
title={
isLatest
? `Effektive Werte erfasst für ${y} (aktuellster Stand)`
: `Effektive Werte erfasst für ${y} (überholt)`
}
>
<CalendarCheck
className="h-3.5 w-3.5"
style={{ color: isLatest ? "var(--attention)" : "var(--muted)" }}
/>
<span
className="whitespace-nowrap text-[10px] font-medium"
style={{ color: isLatest ? "var(--attention)" : "var(--muted)" }}
>
{y}
</span>
<div
className="mt-0.5 h-3 w-px"
style={{ backgroundColor: isLatest ? "var(--attention)" : "var(--muted)" }}
/>
</div>
);
})}
{/* Phasen-Segmente: Breite proportional zur Dauer, Einfärbung nach Phasentyp. */}
<div className="flex w-full overflow-hidden rounded-lg border border-border">
{segments.map((s, i) => {
+15 -17
View File
@@ -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 <p className="text-sm text-muted">Noch keine Phasen vorhanden.</p>;
}
@@ -47,8 +56,7 @@ export function WealthChart({ series }: { series: TimelineSeries[] }) {
const row: Record<string, number | null> = { 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[] }) {
<Legend wrapperStyle={{ fontSize: 12 }} />
{withPoints.map((s) => (
<Line
key={`${s.label}-nominal`}
key={s.label}
type="monotone"
dataKey={`${s.label} (nominal)`}
dataKey={s.label}
stroke={s.color}
strokeWidth={2}
strokeDasharray={s.dashed ? "5 3" : undefined}
dot={false}
connectNulls
/>
))}
{withPoints.map((s) => (
<Line
key={`${s.label}-real`}
type="monotone"
dataKey={`${s.label} (real)`}
stroke={s.color}
strokeWidth={2}
strokeDasharray="5 3"
dot={false}
connectNulls
isAnimationActive={false}
/>
))}
</LineChart>
+239
View File
@@ -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<typeof computePlan>, 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<string, { value?: number; mortgage?: number }>, 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);
});
});
+151
View File
@@ -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<string, ActualElementValue>;
}
// 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<string, ActualElementValue>;
}
// 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<string, string | null>();
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<ResolvedActuals>((set) => {
const planYear = toPlanYear(set.year, scenario.startYear);
if (planYear === null) return [];
const byElementId: Record<string, ActualElementValue> = {};
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;
}
+73 -2
View File
@@ -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,
});
+95
View File
@@ -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));
});
});
+67
View File
@@ -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);
}
+8 -2
View File
@@ -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) };
}
+19
View File
@@ -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);
});
+10 -6
View File
@@ -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);
+3
View File
@@ -29,6 +29,9 @@ const EXEMPT: Record<string, string> = {
"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", () => {