diff --git a/SPEZIFIKATION.md b/SPEZIFIKATION.md index 6cdcdaa..b2aec87 100644 --- a/SPEZIFIKATION.md +++ b/SPEZIFIKATION.md @@ -4,10 +4,10 @@ | | | |---|---| | **Dokument** | Funktionale und Technische Spezifikation FPT | -| **Version** | 0.28 | +| **Version** | 0.29 | | **Datum** | 2026-07-24 | | **Status** | Lebendes Dokument | -| **Codestand** | Arbeitsstand nach `2bb243b` inkl. Modul-Review 2 (Onboarding) (Branch `main`) | +| **Codestand** | Arbeitsstand nach `52c48a9` inkl. Modul-Review 2 (Feinschliff) (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.29 | 2026-07-24 | Claude (Opus 4.8) | **Modul-Review 2, Feinschliff (Assistent, Tour, Ansicht).** (1) **Assistent:** neuer **Willkommens-Screen** vor Schritt 1 mit dem Gesamtbild der fünf Schritte, danach eine **persistente Schritt-Leiste** (links im breiten Modal, mobil als Fortschrittsbalken) – der aktuelle Schritt hervorgehoben, erledigte mit Haken, kommende gedämpft. Schritt «Vorsorge & Vermögen» fragt neu auch die **erwartete Rendite** bei PK, 3a und Wertschriften ab (vorher fest verdrahtet). Im Schritt «Sparen & Verteilen» rechnet die Sparquote-Vorschau **Hypothekarzinsen**, die als «noch nicht in den Ausgaben» markiert sind, korrekt zu den Ausgaben dazu (wie der Rechenkern bei `interestHandling: ADD`); der **noch nicht verteilte Rest** steht neu **prominent oben** (zwischen PK und Verteilung), und die Verteilung ist nach **Gemeinsam / Person A / Person B** gruppiert. (2) **Tour** (Kap. 9.24): statt der bisher bewusst schlichten Hervorhebung jetzt ein **Spotlight** – der Rest der Ansicht wird abgedunkelt (ein 9999-px-Kastenschatten, kein separates Overlay), der pulsierende Rahmen ist deutlich stärker, die Karte ist grösser und **springt** auf die dem Ziel gegenüberliegende Bildschirmhälfte (verdeckt es nie); neu mit **«Überspringen»**-Knopf. (3) **Szenario-Ansicht** (Kap. 3.7.7): Grundprofil, Zeitachse und die Kennzahlen (Endvermögen nominal + real, Ruinalter) bilden neu **einen kompakten Block** aus drei Spalten (25 / 50 / 25 %) statt zweier über die ganze Breite gezogener Zeilen. Neue Modal-Grösse `xwide`. Kein Eingriff in den Rechenkern; 267 Tests unverändert grün. | | 0.28 | 2026-07-24 | Claude (Opus 4.8) | **Modul-Review 2 (Onboarding & Ansicht): Layout-Umbau, Tour und Assistenten-Redesign.** (1) **Szenario-Ansicht neu geordnet** (Kap. 3.7.7): oben eine schlanke Funktions-Leiste (Änderungshistorie · Tour · Neues Szenario · Rechenwege · CSV-Export · Löschen · Abweichungs-Badge), darunter Nächste Schritte → Grundprofil → Zeitachse → Anzeige-Umschalter → Matrix. Grafiken, Effektive Werte, Live-Simulation, Monte-Carlo und Einflussfaktoren sind aus der Leiste **entfernt** – sie laufen über die eigenen Menüpunkte (Analysen / Effektive Werte). (2) **Tour** liegt neu in `AppShell` (statt `PlanView`) und startet nach **jeder** Plan-Erstellung – Assistent, Beispielplan **und** leerer Plan – unabhängig davon, ob sie schon einmal beendet wurde (Kap. 3.7.8); die Erfolgsmeldung hält damit ihr Versprechen. (3) **Assistent durchgehend in Du-Form** im Einzelmodus (Paarmodus weiter «ihr» / je Person). (4) **Schritt-Redesign** (Kap. 3.2.8): Schritt «Vorsorge & Vermögen» erfasst nur noch **Bestandswerte**; ein **neuer Schritt «Sparen & Verteilen»** zeigt die Sparquote (Nettoeinkommen − Ausgaben) und lässt sie auf 3a, Wertschriften, Amortisation und Schuldtilgung verteilen – der Rest bleibt sichtbar auf dem Cash-Konto. Die **PK-Einzahlung** steht dort bewusst separat, mit dem Hinweis, dass sie vom Bruttolohn kommt und die Sparquote **nicht** schmälert (deckt sich mit dem Rechenkern). Immobilien fragen im Assistenten neu **Wertsteigerung** und den **Zins-in-Ausgaben-Schalter** ab; der Lohn bekommt 1 % Default-Erhöhung. (5) **«Grosse Säule 3a» für Selbstständige** (Roadmap-Feedback): ein Schalter am 3a-Element (und im Assistenten) hebt die Beitrags-Obergrenze von 7’258 auf ca. 36’288 CHF an (neue Konstante `PILLAR_3A_MAX_SELF_EMPLOYED`, **2026-Wert zu verifizieren**; neues Feld `selfEmployed3a`). (6) Nebenbei: `POST /plans` liefert bei Validierungsfehlern eine lesbare Meldung statt eines rohen zod-Objekts. Keine Änderung am Rechenkern; 267 Tests unverändert grün. | | 0.27 | 2026-07-24 | Claude (Opus 4.8) | **Modul-Review 1 (Zugang & App-Rahmen): fünf Nachbesserungen an der Authentifizierung.** Ergebnis der ersten gemeinsamen Test- und Review-Runde. (1) **Timing-Ausgleich beim Login:** Ein unbekannter Benutzer wird neu gegen einen Dummy-bcrypt-Hash geprüft, damit die Antwortzeit dieselbe ist wie bei einem bekannten -- vorher liess sich aus der Dauer ablesen, ob ein Benutzername existiert (Kap. 3.1.2). (2) **Zurück-Knopf nach Logout:** Die aus dem Browser-Cache (bfcache) zurückgeholte, eingefrorene Ansicht prüft neu beim `pageshow` die Session und leitet ohne Anmeldung sofort auf `/login` -- vorher blieb die alte Ansicht sichtbar (Kap. 3.1.3). (3) **Rate-Limiting:** Login (10/15 min je IP), Registrierung (5/h je IP) und Passwortänderung (10/15 min je Benutzer) sind gegen Durchprobieren gebremst; neues In-Memory-Modul `rate-limit.ts`, 429 mit `Retry-After` (Kap. 3.1.6). Der Login-Zähler läuft je IP **und Benutzername** -- ein Konto sperrt nicht die anderen Konten derselben IP. (4) **Passwort-Dialog** läuft neu über die zentrale `Modal`-Komponente und schliesst damit auf Esc (Fokus-Falle, aria-modal inklusive) -- war zuvor von Hand gebaut (Kap. 3.1.4). (5) **Registrierungs-Fehler** werden sauber getrennt: nur der belegte Benutzername ist ein 409 mit freundlicher Meldung, jeder andere Fehler ein 500 statt einer rohen Prisma-Meldung als Konflikt. 6 Tests ergänzt (261 → 267). Offen für Roadmap #34: kein Passwort-Längen-Maximum (bcrypt-72-Byte-Grenze), keine ARIA-Labels auf Login/Palette. | | 0.26 | 2026-07-21 | Claude (Opus 4.8) | **Pensionsalter anpassen** (Roadmap Nr. 44, neue Kapitel 3.12, 4.4.7 und 4.16). Bisher war das Pensionsalter faktisch unantastbar: Es bestimmt, wo eine Lebensphase endet – ein frei geändertes Alter hätte die Phasengrenze zerrissen. Neu wird nicht das Alter geändert, sondern **die Grenze verschoben**: Die Phase davor wird länger, die danach kürzer, die Gesamtdauer bleibt gleich. Der Spielraum endet dort, wo eine angrenzende Phase unter ein Jahr fiele; ein Schritt weiter **entfällt sie ganz**, was vorher bestätigt wird, weil dabei zwei Übergänge **zusammengelegt** werden (bereits getroffene Entscheide bleiben, leere Felder werden aus dem entfallenden Übergang ergänzt, einmalige Cash-Beträge werden addiert – der Steuersatz betragsgewichtet). Das Pensionsalter ist damit auch **Treiber im Tornado** und **Regler in der Live-Simulation**. **AHV-Referenzalter (4.4.7):** Die Rente beginnt neu **immer mit 65**, unabhängig vom Pensionsalter – wer länger arbeitet, erhält sie zusätzlich zum Lohn; wer früher aufhört, zahlt bis 65 einen **Beitrag als Nichterwerbstätige(r)**, der als laufende Ausgabe auf die Verzehrquote schlägt und mit 65 wegfällt (neues Feld `ahvContribution`, ohne Default, Hilfetext nennt die reale Bandbreite von rund 530 bis 26'500 CHF pro Jahr). Beide Wechsel können **innerhalb** einer Phase liegen, die AHV wird deshalb **jahresweise** statt phasenweise gerechnet. **Punkt B:** Ein Einkommen, das einer **Person** zugeordnet ist, fällt bei deren Pensionierung auf 0 – bisher lief der Lohn stillschweigend in die Pension weiter. Gemeinsame Einkommen (Mieterträge o. Ä.) bleiben; ein ausdrücklich erfasster Betrag gewinnt, damit ein Teilzeitpensum modellierbar bleibt. **Punkt A:** Wiederkehr-Parameter (Raten, Beiträge, Amortisation, Wertsteigerung, Zinssatz) werden neu **live aus der Vorphase geerbt** statt beim Anlegen der Phase kopiert – sichtbar als angehaktes **«Aus Vorphase übernehmen»** je Feld. Vorher blieb die Kopie stehen, wenn man die Vorphase später änderte. **Punkt C:** Der Kapitalzufluss am Pensions-Übergang (PK, 3a, Verkaufserlös) lässt sich in **Prozent** auf Amortisation, Anlage und Cash aufteilen – bewusst nicht in Franken, weil sich der Betrag mit dem Pensionsalter ändert und eine Quote mitskaliert. **Nebenbei ein echter Fehler behoben:** Der fortgeschriebene Basiswert für Einkommen und Ausgaben wurde **vor** der Jahresschleife berechnet – effektive Werte kamen dadurch nie in der Folgephase an. Neues Modul `retirement.ts`, neuer Endpunkt `POST /api/scenarios//retirement`; 40 Tests ergänzt (221 → 261). | @@ -456,8 +457,19 @@ der die Beitrags-Obergrenze anhebt (siehe 4.11 / Feld `selfEmployed3a`). **Schritt 5 (Sparen & Verteilen)** bringt das Kernmodell des Tools zum Anfassen: Aus `Nettoeinkommen − Ausgaben` entsteht die **Sparquote**; der Nutzer verteilt sie auf Säule 3a, -Wertschriften, Amortisation und Schuldtilgung, und der **Rest bleibt sichtbar auf dem -Cash-Konto** (live gerechnet, negativer Rest wird gewarnt). Die **PK-Einzahlung** steht bewusst +Wertschriften, Amortisation und Schuldtilgung, und der **noch nicht verteilte Rest** steht +**prominent zwischen PK-Block und Verteilung** und bleibt auf dem Cash-Konto (live gerechnet, +negativer Rest wird gewarnt). Die Verteilung ist nach **Gemeinsam / Person A / Person B** +gruppiert. Hypothekarzinsen, die im vorigen Schritt als «noch nicht in den Ausgaben» markiert +sind, rechnet die Sparquote-Vorschau zu den Ausgaben dazu – so wie der Rechenkern bei +`interestHandling: ADD`. + +**Vor Schritt 1** steht ein **Willkommens-Screen** mit dem Gesamtbild der fünf Schritte (Icon, +Titel, ein Satz je Schritt); danach begleitet eine **persistente Schritt-Leiste** den ganzen +Ablauf (links im breiten Modal, auf schmalen Screens als Fortschrittsbalken): aktueller Schritt +hervorgehoben mit Kurzbeschreibung, erledigte mit Haken, kommende gedämpft. Der Nutzer weiss so +jederzeit, wo er steht und was noch folgt. Die **Zusammenfassung** ist der Abschluss und trägt +keine eigene Schritt-Nummer. Die **PK-Einzahlung** steht bewusst in einem **eigenen** Block mit dem Hinweis, dass sie vom **Bruttolohn** bezahlt wird – also **vor** dem Nettoeinkommen – und die Sparquote deshalb **nicht** schmälert. Das deckt sich exakt mit dem Rechenkern, wo der PK-Beitrag nicht zur Quote zählt ([4.6.3](#463-pension_fund)). Der @@ -1291,11 +1303,15 @@ Neuaufbau des Formulars wie zuvor bei den Dialogen. Der frühere Dialog «Plan-Einstellungen» heisst im Panel korrekt **«Szenario-Profil»** – er bearbeitet seit V6 das Szenario, nicht den Plan. -**Aufbau der Szenario-Ansicht (seit 0.28):** von oben nach unten eine schlanke +**Aufbau der Szenario-Ansicht (seit 0.28/0.29):** von oben nach unten eine schlanke **Funktions-Leiste** (Änderungshistorie · Tour · Neues Szenario aus diesem · Rechenwege · -CSV-Export · Szenario löschen · Abweichungs-Badge), dann **Nächste Schritte**, das -**Grundprofil**, die **Zeitachse**, der **Anzeige-Umschalter** (Nominal/Beide/Real, Plan/Ist) -und zuunterst die **Matrix** mit ihren «Element»-/«Lebensphase»-Knöpfen. Die Analyse-Werkzeuge +CSV-Export · Szenario löschen · Abweichungs-Badge), dann **Nächste Schritte**, danach ein +**kompakter Block aus drei Spalten** – **Grundprofil** (25 %, Angaben untereinander, +«Bearbeiten» oben rechts), **Zeitachse** (50 %) und **Kennzahlen** (25 %: Endvermögen nominal +und real, bei Ruin das Ruinalter) –, dann der **Anzeige-Umschalter** (Nominal/Beide/Real, +Plan/Ist) und zuunterst die **Matrix** mit ihren «Element»-/«Lebensphase»-Knöpfen. Der +Drei-Spalten-Block ersetzt das früher über die ganze Breite gezogene Grundprofil, das auf +breiten Bildschirmen unnötig lang wirkte. Auf schmalen Screens stapeln die drei Spalten. Die Analyse-Werkzeuge (Grafiken, Live-Simulation, Monte-Carlo, Einflussfaktoren) und die Effektiven Werte sind hier bewusst **nicht** mehr verlinkt – sie laufen ausschliesslich über ihre eigenen Menüpunkte ([3.10](#310-navigation-auf-plan-ebene-und-gespeicherte-analysen)), damit es je Funktion genau @@ -1304,9 +1320,11 @@ einen Ort gibt. ### 3.7.8 Tour und «Nächste Schritte» Die **Tour** (`Tour.tsx`) 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 oberen Funktions-Leiste startet sie jederzeit neu. Grenze: 9.24. +Übergänge, Cash und Analysen – als **Spotlight**: das jeweilige Ziel bleibt hell, der Rest wird +abgedunkelt, dazu ein pulsierender Rahmen (`data-tour`-Attribute); die Karte springt auf die +gegenüberliegende Bildschirmhälfte und lässt sich überspringen (Umsetzung und Grenze: 9.24). +Schritte ohne vorhandenes Ziel werden übersprungen; der «Tour»-Knopf in der oberen +Funktions-Leiste startet sie jederzeit neu. Sie liegt seit 0.28 in `AppShell` (nicht mehr in `PlanView`), weil dort die Plan-Erstellung zusammenläuft, und hat **zwei Auslöser**: Nach **jeder** Plan-Erstellung (Assistent, @@ -4210,13 +4228,20 @@ auch – aber es gibt kein automatisches Rollback. Das wäre nur mit Backend-Unt (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 +## 9.24 Tour: Spotlight ohne 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. +Seit 0.29 hebt die Tour ihr Ziel als **Spotlight** hervor: Der Rest der Ansicht wird +abgedunkelt, das Ziel bleibt hell und trägt einen kräftig pulsierenden Rahmen. Der Trick ist +bewusst **ohne Positionierungs-Engine und ohne separates Overlay** umgesetzt – ein einziger +CSS-Kastenschatten mit riesigem Radius (`box-shadow: 0 0 0 9999px …`) dunkelt alles **um** das +Element herum ab, ohne es selbst zu berühren (keine Transparenz-Probleme). Die Karte springt auf +die dem Ziel **gegenüberliegende** Bildschirmhälfte (oben/unten), damit sie es nie verdeckt, und +lässt sich jederzeit **überspringen**. + +Grenze: Liegt das Ziel in einem Container mit `overflow: hidden` (z. B. der Matrix-Scrollbereich), +wird der Abdunkel-Schatten an dessen Rand geklippt – der Spotlight wirkt dann nur innerhalb dieses +Bereichs. Für die heutigen Ziele ist das akzeptabel; eine echte Coach-Mark-Bibliothek mit +Viewport-genauem Cutout wäre der nächste Schritt, wenn die Tour sich bewährt. ## 9.25 Die Quote ist kein fester Betrag diff --git a/src/app/globals.css b/src/app/globals.css index e255d45..6770e28 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -207,15 +207,23 @@ body { } /* Hervorhebung des aktuellen Tour-Ziels. */ +/* Spotlight: Der riesige (9999px) erste Schatten dunkelt ALLES um das Element herum ab, das + Element selbst bleibt hell (der Schatten liegt ausserhalb). Der zweite Schatten ist der + pulsierende Akzent-Rahmen. So braucht es kein separates Overlay, und es gibt keine + Transparenz-Probleme. */ @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); } + 0%, 100% { + box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.55), 0 0 0 3px color-mix(in srgb, var(--accent) 85%, transparent); + } + 50% { + box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.55), 0 0 0 9px color-mix(in srgb, var(--accent) 45%, transparent); + } } .tour-highlight { position: relative; - z-index: 35; + z-index: 40; border-radius: 0.75rem; - animation: tour-pulse-kf 1.6s ease-in-out infinite; + animation: tour-pulse-kf 1.4s ease-in-out infinite; } /* ------------------------------------------------------------------------- diff --git a/src/components/PlanView.tsx b/src/components/PlanView.tsx index d535fa1..4210869 100644 --- a/src/components/PlanView.tsx +++ b/src/components/PlanView.tsx @@ -343,6 +343,7 @@ export function PlanView({ const hasPhases = computed.phases.length > 0; const firstPhase = computed.phases[0] ?? null; + const lastPhase = computed.phases[computed.phases.length - 1] ?? null; return (
@@ -360,39 +361,70 @@ export function PlanView({ onOpenSensitivity={onOpenSensitivity} /> - {/* Szenario-Profil */} -
- - Grundprofil (Szenario){diff?.profileChanged ? " · abweichend" : ""} - - {plan.persons.map((p) => ( - - {personLabel(p.role)}: {p.age} J., Pension {p.retirementAge} - - ))} - Inflation {plan.inflationRateDefault}% - -
+
+ + Grundprofil{diff?.profileChanged ? " · abweichend" : ""} + + +
+
+ {plan.persons.map((p) => ( + + {personLabel(p.role)}: {p.age} J., Pension {p.retirementAge} + + ))} + Inflation {plan.inflationRateDefault}% +
+
-
- + {/* Zeitachse */} +
+ +
+ + {/* Kennzahlen: Endvermögen nominal + real (+ Ruin, falls vorhanden). */} +
+ Endvermögen +
+
Nominal
+
+ {lastPhase ? formatChf(Math.round(lastPhase.endWealthNominal)) : "—"} +
+
+
+
Real (heutige Kaufkraft)
+
+ {lastPhase ? formatChf(Math.round(lastPhase.endWealthReal)) : "—"} +
+
+ {computed.ruinAge !== null && ( +
+ Kapital reicht nur bis Alter {computed.ruinAge} +
+ )} +
{/* Anzeige-Umschalter (nominal/real, Plan/Ist) -- direkt über der Matrix, weil er nur diff --git a/src/components/PlanWizard.tsx b/src/components/PlanWizard.tsx index 7ff2926..8861864 100644 --- a/src/components/PlanWizard.tsx +++ b/src/components/PlanWizard.tsx @@ -16,8 +16,8 @@ // Ehrliche Grenze: Bricht die Sequenz mittendrin ab (Netzfehler), existiert ein Teil-Plan // (siehe SPEZIFIKATION 9.23). -import { Fragment, useMemo, useRef, useState } from "react"; -import { ArrowLeft, ArrowRight, Flag, Plus, Sparkles, Trash2 } from "lucide-react"; +import { useMemo, useRef, useState } from "react"; +import { ArrowLeft, ArrowRight, CalendarRange, Check, Coins, Flag, PiggyBank, Plus, Sparkles, Trash2, User, Wallet } from "lucide-react"; import { Modal, Button } from "@/components/ui"; import { MoneyField, NumberField, TextField } from "@/components/FormField"; import { InfoBubble } from "@/components/InfoBubble"; @@ -41,6 +41,16 @@ const STEP_TITLES = [ "Fertig", ]; +// Die fünf «Tu-was»-Schritte für den Willkommens-Screen und die persistente Schritt-Leiste. +// «Fertig» (Zusammenfassung) ist der Abschluss und trägt keine eigene Nummer. +const WIZARD_OVERVIEW: { icon: React.ComponentType<{ className?: string }>; title: string; desc: string }[] = [ + { icon: User, title: "Über dich", desc: "Haushalt, Alter und Pensionsalter." }, + { icon: CalendarRange, title: "Lebensphasen", desc: "Die Abschnitte deiner Planung – bis zur Pension und danach." }, + { icon: Wallet, title: "Einkommen & Ausgaben", desc: "Was reinkommt, was rausgeht, was auf dem Konto liegt." }, + { icon: PiggyBank, title: "Vorsorge & Vermögen", desc: "Was du heute besitzt – PK, 3a, Wertschriften, Wohneigentum." }, + { icon: Coins, title: "Sparen & Verteilen", desc: "Deine Sparquote auf die Sparziele aufteilen." }, +]; + const SEGMENT_META: Record = { ERWERB: { label: "Erwerb", @@ -86,10 +96,10 @@ type Scope = "SELF" | "HOUSEHOLD" | "PERSON_A" | "PERSON_B"; type Category = "pk" | "p3a" | "etf" | "re" | "debt"; interface ScopeAssets { - pk: { on: boolean; value: number; contribution: number }; + pk: { on: boolean; value: number; contribution: number; ret: number }; // selfEmployed: «grosse Säule 3a» ohne PK -> höhere Obergrenze (Roadmap-Feedback C7c). - p3a: { on: boolean; value: number; contribution: number; selfEmployed: boolean }; - etf: { on: boolean; value: number; contribution: number }; + p3a: { on: boolean; value: number; contribution: number; selfEmployed: boolean; ret: number }; + etf: { on: boolean; value: number; contribution: number; ret: number }; // growth = Wertsteigerung %/Jahr; interestIncluded = Zinsen bereits in den Ausgaben. re: { on: boolean; price: number; mortgage: number; amortization: number; interest: number; growth: number; interestIncluded: boolean }; debt: { on: boolean; value: number; repayment: number }; @@ -97,9 +107,9 @@ interface ScopeAssets { function emptyScope(): ScopeAssets { return { - pk: { on: false, value: 0, contribution: 0 }, - p3a: { on: false, value: 0, contribution: 0, selfEmployed: false }, - etf: { on: false, value: 0, contribution: 0 }, + pk: { on: false, value: 0, contribution: 0, ret: 1.5 }, + p3a: { on: false, value: 0, contribution: 0, selfEmployed: false, ret: 2 }, + etf: { on: false, value: 0, contribution: 0, ret: 5 }, re: { on: false, price: 0, mortgage: 0, amortization: 0, interest: 1.5, growth: 1, interestIncluded: true }, debt: { on: false, value: 0, repayment: 0 }, }; @@ -112,6 +122,7 @@ export function PlanWizard({ onCreated: (scenarioId: string) => void; onClose: () => void; }) { + const [started, setStarted] = useState(false); const [step, setStep] = useState(0); const [planName, setPlanName] = useState("Meine Planung"); const [profile, setProfile] = useState(emptyProfileDraft); @@ -245,14 +256,14 @@ export function PlanWizard({ await addEl("PENSION_FUND", `Pensionskasse${suffix}`, scope.vorsorgeRole!, { currentValue: a.pk.value, annualContribution: a.pk.contribution, - expectedReturn: 1.5, + expectedReturn: a.pk.ret, }); } if (a.p3a.on) { await addEl("PILLAR_3A", `Säule 3a${suffix}`, scope.vorsorgeRole!, { currentValue: a.p3a.value, annualContribution: Math.min(a.p3a.contribution, max3aFor(scope.id)), - expectedReturn: 2, + expectedReturn: a.p3a.ret, selfEmployed3a: a.p3a.selfEmployed, }); } @@ -261,7 +272,7 @@ export function PlanWizard({ await addEl("OTHER_ASSET", `Wertschriften${suffix}`, scope.otherRole, { startValue: a.etf.value, annualContribution: a.etf.contribution, - expectedReturn: 5, + expectedReturn: a.etf.ret, }); } if (a.re.on) { @@ -324,7 +335,15 @@ export function PlanWizard({ // Die Sparquote entsteht aus Nettoeinkommen minus Ausgaben. PK-Beiträge kommen VOM // Bruttolohn (vor dem Netto) und reduzieren sie deshalb NICHT -- genau wie im Rechenkern. const totalIncome = profile.persons.reduce((s, p) => s + (incomes[p.role] ?? 0), 0); - const sparBase = totalIncome - expenses; + // Hypothekarzinsen, die NICHT bereits in den Ausgaben stecken, werden hier den Ausgaben + // zugerechnet -- so wie sie der Rechenkern bei "interestHandling: ADD" behandelt. Sonst + // zeigte die Vorschau eine zu hohe Sparquote. + const addedInterest = renderedScopes.reduce((sum, sc) => { + const re = assets[sc.id].re; + return sum + (re.on && !re.interestIncluded ? Math.round((re.mortgage * re.interest) / 100) : 0); + }, 0); + const effectiveExpenses = expenses + addedInterest; + const sparBase = totalIncome - effectiveExpenses; const allocatedFromQuote = renderedScopes.reduce((sum, sc) => { const a = assets[sc.id]; return ( @@ -344,20 +363,102 @@ export function PlanWizard({ const scopeTag = (sc: { label: string }) => (isCouple ? ` · ${sc.label}` : ""); const max3aFor = (sc: Scope) => (assets[sc].p3a.selfEmployed ? PILLAR_3A_MAX_SELF_EMPLOYED : PILLAR_3A_MAX_ANNUAL); + // Willkommens-Screen vor dem ersten Schritt: das Gesamtbild der fünf Schritte, damit der + // Nutzer von Anfang an weiss, was auf ihn zukommt (Roadmap-Feedback). + if (!started) { + return ( + +
+

+ Erstelle deinen Finanzplan in fünf einfachen Schritten – das dauert nur ein paar Minuten, und alles lässt + sich später jederzeit ändern. +

+
+ {WIZARD_OVERVIEW.map((s, i) => ( +
+ + + +
+
+ {i + 1}. {s.title} +
+

{s.desc}

+
+
+ ))} +
+
+ + +
+
+
+ ); + } + return ( -
- {STEP_TITLES.map((_, i) => ( -
- ))} -
+
+ {/* Persistente Schritt-Leiste (Desktop): der Nutzer sieht immer, wo er steht und was folgt. */} + - {step === 0 && ( +
+ {/* Fortschritt (Mobile, wo die Leiste ausgeblendet ist). */} +
+ {WIZARD_OVERVIEW.map((_, i) => ( +
+ ))} +
+ + {step === 0 && (

Ein paar Angaben zu dir – daraus baut das Tool dein Grundgerüst. Alles lässt sich später ändern. @@ -507,6 +608,7 @@ export function PlanWizard({ onChange={(v) => setScope(sc.id, "pk", { on: v })} > setScope(sc.id, "pk", { value: v })} /> + setScope(sc.id, "pk", { ret: v })} /> )} {sc.cats.includes("p3a") && ( @@ -517,6 +619,7 @@ export function PlanWizard({ onChange={(v) => setScope(sc.id, "p3a", { on: v })} > setScope(sc.id, "p3a", { value: v })} /> + setScope(sc.id, "p3a", { ret: v })} />

{totalIncome.toLocaleString("de-CH")} CHF Einkommen - {expenses.toLocaleString("de-CH")} CHF Ausgaben + {effectiveExpenses.toLocaleString("de-CH")} CHF Ausgaben = = 0 ? "text-fg" : "text-danger"}`}> {sparBase.toLocaleString("de-CH")} CHF / Jahr
+ {addedInterest > 0 && ( +

+ Enthält {addedInterest.toLocaleString("de-CH")} CHF Hypothekarzinsen, die du im vorigen Schritt als «noch + nicht in den Ausgaben» markiert hast. +

+ )}

{sparBase >= 0 ? "Dieser Betrag bleibt dir jährlich zum Sparen, Amortisieren oder Anlegen. Verteile ihn unten – was übrig bleibt, wächst auf dem Cash-Konto." @@ -625,62 +735,73 @@ export function PlanWizard({

)} - {/* Verteilung der Sparquote auf 3a / Wertschriften / Amortisation / Schuldtilgung. */} - {anyQuoteTarget ? ( -
-
- Wohin fliesst deine Sparquote? -
-
- {renderedScopes.map((sc) => ( - - {assets[sc.id].p3a.on && ( - setScope(sc.id, "p3a", { contribution: v })} - /> - )} - {assets[sc.id].etf.on && ( - setScope(sc.id, "etf", { contribution: v })} - /> - )} - {assets[sc.id].re.on && ( - setScope(sc.id, "re", { amortization: v })} - /> - )} - {assets[sc.id].debt.on && ( - setScope(sc.id, "debt", { repayment: v })} - /> - )} - - ))} -
+ {/* "Noch zu verteilen" prominent -- direkt unter der PK, ueber der Verteilung. */} + {anyQuoteTarget && ( +
+ Noch nicht verteilt (bleibt auf dem Cash-Konto) + + {restToCash.toLocaleString("de-CH")} CHF / Jahr + +
+ )} + {anyQuoteTarget && restToCash < 0 && ( +

+ Du verteilst mehr, als deine Sparquote hergibt – die Differenz muss aus dem Cash-Bestand kommen und kann ihn + ins Minus ziehen. +

+ )} - {/* Live-Rest: was nach der Verteilung aufs Cash geht. */} -
- Rest bleibt auf dem Cash-Konto - - {restToCash.toLocaleString("de-CH")} CHF / Jahr - -
- {restToCash < 0 && ( -

- Du verteilst mehr, als deine Sparquote hergibt – die Differenz muss aus dem Cash-Bestand kommen und kann - ihn ins Minus ziehen. -

- )} + {/* Verteilung der Sparquote -- gruppiert nach Eigentuemer-Bereich (Gemeinsam/A/B). */} + {anyQuoteTarget ? ( +
+ {renderedScopes + .filter((sc) => { + const a = assets[sc.id]; + return a.p3a.on || a.etf.on || a.re.on || a.debt.on; + }) + .map((sc) => ( +
+
+ {isCouple ? sc.label : "Wohin fliesst deine Sparquote?"} +
+
+ {assets[sc.id].p3a.on && ( + setScope(sc.id, "p3a", { contribution: v })} + /> + )} + {assets[sc.id].etf.on && ( + setScope(sc.id, "etf", { contribution: v })} + /> + )} + {assets[sc.id].re.on && ( + setScope(sc.id, "re", { amortization: v })} + /> + )} + {assets[sc.id].debt.on && ( + setScope(sc.id, "debt", { repayment: v })} + /> + )} +
+
+ ))}
) : (

@@ -714,25 +835,26 @@ export function PlanWizard({ {error &&

{error}

} -
- -
- {step > 0 && ( - - )} - {step < STEP_TITLES.length - 1 ? ( - - ) : ( - - )} +
+ {/* Zurück führt in Schritt 0 auf den Willkommens-Screen. */} + + {step < STEP_TITLES.length - 1 ? ( + + ) : ( + + )} +
+
diff --git a/src/components/Tour.tsx b/src/components/Tour.tsx index eb6dd79..cf82a25 100644 --- a/src/components/Tour.tsx +++ b/src/components/Tour.tsx @@ -58,6 +58,9 @@ export function Tour({ onClose }: { onClose: () => void }) { [] ); const [index, setIndex] = useState(0); + // Die Karte springt auf die dem Ziel GEGENÜBERliegende Bildschirmhälfte, damit sie das + // hervorgehobene Element nie verdeckt. + const [cardAtBottom, setCardAtBottom] = useState(true); const step = steps[index]; useEffect(() => { @@ -66,6 +69,9 @@ export function Tour({ onClose }: { onClose: () => void }) { if (!el) return; el.classList.add("tour-highlight"); el.scrollIntoView({ block: "center", behavior: "smooth" }); + const rect = el.getBoundingClientRect(); + // eslint-disable-next-line react-hooks/set-state-in-effect -- Position aus der DOM-Lage des Ziels + setCardAtBottom(rect.top + rect.height / 2 < window.innerHeight * 0.5); return () => el.classList.remove("tour-highlight"); }, [step]); @@ -81,15 +87,20 @@ export function Tour({ onClose }: { onClose: () => void }) { } return ( -
+
-
- - +
+ +
-
{step.title}
-
+
{step.title}
+
Schritt {index + 1} von {steps.length}
@@ -103,31 +114,33 @@ export function Tour({ onClose }: { onClose: () => void }) {
-

{step.text}

-
-
- {steps.map((_, i) => ( - - ))} -
-
- {index > 0 && ( - - )} - {index < steps.length - 1 ? ( - - ) : ( - - )} +

{step.text}

+
+ +
+
+ {steps.map((_, i) => ( + + ))} +
+
+ {index > 0 && ( + + )} + {index < steps.length - 1 ? ( + + ) : ( + + )} +
diff --git a/src/components/ui.tsx b/src/components/ui.tsx index 9dccdb0..30852fe 100644 --- a/src/components/ui.tsx +++ b/src/components/ui.tsx @@ -128,16 +128,19 @@ export function Modal({ onClose, children, wide, + xwide, }: { title: string; subtitle?: string; onClose: () => void; children: React.ReactNode; wide?: boolean; + xwide?: boolean; }) { const panelRef = useRef(null); useEscClose(onClose); useFocusTrap(panelRef); + const maxWidth = xwide ? "max-w-4xl" : wide ? "max-w-2xl" : "max-w-md"; 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`} + className={`ui-pop flex w-full ${maxWidth} flex-col gap-3 rounded-2xl border border-border bg-surface p-6 shadow-xl`} >