UI-Gesamtumbau: Du-Form, Inspector-Panel, Onboarding-Assistent, Tour, Palette
Deploy App / deploy (push) Successful in 1m1s

Rein an der Oberflaeche -- Berechnung, Datenmodell und API-Semantik unveraendert
(103 Tests unveraendert gruen).

Paket A (Fundament):
- durchgehend Du-Form und echte Umlaute in allen sichtbaren Texten,
  inkl. API-Fehlermeldungen (vorher Mix aus Sie/Du und ae/oe/ue)
- neue UI-Primitiven (ui.tsx): Button, Modal mit ESC/Fokus-Falle/Animation,
  Bestaetigungs-Dialog statt window.confirm, Toasts statt alert,
  Skeleton-Loader, EmptyState
- eigene Attention-Farbe (Amber) fuer offene Entscheide, getrennt vom Akzent
- Micro-Interactions mit prefers-reduced-motion-Fallback

Paket B (Onboarding, Roadmap Nr. 10):
- gefuehrter Plan-Assistent in 5 Schritten; Einkommen bewusst pro Person
  (raeumt die 9.9-AHV-Falle aus); reine Orchestrierung bestehender Endpunkte
- Beispielplan mit einem Klick; Uebergaenge absichtlich offen
- interaktive Tour ueber die Planansicht (localStorage, jederzeit neu startbar)
- abgeleitete "Naechste Schritte"-Karte (offene Entscheide, fehlende Elemente,
  fehlende Pensionsphase, Ruin -> Einflussfaktoren)

Paket C (Struktur):
- Inspector-Panel rechts statt Modals fuer alle Einzel-Bearbeitungen;
  Matrix bleibt sichtbar, Zellklick wechselt den Inhalt
- Phasenkopf auf vier Kern-Infos entschlackt (Rest in der 0.11-Detailansicht)
- Matrix mit eigenem Scrollbereich, Koepfe beidachsig fixiert
- Sidebar-Gruppen "Meine Plaene" / "Wissen"; "So rechnet FPT" statt
  SPEZIFIKATION; "Szenario-Profil" statt "Plan-Einstellungen"
- Aktions-Icons ohne Hover sichtbar (Touch)

Paket D (Extras):
- Sparklines je Element-Zeile aus den 0.11-Verlaufswerten
- Befehls-Palette (Ctrl/Cmd+K)
- Ruin-Banner verlinkt auf die Einflussfaktoren

Nebenbei: der ProfileMenu-Lint-Fehler und der Selection-Rest (9.17) sind
behoben -- npm run lint laeuft erstmals fehlerfrei.

SPEZIFIKATION auf 0.13: neue Kapitel 3.2.8, 3.7.6-3.7.9, 9.23, 9.24;
3.6.3 und 3.7.1 ueberarbeitet, 9.17 bereinigt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 07:40:42 +02:00
parent e1f74fca95
commit c2fb82b0de
60 changed files with 2915 additions and 1210 deletions
+2 -2
View File
@@ -17,7 +17,7 @@ export async function POST(request: NextRequest) {
const body = await request.json();
const parsed = changePasswordSchema.safeParse(body);
if (!parsed.success) {
const message = parsed.error.issues[0]?.message ?? "Ungueltige Eingabe.";
const message = parsed.error.issues[0]?.message ?? "Ungültige Eingabe.";
return NextResponse.json({ error: message }, { status: 400 });
}
@@ -25,7 +25,7 @@ export async function POST(request: NextRequest) {
await changeUserPassword(userId, parsed.data.currentPassword, parsed.data.newPassword);
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : "Passwort aendern fehlgeschlagen." },
{ error: e instanceof Error ? e.message : "Passwort ändern fehlgeschlagen." },
{ status: 400 }
);
}
+1 -1
View File
@@ -12,7 +12,7 @@ export async function POST(request: NextRequest) {
const body = await request.json();
const parsed = registerSchema.safeParse(body);
if (!parsed.success) {
const message = parsed.error.issues[0]?.message ?? "Ungueltige Eingabe.";
const message = parsed.error.issues[0]?.message ?? "Ungültige Eingabe.";
return NextResponse.json({ error: message }, { status: 400 });
}
@@ -21,7 +21,7 @@ export async function PUT(
const body = await request.json();
const parsed = phaseDataSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
await prisma.elementPhaseValue.upsert({
where: { elementId_phaseId: { elementId, phaseId } },
+1 -1
View File
@@ -19,7 +19,7 @@ export async function PATCH(
const body = await request.json();
const parsed = patchSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
await prisma.financialElement.update({ where: { id: element.id }, data: { name: parsed.data.name } });
return NextResponse.json({ ok: true });
@@ -4,7 +4,7 @@ import { getOwnedElement } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { transitionDataSchema } from "@/lib/elements";
// Speichert den Uebergangs-Entscheid eines Elements nach der Phase fromPhase (Upsert).
// Speichert den Übergangs-Entscheid eines Elements nach der Phase fromPhase (Upsert).
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ elementId: string; fromPhaseId: string }> }
@@ -21,7 +21,7 @@ export async function PUT(
const body = await request.json();
const parsed = transitionDataSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
await prisma.elementTransitionValue.upsert({
where: { elementId_fromPhaseId: { elementId, fromPhaseId } },
@@ -4,7 +4,7 @@ import { getOwnedPhase } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { cashTransitionSchema } from "@/lib/elements";
// Speichert den Cash-Entscheid beim UEBERGANG nach dieser Phase: 1:1 uebernehmen oder
// Speichert den Cash-Entscheid beim UEBERGANG nach dieser Phase: 1:1 übernehmen oder
// einmaliger Zufluss / einmalige Kosten (Roadmap Nr. 1). Cash ist kein FinancialElement,
// deshalb liegt der Entscheid direkt an der Von-Phase.
export async function PUT(
@@ -20,7 +20,7 @@ export async function PUT(
const body = await request.json();
const parsed = cashTransitionSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
await prisma.phase.update({
where: { id: phase.id },
+4 -4
View File
@@ -23,11 +23,11 @@ export async function PUT(
const body = await request.json();
const parsed = updatePhaseSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
let duration = parsed.data.durationYears;
if (duration != null) {
// Dauer ans naechste Pensionsereignis kappen (Jahre vor dieser Phase).
// Dauer ans nächste Pensionsereignis kappen (Jahre vor dieser Phase).
const scenario = await getOwnedScenario(existing.scenarioId, userId);
if (scenario) {
const planInput = toPlanInput(scenario);
@@ -50,7 +50,7 @@ export async function PUT(
return NextResponse.json({ phase: { id: phase.id } });
}
// Nur die letzte Phase kann geloescht werden (Verkettung bleibt intakt).
// Nur die letzte Phase kann gelöscht werden (Verkettung bleibt intakt).
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ phaseId: string }> }
@@ -66,7 +66,7 @@ export async function DELETE(
where: { scenarioId: phase.scenarioId, sequenceNumber: { gt: phase.sequenceNumber } },
});
if (later) {
return NextResponse.json({ error: "Nur die letzte Phase kann geloescht werden." }, { status: 400 });
return NextResponse.json({ error: "Nur die letzte Phase kann gelöscht werden." }, { status: 400 });
}
await prisma.phase.delete({ where: { id: phaseId } });
+3 -3
View File
@@ -3,7 +3,7 @@ import { z } from "zod";
import { prisma } from "@/lib/db";
import { getCurrentUserId } from "@/lib/session";
// Der Plan ist nur noch der Behaelter: er traegt den Namen; die Finanzdaten liegen im Szenario.
// Der Plan ist nur noch der Behälter: er trägt den Namen; die Finanzdaten liegen im Szenario.
const patchSchema = z.object({ name: z.string().min(1).max(120) });
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ planId: string }> }) {
@@ -15,13 +15,13 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
const parsed = patchSchema.safeParse(await request.json());
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
const updated = await prisma.plan.update({ where: { id: plan.id }, data: { name: parsed.data.name } });
return NextResponse.json({ plan: { id: updated.id, name: updated.name } });
}
// Loescht den Plan inkl. aller Szenarien (Cascade).
// Löscht den Plan inkl. aller Szenarien (Cascade).
export async function DELETE(_request: NextRequest, { params }: { params: Promise<{ planId: string }> }) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
+4 -4
View File
@@ -23,15 +23,15 @@ const createPlanSchema = z.object({ name: z.string().min(1).max(120) }).and(scen
export function validatePersonsForType(data: z.infer<typeof scenarioProfileSchema>): string | null {
if (data.householdType === "SINGLE" && data.persons.length !== 1) {
return "Einzelperson-Plan benoetigt genau eine Person.";
return "Einzelperson-Plan benötigt genau eine Person.";
}
if (data.householdType === "COUPLE" && data.persons.length !== 2) {
return "Paar-Plan benoetigt genau zwei Personen (Person A und Person B).";
return "Paar-Plan benötigt genau zwei Personen (Person A und Person B).";
}
return null;
}
// Liste der Plaene mit ihrem Szenario-Baum (nur Kopfdaten).
// Liste der Pläne mit ihrem Szenario-Baum (nur Kopfdaten).
export async function GET() {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
@@ -52,7 +52,7 @@ export async function GET() {
return NextResponse.json({ plans });
}
// Legt einen Plan an -- zusammen mit seinem Basisszenario, das das Grundprofil traegt.
// Legt einen Plan an -- zusammen mit seinem Basisszenario, das das Grundprofil trägt.
export async function POST(request: NextRequest) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
@@ -6,17 +6,17 @@ import { getCurrentUserId } from "@/lib/session";
const copySchema = z.object({ name: z.string().min(1).max(120) });
// Erstellt ein neues Szenario als vollstaendige Kopie eines bestehenden. Das Original wird
// zum Elternteil -- damit haengt der Baum in der Seitenleiste und der Diff hat seine Basis.
// Jede kopierte Phase/jedes kopierte Element traegt einen Herkunfts-Verweis auf sein
// Gegenstueck im Original; darauf beruht die Abweichungs-Markierung im UI.
// Erstellt ein neues Szenario als vollständige Kopie eines bestehenden. Das Original wird
// zum Elternteil -- damit hängt der Baum in der Seitenleiste und der Diff hat seine Basis.
// Jede kopierte Phase/jedes kopierte Element trägt einen Herkunfts-Verweis auf sein
// Gegenstück im Original; darauf beruht die Abweichungs-Markierung im UI.
export async function POST(request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { scenarioId } = await params;
const parsed = copySchema.safeParse(await request.json());
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
const source = await getOwnedScenario(scenarioId, userId);
if (!source) return NextResponse.json({ error: "Ursprungs-Szenario nicht gefunden." }, { status: 404 });
@@ -43,7 +43,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
},
});
// Phasen kopieren (alte -> neue Id merken, fuer die Werte-Zuordnung).
// Phasen kopieren (alte -> neue Id merken, für die Werte-Zuordnung).
const phaseIdMap = new Map<string, string>();
for (const phase of source.phases) {
const p = await tx.phase.create({
@@ -59,7 +59,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
phaseIdMap.set(phase.id, p.id);
}
// Elemente inkl. Phasen- und Uebergangswerten kopieren.
// Elemente inkl. Phasen- und Übergangswerten kopieren.
for (const el of source.elements) {
const newEl = await tx.financialElement.create({
data: {
@@ -34,7 +34,7 @@ export async function POST(
const body = await request.json();
const parsed = createSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
const { category, name } = parsed.data;
let ownerRole = parsed.data.ownerRole ?? null;
@@ -12,8 +12,8 @@ const createPhaseSchema = z.object({
durationYears: z.number().int().min(1).max(80).optional(),
});
// Legt eine neue Lebensphase am Ende der Kette an. Die Dauer wird ans naechste
// Pensionsereignis gekappt. Fuer bestehende Elemente werden die editierbaren Felder
// Legt eine neue Lebensphase am Ende der Kette an. Die Dauer wird ans nächste
// Pensionsereignis gekappt. Für bestehende Elemente werden die editierbaren Felder
// vorbelegt; die Startwerte werden in der Berechnung live aus der Vorphase fortgeschrieben.
export async function POST(
request: NextRequest,
@@ -28,7 +28,7 @@ export async function POST(
const body = await request.json().catch(() => ({}));
const parsed = createPhaseSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
const planInput = toPlanInput(scenario);
const yearsBefore = planInput.phases.reduce((s, p) => s + p.durationYears, 0);
@@ -40,12 +40,12 @@ export async function POST(
const nextSequence = planInput.phases.length + 1;
// Phasentyp der neuen Phase fuer den Default-Namen bestimmen.
// Phasentyp der neuen Phase für den Default-Namen bestimmen.
const anyRetiredAtStart = planInput.persons.some((p) => p.age + yearsBefore >= p.retirementAge);
const defaultName =
parsed.data.name ?? (nextSequence === 1 ? "Erste Lebensphase" : anyRetiredAtStart ? "Pensionsphase" : "Erwerbsphase");
// Status der Elemente in der bisher letzten Phase (verkauft/getilgt nicht fortfuehren).
// Status der Elemente in der bisher letzten Phase (verkauft/getilgt nicht fortführen).
const prevComputed = planInput.phases.length > 0 ? computePlan(planInput) : null;
const lastPhaseId = planInput.phases.at(-1)?.id;
const prevPhase = prevComputed?.phases.find((p) => p.id === lastPhaseId) ?? null;
@@ -77,14 +77,14 @@ export async function POST(
return NextResponse.json({ phase: { id: phase.id } }, { status: 201 });
}
// Vorbelegung fuer eine neue Phase: nur die editierbaren Felder werden uebernommen. Start-
// bzw. Restwerte (PK/3a/Vermoegen/Hypothek/Schuld) werden in der Berechnung live aus der
// Vorbelegung für eine neue Phase: nur die editierbaren Felder werden übernommen. Start-
// bzw. Restwerte (PK/3a/Vermögen/Hypothek/Schuld) werden in der Berechnung live aus der
// Vorphase fortgeschrieben und deshalb hier NICHT als Snapshot gespeichert.
function buildCarryData(category: string, prev: PhaseData): PhaseData {
switch (category) {
case "INCOME":
case "EXPENSE":
// Basis wird live indexiert fortgeschrieben; nur der Teuerungsausgleich wird uebernommen.
// Basis wird live indexiert fortgeschrieben; nur der Teuerungsausgleich wird übernommen.
return prev.teuerungsausgleich != null ? { teuerungsausgleich: prev.teuerungsausgleich } : {};
case "AHV":
return { gapYears: 0 };
+4 -4
View File
@@ -6,7 +6,7 @@ import { getCurrentUserId } from "@/lib/session";
import { computePlan } from "@/lib/calculations";
import { scenarioProfileSchema, validatePersonsForType } from "@/app/api/plans/route";
// Liefert das Szenario samt Berechnung -- und zusaetzlich das ELTERN-Szenario als
// Liefert das Szenario samt Berechnung -- und zusätzlich das ELTERN-Szenario als
// Vergleichsbasis, damit das UI die Abweichungen markieren kann (Basisszenario: null).
export async function GET(_request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) {
const userId = await getCurrentUserId();
@@ -58,7 +58,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
const parsed = patchSchema.safeParse(await request.json());
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
const data = parsed.data;
if ("householdType" in data) {
@@ -91,7 +91,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
return NextResponse.json({ scenario: { id: updated.id, name: updated.name } });
}
// Loescht ein Szenario. Das Basisszenario kann nicht geloescht werden (dafuer den Plan loeschen).
// Löscht ein Szenario. Das Basisszenario kann nicht gelöscht werden (dafür den Plan löschen).
export async function DELETE(_request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
@@ -101,7 +101,7 @@ export async function DELETE(_request: NextRequest, { params }: { params: Promis
if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
if (scenario.isBase) {
return NextResponse.json(
{ error: "Das Basisszenario kann nicht geloescht werden. Loeschen Sie stattdessen den Plan." },
{ error: "Das Basisszenario kann nicht gelöscht werden. Löschen Sie stattdessen den Plan." },
{ status: 400 }
);
}
+65 -6
View File
@@ -1,7 +1,7 @@
@import "tailwindcss";
/* -------------------------------------------------------------------------
Semantische Farb-Tokens. Drei umschaltbare Schemata ueber data-theme am
Semantische Farb-Tokens. Drei umschaltbare Schemata über data-theme am
<html>: "light", "dark", "warm". Ohne explizite Wahl folgt das Standard-
:root der OS-Einstellung (prefers-color-scheme). Umschaltung: lib/theme.ts.
------------------------------------------------------------------------- */
@@ -33,6 +33,12 @@
--diff-removed: #71717a;
--diff-removed-soft: #f4f4f5;
--person-b: #0ea5e9;
/* Attention: "hier fehlt eine Eingabe" -- bewusst getrennt vom Akzent, damit
"offen" und "klickbar" nicht dieselbe Farbe tragen. */
--attention: #d97706;
--attention-fg: #ffffff;
--attention-soft: #fef3c7;
--attention-soft-fg: #92400e;
}
:root[data-theme="dark"] {
@@ -61,9 +67,13 @@
--diff-removed: #a1a1aa;
--diff-removed-soft: rgba(161, 161, 170, 0.12);
--person-b: #38bdf8;
--attention: #f59e0b;
--attention-fg: #1c1400;
--attention-soft: rgba(245, 158, 11, 0.16);
--attention-soft-fg: #fcd34d;
}
/* Warm / Sunset: cremefarbener Grund, Koralle-Akzent, Amber-Sekundaerton. */
/* Warm / Sunset: cremefarbener Grund, Koralle-Akzent, Amber-Sekundärton. */
:root[data-theme="warm"] {
--bg: #fbf7f2;
--surface: #fffdfa;
@@ -80,7 +90,7 @@
--accent-soft: #fcebe2;
--accent-soft-fg: #b24521;
--danger: #c0392b;
--danger-soft: #fbeae7;
--danger-soft: #fbeä7;
--success: #2e9e7b;
--person-a: #e8663c;
--diff: #b45309;
@@ -90,6 +100,10 @@
--diff-removed: #8a7f72;
--diff-removed-soft: #f0e8dd;
--person-b: #f2a93b;
--attention: #c77414;
--attention-fg: #ffffff;
--attention-soft: #fdeed3;
--attention-soft-fg: #8f5410;
}
/* Ohne gespeicherte Wahl der Dunkel-OS-Einstellung folgen. */
@@ -120,6 +134,10 @@
--diff-removed: #a1a1aa;
--diff-removed-soft: rgba(161, 161, 170, 0.12);
--person-b: #38bdf8;
--attention: #f59e0b;
--attention-fg: #1c1400;
--attention-soft: rgba(245, 158, 11, 0.16);
--attention-soft-fg: #fcd34d;
}
}
@@ -147,6 +165,10 @@
--color-diff-added-soft: var(--diff-added-soft);
--color-diff-removed: var(--diff-removed);
--color-diff-removed-soft: var(--diff-removed-soft);
--color-attention: var(--attention);
--color-attention-fg: var(--attention-fg);
--color-attention-soft: var(--attention-soft);
--color-attention-soft-fg: var(--attention-soft-fg);
--color-person-a: var(--person-a);
--color-person-b: var(--person-b);
--font-sans: var(--font-geist-sans);
@@ -156,12 +178,49 @@
body {
background: var(--bg);
color: var(--fg);
font-family: Arial, Helvetica, sans-serif;
font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif;
}
/* -------------------------------------------------------------------------
Dokument-Darstellung fuer gerenderten Markdown (Spezifikation, /spezifikation).
Bewusst ueber die semantischen Tokens, damit alle drei Farbschemata greifen.
Micro-Interactions: kurze, dezente Übergänge für Dialoge, Panels, Toasts.
150-220 ms -- schnell genug, um nie zu bremsen (Doherty-Schwelle).
------------------------------------------------------------------------- */
@keyframes ui-fade-kf {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes ui-pop-kf {
from { opacity: 0; transform: translateY(6px) scale(0.985); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes ui-slide-in-kf {
from { opacity: 0.4; transform: translateX(24px); }
to { opacity: 1; transform: translateX(0); }
}
.ui-fade { animation: ui-fade-kf 150ms ease-out; }
.ui-pop { animation: ui-pop-kf 180ms cubic-bezier(0.2, 0.9, 0.3, 1); }
.ui-slide-in { animation: ui-slide-in-kf 200ms cubic-bezier(0.2, 0.9, 0.3, 1); }
@media (prefers-reduced-motion: reduce) {
.ui-fade, .ui-pop, .ui-slide-in { animation: none; }
}
/* Hervorhebung des aktuellen Tour-Ziels. */
@keyframes tour-pulse-kf {
0%, 100% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 45%, transparent); }
50% { box-shadow: 0 0 0 7px color-mix(in srgb, var(--accent) 20%, transparent); }
}
.tour-highlight {
position: relative;
z-index: 35;
border-radius: 0.75rem;
animation: tour-pulse-kf 1.6s ease-in-out infinite;
}
/* -------------------------------------------------------------------------
Dokument-Darstellung für gerenderten Markdown (Spezifikation, /spezifikation).
Bewusst über die semantischen Tokens, damit alle drei Farbschemata greifen.
------------------------------------------------------------------------- */
.md-doc {
+1 -1
View File
@@ -14,7 +14,7 @@ const geistMono = Geist_Mono({
export const metadata: Metadata = {
title: "FPT — Financial Planning Tool",
description: "Persoenliche Finanzplanung ueber Lebensabschnittsphasen (AICDS)",
description: "Persönliche Finanzplanung über Lebensabschnittsphasen (AICDS)",
};
// Setzt data-theme aus localStorage noch vor dem ersten Paint (verhindert FOUC).
+3 -3
View File
@@ -21,7 +21,7 @@ function LoginForm() {
setError(null);
if (mode === "register" && password !== passwordConfirm) {
setError("Die Passwoerter stimmen nicht ueberein.");
setError("Die Passwörter stimmen nicht überein.");
return;
}
@@ -59,7 +59,7 @@ function LoginForm() {
Financial Planning Tool
</h1>
<p className="text-xs text-muted">
Persoenliche Finanzplanung ueber Lebensphasen
Persönliche Finanzplanung über Lebensphasen
</p>
</div>
@@ -116,7 +116,7 @@ function LoginForm() {
<input
type="password"
autoComplete="new-password"
placeholder="Passwort bestaetigen"
placeholder="Passwort bestätigen"
value={passwordConfirm}
onChange={(e) => setPasswordConfirm(e.target.value)}
className={inputClass}
+2 -2
View File
@@ -12,7 +12,7 @@ export default function Home() {
.get<{ user: { username: string } }>("/api/auth/me")
.then((data) => setUsername(data.user.username))
.catch(() => {
// Session abgelaufen/ungueltig -- Middleware leitet beim naechsten Request um.
// Session abgelaufen/ungültig -- Middleware leitet beim nächsten Request um.
window.location.href = "/login";
});
}, []);
@@ -20,7 +20,7 @@ export default function Home() {
if (username === null) {
return (
<div className="flex flex-1 items-center justify-center">
<p className="text-sm text-muted">Laedt</p>
<p className="text-sm text-muted">Lädt</p>
</div>
);
}
+411 -218
View File
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
BarChart3,
BookOpen,
@@ -13,7 +13,9 @@ import {
Menu,
PiggyBank,
Plus,
Search,
SlidersHorizontal,
Sparkles,
Tornado,
Trash2,
X,
@@ -27,8 +29,21 @@ import { SystemParametersView } from "@/components/SystemParametersView";
import { PlanTraceDialog } from "@/components/DetailView";
import { computePlan } from "@/lib/calculations";
import { ProfileMenu } from "@/components/ProfileMenu";
import { PlanWizard } from "@/components/PlanWizard";
import { CommandPalette, type PaletteAction } from "@/components/CommandPalette";
import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields";
import {
Button,
ConfirmProvider,
EmptyState,
Modal,
PlanSkeleton,
ToastProvider,
useConfirm,
useToast,
} from "@/components/ui";
import { api } from "@/lib/api-client";
import { createDemoPlan } from "@/lib/demoplan";
import { computeScenarioDiff } from "@/lib/diff";
import type { PlanInput, PlanListItem, ScenarioMeta } from "@/lib/types";
import type { PlanComputed } from "@/lib/calculations";
@@ -40,13 +55,31 @@ interface ScenarioDetail {
meta: ScenarioMeta & { planName: string };
}
// Die Provider (Toast, Bestätigung) müssen UM die Shell liegen, damit deren Hooks
// innerhalb funktionieren.
export function AppShell({ username }: { username: string }) {
return (
<ToastProvider>
<ConfirmProvider>
<AppShellInner username={username} />
</ConfirmProvider>
</ToastProvider>
);
}
function AppShellInner({ username }: { username: string }) {
const toast = useToast();
const confirm = useConfirm();
const [plans, setPlans] = useState<PlanListItem[]>([]);
const [selectedScenarioId, setSelectedScenarioId] = useState<string | null>(null);
const [detail, setDetail] = useState<ScenarioDetail | null>(null);
const [loading, setLoading] = useState(true);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [showNewPlan, setShowNewPlan] = useState(false);
const [showNewPlanChooser, setShowNewPlanChooser] = useState(false);
const [showWizard, setShowWizard] = useState(false);
const [showEmptyPlanDialog, setShowEmptyPlanDialog] = useState(false);
const [creatingDemo, setCreatingDemo] = useState(false);
const [copyFrom, setCopyFrom] = useState<ScenarioMeta | null>(null);
const [showSpec, setShowSpec] = useState(false);
const [showCharts, setShowCharts] = useState(false);
@@ -54,6 +87,7 @@ export function AppShell({ username }: { username: string }) {
const [showSensitivity, setShowSensitivity] = useState(false);
const [showSystemParams, setShowSystemParams] = useState(false);
const [showPlanTraces, setShowPlanTraces] = useState(false);
const [showPalette, setShowPalette] = useState(false);
// Sprungmarke in die SPEZIFIKATION, gesetzt aus einem Rechenweg heraus.
const [specAnchor, setSpecAnchor] = useState<string | null>(null);
@@ -71,7 +105,7 @@ export function AppShell({ username }: { username: string }) {
}, []);
// silent = Hintergrund-Refresh ohne Loading-Umschaltung: die PlanView bleibt montiert,
// damit die Scrollposition (z. B. nach dem Schliessen eines Popups) erhalten bleibt.
// damit die Scrollposition (z. B. nach dem Schliessen eines Panels) erhalten bleibt.
const loadDetail = useCallback(async (scenarioId: string, silent = false) => {
if (!silent) setLoading(true);
try {
@@ -95,6 +129,18 @@ export function AppShell({ username }: { username: string }) {
}
}, [selectedScenarioId, loadDetail]);
// Cmd/Ctrl+K öffnet die Befehls-Palette.
useEffect(() => {
function onKey(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
setShowPalette((v) => !v);
}
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
function refreshCurrent() {
if (selectedScenarioId) loadDetail(selectedScenarioId, true);
}
@@ -107,8 +153,16 @@ export function AppShell({ username }: { username: string }) {
}
async function handleDeletePlan(id: string) {
if (!confirm("Diesen Plan mit ALLEN Szenarien wirklich loeschen?")) return;
const plan = plans.find((p) => p.id === id);
const ok = await confirm({
title: "Plan löschen?",
message: `«${plan?.name ?? "Plan"}» wird mit allen Szenarien unwiderruflich gelöscht.`,
confirmLabel: "Endgültig löschen",
danger: true,
});
if (!ok) return;
await api.delete(`/api/plans/${id}`);
toast("success", "Plan gelöscht.");
const rest = await loadPlans();
if (!rest.some((p) => p.scenarios.some((s) => s.id === selectedScenarioId))) {
setSelectedScenarioId(null);
@@ -116,19 +170,61 @@ export function AppShell({ username }: { username: string }) {
}
async function handleDeleteScenario(s: ScenarioMeta) {
if (!confirm(`Szenario "${s.name}" wirklich loeschen?`)) return;
const ok = await confirm({
title: "Szenario löschen?",
message: `«${s.name}» wird unwiderruflich gelöscht. Untergeordnete Szenarien bleiben bestehen.`,
confirmLabel: "Endgültig löschen",
danger: true,
});
if (!ok) return;
try {
await api.delete(`/api/scenarios/${s.id}`);
toast("success", "Szenario gelöscht.");
} catch (e) {
alert(e instanceof Error ? e.message : "Loeschen fehlgeschlagen.");
toast("error", e instanceof Error ? e.message : "Löschen fehlgeschlagen.");
return;
}
await loadPlans();
if (selectedScenarioId === s.id) setSelectedScenarioId(null);
}
async function handleCreateDemo() {
setCreatingDemo(true);
try {
const sid = await createDemoPlan();
await loadPlans();
setShowNewPlanChooser(false);
openScenario(sid);
toast("success", "Beispielplan angelegt schau dich um!");
} catch (e) {
toast("error", e instanceof Error ? e.message : "Beispielplan konnte nicht angelegt werden.");
} finally {
setCreatingDemo(false);
}
}
const activePlan = plans.find((p) => p.scenarios.some((s) => s.id === selectedScenarioId)) ?? null;
// Aktionen der Befehls-Palette -- kontextabhängig (Analysen nur bei offenem Szenario).
const paletteActions = useMemo<PaletteAction[]>(() => {
const base: PaletteAction[] = [
{ id: "a-new", label: "Neuen Plan erstellen", hint: "Aktion", run: () => setShowNewPlanChooser(true) },
{ id: "a-home", label: "Übersicht öffnen", hint: "Aktion", run: () => { setSelectedScenarioId(null); setShowSpec(false); setShowSystemParams(false); } },
{ id: "a-spec", label: "So rechnet FPT (Spezifikation)", hint: "Aktion", run: () => { setShowSpec(true); setSpecAnchor(null); setShowSystemParams(false); setSelectedScenarioId(null); } },
{ id: "a-params", label: "Systemparameter", hint: "Aktion", run: () => { setShowSystemParams(true); setShowSpec(false); setSelectedScenarioId(null); } },
];
if (detail && selectedScenarioId) {
base.unshift(
{ id: "a-charts", label: "Grafiken öffnen", hint: "Aktion", run: () => setShowCharts(true) },
{ id: "a-mc", label: "Monte-Carlo-Simulation", hint: "Aktion", run: () => setShowMonteCarlo(true) },
{ id: "a-sens", label: "Einflussfaktoren berechnen", hint: "Aktion", run: () => setShowSensitivity(true) },
{ id: "a-traces", label: "Rechenwege anzeigen", hint: "Aktion", run: () => setShowPlanTraces(true) },
{ id: "a-copy", label: "Neues Szenario aus diesem", hint: "Aktion", run: () => setCopyFrom(detail.meta) }
);
}
return base;
}, [detail, selectedScenarioId]);
const sidebar = (
<div className="flex h-full flex-col">
<div className="flex items-center gap-2 px-4 py-4">
@@ -136,6 +232,15 @@ export function AppShell({ username }: { username: string }) {
<PiggyBank className="h-5 w-5 text-accent-fg" />
</div>
<span className="text-sm font-semibold text-fg">FPT</span>
<button
type="button"
onClick={() => setShowPalette(true)}
title="Suchen und springen (Ctrl+K)"
className="ml-auto flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-[10px] text-faint transition-colors hover:bg-surface-2 hover:text-fg"
>
<Search className="h-3 w-3" />
Ctrl K
</button>
</div>
<nav className="flex flex-1 flex-col gap-1 overflow-y-auto px-3 pb-4">
@@ -147,26 +252,26 @@ export function AppShell({ username }: { username: string }) {
setShowSystemParams(false);
setSidebarOpen(false);
}}
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium ${
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
selectedScenarioId === null && !showSpec && !showSystemParams ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
}`}
>
<LayoutDashboard className="h-4 w-4" />
Uebersicht
Übersicht
</button>
<div className="mt-4 flex items-center justify-between px-3">
<span className="text-[11px] font-semibold uppercase tracking-wide text-faint">Plaene</span>
<span className="text-[11px] font-semibold uppercase tracking-wide text-faint">Meine Pläne</span>
<button
type="button"
onClick={() => setShowNewPlan(true)}
onClick={() => setShowNewPlanChooser(true)}
aria-label="Neuen Plan erstellen"
className="rounded-md p-1 text-accent hover:bg-accent-soft"
className="rounded-md p-1 text-accent transition-colors hover:bg-accent-soft"
>
<Plus className="h-4 w-4" />
</button>
</div>
{plans.length === 0 && <p className="px-3 py-2 text-xs text-faint">Noch keine Plaene.</p>}
{plans.length === 0 && <p className="px-3 py-2 text-xs text-faint">Noch keine Pläne.</p>}
{plans.map((p) => (
<div key={p.id} className="mb-1">
@@ -175,9 +280,9 @@ export function AppShell({ username }: { username: string }) {
<span className="min-w-0 flex-1 truncate" title={p.name}>{p.name}</span>
<button
type="button"
aria-label="Plan loeschen"
aria-label="Plan löschen"
onClick={() => handleDeletePlan(p.id)}
className="rounded p-0.5 text-faint opacity-0 hover:bg-danger-soft hover:text-danger group-hover:opacity-100"
className="rounded p-0.5 text-faint opacity-60 transition-opacity hover:bg-danger-soft hover:text-danger group-hover:opacity-100"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
@@ -195,21 +300,7 @@ export function AppShell({ username }: { username: string }) {
))}
<div className="mt-4 flex flex-col gap-1 border-t border-border pt-3">
<button
type="button"
onClick={() => {
setShowSystemParams(true);
setShowSpec(false);
setSelectedScenarioId(null);
setSidebarOpen(false);
}}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm font-medium ${
showSystemParams ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
}`}
>
<SlidersHorizontal className="h-4 w-4 shrink-0" />
Systemparameter
</button>
<span className="px-3 pb-1 text-[11px] font-semibold uppercase tracking-wide text-faint">Wissen</span>
<button
type="button"
onClick={() => {
@@ -219,12 +310,27 @@ export function AppShell({ username }: { username: string }) {
setSelectedScenarioId(null);
setSidebarOpen(false);
}}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm font-medium ${
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm font-medium transition-colors ${
showSpec ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
}`}
>
<FileText className="h-4 w-4 shrink-0" />
SPEZIFIKATION
So rechnet FPT
</button>
<button
type="button"
onClick={() => {
setShowSystemParams(true);
setShowSpec(false);
setSelectedScenarioId(null);
setSidebarOpen(false);
}}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm font-medium transition-colors ${
showSystemParams ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
}`}
>
<SlidersHorizontal className="h-4 w-4 shrink-0" />
Systemparameter
</button>
</div>
</nav>
@@ -239,12 +345,12 @@ export function AppShell({ username }: { username: string }) {
{sidebarOpen && (
<div className="fixed inset-0 z-40 lg:hidden">
<div className="absolute inset-0 bg-black/40" onClick={() => setSidebarOpen(false)} />
<aside className="absolute left-0 top-0 h-full w-72 border-r border-border bg-surface shadow-xl">
<div className="ui-fade absolute inset-0 bg-black/40" onClick={() => setSidebarOpen(false)} />
<aside className="ui-slide-in absolute left-0 top-0 h-full w-72 border-r border-border bg-surface shadow-xl">
<button
type="button"
onClick={() => setSidebarOpen(false)}
aria-label="Menue schliessen"
aria-label="Menü schliessen"
className="absolute right-2 top-3 rounded-md p-1.5 text-faint hover:bg-surface-2"
>
<X className="h-4 w-4" />
@@ -259,7 +365,7 @@ export function AppShell({ username }: { username: string }) {
<button
type="button"
onClick={() => setSidebarOpen(true)}
aria-label="Menue oeffnen"
aria-label="Menü öffnen"
className="rounded-lg border border-border p-2 text-muted lg:hidden"
>
<Menu className="h-4 w-4" />
@@ -268,10 +374,10 @@ export function AppShell({ username }: { username: string }) {
{showSystemParams
? "Systemparameter"
: showSpec
? "Spezifikation"
? "So rechnet FPT"
: detail
? `${detail.meta.planName} · ${detail.meta.name}`
: "Uebersicht"}
: "Übersicht"}
</h1>
<ProfileMenu username={username} />
</header>
@@ -281,82 +387,64 @@ export function AppShell({ username }: { username: string }) {
{showSpec && <SpecView anchor={specAnchor} />}
{!showSpec && !showSystemParams && loading && <p className="text-sm text-muted">Laedt</p>}
{!showSpec && !showSystemParams && loading && <PlanSkeleton />}
{!showSpec && !showSystemParams && !loading && selectedScenarioId === null && (
<DashboardHome
username={username}
plans={plans}
creatingDemo={creatingDemo}
onSelect={openScenario}
onCreate={() => setShowNewPlan(true)}
onCreateGuided={() => setShowWizard(true)}
onCreateChooser={() => setShowNewPlanChooser(true)}
onCreateDemo={handleCreateDemo}
onDelete={handleDeletePlan}
/>
)}
{!showSpec && !showSystemParams && !loading && detail && selectedScenarioId && (
<div className="flex flex-col gap-6">
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => setCopyFrom(detail.meta)}
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-muted hover:bg-surface-2"
>
<div className="flex flex-wrap items-center gap-2" data-tour="analysen">
<Button variant="secondary" onClick={() => setCopyFrom(detail.meta)}>
<Copy className="h-4 w-4" />
Neues Szenario aus diesem
</button>
</Button>
{!detail.meta.isBase && (
<button
type="button"
onClick={() => handleDeleteScenario(detail.meta)}
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-muted hover:border-danger hover:bg-danger-soft hover:text-danger"
>
<Button variant="danger" onClick={() => handleDeleteScenario(detail.meta)}>
<Trash2 className="h-4 w-4" />
Szenario loeschen
</button>
Szenario löschen
</Button>
)}
{detail.plan.phases.length > 0 && (
<>
<button
type="button"
onClick={() => setShowCharts(true)}
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-muted hover:bg-surface-2"
>
<Button variant="secondary" onClick={() => setShowCharts(true)}>
<BarChart3 className="h-4 w-4" />
Grafiken
</button>
<button
type="button"
onClick={() => setShowMonteCarlo(true)}
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-muted hover:bg-surface-2"
>
</Button>
<Button variant="secondary" onClick={() => setShowMonteCarlo(true)}>
<Dices className="h-4 w-4" />
Monte-Carlo-Simulation
</button>
<button
type="button"
onClick={() => setShowSensitivity(true)}
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-muted hover:bg-surface-2"
>
</Button>
<Button variant="secondary" onClick={() => setShowSensitivity(true)}>
<Tornado className="h-4 w-4" />
Einflussfaktoren berechnen
</button>
<button
type="button"
Einflussfaktoren
</Button>
<Button
variant="secondary"
onClick={() => setShowPlanTraces(true)}
title="Wie wird gerechnet? Plan-weite Grössen wie Deflatoren, AHV-Karriere und Ruinalter"
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-muted hover:bg-surface-2"
>
<BookOpen className="h-4 w-4" />
Rechenwege
</button>
</Button>
</>
)}
{diff && detail.base && (
<span className="ml-auto flex items-center gap-1.5 rounded-lg border border-diff bg-diff-soft px-3 py-1.5 text-xs font-medium text-diff">
<GitBranch className="h-3.5 w-3.5" />
{diff.total === 0
? "Unveraendert gegenueber der Vorlage"
: `${diff.total} Abweichung${diff.total === 1 ? "" : "en"} gegenueber der Vorlage`}
? "Unverändert gegenüber der Vorlage"
: `${diff.total} Abweichung${diff.total === 1 ? "" : "en"} gegenüber der Vorlage`}
</span>
)}
</div>
@@ -367,24 +455,54 @@ export function AppShell({ username }: { username: string }) {
diff={diff}
onChanged={refreshCurrent}
onOpenSpec={openSpecAt}
onOpenSensitivity={() => setShowSensitivity(true)}
/>
</div>
)}
</main>
</div>
{showNewPlan && (
{showNewPlanChooser && (
<NewPlanChooser
creatingDemo={creatingDemo}
onGuided={() => {
setShowNewPlanChooser(false);
setShowWizard(true);
}}
onEmpty={() => {
setShowNewPlanChooser(false);
setShowEmptyPlanDialog(true);
}}
onDemo={handleCreateDemo}
onClose={() => setShowNewPlanChooser(false)}
/>
)}
{showWizard && (
<PlanWizard
onCreated={async (sid) => {
setShowWizard(false);
await loadPlans();
openScenario(sid);
toast("success", "Plan erstellt die Tour zeigt dir gleich die wichtigsten Stellen.");
}}
onClose={() => setShowWizard(false)}
/>
)}
{showEmptyPlanDialog && (
<PlanDialog
onCreate={async (name, profile) => {
const { scenario } = await api.post<{ plan: { id: string }; scenario: { id: string } }>(
"/api/plans",
{ name, ...profile }
);
setShowNewPlan(false);
setShowEmptyPlanDialog(false);
await loadPlans();
openScenario(scenario.id);
toast("success", "Plan erstellt.");
}}
onClose={() => setShowNewPlan(false)}
onClose={() => setShowEmptyPlanDialog(false)}
/>
)}
@@ -432,14 +550,90 @@ export function AppShell({ username }: { username: string }) {
setCopyFrom(null);
await loadPlans();
openScenario(scenarioId);
toast("success", "Szenario erstellt.");
}}
/>
)}
<CommandPalette
open={showPalette}
onClose={() => setShowPalette(false)}
plans={plans}
actions={paletteActions}
onOpenScenario={openScenario}
/>
</div>
);
}
// Breiter Dialog fuer den Analyse-Bereich (Grafiken).
// Auswahl beim Anlegen eines Plans: geführt (empfohlen), leer oder Beispielplan.
function NewPlanChooser({
creatingDemo,
onGuided,
onEmpty,
onDemo,
onClose,
}: {
creatingDemo: boolean;
onGuided: () => void;
onEmpty: () => void;
onDemo: () => void;
onClose: () => void;
}) {
const options = [
{
icon: <Sparkles className="h-5 w-5" />,
title: "Geführt erstellen",
badge: "Empfohlen",
text: "Der Assistent fragt Schritt für Schritt in fünf Minuten steht dein Grundgerüst.",
run: onGuided,
},
{
icon: <Plus className="h-5 w-5" />,
title: "Leer starten",
badge: null,
text: "Nur das Grundprofil erfassen, alles Weitere selbst aufbauen für geübte Nutzer.",
run: onEmpty,
},
{
icon: <PiggyBank className="h-5 w-5" />,
title: creatingDemo ? "Beispielplan wird angelegt…" : "Beispielplan ansehen",
badge: null,
text: "Ein fertig ausgefüllter, fiktiver Plan zum Erkunden so siehst du das Tool zuerst in Aktion.",
run: onDemo,
},
];
return (
<Modal title="Neuer Plan" onClose={onClose}>
<div className="flex flex-col gap-2">
{options.map((o) => (
<button
key={o.title}
type="button"
disabled={creatingDemo}
onClick={o.run}
className="flex items-start gap-3 rounded-xl border border-border p-3.5 text-left transition-colors hover:border-accent hover:bg-accent-soft/30 disabled:opacity-60"
>
<span className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-accent-soft text-accent-soft-fg">
{o.icon}
</span>
<span className="min-w-0">
<span className="flex items-center gap-2 text-sm font-semibold text-fg">
{o.title}
{o.badge && (
<span className="rounded bg-accent px-1.5 py-0.5 text-[9px] font-semibold uppercase text-accent-fg">{o.badge}</span>
)}
</span>
<span className="mt-0.5 block text-xs text-muted">{o.text}</span>
</span>
</button>
))}
</div>
</Modal>
);
}
// Breiter Dialog für den Analyse-Bereich (Grafiken).
function ChartsDialog({
title,
children,
@@ -450,10 +644,10 @@ function ChartsDialog({
onClose: () => void;
}) {
return (
<div className="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={onClose}>
<div className="ui-fade fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={onClose}>
<div
onClick={(e) => e.stopPropagation()}
className="flex w-full max-w-5xl flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl"
className="ui-pop flex w-full max-w-5xl flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl"
>
<div className="flex items-center justify-between">
<h2 className="flex items-center gap-2 text-base font-semibold text-fg">
@@ -469,7 +663,9 @@ function ChartsDialog({
);
}
// Rekursiver Szenario-Baum: Kinder werden eingerueckt, damit Sub-Szenarien sichtbar sind.
// Rekursiver Szenario-Baum: Kinder werden eingerückt, damit Sub-Szenarien sichtbar sind.
// Die Aktions-Icons sind immer leicht sichtbar (nicht nur bei Hover) -- auf Touch-Geräten
// gibt es kein Hover.
function ScenarioTree({
scenarios,
parentId,
@@ -494,7 +690,7 @@ function ScenarioTree({
{level.map((s) => (
<div key={s.id}>
<div
className={`group flex items-center gap-1.5 rounded-lg py-1.5 pr-1.5 text-sm ${
className={`group flex items-center gap-1.5 rounded-lg py-1.5 pr-1.5 text-sm transition-colors ${
selectedId === s.id ? "bg-accent-soft font-medium text-accent-soft-fg" : "text-muted hover:bg-surface-2"
}`}
style={{ paddingLeft: `${12 + depth * 14}px` }}
@@ -512,16 +708,16 @@ function ScenarioTree({
aria-label="Kopie erstellen"
title="Neues Szenario aus diesem"
onClick={() => onCopy(s)}
className="rounded p-0.5 text-faint opacity-0 hover:bg-accent-soft hover:text-accent group-hover:opacity-100"
className="rounded p-0.5 text-faint opacity-60 transition-opacity hover:bg-accent-soft hover:text-accent group-hover:opacity-100"
>
<Copy className="h-3.5 w-3.5" />
</button>
{!s.isBase && (
<button
type="button"
aria-label="Szenario loeschen"
aria-label="Szenario löschen"
onClick={() => onDelete(s)}
className="rounded p-0.5 text-faint opacity-0 hover:bg-danger-soft hover:text-danger group-hover:opacity-100"
className="rounded p-0.5 text-faint opacity-60 transition-opacity hover:bg-danger-soft hover:text-danger group-hover:opacity-100"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
@@ -542,18 +738,24 @@ function ScenarioTree({
);
}
// Startansicht: Begruessung + Plan-Kacheln (Klick oeffnet das Basisszenario).
// Startansicht: Begrüssung + Plan-Kacheln (Klick öffnet das Basisszenario).
function DashboardHome({
username,
plans,
creatingDemo,
onSelect,
onCreate,
onCreateGuided,
onCreateChooser,
onCreateDemo,
onDelete,
}: {
username: string;
plans: PlanListItem[];
creatingDemo: boolean;
onSelect: (scenarioId: string) => void;
onCreate: () => void;
onCreateGuided: () => void;
onCreateChooser: () => void;
onCreateDemo: () => void;
onDelete: (id: string) => void;
}) {
return (
@@ -561,56 +763,71 @@ function DashboardHome({
<div>
<h2 className="text-xl font-semibold text-fg">Willkommen, {username}</h2>
<p className="mt-1 text-sm text-muted">
Waehlen Sie einen Plan oder erstellen Sie einen neuen. Jeder Plan enthaelt ein Basisszenario
und beliebig viele Varianten davon.
Wähle einen Plan oder erstelle einen neuen. Jeder Plan enthält ein Basisszenario und beliebig
viele Varianten davon.
</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{plans.map((p) => {
const base = p.scenarios.find((s) => s.isBase) ?? p.scenarios[0];
const others = p.scenarios.length - 1;
return (
<div
key={p.id}
className="group relative flex cursor-pointer flex-col gap-2 rounded-xl border border-border bg-surface p-4 shadow-sm transition-shadow hover:shadow-md"
onClick={() => base && onSelect(base.id)}
>
<div className="flex items-center gap-2">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-accent-soft">
<FolderKanban className="h-5 w-5 text-accent-soft-fg" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-fg">{p.name}</div>
<div className="text-xs text-muted">
Basisszenario{others > 0 ? ` + ${others} Variante${others === 1 ? "" : "n"}` : ""}
</div>
</div>
<button
type="button"
aria-label="Plan loeschen"
onClick={(e) => {
e.stopPropagation();
onDelete(p.id);
}}
className="rounded-md p-1.5 text-faint opacity-0 transition-opacity hover:bg-danger-soft hover:text-danger group-hover:opacity-100"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
);
})}
<button
type="button"
onClick={onCreate}
className="flex min-h-20 items-center justify-center gap-2 rounded-xl border border-dashed border-accent bg-accent-soft text-sm font-medium text-accent-soft-fg hover:bg-accent-soft"
{plans.length === 0 ? (
<EmptyState
icon={<Sparkles className="h-6 w-6" />}
title="Deine erste Finanzplanung"
text="Am schnellsten geht es geführt: Der Assistent fragt dich Schritt für Schritt und baut dein Grundgerüst in rund fünf Minuten. Oder schau dir zuerst den Beispielplan an."
>
<Plus className="h-4 w-4" />
Neuer Plan
</button>
</div>
<Button onClick={onCreateGuided}>
<Sparkles className="h-4 w-4" /> Geführt starten
</Button>
<Button variant="secondary" onClick={onCreateDemo} disabled={creatingDemo}>
<PiggyBank className="h-4 w-4" /> {creatingDemo ? "Wird angelegt…" : "Beispielplan ansehen"}
</Button>
</EmptyState>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{plans.map((p) => {
const base = p.scenarios.find((s) => s.isBase) ?? p.scenarios[0];
const others = p.scenarios.length - 1;
return (
<div
key={p.id}
className="group relative flex cursor-pointer flex-col gap-2 rounded-xl border border-border bg-surface p-4 shadow-sm transition-shadow hover:shadow-md"
onClick={() => base && onSelect(base.id)}
>
<div className="flex items-center gap-2">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-accent-soft">
<FolderKanban className="h-5 w-5 text-accent-soft-fg" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-fg">{p.name}</div>
<div className="text-xs text-muted">
Basisszenario{others > 0 ? ` + ${others} Variante${others === 1 ? "" : "n"}` : ""}
</div>
</div>
<button
type="button"
aria-label="Plan löschen"
onClick={(e) => {
e.stopPropagation();
onDelete(p.id);
}}
className="rounded-md p-1.5 text-faint opacity-0 transition-opacity hover:bg-danger-soft hover:text-danger group-hover:opacity-100"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
);
})}
<button
type="button"
onClick={onCreateChooser}
className="flex min-h-20 items-center justify-center gap-2 rounded-xl border border-dashed border-accent bg-accent-soft text-sm font-medium text-accent-soft-fg transition-colors hover:bg-accent-soft"
>
<Plus className="h-4 w-4" />
Neuer Plan
</button>
</div>
)}
</div>
);
}
@@ -627,49 +844,37 @@ function PlanDialog({
const [saving, setSaving] = useState(false);
return (
<div className="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={onClose}>
<div
onClick={(e) => e.stopPropagation()}
className="flex w-full max-w-md flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl"
>
<h2 className="text-base font-semibold text-fg">Neuen Plan erstellen</h2>
<p className="text-xs text-muted">
Es wird automatisch ein <strong className="text-fg">Basisszenario</strong> angelegt. Weitere
Szenarien entstehen spaeter als Kopien davon.
</p>
<div>
<label className="mb-1 block text-xs font-medium text-muted">Name des Plans</label>
<input
autoFocus
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name des Plans"
/>
</div>
<PlanProfileFields draft={draft} onChange={setDraft} />
<div className="flex gap-2">
<button
type="button"
disabled={saving}
onClick={() => {
setSaving(true);
onCreate(name.trim() || "Plan", draft);
}}
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-fg shadow-sm hover:bg-accent-hover disabled:opacity-50"
>
{saving ? "..." : "Erstellen"}
</button>
<button
type="button"
onClick={onClose}
className="rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted hover:bg-surface-2"
>
Abbrechen
</button>
</div>
<Modal title="Neuen Plan erstellen" onClose={onClose}>
<p className="text-xs text-muted">
Es wird automatisch ein <strong className="text-fg">Basisszenario</strong> angelegt. Weitere
Szenarien entstehen später als Kopien davon.
</p>
<div>
<label className="mb-1 block text-xs font-medium text-muted">Name des Plans</label>
<input
autoFocus
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name des Plans"
/>
</div>
</div>
<PlanProfileFields draft={draft} onChange={setDraft} />
<div className="flex gap-2">
<Button
disabled={saving}
onClick={() => {
setSaving(true);
onCreate(name.trim() || "Plan", draft);
}}
>
{saving ? "…" : "Erstellen"}
</Button>
<Button variant="secondary" onClick={onClose}>
Abbrechen
</Button>
</div>
</Modal>
);
}
@@ -685,44 +890,32 @@ function CopyScenarioDialog({
const [name, setName] = useState(`${source.name} Variante`);
const [saving, setSaving] = useState(false);
return (
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4" onClick={onClose}>
<div
onClick={(e) => e.stopPropagation()}
className="flex w-full max-w-sm flex-col gap-3 rounded-2xl border border-border bg-surface p-6 shadow-xl"
>
<h2 className="text-base font-semibold text-fg">Neues Szenario</h2>
<p className="text-xs text-muted">
Vollstaendige Kopie von <strong className="text-fg">{source.name}</strong>. Aenderungen darin
werden anschliessend farblich hervorgehoben.
</p>
<input
autoFocus
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name des Szenarios"
/>
<div className="flex gap-2">
<button
type="button"
disabled={saving}
onClick={() => {
setSaving(true);
onCreate(name.trim() || "Szenario");
}}
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-fg shadow-sm hover:bg-accent-hover disabled:opacity-50"
>
{saving ? "..." : "Erstellen"}
</button>
<button
type="button"
onClick={onClose}
className="rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted hover:bg-surface-2"
>
Abbrechen
</button>
</div>
<Modal title="Neues Szenario" onClose={onClose}>
<p className="text-xs text-muted">
Vollständige Kopie von <strong className="text-fg">{source.name}</strong>. Änderungen darin
werden anschliessend farblich hervorgehoben.
</p>
<input
autoFocus
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name des Szenarios"
/>
<div className="flex gap-2">
<Button
disabled={saving}
onClick={() => {
setSaving(true);
onCreate(name.trim() || "Szenario");
}}
>
{saving ? "…" : "Erstellen"}
</Button>
<Button variant="secondary" onClick={onClose}>
Abbrechen
</Button>
</div>
</div>
</Modal>
);
}
+126
View File
@@ -0,0 +1,126 @@
"use client";
// Befehls-Palette (Cmd/Ctrl+K): schnelles Springen zwischen Plänen und Szenarien plus
// die wichtigsten Aktionen -- der Effizienz-Pfad für geübte Nutzer, ohne der Maus-
// Bedienung etwas wegzunehmen.
import { useEffect, useMemo, useRef, useState } from "react";
import { CornerDownLeft, Search } from "lucide-react";
import type { PlanListItem } from "@/lib/types";
export interface PaletteAction {
id: string;
label: string;
hint?: string;
run: () => void;
}
export function CommandPalette({
open,
onClose,
plans,
actions,
onOpenScenario,
}: {
open: boolean;
onClose: () => void;
plans: PlanListItem[];
actions: PaletteAction[];
onOpenScenario: (id: string) => void;
}) {
const [query, setQuery] = useState("");
const [index, setIndex] = useState(0);
const inputRef = useRef<HTMLInputElement | null>(null);
const items = useMemo<PaletteAction[]>(() => {
const scenarioItems: PaletteAction[] = plans.flatMap((p) =>
p.scenarios.map((s) => ({
id: `s-${s.id}`,
label: `${p.name} · ${s.name}`,
hint: "Szenario öffnen",
run: () => onOpenScenario(s.id),
}))
);
const all = [...actions, ...scenarioItems];
const q = query.trim().toLowerCase();
if (!q) return all;
return all.filter((i) => i.label.toLowerCase().includes(q));
}, [plans, actions, query, onOpenScenario]);
useEffect(() => {
if (open) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Reset beim Öffnen
setQuery("");
setIndex(0);
requestAnimationFrame(() => inputRef.current?.focus());
}
}, [open]);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Auswahl folgt der Eingabe
setIndex(0);
}, [query]);
if (!open) return null;
function choose(item: PaletteAction | undefined) {
if (!item) return;
onClose();
item.run();
}
return (
<div className="ui-fade fixed inset-0 z-50 flex items-start justify-center bg-black/40 px-4 pt-[15vh]" onClick={onClose}>
<div
onClick={(e) => e.stopPropagation()}
className="ui-pop w-full max-w-lg overflow-hidden rounded-2xl border border-border bg-surface shadow-2xl"
>
<div className="flex items-center gap-2 border-b border-border px-4 py-3">
<Search className="h-4 w-4 shrink-0 text-faint" />
<input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setIndex((i) => Math.min(i + 1, items.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setIndex((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter") {
e.preventDefault();
choose(items[index]);
} else if (e.key === "Escape") {
onClose();
}
}}
placeholder="Plan, Szenario oder Aktion suchen…"
className="w-full bg-transparent text-sm text-fg outline-none placeholder:text-faint"
/>
<kbd className="rounded border border-border bg-surface-2 px-1.5 py-0.5 text-[10px] text-faint">ESC</kbd>
</div>
<div className="max-h-80 overflow-y-auto py-1">
{items.length === 0 && <p className="px-4 py-3 text-sm text-muted">Nichts gefunden.</p>}
{items.slice(0, 40).map((item, i) => (
<button
key={item.id}
type="button"
onMouseEnter={() => setIndex(i)}
onClick={() => choose(item)}
className={`flex w-full items-center justify-between gap-3 px-4 py-2 text-left text-sm ${
i === index ? "bg-accent-soft text-accent-soft-fg" : "text-fg"
}`}
>
<span className="min-w-0 flex-1 truncate">{item.label}</span>
<span className="flex items-center gap-1.5 text-[10px] text-faint">
{item.hint}
{i === index && <CornerDownLeft className="h-3 w-3" />}
</span>
</button>
))}
</div>
</div>
</div>
);
}
+8 -8
View File
@@ -10,7 +10,7 @@ import { formatChf } from "@/lib/format";
import type { PlanComputed } from "@/lib/calculations";
import type { PlanInput } from "@/lib/types";
const PALETTE = ["#4f46e5", "#0ea5e9", "#16a34a", "#d97706", "#dc2626", "#7c3aed"];
const PALETTE = ["#4f46e5", "#0ea5e9", "#16a34a", "#d97706", "#dc2626", "#7c3äd"];
interface PlanListItem {
id: string;
@@ -24,7 +24,7 @@ export function Dashboard({
}: {
plan: PlanInput;
computed: PlanComputed;
// Die uebrigen Szenarien desselben Plans -- nur die sind sinnvoll vergleichbar.
// Die übrigen Szenarien desselben Plans -- nur die sind sinnvoll vergleichbar.
siblings: PlanListItem[];
}) {
const [compareIds, setCompareIds] = useState<string[]>([]);
@@ -94,8 +94,8 @@ export function Dashboard({
return (
<div className="flex flex-col gap-6">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<StatCard label="Endvermoegen (nominal)" value={lastPhase ? lastPhase.endWealthNominal : 0} />
<StatCard label="Endvermoegen (real, kaufkraftbereinigt)" value={lastPhase ? lastPhase.endWealthReal : 0} />
<StatCard label="Endvermögen (nominal)" value={lastPhase ? lastPhase.endWealthNominal : 0} />
<StatCard label="Endvermögen (real, kaufkraftbereinigt)" value={lastPhase ? lastPhase.endWealthReal : 0} />
</div>
<section className="rounded-xl border border-border bg-surface p-4 shadow-sm">
@@ -104,7 +104,7 @@ export function Dashboard({
Einkommen vs. Ausgaben pro Jahr
</h3>
<p className="mb-3 text-xs text-muted">
Die Flaeche zwischen Einkommen und nominalen Ausgaben ist die Spar- (gruen) bzw. Verzehrquote (rot).
Die Fläche zwischen Einkommen und nominalen Ausgaben ist die Spar- (grün) bzw. Verzehrquote (rot).
Die blasse Linie sind die realen Ausgaben &ndash; der Abstand zur nominalen Linie ist der Inflationsanteil.
</p>
<SparquoteChart computed={computed} />
@@ -114,7 +114,7 @@ export function Dashboard({
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h3 className="flex items-center gap-1.5 text-sm font-semibold text-fg">
<LineChartIcon className="h-4 w-4 text-accent" />
Vermoegensverlauf nach Alter
Vermögensverlauf nach Alter
</h3>
<a
href={`/api/scenarios/${plan.id}/export`}
@@ -145,11 +145,11 @@ export function Dashboard({
<section className="rounded-xl border border-border bg-surface p-4 shadow-sm">
<h3 className="mb-1 flex items-center gap-1.5 text-sm font-semibold text-fg">
<BarChart3 className="h-4 w-4 text-accent" />
Vermoegensaufteilung pro Phase (Beginn &amp; Ende)
Vermögensaufteilung pro Phase (Beginn &amp; Ende)
</h3>
<p className="mb-3 text-xs text-muted">
Je Phase links die Aufteilung zu Beginn, rechts am Ende. Das Ende einer Phase entspricht im
Gesamtvolumen dem Beginn der naechsten &ndash; die Aufteilung kann durch Umschichtung abweichen.
Gesamtvolumen dem Beginn der nächsten &ndash; die Aufteilung kann durch Umschichtung abweichen.
</p>
<div className="h-72 w-full">
<ResponsiveContainer width="100%" height="100%">
+15 -15
View File
@@ -30,18 +30,18 @@ import type {
// --- Wasserfall ---------------------------------------------------------------------------
// Bewusst NICHT mit Recharts, sondern als eigene HTML/CSS-Darstellung. Ein Wasserfall lebt
// von drei Dingen, die Recharts hier nicht hergibt: Verbindungslinien zwischen den Balken
// (ohne sie sieht man nicht, dass jeder Balken dort ansetzt, wo der vorherige aufhoert),
// Wertbeschriftung an jedem Balken, und eine klare optische Trennung von Zwischenstaenden
// und Veraenderungen.
// (ohne sie sieht man nicht, dass jeder Balken dort ansetzt, wo der vorherige aufhört),
// Wertbeschriftung an jedem Balken, und eine klare optische Trennung von Zwischenständen
// und Veränderungen.
//
// Liegend statt stehend: Die Beschriftungen sind lang ("Wertsteigerung Immobilie"), stehend
// muessten sie gedreht werden. Liegend ist es ausserdem konsistent zum Tornado.
// müssten sie gedreht werden. Liegend ist es ausserdem konsistent zum Tornado.
interface WaterfallItem {
label: string;
value: number;
total?: boolean; // Zwischen-/Endsumme: absoluter Stand statt Veraenderung
section?: string; // optionale Abschnitts-Ueberschrift VOR diesem Eintrag
total?: boolean; // Zwischen-/Endsumme: absoluter Stand statt Veränderung
section?: string; // optionale Abschnitts-Überschrift VOR diesem Eintrag
}
interface WaterfallRow {
@@ -130,7 +130,7 @@ function Waterfall({ items }: { items: WaterfallItem[] }) {
}}
title={`${r.label}: ${formatChf(r.value)}`}
/>
{/* Verbindungslinie zum naechsten Balken: auf dem Stand NACH diesem Schritt */}
{/* Verbindungslinie zum nächsten Balken: auf dem Stand NACH diesem Schritt */}
{!isLast && (
<div
className="absolute border-l border-dotted border-faint"
@@ -155,8 +155,8 @@ function Waterfall({ items }: { items: WaterfallItem[] }) {
})}
</div>
{/* Zahlen mit laufendem Zwischenstand -- bei stark unterschiedlichen Groessenordnungen
ist die Tabelle der Grafik ueberlegen. */}
{/* Zahlen mit laufendem Zwischenstand -- bei stark unterschiedlichen Grössenordnungen
ist die Tabelle der Grafik überlegen. */}
<details className="mt-2">
<summary className="cursor-pointer text-[11px] text-muted hover:text-fg">Zahlen mit Zwischenstand anzeigen</summary>
<div className="mt-2 overflow-x-auto rounded-lg border border-border">
@@ -190,9 +190,9 @@ function Waterfall({ items }: { items: WaterfallItem[] }) {
);
}
// Kontrollgroesse: Ist die Zerlegung vollstaendig, muss die Differenz zwischen Endwert und
// Kontrollgrösse: Ist die Zerlegung vollständig, muss die Differenz zwischen Endwert und
// der Summe der Schritte 0 sein. Sichtbar machen statt verstecken -- ein Wasserfall, der
// nicht aufgeht, ist ein Fehler und kein Schoenheitsproblem.
// nicht aufgeht, ist ein Fehler und kein Schönheitsproblem.
function ResidualNote({ residual }: { residual: number }) {
if (Math.abs(residual) <= 2) return null;
return (
@@ -367,7 +367,7 @@ export function ElementDetailDialog({
onClose: () => void;
onOpenSpec?: (anchor: string) => void;
}) {
// Verlauf ueber ALLE Phasen zusammensetzen.
// Verlauf über ALLE Phasen zusammensetzen.
const points = useMemo(() => {
const out: ElementYearPoint[] = [];
for (const ph of computed.phases) {
@@ -479,7 +479,7 @@ export function ElementDetailDialog({
// --- Phasen-Detailansicht ------------------------------------------------------------------
const ASSET_CATS: ElementCategory[] = ["PENSION_FUND", "PILLAR_3A", "REAL_ESTATE", "OTHER_ASSET"];
const ALLOC_PALETTE = ["#4f46e5", "#0ea5e9", "#16a34a", "#d97706", "#dc2626", "#7c3aed"];
const ALLOC_PALETTE = ["#4f46e5", "#0ea5e9", "#16a34a", "#d97706", "#dc2626", "#7c3äd"];
export function PhaseDetailDialog({
phase,
@@ -504,7 +504,7 @@ export function PhaseDetailDialog({
[phase]
);
const uebersicht = (
const übersicht = (
<>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Kpi label="Typ" text={phase.type === "ERWERB" ? "Erwerb" : phase.type === "PENSION" ? "Pension" : "Misch"} />
@@ -578,7 +578,7 @@ export function PhaseDetailDialog({
subtitle={`Lebensphase ${phase.sequenceNumber} · ${phase.durationYears} Jahre`}
onClose={onClose}
tabs={[
{ key: "uebersicht", label: "Übersicht", content: uebersicht },
{ key: "übersicht", label: "Übersicht", content: übersicht },
{ key: "wasserfall", label: "Wasserfall", content: wasserfall },
{ key: "rechenweg", label: "Rechenweg", content: rechenweg },
]}
+91 -91
View File
@@ -29,18 +29,18 @@ export interface CellContext {
isConsumption: boolean;
durationYears: number;
isRetirementTransition: boolean;
carriedEndValue: number; // Endwert des Elements in der (Vor-)Phase, fuer Bezugs-Maxima
carriedEndValue: number; // Endwert des Elements in der (Vor-)Phase, für Bezugs-Maxima
carried: boolean; // Phase >= 2: Basiswert wird aus der Vorphase fortgeschrieben
derivedStart: number; // fortgeschriebener Basiswert (read-only Anzeige)
derivedMortgage: number; // nur Immobilie: fortgeschriebene Resthypothek zu Phasenbeginn
mortgageEnd: number; // nur Immobilie: Resthypothek am Phasenende (fuer die Sonderamortisation)
mortgageEnd: number; // nur Immobilie: Resthypothek am Phasenende (für die Sonderamortisation)
// nur Immobilie: modellierter VERKEHRSWERT am Phasenende (Eigenkapital + Resthypothek),
// inkl. aufgelaufener Wertsteigerung. Vorbelegung und Vergleichswert fuer den Verkaufspreis.
// inkl. aufgelaufener Wertsteigerung. Vorbelegung und Vergleichswert für den Verkaufspreis.
propertyValueEnd: number;
deflatorStart: number; // Kaufkraft-Deflator zu Phasenbeginn (real <-> nominal, erstes Jahr)
// Warnhinweis: Anzahl Phasen NACH dieser (Aenderungen schreiben sich dorthin fort).
// Warnhinweis: Anzahl Phasen NACH dieser (Änderungen schreiben sich dorthin fort).
laterPhaseCount: number;
// AHV-Beitragskarriere des Element-Besitzers (fuer die Pruefung am Pensions-Uebergang).
// AHV-Beitragskarriere des Element-Besitzers (für die Prüfung am Pensions-Übergang).
ahvCareer: AhvCareer | null;
}
@@ -54,8 +54,8 @@ interface Props {
}
// Warnhinweis beim Bearbeiten einer Phase, der noch Phasen folgen. Seit V3 werden Werte live
// fortgeschrieben (Endwert = Startwert der Folgephase) -- eine Aenderung hier wirkt also bis
// ans Planende durch, was ohne Hinweis leicht uebersehen wird.
// fortgeschrieben (Endwert = Startwert der Folgephase) -- eine Änderung hier wirkt also bis
// ans Planende durch, was ohne Hinweis leicht übersehen wird.
export function CarryWarning({ laterPhaseCount }: { laterPhaseCount: number }) {
if (laterPhaseCount <= 0) return null;
const phasen = laterPhaseCount === 1 ? "die nachfolgende Lebensphase" : `die ${laterPhaseCount} nachfolgenden Lebensphasen`;
@@ -64,8 +64,8 @@ export function CarryWarning({ laterPhaseCount }: { laterPhaseCount: number }) {
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span>
<strong>Achtung: Diese Lebensphase ist nicht die letzte.</strong> Werte werden fortgeschrieben
(Endwert = Startwert der Folgephase). Eine Aenderung hier wirkt sich auf {phasen} aus und
veraendert deren Startwerte, Kennzahlen sowie Endvermoegen, Cash-Verlauf und Ruinalter.
(Endwert = Startwert der Folgephase). Eine Änderung hier wirkt sich auf {phasen} aus und
verändert deren Startwerte, Kennzahlen sowie Endvermögen, Cash-Verlauf und Ruinalter.
</span>
</div>
);
@@ -83,7 +83,7 @@ function DerivedField({ label, value, help }: { label: string; value: number; he
);
}
// PK/3a-Bezugs-Entscheid im normalen Uebergang (Vorbezug): Kein Bezug / Bezug (+ Betrag +
// PK/3a-Bezugs-Entscheid im normalen Übergang (Vorbezug): Kein Bezug / Bezug (+ Betrag +
// Kapitalbezugssteuer). Der Bezugsbetrag wird brutto dem Kapital entnommen; ins Cash fliesst
// der Betrag nach Abzug der Steuer.
function WithdrawalDecision({
@@ -130,7 +130,7 @@ function WithdrawalDecision({
<DerivedField
label="Auszahlung netto (ins Cash)"
value={Math.round(gross * (1 - taxRate / 100))}
help="Bruttobezug abzueglich Kapitalbezugssteuer. Wird automatisch berechnet."
help="Bruttobezug abzüglich Kapitalbezugssteuer. Wird automatisch berechnet."
/>
</>
)}
@@ -139,9 +139,9 @@ function WithdrawalDecision({
}
// Vorbelegung expliziter Entscheide, damit ein blosses "Speichern" den sichtbaren Default
// (Halten / Kein Bezug / Rente) auch tatsaechlich persistiert.
// AHV-Pruefung am Pensions-Uebergang: geplantes Durchschnittseinkommen aus dem Plan plus --
// nur falls der Plan nicht bis zum Beitragsbeginn (Alter 21) zurueckreicht -- die Jahre davor.
// (Halten / Kein Bezug / Rente) auch tatsächlich persistiert.
// AHV-Prüfung am Pensions-Übergang: geplantes Durchschnittseinkommen aus dem Plan plus --
// nur falls der Plan nicht bis zum Beitragsbeginn (Alter 21) zurückreicht -- die Jahre davor.
export function AhvReviewFields({
career,
td,
@@ -162,39 +162,39 @@ export function AhvReviewFields({
return (
<>
<p className="col-span-full rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
Die AHV-Rente haengt vom <strong className="text-fg">massgebenden durchschnittlichen Jahreseinkommen</strong>{" "}
ueber die ganze Beitragsdauer (ab Alter 21) ab. Massgebend ist der <strong className="text-fg">Bruttolohn</strong>;
das Tool rechnet die im Plan erfassten Nettoeinkommen dafuer automatisch hoch. Alle Betraege sind
Die AHV-Rente hängt vom <strong className="text-fg">massgebenden durchschnittlichen Jahreseinkommen</strong>{" "}
über die ganze Beitragsdauer (ab Alter 21) ab. Massgebend ist der <strong className="text-fg">Bruttolohn</strong>;
das Tool rechnet die im Plan erfassten Nettöinkommen dafür automatisch hoch. Alle Beträge sind
REAL (heutige Kaufkraft) die AHV wertet vergangene Einkommen auf und indexiert die Schwellen,
was sich real weitgehend aufhebt.
</p>
<DerivedField
label="Geplantes Bruttoeinkommen (aus dem Plan)"
label="Geplantes Bruttöinkommen (aus dem Plan)"
value={Math.round(career.plannedAvgGrossIncome)}
help={`Reales Mittel ueber ${career.planYears} Beitragsjahre der Erwerbsphasen dieses Plans, hochgerechnet vom Netto- auf den Bruttolohn. Nur Einkommen, die dieser Person zugeordnet sind.`}
help={`Reales Mittel über ${career.planYears} Beitragsjahre der Erwerbsphasen dieses Plans, hochgerechnet vom Netto- auf den Bruttolohn. Nur Einkommen, die dieser Person zugeordnet sind.`}
/>
<DerivedField
label="Beitragsjahre im Plan"
value={career.planYears}
help="Dauer der Erwerbsphasen abzueglich der dort erfassten Ausfalljahre."
help="Dauer der Erwerbsphasen abzüglich der dort erfassten Ausfalljahre."
/>
{brauchtVorgeschichte && (
<>
<p className="col-span-full rounded-lg border border-dashed border-border px-3 py-2 text-xs text-muted">
Der Plan beginnt erst im Alter {career.yearsBeforePlan + 21}. Die {career.yearsBeforePlan} Beitragsjahre
davor kennt das Tool nicht bitte ergaenzen.
davor kennt das Tool nicht bitte ergänzen.
</p>
<MoneyField
label="Durchschnittliches Bruttoeinkommen vor Planbeginn (real)"
help="BRUTTO und in HEUTIGER Kaufkraft. Am besten aus der AHV-Rentenvorausberechnung uebernehmen dort ist die Aufwertung bereits enthalten. Die Rohbetraege im Kontoauszug sind historisch-nominal und daher zu tief."
label="Durchschnittliches Bruttöinkommen vor Planbeginn (real)"
help="BRUTTO und in HEUTIGER Kaufkraft. Am besten aus der AHV-Rentenvorausberechnung übernehmen dort ist die Aufwertung bereits enthalten. Die Rohbeträge im Kontoauszug sind historisch-nominal und daher zu tief."
value={avgBefore}
onChange={(v) => setT({ avgIncomeBefore: v })}
/>
<NumberField
label="Ausfalljahre vor Planbeginn"
help="Jahre ohne AHV-Beitraege zwischen Alter 21 und Planbeginn (z. B. Ausland, Studium). Jedes kuerzt die Rente um 1/44."
help="Jahre ohne AHV-Beiträge zwischen Alter 21 und Planbeginn (z. B. Ausland, Studium). Jedes kürzt die Rente um 1/44."
value={gapBefore}
min={0}
max={career.yearsBeforePlan}
@@ -206,12 +206,12 @@ export function AhvReviewFields({
<DerivedField
label="Massgebendes durchschnittliches Jahreseinkommen"
value={Math.round(mdJE)}
help="Gewichtetes Mittel ueber alle Beitragsjahre (vor Planbeginn und im Plan). Ab 90'720 gibt es die Maximalrente."
help="Gewichtetes Mittel über alle Beitragsjahre (vor Planbeginn und im Plan). Ab 90'720 gibt es die Maximalrente."
/>
<DerivedField
label="Resultierende AHV-Rente pro Jahr"
value={rente}
help="Vollrente zum mdJE (Skala 44), inkl. 13. Altersrente, gekuerzt um die Ausfalljahre. Bei Ehepaaren zusaetzlich plafoniert."
help="Vollrente zum mdJE (Skala 44), inkl. 13. Altersrente, gekürzt um die Ausfalljahre. Bei Ehepaaren zusätzlich plafoniert."
/>
</>
);
@@ -220,7 +220,7 @@ export function AhvReviewFields({
export function withTransitionDefaults(category: ElementCategory, isRetirement: boolean, td: TransitionData): TransitionData {
const out = { ...td };
if (category === "AHV") {
// Ein Speichern der Pruefung markiert sie als erledigt (gleiches Muster wie bei PK/3a).
// Ein Speichern der Prüfung markiert sie als erledigt (gleiches Muster wie bei PK/3a).
if (isRetirement && out.reviewed === undefined) out.reviewed = true;
} else if (category === "REAL_ESTATE" || category === "OTHER_ASSET") {
if (out.decision === undefined) out.decision = "HOLD";
@@ -236,7 +236,7 @@ export function withTransitionDefaults(category: ElementCategory, isRetirement:
return out;
}
// --- Cash-Uebergang: einmalige Sonderein-/ausgaben ---
// --- Cash-Übergang: einmalige Sonderein-/ausgaben ---
export function withCashTransitionDefaults(ct: CashTransitionData): CashTransitionData {
return ct.mode === undefined ? { ...ct, mode: "NONE" } : { ...ct };
@@ -246,7 +246,7 @@ export function isCashTransitionAnswered(ct: CashTransitionData): boolean {
return ct.mode !== undefined;
}
// Kurzfassung fuer die Uebergangszelle der Cash-Zeile.
// Kurzfassung für die Übergangszelle der Cash-Zeile.
export function cashTransitionSummary(ct: CashTransitionData): string {
const mode = ct.mode;
if (mode === undefined) return "?";
@@ -264,7 +264,7 @@ export function cashTransitionSummary(ct: CashTransitionData): string {
}
}
// Eingabefelder fuer den Cash-Entscheid. Erfassungs-Konventionen bewusst wie bei den
// Eingabefelder für den Cash-Entscheid. Erfassungs-Konventionen bewusst wie bei den
// laufenden Flows: Zufluss nominal (wie Einkommen), Kosten real (wie Ausgaben).
export function CashTransitionFields({
ct,
@@ -287,8 +287,8 @@ export function CashTransitionFields({
<>
<div className="sm:col-span-2">
<SelectField
label="Entscheid fuer das Cash-Konto"
help="Was passiert beim Uebergang in die naechste Lebensphase mit dem Cash-Bestand?"
label="Entscheid für das Cash-Konto"
help="Was passiert beim Übergang in die nächste Lebensphase mit dem Cash-Bestand?"
value={mode}
onChange={(v: CashTransitionMode) =>
setC(
@@ -302,7 +302,7 @@ export function CashTransitionFields({
)
}
options={[
{ value: "NONE", label: "1:1 uebernehmen" },
{ value: "NONE", label: "1:1 übernehmen" },
{ value: "INFLOW", label: "Einmaliger Zufluss" },
{ value: "OUTFLOW", label: "Einmalige Kosten" },
{ value: "BOTH", label: "Zufluss und Kosten" },
@@ -314,7 +314,7 @@ export function CashTransitionFields({
<>
<div className="sm:col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
<strong className="text-fg">Einmaliger Zufluss</strong> (z. B. Erbschaft). Wird NOMINAL erfasst
der Betrag, der zu diesem Zeitpunkt tatsaechlich aufs Konto kommt.
der Betrag, der zu diesem Zeitpunkt tatsächlich aufs Konto kommt.
</div>
<TextField
label="Bezeichnung"
@@ -337,13 +337,13 @@ export function CashTransitionFields({
<DerivedField
label="≈ real (heutige Kaufkraft)"
value={Math.round(inflowNet / d)}
help="Netto-Zufluss nach Steuer, zurueckgerechnet auf die Kaufkraft bei Planbeginn. Nur zur Info."
help="Netto-Zufluss nach Steuer, zurückgerechnet auf die Kaufkraft bei Planbeginn. Nur zur Info."
/>
{taxRate > 0 && (
<DerivedField
label="Netto aufs Cash (nominal)"
value={inflowNet}
help="Betrag abzueglich Steuer. Wird automatisch berechnet."
help="Betrag abzüglich Steuer. Wird automatisch berechnet."
/>
)}
</>
@@ -369,7 +369,7 @@ export function CashTransitionFields({
<DerivedField
label="≈ nominal (zum Zeitpunkt)"
value={Math.round(num(ct.outflowAmount) * d)}
help="Der Betrag, der zu diesem Zeitpunkt tatsaechlich faellig ist. Nur zur Info."
help="Der Betrag, der zu diesem Zeitpunkt tatsächlich fällig ist. Nur zur Info."
/>
</>
)}
@@ -381,7 +381,7 @@ export function CashTransitionFields({
export function isTransitionAnswered(category: ElementCategory, isRetirement: boolean, td: TransitionData): boolean {
switch (category) {
case "AHV":
// Nur bei der Pensionierung ist eine Pruefung der Beitragskarriere noetig.
// Nur bei der Pensionierung ist eine Prüfung der Beitragskarriere nötig.
return isRetirement ? td.reviewed === true : true;
case "REAL_ESTATE":
case "OTHER_ASSET":
@@ -395,7 +395,7 @@ export function isTransitionAnswered(category: ElementCategory, isRetirement: bo
}
}
// --- Wiederverwendbare Feldgruppen (Detail-Panel, Erstell-Popup, Uebergangs-Review) ---
// --- Wiederverwendbare Feldgruppen (Detail-Panel, Erstell-Popup, Übergangs-Review) ---
export function ElementPhaseFields({
element,
@@ -414,7 +414,7 @@ export function ElementPhaseFields({
case "EXPENSE": {
const isIncome = element.category === "INCOME";
// Basiswert (erstes Jahr). Ab Phase 2 mit dem fortgeschriebenen Wert der Vorphase
// vorbelegt, aber bewusst aenderbar (Teilzeit, Befoerderung, Jobwechsel …).
// vorbelegt, aber bewusst änderbar (Teilzeit, Beförderung, Jobwechsel …).
const baseValue = typeof pd.amount === "number" ? pd.amount : carried ? context.derivedStart : 0;
const rate = num(pd.teuerungsausgleich, 0);
const d = context.deflatorStart || 1;
@@ -424,12 +424,12 @@ export function ElementPhaseFields({
<>
<p className="col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
{isIncome
? "Bitte das NETTO-Einkommen erfassen: der Betrag, der nach allen Lohnabzuegen (AHV/ALV, Pensionskasse, NBU) tatsaechlich aufs Konto kommt nicht der Bruttolohn. Erfasst wird NOMINAL; der reale Wert (heutige Kaufkraft) erscheint nur zur Info. Fuer die AHV-Rente rechnet das Tool intern auf den Bruttolohn zurueck."
? "Bitte das NETTO-Einkommen erfassen: der Betrag, der nach allen Lohnabzügen (AHV/ALV, Pensionskasse, NBU) tatsächlich aufs Konto kommt nicht der Bruttolohn. Erfasst wird NOMINAL; der reale Wert (heutige Kaufkraft) erscheint nur zur Info. Für die AHV-Rente rechnet das Tool intern auf den Bruttolohn zurück."
: "Ausgaben werden REAL erfasst (in heutiger Kaufkraft). Die Inflation (plan-weit) rechnet daraus automatisch die nominalen Ausgaben nur zur Info."}
</p>
<MoneyField
label={isIncome ? "Jahreseinkommen NETTO, nominal (erstes Jahr)" : "Jahresausgaben REAL (erstes Jahr, heutige Kaufkraft)"}
help={carried ? "Vorbelegt mit dem fortgeschriebenen Wert der Vorphase; hier bewusst aenderbar." : undefined}
help={carried ? "Vorbelegt mit dem fortgeschriebenen Wert der Vorphase; hier bewusst änderbar." : undefined}
value={baseValue}
onChange={(v) => setP({ amount: v })}
/>
@@ -439,11 +439,11 @@ export function ElementPhaseFields({
help="Nur zur Info, wird automatisch berechnet."
/>
<NumberField
label={isIncome ? "Nominale Lohnerhoehung (%/Jahr)" : "Reale Mehrausgaben (%/Jahr)"}
label={isIncome ? "Nominale Lohnerhöhung (%/Jahr)" : "Reale Mehrausgaben (%/Jahr)"}
help={
isIncome
? "Geschaetzte jaehrliche Lohnerhoehung. 0% = Lohn bleibt nominal gleich und verliert real an Kaufkraft."
: "Zusaetzliche reale Mehrausgaben pro Jahr (z. B. weil man sich mehr goennt). Die Inflation kommt separat automatisch dazu. 0% = gleicher Lebensstandard."
? "Geschätzte jährliche Lohnerhöhung. 0% = Lohn bleibt nominal gleich und verliert real an Kaufkraft."
: "Zusätzliche reale Mehrausgaben pro Jahr (z. B. weil man sich mehr gönnt). Die Inflation kommt separat automatisch dazu. 0% = gleicher Lebensstandard."
}
step={0.1}
value={rate}
@@ -454,25 +454,25 @@ export function ElementPhaseFields({
}
case "AHV":
if (!context.ownerWorking) {
// Sonderfall: bei Planbeginn bereits pensioniert -> es gibt keinen Pensions-Uebergang,
// an dem die Karriere geprueft werden koennte. Dann hier erfassen (nur erste Phase).
// Sonderfall: bei Planbeginn bereits pensioniert -> es gibt keinen Pensions-Übergang,
// an dem die Karriere geprüft werden könnte. Dann hier erfassen (nur erste Phase).
if (!carried && context.ahvCareer && context.ahvCareer.planYears === 0) {
return (
<>
<p className="col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
Diese Person ist bei Planbeginn bereits pensioniert. Die AHV-Rente haengt vom
massgebenden durchschnittlichen Jahreseinkommen ueber die ganze Beitragsdauer ab
Diese Person ist bei Planbeginn bereits pensioniert. Die AHV-Rente hängt vom
massgebenden durchschnittlichen Jahreseinkommen über die ganze Beitragsdauer ab
bitte hier erfassen (REAL, heutige Kaufkraft).
</p>
<MoneyField
label="Durchschnittliches Bruttoeinkommen der Beitragsjahre (real)"
help="BRUTTO und in heutiger Kaufkraft. Am besten aus der AHV-Rentenvorausberechnung bzw. der Rentenverfuegung. Ab 90'720 gibt es die Maximalrente."
label="Durchschnittliches Bruttöinkommen der Beitragsjahre (real)"
help="BRUTTO und in heutiger Kaufkraft. Am besten aus der AHV-Rentenvorausberechnung bzw. der Rentenverfügung. Ab 90'720 gibt es die Maximalrente."
value={num(pd.avgIncomeBefore)}
onChange={(v) => setP({ avgIncomeBefore: v })}
/>
<NumberField
label="Ausfalljahre total"
help="Jahre ohne AHV-Beitraege. Jedes kuerzt die Rente um 1/44."
help="Jahre ohne AHV-Beiträge. Jedes kürzt die Rente um 1/44."
value={num(pd.gapYearsBefore)}
min={0}
onChange={(v) => setP({ gapYearsBefore: Math.max(0, Math.round(v)) })}
@@ -480,14 +480,14 @@ export function ElementPhaseFields({
<DerivedField
label="Resultierende AHV-Rente pro Jahr"
value={ahvAnnualPension(num(pd.avgIncomeBefore), num(pd.gapYearsBefore))}
help="Inkl. 13. Altersrente. Bei Ehepaaren zusaetzlich plafoniert."
help="Inkl. 13. Altersrente. Bei Ehepaaren zusätzlich plafoniert."
/>
</>
);
}
return (
<p className="col-span-2 text-sm text-muted">
Die AHV-Rente wird aus der beim Pensions-Uebergang geprueften Beitragskarriere berechnet
Die AHV-Rente wird aus der beim Pensions-Übergang geprüften Beitragskarriere berechnet
(massgebendes Durchschnittseinkommen und Ausfalljahre). Bei Ehepaaren greift die
Plafonierung auf 150% der Maximalrente.
</p>
@@ -496,7 +496,7 @@ export function ElementPhaseFields({
return (
<NumberField
label="Geplante Ausfalljahre"
help="Jahre ohne AHV-Beitraege in dieser Phase. Jedes Ausfalljahr kuerzt die spaetere Rente um 1/44."
help="Jahre ohne AHV-Beiträge in dieser Phase. Jedes Ausfalljahr kürzt die spätere Rente um 1/44."
value={num(pd.gapYears)}
min={0}
max={context.durationYears}
@@ -507,8 +507,8 @@ export function ElementPhaseFields({
if (!context.ownerWorking) {
return (
<p className="col-span-2 text-sm text-muted">
Die PK-Rente wird aus dem beim Pensions-Uebergang gewaehlten Umwandlungssatz berechnet (siehe
Kennzahl). Bei reinem Kapitalbezug erscheint hier &quot;Vollstaendig bezogen&quot;.
Die PK-Rente wird aus dem beim Pensions-Übergang gewählten Umwandlungssatz berechnet (siehe
Kennzahl). Bei reinem Kapitalbezug erscheint hier &quot;Vollständig bezogen&quot;.
</p>
);
}
@@ -519,7 +519,7 @@ export function ElementPhaseFields({
<DerivedField label="Startwert (fortgeschrieben)" value={context.derivedStart} help="Endwert der Vorphase, fortgeschrieben." />
<MoneyField
label="Zusatzeinlage aus Kapital (CHF)"
help="Aufstockung aus dem verfuegbaren Kapital dieser Phase."
help="Aufstockung aus dem verfügbaren Kapital dieser Phase."
value={num(pd.additionalInvestment)}
onChange={(v) => setP({ additionalInvestment: v })}
@@ -529,8 +529,8 @@ export function ElementPhaseFields({
<MoneyField label="Aktueller PK-Wert (CHF)" value={num(pd.currentValue)} onChange={(v) => setP({ currentValue: v })} />
)}
<MoneyField
label="Jaehrliche Einzahlung (CHF)"
help="Arbeitnehmer- und Arbeitgeberbeitraege. Fliesst NICHT in die Sparquote ein (bereits in den Ausgaben beruecksichtigt)."
label="Jährliche Einzahlung (CHF)"
help="Arbeitnehmer- und Arbeitgeberbeiträge. Fliesst NICHT in die Sparquote ein (bereits in den Ausgaben berücksichtigt)."
value={num(pd.annualContribution)}
onChange={(v) => setP({ annualContribution: v })}
/>
@@ -539,7 +539,7 @@ export function ElementPhaseFields({
);
case "PILLAR_3A":
if (!context.ownerWorking) {
return <p className="col-span-2 text-sm text-muted">Die Saeule 3a wird beim Pensions-Uebergang vollstaendig bezogen.</p>;
return <p className="col-span-2 text-sm text-muted">Die Säule 3a wird beim Pensions-Übergang vollständig bezogen.</p>;
}
return (
<>
@@ -548,7 +548,7 @@ export function ElementPhaseFields({
<DerivedField label="Startwert (fortgeschrieben)" value={context.derivedStart} help="Endwert der Vorphase, fortgeschrieben." />
<MoneyField
label="Zusatzeinlage aus Kapital (CHF)"
help="Aufstockung aus dem verfuegbaren Kapital dieser Phase."
help="Aufstockung aus dem verfügbaren Kapital dieser Phase."
value={num(pd.additionalInvestment)}
onChange={(v) => setP({ additionalInvestment: v })}
@@ -558,8 +558,8 @@ export function ElementPhaseFields({
<MoneyField label="Aktueller 3a-Wert (CHF)" value={num(pd.currentValue)} onChange={(v) => setP({ currentValue: v })} />
)}
<MoneyField
label="Jaehrliche Einzahlung (CHF)"
help={`Maximal CHF ${PILLAR_3A_MAX_ANNUAL.toLocaleString("de-CH")} (2026, mit PK) und hoechstens die Sparquote. Wird von der Sparquote abgezogen.`}
label="Jährliche Einzahlung (CHF)"
help={`Maximal CHF ${PILLAR_3A_MAX_ANNUAL.toLocaleString("de-CH")} (2026, mit PK) und höchstens die Sparquote. Wird von der Sparquote abgezogen.`}
value={num(pd.annualContribution)}
max={PILLAR_3A_MAX_ANNUAL}
onChange={(v) => setP({ annualContribution: v })}
@@ -594,7 +594,7 @@ export function ElementPhaseFields({
)}
<MoneyField
label="Amortisation (CHF/Jahr)"
help="Jaehrliche Reduktion der Hypothek. Zaehlt gegen die Sparquote und endet, sobald die Hypothek abbezahlt ist."
help="Jährliche Reduktion der Hypothek. Zählt gegen die Sparquote und endet, sobald die Hypothek abbezahlt ist."
value={num(pd.amortization)}
onChange={(v) => setP({ amortization: v })}
/>
@@ -615,7 +615,7 @@ export function ElementPhaseFields({
</div>
</div>
<NumberField
label="Geschaetzte Wertsteigerung (%/Jahr)"
label="Geschätzte Wertsteigerung (%/Jahr)"
help="Wirkt auf den Wert der LIEGENSCHAFT, nicht auf das Eigenkapital. 1% von 1 Mio sind 10'000 pro Jahr bei 100'000 Eigenkapital also 10% darauf (Hebel)."
step={0.1}
value={num(pd.valueGrowth)}
@@ -624,12 +624,12 @@ export function ElementPhaseFields({
<div className="sm:col-span-2">
<SelectField
label="Sind die Hypothekarzinsen bereits in den Ausgaben enthalten?"
help="Damit die Zinsen nicht doppelt zaehlen. Standard: bereits enthalten so verhalten sich bestehende Plaene unveraendert."
help="Damit die Zinsen nicht doppelt zählen. Standard: bereits enthalten so verhalten sich bestehende Pläne unverändert."
value={handling}
onChange={(v: "INCLUDED" | "ADD") => setP({ interestHandling: v })}
options={[
{ value: "INCLUDED", label: "Ja bereits im Ausgaben-Element beruecksichtigt" },
{ value: "ADD", label: "Nein bitte zu den Ausgaben dazuzaehlen" },
{ value: "INCLUDED", label: "Ja bereits im Ausgaben-Element berücksichtigt" },
{ value: "ADD", label: "Nein bitte zu den Ausgaben dazuzählen" },
]}
/>
</div>
@@ -644,7 +644,7 @@ export function ElementPhaseFields({
<DerivedField label="Startwert (fortgeschrieben)" value={context.derivedStart} help="Endwert der Vorphase, fortgeschrieben." />
<MoneyField
label="Zusatzinvestition aus Kapital (CHF)"
help="Neuinvestition aus dem verfuegbaren Kapital dieser Phase."
help="Neuinvestition aus dem verfügbaren Kapital dieser Phase."
value={num(pd.additionalInvestment)}
onChange={(v) => setP({ additionalInvestment: v })}
@@ -655,14 +655,14 @@ export function ElementPhaseFields({
)}
<NumberField label="Erwartete Rendite (%/Jahr)" step={0.1} value={num(pd.expectedReturn)} onChange={(v) => setP({ expectedReturn: v })} />
<MoneyField
label="Jaehrlicher Sparbeitrag (CHF)"
help="Flacher Jahresbetrag, der ins Vermoegen fliesst und vom Cash abgezogen wird (geplante Sparrate)."
label="Jährlicher Sparbeitrag (CHF)"
help="Flacher Jahresbetrag, der ins Vermögen fliesst und vom Cash abgezogen wird (geplante Sparrate)."
value={num(pd.annualContribution)}
onChange={(v) => setP({ annualContribution: v })}
/>
<MoneyField
label="Jaehrliche Bezugsrate (CHF)"
help="Entnahme aus dem Vermoegen (z. B. laufende Renten-Entnahme im Alter). Mindert das Vermoegen und fliesst jaehrlich ins Cash (geplante Verzehrrate)."
label="Jährliche Bezugsrate (CHF)"
help="Entnahme aus dem Vermögen (z. B. laufende Renten-Entnahme im Alter). Mindert das Vermögen und fliesst jährlich ins Cash (geplante Verzehrrate)."
value={num(pd.annualWithdrawal)}
onChange={(v) => setP({ annualWithdrawal: v })}
/>
@@ -677,8 +677,8 @@ export function ElementPhaseFields({
<MoneyField label="Restschuld (CHF)" value={num(pd.startValue)} onChange={(v) => setP({ startValue: v })} />
)}
<MoneyField
label="Jaehrliche Tilgung (CHF)"
help="Zaehlt gegen die Sparquote (max. die Sparquote)."
label="Jährliche Tilgung (CHF)"
help="Zählt gegen die Sparquote (max. die Sparquote)."
value={num(pd.annualRepayment)}
onChange={(v) => setP({ annualRepayment: v })}
@@ -704,8 +704,8 @@ export function ElementTransitionFields({
case "EXPENSE":
return (
<p className="col-span-2 text-sm text-muted">
Fuer diese Kategorie gibt es im Uebergang keine Eingaben. Die Werte werden 1:1 in die naechste
Lebensphase uebernommen und koennen dort angepasst werden.
Für diese Kategorie gibt es im Übergang keine Eingaben. Die Werte werden 1:1 in die nächste
Lebensphase übernommen und können dort angepasst werden.
</p>
);
case "AHV":
@@ -714,7 +714,7 @@ export function ElementTransitionFields({
}
return (
<p className="col-span-2 text-sm text-muted">
Die Beitragskarriere wird erst beim Uebergang in die Pensionierung geprueft.
Die Beitragskarriere wird erst beim Übergang in die Pensionierung geprüft.
</p>
);
case "PENSION_FUND":
@@ -735,7 +735,7 @@ export function ElementTransitionFields({
{(mode === "PENSION" || mode === "COMBI") && (
<NumberField
label="Umwandlungssatz (%)"
help="Jaehrliche Rente = verrentetes Kapital x Umwandlungssatz."
help="Jährliche Rente = verrentetes Kapital x Umwandlungssatz."
step={0.1}
value={num(td.conversionRate, DEFAULT_PK_CONVERSION_RATE)}
onChange={(v) => setT({ conversionRate: v })}
@@ -762,7 +762,7 @@ export function ElementTransitionFields({
return (
<NumberField
label="Kapitalbezugssteuer (%)"
help="Die Saeule 3a wird bei Pensionierung vollstaendig bezogen."
help="Die Säule 3a wird bei Pensionierung vollständig bezogen."
step={0.5}
value={num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE)}
onChange={(v) => setT({ capitalTaxRate: v })}
@@ -775,7 +775,7 @@ export function ElementTransitionFields({
const marktwert = Math.round(context.propertyValueEnd);
const preis = num(td.salePrice);
// Abweichung zwischen erfasstem Verkaufspreis und modelliertem Verkehrswert. Beide
// Groessen sind unabhaengig erfassbar -- ohne diesen Vergleich koennte man 2 %
// Grössen sind unabhängig erfassbar -- ohne diesen Vergleich könnte man 2 %
// Wertsteigerung annehmen und trotzdem zum Kaufpreis verkaufen, ohne es zu merken.
const abweichung = preis - marktwert;
const abweichungPct = marktwert > 0 ? (abweichung / marktwert) * 100 : 0;
@@ -833,13 +833,13 @@ export function ElementTransitionFields({
)}
</p>
)}
<NumberField label="Grundstueckgewinnsteuer (%)" step={1} value={num(td.saleTaxRate, DEFAULT_PROPERTY_GAINS_TAX_RATE)} onChange={(v) => setT({ saleTaxRate: v })} />
<NumberField label="Grundstückgewinnsteuer (%)" step={1} value={num(td.saleTaxRate, DEFAULT_PROPERTY_GAINS_TAX_RATE)} onChange={(v) => setT({ saleTaxRate: v })} />
</>
)}
{decision !== "SELL" && context.mortgageEnd > 0 && (
<MoneyField
label="Sonderamortisation (CHF)"
help={`Einmaltilgung der Hypothek aus dem Cash der naechsten Phase (z. B. aus einem 3a-Bezug). Senkt Restschuld und kuenftige Zinsen. Maximal ${formatChf(context.mortgageEnd)} (Resthypothek am Phasenende).`}
help={`Einmaltilgung der Hypothek aus dem Cash der nächsten Phase (z. B. aus einem 3a-Bezug). Senkt Restschuld und künftige Zinsen. Maximal ${formatChf(context.mortgageEnd)} (Resthypothek am Phasenende).`}
value={num(td.extraAmortization)}
max={context.mortgageEnd}
onChange={(v) => setT({ extraAmortization: v })}
@@ -865,7 +865,7 @@ export function ElementTransitionFields({
{decision === "PARTIAL" && (
<MoneyField
label="Teilverkauf (CHF)"
help={`Betrag, der am Uebergang ins Cash fliesst. Der Rest bleibt investiert. Maximal ${formatChf(context.carriedEndValue)} (Endwert der Vorphase).`}
help={`Betrag, der am Übergang ins Cash fliesst. Der Rest bleibt investiert. Maximal ${formatChf(context.carriedEndValue)} (Endwert der Vorphase).`}
value={num(td.partialSaleAmount)}
max={context.carriedEndValue}
onChange={(v) => setT({ partialSaleAmount: v })}
@@ -878,7 +878,7 @@ export function ElementTransitionFields({
return (
<MoneyField
label="Sofortige Tilgung (CHF)"
help="Wird sofort getilgt und vom verfuegbaren Kapital der naechsten Phase abgezogen."
help="Wird sofort getilgt und vom verfügbaren Kapital der nächsten Phase abgezogen."
value={num(td.immediateRepayment)}
onChange={(v) => setT({ immediateRepayment: v })}
/>
@@ -903,8 +903,8 @@ export function ElementDetail({ element, context, phaseData, transitionData, onS
} else {
const payload: PhaseData = { ...pd };
// Einkommen/Ausgaben ab Phase 2: entspricht der Basiswert dem fortgeschriebenen Wert
// der Vorphase, KEINEN Override speichern -> Wert bleibt live vererbt (Aenderungen in
// frueheren Phasen wirken sich weiter aus). Nur ein bewusst abweichender Wert wird fix.
// der Vorphase, KEINEN Override speichern -> Wert bleibt live vererbt (Änderungen in
// früheren Phasen wirken sich weiter aus). Nur ein bewusst abweichender Wert wird fix.
if (
(element.category === "INCOME" || element.category === "EXPENSE") &&
context.carried &&
@@ -936,7 +936,7 @@ export function ElementDetail({ element, context, phaseData, transitionData, onS
<div>
<div className="text-xs font-semibold uppercase tracking-wide text-accent-soft-fg">
{CATEGORY_LABELS[element.category]}
{isTransition ? " · Uebergang" : ""}
{isTransition ? " · Übergang" : ""}
</div>
<div className="text-base font-semibold text-fg">{element.name}</div>
</div>
@@ -945,12 +945,12 @@ export function ElementDetail({ element, context, phaseData, transitionData, onS
onClick={onDeleteElement}
className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-xs text-muted hover:border-danger hover:bg-danger-soft hover:text-danger"
>
<Trash2 className="h-3.5 w-3.5" /> Element loeschen
<Trash2 className="h-3.5 w-3.5" /> Element löschen
</button>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{/* Punkt 4: Warnung, dass Aenderungen sich in die Folgephasen fortschreiben. */}
{/* Punkt 4: Warnung, dass Änderungen sich in die Folgephasen fortschreiben. */}
{!isTransition && <CarryWarning laterPhaseCount={context.laterPhaseCount} />}
{isTransition ? (
<ElementTransitionFields element={element} context={context} td={td} setT={setT} />
+5 -5
View File
@@ -51,10 +51,10 @@ export function NumberField({
);
}
// Pflicht-Zahlenfeld, das wirklich LEER sein kann (NumberField erzwingt eine Zahl und haette
// Pflicht-Zahlenfeld, das wirklich LEER sein kann (NumberField erzwingt eine Zahl und hätte
// damit immer einen Default -- genau das soll bei Annahmen vermieden werden, siehe
// SPEZIFIKATION 9.15/9.18: ein stiller Default wird nicht hinterfragt). Der Wert wird als
// String gefuehrt; die Validierung liegt beim Aufrufer.
// String geführt; die Validierung liegt beim Aufrufer.
export function RequiredNumberField({
label,
help,
@@ -148,7 +148,7 @@ export function MoneyInput({
}
// Klick = 1 Schritt. Halten: nach 400 ms Wiederholung im 70-ms-Takt, wobei die
// Schrittweite mit der Haltedauer waechst (immer schneller).
// Schrittweite mit der Haltedauer wächst (immer schneller).
function startHold(sign: number) {
stepBy(sign);
const start = Date.now();
@@ -177,7 +177,7 @@ export function MoneyInput({
onFocus={() => {
setFocused(true);
const current = Math.round(value || 0);
// Default-0 sofort leeren; sonst Wert markieren, damit man ihn ueberschreiben kann.
// Default-0 sofort leeren; sonst Wert markieren, damit man ihn überschreiben kann.
setText(current === 0 ? "" : String(current));
requestAnimationFrame(() => inputRef.current?.select());
}}
@@ -200,7 +200,7 @@ export function MoneyInput({
<button
type="button"
tabIndex={-1}
aria-label="Erhoehen"
aria-label="Erhöhen"
onMouseDown={(e) => {
e.preventDefault();
startHold(1);
+12 -12
View File
@@ -27,8 +27,8 @@ import { CATEGORY_LABELS } from "@/lib/elements";
import type { PlanInput, ScenarioMeta } from "@/lib/types";
import type { PlanComputed } from "@/lib/calculations";
// Farben der Szenario-Serien -- wie im Vermoegensverlauf, damit die Zuordnung vertraut bleibt.
const PALETTE = ["#4f46e5", "#0ea5e9", "#16a34a", "#d97706", "#dc2626", "#7c3aed"];
// Farben der Szenario-Serien -- wie im Vermögensverlauf, damit die Zuordnung vertraut bleibt.
const PALETTE = ["#4f46e5", "#0ea5e9", "#16a34a", "#d97706", "#dc2626", "#7c3äd"];
const RETURN_LEVEL_OPTIONS: { value: ReturnVolatilityLevel; label: string }[] = [
{ value: "sehr_niedrig", label: "Sehr niedrig" },
@@ -92,8 +92,8 @@ export function MonteCarloDialog({
plan: PlanInput;
computed: PlanComputed;
meta: ScenarioMeta;
// Alle Szenarien dieses Plans (auch nicht ausgewaehlte) -- sie werden geladen, damit sich
// die Herkunfts-Kette der Elemente auch ueber uebersprungene Zwischen-Szenarien aufloest.
// Alle Szenarien dieses Plans (auch nicht ausgewählte) -- sie werden geladen, damit sich
// die Herkunfts-Kette der Elemente auch über übersprungene Zwischen-Szenarien auflöst.
scenarios: ScenarioMeta[];
onClose: () => void;
}) {
@@ -119,8 +119,8 @@ export function MonteCarloDialog({
const scenarioKey = scenarios.map((s) => s.id).join(",");
// Die uebrigen Szenarien einmalig nachladen. Ein Plan hat realistisch eine Handvoll
// Szenarien -- gegenueber tausenden Simulationslaeufen faellt das nicht ins Gewicht.
// Die übrigen Szenarien einmalig nachladen. Ein Plan hat realistisch eine Handvoll
// Szenarien -- gegenüber tausenden Simulationsläufen fällt das nicht ins Gewicht.
useEffect(() => {
const missing = scenarios.filter((s) => s.id !== meta.id);
if (missing.length === 0) return;
@@ -156,7 +156,7 @@ export function MonteCarloDialog({
[scenarios, loaded]
);
// Ein Parametersatz je LOGISCHEM Element (ueber die sourceElementId-Kette zusammengefasst).
// Ein Parametersatz je LOGISCHEM Element (über die sourceElementId-Kette zusammengefasst).
const groups = useMemo(
() => buildElementGroups(allLoaded, selectedIds),
[allLoaded, selectedIds]
@@ -195,8 +195,8 @@ export function MonteCarloDialog({
return [g.rootId, { mean: Number(d.mean) || 0, sigma: returnSigma(d), floor: floorFor(g.category) }];
})
);
// EIN Seed fuer alle Szenarien: strukturgleiche Szenarien teilen damit dieselben
// Marktpfade, und die Unterschiede sind strukturell statt zufaellig.
// EIN Seed für alle Szenarien: strukturgleiche Szenarien teilen damit dieselben
// Marktpfade, und die Unterschiede sind strukturell statt zufällig.
const seed = (Math.random() * 2 ** 32) >>> 0;
const runInputs: ScenarioRunInput[] = selected.map((s) => ({
scenarioId: s.id,
@@ -239,7 +239,7 @@ export function MonteCarloDialog({
</button>
</div>
{/* Erklaerung */}
{/* Erklärung */}
<div className="rounded-xl border border-border bg-surface-2 p-4 text-xs leading-relaxed text-muted">
<p className="mb-2">
<strong className="text-fg">Was ist das?</strong> Dein Plan rechnet mit einer festen Rendite und Inflation
@@ -479,7 +479,7 @@ function MonteCarloResults({
}) {
const single = results.length === 1;
// Einzelnes Szenario: der gewohnte Faecher (Band + Median + Planungslinie).
// Einzelnes Szenario: der gewohnte Fächer (Band + Median + Planungslinie).
const singleData = useMemo(() => {
if (!single) return [];
return results[0].bands.map((b) => ({
@@ -490,7 +490,7 @@ function MonteCarloResults({
}));
}, [single, results, detPoints]);
// Mehrere Szenarien: nur die Median-Linien -- uebereinandergelegte Baender waeren Farbbrei.
// Mehrere Szenarien: nur die Median-Linien -- übereinandergelegte Bänder wären Farbbrei.
const compareData = useMemo(() => {
if (single) return [];
const ages = Array.from(new Set(results.flatMap((r) => r.bands.map((b) => b.age)))).sort((a, b) => a - b);
+5 -5
View File
@@ -43,12 +43,12 @@ export function PhaseDetail({
}
async function remove() {
if (!confirm(`Phase "${phase.name}" wirklich loeschen?`)) return;
if (!confirm(`Phase "${phase.name}" wirklich löschen?`)) return;
try {
await api.delete(`/api/phases/${phase.id}`);
onDeleted();
} catch (e) {
alert(e instanceof Error ? e.message : "Loeschen fehlgeschlagen.");
alert(e instanceof Error ? e.message : "Löschen fehlgeschlagen.");
}
}
@@ -64,18 +64,18 @@ export function PhaseDetail({
onClick={remove}
className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-xs text-muted hover:border-danger hover:bg-danger-soft hover:text-danger"
>
<Trash2 className="h-3.5 w-3.5" /> Phase loeschen
<Trash2 className="h-3.5 w-3.5" /> Phase löschen
</button>
)}
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{/* Eine geaenderte Dauer verschiebt alle Folgephasen (Alter, Renten, Vermoegen). */}
{/* Eine geänderte Dauer verschiebt alle Folgephasen (Alter, Renten, Vermögen). */}
<CarryWarning laterPhaseCount={laterPhaseCount} />
<TextField label="Bezeichnung" value={name} onChange={setName} />
<NumberField
label={`Dauer (Jahre)${cap != null ? ` · max. ${cap}` : ""}`}
help={cap != null ? "Die Dauer ist ans naechste Pensionsereignis gekappt." : undefined}
help={cap != null ? "Die Dauer ist ans nächste Pensionsereignis gekappt." : undefined}
value={durationYears}
min={1}
max={cap ?? undefined}
+4 -4
View File
@@ -19,7 +19,7 @@ export function emptyProfileDraft(): ProfileDraft {
};
}
// Gemeinsame Formularfelder fuer das Grundprofil eines Plans (Haushaltsform, Personen,
// Gemeinsame Formularfelder für das Grundprofil eines Plans (Haushaltsform, Personen,
// Inflation). Wird beim Plan-Erstellen und in den Plan-Einstellungen verwendet.
export function PlanProfileFields({
draft,
@@ -51,7 +51,7 @@ export function PlanProfileFields({
<div className="flex flex-col gap-4">
<SelectField
label="Haushaltsform"
help="Waehlen Sie, ob Sie alleine oder gemeinsam mit einer Partnerin / einem Partner planen."
help="Planst du alleine oder gemeinsam mit einer Partnerin / einem Partner?"
value={draft.householdType}
onChange={setType}
options={[
@@ -63,7 +63,7 @@ export function PlanProfileFields({
{draft.persons.map((person, index) => (
<div key={person.role} className="grid grid-cols-2 gap-3 rounded-xl border border-border bg-surface-2 p-3">
<div className="col-span-2 text-xs font-semibold uppercase tracking-wide text-faint">
{draft.householdType === "COUPLE" ? (person.role === "PERSON_A" ? "Person A" : "Person B") : "Ihre Angaben"}
{draft.householdType === "COUPLE" ? (person.role === "PERSON_A" ? "Person A" : "Person B") : "Deine Angaben"}
</div>
<div className="col-span-2">
<TextField
@@ -102,7 +102,7 @@ export function PlanProfileFields({
<NumberField
label="Erwartete Inflationsrate (%)"
help="Langfristige Annahme zur jaehrlichen Teuerung. Gilt plan-weit fuer alle Lebensphasen."
help="Langfristige Annahme zur jährlichen Teuerung. Gilt plan-weit für alle Lebensphasen."
value={draft.inflationRateDefault}
step={0.1}
onChange={(v) => onChange({ ...draft, inflationRateDefault: v })}
File diff suppressed because it is too large Load Diff
+419
View File
@@ -0,0 +1,419 @@
"use client";
// Geführter Plan-Assistent (Roadmap Nr. 10): "Schritt für Schritt statt leerer Matrix".
// Der Wizard fragt in Alltagssprache und erzeugt am Ende Plan, Phasen und Elemente über
// die BESTEHENDEN API-Endpunkte -- reine Orchestrierung, keine Änderung an Berechnung
// oder Datenmodell.
//
// Reihenfolge beim Anlegen (wichtig): erst Phase 1, dann Elemente samt Phase-1-Werten,
// dann die Folgephasen -- so belegt die Phasen-Route die Folgephasen korrekt vor.
//
// Ehrliche Grenze: Bricht die Sequenz mittendrin ab (Netzfehler), existiert ein Teil-Plan.
// Der ist normal weiterbearbeitbar; ein Rollback bräuchte Backend-Unterstützung und ist
// bewusst nicht gebaut (siehe SPEZIFIKATION 9.23).
import { useState } from "react";
import { ArrowLeft, ArrowRight, Sparkles } from "lucide-react";
import { Modal, Button } from "@/components/ui";
import { MoneyField, NumberField, TextField } from "@/components/FormField";
import { InfoBubble } from "@/components/InfoBubble";
import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields";
import { api } from "@/lib/api-client";
import { maxPhaseDuration } from "@/lib/calculations";
import { PILLAR_3A_MAX_ANNUAL } from "@/lib/constants";
import type { PhaseData } from "@/lib/elements";
interface PhaseDraft {
name: string;
durationYears: number;
}
interface AssetToggles {
pk: boolean;
p3a: boolean;
etf: boolean;
realEstate: boolean;
debt: boolean;
}
const STEP_TITLES = ["Über dich", "Deine Lebensphasen", "Einkommen & Ausgaben", "Vorsorge & Vermögen", "Fertig"];
// Vorschlag für die Phasenkette: Erwerbsphase bis zum nächsten Pensionsereignis
// (dieselbe Kappungslogik wie beim manuellen Anlegen), danach eine Pensionsphase bis ~90.
function suggestPhases(draft: ProfileDraft): PhaseDraft[] {
const persons = draft.persons.map((p) => ({ role: p.role, age: p.age, retirementAge: p.retirementAge }));
const cap = maxPhaseDuration(persons, 0);
const phases: PhaseDraft[] = [];
let elapsed = 0;
if (cap != null && cap > 0) {
phases.push({ name: "Erwerbsphase", durationYears: cap });
elapsed = cap;
// Bei Paaren mit unterschiedlichem Pensionsalter folgt eine Mischphase bis zur
// zweiten Pensionierung.
const cap2 = maxPhaseDuration(persons, elapsed);
if (cap2 != null && cap2 > 0) {
phases.push({ name: "Übergangsphase", durationYears: cap2 });
elapsed += cap2;
}
}
const oldest = Math.max(...draft.persons.map((p) => p.age));
const pensionYears = Math.max(5, 90 - (oldest + elapsed));
phases.push({ name: "Pensionsphase", durationYears: pensionYears });
return phases;
}
export function PlanWizard({
onCreated,
onClose,
}: {
onCreated: (scenarioId: string) => void;
onClose: () => void;
}) {
const [step, setStep] = useState(0);
const [planName, setPlanName] = useState("Meine Planung");
const [profile, setProfile] = useState<ProfileDraft>(emptyProfileDraft);
const [phases, setPhases] = useState<PhaseDraft[] | null>(null);
// Einkommen bewusst PRO PERSON: In Paar-Plänen zählt "Gemeinsam" nicht für die
// AHV-Rente (SPEZIFIKATION 9.9) -- der Assistent räumt diese Falle von Anfang an aus.
const [incomes, setIncomes] = useState<Record<string, number>>({ PERSON_A: 0 });
const [expenses, setExpenses] = useState(0);
const [initialCash, setInitialCash] = useState(0);
const [toggles, setToggles] = useState<AssetToggles>({ pk: false, p3a: false, etf: false, realEstate: false, debt: false });
const [pk, setPk] = useState({ value: 0, contribution: 0 });
const [p3a, setP3a] = useState({ value: 0, contribution: 0 });
const [etf, setEtf] = useState({ value: 0, contribution: 0 });
const [re, setRe] = useState({ price: 0, mortgage: 0, amortization: 0, interest: 1.5 });
const [debt, setDebt] = useState({ value: 0, repayment: 0 });
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const personLabel = (role: string) => {
const p = profile.persons.find((x) => x.role === role);
return p?.name?.trim() || (role === "PERSON_A" ? "Person A" : "Person B");
};
function goNext() {
if (step === 0) {
// Phasenvorschlag aus dem (evtl. geänderten) Profil neu ableiten.
setPhases(suggestPhases(profile));
const inc: Record<string, number> = {};
for (const p of profile.persons) inc[p.role] = incomes[p.role] ?? 0;
setIncomes(inc);
}
setStep((s) => Math.min(s + 1, STEP_TITLES.length - 1));
}
async function create() {
setSaving(true);
setError(null);
try {
const { scenario } = await api.post<{ plan: { id: string }; scenario: { id: string } }>("/api/plans", {
name: planName.trim() || "Meine Planung",
householdType: profile.householdType,
inflationRateDefault: profile.inflationRateDefault,
startYear: profile.startYear,
persons: profile.persons.map((p) => ({ role: p.role, name: p.name.trim() || null, age: p.age, retirementAge: p.retirementAge })),
});
const sid = scenario.id;
if (initialCash > 0) await api.patch(`/api/scenarios/${sid}`, { initialCash });
const phaseList = phases ?? suggestPhases(profile);
const first = await api.post<{ phase: { id: string } }>(`/api/scenarios/${sid}/phases`, {
name: phaseList[0].name,
durationYears: phaseList[0].durationYears,
});
const firstPhaseId = first.phase.id;
async function addEl(category: string, name: string, ownerRole: string, values?: PhaseData) {
const { element } = await api.post<{ element: { id: string } }>(`/api/scenarios/${sid}/elements`, {
category,
name,
ownerRole,
});
if (values) await api.put(`/api/elements/${element.id}/phase/${firstPhaseId}`, values);
}
for (const p of profile.persons) {
const amount = incomes[p.role] ?? 0;
if (amount > 0) await addEl("INCOME", `Lohn ${personLabel(p.role)}`, p.role, { amount, teuerungsausgleich: 0 });
}
if (expenses > 0) await addEl("EXPENSE", "Lebenshaltung", "HOUSEHOLD", { amount: expenses, teuerungsausgleich: 0 });
for (const p of profile.persons) {
await addEl("AHV", profile.persons.length > 1 ? `AHV ${personLabel(p.role)}` : "AHV", p.role, { gapYears: 0 });
}
if (toggles.pk) {
await addEl("PENSION_FUND", "Pensionskasse", "PERSON_A", {
currentValue: pk.value,
annualContribution: pk.contribution,
expectedReturn: 1.5,
});
}
if (toggles.p3a) {
await addEl("PILLAR_3A", "Säule 3a", "PERSON_A", {
currentValue: p3a.value,
annualContribution: Math.min(p3a.contribution, PILLAR_3A_MAX_ANNUAL),
expectedReturn: 2,
});
}
if (toggles.etf) {
await addEl("OTHER_ASSET", "Wertschriften / ETF", "HOUSEHOLD", {
startValue: etf.value,
annualContribution: etf.contribution,
expectedReturn: 5,
});
}
if (toggles.realEstate) {
await addEl("REAL_ESTATE", "Eigenheim", "HOUSEHOLD", {
purchasePrice: re.price,
mortgage: re.mortgage,
amortization: re.amortization,
interestRate: re.interest,
});
}
if (toggles.debt) {
await addEl("OTHER_DEBT", "Kredit", "HOUSEHOLD", { startValue: debt.value, annualRepayment: debt.repayment });
}
for (const ph of phaseList.slice(1)) {
await api.post(`/api/scenarios/${sid}/phases`, { name: ph.name, durationYears: ph.durationYears });
}
onCreated(sid);
} catch (e) {
setError(
(e instanceof Error ? e.message : "Erstellen fehlgeschlagen.") +
" Falls der Plan teilweise angelegt wurde, findest du ihn in der Seitenleiste und kannst dort normal weiterarbeiten."
);
setSaving(false);
}
}
const workingPersons = profile.persons.filter((p) => p.age < p.retirementAge);
return (
<Modal
title="Plan erstellen geführt"
subtitle={`Schritt ${step + 1} von ${STEP_TITLES.length}: ${STEP_TITLES[step]}`}
onClose={onClose}
wide
>
{/* Fortschritt */}
<div className="flex gap-1">
{STEP_TITLES.map((_, i) => (
<div key={i} className={`h-1 flex-1 rounded-full ${i <= step ? "bg-accent" : "bg-surface-2"}`} />
))}
</div>
{step === 0 && (
<div className="flex flex-col gap-4">
<p className="text-sm text-muted">
Ein paar Angaben zu dir daraus baut das Tool dein Grundgerüst. Alles lässt sich später ändern.
</p>
<TextField label="Name des Plans" value={planName} onChange={setPlanName} placeholder="z. B. Meine Planung" />
<PlanProfileFields draft={profile} onChange={setProfile} />
</div>
)}
{step === 1 && phases && (
<div className="flex flex-col gap-3">
<p className="text-sm text-muted">
Deine Planung besteht aus <strong className="text-fg">Lebensphasen</strong> Abschnitten, in denen deine
Finanzen ähnlich laufen. Das Tool schlägt sie dir anhand deines Pensionsalters vor; die Dauer kannst du
anpassen.
</p>
{/* Mini-Vorschau der Kette */}
<div className="flex w-full overflow-hidden rounded-lg border border-border">
{phases.map((ph, i) => (
<div
key={i}
style={{ width: `${(ph.durationYears / phases.reduce((s, x) => s + x.durationYears, 0)) * 100}%` }}
className={`px-2 py-1.5 text-[10px] font-semibold ${i === 0 && workingPersons.length > 0 ? "bg-accent/70 text-accent-fg" : "bg-accent-soft text-accent-soft-fg"} ${i > 0 ? "border-l border-border" : ""}`}
>
<span className="block truncate">{ph.name}</span>
<span className="opacity-80">{ph.durationYears} J.</span>
</div>
))}
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{phases.map((ph, i) => (
<NumberField
key={i}
label={`${ph.name} Dauer (Jahre)`}
value={ph.durationYears}
min={1}
max={80}
onChange={(v) =>
setPhases((prev) => prev!.map((x, j) => (j === i ? { ...x, durationYears: Math.max(1, Math.round(v)) } : x)))
}
/>
))}
</div>
<p className="text-[11px] text-faint">
Eine Phase darf keine Pensionierung überspannen die Dauer der Erwerbsphase ist deshalb ans Pensionsalter
gekoppelt und wird beim Anlegen automatisch begrenzt.
</p>
</div>
)}
{step === 2 && (
<div className="flex flex-col gap-3">
<p className="text-sm text-muted">Die zwei wichtigsten Zahlen deiner Planung grob geschätzt reicht für den Anfang.</p>
{profile.persons.map((p) => (
<MoneyField
key={p.role}
label={`Was verdient ${personLabel(p.role)} netto pro Jahr? (CHF)`}
help="Der Betrag, der nach allen Lohnabzügen tatsächlich aufs Konto kommt nicht der Bruttolohn. Für die AHV-Rente rechnet das Tool intern hoch."
value={incomes[p.role] ?? 0}
onChange={(v) => setIncomes((prev) => ({ ...prev, [p.role]: v }))}
/>
))}
<MoneyField
label="Was gebt ihr heute pro Jahr aus? (CHF)"
help="Alle Lebenshaltungskosten in heutiger Kaufkraft Miete/Wohnen, Essen, Mobilität, Versicherungen, Steuern, Freizeit. Die Inflation rechnet das Tool automatisch dazu."
value={expenses}
onChange={setExpenses}
/>
<MoneyField
label="Wie viel liegt auf dem Konto? (CHF)"
help="Dein heutiger Kontostand (Cash). Er ist der Startwert des Ausgleichskontos."
value={initialCash}
onChange={setInitialCash}
/>
</div>
)}
{step === 3 && (
<div className="flex flex-col gap-2">
<p className="text-sm text-muted">
Was besitzt du? Hake an, was auf dich zutrifft nur das Angehakte fragt nach Details.
{profile.persons.length > 1 && " Vorsorge (PK/3a) wird Person A zugeordnet weitere Konten kannst du später ergänzen."}
</p>
<WizardToggle
label="Pensionskasse"
help="Dein PK-Guthaben laut Vorsorgeausweis und die jährlichen Einzahlungen (Arbeitnehmer + Arbeitgeber)."
checked={toggles.pk}
onChange={(v) => setToggles((t) => ({ ...t, pk: v }))}
>
<MoneyField label="Aktuelles Guthaben (CHF)" value={pk.value} onChange={(v) => setPk((s) => ({ ...s, value: v }))} />
<MoneyField label="Einzahlung pro Jahr (CHF)" value={pk.contribution} onChange={(v) => setPk((s) => ({ ...s, contribution: v }))} />
</WizardToggle>
<WizardToggle
label="Säule 3a"
help={`Dein 3a-Guthaben und die jährliche Einzahlung (max. ${PILLAR_3A_MAX_ANNUAL.toLocaleString("de-CH")} CHF).`}
checked={toggles.p3a}
onChange={(v) => setToggles((t) => ({ ...t, p3a: v }))}
>
<MoneyField label="Aktuelles Guthaben (CHF)" value={p3a.value} onChange={(v) => setP3a((s) => ({ ...s, value: v }))} />
<MoneyField label="Einzahlung pro Jahr (CHF)" value={p3a.contribution} max={PILLAR_3A_MAX_ANNUAL} onChange={(v) => setP3a((s) => ({ ...s, contribution: v }))} />
</WizardToggle>
<WizardToggle
label="Wertschriften / ETF / Sparen"
help="Depots, Fonds, Sparkonten alles, was Rendite bringt und nicht Vorsorge ist."
checked={toggles.etf}
onChange={(v) => setToggles((t) => ({ ...t, etf: v }))}
>
<MoneyField label="Aktueller Wert (CHF)" value={etf.value} onChange={(v) => setEtf((s) => ({ ...s, value: v }))} />
<MoneyField label="Sparbeitrag pro Jahr (CHF)" value={etf.contribution} onChange={(v) => setEtf((s) => ({ ...s, contribution: v }))} />
</WizardToggle>
<WizardToggle
label="Wohneigentum"
help="Kaufpreis, Hypothek und jährliche Amortisation gemäss Hypothekarvertrag."
checked={toggles.realEstate}
onChange={(v) => setToggles((t) => ({ ...t, realEstate: v }))}
>
<MoneyField label="Kaufpreis (CHF)" value={re.price} onChange={(v) => setRe((s) => ({ ...s, price: v }))} />
<MoneyField label="Hypothek (CHF)" value={re.mortgage} onChange={(v) => setRe((s) => ({ ...s, mortgage: v }))} />
<MoneyField label="Amortisation pro Jahr (CHF)" value={re.amortization} onChange={(v) => setRe((s) => ({ ...s, amortization: v }))} />
<NumberField label="Hypothekarzins (%)" step={0.1} value={re.interest} onChange={(v) => setRe((s) => ({ ...s, interest: v }))} />
</WizardToggle>
<WizardToggle
label="Schulden / Kredite"
help="Privatkredite, Autoleasing-Restwert und Ähnliches ohne die Hypothek (die gehört zum Wohneigentum)."
checked={toggles.debt}
onChange={(v) => setToggles((t) => ({ ...t, debt: v }))}
>
<MoneyField label="Restschuld (CHF)" value={debt.value} onChange={(v) => setDebt((s) => ({ ...s, value: v }))} />
<MoneyField label="Rückzahlung pro Jahr (CHF)" value={debt.repayment} onChange={(v) => setDebt((s) => ({ ...s, repayment: v }))} />
</WizardToggle>
<p className="mt-1 text-[11px] text-faint">
Die AHV wird automatisch für {profile.persons.length > 1 ? "beide Personen" : "dich"} angelegt die Rente
berechnet das Tool aus deinem Einkommen.
</p>
</div>
)}
{step === 4 && (
<div className="flex flex-col gap-3">
<p className="text-sm text-muted">Das wird angelegt:</p>
<ul className="flex flex-col gap-1.5 rounded-xl border border-border bg-surface-2 p-4 text-sm text-fg">
<li>📋 Plan «{planName.trim() || "Meine Planung"}» mit Basisszenario</li>
<li>📅 {(phases ?? []).map((p) => `${p.name} (${p.durationYears} J.)`).join(" → ")}</li>
<li>
💰 {profile.persons.filter((p) => (incomes[p.role] ?? 0) > 0).length > 0 ? "Einkommen, " : ""}
{expenses > 0 ? "Ausgaben, " : ""}AHV
{toggles.pk ? ", Pensionskasse" : ""}
{toggles.p3a ? ", Säule 3a" : ""}
{toggles.etf ? ", Wertschriften" : ""}
{toggles.realEstate ? ", Wohneigentum" : ""}
{toggles.debt ? ", Schulden" : ""}
</li>
</ul>
<p className="text-xs text-muted">
Danach zeigt dir eine kurze Tour die Matrix und die offenen{" "}
<strong className="text-fg">Übergangs-Entscheide</strong> (z. B. was bei der Pensionierung mit der PK
passiert) gehst du direkt in der Ansicht durch.
</p>
</div>
)}
{error && <p className="text-sm text-danger">{error}</p>}
<div className="flex items-center justify-between border-t border-border pt-3">
<Button variant="ghost" size="sm" onClick={onClose}>
Abbrechen
</Button>
<div className="flex gap-2">
{step > 0 && (
<Button variant="secondary" onClick={() => setStep(step - 1)} disabled={saving}>
<ArrowLeft className="h-4 w-4" /> Zurück
</Button>
)}
{step < STEP_TITLES.length - 1 ? (
<Button onClick={goNext}>
Weiter <ArrowRight className="h-4 w-4" />
</Button>
) : (
<Button onClick={create} disabled={saving}>
<Sparkles className="h-4 w-4" /> {saving ? "Wird angelegt…" : "Plan erstellen"}
</Button>
)}
</div>
</div>
</Modal>
);
}
function WizardToggle({
label,
help,
checked,
onChange,
children,
}: {
label: string;
help: string;
checked: boolean;
onChange: (v: boolean) => void;
children: React.ReactNode;
}) {
return (
<div className={`rounded-xl border p-3 transition-colors ${checked ? "border-accent bg-accent-soft/30" : "border-border bg-surface-2"}`}>
<label className="flex cursor-pointer items-center gap-2 text-sm font-medium text-fg">
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
{label}
<InfoBubble text={help} />
</label>
{checked && <div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">{children}</div>}
</div>
);
}
+7 -6
View File
@@ -12,6 +12,7 @@ export function ProfileMenu({ username }: { username: string }) {
const menuRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- einmalige Übernahme aus localStorage
setThemeState(getEffectiveTheme());
}, []);
@@ -74,7 +75,7 @@ export function ProfileMenu({ username }: { username: string }) {
<MenuItem
icon={<KeyRound className="h-4 w-4" />}
label="Passwort aendern"
label="Passwort ändern"
onClick={() => {
setOpen(false);
setShowPasswordDialog(true);
@@ -121,7 +122,7 @@ function ChangePasswordDialog({ onClose }: { onClose: () => void }) {
e.preventDefault();
setError(null);
if (newPassword !== newPasswordConfirm) {
setError("Die neuen Passwoerter stimmen nicht ueberein.");
setError("Die neuen Passwörter stimmen nicht überein.");
return;
}
setSaving(true);
@@ -130,7 +131,7 @@ function ChangePasswordDialog({ onClose }: { onClose: () => void }) {
setDone(true);
setTimeout(onClose, 1200);
} catch (err) {
setError(err instanceof Error ? err.message : "Passwort aendern fehlgeschlagen.");
setError(err instanceof Error ? err.message : "Passwort ändern fehlgeschlagen.");
} finally {
setSaving(false);
}
@@ -146,12 +147,12 @@ function ChangePasswordDialog({ onClose }: { onClose: () => void }) {
onClick={(e) => e.stopPropagation()}
className="flex w-full max-w-sm flex-col gap-3 rounded-2xl border border-border bg-surface p-6 shadow-xl"
>
<h2 className="text-base font-semibold text-fg">Passwort aendern</h2>
<h2 className="text-base font-semibold text-fg">Passwort ändern</h2>
<input type="password" placeholder="Aktuelles Passwort" autoComplete="current-password" value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} className={inputClass} />
<input type="password" placeholder="Neues Passwort" autoComplete="new-password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} className={inputClass} />
<input type="password" placeholder="Neues Passwort bestaetigen" autoComplete="new-password" value={newPasswordConfirm} onChange={(e) => setNewPasswordConfirm(e.target.value)} className={inputClass} />
<input type="password" placeholder="Neues Passwort bestätigen" autoComplete="new-password" value={newPasswordConfirm} onChange={(e) => setNewPasswordConfirm(e.target.value)} className={inputClass} />
{error && <p className="text-sm text-danger">{error}</p>}
{done && <p className="text-sm text-success">Passwort geaendert.</p>}
{done && <p className="text-sm text-success">Passwort geändert.</p>}
<div className="flex gap-2 pt-1">
<button type="submit" disabled={saving} className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-fg shadow-sm hover:bg-accent-hover disabled:opacity-50">
{saving ? "..." : "Speichern"}
+7 -7
View File
@@ -23,9 +23,9 @@ interface RangeDraft {
high: string;
}
// Bewusst OHNE Defaults: Die Balkenlaenge haengt direkt von der eingegebenen Bandbreite ab.
// Ein stiller Default wuerde nicht hinterfragt und das Ranking waere dann eine Aussage ueber
// unsere Vorgabe statt ueber den Plan (gleiche Begruendung wie bei den Monte-Carlo-Mittelwerten).
// Bewusst OHNE Defaults: Die Balkenlänge hängt direkt von der eingegebenen Bandbreite ab.
// Ein stiller Default würde nicht hinterfragt und das Ranking wäre dann eine Aussage über
// unsere Vorgabe statt über den Plan (gleiche Begründung wie bei den Monte-Carlo-Mittelwerten).
const emptyDraft: RangeDraft = { checked: false, low: "", high: "" };
function formatRange(low: number, high: number, unit: DriverDef["unit"]): string {
@@ -60,7 +60,7 @@ export function SensitivityDialog({ plan, onClose }: { plan: PlanInput; onClose:
});
// Mindestens zwei Treiber: Ein Tornado ist eine RANGLISTE. Mit einem einzigen Balken gibt
// es nichts zu ordnen und die Grafik wuerde eine Aussage suggerieren, die sie nicht hat.
// es nichts zu ordnen und die Grafik würde eine Aussage suggerieren, die sie nicht hat.
const canRun =
checked.length >= 2 && incomplete.length === 0 && degenerate.length === 0 && plan.phases.length > 0;
@@ -79,7 +79,7 @@ export function SensitivityDialog({ plan, onClose }: { plan: PlanInput; onClose:
const chartData = useMemo(() => {
if (!result) return [];
// Von unten nach oben gezeichnet -> fuer die Trichterform (groesster Balken zuoberst)
// Von unten nach oben gezeichnet -> für die Trichterform (grösster Balken zuoberst)
// muss die Reihenfolge umgekehrt in die Grafik.
return [...result.bars].reverse().map((b) => ({
label: b.shortLabel,
@@ -103,7 +103,7 @@ export function SensitivityDialog({ plan, onClose }: { plan: PlanInput; onClose:
</button>
</div>
{/* Erklaerung */}
{/* Erklärung */}
<div className="rounded-xl border border-border bg-surface-2 p-4 text-xs leading-relaxed text-muted">
<p className="mb-2">
<strong className="text-fg">Was ist das?</strong> Nicht «wie viel Geld habe ich am Schluss», sondern
@@ -130,7 +130,7 @@ export function SensitivityDialog({ plan, onClose }: { plan: PlanInput; onClose:
</p>
</div>
{/* Zielgroesse */}
{/* Zielgrösse */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<SelectField
label="Zielgrösse"
+43
View File
@@ -0,0 +1,43 @@
"use client";
// Mini-Verlaufskurve für die Matrix-Zeilen: der Trend eines Elements über alle Planjahre
// auf einen Blick. Die per-Jahr-Werte existieren seit 0.11 (ElementPhaseComputed.yearly) --
// hier werden sie nur gezeichnet, nicht neu gerechnet. Reines SVG, keine Chart-Bibliothek.
export function Sparkline({
values,
width = 64,
height = 16,
className = "",
}: {
values: number[];
width?: number;
height?: number;
className?: string;
}) {
if (values.length < 2) return null;
const min = Math.min(...values, 0);
const max = Math.max(...values, 0);
if (max === min) return null; // flache Linie trägt keine Information
const pad = 1.5;
const x = (i: number) => pad + (i / (values.length - 1)) * (width - 2 * pad);
const y = (v: number) => pad + (1 - (v - min) / (max - min)) * (height - 2 * pad);
const points = values.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(" ");
return (
<svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
aria-hidden="true"
className={`shrink-0 ${className}`}
>
{/* Nulllinie, falls die Werte das Vorzeichen wechseln (z. B. Schulden). */}
{min < 0 && max > 0 && (
<line x1={0} x2={width} y1={y(0)} y2={y(0)} stroke="var(--border-strong)" strokeWidth="1" strokeDasharray="2 2" />
)}
<polyline points={points} fill="none" stroke="var(--accent)" strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round" />
</svg>
);
}
+3 -3
View File
@@ -18,8 +18,8 @@ const INCOME_COLOR = "#16a34a";
const EXPENSE_COLOR = "#dc2626";
const REAL_COLOR = "#9ca3af";
// Verlauf pro Jahr: Einkommen (nominal, inkl. Renten) vs. nominale Ausgaben; die Flaeche
// dazwischen ist die Spar-/Verzehrquote (gruen = Sparen, rot = Verzehr). Reale Ausgaben als
// Verlauf pro Jahr: Einkommen (nominal, inkl. Renten) vs. nominale Ausgaben; die Fläche
// dazwischen ist die Spar-/Verzehrquote (grün = Sparen, rot = Verzehr). Reale Ausgaben als
// blasse Referenzlinie -> der Keil zu den nominalen Ausgaben ist "das, was die Inflation frisst".
export function SparquoteChart({ computed }: { computed: PlanComputed }) {
if (computed.yearly.length === 0) return <p className="text-sm text-muted">Noch keine Phasen vorhanden.</p>;
@@ -31,7 +31,7 @@ export function SparquoteChart({ computed }: { computed: PlanComputed }) {
income: y.income,
expenseNominal: y.expenseNominal,
expenseReal: y.expenseReal,
// Range-Flaechen [unten, oben]; nur dort gesetzt, wo zutreffend (sonst Luecke).
// Range-Flächen [unten, oben]; nur dort gesetzt, wo zutreffend (sonst Lücke).
sparBand: surplus ? [y.expenseNominal, y.income] : undefined,
verzehrBand: surplus ? undefined : [y.income, y.expenseNominal],
};
+4 -4
View File
@@ -7,8 +7,8 @@ import rehypeSlug from "rehype-slug";
import { FileText } from "lucide-react";
import { api } from "@/lib/api-client";
// Rendert SPEZIFIKATION.md (via /api/spec) als lesbares Dokument. Das Styling laeuft ueber
// die Klasse .md-doc in globals.css und folgt damit dem gewaehlten Farbschema.
// Rendert SPEZIFIKATION.md (via /api/spec) als lesbares Dokument. Das Styling läuft über
// die Klasse .md-doc in globals.css und folgt damit dem gewählten Farbschema.
export function SpecView({ anchor }: { anchor?: string | null }) {
const [markdown, setMarkdown] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -21,7 +21,7 @@ export function SpecView({ anchor }: { anchor?: string | null }) {
}, []);
// Sprungmarke aus einem Rechenweg: erst nach dem Rendern des Markdowns existiert die
// Ueberschrift mit der von rehype-slug erzeugten id.
// Überschrift mit der von rehype-slug erzeugten id.
useEffect(() => {
if (!anchor || markdown === null) return;
const el = document.getElementById(anchor);
@@ -29,7 +29,7 @@ export function SpecView({ anchor }: { anchor?: string | null }) {
}, [anchor, markdown]);
if (error) return <p className="text-sm text-danger">{error}</p>;
if (markdown === null) return <p className="text-sm text-muted">Laedt</p>;
if (markdown === null) return <p className="text-sm text-muted">Lädt</p>;
return (
<div className="flex flex-col gap-4">
+1 -1
View File
@@ -38,7 +38,7 @@ function formatValue(p: SystemParameter): string {
}
// Zeigt die Systemparameter mit Wert, Bedeutung, Herleitung und Quelle (Roadmap Nr. 41).
// Die Eintraege stammen direkt aus constants.ts -- dieselbe Datenquelle, aus der auch
// Die Einträge stammen direkt aus constants.ts -- dieselbe Datenquelle, aus der auch
// gerechnet wird. Ein Abdriften zwischen Anzeige und Rechnung ist damit ausgeschlossen.
export function SystemParametersView() {
return (
+3 -3
View File
@@ -13,7 +13,7 @@ interface PersonAxis {
// Horizontale Zeitachse: die Lebensphasen als aneinandergereihte Segmente (Breite = Dauer),
// darunter Alter und -- sofern ein Planstart gesetzt ist -- die Kalenderjahre. Pensionsalter
// je Person und ein allfaelliges Ruinalter sind als Marker eingezeichnet.
// je Person und ein allfälliges Ruinalter sind als Marker eingezeichnet.
export function Timeline({
phases,
persons,
@@ -35,7 +35,7 @@ export function Timeline({
const pct = (age: number) => `${(Math.max(0, Math.min(span, age - minAge)) / span) * 100}%`;
const yearOf = (yearsFromStart: number) => (startYear ? startYear + yearsFromStart : null);
// Segmente: Start-/Endjahr (relativ zum Planbeginn) je Phase -- ohne Mutation waehrend
// Segmente: Start-/Endjahr (relativ zum Planbeginn) je Phase -- ohne Mutation während
// des Renderns, deshalb die kumulierte Summe der vorangehenden Dauern.
const segments = phases.map((p, i) => {
const from = phases.slice(0, i).reduce((s, x) => s + x.durationYears, 0);
@@ -91,7 +91,7 @@ export function Timeline({
</div>
)}
{/* Phasen-Segmente: Breite proportional zur Dauer, Einfaerbung nach Phasentyp. */}
{/* Phasen-Segmente: Breite proportional zur Dauer, Einfärbung nach Phasentyp. */}
<div className="flex w-full overflow-hidden rounded-lg border border-border">
{segments.map((s, i) => {
const soft = s.phase.type === "PENSION" || s.phase.type === "MIXED";
+135
View File
@@ -0,0 +1,135 @@
"use client";
// Interaktive Kurz-Tour über die Planansicht (Teil von Roadmap Nr. 10). Bewusst simpel
// gehalten: eine feste Karte unten rechts plus ein pulsierender Rahmen um das jeweilige
// Ziel-Element (per data-tour-Attribut) -- kein Spotlight-Overlay, keine Positionierungs-
// Engine. Ziele, die es im aktuellen Plan nicht gibt (z. B. Übergänge bei nur einer
// Phase), werden zur Laufzeit übersprungen.
import { useEffect, useMemo, useState } from "react";
import { Lightbulb, X } from "lucide-react";
import { Button } from "@/components/ui";
export const TOUR_DONE_KEY = "fpt-tour-done";
interface TourStep {
target: string; // data-tour="…"
title: string;
text: string;
}
const STEPS: TourStep[] = [
{
target: "profil",
title: "Dein Grundprofil",
text: "Alter, Pensionsalter und Inflationsannahme. Alles hier gilt für dieses Szenario über «Profil» änderst du es jederzeit.",
},
{
target: "timeline",
title: "Die Zeitachse",
text: "Deine Lebensphasen im Überblick: Breite = Dauer, Fähnchen = Pensionierung. Die ganze Planung denkt in diesen Phasen.",
},
{
target: "matrix",
title: "Die Matrix das Herzstück",
text: "Zeilen sind deine Finanzen, Spalten deine Lebensphasen. Klick auf eine Zelle: rechts öffnet sich das Bearbeiten-Panel, die Matrix bleibt sichtbar.",
},
{
target: "transition",
title: "Übergänge",
text: "Zwischen zwei Phasen triffst du einmalige Entscheide verkaufen, beziehen, verrenten. Orange heisst: noch offen. Klick auf den Spaltenkopf führt dich durch alle Entscheide.",
},
{
target: "cash",
title: "Das Cash-Konto",
text: "Dein Ausgleichskonto: Was übrig bleibt, landet hier was fehlt, macht es rot. Negativ = Liquiditätslücke.",
},
{
target: "analysen",
title: "Analysen",
text: "Grafiken, Monte-Carlo-Simulation und Einflussfaktoren: Wie sicher ist dein Plan, und welche Annahme entscheidet wirklich?",
},
];
export function Tour({ onClose }: { onClose: () => void }) {
// Nur Schritte, deren Ziel im DOM existiert (hängt vom Plan ab).
const steps = useMemo(
() => STEPS.filter((s) => document.querySelector(`[data-tour="${s.target}"]`) !== null),
[]
);
const [index, setIndex] = useState(0);
const step = steps[index];
useEffect(() => {
if (!step) return;
const el = document.querySelector(`[data-tour="${step.target}"]`);
if (!el) return;
el.classList.add("tour-highlight");
el.scrollIntoView({ block: "center", behavior: "smooth" });
return () => el.classList.remove("tour-highlight");
}, [step]);
if (!step) return null;
function finish() {
try {
localStorage.setItem(TOUR_DONE_KEY, "1");
} catch {
/* localStorage nicht verfügbar -> Tour erscheint beim nächsten Mal erneut */
}
onClose();
}
return (
<div className="ui-pop fixed bottom-4 left-1/2 z-40 w-[26rem] max-w-[calc(100vw-2rem)] -translate-x-1/2 rounded-2xl border border-border bg-surface p-4 shadow-2xl">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2">
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-accent-soft text-accent-soft-fg">
<Lightbulb className="h-4 w-4" />
</span>
<div>
<div className="text-sm font-semibold text-fg">{step.title}</div>
<div className="text-[11px] text-faint">
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-2 text-sm leading-relaxed text-muted">{step.text}</p>
<div className="mt-3 flex items-center justify-between">
<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>
);
}
+3 -3
View File
@@ -20,7 +20,7 @@ export interface TimelineSeries {
}
// Datenpunkte je Serie: JEDES Planjahr (nicht nur die Phasengrenzen), verortet auf dem Alter
// der Referenzperson (Person A). Der erste Punkt ist das Startvermoegen vor Jahr 1.
// der Referenzperson (Person A). Der erste Punkt ist das Startvermögen vor Jahr 1.
function pointsFor(computed: PlanComputed) {
const phases = computed.phases;
if (phases.length === 0) return [] as { age: number; nominal: number; real: number }[];
@@ -33,8 +33,8 @@ function pointsFor(computed: PlanComputed) {
return pts;
}
// Liniendiagramm: Gesamtvermoegen (nominal + real) ueber das Alter. Unterstuetzt mehrere
// ueberlagerte Plaene fuer den Szenario-Vergleich.
// Liniendiagramm: Gesamtvermögen (nominal + real) über das Alter. Unterstützt mehrere
// überlagerte Pläne für den Szenario-Vergleich.
export function WealthChart({ series }: { series: TimelineSeries[] }) {
if (series.length === 0 || series[0].computed.phases.length === 0) {
return <p className="text-sm text-muted">Noch keine Phasen vorhanden.</p>;
+381
View File
@@ -0,0 +1,381 @@
"use client";
// UI-Primitiven des Tools. Ein einziger Ort für Buttons, Dialoge, Bestätigungen, Toasts
// und Ladezustände -- damit Verhalten (ESC, Fokus-Falle, Animation) und Optik überall
// identisch sind, statt in jeder Komponente von Hand nachgebaut zu werden.
//
// Ersetzt insbesondere window.confirm()/alert(): Browser-Systemdialoge folgen keinem der
// drei Farbschemata und wirken wie ein Fremdkörper.
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { AlertTriangle, CheckCircle2, X } from "lucide-react";
// --- ESC-Verwaltung ----------------------------------------------------------------------
// Mehrere Schichten können gleichzeitig offen sein (Inspector + Dialog). ESC soll nur die
// OBERSTE schliessen. Jede Schicht registriert sich beim Öffnen; ESC trifft die letzte.
const escStack: (() => void)[] = [];
let escListenerAttached = false;
function ensureEscListener() {
if (escListenerAttached || typeof window === "undefined") return;
escListenerAttached = true;
window.addEventListener("keydown", (e) => {
if (e.key !== "Escape" || escStack.length === 0) return;
e.stopPropagation();
escStack[escStack.length - 1]();
});
}
function useEscClose(onClose: () => void) {
const closeRef = useRef(onClose);
useEffect(() => {
closeRef.current = onClose;
}, [onClose]);
useEffect(() => {
ensureEscListener();
const handler = () => closeRef.current();
escStack.push(handler);
return () => {
const i = escStack.indexOf(handler);
if (i >= 0) escStack.splice(i, 1);
};
}, []);
}
// Einfache Fokus-Falle: beim Öffnen den ersten fokussierbaren Inhalt fokussieren,
// Tab bleibt innerhalb der Schicht, beim Schliessen kehrt der Fokus zurück.
function useFocusTrap(ref: React.RefObject<HTMLElement | null>) {
useEffect(() => {
const el = ref.current;
if (!el) return;
const previous = document.activeElement as HTMLElement | null;
const focusables = () =>
[...el.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)].filter((x) => !x.hasAttribute("disabled"));
(focusables()[0] ?? el).focus({ preventScroll: true });
function onKey(e: KeyboardEvent) {
if (e.key !== "Tab") return;
const f = focusables();
if (f.length === 0) return;
const first = f[0];
const last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
el.addEventListener("keydown", onKey);
return () => {
el.removeEventListener("keydown", onKey);
previous?.focus?.({ preventScroll: true });
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}
// --- Button ------------------------------------------------------------------------------
type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
const BUTTON_CLASSES: Record<ButtonVariant, string> = {
primary:
"bg-accent text-accent-fg shadow-sm hover:bg-accent-hover active:scale-[0.98]",
secondary:
"border border-border text-muted hover:bg-surface-2 hover:text-fg active:scale-[0.98]",
danger:
"border border-border text-muted hover:border-danger hover:bg-danger-soft hover:text-danger active:scale-[0.98]",
ghost: "text-muted hover:bg-surface-2 hover:text-fg",
};
export function Button({
variant = "primary",
size = "md",
className = "",
children,
...rest
}: React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: ButtonVariant;
size?: "sm" | "md";
}) {
const sizeClass = size === "sm" ? "px-2.5 py-1 text-xs" : "px-4 py-2 text-sm";
return (
<button
type="button"
{...rest}
className={`inline-flex items-center justify-center gap-1.5 rounded-lg font-medium transition-all duration-150 disabled:pointer-events-none disabled:opacity-50 ${BUTTON_CLASSES[variant]} ${sizeClass} ${className}`}
>
{children}
</button>
);
}
// --- Modal -------------------------------------------------------------------------------
export function Modal({
title,
subtitle,
onClose,
children,
wide,
}: {
title: string;
subtitle?: string;
onClose: () => void;
children: React.ReactNode;
wide?: boolean;
}) {
const panelRef = useRef<HTMLDivElement | null>(null);
useEscClose(onClose);
useFocusTrap(panelRef);
return (
<div
className="ui-fade fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8"
onClick={onClose}
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-label={title}
onClick={(e) => 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`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="text-base font-semibold text-fg">{title}</h2>
{subtitle && <p className="mt-0.5 text-xs text-muted">{subtitle}</p>}
</div>
<button
type="button"
onClick={onClose}
aria-label="Schliessen"
className="rounded-md p-1 text-faint transition-colors hover:bg-surface-2 hover:text-fg"
>
<X className="h-4 w-4" />
</button>
</div>
{children}
</div>
</div>
);
}
// --- Inspector ---------------------------------------------------------------------------
// Rechtes Seitenpanel für Einzel-Bearbeitungen (Zellen, Übergänge, Phase, Profil).
// Bewusst OHNE Backdrop: Die Matrix bleibt sichtbar und klickbar -- ein Klick auf eine
// andere Zelle wechselt einfach den Panel-Inhalt. Das hält den Kontext, den Modals nehmen.
// Auf schmalen Screens deckt das Panel die volle Breite ab.
export function InspectorShell({
title,
subtitle,
onClose,
children,
}: {
title: string;
subtitle?: string;
onClose: () => void;
children: React.ReactNode;
}) {
const panelRef = useRef<HTMLDivElement | null>(null);
useEscClose(onClose);
return (
<aside
ref={panelRef}
role="complementary"
aria-label={title}
className="ui-slide-in fixed inset-y-0 right-0 z-30 flex w-full flex-col border-l border-border bg-surface shadow-2xl sm:w-[26rem]"
>
<div className="flex items-start justify-between gap-3 border-b border-border px-5 py-4">
<div className="min-w-0">
<h2 className="truncate text-base font-semibold text-fg">{title}</h2>
{subtitle && <p className="mt-0.5 text-xs text-muted">{subtitle}</p>}
</div>
<button
type="button"
onClick={onClose}
aria-label="Panel schliessen"
className="rounded-md p-1 text-faint transition-colors hover:bg-surface-2 hover:text-fg"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="flex-1 overflow-y-auto px-5 py-4">{children}</div>
</aside>
);
}
// --- Bestätigungs-Dialog (ersetzt window.confirm) ---------------------------------------
interface ConfirmOptions {
title: string;
message: string;
confirmLabel?: string;
danger?: boolean;
}
const ConfirmContext = createContext<((opts: ConfirmOptions) => Promise<boolean>) | null>(null);
export function useConfirm() {
const fn = useContext(ConfirmContext);
if (!fn) throw new Error("useConfirm ausserhalb des ConfirmProviders");
return fn;
}
export function ConfirmProvider({ children }: { children: React.ReactNode }) {
const [pending, setPending] = useState<{
opts: ConfirmOptions;
resolve: (ok: boolean) => void;
} | null>(null);
const confirm = useCallback(
(opts: ConfirmOptions) =>
new Promise<boolean>((resolve) => setPending({ opts, resolve })),
[]
);
function settle(ok: boolean) {
pending?.resolve(ok);
setPending(null);
}
return (
<ConfirmContext.Provider value={confirm}>
{children}
{pending && (
<Modal title={pending.opts.title} onClose={() => settle(false)}>
<p className="text-sm text-muted">{pending.opts.message}</p>
<div className="flex gap-2 pt-1">
<Button
variant={pending.opts.danger ? "danger" : "primary"}
autoFocus
onClick={() => settle(true)}
className={pending.opts.danger ? "border-danger bg-danger-soft text-danger" : ""}
>
{pending.opts.confirmLabel ?? "Bestätigen"}
</Button>
<Button variant="secondary" onClick={() => settle(false)}>
Abbrechen
</Button>
</div>
</Modal>
)}
</ConfirmContext.Provider>
);
}
// --- Toasts (ersetzt alert und stumme Erfolge) -------------------------------------------
interface ToastItem {
id: number;
kind: "success" | "error";
text: string;
}
const ToastContext = createContext<((kind: ToastItem["kind"], text: string) => void) | null>(null);
export function useToast() {
const fn = useContext(ToastContext);
if (!fn) throw new Error("useToast ausserhalb des ToastProviders");
return fn;
}
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const nextId = useRef(1);
const push = useCallback((kind: ToastItem["kind"], text: string) => {
const id = nextId.current++;
setToasts((prev) => [...prev, { id, kind, text }]);
// Fehler bleiben länger stehen als Erfolge.
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), kind === "error" ? 6000 : 3200);
}, []);
return (
<ToastContext.Provider value={push}>
{children}
<div className="pointer-events-none fixed bottom-4 right-4 z-50 flex w-80 flex-col gap-2">
{toasts.map((t) => (
<div
key={t.id}
className={`ui-pop pointer-events-auto flex items-start gap-2 rounded-xl border px-3.5 py-2.5 text-sm shadow-lg ${
t.kind === "error"
? "border-danger bg-danger-soft text-danger"
: "border-border bg-surface text-fg"
}`}
>
{t.kind === "error" ? (
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
) : (
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-success" />
)}
<span className="min-w-0 flex-1">{t.text}</span>
<button
type="button"
aria-label="Meldung schliessen"
onClick={() => setToasts((prev) => prev.filter((x) => x.id !== t.id))}
className="rounded p-0.5 text-faint hover:text-fg"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
</ToastContext.Provider>
);
}
// --- Ladezustände und leere Zustände ---------------------------------------------------
export function Skeleton({ className = "" }: { className?: string }) {
return <div className={`animate-pulse rounded-lg bg-surface-2 ${className}`} />;
}
// Platzhalter während die Planansicht lädt -- deutet die Struktur an, statt nur
// "Lädt…" zu schreiben (wahrgenommene Geschwindigkeit).
export function PlanSkeleton() {
return (
<div className="flex flex-col gap-5" aria-busy="true" aria-label="Lädt">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-72 w-full" />
</div>
);
}
export function EmptyState({
icon,
title,
text,
children,
}: {
icon: React.ReactNode;
title: string;
text: string;
children?: React.ReactNode;
}) {
return (
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed border-border bg-surface p-10 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-accent-soft text-accent-soft-fg">
{icon}
</div>
<div>
<h3 className="text-sm font-semibold text-fg">{title}</h3>
<p className="mx-auto mt-1 max-w-md text-sm text-muted">{text}</p>
</div>
{children && <div className="mt-1 flex flex-wrap justify-center gap-2">{children}</div>}
</div>
);
}
+1 -1
View File
@@ -19,7 +19,7 @@ export async function createSessionToken(userId: string): Promise<string> {
.sign(getSecretKey());
}
// Liefert die User-ID aus einem gueltigen Session-Token, sonst null.
// Liefert die User-ID aus einem gültigen Session-Token, sonst null.
export async function verifySessionToken(token: string): Promise<string | null> {
try {
const { payload } = await jwtVerify(token, getSecretKey());
+21 -21
View File
@@ -3,10 +3,10 @@ import { computePlan } from "@/lib/calculations";
import type { CashTransitionData, ElementCategory, PhaseData, TransitionData } from "@/lib/elements";
import type { PlanInput } from "@/lib/types";
// Die zentrale Invariante beider Wasserfall-Bruecken: `residual` ist die Differenz zwischen
// dem tatsaechlichen Endwert und der Summe der gezeichneten Schritte. Ist die Zerlegung
// vollstaendig und richtig, MUSS sie 0 sein -- ein Wasserfall, der nicht aufgeht, waere ein
// Fehler in der Berechnung und nicht bloss ein Darstellungsproblem. Deshalb ueber moeglichst
// Die zentrale Invariante beider Wasserfall-Brücken: `residual` ist die Differenz zwischen
// dem tatsächlichen Endwert und der Summe der gezeichneten Schritte. Ist die Zerlegung
// vollständig und richtig, MUSS sie 0 sein -- ein Wasserfall, der nicht aufgeht, wäre ein
// Fehler in der Berechnung und nicht bloss ein Darstellungsproblem. Deshalb über möglichst
// verschiedene Konstellationen festgenagelt statt an einem einzigen Beispiel.
let idc = 0;
@@ -100,7 +100,7 @@ const konstellationen: { name: string; build: () => PlanInput }[] = [
}),
},
{
name: "PK-Kapitalbezug statt Rente (Steuer am Uebergang)",
name: "PK-Kapitalbezug statt Rente (Steuer am Übergang)",
build: () =>
plan({
age: 62,
@@ -142,7 +142,7 @@ const konstellationen: { name: string; build: () => PlanInput }[] = [
}),
},
{
name: "Immobilie verkauft (Verkaufsdifferenz und Grundstueckgewinnsteuer)",
name: "Immobilie verkauft (Verkaufsdifferenz und Grundstückgewinnsteuer)",
build: () =>
plan({
age: 45,
@@ -164,7 +164,7 @@ const konstellationen: { name: string; build: () => PlanInput }[] = [
}),
},
{
name: "Einmalige Sonderein-/ausgaben am Uebergang",
name: "Einmalige Sonderein-/ausgaben am Übergang",
build: () =>
plan({
age: 50,
@@ -189,7 +189,7 @@ const konstellationen: { name: string; build: () => PlanInput }[] = [
}),
},
{
name: "Sofort-Tilgung und Sonderamortisation am Uebergang",
name: "Sofort-Tilgung und Sonderamortisation am Übergang",
build: () =>
plan({
age: 50,
@@ -222,15 +222,15 @@ const konstellationen: { name: string; build: () => PlanInput }[] = [
},
];
describe("Wasserfall-Bruecken", () => {
describe("Wasserfall-Brücken", () => {
for (const k of konstellationen) {
it(`${k.name}: beide Bruecken gehen ohne Restgroesse auf`, () => {
it(`${k.name}: beide Brücken gehen ohne Restgrösse auf`, () => {
const r = computePlan(k.build());
expect(r.phases.length).toBeGreaterThan(0);
for (const ph of r.phases) {
expect(ph.wealthBridge.residual, `Vermoegensbruecke ${ph.name}`).toBe(0);
expect(ph.cashBridge.residual, `Cash-Bruecke ${ph.name}`).toBe(0);
// Die Kontrollpunkte muessen den offiziellen Kennzahlen entsprechen.
expect(ph.wealthBridge.residual, `Vermögensbrücke ${ph.name}`).toBe(0);
expect(ph.cashBridge.residual, `Cash-Brücke ${ph.name}`).toBe(0);
// Die Kontrollpunkte müssen den offiziellen Kennzahlen entsprechen.
expect(ph.wealthBridge.startWealth).toBe(ph.startWealthNominal);
expect(ph.wealthBridge.endWealth).toBe(ph.endWealthNominal);
expect(ph.cashBridge.cashStart).toBe(ph.cashStart);
@@ -239,10 +239,10 @@ describe("Wasserfall-Bruecken", () => {
});
}
it("Umbuchungen erscheinen NICHT in der Vermoegensbruecke", () => {
// Sparraten und Amortisationen verschieben Geld vom Cash in einen Vermoegenswert, ohne
// das Vermoegen zu aendern. Sie duerfen deshalb nur in der Cash-Bruecke auftauchen; die
// Vermoegensaenderung erklaert sich allein aus Quote, Ertraegen und PK-Beitraegen.
it("Umbuchungen erscheinen NICHT in der Vermögensbrücke", () => {
// Sparraten und Amortisationen verschieben Geld vom Cash in einen Vermögenswert, ohne
// das Vermögen zu ändern. Sie dürfen deshalb nur in der Cash-Brücke auftauchen; die
// Vermögensänderung erklärt sich allein aus Quote, Erträgen und PK-Beiträgen.
const r = computePlan(konstellationen[0].build());
const ph = r.phases[0];
expect(ph.cashBridge.savingRates).toBeGreaterThan(0);
@@ -253,9 +253,9 @@ describe("Wasserfall-Bruecken", () => {
);
});
it("PK-Verrentung erscheint als Vermoegensabgang am Uebergang", () => {
// Das verrentete Kapital verlaesst die Bilanz und wird zum Rentenstrom -- in der
// Vermoegensbruecke ein echter Abgang, kein Umbuchungsposten.
it("PK-Verrentung erscheint als Vermögensabgang am Übergang", () => {
// Das verrentete Kapital verlässt die Bilanz und wird zum Rentenstrom -- in der
// Vermögensbrücke ein echter Abgang, kein Umbuchungsposten.
const r = computePlan(konstellationen[1].build());
const pension = r.phases[1];
expect(pension.wealthBridge.pensionConversion).toBeGreaterThan(0);
@@ -268,7 +268,7 @@ describe("Wasserfall-Bruecken", () => {
expect(gehalten.saleGainLoss).toBe(0);
expect(gehalten.transitionTax).toBe(0);
expect(verkauft.transitionTax).toBeGreaterThan(0);
// Verkaufspreis ueber dem modellierten Verkehrswert -> positive Differenz.
// Verkaufspreis über dem modellierten Verkehrswert -> positive Differenz.
expect(verkauft.saleGainLoss).not.toBe(0);
});
});
+37 -37
View File
@@ -45,8 +45,8 @@ function plan(opts: {
const within = (actual: number, expected: number, pct: number) => Math.abs(actual - expected) <= Math.abs(expected) * pct;
// Stuetzstellen aus der AMTLICHEN Tabelle 318.117.1 "Monatliche Vollrenten, Skala 44"
// (BSV, gueltig ab 1.1.2025/2026): mdJE -> monatliche Vollrente. Deckt Mindestrente,
// Stützstellen aus der AMTLICHEN Tabelle 318.117.1 "Monatliche Vollrenten, Skala 44"
// (BSV, gültig ab 1.1.2025/2026): mdJE -> monatliche Vollrente. Deckt Mindestrente,
// Formel 1, den Wendepunkt (45'360), Formel 2 und die Maximalrente ab.
const AHV_AMTLICHE_TABELLE: [number, number][] = [
[15120, 1260], // Mindestrente (12 x R0)
@@ -72,7 +72,7 @@ describe("AHV-Rentenformel (Skala 44)", () => {
it("kappt ausserhalb der Schwellen", () => {
expect(ahvMonthlyFullPension(0)).toBe(1260); // unter 12 x R0 -> Mindestrente
expect(ahvMonthlyFullPension(10000)).toBe(1260);
expect(ahvMonthlyFullPension(150000)).toBe(2520); // ueber 72 x R0 -> Maximalrente
expect(ahvMonthlyFullPension(150000)).toBe(2520); // über 72 x R0 -> Maximalrente
});
it("ist am Wendepunkt stetig (kein Sprung zwischen Formel 1 und 2)", () => {
@@ -82,12 +82,12 @@ describe("AHV-Rentenformel (Skala 44)", () => {
});
});
// V5-Modell: Einkommen = nominale Basis + nominale Lohnerhoehung; Ausgaben = REALE Basis +
// V5-Modell: Einkommen = nominale Basis + nominale Lohnerhöhung; Ausgaben = REALE Basis +
// reale Mehrausgaben, nominal = real x (plan-weite Inflation).
// Netto -> Brutto fuer die AHV (AHV_GROSS_FROM_NET_FACTOR).
// Netto -> Brutto für die AHV (AHV_GROSS_FROM_NET_FACTOR).
const BRUTTO = 1.12;
describe("Teilverkauf Sonstiges Vermoegen (Roadmap 42)", () => {
describe("Teilverkauf Sonstiges Vermögen (Roadmap 42)", () => {
function p42(decision: "HOLD" | "SELL" | "PARTIAL", amount?: number) {
return plan({
age: 40, retirementAge: 70, inflation: 0, initialCash: 0,
@@ -111,10 +111,10 @@ describe("Teilverkauf Sonstiges Vermoegen (Roadmap 42)", () => {
const r = computePlan(p42("PARTIAL", 30000));
expect(r.phases[1].capitalInflow).toBe(30000); // "zu investierendes Kapital" im Phasenkopf
expect(r.phases[1].cashStart).toBe(30000);
expect(asset(p42("PARTIAL", 30000), 1).startValue).toBe(70000); // Rest laeuft weiter
expect(asset(p42("PARTIAL", 30000), 1).startValue).toBe(70000); // Rest läuft weiter
});
it("Teilverkauf am Endwert gekappt; Vollverkauf und Halten unveraendert", () => {
it("Teilverkauf am Endwert gekappt; Vollverkauf und Halten unverändert", () => {
expect(computePlan(p42("PARTIAL", 999999)).phases[1].capitalInflow).toBe(100000);
expect(computePlan(p42("SELL")).phases[1].capitalInflow).toBe(100000);
expect(computePlan(p42("HOLD")).phases[1].capitalInflow).toBe(0);
@@ -203,7 +203,7 @@ describe("Immobilie: Zins und Wertsteigerung", () => {
expect(computePlan(mit).phases[0].cashEnd).toBe(500000 - 90000);
});
it("Zinsbetrag sinkt mit der Amortisation und zaehlt in die Quote", () => {
it("Zinsbetrag sinkt mit der Amortisation und zählt in die Quote", () => {
const p = immoPlan({
purchasePrice: 1000000, mortgage: 1000000, amortization: 10000, interestRate: 1, interestHandling: "ADD",
});
@@ -211,10 +211,10 @@ describe("Immobilie: Zins und Wertsteigerung", () => {
// Jahr 1: 1% von 1'000'000 = 10'000. Jahr 10: 1% von (1'000'000 - 9 x 10'000) = 9'100.
expect(ph.expenseStart).toBe(10000);
expect(ph.expenseEnd).toBe(9100);
expect(ph.quotaStart).toBe(-10000); // Zins schlaegt auf die Quote durch
expect(ph.quotaStart).toBe(-10000); // Zins schlägt auf die Quote durch
});
it("Verkauf: Grundstueckgewinnsteuer bemisst sich am urspruenglichen Kaufpreis", () => {
it("Verkauf: Grundstückgewinnsteuer bemisst sich am ursprünglichen Kaufpreis", () => {
const p = plan({
age: 40, retirementAge: 70, inflation: 0, initialCash: 0,
phases: [
@@ -231,13 +231,13 @@ describe("Immobilie: Zins und Wertsteigerung", () => {
});
const r = computePlan(p);
// Gewinn = 1'200'000 - 1'000'000 (Kaufpreis!) = 200'000 -> Steuer 40'000.
// Erloes = 1'200'000 - 800'000 Resthypothek - 40'000 = 360'000.
// Erlös = 1'200'000 - 800'000 Resthypothek - 40'000 = 360'000.
expect(r.phases[1].capitalInflow).toBe(360000);
});
});
describe("AHV einkommensabhaengig", () => {
// 60-jaehrig, Pension mit 65: 39 Beitragsjahre vor Planbeginn (ab 21), 5 im Plan.
describe("AHV einkommensabhängig", () => {
// 60-jährig, Pension mit 65: 39 Beitragsjahre vor Planbeginn (ab 21), 5 im Plan.
function ahvPlan(opts: {
age?: number;
income: number;
@@ -250,7 +250,7 @@ describe("AHV einkommensabhaengig", () => {
return plan({
age,
retirementAge: 65,
inflation: 0, // real = nominal, damit die Erwartungswerte von Hand pruefbar bleiben
inflation: 0, // real = nominal, damit die Erwartungswerte von Hand prüfbar bleiben
phases: [
{ id: "p1", durationYears: 65 - age },
{ id: "p2", durationYears: 10 },
@@ -282,7 +282,7 @@ describe("AHV einkommensabhaengig", () => {
});
it("mdJE unter der Schwelle -> abgestufte Rente (Formel 2)", () => {
// Plan-Einkommen ist NETTO -> fuer die AHV auf brutto hochgerechnet; das Feld
// Plan-Einkommen ist NETTO -> für die AHV auf brutto hochgerechnet; das Feld
// "vor Planbeginn" ist bereits brutto. 39 Jahre davor, 5 im Plan.
const p = ahvPlan({ income: 60000, avgIncomeBefore: 60000 });
const mdJE = (60000 * 39 + 60000 * BRUTTO * 5) / 44;
@@ -290,8 +290,8 @@ describe("AHV einkommensabhaengig", () => {
expect(renteIn(p, 1)).toBeLessThan(32760);
});
it("Nettolohn wird fuer die AHV auf den Bruttolohn hochgerechnet", () => {
// Ohne Vorgeschichte (Alter 21 bei Planbeginn) haengt das mdJE nur am Plan-Einkommen.
it("Nettolohn wird für die AHV auf den Bruttolohn hochgerechnet", () => {
// Ohne Vorgeschichte (Alter 21 bei Planbeginn) hängt das mdJE nur am Plan-Einkommen.
const p = plan({
age: 21,
retirementAge: 65,
@@ -323,7 +323,7 @@ describe("AHV einkommensabhaengig", () => {
expect(renteIn(p, 1)).toBe(Math.round(ahvMonthlyFullPension(mdJE) * 13));
});
it("Ausfalljahre kuerzen die Rente ueber die Skala 44", () => {
it("Ausfalljahre kürzen die Rente über die Skala 44", () => {
const ohne = ahvPlan({ income: 100000, avgIncomeBefore: 100000 });
const mit = ahvPlan({ income: 100000, avgIncomeBefore: 100000, gapYearsBefore: 4 });
expect(renteIn(mit, 1)).toBe(Math.round(32760 * (40 / 44))); // 4 Ausfalljahre = 4/44 weniger
@@ -336,7 +336,7 @@ describe("AHV einkommensabhaengig", () => {
expect(renteIn(p, 1)).toBe(Math.round(32760 * (42 / 44)));
});
it("ohne Pruefung gilt der geplante Durchschnitt auch fuer die Jahre vor Planbeginn", () => {
it("ohne Prüfung gilt der geplante Durchschnitt auch für die Jahre vor Planbeginn", () => {
// Kein avgIncomeBefore erfasst -> darf NICHT als 0 gerechnet werden (sonst mdJE ~45%).
const p = plan({
age: 60,
@@ -348,11 +348,11 @@ describe("AHV einkommensabhaengig", () => {
],
elements: [
el("INCOME", "PERSON_A", { p1: { amount: 80000, teuerungsausgleich: 0 }, p2: {} }),
el("AHV", "PERSON_A", { p1: {}, p2: {} }), // keine Uebergangsdaten
el("AHV", "PERSON_A", { p1: {}, p2: {} }), // keine Übergangsdaten
],
});
const rente = computePlan(p).phases[1].elements.find((e) => e.category === "AHV")!.startValue;
// mdJE = geplantes Brutto (80'000 x 1.12), nicht ~9'091 (das waere der Fallback auf 0).
// mdJE = geplantes Brutto (80'000 x 1.12), nicht ~9'091 (das wäre der Fallback auf 0).
expect(rente).toBe(Math.round(ahvMonthlyFullPension(80000 * BRUTTO) * 13));
});
@@ -463,7 +463,7 @@ describe("V5 Golden Tests", () => {
expect(ph.expenseEnd).toBe(Math.round(100000 * Math.pow(1.02, 4))); // 108'243
});
it("Einkommen nominal flach bei Lohnerhoehung 0%", () => {
it("Einkommen nominal flach bei Lohnerhöhung 0%", () => {
const p = plan({
age: 40,
retirementAge: 60,
@@ -475,7 +475,7 @@ describe("V5 Golden Tests", () => {
expect(ph.incomeEnd).toBe(80000);
});
it("Bezugsrate aus Sonstigem Vermoegen fliesst ins Cash (Verzehrrate)", () => {
it("Bezugsrate aus Sonstigem Vermögen fliesst ins Cash (Verzehrrate)", () => {
const p = plan({
age: 65,
retirementAge: 65,
@@ -506,11 +506,11 @@ describe("V5 Golden Tests", () => {
});
const r = computePlan(p);
const p2 = r.phases[1];
expect(p2.capitalInflow).toBe(10000); // Verkaufserloes aus dem Uebergang
expect(p2.capitalInflow).toBe(10000); // Verkaufserlös aus dem Übergang
expect(p2.capitalInvest).toBe(10000); // Zusatzinvestition
expect(p2.cashStart).toBe(0); // Startwert bereits nach Abzug der Investition
expect(p2.cashEnd).toBe(0);
// Startvermoegen Phase 2: Cash(0) + Vermoegen B(10'000) = 10'000 (kein Doppelzaehlen).
// Startvermögen Phase 2: Cash(0) + Vermögen B(10'000) = 10'000 (kein Doppelzählen).
expect(p2.startWealthNominal).toBe(10000);
});
@@ -597,7 +597,7 @@ describe("V5 Golden Tests", () => {
expect(pk.startValue).toBe(200000); // brutto 100'000 dem Kapital entnommen
});
it("Einmaliger Zufluss am Uebergang: nominal erfasst, Steuer abgezogen, direkt ins Cash", () => {
it("Einmaliger Zufluss am Übergang: nominal erfasst, Steuer abgezogen, direkt ins Cash", () => {
const p = plan({
age: 40,
retirementAge: 70,
@@ -610,13 +610,13 @@ describe("V5 Golden Tests", () => {
elements: [],
});
const r = computePlan(p);
expect(r.phases[1].oneOffInflow).toBe(90000); // 100'000 abzueglich 10% Steuer
expect(r.phases[1].oneOffInflow).toBe(90000); // 100'000 abzüglich 10% Steuer
expect(r.phases[1].oneOffInflowLabel).toBe("Erbschaft");
expect(r.phases[1].cashStart).toBe(91000); // 1'000 + 90'000
expect(r.phases[0].oneOffInflow).toBe(0); // Phase 1 hat keinen eingehenden Uebergang
expect(r.phases[0].oneOffInflow).toBe(0); // Phase 1 hat keinen eingehenden Übergang
});
it("Einmalige Kosten am Uebergang: real erfasst, mit Inflation aufgewertet", () => {
it("Einmalige Kosten am Übergang: real erfasst, mit Inflation aufgewertet", () => {
// Kosten 20'000 real, 2% Inflation, Grenze nach 10 Jahren -> 20'000 x 1.02^10 = 24'380.
const p = plan({
age: 40,
@@ -648,7 +648,7 @@ describe("V5 Golden Tests", () => {
const r1 = computePlan(beide);
expect(r1.phases[1].cashStart).toBe(30000); // +50'000 -20'000
// Betraege sind erfasst, aber der Entscheid lautet "1:1 uebernehmen" -> keine Wirkung.
// Beträge sind erfasst, aber der Entscheid lautet "1:1 übernehmen" -> keine Wirkung.
const keine = plan({
age: 40, retirementAge: 70, inflation: 0, initialCash: 0,
phases: [
@@ -660,7 +660,7 @@ describe("V5 Golden Tests", () => {
expect(computePlan(keine).phases[1].cashStart).toBe(0);
});
it("Einmalige Kosten koennen eine Liquiditaetsluecke ausloesen", () => {
it("Einmalige Kosten können eine Liquiditätslücke auslösen", () => {
const p = plan({
age: 40, retirementAge: 70, inflation: 0, initialCash: 10000,
phases: [
@@ -675,7 +675,7 @@ describe("V5 Golden Tests", () => {
expect(p2.incomplete).toBe(true);
});
it("Cash-Entscheid der LETZTEN Phase bleibt wirkungslos (kein Uebergang mehr)", () => {
it("Cash-Entscheid der LETZTEN Phase bleibt wirkungslos (kein Übergang mehr)", () => {
const p = plan({
age: 40, retirementAge: 70, inflation: 0, initialCash: 5000,
phases: [{ id: "p1", durationYears: 1, cashTransition: { mode: "INFLOW", inflowAmount: 999999 } }],
@@ -686,7 +686,7 @@ describe("V5 Golden Tests", () => {
expect(r.nachlass).toBe(5000);
});
it("Vermoegen wird pro JAHR mitgefuehrt (Grundlage des Verlaufs ueber alle Jahre)", () => {
it("Vermögen wird pro JAHR mitgeführt (Grundlage des Verlaufs über alle Jahre)", () => {
const p = plan({
age: 40, retirementAge: 70, inflation: 0, initialCash: 0,
phases: [{ id: "p1", durationYears: 3 }],
@@ -697,12 +697,12 @@ describe("V5 Golden Tests", () => {
expect(r.yearly[0].wealthNominal).toBe(110000);
expect(r.yearly[1].wealthNominal).toBe(121000);
expect(r.yearly[2].wealthNominal).toBe(133100);
// Ohne Inflation sind real und nominal identisch; Endjahr = Phasen-Endvermoegen.
// Ohne Inflation sind real und nominal identisch; Endjahr = Phasen-Endvermögen.
expect(r.yearly[2].wealthReal).toBe(133100);
expect(r.yearly[2].wealthNominal).toBe(r.phases[0].endWealthNominal);
});
it("Vermoegen real ist bei Inflation kaufkraftbereinigt", () => {
it("Vermögen real ist bei Inflation kaufkraftbereinigt", () => {
const p = plan({
age: 40, retirementAge: 70, inflation: 10, initialCash: 100000,
phases: [{ id: "p1", durationYears: 1 }],
@@ -713,7 +713,7 @@ describe("V5 Golden Tests", () => {
expect(y.wealthReal).toBe(Math.round(100000 / 1.1)); // 90'909
});
it("Fortschreibung: nominaler Einkommens-Basiswert Phase 1 -> Startwert Phase 2; Cash laeuft fort", () => {
it("Fortschreibung: nominaler Einkommens-Basiswert Phase 1 -> Startwert Phase 2; Cash läuft fort", () => {
const p = plan({
age: 40,
retirementAge: 70,
+120 -120
View File
@@ -27,13 +27,13 @@ export interface PersonPhaseInfo {
}
// --- Rechenweg-Protokoll (Roadmap Nr. 41) ------------------------------------------------
// Ein Schritt haelt die Formel UND die eingesetzten Zahlen fest. Entscheidend: Die Schritte
// entstehen INNERHALB der Berechnung, als Nebenprodukt der Rechnung, die ohnehin laeuft.
// Damit kann die angezeigte Herleitung nicht von der tatsaechlichen Rechnung abdriften --
// eine zweite Formel-Implementierung im UI waere die gefaehrlichste Variante ueberhaupt.
// Ein Schritt hält die Formel UND die eingesetzten Zahlen fest. Entscheidend: Die Schritte
// entstehen INNERHALB der Berechnung, als Nebenprodukt der Rechnung, die ohnehin läuft.
// Damit kann die angezeigte Herleitung nicht von der tatsächlichen Rechnung abdriften --
// eine zweite Formel-Implementierung im UI wäre die gefährlichste Variante überhaupt.
export interface TraceStep {
label: string;
formula?: string; // abstrakt, z. B. "Basis x (1 + Lohnerhoehung)^(t-1)"
formula?: string; // abstrakt, z. B. "Basis x (1 + Lohnerhöhung)^(t-1)"
substituted?: string; // mit eingesetzten Zahlen, z. B. "100'000 x (1 + 2%)^4"
result: number;
unit?: "CHF" | "%" | "Jahre" | "";
@@ -47,15 +47,15 @@ export interface Trace {
}
export interface ComputeOptions {
// Standardmaessig aus: die Monte-Carlo-Simulation ruft computePlan zehntausendfach auf
// Standardmässig aus: die Monte-Carlo-Simulation ruft computePlan zehntausendfach auf
// und darf von der Protokollierung nichts merken.
explain?: boolean;
}
// Ein Datenpunkt pro Jahr JE ELEMENT -- Grundlage der Detailansicht (Roadmap Nr. 43).
// Nur die Phasengrenzen zu kennen reicht nicht: Eine Anlage mit 5 % ueber 20 Jahre waechst
// konvex, eine Gerade von Start zu Ende waere sichtbar falsch (gleiche Ueberlegung wie beim
// Vermoegensverlauf in v0.9).
// Nur die Phasengrenzen zu kennen reicht nicht: Eine Anlage mit 5 % über 20 Jahre wächst
// konvex, eine Gerade von Start zu Ende wäre sichtbar falsch (gleiche Überlegung wie beim
// Vermögensverlauf in v0.9).
export interface ElementYearPoint {
year: number; // 1-basiert ab Planbeginn
age: number; // Alter Person A am Jahresende
@@ -81,47 +81,47 @@ export interface ElementPhaseComputed {
note: string | null;
yearly: ElementYearPoint[]; // Verlauf innerhalb dieser Phase
trace?: Trace; // Rechenweg der Phasenwerte (nur mit explain)
transitionTrace?: Trace; // Rechenweg des Uebergangs NACH dieser Phase (nur mit explain)
transitionTrace?: Trace; // Rechenweg des Übergangs NACH dieser Phase (nur mit explain)
}
// --- Wasserfall-Zerlegungen (Roadmap Nr. 43) ---------------------------------------------
//
// WICHTIG -- der Unterschied zwischen den beiden: Sparraten, Amortisationen und
// Zusatzinvestitionen sind UMBUCHUNGEN. Sie verlassen das Cash und erhoehen im selben Zug
// einen Vermoegenswert bzw. senken eine Schuld; das Vermoegen bleibt unveraendert. Sie
// gehoeren deshalb in die Cash-Bruecke und ausdruecklich NICHT in die Vermoegens-Bruecke --
// dort als Abgang gezeichnet wuerden sie eine Vermoegensminderung suggerieren, die es nicht
// Zusatzinvestitionen sind UMBUCHUNGEN. Sie verlassen das Cash und erhöhen im selben Zug
// einen Vermögenswert bzw. senken eine Schuld; das Vermögen bleibt unverändert. Sie
// gehören deshalb in die Cash-Brücke und ausdrücklich NICHT in die Vermögens-Brücke --
// dort als Abgang gezeichnet würden sie eine Vermögensminderung suggerieren, die es nicht
// gibt.
export interface WealthBridge {
openingWealth: number; // Endvermoegen der Vorphase (Phase 1: Startvermoegen)
// Uebergang IN diese Phase -- echte Vermoegensaenderungen an der Phasengrenze:
openingWealth: number; // Endvermögen der Vorphase (Phase 1: Startvermögen)
// Übergang IN diese Phase -- echte Vermögensänderungen an der Phasengrenze:
oneOffInflow: number; // einmaliger Zufluss (netto nach Steuer)
oneOffOutflow: number; // einmalige Kosten
transitionTax: number; // Kapitalbezugs- und Grundstueckgewinnsteuer (Abgang)
pensionConversion: number; // in eine Rente umgewandeltes PK-Kapital (verlaesst die Bilanz)
transitionTax: number; // Kapitalbezugs- und Grundstückgewinnsteuer (Abgang)
pensionConversion: number; // in eine Rente umgewandeltes PK-Kapital (verlässt die Bilanz)
saleGainLoss: number; // Verkaufspreis minus Verkehrswert bei einem Immobilienverkauf
startWealth: number; // Kontrollpunkt = startWealthNominal
// Innerhalb der Phase:
quotaTotal: number; // Summe (Einkommen - nominale Ausgaben) ueber alle Phasenjahre
investmentReturn: number; // Rendite auf PK/3a/Sonstigem Vermoegen
quotaTotal: number; // Summe (Einkommen - nominale Ausgaben) über alle Phasenjahre
investmentReturn: number; // Rendite auf PK/3a/Sonstigem Vermögen
propertyAppreciation: number; // Wertsteigerung der Liegenschaft
pensionFundContribution: number; // PK-Beitraege: erhoehen das Vermoegen, ohne Cash zu kosten
pensionFundContribution: number; // PK-Beiträge: erhöhen das Vermögen, ohne Cash zu kosten
endWealth: number; // = endWealthNominal
residual: number; // Rundungsdifferenz (Kontrollgroesse, sollte nahe 0 sein)
residual: number; // Rundungsdifferenz (Kontrollgrösse, sollte nahe 0 sein)
}
export interface CashBridge {
openingCash: number; // Cash-Endbestand der Vorphase (Phase 1: initialCash)
capitalInflow: number; // Verkaeufe + PK-/3a-Bezuege aus dem Uebergang
capitalInflow: number; // Verkäufe + PK-/3a-Bezüge aus dem Übergang
oneOffInflow: number;
immediateRepay: number; // Sofort-Tilgungen und Sonderamortisation (Abgang)
oneOffOutflow: number;
investments: number; // Zusatz-/Neuinvestitionen am Phasenanfang (Abgang)
cashStart: number; // Kontrollpunkt
quotaTotal: number;
savingRates: number; // 3a + Sparbeitraege (Abgang)
savingRates: number; // 3a + Sparbeiträge (Abgang)
debtRates: number; // Amortisationen + Tilgungen (Abgang)
withdrawals: number; // Bezugsraten aus Sonstigem Vermoegen (Zugang)
withdrawals: number; // Bezugsraten aus Sonstigem Vermögen (Zugang)
cashEnd: number;
residual: number;
}
@@ -143,15 +143,15 @@ export interface PhaseComputed {
quotaStart: number;
quotaEnd: number;
isConsumption: boolean;
// Geplante Sparrate im ERSTEN Phasenjahr: 3a + Sonstiges-Vermoegen-Sparbeitrag + Amort. +
// Geplante Sparrate im ERSTEN Phasenjahr: 3a + Sonstiges-Vermögen-Sparbeitrag + Amort. +
// Tilgung. Amortisation/Tilgung entfallen, sobald Hypothek/Schuld abbezahlt sind -- die Rate
// kann in spaeteren Phasenjahren also tiefer liegen.
// kann in späteren Phasenjahren also tiefer liegen.
plannedSaveRate: number;
plannedWithdrawRate: number; // geplante Verzehrrate: Bezugsraten aus Sonstigem Vermoegen
capitalInflow: number; // Kapitalzufluss: PK-/3a-Bezuege + Verkaeufe (aus dem Uebergang in diese Phase)
plannedWithdrawRate: number; // geplante Verzehrrate: Bezugsraten aus Sonstigem Vermögen
capitalInflow: number; // Kapitalzufluss: PK-/3a-Bezüge + Verkäufe (aus dem Übergang in diese Phase)
capitalInvest: number; // Kapitalinvestitionen: Zusatz-/Neuinvestitionen + sofortige Tilgungen
// Einmalige Sonderein-/ausgaben aus dem Uebergang in DIESE Phase (nominal, netto nach Steuer).
// Bewusst getrennt von capitalInflow/capitalInvest: eine Erbschaft ist kein Verkaufserloes,
// Einmalige Sonderein-/ausgaben aus dem Übergang in DIESE Phase (nominal, netto nach Steuer).
// Bewusst getrennt von capitalInflow/capitalInvest: eine Erbschaft ist kein Verkaufserlös,
// ein Poolbau keine Kapitalinvestition.
oneOffInflow: number;
oneOffInflowLabel: string | null;
@@ -159,29 +159,29 @@ export interface PhaseComputed {
oneOffOutflowLabel: string | null;
cashStart: number;
cashEnd: number;
cashNegative: boolean; // Cash faellt in dieser Phase (irgendwann) unter 0 -> Liquiditaetsluecke
incomplete: boolean; // roter Status = Liquiditaetsluecke
cashNegative: boolean; // Cash fällt in dieser Phase (irgendwann) unter 0 -> Liquiditätslücke
incomplete: boolean; // roter Status = Liquiditätslücke
elements: ElementPhaseComputed[];
startWealthNominal: number; // inkl. Cash
endWealthNominal: number; // inkl. Cash
cumulativeInflationStart: number; // Kaufkraft-Deflator zu Phasenbeginn (Bestandswerte)
cumulativeInflationEnd: number; // Kaufkraft-Deflator am Phasenende (Bestandswerte)
flowDeflatorEnd: number; // Deflator fuer den Flow-Endwert (Jahr `duration`)
flowDeflatorEnd: number; // Deflator für den Flow-Endwert (Jahr `duration`)
endWealthReal: number;
wealthBridge: WealthBridge;
cashBridge: CashBridge;
traces?: Trace[]; // Rechenwege der Phasen-Kennzahlen (nur mit explain)
}
// Ein Datenpunkt pro Jahr (ueber alle Phasen), fuer die Verlaufsgrafik.
// Ein Datenpunkt pro Jahr (über alle Phasen), für die Verlaufsgrafik.
export interface YearPoint {
year: number; // 1-basiert ab Planbeginn
age: number; // Alter Person A am Jahresende
income: number; // Einkommen inkl. Renten (nominal)
expenseNominal: number;
expenseReal: number;
// Gesamtvermoegen am Jahresende (inkl. Cash, abzgl. Schulden) -- fuer den
// Vermoegensverlauf ueber ALLE Jahre statt nur ueber die Phasengrenzen.
// Gesamtvermögen am Jahresende (inkl. Cash, abzgl. Schulden) -- für den
// Vermögensverlauf über ALLE Jahre statt nur über die Phasengrenzen.
wealthNominal: number;
wealthReal: number;
}
@@ -190,8 +190,8 @@ export interface PlanComputed {
phases: PhaseComputed[];
yearly: YearPoint[];
nachlass: number;
ruinAge: number | null; // Alter (Person A), in dem das Gesamtvermoegen (inkl. Cash) erstmals < 0 faellt
ahvCareer: AhvCareer[]; // Beitragskarriere je Person (fuer die AHV-Pruefung am Uebergang)
ruinAge: number | null; // Alter (Person A), in dem das Gesamtvermögen (inkl. Cash) erstmals < 0 fällt
ahvCareer: AhvCareer[]; // Beitragskarriere je Person (für die AHV-Prüfung am Übergang)
traces?: Trace[]; // plan-weite Rechenwege: Deflatoren, AHV-Karriere, Ruinalter (nur mit explain)
}
@@ -203,7 +203,7 @@ export interface PlanComputed {
// mdJE >= 72 x R0 -> Maximalrente 2 x R0
// Verifiziert gegen die amtliche Tabelle 318.117.1 (51/51 Zeilen exakt, siehe Tests).
// Bewusst STETIG gerechnet: die amtliche Tabelle stuft das mdJE auf Vielfache von 1.2 x R0;
// fuer eine Planung ist der stetige Wert naeher an der Wahrheit (Abweichung < 20/Monat).
// für eine Planung ist der stetige Wert näher an der Wahrheit (Abweichung < 20/Monat).
export function ahvMonthlyFullPension(mdJE: number): number {
const r0 = AHV_MIN_MONTHLY_FULL;
if (mdJE <= 12 * r0) return r0;
@@ -212,20 +212,20 @@ export function ahvMonthlyFullPension(mdJE: number): number {
return mdJE <= 36 * r0 ? r0 * (0.74 + 0.26 * x) : r0 * (1.04 + 0.16 * x);
}
// Beitragskarriere einer Person fuer die AHV -- akkumuliert ueber die Erwerbsphasen des Plans.
// Beitragskarriere einer Person für die AHV -- akkumuliert über die Erwerbsphasen des Plans.
// ACHTUNG: Alle Einkommen sind BRUTTO. Das Tool erfasst netto (so stimmt der Cash-Fluss), die
// AHV bemisst sich aber am Bruttolohn -- die Umrechnung passiert beim Aufbau der Karriere.
export interface AhvCareer {
personId: string;
role: PersonRole;
plannedAvgGrossIncome: number; // reales BRUTTO-Durchschnittseinkommen der Beitragsjahre im Plan
planYears: number; // Beitragsjahre im Plan (Dauer abzueglich Ausfalljahre)
planYears: number; // Beitragsjahre im Plan (Dauer abzüglich Ausfalljahre)
yearsBeforePlan: number; // Jahre zwischen Alter 21 und Planbeginn
gapYearsInPlan: number;
}
// Massgebendes durchschnittliches Jahreseinkommen: gewichteter Mittelwert der realen
// Einkommen ueber ALLE Beitragsjahre (vor Planbeginn + im Plan). REAL gerechnet, weil die
// Einkommen über ALLE Beitragsjahre (vor Planbeginn + im Plan). REAL gerechnet, weil die
// AHV vergangene Einkommen aufwertet UND die Schwellen indexiert -- beides hebt sich in
// realer Betrachtung weitgehend auf.
// `avgGrossIncomeBefore` ist -- wie die Karriere -- ein BRUTTO-Wert.
@@ -236,7 +236,7 @@ export function ahvMdje(career: AhvCareer, avgGrossIncomeBefore: number, gapYear
return (avgGrossIncomeBefore * yearsBefore + career.plannedAvgGrossIncome * career.planYears) / totalYears;
}
// Jaehrliche AHV-Rente: Vollrente zum mdJE, mal 13 Zahlungen, gekuerzt um die Ausfalljahre
// Jährliche AHV-Rente: Vollrente zum mdJE, mal 13 Zahlungen, gekürzt um die Ausfalljahre
// (Rentenskala: pro fehlendes Beitragsjahr 1/44).
export function ahvAnnualPension(mdJE: number, totalGapYears: number): number {
const factor = Math.max(
@@ -251,7 +251,7 @@ export function ahvYearsBeforePlan(ageAtPlanStart: number): number {
return Math.max(0, ageAtPlanStart - AHV_CONTRIBUTION_START_AGE);
}
// Maximale Dauer einer neuen Phase bis zum naechsten Pensionsereignis (null = unbegrenzt).
// Maximale Dauer einer neuen Phase bis zum nächsten Pensionsereignis (null = unbegrenzt).
export function maxPhaseDuration(
persons: { role: PersonRole; age: number; retirementAge: number }[],
yearsBefore: number
@@ -266,13 +266,13 @@ export function maxPhaseDuration(
interface Carry {
status: ElementStatus;
value: number; // Aktiven-Saldo (PK/3a/Sonstiges Vermoegen) am Ende der Vorphase
value: number; // Aktiven-Saldo (PK/3a/Sonstiges Vermögen) am Ende der Vorphase
mortgage: number; // Immobilie: Resthypothek
propertyValue: number; // Immobilie: Verkehrswert am Ende der Vorphase (inkl. Wertsteigerung)
propertyPurchase: number; // Immobilie: urspruenglicher Kaufpreis (Basis der Grundstueckgewinnsteuer)
propertyPurchase: number; // Immobilie: ursprünglicher Kaufpreis (Basis der Grundstückgewinnsteuer)
owed: number; // Schulden: Restschuld (positiv)
pkPensionAnnual: number; // PK: jaehrliche Rente nach Verrentung
flowBasis: number; // Einkommen/Ausgaben: indexierter Basiswert der naechsten Phase
pkPensionAnnual: number; // PK: jährliche Rente nach Verrentung
flowBasis: number; // Einkommen/Ausgaben: indexierter Basiswert der nächsten Phase
hasCarry: boolean;
}
@@ -296,12 +296,12 @@ function fmt(v: number): string {
return sign + Math.abs(rounded).toString().replace(/\B(?=(\d{3})+(?!\d))/g, "'");
}
// Generisch, damit der Aufrufer den vollen Personen-Typ (inkl. age) behaelt.
// Generisch, damit der Aufrufer den vollen Personen-Typ (inkl. age) behält.
function personByRole<T extends { role: PersonRole }>(persons: T[], role: string): T | null {
return persons.find((p) => p.role === role) ?? null;
}
// Ein Zufalls-Szenario fuer die Monte-Carlo-Simulation: liefert je Jahr eine Inflation und
// Ein Zufalls-Szenario für die Monte-Carlo-Simulation: liefert je Jahr eine Inflation und
// je Element/Jahr eine Rendite. Ohne Sample rechnet computePlan rein deterministisch (die
// geplanten Annahmen), mit Sample einen einzelnen simulierten Pfad. Jahr ist 1-basiert
// (ab Planbeginn); der Inflations-Index ist 0-basiert (inflation[0] = Jahr 1).
@@ -310,7 +310,7 @@ export interface PlanSample {
assetReturn: (elementId: string, year: number) => number;
}
// Kurzform fuer einen Rechenschritt. `pct` formatiert Prozentsaetze lesbar (2 statt 2.0000001).
// Kurzform für einen Rechenschritt. `pct` formatiert Prozentsätze lesbar (2 statt 2.0000001).
function st(
label: string,
result: number,
@@ -336,7 +336,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
// Kumulierter Inflations-Deflator je Jahr (cumInfl[0] = 1, cumInfl[k] = Kaufkraftfaktor nach
// k Jahren). Deterministisch identisch zur bisherigen (1+infl)^k-Formel; mit Sample variiert
// die Inflation pro Jahr. Ersetzt die frueheren geschlossenen Potenz-Ausdruecke.
// die Inflation pro Jahr. Ersetzt die früheren geschlossenen Potenz-Ausdrücke.
const totalYears = phases.reduce((s, p) => s + Math.max(1, p.durationYears), 0);
const inflationOfYear = (year: number) =>
sample ? sample.inflation[year - 1] ?? plan.inflationRateDefault : plan.inflationRateDefault;
@@ -347,7 +347,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
// AHV-Beitragskarriere je Person: reales Einkommen x Beitragsjahre, und Beitragsjahre.
const ahvIncomeAccum = new Map<string, number>();
const ahvYearsAccum = new Map<string, number>();
// Karriere VOR Planbeginn -- aus der Pruefung am Pensions-Uebergang bzw. (fuer bereits
// Karriere VOR Planbeginn -- aus der Prüfung am Pensions-Übergang bzw. (für bereits
// Pensionierte) aus der Phasenzelle der ersten Phase.
const ahvBeforeByPerson = new Map<string, { avg: number; gap: number }>();
const carries = new Map<string, Carry>();
@@ -358,18 +358,18 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
let yearsBefore = 0;
let cumulativeInflation = 1;
let cashCarryIn = Math.round(plan.initialCash || 0);
// Aus dem Uebergang der Vorphase in DIESE Phase fliessende Groessen (Kopf-Kennzahlen).
let incomingInflow = 0; // Brutto-Zufluss: Verkaeufe + PK-/3a-Bezuege
// Aus dem Übergang der Vorphase in DIESE Phase fliessende Grössen (Kopf-Kennzahlen).
let incomingInflow = 0; // Brutto-Zufluss: Verkäufe + PK-/3a-Bezüge
let incomingImmediateRepay = 0; // sofortige Schuldentilgungen (Abfluss)
let incomingOneOffInflow = 0; // einmaliger Sonderzufluss (netto nach Steuer)
let incomingOneOffInflowLabel: string | null = null;
let incomingOneOffOutflow = 0; // einmalige Sonderkosten (nominal)
let incomingOneOffOutflowLabel: string | null = null;
// Echte Vermoegensaenderungen am Uebergang IN die Folgephase (fuer die Vermoegens-Bruecke).
let incomingTax = 0; // Kapitalbezugs- + Grundstueckgewinnsteuer
// Echte Vermögensänderungen am Übergang IN die Folgephase (für die Vermögens-Brücke).
let incomingTax = 0; // Kapitalbezugs- + Grundstückgewinnsteuer
let incomingPensionConversion = 0; // in eine Rente umgewandeltes PK-Kapital
let incomingSaleGainLoss = 0; // Verkaufspreis minus Verkehrswert
let previousEndWealth = 0; // Endvermoegen der Vorphase (Startpunkt der Bruecke)
let previousEndWealth = 0; // Endvermögen der Vorphase (Startpunkt der Brücke)
let previousCashEnd = Math.round(plan.initialCash || 0);
let ruinAge: number | null = null;
@@ -402,7 +402,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
const maxDurationYears = capsFromWorking.length > 0 ? Math.min(...capsFromWorking) : null;
const workingByPerson = new Map(personInfos.map((p) => [p.personId, p.working]));
// Ausfalljahre kumulieren (nur waehrend der Erwerbstaetigkeit).
// Ausfalljahre kumulieren (nur während der Erwerbstätigkeit).
const gapThisPhase = new Map<string, number>();
for (const e of plan.elements) {
if (e.category !== "AHV" || !e.ownerRole) continue;
@@ -413,13 +413,13 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
gapYearsByPerson.set(owner.id, (gapYearsByPerson.get(owner.id) ?? 0) + gy);
}
// Bereits bei Planbeginn pensioniert: es gibt keinen Pensions-Uebergang, an dem die
// Beitragskarriere geprueft werden koennte -- die Werte liegen dann in der Phasenzelle.
// Bereits bei Planbeginn pensioniert: es gibt keinen Pensions-Übergang, an dem die
// Beitragskarriere geprüft werden könnte -- die Werte liegen dann in der Phasenzelle.
for (const e of plan.elements) {
if (e.category !== "AHV" || !e.ownerRole) continue;
const owner = personByRole(persons, e.ownerRole);
if (!owner || workingByPerson.get(owner.id)) continue;
if (ahvBeforeByPerson.has(owner.id)) continue; // aus dem Uebergang bereits gesetzt
if (ahvBeforeByPerson.has(owner.id)) continue; // aus dem Übergang bereits gesetzt
const pd = e.phaseValues[phase.id] ?? {};
ahvBeforeByPerson.set(owner.id, {
avg: num(pd.avgIncomeBefore),
@@ -427,7 +427,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
});
}
// AHV-Renten der Pensionierten: Vollrente zum mdJE, gekuerzt um die Ausfalljahre.
// AHV-Renten der Pensionierten: Vollrente zum mdJE, gekürzt um die Ausfalljahre.
const ahvUncapped = new Map<string, number>();
for (const e of plan.elements) {
if (e.category !== "AHV" || !e.ownerRole) continue;
@@ -446,16 +446,16 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
if (sum > cap && sum > 0) for (const [pid, v] of ahvUncapped) ahvFinal.set(pid, Math.round(v * (cap / sum)));
}
// --- Element-Laufzeitzustaende aufbauen ---
// --- Element-Laufzeitzustände aufbauen ---
const orderedElements = [...plan.elements].sort((a, b) => a.orderIndex - b.orderIndex);
const ecById = new Map<string, ElementPhaseComputed>();
// Reales Durchschnittseinkommen dieser Phase je Person (fuer die AHV-Karriere).
// Reales Durchschnittseinkommen dieser Phase je Person (für die AHV-Karriere).
const phaseRealIncomeByPerson = new Map<string, number>();
const incomes: { basis: number; idx: number; ec: ElementPhaseComputed }[] = [];
const expenses: { basis: number; idx: number; ec: ElementPhaseComputed }[] = [];
let renteTotal = 0; // AHV + PK-Renten (nominal fix)
// `isPk` fuer die Vermoegens-Bruecke: PK-Beitraege verlassen das Cash NICHT (sie sind im
// Nettolohn bereits abgezogen), erhoehen aber das Vermoegen -- sie sind also ein echter
// `isPk` für die Vermögens-Brücke: PK-Beiträge verlassen das Cash NICHT (sie sind im
// Nettolohn bereits abgezogen), erhöhen aber das Vermögen -- sie sind also ein echter
// Zugang und keine Umbuchung.
const assets: {
value: number;
@@ -467,7 +467,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
}[] = [];
// mortgage/owed sind LAUFENDE Salden: sie werden in der Jahresschleife abgebaut und am
// Nullpunkt gestoppt (keine Rate mehr, sobald abbezahlt). `value` ist der Verkehrswert der
// Liegenschaft (waechst mit valueGrowth), `purchase` der urspruengliche Kaufpreis.
// Liegenschaft (wächst mit valueGrowth), `purchase` der ursprüngliche Kaufpreis.
const realEstates: {
value: number;
purchase: number;
@@ -479,8 +479,8 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
ec: ElementPhaseComputed;
}[] = [];
const debts: { owed: number; repay: number; ec: ElementPhaseComputed }[] = [];
let fixedRatesTotal = 0; // Sparraten mit konstantem Jahresbetrag: 3a + Sonstiges Vermoegen
let plannedWithdrawTotal = 0; // Bezugsraten (fliessen ins Cash): Sonstiges Vermoegen
let fixedRatesTotal = 0; // Sparraten mit konstantem Jahresbetrag: 3a + Sonstiges Vermögen
let plannedWithdrawTotal = 0; // Bezugsraten (fliessen ins Cash): Sonstiges Vermögen
let investmentsFromCash = 0; // Neuinvestitionen/Aufstockungen (ab Phase 2, aus Cash)
let wealthStart = 0;
let wealthEnd = 0; // wird nach der Jahresschleife gefuellt
@@ -524,10 +524,10 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
switch (e.category) {
case "INCOME":
case "EXPENSE": {
// Einkommen: Basis + Rate sind NOMINAL (Lohnerhoehung). Ausgaben: Basis + Rate sind
// Einkommen: Basis + Rate sind NOMINAL (Lohnerhöhung). Ausgaben: Basis + Rate sind
// REAL (heutige Kaufkraft; reale Mehrausgaben) -- die Inflation kommt separat dazu.
// Rate-Default = 0 %. Basiswert ab Phase 2 = fortgeschriebener Wert der Vorphase
// (nominal fuer Einkommen, real fuer Ausgaben), ausser bewusst geaendert (pd.amount).
// (nominal für Einkommen, real für Ausgaben), ausser bewusst geändert (pd.amount).
const idx = num(pd.teuerungsausgleich, 0);
ec.baseValue = carry.hasCarry ? Math.round(carry.flowBasis) : Math.round(num(pd.amount));
const basis = !carry.hasCarry
@@ -537,15 +537,15 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
: ec.baseValue;
(e.category === "INCOME" ? incomes : expenses).push({ basis, idx, ec });
// AHV: reales Erwerbseinkommen der Person mitfuehren. Nur Einkommen, die einer
// Person zugeordnet sind -- bei einem Einzelplan zaehlt "Gemeinsam" zur Person A.
// AHV: reales Erwerbseinkommen der Person mitführen. Nur Einkommen, die einer
// Person zugeordnet sind -- bei einem Einzelplan zählt "Gemeinsam" zur Person A.
// Das Feld ist NETTO erfasst; die AHV bemisst sich am Bruttolohn -> hochrechnen.
if (e.category === "INCOME" && ownerWorking) {
const attributed =
owner ?? (plan.householdType === "SINGLE" && e.ownerRole === "HOUSEHOLD" ? personA : null);
if (attributed) {
// Die AHV-Karriere ist eine Real-Groesse auf Planungsbasis -- bewusst mit der
// festen Plan-Inflation, nicht der (evtl. gewuerfelten) Sample-Inflation.
// Die AHV-Karriere ist eine Real-Grösse auf Planungsbasis -- bewusst mit der
// festen Plan-Inflation, nicht der (evtl. gewürfelten) Sample-Inflation.
const avgRealGross =
avgRealFlow(basis, idx, plan.inflationRateDefault, duration, cumInflStart) * AHV_GROSS_FROM_NET_FACTOR;
phaseRealIncomeByPerson.set(
@@ -555,7 +555,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
}
}
// Basiswert der Folgephase fortschreiben (nominal fuer Einkommen, real fuer Ausgaben).
// Basiswert der Folgephase fortschreiben (nominal für Einkommen, real für Ausgaben).
carry.flowBasis = basis * Math.pow(1 + idx / 100, duration);
break;
}
@@ -579,13 +579,13 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
ec.endValue = carry.pkPensionAnnual;
ec.summary = `Rente ${fmt(carry.pkPensionAnnual)}`;
} else if (!ownerWorking) {
ec.note = "Vollstaendig bezogen";
ec.note = "Vollständig bezogen";
ec.summary = "Bezogen";
} else {
const base = carry.hasCarry ? carry.value : Math.round(num(pd.currentValue));
const topUp = carry.hasCarry ? Math.round(num(pd.additionalInvestment)) : 0;
const start = base + topUp;
const rate = Math.round(num(pd.annualContribution)); // PK-Beitrag zaehlt NICHT zur Quote
const rate = Math.round(num(pd.annualContribution)); // PK-Beitrag zählt NICHT zur Quote
if (!isFirstPhase) investmentsFromCash += topUp;
ec.baseValue = base;
ec.startValue = start;
@@ -596,7 +596,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
}
case "PILLAR_3A": {
if (!ownerWorking) {
ec.note = "Vollstaendig bezogen";
ec.note = "Vollständig bezogen";
ec.summary = "Bezogen";
} else {
const base = carry.hasCarry ? carry.value : Math.round(num(pd.currentValue));
@@ -628,8 +628,8 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
break;
}
case "REAL_ESTATE": {
// Urspruenglicher Kaufpreis (Basis der Grundstueckgewinnsteuer) und Verkehrswert
// (waechst mit der Wertsteigerung) laufen getrennt.
// Ursprünglicher Kaufpreis (Basis der Grundstückgewinnsteuer) und Verkehrswert
// (wächst mit der Wertsteigerung) laufen getrennt.
const purchase = carry.hasCarry ? carry.propertyPurchase : Math.round(num(pd.purchasePrice));
const valueStart = carry.hasCarry ? carry.propertyValue : Math.round(num(pd.purchasePrice));
const mortgageStart = carry.hasCarry ? carry.mortgage : Math.round(num(pd.mortgage));
@@ -647,7 +647,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
amort,
growth: num(pd.valueGrowth),
interestRate: num(pd.interestRate),
// Default INCLUDED: bestehende Plaene haben die Zinsen in den Ausgaben -> nicht
// Default INCLUDED: bestehende Pläne haben die Zinsen in den Ausgaben -> nicht
// nochmals abziehen. Nur bei bewusstem "ADD" rechnet das Tool sie dazu.
addInterest: pd.interestHandling === "ADD",
ec,
@@ -677,9 +677,9 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
ahvYearsAccum.set(p.id, (ahvYearsAccum.get(p.id) ?? 0) + contribYears);
}
// --- Jahr-fuer-Jahr: indexierte Flows, Cash-Ausgleich, Verzinsung, Ruin ---
// --- Jahr-für-Jahr: indexierte Flows, Cash-Ausgleich, Verzinsung, Ruin ---
// Investitionen dieser Phase werden am Phasenanfang abgezogen -> der Cash-Startwert
// zeigt den Bestand NACH den Investitionen (kein Doppelzaehlen mit dem Vermoegen).
// zeigt den Bestand NACH den Investitionen (kein Doppelzählen mit dem Vermögen).
let cash = cashCarryIn - (isFirstPhase ? 0 : investmentsFromCash);
const cashStart = cash;
let cashNegative = cash < 0;
@@ -689,8 +689,8 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
let expenseEnd = 0;
let quotaStart = 0;
let quotaEnd = 0;
let plannedSaveRate = 0; // Kopf-Kennzahl: die tatsaechliche Sparrate im ersten Phasenjahr
// Summanden der beiden Wasserfall-Bruecken (ueber alle Phasenjahre kumuliert).
let plannedSaveRate = 0; // Kopf-Kennzahl: die tatsächliche Sparrate im ersten Phasenjahr
// Summanden der beiden Wasserfall-Brücken (über alle Phasenjahre kumuliert).
let quotaTotal = 0;
let investmentReturnTotal = 0;
let propertyAppreciationTotal = 0;
@@ -700,7 +700,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
let withdrawalsTotal = 0;
for (let t = 1; t <= duration; t++) {
// Einkommen: nominal (Basis x (1+Lohnerhoehung)^(t-1)) + Renten (nominal fix).
// Einkommen: nominal (Basis x (1+Lohnerhöhung)^(t-1)) + Renten (nominal fix).
let incomeFlow = renteTotal;
for (const inc of incomes) incomeFlow += inc.basis * Math.pow(1 + inc.idx / 100, t - 1);
// Ausgaben: real (Basis x (1+reale Mehrausgabe)^(t-1)); nominal = real x kumul. Inflation.
@@ -709,7 +709,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
for (const exp of expenses) expenseRealBase += exp.basis * Math.pow(1 + exp.idx / 100, t - 1);
// Hypothekarzins: NOMINAL aus der Restschuld zu Jahresbeginn -- nicht mit der Inflation
// hochrechnen. Zaehlt zu den Ausgaben (und damit in die Quote), sofern nicht bereits
// hochrechnen. Zählt zu den Ausgaben (und damit in die Quote), sofern nicht bereits
// im Ausgaben-Element enthalten.
let interestNominal = 0;
for (const re of realEstates) {
@@ -721,8 +721,8 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
const expenseReal = expenseRealBase + interestNominal / (inflFactor || 1);
const quote = incomeFlow - expenseNominal;
// Der Vermoegenswert wird erst nach Verzinsung/Cash-Fortschreibung bekannt und weiter
// unten nachgetragen (gleiche Groesse, die auch die Ruin-Erkennung verwendet).
// Der Vermögenswert wird erst nach Verzinsung/Cash-Fortschreibung bekannt und weiter
// unten nachgetragen (gleiche Grösse, die auch die Ruin-Erkennung verwendet).
const yearPoint: YearPoint = {
year: yearsBefore + t,
age: personA.age + yearsBefore + t,
@@ -745,11 +745,11 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
quotaEnd = quote;
}
// Vermoegen verzinsen + Sparbeitrag; Bezugsrate entnehmen (gekappt am Bestand) und ins Cash.
// Vermögen verzinsen + Sparbeitrag; Bezugsrate entnehmen (gekappt am Bestand) und ins Cash.
let cashFromWithdraw = 0;
for (const a of assets) {
const r = sample ? sample.assetReturn(a.ec.elementId, yearsBefore + t) : a.r;
// Arithmetik unveraendert lassen; die Rendite wird als Differenz abgeleitet, damit
// Arithmetik unverändert lassen; die Rendite wird als Differenz abgeleitet, damit
// die Protokollierung das Ergebnis nicht (per Gleitkomma-Umformung) verschiebt.
const grown = a.value * (1 + r / 100) + a.rate;
const growth = grown - a.rate - a.value;
@@ -761,7 +761,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
}
// Amortisation/Tilgung: nur so lange und so viel, wie noch Restschuld besteht. Ist die
// Hypothek/Schuld abbezahlt, entfaellt die Rate -- sie belastet weder Cash noch Sparquote.
// Hypothek/Schuld abbezahlt, entfällt die Rate -- sie belastet weder Cash noch Sparquote.
let debtRates = 0;
for (const re of realEstates) {
const pay = Math.min(re.amort, re.mortgage);
@@ -771,7 +771,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
// Hebel: 1 % von 1 Mio sind 10'000, also 10 % eines Eigenkapitals von 100'000.
const g = sample ? sample.assetReturn(re.ec.elementId, yearsBefore + t) : re.growth;
const valueBefore = re.value;
re.value *= 1 + g / 100; // Arithmetik unveraendert
re.value *= 1 + g / 100; // Arithmetik unverändert
propertyAppreciationTotal += re.value - valueBefore;
}
for (const d of debts) {
@@ -790,7 +790,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
withdrawalsTotal += cashFromWithdraw;
// Verlaufspunkt je Element (Detailansicht). Bewusst NACH Verzinsung und Tilgung, also
// der Stand am Jahresende -- konsistent zum Gesamtvermoegen im YearPoint.
// der Stand am Jahresende -- konsistent zum Gesamtvermögen im YearPoint.
const age = personA.age + yearsBefore + t;
const yr = yearsBefore + t;
for (const inc of incomes) {
@@ -821,7 +821,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
}
}
// Gesamtvermoegen zum Jahresende t (fuer Ruin-Erkennung).
// Gesamtvermögen zum Jahresende t (für Ruin-Erkennung).
let total = cash;
for (const a of assets) total += a.value;
for (const re of realEstates) total += re.value - re.mortgage;
@@ -831,7 +831,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
if (ruinAge === null && total < 0) ruinAge = personA.age + yearsBefore + t;
}
// Flow-Deflator fuer den Endwert (Jahr `duration`): eine Kaufkraft-Stufe weniger als der
// Flow-Deflator für den Endwert (Jahr `duration`): eine Kaufkraft-Stufe weniger als der
// Bestands-Deflator am Phasenende.
const flowDeflatorEnd = cumInfl[yearsBefore + duration - 1];
@@ -1007,7 +1007,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
};
}
// AHV-/PK-Renten: der Rechenweg der Rentenhoehe selbst liegt auf Plan-Ebene (mdJE).
// AHV-/PK-Renten: der Rechenweg der Rentenhöhe selbst liegt auf Plan-Ebene (mdJE).
for (const e of orderedElements) {
const ec = ecById.get(e.id)!;
if (ec.trace || ec.startValue === 0) continue;
@@ -1232,19 +1232,19 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
previousEndWealth = endWealthNominal;
previousCashEnd = cashEnd;
// --- Uebergang: Carry aktualisieren, Cash der Folgephase bilden ---
// --- Übergang: Carry aktualisieren, Cash der Folgephase bilden ---
let txInflow = 0;
let txImmediateRepay = 0;
// Echte Vermoegensaenderungen an dieser Grenze (fuer die Bruecke der Folgephase).
// Verkaeufe, Bezuege und Tilgungen sind fuer sich Umbuchungen -- vermoegenswirksam sind
// nur die Steuer, die Verrentung (Kapital verlaesst die Bilanz) und die Differenz
// Echte Vermögensänderungen an dieser Grenze (für die Brücke der Folgephase).
// Verkäufe, Bezüge und Tilgungen sind für sich Umbuchungen -- vermögenswirksam sind
// nur die Steuer, die Verrentung (Kapital verlässt die Bilanz) und die Differenz
// zwischen Verkaufspreis und Verkehrswert.
let txTax = 0;
let txPensionConversion = 0;
let txSaleGainLoss = 0;
// Einmalige Sonderein-/ausgaben auf dem Cash-Konto. Nur sinnvoll, wenn eine Folgephase
// existiert -- nach der letzten Phase gibt es keinen Uebergang. Der Wechselkurs zwischen
// existiert -- nach der letzten Phase gibt es keinen Übergang. Der Wechselkurs zwischen
// real und nominal ist an dieser Grenze `cumulativeInflation` (Bestands-Deflator am
// Phasenende), denn Cash ist ein Bestand.
let txOneOffInflow = 0;
@@ -1281,14 +1281,14 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
continue;
}
// AHV: beim Pensions-Uebergang die geprueften Karriere-Werte vor Planbeginn uebernehmen.
// AHV: beim Pensions-Übergang die geprüften Karriere-Werte vor Planbeginn übernehmen.
if (e.category === "AHV") {
if (ownerRetiresNext && owner) {
const career = buildCareer(owner, ahvIncomeAccum, ahvYearsAccum, gapYearsByPerson);
ahvBeforeByPerson.set(owner.id, {
// Ohne erfassten Wert gilt der geplante Durchschnitt als Schaetzung fuer die Jahre
// vor Planbeginn -- exakt der Wert, den der Pruef-Dialog vorbelegt. Ein Fallback auf
// 0 wuerde die Rente still und massiv zu tief rechnen. Beide Werte sind BRUTTO.
// Ohne erfassten Wert gilt der geplante Durchschnitt als Schätzung für die Jahre
// vor Planbeginn -- exakt der Wert, den der Prüf-Dialog vorbelegt. Ein Fallback auf
// 0 würde die Rente still und massiv zu tief rechnen. Beide Werte sind BRUTTO.
avg: num(td.avgIncomeBefore, career.plannedAvgGrossIncome),
gap: Math.max(0, Math.round(num(td.gapYearsBefore))),
});
@@ -1330,7 +1330,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
carry.value = 0;
}
} else {
// Vorbezug (z. B. Wohneigentum/Selbststaendigkeit): ebenfalls kapitalbezugssteuerpflichtig.
// Vorbezug (z. B. Wohneigentum/Selbstständigkeit): ebenfalls kapitalbezugssteuerpflichtig.
// Das Kapital wird brutto entnommen, netto (nach Steuer) fliesst es ins Cash.
const withdrawal = Math.min(ec.endValue, Math.round(num(td.withdrawal)));
carry.value = ec.endValue - withdrawal;
@@ -1360,7 +1360,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
txInflow += ec.endValue;
carry.status = "SOLD";
} else if (td.decision === "PARTIAL") {
// Teilverkauf: Betrag fliesst ins Cash, der Rest bleibt aktiv und waechst weiter.
// Teilverkauf: Betrag fliesst ins Cash, der Rest bleibt aktiv und wächst weiter.
const part = Math.min(ec.endValue, Math.round(num(td.partialSaleAmount)));
txInflow += part;
carry.value = ec.endValue - part;
@@ -1374,14 +1374,14 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
if (!re) break;
if (td.decision === "SELL") {
const salePrice = Math.round(num(td.salePrice));
// Grundstueckgewinnsteuer bemisst sich am urspruenglichen Kaufpreis, nicht am
// Grundstückgewinnsteuer bemisst sich am ursprünglichen Kaufpreis, nicht am
// zwischenzeitlich gestiegenen Verkehrswert.
const gain = Math.max(0, salePrice - re.purchase);
const tax = gain * (num(td.saleTaxRate, DEFAULT_PROPERTY_GAINS_TAX_RATE) / 100);
txInflow += Math.round(salePrice - re.mortgage - tax);
txTax += Math.round(tax);
// Der Verkaufspreis kann vom fortgeschriebenen Verkehrswert abweichen -- diese
// Differenz ist ein echter Vermoegensgewinn bzw. -verlust an der Grenze.
// Differenz ist ein echter Vermögensgewinn bzw. -verlust an der Grenze.
txSaleGainLoss += Math.round(salePrice - re.value);
carry.status = "SOLD";
} else {
@@ -1409,7 +1409,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
break;
}
// --- Rechenweg des Uebergangs je Element ---
// --- Rechenweg des Übergangs je Element ---
if (explain && nextPhase) {
const inflowHere = txInflow - txStepsBefore;
const taxHere = txTax - taxBefore;
@@ -1492,8 +1492,8 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
incomingPensionConversion = txPensionConversion;
incomingSaleGainLoss = txSaleGainLoss;
// Rechenweg des Cash-Uebergangs (einmalige Sonderein-/ausgaben). Wird nachtraeglich an
// die Trace-Liste dieser Phase gehaengt -- `result` haelt die Referenz auf das Array.
// Rechenweg des Cash-Übergangs (einmalige Sonderein-/ausgaben). Wird nachträglich an
// die Trace-Liste dieser Phase gehängt -- `result` hält die Referenz auf das Array.
if (explain && nextPhase && (txOneOffInflow !== 0 || txOneOffOutflow !== 0)) {
const ct = phase.cashTransition ?? {};
const steps: TraceStep[] = [];
@@ -1541,7 +1541,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
specAnchor: "442-beitragskarriere-und-mdje",
steps: [
st("Beitragsjahre im Plan", career.planYears, "Summe (Phasendauer Ausfalljahre)", undefined, undefined, "Jahre"),
st("Durchschnittliches Bruttoeinkommen im Plan (real)", Math.round(career.plannedAvgGrossIncome), `Nettolohn × ${AHV_GROSS_FROM_NET_FACTOR}`, undefined,
st("Durchschnittliches Bruttöinkommen im Plan (real)", Math.round(career.plannedAvgGrossIncome), `Nettolohn × ${AHV_GROSS_FROM_NET_FACTOR}`, undefined,
"Das Tool erfasst netto, die AHV bemisst sich am Brutto. Der Faktor 1.12 ist ein Karriere-Durchschnitt und keine exakte Einzelfall-Umrechnung."),
st("Beitragsjahre vor Planbeginn", Math.max(0, career.yearsBeforePlan - before.gap), "Alter bei Planbeginn 21 Ausfalljahre davor", undefined, undefined, "Jahre"),
st("Durchschnittseinkommen vor Planbeginn (real, brutto)", Math.round(before.avg), undefined, undefined,
@@ -1568,7 +1568,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
return { phases: result, yearly, nachlass, ruinAge, ahvCareer, traces: explain ? planTraces : undefined };
}
// Durchschnittliches REALES Jahreseinkommen ueber eine Phase. Nominal waechst der Flow mit
// Durchschnittliches REALES Jahreseinkommen über eine Phase. Nominal wächst der Flow mit
// idx, real wird er mit der Inflation deflationiert -> geometrische Reihe mit q.
function avgRealFlow(basis: number, idx: number, infl: number, duration: number, cumInflStart: number): number {
const q = (1 + idx / 100) / (1 + infl / 100);
@@ -1616,8 +1616,8 @@ export function planToCsv(plan: PlanInput, computed: PlanComputed): string {
"Quote (Beginn)",
"Quote (Ende)",
"Cash (Ende)",
"Endvermoegen (nominal)",
"Endvermoegen (real)",
"Endvermögen (nominal)",
"Endvermögen (real)",
];
const rows = computed.phases.map((p) => [
p.name,
+26 -26
View File
@@ -1,9 +1,9 @@
// Konfigurierbare Systemparameter (Stand 2026). Aendern sich periodisch durch
// Anpassungen des Bundes -- deshalb hier zentral gefuehrt, nicht im Code verteilt.
// Konfigurierbare Systemparameter (Stand 2026). Ändern sich periodisch durch
// Anpassungen des Bundes -- deshalb hier zentral geführt, nicht im Code verteilt.
// R0 = Mindestbetrag der vollen monatlichen Altersrente (Skala 44). Alle Schwellen der
// Rentenformel sind Vielfache von R0 -- aendert sich R0, verschiebt sich die ganze Skala.
// Quelle: BSV/MAS "Berechnungsvorschriften der AHV/IV-Renten", gueltig ab 1.1.2026;
// Rentenformel sind Vielfache von R0 -- ändert sich R0, verschiebt sich die ganze Skala.
// Quelle: BSV/MAS "Berechnungsvorschriften der AHV/IV-Renten", gültig ab 1.1.2026;
// amtliche Tabelle 318.117.1 "Monatliche Vollrenten, Skala 44".
export const AHV_MIN_MONTHLY_FULL = 1260;
@@ -18,48 +18,48 @@ export const AHV_CONTRIBUTION_START_AGE = 21;
// Abgeleitet, damit eine Anpassung von R0 nicht an zwei Stellen nachgezogen werden muss.
export const AHV_MAX_ANNUAL_SINGLE = 2 * AHV_MIN_MONTHLY_FULL * AHV_PENSION_MONTHS;
// Umrechnung Netto- -> Bruttolohn fuer die AHV. Das Tool erfasst das Einkommen NETTO (so
// Umrechnung Netto- -> Bruttolohn für die AHV. Das Tool erfasst das Einkommen NETTO (so
// denkt der Nutzer, und so stimmt der Cash-Fluss), die AHV bemisst sich aber am BRUTTOlohn.
//
// Herleitung (Arbeitnehmer-Abzuege in % des Bruttolohns):
// AHV/IV/EO 5.30 % (10.6 % total, haelftig geteilt)
// ALV 1.10 % ( 2.2 % total, haelftig geteilt, bis 148'200)
// Herleitung (Arbeitnehmer-Abzüge in % des Bruttolohns):
// AHV/IV/EO 5.30 % (10.6 % total, hälftig geteilt)
// ALV 1.10 % ( 2.2 % total, hälftig geteilt, bis 148'200)
// NBU ~1.00 % (variiert 0.4-1.6 %, teils vom Arbeitgeber getragen)
// PK ~2-5 % (BVG-Altersgutschrift 7/10/15/18 % auf den KOORDINIERTEN Lohn
// [Brutto - 26'460, max. 90'720], Arbeitnehmer >= die Haelfte)
// [Brutto - 26'460, max. 90'720], Arbeitnehmer >= die Hälfte)
// -> total ~9-13 % je nach Alter und Lohn; Faktor 1/(1-q) = 1.10 bis 1.16, Mittel ~1.12.
//
// Ein fixer Faktor ist hier vertretbar, weil das mdJE selbst ein Durchschnitt ueber die ganze
// Beitragskarriere (44 Jahre) ist: der altersabhaengige PK-Satz mittelt sich dabei heraus.
// Die Restunschaerfe (~3 %) ist deutlich kleiner als der Fehler, den die Umrechnung behebt (~11 %).
// Ein fixer Faktor ist hier vertretbar, weil das mdJE selbst ein Durchschnitt über die ganze
// Beitragskarriere (44 Jahre) ist: der altersabhängige PK-Satz mittelt sich dabei heraus.
// Die Restunschärfe (~3 %) ist deutlich kleiner als der Fehler, den die Umrechnung behebt (~11 %).
//
// Quellen: BSV "Betraege gueltig ab 1.1.2026" (Koordinationsabzug 26'460, obere Limite 90'720,
// keine Aenderung gegenueber 2025); AHV-Merkblaetter 2.01 (AHV/IV/EO) und 2.08 (ALV).
// Quellen: BSV "Beträge gültig ab 1.1.2026" (Koordinationsabzug 26'460, obere Limite 90'720,
// keine Änderung gegenüber 2025); AHV-Merkblätter 2.01 (AHV/IV/EO) und 2.08 (ALV).
export const AHV_GROSS_FROM_NET_FACTOR = 1.12;
// Ehepaar-Plafonierung: die Summe beider Einzelrenten ist auf 150% der Einzel-
// Maximalrente begrenzt. Bei Ueberschreitung werden beide Renten proportional gekuerzt.
// Maximalrente begrenzt. Bei Überschreitung werden beide Renten proportional gekürzt.
export const AHV_COUPLE_CAP_FACTOR = 1.5;
// Volle Beitragsdauer fuer eine ungekuerzte AHV-Rente (Rentenskala 44). Pro fehlendes
// Beitragsjahr (Ausfalljahr) wird die Rente um 1/44 gekuerzt.
// Volle Beitragsdauer für eine ungekürzte AHV-Rente (Rentenskala 44). Pro fehlendes
// Beitragsjahr (Ausfalljahr) wird die Rente um 1/44 gekürzt.
export const AHV_FULL_CONTRIBUTION_YEARS = 44;
// Maximaler jaehrlicher Saeule-3a-Beitrag fuer PK-Versicherte (2026).
// Maximaler jährlicher Säule-3a-Beitrag für PK-Versicherte (2026).
export const PILLAR_3A_MAX_ANNUAL = 7258;
// Default-Annahmen fuer Uebergangs-Ereignisse (pro Ereignis editierbar). Werden SOWOHL als
// Default-Annahmen für Übergangs-Ereignisse (pro Ereignis editierbar). Werden SOWOHL als
// UI-Vorschlag als auch in der Berechnung als Fallback verwendet -- damit ein nicht
// angetupfter Wert nicht faelschlich als 0 gerechnet wird.
export const DEFAULT_PK_CONVERSION_RATE = 6; // % (jaehrliche Rente = Kapital x Satz)
// angetupfter Wert nicht fälschlich als 0 gerechnet wird.
export const DEFAULT_PK_CONVERSION_RATE = 6; // % (jährliche Rente = Kapital x Satz)
export const DEFAULT_CAPITAL_TAX_RATE = 8; // % Kapitalbezugssteuer (PK-/3a-Kapitalbezug)
export const DEFAULT_PROPERTY_GAINS_TAX_RATE = 20; // % Grundstueckgewinnsteuer
export const DEFAULT_PROPERTY_GAINS_TAX_RATE = 20; // % Grundstückgewinnsteuer
// --- Strukturierte Fassung fuer die Systemparameter-Ansicht (Roadmap Nr. 41) -------------
// --- Strukturierte Fassung für die Systemparameter-Ansicht (Roadmap Nr. 41) -------------
//
// Dieselben Werte wie oben, nur maschinenlesbar beschrieben: Damit kann die Oberflaeche
// Dieselben Werte wie oben, nur maschinenlesbar beschrieben: Damit kann die Oberfläche
// Wert, Bedeutung, Herleitung und Quelle anzeigen, OHNE dass die Zahlen ein zweites Mal
// gepflegt werden muessen -- die Eintraege referenzieren die Konstanten direkt und koennen
// gepflegt werden müssen -- die Einträge referenzieren die Konstanten direkt und können
// deshalb nicht von ihnen abdriften.
export type ParameterGroup = "AHV" | "Vorsorge" | "Steuern";
@@ -74,7 +74,7 @@ export interface SystemParameter {
source: string;
validFrom: string;
group: ParameterGroup;
editablePerEvent?: boolean; // Default-Satz, pro Ereignis ueberschreibbar
editablePerEvent?: boolean; // Default-Satz, pro Ereignis überschreibbar
}
export const SYSTEM_PARAMETERS: SystemParameter[] = [
+96
View File
@@ -0,0 +1,96 @@
// Beispielplan mit einem Klick (Teil von Roadmap Nr. 10). Erzeugt einen realistischen,
// vollständig ausgefuellten Plan über die BESTEHENDEN API-Endpunkte -- reine Orchestrierung,
// kein neuer Endpunkt, keine Änderung an der Berechnung.
//
// Didaktischer Entscheid: Die Übergangs-Entscheide bleiben absichtlich OFFEN. Der neue
// Nutzer sieht dadurch die Ampel ("N offen") in Aktion und lernt das wichtigste Konzept des
// Tools am Beispiel, statt es erklärt zu bekommen.
//
// WICHTIG zur Reihenfolge: erst Phase 1, dann die Elemente samt Phase-1-Werten, dann die
// Folgephase. Die Phasen-Route belegt beim Anlegen einer Phase die editierbaren Felder aus
// der Vorphase vor -- existieren die Elemente noch nicht, bleibt die Folgephase leer
// (Renditen/Beiträge wären dort 0).
import { api } from "@/lib/api-client";
import type { PhaseData } from "@/lib/elements";
async function addElement(
scenarioId: string,
firstPhaseId: string,
category: string,
name: string,
ownerRole: string,
values?: PhaseData
): Promise<void> {
const { element } = await api.post<{ element: { id: string } }>(
`/api/scenarios/${scenarioId}/elements`,
{ category, name, ownerRole }
);
if (values && Object.keys(values).length > 0) {
await api.put(`/api/elements/${element.id}/phase/${firstPhaseId}`, values);
}
}
// Liefert die Szenario-Id des angelegten Beispielplans.
export async function createDemoPlan(): Promise<string> {
const currentYear = new Date().getFullYear();
const { scenario } = await api.post<{ plan: { id: string }; scenario: { id: string } }>(
"/api/plans",
{
name: "Beispiel: Alex Muster",
householdType: "SINGLE",
inflationRateDefault: 1.5,
startYear: currentYear,
persons: [{ role: "PERSON_A", name: "Alex", age: 45, retirementAge: 65 }],
}
);
const sid = scenario.id;
await api.patch(`/api/scenarios/${sid}`, { initialCash: 40000 });
// Phase 1: Erwerbsphase bis zur Pension (die Route kappt die Dauer ohnehin am
// Pensionsereignis; 20 Jahre = 45 -> 65).
const p1 = await api.post<{ phase: { id: string } }>(`/api/scenarios/${sid}/phases`, {
name: "Erwerbsphase",
durationYears: 20,
});
const firstPhaseId = p1.phase.id;
await addElement(sid, firstPhaseId, "INCOME", "Lohn Alex", "PERSON_A", {
amount: 110000,
teuerungsausgleich: 1,
});
await addElement(sid, firstPhaseId, "EXPENSE", "Lebenshaltung", "HOUSEHOLD", {
amount: 78000,
teuerungsausgleich: 0,
});
await addElement(sid, firstPhaseId, "AHV", "AHV Alex", "PERSON_A", { gapYears: 0 });
await addElement(sid, firstPhaseId, "PENSION_FUND", "Pensionskasse", "PERSON_A", {
currentValue: 380000,
annualContribution: 14000,
expectedReturn: 1.5,
});
await addElement(sid, firstPhaseId, "PILLAR_3A", "Säule 3a", "PERSON_A", {
currentValue: 75000,
annualContribution: 7000,
expectedReturn: 2,
});
await addElement(sid, firstPhaseId, "OTHER_ASSET", "ETF-Depot", "HOUSEHOLD", {
startValue: 120000,
annualContribution: 12000,
expectedReturn: 5,
});
await addElement(sid, firstPhaseId, "REAL_ESTATE", "Eigenheim", "HOUSEHOLD", {
purchasePrice: 900000,
mortgage: 600000,
amortization: 12000,
interestRate: 1.5,
valueGrowth: 1,
});
// Pensionsphase 65 -> 90. Die Route belegt die editierbaren Felder aus Phase 1 vor.
await api.post(`/api/scenarios/${sid}/phases`, { name: "Pensionsphase", durationYears: 25 });
return sid;
}
+8 -8
View File
@@ -15,7 +15,7 @@ function base(): PlanInput {
};
}
// Unveraenderte Kopie.
// Unveränderte Kopie.
function copy(): PlanInput {
return {
id: "S2", name: "Variante", householdType: "SINGLE", inflationRateDefault: 1.5, initialCash: 1000,
@@ -40,16 +40,16 @@ describe("Szenario-Diff", () => {
expect(computeScenarioDiff(base(), null).total).toBe(0);
});
it("geaenderter Zellwert wird genau einer Zelle zugeordnet", () => {
it("geänderter Zellwert wird genau einer Zelle zugeordnet", () => {
const c = copy();
c.elements[0].phaseValues["c-ph1"] = { amount: 120000 };
const d = computeScenarioDiff(c, base());
expect(d.phaseCell.has("c-e1:c-ph1")).toBe(true);
expect(d.phaseCell.size).toBe(1);
expect(d.elementRow.size).toBe(0); // Zeile selbst unveraendert
expect(d.elementRow.size).toBe(0); // Zeile selbst unverändert
});
it("geaenderter Uebergangs-Entscheid markiert die Uebergangszelle", () => {
it("geänderter Übergangs-Entscheid markiert die Übergangszelle", () => {
const c = copy();
c.elements[1].transitionValues["c-ph1"] = { decision: "SELL" };
const d = computeScenarioDiff(c, base());
@@ -57,16 +57,16 @@ describe("Szenario-Diff", () => {
expect(d.phaseCell.size).toBe(0);
});
it("neues Element ist 'added', geloeschtes wird als entfernt gemeldet", () => {
it("neues Element ist 'added', gelöschtes wird als entfernt gemeldet", () => {
const c = copy();
c.elements.push({ id: "c-e3", category: "OTHER_DEBT", name: "Kredit", ownerRole: "HOUSEHOLD", orderIndex: 3, phaseValues: {}, transitionValues: {}, sourceElementId: null });
c.elements = c.elements.filter((e) => e.id !== "c-e1"); // Lohn geloescht
c.elements = c.elements.filter((e) => e.id !== "c-e1"); // Lohn gelöscht
const d = computeScenarioDiff(c, base());
expect(d.elementRow.get("c-e3")).toBe("added");
expect(d.removedElements.map((r) => r.name)).toEqual(["Lohn"]);
});
it("Phasenkopf: Dauer/Name geaendert bzw. Phase neu", () => {
it("Phasenkopf: Dauer/Name geändert bzw. Phase neu", () => {
const c = copy();
c.phases[0].durationYears = 15;
c.phases.push({ id: "c-ph2", sequenceNumber: 2, name: "Pension", durationYears: 25, cashTransition: {}, sourcePhaseId: null });
@@ -84,7 +84,7 @@ describe("Szenario-Diff", () => {
expect(d.cashInitialChanged).toBe(true);
});
it("Cash-Uebergang wird erkannt", () => {
it("Cash-Übergang wird erkannt", () => {
const c = copy();
c.phases[0].cashTransition = { mode: "INFLOW", inflowAmount: 100000 };
const d = computeScenarioDiff(c, base());
+10 -10
View File
@@ -1,10 +1,10 @@
// Abweichungs-Erkennung zwischen einem Szenario und seinem ELTERN-Szenario.
//
// Die Zuordnung laeuft ueber die Herkunfts-Verweise, die beim Kopieren gesetzt werden
// (Phase.sourcePhaseId, FinancialElement.sourceElementId). Ueber den Namen zu matchen waere
// fragil: Umbenennen wuerde die Verknuepfung brechen und gleichnamige Elemente kollidieren.
// Die Zuordnung läuft über die Herkunfts-Verweise, die beim Kopieren gesetzt werden
// (Phase.sourcePhaseId, FinancialElement.sourceElementId). Über den Namen zu matchen wäre
// fragil: Umbenennen würde die Verknüpfung brechen und gleichnamige Elemente kollidieren.
//
// Der Vergleich ist LIVE gegen den aktuellen Stand des Elternteils -- aendert man dort einen
// Der Vergleich ist LIVE gegen den aktuellen Stand des Elternteils -- ändert man dort einen
// Wert, verschiebt sich die Markierung im Kind entsprechend.
import { num } from "@/lib/elements";
@@ -18,15 +18,15 @@ export interface ScenarioDiff {
elementRow: Map<string, DiffKind>;
// `${elementId}:${phaseId}` -> Zelle weicht ab.
phaseCell: Set<string>;
// `${elementId}:${fromPhaseId}` -> Uebergangs-Zelle weicht ab.
// `${elementId}:${fromPhaseId}` -> Übergangs-Zelle weicht ab.
transitionCell: Set<string>;
// phaseId -> Phasenkopf weicht ab (Name/Dauer) bzw. Phase ist neu.
phaseHeader: Map<string, DiffKind>;
// phaseId -> Cash-Uebergang nach dieser Phase weicht ab.
// phaseId -> Cash-Übergang nach dieser Phase weicht ab.
cashTransitionCell: Set<string>;
cashInitialChanged: boolean;
profileChanged: boolean;
// Im Elternteil vorhandene, hier geloeschte Elemente (fuer die Geisterzeilen).
// Im Elternteil vorhandene, hier gelöschte Elemente (für die Geisterzeilen).
removedElements: { id: string; name: string; category: ElementInput["category"] }[];
removedPhaseCount: number;
total: number;
@@ -48,7 +48,7 @@ export function emptyDiff(): ScenarioDiff {
}
// Vergleicht zwei JSON-Payloads feldweise; fehlend und 0 gelten als gleich, damit ein
// nicht gesetztes Feld nicht faelschlich als Aenderung erscheint.
// nicht gesetztes Feld nicht fälschlich als Änderung erscheint.
function sameData(a: object = {}, b: object = {}): boolean {
const ra = a as Record<string, unknown>;
const rb = b as Record<string, unknown>;
@@ -72,7 +72,7 @@ export function computeScenarioDiff(scenario: PlanInput, base: PlanInput | null)
// --- Phasen ---
const basePhaseById = new Map(base.phases.map((p) => [p.id, p]));
const usedBasePhases = new Set<string>();
// Zuordnung eigene Phase -> Eltern-Phase (fuer die Zellen-Vergleiche).
// Zuordnung eigene Phase -> Eltern-Phase (für die Zellen-Vergleiche).
const phaseToBase = new Map<string, string>();
for (const ph of scenario.phases) {
@@ -108,7 +108,7 @@ export function computeScenarioDiff(scenario: PlanInput, base: PlanInput | null)
d.elementRow.set(el.id, "changed");
}
// Zellen je Phase / Uebergang ueber die Phasen-Zuordnung vergleichen.
// Zellen je Phase / Übergang über die Phasen-Zuordnung vergleichen.
for (const ph of scenario.phases) {
const basePhaseId = phaseToBase.get(ph.id);
const mine = el.phaseValues[ph.id];
+22 -22
View File
@@ -1,4 +1,4 @@
// Typ- und Validierungs-Layer fuer die finanziellen Elemente. Die kategorie- und
// Typ- und Validierungs-Layer für die finanziellen Elemente. Die kategorie- und
// kontextspezifischen Felder liegen in der DB als JSON; hier werden sie typisiert und
// (an der API-Grenze) mit Zod validiert.
@@ -19,7 +19,7 @@ export type OwnerRole = "PERSON_A" | "PERSON_B" | "HOUSEHOLD";
// Kategorien, deren Element zwingend genau einer Person zugeordnet ist.
export const PERSON_ONLY_CATEGORIES: ElementCategory[] = ["AHV", "PENSION_FUND", "PILLAR_3A"];
// Kategorien, die gemeinsam ODER pro Person erfasst werden koennen (inkl. Einkommen).
// Kategorien, die gemeinsam ODER pro Person erfasst werden können (inkl. Einkommen).
export const OWNER_OPTIONAL_CATEGORIES: ElementCategory[] = ["INCOME", "EXPENSE", "REAL_ESTATE", "OTHER_ASSET", "OTHER_DEBT"];
export const CATEGORY_LABELS: Record<ElementCategory, string> = {
@@ -27,9 +27,9 @@ export const CATEGORY_LABELS: Record<ElementCategory, string> = {
EXPENSE: "Ausgaben",
AHV: "AHV",
PENSION_FUND: "Pensionskasse",
PILLAR_3A: "Saeule 3a",
PILLAR_3A: "Säule 3a",
REAL_ESTATE: "Immobilie",
OTHER_ASSET: "Sonstiges Vermoegen",
OTHER_ASSET: "Sonstiges Vermögen",
OTHER_DEBT: "Sonstige Schulden",
};
@@ -52,14 +52,14 @@ export const CATEGORY_ORDER: ElementCategory[] = [
export interface PhaseData {
// INCOME / EXPENSE
amount?: number;
// INCOME / EXPENSE: jaehrlicher Teuerungsausgleich (%). Indexiert den Flow ueber die
// INCOME / EXPENSE: jährlicher Teuerungsausgleich (%). Indexiert den Flow über die
// Phasenjahre (Jahr t = Basis x (1+idx)^(t-1)). Eigenes Feld je Element (Einkommen und
// Ausgaben unabhaengig). Default = Phaseninflation.
// Ausgaben unabhängig). Default = Phaseninflation.
teuerungsausgleich?: number;
// AHV
gapYears?: number;
// AHV, nur wenn die Person bei Planbeginn BEREITS pensioniert ist (dann gibt es keinen
// Pensions-Uebergang, an dem die Karriere geprueft werden koennte): Beitragskarriere.
// Pensions-Übergang, an dem die Karriere geprüft werden könnte): Beitragskarriere.
// avgIncomeBefore ist REAL (heutige Kaufkraft).
avgIncomeBefore?: number;
gapYearsBefore?: number;
@@ -68,10 +68,10 @@ export interface PhaseData {
startValue?: number;
expectedReturn?: number;
annualContribution?: number;
// OTHER_ASSET: jaehrliche Bezugsrate (Entnahme). Mindert das Vermoegen und fliesst ins Cash.
// OTHER_ASSET: jährliche Bezugsrate (Entnahme). Mindert das Vermögen und fliesst ins Cash.
annualWithdrawal?: number;
// PENSION_FUND / PILLAR_3A / OTHER_ASSET (ab Phase 2): zusaetzliche Einlage aus dem
// verfuegbaren Kapital der Phase. Der Basis-Startwert wird aus der Vorphase fortgeschrieben.
// PENSION_FUND / PILLAR_3A / OTHER_ASSET (ab Phase 2): zusätzliche Einlage aus dem
// verfügbaren Kapital der Phase. Der Basis-Startwert wird aus der Vorphase fortgeschrieben.
additionalInvestment?: number;
// REAL_ESTATE
purchasePrice?: number;
@@ -79,10 +79,10 @@ export interface PhaseData {
amortization?: number;
// Hypothekarzins in % der Restschuld. Der Zinsbetrag sinkt dadurch mit der Amortisation.
interestRate?: number;
// Steuert die Doppelzaehlung: Sind die Zinsen im Ausgaben-Element bereits enthalten
// (INCLUDED, Default -- Verhalten bisheriger Plaene) oder soll das Tool sie dazurechnen (ADD)?
// Steuert die Doppelzählung: Sind die Zinsen im Ausgaben-Element bereits enthalten
// (INCLUDED, Default -- Verhalten bisheriger Pläne) oder soll das Tool sie dazurechnen (ADD)?
interestHandling?: "INCLUDED" | "ADD";
// Geschaetzte jaehrliche Wertveraenderung der LIEGENSCHAFT (nicht des Eigenkapitals).
// Geschätzte jährliche Wertveränderung der LIEGENSCHAFT (nicht des Eigenkapitals).
valueGrowth?: number;
// OTHER_DEBT
annualRepayment?: number;
@@ -92,36 +92,36 @@ export type TransitionDecision = "HOLD" | "SELL" | "PARTIAL";
export type PkPayoutMode = "CAPITAL" | "PENSION" | "COMBI";
export interface TransitionData {
// AHV (Pensions-Uebergang): Pruefung der Beitragskarriere. Das geplante Durchschnitts-
// AHV (Pensions-Übergang): Prüfung der Beitragskarriere. Das geplante Durchschnitts-
// einkommen kommt aus dem Plan; reicht der Plan nicht bis zum Beitragsbeginn (Alter 21)
// zurueck, ergaenzt der Benutzer die Jahre davor. avgIncomeBefore ist REAL.
// zurück, ergänzt der Benutzer die Jahre davor. avgIncomeBefore ist REAL.
reviewed?: boolean;
avgIncomeBefore?: number;
gapYearsBefore?: number;
// PENSION_FUND / PILLAR_3A (normaler Uebergang): expliziter Bezugs-Entscheid.
// PENSION_FUND / PILLAR_3A (normaler Übergang): expliziter Bezugs-Entscheid.
withdrawalMode?: "NONE" | "AMOUNT";
withdrawal?: number;
// PENSION_FUND (Pensions-Uebergang)
// PENSION_FUND (Pensions-Übergang)
payoutMode?: PkPayoutMode;
capitalAmount?: number;
conversionRate?: number;
// PENSION_FUND (Kapital) / PILLAR_3A (Pensions-Uebergang) / REAL_ESTATE
// PENSION_FUND (Kapital) / PILLAR_3A (Pensions-Übergang) / REAL_ESTATE
capitalTaxRate?: number;
saleTaxRate?: number;
// REAL_ESTATE / OTHER_ASSET
decision?: TransitionDecision;
salePrice?: number;
// OTHER_ASSET (Teilverkauf): Betrag, der am Uebergang ins Cash fliesst; der Rest bleibt aktiv.
// OTHER_ASSET (Teilverkauf): Betrag, der am Übergang ins Cash fliesst; der Rest bleibt aktiv.
partialSaleAmount?: number;
// REAL_ESTATE (Sonderamortisation): Einmaltilgung der Hypothek am Uebergang, aus dem Cash.
// REAL_ESTATE (Sonderamortisation): Einmaltilgung der Hypothek am Übergang, aus dem Cash.
extraAmortization?: number;
// OTHER_DEBT
immediateRepayment?: number;
}
// --- Cash-Uebergang: einmalige Sonderein-/ausgaben ---
// --- Cash-Übergang: einmalige Sonderein-/ausgaben ---
// Entscheid am UEBERGANG zwischen zwei Phasen, direkt auf dem Cash-Konto (Cash ist kein
// Element, der Entscheid haengt darum an der Von-Phase). Erfassungs-Konventionen analog zu
// Element, der Entscheid hängt darum an der Von-Phase). Erfassungs-Konventionen analog zu
// den laufenden Flows: Zufluss NOMINAL (wie Einkommen), Kosten REAL (wie Ausgaben).
export type CashTransitionMode = "NONE" | "INFLOW" | "OUTFLOW" | "BOTH";
+17 -17
View File
@@ -7,7 +7,7 @@ import type { PlanInput } from "@/lib/types";
// Reichhaltiger Plan: Erwerbsphase mit Einkommen, Ausgaben, PK, 3a, Immobilie (Hypothek,
// Amortisation, Wertsteigerung) und einer Schuld; danach Pensionsphase mit Verzehr.
// Am Uebergang: PK-Verrentung, 3a-Bezug mit Steuer, Sonderamortisation, Einmalposten.
// Am Übergang: PK-Verrentung, 3a-Bezug mit Steuer, Sonderamortisation, Einmalposten.
function richPlan(): PlanInput {
return {
id: "p",
@@ -93,8 +93,8 @@ describe("Verlaufswerte je Element", () => {
const etf = c.phases[0].elements.find((e) => e.elementId === "asset")!;
expect(etf.yearly[0].value).toBe(220000);
expect(etf.yearly[1].value).toBe(Math.round(220000 * 1.05 + 10000));
// Konvexitaet: die Jahreszuwaechse werden von Jahr zu Jahr GROESSER. Eine Gerade
// zwischen den Phasengrenzen haette konstante Zuwaechse -- genau der Fehler, den die
// Konvexität: die Jahreszuwächse werden von Jahr zu Jahr GROESSER. Eine Gerade
// zwischen den Phasengrenzen hätte konstante Zuwächse -- genau der Fehler, den die
// per-Jahr-Werte vermeiden.
const deltas = etf.yearly.slice(1).map((p, i) => p.value - etf.yearly[i].value);
for (let i = 1; i < deltas.length; i++) {
@@ -102,7 +102,7 @@ describe("Verlaufswerte je Element", () => {
}
});
it("Immobilie fuehrt Verkehrswert und Restschuld getrennt mit", () => {
it("Immobilie führt Verkehrswert und Restschuld getrennt mit", () => {
const c = computePlan(richPlan());
const re = c.phases[0].elements.find((e) => e.elementId === "re")!;
const y0 = re.yearly[0];
@@ -112,8 +112,8 @@ describe("Verlaufswerte je Element", () => {
});
});
describe("Wasserfall-Bruecken", () => {
it("Vermoegens-Bruecke geht auf (Restposten ~ 0)", () => {
describe("Wasserfall-Brücken", () => {
it("Vermögens-Brücke geht auf (Restposten ~ 0)", () => {
const c = computePlan(richPlan());
for (const ph of c.phases) {
// Rundungen auf ganze Franken je Summand -> ein paar Franken Toleranz.
@@ -123,7 +123,7 @@ describe("Wasserfall-Bruecken", () => {
}
});
it("Cash-Bruecke geht auf (Restposten ~ 0)", () => {
it("Cash-Brücke geht auf (Restposten ~ 0)", () => {
const c = computePlan(richPlan());
for (const ph of c.phases) {
expect(Math.abs(ph.cashBridge.residual)).toBeLessThanOrEqual(5);
@@ -132,7 +132,7 @@ describe("Wasserfall-Bruecken", () => {
}
});
it("Sparraten erscheinen in der CASH-, nicht in der Vermoegens-Bruecke", () => {
it("Sparraten erscheinen in der CASH-, nicht in der Vermögens-Brücke", () => {
// Reiner Umbuchungs-Plan: 12'000 Sparbeitrag pro Jahr, 0 % Rendite, keine Flows.
const plan: PlanInput = {
id: "p", name: "T", householdType: "SINGLE", inflationRateDefault: 0, initialCash: 100000,
@@ -149,14 +149,14 @@ describe("Wasserfall-Bruecken", () => {
// Cash: 5 x 12'000 fliessen ab.
expect(ph.cashBridge.savingRates).toBe(60000);
expect(ph.cashEnd).toBe(100000 - 60000);
// Vermoegen: unveraendert -- das Geld ist nur umgezogen, kein Ertrag, keine Quote.
// Vermögen: unverändert -- das Geld ist nur umgezogen, kein Ertrag, keine Quote.
expect(ph.wealthBridge.quotaTotal).toBe(0);
expect(ph.wealthBridge.investmentReturn).toBe(0);
expect(ph.startWealthNominal).toBe(100000);
expect(ph.endWealthNominal).toBe(100000);
});
it("PK-Beitraege sind ein Vermoegenszugang, ohne das Cash zu belasten", () => {
it("PK-Beiträge sind ein Vermögenszugang, ohne das Cash zu belasten", () => {
const plan: PlanInput = {
id: "p", name: "T", householdType: "SINGLE", inflationRateDefault: 0, initialCash: 0,
persons: [{ id: "A", role: "PERSON_A", name: null, age: 40, retirementAge: 70 }],
@@ -174,18 +174,18 @@ describe("Wasserfall-Bruecken", () => {
expect(ph.endWealthNominal).toBe(40000);
});
it("Verrentung und Kapitalbezugssteuer erscheinen in der Vermoegens-Bruecke der Folgephase", () => {
it("Verrentung und Kapitalbezugssteuer erscheinen in der Vermögens-Brücke der Folgephase", () => {
const c = computePlan(richPlan());
const p2 = c.phases[1];
expect(p2.wealthBridge.pensionConversion).toBeGreaterThan(0); // PK wurde verrentet
expect(p2.wealthBridge.transitionTax).toBeGreaterThan(0); // 8 % auf den 3a-Bezug
expect(p2.wealthBridge.oneOffInflow).toBe(90000); // 100'000 abzueglich 10 % Steuer
expect(p2.wealthBridge.oneOffInflow).toBe(90000); // 100'000 abzüglich 10 % Steuer
expect(p2.wealthBridge.oneOffOutflow).toBeGreaterThan(20000); // real 20'000, nominal aufgewertet
});
});
describe("Rechenweg (explain)", () => {
it("ist standardmaessig aus und aendert das Ergebnis nicht", () => {
it("ist standardmässig aus und ändert das Ergebnis nicht", () => {
const plain = computePlan(richPlan());
const explained = computePlan(richPlan(), undefined, { explain: true });
@@ -208,7 +208,7 @@ describe("Rechenweg (explain)", () => {
expect((c.phases[0].traces ?? []).length).toBeGreaterThan(0);
const withTrace = c.phases[0].elements.filter((e) => e.trace);
// Einkommen, Ausgaben, PK, 3a, Immobilie, Vermoegen, Schuld
// Einkommen, Ausgaben, PK, 3a, Immobilie, Vermögen, Schuld
expect(withTrace.length).toBeGreaterThanOrEqual(7);
const withTxTrace = c.phases[0].elements.filter((e) => e.transitionTrace);
@@ -224,9 +224,9 @@ describe("Rechenweg (explain)", () => {
expect(endStep.result).toBe(inc.endValue);
});
// Der Rechenweg verlinkt in die SPEZIFIKATION. Wird dort eine Ueberschrift umbenannt,
// zeigen die Verweise ins Leere -- und zwar still. Dieser Test faengt das ab.
it("alle Spezifikations-Verweise treffen eine existierende Ueberschrift", () => {
// Der Rechenweg verlinkt in die SPEZIFIKATION. Wird dort eine Überschrift umbenannt,
// zeigen die Verweise ins Leere -- und zwar still. Dieser Test fängt das ab.
it("alle Spezifikations-Verweise treffen eine existierende Überschrift", () => {
const md = readFileSync(path.join(process.cwd(), "SPEZIFIKATION.md"), "utf8");
// Slug-Regel von github-slugger (das rehype-slug verwendet) nachgebildet.
const slug = (s: string) =>
+2 -2
View File
@@ -1,5 +1,5 @@
// Einheitliches Format fuer Geldbetraege im ganzen Tool: Tausendertrennzeichen mit
// geradem Apostroph, keine Nachkommastellen (z. B. 1'780'000). Bewusst nicht ueber
// Einheitliches Format für Geldbeträge im ganzen Tool: Tausendertrennzeichen mit
// geradem Apostroph, keine Nachkommastellen (z. B. 1'780'000). Bewusst nicht über
// Intl/toLocaleString("de-CH"), da dessen Gruppentrennzeichen ( U+2019) nicht dem
// geraden Apostroph entspricht.
export function formatChf(value: number): string {
+11 -11
View File
@@ -1,6 +1,6 @@
// Spielt ALLE Migrationen gegen ein echtes PostgreSQL (PGlite, in-process) ein und prueft das
// Ergebnis. Faengt kaputte oder nicht-idempotente Migrations-SQL ab, bevor sie beim Deploy
// gegen die Live-Datenbank laufen -- lokal steht sonst keine Datenbank zur Verfuegung.
// Spielt ALLE Migrationen gegen ein echtes PostgreSQL (PGlite, in-process) ein und prüft das
// Ergebnis. Fängt kaputte oder nicht-idempotente Migrations-SQL ab, bevor sie beim Deploy
// gegen die Live-Datenbank laufen -- lokal steht sonst keine Datenbank zur Verfügung.
import { describe, it, expect } from "vitest";
import { PGlite } from "@electric-sql/pglite";
import { readFileSync, readdirSync } from "node:fs";
@@ -9,7 +9,7 @@ import path from "node:path";
const MIG = path.join(process.cwd(), "prisma", "migrations");
describe("Datenbank-Migrationen", () => {
it("laufen vollstaendig durch und ergeben das erwartete Schema", async () => {
it("laufen vollständig durch und ergeben das erwartete Schema", async () => {
const db = await PGlite.create();
const dirs = readdirSync(MIG).filter((d) => !d.endsWith(".toml")).sort();
expect(dirs.length).toBeGreaterThan(0);
@@ -24,7 +24,7 @@ describe("Datenbank-Migrationen", () => {
)
).rows.map((r) => r.table_name);
// V6-Struktur: Behaelter Plan + berechenbares Scenario.
// V6-Struktur: Behälter Plan + berechenbares Scenario.
for (const t of ["User", "Plan", "Scenario", "Person", "Phase", "FinancialElement"]) {
expect(tables, `Tabelle ${t} fehlt`).toContain(t);
}
@@ -37,27 +37,27 @@ describe("Datenbank-Migrationen", () => {
)
).rows.map((r) => r.column_name);
// Kind-Tabellen haengen am Szenario, nicht mehr am Plan.
// Kind-Tabellen hängen am Szenario, nicht mehr am Plan.
for (const t of ["Person", "Phase", "FinancialElement"]) {
const c = await cols(t);
expect(c, `${t}.scenarioId fehlt`).toContain("scenarioId");
expect(c, `${t}.planId haette entfernt werden muessen`).not.toContain("planId");
expect(c, `${t}.planId hätte entfernt werden müssen`).not.toContain("planId");
}
// Herkunfts-Verweise fuer den Diff.
// Herkunfts-Verweise für den Diff.
expect(await cols("Phase")).toContain("sourcePhaseId");
expect(await cols("FinancialElement")).toContain("sourceElementId");
// Der Plan traegt keine Finanzdaten mehr.
// Der Plan trägt keine Finanzdaten mehr.
const planCols = await cols("Plan");
expect(planCols).not.toContain("householdType");
expect(planCols).toContain("userId");
// Das Szenario traegt sie.
// Das Szenario trägt sie.
const scenCols = await cols("Scenario");
for (const c of ["planId", "isBase", "parentScenarioId", "householdType", "initialCash"]) {
expect(scenCols, `Scenario.${c} fehlt`).toContain(c);
}
expect(scenCols).not.toContain("userId"); // Eigentuemer haengt am Plan
expect(scenCols).not.toContain("userId"); // Eigentümer hängt am Plan
}, 60000);
});
+21 -21
View File
@@ -12,8 +12,8 @@ import {
} from "@/lib/montecarlo";
import type { PlanInput } from "@/lib/types";
// Plan: 40-jaehrig, 1 Phase 10 Jahre, ein Sonstiges Vermoegen 100'000 @ 5 %, 2 % Inflation.
// Deterministisch: 100'000 x 1.05^10 = 162'889 Endvermoegen, kein Ruin.
// Plan: 40-jährig, 1 Phase 10 Jahre, ein Sonstiges Vermögen 100'000 @ 5 %, 2 % Inflation.
// Deterministisch: 100'000 x 1.05^10 = 162'889 Endvermögen, kein Ruin.
function basePlan(): PlanInput {
return {
id: "p", name: "T", householdType: "SINGLE", inflationRateDefault: 2, initialCash: 0,
@@ -49,15 +49,15 @@ describe("Monte Carlo", () => {
const det = detEnd();
expect(r.finalWealthMedian).toBe(det);
expect(r.finalWealthP10).toBe(det);
expect(r.finalWealthP90).toBe(det); // Baender kollabieren auf die deterministische Linie
expect(r.finalWealthP90).toBe(det); // Bänder kollabieren auf die deterministische Linie
expect(r.ruinProbability).toBe(0);
expect(r.bands[r.bands.length - 1].p50).toBe(det);
});
it("Volatilitaet spreizt den Faecher (p90 > p10)", async () => {
it("Volatilität spreizt den Fächer (p90 > p10)", async () => {
const r = await runMonteCarlo(basePlan(), params({ elements: { asset: { mean: 5, sigma: 20, floor: -100 } } }));
expect(r.finalWealthP90).toBeGreaterThan(r.finalWealthP10);
// Median bleibt in der Naehe des deterministischen Werts (leicht darunter wegen Vol-Drag).
// Median bleibt in der Nähe des deterministischen Werts (leicht darunter wegen Vol-Drag).
expect(r.finalWealthMedian).toBeLessThan(r.finalWealthP90);
expect(r.finalWealthMedian).toBeGreaterThan(r.finalWealthP10);
});
@@ -79,8 +79,8 @@ describe("Monte Carlo", () => {
expect(a.ruinProbability).toBe(b.ruinProbability);
});
it("Boden 0 % (PK/3a): Rendite nie negativ -> Endvermoegen nie unter dem Startwert", async () => {
// Ohne Beitraege kann ein bei 0 % gebodetes Asset nur wachsen oder gleich bleiben.
it("Boden 0 % (PK/3a): Rendite nie negativ -> Endvermögen nie unter dem Startwert", async () => {
// Ohne Beiträge kann ein bei 0 % gebodetes Asset nur wachsen oder gleich bleiben.
const r = await runMonteCarlo(
basePlan(),
params({ elements: { asset: { mean: 0, sigma: 80, floor: 0 } } })
@@ -88,8 +88,8 @@ describe("Monte Carlo", () => {
expect(r.finalWealthP10).toBeGreaterThanOrEqual(100000);
});
it("Ruinwahrscheinlichkeit: sicherer Verzehr fuehrt immer in den Ruin", async () => {
// Rente 20k, Ausgaben 60k, kleines Vermoegen -> deterministisch Ruin, auch ohne Streuung.
it("Ruinwahrscheinlichkeit: sicherer Verzehr führt immer in den Ruin", async () => {
// Rente 20k, Ausgaben 60k, kleines Vermögen -> deterministisch Ruin, auch ohne Streuung.
const plan: PlanInput = {
id: "p", name: "T", householdType: "SINGLE", inflationRateDefault: 0, initialCash: 0,
persons: [{ id: "A", role: "PERSON_A", name: null, age: 65, retirementAge: 65 }],
@@ -104,7 +104,7 @@ describe("Monte Carlo", () => {
runs: 1000, inflationMean: 0, inflationSigma: 0,
elements: { asset: { mean: 0, sigma: 10, floor: -100 } }, target: 0, seed: 1,
});
expect(r.ruinProbability).toBe(1); // Verzehr uebersteigt Rente + Vermoegen in jedem Pfad
expect(r.ruinProbability).toBe(1); // Verzehr übersteigt Rente + Vermögen in jedem Pfad
});
it("Hilfsfunktionen: Default-Stufen und σ-Tabelle", () => {
@@ -118,7 +118,7 @@ describe("Monte Carlo", () => {
// --- Mehrere Szenarien im selben Lauf ----------------------------------------------------
// Kopie des Basisplans mit neuen Ids; das Element verweist per sourceElementId auf sein
// Gegenstueck -- genau wie es die Kopier-Route beim Anlegen eines Szenarios setzt.
// Gegenstück -- genau wie es die Kopier-Route beim Anlegen eines Szenarios setzt.
function copyPlan(source: PlanInput, suffix: string, expectedReturn: number): PlanInput {
return {
...source,
@@ -133,7 +133,7 @@ function copyPlan(source: PlanInput, suffix: string, expectedReturn: number): Pl
}
describe("Monte Carlo: mehrere Szenarien", () => {
it("loest die Herkunfts-Kette bis zum Ursprung auf", () => {
it("löst die Herkunfts-Kette bis zum Ursprung auf", () => {
const chain = new Map<string, string | null>([
["a", null],
["b", "a"],
@@ -141,13 +141,13 @@ describe("Monte Carlo: mehrere Szenarien", () => {
]);
expect(resolveRootElementId("c", chain)).toBe("a");
expect(resolveRootElementId("a", chain)).toBe("a");
// Verweis ins Leere (Vorlage geloescht): das Element ist selbst die Wurzel.
// Verweis ins Leere (Vorlage gelöscht): das Element ist selbst die Wurzel.
expect(resolveRootElementId("x", new Map([["x", "weg"]]))).toBe("x");
// Defekte Kette darf nicht zur Endlosschleife fuehren.
// Defekte Kette darf nicht zur Endlosschleife führen.
expect(resolveRootElementId("p", new Map([["p", "q"], ["q", "p"]]))).toBeDefined();
});
it("fasst dasselbe Element ueber Szenarien zu EINER Gruppe zusammen", () => {
it("fasst dasselbe Element über Szenarien zu EINER Gruppe zusammen", () => {
const base = basePlan();
const s2 = copyPlan(base, "s2", 6);
const groups = buildElementGroups(
@@ -160,13 +160,13 @@ describe("Monte Carlo: mehrere Szenarien", () => {
expect(groups[0].scenarioIds.sort()).toEqual(["base", "s2"]);
});
it("loest die Kette auch ueber ein NICHT ausgewaehltes Zwischen-Szenario auf", () => {
it("löst die Kette auch über ein NICHT ausgewähltes Zwischen-Szenario auf", () => {
const base = basePlan();
const s1 = copyPlan(base, "s1", 6);
const s2 = copyPlan(s1, "s2", 7); // zeigt auf s1, nicht auf base
const groups = buildElementGroups(
[{ id: "base", plan: base }, { id: "s1", plan: s1 }, { id: "s2", plan: s2 }],
["base", "s2"] // s1 ist nur zur Aufloesung geladen
["base", "s2"] // s1 ist nur zur Auflösung geladen
);
expect(groups).toHaveLength(1);
expect(groups[0].rootId).toBe("asset");
@@ -191,7 +191,7 @@ describe("Monte Carlo: mehrere Szenarien", () => {
expect(neu.scenarioIds).toEqual(["s2"]); // im Basisszenario ohne Wirkung
});
it("uebersetzt die Gruppen-Parameter auf die Element-Ids des jeweiligen Szenarios", () => {
it("übersetzt die Gruppen-Parameter auf die Element-Ids des jeweiligen Szenarios", () => {
const base = basePlan();
const s2 = copyPlan(base, "s2", 6);
const groups = buildElementGroups([{ id: "base", plan: base }, { id: "s2", plan: s2 }], ["base", "s2"]);
@@ -203,9 +203,9 @@ describe("Monte Carlo: mehrere Szenarien", () => {
it("gleicher Seed: nur die GEPLANTE Rendite unterscheidet sich -> identische Verteilung, andere Erfolgsquote", async () => {
// Der Kern-Anwendungsfall: dasselbe Portfolio, einmal pessimistisch (5 %) und einmal
// optimistisch (6 %) geplant. In der Simulation wird die geplante Rendite ersetzt, also
// sind beide Verlaeufe identisch -- der Unterschied liegt allein im Zielbetrag, der aus
// der jeweiligen Planung stammt. Das pessimistische Szenario haelt sein (tieferes)
// Versprechen oefter.
// sind beide Verläufe identisch -- der Unterschied liegt allein im Zielbetrag, der aus
// der jeweiligen Planung stammt. Das pessimistische Szenario hält sein (tieferes)
// Versprechen öfter.
const base = basePlan(); // expectedReturn 5
const optimistisch = copyPlan(base, "opt", 6);
+32 -32
View File
@@ -1,11 +1,11 @@
// Monte-Carlo-Simulation (Roadmap Nr. 19, Stufe A). Laeuft vollstaendig im Browser, weil
// computePlan eine reine Funktion ohne Server-Abhaengigkeiten ist.
// Monte-Carlo-Simulation (Roadmap Nr. 19, Stufe A). Läuft vollständig im Browser, weil
// computePlan eine reine Funktion ohne Server-Abhängigkeiten ist.
//
// Modell: Pro Jahr wird EIN gemeinsamer Marktschock gezogen (damit riskante Anlagen zusammen
// fallen, nicht gegeneinander). Jede Rendite = historischer Mittelwert + Standardabweichung x
// (Marktanteil x Marktschock + Eigenanteil x Eigenrauschen). Beide Schocks sind fettschwaenzig
// (standardisierte Student-t), damit Extremcrashs realistisch haeufig auftreten -- eine
// Normalverteilung wuerde sie stark unterschaetzen. Boeden: 0 % fuer PK/3a, -100 % sonst.
// (Marktanteil x Marktschock + Eigenanteil x Eigenrauschen). Beide Schocks sind fettschwänzig
// (standardisierte Student-t), damit Extremcrashs realistisch häufig auftreten -- eine
// Normalverteilung würde sie stark unterschätzen. Böden: 0 % für PK/3a, -100 % sonst.
import { computePlan, type PlanSample } from "@/lib/calculations";
import type { ElementCategory } from "@/lib/elements";
@@ -23,7 +23,7 @@ export const RETURN_VOLATILITY_LEVELS = {
} as const;
// Inflation ist in der Schweiz historisch stabil (Standardabweichung der letzten 20 Jahre
// ~1 %). Hoehere Stufen ergaeben Hyperinflations-Annahmen -- deshalb nur zwei Stufen.
// ~1 %). Höhere Stufen ergäben Hyperinflations-Annahmen -- deshalb nur zwei Stufen.
export const INFLATION_VOLATILITY_LEVELS = {
sehr_niedrig: 1,
niedrig: 2,
@@ -56,7 +56,7 @@ export function floorFor(category: ElementCategory): number {
export interface ElementMcParams {
mean: number; // historische Durchschnittsrendite (%/Jahr)
sigma: number; // Standardabweichung (%/Jahr)
floor: number; // 0 fuer PK/3a, -100 sonst
floor: number; // 0 für PK/3a, -100 sonst
}
export interface MonteCarloParams {
@@ -64,42 +64,42 @@ export interface MonteCarloParams {
inflationMean: number;
inflationSigma: number;
elements: Record<string, ElementMcParams>; // key = elementId
target: number; // Zielbetrag fuer die Erfolgswahrscheinlichkeit (nominal)
target: number; // Zielbetrag für die Erfolgswahrscheinlichkeit (nominal)
seed?: number;
}
export interface MonteCarloResult {
runs: number;
ruinProbability: number; // P(Vermoegen faellt vor Planende unter 0)
successProbability: number; // P(Endvermoegen >= Zielbetrag)
ruinProbability: number; // P(Vermögen fällt vor Planende unter 0)
successProbability: number; // P(Endvermögen >= Zielbetrag)
finalWealthP10: number;
finalWealthMedian: number;
finalWealthP90: number;
// Faecher ueber das Alter: je Alterspunkt der pessimistische/mittlere/optimistische Wert.
// Fächer über das Alter: je Alterspunkt der pessimistische/mittlere/optimistische Wert.
bands: { age: number; p10: number; p50: number; p90: number }[];
}
// --- Mehrere Szenarien im selben Lauf vergleichen -------------------------------------
//
// Ein "logisches" Element ueber Szenariogrenzen hinweg: Beim Kopieren eines Szenarios
// erhaelt jedes Element eine NEUE Id plus einen Verweis auf sein Gegenstueck in der Vorlage
// (sourceElementId -- dieselbe Kette, auf der auch der Diff beruht). Ueber diese Kette wird
// dieselbe Anlage in mehreren Szenarien wiedergefunden. Das ist die Voraussetzung dafuer,
// die historischen Annahmen nur EINMAL zu erfassen: Ein Vergleich ist nur dann aussagekraeftig,
// wenn alle Szenarien mit denselben Marktannahmen gewuerfelt werden -- sonst vergleicht man
// Ein "logisches" Element über Szenariogrenzen hinweg: Beim Kopieren eines Szenarios
// erhält jedes Element eine NEUE Id plus einen Verweis auf sein Gegenstück in der Vorlage
// (sourceElementId -- dieselbe Kette, auf der auch der Diff beruht). Über diese Kette wird
// dieselbe Anlage in mehreren Szenarien wiedergefunden. Das ist die Voraussetzung dafür,
// die historischen Annahmen nur EINMAL zu erfassen: Ein Vergleich ist nur dann aussagekräftig,
// wenn alle Szenarien mit denselben Marktannahmen gewürfelt werden -- sonst vergleicht man
// die Eingaben statt der Szenarien.
export interface ElementGroup {
rootId: string; // Id des Ursprungs-Elements (Anker der Parametereingabe)
name: string;
category: ElementCategory;
memberIds: string[]; // Element-Ids ueber alle ausgewaehlten Szenarien
memberIds: string[]; // Element-Ids über alle ausgewählten Szenarien
scenarioIds: string[]; // Szenarien, in denen dieses Element vorkommt
}
// Folgt sourceElementId bis zum Ursprung. Bricht ab, sobald der Verweis ins Leere zeigt
// (Vorlage geloescht oder Szenario nicht geladen) -- dann ist dieses Element selbst die
// (Vorlage gelöscht oder Szenario nicht geladen) -- dann ist dieses Element selbst die
// Wurzel und bildet eine eigene Gruppe. Der Zyklusschutz ist reine Vorsicht: die Verweise
// sind lose (kein FK), eine defekte Kette darf nicht zur Endlosschleife fuehren.
// sind lose (kein FK), eine defekte Kette darf nicht zur Endlosschleife führen.
export function resolveRootElementId(elementId: string, sourceById: Map<string, string | null>): string {
let current = elementId;
const seen = new Set<string>();
@@ -112,9 +112,9 @@ export function resolveRootElementId(elementId: string, sourceById: Map<string,
return current;
}
// `all` enthaelt ALLE Szenarien des Plans (auch nicht ausgewaehlte) -- nur so laesst sich die
// Kette ueber ein uebersprungenes Zwischen-Szenario hinweg aufloesen (Basis -> S1 -> S2, wenn
// nur Basis und S2 ausgewaehlt sind). Gruppen entstehen nur fuer die ausgewaehlten Szenarien.
// `all` enthält ALLE Szenarien des Plans (auch nicht ausgewählte) -- nur so lässt sich die
// Kette über ein übersprungenes Zwischen-Szenario hinweg auflösen (Basis -> S1 -> S2, wenn
// nur Basis und S2 ausgewählt sind). Gruppen entstehen nur für die ausgewählten Szenarien.
export function buildElementGroups(
all: { id: string; plan: PlanInput }[],
selectedIds: string[]
@@ -154,7 +154,7 @@ export function buildElementGroups(
return [...groups.values()];
}
// Uebersetzt die je Gruppe erfassten Parameter auf die Element-Ids EINES Szenarios.
// Übersetzt die je Gruppe erfassten Parameter auf die Element-Ids EINES Szenarios.
export function paramsForScenario(
plan: PlanInput,
groups: ElementGroup[],
@@ -186,9 +186,9 @@ export interface ScenarioMcResult extends MonteCarloResult {
target: number;
}
// Fuehrt dieselbe Simulation fuer mehrere Szenarien aus -- mit DEMSELBEN Seed. Ohne das
// waeren kleine Unterschiede blosses Rauschen (bei 1'000 Laeufen betraegt der Standardfehler
// der Erfolgswahrscheinlichkeit rund 1.5 Prozentpunkte, zwei identische Szenarien koennten
// Führt dieselbe Simulation für mehrere Szenarien aus -- mit DEMSELBEN Seed. Ohne das
// wären kleine Unterschiede blosses Rauschen (bei 1'000 Läufen beträgt der Standardfehler
// der Erfolgswahrscheinlichkeit rund 1.5 Prozentpunkte, zwei identische Szenarien könnten
// also 87 % und 90 % zeigen). Mit gemeinsamem Seed teilen strukturgleiche Szenarien dieselben
// Marktpfade, und die Unterschiede sind rein strukturell (Common Random Numbers).
export async function runMonteCarloMulti(
@@ -242,7 +242,7 @@ function normal(rng: () => number): number {
}
// Standardisierte Student-t mit nu Freiheitsgraden (Einheitsvarianz -> die eingegebene
// Standardabweichung bleibt die tatsaechliche). Fette Raender: nu = 5.
// Standardabweichung bleibt die tatsächliche). Fette Ränder: nu = 5.
const NU = 5;
function studentT(rng: () => number): number {
let chi2 = 0;
@@ -265,7 +265,7 @@ function percentile(sortedAsc: number[], p: number): number {
return sortedAsc[idx];
}
// Ein Alterspunkt je Phasenanfang plus das Planende (wie im Vermoegensverlauf-Chart).
// Ein Alterspunkt je Phasenanfang plus das Planende (wie im Vermögensverlauf-Chart).
function agePointsOf(plan: PlanInput): number[] {
const phases = [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber);
const startAge = plan.persons.find((p) => p.role === "PERSON_A")?.age ?? plan.persons[0]?.age ?? 0;
@@ -278,7 +278,7 @@ function agePointsOf(plan: PlanInput): number[] {
return points;
}
// Wert je Alterspunkt fuer EINEN Durchlauf (Start-/Endvermoegen der Phasen, nominal).
// Wert je Alterspunkt für EINEN Durchlauf (Start-/Endvermögen der Phasen, nominal).
function wealthTrajectory(computed: ReturnType<typeof computePlan>): number[] {
const phases = computed.phases;
if (phases.length === 0) return [];
@@ -306,7 +306,7 @@ export async function runMonteCarlo(
const elementIds = Object.keys(params.elements);
for (let run = 0; run < params.runs; run++) {
// Pro Jahr ein Marktschock; pro Element/Jahr eine Rendite (fette Raender, gebodet).
// Pro Jahr ein Marktschock; pro Element/Jahr eine Rendite (fette Ränder, gebodet).
const inflation: number[] = new Array(totalYears);
for (let y = 0; y < totalYears; y++) {
inflation[y] = params.inflationMean + params.inflationSigma * studentT(rng);
@@ -338,7 +338,7 @@ export async function runMonteCarlo(
finalWealth.push(fw);
if (fw >= params.target) successCount++;
// Alle ~500 Laeufe die Kontrolle abgeben, damit die Oberflaeche nicht einfriert.
// Alle ~500 Läufe die Kontrolle abgeben, damit die Oberfläche nicht einfriert.
if (run % 500 === 499) {
onProgress?.(run + 1, params.runs);
await new Promise((r) => setTimeout(r, 0));
+7 -7
View File
@@ -73,10 +73,10 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
};
}
// --- Ownership-Abfragen. Der Eigentuemer haengt neu am PLAN; Szenario/Phase/Element
// erben ihn ueber die Kette Scenario -> Plan -> User. ---
// --- Ownership-Abfragen. Der Eigentümer hängt neu am PLAN; Szenario/Phase/Element
// erben ihn über die Kette Scenario -> Plan -> User. ---
// Laedt ein Szenario inkl. Profil + Phasen + Elemente, nur wenn es dem Benutzer gehoert.
// Lädt ein Szenario inkl. Profil + Phasen + Elemente, nur wenn es dem Benutzer gehört.
export async function getOwnedScenario(scenarioId: string, userId: string) {
return prisma.scenario.findFirst({
where: { id: scenarioId, plan: { userId } },
@@ -84,7 +84,7 @@ export async function getOwnedScenario(scenarioId: string, userId: string) {
});
}
// Wie oben, zusaetzlich mit den Kopfdaten (Plan, Basis-Flag, Elternteil).
// Wie oben, zusätzlich mit den Kopfdaten (Plan, Basis-Flag, Elternteil).
export async function getOwnedScenarioWithMeta(scenarioId: string, userId: string) {
return prisma.scenario.findFirst({
where: { id: scenarioId, plan: { userId } },
@@ -92,7 +92,7 @@ export async function getOwnedScenarioWithMeta(scenarioId: string, userId: strin
});
}
// Laedt einen Plan (Behaelter) inkl. Szenario-Kopfdaten.
// Lädt einen Plan (Behälter) inkl. Szenario-Kopfdaten.
export async function getOwnedPlan(planId: string, userId: string) {
return prisma.plan.findFirst({
where: { id: planId, userId },
@@ -100,14 +100,14 @@ export async function getOwnedPlan(planId: string, userId: string) {
});
}
// Laedt eine Phase (Basisdaten), aber nur wenn sie dem Benutzer gehoert.
// Lädt eine Phase (Basisdaten), aber nur wenn sie dem Benutzer gehört.
export async function getOwnedPhase(phaseId: string, userId: string) {
return prisma.phase.findFirst({
where: { id: phaseId, scenario: { plan: { userId } } },
});
}
// Laedt ein Element (Basisdaten), aber nur wenn es dem Benutzer gehoert.
// Lädt ein Element (Basisdaten), aber nur wenn es dem Benutzer gehört.
export async function getOwnedElement(elementId: string, userId: string) {
return prisma.financialElement.findFirst({
where: { id: elementId, scenario: { plan: { userId } } },
+19 -19
View File
@@ -9,8 +9,8 @@ import {
} from "@/lib/sensitivity";
import type { PlanInput } from "@/lib/types";
// Plan: 45-jaehrig, zwei Phasen (20 J. Erwerb + 20 J. Pension), Einkommen 100'000 netto,
// Ausgaben 70'000 real, ein Sonstiges Vermoegen 200'000 @ 4 %, Inflation 1.5 %.
// Plan: 45-jährig, zwei Phasen (20 J. Erwerb + 20 J. Pension), Einkommen 100'000 netto,
// Ausgaben 70'000 real, ein Sonstiges Vermögen 200'000 @ 4 %, Inflation 1.5 %.
function basePlan(): PlanInput {
return {
id: "p",
@@ -58,7 +58,7 @@ function basePlan(): PlanInput {
};
}
// Plan mit Immobilie -- wahlweise gehalten oder am Uebergang verkauft.
// Plan mit Immobilie -- wahlweise gehalten oder am Übergang verkauft.
function planMitImmobilie(verkaufen: boolean): PlanInput {
const p = basePlan();
return {
@@ -81,8 +81,8 @@ function planMitImmobilie(verkaufen: boolean): PlanInput {
};
}
describe("Sensitivitaet: applyDriver", () => {
it("laesst den Ausgangsplan unberuehrt (rein)", () => {
describe("Sensitivität: applyDriver", () => {
it("lässt den Ausgangsplan unberührt (rein)", () => {
const p = basePlan();
const snapshot = JSON.stringify(p);
applyDriver(p, "inflation", 3);
@@ -101,7 +101,7 @@ describe("Sensitivitaet: applyDriver", () => {
const asset = p.elements.find((e) => e.id === "asset")!;
expect(asset.phaseValues.p1.expectedReturn).toBe(6);
expect(asset.phaseValues.p2.expectedReturn).toBe(6);
// Einkommen/Ausgaben bleiben unberuehrt.
// Einkommen/Ausgaben bleiben unberührt.
expect(p.elements.find((e) => e.id === "inc")!.phaseValues.p1.amount).toBe(100000);
});
@@ -126,8 +126,8 @@ describe("Sensitivitaet: applyDriver", () => {
});
});
describe("Sensitivitaet: Treiber-Verfuegbarkeit", () => {
it("blendet Treiber aus, fuer die es keine passenden Elemente gibt", () => {
describe("Sensitivität: Treiber-Verfügbarkeit", () => {
it("blendet Treiber aus, für die es keine passenden Elemente gibt", () => {
const p = basePlan();
expect(driverById("propertyGrowth").applies(p)).toBe(false); // keine Immobilie
expect(driverById("returns").applies(p)).toBe(true);
@@ -143,8 +143,8 @@ describe("Sensitivitaet: Treiber-Verfuegbarkeit", () => {
});
});
describe("Sensitivitaet: Tornado", () => {
it("Basiswert entspricht dem unveraenderten Plan", () => {
describe("Sensitivität: Tornado", () => {
it("Basiswert entspricht dem unveränderten Plan", () => {
const p = basePlan();
const result = computeTornado(p, "real", [{ id: "inflation", low: 0.5, high: 3.5 }]);
const last = computePlan(p).phases.at(-1)!;
@@ -163,19 +163,19 @@ describe("Sensitivitaet: Tornado", () => {
// Ein Treiber ohne Bandbreite kann nichts bewegen und landet zuunterst.
expect(result.bars.at(-1)!.id).toBe("salaryGrowth");
expect(result.bars.at(-1)!.swing).toBe(0);
// Welcher Treiber oben steht, haengt vom konkreten Plan ab -- genau das ist die Aussage
// Welcher Treiber oben steht, hängt vom konkreten Plan ab -- genau das ist die Aussage
// des Tornados und deshalb bewusst nicht fix getestet.
});
it("kehrt die Richtung korrekt ab: hoehere Ausgaben -> tieferes Endvermoegen", () => {
it("kehrt die Richtung korrekt ab: höhere Ausgaben -> tieferes Endvermögen", () => {
const [bar] = computeTornado(basePlan(), "real", [{ id: "expenses", low: -15, high: 15 }]).bars;
expect(bar.lowResult).toBeGreaterThan(bar.highResult); // tiefe Ausgaben = mehr Vermoegen
expect(bar.lowResult).toBeGreaterThan(bar.highResult); // tiefe Ausgaben = mehr Vermögen
expect(bar.min).toBe(bar.highResult);
expect(bar.max).toBe(bar.lowResult);
expect(bar.swing).toBe(bar.max - bar.min);
});
it("hoehere Rendite -> hoeheres Endvermoegen", () => {
it("höhere Rendite -> höheres Endvermögen", () => {
const [bar] = computeTornado(basePlan(), "real", [{ id: "returns", low: -2, high: 2 }]).bars;
expect(bar.highResult).toBeGreaterThan(bar.lowResult);
});
@@ -183,13 +183,13 @@ describe("Sensitivitaet: Tornado", () => {
it("identische Bandbreite ergibt Spannweite 0", () => {
const [bar] = computeTornado(basePlan(), "real", [{ id: "inflation", low: 2, high: 2 }]).bars;
expect(bar.swing).toBe(0);
expect(bar.note).toBeTruthy(); // Nullbalken bekommt immer eine Erklaerung
expect(bar.note).toBeTruthy(); // Nullbalken bekommt immer eine Erklärung
});
it("verkaufte Immobilie: Wertsteigerung ist nachweislich wirkungslos und wird erklaert", () => {
// Beim Verkauf ist der Erloes `Verkaufspreis - Hypothek - Steuer` und haengt am ERFASSTEN
it("verkaufte Immobilie: Wertsteigerung ist nachweislich wirkungslos und wird erklärt", () => {
// Beim Verkauf ist der Erlös `Verkaufspreis - Hypothek - Steuer` und hängt am ERFASSTEN
// Preis, nicht am modellierten Verkehrswert. Die aufgelaufene Wertsteigerung wird damit
// verworfen -- der Treiber kann das Endvermoegen nicht mehr bewegen.
// verworfen -- der Treiber kann das Endvermögen nicht mehr bewegen.
const gehalten = computeTornado(planMitImmobilie(false), "real", [{ id: "propertyGrowth", low: 0.5, high: 2.5 }]);
const verkauft = computeTornado(planMitImmobilie(true), "real", [{ id: "propertyGrowth", low: 0.5, high: 2.5 }]);
@@ -206,7 +206,7 @@ describe("Sensitivitaet: Tornado", () => {
const real = planMetric(p, "real");
const nominal = planMetric(p, "nominal");
const last = computePlan(p).phases.at(-1)!;
expect(nominal).toBeGreaterThan(real); // 1.5 % Inflation ueber 40 Jahre
expect(nominal).toBeGreaterThan(real); // 1.5 % Inflation über 40 Jahre
expect(real).toBe(Math.round(nominal / last.cumulativeInflationEnd));
});
});
+31 -31
View File
@@ -1,17 +1,17 @@
// Sensitivitaetsanalyse / Tornado (Roadmap Nr. 20). Beantwortet nicht "wie viel Geld habe
// ich am Schluss", sondern "welche meiner Annahmen entscheidet ueberhaupt ueber das Ergebnis".
// Sensitivitätsanalyse / Tornado (Roadmap Nr. 20). Beantwortet nicht "wie viel Geld habe
// ich am Schluss", sondern "welche meiner Annahmen entscheidet überhaupt über das Ergebnis".
//
// Verfahren: One-at-a-time (OAT). Je Treiber wird EIN Parameter auf seinen tiefen und seinen
// hohen Wert gesetzt, alle uebrigen bleiben auf dem Planwert; die Differenz der beiden
// hohen Wert gesetzt, alle übrigen bleiben auf dem Planwert; die Differenz der beiden
// Ergebnisse ist die Spannweite. Nach Spannweite sortiert ergibt sich die Trichterform.
//
// Zwei bewusste Grenzen (im Dialog ausgewiesen, siehe SPEZIFIKATION 9.18):
// 1. Die Balkenlaenge haengt von den eingegebenen Bandbreiten ab -- deshalb gibt es hier
// 1. Die Balkenlänge hängt von den eingegebenen Bandbreiten ab -- deshalb gibt es hier
// KEINE Defaults, die Bandbreite ist je Treiber Pflichteingabe.
// 2. OAT sieht keine Wechselwirkungen (tiefe Rendite UND hohe Ausgaben treffen haerter als
// die Summe der Einzelbalken). Dafuer ist die Monte-Carlo-Simulation zustaendig.
// 2. OAT sieht keine Wechselwirkungen (tiefe Rendite UND hohe Ausgaben treffen härter als
// die Summe der Einzelbalken). Dafür ist die Monte-Carlo-Simulation zuständig.
//
// Laeuft wie die Monte-Carlo-Simulation vollstaendig im Browser: computePlan ist rein, und
// Läuft wie die Monte-Carlo-Simulation vollständig im Browser: computePlan ist rein, und
// ein Tornado braucht nur 2 Aufrufe je Treiber (Millisekunden).
import { computePlan } from "@/lib/calculations";
@@ -29,18 +29,18 @@ export type DriverId =
| "propertyGrowth";
// Die Einheit bestimmt, WAS der eingegebene Wert bedeutet -- das ist je Treiber verschieden
// und laesst sich nicht vereinheitlichen, ohne fachlich falsch zu werden:
// und lässt sich nicht vereinheitlichen, ohne fachlich falsch zu werden:
// abs_pct absoluter Prozentsatz (es gibt genau einen plan-weiten Wert)
// delta_pp Verschiebung in Prozentpunkten (die Elemente haben je eigene Saetze -- ein
// absoluter Wert wuerde die PK auf ETF-Rendite plaetten)
// rel_pct relative Abweichung in Prozent (die Elemente haben je eigene Betraege)
// delta_pp Verschiebung in Prozentpunkten (die Elemente haben je eigene Sätze -- ein
// absoluter Wert würde die PK auf ETF-Rendite plätten)
// rel_pct relative Abweichung in Prozent (die Elemente haben je eigene Beträge)
// delta_years Verschiebung in Jahren
export type DriverUnit = "abs_pct" | "delta_pp" | "rel_pct" | "delta_years";
export interface DriverDef {
id: DriverId;
label: string;
shortLabel: string; // Achsenbeschriftung im Tornado (kurz genug fuer die y-Achse)
shortLabel: string; // Achsenbeschriftung im Tornado (kurz genug für die y-Achse)
unit: DriverUnit;
help: string;
applies: (plan: PlanInput) => boolean;
@@ -118,7 +118,7 @@ export const DRIVERS: DriverDef[] = [
},
];
// Einheiten-Suffix fuer die Eingabefelder und die Ergebnistabelle.
// Einheiten-Suffix für die Eingabefelder und die Ergebnistabelle.
export const UNIT_SUFFIX: Record<DriverUnit, string> = {
abs_pct: "%",
delta_pp: "pp",
@@ -133,7 +133,7 @@ export function driverById(id: DriverId): DriverDef {
}
// --- Anwenden eines Treiber-Wertes auf einen Plan -------------------------------------
// Alle Transformationen sind rein: sie liefern eine Kopie und lassen das Original unberuehrt.
// Alle Transformationen sind rein: sie liefern eine Kopie und lassen das Original unberührt.
function mapPhaseData(element: ElementInput, f: (pd: PhaseData) => PhaseData): ElementInput {
const phaseValues: Record<string, PhaseData> = {};
@@ -141,7 +141,7 @@ function mapPhaseData(element: ElementInput, f: (pd: PhaseData) => PhaseData): E
return { ...element, phaseValues };
}
// Bildet die Elemente der gegebenen Kategorien ab; alle uebrigen bleiben unveraendert.
// Bildet die Elemente der gegebenen Kategorien ab; alle übrigen bleiben unverändert.
function mapElements(
plan: PlanInput,
categories: ElementCategory[],
@@ -159,7 +159,7 @@ export function applyDriver(plan: PlanInput, id: DriverId, value: number): PlanI
return { ...plan, inflationRateDefault: value };
case "returns":
// Verschiebung in Prozentpunkten auf die geplante Rendite. Nur dort, wo ueberhaupt ein
// Verschiebung in Prozentpunkten auf die geplante Rendite. Nur dort, wo überhaupt ein
// Werte-Datensatz existiert -- fehlt er, rechnet computePlan ohnehin mit 0 %.
return mapElements(plan, RETURN_CATEGORIES, (pd) => ({
...pd,
@@ -177,7 +177,7 @@ export function applyDriver(plan: PlanInput, id: DriverId, value: number): PlanI
// Relative Skalierung des Basisbetrags. Bewusst nur dort, wo `amount` gesetzt ist:
// ab Phase 2 ist der Wert in der Regel live vererbt (kein gespeicherter Betrag), und
// die Fortschreibung leitet ihn aus dem skalierten Basiswert ab -- dadurch wirkt die
// Skalierung automatisch ueber alle Folgephasen.
// Skalierung automatisch über alle Folgephasen.
const factor = 1 + value / 100;
return mapElements(plan, [id === "expenses" ? "EXPENSE" : "INCOME"], (pd) =>
typeof pd.amount === "number" ? { ...pd, amount: Math.max(0, pd.amount * factor) } : pd
@@ -191,7 +191,7 @@ export function applyDriver(plan: PlanInput, id: DriverId, value: number): PlanI
}));
case "lifespan": {
// Verlaengert/verkuerzt die LETZTE Phase. Bewusst nicht das Pensionsalter: das laesst
// Verlängert/verkürzt die LETZTE Phase. Bewusst nicht das Pensionsalter: das lässt
// sich ohne Mitverschieben der Phasengrenzen nicht sinnvoll variieren (siehe 9.18).
if (plan.phases.length === 0) return plan;
const lastSeq = Math.max(...plan.phases.map((p) => p.sequenceNumber));
@@ -221,13 +221,13 @@ export interface TornadoBar {
id: DriverId;
label: string;
shortLabel: string;
note?: string; // Erklaerung, wenn der Treiber das Ergebnis nachweislich nicht bewegt
note?: string; // Erklärung, wenn der Treiber das Ergebnis nachweislich nicht bewegt
unit: DriverUnit;
low: number; // eingegebene Bandbreite
high: number;
lowResult: number; // Zielgroesse beim tiefen Wert
highResult: number; // Zielgroesse beim hohen Wert
min: number; // fuer den Balken: kleinerer der beiden Ergebniswerte
lowResult: number; // Zielgrösse beim tiefen Wert
highResult: number; // Zielgrösse beim hohen Wert
min: number; // für den Balken: kleinerer der beiden Ergebniswerte
max: number;
swing: number; // max - min
}
@@ -237,13 +237,13 @@ export interface TornadoResult {
bars: TornadoBar[];
}
// Warum bewegt ein Treiber gar nichts? Ein stummer Nullbalken ohne Erklaerung ist die
// schlechteste Antwort -- der Nutzer haelt ihn fuer einen Fehler.
// Warum bewegt ein Treiber gar nichts? Ein stummer Nullbalken ohne Erklärung ist die
// schlechteste Antwort -- der Nutzer hält ihn für einen Fehler.
//
// Der wichtigste Fall ist die Immobilien-Wertsteigerung bei einem Verkauf: Der Verkaufserloes
// ist `Verkaufspreis - Hypothek - Grundstueckgewinnsteuer` und haengt damit am ERFASSTEN
// Der wichtigste Fall ist die Immobilien-Wertsteigerung bei einem Verkauf: Der Verkaufserlös
// ist `Verkaufspreis - Hypothek - Grundstückgewinnsteuer` und hängt damit am ERFASSTEN
// Verkaufspreis, nicht am modellierten Verkehrswert. Die aufgelaufene Wertsteigerung wird beim
// Verkauf also verworfen -- der Treiber kann das Endvermoegen nicht mehr beeinflussen.
// Verkauf also verworfen -- der Treiber kann das Endvermögen nicht mehr beeinflussen.
export function ineffectiveReason(plan: PlanInput, id: DriverId): string {
if (id === "propertyGrowth") {
const computed = computePlan(plan);
@@ -256,7 +256,7 @@ export function ineffectiveReason(plan: PlanInput, id: DriverId): string {
return "Dieser Parameter bewegt das Endvermögen in diesem Plan nicht.";
}
// Zielgroesse: Endvermoegen der letzten Phase, real (kaufkraftbereinigt) oder nominal.
// Zielgrösse: Endvermögen der letzten Phase, real (kaufkraftbereinigt) oder nominal.
export function planMetric(plan: PlanInput, metric: TornadoMetric): number {
const computed = computePlan(plan);
const last = computed.phases[computed.phases.length - 1];
@@ -275,8 +275,8 @@ export function computeTornado(
const def = driverById(input.id);
const lowResult = planMetric(applyDriver(plan, input.id, input.low), metric);
const highResult = planMetric(applyDriver(plan, input.id, input.high), metric);
// Die Richtung kann sich umkehren (tiefe Ausgaben -> hohes Vermoegen). Der Balken spannt
// deshalb ueber min..max; welche Eingabe zu welchem Ende gehoert, zeigt die Tabelle.
// Die Richtung kann sich umkehren (tiefe Ausgaben -> hohes Vermögen). Der Balken spannt
// deshalb über min..max; welche Eingabe zu welchem Ende gehört, zeigt die Tabelle.
const swing = Math.abs(highResult - lowResult);
return {
id: input.id,
@@ -294,7 +294,7 @@ export function computeTornado(
};
});
// Trichterform: groesste Spannweite zuoberst.
// Trichterform: grösste Spannweite zuoberst.
bars.sort((a, b) => b.swing - a.swing);
return { base, bars };
}
+2 -2
View File
@@ -2,8 +2,8 @@ import { cookies } from "next/headers";
import { SESSION_COOKIE_NAME, verifySessionToken } from "@/lib/auth";
// Liest die User-ID des eingeloggten Benutzers aus dem Session-Cookie.
// Fuer API-Routen (Node.js-Runtime); die Middleware schuetzt die Routen bereits,
// dies ist die zweite Verteidigungslinie und liefert die ID fuer Ownership-Checks.
// Für API-Routen (Node.js-Runtime); die Middleware schützt die Routen bereits,
// dies ist die zweite Verteidigungslinie und liefert die ID für Ownership-Checks.
export async function getCurrentUserId(): Promise<string | null> {
const cookieStore = await cookies();
const token = cookieStore.get(SESSION_COOKIE_NAME)?.value;
+2 -2
View File
@@ -1,5 +1,5 @@
// Theme-Verwaltung: drei waehlbare Schemata, persistiert in localStorage und als
// data-theme am <html> gesetzt. Ohne gespeicherte Wahl folgt die Oberflaeche der
// Theme-Verwaltung: drei wählbare Schemata, persistiert in localStorage und als
// data-theme am <html> gesetzt. Ohne gespeicherte Wahl folgt die Oberfläche der
// OS-Einstellung (siehe globals.css, prefers-color-scheme).
export type Theme = "light" | "dark" | "warm";
+11 -11
View File
@@ -1,5 +1,5 @@
// Domain-Typen fuer Berechnungslogik und API-Payloads. Entkoppelt von den generierten
// Prisma-Typen, damit die Berechnung unabhaengig testbar bleibt.
// Domain-Typen für Berechnungslogik und API-Payloads. Entkoppelt von den generierten
// Prisma-Typen, damit die Berechnung unabhängig testbar bleibt.
//
// V3-Rework: Das Grundprofil (Haushaltsform, Personen, Inflation) liegt neu direkt am Plan.
@@ -21,9 +21,9 @@ export interface PhaseInput {
sequenceNumber: number;
name: string;
durationYears: number;
// Cash-Entscheid beim Uebergang NACH dieser Phase (einmalige Sonderein-/ausgaben).
// Cash-Entscheid beim Übergang NACH dieser Phase (einmalige Sonderein-/ausgaben).
cashTransition: CashTransitionData;
// Gegenstueck im Eltern-Szenario (Diff-Grundlage); null im Basisszenario.
// Gegenstück im Eltern-Szenario (Diff-Grundlage); null im Basisszenario.
sourcePhaseId?: string | null;
}
@@ -33,14 +33,14 @@ export interface ElementInput {
name: string;
ownerRole: OwnerRole | null;
orderIndex: number;
// Werte je Phase (Key = phaseId) bzw. je Uebergang (Key = fromPhaseId).
// Werte je Phase (Key = phaseId) bzw. je Übergang (Key = fromPhaseId).
phaseValues: Record<string, PhaseData>;
transitionValues: Record<string, TransitionData>;
// Gegenstueck im Eltern-Szenario (Diff-Grundlage); null im Basisszenario.
// Gegenstück im Eltern-Szenario (Diff-Grundlage); null im Basisszenario.
sourceElementId?: string | null;
}
// Kopf-Daten eines Szenarios (fuer Baum und Auswahl in der Seitenleiste).
// Kopf-Daten eines Szenarios (für Baum und Auswahl in der Seitenleiste).
export interface ScenarioMeta {
id: string;
planId: string;
@@ -49,7 +49,7 @@ export interface ScenarioMeta {
parentScenarioId: string | null;
}
// Ein Plan ist der Behaelter; er traegt nur den Namen und seine Szenarien.
// Ein Plan ist der Behälter; er trägt nur den Namen und seine Szenarien.
export interface PlanListItem {
id: string;
name: string;
@@ -57,7 +57,7 @@ export interface PlanListItem {
scenarios: ScenarioMeta[];
}
// Die berechenbare Einheit (fachlich: ein SZENARIO). Sie ist selbsttragend und traegt ihr
// Die berechenbare Einheit (fachlich: ein SZENARIO). Sie ist selbsttragend und trägt ihr
// eigenes Grundprofil (Haushaltsform, Personen, Inflation, Cash) plus Phasen und Elemente.
// Der Name `PlanInput` ist historisch und bleibt, weil die ganze Berechnungsschicht darauf
// aufsetzt (computePlan, Monte Carlo, Tests).
@@ -67,8 +67,8 @@ export interface PlanInput {
householdType: HouseholdType;
inflationRateDefault: number;
initialCash: number; // Anfangswert des Cash-Kontos in der ersten Lebensphase
// Kalenderjahr des Planbeginns (Jahr 1) -- nur fuer die Darstellung, nicht fuer die
// Berechnung. Optional, damit Berechnungs-Fixtures es nicht setzen muessen.
// Kalenderjahr des Planbeginns (Jahr 1) -- nur für die Darstellung, nicht für die
// Berechnung. Optional, damit Berechnungs-Fixtures es nicht setzen müssen.
startYear?: number | null;
persons: PersonInput[];
phases: PhaseInput[];
+1 -1
View File
@@ -2,7 +2,7 @@ import bcrypt from "bcryptjs";
import { prisma } from "@/lib/db";
// Nur von API-Routen (Node.js-Runtime) verwendet -- niemals von middleware.ts
// importieren, da dort (Edge-Runtime) kein Datenbankzugriff moeglich ist.
// importieren, da dort (Edge-Runtime) kein Datenbankzugriff möglich ist.
const USERNAME_PATTERN = /^[a-zA-Z0-9._-]{3,32}$/;