Tour-Korrekturen (Spotlight, Schritte) + 3a-Schalter nach Schritt 5
Deploy App / deploy (push) Successful in 1m1s

Tour:
- Spotlight neu aus vier fixed-Abdunkelflaechen um die Bounding-Box statt
  box-shadow -- funktioniert jetzt unabhaengig von z-index (sticky
  Matrix-Koepfe blieben hell) und overflow (im Matrix-Scrollbereich blieb
  fast alles hell); folgt dem Ziel per requestAnimationFrame
- Schritte ueberarbeitet: Zeitachse + Endvermoegen neu erklaert; der
  vormalige "Analysen"-Schritt beschreibt jetzt die obere Funktions-Leiste;
  neuer Schritt fuer das linke Menue (Analysen/Berichte/Effektive Werte);
  unsichtbare Ziele werden uebersprungen

Wizard:
- 3a-Schalter "Selbststaendig ohne PK" von Schritt 4 nach Schritt 5
  (er betrifft die Einzahlung / Sparraten-Verteilung)

SPEZIFIKATION 0.30 (9.24 neu gefasst). 267 Tests unveraendert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 22:59:51 +02:00
parent efcc04c645
commit 96fdd00d5c
6 changed files with 188 additions and 121 deletions
+17 -16
View File
@@ -4,10 +4,10 @@
| | | | | |
|---|---| |---|---|
| **Dokument** | Funktionale und Technische Spezifikation FPT | | **Dokument** | Funktionale und Technische Spezifikation FPT |
| **Version** | 0.29 | | **Version** | 0.30 |
| **Datum** | 2026-07-24 | | **Datum** | 2026-07-24 |
| **Status** | Lebendes Dokument | | **Status** | Lebendes Dokument |
| **Codestand** | Arbeitsstand nach `52c48a9` inkl. Modul-Review 2 (Feinschliff) (Branch `main`) | | **Codestand** | Arbeitsstand nach `efcc04c` inkl. Tour-Korrekturen (Branch `main`) |
| **Ersetzt** | `FDD_TDD_FPT.docx` (v1v5) im Ordner `Info Dateien` diese sind ab Version 0.1 dieses Dokuments obsolet | | **Ersetzt** | `FDD_TDD_FPT.docx` (v1v5) im Ordner `Info Dateien` diese sind ab Version 0.1 dieses Dokuments obsolet |
| **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` | | **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` |
@@ -17,6 +17,7 @@
| Version | Datum | Autor | Änderung | | Version | Datum | Autor | Änderung |
|---|---|---|---| |---|---|---|---|
| 0.30 | 2026-07-24 | Claude (Opus 4.8) | **Tour-Korrekturen und 3a-Verschiebung.** (1) Der Tour-**Spotlight** wird neu aus **vier fixed-Flächen** um die Bounding-Box des Ziels gezeichnet (Kap. 9.24) statt aus einem `box-shadow`-Trick. Grund: Der Schatten liess sticky Matrix-Köpfe (hoher z-index) hell durchscheinen und wurde im Matrix-Scrollbereich abgeschnitten (dann blieb fast alles hell). Die vier Flächen funktionieren unabhängig von z-index und overflow und folgen dem Ziel per `requestAnimationFrame`. (2) **Tour-Schritte** überarbeitet: neu erklärt sind **Zeitachse** und **Endvermögen**; der vormals «Analysen»-Schritt beschreibt jetzt korrekt die **obere Funktions-Leiste** (die Analyse-Werkzeuge sind dort nicht mehr), und ein neuer Schritt zeigt das **linke Menü** (Analysen, Berichte, Effektive Werte). Unsichtbare Ziele (z. B. das Menü auf schmalen Screens) werden übersprungen. (3) Der Schalter **«Selbstständig grosse Säule 3a»** wandert im Assistenten von Schritt 4 zu **Schritt 5**, weil er die 3a-Einzahlung (also die Sparraten-Verteilung) betrifft. Kein Eingriff in den Rechenkern; 267 Tests unverändert grün. |
| 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.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 7258 auf ca. 36288 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.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 7258 auf ca. 36288 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.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. |
@@ -452,8 +453,9 @@ Wertschriften, Wohneigentum und Schulden lassen sich gemeinsam **oder** je Perso
werden nur die **heutigen Bestandswerte** erfasst (Guthaben, Kaufpreis, Hypothek, Restschuld) werden nur die **heutigen Bestandswerte** erfasst (Guthaben, Kaufpreis, Hypothek, Restschuld)
die laufenden Jahresbeträge folgen in Schritt 5. Zusätzlich fragt das Wohneigentum die die laufenden Jahresbeträge folgen in Schritt 5. Zusätzlich fragt das Wohneigentum die
**Wertsteigerung** und den **Zins-in-Ausgaben-Schalter** ab (damit Hypothekarzinsen nicht **Wertsteigerung** und den **Zins-in-Ausgaben-Schalter** ab (damit Hypothekarzinsen nicht
doppelt zählen), und die Säule 3a einen Schalter **«Selbstständig ohne PK (grosse Säule 3a)»**, doppelt zählen). Der Schalter **«Selbstständig ohne PK (grosse Säule 3a)»** der Säule 3a steht
der die Beitrags-Obergrenze anhebt (siehe 4.11 / Feld `selfEmployed3a`). in **Schritt 5** (direkt bei der 3a-Einzahlung, denn er betrifft deren Obergrenze) und hebt die
Beitrags-Obergrenze an (siehe 4.11 / Feld `selfEmployed3a`).
**Schritt 5 (Sparen & Verteilen)** bringt das Kernmodell des Tools zum Anfassen: Aus **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, `Nettoeinkommen Ausgaben` entsteht die **Sparquote**; der Nutzer verteilt sie auf Säule 3a,
@@ -4230,18 +4232,17 @@ gebaut: Der seltene Fehlerfall rechtfertigt keinen neuen Endpunkt.
## 9.24 Tour: Spotlight ohne Engine ## 9.24 Tour: Spotlight ohne Engine
Seit 0.29 hebt die Tour ihr Ziel als **Spotlight** hervor: Der Rest der Ansicht wird Die Tour hebt ihr Ziel als **Spotlight** hervor: Der Rest der Ansicht wird abgedunkelt, das Ziel
abgedunkelt, das Ziel bleibt hell und trägt einen kräftig pulsierenden Rahmen. Der Trick ist bleibt hell und trägt einen kräftig pulsierenden Rahmen. Der abgedunkelte Bereich entsteht aus
bewusst **ohne Positionierungs-Engine und ohne separates Overlay** umgesetzt ein einziger **vier fixed-Flächen** (oben/unten/links/rechts der Bounding-Box des Ziels), die ein «Loch» am
CSS-Kastenschatten mit riesigem Radius (`box-shadow: 0 0 0 9999px …`) dunkelt alles **um** das Ziel frei lassen. Das ist bewusst **ohne Positionierungs-Engine** umgesetzt und funktioniert
Element herum ab, ohne es selbst zu berühren (keine Transparenz-Probleme). Die Karte springt auf anders als der zunächst probierte `box-shadow`-Trick (0.29) **unabhängig von z-index und
die dem Ziel **gegenüberliegende** Bildschirmhälfte (oben/unten), damit sie es nie verdeckt, und overflow**: Der Schatten liess sonst sticky Matrix-Köpfe hell durchscheinen und wurde im
lässt sich jederzeit **überspringen**. Matrix-Scrollbereich abgeschnitten. Die vier Flächen folgen dem Ziel per
`requestAnimationFrame` (sanftes Scrollen, Resize). Die Karte springt auf die dem Ziel
Grenze: Liegt das Ziel in einem Container mit `overflow: hidden` (z. B. der Matrix-Scrollbereich), **gegenüberliegende** Bildschirmhälfte (oben/unten), damit sie es nie verdeckt, und lässt sich
wird der Abdunkel-Schatten an dessen Rand geklippt der Spotlight wirkt dann nur innerhalb dieses jederzeit **überspringen**. Ziele, die es im aktuellen Plan nicht gibt oder die unsichtbar sind
Bereichs. Für die heutigen Ziele ist das akzeptabel; eine echte Coach-Mark-Bibliothek mit (z. B. das linke Menü auf schmalen Screens), werden übersprungen.
Viewport-genauem Cutout wäre der nächste Schritt, wenn die Tour sich bewährt.
## 9.25 Die Quote ist kein fester Betrag ## 9.25 Die Quote ist kein fester Betrag
+9 -12
View File
@@ -207,23 +207,20 @@ body {
} }
/* Hervorhebung des aktuellen Tour-Ziels. */ /* Hervorhebung des aktuellen Tour-Ziels. */
/* Spotlight: Der riesige (9999px) erste Schatten dunkelt ALLES um das Element herum ab, das /* Tour-Spotlight: Der abgedunkelte Bereich entsteht aus vier fixed-Flächen UM das Ziel herum
Element selbst bleibt hell (der Schatten liegt ausserhalb). Der zweite Schatten ist der (in Tour.tsx aus der Bounding-Box berechnet) -- das funktioniert unabhängig von z-index und
pulsierende Akzent-Rahmen. So braucht es kein separates Overlay, und es gibt keine overflow. Hier nur der pulsierende Rahmen, der als eigene fixed-Fläche über dem Loch liegt. */
Transparenz-Probleme. */ @keyframes tour-ring-kf {
@keyframes tour-pulse-kf {
0%, 100% { 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); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 85%, transparent), 0 0 14px 2px color-mix(in srgb, var(--accent) 40%, transparent);
} }
50% { 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); box-shadow: 0 0 0 7px color-mix(in srgb, var(--accent) 50%, transparent), 0 0 22px 6px color-mix(in srgb, var(--accent) 25%, transparent);
} }
} }
.tour-highlight { .tour-ring {
position: relative; border: 2px solid var(--accent);
z-index: 40; animation: tour-ring-kf 1.4s ease-in-out infinite;
border-radius: 0.75rem;
animation: tour-pulse-kf 1.4s ease-in-out infinite;
} }
/* ------------------------------------------------------------------------- /* -------------------------------------------------------------------------
+4 -2
View File
@@ -378,6 +378,8 @@ function AppShellInner({ username }: { username: string }) {
{plans.map((p) => { {plans.map((p) => {
const navHere = planNav?.planId === p.id; const navHere = planNav?.planId === p.id;
// Tour-Ziel «menu»: die Menü-Gruppe des gerade offenen Plans.
const isActivePlan = p.scenarios.some((s) => s.id === selectedScenarioId);
const subItem = (tab: "dashboard" | "scenarios" | "actuals" | "analyses" | "reports", label: string, Icon: typeof FolderKanban) => ( const subItem = (tab: "dashboard" | "scenarios" | "actuals" | "analyses" | "reports", label: string, Icon: typeof FolderKanban) => (
<button <button
type="button" type="button"
@@ -391,7 +393,7 @@ function AppShellInner({ username }: { username: string }) {
</button> </button>
); );
return ( return (
<div key={p.id} className="mb-1"> <div key={p.id} className="mb-1" data-tour={isActivePlan ? "menu" : undefined}>
{/* Plan-Name: Klick öffnet das Plan-Dashboard. */} {/* Plan-Name: Klick öffnet das Plan-Dashboard. */}
<div className="group flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-semibold text-fg"> <div className="group flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-semibold text-fg">
<button <button
@@ -630,7 +632,7 @@ function AppShellInner({ username }: { username: string }) {
Monte-Carlo/Einflussfaktoren sind hier bewusst NICHT mehr -- sie laufen über Monte-Carlo/Einflussfaktoren sind hier bewusst NICHT mehr -- sie laufen über
die eigenen Menüpunkte (Analysen / Effektive Werte). Hier bleiben die reinen die eigenen Menüpunkte (Analysen / Effektive Werte). Hier bleiben die reinen
Szenario-Aktionen plus Tour und CSV-Export. */} Szenario-Aktionen plus Tour und CSV-Export. */}
<div className="flex flex-wrap items-center gap-2" data-tour="analysen"> <div className="flex flex-wrap items-center gap-2" data-tour="toolbar">
{detail.plan.phases.length > 0 && ( {detail.plan.phases.length > 0 && (
<Button variant="secondary" onClick={() => setShowHistory(true)}> <Button variant="secondary" onClick={() => setShowHistory(true)}>
<History className="h-4 w-4" /> <History className="h-4 w-4" />
+1 -1
View File
@@ -405,7 +405,7 @@ export function PlanView({
</div> </div>
{/* Kennzahlen: Endvermögen nominal + real (+ Ruin, falls vorhanden). */} {/* Kennzahlen: Endvermögen nominal + real (+ Ruin, falls vorhanden). */}
<div className="flex flex-col justify-center gap-2 rounded-xl border border-border bg-surface px-4 py-3 shadow-sm lg:col-span-1"> <div data-tour="endwealth" className="flex flex-col justify-center gap-2 rounded-xl border border-border bg-surface px-4 py-3 shadow-sm lg:col-span-1">
<span className="text-xs font-semibold uppercase tracking-wide text-faint">Endvermögen</span> <span className="text-xs font-semibold uppercase tracking-wide text-faint">Endvermögen</span>
<div> <div>
<div className="text-[11px] text-muted">Nominal</div> <div className="text-[11px] text-muted">Nominal</div>
+13 -9
View File
@@ -620,15 +620,6 @@ export function PlanWizard({
> >
<MoneyField label="Aktuelles Guthaben (CHF)" value={assets[sc.id].p3a.value} onChange={(v) => setScope(sc.id, "p3a", { value: v })} /> <MoneyField label="Aktuelles Guthaben (CHF)" value={assets[sc.id].p3a.value} onChange={(v) => setScope(sc.id, "p3a", { value: v })} />
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={assets[sc.id].p3a.ret} onChange={(v) => setScope(sc.id, "p3a", { ret: v })} /> <NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={assets[sc.id].p3a.ret} onChange={(v) => setScope(sc.id, "p3a", { ret: v })} />
<label className="col-span-full flex cursor-pointer items-center gap-2 text-xs text-muted">
<input
type="checkbox"
checked={assets[sc.id].p3a.selfEmployed}
onChange={(e) => setScope(sc.id, "p3a", { selfEmployed: e.target.checked })}
/>
Selbstständig ohne Pensionskasse («grosse Säule 3a»)
<InfoBubble text={`Selbstständigerwerbende ohne PK dürfen bis 20 % des Nettoerwerbseinkommens einzahlen, gedeckelt auf ca. ${PILLAR_3A_MAX_SELF_EMPLOYED.toLocaleString("de-CH")} CHF statt der ${PILLAR_3A_MAX_ANNUAL.toLocaleString("de-CH")} CHF mit PK.`} />
</label>
</WizardToggle> </WizardToggle>
)} )}
{sc.cats.includes("etf") && ( {sc.cats.includes("etf") && (
@@ -770,6 +761,17 @@ export function PlanWizard({
</div> </div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{assets[sc.id].p3a.on && ( {assets[sc.id].p3a.on && (
<div className="flex flex-col gap-1.5 sm:col-span-2">
<label className="flex cursor-pointer items-center gap-2 text-xs text-muted">
<input
type="checkbox"
checked={assets[sc.id].p3a.selfEmployed}
onChange={(e) => setScope(sc.id, "p3a", { selfEmployed: e.target.checked })}
/>
Selbstständig ohne Pensionskasse («grosse Säule 3a»)
<InfoBubble text={`Selbstständigerwerbende ohne PK dürfen bis 20 % des Nettoerwerbseinkommens einzahlen, gedeckelt auf ca. ${PILLAR_3A_MAX_SELF_EMPLOYED.toLocaleString("de-CH")} CHF statt der ${PILLAR_3A_MAX_ANNUAL.toLocaleString("de-CH")} CHF mit PK.`} />
</label>
<div className="sm:max-w-[calc(50%-0.375rem)]">
<MoneyField <MoneyField
label="Säule 3a: Einzahlung / Jahr (CHF)" label="Säule 3a: Einzahlung / Jahr (CHF)"
help={`Maximal ${max3aFor(sc.id).toLocaleString("de-CH")} CHF${assets[sc.id].p3a.selfEmployed ? " (Selbstständig ohne PK)" : " (mit PK)"}.`} help={`Maximal ${max3aFor(sc.id).toLocaleString("de-CH")} CHF${assets[sc.id].p3a.selfEmployed ? " (Selbstständig ohne PK)" : " (mit PK)"}.`}
@@ -777,6 +779,8 @@ export function PlanWizard({
max={max3aFor(sc.id)} max={max3aFor(sc.id)}
onChange={(v) => setScope(sc.id, "p3a", { contribution: v })} onChange={(v) => setScope(sc.id, "p3a", { contribution: v })}
/> />
</div>
</div>
)} )}
{assets[sc.id].etf.on && ( {assets[sc.id].etf.on && (
<MoneyField <MoneyField
+85 -22
View File
@@ -1,10 +1,12 @@
"use client"; "use client";
// Interaktive Kurz-Tour über die Planansicht (Teil von Roadmap Nr. 10). Bewusst simpel // Interaktive Kurz-Tour über die Planansicht (Teil von Roadmap Nr. 10).
// gehalten: eine feste Karte unten rechts plus ein pulsierender Rahmen um das jeweilige //
// Ziel-Element (per data-tour-Attribut) -- kein Spotlight-Overlay, keine Positionierungs- // Spotlight ohne Engine-Bibliothek: Der abgedunkelte Bereich entsteht aus VIER fixed-Flächen
// Engine. Ziele, die es im aktuellen Plan nicht gibt (z. B. Übergänge bei nur einer // um die Bounding-Box des Ziels herum (oben/unten/links/rechts). Das lässt ein «Loch» am Ziel
// Phase), werden zur Laufzeit übersprungen. // frei und funktioniert -- anders als ein box-shadow-Trick -- unabhängig von z-index (sticky
// Matrix-Köpfe) und overflow (Matrix-Scrollbereich). Ziele, die es im aktuellen Plan nicht
// gibt oder die unsichtbar sind, werden übersprungen.
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Lightbulb, X } from "lucide-react"; import { Lightbulb, X } from "lucide-react";
@@ -22,12 +24,17 @@ const STEPS: TourStep[] = [
{ {
target: "profil", target: "profil",
title: "Dein Grundprofil", title: "Dein Grundprofil",
text: "Alter, Pensionsalter und Inflationsannahme. Alles hier gilt für dieses Szenario über «Profil» änderst du es jederzeit.", text: "Alter, Pensionsalter und Inflationsannahme die Basis dieses Szenarios. Über «Bearbeiten» änderst du sie jederzeit.",
}, },
{ {
target: "timeline", target: "timeline",
title: "Die Zeitachse", title: "Die Zeitachse",
text: "Deine Lebensphasen im Überblick: Breite = Dauer, Fähnchen = Pensionierung. Die ganze Planung denkt in diesen Phasen.", text: "Deine Lebensphasen im Zeitverlauf: Die Breite zeigt die Dauer, die Fähnchen die Pensionierungen und falls es dazu kommt, den Punkt, an dem das Kapital ausgeht. Die ganze Planung denkt in diesen Phasen.",
},
{
target: "endwealth",
title: "Dein Endvermögen",
text: "Das Ergebnis auf einen Blick: das Vermögen am Ende der Planung nominal und real (in heutiger Kaufkraft). Reicht das Kapital nicht bis zum Schluss, steht hier stattdessen das Alter, in dem es ausgeht.",
}, },
{ {
target: "matrix", target: "matrix",
@@ -45,37 +52,71 @@ const STEPS: TourStep[] = [
text: "Dein Ausgleichskonto: Was übrig bleibt, landet hier was fehlt, macht es rot. Negativ = Liquiditätslücke.", text: "Dein Ausgleichskonto: Was übrig bleibt, landet hier was fehlt, macht es rot. Negativ = Liquiditätslücke.",
}, },
{ {
target: "analysen", target: "toolbar",
title: "Analysen", title: "Aktionen zum Szenario",
text: "Grafiken, Live-Simulation, Monte-Carlo und Einflussfaktoren: Was passiert, wenn ich hier drehe wie sicher ist mein Plan und welche Annahme entscheidet wirklich?", text: "Hier oben: die Änderungshistorie ansehen, ein neues Szenario aus diesem ableiten, die Rechenwege einsehen, die Matrix als CSV exportieren und diese Tour jederzeit neu starten.",
},
{
target: "menu",
title: "Analysen & Berichte",
text: "Links im Menü öffnest du die grossen Werkzeuge: unter «Analysen» die Grafiken, die Live-Simulation, Monte-Carlo und die Einflussfaktoren; unter «Berichte» erzeugst du ein PDF. «Effektive Werte» hält deine echten Zahlen gegen den Plan.",
}, },
]; ];
interface Box {
top: number;
left: number;
width: number;
height: number;
}
export function Tour({ onClose }: { onClose: () => void }) { export function Tour({ onClose }: { onClose: () => void }) {
// Nur Schritte, deren Ziel im DOM existiert (hängt vom Plan ab). // Nur Schritte, deren Ziel im DOM existiert UND sichtbar ist (z. B. die Menü-Leiste ist auf
// schmalen Screens ausgeblendet -- getClientRects() ist dann leer).
const steps = useMemo( const steps = useMemo(
() => STEPS.filter((s) => document.querySelector(`[data-tour="${s.target}"]`) !== null), () =>
STEPS.filter((s) => {
const el = document.querySelector(`[data-tour="${s.target}"]`);
return el !== null && el.getClientRects().length > 0;
}),
[] []
); );
const [index, setIndex] = useState(0); const [index, setIndex] = useState(0);
// Die Karte springt auf die dem Ziel GEGENÜBERliegende Bildschirmhälfte, damit sie das const [rect, setRect] = useState<Box | null>(null);
// hervorgehobene Element nie verdeckt.
const [cardAtBottom, setCardAtBottom] = useState(true);
const step = steps[index]; const step = steps[index];
useEffect(() => { useEffect(() => {
if (!step) return; if (!step) return;
const el = document.querySelector(`[data-tour="${step.target}"]`); const el = document.querySelector(`[data-tour="${step.target}"]`);
if (!el) return; if (!el) return;
el.classList.add("tour-highlight");
el.scrollIntoView({ block: "center", behavior: "smooth" }); 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 // Die Bounding-Box jedes Frame nachführen (folgt dem sanften Scrollen und Resize), aber
setCardAtBottom(rect.top + rect.height / 2 < window.innerHeight * 0.5); // den State nur bei tatsächlicher Änderung neu setzen.
return () => el.classList.remove("tour-highlight"); let raf = 0;
const update = () => {
const r = el.getBoundingClientRect();
setRect((prev) =>
prev && prev.top === r.top && prev.left === r.left && prev.width === r.width && prev.height === r.height
? prev
: { top: r.top, left: r.left, width: r.width, height: r.height }
);
raf = requestAnimationFrame(update);
};
update();
return () => cancelAnimationFrame(raf);
}, [step]); }, [step]);
if (!step) return null; if (!step || !rect) return null;
const pad = 6;
const hole: Box = {
top: Math.max(0, rect.top - pad),
left: Math.max(0, rect.left - pad),
width: rect.width + pad * 2,
height: rect.height + pad * 2,
};
const cardAtBottom = rect.top + rect.height / 2 < window.innerHeight * 0.5;
function finish() { function finish() {
try { try {
@@ -86,10 +127,31 @@ export function Tour({ onClose }: { onClose: () => void }) {
onClose(); onClose();
} }
// Vier Abdunkel-Flächen um das Loch. pointer-events-none, damit nichts blockiert wird.
const dim = "fixed z-[45] bg-black/60 pointer-events-none";
const belowTop = hole.top + hole.height;
const rightOfLeft = hole.left + hole.width;
return ( return (
<>
<div className={dim} style={{ top: 0, left: 0, width: "100vw", height: hole.top }} />
<div className={dim} style={{ top: belowTop, left: 0, width: "100vw", height: `calc(100vh - ${belowTop}px)` }} />
<div className={dim} style={{ top: hole.top, left: 0, width: hole.left, height: hole.height }} />
<div
className={dim}
style={{ top: hole.top, left: rightOfLeft, width: `calc(100vw - ${rightOfLeft}px)`, height: hole.height }}
/>
{/* Pulsierender Rahmen ums Ziel. */}
<div
className="tour-ring pointer-events-none fixed z-[46] rounded-xl"
style={{ top: hole.top, left: hole.left, width: hole.width, height: hole.height }}
/>
{/* Karte -- springt auf die dem Ziel gegenüberliegende Bildschirmhälfte. */}
<div <div
key={index} key={index}
className={`ui-pop fixed left-1/2 z-50 w-[32rem] max-w-[calc(100vw-2rem)] -translate-x-1/2 rounded-2xl border-2 border-accent bg-surface p-5 shadow-2xl ${ className={`ui-pop fixed left-1/2 z-[50] w-[32rem] max-w-[calc(100vw-2rem)] -translate-x-1/2 rounded-2xl border-2 border-accent bg-surface p-5 shadow-2xl ${
cardAtBottom ? "bottom-8" : "top-8" cardAtBottom ? "bottom-8" : "top-8"
}`} }`}
> >
@@ -144,5 +206,6 @@ export function Tour({ onClose }: { onClose: () => void }) {
</div> </div>
</div> </div>
</div> </div>
</>
); );
} }