feat(t5): Waben-Standkarte für Bienenstände (T5b B1)
- 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:
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`);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user