Zeitachse als Zeitstrahl: Beschriftung alternierend, Startjahr markant, aktive Phase hervorgehoben
Deploy App / deploy (push) Successful in 1m11s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 22:20:12 +02:00
parent 5842a84b20
commit a3c86fc559
2 changed files with 183 additions and 105 deletions
+20 -7
View File
@@ -4,10 +4,10 @@
| | |
|---|---|
| **Dokument** | Funktionale und Technische Spezifikation FPT |
| **Version** | 0.42.2 |
| **Version** | 0.42.3 |
| **Datum** | 2026-07-25 |
| **Status** | Lebendes Dokument |
| **Codestand** | Arbeitsstand nach `ff862ed` inkl. Memo 2026081703 (Branch `main`) |
| **Codestand** | Arbeitsstand nach `5842a84` inkl. Zeitstrahl (Branch `main`) |
| **Ersetzt** | `FDD_TDD_FPT.docx` (v1v5) 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.42.3 | 2026-08-17 | Claude (Opus 5) | **Die Zeitachse als klassischer Zeitstrahl.** Die Beschriftungen stehen neu **abwechselnd ober- und unterhalb** des Balkens, verbunden über ein Dreieck und eine dünne Linie. Das ist nicht Zierde, sondern löst ein Platzproblem: Nebeneinander gesetzte Beschriftungen kollidieren, sobald eine Phase kurz ist abwechselnd liegen Nachbarn auf derselben Seite zwei Phasen auseinander und haben den doppelten Raum. Jede Beschriftung nennt Jahresspanne, Phasenname und Dauer. Der Balken ist durchgehend und dunkel, seine Segmente teilen sich die Breite **streng nach Dauer**; die aktive Phase ist im Akzent gefüllt, samt Dreieck, Verbindungslinie und hervorgehobener Beschriftung. Das **Startjahr** steht als grosse Zahl am linken Ende es ist der Nullpunkt, auf den sich die reale Kaufkraft und die ganze Achse beziehen , das Planende kleiner rechts. Die Marker (Pensionierung je Person, Ruinalter, erfasste Ist-Jahre) liegen neu **auf** dem Balken statt darüber zu schweben; oberhalb kollidierten sie mit den Beschriftungen. |
| 0.42.2 | 2026-08-17 | Claude (Opus 5) | **Memo 2026081703** Feinschliff der neuen Oberfläche, sechs Punkte. (1) **Die Menüleiste schiebt, statt zu überlappen.** Beim Ausfahren legte sie sich über den Inhalt; ein Streifen verschwand jedes Mal, und das las sich wie ein Fehler. Neu ist der ausgefahrene Zustand optisch identisch mit dem angepinnten das Anpinnen entscheidet nur noch, ob es so BLEIBT. (2) **Suchen und Anpinnen stehen direkt unter den Systemparametern** statt am Fuss der Leiste. Die Leiste ist so hoch wie die Seite, und mit einer aufgeklappten Karte wird die sehr lang am Fuss waren die beiden ausserhalb des Blickfelds. Zusätzlich klebt der Inhalt beider Leisten neu am oberen Bildschirmrand (`sticky`), sonst scrollt die Steuerung beim Arbeiten weg. (3) **Die Karte nutzt nicht mehr die volle Breite**, sondern höchstens 46 rem. Eine Zeile aus 1300 px liest sich schlecht: Das Auge verliert beim Zeilenwechsel den Anschluss, und Beschriftung und Wert driften so weit auseinander, dass man sie nicht mehr zusammenbringt. Die frei werdende Luft links und rechts trägt zwei **grosse Pfeile** zur Nachbarphase ein Bedienelement, das keinen zusätzlichen Platz kostet. (4) **Die drei Akte sind sichtbar getrennt.** Vorher schied sie nur eine Haarlinie, und man las die Karte als eine lange Liste statt als drei Schritte. Neu trägt jeder Akt einen getönten Kopfstreifen über die volle Breite mit einer **Nummer im Kreis**; «Ergebnis» ist durchgehend getönt und trägt die Nummer im Akzent es ist der Schlusspunkt, nicht ein weiterer Abschnitt. (5) **Die Zeitachse neu gezeichnet**: kein Kasten mit Überschrift, keine Legende, keine Fusszeile. Stattdessen abgesetzte Kacheln, deren Abstand die Phasengrenze zeigt, dazwischen ein Pfeil für den Übergang, und die Marker (Pensionierung, Ruinalter, erfasste Ist-Jahre) sitzen als Pillen direkt an der Leiste statt darüber zu schweben. (6) **Cash verhält sich wie jede andere Zeile.** Es war der einzige Eintritts-Punkt, der das rechte Panel öffnete statt aufzuklappen auf einer Karte, die genau davon wegwollte, ein Fremdkörper. Neu klappt es inline auf, mit denselben Feldern (`CashTransitionFields`) und eigenem Speichern. |
| 0.42.1 | 2026-08-17 | Claude (Opus 5) | **Memo 2026081702** vier Befunde aus der ersten Testrunde der neuen Oberfläche. (1) **Die Kartenansicht war überhaupt nicht erreichbar** der Umschalter dauerhaft ausgegraut. Ursache: Die Bedingung lautete `forceMatrix = !!diff`, aber `computeScenarioDiff` liefert **immer** ein Objekt (bei Gleichheit ein leeres). Auch `diff.total > 0` wäre falsch gewesen: Ein Kind-Szenario weicht nach der ersten Änderung immer von seiner Vorlage ab, Karten wären dort für immer gesperrt. Die Diff-Markierungen sind eine **Verzierung** der Matrix, kein Modus. Neu erzwingt nur die **Ist-Ansicht** die Matrix (das ist ein Modus, in den man bewusst wechselt); bei Abweichungen zur Vorlage steht in der Kartenansicht ein Hinweis mit dem Weg dorthin. (2) **Die Menüleiste wechselte beim Überfahren die Darstellung** eingeklappt Icons, ausgefahren ein Plan-Baum. Das war kein Übergang, sondern ein Sprung. Neu ist es **eine Komponente in zwei Breiten**: Die Symbole sitzen in einer festen 3.5-rem-Spalte und bleiben exakt an ihrem Platz, rechts daneben fährt nur die Beschriftung aus. Damit entfällt der Baum aller Pläne; er wird nicht vermisst, weil der Plan über den Wähler oben gewechselt wird und die vier Bereiche sich immer auf den **aktiven** Plan beziehen. Plan anlegen und löschen sind in den Wähler gewandert, der Szenario-Baum hängt aufklappbar unter «Szenarien». (3) **Die Matrix schob sich vor die ausgefahrene Leiste.** Ihre sticky Köpfe tragen `z-40` und konkurrierten damit global; die Ecke oben links gewann. Die Matrix ist neu ein eigener **Stapel-Kontext** (`relative z-0`) ihre Köpfe stapeln sich nur noch untereinander. (4) **Die Kachel «Offene Punkte» zog sich über den ganzen Bildschirm**, auch mit einem einzigen Eintrag: ein `h-full` aus der Zeit, als die vier Kacheln in einem Raster gleich hoch sein mussten. In der rechten Leiste ist das falsch sie ist jetzt so hoch wie ihr Inhalt. |
| 0.42 | 2026-08-17 | Claude (Opus 5) | **Kartenansicht und Drei-Spalten-Shell** (neues Kapitel 3.15). Der grösste Eingriff in die Oberfläche seit 0.36 und er behebt eine Ursache statt Symptome. Ausgemessen war die Lage eindeutig: Der Phasenkopf trug 220 px an Kennzahlen, dauerhaft fixiert; von 675 px Matrixhöhe blieben 430 px zum Arbeiten. Fünf Phasen brauchten 1760 px Breite, sichtbar waren mit offenem Panel 1024 px. Und das Bearbeitungs-Panel (`fixed right-0`, 416 px) legte sich ausgerechnet über die Spalte, zu der man gerade gescrollt hatte. (1) **Eine Karte je Lebensphase** ersetzt die Matrix als Arbeitsansicht. Sie erzählt die Phase als Zeitablauf: **Eintritt** (der Übergang aus der Vorphase; bei Phase 1 der Bestand), **Laufend** (die Elemente mit ihren Annahmen und die Quote), **Ergebnis** (Vermögen und Cash am Phasenende). Horizontales Scrollen entfällt vollständig. Der Übergang gehört bewusst zur FOLGEphase dort wird das frei werdende Kapital verteilt, konsistent mit 0.41. (2) **Bearbeitet wird IN der Karte.** Ein Klick klappt die Zeile auf, die Felder stehen darunter. Damit kann nichts mehr verdecken, woran man arbeitet das Panel bleibt nur für die Matrix. Ein Knopf im Kartenkopf klappt **alle** Zeilen auf oder zu; bei acht Elementen ist das der Unterschied zwischen einmal durchsehen und acht Klicks. (3) **Die Zeitachse wird zum Navigator**: Das Segment der offenen Karte ist hervorgehoben, ein Klick springt hin. Dazu Pfeilknöpfe unten und die **Pfeiltasten** (ausser in Eingabefeldern sonst springt die Karte beim Tippen weg). (4) **Die Matrix bleibt als Vergleichsansicht.** Umschalter oben rechts; die Wahl gehört zum Benutzer und überlebt die Sitzung. Ein Szenario-Diff oder eine Ist-Ansicht **erzwingt** die Matrix: Vergleich über alle Phasen kann eine einzelne Karte prinzipiell nicht leisten, und stillschweigend die falsche Darstellung anzubieten wäre schlechter als der Wechsel. (5) **Drei-Spalten-Shell.** Die Menüleiste links ist eingeklappt eine **Icon-Schiene** (56 statt 256 px); beim Überfahren fährt sie als Overlay aus, ein **Pin** hält sie breit. Die vier Kacheln wandern von oben in eine **rechte Leiste** mit aufklappbaren Abschnitten. Das ist der entscheidende Tausch: Sie kosteten oben **vertikalen** Platz, rechts kosten sie horizontalen und Höhe ist auf einem Breitbildschirm die knappe Grösse. Eingeklappt bleibt die Zahl der offenen Punkte am Knopf sichtbar. Zusammen: statt 1184 × 574 rund **1328 × 804** für den Inhalt, also +12 % Breite und **+40 % Höhe**. (6) Der Anzeige-Umschalter nominal/beide/real steht neu auch in der Werkzeugleiste (`ValueModeSwitch`, eine Implementierung für beide Orte). Neue Komponente `PhaseCards`; `ElementDetail` bekommt eine `compact`-Variante, `Timeline` die Navigator-Rolle, `Tile` eine rahmenlose Variante. Kein Eingriff in den Rechenkern; 337 Tests unverändert grün. |
@@ -2558,11 +2559,23 @@ Sie war bis 0.41 Dekoration. Neu ist das Segment der offenen Karte hervorgehoben
Klick springt hin. Das ist der Punkt, an dem sich Aussehen und Verständlichkeit treffen: Zeit
läuft von links nach rechts, das braucht keine Erklärung.
**Gezeichnet ist sie als Reihe abgesetzter Kacheln** (0.42.2). Der Abstand dazwischen zeigt die
Phasengrenze, ohne dass es dafür eine Trennlinie braucht; im Zwischenraum steht ein Pfeil für
den Übergang. Die Marker Pensionierung je Person, Ruinalter, erfasste Ist-Jahre sitzen als
Pillen direkt an der Leiste, statt als Fähnchen darüber zu schweben. Entfallen sind die
Überschrift (eine Zeitachse erkennt man), der Rahmen und die Legende.
**Gezeichnet ist sie als klassischer Zeitstrahl** (0.42.3): ein durchgehender dunkler Balken,
dessen Segmente sich die Breite **streng nach Dauer** teilen, und Beschriftungen **abwechselnd
ober- und unterhalb**, verbunden über ein Dreieck und eine dünne Linie.
Das Abwechseln ist kein Stilmittel, sondern löst ein Platzproblem: Nebeneinander gesetzte
Beschriftungen kollidieren, sobald eine Phase kurz ist. Abwechselnd liegen Nachbarn auf
derselben Seite **zwei** Phasen auseinander und haben damit den doppelten Raum. Jede
Beschriftung nennt Jahresspanne, Phasenname und Dauer.
| Element | Gestaltung |
|---|---|
| Aktive Phase | Segment, Dreieck, Linie und Beschriftung im **Akzent** |
| Startjahr | grosse Zahl am linken Ende der Nullpunkt, auf den sich die reale Kaufkraft bezieht |
| Planende | kleiner am rechten Ende |
| Pensionierung, Ruinalter, Ist-Jahre | Marker **auf** dem Balken (oberhalb kollidierten sie mit den Beschriftungen) |
Entfallen sind die Überschrift (eine Zeitachse erkennt man), der Rahmen und die Legende.
Drei Wege durch die Phasen, weil verschiedene Leute verschieden arbeiten:
+163 -98
View File
@@ -1,6 +1,6 @@
"use client";
import { CalendarCheck, ChevronRight, Flag } from "lucide-react";
import { CalendarCheck, Flag } from "lucide-react";
import type { PhaseComputed } from "@/lib/calculations";
interface PersonAxis {
@@ -11,17 +11,16 @@ interface PersonAxis {
color: string;
}
// Die Zeitachse: die Lebensphasen als Segmente, Breite nach Dauer.
// Die Zeitachse: ein durchgehender Balken, die Lebensphasen als Segmente, Breite nach Dauer.
//
// Seit 0.42 ist sie zugleich der NAVIGATOR der Kartenansicht -- das aktive Segment ist
// hervorgehoben, ein Klick springt hin. Zeit laeuft von links nach rechts; das versteht man
// ohne Erklaerung.
// Seit 0.42 ist sie zugleich der NAVIGATOR der Kartenansicht -- ein Klick springt zur Phase.
// Zeit laeuft von links nach rechts; das versteht man ohne Erklaerung.
//
// 0.42.2 hat sie neu gezeichnet. Vorher war sie ein Kasten mit Ueberschrift, Legende, drei
// Sorten schwebender Marker und einer Fusszeile -- viel Beiwerk fuer wenig Aussage. Jetzt ist
// sie eine durchgehende Leiste aus abgesetzten Kacheln: keine Ueberschrift (eine Zeitachse
// erkennt man), keine Rahmen um Rahmen, die Marker sitzen AN der Leiste statt darueber zu
// schweben, und zwischen den Phasen steht ein Pfeil fuer den Uebergang.
// 0.42.3 gibt ihr die Form, die man von Zeitstrahlen kennt: Die Beschriftungen stehen
// ABWECHSELND ober- und unterhalb des Balkens, mit Dreieck und Verbindungslinie. Das ist nicht
// Zierde, sondern loest ein Platzproblem: Nebeneinander gesetzte Beschriftungen kollidieren,
// sobald eine Phase kurz ist. Abwechselnd hat jede den doppelten Raum -- Nachbarn auf
// derselben Seite liegen zwei Phasen auseinander.
export function Timeline({
phases,
persons,
@@ -46,114 +45,180 @@ export function Timeline({
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 span = Math.max(1, totalYears);
const pct = (age: number) => `${(Math.max(0, Math.min(span, age - minAge)) / span) * 100}%`;
// Position eines Alters auf dem Balken, in Prozent seiner Länge.
const pctOfAge = (age: number) => (Math.max(0, Math.min(span, age - minAge)) / span) * 100;
const yearOf = (yearsFromStart: number) => (startYear ? startYear + yearsFromStart : null);
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 { phase: p, from, to: from + p.durationYears, index: i };
});
const clickable = !!onSelectPhase;
const BAR = "var(--fg)";
// Ein Beschriftungsblock. Oben steht er auf dem Kopf (Karte, Linie, Dreieck), unten in
// umgekehrter Reihenfolge -- so zeigt das Dreieck immer zum Balken hin.
const labelFor = (s: (typeof segments)[number], above: boolean) => {
const active = activePhaseId === s.phase.id;
const from = yearOf(s.from);
const to = yearOf(s.to);
const tri = (
<span
aria-hidden="true"
className="h-0 w-0 border-x-[7px] border-x-transparent"
style={
above
? { borderBottomWidth: 9, borderBottomStyle: "solid", borderBottomColor: active ? "var(--accent)" : BAR }
: { borderTopWidth: 9, borderTopStyle: "solid", borderTopColor: active ? "var(--accent)" : BAR }
}
/>
);
const line = <span className="w-px flex-1" style={{ backgroundColor: active ? "var(--accent)" : "var(--border-strong)" }} />;
const card = (
<span
className={`max-w-[9.5rem] rounded-xl border px-2.5 py-1 text-center transition-colors ${
active
? "border-accent bg-accent-soft text-accent-soft-fg shadow-sm"
: "border-border bg-surface text-muted group-hover/tl:border-accent group-hover/tl:text-accent-soft-fg"
}`}
>
<span className={`block whitespace-nowrap text-[13px] font-semibold ${active ? "" : "text-fg"}`}>
{from ? (s.phase.durationYears === 1 ? `${from}` : `${from}${to! - 1}`) : `Phase ${s.index + 1}`}
</span>
<span className="block truncate text-[11px]">{s.phase.name}</span>
<span className="block text-[10px] text-faint">
{s.phase.durationYears} {s.phase.durationYears === 1 ? "Jahr" : "Jahre"}
</span>
</span>
);
return (
<button
key={s.phase.id}
type="button"
disabled={!clickable}
onClick={() => onSelectPhase?.(s.phase.id)}
aria-current={active ? "true" : undefined}
title={`${s.phase.name} · ${s.phase.durationYears} Jahre`}
className={`group/tl absolute bottom-0 top-0 flex -translate-x-1/2 flex-col items-center ${
clickable ? "cursor-pointer" : "cursor-default"
}`}
style={{ left: `${((s.from + s.phase.durationYears / 2) / span) * 100}%` }}
>
{above ? (
<>
{card}
{line}
{tri}
</>
) : (
<>
{tri}
{line}
{card}
</>
)}
</button>
);
};
return (
<div className="flex flex-col gap-1.5">
{/* Marker-Band. Es traegt Pensionierungen, das Ruinalter und erfasste Ist-Jahre --
dieselbe Prozent-Position wie die Segmente darunter, damit sie sich decken. */}
<div className="relative h-5">
{persons.map((p) =>
p.retirementAge > minAge && p.retirementAge < maxAge ? (
<span
key={p.role}
className="absolute bottom-0 flex -translate-x-1/2 items-center gap-1 whitespace-nowrap rounded-full px-1.5 py-0.5 text-[10px] font-medium"
style={{ left: pct(p.retirementAge), color: p.color, backgroundColor: `${p.color}1f` }}
title={`${p.label}: Pensionierung mit ${p.retirementAge}${
yearOf(p.retirementAge - minAge) ? ` (${yearOf(p.retirementAge - minAge)})` : ""
}`}
>
<Flag className="h-2.5 w-2.5" style={{ color: p.color }} />
{p.label} {p.retirementAge}
</span>
) : null
)}
{ruinAge != null && ruinAge > minAge && ruinAge <= maxAge && (
<span
className="absolute bottom-0 -translate-x-1/2 whitespace-nowrap rounded-full bg-danger-soft px-1.5 py-0.5 text-[10px] font-semibold text-danger"
style={{ left: pct(ruinAge) }}
title={`Kapital aufgebraucht mit Alter ${ruinAge}`}
>
Ruin {ruinAge}
</span>
)}
{startYear &&
actualYears.map((y) => {
const age = minAge + (y - startYear);
if (age < minAge || age > maxAge) return null;
const isLatest = y === actualYears[actualYears.length - 1];
return (
<span
key={y}
className="absolute bottom-0 flex -translate-x-1/2 items-center gap-0.5 whitespace-nowrap text-[10px] font-medium"
style={{ left: pct(age), color: isLatest ? "var(--attention)" : "var(--faint)" }}
title={isLatest ? `Effektive Werte für ${y} (aktuell)` : `Effektive Werte für ${y} (überholt)`}
>
<CalendarCheck className="h-2.5 w-2.5" />
{y}
</span>
);
})}
<div className="flex items-stretch gap-3">
{/* Das Startjahr steht markant am linken Ende -- es ist der Nullpunkt, auf den sich alle
realen Werte und die ganze Achse beziehen. */}
<div className="flex shrink-0 flex-col justify-center pb-1 text-right">
<span className="text-xl font-semibold leading-none text-fg">{startYear ?? minAge}</span>
<span className="mt-0.5 text-[10px] uppercase tracking-wide text-faint">
{startYear ? "Start" : "Jahre"}
</span>
</div>
{/* Die Leiste. Abgesetzte Kacheln statt eines geteilten Balkens: Der Abstand macht die
Phasengrenze sichtbar, ohne dass es dafür eine Trennlinie braucht. */}
<div className="flex items-stretch gap-1">
{segments.map((s, i) => {
const active = activePhaseId === s.phase.id;
const from = yearOf(s.from);
const to = yearOf(s.to);
return (
<div key={s.phase.id} className="flex min-w-0 items-stretch" style={{ width: `${(s.phase.durationYears / span) * 100}%` }}>
{i > 0 && (
<span className="flex w-4 shrink-0 items-center justify-center text-faint" title="Übergang">
<ChevronRight className="h-3.5 w-3.5" />
</span>
)}
<div className="relative min-w-0 flex-1">
{/* Beschriftungen oberhalb (Phase 1, 3, 5 …) */}
<div className="relative h-[68px]">{segments.filter((s) => s.index % 2 === 0).map((s) => labelFor(s, true))}</div>
{/* Der Balken. Die Segmente teilen sich die Breite streng nach Dauer -- eine Phase von
fünf Jahren ist halb so breit wie eine von zehn. */}
<div className="relative flex h-9 gap-[3px] overflow-hidden rounded-full">
{segments.map((s) => {
const active = activePhaseId === s.phase.id;
return (
<button
key={s.phase.id}
type="button"
disabled={!clickable}
onClick={() => onSelectPhase?.(s.phase.id)}
aria-current={active ? "true" : undefined}
aria-label={`${s.phase.name}, ${s.phase.durationYears} Jahre`}
title={`${s.phase.name} · ${s.phase.durationYears} Jahre`}
className={`flex min-w-0 flex-1 flex-col justify-center rounded-xl px-2.5 py-2 text-left transition-all ${
clickable ? "cursor-pointer" : ""
} ${
active
? "bg-accent text-accent-fg shadow-sm"
: s.phase.type === "PENSION"
? "bg-accent-soft text-accent-soft-fg hover:brightness-95"
: "bg-surface-2 text-muted hover:bg-accent-soft hover:text-accent-soft-fg"
}`}
>
<span className="truncate text-[11px] font-semibold">{s.phase.name}</span>
<span className={`truncate text-[10px] ${active ? "opacity-80" : "opacity-70"}`}>
{from ? `${from}${to! - 1}` : `${s.phase.durationYears} J.`}
</span>
</button>
</div>
);
})}
className={`min-w-0 transition-all ${clickable ? "cursor-pointer hover:brightness-110" : ""}`}
style={{
width: `${(s.phase.durationYears / span) * 100}%`,
backgroundColor: active ? "var(--accent)" : BAR,
opacity: active ? 1 : 0.85,
}}
/>
);
})}
{/* Marker auf dem Balken: Pensionierung je Person, Ruinalter, erfasste Ist-Jahre.
Sie liegen AUF dem Balken statt darüber -- oberhalb kollidierten sie mit den
Beschriftungen. */}
{persons.map((p) =>
p.retirementAge > minAge && p.retirementAge < maxAge ? (
<span
key={p.role}
className="pointer-events-none absolute inset-y-1 w-0.5 -translate-x-1/2 rounded-full"
style={{ left: `${pctOfAge(p.retirementAge)}%`, backgroundColor: p.color }}
title={`${p.label}: Pensionierung mit ${p.retirementAge}`}
/>
) : null
)}
{ruinAge != null && ruinAge > minAge && ruinAge <= maxAge && (
<span
className="pointer-events-none absolute inset-y-0 w-1 -translate-x-1/2"
style={{ left: `${pctOfAge(ruinAge)}%`, backgroundColor: "var(--danger)" }}
title={`Kapital aufgebraucht mit Alter ${ruinAge}`}
/>
)}
{startYear &&
actualYears.map((y) => {
const age = minAge + (y - startYear);
if (age < minAge || age > maxAge) return null;
const isLatest = y === actualYears[actualYears.length - 1];
return (
<span
key={y}
className="pointer-events-none absolute top-1/2 h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full ring-2"
style={{
left: `${pctOfAge(age)}%`,
backgroundColor: isLatest ? "var(--attention)" : "var(--faint)",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
["--tw-ring-color" as any]: "var(--surface)",
}}
title={isLatest ? `Effektive Werte für ${y} (aktuell)` : `Effektive Werte für ${y} (überholt)`}
/>
);
})}
</div>
{/* Beschriftungen unterhalb (Phase 2, 4, 6 …) */}
<div className="relative h-[68px]">{segments.filter((s) => s.index % 2 === 1).map((s) => labelFor(s, false))}</div>
</div>
<div className="flex justify-between px-0.5 text-[10px] text-faint">
<span>
{minAge} J.{yearOf(0) ? ` · ${yearOf(0)}` : ""}
{/* Rechts das Planende und, falls vorhanden, die Marker-Erklärung in Kurzform. */}
<div className="flex shrink-0 flex-col justify-center pb-1">
<span className="text-sm font-semibold leading-none text-muted">
{startYear ? startYear + totalYears - 1 : maxAge}
</span>
<span>
{maxAge} J.{startYear ? ` · ${startYear + totalYears - 1}` : ""}
<span className="mt-0.5 flex items-center gap-1 text-[10px] uppercase tracking-wide text-faint">
Ende
{persons.some((p) => p.retirementAge > minAge && p.retirementAge < maxAge) && (
<Flag className="h-2.5 w-2.5" aria-label="Pensionierung markiert" />
)}
{actualYears.length > 0 && <CalendarCheck className="h-2.5 w-2.5" aria-label="Effektive Werte markiert" />}
</span>
</div>
</div>