feat(t5): Waben-Standkarte für Bienenstände (T5b B1)
Backend E2E / e2e (push) Successful in 5m47s
Backend Image / build-and-push (push) Successful in 5m11s
Frontend Image / build-and-push (push) Successful in 16m19s

- Völker als Waben-Zellen (src/components/honeycomb, src/lib/honeycomb):
  Zelle = Link mit Symbol, Kürzel und vollständigem Namen inkl. Zustand,
  Rand für fällige Durchsicht; nie nur Farbe, keine Animation.
- Standseite mit Satz, Legende, „Braucht Blick“ und aufklappbarer Liste;
  Offline-Standseite aus dem Paket; /apiaries-Karten mit Wabe (erste 20).
- Nur das Sechseck nimmt Tipps an (Zeilen greifen ineinander), Breite vor
  dem ersten Bild gemessen, 403 ohne Fehlerkarte.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick.RosenloecherandClaude Opus 5.5 committed 2026-10-19 16:08:49 +02:00
1 parent 2a6f0b684c
commit 15e671e921
22 files changed
+1497 -30

No files matched your search

@@ -102,7 +102,12 @@ describe("codemod t5 focus and sizes (spec 4.5, 4.6, 10.8 step 5)", () => {
}); });
it("leaves nothing to migrate in src", () => { it("leaves nothing to migrate in src", () => {
const files = sourceFiles(join(process.cwd(), "src")); // Task B1: the comb cell drops its outline on purpose; its outer hexagon draws the ring
// (spec 6.6, the one exception of the baukasten guard rule „no outline-none“).
const exceptions = [join("src", "components", "honeycomb", "honeycomb-cell.tsx")];
const files = sourceFiles(join(process.cwd(), "src")).filter(
(file) => !exceptions.some((exception) => file.endsWith(exception)),
);
expect(files.length).toBeGreaterThan(100); expect(files.length).toBeGreaterThan(100);
const pending = files.filter((file) => { const pending = files.filter((file) => {
const text = readFileSync(file, "utf8"); const text = readFileSync(file, "utf8");
@@ -58,6 +58,15 @@ function renderCard() {
); );
} }
/** Opens „Alle n Völker als Liste“ and returns the T3 list it shows. */
async function openList(count: number): Promise<HTMLElement> {
const toggle = screen.getByRole("button", { name: `Alle ${count} Völker als Liste` });
await userEvent.click(toggle);
const panel = document.getElementById(toggle.getAttribute("aria-controls") ?? "");
if (!panel) throw new Error("list panel missing");
return panel;
}
describe("ApiaryColoniesCard", () => { describe("ApiaryColoniesCard", () => {
beforeEach(() => { beforeEach(() => {
window.sessionStorage.clear(); window.sessionStorage.clear();
@@ -74,10 +83,11 @@ describe("ApiaryColoniesCard", () => {
renderCard(); renderCard();
expect(await screen.findByRole("heading", { name: "Völker (2)" })).toBeInTheDocument(); expect(await screen.findByRole("heading", { name: "Völker (2)" })).toBeInTheDocument();
const first = screen.getByRole("link", { name: /A1/ }); const list = await openList(2);
const first = within(list).getByRole("link", { name: /A1/ });
expect(first).toHaveAttribute("href", "/colonies/c-1"); expect(first).toHaveAttribute("href", "/colonies/c-1");
expect(within(first).getByText("Gut")).toBeInTheDocument(); expect(within(first).getByText("Gut")).toBeInTheDocument();
const second = screen.getByRole("link", { name: /A2/ }); const second = within(list).getByRole("link", { name: /A2/ });
expect(within(second).getByText("Kritisch")).toBeInTheDocument(); expect(within(second).getByText("Kritisch")).toBeInTheDocument();
expect(within(second).getByText("Weisellos · 2 Warnungen")).toBeInTheDocument(); expect(within(second).getByText("Weisellos · 2 Warnungen")).toBeInTheDocument();
expect(within(first).getByText("Aktiv")).toBeInTheDocument(); expect(within(first).getByText("Aktiv")).toBeInTheDocument();
@@ -94,15 +104,34 @@ describe("ApiaryColoniesCard", () => {
renderCard(); renderCard();
await screen.findByRole("heading", { name: "Völker (3)" }); await screen.findByRole("heading", { name: "Völker (3)" });
const first = screen.getByRole("link", { name: /A1/ }); const list = await openList(3);
const first = within(list).getByRole("link", { name: /A1/ });
expect(within(first).getByText("Durchsicht fällig in 2 Tagen")).toBeInTheDocument(); expect(within(first).getByText("Durchsicht fällig in 2 Tagen")).toBeInTheDocument();
expect(within(first).getByText("Als Nächstes: Varroa messen")).toBeInTheDocument(); expect(within(first).getByText("Als Nächstes: Varroa messen")).toBeInTheDocument();
const second = screen.getByRole("link", { name: /A2/ }); const second = within(list).getByRole("link", { name: /A2/ });
expect(within(second).getByText(/Durchsicht seit 3 Tagen fällig/)).toBeInTheDocument(); expect(within(second).getByText(/Durchsicht seit 3 Tagen fällig/)).toBeInTheDocument();
const third = screen.getByRole("link", { name: /A3/ }); const third = within(list).getByRole("link", { name: /A3/ });
expect(within(third).queryByText(/Durchsicht/)).not.toBeInTheDocument(); expect(within(third).queryByText(/Durchsicht/)).not.toBeInTheDocument();
}); });
it("shows the colonies as a comb whose cells lead to the colony pages", async () => {
vi.mocked(fetchColonyOverview).mockResolvedValue(
overview([
row({ id: "c-2", code: "A2", feedStatus: "WEAK" }),
row({ id: "c-1", code: "A1" }),
]),
);
renderCard();
const comb = await screen.findByRole("list", { name: "Völker am Stand Obstwiese" });
const cells = within(comb).getAllByRole("link");
expect(cells.map((cell) => cell.getAttribute("href"))).toEqual(["/colonies/c-1", "/colonies/c-2"]);
expect(cells[1]).toHaveAccessibleName(/^A2 – Achtung: Futter schwach/);
expect(screen.getByText("1 Gut · 1 Achtung")).toBeInTheDocument();
// The T3 list stays as the fallback, folded away.
expect(screen.getByRole("button", { name: "Alle 2 Völker als Liste" })).toHaveAttribute("aria-expanded", "false");
});
it("„Runde starten\" opens the first colony's inspection with the round param", async () => { it("„Runde starten\" opens the first colony's inspection with the round param", async () => {
vi.mocked(fetchColonyOverview).mockResolvedValue( vi.mocked(fetchColonyOverview).mockResolvedValue(
overview([row({ id: "c-1", code: "A1" }), row({ id: "c-2", code: "A2" })]), overview([row({ id: "c-1", code: "A1" }), row({ id: "c-2", code: "A2" })]),
@@ -1,7 +1,7 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { ChevronRight, Play, Sprout } from "lucide-react"; import { ChevronRight, Play } from "lucide-react";
import { useColonyOverview } from "@/lib/apiaries/use-colony-overview"; import { useColonyOverview } from "@/lib/apiaries/use-colony-overview";
import type { ColonyOverview } from "@/lib/apiaries/field-types"; import type { ColonyOverview } from "@/lib/apiaries/field-types";
import { useCurrentUserId } from "@/lib/auth/auth-context"; import { useCurrentUserId } from "@/lib/auth/auth-context";
@@ -11,7 +11,7 @@ import { daysBetween, inspectionDueText } from "@/lib/autopilot/format";
import { todayLocalIsoDate } from "@/lib/dates"; import { todayLocalIsoDate } from "@/lib/dates";
import { startRound } from "@/lib/rounds/round-state"; import { startRound } from "@/lib/rounds/round-state";
import { StatusLightBadge } from "@/components/colonies/status-light-badge"; import { StatusLightBadge } from "@/components/colonies/status-light-badge";
import { EmptyState } from "@/components/empty-state"; import { ApiaryComb } from "@/components/honeycomb/apiary-comb";
import { LoadPackButton } from "@/components/offline/load-pack-button"; import { LoadPackButton } from "@/components/offline/load-pack-button";
import { ErrorState, LoadingState } from "@/components/query-state"; import { ErrorState, LoadingState } from "@/components/query-state";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -120,14 +120,11 @@ export function ApiaryColoniesCard({ apiary }: { apiary: ApiaryRef }) {
{!query.isLoading && !overview ? ( {!query.isLoading && !overview ? (
<ErrorState what="Völker" onRetry={() => void query.refetch()} isRetrying={query.isFetching} /> <ErrorState what="Völker" onRetry={() => void query.refetch()} isRetrying={query.isFetching} />
) : null} ) : null}
{overview && overview.rows.length === 0 ? ( {overview ? (
<EmptyState <ApiaryComb apiaryName={apiary.name} overview={overview} hrefFor={colonyPageHref}>
icon={Sprout} <ColonyOverviewLinks overview={overview} hrefFor={colonyPageHref} />
title="Noch keine Völker an diesem Stand" </ApiaryComb>
description="Sobald ein Volk hier steht, erscheint es mit seiner Ampel."
/>
) : null} ) : null}
{overview ? <ColonyOverviewLinks overview={overview} hrefFor={colonyPageHref} /> : null}
<div className="mt-2"> <div className="mt-2">
<LoadPackButton apiaryId={apiary.id} /> <LoadPackButton apiaryId={apiary.id} />
</div> </div>
@@ -1,13 +1,64 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react"; import { render, screen, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactElement } from "react"; import type { ReactElement } from "react";
import ApiariesPage from "./page"; import ApiariesPage from "./page";
import { fetchApiaries } from "@/lib/apiaries/api"; import { fetchApiaries } from "@/lib/apiaries/api";
import { fetchColonyOverview } from "@/lib/apiaries/field-api";
import type { Apiary } from "@/lib/apiaries/types";
import type { ColonyOverviewRow } from "@/lib/apiaries/field-types";
import { ApiError } from "@/lib/auth/api-client";
vi.mock("@/lib/apiaries/api", () => ({ vi.mock("@/lib/apiaries/api", () => ({
fetchApiaries: vi.fn(), fetchApiaries: vi.fn(),
})); }));
vi.mock("@/lib/apiaries/field-api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/apiaries/field-api")>()),
fetchColonyOverview: vi.fn(),
}));
vi.mock("@/lib/auth/auth-context", () => ({
useCurrentUserId: () => "user-1",
useAuth: () => ({ hasPermission: () => true }),
}));
function apiaryOf(id: string, name: string, overrides: Partial<Apiary> = {}): Apiary {
return {
id,
name,
description: null,
location: null,
latitude: null,
longitude: null,
address: null,
status: "ACTIVE",
notes: null,
publicLocationLabel: null,
createdAt: "",
updatedAt: "",
deactivatedAt: null,
...overrides,
};
}
function overviewRow(id: string, code: string, overrides: Partial<ColonyOverviewRow> = {}): ColonyOverviewRow {
return {
id,
code,
status: "ACTIVE",
strength: 6,
occupiedFrames: 9,
broodStatus: "GOOD",
feedStatus: "GOOD",
healthStatus: "GOOD",
lastInspectionDate: new Date().toISOString(),
inspectionDueOn: null,
segment: "SCHWARMZEIT",
nextStepTitle: null,
openWarnings: 0,
openCritical: 0,
...overrides,
};
}
function renderWithClient(ui: ReactElement) { function renderWithClient(ui: ReactElement) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
@@ -17,6 +68,12 @@ function renderWithClient(ui: ReactElement) {
describe("ApiariesPage", () => { describe("ApiariesPage", () => {
beforeEach(() => { beforeEach(() => {
vi.mocked(fetchApiaries).mockReset(); vi.mocked(fetchApiaries).mockReset();
vi.mocked(fetchColonyOverview).mockReset();
vi.mocked(fetchColonyOverview).mockImplementation(async (apiaryId) => ({
apiaryId,
inspectionsVisible: true,
rows: [],
}));
}); });
it("renders the fetched apiaries", async () => { it("renders the fetched apiaries", async () => {
@@ -53,4 +110,66 @@ describe("ApiariesPage", () => {
expect(screen.getByRole("tab", { name: "Historie" })).toBeInTheDocument(); expect(screen.getByRole("tab", { name: "Historie" })).toBeInTheDocument();
expect(screen.queryByText(/\(0\)/)).toBeNull(); expect(screen.queryByText(/\(0\)/)).toBeNull();
}); });
it("the apiaries list shows one comb card per active apiary and only the summary beyond 20", async () => {
const apiaries = Array.from({ length: 22 }, (_, index) =>
apiaryOf(`a-${index + 1}`, `Stand ${index + 1}`, { location: `Ort ${index + 1}` }),
);
vi.mocked(fetchApiaries).mockResolvedValueOnce([
...apiaries,
apiaryOf("a-old", "Alter Stand", { status: "INACTIVE" }),
]);
vi.mocked(fetchColonyOverview).mockImplementation(async (apiaryId) => ({
apiaryId,
inspectionsVisible: true,
rows:
apiaryId === "a-1"
? [
overviewRow("c-1", "N1"),
overviewRow("c-2", "N2", { feedStatus: "WEAK" }),
overviewRow("c-3", "N3", { status: "QUEENLESS" }),
]
: [],
}));
renderWithClient(<ApiariesPage />);
// Name links to the apiary, the cells to the colonies.
const first = await screen.findByRole("list", { name: "Völker am Stand Stand 1" });
expect(within(first).getAllByRole("link").map((link) => link.getAttribute("href"))).toEqual([
"/colonies/c-1",
"/colonies/c-2",
"/colonies/c-3",
]);
expect(screen.getByRole("link", { name: "Stand 1" })).toHaveAttribute("href", "/apiaries/a-1");
expect(screen.getByText("3 Völker · 1 Gut · 1 Achtung · 1 Kritisch")).toBeInTheDocument();
expect(screen.getAllByText("Schwarmzeit").length).toBeGreaterThan(0);
// The legend once above all cards, one shared hexagon symbol.
expect(screen.getAllByRole("list", { name: "Legende" })).toHaveLength(1);
expect(document.querySelectorAll("symbol#comb-hex")).toHaveLength(1);
// At most 20 overviews are loaded; the other cards keep name and place only.
expect(await screen.findByRole("link", { name: "Stand 22" })).toHaveAttribute("href", "/apiaries/a-22");
expect(screen.getByText("Ort 22")).toBeInTheDocument();
await vi.waitFor(() => expect(fetchColonyOverview).toHaveBeenCalledTimes(20));
expect(vi.mocked(fetchColonyOverview).mock.calls.map(([id]) => id)).not.toContain("a-21");
// Inactive apiaries stay in the history tab.
expect(screen.queryByText("Alter Stand")).toBeNull();
});
it("a stand whose colonies the user may not read shows no comb and no retry", async () => {
vi.mocked(fetchApiaries).mockResolvedValueOnce([apiaryOf("a-1", "Stand 1"), apiaryOf("a-2", "Stand 2")]);
vi.mocked(fetchColonyOverview).mockImplementation(async (apiaryId) => {
if (apiaryId === "a-2") throw new ApiError(403, "Forbidden");
return { apiaryId, inspectionsVisible: true, rows: [overviewRow("c-1", "N1")] };
});
renderWithClient(<ApiariesPage />);
expect(await screen.findByRole("list", { name: "Völker am Stand Stand 1" })).toBeInTheDocument();
await vi.waitFor(() => expect(fetchColonyOverview).toHaveBeenCalledWith("a-2", "user-1"));
await new Promise((resolve) => setTimeout(resolve, 50));
expect(screen.queryByRole("alert")).toBeNull();
expect(screen.queryByRole("list", { name: "Völker am Stand Stand 2" })).toBeNull();
});
}); });
@@ -13,6 +13,7 @@ import { PageHeader } from "@/components/page-header";
import { EmptyState } from "@/components/empty-state"; import { EmptyState } from "@/components/empty-state";
import { EntityCard } from "@/components/entity-card"; import { EntityCard } from "@/components/entity-card";
import { QueryState } from "@/components/query-state"; import { QueryState } from "@/components/query-state";
import { ApiaryCombList } from "@/components/honeycomb/apiary-comb-card";
import { ActiveHistoryTabs, countOf, type EntityLifecycleTab } from "@/components/active-history-tabs"; import { ActiveHistoryTabs, countOf, type EntityLifecycleTab } from "@/components/active-history-tabs";
import { import {
DropdownMenu, DropdownMenu,
@@ -47,6 +48,8 @@ export default function ApiariesPage() {
isFiltered={location !== ""} isFiltered={location !== ""}
onResetFilter={() => setLocation("")} onResetFilter={() => setLocation("")}
/> />
) : bucket === "active" ? (
<ApiaryCombList apiaries={apiaries} />
) : ( ) : (
<ApiaryGrid apiaries={apiaries} /> <ApiaryGrid apiaries={apiaries} />
) )
@@ -1,6 +1,7 @@
import "fake-indexeddb/auto"; import "fake-indexeddb/auto";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, within } from "@testing-library/react"; import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { getDb } from "@/lib/offline/db"; import { getDb } from "@/lib/offline/db";
import { deleteAllPacks } from "@/lib/apiaries/offline-pack"; import { deleteAllPacks } from "@/lib/apiaries/offline-pack";
@@ -50,7 +51,11 @@ describe("OfflineApiaryPage", () => {
renderPage(); renderPage();
expect(await screen.findByRole("heading", { level: 1, name: "Obstwiese" })).toBeInTheDocument(); expect(await screen.findByRole("heading", { level: 1, name: "Obstwiese" })).toBeInTheDocument();
const second = screen.getByRole("link", { name: /A2/ }); await userEvent.click(screen.getByRole("button", { name: "Alle 2 Völker als Liste" }));
const list = document.getElementById(
screen.getByRole("button", { name: "Alle 2 Völker als Liste" }).getAttribute("aria-controls") ?? "",
) as HTMLElement;
const second = within(list).getByRole("link", { name: /A2/ });
expect(second).toHaveAttribute("href", "/offline/colony?id=colony-2"); expect(second).toHaveAttribute("href", "/offline/colony?id=colony-2");
expect(within(second).getByText("Kritisch")).toBeInTheDocument(); expect(within(second).getByText("Kritisch")).toBeInTheDocument();
expect(second).toHaveTextContent(/Weisellos/); expect(second).toHaveTextContent(/Weisellos/);
@@ -65,6 +70,26 @@ describe("OfflineApiaryPage", () => {
); );
}); });
it("the offline apiary page shows the comb from the pack", async () => {
const pack = offlinePack({
apiaryId: "apiary-1",
apiaryName: "Obstwiese",
colonies: [packColony("colony-2", "A2", { status: "QUEENLESS" }), packColony("colony-1", "A1")],
});
const db = await getDb();
await db.put("offline-packs", storedPack(pack));
renderPage();
const comb = await screen.findByRole("list", { name: "Völker am Stand Obstwiese" });
const cells = within(comb).getAllByRole("link");
expect(cells.map((cell) => cell.getAttribute("href"))).toEqual([
"/offline/colony?id=colony-1",
"/offline/colony?id=colony-2",
]);
expect(cells[1]).toHaveAccessibleName(/^A2 – Kritisch: Weisellos/);
expect(screen.getByRole("region", { name: "Braucht Blick" })).toHaveTextContent(/A2/);
});
it("leads back to the apiary page once the network is back", async () => { it("leads back to the apiary page once the network is back", async () => {
const db = await getDb(); const db = await getDb();
await db.put("offline-packs", storedPack(offlinePack({ apiaryId: "apiary-1" }))); await db.put("offline-packs", storedPack(offlinePack({ apiaryId: "apiary-1" })));
@@ -5,6 +5,7 @@ import { useSearchParams } from "next/navigation";
import { useCurrentUserId } from "@/lib/auth/auth-context"; import { useCurrentUserId } from "@/lib/auth/auth-context";
import type { StoredPack } from "@/lib/offline/db"; import type { StoredPack } from "@/lib/offline/db";
import { DetailHeader } from "@/components/detail-header"; import { DetailHeader } from "@/components/detail-header";
import { ApiaryComb } from "@/components/honeycomb/apiary-comb";
import { PackAgeNote, useOwnPack } from "@/components/offline/load-pack-button"; import { PackAgeNote, useOwnPack } from "@/components/offline/load-pack-button";
import { ColonyOverviewLinks, StartRoundButton } from "../../apiaries/[id]/apiary-colonies-card"; import { ColonyOverviewLinks, StartRoundButton } from "../../apiaries/[id]/apiary-colonies-card";
import { import {
@@ -84,11 +85,9 @@ function OfflineApiaryView({ stored, readAt, returnHref }: OfflineApiaryViewProp
<PackAgeNote stored={stored} now={readAt} /> <PackAgeNote stored={stored} now={readAt} />
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-2"> <div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-2">
<OfflineSection title={`Völker (${overview.rows.length})`}> <OfflineSection title={`Völker (${overview.rows.length})`}>
{overview.rows.length === 0 ? ( <ApiaryComb apiaryName={apiary.name} overview={overview} hrefFor={offlineColonyHref}>
<p className="text-muted-foreground">Keine Völker an diesem Stand.</p>
) : (
<ColonyOverviewLinks overview={overview} hrefFor={offlineColonyHref} /> <ColonyOverviewLinks overview={overview} hrefFor={offlineColonyHref} />
)} </ApiaryComb>
</OfflineSection> </OfflineSection>
{apiaryTasks !== null ? ( {apiaryTasks !== null ? (
<OfflineSection title="Offene Aufgaben am Stand"> <OfflineSection title="Offene Aufgaben am Stand">
@@ -1,22 +1,29 @@
import { AlertTriangle, CheckCircle2, XCircle, type LucideIcon } from "lucide-react"; import { AlertTriangle, CheckCircle2, XCircle, type LucideIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { COLONY_CONDITION_TONE_CLASS } from "@/lib/colonies/labels"; import { COLONY_CONDITION_TONE_CLASS } from "@/lib/colonies/labels";
import type { StatusLight } from "@/lib/colonies/status-light"; import { STATUS_LIGHT_LABELS, type StatusLight } from "@/lib/colonies/status-light";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const LIGHTS: Record<StatusLight, { label: string; icon: LucideIcon; tone: string }> = { /** The icon of each light, shared with the comb cell. */
green: { label: "Gut", icon: CheckCircle2, tone: COLONY_CONDITION_TONE_CLASS.GOOD }, export const STATUS_LIGHT_ICONS: Record<StatusLight, LucideIcon> = {
yellow: { label: "Achtung", icon: AlertTriangle, tone: COLONY_CONDITION_TONE_CLASS.AVERAGE }, green: CheckCircle2,
red: { label: "Kritisch", icon: XCircle, tone: COLONY_CONDITION_TONE_CLASS.CRITICAL }, yellow: AlertTriangle,
red: XCircle,
};
const TONES: Record<StatusLight, string> = {
green: COLONY_CONDITION_TONE_CLASS.GOOD,
yellow: COLONY_CONDITION_TONE_CLASS.AVERAGE,
red: COLONY_CONDITION_TONE_CLASS.CRITICAL,
}; };
/** The traffic light as icon plus word - the colour only underlines it. */ /** The traffic light as icon plus word - the colour only underlines it. */
export function StatusLightBadge({ light, className }: { light: StatusLight; className?: string }) { export function StatusLightBadge({ light, className }: { light: StatusLight; className?: string }) {
const { label, icon: Icon, tone } = LIGHTS[light]; const Icon = STATUS_LIGHT_ICONS[light];
return ( return (
<Badge className={cn(tone, className)}> <Badge className={cn(TONES[light], className)}>
<Icon aria-hidden /> <Icon aria-hidden />
{label} {STATUS_LIGHT_LABELS[light]}
</Badge> </Badge>
); );
} }
@@ -0,0 +1,88 @@
"use client";
import "@/styles/comb.css";
import { useMemo } from "react";
import Link from "next/link";
import { AreaIcon } from "@/components/area-icon";
import { SeasonChip } from "@/components/autopilot/season-chip";
import { ErrorState, LoadingState } from "@/components/query-state";
import { Card, CardContent } from "@/components/ui/card";
import type { Apiary } from "@/lib/apiaries/types";
import { ApiError } from "@/lib/auth/api-client";
import { useAuth } from "@/lib/auth/auth-context";
import { useColonyOverview } from "@/lib/apiaries/use-colony-overview";
import { summarizeColonies } from "@/lib/honeycomb/cell-state";
import { CombCells, CombSummary, cellModelsOf } from "./apiary-comb";
import { CombLegend } from "./comb-legend";
import { CombSymbols } from "./comb-symbols";
/** More apiaries than this show no comb: their overview is not loaded (spec 6.1). */
export const MAX_COMB_CARDS = 20;
function colonyPageHref(colonyId: string): string {
return `/colonies/${colonyId}`;
}
interface ApiaryCombCardProps {
apiary: Apiary;
/** Load the overview and show the comb; false beyond the first 20 apiaries. */
withComb: boolean;
}
/** One active apiary in the apiaries list: name (link), season, summary and cells (spec 6.2). */
export function ApiaryCombCard({ apiary, withComb }: ApiaryCombCardProps) {
const query = useColonyOverview(apiary.id, withComb);
const overview = query.data;
const models = useMemo(() => (overview ? cellModelsOf(overview) : []), [overview]);
const segment = overview?.rows[0]?.segment ?? null;
// Without the right to read the colonies the card keeps name and place; a retry would not help.
const isForbidden = query.error instanceof ApiError && query.error.status === 403;
return (
<Card>
<CardContent className="flex flex-col gap-3">
<div className="flex items-start gap-3">
<AreaIcon area="apiaries" />
<div className="min-w-0 flex-1">
<h2 className="truncate text-base font-medium">
<Link href={`/apiaries/${apiary.id}`} className="inline-flex min-h-11 max-w-full items-center underline-offset-4 hover:underline">
{apiary.name}
</Link>
</h2>
{apiary.location ? <p className="truncate text-sm text-muted-foreground">{apiary.location}</p> : null}
</div>
{segment ? <SeasonChip segment={segment} /> : null}
</div>
{withComb && query.isLoading ? <LoadingState what="Völker" /> : null}
{withComb && !query.isLoading && !overview && !isForbidden ? (
<ErrorState what="Völker" onRetry={() => void query.refetch()} isRetrying={query.isFetching} />
) : null}
{overview && models.length === 0 ? (
<p className="text-base text-muted-foreground">Noch keine Völker an diesem Stand.</p>
) : null}
{models.length > 0 ? (
<>
<CombSummary summary={summarizeColonies(models)} total={models.length} />
<CombCells apiaryName={apiary.name} models={models} hrefFor={colonyPageHref} size="list" />
</>
) : null}
</CardContent>
</Card>
);
}
/** The active apiaries as comb cards; legend and hexagon symbol once above all of them. */
export function ApiaryCombList({ apiaries }: { apiaries: readonly Apiary[] }) {
const { hasPermission } = useAuth();
return (
<div className="flex flex-col gap-3">
<CombSymbols />
<CombLegend showRim={hasPermission("inspections:read")} />
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{apiaries.map((apiary, index) => (
<ApiaryCombCard key={apiary.id} apiary={apiary} withComb={index < MAX_COMB_CARDS} />
))}
</div>
</div>
);
}
@@ -0,0 +1,326 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ColonyOverview, ColonyOverviewRow } from "@/lib/apiaries/field-types";
import { toLocalIsoDate } from "@/lib/dates";
import { cellModelOf } from "@/lib/honeycomb/cell-state";
import { COMB_GAP, combLayout, hexHeight } from "@/lib/honeycomb/layout";
import { ApiaryComb, CombCells } from "./apiary-comb";
/** YYYY-MM-DD, `offset` days from today (local day, like the component). */
function dayFromToday(offset: number): string {
const date = new Date();
date.setDate(date.getDate() + offset);
return toLocalIsoDate(date);
}
function row(overrides: Partial<ColonyOverviewRow>): ColonyOverviewRow {
return {
id: "c-1",
code: "B1",
status: "ACTIVE",
strength: 6,
occupiedFrames: 9,
broodStatus: "GOOD",
feedStatus: "GOOD",
healthStatus: "GOOD",
lastInspectionDate: `${dayFromToday(-2)}T00:00:00.000Z`,
inspectionDueOn: dayFromToday(5),
segment: "SOMMER",
nextStepTitle: null,
openWarnings: 0,
openCritical: 0,
...overrides,
};
}
function overview(rows: ColonyOverviewRow[], inspectionsVisible = true): ColonyOverview {
return { apiaryId: "a-1", inspectionsVisible, rows };
}
function renderComb(rows: ColonyOverviewRow[], options: { inspectionsVisible?: boolean } = {}) {
return render(
<ApiaryComb
apiaryName="Nordhang"
overview={overview(rows, options.inspectionsVisible)}
hrefFor={(id) => `/colonies/${id}`}
>
<p>Die T3-Liste</p>
</ApiaryComb>,
);
}
const comb = () => screen.getByRole("list", { name: "Völker am Stand Nordhang" });
describe("ApiaryComb", () => {
it("renders a list of named links in code order", () => {
renderComb([
row({ id: "c-10", code: "V-10" }),
row({ id: "c-2", code: "V-2", feedStatus: "WEAK" }),
row({ id: "c-1", code: "V-1" }),
]);
const links = within(comb()).getAllByRole("link");
expect(links.map((link) => link.getAttribute("href"))).toEqual([
"/colonies/c-1",
"/colonies/c-2",
"/colonies/c-10",
]);
expect(links[1]).toHaveAccessibleName("V-2 – Achtung: Futter schwach. Durchsicht fällig in 5 Tagen.");
expect(within(comb()).getAllByRole("listitem")).toHaveLength(3);
});
it("a cell always shows icon and code, never colour alone", () => {
renderComb([
row({ id: "c-1", code: "B1" }),
row({ id: "c-2", code: "B2", feedStatus: "WEAK" }),
row({ id: "c-3", code: "B3", status: "QUEENLESS", openWarnings: 1 }),
row({ id: "c-4", code: "VERYLONGCODE" }),
]);
const links = within(comb()).getAllByRole("link");
for (const link of links) {
expect(link.querySelector("[data-light-icon]")).not.toBeNull();
}
expect(links.map((link) => link.querySelector("[data-light-icon]")?.getAttribute("data-light-icon"))).toEqual([
"green",
"yellow",
"red",
"green",
]);
expect(within(links[0]).getByText("B1")).toBeInTheDocument();
// Longer than six characters: cut with an ellipsis, the full code stays in the name.
expect(within(links[3]).getByText("VERYL…")).toBeInTheDocument();
expect(links[3]).toHaveAccessibleName(/^VERYLONGCODE – Gut/);
// More than one reason: a small counter.
expect(within(links[2]).getByText("2")).toBeInTheDocument();
expect(within(links[1]).queryByText("1")).toBeNull();
});
it("draws the rim step on the cell and the clock on a due inspection", () => {
renderComb([
row({ id: "c-1", code: "B1" }),
row({ id: "c-2", code: "B2", inspectionDueOn: dayFromToday(1) }),
row({ id: "c-3", code: "B3", inspectionDueOn: dayFromToday(-4) }),
]);
const links = within(comb()).getAllByRole("link");
expect(links.map((link) => link.getAttribute("data-rim"))).toEqual(["1", "2", "3"]);
expect(links[2].querySelector("[data-rim-clock]")).not.toBeNull();
expect(links[0].querySelector("[data-rim-clock]")).toBeNull();
});
it("summarises lights and due inspections in one sentence", () => {
renderComb([
row({ id: "c-1", code: "B1" }),
row({ id: "c-2", code: "B2", feedStatus: "WEAK", inspectionDueOn: dayFromToday(-1) }),
row({ id: "c-3", code: "B3", status: "QUEENLESS" }),
]);
expect(screen.getByText("1 Gut · 1 Achtung · 1 Kritisch · 1 Durchsicht fällig")).toBeInTheDocument();
});
it("shows the legend and „Braucht Blick“ only with yellow and red colonies", () => {
renderComb([
row({ id: "c-1", code: "B1" }),
row({ id: "c-3", code: "B3", feedStatus: "WEAK" }),
row({ id: "c-6", code: "B6", status: "QUEENLESS" }),
]);
const legend = screen.getByRole("list", { name: "Legende" });
expect(within(legend).getByText("Gut")).toBeInTheDocument();
expect(within(legend).getByText("Achtung")).toBeInTheDocument();
expect(within(legend).getByText("Kritisch")).toBeInTheDocument();
expect(within(legend).getByText(/Rand/)).toBeInTheDocument();
const attention = screen.getByRole("region", { name: "Braucht Blick" });
expect(within(attention).queryByText("B1")).toBeNull();
expect(within(attention).getByText("B3")).toBeInTheDocument();
expect(within(attention).getByText("Futter schwach")).toBeInTheDocument();
expect(within(attention).getByText("B6")).toBeInTheDocument();
expect(within(attention).getByText("Weisellos")).toBeInTheDocument();
// Worst first.
const codes = within(attention).getAllByText(/^B\d$/).map((element) => element.textContent);
expect(codes).toEqual(["B6", "B3"]);
});
it("says „Alle Völker sind in Ordnung.“ when no colony needs a look", () => {
renderComb([row({ id: "c-1", code: "B1" })]);
const attention = screen.getByRole("region", { name: "Braucht Blick" });
expect(within(attention).getByText("Alle Völker sind in Ordnung.")).toBeInTheDocument();
});
it("„Alle Völker als Liste“ opens the T3 list (aria-expanded)", async () => {
renderComb([row({ id: "c-1", code: "B1" }), row({ id: "c-2", code: "B2" })]);
const toggle = screen.getByRole("button", { name: "Alle 2 Völker als Liste" });
expect(toggle).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByText("Die T3-Liste")).toBeNull();
await userEvent.click(toggle);
expect(toggle).toHaveAttribute("aria-expanded", "true");
expect(screen.getByText("Die T3-Liste")).toBeInTheDocument();
expect(document.getElementById(toggle.getAttribute("aria-controls") ?? "")).toContainElement(
screen.getByText("Die T3-Liste"),
);
});
it("an empty apiary shows the empty hint and no comb", () => {
renderComb([]);
expect(screen.getByText("Noch keine Völker an diesem Stand")).toBeInTheDocument();
expect(screen.queryByRole("list", { name: /Völker am Stand/ })).toBeNull();
expect(screen.queryByRole("button", { name: /als Liste/ })).toBeNull();
expect(document.querySelector("#comb-hex")).toBeNull();
});
it("100 cells render with one shared symbol", () => {
const rows = Array.from({ length: 100 }, (_, index) => row({ id: `c-${index}`, code: `V-${index + 1}` }));
const { container } = renderComb(rows);
expect(within(comb()).getAllByRole("link")).toHaveLength(100);
expect(container.querySelectorAll("symbol")).toHaveLength(1);
expect(container.querySelectorAll("symbol#comb-hex")).toHaveLength(1);
const uses = [...container.querySelectorAll("use")];
expect(uses.length).toBeGreaterThanOrEqual(100);
expect(uses.every((use) => use.getAttribute("href") === "#comb-hex")).toBe(true);
// Each shape is decoration; the link carries the name.
expect(container.querySelectorAll("li svg:not([aria-hidden='true'])")).toHaveLength(0);
});
it("without inspections:read the rims are neutral and the sentence counts no due inspection", () => {
renderComb([row({ id: "c-1", code: "B1", lastInspectionDate: null, inspectionDueOn: null })], {
inspectionsVisible: false,
});
const link = within(comb()).getByRole("link");
expect(link).toHaveAttribute("data-rim", "neutral");
expect(link).toHaveAccessibleName("B1 – Gut.");
expect(screen.getByText("1 Gut")).toBeInTheDocument();
});
it("focus draws the ring on the outer hexagon path", () => {
renderComb([row({ id: "c-1", code: "B1" })]);
const link = within(comb()).getByRole("link");
// The link drops its own outline (the one guard exception); a second hexagon outside the
// cell draws the ring instead.
expect(link).toHaveClass("comb-cell", "outline-none");
const focusPath = link.querySelector("use.comb-focus");
expect(focusPath).not.toBeNull();
expect(Number(focusPath?.getAttribute("x"))).toBeLessThan(0);
expect(Number(focusPath?.getAttribute("width"))).toBeGreaterThan(Number(link.querySelector("use.comb-fill")?.getAttribute("width")));
const css = readFileSync(join(__dirname, "..", "..", "styles", "comb.css"), "utf8");
expect(css).toMatch(/\.comb-cell:focus-visible\s+\.comb-focus\s*\{[^}]*stroke:\s*var\(--ring\)[^}]*stroke-width:\s*var\(--focus-width\)/);
// Forced colours: the stroke stays visible and the steps keep their widths.
expect(css).toMatch(/@media \(forced-colors: active\)/);
// No animation (spec 6.8).
expect(css).not.toMatch(/transition|animation/);
});
it("without inspections:read the legend says nothing about the rim", () => {
renderComb([row({ id: "c-1", code: "B1", lastInspectionDate: null, inspectionDueOn: null })], {
inspectionsVisible: false,
});
const legend = screen.getByRole("list", { name: "Legende" });
expect(within(legend).getByText("Gut")).toBeInTheDocument();
expect(within(legend).queryByText(/Rand/)).toBeNull();
});
it("only the hexagon takes taps: a point in the lower quarter of a cell belongs to it, not to the offset row", () => {
// Interlocking rows: the rectangles overlap, the hexagons do not.
const cell = 64;
const height = hexHeight(cell);
const [a, , , , b] = combLayout(5, 4, cell);
const hexagon = ({ x, y }: { x: number; y: number }) => [
[x + cell / 2, y],
[x + cell, y + height / 4],
[x + cell, y + (height * 3) / 4],
[x + cell / 2, y + height],
[x, y + (height * 3) / 4],
[x, y + height / 4],
];
const inside = (polygon: number[][], [px, py]: number[]) => {
let isIn = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i, i += 1) {
const [xi, yi] = polygon[i];
const [xj, yj] = polygon[j];
if (yi > py !== yj > py && px < ((xj - xi) * (py - yi)) / (yj - yi) + xi) isIn = !isIn;
}
return isIn;
};
const inRect = ({ x, y }: { x: number; y: number }, [px, py]: number[]) =>
px >= x && px <= x + cell && py >= y && py <= y + height;
// Lower quarter of A, right of its centre: under B's rectangle, inside A's hexagon only.
const point = [a.x + cell * 0.6, a.y + height * 0.8];
expect(b.x).toBe((cell + COMB_GAP) / 2);
expect(inRect(b, point)).toBe(true);
expect(inside(hexagon(a), point)).toBe(true);
expect(inside(hexagon(b), point)).toBe(false);
// So the rectangles (li, link, text) let pointers through; only the painted fill takes them.
const css = readFileSync(join(__dirname, "..", "..", "styles", "comb.css"), "utf8");
expect(css).toMatch(/\.comb-slot[^{]*\{[^}]*pointer-events:\s*none/);
expect(css).toMatch(/\.comb-fill\s*\{[^}]*pointer-events:\s*visiblePainted/);
renderComb([row({ id: "c-1", code: "B1" })]);
const link = within(comb()).getByRole("link");
expect(link.closest("li")).toHaveClass("comb-slot");
// No clip-path on the link: it would cut off the focus ring outside the cell.
expect(link.getAttribute("style") ?? "").not.toMatch(/clip-path/);
});
describe("measuring the container", () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("the first frame already uses the measured width (no 3-column frame, no reflow)", () => {
vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(600);
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
disconnect() {}
},
);
const models = Array.from({ length: 9 }, (_, index) =>
cellModelOf(row({ id: `c-${index}`, code: `V-${index + 1}` }), {
today: "2026-06-15",
inspectionsVisible: true,
}),
);
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
// flushSync runs layout effects before returning, passive effects (useEffect) not.
flushSync(() =>
root.render(<CombCells apiaryName="Nordhang" models={models} hrefFor={(id) => `/c/${id}`} size="page" />),
);
// 600 px at 64 px cells: (600 − 34) / 68 → 8 columns, so the 8th cell is still in row 1.
const slots = [...host.querySelectorAll("li")];
expect(slots[7].style.top).toBe("0px");
expect(slots[8].style.top).not.toBe("0px");
root.unmount();
host.remove();
});
it("cells of 48 px (list, mouse) set their code at 14 px", () => {
vi.stubGlobal("matchMedia", (query: string) => ({
matches: query === "(pointer: fine)",
addEventListener: () => {},
removeEventListener: () => {},
}));
const model = cellModelOf(row({ code: "AB-123" }), { today: "2026-06-15", inspectionsVisible: true });
render(<CombCells apiaryName="Nordhang" models={[model]} hrefFor={(id) => `/c/${id}`} size="list" />);
expect(screen.getByText("AB-123")).toHaveClass("text-sm");
});
});
});
@@ -0,0 +1,171 @@
"use client";
import "@/styles/comb.css";
import { Fragment, useId, useMemo, useRef, useState, type ReactNode } from "react";
import { ChevronDown, ChevronUp, Clock, Sprout } from "lucide-react";
import { STATUS_LIGHT_ICONS, StatusLightBadge } from "@/components/colonies/status-light-badge";
import { EmptyState } from "@/components/empty-state";
import { Button } from "@/components/ui/button";
import type { ColonyOverview } from "@/lib/apiaries/field-types";
import { todayLocalIsoDate } from "@/lib/dates";
import {
cellModelOf,
sortCells,
summarizeColonies,
summaryParts,
type CellModel,
type ColonySummary,
} from "@/lib/honeycomb/cell-state";
import { columnsFor, combLayout, combSize } from "@/lib/honeycomb/layout";
import { CombLegend } from "./comb-legend";
import { CombSymbols } from "./comb-symbols";
import { HoneycombCell } from "./honeycomb-cell";
import { useContainerWidth, useFinePointer } from "./use-container-width";
/** Cell widths in px (spec 6.3): touch / mouse, on the apiary page and in the apiaries list. */
const CELL_SIZES = { page: { coarse: 64, fine: 56 }, list: { coarse: 56, fine: 48 } } as const;
/** The overview's colonies as cell models, in code order. */
export function cellModelsOf(overview: ColonyOverview): CellModel[] {
const ctx = { today: todayLocalIsoDate(), inspectionsVisible: overview.inspectionsVisible };
return sortCells(overview.rows.map((row) => cellModelOf(row, ctx)));
}
interface CombCellsProps {
apiaryName: string;
models: readonly CellModel[];
hrefFor: (colonyId: string) => string;
size: keyof typeof CELL_SIZES;
}
/** The cells as a list of links, row by row in code order (spec 6.5). Needs <CombSymbols /> on the page. */
export function CombCells({ apiaryName, models, hrefFor, size }: CombCellsProps) {
const ref = useRef<HTMLDivElement>(null);
const width = useContainerWidth(ref);
const isFine = useFinePointer();
const cell = isFine ? CELL_SIZES[size].fine : CELL_SIZES[size].coarse;
const columns = columnsFor(width, cell);
const places = useMemo(() => combLayout(models.length, columns, cell), [models.length, columns, cell]);
const { height } = combSize(models.length, columns, cell);
return (
<div ref={ref}>
<ul aria-label={`Völker am Stand ${apiaryName}`} className="relative" style={{ height }}>
{models.map((model, index) => (
<HoneycombCell
key={model.colonyId}
model={model}
href={hrefFor(model.colonyId)}
cell={cell}
x={places[index].x}
y={places[index].y}
/>
))}
</ul>
</div>
);
}
/** „5 Gut · 1 Achtung · 1 Kritisch · 2 Durchsichten fällig“ with the icon of each part. */
export function CombSummary({ summary, total }: { summary: ColonySummary; total?: number }) {
const parts = summaryParts(summary);
return (
<p className="flex flex-wrap items-center gap-x-1 text-base">
{total === undefined ? null : `${total} ${total === 1 ? "Volk" : "Völker"}`}
{parts.map((part, index) => {
const Icon = part.key === "due" ? Clock : STATUS_LIGHT_ICONS[part.key];
return (
<Fragment key={part.key}>
{index > 0 || total !== undefined ? " · " : null}
<Icon aria-hidden="true" className="size-4 shrink-0" />
{part.text}
</Fragment>
);
})}
</p>
);
}
const SEVERITY = { red: 0, yellow: 1, green: 2 } as const;
/** The work list: only yellow and red colonies with their reasons, worst first. */
function NeedsLook({ models }: { models: readonly CellModel[] }) {
const headingId = useId();
const needing = models
.filter((model) => model.light !== "green")
.sort((a, b) => SEVERITY[a.light] - SEVERITY[b.light]);
return (
<section aria-labelledby={headingId} className="flex flex-col gap-2 border-t border-border pt-3">
<h3 id={headingId} className="text-base font-semibold">
Braucht Blick
</h3>
{needing.length === 0 ? (
<p className="text-base text-muted-foreground">Alle Völker sind in Ordnung.</p>
) : (
<ul className="flex flex-col gap-2">
{needing.map((model) => (
<li key={model.colonyId} className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="font-medium">{model.code}</span>
<StatusLightBadge light={model.light} />
<span className="text-sm text-muted-foreground">{model.reasons.join(" · ")}</span>
</li>
))}
</ul>
)}
</section>
);
}
interface ApiaryCombProps {
apiaryName: string;
overview: ColonyOverview;
hrefFor: (colonyId: string) => string;
/** The T3 row list: the table fallback behind „Alle n Völker als Liste“. */
children: ReactNode;
}
/**
* Waben-Standkarte of the apiary page (spec 6.2): summary, comb, legend, „Braucht Blick“ and
* the folded row list. Online and offline (pack) alike; no animation (spec 6.8).
*/
export function ApiaryComb({ apiaryName, overview, hrefFor, children }: ApiaryCombProps) {
const [isListOpen, setListOpen] = useState(false);
const listId = useId();
const models = useMemo(() => cellModelsOf(overview), [overview]);
if (models.length === 0) {
return (
<EmptyState
icon={Sprout}
title="Noch keine Völker an diesem Stand"
description="Sobald ein Volk hier steht, erscheint es mit seiner Ampel."
/>
);
}
const ListIcon = isListOpen ? ChevronUp : ChevronDown;
return (
<div className="flex flex-col gap-3">
<CombSymbols />
<CombSummary summary={summarizeColonies(models)} />
<CombCells apiaryName={apiaryName} models={models} hrefFor={hrefFor} size="page" />
<CombLegend showRim={overview.inspectionsVisible} />
<NeedsLook models={models} />
<div>
<Button
type="button"
variant="outline"
size="touch"
aria-expanded={isListOpen}
aria-controls={listId}
onClick={() => setListOpen((open) => !open)}
>
Alle {models.length} Völker als Liste
<ListIcon aria-hidden="true" />
</Button>
</div>
<div id={listId} hidden={!isListOpen} className="flex flex-col gap-2">
{isListOpen ? children : null}
</div>
</div>
);
}
@@ -0,0 +1,29 @@
import { STATUS_LIGHT_ICONS } from "@/components/colonies/status-light-badge";
import { STATUS_LIGHT_LABELS, type StatusLight } from "@/lib/colonies/status-light";
import { COMB_HEX_VIEWBOX } from "./comb-symbols";
const LIGHTS: readonly StatusLight[] = ["green", "yellow", "red"];
/**
* Always visible, one line at 13 px: the three fills with word and icon, and the rim (spec 6.2).
* Without inspections:read the rim says nothing, so neither does the legend.
*/
export function CombLegend({ showRim }: { showRim: boolean }) {
return (
<ul aria-label="Legende" className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
{LIGHTS.map((light) => {
const Icon = STATUS_LIGHT_ICONS[light];
return (
<li key={light} data-light={light} className="comb-key flex items-center gap-1 text-foreground">
<svg aria-hidden="true" focusable="false" viewBox={COMB_HEX_VIEWBOX} className="h-3.5 w-3">
<polygon className="comb-swatch" vectorEffect="non-scaling-stroke" points="50,0 100,28.87 100,86.6 50,115.47 0,86.6 0,28.87" />
</svg>
<Icon aria-hidden="true" className="size-3.5" />
{STATUS_LIGHT_LABELS[light]}
</li>
);
})}
{showRim ? <li>Rand: dünn = frisch, dick = bald fällig, gestrichelt = Durchsicht fällig</li> : null}
</ul>
);
}
@@ -0,0 +1,23 @@
/** Pointy-top hexagon in a 100 x 115.47 box (height = width × 2/√3). */
export const COMB_HEX_VIEWBOX = "0 0 100 115.47";
const COMB_HEX_POINTS = "50,0 100,28.87 100,86.6 50,115.47 0,86.6 0,28.87";
/**
* The shared shapes of the comb, once per page (spec 6.7): every cell draws the hexagon with
* `<use href="#comb-hex">`. The clip path keeps only the inner half of a stroke, so the rim is
* drawn inside and the cell keeps its size (spec 6.3). The stroke does not scale with the cell.
*/
export function CombSymbols() {
return (
<svg aria-hidden="true" focusable="false" width="0" height="0" className="absolute">
<defs>
<symbol id="comb-hex" viewBox={COMB_HEX_VIEWBOX} preserveAspectRatio="none" overflow="visible">
<polygon points={COMB_HEX_POINTS} vectorEffect="non-scaling-stroke" strokeLinejoin="round" />
</symbol>
<clipPath id="comb-clip" clipPathUnits="objectBoundingBox">
<polygon points="0.5,0 1,0.25 1,0.75 0.5,1 0,0.75 0,0.25" />
</clipPath>
</defs>
</svg>
);
}
@@ -0,0 +1,75 @@
import Link from "next/link";
import { Clock } from "lucide-react";
import { STATUS_LIGHT_ICONS } from "@/components/colonies/status-light-badge";
import type { CellModel } from "@/lib/honeycomb/cell-state";
import { hexHeight } from "@/lib/honeycomb/layout";
import { cn } from "@/lib/utils";
/** Longer codes are cut with an ellipsis; the full code stays in the name (spec 6.3). */
const MAX_CODE_CHARS = 6;
/** Below this width the code is set at 14 px, so six characters fit (list cells, mouse). */
const COMPACT_CELL = 56;
/** The focus hexagon runs this far outside the cell (3 px gap plus half the ring). */
const FOCUS_DISTANCE = 4;
interface HoneycombCellProps {
model: CellModel;
href: string;
/** Cell width in px. */
cell: number;
x: number;
y: number;
}
function shortCode(code: string): string {
return code.length > MAX_CODE_CHARS ? `${code.slice(0, MAX_CODE_CHARS - 1)}…` : code;
}
/**
* One colony as a hexagon link (spec 6.3–6.6): fill by light, icon and code as HTML on top,
* rim by days since the last inspection. The link has no outline of its own; the outer
* hexagon path `.comb-focus` draws the focus ring (styles/comb.css, guard exception).
*/
export function HoneycombCell({ model, href, cell, x, y }: HoneycombCellProps) {
const height = hexHeight(cell);
const Icon = STATUS_LIGHT_ICONS[model.light];
const rim = String(model.rim);
const focusX = -FOCUS_DISTANCE;
const focusY = (-FOCUS_DISTANCE * 2) / Math.sqrt(3);
return (
<li className="comb-slot absolute focus-within:z-10" style={{ left: x, top: y, width: cell, height }}>
<Link
href={href}
aria-label={model.name}
data-light={model.light}
data-rim={rim}
className="comb-cell relative flex size-full flex-col items-center justify-center outline-none"
>
<svg aria-hidden="true" focusable="false" className="absolute inset-0 overflow-visible" width={cell} height={height}>
<use href="#comb-hex" className="comb-fill" width={cell} height={height} />
<use href="#comb-hex" className="comb-rim" data-rim={rim} clipPath="url(#comb-clip)" width={cell} height={height} />
<use
href="#comb-hex"
className="comb-focus"
x={focusX}
y={focusY}
width={cell - 2 * focusX}
height={height - 2 * focusY}
/>
</svg>
<Icon aria-hidden="true" data-light-icon={model.light} className="relative size-4 shrink-0" />
<span className={cn("relative max-w-full truncate px-1 leading-tight font-semibold", cell < COMPACT_CELL ? "text-sm" : "text-base")}>
{shortCode(model.code)}
</span>
{model.rim === 3 ? (
<Clock aria-hidden="true" data-rim-clock="" className="absolute top-[28%] right-[18%] size-3.5" />
) : null}
{model.reasons.length > 1 ? (
<span aria-hidden="true" className="absolute right-[14%] bottom-[16%] text-xs leading-none font-semibold">
{model.reasons.length}
</span>
) : null}
</Link>
</li>
);
}
@@ -0,0 +1,41 @@
"use client";
import { useEffect, useLayoutEffect, useState, useSyncExternalStore, type RefObject } from "react";
/** Layout effect in the browser (no effect warning on the server render). */
const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
/** Inner width of `ref`'s element, kept current by a ResizeObserver; 0 until measured. */
export function useContainerWidth(ref: RefObject<HTMLElement | null>): number {
const [width, setWidth] = useState(0);
// Measured before paint, so the first frame already has the right columns (no reflow).
useIsomorphicLayoutEffect(() => {
const element = ref.current;
if (!element) return;
const measure = () => setWidth(Math.floor(element.clientWidth));
measure();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(measure);
observer.observe(element);
return () => observer.disconnect();
}, [ref]);
return width;
}
const FINE_POINTER = "(pointer: fine)";
function subscribeFinePointer(onChange: () => void): () => void {
if (typeof window.matchMedia !== "function") return () => {};
const query = window.matchMedia(FINE_POINTER);
query.addEventListener("change", onChange);
return () => query.removeEventListener("change", onChange);
}
/** Mouse or pen: the comb may use its compact cells. Touch and the server render take the big ones. */
export function useFinePointer(): boolean {
return useSyncExternalStore(
subscribeFinePointer,
() => typeof window.matchMedia === "function" && window.matchMedia(FINE_POINTER).matches,
() => false,
);
}
@@ -3,6 +3,13 @@ import { daysBetween, inspectionDueText } from "@/lib/autopilot/format";
export type StatusLight = "green" | "yellow" | "red"; export type StatusLight = "green" | "yellow" | "red";
/** The word of each light; the badge and the comb cell show it with its icon. */
export const STATUS_LIGHT_LABELS: Record<StatusLight, string> = {
green: "Gut",
yellow: "Achtung",
red: "Kritisch",
};
export interface StatusLightResult { export interface StatusLightResult {
light: StatusLight; light: StatusLight;
reasons: string[]; reasons: string[];
@@ -0,0 +1,159 @@
import { describe, expect, it } from "vitest";
import type { ColonyOverviewRow } from "@/lib/apiaries/field-types";
import { cellModelOf, rimStepOf, sortCells, summarizeColonies } from "./cell-state";
const TODAY = "2026-06-15";
/** YYYY-MM-DD, `offset` days from TODAY. */
function day(offset: number): string {
const date = new Date(`${TODAY}T00:00:00Z`);
date.setUTCDate(date.getUTCDate() + offset);
return date.toISOString().slice(0, 10);
}
/** A row as the backend sends it since T4 (with `inspectionDueOn`). */
function row(overrides: Partial<ColonyOverviewRow> = {}): ColonyOverviewRow {
return {
id: "c-1",
code: "B1",
status: "ACTIVE",
strength: 6,
occupiedFrames: 9,
broodStatus: "GOOD",
feedStatus: "GOOD",
healthStatus: "GOOD",
lastInspectionDate: `${day(-3)}T00:00:00.000Z`,
inspectionDueOn: day(5),
segment: "SOMMER",
nextStepTitle: null,
openWarnings: 0,
openCritical: 0,
...overrides,
};
}
/** A row from a pack cached before T4: no `inspectionDueOn` at all. */
function oldRow(daysAgo: number | null, overrides: Partial<ColonyOverviewRow> = {}): ColonyOverviewRow {
const old: Partial<ColonyOverviewRow> = row({
lastInspectionDate: daysAgo === null ? null : `${day(-daysAgo)}T00:00:00.000Z`,
...overrides,
});
delete old.inspectionDueOn;
return old as ColonyOverviewRow;
}
const ctx = { today: TODAY, inspectionsVisible: true };
describe("rimStepOf", () => {
it.each([
[10, 1],
[11, 2],
[14, 2],
[15, 3],
] as const)("before T4: %i days since the last inspection is rim step %i", (daysAgo, step) => {
expect(rimStepOf(oldRow(daysAgo), TODAY, true)).toBe(step);
});
it("before T4: never inspected is step 3", () => {
expect(rimStepOf(oldRow(null), TODAY, true)).toBe(3);
});
it.each([
[4, 1],
[3, 2],
[0, 2],
[-1, 3],
] as const)("with T4: due in %i days is step %i", (dueIn, step) => {
expect(rimStepOf(row({ inspectionDueOn: day(dueIn) }), TODAY, true)).toBe(step);
});
it("with T4: never inspected is step 3", () => {
expect(rimStepOf(row({ lastInspectionDate: null, inspectionDueOn: TODAY }), TODAY, true)).toBe(3);
});
it("with T4: inspectionDueOn null (winter rest) is step 1 and named as such", () => {
const winter = row({ inspectionDueOn: null, segment: "WINTERRUHE" });
expect(rimStepOf(winter, TODAY, true)).toBe(1);
const model = cellModelOf(winter, ctx);
expect(model.winterRest).toBe(true);
expect(model.name).toBe("B1 – Gut. Winterruhe – keine Durchsicht nötig.");
});
it("without inspections:read the rim is neutral and says nothing about inspections", () => {
const hidden = row({ lastInspectionDate: null, inspectionDueOn: null });
expect(rimStepOf(hidden, TODAY, false)).toBe("neutral");
const model = cellModelOf(hidden, { today: TODAY, inspectionsVisible: false });
expect(model.rim).toBe("neutral");
expect(model.isDue).toBe(false);
expect(model.name).toBe("B1 – Gut.");
expect(model.name).not.toMatch(/Durchsicht/);
});
});
describe("cellModelOf", () => {
it("the cell name starts with the code and names light, reasons and days", () => {
const before = cellModelOf(oldRow(16, { code: "B3", feedStatus: "WEAK" }), ctx);
expect(before.name).toBe("B3 – Achtung: Futter schwach. Letzte Durchsicht vor 16 Tagen.");
expect(before.daysSince).toBe(16);
const overdue = cellModelOf(row({ code: "B3", inspectionDueOn: day(-2) }), ctx);
// The overdue inspection is a reason of the light; it is not said twice.
expect(overdue.name).toBe("B3 – Achtung: Durchsicht seit 2 Tagen fällig.");
const red = cellModelOf(row({ code: "B6", status: "QUEENLESS", openWarnings: 2 }), ctx);
expect(red.name).toBe("B6 – Kritisch: Weisellos, 2 Warnungen. Durchsicht fällig in 5 Tagen.");
expect(red.reasons).toEqual(["Weisellos", "2 Warnungen"]);
expect(cellModelOf(oldRow(null), ctx).name).toBe("B1 – Gut. Noch nie durchgesehen.");
expect(cellModelOf(oldRow(1), ctx).name).toBe("B1 – Gut. Letzte Durchsicht vor 1 Tag.");
});
it("a cell always carries its code and a light with a word, never colour alone", () => {
const words = { green: "Gut", yellow: "Achtung", red: "Kritisch" } as const;
for (const status of ["ACTIVE", "WEAK", "QUEENLESS"] as const) {
const model = cellModelOf(row({ code: "V-7", status }), ctx);
expect(model.code).toBe("V-7");
expect(model.name.startsWith(`V-7 – ${words[model.light]}`)).toBe(true);
}
});
it("marks a due inspection (today, overdue or never) but not one in a few days", () => {
expect(cellModelOf(row({ inspectionDueOn: TODAY }), ctx).isDue).toBe(true);
expect(cellModelOf(row({ inspectionDueOn: day(-1) }), ctx).isDue).toBe(true);
expect(cellModelOf(row({ inspectionDueOn: day(2) }), ctx).isDue).toBe(false);
expect(cellModelOf(row({ inspectionDueOn: null }), ctx).isDue).toBe(false);
expect(cellModelOf(oldRow(15), ctx).isDue).toBe(true);
expect(cellModelOf(oldRow(12), ctx).isDue).toBe(false);
});
});
describe("summarizeColonies", () => {
it("summarizeColonies counts lights and due inspections", () => {
const models = [
row({ id: "1", code: "B1" }),
row({ id: "2", code: "B2" }),
row({ id: "3", code: "B3", feedStatus: "WEAK", inspectionDueOn: day(-2) }),
row({ id: "4", code: "B4", status: "QUEENLESS", inspectionDueOn: TODAY }),
].map((entry) => cellModelOf(entry, ctx));
const summary = summarizeColonies(models);
expect(summary.counts).toEqual({ green: 2, yellow: 1, red: 1 });
expect(summary.dueCount).toBe(2);
expect(summary.sentence).toBe("2 Gut · 1 Achtung · 1 Kritisch · 2 Durchsichten fällig");
});
it("leaves out empty counts and says one due inspection in the singular", () => {
const models = [row({ inspectionDueOn: day(-1) })].map((entry) => cellModelOf(entry, ctx));
expect(summarizeColonies(models).sentence).toBe("1 Achtung · 1 Durchsicht fällig");
expect(summarizeColonies([]).sentence).toBe("");
});
});
describe("sortCells", () => {
it("cells are ordered naturally by code (V-2 before V-10)", () => {
const models = ["V-10", "V-2", "A1", "V-1"].map((code) => cellModelOf(row({ id: code, code }), ctx));
expect(sortCells(models).map((model) => model.code)).toEqual(["A1", "V-1", "V-2", "V-10"]);
// Pure: the input keeps its order.
expect(models.map((model) => model.code)).toEqual(["V-10", "V-2", "A1", "V-1"]);
});
});
@@ -0,0 +1,136 @@
import type { ColonyOverviewRow } from "@/lib/apiaries/field-types";
import { daysBetween, inspectionDueText } from "@/lib/autopilot/format";
import { colonyStatusLight, STATUS_LIGHT_LABELS, type StatusLight } from "@/lib/colonies/status-light";
import { toCalendarDay } from "@/lib/dates";
/** How the rim of a comb cell is drawn (spec 6.3): thin = fresh, thick = soon, dashed = due. */
export type RimStep = 1 | 2 | 3 | "neutral";
/** Before T4 (pack without `inspectionDueOn`): an inspection older than this is due. */
export const INSPECTION_STALE_DAYS = 14;
/** Before T4: up to this many days since the last inspection the rim is fresh. */
const FRESH_DAYS = 10;
/** With T4: due within this many days is „bald fällig“ (the SOON of the T4 ranking). */
export const SOON_DAYS = 3;
export interface CellModel {
colonyId: string;
code: string;
light: StatusLight;
reasons: string[];
rim: RimStep;
/** Days since the last inspection; null when never inspected or not visible. */
daysSince: number | null;
/** Since T4: no inspection due (`inspectionDueOn` null). */
winterRest: boolean;
/** The inspection is due today, overdue or never happened (the summary counts it). */
isDue: boolean;
/** The accessible name: starts with the code, names light, reasons and the inspection. */
name: string;
}
interface CellContext {
today: string;
inspectionsVisible: boolean;
}
/** A pack cached before T4 carries no `inspectionDueOn` at all (null means winter rest). */
function hasDueDay(row: ColonyOverviewRow): boolean {
return row.inspectionDueOn !== undefined;
}
function daysSinceInspection(row: ColonyOverviewRow, today: string): number | null {
return row.lastInspectionDate ? daysBetween(toCalendarDay(row.lastInspectionDate), today) : null;
}
export function rimStepOf(row: ColonyOverviewRow, today: string, inspectionsVisible: boolean): RimStep {
if (!inspectionsVisible) return "neutral";
if (hasDueDay(row)) {
if (row.inspectionDueOn === null) return 1;
if (row.lastInspectionDate === null) return 3;
const dueIn = daysBetween(today, row.inspectionDueOn);
return dueIn > SOON_DAYS ? 1 : dueIn >= 0 ? 2 : 3;
}
const days = daysSinceInspection(row, today);
if (days === null || days > INSPECTION_STALE_DAYS) return 3;
return days > FRESH_DAYS ? 2 : 1;
}
function isDueOf(row: ColonyOverviewRow, rim: RimStep, today: string): boolean {
if (rim === "neutral") return false;
if (!hasDueDay(row)) return rim === 3;
if (row.inspectionDueOn === null) return false;
return row.lastInspectionDate === null || daysBetween(today, row.inspectionDueOn) <= 0;
}
function daysText(days: number): string {
if (days === 0) return "Letzte Durchsicht heute";
return `Letzte Durchsicht vor ${days} ${days === 1 ? "Tag" : "Tagen"}`;
}
/** The sentence about the inspection, or null when there is nothing (else) to say. */
function inspectionSentence(row: ColonyOverviewRow, ctx: CellContext, reasons: readonly string[]): string | null {
if (!ctx.inspectionsVisible) return null;
if (hasDueDay(row) && row.inspectionDueOn === null) return "Winterruhe – keine Durchsicht nötig";
if (row.lastInspectionDate === null) return "Noch nie durchgesehen";
if (!hasDueDay(row)) return daysText(daysSinceInspection(row, ctx.today) ?? 0);
const due = inspectionDueText(row.inspectionDueOn as string, ctx.today);
// An overdue inspection is already a reason of the light.
return reasons.includes(due) ? null : due;
}
export function cellModelOf(row: ColonyOverviewRow, ctx: CellContext): CellModel {
const { light, reasons } = colonyStatusLight(row, ctx);
const rim = rimStepOf(row, ctx.today, ctx.inspectionsVisible);
const lightText = reasons.length > 0 ? `${STATUS_LIGHT_LABELS[light]}: ${reasons.join(", ")}` : STATUS_LIGHT_LABELS[light];
const inspection = inspectionSentence(row, ctx, reasons);
return {
colonyId: row.id,
code: row.code,
light,
reasons,
rim,
daysSince: ctx.inspectionsVisible ? daysSinceInspection(row, ctx.today) : null,
winterRest: hasDueDay(row) && row.inspectionDueOn === null,
isDue: isDueOf(row, rim, ctx.today),
name: `${row.code} – ${lightText}.${inspection ? ` ${inspection}.` : ""}`,
};
}
const LIGHT_ORDER: readonly StatusLight[] = ["green", "yellow", "red"];
export interface ColonySummary {
counts: Record<StatusLight, number>;
dueCount: number;
/** „5 Gut · 1 Achtung · 1 Kritisch · 2 Durchsichten fällig“ (empty counts left out). */
sentence: string;
}
/** The parts of the summary sentence, for a view that sets an icon before each. */
export function summaryParts(summary: Pick<ColonySummary, "counts" | "dueCount">): { key: StatusLight | "due"; text: string }[] {
const parts: { key: StatusLight | "due"; text: string }[] = LIGHT_ORDER.filter(
(light) => summary.counts[light] > 0,
).map((light) => ({ key: light, text: `${summary.counts[light]} ${STATUS_LIGHT_LABELS[light]}` }));
if (summary.dueCount > 0) {
const noun = summary.dueCount === 1 ? "Durchsicht" : "Durchsichten";
parts.push({ key: "due", text: `${summary.dueCount} ${noun} fällig` });
}
return parts;
}
export function summarizeColonies(models: readonly CellModel[]): ColonySummary {
const counts: Record<StatusLight, number> = { green: 0, yellow: 0, red: 0 };
for (const model of models) counts[model.light] += 1;
const dueCount = models.filter((model) => model.isDue).length;
const sentence = summaryParts({ counts, dueCount })
.map((part) => part.text)
.join(" · ");
return { counts, dueCount, sentence };
}
const CODE_ORDER = new Intl.Collator("de", { numeric: true, sensitivity: "base" });
/** By colony code, naturally (V-2 before V-10), like the list and the round (Offene Frage 2, A). */
export function sortCells(models: readonly CellModel[]): CellModel[] {
return [...models].sort((a, b) => CODE_ORDER.compare(a.code, b.code));
}
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { COMB_GAP, columnsFor, combLayout, combSize, hexHeight } from "./layout";
describe("columnsFor", () => {
it("columns follow the container width between 3 and 10", () => {
// Pitch 64 + 4 = 68: (328 − 34) / 68 = 4.3 → 4 cells (spec 6.2, phone with 328 px).
expect(columnsFor(328, 64)).toBe(4);
expect(columnsFor(600, 56)).toBe(9);
expect(columnsFor(0, 64)).toBe(3);
expect(columnsFor(120, 64)).toBe(3);
expect(columnsFor(2000, 48)).toBe(10);
});
});
describe("combLayout", () => {
const cell = 64;
const pitch = cell + COMB_GAP;
const height = hexHeight(cell);
it("the hexagon is pointy-top: height = width × 2/√3", () => {
expect(height).toBeCloseTo((cell * 2) / Math.sqrt(3), 6);
});
it("fills rows of `columns` cells, 4 px apart", () => {
const places = combLayout(5, 4, cell);
expect(places.slice(0, 4).map(({ x, row }) => [x, row])).toEqual([
[0, 0],
[pitch, 0],
[2 * pitch, 0],
[3 * pitch, 0],
]);
expect(places[4].row).toBe(1);
});
it("every second row is offset and interlocked by a quarter of the height", () => {
const places = combLayout(9, 4, cell);
const rowStep = places[4].y - places[0].y;
// Offset by half a pitch to the right.
expect(places[4].x).toBe(pitch / 2);
expect(places[8].x).toBe(0);
// Three quarters of the height down, plus the gap measured across the slanted edges.
expect(rowStep).toBeCloseTo((height * 3) / 4 + (COMB_GAP * Math.sqrt(3)) / 2, 6);
expect(places[8].y).toBeCloseTo(2 * rowStep, 6);
});
it("measures the comb: offset rows widen it by half a pitch", () => {
expect(combSize(0, 4, cell)).toEqual({ width: 0, height: 0 });
expect(combSize(3, 4, cell)).toEqual({ width: 3 * pitch - COMB_GAP, height });
const two = combSize(6, 4, cell);
expect(two.width).toBe(4 * pitch - COMB_GAP + pitch / 2);
expect(two.height).toBeCloseTo(combLayout(6, 4, cell)[5].y + height, 6);
});
});
@@ -0,0 +1,53 @@
/** Space between two comb cells (spec 6.3). */
export const COMB_GAP = 4;
const MIN_COLUMNS = 3;
const MAX_COLUMNS = 10;
/** Height of a pointy-top hexagon of width `cell` (distance of its vertical sides). */
export function hexHeight(cell: number): number {
return (cell * 2) / Math.sqrt(3);
}
/** Cells per row: n = floor((W − (b + 4)/2) / (b + 4)), between 3 and 10. */
export function columnsFor(containerWidth: number, cell: number): number {
const pitch = cell + COMB_GAP;
const fitting = Math.floor((containerWidth - pitch / 2) / pitch);
return Math.min(MAX_COLUMNS, Math.max(MIN_COLUMNS, fitting));
}
/**
* Vertical step between rows: rows interlock by a quarter of the height; the gap across the
* slanted edges is then COMB_GAP, like the gap between the vertical sides.
*/
function rowStep(cell: number): number {
return (hexHeight(cell) * 3) / 4 + (COMB_GAP * Math.sqrt(3)) / 2;
}
export interface CombPlace {
x: number;
y: number;
row: number;
}
/** Top-left corner of each cell; every second row is shifted right by half a pitch. */
export function combLayout(count: number, columns: number, cell: number): CombPlace[] {
const pitch = cell + COMB_GAP;
const step = rowStep(cell);
return Array.from({ length: count }, (_, index) => {
const row = Math.floor(index / columns);
const offset = row % 2 === 1 ? pitch / 2 : 0;
return { x: (index % columns) * pitch + offset, y: row * step, row };
});
}
/** Outer size of the comb, for the positioned list that holds the cells. */
export function combSize(count: number, columns: number, cell: number): { width: number; height: number } {
if (count === 0) return { width: 0, height: 0 };
const pitch = cell + COMB_GAP;
const rows = Math.ceil(count / columns);
const perRow = Math.min(count, columns);
return {
width: perRow * pitch - COMB_GAP + (rows > 1 ? pitch / 2 : 0),
height: (rows - 1) * rowStep(cell) + hexHeight(cell),
};
}
@@ -186,7 +186,14 @@ const RULES: GuardRule[] = [
id: "no outline-none", id: "no outline-none",
pattern: /(?<![\w-])outline-none\b/, pattern: /(?<![\w-])outline-none\b/,
scope: "src/**/*.{ts,tsx}", scope: "src/**/*.{ts,tsx}",
allow: [], // Task B1: the one exception of spec 4.6/6.6.
allow: [
{
file: "src/components/honeycomb/honeycomb-cell.tsx",
reason:
"Zweiter Sechseckpfad zeichnet den Ring, Spec 6.6: a rectangular outline around a hexagon cell would cover its neighbours, so the link drops it and .comb-focus draws the ring in --ring with --focus-width outside the cell (styles/comb.css).",
},
],
examples: { examples: {
catches: ['"px-2 focus-visible:outline-none"', '"outline-none px-2"'], catches: ['"px-2 focus-visible:outline-none"', '"outline-none px-2"'],
ignores: ['"outline-hidden focus:bg-accent"', '"outline-2"'], ignores: ['"outline-hidden focus:bg-accent"', '"outline-2"'],
@@ -583,6 +590,12 @@ 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 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",
"src/components/honeycomb/apiary-comb-card.tsx",
"src/components/honeycomb/comb-legend.tsx",
"src/styles/comb.css",
]; ];
/** Overlays of the kit (spec 17.4): enter with --motion-enter, leave with --motion-fade and --ease-in. */ /** Overlays of the kit (spec 17.4): enter with --motion-enter, leave with --motion-fade and --ease-in. */
const OVERLAY_FILES = ["sheet", "dialog", "dropdown-menu", "select"].map((name) => `src/components/ui/${name}.tsx`); const OVERLAY_FILES = ["sheet", "dialog", "dropdown-menu", "select"].map((name) => `src/components/ui/${name}.tsx`);
+109
View File
@@ -0,0 +1,109 @@
/* Waben-Standkarte (spec 6). Colours only from tokens; no movement (spec 6.8). */
/* .comb-key: a legend entry with the same fill as its light. */
.comb-cell,
.comb-key {
--cell-fill: var(--condition-good);
--cell-ink: var(--condition-good-foreground);
}
/* Icon and code sit on the fill; legend text stays in the page colour. */
.comb-cell {
color: var(--cell-ink);
}
.comb-cell[data-light="yellow"],
.comb-key[data-light="yellow"] {
--cell-fill: var(--condition-average);
--cell-ink: var(--condition-average-foreground);
}
.comb-cell[data-light="red"],
.comb-key[data-light="red"] {
--cell-fill: var(--condition-critical);
--cell-ink: var(--condition-critical-foreground);
}
/* Rows interlock, so the rectangles of slot and link overlap the cell above. Only the painted
hexagon takes pointers (taps, hover); a clip-path on the link would cut off the focus ring. */
.comb-slot {
pointer-events: none;
}
.comb-fill {
fill: var(--cell-fill);
stroke: none;
pointer-events: visiblePainted;
}
/* The rim lies inside the cell: #comb-clip keeps the inner half of a doubled stroke. It is
drawn in the ink of the fill (dark on all three fills in all four sets, at least 4.5:1),
so its width reads against the fill in dark mode too. */
.comb-rim {
fill: none;
stroke: var(--cell-ink);
stroke-width: calc(var(--comb-stroke-1) * 2);
}
.comb-rim[data-rim="2"] {
stroke-width: calc(var(--comb-stroke-2) * 2);
}
.comb-rim[data-rim="3"] {
stroke-width: calc(var(--comb-stroke-3) * 2);
stroke-dasharray: 6 4;
}
/* Without inspections:read: a thin neutral rim that says nothing about inspections. */
.comb-rim[data-rim="neutral"] {
stroke: var(--input);
stroke-width: 2px;
}
.comb-focus {
fill: none;
stroke: none;
}
@media (pointer: fine) {
.comb-cell:hover .comb-focus {
stroke: var(--primary-ink);
stroke-width: 2px;
}
}
/* The link has no outline (guard exception): this hexagon outside the cell is the ring. */
.comb-cell:focus-visible .comb-focus {
stroke: var(--ring);
stroke-width: var(--focus-width);
}
/* Small hexagon swatches of the legend. */
.comb-swatch {
fill: var(--cell-fill);
stroke: var(--cell-ink);
stroke-width: 1px;
}
@media (forced-colors: active) {
.comb-cell,
.comb-key {
color: CanvasText;
}
.comb-fill,
.comb-swatch {
fill: Canvas;
stroke: CanvasText;
}
/* The steps stay readable by width and dashes; the state by the icon. */
.comb-rim,
.comb-rim[data-rim="neutral"] {
stroke: CanvasText;
}
.comb-cell:focus-visible .comb-focus {
stroke: Highlight;
}
}