diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/page.spec.tsx index de7435b..1f985a1 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/page.spec.tsx @@ -35,6 +35,9 @@ vi.mock("@/components/autopilot/next-steps-card", () => ({ NextStepsCard: ({ colonyId }: { colonyId: string }) =>

Als Nächstes für {colonyId}

, })); vi.mock("@/components/qr-code-dialog", () => ({ QrCodeDialog: () => null })); +vi.mock("@/components/colonies/colony-lifeline", () => ({ + ColonyLifeline: ({ colonyId }: { colonyId: string }) =>
{colonyId}
, +})); vi.mock("@/components/voice/voice-entry-link", () => ({ VoiceEntryLink: ({ colonyId }: { colonyId: string }) => ( Sprachnotiz aufnehmen @@ -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); + }); }); diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/page.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/page.tsx index 036054c..76aae9b 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/page.tsx @@ -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() {
+ {/* Row 2 opens with the lifeline, above the record cards (spec 8.3, review B4). */} +
diff --git a/ApiCore-Frontend/src/components/colonies/colony-lifeline.spec.tsx b/ApiCore-Frontend/src/components/colonies/colony-lifeline.spec.tsx new file mode 100644 index 0000000..8f1297e --- /dev/null +++ b/ApiCore-Frontend/src/components/colonies/colony-lifeline.spec.tsx @@ -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 { + 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 { + 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( + + + , + ); +} + +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('[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( + + + , + ); + + 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); + }); +}); diff --git a/ApiCore-Frontend/src/components/colonies/colony-lifeline.tsx b/ApiCore-Frontend/src/components/colonies/colony-lifeline.tsx new file mode 100644 index 0000000..f900799 --- /dev/null +++ b/ApiCore-Frontend/src/components/colonies/colony-lifeline.tsx @@ -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 = { + 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 ( +

+

+ ); +} + +function hasGraphic(model: LifelineModel): boolean { + return model.hasData || model.events.length > 0 || (model.queen?.spans.length ?? 0) > 0; +} + +function EmptyLifeline({ colonyId }: { colonyId: string }) { + return ( +
+

Noch keine Durchsicht. Die Lebenslinie wächst mit jeder Durchsicht.

+ +
+ ); +} + +/** + * 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 ( + + + Lebenslinie + {showsGraphic ? ( +
+ + +
+ ) : null} +
+ + {isPending ? ( +
(["colonies", colonyId])?.createdAt) }} + /> + ) : isError ? ( + + ) : !model ? ( +

Offline. Die Lebenslinie erscheint, sobald die Daten da sind.

+ ) : ( + <> + {/* Without the right to read inspections, the card never asks for a first one. */} + {model.hasData ? null : model.missing.includes("strength") ? ( + showsGraphic ? null :

Keine Daten für die Lebenslinie.

+ ) : ( + + )} + {model.hasData && !model.hasDataInWindow ? ( +

+ In den letzten 12 Monaten keine Durchsicht und keine Messung. „Ganzes Leben zeigen“ zeigt ältere. +

+ ) : null} + {showsGraphic ? ( +
+ + + {model.withdrawalUntil ? ( +

{`Wartezeit bis ${formatDayMonth(model.withdrawalUntil)}`}

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