(["colonies", colonyId])?.createdAt) }}
+ />
+ ) : isError ? (
+
+ ) : !model ? (
+
Offline. Die Lebenslinie erscheint, sobald die Daten da sind.
+ ) : (
+ <>
+ {/* Without the right to read inspections, the card never asks for a first one. */}
+ {model.hasData ? null : model.missing.includes("strength") ? (
+ showsGraphic ? null :
Keine Daten für die Lebenslinie.
+ ) : (
+
+ )}
+ {model.hasData && !model.hasDataInWindow ? (
+
+ In den letzten 12 Monaten keine Durchsicht und keine Messung. „Ganzes Leben zeigen“ zeigt ältere.
+
+ ) : null}
+ {showsGraphic ? (
+
+
+
+ {model.withdrawalUntil ? (
+ {`Wartezeit bis ${formatDayMonth(model.withdrawalUntil)}`}
+ ) : null}
+
+ ) : null}
+ {showsGraphic ? (
+
+
+
+ ) : null}
+ >
+ )}
+
+
+ );
+}
diff --git a/ApiCore-Frontend/src/components/colonies/lifeline-svg.tsx b/ApiCore-Frontend/src/components/colonies/lifeline-svg.tsx
new file mode 100644
index 0000000..501d2ea
--- /dev/null
+++ b/ApiCore-Frontend/src/components/colonies/lifeline-svg.tsx
@@ -0,0 +1,402 @@
+"use client";
+
+import { memo, useId, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
+import { AREAS, type AreaKey } from "@/lib/areas";
+import { daysBetween, formatDayMonth } from "@/lib/autopilot/format";
+import { EVENT_ORDER, FUTURE_DAYS, type LifelineModel, type MiteBar, type QueenColor } from "@/lib/colonies/lifeline";
+import type { StatusLight } from "@/lib/colonies/status-light";
+import { formatMitesPerDay, formatThreshold } from "@/lib/varroa/format";
+import { cn } from "@/lib/utils";
+
+/** Spec 8.2: 1.6 px per day, about 48 px per month. */
+export const PX_PER_DAY = 1.6;
+/** Height of the axis header above the graphic (h-9 plus the gap). */
+export const HEADER_PX = 40;
+const MONTH_W = 44;
+const QUEEN_W = 16;
+const EVENT_W = 24;
+const LEFT = MONTH_W + QUEEN_W + EVENT_W;
+const MITES_W = 64;
+const MITES_W_WIDE = 96;
+const WIDE_FROM_PX = 480;
+/** Room right of the longest mite bar for its value. */
+const VALUE_ROOM = 26;
+const STRENGTH_PAD = 8;
+/** Phone card width before the first measurement (and in tests). */
+const DEFAULT_WIDTH = 328;
+const FUTURE_ROW = 22;
+const FUTURE_PAD = 8;
+const BOTTOM_PAD = 8;
+/** Value labels closer than this would overlap (spec 8.2). */
+const LABEL_GAP = 24;
+/** Event icons are 20 px; closer ones merge into one icon. */
+const EVENT_MERGE_PX = 20;
+const MIN_BAR = 4;
+const POINT_RADIUS = 4;
+const AXIS_STEP = 5;
+const MONTHS = ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"];
+const DAY_MS = 24 * 60 * 60 * 1000;
+
+const LIGHT_FILL: Record
= {
+ green: "fill-condition-good",
+ yellow: "fill-condition-average",
+ red: "fill-condition-critical",
+};
+const QUEEN_FILL: Record = {
+ white: "fill-queen-white",
+ yellow: "fill-queen-yellow",
+ red: "fill-queen-red",
+ green: "fill-queen-green",
+ blue: "fill-queen-blue",
+};
+const INK = "forced-colors:stroke-[CanvasText]";
+/** Forced colours drop the fill, so a red or yellow value names its light (spec 8.4). */
+const LIGHT_SHORT: Partial> = { yellow: "gelb", red: "rot" };
+const EVENT_WORDS: Partial> = {
+ queens: "Königin",
+ colonies: "Ableger oder Vereinigung",
+ apiaries: "Umsetzen",
+ treatments: "Behandlung",
+ "swarm-control": "Schwarmkontrolle",
+ feedings: "Fütterung",
+};
+
+type LifelineEvent = LifelineModel["events"][number];
+
+function eventText({ date, kind, count }: LifelineEvent): string {
+ const more = count > 1 ? ` und ${count - 1} weitere` : "";
+ return `${formatDayMonth(date)}: ${EVENT_WORDS[kind] ?? AREAS[kind].label}${more}`;
+}
+
+/** Newest first; an event less than 20 px below the cluster's first icon joins that icon. */
+function eventClusters(events: LifelineEvent[], yOf: (day: string) => number) {
+ const clusters = [...events].reverse().reduce<{ y: number; events: LifelineEvent[] }[]>((all, event) => {
+ const y = yOf(event.date);
+ const last = all.at(-1);
+ if (last && y - last.y < EVENT_MERGE_PX) return [...all.slice(0, -1), { ...last, events: [...last.events, event] }];
+ return [...all, { y, events: [event] }];
+ }, []);
+ return clusters.map(({ y, events: members }) => ({
+ y,
+ kind: members.reduce((best, { kind }) => (EVENT_ORDER.indexOf(kind) < EVENT_ORDER.indexOf(best) ? kind : best), members[0].kind),
+ count: members.reduce((sum, { count }) => sum + count, 0),
+ label: members.map(eventText).join("; "),
+ }));
+}
+
+function Swatch({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+function shiftDay(day: string, days: number): string {
+ return new Date(Date.parse(`${day}T00:00:00Z`) + days * DAY_MS).toISOString().slice(0, 10);
+}
+
+/** Card width in px; measured before paint, so the graphic never draws at a wrong width. */
+function useWidth() {
+ const ref = useRef(null);
+ const [width, setWidth] = useState(DEFAULT_WIDTH);
+ useLayoutEffect(() => {
+ const element = ref.current;
+ if (!element) return;
+ const measure = () => {
+ const next = Math.round(element.clientWidth);
+ if (next > 0) setWidth(next);
+ };
+ measure();
+ if (typeof ResizeObserver === "undefined") return;
+ const observer = new ResizeObserver(measure);
+ observer.observe(element);
+ return () => observer.disconnect();
+ }, []);
+ return { ref, width };
+}
+
+/** Keeps candidates in priority order whose y is at least `gap` from every one kept before. */
+function spaced(candidates: T[], gap: number): T[] {
+ return candidates.reduce(
+ (kept, candidate) => (kept.every((other) => Math.abs(other.y - candidate.y) >= gap) ? [...kept, candidate] : kept),
+ [],
+ );
+}
+
+function monthStarts(from: string, today: string): string[] {
+ const starts: string[] = [];
+ const cursor = new Date(`${from.slice(0, 7)}-01T00:00:00Z`);
+ for (cursor.setUTCMonth(cursor.getUTCMonth() + 1); cursor.toISOString().slice(0, 10) <= today; cursor.setUTCMonth(cursor.getUTCMonth() + 1)) {
+ starts.push(cursor.toISOString().slice(0, 10));
+ }
+ return starts;
+}
+
+function Label({ x, y, children, className, align = "start" }: { x: number; y: number; children: ReactNode; className?: string; align?: "start" | "end" | "center" }) {
+ const shift = { start: "0", end: "-100%", center: "-50%" }[align];
+ return (
+
+ {children}
+
+ );
+}
+
+/** One SVG for the lines, bars and rails; every text is HTML at a fixed size (spec 4.5, 8.2). */
+export const LifelineSvg = memo(function LifelineSvg({ model }: { model: LifelineModel }) {
+ const { ref, width } = useWidth();
+ const titleId = useId();
+ const descId = useId();
+ const hatchId = `hatch-${useId().replace(/[^\w-]/g, "")}`;
+
+ const today = shiftDay(model.to, -FUTURE_DAYS);
+ const futureH = model.future.length > 0 ? model.future.length * FUTURE_ROW + FUTURE_PAD : FUTURE_PAD;
+ const pastH = Math.max(0, daysBetween(model.from, today)) * PX_PER_DAY;
+ const bottom = futureH + pastH;
+ const height = bottom + BOTTOM_PAD;
+ const clamp = (day: string) => (day < model.from ? model.from : day > today ? today : day);
+ const yOf = (day: string) => futureH + daysBetween(clamp(day), today) * PX_PER_DAY;
+
+ const mitesW = model.mites ? (width >= WIDE_FROM_PX ? MITES_W_WIDE : MITES_W) : 0;
+ const x0 = LEFT + STRENGTH_PAD;
+ const x1 = width - mitesW - STRENGTH_PAD;
+ const mx0 = width - mitesW + 2;
+
+ const strength = model.strength;
+ const xOf = (frames: number) => x0 + (frames / (strength?.max ?? 1)) * (x1 - x0);
+ const points = (strength?.points ?? []).filter((point) => point.date <= today).map((point) => ({ ...point, x: xOf(point.frames), y: yOf(point.date) }));
+ const segments = points.reduce<(typeof points)[]>((all, point, index) => {
+ if (index === 0 || strength?.breaks.includes(point.date)) return [...all, [point]];
+ return [...all.slice(0, -1), [...all[all.length - 1], point]];
+ }, []);
+ const gaps = segments.slice(1).map((segment, index) => ({ from: segments[index][segments[index].length - 1], to: segment[0] }));
+ const ranked = [...points].reverse();
+ const extremes = [ranked[0], ranked.reduce((a, b) => (b.frames > a.frames ? b : a), ranked[0]), ranked.reduce((a, b) => (b.frames < a.frames ? b : a), ranked[0])];
+ const valueLabels = points.length > 0 ? spaced([...extremes, ...ranked], LABEL_GAP) : [];
+ const grid = strength ? Array.from({ length: strength.max / AXIS_STEP + 1 }, (_, index) => index * AXIS_STEP) : [];
+
+ const bars = model.mites?.bars ?? [];
+ const limit = Math.max(model.mites?.threshold ?? 0, ...bars.map((bar) => bar.redAbove ?? 0));
+ const scale = limit > 0 ? limit * 2 : Math.max(1, ...bars.map((bar) => bar.perDay));
+ const barMax = mitesW - VALUE_ROOM - 4;
+ const mxOf = (rate: number) => mx0 + (Math.min(rate, scale) / scale) * barMax;
+ const barBox = (bar: MiteBar) => {
+ const top = yOf(bar.to);
+ const h = Math.max(MIN_BAR, yOf(bar.from) - top);
+ return { y: Math.min(top, bottom - h), h };
+ };
+ const barLabels = spaced(
+ bars
+ .map((bar, index) => ({ bar, y: barBox(bar).y + barBox(bar).h / 2, rank: index === bars.length - 1 ? 0 : bar.light === "red" ? 1 : bar.light === "yellow" ? 2 : 3 }))
+ .filter(({ rank }) => rank < 3)
+ .sort((a, b) => a.rank - b.rank || a.y - b.y),
+ LABEL_GAP,
+ );
+ const globalThreshold = model.mites?.threshold !== undefined && !bars.some((bar) => bar.redAbove !== undefined) ? model.mites.threshold : undefined;
+ const hatch = model.mites ? { x: mx0 - 2, w: mitesW } : { x: x0, w: x1 - x0 };
+ const treatments = model.treatments.filter((span) => span.from <= today);
+
+ const queenX = MONTH_W + 6;
+ const eventX = MONTH_W + QUEEN_W + EVENT_W / 2;
+ const months = monthStarts(model.from, today).filter((day) => yOf(day) - futureH > 20);
+ const events = eventClusters(model.events.filter((event) => event.date <= today), yOf);
+ const hasUncoloredQueen = model.queen?.spans.some((span) => span.color === null) ?? false;
+
+ return (
+
+
+ {strength ? (
+ <>
+ Stärke (besetzte Waben)
+ {grid.map((value) => {value} )}
+ >
+ ) : null}
+ {model.mites ? (
+ <>
+ Milben/Tag
+ {globalThreshold !== undefined ? {formatThreshold(globalThreshold)} : null}
+ >
+ ) : null}
+
+
+
+ {`Lebenslinie ${model.colonyCode}`}
+ {model.desc}
+
+
+
+
+
+ {months.map((day) => (
+
+ ))}
+ {treatments.map((span, index) => {
+ const y = yOf(span.to);
+ return ;
+ })}
+ {strength ? (
+
+ {grid.map((value) => (
+
+ ))}
+ {segments.map((segment) => {
+ const line = segment.map((point) => `${point.x},${point.y}`).join(" ");
+ const first = segment[0];
+ const last = segment[segment.length - 1];
+ return (
+
+
+
+
+ );
+ })}
+ {gaps.map(({ from, to }, index) => (
+
+ ))}
+ {points.map((point, index) => (
+
+ ))}
+
+ ) : null}
+ {model.mites ? (
+
+ {globalThreshold !== undefined ? (
+
+ ) : null}
+ {bars.map((bar, index) => {
+ const { y, h } = barBox(bar);
+ return (
+
+
+ {bar.redAbove !== undefined ? (
+
+ ) : null}
+
+ );
+ })}
+
+ ) : null}
+ {model.queen?.spans.map((span) => {
+ const top = yOf(span.to ?? today);
+ return (
+
+
+ {span.from >= model.from ? (
+
+ ) : null}
+
+ );
+ })}
+ {model.future.length > 0 ? (
+
+ ) : null}
+ {model.future.map((entry, index) => {
+ const y = futureH - FUTURE_PAD / 2 - FUTURE_ROW * (index + 0.5);
+ const r = 7;
+ const hexagon = [0, 1, 2, 3, 4, 5].map((corner) => {
+ const angle = (Math.PI / 3) * corner - Math.PI / 2;
+ return `${eventX + r * Math.cos(angle)},${y + r * Math.sin(angle)}`;
+ });
+ return ;
+ })}
+
+
+
+ heute
+ {months.map((day) => {
+ const month = Number(day.slice(5, 7)) - 1;
+ return (
+
+ {month === 0 ? day.slice(0, 4) : MONTHS[month]}
+
+ );
+ })}
+ {model.future.map((entry, index) => (
+
+ {`${entry.title} · ${entry.overdue ? `fällig seit ${formatDayMonth(entry.dueOn)}` : formatDayMonth(entry.dueOn)}`}
+
+ ))}
+ {valueLabels.map((point, index) => (
+ (x0 + x1) / 2 ? point.x - 7 : point.x + 7} y={point.y} align={point.x > (x0 + x1) / 2 ? "end" : "start"} className="rounded-sm bg-card px-0.5 font-medium tabular-nums">
+ {point.frames}
+
+ ))}
+ {gaps.map(({ from, to }, index) => (
+ keine Durchsicht
+ ))}
+ {barLabels.map(({ bar, y }, index) => (
+
+
+ {`${formatMitesPerDay(bar.perDay)}${bar.perDay > scale ? " ▶" : ""}`}
+ {bar.light && LIGHT_SHORT[bar.light] ? {` (${LIGHT_SHORT[bar.light]})`} : null}
+
+
+ ))}
+
+ {/* Not hidden: each icon names its events, also those merged into it. */}
+
+ {events.map((event) => {
+ const { icon: Icon, tone } = AREAS[event.kind];
+ return (
+
+
+ {event.count > 1 ? (
+
+ {event.count}
+
+ ) : null}
+
+ );
+ })}
+
+
+
+ {treatments.length > 0 ? (
+
+
+ Behandlung
+
+ ) : null}
+ {model.future.length > 0 ? (
+
+
+ geplant
+
+ ) : null}
+ {gaps.length > 0 ? (
+
+
+ Lücke über 45 Tage
+
+ ) : null}
+ {hasUncoloredQueen ? (
+
+
+ Königin ohne Zeichenfarbe
+
+ ) : null}
+ {bars.length > 0 ? (
+
+
+
+
+
+ Milben/Tag in Ampelfarbe, Strich = rote Grenze
+
+ ) : null}
+
+
+ );
+});
diff --git a/ApiCore-Frontend/src/components/colonies/lifeline-table.tsx b/ApiCore-Frontend/src/components/colonies/lifeline-table.tsx
new file mode 100644
index 0000000..93a918f
--- /dev/null
+++ b/ApiCore-Frontend/src/components/colonies/lifeline-table.tsx
@@ -0,0 +1,29 @@
+import { formatDay, type LifelineModel } from "@/lib/colonies/lifeline";
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
+
+/** The lifeline as a table: the same model and span as the graphic, newest first (spec 8.4). */
+export function LifelineTable({ model }: { model: LifelineModel }) {
+ return (
+
+ {`Lebenslinie ${model.colonyCode} als Tabelle`}
+
+
+ Datum
+ Ereignis
+ Wert
+
+
+
+ {model.table.map((row, index) => (
+
+
+ {formatDay(row.date)}
+
+ {row.event}
+ {row.value || "—"}
+
+ ))}
+
+
+ );
+}
diff --git a/ApiCore-Frontend/src/lib/colonies/lifeline.ts b/ApiCore-Frontend/src/lib/colonies/lifeline.ts
index 3c4a16f..734f924 100644
--- a/ApiCore-Frontend/src/lib/colonies/lifeline.ts
+++ b/ApiCore-Frontend/src/lib/colonies/lifeline.ts
@@ -24,7 +24,8 @@ import type { Colony, ColonyHistory } from "./types";
export const STRENGTH_IN_FRAMES_SINCE = "2026-10-17";
const GAP_DAYS = 45;
-const FUTURE_DAYS = 21;
+/** Days ahead of today the window reaches (the future zone). */
+export const FUTURE_DAYS = 21;
const MAX_FUTURE = 3;
const MIN_AXIS = 20;
const AXIS_STEP = 5;
@@ -47,7 +48,7 @@ const COLOR_WORDS: Readonly> = {
white: "weiß", yellow: "gelb", red: "rot", green: "grün", blue: "blau",
};
/** One icon per day: the most telling kind of that day wins. */
-const EVENT_ORDER: readonly AreaKey[] = ["queens", "colonies", "apiaries", "treatments", "swarm-control", "feedings"];
+export const EVENT_ORDER: readonly AreaKey[] = ["queens", "colonies", "apiaries", "treatments", "swarm-control", "feedings"];
const MONTH = new Intl.DateTimeFormat("de-DE", { timeZone: "UTC", month: "long" });
/** A track left `undefined` could not be read (403) and drops out without an error. */
@@ -141,7 +142,7 @@ function yearBefore(day: string): string {
}
/** „12.03.2025“ */
-function formatDay(day: string): string {
+export function formatDay(day: string): string {
return `${formatDayMonth(day)}${day.slice(0, 4)}`;
}
diff --git a/ApiCore-Frontend/src/styles/baukasten-guard.spec.ts b/ApiCore-Frontend/src/styles/baukasten-guard.spec.ts
index 68e0085..b866cbf 100644
--- a/ApiCore-Frontend/src/styles/baukasten-guard.spec.ts
+++ b/ApiCore-Frontend/src/styles/baukasten-guard.spec.ts
@@ -590,6 +590,9 @@ const DATA_VALUE_FILES = [
"src/app/(app)/colonies/[id]/colony-varroa.tsx",
"src/components/honeycomb-pattern.tsx",
"src/components/brand-mark.tsx",
+ // Task B4: the lifeline appears at once, no build-up effect (spec 8.5, 17).
+ "src/components/colonies/colony-lifeline.tsx",
+ "src/components/colonies/lifeline-svg.tsx",
// Task B1: the comb changes its fill at once after a sync (spec 6.8).
"src/components/honeycomb/honeycomb-cell.tsx",
"src/components/honeycomb/apiary-comb.tsx",