Tour-Korrekturen (Spotlight, Schritte) + 3a-Schalter nach Schritt 5
Deploy App / deploy (push) Successful in 1m1s
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:
+17
-16
@@ -4,10 +4,10 @@
|
||||
| | |
|
||||
|---|---|
|
||||
| **Dokument** | Funktionale und Technische Spezifikation FPT |
|
||||
| **Version** | 0.29 |
|
||||
| **Version** | 0.30 |
|
||||
| **Datum** | 2026-07-24 |
|
||||
| **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` (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.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.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. |
|
||||
@@ -452,8 +453,9 @@ Wertschriften, Wohneigentum und Schulden lassen sich gemeinsam **oder** je Perso
|
||||
werden nur die **heutigen Bestandswerte** erfasst (Guthaben, Kaufpreis, Hypothek, Restschuld) –
|
||||
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
|
||||
doppelt zählen), und die Säule 3a einen Schalter **«Selbstständig ohne PK (grosse Säule 3a)»**,
|
||||
der die Beitrags-Obergrenze anhebt (siehe 4.11 / Feld `selfEmployed3a`).
|
||||
doppelt zählen). Der Schalter **«Selbstständig ohne PK (grosse Säule 3a)»** der Säule 3a steht
|
||||
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
|
||||
`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
|
||||
|
||||
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.
|
||||
Die Tour hebt ihr Ziel als **Spotlight** hervor: Der Rest der Ansicht wird abgedunkelt, das Ziel
|
||||
bleibt hell und trägt einen kräftig pulsierenden Rahmen. Der abgedunkelte Bereich entsteht aus
|
||||
**vier fixed-Flächen** (oben/unten/links/rechts der Bounding-Box des Ziels), die ein «Loch» am
|
||||
Ziel frei lassen. Das ist bewusst **ohne Positionierungs-Engine** umgesetzt und funktioniert –
|
||||
anders als der zunächst probierte `box-shadow`-Trick (0.29) – **unabhängig von z-index und
|
||||
overflow**: Der Schatten liess sonst sticky Matrix-Köpfe hell durchscheinen und wurde im
|
||||
Matrix-Scrollbereich abgeschnitten. Die vier Flächen folgen dem Ziel per
|
||||
`requestAnimationFrame` (sanftes Scrollen, Resize). Die Karte springt auf die dem Ziel
|
||||
**gegenüberliegende** Bildschirmhälfte (oben/unten), damit sie es nie verdeckt, und lässt sich
|
||||
jederzeit **überspringen**. Ziele, die es im aktuellen Plan nicht gibt oder die unsichtbar sind
|
||||
(z. B. das linke Menü auf schmalen Screens), werden übersprungen.
|
||||
|
||||
## 9.25 Die Quote ist kein fester Betrag
|
||||
|
||||
|
||||
+9
-12
@@ -207,23 +207,20 @@ 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 {
|
||||
/* Tour-Spotlight: Der abgedunkelte Bereich entsteht aus vier fixed-Flächen UM das Ziel herum
|
||||
(in Tour.tsx aus der Bounding-Box berechnet) -- das funktioniert unabhängig von z-index und
|
||||
overflow. Hier nur der pulsierende Rahmen, der als eigene fixed-Fläche über dem Loch liegt. */
|
||||
@keyframes tour-ring-kf {
|
||||
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% {
|
||||
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 {
|
||||
position: relative;
|
||||
z-index: 40;
|
||||
border-radius: 0.75rem;
|
||||
animation: tour-pulse-kf 1.4s ease-in-out infinite;
|
||||
.tour-ring {
|
||||
border: 2px solid var(--accent);
|
||||
animation: tour-ring-kf 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
|
||||
@@ -378,6 +378,8 @@ function AppShellInner({ username }: { username: string }) {
|
||||
|
||||
{plans.map((p) => {
|
||||
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) => (
|
||||
<button
|
||||
type="button"
|
||||
@@ -391,7 +393,7 @@ function AppShellInner({ username }: { username: string }) {
|
||||
</button>
|
||||
);
|
||||
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. */}
|
||||
<div className="group flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-semibold text-fg">
|
||||
<button
|
||||
@@ -630,7 +632,7 @@ function AppShellInner({ username }: { username: string }) {
|
||||
Monte-Carlo/Einflussfaktoren sind hier bewusst NICHT mehr -- sie laufen über
|
||||
die eigenen Menüpunkte (Analysen / Effektive Werte). Hier bleiben die reinen
|
||||
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 && (
|
||||
<Button variant="secondary" onClick={() => setShowHistory(true)}>
|
||||
<History className="h-4 w-4" />
|
||||
|
||||
@@ -405,7 +405,7 @@ export function PlanView({
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
<div>
|
||||
<div className="text-[11px] text-muted">Nominal</div>
|
||||
|
||||
@@ -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 })} />
|
||||
<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>
|
||||
)}
|
||||
{sc.cats.includes("etf") && (
|
||||
@@ -770,13 +761,26 @@ export function PlanWizard({
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{assets[sc.id].p3a.on && (
|
||||
<MoneyField
|
||||
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)"}.`}
|
||||
value={assets[sc.id].p3a.contribution}
|
||||
max={max3aFor(sc.id)}
|
||||
onChange={(v) => setScope(sc.id, "p3a", { contribution: v })}
|
||||
/>
|
||||
<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
|
||||
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)"}.`}
|
||||
value={assets[sc.id].p3a.contribution}
|
||||
max={max3aFor(sc.id)}
|
||||
onChange={(v) => setScope(sc.id, "p3a", { contribution: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{assets[sc.id].etf.on && (
|
||||
<MoneyField
|
||||
|
||||
+137
-74
@@ -1,10 +1,12 @@
|
||||
"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.
|
||||
// Interaktive Kurz-Tour über die Planansicht (Teil von Roadmap Nr. 10).
|
||||
//
|
||||
// Spotlight ohne Engine-Bibliothek: Der abgedunkelte Bereich entsteht aus VIER fixed-Flächen
|
||||
// um die Bounding-Box des Ziels herum (oben/unten/links/rechts). Das lässt ein «Loch» am Ziel
|
||||
// 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 { Lightbulb, X } from "lucide-react";
|
||||
@@ -22,12 +24,17 @@ 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.",
|
||||
text: "Alter, Pensionsalter und Inflationsannahme – die Basis dieses Szenarios. Über «Bearbeiten» änderst du sie jederzeit.",
|
||||
},
|
||||
{
|
||||
target: "timeline",
|
||||
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",
|
||||
@@ -45,37 +52,71 @@ const STEPS: TourStep[] = [
|
||||
text: "Dein Ausgleichskonto: Was übrig bleibt, landet hier – was fehlt, macht es rot. Negativ = Liquiditätslücke.",
|
||||
},
|
||||
{
|
||||
target: "analysen",
|
||||
title: "Analysen",
|
||||
text: "Grafiken, Live-Simulation, Monte-Carlo und Einflussfaktoren: Was passiert, wenn ich hier drehe – wie sicher ist mein Plan – und welche Annahme entscheidet wirklich?",
|
||||
target: "toolbar",
|
||||
title: "Aktionen zum Szenario",
|
||||
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 }) {
|
||||
// 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(
|
||||
() => 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);
|
||||
// Die Karte springt auf die dem Ziel GEGENÜBERliegende Bildschirmhälfte, damit sie das
|
||||
// hervorgehobene Element nie verdeckt.
|
||||
const [cardAtBottom, setCardAtBottom] = useState(true);
|
||||
const [rect, setRect] = useState<Box | null>(null);
|
||||
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" });
|
||||
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");
|
||||
|
||||
// Die Bounding-Box jedes Frame nachführen (folgt dem sanften Scrollen und Resize), aber
|
||||
// den State nur bei tatsächlicher Änderung neu setzen.
|
||||
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]);
|
||||
|
||||
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() {
|
||||
try {
|
||||
@@ -86,63 +127,85 @@ export function Tour({ onClose }: { onClose: () => void }) {
|
||||
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 (
|
||||
<div
|
||||
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 ${
|
||||
cardAtBottom ? "bottom-8" : "top-8"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-accent text-accent-fg shadow-sm">
|
||||
<Lightbulb className="h-5 w-5" />
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-base font-semibold text-fg">{step.title}</div>
|
||||
<div className="text-[11px] font-medium uppercase tracking-wide text-accent">
|
||||
Schritt {index + 1} von {steps.length}
|
||||
<>
|
||||
<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
|
||||
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 ${
|
||||
cardAtBottom ? "bottom-8" : "top-8"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-accent text-accent-fg shadow-sm">
|
||||
<Lightbulb className="h-5 w-5" />
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-base font-semibold text-fg">{step.title}</div>
|
||||
<div className="text-[11px] font-medium uppercase tracking-wide text-accent">
|
||||
Schritt {index + 1} von {steps.length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={finish}
|
||||
aria-label="Tour beenden"
|
||||
className="rounded-md p-1 text-faint hover:bg-surface-2 hover:text-fg"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-relaxed text-muted">{step.text}</p>
|
||||
<div className="mt-4 flex items-center justify-between gap-2">
|
||||
<button type="button" onClick={finish} className="text-xs font-medium text-faint hover:text-muted">
|
||||
Überspringen
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex gap-1">
|
||||
{steps.map((_, i) => (
|
||||
<span key={i} className={`h-1.5 w-1.5 rounded-full ${i === index ? "bg-accent" : "bg-border-strong"}`} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{index > 0 && (
|
||||
<Button variant="secondary" size="sm" onClick={() => setIndex(index - 1)}>
|
||||
Zurück
|
||||
</Button>
|
||||
)}
|
||||
{index < steps.length - 1 ? (
|
||||
<Button size="sm" onClick={() => setIndex(index + 1)}>
|
||||
Weiter
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" onClick={finish}>
|
||||
Fertig
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={finish}
|
||||
aria-label="Tour beenden"
|
||||
className="rounded-md p-1 text-faint hover:bg-surface-2 hover:text-fg"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-relaxed text-muted">{step.text}</p>
|
||||
<div className="mt-4 flex items-center justify-between gap-2">
|
||||
<button type="button" onClick={finish} className="text-xs font-medium text-faint hover:text-muted">
|
||||
Überspringen
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex gap-1">
|
||||
{steps.map((_, i) => (
|
||||
<span key={i} className={`h-1.5 w-1.5 rounded-full ${i === index ? "bg-accent" : "bg-border-strong"}`} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{index > 0 && (
|
||||
<Button variant="secondary" size="sm" onClick={() => setIndex(index - 1)}>
|
||||
Zurück
|
||||
</Button>
|
||||
)}
|
||||
{index < steps.length - 1 ? (
|
||||
<Button size="sm" onClick={() => setIndex(index + 1)}>
|
||||
Weiter
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" onClick={finish}>
|
||||
Fertig
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user