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];
+1 -1
View File
@@ -337,7 +337,7 @@ Plan: `docs/superpowers/plans/2026-10-14-t6-compliance.md`, Annahmen ab 59 in AR
Spec: `docs/superpowers/specs/2026-10-15-beute-3d-design.md`. Teil 1 (Daten) und 2 (3D-Ansicht) in dieser Spec, Teil 3 (KI-Bewertung aus Foto, Aufbau-Vorschlag) und 4 (KI-Gespräch in der Durchsicht, auf T7) folgen mit eigenen Specs.
- [x] L1 Daten und API (Katalog Liebig-Zander, Aufbau je Volk, Operationen, Historie)
- [ ] L2 Karte ohne 3D (SVG, Aufbau-Liste, Sheets, Volk-Seite, Historie, Vorschläge Fütterung/Behandlung)
- [x] L2 Karte ohne 3D (SVG, Aufbau-Liste, Sheets, Volk-Seite, Historie, Vorschläge Fütterung/Behandlung)
- [ ] L3 3D-Szene (holografischer Stil, Einpassen, Fokus-Modus, Pegel, Bewegungsstufen)
- [ ] L4 Umbauen (Vollbild, Drag & Drop, Rückgängig)
- [ ] L5 Durchsicht und Offline
@@ -376,10 +376,14 @@ Der Zustand der Ansicht (berührtes Teil, offene Zarge, berührtes Rähmchen) li
### 9.3 Stil
- **Hintergrund der Karte:** immer ein dunkler „Bildschirm“ (eigene Tokens `--holo-bg`, `--holo-edge`, `--holo-edge-strong`, `--holo-fill`, `--holo-label-bg`, `--holo-label-fg` in `:root` und `.dark`; die Sonne-Sätze kommen ab T5). Ein feines Raster am Boden gibt Halt.
- **Teile:** Fläche mit `MeshBasicMaterial`, Deckkraft 0,06–0,12, additiv. Kanten mit drei `<Edges>` in `--holo-edge`. Bloom aus `@react-three/postprocessing` mit hoher Helligkeitsschwelle, sodass nur Kanten, Pegel und gewählte Teile leuchten.
- **Rähmchen:** im Ruhezustand nur Oberträger und Umriss als Linien (Deckkraft 0,25). Im Fokus-Modus als Platten mit Kante und einer Tönung je Art. Die Art steht immer auch als Wort im Etikett und in der Liste (T5: Zustand nie nur über Farbe).
- **Pegel:** durchsichtiges, leuchtendes Volumen mit einer leicht wogenden Oberfläche (Vertex-Verschiebung mit `sin`, nur bei Bewegung `full`). Etikett „5 l / 3 l“.
Inhaber-Entscheidung 2026-10-16 nach Bild-Entwürfen (`.superpowers/mockups/`, Richtung D/E/F, Kalibrierung G/H/I): Die Karte ist eine **randlose, tiefe Holo-Bühne** im warmen App-Rahmen. Qualitätsanspruch: wirkt wie ein Produkt-Render aus einer Keynote, nicht wie eine Linienzeichnung.
- **Bühne:** füllt die ganze Karte (Titel „Beute“, Stand-Zeile und Knöpfe liegen als Milchglas-Elemente über der Bühne). Tiefes Mitternachtsblau mit Vignette, ein dezentes Waben-Raster als spiegelnder Boden, wenige langsam treibende Lichtpartikel, weiche Lichtquelle von unten.
- **Ruhe-Ansicht: alles reines Glas.** Jedes Teil (Dach, Deckel, Dämmung, Folie, Futterzarge, Zargen, Boden, Einlagen) ist klares Glas mit scharfen, blau leuchtenden Kanten (Bloom), leichter Lichtbrechung und Spiegelung. Kein Holz, keine Bienen, keine Farben. Rähmchen sind nur als feine Glas-Umrisse angedeutet, Füllstände nicht sichtbar.
- **Fokus-Ansicht:** Das gewählte Teil zeigt seinen **Inhalt**: Futterzarge mit leuchtendem, leicht wogendem goldenem Flüssigfutter und einem Futterteig-Block mit Etiketten „Flüssigfutter 2,5 l von 3 l“, „Futterteig 1 kg“; Verdunster mit Säurepegel; Boden mit Bausperre bzw. Varroa-Schublade; Deckel und Dach mit ihren Zuständen. Zargen zeigen ihre Rähmchen als klare Glasplatten.
- **Rähmchen-Look:** Ein Rähmchen bekommt seine Art als Farbe **und** Muster erst, wenn es angehoben (Hover/Tippen) oder ausgewählt ist: Brutwabe bernstein mit verdeckelter Brut-Wabenstruktur, Drohnenwabe dunkleres Bernstein mit gewölbten Zellen, Honigwabe gold glänzend, Pollenwabe orange-gefleckt, Futterwabe hellgold, Mittelwand helles Wachs-Raster, Leerwabe leere Zellen, Baurahmen offener Rahmen, Schied massive Glasplatte. Die Art steht zusätzlich als Wort im Etikett (T5: Zustand nie nur über Farbe).
- **Pegel:** leuchtendes, halbtransparentes Volumen mit sanft wogender Oberfläche (nur bei Bewegung `full`).
- Eigene Tokens `--holo-*` in `:root` und `.dark`; Sonne-Sätze ab T5.
- **Etiketten:** drei `<Html>` mit DOM-Text in `--holo-label-*` (scharf, übersetzbar, gut lesbar). Höchstens drei Etiketten gleichzeitig.
- **Sonne-Modus (ab T5, solange es ihn nicht gibt, entfällt dieser Punkt):** kein Bloom, Kanten 2 px in reinem Weiß auf Schwarz, Tönungen der Rähmchen durch Muster ersetzt (Schraffur-Textur je Art), Etiketten 16 px.
- **`forced-colors`:** Die Karte zeigt den SVG-Rückfall mit `CanvasText`-Strichen.
@@ -389,10 +393,10 @@ Der Zustand der Ansicht (berührtes Teil, offene Zarge, berührtes Rähmchen) li
| Aktion | Maus | Touch | Ergebnis |
|---|---|---|---|
| Teil berühren | Hover | erstes Tippen | Kanten leuchten stärker, Teil hebt sich 8 mm, Etikett mit Namen. Andere Teile bleiben. |
| Zarge öffnen | Klick | zweites Tippen | Fokus-Modus: Zarge fliegt nach vorn und wächst auf das 1,4-Fache, andere Teile weichen 300 mm zurück und verblassen auf 0,15. Rähmchen werden zu Platten. Knopf „Zurück“ erscheint. |
| Rähmchen berühren (im Fokus) | Hover | erstes Tippen | Rähmchen steigt um 40 % seiner Höhe aus der Zarge, Etikett „R4 · Brutwabe · Name“. |
| Teil öffnen (jedes Teil: Zarge, Futterzarge, Boden, Deckel, Dach, Einlage, Verdunster) | Klick | zweites Tippen | Fokus-Modus: Das Teil fliegt aus dem Stapel nach vorn in die Mitte und wird groß (Kamera passt auf das Teil ein). Alle anderen Teile bleiben an ihrem Platz als fast durchsichtiger Schatten der ganzen Beute (Deckkraft ≈ 0,08), damit man sieht, woher das Teil kommt. Das Teil zeigt seinen Inhalt (9.3). Knopf „Zurück“ und der Name des Teils erscheinen. |
| Rähmchen berühren (im Fokus einer Zarge) | Hover | erstes Tippen | Rähmchen steigt um 40 % seiner Höhe aus der Zarge und bekommt seinen Rähmchen-Look (Farbe + Muster, 9.3), Etikett „R4 · Brutwabe · Name“. Beim Verlassen sinkt es zurück und wird wieder klares Glas, außer es ist ausgewählt. |
| Rähmchen ändern | Klick | zweites Tippen | Sheet „Rähmchen 4“ (`components/ui/sheet.tsx`): Art als `ChoiceGroup` (`components/form/choice-group.tsx`), Name, „Speichern“. Speichert eine Änderung (Quelle `CARD`). |
| Teil mit Zustand ändern | Klick auf Futterzarge, Boden, Holzdeckel, Zwischenboden, Verdunster | zweites Tippen | Sheet mit Zustandsfeldern (`NumberStepper` für Pegel, `ChoiceGroup` für `choice`), beim Boden zusätzlich „Bausperre einlegen/entfernen“, bei einer Zarge „Verdunster einsetzen/entfernen“. |
| Teil mit Zustand ändern | im Fokus: Klick auf den Inhalt (Pegel, Bausperre …) oder Glas-Knopf „Ändern“ | Tippen | Sheet mit Zustandsfeldern (`NumberStepper` für Pegel, `ChoiceGroup` für `choice`), beim Boden zusätzlich „Bausperre einlegen/entfernen“, bei einer Zarge „Verdunster einsetzen/entfernen“. |
| Drehen | Ziehen | Wischen mit einem Finger | Drehung um die Hochachse, ±60°, federt zurück auf die Grundansicht, wenn 4 s nichts passiert. Kein Zoom in der Karte. Senkrechtes Wischen scrollt die Seite. |
| Fokus verlassen | Klick ins Leere, Esc | Tippen ins Leere, „Zurück“ | Alles fliegt in den Stapel zurück. |
| Umbauen | Knopf „Umbauen“ | dito | Vollbild (9.5). |