feat(t5): Lebenslinie auf der Volksseite (T5b B4)

- Karte „Lebenslinie“ als erstes Kind der zweiten Reihe, über den
  Karteikarten: Stärke in besetzten Waben, Milbenbalken mit Wort,
  Königinnen mit Code und Markierungsfarbe, Behandlungen schraffiert,
  Ereignisse (nahe beieinander zu einem Symbol zusammengefasst) und die
  nächsten 21 Tage.
- Beschriftung als HTML in fester Größe, Legende, Tabelle als
  gleichwertige Alternative, „Ganzes Leben zeigen“; ohne Leserecht keine
  falschen Leerhinweise; keine Animation.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick.RosenloecherandClaude Opus 5.5 committed 2026-10-19 16:47:55 +02:00
1 parent f9c49074d1
commit 39eee3cdb1
8 files changed
+1010 -4

No files matched your search

@@ -35,6 +35,9 @@ vi.mock("@/components/autopilot/next-steps-card", () => ({
NextStepsCard: ({ colonyId }: { colonyId: string }) => <p>Als Nächstes für {colonyId}</p>,
}));
vi.mock("@/components/qr-code-dialog", () => ({ QrCodeDialog: () => null }));
vi.mock("@/components/colonies/colony-lifeline", () => ({
ColonyLifeline: ({ colonyId }: { colonyId: string }) => <div data-testid="lifeline">{colonyId}</div>,
}));
vi.mock("@/components/voice/voice-entry-link", () => ({
VoiceEntryLink: ({ colonyId }: { colonyId: string }) => (
<a href={`/voice/new?colonyId=${colonyId}`}>Sprachnotiz aufnehmen</a>
@@ -198,13 +201,14 @@ describe("ColonyDetailPage – grid", () => {
expect(row).not.toHaveClass("items-start");
});
it("keeps the order Stammdaten, Beute, Aktionen, Karten", async () => {
it("keeps the order Stammdaten, Beute, Aktionen, Lebenslinie, Karten", async () => {
renderPage();
const nodes = [
await screen.findByTestId("edit-card"),
screen.getByTestId("hive-card"),
screen.getByTestId("actions"),
screen.getByTestId("lifeline"),
screen.getByText("Behandlungen, Dialog für keine"),
];
for (let i = 1; i < nodes.length; i += 1) {
@@ -221,4 +225,21 @@ describe("ColonyDetailPage – grid", () => {
expect(secondRow.contains(screen.getByText("Behandlungen, Dialog für keine"))).toBe(true);
expect(secondRow.contains(screen.getByTestId("hive-card"))).toBe(false);
});
it("the lifeline opens row 2 above the record cards and leaves row 1 to Stammdaten and Beute", async () => {
renderPage();
const lifeline = await screen.findByTestId("lifeline");
expect(lifeline).toHaveTextContent("colony-1");
const firstRow = screen.getByTestId("edit-card").parentElement!;
expect(firstRow.contains(lifeline)).toBe(false);
// First child of the 2-column area of row 2, above the xl:grid-cols-2 grid of cards.
const area = lifeline.parentElement!;
expect(area).toHaveClass("lg:col-span-2");
expect(area.firstElementChild).toBe(lifeline);
const cards = screen.getByText("Behandlungen, Dialog für keine").closest(".grid")!;
expect(cards).toHaveClass("xl:grid-cols-2");
expect(cards.parentElement).toBe(area);
expect(cards.contains(lifeline)).toBe(false);
});
});
@@ -10,6 +10,7 @@ import { COLONY_STATUS_LABELS, COLONY_TYPE_LABELS, colonyStrengthText } from "@/
import { exportColonyAnnualReportPdf, exportColonyRecordCardPdf } from "@/lib/export/api";
import { HiveCard } from "@/components/hive/hive-card";
import { NextStepsCard } from "@/components/autopilot/next-steps-card";
import { ColonyLifeline } from "@/components/colonies/colony-lifeline";
import { QrCodeDialog } from "@/components/qr-code-dialog";
import { DocumentUploadCard } from "@/components/document-upload-card";
import { DetailHeader, DetailQueryFallback, DetailStatus } from "@/components/detail-header";
@@ -106,6 +107,8 @@ export default function ColonyDetailPage() {
</div>
</div>
<div className="flex flex-col gap-4 lg:col-span-2">
{/* Row 2 opens with the lifeline, above the record cards (spec 8.3, review B4). */}
<ColonyLifeline colonyId={colony.id} />
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
<ColonyInspectionsCard colonyId={colony.id} />
<ColonyVarroaCard colonyId={colony.id} />
@@ -0,0 +1,387 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ColonyLifeline as LifelineState } from "@/lib/colonies/use-colony-lifeline";
import type { LifelineModel } from "@/lib/colonies/lifeline";
import { useColonyLifeline } from "@/lib/colonies/use-colony-lifeline";
import { ColonyLifeline } from "./colony-lifeline";
vi.mock("@/lib/colonies/use-colony-lifeline", () => ({ useColonyLifeline: vi.fn() }));
const DESC = "Volk B3 seit 12.03.2025. Stärke zuletzt 9 Waben am 02.10., höchstens 14 Waben im Juli.";
function model(overrides: Partial<LifelineModel> = {}): LifelineModel {
return {
colonyCode: "B3",
startDate: "2025-03-12",
from: "2025-10-19",
to: "2026-11-09",
strength: {
points: [
{ date: "2026-06-01", frames: 6 },
{ date: "2026-07-10", frames: 14 },
{ date: "2026-10-02", frames: 9 },
],
breaks: [],
max: 20,
},
mites: {
bars: [
{ from: "2026-08-25", to: "2026-08-28", perDay: 8.4, light: "yellow", redAbove: 10 },
{ from: "2026-09-25", to: "2026-09-28", perDay: 2.1, light: "green", redAbove: 10 },
],
},
treatments: [{ from: "2026-09-01", to: "2026-09-14", product: "Oxuvar" }],
queen: { spans: [{ from: "2026-04-20", to: null, code: "Q-12", color: "blue", colorWord: "blau", hatchYear: 2025 }] },
events: [
{ date: "2026-04-20", kind: "queens", count: 1 },
{ date: "2026-09-01", kind: "treatments", count: 2 },
],
future: [{ date: "2026-10-25", dueOn: "2026-10-25", overdue: false, title: "Durchsicht mit Schwarmkontrolle" }],
withdrawalUntil: null,
missing: [],
hasData: true,
hasDataInWindow: true,
desc: DESC,
table: [
{ date: "2026-10-25", event: "Durchsicht mit Schwarmkontrolle", value: "geplant" },
{ date: "2026-10-02", event: "Durchsicht", value: "9 besetzte Waben" },
{ date: "2026-09-28", event: "Varroa", value: "2,1 Milben/Tag · Gut" },
{ date: "2026-09-01", event: "Behandlung Oxuvar", value: "01.09.–14.09." },
],
...overrides,
};
}
function state(overrides: Partial<LifelineState> = {}): LifelineState {
return { model: model(), isPending: false, isError: false, isOffline: false, refetch: vi.fn(), ...overrides };
}
function renderLifeline(value: LifelineState = state(), client = new QueryClient()) {
vi.mocked(useColonyLifeline).mockReturnValue(value);
return render(
<QueryClientProvider client={client}>
<ColonyLifeline colonyId="c-1" />
</QueryClientProvider>,
);
}
function graphicOf(figure: HTMLElement): HTMLElement {
return within(figure).getByRole("img", { name: /^Lebenslinie/ });
}
describe("ColonyLifeline", () => {
beforeEach(() => {
vi.mocked(useColonyLifeline).mockReset();
});
it("renders a figure with the title and the description", () => {
renderLifeline();
const figure = screen.getByRole("figure");
const graphic = graphicOf(figure);
expect(graphic.tagName.toLowerCase()).toBe("svg");
expect(graphic.querySelector("title")).toHaveTextContent("Lebenslinie B3");
expect(graphic.querySelector("desc")).toHaveTextContent(DESC);
// The queen's marking colour is a word too, never colour alone (spec 8.4).
expect(figure).toHaveTextContent("Q-12 · Schlupf 2025 · blau · seit 20.04.2026");
// No SVG text: labels are HTML at a fixed size (spec 4.5, colony-varroa).
expect(graphic.querySelector("text")).toBeNull();
});
it("„Als Tabelle“ opens the same entries as the graphic", async () => {
renderLifeline();
const toggle = screen.getByRole("button", { name: "Als Tabelle" });
expect(toggle).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByRole("table")).not.toBeInTheDocument();
// aria-controls points at a real element even while the table is closed.
const panel = document.getElementById(toggle.getAttribute("aria-controls") ?? "");
expect(panel).not.toBeNull();
expect(panel).not.toBeVisible();
await userEvent.click(toggle);
expect(toggle).toHaveAttribute("aria-expanded", "true");
const table = screen.getByRole("table");
expect(panel).toBeVisible();
expect(panel).toContainElement(table);
expect(within(table).getAllByRole("columnheader").map((cell) => cell.textContent)).toEqual([
"Datum",
"Ereignis",
"Wert",
]);
const rows = within(table).getAllByRole("row").slice(1);
expect(rows).toHaveLength(model().table.length);
expect(rows[1]).toHaveTextContent("02.10.2026Durchsicht9 besetzte Waben");
expect(within(rows[1]).getByRole("rowheader")).toHaveTextContent("02.10.2026");
expect(rows[3]).toHaveTextContent("01.09.2026Behandlung Oxuvar01.09.–14.09.");
});
it("„Ganzes Leben zeigen“ widens the window and flips its label", async () => {
renderLifeline();
expect(useColonyLifeline).toHaveBeenLastCalledWith("c-1", { showAll: false });
const widen = screen.getByRole("button", { name: "Ganzes Leben zeigen" });
expect(widen).toHaveAttribute("aria-pressed", "false");
await userEvent.click(widen);
expect(useColonyLifeline).toHaveBeenLastCalledWith("c-1", { showAll: true });
const narrow = screen.getByRole("button", { name: "Letzte 12 Monate" });
expect(narrow).toHaveAttribute("aria-pressed", "true");
});
it("shows the empty state with the outline button when nothing was recorded", () => {
renderLifeline(
state({
model: model({
strength: { points: [], breaks: [], max: 20 },
mites: { bars: [] },
treatments: [],
queen: { spans: [] },
events: [],
future: [],
hasData: false,
hasDataInWindow: false,
table: [],
}),
}),
);
expect(
screen.getByText("Noch keine Durchsicht. Die Lebenslinie wächst mit jeder Durchsicht."),
).toBeInTheDocument();
const record = screen.getByRole("button", { name: "Durchsicht erfassen" });
expect(record).toHaveAttribute("href", "/inspections/new?colonyId=c-1");
// Outline, not honey: the page's main button stays „Durchsicht erfassen“ in the header.
expect(record).toHaveClass("border-border", "bg-background");
expect(record).not.toHaveClass("bg-primary");
expect(screen.queryByRole("figure")).not.toBeInTheDocument();
});
it("an empty colony still shows its queen and events", () => {
renderLifeline(
state({
model: model({ strength: { points: [], breaks: [], max: 20 }, mites: { bars: [] }, hasData: false, hasDataInWindow: false }),
}),
);
expect(screen.getByText(/Noch keine Durchsicht/)).toBeInTheDocument();
expect(screen.getByRole("figure")).toHaveTextContent("Q-12");
});
it("without varroa:read there is no mite column", () => {
renderLifeline(state({ model: model({ mites: undefined, missing: ["mites"] }) }));
const figure = screen.getByRole("figure");
expect(figure).not.toHaveTextContent("Milben/Tag");
expect(figure.querySelector('[data-track="mites"]')).toBeNull();
expect(figure.querySelector('[data-track="strength"]')).not.toBeNull();
});
it("a long gap leaves the line broken and says „keine Durchsicht“", () => {
renderLifeline(
state({
model: model({
strength: {
points: [
{ date: "2026-03-01", frames: 5 },
{ date: "2026-03-20", frames: 7 },
{ date: "2026-07-10", frames: 14 },
{ date: "2026-08-01", frames: 12 },
],
breaks: ["2026-07-10"],
max: 20,
},
}),
}),
);
const figure = screen.getByRole("figure");
expect(figure.querySelectorAll('[data-part="line"]')).toHaveLength(2);
expect(figure.querySelectorAll('[data-part="gap"]')).toHaveLength(1);
expect(within(figure).getByText("keine Durchsicht")).toBeInTheDocument();
});
it("clips a queen span and a mite bar that start before the window", () => {
renderLifeline(
state({
model: model({
queen: { spans: [{ from: "2024-05-01", to: null, code: "Q-7", color: "green", colorWord: "grün", hatchYear: 2024 }] },
mites: { bars: [{ from: "2025-10-16", to: "2025-10-20", perDay: 3, light: "green", redAbove: 10 }] },
}),
}),
);
const figure = screen.getByRole("figure");
const svg = graphicOf(figure);
const height = Number(svg.getAttribute("height"));
for (const part of figure.querySelectorAll<SVGRectElement>('[data-part="queen-rail"], [data-part="mite-bar"]')) {
const y = Number(part.getAttribute("y"));
expect(y + Number(part.getAttribute("height"))).toBeLessThanOrEqual(height);
}
// The queen started before the window: no start dot inside it.
expect(figure.querySelector('[data-part="queen-dot"]')).toBeNull();
});
it("names the withdrawal time under the graphic", () => {
renderLifeline(state({ model: model({ withdrawalUntil: "2026-08-20" }) }));
expect(screen.getByText("Wartezeit bis 20.08.")).toBeInTheDocument();
});
it("shows a skeleton while loading and a retry on error", async () => {
const client = new QueryClient();
const { rerender } = renderLifeline(state({ model: undefined, isPending: true }), client);
const skeleton = screen.getByLabelText("Lebenslinie wird geladen");
expect(skeleton).toHaveAttribute("aria-busy", "true");
expect(skeleton.className).not.toMatch(/animate-/);
const refetch = vi.fn();
vi.mocked(useColonyLifeline).mockReturnValue(state({ model: undefined, isError: true, refetch }));
rerender(
<QueryClientProvider client={client}>
<ColonyLifeline colonyId="c-1" />
</QueryClientProvider>,
);
expect(screen.getByRole("alert")).toHaveTextContent("Daten der Lebenslinie konnten nicht geladen werden.");
await userEvent.click(screen.getByRole("button", { name: "Erneut versuchen" }));
expect(refetch).toHaveBeenCalledTimes(1);
});
it("renders nothing when the colony cannot be read", () => {
const { container } = renderLifeline(state({ model: undefined }));
expect(container).toBeEmptyDOMElement();
});
it("keeps three years of data under 600 SVG nodes (spec 8.5)", () => {
const day = (offset: number) => new Date(Date.UTC(2023, 10, 1) + offset * 86_400_000).toISOString().slice(0, 10);
renderLifeline(
state({
model: model({
from: "2023-11-01",
strength: { points: Array.from({ length: 150 }, (_, i) => ({ date: day(i * 7), frames: 5 + (i % 15) })), breaks: [day(70)], max: 20 },
mites: { bars: Array.from({ length: 60 }, (_, i) => ({ from: day(i * 18), to: day(i * 18 + 3), perDay: i % 12, light: "green" as const, redAbove: 10 })) },
treatments: Array.from({ length: 6 }, (_, i) => ({ from: day(i * 180), to: day(i * 180 + 14), product: "Oxuvar" })),
}),
}),
);
const svg = graphicOf(screen.getByRole("figure"));
expect(svg.querySelectorAll("*").length).toBeLessThanOrEqual(600);
});
it("merges event icons closer than 20 px into one, keeping the strongest kind and the sum", () => {
renderLifeline(
state({
model: model({
events: [
{ date: "2026-04-20", kind: "queens", count: 1 },
{ date: "2026-09-01", kind: "feedings", count: 2 },
{ date: "2026-09-05", kind: "treatments", count: 1 },
],
}),
}),
);
const icons = screen.getByRole("figure").querySelectorAll('[data-part="event"]');
expect(icons).toHaveLength(2);
const merged = screen.getByRole("img", { name: /Behandlung/ });
expect(merged).toHaveAttribute("data-kind", "treatments");
expect(merged).toHaveAccessibleName("05.09.: Behandlung; 01.09.: Fütterung und 1 weitere");
expect(merged).toHaveTextContent("3");
expect(screen.getByRole("img", { name: "20.04.: Königin" })).toBeInTheDocument();
});
it("draws no treatment that starts after today", () => {
renderLifeline(
state({
model: model({
treatments: [
{ from: "2026-09-01", to: "2026-09-14", product: "Oxuvar" },
{ from: "2026-11-01", to: "2026-11-05", product: "Oxalsäure" },
],
}),
}),
);
expect(screen.getByRole("figure").querySelectorAll('[data-part="treatment"]')).toHaveLength(1);
});
it("labels the newest of two close red bars and names the light for forced colours", () => {
renderLifeline(
state({
model: model({
mites: {
bars: [
{ from: "2026-06-01", to: "2026-06-03", perDay: 12, light: "red", redAbove: 10 },
{ from: "2026-06-05", to: "2026-06-07", perDay: 14, light: "red", redAbove: 10 },
{ from: "2026-09-25", to: "2026-09-28", perDay: 2.1, light: "green", redAbove: 10 },
],
},
}),
}),
);
const figure = screen.getByRole("figure");
const labels = [...figure.querySelectorAll('[data-part="mite-value"]')].map((label) => label.textContent);
expect(labels).toEqual(["2,1", "14,0 (rot)"]);
expect(within(figure).getByText("(rot)")).toHaveClass("hidden", "forced-colors:inline");
});
it("explains its marks in a legend and names the strength unit", () => {
renderLifeline();
const legend = screen.getByRole("list", { name: "Legende" });
expect(legend).toHaveTextContent("Behandlung");
expect(legend).toHaveTextContent("geplant");
expect(screen.getByRole("figure")).toHaveTextContent("Stärke (besetzte Waben)");
});
it("rings the queen swatch with 2 px", () => {
renderLifeline();
expect(screen.getByRole("figure").querySelector('[data-part="queen-swatch"]')).toHaveClass(
"border-2",
"border-foreground",
);
});
it("does not ask for a first inspection when the inspections cannot be read", () => {
renderLifeline(
state({
model: model({
strength: undefined,
mites: undefined,
missing: ["strength", "mites"],
hasData: false,
hasDataInWindow: false,
}),
}),
);
expect(screen.queryByText(/Noch keine Durchsicht/)).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Durchsicht erfassen" })).not.toBeInTheDocument();
});
it("shows an empty value as a dash in the table", async () => {
renderLifeline(state({ model: model({ table: [{ date: "2026-09-02", event: "Ableger", value: "" }] }) }));
await userEvent.click(screen.getByRole("button", { name: "Als Tabelle" }));
expect(within(screen.getByRole("table")).getAllByRole("row")[1]).toHaveTextContent("02.09.2026Ableger—");
});
it("sizes the skeleton to the colony's age from the cache", () => {
const client = new QueryClient();
const created = new Date(Date.now() - 60 * 86_400_000).toISOString();
client.setQueryData(["colonies", "c-1"], { id: "c-1", code: "B3", createdAt: created });
renderLifeline(state({ model: undefined, isPending: true }), client);
const height = parseFloat(screen.getByLabelText("Lebenslinie wird geladen").style.height);
expect(height).toBeGreaterThanOrEqual(Math.round((59 + 21) * 1.6) + 40);
expect(height).toBeLessThanOrEqual(Math.round((62 + 21) * 1.6) + 40);
});
});
@@ -0,0 +1,160 @@
"use client";
import Link from "next/link";
import { useId, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { ChevronDown, ChevronUp, ClipboardCheck } from "lucide-react";
import { daysBetween, formatDayMonth } from "@/lib/autopilot/format";
import { berlinCalendarDay, todayLocalIsoDate } from "@/lib/dates";
import { FUTURE_DAYS, formatDay, type LifelineModel, type QueenColor } from "@/lib/colonies/lifeline";
import type { Colony } from "@/lib/colonies/types";
import { useColonyLifeline } from "@/lib/colonies/use-colony-lifeline";
import { ErrorState } from "@/components/query-state";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { HEADER_PX, LifelineSvg, PX_PER_DAY } from "./lifeline-svg";
import { LifelineTable } from "./lifeline-table";
const WINDOW_DAYS = 365;
/** The default window (at most 12 months, from the cached colony's start) plus the future zone and header. */
function skeletonHeight(createdAt: string | undefined): number {
const age = createdAt ? daysBetween(berlinCalendarDay(createdAt), todayLocalIsoDate()) : WINDOW_DAYS;
return Math.round((Math.min(WINDOW_DAYS, Math.max(0, age)) + FUTURE_DAYS) * PX_PER_DAY) + HEADER_PX;
}
const QUEEN_SWATCH: Record<QueenColor, string> = {
white: "bg-queen-white",
yellow: "bg-queen-yellow",
red: "bg-queen-red",
green: "bg-queen-green",
blue: "bg-queen-blue",
};
/** „Q-12 · Schlupf 2025 · blau · seit 20.04.2026“: the marking colour is always a word too. */
function QueenLine({ model }: { model: LifelineModel }) {
const span = model.queen?.spans.at(-1);
if (!span) return null;
const since = span.to ? `${formatDay(span.from)}–${formatDay(span.to)}` : `seit ${formatDay(span.from)}`;
return (
<p className="flex items-center gap-2 text-sm">
<span
aria-hidden="true"
data-part="queen-swatch"
className={cn(
"size-3 shrink-0 rounded-full border-2 border-foreground",
span.color ? QUEEN_SWATCH[span.color] : "border-dashed border-muted-foreground",
)}
/>
{[span.code, span.hatchYear ? `Schlupf ${span.hatchYear}` : "", span.colorWord, since].filter(Boolean).join(" · ")}
</p>
);
}
function hasGraphic(model: LifelineModel): boolean {
return model.hasData || model.events.length > 0 || (model.queen?.spans.length ?? 0) > 0;
}
function EmptyLifeline({ colonyId }: { colonyId: string }) {
return (
<div className="flex flex-col items-start gap-3">
<p className="text-base">Noch keine Durchsicht. Die Lebenslinie wächst mit jeder Durchsicht.</p>
<Button
size="touch"
variant="outline"
nativeButton={false}
render={<Link href={`/inspections/new?colonyId=${colonyId}`} />}
>
<ClipboardCheck aria-hidden />
Durchsicht erfassen
</Button>
</div>
);
}
/**
* The colony's life on one vertical time axis (spec 8.2–8.5): present on top, the future zone
* above it. Two buttons only; points and bars are not tappable, details sit in the table.
* Nothing animates (spec 17.5).
*/
export function ColonyLifeline({ colonyId }: { colonyId: string }) {
const [showAll, setShowAll] = useState(false);
const [isTableOpen, setTableOpen] = useState(false);
const tableId = useId();
const queryClient = useQueryClient();
const { model, isPending, isError, isOffline, refetch } = useColonyLifeline(colonyId, { showAll });
// No read right on the colony: the card stays away.
if (!model && !isPending && !isError && !isOffline) return null;
const showsGraphic = model !== undefined && hasGraphic(model);
return (
<Card>
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2">
<CardTitle>Lebenslinie</CardTitle>
{showsGraphic ? (
<div className="flex flex-wrap gap-2">
<Button type="button" size="touch" variant="outline" pressed={showAll} onClick={() => setShowAll(!showAll)}>
{showAll ? "Letzte 12 Monate" : "Ganzes Leben zeigen"}
</Button>
<Button
type="button"
size="touch"
variant="outline"
aria-expanded={isTableOpen}
aria-controls={tableId}
onClick={() => setTableOpen(!isTableOpen)}
>
Als Tabelle
{isTableOpen ? <ChevronUp aria-hidden /> : <ChevronDown aria-hidden />}
</Button>
</div>
) : null}
</CardHeader>
<CardContent className="flex flex-col gap-3">
{isPending ? (
<div
role="status"
aria-busy="true"
aria-label="Lebenslinie wird geladen"
className="rounded-xl bg-muted/60"
style={{ height: skeletonHeight(queryClient.getQueryData<Colony>(["colonies", colonyId])?.createdAt) }}
/>
) : isError ? (
<ErrorState what="Daten der Lebenslinie" onRetry={refetch} />
) : !model ? (
<p className="text-sm text-muted-foreground">Offline. Die Lebenslinie erscheint, sobald die Daten da sind.</p>
) : (
<>
{/* Without the right to read inspections, the card never asks for a first one. */}
{model.hasData ? null : model.missing.includes("strength") ? (
showsGraphic ? null : <p className="text-sm text-muted-foreground">Keine Daten für die Lebenslinie.</p>
) : (
<EmptyLifeline colonyId={colonyId} />
)}
{model.hasData && !model.hasDataInWindow ? (
<p className="text-sm text-muted-foreground">
In den letzten 12 Monaten keine Durchsicht und keine Messung. „Ganzes Leben zeigen“ zeigt ältere.
</p>
) : null}
{showsGraphic ? (
<figure className="flex flex-col gap-2">
<LifelineSvg model={model} />
<QueenLine model={model} />
{model.withdrawalUntil ? (
<p className="text-sm font-medium">{`Wartezeit bis ${formatDayMonth(model.withdrawalUntil)}`}</p>
) : null}
</figure>
) : null}
{showsGraphic ? (
<div id={tableId} hidden={!isTableOpen}>
<LifelineTable model={model} />
</div>
) : null}
</>
)}
</CardContent>
</Card>
);
}
@@ -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<StatusLight, string> = {
green: "fill-condition-good",
yellow: "fill-condition-average",
red: "fill-condition-critical",
};
const QUEEN_FILL: Record<QueenColor, string> = {
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<Record<StatusLight, string>> = { yellow: "gelb", red: "rot" };
const EVENT_WORDS: Partial<Record<AreaKey, string>> = {
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 (
<svg width="16" height="10" viewBox="0 0 16 10" aria-hidden="true" className="shrink-0 overflow-visible">
{children}
</svg>
);
}
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<HTMLDivElement>(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<T extends { y: number }>(candidates: T[], gap: number): T[] {
return candidates.reduce<T[]>(
(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 (
<span
className={cn("absolute whitespace-nowrap leading-none", className)}
style={{ left: x, top: y, transform: `translate(${shift}, -50%)` }}
>
{children}
</span>
);
}
/** 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 (
<div ref={ref} className="flex flex-col gap-1">
<div aria-hidden="true" className="relative h-9 text-xs text-muted-foreground">
{strength ? (
<>
<Label x={x0} y={7}>Stärke (besetzte Waben)</Label>
{grid.map((value) => <Label key={value} x={xOf(value)} y={27} align="center" className="tabular-nums">{value}</Label>)}
</>
) : null}
{model.mites ? (
<>
<Label x={width} y={7} align="end">Milben/Tag</Label>
{globalThreshold !== undefined ? <Label x={mxOf(globalThreshold)} y={27} align="center" className="tabular-nums">{formatThreshold(globalThreshold)}</Label> : null}
</>
) : null}
</div>
<div className="relative" style={{ height }}>
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} role="img" aria-labelledby={`${titleId} ${descId}`} className="block overflow-visible">
<title id={titleId}>{`Lebenslinie ${model.colonyCode}`}</title>
<desc id={descId}>{model.desc}</desc>
<defs>
<pattern id={hatchId} width={6} height={6} patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
<line x1={0} y1={0} x2={0} y2={6} strokeWidth={3} className={cn("stroke-chart-4/30", INK)} />
</pattern>
</defs>
{months.map((day) => (
<line key={day} x1={0} x2={MONTH_W - 6} y1={yOf(day)} y2={yOf(day)} strokeWidth={1} className="stroke-border" />
))}
{treatments.map((span, index) => {
const y = yOf(span.to);
return <rect key={index} data-part="treatment" x={hatch.x} width={hatch.w} y={y} height={Math.max(MIN_BAR, yOf(span.from) - y)} fill={`url(#${hatchId})`} />;
})}
{strength ? (
<g data-track="strength">
{grid.map((value) => (
<line key={value} x1={xOf(value)} x2={xOf(value)} y1={futureH} y2={bottom} strokeWidth={1} strokeDasharray="1 3" className="stroke-muted-foreground/60" />
))}
{segments.map((segment) => {
const line = segment.map((point) => `${point.x},${point.y}`).join(" ");
const first = segment[0];
const last = segment[segment.length - 1];
return (
<g key={first.date}>
<polygon points={`${x0},${last.y} ${line} ${x0},${first.y}`} className="fill-primary/18 sun:hidden forced-colors:hidden" />
<polyline data-part="line" points={line} fill="none" strokeWidth={2} strokeLinejoin="round" className={cn("stroke-foreground", INK)} />
</g>
);
})}
{gaps.map(({ from, to }, index) => (
<line key={index} data-part="gap" x1={from.x} y1={from.y} x2={to.x} y2={to.y} strokeWidth={1.5} strokeDasharray="4 4" className={cn("stroke-muted-foreground", INK)} />
))}
{points.map((point, index) => (
<circle key={index} cx={point.x} cy={point.y} r={POINT_RADIUS} className="fill-foreground forced-colors:fill-[CanvasText]" />
))}
</g>
) : null}
{model.mites ? (
<g data-track="mites">
{globalThreshold !== undefined ? (
<line data-part="threshold" x1={mxOf(globalThreshold)} x2={mxOf(globalThreshold)} y1={futureH} y2={bottom} strokeWidth={1.5} strokeDasharray="4 3" className={cn("stroke-foreground", INK)} />
) : null}
{bars.map((bar, index) => {
const { y, h } = barBox(bar);
return (
<g key={index}>
<rect data-part="mite-bar" x={mx0} y={y} width={Math.max(2, mxOf(bar.perDay) - mx0)} height={h} rx={1} className={cn(bar.light ? LIGHT_FILL[bar.light] : "fill-muted-foreground/50", "forced-colors:fill-[CanvasText]")} />
{bar.redAbove !== undefined ? (
<line data-part="red-above" x1={mxOf(bar.redAbove)} x2={mxOf(bar.redAbove)} y1={y - 2} y2={y + h + 2} strokeWidth={1.5} className={cn("stroke-foreground", INK)} />
) : null}
</g>
);
})}
</g>
) : null}
{model.queen?.spans.map((span) => {
const top = yOf(span.to ?? today);
return (
<g key={span.from}>
<rect data-part="queen-rail" x={queenX} y={top} width={4} height={Math.max(MIN_BAR, yOf(span.from) - top)} strokeWidth={1} strokeDasharray={span.color ? undefined : "3 2"} className={cn(span.color ? `${QUEEN_FILL[span.color]} stroke-foreground` : "fill-none stroke-muted-foreground", INK)} />
{span.from >= model.from ? (
<circle data-part="queen-dot" cx={queenX + 2} cy={yOf(span.from)} r={6} strokeWidth={2} className={cn(span.color ? QUEEN_FILL[span.color] : "fill-card", "stroke-foreground", INK)} />
) : null}
</g>
);
})}
{model.future.length > 0 ? (
<rect data-part="future-zone" x={1} y={1} width={width - 2} height={futureH - 2} rx={6} fill="none" strokeWidth={1} strokeDasharray="4 3" className={cn("stroke-muted-foreground", INK)} />
) : 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 <polygon key={`${entry.dueOn}-${entry.title}`} data-part="future" points={hexagon.join(" ")} fill="none" strokeWidth={1.5} className={cn("stroke-foreground", INK)} />;
})}
<line data-part="today" x1={0} x2={width} y1={futureH} y2={futureH} strokeWidth={2} className={cn("stroke-foreground", INK)} />
</svg>
<div aria-hidden="true" className="pointer-events-none absolute inset-0 text-xs">
<Label x={0} y={futureH + 9} className="font-medium">heute</Label>
{months.map((day) => {
const month = Number(day.slice(5, 7)) - 1;
return (
<Label key={day} x={0} y={yOf(day) + 9} className={month === 0 ? "font-semibold" : "text-muted-foreground"}>
{month === 0 ? day.slice(0, 4) : MONTHS[month]}
</Label>
);
})}
{model.future.map((entry, index) => (
<Label key={`${entry.dueOn}-${entry.title}`} x={x0} y={futureH - FUTURE_PAD / 2 - FUTURE_ROW * (index + 0.5)} className="max-w-[calc(100%-6rem)] truncate">
{`${entry.title} · ${entry.overdue ? `fällig seit ${formatDayMonth(entry.dueOn)}` : formatDayMonth(entry.dueOn)}`}
</Label>
))}
{valueLabels.map((point, index) => (
<Label key={index} x={point.x > (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}
</Label>
))}
{gaps.map(({ from, to }, index) => (
<Label key={index} x={x0} y={(from.y + to.y) / 2} className="rounded-sm bg-card px-0.5 text-muted-foreground">keine Durchsicht</Label>
))}
{barLabels.map(({ bar, y }, index) => (
<Label key={index} x={width} y={y} align="end" className="rounded-sm bg-card px-0.5 font-medium tabular-nums">
<span data-part="mite-value">
{`${formatMitesPerDay(bar.perDay)}${bar.perDay > scale ? " ▶" : ""}`}
{bar.light && LIGHT_SHORT[bar.light] ? <span className="hidden forced-colors:inline">{` (${LIGHT_SHORT[bar.light]})`}</span> : null}
</span>
</Label>
))}
</div>
{/* Not hidden: each icon names its events, also those merged into it. */}
<div className="pointer-events-none absolute inset-0 text-xs">
{events.map((event) => {
const { icon: Icon, tone } = AREAS[event.kind];
return (
<span
key={event.y}
role="img"
aria-label={event.label}
data-part="event"
data-kind={event.kind}
data-tone={tone}
style={{ left: eventX - 10, top: event.y - 10, "--tone": `var(--area-${tone})` } as CSSProperties}
className="absolute grid size-5 place-items-center rounded-full border border-(--tone) bg-card text-(--tone) sun:border-2"
>
<Icon aria-hidden="true" className="size-4" />
{event.count > 1 ? (
<span aria-hidden="true" className="absolute -right-2 -bottom-1.5 rounded-full bg-card px-0.5 leading-none text-foreground">
{event.count}
</span>
) : null}
</span>
);
})}
</div>
</div>
<ul aria-label="Legende" className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
{treatments.length > 0 ? (
<li className="flex items-center gap-1.5">
<Swatch><rect width="16" height="10" fill={`url(#${hatchId})`} className="stroke-muted-foreground" strokeWidth={0.5} /></Swatch>
Behandlung
</li>
) : null}
{model.future.length > 0 ? (
<li className="flex items-center gap-1.5">
<Swatch><polygon points="8,0 12.5,2.5 12.5,7.5 8,10 3.5,7.5 3.5,2.5" fill="none" strokeWidth={1.5} className={cn("stroke-foreground", INK)} /></Swatch>
geplant
</li>
) : null}
{gaps.length > 0 ? (
<li className="flex items-center gap-1.5">
<Swatch><line x1="0" x2="16" y1="5" y2="5" strokeWidth={1.5} strokeDasharray="4 4" className={cn("stroke-muted-foreground", INK)} /></Swatch>
Lücke über 45 Tage
</li>
) : null}
{hasUncoloredQueen ? (
<li className="flex items-center gap-1.5">
<Swatch><rect x="6" y="0" width="4" height="10" fill="none" strokeDasharray="3 2" className={cn("stroke-muted-foreground", INK)} /></Swatch>
Königin ohne Zeichenfarbe
</li>
) : null}
{bars.length > 0 ? (
<li className="flex items-center gap-1.5">
<Swatch>
<rect x="0" y="2" width="11" height="6" className="fill-condition-average forced-colors:fill-[CanvasText]" />
<line x1="13" x2="13" y1="0" y2="10" strokeWidth={1.5} className={cn("stroke-foreground", INK)} />
</Swatch>
Milben/Tag in Ampelfarbe, Strich = rote Grenze
</li>
) : null}
</ul>
</div>
);
});
@@ -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 (
<Table>
<caption className="sr-only">{`Lebenslinie ${model.colonyCode} als Tabelle`}</caption>
<TableHeader>
<TableRow>
<TableHead>Datum</TableHead>
<TableHead>Ereignis</TableHead>
<TableHead>Wert</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{model.table.map((row, index) => (
<TableRow key={`${row.date}-${index}`}>
<th scope="row" className="px-3 py-3 text-left align-middle font-normal whitespace-nowrap tabular-nums">
{formatDay(row.date)}
</th>
<TableCell>{row.event}</TableCell>
<TableCell>{row.value || "—"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
@@ -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<Record<QueenColor, string>> = {
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)}`;
}
@@ -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",