From d023534a030837fb2bfccc90c44ffee1a808d2d2 Mon Sep 17 00:00:00 2001 From: kelle Date: Sun, 19 Jul 2026 22:09:10 +0200 Subject: [PATCH] Versionierung und Aenderungshistorie je Szenario 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 --- SPEZIFIKATION.md | 175 +++++++++- .../migration.sql | 44 +++ prisma/schema.prisma | 34 +- .../[elementId]/phase/[phaseId]/route.ts | 2 + src/app/api/elements/[elementId]/route.ts | 3 + .../transition/[fromPhaseId]/route.ts | 2 + .../phases/[phaseId]/cash-transition/route.ts | 2 + src/app/api/phases/[phaseId]/route.ts | 3 + src/app/api/plans/route.ts | 3 + .../api/scenarios/[scenarioId]/copy/route.ts | 3 + .../scenarios/[scenarioId]/elements/route.ts | 2 + .../scenarios/[scenarioId]/phases/route.ts | 2 + src/app/api/scenarios/[scenarioId]/route.ts | 3 + .../versions/[versionId]/route.ts | 61 ++++ .../scenarios/[scenarioId]/versions/route.ts | 73 ++++ src/components/AppShell.tsx | 16 + src/components/Dashboard.tsx | 32 +- src/components/LiveSimDialog.tsx | 15 +- src/components/MonteCarloDialog.tsx | 46 ++- src/components/SensitivityDialog.tsx | 15 +- src/components/VersionHistoryDialog.tsx | 327 ++++++++++++++++++ src/components/VersionMatrix.tsx | 120 +++++++ src/components/VersionPicker.tsx | 172 +++++++++ src/lib/migrations.test.ts | 27 ++ src/lib/versioning-coverage.test.ts | 81 +++++ src/lib/versioning-db.ts | 283 +++++++++++++++ src/lib/versioning.test.ts | 267 ++++++++++++++ src/lib/versioning.ts | 221 ++++++++++++ 28 files changed, 2021 insertions(+), 13 deletions(-) create mode 100644 prisma/migrations/20260719210000_scenario_versioning/migration.sql create mode 100644 src/app/api/scenarios/[scenarioId]/versions/[versionId]/route.ts create mode 100644 src/app/api/scenarios/[scenarioId]/versions/route.ts create mode 100644 src/components/VersionHistoryDialog.tsx create mode 100644 src/components/VersionMatrix.tsx create mode 100644 src/components/VersionPicker.tsx create mode 100644 src/lib/versioning-coverage.test.ts create mode 100644 src/lib/versioning-db.ts create mode 100644 src/lib/versioning.test.ts create mode 100644 src/lib/versioning.ts diff --git a/SPEZIFIKATION.md b/SPEZIFIKATION.md index 5763189..86ef69c 100644 --- a/SPEZIFIKATION.md +++ b/SPEZIFIKATION.md @@ -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//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 27–48 %. 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: "" }` ### `GET /api/scenarios//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//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//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//versions/` +Ein einzelner Stand samt Berechnung und der Vorwarnung für das Wiederherstellen: +```json +{ "version": { "id", "major", "minor", "comment", "isMajor", "createdAt" }, + "plan": , "computed": , + "impact": { "lostElementIds": [], "lostPhaseIds": [], "affectedChildren": [] } } +``` +`impact` benennt die Kind-Szenarien, die durch ein Wiederherstellen ihre Diff-Basis verlören. + +### `POST /api/scenarios//versions/` +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 diff --git a/prisma/migrations/20260719210000_scenario_versioning/migration.sql b/prisma/migrations/20260719210000_scenario_versioning/migration.sql new file mode 100644 index 0000000..9d0836d --- /dev/null +++ b/prisma/migrations/20260719210000_scenario_versioning/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index af6ace3..885fffc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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) diff --git a/src/app/api/elements/[elementId]/phase/[phaseId]/route.ts b/src/app/api/elements/[elementId]/phase/[phaseId]/route.ts index e5c03fa..c805513 100644 --- a/src/app/api/elements/[elementId]/phase/[phaseId]/route.ts +++ b/src/app/api/elements/[elementId]/phase/[phaseId]/route.ts @@ -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 }); } diff --git a/src/app/api/elements/[elementId]/route.ts b/src/app/api/elements/[elementId]/route.ts index f66129b..5a6d53e 100644 --- a/src/app/api/elements/[elementId]/route.ts +++ b/src/app/api/elements/[elementId]/route.ts @@ -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 }); } diff --git a/src/app/api/elements/[elementId]/transition/[fromPhaseId]/route.ts b/src/app/api/elements/[elementId]/transition/[fromPhaseId]/route.ts index 3e9b05b..6b2df84 100644 --- a/src/app/api/elements/[elementId]/transition/[fromPhaseId]/route.ts +++ b/src/app/api/elements/[elementId]/transition/[fromPhaseId]/route.ts @@ -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 }); } diff --git a/src/app/api/phases/[phaseId]/cash-transition/route.ts b/src/app/api/phases/[phaseId]/cash-transition/route.ts index a58af59..5e0d5c4 100644 --- a/src/app/api/phases/[phaseId]/cash-transition/route.ts +++ b/src/app/api/phases/[phaseId]/cash-transition/route.ts @@ -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 }); } diff --git a/src/app/api/phases/[phaseId]/route.ts b/src/app/api/phases/[phaseId]/route.ts index 5a2ddf8..8c10da5 100644 --- a/src/app/api/phases/[phaseId]/route.ts +++ b/src/app/api/phases/[phaseId]/route.ts @@ -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 }); } diff --git a/src/app/api/plans/route.ts b/src/app/api/plans/route.ts index cf4f08e..22b0014 100644 --- a/src/app/api/plans/route.ts +++ b/src/app/api/plans/route.ts @@ -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 } diff --git a/src/app/api/scenarios/[scenarioId]/copy/route.ts b/src/app/api/scenarios/[scenarioId]/copy/route.ts index 2a6d496..14b43e4 100644 --- a/src/app/api/scenarios/[scenarioId]/copy/route.ts +++ b/src/app/api/scenarios/[scenarioId]/copy/route.ts @@ -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 }); } diff --git a/src/app/api/scenarios/[scenarioId]/elements/route.ts b/src/app/api/scenarios/[scenarioId]/elements/route.ts index 3be8614..f74c609 100644 --- a/src/app/api/scenarios/[scenarioId]/elements/route.ts +++ b/src/app/api/scenarios/[scenarioId]/elements/route.ts @@ -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 }); } diff --git a/src/app/api/scenarios/[scenarioId]/phases/route.ts b/src/app/api/scenarios/[scenarioId]/phases/route.ts index d79ed33..0f6bd48 100644 --- a/src/app/api/scenarios/[scenarioId]/phases/route.ts +++ b/src/app/api/scenarios/[scenarioId]/phases/route.ts @@ -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 }); } diff --git a/src/app/api/scenarios/[scenarioId]/route.ts b/src/app/api/scenarios/[scenarioId]/route.ts index 90e0757..1243146 100644 --- a/src/app/api/scenarios/[scenarioId]/route.ts +++ b/src/app/api/scenarios/[scenarioId]/route.ts @@ -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 } }); } diff --git a/src/app/api/scenarios/[scenarioId]/versions/[versionId]/route.ts b/src/app/api/scenarios/[scenarioId]/versions/[versionId]/route.ts new file mode 100644 index 0000000..82ba08f --- /dev/null +++ b/src/app/api/scenarios/[scenarioId]/versions/[versionId]/route.ts @@ -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 }); +} diff --git a/src/app/api/scenarios/[scenarioId]/versions/route.ts b/src/app/api/scenarios/[scenarioId]/versions/route.ts new file mode 100644 index 0000000..8a2c252 --- /dev/null +++ b/src/app/api/scenarios/[scenarioId]/versions/route.ts @@ -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 }); +} diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index dda06ab..8bec43f 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -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 }) { Grafiken + + { + setVersionId(id); + setValues({}); + }} + loading={versionLoading} + /> +

Dreh an den Reglern und sieh sofort, was passiert. Nichts davon wird gespeichert – dein Plan bleibt unverändert, du brauchst für kein Durchspielen eine diff --git a/src/components/MonteCarloDialog.tsx b/src/components/MonteCarloDialog.tsx index 4701706..f2be0eb 100644 --- a/src/components/MonteCarloDialog.tsx +++ b/src/components/MonteCarloDialog.tsx @@ -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>({}); + const [versionPlans, setVersionPlans] = useState>({}); + const [outcomes, setOutcomes] = useState(null); // Fächer/Bänder stammen aus der historischen Welt. const [fanRes, setFanRes] = useState(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 = {}; + 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 && Basis} {!isLoaded && lädt…} + {checked && ( + void chooseVersion(s.id, vid)} + /> + )} {checked && typeof nachlass === "number" && ( Planungs-Endbetrag: {formatChf(Math.max(0, nachlass))} diff --git a/src/components/SensitivityDialog.tsx b/src/components/SensitivityDialog.tsx index 10c8a27..59f424a 100644 --- a/src/components/SensitivityDialog.tsx +++ b/src/components/SensitivityDialog.tsx @@ -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("real"); @@ -103,6 +106,16 @@ export function SensitivityDialog({ plan, onClose }: { plan: PlanInput; onClose: + { + setVersionId(id); + setResult(null); + }} + loading={versionLoading} + /> + {/* Erklärung */}

diff --git a/src/components/VersionHistoryDialog.tsx b/src/components/VersionHistoryDialog.tsx new file mode 100644 index 0000000..c02f164 --- /dev/null +++ b/src/components/VersionHistoryDialog.tsx @@ -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(null); + const [error, setError] = useState(null); + const [detail, setDetail] = useState(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(`/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(`/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 ( +

setDetail(null)}> +
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"> +
+
+

+ + Version {detail.version.major}.{detail.version.minor} + {detail.version.isMajor && ( + + Hauptversion + + )} +

+

+ Nur-Lese-Ansicht dieses Standes. {detail.version.comment && `«${detail.version.comment}»`} +

+
+ +
+ + {last && ( +
+ + Endvermögen nominal: {formatChf(last.endWealthNominal)} + + + real: {formatChf(last.endWealthReal)} + + + Kapital reicht:{" "} + + {detail.computed.ruinAge === null ? "bis Planende" : `bis Alter ${detail.computed.ruinAge}`} + + +
+ )} + + +
+
+ ); + } + + // --- Liste --- + return ( +
+
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"> +
+
+

+ Änderungshistorie +

+

+ Szenario «{scenarioName}». Eine Nebenversion entsteht je Bearbeitungssitzung, nicht je + einzelner Änderung – sonst wäre die Liste ein Tastenprotokoll. +

+
+ +
+ + {/* Hauptversion festlegen */} +
+ {showMajorForm ? ( +
+ + 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" + /> +
+ + +
+
+ ) : ( +
+ + Einen bewusst gesetzten Meilenstein festhalten – mit Begründung. + + +
+ )} +
+ + {error &&

{error}

} + {!rows && !error &&

Historie wird geladen…

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

+ Für dieses Szenario ist noch keine Version festgehalten. Die erste entsteht mit der + nächsten Änderung. +

+ )} + + {rows && rows.length > 0 && ( +
+ {rows.map((r, i) => ( +
+ + {r.isMajor && } + {r.major}.{r.minor} + + +
+
+ {r.author} · {dt(r.updatedAt)} + {i === 0 && ( + + aktueller Stand + + )} +
+ {r.comment &&
«{r.comment}»
} +
+ +
+ + +
+
+ ))} +
+ )} + +

+ + Wiederherstellen löscht nichts: Der zurückgesetzte Stand wird selbst als neue Version + festgehalten. Hängen Szenarien an diesem, wird vorher gewarnt. +

+
+
+ ); +} diff --git a/src/components/VersionMatrix.tsx b/src/components/VersionMatrix.tsx new file mode 100644 index 0000000..28906b2 --- /dev/null +++ b/src/components/VersionMatrix.tsx @@ -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

Dieser Stand enthielt noch keine Lebensphasen.

; + } + + // Elemente in der gewohnten Kategorie-Reihenfolge, über alle Phasen gesammelt. + const elements = (() => { + const seen = new Map(); + 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(); + elements.forEach((el, i) => { + if (i === 0 || el.category !== elements[i - 1].category) headerAt.set(el.id, el.category); + }); + + return ( +
+ + + + + {phases.map((p) => ( + + ))} + + + + {elements.map((el) => { + const header = headerAt.get(el.id) ?? null; + return ( + + {header && ( + + + + )} + + + {phases.map((p) => { + const c = p.elements.find((x) => x.elementId === el.id); + if (!c) return ; + return ( + + ); + })} + + + ); + })} + + {/* Vermögen je Phase -- die Kennzahl, für die der ganze Stand steht. */} + + + {phases.map((p) => ( + + ))} + + +
Element +
{p.name}
+
{p.durationYears} J.
+
+ {CATEGORY_LABELS[header]} +
+ {el.name} + {el.ownerRole && el.ownerRole !== "HOUSEHOLD" && ( + + {el.ownerRole === "PERSON_A" ? "A" : "B"} + + )} + +
+ {formatChf(c.startValue)} +
+ {c.endValue !== c.startValue && ( +
→ {formatChf(c.endValue)}
+ )} +
+ Vermögen am Phasenende + + {formatChf(p.endWealthNominal)} +
({formatChf(p.endWealthReal)} real)
+
+
+ ); +} diff --git a/src/components/VersionPicker.tsx b/src/components/VersionPicker.tsx new file mode 100644 index 0000000..e516478 --- /dev/null +++ b/src/components/VersionPicker.tsx @@ -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([]); + + 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 { + 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 ( + + ); +} + +// 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(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>({}); + const [error, setError] = useState(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 ( +
+ + + {loading && wird geladen…} + {versionId !== CURRENT && !loading && ( + + Nicht der aktuelle Stand + + )} +
+ ); +} + +// Hinweiszeile für die Analyse-Dialoge. +export function VersionHint() { + return ( + + ); +} diff --git a/src/lib/migrations.test.ts b/src/lib/migrations.test.ts index 0da4b9b..12cbf98 100644 --- a/src/lib/migrations.test.ts +++ b/src/lib/migrations.test.ts @@ -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); }); diff --git a/src/lib/versioning-coverage.test.ts b/src/lib/versioning-coverage.test.ts new file mode 100644 index 0000000..ac63826 --- /dev/null +++ b/src/lib/versioning-coverage.test.ts @@ -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 = { + "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([]); + }); +}); diff --git a/src/lib/versioning-db.ts b/src/lib/versioning-db.ts new file mode 100644 index 0000000..b6c8e60 --- /dev/null +++ b/src/lib/versioning-db.ts @@ -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 { + const scenario = await prisma.scenario.findUnique({ where: { id: scenarioId }, include: planInclude }); + return scenario ? toPlanInput(scenario) : null; +} + +async function loadLatest(scenarioId: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 }; +} diff --git a/src/lib/versioning.test.ts b/src/lib/versioning.test.ts new file mode 100644 index 0000000..db7fb35 --- /dev/null +++ b/src/lib/versioning.test.ts @@ -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 { + 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 { + 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); + + 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); + + 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); + }); +}); diff --git a/src/lib/versioning.ts b/src/lib/versioning.ts new file mode 100644 index 0000000..54bff42 --- /dev/null +++ b/src/lib/versioning.ts @@ -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).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(); + const lostPhaseIds = new Set(); + 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, + }; +}