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:
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>,
|
NextStepsCard: ({ colonyId }: { colonyId: string }) => <p>Als Nächstes für {colonyId}</p>,
|
||||||
}));
|
}));
|
||||||
vi.mock("@/components/qr-code-dialog", () => ({ QrCodeDialog: () => null }));
|
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", () => ({
|
vi.mock("@/components/voice/voice-entry-link", () => ({
|
||||||
VoiceEntryLink: ({ colonyId }: { colonyId: string }) => (
|
VoiceEntryLink: ({ colonyId }: { colonyId: string }) => (
|
||||||
<a href={`/voice/new?colonyId=${colonyId}`}>Sprachnotiz aufnehmen</a>
|
<a href={`/voice/new?colonyId=${colonyId}`}>Sprachnotiz aufnehmen</a>
|
||||||
@@ -198,13 +201,14 @@ describe("ColonyDetailPage – grid", () => {
|
|||||||
expect(row).not.toHaveClass("items-start");
|
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();
|
renderPage();
|
||||||
|
|
||||||
const nodes = [
|
const nodes = [
|
||||||
await screen.findByTestId("edit-card"),
|
await screen.findByTestId("edit-card"),
|
||||||
screen.getByTestId("hive-card"),
|
screen.getByTestId("hive-card"),
|
||||||
screen.getByTestId("actions"),
|
screen.getByTestId("actions"),
|
||||||
|
screen.getByTestId("lifeline"),
|
||||||
screen.getByText("Behandlungen, Dialog für keine"),
|
screen.getByText("Behandlungen, Dialog für keine"),
|
||||||
];
|
];
|
||||||
for (let i = 1; i < nodes.length; i += 1) {
|
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.getByText("Behandlungen, Dialog für keine"))).toBe(true);
|
||||||
expect(secondRow.contains(screen.getByTestId("hive-card"))).toBe(false);
|
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 { exportColonyAnnualReportPdf, exportColonyRecordCardPdf } from "@/lib/export/api";
|
||||||
import { HiveCard } from "@/components/hive/hive-card";
|
import { HiveCard } from "@/components/hive/hive-card";
|
||||||
import { NextStepsCard } from "@/components/autopilot/next-steps-card";
|
import { NextStepsCard } from "@/components/autopilot/next-steps-card";
|
||||||
|
import { ColonyLifeline } from "@/components/colonies/colony-lifeline";
|
||||||
import { QrCodeDialog } from "@/components/qr-code-dialog";
|
import { QrCodeDialog } from "@/components/qr-code-dialog";
|
||||||
import { DocumentUploadCard } from "@/components/document-upload-card";
|
import { DocumentUploadCard } from "@/components/document-upload-card";
|
||||||
import { DetailHeader, DetailQueryFallback, DetailStatus } from "@/components/detail-header";
|
import { DetailHeader, DetailQueryFallback, DetailStatus } from "@/components/detail-header";
|
||||||
@@ -106,6 +107,8 @@ export default function ColonyDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4 lg:col-span-2">
|
<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">
|
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||||
<ColonyInspectionsCard colonyId={colony.id} />
|
<ColonyInspectionsCard colonyId={colony.id} />
|
||||||
<ColonyVarroaCard 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";
|
export const STRENGTH_IN_FRAMES_SINCE = "2026-10-17";
|
||||||
|
|
||||||
const GAP_DAYS = 45;
|
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 MAX_FUTURE = 3;
|
||||||
const MIN_AXIS = 20;
|
const MIN_AXIS = 20;
|
||||||
const AXIS_STEP = 5;
|
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",
|
white: "weiß", yellow: "gelb", red: "rot", green: "grün", blue: "blau",
|
||||||
};
|
};
|
||||||
/** One icon per day: the most telling kind of that day wins. */
|
/** 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" });
|
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. */
|
/** 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“ */
|
/** „12.03.2025“ */
|
||||||
function formatDay(day: string): string {
|
export function formatDay(day: string): string {
|
||||||
return `${formatDayMonth(day)}${day.slice(0, 4)}`;
|
return `${formatDayMonth(day)}${day.slice(0, 4)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -590,6 +590,9 @@ const DATA_VALUE_FILES = [
|
|||||||
"src/app/(app)/colonies/[id]/colony-varroa.tsx",
|
"src/app/(app)/colonies/[id]/colony-varroa.tsx",
|
||||||
"src/components/honeycomb-pattern.tsx",
|
"src/components/honeycomb-pattern.tsx",
|
||||||
"src/components/brand-mark.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).
|
// Task B1: the comb changes its fill at once after a sync (spec 6.8).
|
||||||
"src/components/honeycomb/honeycomb-cell.tsx",
|
"src/components/honeycomb/honeycomb-cell.tsx",
|
||||||
"src/components/honeycomb/apiary-comb.tsx",
|
"src/components/honeycomb/apiary-comb.tsx",
|
||||||
|
|||||||
Reference in new issue
Block a user