f41c3614b5
Deploy App / deploy (push) Successful in 1m10s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
187 lines
7.8 KiB
TypeScript
187 lines
7.8 KiB
TypeScript
"use client";
|
||
|
||
import { CalendarCheck, Flag } from "lucide-react";
|
||
import type { PhaseComputed } from "@/lib/calculations";
|
||
|
||
interface PersonAxis {
|
||
role: "PERSON_A" | "PERSON_B";
|
||
label: string;
|
||
currentAge: number;
|
||
retirementAge: number;
|
||
color: string;
|
||
}
|
||
|
||
// Horizontale Zeitachse: die Lebensphasen als aneinandergereihte Segmente (Breite = Dauer),
|
||
// darunter Alter und -- sofern ein Planstart gesetzt ist -- die Kalenderjahre. Pensionsalter
|
||
// je Person und ein allfälliges Ruinalter sind als Marker eingezeichnet.
|
||
export function Timeline({
|
||
phases,
|
||
persons,
|
||
ruinAge,
|
||
startYear,
|
||
actualYears = [],
|
||
activePhaseId,
|
||
onSelectPhase,
|
||
}: {
|
||
phases: PhaseComputed[];
|
||
persons: PersonAxis[];
|
||
ruinAge?: number | null;
|
||
startYear?: number | null;
|
||
// Kalenderjahre, für die effektive Werte erfasst sind (aufsteigend). Der jüngste Satz
|
||
// wird hervorgehoben, ältere bleiben blass -- sie sind überholt, aber nicht bedeutungslos.
|
||
actualYears?: number[];
|
||
// Seit 0.42 ist die Zeitachse nicht mehr nur Anzeige, sondern der NAVIGATOR der
|
||
// Kartenansicht: Das Segment der offenen Karte ist hervorgehoben, ein Klick springt hin.
|
||
// Zeit läuft von links nach rechts -- das versteht man ohne Erklärung.
|
||
activePhaseId?: string | null;
|
||
onSelectPhase?: (phaseId: string) => void;
|
||
}) {
|
||
if (phases.length === 0 || persons.length === 0) return null;
|
||
|
||
const totalYears = phases.reduce((s, p) => s + p.durationYears, 0);
|
||
const minAge = Math.min(...persons.map((p) => p.currentAge));
|
||
const maxAge = minAge + totalYears;
|
||
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);
|
||
|
||
// 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);
|
||
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 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) => (
|
||
<span key={p.role} className="flex items-center gap-1">
|
||
<span className="inline-block h-2 w-2 rounded-full" style={{ backgroundColor: p.color }} />
|
||
{p.label} (heute {p.currentAge})
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="relative pt-6">
|
||
{/* Pensionsmarker je Person */}
|
||
{persons.map((p, i) =>
|
||
p.retirementAge > minAge && p.retirementAge < maxAge ? (
|
||
<div
|
||
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}${
|
||
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 }}>
|
||
{p.retirementAge}
|
||
</span>
|
||
<div className="mt-0.5 h-3 w-px" style={{ backgroundColor: p.color, marginTop: i * 2 }} />
|
||
</div>
|
||
) : null
|
||
)}
|
||
|
||
{/* Ruin-Marker (Person A) */}
|
||
{ruinAge != null && ruinAge > minAge && ruinAge <= maxAge && (
|
||
<div
|
||
className="absolute top-0 flex -translate-x-1/2 flex-col items-center"
|
||
style={{ left: pct(ruinAge) }}
|
||
title={`Kapital aufgebraucht mit Alter ${ruinAge}`}
|
||
>
|
||
<span className="text-[10px] font-semibold" style={{ color: "var(--danger)" }}>
|
||
Ruin {ruinAge}
|
||
</span>
|
||
<div className="mt-0.5 h-4 w-px" style={{ backgroundColor: "var(--danger)" }} />
|
||
</div>
|
||
)}
|
||
|
||
{/* Marker für erfasste effektive Werte. Nur mit bekanntem Planstartjahr platzierbar --
|
||
ohne Kalenderbezug gäbe es keine Position auf der Achse. */}
|
||
{startYear &&
|
||
actualYears.map((y) => {
|
||
const age = minAge + (y - startYear);
|
||
if (age < minAge || age > maxAge) return null;
|
||
const isLatest = y === actualYears[actualYears.length - 1];
|
||
return (
|
||
<div
|
||
key={y}
|
||
className="absolute top-0 flex -translate-x-1/2 flex-col items-center"
|
||
style={{ left: pct(age), opacity: isLatest ? 1 : 0.35 }}
|
||
title={
|
||
isLatest
|
||
? `Effektive Werte erfasst für ${y} (aktuellster Stand)`
|
||
: `Effektive Werte erfasst für ${y} (überholt)`
|
||
}
|
||
>
|
||
<CalendarCheck
|
||
className="h-3.5 w-3.5"
|
||
style={{ color: isLatest ? "var(--attention)" : "var(--muted)" }}
|
||
/>
|
||
<span
|
||
className="whitespace-nowrap text-[10px] font-medium"
|
||
style={{ color: isLatest ? "var(--attention)" : "var(--muted)" }}
|
||
>
|
||
{y}
|
||
</span>
|
||
<div
|
||
className="mt-0.5 h-3 w-px"
|
||
style={{ backgroundColor: isLatest ? "var(--attention)" : "var(--muted)" }}
|
||
/>
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{/* 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";
|
||
const active = activePhaseId === s.phase.id;
|
||
const clickable = !!onSelectPhase;
|
||
return (
|
||
<button
|
||
key={s.phase.id}
|
||
type="button"
|
||
disabled={!clickable}
|
||
onClick={() => onSelectPhase?.(s.phase.id)}
|
||
aria-current={active ? "true" : undefined}
|
||
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 text-left transition-all ${
|
||
i > 0 ? "border-l border-border" : ""
|
||
} ${clickable ? "cursor-pointer hover:brightness-105" : ""} ${
|
||
s.phase.type === "PENSION" ? "bg-accent-soft" : s.phase.type === "MIXED" ? "bg-accent/25" : "bg-accent/70"
|
||
} ${active ? "ring-2 ring-inset ring-fg/70" : clickable ? "opacity-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>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Alters- und Jahres-Beschriftung */}
|
||
<div className="mt-1 flex justify-between text-[11px] text-muted">
|
||
<span>
|
||
{minAge} J.{yearOf(0) ? ` · ${yearOf(0)}` : ""}
|
||
</span>
|
||
<span>
|
||
{maxAge} J.{yearOf(totalYears) ? ` · ${yearOf(totalYears)}` : ""}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|