Versionierung und Aenderungshistorie je Szenario
Deploy App / deploy (push) Successful in 1m48s

Version A.B: B automatisch je Bearbeitungssitzung (10-Minuten-Fenster,
unveraenderte Staende erzeugen keine), A manuell mit Pflichtkommentar.

Eine Version haelt den vollstaendigen Zustand als PlanInput-JSON -- dadurch
ist die Versionsauswahl in allen vier Analysewerkzeugen fast kostenlos,
bei Monte-Carlo je Szenario einzeln.

Wiederherstellen erhaelt die IDs (sonst verlieren Kind-Szenarien ihre
Diff-Basis) und legt den Stand selbst als neue Version an. Wo ein Bezug
trotzdem bricht, warnt der Dialog vorher namentlich.

Statischer Waechter-Test: jeder schreibende Endpunkt loest eine Version aus.
Migration gegen echtes Postgres verifiziert.

Spezifikation 0.19 (3.8 und 9.28 neu), 25 Tests (139 -> 164).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 22:09:10 +02:00
parent d04e07fdfb
commit d023534a03
28 changed files with 2021 additions and 13 deletions
+171 -4
View File
@@ -4,7 +4,7 @@
| | |
|---|---|
| **Dokument** | Funktionale und Technische Spezifikation FPT |
| **Version** | 0.18 |
| **Version** | 0.19 |
| **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.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. |
| 0.17 | 2026-07-19 | Claude (Opus 4.8) | **Monte-Carlo: zwei Welten, vier Fälle.** Behebt einen Darstellungs-Widerspruch: Zuvor konnte «Planung 69 % erreicht» neben «Ziel 3 Mio nur 41 %» stehen, obwohl 3 Mio unter dem Plan-Endbetrag von 3.7 Mio lag die beiden Zahlen stammten aus **verschiedenen simulierten Welten**. Neu läuft die Simulation **immer zweimal** (historische Renditen / geplante Werte, gemeinsamer Seed) und liest aus **jeder** Verteilung **beide** Schwellen ab: Plan-Endbetrag und Zielbetrag. Fall 1 und Fall 3 stammen damit aus derselben Verteilung, wodurch ein tieferes Ziel **nie** unwahrscheinlicher sein kann als ein höheres der Widerspruch ist strukturell ausgeschlossen (Test). Zweite Korrektur: Der Nullpunkt für das Urteil ist **nicht 50 %**, sondern **Fall 2** (derselbe Schwellwert in der eigenen geplanten Welt); durch den Volatilitäts-Drag liegt der je nach Streuung bei 2748 %. Verglichen wird Fall 1 gegen Fall 2 mit ± 5 pp Toleranzband → «zurückhaltend / realistisch / zu optimistisch». Darstellung: Fall 1 prominent mit Urteil, Fall 3+4 als Satzpaar untergeordnet, Fall 2 und die Mediane klein als Referenz. Der Drei-Wege-Umschalter aus 0.16 entfällt; historische Mittelwerte **und** Zielbetrag sind jetzt beide Pflicht. Technisch: `MonteCarloResult.finalWealthSorted` (alle Endvermögen sortiert) plus neuer Helfer `probabilityAtLeast` (Binärsuche) vier Zahlen aus zwei Läufen statt vier Läufen. Kapitel 4.12.7 und 9.26 neu gefasst; 3 Tests ergänzt (121 → 124). Keine Änderung am Rechenkern. |
| 0.16 | 2026-07-19 | Claude (Opus 4.8) | **Monte-Carlo mit zwei Fragestellungen** (Roadmap Nr. 46). Ein Umschalter oben trennt: **«Planung prüfen»** (Fall 1, wie bisher) würfelt um die **historischen** Renditen und prüft gegen den **Planungs-Endbetrag** (read-only) «wie realistisch ist meine Planung?». **«Ziel prüfen»** (Fall 2, neu) würfelt um die **geplanten** Werte aus dem Plan und prüft gegen einen **manuellen Zielbetrag** «erreiche ich mein Ziel?»; hier sind keine historischen Mittelwerte nötig, nur die Streuung. **«Beides»** rechnet beide Durchgänge gleichzeitig (gemeinsamer Seed). Neue Ergebnis-**Deutungstexte** je Fall (weich formuliert wegen des Volatilitäts-Drags). Der Fächer stammt aus dem historischen Durchgang; der Zielbetrag ist in Fall 2 **einer für alle** Szenarien. `runMonteCarloMulti` nimmt neu die Inflation **je Szenario** (`inflationMeanFor`); neuer Helfer `plannedReturnOf`. Kapitel 4.12 überarbeitet, 4.12.7 und 9.26 neu; 8 Tests ergänzt (119 → 121). Nebenbei eine vom Umlaut-Sweep verstümmelte Hex-Farbe (`#7c3aed`) korrigiert. Keine Änderung am Rechenkern. |
@@ -1204,6 +1205,104 @@ Jede Element-Zeile der Matrix trägt eine **Sparkline** (`Sparkline.tsx`): der W
(`ElementPhaseComputed.yearly`, seit 0.11) keine Neuberechnung, reine Darstellung. Flache
Verläufe werden nicht gezeichnet (keine Information).
## 3.8 Versionierung und Änderungshistorie
Jedes **Szenario** trägt eine Version **A.B** und eine vollständige Änderungshistorie. Zugang
über den Knopf **«Änderungshistorie»** in der Szenario-Leiste.
| | Bedeutung | Entsteht |
|---|---|---|
| **B** (Nebenversion) | ein Bearbeitungsstand | **automatisch**, eine je Bearbeitungssitzung |
| **A** (Hauptversion) | ein bewusst gesetzter Meilenstein | **manuell**, mit Pflichtkommentar; setzt B auf 0 |
### 3.8.1 Eine Version je Sitzung nicht je Änderung
FPT hat **keinen Speichern-Knopf**: Jede Änderung schreibt sofort. Eine Version je
Schreibvorgang wäre deshalb ein Tastenprotokoll und keine Historie ein Durchlauf des
Plan-Assistenten macht rund **14** Schreibvorgänge, ein Klick im Verteil-Dialog einen **je
Zielelement**.
Stattdessen werden alle Schreibvorgänge innerhalb eines **Zeitfensters von 10 Minuten** zu
**einer** Nebenversion zusammengefasst: Der erste legt sie an, alle weiteren aktualisieren
sie. Zwei Sicherungen ergänzen das:
- **Unverändert = keine Version.** Ergibt ein Schreibvorgang inhaltlich denselben Stand
(Dialog geöffnet und unverändert geschlossen), entsteht nichts. Verglichen wird über eine
Serialisierung mit **sortierten Schlüsseln** ohne das würden identische Stände als
verschieden gelten, weil die Reihenfolge der Werte aus der Datenbank-Zeilenfolge stammt.
- **Benutzerwechsel trennt immer.** Änderungen verschiedener Benutzer laufen nie in einer
Version zusammen, auch nicht innerhalb des Fensters Vorbereitung auf die spätere Freigabe
an einen Finanzberater.
Eine **Hauptversion** wird nie zusammengefasst und nie nachträglich verändert; die nächste
Änderung beginnt bei A.1.
### 3.8.2 Was die Historie zeigt
Je Version: **wer** (Benutzername), **wann** (Zeitpunkt der letzten Änderung dieser Sitzung)
und bei Hauptversionen der **Kommentar**. Die oberste Zeile ist der aktuelle Stand.
Zwei Aktionen je Version:
- **Anzeigen** ein Nur-Lese-Fenster mit der Matrix dieses Standes ([VersionMatrix](#552-komponenten)),
Phasen als Spalten, Elemente als Zeilen, dazu Endvermögen und Ruinalter. Bewusst **nicht**
die Bearbeitungs-Matrix: Inspector, Übergangs-Ampeln und Diff-Markierung haben für einen
alten Stand keine Bedeutung.
- **Wiederherstellen** setzt das Szenario vollständig auf diesen Stand zurück.
### 3.8.3 Wiederherstellen
Zwei Eigenschaften machen den Knopf ungefährlich:
**Es geht nichts verloren.** Der wiederhergestellte Stand wird selbst als **neue Version**
festgehalten, mit dem Kommentar «Wiederhergestellt aus A.B». Die bisherige Historie bleibt
vollständig auch alles, was nach dem Zielstand kam.
**IDs bleiben erhalten.** Phasen und Elemente behalten ihre IDs (der Snapshot trägt sie mit).
Das ist keine Kosmetik: Kind-Szenarien zeigen über `sourceElementId` / `sourcePhaseId` auf
genau diese IDs, und daran hängt die Abweichungs-Markierung
([3.2.6](#326-abweichungs-markierung-diff)). Würden neue IDs entstehen, erschiene in
jedem Kind-Szenario schlagartig **alles als «neu»** statt als «geändert».
Restlos vermeiden lässt sich das nicht: Geht man auf einen Stand zurück, in dem ein Element
noch gar nicht existierte, auf das ein Kind verweist, bricht dieser eine Bezug zwangsläufig.
Der Dialog **prüft das vorher** und benennt die betroffenen Szenarien namentlich, statt es
stillschweigend zu tun.
### 3.8.4 Analyse auf einer bestimmten Version
Alle vier Analysewerkzeuge **Grafiken**, **Live-Simulation**, **Monte-Carlo** und
**Einflussfaktoren** haben oben einen Wähler **«Berechnungsgrundlage»**: aktueller Stand
(Vorgabe) oder eine festgehaltene Version.
In der **Monte-Carlo-Simulation** ist die Auswahl **zweidimensional**: Sie rechnet mehrere
Szenarien gleichzeitig, und der Wähler hängt deshalb an der jeweiligen Szenario-Zeile man
kann Szenario A in Version 1.2 gegen Szenario B in Version 2.0 stellen.
Das war fast kostenlos: Der Snapshot ist bereits ein **`PlanInput`**, also genau der Typ, den
alle Werkzeuge ohnehin entgegennehmen. Es genügt, den Arbeitsstand durch den Snapshot zu
ersetzen; das Gerechnete entsteht lokal über `computePlan`.
**Eine Ausnahme ist beschriftet:** Der CSV-Export liest immer das Szenario aus der Datenbank
und liefert deshalb den **aktuellen** Stand, nicht die betrachtete Version. Der Knopf sagt das
in diesem Fall ausdrücklich.
### 3.8.5 Vollständigkeit der Historie
Die Versionierung hängt daran, dass **jeder** inhaltsverändernde Endpunkt sie auslöst
(`touchScenario`). Ein vergessener Pfad fiele nicht auf er erzeugte still keine Version, und
die Lücke bemerkte man erst Wochen später. Ein **Test liest deshalb alle Route-Dateien** und
verlangt, dass jeder schreibende Endpunkt entweder `touchScenario` aufruft oder mit Begründung
in einer Ausnahmeliste steht; verwaiste Ausnahmen meldet er ebenfalls.
Das Festhalten einer Version ist bewusst **fehlertolerant**: Schlägt es fehl, wird es
protokolliert, die eigentliche Änderung des Nutzers aber nicht zurückgewiesen. Die Historie
ist Begleitinformation, nicht der Zweck der Anfrage.
Referenz: `src/lib/versioning.ts` (reine Logik), `src/lib/versioning-db.ts` (Datenbank),
`src/components/VersionHistoryDialog.tsx`, `src/components/VersionMatrix.tsx`,
`src/components/VersionPicker.tsx`.
---
# 4. Berechnungsmodell
@@ -2406,7 +2505,7 @@ Referenz: `src/lib/livesim.ts`, `src/lib/sensitivity.ts` (`applyElementDriver`,
FPT/
├── prisma/
│ ├── schema.prisma Datenmodell
│ └── migrations/ 12 Migrationen (chronologisch, siehe 5.4.6)
│ └── migrations/ 13 Migrationen (chronologisch, siehe 5.4.6)
├── src/
│ ├── app/
│ │ ├── api/ Route Handlers (siehe Kapitel 6)
@@ -2447,6 +2546,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. |
| `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. |
| `livesim.ts` | Live-Simulation: Reglerkatalog mit Standardbereichen, Anwenden mehrerer Regler, Kennzahlen (Kap. 4.15). Rein, läuft im Browser. |
| `distribution.ts` | Kapitaltopf und Quoten-Zerlegung, Anwenden von Entwurfswerten für die Verteil-Werkzeuge (Kap. 3.6.9/3.6.10). Rein. |
| `phaseplan.ts` | Ableitung der Lebensabschnitte (Erwerb/Misch/Pension) aus den fixen Pensionierungszeitpunkten für den Assistenten (Kap. 3.2.8). Rein. |
@@ -2658,6 +2759,7 @@ sondern zu leeren Werten.
| `20260716230000_phase_cash_transition` | `Phase.cashTransition` (JSONB) für einmalige Sonderein-/ausgaben |
| `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` |
**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
@@ -2705,6 +2807,9 @@ wird der Plan neu geladen; die Berechnung kommt immer vom Server.
| `PhaseDetail` | 95 | Phase bearbeiten/löschen |
| `SparquoteChart` | 64 | Einkommen/Ausgaben pro Jahr mit Spar-/Verzehrbändern |
| `MonteCarloDialog` | ~600 | Monte-Carlo-Dialog: Erklärung, Szenario-Auswahl, Eingaben, Lauf, Vergleichstabelle + Fächer |
| `VersionHistoryDialog` | ~290 | Änderungshistorie: Liste, Hauptversion festlegen, Anzeigen, Wiederherstellen mit Warnung ([3.8](#38-versionierung-und-änderungshistorie)) |
| `VersionMatrix` | ~110 | Nur-Lese-Matrix eines festgehaltenen Standes |
| `VersionPicker` | ~140 | Wahl der Berechnungsgrundlage in den vier Analysewerkzeugen |
| `LiveSimDialog` | ~330 | Live-Simulation: Regler links, Grafikwahl rechts, Kennzahlenleiste mit Differenz zum Plan ([4.15](#415-live-simulation-was-wäre-wenn-regler)) |
| `AllocationChart` | ~80 | Gestapelte Vermögensaufteilung je Phase; aus dem Dashboard herausgelöst, damit die Live-Simulation sie mitbenutzen kann |
| `SensitivityDialog` | ~330 | Einflussfaktoren: Erklärung, Treiber-Auswahl mit Bandbreiten, Tornado + Tabelle |
@@ -2838,7 +2943,34 @@ Akzeptiert eine **Union** von zwei Formen:
→ 201 `{ scenarioId: "<neue Id>" }`
### `GET /api/scenarios/<scenarioId>/export`
`text/csv; charset=utf-8`, `Content-Disposition: attachment`.
`text/csv; charset=utf-8`, `Content-Disposition: attachment`. Liefert immer den **aktuellen**
Stand, auch wenn im Client eine ältere Version betrachtet wird ([3.8.4](#384-analyse-auf-einer-bestimmten-version)).
### `GET /api/scenarios/<scenarioId>/versions`
Änderungshistorie, neueste zuerst **ohne** die Snapshots (je zig Kilobyte).
```json
{ "currentMajor": 2,
"versions": [ { "id", "major", "minor", "comment", "isMajor",
"createdAt", "updatedAt", "author" } ] }
```
### `POST /api/scenarios/<scenarioId>/versions`
`{ comment }` (Pflicht, ≥ 3 Zeichen) legt den aktuellen Stand als **Hauptversion** fest und
setzt `Scenario.currentMajor`. → 201 `{ version: { major, minor } }` · 400 ohne Kommentar.
### `GET /api/scenarios/<scenarioId>/versions/<versionId>`
Ein einzelner Stand samt Berechnung und der Vorwarnung für das Wiederherstellen:
```json
{ "version": { "id", "major", "minor", "comment", "isMajor", "createdAt" },
"plan": <PlanInput>, "computed": <PlanComputed>,
"impact": { "lostElementIds": [], "lostPhaseIds": [], "affectedChildren": [] } }
```
`impact` benennt die Kind-Szenarien, die durch ein Wiederherstellen ihre Diff-Basis verlören.
### `POST /api/scenarios/<scenarioId>/versions/<versionId>`
Setzt das Szenario auf diesen Stand zurück **IDs bleiben erhalten**, und der wiederhergestellte
Stand wird selbst als neue Version festgehalten («Wiederhergestellt aus A.B»).
→ 200 `{ version: { major, minor } }`
## 6.4 Phasen
@@ -2949,12 +3081,14 @@ 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 |
| `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 |
| `livesim.test.ts` | 15 | Element-Regler bewegt genau ein Element; Aufschlüsselung deckt sich mit dem Sammelregler; neutrale Stellung verändert den Plan nicht; Ruinmeldung |
| `phaseplan.test.ts` | 8 | Ableitung der Lebensabschnitte aus den Pensionierungszeitpunkten (Einzel/Paar/bereits pensioniert); letzter Teil immer offen |
| `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** | **139** | |
| **Total** | **164** | |
## 8.2 Testfälle
@@ -3416,6 +3550,39 @@ neben jedem Regler steht sein **Neutralpunkt** der Wert, bei dem der Plan un
Bei allen Verschiebungs-Treibern ist das 0, bei der Inflation der Planwert selbst, weil sie als
einzige absolut und nicht als Differenz eingegeben wird.
## 9.28 Was die Versionierung nicht leistet
**Das Zeitfenster ist eine Konvention, keine Wahrheit.** Zehn Minuten sind gesetzt, weil FPT
keinen Speichern-Knopf hat und der Nutzer den Schnitt sonst nie selbst zieht
([3.8.1](#381-eine-version-je-sitzung--nicht-je-änderung)). Wer nach acht Minuten Pause
weiterarbeitet, landet in derselben Version; wer nach zwölf Minuten eine Kleinigkeit ändert,
bekommt eine neue. Beides ist gelegentlich nicht das, was man gemeint hätte. Ein
Speichern-Knopf wäre die exaktere Lösung, würde aber die Bedienung des ganzen Werkzeugs
umkrempeln.
**Eine Nebenversion hält den Stand am ENDE der Sitzung fest**, nicht jeden Zwischenschritt
darin. Wer innerhalb einer Sitzung etwas ändert und wieder zurücknimmt, findet den
Zwischenstand nirgends. Die Historie ist eine Folge von Arbeitsständen, kein Undo.
**Es gibt keinen Versionsvergleich.** Zwei Stände lassen sich nur nacheinander ansehen, nicht
nebeneinander stellen. Der bestehende Diff ([3.2.6](#326-abweichungs-markierung-diff))
vergleicht Szenarien gegen ihr Eltern-Szenario, nicht Versionen gegeneinander technisch
wäre beides verwandt, aber es ist bewusst nicht Teil dieser Stufe.
**Die Historie wird nie beschnitten.** Jede Version hält den vollständigen Zustand als JSON
(Grössenordnung 40 KB bei sechs Phasen und zwölf Elementen). Bei intensiver Nutzung wächst
das linear; eine Aufräumregel (etwa: Nebenversionen älter als ein Jahr verdichten, Haupt-
versionen behalten) gibt es noch nicht. Bei den heutigen Datenmengen ist das unkritisch.
**«Wer» unterscheidet heute nichts.** Ein Plan gehört genau einem Benutzer, es gibt keine
Freigabe und keine Rollen die Spalte zeigt also immer denselben Namen. Sie ist Vorbereitung
auf den Finanzberater, und die Logik trennt Benutzer bereits sauber (Änderungen verschiedener
Benutzer werden nie in einer Version zusammengefasst).
**Der Plan selbst ist nicht versioniert**, nur seine Szenarien. Wird ein Szenario gelöscht,
verschwindet seine Historie mit ihm (Cascade). Das ist gewollt: Eine Historie ohne das Objekt,
das sie beschreibt, wäre nicht wiederherstellbar.
---
# 10. Glossar
@@ -0,0 +1,44 @@
-- Versionierung je Szenario (SPEZIFIKATION 3.8, 4.16).
--
-- Eine Version haelt den VOLLSTAENDIGEN Zustand des Szenarios als JSON (Form: PlanInput),
-- nicht eine Differenz. Grund: Alle Analysewerkzeuge nehmen ohnehin PlanInput entgegen --
-- damit lassen sich alte Staende ohne Umbau rechnen, anzeigen und wiederherstellen.
-- Laufende Hauptversion des Szenarios. Nebenversionen zaehlen in ScenarioVersion.
ALTER TABLE "Scenario" ADD COLUMN "currentMajor" INTEGER NOT NULL DEFAULT 1;
CREATE TABLE "ScenarioVersion" (
"id" TEXT NOT NULL,
"scenarioId" TEXT NOT NULL,
-- Version A.B: major wird manuell hochgezogen, minor je Bearbeitungssitzung.
"major" INTEGER NOT NULL,
"minor" INTEGER NOT NULL,
-- Pflicht bei Hauptversionen, sonst leer. Wiederherstellungen tragen hier ihre Herkunft.
"comment" TEXT,
"isMajor" BOOLEAN NOT NULL DEFAULT false,
-- Wer die Version erzeugt hat. Heute immer der Eigentuemer; vorbereitet auf spaetere
-- Freigabe an einen Finanzberater.
"createdById" TEXT NOT NULL,
-- Vollstaendiger Zustand (PlanInput als JSON).
"snapshot" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- Wird bei Zusammenfassung innerhalb einer Bearbeitungssitzung mitgezogen.
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ScenarioVersion_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "ScenarioVersion_scenarioId_major_minor_key"
ON "ScenarioVersion"("scenarioId", "major", "minor");
-- Die Historie wird immer neuestzuerst gelesen.
CREATE INDEX "ScenarioVersion_scenarioId_createdAt_idx"
ON "ScenarioVersion"("scenarioId", "createdAt");
ALTER TABLE "ScenarioVersion" ADD CONSTRAINT "ScenarioVersion_scenarioId_fkey"
FOREIGN KEY ("scenarioId") REFERENCES "Scenario"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Beim Loeschen eines Benutzers verschwinden dessen Plaene ohnehin per Cascade; der
-- Verweis bleibt bewusst RESTRICT-frei, damit die Historie nie ein Loeschen blockiert.
ALTER TABLE "ScenarioVersion" ADD CONSTRAINT "ScenarioVersion_createdById_fkey"
FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+33 -1
View File
@@ -32,7 +32,8 @@ model User {
passwordHash String
createdAt DateTime @default(now())
plans Plan[]
plans Plan[]
versions ScenarioVersion[]
}
enum HouseholdType {
@@ -114,9 +115,40 @@ model Scenario {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Laufende Hauptversion. Die Nebenversionen zaehlen in ScenarioVersion.
currentMajor Int @default(1)
persons Person[]
phases Phase[]
elements FinancialElement[]
versions ScenarioVersion[]
}
// Ein festgehaltener Stand eines Szenarios (Version A.B). Haelt den VOLLSTAENDIGEN Zustand
// als JSON in der Form PlanInput -- nicht eine Differenz. Dadurch laesst sich jeder alte
// Stand ohne Umbau rechnen (computePlan), anzeigen und wiederherstellen.
model ScenarioVersion {
id String @id @default(cuid())
scenarioId String
scenario Scenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade)
major Int
minor Int
// Pflicht bei Hauptversionen; Wiederherstellungen tragen hier ihre Herkunft.
comment String?
isMajor Boolean @default(false)
createdById String
createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade)
snapshot Json
createdAt DateTime @default(now())
// Wird bei Zusammenfassung innerhalb einer Bearbeitungssitzung mitgezogen.
updatedAt DateTime @updatedAt
@@unique([scenarioId, major, minor])
@@index([scenarioId, createdAt])
}
// Ein Lebensabschnitt innerhalb eines Plans. Der Phasentyp (Erwerb/Pension/Mischung)
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getOwnedElement } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { touchScenario } from "@/lib/versioning-db";
import { phaseDataSchema } from "@/lib/elements";
// Speichert die Werte eines Elements innerhalb einer Lebensphase (Upsert).
@@ -29,5 +30,6 @@ export async function PUT(
update: { data: parsed.data },
});
await touchScenario(element.scenarioId, userId);
return NextResponse.json({ ok: true });
}
@@ -3,6 +3,7 @@ import { z } from "zod";
import { prisma } from "@/lib/db";
import { getOwnedElement } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { touchScenario } from "@/lib/versioning-db";
const patchSchema = z.object({ name: z.string().min(1).max(120) });
@@ -22,6 +23,7 @@ export async function PATCH(
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
await prisma.financialElement.update({ where: { id: element.id }, data: { name: parsed.data.name } });
await touchScenario(element.scenarioId, userId);
return NextResponse.json({ ok: true });
}
@@ -37,5 +39,6 @@ export async function DELETE(
if (!element) return NextResponse.json({ error: "Element nicht gefunden." }, { status: 404 });
await prisma.financialElement.delete({ where: { id: element.id } });
await touchScenario(element.scenarioId, userId);
return NextResponse.json({ ok: true });
}
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getOwnedElement } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { touchScenario } from "@/lib/versioning-db";
import { transitionDataSchema } from "@/lib/elements";
// Speichert den Übergangs-Entscheid eines Elements nach der Phase fromPhase (Upsert).
@@ -29,5 +30,6 @@ export async function PUT(
update: { data: parsed.data },
});
await touchScenario(element.scenarioId, userId);
return NextResponse.json({ ok: true });
}
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getOwnedPhase } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { touchScenario } from "@/lib/versioning-db";
import { cashTransitionSchema } from "@/lib/elements";
// Speichert den Cash-Entscheid beim UEBERGANG nach dieser Phase: 1:1 übernehmen oder
@@ -27,5 +28,6 @@ export async function PUT(
data: { cashTransition: parsed.data },
});
await touchScenario(phase.scenarioId, userId);
return NextResponse.json({ ok: true });
}
+3
View File
@@ -3,6 +3,7 @@ import { z } from "zod";
import { prisma } from "@/lib/db";
import { getOwnedPhase, getOwnedScenario, toPlanInput } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { touchScenario } from "@/lib/versioning-db";
import { maxPhaseDuration } from "@/lib/calculations";
const updatePhaseSchema = z.object({
@@ -47,6 +48,7 @@ export async function PUT(
durationYears: duration ?? undefined,
},
});
await touchScenario(existing.scenarioId, userId);
return NextResponse.json({ phase: { id: phase.id } });
}
@@ -70,5 +72,6 @@ export async function DELETE(
}
await prisma.phase.delete({ where: { id: phaseId } });
await touchScenario(phase.scenarioId, userId);
return NextResponse.json({ ok: true });
}
+3
View File
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { getCurrentUserId } from "@/lib/session";
import { touchScenario } from "@/lib/versioning-db";
const personSchema = z.object({
role: z.enum(["PERSON_A", "PERSON_B"]),
@@ -81,6 +82,8 @@ export async function POST(request: NextRequest) {
include: { scenarios: true },
});
// Das frisch angelegte Basisszenario startet mit Version 1.0.
await touchScenario(plan.scenarios[0].id, userId);
return NextResponse.json(
{ plan: { id: plan.id }, scenario: { id: plan.scenarios[0].id } },
{ status: 201 }
@@ -3,6 +3,7 @@ import { z } from "zod";
import { prisma } from "@/lib/db";
import { getOwnedScenario } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { touchScenario } from "@/lib/versioning-db";
const copySchema = z.object({ name: z.string().min(1).max(120) });
@@ -90,5 +91,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
return created.id;
});
// Das kopierte Szenario startet mit einer eigenen Version 1.0.
await touchScenario(newId, userId);
return NextResponse.json({ scenarioId: newId }, { status: 201 });
}
@@ -3,6 +3,7 @@ import { z } from "zod";
import { prisma } from "@/lib/db";
import { getOwnedScenario } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { touchScenario } from "@/lib/versioning-db";
import { PERSON_ONLY_CATEGORIES } from "@/lib/elements";
const createSchema = z.object({
@@ -65,5 +66,6 @@ export async function POST(
},
});
await touchScenario(scenario.id, userId);
return NextResponse.json({ element: { id: element.id } }, { status: 201 });
}
@@ -3,6 +3,7 @@ import { z } from "zod";
import { prisma } from "@/lib/db";
import { getOwnedScenario, toPlanInput } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { touchScenario } from "@/lib/versioning-db";
import { Prisma } from "@/generated/prisma/client";
import { computePlan, maxPhaseDuration } from "@/lib/calculations";
import { num, type PhaseData } from "@/lib/elements";
@@ -74,6 +75,7 @@ export async function POST(
return created;
});
await touchScenario(scenario.id, userId);
return NextResponse.json({ phase: { id: phase.id } }, { status: 201 });
}
@@ -3,6 +3,7 @@ import { z } from "zod";
import { prisma } from "@/lib/db";
import { toPlanInput, getOwnedScenario, getOwnedScenarioWithMeta } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { touchScenario } from "@/lib/versioning-db";
import { computePlan } from "@/lib/calculations";
import { scenarioProfileSchema, validatePersonsForType } from "@/app/api/plans/route";
@@ -77,6 +78,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
},
});
});
await touchScenario(scenario.id, userId);
return NextResponse.json({ scenario: { id: updated.id, name: updated.name } });
}
@@ -88,6 +90,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
"initialCash" in data && data.initialCash != null ? Math.round(data.initialCash) : undefined,
},
});
await touchScenario(scenario.id, userId);
return NextResponse.json({ scenario: { id: updated.id, name: updated.name } });
}
@@ -0,0 +1,61 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getCurrentUserId } from "@/lib/session";
import { computePlan } from "@/lib/calculations";
import { restoreImpact, restoreVersion } from "@/lib/versioning-db";
import type { PlanInput } from "@/lib/types";
async function ownedVersion(scenarioId: string, versionId: string, userId: string) {
return prisma.scenarioVersion.findFirst({
where: { id: versionId, scenarioId, scenario: { plan: { userId } } },
});
}
// Ein einzelner Stand: der Snapshot selbst, das daraus Gerechnete und -- als Vorwarnung für
// den Wiederherstellen-Knopf -- welche Kind-Szenarien dabei ihre Diff-Basis verlieren würden.
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ scenarioId: string; versionId: string }> }
) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { scenarioId, versionId } = await params;
const version = await ownedVersion(scenarioId, versionId, userId);
if (!version) return NextResponse.json({ error: "Version nicht gefunden." }, { status: 404 });
const snapshot = version.snapshot as unknown as PlanInput;
return NextResponse.json({
version: {
id: version.id,
major: version.major,
minor: version.minor,
comment: version.comment,
isMajor: version.isMajor,
createdAt: version.createdAt,
},
plan: snapshot,
computed: computePlan(snapshot),
impact: await restoreImpact(scenarioId, snapshot),
});
}
// Setzt das Szenario auf diesen Stand zurück. Löscht KEINE Historie: Der wiederhergestellte
// Stand wird selbst als neue Version mit Herkunftsvermerk festgehalten.
export async function POST(
_request: NextRequest,
{ params }: { params: Promise<{ scenarioId: string; versionId: string }> }
) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { scenarioId, versionId } = await params;
const version = await ownedVersion(scenarioId, versionId, userId);
if (!version) return NextResponse.json({ error: "Version nicht gefunden." }, { status: 404 });
const ref = await restoreVersion(scenarioId, versionId, userId);
if (!ref) return NextResponse.json({ error: "Wiederherstellen fehlgeschlagen." }, { status: 500 });
return NextResponse.json({ version: ref });
}
@@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { getCurrentUserId } from "@/lib/session";
import { createMajorVersion } from "@/lib/versioning-db";
import { isValidMajorComment } from "@/lib/versioning";
async function ownedScenario(scenarioId: string, userId: string) {
return prisma.scenario.findFirst({ where: { id: scenarioId, plan: { userId } } });
}
// Änderungshistorie eines Szenarios, neueste zuerst. Ohne die Snapshots -- die sind je
// Version zig Kilobyte gross und werden erst beim Anzeigen einzeln geladen.
export async function GET(_request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { scenarioId } = await params;
const scenario = await ownedScenario(scenarioId, userId);
if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
const rows = await prisma.scenarioVersion.findMany({
where: { scenarioId },
orderBy: [{ major: "desc" }, { minor: "desc" }],
select: {
id: true,
major: true,
minor: true,
comment: true,
isMajor: true,
createdAt: true,
updatedAt: true,
createdBy: { select: { username: true } },
},
});
return NextResponse.json({
currentMajor: scenario.currentMajor,
versions: rows.map((r) => ({
id: r.id,
major: r.major,
minor: r.minor,
comment: r.comment,
isMajor: r.isMajor,
createdAt: r.createdAt,
updatedAt: r.updatedAt,
author: r.createdBy.username,
})),
});
}
const majorSchema = z.object({ comment: z.string().min(3).max(500) });
// Legt den aktuellen Stand als HAUPTVERSION fest. Der Kommentar ist Pflicht -- eine
// Hauptversion ohne Begründung wäre nur eine Zahl.
export async function POST(request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { scenarioId } = await params;
const scenario = await ownedScenario(scenarioId, userId);
if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
const parsed = majorSchema.safeParse(await request.json());
if (!parsed.success || !isValidMajorComment(parsed.data.comment)) {
return NextResponse.json({ error: "Bitte einen Kommentar zur Hauptversion angeben." }, { status: 400 });
}
const ref = await createMajorVersion(scenarioId, userId, parsed.data.comment);
if (!ref) return NextResponse.json({ error: "Hauptversion konnte nicht angelegt werden." }, { status: 500 });
return NextResponse.json({ version: ref }, { status: 201 });
}
+16
View File
@@ -9,6 +9,7 @@ import {
FileText,
FolderKanban,
GitBranch,
History,
LayoutDashboard,
Menu,
PiggyBank,
@@ -25,6 +26,7 @@ import { Dashboard } from "@/components/Dashboard";
import { MonteCarloDialog } from "@/components/MonteCarloDialog";
import { SensitivityDialog } from "@/components/SensitivityDialog";
import { LiveSimDialog } from "@/components/LiveSimDialog";
import { VersionHistoryDialog } from "@/components/VersionHistoryDialog";
import { SpecView } from "@/components/SpecView";
import { SystemParametersView } from "@/components/SystemParametersView";
import { PlanTraceDialog } from "@/components/DetailView";
@@ -87,6 +89,7 @@ function AppShellInner({ username }: { username: string }) {
const [showMonteCarlo, setShowMonteCarlo] = useState(false);
const [showSensitivity, setShowSensitivity] = useState(false);
const [showLiveSim, setShowLiveSim] = useState(false);
const [showHistory, setShowHistory] = useState(false);
const [showSystemParams, setShowSystemParams] = useState(false);
const [showPlanTraces, setShowPlanTraces] = useState(false);
const [showPalette, setShowPalette] = useState(false);
@@ -423,6 +426,10 @@ function AppShellInner({ username }: { username: string }) {
<BarChart3 className="h-4 w-4" />
Grafiken
</Button>
<Button variant="secondary" onClick={() => setShowHistory(true)}>
<History className="h-4 w-4" />
Änderungshistorie
</Button>
<Button variant="secondary" onClick={() => setShowLiveSim(true)}>
<SlidersHorizontal className="h-4 w-4" />
Live-Simulation
@@ -540,6 +547,15 @@ function AppShellInner({ username }: { username: string }) {
<LiveSimDialog plan={detail.plan} onClose={() => setShowLiveSim(false)} />
)}
{showHistory && detail && (
<VersionHistoryDialog
scenarioId={detail.meta.id}
scenarioName={detail.meta.name}
onClose={() => setShowHistory(false)}
onRestored={refreshCurrent}
/>
)}
{showPlanTraces && detail && (
<PlanTraceDialog
computed={computePlan(detail.plan, undefined, { explain: true })}
+28 -4
View File
@@ -3,6 +3,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 { WealthChart, type TimelineSeries } from "@/components/WealthChart";
import { SparquoteChart } from "@/components/SparquoteChart";
import { api } from "@/lib/api-client";
@@ -16,8 +18,8 @@ interface PlanListItem {
}
export function Dashboard({
plan,
computed,
plan: currentPlan,
computed: currentComputed,
siblings,
}: {
plan: PlanInput;
@@ -25,6 +27,14 @@ export function Dashboard({
// Die übrigen Szenarien desselben Plans -- nur die sind sinnvoll vergleichbar.
siblings: PlanListItem[];
}) {
// 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 computed = useMemo(
() => (versionId === "current" ? currentComputed : computePlan(plan)),
[versionId, currentComputed, plan]
);
const [compareIds, setCompareIds] = useState<string[]>([]);
const [compareData, setCompareData] = useState<Record<string, PlanComputed>>({});
@@ -55,6 +65,13 @@ export function Dashboard({
return (
<div className="flex flex-col gap-6">
<VersionBar
scenarioId={currentPlan.id}
versionId={versionId}
onChange={setVersionId}
loading={versionLoading}
/>
<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} />
@@ -78,12 +95,19 @@ export function Dashboard({
<LineChartIcon className="h-4 w-4 text-accent" />
Vermögensverlauf nach Alter
</h3>
{/* Der Export liest immer das Szenario aus der Datenbank -- also den aktuellen
Stand, nicht die betrachtete Version. Das wird beschriftet statt verschwiegen. */}
<a
href={`/api/scenarios/${plan.id}/export`}
href={`/api/scenarios/${currentPlan.id}/export`}
title={
versionId === "current"
? undefined
: "Der CSV-Export liefert immer den aktuellen Stand, nicht die betrachtete Version."
}
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-xs font-medium text-muted hover:bg-surface-2"
>
<Download className="h-3.5 w-3.5" />
CSV-Export
CSV-Export{versionId === "current" ? "" : " (aktueller Stand)"}
</a>
</div>
{otherPlans.length > 0 && (
+14 -1
View File
@@ -6,6 +6,7 @@ import { AllocationChart } from "@/components/AllocationChart";
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 { formatChf } from "@/lib/format";
import {
@@ -43,7 +44,9 @@ const CHARTS: { id: ChartId; label: string; hint: string }[] = [
const BASE_COLOR = "#9ca3af";
const LIVE_COLOR = "#4f46e5";
export function LiveSimDialog({ plan, onClose }: { plan: PlanInput; onClose: () => void }) {
export function LiveSimDialog({ plan: currentPlan, onClose }: { plan: PlanInput; onClose: () => void }) {
// Geregelt wird wahlweise am Arbeitsstand oder an einer festgehaltenen Version.
const { versionId, setVersionId, plan, loading: versionLoading } = useVersionedPlan(currentPlan.id, currentPlan);
const [expandReturns, setExpandReturns] = useState(false);
const [values, setValues] = useState<SliderValues>({});
// Vom Nutzer überschriebene Reglerbereiche (Schlüssel -> [min, max]).
@@ -108,6 +111,16 @@ export function LiveSimDialog({ plan, onClose }: { plan: PlanInput; onClose: ()
</button>
</div>
<VersionBar
scenarioId={currentPlan.id}
versionId={versionId}
onChange={(id) => {
setVersionId(id);
setValues({});
}}
loading={versionLoading}
/>
<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
+44 -2
View File
@@ -5,6 +5,8 @@ import { Area, CartesianGrid, ComposedChart, Legend, Line, LineChart, Responsive
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 { computePlan } from "@/lib/calculations";
import { api } from "@/lib/api-client";
import { formatChf } from "@/lib/format";
import {
@@ -137,6 +139,10 @@ export function MonteCarloDialog({
const [running, setRunning] = useState(false);
const [progress, setProgress] = useState({ index: 0, count: 1, fraction: 0 });
// Gewaehlter Stand je Szenario (Default: Arbeitsstand) und die dazu geladenen Snapshots.
const [versionByScenario, setVersionByScenario] = useState<Record<string, string>>({});
const [versionPlans, setVersionPlans] = useState<Record<string, PlanInput>>({});
const [outcomes, setOutcomes] = useState<Outcome[] | null>(null);
// Fächer/Bänder stammen aus der historischen Welt.
const [fanRes, setFanRes] = useState<ScenarioMcResult[] | null>(null);
@@ -169,12 +175,40 @@ export function MonteCarloDialog({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scenarioKey, meta.id]);
async function chooseVersion(scenarioId: string, versionId: string) {
setVersionByScenario((prev) => ({ ...prev, [scenarioId]: versionId }));
clearResults();
if (versionId === CURRENT) return;
const key = `${scenarioId}:${versionId}`;
if (versionPlans[key]) return;
try {
const loadedPlan = await loadVersionPlan(scenarioId, versionId);
if (loadedPlan) setVersionPlans((prev) => ({ ...prev, [key]: loadedPlan }));
} catch {
setVersionByScenario((prev) => ({ ...prev, [scenarioId]: CURRENT }));
}
}
const allLoaded = useMemo(
() => scenarios.map((s) => loaded[s.id]).filter((s): s is LoadedScenario => !!s),
[scenarios, loaded]
);
const groups = useMemo(() => buildElementGroups(allLoaded, selectedIds), [allLoaded, selectedIds]);
// Ersetzt je Szenario den Arbeitsstand durch den gewaehlten Snapshot. Der Snapshot ist
// bereits ein PlanInput, das Gerechnete entsteht lokal.
const resolved = useMemo(() => {
const out: Record<string, LoadedScenario> = {};
for (const sc of allLoaded) {
const vid = versionByScenario[sc.id] ?? CURRENT;
const snap = vid === CURRENT ? null : versionPlans[`${sc.id}:${vid}`];
out[sc.id] = snap ? { ...sc, plan: snap, computed: computePlan(snap) } : sc;
}
return out;
}, [allLoaded, versionByScenario, versionPlans]);
const resolvedList = useMemo(() => Object.values(resolved), [resolved]);
const groups = useMemo(() => buildElementGroups(resolvedList, selectedIds), [resolvedList, selectedIds]);
function draftFor(g: ElementGroup): ElementDraft {
return drafts[g.rootId] ?? { mean: "", level: defaultVolatilityLevel(g.category), manualSigma: "10" };
@@ -193,7 +227,7 @@ export function MonteCarloDialog({
clearResults();
}
const selected = selectedIds.map((id) => loaded[id]).filter((s): s is LoadedScenario => !!s);
const selected = selectedIds.map((id) => resolved[id]).filter((s): s is LoadedScenario => !!s);
const anyReturnBearing = selected.some((s) => s.plan.elements.some((e) => RETURN_BEARING.includes(e.category)));
const missingHist = inflMean.trim() === "" || groups.some((g) => draftFor(g).mean.trim() === "");
@@ -362,6 +396,14 @@ export function MonteCarloDialog({
{s.isBase && <span className="rounded bg-surface px-1.5 text-[10px] text-muted">Basis</span>}
{!isLoaded && <span className="text-[11px] text-faint">lädt</span>}
</label>
{checked && (
<VersionSelect
compact
scenarioId={s.id}
value={versionByScenario[s.id] ?? CURRENT}
onChange={(vid) => void chooseVersion(s.id, vid)}
/>
)}
{checked && typeof nachlass === "number" && (
<span className="text-[11px] text-muted" title="Planungs-Endbetrag dieses Szenarios (read-only)">
Planungs-Endbetrag: <strong className="text-fg">{formatChf(Math.max(0, nachlass))}</strong>
+14 -1
View File
@@ -5,6 +5,7 @@ import { Bar, BarChart, CartesianGrid, ReferenceLine, ResponsiveContainer, Toolt
import { Tornado, X } from "lucide-react";
import { RequiredNumberField, SelectField } from "@/components/FormField";
import { InfoBubble } from "@/components/InfoBubble";
import { useVersionedPlan, VersionBar } from "@/components/VersionPicker";
import { formatChf } from "@/lib/format";
import {
computeTornado,
@@ -34,7 +35,9 @@ function formatRange(low: number, high: number, unit: DriverDef["unit"]): string
return `${sign(low)} ${suffix}${sign(high)} ${suffix}`;
}
export function SensitivityDialog({ plan, onClose }: { plan: PlanInput; onClose: () => void }) {
export function SensitivityDialog({ plan: currentPlan, onClose }: { plan: PlanInput; 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");
@@ -103,6 +106,16 @@ export function SensitivityDialog({ plan, onClose }: { plan: PlanInput; onClose:
</button>
</div>
<VersionBar
scenarioId={currentPlan.id}
versionId={versionId}
onChange={(id) => {
setVersionId(id);
setResult(null);
}}
loading={versionLoading}
/>
{/* Erklärung */}
<div className="rounded-xl border border-border bg-surface-2 p-4 text-xs leading-relaxed text-muted">
<p className="mb-2">
+327
View File
@@ -0,0 +1,327 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { AlertTriangle, Eye, History, RotateCcw, Tag, X } from "lucide-react";
import { VersionMatrix } from "@/components/VersionMatrix";
import { Button, useConfirm, useToast } from "@/components/ui";
import { api } from "@/lib/api-client";
import { formatChf } from "@/lib/format";
import type { PlanComputed } from "@/lib/calculations";
import type { PlanInput } from "@/lib/types";
import type { RestoreImpact } from "@/lib/versioning";
export interface VersionRow {
id: string;
major: number;
minor: number;
comment: string | null;
isMajor: boolean;
createdAt: string;
updatedAt: string;
author: string;
}
interface VersionDetail {
version: { id: string; major: number; minor: number; comment: string | null; isMajor: boolean };
plan: PlanInput;
computed: PlanComputed;
impact: RestoreImpact;
}
const dt = (iso: string) =>
new Date(iso).toLocaleString("de-CH", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
export function VersionHistoryDialog({
scenarioId,
scenarioName,
onClose,
onRestored,
}: {
scenarioId: string;
scenarioName: string;
onClose: () => void;
onRestored: () => void;
}) {
const [rows, setRows] = useState<VersionRow[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [detail, setDetail] = useState<VersionDetail | null>(null);
const [busy, setBusy] = useState(false);
const [majorComment, setMajorComment] = useState("");
const [showMajorForm, setShowMajorForm] = useState(false);
const confirm = useConfirm();
const toast = useToast();
// Nachladen nach einer Aktion (Wiederherstellen, Hauptversion) -- nicht beim Öffnen.
const load = useCallback(async () => {
try {
const data = await api.get<{ versions: VersionRow[] }>(`/api/scenarios/${scenarioId}/versions`);
setRows(data.versions);
} catch (e) {
setError(e instanceof Error ? e.message : "Historie konnte nicht geladen werden.");
}
}, [scenarioId]);
// Erstes Laden. Das Cancel-Flag verhindert ein setState nach dem Schliessen des Dialogs.
useEffect(() => {
let cancelled = false;
(async () => {
try {
const data = await api.get<{ versions: VersionRow[] }>(`/api/scenarios/${scenarioId}/versions`);
if (!cancelled) setRows(data.versions);
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : "Historie konnte nicht geladen werden.");
}
})();
return () => {
cancelled = true;
};
}, [scenarioId]);
async function openDetail(id: string) {
setBusy(true);
try {
setDetail(await api.get<VersionDetail>(`/api/scenarios/${scenarioId}/versions/${id}`));
} catch (e) {
toast("error", e instanceof Error ? e.message : "Version konnte nicht geladen werden.");
} finally {
setBusy(false);
}
}
async function restore(row: VersionRow) {
// Erst die Auswirkung holen: Kind-Szenarien können ihre Diff-Basis verlieren, und das
// muss VOR dem Klick auf dem Tisch liegen, nicht danach.
setBusy(true);
let impact: RestoreImpact;
try {
const d = await api.get<VersionDetail>(`/api/scenarios/${scenarioId}/versions/${row.id}`);
impact = d.impact;
} catch (e) {
setBusy(false);
toast("error", e instanceof Error ? e.message : "Version konnte nicht geladen werden.");
return;
}
setBusy(false);
const warn =
impact.affectedChildren.length > 0
? `\n\nAchtung: ${impact.affectedChildren.length === 1 ? "Das Szenario" : "Die Szenarien"} ` +
`«${impact.affectedChildren.join("», «")}» ${impact.affectedChildren.length === 1 ? "hängt" : "hängen"} ` +
`an diesem Szenario. Dieser Stand kannte ${impact.lostElementIds.length} Element(e) und ` +
`${impact.lostPhaseIds.length} Phase(n) noch nicht, auf die dort verwiesen wird die Abweichungs-Markierung ` +
`zeigt sie danach als «neu» statt als «geändert».`
: "";
const ok = await confirm({
title: `Auf Version ${row.major}.${row.minor} zurücksetzen?`,
message:
`Das Szenario «${scenarioName}» wird vollständig auf diesen Stand zurückgesetzt. ` +
`Es geht nichts verloren: Der wiederhergestellte Stand wird als neue Version festgehalten, ` +
`die bisherige Historie bleibt vollständig erhalten.${warn}`,
confirmLabel: "Wiederherstellen",
danger: impact.affectedChildren.length > 0,
});
if (!ok) return;
setBusy(true);
try {
const res = await api.post<{ version: { major: number; minor: number } }>(
`/api/scenarios/${scenarioId}/versions/${row.id}`,
{}
);
toast("success", `Wiederhergestellt aus ${row.major}.${row.minor} neue Version ${res.version.major}.${res.version.minor}.`);
await load();
onRestored();
} catch (e) {
toast("error", e instanceof Error ? e.message : "Wiederherstellen fehlgeschlagen.");
} finally {
setBusy(false);
}
}
async function createMajor() {
setBusy(true);
try {
const res = await api.post<{ version: { major: number; minor: number } }>(
`/api/scenarios/${scenarioId}/versions`,
{ comment: majorComment }
);
toast("success", `Hauptversion ${res.version.major}.${res.version.minor} festgelegt.`);
setMajorComment("");
setShowMajorForm(false);
await load();
onRestored();
} catch (e) {
toast("error", e instanceof Error ? e.message : "Hauptversion konnte nicht angelegt werden.");
} finally {
setBusy(false);
}
}
// --- Detailansicht (nur lesen) ---
if (detail) {
const last = detail.computed.phases[detail.computed.phases.length - 1];
return (
<div className="ui-fade fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={() => setDetail(null)}>
<div onClick={(e) => e.stopPropagation()} className="ui-pop flex w-full max-w-5xl flex-col gap-3 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">
<Eye className="h-5 w-5 text-accent" />
Version {detail.version.major}.{detail.version.minor}
{detail.version.isMajor && (
<span className="rounded bg-accent-soft px-1.5 py-0.5 text-[10px] font-semibold text-accent-soft-fg">
Hauptversion
</span>
)}
</h2>
<p className="mt-0.5 text-xs text-muted">
Nur-Lese-Ansicht dieses Standes. {detail.version.comment && `«${detail.version.comment}»`}
</p>
</div>
<button type="button" onClick={() => setDetail(null)} aria-label="Schliessen" className="rounded-md p-1 text-faint hover:bg-surface-2">
<X className="h-4 w-4" />
</button>
</div>
{last && (
<div className="flex flex-wrap gap-4 rounded-xl border border-border bg-surface-2 p-3 text-xs">
<span className="text-muted">
Endvermögen nominal: <strong className="text-fg">{formatChf(last.endWealthNominal)}</strong>
</span>
<span className="text-muted">
real: <strong className="text-fg">{formatChf(last.endWealthReal)}</strong>
</span>
<span className="text-muted">
Kapital reicht:{" "}
<strong className={detail.computed.ruinAge === null ? "text-success" : "text-danger"}>
{detail.computed.ruinAge === null ? "bis Planende" : `bis Alter ${detail.computed.ruinAge}`}
</strong>
</span>
</div>
)}
<VersionMatrix computed={detail.computed} />
</div>
</div>
);
}
// --- Liste ---
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-3xl 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">
<History className="h-5 w-5 text-accent" /> Änderungshistorie
</h2>
<p className="mt-0.5 text-xs text-muted">
Szenario «{scenarioName}». Eine Nebenversion entsteht je Bearbeitungssitzung, nicht je
einzelner Änderung sonst wäre die Liste ein Tastenprotokoll.
</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>
{/* Hauptversion festlegen */}
<div className="rounded-xl border border-border bg-surface-2 p-3">
{showMajorForm ? (
<div className="flex flex-col gap-2">
<label className="text-xs font-medium text-fg">
Wofür steht diese Hauptversion? <span className="text-danger">*</span>
</label>
<input
autoFocus
value={majorComment}
onChange={(e) => setMajorComment(e.target.value)}
placeholder="z. B. «Stand nach Beratungsgespräch, vor dem Hauskauf»"
className="w-full rounded-lg border border-border bg-surface px-2.5 py-1.5 text-sm text-fg"
/>
<div className="flex items-center gap-2">
<Button size="sm" disabled={majorComment.trim().length < 3 || busy} onClick={createMajor}>
<Tag className="h-3.5 w-3.5" /> Als Hauptversion festlegen
</Button>
<Button size="sm" variant="secondary" onClick={() => setShowMajorForm(false)}>
Abbrechen
</Button>
</div>
</div>
) : (
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-xs text-muted">
Einen bewusst gesetzten Meilenstein festhalten mit Begründung.
</span>
<Button size="sm" variant="secondary" onClick={() => setShowMajorForm(true)}>
<Tag className="h-3.5 w-3.5" /> Aktuellen Stand als Hauptversion festlegen
</Button>
</div>
)}
</div>
{error && <p className="text-xs text-danger">{error}</p>}
{!rows && !error && <p className="text-xs text-muted">Historie wird geladen</p>}
{rows && rows.length === 0 && (
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-4 text-xs text-muted">
Für dieses Szenario ist noch keine Version festgehalten. Die erste entsteht mit der
nächsten Änderung.
</p>
)}
{rows && rows.length > 0 && (
<div className="flex flex-col gap-2">
{rows.map((r, i) => (
<div
key={r.id}
className={`flex flex-wrap items-center gap-3 rounded-xl border p-3 ${
r.isMajor ? "border-accent bg-accent-soft/20" : "border-border bg-surface-2"
}`}
>
<span className="flex w-16 shrink-0 items-center gap-1.5 font-semibold text-fg">
{r.isMajor && <Tag className="h-3.5 w-3.5 text-accent" />}
{r.major}.{r.minor}
</span>
<div className="min-w-0 flex-1">
<div className="text-xs text-muted">
{r.author} · {dt(r.updatedAt)}
{i === 0 && (
<span className="ml-1.5 rounded bg-surface px-1.5 py-0.5 text-[10px] text-faint">
aktueller Stand
</span>
)}
</div>
{r.comment && <div className="mt-0.5 truncate text-xs text-fg">«{r.comment}»</div>}
</div>
<div className="flex shrink-0 items-center gap-1.5">
<Button size="sm" variant="secondary" disabled={busy} onClick={() => openDetail(r.id)}>
<Eye className="h-3.5 w-3.5" /> Anzeigen
</Button>
<Button size="sm" variant="secondary" disabled={busy || i === 0} onClick={() => restore(r)}>
<RotateCcw className="h-3.5 w-3.5" /> Wiederherstellen
</Button>
</div>
</div>
))}
</div>
)}
<p className="flex items-start gap-1.5 text-[11px] text-faint">
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0" />
Wiederherstellen löscht nichts: Der zurückgesetzte Stand wird selbst als neue Version
festgehalten. Hängen Szenarien an diesem, wird vorher gewarnt.
</p>
</div>
</div>
);
}
+120
View File
@@ -0,0 +1,120 @@
"use client";
import { Fragment } from "react";
import { CATEGORY_LABELS, CATEGORY_ORDER } from "@/lib/elements";
import { formatChf } from "@/lib/format";
import type { PlanComputed } from "@/lib/calculations";
import type { ElementCategory } from "@/lib/elements";
// Nur-Lese-Matrix eines festgehaltenen Standes. Bewusst NICHT die Bearbeitungs-Matrix aus
// PlanView: Die trägt Inspector-Anbindung, Übergangs-Ampeln, Diff-Markierung und Dialoge --
// alles ohne Bedeutung für einen alten Stand, den man nur ansehen kann. Diese Ansicht zeigt
// stattdessen genau das, was eine Version ausmacht: Phasen, Elemente, Werte.
export function VersionMatrix({ computed }: { computed: PlanComputed }) {
const phases = computed.phases;
if (phases.length === 0) {
return <p className="text-sm text-muted">Dieser Stand enthielt noch keine Lebensphasen.</p>;
}
// Elemente in der gewohnten Kategorie-Reihenfolge, über alle Phasen gesammelt.
const elements = (() => {
const seen = new Map<string, { name: string; category: ElementCategory; ownerRole: string | null }>();
for (const p of phases) {
for (const e of p.elements) {
if (!seen.has(e.elementId)) {
seen.set(e.elementId, { name: e.name, category: e.category, ownerRole: e.ownerRole });
}
}
}
return [...seen.entries()]
.map(([id, v]) => ({ id, ...v }))
.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");
});
})();
// Kategoriewechsel als Zwischenüberschrift -- dieselbe Gliederung wie in der Planansicht.
// Vorab bestimmt statt während des Renderns mitgezählt: Eine Variable, die der Render-Lauf
// fortschreibt, verhält sich bei erneutem Rendern nicht mehr gleich.
const headerAt = new Map<string, ElementCategory>();
elements.forEach((el, i) => {
if (i === 0 || el.category !== elements[i - 1].category) headerAt.set(el.id, el.category);
});
return (
<div className="max-h-[60vh] overflow-auto rounded-xl border border-border">
<table className="w-full border-collapse text-sm">
<thead className="sticky top-0 z-10">
<tr className="bg-surface-2 text-xs text-faint">
<th className="sticky left-0 z-20 bg-surface-2 px-3 py-2 text-left font-semibold">Element</th>
{phases.map((p) => (
<th key={p.id} className="whitespace-nowrap px-3 py-2 text-right font-semibold">
<div className="text-fg">{p.name}</div>
<div className="font-normal text-faint">{p.durationYears} J.</div>
</th>
))}
</tr>
</thead>
<tbody>
{elements.map((el) => {
const header = headerAt.get(el.id) ?? null;
return (
<Fragment key={el.id}>
{header && (
<tr className="border-t border-border bg-surface-2/60">
<td
colSpan={phases.length + 1}
className="sticky left-0 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="sticky left-0 z-10 bg-surface px-3 py-2">
<span className="font-medium text-fg">{el.name}</span>
{el.ownerRole && el.ownerRole !== "HOUSEHOLD" && (
<span className="ml-1.5 text-[10px] text-faint">
{el.ownerRole === "PERSON_A" ? "A" : "B"}
</span>
)}
</td>
{phases.map((p) => {
const c = p.elements.find((x) => x.elementId === el.id);
if (!c) return <td key={p.id} className="px-3 py-2 text-right text-faint"></td>;
return (
<td key={p.id} className="whitespace-nowrap px-3 py-2 text-right">
<div className={c.status === "ACTIVE" ? "text-fg" : "text-faint line-through"}>
{formatChf(c.startValue)}
</div>
{c.endValue !== c.startValue && (
<div className="text-[11px] text-muted"> {formatChf(c.endValue)}</div>
)}
</td>
);
})}
</tr>
</Fragment>
);
})}
{/* Vermögen je Phase -- die Kennzahl, für die der ganze Stand steht. */}
<tr className="border-t-2 border-border bg-surface-2">
<td className="sticky left-0 z-10 bg-surface-2 px-3 py-2 text-xs font-semibold text-fg">
Vermögen am Phasenende
</td>
{phases.map((p) => (
<td key={p.id} className="whitespace-nowrap px-3 py-2 text-right text-xs font-semibold text-fg">
{formatChf(p.endWealthNominal)}
<div className="font-normal text-faint">({formatChf(p.endWealthReal)} real)</div>
</td>
))}
</tr>
</tbody>
</table>
</div>
);
}
+172
View File
@@ -0,0 +1,172 @@
"use client";
import { useEffect, useState } from "react";
import { History } from "lucide-react";
import { InfoBubble } from "@/components/InfoBubble";
import { api } from "@/lib/api-client";
import type { PlanInput } from "@/lib/types";
// Kennzeichnet den aktuellen (ungespeicherten) Arbeitsstand -- im Gegensatz zu einer
// festgehaltenen Version.
export const CURRENT = "current";
export interface VersionOption {
id: string;
label: string; // "1.4" bzw. "1.4 (Hauptversion)"
isMajor: boolean;
comment: string | null;
}
// Lädt die Versionsliste eines Szenarios. Bewusst ohne Snapshots -- die kommen erst beim
// tatsächlichen Auswählen dazu.
export function useVersionOptions(scenarioId: string | null): VersionOption[] {
const [options, setOptions] = useState<VersionOption[]>([]);
useEffect(() => {
if (!scenarioId) return;
let cancelled = false;
(async () => {
try {
const data = await api.get<{
versions: { id: string; major: number; minor: number; isMajor: boolean; comment: string | null }[];
}>(`/api/scenarios/${scenarioId}/versions`);
if (cancelled) return;
setOptions(
data.versions.map((v) => ({
id: v.id,
label: `${v.major}.${v.minor}${v.isMajor ? " (Hauptversion)" : ""}`,
isMajor: v.isMajor,
comment: v.comment,
}))
);
} catch {
if (!cancelled) setOptions([]);
}
})();
return () => {
cancelled = true;
};
}, [scenarioId]);
return options;
}
// Holt den Plan-Stand einer Version. `CURRENT` liefert null -- dann gilt der Arbeitsstand.
export async function loadVersionPlan(scenarioId: string, versionId: string): Promise<PlanInput | null> {
if (versionId === CURRENT) return null;
const data = await api.get<{ plan: PlanInput }>(`/api/scenarios/${scenarioId}/versions/${versionId}`);
return data.plan;
}
// Ein kompakter Wähler je Szenario. Wird in allen vier Analysewerkzeugen verwendet, damit
// die Bedienung überall dieselbe ist.
export function VersionSelect({
scenarioId,
value,
onChange,
label,
compact,
}: {
scenarioId: string;
value: string;
onChange: (versionId: string) => void;
label?: string;
compact?: boolean;
}) {
const options = useVersionOptions(scenarioId);
return (
<label className={`flex items-center gap-1.5 ${compact ? "text-[11px]" : "text-xs"} text-muted`}>
{label && (
<span className="flex items-center font-medium">
<History className="mr-1 h-3.5 w-3.5 text-faint" />
{label}
</span>
)}
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className={`rounded-lg border border-border bg-surface px-2 py-1 text-fg ${compact ? "text-[11px]" : "text-xs"}`}
>
<option value={CURRENT}>Aktueller Stand</option>
{options.map((o) => (
<option key={o.id} value={o.id}>
{o.label}
{o.comment ? ` ${o.comment.slice(0, 40)}` : ""}
</option>
))}
</select>
</label>
);
}
// Hält die Auswahl "welcher Stand" für ein einzelnes Szenario. Solange `CURRENT` gewählt
// ist, wird der übergebene Arbeitsstand durchgereicht -- ohne Netzwerkzugriff.
//
// Der Snapshot ist bereits ein PlanInput, deshalb rechnen die Werkzeuge damit unverändert
// weiter; das Gerechnete entsteht bei ihnen lokal (computePlan ist rein und kostet ~0.2 ms).
export function useVersionedPlan(scenarioId: string, currentPlan: PlanInput) {
const [versionId, setVersionId] = useState<string>(CURRENT);
// Nur die geladenen Snapshots liegen im Zustand. Der Arbeitsstand ist bereits da und wird
// abgeleitet -- ihn in den Zustand zu spiegeln, hiesse ihn doppelt zu führen.
const [snapshots, setSnapshots] = useState<Record<string, PlanInput>>({});
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (versionId === CURRENT || snapshots[versionId]) return;
let cancelled = false;
(async () => {
try {
const loaded = await loadVersionPlan(scenarioId, versionId);
if (cancelled) return;
if (loaded) setSnapshots((prev) => ({ ...prev, [versionId]: loaded }));
setError(null);
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : "Version konnte nicht geladen werden.");
}
})();
return () => {
cancelled = true;
};
}, [scenarioId, versionId, snapshots]);
const plan = versionId === CURRENT ? currentPlan : (snapshots[versionId] ?? currentPlan);
// Solange der Snapshot fehlt und kein Fehler vorliegt, laeuft der Abruf noch. Abgeleitet
// statt als eigener Zustand -- ein Ladeflag, das nur den Abruf spiegelt, ist redundant.
const loading = versionId !== CURRENT && !snapshots[versionId] && !error;
return { versionId, setVersionId, plan, loading, error };
}
// Kopfzeile für die Analyse-Dialoge mit einem einzelnen Szenario.
export function VersionBar({
scenarioId,
versionId,
onChange,
loading,
}: {
scenarioId: string;
versionId: string;
onChange: (id: string) => void;
loading?: boolean;
}) {
return (
<div className="flex flex-wrap items-center gap-2 rounded-xl border border-border bg-surface-2 px-3 py-2">
<VersionSelect scenarioId={scenarioId} value={versionId} onChange={onChange} label="Berechnungsgrundlage" />
<VersionHint />
{loading && <span className="text-[11px] text-muted">wird geladen</span>}
{versionId !== CURRENT && !loading && (
<span className="rounded bg-attention-soft px-1.5 py-0.5 text-[10px] font-semibold text-attention-soft-fg">
Nicht der aktuelle Stand
</span>
)}
</div>
);
}
// Hinweiszeile für die Analyse-Dialoge.
export function VersionHint() {
return (
<InfoBubble text="Standardmässig rechnen die Werkzeuge mit dem aktuellen Arbeitsstand. Du kannst stattdessen jede festgehaltene Version wählen dann wird genau der damalige Stand gerechnet." />
);
}
+27
View File
@@ -59,5 +59,32 @@ describe("Datenbank-Migrationen", () => {
expect(scenCols, `Scenario.${c} fehlt`).toContain(c);
}
expect(scenCols).not.toContain("userId"); // Eigentümer hängt am Plan
// --- Versionierung ---
expect(tables, "Tabelle ScenarioVersion fehlt").toContain("ScenarioVersion");
expect(scenCols, "Scenario.currentMajor fehlt").toContain("currentMajor");
const verCols = await cols("ScenarioVersion");
for (const c of ["scenarioId", "major", "minor", "comment", "isMajor", "createdById", "snapshot"]) {
expect(verCols, `ScenarioVersion.${c} fehlt`).toContain(c);
}
// Bestehende Szenarien müssen nach der Migration eine gültige Hauptversion tragen --
// sonst stünde ein vor der Migration angelegter Plan ohne Version da.
const major = await db.query<{ column_default: string | null; is_nullable: string }>(
`SELECT column_default, is_nullable FROM information_schema.columns
WHERE table_name='Scenario' AND column_name='currentMajor'`
);
expect(major.rows[0].is_nullable).toBe("NO");
expect(major.rows[0].column_default).toContain("1");
// A.B muss je Szenario eindeutig sein, sonst kollidieren zwei Sitzungen auf derselben
// Nummer und die Historie wird mehrdeutig.
const idx = (
await db.query<{ indexname: string }>(
`SELECT indexname FROM pg_indexes WHERE tablename='ScenarioVersion'`
)
).rows.map((r) => r.indexname);
expect(idx).toContain("ScenarioVersion_scenarioId_major_minor_key");
}, 60000);
});
+81
View File
@@ -0,0 +1,81 @@
// Wächter über die Vollständigkeit der Historie.
//
// Die Versionierung hängt daran, dass JEDER inhaltsverändernde Endpunkt `touchScenario`
// aufruft. Ein vergessener Pfad fällt nicht auf -- er erzeugt einfach still keine Version,
// und die Historie hat eine Lücke, die man erst Wochen später bemerkt. Dieser Test liest die
// Route-Dateien und prüft das statisch.
import { describe, it, expect } from "vitest";
import { readFileSync, readdirSync } from "node:fs";
import path from "node:path";
const API = path.join(process.cwd(), "src", "app", "api");
function routeFiles(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) out.push(...routeFiles(full));
else if (entry.name === "route.ts") out.push(full);
}
return out;
}
// Endpunkte, die schreiben, aber bewusst KEINE Version erzeugen -- jeweils mit Begründung.
const EXEMPT: Record<string, string> = {
"auth/login": "kein Szenario betroffen",
"auth/logout": "kein Szenario betroffen",
"auth/register": "kein Szenario betroffen",
"auth/change-password": "kein Szenario betroffen",
"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",
};
describe("Versionierung: Abdeckung der Schreibpfade", () => {
const files = routeFiles(API);
it("findet überhaupt Endpunkte", () => {
expect(files.length).toBeGreaterThan(10);
});
it("ruft in jedem inhaltsverändernden Endpunkt touchScenario auf", () => {
const missing: string[] = [];
for (const file of files) {
const src = readFileSync(file, "utf8");
const rel = path
.relative(API, file)
.replace(/\\/g, "/")
.replace(/\/route\.ts$/, "");
// Schreibt dieser Endpunkt überhaupt?
const mutates = /export async function (POST|PUT|PATCH|DELETE)/.test(src);
if (!mutates) continue;
if (rel in EXEMPT) continue;
// Ein DELETE, das das Szenario selbst entfernt, braucht keine Version mehr.
const onlyDeletesScenario =
rel === "scenarios/[scenarioId]" && !/export async function (POST|PUT|PATCH)/.test(src);
if (onlyDeletesScenario) continue;
if (!src.includes("touchScenario(")) missing.push(rel);
}
expect(
missing,
`Diese Endpunkte verändern Inhalte, erzeugen aber keine Version:\n ${missing.join(
"\n "
)}\nEntweder touchScenario ergänzen oder in EXEMPT mit Begründung eintragen.`
).toEqual([]);
});
it("hält die Ausnahmeliste frei von Karteileichen", () => {
// Eine Ausnahme für einen Endpunkt, den es nicht mehr gibt, verschleiert später eine
// echte Lücke.
const known = new Set(
files.map((f) => path.relative(API, f).replace(/\\/g, "/").replace(/\/route\.ts$/, ""))
);
const stale = Object.keys(EXEMPT).filter((k) => !known.has(k));
expect(stale, `Ausnahmen ohne zugehörigen Endpunkt: ${stale.join(", ")}`).toEqual([]);
});
});
+283
View File
@@ -0,0 +1,283 @@
// Datenbank-Anbindung der Versionierung. Die Entscheidungslogik (wann eine Version entsteht,
// wie sie nummeriert wird) liegt in `versioning.ts` und ist dort ohne Datenbank getestet.
import { prisma } from "@/lib/db";
import { planInclude, toPlanInput } from "@/lib/queries";
import {
assessRestore,
decideVersion,
nextMajor,
planRestore,
restoreComment,
type LatestVersion,
type RestoreImpact,
type VersionRef,
} from "@/lib/versioning";
import type { PlanInput } from "@/lib/types";
import type { Prisma } from "@/generated/prisma/client";
const asJson = (plan: PlanInput) => plan as unknown as Prisma.InputJsonValue;
async function loadPlanInput(scenarioId: string): Promise<PlanInput | null> {
const scenario = await prisma.scenario.findUnique({ where: { id: scenarioId }, include: planInclude });
return scenario ? toPlanInput(scenario) : null;
}
async function loadLatest(scenarioId: string): Promise<LatestVersion | null> {
const v = await prisma.scenarioVersion.findFirst({
where: { scenarioId },
orderBy: [{ major: "desc" }, { minor: "desc" }],
});
if (!v) return null;
return {
id: v.id,
major: v.major,
minor: v.minor,
isMajor: v.isMajor,
createdById: v.createdById,
updatedAt: v.updatedAt,
snapshot: v.snapshot as unknown as PlanInput,
};
}
// Wird nach JEDEM inhaltsveraendernden Schreibvorgang aufgerufen. Legt je nach Entscheidung
// eine neue Nebenversion an, aktualisiert die laufende oder tut nichts.
//
// Bewusst fehlertolerant: Schlaegt das Festhalten der Version fehl, darf das die eigentliche
// Aenderung des Nutzers nicht scheitern lassen -- die Historie ist Begleitinformation, nicht
// der Zweck der Anfrage.
export async function touchScenario(scenarioId: string, userId: string): Promise<void> {
try {
const next = await loadPlanInput(scenarioId);
if (!next) return;
const latest = await loadLatest(scenarioId);
const decision = decideVersion(latest, next, userId, new Date());
if (decision.action === "skip") return;
if (decision.action === "update") {
await prisma.scenarioVersion.update({
where: { id: decision.versionId },
data: { snapshot: asJson(next) },
});
return;
}
await prisma.scenarioVersion.create({
data: {
scenarioId,
major: decision.major,
minor: decision.minor,
createdById: userId,
snapshot: asJson(next),
},
});
} catch (err) {
console.error("Versionierung fehlgeschlagen", { scenarioId, err });
}
}
// Legt den aktuellen Stand als HAUPTVERSION fest. Anders als eine Nebenversion wird sie nie
// zusammengefasst und nie nachtraeglich veraendert.
export async function createMajorVersion(
scenarioId: string,
userId: string,
comment: string
): Promise<VersionRef | null> {
const snapshot = await loadPlanInput(scenarioId);
if (!snapshot) return null;
const latest = await loadLatest(scenarioId);
const ref = nextMajor(latest);
await prisma.$transaction([
prisma.scenarioVersion.create({
data: {
scenarioId,
major: ref.major,
minor: ref.minor,
comment: comment.trim(),
isMajor: true,
createdById: userId,
snapshot: asJson(snapshot),
},
}),
prisma.scenario.update({ where: { id: scenarioId }, data: { currentMajor: ref.major } }),
]);
return ref;
}
// Welche Bezuege wuerde ein Wiederherstellen in den Kind-Szenarien brechen? Wird vor dem
// eigentlichen Wiederherstellen abgefragt, damit der Dialog warnen kann.
export async function restoreImpact(scenarioId: string, snapshot: PlanInput): Promise<RestoreImpact> {
const children = await prisma.scenario.findMany({
where: { parentScenarioId: scenarioId },
select: {
name: true,
elements: { select: { sourceElementId: true } },
phases: { select: { sourcePhaseId: true } },
},
});
return assessRestore(
snapshot,
children.map((c) => ({
name: c.name,
elementSourceIds: c.elements.map((e) => e.sourceElementId).filter((x): x is string => !!x),
phaseSourceIds: c.phases.map((p) => p.sourcePhaseId).filter((x): x is string => !!x),
}))
);
}
// Setzt das Szenario auf den Stand einer Version zurueck.
//
// IDs von Phasen und Elementen werden ERHALTEN (der Snapshot traegt sie mit). Wuerden hier
// neue IDs entstehen, verloeren alle Kind-Szenarien ihre Diff-Basis: `sourceElementId` zeigt
// auf die IDs dieses Szenarios, und jedes Element wuerde schlagartig als "neu" statt als
// "geaendert" gelten.
//
// Das Wiederherstellen loescht KEINE Historie -- es legt anschliessend eine neue Version an.
export async function restoreVersion(
scenarioId: string,
versionId: string,
userId: string
): Promise<VersionRef | null> {
const version = await prisma.scenarioVersion.findFirst({ where: { id: versionId, scenarioId } });
if (!version) return null;
const snap = version.snapshot as unknown as PlanInput;
// Was zu tun ist, entscheidet die reine Funktion `planRestore` (dort getestet); hier wird
// der Plan nur noch ausgefuehrt.
const [curPhases, curElements, curPersons] = await Promise.all([
prisma.phase.findMany({ where: { scenarioId }, select: { id: true } }),
prisma.financialElement.findMany({ where: { scenarioId }, select: { id: true } }),
prisma.person.findMany({ where: { scenarioId }, select: { role: true } }),
]);
const plan = planRestore(snap, {
phaseIds: curPhases.map((p) => p.id),
elementIds: curElements.map((e) => e.id),
personRoles: curPersons.map((p) => p.role),
});
const phaseById = new Map(snap.phases.map((p) => [p.id, p]));
const elementById = new Map(snap.elements.map((e) => [e.id, e]));
await prisma.$transaction(async (tx) => {
// Profil.
await tx.scenario.update({
where: { id: scenarioId },
data: {
householdType: snap.householdType,
inflationRateDefault: snap.inflationRateDefault,
initialCash: snap.initialCash,
startYear: snap.startYear ?? null,
},
});
// Personen: an der Rolle festgemacht (je Szenario eindeutig).
for (const p of snap.persons) {
await tx.person.upsert({
where: { scenarioId_role: { scenarioId, role: p.role } },
create: { id: p.id, scenarioId, role: p.role, name: p.name, age: p.age, retirementAge: p.retirementAge },
update: { name: p.name, age: p.age, retirementAge: p.retirementAge },
});
}
if (plan.deletePersonRoles.length > 0) {
await tx.person.deleteMany({
where: { scenarioId, role: { in: plan.deletePersonRoles as never } },
});
}
// Was der alte Stand nicht kannte, verschwindet -- samt seiner Werte (Cascade).
if (plan.deletePhaseIds.length > 0) {
await tx.phase.deleteMany({ where: { id: { in: plan.deletePhaseIds } } });
}
if (plan.deleteElementIds.length > 0) {
await tx.financialElement.deleteMany({ where: { id: { in: plan.deleteElementIds } } });
}
// Sequenznummern zuerst auf negative Werte parken: Sie sind je Szenario eindeutig, und
// beim Zurueckgehen koennen sich alte und neue Nummern ueberschneiden.
for (const [i, id] of plan.parkPhaseIds.entries()) {
await tx.phase.update({ where: { id }, data: { sequenceNumber: -1 - i } });
}
const phaseData = (id: string) => {
const ph = phaseById.get(id)!;
return {
sequenceNumber: ph.sequenceNumber,
name: ph.name,
durationYears: ph.durationYears,
cashTransition: (ph.cashTransition ?? {}) as Prisma.InputJsonValue,
sourcePhaseId: ph.sourcePhaseId ?? null,
};
};
for (const id of plan.updatePhaseIds) await tx.phase.update({ where: { id }, data: phaseData(id) });
for (const id of plan.createPhaseIds) {
await tx.phase.create({ data: { id, scenarioId, ...phaseData(id) } });
}
const elementData = (id: string) => {
const el = elementById.get(id)!;
return {
category: el.category,
name: el.name,
ownerRole: el.ownerRole ?? null,
orderIndex: el.orderIndex,
sourceElementId: el.sourceElementId ?? null,
};
};
for (const id of plan.updateElementIds) {
await tx.financialElement.update({ where: { id }, data: elementData(id) });
}
for (const id of plan.createElementIds) {
await tx.financialElement.create({ data: { id, scenarioId, ...elementData(id) } });
}
// Werte vollstaendig ersetzen statt abgleichen -- der Snapshot ist die Wahrheit.
const allElementIds = [...plan.updateElementIds, ...plan.createElementIds];
if (allElementIds.length > 0) {
await tx.elementPhaseValue.deleteMany({ where: { elementId: { in: allElementIds } } });
await tx.elementTransitionValue.deleteMany({ where: { elementId: { in: allElementIds } } });
}
for (const { elementId, phaseId } of plan.phaseValueKeys) {
await tx.elementPhaseValue.create({
data: {
elementId,
phaseId,
data: elementById.get(elementId)!.phaseValues[phaseId] as Prisma.InputJsonValue,
},
});
}
for (const { elementId, fromPhaseId } of plan.transitionValueKeys) {
await tx.elementTransitionValue.create({
data: {
elementId,
fromPhaseId,
data: elementById.get(elementId)!.transitionValues[fromPhaseId] as Prisma.InputJsonValue,
},
});
}
});
// Der wiederhergestellte Stand ist selbst eine neue Version -- mit Herkunftsvermerk, und
// ohne dass irgendetwas aus der Historie verloren geht.
const latest = await loadLatest(scenarioId);
const current = await loadPlanInput(scenarioId);
if (!current) return null;
const created = await prisma.scenarioVersion.create({
data: {
scenarioId,
major: latest?.major ?? 1,
minor: (latest?.minor ?? 0) + 1,
comment: restoreComment({ major: version.major, minor: version.minor }),
createdById: userId,
snapshot: asJson(current),
},
});
return { major: created.major, minor: created.minor };
}
+267
View File
@@ -0,0 +1,267 @@
import { describe, it, expect } from "vitest";
import {
assessRestore,
canonicalJson,
COALESCE_WINDOW_MS,
decideVersion,
formatVersion,
isValidMajorComment,
nextMajor,
planRestore,
restoreComment,
sameSnapshot,
type LatestVersion,
} from "@/lib/versioning";
import type { PlanInput } from "@/lib/types";
function snap(overrides: Partial<PlanInput> = {}): PlanInput {
return {
id: "s",
name: "Szenario",
householdType: "SINGLE",
inflationRateDefault: 1.5,
initialCash: 0,
startYear: 2026,
persons: [{ id: "A", role: "PERSON_A", name: null, age: 40, retirementAge: 65 }],
phases: [{ id: "p1", sequenceNumber: 1, name: "Erwerb", durationYears: 20, cashTransition: {} }],
elements: [
{
id: "e1",
category: "INCOME",
name: "Lohn",
ownerRole: "HOUSEHOLD",
orderIndex: 1,
phaseValues: { p1: { amount: 100000 } },
transitionValues: {},
},
],
...overrides,
} as unknown as PlanInput;
}
function latest(over: Partial<LatestVersion> = {}): LatestVersion {
return {
id: "v1",
major: 1,
minor: 3,
isMajor: false,
createdById: "u1",
updatedAt: new Date("2026-07-19T10:00:00Z"),
snapshot: snap(),
...over,
} as LatestVersion;
}
const T0 = new Date("2026-07-19T10:00:00Z");
const after = (ms: number) => new Date(T0.getTime() + ms);
describe("canonicalJson", () => {
it("ist unabhängig von der Schlüsselreihenfolge", () => {
// Genau der Fall aus der Praxis: `toPlanInput` baut phaseValues aus Datenbankzeilen auf,
// deren Reihenfolge nicht garantiert ist.
const a = { phaseValues: { p1: { amount: 1 }, p2: { amount: 2 } } };
const b = { phaseValues: { p2: { amount: 2 }, p1: { amount: 1 } } };
expect(canonicalJson(a)).toBe(canonicalJson(b));
// Ein echter Unterschied bleibt aber einer.
expect(canonicalJson(a)).not.toBe(canonicalJson({ phaseValues: { p1: { amount: 9 } } }));
});
it("lässt die Reihenfolge von Listen in Ruhe", () => {
// Bei Phasen ist die Reihenfolge inhaltlich bedeutsam.
expect(canonicalJson([1, 2])).not.toBe(canonicalJson([2, 1]));
});
});
describe("decideVersion", () => {
it("legt für den allerersten Stand 1.0 an", () => {
expect(decideVersion(null, snap(), "u1", T0)).toEqual({ action: "create", major: 1, minor: 0 });
});
it("tut nichts, wenn sich inhaltlich nichts geändert hat", () => {
// Dialog geöffnet, unverändert gespeichert -- darf keine Version erzeugen.
const d = decideVersion(latest(), snap(), "u1", after(1000));
expect(d.action).toBe("skip");
});
it("fasst Schreibvorgänge derselben Sitzung zu EINER Nebenversion zusammen", () => {
// Der Kern der Entscheidung: Der Verteil-Dialog schreibt einmal je Zielelement, der
// Assistent ~14 Mal. Ohne Zusammenfassung wäre die Historie ein Tastenprotokoll.
const d = decideVersion(latest(), snap({ initialCash: 5000 }), "u1", after(60_000));
expect(d).toEqual({ action: "update", versionId: "v1" });
});
it("beginnt nach Ablauf des Fensters eine neue Nebenversion", () => {
const d = decideVersion(latest(), snap({ initialCash: 5000 }), "u1", after(COALESCE_WINDOW_MS + 1));
expect(d).toEqual({ action: "create", major: 1, minor: 4 });
});
it("fasst Änderungen verschiedener Benutzer nie zusammen", () => {
// Vorbereitung auf den Finanzberater: fremde Änderungen dürfen nicht unter einem Namen
// zusammenlaufen, auch nicht innerhalb des Zeitfensters.
const d = decideVersion(latest(), snap({ initialCash: 5000 }), "u2", after(1000));
expect(d).toEqual({ action: "create", major: 1, minor: 4 });
});
it("verändert eine Hauptversion nie nachträglich", () => {
// Eine Hauptversion ist ein bewusst gesetzter Schnitt. Die nächste Änderung beginnt A.1,
// auch wenn sie eine Sekunde später kommt.
const d = decideVersion(
latest({ isMajor: true, major: 2, minor: 0 }),
snap({ initialCash: 5000 }),
"u1",
after(1000)
);
expect(d).toEqual({ action: "create", major: 2, minor: 1 });
});
});
describe("Hauptversionen", () => {
it("zählt die Hauptversion hoch und setzt die Nebenversion zurück", () => {
expect(nextMajor({ major: 1, minor: 7 })).toEqual({ major: 2, minor: 0 });
expect(nextMajor(null)).toEqual({ major: 1, minor: 0 });
});
it("verlangt einen echten Kommentar", () => {
expect(isValidMajorComment("Vor dem Hauskauf")).toBe(true);
expect(isValidMajorComment(" ")).toBe(false);
expect(isValidMajorComment("ok")).toBe(false);
});
it("formatiert A.B", () => {
expect(formatVersion({ major: 2, minor: 0 })).toBe("2.0");
});
});
describe("assessRestore", () => {
const s = snap(); // kennt Phase p1 und Element e1
it("meldet nichts, wenn alle Bezüge der Kinder im alten Stand existieren", () => {
const impact = assessRestore(s, [
{ name: "Frühpension", elementSourceIds: ["e1"], phaseSourceIds: ["p1"] },
]);
expect(impact.affectedChildren).toEqual([]);
expect(impact.lostElementIds).toEqual([]);
});
it("meldet Kind-Szenarien, deren Diff-Basis wegfällt", () => {
// Das Kind zeigt auf ein Element, das es im wiederhergestellten Stand noch nicht gab.
// Dann erschiene es dort schlagartig als "neu" statt als "geändert".
const impact = assessRestore(s, [
{ name: "Frühpension", elementSourceIds: ["e1", "e99"], phaseSourceIds: ["p1"] },
{ name: "Umzug", elementSourceIds: ["e1"], phaseSourceIds: ["p1"] },
]);
expect(impact.affectedChildren).toEqual(["Frühpension"]);
expect(impact.lostElementIds).toEqual(["e99"]);
expect(impact.lostPhaseIds).toEqual([]);
});
it("erkennt auch verlorene Phasenbezüge und zählt jede ID nur einmal", () => {
const impact = assessRestore(s, [
{ name: "A", elementSourceIds: [], phaseSourceIds: ["p9"] },
{ name: "B", elementSourceIds: [], phaseSourceIds: ["p9"] },
]);
expect(impact.lostPhaseIds).toEqual(["p9"]);
expect(impact.affectedChildren).toEqual(["A", "B"]);
});
});
describe("planRestore", () => {
// Alter Stand: zwei Phasen, ein Element mit Werten in beiden.
const old = snap({
phases: [
{ id: "p1", sequenceNumber: 1, name: "Erwerb", durationYears: 20, cashTransition: {} },
{ id: "p2", sequenceNumber: 2, name: "Pension", durationYears: 20, cashTransition: {} },
],
elements: [
{
id: "e1",
category: "INCOME",
name: "Lohn",
ownerRole: "HOUSEHOLD",
orderIndex: 1,
phaseValues: { p1: { amount: 100000 }, p2: { amount: 0 } },
transitionValues: { p1: {} },
},
],
} as unknown as Partial<PlanInput>);
it("erhält bestehende IDs, statt neu anzulegen", () => {
// Der Kern: Kind-Szenarien zeigen über sourceElementId auf genau diese IDs.
const plan = planRestore(old, { phaseIds: ["p1", "p2"], elementIds: ["e1"], personRoles: ["PERSON_A"] });
expect(plan.updatePhaseIds).toEqual(["p1", "p2"]);
expect(plan.updateElementIds).toEqual(["e1"]);
expect(plan.createPhaseIds).toEqual([]);
expect(plan.createElementIds).toEqual([]);
});
it("entfernt, was der alte Stand nicht kannte", () => {
const plan = planRestore(old, {
phaseIds: ["p1", "p2", "p3"],
elementIds: ["e1", "e2"],
personRoles: ["PERSON_A", "PERSON_B"],
});
expect(plan.deletePhaseIds).toEqual(["p3"]);
expect(plan.deleteElementIds).toEqual(["e2"]);
// Der Haushalt war damals eine Einzelperson -- Person B muss weg.
expect(plan.deletePersonRoles).toEqual(["PERSON_B"]);
});
it("legt neu an, was seither gelöscht wurde", () => {
const plan = planRestore(old, { phaseIds: ["p1"], elementIds: [], personRoles: ["PERSON_A"] });
expect(plan.createPhaseIds).toEqual(["p2"]);
expect(plan.createElementIds).toEqual(["e1"]);
expect(plan.updatePhaseIds).toEqual(["p1"]);
});
it("parkt nur die überlebenden Phasen zum Umnummerieren", () => {
// Die zu löschenden sind vorher schon weg -- sie zu parken wäre ein Fehler.
const plan = planRestore(old, { phaseIds: ["p2", "p3"], elementIds: [], personRoles: [] });
expect(plan.parkPhaseIds).toEqual(["p2"]);
expect(plan.deletePhaseIds).toEqual(["p3"]);
});
it("verwirft Werte, die auf inzwischen entfernte Phasen zeigen", () => {
// Ein Element trägt einen Wert für eine Phase, die dieser Stand gar nicht kannte.
const withOrphan = snap({
phases: [{ id: "p1", sequenceNumber: 1, name: "Erwerb", durationYears: 20, cashTransition: {} }],
elements: [
{
id: "e1",
category: "INCOME",
name: "Lohn",
ownerRole: "HOUSEHOLD",
orderIndex: 1,
phaseValues: { p1: { amount: 1 }, pX: { amount: 2 } },
transitionValues: { pX: {} },
},
],
} as unknown as Partial<PlanInput>);
const plan = planRestore(withOrphan, { phaseIds: ["p1"], elementIds: ["e1"], personRoles: [] });
expect(plan.phaseValueKeys).toEqual([{ elementId: "e1", phaseId: "p1" }]);
// Ein Übergangswert nach einer nicht existierenden Phase würde beim Schreiben auf einen
// Fremdschlüsselfehler laufen.
expect(plan.transitionValueKeys).toEqual([]);
});
it("führt alle Werte des alten Standes auf", () => {
const plan = planRestore(old, { phaseIds: ["p1", "p2"], elementIds: ["e1"], personRoles: [] });
expect(plan.phaseValueKeys).toHaveLength(2);
expect(plan.transitionValueKeys).toEqual([{ elementId: "e1", fromPhaseId: "p1" }]);
});
});
describe("restoreComment", () => {
it("hält die Herkunft fest", () => {
expect(restoreComment({ major: 1, minor: 3 })).toBe("Wiederhergestellt aus 1.3");
});
});
describe("sameSnapshot", () => {
it("erkennt inhaltliche Gleichheit trotz anderer Schlüsselreihenfolge", () => {
const a = snap();
const b = snap({ elements: [{ ...snap().elements[0] }] });
expect(sameSnapshot(a, b)).toBe(true);
expect(sameSnapshot(a, snap({ inflationRateDefault: 2 }))).toBe(false);
});
});
+221
View File
@@ -0,0 +1,221 @@
// Versionierung je Szenario (SPEZIFIKATION 3.8, 4.16).
//
// Version A.B:
// B (Nebenversion) -- automatisch, EINE je Bearbeitungssitzung.
// A (Hauptversion) -- manuell, mit Pflichtkommentar; setzt B auf 0 zurueck.
//
// Warum nicht je Schreibvorgang: FPT hat keinen Speichern-Knopf, jede Aenderung schreibt
// sofort. Ein Durchlauf des Plan-Assistenten macht ~14 Schreibvorgaenge, ein Klick im
// Verteil-Dialog einen pro Zielelement. Eine Version je Schreibvorgang waere ein
// Tastenprotokoll, keine Historie. Stattdessen fassen wir alle Schreibvorgaenge innerhalb
// eines Zeitfensters zu EINER Nebenversion zusammen (siehe 9.28).
//
// Dieses Modul enthaelt die reine Entscheidungslogik (ohne I/O), damit sie ohne Datenbank
// testbar ist. Die Datenbank-Anbindung liegt in `versioning-db.ts`.
import type { PlanInput } from "@/lib/types";
// Innerhalb dieses Fensters aktualisiert ein weiterer Schreibvorgang die bestehende
// Nebenversion, statt eine neue anzulegen.
export const COALESCE_WINDOW_MS = 10 * 60 * 1000; // 10 Minuten
export interface VersionRef {
major: number;
minor: number;
}
export function formatVersion(v: VersionRef): string {
return `${v.major}.${v.minor}`;
}
// Der jeweils letzte Stand, gegen den entschieden wird.
export interface LatestVersion extends VersionRef {
id: string;
isMajor: boolean;
createdById: string;
updatedAt: Date;
snapshot: PlanInput;
}
export type VersionDecision =
| { action: "skip"; reason: string }
| { action: "update"; versionId: string }
| { action: "create"; major: number; minor: number };
// Serialisiert mit SORTIERTEN Schluesseln. Noetig, weil `toPlanInput` die Werte je Phase als
// Objekt aufbaut (`phaseValues[phaseId] = ...`) und die Reihenfolge dieser Schluessel aus der
// Datenbank-Zeilenfolge stammt -- die ist nicht garantiert. Ohne Sortierung wuerden zwei
// inhaltlich identische Staende als verschieden gelten und die Historie mit Scheinversionen
// fluten.
export function canonicalJson(value: unknown): string {
return JSON.stringify(value, (_key, val) => {
if (val === null || typeof val !== "object" || Array.isArray(val)) return val;
return Object.fromEntries(Object.entries(val as Record<string, unknown>).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
});
}
// Vergleicht zwei Staende inhaltlich.
export function sameSnapshot(a: PlanInput, b: PlanInput): boolean {
return canonicalJson(a) === canonicalJson(b);
}
// Entscheidet, was ein Schreibvorgang mit der Historie macht.
//
// `latest` ist die zuletzt angelegte Version des Szenarios (oder null, wenn es noch keine
// gibt). `now` und `userId` beschreiben den aktuellen Schreibvorgang.
export function decideVersion(
latest: LatestVersion | null,
next: PlanInput,
userId: string,
now: Date
): VersionDecision {
// Allererster Stand: 1.0.
if (!latest) return { action: "create", major: 1, minor: 0 };
// Nichts veraendert -- z. B. Dialog geoeffnet und unveraendert gespeichert. Ohne diese
// Pruefung sammelt die Historie Versionen, die nichts unterscheiden.
if (sameSnapshot(latest.snapshot, next)) {
return { action: "skip", reason: "unveraendert" };
}
const withinWindow = now.getTime() - latest.updatedAt.getTime() < COALESCE_WINDOW_MS;
// Eine Hauptversion ist ein bewusst gesetzter Schnitt -- sie wird nie nachtraeglich
// veraendert, auch nicht innerhalb des Fensters. Die naechste Aenderung beginnt A.1.
if (latest.isMajor) {
return { action: "create", major: latest.major, minor: latest.minor + 1 };
}
// Fortsetzung derselben Sitzung: bestehende Nebenversion aktualisieren. An den Benutzer
// gebunden, damit spaeter (Finanzberater) nicht fremde Aenderungen unter einem Namen
// zusammenlaufen.
if (withinWindow && latest.createdById === userId) {
return { action: "update", versionId: latest.id };
}
return { action: "create", major: latest.major, minor: latest.minor + 1 };
}
// Naechste Hauptversion. Der Kommentar ist Pflicht -- eine Hauptversion ohne Begruendung
// waere nur eine Zahl.
export function nextMajor(latest: VersionRef | null): VersionRef {
return { major: (latest?.major ?? 0) + 1, minor: 0 };
}
export function isValidMajorComment(comment: string): boolean {
return comment.trim().length >= 3;
}
// --- Wiederherstellen ------------------------------------------------------------------
// Kind-Szenarien zeigen ueber `sourceElementId` / `sourcePhaseId` auf die IDs DIESES
// Szenarios -- darauf beruht die Abweichungs-Markierung. Beim Wiederherstellen bleiben die
// IDs erhalten; trotzdem kann ein Bezug brechen, wenn der alte Stand ein Element oder eine
// Phase noch gar nicht enthielt. Das laesst sich nicht verhindern, aber ankuendigen.
export interface RestoreImpact {
lostElementIds: string[];
lostPhaseIds: string[];
affectedChildren: string[]; // Namen der betroffenen Kind-Szenarien
}
export function assessRestore(
snapshot: PlanInput,
children: { name: string; elementSourceIds: string[]; phaseSourceIds: string[] }[]
): RestoreImpact {
const haveElements = new Set(snapshot.elements.map((e) => e.id));
const havePhases = new Set(snapshot.phases.map((p) => p.id));
const lostElementIds = new Set<string>();
const lostPhaseIds = new Set<string>();
const affectedChildren: string[] = [];
for (const child of children) {
const lostE = child.elementSourceIds.filter((id) => !haveElements.has(id));
const lostP = child.phaseSourceIds.filter((id) => !havePhases.has(id));
if (lostE.length === 0 && lostP.length === 0) continue;
lostE.forEach((id) => lostElementIds.add(id));
lostP.forEach((id) => lostPhaseIds.add(id));
affectedChildren.push(child.name);
}
return {
lostElementIds: [...lostElementIds],
lostPhaseIds: [...lostPhaseIds],
affectedChildren,
};
}
// Kommentar der Version, die durch ein Wiederherstellen entsteht. Das Wiederherstellen
// LOESCHT keine Historie -- es legt einen neuen Stand oben drauf.
export function restoreComment(from: VersionRef): string {
return `Wiederhergestellt aus ${formatVersion(from)}`;
}
// --- Bauplan des Wiederherstellens -----------------------------------------------------
//
// Das Wiederherstellen ist der einzige destruktive Pfad der Anwendung. Damit er pruefbar
// ist, entscheidet diese reine Funktion, WAS geschehen soll; `versioning-db.ts` fuehrt den
// Plan nur noch aus. Drei Feinheiten stecken darin:
//
// 1. IDs bleiben erhalten -- sonst verlieren Kind-Szenarien ihre Diff-Basis.
// 2. `Phase.sequenceNumber` ist je Szenario eindeutig. Beim Zurueckgehen koennen sich alte
// und neue Nummern ueberschneiden (z. B. Phase X hatte 2, soll wieder 1 werden, waehrend
// eine andere noch auf 1 sitzt). Deshalb werden bestehende Phasen zuerst auf negative
// Nummern geparkt und danach auf ihre Zielnummer gesetzt.
// 3. Werte zu inzwischen entfernten Phasen werden verworfen, nicht wiederhergestellt.
export interface RestorePlan {
deletePhaseIds: string[];
deleteElementIds: string[];
parkPhaseIds: string[]; // vor dem Umnummerieren auf negative Werte schieben
createPhaseIds: string[];
updatePhaseIds: string[];
createElementIds: string[];
updateElementIds: string[];
deletePersonRoles: string[];
// Werte je Element, bereits auf die ueberlebenden Phasen gefiltert.
phaseValueKeys: { elementId: string; phaseId: string }[];
transitionValueKeys: { elementId: string; fromPhaseId: string }[];
}
export interface CurrentState {
phaseIds: string[];
elementIds: string[];
personRoles: string[];
}
export function planRestore(snapshot: PlanInput, current: CurrentState): RestorePlan {
const keepPhaseIds = snapshot.phases.map((p) => p.id);
const keepElementIds = snapshot.elements.map((e) => e.id);
const keepRoles = snapshot.persons.map((p) => p.role);
const keepPhaseSet = new Set(keepPhaseIds);
const havePhase = new Set(current.phaseIds);
const haveElement = new Set(current.elementIds);
const phaseValueKeys: { elementId: string; phaseId: string }[] = [];
const transitionValueKeys: { elementId: string; fromPhaseId: string }[] = [];
for (const el of snapshot.elements) {
for (const phaseId of Object.keys(el.phaseValues)) {
// Werte zu einer Phase, die dieser Stand nicht kannte, gehoeren nirgendwohin.
if (keepPhaseSet.has(phaseId)) phaseValueKeys.push({ elementId: el.id, phaseId });
}
for (const fromPhaseId of Object.keys(el.transitionValues)) {
if (keepPhaseSet.has(fromPhaseId)) transitionValueKeys.push({ elementId: el.id, fromPhaseId });
}
}
return {
deletePhaseIds: current.phaseIds.filter((id) => !keepPhaseSet.has(id)),
deleteElementIds: current.elementIds.filter((id) => !keepElementIds.includes(id)),
// Nur die Phasen parken, die bleiben -- die anderen sind vorher schon weg.
parkPhaseIds: current.phaseIds.filter((id) => keepPhaseSet.has(id)),
createPhaseIds: keepPhaseIds.filter((id) => !havePhase.has(id)),
updatePhaseIds: keepPhaseIds.filter((id) => havePhase.has(id)),
createElementIds: keepElementIds.filter((id) => !haveElement.has(id)),
updateElementIds: keepElementIds.filter((id) => haveElement.has(id)),
deletePersonRoles: current.personRoles.filter((r) => !keepRoles.includes(r as never)),
phaseValueKeys,
transitionValueKeys,
};
}