diff --git a/SPEZIFIKATION.md b/SPEZIFIKATION.md index 796cc97..4303290 100644 --- a/SPEZIFIKATION.md +++ b/SPEZIFIKATION.md @@ -4,10 +4,10 @@ | | | |---|---| | **Dokument** | Funktionale und Technische Spezifikation FPT | -| **Version** | 0.12 | +| **Version** | 0.13 | | **Datum** | 2026-07-18 | | **Status** | Lebendes Dokument | -| **Codestand** | Arbeitsstand nach `4791dcc` inkl. lesbarer Wasserfälle und Verkaufspreis-Abgleich (Branch `main`) | +| **Codestand** | Arbeitsstand nach `e1f74fc` inkl. UI-Gesamtumbau (Pakete A–D) und geführtem Onboarding (Branch `main`) | | **Ersetzt** | `FDD_TDD_FPT.docx` (v1–v5) im Ordner `Info Dateien` – diese sind ab Version 0.1 dieses Dokuments obsolet | | **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` | @@ -17,6 +17,7 @@ | Version | Datum | Autor | Änderung | |---|---|---|---| +| 0.13 | 2026-07-18 | Claude (Fable 5) | **UI-Gesamtumbau** – rein an der Oberfläche, Berechnung, Datenmodell und API-Semantik unverändert. **(A) Fundament:** durchgehende **Du-Form** und **echte Umlaute** in allen sichtbaren Texten (inkl. API-Fehlermeldungen); neue UI-Primitiven in `ui.tsx` (Button, Modal mit ESC/Fokus-Falle/Animation, Bestätigungs-Dialog statt `window.confirm`, Toasts statt `alert`, Skeleton-Loader, EmptyState); eigene **Attention-Farbe** (Amber) für offene Entscheide, getrennt vom Akzent; Micro-Interactions mit `prefers-reduced-motion`-Fallback. **(B) Onboarding (Roadmap Nr. 10):** geführter **Plan-Assistent** in fünf Schritten (reine Orchestrierung bestehender Endpunkte, Einkommen bewusst pro Person – räumt die 9.9-Falle aus), **Beispielplan mit einem Klick** (Übergänge absichtlich offen – die Ampel lehrt sich selbst), **interaktive Tour** über die Planansicht, abgeleitete **«Nächste Schritte»**-Karte. **(C) Struktur:** Einzel-Bearbeitungen laufen neu über ein rechtes **Inspector-Panel** statt Modals (Matrix bleibt sichtbar; Klick auf andere Zelle wechselt den Inhalt); **Phasenkopf entschlackt** auf vier Kern-Infos (Rest wohnt in der Detailansicht aus 0.11); Matrix mit eigenem Scrollbereich und **beidachsig fixierten Köpfen**; Sidebar-Gruppen «Meine Pläne»/«Wissen» («So rechnet FPT», Systemparameter); Terminologie-Fix «Szenario-Profil» statt «Plan-Einstellungen»; Aktions-Icons auch ohne Hover sichtbar (Touch). **(D) Extras:** **Sparklines** je Element-Zeile (aus den 0.11-Verlaufswerten, keine Neuberechnung), **Befehls-Palette** (Ctrl/Cmd+K), Ruin-Banner verlinkt auf die Einflussfaktoren. Neue Kapitel 3.2.8, 3.7.6–3.7.9, 9.23, 9.24; 9.17 bereinigt (der `Selection`-Rest und der ProfileMenu-Lint-Fehler sind behoben – `npm run lint` ist erstmals fehlerfrei). Testbestand unverändert 103. | | 0.12 | 2026-07-18 | Claude (Opus 4.8) | **Lesbarkeit der Wasserfälle, Verkaufspreis-Abgleich und Erklärung wirkungsloser Tornado-Treiber.** (1) Die beiden Wasserfälle werden **nicht mehr mit Recharts** gezeichnet, sondern als eigene liegende Darstellung: Verbindungslinien zwischen den Balken, Wertbeschriftung an jedem Schritt, Abschnitts-Überschriften („Am Übergang" / „Innerhalb der Phase") und eine aufklappbare Tabelle mit **laufendem Zwischenstand**. Anlass war, dass die bisherige Darstellung faktisch nicht lesbar war – die Zahlen waren korrekt, die Grafik nicht. (2) Der Restposten beider Brücken wird bei Abweichung neu als **Fehlermeldung** ausgewiesen statt als beiläufige „Rundungsdifferenz"; eine nicht aufgehende Zerlegung ist ein Rechenfehler und kein Schönheitsproblem. (3) **Verkaufspreis einer Immobilie** wird beim Wechsel auf „Verkaufen" neu mit dem **modellierten Verkehrswert** vorbelegt; der Dialog weist Verkehrswert und Abweichung aus und warnt ab 10 % Differenz (Kap. 3.5.8, 9.22). Damit fällt auf, wenn angenommene Wertsteigerung und erwarteter Verkaufspreis nicht zusammenpassen. (4) Der Tornado erklärt neu **Nullbalken** statt sie stumm zu zeigen – insbesondere den Fall, dass die Immobilien-Wertsteigerung bei einem Verkauf nachweislich wirkungslos ist (`ineffectiveReason`, Kap. 4.13.5). Neue Kapitel 3.5.8, 4.13.5, 9.22; 11 Tests ergänzt (92 → 103), darunter die Invariante `residual === 0` über sieben Plankonstellationen. Keine DB-Änderung, keine Änderung an der Berechnung. | | 0.11 | 2026-07-18 | Claude (Opus 4.8) | **Detailansichten (Roadmap Nr. 43)** und **vollständige Offenlegung der Berechnungslogiken (Roadmap Nr. 41)**. (1) Neue **Systemparameter-Ansicht** in der Seitenleiste: alle fest hinterlegten Grössen mit Wert, Bedeutung, Herleitung, Quelle und Stand – als strukturierte Daten aus `constants.ts`, also aus derselben Quelle, aus der gerechnet wird. (2) **Nur-Lese-Detailansicht** je Element und je Lebensphase über ein Expand-Icon: Element mit Verlaufsgrafik über **alle Planjahre** (dafür führt `computePlan` neu `ElementPhaseComputed.yearly` je Element mit), Phase mit Vermögensaufteilung und **zwei Wasserfällen**. (3) Die **Wasserfälle** sind bewusst getrennt: Der Vermögens-Wasserfall zeigt nur echte Zu- und Abgänge (Quote, Kapitalerträge, Wertsteigerung, PK-Beiträge, Steuern, Verrentung, Einmalposten); Sparraten, Amortisationen und Investitionen sind **Umbuchungen** und erscheinen ausschliesslich im Cash-Wasserfall – als Vermögensabgang gezeichnet würden sie einen Verlust vortäuschen, den es nicht gibt. Neue Strukturen `WealthBridge` / `CashBridge` inkl. Restposten als Kontrollgrösse. (4) **Rechenweg-Protokoll**: `computePlan(plan, sample?, { explain })` protokolliert die Schritte, die es ohnehin ausführt – Formel, eingesetzte Zahlen, Ergebnis und Hinweis auf geltende Vereinfachungen. Abdeckung über **alle** Ebenen (Element je Phase, Element je Übergang, Phasen-Kennzahlen, Plan-Ebene). Standardmässig aus, damit die Monte-Carlo-Simulation unberührt bleibt. Jeder Rechenweg verlinkt in das passende Kapitel dieser Spezifikation; ein Test prüft, dass alle Verweise eine existierende Überschrift treffen. Neue Kapitel 3.6.7, 3.6.8, 4.14, 9.20, 9.21; 12 Tests ergänzt (80 → 92). Keine DB-Änderung; die 43 Golden Tests laufen unverändert. | | 0.10 | 2026-07-18 | Claude (Opus 4.8) | **Szenario-Vergleich in der Monte-Carlo-Simulation** und **Sensitivitätsanalyse / Tornado** (Roadmap Nr. 20). (1) Die MC-Simulation rechnet neu **mehrere Szenarien desselben Plans in einem Lauf**. Die historischen Annahmen werden dabei nur **einmal je logischem Element** erfasst – die Zuordnung über die Herkunfts-Kette `sourceElementId`, dieselbe Grundlage wie beim Diff (neue Funktionen `resolveRootElementId`, `buildElementGroups`, `paramsForScenario`, `runMonteCarloMulti`). Alle Szenarien laufen mit **demselben Seed** (Common Random Numbers), damit Unterschiede strukturell und nicht zufällig sind. Der **Zielbetrag bleibt szenario-eigen** (vorbelegt mit dem jeweils geplanten Endvermögen) – nur so misst die Erfolgswahrscheinlichkeit, wie oft ein Szenario sein *eigenes* Versprechen hält. Ergebnis als Vergleichstabelle plus Median-Linien je Szenario; bei einem einzelnen Szenario unverändert der bisherige Fächer. (2) Neuer Bereich **Einflussfaktoren** (eigener Button, eigener Dialog) mit einem **Tornado-Chart** nach dem One-at-a-time-Verfahren: neues reines Modul `sensitivity.ts` mit sieben Treibern, je Treiber an-/abwählbar und mit **pflichtiger, frei definierbarer Bandbreite ohne Default**. Das **Pensionsalter ist bewusst nicht enthalten** (Begründung: 9.18). Neue Kapitel 3.6.6, 4.12.6, 4.13, 9.18; 20 Tests ergänzt (60 → 80). Keine DB-Änderung, keine Verhaltensänderung bestehender Pläne. **Ausserdem vier Dokumentationsfehler korrigiert:** Kap. 1.2 nannte noch Monte-Carlo, Hypothekarzinsen und Immobilien-Wertsteigerung als nicht umgesetzt (seit 0.5/0.7 vorhanden); Kap. 4.6.5 endete mit einem widersprüchlichen Restsatz zur fehlenden Wertsteigerung; Kap. 5.2/5.3/5.5.2/8.1 waren bei Migrationszahl, Dateiliste, Komponentenliste und Testzahlen veraltet; der Glossar-Eintrag „Szenario" beschrieb noch das Modell vor V6. | @@ -245,7 +246,15 @@ Zweistufig: ### 3.2.1 Plan erstellen -Dialog mit Name plus dem vollständigen Grundprofil: +Der «+»-Knopf öffnet eine Auswahl mit drei Wegen: + +| Weg | Für wen | Was passiert | +|---|---|---| +| **Geführt erstellen** (empfohlen) | neue Nutzer | der Assistent aus [3.2.8](#328-geführter-assistent-und-beispielplan) | +| **Leer starten** | geübte Nutzer | der bisherige Dialog (unten) – nur Grundprofil, keine Phasen/Elemente | +| **Beispielplan ansehen** | Erkunden | legt einen fiktiven, voll ausgefüllten Plan an ([3.2.8](#328-geführter-assistent-und-beispielplan)) | + +Der Dialog «Leer starten» fragt Name plus das vollständige Grundprofil: | Feld | Typ | Default | Wertebereich | |---|---|---|---| @@ -353,6 +362,32 @@ und Grafiken beschriften damit Jahre statt nur Alter. Die Berechnung rechnet unv Beim Anlegen wird das laufende Jahr vorbelegt; bestehende Szenarien wurden per Migration darauf gesetzt. Kalenderjahr eines Planjahrs: `startYear + (Jahr − 1)`. +### 3.2.8 Geführter Assistent und Beispielplan + +**Der Plan-Assistent** (Roadmap Nr. 10: «Schritt für Schritt statt leerer Matrix») fragt in +fünf Schritten in Alltagssprache: (1) Grundprofil, (2) Lebensphasen – vom Tool anhand des +Pensionsalters **vorgeschlagen** (dieselbe Kappungslogik `maxPhaseDuration` wie beim manuellen +Anlegen; bei Paaren mit unterschiedlichem Pensionsalter inkl. Übergangsphase), Dauer editierbar, +(3) Einkommen und Ausgaben plus Kontostand, (4) Vorsorge und Vermögen als Checkliste (PK, 3a, +Wertschriften, Wohneigentum, Schulden – nur Angehaktes fragt Details; die AHV wird automatisch +je Person angelegt), (5) Zusammenfassung. + +Zwei bewusste Entscheide: +- **Einkommen wird pro Person erfasst**, nie als «Gemeinsam» – in Paar-Plänen zählt + Haushalts-Einkommen nicht für die AHV ([9.9](#99-gemeinsames-einkommen-zählt-bei-paaren-nicht-für-die-ahv)); + der Assistent räumt diese Falle von Anfang an aus. +- Technisch ist der Assistent **reine Orchestrierung bestehender Endpunkte** (Plan → Phase 1 → + Elemente samt Werten → Folgephasen; die Reihenfolge stellt sicher, dass die Phasen-Route die + Folgephasen korrekt vorbelegt). Kein neuer Endpunkt, keine Berechnungsänderung. Grenze: 9.23. + +**Der Beispielplan** («Beispiel: Alex Muster», `src/lib/demoplan.ts`) ist ein fiktiver, voll +ausgefüllter Plan per Ein-Klick – ebenfalls reine Orchestrierung. Die Übergangs-Entscheide +bleiben **absichtlich offen**: Der neue Nutzer sieht die Ampel («N offen») in Aktion und lernt +das wichtigste Konzept am Beispiel statt aus einer Erklärung. + +Nach dem ersten Öffnen eines Plans mit Phasen startet einmalig die **Tour** +([3.7.8](#378-tour-und-nächste-schritte)). + ## 3.3 Lebensphasen ### 3.3.1 Phase anlegen @@ -403,7 +438,7 @@ Referenz: `src/components/PhaseDetail.tsx`. ### 3.3.3 Phase löschen **Nur die letzte Phase** kann gelöscht werden – serverseitig geprüft (HTTP 400: „Nur die letzte -Phase kann geloescht werden."). Damit bleibt die Kette der `sequenceNumber` lückenlos. Der +Phase kann gelöscht werden."). Damit bleibt die Kette der `sequenceNumber` lückenlos. Der Löschen-Button erscheint im Detail-Panel nur bei der letzten Phase. Referenz: `src/app/api/phases/[phaseId]/route.ts` Zeilen 55–77. @@ -797,27 +832,23 @@ Referenz: `src/components/Timeline.tsx`. ### 3.6.3 Phasenkopf-Kennzahlen -Jeder Phasenkopf zeigt kompakt: +Seit 0.13 ist der Phasenkopf bewusst **schlank** – zwölf Kennzahlen in 10-Pixel-Schrift liest +niemand (Progressive Disclosure): | Kennzahl | Bedeutung | |---|---| | Name + Status-Icon | grünes Häkchen oder rotes Warnsymbol (Liquiditätslücke) | | Typ-Badge + Dauer | Erwerb / Pension / Misch, „N J." | | Alter je Person | ` ` | -| Einkommen | Jahr 1 → letztes Jahr | -| Ausgaben | Jahr 1 → letztes Jahr (nominal) | -| **Quote** bzw. **Verzehr** | Einkommen − Ausgaben; Label wechselt auf „Verzehr", wenn Jahr 1 negativ | -| Geplante Sparrate | 3a + Sparbeitrag + Amortisation + Tilgung, im **ersten** Phasenjahr | -| Geplante Verzehrrate | Summe der Bezugsraten | -| Kapitalzufluss | nur wenn > 0: Verkäufe + PK-/3a-Bezüge aus dem Übergang **in** diese Phase | -| Kapitalinvestitionen | nur wenn > 0: Zusatzinvestitionen + Sofort-Tilgungen | -| **Einmaliger Zufluss** | nur wenn > 0: Bezeichnung + Betrag (grün), aus dem Übergang in diese Phase | -| **Einmalige Kosten** | nur wenn > 0: Bezeichnung + Betrag (rot) | -| Vermögen | Start → Ende (inkl. Cash) | +| **Quote** bzw. **Verzehr** | Einkommen − Ausgaben; Label wechselt auf „Verzehr", wenn Jahr 1 negativ; rot bei Verzehr | +| Vermögen | Start → Ende (inkl. Cash), hervorgehoben | +| Einmalposten | nur als Kurzhinweis (Bezeichnung), wenn vorhanden | -Die Einmalposten stehen bewusst **getrennt** von Kapitalzufluss/-investitionen: Eine Erbschaft -ist kein Verkaufserlös und ein Poolbau keine Kapitalinvestition – eine Vermischung würde die -Kennzahl falsch beschriften. +Alles Weitere – Einkommen/Ausgaben Jahr 1 → letztes Jahr, geplante Spar-/Verzehrrate, +Kapitalzufluss und -investitionen, die vollen Einmalposten – wohnt in der +**Phasen-Detailansicht** (seit 0.11, [3.6.7](#367-detailansichten-je-element-und-je-lebensphase)), +erreichbar über das Expand-Icon im Kopf. Die Kennzahlen selbst sind unverändert berechnet; nur +der Anzeigeort hat gewechselt. Referenz: `src/components/PlanView.tsx` Zeilen 701–769. @@ -931,11 +962,17 @@ Referenz: `src/components/SystemParametersView.tsx`, `src/lib/constants.ts`. ### 3.7.1 Layout -- **Sidebar** (Desktop ab `lg` fix, mobil als Overlay): Logo, „Übersicht", Planliste mit - Phasenanzahl, „+"-Button für neuen Plan. +- **Sidebar** (Desktop ab `lg` fix, mobil als Overlay): Logo, Ctrl-K-Hinweis, „Übersicht", + Gruppe **«Meine Pläne»** (Szenario-Baum, «+»), Gruppe **«Wissen»** («So rechnet FPT» = + gerenderte Spezifikation, «Systemparameter»). - **Header**: Menü-Button (mobil), Plantitel, Profilmenü. -- **Hauptbereich**: Übersicht (Plan-Kacheln) oder Planansicht (Umschalter, Zeitachse, - Grundprofil-Leiste, Aktionsbuttons, Ruin-Banner, Matrix, Detail-Panel, Dashboard). +- **Hauptbereich**: Übersicht (Plan-Kacheln bzw. EmptyState mit «Geführt starten» / + «Beispielplan») oder Planansicht (Umschalter, Zeitachse, Szenario-Profil-Leiste, + «Nächste Schritte», Ruin-Banner, Matrix, Inspector-Panel rechts). +- Die **Matrix** hat einen eigenen Scrollbereich (max. 75 vh): Phasen-Köpfe (oben) und + Elementnamen (links) bleiben beim Scrollen in **beiden Achsen** fixiert. +- Alle Aktions-Icons (Kopieren, Löschen, Detailansicht) sind auch ohne Hover leicht sichtbar – + auf Touch-Geräten gibt es kein Hover. ### 3.7.2 Farbschemata @@ -975,7 +1012,75 @@ gefolgt von `onChanged()` → `loadDetail(planId, silent = true)`. Der „silent und Berechnung neu, ohne die `PlanView` zu demontieren – so bleibt die Scrollposition nach dem Schliessen eines Popups erhalten. -Referenz: `src/components/AppShell.tsx` Zeilen 46–74. +Referenz: `src/components/AppShell.tsx`. + +### 3.7.6 Sprache und UI-Primitiven + +Seit 0.13 gilt durchgehend die **Du-Form** (wie moderne CH-Finanz-Apps) und es werden überall +**echte Umlaute** verwendet – zuvor mischte das UI «Übergang prüfen» mit «Uebersicht» und +Sie- mit Du-Anrede. Der Sweep umfasst auch die API-Fehlermeldungen (z. B. „Nur die letzte +Phase kann gelöscht werden."). + +Alle Bausteine liegen zentral in `src/components/ui.tsx`: + +| Baustein | Ersetzt / leistet | +|---|---| +| `Button` | vier Varianten (primary/secondary/danger/ghost), einheitliche Hover-/Active-Zustände | +| `Modal` | Dialog mit ESC, Fokus-Falle, `aria-modal`, Einblend-Animation | +| `ConfirmProvider` / `useConfirm` | ersetzt `window.confirm()` – Browser-Systemdialoge folgen keinem Farbschema | +| `ToastProvider` / `useToast` | ersetzt `alert()`; Erfolgs- und Fehlermeldungen unten rechts | +| `Skeleton` / `PlanSkeleton` | Struktur-Platzhalter statt «Lädt…»-Text | +| `EmptyState` | einheitliche leere Zustände mit Erklärung und Aktion | +| `InspectorShell` | das rechte Bearbeiten-Panel (3.7.7) | + +Mehrere Schichten (Panel + Dialog) teilen sich einen **ESC-Stapel**: ESC schliesst immer nur +die oberste. Animationen (150–220 ms) respektieren `prefers-reduced-motion`. + +**Attention-Farbe:** Offene Entscheide tragen neu einen eigenen Amber-Ton (`--attention`, +je Farbschema abgestimmt) statt der Akzentfarbe – «hier fehlt eine Eingabe» und «hier kannst +du klicken» sind damit unterscheidbar. + +### 3.7.7 Inspector-Panel statt Modals + +Alle **Einzel-Bearbeitungen** – Phasenzelle, Übergangszelle, Cash-Übergang, Cash-Anfangswert, +Phase (Name/Dauer), Szenario-Profil – öffnen sich als rechtes Seitenpanel (`InspectorShell`, +26 rem, mobil volle Breite) statt als Modal. Bewusst **ohne Backdrop**: Die Matrix bleibt +sichtbar und klickbar, ein Klick auf eine andere Zelle wechselt einfach den Panel-Inhalt – +der Kontext, den Modals nehmen, bleibt erhalten. Genau ein Panel kann offen sein (eine +`Panel`-Union ersetzt die früheren Einzel-Zustände); der `key` erzwingt beim Wechsel den +Neuaufbau des Formulars wie zuvor bei den Dialogen. + +**Modals bleiben** für Erstell-Flows (Element, Phase, Plan, Assistent), den geführten +Übergang (mehrere Objekte auf einmal), die Analysen und die Detailansichten. + +Der frühere Dialog «Plan-Einstellungen» heisst im Panel korrekt **«Szenario-Profil»** – er +bearbeitet seit V6 das Szenario, nicht den Plan. + +### 3.7.8 Tour und «Nächste Schritte» + +Die **Tour** (`Tour.tsx`) startet einmalig beim ersten Öffnen eines Plans mit Phasen +(localStorage `fpt-tour-done`) und führt in bis zu sechs Schritten über Profil, Zeitachse, +Matrix, Übergänge, Cash und Analysen – als Karte am unteren Rand plus pulsierender Rahmen um +das Ziel (`data-tour`-Attribute). Schritte ohne vorhandenes Ziel werden übersprungen; der +«Tour»-Knopf in der Werkzeugleiste startet sie jederzeit neu. Grenze: 9.24. + +Die Karte **«Nächste Schritte»** über der Matrix leitet aus den vorhandenen Daten ab, was +sinnvollerweise als Nächstes ansteht – offene Übergangs-Entscheide (mit Direktsprung in den +geführten Übergang), fehlendes Einkommen/fehlende Ausgaben, ein Plan, der vor der +Pensionierung endet, und bei Ruin der Sprung zu den Einflussfaktoren. Kein eigener Zustand, +kein API-Aufruf; die Karte verschwindet von selbst, wenn nichts offen ist. Auch das +**Ruin-Banner** verlinkt neu auf die Einflussfaktoren («Wirksamsten Hebel finden»). + +### 3.7.9 Befehls-Palette und Sparklines + +**Ctrl/Cmd+K** öffnet die Befehls-Palette (`CommandPalette.tsx`): Springen zu jedem +Plan/Szenario und die wichtigsten Aktionen (Analysen, neues Szenario, neuer Plan), mit +Tastatur-Navigation – der Effizienz-Pfad für geübte Nutzer. + +Jede Element-Zeile der Matrix trägt eine **Sparkline** (`Sparkline.tsx`): der Wertverlauf +über alle Planjahre als Mini-SVG, gespeist aus den per-Jahr-Werten der Detailansichten +(`ElementPhaseComputed.yearly`, seit 0.11) – keine Neuberechnung, reine Darstellung. Flache +Verläufe werden nicht gezeichnet (keine Information). --- @@ -2042,7 +2147,7 @@ FPT/ │ │ ├── page.tsx Einstiegsseite (lädt /api/auth/me → AppShell) │ │ ├── layout.tsx Root-Layout, Theme-Init-Script, Metadata │ │ └── globals.css Tailwind + semantische Farb-Tokens (3 Themes) -│ ├── components/ 17 React-Komponenten (alle "use client") +│ ├── components/ 22 React-Komponenten (alle "use client") │ ├── generated/prisma/ Generierter Prisma-Client (nicht editieren) │ ├── lib/ Domänenlogik (siehe 5.3) │ └── middleware.ts Zugriffsschutz (Edge-Runtime) @@ -2335,6 +2440,11 @@ wird der Plan neu geladen; die Berechnung kommt immer vom Server. | `SystemParametersView` | ~90 | Systemparameter mit Wert, Bedeutung, Herleitung, Quelle und Stand | | `SpecView` | 65 | Rendert `SPEZIFIKATION.md` (via `/api/spec`) als lesbares Dokument, inkl. Sprungmarken aus den Rechenwegen | | `InfoBubble` | 28 | Hilfe-Tooltip | +| `ui` | ~370 | UI-Primitiven: Button, Modal, InspectorShell, Confirm, Toast, Skeleton, EmptyState ([3.7.6](#376-sprache-und-ui-primitiven)/[3.7.7](#377-inspector-panel-statt-modals)) | +| `PlanWizard` | ~400 | Geführter Plan-Assistent in fünf Schritten ([3.2.8](#328-geführter-assistent-und-beispielplan)) | +| `Tour` | ~140 | Interaktive Kurz-Tour über die Planansicht ([3.7.8](#378-tour-und-nächste-schritte)) | +| `CommandPalette` | ~130 | Befehls-Palette Ctrl/Cmd+K ([3.7.9](#379-befehls-palette-und-sparklines)) | +| `Sparkline` | ~45 | Mini-Verlaufskurve je Element-Zeile ([3.7.9](#379-befehls-palette-und-sparklines)) | ### 5.5.3 Wiederverwendungsmuster @@ -2470,7 +2580,7 @@ Hängt eine Phase am Ende an, kappt die Dauer, vergibt Default-Name, legt vorbel → 200 `{ phase: { id } }` ### `DELETE /api/phases/` -Nur die letzte Phase. → 200 `{ ok }` · 400 „Nur die letzte Phase kann geloescht werden." +Nur die letzte Phase. → 200 `{ ok }` · 400 „Nur die letzte Phase kann gelöscht werden." ### `PUT /api/phases//cash-transition` Body = `CashTransitionData` (siehe 5.4.5). Speichert den Cash-Entscheid für den Übergang **nach** @@ -2834,15 +2944,13 @@ Verwandt: Wird ein Element in der Vorlage gelöscht, gilt das Gegenstück im Kin ## 9.17 Kleinere Beobachtungen - `planToCsv(plan, computed)` erhält den `plan`-Parameter, verwendet ihn aber nicht. -- Der Typ `Selection` in `PlanView` hat nur eine Variante (`{ type: "phase" }`) – ein Rest der - früheren Struktur mit mehreren Auswahlarten. - `Plan.branchFromPhaseId` hat keine Fremdschlüssel-Constraint; die Phase kann gelöscht werden, ohne dass das Feld bereinigt wird. - Die Element-Umbenennung ist als API vorhanden, im UI aber nicht erreichbar. - Die `README.md` ist noch der unveränderte `create-next-app`-Text. -- `npm run lint` meldet einen bestehenden Fehler in `ProfileMenu.tsx` Zeile 15 - (`react-hooks/set-state-in-effect`); an vergleichbaren Stellen ist die Regel andernorts - bewusst per `eslint-disable` deaktiviert. + +(Der frühere `Selection`-Rest in `PlanView` und der ProfileMenu-Lint-Fehler sind seit 0.13 +behoben – `npm run lint` läuft fehlerfrei.) ## 9.18 Tornado: was der Chart nicht leistet @@ -2934,6 +3042,23 @@ seit 0.12 erklärt wird ([4.13.5](#4135-wirkungslose-treiber-werden-erklärt)); Vermögensbrücke erscheint stattdessen die Differenz `Verkaufspreis − Verkehrswert` als eigener Posten. +## 9.23 Assistent: Teilzustand bei Abbruch + +Der Plan-Assistent und der Beispielplan senden am Ende eine **Sequenz** bestehender API-Aufrufe +(Plan → Phase 1 → Elemente → Folgephasen). Bricht die Sequenz mittendrin ab (Netzfehler), +existiert ein **Teil-Plan**. Der ist normal weiterbearbeitbar und der Fehlerhinweis sagt das +auch – aber es gibt kein automatisches Rollback. Das wäre nur mit Backend-Unterstützung +(Transaktion über mehrere Requests oder Batch-Endpunkt) sauber lösbar und ist bewusst nicht +gebaut: Der seltene Fehlerfall rechtfertigt keinen neuen Endpunkt. + +## 9.24 Tour ohne Spotlight-Engine + +Die Tour hebt ihr Ziel per Rahmen-Puls und `scrollIntoView` hervor – bewusst ohne +Spotlight-Overlay und Positionierungs-Engine (die Karte sitzt fix unten). Bei stark +verschachtelten Scroll-Situationen kann das Ziel teilweise verdeckt sein. Der Einfachheit +halber in Kauf genommen; eine echte Coach-Mark-Bibliothek wäre der nächste Schritt, wenn die +Tour sich bewährt. + --- # 10. Glossar @@ -2976,4 +3101,4 @@ Posten. --- -*Ende der Spezifikation v0.12* +*Ende der Spezifikation v0.13* diff --git a/src/app/api/auth/change-password/route.ts b/src/app/api/auth/change-password/route.ts index 8baaa3f..81651e0 100644 --- a/src/app/api/auth/change-password/route.ts +++ b/src/app/api/auth/change-password/route.ts @@ -17,7 +17,7 @@ export async function POST(request: NextRequest) { const body = await request.json(); const parsed = changePasswordSchema.safeParse(body); if (!parsed.success) { - const message = parsed.error.issues[0]?.message ?? "Ungueltige Eingabe."; + const message = parsed.error.issues[0]?.message ?? "Ungültige Eingabe."; return NextResponse.json({ error: message }, { status: 400 }); } @@ -25,7 +25,7 @@ export async function POST(request: NextRequest) { await changeUserPassword(userId, parsed.data.currentPassword, parsed.data.newPassword); } catch (e) { return NextResponse.json( - { error: e instanceof Error ? e.message : "Passwort aendern fehlgeschlagen." }, + { error: e instanceof Error ? e.message : "Passwort ändern fehlgeschlagen." }, { status: 400 } ); } diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index 762cdee..928168f 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -12,7 +12,7 @@ export async function POST(request: NextRequest) { const body = await request.json(); const parsed = registerSchema.safeParse(body); if (!parsed.success) { - const message = parsed.error.issues[0]?.message ?? "Ungueltige Eingabe."; + const message = parsed.error.issues[0]?.message ?? "Ungültige Eingabe."; return NextResponse.json({ error: message }, { status: 400 }); } diff --git a/src/app/api/elements/[elementId]/phase/[phaseId]/route.ts b/src/app/api/elements/[elementId]/phase/[phaseId]/route.ts index 4bd79de..e5c03fa 100644 --- a/src/app/api/elements/[elementId]/phase/[phaseId]/route.ts +++ b/src/app/api/elements/[elementId]/phase/[phaseId]/route.ts @@ -21,7 +21,7 @@ export async function PUT( const body = await request.json(); const parsed = phaseDataSchema.safeParse(body); - if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 }); + if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 }); await prisma.elementPhaseValue.upsert({ where: { elementId_phaseId: { elementId, phaseId } }, diff --git a/src/app/api/elements/[elementId]/route.ts b/src/app/api/elements/[elementId]/route.ts index 8d273bc..f66129b 100644 --- a/src/app/api/elements/[elementId]/route.ts +++ b/src/app/api/elements/[elementId]/route.ts @@ -19,7 +19,7 @@ export async function PATCH( const body = await request.json(); const parsed = patchSchema.safeParse(body); - if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 }); + 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 } }); 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 ff15d05..3e9b05b 100644 --- a/src/app/api/elements/[elementId]/transition/[fromPhaseId]/route.ts +++ b/src/app/api/elements/[elementId]/transition/[fromPhaseId]/route.ts @@ -4,7 +4,7 @@ import { getOwnedElement } from "@/lib/queries"; import { getCurrentUserId } from "@/lib/session"; import { transitionDataSchema } from "@/lib/elements"; -// Speichert den Uebergangs-Entscheid eines Elements nach der Phase fromPhase (Upsert). +// Speichert den Übergangs-Entscheid eines Elements nach der Phase fromPhase (Upsert). export async function PUT( request: NextRequest, { params }: { params: Promise<{ elementId: string; fromPhaseId: string }> } @@ -21,7 +21,7 @@ export async function PUT( const body = await request.json(); const parsed = transitionDataSchema.safeParse(body); - if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 }); + if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 }); await prisma.elementTransitionValue.upsert({ where: { elementId_fromPhaseId: { elementId, fromPhaseId } }, diff --git a/src/app/api/phases/[phaseId]/cash-transition/route.ts b/src/app/api/phases/[phaseId]/cash-transition/route.ts index 04c10f6..a58af59 100644 --- a/src/app/api/phases/[phaseId]/cash-transition/route.ts +++ b/src/app/api/phases/[phaseId]/cash-transition/route.ts @@ -4,7 +4,7 @@ import { getOwnedPhase } from "@/lib/queries"; import { getCurrentUserId } from "@/lib/session"; import { cashTransitionSchema } from "@/lib/elements"; -// Speichert den Cash-Entscheid beim UEBERGANG nach dieser Phase: 1:1 uebernehmen oder +// Speichert den Cash-Entscheid beim UEBERGANG nach dieser Phase: 1:1 übernehmen oder // einmaliger Zufluss / einmalige Kosten (Roadmap Nr. 1). Cash ist kein FinancialElement, // deshalb liegt der Entscheid direkt an der Von-Phase. export async function PUT( @@ -20,7 +20,7 @@ export async function PUT( const body = await request.json(); const parsed = cashTransitionSchema.safeParse(body); - if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 }); + if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 }); await prisma.phase.update({ where: { id: phase.id }, diff --git a/src/app/api/phases/[phaseId]/route.ts b/src/app/api/phases/[phaseId]/route.ts index 37712f4..5a2ddf8 100644 --- a/src/app/api/phases/[phaseId]/route.ts +++ b/src/app/api/phases/[phaseId]/route.ts @@ -23,11 +23,11 @@ export async function PUT( const body = await request.json(); const parsed = updatePhaseSchema.safeParse(body); - if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 }); + if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 }); let duration = parsed.data.durationYears; if (duration != null) { - // Dauer ans naechste Pensionsereignis kappen (Jahre vor dieser Phase). + // Dauer ans nächste Pensionsereignis kappen (Jahre vor dieser Phase). const scenario = await getOwnedScenario(existing.scenarioId, userId); if (scenario) { const planInput = toPlanInput(scenario); @@ -50,7 +50,7 @@ export async function PUT( return NextResponse.json({ phase: { id: phase.id } }); } -// Nur die letzte Phase kann geloescht werden (Verkettung bleibt intakt). +// Nur die letzte Phase kann gelöscht werden (Verkettung bleibt intakt). export async function DELETE( _request: NextRequest, { params }: { params: Promise<{ phaseId: string }> } @@ -66,7 +66,7 @@ export async function DELETE( where: { scenarioId: phase.scenarioId, sequenceNumber: { gt: phase.sequenceNumber } }, }); if (later) { - return NextResponse.json({ error: "Nur die letzte Phase kann geloescht werden." }, { status: 400 }); + return NextResponse.json({ error: "Nur die letzte Phase kann gelöscht werden." }, { status: 400 }); } await prisma.phase.delete({ where: { id: phaseId } }); diff --git a/src/app/api/plans/[planId]/route.ts b/src/app/api/plans/[planId]/route.ts index 3425f5f..bdc8746 100644 --- a/src/app/api/plans/[planId]/route.ts +++ b/src/app/api/plans/[planId]/route.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { prisma } from "@/lib/db"; import { getCurrentUserId } from "@/lib/session"; -// Der Plan ist nur noch der Behaelter: er traegt den Namen; die Finanzdaten liegen im Szenario. +// Der Plan ist nur noch der Behälter: er trägt den Namen; die Finanzdaten liegen im Szenario. const patchSchema = z.object({ name: z.string().min(1).max(120) }); export async function PATCH(request: NextRequest, { params }: { params: Promise<{ planId: string }> }) { @@ -15,13 +15,13 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 }); const parsed = patchSchema.safeParse(await request.json()); - if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 }); + if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 }); const updated = await prisma.plan.update({ where: { id: plan.id }, data: { name: parsed.data.name } }); return NextResponse.json({ plan: { id: updated.id, name: updated.name } }); } -// Loescht den Plan inkl. aller Szenarien (Cascade). +// Löscht den Plan inkl. aller Szenarien (Cascade). export async function DELETE(_request: NextRequest, { params }: { params: Promise<{ planId: string }> }) { const userId = await getCurrentUserId(); if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); diff --git a/src/app/api/plans/route.ts b/src/app/api/plans/route.ts index 55f29b1..cf4f08e 100644 --- a/src/app/api/plans/route.ts +++ b/src/app/api/plans/route.ts @@ -23,15 +23,15 @@ const createPlanSchema = z.object({ name: z.string().min(1).max(120) }).and(scen export function validatePersonsForType(data: z.infer): string | null { if (data.householdType === "SINGLE" && data.persons.length !== 1) { - return "Einzelperson-Plan benoetigt genau eine Person."; + return "Einzelperson-Plan benötigt genau eine Person."; } if (data.householdType === "COUPLE" && data.persons.length !== 2) { - return "Paar-Plan benoetigt genau zwei Personen (Person A und Person B)."; + return "Paar-Plan benötigt genau zwei Personen (Person A und Person B)."; } return null; } -// Liste der Plaene mit ihrem Szenario-Baum (nur Kopfdaten). +// Liste der Pläne mit ihrem Szenario-Baum (nur Kopfdaten). export async function GET() { const userId = await getCurrentUserId(); if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); @@ -52,7 +52,7 @@ export async function GET() { return NextResponse.json({ plans }); } -// Legt einen Plan an -- zusammen mit seinem Basisszenario, das das Grundprofil traegt. +// Legt einen Plan an -- zusammen mit seinem Basisszenario, das das Grundprofil trägt. export async function POST(request: NextRequest) { const userId = await getCurrentUserId(); if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); diff --git a/src/app/api/scenarios/[scenarioId]/copy/route.ts b/src/app/api/scenarios/[scenarioId]/copy/route.ts index 0f100c6..2a6d496 100644 --- a/src/app/api/scenarios/[scenarioId]/copy/route.ts +++ b/src/app/api/scenarios/[scenarioId]/copy/route.ts @@ -6,17 +6,17 @@ import { getCurrentUserId } from "@/lib/session"; const copySchema = z.object({ name: z.string().min(1).max(120) }); -// Erstellt ein neues Szenario als vollstaendige Kopie eines bestehenden. Das Original wird -// zum Elternteil -- damit haengt der Baum in der Seitenleiste und der Diff hat seine Basis. -// Jede kopierte Phase/jedes kopierte Element traegt einen Herkunfts-Verweis auf sein -// Gegenstueck im Original; darauf beruht die Abweichungs-Markierung im UI. +// Erstellt ein neues Szenario als vollständige Kopie eines bestehenden. Das Original wird +// zum Elternteil -- damit hängt der Baum in der Seitenleiste und der Diff hat seine Basis. +// Jede kopierte Phase/jedes kopierte Element trägt einen Herkunfts-Verweis auf sein +// Gegenstück im Original; darauf beruht die Abweichungs-Markierung im UI. 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 parsed = copySchema.safeParse(await request.json()); - if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 }); + if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 }); const source = await getOwnedScenario(scenarioId, userId); if (!source) return NextResponse.json({ error: "Ursprungs-Szenario nicht gefunden." }, { status: 404 }); @@ -43,7 +43,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ }, }); - // Phasen kopieren (alte -> neue Id merken, fuer die Werte-Zuordnung). + // Phasen kopieren (alte -> neue Id merken, für die Werte-Zuordnung). const phaseIdMap = new Map(); for (const phase of source.phases) { const p = await tx.phase.create({ @@ -59,7 +59,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ phaseIdMap.set(phase.id, p.id); } - // Elemente inkl. Phasen- und Uebergangswerten kopieren. + // Elemente inkl. Phasen- und Übergangswerten kopieren. for (const el of source.elements) { const newEl = await tx.financialElement.create({ data: { diff --git a/src/app/api/scenarios/[scenarioId]/elements/route.ts b/src/app/api/scenarios/[scenarioId]/elements/route.ts index c7a2f9f..3be8614 100644 --- a/src/app/api/scenarios/[scenarioId]/elements/route.ts +++ b/src/app/api/scenarios/[scenarioId]/elements/route.ts @@ -34,7 +34,7 @@ export async function POST( const body = await request.json(); const parsed = createSchema.safeParse(body); - if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 }); + if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 }); const { category, name } = parsed.data; let ownerRole = parsed.data.ownerRole ?? null; diff --git a/src/app/api/scenarios/[scenarioId]/phases/route.ts b/src/app/api/scenarios/[scenarioId]/phases/route.ts index 7dc85fd..d79ed33 100644 --- a/src/app/api/scenarios/[scenarioId]/phases/route.ts +++ b/src/app/api/scenarios/[scenarioId]/phases/route.ts @@ -12,8 +12,8 @@ const createPhaseSchema = z.object({ durationYears: z.number().int().min(1).max(80).optional(), }); -// Legt eine neue Lebensphase am Ende der Kette an. Die Dauer wird ans naechste -// Pensionsereignis gekappt. Fuer bestehende Elemente werden die editierbaren Felder +// Legt eine neue Lebensphase am Ende der Kette an. Die Dauer wird ans nächste +// Pensionsereignis gekappt. Für bestehende Elemente werden die editierbaren Felder // vorbelegt; die Startwerte werden in der Berechnung live aus der Vorphase fortgeschrieben. export async function POST( request: NextRequest, @@ -28,7 +28,7 @@ export async function POST( const body = await request.json().catch(() => ({})); const parsed = createPhaseSchema.safeParse(body); - if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 }); + if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 }); const planInput = toPlanInput(scenario); const yearsBefore = planInput.phases.reduce((s, p) => s + p.durationYears, 0); @@ -40,12 +40,12 @@ export async function POST( const nextSequence = planInput.phases.length + 1; - // Phasentyp der neuen Phase fuer den Default-Namen bestimmen. + // Phasentyp der neuen Phase für den Default-Namen bestimmen. const anyRetiredAtStart = planInput.persons.some((p) => p.age + yearsBefore >= p.retirementAge); const defaultName = parsed.data.name ?? (nextSequence === 1 ? "Erste Lebensphase" : anyRetiredAtStart ? "Pensionsphase" : "Erwerbsphase"); - // Status der Elemente in der bisher letzten Phase (verkauft/getilgt nicht fortfuehren). + // Status der Elemente in der bisher letzten Phase (verkauft/getilgt nicht fortführen). const prevComputed = planInput.phases.length > 0 ? computePlan(planInput) : null; const lastPhaseId = planInput.phases.at(-1)?.id; const prevPhase = prevComputed?.phases.find((p) => p.id === lastPhaseId) ?? null; @@ -77,14 +77,14 @@ export async function POST( return NextResponse.json({ phase: { id: phase.id } }, { status: 201 }); } -// Vorbelegung fuer eine neue Phase: nur die editierbaren Felder werden uebernommen. Start- -// bzw. Restwerte (PK/3a/Vermoegen/Hypothek/Schuld) werden in der Berechnung live aus der +// Vorbelegung für eine neue Phase: nur die editierbaren Felder werden übernommen. Start- +// bzw. Restwerte (PK/3a/Vermögen/Hypothek/Schuld) werden in der Berechnung live aus der // Vorphase fortgeschrieben und deshalb hier NICHT als Snapshot gespeichert. function buildCarryData(category: string, prev: PhaseData): PhaseData { switch (category) { case "INCOME": case "EXPENSE": - // Basis wird live indexiert fortgeschrieben; nur der Teuerungsausgleich wird uebernommen. + // Basis wird live indexiert fortgeschrieben; nur der Teuerungsausgleich wird übernommen. return prev.teuerungsausgleich != null ? { teuerungsausgleich: prev.teuerungsausgleich } : {}; case "AHV": return { gapYears: 0 }; diff --git a/src/app/api/scenarios/[scenarioId]/route.ts b/src/app/api/scenarios/[scenarioId]/route.ts index 3bf6d53..90e0757 100644 --- a/src/app/api/scenarios/[scenarioId]/route.ts +++ b/src/app/api/scenarios/[scenarioId]/route.ts @@ -6,7 +6,7 @@ import { getCurrentUserId } from "@/lib/session"; import { computePlan } from "@/lib/calculations"; import { scenarioProfileSchema, validatePersonsForType } from "@/app/api/plans/route"; -// Liefert das Szenario samt Berechnung -- und zusaetzlich das ELTERN-Szenario als +// Liefert das Szenario samt Berechnung -- und zusätzlich das ELTERN-Szenario als // Vergleichsbasis, damit das UI die Abweichungen markieren kann (Basisszenario: null). export async function GET(_request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) { const userId = await getCurrentUserId(); @@ -58,7 +58,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 }); const parsed = patchSchema.safeParse(await request.json()); - if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 }); + if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 }); const data = parsed.data; if ("householdType" in data) { @@ -91,7 +91,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< return NextResponse.json({ scenario: { id: updated.id, name: updated.name } }); } -// Loescht ein Szenario. Das Basisszenario kann nicht geloescht werden (dafuer den Plan loeschen). +// Löscht ein Szenario. Das Basisszenario kann nicht gelöscht werden (dafür den Plan löschen). export async function DELETE(_request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) { const userId = await getCurrentUserId(); if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); @@ -101,7 +101,7 @@ export async function DELETE(_request: NextRequest, { params }: { params: Promis if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 }); if (scenario.isBase) { return NextResponse.json( - { error: "Das Basisszenario kann nicht geloescht werden. Loeschen Sie stattdessen den Plan." }, + { error: "Das Basisszenario kann nicht gelöscht werden. Löschen Sie stattdessen den Plan." }, { status: 400 } ); } diff --git a/src/app/globals.css b/src/app/globals.css index a7530c3..e255d45 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,7 +1,7 @@ @import "tailwindcss"; /* ------------------------------------------------------------------------- - Semantische Farb-Tokens. Drei umschaltbare Schemata ueber data-theme am + Semantische Farb-Tokens. Drei umschaltbare Schemata über data-theme am : "light", "dark", "warm". Ohne explizite Wahl folgt das Standard- :root der OS-Einstellung (prefers-color-scheme). Umschaltung: lib/theme.ts. ------------------------------------------------------------------------- */ @@ -33,6 +33,12 @@ --diff-removed: #71717a; --diff-removed-soft: #f4f4f5; --person-b: #0ea5e9; + /* Attention: "hier fehlt eine Eingabe" -- bewusst getrennt vom Akzent, damit + "offen" und "klickbar" nicht dieselbe Farbe tragen. */ + --attention: #d97706; + --attention-fg: #ffffff; + --attention-soft: #fef3c7; + --attention-soft-fg: #92400e; } :root[data-theme="dark"] { @@ -61,9 +67,13 @@ --diff-removed: #a1a1aa; --diff-removed-soft: rgba(161, 161, 170, 0.12); --person-b: #38bdf8; + --attention: #f59e0b; + --attention-fg: #1c1400; + --attention-soft: rgba(245, 158, 11, 0.16); + --attention-soft-fg: #fcd34d; } -/* Warm / Sunset: cremefarbener Grund, Koralle-Akzent, Amber-Sekundaerton. */ +/* Warm / Sunset: cremefarbener Grund, Koralle-Akzent, Amber-Sekundärton. */ :root[data-theme="warm"] { --bg: #fbf7f2; --surface: #fffdfa; @@ -80,7 +90,7 @@ --accent-soft: #fcebe2; --accent-soft-fg: #b24521; --danger: #c0392b; - --danger-soft: #fbeae7; + --danger-soft: #fbeä7; --success: #2e9e7b; --person-a: #e8663c; --diff: #b45309; @@ -90,6 +100,10 @@ --diff-removed: #8a7f72; --diff-removed-soft: #f0e8dd; --person-b: #f2a93b; + --attention: #c77414; + --attention-fg: #ffffff; + --attention-soft: #fdeed3; + --attention-soft-fg: #8f5410; } /* Ohne gespeicherte Wahl der Dunkel-OS-Einstellung folgen. */ @@ -120,6 +134,10 @@ --diff-removed: #a1a1aa; --diff-removed-soft: rgba(161, 161, 170, 0.12); --person-b: #38bdf8; + --attention: #f59e0b; + --attention-fg: #1c1400; + --attention-soft: rgba(245, 158, 11, 0.16); + --attention-soft-fg: #fcd34d; } } @@ -147,6 +165,10 @@ --color-diff-added-soft: var(--diff-added-soft); --color-diff-removed: var(--diff-removed); --color-diff-removed-soft: var(--diff-removed-soft); + --color-attention: var(--attention); + --color-attention-fg: var(--attention-fg); + --color-attention-soft: var(--attention-soft); + --color-attention-soft-fg: var(--attention-soft-fg); --color-person-a: var(--person-a); --color-person-b: var(--person-b); --font-sans: var(--font-geist-sans); @@ -156,12 +178,49 @@ body { background: var(--bg); color: var(--fg); - font-family: Arial, Helvetica, sans-serif; + font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif; } /* ------------------------------------------------------------------------- - Dokument-Darstellung fuer gerenderten Markdown (Spezifikation, /spezifikation). - Bewusst ueber die semantischen Tokens, damit alle drei Farbschemata greifen. + Micro-Interactions: kurze, dezente Übergänge für Dialoge, Panels, Toasts. + 150-220 ms -- schnell genug, um nie zu bremsen (Doherty-Schwelle). + ------------------------------------------------------------------------- */ + +@keyframes ui-fade-kf { + from { opacity: 0; } + to { opacity: 1; } +} +@keyframes ui-pop-kf { + from { opacity: 0; transform: translateY(6px) scale(0.985); } + to { opacity: 1; transform: translateY(0) scale(1); } +} +@keyframes ui-slide-in-kf { + from { opacity: 0.4; transform: translateX(24px); } + to { opacity: 1; transform: translateX(0); } +} +.ui-fade { animation: ui-fade-kf 150ms ease-out; } +.ui-pop { animation: ui-pop-kf 180ms cubic-bezier(0.2, 0.9, 0.3, 1); } +.ui-slide-in { animation: ui-slide-in-kf 200ms cubic-bezier(0.2, 0.9, 0.3, 1); } + +@media (prefers-reduced-motion: reduce) { + .ui-fade, .ui-pop, .ui-slide-in { animation: none; } +} + +/* Hervorhebung des aktuellen Tour-Ziels. */ +@keyframes tour-pulse-kf { + 0%, 100% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 45%, transparent); } + 50% { box-shadow: 0 0 0 7px color-mix(in srgb, var(--accent) 20%, transparent); } +} +.tour-highlight { + position: relative; + z-index: 35; + border-radius: 0.75rem; + animation: tour-pulse-kf 1.6s ease-in-out infinite; +} + +/* ------------------------------------------------------------------------- + Dokument-Darstellung für gerenderten Markdown (Spezifikation, /spezifikation). + Bewusst über die semantischen Tokens, damit alle drei Farbschemata greifen. ------------------------------------------------------------------------- */ .md-doc { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 34d350a..6dc275f 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -14,7 +14,7 @@ const geistMono = Geist_Mono({ export const metadata: Metadata = { title: "FPT — Financial Planning Tool", - description: "Persoenliche Finanzplanung ueber Lebensabschnittsphasen (AICDS)", + description: "Persönliche Finanzplanung über Lebensabschnittsphasen (AICDS)", }; // Setzt data-theme aus localStorage noch vor dem ersten Paint (verhindert FOUC). diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 800ca24..139170e 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -21,7 +21,7 @@ function LoginForm() { setError(null); if (mode === "register" && password !== passwordConfirm) { - setError("Die Passwoerter stimmen nicht ueberein."); + setError("Die Passwörter stimmen nicht überein."); return; } @@ -59,7 +59,7 @@ function LoginForm() { Financial Planning Tool

- Persoenliche Finanzplanung ueber Lebensphasen + Persönliche Finanzplanung über Lebensphasen

@@ -116,7 +116,7 @@ function LoginForm() { setPasswordConfirm(e.target.value)} className={inputClass} diff --git a/src/app/page.tsx b/src/app/page.tsx index 4c01484..a1c211a 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -12,7 +12,7 @@ export default function Home() { .get<{ user: { username: string } }>("/api/auth/me") .then((data) => setUsername(data.user.username)) .catch(() => { - // Session abgelaufen/ungueltig -- Middleware leitet beim naechsten Request um. + // Session abgelaufen/ungültig -- Middleware leitet beim nächsten Request um. window.location.href = "/login"; }); }, []); @@ -20,7 +20,7 @@ export default function Home() { if (username === null) { return (
-

Laedt…

+

Lädt…

); } diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 6ad93ac..077df32 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { BarChart3, BookOpen, @@ -13,7 +13,9 @@ import { Menu, PiggyBank, Plus, + Search, SlidersHorizontal, + Sparkles, Tornado, Trash2, X, @@ -27,8 +29,21 @@ import { SystemParametersView } from "@/components/SystemParametersView"; import { PlanTraceDialog } from "@/components/DetailView"; import { computePlan } from "@/lib/calculations"; import { ProfileMenu } from "@/components/ProfileMenu"; +import { PlanWizard } from "@/components/PlanWizard"; +import { CommandPalette, type PaletteAction } from "@/components/CommandPalette"; import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields"; +import { + Button, + ConfirmProvider, + EmptyState, + Modal, + PlanSkeleton, + ToastProvider, + useConfirm, + useToast, +} from "@/components/ui"; import { api } from "@/lib/api-client"; +import { createDemoPlan } from "@/lib/demoplan"; import { computeScenarioDiff } from "@/lib/diff"; import type { PlanInput, PlanListItem, ScenarioMeta } from "@/lib/types"; import type { PlanComputed } from "@/lib/calculations"; @@ -40,13 +55,31 @@ interface ScenarioDetail { meta: ScenarioMeta & { planName: string }; } +// Die Provider (Toast, Bestätigung) müssen UM die Shell liegen, damit deren Hooks +// innerhalb funktionieren. export function AppShell({ username }: { username: string }) { + return ( + + + + + + ); +} + +function AppShellInner({ username }: { username: string }) { + const toast = useToast(); + const confirm = useConfirm(); + const [plans, setPlans] = useState([]); const [selectedScenarioId, setSelectedScenarioId] = useState(null); const [detail, setDetail] = useState(null); const [loading, setLoading] = useState(true); const [sidebarOpen, setSidebarOpen] = useState(false); - const [showNewPlan, setShowNewPlan] = useState(false); + const [showNewPlanChooser, setShowNewPlanChooser] = useState(false); + const [showWizard, setShowWizard] = useState(false); + const [showEmptyPlanDialog, setShowEmptyPlanDialog] = useState(false); + const [creatingDemo, setCreatingDemo] = useState(false); const [copyFrom, setCopyFrom] = useState(null); const [showSpec, setShowSpec] = useState(false); const [showCharts, setShowCharts] = useState(false); @@ -54,6 +87,7 @@ export function AppShell({ username }: { username: string }) { const [showSensitivity, setShowSensitivity] = useState(false); const [showSystemParams, setShowSystemParams] = useState(false); const [showPlanTraces, setShowPlanTraces] = useState(false); + const [showPalette, setShowPalette] = useState(false); // Sprungmarke in die SPEZIFIKATION, gesetzt aus einem Rechenweg heraus. const [specAnchor, setSpecAnchor] = useState(null); @@ -71,7 +105,7 @@ export function AppShell({ username }: { username: string }) { }, []); // silent = Hintergrund-Refresh ohne Loading-Umschaltung: die PlanView bleibt montiert, - // damit die Scrollposition (z. B. nach dem Schliessen eines Popups) erhalten bleibt. + // damit die Scrollposition (z. B. nach dem Schliessen eines Panels) erhalten bleibt. const loadDetail = useCallback(async (scenarioId: string, silent = false) => { if (!silent) setLoading(true); try { @@ -95,6 +129,18 @@ export function AppShell({ username }: { username: string }) { } }, [selectedScenarioId, loadDetail]); + // Cmd/Ctrl+K öffnet die Befehls-Palette. + useEffect(() => { + function onKey(e: KeyboardEvent) { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { + e.preventDefault(); + setShowPalette((v) => !v); + } + } + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); + function refreshCurrent() { if (selectedScenarioId) loadDetail(selectedScenarioId, true); } @@ -107,8 +153,16 @@ export function AppShell({ username }: { username: string }) { } async function handleDeletePlan(id: string) { - if (!confirm("Diesen Plan mit ALLEN Szenarien wirklich loeschen?")) return; + const plan = plans.find((p) => p.id === id); + const ok = await confirm({ + title: "Plan löschen?", + message: `«${plan?.name ?? "Plan"}» wird mit allen Szenarien unwiderruflich gelöscht.`, + confirmLabel: "Endgültig löschen", + danger: true, + }); + if (!ok) return; await api.delete(`/api/plans/${id}`); + toast("success", "Plan gelöscht."); const rest = await loadPlans(); if (!rest.some((p) => p.scenarios.some((s) => s.id === selectedScenarioId))) { setSelectedScenarioId(null); @@ -116,19 +170,61 @@ export function AppShell({ username }: { username: string }) { } async function handleDeleteScenario(s: ScenarioMeta) { - if (!confirm(`Szenario "${s.name}" wirklich loeschen?`)) return; + const ok = await confirm({ + title: "Szenario löschen?", + message: `«${s.name}» wird unwiderruflich gelöscht. Untergeordnete Szenarien bleiben bestehen.`, + confirmLabel: "Endgültig löschen", + danger: true, + }); + if (!ok) return; try { await api.delete(`/api/scenarios/${s.id}`); + toast("success", "Szenario gelöscht."); } catch (e) { - alert(e instanceof Error ? e.message : "Loeschen fehlgeschlagen."); + toast("error", e instanceof Error ? e.message : "Löschen fehlgeschlagen."); return; } await loadPlans(); if (selectedScenarioId === s.id) setSelectedScenarioId(null); } + async function handleCreateDemo() { + setCreatingDemo(true); + try { + const sid = await createDemoPlan(); + await loadPlans(); + setShowNewPlanChooser(false); + openScenario(sid); + toast("success", "Beispielplan angelegt – schau dich um!"); + } catch (e) { + toast("error", e instanceof Error ? e.message : "Beispielplan konnte nicht angelegt werden."); + } finally { + setCreatingDemo(false); + } + } + const activePlan = plans.find((p) => p.scenarios.some((s) => s.id === selectedScenarioId)) ?? null; + // Aktionen der Befehls-Palette -- kontextabhängig (Analysen nur bei offenem Szenario). + const paletteActions = useMemo(() => { + const base: PaletteAction[] = [ + { id: "a-new", label: "Neuen Plan erstellen", hint: "Aktion", run: () => setShowNewPlanChooser(true) }, + { id: "a-home", label: "Übersicht öffnen", hint: "Aktion", run: () => { setSelectedScenarioId(null); setShowSpec(false); setShowSystemParams(false); } }, + { id: "a-spec", label: "So rechnet FPT (Spezifikation)", hint: "Aktion", run: () => { setShowSpec(true); setSpecAnchor(null); setShowSystemParams(false); setSelectedScenarioId(null); } }, + { id: "a-params", label: "Systemparameter", hint: "Aktion", run: () => { setShowSystemParams(true); setShowSpec(false); setSelectedScenarioId(null); } }, + ]; + if (detail && selectedScenarioId) { + base.unshift( + { id: "a-charts", label: "Grafiken öffnen", hint: "Aktion", run: () => setShowCharts(true) }, + { id: "a-mc", label: "Monte-Carlo-Simulation", hint: "Aktion", run: () => setShowMonteCarlo(true) }, + { id: "a-sens", label: "Einflussfaktoren berechnen", hint: "Aktion", run: () => setShowSensitivity(true) }, + { id: "a-traces", label: "Rechenwege anzeigen", hint: "Aktion", run: () => setShowPlanTraces(true) }, + { id: "a-copy", label: "Neues Szenario aus diesem", hint: "Aktion", run: () => setCopyFrom(detail.meta) } + ); + } + return base; + }, [detail, selectedScenarioId]); + const sidebar = (
@@ -136,6 +232,15 @@ export function AppShell({ username }: { username: string }) {
FPT +
@@ -239,12 +345,12 @@ export function AppShell({ username }: { username: string }) { {sidebarOpen && (
-
setSidebarOpen(false)} /> -
)}
- {showNewPlan && ( + {showNewPlanChooser && ( + { + setShowNewPlanChooser(false); + setShowWizard(true); + }} + onEmpty={() => { + setShowNewPlanChooser(false); + setShowEmptyPlanDialog(true); + }} + onDemo={handleCreateDemo} + onClose={() => setShowNewPlanChooser(false)} + /> + )} + + {showWizard && ( + { + setShowWizard(false); + await loadPlans(); + openScenario(sid); + toast("success", "Plan erstellt – die Tour zeigt dir gleich die wichtigsten Stellen."); + }} + onClose={() => setShowWizard(false)} + /> + )} + + {showEmptyPlanDialog && ( { const { scenario } = await api.post<{ plan: { id: string }; scenario: { id: string } }>( "/api/plans", { name, ...profile } ); - setShowNewPlan(false); + setShowEmptyPlanDialog(false); await loadPlans(); openScenario(scenario.id); + toast("success", "Plan erstellt."); }} - onClose={() => setShowNewPlan(false)} + onClose={() => setShowEmptyPlanDialog(false)} /> )} @@ -432,14 +550,90 @@ export function AppShell({ username }: { username: string }) { setCopyFrom(null); await loadPlans(); openScenario(scenarioId); + toast("success", "Szenario erstellt."); }} /> )} + + setShowPalette(false)} + plans={plans} + actions={paletteActions} + onOpenScenario={openScenario} + /> ); } -// Breiter Dialog fuer den Analyse-Bereich (Grafiken). +// Auswahl beim Anlegen eines Plans: geführt (empfohlen), leer oder Beispielplan. +function NewPlanChooser({ + creatingDemo, + onGuided, + onEmpty, + onDemo, + onClose, +}: { + creatingDemo: boolean; + onGuided: () => void; + onEmpty: () => void; + onDemo: () => void; + onClose: () => void; +}) { + const options = [ + { + icon: , + title: "Geführt erstellen", + badge: "Empfohlen", + text: "Der Assistent fragt Schritt für Schritt – in fünf Minuten steht dein Grundgerüst.", + run: onGuided, + }, + { + icon: , + title: "Leer starten", + badge: null, + text: "Nur das Grundprofil erfassen, alles Weitere selbst aufbauen – für geübte Nutzer.", + run: onEmpty, + }, + { + icon: , + title: creatingDemo ? "Beispielplan wird angelegt…" : "Beispielplan ansehen", + badge: null, + text: "Ein fertig ausgefüllter, fiktiver Plan zum Erkunden – so siehst du das Tool zuerst in Aktion.", + run: onDemo, + }, + ]; + return ( + +
+ {options.map((o) => ( + + ))} +
+
+ ); +} + +// Breiter Dialog für den Analyse-Bereich (Grafiken). function ChartsDialog({ title, children, @@ -450,10 +644,10 @@ function ChartsDialog({ onClose: () => void; }) { return ( -
+
e.stopPropagation()} - className="flex w-full max-w-5xl flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl" + className="ui-pop flex w-full max-w-5xl flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl" >

@@ -469,7 +663,9 @@ function ChartsDialog({ ); } -// Rekursiver Szenario-Baum: Kinder werden eingerueckt, damit Sub-Szenarien sichtbar sind. +// Rekursiver Szenario-Baum: Kinder werden eingerückt, damit Sub-Szenarien sichtbar sind. +// Die Aktions-Icons sind immer leicht sichtbar (nicht nur bei Hover) -- auf Touch-Geräten +// gibt es kein Hover. function ScenarioTree({ scenarios, parentId, @@ -494,7 +690,7 @@ function ScenarioTree({ {level.map((s) => (
onCopy(s)} - className="rounded p-0.5 text-faint opacity-0 hover:bg-accent-soft hover:text-accent group-hover:opacity-100" + className="rounded p-0.5 text-faint opacity-60 transition-opacity hover:bg-accent-soft hover:text-accent group-hover:opacity-100" > {!s.isBase && ( @@ -542,18 +738,24 @@ function ScenarioTree({ ); } -// Startansicht: Begruessung + Plan-Kacheln (Klick oeffnet das Basisszenario). +// Startansicht: Begrüssung + Plan-Kacheln (Klick öffnet das Basisszenario). function DashboardHome({ username, plans, + creatingDemo, onSelect, - onCreate, + onCreateGuided, + onCreateChooser, + onCreateDemo, onDelete, }: { username: string; plans: PlanListItem[]; + creatingDemo: boolean; onSelect: (scenarioId: string) => void; - onCreate: () => void; + onCreateGuided: () => void; + onCreateChooser: () => void; + onCreateDemo: () => void; onDelete: (id: string) => void; }) { return ( @@ -561,56 +763,71 @@ function DashboardHome({

Willkommen, {username}

- Waehlen Sie einen Plan oder erstellen Sie einen neuen. Jeder Plan enthaelt ein Basisszenario - und beliebig viele Varianten davon. + Wähle einen Plan oder erstelle einen neuen. Jeder Plan enthält ein Basisszenario und beliebig + viele Varianten davon.

-
- {plans.map((p) => { - const base = p.scenarios.find((s) => s.isBase) ?? p.scenarios[0]; - const others = p.scenarios.length - 1; - return ( -
base && onSelect(base.id)} - > -
-
- -
-
-
{p.name}
-
- Basisszenario{others > 0 ? ` + ${others} Variante${others === 1 ? "" : "n"}` : ""} -
-
- -
-
- ); - })} - - -
+ + + + ) : ( +
+ {plans.map((p) => { + const base = p.scenarios.find((s) => s.isBase) ?? p.scenarios[0]; + const others = p.scenarios.length - 1; + return ( +
base && onSelect(base.id)} + > +
+
+ +
+
+
{p.name}
+
+ Basisszenario{others > 0 ? ` + ${others} Variante${others === 1 ? "" : "n"}` : ""} +
+
+ +
+
+ ); + })} + + +
+ )}
); } @@ -627,49 +844,37 @@ function PlanDialog({ const [saving, setSaving] = useState(false); return ( -
-
e.stopPropagation()} - className="flex w-full max-w-md flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl" - > -

Neuen Plan erstellen

-

- Es wird automatisch ein Basisszenario angelegt. Weitere - Szenarien entstehen spaeter als Kopien davon. -

-
- - setName(e.target.value)} - placeholder="Name des Plans" - /> -
- -
- - -
+ +

+ Es wird automatisch ein Basisszenario angelegt. Weitere + Szenarien entstehen später als Kopien davon. +

+
+ + setName(e.target.value)} + placeholder="Name des Plans" + />
-
+ +
+ + +
+ ); } @@ -685,44 +890,32 @@ function CopyScenarioDialog({ const [name, setName] = useState(`${source.name} – Variante`); const [saving, setSaving] = useState(false); return ( -
-
e.stopPropagation()} - className="flex w-full max-w-sm flex-col gap-3 rounded-2xl border border-border bg-surface p-6 shadow-xl" - > -

Neues Szenario

-

- Vollstaendige Kopie von {source.name}. Aenderungen darin - werden anschliessend farblich hervorgehoben. -

- setName(e.target.value)} - placeholder="Name des Szenarios" - /> -
- - -
+ +

+ Vollständige Kopie von {source.name}. Änderungen darin + werden anschliessend farblich hervorgehoben. +

+ setName(e.target.value)} + placeholder="Name des Szenarios" + /> +
+ +
-
+ ); } diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx new file mode 100644 index 0000000..cea183e --- /dev/null +++ b/src/components/CommandPalette.tsx @@ -0,0 +1,126 @@ +"use client"; + +// Befehls-Palette (Cmd/Ctrl+K): schnelles Springen zwischen Plänen und Szenarien plus +// die wichtigsten Aktionen -- der Effizienz-Pfad für geübte Nutzer, ohne der Maus- +// Bedienung etwas wegzunehmen. + +import { useEffect, useMemo, useRef, useState } from "react"; +import { CornerDownLeft, Search } from "lucide-react"; +import type { PlanListItem } from "@/lib/types"; + +export interface PaletteAction { + id: string; + label: string; + hint?: string; + run: () => void; +} + +export function CommandPalette({ + open, + onClose, + plans, + actions, + onOpenScenario, +}: { + open: boolean; + onClose: () => void; + plans: PlanListItem[]; + actions: PaletteAction[]; + onOpenScenario: (id: string) => void; +}) { + const [query, setQuery] = useState(""); + const [index, setIndex] = useState(0); + const inputRef = useRef(null); + + const items = useMemo(() => { + const scenarioItems: PaletteAction[] = plans.flatMap((p) => + p.scenarios.map((s) => ({ + id: `s-${s.id}`, + label: `${p.name} · ${s.name}`, + hint: "Szenario öffnen", + run: () => onOpenScenario(s.id), + })) + ); + const all = [...actions, ...scenarioItems]; + const q = query.trim().toLowerCase(); + if (!q) return all; + return all.filter((i) => i.label.toLowerCase().includes(q)); + }, [plans, actions, query, onOpenScenario]); + + useEffect(() => { + if (open) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- Reset beim Öffnen + setQuery(""); + setIndex(0); + requestAnimationFrame(() => inputRef.current?.focus()); + } + }, [open]); + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- Auswahl folgt der Eingabe + setIndex(0); + }, [query]); + + if (!open) return null; + + function choose(item: PaletteAction | undefined) { + if (!item) return; + onClose(); + item.run(); + } + + return ( +
+
e.stopPropagation()} + className="ui-pop w-full max-w-lg overflow-hidden rounded-2xl border border-border bg-surface shadow-2xl" + > +
+ + setQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "ArrowDown") { + e.preventDefault(); + setIndex((i) => Math.min(i + 1, items.length - 1)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setIndex((i) => Math.max(i - 1, 0)); + } else if (e.key === "Enter") { + e.preventDefault(); + choose(items[index]); + } else if (e.key === "Escape") { + onClose(); + } + }} + placeholder="Plan, Szenario oder Aktion suchen…" + className="w-full bg-transparent text-sm text-fg outline-none placeholder:text-faint" + /> + ESC +
+
+ {items.length === 0 &&

Nichts gefunden.

} + {items.slice(0, 40).map((item, i) => ( + + ))} +
+
+
+ ); +} diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index 0afc109..9537acc 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -10,7 +10,7 @@ import { formatChf } from "@/lib/format"; import type { PlanComputed } from "@/lib/calculations"; import type { PlanInput } from "@/lib/types"; -const PALETTE = ["#4f46e5", "#0ea5e9", "#16a34a", "#d97706", "#dc2626", "#7c3aed"]; +const PALETTE = ["#4f46e5", "#0ea5e9", "#16a34a", "#d97706", "#dc2626", "#7c3äd"]; interface PlanListItem { id: string; @@ -24,7 +24,7 @@ export function Dashboard({ }: { plan: PlanInput; computed: PlanComputed; - // Die uebrigen Szenarien desselben Plans -- nur die sind sinnvoll vergleichbar. + // Die übrigen Szenarien desselben Plans -- nur die sind sinnvoll vergleichbar. siblings: PlanListItem[]; }) { const [compareIds, setCompareIds] = useState([]); @@ -94,8 +94,8 @@ export function Dashboard({ return (
- - + +
@@ -104,7 +104,7 @@ export function Dashboard({ Einkommen vs. Ausgaben pro Jahr

- Die Flaeche zwischen Einkommen und nominalen Ausgaben ist die Spar- (gruen) bzw. Verzehrquote (rot). + Die Fläche zwischen Einkommen und nominalen Ausgaben ist die Spar- (grün) bzw. Verzehrquote (rot). Die blasse Linie sind die realen Ausgaben – der Abstand zur nominalen Linie ist der Inflationsanteil.

@@ -114,7 +114,7 @@ export function Dashboard({

- Vermoegensverlauf nach Alter + Vermögensverlauf nach Alter

- Vermoegensaufteilung pro Phase (Beginn & Ende) + Vermögensaufteilung pro Phase (Beginn & Ende)

Je Phase links die Aufteilung zu Beginn, rechts am Ende. Das Ende einer Phase entspricht im - Gesamtvolumen dem Beginn der naechsten – die Aufteilung kann durch Umschichtung abweichen. + Gesamtvolumen dem Beginn der nächsten – die Aufteilung kann durch Umschichtung abweichen.

diff --git a/src/components/DetailView.tsx b/src/components/DetailView.tsx index 22054c7..4d5db7c 100644 --- a/src/components/DetailView.tsx +++ b/src/components/DetailView.tsx @@ -30,18 +30,18 @@ import type { // --- Wasserfall --------------------------------------------------------------------------- // Bewusst NICHT mit Recharts, sondern als eigene HTML/CSS-Darstellung. Ein Wasserfall lebt // von drei Dingen, die Recharts hier nicht hergibt: Verbindungslinien zwischen den Balken -// (ohne sie sieht man nicht, dass jeder Balken dort ansetzt, wo der vorherige aufhoert), -// Wertbeschriftung an jedem Balken, und eine klare optische Trennung von Zwischenstaenden -// und Veraenderungen. +// (ohne sie sieht man nicht, dass jeder Balken dort ansetzt, wo der vorherige aufhört), +// Wertbeschriftung an jedem Balken, und eine klare optische Trennung von Zwischenständen +// und Veränderungen. // // Liegend statt stehend: Die Beschriftungen sind lang ("Wertsteigerung Immobilie"), stehend -// muessten sie gedreht werden. Liegend ist es ausserdem konsistent zum Tornado. +// müssten sie gedreht werden. Liegend ist es ausserdem konsistent zum Tornado. interface WaterfallItem { label: string; value: number; - total?: boolean; // Zwischen-/Endsumme: absoluter Stand statt Veraenderung - section?: string; // optionale Abschnitts-Ueberschrift VOR diesem Eintrag + total?: boolean; // Zwischen-/Endsumme: absoluter Stand statt Veränderung + section?: string; // optionale Abschnitts-Überschrift VOR diesem Eintrag } interface WaterfallRow { @@ -130,7 +130,7 @@ function Waterfall({ items }: { items: WaterfallItem[] }) { }} title={`${r.label}: ${formatChf(r.value)}`} /> - {/* Verbindungslinie zum naechsten Balken: auf dem Stand NACH diesem Schritt */} + {/* Verbindungslinie zum nächsten Balken: auf dem Stand NACH diesem Schritt */} {!isLast && (
- {/* Zahlen mit laufendem Zwischenstand -- bei stark unterschiedlichen Groessenordnungen - ist die Tabelle der Grafik ueberlegen. */} + {/* Zahlen mit laufendem Zwischenstand -- bei stark unterschiedlichen Grössenordnungen + ist die Tabelle der Grafik überlegen. */}
Zahlen mit Zwischenstand anzeigen
@@ -190,9 +190,9 @@ function Waterfall({ items }: { items: WaterfallItem[] }) { ); } -// Kontrollgroesse: Ist die Zerlegung vollstaendig, muss die Differenz zwischen Endwert und +// Kontrollgrösse: Ist die Zerlegung vollständig, muss die Differenz zwischen Endwert und // der Summe der Schritte 0 sein. Sichtbar machen statt verstecken -- ein Wasserfall, der -// nicht aufgeht, ist ein Fehler und kein Schoenheitsproblem. +// nicht aufgeht, ist ein Fehler und kein Schönheitsproblem. function ResidualNote({ residual }: { residual: number }) { if (Math.abs(residual) <= 2) return null; return ( @@ -367,7 +367,7 @@ export function ElementDetailDialog({ onClose: () => void; onOpenSpec?: (anchor: string) => void; }) { - // Verlauf ueber ALLE Phasen zusammensetzen. + // Verlauf über ALLE Phasen zusammensetzen. const points = useMemo(() => { const out: ElementYearPoint[] = []; for (const ph of computed.phases) { @@ -479,7 +479,7 @@ export function ElementDetailDialog({ // --- Phasen-Detailansicht ------------------------------------------------------------------ const ASSET_CATS: ElementCategory[] = ["PENSION_FUND", "PILLAR_3A", "REAL_ESTATE", "OTHER_ASSET"]; -const ALLOC_PALETTE = ["#4f46e5", "#0ea5e9", "#16a34a", "#d97706", "#dc2626", "#7c3aed"]; +const ALLOC_PALETTE = ["#4f46e5", "#0ea5e9", "#16a34a", "#d97706", "#dc2626", "#7c3äd"]; export function PhaseDetailDialog({ phase, @@ -504,7 +504,7 @@ export function PhaseDetailDialog({ [phase] ); - const uebersicht = ( + const übersicht = ( <>
@@ -578,7 +578,7 @@ export function PhaseDetailDialog({ subtitle={`Lebensphase ${phase.sequenceNumber} · ${phase.durationYears} Jahre`} onClose={onClose} tabs={[ - { key: "uebersicht", label: "Übersicht", content: uebersicht }, + { key: "übersicht", label: "Übersicht", content: übersicht }, { key: "wasserfall", label: "Wasserfall", content: wasserfall }, { key: "rechenweg", label: "Rechenweg", content: rechenweg }, ]} diff --git a/src/components/ElementDetail.tsx b/src/components/ElementDetail.tsx index e59b266..9d0018a 100644 --- a/src/components/ElementDetail.tsx +++ b/src/components/ElementDetail.tsx @@ -29,18 +29,18 @@ export interface CellContext { isConsumption: boolean; durationYears: number; isRetirementTransition: boolean; - carriedEndValue: number; // Endwert des Elements in der (Vor-)Phase, fuer Bezugs-Maxima + carriedEndValue: number; // Endwert des Elements in der (Vor-)Phase, für Bezugs-Maxima carried: boolean; // Phase >= 2: Basiswert wird aus der Vorphase fortgeschrieben derivedStart: number; // fortgeschriebener Basiswert (read-only Anzeige) derivedMortgage: number; // nur Immobilie: fortgeschriebene Resthypothek zu Phasenbeginn - mortgageEnd: number; // nur Immobilie: Resthypothek am Phasenende (fuer die Sonderamortisation) + mortgageEnd: number; // nur Immobilie: Resthypothek am Phasenende (für die Sonderamortisation) // nur Immobilie: modellierter VERKEHRSWERT am Phasenende (Eigenkapital + Resthypothek), - // inkl. aufgelaufener Wertsteigerung. Vorbelegung und Vergleichswert fuer den Verkaufspreis. + // inkl. aufgelaufener Wertsteigerung. Vorbelegung und Vergleichswert für den Verkaufspreis. propertyValueEnd: number; deflatorStart: number; // Kaufkraft-Deflator zu Phasenbeginn (real <-> nominal, erstes Jahr) - // Warnhinweis: Anzahl Phasen NACH dieser (Aenderungen schreiben sich dorthin fort). + // Warnhinweis: Anzahl Phasen NACH dieser (Änderungen schreiben sich dorthin fort). laterPhaseCount: number; - // AHV-Beitragskarriere des Element-Besitzers (fuer die Pruefung am Pensions-Uebergang). + // AHV-Beitragskarriere des Element-Besitzers (für die Prüfung am Pensions-Übergang). ahvCareer: AhvCareer | null; } @@ -54,8 +54,8 @@ interface Props { } // Warnhinweis beim Bearbeiten einer Phase, der noch Phasen folgen. Seit V3 werden Werte live -// fortgeschrieben (Endwert = Startwert der Folgephase) -- eine Aenderung hier wirkt also bis -// ans Planende durch, was ohne Hinweis leicht uebersehen wird. +// fortgeschrieben (Endwert = Startwert der Folgephase) -- eine Änderung hier wirkt also bis +// ans Planende durch, was ohne Hinweis leicht übersehen wird. export function CarryWarning({ laterPhaseCount }: { laterPhaseCount: number }) { if (laterPhaseCount <= 0) return null; const phasen = laterPhaseCount === 1 ? "die nachfolgende Lebensphase" : `die ${laterPhaseCount} nachfolgenden Lebensphasen`; @@ -64,8 +64,8 @@ export function CarryWarning({ laterPhaseCount }: { laterPhaseCount: number }) { Achtung: Diese Lebensphase ist nicht die letzte. Werte werden fortgeschrieben - (Endwert = Startwert der Folgephase). Eine Aenderung hier wirkt sich auf {phasen} aus und - veraendert deren Startwerte, Kennzahlen sowie Endvermoegen, Cash-Verlauf und Ruinalter. + (Endwert = Startwert der Folgephase). Eine Änderung hier wirkt sich auf {phasen} aus und + verändert deren Startwerte, Kennzahlen sowie Endvermögen, Cash-Verlauf und Ruinalter.
); @@ -83,7 +83,7 @@ function DerivedField({ label, value, help }: { label: string; value: number; he ); } -// PK/3a-Bezugs-Entscheid im normalen Uebergang (Vorbezug): Kein Bezug / Bezug (+ Betrag + +// PK/3a-Bezugs-Entscheid im normalen Übergang (Vorbezug): Kein Bezug / Bezug (+ Betrag + // Kapitalbezugssteuer). Der Bezugsbetrag wird brutto dem Kapital entnommen; ins Cash fliesst // der Betrag nach Abzug der Steuer. function WithdrawalDecision({ @@ -130,7 +130,7 @@ function WithdrawalDecision({ )} @@ -139,9 +139,9 @@ function WithdrawalDecision({ } // Vorbelegung expliziter Entscheide, damit ein blosses "Speichern" den sichtbaren Default -// (Halten / Kein Bezug / Rente) auch tatsaechlich persistiert. -// AHV-Pruefung am Pensions-Uebergang: geplantes Durchschnittseinkommen aus dem Plan plus -- -// nur falls der Plan nicht bis zum Beitragsbeginn (Alter 21) zurueckreicht -- die Jahre davor. +// (Halten / Kein Bezug / Rente) auch tatsächlich persistiert. +// AHV-Prüfung am Pensions-Übergang: geplantes Durchschnittseinkommen aus dem Plan plus -- +// nur falls der Plan nicht bis zum Beitragsbeginn (Alter 21) zurückreicht -- die Jahre davor. export function AhvReviewFields({ career, td, @@ -162,39 +162,39 @@ export function AhvReviewFields({ return ( <>

- Die AHV-Rente haengt vom massgebenden durchschnittlichen Jahreseinkommen{" "} - ueber die ganze Beitragsdauer (ab Alter 21) ab. Massgebend ist der Bruttolohn; - das Tool rechnet die im Plan erfassten Nettoeinkommen dafuer automatisch hoch. Alle Betraege sind + Die AHV-Rente hängt vom massgebenden durchschnittlichen Jahreseinkommen{" "} + über die ganze Beitragsdauer (ab Alter 21) ab. Massgebend ist der Bruttolohn; + das Tool rechnet die im Plan erfassten Nettöinkommen dafür automatisch hoch. Alle Beträge sind REAL (heutige Kaufkraft) – die AHV wertet vergangene Einkommen auf und indexiert die Schwellen, was sich real weitgehend aufhebt.

{brauchtVorgeschichte && ( <>

Der Plan beginnt erst im Alter {career.yearsBeforePlan + 21}. Die {career.yearsBeforePlan} Beitragsjahre - davor kennt das Tool nicht – bitte ergaenzen. + davor kennt das Tool nicht – bitte ergänzen.

setT({ avgIncomeBefore: v })} /> ); @@ -220,7 +220,7 @@ export function AhvReviewFields({ export function withTransitionDefaults(category: ElementCategory, isRetirement: boolean, td: TransitionData): TransitionData { const out = { ...td }; if (category === "AHV") { - // Ein Speichern der Pruefung markiert sie als erledigt (gleiches Muster wie bei PK/3a). + // Ein Speichern der Prüfung markiert sie als erledigt (gleiches Muster wie bei PK/3a). if (isRetirement && out.reviewed === undefined) out.reviewed = true; } else if (category === "REAL_ESTATE" || category === "OTHER_ASSET") { if (out.decision === undefined) out.decision = "HOLD"; @@ -236,7 +236,7 @@ export function withTransitionDefaults(category: ElementCategory, isRetirement: return out; } -// --- Cash-Uebergang: einmalige Sonderein-/ausgaben --- +// --- Cash-Übergang: einmalige Sonderein-/ausgaben --- export function withCashTransitionDefaults(ct: CashTransitionData): CashTransitionData { return ct.mode === undefined ? { ...ct, mode: "NONE" } : { ...ct }; @@ -246,7 +246,7 @@ export function isCashTransitionAnswered(ct: CashTransitionData): boolean { return ct.mode !== undefined; } -// Kurzfassung fuer die Uebergangszelle der Cash-Zeile. +// Kurzfassung für die Übergangszelle der Cash-Zeile. export function cashTransitionSummary(ct: CashTransitionData): string { const mode = ct.mode; if (mode === undefined) return "?"; @@ -264,7 +264,7 @@ export function cashTransitionSummary(ct: CashTransitionData): string { } } -// Eingabefelder fuer den Cash-Entscheid. Erfassungs-Konventionen bewusst wie bei den +// Eingabefelder für den Cash-Entscheid. Erfassungs-Konventionen bewusst wie bei den // laufenden Flows: Zufluss nominal (wie Einkommen), Kosten real (wie Ausgaben). export function CashTransitionFields({ ct, @@ -287,8 +287,8 @@ export function CashTransitionFields({ <>
setC( @@ -302,7 +302,7 @@ export function CashTransitionFields({ ) } options={[ - { value: "NONE", label: "1:1 uebernehmen" }, + { value: "NONE", label: "1:1 übernehmen" }, { value: "INFLOW", label: "Einmaliger Zufluss" }, { value: "OUTFLOW", label: "Einmalige Kosten" }, { value: "BOTH", label: "Zufluss und Kosten" }, @@ -314,7 +314,7 @@ export function CashTransitionFields({ <>
Einmaliger Zufluss (z. B. Erbschaft). Wird NOMINAL erfasst – - der Betrag, der zu diesem Zeitpunkt tatsaechlich aufs Konto kommt. + der Betrag, der zu diesem Zeitpunkt tatsächlich aufs Konto kommt.
{taxRate > 0 && ( )} @@ -369,7 +369,7 @@ export function CashTransitionFields({ )} @@ -381,7 +381,7 @@ export function CashTransitionFields({ export function isTransitionAnswered(category: ElementCategory, isRetirement: boolean, td: TransitionData): boolean { switch (category) { case "AHV": - // Nur bei der Pensionierung ist eine Pruefung der Beitragskarriere noetig. + // Nur bei der Pensionierung ist eine Prüfung der Beitragskarriere nötig. return isRetirement ? td.reviewed === true : true; case "REAL_ESTATE": case "OTHER_ASSET": @@ -395,7 +395,7 @@ export function isTransitionAnswered(category: ElementCategory, isRetirement: bo } } -// --- Wiederverwendbare Feldgruppen (Detail-Panel, Erstell-Popup, Uebergangs-Review) --- +// --- Wiederverwendbare Feldgruppen (Detail-Panel, Erstell-Popup, Übergangs-Review) --- export function ElementPhaseFields({ element, @@ -414,7 +414,7 @@ export function ElementPhaseFields({ case "EXPENSE": { const isIncome = element.category === "INCOME"; // Basiswert (erstes Jahr). Ab Phase 2 mit dem fortgeschriebenen Wert der Vorphase - // vorbelegt, aber bewusst aenderbar (Teilzeit, Befoerderung, Jobwechsel …). + // vorbelegt, aber bewusst änderbar (Teilzeit, Beförderung, Jobwechsel …). const baseValue = typeof pd.amount === "number" ? pd.amount : carried ? context.derivedStart : 0; const rate = num(pd.teuerungsausgleich, 0); const d = context.deflatorStart || 1; @@ -424,12 +424,12 @@ export function ElementPhaseFields({ <>

{isIncome - ? "Bitte das NETTO-Einkommen erfassen: der Betrag, der nach allen Lohnabzuegen (AHV/ALV, Pensionskasse, NBU) tatsaechlich aufs Konto kommt – nicht der Bruttolohn. Erfasst wird NOMINAL; der reale Wert (heutige Kaufkraft) erscheint nur zur Info. Fuer die AHV-Rente rechnet das Tool intern auf den Bruttolohn zurueck." + ? "Bitte das NETTO-Einkommen erfassen: der Betrag, der nach allen Lohnabzügen (AHV/ALV, Pensionskasse, NBU) tatsächlich aufs Konto kommt – nicht der Bruttolohn. Erfasst wird NOMINAL; der reale Wert (heutige Kaufkraft) erscheint nur zur Info. Für die AHV-Rente rechnet das Tool intern auf den Bruttolohn zurück." : "Ausgaben werden REAL erfasst (in heutiger Kaufkraft). Die Inflation (plan-weit) rechnet daraus automatisch die nominalen Ausgaben – nur zur Info."}

setP({ amount: v })} /> @@ -439,11 +439,11 @@ export function ElementPhaseFields({ help="Nur zur Info, wird automatisch berechnet." /> es gibt keinen Pensions-Uebergang, - // an dem die Karriere geprueft werden koennte. Dann hier erfassen (nur erste Phase). + // Sonderfall: bei Planbeginn bereits pensioniert -> es gibt keinen Pensions-Übergang, + // an dem die Karriere geprüft werden könnte. Dann hier erfassen (nur erste Phase). if (!carried && context.ahvCareer && context.ahvCareer.planYears === 0) { return ( <>

- Diese Person ist bei Planbeginn bereits pensioniert. Die AHV-Rente haengt vom - massgebenden durchschnittlichen Jahreseinkommen ueber die ganze Beitragsdauer ab – + Diese Person ist bei Planbeginn bereits pensioniert. Die AHV-Rente hängt vom + massgebenden durchschnittlichen Jahreseinkommen über die ganze Beitragsdauer ab – bitte hier erfassen (REAL, heutige Kaufkraft).

setP({ avgIncomeBefore: v })} /> setP({ gapYearsBefore: Math.max(0, Math.round(v)) })} @@ -480,14 +480,14 @@ export function ElementPhaseFields({ ); } return (

- Die AHV-Rente wird aus der beim Pensions-Uebergang geprueften Beitragskarriere berechnet + Die AHV-Rente wird aus der beim Pensions-Übergang geprüften Beitragskarriere berechnet (massgebendes Durchschnittseinkommen und Ausfalljahre). Bei Ehepaaren greift die Plafonierung auf 150% der Maximalrente.

@@ -496,7 +496,7 @@ export function ElementPhaseFields({ return ( - Die PK-Rente wird aus dem beim Pensions-Uebergang gewaehlten Umwandlungssatz berechnet (siehe - Kennzahl). Bei reinem Kapitalbezug erscheint hier "Vollstaendig bezogen". + Die PK-Rente wird aus dem beim Pensions-Übergang gewählten Umwandlungssatz berechnet (siehe + Kennzahl). Bei reinem Kapitalbezug erscheint hier "Vollständig bezogen".

); } @@ -519,7 +519,7 @@ export function ElementPhaseFields({ setP({ additionalInvestment: v })} @@ -529,8 +529,8 @@ export function ElementPhaseFields({ setP({ currentValue: v })} /> )} setP({ annualContribution: v })} /> @@ -539,7 +539,7 @@ export function ElementPhaseFields({ ); case "PILLAR_3A": if (!context.ownerWorking) { - return

Die Saeule 3a wird beim Pensions-Uebergang vollstaendig bezogen.

; + return

Die Säule 3a wird beim Pensions-Übergang vollständig bezogen.

; } return ( <> @@ -548,7 +548,7 @@ export function ElementPhaseFields({ setP({ additionalInvestment: v })} @@ -558,8 +558,8 @@ export function ElementPhaseFields({ setP({ currentValue: v })} /> )} setP({ annualContribution: v })} @@ -594,7 +594,7 @@ export function ElementPhaseFields({ )} setP({ amortization: v })} /> @@ -615,7 +615,7 @@ export function ElementPhaseFields({
setP({ interestHandling: v })} options={[ - { value: "INCLUDED", label: "Ja – bereits im Ausgaben-Element beruecksichtigt" }, - { value: "ADD", label: "Nein – bitte zu den Ausgaben dazuzaehlen" }, + { value: "INCLUDED", label: "Ja – bereits im Ausgaben-Element berücksichtigt" }, + { value: "ADD", label: "Nein – bitte zu den Ausgaben dazuzählen" }, ]} />
@@ -644,7 +644,7 @@ export function ElementPhaseFields({ setP({ additionalInvestment: v })} @@ -655,14 +655,14 @@ export function ElementPhaseFields({ )} setP({ expectedReturn: v })} /> setP({ annualContribution: v })} /> setP({ annualWithdrawal: v })} /> @@ -677,8 +677,8 @@ export function ElementPhaseFields({ setP({ startValue: v })} /> )} setP({ annualRepayment: v })} @@ -704,8 +704,8 @@ export function ElementTransitionFields({ case "EXPENSE": return (

- Fuer diese Kategorie gibt es im Uebergang keine Eingaben. Die Werte werden 1:1 in die naechste - Lebensphase uebernommen und koennen dort angepasst werden. + Für diese Kategorie gibt es im Übergang keine Eingaben. Die Werte werden 1:1 in die nächste + Lebensphase übernommen und können dort angepasst werden.

); case "AHV": @@ -714,7 +714,7 @@ export function ElementTransitionFields({ } return (

- Die Beitragskarriere wird erst beim Uebergang in die Pensionierung geprueft. + Die Beitragskarriere wird erst beim Übergang in die Pensionierung geprüft.

); case "PENSION_FUND": @@ -735,7 +735,7 @@ export function ElementTransitionFields({ {(mode === "PENSION" || mode === "COMBI") && ( setT({ conversionRate: v })} @@ -762,7 +762,7 @@ export function ElementTransitionFields({ return ( setT({ capitalTaxRate: v })} @@ -775,7 +775,7 @@ export function ElementTransitionFields({ const marktwert = Math.round(context.propertyValueEnd); const preis = num(td.salePrice); // Abweichung zwischen erfasstem Verkaufspreis und modelliertem Verkehrswert. Beide - // Groessen sind unabhaengig erfassbar -- ohne diesen Vergleich koennte man 2 % + // Grössen sind unabhängig erfassbar -- ohne diesen Vergleich könnte man 2 % // Wertsteigerung annehmen und trotzdem zum Kaufpreis verkaufen, ohne es zu merken. const abweichung = preis - marktwert; const abweichungPct = marktwert > 0 ? (abweichung / marktwert) * 100 : 0; @@ -833,13 +833,13 @@ export function ElementTransitionFields({ )}

)} - setT({ saleTaxRate: v })} /> + setT({ saleTaxRate: v })} /> )} {decision !== "SELL" && context.mortgageEnd > 0 && ( setT({ extraAmortization: v })} @@ -865,7 +865,7 @@ export function ElementTransitionFields({ {decision === "PARTIAL" && ( setT({ partialSaleAmount: v })} @@ -878,7 +878,7 @@ export function ElementTransitionFields({ return ( setT({ immediateRepayment: v })} /> @@ -903,8 +903,8 @@ export function ElementDetail({ element, context, phaseData, transitionData, onS } else { const payload: PhaseData = { ...pd }; // Einkommen/Ausgaben ab Phase 2: entspricht der Basiswert dem fortgeschriebenen Wert - // der Vorphase, KEINEN Override speichern -> Wert bleibt live vererbt (Aenderungen in - // frueheren Phasen wirken sich weiter aus). Nur ein bewusst abweichender Wert wird fix. + // der Vorphase, KEINEN Override speichern -> Wert bleibt live vererbt (Änderungen in + // früheren Phasen wirken sich weiter aus). Nur ein bewusst abweichender Wert wird fix. if ( (element.category === "INCOME" || element.category === "EXPENSE") && context.carried && @@ -936,7 +936,7 @@ export function ElementDetail({ element, context, phaseData, transitionData, onS
{CATEGORY_LABELS[element.category]} - {isTransition ? " · Uebergang" : ""} + {isTransition ? " · Übergang" : ""}
{element.name}
@@ -945,12 +945,12 @@ export function ElementDetail({ element, context, phaseData, transitionData, onS onClick={onDeleteElement} className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-xs text-muted hover:border-danger hover:bg-danger-soft hover:text-danger" > - Element loeschen + Element löschen
- {/* Punkt 4: Warnung, dass Aenderungen sich in die Folgephasen fortschreiben. */} + {/* Punkt 4: Warnung, dass Änderungen sich in die Folgephasen fortschreiben. */} {!isTransition && } {isTransition ? ( diff --git a/src/components/FormField.tsx b/src/components/FormField.tsx index be8c40f..e17ff90 100644 --- a/src/components/FormField.tsx +++ b/src/components/FormField.tsx @@ -51,10 +51,10 @@ export function NumberField({ ); } -// Pflicht-Zahlenfeld, das wirklich LEER sein kann (NumberField erzwingt eine Zahl und haette +// Pflicht-Zahlenfeld, das wirklich LEER sein kann (NumberField erzwingt eine Zahl und hätte // damit immer einen Default -- genau das soll bei Annahmen vermieden werden, siehe // SPEZIFIKATION 9.15/9.18: ein stiller Default wird nicht hinterfragt). Der Wert wird als -// String gefuehrt; die Validierung liegt beim Aufrufer. +// String geführt; die Validierung liegt beim Aufrufer. export function RequiredNumberField({ label, help, @@ -148,7 +148,7 @@ export function MoneyInput({ } // Klick = 1 Schritt. Halten: nach 400 ms Wiederholung im 70-ms-Takt, wobei die - // Schrittweite mit der Haltedauer waechst (immer schneller). + // Schrittweite mit der Haltedauer wächst (immer schneller). function startHold(sign: number) { stepBy(sign); const start = Date.now(); @@ -177,7 +177,7 @@ export function MoneyInput({ onFocus={() => { setFocused(true); const current = Math.round(value || 0); - // Default-0 sofort leeren; sonst Wert markieren, damit man ihn ueberschreiben kann. + // Default-0 sofort leeren; sonst Wert markieren, damit man ihn überschreiben kann. setText(current === 0 ? "" : String(current)); requestAnimationFrame(() => inputRef.current?.select()); }} @@ -200,7 +200,7 @@ export function MoneyInput({
- {/* Erklaerung */} + {/* Erklärung */}

Was ist das? Dein Plan rechnet mit einer festen Rendite und Inflation @@ -479,7 +479,7 @@ function MonteCarloResults({ }) { const single = results.length === 1; - // Einzelnes Szenario: der gewohnte Faecher (Band + Median + Planungslinie). + // Einzelnes Szenario: der gewohnte Fächer (Band + Median + Planungslinie). const singleData = useMemo(() => { if (!single) return []; return results[0].bands.map((b) => ({ @@ -490,7 +490,7 @@ function MonteCarloResults({ })); }, [single, results, detPoints]); - // Mehrere Szenarien: nur die Median-Linien -- uebereinandergelegte Baender waeren Farbbrei. + // Mehrere Szenarien: nur die Median-Linien -- übereinandergelegte Bänder wären Farbbrei. const compareData = useMemo(() => { if (single) return []; const ages = Array.from(new Set(results.flatMap((r) => r.bands.map((b) => b.age)))).sort((a, b) => a - b); diff --git a/src/components/PhaseDetail.tsx b/src/components/PhaseDetail.tsx index 60bedf4..34894f8 100644 --- a/src/components/PhaseDetail.tsx +++ b/src/components/PhaseDetail.tsx @@ -43,12 +43,12 @@ export function PhaseDetail({ } async function remove() { - if (!confirm(`Phase "${phase.name}" wirklich loeschen?`)) return; + if (!confirm(`Phase "${phase.name}" wirklich löschen?`)) return; try { await api.delete(`/api/phases/${phase.id}`); onDeleted(); } catch (e) { - alert(e instanceof Error ? e.message : "Loeschen fehlgeschlagen."); + alert(e instanceof Error ? e.message : "Löschen fehlgeschlagen."); } } @@ -64,18 +64,18 @@ export function PhaseDetail({ onClick={remove} className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-xs text-muted hover:border-danger hover:bg-danger-soft hover:text-danger" > - Phase loeschen + Phase löschen )}

- {/* Eine geaenderte Dauer verschiebt alle Folgephasen (Alter, Renten, Vermoegen). */} + {/* Eine geänderte Dauer verschiebt alle Folgephasen (Alter, Renten, Vermögen). */} (
- {draft.householdType === "COUPLE" ? (person.role === "PERSON_A" ? "Person A" : "Person B") : "Ihre Angaben"} + {draft.householdType === "COUPLE" ? (person.role === "PERSON_A" ? "Person A" : "Person B") : "Deine Angaben"}
onChange({ ...draft, inflationRateDefault: v })} diff --git a/src/components/PlanView.tsx b/src/components/PlanView.tsx index 5784429..c526d7a 100644 --- a/src/components/PlanView.tsx +++ b/src/components/PlanView.tsx @@ -3,23 +3,29 @@ import { useEffect, useMemo, useState } from "react"; import { AlertCircle, + ArrowRight, Building2, CheckCircle2, ChevronDown, ChevronRight, CreditCard, + HelpCircle, Home, Landmark, + ListTodo, Maximize2, PiggyBank, Plus, Settings2, ShoppingCart, + Tornado, TrendingUp, Wallet, - X, } from "lucide-react"; import { Timeline } from "@/components/Timeline"; +import { Sparkline } from "@/components/Sparkline"; +import { Tour, TOUR_DONE_KEY } from "@/components/Tour"; +import { Button, EmptyState, InspectorShell, Modal, useConfirm, useToast } from "@/components/ui"; import { ElementDetailDialog, PhaseDetailDialog } from "@/components/DetailView"; import { CashTransitionFields, @@ -63,8 +69,8 @@ const CATEGORY_ICON: Record = { OTHER_DEBT: , }; -// Kategorien mit einem Uebergangs-Entscheid. AHV ist dabei ein Sonderfall: nur beim -// Pensions-Uebergang ist die Beitragskarriere zu pruefen (siehe transitionInactive). +// Kategorien mit einem Übergangs-Entscheid. AHV ist dabei ein Sonderfall: nur beim +// Pensions-Übergang ist die Beitragskarriere zu prüfen (siehe transitionInactive). const TRANSITION_CATEGORIES: ElementCategory[] = [ "AHV", "PENSION_FUND", @@ -86,8 +92,16 @@ type Column = | { kind: "phase"; phase: PhaseComputed } | { kind: "transition"; fromPhase: PhaseComputed; toPhase: PhaseComputed }; -type Selection = - | { type: "phase"; phaseId: string }; +// Alle Einzel-Bearbeitungen laufen über EIN rechtes Inspector-Panel (statt Modals): +// die Matrix bleibt sichtbar, ein Klick auf eine andere Zelle wechselt den Inhalt. +// Genau ein Panel kann offen sein -- deshalb eine Union statt sechs einzelner Zustände. +type Panel = + | { kind: "phase"; phaseId: string } + | { kind: "cell"; elementId: string; phaseId: string } + | { kind: "transition"; elementId: string; fromPhaseId: string } + | { kind: "cashTransition"; fromPhaseId: string } + | { kind: "cashInitial" } + | { kind: "profile" }; export function PlanView({ plan, @@ -95,15 +109,19 @@ export function PlanView({ diff, onChanged, onOpenSpec, + onOpenSensitivity, }: { plan: PlanInput; computed: PlanComputed; - // Abweichungen gegenueber dem Eltern-Szenario; null im Basisszenario (nichts zu markieren). + // Abweichungen gegenüber dem Eltern-Szenario; null im Basisszenario (nichts zu markieren). diff: ScenarioDiff | null; onChanged: () => void; onOpenSpec?: (anchor: string) => void; + onOpenSensitivity?: () => void; }) { - // Markierungs-Klassen: geaendert = gelb, neu = gruen, entfernt = grau. + const confirmDialog = useConfirm(); + const toast = useToast(); + // Markierungs-Klassen: geändert = gelb, neu = grün, entfernt = grau. const cellDiff = (elementId: string, phaseId: string) => diff?.phaseCell.has(`${elementId}:${phaseId}`) ? "bg-diff-soft ring-1 ring-inset ring-diff/40" : ""; const transDiff = (elementId: string, fromPhaseId: string) => @@ -116,23 +134,32 @@ export function PlanView({ ? "bg-diff-soft" : ""; }; - const [selected, setSelected] = useState(null); + const [panel, setPanel] = useState(null); const [collapsedCats, setCollapsedCats] = useState>(new Set()); const [showAdd, setShowAdd] = useState(false); + const [addCategory, setAddCategory] = useState(null); const [showAddPhase, setShowAddPhase] = useState(false); - const [showSettings, setShowSettings] = useState(false); const [reviewFromPhaseId, setReviewFromPhaseId] = useState(null); - const [editTransition, setEditTransition] = useState<{ elementId: string; fromPhaseId: string } | null>(null); - const [editPhaseCell, setEditPhaseCell] = useState<{ elementId: string; phaseId: string } | null>(null); - const [showCashInit, setShowCashInit] = useState(false); - // fromPhaseId des Cash-Uebergangs, der gerade bearbeitet wird. - const [editCashTransition, setEditCashTransition] = useState(null); + const [showTour, setShowTour] = useState(false); const [valueMode, setValueMode] = useState("nominal"); - // Nur-Lese-Detailansicht (Roadmap Nr. 43). Der Rechenweg wird erst beim Oeffnen erzeugt. + + // Tour beim ersten Besuch eines Plans mit Phasen automatisch starten. + useEffect(() => { + try { + if (computed.phases.length > 0 && !localStorage.getItem(TOUR_DONE_KEY)) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- einmalige Initialisierung aus localStorage + setShowTour(true); + } + } catch { + /* localStorage nicht verfügbar */ + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + // Nur-Lese-Detailansicht (Roadmap Nr. 43). Der Rechenweg wird erst beim Öffnen erzeugt. const [detailFor, setDetailFor] = useState<{ kind: "element"; id: string } | { kind: "phase"; id: string } | null>(null); - // Erklaerte Berechnung: bewusst NUR wenn eine Detailansicht offen ist. computePlan ist rein - // und laeuft im Browser -- es braucht dafuer weder einen API-Aufruf noch eine groessere + // Erklärte Berechnung: bewusst NUR wenn eine Detailansicht offen ist. computePlan ist rein + // und läuft im Browser -- es braucht dafür weder einen API-Aufruf noch eine grössere // Server-Antwort, und das Ergebnis ist per Konstruktion identisch zum Serverergebnis. const explained = useMemo( () => (detailFor ? computePlan(plan, undefined, { explain: true }) : null), @@ -141,7 +168,7 @@ export function PlanView({ useEffect(() => { const stored = typeof window !== "undefined" ? window.localStorage.getItem(VALUE_MODE_KEY) : null; - // eslint-disable-next-line react-hooks/set-state-in-effect -- einmalige Uebernahme der gespeicherten Wahl + // eslint-disable-next-line react-hooks/set-state-in-effect -- einmalige Übernahme der gespeicherten Wahl if (stored === "nominal" || stored === "both" || stored === "real") setValueMode(stored); }, []); function changeValueMode(m: ValueMode) { @@ -195,19 +222,19 @@ export function PlanView({ return !!before?.working && !!after && !after.working; } - // Beitragskarriere des Element-Besitzers (nur fuer AHV relevant). + // Beitragskarriere des Element-Besitzers (nur für AHV relevant). function careerFor(element: ElementInput) { if (element.category !== "AHV" || !element.ownerRole || element.ownerRole === "HOUSEHOLD") return null; const person = plan.persons.find((p) => p.role === element.ownerRole); return computed.ahvCareer.find((c) => c.personId === person?.id) ?? null; } - // Anzahl Phasen NACH dieser (fuer den Fortschreibungs-Warnhinweis). + // Anzahl Phasen NACH dieser (für den Fortschreibungs-Warnhinweis). function laterPhaseCount(phase: PhaseComputed): number { return computed.phases.length - phase.sequenceNumber; } - // Baut den Kontext fuer eine Phasenzelle. + // Baut den Kontext für eine Phasenzelle. function buildPhaseContext(phase: PhaseComputed, element: ElementInput): CellContext { const ce = computedElement(phase.id, element.id); const ownerWorking = @@ -256,9 +283,9 @@ export function PlanView({ }; } - // Am Uebergang nichts (mehr) zu tun: verkauft/getilgt ODER PK/3a nach der Pensionierung + // Am Übergang nichts (mehr) zu tun: verkauft/getilgt ODER PK/3a nach der Pensionierung // (Besitzer ist zu Beginn der Von-Phase bereits pensioniert -> bereits bezogen/verrentet) - // ODER AHV ausserhalb des Pensions-Uebergangs. + // ODER AHV ausserhalb des Pensions-Übergangs. function transitionInactive(el: ElementInput, fromPhase: PhaseComputed, toPhase?: PhaseComputed): boolean { const ce = computedElement(fromPhase.id, el.id); if (ce && ce.status !== "ACTIVE") return true; @@ -278,8 +305,8 @@ export function PlanView({ return plan.phases.find((p) => p.id === phaseId)?.cashTransition ?? {}; } - // Anzahl offener (noch nicht getroffener) Uebergangs-Entscheide an einer Grenze. - // Der Cash-Entscheid (einmalige Sonderein-/ausgaben) zaehlt mit. + // Anzahl offener (noch nicht getroffener) Übergangs-Entscheide an einer Grenze. + // Der Cash-Entscheid (einmalige Sonderein-/ausgaben) zählt mit. function transitionOpenCount(fromPhase: PhaseComputed, toPhase: PhaseComputed): number { let n = isCashTransitionAnswered(cashTransitionFor(fromPhase.id)) ? 0 : 1; for (const el of plan.elements) { @@ -292,7 +319,7 @@ export function PlanView({ return n; } - // Ist der Uebergangs-Entscheid dieses Elements noch offen? + // Ist der Übergangs-Entscheid dieses Elements noch offen? function transitionUnanswered(el: ElementInput, fromPhase: PhaseComputed, toPhase: PhaseComputed): boolean { if (transitionInactive(el, fromPhase, toPhase)) return false; const retire = isRetirementTransition(el, fromPhase, toPhase); @@ -336,15 +363,18 @@ export function PlanView({ real = kaufkraftbereinigt (Planbeginn)
- +
+ +
- {/* Plan-Profil */} + {/* Szenario-Profil */}
Inflation {plan.inflationRateDefault}%
+ setReviewFromPhaseId(fromPhaseId)} + onAddElement={(cat) => { + setAddCategory(cat); + setShowAdd(true); + }} + onAddPhase={() => setShowAddPhase(true)} + onOpenSensitivity={onOpenSensitivity} + /> + {!hasPhases && ( -
-

Dieser Plan hat noch keine Lebensphasen.

- -
+ + )} {hasPhases && (
- + +
)} {computed.ruinAge !== null && ( -
- Kapital aufgebraucht mit Alter {computed.ruinAge} – das Gesamtvermoegen (inkl. Cash) faellt danach unter 0. +
+ + + Kapital aufgebraucht mit Alter {computed.ruinAge} – das Gesamtvermögen (inkl. Cash) fällt danach unter 0. + + {onOpenSensitivity && ( + + )}
)} - {/* Matrix */} + {/* Matrix: eigener Scrollbereich, damit Phasen-Köpfe (oben) UND Elementnamen (links) + beim Scrollen sichtbar bleiben. */} {hasPhases && ( -
+
- {columns.map((col) => @@ -422,9 +482,9 @@ export function PlanView({ personLabel={personLabel} mode={valueMode} diffKind={diff?.phaseHeader.get(col.phase.id) ?? null} - onClick={() => setSelected({ type: "phase", phaseId: col.phase.id })} + onClick={() => setPanel({ kind: "phase", phaseId: col.phase.id })} onExpand={() => setDetailFor({ kind: "phase", id: col.phase.id })} - active={selected?.type === "phase" && selected.phaseId === col.phase.id} + active={panel?.kind === "phase" && panel.phaseId === col.phase.id} /> ) : ( {/* Cash-Zeile (systemseitig, immer sichtbar, read-only). */} - {columns.map((col) => { const isFirst = col.kind === "phase" && col.phase.sequenceNumber === 1; return col.kind === "phase" ? ( ) : ( (() => { - // Uebergangszelle der Cash-Zeile: einmalige Sonderein-/ausgaben. + // Übergangszelle der Cash-Zeile: einmalige Sonderein-/ausgaben. const ct = cashTransitionFor(col.fromPhase.id); const open = !isCashTransitionAnswered(ct); return ( {columns.map((col) => { if (col.kind === "phase") { @@ -547,7 +614,7 @@ export function PlanView({ return ( )} @@ -616,26 +684,24 @@ export function PlanView({ )} - {/* Lebensphase bearbeiten -- als Popup, konsistent zu allen anderen Eingaben. - Der key erzwingt beim Wechsel der Phase einen Neuaufbau, damit der lokale - Formular-Zustand nicht von der vorher geoeffneten Phase uebrig bleibt. */} - {selected && (() => { - const phase = computed.phases.find((p) => p.id === selected.phaseId); - if (!phase) return null; - return ( - setSelected(null)}> - {renderDetail()} - - ); - })()} + {/* Inspector-Panel: alle Einzel-Bearbeitungen. Der key erzwingt beim Wechsel der + Auswahl einen Neuaufbau, damit kein Formular-Zustand der vorherigen Zelle + übrig bleibt (gleiches Muster wie zuvor bei den Dialogen). */} + {panel && renderPanel()} {showAdd && firstPhase && ( setShowAdd(false)} + initialCategory={addCategory} + onClose={() => { + setShowAdd(false); + setAddCategory(null); + }} onCreated={() => { setShowAdd(false); + setAddCategory(null); + toast("success", "Element angelegt."); onChanged(); }} /> @@ -649,48 +715,6 @@ export function PlanView({ /> )} - {showSettings && ( - setShowSettings(false)} - onSaved={() => { - setShowSettings(false); - onChanged(); - }} - /> - )} - - {showCashInit && ( - setShowCashInit(false)} - onSaved={() => { - setShowCashInit(false); - onChanged(); - }} - /> - )} - - {editCashTransition && (() => { - const fromPhase = computed.phases.find((p) => p.id === editCashTransition); - if (!fromPhase) return null; - const toIndex = computed.phases.findIndex((p) => p.id === fromPhase.id) + 1; - const toPhase = computed.phases[toIndex]; - return ( - setEditCashTransition(null)} - onSaved={() => { - setEditCashTransition(null); - onChanged(); - }} - /> - ); - })()} - {reviewFromPhaseId && (() => { const fromPhase = computed.phases.find((p) => p.id === reviewFromPhaseId); if (!fromPhase) return null; @@ -714,29 +738,6 @@ export function PlanView({ ); })()} - {editTransition && (() => { - const element = plan.elements.find((e) => e.id === editTransition.elementId); - const fromPhase = computed.phases.find((p) => p.id === editTransition.fromPhaseId); - if (!element || !fromPhase) return null; - const toIndex = computed.phases.findIndex((p) => p.id === fromPhase.id) + 1; - const toPhase = computed.phases[toIndex]; - const context = buildTransitionContext(fromPhase, toPhase, element); - return ( - setEditTransition(null)} - onSaved={() => { - setEditTransition(null); - onChanged(); - }} - /> - ); - })()} - {detailFor?.kind === "element" && explained && (() => { const el = plan.elements.find((e) => e.id === detailFor.id); if (!el) return null; @@ -767,34 +768,11 @@ export function PlanView({ ); })()} - {editPhaseCell && (() => { - const element = plan.elements.find((e) => e.id === editPhaseCell.elementId); - const phase = computed.phases.find((p) => p.id === editPhaseCell.phaseId); - if (!element || !phase) return null; - const context = buildPhaseContext(phase, element); - return ( - setEditPhaseCell(null)} - onSaved={() => { - setEditPhaseCell(null); - onChanged(); - }} - onDeleted={() => { - setEditPhaseCell(null); - deleteElement(element.id); - }} - /> - ); - })()} + {showTour && setShowTour(false)} />} ); - // Naechste Phasen-Kappung (fuer das Phase-Popup). + // Nächste Phasen-Kappung (für das Phase-Popup). function nextPhaseCap(): number | null { // Simpel aus den Personen ableiten (Jahre nach Planbeginn = Summe der Dauern). const yearsBefore = plan.phases.reduce((s, p) => s + p.durationYears, 0); @@ -804,34 +782,108 @@ export function PlanView({ return caps.length > 0 ? Math.min(...caps) : null; } - function renderDetail() { - if (!selected) return null; - const phase = computed.phases.find((p) => p.id === selected.phaseId); - const phaseInput = plan.phases.find((p) => p.id === selected.phaseId); - if (!phase || !phaseInput) return null; - const isLast = phase.sequenceNumber === computed.phases.length; - return ( - { - setSelected(null); - onChanged(); - }} - onDeleted={() => { - setSelected(null); - onChanged(); - }} - /> - ); + // Inhalt des Inspector-Panels je nach Auswahl. + function renderPanel() { + if (!panel) return null; + const close = () => setPanel(null); + const closeAndRefresh = () => { + setPanel(null); + onChanged(); + }; + + switch (panel.kind) { + case "phase": { + const phase = computed.phases.find((p) => p.id === panel.phaseId); + const phaseInput = plan.phases.find((p) => p.id === panel.phaseId); + if (!phase || !phaseInput) return null; + return ( + + + + ); + } + case "cell": { + const element = plan.elements.find((e) => e.id === panel.elementId); + const phase = computed.phases.find((p) => p.id === panel.phaseId); + if (!element || !phase) return null; + return ( + + { + setPanel(null); + deleteElement(element.id); + }} + /> + + ); + } + case "transition": { + const element = plan.elements.find((e) => e.id === panel.elementId); + const fromPhase = computed.phases.find((p) => p.id === panel.fromPhaseId); + if (!element || !fromPhase) return null; + const toPhase = computed.phases[computed.phases.findIndex((p) => p.id === fromPhase.id) + 1]; + return ( + + ); + } + case "cashTransition": { + const fromPhase = computed.phases.find((p) => p.id === panel.fromPhaseId); + if (!fromPhase) return null; + const toPhase = computed.phases[computed.phases.findIndex((p) => p.id === fromPhase.id) + 1]; + return ( + + ); + } + case "cashInitial": + return ; + case "profile": + return ; + } } async function deleteElement(id: string) { - if (!confirm("Dieses Element wirklich loeschen (aus allen Phasen)?")) return; + const ok = await confirmDialog({ + title: "Element löschen?", + message: "Das Element wird aus ALLEN Lebensphasen entfernt – samt aller Werte und Übergangs-Entscheide.", + confirmLabel: "Endgültig löschen", + danger: true, + }); + if (!ok) return; await api.delete(`/api/elements/${id}`); - setSelected(null); + toast("success", "Element gelöscht."); + setPanel(null); onChanged(); } @@ -839,7 +891,7 @@ export function PlanView({ const td = el.transitionValues[fromPhase.id] ?? {}; switch (el.category) { case "AHV": - return td.reviewed === true ? "Geprueft" : "?"; + return td.reviewed === true ? "Geprüft" : "?"; case "OTHER_ASSET": return td.decision === "SELL" ? "Verkauf" @@ -870,7 +922,7 @@ export function PlanView({ } } -// Zellinhalt: Start- UND Zielwert fuer wertbehaftete Elemente, sonst die Kennzahl. +// Zellinhalt: Start- UND Zielwert für wertbehaftete Elemente, sonst die Kennzahl. const START_END_CATEGORIES: ElementCategory[] = [...VALUE_CATEGORIES, "INCOME", "EXPENSE"]; type ValueMode = "nominal" | "both" | "real"; @@ -928,12 +980,12 @@ function PhaseHeader({ }) { const quotaLabel = phase.isConsumption ? "Verzehr" : "Quote"; const dS = phase.cumulativeInflationStart; - const dE = phase.cumulativeInflationEnd; // Bestandswerte (Cash, Vermoegen) + const dE = phase.cumulativeInflationEnd; // Bestandswerte (Cash, Vermögen) const dF = phase.flowDeflatorEnd; // Flow-Werte (Einkommen, Ausgaben, Quote) return ( ); @@ -1027,17 +1057,18 @@ function TransitionHeader({ openCount, onClick }: { openCount: number; onClick: return (
+ Finanzielle Elemente
+ Cash - verfuegbares Kapital + verfügbares Kapital setShowCashInit(true) : undefined} + onClick={isFirst ? () => setPanel({ kind: "cashInitial" }) : undefined} title={isFirst ? "Cash-Anfangswert bearbeiten" : undefined} className={`border-b border-r border-border px-2 py-1.5 text-center text-xs ${ isFirst ? "cursor-pointer hover:bg-accent-soft" : "" @@ -466,17 +526,17 @@ export function PlanView({ setEditCashTransition(col.fromPhase.id)} + onClick={() => setPanel({ kind: "cashTransition", fromPhaseId: col.fromPhase.id })} title="Einmalige Sonderein-/ausgaben" className={`cursor-pointer border-b border-r border-border px-2 py-1.5 text-center text-[11px] ${ open - ? "bg-accent font-semibold text-accent-fg" + ? "bg-attention font-semibold text-attention-fg" : diff?.cashTransitionCell.has(col.fromPhase.id) ? "bg-diff-soft text-diff ring-1 ring-inset ring-diff/40" : "bg-accent-soft/40 text-accent" @@ -540,6 +600,13 @@ export function PlanView({ {el.ownerRole && el.ownerRole !== "HOUSEHOLD" && (
{personLabel(el.ownerRole)}
)} + {/* Trend über alle Planjahre -- die per-Jahr-Werte existieren seit 0.11. */} + ph.elements.find((e) => e.elementId === el.id)?.yearly?.map((y) => y.value) ?? [] + )} + className="mt-0.5" + />
setEditPhaseCell({ elementId: el.id, phaseId: col.phase.id })} + onClick={() => setPanel({ kind: "cell", elementId: el.id, phaseId: col.phase.id })} title={cellDiff(el.id, col.phase.id) ? "Weicht von der Vorlage ab" : undefined} className={`cursor-pointer border-b border-r border-border px-2 py-1.5 text-center text-xs ${ ce?.locked ? "text-faint" : "text-fg" @@ -565,15 +632,16 @@ export function PlanView({ key={`t-${col.fromPhase.id}`} onClick={() => canTransition && - setEditTransition({ elementId: el.id, fromPhaseId: col.fromPhase.id }) + setPanel({ kind: "transition", elementId: el.id, fromPhaseId: col.fromPhase.id }) } title={transDiff(el.id, col.fromPhase.id) ? "Weicht von der Vorlage ab" : undefined} className={`border-b border-r border-border px-2 py-1.5 text-center text-[11px] ${ canTransition ? "cursor-pointer" : "text-faint" } ${ - // Offene Entscheide bleiben in Akzentfarbe; sonst gewinnt die Abweichungs-Markierung. + // Offene Entscheide tragen die Attention-Farbe (getrennt vom + // Akzent: "hier fehlt was" ist nicht "hier kannst du klicken"). open - ? "bg-accent font-semibold text-accent-fg" + ? "bg-attention font-semibold text-attention-fg" : transDiff(el.id, col.fromPhase.id) ? "bg-diff-soft text-diff ring-1 ring-inset ring-diff/40" : canTransition @@ -590,7 +658,7 @@ export function PlanView({ ); })} - {/* Geisterzeilen: in der Vorlage vorhanden, in diesem Szenario geloescht. */} + {/* Geisterzeilen: in der Vorlage vorhanden, in diesem Szenario gelöscht. */} {(diff?.removedElements ?? []).map((r) => (
@@ -607,7 +675,7 @@ export function PlanView({ {plan.elements.length === 0 && (diff?.removedElements.length ?? 0) === 0 && (
- Noch keine finanziellen Elemente. Fuegen Sie oben Ihr erstes Element hinzu. + Noch keine finanziellen Elemente. Füge oben dein erstes Element hinzu.
)} - {/* Der Kopf oeffnet per Klick das Bearbeiten-Popup -- das Expand-Icon muss das Event + {/* Der Kopf öffnet per Klick das Bearbeiten-Popup -- das Expand-Icon muss das Event deshalb stoppen, sonst gingen beide Dialoge gleichzeitig auf. */} @@ -980,43 +1032,21 @@ function PhaseHeader({ {personLabel(p.role)} {p.startAge} → {p.endAge} ))} -
Einkommen {valStr(phase.incomeStart, dS, mode)} → {valStr(phase.incomeEnd, dF, mode)}
-
Ausgaben {valStr(phase.expenseStart, dS, mode)} → {valStr(phase.expenseEnd, dF, mode)}
-
+
{quotaLabel} {valStr(phase.quotaStart, dS, mode)} → {valStr(phase.quotaEnd, dF, mode)}
-
-
Geplante Sparrate {valStr(phase.plannedSaveRate, dS, mode)}
-
Geplante Verzehrrate {valStr(phase.plannedWithdrawRate, dS, mode)}
- - {(phase.capitalInflow > 0 || phase.capitalInvest > 0) && ( - <> -
- {phase.capitalInflow > 0 &&
Kapitalzufluss {valStr(phase.capitalInflow, dS, mode)}
} - {phase.capitalInvest > 0 &&
Kapitalinvestitionen {valStr(phase.capitalInvest, dS, mode)}
} - - )} - - {(phase.oneOffInflow > 0 || phase.oneOffOutflow > 0) && ( - <> -
- {phase.oneOffInflow > 0 && ( -
- + {phase.oneOffInflowLabel ?? "Einmaliger Zufluss"} {valStr(phase.oneOffInflow, dS, mode)} -
- )} - {phase.oneOffOutflow > 0 && ( -
- − {phase.oneOffOutflowLabel ?? "Einmalige Kosten"} {valStr(phase.oneOffOutflow, dS, mode)} -
- )} - - )} - -
-
- Vermoegen {valStr(phase.startWealthNominal, dS, mode)} → {valStr(phase.endWealthNominal, dE, mode)} +
+ Vermögen {valStr(phase.startWealthNominal, dS, mode)} → {valStr(phase.endWealthNominal, dE, mode)}
+ {(phase.oneOffInflow > 0 || phase.oneOffOutflow > 0) && ( +
phase.oneOffInflow ? "text-danger" : "text-success"}> + {phase.oneOffInflow > 0 ? `+ ${phase.oneOffInflowLabel ?? "Zufluss"}` : ""} + {phase.oneOffInflow > 0 && phase.oneOffOutflow > 0 ? " · " : ""} + {phase.oneOffOutflow > 0 ? `− ${phase.oneOffOutflowLabel ?? "Kosten"}` : ""} +
+ )} + {/* Alles Weitere (Einkommen, Ausgaben, Raten, Kapitalflüsse) steht in der + Detailansicht -- erreichbar über das Expand-Icon oben. */}
-
Uebergang
+
Übergang
{done ? (
- geprueft + geprüft
) : ( -
+
{openCount} offen
)} @@ -1049,19 +1080,129 @@ function FragmentRows({ children }: { children: React.ReactNode }) { return <>{children}; } +// --- "Nächste Schritte": abgeleitete Checkliste ------------------------------------------ +// Führt neue (und wiederkehrende) Nutzer zum jeweils nächsten sinnvollen Schritt, statt +// sie vor der Matrix raten zu lassen. Alles wird aus den vorhandenen Daten abgeleitet -- +// kein eigener Zustand, kein API-Aufruf, verschwindet von selbst, wenn nichts offen ist. +function NextSteps({ + plan, + computed, + columns, + openCountFor, + onReview, + onAddElement, + onAddPhase, + onOpenSensitivity, +}: { + plan: PlanInput; + computed: PlanComputed; + columns: Column[]; + openCountFor: (fromPhase: PhaseComputed, toPhase: PhaseComputed) => number; + onReview: (fromPhaseId: string) => void; + onAddElement: (category: ElementCategory) => void; + onAddPhase: () => void; + onOpenSensitivity?: () => void; +}) { + if (computed.phases.length === 0) return null; + + const items: { key: string; text: string; action: string; run: () => void }[] = []; + + // Offene Übergangs-Entscheide (inkl. Cash) -- der wichtigste Handlungsbedarf. + let openTotal = 0; + let firstOpenPhaseId: string | null = null; + for (const col of columns) { + if (col.kind !== "transition") continue; + const n = openCountFor(col.fromPhase, col.toPhase); + openTotal += n; + if (n > 0 && firstOpenPhaseId === null) firstOpenPhaseId = col.fromPhase.id; + } + if (openTotal > 0 && firstOpenPhaseId) { + const target = firstOpenPhaseId; + items.push({ + key: "transitions", + text: `${openTotal} Übergangs-Entscheid${openTotal === 1 ? "" : "e"} offen – z. B. was bei der Pensionierung mit PK und 3a passiert.`, + action: "Jetzt durchgehen", + run: () => onReview(target), + }); + } + + if (!plan.elements.some((e) => e.category === "INCOME")) { + items.push({ + key: "income", + text: "Noch kein Einkommen erfasst – ohne Einkommen gibt es keine Sparquote und keine AHV-Rente.", + action: "Einkommen erfassen", + run: () => onAddElement("INCOME"), + }); + } + if (!plan.elements.some((e) => e.category === "EXPENSE")) { + items.push({ + key: "expense", + text: "Noch keine Ausgaben erfasst – sie sind erfahrungsgemäss der stärkste Hebel der ganzen Planung.", + action: "Ausgaben erfassen", + run: () => onAddElement("EXPENSE"), + }); + } + + // Plan endet vor der Pensionierung: die spannendste Phase fehlt noch. + const totalYears = computed.phases.reduce((s, p) => s + p.durationYears, 0); + if (plan.persons.some((p) => p.age + totalYears < p.retirementAge)) { + items.push({ + key: "pension-phase", + text: "Dein Plan endet vor der Pensionierung – die entscheidende Phase fehlt noch.", + action: "Phase anlegen", + run: onAddPhase, + }); + } + + if (computed.ruinAge !== null && onOpenSensitivity) { + items.push({ + key: "ruin", + text: `Das Kapital reicht nicht bis ans Planende (Ruin mit ${computed.ruinAge}). Finde heraus, welche Annahme am meisten bewegt.`, + action: "Einflussfaktoren", + run: onOpenSensitivity, + }); + } + + if (items.length === 0) return null; + + return ( +
+
+ Nächste Schritte +
+
    + {items.map((it) => ( +
  • + {it.text} + +
  • + ))} +
+
+ ); +} + // --- Dialog: neues finanzielles Element mit Direkteingabe der Phase-1-Werte --- function AddElementDialog({ plan, firstPhase, + initialCategory, onClose, onCreated, }: { plan: PlanInput; firstPhase: PhaseComputed; + initialCategory?: ElementCategory | null; onClose: () => void; onCreated: () => void; }) { - const [category, setCategory] = useState("INCOME"); + const [category, setCategory] = useState(initialCategory ?? "INCOME"); const [name, setName] = useState(""); const [ownerRole, setOwnerRole] = useState("PERSON_A"); const [pd, setPd] = useState({}); @@ -1093,7 +1234,7 @@ function AddElementDialog({ { value: "PERSON_A", label: pLabel("PERSON_A") }, ]; - // Kontext fuer die Phase-1-Felder des neuen Elements. + // Kontext für die Phase-1-Felder des neuen Elements. const owner = needsPerson || ownerRole !== "HOUSEHOLD" ? ownerRole : null; const ownerWorking = owner && owner !== "HOUSEHOLD" @@ -1262,8 +1403,8 @@ function AddPhaseDialog({ ); } -// --- Dialog: Plan-Einstellungen (Grundprofil bearbeiten) --- -function PlanSettingsDialog({ plan, onClose, onSaved }: { plan: PlanInput; onClose: () => void; onSaved: () => void }) { +// --- Panel: Szenario-Profil (Grundprofil bearbeiten) --- +function ProfilePanel({ plan, onClose, onSaved }: { plan: PlanInput; onClose: () => void; onSaved: () => void }) { const [draft, setDraft] = useState({ householdType: plan.householdType, inflationRateDefault: plan.inflationRateDefault, @@ -1287,11 +1428,16 @@ function PlanSettingsDialog({ plan, onClose, onSaved }: { plan: PlanInput; onClo } return ( - - - {error &&

{error}

} - -
+ +
+ + {error &&

{error}

} +
+ + +
+
+
); } @@ -1343,11 +1489,11 @@ function TransitionReviewDialog({ return (

- Gehen Sie die Positionen durch und treffen Sie je Element den Übergangs-Entscheid (Halten, Verkaufen, + Geh die Positionen durch und triff je Element den Übergangs-Entscheid (Halten, Verkaufen, Bezug). Danach werden gehaltene Werte automatisch in die nächste Phase fortgeschrieben.

- {/* Cash zuerst: einmalige Sonderein-/ausgaben betreffen jeden Uebergang. */} + {/* Cash zuerst: einmalige Sonderein-/ausgaben betreffen jeden Übergang. */}
@@ -1381,7 +1527,7 @@ function TransitionReviewDialog({ el.category === "AHV" && retire ? "Pensionierung: Beitragskarriere prüfen – die Rente hängt vom Durchschnittseinkommen ab." : (el.category === "PENSION_FUND" || el.category === "PILLAR_3A") && !retire - ? "Hier könnten Sie optional Kapital beziehen." + ? "Hier könntest du optional Kapital beziehen." : el.category === "PENSION_FUND" && retire ? "Pensionierung: Bezugsart wählen (Rente / Kapital / Kombination)." : el.category === "PILLAR_3A" && retire @@ -1413,8 +1559,8 @@ function TransitionReviewDialog({ ); } -// --- Dialog: Cash-Uebergang (einmalige Sonderein-/ausgaben) --- -function CashTransitionDialog({ +// --- Panel: Cash-Übergang (einmalige Sonderein-/ausgaben) --- +function CashTransitionPanel({ fromPhase, toPhase, initial, @@ -1446,10 +1592,11 @@ function CashTransitionDialog({ } return ( - -
- Einmalige Sonderein-/ausgaben · {fromPhase.name} → {toPhase?.name ?? "Ende"} -
+
- {error &&

{error}

} - -
+ {error &&

{error}

} + + ); } -// --- Dialog: Cash-Anfangswert (erste Lebensphase) --- -function CashInitialDialog({ plan, onClose, onSaved }: { plan: PlanInput; onClose: () => void; onSaved: () => void }) { +// --- Panel: Cash-Anfangswert (erste Lebensphase) --- +function CashInitialPanel({ plan, onClose, onSaved }: { plan: PlanInput; onClose: () => void; onSaved: () => void }) { const [value, setValue] = useState(plan.initialCash); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); @@ -1483,49 +1630,22 @@ function CashInitialDialog({ plan, onClose, onSaved }: { plan: PlanInput; onClos } return ( - -

Startbestand des Cash-Kontos zu Beginn der ersten Lebensphase.

- - {error &&

{error}

} - -
+ +
+ + {error &&

{error}

} + +
+
); } -// --- Dialog: Element-Werte einer Lebensphase (per Klick auf eine Phasenzelle) --- -function PhaseCellDialog({ - element, - phase, - context, - phaseData, - onClose, - onSaved, - onDeleted, -}: { - element: ElementInput; - phase: PhaseComputed; - context: CellContext; - phaseData: PhaseData; - onClose: () => void; - onSaved: () => void; - onDeleted: () => void; -}) { - return ( - - - - ); -} - -// --- Dialog: einzelner Übergangs-Entscheid (per Klick auf eine Übergangszelle) --- -function TransitionCellDialog({ +// --- Panel: einzelner Übergangs-Entscheid (per Klick auf eine Übergangszelle) --- +function TransitionCellPanel({ element, fromPhase, toPhase, @@ -1560,10 +1680,11 @@ function TransitionCellDialog({ } return ( - -
- {CATEGORY_LABELS[element.category]} · {fromPhase.name} → {toPhase?.name ?? "Ende"} -
+
setTd((prev) => ({ ...prev, ...patch }))} />
- {error &&

{error}

} - -
+ {error &&

{error}

} + + ); } -// --- gemeinsame Dialog-Bausteine --- +// --- gemeinsame Bausteine --- +// Erstell-Flows und der geführte Übergang bleiben Modals (ui.Modal: ESC, Fokus-Falle, +// Animation); Einzel-Bearbeitungen laufen über das Inspector-Panel. function DialogShell({ title, onClose, @@ -1591,20 +1714,9 @@ function DialogShell({ wide?: boolean; }) { return ( -
-
e.stopPropagation()} - className={`flex w-full ${wide ? "max-w-2xl" : "max-w-md"} flex-col gap-3 rounded-2xl border border-border bg-surface p-6 shadow-xl`} - > -
-

{title}

- -
- {children} -
-
+ + {children} + ); } @@ -1621,21 +1733,36 @@ function DialogActions({ }) { return (
- - + + +
+ ); +} + +// Aktionsleiste im Inspector-Panel. +function PanelActions({ + saving, + onConfirm, + onClose, + confirmLabel, +}: { + saving: boolean; + onConfirm: () => void; + onClose: () => void; + confirmLabel: string; +}) { + return ( +
+ +
); } diff --git a/src/components/PlanWizard.tsx b/src/components/PlanWizard.tsx new file mode 100644 index 0000000..1e628c2 --- /dev/null +++ b/src/components/PlanWizard.tsx @@ -0,0 +1,419 @@ +"use client"; + +// Geführter Plan-Assistent (Roadmap Nr. 10): "Schritt für Schritt statt leerer Matrix". +// Der Wizard fragt in Alltagssprache und erzeugt am Ende Plan, Phasen und Elemente über +// die BESTEHENDEN API-Endpunkte -- reine Orchestrierung, keine Änderung an Berechnung +// oder Datenmodell. +// +// Reihenfolge beim Anlegen (wichtig): erst Phase 1, dann Elemente samt Phase-1-Werten, +// dann die Folgephasen -- so belegt die Phasen-Route die Folgephasen korrekt vor. +// +// Ehrliche Grenze: Bricht die Sequenz mittendrin ab (Netzfehler), existiert ein Teil-Plan. +// Der ist normal weiterbearbeitbar; ein Rollback bräuchte Backend-Unterstützung und ist +// bewusst nicht gebaut (siehe SPEZIFIKATION 9.23). + +import { useState } from "react"; +import { ArrowLeft, ArrowRight, Sparkles } from "lucide-react"; +import { Modal, Button } from "@/components/ui"; +import { MoneyField, NumberField, TextField } from "@/components/FormField"; +import { InfoBubble } from "@/components/InfoBubble"; +import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields"; +import { api } from "@/lib/api-client"; +import { maxPhaseDuration } from "@/lib/calculations"; +import { PILLAR_3A_MAX_ANNUAL } from "@/lib/constants"; +import type { PhaseData } from "@/lib/elements"; + +interface PhaseDraft { + name: string; + durationYears: number; +} + +interface AssetToggles { + pk: boolean; + p3a: boolean; + etf: boolean; + realEstate: boolean; + debt: boolean; +} + +const STEP_TITLES = ["Über dich", "Deine Lebensphasen", "Einkommen & Ausgaben", "Vorsorge & Vermögen", "Fertig"]; + +// Vorschlag für die Phasenkette: Erwerbsphase bis zum nächsten Pensionsereignis +// (dieselbe Kappungslogik wie beim manuellen Anlegen), danach eine Pensionsphase bis ~90. +function suggestPhases(draft: ProfileDraft): PhaseDraft[] { + const persons = draft.persons.map((p) => ({ role: p.role, age: p.age, retirementAge: p.retirementAge })); + const cap = maxPhaseDuration(persons, 0); + const phases: PhaseDraft[] = []; + let elapsed = 0; + if (cap != null && cap > 0) { + phases.push({ name: "Erwerbsphase", durationYears: cap }); + elapsed = cap; + // Bei Paaren mit unterschiedlichem Pensionsalter folgt eine Mischphase bis zur + // zweiten Pensionierung. + const cap2 = maxPhaseDuration(persons, elapsed); + if (cap2 != null && cap2 > 0) { + phases.push({ name: "Übergangsphase", durationYears: cap2 }); + elapsed += cap2; + } + } + const oldest = Math.max(...draft.persons.map((p) => p.age)); + const pensionYears = Math.max(5, 90 - (oldest + elapsed)); + phases.push({ name: "Pensionsphase", durationYears: pensionYears }); + return phases; +} + +export function PlanWizard({ + onCreated, + onClose, +}: { + onCreated: (scenarioId: string) => void; + onClose: () => void; +}) { + const [step, setStep] = useState(0); + const [planName, setPlanName] = useState("Meine Planung"); + const [profile, setProfile] = useState(emptyProfileDraft); + const [phases, setPhases] = useState(null); + // Einkommen bewusst PRO PERSON: In Paar-Plänen zählt "Gemeinsam" nicht für die + // AHV-Rente (SPEZIFIKATION 9.9) -- der Assistent räumt diese Falle von Anfang an aus. + const [incomes, setIncomes] = useState>({ PERSON_A: 0 }); + const [expenses, setExpenses] = useState(0); + const [initialCash, setInitialCash] = useState(0); + const [toggles, setToggles] = useState({ pk: false, p3a: false, etf: false, realEstate: false, debt: false }); + const [pk, setPk] = useState({ value: 0, contribution: 0 }); + const [p3a, setP3a] = useState({ value: 0, contribution: 0 }); + const [etf, setEtf] = useState({ value: 0, contribution: 0 }); + const [re, setRe] = useState({ price: 0, mortgage: 0, amortization: 0, interest: 1.5 }); + const [debt, setDebt] = useState({ value: 0, repayment: 0 }); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + const personLabel = (role: string) => { + const p = profile.persons.find((x) => x.role === role); + return p?.name?.trim() || (role === "PERSON_A" ? "Person A" : "Person B"); + }; + + function goNext() { + if (step === 0) { + // Phasenvorschlag aus dem (evtl. geänderten) Profil neu ableiten. + setPhases(suggestPhases(profile)); + const inc: Record = {}; + for (const p of profile.persons) inc[p.role] = incomes[p.role] ?? 0; + setIncomes(inc); + } + setStep((s) => Math.min(s + 1, STEP_TITLES.length - 1)); + } + + async function create() { + setSaving(true); + setError(null); + try { + const { scenario } = await api.post<{ plan: { id: string }; scenario: { id: string } }>("/api/plans", { + name: planName.trim() || "Meine Planung", + householdType: profile.householdType, + inflationRateDefault: profile.inflationRateDefault, + startYear: profile.startYear, + persons: profile.persons.map((p) => ({ role: p.role, name: p.name.trim() || null, age: p.age, retirementAge: p.retirementAge })), + }); + const sid = scenario.id; + if (initialCash > 0) await api.patch(`/api/scenarios/${sid}`, { initialCash }); + + const phaseList = phases ?? suggestPhases(profile); + const first = await api.post<{ phase: { id: string } }>(`/api/scenarios/${sid}/phases`, { + name: phaseList[0].name, + durationYears: phaseList[0].durationYears, + }); + const firstPhaseId = first.phase.id; + + async function addEl(category: string, name: string, ownerRole: string, values?: PhaseData) { + const { element } = await api.post<{ element: { id: string } }>(`/api/scenarios/${sid}/elements`, { + category, + name, + ownerRole, + }); + if (values) await api.put(`/api/elements/${element.id}/phase/${firstPhaseId}`, values); + } + + for (const p of profile.persons) { + const amount = incomes[p.role] ?? 0; + if (amount > 0) await addEl("INCOME", `Lohn ${personLabel(p.role)}`, p.role, { amount, teuerungsausgleich: 0 }); + } + if (expenses > 0) await addEl("EXPENSE", "Lebenshaltung", "HOUSEHOLD", { amount: expenses, teuerungsausgleich: 0 }); + for (const p of profile.persons) { + await addEl("AHV", profile.persons.length > 1 ? `AHV ${personLabel(p.role)}` : "AHV", p.role, { gapYears: 0 }); + } + if (toggles.pk) { + await addEl("PENSION_FUND", "Pensionskasse", "PERSON_A", { + currentValue: pk.value, + annualContribution: pk.contribution, + expectedReturn: 1.5, + }); + } + if (toggles.p3a) { + await addEl("PILLAR_3A", "Säule 3a", "PERSON_A", { + currentValue: p3a.value, + annualContribution: Math.min(p3a.contribution, PILLAR_3A_MAX_ANNUAL), + expectedReturn: 2, + }); + } + if (toggles.etf) { + await addEl("OTHER_ASSET", "Wertschriften / ETF", "HOUSEHOLD", { + startValue: etf.value, + annualContribution: etf.contribution, + expectedReturn: 5, + }); + } + if (toggles.realEstate) { + await addEl("REAL_ESTATE", "Eigenheim", "HOUSEHOLD", { + purchasePrice: re.price, + mortgage: re.mortgage, + amortization: re.amortization, + interestRate: re.interest, + }); + } + if (toggles.debt) { + await addEl("OTHER_DEBT", "Kredit", "HOUSEHOLD", { startValue: debt.value, annualRepayment: debt.repayment }); + } + + for (const ph of phaseList.slice(1)) { + await api.post(`/api/scenarios/${sid}/phases`, { name: ph.name, durationYears: ph.durationYears }); + } + + onCreated(sid); + } catch (e) { + setError( + (e instanceof Error ? e.message : "Erstellen fehlgeschlagen.") + + " – Falls der Plan teilweise angelegt wurde, findest du ihn in der Seitenleiste und kannst dort normal weiterarbeiten." + ); + setSaving(false); + } + } + + const workingPersons = profile.persons.filter((p) => p.age < p.retirementAge); + + return ( + + {/* Fortschritt */} +
+ {STEP_TITLES.map((_, i) => ( +
+ ))} +
+ + {step === 0 && ( +
+

+ Ein paar Angaben zu dir – daraus baut das Tool dein Grundgerüst. Alles lässt sich später ändern. +

+ + +
+ )} + + {step === 1 && phases && ( +
+

+ Deine Planung besteht aus Lebensphasen – Abschnitten, in denen deine + Finanzen ähnlich laufen. Das Tool schlägt sie dir anhand deines Pensionsalters vor; die Dauer kannst du + anpassen. +

+ {/* Mini-Vorschau der Kette */} +
+ {phases.map((ph, i) => ( +
s + x.durationYears, 0)) * 100}%` }} + className={`px-2 py-1.5 text-[10px] font-semibold ${i === 0 && workingPersons.length > 0 ? "bg-accent/70 text-accent-fg" : "bg-accent-soft text-accent-soft-fg"} ${i > 0 ? "border-l border-border" : ""}`} + > + {ph.name} + {ph.durationYears} J. +
+ ))} +
+
+ {phases.map((ph, i) => ( + + setPhases((prev) => prev!.map((x, j) => (j === i ? { ...x, durationYears: Math.max(1, Math.round(v)) } : x))) + } + /> + ))} +
+

+ Eine Phase darf keine Pensionierung überspannen – die Dauer der Erwerbsphase ist deshalb ans Pensionsalter + gekoppelt und wird beim Anlegen automatisch begrenzt. +

+
+ )} + + {step === 2 && ( +
+

Die zwei wichtigsten Zahlen deiner Planung – grob geschätzt reicht für den Anfang.

+ {profile.persons.map((p) => ( + setIncomes((prev) => ({ ...prev, [p.role]: v }))} + /> + ))} + + +
+ )} + + {step === 3 && ( +
+

+ Was besitzt du? Hake an, was auf dich zutrifft – nur das Angehakte fragt nach Details. + {profile.persons.length > 1 && " Vorsorge (PK/3a) wird Person A zugeordnet – weitere Konten kannst du später ergänzen."} +

+ setToggles((t) => ({ ...t, pk: v }))} + > + setPk((s) => ({ ...s, value: v }))} /> + setPk((s) => ({ ...s, contribution: v }))} /> + + setToggles((t) => ({ ...t, p3a: v }))} + > + setP3a((s) => ({ ...s, value: v }))} /> + setP3a((s) => ({ ...s, contribution: v }))} /> + + setToggles((t) => ({ ...t, etf: v }))} + > + setEtf((s) => ({ ...s, value: v }))} /> + setEtf((s) => ({ ...s, contribution: v }))} /> + + setToggles((t) => ({ ...t, realEstate: v }))} + > + setRe((s) => ({ ...s, price: v }))} /> + setRe((s) => ({ ...s, mortgage: v }))} /> + setRe((s) => ({ ...s, amortization: v }))} /> + setRe((s) => ({ ...s, interest: v }))} /> + + setToggles((t) => ({ ...t, debt: v }))} + > + setDebt((s) => ({ ...s, value: v }))} /> + setDebt((s) => ({ ...s, repayment: v }))} /> + +

+ Die AHV wird automatisch für {profile.persons.length > 1 ? "beide Personen" : "dich"} angelegt – die Rente + berechnet das Tool aus deinem Einkommen. +

+
+ )} + + {step === 4 && ( +
+

Das wird angelegt:

+
    +
  • 📋 Plan «{planName.trim() || "Meine Planung"}» mit Basisszenario
  • +
  • 📅 {(phases ?? []).map((p) => `${p.name} (${p.durationYears} J.)`).join(" → ")}
  • +
  • + 💰 {profile.persons.filter((p) => (incomes[p.role] ?? 0) > 0).length > 0 ? "Einkommen, " : ""} + {expenses > 0 ? "Ausgaben, " : ""}AHV + {toggles.pk ? ", Pensionskasse" : ""} + {toggles.p3a ? ", Säule 3a" : ""} + {toggles.etf ? ", Wertschriften" : ""} + {toggles.realEstate ? ", Wohneigentum" : ""} + {toggles.debt ? ", Schulden" : ""} +
  • +
+

+ Danach zeigt dir eine kurze Tour die Matrix – und die offenen{" "} + Übergangs-Entscheide (z. B. was bei der Pensionierung mit der PK + passiert) gehst du direkt in der Ansicht durch. +

+
+ )} + + {error &&

{error}

} + +
+ +
+ {step > 0 && ( + + )} + {step < STEP_TITLES.length - 1 ? ( + + ) : ( + + )} +
+
+ + ); +} + +function WizardToggle({ + label, + help, + checked, + onChange, + children, +}: { + label: string; + help: string; + checked: boolean; + onChange: (v: boolean) => void; + children: React.ReactNode; +}) { + return ( +
+ + {checked &&
{children}
} +
+ ); +} diff --git a/src/components/ProfileMenu.tsx b/src/components/ProfileMenu.tsx index 1684703..eb4c284 100644 --- a/src/components/ProfileMenu.tsx +++ b/src/components/ProfileMenu.tsx @@ -12,6 +12,7 @@ export function ProfileMenu({ username }: { username: string }) { const menuRef = useRef(null); useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- einmalige Übernahme aus localStorage setThemeState(getEffectiveTheme()); }, []); @@ -74,7 +75,7 @@ export function ProfileMenu({ username }: { username: string }) { } - label="Passwort aendern" + label="Passwort ändern" onClick={() => { setOpen(false); setShowPasswordDialog(true); @@ -121,7 +122,7 @@ function ChangePasswordDialog({ onClose }: { onClose: () => void }) { e.preventDefault(); setError(null); if (newPassword !== newPasswordConfirm) { - setError("Die neuen Passwoerter stimmen nicht ueberein."); + setError("Die neuen Passwörter stimmen nicht überein."); return; } setSaving(true); @@ -130,7 +131,7 @@ function ChangePasswordDialog({ onClose }: { onClose: () => void }) { setDone(true); setTimeout(onClose, 1200); } catch (err) { - setError(err instanceof Error ? err.message : "Passwort aendern fehlgeschlagen."); + setError(err instanceof Error ? err.message : "Passwort ändern fehlgeschlagen."); } finally { setSaving(false); } @@ -146,12 +147,12 @@ function ChangePasswordDialog({ onClose }: { onClose: () => void }) { onClick={(e) => e.stopPropagation()} className="flex w-full max-w-sm flex-col gap-3 rounded-2xl border border-border bg-surface p-6 shadow-xl" > -

Passwort aendern

+

Passwort ändern

setCurrentPassword(e.target.value)} className={inputClass} /> setNewPassword(e.target.value)} className={inputClass} /> - setNewPasswordConfirm(e.target.value)} className={inputClass} /> + setNewPasswordConfirm(e.target.value)} className={inputClass} /> {error &&

{error}

} - {done &&

Passwort geaendert.

} + {done &&

Passwort geändert.

}
- {/* Erklaerung */} + {/* Erklärung */}

Was ist das? Nicht «wie viel Geld habe ich am Schluss», sondern @@ -130,7 +130,7 @@ export function SensitivityDialog({ plan, onClose }: { plan: PlanInput; onClose:

- {/* Zielgroesse */} + {/* Zielgrösse */}
pad + (i / (values.length - 1)) * (width - 2 * pad); + const y = (v: number) => pad + (1 - (v - min) / (max - min)) * (height - 2 * pad); + const points = values.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(" "); + + return ( + + ); +} diff --git a/src/components/SparquoteChart.tsx b/src/components/SparquoteChart.tsx index d54b23b..4d67a4e 100644 --- a/src/components/SparquoteChart.tsx +++ b/src/components/SparquoteChart.tsx @@ -18,8 +18,8 @@ const INCOME_COLOR = "#16a34a"; const EXPENSE_COLOR = "#dc2626"; const REAL_COLOR = "#9ca3af"; -// Verlauf pro Jahr: Einkommen (nominal, inkl. Renten) vs. nominale Ausgaben; die Flaeche -// dazwischen ist die Spar-/Verzehrquote (gruen = Sparen, rot = Verzehr). Reale Ausgaben als +// Verlauf pro Jahr: Einkommen (nominal, inkl. Renten) vs. nominale Ausgaben; die Fläche +// dazwischen ist die Spar-/Verzehrquote (grün = Sparen, rot = Verzehr). Reale Ausgaben als // blasse Referenzlinie -> der Keil zu den nominalen Ausgaben ist "das, was die Inflation frisst". export function SparquoteChart({ computed }: { computed: PlanComputed }) { if (computed.yearly.length === 0) return

Noch keine Phasen vorhanden.

; @@ -31,7 +31,7 @@ export function SparquoteChart({ computed }: { computed: PlanComputed }) { income: y.income, expenseNominal: y.expenseNominal, expenseReal: y.expenseReal, - // Range-Flaechen [unten, oben]; nur dort gesetzt, wo zutreffend (sonst Luecke). + // Range-Flächen [unten, oben]; nur dort gesetzt, wo zutreffend (sonst Lücke). sparBand: surplus ? [y.expenseNominal, y.income] : undefined, verzehrBand: surplus ? undefined : [y.income, y.expenseNominal], }; diff --git a/src/components/SpecView.tsx b/src/components/SpecView.tsx index 15aafdd..a0d8dea 100644 --- a/src/components/SpecView.tsx +++ b/src/components/SpecView.tsx @@ -7,8 +7,8 @@ import rehypeSlug from "rehype-slug"; import { FileText } from "lucide-react"; import { api } from "@/lib/api-client"; -// Rendert SPEZIFIKATION.md (via /api/spec) als lesbares Dokument. Das Styling laeuft ueber -// die Klasse .md-doc in globals.css und folgt damit dem gewaehlten Farbschema. +// Rendert SPEZIFIKATION.md (via /api/spec) als lesbares Dokument. Das Styling läuft über +// die Klasse .md-doc in globals.css und folgt damit dem gewählten Farbschema. export function SpecView({ anchor }: { anchor?: string | null }) { const [markdown, setMarkdown] = useState(null); const [error, setError] = useState(null); @@ -21,7 +21,7 @@ export function SpecView({ anchor }: { anchor?: string | null }) { }, []); // Sprungmarke aus einem Rechenweg: erst nach dem Rendern des Markdowns existiert die - // Ueberschrift mit der von rehype-slug erzeugten id. + // Überschrift mit der von rehype-slug erzeugten id. useEffect(() => { if (!anchor || markdown === null) return; const el = document.getElementById(anchor); @@ -29,7 +29,7 @@ export function SpecView({ anchor }: { anchor?: string | null }) { }, [anchor, markdown]); if (error) return

{error}

; - if (markdown === null) return

Laedt…

; + if (markdown === null) return

Lädt…

; return (
diff --git a/src/components/SystemParametersView.tsx b/src/components/SystemParametersView.tsx index 2ee900f..682a318 100644 --- a/src/components/SystemParametersView.tsx +++ b/src/components/SystemParametersView.tsx @@ -38,7 +38,7 @@ function formatValue(p: SystemParameter): string { } // Zeigt die Systemparameter mit Wert, Bedeutung, Herleitung und Quelle (Roadmap Nr. 41). -// Die Eintraege stammen direkt aus constants.ts -- dieselbe Datenquelle, aus der auch +// Die Einträge stammen direkt aus constants.ts -- dieselbe Datenquelle, aus der auch // gerechnet wird. Ein Abdriften zwischen Anzeige und Rechnung ist damit ausgeschlossen. export function SystemParametersView() { return ( diff --git a/src/components/Timeline.tsx b/src/components/Timeline.tsx index bc9dd79..cb612f2 100644 --- a/src/components/Timeline.tsx +++ b/src/components/Timeline.tsx @@ -13,7 +13,7 @@ interface PersonAxis { // Horizontale Zeitachse: die Lebensphasen als aneinandergereihte Segmente (Breite = Dauer), // darunter Alter und -- sofern ein Planstart gesetzt ist -- die Kalenderjahre. Pensionsalter -// je Person und ein allfaelliges Ruinalter sind als Marker eingezeichnet. +// je Person und ein allfälliges Ruinalter sind als Marker eingezeichnet. export function Timeline({ phases, persons, @@ -35,7 +35,7 @@ export function Timeline({ const pct = (age: number) => `${(Math.max(0, Math.min(span, age - minAge)) / span) * 100}%`; const yearOf = (yearsFromStart: number) => (startYear ? startYear + yearsFromStart : null); - // Segmente: Start-/Endjahr (relativ zum Planbeginn) je Phase -- ohne Mutation waehrend + // Segmente: Start-/Endjahr (relativ zum Planbeginn) je Phase -- ohne Mutation während // des Renderns, deshalb die kumulierte Summe der vorangehenden Dauern. const segments = phases.map((p, i) => { const from = phases.slice(0, i).reduce((s, x) => s + x.durationYears, 0); @@ -91,7 +91,7 @@ export function Timeline({
)} - {/* Phasen-Segmente: Breite proportional zur Dauer, Einfaerbung nach Phasentyp. */} + {/* Phasen-Segmente: Breite proportional zur Dauer, Einfärbung nach Phasentyp. */}
{segments.map((s, i) => { const soft = s.phase.type === "PENSION" || s.phase.type === "MIXED"; diff --git a/src/components/Tour.tsx b/src/components/Tour.tsx new file mode 100644 index 0000000..0a1005f --- /dev/null +++ b/src/components/Tour.tsx @@ -0,0 +1,135 @@ +"use client"; + +// Interaktive Kurz-Tour über die Planansicht (Teil von Roadmap Nr. 10). Bewusst simpel +// gehalten: eine feste Karte unten rechts plus ein pulsierender Rahmen um das jeweilige +// Ziel-Element (per data-tour-Attribut) -- kein Spotlight-Overlay, keine Positionierungs- +// Engine. Ziele, die es im aktuellen Plan nicht gibt (z. B. Übergänge bei nur einer +// Phase), werden zur Laufzeit übersprungen. + +import { useEffect, useMemo, useState } from "react"; +import { Lightbulb, X } from "lucide-react"; +import { Button } from "@/components/ui"; + +export const TOUR_DONE_KEY = "fpt-tour-done"; + +interface TourStep { + target: string; // data-tour="…" + title: string; + text: string; +} + +const STEPS: TourStep[] = [ + { + target: "profil", + title: "Dein Grundprofil", + text: "Alter, Pensionsalter und Inflationsannahme. Alles hier gilt für dieses Szenario – über «Profil» änderst du es jederzeit.", + }, + { + target: "timeline", + title: "Die Zeitachse", + text: "Deine Lebensphasen im Überblick: Breite = Dauer, Fähnchen = Pensionierung. Die ganze Planung denkt in diesen Phasen.", + }, + { + target: "matrix", + title: "Die Matrix – das Herzstück", + text: "Zeilen sind deine Finanzen, Spalten deine Lebensphasen. Klick auf eine Zelle: rechts öffnet sich das Bearbeiten-Panel, die Matrix bleibt sichtbar.", + }, + { + target: "transition", + title: "Übergänge", + text: "Zwischen zwei Phasen triffst du einmalige Entscheide – verkaufen, beziehen, verrenten. Orange heisst: noch offen. Klick auf den Spaltenkopf führt dich durch alle Entscheide.", + }, + { + target: "cash", + title: "Das Cash-Konto", + text: "Dein Ausgleichskonto: Was übrig bleibt, landet hier – was fehlt, macht es rot. Negativ = Liquiditätslücke.", + }, + { + target: "analysen", + title: "Analysen", + text: "Grafiken, Monte-Carlo-Simulation und Einflussfaktoren: Wie sicher ist dein Plan, und welche Annahme entscheidet wirklich?", + }, +]; + +export function Tour({ onClose }: { onClose: () => void }) { + // Nur Schritte, deren Ziel im DOM existiert (hängt vom Plan ab). + const steps = useMemo( + () => STEPS.filter((s) => document.querySelector(`[data-tour="${s.target}"]`) !== null), + [] + ); + const [index, setIndex] = useState(0); + const step = steps[index]; + + useEffect(() => { + if (!step) return; + const el = document.querySelector(`[data-tour="${step.target}"]`); + if (!el) return; + el.classList.add("tour-highlight"); + el.scrollIntoView({ block: "center", behavior: "smooth" }); + return () => el.classList.remove("tour-highlight"); + }, [step]); + + if (!step) return null; + + function finish() { + try { + localStorage.setItem(TOUR_DONE_KEY, "1"); + } catch { + /* localStorage nicht verfügbar -> Tour erscheint beim nächsten Mal erneut */ + } + onClose(); + } + + return ( +
+
+
+ + + +
+
{step.title}
+
+ Schritt {index + 1} von {steps.length} +
+
+
+ +
+

{step.text}

+
+
+ {steps.map((_, i) => ( + + ))} +
+
+ {index > 0 && ( + + )} + {index < steps.length - 1 ? ( + + ) : ( + + )} +
+
+
+ ); +} diff --git a/src/components/WealthChart.tsx b/src/components/WealthChart.tsx index ddbdca4..52ea651 100644 --- a/src/components/WealthChart.tsx +++ b/src/components/WealthChart.tsx @@ -20,7 +20,7 @@ export interface TimelineSeries { } // Datenpunkte je Serie: JEDES Planjahr (nicht nur die Phasengrenzen), verortet auf dem Alter -// der Referenzperson (Person A). Der erste Punkt ist das Startvermoegen vor Jahr 1. +// der Referenzperson (Person A). Der erste Punkt ist das Startvermögen vor Jahr 1. function pointsFor(computed: PlanComputed) { const phases = computed.phases; if (phases.length === 0) return [] as { age: number; nominal: number; real: number }[]; @@ -33,8 +33,8 @@ function pointsFor(computed: PlanComputed) { return pts; } -// Liniendiagramm: Gesamtvermoegen (nominal + real) ueber das Alter. Unterstuetzt mehrere -// ueberlagerte Plaene fuer den Szenario-Vergleich. +// Liniendiagramm: Gesamtvermögen (nominal + real) über das Alter. Unterstützt mehrere +// überlagerte Pläne für den Szenario-Vergleich. export function WealthChart({ series }: { series: TimelineSeries[] }) { if (series.length === 0 || series[0].computed.phases.length === 0) { return

Noch keine Phasen vorhanden.

; diff --git a/src/components/ui.tsx b/src/components/ui.tsx new file mode 100644 index 0000000..9dccdb0 --- /dev/null +++ b/src/components/ui.tsx @@ -0,0 +1,381 @@ +"use client"; + +// UI-Primitiven des Tools. Ein einziger Ort für Buttons, Dialoge, Bestätigungen, Toasts +// und Ladezustände -- damit Verhalten (ESC, Fokus-Falle, Animation) und Optik überall +// identisch sind, statt in jeder Komponente von Hand nachgebaut zu werden. +// +// Ersetzt insbesondere window.confirm()/alert(): Browser-Systemdialoge folgen keinem der +// drei Farbschemata und wirken wie ein Fremdkörper. + +import { + createContext, + useCallback, + useContext, + useEffect, + useRef, + useState, +} from "react"; +import { AlertTriangle, CheckCircle2, X } from "lucide-react"; + +// --- ESC-Verwaltung ---------------------------------------------------------------------- +// Mehrere Schichten können gleichzeitig offen sein (Inspector + Dialog). ESC soll nur die +// OBERSTE schliessen. Jede Schicht registriert sich beim Öffnen; ESC trifft die letzte. +const escStack: (() => void)[] = []; +let escListenerAttached = false; + +function ensureEscListener() { + if (escListenerAttached || typeof window === "undefined") return; + escListenerAttached = true; + window.addEventListener("keydown", (e) => { + if (e.key !== "Escape" || escStack.length === 0) return; + e.stopPropagation(); + escStack[escStack.length - 1](); + }); +} + +function useEscClose(onClose: () => void) { + const closeRef = useRef(onClose); + useEffect(() => { + closeRef.current = onClose; + }, [onClose]); + useEffect(() => { + ensureEscListener(); + const handler = () => closeRef.current(); + escStack.push(handler); + return () => { + const i = escStack.indexOf(handler); + if (i >= 0) escStack.splice(i, 1); + }; + }, []); +} + +// Einfache Fokus-Falle: beim Öffnen den ersten fokussierbaren Inhalt fokussieren, +// Tab bleibt innerhalb der Schicht, beim Schliessen kehrt der Fokus zurück. +function useFocusTrap(ref: React.RefObject) { + useEffect(() => { + const el = ref.current; + if (!el) return; + const previous = document.activeElement as HTMLElement | null; + const focusables = () => + [...el.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + )].filter((x) => !x.hasAttribute("disabled")); + (focusables()[0] ?? el).focus({ preventScroll: true }); + function onKey(e: KeyboardEvent) { + if (e.key !== "Tab") return; + const f = focusables(); + if (f.length === 0) return; + const first = f[0]; + const last = f[f.length - 1]; + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + el.addEventListener("keydown", onKey); + return () => { + el.removeEventListener("keydown", onKey); + previous?.focus?.({ preventScroll: true }); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); +} + +// --- Button ------------------------------------------------------------------------------ + +type ButtonVariant = "primary" | "secondary" | "danger" | "ghost"; + +const BUTTON_CLASSES: Record = { + primary: + "bg-accent text-accent-fg shadow-sm hover:bg-accent-hover active:scale-[0.98]", + secondary: + "border border-border text-muted hover:bg-surface-2 hover:text-fg active:scale-[0.98]", + danger: + "border border-border text-muted hover:border-danger hover:bg-danger-soft hover:text-danger active:scale-[0.98]", + ghost: "text-muted hover:bg-surface-2 hover:text-fg", +}; + +export function Button({ + variant = "primary", + size = "md", + className = "", + children, + ...rest +}: React.ButtonHTMLAttributes & { + variant?: ButtonVariant; + size?: "sm" | "md"; +}) { + const sizeClass = size === "sm" ? "px-2.5 py-1 text-xs" : "px-4 py-2 text-sm"; + return ( + + ); +} + +// --- Modal ------------------------------------------------------------------------------- + +export function Modal({ + title, + subtitle, + onClose, + children, + wide, +}: { + title: string; + subtitle?: string; + onClose: () => void; + children: React.ReactNode; + wide?: boolean; +}) { + const panelRef = useRef(null); + useEscClose(onClose); + useFocusTrap(panelRef); + return ( +
+
e.stopPropagation()} + className={`ui-pop flex w-full ${wide ? "max-w-2xl" : "max-w-md"} flex-col gap-3 rounded-2xl border border-border bg-surface p-6 shadow-xl`} + > +
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+ +
+ {children} +
+
+ ); +} + +// --- Inspector --------------------------------------------------------------------------- +// Rechtes Seitenpanel für Einzel-Bearbeitungen (Zellen, Übergänge, Phase, Profil). +// Bewusst OHNE Backdrop: Die Matrix bleibt sichtbar und klickbar -- ein Klick auf eine +// andere Zelle wechselt einfach den Panel-Inhalt. Das hält den Kontext, den Modals nehmen. +// Auf schmalen Screens deckt das Panel die volle Breite ab. + +export function InspectorShell({ + title, + subtitle, + onClose, + children, +}: { + title: string; + subtitle?: string; + onClose: () => void; + children: React.ReactNode; +}) { + const panelRef = useRef(null); + useEscClose(onClose); + return ( + + ); +} + +// --- Bestätigungs-Dialog (ersetzt window.confirm) --------------------------------------- + +interface ConfirmOptions { + title: string; + message: string; + confirmLabel?: string; + danger?: boolean; +} + +const ConfirmContext = createContext<((opts: ConfirmOptions) => Promise) | null>(null); + +export function useConfirm() { + const fn = useContext(ConfirmContext); + if (!fn) throw new Error("useConfirm ausserhalb des ConfirmProviders"); + return fn; +} + +export function ConfirmProvider({ children }: { children: React.ReactNode }) { + const [pending, setPending] = useState<{ + opts: ConfirmOptions; + resolve: (ok: boolean) => void; + } | null>(null); + + const confirm = useCallback( + (opts: ConfirmOptions) => + new Promise((resolve) => setPending({ opts, resolve })), + [] + ); + + function settle(ok: boolean) { + pending?.resolve(ok); + setPending(null); + } + + return ( + + {children} + {pending && ( + settle(false)}> +

{pending.opts.message}

+
+ + +
+
+ )} +
+ ); +} + +// --- Toasts (ersetzt alert und stumme Erfolge) ------------------------------------------- + +interface ToastItem { + id: number; + kind: "success" | "error"; + text: string; +} + +const ToastContext = createContext<((kind: ToastItem["kind"], text: string) => void) | null>(null); + +export function useToast() { + const fn = useContext(ToastContext); + if (!fn) throw new Error("useToast ausserhalb des ToastProviders"); + return fn; +} + +export function ToastProvider({ children }: { children: React.ReactNode }) { + const [toasts, setToasts] = useState([]); + const nextId = useRef(1); + + const push = useCallback((kind: ToastItem["kind"], text: string) => { + const id = nextId.current++; + setToasts((prev) => [...prev, { id, kind, text }]); + // Fehler bleiben länger stehen als Erfolge. + setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), kind === "error" ? 6000 : 3200); + }, []); + + return ( + + {children} +
+ {toasts.map((t) => ( +
+ {t.kind === "error" ? ( + + ) : ( + + )} + {t.text} + +
+ ))} +
+
+ ); +} + +// --- Ladezustände und leere Zustände --------------------------------------------------- + +export function Skeleton({ className = "" }: { className?: string }) { + return
; +} + +// Platzhalter während die Planansicht lädt -- deutet die Struktur an, statt nur +// "Lädt…" zu schreiben (wahrgenommene Geschwindigkeit). +export function PlanSkeleton() { + return ( +
+ + + + +
+ ); +} + +export function EmptyState({ + icon, + title, + text, + children, +}: { + icon: React.ReactNode; + title: string; + text: string; + children?: React.ReactNode; +}) { + return ( +
+
+ {icon} +
+
+

{title}

+

{text}

+
+ {children &&
{children}
} +
+ ); +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 1e2f0d8..4eefc42 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -19,7 +19,7 @@ export async function createSessionToken(userId: string): Promise { .sign(getSecretKey()); } -// Liefert die User-ID aus einem gueltigen Session-Token, sonst null. +// Liefert die User-ID aus einem gültigen Session-Token, sonst null. export async function verifySessionToken(token: string): Promise { try { const { payload } = await jwtVerify(token, getSecretKey()); diff --git a/src/lib/bridges.test.ts b/src/lib/bridges.test.ts index a69702c..9c2d94c 100644 --- a/src/lib/bridges.test.ts +++ b/src/lib/bridges.test.ts @@ -3,10 +3,10 @@ import { computePlan } from "@/lib/calculations"; import type { CashTransitionData, ElementCategory, PhaseData, TransitionData } from "@/lib/elements"; import type { PlanInput } from "@/lib/types"; -// Die zentrale Invariante beider Wasserfall-Bruecken: `residual` ist die Differenz zwischen -// dem tatsaechlichen Endwert und der Summe der gezeichneten Schritte. Ist die Zerlegung -// vollstaendig und richtig, MUSS sie 0 sein -- ein Wasserfall, der nicht aufgeht, waere ein -// Fehler in der Berechnung und nicht bloss ein Darstellungsproblem. Deshalb ueber moeglichst +// Die zentrale Invariante beider Wasserfall-Brücken: `residual` ist die Differenz zwischen +// dem tatsächlichen Endwert und der Summe der gezeichneten Schritte. Ist die Zerlegung +// vollständig und richtig, MUSS sie 0 sein -- ein Wasserfall, der nicht aufgeht, wäre ein +// Fehler in der Berechnung und nicht bloss ein Darstellungsproblem. Deshalb über möglichst // verschiedene Konstellationen festgenagelt statt an einem einzigen Beispiel. let idc = 0; @@ -100,7 +100,7 @@ const konstellationen: { name: string; build: () => PlanInput }[] = [ }), }, { - name: "PK-Kapitalbezug statt Rente (Steuer am Uebergang)", + name: "PK-Kapitalbezug statt Rente (Steuer am Übergang)", build: () => plan({ age: 62, @@ -142,7 +142,7 @@ const konstellationen: { name: string; build: () => PlanInput }[] = [ }), }, { - name: "Immobilie verkauft (Verkaufsdifferenz und Grundstueckgewinnsteuer)", + name: "Immobilie verkauft (Verkaufsdifferenz und Grundstückgewinnsteuer)", build: () => plan({ age: 45, @@ -164,7 +164,7 @@ const konstellationen: { name: string; build: () => PlanInput }[] = [ }), }, { - name: "Einmalige Sonderein-/ausgaben am Uebergang", + name: "Einmalige Sonderein-/ausgaben am Übergang", build: () => plan({ age: 50, @@ -189,7 +189,7 @@ const konstellationen: { name: string; build: () => PlanInput }[] = [ }), }, { - name: "Sofort-Tilgung und Sonderamortisation am Uebergang", + name: "Sofort-Tilgung und Sonderamortisation am Übergang", build: () => plan({ age: 50, @@ -222,15 +222,15 @@ const konstellationen: { name: string; build: () => PlanInput }[] = [ }, ]; -describe("Wasserfall-Bruecken", () => { +describe("Wasserfall-Brücken", () => { for (const k of konstellationen) { - it(`${k.name}: beide Bruecken gehen ohne Restgroesse auf`, () => { + it(`${k.name}: beide Brücken gehen ohne Restgrösse auf`, () => { const r = computePlan(k.build()); expect(r.phases.length).toBeGreaterThan(0); for (const ph of r.phases) { - expect(ph.wealthBridge.residual, `Vermoegensbruecke ${ph.name}`).toBe(0); - expect(ph.cashBridge.residual, `Cash-Bruecke ${ph.name}`).toBe(0); - // Die Kontrollpunkte muessen den offiziellen Kennzahlen entsprechen. + expect(ph.wealthBridge.residual, `Vermögensbrücke ${ph.name}`).toBe(0); + expect(ph.cashBridge.residual, `Cash-Brücke ${ph.name}`).toBe(0); + // Die Kontrollpunkte müssen den offiziellen Kennzahlen entsprechen. expect(ph.wealthBridge.startWealth).toBe(ph.startWealthNominal); expect(ph.wealthBridge.endWealth).toBe(ph.endWealthNominal); expect(ph.cashBridge.cashStart).toBe(ph.cashStart); @@ -239,10 +239,10 @@ describe("Wasserfall-Bruecken", () => { }); } - it("Umbuchungen erscheinen NICHT in der Vermoegensbruecke", () => { - // Sparraten und Amortisationen verschieben Geld vom Cash in einen Vermoegenswert, ohne - // das Vermoegen zu aendern. Sie duerfen deshalb nur in der Cash-Bruecke auftauchen; die - // Vermoegensaenderung erklaert sich allein aus Quote, Ertraegen und PK-Beitraegen. + it("Umbuchungen erscheinen NICHT in der Vermögensbrücke", () => { + // Sparraten und Amortisationen verschieben Geld vom Cash in einen Vermögenswert, ohne + // das Vermögen zu ändern. Sie dürfen deshalb nur in der Cash-Brücke auftauchen; die + // Vermögensänderung erklärt sich allein aus Quote, Erträgen und PK-Beiträgen. const r = computePlan(konstellationen[0].build()); const ph = r.phases[0]; expect(ph.cashBridge.savingRates).toBeGreaterThan(0); @@ -253,9 +253,9 @@ describe("Wasserfall-Bruecken", () => { ); }); - it("PK-Verrentung erscheint als Vermoegensabgang am Uebergang", () => { - // Das verrentete Kapital verlaesst die Bilanz und wird zum Rentenstrom -- in der - // Vermoegensbruecke ein echter Abgang, kein Umbuchungsposten. + it("PK-Verrentung erscheint als Vermögensabgang am Übergang", () => { + // Das verrentete Kapital verlässt die Bilanz und wird zum Rentenstrom -- in der + // Vermögensbrücke ein echter Abgang, kein Umbuchungsposten. const r = computePlan(konstellationen[1].build()); const pension = r.phases[1]; expect(pension.wealthBridge.pensionConversion).toBeGreaterThan(0); @@ -268,7 +268,7 @@ describe("Wasserfall-Bruecken", () => { expect(gehalten.saleGainLoss).toBe(0); expect(gehalten.transitionTax).toBe(0); expect(verkauft.transitionTax).toBeGreaterThan(0); - // Verkaufspreis ueber dem modellierten Verkehrswert -> positive Differenz. + // Verkaufspreis über dem modellierten Verkehrswert -> positive Differenz. expect(verkauft.saleGainLoss).not.toBe(0); }); }); diff --git a/src/lib/calculations.test.ts b/src/lib/calculations.test.ts index 8ff2846..3bd2767 100644 --- a/src/lib/calculations.test.ts +++ b/src/lib/calculations.test.ts @@ -45,8 +45,8 @@ function plan(opts: { const within = (actual: number, expected: number, pct: number) => Math.abs(actual - expected) <= Math.abs(expected) * pct; -// Stuetzstellen aus der AMTLICHEN Tabelle 318.117.1 "Monatliche Vollrenten, Skala 44" -// (BSV, gueltig ab 1.1.2025/2026): mdJE -> monatliche Vollrente. Deckt Mindestrente, +// Stützstellen aus der AMTLICHEN Tabelle 318.117.1 "Monatliche Vollrenten, Skala 44" +// (BSV, gültig ab 1.1.2025/2026): mdJE -> monatliche Vollrente. Deckt Mindestrente, // Formel 1, den Wendepunkt (45'360), Formel 2 und die Maximalrente ab. const AHV_AMTLICHE_TABELLE: [number, number][] = [ [15120, 1260], // Mindestrente (12 x R0) @@ -72,7 +72,7 @@ describe("AHV-Rentenformel (Skala 44)", () => { it("kappt ausserhalb der Schwellen", () => { expect(ahvMonthlyFullPension(0)).toBe(1260); // unter 12 x R0 -> Mindestrente expect(ahvMonthlyFullPension(10000)).toBe(1260); - expect(ahvMonthlyFullPension(150000)).toBe(2520); // ueber 72 x R0 -> Maximalrente + expect(ahvMonthlyFullPension(150000)).toBe(2520); // über 72 x R0 -> Maximalrente }); it("ist am Wendepunkt stetig (kein Sprung zwischen Formel 1 und 2)", () => { @@ -82,12 +82,12 @@ describe("AHV-Rentenformel (Skala 44)", () => { }); }); -// V5-Modell: Einkommen = nominale Basis + nominale Lohnerhoehung; Ausgaben = REALE Basis + +// V5-Modell: Einkommen = nominale Basis + nominale Lohnerhöhung; Ausgaben = REALE Basis + // reale Mehrausgaben, nominal = real x (plan-weite Inflation). -// Netto -> Brutto fuer die AHV (AHV_GROSS_FROM_NET_FACTOR). +// Netto -> Brutto für die AHV (AHV_GROSS_FROM_NET_FACTOR). const BRUTTO = 1.12; -describe("Teilverkauf Sonstiges Vermoegen (Roadmap 42)", () => { +describe("Teilverkauf Sonstiges Vermögen (Roadmap 42)", () => { function p42(decision: "HOLD" | "SELL" | "PARTIAL", amount?: number) { return plan({ age: 40, retirementAge: 70, inflation: 0, initialCash: 0, @@ -111,10 +111,10 @@ describe("Teilverkauf Sonstiges Vermoegen (Roadmap 42)", () => { const r = computePlan(p42("PARTIAL", 30000)); expect(r.phases[1].capitalInflow).toBe(30000); // "zu investierendes Kapital" im Phasenkopf expect(r.phases[1].cashStart).toBe(30000); - expect(asset(p42("PARTIAL", 30000), 1).startValue).toBe(70000); // Rest laeuft weiter + expect(asset(p42("PARTIAL", 30000), 1).startValue).toBe(70000); // Rest läuft weiter }); - it("Teilverkauf am Endwert gekappt; Vollverkauf und Halten unveraendert", () => { + it("Teilverkauf am Endwert gekappt; Vollverkauf und Halten unverändert", () => { expect(computePlan(p42("PARTIAL", 999999)).phases[1].capitalInflow).toBe(100000); expect(computePlan(p42("SELL")).phases[1].capitalInflow).toBe(100000); expect(computePlan(p42("HOLD")).phases[1].capitalInflow).toBe(0); @@ -203,7 +203,7 @@ describe("Immobilie: Zins und Wertsteigerung", () => { expect(computePlan(mit).phases[0].cashEnd).toBe(500000 - 90000); }); - it("Zinsbetrag sinkt mit der Amortisation und zaehlt in die Quote", () => { + it("Zinsbetrag sinkt mit der Amortisation und zählt in die Quote", () => { const p = immoPlan({ purchasePrice: 1000000, mortgage: 1000000, amortization: 10000, interestRate: 1, interestHandling: "ADD", }); @@ -211,10 +211,10 @@ describe("Immobilie: Zins und Wertsteigerung", () => { // Jahr 1: 1% von 1'000'000 = 10'000. Jahr 10: 1% von (1'000'000 - 9 x 10'000) = 9'100. expect(ph.expenseStart).toBe(10000); expect(ph.expenseEnd).toBe(9100); - expect(ph.quotaStart).toBe(-10000); // Zins schlaegt auf die Quote durch + expect(ph.quotaStart).toBe(-10000); // Zins schlägt auf die Quote durch }); - it("Verkauf: Grundstueckgewinnsteuer bemisst sich am urspruenglichen Kaufpreis", () => { + it("Verkauf: Grundstückgewinnsteuer bemisst sich am ursprünglichen Kaufpreis", () => { const p = plan({ age: 40, retirementAge: 70, inflation: 0, initialCash: 0, phases: [ @@ -231,13 +231,13 @@ describe("Immobilie: Zins und Wertsteigerung", () => { }); const r = computePlan(p); // Gewinn = 1'200'000 - 1'000'000 (Kaufpreis!) = 200'000 -> Steuer 40'000. - // Erloes = 1'200'000 - 800'000 Resthypothek - 40'000 = 360'000. + // Erlös = 1'200'000 - 800'000 Resthypothek - 40'000 = 360'000. expect(r.phases[1].capitalInflow).toBe(360000); }); }); -describe("AHV einkommensabhaengig", () => { - // 60-jaehrig, Pension mit 65: 39 Beitragsjahre vor Planbeginn (ab 21), 5 im Plan. +describe("AHV einkommensabhängig", () => { + // 60-jährig, Pension mit 65: 39 Beitragsjahre vor Planbeginn (ab 21), 5 im Plan. function ahvPlan(opts: { age?: number; income: number; @@ -250,7 +250,7 @@ describe("AHV einkommensabhaengig", () => { return plan({ age, retirementAge: 65, - inflation: 0, // real = nominal, damit die Erwartungswerte von Hand pruefbar bleiben + inflation: 0, // real = nominal, damit die Erwartungswerte von Hand prüfbar bleiben phases: [ { id: "p1", durationYears: 65 - age }, { id: "p2", durationYears: 10 }, @@ -282,7 +282,7 @@ describe("AHV einkommensabhaengig", () => { }); it("mdJE unter der Schwelle -> abgestufte Rente (Formel 2)", () => { - // Plan-Einkommen ist NETTO -> fuer die AHV auf brutto hochgerechnet; das Feld + // Plan-Einkommen ist NETTO -> für die AHV auf brutto hochgerechnet; das Feld // "vor Planbeginn" ist bereits brutto. 39 Jahre davor, 5 im Plan. const p = ahvPlan({ income: 60000, avgIncomeBefore: 60000 }); const mdJE = (60000 * 39 + 60000 * BRUTTO * 5) / 44; @@ -290,8 +290,8 @@ describe("AHV einkommensabhaengig", () => { expect(renteIn(p, 1)).toBeLessThan(32760); }); - it("Nettolohn wird fuer die AHV auf den Bruttolohn hochgerechnet", () => { - // Ohne Vorgeschichte (Alter 21 bei Planbeginn) haengt das mdJE nur am Plan-Einkommen. + it("Nettolohn wird für die AHV auf den Bruttolohn hochgerechnet", () => { + // Ohne Vorgeschichte (Alter 21 bei Planbeginn) hängt das mdJE nur am Plan-Einkommen. const p = plan({ age: 21, retirementAge: 65, @@ -323,7 +323,7 @@ describe("AHV einkommensabhaengig", () => { expect(renteIn(p, 1)).toBe(Math.round(ahvMonthlyFullPension(mdJE) * 13)); }); - it("Ausfalljahre kuerzen die Rente ueber die Skala 44", () => { + it("Ausfalljahre kürzen die Rente über die Skala 44", () => { const ohne = ahvPlan({ income: 100000, avgIncomeBefore: 100000 }); const mit = ahvPlan({ income: 100000, avgIncomeBefore: 100000, gapYearsBefore: 4 }); expect(renteIn(mit, 1)).toBe(Math.round(32760 * (40 / 44))); // 4 Ausfalljahre = 4/44 weniger @@ -336,7 +336,7 @@ describe("AHV einkommensabhaengig", () => { expect(renteIn(p, 1)).toBe(Math.round(32760 * (42 / 44))); }); - it("ohne Pruefung gilt der geplante Durchschnitt auch fuer die Jahre vor Planbeginn", () => { + it("ohne Prüfung gilt der geplante Durchschnitt auch für die Jahre vor Planbeginn", () => { // Kein avgIncomeBefore erfasst -> darf NICHT als 0 gerechnet werden (sonst mdJE ~45%). const p = plan({ age: 60, @@ -348,11 +348,11 @@ describe("AHV einkommensabhaengig", () => { ], elements: [ el("INCOME", "PERSON_A", { p1: { amount: 80000, teuerungsausgleich: 0 }, p2: {} }), - el("AHV", "PERSON_A", { p1: {}, p2: {} }), // keine Uebergangsdaten + el("AHV", "PERSON_A", { p1: {}, p2: {} }), // keine Übergangsdaten ], }); const rente = computePlan(p).phases[1].elements.find((e) => e.category === "AHV")!.startValue; - // mdJE = geplantes Brutto (80'000 x 1.12), nicht ~9'091 (das waere der Fallback auf 0). + // mdJE = geplantes Brutto (80'000 x 1.12), nicht ~9'091 (das wäre der Fallback auf 0). expect(rente).toBe(Math.round(ahvMonthlyFullPension(80000 * BRUTTO) * 13)); }); @@ -463,7 +463,7 @@ describe("V5 Golden Tests", () => { expect(ph.expenseEnd).toBe(Math.round(100000 * Math.pow(1.02, 4))); // 108'243 }); - it("Einkommen nominal flach bei Lohnerhoehung 0%", () => { + it("Einkommen nominal flach bei Lohnerhöhung 0%", () => { const p = plan({ age: 40, retirementAge: 60, @@ -475,7 +475,7 @@ describe("V5 Golden Tests", () => { expect(ph.incomeEnd).toBe(80000); }); - it("Bezugsrate aus Sonstigem Vermoegen fliesst ins Cash (Verzehrrate)", () => { + it("Bezugsrate aus Sonstigem Vermögen fliesst ins Cash (Verzehrrate)", () => { const p = plan({ age: 65, retirementAge: 65, @@ -506,11 +506,11 @@ describe("V5 Golden Tests", () => { }); const r = computePlan(p); const p2 = r.phases[1]; - expect(p2.capitalInflow).toBe(10000); // Verkaufserloes aus dem Uebergang + expect(p2.capitalInflow).toBe(10000); // Verkaufserlös aus dem Übergang expect(p2.capitalInvest).toBe(10000); // Zusatzinvestition expect(p2.cashStart).toBe(0); // Startwert bereits nach Abzug der Investition expect(p2.cashEnd).toBe(0); - // Startvermoegen Phase 2: Cash(0) + Vermoegen B(10'000) = 10'000 (kein Doppelzaehlen). + // Startvermögen Phase 2: Cash(0) + Vermögen B(10'000) = 10'000 (kein Doppelzählen). expect(p2.startWealthNominal).toBe(10000); }); @@ -597,7 +597,7 @@ describe("V5 Golden Tests", () => { expect(pk.startValue).toBe(200000); // brutto 100'000 dem Kapital entnommen }); - it("Einmaliger Zufluss am Uebergang: nominal erfasst, Steuer abgezogen, direkt ins Cash", () => { + it("Einmaliger Zufluss am Übergang: nominal erfasst, Steuer abgezogen, direkt ins Cash", () => { const p = plan({ age: 40, retirementAge: 70, @@ -610,13 +610,13 @@ describe("V5 Golden Tests", () => { elements: [], }); const r = computePlan(p); - expect(r.phases[1].oneOffInflow).toBe(90000); // 100'000 abzueglich 10% Steuer + expect(r.phases[1].oneOffInflow).toBe(90000); // 100'000 abzüglich 10% Steuer expect(r.phases[1].oneOffInflowLabel).toBe("Erbschaft"); expect(r.phases[1].cashStart).toBe(91000); // 1'000 + 90'000 - expect(r.phases[0].oneOffInflow).toBe(0); // Phase 1 hat keinen eingehenden Uebergang + expect(r.phases[0].oneOffInflow).toBe(0); // Phase 1 hat keinen eingehenden Übergang }); - it("Einmalige Kosten am Uebergang: real erfasst, mit Inflation aufgewertet", () => { + it("Einmalige Kosten am Übergang: real erfasst, mit Inflation aufgewertet", () => { // Kosten 20'000 real, 2% Inflation, Grenze nach 10 Jahren -> 20'000 x 1.02^10 = 24'380. const p = plan({ age: 40, @@ -648,7 +648,7 @@ describe("V5 Golden Tests", () => { const r1 = computePlan(beide); expect(r1.phases[1].cashStart).toBe(30000); // +50'000 -20'000 - // Betraege sind erfasst, aber der Entscheid lautet "1:1 uebernehmen" -> keine Wirkung. + // Beträge sind erfasst, aber der Entscheid lautet "1:1 übernehmen" -> keine Wirkung. const keine = plan({ age: 40, retirementAge: 70, inflation: 0, initialCash: 0, phases: [ @@ -660,7 +660,7 @@ describe("V5 Golden Tests", () => { expect(computePlan(keine).phases[1].cashStart).toBe(0); }); - it("Einmalige Kosten koennen eine Liquiditaetsluecke ausloesen", () => { + it("Einmalige Kosten können eine Liquiditätslücke auslösen", () => { const p = plan({ age: 40, retirementAge: 70, inflation: 0, initialCash: 10000, phases: [ @@ -675,7 +675,7 @@ describe("V5 Golden Tests", () => { expect(p2.incomplete).toBe(true); }); - it("Cash-Entscheid der LETZTEN Phase bleibt wirkungslos (kein Uebergang mehr)", () => { + it("Cash-Entscheid der LETZTEN Phase bleibt wirkungslos (kein Übergang mehr)", () => { const p = plan({ age: 40, retirementAge: 70, inflation: 0, initialCash: 5000, phases: [{ id: "p1", durationYears: 1, cashTransition: { mode: "INFLOW", inflowAmount: 999999 } }], @@ -686,7 +686,7 @@ describe("V5 Golden Tests", () => { expect(r.nachlass).toBe(5000); }); - it("Vermoegen wird pro JAHR mitgefuehrt (Grundlage des Verlaufs ueber alle Jahre)", () => { + it("Vermögen wird pro JAHR mitgeführt (Grundlage des Verlaufs über alle Jahre)", () => { const p = plan({ age: 40, retirementAge: 70, inflation: 0, initialCash: 0, phases: [{ id: "p1", durationYears: 3 }], @@ -697,12 +697,12 @@ describe("V5 Golden Tests", () => { expect(r.yearly[0].wealthNominal).toBe(110000); expect(r.yearly[1].wealthNominal).toBe(121000); expect(r.yearly[2].wealthNominal).toBe(133100); - // Ohne Inflation sind real und nominal identisch; Endjahr = Phasen-Endvermoegen. + // Ohne Inflation sind real und nominal identisch; Endjahr = Phasen-Endvermögen. expect(r.yearly[2].wealthReal).toBe(133100); expect(r.yearly[2].wealthNominal).toBe(r.phases[0].endWealthNominal); }); - it("Vermoegen real ist bei Inflation kaufkraftbereinigt", () => { + it("Vermögen real ist bei Inflation kaufkraftbereinigt", () => { const p = plan({ age: 40, retirementAge: 70, inflation: 10, initialCash: 100000, phases: [{ id: "p1", durationYears: 1 }], @@ -713,7 +713,7 @@ describe("V5 Golden Tests", () => { expect(y.wealthReal).toBe(Math.round(100000 / 1.1)); // 90'909 }); - it("Fortschreibung: nominaler Einkommens-Basiswert Phase 1 -> Startwert Phase 2; Cash laeuft fort", () => { + it("Fortschreibung: nominaler Einkommens-Basiswert Phase 1 -> Startwert Phase 2; Cash läuft fort", () => { const p = plan({ age: 40, retirementAge: 70, diff --git a/src/lib/calculations.ts b/src/lib/calculations.ts index d53a70a..f196395 100644 --- a/src/lib/calculations.ts +++ b/src/lib/calculations.ts @@ -27,13 +27,13 @@ export interface PersonPhaseInfo { } // --- Rechenweg-Protokoll (Roadmap Nr. 41) ------------------------------------------------ -// Ein Schritt haelt die Formel UND die eingesetzten Zahlen fest. Entscheidend: Die Schritte -// entstehen INNERHALB der Berechnung, als Nebenprodukt der Rechnung, die ohnehin laeuft. -// Damit kann die angezeigte Herleitung nicht von der tatsaechlichen Rechnung abdriften -- -// eine zweite Formel-Implementierung im UI waere die gefaehrlichste Variante ueberhaupt. +// Ein Schritt hält die Formel UND die eingesetzten Zahlen fest. Entscheidend: Die Schritte +// entstehen INNERHALB der Berechnung, als Nebenprodukt der Rechnung, die ohnehin läuft. +// Damit kann die angezeigte Herleitung nicht von der tatsächlichen Rechnung abdriften -- +// eine zweite Formel-Implementierung im UI wäre die gefährlichste Variante überhaupt. export interface TraceStep { label: string; - formula?: string; // abstrakt, z. B. "Basis x (1 + Lohnerhoehung)^(t-1)" + formula?: string; // abstrakt, z. B. "Basis x (1 + Lohnerhöhung)^(t-1)" substituted?: string; // mit eingesetzten Zahlen, z. B. "100'000 x (1 + 2%)^4" result: number; unit?: "CHF" | "%" | "Jahre" | ""; @@ -47,15 +47,15 @@ export interface Trace { } export interface ComputeOptions { - // Standardmaessig aus: die Monte-Carlo-Simulation ruft computePlan zehntausendfach auf + // Standardmässig aus: die Monte-Carlo-Simulation ruft computePlan zehntausendfach auf // und darf von der Protokollierung nichts merken. explain?: boolean; } // Ein Datenpunkt pro Jahr JE ELEMENT -- Grundlage der Detailansicht (Roadmap Nr. 43). -// Nur die Phasengrenzen zu kennen reicht nicht: Eine Anlage mit 5 % ueber 20 Jahre waechst -// konvex, eine Gerade von Start zu Ende waere sichtbar falsch (gleiche Ueberlegung wie beim -// Vermoegensverlauf in v0.9). +// Nur die Phasengrenzen zu kennen reicht nicht: Eine Anlage mit 5 % über 20 Jahre wächst +// konvex, eine Gerade von Start zu Ende wäre sichtbar falsch (gleiche Überlegung wie beim +// Vermögensverlauf in v0.9). export interface ElementYearPoint { year: number; // 1-basiert ab Planbeginn age: number; // Alter Person A am Jahresende @@ -81,47 +81,47 @@ export interface ElementPhaseComputed { note: string | null; yearly: ElementYearPoint[]; // Verlauf innerhalb dieser Phase trace?: Trace; // Rechenweg der Phasenwerte (nur mit explain) - transitionTrace?: Trace; // Rechenweg des Uebergangs NACH dieser Phase (nur mit explain) + transitionTrace?: Trace; // Rechenweg des Übergangs NACH dieser Phase (nur mit explain) } // --- Wasserfall-Zerlegungen (Roadmap Nr. 43) --------------------------------------------- // // WICHTIG -- der Unterschied zwischen den beiden: Sparraten, Amortisationen und -// Zusatzinvestitionen sind UMBUCHUNGEN. Sie verlassen das Cash und erhoehen im selben Zug -// einen Vermoegenswert bzw. senken eine Schuld; das Vermoegen bleibt unveraendert. Sie -// gehoeren deshalb in die Cash-Bruecke und ausdruecklich NICHT in die Vermoegens-Bruecke -- -// dort als Abgang gezeichnet wuerden sie eine Vermoegensminderung suggerieren, die es nicht +// Zusatzinvestitionen sind UMBUCHUNGEN. Sie verlassen das Cash und erhöhen im selben Zug +// einen Vermögenswert bzw. senken eine Schuld; das Vermögen bleibt unverändert. Sie +// gehören deshalb in die Cash-Brücke und ausdrücklich NICHT in die Vermögens-Brücke -- +// dort als Abgang gezeichnet würden sie eine Vermögensminderung suggerieren, die es nicht // gibt. export interface WealthBridge { - openingWealth: number; // Endvermoegen der Vorphase (Phase 1: Startvermoegen) - // Uebergang IN diese Phase -- echte Vermoegensaenderungen an der Phasengrenze: + openingWealth: number; // Endvermögen der Vorphase (Phase 1: Startvermögen) + // Übergang IN diese Phase -- echte Vermögensänderungen an der Phasengrenze: oneOffInflow: number; // einmaliger Zufluss (netto nach Steuer) oneOffOutflow: number; // einmalige Kosten - transitionTax: number; // Kapitalbezugs- und Grundstueckgewinnsteuer (Abgang) - pensionConversion: number; // in eine Rente umgewandeltes PK-Kapital (verlaesst die Bilanz) + transitionTax: number; // Kapitalbezugs- und Grundstückgewinnsteuer (Abgang) + pensionConversion: number; // in eine Rente umgewandeltes PK-Kapital (verlässt die Bilanz) saleGainLoss: number; // Verkaufspreis minus Verkehrswert bei einem Immobilienverkauf startWealth: number; // Kontrollpunkt = startWealthNominal // Innerhalb der Phase: - quotaTotal: number; // Summe (Einkommen - nominale Ausgaben) ueber alle Phasenjahre - investmentReturn: number; // Rendite auf PK/3a/Sonstigem Vermoegen + quotaTotal: number; // Summe (Einkommen - nominale Ausgaben) über alle Phasenjahre + investmentReturn: number; // Rendite auf PK/3a/Sonstigem Vermögen propertyAppreciation: number; // Wertsteigerung der Liegenschaft - pensionFundContribution: number; // PK-Beitraege: erhoehen das Vermoegen, ohne Cash zu kosten + pensionFundContribution: number; // PK-Beiträge: erhöhen das Vermögen, ohne Cash zu kosten endWealth: number; // = endWealthNominal - residual: number; // Rundungsdifferenz (Kontrollgroesse, sollte nahe 0 sein) + residual: number; // Rundungsdifferenz (Kontrollgrösse, sollte nahe 0 sein) } export interface CashBridge { openingCash: number; // Cash-Endbestand der Vorphase (Phase 1: initialCash) - capitalInflow: number; // Verkaeufe + PK-/3a-Bezuege aus dem Uebergang + capitalInflow: number; // Verkäufe + PK-/3a-Bezüge aus dem Übergang oneOffInflow: number; immediateRepay: number; // Sofort-Tilgungen und Sonderamortisation (Abgang) oneOffOutflow: number; investments: number; // Zusatz-/Neuinvestitionen am Phasenanfang (Abgang) cashStart: number; // Kontrollpunkt quotaTotal: number; - savingRates: number; // 3a + Sparbeitraege (Abgang) + savingRates: number; // 3a + Sparbeiträge (Abgang) debtRates: number; // Amortisationen + Tilgungen (Abgang) - withdrawals: number; // Bezugsraten aus Sonstigem Vermoegen (Zugang) + withdrawals: number; // Bezugsraten aus Sonstigem Vermögen (Zugang) cashEnd: number; residual: number; } @@ -143,15 +143,15 @@ export interface PhaseComputed { quotaStart: number; quotaEnd: number; isConsumption: boolean; - // Geplante Sparrate im ERSTEN Phasenjahr: 3a + Sonstiges-Vermoegen-Sparbeitrag + Amort. + + // Geplante Sparrate im ERSTEN Phasenjahr: 3a + Sonstiges-Vermögen-Sparbeitrag + Amort. + // Tilgung. Amortisation/Tilgung entfallen, sobald Hypothek/Schuld abbezahlt sind -- die Rate - // kann in spaeteren Phasenjahren also tiefer liegen. + // kann in späteren Phasenjahren also tiefer liegen. plannedSaveRate: number; - plannedWithdrawRate: number; // geplante Verzehrrate: Bezugsraten aus Sonstigem Vermoegen - capitalInflow: number; // Kapitalzufluss: PK-/3a-Bezuege + Verkaeufe (aus dem Uebergang in diese Phase) + plannedWithdrawRate: number; // geplante Verzehrrate: Bezugsraten aus Sonstigem Vermögen + capitalInflow: number; // Kapitalzufluss: PK-/3a-Bezüge + Verkäufe (aus dem Übergang in diese Phase) capitalInvest: number; // Kapitalinvestitionen: Zusatz-/Neuinvestitionen + sofortige Tilgungen - // Einmalige Sonderein-/ausgaben aus dem Uebergang in DIESE Phase (nominal, netto nach Steuer). - // Bewusst getrennt von capitalInflow/capitalInvest: eine Erbschaft ist kein Verkaufserloes, + // Einmalige Sonderein-/ausgaben aus dem Übergang in DIESE Phase (nominal, netto nach Steuer). + // Bewusst getrennt von capitalInflow/capitalInvest: eine Erbschaft ist kein Verkaufserlös, // ein Poolbau keine Kapitalinvestition. oneOffInflow: number; oneOffInflowLabel: string | null; @@ -159,29 +159,29 @@ export interface PhaseComputed { oneOffOutflowLabel: string | null; cashStart: number; cashEnd: number; - cashNegative: boolean; // Cash faellt in dieser Phase (irgendwann) unter 0 -> Liquiditaetsluecke - incomplete: boolean; // roter Status = Liquiditaetsluecke + cashNegative: boolean; // Cash fällt in dieser Phase (irgendwann) unter 0 -> Liquiditätslücke + incomplete: boolean; // roter Status = Liquiditätslücke elements: ElementPhaseComputed[]; startWealthNominal: number; // inkl. Cash endWealthNominal: number; // inkl. Cash cumulativeInflationStart: number; // Kaufkraft-Deflator zu Phasenbeginn (Bestandswerte) cumulativeInflationEnd: number; // Kaufkraft-Deflator am Phasenende (Bestandswerte) - flowDeflatorEnd: number; // Deflator fuer den Flow-Endwert (Jahr `duration`) + flowDeflatorEnd: number; // Deflator für den Flow-Endwert (Jahr `duration`) endWealthReal: number; wealthBridge: WealthBridge; cashBridge: CashBridge; traces?: Trace[]; // Rechenwege der Phasen-Kennzahlen (nur mit explain) } -// Ein Datenpunkt pro Jahr (ueber alle Phasen), fuer die Verlaufsgrafik. +// Ein Datenpunkt pro Jahr (über alle Phasen), für die Verlaufsgrafik. export interface YearPoint { year: number; // 1-basiert ab Planbeginn age: number; // Alter Person A am Jahresende income: number; // Einkommen inkl. Renten (nominal) expenseNominal: number; expenseReal: number; - // Gesamtvermoegen am Jahresende (inkl. Cash, abzgl. Schulden) -- fuer den - // Vermoegensverlauf ueber ALLE Jahre statt nur ueber die Phasengrenzen. + // Gesamtvermögen am Jahresende (inkl. Cash, abzgl. Schulden) -- für den + // Vermögensverlauf über ALLE Jahre statt nur über die Phasengrenzen. wealthNominal: number; wealthReal: number; } @@ -190,8 +190,8 @@ export interface PlanComputed { phases: PhaseComputed[]; yearly: YearPoint[]; nachlass: number; - ruinAge: number | null; // Alter (Person A), in dem das Gesamtvermoegen (inkl. Cash) erstmals < 0 faellt - ahvCareer: AhvCareer[]; // Beitragskarriere je Person (fuer die AHV-Pruefung am Uebergang) + ruinAge: number | null; // Alter (Person A), in dem das Gesamtvermögen (inkl. Cash) erstmals < 0 fällt + ahvCareer: AhvCareer[]; // Beitragskarriere je Person (für die AHV-Prüfung am Übergang) traces?: Trace[]; // plan-weite Rechenwege: Deflatoren, AHV-Karriere, Ruinalter (nur mit explain) } @@ -203,7 +203,7 @@ export interface PlanComputed { // mdJE >= 72 x R0 -> Maximalrente 2 x R0 // Verifiziert gegen die amtliche Tabelle 318.117.1 (51/51 Zeilen exakt, siehe Tests). // Bewusst STETIG gerechnet: die amtliche Tabelle stuft das mdJE auf Vielfache von 1.2 x R0; -// fuer eine Planung ist der stetige Wert naeher an der Wahrheit (Abweichung < 20/Monat). +// für eine Planung ist der stetige Wert näher an der Wahrheit (Abweichung < 20/Monat). export function ahvMonthlyFullPension(mdJE: number): number { const r0 = AHV_MIN_MONTHLY_FULL; if (mdJE <= 12 * r0) return r0; @@ -212,20 +212,20 @@ export function ahvMonthlyFullPension(mdJE: number): number { return mdJE <= 36 * r0 ? r0 * (0.74 + 0.26 * x) : r0 * (1.04 + 0.16 * x); } -// Beitragskarriere einer Person fuer die AHV -- akkumuliert ueber die Erwerbsphasen des Plans. +// Beitragskarriere einer Person für die AHV -- akkumuliert über die Erwerbsphasen des Plans. // ACHTUNG: Alle Einkommen sind BRUTTO. Das Tool erfasst netto (so stimmt der Cash-Fluss), die // AHV bemisst sich aber am Bruttolohn -- die Umrechnung passiert beim Aufbau der Karriere. export interface AhvCareer { personId: string; role: PersonRole; plannedAvgGrossIncome: number; // reales BRUTTO-Durchschnittseinkommen der Beitragsjahre im Plan - planYears: number; // Beitragsjahre im Plan (Dauer abzueglich Ausfalljahre) + planYears: number; // Beitragsjahre im Plan (Dauer abzüglich Ausfalljahre) yearsBeforePlan: number; // Jahre zwischen Alter 21 und Planbeginn gapYearsInPlan: number; } // Massgebendes durchschnittliches Jahreseinkommen: gewichteter Mittelwert der realen -// Einkommen ueber ALLE Beitragsjahre (vor Planbeginn + im Plan). REAL gerechnet, weil die +// Einkommen über ALLE Beitragsjahre (vor Planbeginn + im Plan). REAL gerechnet, weil die // AHV vergangene Einkommen aufwertet UND die Schwellen indexiert -- beides hebt sich in // realer Betrachtung weitgehend auf. // `avgGrossIncomeBefore` ist -- wie die Karriere -- ein BRUTTO-Wert. @@ -236,7 +236,7 @@ export function ahvMdje(career: AhvCareer, avgGrossIncomeBefore: number, gapYear return (avgGrossIncomeBefore * yearsBefore + career.plannedAvgGrossIncome * career.planYears) / totalYears; } -// Jaehrliche AHV-Rente: Vollrente zum mdJE, mal 13 Zahlungen, gekuerzt um die Ausfalljahre +// Jährliche AHV-Rente: Vollrente zum mdJE, mal 13 Zahlungen, gekürzt um die Ausfalljahre // (Rentenskala: pro fehlendes Beitragsjahr 1/44). export function ahvAnnualPension(mdJE: number, totalGapYears: number): number { const factor = Math.max( @@ -251,7 +251,7 @@ export function ahvYearsBeforePlan(ageAtPlanStart: number): number { return Math.max(0, ageAtPlanStart - AHV_CONTRIBUTION_START_AGE); } -// Maximale Dauer einer neuen Phase bis zum naechsten Pensionsereignis (null = unbegrenzt). +// Maximale Dauer einer neuen Phase bis zum nächsten Pensionsereignis (null = unbegrenzt). export function maxPhaseDuration( persons: { role: PersonRole; age: number; retirementAge: number }[], yearsBefore: number @@ -266,13 +266,13 @@ export function maxPhaseDuration( interface Carry { status: ElementStatus; - value: number; // Aktiven-Saldo (PK/3a/Sonstiges Vermoegen) am Ende der Vorphase + value: number; // Aktiven-Saldo (PK/3a/Sonstiges Vermögen) am Ende der Vorphase mortgage: number; // Immobilie: Resthypothek propertyValue: number; // Immobilie: Verkehrswert am Ende der Vorphase (inkl. Wertsteigerung) - propertyPurchase: number; // Immobilie: urspruenglicher Kaufpreis (Basis der Grundstueckgewinnsteuer) + propertyPurchase: number; // Immobilie: ursprünglicher Kaufpreis (Basis der Grundstückgewinnsteuer) owed: number; // Schulden: Restschuld (positiv) - pkPensionAnnual: number; // PK: jaehrliche Rente nach Verrentung - flowBasis: number; // Einkommen/Ausgaben: indexierter Basiswert der naechsten Phase + pkPensionAnnual: number; // PK: jährliche Rente nach Verrentung + flowBasis: number; // Einkommen/Ausgaben: indexierter Basiswert der nächsten Phase hasCarry: boolean; } @@ -296,12 +296,12 @@ function fmt(v: number): string { return sign + Math.abs(rounded).toString().replace(/\B(?=(\d{3})+(?!\d))/g, "'"); } -// Generisch, damit der Aufrufer den vollen Personen-Typ (inkl. age) behaelt. +// Generisch, damit der Aufrufer den vollen Personen-Typ (inkl. age) behält. function personByRole(persons: T[], role: string): T | null { return persons.find((p) => p.role === role) ?? null; } -// Ein Zufalls-Szenario fuer die Monte-Carlo-Simulation: liefert je Jahr eine Inflation und +// Ein Zufalls-Szenario für die Monte-Carlo-Simulation: liefert je Jahr eine Inflation und // je Element/Jahr eine Rendite. Ohne Sample rechnet computePlan rein deterministisch (die // geplanten Annahmen), mit Sample einen einzelnen simulierten Pfad. Jahr ist 1-basiert // (ab Planbeginn); der Inflations-Index ist 0-basiert (inflation[0] = Jahr 1). @@ -310,7 +310,7 @@ export interface PlanSample { assetReturn: (elementId: string, year: number) => number; } -// Kurzform fuer einen Rechenschritt. `pct` formatiert Prozentsaetze lesbar (2 statt 2.0000001). +// Kurzform für einen Rechenschritt. `pct` formatiert Prozentsätze lesbar (2 statt 2.0000001). function st( label: string, result: number, @@ -336,7 +336,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp // Kumulierter Inflations-Deflator je Jahr (cumInfl[0] = 1, cumInfl[k] = Kaufkraftfaktor nach // k Jahren). Deterministisch identisch zur bisherigen (1+infl)^k-Formel; mit Sample variiert - // die Inflation pro Jahr. Ersetzt die frueheren geschlossenen Potenz-Ausdruecke. + // die Inflation pro Jahr. Ersetzt die früheren geschlossenen Potenz-Ausdrücke. const totalYears = phases.reduce((s, p) => s + Math.max(1, p.durationYears), 0); const inflationOfYear = (year: number) => sample ? sample.inflation[year - 1] ?? plan.inflationRateDefault : plan.inflationRateDefault; @@ -347,7 +347,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp // AHV-Beitragskarriere je Person: reales Einkommen x Beitragsjahre, und Beitragsjahre. const ahvIncomeAccum = new Map(); const ahvYearsAccum = new Map(); - // Karriere VOR Planbeginn -- aus der Pruefung am Pensions-Uebergang bzw. (fuer bereits + // Karriere VOR Planbeginn -- aus der Prüfung am Pensions-Übergang bzw. (für bereits // Pensionierte) aus der Phasenzelle der ersten Phase. const ahvBeforeByPerson = new Map(); const carries = new Map(); @@ -358,18 +358,18 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp let yearsBefore = 0; let cumulativeInflation = 1; let cashCarryIn = Math.round(plan.initialCash || 0); - // Aus dem Uebergang der Vorphase in DIESE Phase fliessende Groessen (Kopf-Kennzahlen). - let incomingInflow = 0; // Brutto-Zufluss: Verkaeufe + PK-/3a-Bezuege + // Aus dem Übergang der Vorphase in DIESE Phase fliessende Grössen (Kopf-Kennzahlen). + let incomingInflow = 0; // Brutto-Zufluss: Verkäufe + PK-/3a-Bezüge let incomingImmediateRepay = 0; // sofortige Schuldentilgungen (Abfluss) let incomingOneOffInflow = 0; // einmaliger Sonderzufluss (netto nach Steuer) let incomingOneOffInflowLabel: string | null = null; let incomingOneOffOutflow = 0; // einmalige Sonderkosten (nominal) let incomingOneOffOutflowLabel: string | null = null; - // Echte Vermoegensaenderungen am Uebergang IN die Folgephase (fuer die Vermoegens-Bruecke). - let incomingTax = 0; // Kapitalbezugs- + Grundstueckgewinnsteuer + // Echte Vermögensänderungen am Übergang IN die Folgephase (für die Vermögens-Brücke). + let incomingTax = 0; // Kapitalbezugs- + Grundstückgewinnsteuer let incomingPensionConversion = 0; // in eine Rente umgewandeltes PK-Kapital let incomingSaleGainLoss = 0; // Verkaufspreis minus Verkehrswert - let previousEndWealth = 0; // Endvermoegen der Vorphase (Startpunkt der Bruecke) + let previousEndWealth = 0; // Endvermögen der Vorphase (Startpunkt der Brücke) let previousCashEnd = Math.round(plan.initialCash || 0); let ruinAge: number | null = null; @@ -402,7 +402,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp const maxDurationYears = capsFromWorking.length > 0 ? Math.min(...capsFromWorking) : null; const workingByPerson = new Map(personInfos.map((p) => [p.personId, p.working])); - // Ausfalljahre kumulieren (nur waehrend der Erwerbstaetigkeit). + // Ausfalljahre kumulieren (nur während der Erwerbstätigkeit). const gapThisPhase = new Map(); for (const e of plan.elements) { if (e.category !== "AHV" || !e.ownerRole) continue; @@ -413,13 +413,13 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp gapYearsByPerson.set(owner.id, (gapYearsByPerson.get(owner.id) ?? 0) + gy); } - // Bereits bei Planbeginn pensioniert: es gibt keinen Pensions-Uebergang, an dem die - // Beitragskarriere geprueft werden koennte -- die Werte liegen dann in der Phasenzelle. + // Bereits bei Planbeginn pensioniert: es gibt keinen Pensions-Übergang, an dem die + // Beitragskarriere geprüft werden könnte -- die Werte liegen dann in der Phasenzelle. for (const e of plan.elements) { if (e.category !== "AHV" || !e.ownerRole) continue; const owner = personByRole(persons, e.ownerRole); if (!owner || workingByPerson.get(owner.id)) continue; - if (ahvBeforeByPerson.has(owner.id)) continue; // aus dem Uebergang bereits gesetzt + if (ahvBeforeByPerson.has(owner.id)) continue; // aus dem Übergang bereits gesetzt const pd = e.phaseValues[phase.id] ?? {}; ahvBeforeByPerson.set(owner.id, { avg: num(pd.avgIncomeBefore), @@ -427,7 +427,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp }); } - // AHV-Renten der Pensionierten: Vollrente zum mdJE, gekuerzt um die Ausfalljahre. + // AHV-Renten der Pensionierten: Vollrente zum mdJE, gekürzt um die Ausfalljahre. const ahvUncapped = new Map(); for (const e of plan.elements) { if (e.category !== "AHV" || !e.ownerRole) continue; @@ -446,16 +446,16 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp if (sum > cap && sum > 0) for (const [pid, v] of ahvUncapped) ahvFinal.set(pid, Math.round(v * (cap / sum))); } - // --- Element-Laufzeitzustaende aufbauen --- + // --- Element-Laufzeitzustände aufbauen --- const orderedElements = [...plan.elements].sort((a, b) => a.orderIndex - b.orderIndex); const ecById = new Map(); - // Reales Durchschnittseinkommen dieser Phase je Person (fuer die AHV-Karriere). + // Reales Durchschnittseinkommen dieser Phase je Person (für die AHV-Karriere). const phaseRealIncomeByPerson = new Map(); const incomes: { basis: number; idx: number; ec: ElementPhaseComputed }[] = []; const expenses: { basis: number; idx: number; ec: ElementPhaseComputed }[] = []; let renteTotal = 0; // AHV + PK-Renten (nominal fix) - // `isPk` fuer die Vermoegens-Bruecke: PK-Beitraege verlassen das Cash NICHT (sie sind im - // Nettolohn bereits abgezogen), erhoehen aber das Vermoegen -- sie sind also ein echter + // `isPk` für die Vermögens-Brücke: PK-Beiträge verlassen das Cash NICHT (sie sind im + // Nettolohn bereits abgezogen), erhöhen aber das Vermögen -- sie sind also ein echter // Zugang und keine Umbuchung. const assets: { value: number; @@ -467,7 +467,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp }[] = []; // mortgage/owed sind LAUFENDE Salden: sie werden in der Jahresschleife abgebaut und am // Nullpunkt gestoppt (keine Rate mehr, sobald abbezahlt). `value` ist der Verkehrswert der - // Liegenschaft (waechst mit valueGrowth), `purchase` der urspruengliche Kaufpreis. + // Liegenschaft (wächst mit valueGrowth), `purchase` der ursprüngliche Kaufpreis. const realEstates: { value: number; purchase: number; @@ -479,8 +479,8 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp ec: ElementPhaseComputed; }[] = []; const debts: { owed: number; repay: number; ec: ElementPhaseComputed }[] = []; - let fixedRatesTotal = 0; // Sparraten mit konstantem Jahresbetrag: 3a + Sonstiges Vermoegen - let plannedWithdrawTotal = 0; // Bezugsraten (fliessen ins Cash): Sonstiges Vermoegen + let fixedRatesTotal = 0; // Sparraten mit konstantem Jahresbetrag: 3a + Sonstiges Vermögen + let plannedWithdrawTotal = 0; // Bezugsraten (fliessen ins Cash): Sonstiges Vermögen let investmentsFromCash = 0; // Neuinvestitionen/Aufstockungen (ab Phase 2, aus Cash) let wealthStart = 0; let wealthEnd = 0; // wird nach der Jahresschleife gefuellt @@ -524,10 +524,10 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp switch (e.category) { case "INCOME": case "EXPENSE": { - // Einkommen: Basis + Rate sind NOMINAL (Lohnerhoehung). Ausgaben: Basis + Rate sind + // Einkommen: Basis + Rate sind NOMINAL (Lohnerhöhung). Ausgaben: Basis + Rate sind // REAL (heutige Kaufkraft; reale Mehrausgaben) -- die Inflation kommt separat dazu. // Rate-Default = 0 %. Basiswert ab Phase 2 = fortgeschriebener Wert der Vorphase - // (nominal fuer Einkommen, real fuer Ausgaben), ausser bewusst geaendert (pd.amount). + // (nominal für Einkommen, real für Ausgaben), ausser bewusst geändert (pd.amount). const idx = num(pd.teuerungsausgleich, 0); ec.baseValue = carry.hasCarry ? Math.round(carry.flowBasis) : Math.round(num(pd.amount)); const basis = !carry.hasCarry @@ -537,15 +537,15 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp : ec.baseValue; (e.category === "INCOME" ? incomes : expenses).push({ basis, idx, ec }); - // AHV: reales Erwerbseinkommen der Person mitfuehren. Nur Einkommen, die einer - // Person zugeordnet sind -- bei einem Einzelplan zaehlt "Gemeinsam" zur Person A. + // AHV: reales Erwerbseinkommen der Person mitführen. Nur Einkommen, die einer + // Person zugeordnet sind -- bei einem Einzelplan zählt "Gemeinsam" zur Person A. // Das Feld ist NETTO erfasst; die AHV bemisst sich am Bruttolohn -> hochrechnen. if (e.category === "INCOME" && ownerWorking) { const attributed = owner ?? (plan.householdType === "SINGLE" && e.ownerRole === "HOUSEHOLD" ? personA : null); if (attributed) { - // Die AHV-Karriere ist eine Real-Groesse auf Planungsbasis -- bewusst mit der - // festen Plan-Inflation, nicht der (evtl. gewuerfelten) Sample-Inflation. + // Die AHV-Karriere ist eine Real-Grösse auf Planungsbasis -- bewusst mit der + // festen Plan-Inflation, nicht der (evtl. gewürfelten) Sample-Inflation. const avgRealGross = avgRealFlow(basis, idx, plan.inflationRateDefault, duration, cumInflStart) * AHV_GROSS_FROM_NET_FACTOR; phaseRealIncomeByPerson.set( @@ -555,7 +555,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp } } - // Basiswert der Folgephase fortschreiben (nominal fuer Einkommen, real fuer Ausgaben). + // Basiswert der Folgephase fortschreiben (nominal für Einkommen, real für Ausgaben). carry.flowBasis = basis * Math.pow(1 + idx / 100, duration); break; } @@ -579,13 +579,13 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp ec.endValue = carry.pkPensionAnnual; ec.summary = `Rente ${fmt(carry.pkPensionAnnual)}`; } else if (!ownerWorking) { - ec.note = "Vollstaendig bezogen"; + ec.note = "Vollständig bezogen"; ec.summary = "Bezogen"; } else { const base = carry.hasCarry ? carry.value : Math.round(num(pd.currentValue)); const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0; const start = base + topUp; - const rate = Math.round(num(pd.annualContribution)); // PK-Beitrag zaehlt NICHT zur Quote + const rate = Math.round(num(pd.annualContribution)); // PK-Beitrag zählt NICHT zur Quote if (!isFirstPhase) investmentsFromCash += topUp; ec.baseValue = base; ec.startValue = start; @@ -596,7 +596,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp } case "PILLAR_3A": { if (!ownerWorking) { - ec.note = "Vollstaendig bezogen"; + ec.note = "Vollständig bezogen"; ec.summary = "Bezogen"; } else { const base = carry.hasCarry ? carry.value : Math.round(num(pd.currentValue)); @@ -628,8 +628,8 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp break; } case "REAL_ESTATE": { - // Urspruenglicher Kaufpreis (Basis der Grundstueckgewinnsteuer) und Verkehrswert - // (waechst mit der Wertsteigerung) laufen getrennt. + // Ursprünglicher Kaufpreis (Basis der Grundstückgewinnsteuer) und Verkehrswert + // (wächst mit der Wertsteigerung) laufen getrennt. const purchase = carry.hasCarry ? carry.propertyPurchase : Math.round(num(pd.purchasePrice)); const valueStart = carry.hasCarry ? carry.propertyValue : Math.round(num(pd.purchasePrice)); const mortgageStart = carry.hasCarry ? carry.mortgage : Math.round(num(pd.mortgage)); @@ -647,7 +647,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp amort, growth: num(pd.valueGrowth), interestRate: num(pd.interestRate), - // Default INCLUDED: bestehende Plaene haben die Zinsen in den Ausgaben -> nicht + // Default INCLUDED: bestehende Pläne haben die Zinsen in den Ausgaben -> nicht // nochmals abziehen. Nur bei bewusstem "ADD" rechnet das Tool sie dazu. addInterest: pd.interestHandling === "ADD", ec, @@ -677,9 +677,9 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp ahvYearsAccum.set(p.id, (ahvYearsAccum.get(p.id) ?? 0) + contribYears); } - // --- Jahr-fuer-Jahr: indexierte Flows, Cash-Ausgleich, Verzinsung, Ruin --- + // --- Jahr-für-Jahr: indexierte Flows, Cash-Ausgleich, Verzinsung, Ruin --- // Investitionen dieser Phase werden am Phasenanfang abgezogen -> der Cash-Startwert - // zeigt den Bestand NACH den Investitionen (kein Doppelzaehlen mit dem Vermoegen). + // zeigt den Bestand NACH den Investitionen (kein Doppelzählen mit dem Vermögen). let cash = cashCarryIn - (isFirstPhase ? 0 : investmentsFromCash); const cashStart = cash; let cashNegative = cash < 0; @@ -689,8 +689,8 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp let expenseEnd = 0; let quotaStart = 0; let quotaEnd = 0; - let plannedSaveRate = 0; // Kopf-Kennzahl: die tatsaechliche Sparrate im ersten Phasenjahr - // Summanden der beiden Wasserfall-Bruecken (ueber alle Phasenjahre kumuliert). + let plannedSaveRate = 0; // Kopf-Kennzahl: die tatsächliche Sparrate im ersten Phasenjahr + // Summanden der beiden Wasserfall-Brücken (über alle Phasenjahre kumuliert). let quotaTotal = 0; let investmentReturnTotal = 0; let propertyAppreciationTotal = 0; @@ -700,7 +700,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp let withdrawalsTotal = 0; for (let t = 1; t <= duration; t++) { - // Einkommen: nominal (Basis x (1+Lohnerhoehung)^(t-1)) + Renten (nominal fix). + // Einkommen: nominal (Basis x (1+Lohnerhöhung)^(t-1)) + Renten (nominal fix). let incomeFlow = renteTotal; for (const inc of incomes) incomeFlow += inc.basis * Math.pow(1 + inc.idx / 100, t - 1); // Ausgaben: real (Basis x (1+reale Mehrausgabe)^(t-1)); nominal = real x kumul. Inflation. @@ -709,7 +709,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp for (const exp of expenses) expenseRealBase += exp.basis * Math.pow(1 + exp.idx / 100, t - 1); // Hypothekarzins: NOMINAL aus der Restschuld zu Jahresbeginn -- nicht mit der Inflation - // hochrechnen. Zaehlt zu den Ausgaben (und damit in die Quote), sofern nicht bereits + // hochrechnen. Zählt zu den Ausgaben (und damit in die Quote), sofern nicht bereits // im Ausgaben-Element enthalten. let interestNominal = 0; for (const re of realEstates) { @@ -721,8 +721,8 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp const expenseReal = expenseRealBase + interestNominal / (inflFactor || 1); const quote = incomeFlow - expenseNominal; - // Der Vermoegenswert wird erst nach Verzinsung/Cash-Fortschreibung bekannt und weiter - // unten nachgetragen (gleiche Groesse, die auch die Ruin-Erkennung verwendet). + // Der Vermögenswert wird erst nach Verzinsung/Cash-Fortschreibung bekannt und weiter + // unten nachgetragen (gleiche Grösse, die auch die Ruin-Erkennung verwendet). const yearPoint: YearPoint = { year: yearsBefore + t, age: personA.age + yearsBefore + t, @@ -745,11 +745,11 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp quotaEnd = quote; } - // Vermoegen verzinsen + Sparbeitrag; Bezugsrate entnehmen (gekappt am Bestand) und ins Cash. + // Vermögen verzinsen + Sparbeitrag; Bezugsrate entnehmen (gekappt am Bestand) und ins Cash. let cashFromWithdraw = 0; for (const a of assets) { const r = sample ? sample.assetReturn(a.ec.elementId, yearsBefore + t) : a.r; - // Arithmetik unveraendert lassen; die Rendite wird als Differenz abgeleitet, damit + // Arithmetik unverändert lassen; die Rendite wird als Differenz abgeleitet, damit // die Protokollierung das Ergebnis nicht (per Gleitkomma-Umformung) verschiebt. const grown = a.value * (1 + r / 100) + a.rate; const growth = grown - a.rate - a.value; @@ -761,7 +761,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp } // Amortisation/Tilgung: nur so lange und so viel, wie noch Restschuld besteht. Ist die - // Hypothek/Schuld abbezahlt, entfaellt die Rate -- sie belastet weder Cash noch Sparquote. + // Hypothek/Schuld abbezahlt, entfällt die Rate -- sie belastet weder Cash noch Sparquote. let debtRates = 0; for (const re of realEstates) { const pay = Math.min(re.amort, re.mortgage); @@ -771,7 +771,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp // Hebel: 1 % von 1 Mio sind 10'000, also 10 % eines Eigenkapitals von 100'000. const g = sample ? sample.assetReturn(re.ec.elementId, yearsBefore + t) : re.growth; const valueBefore = re.value; - re.value *= 1 + g / 100; // Arithmetik unveraendert + re.value *= 1 + g / 100; // Arithmetik unverändert propertyAppreciationTotal += re.value - valueBefore; } for (const d of debts) { @@ -790,7 +790,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp withdrawalsTotal += cashFromWithdraw; // Verlaufspunkt je Element (Detailansicht). Bewusst NACH Verzinsung und Tilgung, also - // der Stand am Jahresende -- konsistent zum Gesamtvermoegen im YearPoint. + // der Stand am Jahresende -- konsistent zum Gesamtvermögen im YearPoint. const age = personA.age + yearsBefore + t; const yr = yearsBefore + t; for (const inc of incomes) { @@ -821,7 +821,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp } } - // Gesamtvermoegen zum Jahresende t (fuer Ruin-Erkennung). + // Gesamtvermögen zum Jahresende t (für Ruin-Erkennung). let total = cash; for (const a of assets) total += a.value; for (const re of realEstates) total += re.value - re.mortgage; @@ -831,7 +831,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp if (ruinAge === null && total < 0) ruinAge = personA.age + yearsBefore + t; } - // Flow-Deflator fuer den Endwert (Jahr `duration`): eine Kaufkraft-Stufe weniger als der + // Flow-Deflator für den Endwert (Jahr `duration`): eine Kaufkraft-Stufe weniger als der // Bestands-Deflator am Phasenende. const flowDeflatorEnd = cumInfl[yearsBefore + duration - 1]; @@ -1007,7 +1007,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp }; } - // AHV-/PK-Renten: der Rechenweg der Rentenhoehe selbst liegt auf Plan-Ebene (mdJE). + // AHV-/PK-Renten: der Rechenweg der Rentenhöhe selbst liegt auf Plan-Ebene (mdJE). for (const e of orderedElements) { const ec = ecById.get(e.id)!; if (ec.trace || ec.startValue === 0) continue; @@ -1232,19 +1232,19 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp previousEndWealth = endWealthNominal; previousCashEnd = cashEnd; - // --- Uebergang: Carry aktualisieren, Cash der Folgephase bilden --- + // --- Übergang: Carry aktualisieren, Cash der Folgephase bilden --- let txInflow = 0; let txImmediateRepay = 0; - // Echte Vermoegensaenderungen an dieser Grenze (fuer die Bruecke der Folgephase). - // Verkaeufe, Bezuege und Tilgungen sind fuer sich Umbuchungen -- vermoegenswirksam sind - // nur die Steuer, die Verrentung (Kapital verlaesst die Bilanz) und die Differenz + // Echte Vermögensänderungen an dieser Grenze (für die Brücke der Folgephase). + // Verkäufe, Bezüge und Tilgungen sind für sich Umbuchungen -- vermögenswirksam sind + // nur die Steuer, die Verrentung (Kapital verlässt die Bilanz) und die Differenz // zwischen Verkaufspreis und Verkehrswert. let txTax = 0; let txPensionConversion = 0; let txSaleGainLoss = 0; // Einmalige Sonderein-/ausgaben auf dem Cash-Konto. Nur sinnvoll, wenn eine Folgephase - // existiert -- nach der letzten Phase gibt es keinen Uebergang. Der Wechselkurs zwischen + // existiert -- nach der letzten Phase gibt es keinen Übergang. Der Wechselkurs zwischen // real und nominal ist an dieser Grenze `cumulativeInflation` (Bestands-Deflator am // Phasenende), denn Cash ist ein Bestand. let txOneOffInflow = 0; @@ -1281,14 +1281,14 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp continue; } - // AHV: beim Pensions-Uebergang die geprueften Karriere-Werte vor Planbeginn uebernehmen. + // AHV: beim Pensions-Übergang die geprüften Karriere-Werte vor Planbeginn übernehmen. if (e.category === "AHV") { if (ownerRetiresNext && owner) { const career = buildCareer(owner, ahvIncomeAccum, ahvYearsAccum, gapYearsByPerson); ahvBeforeByPerson.set(owner.id, { - // Ohne erfassten Wert gilt der geplante Durchschnitt als Schaetzung fuer die Jahre - // vor Planbeginn -- exakt der Wert, den der Pruef-Dialog vorbelegt. Ein Fallback auf - // 0 wuerde die Rente still und massiv zu tief rechnen. Beide Werte sind BRUTTO. + // Ohne erfassten Wert gilt der geplante Durchschnitt als Schätzung für die Jahre + // vor Planbeginn -- exakt der Wert, den der Prüf-Dialog vorbelegt. Ein Fallback auf + // 0 würde die Rente still und massiv zu tief rechnen. Beide Werte sind BRUTTO. avg: num(td.avgIncomeBefore, career.plannedAvgGrossIncome), gap: Math.max(0, Math.round(num(td.gapYearsBefore))), }); @@ -1330,7 +1330,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp carry.value = 0; } } else { - // Vorbezug (z. B. Wohneigentum/Selbststaendigkeit): ebenfalls kapitalbezugssteuerpflichtig. + // Vorbezug (z. B. Wohneigentum/Selbstständigkeit): ebenfalls kapitalbezugssteuerpflichtig. // Das Kapital wird brutto entnommen, netto (nach Steuer) fliesst es ins Cash. const withdrawal = Math.min(ec.endValue, Math.round(num(td.withdrawal))); carry.value = ec.endValue - withdrawal; @@ -1360,7 +1360,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp txInflow += ec.endValue; carry.status = "SOLD"; } else if (td.decision === "PARTIAL") { - // Teilverkauf: Betrag fliesst ins Cash, der Rest bleibt aktiv und waechst weiter. + // Teilverkauf: Betrag fliesst ins Cash, der Rest bleibt aktiv und wächst weiter. const part = Math.min(ec.endValue, Math.round(num(td.partialSaleAmount))); txInflow += part; carry.value = ec.endValue - part; @@ -1374,14 +1374,14 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp if (!re) break; if (td.decision === "SELL") { const salePrice = Math.round(num(td.salePrice)); - // Grundstueckgewinnsteuer bemisst sich am urspruenglichen Kaufpreis, nicht am + // Grundstückgewinnsteuer bemisst sich am ursprünglichen Kaufpreis, nicht am // zwischenzeitlich gestiegenen Verkehrswert. const gain = Math.max(0, salePrice - re.purchase); const tax = gain * (num(td.saleTaxRate, DEFAULT_PROPERTY_GAINS_TAX_RATE) / 100); txInflow += Math.round(salePrice - re.mortgage - tax); txTax += Math.round(tax); // Der Verkaufspreis kann vom fortgeschriebenen Verkehrswert abweichen -- diese - // Differenz ist ein echter Vermoegensgewinn bzw. -verlust an der Grenze. + // Differenz ist ein echter Vermögensgewinn bzw. -verlust an der Grenze. txSaleGainLoss += Math.round(salePrice - re.value); carry.status = "SOLD"; } else { @@ -1409,7 +1409,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp break; } - // --- Rechenweg des Uebergangs je Element --- + // --- Rechenweg des Übergangs je Element --- if (explain && nextPhase) { const inflowHere = txInflow - txStepsBefore; const taxHere = txTax - taxBefore; @@ -1492,8 +1492,8 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp incomingPensionConversion = txPensionConversion; incomingSaleGainLoss = txSaleGainLoss; - // Rechenweg des Cash-Uebergangs (einmalige Sonderein-/ausgaben). Wird nachtraeglich an - // die Trace-Liste dieser Phase gehaengt -- `result` haelt die Referenz auf das Array. + // Rechenweg des Cash-Übergangs (einmalige Sonderein-/ausgaben). Wird nachträglich an + // die Trace-Liste dieser Phase gehängt -- `result` hält die Referenz auf das Array. if (explain && nextPhase && (txOneOffInflow !== 0 || txOneOffOutflow !== 0)) { const ct = phase.cashTransition ?? {}; const steps: TraceStep[] = []; @@ -1541,7 +1541,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp specAnchor: "442-beitragskarriere-und-mdje", steps: [ st("Beitragsjahre im Plan", career.planYears, "Summe (Phasendauer − Ausfalljahre)", undefined, undefined, "Jahre"), - st("Durchschnittliches Bruttoeinkommen im Plan (real)", Math.round(career.plannedAvgGrossIncome), `Nettolohn × ${AHV_GROSS_FROM_NET_FACTOR}`, undefined, + st("Durchschnittliches Bruttöinkommen im Plan (real)", Math.round(career.plannedAvgGrossIncome), `Nettolohn × ${AHV_GROSS_FROM_NET_FACTOR}`, undefined, "Das Tool erfasst netto, die AHV bemisst sich am Brutto. Der Faktor 1.12 ist ein Karriere-Durchschnitt und keine exakte Einzelfall-Umrechnung."), st("Beitragsjahre vor Planbeginn", Math.max(0, career.yearsBeforePlan - before.gap), "Alter bei Planbeginn − 21 − Ausfalljahre davor", undefined, undefined, "Jahre"), st("Durchschnittseinkommen vor Planbeginn (real, brutto)", Math.round(before.avg), undefined, undefined, @@ -1568,7 +1568,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp return { phases: result, yearly, nachlass, ruinAge, ahvCareer, traces: explain ? planTraces : undefined }; } -// Durchschnittliches REALES Jahreseinkommen ueber eine Phase. Nominal waechst der Flow mit +// Durchschnittliches REALES Jahreseinkommen über eine Phase. Nominal wächst der Flow mit // idx, real wird er mit der Inflation deflationiert -> geometrische Reihe mit q. function avgRealFlow(basis: number, idx: number, infl: number, duration: number, cumInflStart: number): number { const q = (1 + idx / 100) / (1 + infl / 100); @@ -1616,8 +1616,8 @@ export function planToCsv(plan: PlanInput, computed: PlanComputed): string { "Quote (Beginn)", "Quote (Ende)", "Cash (Ende)", - "Endvermoegen (nominal)", - "Endvermoegen (real)", + "Endvermögen (nominal)", + "Endvermögen (real)", ]; const rows = computed.phases.map((p) => [ p.name, diff --git a/src/lib/constants.ts b/src/lib/constants.ts index de25aae..4013a7d 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -1,9 +1,9 @@ -// Konfigurierbare Systemparameter (Stand 2026). Aendern sich periodisch durch -// Anpassungen des Bundes -- deshalb hier zentral gefuehrt, nicht im Code verteilt. +// Konfigurierbare Systemparameter (Stand 2026). Ändern sich periodisch durch +// Anpassungen des Bundes -- deshalb hier zentral geführt, nicht im Code verteilt. // R0 = Mindestbetrag der vollen monatlichen Altersrente (Skala 44). Alle Schwellen der -// Rentenformel sind Vielfache von R0 -- aendert sich R0, verschiebt sich die ganze Skala. -// Quelle: BSV/MAS "Berechnungsvorschriften der AHV/IV-Renten", gueltig ab 1.1.2026; +// Rentenformel sind Vielfache von R0 -- ändert sich R0, verschiebt sich die ganze Skala. +// Quelle: BSV/MAS "Berechnungsvorschriften der AHV/IV-Renten", gültig ab 1.1.2026; // amtliche Tabelle 318.117.1 "Monatliche Vollrenten, Skala 44". export const AHV_MIN_MONTHLY_FULL = 1260; @@ -18,48 +18,48 @@ export const AHV_CONTRIBUTION_START_AGE = 21; // Abgeleitet, damit eine Anpassung von R0 nicht an zwei Stellen nachgezogen werden muss. export const AHV_MAX_ANNUAL_SINGLE = 2 * AHV_MIN_MONTHLY_FULL * AHV_PENSION_MONTHS; -// Umrechnung Netto- -> Bruttolohn fuer die AHV. Das Tool erfasst das Einkommen NETTO (so +// Umrechnung Netto- -> Bruttolohn für die AHV. Das Tool erfasst das Einkommen NETTO (so // denkt der Nutzer, und so stimmt der Cash-Fluss), die AHV bemisst sich aber am BRUTTOlohn. // -// Herleitung (Arbeitnehmer-Abzuege in % des Bruttolohns): -// AHV/IV/EO 5.30 % (10.6 % total, haelftig geteilt) -// ALV 1.10 % ( 2.2 % total, haelftig geteilt, bis 148'200) +// Herleitung (Arbeitnehmer-Abzüge in % des Bruttolohns): +// AHV/IV/EO 5.30 % (10.6 % total, hälftig geteilt) +// ALV 1.10 % ( 2.2 % total, hälftig geteilt, bis 148'200) // NBU ~1.00 % (variiert 0.4-1.6 %, teils vom Arbeitgeber getragen) // PK ~2-5 % (BVG-Altersgutschrift 7/10/15/18 % auf den KOORDINIERTEN Lohn -// [Brutto - 26'460, max. 90'720], Arbeitnehmer >= die Haelfte) +// [Brutto - 26'460, max. 90'720], Arbeitnehmer >= die Hälfte) // -> total ~9-13 % je nach Alter und Lohn; Faktor 1/(1-q) = 1.10 bis 1.16, Mittel ~1.12. // -// Ein fixer Faktor ist hier vertretbar, weil das mdJE selbst ein Durchschnitt ueber die ganze -// Beitragskarriere (44 Jahre) ist: der altersabhaengige PK-Satz mittelt sich dabei heraus. -// Die Restunschaerfe (~3 %) ist deutlich kleiner als der Fehler, den die Umrechnung behebt (~11 %). +// Ein fixer Faktor ist hier vertretbar, weil das mdJE selbst ein Durchschnitt über die ganze +// Beitragskarriere (44 Jahre) ist: der altersabhängige PK-Satz mittelt sich dabei heraus. +// Die Restunschärfe (~3 %) ist deutlich kleiner als der Fehler, den die Umrechnung behebt (~11 %). // -// Quellen: BSV "Betraege gueltig ab 1.1.2026" (Koordinationsabzug 26'460, obere Limite 90'720, -// keine Aenderung gegenueber 2025); AHV-Merkblaetter 2.01 (AHV/IV/EO) und 2.08 (ALV). +// Quellen: BSV "Beträge gültig ab 1.1.2026" (Koordinationsabzug 26'460, obere Limite 90'720, +// keine Änderung gegenüber 2025); AHV-Merkblätter 2.01 (AHV/IV/EO) und 2.08 (ALV). export const AHV_GROSS_FROM_NET_FACTOR = 1.12; // Ehepaar-Plafonierung: die Summe beider Einzelrenten ist auf 150% der Einzel- -// Maximalrente begrenzt. Bei Ueberschreitung werden beide Renten proportional gekuerzt. +// Maximalrente begrenzt. Bei Überschreitung werden beide Renten proportional gekürzt. export const AHV_COUPLE_CAP_FACTOR = 1.5; -// Volle Beitragsdauer fuer eine ungekuerzte AHV-Rente (Rentenskala 44). Pro fehlendes -// Beitragsjahr (Ausfalljahr) wird die Rente um 1/44 gekuerzt. +// Volle Beitragsdauer für eine ungekürzte AHV-Rente (Rentenskala 44). Pro fehlendes +// Beitragsjahr (Ausfalljahr) wird die Rente um 1/44 gekürzt. export const AHV_FULL_CONTRIBUTION_YEARS = 44; -// Maximaler jaehrlicher Saeule-3a-Beitrag fuer PK-Versicherte (2026). +// Maximaler jährlicher Säule-3a-Beitrag für PK-Versicherte (2026). export const PILLAR_3A_MAX_ANNUAL = 7258; -// Default-Annahmen fuer Uebergangs-Ereignisse (pro Ereignis editierbar). Werden SOWOHL als +// Default-Annahmen für Übergangs-Ereignisse (pro Ereignis editierbar). Werden SOWOHL als // UI-Vorschlag als auch in der Berechnung als Fallback verwendet -- damit ein nicht -// angetupfter Wert nicht faelschlich als 0 gerechnet wird. -export const DEFAULT_PK_CONVERSION_RATE = 6; // % (jaehrliche Rente = Kapital x Satz) +// angetupfter Wert nicht fälschlich als 0 gerechnet wird. +export const DEFAULT_PK_CONVERSION_RATE = 6; // % (jährliche Rente = Kapital x Satz) export const DEFAULT_CAPITAL_TAX_RATE = 8; // % Kapitalbezugssteuer (PK-/3a-Kapitalbezug) -export const DEFAULT_PROPERTY_GAINS_TAX_RATE = 20; // % Grundstueckgewinnsteuer +export const DEFAULT_PROPERTY_GAINS_TAX_RATE = 20; // % Grundstückgewinnsteuer -// --- Strukturierte Fassung fuer die Systemparameter-Ansicht (Roadmap Nr. 41) ------------- +// --- Strukturierte Fassung für die Systemparameter-Ansicht (Roadmap Nr. 41) ------------- // -// Dieselben Werte wie oben, nur maschinenlesbar beschrieben: Damit kann die Oberflaeche +// Dieselben Werte wie oben, nur maschinenlesbar beschrieben: Damit kann die Oberfläche // Wert, Bedeutung, Herleitung und Quelle anzeigen, OHNE dass die Zahlen ein zweites Mal -// gepflegt werden muessen -- die Eintraege referenzieren die Konstanten direkt und koennen +// gepflegt werden müssen -- die Einträge referenzieren die Konstanten direkt und können // deshalb nicht von ihnen abdriften. export type ParameterGroup = "AHV" | "Vorsorge" | "Steuern"; @@ -74,7 +74,7 @@ export interface SystemParameter { source: string; validFrom: string; group: ParameterGroup; - editablePerEvent?: boolean; // Default-Satz, pro Ereignis ueberschreibbar + editablePerEvent?: boolean; // Default-Satz, pro Ereignis überschreibbar } export const SYSTEM_PARAMETERS: SystemParameter[] = [ diff --git a/src/lib/demoplan.ts b/src/lib/demoplan.ts new file mode 100644 index 0000000..9ac8f08 --- /dev/null +++ b/src/lib/demoplan.ts @@ -0,0 +1,96 @@ +// Beispielplan mit einem Klick (Teil von Roadmap Nr. 10). Erzeugt einen realistischen, +// vollständig ausgefuellten Plan über die BESTEHENDEN API-Endpunkte -- reine Orchestrierung, +// kein neuer Endpunkt, keine Änderung an der Berechnung. +// +// Didaktischer Entscheid: Die Übergangs-Entscheide bleiben absichtlich OFFEN. Der neue +// Nutzer sieht dadurch die Ampel ("N offen") in Aktion und lernt das wichtigste Konzept des +// Tools am Beispiel, statt es erklärt zu bekommen. +// +// WICHTIG zur Reihenfolge: erst Phase 1, dann die Elemente samt Phase-1-Werten, dann die +// Folgephase. Die Phasen-Route belegt beim Anlegen einer Phase die editierbaren Felder aus +// der Vorphase vor -- existieren die Elemente noch nicht, bleibt die Folgephase leer +// (Renditen/Beiträge wären dort 0). + +import { api } from "@/lib/api-client"; +import type { PhaseData } from "@/lib/elements"; + +async function addElement( + scenarioId: string, + firstPhaseId: string, + category: string, + name: string, + ownerRole: string, + values?: PhaseData +): Promise { + const { element } = await api.post<{ element: { id: string } }>( + `/api/scenarios/${scenarioId}/elements`, + { category, name, ownerRole } + ); + if (values && Object.keys(values).length > 0) { + await api.put(`/api/elements/${element.id}/phase/${firstPhaseId}`, values); + } +} + +// Liefert die Szenario-Id des angelegten Beispielplans. +export async function createDemoPlan(): Promise { + const currentYear = new Date().getFullYear(); + + const { scenario } = await api.post<{ plan: { id: string }; scenario: { id: string } }>( + "/api/plans", + { + name: "Beispiel: Alex Muster", + householdType: "SINGLE", + inflationRateDefault: 1.5, + startYear: currentYear, + persons: [{ role: "PERSON_A", name: "Alex", age: 45, retirementAge: 65 }], + } + ); + const sid = scenario.id; + + await api.patch(`/api/scenarios/${sid}`, { initialCash: 40000 }); + + // Phase 1: Erwerbsphase bis zur Pension (die Route kappt die Dauer ohnehin am + // Pensionsereignis; 20 Jahre = 45 -> 65). + const p1 = await api.post<{ phase: { id: string } }>(`/api/scenarios/${sid}/phases`, { + name: "Erwerbsphase", + durationYears: 20, + }); + const firstPhaseId = p1.phase.id; + + await addElement(sid, firstPhaseId, "INCOME", "Lohn Alex", "PERSON_A", { + amount: 110000, + teuerungsausgleich: 1, + }); + await addElement(sid, firstPhaseId, "EXPENSE", "Lebenshaltung", "HOUSEHOLD", { + amount: 78000, + teuerungsausgleich: 0, + }); + await addElement(sid, firstPhaseId, "AHV", "AHV Alex", "PERSON_A", { gapYears: 0 }); + await addElement(sid, firstPhaseId, "PENSION_FUND", "Pensionskasse", "PERSON_A", { + currentValue: 380000, + annualContribution: 14000, + expectedReturn: 1.5, + }); + await addElement(sid, firstPhaseId, "PILLAR_3A", "Säule 3a", "PERSON_A", { + currentValue: 75000, + annualContribution: 7000, + expectedReturn: 2, + }); + await addElement(sid, firstPhaseId, "OTHER_ASSET", "ETF-Depot", "HOUSEHOLD", { + startValue: 120000, + annualContribution: 12000, + expectedReturn: 5, + }); + await addElement(sid, firstPhaseId, "REAL_ESTATE", "Eigenheim", "HOUSEHOLD", { + purchasePrice: 900000, + mortgage: 600000, + amortization: 12000, + interestRate: 1.5, + valueGrowth: 1, + }); + + // Pensionsphase 65 -> 90. Die Route belegt die editierbaren Felder aus Phase 1 vor. + await api.post(`/api/scenarios/${sid}/phases`, { name: "Pensionsphase", durationYears: 25 }); + + return sid; +} diff --git a/src/lib/diff.test.ts b/src/lib/diff.test.ts index 074ce44..29af6bb 100644 --- a/src/lib/diff.test.ts +++ b/src/lib/diff.test.ts @@ -15,7 +15,7 @@ function base(): PlanInput { }; } -// Unveraenderte Kopie. +// Unveränderte Kopie. function copy(): PlanInput { return { id: "S2", name: "Variante", householdType: "SINGLE", inflationRateDefault: 1.5, initialCash: 1000, @@ -40,16 +40,16 @@ describe("Szenario-Diff", () => { expect(computeScenarioDiff(base(), null).total).toBe(0); }); - it("geaenderter Zellwert wird genau einer Zelle zugeordnet", () => { + it("geänderter Zellwert wird genau einer Zelle zugeordnet", () => { const c = copy(); c.elements[0].phaseValues["c-ph1"] = { amount: 120000 }; const d = computeScenarioDiff(c, base()); expect(d.phaseCell.has("c-e1:c-ph1")).toBe(true); expect(d.phaseCell.size).toBe(1); - expect(d.elementRow.size).toBe(0); // Zeile selbst unveraendert + expect(d.elementRow.size).toBe(0); // Zeile selbst unverändert }); - it("geaenderter Uebergangs-Entscheid markiert die Uebergangszelle", () => { + it("geänderter Übergangs-Entscheid markiert die Übergangszelle", () => { const c = copy(); c.elements[1].transitionValues["c-ph1"] = { decision: "SELL" }; const d = computeScenarioDiff(c, base()); @@ -57,16 +57,16 @@ describe("Szenario-Diff", () => { expect(d.phaseCell.size).toBe(0); }); - it("neues Element ist 'added', geloeschtes wird als entfernt gemeldet", () => { + it("neues Element ist 'added', gelöschtes wird als entfernt gemeldet", () => { const c = copy(); c.elements.push({ id: "c-e3", category: "OTHER_DEBT", name: "Kredit", ownerRole: "HOUSEHOLD", orderIndex: 3, phaseValues: {}, transitionValues: {}, sourceElementId: null }); - c.elements = c.elements.filter((e) => e.id !== "c-e1"); // Lohn geloescht + c.elements = c.elements.filter((e) => e.id !== "c-e1"); // Lohn gelöscht const d = computeScenarioDiff(c, base()); expect(d.elementRow.get("c-e3")).toBe("added"); expect(d.removedElements.map((r) => r.name)).toEqual(["Lohn"]); }); - it("Phasenkopf: Dauer/Name geaendert bzw. Phase neu", () => { + it("Phasenkopf: Dauer/Name geändert bzw. Phase neu", () => { const c = copy(); c.phases[0].durationYears = 15; c.phases.push({ id: "c-ph2", sequenceNumber: 2, name: "Pension", durationYears: 25, cashTransition: {}, sourcePhaseId: null }); @@ -84,7 +84,7 @@ describe("Szenario-Diff", () => { expect(d.cashInitialChanged).toBe(true); }); - it("Cash-Uebergang wird erkannt", () => { + it("Cash-Übergang wird erkannt", () => { const c = copy(); c.phases[0].cashTransition = { mode: "INFLOW", inflowAmount: 100000 }; const d = computeScenarioDiff(c, base()); diff --git a/src/lib/diff.ts b/src/lib/diff.ts index ae21d1e..64f5163 100644 --- a/src/lib/diff.ts +++ b/src/lib/diff.ts @@ -1,10 +1,10 @@ // Abweichungs-Erkennung zwischen einem Szenario und seinem ELTERN-Szenario. // -// Die Zuordnung laeuft ueber die Herkunfts-Verweise, die beim Kopieren gesetzt werden -// (Phase.sourcePhaseId, FinancialElement.sourceElementId). Ueber den Namen zu matchen waere -// fragil: Umbenennen wuerde die Verknuepfung brechen und gleichnamige Elemente kollidieren. +// Die Zuordnung läuft über die Herkunfts-Verweise, die beim Kopieren gesetzt werden +// (Phase.sourcePhaseId, FinancialElement.sourceElementId). Über den Namen zu matchen wäre +// fragil: Umbenennen würde die Verknüpfung brechen und gleichnamige Elemente kollidieren. // -// Der Vergleich ist LIVE gegen den aktuellen Stand des Elternteils -- aendert man dort einen +// Der Vergleich ist LIVE gegen den aktuellen Stand des Elternteils -- ändert man dort einen // Wert, verschiebt sich die Markierung im Kind entsprechend. import { num } from "@/lib/elements"; @@ -18,15 +18,15 @@ export interface ScenarioDiff { elementRow: Map; // `${elementId}:${phaseId}` -> Zelle weicht ab. phaseCell: Set; - // `${elementId}:${fromPhaseId}` -> Uebergangs-Zelle weicht ab. + // `${elementId}:${fromPhaseId}` -> Übergangs-Zelle weicht ab. transitionCell: Set; // phaseId -> Phasenkopf weicht ab (Name/Dauer) bzw. Phase ist neu. phaseHeader: Map; - // phaseId -> Cash-Uebergang nach dieser Phase weicht ab. + // phaseId -> Cash-Übergang nach dieser Phase weicht ab. cashTransitionCell: Set; cashInitialChanged: boolean; profileChanged: boolean; - // Im Elternteil vorhandene, hier geloeschte Elemente (fuer die Geisterzeilen). + // Im Elternteil vorhandene, hier gelöschte Elemente (für die Geisterzeilen). removedElements: { id: string; name: string; category: ElementInput["category"] }[]; removedPhaseCount: number; total: number; @@ -48,7 +48,7 @@ export function emptyDiff(): ScenarioDiff { } // Vergleicht zwei JSON-Payloads feldweise; fehlend und 0 gelten als gleich, damit ein -// nicht gesetztes Feld nicht faelschlich als Aenderung erscheint. +// nicht gesetztes Feld nicht fälschlich als Änderung erscheint. function sameData(a: object = {}, b: object = {}): boolean { const ra = a as Record; const rb = b as Record; @@ -72,7 +72,7 @@ export function computeScenarioDiff(scenario: PlanInput, base: PlanInput | null) // --- Phasen --- const basePhaseById = new Map(base.phases.map((p) => [p.id, p])); const usedBasePhases = new Set(); - // Zuordnung eigene Phase -> Eltern-Phase (fuer die Zellen-Vergleiche). + // Zuordnung eigene Phase -> Eltern-Phase (für die Zellen-Vergleiche). const phaseToBase = new Map(); for (const ph of scenario.phases) { @@ -108,7 +108,7 @@ export function computeScenarioDiff(scenario: PlanInput, base: PlanInput | null) d.elementRow.set(el.id, "changed"); } - // Zellen je Phase / Uebergang ueber die Phasen-Zuordnung vergleichen. + // Zellen je Phase / Übergang über die Phasen-Zuordnung vergleichen. for (const ph of scenario.phases) { const basePhaseId = phaseToBase.get(ph.id); const mine = el.phaseValues[ph.id]; diff --git a/src/lib/elements.ts b/src/lib/elements.ts index 8a27c5e..9402d1d 100644 --- a/src/lib/elements.ts +++ b/src/lib/elements.ts @@ -1,4 +1,4 @@ -// Typ- und Validierungs-Layer fuer die finanziellen Elemente. Die kategorie- und +// Typ- und Validierungs-Layer für die finanziellen Elemente. Die kategorie- und // kontextspezifischen Felder liegen in der DB als JSON; hier werden sie typisiert und // (an der API-Grenze) mit Zod validiert. @@ -19,7 +19,7 @@ export type OwnerRole = "PERSON_A" | "PERSON_B" | "HOUSEHOLD"; // Kategorien, deren Element zwingend genau einer Person zugeordnet ist. export const PERSON_ONLY_CATEGORIES: ElementCategory[] = ["AHV", "PENSION_FUND", "PILLAR_3A"]; -// Kategorien, die gemeinsam ODER pro Person erfasst werden koennen (inkl. Einkommen). +// Kategorien, die gemeinsam ODER pro Person erfasst werden können (inkl. Einkommen). export const OWNER_OPTIONAL_CATEGORIES: ElementCategory[] = ["INCOME", "EXPENSE", "REAL_ESTATE", "OTHER_ASSET", "OTHER_DEBT"]; export const CATEGORY_LABELS: Record = { @@ -27,9 +27,9 @@ export const CATEGORY_LABELS: Record = { EXPENSE: "Ausgaben", AHV: "AHV", PENSION_FUND: "Pensionskasse", - PILLAR_3A: "Saeule 3a", + PILLAR_3A: "Säule 3a", REAL_ESTATE: "Immobilie", - OTHER_ASSET: "Sonstiges Vermoegen", + OTHER_ASSET: "Sonstiges Vermögen", OTHER_DEBT: "Sonstige Schulden", }; @@ -52,14 +52,14 @@ export const CATEGORY_ORDER: ElementCategory[] = [ export interface PhaseData { // INCOME / EXPENSE amount?: number; - // INCOME / EXPENSE: jaehrlicher Teuerungsausgleich (%). Indexiert den Flow ueber die + // INCOME / EXPENSE: jährlicher Teuerungsausgleich (%). Indexiert den Flow über die // Phasenjahre (Jahr t = Basis x (1+idx)^(t-1)). Eigenes Feld je Element (Einkommen und - // Ausgaben unabhaengig). Default = Phaseninflation. + // Ausgaben unabhängig). Default = Phaseninflation. teuerungsausgleich?: number; // AHV gapYears?: number; // AHV, nur wenn die Person bei Planbeginn BEREITS pensioniert ist (dann gibt es keinen - // Pensions-Uebergang, an dem die Karriere geprueft werden koennte): Beitragskarriere. + // Pensions-Übergang, an dem die Karriere geprüft werden könnte): Beitragskarriere. // avgIncomeBefore ist REAL (heutige Kaufkraft). avgIncomeBefore?: number; gapYearsBefore?: number; @@ -68,10 +68,10 @@ export interface PhaseData { startValue?: number; expectedReturn?: number; annualContribution?: number; - // OTHER_ASSET: jaehrliche Bezugsrate (Entnahme). Mindert das Vermoegen und fliesst ins Cash. + // OTHER_ASSET: jährliche Bezugsrate (Entnahme). Mindert das Vermögen und fliesst ins Cash. annualWithdrawal?: number; - // PENSION_FUND / PILLAR_3A / OTHER_ASSET (ab Phase 2): zusaetzliche Einlage aus dem - // verfuegbaren Kapital der Phase. Der Basis-Startwert wird aus der Vorphase fortgeschrieben. + // PENSION_FUND / PILLAR_3A / OTHER_ASSET (ab Phase 2): zusätzliche Einlage aus dem + // verfügbaren Kapital der Phase. Der Basis-Startwert wird aus der Vorphase fortgeschrieben. additionalInvestment?: number; // REAL_ESTATE purchasePrice?: number; @@ -79,10 +79,10 @@ export interface PhaseData { amortization?: number; // Hypothekarzins in % der Restschuld. Der Zinsbetrag sinkt dadurch mit der Amortisation. interestRate?: number; - // Steuert die Doppelzaehlung: Sind die Zinsen im Ausgaben-Element bereits enthalten - // (INCLUDED, Default -- Verhalten bisheriger Plaene) oder soll das Tool sie dazurechnen (ADD)? + // Steuert die Doppelzählung: Sind die Zinsen im Ausgaben-Element bereits enthalten + // (INCLUDED, Default -- Verhalten bisheriger Pläne) oder soll das Tool sie dazurechnen (ADD)? interestHandling?: "INCLUDED" | "ADD"; - // Geschaetzte jaehrliche Wertveraenderung der LIEGENSCHAFT (nicht des Eigenkapitals). + // Geschätzte jährliche Wertveränderung der LIEGENSCHAFT (nicht des Eigenkapitals). valueGrowth?: number; // OTHER_DEBT annualRepayment?: number; @@ -92,36 +92,36 @@ export type TransitionDecision = "HOLD" | "SELL" | "PARTIAL"; export type PkPayoutMode = "CAPITAL" | "PENSION" | "COMBI"; export interface TransitionData { - // AHV (Pensions-Uebergang): Pruefung der Beitragskarriere. Das geplante Durchschnitts- + // AHV (Pensions-Übergang): Prüfung der Beitragskarriere. Das geplante Durchschnitts- // einkommen kommt aus dem Plan; reicht der Plan nicht bis zum Beitragsbeginn (Alter 21) - // zurueck, ergaenzt der Benutzer die Jahre davor. avgIncomeBefore ist REAL. + // zurück, ergänzt der Benutzer die Jahre davor. avgIncomeBefore ist REAL. reviewed?: boolean; avgIncomeBefore?: number; gapYearsBefore?: number; - // PENSION_FUND / PILLAR_3A (normaler Uebergang): expliziter Bezugs-Entscheid. + // PENSION_FUND / PILLAR_3A (normaler Übergang): expliziter Bezugs-Entscheid. withdrawalMode?: "NONE" | "AMOUNT"; withdrawal?: number; - // PENSION_FUND (Pensions-Uebergang) + // PENSION_FUND (Pensions-Übergang) payoutMode?: PkPayoutMode; capitalAmount?: number; conversionRate?: number; - // PENSION_FUND (Kapital) / PILLAR_3A (Pensions-Uebergang) / REAL_ESTATE + // PENSION_FUND (Kapital) / PILLAR_3A (Pensions-Übergang) / REAL_ESTATE capitalTaxRate?: number; saleTaxRate?: number; // REAL_ESTATE / OTHER_ASSET decision?: TransitionDecision; salePrice?: number; - // OTHER_ASSET (Teilverkauf): Betrag, der am Uebergang ins Cash fliesst; der Rest bleibt aktiv. + // OTHER_ASSET (Teilverkauf): Betrag, der am Übergang ins Cash fliesst; der Rest bleibt aktiv. partialSaleAmount?: number; - // REAL_ESTATE (Sonderamortisation): Einmaltilgung der Hypothek am Uebergang, aus dem Cash. + // REAL_ESTATE (Sonderamortisation): Einmaltilgung der Hypothek am Übergang, aus dem Cash. extraAmortization?: number; // OTHER_DEBT immediateRepayment?: number; } -// --- Cash-Uebergang: einmalige Sonderein-/ausgaben --- +// --- Cash-Übergang: einmalige Sonderein-/ausgaben --- // Entscheid am UEBERGANG zwischen zwei Phasen, direkt auf dem Cash-Konto (Cash ist kein -// Element, der Entscheid haengt darum an der Von-Phase). Erfassungs-Konventionen analog zu +// Element, der Entscheid hängt darum an der Von-Phase). Erfassungs-Konventionen analog zu // den laufenden Flows: Zufluss NOMINAL (wie Einkommen), Kosten REAL (wie Ausgaben). export type CashTransitionMode = "NONE" | "INFLOW" | "OUTFLOW" | "BOTH"; diff --git a/src/lib/explain.test.ts b/src/lib/explain.test.ts index bc225a4..73f0978 100644 --- a/src/lib/explain.test.ts +++ b/src/lib/explain.test.ts @@ -7,7 +7,7 @@ import type { PlanInput } from "@/lib/types"; // Reichhaltiger Plan: Erwerbsphase mit Einkommen, Ausgaben, PK, 3a, Immobilie (Hypothek, // Amortisation, Wertsteigerung) und einer Schuld; danach Pensionsphase mit Verzehr. -// Am Uebergang: PK-Verrentung, 3a-Bezug mit Steuer, Sonderamortisation, Einmalposten. +// Am Übergang: PK-Verrentung, 3a-Bezug mit Steuer, Sonderamortisation, Einmalposten. function richPlan(): PlanInput { return { id: "p", @@ -93,8 +93,8 @@ describe("Verlaufswerte je Element", () => { const etf = c.phases[0].elements.find((e) => e.elementId === "asset")!; expect(etf.yearly[0].value).toBe(220000); expect(etf.yearly[1].value).toBe(Math.round(220000 * 1.05 + 10000)); - // Konvexitaet: die Jahreszuwaechse werden von Jahr zu Jahr GROESSER. Eine Gerade - // zwischen den Phasengrenzen haette konstante Zuwaechse -- genau der Fehler, den die + // Konvexität: die Jahreszuwächse werden von Jahr zu Jahr GROESSER. Eine Gerade + // zwischen den Phasengrenzen hätte konstante Zuwächse -- genau der Fehler, den die // per-Jahr-Werte vermeiden. const deltas = etf.yearly.slice(1).map((p, i) => p.value - etf.yearly[i].value); for (let i = 1; i < deltas.length; i++) { @@ -102,7 +102,7 @@ describe("Verlaufswerte je Element", () => { } }); - it("Immobilie fuehrt Verkehrswert und Restschuld getrennt mit", () => { + it("Immobilie führt Verkehrswert und Restschuld getrennt mit", () => { const c = computePlan(richPlan()); const re = c.phases[0].elements.find((e) => e.elementId === "re")!; const y0 = re.yearly[0]; @@ -112,8 +112,8 @@ describe("Verlaufswerte je Element", () => { }); }); -describe("Wasserfall-Bruecken", () => { - it("Vermoegens-Bruecke geht auf (Restposten ~ 0)", () => { +describe("Wasserfall-Brücken", () => { + it("Vermögens-Brücke geht auf (Restposten ~ 0)", () => { const c = computePlan(richPlan()); for (const ph of c.phases) { // Rundungen auf ganze Franken je Summand -> ein paar Franken Toleranz. @@ -123,7 +123,7 @@ describe("Wasserfall-Bruecken", () => { } }); - it("Cash-Bruecke geht auf (Restposten ~ 0)", () => { + it("Cash-Brücke geht auf (Restposten ~ 0)", () => { const c = computePlan(richPlan()); for (const ph of c.phases) { expect(Math.abs(ph.cashBridge.residual)).toBeLessThanOrEqual(5); @@ -132,7 +132,7 @@ describe("Wasserfall-Bruecken", () => { } }); - it("Sparraten erscheinen in der CASH-, nicht in der Vermoegens-Bruecke", () => { + it("Sparraten erscheinen in der CASH-, nicht in der Vermögens-Brücke", () => { // Reiner Umbuchungs-Plan: 12'000 Sparbeitrag pro Jahr, 0 % Rendite, keine Flows. const plan: PlanInput = { id: "p", name: "T", householdType: "SINGLE", inflationRateDefault: 0, initialCash: 100000, @@ -149,14 +149,14 @@ describe("Wasserfall-Bruecken", () => { // Cash: 5 x 12'000 fliessen ab. expect(ph.cashBridge.savingRates).toBe(60000); expect(ph.cashEnd).toBe(100000 - 60000); - // Vermoegen: unveraendert -- das Geld ist nur umgezogen, kein Ertrag, keine Quote. + // Vermögen: unverändert -- das Geld ist nur umgezogen, kein Ertrag, keine Quote. expect(ph.wealthBridge.quotaTotal).toBe(0); expect(ph.wealthBridge.investmentReturn).toBe(0); expect(ph.startWealthNominal).toBe(100000); expect(ph.endWealthNominal).toBe(100000); }); - it("PK-Beitraege sind ein Vermoegenszugang, ohne das Cash zu belasten", () => { + it("PK-Beiträge sind ein Vermögenszugang, ohne das Cash zu belasten", () => { const plan: PlanInput = { id: "p", name: "T", householdType: "SINGLE", inflationRateDefault: 0, initialCash: 0, persons: [{ id: "A", role: "PERSON_A", name: null, age: 40, retirementAge: 70 }], @@ -174,18 +174,18 @@ describe("Wasserfall-Bruecken", () => { expect(ph.endWealthNominal).toBe(40000); }); - it("Verrentung und Kapitalbezugssteuer erscheinen in der Vermoegens-Bruecke der Folgephase", () => { + it("Verrentung und Kapitalbezugssteuer erscheinen in der Vermögens-Brücke der Folgephase", () => { const c = computePlan(richPlan()); const p2 = c.phases[1]; expect(p2.wealthBridge.pensionConversion).toBeGreaterThan(0); // PK wurde verrentet expect(p2.wealthBridge.transitionTax).toBeGreaterThan(0); // 8 % auf den 3a-Bezug - expect(p2.wealthBridge.oneOffInflow).toBe(90000); // 100'000 abzueglich 10 % Steuer + expect(p2.wealthBridge.oneOffInflow).toBe(90000); // 100'000 abzüglich 10 % Steuer expect(p2.wealthBridge.oneOffOutflow).toBeGreaterThan(20000); // real 20'000, nominal aufgewertet }); }); describe("Rechenweg (explain)", () => { - it("ist standardmaessig aus und aendert das Ergebnis nicht", () => { + it("ist standardmässig aus und ändert das Ergebnis nicht", () => { const plain = computePlan(richPlan()); const explained = computePlan(richPlan(), undefined, { explain: true }); @@ -208,7 +208,7 @@ describe("Rechenweg (explain)", () => { expect((c.phases[0].traces ?? []).length).toBeGreaterThan(0); const withTrace = c.phases[0].elements.filter((e) => e.trace); - // Einkommen, Ausgaben, PK, 3a, Immobilie, Vermoegen, Schuld + // Einkommen, Ausgaben, PK, 3a, Immobilie, Vermögen, Schuld expect(withTrace.length).toBeGreaterThanOrEqual(7); const withTxTrace = c.phases[0].elements.filter((e) => e.transitionTrace); @@ -224,9 +224,9 @@ describe("Rechenweg (explain)", () => { expect(endStep.result).toBe(inc.endValue); }); - // Der Rechenweg verlinkt in die SPEZIFIKATION. Wird dort eine Ueberschrift umbenannt, - // zeigen die Verweise ins Leere -- und zwar still. Dieser Test faengt das ab. - it("alle Spezifikations-Verweise treffen eine existierende Ueberschrift", () => { + // Der Rechenweg verlinkt in die SPEZIFIKATION. Wird dort eine Überschrift umbenannt, + // zeigen die Verweise ins Leere -- und zwar still. Dieser Test fängt das ab. + it("alle Spezifikations-Verweise treffen eine existierende Überschrift", () => { const md = readFileSync(path.join(process.cwd(), "SPEZIFIKATION.md"), "utf8"); // Slug-Regel von github-slugger (das rehype-slug verwendet) nachgebildet. const slug = (s: string) => diff --git a/src/lib/format.ts b/src/lib/format.ts index 7c4e621..33e72b4 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -1,5 +1,5 @@ -// Einheitliches Format fuer Geldbetraege im ganzen Tool: Tausendertrennzeichen mit -// geradem Apostroph, keine Nachkommastellen (z. B. 1'780'000). Bewusst nicht ueber +// Einheitliches Format für Geldbeträge im ganzen Tool: Tausendertrennzeichen mit +// geradem Apostroph, keine Nachkommastellen (z. B. 1'780'000). Bewusst nicht über // Intl/toLocaleString("de-CH"), da dessen Gruppentrennzeichen (’ U+2019) nicht dem // geraden Apostroph entspricht. export function formatChf(value: number): string { diff --git a/src/lib/migrations.test.ts b/src/lib/migrations.test.ts index 54e6b8c..0da4b9b 100644 --- a/src/lib/migrations.test.ts +++ b/src/lib/migrations.test.ts @@ -1,6 +1,6 @@ -// Spielt ALLE Migrationen gegen ein echtes PostgreSQL (PGlite, in-process) ein und prueft das -// Ergebnis. Faengt kaputte oder nicht-idempotente Migrations-SQL ab, bevor sie beim Deploy -// gegen die Live-Datenbank laufen -- lokal steht sonst keine Datenbank zur Verfuegung. +// Spielt ALLE Migrationen gegen ein echtes PostgreSQL (PGlite, in-process) ein und prüft das +// Ergebnis. Fängt kaputte oder nicht-idempotente Migrations-SQL ab, bevor sie beim Deploy +// gegen die Live-Datenbank laufen -- lokal steht sonst keine Datenbank zur Verfügung. import { describe, it, expect } from "vitest"; import { PGlite } from "@electric-sql/pglite"; import { readFileSync, readdirSync } from "node:fs"; @@ -9,7 +9,7 @@ import path from "node:path"; const MIG = path.join(process.cwd(), "prisma", "migrations"); describe("Datenbank-Migrationen", () => { - it("laufen vollstaendig durch und ergeben das erwartete Schema", async () => { + it("laufen vollständig durch und ergeben das erwartete Schema", async () => { const db = await PGlite.create(); const dirs = readdirSync(MIG).filter((d) => !d.endsWith(".toml")).sort(); expect(dirs.length).toBeGreaterThan(0); @@ -24,7 +24,7 @@ describe("Datenbank-Migrationen", () => { ) ).rows.map((r) => r.table_name); - // V6-Struktur: Behaelter Plan + berechenbares Scenario. + // V6-Struktur: Behälter Plan + berechenbares Scenario. for (const t of ["User", "Plan", "Scenario", "Person", "Phase", "FinancialElement"]) { expect(tables, `Tabelle ${t} fehlt`).toContain(t); } @@ -37,27 +37,27 @@ describe("Datenbank-Migrationen", () => { ) ).rows.map((r) => r.column_name); - // Kind-Tabellen haengen am Szenario, nicht mehr am Plan. + // Kind-Tabellen hängen am Szenario, nicht mehr am Plan. for (const t of ["Person", "Phase", "FinancialElement"]) { const c = await cols(t); expect(c, `${t}.scenarioId fehlt`).toContain("scenarioId"); - expect(c, `${t}.planId haette entfernt werden muessen`).not.toContain("planId"); + expect(c, `${t}.planId hätte entfernt werden müssen`).not.toContain("planId"); } - // Herkunfts-Verweise fuer den Diff. + // Herkunfts-Verweise für den Diff. expect(await cols("Phase")).toContain("sourcePhaseId"); expect(await cols("FinancialElement")).toContain("sourceElementId"); - // Der Plan traegt keine Finanzdaten mehr. + // Der Plan trägt keine Finanzdaten mehr. const planCols = await cols("Plan"); expect(planCols).not.toContain("householdType"); expect(planCols).toContain("userId"); - // Das Szenario traegt sie. + // Das Szenario trägt sie. const scenCols = await cols("Scenario"); for (const c of ["planId", "isBase", "parentScenarioId", "householdType", "initialCash"]) { expect(scenCols, `Scenario.${c} fehlt`).toContain(c); } - expect(scenCols).not.toContain("userId"); // Eigentuemer haengt am Plan + expect(scenCols).not.toContain("userId"); // Eigentümer hängt am Plan }, 60000); }); diff --git a/src/lib/montecarlo.test.ts b/src/lib/montecarlo.test.ts index aa69056..88309a4 100644 --- a/src/lib/montecarlo.test.ts +++ b/src/lib/montecarlo.test.ts @@ -12,8 +12,8 @@ import { } from "@/lib/montecarlo"; import type { PlanInput } from "@/lib/types"; -// Plan: 40-jaehrig, 1 Phase 10 Jahre, ein Sonstiges Vermoegen 100'000 @ 5 %, 2 % Inflation. -// Deterministisch: 100'000 x 1.05^10 = 162'889 Endvermoegen, kein Ruin. +// Plan: 40-jährig, 1 Phase 10 Jahre, ein Sonstiges Vermögen 100'000 @ 5 %, 2 % Inflation. +// Deterministisch: 100'000 x 1.05^10 = 162'889 Endvermögen, kein Ruin. function basePlan(): PlanInput { return { id: "p", name: "T", householdType: "SINGLE", inflationRateDefault: 2, initialCash: 0, @@ -49,15 +49,15 @@ describe("Monte Carlo", () => { const det = detEnd(); expect(r.finalWealthMedian).toBe(det); expect(r.finalWealthP10).toBe(det); - expect(r.finalWealthP90).toBe(det); // Baender kollabieren auf die deterministische Linie + expect(r.finalWealthP90).toBe(det); // Bänder kollabieren auf die deterministische Linie expect(r.ruinProbability).toBe(0); expect(r.bands[r.bands.length - 1].p50).toBe(det); }); - it("Volatilitaet spreizt den Faecher (p90 > p10)", async () => { + it("Volatilität spreizt den Fächer (p90 > p10)", async () => { const r = await runMonteCarlo(basePlan(), params({ elements: { asset: { mean: 5, sigma: 20, floor: -100 } } })); expect(r.finalWealthP90).toBeGreaterThan(r.finalWealthP10); - // Median bleibt in der Naehe des deterministischen Werts (leicht darunter wegen Vol-Drag). + // Median bleibt in der Nähe des deterministischen Werts (leicht darunter wegen Vol-Drag). expect(r.finalWealthMedian).toBeLessThan(r.finalWealthP90); expect(r.finalWealthMedian).toBeGreaterThan(r.finalWealthP10); }); @@ -79,8 +79,8 @@ describe("Monte Carlo", () => { expect(a.ruinProbability).toBe(b.ruinProbability); }); - it("Boden 0 % (PK/3a): Rendite nie negativ -> Endvermoegen nie unter dem Startwert", async () => { - // Ohne Beitraege kann ein bei 0 % gebodetes Asset nur wachsen oder gleich bleiben. + it("Boden 0 % (PK/3a): Rendite nie negativ -> Endvermögen nie unter dem Startwert", async () => { + // Ohne Beiträge kann ein bei 0 % gebodetes Asset nur wachsen oder gleich bleiben. const r = await runMonteCarlo( basePlan(), params({ elements: { asset: { mean: 0, sigma: 80, floor: 0 } } }) @@ -88,8 +88,8 @@ describe("Monte Carlo", () => { expect(r.finalWealthP10).toBeGreaterThanOrEqual(100000); }); - it("Ruinwahrscheinlichkeit: sicherer Verzehr fuehrt immer in den Ruin", async () => { - // Rente 20k, Ausgaben 60k, kleines Vermoegen -> deterministisch Ruin, auch ohne Streuung. + it("Ruinwahrscheinlichkeit: sicherer Verzehr führt immer in den Ruin", async () => { + // Rente 20k, Ausgaben 60k, kleines Vermögen -> deterministisch Ruin, auch ohne Streuung. const plan: PlanInput = { id: "p", name: "T", householdType: "SINGLE", inflationRateDefault: 0, initialCash: 0, persons: [{ id: "A", role: "PERSON_A", name: null, age: 65, retirementAge: 65 }], @@ -104,7 +104,7 @@ describe("Monte Carlo", () => { runs: 1000, inflationMean: 0, inflationSigma: 0, elements: { asset: { mean: 0, sigma: 10, floor: -100 } }, target: 0, seed: 1, }); - expect(r.ruinProbability).toBe(1); // Verzehr uebersteigt Rente + Vermoegen in jedem Pfad + expect(r.ruinProbability).toBe(1); // Verzehr übersteigt Rente + Vermögen in jedem Pfad }); it("Hilfsfunktionen: Default-Stufen und σ-Tabelle", () => { @@ -118,7 +118,7 @@ describe("Monte Carlo", () => { // --- Mehrere Szenarien im selben Lauf ---------------------------------------------------- // Kopie des Basisplans mit neuen Ids; das Element verweist per sourceElementId auf sein -// Gegenstueck -- genau wie es die Kopier-Route beim Anlegen eines Szenarios setzt. +// Gegenstück -- genau wie es die Kopier-Route beim Anlegen eines Szenarios setzt. function copyPlan(source: PlanInput, suffix: string, expectedReturn: number): PlanInput { return { ...source, @@ -133,7 +133,7 @@ function copyPlan(source: PlanInput, suffix: string, expectedReturn: number): Pl } describe("Monte Carlo: mehrere Szenarien", () => { - it("loest die Herkunfts-Kette bis zum Ursprung auf", () => { + it("löst die Herkunfts-Kette bis zum Ursprung auf", () => { const chain = new Map([ ["a", null], ["b", "a"], @@ -141,13 +141,13 @@ describe("Monte Carlo: mehrere Szenarien", () => { ]); expect(resolveRootElementId("c", chain)).toBe("a"); expect(resolveRootElementId("a", chain)).toBe("a"); - // Verweis ins Leere (Vorlage geloescht): das Element ist selbst die Wurzel. + // Verweis ins Leere (Vorlage gelöscht): das Element ist selbst die Wurzel. expect(resolveRootElementId("x", new Map([["x", "weg"]]))).toBe("x"); - // Defekte Kette darf nicht zur Endlosschleife fuehren. + // Defekte Kette darf nicht zur Endlosschleife führen. expect(resolveRootElementId("p", new Map([["p", "q"], ["q", "p"]]))).toBeDefined(); }); - it("fasst dasselbe Element ueber Szenarien zu EINER Gruppe zusammen", () => { + it("fasst dasselbe Element über Szenarien zu EINER Gruppe zusammen", () => { const base = basePlan(); const s2 = copyPlan(base, "s2", 6); const groups = buildElementGroups( @@ -160,13 +160,13 @@ describe("Monte Carlo: mehrere Szenarien", () => { expect(groups[0].scenarioIds.sort()).toEqual(["base", "s2"]); }); - it("loest die Kette auch ueber ein NICHT ausgewaehltes Zwischen-Szenario auf", () => { + it("löst die Kette auch über ein NICHT ausgewähltes Zwischen-Szenario auf", () => { const base = basePlan(); const s1 = copyPlan(base, "s1", 6); const s2 = copyPlan(s1, "s2", 7); // zeigt auf s1, nicht auf base const groups = buildElementGroups( [{ id: "base", plan: base }, { id: "s1", plan: s1 }, { id: "s2", plan: s2 }], - ["base", "s2"] // s1 ist nur zur Aufloesung geladen + ["base", "s2"] // s1 ist nur zur Auflösung geladen ); expect(groups).toHaveLength(1); expect(groups[0].rootId).toBe("asset"); @@ -191,7 +191,7 @@ describe("Monte Carlo: mehrere Szenarien", () => { expect(neu.scenarioIds).toEqual(["s2"]); // im Basisszenario ohne Wirkung }); - it("uebersetzt die Gruppen-Parameter auf die Element-Ids des jeweiligen Szenarios", () => { + it("übersetzt die Gruppen-Parameter auf die Element-Ids des jeweiligen Szenarios", () => { const base = basePlan(); const s2 = copyPlan(base, "s2", 6); const groups = buildElementGroups([{ id: "base", plan: base }, { id: "s2", plan: s2 }], ["base", "s2"]); @@ -203,9 +203,9 @@ describe("Monte Carlo: mehrere Szenarien", () => { it("gleicher Seed: nur die GEPLANTE Rendite unterscheidet sich -> identische Verteilung, andere Erfolgsquote", async () => { // Der Kern-Anwendungsfall: dasselbe Portfolio, einmal pessimistisch (5 %) und einmal // optimistisch (6 %) geplant. In der Simulation wird die geplante Rendite ersetzt, also - // sind beide Verlaeufe identisch -- der Unterschied liegt allein im Zielbetrag, der aus - // der jeweiligen Planung stammt. Das pessimistische Szenario haelt sein (tieferes) - // Versprechen oefter. + // sind beide Verläufe identisch -- der Unterschied liegt allein im Zielbetrag, der aus + // der jeweiligen Planung stammt. Das pessimistische Szenario hält sein (tieferes) + // Versprechen öfter. const base = basePlan(); // expectedReturn 5 const optimistisch = copyPlan(base, "opt", 6); diff --git a/src/lib/montecarlo.ts b/src/lib/montecarlo.ts index 7b99ee8..c156cbf 100644 --- a/src/lib/montecarlo.ts +++ b/src/lib/montecarlo.ts @@ -1,11 +1,11 @@ -// Monte-Carlo-Simulation (Roadmap Nr. 19, Stufe A). Laeuft vollstaendig im Browser, weil -// computePlan eine reine Funktion ohne Server-Abhaengigkeiten ist. +// Monte-Carlo-Simulation (Roadmap Nr. 19, Stufe A). Läuft vollständig im Browser, weil +// computePlan eine reine Funktion ohne Server-Abhängigkeiten ist. // // Modell: Pro Jahr wird EIN gemeinsamer Marktschock gezogen (damit riskante Anlagen zusammen // fallen, nicht gegeneinander). Jede Rendite = historischer Mittelwert + Standardabweichung x -// (Marktanteil x Marktschock + Eigenanteil x Eigenrauschen). Beide Schocks sind fettschwaenzig -// (standardisierte Student-t), damit Extremcrashs realistisch haeufig auftreten -- eine -// Normalverteilung wuerde sie stark unterschaetzen. Boeden: 0 % fuer PK/3a, -100 % sonst. +// (Marktanteil x Marktschock + Eigenanteil x Eigenrauschen). Beide Schocks sind fettschwänzig +// (standardisierte Student-t), damit Extremcrashs realistisch häufig auftreten -- eine +// Normalverteilung würde sie stark unterschätzen. Böden: 0 % für PK/3a, -100 % sonst. import { computePlan, type PlanSample } from "@/lib/calculations"; import type { ElementCategory } from "@/lib/elements"; @@ -23,7 +23,7 @@ export const RETURN_VOLATILITY_LEVELS = { } as const; // Inflation ist in der Schweiz historisch stabil (Standardabweichung der letzten 20 Jahre -// ~1 %). Hoehere Stufen ergaeben Hyperinflations-Annahmen -- deshalb nur zwei Stufen. +// ~1 %). Höhere Stufen ergäben Hyperinflations-Annahmen -- deshalb nur zwei Stufen. export const INFLATION_VOLATILITY_LEVELS = { sehr_niedrig: 1, niedrig: 2, @@ -56,7 +56,7 @@ export function floorFor(category: ElementCategory): number { export interface ElementMcParams { mean: number; // historische Durchschnittsrendite (%/Jahr) sigma: number; // Standardabweichung (%/Jahr) - floor: number; // 0 fuer PK/3a, -100 sonst + floor: number; // 0 für PK/3a, -100 sonst } export interface MonteCarloParams { @@ -64,42 +64,42 @@ export interface MonteCarloParams { inflationMean: number; inflationSigma: number; elements: Record; // key = elementId - target: number; // Zielbetrag fuer die Erfolgswahrscheinlichkeit (nominal) + target: number; // Zielbetrag für die Erfolgswahrscheinlichkeit (nominal) seed?: number; } export interface MonteCarloResult { runs: number; - ruinProbability: number; // P(Vermoegen faellt vor Planende unter 0) - successProbability: number; // P(Endvermoegen >= Zielbetrag) + ruinProbability: number; // P(Vermögen fällt vor Planende unter 0) + successProbability: number; // P(Endvermögen >= Zielbetrag) finalWealthP10: number; finalWealthMedian: number; finalWealthP90: number; - // Faecher ueber das Alter: je Alterspunkt der pessimistische/mittlere/optimistische Wert. + // Fächer über das Alter: je Alterspunkt der pessimistische/mittlere/optimistische Wert. bands: { age: number; p10: number; p50: number; p90: number }[]; } // --- Mehrere Szenarien im selben Lauf vergleichen ------------------------------------- // -// Ein "logisches" Element ueber Szenariogrenzen hinweg: Beim Kopieren eines Szenarios -// erhaelt jedes Element eine NEUE Id plus einen Verweis auf sein Gegenstueck in der Vorlage -// (sourceElementId -- dieselbe Kette, auf der auch der Diff beruht). Ueber diese Kette wird -// dieselbe Anlage in mehreren Szenarien wiedergefunden. Das ist die Voraussetzung dafuer, -// die historischen Annahmen nur EINMAL zu erfassen: Ein Vergleich ist nur dann aussagekraeftig, -// wenn alle Szenarien mit denselben Marktannahmen gewuerfelt werden -- sonst vergleicht man +// Ein "logisches" Element über Szenariogrenzen hinweg: Beim Kopieren eines Szenarios +// erhält jedes Element eine NEUE Id plus einen Verweis auf sein Gegenstück in der Vorlage +// (sourceElementId -- dieselbe Kette, auf der auch der Diff beruht). Über diese Kette wird +// dieselbe Anlage in mehreren Szenarien wiedergefunden. Das ist die Voraussetzung dafür, +// die historischen Annahmen nur EINMAL zu erfassen: Ein Vergleich ist nur dann aussagekräftig, +// wenn alle Szenarien mit denselben Marktannahmen gewürfelt werden -- sonst vergleicht man // die Eingaben statt der Szenarien. export interface ElementGroup { rootId: string; // Id des Ursprungs-Elements (Anker der Parametereingabe) name: string; category: ElementCategory; - memberIds: string[]; // Element-Ids ueber alle ausgewaehlten Szenarien + memberIds: string[]; // Element-Ids über alle ausgewählten Szenarien scenarioIds: string[]; // Szenarien, in denen dieses Element vorkommt } // Folgt sourceElementId bis zum Ursprung. Bricht ab, sobald der Verweis ins Leere zeigt -// (Vorlage geloescht oder Szenario nicht geladen) -- dann ist dieses Element selbst die +// (Vorlage gelöscht oder Szenario nicht geladen) -- dann ist dieses Element selbst die // Wurzel und bildet eine eigene Gruppe. Der Zyklusschutz ist reine Vorsicht: die Verweise -// sind lose (kein FK), eine defekte Kette darf nicht zur Endlosschleife fuehren. +// sind lose (kein FK), eine defekte Kette darf nicht zur Endlosschleife führen. export function resolveRootElementId(elementId: string, sourceById: Map): string { let current = elementId; const seen = new Set(); @@ -112,9 +112,9 @@ export function resolveRootElementId(elementId: string, sourceById: Map S1 -> S2, wenn -// nur Basis und S2 ausgewaehlt sind). Gruppen entstehen nur fuer die ausgewaehlten Szenarien. +// `all` enthält ALLE Szenarien des Plans (auch nicht ausgewählte) -- nur so lässt sich die +// Kette über ein übersprungenes Zwischen-Szenario hinweg auflösen (Basis -> S1 -> S2, wenn +// nur Basis und S2 ausgewählt sind). Gruppen entstehen nur für die ausgewählten Szenarien. export function buildElementGroups( all: { id: string; plan: PlanInput }[], selectedIds: string[] @@ -154,7 +154,7 @@ export function buildElementGroups( return [...groups.values()]; } -// Uebersetzt die je Gruppe erfassten Parameter auf die Element-Ids EINES Szenarios. +// Übersetzt die je Gruppe erfassten Parameter auf die Element-Ids EINES Szenarios. export function paramsForScenario( plan: PlanInput, groups: ElementGroup[], @@ -186,9 +186,9 @@ export interface ScenarioMcResult extends MonteCarloResult { target: number; } -// Fuehrt dieselbe Simulation fuer mehrere Szenarien aus -- mit DEMSELBEN Seed. Ohne das -// waeren kleine Unterschiede blosses Rauschen (bei 1'000 Laeufen betraegt der Standardfehler -// der Erfolgswahrscheinlichkeit rund 1.5 Prozentpunkte, zwei identische Szenarien koennten +// Führt dieselbe Simulation für mehrere Szenarien aus -- mit DEMSELBEN Seed. Ohne das +// wären kleine Unterschiede blosses Rauschen (bei 1'000 Läufen beträgt der Standardfehler +// der Erfolgswahrscheinlichkeit rund 1.5 Prozentpunkte, zwei identische Szenarien könnten // also 87 % und 90 % zeigen). Mit gemeinsamem Seed teilen strukturgleiche Szenarien dieselben // Marktpfade, und die Unterschiede sind rein strukturell (Common Random Numbers). export async function runMonteCarloMulti( @@ -242,7 +242,7 @@ function normal(rng: () => number): number { } // Standardisierte Student-t mit nu Freiheitsgraden (Einheitsvarianz -> die eingegebene -// Standardabweichung bleibt die tatsaechliche). Fette Raender: nu = 5. +// Standardabweichung bleibt die tatsächliche). Fette Ränder: nu = 5. const NU = 5; function studentT(rng: () => number): number { let chi2 = 0; @@ -265,7 +265,7 @@ function percentile(sortedAsc: number[], p: number): number { return sortedAsc[idx]; } -// Ein Alterspunkt je Phasenanfang plus das Planende (wie im Vermoegensverlauf-Chart). +// Ein Alterspunkt je Phasenanfang plus das Planende (wie im Vermögensverlauf-Chart). function agePointsOf(plan: PlanInput): number[] { const phases = [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber); const startAge = plan.persons.find((p) => p.role === "PERSON_A")?.age ?? plan.persons[0]?.age ?? 0; @@ -278,7 +278,7 @@ function agePointsOf(plan: PlanInput): number[] { return points; } -// Wert je Alterspunkt fuer EINEN Durchlauf (Start-/Endvermoegen der Phasen, nominal). +// Wert je Alterspunkt für EINEN Durchlauf (Start-/Endvermögen der Phasen, nominal). function wealthTrajectory(computed: ReturnType): number[] { const phases = computed.phases; if (phases.length === 0) return []; @@ -306,7 +306,7 @@ export async function runMonteCarlo( const elementIds = Object.keys(params.elements); for (let run = 0; run < params.runs; run++) { - // Pro Jahr ein Marktschock; pro Element/Jahr eine Rendite (fette Raender, gebodet). + // Pro Jahr ein Marktschock; pro Element/Jahr eine Rendite (fette Ränder, gebodet). const inflation: number[] = new Array(totalYears); for (let y = 0; y < totalYears; y++) { inflation[y] = params.inflationMean + params.inflationSigma * studentT(rng); @@ -338,7 +338,7 @@ export async function runMonteCarlo( finalWealth.push(fw); if (fw >= params.target) successCount++; - // Alle ~500 Laeufe die Kontrolle abgeben, damit die Oberflaeche nicht einfriert. + // Alle ~500 Läufe die Kontrolle abgeben, damit die Oberfläche nicht einfriert. if (run % 500 === 499) { onProgress?.(run + 1, params.runs); await new Promise((r) => setTimeout(r, 0)); diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 688a2f3..72ee21a 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -73,10 +73,10 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput { }; } -// --- Ownership-Abfragen. Der Eigentuemer haengt neu am PLAN; Szenario/Phase/Element -// erben ihn ueber die Kette Scenario -> Plan -> User. --- +// --- Ownership-Abfragen. Der Eigentümer hängt neu am PLAN; Szenario/Phase/Element +// erben ihn über die Kette Scenario -> Plan -> User. --- -// Laedt ein Szenario inkl. Profil + Phasen + Elemente, nur wenn es dem Benutzer gehoert. +// Lädt ein Szenario inkl. Profil + Phasen + Elemente, nur wenn es dem Benutzer gehört. export async function getOwnedScenario(scenarioId: string, userId: string) { return prisma.scenario.findFirst({ where: { id: scenarioId, plan: { userId } }, @@ -84,7 +84,7 @@ export async function getOwnedScenario(scenarioId: string, userId: string) { }); } -// Wie oben, zusaetzlich mit den Kopfdaten (Plan, Basis-Flag, Elternteil). +// Wie oben, zusätzlich mit den Kopfdaten (Plan, Basis-Flag, Elternteil). export async function getOwnedScenarioWithMeta(scenarioId: string, userId: string) { return prisma.scenario.findFirst({ where: { id: scenarioId, plan: { userId } }, @@ -92,7 +92,7 @@ export async function getOwnedScenarioWithMeta(scenarioId: string, userId: strin }); } -// Laedt einen Plan (Behaelter) inkl. Szenario-Kopfdaten. +// Lädt einen Plan (Behälter) inkl. Szenario-Kopfdaten. export async function getOwnedPlan(planId: string, userId: string) { return prisma.plan.findFirst({ where: { id: planId, userId }, @@ -100,14 +100,14 @@ export async function getOwnedPlan(planId: string, userId: string) { }); } -// Laedt eine Phase (Basisdaten), aber nur wenn sie dem Benutzer gehoert. +// Lädt eine Phase (Basisdaten), aber nur wenn sie dem Benutzer gehört. export async function getOwnedPhase(phaseId: string, userId: string) { return prisma.phase.findFirst({ where: { id: phaseId, scenario: { plan: { userId } } }, }); } -// Laedt ein Element (Basisdaten), aber nur wenn es dem Benutzer gehoert. +// Lädt ein Element (Basisdaten), aber nur wenn es dem Benutzer gehört. export async function getOwnedElement(elementId: string, userId: string) { return prisma.financialElement.findFirst({ where: { id: elementId, scenario: { plan: { userId } } }, diff --git a/src/lib/sensitivity.test.ts b/src/lib/sensitivity.test.ts index cbf5798..82cae2f 100644 --- a/src/lib/sensitivity.test.ts +++ b/src/lib/sensitivity.test.ts @@ -9,8 +9,8 @@ import { } from "@/lib/sensitivity"; import type { PlanInput } from "@/lib/types"; -// Plan: 45-jaehrig, zwei Phasen (20 J. Erwerb + 20 J. Pension), Einkommen 100'000 netto, -// Ausgaben 70'000 real, ein Sonstiges Vermoegen 200'000 @ 4 %, Inflation 1.5 %. +// Plan: 45-jährig, zwei Phasen (20 J. Erwerb + 20 J. Pension), Einkommen 100'000 netto, +// Ausgaben 70'000 real, ein Sonstiges Vermögen 200'000 @ 4 %, Inflation 1.5 %. function basePlan(): PlanInput { return { id: "p", @@ -58,7 +58,7 @@ function basePlan(): PlanInput { }; } -// Plan mit Immobilie -- wahlweise gehalten oder am Uebergang verkauft. +// Plan mit Immobilie -- wahlweise gehalten oder am Übergang verkauft. function planMitImmobilie(verkaufen: boolean): PlanInput { const p = basePlan(); return { @@ -81,8 +81,8 @@ function planMitImmobilie(verkaufen: boolean): PlanInput { }; } -describe("Sensitivitaet: applyDriver", () => { - it("laesst den Ausgangsplan unberuehrt (rein)", () => { +describe("Sensitivität: applyDriver", () => { + it("lässt den Ausgangsplan unberührt (rein)", () => { const p = basePlan(); const snapshot = JSON.stringify(p); applyDriver(p, "inflation", 3); @@ -101,7 +101,7 @@ describe("Sensitivitaet: applyDriver", () => { const asset = p.elements.find((e) => e.id === "asset")!; expect(asset.phaseValues.p1.expectedReturn).toBe(6); expect(asset.phaseValues.p2.expectedReturn).toBe(6); - // Einkommen/Ausgaben bleiben unberuehrt. + // Einkommen/Ausgaben bleiben unberührt. expect(p.elements.find((e) => e.id === "inc")!.phaseValues.p1.amount).toBe(100000); }); @@ -126,8 +126,8 @@ describe("Sensitivitaet: applyDriver", () => { }); }); -describe("Sensitivitaet: Treiber-Verfuegbarkeit", () => { - it("blendet Treiber aus, fuer die es keine passenden Elemente gibt", () => { +describe("Sensitivität: Treiber-Verfügbarkeit", () => { + it("blendet Treiber aus, für die es keine passenden Elemente gibt", () => { const p = basePlan(); expect(driverById("propertyGrowth").applies(p)).toBe(false); // keine Immobilie expect(driverById("returns").applies(p)).toBe(true); @@ -143,8 +143,8 @@ describe("Sensitivitaet: Treiber-Verfuegbarkeit", () => { }); }); -describe("Sensitivitaet: Tornado", () => { - it("Basiswert entspricht dem unveraenderten Plan", () => { +describe("Sensitivität: Tornado", () => { + it("Basiswert entspricht dem unveränderten Plan", () => { const p = basePlan(); const result = computeTornado(p, "real", [{ id: "inflation", low: 0.5, high: 3.5 }]); const last = computePlan(p).phases.at(-1)!; @@ -163,19 +163,19 @@ describe("Sensitivitaet: Tornado", () => { // Ein Treiber ohne Bandbreite kann nichts bewegen und landet zuunterst. expect(result.bars.at(-1)!.id).toBe("salaryGrowth"); expect(result.bars.at(-1)!.swing).toBe(0); - // Welcher Treiber oben steht, haengt vom konkreten Plan ab -- genau das ist die Aussage + // Welcher Treiber oben steht, hängt vom konkreten Plan ab -- genau das ist die Aussage // des Tornados und deshalb bewusst nicht fix getestet. }); - it("kehrt die Richtung korrekt ab: hoehere Ausgaben -> tieferes Endvermoegen", () => { + it("kehrt die Richtung korrekt ab: höhere Ausgaben -> tieferes Endvermögen", () => { const [bar] = computeTornado(basePlan(), "real", [{ id: "expenses", low: -15, high: 15 }]).bars; - expect(bar.lowResult).toBeGreaterThan(bar.highResult); // tiefe Ausgaben = mehr Vermoegen + expect(bar.lowResult).toBeGreaterThan(bar.highResult); // tiefe Ausgaben = mehr Vermögen expect(bar.min).toBe(bar.highResult); expect(bar.max).toBe(bar.lowResult); expect(bar.swing).toBe(bar.max - bar.min); }); - it("hoehere Rendite -> hoeheres Endvermoegen", () => { + it("höhere Rendite -> höheres Endvermögen", () => { const [bar] = computeTornado(basePlan(), "real", [{ id: "returns", low: -2, high: 2 }]).bars; expect(bar.highResult).toBeGreaterThan(bar.lowResult); }); @@ -183,13 +183,13 @@ describe("Sensitivitaet: Tornado", () => { it("identische Bandbreite ergibt Spannweite 0", () => { const [bar] = computeTornado(basePlan(), "real", [{ id: "inflation", low: 2, high: 2 }]).bars; expect(bar.swing).toBe(0); - expect(bar.note).toBeTruthy(); // Nullbalken bekommt immer eine Erklaerung + expect(bar.note).toBeTruthy(); // Nullbalken bekommt immer eine Erklärung }); - it("verkaufte Immobilie: Wertsteigerung ist nachweislich wirkungslos und wird erklaert", () => { - // Beim Verkauf ist der Erloes `Verkaufspreis - Hypothek - Steuer` und haengt am ERFASSTEN + it("verkaufte Immobilie: Wertsteigerung ist nachweislich wirkungslos und wird erklärt", () => { + // Beim Verkauf ist der Erlös `Verkaufspreis - Hypothek - Steuer` und hängt am ERFASSTEN // Preis, nicht am modellierten Verkehrswert. Die aufgelaufene Wertsteigerung wird damit - // verworfen -- der Treiber kann das Endvermoegen nicht mehr bewegen. + // verworfen -- der Treiber kann das Endvermögen nicht mehr bewegen. const gehalten = computeTornado(planMitImmobilie(false), "real", [{ id: "propertyGrowth", low: 0.5, high: 2.5 }]); const verkauft = computeTornado(planMitImmobilie(true), "real", [{ id: "propertyGrowth", low: 0.5, high: 2.5 }]); @@ -206,7 +206,7 @@ describe("Sensitivitaet: Tornado", () => { const real = planMetric(p, "real"); const nominal = planMetric(p, "nominal"); const last = computePlan(p).phases.at(-1)!; - expect(nominal).toBeGreaterThan(real); // 1.5 % Inflation ueber 40 Jahre + expect(nominal).toBeGreaterThan(real); // 1.5 % Inflation über 40 Jahre expect(real).toBe(Math.round(nominal / last.cumulativeInflationEnd)); }); }); diff --git a/src/lib/sensitivity.ts b/src/lib/sensitivity.ts index 2cd792c..a957b4f 100644 --- a/src/lib/sensitivity.ts +++ b/src/lib/sensitivity.ts @@ -1,17 +1,17 @@ -// Sensitivitaetsanalyse / Tornado (Roadmap Nr. 20). Beantwortet nicht "wie viel Geld habe -// ich am Schluss", sondern "welche meiner Annahmen entscheidet ueberhaupt ueber das Ergebnis". +// Sensitivitätsanalyse / Tornado (Roadmap Nr. 20). Beantwortet nicht "wie viel Geld habe +// ich am Schluss", sondern "welche meiner Annahmen entscheidet überhaupt über das Ergebnis". // // Verfahren: One-at-a-time (OAT). Je Treiber wird EIN Parameter auf seinen tiefen und seinen -// hohen Wert gesetzt, alle uebrigen bleiben auf dem Planwert; die Differenz der beiden +// hohen Wert gesetzt, alle übrigen bleiben auf dem Planwert; die Differenz der beiden // Ergebnisse ist die Spannweite. Nach Spannweite sortiert ergibt sich die Trichterform. // // Zwei bewusste Grenzen (im Dialog ausgewiesen, siehe SPEZIFIKATION 9.18): -// 1. Die Balkenlaenge haengt von den eingegebenen Bandbreiten ab -- deshalb gibt es hier +// 1. Die Balkenlänge hängt von den eingegebenen Bandbreiten ab -- deshalb gibt es hier // KEINE Defaults, die Bandbreite ist je Treiber Pflichteingabe. -// 2. OAT sieht keine Wechselwirkungen (tiefe Rendite UND hohe Ausgaben treffen haerter als -// die Summe der Einzelbalken). Dafuer ist die Monte-Carlo-Simulation zustaendig. +// 2. OAT sieht keine Wechselwirkungen (tiefe Rendite UND hohe Ausgaben treffen härter als +// die Summe der Einzelbalken). Dafür ist die Monte-Carlo-Simulation zuständig. // -// Laeuft wie die Monte-Carlo-Simulation vollstaendig im Browser: computePlan ist rein, und +// Läuft wie die Monte-Carlo-Simulation vollständig im Browser: computePlan ist rein, und // ein Tornado braucht nur 2 Aufrufe je Treiber (Millisekunden). import { computePlan } from "@/lib/calculations"; @@ -29,18 +29,18 @@ export type DriverId = | "propertyGrowth"; // Die Einheit bestimmt, WAS der eingegebene Wert bedeutet -- das ist je Treiber verschieden -// und laesst sich nicht vereinheitlichen, ohne fachlich falsch zu werden: +// und lässt sich nicht vereinheitlichen, ohne fachlich falsch zu werden: // abs_pct absoluter Prozentsatz (es gibt genau einen plan-weiten Wert) -// delta_pp Verschiebung in Prozentpunkten (die Elemente haben je eigene Saetze -- ein -// absoluter Wert wuerde die PK auf ETF-Rendite plaetten) -// rel_pct relative Abweichung in Prozent (die Elemente haben je eigene Betraege) +// delta_pp Verschiebung in Prozentpunkten (die Elemente haben je eigene Sätze -- ein +// absoluter Wert würde die PK auf ETF-Rendite plätten) +// rel_pct relative Abweichung in Prozent (die Elemente haben je eigene Beträge) // delta_years Verschiebung in Jahren export type DriverUnit = "abs_pct" | "delta_pp" | "rel_pct" | "delta_years"; export interface DriverDef { id: DriverId; label: string; - shortLabel: string; // Achsenbeschriftung im Tornado (kurz genug fuer die y-Achse) + shortLabel: string; // Achsenbeschriftung im Tornado (kurz genug für die y-Achse) unit: DriverUnit; help: string; applies: (plan: PlanInput) => boolean; @@ -118,7 +118,7 @@ export const DRIVERS: DriverDef[] = [ }, ]; -// Einheiten-Suffix fuer die Eingabefelder und die Ergebnistabelle. +// Einheiten-Suffix für die Eingabefelder und die Ergebnistabelle. export const UNIT_SUFFIX: Record = { abs_pct: "%", delta_pp: "pp", @@ -133,7 +133,7 @@ export function driverById(id: DriverId): DriverDef { } // --- Anwenden eines Treiber-Wertes auf einen Plan ------------------------------------- -// Alle Transformationen sind rein: sie liefern eine Kopie und lassen das Original unberuehrt. +// Alle Transformationen sind rein: sie liefern eine Kopie und lassen das Original unberührt. function mapPhaseData(element: ElementInput, f: (pd: PhaseData) => PhaseData): ElementInput { const phaseValues: Record = {}; @@ -141,7 +141,7 @@ function mapPhaseData(element: ElementInput, f: (pd: PhaseData) => PhaseData): E return { ...element, phaseValues }; } -// Bildet die Elemente der gegebenen Kategorien ab; alle uebrigen bleiben unveraendert. +// Bildet die Elemente der gegebenen Kategorien ab; alle übrigen bleiben unverändert. function mapElements( plan: PlanInput, categories: ElementCategory[], @@ -159,7 +159,7 @@ export function applyDriver(plan: PlanInput, id: DriverId, value: number): PlanI return { ...plan, inflationRateDefault: value }; case "returns": - // Verschiebung in Prozentpunkten auf die geplante Rendite. Nur dort, wo ueberhaupt ein + // Verschiebung in Prozentpunkten auf die geplante Rendite. Nur dort, wo überhaupt ein // Werte-Datensatz existiert -- fehlt er, rechnet computePlan ohnehin mit 0 %. return mapElements(plan, RETURN_CATEGORIES, (pd) => ({ ...pd, @@ -177,7 +177,7 @@ export function applyDriver(plan: PlanInput, id: DriverId, value: number): PlanI // Relative Skalierung des Basisbetrags. Bewusst nur dort, wo `amount` gesetzt ist: // ab Phase 2 ist der Wert in der Regel live vererbt (kein gespeicherter Betrag), und // die Fortschreibung leitet ihn aus dem skalierten Basiswert ab -- dadurch wirkt die - // Skalierung automatisch ueber alle Folgephasen. + // Skalierung automatisch über alle Folgephasen. const factor = 1 + value / 100; return mapElements(plan, [id === "expenses" ? "EXPENSE" : "INCOME"], (pd) => typeof pd.amount === "number" ? { ...pd, amount: Math.max(0, pd.amount * factor) } : pd @@ -191,7 +191,7 @@ export function applyDriver(plan: PlanInput, id: DriverId, value: number): PlanI })); case "lifespan": { - // Verlaengert/verkuerzt die LETZTE Phase. Bewusst nicht das Pensionsalter: das laesst + // Verlängert/verkürzt die LETZTE Phase. Bewusst nicht das Pensionsalter: das lässt // sich ohne Mitverschieben der Phasengrenzen nicht sinnvoll variieren (siehe 9.18). if (plan.phases.length === 0) return plan; const lastSeq = Math.max(...plan.phases.map((p) => p.sequenceNumber)); @@ -221,13 +221,13 @@ export interface TornadoBar { id: DriverId; label: string; shortLabel: string; - note?: string; // Erklaerung, wenn der Treiber das Ergebnis nachweislich nicht bewegt + note?: string; // Erklärung, wenn der Treiber das Ergebnis nachweislich nicht bewegt unit: DriverUnit; low: number; // eingegebene Bandbreite high: number; - lowResult: number; // Zielgroesse beim tiefen Wert - highResult: number; // Zielgroesse beim hohen Wert - min: number; // fuer den Balken: kleinerer der beiden Ergebniswerte + lowResult: number; // Zielgrösse beim tiefen Wert + highResult: number; // Zielgrösse beim hohen Wert + min: number; // für den Balken: kleinerer der beiden Ergebniswerte max: number; swing: number; // max - min } @@ -237,13 +237,13 @@ export interface TornadoResult { bars: TornadoBar[]; } -// Warum bewegt ein Treiber gar nichts? Ein stummer Nullbalken ohne Erklaerung ist die -// schlechteste Antwort -- der Nutzer haelt ihn fuer einen Fehler. +// Warum bewegt ein Treiber gar nichts? Ein stummer Nullbalken ohne Erklärung ist die +// schlechteste Antwort -- der Nutzer hält ihn für einen Fehler. // -// Der wichtigste Fall ist die Immobilien-Wertsteigerung bei einem Verkauf: Der Verkaufserloes -// ist `Verkaufspreis - Hypothek - Grundstueckgewinnsteuer` und haengt damit am ERFASSTEN +// Der wichtigste Fall ist die Immobilien-Wertsteigerung bei einem Verkauf: Der Verkaufserlös +// ist `Verkaufspreis - Hypothek - Grundstückgewinnsteuer` und hängt damit am ERFASSTEN // Verkaufspreis, nicht am modellierten Verkehrswert. Die aufgelaufene Wertsteigerung wird beim -// Verkauf also verworfen -- der Treiber kann das Endvermoegen nicht mehr beeinflussen. +// Verkauf also verworfen -- der Treiber kann das Endvermögen nicht mehr beeinflussen. export function ineffectiveReason(plan: PlanInput, id: DriverId): string { if (id === "propertyGrowth") { const computed = computePlan(plan); @@ -256,7 +256,7 @@ export function ineffectiveReason(plan: PlanInput, id: DriverId): string { return "Dieser Parameter bewegt das Endvermögen in diesem Plan nicht."; } -// Zielgroesse: Endvermoegen der letzten Phase, real (kaufkraftbereinigt) oder nominal. +// Zielgrösse: Endvermögen der letzten Phase, real (kaufkraftbereinigt) oder nominal. export function planMetric(plan: PlanInput, metric: TornadoMetric): number { const computed = computePlan(plan); const last = computed.phases[computed.phases.length - 1]; @@ -275,8 +275,8 @@ export function computeTornado( const def = driverById(input.id); const lowResult = planMetric(applyDriver(plan, input.id, input.low), metric); const highResult = planMetric(applyDriver(plan, input.id, input.high), metric); - // Die Richtung kann sich umkehren (tiefe Ausgaben -> hohes Vermoegen). Der Balken spannt - // deshalb ueber min..max; welche Eingabe zu welchem Ende gehoert, zeigt die Tabelle. + // Die Richtung kann sich umkehren (tiefe Ausgaben -> hohes Vermögen). Der Balken spannt + // deshalb über min..max; welche Eingabe zu welchem Ende gehört, zeigt die Tabelle. const swing = Math.abs(highResult - lowResult); return { id: input.id, @@ -294,7 +294,7 @@ export function computeTornado( }; }); - // Trichterform: groesste Spannweite zuoberst. + // Trichterform: grösste Spannweite zuoberst. bars.sort((a, b) => b.swing - a.swing); return { base, bars }; } diff --git a/src/lib/session.ts b/src/lib/session.ts index a0e93f6..f44f63a 100644 --- a/src/lib/session.ts +++ b/src/lib/session.ts @@ -2,8 +2,8 @@ import { cookies } from "next/headers"; import { SESSION_COOKIE_NAME, verifySessionToken } from "@/lib/auth"; // Liest die User-ID des eingeloggten Benutzers aus dem Session-Cookie. -// Fuer API-Routen (Node.js-Runtime); die Middleware schuetzt die Routen bereits, -// dies ist die zweite Verteidigungslinie und liefert die ID fuer Ownership-Checks. +// Für API-Routen (Node.js-Runtime); die Middleware schützt die Routen bereits, +// dies ist die zweite Verteidigungslinie und liefert die ID für Ownership-Checks. export async function getCurrentUserId(): Promise { const cookieStore = await cookies(); const token = cookieStore.get(SESSION_COOKIE_NAME)?.value; diff --git a/src/lib/theme.ts b/src/lib/theme.ts index 4ab69b4..2436534 100644 --- a/src/lib/theme.ts +++ b/src/lib/theme.ts @@ -1,5 +1,5 @@ -// Theme-Verwaltung: drei waehlbare Schemata, persistiert in localStorage und als -// data-theme am gesetzt. Ohne gespeicherte Wahl folgt die Oberflaeche der +// Theme-Verwaltung: drei wählbare Schemata, persistiert in localStorage und als +// data-theme am gesetzt. Ohne gespeicherte Wahl folgt die Oberfläche der // OS-Einstellung (siehe globals.css, prefers-color-scheme). export type Theme = "light" | "dark" | "warm"; diff --git a/src/lib/types.ts b/src/lib/types.ts index 7f76ac9..5a1ef0f 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,5 +1,5 @@ -// Domain-Typen fuer Berechnungslogik und API-Payloads. Entkoppelt von den generierten -// Prisma-Typen, damit die Berechnung unabhaengig testbar bleibt. +// Domain-Typen für Berechnungslogik und API-Payloads. Entkoppelt von den generierten +// Prisma-Typen, damit die Berechnung unabhängig testbar bleibt. // // V3-Rework: Das Grundprofil (Haushaltsform, Personen, Inflation) liegt neu direkt am Plan. @@ -21,9 +21,9 @@ export interface PhaseInput { sequenceNumber: number; name: string; durationYears: number; - // Cash-Entscheid beim Uebergang NACH dieser Phase (einmalige Sonderein-/ausgaben). + // Cash-Entscheid beim Übergang NACH dieser Phase (einmalige Sonderein-/ausgaben). cashTransition: CashTransitionData; - // Gegenstueck im Eltern-Szenario (Diff-Grundlage); null im Basisszenario. + // Gegenstück im Eltern-Szenario (Diff-Grundlage); null im Basisszenario. sourcePhaseId?: string | null; } @@ -33,14 +33,14 @@ export interface ElementInput { name: string; ownerRole: OwnerRole | null; orderIndex: number; - // Werte je Phase (Key = phaseId) bzw. je Uebergang (Key = fromPhaseId). + // Werte je Phase (Key = phaseId) bzw. je Übergang (Key = fromPhaseId). phaseValues: Record; transitionValues: Record; - // Gegenstueck im Eltern-Szenario (Diff-Grundlage); null im Basisszenario. + // Gegenstück im Eltern-Szenario (Diff-Grundlage); null im Basisszenario. sourceElementId?: string | null; } -// Kopf-Daten eines Szenarios (fuer Baum und Auswahl in der Seitenleiste). +// Kopf-Daten eines Szenarios (für Baum und Auswahl in der Seitenleiste). export interface ScenarioMeta { id: string; planId: string; @@ -49,7 +49,7 @@ export interface ScenarioMeta { parentScenarioId: string | null; } -// Ein Plan ist der Behaelter; er traegt nur den Namen und seine Szenarien. +// Ein Plan ist der Behälter; er trägt nur den Namen und seine Szenarien. export interface PlanListItem { id: string; name: string; @@ -57,7 +57,7 @@ export interface PlanListItem { scenarios: ScenarioMeta[]; } -// Die berechenbare Einheit (fachlich: ein SZENARIO). Sie ist selbsttragend und traegt ihr +// Die berechenbare Einheit (fachlich: ein SZENARIO). Sie ist selbsttragend und trägt ihr // eigenes Grundprofil (Haushaltsform, Personen, Inflation, Cash) plus Phasen und Elemente. // Der Name `PlanInput` ist historisch und bleibt, weil die ganze Berechnungsschicht darauf // aufsetzt (computePlan, Monte Carlo, Tests). @@ -67,8 +67,8 @@ export interface PlanInput { householdType: HouseholdType; inflationRateDefault: number; initialCash: number; // Anfangswert des Cash-Kontos in der ersten Lebensphase - // Kalenderjahr des Planbeginns (Jahr 1) -- nur fuer die Darstellung, nicht fuer die - // Berechnung. Optional, damit Berechnungs-Fixtures es nicht setzen muessen. + // Kalenderjahr des Planbeginns (Jahr 1) -- nur für die Darstellung, nicht für die + // Berechnung. Optional, damit Berechnungs-Fixtures es nicht setzen müssen. startYear?: number | null; persons: PersonInput[]; phases: PhaseInput[]; diff --git a/src/lib/users.ts b/src/lib/users.ts index 2a84c51..564dbf5 100644 --- a/src/lib/users.ts +++ b/src/lib/users.ts @@ -2,7 +2,7 @@ import bcrypt from "bcryptjs"; import { prisma } from "@/lib/db"; // Nur von API-Routen (Node.js-Runtime) verwendet -- niemals von middleware.ts -// importieren, da dort (Edge-Runtime) kein Datenbankzugriff moeglich ist. +// importieren, da dort (Edge-Runtime) kein Datenbankzugriff möglich ist. const USERNAME_PATTERN = /^[a-zA-Z0-9._-]{3,32}$/;