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
+9 -12
View File
@@ -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;
}
/* -------------------------------------------------------------------------
+4 -2
View File
@@ -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" />
+1 -1
View File
@@ -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>
+20 -16
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 })} />
<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
View File
@@ -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>
</>
);
}