UI-Umbau: Analyse-Bereich, Planstart, Zeitachse mit Phasen
Deploy App / deploy (push) Successful in 1m44s

Sieben Anpassungen aus dem Feedback:

- Grafiken liegen neu im eigenen Bereich "Grafiken" (Dialog, Button oben) statt
  unter der Matrix. Die Matrix bleibt die ruhige Hauptansicht.
- Kennzahl "Geschaetzter Nachlass" entfernt -- sie war rechnerisch identisch mit
  dem nominalen Endvermoegen und suggerierte eine Zusatzinformation, die es nicht gab.
- Monte-Carlo-Button nach oben zu den Szenario-Aktionen verschoben.
- Neues Profilfeld "Planstart (Jahr)" (Scenario.startYear + Migration). Rein fuer
  die Darstellung -- die Berechnung rechnet unveraendert in relativen Jahren.
  Bestehende Szenarien werden auf das laufende Jahr gesetzt.
- Zeitachse zeigt die Lebensphasen als Segmente (Breite = Dauer, Einfaerbung nach
  Phasentyp) inkl. Jahresspanne, plus Jahres-Beschriftung an den Enden.
- Lebensphase bearbeiten neu als Popup statt Panel unter der Tabelle -- konsistent
  zu allen anderen Eingaben; Speichern schliesst.
- Vermoegensverlauf ueber ALLE Jahre statt nur ueber die Phasengrenzen. Dafuer
  fuehrt computePlan das Vermoegen neu pro Jahr mit (YearPoint.wealthNominal/
  wealthReal) -- dieselbe Groesse, die schon die Ruin-Erkennung berechnet. Damit
  werden Verlaeufe INNERHALB einer Phase sichtbar (z. B. Kapitalverzehr).

Zwei Tests fuer das Jahres-Vermoegen (58 -> 60). Spezifikation auf v0.9.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 13:40:31 +02:00
parent a5d4868c58
commit d203e504c0
16 changed files with 266 additions and 84 deletions
+46 -23
View File
@@ -11,16 +11,19 @@ interface PersonAxis {
color: string;
}
// Horizontale Zeitachse: Alter von links nach rechts, mit deutlich markiertem
// Pensionsalter je Person und Trennlinien an den Phasengrenzen.
// 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.
export function Timeline({
phases,
persons,
ruinAge,
startYear,
}: {
phases: PhaseComputed[];
persons: PersonAxis[];
ruinAge?: number | null;
startYear?: number | null;
}) {
if (phases.length === 0 || persons.length === 0) return null;
@@ -30,18 +33,18 @@ export function Timeline({
const span = Math.max(1, maxAge - minAge);
const pct = (age: number) => `${(Math.max(0, Math.min(span, age - minAge)) / span) * 100}%`;
const yearOf = (yearsFromStart: number) => (startYear ? startYear + yearsFromStart : null);
// Phasengrenzen (kumulierte Jahre).
const boundaries: { year: number; label: string }[] = [];
let acc = 0;
for (const p of phases) {
boundaries.push({ year: acc, label: p.name });
acc += p.durationYears;
}
// Segmente: Start-/Endjahr (relativ zum Planbeginn) je Phase -- ohne Mutation waehrend
// 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);
return { phase: p, from, to: from + p.durationYears };
});
return (
<div className="rounded-xl border border-border bg-surface p-4 shadow-sm">
<div className="mb-3 flex items-center justify-between">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-fg">Zeitachse</h3>
<div className="flex gap-3 text-xs text-muted">
{persons.map((p) => (
@@ -61,7 +64,9 @@ export function Timeline({
key={p.role}
className="absolute top-0 flex -translate-x-1/2 flex-col items-center"
style={{ left: pct(p.retirementAge) }}
title={`${p.label}: Pensionierung mit ${p.retirementAge}`}
title={`${p.label}: Pensionierung mit ${p.retirementAge}${
yearOf(p.retirementAge - minAge) ? ` (${yearOf(p.retirementAge - minAge)})` : ""
}`}
>
<Flag className="h-3.5 w-3.5" style={{ color: p.color }} fill={p.color} />
<span className="whitespace-nowrap text-[10px] font-medium" style={{ color: p.color }}>
@@ -86,21 +91,39 @@ export function Timeline({
</div>
)}
{/* Achse */}
<div className="relative h-2 w-full rounded-full bg-gradient-to-r from-accent-soft to-accent">
{boundaries.slice(1).map((b) => (
<div
key={b.year}
className="absolute top-0 h-2 w-px bg-surface/70"
style={{ left: pct(minAge + b.year) }}
/>
))}
{/* Phasen-Segmente: Breite proportional zur Dauer, Einfaerbung 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";
return (
<div
key={s.phase.id}
style={{ width: `${(s.phase.durationYears / span) * 100}%` }}
title={`${s.phase.name} · ${s.phase.durationYears} Jahre`}
className={`flex min-w-0 flex-col justify-center px-2 py-1.5 ${
i > 0 ? "border-l border-border" : ""
} ${s.phase.type === "PENSION" ? "bg-accent-soft" : s.phase.type === "MIXED" ? "bg-accent/25" : "bg-accent/70"}`}
>
<span className={`truncate text-[10px] font-semibold ${soft ? "text-accent-soft-fg" : "text-accent-fg"}`}>
{s.phase.name}
</span>
<span className={`truncate text-[9px] ${soft ? "text-accent-soft-fg" : "text-accent-fg"} opacity-80`}>
{s.phase.durationYears} J.
{yearOf(s.from) ? ` · ${yearOf(s.from)}${yearOf(s.to)}` : ""}
</span>
</div>
);
})}
</div>
{/* Alters-Beschriftung */}
{/* Alters- und Jahres-Beschriftung */}
<div className="mt-1 flex justify-between text-[11px] text-muted">
<span>{minAge} J.</span>
<span>{maxAge} J.</span>
<span>
{minAge} J.{yearOf(0) ? ` · ${yearOf(0)}` : ""}
</span>
<span>
{maxAge} J.{yearOf(totalYears) ? ` · ${yearOf(totalYears)}` : ""}
</span>
</div>
</div>
</div>