diff --git a/ApiCore-Frontend/scripts/codemod-t5-focus.spec.mjs b/ApiCore-Frontend/scripts/codemod-t5-focus.spec.mjs index 3349a00..f1d4628 100644 --- a/ApiCore-Frontend/scripts/codemod-t5-focus.spec.mjs +++ b/ApiCore-Frontend/scripts/codemod-t5-focus.spec.mjs @@ -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", () => { - 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); const pending = files.filter((file) => { const text = readFileSync(file, "utf8"); diff --git a/ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-colonies-card.spec.tsx b/ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-colonies-card.spec.tsx index e85c5e1..1b4bfe1 100644 --- a/ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-colonies-card.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-colonies-card.spec.tsx @@ -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 { + 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", () => { beforeEach(() => { window.sessionStorage.clear(); @@ -74,10 +83,11 @@ describe("ApiaryColoniesCard", () => { renderCard(); 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(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("Weisellos · 2 Warnungen")).toBeInTheDocument(); expect(within(first).getByText("Aktiv")).toBeInTheDocument(); @@ -94,15 +104,34 @@ describe("ApiaryColoniesCard", () => { renderCard(); 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("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(); - const third = screen.getByRole("link", { name: /A3/ }); + const third = within(list).getByRole("link", { name: /A3/ }); 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 () => { vi.mocked(fetchColonyOverview).mockResolvedValue( overview([row({ id: "c-1", code: "A1" }), row({ id: "c-2", code: "A2" })]), diff --git a/ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-colonies-card.tsx b/ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-colonies-card.tsx index efa4c24..efaf61b 100644 --- a/ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-colonies-card.tsx +++ b/ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-colonies-card.tsx @@ -1,7 +1,7 @@ "use client"; 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 type { ColonyOverview } from "@/lib/apiaries/field-types"; import { useCurrentUserId } from "@/lib/auth/auth-context"; @@ -11,7 +11,7 @@ import { daysBetween, inspectionDueText } from "@/lib/autopilot/format"; import { todayLocalIsoDate } from "@/lib/dates"; import { startRound } from "@/lib/rounds/round-state"; 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 { ErrorState, LoadingState } from "@/components/query-state"; import { Button } from "@/components/ui/button"; @@ -120,14 +120,11 @@ export function ApiaryColoniesCard({ apiary }: { apiary: ApiaryRef }) { {!query.isLoading && !overview ? ( void query.refetch()} isRetrying={query.isFetching} /> ) : null} - {overview && overview.rows.length === 0 ? ( - + {overview ? ( + + + ) : null} - {overview ? : null}
diff --git a/ApiCore-Frontend/src/app/(app)/apiaries/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/apiaries/page.spec.tsx index 0679eda..cf78850 100644 --- a/ApiCore-Frontend/src/app/(app)/apiaries/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/apiaries/page.spec.tsx @@ -1,13 +1,64 @@ 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 type { ReactElement } from "react"; import ApiariesPage from "./page"; 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", () => ({ fetchApiaries: vi.fn(), })); +vi.mock("@/lib/apiaries/field-api", async (importOriginal) => ({ + ...(await importOriginal()), + fetchColonyOverview: vi.fn(), +})); +vi.mock("@/lib/auth/auth-context", () => ({ + useCurrentUserId: () => "user-1", + useAuth: () => ({ hasPermission: () => true }), +})); + +function apiaryOf(id: string, name: string, overrides: Partial = {}): 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 { + 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) { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); @@ -17,6 +68,12 @@ function renderWithClient(ui: ReactElement) { describe("ApiariesPage", () => { beforeEach(() => { vi.mocked(fetchApiaries).mockReset(); + vi.mocked(fetchColonyOverview).mockReset(); + vi.mocked(fetchColonyOverview).mockImplementation(async (apiaryId) => ({ + apiaryId, + inspectionsVisible: true, + rows: [], + })); }); it("renders the fetched apiaries", async () => { @@ -53,4 +110,66 @@ describe("ApiariesPage", () => { expect(screen.getByRole("tab", { name: "Historie" })).toBeInTheDocument(); 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(); + + // 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(); + + 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(); + }); }); diff --git a/ApiCore-Frontend/src/app/(app)/apiaries/page.tsx b/ApiCore-Frontend/src/app/(app)/apiaries/page.tsx index 6e75a05..dda7d68 100644 --- a/ApiCore-Frontend/src/app/(app)/apiaries/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/apiaries/page.tsx @@ -13,6 +13,7 @@ import { PageHeader } from "@/components/page-header"; import { EmptyState } from "@/components/empty-state"; import { EntityCard } from "@/components/entity-card"; 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 { DropdownMenu, @@ -47,6 +48,8 @@ export default function ApiariesPage() { isFiltered={location !== ""} onResetFilter={() => setLocation("")} /> + ) : bucket === "active" ? ( + ) : ( ) diff --git a/ApiCore-Frontend/src/app/(app)/offline/apiary/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/offline/apiary/page.spec.tsx index faba961..586d765 100644 --- a/ApiCore-Frontend/src/app/(app)/offline/apiary/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/offline/apiary/page.spec.tsx @@ -1,6 +1,7 @@ import "fake-indexeddb/auto"; 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 { getDb } from "@/lib/offline/db"; import { deleteAllPacks } from "@/lib/apiaries/offline-pack"; @@ -50,7 +51,11 @@ describe("OfflineApiaryPage", () => { renderPage(); 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(within(second).getByText("Kritisch")).toBeInTheDocument(); 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 () => { const db = await getDb(); await db.put("offline-packs", storedPack(offlinePack({ apiaryId: "apiary-1" }))); diff --git a/ApiCore-Frontend/src/app/(app)/offline/apiary/page.tsx b/ApiCore-Frontend/src/app/(app)/offline/apiary/page.tsx index b91aaf2..750e55c 100644 --- a/ApiCore-Frontend/src/app/(app)/offline/apiary/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/offline/apiary/page.tsx @@ -5,6 +5,7 @@ import { useSearchParams } from "next/navigation"; import { useCurrentUserId } from "@/lib/auth/auth-context"; import type { StoredPack } from "@/lib/offline/db"; import { DetailHeader } from "@/components/detail-header"; +import { ApiaryComb } from "@/components/honeycomb/apiary-comb"; import { PackAgeNote, useOwnPack } from "@/components/offline/load-pack-button"; import { ColonyOverviewLinks, StartRoundButton } from "../../apiaries/[id]/apiary-colonies-card"; import { @@ -84,11 +85,9 @@ function OfflineApiaryView({ stored, readAt, returnHref }: OfflineApiaryViewProp
- {overview.rows.length === 0 ? ( -

Keine Völker an diesem Stand.

- ) : ( + - )} +
{apiaryTasks !== null ? ( diff --git a/ApiCore-Frontend/src/components/colonies/status-light-badge.tsx b/ApiCore-Frontend/src/components/colonies/status-light-badge.tsx index 1f747c1..e51b1b4 100644 --- a/ApiCore-Frontend/src/components/colonies/status-light-badge.tsx +++ b/ApiCore-Frontend/src/components/colonies/status-light-badge.tsx @@ -1,22 +1,29 @@ import { AlertTriangle, CheckCircle2, XCircle, type LucideIcon } from "lucide-react"; import { Badge } from "@/components/ui/badge"; 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"; -const LIGHTS: Record = { - green: { label: "Gut", icon: CheckCircle2, tone: COLONY_CONDITION_TONE_CLASS.GOOD }, - yellow: { label: "Achtung", icon: AlertTriangle, tone: COLONY_CONDITION_TONE_CLASS.AVERAGE }, - red: { label: "Kritisch", icon: XCircle, tone: COLONY_CONDITION_TONE_CLASS.CRITICAL }, +/** The icon of each light, shared with the comb cell. */ +export const STATUS_LIGHT_ICONS: Record = { + green: CheckCircle2, + yellow: AlertTriangle, + red: XCircle, +}; + +const TONES: Record = { + 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. */ export function StatusLightBadge({ light, className }: { light: StatusLight; className?: string }) { - const { label, icon: Icon, tone } = LIGHTS[light]; + const Icon = STATUS_LIGHT_ICONS[light]; return ( - + - {label} + {STATUS_LIGHT_LABELS[light]} ); } diff --git a/ApiCore-Frontend/src/components/honeycomb/apiary-comb-card.tsx b/ApiCore-Frontend/src/components/honeycomb/apiary-comb-card.tsx new file mode 100644 index 0000000..7eac3c1 --- /dev/null +++ b/ApiCore-Frontend/src/components/honeycomb/apiary-comb-card.tsx @@ -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 ( + + +
+ +
+

+ + {apiary.name} + +

+ {apiary.location ?

{apiary.location}

: null} +
+ {segment ? : null} +
+ {withComb && query.isLoading ? : null} + {withComb && !query.isLoading && !overview && !isForbidden ? ( + void query.refetch()} isRetrying={query.isFetching} /> + ) : null} + {overview && models.length === 0 ? ( +

Noch keine Völker an diesem Stand.

+ ) : null} + {models.length > 0 ? ( + <> + + + + ) : null} +
+
+ ); +} + +/** 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 ( +
+ + +
+ {apiaries.map((apiary, index) => ( + + ))} +
+
+ ); +} diff --git a/ApiCore-Frontend/src/components/honeycomb/apiary-comb.spec.tsx b/ApiCore-Frontend/src/components/honeycomb/apiary-comb.spec.tsx new file mode 100644 index 0000000..22f25cb --- /dev/null +++ b/ApiCore-Frontend/src/components/honeycomb/apiary-comb.spec.tsx @@ -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 { + 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( + `/colonies/${id}`} + > +

Die T3-Liste

+
, + ); +} + +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( `/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( `/c/${id}`} size="list" />); + expect(screen.getByText("AB-123")).toHaveClass("text-sm"); + }); + }); +}); diff --git a/ApiCore-Frontend/src/components/honeycomb/apiary-comb.tsx b/ApiCore-Frontend/src/components/honeycomb/apiary-comb.tsx new file mode 100644 index 0000000..7700496 --- /dev/null +++ b/ApiCore-Frontend/src/components/honeycomb/apiary-comb.tsx @@ -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 on the page. */ +export function CombCells({ apiaryName, models, hrefFor, size }: CombCellsProps) { + const ref = useRef(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 ( +
+
    + {models.map((model, index) => ( + + ))} +
+
+ ); +} + +/** „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 ( +

+ {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 ( + + {index > 0 || total !== undefined ? " · " : null} + + ); + })} +

+ ); +} + +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 ( +
+

+ Braucht Blick +

+ {needing.length === 0 ? ( +

Alle Völker sind in Ordnung.

+ ) : ( +
    + {needing.map((model) => ( +
  • + {model.code} + + {model.reasons.join(" · ")} +
  • + ))} +
+ )} +
+ ); +} + +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 ( + + ); + } + + const ListIcon = isListOpen ? ChevronUp : ChevronDown; + return ( +
+ + + + + +
+ +
+ +
+ ); +} diff --git a/ApiCore-Frontend/src/components/honeycomb/comb-legend.tsx b/ApiCore-Frontend/src/components/honeycomb/comb-legend.tsx new file mode 100644 index 0000000..f0d737b --- /dev/null +++ b/ApiCore-Frontend/src/components/honeycomb/comb-legend.tsx @@ -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 ( +
    + {LIGHTS.map((light) => { + const Icon = STATUS_LIGHT_ICONS[light]; + return ( +
  • + +
  • + ); + })} + {showRim ?
  • Rand: dünn = frisch, dick = bald fällig, gestrichelt = Durchsicht fällig
  • : null} +
+ ); +} diff --git a/ApiCore-Frontend/src/components/honeycomb/comb-symbols.tsx b/ApiCore-Frontend/src/components/honeycomb/comb-symbols.tsx new file mode 100644 index 0000000..d8e5ed7 --- /dev/null +++ b/ApiCore-Frontend/src/components/honeycomb/comb-symbols.tsx @@ -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 + * ``. 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 ( + + ); +} diff --git a/ApiCore-Frontend/src/components/honeycomb/honeycomb-cell.tsx b/ApiCore-Frontend/src/components/honeycomb/honeycomb-cell.tsx new file mode 100644 index 0000000..12dc855 --- /dev/null +++ b/ApiCore-Frontend/src/components/honeycomb/honeycomb-cell.tsx @@ -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 ( +
  • + + +
  • + ); +} diff --git a/ApiCore-Frontend/src/components/honeycomb/use-container-width.ts b/ApiCore-Frontend/src/components/honeycomb/use-container-width.ts new file mode 100644 index 0000000..c283f0f --- /dev/null +++ b/ApiCore-Frontend/src/components/honeycomb/use-container-width.ts @@ -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): 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, + ); +} diff --git a/ApiCore-Frontend/src/lib/colonies/status-light.ts b/ApiCore-Frontend/src/lib/colonies/status-light.ts index aec8701..a3ab373 100644 --- a/ApiCore-Frontend/src/lib/colonies/status-light.ts +++ b/ApiCore-Frontend/src/lib/colonies/status-light.ts @@ -3,6 +3,13 @@ import { daysBetween, inspectionDueText } from "@/lib/autopilot/format"; 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 = { + green: "Gut", + yellow: "Achtung", + red: "Kritisch", +}; + export interface StatusLightResult { light: StatusLight; reasons: string[]; diff --git a/ApiCore-Frontend/src/lib/honeycomb/cell-state.spec.ts b/ApiCore-Frontend/src/lib/honeycomb/cell-state.spec.ts new file mode 100644 index 0000000..133b4ca --- /dev/null +++ b/ApiCore-Frontend/src/lib/honeycomb/cell-state.spec.ts @@ -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 { + 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 { + const old: Partial = 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"]); + }); +}); diff --git a/ApiCore-Frontend/src/lib/honeycomb/cell-state.ts b/ApiCore-Frontend/src/lib/honeycomb/cell-state.ts new file mode 100644 index 0000000..ffdc1d5 --- /dev/null +++ b/ApiCore-Frontend/src/lib/honeycomb/cell-state.ts @@ -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; + 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): { 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 = { 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)); +} diff --git a/ApiCore-Frontend/src/lib/honeycomb/layout.spec.ts b/ApiCore-Frontend/src/lib/honeycomb/layout.spec.ts new file mode 100644 index 0000000..544d751 --- /dev/null +++ b/ApiCore-Frontend/src/lib/honeycomb/layout.spec.ts @@ -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); + }); +}); diff --git a/ApiCore-Frontend/src/lib/honeycomb/layout.ts b/ApiCore-Frontend/src/lib/honeycomb/layout.ts new file mode 100644 index 0000000..a7d1917 --- /dev/null +++ b/ApiCore-Frontend/src/lib/honeycomb/layout.ts @@ -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), + }; +} diff --git a/ApiCore-Frontend/src/styles/baukasten-guard.spec.ts b/ApiCore-Frontend/src/styles/baukasten-guard.spec.ts index 3aaa73a..68e0085 100644 --- a/ApiCore-Frontend/src/styles/baukasten-guard.spec.ts +++ b/ApiCore-Frontend/src/styles/baukasten-guard.spec.ts @@ -186,7 +186,14 @@ const RULES: GuardRule[] = [ id: "no outline-none", pattern: /(? `src/components/ui/${name}.tsx`); diff --git a/ApiCore-Frontend/src/styles/comb.css b/ApiCore-Frontend/src/styles/comb.css new file mode 100644 index 0000000..01da11b --- /dev/null +++ b/ApiCore-Frontend/src/styles/comb.css @@ -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; + } +}