diff --git a/SPEZIFIKATION.md b/SPEZIFIKATION.md index 86ef69c..3757940 100644 --- a/SPEZIFIKATION.md +++ b/SPEZIFIKATION.md @@ -4,7 +4,7 @@ | | | |---|---| | **Dokument** | Funktionale und Technische Spezifikation FPT | -| **Version** | 0.19 | +| **Version** | 0.20 | | **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.20 | 2026-07-19 | Claude (Opus 4.8) | **Raten über Lebensphasen übernehmen** (neues Kapitel 3.6.11) und **Rate in der Verlaufsgrafik**. (1) Ändert man ein **Ratenfeld**, fragt das Bearbeitungspanel neu nach der Reichweite: **nur diese Phase** (Vorgabe, bisheriges Verhalten), **diese + folgende** oder **alle Phasen**. Anlass war, dass eine geänderte Rendite bisher nur für die eine Phase galt und viermal eingetippt werden musste. Als Ratenfelder gelten `expectedReturn` (PK, 3a, Sonstiges Vermögen), `valueGrowth` und `interestRate` (Immobilie) sowie `teuerungsausgleich` (Einkommen, Ausgaben); AHV und Schulden haben keine. Die Rückfrage erscheint **inline und erst beim Speichern wirksam**, nicht als Modal – das Zahlenfeld löst bei jedem Tastendruck aus, ein Dialog erschiene bei «5.2» viermal. Sie erscheint nur bei **tatsächlich veränderten** Raten («nicht gesetzt» und 0 gelten als gleich). Beim Übertragen bleiben die **übrigen Werte der Zielphasen erhalten** – der Endpunkt ersetzt den ganzen Werte-Satz, ein blosses Kopieren des Entwurfs hätte dort Beträge, Sparraten und Bezüge gelöscht (durch Test abgesichert). Phasen, in denen der Wert schon stimmt, werden übersprungen. Kein neuer Schreibpfad: ein PUT je Zielphase über den bestehenden Endpunkt, alle in einer Bearbeitungssitzung und damit in **einer** Nebenversion. (2) `ElementYearPoint` führt neu ein Feld **`rate`** mit – additiv, es wird nur durchgereicht, was die Rechnung ohnehin benutzt; die 43 Golden Tests laufen unverändert. Die Verlaufsgrafik der Element-Detailansicht zeigt die Rate damit auf einer **zweiten Y-Achse rechts** in Prozent, als **Stufenlinie** (innerhalb einer Phase konstant, Sprung an der Phasengrenze). Neues Modul `ratefields.ts`; 17 Tests ergänzt (164 → 181). Keine DB- oder API-Änderung. | | 0.19 | 2026-07-19 | Claude (Opus 4.8) | **Versionierung und Änderungshistorie je Szenario** (neues Kapitel 3.8). Jedes Szenario trägt eine Version **A.B**: **B** entsteht automatisch, **A** manuell mit Pflichtkommentar. **Der zentrale Entwurfsentscheid:** FPT hat keinen Speichern-Knopf – jede Änderung schreibt sofort, ein Assistenten-Durchlauf macht ~14 Schreibvorgänge, ein Verteil-Klick einen je Zielelement. Eine Version je Schreibvorgang wäre ein Tastenprotokoll gewesen; stattdessen werden alle Schreibvorgänge innerhalb von **10 Minuten zu einer** Nebenversion zusammengefasst, inhaltlich unveränderte Stände erzeugen gar keine, und verschiedene Benutzer laufen nie in einer Version zusammen. Eine Version hält den **vollständigen** Zustand als JSON in der Form `PlanInput` – dadurch ist die **Versionsauswahl in allen vier Analysewerkzeugen** (Grafiken, Live-Simulation, Monte-Carlo, Einflussfaktoren) fast kostenlos; bei Monte-Carlo **je Szenario einzeln**, weil dort mehrere gleichzeitig laufen. **Wiederherstellen** ist ungefährlich gebaut: Es legt den zurückgesetzten Stand selbst als neue Version an («Wiederhergestellt aus A.B»), löscht also nichts, und **erhält die IDs** von Phasen und Elementen – sonst verlören alle Kind-Szenarien ihre Diff-Basis und zeigten schlagartig alles als «neu». Wo ein Bezug trotzdem bricht (der alte Stand kannte das Element noch nicht), **warnt der Dialog vorher namentlich**. Die destruktive Logik liegt als reine Funktion `planRestore` vor und ist dort getestet; `versioning-db.ts` führt sie nur aus. Ein **statischer Wächter-Test** liest alle Route-Dateien und verlangt, dass jeder schreibende Endpunkt eine Version auslöst – eine vergessene Stelle wäre eine stille Lücke. Neue Tabelle `ScenarioVersion` + `Scenario.currentMajor` (Migration gegen echtes Postgres verifiziert), neue Endpunkte unter `/api/scenarios//versions`. Neue Kapitel 3.8 und 9.28; 25 Tests ergänzt (139 → 164). | | 0.18 | 2026-07-19 | Claude (Opus 4.8) | **Live-Simulation** (Roadmap Nr. 22). Neuer Button und Dialog als Zweispalter: links Schieberegler, rechts eine wählbare Grafik, darüber eine Kennzahlenleiste. Dreht man an einem Regler, wird der Plan **sofort** neu gerechnet – ohne für jede Variante eine Szenario-Kopie anzulegen. **Keine eigene Rechenlogik:** Die Regler benutzen dieselben Transformationen wie der Tornado (`applyDriver`), können also gar nicht etwas anderes zeigen als die Einflussfaktoren-Analyse. Neu ist nur `applyElementDriver` – dieselbe Verschiebung auf ein **einzelnes** Element statt auf eine ganze Kategorie: Standardmässig gibt es einen Sammelregler «Rendite», ein Klick auf «Renditen einzeln aufschlüsseln» ersetzt ihn durch je einen Regler pro Anlage (der Sammelregler wird dabei **entfernt**, nicht ergänzt, sonst zählte eine Bewegung doppelt; ein Test sichert ab, dass beide Ansichten denselben Plan beschreiben). Der unveränderte Plan wird als **Referenzlinie** mitgezeichnet, und die Kennzahlenleiste weist Endvermögen nominal/real **mit Differenz zum Plan** aus sowie – als eigene Karte – ob das Kapital reicht; ein gekippter Plan ist einer Verlaufslinie sonst nicht anzusehen. Gemessene Laufzeit von `computePlan`: **0.2 ms** auf einem 60-Jahres-Plan mit 10 Elementen, also rund 1 % des 16-ms-Frame-Budgets – deshalb wird synchron gerechnet, **ohne Debounce und ohne Worker**. Anders als der Tornado haben die Regler **Standardbereiche** (Begründung des scheinbaren Widerspruchs zu 9.18: neues Kapitel 9.27), beide Enden editierbar. **Das Pensionsalter fehlt weiterhin** (9.18, eigener Roadmap-Punkt); «Als Szenario speichern» ist bewusst zurückgestellt, ersatzweise zeigt der Dialog die aktive Einstellung als lesbare Zeile. Die Vermögensaufteilung wurde als `AllocationChart` aus dem Dashboard herausgelöst, damit beide sie nutzen. Neue Kapitel 4.15 und 9.27; 15 Tests ergänzt (124 → 139). Keine API-, DB- oder Schreib-Änderung – das Feature liest ausschliesslich. Nebenbei dieselbe vom Umlaut-Sweep verstümmelte Hex-Farbe (`#7c3aed`) wie in 0.17, diesmal im Dashboard. | | 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. | @@ -978,7 +979,7 @@ Jede Element-Zeile und jeder Phasenkopf trägt oben rechts ein **Expand-Icon** ( | Reiter | Inhalt | |---|---| -| Verlauf | Liniendiagramm über **alle Planjahre**, nicht nur die Phasengrenzen. Bei einer Immobilie drei Reihen (Eigenkapital, Verkehrswert, Restschuld). Darunter eine Tabelle Beginn/Ende je Lebensphase. | +| Verlauf | Liniendiagramm über **alle Planjahre**, nicht nur die Phasengrenzen. Bei einer Immobilie drei Reihen (Eigenkapital, Verkehrswert, Restschuld). Zusätzlich die wirksame **Rate** auf einer zweiten Y-Achse rechts in Prozent – als **Stufenlinie**, weil sie innerhalb einer Phase konstant ist und an der Phasengrenze springt; eine interpolierte Kurve würde einen gleitenden Übergang suggerieren, den die Berechnung nicht macht. Die Achse erscheint nur, wenn das Element überhaupt eine Rate trägt. Darunter eine Tabelle Beginn/Ende je Lebensphase. | | Rechenweg | Die Herleitung je Phase und je Übergang (siehe [4.14](#414-verlaufswerte-brücken-und-rechenwege)) | **Lebensphase** – drei Reiter: @@ -1081,6 +1082,59 @@ wenn Folgephasen existieren. Referenz: `src/components/DistributionDialogs.tsx`, `src/lib/distribution.ts`. +### 3.6.11 Raten über Lebensphasen übernehmen + +Werte liegen **je Lebensphase** vor. Bei Beträgen ist das richtig – das Einkommen ändert sich, +der Vermögensstand ohnehin. Bei **Raten** ist es meist nicht gemeint: Wer die erwartete Rendite +seines ETF auf 5 % setzt, meint fast nie «nur in Phase 2». Ohne Übernahme muss derselbe Wert +vier- oder fünfmal eingetippt werden, und dabei wird zuverlässig eine Phase übersehen. + +Ändert man ein Ratenfeld, erscheint deshalb im Bearbeitungspanel eine Rückfrage mit drei +Möglichkeiten: + +| Auswahl | Wirkung | +|---|---| +| **Nur diese Phase** (Vorgabe) | bisheriges Verhalten, andere Phasen bleiben unberührt | +| **Diese + folgende** | ab der bearbeiteten Phase vorwärts – Vergangenes bleibt stehen | +| **Alle Phasen** | der Wert gilt für den ganzen Plan | + +Als **Ratenfelder** gelten: + +| Feld | Kategorien | +|---|---| +| `expectedReturn` | Pensionskasse, Säule 3a, Sonstiges Vermögen | +| `valueGrowth` | Immobilie (Wertsteigerung) | +| `interestRate` | Immobilie (Hypothekarzins) | +| `teuerungsausgleich` | Einkommen (Lohnentwicklung), Ausgaben (reale Mehrausgaben) | + +**AHV und Schulden haben keine**: Die AHV-Rente folgt der amtlichen Formel +([4.4](#44-ahv-rente)), Schulden tragen ihren Zins nicht als eigenes Feld. + +**Vier Entwurfsentscheide:** + +**Die Rückfrage erscheint beim Bearbeiten, nicht als Modal.** Das Zahlenfeld löst bei *jedem +Tastendruck* aus – ein Dialog erschiene bei der Eingabe «5.2» viermal. Stattdessen taucht die +Auswahl inline unter den Feldern auf, sobald sich eine Rate tatsächlich vom gespeicherten Wert +unterscheidet, und wird beim **Speichern** ausgeführt. + +**Nur veränderte Raten lösen sie aus.** Wer bloss einen Betrag anpasst, bekommt keine +Rückfrage. «Nicht gesetzt» und «0» gelten dabei als gleich – sonst meldete schon das Öffnen +eines Panels mit leerem Feld eine Änderung. + +**Die Zielphasen behalten ihre übrigen Werte.** Der Endpunkt ersetzt den *ganzen* Werte-Satz +einer Phase. Würde man den Entwurf der bearbeiteten Phase einfach hinüberkopieren, verlöre jede +andere Phase ihre Beträge, Sparraten und Bezüge. Übertragen wird deshalb ausschliesslich das +geänderte Ratenfeld, in die bestehenden Werte hineingemischt. Ein Test sichert genau das ab. + +**Phasen, in denen der Wert schon stimmt, werden übersprungen** – das spart Schreibvorgänge und +verhindert eine Version, obwohl sich inhaltlich nichts geändert hat. + +Technisch ist die Übernahme ein Schreibvorgang **je Zielphase** über den bestehenden Endpunkt; +es gibt keinen neuen Schreibpfad. Alle fallen in dieselbe Bearbeitungssitzung und ergeben +deshalb **eine** Nebenversion, nicht eine je Phase ([3.8.1](#381-eine-version-je-sitzung--nicht-je-änderung)). + +Referenz: `src/lib/ratefields.ts`, `src/components/ElementDetail.tsx`. + ## 3.7 Bedienoberfläche ### 3.7.1 Layout @@ -2546,6 +2600,7 @@ 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. | +| `ratefields.ts` | Ratenfelder je Kategorie, Reichweite der Übernahme über Lebensphasen, Aufbau der Schreibvorgänge (Kap. 3.6.11). Rein. | | `versioning.ts` | Versionierung: Nummerierung A.B, Zusammenfassung je Sitzung, Bauplan des Wiederherstellens, Auswirkung auf Kind-Szenarien. Rein, ohne I/O. | | `versioning-db.ts` | Datenbank-Anbindung der Versionierung: Snapshot festhalten, Hauptversion, Wiederherstellen. Führt den Bauplan aus `versioning.ts` nur aus. | | `livesim.ts` | Live-Simulation: Reglerkatalog mit Standardbereichen, Anwenden mehrerer Regler, Kennzahlen (Kap. 4.15). Rein, läuft im Browser. | @@ -3081,6 +3136,7 @@ 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 | +| `ratefields.test.ts` | 17 | Erkennung geänderter Raten, Reichweite (diese/folgende/alle), Erhalt der übrigen Werte in den Zielphasen | | `versioning.test.ts` | 22 | Nummerierung und Zusammenfassung je Sitzung, unveränderte Stände, Benutzertrennung, Bauplan des Wiederherstellens, Auswirkung auf Kind-Szenarien | | `versioning-coverage.test.ts` | 3 | statischer Wächter: jeder schreibende Endpunkt löst eine Version aus | | `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 | @@ -3088,7 +3144,7 @@ Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-, API- oder E2E-Tests | `bridges.test.ts` | 10 | Vermögens- und Cash-Brücke gehen über sieben Plankonstellationen ohne Restgrösse auf; Umbuchungen bleiben aus der Vermögensbrücke heraus | | `diff.test.ts` | 9 | Abweichungs-Erkennung gegen das Eltern-Szenario | | `migrations.test.ts` | 1 | spielt alle Migrationen gegen echtes PostgreSQL (PGlite) ein | -| **Total** | **164** | | +| **Total** | **181** | | ## 8.2 Testfälle diff --git a/src/components/DetailView.tsx b/src/components/DetailView.tsx index 4d5db7c..31cabe5 100644 --- a/src/components/DetailView.tsx +++ b/src/components/DetailView.tsx @@ -396,6 +396,17 @@ export function ElementDetailDialog({ ? "Eigenkapital" : "Wert"; + // Zweite Achse nur, wenn dieses Element überhaupt eine Rate trägt (AHV und Schulden nicht). + const rateLabel = + category === "REAL_ESTATE" + ? "Wertsteigerung" + : isFlow + ? category === "INCOME" + ? "Lohnentwicklung" + : "Reale Mehrausgaben" + : "Erwartete Rendite"; + const hasRate = points.some((p) => typeof p.rate === "number"); + const verlauf = ( <> {points.length === 0 ? ( @@ -406,16 +417,49 @@ export function ElementDetailDialog({ `${v} J.`} /> - Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} /> - [typeof v === "number" ? formatChf(v) : v, n]} labelFormatter={(v) => `Alter ${v}`} /> + Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} /> + {hasRate && ( + `${v} %`} + // Etwas Luft nach oben und unten, damit eine konstante Rate nicht als + // Linie direkt auf der Achse klebt. + domain={([min, max]: readonly [number, number]) => [Math.min(0, min - 1), max + 1]} + /> + )} + + typeof v !== "number" ? [v, n] : n === rateLabel ? [`${v} %`, n] : [formatChf(v), n] + } + labelFormatter={(v) => `Alter ${v}`} + /> - + {MULTI_SERIES.includes(category) && ( <> - - + + )} + {hasRate && ( + // Stufenlinie, nicht interpoliert: Die Rate ist innerhalb einer Phase + // konstant und springt an der Phasengrenze. Eine weiche Kurve würde einen + // gleitenden Übergang suggerieren, den die Berechnung nicht macht. + + )} diff --git a/src/components/ElementDetail.tsx b/src/components/ElementDetail.tsx index 9d0018a..1b5fdbc 100644 --- a/src/components/ElementDetail.tsx +++ b/src/components/ElementDetail.tsx @@ -6,6 +6,14 @@ import { AlertTriangle } from "lucide-react"; import { FieldLabel, MoneyField, NumberField, SelectField, TextField } from "@/components/FormField"; import { formatChf } from "@/lib/format"; import { api } from "@/lib/api-client"; +import { + buildRateWrites, + changedRateFields, + scopeQuestion, + SCOPE_OPTIONS, + type PhaseRef, + type RateScope, +} from "@/lib/ratefields"; import { ahvAnnualPension, ahvMdje, type AhvCareer } from "@/lib/calculations"; import { CATEGORY_LABELS, num } from "@/lib/elements"; import { @@ -49,6 +57,10 @@ interface Props { context: CellContext; phaseData: PhaseData; transitionData: TransitionData; + // Alle Phasen des Szenarios und die dort bereits erfassten Werte -- Grundlage für die + // Übernahme einer geänderten Rate auf weitere Phasen (siehe SPEZIFIKATION 3.6.11). + allPhases?: PhaseRef[]; + phaseDataByPhase?: Record; onSaved: () => void; onDeleteElement: () => void; } @@ -886,14 +898,29 @@ export function ElementTransitionFields({ } } -export function ElementDetail({ element, context, phaseData, transitionData, onSaved, onDeleteElement }: Props) { +export function ElementDetail({ + element, + context, + phaseData, + transitionData, + allPhases = [], + phaseDataByPhase = {}, + onSaved, + onDeleteElement, +}: Props) { const [pd, setPd] = useState({ ...phaseData }); const [td, setTd] = useState({ ...transitionData }); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); + // Reichweite einer geänderten Rate. Vorgabe ist das bisherige Verhalten: nur diese Phase. + const [scope, setScope] = useState("THIS"); const isTransition = context.kind === "transition"; + // Welche Ratenfelder hat der Nutzer verändert? Nur dann wird überhaupt gefragt. + const changedRates = isTransition ? [] : changedRateFields(element.category, phaseData, pd); + const showScope = changedRates.length > 0 && allPhases.length > 1; + async function save() { setSaving(true); setError(null); @@ -914,6 +941,21 @@ export function ElementDetail({ element, context, phaseData, transitionData, onS delete payload.amount; } await api.put(`/api/elements/${element.id}/phase/${context.phaseId}`, payload); + + // Geänderte Raten auf die gewählten weiteren Phasen übertragen. Die bestehenden + // Werte der Zielphasen bleiben erhalten -- übernommen wird ausschliesslich die Rate. + // Alle Schreibvorgänge fallen in dieselbe Bearbeitungssitzung und ergeben daher + // EINE Nebenversion, nicht eine je Phase (siehe 3.8.1). + for (const w of buildRateWrites( + allPhases, + context.phaseId, + scope, + changedRates, + pd, + phaseDataByPhase + )) { + await api.put(`/api/elements/${element.id}/phase/${w.phaseId}`, w.data); + } } onSaved(); } catch (e) { @@ -959,6 +1001,38 @@ export function ElementDetail({ element, context, phaseData, transitionData, onS )} + {/* Rückfrage zur Reichweite. Erscheint erst, wenn eine Rate tatsächlich verändert + wurde -- und bewusst hier statt als Modal beim Tippen: Das Zahlenfeld löst bei + jedem Tastendruck aus, ein Dialog erschiene bei «5.2» viermal. */} + {showScope && ( +
+

+ {scopeQuestion(changedRates, allPhases.length)} +

+
+ {SCOPE_OPTIONS.map((o) => ( + + ))} +
+

+ {scope === "THIS" + ? "Andere Phasen bleiben unverändert." + : `Wird beim Speichern in ${ + buildRateWrites(allPhases, context.phaseId, scope, changedRates, pd, phaseDataByPhase).length + } weitere Phase(n) übertragen. Beträge und Raten dort bleiben erhalten – nur der geänderte Wert wird gesetzt.`} +

+
+ )} + {error &&

{error}

}