Files
FPT/src/components/Timeline.tsx
T
admGitAICDS f41c3614b5
Deploy App / deploy (push) Successful in 1m10s
Kartenansicht je Lebensphase, Icon-Schiene links, Steuerung rechts
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 21:29:42 +02:00

187 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}