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

@@ -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 };
}