diff --git a/SPEZIFIKATION.md b/SPEZIFIKATION.md index 5a46d19..003f22a 100644 --- a/SPEZIFIKATION.md +++ b/SPEZIFIKATION.md @@ -4,10 +4,10 @@ | | | |---|---| | **Dokument** | Funktionale und Technische Spezifikation FPT | -| **Version** | 0.14 | +| **Version** | 0.15 | | **Datum** | 2026-07-18 | | **Status** | Lebendes Dokument | -| **Codestand** | Arbeitsstand nach `c2fb82b` inkl. Phasenkopf-Überarbeitung und Verteil-Werkzeugen (Branch `main`) | +| **Codestand** | Arbeitsstand nach `9252f71` inkl. abschnittsbasiertem Plan-Assistenten (Branch `main`) | | **Ersetzt** | `FDD_TDD_FPT.docx` (v1–v5) im Ordner `Info Dateien` – diese sind ab Version 0.1 dieses Dokuments obsolet | | **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` | @@ -17,6 +17,7 @@ | Version | Datum | Autor | Änderung | |---|---|---|---| +| 0.15 | 2026-07-19 | Claude (Opus 4.8) | **Plan-Assistent überarbeitet** (Schritt 2 und 4). Rein an der Oberfläche, keine Änderung an Berechnung, Datenmodell oder API. **(Schritt 2 – Lebensphasen):** Die Lebenslinie zerfällt neu an den **fixen Pensionierungszeitpunkten** in Abschnitte (neues reines Modul `phaseplan.ts`, `planSegments`): Erwerb (alle arbeiten), Misch (eine pensioniert, eine arbeitet), Pension (alle pensioniert) – jeweils mit **kurzer Definition**. In den durch eine Pensionierung **fest begrenzten** Abschnitten verteilt der Nutzer beliebig viele Phasen mit **+/Papierkorb** und **eigenem Namen je Phase**; eine Live-Summe erzwingt, dass die Phasendauern exakt aufgehen («Weiter» ist bis dahin gesperrt). Der letzte Pensions-Abschnitt ist **offen** (Lebensdauer frei). Die Anzahl Abschnitte wird **abgeleitet** – Einzelplan: 2 (Erwerb, Pension); Paar mit unterschiedlichem Pensionsalter: 3. Neue **Zeitachse** mit Pensionierungs-Flaggen und nummerierter Beschriftung **unter** dem Balken (auch kurze Phasen bleiben lesbar). Behebt den Fehler, dass die Erwerbsphase zuvor beliebig über die Pensionierung hinaus gesetzt werden konnte. **(Schritt 4 – Vorsorge & Vermögen):** bei Paaren aufgeteilt in **Gemeinsam / Person A / Person B**; PK und 3a sind je Person, Wertschriften/Wohneigentum/Schulden je Bereich (gemeinsam oder pro Person). Neue Kapitel 3.2.8 überarbeitet; 8 Tests ergänzt (111 → 119). | | 0.14 | 2026-07-18 | Claude (Opus 4.8) | **Phasenkopf überarbeitet und zwei Verteil-Werkzeuge.** (1) **Zweizeilige Wertdarstellung:** Im Anzeigemodus «Beide» steht der Realwert neu in Klammern in einer **eigenen Zeile** unter dem nominalen Wert statt daneben – im Phasenkopf *und* in den Matrix-Zellen. Der Pfeil wiederholt sich auf der zweiten Zeile, damit der Bezug Start → Ende erhalten bleibt. Nebeneffekt: Die Spalten werden schmaler, wodurch **jede Kennzahl umbruchfrei** (`whitespace-nowrap`) dargestellt werden kann. (2) Die Kennzahl heisst korrekt **«Sparquote»** bzw. **«Verzehrquote»** statt «Quote»/«Verzehr». (3) Neuer Block **«Verfügbares Kapital»** im Phasenkopf (ab Phase 2, nur wenn > 0): der beim Übergang zugeflossene Topf mit «davon verteilt» und «Rest auf Cash». (4) Zwei neue Werkzeuge als eigene Popups: **«Kapital verteilen»** (Zusatzeinlagen in PK/3a/Vermögen, Sonderamortisation, Sofort-Tilgung) und **«Sparquote/Bezug verteilen»** (jährliche Raten), beide mit **Live-Vorschau** über eine erneute `computePlan`-Rechnung im Browser – die angezeigte Wirkung ist dadurch per Konstruktion exakt die spätere, inklusive aller Kappungen. Der Quoten-Dialog weist neben erstem und letztem Jahr die **absolute Quote über die ganze Phase** aus und warnt, wenn die Quote über die Phase sinkt. Neues reines Modul `distribution.ts`. Neue Kapitel 3.6.9, 3.6.10, 9.25; 3.6.1 und 3.6.3 überarbeitet. 8 Tests ergänzt (103 → 111). **Keine Änderung an Berechnung, Datenmodell oder API** – beide Werkzeuge schreiben ausschliesslich bestehende Felder über bestehende Endpunkte. | | 0.13 | 2026-07-18 | Claude (Fable 5) | **UI-Gesamtumbau** – rein an der Oberfläche, Berechnung, Datenmodell und API-Semantik unverändert. **(A) Fundament:** durchgehende **Du-Form** und **echte Umlaute** in allen sichtbaren Texten (inkl. API-Fehlermeldungen); neue UI-Primitiven in `ui.tsx` (Button, Modal mit ESC/Fokus-Falle/Animation, Bestätigungs-Dialog statt `window.confirm`, Toasts statt `alert`, Skeleton-Loader, EmptyState); eigene **Attention-Farbe** (Amber) für offene Entscheide, getrennt vom Akzent; Micro-Interactions mit `prefers-reduced-motion`-Fallback. **(B) Onboarding (Roadmap Nr. 10):** geführter **Plan-Assistent** in fünf Schritten (reine Orchestrierung bestehender Endpunkte, Einkommen bewusst pro Person – räumt die 9.9-Falle aus), **Beispielplan mit einem Klick** (Übergänge absichtlich offen – die Ampel lehrt sich selbst), **interaktive Tour** über die Planansicht, abgeleitete **«Nächste Schritte»**-Karte. **(C) Struktur:** Einzel-Bearbeitungen laufen neu über ein rechtes **Inspector-Panel** statt Modals (Matrix bleibt sichtbar; Klick auf andere Zelle wechselt den Inhalt); **Phasenkopf entschlackt** auf vier Kern-Infos (Rest wohnt in der Detailansicht aus 0.11); Matrix mit eigenem Scrollbereich und **beidachsig fixierten Köpfen**; Sidebar-Gruppen «Meine Pläne»/«Wissen» («So rechnet FPT», Systemparameter); Terminologie-Fix «Szenario-Profil» statt «Plan-Einstellungen»; Aktions-Icons auch ohne Hover sichtbar (Touch). **(D) Extras:** **Sparklines** je Element-Zeile (aus den 0.11-Verlaufswerten, keine Neuberechnung), **Befehls-Palette** (Ctrl/Cmd+K), Ruin-Banner verlinkt auf die Einflussfaktoren. Neue Kapitel 3.2.8, 3.7.6–3.7.9, 9.23, 9.24; 9.17 bereinigt (der `Selection`-Rest und der ProfileMenu-Lint-Fehler sind behoben – `npm run lint` ist erstmals fehlerfrei). Testbestand unverändert 103. | | 0.12 | 2026-07-18 | Claude (Opus 4.8) | **Lesbarkeit der Wasserfälle, Verkaufspreis-Abgleich und Erklärung wirkungsloser Tornado-Treiber.** (1) Die beiden Wasserfälle werden **nicht mehr mit Recharts** gezeichnet, sondern als eigene liegende Darstellung: Verbindungslinien zwischen den Balken, Wertbeschriftung an jedem Schritt, Abschnitts-Überschriften („Am Übergang" / „Innerhalb der Phase") und eine aufklappbare Tabelle mit **laufendem Zwischenstand**. Anlass war, dass die bisherige Darstellung faktisch nicht lesbar war – die Zahlen waren korrekt, die Grafik nicht. (2) Der Restposten beider Brücken wird bei Abweichung neu als **Fehlermeldung** ausgewiesen statt als beiläufige „Rundungsdifferenz"; eine nicht aufgehende Zerlegung ist ein Rechenfehler und kein Schönheitsproblem. (3) **Verkaufspreis einer Immobilie** wird beim Wechsel auf „Verkaufen" neu mit dem **modellierten Verkehrswert** vorbelegt; der Dialog weist Verkehrswert und Abweichung aus und warnt ab 10 % Differenz (Kap. 3.5.8, 9.22). Damit fällt auf, wenn angenommene Wertsteigerung und erwarteter Verkaufspreis nicht zusammenpassen. (4) Der Tornado erklärt neu **Nullbalken** statt sie stumm zu zeigen – insbesondere den Fall, dass die Immobilien-Wertsteigerung bei einem Verkauf nachweislich wirkungslos ist (`ineffectiveReason`, Kap. 4.13.5). Neue Kapitel 3.5.8, 4.13.5, 9.22; 11 Tests ergänzt (92 → 103), darunter die Invariante `residual === 0` über sieben Plankonstellationen. Keine DB-Änderung, keine Änderung an der Berechnung. | @@ -366,14 +367,42 @@ gesetzt. Kalenderjahr eines Planjahrs: `startYear + (Jahr − 1)`. ### 3.2.8 Geführter Assistent und Beispielplan **Der Plan-Assistent** (Roadmap Nr. 10: «Schritt für Schritt statt leerer Matrix») fragt in -fünf Schritten in Alltagssprache: (1) Grundprofil, (2) Lebensphasen – vom Tool anhand des -Pensionsalters **vorgeschlagen** (dieselbe Kappungslogik `maxPhaseDuration` wie beim manuellen -Anlegen; bei Paaren mit unterschiedlichem Pensionsalter inkl. Übergangsphase), Dauer editierbar, -(3) Einkommen und Ausgaben plus Kontostand, (4) Vorsorge und Vermögen als Checkliste (PK, 3a, -Wertschriften, Wohneigentum, Schulden – nur Angehaktes fragt Details; die AHV wird automatisch -je Person angelegt), (5) Zusammenfassung. +fünf Schritten in Alltagssprache: (1) Grundprofil, (2) Lebensphasen, (3) Einkommen und Ausgaben +plus Kontostand, (4) Vorsorge und Vermögen, (5) Zusammenfassung. -Zwei bewusste Entscheide: +**Schritt 2 ist an den fixen Pensionierungszeitpunkten ausgerichtet.** Das Pensionsalter jeder +Person ist ein Fixpunkt auf der Lebenslinie; dazwischen entstehen Abschnitte mit konstantem +Erwerbsstatus (reines Modul `phaseplan.ts`, `planSegments`): + +| Abschnitt | Bedeutung | Länge | +|---|---|---| +| **Erwerb** | alle arbeiten | fest (bis zur ersten Pensionierung) | +| **Misch** | eine Person pensioniert, eine arbeitet | fest (zwischen den Pensionierungen) | +| **Pension** | alle pensioniert | **offen** (Lebensdauer frei) | + +Die **Anzahl** Abschnitte wird abgeleitet, nicht vorgegeben: Einzelplan → 2 (Erwerb, Pension); +Paar mit gleichem Pensionsalter → 2 (keine Mischzeit); Paar mit unterschiedlichem Pensionsalter +→ 3. Ist eine Person bei Planbeginn bereits pensioniert, beginnt die Linie mit einem Misch- oder +Pensions-Abschnitt. + +In jedem **fest begrenzten** Abschnitt verteilt der Nutzer beliebig viele Phasen (mit ++/Papierkorb und eigenem Namen je Phase); eine **Live-Summe** erzwingt, dass die Phasendauern +exakt der festen Länge entsprechen – «Weiter» bleibt gesperrt, bis es aufgeht. Das ist zwingend: +Die Berechnung leitet den Phasentyp am Phasenbeginn ab und **kappt jede Phase am nächsten +Pensionsereignis** ([2.3](#23-phasentyp--abgeleitet-nicht-gespeichert)) – eine Phase, die eine +Pensionierung überspannt, gäbe es nicht. Der frühere Assistent liess die Erwerbsphase beliebig +über das Pensionsalter hinaus setzen; das ist damit behoben. Der **offene** Pensions-Abschnitt +nimmt beliebige Dauern (Summe = geplante Restlebensdauer). + +Eine **Zeitachse** zeigt den proportionalen Verlauf mit den Pensionierungs-Fixpunkten als +Flaggen; die Phasen sind nummeriert und **unter** dem Balken beschriftet, damit auch kurze +Phasen lesbar bleiben. + +**Schritt 4 (Vorsorge & Vermögen)** ist bei Paaren in **Gemeinsam / Person A / Person B** +aufgeteilt. Pensionskasse und Säule 3a sind immer persönlich (personengebundene Kategorien); +Wertschriften, Wohneigentum und Schulden lassen sich gemeinsam **oder** je Person erfassen. + +Zwei bewusste Entscheide bleiben: - **Einkommen wird pro Person erfasst**, nie als «Gemeinsam» – in Paar-Plänen zählt Haushalts-Einkommen nicht für die AHV ([9.9](#99-gemeinsames-einkommen-zählt-bei-paaren-nicht-für-die-ahv)); der Assistent räumt diese Falle von Anfang an aus. @@ -2271,6 +2300,7 @@ PlanComputed ← an den Client geliefert | `montecarlo.ts` | Monte-Carlo-Simulation (Sampler + Treiber), Szenario-Vergleich und Element-Gruppierung über die Herkunfts-Kette. Keine I/O, läuft im Browser. | | `sensitivity.ts` | Sensitivitätsanalyse / Tornado: Treiber-Katalog, Parameter-Transformationen, `computeTornado`. Rein, läuft im Browser. | | `distribution.ts` | Kapitaltopf und Quoten-Zerlegung, Anwenden von Entwurfswerten für die Verteil-Werkzeuge (Kap. 3.6.9/3.6.10). Rein. | +| `phaseplan.ts` | Ableitung der Lebensabschnitte (Erwerb/Misch/Pension) aus den fixen Pensionierungszeitpunkten für den Assistenten (Kap. 3.2.8). Rein. | | `constants.ts` (erweitert) | zusätzlich `SYSTEM_PARAMETERS`: dieselben Werte maschinenlesbar mit Bedeutung, Herleitung, Quelle und Stand – Grundlage der Systemparameter-Ansicht | | `diff.ts` | Abweichungs-Erkennung eines Szenarios gegen sein Eltern-Szenario (Kap. 3.2.6) | | `queries.ts` | Prisma-Includes, `toPlanInput()`, Ownership-Abfragen | @@ -2532,7 +2562,7 @@ wird der Plan neu geladen; die Berechnung kommt immer vom Server. | `SpecView` | 65 | Rendert `SPEZIFIKATION.md` (via `/api/spec`) als lesbares Dokument, inkl. Sprungmarken aus den Rechenwegen | | `InfoBubble` | 28 | Hilfe-Tooltip | | `ui` | ~370 | UI-Primitiven: Button, Modal, InspectorShell, Confirm, Toast, Skeleton, EmptyState ([3.7.6](#376-sprache-und-ui-primitiven)/[3.7.7](#377-inspector-panel-statt-modals)) | -| `PlanWizard` | ~400 | Geführter Plan-Assistent in fünf Schritten ([3.2.8](#328-geführter-assistent-und-beispielplan)) | +| `PlanWizard` | ~560 | Geführter Plan-Assistent in fünf Schritten, abschnittsbasierte Phasenplanung ([3.2.8](#328-geführter-assistent-und-beispielplan)) | | `Tour` | ~140 | Interaktive Kurz-Tour über die Planansicht ([3.7.8](#378-tour-und-nächste-schritte)) | | `CommandPalette` | ~130 | Befehls-Palette Ctrl/Cmd+K ([3.7.9](#379-befehls-palette-und-sparklines)) | | `DistributionDialogs` | ~460 | Verteil-Werkzeuge für Kapital und Spar-/Verzehrquote ([3.6.10](#3610-verteil-werkzeuge)) | @@ -2768,10 +2798,11 @@ Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-, API- oder E2E-Tests | `explain.test.ts` | 12 | Verlaufswerte je Element, Vollständigkeit beider Wasserfall-Zerlegungen, Rechenweg-Protokoll, Gültigkeit der Spezifikations-Verweise | | `montecarlo.test.ts` | 13 | Determinismus, Volatilität/Vol-Drag, Böden, Reproduzierbarkeit; Element-Gruppierung und Szenario-Vergleich | | `distribution.test.ts` | 8 | Kapitaltopf und Quoten-Zerlegung gegen die Cash-Brücke; Entwurfswerte anwenden ohne Verlust bestehender Felder | +| `phaseplan.test.ts` | 8 | Ableitung der Lebensabschnitte aus den Pensionierungszeitpunkten (Einzel/Paar/bereits pensioniert); letzter Teil immer offen | | `bridges.test.ts` | 10 | Vermögens- und Cash-Brücke gehen über sieben Plankonstellationen ohne Restgrösse auf; Umbuchungen bleiben aus der Vermögensbrücke heraus | | `diff.test.ts` | 9 | Abweichungs-Erkennung gegen das Eltern-Szenario | | `migrations.test.ts` | 1 | spielt alle Migrationen gegen echtes PostgreSQL (PGlite) ein | -| **Total** | **111** | | +| **Total** | **119** | | ## 8.2 Testfälle @@ -3215,4 +3246,4 @@ werden soll). Das Werkzeug informiert, es bevormundet nicht. --- -*Ende der Spezifikation v0.14* +*Ende der Spezifikation v0.15* diff --git a/src/components/PlanWizard.tsx b/src/components/PlanWizard.tsx index 1e628c2..9260549 100644 --- a/src/components/PlanWizard.tsx +++ b/src/components/PlanWizard.tsx @@ -5,21 +5,25 @@ // 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. +// Schritt 2 (Lebensphasen) ist an den fixen Pensionierungszeitpunkten ausgerichtet: Die +// Lebenslinie zerfällt in Abschnitte (Erwerb / Misch / Pension, siehe lib/phaseplan). In den +// FESTEN Abschnitten (durch eine Pensionierung begrenzt) verteilt der Nutzer beliebig viele +// Phasen, deren Dauer exakt aufgehen muss; der letzte Pensions-Abschnitt ist offen. // -// 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). +// 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 +// (siehe SPEZIFIKATION 9.23). -import { useState } from "react"; -import { ArrowLeft, ArrowRight, Sparkles } from "lucide-react"; +import { useMemo, useRef, useState } from "react"; +import { ArrowLeft, ArrowRight, Flag, Plus, Sparkles, Trash2 } 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 { planSegments, defaultOpenDuration, type PlanSegment, type SegmentType } from "@/lib/phaseplan"; import { PILLAR_3A_MAX_ANNUAL } from "@/lib/constants"; import type { PhaseData } from "@/lib/elements"; @@ -28,38 +32,68 @@ interface PhaseDraft { 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; +const SEGMENT_META: Record = { + ERWERB: { + label: "Erwerb", + phase: "Erwerbsphase", + def: "Alle arbeiten noch – Einkommen fliesst, Vorsorge wächst.", + color: "bg-accent/70 text-accent-fg", + soft: "border-accent", + }, + MIXED: { + label: "Misch", + phase: "Übergangsphase", + def: "Eine Person ist pensioniert, die andere arbeitet noch.", + color: "bg-accent/40 text-accent-fg", + soft: "border-accent", + }, + PENSION: { + label: "Pension", + phase: "Pensionsphase", + def: "Alle sind pensioniert – gelebt wird aus Renten und Vermögen.", + color: "bg-accent-soft text-accent-soft-fg", + soft: "border-accent", + }, +}; + +function personsSignature(draft: ProfileDraft): string { + return draft.householdType + "|" + draft.persons.map((p) => `${p.age}/${p.retirementAge}`).join(","); +} + +// Eine Phase je Abschnitt als Startpunkt: feste Länge in den begrenzten Abschnitten, eine +// sinnvolle Vorgabe im offenen Pensions-Abschnitt. +function defaultSegPhases(segments: PlanSegment[], persons: { age: number }[]): PhaseDraft[][] { + const typeCount: Record = { ERWERB: 0, MIXED: 0, PENSION: 0 }; + return segments.map((seg) => { + typeCount[seg.type] += 1; + const dur = seg.fixedYears ?? defaultOpenDuration(persons, seg.fromYear); + return [{ name: SEGMENT_META[seg.type].phase, durationYears: Math.max(1, dur) }]; + }); +} + +// --- Vermögens-/Vorsorge-Erfassung je Eigentümer-Bereich --------------------------------- + +type Scope = "SELF" | "HOUSEHOLD" | "PERSON_A" | "PERSON_B"; +type Category = "pk" | "p3a" | "etf" | "re" | "debt"; + +interface ScopeAssets { + pk: { on: boolean; value: number; contribution: number }; + p3a: { on: boolean; value: number; contribution: number }; + etf: { on: boolean; value: number; contribution: number }; + re: { on: boolean; price: number; mortgage: number; amortization: number; interest: number }; + debt: { on: boolean; value: number; repayment: number }; +} + +function emptyScope(): ScopeAssets { + return { + pk: { on: false, value: 0, contribution: 0 }, + p3a: { on: false, value: 0, contribution: 0 }, + etf: { on: false, value: 0, contribution: 0 }, + re: { on: false, price: 0, mortgage: 0, amortization: 0, interest: 1.5 }, + debt: { on: false, value: 0, repayment: 0 }, + }; } export function PlanWizard({ @@ -72,30 +106,47 @@ export function PlanWizard({ const [step, setStep] = useState(0); const [planName, setPlanName] = useState("Meine Planung"); const [profile, setProfile] = useState(emptyProfileDraft); - const [phases, setPhases] = useState(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. + + // Abschnitte der Lebenslinie und die je Abschnitt geplanten Phasen (parallel indexiert). + const segments = useMemo(() => planSegments(profile.persons), [profile.persons]); + const [segPhases, setSegPhases] = useState([]); + const segSig = useRef(""); + + // 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>({ PERSON_A: 0 }); const [expenses, setExpenses] = useState(0); const [initialCash, setInitialCash] = useState(0); - const [toggles, setToggles] = useState({ 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 [assets, setAssets] = useState>({ + SELF: emptyScope(), + HOUSEHOLD: emptyScope(), + PERSON_A: emptyScope(), + PERSON_B: emptyScope(), + }); + const [saving, setSaving] = useState(false); const [error, setError] = useState(null); + const isCouple = profile.householdType === "COUPLE"; 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 setScope(scope: Scope, cat: Category, patch: Record) { + setAssets((prev) => ({ ...prev, [scope]: { ...prev[scope], [cat]: { ...prev[scope][cat], ...patch } } })); + } + function goNext() { if (step === 0) { - // Phasenvorschlag aus dem (evtl. geänderten) Profil neu ableiten. - setPhases(suggestPhases(profile)); + // Abschnitte neu ableiten; Phasen nur zurücksetzen, wenn sich das Profil strukturell + // geändert hat (sonst blieben Bearbeitungen des Nutzers erhalten). + const sig = personsSignature(profile); + if (sig !== segSig.current || segPhases.length === 0) { + setSegPhases(defaultSegPhases(segments, profile.persons)); + segSig.current = sig; + } const inc: Record = {}; for (const p of profile.persons) inc[p.role] = incomes[p.role] ?? 0; setIncomes(inc); @@ -103,6 +154,40 @@ export function PlanWizard({ setStep((s) => Math.min(s + 1, STEP_TITLES.length - 1)); } + // --- Validierung Schritt 1 --- + const segSums = segPhases.map((phs) => phs.reduce((s, p) => s + Math.max(0, p.durationYears), 0)); + const step1Valid = + segPhases.length === segments.length && + segments.every((seg, i) => { + const phs = segPhases[i] ?? []; + if (phs.length === 0) return false; + if (phs.some((p) => p.durationYears < 1)) return false; + return seg.fixedYears === null ? segSums[i] >= 1 : segSums[i] === seg.fixedYears; + }); + + // Flache, geordnete Phasenliste (für Vorschau, Timeline und Anlegen). + const flatPhases: PhaseDraft[] = segPhases.flat(); + + function updatePhase(segIdx: number, phaseIdx: number, patch: Partial) { + setSegPhases((prev) => + prev.map((phs, i) => (i === segIdx ? phs.map((p, j) => (j === phaseIdx ? { ...p, ...patch } : p)) : phs)) + ); + } + function addPhase(segIdx: number) { + const seg = segments[segIdx]; + const rest = seg.fixedYears === null ? 5 : Math.max(0, seg.fixedYears - segSums[segIdx]); + setSegPhases((prev) => + prev.map((phs, i) => + i === segIdx + ? [...phs, { name: `${SEGMENT_META[seg.type].phase} ${phs.length + 1}`, durationYears: Math.max(1, rest) }] + : phs + ) + ); + } + function removePhase(segIdx: number, phaseIdx: number) { + setSegPhases((prev) => prev.map((phs, i) => (i === segIdx ? phs.filter((_, j) => j !== phaseIdx) : phs))); + } + async function create() { setSaving(true); setError(null); @@ -117,10 +202,9 @@ export function PlanWizard({ 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, + name: flatPhases[0].name, + durationYears: flatPhases[0].durationYears, }); const firstPhaseId = first.phase.id; @@ -139,42 +223,53 @@ export function PlanWizard({ } 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 }); + await addEl("AHV", isCouple ? `AHV ${personLabel(p.role)}` : "AHV", p.role, { gapYears: 0 }); } - for (const ph of phaseList.slice(1)) { + // Vermögen/Vorsorge je Eigentümer-Bereich anlegen. + for (const scope of renderedScopes) { + const a = assets[scope.id]; + const suffix = scope.nameSuffix; // z. B. " Anna" oder " (gemeinsam)" + if (scope.showVorsorge) { + if (a.pk.on) { + await addEl("PENSION_FUND", `Pensionskasse${suffix}`, scope.vorsorgeRole!, { + currentValue: a.pk.value, + annualContribution: a.pk.contribution, + expectedReturn: 1.5, + }); + } + if (a.p3a.on) { + await addEl("PILLAR_3A", `Säule 3a${suffix}`, scope.vorsorgeRole!, { + currentValue: a.p3a.value, + annualContribution: Math.min(a.p3a.contribution, PILLAR_3A_MAX_ANNUAL), + expectedReturn: 2, + }); + } + } + if (a.etf.on) { + await addEl("OTHER_ASSET", `Wertschriften${suffix}`, scope.otherRole, { + startValue: a.etf.value, + annualContribution: a.etf.contribution, + expectedReturn: 5, + }); + } + if (a.re.on) { + await addEl("REAL_ESTATE", `Wohneigentum${suffix}`, scope.otherRole, { + purchasePrice: a.re.price, + mortgage: a.re.mortgage, + amortization: a.re.amortization, + interestRate: a.re.interest, + }); + } + if (a.debt.on) { + await addEl("OTHER_DEBT", `Schulden${suffix}`, scope.otherRole, { + startValue: a.debt.value, + annualRepayment: a.debt.repayment, + }); + } + } + + for (const ph of flatPhases.slice(1)) { await api.post(`/api/scenarios/${sid}/phases`, { name: ph.name, durationYears: ph.durationYears }); } @@ -188,7 +283,29 @@ export function PlanWizard({ } } - const workingPersons = profile.persons.filter((p) => p.age < p.retirementAge); + // Welche Eigentümer-Bereiche in Schritt 4 gezeigt werden. + const renderedScopes: { + id: Scope; + label: string; + nameSuffix: string; + showVorsorge: boolean; + vorsorgeRole: string | null; + otherRole: string; + cats: Category[]; + }[] = isCouple + ? [ + { id: "HOUSEHOLD", label: "Gemeinsam", nameSuffix: " (gemeinsam)", showVorsorge: false, vorsorgeRole: null, otherRole: "HOUSEHOLD", cats: ["etf", "re", "debt"] }, + { id: "PERSON_A", label: personLabel("PERSON_A"), nameSuffix: ` ${personLabel("PERSON_A")}`, showVorsorge: true, vorsorgeRole: "PERSON_A", otherRole: "PERSON_A", cats: ["pk", "p3a", "etf", "re", "debt"] }, + { id: "PERSON_B", label: personLabel("PERSON_B"), nameSuffix: ` ${personLabel("PERSON_B")}`, showVorsorge: true, vorsorgeRole: "PERSON_B", otherRole: "PERSON_B", cats: ["pk", "p3a", "etf", "re", "debt"] }, + ] + : [ + { id: "SELF", label: "Deine Angaben", nameSuffix: "", showVorsorge: true, vorsorgeRole: "PERSON_A", otherRole: "HOUSEHOLD", cats: ["pk", "p3a", "etf", "re", "debt"] }, + ]; + + const enabledAssetCount = renderedScopes.reduce((n, sc) => { + const a = assets[sc.id]; + return n + sc.cats.filter((c) => a[c].on).length; + }, 0); return ( - {/* Fortschritt */}
{STEP_TITLES.map((_, i) => (
@@ -214,44 +330,87 @@ export function PlanWizard({
)} - {step === 1 && phases && ( -
+ {step === 1 && ( +

- Deine Planung besteht aus Lebensphasen – Abschnitten, in denen deine - Finanzen ähnlich laufen. Das Tool schlägt sie dir anhand deines Pensionsalters vor; die Dauer kannst du - anpassen. + Deine Planung besteht aus Lebensphasen. Das Pensionsalter setzt feste + Grenzen auf der Lebenslinie – dazwischen kannst du beliebig viele Phasen einplanen.

- {/* Mini-Vorschau der Kette */} -
- {phases.map((ph, i) => ( -
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" : ""}`} - > - {ph.name} - {ph.durationYears} J. + + + + {segments.map((seg, segIdx) => { + const meta = SEGMENT_META[seg.type]; + const sum = segSums[segIdx] ?? 0; + const fixed = seg.fixedYears; + const ok = fixed === null ? sum >= 1 : sum === fixed; + return ( +
+
+ + Teil {segIdx + 1}: {meta.label} + + {fixed === null ? ( + + Dauer frei · aktuell {sum} J. + + ) : ( + + Summe {sum} / {fixed} Jahre{ok ? " ✓" : ""} + + )} +
+

{meta.def}

+ +
+ {(segPhases[segIdx] ?? []).map((ph, phaseIdx) => ( +
+ updatePhase(segIdx, phaseIdx, { name: v })} + /> + updatePhase(segIdx, phaseIdx, { durationYears: Math.max(1, Math.round(v)) })} + /> + +
+ ))} +
+ + + + {fixed !== null && !ok && ( +

+ Dieser Teil ist durch die Pensionierung auf genau {fixed} Jahre festgelegt. Die Phasendauern müssen + zusammen {fixed} ergeben (aktuell {sum}). +

+ )}
- ))} -
-
- {phases.map((ph, i) => ( - - setPhases((prev) => prev!.map((x, j) => (j === i ? { ...x, durationYears: Math.max(1, Math.round(v)) } : x))) - } - /> - ))} -
-

- Eine Phase darf keine Pensionierung überspannen – die Dauer der Erwerbsphase ist deshalb ans Pensionsalter - gekoppelt und wird beim Anlegen automatisch begrenzt. -

+ ); + })}
)} @@ -283,61 +442,80 @@ export function PlanWizard({ )} {step === 3 && ( -
+

- 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."} + Was besitzt ihr? Hake an, was zutrifft – nur das Angehakte fragt nach Details. + {isCouple && " Pensionskasse und Säule 3a sind immer persönlich; Wertschriften, Wohneigentum und Schulden kannst du gemeinsam oder je Person erfassen."}

- setToggles((t) => ({ ...t, pk: v }))} - > - setPk((s) => ({ ...s, value: v }))} /> - setPk((s) => ({ ...s, contribution: v }))} /> - - setToggles((t) => ({ ...t, p3a: v }))} - > - setP3a((s) => ({ ...s, value: v }))} /> - setP3a((s) => ({ ...s, contribution: v }))} /> - - setToggles((t) => ({ ...t, etf: v }))} - > - setEtf((s) => ({ ...s, value: v }))} /> - setEtf((s) => ({ ...s, contribution: v }))} /> - - setToggles((t) => ({ ...t, realEstate: v }))} - > - setRe((s) => ({ ...s, price: v }))} /> - setRe((s) => ({ ...s, mortgage: v }))} /> - setRe((s) => ({ ...s, amortization: v }))} /> - setRe((s) => ({ ...s, interest: v }))} /> - - setToggles((t) => ({ ...t, debt: v }))} - > - setDebt((s) => ({ ...s, value: v }))} /> - setDebt((s) => ({ ...s, repayment: v }))} /> - -

- Die AHV wird automatisch für {profile.persons.length > 1 ? "beide Personen" : "dich"} angelegt – die Rente - berechnet das Tool aus deinem Einkommen. + {renderedScopes.map((sc) => ( +

+ {isCouple && ( +
{sc.label}
+ )} +
+ {sc.cats.includes("pk") && ( + setScope(sc.id, "pk", { on: v })} + > + setScope(sc.id, "pk", { value: v })} /> + setScope(sc.id, "pk", { contribution: v })} /> + + )} + {sc.cats.includes("p3a") && ( + setScope(sc.id, "p3a", { on: v })} + > + setScope(sc.id, "p3a", { value: v })} /> + setScope(sc.id, "p3a", { contribution: v })} /> + + )} + {sc.cats.includes("etf") && ( + setScope(sc.id, "etf", { on: v })} + > + setScope(sc.id, "etf", { value: v })} /> + setScope(sc.id, "etf", { contribution: v })} /> + + )} + {sc.cats.includes("re") && ( + setScope(sc.id, "re", { on: v })} + > + setScope(sc.id, "re", { price: v })} /> + setScope(sc.id, "re", { mortgage: v })} /> + setScope(sc.id, "re", { amortization: v })} /> + setScope(sc.id, "re", { interest: v })} /> + + )} + {sc.cats.includes("debt") && ( + setScope(sc.id, "debt", { on: v })} + > + setScope(sc.id, "debt", { value: v })} /> + setScope(sc.id, "debt", { repayment: v })} /> + + )} +
+
+ ))} +

+ Die AHV wird automatisch für {isCouple ? "beide Personen" : "dich"} angelegt – die Rente berechnet das Tool + aus dem Einkommen.

)} @@ -347,15 +525,11 @@ export function PlanWizard({

Das wird angelegt:

  • 📋 Plan «{planName.trim() || "Meine Planung"}» mit Basisszenario
  • -
  • 📅 {(phases ?? []).map((p) => `${p.name} (${p.durationYears} J.)`).join(" → ")}
  • +
  • 📅 {flatPhases.map((p) => `${p.name} (${p.durationYears} J.)`).join(" → ")}
  • 💰 {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" : ""} + {enabledAssetCount > 0 ? ` + ${enabledAssetCount} Vermögens-/Vorsorgeposten` : ""}

@@ -379,7 +553,7 @@ export function PlanWizard({ )} {step < STEP_TITLES.length - 1 ? ( - ) : ( @@ -393,6 +567,89 @@ export function PlanWizard({ ); } +// Zeitachse: proportionaler Balken mit nummerierten Phasen; Pensionierungs-Fixpunkte als +// Flaggen darüber; die Beschriftung steht als nummerierte Liste UNTER dem Balken, damit auch +// kurze Phasen sauber lesbar sind. +function WizardTimeline({ + segments, + segPhases, + profile, +}: { + segments: PlanSegment[]; + segPhases: PhaseDraft[][]; + profile: ProfileDraft; +}) { + const flat = segments.flatMap((seg, segIdx) => + (segPhases[segIdx] ?? []).map((ph) => ({ ...ph, type: seg.type })) + ); + const total = flat.reduce((s, p) => s + Math.max(1, p.durationYears), 0); + if (total <= 0 || flat.length === 0) return null; + + const minAge = Math.min(...profile.persons.map((p) => p.age)); + const markers = profile.persons + .map((p) => ({ label: p.name?.trim() || (p.role === "PERSON_A" ? "A" : "B"), year: Math.max(0, p.retirementAge - p.age), age: p.retirementAge, role: p.role })) + .filter((m) => m.year > 0 && m.year < total); + + const durs = flat.map((ph) => Math.max(1, ph.durationYears)); + const numbered = flat.map((ph, i) => { + const from = durs.slice(0, i).reduce((s, d) => s + d, 0); + return { ...ph, index: i + 1, from, to: from + durs[i] }; + }); + + return ( +

+
+ {/* Pensionierungs-Fixpunkte */} + {markers.map((m) => ( +
+ + {m.label} · {m.age} + +
+
+ ))} + + {/* Balken mit nummerierten Segmenten */} +
+ {numbered.map((ph, i) => ( +
0 ? "border-l border-border/50" : ""}`} + > + {ph.index} +
+ ))} +
+ + {/* Alters-Achse */} +
+ {minAge} J. + {minAge + total} J. +
+
+ + {/* Beschriftung unter dem Balken */} +
+ {numbered.map((ph, i) => ( + + + {ph.index} + + {ph.name} · {ph.durationYears} J. + + ))} +
+
+ ); +} + function WizardToggle({ label, help, diff --git a/src/lib/phaseplan.test.ts b/src/lib/phaseplan.test.ts new file mode 100644 index 0000000..1fdeeff --- /dev/null +++ b/src/lib/phaseplan.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from "vitest"; +import { planSegments, defaultOpenDuration } from "@/lib/phaseplan"; + +describe("planSegments", () => { + it("Einzelperson: genau zwei Teile (Erwerb fix, Pension offen)", () => { + const segs = planSegments([{ age: 45, retirementAge: 65 }]); + expect(segs).toEqual([ + { type: "ERWERB", fromYear: 0, fixedYears: 20 }, + { type: "PENSION", fromYear: 20, fixedYears: null }, + ]); + }); + + it("Paar mit gleichem Pensionsalter: zwei Teile, KEINE Mischphase", () => { + const segs = planSegments([ + { age: 40, retirementAge: 65 }, + { age: 40, retirementAge: 65 }, + ]); + expect(segs.map((s) => s.type)).toEqual(["ERWERB", "PENSION"]); + expect(segs[0].fixedYears).toBe(25); + expect(segs[1].fixedYears).toBeNull(); + }); + + it("Paar mit unterschiedlichem Pensionsalter: drei Teile (Erwerb, Misch, Pension)", () => { + const segs = planSegments([ + { age: 45, retirementAge: 65 }, // Ret in 20 J. + { age: 50, retirementAge: 62 }, // Ret in 12 J. + ]); + expect(segs).toEqual([ + { type: "ERWERB", fromYear: 0, fixedYears: 12 }, + { type: "MIXED", fromYear: 12, fixedYears: 8 }, + { type: "PENSION", fromYear: 20, fixedYears: null }, + ]); + }); + + it("Eine Person bei Planbeginn bereits pensioniert: Misch beginnt bei Jahr 0", () => { + const segs = planSegments([ + { age: 67, retirementAge: 65 }, // bereits pensioniert -> Ret in 0 J. + { age: 60, retirementAge: 65 }, // Ret in 5 J. + ]); + expect(segs).toEqual([ + { type: "MIXED", fromYear: 0, fixedYears: 5 }, + { type: "PENSION", fromYear: 5, fixedYears: null }, + ]); + }); + + it("Beide bereits pensioniert: nur ein offener Pensions-Teil", () => { + const segs = planSegments([ + { age: 70, retirementAge: 65 }, + { age: 68, retirementAge: 64 }, + ]); + expect(segs).toEqual([{ type: "PENSION", fromYear: 0, fixedYears: null }]); + }); + + it("Einzelperson bereits pensioniert: nur Pension", () => { + expect(planSegments([{ age: 66, retirementAge: 65 }])).toEqual([ + { type: "PENSION", fromYear: 0, fixedYears: null }, + ]); + }); + + it("der letzte Teil ist immer offen und vom Typ PENSION", () => { + for (const persons of [ + [{ age: 30, retirementAge: 65 }], + [ + { age: 45, retirementAge: 65 }, + { age: 48, retirementAge: 60 }, + ], + ]) { + const segs = planSegments(persons); + const last = segs[segs.length - 1]; + expect(last.fixedYears).toBeNull(); + expect(last.type).toBe("PENSION"); + } + }); + + it("defaultOpenDuration zielt auf ~90 Jahre, mindestens 5", () => { + expect(defaultOpenDuration([{ age: 45 }], 20)).toBe(25); // 90 - (45+20) + expect(defaultOpenDuration([{ age: 88 }], 6)).toBe(5); // Untergrenze + expect(defaultOpenDuration([{ age: 50 }, { age: 60 }], 5)).toBe(25); // ältester zählt: 90-(60+5) + }); +}); diff --git a/src/lib/phaseplan.ts b/src/lib/phaseplan.ts new file mode 100644 index 0000000..703f0a8 --- /dev/null +++ b/src/lib/phaseplan.ts @@ -0,0 +1,50 @@ +// Ableitung der Lebensabschnitts-"Teile" aus den fixen Pensionierungszeitpunkten. +// +// Die Pensionsalter der Personen sind Fixpunkte auf der Lebenslinie. Zwischen Planbeginn und +// diesen Punkten entstehen Abschnitte mit konstantem Erwerbsstatus -- Erwerb (alle arbeiten), +// Misch (eine Person pensioniert, eine arbeitet) oder Pension (alle pensioniert). Genau diese +// Abschnitte muss der Assistent respektieren: Eine Phase darf keinen Pensionierungspunkt +// überspannen (die Berechnung leitet den Phasentyp am Phasenbeginn ab und kappt entsprechend, +// siehe SPEZIFIKATION 2.3 / maxPhaseDuration). +// +// Reine Funktion, ohne UI und ohne I/O -- nur vom Assistenten verwendet. + +export type SegmentType = "ERWERB" | "MIXED" | "PENSION"; + +export interface PlanSegment { + type: SegmentType; + fromYear: number; // Jahre ab Planbeginn (Beginn des Abschnitts) + // Feste Länge in Jahren (durch den nächsten Pensionierungspunkt bestimmt) ODER null für den + // letzten, OFFENEN Abschnitt (Pension bis Lebensende -- Dauer frei wählbar). + fixedYears: number | null; +} + +// Ein Person-Erwerbsstatus wechselt genau einmal: im Jahr `retirementAge - age` (auf 0 gekappt, +// falls bei Planbeginn bereits pensioniert). Die distinkten dieser Zeitpunkte sind die +// Abschnittsgrenzen; der Abschnitt nach dem letzten Zeitpunkt ist offen. +export function planSegments(persons: { age: number; retirementAge: number }[]): PlanSegment[] { + if (persons.length === 0) return [{ type: "PENSION", fromYear: 0, fixedYears: null }]; + + const retYears = persons.map((p) => Math.max(0, p.retirementAge - p.age)); + const events = [...new Set(retYears.filter((r) => r > 0))].sort((a, b) => a - b); + const boundaries = [0, ...events]; + + const segments: PlanSegment[] = []; + for (let i = 0; i < boundaries.length; i++) { + const from = boundaries[i]; + const next = i + 1 < boundaries.length ? boundaries[i + 1] : null; + // Eine Person arbeitet in einem Abschnitt, der bei Jahr `from` beginnt, genau dann, wenn + // ihr Pensionierungszeitpunkt echt später liegt. + const working = retYears.filter((r) => r > from).length; + const type: SegmentType = working === persons.length ? "ERWERB" : working > 0 ? "MIXED" : "PENSION"; + segments.push({ type, fromYear: from, fixedYears: next === null ? null : next - from }); + } + return segments; +} + +// Vorgeschlagene Dauer des offenen Pensions-Abschnitts: bis das älteste Haushaltsmitglied +// ungefähr 90 ist, mindestens aber 5 Jahre. +export function defaultOpenDuration(persons: { age: number }[], fromYear: number): number { + const oldestNow = persons.length > 0 ? Math.max(...persons.map((p) => p.age)) : 65; + return Math.max(5, 90 - (oldestNow + fromYear)); +}