feat: hive card with SVG, outline, edit sheets and catalog settings (Beute L2)

- lib/hive: frontend port of the hive domain (ops, validator, facts,
  layout, follow-ups, describe texts, reason texts) running the backend
  fixtures, API client
- hive card on the colony page (same height as Stammdaten): SVG view,
  accessible outline tree, frame/part sheets with optimistic save,
  conflict sheet, history with past states, init from template or
  another colony, inspection draft mode
- follow-up toasts prefilling feeding and treatment forms (treatment
  end only with an open treatment)
- settings page "Beutensysteme" (hive-systems:manage)
- NumberStepper decimal steps, reactive motion level hook, holo tokens
- spec: owner design decision 2026-10-16 (glass hologram, focus mode)

The SVG view is the fallback; the 3D scene follows in L3.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-16 09:12:03 +02:00
1 parent 0091b3d28b
commit c608f5d405
87 files changed
+9493 -43

No files matched your search

@@ -24,7 +24,7 @@ export function ColonyTreatmentsCard({
});
return (
<Card>
<Card id="behandlungen">
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2">
<CardTitle>Behandlungen</CardTitle>
<Button
@@ -17,8 +17,9 @@ vi.mock("@/lib/export/api", () => ({
exportColonyRecordCardPdf: vi.fn(),
}));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock("./colony-edit-card", () => ({ ColonyEditCard: () => null }));
vi.mock("./colony-actions", () => ({ ColonyActions: () => null }));
vi.mock("./colony-edit-card", () => ({ ColonyEditCard: () => <div data-testid="edit-card" /> }));
vi.mock("./colony-actions", () => ({ ColonyActions: () => <div data-testid="actions" /> }));
vi.mock("@/components/hive/hive-card", () => ({ HiveCard: () => <div data-testid="hive-card" /> }));
vi.mock("./colony-history", () => ({ ColonyHistoryCard: () => null }));
vi.mock("./colony-inspections", () => ({ ColonyInspectionsCard: () => null }));
vi.mock("./colony-varroa", () => ({ ColonyVarroaCard: () => null }));
@@ -159,3 +160,51 @@ describe("ColonyDetailPage – ?editTreatment", () => {
expect(await screen.findByText("Behandlungen, Dialog für keine")).toBeInTheDocument();
});
});
describe("ColonyDetailPage – grid", () => {
beforeEach(() => {
vi.mocked(fetchColony).mockReset().mockResolvedValue({
id: "colony-1",
code: "V-01",
colonyType: "PRODUCTION_COLONY",
status: "ACTIVE",
strength: null,
occupiedFrames: null,
} as Colony);
});
it("puts Stammdaten and Beute in one grid row that stretches both to the same height", async () => {
renderPage();
const edit = await screen.findByTestId("edit-card");
const hive = screen.getByTestId("hive-card");
expect(edit.parentElement).toBe(hive.parentElement);
const row = edit.parentElement!;
expect(row).toHaveClass("grid", "grid-cols-1", "gap-4", "lg:grid-cols-3");
expect(row).not.toHaveClass("items-start");
});
it("keeps the order Stammdaten, Beute, Aktionen, Karten", async () => {
renderPage();
const nodes = [
await screen.findByTestId("edit-card"),
screen.getByTestId("hive-card"),
screen.getByTestId("actions"),
screen.getByText("Behandlungen, Dialog für keine"),
];
for (let i = 1; i < nodes.length; i += 1) {
expect(nodes[i - 1].compareDocumentPosition(nodes[i]) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
}
});
it("starts the second row (Aktionen, Karten) with items-start", async () => {
renderPage();
const actions = await screen.findByTestId("actions");
const secondRow = actions.closest(".grid")!;
expect(secondRow).toHaveClass("lg:grid-cols-3", "items-start");
expect(secondRow.contains(screen.getByText("Behandlungen, Dialog für keine"))).toBe(true);
expect(secondRow.contains(screen.getByTestId("hive-card"))).toBe(false);
});
});
@@ -8,6 +8,7 @@ import { toast } from "sonner";
import { fetchColony } from "@/lib/colonies/api";
import { COLONY_STATUS_LABELS, COLONY_TYPE_LABELS, colonyStrengthText } from "@/lib/colonies/labels";
import { exportColonyAnnualReportPdf, exportColonyRecordCardPdf } from "@/lib/export/api";
import { HiveCard } from "@/components/hive/hive-card";
import { NextStepsCard } from "@/components/autopilot/next-steps-card";
import { QrCodeDialog } from "@/components/qr-code-dialog";
import { DocumentUploadCard } from "@/components/document-upload-card";
@@ -88,9 +89,13 @@ export default function ColonyDetailPage() {
/>
{strengthText ? <p className="text-sm text-muted-foreground">{strengthText}</p> : null}
<NextStepsCard colonyId={colony.id} />
{/* Row 1 has no items-start: the grid row stretches Stammdaten and Beute to one height (spec 9.2). */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<ColonyEditCard key={colony.id} colony={colony} />
<HiveCard key={`hive-${colony.id}`} colonyId={colony.id} colony={colony} />
</div>
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-3">
<div className="flex flex-col gap-4 lg:col-span-1">
<ColonyEditCard key={colony.id} colony={colony} />
<ColonyActions colony={colony} />
<div>
<QrCodeDialog entityType="colonies" id={colony.id} label="QR-Code für die Beute" />
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { initialFeedingValues } from "./feeding-form-values";
const params = (query: string) => new URLSearchParams(query);
describe("initialFeedingValues prefill", () => {
it("keeps the defaults without search params", () => {
const values = initialFeedingValues("c1", params("colonyId=c1"));
expect(values).toMatchObject({ colonyId: "c1", amount: "", unit: "l", feedType: "SUGAR_WATER" });
});
it("reads date, amount, unit and feedType from the search params", () => {
const values = initialFeedingValues(
"c1",
params("colonyId=c1&date=2026-10-12&amount=3&unit=l&feedType=FEED_PASTE"),
);
expect(values).toMatchObject({
date: "2026-10-12",
amount: "3",
unit: "l",
feedType: "FEED_PASTE",
});
});
it("accepts a decimal amount", () => {
expect(initialFeedingValues("c1", params("amount=2.5&unit=kg")).amount).toBe("2.5");
});
it.each([
["date=2026-02-30", "date"],
["date=morgen", "date"],
["amount=abc", "amount"],
["amount=-1", "amount"],
["amount=0", "amount"],
["amount=Infinity", "amount"],
["unit=Eimer", "unit"],
["feedType=HONEY", "feedType"],
] as const)("ignores the invalid value in %s", (query, key) => {
const defaults = initialFeedingValues("c1");
const values = initialFeedingValues("c1", params(query));
expect(values[key]).toBe(defaults[key]);
});
});
@@ -4,6 +4,8 @@ import type { BulkCreateFeedingInput } from "@/lib/bulk/api";
import type { CaptureTarget } from "@/components/form/capture-target-toggle";
import { toApiDate, todayLocalIsoDate } from "@/lib/dates";
import { emptyToNull } from "@/lib/forms/empty-to-null";
import { prefillAmount, prefillDay, type ParamSource } from "@/lib/forms/prefill-params";
import { FEED_TYPES } from "@/lib/feedings/labels";
/** Common units as one tap each; the API still receives a plain string. */
export const FEEDING_UNIT_OPTIONS: { value: string; label: string }[] = [
@@ -28,16 +30,22 @@ export interface FeedingFormValues {
notes: string;
}
export function initialFeedingValues(colonyId: string): FeedingFormValues {
/**
* Start values. Search params `date`, `amount`, `unit`, `feedType` (the hive
* card's „Als Fütterung eintragen“ link) prefill the fields; invalid ones are ignored.
*/
export function initialFeedingValues(colonyId: string, params?: ParamSource): FeedingFormValues {
const unit = params?.get("unit") ?? "";
const feedType = params?.get("feedType") ?? "";
return {
colonyId,
target: "single",
bulkApiaryId: null,
bulkColonyIds: [],
date: todayLocalIsoDate(),
feedType: "SUGAR_WATER",
amount: "",
unit: "l",
date: (params && prefillDay(params.get("date"))) ?? todayLocalIsoDate(),
feedType: FEED_TYPES.find((candidate) => candidate === feedType) ?? "SUGAR_WATER",
amount: (params && prefillAmount(params.get("amount"))) ?? "",
unit: FEEDING_UNIT_OPTIONS.some((option) => option.value === unit) ? unit : "l",
product: "",
batchNumber: "",
notes: "",
@@ -9,11 +9,13 @@ import { createFeedingsBulk } from "@/lib/bulk/api";
import { queueBulk } from "@/lib/offline/db";
import { fetchApiaries } from "@/lib/apiaries/api";
import { fetchColonies } from "@/lib/colonies/api";
import { FEED_TYPE_LABELS } from "@/lib/feedings/labels";
const push = vi.fn();
const search = vi.hoisted(() => ({ query: "colonyId=colony-1" }));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push }),
useSearchParams: () => new URLSearchParams("colonyId=colony-1"),
useSearchParams: () => new URLSearchParams(search.query),
}));
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() }));
vi.mock("@/lib/feedings/api", () => ({ createFeeding: vi.fn() }));
@@ -45,6 +47,7 @@ function renderPage() {
describe("NewFeedingPage", () => {
beforeEach(() => {
window.localStorage.clear();
search.query = "colonyId=colony-1";
push.mockReset();
vi.mocked(fetchColonies).mockResolvedValue([
{ id: "colony-1", code: "V-01", status: "ACTIVE" },
@@ -57,6 +60,15 @@ describe("NewFeedingPage", () => {
.mockResolvedValue([{ id: "apiary-1", name: "Waldrand" }] as never);
});
it("prefills date, amount, unit and feed type from the hive card link", async () => {
search.query = "colonyId=colony-1&date=2026-10-12&amount=3&unit=l&feedType=FEED_PASTE";
renderPage();
expect(await screen.findByLabelText(/^Menge/)).toHaveValue(3);
expect(screen.getByLabelText(/^Datum/)).toHaveValue("2026-10-12");
expect(screen.getByRole("radio", { name: "l" })).toBeChecked();
expect(screen.getByRole("radio", { name: FEED_TYPE_LABELS.FEED_PASTE })).toBeChecked();
});
it("marks what is required and keeps the extra fields folded", () => {
renderPage();
// Volk, Datum, Futterart, Menge, Einheit
@@ -80,7 +80,7 @@ export default function NewFeedingPage() {
const [values, setValues] = useState<FeedingFormValues>(() =>
initialFeedingValues(searchParams.get("colonyId") ?? ""),
initialFeedingValues(searchParams.get("colonyId") ?? "", searchParams),
);
const [dirty, setDirty] = useState(false);
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
@@ -51,6 +51,12 @@ describe("nav-config", () => {
expect(hrefs(["system:read"])).toContain("/settings/system");
});
it("shows „Beutensysteme“ only with hive-systems:manage", () => {
expect(hrefs([])).not.toContain("/settings/hive-systems");
expect(hrefs(["colonies:read"])).not.toContain("/settings/hive-systems");
expect(hrefs(["hive-systems:manage"])).toContain("/settings/hive-systems");
});
it("hides items the user may not see", () => {
expect(hrefs([])).not.toContain("/winter");
expect(hrefs(["winter-checks:read"])).toContain("/winter");
@@ -1,6 +1,7 @@
import {
Activity,
BarChart3,
Box,
Boxes,
CalendarDays,
Crown,
@@ -152,6 +153,12 @@ export const ADMIN_GROUP: NavGroup = {
"api-keys:manage",
],
},
{
label: "Beutensysteme",
href: "/settings/hive-systems",
icon: Box,
permission: "hive-systems:manage",
},
{
label: "Backup",
href: "/settings/backup",
@@ -0,0 +1,181 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import HiveSystemsPage from "./page";
import { fetchHiveSystems } from "@/lib/hive/api";
import { updateHivePartType } from "@/lib/hive/catalog-api";
import type { HivePartTypeDto, HiveSystemDto } from "@/lib/hive/types";
vi.mock("@/lib/hive/api", async (importOriginal) => {
const original = await importOriginal<typeof import("@/lib/hive/api")>();
return { ...original, fetchHiveSystems: vi.fn() };
});
vi.mock("@/lib/hive/catalog-api", () => ({
createHiveSystem: vi.fn(),
updateHiveSystem: vi.fn(),
createHivePartType: vi.fn(),
updateHivePartType: vi.fn(),
}));
let permissions: string[] = [];
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({ user: { permissions } }),
}));
const stamp = { createdAt: "2026-10-01T00:00:00.000Z", updatedAt: "2026-10-01T00:00:00.000Z" };
const feeder: HivePartTypeDto = {
id: "pt1",
systemId: "s1",
kind: "FEEDER",
name: "Futtertrog XL",
articleRef: null,
lengthMm: 500,
widthMm: 480,
heightMm: 200,
frameSlots: null,
boxDepth: null,
stateSchema: [
{ key: "syrup", label: "Flüssigfutter", kind: "level", unit: "l", max: 3, step: 0.5 },
{ key: "paste", label: "Futterteig", kind: "level", unit: "kg", max: 2.5, step: 0.5 },
],
active: true,
...stamp,
};
const box: HivePartTypeDto = {
...feeder,
id: "pt2",
kind: "BOX",
name: "Zarge DNM",
heightMm: 240,
frameSlots: 10,
boxDepth: "FULL",
stateSchema: [],
active: false,
};
const dadant: HiveSystemDto = {
id: "s1",
name: "Dadant",
frameWidthMm: 435,
frameHeightMm: 300,
footprintLMm: 500,
footprintWMm: 480,
template: { schemaVersion: 1, stack: [{}, {}, {}, {}, {}, {}] },
active: true,
partTypes: [feeder, box],
...stamp,
};
const zander: HiveSystemDto = {
...dadant,
id: "s2",
name: "Zander",
template: null,
active: false,
partTypes: [],
};
function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={client}>
<HiveSystemsPage />
</QueryClientProvider>,
);
}
describe("HiveSystemsPage", () => {
beforeEach(() => {
permissions = ["hive-systems:manage"];
vi.mocked(fetchHiveSystems).mockReset().mockResolvedValue([dadant, zander]);
vi.mocked(updateHivePartType).mockReset().mockResolvedValue({} as never);
});
it("shows the no-access state and loads nothing without the permission", () => {
permissions = ["colonies:read"];
renderPage();
expect(screen.getByText(/keine Berechtigung/i)).toBeInTheDocument();
expect(fetchHiveSystems).not.toHaveBeenCalled();
});
it("lists one card per system with chips, dimensions and template info", async () => {
renderPage();
const dadantCard = await screen.findByRole("region", { name: "Dadant" });
expect(within(dadantCard).getByText("aktiv", { selector: "[data-system-chip]" })).toBeInTheDocument();
expect(within(dadantCard).getByText(/435 × 300 mm/)).toBeInTheDocument();
expect(within(dadantCard).getByText(/500 × 480 mm/)).toBeInTheDocument();
expect(within(dadantCard).getByText("Vorlage: 6 Teile")).toBeInTheDocument();
const zanderCard = screen.getByRole("region", { name: "Zander" });
expect(within(zanderCard).getByText("ausgeblendet", { selector: "[data-system-chip]" })).toBeInTheDocument();
expect(within(zanderCard).getByText("Keine Vorlage")).toBeInTheDocument();
});
it("describes each part type: kind, size, slots and state fields", async () => {
renderPage();
const card = await screen.findByRole("region", { name: "Dadant" });
const feederRow = within(card).getByRole("row", { name: /Futtertrog XL/ });
expect(feederRow).toHaveTextContent("Futterzarge");
expect(feederRow).toHaveTextContent("500 × 480 × 200 mm");
expect(feederRow).toHaveTextContent("Flüssigfutter 0–3 l, Futterteig 0–2,5 kg");
expect(feederRow).toHaveTextContent("aktiv");
const boxRow = within(card).getByRole("row", { name: /Zarge DNM/ });
expect(boxRow).toHaveTextContent("10");
expect(boxRow).toHaveTextContent("ausgeblendet");
});
it("„Ausblenden“ sends active:false, „Einblenden“ active:true", async () => {
renderPage();
const card = await screen.findByRole("region", { name: "Dadant" });
await userEvent.click(
within(within(card).getByRole("row", { name: /Futtertrog XL/ })).getByRole("button", {
name: /Ausblenden/,
}),
);
await waitFor(() => expect(updateHivePartType).toHaveBeenCalledWith("pt1", { active: false }));
await userEvent.click(
within(within(card).getByRole("row", { name: /Zarge DNM/ })).getByRole("button", {
name: /Einblenden/,
}),
);
await waitFor(() => expect(updateHivePartType).toHaveBeenCalledWith("pt2", { active: true }));
});
it("has no delete action anywhere", async () => {
renderPage();
await screen.findByRole("region", { name: "Dadant" });
expect(screen.queryByRole("button", { name: /löschen|entfernen/i })).not.toBeInTheDocument();
});
it("opens the dialog to add a part and to edit one", async () => {
renderPage();
const card = await screen.findByRole("region", { name: "Dadant" });
await userEvent.click(within(card).getByRole("button", { name: "Teil hinzufügen" }));
expect(await screen.findByRole("heading", { name: "Teil hinzufügen" })).toBeInTheDocument();
await userEvent.keyboard("{Escape}");
await waitFor(() =>
expect(screen.queryByRole("heading", { name: "Teil hinzufügen" })).not.toBeInTheDocument(),
);
await userEvent.click(
within(within(card).getByRole("row", { name: /Futtertrog XL/ })).getByRole("button", {
name: /Bearbeiten/,
}),
);
expect(await screen.findByRole("heading", { name: "Teil bearbeiten" })).toBeInTheDocument();
});
it("opens the system dialog from the card", async () => {
renderPage();
const card = await screen.findByRole("region", { name: "Dadant" });
await userEvent.click(within(card).getByRole("button", { name: "System bearbeiten" }));
expect(await screen.findByRole("heading", { name: "System bearbeiten" })).toBeInTheDocument();
});
it("says so when there is no system yet", async () => {
vi.mocked(fetchHiveSystems).mockResolvedValue([]);
renderPage();
expect(await screen.findByText(/Noch kein Beutensystem/)).toBeInTheDocument();
});
});
@@ -0,0 +1,176 @@
"use client";
import { useState } from "react";
import { Boxes, Pencil, Plus, ShieldOff } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { EmptyState } from "@/components/empty-state";
import { PageHeader } from "@/components/page-header";
import { QueryState } from "@/components/query-state";
import { useAuth } from "@/lib/auth/auth-context";
import { fetchHiveSystems, hiveKeys } from "@/lib/hive/api";
import { updateHivePartType } from "@/lib/hive/catalog-api";
import { serverMessage, templatePartCount } from "@/lib/hive/catalog-form";
import type { HivePartTypeDto, HiveSystemDto } from "@/lib/hive/types";
import { PartTypeDialog } from "./part-type-dialog";
import { ActiveChip, PartTypeTable } from "./part-type-table";
import { SystemDialog } from "./system-dialog";
const MANAGE_PERMISSION = "hive-systems:manage";
interface SystemCardProps {
system: HiveSystemDto;
pendingId: string | null;
onEditSystem: () => void;
onAddPart: () => void;
onEditPart: (partType: HivePartTypeDto) => void;
onToggleActive: (partType: HivePartTypeDto) => void;
}
function SystemCard({
system,
pendingId,
onEditSystem,
onAddPart,
onEditPart,
onToggleActive,
}: SystemCardProps) {
const headingId = `hive-system-${system.id}`;
const templateParts = templatePartCount(system.template);
return (
<section aria-labelledby={headingId}>
<Card>
<CardHeader className="gap-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
<CardTitle>
<h2 id={headingId} className="font-heading text-lg font-semibold">
{system.name}
</h2>
</CardTitle>
<ActiveChip active={system.active} data-system-chip="" />
</div>
<Button type="button" variant="outline" size="touch" onClick={onEditSystem}>
<Pencil aria-hidden />
System bearbeiten
</Button>
</div>
<p className="text-sm text-muted-foreground">
Rähmchen {system.frameWidthMm} × {system.frameHeightMm} mm · Grundfläche{" "}
{system.footprintLMm} × {system.footprintWMm} mm
</p>
<p className="text-sm text-muted-foreground">
{templateParts === null ? "Keine Vorlage" : `Vorlage: ${templateParts} Teile`}
</p>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<PartTypeTable
partTypes={system.partTypes}
pendingId={pendingId}
onEdit={onEditPart}
onToggleActive={onToggleActive}
/>
<Button type="button" size="touch" className="self-start" onClick={onAddPart}>
<Plus aria-hidden />
Teil hinzufügen
</Button>
</CardContent>
</Card>
</section>
);
}
/** The part dialog is open for a new part (no `partType`) or for an existing one. */
interface PartDialogTarget {
systemId: string;
partType?: HivePartTypeDto;
}
function HiveSystemsContent() {
const queryClient = useQueryClient();
const systemsQuery = useQuery({ queryKey: hiveKeys.systems, queryFn: fetchHiveSystems });
const [partTarget, setPartTarget] = useState<PartDialogTarget | null>(null);
const [systemTarget, setSystemTarget] = useState<{ system?: HiveSystemDto } | null>(null);
const [toggleError, setToggleError] = useState<string | null>(null);
const toggle = useMutation({
mutationFn: (partType: HivePartTypeDto) =>
updateHivePartType(partType.id, { active: !partType.active }),
onMutate: () => setToggleError(null),
onSuccess: () => queryClient.invalidateQueries({ queryKey: hiveKeys.systems }),
onError: (error: unknown) =>
setToggleError(serverMessage(error, "Die Sichtbarkeit konnte nicht geändert werden.")),
});
return (
<div className="flex flex-col gap-6">
<PageHeader
icon={Boxes}
title="Beutensysteme"
description="Welche Teile es pro System gibt, wie groß sie sind und was ihr Zustand zeigt."
actions={
<Button type="button" size="touch" onClick={() => setSystemTarget({})}>
<Plus aria-hidden />
System hinzufügen
</Button>
}
/>
{toggleError ? (
<p role="alert" className="text-sm text-destructive">
{toggleError}
</p>
) : null}
<QueryState
query={systemsQuery}
what="Beutensysteme"
empty={
<EmptyState
icon={Boxes}
title="Noch kein Beutensystem"
description="Lege ein System an, damit Völker einen Beutenaufbau bekommen können."
/>
}
>
{(systems) =>
systems.map((system) => (
<SystemCard
key={system.id}
system={system}
pendingId={toggle.isPending ? toggle.variables.id : null}
onEditSystem={() => setSystemTarget({ system })}
onAddPart={() => setPartTarget({ systemId: system.id })}
onEditPart={(partType) => setPartTarget({ systemId: system.id, partType })}
onToggleActive={(partType) => toggle.mutate(partType)}
/>
))
}
</QueryState>
<PartTypeDialog
systemId={partTarget?.systemId ?? ""}
partType={partTarget?.partType}
open={partTarget !== null}
onOpenChange={(open) => (open ? undefined : setPartTarget(null))}
/>
<SystemDialog
system={systemTarget?.system}
open={systemTarget !== null}
onOpenChange={(open) => (open ? undefined : setSystemTarget(null))}
/>
</div>
);
}
export default function HiveSystemsPage() {
const { user } = useAuth();
if (!user?.permissions.includes(MANAGE_PERMISSION)) {
return (
<EmptyState
icon={ShieldOff}
title="Du hast keine Berechtigung für die Beutensysteme."
description="Frag eine Administratorin oder einen Administrator."
/>
);
}
return <HiveSystemsContent />;
}
@@ -0,0 +1,250 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { PartTypeDialog } from "./part-type-dialog";
import { ApiError } from "@/lib/auth/api-client";
import { createHivePartType, updateHivePartType } from "@/lib/hive/catalog-api";
import type { HivePartTypeDto } from "@/lib/hive/types";
vi.mock("@/lib/hive/catalog-api", () => ({
createHivePartType: vi.fn(),
updateHivePartType: vi.fn(),
}));
const feeder: HivePartTypeDto = {
id: "pt1",
systemId: "s1",
kind: "FEEDER",
name: "Futterzarge",
articleRef: "FZ-1",
lengthMm: 500,
widthMm: 480,
heightMm: 200,
frameSlots: null,
boxDepth: null,
stateSchema: [
{
key: "syrup",
label: "Flüssigfutter",
kind: "level",
unit: "l",
max: 3,
step: 0.5,
feedType: "FEED_SYRUP",
},
],
active: true,
createdAt: "2026-10-01T00:00:00.000Z",
updatedAt: "2026-10-01T00:00:00.000Z",
};
function renderDialog(partType?: HivePartTypeDto) {
const onOpenChange = vi.fn();
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const invalidate = vi.spyOn(client, "invalidateQueries");
render(
<QueryClientProvider client={client}>
<PartTypeDialog systemId="s1" partType={partType} open onOpenChange={onOpenChange} />
</QueryClientProvider>,
);
return { onOpenChange, invalidate };
}
const typeInto = async (label: RegExp | string, text: string) => {
const input = screen.getByLabelText(label);
await userEvent.clear(input);
await userEvent.type(input, text);
};
async function pickBox() {
await userEvent.click(screen.getByRole("combobox", { name: /Art des Teils/ }));
await userEvent.click(await screen.findByRole("option", { name: "Zarge" }));
}
describe("PartTypeDialog", () => {
beforeEach(() => {
vi.mocked(createHivePartType).mockReset();
vi.mocked(updateHivePartType).mockReset();
vi.mocked(createHivePartType).mockResolvedValue({} as never);
vi.mocked(updateHivePartType).mockResolvedValue({} as never);
});
it("creates a box with frame slots and refreshes the catalog", async () => {
const { invalidate, onOpenChange } = renderDialog();
expect(screen.getByRole("heading", { name: "Teil hinzufügen" })).toBeInTheDocument();
await typeInto(/^Name/, "Zarge DNM");
await pickBox();
await typeInto(/^Länge/, "500");
await typeInto(/^Breite/, "480");
await typeInto(/^Höhe/, "240");
await typeInto(/^Rähmchenplätze/, "10");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
await waitFor(() =>
expect(createHivePartType).toHaveBeenCalledWith("s1", {
kind: "BOX",
name: "Zarge DNM",
lengthMm: 500,
widthMm: 480,
heightMm: 240,
boxDepth: "FULL",
frameSlots: 10,
}),
);
expect(invalidate).toHaveBeenCalledWith({ queryKey: ["hive-systems"] });
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it("blocks saving without a name and sends nothing", async () => {
renderDialog();
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(await screen.findByRole("alert")).toHaveTextContent(/Name/);
expect(createHivePartType).not.toHaveBeenCalled();
});
it("caps frame slots at the server limit", async () => {
renderDialog();
await typeInto(/^Name/, "Zarge");
await pickBox();
await typeInto(/^Länge/, "500");
await typeInto(/^Breite/, "480");
await typeInto(/^Höhe/, "240");
await typeInto(/^Rähmchenplätze/, "25");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
await waitFor(() =>
expect(createHivePartType).toHaveBeenCalledWith("s1", expect.objectContaining({ frameSlots: 20 })),
);
});
it("requires the frame slots of a box", async () => {
renderDialog();
await typeInto(/^Name/, "Zarge");
await pickBox();
await typeInto(/^Länge/, "500");
await typeInto(/^Breite/, "480");
await typeInto(/^Höhe/, "240");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(await screen.findByRole("alert")).toHaveTextContent(/Rähmchenplätze/);
expect(createHivePartType).not.toHaveBeenCalled();
});
it("edit: sends only the changed dimension as PATCH", async () => {
const { onOpenChange } = renderDialog(feeder);
expect(screen.getByRole("heading", { name: "Teil bearbeiten" })).toBeInTheDocument();
await typeInto(/^Höhe/, "250");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
await waitFor(() => expect(updateHivePartType).toHaveBeenCalledWith("pt1", { heightMm: 250 }));
expect(createHivePartType).not.toHaveBeenCalled();
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it("edit: the kind is shown but cannot be changed", () => {
renderDialog(feeder);
expect(screen.queryByRole("combobox", { name: /Art des Teils/ })).not.toBeInTheDocument();
expect(screen.getByText("Futterzarge", { selector: "dd" })).toBeInTheDocument();
});
it("existing state fields are read-only and cannot be removed", () => {
renderDialog(feeder);
const list = screen.getByRole("list", { name: "Bestehende Zustandsfelder" });
expect(within(list).getByText(/Flüssigfutter/)).toBeInTheDocument();
expect(within(list).queryByRole("button")).not.toBeInTheDocument();
expect(within(list).queryByRole("textbox")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /entfernen|löschen/i })).not.toBeInTheDocument();
});
it("appends a level field after the existing ones", async () => {
renderDialog(feeder);
await userEvent.click(screen.getByRole("button", { name: "Feld hinzufügen" }));
await typeInto(/^Schlüssel/, "paste");
await typeInto(/^Feldname/, "Futterteig");
await typeInto(/^Einheit/, "kg");
await typeInto(/^Höchstwert/, "2,5");
await typeInto(/^Schrittweite/, "0,5");
await userEvent.click(screen.getByRole("combobox", { name: /Futtertyp/ }));
await userEvent.click(await screen.findByRole("option", { name: "Futterteig" }));
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
await waitFor(() =>
expect(updateHivePartType).toHaveBeenCalledWith("pt1", {
stateSchema: [
feeder.stateSchema[0],
{
key: "paste",
label: "Futterteig",
kind: "level",
unit: "kg",
max: 2.5,
step: 0.5,
feedType: "FEED_PASTE",
},
],
}),
);
});
it("appends a choice field with option labels", async () => {
renderDialog(feeder);
await userEvent.click(screen.getByRole("button", { name: "Feld hinzufügen" }));
await userEvent.click(screen.getByRole("combobox", { name: /Art des Feldes/ }));
await userEvent.click(await screen.findByRole("option", { name: "Auswahl" }));
await typeInto(/^Schlüssel/, "lid");
await typeInto(/^Feldname/, "Deckel");
await typeInto("Option 1 Wert", "open");
await typeInto("Option 1 Bezeichnung", "offen");
await userEvent.click(screen.getByRole("button", { name: "Option hinzufügen" }));
await typeInto("Option 2 Wert", "closed");
await typeInto("Option 2 Bezeichnung", "zu");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
await waitFor(() =>
expect(updateHivePartType).toHaveBeenCalledWith("pt1", {
stateSchema: [
feeder.stateSchema[0],
{
key: "lid",
label: "Deckel",
kind: "choice",
options: ["open", "closed"],
optionLabels: ["offen", "zu"],
},
],
}),
);
});
it("refuses a new field whose key already exists", async () => {
renderDialog(feeder);
await userEvent.click(screen.getByRole("button", { name: "Feld hinzufügen" }));
await typeInto(/^Schlüssel/, "syrup");
await typeInto(/^Feldname/, "Nochmal");
await typeInto(/^Höchstwert/, "1");
await typeInto(/^Schrittweite/, "1");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(await screen.findByRole("alert")).toHaveTextContent(/Schlüssel/);
expect(updateHivePartType).not.toHaveBeenCalled();
});
it("a new field can be dropped again before saving", async () => {
renderDialog(feeder);
await userEvent.click(screen.getByRole("button", { name: "Feld hinzufügen" }));
await userEvent.click(screen.getByRole("button", { name: "Neues Feld verwerfen" }));
expect(screen.queryByLabelText(/^Schlüssel/)).not.toBeInTheDocument();
});
it("shows the server 400 message inline", async () => {
vi.mocked(updateHivePartType).mockRejectedValue(
new ApiError(400, "x", {
code: "SCHEMA_INVALID",
messages: ["stateSchema[1].max must be positive"],
}),
);
const { onOpenChange } = renderDialog(feeder);
await typeInto(/^Höhe/, "250");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(await screen.findByRole("alert")).toHaveTextContent("stateSchema[1].max must be positive");
expect(onOpenChange).not.toHaveBeenCalledWith(false);
});
});
@@ -0,0 +1,340 @@
"use client";
import { FormEvent, useState } from "react";
import { Plus } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { EnumSelect } from "@/components/form/enum-select";
import { Field } from "@/components/form/field";
import { NumberStepper } from "@/components/form/number-stepper";
import { hiveKeys } from "@/lib/hive/api";
import {
createHivePartType,
updateHivePartType,
type HivePartTypeInput,
type HivePartTypePatch,
} from "@/lib/hive/catalog-api";
import {
MAX_DIMENSION_MM,
MAX_FRAME_SLOTS,
draftProblem,
emptyDraft,
fieldFromDraft,
serverMessage,
type FieldDraft,
} from "@/lib/hive/catalog-form";
import { PART_KIND_LABELS } from "@/lib/hive/labels";
import type { HiveBoxDepth, HivePartKind, HivePartTypeDto } from "@/lib/hive/types";
import { StateFieldEditor } from "./state-field-editor";
import { StateFieldList } from "./state-field-list";
const BOX_DEPTH_LABELS: Record<HiveBoxDepth, string> = {
FULL: "Volle Zarge",
SHALLOW: "Flachzarge",
};
const MAX_NAME_LENGTH = 120;
const MAX_ARTICLE_REF_LENGTH = 80;
const DIMENSION_STEP_MM = 10;
interface PartTypeFormProps {
systemId: string;
partType: HivePartTypeDto | undefined;
onSaved: () => void;
}
interface FormValues {
name: string;
articleRef: string;
kind: HivePartKind | "";
lengthMm: string;
widthMm: string;
heightMm: string;
boxDepth: HiveBoxDepth;
frameSlots: string;
}
function initialValues(partType: HivePartTypeDto | undefined): FormValues {
return {
name: partType?.name ?? "",
articleRef: partType?.articleRef ?? "",
kind: partType?.kind ?? "",
lengthMm: partType ? String(partType.lengthMm) : "",
widthMm: partType ? String(partType.widthMm) : "",
heightMm: partType ? String(partType.heightMm) : "",
boxDepth: partType?.boxDepth ?? "FULL",
frameSlots: partType?.frameSlots == null ? "" : String(partType.frameSlots),
};
}
function isDimension(text: string): boolean {
const value = Number(text);
return text !== "" && Number.isInteger(value) && value >= 1 && value <= MAX_DIMENSION_MM;
}
function valuesProblem(values: FormValues): string | null {
if (values.name.trim() === "") return "Bitte einen Namen eingeben.";
if (values.kind === "") return "Bitte die Art des Teils wählen.";
if (![values.lengthMm, values.widthMm, values.heightMm].every(isDimension)) {
return `Länge, Breite und Höhe müssen ganze Millimeter zwischen 1 und ${MAX_DIMENSION_MM} sein.`;
}
if (values.kind === "BOX" && values.frameSlots === "") {
return "Eine Zarge braucht die Zahl der Rähmchenplätze.";
}
return null;
}
/** Only what differs from the stored part type, so a PATCH never touches the rest. */
function patchFrom(
values: FormValues,
partType: HivePartTypeDto,
newField: FieldDraft | null,
): HivePartTypePatch {
const patch: HivePartTypePatch = {};
const name = values.name.trim();
if (name !== partType.name) patch.name = name;
const articleRef = values.articleRef.trim();
if (articleRef !== (partType.articleRef ?? "")) patch.articleRef = articleRef === "" ? null : articleRef;
if (Number(values.lengthMm) !== partType.lengthMm) patch.lengthMm = Number(values.lengthMm);
if (Number(values.widthMm) !== partType.widthMm) patch.widthMm = Number(values.widthMm);
if (Number(values.heightMm) !== partType.heightMm) patch.heightMm = Number(values.heightMm);
if (partType.kind === "BOX") {
if (values.boxDepth !== partType.boxDepth) patch.boxDepth = values.boxDepth;
if (Number(values.frameSlots) !== partType.frameSlots) patch.frameSlots = Number(values.frameSlots);
}
if (newField) patch.stateSchema = [...partType.stateSchema, fieldFromDraft(newField)];
return patch;
}
function inputFrom(values: FormValues, newField: FieldDraft | null): HivePartTypeInput {
const kind = values.kind as HivePartKind;
const articleRef = values.articleRef.trim();
return {
kind,
name: values.name.trim(),
...(articleRef === "" ? {} : { articleRef }),
lengthMm: Number(values.lengthMm),
widthMm: Number(values.widthMm),
heightMm: Number(values.heightMm),
...(kind === "BOX" ? { boxDepth: values.boxDepth, frameSlots: Number(values.frameSlots) } : {}),
...(newField ? { stateSchema: [fieldFromDraft(newField)] } : {}),
};
}
function DimensionField({
label,
value,
onChange,
}: {
label: string;
value: string;
onChange: (value: string) => void;
}) {
return (
<Field label={label} required>
{({ id, describedBy }) => (
<NumberStepper
id={id}
describedBy={describedBy}
value={value}
onChange={onChange}
min={1}
max={MAX_DIMENSION_MM}
step={DIMENSION_STEP_MM}
unitLabel="mm"
decrementLabel={`${label} verringern`}
incrementLabel={`${label} erhöhen`}
/>
)}
</Field>
);
}
function PartTypeForm({ systemId, partType, onSaved }: PartTypeFormProps) {
const queryClient = useQueryClient();
const [values, setValues] = useState(() => initialValues(partType));
const [newField, setNewField] = useState<FieldDraft | null>(null);
const [error, setError] = useState<string | null>(null);
const existingSchema = partType?.stateSchema ?? [];
const setValue = <K extends keyof FormValues>(key: K, value: FormValues[K]) =>
setValues((current) => ({ ...current, [key]: value }));
const mutation = useMutation({
mutationFn: async () => {
if (!partType) return createHivePartType(systemId, inputFrom(values, newField));
const patch = patchFrom(values, partType, newField);
// Nothing changed: no request needed.
if (Object.keys(patch).length > 0) await updateHivePartType(partType.id, patch);
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: hiveKeys.systems });
onSaved();
},
onError: (mutationError: unknown) =>
setError(serverMessage(mutationError, "Das Teil konnte nicht gespeichert werden.")),
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const problem =
valuesProblem(values) ??
(newField
? draftProblem(newField, existingSchema.map((field) => field.key))
: null);
setError(problem);
if (problem === null) mutation.mutate();
}
const isBox = values.kind === "BOX";
return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit} noValidate>
<Field label="Name" required>
{({ id, describedBy }) => (
<Input
id={id}
aria-describedby={describedBy}
maxLength={MAX_NAME_LENGTH}
value={values.name}
onChange={(event) => setValue("name", event.target.value)}
/>
)}
</Field>
<Field label="Artikelnummer">
{({ id, describedBy }) => (
<Input
id={id}
aria-describedby={describedBy}
maxLength={MAX_ARTICLE_REF_LENGTH}
value={values.articleRef}
onChange={(event) => setValue("articleRef", event.target.value)}
/>
)}
</Field>
{partType ? (
<dl className="flex flex-col gap-1 text-sm">
<dt className="font-medium">Art des Teils</dt>
<dd>{PART_KIND_LABELS[partType.kind]}</dd>
</dl>
) : (
<Field label="Art des Teils" required hint="Lässt sich später nicht mehr ändern.">
{({ id, describedBy }) => (
<EnumSelect
id={id}
describedBy={describedBy}
labels={PART_KIND_LABELS}
noneLabel="Bitte wählen"
noneIsPlaceholder
value={values.kind}
onChange={(kind) => setValue("kind", kind)}
/>
)}
</Field>
)}
<DimensionField label="Länge" value={values.lengthMm} onChange={(v) => setValue("lengthMm", v)} />
<DimensionField label="Breite" value={values.widthMm} onChange={(v) => setValue("widthMm", v)} />
<DimensionField label="Höhe" value={values.heightMm} onChange={(v) => setValue("heightMm", v)} />
{isBox ? (
<>
<Field label="Bauart der Zarge" required>
{({ id, describedBy }) => (
<EnumSelect
id={id}
describedBy={describedBy}
labels={BOX_DEPTH_LABELS}
value={values.boxDepth}
onChange={(boxDepth) => setValue("boxDepth", boxDepth)}
/>
)}
</Field>
<Field label="Rähmchenplätze" required>
{({ id, describedBy }) => (
<NumberStepper
id={id}
describedBy={describedBy}
value={values.frameSlots}
onChange={(frameSlots) => setValue("frameSlots", frameSlots)}
max={MAX_FRAME_SLOTS}
unitLabel="Plätze"
decrementLabel="Rähmchenplätze verringern"
incrementLabel="Rähmchenplätze erhöhen"
/>
)}
</Field>
</>
) : null}
<section className="flex flex-col gap-3" aria-labelledby="state-fields-heading">
<h3 id="state-fields-heading" className="text-sm font-medium">
Zustandsfelder
</h3>
<StateFieldList fields={existingSchema} />
{newField ? (
<StateFieldEditor
draft={newField}
onChange={setNewField}
onDiscard={() => setNewField(null)}
allowFeedType={values.kind === "FEEDER"}
/>
) : (
<Button
type="button"
variant="outline"
size="touch"
className="self-start"
onClick={() => setNewField(emptyDraft())}
>
<Plus aria-hidden />
Feld hinzufügen
</Button>
)}
</section>
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
) : null}
<DialogFooter>
<Button type="submit" size="touch" disabled={mutation.isPending}>
{mutation.isPending ? "Speichert…" : "Speichern"}
</Button>
</DialogFooter>
</form>
);
}
interface PartTypeDialogProps {
systemId: string;
/** Without a part type the dialog adds a new one. */
partType?: HivePartTypeDto;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function PartTypeDialog({ systemId, partType, open, onOpenChange }: PartTypeDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{partType ? "Teil bearbeiten" : "Teil hinzufügen"}</DialogTitle>
</DialogHeader>
{/* Mounted only while open, so every opening starts from the stored values. */}
{open ? (
<PartTypeForm
key={partType?.id ?? "new"}
systemId={systemId}
partType={partType}
onSaved={() => onOpenChange(false)}
/>
) : null}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,99 @@
"use client";
import { Eye, EyeOff, Pencil } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { describeStateFields } from "@/lib/hive/catalog-form";
import { PART_KIND_LABELS } from "@/lib/hive/labels";
import type { HivePartTypeDto } from "@/lib/hive/types";
/** Active or hidden in words plus an icon - never colour alone. */
interface ActiveChipProps {
active: boolean;
/** Marker for the chip of a whole system (tests tell it from the part type chips). */
"data-system-chip"?: string;
}
export function ActiveChip({ active, ...rest }: ActiveChipProps) {
const Icon = active ? Eye : EyeOff;
return (
<Badge variant={active ? "secondary" : "outline"} {...rest}>
<Icon aria-hidden />
{active ? "aktiv" : "ausgeblendet"}
</Badge>
);
}
interface PartTypeTableProps {
partTypes: readonly HivePartTypeDto[];
/** The part type whose visibility is being changed right now. */
pendingId: string | null;
onEdit: (partType: HivePartTypeDto) => void;
onToggleActive: (partType: HivePartTypeDto) => void;
}
export function PartTypeTable({ partTypes, pendingId, onEdit, onToggleActive }: PartTypeTableProps) {
if (partTypes.length === 0) {
return <p className="text-sm text-muted-foreground">Noch keine Teile in diesem System.</p>;
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Art</TableHead>
<TableHead>Maße</TableHead>
<TableHead>Plätze</TableHead>
<TableHead>Zustandsfelder</TableHead>
<TableHead>Status</TableHead>
<TableHead>Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{partTypes.map((partType) => (
<TableRow key={partType.id} className={partType.active ? undefined : "text-muted-foreground"}>
<TableCell className="font-medium">{partType.name}</TableCell>
<TableCell>{PART_KIND_LABELS[partType.kind]}</TableCell>
<TableCell className="whitespace-nowrap">
{partType.lengthMm} × {partType.widthMm} × {partType.heightMm} mm
</TableCell>
<TableCell>{partType.frameSlots ?? "–"}</TableCell>
<TableCell className="min-w-48 whitespace-normal">
{describeStateFields(partType.stateSchema)}
</TableCell>
<TableCell>
<ActiveChip active={partType.active} />
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" size="touch" onClick={() => onEdit(partType)}>
<Pencil aria-hidden />
Bearbeiten<span className="sr-only"> {partType.name}</span>
</Button>
<Button
type="button"
variant="outline"
size="touch"
disabled={pendingId === partType.id}
onClick={() => onToggleActive(partType)}
>
{partType.active ? <EyeOff aria-hidden /> : <Eye aria-hidden />}
{partType.active ? "Ausblenden" : "Einblenden"}
<span className="sr-only"> {partType.name}</span>
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
@@ -0,0 +1,193 @@
"use client";
import { Plus, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { EnumSelect } from "@/components/form/enum-select";
import { Field } from "@/components/form/field";
import { NumberStepper } from "@/components/form/number-stepper";
import { FEED_TYPES, FEED_TYPE_LABELS } from "@/lib/feedings/labels";
import {
MAX_FIELD_LABEL_LENGTH,
MAX_KEY_LENGTH,
MAX_OPTION_LENGTH,
MAX_UNIT_LENGTH,
type FieldDraft,
} from "@/lib/hive/catalog-form";
const FIELD_KIND_LABELS = { level: "Füllstand (Zahl)", choice: "Auswahl" } as const;
interface StateFieldEditorProps {
draft: FieldDraft;
onChange: (draft: FieldDraft) => void;
onDiscard: () => void;
/** Only feeders may tie a level to a feed type. */
allowFeedType: boolean;
}
function LevelInputs({ draft, onChange, allowFeedType }: Omit<StateFieldEditorProps, "onDiscard">) {
return (
<>
<Field label="Einheit" hint="Zum Beispiel l oder kg.">
{({ id, describedBy }) => (
<Input
id={id}
aria-describedby={describedBy}
maxLength={MAX_UNIT_LENGTH}
value={draft.unit}
onChange={(event) => onChange({ ...draft, unit: event.target.value })}
/>
)}
</Field>
<Field label="Höchstwert" required>
{({ id, describedBy }) => (
<NumberStepper
id={id}
describedBy={describedBy}
value={draft.max}
onChange={(max) => onChange({ ...draft, max })}
decimals={2}
step={0.5}
unitLabel={draft.unit || "Einheiten"}
decrementLabel="Höchstwert verringern"
incrementLabel="Höchstwert erhöhen"
/>
)}
</Field>
<Field label="Schrittweite" required>
{({ id, describedBy }) => (
<NumberStepper
id={id}
describedBy={describedBy}
value={draft.step}
onChange={(step) => onChange({ ...draft, step })}
decimals={2}
step={0.25}
unitLabel={draft.unit || "Einheiten"}
decrementLabel="Schrittweite verringern"
incrementLabel="Schrittweite erhöhen"
/>
)}
</Field>
{allowFeedType ? (
<Field label="Futtertyp" hint="Damit schlägt die App eine Fütterung vor.">
{({ id, describedBy }) => (
<EnumSelect
id={id}
describedBy={describedBy}
labels={FEED_TYPE_LABELS}
values={FEED_TYPES}
noneLabel="Kein Futtertyp"
value={draft.feedType}
onChange={(feedType) => onChange({ ...draft, feedType })}
/>
)}
</Field>
) : null}
</>
);
}
function OptionRows({ draft, onChange }: Pick<StateFieldEditorProps, "draft" | "onChange">) {
const setOption = (index: number, patch: Partial<FieldDraft["options"][number]>) =>
onChange({
...draft,
options: draft.options.map((option, i) => (i === index ? { ...option, ...patch } : option)),
});
return (
<div className="flex flex-col gap-2">
{draft.options.map((option, index) => (
<div key={index} className="flex items-end gap-2">
<Input
aria-label={`Option ${index + 1} Wert`}
placeholder="Wert"
maxLength={MAX_OPTION_LENGTH}
value={option.value}
onChange={(event) => setOption(index, { value: event.target.value })}
/>
<Input
aria-label={`Option ${index + 1} Bezeichnung`}
placeholder="Bezeichnung"
maxLength={MAX_OPTION_LENGTH}
value={option.label}
onChange={(event) => setOption(index, { label: event.target.value })}
/>
{draft.options.length > 1 ? (
<Button
type="button"
variant="ghost"
size="touch"
aria-label={`Option ${index + 1} entfernen`}
onClick={() =>
onChange({ ...draft, options: draft.options.filter((_, i) => i !== index) })
}
>
<X aria-hidden />
</Button>
) : null}
</div>
))}
<Button
type="button"
variant="outline"
size="touch"
className="self-start"
onClick={() => onChange({ ...draft, options: [...draft.options, { value: "", label: "" }] })}
>
<Plus aria-hidden />
Option hinzufügen
</Button>
</div>
);
}
/** Inputs of the one new state field; it is appended to the schema on save. */
export function StateFieldEditor({ draft, onChange, onDiscard, allowFeedType }: StateFieldEditorProps) {
return (
<fieldset className="flex flex-col gap-3 rounded-xl border border-border p-3">
<legend className="px-1 text-sm font-medium">Neues Zustandsfeld</legend>
<Field label="Art des Feldes" required>
{({ id }) => (
<EnumSelect
id={id}
labels={FIELD_KIND_LABELS}
value={draft.kind}
onChange={(kind) => onChange({ ...draft, kind })}
/>
)}
</Field>
<Field label="Schlüssel" required hint="Technischer Name, später nicht mehr änderbar.">
{({ id, describedBy }) => (
<Input
id={id}
aria-describedby={describedBy}
maxLength={MAX_KEY_LENGTH}
value={draft.key}
onChange={(event) => onChange({ ...draft, key: event.target.value })}
/>
)}
</Field>
<Field label="Feldname" required>
{({ id, describedBy }) => (
<Input
id={id}
aria-describedby={describedBy}
maxLength={MAX_FIELD_LABEL_LENGTH}
value={draft.label}
onChange={(event) => onChange({ ...draft, label: event.target.value })}
/>
)}
</Field>
{draft.kind === "level" ? (
<LevelInputs draft={draft} onChange={onChange} allowFeedType={allowFeedType} />
) : (
<OptionRows draft={draft} onChange={onChange} />
)}
<Button type="button" variant="ghost" size="touch" className="self-start" onClick={onDiscard}>
<X aria-hidden />
Neues Feld verwerfen
</Button>
</fieldset>
);
}
@@ -0,0 +1,29 @@
import type { StateField } from "@/lib/hive/types";
function summary(field: StateField): string {
if (field.kind === "choice") return `Auswahl: ${field.optionLabels.join(" / ")}`;
const unit = field.unit === "" ? "" : ` ${field.unit}`;
return `Füllstand 0–${String(field.max).replace(".", ",")}${unit}`;
}
/**
* Existing state fields, read only: the schema may only grow (the server
* answers SCHEMA_NOT_ADDITIVE otherwise), so there is nothing to edit or remove.
*/
export function StateFieldList({ fields }: { fields: readonly StateField[] }) {
if (fields.length === 0) {
return <p className="text-sm text-muted-foreground">Noch keine Zustandsfelder.</p>;
}
return (
<ul aria-label="Bestehende Zustandsfelder" className="flex flex-col gap-1 text-sm">
{fields.map((field) => (
<li key={field.key} className="rounded-lg bg-muted/50 px-3 py-2">
<span className="font-medium">{field.label}</span>{" "}
<span className="text-muted-foreground">
({field.key}) · {summary(field)}
</span>
</li>
))}
</ul>
);
}
@@ -0,0 +1,82 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { SystemDialog } from "./system-dialog";
import { createHiveSystem, updateHiveSystem } from "@/lib/hive/catalog-api";
import type { HiveSystemDto } from "@/lib/hive/types";
vi.mock("@/lib/hive/catalog-api", () => ({
createHiveSystem: vi.fn(),
updateHiveSystem: vi.fn(),
}));
const system: HiveSystemDto = {
id: "s1",
name: "Dadant",
frameWidthMm: 435,
frameHeightMm: 300,
footprintLMm: 500,
footprintWMm: 480,
template: null,
active: true,
partTypes: [],
createdAt: "2026-10-01T00:00:00.000Z",
updatedAt: "2026-10-01T00:00:00.000Z",
};
function renderDialog(value?: HiveSystemDto) {
const onOpenChange = vi.fn();
const client = new QueryClient();
const invalidate = vi.spyOn(client, "invalidateQueries");
render(
<QueryClientProvider client={client}>
<SystemDialog system={value} open onOpenChange={onOpenChange} />
</QueryClientProvider>,
);
return { onOpenChange, invalidate };
}
const typeInto = async (label: RegExp, text: string) => {
const input = screen.getByLabelText(label);
await userEvent.clear(input);
await userEvent.type(input, text);
};
describe("SystemDialog", () => {
beforeEach(() => {
vi.mocked(createHiveSystem).mockReset().mockResolvedValue({} as never);
vi.mocked(updateHiveSystem).mockReset().mockResolvedValue({} as never);
});
it("edit: sends the changed footprint as PATCH and refreshes the catalog", async () => {
const { invalidate, onOpenChange } = renderDialog(system);
await typeInto(/^Grundfläche Länge/, "520");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
await waitFor(() => expect(updateHiveSystem).toHaveBeenCalledWith("s1", { footprintLMm: 520 }));
expect(invalidate).toHaveBeenCalledWith({ queryKey: ["hive-systems"] });
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it("create: needs a name and sends all four dimensions", async () => {
renderDialog();
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(await screen.findByRole("alert")).toHaveTextContent(/Name/);
await typeInto(/^Name/, "Zander");
await typeInto(/^Rähmchen Breite/, "420");
await typeInto(/^Rähmchen Höhe/, "220");
await typeInto(/^Grundfläche Länge/, "450");
await typeInto(/^Grundfläche Breite/, "430");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
await waitFor(() =>
expect(createHiveSystem).toHaveBeenCalledWith({
name: "Zander",
frameWidthMm: 420,
frameHeightMm: 220,
footprintLMm: 450,
footprintWMm: 430,
}),
);
});
});
@@ -0,0 +1,193 @@
"use client";
import { FormEvent, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Field } from "@/components/form/field";
import { NumberStepper } from "@/components/form/number-stepper";
import { hiveKeys } from "@/lib/hive/api";
import {
createHiveSystem,
updateHiveSystem,
type HiveSystemInput,
type HiveSystemPatch,
} from "@/lib/hive/catalog-api";
import { MAX_DIMENSION_MM, serverMessage } from "@/lib/hive/catalog-form";
import type { HiveSystemDto } from "@/lib/hive/types";
const MAX_NAME_LENGTH = 120;
const DIMENSION_STEP_MM = 5;
type DimensionKey = "frameWidthMm" | "frameHeightMm" | "footprintLMm" | "footprintWMm";
const DIMENSIONS: { key: DimensionKey; label: string }[] = [
{ key: "frameWidthMm", label: "Rähmchen Breite" },
{ key: "frameHeightMm", label: "Rähmchen Höhe" },
{ key: "footprintLMm", label: "Grundfläche Länge" },
{ key: "footprintWMm", label: "Grundfläche Breite" },
];
interface FormValues {
name: string;
active: boolean;
dimensions: Record<DimensionKey, string>;
}
function initialValues(system: HiveSystemDto | undefined): FormValues {
const text = (key: DimensionKey) => (system ? String(system[key]) : "");
return {
name: system?.name ?? "",
active: system?.active ?? true,
dimensions: {
frameWidthMm: text("frameWidthMm"),
frameHeightMm: text("frameHeightMm"),
footprintLMm: text("footprintLMm"),
footprintWMm: text("footprintWMm"),
},
};
}
function problemOf(values: FormValues): string | null {
if (values.name.trim() === "") return "Bitte einen Namen eingeben.";
const valid = DIMENSIONS.every(({ key }) => {
const value = Number(values.dimensions[key]);
return values.dimensions[key] !== "" && Number.isInteger(value) && value >= 1 && value <= MAX_DIMENSION_MM;
});
return valid ? null : `Alle Maße müssen ganze Millimeter zwischen 1 und ${MAX_DIMENSION_MM} sein.`;
}
function inputFrom(values: FormValues): HiveSystemInput {
return {
name: values.name.trim(),
frameWidthMm: Number(values.dimensions.frameWidthMm),
frameHeightMm: Number(values.dimensions.frameHeightMm),
footprintLMm: Number(values.dimensions.footprintLMm),
footprintWMm: Number(values.dimensions.footprintWMm),
};
}
/** Only what differs from the stored system. */
function patchFrom(values: FormValues, system: HiveSystemDto): HiveSystemPatch {
const input = inputFrom(values);
const patch: Record<string, unknown> = {};
for (const [key, value] of Object.entries(input)) {
if (value !== system[key as keyof HiveSystemInput]) patch[key] = value;
}
if (values.active !== system.active) patch.active = values.active;
return patch as HiveSystemPatch;
}
function SystemForm({ system, onSaved }: { system: HiveSystemDto | undefined; onSaved: () => void }) {
const queryClient = useQueryClient();
const [values, setValues] = useState(() => initialValues(system));
const [error, setError] = useState<string | null>(null);
const mutation = useMutation({
mutationFn: async () => {
if (!system) return createHiveSystem(inputFrom(values));
const patch = patchFrom(values, system);
// Nothing changed: no request needed.
if (Object.keys(patch).length > 0) await updateHiveSystem(system.id, patch);
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: hiveKeys.systems });
onSaved();
},
onError: (mutationError: unknown) =>
setError(serverMessage(mutationError, "Das System konnte nicht gespeichert werden.")),
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const problem = problemOf(values);
setError(problem);
if (problem === null) mutation.mutate();
}
return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit} noValidate>
<Field label="Name" required>
{({ id, describedBy }) => (
<Input
id={id}
aria-describedby={describedBy}
maxLength={MAX_NAME_LENGTH}
value={values.name}
onChange={(event) => setValues({ ...values, name: event.target.value })}
/>
)}
</Field>
{DIMENSIONS.map(({ key, label }) => (
<Field key={key} label={label} required>
{({ id, describedBy }) => (
<NumberStepper
id={id}
describedBy={describedBy}
value={values.dimensions[key]}
onChange={(value) =>
setValues({ ...values, dimensions: { ...values.dimensions, [key]: value } })
}
min={1}
max={MAX_DIMENSION_MM}
step={DIMENSION_STEP_MM}
unitLabel="mm"
decrementLabel={`${label} verringern`}
incrementLabel={`${label} erhöhen`}
/>
)}
</Field>
))}
{system ? (
<label className="flex min-h-11 items-center gap-3 text-sm" htmlFor="hive-system-active">
<input
id="hive-system-active"
type="checkbox"
className="size-4.5"
checked={values.active}
onChange={(event) => setValues({ ...values, active: event.target.checked })}
/>
Aktiv – ausgeblendete Systeme stehen bei neuen Beuten nicht zur Wahl
</label>
) : null}
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
) : null}
<DialogFooter>
<Button type="submit" size="touch" disabled={mutation.isPending}>
{mutation.isPending ? "Speichert…" : "Speichern"}
</Button>
</DialogFooter>
</form>
);
}
interface SystemDialogProps {
/** Without a system the dialog creates a new one. */
system?: HiveSystemDto;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function SystemDialog({ system, open, onOpenChange }: SystemDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{system ? "System bearbeiten" : "System hinzufügen"}</DialogTitle>
</DialogHeader>
{/* Mounted only while open, so every opening starts from the stored values. */}
{open ? <SystemForm system={system} onSaved={() => onOpenChange(false)} /> : null}
</DialogContent>
</Dialog>
);
}
@@ -43,6 +43,18 @@ describe("SettingsLayout", () => {
expect(screen.getByText("Einstellungen-Inhalt")).toBeInTheDocument();
});
it("lets a user with only hive-systems:manage into the settings area", () => {
mockUser = { permissions: ["hive-systems:manage"] };
render(
<SettingsLayout>
<p>Einstellungen-Inhalt</p>
</SettingsLayout>,
);
expect(screen.getByText("Einstellungen-Inhalt")).toBeInTheDocument();
});
it("redirects to the dashboard when the user lacks every settings-area permission", () => {
mockUser = { permissions: ["colonies:read"] };
@@ -19,6 +19,7 @@ const SETTINGS_AREA_PERMISSIONS = [
"backup:configure",
"calendar:configure",
"api-keys:manage",
"hive-systems:manage",
"system:read",
];
@@ -91,7 +91,7 @@ export default function NewTreatmentPage() {
const [values, setValues] = useState<TreatmentFormValues>(() =>
initialTreatmentValues(searchParams.get("colonyId") ?? ""),
initialTreatmentValues(searchParams.get("colonyId") ?? "", searchParams),
);
const [dirty, setDirty] = useState(false);
const [medicineNote, setMedicineNote] = useState<string | null>(null);
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { initialTreatmentValues } from "./treatment-form-values";
const params = (query: string) => new URLSearchParams(query);
describe("initialTreatmentValues prefill", () => {
it("keeps the defaults without search params", () => {
expect(initialTreatmentValues("c1", params("colonyId=c1"))).toMatchObject({
colonyId: "c1",
amount: "",
});
});
it("reads date and a millilitre amount", () => {
const values = initialTreatmentValues("c1", params("date=2026-10-12&amount=250&unit=ml"));
expect(values).toMatchObject({ date: "2026-10-12", amount: "250 ml" });
});
it("needs the unit ml for the amount", () => {
expect(initialTreatmentValues("c1", params("amount=250")).amount).toBe("");
expect(initialTreatmentValues("c1", params("amount=250&unit=l")).amount).toBe("");
});
it.each([["date=2026-13-01"], ["date=x"], ["amount=-5&unit=ml"], ["amount=abc&unit=ml"]])(
"ignores the invalid value in %s",
(query) => {
const defaults = initialTreatmentValues("c1");
const values = initialTreatmentValues("c1", params(query));
expect(values.date).toBe(defaults.date);
expect(values.amount).toBe("");
},
);
});
@@ -3,6 +3,7 @@ import type { BulkCreateTreatmentInput } from "@/lib/bulk/api";
import type { CaptureTarget } from "@/components/form/capture-target-toggle";
import { toApiDate, todayLocalIsoDate } from "@/lib/dates";
import { emptyToNull } from "@/lib/forms/empty-to-null";
import { prefillAmount, prefillDay, type ParamSource } from "@/lib/forms/prefill-params";
import { parsedDose } from "@/lib/treatments/dose";
/** Everything the form holds, as plain values that survive a draft save. */
@@ -34,13 +35,18 @@ export interface TreatmentFormValues {
prescribingVet: string;
}
export function initialTreatmentValues(colonyId: string): TreatmentFormValues {
/**
* Start values. Search params `date`, `amount` and `unit=ml` (the hive card's
* „Als Behandlung eintragen“ link) prefill the fields; invalid ones are ignored.
*/
export function initialTreatmentValues(colonyId: string, params?: ParamSource): TreatmentFormValues {
const amount = params?.get("unit") === "ml" ? prefillAmount(params.get("amount")) : null;
return {
colonyId,
target: "single",
bulkApiaryId: null,
bulkColonyIds: [],
date: todayLocalIsoDate(),
date: (params && prefillDay(params.get("date"))) ?? todayLocalIsoDate(),
startDate: "",
endDate: "",
product: "",
@@ -48,7 +54,7 @@ export function initialTreatmentValues(colonyId: string): TreatmentFormValues {
medicineLotId: "",
doseQuantity: "",
activeIngredient: "",
amount: "",
amount: amount ? `${amount} ml` : "",
method: "",
reason: "",
batchNumber: "",
+20
View File
@@ -41,6 +41,12 @@
--color-condition-weak-foreground: var(--condition-weak-foreground);
--color-condition-critical: var(--condition-critical);
--color-condition-critical-foreground: var(--condition-critical-foreground);
--color-holo-bg: var(--holo-bg);
--color-holo-edge: var(--holo-edge);
--color-holo-edge-strong: var(--holo-edge-strong);
--color-holo-fill: var(--holo-fill);
--color-holo-label-bg: var(--holo-label-bg);
--color-holo-label-fg: var(--holo-label-fg);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
@@ -126,6 +132,13 @@
--gradient-sky: linear-gradient(135deg, oklch(0.68 0.1 235) 0%, oklch(0.5 0.1 255) 100%);
--shadow-elevated: 0 1px 2px -1px oklch(0.3 0.05 50 / 8%), 0 8px 24px -8px oklch(0.3 0.05 50 / 16%);
--shadow-soft: 0 1px 2px -1px oklch(0.3 0.05 50 / 6%), 0 4px 12px -4px oklch(0.3 0.05 50 / 10%);
/* Hive "screen": always dark, in light and dark mode (spec 9.3). */
--holo-bg: oklch(0.18 0.03 250);
--holo-edge: oklch(0.78 0.14 220);
--holo-edge-strong: oklch(0.88 0.12 215);
--holo-fill: oklch(0.78 0.14 220 / 10%);
--holo-label-bg: oklch(0.26 0.04 250);
--holo-label-fg: oklch(0.95 0.02 230);
}
.dark {
@@ -179,6 +192,13 @@
--gradient-sky: linear-gradient(135deg, oklch(0.55 0.09 235) 0%, oklch(0.34 0.09 255) 100%);
--shadow-elevated: 0 1px 2px -1px oklch(0 0 0 / 30%), 0 8px 24px -8px oklch(0 0 0 / 45%);
--shadow-soft: 0 1px 2px -1px oklch(0 0 0 / 25%), 0 4px 12px -4px oklch(0 0 0 / 30%);
/* Hive "screen": always dark, in light and dark mode (spec 9.3). */
--holo-bg: oklch(0.18 0.03 250);
--holo-edge: oklch(0.78 0.14 220);
--holo-edge-strong: oklch(0.88 0.12 215);
--holo-fill: oklch(0.78 0.14 220 / 10%);
--holo-label-bg: oklch(0.26 0.04 250);
--holo-label-fg: oklch(0.95 0.02 230);
}
/* ---------------------------------------------------------------------------
@@ -1,17 +1,37 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { useState } from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { NumberStepper } from "./number-stepper";
function Harness({ initial = "", max }: { initial?: string; max?: number }) {
function Harness({
initial = "",
max,
min,
decimals,
step,
onEmit,
}: {
initial?: string;
max?: number;
min?: number;
decimals?: number;
step?: number;
onEmit?: (value: string) => void;
}) {
const [value, setValue] = useState(initial);
return (
<NumberStepper
id="mites"
value={value}
onChange={setValue}
onChange={(next) => {
setValue(next);
onEmit?.(next);
}}
max={max}
min={min}
decimals={decimals}
step={step}
unitLabel="Milben"
decrementLabel="Eine Milbe weniger"
incrementLabel="Eine Milbe mehr"
@@ -152,4 +172,148 @@ describe("NumberStepper", () => {
expect(screen.getByRole("textbox")).toHaveValue("45");
});
describe("with decimals", () => {
const more = () => screen.getByRole("button", { name: /Eine Milbe mehr/ });
const less = () => screen.getByRole("button", { name: /Eine Milbe weniger/ });
it("typing „2,5“ emits the dot-decimal 2.5 and shows a comma", async () => {
const onEmit = vi.fn();
render(<Harness decimals={1} step={0.5} onEmit={onEmit} />);
await userEvent.type(screen.getByRole("textbox"), "2,5");
expect(onEmit).toHaveBeenLastCalledWith("2.5");
expect(screen.getByRole("textbox")).toHaveValue("2,5");
});
it("accepts a dot as separator, but only one", async () => {
render(<Harness decimals={1} step={0.5} />);
await userEvent.type(screen.getByRole("textbox"), "2.,5");
expect(screen.getByRole("textbox")).toHaveValue("2,5");
});
it("limits the digits after the separator to decimals", async () => {
render(<Harness decimals={1} step={0.5} />);
await userEvent.type(screen.getByRole("textbox"), "2,55");
expect(screen.getByRole("textbox")).toHaveValue("2,5");
});
it("„+“ from „2,5“ shows „3“ (no trailing zero)", async () => {
render(<Harness decimals={1} step={0.5} initial="2.5" />);
await userEvent.click(more());
expect(screen.getByRole("textbox")).toHaveValue("3");
});
it("„−“ steps down on the grid", async () => {
render(<Harness decimals={1} step={0.5} initial="3" />);
await userEvent.click(less());
expect(screen.getByRole("textbox")).toHaveValue("2,5");
});
it("„+“ from empty starts at min + step and clamps at max", async () => {
render(<Harness decimals={1} step={0.5} min={1} max={2} />);
await userEvent.click(more());
expect(screen.getByRole("textbox")).toHaveValue("1,5");
await userEvent.click(more());
await userEvent.click(more());
expect(screen.getByRole("textbox")).toHaveValue("2");
});
it("has no float drift over repeated 0.1 steps", async () => {
render(<Harness decimals={1} step={0.1} initial="0" />);
for (let i = 0; i < 3; i += 1) await userEvent.click(more());
expect(screen.getByRole("textbox")).toHaveValue("0,3");
});
it("shows „Nur Schritte von 0,5.“ for an off-grid value, wired to the field", () => {
render(<Harness decimals={1} step={0.5} initial="2.25" />);
const input = screen.getByRole("textbox");
expect(screen.getByText("Nur Schritte von 0,5.")).toBeInTheDocument();
expect(input).toHaveAttribute("aria-invalid", "true");
expect(input).toHaveAccessibleDescription(/Nur Schritte von 0,5\./);
});
it("shows the error while typing off the grid and removes it on a grid value", async () => {
render(<Harness decimals={2} step={0.5} />);
const input = screen.getByRole("textbox");
await userEvent.type(input, "2,25");
expect(screen.getByText("Nur Schritte von 0,5.")).toBeInTheDocument();
await userEvent.clear(input);
await userEvent.type(input, "2,5");
expect(screen.queryByText(/Nur Schritte/)).not.toBeInTheDocument();
});
it("tolerates float noise when checking the grid", () => {
render(<Harness decimals={2} step={0.1} initial="0.3" />);
expect(screen.queryByText(/Nur Schritte/)).not.toBeInTheDocument();
});
it("announces the grid error in a polite live region that is always present", () => {
render(<Harness decimals={1} step={0.5} initial="2.5" />);
const live = screen.getByRole("status");
expect(live).toBeEmptyDOMElement();
expect(screen.getByRole("textbox")).toHaveAccessibleDescription(/Milben/);
});
it("fills the same status element when an off-grid value is typed", async () => {
render(<Harness decimals={2} step={0.5} />);
const before = screen.getByRole("status");
expect(before).toBeEmptyDOMElement();
expect(before).not.toHaveClass("empty:hidden");
await userEvent.type(screen.getByRole("textbox"), "2,25");
const after = screen.getByRole("status");
expect(after).toBe(before);
expect(after).toHaveTextContent("Nur Schritte von 0,5.");
});
it("writes the step with its own precision in the error", () => {
render(<Harness decimals={1} step={0.25} initial="2.1" />);
expect(screen.getByText("Nur Schritte von 0,25.")).toBeInTheDocument();
});
it("emits a leading zero for „,5“", async () => {
const onEmit = vi.fn();
render(<Harness decimals={1} step={0.5} onEmit={onEmit} />);
await userEvent.type(screen.getByRole("textbox"), ",5");
expect(onEmit).toHaveBeenLastCalledWith("0.5");
});
it("displays a caller value with surplus zeros normalised", () => {
render(<Harness decimals={1} step={0.5} initial="2.5000" />);
expect(screen.getByRole("textbox")).toHaveValue("2,5");
});
it("emits an empty string for empty input", async () => {
const onEmit = vi.fn();
render(<Harness decimals={1} step={0.5} initial="2.5" onEmit={onEmit} />);
await userEvent.clear(screen.getByRole("textbox"));
expect(onEmit).toHaveBeenLastCalledWith("");
});
});
});
@@ -12,6 +12,12 @@ export interface NumberStepperProps {
/** Highest count; typing more or tapping „+“ stops here. Default: 4 digits. */
max?: number;
step?: number;
/**
* Digits after the separator (default 0 = whole counts). With more, the field
* takes "," or ".", shows a comma, emits a dot-decimal string ("2.5") and
* keeps values on the `step` grid starting at `min`.
*/
decimals?: number;
/** Visible unit next to the field, e.g. "Milben"; the name comes from the Field label. */
unitLabel: string;
decrementLabel: string;
@@ -26,14 +32,59 @@ const STEP_BUTTON_CLASS =
const MAX_DIGITS = 4;
const MAX_VALUE = 10 ** MAX_DIGITS - 1;
const GRID_TOLERANCE = 1e-9;
function digitsOnly(text: string): string {
return text.replace(/\D/g, "");
}
/** Digits plus at most one separator, at most `decimals` digits after it; dot-decimal out. */
function sanitizeDecimal(text: string, decimals: number): string {
const [whole = "", ...rest] = text.replace(/[^\d.,]/g, "").split(/[.,]/);
const fraction = rest.join("").slice(0, decimals);
return rest.length > 0 ? `${whole === "" ? "0" : whole}.${fraction}` : whole;
}
/** At most `decimals` digits, no trailing zeros ("3", not "3.0"). */
function formatNumber(value: number, decimals: number): string {
return String(Number(value.toFixed(decimals)));
}
/** Caller value for display: surplus fraction digits ("2.5000") are cut, typing in progress ("2.0") is kept. */
function displayValue(value: string, decimals: number): string {
const fractionLength = value.split(".")[1]?.length ?? 0;
const number = Number(value);
const shown =
fractionLength > decimals && !Number.isNaN(number) ? formatNumber(number, decimals) : value;
return toComma(shown);
}
/** The step with its own precision, comma-separated (0.25 -> "0,25"). */
function stepLabel(step: number): string {
return toComma(String(step));
}
function toComma(text: string): string {
return text.replace(".", ",");
}
/** Position on the step grid (min + n * step). */
function gridPosition(value: number, min: number, step: number): number {
return (value - min) / step;
}
function isOnGrid(value: number, min: number, step: number): boolean {
const position = gridPosition(value, min, step);
return Math.abs(position - Math.round(position)) < GRID_TOLERANCE;
}
/**
* A count with big plus and minus buttons for gloved fingers; the field stays
* typable. Whole counts only: digits only (no negatives or decimals), at most
* 4 digits. Empty means "not counted": "+" then starts at min + step, "−" is
* typable. By default whole counts only: digits only, at most 4 digits. With
* `decimals` > 0 the field also takes one "," or "." (shown as a comma, emitted
* as a dot-decimal string) and a value off the `step` grid shows an inline
* error (the value is still emitted). Negatives cannot be typed in either mode
* (the "-" is dropped), so `min` below 0 is only reachable through the buttons. Empty means "not counted": "+" then starts at min + step, "−" is
* off, and "leeren" goes back to empty (a 0 would read as "counted: none").
*/
export function NumberStepper({
@@ -43,6 +94,7 @@ export function NumberStepper({
min = 0,
max: maxProp,
step = 1,
decimals = 0,
unitLabel,
decrementLabel,
incrementLabel,
@@ -52,8 +104,29 @@ export function NumberStepper({
const max = maxProp ?? MAX_VALUE;
const [limitHit, setLimitHit] = useState(false);
const unitId = `${id}-unit`;
const current = value === "" ? null : Number(value);
const canDecrement = current !== null && current - step >= min;
const errorId = `${id}-grid-error`;
const isDecimal = decimals > 0;
const parsed = value === "" ? Number.NaN : Number(value);
const current = Number.isNaN(parsed) ? null : parsed;
// Derived from the value itself, so it always matches what the caller holds.
const offGrid = isDecimal && current !== null && !isOnGrid(current, min, step);
const gridError = offGrid ? `Nur Schritte von ${stepLabel(step)}.` : null;
const canDecrement = current !== null && current - step >= min - GRID_TOLERANCE;
function format(next: number): string {
return isDecimal ? formatNumber(next, decimals) : String(next);
}
// Next grid point above / below; from an off-grid value it snaps to the grid.
function stepUp(from: number): number {
if (!isDecimal) return from + step;
return min + (Math.floor(gridPosition(from, min, step) + GRID_TOLERANCE) + 1) * step;
}
function stepDown(from: number): number {
if (!isDecimal) return from - step;
return min + (Math.ceil(gridPosition(from, min, step) - GRID_TOLERANCE) - 1) * step;
}
const inputRef = useRef<HTMLInputElement>(null);
@@ -64,15 +137,15 @@ export function NumberStepper({
return;
}
setLimitHit(false);
onChange(String(Math.min(max, current === null ? min + step : current + step)));
onChange(format(Math.min(max, current === null ? min + step : stepUp(current))));
}
function decrement() {
if (!canDecrement) return;
const next = current - step;
onChange(String(next));
const next = Math.max(min, stepDown(current));
onChange(format(next));
// A button that turns disabled would drop keyboard focus to the page.
if (next - step < min) inputRef.current?.focus();
if (next - step < min - GRID_TOLERANCE) inputRef.current?.focus();
}
function clear() {
@@ -80,7 +153,19 @@ export function NumberStepper({
inputRef.current?.focus();
}
function handleDecimalInput(text: string) {
const clean = sanitizeDecimal(text, decimals);
const typed = clean === "" ? Number.NaN : Number(clean);
const clamped = !Number.isNaN(typed) && typed > max;
setLimitHit(clamped && maxProp !== undefined);
onChange(clamped ? format(max) : clean);
}
function handleInput(event: ChangeEvent<HTMLInputElement>) {
if (isDecimal) {
handleDecimalInput(event.target.value);
return;
}
const digits = digitsOnly(event.target.value);
const clamped = digits !== "" && Number(digits) > max;
setLimitHit(clamped && maxProp !== undefined);
@@ -102,13 +187,15 @@ export function NumberStepper({
id={id}
ref={inputRef}
type="text"
maxLength={MAX_DIGITS}
inputMode="numeric"
maxLength={isDecimal ? MAX_DIGITS + 1 + decimals : MAX_DIGITS}
inputMode={isDecimal ? "decimal" : "numeric"}
autoComplete="off"
value={value}
value={isDecimal ? displayValue(value, decimals) : value}
onChange={handleInput}
aria-describedby={[describedBy, unitId].filter(Boolean).join(" ")}
aria-invalid={invalid || undefined}
aria-describedby={[describedBy, unitId, isDecimal ? errorId : undefined]
.filter(Boolean)
.join(" ")}
aria-invalid={invalid || offGrid || undefined}
className="w-20 text-center text-lg font-semibold sm:h-11"
/>
<button
@@ -122,6 +209,16 @@ export function NumberStepper({
{unitLabel}
</span>
</div>
{isDecimal ? (
<p
id={errorId}
role="status"
aria-live="polite"
className="text-xs text-destructive"
>
{gridError}
</p>
) : null}
{maxProp !== undefined ? (
<p role="status" aria-live="polite" className="text-xs text-muted-foreground empty:hidden">
{limitHit ? `Höchstens ${maxProp}` : ""}
@@ -0,0 +1,101 @@
import { toast } from "sonner";
import { FEED_TYPE_LABELS } from "@/lib/feedings/labels";
import { hiveFollowUps } from "@/lib/hive/follow-ups";
import type { FollowUp } from "@/lib/hive/follow-ups";
import type { HiveCatalog, HiveOp, HiveState } from "@/lib/hive/types";
// Spec section 8: the toast only suggests; the form it links to is prefilled
// and the beekeeper books (or not) on their own responsibility.
const TOAST_DURATION_MS = 12_000;
const numberFormat = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 3 });
export interface FollowUpContext {
colonyId: string;
/** Berlin day of the change; becomes the form's date. */
occurredOn: string;
navigate: (href: string) => void;
/** Whether the colony has a treatment without end date; „Behandlung beenden?“ needs one. */
hasOpenTreatment: boolean;
}
/** Link with the query order the spec fixes: colonyId, date, amount, unit, feedType. */
function link(path: string, entries: [string, string][]): string {
return `${path}?${new URLSearchParams(entries).toString()}`;
}
/** The feeder field's own name („Flüssigfutter“), else the feed type's. */
function feedName(followUp: Extract<FollowUp, { kind: "feeding" }>, catalog: HiveCatalog): string {
for (const type of catalog.partTypes.values()) {
if (type.kind !== "FEEDER") continue;
for (const field of type.stateSchema) {
if (field.kind === "level" && field.feedType === followUp.feedType && field.unit === followUp.unit) {
return field.label;
}
}
}
return FEED_TYPE_LABELS[followUp.feedType];
}
function addsEvaporator(ops: HiveOp[], catalog: HiveCatalog): boolean {
return ops.some(
(op) =>
(op.op === "addInsert" || op.op === "addPart") &&
catalog.partTypes.get(op.part.partTypeId)?.kind === "EVAPORATOR",
);
}
function showOne(followUp: FollowUp, ops: HiveOp[], catalog: HiveCatalog, context: FollowUpContext): void {
const { colonyId, occurredOn, navigate } = context;
if (followUp.kind === "feeding") {
const href = link("/feedings/new", [
["colonyId", colonyId],
["date", occurredOn],
["amount", String(followUp.amount)],
["unit", followUp.unit],
["feedType", followUp.feedType],
]);
const amount = `${numberFormat.format(followUp.amount)} ${followUp.unit}`;
toast(`${amount} ${feedName(followUp, catalog)} nachgefüllt. Als Fütterung eintragen?`, {
duration: TOAST_DURATION_MS,
action: { label: "Fütterung eintragen", onClick: () => navigate(href) },
});
return;
}
if (followUp.kind === "treatment") {
const href = link("/treatments/new", [
["colonyId", colonyId],
["date", occurredOn],
["amount", String(followUp.amountMl)],
["unit", "ml"],
]);
const verb = addsEvaporator(ops, catalog) ? "eingesetzt" : "nachgefüllt";
toast(`Verdunster mit ${numberFormat.format(followUp.amountMl)} ml ${verb}. Als Behandlung eintragen?`, {
duration: TOAST_DURATION_MS,
action: { label: "Behandlung eintragen", onClick: () => navigate(href) },
});
return;
}
if (!context.hasOpenTreatment) return;
toast("Behandlung beenden?", {
duration: TOAST_DURATION_MS,
action: {
label: "Behandlungen öffnen",
onClick: () => navigate(`/colonies/${encodeURIComponent(colonyId)}#behandlungen`),
},
});
}
/** One toast per suggestion of `hiveFollowUps`; nothing is booked here. */
export function showHiveFollowUps(
ops: HiveOp[],
before: HiveState,
after: HiveState,
catalog: HiveCatalog,
context: FollowUpContext,
): void {
for (const followUp of hiveFollowUps(ops, before, after, catalog)) {
showOne(followUp, ops, catalog, context);
}
}
@@ -0,0 +1,172 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useRef, useState } from "react";
import { ApiError } from "@/lib/auth/api-client";
import { FRAME_TYPE_LABELS } from "@/lib/hive/labels";
import { frames, inlineCatalog, part } from "@/lib/hive/test-utils";
import type { HiveOp, HiveState } from "@/lib/hive/types";
import { FrameSheet } from "./frame-sheet";
import { HiveOutline } from "./hive-outline";
import type { HiveOutlineHandle } from "./hive-outline";
import { createHiveViewStore, HiveViewProvider } from "./use-hive-view";
const catalog = inlineCatalog();
const state: HiveState = {
schemaVersion: 1,
stack: [
part("f", "floor"),
part("b1", "box", {
frames: frames(10).map((frame) =>
frame.slot === 4 ? { ...frame, type: "BROOD" as const, name: "Königin" } : frame,
),
}),
part("b2", "box", { frames: frames(10) }),
part("r", "roof"),
],
};
interface HarnessProps {
submit: (ops: HiveOp[]) => Promise<unknown>;
boxId?: string;
slot?: number;
}
function Harness({ submit, boxId = "b1", slot = 4 }: HarnessProps) {
const [open, setOpen] = useState(true);
const outlineRef = useRef<HiveOutlineHandle>(null);
const [store] = useState(createHiveViewStore);
return (
<HiveViewProvider store={store}>
<HiveOutline
ref={outlineRef}
state={state}
catalog={catalog}
canEdit
announcement=""
onEditPart={() => {}}
onEditFrame={() => setOpen(true)}
/>
<FrameSheet
open={open}
onOpenChange={setOpen}
state={state}
catalog={catalog}
boxId={boxId}
slot={slot}
submit={submit}
outlineRef={outlineRef}
/>
</HiveViewProvider>
);
}
const save = () => screen.getByRole("button", { name: "Speichern" });
describe("FrameSheet", () => {
it("is titled with the slot and names the part", () => {
render(<Harness submit={vi.fn()} />);
expect(screen.getByRole("dialog", { name: "Rähmchen 4" })).toBeInTheDocument();
expect(screen.getByText("Zarge 1")).toBeInTheDocument();
});
it("offers the ten frame types by their German names with the current one chosen", () => {
render(<Harness submit={vi.fn()} />);
expect(screen.getAllByRole("radio")).toHaveLength(10);
expect(screen.getByRole("radio", { name: FRAME_TYPE_LABELS.BROOD })).toBeChecked();
expect(screen.getByRole("radio", { name: FRAME_TYPE_LABELS.NONE })).toBeInTheDocument();
});
it("keeps Speichern disabled while nothing changed", () => {
render(<Harness submit={vi.fn()} />);
expect(save()).toBeDisabled();
});
it("saves one setFrame with the chosen type and the trimmed name", async () => {
const submit = vi.fn().mockResolvedValue(undefined);
const user = userEvent.setup();
render(<Harness submit={submit} />);
await user.click(screen.getByRole("radio", { name: FRAME_TYPE_LABELS.HONEY }));
const name = screen.getByLabelText("Name");
await user.clear(name);
await user.type(name, " Vorrat ");
await user.click(save());
expect(submit).toHaveBeenCalledTimes(1);
expect(submit).toHaveBeenCalledWith([
{ op: "setFrame", boxId: "b1", slot: 4, type: "HONEY", name: "Vorrat" },
]);
});
it("sends name null when the name is emptied", async () => {
const submit = vi.fn().mockResolvedValue(undefined);
const user = userEvent.setup();
render(<Harness submit={submit} />);
await user.clear(screen.getByLabelText("Name"));
await user.click(save());
expect(submit).toHaveBeenCalledWith([
{ op: "setFrame", boxId: "b1", slot: 4, type: "BROOD", name: null },
]);
});
it("limits the name to 40 characters and shows a counter", async () => {
const user = userEvent.setup();
render(<Harness submit={vi.fn()} slot={1} />);
const name = screen.getByLabelText("Name");
expect(name).toHaveAttribute("maxlength", "40");
await user.type(name, "x".repeat(45));
expect(name).toHaveValue("x".repeat(40));
expect(screen.getByText("40 / 40")).toBeInTheDocument();
});
it("disables Speichern while the request is pending", async () => {
let finish: () => void = () => undefined;
const submit = vi.fn().mockReturnValue(new Promise<void>((resolve) => (finish = resolve)));
const user = userEvent.setup();
render(<Harness submit={submit} />);
await user.click(screen.getByRole("radio", { name: FRAME_TYPE_LABELS.POLLEN }));
await user.click(save());
expect(save()).toBeDisabled();
finish();
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
});
it("shows an error inline and stays open when saving fails", async () => {
const submit = vi.fn().mockRejectedValue(new ApiError(500, "kaputt", null));
const user = userEvent.setup();
render(<Harness submit={submit} />);
await user.click(screen.getByRole("radio", { name: FRAME_TYPE_LABELS.POLLEN }));
await user.click(save());
expect(await screen.findByRole("alert")).toHaveTextContent("Speichern fehlgeschlagen");
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
it("closes on a conflict so the conflict sheet can take over", async () => {
const submit = vi.fn().mockRejectedValue(
new ApiError(409, "Konflikt", { code: "HIVE_CONFLICT", opIndex: 0, reason: "x", current: {} }),
);
const user = userEvent.setup();
render(<Harness submit={submit} />);
await user.click(screen.getByRole("radio", { name: FRAME_TYPE_LABELS.POLLEN }));
await user.click(save());
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
});
it("returns focus to the edited frame in the outline, not to the body", async () => {
const submit = vi.fn().mockResolvedValue(undefined);
const user = userEvent.setup();
render(<Harness submit={submit} />);
await user.click(screen.getByRole("radio", { name: FRAME_TYPE_LABELS.HONEY }));
await user.click(save());
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
await waitFor(() => expect(screen.getByRole("treeitem", { name: /R4 ·/ })).toHaveFocus());
expect(document.body).not.toHaveFocus();
});
it("returns focus to the frame also when closed with Escape", async () => {
const user = userEvent.setup();
render(<Harness submit={vi.fn()} />);
await user.keyboard("{Escape}");
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
await waitFor(() => expect(screen.getByRole("treeitem", { name: /R4 ·/ })).toHaveFocus());
});
});
@@ -0,0 +1,140 @@
"use client";
import { useState } from "react";
import type { RefObject } from "react";
import { ChoiceGroup, choiceOptions } from "@/components/form/choice-group";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { SheetFooter } from "@/components/ui/sheet";
import { FRAME_TYPE_LABELS } from "@/lib/hive/labels";
import { partDisplayName } from "@/lib/hive/names";
import { FRAME_TYPES, MAX_TEXT_LENGTH } from "@/lib/hive/types";
import type { FrameType, HiveCatalog, HiveState } from "@/lib/hive/types";
import type { HiveOutlineHandle } from "./hive-outline";
import { HiveSheetShell } from "./hive-sheet-shell";
import { useSheetSubmit } from "./use-sheet-submit";
import type { SubmitOps } from "./use-sheet-submit";
const FRAME_OPTIONS = choiceOptions(FRAME_TYPE_LABELS, FRAME_TYPES);
export interface FrameSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
state: HiveState;
catalog: HiveCatalog;
boxId: string;
slot: number;
/** Sends the ops (source CARD); rejects on failure. */
submit: SubmitOps;
/** On close, focus returns to this frame's item in the outline. */
outlineRef: RefObject<HiveOutlineHandle | null>;
}
interface FrameFormProps {
initialType: FrameType;
initialName: string;
boxId: string;
slot: number;
submit: SubmitOps;
/** Saved, or the hive changed meanwhile (the conflict sheet takes over). */
onClose: () => void;
}
function FrameForm({ initialType, initialName, boxId, slot, submit, onClose }: FrameFormProps) {
const [type, setType] = useState<FrameType>(initialType);
const [name, setName] = useState(initialName);
const { send, pending, error } = useSheetSubmit(submit);
const trimmed = name.trim();
const changed = type !== initialType || trimmed !== initialName.trim();
async function save() {
const outcome = await send([{ op: "setFrame", boxId, slot, type, name: trimmed === "" ? null : trimmed }]);
if (outcome !== "failed") onClose();
}
return (
<form
className="flex flex-col gap-4 px-4 pb-4"
onSubmit={(event) => {
event.preventDefault();
if (changed && !pending) void save();
}}
>
<div className="flex flex-col gap-1.5">
<span id={`frame-${boxId}-${slot}-type`} className="text-sm font-medium">
Art
</span>
<ChoiceGroup
options={FRAME_OPTIONS}
value={type}
onChange={setType}
labelledBy={`frame-${boxId}-${slot}-type`}
className="grid-cols-2 sm:grid-cols-3"
/>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor={`frame-${boxId}-${slot}-name`} className="text-sm font-medium">
Name
</label>
<Input
id={`frame-${boxId}-${slot}-name`}
value={name}
maxLength={MAX_TEXT_LENGTH}
autoComplete="off"
onChange={(event) => setName(event.target.value)}
aria-describedby={`frame-${boxId}-${slot}-count`}
className="h-11"
/>
<p id={`frame-${boxId}-${slot}-count`} className="text-xs text-muted-foreground">
{name.length} / {MAX_TEXT_LENGTH}
</p>
</div>
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
) : null}
<SheetFooter className="p-0">
<Button type="submit" size="touch" disabled={!changed || pending}>
Speichern
</Button>
</SheetFooter>
</form>
);
}
/** Edit one frame of a box: its type and an optional name (spec 9.4). One `setFrame` per save. */
export function FrameSheet({ open, onOpenChange, state, catalog, boxId, slot, submit, outlineRef }: FrameSheetProps) {
const box = state.stack.find((candidate) => candidate.id === boxId);
const frame = box?.frames?.find((candidate) => candidate.slot === slot);
return (
<HiveSheetShell
open={open}
onOpenChange={onOpenChange}
title={`Rähmchen ${slot}`}
description={partDisplayName(state, catalog, boxId)}
finalFocus={() => {
outlineRef.current?.focusFrame(boxId, slot);
return false;
}}
>
{frame ? (
<FrameForm
// A different frame starts a fresh draft.
key={`${boxId}#${slot}`}
initialType={frame.type}
initialName={frame.name ?? ""}
boxId={boxId}
slot={slot}
submit={submit}
onClose={() => onOpenChange(false)}
/>
) : (
<p role="alert" className="px-4 pb-4 text-sm text-destructive">
Dieses Rähmchen gibt es nicht mehr.
</p>
)}
</HiveSheetShell>
);
}
@@ -0,0 +1,278 @@
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 { ApiError } from "@/lib/auth/api-client";
import {
fetchHive,
fetchHiveAt,
fetchHiveHistory,
fetchHiveSystems,
fetchHiveTemplate,
postHiveChange,
} from "@/lib/hive/api";
import { templateState } from "@/lib/hive/test-utils";
import type { HiveOp } from "@/lib/hive/types";
import { HiveCard } from "./hive-card";
import { colonyFixture, historyItem, sampleState, setupView, systemDto } from "./test-fixtures";
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
vi.mock("sonner", () => ({ toast: vi.fn() }));
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn().mockResolvedValue([]) }));
vi.mock("@/lib/hive/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/hive/api")>()),
fetchHive: vi.fn(),
fetchHiveSystems: vi.fn(),
fetchHiveTemplate: vi.fn(),
fetchHiveHistory: vi.fn(),
fetchHiveAt: vi.fn(),
postHiveChange: vi.fn(),
createHive: vi.fn(),
}));
let permissions: string[] = [];
vi.mock("@/lib/auth/auth-context", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/auth/auth-context")>()),
useAuth: () => ({ hasPermission: (key: string) => permissions.includes(key) }),
}));
const connectivity = vi.hoisted(() => ({ value: "online" as "online" | "offline" }));
vi.mock("@/lib/offline/connectivity", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/offline/connectivity")>()),
useConnectivity: () => connectivity.value,
}));
function renderCard(props: Partial<React.ComponentProps<typeof HiveCard>> = {}) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<HiveCard colonyId="colony-1" colony={colonyFixture()} {...props} />
</QueryClientProvider>,
);
}
const FRAME_EDIT_OPS: HiveOp[] = [{ op: "setFrame", boxId: "b1", slot: 5, type: "BROOD", name: null }];
/** Opens Zarge 1 in the outline, picks frame 5 and chooses „Brutwabe“. */
async function editFrameFive() {
await userEvent.click(screen.getByRole("treeitem", { name: /Zarge 1/ }));
await userEvent.click(screen.getByRole("treeitem", { name: /R5 · Mittelwand/ }));
const dialog = await screen.findByRole("dialog", { name: "Rähmchen 5" });
await userEvent.click(within(dialog).getByRole("radio", { name: "Brutwabe" }));
await userEvent.click(within(dialog).getByRole("button", { name: "Speichern" }));
}
describe("HiveCard", () => {
beforeEach(() => {
permissions = ["colonies:read", "colonies:update"];
connectivity.value = "online";
vi.mocked(fetchHive).mockReset().mockResolvedValue(setupView());
vi.mocked(fetchHiveSystems).mockReset().mockResolvedValue([systemDto()]);
vi.mocked(fetchHiveTemplate).mockReset().mockResolvedValue(templateState());
vi.mocked(fetchHiveHistory)
.mockReset()
.mockResolvedValue({ items: [historyItem()], nextCursor: null });
vi.mocked(fetchHiveAt).mockReset();
vi.mocked(postHiveChange).mockReset();
});
it("shows the model, the facts line and where the state comes from", async () => {
renderCard();
expect(await screen.findByRole("img", { name: /Beute mit/ })).toBeInTheDocument();
expect(screen.getByText("2 Zargen · 6 Brutwaben · Futter 5 l · Bausperre drin")).toBeInTheDocument();
expect(await screen.findByText("Stand: 12.10. aus Durchsicht")).toBeInTheDocument();
});
it("is the right column of the first row and as tall as the card beside it", async () => {
const { container } = renderCard();
await screen.findByRole("img", { name: /Beute mit/ });
const root = container.querySelector("[data-slot=card]")!;
expect(root).toHaveClass("lg:col-span-2", "min-h-80", "lg:h-auto");
expect(root.className).toContain("h-[min(55svh,520px)]");
});
it("offers the hive setup instead of a model when the colony has none", async () => {
vi.mocked(fetchHive).mockRejectedValue(new ApiError(404, "Nicht gefunden", { code: "HIVE_NOT_FOUND" }));
renderCard();
expect(await screen.findByText("Noch keine Beute angelegt")).toBeInTheDocument();
expect(await screen.findByRole("button", { name: "Übernehmen" })).toBeInTheDocument();
});
it("shows readers only „Noch keine Beute angelegt“", async () => {
permissions = ["colonies:read"];
vi.mocked(fetchHive).mockRejectedValue(new ApiError(404, "Nicht gefunden", { code: "HIVE_NOT_FOUND" }));
renderCard();
expect(await screen.findByText("Noch keine Beute angelegt")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Übernehmen" })).not.toBeInTheDocument();
});
it("explains a failed load, keeps the card and retries on request", async () => {
vi.mocked(fetchHive).mockRejectedValueOnce(new ApiError(500, "Fehler", {}));
renderCard();
expect(await screen.findByRole("alert")).toHaveTextContent("Beute konnte nicht geladen werden.");
await userEvent.click(screen.getByRole("button", { name: "Erneut versuchen" }));
expect(await screen.findByRole("img", { name: /Beute mit/ })).toBeInTheDocument();
expect(fetchHive).toHaveBeenCalledTimes(2);
});
describe("Umbauen", () => {
it("links to the editor route", async () => {
renderCard();
expect(await screen.findByRole("link", { name: "Umbauen" })).toHaveAttribute("href", "/colonies/colony-1/hive");
});
it("is hidden without colonies:update", async () => {
permissions = ["colonies:read"];
renderCard();
await screen.findByRole("img", { name: /Beute mit/ });
expect(screen.queryByRole("link", { name: "Umbauen" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Umbauen" })).not.toBeInTheDocument();
});
it("is hidden for a colony that no longer lives", async () => {
renderCard({ colony: colonyFixture({ status: "DISSOLVED" }) });
await screen.findByRole("img", { name: /Beute mit/ });
expect(screen.queryByRole("link", { name: "Umbauen" })).not.toBeInTheDocument();
});
it("is disabled offline and says why", async () => {
connectivity.value = "offline";
renderCard();
expect(await screen.findByText("Umbauen geht nur mit Netz")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Umbauen" })).toBeDisabled();
expect(screen.queryByRole("link", { name: "Umbauen" })).not.toBeInTheDocument();
});
});
it("keeps the outline in the DOM but visually hidden until „Aufbau als Liste“", async () => {
renderCard();
const toggle = await screen.findByRole("button", { name: "Aufbau als Liste" });
expect(screen.getByRole("tree", { name: "Aufbau der Beute" })).toBeInTheDocument();
const wrapper = document.getElementById(toggle.getAttribute("aria-controls")!)!;
expect(wrapper).toHaveClass("sr-only");
expect(toggle).toHaveAttribute("aria-pressed", "false");
await userEvent.click(toggle);
expect(wrapper).not.toHaveClass("sr-only");
expect(toggle).toHaveAttribute("aria-pressed", "true");
});
it("keeps the outline from growing the card: it scrolls in an absolutely placed child", async () => {
renderCard();
await userEvent.click(await screen.findByRole("button", { name: "Aufbau als Liste" }));
const scroll = screen.getByTestId("hive-outline-scroll");
expect(scroll).toHaveClass("sm:absolute", "sm:inset-0", "sm:overflow-y-auto");
expect(scroll.parentElement).toHaveClass("sm:relative", "sm:w-72");
});
it("points „Aufbau als Liste“ at the outline", async () => {
renderCard();
const toggle = await screen.findByRole("button", { name: "Aufbau als Liste" });
const target = document.getElementById(toggle.getAttribute("aria-controls")!);
expect(target).toContainElement(screen.getByRole("tree"));
});
describe("editing", () => {
it("saves a frame change from the outline as a CARD change", async () => {
vi.mocked(postHiveChange).mockResolvedValue(setupView());
renderCard();
await screen.findByRole("img", { name: /Beute mit/ });
await editFrameFive();
expect(postHiveChange).toHaveBeenCalledWith(
"colony-1",
{ occurredOn: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/), source: "CARD", ops: FRAME_EDIT_OPS },
expect.any(String),
);
});
it("does not open a frame sheet for a reader", async () => {
permissions = ["colonies:read"];
renderCard();
await screen.findByRole("img", { name: /Beute mit/ });
await userEvent.click(screen.getByRole("treeitem", { name: /Zarge 1/ }));
await userEvent.click(screen.getByRole("treeitem", { name: /R5 · Mittelwand/ }));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("in inspection mode keeps a draft and reports the ops instead of saving", async () => {
const onOpsChange = vi.fn();
renderCard({ mode: "inspection", onOpsChange });
await screen.findByRole("img", { name: /Beute mit/ });
await editFrameFive();
expect(onOpsChange).toHaveBeenCalledWith(FRAME_EDIT_OPS);
expect(postHiveChange).not.toHaveBeenCalled();
expect(await screen.findByText(/7 Brutwaben/)).toBeInTheDocument();
expect(screen.queryByRole("link", { name: "Umbauen" })).not.toBeInTheDocument();
});
it("opens the conflict sheet when the hive changed meanwhile", async () => {
vi.mocked(postHiveChange).mockRejectedValue(
new ApiError(409, "Konflikt", {
code: "HIVE_CONFLICT",
opIndex: 0,
reason: "x",
current: { version: 9, state: sampleState() },
}),
);
renderCard();
await screen.findByRole("img", { name: /Beute mit/ });
await editFrameFive();
expect(await screen.findByRole("dialog", { name: "Die Beute wurde inzwischen geändert" })).toBeInTheDocument();
});
});
describe("Verlauf", () => {
it("shows the state of a past day read-only and returns to today", async () => {
const past = { ...sampleState(), stack: sampleState().stack.slice(0, 2) };
vi.mocked(fetchHiveAt).mockResolvedValue({
version: 2,
state: past,
facts: {} as never,
occurredOn: "2026-08-12",
});
vi.mocked(fetchHiveHistory).mockResolvedValue({
items: [historyItem({ occurredOn: "2026-08-12", createdAt: "2026-08-12T10:00:00.000Z" })],
nextCursor: null,
});
renderCard();
await screen.findByRole("img", { name: /Beute mit/ });
await userEvent.click(screen.getByRole("button", { name: "Verlauf" }));
const dialog = await screen.findByRole("dialog", { name: "Verlauf" });
await userEvent.click(await within(dialog).findByRole("button", { name: /12\.08\./ }));
expect(await screen.findByText(/Stand vom 12\.08\./)).toBeInTheDocument();
expect(screen.getByText("1 Zarge · 3 Brutwaben · Bausperre drin")).toBeInTheDocument();
// Read-only: a frame cannot be edited while a past state is shown.
await userEvent.click(screen.getByRole("treeitem", { name: /Zarge/ }));
await userEvent.click(screen.getByRole("treeitem", { name: /R5 · Mittelwand/ }));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Zurück zu heute" }));
expect(screen.queryByText(/Stand vom/)).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Verlauf" })).toHaveFocus();
expect(screen.getByText("2 Zargen · 6 Brutwaben · Futter 5 l · Bausperre drin")).toBeInTheDocument();
});
});
});
@@ -0,0 +1,347 @@
"use client";
import { useId, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { History, ListTree } from "lucide-react";
import { Button, buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useAuth } from "@/lib/auth/auth-context";
import { isLivingColony } from "@/lib/colonies/status";
import type { Colony } from "@/lib/colonies/types";
import { fetchHive, hiveErrorCode, hiveKeys } from "@/lib/hive/api";
import { hiveReasonText } from "@/lib/hive/reason-text";
import { describeHiveOps } from "@/lib/hive/describe";
import { deriveHiveFacts } from "@/lib/hive/facts";
import { hiveLayout } from "@/lib/hive/layout";
import { partDisplayName } from "@/lib/hive/names";
import { applyHiveOps } from "@/lib/hive/ops";
import type { HiveCatalog, HiveOp, HiveSetupView, HiveState } from "@/lib/hive/types";
import { useConnectivity } from "@/lib/offline/connectivity";
import { FrameSheet } from "./frame-sheet";
import { HiveConflictSheet } from "./hive-conflict-sheet";
import { HiveFactsLine } from "./hive-facts-line";
import { HiveHistory, HiveVersionBar, useHiveHistory } from "./hive-history";
import { HiveInit } from "./hive-init";
import { HiveOutline } from "./hive-outline";
import type { HiveOutlineHandle } from "./hive-outline";
import { HiveSheetShell } from "./hive-sheet-shell";
import { HiveVisualSlot } from "./hive-visual-slot";
import { PartSheet } from "./part-sheet";
import { useHiveMutation } from "./use-hive-mutation";
import { useHiveSystems, useSystemCatalog } from "./use-hive-systems";
import { createHiveViewStore, HiveViewProvider, useHiveView } from "./use-hive-view";
import { HiveDraftError } from "./use-sheet-submit";
import type { SubmitOps } from "./use-sheet-submit";
const LOAD_FAILED_TEXT = "Beute konnte nicht geladen werden.";
const OFFLINE_REBUILD_HINT = "Umbauen geht nur mit Netz";
export interface HiveCardProps {
colonyId: string;
colony: Colony;
/** "inspection" keeps a local draft and reports the ops instead of saving (inspection form). */
mode?: "view" | "inspection";
onOpsChange?: (ops: HiveOp[]) => void;
}
/** Colony page card: model, facts line, outline, history (spec 9). Fixed height below lg, as tall as „Stammdaten“ above. */
export function HiveCard(props: HiveCardProps) {
const [store] = useState(createHiveViewStore);
return (
<HiveViewProvider store={store}>
<Card className="h-[min(55svh,520px)] min-h-80 lg:col-span-2 lg:h-auto lg:min-h-0">
<CardHeader>
<CardTitle>Beute</CardTitle>
</CardHeader>
<CardContent className="flex min-h-0 flex-1 flex-col">
<div className="flex h-full min-h-0 flex-col gap-3">
<HiveCardContent {...props} />
</div>
</CardContent>
</Card>
</HiveViewProvider>
);
}
function HiveCardContent({ colonyId, colony, mode = "view", onOpsChange }: HiveCardProps) {
const { hasPermission } = useAuth();
const hive = useQuery({ queryKey: hiveKeys.detail(colonyId), queryFn: () => fetchHive(colonyId) });
const systems = useHiveSystems();
const catalog = useSystemCatalog(systems.data, hive.data?.systemId);
const canEdit = hasPermission("colonies:update") && isLivingColony(colony);
if (hiveErrorCode(hive.error) === "HIVE_NOT_FOUND") {
return <HiveInit colonyId={colonyId} colony={colony} canEdit={canEdit} />;
}
if (hive.isError || systems.isError) {
return (
<div role="alert" className="flex flex-1 flex-col items-center justify-center gap-3 text-center">
<p className="text-sm">{LOAD_FAILED_TEXT}</p>
<Button
type="button"
variant="outline"
size="touch"
onClick={() => {
if (hive.isError) void hive.refetch();
if (systems.isError) void systems.refetch();
}}
>
Erneut versuchen
</Button>
</div>
);
}
if (!hive.data || !catalog) {
return (
<div
role="status"
aria-label="Beute wird geladen"
className="min-h-40 flex-1 rounded-lg bg-muted motion-safe:animate-pulse"
/>
);
}
return (
<HiveCardBody
colonyId={colonyId}
setup={hive.data}
catalog={catalog}
canEdit={canEdit}
mode={mode}
onOpsChange={onOpsChange}
/>
);
}
interface FrameTarget {
boxId: string;
slot: number;
}
interface PartTarget {
id: string;
evaporator: boolean;
}
interface PastState {
state: HiveState;
occurredOn: string;
}
function findPartKind(state: HiveState, catalog: HiveCatalog, id: string) {
for (const part of state.stack) {
const hit = part.id === id ? part : part.inserts?.find((insert) => insert.id === id);
if (hit) return catalog.partTypes.get(hit.partTypeId);
}
return undefined;
}
interface HiveCardBodyProps {
colonyId: string;
setup: HiveSetupView;
catalog: HiveCatalog;
canEdit: boolean;
mode: "view" | "inspection";
onOpsChange?: (ops: HiveOp[]) => void;
}
function HiveCardBody({ colonyId, setup, catalog, canEdit, mode, onOpsChange }: HiveCardBodyProps) {
const view = useHiveView();
const connectivity = useConnectivity();
const mutation = useHiveMutation(colonyId, catalog);
const history = useHiveHistory(colonyId);
const outlineRef = useRef<HiveOutlineHandle>(null);
const historyButtonRef = useRef<HTMLButtonElement>(null);
const outlineId = useId();
const [draftOps, setDraftOps] = useState<HiveOp[]>([]);
const [past, setPast] = useState<PastState | null>(null);
const [showOutline, setShowOutline] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
const [frameTarget, setFrameTarget] = useState<FrameTarget | null>(null);
const [frameOpen, setFrameOpen] = useState(false);
const [partTarget, setPartTarget] = useState<PartTarget | null>(null);
const [partOpen, setPartOpen] = useState(false);
const [announcement, setAnnouncement] = useState("");
const isInspection = mode === "inspection";
// The state edits apply to: the loaded one, plus the local draft in inspection mode.
const workingState = useMemo(() => {
if (!isInspection || draftOps.length === 0) return setup.state;
const applied = applyHiveOps(setup.state, draftOps, catalog);
return applied.ok ? applied.state : setup.state;
}, [isInspection, draftOps, setup.state, catalog]);
const shownState = past?.state ?? workingState;
const editable = canEdit && past === null;
const facts = useMemo(() => deriveHiveFacts(shownState, catalog), [shownState, catalog]);
const layout = useMemo(() => hiveLayout(shownState, catalog), [shownState, catalog]);
const lastChange = history.data?.pages[0]?.items[0];
const submit: SubmitOps = async (ops) => {
const before = workingState;
if (isInspection) {
const next = [...draftOps, ...ops];
const applied = applyHiveOps(setup.state, next, catalog);
if (!applied.ok) throw new HiveDraftError(hiveReasonText(applied.reason));
setDraftOps(next);
onOpsChange?.(next);
} else {
await mutation.submit(ops, "CARD");
}
setAnnouncement(`${describeHiveOps(ops, before, catalog)}.`);
};
const partName = (id: string) => partDisplayName(shownState, catalog, id);
function editFrame(boxId: string, slot: number) {
if (!editable) return;
setFrameTarget({ boxId, slot });
setFrameOpen(true);
}
function editPart(id: string) {
if (!editable) return;
// The outline's „Verdunster einsetzen“ action reports its box.
const evaporator = findPartKind(workingState, catalog, id)?.kind === "BOX";
setPartTarget({ id, evaporator });
setPartOpen(true);
}
function selectPart(id: string) {
const type = findPartKind(shownState, catalog, id);
if (type?.kind === "BOX") {
view.openBox(id);
return;
}
if (type && (type.stateSchema.length > 0 || type.kind === "FLOOR")) editPart(id);
}
const showRebuild = canEdit && !isInspection;
const offline = connectivity === "offline";
return (
<>
{past ? <HiveVersionBar occurredOn={past.occurredOn} onBack={() => {
setPast(null);
historyButtonRef.current?.focus();
}} /> : null}
<div className="flex min-h-0 flex-1 flex-col gap-3 sm:flex-row">
<div className="relative min-h-40 flex-1 overflow-hidden rounded-lg bg-holo-bg">
<div className="absolute inset-0">
<HiveVisualSlot
layout={layout}
catalog={catalog}
partName={partName}
onSelectPart={selectPart}
onSelectFrame={editFrame}
/>
</div>
{view.openBoxId ? (
<Button
type="button"
variant="secondary"
size="touch"
className="absolute start-2 top-2"
onClick={() => view.openBox(null)}
>
Zurück
</Button>
) : null}
</div>
<div
id={outlineId}
className={
showOutline
? "max-h-40 min-h-0 overflow-y-auto sm:relative sm:max-h-none sm:w-72 sm:shrink-0 sm:overflow-visible"
: "sr-only"
}
>
<div className="sm:absolute sm:inset-0 sm:overflow-y-auto" data-testid="hive-outline-scroll">
<HiveOutline
ref={outlineRef}
state={shownState}
catalog={catalog}
canEdit={editable}
announcement={announcement}
onEditPart={editPart}
onEditFrame={editFrame}
/>
</div>
</div>
</div>
<HiveFactsLine facts={facts} lastChange={past ? null : lastChange} />
<div className="flex flex-wrap items-center gap-2">
{showRebuild && !offline ? (
<Link
href={`/colonies/${encodeURIComponent(colonyId)}/hive`}
className={buttonVariants({ size: "touch" })}
>
Umbauen
</Link>
) : null}
{showRebuild && offline ? (
<Button size="touch" disabled aria-describedby="hive-rebuild-hint">
Umbauen
</Button>
) : null}
<Button
type="button"
variant="outline"
size="touch"
aria-pressed={showOutline}
aria-controls={outlineId}
onClick={() => setShowOutline(!showOutline)}
>
<ListTree aria-hidden />
Aufbau als Liste
</Button>
<Button ref={historyButtonRef} type="button" variant="outline" size="touch" onClick={() => setHistoryOpen(true)}>
<History aria-hidden />
Verlauf
</Button>
</div>
{showRebuild && offline ? (
<p id="hive-rebuild-hint" className="text-xs text-muted-foreground">
{OFFLINE_REBUILD_HINT}
</p>
) : null}
<HiveSheetShell open={historyOpen} onOpenChange={setHistoryOpen} title="Verlauf">
<HiveHistory
colonyId={colonyId}
state={setup.state}
catalog={catalog}
onShowVersion={(state, occurredOn) => {
setPast({ state, occurredOn });
setHistoryOpen(false);
}}
/>
</HiveSheetShell>
{frameTarget ? (
<FrameSheet
open={frameOpen}
onOpenChange={setFrameOpen}
state={workingState}
catalog={catalog}
boxId={frameTarget.boxId}
slot={frameTarget.slot}
submit={submit}
outlineRef={outlineRef}
/>
) : null}
{partTarget ? (
<PartSheet
open={partOpen}
onOpenChange={setPartOpen}
state={workingState}
catalog={catalog}
partId={partTarget.id}
startWithEvaporator={partTarget.evaporator}
submit={submit}
outlineRef={outlineRef}
/>
) : null}
<HiveConflictSheet conflict={mutation.conflict} catalog={catalog} onResolve={mutation.resolveConflict} />
</>
);
}
@@ -0,0 +1,78 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { applyHiveOps } from "@/lib/hive/ops";
import { frames, inlineCatalog, part } from "@/lib/hive/test-utils";
import type { HiveOp, HiveState } from "@/lib/hive/types";
import { HiveConflictSheet } from "./hive-conflict-sheet";
import type { HiveConflict } from "./use-hive-mutation";
const catalog = inlineCatalog();
const current: HiveState = {
schemaVersion: 1,
stack: [
part("f", "floor"),
part("b", "box", { frames: frames(10) }),
part("fe", "feeder", { state: { syrupLiters: 1, fondantKg: 0 } }),
part("r", "roof"),
],
};
const ops: HiveOp[] = [{ op: "setState", id: "fe", key: "syrupLiters", value: 3 }];
const conflictOf = (state: HiveState, userOps: HiveOp[] = ops): HiveConflict => ({
current: { version: 5, state },
ops: userOps,
preview: applyHiveOps(state, userOps, catalog),
});
describe("HiveConflictSheet", () => {
it("renders nothing without a conflict", () => {
render(<HiveConflictSheet conflict={null} catalog={catalog} onResolve={vi.fn()} />);
expect(screen.queryByRole("dialog")).toBeNull();
});
it("shows the preview of the own changes on the current state", () => {
render(<HiveConflictSheet conflict={conflictOf(current)} catalog={catalog} onResolve={vi.fn()} />);
expect(screen.getByRole("dialog", { name: "Die Beute wurde inzwischen geändert" })).toBeInTheDocument();
expect(screen.getByText(/Flüssigfutter 3 l/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Erneut speichern" })).toBeEnabled();
});
it("names the operation and the reason when the changes no longer fit", () => {
const gone: HiveState = { ...current, stack: current.stack.filter((p) => p.id !== "fe") };
render(<HiveConflictSheet conflict={conflictOf(gone)} catalog={catalog} onResolve={vi.fn()} />);
expect(screen.getByRole("alert")).toHaveTextContent("passt nicht mehr");
expect(screen.getByRole("alert")).toHaveTextContent("gibt es nicht mehr");
expect(screen.getByRole("button", { name: "Erneut speichern" })).toBeDisabled();
});
it("resolves with retry and with discard", async () => {
const onResolve = vi.fn().mockResolvedValue(undefined);
const user = userEvent.setup();
render(<HiveConflictSheet conflict={conflictOf(current)} catalog={catalog} onResolve={onResolve} />);
await user.click(screen.getByRole("button", { name: "Erneut speichern" }));
expect(onResolve).toHaveBeenLastCalledWith("retry");
await user.click(screen.getByRole("button", { name: "Verwerfen" }));
expect(onResolve).toHaveBeenLastCalledWith("discard");
});
it("shows a failed retry inline", async () => {
const onResolve = vi.fn().mockRejectedValue(new Error("boom"));
const user = userEvent.setup();
render(<HiveConflictSheet conflict={conflictOf(current)} catalog={catalog} onResolve={onResolve} />);
await user.click(screen.getByRole("button", { name: "Erneut speichern" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Speichern fehlgeschlagen");
});
it("ignores closing with Escape while a request is running", async () => {
let finish: () => void = () => undefined;
const onResolve = vi.fn().mockReturnValue(new Promise<void>((r) => (finish = r)));
const user = userEvent.setup();
render(<HiveConflictSheet conflict={conflictOf(current)} catalog={catalog} onResolve={onResolve} />);
await user.click(screen.getByRole("button", { name: "Erneut speichern" }));
await user.keyboard("{Escape}");
expect(onResolve).toHaveBeenCalledTimes(1);
expect(onResolve).toHaveBeenCalledWith("retry");
finish();
});
});
@@ -0,0 +1,85 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { SheetFooter } from "@/components/ui/sheet";
import { hiveReasonText } from "@/lib/hive/reason-text";
import { describeHiveOps } from "@/lib/hive/describe";
import type { HiveCatalog } from "@/lib/hive/types";
import { HiveSheetShell } from "./hive-sheet-shell";
import type { HiveConflict } from "./use-hive-mutation";
import { SAVE_FAILED_TEXT } from "./use-sheet-submit";
export interface HiveConflictSheetProps {
conflict: HiveConflict | null;
catalog: HiveCatalog;
onResolve: (action: "retry" | "discard") => Promise<void>;
}
/** Spec section 11: the hive changed meanwhile; the own ops are shown on the current state. */
export function HiveConflictSheet({ conflict, catalog, onResolve }: HiveConflictSheetProps) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function resolve(action: "retry" | "discard") {
setBusy(true);
setError(null);
try {
await onResolve(action);
} catch {
setError(SAVE_FAILED_TEXT);
} finally {
setBusy(false);
}
}
const preview = conflict?.preview;
const failedOp = conflict && preview && !preview.ok ? conflict.ops.slice(preview.opIndex, preview.opIndex + 1) : [];
return (
<HiveSheetShell
open={conflict !== null}
onOpenChange={(open) => {
// Closing counts as discard, but not while a request is running.
if (!open && !busy) void resolve("discard");
}}
title="Die Beute wurde inzwischen geändert"
description="Jemand anderes hat den Aufbau verändert. Deine Änderung wurde noch nicht gespeichert."
>
{conflict && preview ? (
<div className="flex flex-col gap-4 px-4 pb-4">
{preview.ok ? (
<p className="text-sm">
<span className="font-medium">Deine Änderung auf dem neuen Stand: </span>
{describeHiveOps(conflict.ops, conflict.current.state, catalog)}
</p>
) : (
<p role="alert" className="text-sm text-destructive">
Deine Änderung passt nicht mehr:{" "}
{describeHiveOps(failedOp, conflict.current.state, catalog) || "Schritt " + (preview.opIndex + 1)}.
<span className="mt-1 block text-xs text-muted-foreground">{hiveReasonText(preview.reason)}</span>
</p>
)}
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
) : null}
<SheetFooter className="p-0">
<Button
type="button"
size="touch"
disabled={!preview.ok || busy}
onClick={() => void resolve("retry")}
>
Erneut speichern
</Button>
<Button type="button" variant="outline" size="touch" disabled={busy} onClick={() => void resolve("discard")}>
Verwerfen
</Button>
</SheetFooter>
</div>
) : null}
</HiveSheetShell>
);
}
@@ -0,0 +1,22 @@
import { hiveFactsText, shortGermanDay, sourceLabel } from "@/lib/hive/summary";
import type { HiveChangeSource, HiveFacts } from "@/lib/hive/types";
export interface HiveFactsLineProps {
facts: HiveFacts;
/** The latest change; omitted while a past state is shown. */
lastChange?: { occurredOn: string; source: HiveChangeSource } | null;
}
/** „2 Zargen · 6 Brutwaben · Futter 5 l · Bausperre drin“ and „Stand: 12.10. aus Durchsicht“ (spec 9.4). */
export function HiveFactsLine({ facts, lastChange }: HiveFactsLineProps) {
return (
<div className="flex flex-col gap-0.5">
<p className="text-sm font-medium">{hiveFactsText(facts)}</p>
{lastChange ? (
<p className="text-xs text-muted-foreground">
Stand: {shortGermanDay(lastChange.occurredOn)} aus {sourceLabel(lastChange.source)}
</p>
) : null}
</div>
);
}
@@ -0,0 +1,118 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fetchHiveAt, fetchHiveHistory } from "@/lib/hive/api";
import { inlineCatalog } from "@/lib/hive/test-utils";
import { HiveHistory, HiveVersionBar } from "./hive-history";
import { historyItem, sampleState } from "./test-fixtures";
vi.mock("@/lib/hive/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/hive/api")>()),
fetchHiveHistory: vi.fn(),
fetchHiveAt: vi.fn(),
}));
const catalog = inlineCatalog();
function renderHistory(onShowVersion = vi.fn()) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={client}>
<HiveHistory colonyId="colony-1" state={sampleState()} catalog={catalog} onShowVersion={onShowVersion} />
</QueryClientProvider>,
);
return onShowVersion;
}
describe("HiveHistory", () => {
beforeEach(() => {
vi.mocked(fetchHiveHistory).mockReset();
vi.mocked(fetchHiveAt).mockReset();
});
it("lists a change with day, source and the text from its operations", async () => {
vi.mocked(fetchHiveHistory).mockResolvedValue({ items: [historyItem()], nextCursor: null });
renderHistory();
const row = await screen.findByRole("button", { name: /12\.10\./ });
expect(row).toHaveTextContent("Durchsicht");
expect(row).toHaveTextContent("Flüssigfutter 3 l");
});
it("marks a change entered after the fact with „nachgetragen am“", async () => {
vi.mocked(fetchHiveHistory).mockResolvedValue({
items: [historyItem({ occurredOn: "2026-10-10", createdAt: "2026-10-15T09:00:00.000Z" })],
nextCursor: null,
});
renderHistory();
expect(await screen.findByText("nachgetragen am 15.10.")).toBeInTheDocument();
});
it("does not mark a change entered on its own day", async () => {
vi.mocked(fetchHiveHistory).mockResolvedValue({ items: [historyItem()], nextCursor: null });
renderHistory();
await screen.findByRole("button", { name: /12\.10\./ });
expect(screen.queryByText(/nachgetragen/)).not.toBeInTheDocument();
});
it("loads the state of that day when a row is chosen", async () => {
vi.mocked(fetchHiveHistory).mockResolvedValue({ items: [historyItem()], nextCursor: null });
const at = { version: 3, state: sampleState(), facts: {} as never, occurredOn: "2026-10-12" };
vi.mocked(fetchHiveAt).mockResolvedValue(at);
const onShowVersion = renderHistory();
await userEvent.click(await screen.findByRole("button", { name: /12\.10\./ }));
expect(fetchHiveAt).toHaveBeenCalledWith("colony-1", "2026-10-12");
expect(onShowVersion).toHaveBeenCalledWith(at.state, "2026-10-12");
});
it("says so when the state of that day cannot be loaded", async () => {
vi.mocked(fetchHiveHistory).mockResolvedValue({ items: [historyItem()], nextCursor: null });
vi.mocked(fetchHiveAt).mockRejectedValue(new Error("offline"));
const onShowVersion = renderHistory();
await userEvent.click(await screen.findByRole("button", { name: /12\.10\./ }));
expect(await screen.findByRole("alert")).toHaveTextContent("Stand konnte nicht geladen werden.");
expect(onShowVersion).not.toHaveBeenCalled();
});
it("loads the next page with the cursor", async () => {
vi.mocked(fetchHiveHistory)
.mockResolvedValueOnce({ items: [historyItem()], nextCursor: "cur-1" })
.mockResolvedValueOnce({
items: [historyItem({ id: "c0", version: 2, occurredOn: "2026-09-01", source: "INIT", ops: [] })],
nextCursor: null,
});
renderHistory();
await userEvent.click(await screen.findByRole("button", { name: "Mehr laden" }));
expect(fetchHiveHistory).toHaveBeenLastCalledWith("colony-1", "cur-1");
const row = await screen.findByRole("button", { name: /01\.09\./ });
expect(row).toHaveTextContent("Beute angelegt");
expect(screen.queryByRole("button", { name: "Mehr laden" })).not.toBeInTheDocument();
});
it("says when nothing was changed yet", async () => {
vi.mocked(fetchHiveHistory).mockResolvedValue({ items: [], nextCursor: null });
renderHistory();
expect(await screen.findByText("Noch keine Änderungen.")).toBeInTheDocument();
});
});
describe("HiveVersionBar", () => {
it("shows the day and a way back to today", async () => {
const onBack = vi.fn();
render(<HiveVersionBar occurredOn="2026-08-12" onBack={onBack} />);
expect(screen.getByRole("status")).toHaveTextContent(/Stand vom 12\.08\./);
await userEvent.click(screen.getByRole("button", { name: "Zurück zu heute" }));
expect(onBack).toHaveBeenCalled();
});
});
@@ -0,0 +1,142 @@
"use client";
import { useState } from "react";
import { useInfiniteQuery } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { berlinCalendarDay } from "@/lib/dates";
import { fetchHiveAt, fetchHiveHistory, hiveKeys } from "@/lib/hive/api";
import { describeHiveOps } from "@/lib/hive/describe";
import { shortGermanDay, sourceLabel } from "@/lib/hive/summary";
import type { HiveCatalog, HiveChangeListItem, HiveOp, HiveState } from "@/lib/hive/types";
const LOAD_FAILED_TEXT = "Stand konnte nicht geladen werden.";
/** History pages of a colony; the card reads the first item for „Stand: …“, the list reads all pages. */
export function useHiveHistory(colonyId: string) {
return useInfiniteQuery({
queryKey: [...hiveKeys.history(colonyId), "pages"],
queryFn: ({ pageParam }) => fetchHiveHistory(colonyId, pageParam),
initialPageParam: undefined as string | undefined,
getNextPageParam: (page) => page.nextCursor ?? undefined,
});
}
function textOf(item: HiveChangeListItem, state: HiveState, catalog: HiveCatalog): string {
const ops = Array.isArray(item.ops) ? (item.ops as HiveOp[]) : [];
// Items carry no state before; the current state names parts that still exist.
const text = describeHiveOps(ops, state, catalog);
if (text) return text;
return item.source === "INIT" ? "Beute angelegt" : "Änderung";
}
export interface HiveHistoryProps {
colonyId: string;
/** The current state, only used to name parts in the texts. */
state: HiveState;
catalog: HiveCatalog;
/** The state after the chosen change (read-only in the card). */
onShowVersion: (state: HiveState, occurredOn: string) => void;
}
/** Paged list of the hive's changes (spec 9.10). */
export function HiveHistory({ colonyId, state, catalog, onShowVersion }: HiveHistoryProps) {
const history = useHiveHistory(colonyId);
const [loadingId, setLoadingId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const items = history.data?.pages.flatMap((page) => page.items) ?? [];
async function show(item: HiveChangeListItem) {
setLoadingId(item.id);
setError(null);
try {
const view = await fetchHiveAt(colonyId, item.occurredOn);
onShowVersion(view.state, view.occurredOn);
} catch {
setError(LOAD_FAILED_TEXT);
} finally {
setLoadingId(null);
}
}
if (history.isLoading) {
return <p className="px-4 pb-4 text-sm text-muted-foreground">Verlauf wird geladen…</p>;
}
if (history.isError) {
return (
<div className="flex flex-col items-start gap-2 px-4 pb-4">
<p role="alert" className="text-sm text-destructive">
Verlauf konnte nicht geladen werden.
</p>
<Button type="button" variant="outline" size="touch" onClick={() => void history.refetch()}>
Erneut versuchen
</Button>
</div>
);
}
if (items.length === 0) {
return <p className="px-4 pb-4 text-sm text-muted-foreground">Noch keine Änderungen.</p>;
}
return (
<div className="flex flex-col gap-2 px-4 pb-4">
<ul className="flex flex-col gap-1">
{items.map((item) => {
const enteredOn = berlinCalendarDay(item.createdAt);
const isLate = item.occurredOn < enteredOn;
return (
<li key={item.id}>
<button
type="button"
disabled={loadingId !== null}
onClick={() => void show(item)}
className="flex min-h-11 w-full flex-col items-start justify-center rounded-lg px-3 py-1.5 text-start text-sm outline-none hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 disabled:opacity-60"
>
<span className="font-medium">
{shortGermanDay(item.occurredOn)} · {sourceLabel(item.source)}
</span>
<span>{textOf(item, state, catalog)}</span>
{isLate ? (
<span className="text-xs text-muted-foreground">nachgetragen am {shortGermanDay(enteredOn)}</span>
) : null}
</button>
</li>
);
})}
</ul>
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
) : null}
{history.hasNextPage ? (
<Button
type="button"
variant="outline"
size="touch"
disabled={history.isFetchingNextPage}
onClick={() => void history.fetchNextPage()}
>
Mehr laden
</Button>
) : null}
</div>
);
}
export interface HiveVersionBarProps {
occurredOn: string;
onBack: () => void;
}
/** „Stand vom 12.08. · Zurück zu heute“: shown while a past state is displayed read-only. */
export function HiveVersionBar({ occurredOn, onBack }: HiveVersionBarProps) {
return (
<div role="status" className="flex flex-wrap items-center gap-2 rounded-lg bg-muted px-3 py-1 text-sm">
<span>Stand vom {shortGermanDay(occurredOn)}</span>
<span aria-hidden>·</span>
<Button type="button" variant="link" size="touch" onClick={onBack}>
Zurück zu heute
</Button>
</div>
);
}
@@ -0,0 +1,166 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ApiError } from "@/lib/auth/api-client";
import { fetchColonies } from "@/lib/colonies/api";
import { createHive, fetchHive, fetchHiveSystems, fetchHiveTemplate } from "@/lib/hive/api";
import { frames, part, templateState } from "@/lib/hive/test-utils";
import type { HiveState } from "@/lib/hive/types";
import { HiveInit } from "./hive-init";
import { colonyFixture, setupView, systemDto } from "./test-fixtures";
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() }));
vi.mock("@/lib/hive/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/hive/api")>()),
fetchHiveSystems: vi.fn(),
fetchHiveTemplate: vi.fn(),
fetchHive: vi.fn(),
createHive: vi.fn(),
}));
let permissions: string[] = [];
vi.mock("@/lib/auth/auth-context", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/auth/auth-context")>()),
useAuth: () => ({ hasPermission: (key: string) => permissions.includes(key) }),
}));
function renderInit(canEdit = true) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={client}>
<HiveInit colonyId="colony-1" colony={colonyFixture()} canEdit={canEdit} />
</QueryClientProvider>,
);
return client;
}
describe("HiveInit", () => {
beforeEach(() => {
permissions = ["colonies:update"];
vi.mocked(fetchHiveSystems).mockReset().mockResolvedValue([systemDto()]);
vi.mocked(fetchHiveTemplate).mockReset().mockResolvedValue(templateState());
vi.mocked(fetchHive).mockReset();
vi.mocked(createHive).mockReset();
vi.mocked(fetchColonies)
.mockReset()
.mockResolvedValue([
colonyFixture(),
colonyFixture({ id: "colony-2", code: "V-02", apiaryId: "apiary-2" }),
colonyFixture({ id: "colony-3", code: "V-03", apiaryId: "apiary-1" }),
colonyFixture({ id: "colony-4", code: "V-04", status: "DISSOLVED" }),
]);
});
it("shows readers only the text, without loading the template", async () => {
permissions = [];
renderInit(false);
expect(await screen.findByText("Noch keine Beute angelegt")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Übernehmen" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Wie anderes Volk" })).not.toBeInTheDocument();
expect(fetchHiveTemplate).not.toHaveBeenCalled();
});
it("previews the template of the first active system and creates it on „Übernehmen“", async () => {
vi.mocked(createHive).mockResolvedValue(setupView(templateState()));
const client = renderInit();
expect(await screen.findByRole("img", { name: /Beute mit Zarge/ })).toBeInTheDocument();
expect(fetchHiveTemplate).toHaveBeenCalledWith("sys");
await userEvent.click(screen.getByRole("button", { name: "Übernehmen" }));
expect(createHive).toHaveBeenCalledWith(
"colony-1",
{ systemId: "sys", state: templateState(), occurredOn: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/) },
expect.any(String),
);
await vi.waitFor(() => expect(client.getQueryData(["colonies", "colony-1", "hive"])).toBeDefined());
});
it("explains a missing template and links to the settings only for hive-systems:manage", async () => {
vi.mocked(fetchHiveTemplate).mockRejectedValue(
new ApiError(409, "Vorlage fehlt", { code: "TEMPLATE_NOT_SET" }),
);
permissions = ["colonies:update", "hive-systems:manage"];
renderInit();
expect(await screen.findByText("Für dieses Beutensystem fehlt eine Vorlage.")).toBeInTheDocument();
expect(screen.getByRole("link", { name: /Einstellungen/ })).toHaveAttribute("href", "/settings/hive-systems");
expect(screen.getByRole("button", { name: "Übernehmen" })).toBeDisabled();
});
it("shows no settings link without the permission", async () => {
vi.mocked(fetchHiveTemplate).mockRejectedValue(
new ApiError(409, "Vorlage fehlt", { code: "TEMPLATE_NOT_SET" }),
);
renderInit();
await screen.findByText("Für dieses Beutensystem fehlt eine Vorlage.");
expect(screen.queryByRole("link", { name: /Einstellungen/ })).not.toBeInTheDocument();
});
it("copies another colony: same apiary first, empty combs, fresh ids", async () => {
const other: HiveState = {
schemaVersion: 1,
stack: [
part("f", "floor"),
part("b", "box", { frames: frames(10, "BROOD") }),
part("r", "roof"),
],
};
vi.mocked(fetchHive).mockResolvedValue(setupView(other, "colony-3"));
vi.mocked(createHive).mockResolvedValue(setupView(other));
renderInit();
await userEvent.click(await screen.findByRole("button", { name: "Wie anderes Volk" }));
await userEvent.click(screen.getByRole("combobox"));
const options = await screen.findAllByRole("option");
// The colony itself and ended colonies are not offered; V-03 shares the apiary and comes first.
expect(options.map((option) => option.textContent)).toEqual(["V-03", "V-02"]);
await userEvent.click(options[0]);
expect(await screen.findByRole("img", { name: /Beute mit/ })).toBeInTheDocument();
expect(fetchHive).toHaveBeenCalledWith("colony-3");
await userEvent.click(screen.getByRole("button", { name: "Übernehmen" }));
const input = vi.mocked(createHive).mock.calls[0][1];
const box = input.state.stack[1];
expect(box.frames?.every((frame) => frame.type === "EMPTY_COMB")).toBe(true);
expect(input.state.stack.map((p) => p.id)).not.toContain("b");
});
it("tells when the other colony has no hive yet", async () => {
vi.mocked(fetchHive).mockRejectedValue(new ApiError(404, "Nicht gefunden", { code: "HIVE_NOT_FOUND" }));
renderInit();
await userEvent.click(await screen.findByRole("button", { name: "Wie anderes Volk" }));
await userEvent.click(screen.getByRole("combobox"));
await userEvent.click((await screen.findAllByRole("option"))[0]);
expect(await screen.findByText("Dieses Volk hat noch keine Beute.")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Übernehmen" })).toBeDisabled();
});
it("shows an error when creating fails and keeps the button", async () => {
vi.mocked(createHive).mockRejectedValue(new ApiError(500, "Fehler", {}));
renderInit();
await userEvent.click(await screen.findByRole("button", { name: "Übernehmen" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Beute konnte nicht angelegt werden.");
expect(screen.getByRole("button", { name: "Übernehmen" })).toBeEnabled();
});
it("reuses the idempotency key when the same proposal is pressed again after an error", async () => {
vi.mocked(createHive).mockRejectedValueOnce(new ApiError(500, "Fehler", {})).mockResolvedValue(setupView());
renderInit();
await userEvent.click(await screen.findByRole("button", { name: "Übernehmen" }));
await screen.findByRole("alert");
await userEvent.click(screen.getByRole("button", { name: "Übernehmen" }));
const keys = vi.mocked(createHive).mock.calls.map((call) => call[2]);
expect(keys).toHaveLength(2);
expect(keys[0]).toBe(keys[1]);
});
});
@@ -0,0 +1,222 @@
"use client";
import { useMemo, useRef, useState } from "react";
import Link from "next/link";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Field } from "@/components/form/field";
import { RecordSelect } from "@/components/form/record-select";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/lib/auth/auth-context";
import { fetchColonies } from "@/lib/colonies/api";
import { colonyOptionLabel } from "@/lib/colonies/labels";
import { isLivingColony } from "@/lib/colonies/status";
import type { Colony } from "@/lib/colonies/types";
import { berlinCalendarDay } from "@/lib/dates";
import { createHive, fetchHive, fetchHiveTemplate, hiveErrorCode, hiveKeys } from "@/lib/hive/api";
import { copyHiveFrom } from "@/lib/hive/copy";
import { hiveLayout } from "@/lib/hive/layout";
import { partDisplayName } from "@/lib/hive/names";
import type { HiveCatalog, HiveState } from "@/lib/hive/types";
import { newIdempotencyKey } from "@/lib/offline/idempotency";
import { HiveSvg } from "./hive-svg";
import { useHiveSystems, useSystemCatalog } from "./use-hive-systems";
import { createHiveViewStore, HiveViewProvider } from "./use-hive-view";
const NO_TEMPLATE_TEXT = "Für dieses Beutensystem fehlt eine Vorlage.";
const OTHER_WITHOUT_HIVE_TEXT = "Dieses Volk hat noch keine Beute.";
const CREATE_FAILED_TEXT = "Beute konnte nicht angelegt werden.";
const LOAD_FAILED_TEXT = "Vorschau konnte nicht geladen werden.";
const SETTINGS_HREF = "/settings/hive-systems";
/** Candidates for „Wie anderes Volk“: living colonies of the same apiary first, then all others. */
function otherColonies(colonies: Colony[] | undefined, colony: Colony): Colony[] {
const living = (colonies ?? []).filter((candidate) => isLivingColony(candidate) && candidate.id !== colony.id);
return [
...living.filter((candidate) => candidate.apiaryId === colony.apiaryId),
...living.filter((candidate) => candidate.apiaryId !== colony.apiaryId),
];
}
/** Read-only picture of a state that is not saved yet. */
function Preview({ state, catalog }: { state: HiveState; catalog: HiveCatalog }) {
const [store] = useState(createHiveViewStore);
const layout = useMemo(() => hiveLayout(state, catalog), [state, catalog]);
return (
<HiveViewProvider store={store}>
<HiveSvg layout={layout} partName={(id) => partDisplayName(state, catalog, id)} />
</HiveViewProvider>
);
}
interface Proposal {
systemId: string;
state: HiveState;
catalog: HiveCatalog;
}
export interface HiveInitProps {
colonyId: string;
colony: Colony;
/** colonies:update on a living colony; readers only see the text. */
canEdit: boolean;
}
/** Colony without a hive (spec 11): preview of the template or of another colony, then „Übernehmen“. */
export function HiveInit({ colonyId, colony, canEdit }: HiveInitProps) {
const { hasPermission } = useAuth();
const queryClient = useQueryClient();
const [copying, setCopying] = useState(false);
const [otherId, setOtherId] = useState("");
const systems = useHiveSystems();
const system = systems.data?.find((candidate) => candidate.active) ?? systems.data?.[0];
const templateCatalog = useSystemCatalog(systems.data, system?.id);
const template = useQuery({
queryKey: ["hive-systems", system?.id, "template"],
queryFn: () => fetchHiveTemplate(system?.id ?? ""),
enabled: canEdit && !copying && system !== undefined,
});
const colonies = useQuery({ queryKey: ["colonies"], queryFn: () => fetchColonies(), enabled: canEdit && copying });
const other = useQuery({
queryKey: hiveKeys.detail(otherId),
queryFn: () => fetchHive(otherId),
enabled: canEdit && copying && otherId !== "",
});
const otherCatalog = useSystemCatalog(systems.data, other.data?.systemId);
const proposal = useMemo<Proposal | null>(() => {
if (!copying) {
return system && templateCatalog && template.data
? { systemId: system.id, state: template.data, catalog: templateCatalog }
: null;
}
if (!other.data || !otherCatalog) return null;
return {
systemId: other.data.systemId,
state: copyHiveFrom(other.data.state, otherCatalog),
catalog: otherCatalog,
};
}, [copying, system, templateCatalog, template.data, other.data, otherCatalog]);
// The same proposed content keeps its key, so a repeat press after a network error cannot create twice.
const keyRef = useRef<{ signature: string; key: string } | null>(null);
function keyFor(current: Proposal): string {
const signature = JSON.stringify([current.systemId, current.state]);
if (keyRef.current?.signature !== signature) keyRef.current = { signature, key: newIdempotencyKey() };
return keyRef.current.key;
}
const create = useMutation({
mutationFn: (current: Proposal) =>
createHive(
colonyId,
{
systemId: current.systemId,
state: current.state,
occurredOn: berlinCalendarDay(new Date().toISOString()),
},
keyFor(current),
),
onSuccess: (view) => {
queryClient.setQueryData(hiveKeys.detail(colonyId), view);
void queryClient.invalidateQueries({ queryKey: hiveKeys.history(colonyId) });
},
onError: (error) => {
// Someone created it meanwhile: show theirs.
if (hiveErrorCode(error) === "HIVE_EXISTS") {
void queryClient.invalidateQueries({ queryKey: hiveKeys.detail(colonyId) });
}
},
});
if (!canEdit) {
return (
<div className="flex flex-1 items-center justify-center">
<p className="text-sm text-muted-foreground">Noch keine Beute angelegt</p>
</div>
);
}
const templateMissing = !copying && hiveErrorCode(template.error) === "TEMPLATE_NOT_SET";
const otherHasNoHive = copying && hiveErrorCode(other.error) === "HIVE_NOT_FOUND";
const loadFailed =
systems.isError ||
(!copying && template.isError && !templateMissing) ||
(copying && other.isError && !otherHasNoHive);
return (
<div className="flex min-h-0 flex-1 flex-col gap-3">
<div>
<p className="text-sm font-medium">Noch keine Beute angelegt</p>
<p className="text-xs text-muted-foreground">
{copying ? "Aufbau eines anderen Volks, ohne Namen und Füllstände." : "Startaufbau aus der Vorlage des Beutensystems."}
</p>
</div>
<div className="relative min-h-40 flex-1 overflow-hidden rounded-lg bg-holo-bg">
<div className="absolute inset-0">
{proposal ? <Preview state={proposal.state} catalog={proposal.catalog} /> : null}
</div>
</div>
{templateMissing ? (
<p role="status" className="text-sm">
{NO_TEMPLATE_TEXT}{" "}
{hasPermission("hive-systems:manage") ? (
<Link href={SETTINGS_HREF} className="underline underline-offset-2">
Zu den Einstellungen
</Link>
) : null}
</p>
) : null}
{otherHasNoHive ? <p className="text-sm">{OTHER_WITHOUT_HIVE_TEXT}</p> : null}
{loadFailed ? (
<p role="alert" className="text-sm text-destructive">
{LOAD_FAILED_TEXT}
</p>
) : null}
{copying ? (
<Field id="hive-init-other" label="Volk" required>
{({ id, describedBy, invalid }) => (
<RecordSelect
id={id}
describedBy={describedBy}
invalid={invalid}
value={otherId}
onChange={setOtherId}
items={colonies.data ? otherColonies(colonies.data, colony) : undefined}
getLabel={colonyOptionLabel}
placeholder={colonies.isLoading ? "Völker werden geladen…" : "Volk wählen"}
/>
)}
</Field>
) : null}
{create.isError ? (
<p role="alert" className="text-sm text-destructive">
{CREATE_FAILED_TEXT}
</p>
) : null}
<div className="flex flex-wrap gap-2">
<Button
type="button"
size="touch"
disabled={proposal === null || create.isPending}
onClick={() => proposal && create.mutate(proposal)}
>
Übernehmen
</Button>
<Button
type="button"
variant="outline"
size="touch"
onClick={() => {
setCopying(!copying);
setOtherId("");
create.reset();
}}
>
{copying ? "Aus Vorlage" : "Wie anderes Volk"}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,322 @@
import { describe, expect, it, vi } from "vitest";
import { createRef } from "react";
import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { inlineCatalog, part } from "@/lib/hive/test-utils";
import type { HiveState } from "@/lib/hive/types";
import { HiveOutline } from "./hive-outline";
import type { HiveOutlineHandle } from "./hive-outline";
import { createHiveViewStore, HiveViewProvider } from "./use-hive-view";
const catalog = inlineCatalog();
const state: HiveState = {
schemaVersion: 1,
stack: [
part("f", "floor", { inserts: [part("fi", "floor-insert")] }),
part("b1", "box", {
frames: [
{ slot: 1, type: "BROOD", name: "Königin" },
{ slot: 2, type: "HONEY" },
],
}),
part("b2", "box", { frames: [{ slot: 1, type: "FOUNDATION" }] }),
part("fe", "feeder", { state: { syrupLiters: 2.5, fondantKg: 0 } }),
part("l", "inner-lid", { state: { feedHole: "CLOSED" } }),
part("r", "roof"),
],
};
function setup(props: Partial<React.ComponentProps<typeof HiveOutline>> = {}) {
const store = createHiveViewStore();
const onEditPart = vi.fn();
const onEditFrame = vi.fn();
const user = userEvent.setup();
const view = render(
<HiveViewProvider store={store}>
<HiveOutline
state={state}
catalog={catalog}
canEdit
announcement=""
onEditPart={onEditPart}
onEditFrame={onEditFrame}
{...props}
/>
</HiveViewProvider>,
);
return { store, onEditPart, onEditFrame, user, ...view };
}
const names = () => screen.getAllByRole("treeitem").map((item) => item.textContent);
describe("HiveOutline", () => {
it("is a labelled tree listing the roof first and the floor last", () => {
setup();
expect(screen.getByRole("tree", { name: "Aufbau der Beute" })).toBeInTheDocument();
const items = names();
expect(items[0]).toContain("Dach");
expect(items[1]).toContain("Holzdeckel");
expect(items[items.length - 1]).toContain("Boden");
expect(items.findIndex((t) => t?.includes("Zarge 2"))).toBeLessThan(items.findIndex((t) => t?.includes("Zarge 1")));
});
it("shows a short state hint per part", () => {
setup();
const text = names().join("|");
expect(text).toContain("Futterzarge · 2,5 l / 0 kg");
expect(text).toContain("Boden · Bausperre drin");
expect(text).toContain("Holzdeckel · zu");
});
it("marks boxes as expandable and keeps frames hidden until expanded", () => {
setup();
const box = screen.getByRole("treeitem", { name: /Zarge 1/ });
expect(box).toHaveAttribute("aria-expanded", "false");
expect(box).toHaveAttribute("aria-level", "1");
expect(screen.queryByRole("treeitem", { name: /Brutwabe/ })).toBeNull();
});
it("has exactly one tabbable item (roving tabindex), the roof first", () => {
setup();
const tabbable = screen.getAllByRole("treeitem").filter((item) => item.getAttribute("tabindex") === "0");
expect(tabbable).toHaveLength(1);
expect(tabbable[0]).toHaveTextContent("Dach");
});
it("moves with Down and Up and calls setHovered on focus", async () => {
const { user, store } = setup();
await user.tab();
expect(screen.getByRole("treeitem", { name: /Dach/ })).toHaveFocus();
expect(store.getState().hoveredId).toBe("r");
await user.keyboard("{ArrowDown}");
expect(screen.getByRole("treeitem", { name: /Holzdeckel/ })).toHaveFocus();
expect(store.getState().hoveredId).toBe("l");
await user.keyboard("{ArrowUp}");
expect(screen.getByRole("treeitem", { name: /Dach/ })).toHaveFocus();
});
it("jumps with Home and End", async () => {
const { user } = setup();
await user.tab();
await user.keyboard("{End}");
expect(screen.getByRole("treeitem", { name: /Boden/ })).toHaveFocus();
await user.keyboard("{Home}");
expect(screen.getByRole("treeitem", { name: /Dach/ })).toHaveFocus();
});
it("expands a box with Right, enters it with Right again and collapses with Left", async () => {
const { user, store } = setup();
await user.tab();
await user.keyboard("{ArrowDown}{ArrowDown}{ArrowDown}");
const box = screen.getByRole("treeitem", { name: /Zarge 2/ });
expect(box).toHaveFocus();
await user.keyboard("{ArrowRight}");
expect(box).toHaveAttribute("aria-expanded", "true");
expect(box).toHaveFocus();
await user.keyboard("{ArrowRight}");
const frame = screen.getByRole("treeitem", { name: /R1 · Mittelwand/ });
expect(frame).toHaveFocus();
expect(frame).toHaveAttribute("aria-level", "2");
expect(store.getState().hoveredFrame).toEqual({ boxId: "b2", slot: 1 });
await user.keyboard("{ArrowLeft}");
expect(box).toHaveFocus();
await user.keyboard("{ArrowLeft}");
expect(box).toHaveAttribute("aria-expanded", "false");
});
it("lists frames as R<slot> · type · name", async () => {
const { user } = setup();
await user.tab();
await user.keyboard("{ArrowDown}{ArrowDown}{ArrowDown}{ArrowDown}{ArrowRight}");
expect(screen.getByRole("treeitem", { name: "R1 · Brutwabe · Königin" })).toBeInTheDocument();
expect(screen.getByRole("treeitem", { name: "R2 · Honigwabe" })).toBeInTheDocument();
});
it("opens the box on Enter, expands it and edits a frame on Enter", async () => {
const { user, store, onEditFrame } = setup();
await user.tab();
await user.keyboard("{ArrowDown}{ArrowDown}{ArrowDown}{ArrowDown}");
expect(screen.getByRole("treeitem", { name: /Zarge 1/ })).toHaveFocus();
await user.keyboard("{Enter}");
expect(store.getState().openBoxId).toBe("b1");
expect(screen.getByRole("treeitem", { name: /Zarge 1/ })).toHaveAttribute("aria-expanded", "true");
await user.keyboard("{ArrowRight}{ArrowDown}");
await user.keyboard("{Enter}");
expect(onEditFrame).toHaveBeenCalledWith("b1", 2);
});
it("edits a part with state fields on Enter and ignores parts without", async () => {
const { user, onEditPart } = setup();
await user.tab();
await user.keyboard("{Enter}");
expect(onEditPart).not.toHaveBeenCalled();
await user.keyboard("{ArrowDown}{Enter}");
expect(onEditPart).toHaveBeenCalledWith("l");
});
it("does not edit without canEdit but still opens boxes", async () => {
const { user, store, onEditPart } = setup({ canEdit: false });
await user.tab();
await user.keyboard("{ArrowDown}{Enter}");
expect(onEditPart).not.toHaveBeenCalled();
await user.keyboard("{ArrowDown}{ArrowDown}{Enter}");
expect(store.getState().openBoxId).toBe("b2");
});
it("expands the box when it is opened from elsewhere", () => {
const { store } = setup();
act(() => store.openBox("b1"));
expect(screen.getByRole("treeitem", { name: /Zarge 1/ })).toHaveAttribute("aria-expanded", "true");
expect(screen.getByRole("treeitem", { name: /R1 · Brutwabe/ })).toBeInTheDocument();
});
it("renders the announcement in a polite status region that is always present", () => {
const { rerender, store } = setup();
const region = screen.getByRole("status");
expect(region).toHaveAttribute("aria-live", "polite");
expect(region).not.toHaveClass("empty:hidden");
rerender(
<HiveViewProvider store={store}>
<HiveOutline
state={state}
catalog={catalog}
canEdit
announcement="Rähmchen 1 in Zarge 1 ist jetzt Brutwabe."
onEditPart={() => {}}
onEditFrame={() => {}}
/>
</HiveViewProvider>,
);
expect(screen.getByRole("status")).toHaveTextContent("Rähmchen 1 in Zarge 1 ist jetzt Brutwabe.");
});
it("gives every item a touch target of at least 44 px", () => {
setup();
for (const item of screen.getAllByRole("treeitem")) expect(item).toHaveClass("min-h-11");
});
it("clears hovering when focus leaves the tree", async () => {
const { user, store } = setup();
await user.tab();
expect(store.getState().hoveredId).toBe("r");
await user.tab();
expect(store.getState().hoveredId).toBeNull();
});
it("edits the floor on Enter and keeps its hint", async () => {
const { user, onEditPart } = setup();
await user.tab();
await user.keyboard("{End}");
expect(screen.getByRole("treeitem", { name: /Boden · Bausperre drin/ })).toHaveFocus();
await user.keyboard("{Enter}");
expect(onEditPart).toHaveBeenCalledWith("f");
});
it("offers Verdunster einsetzen as last child of a box without evaporator", async () => {
const { user, onEditPart } = setup();
await user.tab();
await user.keyboard("{ArrowDown}{ArrowDown}{ArrowDown}{ArrowDown}{ArrowRight}");
const action = screen.getByRole("treeitem", { name: /Verdunster einsetzen/ });
expect(action).toHaveAttribute("aria-level", "2");
expect(action.previousElementSibling).toHaveTextContent("R2 · Honigwabe");
await user.click(action);
expect(onEditPart).toHaveBeenCalledWith("b1");
});
it("hides the add action without canEdit or when an evaporator exists", () => {
const { unmount } = setup({ canEdit: false });
act(() => {});
expect(screen.queryByRole("treeitem", { name: /Verdunster einsetzen/ })).toBeNull();
unmount();
const withEvap: HiveState = {
schemaVersion: 1,
stack: [part("b1", "box", { frames: [], inserts: [part("e1", "evaporator")] })],
};
const store = createHiveViewStore();
store.openBox("b1");
render(
<HiveViewProvider store={store}>
<HiveOutline state={withEvap} catalog={catalog} canEdit announcement="" onEditPart={() => {}} onEditFrame={() => {}} />
</HiveViewProvider>,
);
expect(screen.queryByRole("treeitem", { name: /Verdunster einsetzen/ })).toBeNull();
expect(screen.getByRole("treeitem", { name: /Verdunster/ })).toBeInTheDocument();
});
it("marks the open box with aria-current", () => {
const { store } = setup();
act(() => store.openBox("b1"));
expect(screen.getByRole("treeitem", { name: /Zarge 1/ })).toHaveAttribute("aria-current", "true");
});
it("moves focus to the nearest sibling when the focused item disappears", async () => {
const { user, store, rerender } = setup();
await user.tab();
await user.keyboard("{ArrowDown}");
expect(screen.getByRole("treeitem", { name: /Holzdeckel/ })).toHaveFocus();
const without: HiveState = { ...state, stack: state.stack.filter((p) => p.id !== "l") };
rerender(
<HiveViewProvider store={store}>
<HiveOutline state={without} catalog={catalog} canEdit announcement="" onEditPart={() => {}} onEditFrame={() => {}} />
</HiveViewProvider>,
);
expect(screen.getByRole("treeitem", { name: /Futterzarge/ })).toHaveFocus();
});
describe("imperative focus handle", () => {
function withHandle(outlineState: HiveState = state) {
const ref = createRef<HiveOutlineHandle>();
const store = createHiveViewStore();
render(
<HiveViewProvider store={store}>
<HiveOutline
ref={ref}
state={outlineState}
catalog={catalog}
canEdit
announcement=""
onEditPart={() => {}}
onEditFrame={() => {}}
/>
</HiveViewProvider>,
);
return ref;
}
it("focusPart focuses the item of a visible part", () => {
const ref = withHandle();
act(() => ref.current?.focusPart("fe"));
expect(screen.getByRole("treeitem", { name: /Futterzarge/ })).toHaveFocus();
});
it("focusFrame expands the box and focuses the frame item", () => {
const ref = withHandle();
act(() => ref.current?.focusFrame("b1", 2));
expect(screen.getByRole("treeitem", { name: /R2 · Honigwabe/ })).toHaveFocus();
});
it("focusPart on an insert inside a collapsed box expands the box first", () => {
const withEvap: HiveState = {
...state,
stack: state.stack.map((p) =>
p.id === "b1" ? { ...p, inserts: [part("ev", "evaporator", { state: { acidMl: 100 } })] } : p,
),
};
const ref = withHandle(withEvap);
act(() => ref.current?.focusPart("ev"));
expect(screen.getByRole("treeitem", { name: /Verdunster/ })).toHaveFocus();
});
it("focusPart on the hidden floor insert focuses its floor", () => {
const ref = withHandle();
act(() => ref.current?.focusPart("fi"));
expect(screen.getByRole("treeitem", { name: /Boden/ })).toHaveFocus();
});
it("ignores an unknown id and leaves focus alone", () => {
const ref = withHandle();
act(() => ref.current?.focusPart("nope"));
expect(document.body).toHaveFocus();
});
});
});
@@ -0,0 +1,383 @@
"use client";
import { useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
import type { FocusEvent, KeyboardEvent, Ref } from "react";
import { FRAME_TYPE_LABELS } from "@/lib/hive/labels";
import { partDisplayName } from "@/lib/hive/names";
import type { CatalogPartType, HiveCatalog, HivePart, HiveState, StateField } from "@/lib/hive/types";
import { useHiveView } from "./use-hive-view";
// Accessible outline of the hive (WAI-ARIA tree, spec 9.6). It offers everything
// the 3D view does: hover, open a box, edit a frame or a part with state.
const numberFormat = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 3 });
interface OutlineNode {
key: string;
kind: "part" | "frame" | "action";
/** Part id, or the box id for a frame. */
id: string;
slot?: number;
level: number;
parentKey: string | null;
childCount: number;
name: string;
hint: string | null;
isBox: boolean;
isFloor: boolean;
hasState: boolean;
setSize: number;
position: number;
}
function fieldText(field: StateField, value: number | string | undefined): string {
if (field.kind === "choice") {
const index = field.options.indexOf(String(value));
return index >= 0 ? field.optionLabels[index] : String(value ?? "");
}
const text = numberFormat.format(Number(value ?? 0));
return field.unit ? `${text} ${field.unit}` : text;
}
function visibleInserts(part: HivePart, catalog: HiveCatalog): HivePart[] {
return (part.inserts ?? []).filter((insert) => catalog.partTypes.get(insert.partTypeId)?.kind !== "FLOOR_INSERT");
}
function hintOf(part: HivePart, type: CatalogPartType | undefined, catalog: HiveCatalog): string | null {
if (!type) return null;
if (type.stateSchema.length > 0) {
return type.stateSchema.map((field) => fieldText(field, part.state[field.key])).join(" / ");
}
if (type.kind === "BOX") {
const count = part.frames?.length ?? 0;
return count > 0 ? `${count} Rähmchen` : null;
}
const hasLock = (part.inserts ?? []).some(
(insert) => catalog.partTypes.get(insert.partTypeId)?.kind === "FLOOR_INSERT",
);
return hasLock ? "Bausperre drin" : null;
}
const ADD_EVAPORATOR_LABEL = "Verdunster einsetzen …";
function buildNodes(
state: HiveState,
catalog: HiveCatalog,
expanded: ReadonlySet<string>,
canEdit: boolean,
): OutlineNode[] {
const canAddEvaporator = [...catalog.partTypes.values()].some((t) => t.kind === "EVAPORATOR" && t.active);
const nodes: OutlineNode[] = [];
const topDown = [...state.stack].reverse();
const addPart = (part: HivePart, level: number, parentKey: string | null, position: number, setSize: number) => {
const type = catalog.partTypes.get(part.partTypeId);
const inserts = visibleInserts(part, catalog);
const frames = type?.kind === "BOX" ? (part.frames ?? []) : [];
const isBox = type?.kind === "BOX";
const hasEvaporator = inserts.some((insert) => catalog.partTypes.get(insert.partTypeId)?.kind === "EVAPORATOR");
const hasAddAction = isBox && canEdit && canAddEvaporator && !hasEvaporator;
const childCount = inserts.length + frames.length + (hasAddAction ? 1 : 0);
nodes.push({
key: part.id,
kind: "part",
id: part.id,
level,
parentKey,
childCount,
name: partDisplayName(state, catalog, part.id),
hint: hintOf(part, type, catalog),
isBox,
isFloor: type?.kind === "FLOOR",
hasState: (type?.stateSchema.length ?? 0) > 0,
setSize,
position,
});
if (!expanded.has(part.id)) return;
inserts.forEach((insert, index) => addPart(insert, level + 1, part.id, index + 1, childCount));
frames.forEach((frame, index) => {
const label = [`R${frame.slot}`, FRAME_TYPE_LABELS[frame.type], frame.name].filter(Boolean).join(" · ");
nodes.push({
key: `${part.id}#${frame.slot}`,
kind: "frame",
id: part.id,
slot: frame.slot,
level: level + 1,
parentKey: part.id,
childCount: 0,
name: label,
hint: null,
isBox: false,
isFloor: false,
hasState: false,
setSize: childCount,
position: inserts.length + index + 1,
});
});
if (hasAddAction) {
nodes.push({
key: `${part.id}#add-evaporator`,
kind: "action",
id: part.id,
level: level + 1,
parentKey: part.id,
childCount: 0,
name: ADD_EVAPORATOR_LABEL,
hint: null,
isBox: false,
isFloor: false,
hasState: false,
setSize: childCount,
position: childCount,
});
}
};
topDown.forEach((part, index) => addPart(part, 1, null, index + 1, topDown.length));
return nodes;
}
/** Moves focus to an item, e.g. back to the edited one when a sheet closes. */
export interface HiveOutlineHandle {
focusPart(partId: string): void;
focusFrame(boxId: string, slot: number): void;
}
/** Outline key and the part that must be expanded to show it; null when the part is unknown. */
function focusTargetOf(
state: HiveState,
catalog: HiveCatalog,
partId: string,
): { key: string; expand: string | null } | null {
for (const part of state.stack) {
if (part.id === partId) return { key: partId, expand: null };
const insert = (part.inserts ?? []).find((candidate) => candidate.id === partId);
if (!insert) continue;
// Floor inserts have no item of their own; their floor stands for them.
const hidden = catalog.partTypes.get(insert.partTypeId)?.kind === "FLOOR_INSERT";
return hidden ? { key: part.id, expand: null } : { key: partId, expand: part.id };
}
return null;
}
export interface HiveOutlineProps {
ref?: Ref<HiveOutlineHandle>;
state: HiveState;
catalog: HiveCatalog;
canEdit: boolean;
/** Text for the polite live region ("Rähmchen 4 in Zarge 1 ist jetzt Brutwabe."). */
announcement: string;
onEditPart: (id: string) => void;
onEditFrame: (boxId: string, slot: number) => void;
}
export function HiveOutline({ ref, state, catalog, canEdit, announcement, onEditPart, onEditFrame }: HiveOutlineProps) {
const view = useHiveView();
const [expanded, setExpanded] = useState<ReadonlySet<string>>(() => new Set());
const [seenOpenBox, setSeenOpenBox] = useState<string | null>(null);
const [activeKey, setActiveKey] = useState<string | null>(null);
const itemRefs = useRef(new Map<string, HTMLDivElement>());
// A box opened elsewhere (3D, SVG) shows its frames here as well.
if (view.openBoxId !== seenOpenBox) {
setSeenOpenBox(view.openBoxId);
if (view.openBoxId && !expanded.has(view.openBoxId)) setExpanded(new Set(expanded).add(view.openBoxId));
}
const nodes = buildNodes(state, catalog, expanded, canEdit);
const keySignature = nodes.map((node) => node.key).join("|");
const previousNodes = useRef<OutlineNode[]>(nodes);
const focusWithin = useRef(false);
// When the focused item disappears, focus moves to the nearest remaining
// sibling, else its parent, else the first item.
useLayoutEffect(() => {
const before = previousNodes.current;
previousNodes.current = nodes;
if (!focusWithin.current || !activeKey) return;
if (nodes.some((node) => node.key === activeKey)) return;
const gone = before.findIndex((node) => node.key === activeKey);
if (gone < 0) return;
const present = new Set(nodes.map((node) => node.key));
const parentKey = before[gone].parentKey;
const siblings = (list: OutlineNode[]) => list.filter((n) => n.parentKey === parentKey && present.has(n.key));
const next = siblings(before.slice(gone + 1))[0] ?? siblings(before.slice(0, gone)).reverse()[0];
const target = next?.key ?? (parentKey && present.has(parentKey) ? parentKey : nodes[0]?.key);
if (target) itemRefs.current.get(target)?.focus();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [keySignature]);
// Focus requested for an item that only exists after its parent expands.
const pendingFocus = useRef<string | null>(null);
useLayoutEffect(() => {
const key = pendingFocus.current;
pendingFocus.current = null;
if (key) itemRefs.current.get(key)?.focus();
});
/** Expands a collapsed parent and focuses the item once it is rendered; otherwise does nothing. */
const requestFocusAfterExpand = (itemKey: string, parentKey: string | null): void => {
if (!parentKey || expanded.has(parentKey)) return;
pendingFocus.current = itemKey;
setExpanded(new Set(expanded).add(parentKey));
};
useImperativeHandle(ref, () => ({
focusPart(partId) {
const target = focusTargetOf(state, catalog, partId);
if (!target) return;
const element = itemRefs.current.get(target.key);
if (element) {
element.focus();
return;
}
requestFocusAfterExpand(target.key, target.expand);
},
focusFrame(boxId, slot) {
const key = `${boxId}#${slot}`;
const element = itemRefs.current.get(key);
if (element) {
element.focus();
return;
}
requestFocusAfterExpand(key, boxId);
},
}));
const tabbableKey = nodes.some((node) => node.key === activeKey) ? activeKey : (nodes[0]?.key ?? null);
const toggle = (key: string, open: boolean): void => {
const next = new Set(expanded);
if (open) next.add(key);
else next.delete(key);
setExpanded(next);
};
const focusKey = (key: string | undefined): void => {
if (key) itemRefs.current.get(key)?.focus();
};
const handleFocus = (node: OutlineNode): void => {
focusWithin.current = true;
setActiveKey(node.key);
if (node.kind === "frame") {
view.setHovered(null);
view.setHoveredFrame({ boxId: node.id, slot: node.slot ?? 0 });
} else {
view.setHovered(node.id);
view.setHoveredFrame(null);
}
};
const handleBlur = (event: FocusEvent<HTMLDivElement>): void => {
// A removed item also loses focus; the microtask lets the layout effect see the flag first.
const tree = event.currentTarget.parentElement;
queueMicrotask(() => {
if (!tree?.contains(document.activeElement)) focusWithin.current = false;
});
if (event.currentTarget.parentElement?.contains(event.relatedTarget as Node | null)) return;
view.setHovered(null);
view.setHoveredFrame(null);
};
const activateNode = (node: OutlineNode): void => {
if (node.kind === "action") {
if (canEdit) onEditPart(node.id);
return;
}
if (node.kind === "frame") {
if (canEdit) onEditFrame(node.id, node.slot ?? 0);
return;
}
if (node.isBox) {
view.openBox(node.id);
toggle(node.id, true);
return;
}
if (node.hasState || node.isFloor) {
if (canEdit) onEditPart(node.id);
return;
}
if (node.childCount > 0) toggle(node.key, !expanded.has(node.key));
};
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>, node: OutlineNode, index: number): void => {
const isOpen = expanded.has(node.key);
switch (event.key) {
case "ArrowDown":
focusKey(nodes[index + 1]?.key);
break;
case "ArrowUp":
focusKey(nodes[index - 1]?.key);
break;
case "Home":
focusKey(nodes[0]?.key);
break;
case "End":
focusKey(nodes[nodes.length - 1]?.key);
break;
case "ArrowRight":
if (node.childCount === 0) break;
if (!isOpen) toggle(node.key, true);
else focusKey(nodes[index + 1]?.key);
break;
case "ArrowLeft":
if (node.childCount > 0 && isOpen) toggle(node.key, false);
else if (node.parentKey) focusKey(node.parentKey);
break;
case "Enter":
case " ":
activateNode(node);
break;
default:
return;
}
event.preventDefault();
};
return (
<div>
<div role="tree" aria-label="Aufbau der Beute" className="flex flex-col gap-1">
{nodes.map((node, index) => {
const isOpen = expanded.has(node.key);
const isFrame = node.kind === "frame";
const isCurrentBox = node.isBox && view.openBoxId === node.id;
return (
<div
key={node.key}
ref={(element) => {
if (element) itemRefs.current.set(node.key, element);
else itemRefs.current.delete(node.key);
}}
// aria-current marks the open box; aria-selected would suggest a selection model.
// eslint-disable-next-line jsx-a11y/role-has-required-aria-props
role="treeitem"
aria-level={node.level}
aria-setsize={node.setSize}
aria-posinset={node.position}
aria-expanded={node.childCount > 0 ? isOpen : undefined}
aria-current={isCurrentBox ? "true" : undefined}
tabIndex={node.key === tabbableKey ? 0 : -1}
onFocus={() => handleFocus(node)}
onBlur={handleBlur}
onKeyDown={(event) => handleKeyDown(event, node, index)}
onClick={() => {
focusKey(node.key);
activateNode(node);
}}
style={{ paddingInlineStart: `${0.75 + (node.level - 1) * 1.25}rem` }}
className="flex min-h-11 cursor-pointer items-center gap-1 rounded-lg pe-3 text-sm outline-none hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 aria-[current=true]:bg-muted aria-[current=true]:font-medium"
>
<span className={isFrame ? "text-muted-foreground" : undefined}>
{node.hint ? `${node.name} · ${node.hint}` : node.name}
</span>
</div>
);
})}
</div>
<div role="status" aria-live="polite" className="sr-only">
{announcement}
</div>
</div>
);
}
@@ -0,0 +1,53 @@
"use client";
import type { ComponentProps, ReactNode } from "react";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
export interface HiveSheetShellProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description?: string;
/** Where focus goes once the sheet is closed (Escape, "Schließen" or after saving). */
finalFocus?: ComponentProps<typeof SheetContent>["finalFocus"];
children: ReactNode;
}
/** The bottom sheet frame the hive sheets share: title, German close button, scroll area. */
export function HiveSheetShell({
open,
onOpenChange,
title,
description,
finalFocus,
children,
}: HiveSheetShellProps) {
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="bottom"
// The built-in close button is English; ours is German and 44 px high.
showCloseButton={false}
finalFocus={finalFocus}
className="max-h-[90dvh] gap-0 overflow-y-auto rounded-t-2xl pb-(--safe-area-bottom) sm:mx-auto sm:max-w-lg"
>
<SheetHeader className="flex-row items-start justify-between gap-2">
<div className="flex min-w-0 flex-col gap-0.5">
<SheetTitle>{title}</SheetTitle>
{description ? <SheetDescription>{description}</SheetDescription> : null}
</div>
<SheetClose render={<Button variant="ghost" size="touch" />}>Schließen</SheetClose>
</SheetHeader>
{children}
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,148 @@
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { hiveLayout } from "@/lib/hive/layout";
import { frames, inlineCatalog, part } from "@/lib/hive/test-utils";
import type { HiveState } from "@/lib/hive/types";
import { HiveSvg, projectPoint } from "./hive-svg";
import { createHiveViewStore, HiveViewProvider } from "./use-hive-view";
const catalog = inlineCatalog();
const state: HiveState = {
schemaVersion: 1,
stack: [
part("f", "floor"),
part("b1", "box", {
frames: [
{ slot: 1, type: "BROOD", name: "Königin" },
{ slot: 2, type: "HONEY" },
{ slot: 3, type: "NONE" },
],
inserts: [part("e1", "evaporator")],
}),
part("b2", "box", { frames: frames(10) }),
part("l", "inner-lid"),
part("r", "roof"),
],
};
const layout = hiveLayout(state, catalog);
function renderSvg(ui: ReactNode, store = createHiveViewStore()) {
return { store, ...render(<HiveViewProvider store={store}>{ui}</HiveViewProvider>) };
}
describe("projectPoint", () => {
it("applies the oblique projection with y down", () => {
expect(projectPoint([2, 3, 4])).toEqual([4, -(3 - 0.35 * 4)]);
expect(projectPoint([0, 0, 0])[0]).toBe(0);
});
});
describe("HiveSvg", () => {
it("is an img with a summary label and never crops", () => {
const { container } = renderSvg(<HiveSvg layout={layout} />);
const svg = container.querySelector("svg")!;
expect(svg).toHaveAttribute("role", "img");
expect(svg).toHaveAttribute("preserveAspectRatio", "xMidYMid meet");
expect(svg.getAttribute("aria-label")).toBe("Beute mit 2 Zargen, Boden, Holzdeckel, Dach");
const [x, y, w, h] = svg.getAttribute("viewBox")!.split(" ").map(Number);
expect(w).toBeGreaterThan(0);
expect(h).toBeGreaterThan(0);
expect(Number.isFinite(x + y)).toBe(true);
});
it("fits the viewBox with 5 % padding around the projected bounds", () => {
const { container } = renderSvg(<HiveSvg layout={layout} />);
const [x, , w] = container.querySelector("svg")!.getAttribute("viewBox")!.split(" ").map(Number);
const left = Math.min(...layout.parts.map((p) => p.center[0] - p.size[0] / 2 + 0.5 * (-p.size[2] / 2)));
const right = Math.max(...layout.parts.map((p) => p.center[0] + p.size[0] / 2 + 0.5 * (p.size[2] / 2)));
const width = right - left;
expect(x).toBeCloseTo(left - 0.05 * width, 5);
expect(w).toBeCloseTo(width * 1.1, 5);
});
it("draws one group per part with data-part-id and no frames while no box is open", () => {
const { container } = renderSvg(<HiveSvg layout={layout} />);
const ids = [...container.querySelectorAll("g[data-part-id]")].map((g) => g.getAttribute("data-part-id"));
expect(ids).toEqual(["f", "b1", "b2", "l", "r", "e1"]);
expect(container.querySelectorAll("[data-frame-slot]")).toHaveLength(0);
});
it("is not in the tab order and has no button roles without handlers", () => {
const { container } = renderSvg(<HiveSvg layout={layout} />);
expect(container.querySelectorAll("[role=button]")).toHaveLength(0);
expect(container.querySelectorAll('[tabindex="0"]')).toHaveLength(0);
});
it("makes parts buttons with tabIndex -1 when onSelectPart is given", () => {
const onSelectPart = vi.fn();
const { container } = renderSvg(<HiveSvg layout={layout} onSelectPart={onSelectPart} />);
const group = container.querySelector('g[data-part-id="b2"]')!;
expect(group).toHaveAttribute("role", "button");
expect(group).toHaveAttribute("tabindex", "-1");
fireEvent.click(group);
expect(onSelectPart).toHaveBeenCalledWith("b2");
});
it("draws frames only for the open box, each with its type word as title", () => {
const store = createHiveViewStore();
store.openBox("b1");
const { container } = renderSvg(<HiveSvg layout={layout} />, store);
const slots = [...container.querySelectorAll("[data-frame-slot]")];
expect(slots).toHaveLength(3);
const titles = slots.map((el) => el.querySelector("title")!.textContent);
expect(titles[0]).toContain("Brutwabe");
expect(titles[0]).toContain("Königin");
expect(titles[1]).toContain("Honigwabe");
expect(titles[2]).toContain("leer");
expect(container.querySelectorAll('[data-frame-box="b2"]')).toHaveLength(0);
});
it("namespaces pattern ids and references them from the frames", () => {
const store = createHiveViewStore();
store.openBox("b1");
const { container } = renderSvg(
<>
<HiveSvg layout={layout} />
<HiveSvg layout={layout} />
</>,
store,
);
const ids = [...container.querySelectorAll("pattern")].map((p) => p.id);
expect(new Set(ids).size).toBe(ids.length);
const fill = container.querySelector("[data-frame-slot='1'] rect")!.getAttribute("fill")!;
const target = /url\(#(.+)\)/.exec(fill)![1];
expect(container.querySelector(`pattern[id="${target}"]`)).not.toBeNull();
});
it("selects frames when onSelectFrame is given", () => {
const store = createHiveViewStore();
store.openBox("b1");
const onSelectFrame = vi.fn();
const { container } = renderSvg(<HiveSvg layout={layout} onSelectFrame={onSelectFrame} />, store);
const frame = container.querySelector("[data-frame-slot='2']")!;
expect(frame).toHaveAttribute("role", "button");
expect(frame).toHaveAttribute("tabindex", "-1");
fireEvent.click(frame);
expect(onSelectFrame).toHaveBeenCalledWith("b1", 2);
});
it("uses currentColor based strokes and no hard-coded colours", () => {
const { container } = renderSvg(<HiveSvg layout={layout} />);
expect(container.innerHTML).not.toMatch(/#[0-9a-f]{3,6}\b|rgb\(/i);
expect(container.querySelector("path")?.getAttribute("class")).toContain("stroke-current");
});
it("marks the hovered part", () => {
const store = createHiveViewStore();
store.setHovered("l");
const { container } = renderSvg(<HiveSvg layout={layout} />, store);
expect(container.querySelector('g[data-part-id="l"]')).toHaveAttribute("data-hovered", "true");
expect(container.querySelector('g[data-part-id="r"]')).not.toHaveAttribute("data-hovered");
});
it("renders an empty hive without crashing", () => {
renderSvg(<HiveSvg layout={hiveLayout({ schemaVersion: 1, stack: [] }, catalog)} />);
expect(screen.getByRole("img")).toHaveAttribute("aria-label", "Beute ohne Teile");
});
});
@@ -0,0 +1,287 @@
"use client";
import { useId } from "react";
import type { KeyboardEvent, ReactNode } from "react";
import { FRAME_TYPE_LABELS, PART_KIND_LABELS } from "@/lib/hive/labels";
import type { FrameBox, HiveLayout, PartBox, Vec3 } from "@/lib/hive/layout";
import type { FrameType } from "@/lib/hive/types";
import { useHiveView } from "./use-hive-view";
// SVG view of the hive: fallback without WebGL, print, and the L2 visual.
// Oblique projection of the shared layout (1 unit = 100 mm, y up). Strokes use
// currentColor so forced-colors and print follow the surrounding text colour.
const SKEW_X = 0.5;
const SKEW_Y = 0.35;
const PADDING = 0.05;
const FRAME_FILL_RATIO = 0.9;
const PATTERN_TILE = 0.16;
const EMPTY_VIEW_BOX = "0 0 1 1";
type Point = [number, number];
export function projectPoint([x, y, z]: Vec3): Point {
return [x + SKEW_X * z, -(y - SKEW_Y * z)];
}
const fmt = (value: number): string => Number(value.toFixed(4)).toString();
function polygon(points: Vec3[]): string {
return (
points
.map((point, index) => {
const [x, y] = projectPoint(point);
return `${index === 0 ? "M" : "L"}${fmt(x)} ${fmt(y)}`;
})
.join(" ") + " Z"
);
}
function corners(box: { center: Vec3; size: Vec3 }): Vec3[] {
const [cx, cy, cz] = box.center;
const [w, h, d] = box.size;
const result: Vec3[] = [];
for (const dx of [-1, 1]) for (const dy of [-1, 1]) for (const dz of [-1, 1]) {
result.push([cx + (dx * w) / 2, cy + (dy * h) / 2, cz + (dz * d) / 2]);
}
return result;
}
function viewBoxOf(layout: HiveLayout): string {
const points = [...layout.parts, ...layout.inserts].flatMap((part) => corners(part).map(projectPoint));
if (points.length === 0) return EMPTY_VIEW_BOX;
const xs = points.map(([x]) => x);
const ys = points.map(([, y]) => y);
const minX = Math.min(...xs);
const minY = Math.min(...ys);
const width = Math.max(...xs) - minX;
const height = Math.max(...ys) - minY;
return [minX - PADDING * width, minY - PADDING * height, width * (1 + 2 * PADDING), height * (1 + 2 * PADDING)]
.map(String)
.join(" ");
}
/** "Beute mit 2 Zargen, Boden, Dach": boxes first, then the rest bottom to top. */
function summaryOf(layout: HiveLayout): string {
if (layout.parts.length === 0) return "Beute ohne Teile";
const counts = new Map<string, number>();
for (const part of layout.parts) {
const label = PART_KIND_LABELS[part.kind];
counts.set(label, (counts.get(label) ?? 0) + 1);
}
const boxLabel = PART_KIND_LABELS.BOX;
const labels = [...counts.keys()].sort((a, b) => Number(b === boxLabel) - Number(a === boxLabel));
const items = labels.map((label) => {
const count = counts.get(label) ?? 1;
if (count === 1) return label;
return label === boxLabel ? `${count} Zargen` : `${count}× ${label}`;
});
return `Beute mit ${items.join(", ")}`;
}
function framePitch(frame: FrameBox, all: FrameBox[]): number {
const xs = all.filter((other) => other.boxId === frame.boxId).map((other) => other.center[0]).sort((a, b) => a - b);
const gaps = xs.slice(1).map((x, index) => x - xs[index]);
return gaps.length > 0 ? Math.min(...gaps) : frame.size[0];
}
// ---- Frame patterns: one per type, so the type never rides on colour alone ----
const T = PATTERN_TILE;
const PATTERNS: Record<Exclude<FrameType, "NONE">, ReactNode> = {
BROOD: <circle cx={T / 2} cy={T / 2} r={0.035} className="fill-current stroke-none" />,
DRONE: <circle cx={T / 2} cy={T / 2} r={0.06} className="fill-current stroke-none" />,
HONEY: <path d={`M0 ${T} L${T} 0`} />,
POLLEN: <path d={`M${T / 2} 0 V${T} M0 ${T / 2} H${T}`} />,
FEED: <path d={`M0 ${T / 2} H${T}`} />,
FOUNDATION: <path d={`M${T / 2} 0 V${T}`} />,
EMPTY_COMB: <circle cx={T / 2} cy={T / 2} r={0.05} className="fill-none" />,
DRONE_FRAME: <path d={`M0 0 L${T} ${T} M0 ${T} L${T} 0`} />,
DIVIDER: <rect width={T} height={T} className="fill-current opacity-60 stroke-none" />,
};
const PATTERN_TYPES = Object.keys(PATTERNS) as Exclude<FrameType, "NONE">[];
// Colour comes from the <svg>; only hover changes it, never in print.
const HOVER_COLOR = "data-[hovered=true]:text-holo-edge-strong print:data-[hovered=true]:text-black";
const edge = "fill-none stroke-current [vector-effect:non-scaling-stroke]";
function activate(event: KeyboardEvent, action: () => void): void {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
action();
}
export interface HiveSvgProps {
layout: HiveLayout;
onSelectPart?: (id: string) => void;
onSelectFrame?: (boxId: string, slot: number) => void;
/** Display name per part (see partDisplayName); defaults to the kind label. */
partName?: (id: string) => string;
}
interface PartShapeProps {
part: PartBox;
dashed?: boolean;
name: string;
hovered: boolean;
onSelect?: (id: string) => void;
onHover: (id: string | null) => void;
}
function PartShape({ part, dashed, name, hovered, onSelect, onHover }: PartShapeProps) {
const [cx, cy, cz] = part.center;
const [w, h, d] = part.size;
const [x0, x1, y0, y1, z0, z1] = [cx - w / 2, cx + w / 2, cy - h / 2, cy + h / 2, cz - d / 2, cz + d / 2];
const faces = [
polygon([[x0, y0, z0], [x1, y0, z0], [x1, y1, z0], [x0, y1, z0]]),
polygon([[x0, y1, z0], [x1, y1, z0], [x1, y1, z1], [x0, y1, z1]]),
polygon([[x1, y0, z0], [x1, y0, z1], [x1, y1, z1], [x1, y1, z0]]),
];
const interactive = onSelect
? {
role: "button" as const,
tabIndex: -1,
onClick: () => onSelect(part.id),
onKeyDown: (event: KeyboardEvent) => activate(event, () => onSelect(part.id)),
onPointerEnter: () => onHover(part.id),
onPointerLeave: () => onHover(null),
}
: {};
return (
<g
data-part-id={part.id}
data-hovered={hovered ? "true" : undefined}
className={`${HOVER_COLOR} ${onSelect ? "cursor-pointer" : ""}`}
{...interactive}
>
<title>{name}</title>
{faces.map((d_, index) => (
<path
key={index}
d={d_}
className={`${edge} fill-holo-fill ${dashed ? "[stroke-dasharray:4_3]" : ""}`}
strokeWidth={hovered ? 2.5 : 1.5}
/>
))}
</g>
);
}
interface FrameShapeProps {
frame: FrameBox;
width: number;
patternPrefix: string;
hovered: boolean;
onSelect?: (boxId: string, slot: number) => void;
onHover: (frame: { boxId: string; slot: number } | null) => void;
}
function FrameShape({ frame, width, patternPrefix, hovered, onSelect, onHover }: FrameShapeProps) {
const [cx, cy, cz] = frame.center;
const [, h, d] = frame.size;
const [x, y] = projectPoint([cx - width / 2, cy + h / 2, cz - d / 2]);
const [x2, y2] = projectPoint([cx + width / 2, cy - h / 2, cz - d / 2]);
const title = [`R${frame.slot}`, FRAME_TYPE_LABELS[frame.type], frame.name].filter(Boolean).join(" · ");
const ref = { boxId: frame.boxId, slot: frame.slot };
const interactive = onSelect
? {
role: "button" as const,
tabIndex: -1,
onClick: () => onSelect(frame.boxId, frame.slot),
onKeyDown: (event: KeyboardEvent) => activate(event, () => onSelect(frame.boxId, frame.slot)),
onPointerEnter: () => onHover(ref),
onPointerLeave: () => onHover(null),
}
: {};
return (
<g
data-frame-box={frame.boxId}
data-frame-slot={frame.slot}
data-frame-type={frame.type}
data-hovered={hovered ? "true" : undefined}
className={`${HOVER_COLOR} ${onSelect ? "cursor-pointer" : ""}`}
{...interactive}
>
<title>{title}</title>
<rect
x={x}
y={y}
width={x2 - x}
height={y2 - y}
fill={frame.type === "NONE" ? "none" : `url(#${patternPrefix}-${frame.type})`}
className={`${edge} ${frame.type === "NONE" ? "[stroke-dasharray:2_3]" : ""}`}
strokeWidth={hovered ? 2.5 : 1}
/>
</g>
);
}
export function HiveSvg({ layout, onSelectPart, onSelectFrame, partName }: HiveSvgProps) {
const view = useHiveView();
const patternPrefix = `hive${useId().replace(/[^a-zA-Z0-9]/g, "")}`;
const nameOf = (part: PartBox): string => partName?.(part.id) ?? PART_KIND_LABELS[part.kind];
const frames = view.openBoxId ? layout.frames.filter((frame) => frame.boxId === view.openBoxId) : [];
const hoveredFrame = view.hoveredFrame;
return (
<svg
role="img"
aria-label={summaryOf(layout)}
viewBox={viewBoxOf(layout)}
preserveAspectRatio="xMidYMid meet"
className="block h-full w-full bg-holo-bg text-holo-edge forced-colors:bg-[Canvas] forced-colors:text-[CanvasText] print:bg-transparent print:text-black"
>
{frames.length > 0 ? (
<defs>
{PATTERN_TYPES.map((type) => (
<pattern
key={type}
id={`${patternPrefix}-${type}`}
width={PATTERN_TILE}
height={PATTERN_TILE}
patternUnits="userSpaceOnUse"
className="fill-none stroke-current"
strokeWidth={0.02}
>
{PATTERNS[type]}
</pattern>
))}
</defs>
) : null}
{layout.parts.map((part) => (
<PartShape
key={part.id}
part={part}
name={nameOf(part)}
hovered={view.hoveredId === part.id}
onSelect={onSelectPart}
onHover={view.setHovered}
/>
))}
{layout.inserts.map((insert) => (
<PartShape
key={insert.id}
part={insert}
dashed
name={nameOf(insert)}
hovered={view.hoveredId === insert.id}
onSelect={onSelectPart}
onHover={view.setHovered}
/>
))}
{frames.map((frame) => (
<FrameShape
key={`${frame.boxId}-${frame.slot}`}
frame={frame}
width={framePitch(frame, frames) * FRAME_FILL_RATIO}
patternPrefix={patternPrefix}
hovered={hoveredFrame?.boxId === frame.boxId && hoveredFrame.slot === frame.slot}
onSelect={onSelectFrame}
onHover={view.setHoveredFrame}
/>
))}
</svg>
);
}
@@ -0,0 +1,21 @@
"use client";
import type { HiveLayout } from "@/lib/hive/layout";
import type { HiveCatalog } from "@/lib/hive/types";
import { HiveSvg } from "./hive-svg";
export interface HiveVisualSlotProps {
layout: HiveLayout;
catalog: HiveCatalog;
partName: (id: string) => string;
onSelectPart: (id: string) => void;
onSelectFrame: (boxId: string, slot: number) => void;
}
/**
* The model of the hive. L2 shows the SVG; the 3D scene (with the SVG as
* fallback) replaces this body without changing the props.
*/
export function HiveVisualSlot({ layout, partName, onSelectPart, onSelectFrame }: HiveVisualSlotProps) {
return <HiveSvg layout={layout} partName={partName} onSelectPart={onSelectPart} onSelectFrame={onSelectFrame} />;
}
@@ -0,0 +1,332 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useRef, useState } from "react";
import { ApiError } from "@/lib/auth/api-client";
import { applyHiveOps } from "@/lib/hive/ops";
import { frames, inlineCatalog, part } from "@/lib/hive/test-utils";
import type { HiveOp, HiveState } from "@/lib/hive/types";
import { HiveOutline } from "./hive-outline";
import type { HiveOutlineHandle } from "./hive-outline";
import { PartSheet } from "./part-sheet";
import { createHiveViewStore, HiveViewProvider } from "./use-hive-view";
const catalog = inlineCatalog();
const baseState = (): HiveState => ({
schemaVersion: 1,
stack: [
part("f", "floor"),
part("b", "box", { frames: frames(10) }),
part("fe", "feeder", { state: { syrupLiters: 1, fondantKg: 0 } }),
part("l", "inner-lid", { state: { feedHole: "OPEN" } }),
part("r", "roof"),
],
});
interface HarnessProps {
partId: string;
initialState?: HiveState;
submit?: (ops: HiveOp[]) => Promise<unknown>;
startWithEvaporator?: boolean;
}
/** Applies submitted ops to its own state, like the optimistic cache does. */
function Harness({ partId, initialState = baseState(), submit, startWithEvaporator }: HarnessProps) {
const [state, setState] = useState(initialState);
const [open, setOpen] = useState(true);
const outlineRef = useRef<HiveOutlineHandle>(null);
const [store] = useState(createHiveViewStore);
const send = async (ops: HiveOp[]) => {
await submit?.(ops);
const result = applyHiveOps(state, ops, catalog);
if (result.ok) setState(result.state);
};
return (
<HiveViewProvider store={store}>
<HiveOutline
ref={outlineRef}
state={state}
catalog={catalog}
canEdit
announcement=""
onEditPart={() => {}}
onEditFrame={() => {}}
/>
<PartSheet
open={open}
onOpenChange={setOpen}
state={state}
catalog={catalog}
partId={partId}
submit={send}
outlineRef={outlineRef}
startWithEvaporator={startWithEvaporator}
/>
</HiveViewProvider>
);
}
const save = () => screen.getByRole("button", { name: "Speichern" });
const dialog = () => screen.getByRole("dialog");
describe("PartSheet", () => {
it("is titled with the part's display name", () => {
render(<Harness partId="fe" />);
expect(screen.getByRole("dialog", { name: "Futterzarge" })).toBeInTheDocument();
});
it("shows one control per state field", () => {
render(<Harness partId="fe" />);
expect(within(dialog()).getByLabelText("Flüssigfutter")).toHaveValue("1");
expect(within(dialog()).getByLabelText("Futterteig")).toHaveValue("0");
expect(within(dialog()).getAllByRole("textbox")).toHaveLength(2);
});
it("shows a choice field as a radio group", () => {
render(<Harness partId="l" />);
expect(screen.getByRole("radiogroup", { name: "Futterloch" })).toBeInTheDocument();
expect(screen.getByRole("radio", { name: "offen" })).toBeChecked();
});
it("keeps Speichern disabled until something changed", () => {
render(<Harness partId="fe" />);
expect(save()).toBeDisabled();
});
it("saves only the changed level as setState", async () => {
const submit = vi.fn().mockResolvedValue(undefined);
const user = userEvent.setup();
render(<Harness partId="fe" submit={submit} />);
const field = within(dialog()).getByLabelText("Flüssigfutter");
await user.clear(field);
await user.type(field, "3");
await user.click(save());
expect(submit).toHaveBeenCalledWith([{ op: "setState", id: "fe", key: "syrupLiters", value: 3 }]);
});
it("takes a German decimal comma", async () => {
const submit = vi.fn().mockResolvedValue(undefined);
const user = userEvent.setup();
render(<Harness partId="fe" submit={submit} />);
const field = within(dialog()).getByLabelText("Flüssigfutter");
await user.clear(field);
await user.type(field, "2,5");
await user.click(save());
expect(submit).toHaveBeenCalledWith([{ op: "setState", id: "fe", key: "syrupLiters", value: 2.5 }]);
});
it("blocks saving a level off the step grid and says so", async () => {
const user = userEvent.setup();
render(<Harness partId="fe" />);
const field = within(dialog()).getByLabelText("Flüssigfutter");
await user.clear(field);
await user.type(field, "2,3");
expect(screen.getByText("Nur Schritte von 0,5.")).toBeInTheDocument();
expect(save()).toBeDisabled();
});
it("blocks saving an empty level", async () => {
const user = userEvent.setup();
render(<Harness partId="fe" />);
await user.clear(within(dialog()).getByLabelText("Flüssigfutter"));
expect(screen.getByText("Bitte einen Wert angeben.")).toBeInTheDocument();
expect(save()).toBeDisabled();
});
it("keeps the level at its maximum when more is typed", async () => {
const user = userEvent.setup();
render(<Harness partId="fe" />);
const field = within(dialog()).getByLabelText("Flüssigfutter");
await user.clear(field);
await user.type(field, "9");
expect(field).toHaveValue("3");
expect(save()).toBeEnabled();
});
it("saves a chosen option of a choice field", async () => {
const submit = vi.fn().mockResolvedValue(undefined);
const user = userEvent.setup();
render(<Harness partId="l" submit={submit} />);
await user.click(screen.getByRole("radio", { name: "zu" }));
await user.click(save());
expect(submit).toHaveBeenCalledWith([{ op: "setState", id: "l", key: "feedHole", value: "CLOSED" }]);
});
it("shows an error inline and stays open when saving fails", async () => {
const submit = vi.fn().mockRejectedValue(new ApiError(500, "kaputt", null));
const user = userEvent.setup();
render(<Harness partId="l" submit={submit} />);
await user.click(screen.getByRole("radio", { name: "zu" }));
await user.click(save());
expect(await screen.findByRole("alert")).toHaveTextContent("Speichern fehlgeschlagen");
expect(dialog()).toBeInTheDocument();
});
it("closes on a conflict so the conflict sheet can take over", async () => {
const submit = vi.fn().mockRejectedValue(
new ApiError(409, "Konflikt", { code: "HIVE_CONFLICT", opIndex: 0, reason: "x", current: {} }),
);
const user = userEvent.setup();
render(<Harness partId="l" submit={submit} />);
await user.click(screen.getByRole("radio", { name: "zu" }));
await user.click(save());
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
});
it("returns focus to the edited part, not to the body", async () => {
const user = userEvent.setup();
render(<Harness partId="l" />);
await user.click(screen.getByRole("radio", { name: "zu" }));
await user.click(save());
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
await waitFor(() => expect(screen.getByRole("treeitem", { name: /Holzdeckel/ })).toHaveFocus());
expect(document.body).not.toHaveFocus();
});
describe("floor", () => {
it("offers Bausperre drin / draußen and inserts the active floor insert type", async () => {
const submit = vi.fn().mockResolvedValue(undefined);
const user = userEvent.setup();
render(<Harness partId="f" submit={submit} />);
expect(screen.getByRole("radiogroup", { name: "Bausperre" })).toBeInTheDocument();
expect(screen.getByRole("radio", { name: "draußen" })).toBeChecked();
await user.click(screen.getByRole("radio", { name: "drin" }));
await user.click(save());
expect(submit).toHaveBeenCalledWith([
{
op: "addInsert",
hostId: "f",
part: { id: expect.any(String), partTypeId: "floor-insert", state: {} },
},
]);
});
it("removes an existing Bausperre", async () => {
const withInsert = baseState();
withInsert.stack[0] = part("f", "floor", { inserts: [part("fi", "floor-insert")] });
const submit = vi.fn().mockResolvedValue(undefined);
const user = userEvent.setup();
render(<Harness partId="f" initialState={withInsert} submit={submit} />);
expect(screen.getByRole("radio", { name: "drin" })).toBeChecked();
await user.click(screen.getByRole("radio", { name: "draußen" }));
await user.click(save());
expect(submit).toHaveBeenCalledWith([{ op: "removePart", id: "fi" }]);
});
});
describe("box", () => {
it("inserts an evaporator with the chosen acid level", async () => {
const submit = vi.fn().mockResolvedValue(undefined);
const user = userEvent.setup();
render(<Harness partId="b" submit={submit} />);
expect(screen.queryByLabelText("Säure")).toBeNull();
await user.click(screen.getByRole("radio", { name: "Verdunster einsetzen" }));
const acid = within(dialog()).getByLabelText("Säure");
await user.clear(acid);
await user.type(acid, "250");
await user.click(save());
expect(submit).toHaveBeenCalledWith([
{
op: "addInsert",
hostId: "b",
part: { id: expect.any(String), partTypeId: "evaporator", state: { acidMl: 250 } },
},
]);
});
it("blocks saving an evaporator whose acid is off the step grid", async () => {
const user = userEvent.setup();
render(<Harness partId="b" />);
await user.click(screen.getByRole("radio", { name: "Verdunster einsetzen" }));
const acid = within(dialog()).getByLabelText("Säure");
await user.clear(acid);
await user.type(acid, "255");
expect(save()).toBeDisabled();
});
it("preselects the evaporator when opened from the outline action", () => {
render(<Harness partId="b" startWithEvaporator />);
expect(screen.getByRole("radio", { name: "Verdunster einsetzen" })).toBeChecked();
expect(within(dialog()).getByLabelText("Säure")).toBeInTheDocument();
});
it("offers no insert when the box already has an evaporator", () => {
const withEvaporator = baseState();
withEvaporator.stack[1] = part("b", "box", {
frames: frames(10),
inserts: [part("ev", "evaporator", { state: { acidMl: 100 } })],
});
render(<Harness partId="b" initialState={withEvaporator} />);
expect(screen.queryByRole("radio", { name: "Verdunster einsetzen" })).toBeNull();
});
it("moves focus to the new evaporator item after inserting", async () => {
const user = userEvent.setup();
render(<Harness partId="b" />);
await user.click(screen.getByRole("radio", { name: "Verdunster einsetzen" }));
const acid = within(dialog()).getByLabelText("Säure");
await user.clear(acid);
await user.type(acid, "100");
await user.click(save());
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
await waitFor(() => expect(screen.getByRole("treeitem", { name: /Verdunster/ })).toHaveFocus());
expect(document.body).not.toHaveFocus();
});
});
describe("evaporator", () => {
const withEvaporator = (): HiveState => {
const s = baseState();
s.stack[1] = part("b", "box", {
frames: frames(10),
inserts: [part("ev", "evaporator", { state: { acidMl: 100 } })],
});
return s;
};
it("edits the acid level", async () => {
const submit = vi.fn().mockResolvedValue(undefined);
const user = userEvent.setup();
render(<Harness partId="ev" initialState={withEvaporator()} submit={submit} />);
const acid = within(dialog()).getByLabelText("Säure");
await user.clear(acid);
await user.type(acid, "200");
await user.click(save());
expect(submit).toHaveBeenCalledWith([{ op: "setState", id: "ev", key: "acidMl", value: 200 }]);
});
it("asks before removing, then removes and focuses the box", async () => {
const submit = vi.fn().mockResolvedValue(undefined);
const user = userEvent.setup();
render(<Harness partId="ev" initialState={withEvaporator()} submit={submit} />);
await user.click(screen.getByRole("button", { name: "Verdunster entfernen" }));
expect(submit).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "Ja, entfernen" }));
expect(submit).toHaveBeenCalledWith([{ op: "removePart", id: "ev" }]);
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
await waitFor(() => expect(screen.getByRole("treeitem", { name: /Zarge/ })).toHaveFocus());
expect(document.body).not.toHaveFocus();
});
it("moves focus into the confirmation, labels it as a group and returns focus on cancel", async () => {
const user = userEvent.setup();
render(<Harness partId="ev" initialState={withEvaporator()} submit={vi.fn()} />);
await user.click(screen.getByRole("button", { name: "Verdunster entfernen" }));
const group = screen.getByRole("group", { name: "Verdunster wirklich entfernen?" });
expect(within(group).getByText("Verdunster wirklich entfernen?")).toHaveFocus();
await user.click(screen.getByRole("button", { name: "Abbrechen" }));
expect(screen.getByRole("button", { name: "Verdunster entfernen" })).toHaveFocus();
});
it("keeps the evaporator when the confirmation is cancelled", async () => {
const submit = vi.fn();
const user = userEvent.setup();
render(<Harness partId="ev" initialState={withEvaporator()} submit={submit} />);
await user.click(screen.getByRole("button", { name: "Verdunster entfernen" }));
await user.click(screen.getByRole("button", { name: "Abbrechen" }));
expect(submit).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: "Verdunster entfernen" })).toBeInTheDocument();
});
});
});
@@ -0,0 +1,295 @@
"use client";
import { useEffect, useRef, useState } from "react";
import type { RefObject } from "react";
import { ChoiceGroup } from "@/components/form/choice-group";
import { Button } from "@/components/ui/button";
import { SheetFooter } from "@/components/ui/sheet";
import { partDisplayName } from "@/lib/hive/names";
import { newIdempotencyKey } from "@/lib/offline/idempotency";
import type { CatalogPartType, HiveCatalog, HiveOp, HivePart, HivePartKind, HiveState } from "@/lib/hive/types";
import type { HiveOutlineHandle } from "./hive-outline";
import { HiveSheetShell } from "./hive-sheet-shell";
import {
areDraftFieldsValid,
draftOf,
draftValue,
isFieldChanged,
StateFieldControl,
} from "./state-field-control";
import type { FieldDraft } from "./state-field-control";
import { useSheetSubmit } from "./use-sheet-submit";
import type { SubmitOps } from "./use-sheet-submit";
const INSERT_OPTIONS = [
{ value: true, label: "drin" },
{ value: false, label: "draußen" },
];
const EVAPORATOR_OPTIONS = [
{ value: false, label: "Kein Verdunster" },
{ value: true, label: "Verdunster einsetzen" },
];
export interface PartSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
state: HiveState;
catalog: HiveCatalog;
partId: string;
/** Sends the ops (source CARD); rejects on failure. */
submit: SubmitOps;
/** On close, focus returns to the edited part's item in the outline. */
outlineRef: RefObject<HiveOutlineHandle | null>;
/** Opens a box with the evaporator already chosen (the outline's „Verdunster einsetzen“ action). */
startWithEvaporator?: boolean;
}
interface Located {
part: HivePart;
/** The box or floor an insert sits in. */
host: HivePart | null;
}
function locate(state: HiveState, partId: string): Located | null {
for (const part of state.stack) {
if (part.id === partId) return { part, host: null };
const insert = (part.inserts ?? []).find((candidate) => candidate.id === partId);
if (insert) return { part: insert, host: part };
}
return null;
}
function activeTypeOf(catalog: HiveCatalog, kind: HivePartKind): CatalogPartType | undefined {
return [...catalog.partTypes.values()].find((type) => type.kind === kind && type.active);
}
function insertOfKind(part: HivePart, catalog: HiveCatalog, kind: HivePartKind): HivePart | undefined {
return (part.inserts ?? []).find((insert) => catalog.partTypes.get(insert.partTypeId)?.kind === kind);
}
function newPart(id: string, type: CatalogPartType, draft: FieldDraft): HivePart {
const state: Record<string, number | string> = {};
for (const field of type.stateSchema) state[field.key] = draftValue(field, draft[field.key] ?? "");
return { id, partTypeId: type.id, state };
}
interface PartFormProps {
located: Located;
type: CatalogPartType;
catalog: HiveCatalog;
startWithEvaporator: boolean;
submit: SubmitOps;
/** `focusId` is the part whose item should get focus once the sheet is closed. */
onDone: (focusId: string) => void;
}
function PartForm({ located, type, catalog, startWithEvaporator, submit, onDone }: PartFormProps) {
const { part, host } = located;
const floorInsertType = type.kind === "FLOOR" ? activeTypeOf(catalog, "FLOOR_INSERT") : undefined;
const currentFloorInsert = type.kind === "FLOOR" ? insertOfKind(part, catalog, "FLOOR_INSERT") : undefined;
const evaporatorType =
type.kind === "BOX" && !insertOfKind(part, catalog, "EVAPORATOR") ? activeTypeOf(catalog, "EVAPORATOR") : undefined;
const [draft, setDraft] = useState<FieldDraft>(() => draftOf(type, part.state));
const [wantsFloorInsert, setWantsFloorInsert] = useState(currentFloorInsert !== undefined);
const [wantsEvaporator, setWantsEvaporator] = useState(startWithEvaporator && evaporatorType !== undefined);
const [evaporatorDraft, setEvaporatorDraft] = useState<FieldDraft>(() =>
evaporatorType ? draftOf(evaporatorType, {}) : {},
);
const [confirmingRemoval, setConfirmingRemoval] = useState(false);
const questionRef = useRef<HTMLParagraphElement>(null);
const removeButtonRef = useRef<HTMLButtonElement>(null);
const wasConfirming = useRef(false);
// Keyboard and screen reader users follow the confirmation, and get back to the button on cancel.
useEffect(() => {
if (confirmingRemoval) questionRef.current?.focus();
else if (wasConfirming.current) removeButtonRef.current?.focus();
wasConfirming.current = confirmingRemoval;
}, [confirmingRemoval]);
const { send, pending, error } = useSheetSubmit(submit);
// Fixed per form, so a retry after an error sends the same ids.
const [newIds] = useState(() => ({ floorInsert: newIdempotencyKey(), evaporator: newIdempotencyKey() }));
const changedStateOps: HiveOp[] = type.stateSchema.flatMap((field) =>
isFieldChanged(field, draft[field.key] ?? "", part.state[field.key])
? [{ op: "setState" as const, id: part.id, key: field.key, value: draftValue(field, draft[field.key]) }]
: [],
);
const ops: HiveOp[] = [...changedStateOps];
if (floorInsertType && wantsFloorInsert && !currentFloorInsert) {
ops.push({ op: "addInsert", hostId: part.id, part: newPart(newIds.floorInsert, floorInsertType, {}) });
} else if (currentFloorInsert && !wantsFloorInsert) {
ops.push({ op: "removePart", id: currentFloorInsert.id });
}
const evaporatorToInsert = evaporatorType && wantsEvaporator ? newPart(newIds.evaporator, evaporatorType, evaporatorDraft) : null;
if (evaporatorToInsert) ops.push({ op: "addInsert", hostId: part.id, part: evaporatorToInsert });
const valid =
areDraftFieldsValid(type, draft) &&
(!evaporatorType || !wantsEvaporator || areDraftFieldsValid(evaporatorType, evaporatorDraft));
async function save() {
// After inserting an evaporator, focus goes to the new item.
const outcome = await send(ops);
if (outcome !== "failed") onDone(outcome === "saved" ? (evaporatorToInsert?.id ?? part.id) : part.id);
}
async function remove() {
// An evaporator is an insert: focus goes to its box afterwards.
const outcome = await send([{ op: "removePart", id: part.id }]);
if (outcome !== "failed") onDone(outcome === "saved" ? (host?.id ?? part.id) : part.id);
}
return (
<form
className="flex flex-col gap-4 px-4 pb-4"
onSubmit={(event) => {
event.preventDefault();
if (ops.length > 0 && valid && !pending) void save();
}}
>
{type.stateSchema.map((field) => (
<StateFieldControl
key={field.key}
field={field}
idPrefix={`part-${part.id}`}
value={draft[field.key] ?? ""}
onChange={(value) => setDraft((current) => ({ ...current, [field.key]: value }))}
/>
))}
{floorInsertType || currentFloorInsert ? (
<div className="flex flex-col gap-1.5">
<span id={`part-${part.id}-insert`} className="text-sm font-medium">
Bausperre
</span>
<ChoiceGroup
options={INSERT_OPTIONS}
value={wantsFloorInsert}
onChange={setWantsFloorInsert}
labelledBy={`part-${part.id}-insert`}
className="grid-cols-2"
/>
</div>
) : null}
{evaporatorType ? (
<div className="flex flex-col gap-3">
<span id={`part-${part.id}-evaporator`} className="text-sm font-medium">
Verdunster
</span>
<ChoiceGroup
options={EVAPORATOR_OPTIONS}
value={wantsEvaporator}
onChange={setWantsEvaporator}
labelledBy={`part-${part.id}-evaporator`}
className="grid-cols-2"
/>
{wantsEvaporator
? evaporatorType.stateSchema.map((field) => (
<StateFieldControl
key={field.key}
field={field}
idPrefix={`part-${part.id}-new-evaporator`}
value={evaporatorDraft[field.key] ?? ""}
onChange={(value) => setEvaporatorDraft((current) => ({ ...current, [field.key]: value }))}
/>
))
: null}
</div>
) : null}
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
) : null}
<SheetFooter className="p-0">
<Button type="submit" size="touch" disabled={ops.length === 0 || !valid || pending}>
Speichern
</Button>
{type.kind === "EVAPORATOR" ? (
confirmingRemoval ? (
<div
role="group"
aria-labelledby={`part-${part.id}-confirm`}
className="flex flex-col gap-2 rounded-lg border border-destructive/40 p-3"
>
<p id={`part-${part.id}-confirm`} ref={questionRef} tabIndex={-1} className="text-sm outline-none">
Verdunster wirklich entfernen?
</p>
<div className="grid grid-cols-2 gap-2">
<Button type="button" variant="destructive" size="touch" disabled={pending} onClick={() => void remove()}>
Ja, entfernen
</Button>
<Button type="button" variant="outline" size="touch" onClick={() => setConfirmingRemoval(false)}>
Abbrechen
</Button>
</div>
</div>
) : (
<Button
ref={removeButtonRef}
type="button"
variant="destructive"
size="touch"
onClick={() => setConfirmingRemoval(true)}
>
Verdunster entfernen
</Button>
)
) : null}
</SheetFooter>
</form>
);
}
/**
* Edit one part: a control per state field, the floor's Bausperre, a box's
* evaporator (spec 9.4). Everything changed goes out as one batch of ops.
*/
export function PartSheet({
open,
onOpenChange,
state,
catalog,
partId,
submit,
outlineRef,
startWithEvaporator = false,
}: PartSheetProps) {
const located = locate(state, partId);
const type = located ? catalog.partTypes.get(located.part.partTypeId) : undefined;
// Set by a save that changes what should get focus (a new evaporator, the box of a removed one).
const focusAfterSave = useRef<string | null>(null);
return (
<HiveSheetShell
open={open}
onOpenChange={onOpenChange}
title={partDisplayName(state, catalog, partId)}
finalFocus={() => {
outlineRef.current?.focusPart(focusAfterSave.current ?? partId);
focusAfterSave.current = null;
return false;
}}
>
{located && type ? (
<PartForm
key={partId}
located={located}
type={type}
catalog={catalog}
startWithEvaporator={startWithEvaporator}
submit={submit}
onDone={(id) => {
focusAfterSave.current = id;
onOpenChange(false);
}}
/>
) : (
<p role="alert" className="px-4 pb-4 text-sm text-destructive">
Dieses Teil gibt es nicht mehr.
</p>
)}
</HiveSheetShell>
);
}
@@ -0,0 +1,133 @@
"use client";
import { ChoiceGroup } from "@/components/form/choice-group";
import { NumberStepper } from "@/components/form/number-stepper";
import { LEVEL_TOLERANCE } from "@/lib/hive/types";
import type { CatalogPartType, ChoiceField, LevelField, StateField } from "@/lib/hive/types";
const numberFormat = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 3 });
/** Digits after the decimal point of a step: 0.5 -> 1, 10 -> 0. */
export function decimalsOf(step: number): number {
const text = String(step);
const dot = text.indexOf(".");
return dot < 0 ? 0 : text.length - dot - 1;
}
/** Draft text per state field key ("2.5" for levels, the option for choices). */
export type FieldDraft = Record<string, string>;
export function draftOf(type: CatalogPartType, state: Record<string, number | string>): FieldDraft {
const draft: FieldDraft = {};
for (const field of type.stateSchema) {
const value = state[field.key];
draft[field.key] = value === undefined ? defaultText(field) : String(value);
}
return draft;
}
function defaultText(field: StateField): string {
return field.kind === "level" ? "0" : (field.options[0] ?? "");
}
/** Empty, out of 0..max or off the step grid: the value must not be saved. */
export function isLevelValid(raw: string, field: LevelField): boolean {
return levelProblem(raw, field) === null && !isOffGrid(raw, field);
}
function isOffGrid(raw: string, field: LevelField): boolean {
const position = Number(raw) / field.step;
return Math.abs(position - Math.round(position)) > LEVEL_TOLERANCE;
}
/** Message for an empty or out-of-range level; the stepper words the grid error itself. */
export function levelProblem(raw: string, field: LevelField): string | null {
const value = raw === "" ? Number.NaN : Number(raw);
if (Number.isNaN(value)) return "Bitte einen Wert angeben.";
if (value < 0 || value > field.max) return `Nur Werte von 0 bis ${numberFormat.format(field.max)}.`;
return null;
}
export function areDraftFieldsValid(type: CatalogPartType, draft: FieldDraft): boolean {
return type.stateSchema.every((field) => field.kind === "choice" || isLevelValid(draft[field.key] ?? "", field));
}
/** The value as the API takes it: a number for levels, the option for choices. */
export function draftValue(field: StateField, raw: string): number | string {
return field.kind === "level" ? Number(raw) : raw;
}
export function isFieldChanged(field: StateField, raw: string, current: number | string | undefined): boolean {
if (field.kind === "choice") return raw !== String(current ?? defaultText(field));
return Math.abs(Number(raw) - Number(current ?? 0)) > LEVEL_TOLERANCE;
}
interface StateFieldControlProps {
field: StateField;
idPrefix: string;
value: string;
onChange: (value: string) => void;
}
function LevelControl({ field, idPrefix, value, onChange }: StateFieldControlProps & { field: LevelField }) {
const id = `${idPrefix}-${field.key}`;
const problem = levelProblem(value, field);
return (
<div className="flex flex-col gap-1.5">
<label htmlFor={id} className="text-sm font-medium">
{field.label}
</label>
<NumberStepper
id={id}
value={value}
onChange={onChange}
min={0}
max={field.max}
step={field.step}
decimals={decimalsOf(field.step)}
unitLabel={field.unit}
decrementLabel={`${field.label} verringern`}
incrementLabel={`${field.label} erhöhen`}
invalid={problem !== null}
describedBy={problem ? `${id}-problem` : undefined}
/>
{problem ? (
<p id={`${id}-problem`} className="text-xs text-destructive">
{problem}
</p>
) : null}
</div>
);
}
function ChoiceControl({ field, idPrefix, value, onChange }: StateFieldControlProps & { field: ChoiceField }) {
const id = `${idPrefix}-${field.key}`;
const options = field.options.map((option, index) => ({
value: option,
label: field.optionLabels[index] ?? option,
}));
return (
<div className="flex flex-col gap-1.5">
<span id={`${id}-label`} className="text-sm font-medium">
{field.label}
</span>
<ChoiceGroup
options={options}
value={value}
onChange={onChange}
labelledBy={`${id}-label`}
className="grid-cols-2"
/>
</div>
);
}
/** One control for one `stateSchema` field: a stepper for levels, a choice group for choices. */
export function StateFieldControl(props: StateFieldControlProps) {
const { field } = props;
return field.kind === "level" ? (
<LevelControl {...props} field={field} />
) : (
<ChoiceControl {...props} field={field} />
);
}
@@ -0,0 +1,102 @@
import { deriveHiveFacts } from "@/lib/hive/facts";
import { frames, inlineCatalog, part } from "@/lib/hive/test-utils";
import type {
HiveChangeListItem,
HiveSetupView,
HiveState,
HiveSystemDto,
} from "@/lib/hive/types";
import type { Colony } from "@/lib/colonies/types";
// Shared by the hive card, init and history specs.
export const SYSTEM_ID = "sys";
export function systemDto(): HiveSystemDto {
const catalog = inlineCatalog();
return {
id: SYSTEM_ID,
name: "Zander nach Dr. Liebig",
frameWidthMm: 477,
frameHeightMm: 220,
footprintLMm: 525,
footprintWMm: 430,
template: {},
active: true,
createdAt: "2026-10-01T00:00:00.000Z",
updatedAt: "2026-10-01T00:00:00.000Z",
partTypes: [...catalog.partTypes.values()].map((row) => ({
...row,
systemId: SYSTEM_ID,
articleRef: null,
boxDepth: null,
createdAt: "2026-10-01T00:00:00.000Z",
updatedAt: "2026-10-01T00:00:00.000Z",
})),
};
}
/** Two boxes with 3 brood frames each, two feeders with 5 l together, a floor insert. */
export function sampleState(): HiveState {
return {
schemaVersion: 1,
stack: [
part("f", "floor", { inserts: [part("fi", "floor-insert")] }),
part("b1", "box", { frames: frames(10).map((frame) => (frame.slot <= 3 ? { ...frame, type: "BROOD" as const } : frame)) }),
part("b2", "box", { frames: frames(10).map((frame) => (frame.slot <= 3 ? { ...frame, type: "BROOD" as const } : frame)) }),
part("fe", "feeder", { state: { syrupLiters: 3, fondantKg: 0 } }),
part("fe2", "feeder", { state: { syrupLiters: 2, fondantKg: 0 } }),
part("r", "roof"),
],
};
}
export function setupView(state: HiveState = sampleState(), colonyId = "colony-1", version = 3): HiveSetupView {
return {
colonyId,
systemId: SYSTEM_ID,
version,
state,
facts: deriveHiveFacts(state, inlineCatalog()),
updatedAt: "2026-10-12T10:00:00.000Z",
};
}
export function colonyFixture(extra: Partial<Colony> = {}): Colony {
return {
id: "colony-1",
code: "V-01",
apiaryId: "apiary-1",
hiveType: null,
originText: null,
colonyType: "PRODUCTION_COLONY",
status: "ACTIVE",
strength: null,
occupiedFrames: null,
broodStatus: null,
feedStatus: null,
healthStatus: null,
notes: null,
currentQueenId: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
deactivatedAt: null,
...extra,
};
}
export function historyItem(extra: Partial<HiveChangeListItem> = {}): HiveChangeListItem {
return {
id: "c1",
version: 3,
occurredOn: "2026-10-12",
source: "INSPECTION",
inspectionId: "i1",
userId: "u1",
userName: "Nick",
ops: [{ op: "setState", id: "fe", key: "syrupLiters", value: 3 }],
note: null,
createdAt: "2026-10-12T12:00:00.000Z",
...extra,
};
}
@@ -0,0 +1,389 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { toast } from "sonner";
import { ApiError } from "@/lib/auth/api-client";
import { hiveKeys, postHiveChange } from "@/lib/hive/api";
import { deriveHiveFacts } from "@/lib/hive/facts";
import { frames, inlineCatalog, part } from "@/lib/hive/test-utils";
import type { HiveOp, HiveSetupView, HiveState } from "@/lib/hive/types";
import { fetchColonyTreatments } from "@/lib/treatments/api";
import { HIVE_BUSY_MESSAGE, useHiveMutation } from "./use-hive-mutation";
const push = vi.fn();
vi.mock("next/navigation", () => ({ useRouter: () => ({ push }) }));
vi.mock("sonner", () => ({ toast: vi.fn() }));
vi.mock("@/lib/treatments/api", () => ({ fetchColonyTreatments: vi.fn() }));
vi.mock("@/lib/hive/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/hive/api")>()),
postHiveChange: vi.fn(),
}));
const catalog = inlineCatalog();
const COLONY = "colony-1";
const state = (feederLevel = 2): HiveState => ({
schemaVersion: 1,
stack: [
part("f", "floor"),
part("b", "box", { frames: frames(10) }),
part("fe", "feeder", { state: { syrupLiters: feederLevel, fondantKg: 0 } }),
part("r", "roof"),
],
});
const view = (s: HiveState, version = 1): HiveSetupView => ({
colonyId: COLONY,
systemId: "sys",
version,
state: s,
facts: deriveHiveFacts(s, catalog),
updatedAt: "2026-10-15T10:00:00.000Z",
});
const refill: HiveOp[] = [{ op: "setState", id: "fe", key: "syrupLiters", value: 3 }];
function conflictError(current: HiveState, reason = "MISSING_ID: no part with id x"): ApiError {
return new ApiError(409, "Konflikt", {
code: "HIVE_CONFLICT",
opIndex: 0,
reason,
current: { version: 5, state: current },
});
}
function setup() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
client.setQueryData(hiveKeys.detail(COLONY), view(state()));
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);
const hook = renderHook(() => useHiveMutation(COLONY, catalog), { wrapper });
const cached = () => client.getQueryData<HiveSetupView>(hiveKeys.detail(COLONY));
return { client, hook, cached };
}
beforeEach(() => {
vi.mocked(postHiveChange).mockReset();
vi.mocked(toast).mockReset();
vi.mocked(fetchColonyTreatments).mockReset().mockResolvedValue([]);
push.mockReset();
});
afterEach(() => vi.useRealTimers());
describe("useHiveMutation", () => {
it("shows the result in the cache before the server answers, then stores the answer", async () => {
let resolve: (v: HiveSetupView) => void = () => undefined;
vi.mocked(postHiveChange).mockReturnValue(new Promise((r) => (resolve = r)));
const { hook, cached } = setup();
let pending: Promise<unknown> = Promise.resolve();
act(() => {
pending = hook.result.current.submit(refill, "CARD", "2026-10-15");
});
await waitFor(() => expect(cached()?.state.stack[2].state.syrupLiters).toBe(3));
expect(cached()?.version).toBe(1);
await act(async () => {
resolve(view(state(3), 2));
await pending;
});
expect(cached()?.version).toBe(2);
expect(vi.mocked(postHiveChange).mock.calls[0][1]).toEqual({
occurredOn: "2026-10-15",
source: "CARD",
ops: refill,
});
});
it("defaults occurredOn to the Berlin day", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date("2026-10-15T22:30:00.000Z")); // already 16 Oct in Berlin
vi.mocked(postHiveChange).mockResolvedValue(view(state(3), 2));
const { hook } = setup();
await act(async () => {
await hook.result.current.submit(refill, "CARD");
});
expect(vi.mocked(postHiveChange).mock.calls[0][1].occurredOn).toBe("2026-10-16");
});
it("refetches instead of restoring a snapshot on a plain error, and rethrows", async () => {
vi.mocked(postHiveChange).mockRejectedValue(new ApiError(500, "kaputt", null));
const { hook, client } = setup();
const spy = vi.spyOn(client, "invalidateQueries");
await act(async () => {
await expect(hook.result.current.submit(refill, "CARD", "2026-10-15")).rejects.toThrow("kaputt");
});
expect(spy).toHaveBeenCalledWith({ queryKey: hiveKeys.detail(COLONY) });
expect(hook.result.current.conflict).toBeNull();
});
it("restores the previous state when the request fails offline", async () => {
vi.mocked(postHiveChange).mockRejectedValue(new TypeError("Failed to fetch"));
const { hook, cached } = setup();
await act(async () => {
await hook.result.current.submit(refill, "CARD", "2026-10-15").catch(() => undefined);
});
expect(cached()?.state.stack[2].state.syrupLiters).toBe(2);
});
it("keeps a newer cache value instead of restoring the snapshot", async () => {
let reject: (e: unknown) => void = () => undefined;
vi.mocked(postHiveChange).mockReturnValue(new Promise((_, r) => (reject = r)));
const { hook, cached, client } = setup();
let pending: Promise<unknown> = Promise.resolve();
act(() => {
pending = hook.result.current.submit(refill, "CARD", "2026-10-15").catch(() => undefined);
});
await waitFor(() => expect(cached()?.state.stack[2].state.syrupLiters).toBe(3));
client.setQueryData(hiveKeys.detail(COLONY), view(state(1), 9));
await act(async () => {
reject(new TypeError("Failed to fetch"));
await pending;
});
expect(cached()?.version).toBe(9);
});
it("uses a fresh key after a 4xx failure", async () => {
vi.mocked(postHiveChange)
.mockRejectedValueOnce(new ApiError(400, "falsch", null))
.mockResolvedValueOnce(view(state(3), 2));
const { hook } = setup();
await act(async () => {
await hook.result.current.submit(refill, "CARD", "2026-10-15").catch(() => undefined);
});
await act(async () => {
await hook.result.current.submit(refill, "CARD", "2026-10-15");
});
const calls = vi.mocked(postHiveChange).mock.calls;
expect(calls[1][2]).not.toBe(calls[0][2]);
});
it("reuses the idempotency key when the same submit is pressed again after a network error", async () => {
vi.mocked(postHiveChange)
.mockRejectedValueOnce(new ApiError(503, "weg", null))
.mockResolvedValueOnce(view(state(3), 2));
const { hook } = setup();
await act(async () => {
await hook.result.current.submit(refill, "CARD", "2026-10-15").catch(() => undefined);
});
await act(async () => {
await hook.result.current.submit(refill, "CARD", "2026-10-15");
});
const calls = vi.mocked(postHiveChange).mock.calls;
expect(calls[1][2]).toBe(calls[0][2]);
});
it("uses a fresh key for different ops and after success", async () => {
vi.mocked(postHiveChange).mockResolvedValue(view(state(3), 2));
const { hook } = setup();
await act(async () => {
await hook.result.current.submit(refill, "CARD", "2026-10-15");
await hook.result.current.submit(refill, "CARD", "2026-10-15");
});
const calls = vi.mocked(postHiveChange).mock.calls;
expect(calls[1][2]).not.toBe(calls[0][2]);
});
it("rejects a second submit while one is running", async () => {
let resolve: (v: HiveSetupView) => void = () => undefined;
vi.mocked(postHiveChange).mockReturnValue(new Promise((r) => (resolve = r)));
const { hook } = setup();
let first: Promise<unknown> = Promise.resolve();
act(() => {
first = hook.result.current.submit(refill, "CARD", "2026-10-15");
});
await act(async () => {
await expect(hook.result.current.submit(refill, "CARD", "2026-10-15")).rejects.toThrow(HIVE_BUSY_MESSAGE);
});
await act(async () => {
resolve(view(state(3), 2));
await first;
});
expect(vi.mocked(postHiveChange)).toHaveBeenCalledTimes(1);
});
it("cancels a running detail refetch before the optimistic write", async () => {
vi.mocked(postHiveChange).mockResolvedValue(view(state(3), 2));
const { hook, client } = setup();
const spy = vi.spyOn(client, "cancelQueries");
await act(async () => {
await hook.result.current.submit(refill, "CARD", "2026-10-15");
});
expect(spy).toHaveBeenCalledWith({ queryKey: hiveKeys.detail(COLONY) });
});
it("invalidates the history after success", async () => {
vi.mocked(postHiveChange).mockResolvedValue(view(state(3), 2));
const { hook, client } = setup();
const spy = vi.spyOn(client, "invalidateQueries");
await act(async () => {
await hook.result.current.submit(refill, "CARD", "2026-10-15");
});
expect(spy).toHaveBeenCalledWith({ queryKey: hiveKeys.history(COLONY) });
});
it("suggests a feeding with the prefilled link, never booking one", async () => {
vi.mocked(postHiveChange).mockResolvedValue(view(state(3), 2));
const { hook } = setup();
await act(async () => {
await hook.result.current.submit(refill, "CARD", "2026-10-15");
});
const [message, options] = vi.mocked(toast).mock.calls[0] as unknown as [
string,
{ action: { onClick: () => void } },
];
expect(message).toBe("1 l Flüssigfutter nachgefüllt. Als Fütterung eintragen?");
options.action.onClick();
expect(push).toHaveBeenCalledWith(
"/feedings/new?colonyId=colony-1&date=2026-10-15&amount=1&unit=l&feedType=SUGAR_WATER",
);
});
it("suggests a treatment when an evaporator with acid is inserted", async () => {
const withEvaporator = state();
withEvaporator.stack[1] = {
...withEvaporator.stack[1],
inserts: [part("ev", "evaporator", { state: { acidMl: 250 } })],
};
vi.mocked(postHiveChange).mockResolvedValue(view(withEvaporator, 2));
const { hook } = setup();
const ops: HiveOp[] = [
{ op: "addInsert", hostId: "b", part: part("ev", "evaporator", { state: { acidMl: 250 } }) },
];
await act(async () => {
await hook.result.current.submit(ops, "CARD", "2026-10-15");
});
const [message, options] = vi.mocked(toast).mock.calls[0] as unknown as [
string,
{ action: { onClick: () => void } },
];
expect(message).toBe("Verdunster mit 250 ml eingesetzt. Als Behandlung eintragen?");
options.action.onClick();
expect(push).toHaveBeenCalledWith(
"/treatments/new?colonyId=colony-1&date=2026-10-15&amount=250&unit=ml",
);
});
it("stays silent when an evaporator is removed and no treatment is open", async () => {
const before = state();
before.stack[1] = { ...before.stack[1], inserts: [part("ev", "evaporator", { state: { acidMl: 100 } })] };
vi.mocked(postHiveChange).mockResolvedValue(view(state(), 2));
vi.mocked(fetchColonyTreatments).mockResolvedValue([{ id: "t", endDate: "2026-10-01" }] as never);
const { hook, client } = setup();
client.setQueryData(hiveKeys.detail(COLONY), view(before));
await act(async () => {
await hook.result.current.submit([{ op: "removePart", id: "ev" }], "CARD", "2026-10-15");
});
await waitFor(() => expect(fetchColonyTreatments).toHaveBeenCalled());
await act(async () => {});
expect(toast).not.toHaveBeenCalled();
});
it("asks whether to end the treatment when an evaporator is removed and one is open", async () => {
const before = state();
before.stack[1] = { ...before.stack[1], inserts: [part("ev", "evaporator", { state: { acidMl: 100 } })] };
vi.mocked(postHiveChange).mockResolvedValue(view(state(), 2));
const { hook, client } = setup();
vi.mocked(fetchColonyTreatments).mockResolvedValue([{ id: "t", endDate: null }] as never);
client.setQueryData(hiveKeys.detail(COLONY), view(before));
await act(async () => {
await hook.result.current.submit([{ op: "removePart", id: "ev" }], "CARD", "2026-10-15");
});
await waitFor(() => expect(toast).toHaveBeenCalled());
const [message, options] = vi.mocked(toast).mock.calls[0] as unknown as [
string,
{ action: { onClick: () => void } },
];
expect(message).toBe("Behandlung beenden?");
options.action.onClick();
expect(push).toHaveBeenCalledWith("/colonies/colony-1#behandlungen");
});
describe("on 409 HIVE_CONFLICT", () => {
const serverState = (): HiveState => {
const s = state(1);
return s;
};
it("keeps own ops, the current state and a preview, and shows the server state", async () => {
vi.mocked(postHiveChange).mockRejectedValue(conflictError(serverState()));
const { hook, cached } = setup();
await act(async () => {
await expect(hook.result.current.submit(refill, "CARD", "2026-10-15")).rejects.toBeInstanceOf(ApiError);
});
const conflict = hook.result.current.conflict;
expect(conflict?.ops).toEqual(refill);
expect(conflict?.current.version).toBe(5);
expect(conflict?.preview).toMatchObject({ ok: true });
if (conflict?.preview.ok) expect(conflict.preview.state.stack[2].state.syrupLiters).toBe(3);
expect(cached()?.version).toBe(5);
expect(cached()?.state.stack[2].state.syrupLiters).toBe(1);
});
it("reports a failing preview with op index and reason", async () => {
const gone = serverState();
gone.stack = gone.stack.filter((p) => p.id !== "fe");
vi.mocked(postHiveChange).mockRejectedValue(conflictError(gone));
const { hook } = setup();
await act(async () => {
await hook.result.current.submit(refill, "CARD", "2026-10-15").catch(() => undefined);
});
const preview = hook.result.current.conflict?.preview;
expect(preview?.ok).toBe(false);
if (preview && !preview.ok) {
expect(preview.opIndex).toBe(0);
expect(preview.reason).toMatch(/MISSING_ID/);
}
});
it("retry resubmits the ops with a new idempotency key", async () => {
vi.mocked(postHiveChange)
.mockRejectedValueOnce(conflictError(serverState()))
.mockResolvedValueOnce(view(state(3), 6));
const { hook, cached } = setup();
await act(async () => {
await hook.result.current.submit(refill, "CARD", "2026-10-15").catch(() => undefined);
});
await act(async () => {
await hook.result.current.resolveConflict("retry");
});
const calls = vi.mocked(postHiveChange).mock.calls;
expect(calls).toHaveLength(2);
expect(calls[1][1]).toEqual({ occurredOn: "2026-10-15", source: "CARD", ops: refill });
expect(calls[1][2]).not.toBe(calls[0][2]);
expect(hook.result.current.conflict).toBeNull();
expect(cached()?.version).toBe(6);
});
it("does not resubmit when the preview failed", async () => {
const gone = serverState();
gone.stack = gone.stack.filter((p) => p.id !== "fe");
vi.mocked(postHiveChange).mockRejectedValue(conflictError(gone));
const { hook } = setup();
await act(async () => {
await hook.result.current.submit(refill, "CARD", "2026-10-15").catch(() => undefined);
});
await act(async () => {
await hook.result.current.resolveConflict("retry");
});
expect(vi.mocked(postHiveChange)).toHaveBeenCalledTimes(1);
expect(hook.result.current.conflict).not.toBeNull();
});
it("discard clears the conflict", async () => {
vi.mocked(postHiveChange).mockRejectedValue(conflictError(serverState()));
const { hook } = setup();
await act(async () => {
await hook.result.current.submit(refill, "CARD", "2026-10-15").catch(() => undefined);
});
await act(async () => {
await hook.result.current.resolveConflict("discard");
});
await waitFor(() => expect(hook.result.current.conflict).toBeNull());
expect(vi.mocked(postHiveChange)).toHaveBeenCalledTimes(1);
});
});
});
@@ -0,0 +1,200 @@
"use client";
import { useCallback, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { berlinCalendarDay } from "@/lib/dates";
import { ApiError } from "@/lib/auth/api-client";
import { hiveConflictOf, hiveKeys, postHiveChange } from "@/lib/hive/api";
import { deriveHiveFacts } from "@/lib/hive/facts";
import { applyHiveOps } from "@/lib/hive/ops";
import { fetchColonyTreatments } from "@/lib/treatments/api";
import { newIdempotencyKey } from "@/lib/offline/idempotency";
import type {
ClientHiveChangeSource,
HiveApplyResult,
HiveCatalog,
HiveOp,
HiveSetupView,
HiveState,
} from "@/lib/hive/types";
import { showHiveFollowUps } from "./follow-up-toast";
/** The server refused the ops against its newer state (409 HIVE_CONFLICT). */
export interface HiveConflict {
/** The state the server holds now. */
current: { version: number; state: HiveState };
/** The user's own ops. */
ops: HiveOp[];
/** The ops re-applied to `current.state`; a failure carries op index and reason. */
preview: HiveApplyResult;
}
export interface HiveMutation {
submit(ops: HiveOp[], source: ClientHiveChangeSource, occurredOn?: string): Promise<HiveSetupView>;
conflict: HiveConflict | null;
/** "retry" sends the ops again against the current state (new key); "discard" drops them. */
resolveConflict(action: "retry" | "discard"): Promise<void>;
isPending: boolean;
}
interface Attempt {
ops: HiveOp[];
source: ClientHiveChangeSource;
occurredOn: string;
}
/** Thrown for a submit while another one of this hook is still running. */
export const HIVE_BUSY_MESSAGE = "HIVE_BUSY";
/** Same ops, source and day: a manual re-press after a network error. */
function signatureOf(attempt: Attempt): string {
return JSON.stringify([attempt.source, attempt.occurredOn, attempt.ops]);
}
/** No verdict from the server (network, timeout, 5xx): the change may or may not be saved. */
function isAmbiguousFailure(error: unknown): boolean {
return !(error instanceof ApiError) || error.status >= 500;
}
function withState(view: HiveSetupView, state: HiveState, version: number, catalog: HiveCatalog): HiveSetupView {
return { ...view, version, state, facts: deriveHiveFacts(state, catalog) };
}
/**
* Posts a change of the colony's hive. The cache shows the result at once
* (optimistic); a failure refetches it. One submit at a time. An attempt keeps
* its idempotency key across manual re-presses after a network error or 5xx;
* a retry after a conflict gets a fresh one. Follow-up toasts are suggestions only.
*/
export function useHiveMutation(colonyId: string, catalog: HiveCatalog): HiveMutation {
const queryClient = useQueryClient();
const router = useRouter();
const [conflict, setConflict] = useState<HiveConflict | null>(null);
const [pendingCount, setPendingCount] = useState(0);
const lastAttempt = useRef<Attempt | null>(null);
const inFlight = useRef(false);
// Key of an attempt that failed without a verdict (network, 5xx): a re-press reuses it.
const failedKey = useRef<{ signature: string; key: string } | null>(null);
const queryKey = hiveKeys.detail(colonyId);
const hasOpenTreatment = useCallback(async (): Promise<boolean> => {
try {
const treatments = await queryClient.fetchQuery({
queryKey: ["colonies", colonyId, "treatments"],
queryFn: () => fetchColonyTreatments(colonyId),
});
return treatments.some((treatment) => treatment.endDate == null);
} catch {
return false;
}
}, [colonyId, queryClient]);
const suggestFollowUps = useCallback(
async (attempt: Attempt, before: HiveState, after: HiveState): Promise<void> => {
const { ops, occurredOn } = attempt;
const open = ops.some((op) => op.op === "removePart") ? await hasOpenTreatment() : false;
showHiveFollowUps(ops, before, after, catalog, {
colonyId,
occurredOn,
navigate: (href) => router.push(href),
hasOpenTreatment: open,
});
},
[catalog, colonyId, hasOpenTreatment, router],
);
const run = useCallback(
async (attempt: Attempt): Promise<HiveSetupView> => {
if (inFlight.current) throw new Error(HIVE_BUSY_MESSAGE);
inFlight.current = true;
const { ops, source, occurredOn } = attempt;
const signature = signatureOf(attempt);
const key =
failedKey.current?.signature === signature ? failedKey.current.key : newIdempotencyKey();
setPendingCount((count) => count + 1);
try {
// A running refetch must not overwrite the optimistic state.
await queryClient.cancelQueries({ queryKey });
const previous = queryClient.getQueryData<HiveSetupView>(queryKey);
if (previous) {
const applied = applyHiveOps(previous.state, ops, catalog);
if (applied.ok) {
queryClient.setQueryData(queryKey, withState(previous, applied.state, previous.version, catalog));
}
}
let optimistic: HiveSetupView | undefined;
if (previous) optimistic = queryClient.getQueryData<HiveSetupView>(queryKey);
try {
const view = await postHiveChange(colonyId, { occurredOn, source, ops }, key);
queryClient.setQueryData(queryKey, view);
setConflict(null);
lastAttempt.current = null;
failedKey.current = null;
void queryClient.invalidateQueries({ queryKey: hiveKeys.history(colonyId) });
if (previous) {
// Follow-ups are best effort: a failing suggestion must not fail the saved change.
suggestFollowUps(attempt, previous.state, view.state).catch(() => {});
}
return view;
} catch (error) {
const body = hiveConflictOf(error);
if (body) {
failedKey.current = null;
lastAttempt.current = attempt;
if (previous) {
queryClient.setQueryData(
queryKey,
withState(previous, body.current.state, body.current.version, catalog),
);
}
setConflict({
current: body.current,
ops,
preview: applyHiveOps(body.current.state, ops, catalog),
});
} else {
failedKey.current = isAmbiguousFailure(error) ? { signature, key } : null;
// Restore only if nobody changed the cache since our optimistic write; then refetch.
if (previous && optimistic && queryClient.getQueryData(queryKey) === optimistic) {
queryClient.setQueryData(queryKey, previous);
}
void queryClient.invalidateQueries({ queryKey });
}
throw error;
}
} finally {
inFlight.current = false;
setPendingCount((count) => count - 1);
}
},
[catalog, colonyId, queryClient, queryKey, suggestFollowUps],
);
const submit = useCallback(
(ops: HiveOp[], source: ClientHiveChangeSource, occurredOn?: string) =>
run({ ops, source, occurredOn: occurredOn ?? berlinCalendarDay(new Date().toISOString()) }),
[run],
);
const resolveConflict = useCallback(
async (action: "retry" | "discard"): Promise<void> => {
const attempt = lastAttempt.current;
if (action === "discard" || !attempt) {
lastAttempt.current = null;
setConflict(null);
return;
}
if (conflict && !conflict.preview.ok) return;
try {
await run(attempt);
} catch (error) {
// A new conflict is already in `conflict`; anything else is for the caller.
if (!hiveConflictOf(error)) throw error;
}
},
[conflict, run],
);
return { submit, conflict, resolveConflict, isPending: pendingCount > 0 };
}
@@ -0,0 +1,23 @@
"use client";
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { catalogFromSystem } from "@/lib/hive/facts";
import { fetchHiveSystems, hiveKeys } from "@/lib/hive/api";
import type { HiveCatalog, HiveSystemDto } from "@/lib/hive/types";
/** The shared systems query (catalog of every system); the card and the init flow read the same cache. */
export function useHiveSystems() {
return useQuery({ queryKey: hiveKeys.systems, queryFn: fetchHiveSystems });
}
/** Catalog of one system, stable while the systems data stays the same. */
export function useSystemCatalog(
systems: HiveSystemDto[] | undefined,
systemId: string | undefined,
): HiveCatalog | null {
return useMemo(() => {
const system = systems?.find((candidate) => candidate.id === systemId);
return system ? catalogFromSystem(system) : null;
}, [systems, systemId]);
}
@@ -0,0 +1,73 @@
import { describe, expect, it, vi } from "vitest";
import { act, renderHook } from "@testing-library/react";
import type { ReactNode } from "react";
import { createHiveViewStore, HiveViewProvider, useHiveView } from "./use-hive-view";
describe("createHiveViewStore", () => {
it("starts empty and keeps the same state object until something changes", () => {
const store = createHiveViewStore();
const first = store.getState();
expect(first).toEqual({ hoveredId: null, openBoxId: null, hoveredFrame: null });
store.setHovered(null);
expect(store.getState()).toBe(first);
store.setHovered("a");
expect(store.getState()).not.toBe(first);
expect(store.getState().hoveredId).toBe("a");
});
it("notifies subscribers only on real changes and supports unsubscribe", () => {
const store = createHiveViewStore();
const listener = vi.fn();
const off = store.subscribe(listener);
store.setHovered("a");
store.setHovered("a");
expect(listener).toHaveBeenCalledTimes(1);
off();
store.setHovered("b");
expect(listener).toHaveBeenCalledTimes(1);
});
it("opening a box clears the hovered frame", () => {
const store = createHiveViewStore();
store.setHoveredFrame({ boxId: "b", slot: 3 });
store.openBox("b");
expect(store.getState()).toMatchObject({ openBoxId: "b", hoveredFrame: null });
});
it("openBox(null) clears the open box and the hovered frame", () => {
const store = createHiveViewStore();
store.openBox("b");
store.setHoveredFrame({ boxId: "b", slot: 2 });
store.openBox(null);
expect(store.getState()).toMatchObject({ openBoxId: null, hoveredFrame: null });
});
it("treats an equal frame as no change", () => {
const store = createHiveViewStore();
store.setHoveredFrame({ boxId: "b", slot: 2 });
const before = store.getState();
store.setHoveredFrame({ boxId: "b", slot: 2 });
expect(store.getState()).toBe(before);
});
});
describe("useHiveView", () => {
it("exposes state and setters from the provider and re-renders on change", () => {
const store = createHiveViewStore();
const wrapper = ({ children }: { children: ReactNode }) => (
<HiveViewProvider store={store}>{children}</HiveViewProvider>
);
const { result } = renderHook(() => useHiveView(), { wrapper });
expect(result.current.hoveredId).toBeNull();
act(() => result.current.setHovered("x"));
expect(result.current.hoveredId).toBe("x");
act(() => result.current.openBox("b"));
expect(result.current.openBoxId).toBe("b");
});
it("throws outside a provider", () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
expect(() => renderHook(() => useHiveView())).toThrow(/HiveViewProvider/);
spy.mockRestore();
});
});
@@ -0,0 +1,90 @@
"use client";
import { createContext, createElement, useContext, useMemo, useSyncExternalStore } from "react";
import type { ReactNode } from "react";
// View state shared by the outline, the SVG and the 3D scene. It lives outside
// three.js so every surface sees the same hover and the same open box (spec 9.1).
export interface HoveredFrame {
boxId: string;
slot: number;
}
export interface HiveViewState {
hoveredId: string | null;
openBoxId: string | null;
hoveredFrame: HoveredFrame | null;
}
export interface HiveViewStore {
getState(): HiveViewState;
subscribe(listener: () => void): () => void;
setHovered(id: string | null): void;
/** Opens a box (clears the hovered frame); null closes it and clears the hovered frame too. */
openBox(id: string | null): void;
setHoveredFrame(frame: HoveredFrame | null): void;
}
export interface HiveView extends HiveViewState {
setHovered(id: string | null): void;
openBox(id: string | null): void;
setHoveredFrame(frame: HoveredFrame | null): void;
}
const sameFrame = (a: HoveredFrame | null, b: HoveredFrame | null): boolean =>
a === b || (a !== null && b !== null && a.boxId === b.boxId && a.slot === b.slot);
export function createHiveViewStore(): HiveViewStore {
let state: HiveViewState = { hoveredId: null, openBoxId: null, hoveredFrame: null };
const listeners = new Set<() => void>();
// The state object only changes when a value really changes, so
// useSyncExternalStore gets a stable snapshot.
const update = (patch: Partial<HiveViewState>): void => {
const next = { ...state, ...patch };
if (
next.hoveredId === state.hoveredId &&
next.openBoxId === state.openBoxId &&
sameFrame(next.hoveredFrame, state.hoveredFrame)
) {
return;
}
state = next;
listeners.forEach((listener) => listener());
};
return {
getState: () => state,
subscribe(listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
setHovered: (id) => update({ hoveredId: id }),
openBox: (id) => update({ openBoxId: id, hoveredFrame: null }),
setHoveredFrame: (frame) => update({ hoveredFrame: frame }),
};
}
const HiveViewContext = createContext<HiveViewStore | null>(null);
export function HiveViewProvider({ store, children }: { store: HiveViewStore; children: ReactNode }) {
return createElement(HiveViewContext.Provider, { value: store }, children);
}
export function useHiveView(): HiveView {
const store = useContext(HiveViewContext);
if (!store) throw new Error("useHiveView must be used inside a HiveViewProvider");
const state = useSyncExternalStore(store.subscribe, store.getState, store.getState);
return useMemo(
() => ({
...state,
setHovered: store.setHovered,
openBox: store.openBox,
setHoveredFrame: store.setHoveredFrame,
}),
[state, store],
);
}
@@ -0,0 +1,43 @@
"use client";
import { useCallback, useState } from "react";
import { hiveConflictOf } from "@/lib/hive/api";
import type { HiveOp } from "@/lib/hive/types";
export const SAVE_FAILED_TEXT = "Speichern fehlgeschlagen. Bitte noch einmal versuchen.";
/** A refusal with a German sentence the sheet may show as it is (e.g. a draft that breaks a rule). */
export class HiveDraftError extends Error {}
export type SubmitOps = (ops: HiveOp[]) => Promise<unknown>;
export type SubmitOutcome = "saved" | "conflict" | "failed";
/**
* Sends ops for a sheet. On a conflict the caller closes the sheet (the
* conflict sheet takes over, so sheets never stack); other failures stay inline.
*/
export function useSheetSubmit(submit: SubmitOps) {
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const send = useCallback(
async (ops: HiveOp[]): Promise<SubmitOutcome> => {
setPending(true);
setError(null);
try {
await submit(ops);
return "saved";
} catch (failure) {
if (hiveConflictOf(failure)) return "conflict";
setError(failure instanceof HiveDraftError ? failure.message : SAVE_FAILED_TEXT);
return "failed";
} finally {
setPending(false);
}
},
[submit],
);
return { send, pending, error };
}
@@ -0,0 +1,26 @@
import { toApiDate } from "@/lib/dates";
/** The read side of URLSearchParams / Next's ReadonlyURLSearchParams. */
export interface ParamSource {
get(name: string): string | null;
}
const MAX_AMOUNT = 1_000_000;
/** A real calendar day "YYYY-MM-DD", else null (invalid links are ignored, never fatal). */
export function prefillDay(raw: string | null): string | null {
if (!raw) return null;
try {
toApiDate(raw);
return raw;
} catch {
return null;
}
}
/** A positive, finite number as a dot-decimal string ("2.5"), else null. */
export function prefillAmount(raw: string | null): string | null {
if (!raw || !/^\d+(\.\d+)?$/.test(raw)) return null;
const value = Number(raw);
return value > 0 && value <= MAX_AMOUNT ? String(value) : null;
}
@@ -54,3 +54,20 @@ describe.each([
expect(ratio).toBeGreaterThanOrEqual(MIN_CONTRAST);
});
});
describe.each([
["light", ":root"],
["dark", ".dark"],
])("hive label in %s mode", (_mode, selector) => {
it(`holo label text on label background is at least ${MIN_CONTRAST}:1`, () => {
const scope = block(selector);
const ratio = contrast(oklch(scope, "holo-label-fg"), oklch(scope, "holo-label-bg"));
expect(ratio).toBeGreaterThanOrEqual(MIN_CONTRAST);
});
it(`holo edge on the screen background is at least ${MIN_CONTRAST}:1`, () => {
const scope = block(selector);
const ratio = contrast(oklch(scope, "holo-edge"), oklch(scope, "holo-bg"));
expect(ratio).toBeGreaterThanOrEqual(MIN_CONTRAST);
});
});
+69
View File
@@ -0,0 +1,69 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ApiError, apiFetch } from "@/lib/auth/api-client";
import {
createHive,
fetchHive,
fetchHiveAt,
fetchHiveHistory,
fetchHiveSystems,
fetchHiveTemplate,
hiveConflictOf,
hiveErrorCode,
hiveKeys,
postHiveChange,
} from "./api";
vi.mock("@/lib/auth/api-client", async (importOriginal) => {
const original = await importOriginal<typeof import("@/lib/auth/api-client")>();
return { ...original, apiFetch: vi.fn() };
});
describe("hive api", () => {
beforeEach(() => {
vi.mocked(apiFetch).mockReset();
vi.mocked(apiFetch).mockResolvedValue(undefined);
});
it("reads systems, setup, template, history and a day", async () => {
await fetchHiveSystems();
await fetchHive("c 1");
await fetchHiveTemplate("s1");
await fetchHiveHistory("c1");
await fetchHiveHistory("c1", "abc_-");
await fetchHiveAt("c1", "2026-10-01");
expect(vi.mocked(apiFetch).mock.calls.map((call) => call[0])).toEqual([
"/hive-systems",
"/colonies/c%201/hive",
"/hive-systems/s1/template",
"/colonies/c1/hive/changes",
"/colonies/c1/hive/changes?cursor=abc_-",
"/colonies/c1/hive/at?day=2026-10-01",
]);
});
it("sends the idempotency key on create and change", async () => {
const state = { schemaVersion: 1 as const, stack: [] };
await createHive("c1", { systemId: "s", state, occurredOn: "2026-10-01" }, "key-1");
await postHiveChange("c1", { occurredOn: "2026-10-01", source: "CARD", ops: [] }, "key-2");
const [create, change] = vi.mocked(apiFetch).mock.calls;
expect(create[0]).toBe("/colonies/c1/hive");
expect(create[1]).toMatchObject({ method: "POST", headers: { "Idempotency-Key": "key-1" } });
expect(change[0]).toBe("/colonies/c1/hive/changes");
expect(change[1]).toMatchObject({ method: "POST", headers: { "Idempotency-Key": "key-2" } });
});
it("has the query keys of the plan", () => {
expect(hiveKeys.systems).toEqual(["hive-systems"]);
expect(hiveKeys.detail("c1")).toEqual(["colonies", "c1", "hive"]);
expect(hiveKeys.history("c1")).toEqual(["colonies", "c1", "hive", "history"]);
});
it("extracts the HIVE_CONFLICT body and error codes", () => {
const body = { code: "HIVE_CONFLICT", opIndex: 1, reason: "RULE_ROOF_TOP: x", current: { version: 3, state: {} } };
expect(hiveConflictOf(new ApiError(409, "Conflict", body))).toEqual(body);
expect(hiveConflictOf(new ApiError(409, "Conflict", { code: "HIVE_EXISTS" }))).toBeNull();
expect(hiveConflictOf(new Error("x"))).toBeNull();
expect(hiveErrorCode(new ApiError(404, "nf", { code: "HIVE_NOT_FOUND" }))).toBe("HIVE_NOT_FOUND");
expect(hiveErrorCode(new ApiError(500, "x"))).toBeNull();
});
});
+86
View File
@@ -0,0 +1,86 @@
import { ApiError, apiFetch } from "@/lib/auth/api-client";
import { idempotencyHeaders } from "@/lib/offline/idempotency";
import type {
CreateHiveInput,
HiveAtView,
HiveConflictBody,
HiveHistoryPage,
HiveSetupView,
HiveState,
HiveSystemDto,
PostHiveChangeInput,
} from "./types";
export const hiveKeys = {
systems: ["hive-systems"] as const,
detail: (colonyId: string) => ["colonies", colonyId, "hive"] as const,
history: (colonyId: string) => ["colonies", colonyId, "hive", "history"] as const,
};
const colonyPath = (colonyId: string) => `/colonies/${encodeURIComponent(colonyId)}/hive`;
export function fetchHiveSystems(): Promise<HiveSystemDto[]> {
return apiFetch<HiveSystemDto[]>("/hive-systems");
}
/** 404 HIVE_NOT_FOUND when the colony has no setup yet. */
export function fetchHive(colonyId: string): Promise<HiveSetupView> {
return apiFetch<HiveSetupView>(colonyPath(colonyId));
}
/** Template of a system with fresh ids; 409 TEMPLATE_NOT_SET while it is empty. */
export function fetchHiveTemplate(systemId: string): Promise<HiveState> {
return apiFetch<HiveState>(`/hive-systems/${encodeURIComponent(systemId)}/template`);
}
export function createHive(
colonyId: string,
input: CreateHiveInput,
idempotencyKey: string,
): Promise<HiveSetupView> {
return apiFetch<HiveSetupView>(colonyPath(colonyId), {
method: "POST",
headers: idempotencyHeaders({ idempotencyKey }),
body: JSON.stringify(input),
});
}
/** 409 HIVE_CONFLICT carries the current state; see `hiveConflictOf`. */
export function postHiveChange(
colonyId: string,
input: PostHiveChangeInput,
idempotencyKey: string,
): Promise<HiveSetupView> {
return apiFetch<HiveSetupView>(`${colonyPath(colonyId)}/changes`, {
method: "POST",
headers: idempotencyHeaders({ idempotencyKey }),
body: JSON.stringify(input),
});
}
export function fetchHiveHistory(colonyId: string, cursor?: string): Promise<HiveHistoryPage> {
const query = cursor ? `?cursor=${encodeURIComponent(cursor)}` : "";
return apiFetch<HiveHistoryPage>(`${colonyPath(colonyId)}/changes${query}`);
}
export function fetchHiveAt(colonyId: string, day: string): Promise<HiveAtView> {
return apiFetch<HiveAtView>(`${colonyPath(colonyId)}/at?day=${encodeURIComponent(day)}`);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
/** The HIVE_CONFLICT body of a 409 answer, or null for any other error. */
export function hiveConflictOf(error: unknown): HiveConflictBody | null {
if (!(error instanceof ApiError) || error.status !== 409) return null;
const body = error.body;
if (!isRecord(body) || body.code !== "HIVE_CONFLICT") return null;
return body as unknown as HiveConflictBody;
}
/** Machine code of an API error (HIVE_NOT_FOUND, TEMPLATE_NOT_SET, HIVE_EXISTS ...), if any. */
export function hiveErrorCode(error: unknown): string | null {
if (!(error instanceof ApiError) || !isRecord(error.body)) return null;
return typeof error.body.code === "string" ? error.body.code : null;
}
@@ -0,0 +1,54 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { apiFetch } from "@/lib/auth/api-client";
import {
createHivePartType,
createHiveSystem,
updateHivePartType,
updateHiveSystem,
} from "./catalog-api";
vi.mock("@/lib/auth/api-client", async (importOriginal) => {
const original = await importOriginal<typeof import("@/lib/auth/api-client")>();
return { ...original, apiFetch: vi.fn() };
});
describe("hive catalog api", () => {
beforeEach(() => {
vi.mocked(apiFetch).mockReset();
vi.mocked(apiFetch).mockResolvedValue(undefined);
});
it("creates a system with POST", async () => {
const input = { name: "Dadant", frameWidthMm: 435, frameHeightMm: 300, footprintLMm: 500, footprintWMm: 480 };
await createHiveSystem(input);
expect(apiFetch).toHaveBeenCalledWith("/hive-systems", {
method: "POST",
body: JSON.stringify(input),
});
});
it("patches a system by encoded id", async () => {
await updateHiveSystem("s 1", { active: false });
expect(apiFetch).toHaveBeenCalledWith("/hive-systems/s%201", {
method: "PATCH",
body: JSON.stringify({ active: false }),
});
});
it("creates a part type below its system", async () => {
const input = { kind: "BOX" as const, name: "Zarge", lengthMm: 500, widthMm: 480, heightMm: 240, frameSlots: 10 };
await createHivePartType("s1", input);
expect(apiFetch).toHaveBeenCalledWith("/hive-systems/s1/part-types", {
method: "POST",
body: JSON.stringify(input),
});
});
it("patches a part type", async () => {
await updateHivePartType("p 1", { active: false });
expect(apiFetch).toHaveBeenCalledWith("/hive-part-types/p%201", {
method: "PATCH",
body: JSON.stringify({ active: false }),
});
});
});
@@ -0,0 +1,68 @@
import { apiFetch } from "@/lib/auth/api-client";
import type { HiveBoxDepth, HivePartKind, HivePartTypeDto, HiveSystemDto, StateField } from "./types";
// Admin writes of the hive catalog (hive-systems:manage). Reads stay in api.ts
// (`fetchHiveSystems`); callers refresh them via `hiveKeys.systems`.
export interface HiveSystemInput {
name: string;
frameWidthMm: number;
frameHeightMm: number;
footprintLMm: number;
footprintWMm: number;
}
/** Template editing is not part of this screen (it is saved from the editor). */
export type HiveSystemPatch = Partial<HiveSystemInput> & { active?: boolean };
export interface HivePartTypeInput {
kind: HivePartKind;
name: string;
articleRef?: string;
lengthMm: number;
widthMm: number;
heightMm: number;
boxDepth?: HiveBoxDepth;
frameSlots?: number;
stateSchema?: StateField[];
}
/** The kind cannot change after creation; a schema may only be appended to. */
export type HivePartTypePatch = Partial<Omit<HivePartTypeInput, "kind" | "articleRef">> & {
/** null clears the article reference. */
articleRef?: string | null;
active?: boolean;
};
const systemPath = (id: string) => `/hive-systems/${encodeURIComponent(id)}`;
export function createHiveSystem(input: HiveSystemInput): Promise<HiveSystemDto> {
return apiFetch<HiveSystemDto>("/hive-systems", {
method: "POST",
body: JSON.stringify(input),
});
}
export function updateHiveSystem(id: string, patch: HiveSystemPatch): Promise<HiveSystemDto> {
return apiFetch<HiveSystemDto>(systemPath(id), {
method: "PATCH",
body: JSON.stringify(patch),
});
}
export function createHivePartType(
systemId: string,
input: HivePartTypeInput,
): Promise<HivePartTypeDto> {
return apiFetch<HivePartTypeDto>(`${systemPath(systemId)}/part-types`, {
method: "POST",
body: JSON.stringify(input),
});
}
export function updateHivePartType(id: string, patch: HivePartTypePatch): Promise<HivePartTypeDto> {
return apiFetch<HivePartTypeDto>(`/hive-part-types/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify(patch),
});
}
@@ -0,0 +1,125 @@
import { describe, expect, it } from "vitest";
import { ApiError } from "@/lib/auth/api-client";
import {
describeStateFields,
draftProblem,
emptyDraft,
fieldFromDraft,
serverMessage,
templatePartCount,
type FieldDraft,
} from "./catalog-form";
import type { StateField } from "./types";
const level = (overrides: Partial<FieldDraft> = {}): FieldDraft => ({
...emptyDraft(),
kind: "level",
key: "syrup",
label: "Flüssigfutter",
unit: "l",
max: "3",
step: "0.5",
...overrides,
});
const choice = (overrides: Partial<FieldDraft> = {}): FieldDraft => ({
...emptyDraft(),
kind: "choice",
key: "lid",
label: "Deckel",
options: [
{ value: "open", label: "offen" },
{ value: "closed", label: "zu" },
],
...overrides,
});
describe("describeStateFields", () => {
it("writes level fields as range with unit and choices with their labels", () => {
const schema: StateField[] = [
{ key: "a", label: "Flüssigfutter", kind: "level", unit: "l", max: 3, step: 0.5 },
{ key: "b", label: "Futterteig", kind: "level", unit: "kg", max: 2.5, step: 0.5 },
{ key: "c", label: "Deckel", kind: "choice", options: ["o", "z"], optionLabels: ["offen", "zu"] },
];
expect(describeStateFields(schema)).toBe("Flüssigfutter 0–3 l, Futterteig 0–2,5 kg, Deckel (offen / zu)");
});
it("says so when there are no fields", () => {
expect(describeStateFields([])).toBe("keine");
});
});
describe("draftProblem", () => {
it("accepts valid level and choice drafts", () => {
expect(draftProblem(level(), [])).toBeNull();
expect(draftProblem(choice(), [])).toBeNull();
});
it.each([
["empty key", level({ key: "" })],
["key over 40", level({ key: "k".repeat(41) })],
["empty label", level({ label: " " })],
["label over 60", level({ label: "l".repeat(61) })],
["unit over 10", level({ unit: "x".repeat(11) })],
["max zero", level({ max: "0" })],
["max empty", level({ max: "" })],
["step zero", level({ step: "0" })],
["taken key", level({ key: "taken" })],
["no options", choice({ options: [] })],
["duplicate options", choice({ options: [{ value: "a", label: "A" }, { value: "a", label: "B" }] })],
["option over 40", choice({ options: [{ value: "a".repeat(41), label: "A" }] })],
["option label empty", choice({ options: [{ value: "a", label: "" }] })],
])("rejects %s", (_name, draft) => {
expect(draftProblem(draft, ["taken"])).toEqual(expect.any(String));
});
});
describe("fieldFromDraft", () => {
it("builds a level field and drops an empty feedType", () => {
expect(fieldFromDraft(level())).toEqual({
key: "syrup",
label: "Flüssigfutter",
kind: "level",
unit: "l",
max: 3,
step: 0.5,
});
expect(fieldFromDraft(level({ feedType: "FEED_SYRUP" }))).toMatchObject({ feedType: "FEED_SYRUP" });
});
it("builds a choice field with parallel options and labels", () => {
expect(fieldFromDraft(choice())).toEqual({
key: "lid",
label: "Deckel",
kind: "choice",
options: ["open", "closed"],
optionLabels: ["offen", "zu"],
});
});
});
describe("serverMessage", () => {
it("joins the messages list of a 400", () => {
const error = new ApiError(400, "x", { code: "SCHEMA_INVALID", messages: ["a", "b"] });
expect(serverMessage(error, "fallback")).toBe("a b");
});
it("explains a non additive schema in German", () => {
const error = new ApiError(400, "x", { code: "SCHEMA_NOT_ADDITIVE", message: "english" });
expect(serverMessage(error, "fallback")).toMatch(/Zustandsfelder/);
});
it("uses the message, then the fallback", () => {
expect(serverMessage(new ApiError(409, "Name vergeben", {}), "fallback")).toBe("Name vergeben");
expect(serverMessage(new Error(""), "fallback")).toBe("fallback");
});
});
describe("templatePartCount", () => {
it("counts the stack of a template and treats everything else as none", () => {
expect(templatePartCount({ schemaVersion: 1, stack: [{}, {}, {}] })).toBe(3);
expect(templatePartCount({ schemaVersion: 1, stack: [] })).toBeNull();
expect(templatePartCount(null)).toBeNull();
expect(templatePartCount({})).toBeNull();
});
});
@@ -0,0 +1,153 @@
import { ApiError } from "@/lib/auth/api-client";
import type { FeedType } from "@/lib/feedings/types";
import type { StateField } from "./types";
// Form helpers of the catalog admin screen. The limits mirror the backend
// (hive-state-schema.ts and the part-type DTO); the server stays the authority.
export const MAX_DIMENSION_MM = 5000;
/** Upper bound of `frameSlots` in the backend DTO. */
export const MAX_FRAME_SLOTS = 20;
export const MAX_KEY_LENGTH = 40;
export const MAX_FIELD_LABEL_LENGTH = 60;
export const MAX_UNIT_LENGTH = 10;
export const MAX_OPTION_LENGTH = 40;
export interface OptionDraft {
value: string;
label: string;
}
/** A state field as typed; numbers stay text until the form is saved. */
export interface FieldDraft {
kind: "level" | "choice";
key: string;
label: string;
unit: string;
max: string;
step: string;
feedType: FeedType | "";
options: OptionDraft[];
}
export function emptyDraft(): FieldDraft {
return {
kind: "level",
key: "",
label: "",
unit: "",
max: "",
step: "",
feedType: "",
options: [{ value: "", label: "" }],
};
}
function isPositive(text: string): boolean {
const value = Number(text);
return text.trim() !== "" && Number.isFinite(value) && value > 0;
}
function levelProblem(draft: FieldDraft): string | null {
if (draft.unit.length > MAX_UNIT_LENGTH) {
return `Die Einheit darf höchstens ${MAX_UNIT_LENGTH} Zeichen haben.`;
}
if (!isPositive(draft.max)) return "Der Höchstwert muss größer als 0 sein.";
if (!isPositive(draft.step)) return "Die Schrittweite muss größer als 0 sein.";
return null;
}
function choiceProblem(draft: FieldDraft): string | null {
const { options } = draft;
if (options.length === 0) return "Eine Auswahl braucht mindestens eine Option.";
if (options.some((option) => option.value.trim() === "" || option.label.trim() === "")) {
return "Jede Option braucht einen Wert und eine Bezeichnung.";
}
if (new Set(options.map((option) => option.value)).size !== options.length) {
return "Die Werte der Optionen müssen verschieden sein.";
}
if (options.some((o) => o.value.length > MAX_OPTION_LENGTH || o.label.length > MAX_OPTION_LENGTH)) {
return `Werte und Bezeichnungen der Optionen dürfen höchstens ${MAX_OPTION_LENGTH} Zeichen haben.`;
}
return null;
}
/** First problem of a new field, or null. `takenKeys` are the keys already in the schema. */
export function draftProblem(draft: FieldDraft, takenKeys: readonly string[]): string | null {
if (draft.key.trim() === "" || draft.key.length > MAX_KEY_LENGTH) {
return `Der Schlüssel braucht 1 bis ${MAX_KEY_LENGTH} Zeichen.`;
}
if (takenKeys.includes(draft.key)) return `Der Schlüssel „${draft.key}“ ist schon vergeben.`;
if (draft.label.trim() === "" || draft.label.length > MAX_FIELD_LABEL_LENGTH) {
return `Der Feldname braucht 1 bis ${MAX_FIELD_LABEL_LENGTH} Zeichen.`;
}
return draft.kind === "level" ? levelProblem(draft) : choiceProblem(draft);
}
/** Call only for a draft that passed `draftProblem`. */
export function fieldFromDraft(draft: FieldDraft): StateField {
if (draft.kind === "choice") {
return {
key: draft.key,
label: draft.label,
kind: "choice",
options: draft.options.map((option) => option.value),
optionLabels: draft.options.map((option) => option.label),
};
}
return {
key: draft.key,
label: draft.label,
kind: "level",
unit: draft.unit,
max: Number(draft.max),
step: Number(draft.step),
...(draft.feedType === "" ? {} : { feedType: draft.feedType }),
};
}
function germanNumber(value: number): string {
return String(value).replace(".", ",");
}
/** Short text such as „Flüssigfutter 0–3 l, Futterteig 0–2,5 kg“. */
export function describeStateFields(schema: readonly StateField[]): string {
if (schema.length === 0) return "keine";
return schema
.map((field) => {
if (field.kind === "choice") return `${field.label} (${field.optionLabels.join(" / ")})`;
const unit = field.unit === "" ? "" : ` ${field.unit}`;
return `${field.label} 0–${germanNumber(field.max)}${unit}`;
})
.join(", ");
}
const SERVER_MESSAGES: Record<string, string> = {
SCHEMA_NOT_ADDITIVE:
"Bestehende Zustandsfelder bleiben unverändert, es können nur neue hinzukommen.",
};
function bodyOf(error: unknown): Record<string, unknown> {
if (!(error instanceof ApiError)) return {};
return typeof error.body === "object" && error.body !== null
? (error.body as Record<string, unknown>)
: {};
}
/** What the server said about a failed save, in one text; `fallback` when it said nothing. */
export function serverMessage(error: unknown, fallback: string): string {
const body = bodyOf(error);
if (typeof body.code === "string" && SERVER_MESSAGES[body.code]) {
return SERVER_MESSAGES[body.code];
}
if (Array.isArray(body.messages) && body.messages.length > 0) return body.messages.join(" ");
if (error instanceof Error && error.message) return error.message;
return fallback;
}
/** Parts in the stored template, or null when the system has none yet. */
export function templatePartCount(template: unknown): number | null {
if (typeof template !== "object" || template === null) return null;
const stack = (template as { stack?: unknown }).stack;
return Array.isArray(stack) && stack.length > 0 ? stack.length : null;
}
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { copyHiveFrom } from "./copy";
import { frames, inlineCatalog, part } from "./test-utils";
import type { HiveState } from "./types";
const catalog = inlineCatalog();
const source: HiveState = {
schemaVersion: 1,
stack: [
part("f", "floor", { label: "Mein Boden", inserts: [part("fi", "floor-insert")] }),
part("b", "box", {
label: "Brutraum",
frames: [
{ slot: 1, type: "BROOD", name: "Königin" },
{ slot: 2, type: "HONEY" },
{ slot: 3, type: "FOUNDATION" },
...frames(7, "NONE").map((frame) => ({ ...frame, slot: frame.slot + 3 })),
],
}),
part("fe", "feeder", { state: { syrupLiters: 2, fondantKg: 1 } }),
part("l", "inner-lid", { state: { feedHole: "CLOSED" } }),
],
};
describe("copyHiveFrom", () => {
const copy = copyHiveFrom(source, catalog);
it("resets levels to 0 and choices to the first option", () => {
expect(copy.stack[2].state).toEqual({ syrupLiters: 0, fondantKg: 0 });
expect(copy.stack[3].state).toEqual({ feedHole: "OPEN" });
});
it("removes part labels and frame names", () => {
expect(copy.stack[0].label).toBeUndefined();
expect(copy.stack[1].label).toBeUndefined();
expect(copy.stack[1].frames?.every((frame) => frame.name === undefined)).toBe(true);
});
it("turns every frame into an empty comb except foundations", () => {
const types = copy.stack[1].frames?.map((frame) => frame.type);
expect(types?.slice(0, 3)).toEqual(["EMPTY_COMB", "EMPTY_COMB", "FOUNDATION"]);
expect(types?.slice(3).every((type) => type === "EMPTY_COMB")).toBe(true);
expect(copy.stack[1].frames?.map((frame) => frame.slot)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
});
it("gives every part and insert a fresh id and keeps the stack order", () => {
const oldIds = ["f", "fi", "b", "fe", "l"];
const newIds = [copy.stack[0].id, copy.stack[0].inserts?.[0].id, copy.stack[1].id, copy.stack[2].id, copy.stack[3].id];
expect(new Set(newIds).size).toBe(5);
expect(newIds.some((id) => oldIds.includes(String(id)))).toBe(false);
expect(copy.stack.map((p) => p.partTypeId)).toEqual(["floor", "box", "feeder", "inner-lid"]);
expect(copy.stack[0].inserts?.[0].partTypeId).toBe("floor-insert");
});
it("does not change the source", () => {
expect(source.stack[1].frames?.[0]).toEqual({ slot: 1, type: "BROOD", name: "Königin" });
});
});
+36
View File
@@ -0,0 +1,36 @@
import type { FrameType, HiveCatalog, HivePart, HiveState } from "./types";
// Spec 4.4: „Wie anderes Volk“ copies the stack with state values at their
// start (0 / first option), no names, and all frames as empty combs except
// foundations. Every part gets a fresh id.
const KEPT_FRAME_TYPE: FrameType = "FOUNDATION";
const EMPTY_FRAME_TYPE: FrameType = "EMPTY_COMB";
function startState(part: HivePart, catalog: HiveCatalog): HivePart["state"] {
const schema = catalog.partTypes.get(part.partTypeId)?.stateSchema ?? [];
return Object.fromEntries(
schema.map((field) => [field.key, field.kind === "choice" ? (field.options[0] ?? "") : 0]),
);
}
function copyPart(part: HivePart, catalog: HiveCatalog): HivePart {
return {
id: crypto.randomUUID(),
partTypeId: part.partTypeId,
state: startState(part, catalog),
...(part.inserts ? { inserts: part.inserts.map((insert) => copyPart(insert, catalog)) } : {}),
...(part.frames
? {
frames: part.frames.map((frame) => ({
slot: frame.slot,
type: frame.type === KEPT_FRAME_TYPE ? KEPT_FRAME_TYPE : EMPTY_FRAME_TYPE,
})),
}
: {}),
};
}
export function copyHiveFrom(source: HiveState, catalog: HiveCatalog): HiveState {
return { schemaVersion: 1, stack: source.stack.map((part) => copyPart(part, catalog)) };
}
@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import { describeHiveOps } from "./describe";
import { inlineCatalog, part, templateState } from "./test-utils";
import type { HiveOp, HiveState } from "./types";
describe("describeHiveOps", () => {
const catalog = inlineCatalog();
function stateWithInsert(): HiveState {
const state = templateState();
state.stack[0].inserts = [part("fi", "floor-insert")];
return state;
}
it("describes removed inserts and added parts", () => {
const ops: HiveOp[] = [
{ op: "removePart", id: "fi" },
{ op: "addPart", part: part("b2", "box"), at: 2 },
];
expect(describeHiveOps(ops, stateWithInsert(), catalog)).toBe("Bausperre entfernt, Zarge hinzugefügt");
});
it("uses the label of a part when it has one", () => {
const state = templateState();
state.stack[1].label = "Honigraum 1";
expect(describeHiveOps([{ op: "removePart", id: "b" }], state, catalog)).toBe("Honigraum 1 entfernt");
expect(describeHiveOps([{ op: "movePart", id: "b", to: 2 }], state, catalog)).toBe("Honigraum 1 verschoben");
});
it("describes insert ops", () => {
expect(
describeHiveOps([{ op: "addInsert", hostId: "f", part: part("fi", "floor-insert") }], templateState(), catalog),
).toBe("Bausperre eingelegt");
expect(
describeHiveOps([{ op: "addInsert", hostId: "b", part: part("e", "evaporator") }], templateState(), catalog),
).toBe("Verdunster eingesetzt");
});
it("describes setState with unit and option labels", () => {
const state = templateState();
state.stack.splice(2, 0, part("fe", "feeder"));
expect(describeHiveOps([{ op: "setState", id: "fe", key: "syrupLiters", value: 2.5 }], state, catalog)).toBe(
"Flüssigfutter 2,5 l",
);
expect(describeHiveOps([{ op: "setState", id: "l", key: "feedHole", value: "OPEN" }], state, catalog)).toBe(
"Futterloch offen",
);
});
it("describes setFrame, setFrames and setLabel", () => {
const state = templateState();
expect(describeHiveOps([{ op: "setFrame", boxId: "b", slot: 4, type: "BROOD" }], state, catalog)).toBe(
"Rähmchen 4 in Zarge: Brutwabe",
);
expect(describeHiveOps([{ op: "setFrames", boxId: "b", frames: [] }], state, catalog)).toBe(
"Rähmchen in Zarge neu gesetzt",
);
expect(describeHiveOps([{ op: "setLabel", id: "b", label: "Brutraum" }], state, catalog)).toBe(
"Zarge heißt jetzt „Brutraum“",
);
});
it("resolves parts added earlier in the same batch", () => {
const ops: HiveOp[] = [
{ op: "addPart", part: { ...part("n", "box"), label: "Aufsatz" }, at: 2 },
{ op: "setFrame", boxId: "n", slot: 1, type: "HONEY" },
];
expect(describeHiveOps(ops, templateState(), catalog)).toBe(
"Aufsatz hinzugefügt, Rähmchen 1 in Aufsatz: Honigwabe",
);
});
it("shortens more than three ops", () => {
const ops: HiveOp[] = [1, 2, 3, 4, 5].map((slot) => ({ op: "setFrame", boxId: "b", slot, type: "BROOD" }));
expect(describeHiveOps(ops, templateState(), catalog)).toBe(
"Rähmchen 1 in Zarge: Brutwabe, Rähmchen 2 in Zarge: Brutwabe, Rähmchen 3 in Zarge: Brutwabe und 2 weitere",
);
});
});
+78
View File
@@ -0,0 +1,78 @@
import { FRAME_TYPE_LABELS, PART_KIND_LABELS } from "./labels";
import type { CatalogPartType, HiveCatalog, HiveOp, HivePart, HiveState, StateField } from "./types";
const MAX_SHOWN_OPS = 3;
interface Named {
label?: string;
type?: CatalogPartType;
}
function collect(part: HivePart, catalog: HiveCatalog, into: Map<string, Named>): void {
into.set(part.id, { label: part.label, type: catalog.partTypes.get(part.partTypeId) });
for (const insert of part.inserts ?? []) collect(insert, catalog, into);
}
function nameOf(entry: Named | undefined): string {
if (entry?.label) return entry.label;
return entry?.type ? PART_KIND_LABELS[entry.type.kind] : "Teil";
}
const numberFormat = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 3 });
function valueText(field: StateField | undefined, value: number | string): string {
if (!field) return String(value);
if (field.kind === "choice") {
const index = field.options.indexOf(String(value));
return index >= 0 ? field.optionLabels[index] : String(value);
}
const text = numberFormat.format(Number(value));
return field.unit ? `${text} ${field.unit}` : text;
}
function describeOne(op: HiveOp, known: Map<string, Named>, catalog: HiveCatalog): string {
switch (op.op) {
case "addPart": {
collect(op.part, catalog, known);
return `${nameOf(known.get(op.part.id))} hinzugefügt`;
}
case "addInsert": {
collect(op.part, catalog, known);
const name = nameOf(known.get(op.part.id));
return known.get(op.part.id)?.type?.kind === "FLOOR_INSERT" ? "Bausperre eingelegt" : `${name} eingesetzt`;
}
case "removePart":
return `${nameOf(known.get(op.id))} entfernt`;
case "movePart":
return `${nameOf(known.get(op.id))} verschoben`;
case "setState": {
const entry = known.get(op.id);
const field = entry?.type?.stateSchema.find((f) => f.key === op.key);
return `${field?.label ?? op.key} ${valueText(field, op.value)}`;
}
case "setFrame":
return `Rähmchen ${op.slot} in ${nameOf(known.get(op.boxId))}: ${FRAME_TYPE_LABELS[op.type]}`;
case "setFrames":
return `Rähmchen in ${nameOf(known.get(op.boxId))} neu gesetzt`;
case "setLabel": {
const name = nameOf(known.get(op.id));
const entry = known.get(op.id);
if (entry) entry.label = op.label ?? undefined;
return op.label === null ? `Name von ${name} entfernt` : `${name} heißt jetzt „${op.label}“`;
}
}
}
/**
* German one-line summary of a change, e.g. „Bausperre entfernt, Zarge hinzugefügt“.
* Names come from `before` plus parts the same batch adds; more than three ops
* are shortened to the first three and „und n weitere“.
*/
export function describeHiveOps(ops: HiveOp[], before: HiveState, catalog: HiveCatalog): string {
const known = new Map<string, Named>();
for (const part of before.stack) collect(part, catalog, known);
const texts = ops.map((op) => describeOne(op, known, catalog));
if (texts.length <= MAX_SHOWN_OPS) return texts.join(", ");
const rest = texts.length - MAX_SHOWN_OPS;
return `${texts.slice(0, MAX_SHOWN_OPS).join(", ")} und ${rest} weitere`;
}
+74
View File
@@ -0,0 +1,74 @@
import type {
CatalogPartType,
HiveCatalog,
HiveFacts,
HiveLevelFact,
HivePart,
HiveState,
HiveSystemDto,
} from "./types";
const UNBUILT_FRAME_TYPES = new Set(["NONE", "FOUNDATION", "DIVIDER"]);
/** Catalog of a system as the API returns it (all part types, also inactive). */
export function catalogFromSystem(system: HiveSystemDto): HiveCatalog {
const rows: CatalogPartType[] = system.partTypes.map((row) => ({
id: row.id,
kind: row.kind,
name: row.name,
lengthMm: row.lengthMm,
widthMm: row.widthMm,
heightMm: row.heightMm,
frameSlots: row.frameSlots,
stateSchema: row.stateSchema,
active: row.active,
}));
return { systemId: system.id, partTypes: new Map(rows.map((row) => [row.id, row])) };
}
function allParts(state: HiveState): HivePart[] {
return state.stack.flatMap((part) => [part, ...(part.inserts ?? [])]);
}
function levelsOf(part: HivePart, catalog: HiveCatalog): HiveLevelFact[] {
const type = catalog.partTypes.get(part.partTypeId);
if (!type) return [];
return type.stateSchema.flatMap((field) => {
if (field.kind !== "level") return [];
const raw = part.state[field.key];
return [
{
partId: part.id,
key: field.key,
label: field.label,
unit: field.unit,
value: typeof raw === "number" ? raw : 0,
max: field.max,
...(field.feedType ? { feedType: field.feedType } : {}),
},
];
});
}
/** Pure derivation of display facts (spec 5.3), same as the backend. */
export function deriveHiveFacts(state: HiveState, catalog: HiveCatalog): HiveFacts {
const parts = allParts(state);
const kindOf = (part: HivePart) => catalog.partTypes.get(part.partTypeId)?.kind;
const frames = state.stack
.filter((part) => kindOf(part) === "BOX")
.flatMap((part) => part.frames ?? []);
const evaporators = parts.filter((part) => kindOf(part) === "EVAPORATOR");
const acid = evaporators.reduce((sum, part) => {
const value = part.state.acidMl;
return sum + (typeof value === "number" ? value : 0);
}, 0);
return {
boxCount: state.stack.filter((part) => kindOf(part) === "BOX").length,
broodFrames: frames.filter((f) => f.type === "BROOD").length,
honeyFrames: frames.filter((f) => f.type === "HONEY").length,
builtFrames: frames.filter((f) => !UNBUILT_FRAME_TYPES.has(f.type)).length,
levels: parts.flatMap((part) => levelsOf(part, catalog)),
hasFloorInsert: parts.some((part) => kindOf(part) === "FLOOR_INSERT"),
evaporatorAcidMl: evaporators.length > 0 ? acid : null,
};
}
@@ -0,0 +1,117 @@
import { describe, expect, it } from "vitest";
import { applyHiveOps } from "./ops";
import { hiveFollowUps } from "./follow-ups";
import { inlineCatalog, part, templateState } from "./test-utils";
import type { HiveOp, HiveState } from "./types";
const catalog = inlineCatalog();
function withFeeder(): HiveState {
const state = templateState();
state.stack.splice(2, 0, part("fe", "feeder"));
return state;
}
function run(before: HiveState, ops: HiveOp[]) {
const result = applyHiveOps(before, ops, catalog);
if (!result.ok) throw new Error(result.reason);
return hiveFollowUps(ops, before, result.state, catalog);
}
describe("hiveFollowUps", () => {
it("suggests a feeding when a feeder level rises", () => {
const ops: HiveOp[] = [{ op: "setState", id: "fe", key: "syrupLiters", value: 3 }];
expect(run(withFeeder(), ops)).toEqual([
{ kind: "feeding", amount: 3, unit: "l", feedType: "SUGAR_WATER" },
]);
});
it("uses the increase and the feed type of the field", () => {
const before = withFeeder();
before.stack[2].state = { fondantKg: 0.5 };
const ops: HiveOp[] = [{ op: "setState", id: "fe", key: "fondantKg", value: 2 }];
expect(run(before, ops)).toEqual([{ kind: "feeding", amount: 1.5, unit: "kg", feedType: "FEED_PASTE" }]);
});
it("suggests nothing when a level falls or stays", () => {
const before = withFeeder();
before.stack[2].state = { syrupLiters: 2 };
expect(run(before, [{ op: "setState", id: "fe", key: "syrupLiters", value: 1 }])).toEqual([]);
expect(run(before, [{ op: "setState", id: "fe", key: "syrupLiters", value: 2 }])).toEqual([]);
});
it("nets several ops on the same level into one suggestion", () => {
const ops: HiveOp[] = [
{ op: "setState", id: "fe", key: "syrupLiters", value: 1 },
{ op: "setState", id: "fe", key: "syrupLiters", value: 2 },
];
expect(run(withFeeder(), ops)).toEqual([{ kind: "feeding", amount: 2, unit: "l", feedType: "SUGAR_WATER" }]);
});
it("suggests a treatment for an evaporator added with acid", () => {
const evaporator = { ...part("ev", "evaporator"), state: { acidMl: 250 } };
const ops: HiveOp[] = [{ op: "addInsert", hostId: "b", part: evaporator }];
expect(run(templateState(), ops)).toEqual([{ kind: "treatment", amountMl: 250 }]);
});
it("suggests nothing for an evaporator added without acid", () => {
const ops: HiveOp[] = [{ op: "addInsert", hostId: "b", part: part("ev", "evaporator") }];
expect(run(templateState(), ops)).toEqual([]);
});
it("suggests a treatment when the acid level rises", () => {
const before = templateState();
before.stack[1].inserts = [{ ...part("ev", "evaporator"), state: { acidMl: 100 } }];
const ops: HiveOp[] = [{ op: "setState", id: "ev", key: "acidMl", value: 250 }];
expect(run(before, ops)).toEqual([{ kind: "treatment", amountMl: 150 }]);
});
it("suggests ending the treatment when an evaporator is removed", () => {
const before = templateState();
before.stack[1].inserts = [part("ev", "evaporator")];
expect(run(before, [{ op: "removePart", id: "ev" }])).toEqual([{ kind: "endTreatment" }]);
});
it("gives one treatment when an evaporator is added and raised in one batch", () => {
const evaporator = { ...part("ev", "evaporator"), state: { acidMl: 250 } };
const ops: HiveOp[] = [
{ op: "addInsert", hostId: "b", part: evaporator },
{ op: "setState", id: "ev", key: "acidMl", value: 300 },
];
expect(run(templateState(), ops)).toEqual([{ kind: "treatment", amountMl: 300 }]);
});
it("gives one treatment when an evaporator is added empty and then raised", () => {
const ops: HiveOp[] = [
{ op: "addInsert", hostId: "b", part: part("ev", "evaporator") },
{ op: "setState", id: "ev", key: "acidMl", value: 250 },
];
expect(run(templateState(), ops)).toEqual([{ kind: "treatment", amountMl: 250 }]);
});
it("gives nothing when a level is raised and lowered again in one batch", () => {
const ops: HiveOp[] = [
{ op: "setState", id: "fe", key: "syrupLiters", value: 3 },
{ op: "setState", id: "fe", key: "syrupLiters", value: 0 },
];
expect(run(withFeeder(), ops)).toEqual([]);
});
it("suggests the net filled amount for a feeder added and filled in the same batch", () => {
const filled = { ...part("fe2", "feeder"), state: { syrupLiters: 2 } };
const ops: HiveOp[] = [
{ op: "addPart", part: filled, at: 2 },
{ op: "setState", id: "fe2", key: "syrupLiters", value: 3 },
];
expect(run(templateState(), ops)).toEqual([
{ kind: "feeding", amount: 3, unit: "l", feedType: "SUGAR_WATER" },
]);
});
it("suggests a feeding for a feeder added already filled", () => {
const filled = { ...part("fe2", "feeder"), state: { fondantKg: 1.5 } };
expect(run(templateState(), [{ op: "addPart", part: filled, at: 2 }])).toEqual([
{ kind: "feeding", amount: 1.5, unit: "kg", feedType: "FEED_PASTE" },
]);
});
});
+111
View File
@@ -0,0 +1,111 @@
import type { FeedType } from "@/lib/feedings/types";
import type { CatalogPartType, HiveCatalog, HiveOp, HivePart, HiveState, LevelField } from "./types";
// Spec section 8: suggestions only. Nothing here books a feeding or treatment.
export type FollowUp =
| { kind: "feeding"; amount: number; unit: "l" | "kg"; feedType: FeedType }
| { kind: "treatment"; amountMl: number }
| { kind: "endTreatment" };
const FEED_TYPES: readonly FeedType[] = ["SUGAR_WATER", "FEED_SYRUP", "FEED_PASTE", "OWN_HONEY", "OTHER"];
const ACID_KEY = "acidMl";
const ROUNDING = 1000;
function findPart(state: HiveState, id: string): HivePart | undefined {
for (const part of state.stack) {
if (part.id === id) return part;
const insert = part.inserts?.find((candidate) => candidate.id === id);
if (insert) return insert;
}
return undefined;
}
function typeOf(part: HivePart | undefined, catalog: HiveCatalog): CatalogPartType | undefined {
return part ? catalog.partTypes.get(part.partTypeId) : undefined;
}
function levelOf(part: HivePart | undefined, key: string): number {
const raw = part?.state[key];
return typeof raw === "number" ? raw : 0;
}
function increase(before: HivePart | undefined, after: HivePart, key: string): number {
return Math.round((levelOf(after, key) - levelOf(before, key)) * ROUNDING) / ROUNDING;
}
function feedingFor(
field: LevelField,
amount: number,
): FollowUp | null {
const feedType = FEED_TYPES.find((candidate) => candidate === field.feedType);
if (!feedType || (field.unit !== "l" && field.unit !== "kg")) return null;
return { kind: "feeding", amount, unit: field.unit, feedType };
}
function treatmentFor(before: HivePart | undefined, after: HivePart): FollowUp | null {
// A part absent in `before` (added in this batch) counts as 0 before.
const delta = increase(before, after, ACID_KEY);
return delta > 0 ? { kind: "treatment", amountMl: delta } : null;
}
function feedingForPart(
before: HivePart | undefined,
after: HivePart,
key: string,
catalog: HiveCatalog,
): FollowUp | null {
// A feeder added in this batch counts as 0 before: filling it is a feeding.
const field = typeOf(after, catalog)?.stateSchema.find((f) => f.key === key);
const delta = increase(before, after, key);
return field?.kind === "level" && delta > 0 ? feedingFor(field, delta) : null;
}
/**
* Suggestions after applied ops (spec section 8). Computed from the net
* per-part, per-level difference between `before` and `after`, so several ops
* on one level (also add-then-raise) give exactly one suggestion. A part added
* in the batch counts as 0 before, for evaporators and feeders alike.
*/
export function hiveFollowUps(
ops: HiveOp[],
before: HiveState,
after: HiveState,
catalog: HiveCatalog,
): FollowUp[] {
const out: FollowUp[] = [];
const seen = new Set<string>();
const once = (id: string, key: string): boolean => {
const dedupeKey = `${id}/${key}`;
if (seen.has(dedupeKey)) return false;
seen.add(dedupeKey);
return true;
};
for (const op of ops) {
const followUps: (FollowUp | null)[] = [];
if (op.op === "setState" && once(op.id, op.key)) {
const now = findPart(after, op.id);
const was = findPart(before, op.id);
const kind = typeOf(now, catalog)?.kind;
if (now && kind === "EVAPORATOR" && op.key === ACID_KEY) followUps.push(treatmentFor(was, now));
else if (now && kind === "FEEDER") followUps.push(feedingForPart(was, now, op.key, catalog));
} else if (op.op === "addInsert" || op.op === "addPart") {
const now = findPart(after, op.part.id);
const type = typeOf(now, catalog);
if (now && type?.kind === "EVAPORATOR" && once(now.id, ACID_KEY)) {
followUps.push(treatmentFor(findPart(before, now.id), now));
} else if (now && type?.kind === "FEEDER") {
for (const field of type.stateSchema) {
if (field.kind === "level" && once(now.id, field.key)) {
followUps.push(feedingForPart(findPart(before, now.id), now, field.key, catalog));
}
}
}
} else if (op.op === "removePart") {
if (typeOf(findPart(before, op.id), catalog)?.kind === "EVAPORATOR") followUps.push({ kind: "endTreatment" });
}
for (const followUp of followUps) if (followUp) out.push(followUp);
}
return out;
}
+30
View File
@@ -0,0 +1,30 @@
import type { FrameType, HivePartKind } from "./types";
export const FRAME_TYPE_LABELS: Record<FrameType, string> = {
BROOD: "Brutwabe",
DRONE: "Drohnenwabe",
HONEY: "Honigwabe",
POLLEN: "Pollenwabe",
FEED: "Futterwabe",
FOUNDATION: "Mittelwand",
EMPTY_COMB: "Leerwabe",
DRONE_FRAME: "Baurahmen",
DIVIDER: "Schied",
NONE: "leer",
};
/** Short generic names, used when a part has no label of its own. */
export const PART_KIND_LABELS: Record<HivePartKind, string> = {
FLOOR: "Boden",
BOX: "Zarge",
DIVIDER_BOARD: "Zwischenboden",
QUEEN_EXCLUDER: "Absperrgitter",
FEEDER: "Futterzarge",
COVER_SHEET: "Abdeckfolie",
PROPOLIS_SCREEN: "Propolisgitter",
INNER_LID: "Holzdeckel",
INSULATION: "Dämmung",
ROOF: "Dach",
FLOOR_INSERT: "Bausperre",
EVAPORATOR: "Verdunster",
};
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import { hiveLayout } from "./layout";
import { inlineCatalog, part, templateState } from "./test-utils";
describe("hiveLayout", () => {
const catalog = inlineCatalog();
it("computes the template height with the roof overlapping by 35 mm", () => {
const layout = hiveLayout(templateState(), catalog);
expect(layout.bounds.h).toBeCloseTo(4.31, 9);
expect(layout.bounds.w).toBeCloseTo(5.25, 9);
expect(layout.bounds.d).toBeCloseTo(4.3, 9);
});
it("stacks parts on +y from 0 with centres and sizes in units of 100 mm", () => {
const [floor, box, cover] = hiveLayout(templateState(), catalog).parts;
expect(floor.center).toEqual([0, 0.6, 0]);
expect(floor.size).toEqual([5.25, 1.2, 4.3]);
expect(floor.stackIndex).toBe(0);
expect(box.center[1]).toBeCloseTo(1.2 + 1.15, 9);
expect(cover.center[1]).toBeCloseTo(3.5 + 0.005, 9);
expect(box.stackIndex).toBe(1);
});
it("lets the roof overlap the part below while using its own height", () => {
const layout = hiveLayout(templateState(), catalog);
const roof = layout.parts[layout.parts.length - 1];
const below = layout.parts[layout.parts.length - 2];
const belowTop = below.center[1] + below.size[1] / 2;
const roofBottom = roof.center[1] - roof.size[1] / 2;
expect(belowTop - roofBottom).toBeCloseTo(0.35, 9);
expect(roof.size[1]).toBeCloseTo(0.6, 9);
});
it("places 10 frames evenly across the inner width of the box", () => {
const layout = hiveLayout(templateState(), catalog);
expect(layout.frames).toHaveLength(10);
const inner = 5.25 - 0.4;
const xs = layout.frames.map((f) => f.center[0]);
expect(xs[0]).toBeCloseTo(-inner / 2 + inner / 20, 9);
expect(xs[9]).toBeCloseTo(inner / 2 - inner / 20, 9);
const pitch = inner / 10;
xs.slice(1).forEach((x, i) => expect(x - xs[i]).toBeCloseTo(pitch, 9));
layout.frames.forEach((f, i) => {
expect(f.boxId).toBe("b");
expect(f.slot).toBe(i + 1);
expect(f.size[0]).toBeCloseTo(0.25, 9);
expect(f.size[1]).toBeCloseTo(2.2, 9);
});
});
it("puts the floor insert 5 mm above the floor bottom and the evaporator on the box bottom", () => {
const state = templateState();
state.stack[0].inserts = [part("fi", "floor-insert")];
state.stack[1].inserts = [part("ev", "evaporator")];
const layout = hiveLayout(state, catalog);
const insert = layout.inserts.find((i) => i.id === "fi")!;
const evaporator = layout.inserts.find((i) => i.id === "ev")!;
expect(insert.center[1]).toBeCloseTo(0.05, 9);
expect(insert.kind).toBe("FLOOR_INSERT");
expect(evaporator.center[1]).toBeCloseTo(1.2 + 0.6, 9);
expect(evaporator.stackIndex).toBe(1);
});
it("returns an empty layout for an empty stack", () => {
const layout = hiveLayout({ schemaVersion: 1, stack: [] }, catalog);
expect(layout).toEqual({ parts: [], inserts: [], frames: [], bounds: { w: 0, h: 0, d: 0 } });
});
});
+127
View File
@@ -0,0 +1,127 @@
import type { CatalogPartType, FrameType, HiveCatalog, HivePart, HivePartKind, HiveState } from "./types";
// Layout math shared by the 3D scene, the SVG fallback and the tests.
// 1 unit = 100 mm; x = length, y = height (stack grows on +y from 0), z = width.
// The model is centred at x = z = 0.
export type Vec3 = [number, number, number];
export interface PartBox {
id: string;
partTypeId: string;
kind: HivePartKind;
center: Vec3;
size: Vec3;
/** Stack index; an insert carries the index of its host. */
stackIndex: number;
}
export interface FrameBox {
boxId: string;
slot: number;
type: FrameType;
name?: string;
center: Vec3;
size: Vec3;
}
export interface HiveLayout {
parts: PartBox[];
inserts: PartBox[];
frames: FrameBox[];
bounds: { w: number; h: number; d: number };
}
const UNIT_MM = 100;
const ROOF_OVERLAP_MM = 35;
const BOX_WALL_MM = 20;
const FRAME_THICKNESS_MM = 25;
const FRAME_HEIGHT_MARGIN_MM = 10;
const FLOOR_INSERT_LIFT_MM = 5;
const mm = (value: number): number => value / UNIT_MM;
function sizeOf(type: CatalogPartType): Vec3 {
return [mm(type.lengthMm), mm(type.heightMm), mm(type.widthMm)];
}
function insertBox(
insert: HivePart,
type: CatalogPartType,
host: PartBox,
): PartBox {
const size = sizeOf(type);
const hostBottom = host.center[1] - host.size[1] / 2;
// FLOOR_INSERT lies just above the floor's bottom area (its centre y = floor
// bottom + 5 mm, y is always a centre); every other insert
// (the EVAPORATOR) stands on the bottom of the box it sits in.
const centerY =
type.kind === "FLOOR_INSERT" ? hostBottom + mm(FLOOR_INSERT_LIFT_MM) : hostBottom + size[1] / 2;
return {
id: insert.id,
partTypeId: insert.partTypeId,
kind: type.kind,
center: [0, centerY, 0],
size,
stackIndex: host.stackIndex,
};
}
function frameBoxes(part: HivePart, type: CatalogPartType, box: PartBox): FrameBox[] {
const frames = part.frames ?? [];
if (frames.length === 0) return [];
const inner = box.size[0] - 2 * mm(BOX_WALL_MM);
const pitch = inner / frames.length;
const size: Vec3 = [
mm(FRAME_THICKNESS_MM),
mm(type.heightMm - FRAME_HEIGHT_MARGIN_MM),
box.size[2] - 2 * mm(BOX_WALL_MM),
];
return frames.map((frame, index) => ({
boxId: part.id,
slot: frame.slot,
type: frame.type,
...(frame.name !== undefined ? { name: frame.name } : {}),
center: [-inner / 2 + pitch * (index + 0.5), box.center[1], 0],
size,
}));
}
/** Pure layout of a hive state in 3D units (1 = 100 mm). Unknown part types are skipped. */
export function hiveLayout(state: HiveState, catalog: HiveCatalog): HiveLayout {
const parts: PartBox[] = [];
const inserts: PartBox[] = [];
const frames: FrameBox[] = [];
let top = 0;
let maxTop = 0;
let width = 0;
let depth = 0;
state.stack.forEach((part, stackIndex) => {
const type = catalog.partTypes.get(part.partTypeId);
if (!type) return;
const size = sizeOf(type);
const bottom = type.kind === "ROOF" ? top - mm(ROOF_OVERLAP_MM) : top;
const box: PartBox = {
id: part.id,
partTypeId: part.partTypeId,
kind: type.kind,
center: [0, bottom + size[1] / 2, 0],
size,
stackIndex,
};
parts.push(box);
top = bottom + size[1];
maxTop = Math.max(maxTop, top);
width = Math.max(width, size[0]);
depth = Math.max(depth, size[2]);
for (const insert of part.inserts ?? []) {
const insertType = catalog.partTypes.get(insert.partTypeId);
if (insertType) inserts.push(insertBox(insert, insertType, box));
}
if (type.kind === "BOX") frames.push(...frameBoxes(part, type, box));
});
return { parts, inserts, frames, bounds: { w: width, h: maxTop, d: depth } };
}
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { partDisplayName } from "./names";
import { frames, inlineCatalog, part, templateState } from "./test-utils";
import type { HiveState } from "./types";
const catalog = inlineCatalog();
describe("partDisplayName", () => {
it("uses the kind label when a kind occurs once", () => {
expect(partDisplayName(templateState(), catalog, "f")).toBe("Boden");
expect(partDisplayName(templateState(), catalog, "b")).toBe("Zarge");
});
it("numbers same-kind parts from bottom to top", () => {
const state: HiveState = {
schemaVersion: 1,
stack: [part("f", "floor"), part("b1", "box", { frames: frames(10) }), part("b2", "box", { frames: frames(10) })],
};
expect(partDisplayName(state, catalog, "b1")).toBe("Zarge 1");
expect(partDisplayName(state, catalog, "b2")).toBe("Zarge 2");
});
it("prefers a custom label and does not number it", () => {
const state: HiveState = {
schemaVersion: 1,
stack: [part("b1", "box", { label: "Brutraum" }), part("b2", "box")],
};
expect(partDisplayName(state, catalog, "b1")).toBe("Brutraum");
expect(partDisplayName(state, catalog, "b2")).toBe("Zarge 2");
});
it("finds inserts and falls back for unknown ids", () => {
const state: HiveState = {
schemaVersion: 1,
stack: [part("b1", "box", { inserts: [part("e1", "evaporator")] })],
};
expect(partDisplayName(state, catalog, "e1")).toBe("Verdunster");
expect(partDisplayName(state, catalog, "nope")).toBe("Teil");
});
});
+38
View File
@@ -0,0 +1,38 @@
import { PART_KIND_LABELS } from "./labels";
import type { HiveCatalog, HivePart, HivePartKind, HiveState } from "./types";
const FALLBACK_NAME = "Teil";
interface Entry {
part: HivePart;
kind: HivePartKind | undefined;
}
/** Every part of the stack and its inserts, bottom to top (inserts after their host). */
function flatten(state: HiveState, catalog: HiveCatalog): Entry[] {
const entries: Entry[] = [];
const visit = (part: HivePart): void => {
entries.push({ part, kind: catalog.partTypes.get(part.partTypeId)?.kind });
for (const insert of part.inserts ?? []) visit(insert);
};
state.stack.forEach(visit);
return entries;
}
/**
* Name shown in the UI: the part's own label, else the kind label, numbered
* from bottom to top when the kind occurs more than once ("Zarge 1", "Zarge 2").
* Shared by the outline, the 3D labels and the sheets.
*/
export function partDisplayName(state: HiveState, catalog: HiveCatalog, partId: string): string {
const entries = flatten(state, catalog);
const entry = entries.find((candidate) => candidate.part.id === partId);
if (!entry) return FALLBACK_NAME;
if (entry.part.label) return entry.part.label;
if (!entry.kind) return FALLBACK_NAME;
const base = PART_KIND_LABELS[entry.kind];
const sameKind = entries.filter((candidate) => candidate.kind === entry.kind);
if (sameKind.length < 2) return base;
return `${base} ${sameKind.indexOf(entry) + 1}`;
}
+147
View File
@@ -0,0 +1,147 @@
import { describe, expect, it } from "vitest";
import { applyHiveOps } from "./ops";
import { deriveHiveFacts } from "./facts";
import { validateHiveState } from "./validate";
import {
fixtureDirExists,
inlineCatalog,
loadFixtureCases,
loadFixtureCatalog,
part,
templateState,
} from "./test-utils";
function deepFreeze<T>(value: T): T {
if (typeof value === "object" && value !== null && !Object.isFrozen(value)) {
Object.freeze(value);
Object.values(value).forEach(deepFreeze);
}
return value;
}
// Runs the shared backend fixtures; skipped when the image build does not see
// the backend folder (same pattern as lib/label-templates/placeholder-keys.spec.ts).
const hasFixtures = fixtureDirExists();
const cases = hasFixtures ? loadFixtureCases() : [];
describe("applyHiveOps backend fixtures", () => {
it.skipIf(!hasFixtures)("finds at least 20 cases", () => {
expect(cases.length).toBeGreaterThanOrEqual(20);
});
it.skipIf(!hasFixtures).each(cases.map((c) => [c.file, c.data] as const))("%s", (_file, data) => {
const catalog = loadFixtureCatalog(data.inactivePartTypeIds);
const result = applyHiveOps(
deepFreeze(structuredClone(data.state)),
deepFreeze(structuredClone(data.ops)),
catalog,
);
if (data.expect.ok) {
expect(result).toEqual({ ok: true, state: data.expect.state });
expect(validateHiveState(data.expect.state, catalog)).toEqual([]);
} else {
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.opIndex).toBe(data.expect.opIndex);
expect(result.reason.split(":")[0]).toBe(data.expect.reasonCode);
}
}
});
});
describe("applyHiveOps (inline catalog)", () => {
const catalog = inlineCatalog();
it("does not mutate a deep-frozen input", () => {
const state = deepFreeze(templateState());
const result = applyHiveOps(
state,
deepFreeze([{ op: "setLabel" as const, id: "b", label: "Brut" }]),
catalog,
);
expect(result.ok).toBe(true);
});
it("keeps a frame name when setFrame sends no name and removes it for null", () => {
const named = applyHiveOps(
templateState(),
[{ op: "setFrame", boxId: "b", slot: 2, type: "BROOD", name: "Königin" }],
catalog,
);
if (!named.ok) throw new Error(named.reason);
const kept = applyHiveOps(named.state, [{ op: "setFrame", boxId: "b", slot: 2, type: "HONEY" }], catalog);
const removed = applyHiveOps(
named.state,
[{ op: "setFrame", boxId: "b", slot: 2, type: "HONEY", name: null }],
catalog,
);
expect(kept.ok && kept.state.stack[1].frames?.[1]).toEqual({ slot: 2, type: "HONEY", name: "Königin" });
expect(removed.ok && removed.state.stack[1].frames?.[1]).toEqual({ slot: 2, type: "HONEY" });
});
it("never leaves an empty inserts array", () => {
const added = applyHiveOps(
templateState(),
[{ op: "addInsert", hostId: "b", part: part("e", "evaporator") }],
catalog,
);
if (!added.ok) throw new Error(added.reason);
const removed = applyHiveOps(added.state, [{ op: "removePart", id: "e" }], catalog);
expect(removed.ok && "inserts" in removed.state.stack[1]).toBe(false);
});
it("reports the failing op index and a reason code", () => {
const result = applyHiveOps(
templateState(),
[{ op: "setLabel", id: "b", label: "ok" }, { op: "removePart", id: "f" }],
catalog,
);
expect(result).toMatchObject({ ok: false, opIndex: 1 });
expect(!result.ok && result.reason.startsWith("RULE_FLOOR_BOTTOM")).toBe(true);
});
it("rejects more than 100 ops with LIMIT_OPS", () => {
const ops = Array.from({ length: 101 }, () => ({ op: "setLabel" as const, id: "b", label: null }));
const result = applyHiveOps(templateState(), ops, catalog);
expect(!result.ok && result.reason.startsWith("LIMIT_OPS")).toBe(true);
});
it("measures label length in UTF-16 code units", () => {
const label = "😀".repeat(21); // 42 code units, 21 code points
const result = applyHiveOps(templateState(), [{ op: "setLabel", id: "b", label }], catalog);
expect(!result.ok && result.reason.startsWith("LIMIT_TEXT")).toBe(true);
});
it("reports STATE_STEP beyond the 1e-9 tolerance and accepts inside it", () => {
const base = applyHiveOps(
templateState(),
[{ op: "addPart", part: part("fe", "feeder"), at: 2 }],
catalog,
);
if (!base.ok) throw new Error(base.reason);
const inside = applyHiveOps(base.state, [{ op: "setState", id: "fe", key: "syrupLiters", value: 1 + 1e-10 }], catalog);
const outside = applyHiveOps(base.state, [{ op: "setState", id: "fe", key: "syrupLiters", value: 1.0001 }], catalog);
expect(inside.ok).toBe(true);
expect(!outside.ok && outside.reason.startsWith("STATE_STEP")).toBe(true);
});
});
describe("deriveHiveFacts", () => {
const catalog = inlineCatalog();
it("lists every level field even at 0 and counts frames", () => {
const state = templateState();
state.stack.splice(2, 0, part("fe", "feeder"));
state.stack[1].frames = state.stack[1].frames!.map((f, i) =>
i < 3 ? { ...f, type: "BROOD" } : i < 5 ? { ...f, type: "HONEY" } : f,
);
const facts = deriveHiveFacts(state, catalog);
expect(facts).toMatchObject({ boxCount: 1, broodFrames: 3, honeyFrames: 2, builtFrames: 5 });
expect(facts.levels.map((l) => [l.key, l.value, l.feedType])).toEqual([
["syrupLiters", 0, "SUGAR_WATER"],
["fondantKg", 0, "FEED_PASTE"],
]);
expect(facts.hasFloorInsert).toBe(false);
expect(facts.evaporatorAcidMl).toBeNull();
});
});
+197
View File
@@ -0,0 +1,197 @@
import {
MAX_OPS,
type HiveApplyResult,
type HiveCatalog,
type HiveFrame,
type HiveOp,
type HivePart,
type HiveState,
} from "./types";
import { validateHiveState } from "./validate";
// Port of ApiCore-Backend/src/hive/hive-ops.ts: same semantics, same reason
// codes. Checked against the shared fixtures in ApiCore-Backend/test/fixtures.
interface Location {
part: HivePart;
list: HivePart[];
index: number;
isInsert: boolean;
}
/** Result of one op on the working copy: a reason string, or null on success. */
type OpOutcome = string | null;
function locate(state: HiveState, id: unknown): Location | null {
for (let index = 0; index < state.stack.length; index++) {
const part = state.stack[index];
if (part.id === id) return { part, list: state.stack, index, isInsert: false };
const inserts = part.inserts ?? [];
for (let i = 0; i < inserts.length; i++) {
if (inserts[i].id === id) return { part: inserts[i], list: inserts, index: i, isInsert: true };
}
}
return null;
}
function missing(id: unknown): string {
return `MISSING_ID: no part with id ${String(id)}`;
}
function clamp(value: unknown, max: number): number {
if (typeof value !== "number" || Number.isNaN(value)) return max;
return Math.min(Math.max(Math.trunc(value), 0), max);
}
/** Catalog membership and active check for a part to be added (and its inserts). */
function checkAddable(part: HivePart, catalog: HiveCatalog): OpOutcome {
if (typeof part !== "object" || part === null) return "MISSING_ID: part is not an object";
const type = catalog.partTypes.get(part.partTypeId);
if (!type) return `FOREIGN_PART_TYPE: ${String(part.partTypeId)} is not in this system`;
if (!type.active) return `INACTIVE_PART_TYPE: ${type.id} is inactive`;
for (const insert of part.inserts ?? []) {
const nested = checkAddable(insert, catalog);
if (nested) return nested;
}
return null;
}
/** Deep copy of an incoming part without empty `inserts` arrays. */
function normalisedCopy(part: HivePart): HivePart {
const copy = structuredClone(part);
if (Array.isArray(copy.inserts)) {
if (copy.inserts.length === 0) delete copy.inserts;
else copy.inserts = copy.inserts.map(normalisedCopy);
}
return copy;
}
type OpOf<K extends HiveOp["op"]> = Extract<HiveOp, { op: K }>;
function applyAddPart(state: HiveState, op: OpOf<"addPart">, catalog: HiveCatalog): OpOutcome {
const problem = checkAddable(op.part, catalog);
if (problem) return problem;
state.stack.splice(clamp(op.at, state.stack.length), 0, normalisedCopy(op.part));
return null;
}
function applyAddInsert(state: HiveState, op: OpOf<"addInsert">, catalog: HiveCatalog): OpOutcome {
const host = locate(state, op.hostId);
if (!host || host.isInsert) return missing(op.hostId);
const problem = checkAddable(op.part, catalog);
if (problem) return problem;
host.part.inserts = [...(host.part.inserts ?? []), normalisedCopy(op.part)];
return null;
}
function applyRemovePart(state: HiveState, op: OpOf<"removePart">): OpOutcome {
const found = locate(state, op.id);
if (!found) return missing(op.id);
found.list.splice(found.index, 1);
if (found.isInsert) {
const host = state.stack.find((p) => p.inserts === found.list);
if (host && found.list.length === 0) delete host.inserts;
}
return null;
}
function applyMovePart(state: HiveState, op: OpOf<"movePart">): OpOutcome {
const found = locate(state, op.id);
if (!found) return missing(op.id);
if (found.isInsert) return `RULE_INSERT_HOST: ${op.id} is an insert and cannot be moved`;
const [moved] = state.stack.splice(found.index, 1);
state.stack.splice(clamp(op.to, state.stack.length), 0, moved);
return null;
}
function applySetState(state: HiveState, op: OpOf<"setState">): OpOutcome {
const found = locate(state, op.id);
if (!found) return missing(op.id);
found.part.state = { ...found.part.state, [op.key]: op.value };
return null;
}
function locateBox(state: HiveState, boxId: unknown): Location | string {
const found = locate(state, boxId);
if (!found) return missing(boxId);
if (found.isInsert || !Array.isArray(found.part.frames)) {
return `FRAMES_LENGTH: ${String(boxId)} is not a box with frames`;
}
return found;
}
function applySetFrame(state: HiveState, op: OpOf<"setFrame">): OpOutcome {
const box = locateBox(state, op.boxId);
if (typeof box === "string") return box;
const frames = box.part.frames as HiveFrame[];
if (!Number.isInteger(op.slot) || op.slot < 1 || op.slot > frames.length) {
return `FRAMES_LENGTH: slot ${String(op.slot)} is outside 1..${frames.length}`;
}
// name: undefined keeps the current name, null removes it.
const current = frames[op.slot - 1];
const name = op.name === undefined ? current.name : (op.name ?? undefined);
frames[op.slot - 1] =
name === undefined
? { slot: op.slot, type: op.type }
: { slot: op.slot, type: op.type, name };
return null;
}
function applySetFrames(state: HiveState, op: OpOf<"setFrames">): OpOutcome {
const box = locateBox(state, op.boxId);
if (typeof box === "string") return box;
box.part.frames = structuredClone(op.frames);
return null;
}
function applySetLabel(state: HiveState, op: OpOf<"setLabel">): OpOutcome {
const found = locate(state, op.id);
if (!found) return missing(op.id);
if (op.label === null) delete found.part.label;
else found.part.label = op.label;
return null;
}
function applyOne(state: HiveState, op: HiveOp, catalog: HiveCatalog): OpOutcome {
switch (op?.op) {
case "addPart":
return applyAddPart(state, op, catalog);
case "addInsert":
return applyAddInsert(state, op, catalog);
case "removePart":
return applyRemovePart(state, op);
case "movePart":
return applyMovePart(state, op);
case "setState":
return applySetState(state, op);
case "setFrame":
return applySetFrame(state, op);
case "setFrames":
return applySetFrames(state, op);
case "setLabel":
return applySetLabel(state, op);
default:
return "UNKNOWN_OP: unsupported operation";
}
}
/**
* Applies ops in order and validates the whole state after each one.
* Never mutates its inputs: it works on a private deep copy.
*/
export function applyHiveOps(state: HiveState, ops: HiveOp[], catalog: HiveCatalog): HiveApplyResult {
if (ops.length > MAX_OPS) {
return {
ok: false,
opIndex: MAX_OPS,
reason: `LIMIT_OPS: at most ${MAX_OPS} operations per request`,
};
}
const working = structuredClone(state);
for (let opIndex = 0; opIndex < ops.length; opIndex++) {
const failure =
applyOne(working, ops[opIndex], catalog) ?? validateHiveState(working, catalog)[0] ?? null;
if (failure) return { ok: false, opIndex, reason: failure };
}
return { ok: true, state: working };
}
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { hiveReasonText } from "./reason-text";
describe("hiveReasonText", () => {
it("maps a reason code to a German sentence", () => {
expect(hiveReasonText("RULE_FLOOR_BOTTOM: the stack needs exactly one floor")).toBe(
"Der Boden muss ganz unten liegen.",
);
expect(hiveReasonText("MISSING_ID: no part with id x")).toContain("gibt es nicht mehr");
});
it("covers every code the ops and validator can produce", () => {
for (const code of [
"MISSING_ID", "DUPLICATE_ID", "FOREIGN_PART_TYPE", "INACTIVE_PART_TYPE", "FRAMES_LENGTH",
"RULE_INSERT_HOST", "RULE_INSERT_LIMIT", "RULE_FLOOR_BOTTOM", "RULE_ROOF_TOP",
"STATE_OUT_OF_RANGE", "STATE_STEP", "STATE_UNKNOWN_KEY", "UNKNOWN_FIELD", "UNKNOWN_OP",
"LIMIT_OPS", "LIMIT_PARTS", "LIMIT_TEXT",
]) {
expect(hiveReasonText(`${code}: x`)).not.toBe("Diese Änderung passt nicht zum Aufbau.");
}
});
it("falls back for unknown reasons", () => {
expect(hiveReasonText("WHATEVER: x")).toBe("Diese Änderung passt nicht zum Aufbau.");
});
});
@@ -0,0 +1,27 @@
const FALLBACK = "Diese Änderung passt nicht zum Aufbau.";
const TEXTS: Record<string, string> = {
MISSING_ID: "Ein Teil, auf das sich die Änderung bezieht, gibt es nicht mehr.",
DUPLICATE_ID: "Ein Teil kommt doppelt vor.",
FOREIGN_PART_TYPE: "Diese Teile-Art gehört nicht zum Beutensystem.",
INACTIVE_PART_TYPE: "Diese Teile-Art ist ausgeblendet.",
FRAMES_LENGTH: "Dieses Rähmchen gibt es in der Zarge nicht.",
RULE_INSERT_HOST: "Dieses Teil passt nicht an diese Stelle.",
RULE_INSERT_LIMIT: "Davon passt nur eines an diese Stelle.",
RULE_FLOOR_BOTTOM: "Der Boden muss ganz unten liegen.",
RULE_ROOF_TOP: "Das Dach muss ganz oben liegen.",
STATE_OUT_OF_RANGE: "Dieser Wert ist nicht erlaubt.",
STATE_STEP: "Dieser Wert passt nicht zur Schrittweite.",
STATE_UNKNOWN_KEY: "Dieses Feld gibt es bei diesem Teil nicht.",
UNKNOWN_FIELD: "Die Änderung enthält ein unbekanntes Feld.",
UNKNOWN_OP: "Diese Änderung wird nicht unterstützt.",
LIMIT_OPS: "Zu viele Änderungen auf einmal.",
LIMIT_PARTS: "Der Aufbau darf höchstens 30 Teile haben.",
LIMIT_TEXT: "Der Name ist zu lang.",
};
/** German sentence for a reason such as „RULE_FLOOR_BOTTOM: …“ from applyHiveOps or the server. */
export function hiveReasonText(reason: string): string {
const code = reason.split(":")[0].trim();
return TEXTS[code] ?? FALLBACK;
}
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import { deriveHiveFacts } from "./facts";
import { hiveFactsText, shortGermanDay, sourceLabel } from "./summary";
import { frames, inlineCatalog, part } from "./test-utils";
import type { HiveState } from "./types";
const catalog = inlineCatalog();
describe("shortGermanDay", () => {
it("writes 2026-10-12 as 12.10.", () => {
expect(shortGermanDay("2026-10-12")).toBe("12.10.");
});
});
describe("sourceLabel", () => {
it("names the sources the way the beekeeper knows them", () => {
expect(sourceLabel("CARD")).toBe("Karte");
expect(sourceLabel("EDITOR")).toBe("Umbau");
expect(sourceLabel("INSPECTION")).toBe("Durchsicht");
expect(sourceLabel("INIT")).toBe("Angelegt");
});
});
describe("hiveFactsText", () => {
it("lists boxes, brood, feed and the floor insert", () => {
const state: HiveState = {
schemaVersion: 1,
stack: [
part("f", "floor", { inserts: [part("fi", "floor-insert")] }),
part("b1", "box", { frames: frames(3, "BROOD") }),
part("b2", "box", { frames: frames(3, "BROOD") }),
part("fe", "feeder", { state: { syrupLiters: 3, fondantKg: 0 } }),
part("fe2", "feeder", { state: { syrupLiters: 2, fondantKg: 0 } }),
],
};
expect(hiveFactsText(deriveHiveFacts(state, catalog))).toBe(
"2 Zargen · 6 Brutwaben · Futter 5 l · Bausperre drin",
);
});
it("uses singular forms and leaves out what is not there", () => {
const state: HiveState = {
schemaVersion: 1,
stack: [part("b1", "box", { frames: frames(1, "BROOD") })],
};
expect(hiveFactsText(deriveHiveFacts(state, catalog))).toBe("1 Zarge · 1 Brutwabe");
});
it("shows acid of an evaporator", () => {
const state: HiveState = {
schemaVersion: 1,
stack: [part("b1", "box", { frames: frames(1), inserts: [part("ev", "evaporator", { state: { acidMl: 40 } })] })],
};
expect(hiveFactsText(deriveHiveFacts(state, catalog))).toContain("Säure 40 ml");
});
});
+49
View File
@@ -0,0 +1,49 @@
import type { HiveChangeSource, HiveFacts } from "./types";
const SOURCE_LABELS: Record<HiveChangeSource, string> = {
INIT: "Angelegt",
CARD: "Karte",
EDITOR: "Umbau",
INSPECTION: "Durchsicht",
AI_SUGGESTION: "Vorschlag",
};
export function sourceLabel(source: HiveChangeSource): string {
return SOURCE_LABELS[source];
}
/** "2026-10-12" -> "12.10." */
export function shortGermanDay(day: string): string {
const [, month, date] = day.split("-");
return `${date}.${month}.`;
}
const numberFormat = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 3 });
function plural(count: number, one: string, many: string): string {
return `${count} ${count === 1 ? one : many}`;
}
/** Feed levels (liquid and paste) summed per unit: "5 l" or "5 l, 2 kg". */
function feedText(facts: HiveFacts): string | null {
const perUnit = new Map<string, number>();
for (const level of facts.levels) {
if (!level.feedType || level.value <= 0) continue;
perUnit.set(level.unit, (perUnit.get(level.unit) ?? 0) + level.value);
}
if (perUnit.size === 0) return null;
const parts = [...perUnit].map(([unit, value]) => `${numberFormat.format(value)} ${unit}`);
return `Futter ${parts.join(", ")}`;
}
/** "2 Zargen · 6 Brutwaben · Futter 5 l · Bausperre drin" (spec 9.4); empty parts are left out. */
export function hiveFactsText(facts: HiveFacts): string {
const parts: (string | null)[] = [
plural(facts.boxCount, "Zarge", "Zargen"),
facts.broodFrames > 0 ? plural(facts.broodFrames, "Brutwabe", "Brutwaben") : null,
feedText(facts),
facts.evaporatorAcidMl !== null ? `Säure ${numberFormat.format(facts.evaporatorAcidMl)} ml` : null,
facts.hasFloorInsert ? "Bausperre drin" : null,
];
return parts.filter((part): part is string => part !== null).join(" · ");
}
+125
View File
@@ -0,0 +1,125 @@
import { existsSync, readFileSync, readdirSync } from "node:fs";
import path from "node:path";
import { catalogFromRows } from "./types";
import type { CatalogPartType, HiveCatalog, HiveFrame, HiveOp, HivePart, HiveState } from "./types";
// Test helpers shared by the hive specs. The backend fixtures sit next to the
// frontend in the monorepo; vitest runs from ApiCore-Frontend. The frontend
// image build only sees ApiCore-Frontend, so fixture specs skip there.
export const FIXTURE_DIR = path.resolve(process.cwd(), "../ApiCore-Backend/test/fixtures/hive-ops");
export interface FixtureCase {
name: string;
inactivePartTypeIds?: string[];
state: HiveState;
ops: HiveOp[];
expect:
| { ok: true; state: HiveState }
| { ok: false; opIndex: number; reasonCode: string };
}
interface CatalogFile {
systemId: string;
partTypes: CatalogPartType[];
}
export function fixtureDirExists(): boolean {
return existsSync(FIXTURE_DIR);
}
export function loadFixtureCases(): { file: string; data: FixtureCase }[] {
return readdirSync(FIXTURE_DIR)
.filter((file) => file.endsWith(".json") && file !== "catalog.json")
.sort()
.map((file) => ({
file,
data: JSON.parse(readFileSync(path.join(FIXTURE_DIR, file), "utf8")) as FixtureCase,
}));
}
export function loadFixtureCatalog(inactivePartTypeIds: string[] = []): HiveCatalog {
const file = JSON.parse(readFileSync(path.join(FIXTURE_DIR, "catalog.json"), "utf8")) as CatalogFile;
const rows = file.partTypes.map((row) => ({
...row,
active: row.active && !inactivePartTypeIds.includes(row.id),
}));
return catalogFromRows(rows, file.systemId);
}
// ---- Inline catalog for specs that must run without the backend folder ----
const noState: CatalogPartType["stateSchema"] = [];
function type(
id: string,
kind: CatalogPartType["kind"],
lengthMm: number,
widthMm: number,
heightMm: number,
extra: Partial<CatalogPartType> = {},
): CatalogPartType {
return {
id,
kind,
name: id,
lengthMm,
widthMm,
heightMm,
frameSlots: null,
stateSchema: noState,
active: true,
...extra,
};
}
/** Mirrors the Zander/Liebig catalog the backend fixtures use. */
export function inlineCatalog(): HiveCatalog {
return catalogFromRows(
[
type("floor", "FLOOR", 525, 430, 120),
type("floor-insert", "FLOOR_INSERT", 475, 375, 5),
type("box", "BOX", 525, 430, 230, { frameSlots: 10 }),
type("cover", "COVER_SHEET", 478, 378, 1),
type("feeder", "FEEDER", 520, 420, 70, {
stateSchema: [
{ key: "syrupLiters", label: "Flüssigfutter", kind: "level", unit: "l", max: 3, step: 0.5, feedType: "SUGAR_WATER" },
{ key: "fondantKg", label: "Futterteig", kind: "level", unit: "kg", max: 2.5, step: 0.5, feedType: "FEED_PASTE" },
],
}),
type("inner-lid", "INNER_LID", 525, 430, 35, {
stateSchema: [
{ key: "feedHole", label: "Futterloch", kind: "choice", options: ["OPEN", "CLOSED"], optionLabels: ["offen", "zu"] },
],
}),
type("insulation", "INSULATION", 465, 430, 20),
type("roof", "ROOF", 525, 430, 60),
type("evaporator", "EVAPORATOR", 200, 150, 120, {
stateSchema: [{ key: "acidMl", label: "Säure", kind: "level", unit: "ml", max: 300, step: 10 }],
}),
],
"sys",
);
}
export function frames(count: number, kind: HiveFrame["type"] = "FOUNDATION"): HiveFrame[] {
return Array.from({ length: count }, (_, index) => ({ slot: index + 1, type: kind }));
}
export function part(id: string, partTypeId: string, extra: Partial<HivePart> = {}): HivePart {
return { id, partTypeId, state: {}, ...extra };
}
/** Template of the system: floor, full box, cover, inner lid, insulation, roof. */
export function templateState(): HiveState {
return {
schemaVersion: 1,
stack: [
part("f", "floor"),
part("b", "box", { frames: frames(10) }),
part("c", "cover"),
part("l", "inner-lid"),
part("i", "insulation"),
part("r", "roof"),
],
};
}
+234
View File
@@ -0,0 +1,234 @@
// Frontend mirror of ApiCore-Backend/src/hive/hive-types.ts and the API
// shapes (spec 5.2, 6.1). Declared here on purpose: the Docker build only
// sees ApiCore-Frontend, so nothing is imported from the backend folder.
export type HivePartKind =
| "FLOOR"
| "BOX"
| "DIVIDER_BOARD"
| "QUEEN_EXCLUDER"
| "FEEDER"
| "COVER_SHEET"
| "PROPOLIS_SCREEN"
| "INNER_LID"
| "INSULATION"
| "ROOF"
| "FLOOR_INSERT"
| "EVAPORATOR";
export type HiveBoxDepth = "FULL" | "SHALLOW";
export type FrameType =
| "BROOD"
| "DRONE"
| "HONEY"
| "POLLEN"
| "FEED"
| "FOUNDATION"
| "EMPTY_COMB"
| "DRONE_FRAME"
| "DIVIDER"
| "NONE";
export const FRAME_TYPES: readonly FrameType[] = [
"BROOD",
"DRONE",
"HONEY",
"POLLEN",
"FEED",
"FOUNDATION",
"EMPTY_COMB",
"DRONE_FRAME",
"DIVIDER",
"NONE",
];
export const MAX_STACK_PARTS = 30;
export const MAX_OPS = 100;
export const MAX_TEXT_LENGTH = 40;
export const LEVEL_TOLERANCE = 1e-9;
export interface HiveFrame {
slot: number;
type: FrameType;
name?: string;
}
export interface HivePart {
id: string;
partTypeId: string;
label?: string;
state: Record<string, number | string>;
inserts?: HivePart[];
frames?: HiveFrame[];
}
export interface HiveState {
schemaVersion: 1;
stack: HivePart[];
}
export type HiveOp =
| { op: "addPart"; part: HivePart; at: number }
| { op: "addInsert"; hostId: string; part: HivePart }
| { op: "removePart"; id: string }
| { op: "movePart"; id: string; to: number }
| { op: "setState"; id: string; key: string; value: number | string }
| {
op: "setFrame";
boxId: string;
slot: number;
type: FrameType;
name?: string | null;
}
| { op: "setFrames"; boxId: string; frames: HiveFrame[] }
| { op: "setLabel"; id: string; label: string | null };
export interface LevelField {
key: string;
label: string;
kind: "level";
unit: string;
max: number;
step: number;
feedType?: string;
}
export interface ChoiceField {
key: string;
label: string;
kind: "choice";
options: string[];
optionLabels: string[];
}
export type StateField = LevelField | ChoiceField;
export interface CatalogPartType {
id: string;
kind: HivePartKind;
name: string;
lengthMm: number;
widthMm: number;
heightMm: number;
frameSlots: number | null;
stateSchema: StateField[];
active: boolean;
}
export interface HiveCatalog {
systemId: string;
partTypes: Map<string, CatalogPartType>;
}
export function catalogFromRows(rows: CatalogPartType[], systemId: string): HiveCatalog {
return { systemId, partTypes: new Map(rows.map((row) => [row.id, row])) };
}
export interface HiveLevelFact {
partId: string;
key: string;
label: string;
unit: string;
value: number;
max: number;
feedType?: string;
}
export interface HiveFacts {
boxCount: number;
broodFrames: number;
honeyFrames: number;
builtFrames: number;
levels: HiveLevelFact[];
hasFloorInsert: boolean;
evaporatorAcidMl: number | null;
}
export type HiveApplyResult =
| { ok: true; state: HiveState }
| { ok: false; opIndex: number; reason: string };
// ---- API shapes (hive.service.ts, hive-catalog.service.ts) ----
export type HiveChangeSource = "INIT" | "CARD" | "EDITOR" | "INSPECTION" | "AI_SUGGESTION";
/** Sources a client may send; INIT and INSPECTION are set by the server. */
export type ClientHiveChangeSource = "CARD" | "EDITOR";
export interface HivePartTypeDto extends CatalogPartType {
systemId: string;
articleRef: string | null;
boxDepth: HiveBoxDepth | null;
createdAt: string;
updatedAt: string;
}
export interface HiveSystemDto {
id: string;
name: string;
frameWidthMm: number;
frameHeightMm: number;
footprintLMm: number;
footprintWMm: number;
template: unknown;
active: boolean;
partTypes: HivePartTypeDto[];
createdAt: string;
updatedAt: string;
}
export interface HiveSetupView {
colonyId: string;
systemId: string;
version: number;
state: HiveState;
facts: HiveFacts;
updatedAt: string;
}
export interface HiveChangeListItem {
id: string;
version: number;
occurredOn: string;
source: HiveChangeSource;
inspectionId: string | null;
userId: string;
userName: string;
ops: unknown;
note: string | null;
createdAt: string;
}
export interface HiveHistoryPage {
items: HiveChangeListItem[];
nextCursor: string | null;
}
export interface HiveAtView {
version: number;
state: HiveState;
facts: HiveFacts;
occurredOn: string;
}
export interface CreateHiveInput {
systemId: string;
state: HiveState;
occurredOn: string;
}
export interface PostHiveChangeInput {
occurredOn: string;
source: ClientHiveChangeSource;
ops: HiveOp[];
note?: string;
}
/** Body of the 409 HIVE_CONFLICT answer. */
export interface HiveConflictBody {
code: "HIVE_CONFLICT";
opIndex: number;
reason: string;
current: { version: number; state: HiveState };
}
+217
View File
@@ -0,0 +1,217 @@
import {
FRAME_TYPES,
LEVEL_TOLERANCE,
MAX_STACK_PARTS,
MAX_TEXT_LENGTH,
type CatalogPartType,
type HiveCatalog,
type StateField,
} from "./types";
// Port of ApiCore-Backend/src/hive/hive-validate.ts. Check order decides which
// code is reported first: root shape (schemaVersion must be 1 ->
// STATE_OUT_OF_RANGE; extra root keys -> UNKNOWN_FIELD), LIMIT_PARTS, then per
// stack part: id, unknown fields (UNKNOWN_FIELD), type, label, state, frames
// (per frame: slot, unknown fields, type, name), inserts (each insert in the
// same order), then FLOOR, then ROOF. Text lengths are UTF-16 code units
// (`.length`).
const ROOT_KEYS = ["schemaVersion", "stack"];
const PART_KEYS = ["id", "partTypeId", "label", "state", "inserts", "frames"];
const FRAME_KEYS = ["slot", "type", "name"];
type Obj = Record<string, unknown>;
const isObj = (value: unknown): value is Obj =>
typeof value === "object" && value !== null && !Array.isArray(value);
function unknownFields(obj: Obj, allowed: string[], where: string, out: string[]): void {
for (const key of Object.keys(obj)) {
if (!allowed.includes(key)) out.push(`UNKNOWN_FIELD: ${where}.${key}`);
}
}
/** Checks one state value against its schema field; returns a reason or null. */
export function checkStateValue(field: StateField, value: unknown, where: string): string | null {
if (field.kind === "choice") {
if (typeof value !== "string" || !field.options.includes(value)) {
return `STATE_OUT_OF_RANGE: ${where} must be one of ${field.options.join("/")}`;
}
return null;
}
if (typeof value !== "number" || !Number.isFinite(value)) {
return `STATE_OUT_OF_RANGE: ${where} must be a number`;
}
if (value < 0 || value > field.max) {
return `STATE_OUT_OF_RANGE: ${where} must be between 0 and ${field.max}`;
}
const steps = value / field.step;
if (Math.abs(steps - Math.round(steps)) * field.step > LEVEL_TOLERANCE) {
return `STATE_STEP: ${where} must be a multiple of ${field.step}`;
}
return null;
}
function validateState(part: Obj, type: CatalogPartType, where: string, out: string[]): void {
if (!isObj(part.state)) {
out.push(`STATE_OUT_OF_RANGE: ${where} has no state object`);
return;
}
for (const [key, value] of Object.entries(part.state)) {
const field = type.stateSchema.find((f) => f.key === key);
if (!field) {
out.push(`STATE_UNKNOWN_KEY: ${where} has unknown key ${key}`);
continue;
}
const problem = checkStateValue(field, value, `${where}.${key}`);
if (problem) out.push(problem);
}
}
function validateFrame(frame: unknown, index: number, where: string, out: string[]): void {
if (!isObj(frame) || frame.slot !== index + 1) {
out.push(`FRAMES_LENGTH: ${where} frame ${index + 1} has a wrong slot`);
return;
}
unknownFields(frame, FRAME_KEYS, `${where}.frames[${index}]`, out);
if (!(FRAME_TYPES as readonly unknown[]).includes(frame.type)) {
out.push(`STATE_OUT_OF_RANGE: ${where} frame ${index + 1} has an unknown type`);
}
if (frame.name === undefined) return;
if (typeof frame.name !== "string") {
out.push(`STATE_OUT_OF_RANGE: ${where} frame ${index + 1} name must be a string`);
} else if (frame.name.length > MAX_TEXT_LENGTH) {
out.push(`LIMIT_TEXT: ${where} frame ${index + 1} name is too long`);
}
}
function validateFrames(part: Obj, type: CatalogPartType, where: string, out: string[]): void {
const slots = type.kind === "BOX" ? (type.frameSlots ?? 0) : 0;
const frames = part.frames;
if (type.kind !== "BOX") {
if (frames !== undefined && !(Array.isArray(frames) && frames.length === 0)) {
out.push(`FRAMES_LENGTH: ${where} is not a box and cannot have frames`);
}
return;
}
const list: unknown = frames === undefined && slots === 0 ? [] : frames;
if (!Array.isArray(list) || list.length !== slots) {
out.push(`FRAMES_LENGTH: ${where} needs exactly ${slots} frames`);
return;
}
list.forEach((frame: unknown, index) => validateFrame(frame, index, where, out));
}
interface WalkContext {
catalog: HiveCatalog;
seenIds: Set<string>;
out: string[];
}
/** Checks id, catalog membership, label, state, frames. Returns the type or null. */
function validatePartCommon(
part: unknown,
where: string,
ctx: WalkContext,
): { part: Obj; type: CatalogPartType } | null {
const { out } = ctx;
if (!isObj(part)) {
out.push(`MISSING_ID: ${where} is not an object`);
return null;
}
if (typeof part.id !== "string" || part.id === "") {
out.push(`MISSING_ID: ${where} has no id`);
} else if (ctx.seenIds.has(part.id)) {
out.push(`DUPLICATE_ID: ${part.id}`);
} else {
ctx.seenIds.add(part.id);
}
unknownFields(part, PART_KEYS, where, out);
const type =
typeof part.partTypeId === "string" ? ctx.catalog.partTypes.get(part.partTypeId) : undefined;
if (!type) {
out.push(`FOREIGN_PART_TYPE: ${where} part type is not in this system`);
return null;
}
if (part.label !== undefined) {
if (typeof part.label !== "string") {
out.push(`STATE_OUT_OF_RANGE: ${where} label must be a string`);
} else if (part.label.length > MAX_TEXT_LENGTH) {
out.push(`LIMIT_TEXT: ${where} label is too long`);
}
}
validateState(part, type, where, out);
validateFrames(part, type, where, out);
return { part, type };
}
function allowedInsertKind(hostType: CatalogPartType): string | null {
if (hostType.kind === "FLOOR") return "FLOOR_INSERT";
if (hostType.kind === "BOX") return "EVAPORATOR";
return null;
}
function validateInserts(host: Obj, hostType: CatalogPartType, where: string, ctx: WalkContext): void {
const { out } = ctx;
const inserts = host.inserts;
if (inserts === undefined) return;
if (!Array.isArray(inserts)) {
out.push(`RULE_INSERT_HOST: ${where} inserts must be a list`);
return;
}
const allowed = allowedInsertKind(hostType);
if (inserts.length > 0 && allowed === null) {
out.push(`RULE_INSERT_HOST: ${where} cannot hold inserts`);
}
if (allowed !== null && inserts.length > 1) {
out.push(`RULE_INSERT_LIMIT: ${where} holds at most one ${allowed}`);
}
inserts.forEach((insert: unknown, index) => {
const here = `${where}.inserts[${index}]`;
const checked = validatePartCommon(insert, here, ctx);
if (!checked) return;
if (allowed !== null && checked.type.kind !== allowed) {
out.push(`RULE_INSERT_HOST: ${here} must be ${allowed}`);
}
const nested = checked.part.inserts;
if (nested !== undefined && (!Array.isArray(nested) || nested.length > 0)) {
out.push(`RULE_INSERT_HOST: ${here} cannot hold inserts`);
}
});
}
function validateStackRules(kinds: (CatalogPartType | null)[], out: string[]): void {
const floors = kinds.filter((t) => t?.kind === "FLOOR").length;
if (floors !== 1 || kinds[0]?.kind !== "FLOOR") {
out.push("RULE_FLOOR_BOTTOM: the stack needs exactly one floor at the bottom");
}
const roofIndexes = kinds.map((t, i) => (t?.kind === "ROOF" ? i : -1)).filter((i) => i >= 0);
if (roofIndexes.length > 1 || (roofIndexes.length === 1 && roofIndexes[0] !== kinds.length - 1)) {
out.push("RULE_ROOF_TOP: the roof must be the single top part");
}
}
/** Validates a whole hive state. Empty result means valid. */
export function validateHiveState(state: unknown, catalog: HiveCatalog): string[] {
const out: string[] = [];
if (!isObj(state) || state.schemaVersion !== 1 || !Array.isArray(state.stack)) {
return ["STATE_OUT_OF_RANGE: state needs schemaVersion 1 and a stack list"];
}
unknownFields(state, ROOT_KEYS, "state", out);
if (state.stack.length > MAX_STACK_PARTS) {
out.push(`LIMIT_PARTS: at most ${MAX_STACK_PARTS} parts in the stack`);
}
const ctx: WalkContext = { catalog, seenIds: new Set(), out };
const types: (CatalogPartType | null)[] = [];
state.stack.forEach((entry: unknown, index) => {
const where = `stack[${index}]`;
const checked = validatePartCommon(entry, where, ctx);
types.push(checked?.type ?? null);
if (!checked) return;
if (checked.type.kind === "FLOOR_INSERT" || checked.type.kind === "EVAPORATOR") {
out.push(`RULE_INSERT_HOST: ${where} must be an insert, not a stack part`);
}
validateInserts(checked.part, checked.type, where, ctx);
});
validateStackRules(types, out);
return out;
}
@@ -0,0 +1,108 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { useEffectiveMotionLevel, useMotionPreference } from "./motion-preference";
type Listener = () => void;
const originalMatchMedia = window.matchMedia;
function mockReduceMotion(initial: boolean) {
let matches = initial;
const listeners = new Set<Listener>();
const mql = {
get matches() {
return matches;
},
media: "(prefers-reduced-motion: reduce)",
addEventListener: (_: string, listener: Listener) => listeners.add(listener),
removeEventListener: (_: string, listener: Listener) => listeners.delete(listener),
};
window.matchMedia = (() => mql) as unknown as typeof window.matchMedia;
return {
listenerCount: () => listeners.size,
set(next: boolean) {
matches = next;
listeners.forEach((listener) => listener());
},
};
}
describe("useEffectiveMotionLevel", () => {
beforeEach(() => window.localStorage.clear());
afterEach(() => {
window.matchMedia = originalMatchMedia;
});
it("returns subtle when reduce-motion matches and the stored level is full", () => {
mockReduceMotion(true);
window.localStorage.setItem("apicore.motion", "full");
const { result } = renderHook(() => useEffectiveMotionLevel());
expect(result.current).toBe("subtle");
});
it("updates after the media change event", () => {
const media = mockReduceMotion(false);
const { result } = renderHook(() => useEffectiveMotionLevel());
expect(result.current).toBe("full");
act(() => media.set(true));
expect(result.current).toBe("subtle");
});
it("updates on storage events for the motion key", () => {
mockReduceMotion(false);
const { result } = renderHook(() => useEffectiveMotionLevel());
act(() => {
window.localStorage.setItem("apicore.motion", "off");
window.dispatchEvent(new StorageEvent("storage", { key: "apicore.motion" }));
});
expect(result.current).toBe("off");
});
it("updates when setLevel of useMotionPreference is called in the same tab", () => {
mockReduceMotion(false);
const effective = renderHook(() => useEffectiveMotionLevel());
const preference = renderHook(() => useMotionPreference());
act(() => preference.result.current[1]("off"));
expect(effective.result.current).toBe("off");
});
it("stops listening after unmount", () => {
const media = mockReduceMotion(false);
const remove = vi.spyOn(window, "removeEventListener");
const { unmount } = renderHook(() => useEffectiveMotionLevel());
expect(media.listenerCount()).toBe(1);
unmount();
expect(media.listenerCount()).toBe(0);
const removed = remove.mock.calls.map(([type]) => type);
expect(removed).toContain("storage");
expect(removed).toContain("apicore:motion");
remove.mockRestore();
});
it("falls back to the applied document level when storage is unreadable", () => {
mockReduceMotion(false);
document.documentElement.dataset.motion = "off";
const getItem = vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
throw new Error("blocked");
});
try {
const { result } = renderHook(() => useEffectiveMotionLevel());
expect(result.current).toBe("off");
} finally {
getItem.mockRestore();
delete document.documentElement.dataset.motion;
}
});
});
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useState } from "react";
import { useCallback, useState, useSyncExternalStore } from "react";
/**
* How much the interface moves. A per-device preference (localStorage), not
@@ -20,6 +20,9 @@ export const MOTION_LEVELS: MotionLevel[] = ["full", "subtle", "off"];
export const DEFAULT_MOTION_LEVEL: MotionLevel = "full";
const STORAGE_KEY = "apicore.motion";
const REDUCE_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
/** Fired on window by setLevel (after applying); "storage" never fires in the own tab. */
export const MOTION_EVENT = "apicore:motion";
function isMotionLevel(value: unknown): value is MotionLevel {
return value === "full" || value === "subtle" || value === "off";
@@ -30,8 +33,11 @@ export function readMotionLevel(): MotionLevel {
const stored = window.localStorage.getItem(STORAGE_KEY);
return isMotionLevel(stored) ? stored : DEFAULT_MOTION_LEVEL;
} catch {
// Private mode / blocked storage: fall back to the default.
return DEFAULT_MOTION_LEVEL;
// Private mode / blocked storage: keep what is already applied to the
// document (setLevel still applies it), else the default.
const applied =
typeof document === "undefined" ? undefined : document.documentElement.dataset.motion;
return isMotionLevel(applied) ? applied : DEFAULT_MOTION_LEVEL;
}
}
@@ -43,10 +49,36 @@ export function applyMotionLevel(level: MotionLevel): void {
export function effectiveMotionLevel(level: MotionLevel = readMotionLevel()): MotionLevel {
const systemReduces =
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
window.matchMedia(REDUCE_MOTION_QUERY).matches;
return systemReduces && level === "full" ? "subtle" : level;
}
function subscribeToMotionLevel(onChange: () => void): () => void {
const media =
typeof window.matchMedia === "function" ? window.matchMedia(REDUCE_MOTION_QUERY) : null;
const onStorage = (event: StorageEvent) => {
// key === null: storage was cleared.
if (event.key === null || event.key === STORAGE_KEY) onChange();
};
media?.addEventListener("change", onChange);
window.addEventListener("storage", onStorage);
window.addEventListener(MOTION_EVENT, onChange);
return () => {
media?.removeEventListener("change", onChange);
window.removeEventListener("storage", onStorage);
window.removeEventListener(MOTION_EVENT, onChange);
};
}
/** Reactive effective level: follows the system setting, other tabs and setLevel. */
export function useEffectiveMotionLevel(): MotionLevel {
return useSyncExternalStore(
subscribeToMotionLevel,
() => effectiveMotionLevel(),
() => DEFAULT_MOTION_LEVEL,
);
}
export function useMotionPreference(): [MotionLevel, (level: MotionLevel) => void] {
// Lazy read: the app shell renders only on the client after login, and the
// document attribute itself is applied once in Providers.
@@ -62,6 +94,7 @@ export function useMotionPreference(): [MotionLevel, (level: MotionLevel) => voi
} catch {
// Not persisted - still applies for this visit.
}
window.dispatchEvent(new Event(MOTION_EVENT));
}, []);
return [level, setLevel];