feat: Beute L3–L5 (3D-Szene, Umbauen, Durchsicht, offline) und Diktat (T7 L1)

Beute L3 Runden 2+3 und Review-Fixes:
- Glas-Holo ohne warmes Leuchten, Wanderboden-Fuss, Fokusflug mit
  Geist-Ansicht, Inhalte im Fokus, Rähmchen mit Art-Farbe beim Anheben
- Hinweis-Geometrie je Zarge nur einmal je Layout gebaut; Drehen mit
  Pointer-Capture, endet auch bei Loslassen ausserhalb (rig-pointer.ts)
- Tiefenunschärfe ab 55 fps, Leistungsprobe bei jeder Bewegungsstufe,
  Context-Loss-Listener aufgeräumt, Trefferboxen als skalierte Einheitsbox

Beute L4 Umbauen (/colonies/[id]/hive): Entwurf mit Basis-Schnappschuss
beim ersten Schritt, Rückgängig/Wiederholen, Hinweis bei Serverstand-
Änderung, Konfliktblatt.

Beute L5:
- Durchsicht-Abschnitt „Beute“: Aufbau-Änderungen in der Durchsicht,
  Blatt-Formulare stoppen das Submit-Bubbling durch Portale (sonst
  speicherte „Speichern“ im Blatt die ganze Durchsicht), Fokus nach
  Rückgängig, Folgevorschläge beim Speichern
- Offline: Beute im Offline-Paket, Warteschlange pending-hive-changes
  (DB v5), Abarbeiten in Reihenfolge, keine Online-Änderung überholt
  wartende, Hinweis bei abgelehnter Änderung mit „Warteschlange öffnen“

T7 L1 Diktat: Spracherkennungs-Kapsel (Web Speech API), Einwilligung
standardmässig aus, Diktat-Knopf an Freitextfeldern, Seite
„Sprache & Datenschutz“; axe-core als exakte devDependency.

Backend: NotificationsService liest Empfänger und schreibt in einer
Transaktion mit FOR KEY SHARE (paralleles Löschen eines Benutzers
führte zu P2003 und verzögerten Vorschlägen; Ursache der wackeligen
Endtests). Zwei e2e-Prüfungen gegen geteilten DB-Zustand entschärft.

Gates: Frontend lint, typecheck, 2740 Tests, build; Backend lint,
typecheck, 4266 Unit-Tests, build; parallele e2e 3x grün (978).
Browser-Prüfung 1280/390 px, Tastatur, offline, reduced motion.

Refs #19 #20 #21 #22 #27 #32

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:04 +02:00
1 parent ab45d6e24e
commit 1a57de8573
123 files changed
+7639 -355

No files matched your search

+1
View File
@@ -43,6 +43,7 @@
"@types/react-dom": "^19",
"@types/three": "0.186.0",
"@vitejs/plugin-react": "^4.7.0",
"axe-core": "4.13.0",
"eslint": "^9",
"eslint-config-next": "16.3.1",
"fake-indexeddb": "^6.2.5",
+1
View File
@@ -47,6 +47,7 @@
"@types/react-dom": "^19",
"@types/three": "0.186.0",
"@vitejs/plugin-react": "^4.7.0",
"axe-core": "4.13.0",
"eslint": "^9",
"eslint-config-next": "16.3.1",
"fake-indexeddb": "^6.2.5",
+2
View File
@@ -68,6 +68,8 @@
/\/api\/v1\/colonies\/?$/i,
/\/api\/v1\/colonies\/[^/]+\/inspections\/?$/i,
/\/api\/v1\/apiaries\/[^/]+\/(colony-overview|offline-pack)\/?$/i,
// the hive setup of a colony: the pack answers it, an old kept copy would hide a newer pack
/\/api\/v1\/colonies\/[^/]+\/hive\/?$/i,
// Prüfbereitschaft and authority data: file numbers, addresses, notices,
// restricted zones around an apiary, medicine packs with receipts and
// suppliers, and the label check and print (they carry the food business address)
+1 -1
View File
@@ -21,7 +21,7 @@
// Content version of sw-routes.js (sha256 of its LF text, first 12 hex
// characters; sw-routes.spec.ts checks it). A changed value changes this
// file, so every browser - Safari too - installs the worker with new rules.
const SW_ROUTES_VERSION = "17503174a2a9";
const SW_ROUTES_VERSION = "215afb9f84e9";
importScripts(`/sw-routes.js?v=${SW_ROUTES_VERSION}`);
const {
@@ -0,0 +1,255 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { act, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fetchColony } from "@/lib/colonies/api";
import { ApiError } from "@/lib/auth/api-client";
import { fetchHive, fetchHiveSystemsWithFallback, hiveKeys, postHiveChange } from "@/lib/hive/api";
import { updateHiveSystem } from "@/lib/hive/catalog-api";
import { colonyFixture, sampleState, setupView, systemDto } from "@/components/hive/test-fixtures";
import HiveEditorPage from "./page";
const push = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push }),
useParams: () => ({ id: "colony-1" }),
}));
vi.mock("sonner", () => ({ toast: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn() }) }));
vi.mock("@/lib/colonies/api", () => ({ fetchColony: vi.fn(), fetchColonies: vi.fn().mockResolvedValue([]) }));
vi.mock("@/lib/hive/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/hive/api")>()),
fetchHive: vi.fn(),
fetchHiveSystemsWithFallback: vi.fn(),
postHiveChange: vi.fn(),
}));
vi.mock("@/lib/hive/catalog-api", () => ({ updateHiveSystem: 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) }),
useCurrentUserId: () => null,
}));
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 renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const view = render(
<QueryClientProvider client={client}>
<HiveEditorPage />
</QueryClientProvider>,
);
return { ...view, client };
}
/** Clicks „Hinzufügen …“ of the part type, then the dialog's „Hinzufügen“. */
async function addPart(name: string) {
await userEvent.click(await screen.findByRole("button", { name: `Hinzufügen … ${name}` }));
const dialog = await screen.findByRole("dialog");
await userEvent.click(within(dialog).getByRole("button", { name: "Hinzufügen" }));
return dialog;
}
describe("HiveEditorPage", () => {
beforeEach(() => {
permissions = ["colonies:read", "colonies:update"];
connectivity.value = "online";
push.mockReset();
vi.mocked(fetchColony).mockReset().mockResolvedValue(colonyFixture());
vi.mocked(fetchHive).mockReset().mockResolvedValue(setupView());
vi.mocked(fetchHiveSystemsWithFallback).mockReset().mockResolvedValue([systemDto()]);
vi.mocked(postHiveChange).mockReset().mockResolvedValue(setupView());
vi.mocked(updateHiveSystem).mockReset().mockResolvedValue(systemDto());
});
it("shows the offline hint with a way back", async () => {
connectivity.value = "offline";
renderPage();
expect(await screen.findByText("Umbauen geht nur mit Netz")).toBeInTheDocument();
expect(screen.getByRole("link", { name: /Zurück zum Volk/ })).toHaveAttribute("href", "/colonies/colony-1");
});
it("adds a part, undoes it with Ctrl+Z and redoes it with Ctrl+Shift+Z", async () => {
renderPage();
const stack = await screen.findByRole("list", { name: "Aufbau" });
expect(within(stack).getAllByRole("listitem")).toHaveLength(7); // 6 parts + the floor insert
await addPart("cover");
expect(within(stack).getByRole("button", { name: "Entfernen: Abdeckfolie" })).toBeInTheDocument();
await userEvent.keyboard("{Control>}z{/Control}");
expect(within(stack).queryByRole("button", { name: "Entfernen: Abdeckfolie" })).not.toBeInTheDocument();
await userEvent.keyboard("{Control>}{Shift>}z{/Shift}{/Control}");
expect(within(stack).getByRole("button", { name: "Entfernen: Abdeckfolie" })).toBeInTheDocument();
});
it("shows the floor rule inline when a second floor is added", async () => {
renderPage();
const dialog = await addPart("floor");
expect(await within(dialog).findByRole("alert")).toHaveTextContent("Der Boden muss ganz unten liegen.");
});
it("moves and removes parts from the stack list", async () => {
renderPage();
const stack = await screen.findByRole("list", { name: "Aufbau" });
await userEvent.click(within(stack).getByRole("button", { name: "Nach unten: Zarge 2" }));
expect(within(stack).getAllByRole("button", { name: /^Nach (oben|unten): Zarge/ })).toHaveLength(4);
await userEvent.click(within(stack).getByRole("button", { name: "Entfernen: Futterzarge 2" }));
expect(within(stack).queryByRole("button", { name: "Entfernen: Futterzarge 2" })).not.toBeInTheDocument();
expect(within(stack).getByRole("button", { name: "Entfernen: Futterzarge" })).toBeInTheDocument();
});
it("saves all collected ops as one EDITOR change and leaves", async () => {
renderPage();
await addPart("cover");
await userEvent.click(within(await screen.findByRole("list", { name: "Aufbau" })).getByRole("button", { name: "Entfernen: Futterzarge 2" }));
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(postHiveChange).toHaveBeenCalledTimes(1);
const [colonyId, input] = vi.mocked(postHiveChange).mock.calls[0];
expect(colonyId).toBe("colony-1");
expect(input.source).toBe("EDITOR");
expect(input.occurredOn).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(input.ops.map((op) => op.op)).toEqual(["addPart", "removePart"]);
await vi.waitFor(() => expect(push).toHaveBeenCalledWith("/colonies/colony-1"));
});
it("asks before „Verwerfen“ when ops exist and not when there are none", async () => {
renderPage();
await screen.findByRole("list", { name: "Aufbau" });
await userEvent.click(screen.getByRole("button", { name: "Verwerfen" }));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
await addPart("cover");
await userEvent.click(screen.getByRole("button", { name: "Verwerfen" }));
const dialog = await screen.findByRole("dialog", { name: "Änderungen verwerfen?" });
await userEvent.click(within(dialog).getByRole("button", { name: "Verwerfen bestätigen" }));
expect(screen.getByRole("button", { name: "Speichern" })).toBeDisabled();
});
it("asks before the back link leaves with open ops", async () => {
renderPage();
await addPart("cover");
await userEvent.click(screen.getByRole("link", { name: /Zurück/ }));
const dialog = await screen.findByRole("dialog", { name: "Umbau verlassen?" });
expect(push).not.toHaveBeenCalled();
await userEvent.click(within(dialog).getByRole("button", { name: "Verlassen" }));
expect(push).toHaveBeenCalledWith("/colonies/colony-1");
});
it("offers „Als Vorlage speichern“ only with hive-systems:manage and sends the template", async () => {
const { unmount } = renderPage();
await screen.findByRole("list", { name: "Aufbau" });
expect(screen.queryByRole("button", { name: "Als Vorlage speichern" })).not.toBeInTheDocument();
unmount();
permissions = [...permissions, "hive-systems:manage"];
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Als Vorlage speichern" }));
const dialog = await screen.findByRole("dialog");
await userEvent.click(within(dialog).getByRole("button", { name: "Vorlage ersetzen" }));
await vi.waitFor(() => expect(updateHiveSystem).toHaveBeenCalledTimes(1));
const [systemId, patch] = vi.mocked(updateHiveSystem).mock.calls[0];
expect(systemId).toBe("sys");
expect(JSON.stringify(patch.template)).not.toContain('"id"');
});
const conflictBody = {
code: "HIVE_CONFLICT",
opIndex: 0,
reason: "MISSING_ID: x",
current: { version: 9, state: sampleState() },
};
it("shows the save error for a network failure but not for a conflict", async () => {
vi.mocked(postHiveChange).mockRejectedValueOnce(new ApiError(500, "boom"));
renderPage();
await addPart("cover");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(await screen.findByText(/Speichern fehlgeschlagen/)).toBeInTheDocument();
vi.mocked(postHiveChange).mockRejectedValueOnce(new ApiError(409, "conflict", conflictBody));
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(await screen.findByRole("dialog", { name: "Die Beute wurde inzwischen geändert" })).toBeInTheDocument();
expect(screen.queryByText(/Speichern fehlgeschlagen/)).not.toBeInTheDocument();
});
it("discards the draft when the conflict is discarded", async () => {
vi.mocked(postHiveChange).mockRejectedValueOnce(new ApiError(409, "conflict", conflictBody));
renderPage();
await addPart("cover");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
const sheet = await screen.findByRole("dialog", { name: "Die Beute wurde inzwischen geändert" });
await userEvent.click(within(sheet).getByRole("button", { name: "Verwerfen" }));
await vi.waitFor(() => expect(screen.getByRole("button", { name: "Speichern" })).toBeDisabled());
expect(screen.queryByRole("button", { name: "Entfernen: Abdeckfolie" })).not.toBeInTheDocument();
});
it("keeps showing the draft's own base and hints when the server state changes underneath", async () => {
const { client } = renderPage();
await addPart("cover");
expect(screen.queryByText(/inzwischen geändert/)).not.toBeInTheDocument();
const other = sampleState();
other.stack = other.stack.filter((p) => p.id !== "b2");
act(() => client.setQueryData(hiveKeys.detail("colony-1"), setupView(other, "colony-1", 4)));
expect(await screen.findByText(/Die Beute wurde inzwischen geändert\. Beim Speichern/)).toBeInTheDocument();
const stack = screen.getByRole("list", { name: "Aufbau" });
expect(within(stack).getByRole("button", { name: "Entfernen: Zarge 2" })).toBeInTheDocument();
expect(within(stack).getByRole("button", { name: "Entfernen: Abdeckfolie" })).toBeInTheDocument();
});
it("shows a dialog refusal only in the dialog", async () => {
renderPage();
const dialog = await addPart("floor");
expect(await within(dialog).findByRole("alert")).toBeInTheDocument();
expect(screen.getAllByRole("alert")).toHaveLength(1);
});
it("does not bring a selection back when a removed part returns by undo", async () => {
renderPage();
const stack = await screen.findByRole("list", { name: "Aufbau" });
await userEvent.click(within(stack).getByRole("button", { name: "Futterzarge 2" }));
expect(within(stack).getByRole("button", { name: "Futterzarge 2" })).toHaveAttribute("aria-current", "true");
await userEvent.click(within(stack).getByRole("button", { name: "Entfernen: Futterzarge 2" }));
await userEvent.keyboard("{Control>}z{/Control}");
expect(within(stack).getByRole("button", { name: "Futterzarge 2" })).not.toHaveAttribute("aria-current");
});
it("does not retry a missing hive", async () => {
vi.mocked(fetchHive).mockReset().mockRejectedValue(new ApiError(404, "nf", { code: "HIVE_NOT_FOUND" }));
renderPage();
expect(await screen.findByText("Noch keine Beute")).toBeInTheDocument();
expect(fetchHive).toHaveBeenCalledTimes(1);
});
it("announces undo and redo", async () => {
renderPage();
await addPart("cover");
await userEvent.keyboard("{Control>}z{/Control}");
expect(screen.getByRole("status")).toHaveTextContent(/^Rückgängig: /);
await userEvent.keyboard("{Control>}{Shift>}z{/Shift}{/Control}");
expect(screen.getByRole("status")).toHaveTextContent(/^Wiederholt: /);
});
});
@@ -0,0 +1,77 @@
"use client";
import { useParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/lib/auth/auth-context";
import { fetchColony } from "@/lib/colonies/api";
import { isLivingColony } from "@/lib/colonies/status";
import { fetchHive, hiveErrorCode, hiveKeys } from "@/lib/hive/api";
import { useConnectivity } from "@/lib/offline/connectivity";
import { EditorNotice } from "@/components/hive/editor/editor-notice";
import { HiveEditor } from "@/components/hive/editor/hive-editor";
import { useHiveSystems, useSystemCatalog } from "@/components/hive/use-hive-systems";
/** Full-screen „Umbauen“ of a colony's hive (spec 9.5). Online only. */
export default function HiveEditorPage() {
const { id } = useParams<{ id: string }>();
const { hasPermission } = useAuth();
const connectivity = useConnectivity();
const backHref = `/colonies/${encodeURIComponent(id)}`;
const colony = useQuery({ queryKey: ["colonies", id], queryFn: () => fetchColony(id) });
const hive = useQuery({ queryKey: hiveKeys.detail(id), queryFn: () => fetchHive(id),
// A colony without a hive answers 404 for good; no point retrying.
retry: (count, error) => hiveErrorCode(error) !== "HIVE_NOT_FOUND" && count < 3,
});
const systems = useHiveSystems();
const catalog = useSystemCatalog(systems.data, hive.data?.systemId);
if (connectivity === "offline") {
return (
<EditorNotice title="Umbauen geht nur mit Netz" backHref={backHref}>
<p className="text-sm">Kleine Änderungen in der Karte gehen auch ohne Netz.</p>
</EditorNotice>
);
}
if (colony.data && (!hasPermission("colonies:update") || !isLivingColony(colony.data))) {
return (
<EditorNotice title="Umbauen ist hier nicht möglich" backHref={backHref}>
<p className="text-sm">Dafür fehlt die Berechtigung, oder das Volk ist nicht mehr im Bestand.</p>
</EditorNotice>
);
}
if (hiveErrorCode(hive.error) === "HIVE_NOT_FOUND") {
return (
<EditorNotice title="Noch keine Beute" backHref={backHref}>
<p className="text-sm">Lege die Beute zuerst auf der Volk-Seite an.</p>
</EditorNotice>
);
}
if (colony.isError || hive.isError || systems.isError) {
return (
<EditorNotice title="Beute konnte nicht geladen werden." backHref={backHref}>
<button
type="button"
className="h-11 rounded-xl border border-white/20 px-4 text-sm"
onClick={() => {
void colony.refetch();
void hive.refetch();
void systems.refetch();
}}
>
Erneut versuchen
</button>
</EditorNotice>
);
}
if (!colony.data || !hive.data || !catalog) {
return <div role="status" aria-label="Beute wird geladen" className="fixed inset-0 z-40 bg-holo-bg motion-safe:animate-pulse" />;
}
return (
<HiveEditor
colonyId={id}
setup={hive.data}
catalog={catalog}
canManageTemplates={hasPermission("hive-systems:manage")}
/>
);
}
@@ -0,0 +1,111 @@
import { describe, expect, it } from "vitest";
import { ApiError } from "@/lib/auth/api-client";
import { inlineCatalog } from "@/lib/hive/test-utils";
import type { HiveOp } from "@/lib/hive/types";
import { sampleState } from "@/components/hive/test-fixtures";
import {
broodHintFor,
broodHintLabel,
hiveEntryTexts,
hiveErrorText,
undoHiveEntry,
} from "./hive-change";
const catalog = inlineCatalog();
const base = sampleState();
const FILL: HiveOp = { op: "setState", id: "fe", key: "syrupLiters", value: 2 };
const FRAME: HiveOp = { op: "setFrame", boxId: "b1", slot: 5, type: "BROOD", name: null };
describe("hiveEntryTexts", () => {
it("describes each op against the state the ops before it left", () => {
const texts = hiveEntryTexts([FRAME, FILL], base, catalog);
expect(texts).toHaveLength(2);
expect(texts[0]).toMatch(/Rähmchen 5/);
expect(texts[1]).toMatch(/Flüssigfutter/);
});
it("falls back to a plain sentence when the ops no longer apply", () => {
const broken: HiveOp = { op: "removePart", id: "missing" };
expect(hiveEntryTexts([broken], base, catalog)).toEqual(["Änderung am Aufbau"]);
});
});
describe("undoHiveEntry", () => {
it("rebuilds the ops without the chosen entry", () => {
const result = undoHiveEntry([FRAME, FILL], 0, base, catalog);
expect(result).toEqual({ ok: true, ops: [FILL] });
});
it("refuses with a reason text when the rest does not apply any more", () => {
const add: HiveOp = {
op: "addPart",
part: { id: "n1", partTypeId: "feeder", state: { syrupLiters: 0, fondantKg: 0 } },
at: 5,
};
const fill: HiveOp = { op: "setState", id: "n1", key: "syrupLiters", value: 1 };
const result = undoHiveEntry([add, fill], 0, base, catalog);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toBe("Ein Teil, auf das sich die Änderung bezieht, gibt es nicht mehr.");
});
it("ignores an index outside the list", () => {
expect(undoHiveEntry([FILL], 3, base, catalog)).toEqual({ ok: true, ops: [FILL] });
});
});
describe("broodHintFor", () => {
it("counts the brood frames of the state the ops lead to", () => {
expect(broodHintFor(base, [], catalog)).toBe(6);
expect(broodHintFor(base, [FRAME], catalog)).toBe(7);
});
it("is null while there is no setup", () => {
expect(broodHintFor(null, [], catalog)).toBeNull();
});
it("is null when the ops do not apply", () => {
expect(broodHintFor(base, [{ op: "removePart", id: "missing" }], catalog)).toBeNull();
});
});
describe("broodHintLabel", () => {
it("uses singular and plural", () => {
expect(broodHintLabel(1)).toBe("1 Brutwabe");
expect(broodHintLabel(6)).toBe("6 Brutwaben");
});
});
describe("hiveErrorText", () => {
const conflict = new ApiError(409, "Conflict", {
code: "HIVE_CONFLICT",
opIndex: 0,
reason: "MISSING_ID: no part x",
current: { version: 4, state: base },
});
it("explains a conflict with the reason text", () => {
expect(hiveErrorText(conflict)).toBe(
"Die Beute hat sich inzwischen geändert. Ein Teil, auf das sich die Änderung bezieht, gibt es nicht mehr. Nimm die Änderungen an der Beute heraus und speichere erneut.",
);
});
it("explains a missing right", () => {
const forbidden = new ApiError(403, "Forbidden", { code: "HIVE_FORBIDDEN" });
expect(hiveErrorText(forbidden)).toBe(
"Dir fehlt das Recht, die Beute zu ändern. Nimm die Änderungen an der Beute heraus und speichere erneut.",
);
});
it("leaves other errors to the form", () => {
expect(hiveErrorText(new ApiError(500, "Boom"))).toBeNull();
expect(hiveErrorText(null)).toBeNull();
expect(hiveErrorText(new ApiError(403, "Forbidden", { code: "OTHER" }))).toBeNull();
});
});
@@ -0,0 +1,66 @@
import { hiveConflictOf, hiveErrorCode } from "@/lib/hive/api";
import { describeHiveOps } from "@/lib/hive/describe";
import { deriveHiveFacts } from "@/lib/hive/facts";
import { applyHiveOps } from "@/lib/hive/ops";
import { hiveReasonText } from "@/lib/hive/reason-text";
import type { HiveCatalog, HiveOp, HiveState } from "@/lib/hive/types";
const FALLBACK_ENTRY_TEXT = "Änderung am Aufbau";
/** One German line per op, each described against the state the ops before it leave. */
export function hiveEntryTexts(ops: HiveOp[], base: HiveState, catalog: HiveCatalog): string[] {
let state = base;
return ops.map((op) => {
const text = describeHiveOps([op], state, catalog);
const applied = applyHiveOps(state, [op], catalog);
if (!applied.ok) {
// The ops no longer fit the loaded setup (it changed meanwhile): keep the list readable.
state = base;
return FALLBACK_ENTRY_TEXT;
}
state = applied.state;
return text;
});
}
export type UndoResult = { ok: true; ops: HiveOp[] } | { ok: false; reason: string };
/**
* The ops without entry `index`. When the rest no longer applies to the setup
* (a later op builds on the removed one) the entry stays and the reason is given.
*/
export function undoHiveEntry(
ops: HiveOp[],
index: number,
base: HiveState,
catalog: HiveCatalog,
): UndoResult {
const rest = ops.filter((_, position) => position !== index);
const applied = applyHiveOps(base, rest, catalog);
return applied.ok ? { ok: true, ops: rest } : { ok: false, reason: hiveReasonText(applied.reason) };
}
/** Brood frames of the build the ops lead to, or null without a setup or when the ops do not apply. */
export function broodHintFor(base: HiveState | null, ops: HiveOp[], catalog: HiveCatalog | null): number | null {
if (!base || !catalog) return null;
const applied = applyHiveOps(base, ops, catalog);
return applied.ok ? deriveHiveFacts(applied.state, catalog).broodFrames : null;
}
export function broodHintLabel(count: number): string {
return count === 1 ? "1 Brutwabe" : `${count} Brutwaben`;
}
const TAKE_OUT_HINT = "Nimm die Änderungen an der Beute heraus und speichere erneut.";
/** Inline text for a refusal of the hive part (409 HIVE_CONFLICT, 403 HIVE_FORBIDDEN); null for any other error. */
export function hiveErrorText(error: unknown): string | null {
const conflict = hiveConflictOf(error);
if (conflict) {
return `Die Beute hat sich inzwischen geändert. ${hiveReasonText(conflict.reason)} ${TAKE_OUT_HINT}`;
}
if (hiveErrorCode(error) === "HIVE_FORBIDDEN") {
return `Dir fehlt das Recht, die Beute zu ändern. ${TAKE_OUT_HINT}`;
}
return null;
}
@@ -0,0 +1,43 @@
import { showHiveFollowUps } from "@/components/hive/follow-up-toast";
import { applyHiveOps } from "@/lib/hive/ops";
import type { HiveCatalog, HiveOp, HiveState } from "@/lib/hive/types";
import { fetchColonyTreatments } from "@/lib/treatments/api";
async function hasOpenTreatment(colonyId: string): Promise<boolean> {
try {
const treatments = await fetchColonyTreatments(colonyId);
return treatments.some((treatment) => treatment.endDate == null);
} catch {
return false;
}
}
export interface SavedHiveChange {
colonyId: string;
/** Calendar day of the inspection; becomes the date of the suggested forms. */
occurredOn: string;
ops: HiveOp[];
/** The setup the ops were collected against. */
base: HiveState;
catalog: HiveCatalog;
navigate: (href: string) => void;
}
/**
* Spec section 8: after the inspection is saved, the batch's feeding and
* treatment suggestions appear as toasts. Suggestions only, nothing is booked.
*/
export async function suggestAfterInspection(saved: SavedHiveChange): Promise<void> {
const { ops, base, catalog, colonyId, occurredOn, navigate } = saved;
const applied = applyHiveOps(base, ops, catalog);
// The server was authoritative and saved the inspection: if the ops do not apply to our
// (possibly stale) copy of the setup, we just skip the suggestions instead of guessing.
if (!applied.ok) return;
const open = ops.some((op) => op.op === "removePart") ? await hasOpenTreatment(colonyId) : false;
showHiveFollowUps(ops, base, applied.state, catalog, {
colonyId,
occurredOn,
navigate,
hasOpenTreatment: open,
});
}
@@ -0,0 +1,185 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { HiveCard } from "@/components/hive/hive-card";
import { useHiveSystems, useSystemCatalog } from "@/components/hive/use-hive-systems";
import type { Colony } from "@/lib/colonies/types";
import { fetchHive, hiveKeys } from "@/lib/hive/api";
import type { HiveCatalog, HiveOp, HiveState } from "@/lib/hive/types";
import { hiveEntryTexts, undoHiveEntry } from "./hive-change";
/** The loaded setup and catalog of a colony's hive; same queries (and cache) as the card. */
export function useInspectionHive(colonyId: string): { base: HiveState | null; catalog: HiveCatalog | null } {
const enabled = colonyId !== "";
const hive = useQuery({
queryKey: hiveKeys.detail(colonyId),
queryFn: () => fetchHive(colonyId),
enabled,
retry: false,
});
const systems = useHiveSystems();
const catalog = useSystemCatalog(systems.data, hive.data?.systemId);
return { base: hive.data?.state ?? null, catalog };
}
/** Stable per entry: the op's content plus its n-th occurrence, so removing another entry keeps the key. */
function opKeys(ops: HiveOp[]): string[] {
const seen = new Map<string, number>();
return ops.map((op) => {
const json = JSON.stringify(op);
const nth = seen.get(json) ?? 0;
seen.set(json, nth + 1);
return `${json}#${nth}`;
});
}
interface HiveOpsListProps {
ops: HiveOp[];
base: HiveState;
catalog: HiveCatalog;
onChange: (ops: HiveOp[]) => void;
/** Called with the undone entry's text, for the live region. */
onUndone: (text: string) => void;
/** The list became empty: focus moves to the section. */
onEmptied: () => void;
}
function HiveOpsList({ ops, base, catalog, onChange, onUndone, onEmptied }: HiveOpsListProps) {
const buttons = useRef(new Map<string, HTMLButtonElement>());
const keys = useMemo(() => opKeys(ops), [ops]);
// Index to focus once the shortened list is committed (rows may remount when keys renumber).
const focusIndex = useRef<number | null>(null);
useEffect(() => {
if (focusIndex.current === null) return;
const target = keys[Math.min(focusIndex.current, keys.length - 1)];
focusIndex.current = null;
if (target) buttons.current.get(target)?.focus();
}, [keys]);
const [refusal, setRefusal] = useState<{ ops: HiveOp[]; index: number; reason: string } | null>(null);
const texts = useMemo(() => hiveEntryTexts(ops, base, catalog), [ops, base, catalog]);
// A refusal belongs to the list it was given for; a changed list makes it obsolete.
const blocked = refusal?.ops === ops ? refusal : null;
function undo(index: number) {
const result = undoHiveEntry(ops, index, base, catalog);
if (result.ok) {
// The neighbouring entry gets focus after the commit (see the effect above).
if (keys.length > 1) focusIndex.current = index;
else onEmptied();
onUndone(texts[index]);
onChange(result.ops);
} else setRefusal({ ops, index, reason: result.reason });
}
return (
<div className="flex flex-col gap-2">
<h3 className="text-sm font-semibold">Änderungen an der Beute ({ops.length})</h3>
<ul className="flex flex-col gap-2">
{texts.map((text, index) => (
<li key={keys[index]} className="flex flex-col gap-1 rounded-xl bg-muted/50 p-3">
<div className="flex items-center justify-between gap-3">
<span className="text-sm">{text}</span>
<Button
type="button"
variant="outline"
ref={(node) => {
if (node) buttons.current.set(keys[index], node);
else buttons.current.delete(keys[index]);
}}
className="min-h-11 shrink-0"
aria-label={`Rückgängig: ${text}`}
onClick={() => undo(index)}
>
Rückgängig
</Button>
</div>
{blocked?.index === index ? (
<p role="alert" className="flex items-start gap-2 text-sm">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-warning" aria-hidden />
{blocked.reason} Mach zuerst die späteren Änderungen rückgängig.
</p>
) : null}
</li>
))}
</ul>
</div>
);
}
export interface InspectionHiveSectionProps {
colony: Colony;
/** colonies:update: edit the hive as a draft; without it the card is read-only. */
canEdit: boolean;
ops: HiveOp[];
/** Changes whenever the card must be rebuilt from `ops` (after „Rückgängig“, new colony). */
cardKey: string;
onOpsChange: (ops: HiveOp[]) => void;
/** Rebuilds the card with these ops (after „Rückgängig“). */
onOpsReplace: (ops: HiveOp[]) => void;
base: HiveState | null;
catalog: HiveCatalog | null;
/** Server refusal of the hive part (409 HIVE_CONFLICT, 403), shown right here. */
errorText: string | null;
}
/**
* Content of the form's „Beute“ section: the card (draft mode only with
* colonies:update), the list of collected changes and a server refusal.
*/
export function InspectionHiveSection({
colony,
canEdit,
ops,
cardKey,
onOpsChange,
onOpsReplace,
base,
catalog,
errorText,
}: InspectionHiveSectionProps) {
const sectionRef = useRef<HTMLDivElement>(null);
const [announcement, setAnnouncement] = useState("");
return (
// A portal form inside the card (frame/part sheet) must never submit the enclosing inspection form.
<div
ref={sectionRef}
data-testid="hive-section"
tabIndex={-1}
className="flex flex-col gap-4 outline-none"
onSubmit={(event) => event.stopPropagation()}
>
<p role="status" aria-live="polite" className="sr-only">
{announcement}
</p>
<div className="flex h-[min(55svh,520px)] min-h-80 flex-col *:min-h-0 *:flex-1">
<HiveCard
key={cardKey}
colonyId={colony.id}
colony={colony}
mode={canEdit ? "inspection" : "view"}
onOpsChange={canEdit ? onOpsChange : undefined}
initialOps={canEdit ? ops : undefined}
/>
</div>
{canEdit && ops.length > 0 && base && catalog ? (
<HiveOpsList
ops={ops}
base={base}
catalog={catalog}
onChange={onOpsReplace}
onUndone={(text) => setAnnouncement(`Rückgängig: ${text}`)}
onEmptied={() => sectionRef.current?.focus()}
/>
) : null}
{errorText ? (
<p role="alert" className="flex items-start gap-2 text-sm font-medium text-destructive">
<AlertTriangle className="mt-0.5 size-4 shrink-0" aria-hidden />
{errorText}
</p>
) : null}
</div>
);
}
@@ -1,4 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { HiveOp } from "@/lib/hive/types";
import {
currentDraftValues,
feedLegacyHint,
@@ -209,6 +210,31 @@ describe("Volkskarte selection", () => {
});
});
describe("toCreateInspectionInput with hive ops", () => {
const ops: HiveOp[] = [{ op: "setState", id: "fe", key: "syrupLiters", value: 2 }];
const values = { ...initialInspectionValues("c-1"), date: "2026-10-01" };
it("carries hiveChange only when there are ops", () => {
expect(toCreateInspectionInput(values, true, ops).hiveChange).toEqual({ ops });
expect(toCreateInspectionInput(values, true, [])).not.toHaveProperty("hiveChange");
expect(toCreateInspectionInput(values, true)).not.toHaveProperty("hiveChange");
});
it("never sends hiveChange without colonies:update", () => {
expect(toCreateInspectionInput(values, false, ops)).not.toHaveProperty("hiveChange");
});
it("keeps applyToColony next to hiveChange", () => {
const input = toCreateInspectionInput(
{ ...values, colonyStrength: "6", applyToColony: ["occupiedFrames"] },
true,
ops,
);
expect(input).toMatchObject({ applyToColony: ["occupiedFrames"], hiveChange: { ops } });
});
});
describe("feedStatusOptions", () => {
const labels = (stored: Parameters<typeof feedStatusOptions>[0]) =>
feedStatusOptions(stored).map((option) => option.label);
@@ -7,6 +7,7 @@ import {
} from "@/lib/colonies/condition";
import type { ColonyCondition } from "@/lib/colonies/types";
import { toApiDate, todayLocalIsoDate } from "@/lib/dates";
import type { HiveOp } from "@/lib/hive/types";
import { emptyToNull } from "@/lib/forms/empty-to-null";
import {
COLONY_TAKEOVER_FIELDS,
@@ -238,20 +239,26 @@ export function syncTakeoverSelection(
/**
* What a new inspection sends: the correction input plus, when the beekeeper
* may update colonies, the checked Volkskarte fields that have a value.
* Omitted when empty, so nothing is asked of the server that was not wanted.
* may update colonies, the checked Volkskarte fields that have a value and the
* hive ops collected in the „Beute“ section. Both are omitted when empty, so
* nothing is asked of the server that was not wanted.
*/
export function toCreateInspectionInput(
values: InspectionFormValues,
canUpdateColony: boolean,
hiveOps: HiveOp[] = [],
): CreateInspectionInput {
const input = toInspectionInput(values);
const input: CreateInspectionInput = toInspectionInput(values);
if (!canUpdateColony) {
return input;
}
const filled = new Set(takeoverFieldsFilled(values));
const applyToColony = values.applyToColony.filter((field) => filled.has(field));
return applyToColony.length > 0 ? { ...input, applyToColony } : input;
return {
...input,
...(applyToColony.length > 0 ? { applyToColony } : {}),
...(hiveOps.length > 0 ? { hiveChange: { ops: hiveOps } } : {}),
};
}
/**
@@ -0,0 +1,371 @@
import { createPortal } from "react-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { toast } from "sonner";
import NewInspectionPage from "./page";
import { ApiError } from "@/lib/auth/api-client";
import { fetchColonies } from "@/lib/colonies/api";
import { createInspection, fetchColonyInspections } from "@/lib/inspections/api";
import { fetchHive, fetchHiveSystems } from "@/lib/hive/api";
import { fetchApiaryWeather } from "@/lib/weather/api";
import { showHiveFollowUps } from "@/components/hive/follow-up-toast";
import { sampleState, setupView, systemDto } from "@/components/hive/test-fixtures";
import { queueInspectionWithPhotos } from "@/lib/photos/store";
import { syncPendingEntries } from "@/lib/offline/sync";
// Section „Beute“ of the inspection form (Task 19). The card itself is stubbed:
// its buttons report ops the way the real card does in draft mode.
const push = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push }),
useSearchParams: () => new URLSearchParams("colonyId=colony-1"),
}));
let permissions = new Set<string>();
vi.mock("@/lib/auth/auth-context", () => ({
useCurrentUserId: () => "user-1",
useAuth: () => ({ hasPermission: (permission: string) => permissions.has(permission) }),
}));
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() }));
vi.mock("@/lib/inspections/api", () => ({ createInspection: vi.fn(), fetchColonyInspections: vi.fn() }));
vi.mock("@/lib/weather/api", () => ({ fetchApiaryWeather: vi.fn() }));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }));
vi.mock("@/lib/offline/sync", () => ({ syncPendingEntries: vi.fn() }));
vi.mock("@/lib/photos/store", () => ({
PHOTOS_PER_INSPECTION_MAX: 5,
listSessionPhotos: vi.fn().mockResolvedValue([]),
addDraftPhoto: vi.fn(),
removePhoto: vi.fn(),
handOverSessionPhotos: vi.fn().mockResolvedValue(0),
queueInspectionWithPhotos: vi.fn().mockResolvedValue(0),
moveDraftPhotos: vi.fn(),
discardSessionPhotos: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/components/hive/follow-up-toast", () => ({ showHiveFollowUps: vi.fn() }));
vi.mock("@/lib/hive/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/hive/api")>()),
fetchHive: vi.fn(),
fetchHiveSystems: vi.fn(),
}));
const card = vi.hoisted(() => ({ renders: [] as { mode?: string; initialOps?: unknown[] }[] }));
vi.mock("@/components/hive/hive-card", () => ({
HiveCard: (props: { mode?: string; initialOps?: unknown[]; onOpsChange?: (ops: unknown[]) => void }) => {
card.renders.push({ mode: props.mode, initialOps: props.initialOps });
const fill = { op: "setState", id: "fe", key: "syrupLiters", value: 2 };
const frame = { op: "setFrame", boxId: "b1", slot: 5, type: "BROOD", name: null };
const add = {
op: "addPart",
part: { id: "n1", partTypeId: "feeder", state: { syrupLiters: 0, fondantKg: 0 } },
at: 5,
};
const fillNew = { op: "setState", id: "n1", key: "syrupLiters", value: 1 };
return (
<div data-testid="hive-card" data-mode={props.mode}>
<button type="button" onClick={() => props.onOpsChange?.([fill])}>
Test Futter
</button>
<button type="button" onClick={() => props.onOpsChange?.([frame, fill])}>
Test Rähmchen und Futter
</button>
<button type="button" onClick={() => props.onOpsChange?.([add, fillNew])}>
Test neue Zarge
</button>
<button type="button" onClick={() => props.onOpsChange?.([fill, fill])}>
Test zwei gleiche
</button>
{createPortal(
<form onSubmit={(event) => event.preventDefault()}>
<button type="submit">Sheet speichern</button>
</form>,
document.body,
)}
</div>
);
},
}));
function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<NewInspectionPage />
</QueryClientProvider>,
);
}
async function save() {
await userEvent.click(screen.getByRole("button", { name: "Durchsicht speichern" }));
}
function sentInput(): Record<string, unknown> {
return vi.mocked(createInspection).mock.calls[0][0] as unknown as Record<string, unknown>;
}
const FILL = { op: "setState", id: "fe", key: "syrupLiters", value: 2 };
const FRAME = { op: "setFrame", boxId: "b1", slot: 5, type: "BROOD", name: null };
describe("NewInspectionPage: section Beute", () => {
beforeEach(() => {
window.localStorage.clear();
window.sessionStorage.clear();
push.mockReset();
card.renders.length = 0;
permissions = new Set(["colonies:update"]);
vi.mocked(fetchColonies).mockResolvedValue([
{ id: "colony-1", code: "V-01", status: "ACTIVE", apiaryId: "apiary-1" },
] as never);
vi.mocked(fetchColonyInspections).mockResolvedValue([]);
vi.mocked(fetchApiaryWeather).mockResolvedValue({ daily: [] });
vi.mocked(fetchHive).mockResolvedValue(setupView(sampleState()));
vi.mocked(fetchHiveSystems).mockResolvedValue([systemDto()]);
vi.mocked(createInspection)
.mockReset()
.mockResolvedValue({ id: "i-1", colonyId: "colony-1" } as never);
vi.mocked(showHiveFollowUps).mockReset();
vi.mocked(toast.success).mockReset();
vi.mocked(queueInspectionWithPhotos).mockReset().mockResolvedValue(0);
vi.mocked(syncPendingEntries).mockReset().mockResolvedValue({ synced: 0, failed: 0, skippedForeign: 0 });
});
it("shows the card in draft mode with colonies:update", async () => {
renderPage();
const section = await screen.findByTestId("hive-card");
expect(section).toHaveAttribute("data-mode", "inspection");
expect(screen.getByRole("group", { name: /Beute/ })).toBeInTheDocument();
});
it("shows the model read-only without colonies:update", async () => {
permissions = new Set();
renderPage();
expect(await screen.findByTestId("hive-card")).toHaveAttribute("data-mode", "view");
await userEvent.click(screen.getByRole("button", { name: "Test Futter" }));
expect(screen.queryByText(/Änderungen an der Beute/)).not.toBeInTheDocument();
await save();
expect(sentInput()).not.toHaveProperty("hiveChange");
});
it("lists the changes and sends them with the inspection", async () => {
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Test Rähmchen und Futter" }));
expect(screen.getByRole("heading", { name: "Änderungen an der Beute (2)" })).toBeInTheDocument();
expect(screen.getAllByRole("button", { name: /^Rückgängig/ })).toHaveLength(2);
await save();
expect(sentInput()).toMatchObject({ hiveChange: { ops: [FRAME, FILL] } });
});
it("sends no hiveChange while there are no ops", async () => {
renderPage();
await screen.findByTestId("hive-card");
await save();
expect(sentInput()).not.toHaveProperty("hiveChange");
});
it("Rückgängig removes one entry and rebuilds the card from the rest", async () => {
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Test Rähmchen und Futter" }));
await userEvent.click(screen.getAllByRole("button", { name: /^Rückgängig/ })[0]);
expect(screen.getByRole("heading", { name: "Änderungen an der Beute (1)" })).toBeInTheDocument();
expect(card.renders.at(-1)?.initialOps).toEqual([FILL]);
await save();
expect(sentInput()).toMatchObject({ hiveChange: { ops: [FILL] } });
});
it("remounts the card seeded with the remaining ops after Rückgängig", async () => {
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Test Rähmchen und Futter" }));
const rendersBefore = card.renders.length;
await userEvent.click(screen.getAllByRole("button", { name: /^Rückgängig/ })[1]);
expect(card.renders.length).toBeGreaterThan(rendersBefore);
expect(card.renders.at(-1)?.initialOps).toEqual([FRAME]);
expect(screen.getByText(/Laut Beute: 7 Brutwaben/)).toBeInTheDocument();
});
it("does not save the inspection when a form inside the card submits", async () => {
renderPage();
await screen.findByTestId("hive-card");
await userEvent.click(screen.getByRole("button", { name: "Sheet speichern" }));
expect(createInspection).not.toHaveBeenCalled();
});
it("moves focus to the neighbouring entry and announces the undo", async () => {
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Test Rähmchen und Futter" }));
await userEvent.click(screen.getAllByRole("button", { name: /^Rückgängig/ })[0]);
expect(screen.getByRole("button", { name: /^Rückgängig/ })).toHaveFocus();
expect(screen.getAllByRole("status").some((el) => /^Rückgängig: /.test(el.textContent ?? ""))).toBe(true);
});
it("moves focus to the section when the list becomes empty", async () => {
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Test Futter" }));
await userEvent.click(screen.getByRole("button", { name: /^Rückgängig/ }));
expect(screen.getByTestId("hive-section")).toHaveFocus();
});
it("keeps focus on a row when identical ops renumber their keys", async () => {
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Test zwei gleiche" }));
await userEvent.click(screen.getAllByRole("button", { name: /^Rückgängig/ })[0]);
expect(screen.getByRole("button", { name: /^Rückgängig/ })).toHaveFocus();
});
it("hides a refusal once the ops changed", async () => {
vi.mocked(createInspection).mockRejectedValue(
new ApiError(403, "Forbidden", { code: "HIVE_FORBIDDEN" }),
);
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Test Rähmchen und Futter" }));
await save();
expect(await screen.findByRole("alert")).toHaveTextContent("Dir fehlt das Recht");
await userEvent.click(screen.getAllByRole("button", { name: /^Rückgängig/ })[0]);
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
it("hides the hint for 0 brood frames while the field is empty", async () => {
vi.mocked(fetchHive).mockResolvedValue(
setupView({ schemaVersion: 1, stack: [{ id: "r", partTypeId: "roof", state: {} }] } as never),
);
renderPage();
await screen.findByTestId("hive-card");
await waitFor(() => expect(fetchHive).toHaveBeenCalled());
expect(screen.queryByText(/Laut Beute/)).not.toBeInTheDocument();
});
it("drops hiveChange again when the last entry is undone", async () => {
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Test Futter" }));
await userEvent.click(screen.getByRole("button", { name: /^Rückgängig/ }));
expect(screen.queryByText(/Änderungen an der Beute/)).not.toBeInTheDocument();
await save();
expect(sentInput()).not.toHaveProperty("hiveChange");
});
it("keeps an entry and says why when the rest would no longer apply", async () => {
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Test neue Zarge" }));
await userEvent.click(screen.getAllByRole("button", { name: /^Rückgängig/ })[0]);
expect(screen.getByRole("heading", { name: "Änderungen an der Beute (2)" })).toBeInTheDocument();
expect(
screen.getByText(/Ein Teil, auf das sich die Änderung bezieht, gibt es nicht mehr\./),
).toBeInTheDocument();
});
it("offers the brood frames of the hive but never fills them by itself", async () => {
renderPage();
expect(await screen.findByText(/Laut Beute: 6 Brutwaben/)).toBeInTheDocument();
await save();
expect(sentInput()).toMatchObject({ broodFrames: null });
});
it("takes the hive's brood frames over only after Übernehmen", async () => {
renderPage();
const hint = (await screen.findByText(/Laut Beute: 6 Brutwaben/)).closest("p") as HTMLElement;
await userEvent.click(within(hint).getByRole("button", { name: /übernehmen/ }));
await waitFor(() => expect(screen.queryByText(/Laut Beute/)).not.toBeInTheDocument());
await save();
expect(sentInput()).toMatchObject({ broodFrames: 6 });
});
it("follows the ops: a brood frame in the draft raises the hint", async () => {
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Test Rähmchen und Futter" }));
expect(screen.getByText(/Laut Beute: 7 Brutwaben/)).toBeInTheDocument();
});
it("suggests feeding and treatment for the saved batch", async () => {
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Test Futter" }));
await save();
await waitFor(() => expect(showHiveFollowUps).toHaveBeenCalledTimes(1));
const [ops, before, after, , context] = vi.mocked(showHiveFollowUps).mock.calls[0];
expect(ops).toEqual([FILL]);
expect(before).toEqual(sampleState());
expect(after).not.toEqual(before);
expect(context).toMatchObject({ colonyId: "colony-1", hasOpenTreatment: false });
});
it("suggests nothing when no hive change was sent", async () => {
renderPage();
await screen.findByTestId("hive-card");
await save();
await waitFor(() => expect(toast.success).toHaveBeenCalled());
expect(showHiveFollowUps).not.toHaveBeenCalled();
});
it("passes hiveChange untouched into the offline queue", async () => {
vi.mocked(createInspection).mockRejectedValue(new TypeError("Failed to fetch"));
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Test Futter" }));
await save();
await waitFor(() => expect(queueInspectionWithPhotos).toHaveBeenCalled());
expect(vi.mocked(queueInspectionWithPhotos).mock.calls[0][1]).toMatchObject({
hiveChange: { ops: [FILL] },
});
expect(showHiveFollowUps).not.toHaveBeenCalled();
});
it("explains a 409 HIVE_CONFLICT inline and keeps the ops", async () => {
vi.mocked(createInspection).mockRejectedValue(
new ApiError(409, "Conflict", {
code: "HIVE_CONFLICT",
opIndex: 0,
reason: "MISSING_ID: no part",
current: { version: 5, state: sampleState() },
}),
);
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Test Futter" }));
await save();
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("Die Beute hat sich inzwischen geändert.");
expect(alert).toHaveTextContent("Ein Teil, auf das sich die Änderung bezieht, gibt es nicht mehr.");
expect(screen.getByRole("heading", { name: "Änderungen an der Beute (1)" })).toBeInTheDocument();
expect(screen.getAllByRole("alert")).toHaveLength(1);
});
it("explains a 403 HIVE_FORBIDDEN inline", async () => {
vi.mocked(createInspection).mockRejectedValue(
new ApiError(403, "Forbidden", { code: "HIVE_FORBIDDEN" }),
);
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Test Futter" }));
await save();
expect(await screen.findByRole("alert")).toHaveTextContent("Dir fehlt das Recht, die Beute zu ändern.");
});
});
@@ -48,6 +48,13 @@ vi.mock("@/lib/inspections/api", () => ({
}));
vi.mock("@/lib/weather/api", () => ({ fetchApiaryWeather: vi.fn() }));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }));
// The Beute card and its data have their own specs (page-hive.spec.tsx); here the section stays inert.
vi.mock("@/components/hive/hive-card", () => ({ HiveCard: () => null }));
vi.mock("@/lib/hive/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/hive/api")>()),
fetchHive: vi.fn().mockRejectedValue(new Error("no hive")),
fetchHiveSystems: vi.fn().mockResolvedValue([]),
}));
vi.mock("@/lib/offline/sync", () => ({ syncPendingEntries: vi.fn() }));
vi.mock("@/lib/photos/store", () => ({
PHOTOS_PER_INSPECTION_MAX: 5,
@@ -1110,4 +1117,76 @@ describe("NewInspectionPage", () => {
expect(discardSessionPhotos).not.toHaveBeenCalled();
});
});
describe("dictation", () => {
class FakeRecognition {
static instances: FakeRecognition[] = [];
static available = vi.fn(async () => "available");
lang = "";
continuous = false;
interimResults = false;
maxAlternatives = 1;
handlers: Record<string, (event: unknown) => void> = {};
start = vi.fn();
stop = vi.fn();
abort = vi.fn();
constructor() {
FakeRecognition.instances.push(this);
}
set onresult(fn: (event: unknown) => void) {
this.handlers.result = fn;
}
set onend(fn: (event: unknown) => void) {
this.handlers.end = fn;
}
set onerror(fn: (event: unknown) => void) {
this.handlers.error = fn;
}
set onaudiostart(fn: (event: unknown) => void) {
this.handlers.audiostart = fn;
}
}
beforeEach(() => {
FakeRecognition.instances = [];
(window as unknown as Record<string, unknown>).SpeechRecognition = FakeRecognition;
});
afterEach(() => {
delete (window as unknown as Record<string, unknown>).SpeechRecognition;
});
async function openMoreFields() {
await userEvent.click(screen.getByRole("button", { name: /Weitere Angaben/ }));
}
it("puts a dictation button next to notes, health, varroa and measures", async () => {
renderPage();
await openMoreFields();
for (const field of ["Notizen", "Gesundheitsauffälligkeiten", "Varroa-Befund", "Maßnahmen"]) {
expect(
await screen.findByRole("button", { name: `Diktieren: ${field}` }),
).toBeInTheDocument();
}
});
it("appends the dictated text to a typed note and saves it", async () => {
vi.mocked(createInspection).mockResolvedValue({ id: "i-1", colonyId: "colony-1" } as never);
renderPage();
await openMoreFields();
await userEvent.type(screen.getByRole("textbox", { name: /^Notizen/ }), "Alt");
await userEvent.click(await screen.findByRole("button", { name: "Diktieren: Notizen" }));
const engine = FakeRecognition.instances[0];
act(() =>
engine.handlers.result({
resultIndex: 0,
results: [Object.assign([{ transcript: "neu" }], { isFinal: true })],
}),
);
expect(screen.getByRole("textbox", { name: /^Notizen/ })).toHaveValue("Alt neu");
await userEvent.click(screen.getByRole("button", { name: "Durchsicht speichern" }));
expect(vi.mocked(createInspection).mock.calls[0][0]).toMatchObject({ notes: "Alt neu" });
});
});
});
@@ -20,6 +20,8 @@ import {
} from "@/lib/inspections/takeover";
import { type LastInspection, useLastInspection } from "@/lib/inspections/use-last-inspection";
import type { ColonyTakeoverField, CreateInspectionInput } from "@/lib/inspections/types";
import { hiveKeys } from "@/lib/hive/api";
import type { HiveOp } from "@/lib/hive/types";
import { useAuth, useCurrentUserId } from "@/lib/auth/auth-context";
import { MAX_OCCUPIED_FRAMES } from "@/lib/colonies/labels";
import { photosNotTakenOver } from "@/lib/offline/queue-texts";
@@ -40,6 +42,8 @@ import type { StandRound } from "@/lib/rounds/round-state";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { DictationRow } from "@/components/voice/dictation-button";
import { appendDictation } from "@/lib/voice/append-text";
import { FormBackLink } from "@/components/form/form-back-link";
import { RoundNextPanel } from "@/components/rounds/round-next-panel";
import { PageHeader } from "@/components/page-header";
@@ -66,6 +70,12 @@ import {
syncTakeoverSelection,
toCreateInspectionInput,
} from "./inspection-form-values";
import { broodHintFor, broodHintLabel, hiveErrorText } from "./hive-change";
import { suggestAfterInspection } from "./hive-save-follow-ups";
import { InspectionHiveSection, useInspectionHive } from "./hive-section";
/** Free-text fields that take dictation. */
type DictatedField = "notes" | "healthIssues" | "varroaIssues" | "measures";
const UNDO_TOAST_MS = 8000;
const WEATHER_UPDATED = "Wetter aktualisiert";
@@ -218,6 +228,13 @@ function InspectionCaptureForm({
const colonyCard = colonies?.find((colony) => colony.id === values.colonyId);
const stand = useStandRound(roundApiaryId, values.colonyId);
const [roundSaved, setRoundSaved] = useState<RoundSaved | null>(null);
// Hive ops collected in the „Beute“ section; sent with the inspection, never drafted.
const [hiveOps, setHiveOps] = useState<HiveOp[]>([]);
const [hiveCardGeneration, setHiveCardGeneration] = useState(0);
// The ops of the last submit: a refusal only speaks for exactly those.
const [submittedHiveOps, setSubmittedHiveOps] = useState("");
const { base: hiveBase, catalog: hiveCatalog } = useInspectionHive(values.colonyId);
const broodFromHive = broodHintFor(hiveBase, hiveOps, hiveCatalog);
/**
* Every change of the answers also keeps the Volkskarte selection in step.
@@ -255,10 +272,33 @@ function InspectionCaptureForm({
{ touched: TOUCHED_TAKEOVER_FIELD[key] },
);
setDirty(true);
if (key === "colonyId") setColonyError(null);
if (key === "colonyId") {
// The ops belong to the other colony's hive.
replaceHiveOps([]);
setColonyError(null);
}
if (key === "date") setDateError(null);
}
/** Ops reported by the card as the beekeeper edits. */
function collectHiveOps(ops: HiveOp[]) {
setHiveOps(ops);
setDirty(true);
}
/** Ops changed from outside the card („Rückgängig“, new colony): the card is rebuilt from them. */
function replaceHiveOps(ops: HiveOp[]) {
setHiveOps(ops);
setHiveCardGeneration((generation) => generation + 1);
setDirty(true);
}
/** Dictation is added to the end of a free-text field and marks the form as changed. */
function appendDictated(key: DictatedField, text: string) {
update((current) => ({ ...current, [key]: appendDictation(current[key], text) }));
setDirty(true);
}
/** How many photos the form's photo section shows (none while it is hidden). */
function shownPhotoCount(photoSessionId: string): number {
return (
@@ -336,6 +376,19 @@ function InspectionCaptureForm({
queryKey: ["colonies", outcome.result.colonyId, "inspections"],
});
toast.success("Durchsicht gespeichert");
if (input.hiveChange) {
void queryClient.invalidateQueries({ queryKey: hiveKeys.detail(input.colonyId) });
if (hiveBase && hiveCatalog) {
void suggestAfterInspection({
colonyId: input.colonyId,
occurredOn: toCalendarDay(input.date),
ops: input.hiveChange.ops,
base: hiveBase,
catalog: hiveCatalog,
navigate: (href) => router.push(href),
});
}
}
const takeover = outcome.result.colonyTakeover;
if (takeover?.applied.length) {
// The colony card changed: its page and lists must not show the old values.
@@ -367,7 +420,8 @@ function InspectionCaptureForm({
return;
}
submittedPhotoSession.current = values.photoSessionId;
capture.submit(toCreateInspectionInput(values, canUpdateColony));
setSubmittedHiveOps(JSON.stringify(hiveOps));
capture.submit(toCreateInspectionInput(values, canUpdateColony, hiveOps));
}
/**
@@ -496,6 +550,10 @@ function InspectionCaptureForm({
// Same rule as the backend automation: only an explicit "Nein" counts.
const noQueenSigns = values.queenSeen === false && values.eggsPresent === false;
const hiveError =
hiveOps.length > 0 && JSON.stringify(hiveOps) === submittedHiveOps
? hiveErrorText(capture.error)
: null;
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
@@ -609,12 +667,46 @@ function InspectionCaptureForm({
describedBy={describedBy}
/>
{hintFor("broodFrames")}
{broodFromHive !== null &&
values.broodFrames !== String(broodFromHive) &&
!(broodFromHive === 0 && values.broodFrames === "") ? (
<PreviousValueHint
prefix="Laut Beute"
fieldLabel="Brutwaben"
valueLabel={broodHintLabel(broodFromHive)}
onTakeOver={() => set("broodFrames", String(broodFromHive))}
/>
) : null}
</>
)}
</Field>
</FormSection>
<FormSection step={3} title="Stärke & Verhalten">
{colonyCard ? (
<FormSection
step={3}
title="Beute"
description={
canUpdateColony
? "Änderungen am Aufbau werden mit der Durchsicht gespeichert."
: "Nur ansehen – Änderungen am Aufbau brauchen das Recht, Völker zu bearbeiten."
}
>
<InspectionHiveSection
colony={colonyCard}
canEdit={canUpdateColony}
ops={hiveOps}
cardKey={`${colonyCard.id}:${hiveCardGeneration}`}
onOpsChange={collectHiveOps}
onOpsReplace={replaceHiveOps}
base={hiveBase}
catalog={hiveCatalog}
errorText={hiveError}
/>
</FormSection>
) : null}
<FormSection step={4} title="Stärke & Verhalten">
<Field label="Volksstärke" hint="Anzahl besetzter Waben">
{({ id, describedBy }) => (
<>
@@ -680,7 +772,7 @@ function InspectionCaptureForm({
</Field>
</FormSection>
<FormSection step={4} title="Futter">
<FormSection step={5} title="Futter">
<Field label="Futterstatus" group>
{({ id, labelId }) => (
<>
@@ -705,7 +797,7 @@ function InspectionCaptureForm({
</FormSection>
<FormSection
step={5}
step={6}
title="Wetter"
description="Automatisch aus den Wetterdaten des Stands (Tageshöchstwert) – änderbar"
>
@@ -743,7 +835,7 @@ function InspectionCaptureForm({
</FormSection>
<FormSection
step={6}
step={7}
title="Weitere Angaben"
collapsible
defaultOpen={false}
@@ -766,50 +858,70 @@ function InspectionCaptureForm({
</Field>
<Field label="Varroa-Befund">
{({ id }) => (
<Input
id={id}
className="h-11"
value={values.varroaIssues}
onChange={(event) =>
set("varroaIssues", event.target.value)
}
/>
<DictationRow
fieldLabel="Varroa-Befund"
onAppend={(text) => appendDictated("varroaIssues", text)}
>
<Input
id={id}
className="h-11"
value={values.varroaIssues}
onChange={(event) =>
set("varroaIssues", event.target.value)
}
/>
</DictationRow>
)}
</Field>
<Field label="Gesundheitsauffälligkeiten">
{({ id }) => (
<Input
id={id}
className="h-11"
value={values.healthIssues}
onChange={(event) =>
set("healthIssues", event.target.value)
}
/>
<DictationRow
fieldLabel="Gesundheitsauffälligkeiten"
onAppend={(text) => appendDictated("healthIssues", text)}
>
<Input
id={id}
className="h-11"
value={values.healthIssues}
onChange={(event) =>
set("healthIssues", event.target.value)
}
/>
</DictationRow>
)}
</Field>
<Field label="Maßnahmen">
{({ id }) => (
<Textarea
id={id}
value={values.measures}
onChange={(event) => set("measures", event.target.value)}
/>
<DictationRow
fieldLabel="Maßnahmen"
onAppend={(text) => appendDictated("measures", text)}
>
<Textarea
id={id}
value={values.measures}
onChange={(event) => set("measures", event.target.value)}
/>
</DictationRow>
)}
</Field>
<Field label="Notizen">
{({ id }) => (
<Textarea
id={id}
value={values.notes}
onChange={(event) => set("notes", event.target.value)}
/>
<DictationRow
fieldLabel="Notizen"
onAppend={(text) => appendDictated("notes", text)}
>
<Textarea
id={id}
value={values.notes}
onChange={(event) => set("notes", event.target.value)}
/>
</DictationRow>
)}
</Field>
</FormSection>
<PhotoCaptureField
step={7}
step={8}
sessionId={values.photoSessionId}
date={values.date}
onChange={() => setDirty(true)}
@@ -833,7 +945,7 @@ function InspectionCaptureForm({
<StickySubmit
note={
capture.submitError ? (
capture.submitError && hiveErrorText(capture.error) === null ? (
<p
role="alert"
className="text-sm font-medium text-destructive"
@@ -206,6 +206,21 @@ describe("AppLayout", () => {
expect(pushMock).toHaveBeenCalledWith("/login");
});
it("links „Sprache & Datenschutz“ in the account menu for every user", async () => {
render(
<AppLayout>
<p>Seiteninhalt</p>
</AppLayout>,
);
await userEvent.click(screen.getByRole("button", { name: "Konto-Menü" }));
expect(await screen.findByRole("menuitem", { name: "Sprache & Datenschutz" })).toHaveAttribute(
"href",
"/settings/voice",
);
});
it("gives the band the page content wrapper as a stable focus target", () => {
render(
<AppLayout>
@@ -116,6 +116,9 @@ export default function AppLayout({ children }: { children: ReactNode }) {
<DropdownMenuItem render={<Link href="/profile" />}>
Profil
</DropdownMenuItem>
<DropdownMenuItem render={<Link href="/settings/voice" />}>
Sprache &amp; Datenschutz
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={() => setIsLogoutOpen(true)}
@@ -15,6 +15,7 @@ import {
storedPack,
} from "@/lib/apiaries/offline-pack.fixtures";
import { stepFixture } from "@/lib/autopilot/step-fixtures";
import { setupView, systemDto } from "@/components/hive/test-fixtures";
import OfflineColonyPage from "./page";
let searchString = "id=colony-1";
@@ -262,4 +263,39 @@ describe("OfflineColonyPage", () => {
expect(await screen.findByText("Geladen vor 5 Minuten")).toBeInTheDocument();
expect(screen.queryByText(/bei Netz neu laden/)).not.toBeInTheDocument();
});
describe("hive", () => {
it("shows the hive read-only as SVG and outline from the pack", async () => {
await putPacks(
storedPack({
...fullPack(),
hives: { "colony-1": setupView(undefined, "colony-1") },
hiveCatalog: [systemDto()],
}),
);
renderPage();
const section = (await screen.findByRole("heading", { name: "Beute" })).closest(
"section, [role=region]",
) as HTMLElement;
expect(await within(section).findByRole("img")).toBeInTheDocument();
expect(within(section).getByRole("tree")).toBeInTheDocument();
expect(within(section).queryByRole("button", { name: /Übernehmen|Bearbeiten|Umbauen/ })).not.toBeInTheDocument();
});
it("says there is no hive yet for a colony the pack knows has none", async () => {
await putPacks(storedPack({ ...fullPack(), hives: {}, hiveCatalog: [systemDto()] }));
renderPage();
expect(await screen.findByText("Noch keine Beute angelegt")).toBeInTheDocument();
});
it("shows no hive section for a pack from before hives", async () => {
await putPacks(storedPack(fullPack()));
renderPage();
await screen.findByRole("heading", { name: "Volk" });
expect(screen.queryByRole("heading", { name: "Beute" })).not.toBeInTheDocument();
});
});
});
@@ -25,6 +25,7 @@ import { todayLocalIsoDate } from "@/lib/dates";
import type { StoredPack } from "@/lib/offline/db";
import { QUEEN_STATUS_LABELS } from "@/lib/queens/labels";
import { NextStepsView } from "@/components/autopilot/next-steps-card";
import { OfflineHive } from "@/components/hive/offline-hive-section";
import { StatusLightBadge } from "@/components/colonies/status-light-badge";
import { DetailHeader, DetailStatus } from "@/components/detail-header";
import { PackAgeNote } from "@/components/offline/load-pack-button";
@@ -162,6 +163,16 @@ function OfflineColonyView({ stored, colony, readAt, returnHref }: OfflineColony
/>
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-2">
<ColonyFacts colony={colony} />
{/* A pack from before hives says nothing about them: no section then. */}
{pack.hives ? (
<OfflineSection title="Beute">
<OfflineHive
colonyId={colony.id}
setup={pack.hives[colony.id] ?? null}
systems={pack.hiveCatalog ?? []}
/>
</OfflineSection>
) : null}
{pack.queens !== null ? <QueenFacts queen={queen} /> : null}
{pack.inspections !== null ? (
<LastInspections inspections={pack.inspections} colonyId={colony.id} />
@@ -3,8 +3,10 @@ import { render, screen } from "@testing-library/react";
import SettingsLayout from "./layout";
const replaceMock = vi.fn();
let mockPathname = "/settings/system";
vi.mock("next/navigation", () => ({
useRouter: () => ({ replace: replaceMock }),
usePathname: () => mockPathname,
}));
let mockUser: { permissions: string[] } | null = null;
@@ -16,6 +18,7 @@ describe("SettingsLayout", () => {
beforeEach(() => {
replaceMock.mockReset();
mockUser = null;
mockPathname = "/settings/system";
});
it("renders children when the user has a settings-area permission", () => {
@@ -67,4 +70,32 @@ describe("SettingsLayout", () => {
expect(screen.queryByText("Einstellungen-Inhalt")).not.toBeInTheDocument();
expect(replaceMock).toHaveBeenCalledWith("/dashboard");
});
it("lets every signed-in user read the voice and privacy page", () => {
mockUser = { permissions: [] };
mockPathname = "/settings/voice";
render(
<SettingsLayout>
<p>Einstellungen-Inhalt</p>
</SettingsLayout>,
);
expect(screen.getByText("Einstellungen-Inhalt")).toBeInTheDocument();
expect(replaceMock).not.toHaveBeenCalled();
});
it("does not open other settings pages through the voice exception", () => {
mockUser = { permissions: [] };
mockPathname = "/settings/voice-admin";
render(
<SettingsLayout>
<p>Einstellungen-Inhalt</p>
</SettingsLayout>,
);
expect(screen.queryByText("Einstellungen-Inhalt")).not.toBeInTheDocument();
expect(replaceMock).toHaveBeenCalledWith("/dashboard");
});
});
@@ -1,7 +1,7 @@
"use client";
import { ReactNode, useEffect } from "react";
import { useRouter } from "next/navigation";
import { usePathname, useRouter } from "next/navigation";
import { useAuth } from "@/lib/auth/auth-context";
const SETTINGS_AREA_PERMISSIONS = [
@@ -23,12 +23,19 @@ const SETTINGS_AREA_PERMISSIONS = [
"system:read",
];
/** Pages under /settings that every signed-in user may open (no admin permission). */
const OPEN_SETTINGS_PATHS = ["/settings/voice"];
export default function SettingsLayout({ children }: { children: ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const { user } = useAuth();
const hasSettingsAccess = SETTINGS_AREA_PERMISSIONS.some((permission) =>
user?.permissions.includes(permission),
const isOpenPage = OPEN_SETTINGS_PATHS.some(
(path) => pathname === path || pathname.startsWith(`${path}/`),
);
const hasSettingsAccess =
(isOpenPage && Boolean(user)) ||
SETTINGS_AREA_PERMISSIONS.some((permission) => user?.permissions.includes(permission));
useEffect(() => {
if (!hasSettingsAccess) {
@@ -0,0 +1,125 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import axe from "axe-core";
import VoiceSettingsPage from "./page";
import { getCloudRecognitionConsent } from "@/lib/voice/consent";
import { fetchSiteSettings } from "@/lib/site-settings/api";
import type { SiteSettings } from "@/lib/site-settings/types";
let permissions = new Set<string>();
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({ hasPermission: (permission: string) => permissions.has(permission) }),
}));
vi.mock("@/lib/site-settings/api", () => ({ fetchSiteSettings: vi.fn() }));
function settings(overrides: Partial<SiteSettings> = {}): SiteSettings {
return {
id: "s-1",
youtubeUrl: null,
instagramUrl: null,
tiktokUrl: null,
websiteUrl: null,
holderName: "Imkerei Muster",
holderAddress: "Honigweg 1, 12345 Bienenstadt",
foodBusinessName: null,
foodBusinessAddress: null,
originCountry: "DE",
updatedAt: "2026-10-16T00:00:00.000Z",
...overrides,
};
}
function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<VoiceSettingsPage />
</QueryClientProvider>,
);
}
describe("VoiceSettingsPage", () => {
beforeEach(() => {
window.localStorage.clear();
permissions = new Set();
vi.mocked(fetchSiteSettings).mockReset().mockResolvedValue(settings());
});
it("the cloud switch is off by default and stores the consent", async () => {
const user = userEvent.setup();
renderPage();
const toggle = screen.getByRole("switch", { name: /Browser-Cloud-Erkennung erlauben/ });
expect(toggle).not.toBeChecked();
expect(getCloudRecognitionConsent()).toBe(false);
await user.click(toggle);
expect(toggle).toBeChecked();
expect(getCloudRecognitionConsent()).toBe(true);
expect(screen.getByRole("status")).toHaveTextContent("Erlaubt");
await user.click(toggle);
expect(getCloudRecognitionConsent()).toBe(false);
expect(screen.getByRole("status")).toHaveTextContent("Aus");
});
it("shows a stored consent as switched on", async () => {
window.localStorage.setItem("apicore.voice.cloud-consent", "1");
renderPage();
await waitFor(() =>
expect(screen.getByRole("switch", { name: /Browser-Cloud-Erkennung/ })).toBeChecked(),
);
});
it("names the retention periods in words", () => {
renderPage();
const text = document.body.textContent ?? "";
expect(text).toContain("24 Stunden");
expect(text).toContain("7 Tage");
expect(text).toContain("90 Tage");
expect(text).toContain("Anthropic");
expect(text).toContain("Google");
expect(text).toContain("Apple");
expect(text).toContain("im Cluster");
});
it("shows the holder name and address from the site settings", async () => {
permissions = new Set(["site-settings:read"]);
renderPage();
expect(await screen.findByText("Imkerei Muster")).toBeInTheDocument();
expect(screen.getByText("Honigweg 1, 12345 Bienenstadt")).toBeInTheDocument();
});
it("keeps a placeholder without the permission or while nothing is stored", async () => {
renderPage();
expect(fetchSiteSettings).not.toHaveBeenCalled();
expect(screen.getAllByText(/Noch nicht hinterlegt/)).toHaveLength(2);
permissions = new Set(["site-settings:read"]);
vi.mocked(fetchSiteSettings).mockResolvedValue(
settings({ holderName: null, holderAddress: null }),
);
renderPage();
await waitFor(() => expect(fetchSiteSettings).toHaveBeenCalled());
expect(screen.getAllByText(/Noch nicht hinterlegt/)).toHaveLength(4);
});
it("axe finds nothing", async () => {
permissions = new Set(["site-settings:read"]);
const { container } = renderPage();
await screen.findByText("Imkerei Muster");
// jsdom has no layout or colours: the contrast rule cannot run here.
const results = await axe.run(container, { rules: { "color-contrast": { enabled: false } } });
expect(results.violations).toEqual([]);
});
});
@@ -0,0 +1,115 @@
"use client";
import { useSyncExternalStore } from "react";
import { useQuery } from "@tanstack/react-query";
import { ShieldCheck } from "lucide-react";
import { useAuth } from "@/lib/auth/auth-context";
import { fetchSiteSettings } from "@/lib/site-settings/api";
import {
getCloudRecognitionConsent,
setCloudRecognitionConsent,
subscribeCloudRecognitionConsent,
} from "@/lib/voice/consent";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import {
RECORDING_NOTICE,
VOICE_PRIVACY_SECTIONS,
} from "./voice-privacy-text";
const NOT_STORED = "Noch nicht hinterlegt – der Inhaber trägt das unter Verwaltung ein.";
function CloudConsentCard() {
// localStorage does not exist on the server: it renders "off" and the client corrects it.
const allowed = useSyncExternalStore(
subscribeCloudRecognitionConsent,
getCloudRecognitionConsent,
() => false,
);
function toggle(next: boolean) {
setCloudRecognitionConsent(next);
}
return (
<Card>
<CardContent className="flex flex-col gap-3">
<h2 className="font-heading text-lg font-semibold">Browser-Cloud-Erkennung</h2>
<label
htmlFor="voice-cloud-consent"
className="flex min-h-14 cursor-pointer items-center justify-between gap-4 rounded-xl border border-input px-4 py-2"
>
<span className="text-sm font-medium">Browser-Cloud-Erkennung erlauben</span>
<input
id="voice-cloud-consent"
type="checkbox"
role="switch"
className="size-6 shrink-0 accent-[var(--primary)]"
checked={allowed}
onChange={(event) => toggle(event.target.checked)}
/>
</label>
<p role="status" className="text-sm font-medium">
{allowed ? "Erlaubt" : "Aus"}
</p>
<p className="text-sm text-muted-foreground">
Ohne Erlaubnis diktierst du nur, wenn dein Gerät die Sprache selbst erkennt. Du kannst
die Erlaubnis jederzeit hier zurücknehmen.
</p>
</CardContent>
</Card>
);
}
function HolderCard() {
const { hasPermission } = useAuth();
const canRead = hasPermission("site-settings:read");
const { data } = useQuery({
queryKey: ["site-settings"],
queryFn: fetchSiteSettings,
enabled: canRead,
});
return (
<Card>
<CardContent className="flex flex-col gap-1">
<h2 className="font-heading text-lg font-semibold">Verantwortlich</h2>
<p className="text-sm text-muted-foreground">Name und Anschrift:</p>
<p className="text-sm font-medium">{data?.holderName || NOT_STORED}</p>
<p className="text-sm font-medium">{data?.holderAddress || NOT_STORED}</p>
</CardContent>
</Card>
);
}
export default function VoiceSettingsPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={ShieldCheck}
title="Sprache & Datenschutz"
description="Was mit deiner Stimme und dem Text passiert."
/>
<CloudConsentCard />
{VOICE_PRIVACY_SECTIONS.map((section) => (
<Card key={section.id}>
<CardContent className="flex flex-col gap-2">
<h2 className="font-heading text-lg font-semibold">{section.title}</h2>
{section.paragraphs.map((paragraph) => (
<p key={paragraph} className="text-sm">
{paragraph}
</p>
))}
</CardContent>
</Card>
))}
<Card>
<CardContent className="flex flex-col gap-2">
<h2 className="font-heading text-lg font-semibold">Vor der ersten Aufnahme</h2>
<p className="text-sm">{RECORDING_NOTICE}</p>
</CardContent>
</Card>
<HolderCard />
</main>
);
}
@@ -0,0 +1,58 @@
/**
* What the app does with voice and text, in plain German (T7 spec 8.4 and 9.5).
* Kept apart from the page so the wording can be checked and changed in one
* place. Retention periods are spelled out; the server enforces them in SQL.
*/
export const VOICE_RETENTION = {
audio: "24 Stunden",
content: "7 Tage",
record: "90 Tage",
} as const;
export interface PrivacySection {
id: string;
title: string;
paragraphs: readonly string[];
}
/** Shown once before the first recording and kept here for reading later. */
export const RECORDING_NOTICE =
"Sprich nur selbst und nenne keine Namen anderer Personen. Der Ton bleibt in deinem Cluster und wird nach dem Abschreiben gelöscht. Der Text geht zur Auswertung an Anthropic.";
export const VOICE_PRIVACY_SECTIONS: readonly PrivacySection[] = [
{
id: "dictation",
title: "Diktat in den Textfeldern",
paragraphs: [
"Dein Browser wandelt deine Sprache in Text um. Die App speichert dabei keinen Ton.",
"Wenn dein Gerät das kann, läuft die Erkennung auf dem Gerät. Dann verlässt kein Ton das Gerät.",
"Wenn nicht, schickt der Browser den Ton an den Hersteller: bei Chrome an Google, bei Safari an Apple. Das passiert nur, wenn du es unten erlaubst.",
],
},
{
id: "notes",
title: "Sprachnotizen (in Vorbereitung)",
paragraphs: [
`Der Ton bleibt in deinem Cluster. Er wird nach dem Abschreiben gelöscht, spätestens nach ${VOICE_RETENTION.audio}.`,
`Der abgeschriebene Text geht zur Auswertung an Anthropic. Er bleibt, bis du ihn übernimmst oder verwirfst, höchstens ${VOICE_RETENTION.content}.`,
`Angaben wie Status und Zähler bleiben ${VOICE_RETENTION.record}. Danach löscht das System die Zeile.`,
],
},
{
id: "where",
title: "Wohin geht was",
paragraphs: [
"Ton von Sprachnotizen: bleibt im Cluster.",
"Text zur Auswertung: geht an Anthropic. Die Verarbeitung findet voraussichtlich in den USA statt.",
"Browser-Diktat mit Cloud-Erkennung: geht an Google oder Apple.",
],
},
{
id: "limits",
title: "Grenzen",
paragraphs: [
"Es spricht eine Person. Sprich nur selbst.",
"In den täglichen Sicherungen liegen gelöschte Einträge noch einige Tage verschlüsselt, bis die alte Sicherung ersetzt wird.",
],
},
];
@@ -0,0 +1,254 @@
"use client";
import { useId, useState } from "react";
import type { ReactNode } from "react";
import { ChoiceGroup } from "@/components/form/choice-group";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { feederRange, freeFeederStarts, rangeText } from "@/lib/hive/feeder-slots";
import { PART_KIND_LABELS } from "@/lib/hive/labels";
import { partDisplayName } from "@/lib/hive/names";
import { hiveReasonText } from "@/lib/hive/reason-text";
import { newIdempotencyKey } from "@/lib/offline/idempotency";
import type {
CatalogPartType,
HiveCatalog,
HiveFrame,
HiveOp,
HivePart,
HivePartKind,
HiveState,
} from "@/lib/hive/types";
import { frameFeederOps } from "../frame-feeder-picker";
import { areDraftFieldsValid, draftOf, draftValue, StateFieldControl } from "../state-field-control";
import type { FieldDraft } from "../state-field-control";
import type { DraftApplyResult } from "./use-hive-draft";
/** Host kind per insert kind (mirrors INSERT_KINDS of the validation). */
const INSERT_HOST_KIND: Partial<Record<HivePartKind, HivePartKind>> = {
FLOOR_INSERT: "FLOOR",
EVAPORATOR: "BOX",
FRAME_FEEDER: "BOX",
};
/** Inserts that a host can hold only once. */
const SINGLE_INSERTS: readonly HivePartKind[] = ["FLOOR_INSERT", "EVAPORATOR"];
const SELECT_CLASS =
"h-11 w-full rounded-xl border border-input bg-card px-3 text-base shadow-xs outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 md:text-sm";
export interface AddPartDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
type: CatalogPartType | null;
state: HiveState;
catalog: HiveCatalog;
/** Takes the ops as one draft step; a refusal is shown inline and the dialog stays open. */
onAdd: (ops: HiveOp[]) => DraftApplyResult;
}
/** „Hinzufügen …“ (spec 9.5): the keyboard and screen reader way to place a part. */
export function AddPartDialog({ open, onOpenChange, type, state, catalog, onAdd }: AddPartDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
{type ? (
<AddPartForm
key={type.id}
type={type}
state={state}
catalog={catalog}
onAdd={onAdd}
onClose={() => onOpenChange(false)}
/>
) : null}
</DialogContent>
</Dialog>
);
}
interface AddPartFormProps {
type: CatalogPartType;
state: HiveState;
catalog: HiveCatalog;
onAdd: (ops: HiveOp[]) => DraftApplyResult;
onClose: () => void;
}
function kindOf(catalog: HiveCatalog, part: HivePart): HivePartKind | undefined {
return catalog.partTypes.get(part.partTypeId)?.kind;
}
/** Stack index a new part starts at: floors at the bottom, the rest below a roof. */
function defaultPosition(type: CatalogPartType, state: HiveState, catalog: HiveCatalog): number {
const top = state.stack.length;
if (type.kind === "FLOOR") return 0;
const last = state.stack[top - 1];
return last && type.kind !== "ROOF" && kindOf(catalog, last) === "ROOF" ? top - 1 : top;
}
/** Boxes and floors that can still take this insert. */
function hostsFor(type: CatalogPartType, state: HiveState, catalog: HiveCatalog): HivePart[] {
const hostKind = INSERT_HOST_KIND[type.kind];
return state.stack.filter((part) => {
if (kindOf(catalog, part) !== hostKind) return false;
if (SINGLE_INSERTS.includes(type.kind)) {
return !(part.inserts ?? []).some((insert) => kindOf(catalog, insert) === type.kind);
}
const hostType = catalog.partTypes.get(part.partTypeId);
return hostType ? freeFeederStarts(part, hostType, type, catalog).length > 0 : false;
});
}
function Field({ label, htmlFor, children }: { label: string; htmlFor: string; children: ReactNode }) {
return (
<div className="flex flex-col gap-1.5">
<label htmlFor={htmlFor} className="text-sm font-medium">
{label}
</label>
{children}
</div>
);
}
function newBoxFrames(type: CatalogPartType): HiveFrame[] {
return Array.from({ length: type.frameSlots ?? 0 }, (_, index) => ({ slot: index + 1, type: "FOUNDATION" }));
}
function AddPartForm({ type, state, catalog, onAdd, onClose }: AddPartFormProps) {
const formId = useId();
const [newId] = useState(newIdempotencyKey);
const isInsert = type.kind in INSERT_HOST_KIND;
const hosts = isInsert ? hostsFor(type, state, catalog) : [];
const [position, setPosition] = useState(() => String(defaultPosition(type, state, catalog)));
const [hostId, setHostId] = useState(() => hosts[0]?.id ?? "");
const [start, setStart] = useState<number | null>(null);
const [draft, setDraft] = useState<FieldDraft>(() => draftOf(type, {}));
const [error, setError] = useState<string | null>(null);
const host = hosts.find((candidate) => candidate.id === hostId) ?? hosts[0];
const hostType = host ? catalog.partTypes.get(host.partTypeId) : undefined;
const starts =
type.kind === "FRAME_FEEDER" && host && hostType ? freeFeederStarts(host, hostType, type, catalog) : [];
const chosenStart = start !== null && starts.includes(start) ? start : (starts[0] ?? null);
const noPlace = isInsert && (!host || (type.kind === "FRAME_FEEDER" && chosenStart === null));
function buildOps(): HiveOp[] {
const partState: Record<string, number | string> = {};
for (const field of type.stateSchema) partState[field.key] = draftValue(field, draft[field.key] ?? "");
const fresh: HivePart = { id: newId, partTypeId: type.id, state: partState };
if (type.kind === "BOX") fresh.frames = newBoxFrames(type);
if (!isInsert) return [{ op: "addPart", part: fresh, at: Number(position) }];
if (type.kind === "FRAME_FEEDER" && host && chosenStart !== null) {
return frameFeederOps(host.id, host.frames ?? [], type, chosenStart, fresh);
}
return [{ op: "addInsert", hostId: host?.id ?? "", part: fresh }];
}
function submit() {
const outcome = onAdd(buildOps());
if (outcome.ok) onClose();
else setError(hiveReasonText(outcome.reason));
}
const positions = Array.from({ length: state.stack.length + 1 }, (_, index) => ({
value: String(index),
label: index === 0 ? "ganz unten" : `über ${partDisplayName(state, catalog, state.stack[index - 1].id)}`,
}));
const startOptions = starts.map((from) => {
const range = feederRange({ id: "", partTypeId: type.id, state: {}, slot: from }, type);
const noun = range && range.from !== range.to ? "Plätze" : "Platz";
return { value: from, label: `${noun} ${range ? rangeText(range) : from}` };
});
return (
<>
<DialogHeader>
<DialogTitle>{type.name} hinzufügen</DialogTitle>
<DialogDescription>
{PART_KIND_LABELS[type.kind]}
{isInsert ? ": Wähle, wohin das Teil kommt." : ": Wähle die Stelle im Aufbau."}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
{isInsert ? (
host ? (
<Field label="In" htmlFor={`${formId}-host`}>
<select
id={`${formId}-host`}
className={SELECT_CLASS}
value={host.id}
onChange={(event) => setHostId(event.target.value)}
>
{hosts.map((candidate) => (
<option key={candidate.id} value={candidate.id}>
{partDisplayName(state, catalog, candidate.id)}
</option>
))}
</select>
</Field>
) : (
<p className="text-sm text-muted-foreground">Dafür gibt es gerade keinen passenden Platz.</p>
)
) : (
<Field label="Position" htmlFor={`${formId}-position`}>
<select
id={`${formId}-position`}
className={SELECT_CLASS}
value={position}
onChange={(event) => setPosition(event.target.value)}
>
{positions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</Field>
)}
{type.kind === "FRAME_FEEDER" && host && chosenStart !== null ? (
<div className="flex flex-col gap-1.5">
<span id={`${formId}-slot`} className="text-sm font-medium">
Plätze in der Zarge
</span>
<ChoiceGroup
options={startOptions}
value={chosenStart}
onChange={setStart}
labelledBy={`${formId}-slot`}
className="grid-cols-3"
/>
</div>
) : null}
{type.stateSchema.map((field) => (
<StateFieldControl
key={field.key}
field={field}
idPrefix={`${formId}-new`}
value={draft[field.key] ?? ""}
onChange={(value) => setDraft({ ...draft, [field.key]: value })}
/>
))}
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
) : null}
</div>
<DialogFooter>
<Button type="button" variant="outline" size="touch" onClick={onClose}>
Abbrechen
</Button>
<Button type="button" size="touch" disabled={noPlace || !areDraftFieldsValid(type, draft)} onClick={submit}>
Hinzufügen
</Button>
</DialogFooter>
</>
);
}
@@ -0,0 +1,59 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
export interface ConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description: string;
confirmLabel: string;
cancelLabel: string;
onConfirm: () => void;
}
/** A two-button question; the safe answer (cancel) comes first and takes the initial focus order. */
export function ConfirmDialog({
open,
onOpenChange,
title,
description,
confirmLabel,
cancelLabel,
onConfirm,
}: ConfirmDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" size="touch" onClick={() => onOpenChange(false)}>
{cancelLabel}
</Button>
<Button
type="button"
variant="outline"
size="touch"
onClick={() => {
onOpenChange(false);
onConfirm();
}}
>
{confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,27 @@
import type { ReactNode } from "react";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { GLASS_BUTTON, GLASS_SURFACE } from "../hive-glass";
export interface EditorNoticeProps {
title: string;
/** Where „Zurück zum Volk“ leads. */
backHref: string;
children?: ReactNode;
}
/** Full-screen message of the editor route (offline, no right, no hive): the world stays, the way back is clear. */
export function EditorNotice({ title, backHref, children }: EditorNoticeProps) {
return (
<main className="fixed inset-0 z-40 flex items-center justify-center bg-holo-bg p-4 text-holo-label-fg">
<div className={`flex max-w-md flex-col items-center gap-4 rounded-2xl p-6 text-center ${GLASS_SURFACE}`}>
<h1 className="text-lg font-semibold">{title}</h1>
{children}
<Link href={backHref} className={GLASS_BUTTON}>
<ArrowLeft aria-hidden />
Zurück zum Volk
</Link>
</div>
</main>
);
}
@@ -0,0 +1,97 @@
"use client";
import { ArrowDown, ArrowUp, Trash2 } from "lucide-react";
import { partDisplayName } from "@/lib/hive/names";
import type { HiveCatalog, HiveOp, HivePart, HiveState } from "@/lib/hive/types";
import { GLASS_BUTTON } from "../hive-glass";
import type { DraftApplyResult } from "./use-hive-draft";
const ICON_BUTTON = `${GLASS_BUTTON} w-11 px-0 sm:px-0`;
export interface EditorStackListProps {
state: HiveState;
catalog: HiveCatalog;
selectedId: string | null;
onSelect: (id: string) => void;
onApply: (op: HiveOp) => DraftApplyResult;
}
interface RowProps {
part: HivePart;
name: string;
isSelected: boolean;
onSelect: () => void;
moveUp?: () => void;
moveDown?: () => void;
remove: () => void;
}
function Row({ part, name, isSelected, onSelect, moveUp, moveDown, remove }: RowProps) {
return (
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={onSelect}
aria-current={isSelected ? "true" : undefined}
className="min-h-11 min-w-0 flex-1 truncate rounded-lg px-2 text-left text-sm focus-visible:outline-2 focus-visible:outline-holo-edge-strong aria-[current=true]:bg-holo-edge/25 aria-[current=true]:font-semibold"
data-part-id={part.id}
>
{name}
</button>
{moveUp || moveDown ? (
<>
<button type="button" aria-label={`Nach oben: ${name}`} title="Nach oben" disabled={!moveUp} onClick={moveUp} className={ICON_BUTTON}>
<ArrowUp aria-hidden />
</button>
<button type="button" aria-label={`Nach unten: ${name}`} title="Nach unten" disabled={!moveDown} onClick={moveDown} className={ICON_BUTTON}>
<ArrowDown aria-hidden />
</button>
</>
) : null}
<button type="button" aria-label={`Entfernen: ${name}`} title="Entfernen" onClick={remove} className={ICON_BUTTON}>
<Trash2 aria-hidden />
</button>
</div>
);
}
/**
* The stack as a list from top to bottom (spec 9.5/9.6): „Nach oben“, „Nach unten“
* and „Entfernen“ per part, the way to rearrange without dragging.
*/
export function EditorStackList({ state, catalog, selectedId, onSelect, onApply }: EditorStackListProps) {
const top = state.stack.length - 1;
const rows = state.stack.map((part, index) => ({ part, index })).reverse();
return (
<ul aria-label="Aufbau" className="flex flex-col gap-1">
{rows.map(({ part, index }) => (
<li key={part.id} className="flex flex-col gap-1">
<Row
part={part}
name={partDisplayName(state, catalog, part.id)}
isSelected={selectedId === part.id}
onSelect={() => onSelect(part.id)}
moveUp={index < top ? () => onApply({ op: "movePart", id: part.id, to: index + 1 }) : undefined}
moveDown={index > 0 ? () => onApply({ op: "movePart", id: part.id, to: index - 1 }) : undefined}
remove={() => onApply({ op: "removePart", id: part.id })}
/>
{(part.inserts ?? []).length > 0 ? (
<ul className="ml-4 flex flex-col gap-1 border-l border-white/15 pl-2">
{(part.inserts ?? []).map((insert) => (
<li key={insert.id}>
<Row
part={insert}
name={partDisplayName(state, catalog, insert.id)}
isSelected={selectedId === insert.id}
onSelect={() => onSelect(insert.id)}
remove={() => onApply({ op: "removePart", id: insert.id })}
/>
</li>
))}
</ul>
) : null}
</li>
))}
</ul>
);
}
@@ -0,0 +1,87 @@
"use client";
import type { ReactNode } from "react";
import { LayoutTemplate, Redo2, Save, Trash2, Undo2 } from "lucide-react";
import { GLASS_BUTTON, GLASS_SURFACE } from "../hive-glass";
export interface EditorToolbarProps {
/** The „Zurück“ link (guarded while ops are open). */
back: ReactNode;
title: string;
canUndo: boolean;
canRedo: boolean;
onUndo: () => void;
onRedo: () => void;
date: string;
/** Berlin today; later days are not accepted. */
today: string;
onDateChange: (date: string) => void;
hasOps: boolean;
saving: boolean;
onSave: () => void;
onDiscard: () => void;
/** Only set for users with hive-systems:manage. */
onSaveTemplate?: () => void;
templateSaving?: boolean;
}
/** Top bar of the editor: back, title, undo/redo, date and the three ways to finish. */
export function EditorToolbar(props: EditorToolbarProps) {
const { date, today, hasOps, saving } = props;
const dateValid = /^\d{4}-\d{2}-\d{2}$/.test(date) && date <= today;
return (
<header className={`flex flex-wrap items-center gap-2 px-3 py-2 ${GLASS_SURFACE}`}>
{props.back}
<h1 className="mr-auto text-lg font-semibold tracking-tight">{props.title}</h1>
<button type="button" onClick={props.onUndo} disabled={!props.canUndo} className={GLASS_BUTTON} title="Strg+Z">
<Undo2 aria-hidden />
Rückgängig
</button>
<button
type="button"
onClick={props.onRedo}
disabled={!props.canRedo}
className={GLASS_BUTTON}
title="Strg+Umschalt+Z"
>
<Redo2 aria-hidden />
Wiederholen
</button>
<label className="flex items-center gap-2 text-sm">
Datum
<input
type="date"
value={date}
max={today}
required
onChange={(event) => props.onDateChange(event.target.value)}
className="h-11 rounded-xl border border-white/20 bg-holo-label-bg/70 px-2 text-sm text-holo-label-fg [color-scheme:dark]"
/>
</label>
{props.onSaveTemplate ? (
<button
type="button"
onClick={props.onSaveTemplate}
disabled={props.templateSaving}
className={GLASS_BUTTON}
>
<LayoutTemplate aria-hidden />
Als Vorlage speichern
</button>
) : null}
<button type="button" onClick={props.onDiscard} disabled={saving} className={GLASS_BUTTON}>
<Trash2 aria-hidden />
Verwerfen
</button>
<button
type="button"
onClick={props.onSave}
disabled={!hasOps || !dateValid || saving}
className={GLASS_BUTTON}
>
<Save aria-hidden />
{saving ? "Speichert …" : "Speichern"}
</button>
</header>
);
}
@@ -0,0 +1,314 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { MouseEvent } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft } from "lucide-react";
import { toast } from "sonner";
import { berlinCalendarDay } from "@/lib/dates";
import { hiveConflictOf, hiveKeys } from "@/lib/hive/api";
import { updateHiveSystem } from "@/lib/hive/catalog-api";
import { describeHiveOps } from "@/lib/hive/describe";
import { hiveLayout } from "@/lib/hive/layout";
import { partDisplayName } from "@/lib/hive/names";
import { hiveReasonText } from "@/lib/hive/reason-text";
import type { CatalogPartType, HiveCatalog, HiveOp, HiveSetupView, HiveState } from "@/lib/hive/types";
import { HiveConflictSheet } from "../hive-conflict-sheet";
import { GLASS_BUTTON, GLASS_SURFACE } from "../hive-glass";
import { HiveVisualSlot } from "../hive-visual-slot";
import { useHiveMutation } from "../use-hive-mutation";
import { createHiveViewStore, HiveViewProvider } from "../use-hive-view";
import { SAVE_FAILED_TEXT } from "../use-sheet-submit";
import { AddPartDialog } from "./add-part-dialog";
import { ConfirmDialog } from "./confirm-dialog";
import { EditorStackList } from "./editor-stack-list";
import { EditorToolbar } from "./editor-toolbar";
import { templateFromState } from "./hive-template";
import { PartPalette } from "./part-palette";
import { useHiveDraft } from "./use-hive-draft";
import type { DraftApplyResult, DraftStepInfo } from "./use-hive-draft";
export interface HiveEditorProps {
colonyId: string;
setup: HiveSetupView;
catalog: HiveCatalog;
/** hive-systems:manage: may store the build as the system template. */
canManageTemplates: boolean;
}
const noop = () => {};
const STALE_HINT =
"Die Beute wurde inzwischen geändert. Beim Speichern werden deine Schritte auf den aktuellen Stand angewendet.";
function hasPart(state: HiveState, id: string): boolean {
return state.stack.some((part) => part.id === id || (part.inserts ?? []).some((insert) => insert.id === id));
}
/** True when the key event belongs to a text control or a dialog, where Ctrl+Z means something else. */
function isTypingTarget(target: EventTarget | null): boolean {
return target instanceof Element && target.closest('input, textarea, select, [contenteditable="true"], [role="dialog"]') !== null;
}
/**
* Full-screen „Umbauen“ (spec 9.5): the same holo world as the card, with the
* parts bar and the stack list. Changes pile up in a local draft and are sent
* as one EDITOR change.
*/
export function HiveEditor(props: HiveEditorProps) {
const [store] = useState(createHiveViewStore);
return (
<HiveViewProvider store={store}>
<HiveEditorBody {...props} />
</HiveViewProvider>
);
}
function HiveEditorBody({ colonyId, setup, catalog, canManageTemplates }: HiveEditorProps) {
const router = useRouter();
const queryClient = useQueryClient();
const draft = useHiveDraft(setup.state, catalog);
const mutation = useHiveMutation(colonyId, catalog);
const backHref = `/colonies/${encodeURIComponent(colonyId)}`;
const today = berlinCalendarDay(new Date().toISOString());
const [date, setDate] = useState(today);
const [addType, setAddType] = useState<CatalogPartType | null>(null);
const [addOpen, setAddOpen] = useState(false);
const [pickedId, setPickedId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [announcement, setAnnouncement] = useState({ text: "", nonce: 0 });
const announce = useCallback(
(text: string) => setAnnouncement((current) => ({ text, nonce: current.nonce + 1 })),
[],
);
const [confirm, setConfirm] = useState<"discard" | "leave" | "template" | null>(null);
const hasOps = draft.ops.length > 0;
// A selection ends with its part (removed or undone).
const selectedId = pickedId !== null && hasPart(draft.state, pickedId) ? pickedId : null;
// Forget a vanished selection for good, so a redo does not bring it back.
if (pickedId !== null && selectedId === null) setPickedId(null);
const layout = useMemo(() => hiveLayout(draft.state, catalog), [draft.state, catalog]);
const partName = useCallback((id: string) => partDisplayName(draft.state, catalog, id), [draft.state, catalog]);
/** `inline`: the caller (the add dialog) shows a refusal itself; otherwise it lands in the banner. */
const applyStep = useCallback(
(op: HiveOp | HiveOp[], inline: boolean): DraftApplyResult => {
const ops = Array.isArray(op) ? op : [op];
const before = draft.state;
const outcome = draft.apply(op);
if (outcome.ok) {
setError(null);
announce(`${describeHiveOps(ops, before, catalog)}.`);
} else if (!inline) {
setError(hiveReasonText(outcome.reason));
}
return outcome;
},
[draft, catalog, announce],
);
const apply = useCallback((op: HiveOp | HiveOp[]) => applyStep(op, false), [applyStep]);
const applyInDialog = useCallback((ops: HiveOp[]) => applyStep(ops, true), [applyStep]);
const { undo: undoDraft, redo: redoDraft } = draft;
const describeStep = useCallback(
(prefix: string, step: DraftStepInfo) => `${prefix}: ${describeHiveOps(step.ops, step.before, catalog)}.`,
[catalog],
);
const undo = useCallback(() => {
const step = undoDraft();
if (step) announce(describeStep("Rückgängig", step));
}, [undoDraft, announce, describeStep]);
const redo = useCallback(() => {
const step = redoDraft();
if (step) announce(describeStep("Wiederholt", step));
}, [redoDraft, announce, describeStep]);
// Leaving the page (reload, close, outside link) with open changes asks first.
useEffect(() => {
if (!hasOps) return;
const warn = (event: BeforeUnloadEvent) => event.preventDefault();
window.addEventListener("beforeunload", warn);
return () => window.removeEventListener("beforeunload", warn);
}, [hasOps]);
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (!(event.ctrlKey || event.metaKey) || event.altKey || event.key.toLowerCase() !== "z") return;
if (isTypingTarget(event.target)) return;
event.preventDefault();
if (event.shiftKey) redo();
else undo();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [undo, redo]);
async function save() {
setError(null);
try {
await mutation.submit(draft.ops, "EDITOR", date);
} catch (error) {
// A conflict opens its own sheet (draft stays); anything else is reported here.
if (!hiveConflictOf(error)) setError(SAVE_FAILED_TEXT);
return;
}
draft.reset();
toast.success("Umbau gespeichert");
router.push(backHref);
}
async function resolveConflict(action: "retry" | "discard") {
const conflictVersion = mutation.conflict?.current.version ?? 0;
await mutation.resolveConflict(action);
if (action === "discard") {
draft.reset();
setError(null);
return;
}
const saved = queryClient.getQueryData<HiveSetupView>(hiveKeys.detail(colonyId));
if (action === "retry" && saved && saved.version > conflictVersion) {
draft.reset();
toast.success("Umbau gespeichert");
router.push(backHref);
}
}
const templateMutation = useMutation({
mutationFn: () => updateHiveSystem(setup.systemId, { template: templateFromState(draft.state, catalog) }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: hiveKeys.systems });
toast.success("Vorlage gespeichert");
},
onError: () => toast.error("Vorlage konnte nicht gespeichert werden."),
});
function openAdd(type: CatalogPartType) {
setAddType(type);
setAddOpen(true);
}
function onBackClick(event: MouseEvent<HTMLAnchorElement>) {
const plain = event.button === 0 && !(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey);
if (!hasOps || !plain) return;
event.preventDefault();
setConfirm("leave");
}
return (
<div className="fixed inset-0 z-40 flex flex-col gap-2 bg-holo-bg p-2 text-holo-label-fg">
<EditorToolbar
back={
<Link href={backHref} onClick={onBackClick} className={GLASS_BUTTON}>
<ArrowLeft aria-hidden />
Zurück
</Link>
}
title="Umbauen"
canUndo={draft.canUndo}
canRedo={draft.canRedo}
onUndo={undo}
onRedo={redo}
date={date}
today={today}
onDateChange={setDate}
hasOps={hasOps}
saving={mutation.isPending}
onSave={() => void save()}
onDiscard={() => (hasOps ? setConfirm("discard") : draft.reset())}
onSaveTemplate={canManageTemplates ? () => setConfirm("template") : undefined}
templateSaving={templateMutation.isPending}
/>
<div className="flex min-h-0 flex-1 flex-col gap-2 lg:flex-row">
<div className="relative min-h-[35svh] flex-1 overflow-hidden rounded-2xl">
<HiveVisualSlot
layout={layout}
state={draft.state}
catalog={catalog}
partName={partName}
onSelectPart={setPickedId}
onSelectFrame={noop}
/>
</div>
<aside
aria-label="Teile und Aufbau"
className={`flex max-h-[45svh] w-full flex-col gap-5 overflow-y-auto rounded-2xl p-3 lg:order-first lg:max-h-none lg:w-96 ${GLASS_SURFACE}`}
>
{draft.isBaseStale && !mutation.isPending ? (
<p role="note" className="rounded-lg bg-holo-edge/20 px-3 py-2 text-sm">
{STALE_HINT}
</p>
) : null}
{error ? (
<p role="alert" className="rounded-lg bg-destructive/20 px-3 py-2 text-sm">
{error}
</p>
) : null}
<section aria-labelledby="editor-parts" className="flex flex-col gap-3">
<h2 id="editor-parts" className="text-base font-semibold">
Teile
</h2>
<PartPalette catalog={catalog} onAdd={openAdd} />
</section>
<section aria-labelledby="editor-stack" className="flex flex-col gap-3">
<h2 id="editor-stack" className="text-base font-semibold">
Aufbau
</h2>
<EditorStackList
state={draft.state}
catalog={catalog}
selectedId={selectedId}
onSelect={setPickedId}
onApply={apply}
/>
</section>
</aside>
</div>
<div role="status" aria-live="polite" className="sr-only">
<span key={announcement.nonce}>{announcement.text}</span>
</div>
<AddPartDialog
open={addOpen}
onOpenChange={setAddOpen}
type={addType}
state={draft.state}
catalog={catalog}
onAdd={applyInDialog}
/>
<ConfirmDialog
open={confirm === "discard"}
onOpenChange={(open) => !open && setConfirm(null)}
title="Änderungen verwerfen?"
description="Alle Änderungen an diesem Umbau gehen verloren."
cancelLabel="Weiter umbauen"
confirmLabel="Verwerfen bestätigen"
onConfirm={() => {
draft.reset();
setError(null);
}}
/>
<ConfirmDialog
open={confirm === "leave"}
onOpenChange={(open) => !open && setConfirm(null)}
title="Umbau verlassen?"
description="Nicht gespeicherte Änderungen gehen verloren."
cancelLabel="Weiter umbauen"
confirmLabel="Verlassen"
onConfirm={() => router.push(backHref)}
/>
<ConfirmDialog
open={confirm === "template"}
onOpenChange={(open) => !open && setConfirm(null)}
title="Als Vorlage speichern?"
description="Der Aufbau ersetzt die Vorlage des Beutensystems für neue Völker. Namen und Füllstände werden nicht übernommen."
cancelLabel="Abbrechen"
confirmLabel="Vorlage ersetzen"
onConfirm={() => templateMutation.mutate()}
/>
<HiveConflictSheet conflict={mutation.conflict} catalog={catalog} onResolve={resolveConflict} />
</div>
);
}
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { frames, inlineCatalog, part } from "@/lib/hive/test-utils";
import type { HiveState } from "@/lib/hive/types";
import { templateFromState } from "./hive-template";
describe("templateFromState", () => {
it("strips ids, labels, frame names and levels but keeps the structure and choices", () => {
const state: HiveState = {
schemaVersion: 1,
stack: [
part("f", "floor", { inserts: [part("fi", "floor-insert")] }),
part("b", "box", {
label: "Brut",
frames: frames(2).map((frame) => ({ ...frame, type: "BROOD" as const, name: "Königin" })),
inserts: [part("ff", "frame-feeder", { slot: 1, state: { syrupLiters: 1.5 } })],
}),
part("fe", "feeder", { state: { syrupLiters: 3, fondantKg: 1 } }),
part("l", "inner-lid", { state: { feedHole: "CLOSED" } }),
],
};
const template = templateFromState(state, inlineCatalog());
expect(JSON.stringify(template)).not.toMatch(/"id"|label|Königin|Brut"/);
expect(template.stack[1].frames).toEqual([
{ slot: 1, type: "BROOD" },
{ slot: 2, type: "BROOD" },
]);
expect(template.stack[1].inserts?.[0]).toMatchObject({ partTypeId: "frame-feeder", slot: 1, state: { syrupLiters: 0 } });
expect(template.stack[2].state).toEqual({ syrupLiters: 0, fondantKg: 0 });
expect(template.stack[3].state).toEqual({ feedHole: "CLOSED" });
expect(state.stack[1].label).toBe("Brut");
});
});
@@ -0,0 +1,20 @@
import type { HiveCatalog, HivePart, HiveState, HiveTemplate, HiveTemplatePart } from "@/lib/hive/types";
function templatePart(part: HivePart, catalog: HiveCatalog): HiveTemplatePart {
const type = catalog.partTypes.get(part.partTypeId);
const levelKeys = new Set(type?.stateSchema.filter((field) => field.kind === "level").map((field) => field.key));
// Levels start empty on a new hive; choices (open/closed ...) stay as built.
const state = Object.fromEntries(
Object.entries(part.state).map(([key, value]) => [key, levelKeys.has(key) ? 0 : value]),
);
const copy: HiveTemplatePart = { partTypeId: part.partTypeId, state };
if (part.frames) copy.frames = part.frames.map((frame) => ({ slot: frame.slot, type: frame.type }));
if (part.slot !== undefined) copy.slot = part.slot;
if (part.inserts) copy.inserts = part.inserts.map((insert) => templatePart(insert, catalog));
return copy;
}
/** The editor's state as a system template: without ids, labels, frame names and fill levels. */
export function templateFromState(state: HiveState, catalog: HiveCatalog): HiveTemplate {
return { schemaVersion: 1, stack: state.stack.map((part) => templatePart(part, catalog)) };
}
@@ -0,0 +1,36 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { inlineCatalog } from "@/lib/hive/test-utils";
import { PartPalette } from "./part-palette";
describe("PartPalette", () => {
it("groups the active part types in the four palette groups", () => {
const catalog = inlineCatalog();
catalog.partTypes.set("inner-lid", { ...catalog.partTypes.get("inner-lid")!, active: false });
render(<PartPalette catalog={catalog} onAdd={vi.fn()} />);
const groups = screen.getAllByRole("group").map((group) => group.getAttribute("aria-label"));
expect(groups).toEqual([
"Zargen",
"Gitter und Böden",
"Fütterung und Behandlung",
"Abdeckung und Deckel",
]);
const feeding = screen.getByRole("group", { name: "Fütterung und Behandlung" });
expect(within(feeding).getAllByRole("button")).toHaveLength(3);
expect(screen.queryByRole("button", { name: /inner-lid/i })).not.toBeInTheDocument();
const cover = screen.getByRole("group", { name: "Abdeckung und Deckel" });
expect(within(cover).getAllByRole("button")).toHaveLength(3);
});
it("reports the chosen part type from „Hinzufügen …“", async () => {
const onAdd = vi.fn();
render(<PartPalette catalog={inlineCatalog()} onAdd={onAdd} />);
const zargen = screen.getByRole("group", { name: "Zargen" });
await userEvent.click(within(zargen).getByRole("button", { name: /Hinzufügen …/ }));
expect(onAdd).toHaveBeenCalledWith(expect.objectContaining({ id: "box", kind: "BOX" }));
});
});
@@ -0,0 +1,89 @@
"use client";
import { PART_KIND_LABELS } from "@/lib/hive/labels";
import type { CatalogPartType, HiveCatalog, HivePartKind } from "@/lib/hive/types";
import { GLASS_BUTTON } from "../hive-glass";
interface PaletteGroup {
label: string;
kinds: readonly HivePartKind[];
}
const GROUPS: readonly PaletteGroup[] = [
{ label: "Zargen", kinds: ["BOX"] },
{ label: "Gitter und Böden", kinds: ["FLOOR", "DIVIDER_BOARD", "QUEEN_EXCLUDER", "PROPOLIS_SCREEN", "FLOOR_INSERT"] },
{ label: "Fütterung und Behandlung", kinds: ["FEEDER", "FRAME_FEEDER", "EVAPORATOR"] },
{ label: "Abdeckung und Deckel", kinds: ["COVER_SHEET", "INNER_LID", "INSULATION", "ROOF"] },
];
const SYMBOL_WIDTH = 32;
const SYMBOL_HEIGHT = 22;
const SYMBOL_MAX_LENGTH_MM = 600;
const SYMBOL_MAX_HEIGHT_MM = 260;
/** Small side view of a part in its real proportions (the same sizes hiveLayout uses). */
function PartSymbol({ type }: { type: CatalogPartType }) {
const width = Math.max(8, (type.lengthMm / SYMBOL_MAX_LENGTH_MM) * (SYMBOL_WIDTH - 2));
const height = Math.max(3, (type.heightMm / SYMBOL_MAX_HEIGHT_MM) * (SYMBOL_HEIGHT - 2));
return (
<svg
aria-hidden
viewBox={`0 0 ${SYMBOL_WIDTH} ${SYMBOL_HEIGHT}`}
className="h-6 w-8 shrink-0 text-holo-edge"
>
<rect
x={(SYMBOL_WIDTH - width) / 2}
y={SYMBOL_HEIGHT - 1 - height}
width={width}
height={height}
rx={1.5}
fill="currentColor"
fillOpacity={0.18}
stroke="currentColor"
strokeWidth={1.2}
/>
</svg>
);
}
export interface PartPaletteProps {
catalog: HiveCatalog;
/** „Hinzufügen …“ of one part type: opens the dialog that asks for the position. */
onAdd: (type: CatalogPartType) => void;
}
/** The parts bar of the editor (spec 9.5): active part types in four groups. */
export function PartPalette({ catalog, onAdd }: PartPaletteProps) {
const active = [...catalog.partTypes.values()].filter((type) => type.active);
return (
<div className="flex flex-col gap-4">
{GROUPS.map((group) => {
const types = active
.filter((type) => group.kinds.includes(type.kind))
.sort((a, b) => group.kinds.indexOf(a.kind) - group.kinds.indexOf(b.kind) || a.name.localeCompare(b.name, "de"));
if (types.length === 0) return null;
return (
<div key={group.label} role="group" aria-label={group.label} className="flex flex-col gap-1.5">
<h3 aria-hidden className="text-xs font-medium uppercase tracking-wide text-holo-label-fg/70">
{group.label}
</h3>
<ul className="flex flex-col gap-1.5">
{types.map((type) => (
<li key={type.id} className="flex items-center gap-2">
<PartSymbol type={type} />
<span className="min-w-0 flex-1 text-sm">
<span className="block truncate">{type.name}</span>
<span className="block text-xs text-holo-label-fg/70">{PART_KIND_LABELS[type.kind]}</span>
</span>
<button type="button" onClick={() => onAdd(type)} className={GLASS_BUTTON}>
Hinzufügen … <span className="sr-only">{type.name}</span>
</button>
</li>
))}
</ul>
</div>
);
})}
</div>
);
}
@@ -0,0 +1,144 @@
import { describe, expect, it } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { frames, inlineCatalog, part } from "@/lib/hive/test-utils";
import type { HiveOp, HiveState } from "@/lib/hive/types";
import { useHiveDraft } from "./use-hive-draft";
const catalog = inlineCatalog();
const base: HiveState = {
schemaVersion: 1,
stack: [part("f", "floor"), part("b1", "box", { frames: frames(10) }), part("r", "roof")],
};
const addBox: HiveOp = { op: "addPart", at: 2, part: part("b2", "box", { frames: frames(10) }) };
describe("useHiveDraft", () => {
it("applies an op to the state without touching the base", () => {
const { result } = renderHook(() => useHiveDraft(base, catalog));
let outcome: ReturnType<typeof result.current.apply> | undefined;
act(() => {
outcome = result.current.apply(addBox);
});
expect(outcome).toEqual({ ok: true });
expect(result.current.ops).toEqual([addBox]);
expect(result.current.state.stack.map((p) => p.id)).toEqual(["f", "b1", "b2", "r"]);
expect(base.stack).toHaveLength(3);
});
it("refuses an op that breaks a rule and keeps the draft", () => {
const { result } = renderHook(() => useHiveDraft(base, catalog));
let outcome: ReturnType<typeof result.current.apply> | undefined;
act(() => {
outcome = result.current.apply({ op: "addPart", at: 1, part: part("f2", "floor") });
});
expect(outcome).toMatchObject({ ok: false });
expect(outcome && !outcome.ok && outcome.reason).toMatch(/^RULE_FLOOR_BOTTOM/);
expect(result.current.ops).toEqual([]);
expect(result.current.canUndo).toBe(false);
});
it("undoes and redoes step by step; a new op clears redo", () => {
const { result } = renderHook(() => useHiveDraft(base, catalog));
const label: HiveOp = { op: "setLabel", id: "b1", label: "Brut" };
act(() => void result.current.apply(addBox));
act(() => void result.current.apply(label));
expect(result.current.canUndo).toBe(true);
expect(result.current.canRedo).toBe(false);
act(() => result.current.undo());
expect(result.current.ops).toEqual([addBox]);
expect(result.current.canRedo).toBe(true);
act(() => result.current.undo());
expect(result.current.state).toEqual(base);
act(() => result.current.redo());
expect(result.current.ops).toEqual([addBox]);
act(() => void result.current.apply({ op: "setLabel", id: "b1", label: "Honig" }));
expect(result.current.canRedo).toBe(false);
});
it("treats a batch of ops as one undo step and applies it all or nothing", () => {
const { result } = renderHook(() => useHiveDraft(base, catalog));
const batch: HiveOp[] = [addBox, { op: "setLabel", id: "b2", label: "Neu" }];
act(() => void result.current.apply(batch));
expect(result.current.ops).toHaveLength(2);
act(() => result.current.undo());
expect(result.current.ops).toEqual([]);
let outcome: ReturnType<typeof result.current.apply> | undefined;
act(() => {
outcome = result.current.apply([addBox, { op: "removePart", id: "missing" }]);
});
expect(outcome).toMatchObject({ ok: false });
expect(result.current.ops).toEqual([]);
});
it("reset drops the draft and the history", () => {
const { result } = renderHook(() => useHiveDraft(base, catalog));
act(() => void result.current.apply(addBox));
act(() => result.current.reset());
expect(result.current.ops).toEqual([]);
expect(result.current.canUndo).toBe(false);
expect(result.current.canRedo).toBe(false);
});
it("keeps the base from the first op on and flags a changed base as stale", () => {
const { result, rerender } = renderHook(({ current }) => useHiveDraft(current, catalog), {
initialProps: { current: base },
});
expect(result.current.isBaseStale).toBe(false);
act(() => void result.current.apply(addBox));
const changed: HiveState = { ...base, stack: base.stack.filter((p) => p.id !== "b1") };
rerender({ current: changed });
expect(result.current.isBaseStale).toBe(true);
expect(result.current.state.stack.map((p) => p.id)).toEqual(["f", "b1", "b2", "r"]);
act(() => result.current.reset());
expect(result.current.isBaseStale).toBe(false);
expect(result.current.state).toBe(changed);
});
it("drops the snapshot when undo reaches zero steps and redo starts on the current base", () => {
const { result, rerender } = renderHook(({ current }) => useHiveDraft(current, catalog), {
initialProps: { current: base },
});
act(() => void result.current.apply(addBox));
const changed: HiveState = { ...base, stack: base.stack.map((p) => (p.id === "b1" ? { ...p, label: "neu" } : p)) };
rerender({ current: changed });
expect(result.current.isBaseStale).toBe(true);
act(() => void result.current.undo());
expect(result.current.isBaseStale).toBe(false);
expect(result.current.state).toBe(changed);
expect(result.current.canRedo).toBe(true);
act(() => void result.current.redo());
expect(result.current.isBaseStale).toBe(false);
expect(result.current.state.stack.map((p) => p.id)).toEqual(["f", "b1", "b2", "r"]);
expect(result.current.state.stack[1].label).toBe("neu");
act(() => result.current.undo());
act(() => void result.current.apply({ op: "setLabel", id: "b1", label: "x" }));
expect(result.current.canRedo).toBe(false);
});
it("reports the undone step with the state before it", () => {
const { result } = renderHook(() => useHiveDraft(base, catalog));
act(() => void result.current.apply(addBox));
let info: ReturnType<typeof result.current.undo> = null;
act(() => {
info = result.current.undo();
});
expect(info).toEqual({ ops: [addBox], before: base });
});
});
@@ -0,0 +1,124 @@
"use client";
import { useCallback, useMemo, useRef, useState } from "react";
import { applyHiveOps } from "@/lib/hive/ops";
import type { HiveCatalog, HiveOp, HiveState } from "@/lib/hive/types";
export type DraftApplyResult = { ok: true } | { ok: false; reason: string };
export interface DraftStepInfo {
ops: HiveOp[];
/** The state the step was applied to. */
before: HiveState;
}
export interface HiveDraft {
/** `applyHiveOps(snapshot, ops)`; the base itself until the first op. */
state: HiveState;
/** The server state changed after the draft started (the snapshot differs from the current base). */
isBaseStale: boolean;
/** All collected ops in order, ready to be sent as one change. */
ops: HiveOp[];
/** One undo step: a single op, or a batch that belongs together (all or nothing). */
apply(op: HiveOp | HiveOp[]): DraftApplyResult;
/** Returns the step that was taken back and the state before it (for announcements), or null. */
undo(): DraftStepInfo | null;
redo(): DraftStepInfo | null;
canUndo: boolean;
canRedo: boolean;
reset(): void;
}
interface History {
steps: HiveOp[][];
undone: HiveOp[][];
/** The base as it was when the first op was added; kept until reset. */
snapshot: HiveState | null;
}
const EMPTY: History = { steps: [], undone: [], snapshot: null };
/**
* Local draft of the editor (spec 9.5): ops pile up here and never touch the
* server until the caller sends `ops` in one change. Every step is checked
* against the rules before it is taken, so the draft is always valid.
*/
export function useHiveDraft(base: HiveState, catalog: HiveCatalog): HiveDraft {
const [history, setHistory] = useState<History>(EMPTY);
// Latest history for callers that apply twice before React re-renders.
const latest = useRef(history);
const commit = useCallback((next: History) => {
latest.current = next;
setHistory(next);
}, []);
const ops = useMemo(() => history.steps.flat(), [history.steps]);
// A refetch or an optimistic write must not change what the editor shows.
const baseline = history.snapshot ?? base;
const state = useMemo(() => {
if (ops.length === 0) return baseline;
const applied = applyHiveOps(baseline, ops, catalog);
return applied.ok ? applied.state : baseline;
}, [baseline, catalog, ops]);
const isBaseStale = history.snapshot !== null && JSON.stringify(history.snapshot) !== JSON.stringify(base);
const apply = useCallback(
(op: HiveOp | HiveOp[]): DraftApplyResult => {
const step = Array.isArray(op) ? op : [op];
const { steps, snapshot } = latest.current;
const start = snapshot ?? base;
const result = applyHiveOps(start, [...steps.flat(), ...step], catalog);
if (!result.ok) return { ok: false, reason: result.reason };
commit({ steps: [...steps, step], undone: [], snapshot: start });
return { ok: true };
},
[base, catalog, commit],
);
/** State after the given steps, applied to the snapshot. */
const stateAfter = useCallback(
(steps: HiveOp[][], snapshot: HiveState): HiveState => {
const applied = applyHiveOps(snapshot, steps.flat(), catalog);
return applied.ok ? applied.state : snapshot;
},
[catalog],
);
const undo = useCallback((): DraftStepInfo | null => {
const { steps, undone, snapshot } = latest.current;
if (steps.length === 0 || !snapshot) return null;
const step = steps[steps.length - 1];
const remaining = steps.slice(0, -1);
// Back at zero steps the draft is empty again: the next first op takes a fresh snapshot.
commit({ steps: remaining, undone: [...undone, step], snapshot: remaining.length === 0 ? null : snapshot });
return { ops: step, before: stateAfter(remaining, snapshot) };
}, [commit, stateAfter]);
const redo = useCallback((): DraftStepInfo | null => {
const { steps, undone, snapshot } = latest.current;
if (undone.length === 0) return null;
const step = undone[undone.length - 1];
// From zero steps the redo starts on the current base; it may no longer fit.
const start = snapshot ?? base;
if (!applyHiveOps(start, [...steps.flat(), ...step], catalog).ok) {
commit({ steps, undone: [], snapshot });
return null;
}
commit({ steps: [...steps, step], undone: undone.slice(0, -1), snapshot: start });
return { ops: step, before: stateAfter(steps, start) };
}, [base, catalog, commit, stateAfter]);
const reset = useCallback(() => commit(EMPTY), [commit]);
return {
state,
isBaseStale,
ops,
apply,
undo,
redo,
canUndo: history.steps.length > 0,
canRedo: history.undone.length > 0,
reset,
};
}
@@ -97,6 +97,19 @@ describe("FrameSheet", () => {
]);
});
it("does not let its submit bubble into an enclosing form", async () => {
const outer = vi.fn((event: { preventDefault: () => void }) => event.preventDefault());
const user = userEvent.setup();
render(
<form onSubmit={outer}>
<Harness submit={vi.fn().mockResolvedValue(undefined)} />
</form>,
);
await user.click(screen.getByRole("radio", { name: FRAME_TYPE_LABELS.HONEY }));
await user.click(save());
expect(outer).not.toHaveBeenCalled();
});
it("sends name null when the name is emptied", async () => {
const submit = vi.fn().mockResolvedValue(undefined);
const user = userEvent.setup();
@@ -56,7 +56,9 @@ function FrameForm({ initialType, initialName, boxId, slot, submit, onClose }: F
<form
className="flex flex-col gap-4 px-4 pb-4"
onSubmit={(event) => {
// Portal forms bubble their submit through the React tree (e.g. into an enclosing form).
event.preventDefault();
event.stopPropagation();
if (changed && !pending) void save();
}}
>
@@ -19,20 +19,32 @@ import { colonyFixture, historyItem, sampleState, setupView, systemDto } from ".
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(),
vi.mock("@/lib/hive/api", async (importOriginal) => {
const fetchHiveSystems = vi.fn();
return {
...(await importOriginal<typeof import("@/lib/hive/api")>()),
fetchHive: vi.fn(),
fetchHiveSystems,
// The offline-aware variant answers like the plain one here (no offline packs in this spec).
fetchHiveSystemsWithFallback: vi.fn(() => fetchHiveSystems()),
fetchHiveTemplate: vi.fn(),
fetchHiveHistory: vi.fn(),
fetchHiveAt: vi.fn(),
postHiveChange: vi.fn(),
createHive: vi.fn(),
};
});
const pendingHive = vi.hoisted(() => ({ ops: [] as unknown[], count: 0 }));
vi.mock("@/components/hive/use-pending-hive", async (importOriginal) => ({
...(await importOriginal<typeof import("@/components/hive/use-pending-hive")>()),
usePendingHiveOps: () => pendingHive.ops,
usePendingHiveCount: () => pendingHive.count,
}));
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) }),
useCurrentUserId: () => null,
}));
const connectivity = vi.hoisted(() => ({ value: "online" as "online" | "offline" }));
vi.mock("@/lib/offline/connectivity", async (importOriginal) => ({
@@ -64,6 +76,8 @@ describe("HiveCard", () => {
beforeEach(() => {
permissions = ["colonies:read", "colonies:update"];
connectivity.value = "online";
pendingHive.ops = [];
pendingHive.count = 0;
vi.mocked(fetchHive).mockReset().mockResolvedValue(setupView());
vi.mocked(fetchHiveSystems).mockReset().mockResolvedValue([systemDto()]);
vi.mocked(fetchHiveTemplate).mockReset().mockResolvedValue(templateState());
@@ -152,6 +166,14 @@ describe("HiveCard", () => {
});
});
it("shows changes that wait for network on top of the loaded hive, with a note", async () => {
pendingHive.ops = FRAME_EDIT_OPS;
pendingHive.count = 1;
renderCard();
expect(await screen.findByText(/7 Brutwaben/)).toBeInTheDocument();
expect(screen.getByText("1 Änderung wartet auf Netz")).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" });
@@ -239,6 +261,32 @@ describe("HiveCard", () => {
expect(screen.queryByRole("link", { name: "Umbauen" })).not.toBeInTheDocument();
});
it("seeds the inspection draft from initialOps (after a remount)", async () => {
const onOpsChange = vi.fn();
renderCard({ mode: "inspection", onOpsChange, initialOps: FRAME_EDIT_OPS });
await screen.findByRole("img", { name: /Beute mit/ });
expect(await screen.findByText(/7 Brutwaben/)).toBeInTheDocument();
// The outline shows the seeded draft, and seeding is not reported as a new edit.
await userEvent.click(screen.getByRole("treeitem", { name: /Zarge 1/ }));
expect(screen.getByRole("treeitem", { name: /R5 · Brutwabe/ })).toBeInTheDocument();
expect(onOpsChange).not.toHaveBeenCalled();
});
it("ignores initialOps that do not apply to the loaded hive, and in view mode", async () => {
const broken: HiveOp[] = [{ op: "setFrame", boxId: "gone", slot: 5, type: "BROOD", name: null }];
const onOpsChange = vi.fn();
const first = renderCard({ mode: "inspection", initialOps: broken, onOpsChange });
await screen.findByRole("img", { name: /Beute mit/ });
expect(screen.getByText("2 Zargen · 6 Brutwaben · Futter 5 l · Bausperre drin")).toBeInTheDocument();
// The form learns that its stored ops were dropped, so its list matches the card.
expect(onOpsChange).toHaveBeenCalledWith([]);
first.unmount();
renderCard({ initialOps: FRAME_EDIT_OPS });
await screen.findByRole("img", { name: /Beute mit/ });
expect(screen.getByText("2 Zargen · 6 Brutwaben · Futter 5 l · Bausperre drin")).toBeInTheDocument();
});
it("opens the conflict sheet when the hive changed meanwhile", async () => {
vi.mocked(postHiveChange).mockRejectedValue(
new ApiError(409, "Konflikt", {
@@ -1,22 +1,22 @@
"use client";
import { useId, useMemo, useRef, useState } from "react";
import type { ReactNode } from "react";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import type { ReactNode, RefObject } from "react";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { Boxes, History, ListTree } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, 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 { hiveErrorCode } 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 { withPendingOps } from "@/lib/hive/pending";
import type { HiveCatalog, HiveOp, HiveSetupView, HiveState } from "@/lib/hive/types";
import { useConnectivity } from "@/lib/offline/connectivity";
import { FrameSheet } from "./frame-sheet";
@@ -27,17 +27,22 @@ import { GLASS_BUTTON, GLASS_SURFACE } from "./hive-glass";
import { HiveHistory, HiveVersionBar, useHiveHistory } from "./hive-history";
import { HiveInit } from "./hive-init";
import { HiveOutline } from "./hive-outline";
import { HivePendingNote } from "./hive-pending-note";
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 { useHiveSetup } from "./use-hive-setup";
import { usePendingHiveOps } from "./use-pending-hive";
import { useHiveSystems, useSystemCatalog } from "./use-hive-systems";
import { createHiveViewStore, focusedIdOf, 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.";
/** The pending note inside the frosted panels (light text on the stage). */
const PENDING_NOTE_CLASS = "mt-1 flex items-center gap-1.5 text-xs text-holo-label-fg/90";
const OFFLINE_REBUILD_HINT = "Umbauen geht nur mit Netz";
export interface HiveCardProps {
@@ -46,6 +51,12 @@ export interface HiveCardProps {
/** "inspection" keeps a local draft and reports the ops instead of saving (inspection form). */
mode?: "view" | "inspection";
onOpsChange?: (ops: HiveOp[]) => void;
/**
* Inspection mode only: ops of an earlier draft to start from (the inspection
* form remounts the card after „Rückgängig“). Ignored unless they apply to the
* loaded hive; ignored in view mode.
*/
initialOps?: HiveOp[];
}
/**
@@ -75,11 +86,17 @@ function PlainCardBody({ children }: { children: ReactNode }) {
);
}
function HiveCardContent({ colonyId, colony, mode = "view", onOpsChange }: HiveCardProps) {
function HiveCardContent({ colonyId, colony, mode = "view", onOpsChange, initialOps }: HiveCardProps) {
const { hasPermission } = useAuth();
const hive = useQuery({ queryKey: hiveKeys.detail(colonyId), queryFn: () => fetchHive(colonyId) });
// Offline the colony's pack answers; changes still waiting for network are shown on top (spec 10).
const hive = useHiveSetup(colonyId);
const systems = useHiveSystems();
const catalog = useSystemCatalog(systems.data, hive.data?.systemId);
const pendingKey = JSON.stringify(usePendingHiveOps(colonyId));
const setup = useMemo(
() => (hive.data && catalog ? withPendingOps(hive.data, JSON.parse(pendingKey) as HiveOp[], catalog) : undefined),
[hive.data, catalog, pendingKey],
);
const canEdit = hasPermission("colonies:update") && isLivingColony(colony);
if (hiveErrorCode(hive.error) === "HIVE_NOT_FOUND") {
@@ -109,7 +126,7 @@ function HiveCardContent({ colonyId, colony, mode = "view", onOpsChange }: HiveC
</PlainCardBody>
);
}
if (!hive.data || !catalog) {
if (!setup || !catalog) {
return (
<div
role="status"
@@ -121,15 +138,43 @@ function HiveCardContent({ colonyId, colony, mode = "view", onOpsChange }: HiveC
return (
<HiveCardBody
colonyId={colonyId}
setup={hive.data}
setup={setup}
catalog={catalog}
canEdit={canEdit}
mode={mode}
onOpsChange={onOpsChange}
initialOps={initialOps}
/>
);
}
/** Top: title panel or focus bar (placed 12 px from the top). Bottom: the button column (8 px padding). */
const TOP_OVERLAY_OFFSET_PX = 12;
const BOTTOM_OVERLAY_OFFSET_PX = 8;
/** Heights of the overlays over the stage, so the 3D camera keeps the model clear of them. */
function useOverlayInsets(top: RefObject<HTMLElement | null>, bottom: RefObject<HTMLElement | null>) {
const [insets, setInsets] = useState({ top: 0, bottom: 0 });
useEffect(() => {
const topElement = top.current;
const bottomElement = bottom.current;
if (!topElement || !bottomElement || typeof ResizeObserver === "undefined") return;
const measure = () => {
const next = {
top: Math.round(topElement.offsetHeight + TOP_OVERLAY_OFFSET_PX),
bottom: Math.round(bottomElement.offsetHeight + BOTTOM_OVERLAY_OFFSET_PX),
};
setInsets((current) => (current.top === next.top && current.bottom === next.bottom ? current : next));
};
const observer = new ResizeObserver(measure);
observer.observe(topElement);
observer.observe(bottomElement);
measure();
return () => observer.disconnect();
}, [top, bottom]);
return insets;
}
interface FrameTarget {
boxId: string;
slot: number;
@@ -161,9 +206,16 @@ interface HiveCardBodyProps {
canEdit: boolean;
mode: "view" | "inspection";
onOpsChange?: (ops: HiveOp[]) => void;
initialOps?: HiveOp[];
}
function HiveCardBody({ colonyId, setup, catalog, canEdit, mode, onOpsChange }: HiveCardBodyProps) {
/** The seed of the inspection draft: the given ops when they apply to the loaded hive, else none. */
function initialDraft(mode: "view" | "inspection", setup: HiveSetupView, catalog: HiveCatalog, ops?: HiveOp[]): HiveOp[] {
if (mode !== "inspection" || !ops || ops.length === 0) return [];
return applyHiveOps(setup.state, ops, catalog).ok ? ops : [];
}
function HiveCardBody({ colonyId, setup, catalog, canEdit, mode, onOpsChange, initialOps }: HiveCardBodyProps) {
const view = useHiveView();
const connectivity = useConnectivity();
const mutation = useHiveMutation(colonyId, catalog);
@@ -171,8 +223,18 @@ function HiveCardBody({ colonyId, setup, catalog, canEdit, mode, onOpsChange }:
const outlineRef = useRef<HiveOutlineHandle>(null);
const historyButtonRef = useRef<HTMLButtonElement>(null);
const outlineId = useId();
const topOverlayRef = useRef<HTMLDivElement>(null);
const bottomOverlayRef = useRef<HTMLDivElement>(null);
const insets = useOverlayInsets(topOverlayRef, bottomOverlayRef);
const [draftOps, setDraftOps] = useState<HiveOp[]>([]);
const [draftOps, setDraftOps] = useState<HiveOp[]>(() => initialDraft(mode, setup, catalog, initialOps));
// Seed ops that no longer apply were dropped: tell the form once, so its list matches the card.
const [seedDropped] = useState(() => mode === "inspection" && (initialOps?.length ?? 0) > 0 && draftOps.length === 0);
useEffect(() => {
if (seedDropped) onOpsChange?.([]);
// Once on mount: the form remounts the card when it changes the seed.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [seedDropped]);
const [past, setPast] = useState<PastState | null>(null);
const [showOutline, setShowOutline] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
@@ -256,29 +318,31 @@ function HiveCardBody({ colonyId, setup, catalog, canEdit, mode, onOpsChange }:
partName={partName}
onSelectPart={selectPart}
onSelectFrame={editFrame}
insets={insets}
/>
</div>
{focusedId ? (
<HiveFocusBar
focusedId={focusedId}
name={partName(focusedId)}
chips={focusChips(shownState, catalog, focusedId)}
canEdit={editable && hasSheet(focusedId)}
onEdit={() => editPart(focusedId)}
onBack={leaveFocus}
/>
) : (
<div
className={`pointer-events-none absolute start-3 top-3 max-w-[min(26rem,calc(100%-1.5rem))] rounded-2xl px-4 py-3 ${GLASS_SURFACE}`}
>
<CardTitle className="text-xl font-semibold tracking-tight">Beute</CardTitle>
<HiveFactsLine facts={facts} lastChange={past ? null : lastChange} tone="stage" />
</div>
)}
<div ref={topOverlayRef} className="pointer-events-none absolute inset-x-3 top-3">
{focusedId ? (
<HiveFocusBar
focusedId={focusedId}
name={partName(focusedId)}
chips={focusChips(shownState, catalog, focusedId)}
canEdit={editable && hasSheet(focusedId)}
onEdit={() => editPart(focusedId)}
onBack={leaveFocus}
/>
) : (
<div className={`w-fit max-w-[min(26rem,100%)] rounded-2xl px-4 py-2.5 sm:py-3 ${GLASS_SURFACE}`}>
<CardTitle className="text-lg font-semibold tracking-tight sm:text-xl">Beute</CardTitle>
<HiveFactsLine facts={facts} lastChange={past ? null : lastChange} tone="stage" />
<HivePendingNote colonyId={colonyId} className={PENDING_NOTE_CLASS} />
</div>
)}
</div>
{/* One column for the outline panel and the button bar: the panel always ends above the
buttons, however they wrap. DOM order keeps the buttons before the tree (tab order). */}
<div className="pointer-events-none absolute inset-0 flex flex-col gap-2 p-2">
<div className="order-2 flex flex-col items-center gap-2 p-1">
<div ref={bottomOverlayRef} className="order-2 flex flex-col items-center gap-2 p-1">
{past ? (
<div className="pointer-events-auto">
<HiveVersionBar
@@ -295,6 +359,7 @@ function HiveCardBody({ colonyId, setup, catalog, canEdit, mode, onOpsChange }:
// Phones keep the room for the part; the focus chips carry its state there.
<div className={`hidden max-w-full rounded-xl px-3 py-1.5 text-center sm:block ${GLASS_SURFACE}`}>
<HiveFactsLine facts={facts} lastChange={past ? null : lastChange} tone="stage" />
<HivePendingNote colonyId={colonyId} className={`justify-center ${PENDING_NOTE_CLASS}`} />
</div>
) : null}
<div className="pointer-events-auto flex max-w-full flex-wrap justify-center gap-1.5 sm:gap-2">
@@ -79,7 +79,7 @@ export function HiveFocusBar({ focusedId, name, chips, canEdit, onEdit, onBack }
return (
<div
key={focusedId}
className="pointer-events-none absolute inset-x-3 top-3 flex flex-col items-start gap-2 duration-150 animate-in fade-in-0 motion-full:duration-300 motion-full:slide-in-from-top-2"
className="pointer-events-none flex flex-col items-start gap-2 duration-150 animate-in fade-in-0 motion-full:duration-300 motion-full:slide-in-from-top-2"
>
<div className="flex max-w-full items-center gap-3">
<button type="button" onClick={onBack} className={`pointer-events-auto ${GLASS_PILL}`}>
@@ -0,0 +1,78 @@
import "fake-indexeddb/auto";
import { IDBFactory } from "fake-indexeddb";
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";
vi.mock("@/lib/auth/auth-context", () => ({ useCurrentUserId: () => "user-1" }));
type DbModule = typeof import("@/lib/offline/db");
let db: DbModule;
let HivePendingNote: typeof import("./hive-pending-note").HivePendingNote;
function renderNote() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<HivePendingNote colonyId="colony-1" />
</QueryClientProvider>,
);
}
const change = (colonyId: string) => ({
colonyId,
occurredOn: "2026-10-15",
source: "CARD" as const,
ops: [{ op: "setLabel" as const, id: "b", label: "x" }],
});
describe("HivePendingNote", () => {
beforeEach(async () => {
globalThis.indexedDB = new IDBFactory();
vi.resetModules();
db = await import("@/lib/offline/db");
HivePendingNote = (await import("./hive-pending-note")).HivePendingNote;
});
it("says nothing while no change waits", async () => {
await db.queueHiveChange(change("colony-2"), "k0", "user-1");
renderNote();
await new Promise((resolve) => setTimeout(resolve, 20));
expect(screen.queryByRole("status")).not.toBeInTheDocument();
});
it("names one waiting change", async () => {
await db.queueHiveChange(change("colony-1"), "k1", "user-1");
renderNote();
expect(await screen.findByText("1 Änderung wartet auf Netz")).toBeInTheDocument();
});
it("counts a queued inspection with a hive change too", async () => {
await db.queueHiveChange(change("colony-1"), "k1", "user-1");
await db.queueInspection(
{ colonyId: "colony-1", date: "2026-10-15T00:00:00.000Z", hiveChange: { ops: change("x").ops } },
"k2",
"user-1",
);
renderNote();
expect(await screen.findByText("2 Änderungen warten auf Netz")).toBeInTheDocument();
});
it("points to the queue when a change was refused, and opens the queue sheet", async () => {
await db.queueHiveChange(change("colony-1"), "k1", "user-1");
const [entry] = await db.getPendingEntries("pending-hive-changes");
await db.markFailed("pending-hive-changes", entry.id, {
status: 409,
message: "Konflikt",
details: { code: "HIVE_CONFLICT", reason: "MISSING_ID: x" },
at: "2026-10-15T10:00:00.000Z",
});
renderNote();
expect(await screen.findByText(/Eine Änderung an dieser Beute wurde abgelehnt/)).toBeInTheDocument();
expect(screen.queryByText(/wartet auf Netz/)).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Warteschlange öffnen" }));
expect(await screen.findByRole("dialog", { name: "Wartende Einträge" })).toBeInTheDocument();
});
});
@@ -0,0 +1,54 @@
"use client";
import { useState } from "react";
import { AlertTriangle, CloudOff } from "lucide-react";
import { PendingQueueSheet } from "@/components/offline/pending-queue-sheet";
import { Button } from "@/components/ui/button";
import { useCurrentUserId } from "@/lib/auth/auth-context";
import { useHasRejectedHiveChange, usePendingHiveCount } from "./use-pending-hive";
export function pendingHiveText(count: number): string {
return count === 1 ? "1 Änderung wartet auf Netz" : `${count} Änderungen warten auf Netz`;
}
export const REJECTED_HIVE_TEXT =
"Eine Änderung an dieser Beute wurde abgelehnt. Neue Änderungen warten, bis du sie in der Warteschlange klärst.";
/**
* „1 Änderung wartet auf Netz“; with a refused change in the queue instead the
* hint to resolve it there, with a button that opens the queue sheet. Nothing
* while no change of the colony's hive waits.
*/
export function HivePendingNote({ colonyId, className }: { colonyId: string; className?: string }) {
const count = usePendingHiveCount(colonyId);
const rejected = useHasRejectedHiveChange(colonyId);
const userId = useCurrentUserId();
const [sheetOpen, setSheetOpen] = useState(false);
if (rejected && userId) {
return (
<div role="status" className={className ?? "flex flex-col items-start gap-1.5 text-xs"}>
<p className="flex items-start gap-1.5">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" aria-hidden />
{REJECTED_HIVE_TEXT}
</p>
<Button type="button" variant="outline" size="touch" onClick={() => setSheetOpen(true)}>
Warteschlange öffnen
</Button>
<PendingQueueSheet
userId={userId}
foreignCount={0}
open={sheetOpen}
onOpenChange={setSheetOpen}
trigger={null}
/>
</div>
);
}
if (count === 0) return null;
return (
<p role="status" className={className ?? "flex items-center gap-1.5 text-xs"}>
<CloudOff className="size-3.5 shrink-0" aria-hidden />
{pendingHiveText(count)}
</p>
);
}
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { cameraBasis, cameraPosition, fitDistance, focusFrame, overlayMargin } from "./camera-fit";
import { cameraBasis, cameraPosition, fitDistance, focusFrame, insetView } from "./camera-fit";
describe("cameraBasis", () => {
it("looks from 20° above and 30° to the side, with orthonormal axes", () => {
@@ -35,12 +35,26 @@ describe("fitDistance", () => {
});
});
describe("overlayMargin", () => {
it("leaves more room on a short card than on a tall one, capped at 2", () => {
expect(overlayMargin(1000, 150)).toBeCloseTo(1000 / 850, 9);
expect(overlayMargin(460, 150)).toBeCloseTo(460 / 310, 9);
expect(overlayMargin(200, 150)).toBe(2);
expect(overlayMargin(0)).toBe(1);
describe("insetView", () => {
it("fits into the band between the overlays and centres the model in it", () => {
const view = insetView(460, { top: 96, bottom: 56 });
// 12 px gap next to each overlay.
expect(view.fraction).toBeCloseTo((460 - 108 - 68) / 460, 9);
// More covered at the top: the view moves the model down (negative offset).
expect(view.offsetY).toBeCloseTo(-(108 - 68) / 2, 9);
});
it("is the whole card without overlays and never less than 40 %", () => {
expect(insetView(800, { top: 0, bottom: 0 })).toEqual({ fraction: 1, offsetY: -0 });
expect(insetView(200, { top: 120, bottom: 60 }).fraction).toBeCloseTo(0.4, 9);
expect(insetView(0, { top: 10, bottom: 10 })).toEqual({ fraction: 1, offsetY: 0 });
});
});
describe("fitDistance with a vertical fraction", () => {
it("moves the camera back when only part of the height is free", () => {
const size: [number, number, number] = [5.25, 9, 4.3];
expect(fitDistance(size, 0.8, 1, undefined, undefined, 0.6)).toBeGreaterThan(fitDistance(size, 0.8, 1));
});
});
@@ -7,11 +7,11 @@ export const CAMERA_ELEVATION_DEG = 20;
export const CAMERA_AZIMUTH_DEG = 30;
export const CAMERA_FOV_DEG = 30;
/** Space around the model, as a factor on its projected size. */
export const FIT_MARGIN = 1.05;
export const FIT_MARGIN = 1.08;
/** Focus mode: the frame is pulled toward the ghost hive (focusFrame), so less extra room. */
export const FOCUS_FIT_MARGIN = 0.95;
/** Card pixels taken by the frosted overlays (title or focus bar on top, buttons below). */
export const OVERLAY_RESERVE_PX = 150;
export const FOCUS_FIT_MARGIN = 1.0;
/** Breathing room between the model and the overlays, in pixels. */
export const OVERLAY_GAP_PX = 12;
/** Share of the way from the focused part toward the ghost hive the view centre moves (x, y, z). */
export const FOCUS_PULL: Vec3 = [0.3, 0.42, 0.3];
@@ -45,13 +45,34 @@ export function cameraBasis(
return { back, right, up };
}
export interface OverlayInsets {
/** Pixels covered at the top (title panel or focus bar). */
top: number;
/** Pixels covered at the bottom (button bar). */
bottom: number;
}
export interface InsetView {
/** Share of the card height left free for the model (0.4 … 1). */
fraction: number;
/** Vertical view offset in pixels that centres the model in the free band (camera.setViewOffset). */
offsetY: number;
}
/**
* Extra margin so the model fits the part of the card the overlays leave free:
* about 1.2 on a tall desktop card, about 1.5 on a phone. Never more than 2.
* The band of the card the frosted overlays leave free: the camera fits the
* model into it and shifts the view so the model sits in its middle (so the
* facts panel never covers the roof on a phone).
*/
export function overlayMargin(heightPx: number, reservePx: number = OVERLAY_RESERVE_PX): number {
if (!(heightPx > 0)) return 1;
return heightPx / Math.max(heightPx - reservePx, heightPx / 2);
export function insetView(heightPx: number, insets: OverlayInsets): InsetView {
if (!(heightPx > 0)) return { fraction: 1, offsetY: 0 };
const top = Math.max(0, insets.top) + (insets.top > 0 ? OVERLAY_GAP_PX : 0);
const bottom = Math.max(0, insets.bottom) + (insets.bottom > 0 ? OVERLAY_GAP_PX : 0);
const free = Math.max(heightPx - top - bottom, heightPx * 0.4);
const scale = free / Math.max(heightPx - top - bottom, 1e-6);
// When the overlays leave less than 40 %, shrink both proportionally.
const shift = scale > 1 ? (top - bottom) / (2 * scale) : (top - bottom) / 2;
return { fraction: free / heightPx, offsetY: -shift };
}
/** Half extent of an axis-aligned box of `size` projected on a unit axis. */
@@ -66,13 +87,15 @@ export function fitDistance(
margin: number = FIT_MARGIN,
fovDeg: number = CAMERA_FOV_DEG,
basis: CameraBasis = cameraBasis(),
verticalFraction = 1,
): number {
const tanHalf = Math.tan(toRad(fovDeg) / 2);
const safeAspect = aspect > 0 && Number.isFinite(aspect) ? aspect : 1;
const halfWidth = halfExtent(size, basis.right);
const halfHeight = halfExtent(size, basis.up);
const halfDepth = halfExtent(size, basis.back);
const distance = Math.max(halfHeight / tanHalf, halfWidth / (tanHalf * safeAspect)) * margin;
const fraction = verticalFraction > 0 ? verticalFraction : 1;
const distance = Math.max(halfHeight / (tanHalf * fraction), halfWidth / (tanHalf * safeAspect)) * margin;
// The near face of the box sits half its depth closer to the camera.
return Math.max(distance + halfDepth, 1);
}
@@ -3,9 +3,10 @@
import { useEffect, useMemo, useRef } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import type { HiveLayout, Vec3 } from "@/lib/hive/layout";
import { FIT_MARGIN, FOCUS_FIT_MARGIN, fitDistance, focusFrame, overlayMargin } from "./camera-fit";
import { FIT_MARGIN, FOCUS_FIT_MARGIN, fitDistance, focusFrame, insetView } from "./camera-fit";
import type { OverlayInsets } from "./camera-fit";
import { useSceneKit } from "./scene-kit";
import { placeCamera } from "./scene-mutations";
import { offsetView, placeCamera, setFocusPoint } from "./scene-mutations";
import { MAX_FRAME_SECONDS } from "./use-animated-node";
import { restBounds, stepToward } from "./use-hive-motion";
import type { FocusInfo } from "./use-hive-motion";
@@ -19,10 +20,12 @@ export interface CameraRigProps {
focus: FocusInfo | null;
/** Receives the current look-at point every frame; the model rig turns around it. */
onPivot: (center: Vec3) => void;
/** Card pixels covered by the frosted overlays. */
insets: OverlayInsets;
}
export function CameraRig({ layout, focus, onPivot }: CameraRigProps) {
const { motion } = useSceneKit();
export function CameraRig({ layout, focus, onPivot, insets }: CameraRigProps) {
const { motion, signals } = useSceneKit();
const camera = useThree((state) => state.camera);
const width = useThree((state) => state.size.width);
const height = useThree((state) => state.size.height);
@@ -30,14 +33,15 @@ export function CameraRig({ layout, focus, onPivot }: CameraRigProps) {
const goal = useMemo(() => {
const aspect = height > 0 ? width / height : 1;
const room = overlayMargin(height);
const band = insetView(height, insets);
const bounds = restBounds(layout);
const fit = (size: Vec3, margin: number) => fitDistance(size, aspect, margin, undefined, undefined, band.fraction);
if (focus) {
const frame = focusFrame(focus.center, focus.size, bounds.center);
return { center: frame.center, distance: fitDistance(frame.size, aspect, FOCUS_FIT_MARGIN * room) };
return { center: frame.center, distance: fit(frame.size, FOCUS_FIT_MARGIN), offsetY: band.offsetY };
}
return { center: bounds.center, distance: fitDistance(bounds.size, aspect, FIT_MARGIN * room) };
}, [focus, layout, width, height]);
return { center: bounds.center, distance: fit(bounds.size, FIT_MARGIN), offsetY: band.offsetY };
}, [focus, layout, width, height, insets]);
const current = useRef<{ center: Vec3; distance: number } | null>(null);
useEffect(() => {
@@ -60,7 +64,9 @@ export function CameraRig({ layout, focus, onPivot }: CameraRigProps) {
}
const { center, distance } = current.current;
onPivot(center);
setFocusPoint(signals, focus ? focus.center : null);
placeCamera(camera, center, distance);
offsetView(camera, width, height, goal.offsetY);
const settled =
distance === goal.distance && center.every((value, axis) => value === goal.center[axis]);
if (!settled) invalidate();
@@ -2,10 +2,11 @@
import { useEffect, useMemo, useRef } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import { BoxGeometry, CylinderGeometry, MeshStandardMaterial } from "three";
import type { Mesh } from "three";
import { BoxGeometry, CanvasTexture, CylinderGeometry, MeshStandardMaterial, PlaneGeometry, RepeatWrapping, SRGBColorSpace } from "three";
import { RoundedBoxGeometry } from "three/addons/geometries/RoundedBoxGeometry.js";
import type { Color, Mesh } from "three";
import type { Vec3 } from "@/lib/hive/layout";
import { createLiquidMaterial } from "./materials";
import { createCausticMaterial, createLiquidMaterial } from "./materials";
import { MAX_FRAME_SECONDS } from "./use-animated-node";
import { stepToward } from "./use-hive-motion";
import { noRaycast, useSceneKit } from "./scene-kit";
@@ -19,6 +20,8 @@ const WOBBLE = 0.02;
export type FillKind = "honey" | "acid" | "fondant";
type FillColor = Color;
export interface FillVolumeProps {
/** Centre and size of the full space (local to the part). */
center: Vec3;
@@ -35,31 +38,35 @@ export function FillVolume({ center, size, ratio, kind, shape = "box" }: FillVol
const mesh = useRef<Mesh>(null);
const shown = useRef(ratio);
const geometry = useMemo(
() => (shape === "cylinder" ? new CylinderGeometry(0.5, 0.5, 1, 40, 1) : new BoxGeometry(1, 1, 1, 24, 1, 12)),
[shape],
);
const geometry = useMemo(() => {
if (kind === "fondant") return new RoundedBoxGeometry(1, 1, 1, 3, 0.1);
return shape === "cylinder" ? new CylinderGeometry(0.5, 0.5, 1, 40, 1) : new BoxGeometry(1, 1, 1, 24, 1, 12);
}, [shape, kind]);
const surface = useMemo(() => (kind === "fondant" ? fondantTexture() : null), [kind]);
const material = useMemo(() => {
if (kind === "fondant") {
// Feed paste: a soft sugary block with a fine grain (map and relief from one noise texture).
return new MeshStandardMaterial({
color: theme.fondant,
roughness: 0.55,
map: surface,
bumpMap: surface,
bumpScale: 2.5,
roughness: 0.62,
emissive: theme.fondant,
emissiveIntensity: 0.18,
transparent: true,
opacity: 0.94,
emissiveIntensity: 0.16,
});
}
return kind === "acid"
? createLiquidMaterial({ color: theme.acid, deep: theme.rim, glow: 1.05, alpha: 0.55 })
: createLiquidMaterial({ color: theme.honey, deep: theme.honeyDeep, glow: 1.2, alpha: 0.78 });
}, [kind, theme]);
}, [kind, theme, surface]);
useEffect(
() => () => {
geometry.dispose();
material.dispose();
surface?.dispose();
},
[geometry, material],
[geometry, material, surface],
);
useEffect(() => {
invalidate();
@@ -79,5 +86,72 @@ export function FillVolume({ center, size, ratio, kind, shape = "box" }: FillVol
if (shown.current !== ratio) invalidate();
});
return <mesh ref={mesh} geometry={geometry} material={material} raycast={noRaycast} renderOrder={3} />;
return (
<>
<mesh ref={mesh} geometry={geometry} material={material} raycast={noRaycast} renderOrder={3} />
{kind !== "fondant" && shape === "box" && ratio > 0 ? (
<Caustics center={center} size={size} color={kind === "acid" ? theme.acid : theme.honey} />
) : null}
</>
);
}
/** Fine sugary grain for the feed paste; null without a 2D canvas (tests). */
function fondantTexture(): CanvasTexture | null {
if (typeof document === "undefined") return null;
const canvas = document.createElement("canvas");
canvas.width = 128;
canvas.height = 128;
const context = canvas.getContext("2d");
if (!context) return null;
let seed = 97;
const next = () => {
seed = (seed * 16807) % 2147483647;
return seed / 2147483647;
};
context.fillStyle = "#d8d8d8";
context.fillRect(0, 0, 128, 128);
for (let dot = 0; dot < 900; dot += 1) {
const light = Math.round(180 + next() * 75);
context.fillStyle = `rgb(${light}, ${light}, ${light})`;
context.beginPath();
context.arc(next() * 128, next() * 128, 0.6 + next() * 1.6, 0, Math.PI * 2);
context.fill();
}
const texture = new CanvasTexture(canvas);
texture.colorSpace = SRGBColorSpace;
texture.wrapS = RepeatWrapping;
texture.wrapT = RepeatWrapping;
texture.repeat.set(2, 2);
return texture;
}
/** Soft caustics on the floor of the space a liquid fills, moving only at `full`. */
function Caustics({ center, size, color }: { center: Vec3; size: Vec3; color: FillColor }) {
const { motion } = useSceneKit();
const geometry = useMemo(() => {
const plane = new PlaneGeometry(size[0] * 0.98, size[2] * 0.98);
plane.rotateX(-Math.PI / 2);
return plane;
}, [size]);
const material = useMemo(() => createCausticMaterial(color, [size[0] * 3, size[2] * 3]), [color, size]);
useEffect(
() => () => {
geometry.dispose();
material.dispose();
},
[geometry, material],
);
useFrame((state) => {
if (motion === "full") setUniform(material, "uTime", state.clock.elapsedTime);
});
return (
<mesh
geometry={geometry}
material={material}
position={[center[0], center[1] - size[1] / 2 + 0.006, center[2]]}
raycast={noRaycast}
renderOrder={2}
/>
);
}
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useMemo, useRef } from "react";
import { MeshStandardMaterial } from "three";
import { Color, MeshStandardMaterial } from "three";
import type { Group, ShaderMaterial } from "three";
import type { FrameBox, Vec3 } from "@/lib/hive/layout";
import { HoloLabel } from "./hive-labels";
@@ -9,7 +9,9 @@ import { createEdgeMaterial, createGlassMaterial } from "./materials";
import { buildEdgeGeometry, buildGlassGeometry, frameHintSpec, frameShapeSpec } from "./part-geometry";
import type { PartShapeSpec } from "./part-geometry";
import { meshRaycast, noRaycast, useSceneKit } from "./scene-kit";
import { setMaterialOpacity, setUniform } from "./scene-mutations";
import { setMaterialOpacity, setUniform, setUniformMix } from "./scene-mutations";
import { useLivingEdges } from "./use-scene-effects";
import { frameRimColor } from "./frame-textures";
import type { SceneLabel } from "./scene-model";
import type { NodeHandlers } from "./stack-part";
import { useAnimatedNode } from "./use-animated-node";
@@ -52,7 +54,7 @@ export function FrameHints({
);
return (
<>
<mesh geometry={geometries.glass} material={glass} raycast={noRaycast} renderOrder={1} />
<mesh name={`frame-hints:${frames[0]?.boxId ?? ""}`} geometry={geometries.glass} material={glass} raycast={noRaycast} renderOrder={1} />
<mesh geometry={geometries.edges} material={edges} raycast={noRaycast} renderOrder={4} />
</>
);
@@ -69,7 +71,7 @@ export interface FramePlateProps {
const COMB_FROST: Partial<Record<FrameBox["type"], number>> = { DIVIDER: 0.75, FOUNDATION: 0.25 };
export function FramePlate({ frame, hostCenter, target, handlers, label }: FramePlateProps) {
const { theme, geometries, textures, motion } = useSceneKit();
const { theme, geometries, textures, motion, env, tier } = useSceneKit();
const group = useRef<Group>(null);
const spec = useMemo(() => frameShapeSpec(frame.size, frame.type), [frame.size, frame.type]);
const sizeKey = frame.size.map((value) => value.toFixed(4)).join("x");
@@ -88,18 +90,23 @@ export function FramePlate({ frame, hostCenter, target, handlers, label }: Frame
}))
: null;
const texture = textures.get(frame.type);
const rimHex = frameRimColor(frame.type);
const rim = useMemo(() => (rimHex ? new Color(rimHex) : theme.edgeStrong), [rimHex, theme]);
const materials = useMemo(
() => ({
bars: createGlassMaterial(theme, { frost: 0.28 }),
comb: createGlassMaterial(theme, { frost: COMB_FROST[frame.type] ?? 0.38 }),
bars: createGlassMaterial(theme, { frost: 0.28, env, dispersion: tier === "high" ? 0.03 : 0 }),
comb: createGlassMaterial(theme, { frost: COMB_FROST[frame.type] ?? 0.38, env, dispersion: 0 }),
edges: createEdgeMaterial(theme),
typed: texture
? new MeshStandardMaterial({
map: texture,
// The comb pattern as relief: cells read as depth under the studio light.
bumpMap: texture,
bumpScale: 4,
emissiveMap: texture,
emissive: "#ffffff",
emissiveIntensity: 0.95,
emissiveIntensity: 0.8,
roughness: frame.type === "HONEY" ? 0.2 : 0.45,
metalness: 0.05,
transparent: true,
@@ -107,7 +114,7 @@ export function FramePlate({ frame, hostCenter, target, handlers, label }: Frame
})
: null,
}),
[theme, texture, frame.type],
[theme, texture, frame.type, env, tier],
);
useEffect(
() => () => {
@@ -128,6 +135,8 @@ export function FramePlate({ frame, hostCenter, target, handlers, label }: Frame
setUniform(materials.comb, "uGlow", glow);
setUniform(materials.edges, "uOpacity", opacity);
setUniform(materials.edges, "uIntensity", 1.3 * glow);
// Lifted, the rim takes the colour of the frame type.
setUniformMix(materials.edges, "uCyan", theme.edge, rim, look);
if (materials.typed) setMaterialOpacity(materials.typed, opacity * look);
};
useAnimatedNode(
@@ -136,13 +145,14 @@ export function FramePlate({ frame, hostCenter, target, handlers, label }: Frame
motion,
apply,
);
useLivingEdges([materials.edges]);
const restY = frame.center[1] - hostCenter[1];
const rise = Math.max(0, target.position[1] - restY);
const [fx, fy, fz] = frame.size;
return (
<group ref={group}>
<group ref={group} visible={target.look === "glass" || target.look === "type"}>
<mesh geometry={bars.glass} material={materials.bars} raycast={noRaycast} renderOrder={1} dispose={null} />
{comb ? (
<mesh geometry={comb.glass} material={materials.comb} raycast={noRaycast} renderOrder={1} dispose={null} />
@@ -154,6 +164,7 @@ export function FramePlate({ frame, hostCenter, target, handlers, label }: Frame
{/* The hit box reaches down to the rest place, so a lifted frame does not slip from under the pointer. */}
<mesh
position={[0, -rise / 2, 0]}
scale={[fx, fy + rise, fz]}
raycast={target.interactive ? meshRaycast : noRaycast}
onPointerOver={handlers.onPointerOver}
onPointerOut={handlers.onPointerOut}
@@ -161,7 +172,7 @@ export function FramePlate({ frame, hostCenter, target, handlers, label }: Frame
onClick={handlers.onClick}
userData={{ hit: `${frame.boxId}#${frame.slot}` }}
>
<boxGeometry args={[fx, fy + rise, fz]} />
<boxGeometry args={[1, 1, 1]} />
<meshBasicMaterial visible={false} />
</mesh>
{label ? <HoloLabel position={[0, fy / 2 + 0.12, 0]} text={label.text} tone="frame" /> : null}
@@ -1,7 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { FRAME_TYPES } from "@/lib/hive/types";
import type { FrameType } from "@/lib/hive/types";
import { createFrameTextureCache, framePatternSpec } from "./frame-textures";
import { createFrameTextureCache, framePatternSpec, frameRimColor } from "./frame-textures";
/** A canvas whose 2D context records every fill colour and shape drawn. */
function fakeCanvas() {
@@ -107,3 +107,12 @@ describe("createFrameTextureCache", () => {
expect(cache.get("BROOD")).toBeNull();
});
});
describe("frameRimColor", () => {
it("gives every frame type its own rim colour for the lifted look", () => {
const rims = FRAME_TYPES.filter((type) => type !== "NONE").map(frameRimColor);
expect(new Set(rims).size).toBe(rims.length);
expect(frameRimColor("BROOD")).toBe(framePatternSpec("BROOD")!.rim);
expect(frameRimColor("NONE")).toBeNull();
});
});
@@ -137,6 +137,14 @@ export function framePatternSpec(type: FrameType): FramePatternSpec | null {
return SPECS[type] ?? null;
}
/** Rim colours of the frames without a comb texture. */
const OPEN_RIMS: Partial<Record<FrameType, string>> = { DRONE_FRAME: "#ffc46b", DIVIDER: "#bfe4ff" };
/** Edge colour of a lifted or selected frame, matching its type; null for an empty slot. */
export function frameRimColor(type: FrameType): string | null {
return SPECS[type]?.rim ?? OPEN_RIMS[type] ?? null;
}
/** Small deterministic PRNG (mulberry32). */
function random(seed: number): () => number {
let state = seed >>> 0;
@@ -1,15 +1,17 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import type { RefObject } from "react";
import { Canvas } from "@react-three/fiber";
import type { HiveLayout } from "@/lib/hive/layout";
import type { HiveCatalog, HiveState } from "@/lib/hive/types";
import { useEffectiveMotionLevel } from "@/lib/motion/motion-preference";
import { useHiveView } from "../use-hive-view";
import { useHiveViewStore } from "../use-hive-view";
import { CAMERA_FOV_DEG } from "./camera-fit";
import type { OverlayInsets } from "./camera-fit";
import { HiveSceneContent } from "./hive-scene-content";
import { HiveStage, detectStageTier } from "./hive-stage";
import type { QualityVerdict } from "./quality";
import { SceneKitProvider } from "./scene-kit";
import { shouldIdle } from "./use-hive-motion";
@@ -24,6 +26,8 @@ export interface HiveCanvasProps {
onReady: () => void;
/** The WebGL context was lost; the browser may or may not restore it. */
onContextLost: () => void;
/** Card pixels covered by the frosted overlays (title/focus bar, buttons). */
insets?: OverlayInsets;
}
/** Card visible in the viewport and tab in the foreground (idle motion pauses otherwise). */
@@ -61,12 +65,28 @@ export default function HiveCanvas({
onSelectFrame,
onReady,
onContextLost,
insets,
}: HiveCanvasProps) {
const motion = useEffectiveMotionLevel();
const view = useHiveView();
// Only acts on the view (empty click); the scene content subscribes itself.
const view = useHiveViewStore();
const root = useRef<HTMLDivElement>(null);
const detachContextLost = useRef<(() => void) | null>(null);
// Detach on unmount, so R3F's own forceContextLoss after unmount does not count as a loss.
useEffect(() => () => detachContextLost.current?.(), []);
const { visible, tabVisible } = useOnScreen(root);
const [tier] = useState(detectStageTier);
// Starts from the device class, drops to low when it renders below 45 fps for 2 s;
// depth of field only after it measured 55 fps or more (a 60 Hz screen).
const [tier, setTier] = useState(detectStageTier);
const [fast, setFast] = useState(false);
const onQuality = useCallback((verdict: QualityVerdict) => {
if (verdict === "slow") {
setTier("low");
setFast(false);
} else {
setFast(true);
}
}, []);
const idle = shouldIdle({ motion, visible, tabVisible });
return (
@@ -86,17 +106,20 @@ export default function HiveCanvas({
view.setHoveredFrame(null);
}}
onCreated={({ gl, invalidate }) => {
gl.domElement.addEventListener("webglcontextlost", (event) => {
const canvas = gl.domElement;
const onLost = (event: Event) => {
// Without preventDefault the browser never restores the context.
event.preventDefault();
onContextLost();
});
};
canvas.addEventListener("webglcontextlost", onLost);
detachContextLost.current = () => canvas.removeEventListener("webglcontextlost", onLost);
invalidate();
onReady();
}}
>
<SceneKitProvider motion={motion}>
<HiveStage tier={tier} />
<SceneKitProvider motion={motion} tier={tier}>
<HiveStage tier={tier} depthOfField={fast && motion === "full"} onQuality={onQuality} />
<HiveSceneContent
layout={layout}
state={state}
@@ -105,6 +128,8 @@ export default function HiveCanvas({
onSelectPart={onSelectPart}
onSelectFrame={onSelectFrame}
idle={idle}
onScreen={visible && tabVisible}
insets={insets}
/>
</SceneKitProvider>
</Canvas>
@@ -83,6 +83,13 @@ const hit = (renderer: Rendered["renderer"], id: string): ReactThreeTest.ReactTh
const hits = (renderer: Rendered["renderer"]): string[] =>
renderer.scene.findAll((node) => typeof node.props.userData?.hit === "string").map((node) => node.props.userData.hit);
/** Frame plates stay mounted (no mount work on the focus flight); only the open box shows them. */
const shownFrames = (renderer: Rendered["renderer"]): string[] =>
renderer.scene
.findAll((node) => typeof node.props.userData?.hit === "string" && node.props.userData.hit.includes("#"))
.filter((node) => node.parent?.instance.visible)
.map((node) => node.props.userData.hit);
async function tap(renderer: Rendered["renderer"], id: string, pointerType = "mouse") {
const target = hit(renderer, id);
await renderer.fireEvent(target, "onPointerDown", { pointerType });
@@ -92,18 +99,29 @@ async function tap(renderer: Rendered["renderer"], id: string, pointerType = "mo
describe("HiveSceneContent", () => {
it("renders one hit box per part and insert, and frames only as hints at rest", async () => {
const { renderer } = await renderScene();
expect(hits(renderer).sort()).toEqual(["b1", "f", "fe", "ff", "fi", "r"]);
expect(hits(renderer).filter((id) => !id.includes("#")).sort()).toEqual(["b1", "f", "fe", "ff", "fi", "r"]);
expect(shownFrames(renderer)).toEqual([]);
});
it("shows the frames of an open box as plates, without the empty slots under the pocket", async () => {
const { renderer, store } = await renderScene();
await act(async () => store.openBox("b1"));
const frameHits = hits(renderer).filter((id) => id.startsWith("b1#"));
const frameHits = shownFrames(renderer);
expect(frameHits).toHaveLength(8);
expect(frameHits.every((id) => id.startsWith("b1#"))).toBe(true);
expect(frameHits).not.toContain("b1#1");
expect(frameHits).not.toContain("b1#2");
});
it("keeps the merged frame-hint geometry across hover changes (no rebuild per render)", async () => {
const { renderer, store } = await renderScene();
const hint = () => renderer.scene.find((node) => node.props.name === "frame-hints:b1").instance;
const before = (hint() as unknown as { geometry: unknown }).geometry;
await act(async () => store.setHovered("b1"));
await act(async () => store.setHovered("fe"));
expect((hint() as unknown as { geometry: unknown }).geometry).toBe(before);
});
it("reports pointer over a part as hover to the shared store and clears it on leave", async () => {
const { renderer, store } = await renderScene();
await renderer.fireEvent(hit(renderer, "b1"), "onPointerOver", { pointerType: "mouse" });
@@ -2,10 +2,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useThree } from "@react-three/fiber";
import type { HiveLayout, PartBox, Vec3 } from "@/lib/hive/layout";
import type { FrameBox, HiveLayout, PartBox, Vec3 } from "@/lib/hive/layout";
import type { HiveCatalog, HiveState } from "@/lib/hive/types";
import { focusedIdOf, useHiveView } from "../use-hive-view";
import type { HoveredFrame } from "../use-hive-view";
import type { OverlayInsets } from "./camera-fit";
import { CameraRig } from "./camera-rig";
import { FramePlate } from "./frame-plate";
import { ModelRig } from "./model-rig";
@@ -37,9 +38,16 @@ export interface HiveSceneContentProps {
onSelectFrame: (boxId: string, slot: number) => void;
/** Idle motion may run (see shouldIdle). */
idle: boolean;
/** Card and tab visible (the quality probe runs at any motion level). */
onScreen?: boolean;
/** Card pixels covered by the frosted overlays; the camera fits the model between them. */
insets?: OverlayInsets;
}
const NO_INSETS: OverlayInsets = { top: 0, bottom: 0 };
const partKey = (id: string): string => `part:${id}`;
const NO_FRAMES: FrameBox[] = [];
const frameTargetKey = (boxId: string, slot: number): string => `frame:${frameKey(boxId, slot)}`;
function parseKey(key: string): { kind: "part"; id: string } | { kind: "frame"; boxId: string; slot: number } | null {
@@ -72,6 +80,8 @@ export function HiveSceneContent({
onSelectPart,
onSelectFrame,
idle,
onScreen = false,
insets = NO_INSETS,
}: HiveSceneContentProps) {
const view = useHiveView();
const focusedId = focusedIdOf(view);
@@ -174,16 +184,22 @@ export function HiveSceneContent({
}, []);
const getPivot = useCallback(() => pivot.current, []);
const insertsOf = (host: PartBox) => layout.inserts.filter((insert) => insert.stackIndex === host.stackIndex);
// One stable array per box, so the merged hint geometry is built once per layout, not per render.
const framesByBox = useMemo(() => {
const grouped = new Map<string, FrameBox[]>();
for (const frame of layout.frames) grouped.set(frame.boxId, [...(grouped.get(frame.boxId) ?? []), frame]);
return grouped;
}, [layout]);
return (
<>
<CameraRig layout={layout} focus={targets.focus} onPivot={setPivot} />
<ModelRig getPivot={getPivot} idle={idle}>
<CameraRig layout={layout} focus={targets.focus} onPivot={setPivot} insets={insets} />
<ModelRig getPivot={getPivot} idle={idle} onScreen={onScreen}>
{layout.parts.map((placed) => {
const part = model.get(placed.id);
const target = targets.nodes.get(placed.id);
if (!part || !target) return null;
const frames = layout.frames.filter((frame) => frame.boxId === placed.id);
const frames = framesByBox.get(placed.id) ?? NO_FRAMES;
return (
<StackPart
key={placed.id}
@@ -209,22 +225,22 @@ export function HiveSceneContent({
/>
);
})}
{target.content
? frames.map((frame) => {
const frameTarget = targets.frames.get(frameKey(frame.boxId, frame.slot));
if (!frameTarget || frameTarget.look === "hidden") return null;
return (
<FramePlate
key={frame.slot}
frame={frame}
hostCenter={placed.center}
target={frameTarget}
handlers={handlersFor(frameTargetKey(frame.boxId, frame.slot))}
label={frameLabel(labels, frame.boxId, frame.slot)}
/>
);
})
: null}
{/* Frame plates stay mounted (hidden while their box is closed), so opening a box
only animates and does not create materials during the flight. */}
{frames.map((frame) => {
const frameTarget = targets.frames.get(frameKey(frame.boxId, frame.slot));
if (!frameTarget || frameTarget.look === "hidden") return null;
return (
<FramePlate
key={frame.slot}
frame={frame}
hostCenter={placed.center}
target={frameTarget}
handlers={handlersFor(frameTargetKey(frame.boxId, frame.slot))}
label={frameLabel(labels, frame.boxId, frame.slot)}
/>
);
})}
</StackPart>
);
})}
@@ -1,12 +1,14 @@
"use client";
import { useEffect, useMemo, useRef } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import { MeshReflectorMaterial } from "@react-three/drei";
import { Bloom, EffectComposer, ToneMapping, Vignette } from "@react-three/postprocessing";
import { Bloom, DepthOfField, EffectComposer, ToneMapping, Vignette } from "@react-three/postprocessing";
import { ToneMappingMode } from "postprocessing";
import type { DepthOfFieldEffect } from "postprocessing";
import {
AdditiveBlending,
DoubleSide,
BufferGeometry,
CanvasTexture,
Float32BufferAttribute,
@@ -15,8 +17,10 @@ import {
ShaderMaterial,
} from "three";
import type { Points } from "three";
import { createQualityMonitor } from "./quality";
import type { QualityVerdict, StageTier } from "./quality";
import { useSceneKit } from "./scene-kit";
import { setSceneBackground, setUniform } from "./scene-mutations";
import { markTimerFrame, setEffectBokeh, setEffectTarget, setSceneBackground, setUniform } from "./scene-mutations";
import type { HoloTheme } from "./theme";
// The holo stage (spec 9.3, mockups g2/h2): deep midnight backdrop with a
@@ -29,7 +33,7 @@ const OVERLAY_LAYER = 1;
const FLOOR_SIZE = 240;
const PARTICLE_COUNT = 42;
export type StageTier = "high" | "low";
export type { StageTier } from "./quality";
/** High: reflective floor and MSAA (desktop-class). Low: fake floor, no MSAA (phones, ≤ 4 cores). */
export function detectStageTier(): StageTier {
@@ -342,9 +346,43 @@ function Lights() {
);
}
function Effects({ tier }: { tier: StageTier }) {
/** Watches frame times while the scene animates and reports slow or fast (adaptive tier, depth of field). */
function QualityWatcher({ onVerdict }: { onVerdict: (verdict: QualityVerdict) => void }) {
const { signals } = useSceneKit();
const [monitor] = useState(() => createQualityMonitor());
useFrame((_, delta) => {
if (signals.timerFrame) {
// Frames paced by the idle timer say nothing about speed; start the window over.
markTimerFrame(signals, false);
monitor.push(Number.POSITIVE_INFINITY);
return;
}
const verdict = monitor.push(delta);
if (verdict) onVerdict(verdict);
});
return null;
}
/** Bokeh of the depth of field in focus mode; 0 in the rest view. */
const FOCUS_BOKEH = 2.4;
function Effects({ tier, depthOfField }: { tier: StageTier; depthOfField: boolean }) {
const { signals } = useSceneKit();
const dof = useRef<DepthOfFieldEffect>(null);
// Mounted for good once allowed (no composer rebuild and shader compile on the focus click);
// the focus point switches the blur on and off.
useFrame(() => {
if (!dof.current) return;
if (signals.focusPoint) setEffectTarget(dof.current, signals.focusPoint);
setEffectBokeh(dof.current, signals.focusPoint ? FOCUS_BOKEH : 0);
});
return (
<EffectComposer multisampling={tier === "high" ? 4 : 0}>
{depthOfField ? (
<DepthOfField ref={dof} target={[0, 0, 0]} worldFocusRange={4} bokehScale={0} resolutionScale={0.5} />
) : (
<></>
)}
<Bloom
mipmapBlur
luminanceThreshold={0.7}
@@ -359,14 +397,81 @@ function Effects({ tier }: { tier: StageTier }) {
);
}
export function HiveStage({ tier }: { tier: StageTier }) {
const HAZE_VERTEX = /* glsl */ `
varying float vHeight;
varying float vFacing;
void main() {
vHeight = position.y;
vec4 view = modelViewMatrix * vec4(position, 1.0);
vec3 normalView = normalize(normalMatrix * normal);
vFacing = abs(dot(normalView, normalize(-view.xyz)));
gl_Position = projectionMatrix * view;
}
`;
const HAZE_FRAGMENT = /* glsl */ `
uniform vec3 uColor;
uniform float uHeight;
varying float vHeight;
varying float vFacing;
void main() {
float along = clamp(vHeight / uHeight + 0.5, 0.0, 1.0);
// Brightest in the middle of the cone, fading toward the floor and the apex.
float fade = smoothstep(0.0, 0.35, along) * (1.0 - smoothstep(0.75, 1.0, along));
gl_FragColor = vec4(uColor * pow(vFacing, 2.0) * fade * 0.07, 1.0);
}
`;
const HAZE_HEIGHT = 18;
/** A faint cone of cool light falling onto the hive from above and behind. */
function Haze() {
const { theme } = useSceneKit();
const material = useMemo(
() =>
new ShaderMaterial({
uniforms: { uColor: { value: theme.rim.clone() }, uHeight: { value: HAZE_HEIGHT } },
vertexShader: HAZE_VERTEX,
fragmentShader: HAZE_FRAGMENT,
transparent: true,
depthWrite: false,
blending: AdditiveBlending,
side: DoubleSide,
}),
[theme],
);
useEffect(() => () => material.dispose(), [material]);
return (
<mesh
position={[-1.5, HAZE_HEIGHT / 2 - 1, -3]}
rotation={[0.18, 0, 0.12]}
material={material}
renderOrder={-1}
layers={OVERLAY_LAYER}
raycast={() => {}}
>
<coneGeometry args={[6.5, HAZE_HEIGHT, 48, 1, true]} />
</mesh>
);
}
export interface HiveStageProps {
tier: StageTier;
/** Depth of field (used in focus mode): only when the device measured fast (≥ 55 fps). */
depthOfField: boolean;
onQuality: (verdict: QualityVerdict) => void;
}
export function HiveStage({ tier, depthOfField, onQuality }: HiveStageProps) {
return (
<>
<Backdrop />
<Lights />
<Floor tier={tier} />
<Particles />
<Effects tier={tier} />
<Haze />
{tier === "high" ? <Particles /> : null}
<QualityWatcher onVerdict={onQuality} />
<Effects tier={tier} depthOfField={depthOfField && tier === "high"} />
</>
);
}
@@ -1,17 +1,22 @@
import { AdditiveBlending, Color, DoubleSide, NormalBlending, ShaderMaterial } from "three";
import type { CubeTexture } from "three";
import type { HoloTheme } from "./theme";
// Hand-written shaders instead of transmission: a fake studio environment
// (white soft box above, blue rim light behind, faint cool bounce below) with
// fresnel gives the glass look at a fraction of the cost (spec 9.9).
// Hand-written shaders instead of transmission. The glass samples a small
// procedural studio cube map (scene-env.ts) for reflections, a dispersive
// refraction of it for body, blurred mips for frosted inner faces and its hot
// spots for glints; without the cube map it falls back to an analytic studio.
// One draw call per part either way (spec 9.9).
const GLASS_VERTEX = /* glsl */ `
varying vec3 vNormalW;
varying vec3 vViewDir;
varying vec3 vLocal;
void main() {
vec4 world = modelMatrix * vec4(position, 1.0);
vNormalW = normalize(mat3(modelMatrix) * normal);
vViewDir = cameraPosition - world.xyz;
vLocal = position;
gl_Position = projectionMatrix * viewMatrix * world;
}
`;
@@ -20,31 +25,71 @@ const GLASS_FRAGMENT = /* glsl */ `
uniform vec3 uTint;
uniform vec3 uRim;
uniform vec3 uBounce;
uniform vec3 uSweepColor;
uniform float uOpacity;
uniform float uFrost;
uniform float uGlow;
uniform samplerCube uEnv;
uniform float uEnvStrength;
uniform float uDispersion;
uniform float uSweep;
uniform float uHeight;
varying vec3 vNormalW;
varying vec3 vViewDir;
void main() {
vec3 n = normalize(vNormalW);
vec3 v = normalize(vViewDir);
if (!gl_FrontFacing) n = -n;
float ndv = clamp(abs(dot(n, v)), 0.0, 1.0);
float fresnel = pow(1.0 - ndv, 3.0);
vec3 r = reflect(-v, n);
varying vec3 vLocal;
vec3 analyticStudio(vec3 r) {
float sky = smoothstep(-0.1, 0.9, r.y);
float ground = smoothstep(0.05, -0.7, r.y);
float softbox = pow(max(dot(r, normalize(vec3(-0.35, 0.8, -0.5))), 0.0), 28.0);
float rimBack = pow(max(dot(r, normalize(vec3(0.25, 0.2, -1.0))), 0.0), 5.0);
vec3 env = uRim * (0.3 * sky + 0.85 * rimBack) + uBounce * 0.35 * ground + vec3(1.0) * softbox * 1.3;
vec3 color = uTint * (0.16 + uFrost * 0.3) + env * (0.25 + 0.75 * fresnel);
color += uBounce * 0.08 * smoothstep(0.5, -0.9, n.y);
float alpha = 0.085 + uFrost * 0.16 + fresnel * 0.55 + softbox * 0.45;
if (!gl_FrontFacing) {
// Faintly frosted inner faces.
return uRim * (0.3 * sky + 0.85 * rimBack) + uBounce * 0.35 * ground + vec3(1.0) * softbox * 1.3;
}
void main() {
vec3 n = normalize(vNormalW);
vec3 v = normalize(vViewDir);
bool back = !gl_FrontFacing;
if (back) n = -n;
float ndv = clamp(abs(dot(n, v)), 0.0, 1.0);
float fresnel = pow(1.0 - ndv, 3.0);
vec3 r = reflect(-v, n);
// Reflection: sharp on the outside, blurred (frosted) on the inner faces.
vec3 cubeReflection = textureCube(uEnv, r, back ? 4.0 : 0.0).rgb;
vec3 reflection = mix(analyticStudio(r), cubeReflection, uEnvStrength);
// Body: the studio seen through the glass, slightly split per channel.
vec3 refraction = vec3(
textureCube(uEnv, refract(-v, n, 1.0 / (1.5 - uDispersion)), 2.0).r,
textureCube(uEnv, refract(-v, n, 1.0 / 1.5), 2.0).g,
textureCube(uEnv, refract(-v, n, 1.0 / (1.5 + uDispersion)), 2.0).b
) * uEnvStrength;
// Glints where the chamfers catch the hot spots.
float glint = smoothstep(1.6, 4.0, dot(cubeReflection, vec3(0.333))) * uEnvStrength;
vec3 color = uTint * (0.14 + uFrost * 0.3)
+ reflection * (0.22 + 0.78 * fresnel)
+ mix(refraction, vec3(dot(refraction, vec3(0.333))), 0.6) * 0.09
+ vec3(1.0) * glint * 0.9;
float alpha = 0.08 + uFrost * 0.16 + fresnel * 0.55 + glint * 0.5
+ dot(refraction, vec3(0.03));
if (back) {
alpha *= 0.6;
color = mix(color, uTint * 0.45, 0.35);
}
// Hover: a holographic shimmer sweeps across once and a thin scan line climbs the part.
if (uSweep > 0.0) {
float height = max(uHeight, 1e-3);
float y = clamp(vLocal.y / height + 0.5, 0.0, 1.0);
float diagonal = y * 0.75 + vLocal.x * 0.08;
float progress = uSweep * 2.2 - 0.6;
float band = exp(-pow((diagonal - progress) * 7.0, 2.0)) * (1.0 - smoothstep(0.8, 1.0, uSweep));
float scan = (1.0 - smoothstep(0.0, 0.012, abs(y - uSweep))) * step(uSweep, 0.999);
color += uSweepColor * (band * 0.55 + scan * 1.4);
alpha += band * 0.18 + scan * 0.45;
}
color *= 0.78 + 0.22 * uGlow;
gl_FragColor = vec4(color, clamp(alpha, 0.0, 1.0) * uOpacity);
}
@@ -53,6 +98,10 @@ void main() {
export interface GlassOptions {
/** 0 = clear, 1 = milky (insulation, divider board). */
frost?: number;
/** Studio cube map; without it the analytic studio is used. */
env?: CubeTexture | null;
/** Colour split of the refraction; 0 on the low tier. */
dispersion?: number;
}
export function createGlassMaterial(theme: HoloTheme, options: GlassOptions = {}): ShaderMaterial {
@@ -61,9 +110,15 @@ export function createGlassMaterial(theme: HoloTheme, options: GlassOptions = {}
uTint: { value: theme.tint.clone() },
uRim: { value: theme.rim.clone() },
uBounce: { value: theme.bounce.clone() },
uSweepColor: { value: theme.edgeStrong.clone() },
uOpacity: { value: 1 },
uFrost: { value: options.frost ?? 0 },
uGlow: { value: 1 },
uEnv: { value: options.env ?? null },
uEnvStrength: { value: options.env ? 1 : 0 },
uDispersion: { value: options.dispersion ?? 0.03 },
uSweep: { value: 0 },
uHeight: { value: 1 },
},
vertexShader: GLASS_VERTEX,
fragmentShader: GLASS_FRAGMENT,
@@ -77,12 +132,23 @@ export function createGlassMaterial(theme: HoloTheme, options: GlassOptions = {}
const EDGE_VERTEX = /* glsl */ `
attribute float aHot;
attribute float aStrength;
attribute float aAlong;
attribute float aPhase;
varying float vHot;
varying float vStrength;
varying float vAlong;
varying float vPhase;
varying float vFacing;
void main() {
vHot = aHot;
vStrength = aStrength;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
vAlong = aAlong;
vPhase = aPhase;
vec4 view = modelViewMatrix * vec4(position, 1.0);
// Tube normal in view space: faces toward the camera form the white-hot core.
vec3 normalView = normalize(normalMatrix * normal);
vFacing = abs(dot(normalView, normalize(-view.xyz)));
gl_Position = projectionMatrix * view;
}
`;
@@ -91,17 +157,26 @@ uniform vec3 uCyan;
uniform vec3 uHot;
uniform float uIntensity;
uniform float uOpacity;
uniform float uTime;
uniform float uPulse;
varying float vHot;
varying float vStrength;
varying float vAlong;
varying float vPhase;
varying float vFacing;
void main() {
float hot = vHot * vHot;
vec3 color = mix(uCyan, uHot, hot);
float energy = uIntensity * vStrength * (1.0 + hot * 1.6);
float core = pow(vFacing, 4.0);
vec3 color = mix(uCyan, uHot, clamp(max(hot, core * 0.75), 0.0, 1.0));
// Slow light pulses travelling along the edges (motion full only).
float phase = fract(vAlong * 0.22 - uTime * 0.12 + vPhase);
float pulse = smoothstep(0.0, 0.035, phase) * (1.0 - smoothstep(0.035, 0.14, phase)) * uPulse;
float energy = uIntensity * vStrength * (0.55 + 0.45 * core + hot * 1.6 + pulse * 1.3);
gl_FragColor = vec4(color * energy * uOpacity, 1.0);
}
`;
/** Glowing edges: additive, white-hot at the corners, electric cyan along the edge. */
/** Glowing edges: additive, white-hot core and corners, electric cyan rim, slow pulses at `full`. */
export function createEdgeMaterial(theme: HoloTheme): ShaderMaterial {
return new ShaderMaterial({
uniforms: {
@@ -109,6 +184,8 @@ export function createEdgeMaterial(theme: HoloTheme): ShaderMaterial {
uHot: { value: theme.hot.clone() },
uIntensity: { value: 1.6 },
uOpacity: { value: 1 },
uTime: { value: 0 },
uPulse: { value: 0 },
},
vertexShader: EDGE_VERTEX,
fragmentShader: EDGE_FRAGMENT,
@@ -118,6 +195,49 @@ export function createEdgeMaterial(theme: HoloTheme): ShaderMaterial {
});
}
const CAUSTIC_VERTEX = /* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const CAUSTIC_FRAGMENT = /* glsl */ `
uniform vec3 uColor;
uniform float uTime;
uniform float uStrength;
uniform vec2 uScale;
varying vec2 vUv;
void main() {
vec2 p = vUv * uScale;
float v = sin(p.x + uTime) + sin((p.y + uTime) * 0.5) + sin((p.x + p.y + uTime) * 0.5);
vec2 q = p + vec2(sin(uTime / 3.0), cos(uTime / 2.0)) * 2.0;
v += sin(sqrt(q.x * q.x + q.y * q.y + 1.0) + uTime);
float caustic = pow(1.0 - abs(sin(v * 1.7)), 6.0);
float edge = smoothstep(0.0, 0.08, vUv.x) * smoothstep(0.0, 0.08, 1.0 - vUv.x)
* smoothstep(0.0, 0.08, vUv.y) * smoothstep(0.0, 0.08, 1.0 - vUv.y);
gl_FragColor = vec4(uColor * caustic * edge * uStrength, 1.0);
}
`;
/** Soft golden caustics on the floor under a liquid; additive, animated only at `full`. */
export function createCausticMaterial(color: Color, scale: [number, number]): ShaderMaterial {
return new ShaderMaterial({
uniforms: {
uColor: { value: color.clone() },
uTime: { value: 0 },
uStrength: { value: 0.9 },
uScale: { value: scale },
},
vertexShader: CAUSTIC_VERTEX,
fragmentShader: CAUSTIC_FRAGMENT,
transparent: true,
depthWrite: false,
blending: AdditiveBlending,
});
}
const GRID_VERTEX = /* glsl */ `
varying vec3 vLocal;
void main() {
@@ -5,7 +5,10 @@ import type { MutableRefObject, ReactNode } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import type { Group } from "three";
import type { Vec3 } from "@/lib/hive/layout";
import { createRigPointer } from "./rig-pointer";
import type { RigInput } from "./rig-pointer";
import { useSceneKit } from "./scene-kit";
import { capturePointer, markTimerFrame, releasePointer } from "./scene-mutations";
import { MAX_FRAME_SECONDS } from "./use-animated-node";
import { stepToward } from "./use-hive-motion";
@@ -15,28 +18,11 @@ import { stepToward } from "./use-hive-motion";
// springs back. Vertical swipes scroll the page (touch-action: pan-y).
// - at `full` only: idle float ±4 mm over 6 s and a pointer tilt ≤ 6°.
export const MAX_TURN = (60 * Math.PI) / 180;
export const MAX_TILT = (6 * Math.PI) / 180;
export { MAX_TILT, MAX_TURN, SPRING_BACK_MS, clampTurn } from "./rig-pointer";
export const IDLE_FLOAT = 0.04;
export const IDLE_PERIOD_SECONDS = 6;
export const SPRING_BACK_MS = 4000;
/** Idle motion asks for frames at about 30 fps, which is plenty for ±4 mm. */
export const IDLE_FRAME_MS = 33;
/** Pixels of horizontal travel before a press counts as a drag. */
const DRAG_START_PX = 8;
/** A drag across the full card width turns the model by this much. */
const TURN_PER_WIDTH = Math.PI;
export function clampTurn(angle: number): number {
return Math.max(-MAX_TURN, Math.min(MAX_TURN, angle));
}
interface RigInput {
turn: number;
tiltX: number;
tiltY: number;
dragging: boolean;
}
function useRigInput(enabledTilt: boolean): MutableRefObject<RigInput> {
const gl = useThree((state) => state.gl);
@@ -46,64 +32,31 @@ function useRigInput(enabledTilt: boolean): MutableRefObject<RigInput> {
useEffect(() => {
const element = gl.domElement as HTMLElement | undefined;
if (!element || typeof element.addEventListener !== "function") return;
let press: { x: number; y: number; turn: number; id: number } | null = null;
let springBack: ReturnType<typeof setTimeout> | null = null;
const onDown = (event: PointerEvent) => {
press = { x: event.clientX, y: event.clientY, turn: input.current.turn, id: event.pointerId };
if (springBack) clearTimeout(springBack);
};
const onMove = (event: PointerEvent) => {
const rect = element.getBoundingClientRect();
if (press && press.id === event.pointerId) {
const dx = event.clientX - press.x;
const dy = event.clientY - press.y;
if (!input.current.dragging && Math.abs(dx) > DRAG_START_PX && Math.abs(dx) > Math.abs(dy)) {
input.current.dragging = true;
}
if (input.current.dragging) {
input.current.turn = clampTurn(press.turn + (dx / Math.max(rect.width, 1)) * TURN_PER_WIDTH);
invalidate();
}
return;
}
if (enabledTilt && event.pointerType === "mouse" && rect.width > 0 && rect.height > 0) {
const nx = ((event.clientX - rect.left) / rect.width) * 2 - 1;
const ny = ((event.clientY - rect.top) / rect.height) * 2 - 1;
input.current.tiltY = nx * MAX_TILT;
input.current.tiltX = ny * MAX_TILT * 0.5;
invalidate();
}
};
const onUp = () => {
if (input.current.dragging) {
springBack = setTimeout(() => {
input.current.turn = 0;
invalidate();
}, SPRING_BACK_MS);
}
press = null;
// Let the click that follows a drag see `dragging`, then clear it.
setTimeout(() => {
input.current.dragging = false;
}, 0);
};
const onLeave = () => {
input.current.tiltX = 0;
input.current.tiltY = 0;
invalidate();
};
const pointer = createRigPointer(input.current, {
tilt: enabledTilt,
rect: () => element.getBoundingClientRect(),
// Captured while dragging, so a release outside the canvas still ends the drag.
capture: (id) => capturePointer(element, id),
release: (id) => releasePointer(element, id),
changed: invalidate,
});
const onDown = (event: PointerEvent) => pointer.down(event);
const onMove = (event: PointerEvent) => pointer.move(event);
const onUp = (event: PointerEvent) => pointer.up(event);
const onLeave = () => pointer.leave();
element.addEventListener("pointerdown", onDown);
element.addEventListener("pointermove", onMove);
element.addEventListener("pointerup", onUp);
element.addEventListener("pointercancel", onUp);
element.addEventListener("lostpointercapture", onUp);
element.addEventListener("pointerleave", onLeave);
return () => {
if (springBack) clearTimeout(springBack);
pointer.dispose();
element.removeEventListener("pointerdown", onDown);
element.removeEventListener("pointermove", onMove);
element.removeEventListener("pointerup", onUp);
element.removeEventListener("pointercancel", onUp);
element.removeEventListener("lostpointercapture", onUp);
element.removeEventListener("pointerleave", onLeave);
};
}, [gl, invalidate, enabledTilt]);
@@ -111,21 +64,37 @@ function useRigInput(enabledTilt: boolean): MutableRefObject<RigInput> {
return input;
}
/** Asks for a frame about every 33 ms while the idle motion runs (demand frame loop). */
function useIdleTicker(active: boolean): void {
/** Seconds of continuous idle frames after mount, so the quality monitor can measure the device. */
export const QUALITY_PROBE_SECONDS = 4;
/**
* Asks for frames (demand frame loop): continuously during the quality probe
* while the card is on screen (any motion level, so a weak device also drops
* from the high tier), then about every 33 ms while the idle motion runs
* (marked as timer frames).
*/
function useIdleTicker(active: boolean, probing: boolean): void {
const invalidate = useThree((state) => state.invalidate);
const { signals } = useSceneKit();
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const probe = useRef(0);
useEffect(() => {
if (active) invalidate();
if (active || probing) invalidate();
return () => {
if (timer.current) clearTimeout(timer.current);
timer.current = null;
};
}, [active, invalidate]);
useFrame(() => {
}, [active, probing, invalidate]);
useFrame((_, delta) => {
if (probing && probe.current < QUALITY_PROBE_SECONDS) {
probe.current += Math.min(delta, MAX_FRAME_SECONDS);
invalidate();
return;
}
if (!active || timer.current !== null) return;
timer.current = setTimeout(() => {
timer.current = null;
markTimerFrame(signals, true);
invalidate();
}, IDLE_FRAME_MS);
});
@@ -136,10 +105,12 @@ export interface ModelRigProps {
getPivot: () => Vec3;
/** Idle motion may run (motion `full`, card visible, tab visible). */
idle: boolean;
/** Card and tab visible: the quality probe may run (any motion level). */
onScreen?: boolean;
children: ReactNode;
}
export function ModelRig({ getPivot, idle, children }: ModelRigProps) {
export function ModelRig({ getPivot, idle, onScreen = false, children }: ModelRigProps) {
const { motion } = useSceneKit();
const invalidate = useThree((state) => state.invalidate);
const full = motion === "full";
@@ -147,7 +118,7 @@ export function ModelRig({ getPivot, idle, children }: ModelRigProps) {
const outer = useRef<Group>(null);
const inner = useRef<Group>(null);
const current = useRef({ turn: 0, tiltX: 0, tiltY: 0, float: 0, time: 0 });
useIdleTicker(idle && full);
useIdleTicker(idle && full, onScreen || (idle && full));
useFrame((_, delta) => {
if (!outer.current || !inner.current) return;
@@ -169,4 +169,16 @@ describe("geometry builders", () => {
expect(min).toBe(0);
geometry.dispose();
});
it("gives the edge tubes unit normals, a distance along the edge and a phase per edge", () => {
const geometry = buildEdgeGeometry([{ a: [0, 0, 0], b: [2, 0, 0], hotA: 1, hotB: 1, strength: 1 }]);
const normal = geometry.getAttribute("normal");
const along = geometry.getAttribute("aAlong");
expect(Math.hypot(normal.getX(0), normal.getY(0), normal.getZ(0))).toBeCloseTo(1, 6);
expect(normal.getX(0)).toBeCloseTo(0, 6);
expect(along.getX(0)).toBe(0);
expect(along.getX(along.count - 1)).toBeCloseTo(2, 6);
expect(geometry.getAttribute("aPhase").getX(0)).toBeGreaterThanOrEqual(0);
geometry.dispose();
});
});
@@ -517,14 +517,21 @@ function perpendicular(direction: Vec3): [Vec3, Vec3] {
return [u, normalize(cross(u, direction))];
}
/** Thin square tubes along every edge, with `aHot` (corner glow) and `aStrength` attributes. */
/**
* Thin square tubes along every edge: `normal` (round tube normals for the
* white-hot core), `aHot` (corner glow), `aStrength`, `aAlong` (distance along
* the edge) and `aPhase` (per edge) for the travelling pulses.
*/
export function buildEdgeGeometry(segments: EdgeSegment[], width: number = EDGE_WIDTH): BufferGeometry {
const positions: number[] = [];
const normals: number[] = [];
const hot: number[] = [];
const strength: number[] = [];
const along: number[] = [];
const phase: number[] = [];
const indices: number[] = [];
const half = width / 2;
for (const edge of segments) {
for (const [segmentIndex, edge] of segments.entries()) {
const delta: Vec3 = [edge.b[0] - edge.a[0], edge.b[1] - edge.a[1], edge.b[2] - edge.a[2]];
const length = Math.hypot(delta[0], delta[1], delta[2]);
if (length < 1e-6) continue;
@@ -538,6 +545,8 @@ export function buildEdgeGeometry(segments: EdgeSegment[], width: number = EDGE_
[1, edge.hotB],
];
const base = positions.length / 3;
// Deterministic per-edge phase, so the travelling pulses do not march in step.
const edgePhase = (Math.sin(segmentIndex * 12.9898) * 43758.5453) % 1;
for (const [t, ringHot] of rings) {
for (const [su, sw] of [
[1, 1],
@@ -545,11 +554,21 @@ export function buildEdgeGeometry(segments: EdgeSegment[], width: number = EDGE_
[-1, -1],
[1, -1],
]) {
const offset: Vec3 = [
(u[0] * su + w[0] * sw) * half,
(u[1] * su + w[1] * sw) * half,
(u[2] * su + w[2] * sw) * half,
];
const offsetLength = Math.hypot(offset[0], offset[1], offset[2]) || 1;
for (let axis = 0; axis < 3; axis += 1) {
positions.push(edge.a[axis] + delta[axis] * t + (u[axis] * su + w[axis] * sw) * half);
positions.push(edge.a[axis] + delta[axis] * t + offset[axis]);
// Smooth tube normal: from the edge's axis out through the corner.
normals.push(offset[axis] / offsetLength);
}
hot.push(ringHot);
strength.push(edge.strength);
along.push(t * length);
phase.push(Math.abs(edgePhase));
}
}
for (let band = 0; band < 3; band += 1) {
@@ -564,8 +583,11 @@ export function buildEdgeGeometry(segments: EdgeSegment[], width: number = EDGE_
}
const geometry = new BufferGeometry();
geometry.setAttribute("position", new Float32BufferAttribute(positions, 3));
geometry.setAttribute("normal", new Float32BufferAttribute(normals, 3));
geometry.setAttribute("aHot", new Float32BufferAttribute(hot, 1));
geometry.setAttribute("aStrength", new Float32BufferAttribute(strength, 1));
geometry.setAttribute("aAlong", new Float32BufferAttribute(along, 1));
geometry.setAttribute("aPhase", new Float32BufferAttribute(phase, 1));
geometry.setIndex(indices);
geometry.computeBoundingSphere();
return geometry;
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import { createQualityMonitor, springStep } from "./quality";
describe("createQualityMonitor", () => {
const feed = (monitor: ReturnType<typeof createQualityMonitor>, fps: number, seconds: number) => {
const verdicts = [];
for (let time = 0; time < seconds; time += 1 / fps) verdicts.push(monitor.push(1 / fps));
return verdicts.filter((verdict) => verdict !== null);
};
it("reports slow after two seconds of rendering below 45 fps", () => {
const monitor = createQualityMonitor();
expect(feed(monitor, 30, 1.9)).toEqual([]);
expect(feed(monitor, 30, 0.3)).toEqual(["slow"]);
});
it("reports fast after two seconds at 60 fps or more", () => {
expect(feed(createQualityMonitor(), 120, 2.1)).toEqual(["fast"]);
});
it("counts a 60 Hz screen (about 59.9 fps) as fast", () => {
expect(feed(createQualityMonitor(), 59.9, 2.2)).toEqual(["fast"]);
});
it("stays silent between the two thresholds", () => {
expect(feed(createQualityMonitor(), 50, 4.5)).toEqual([]);
});
it("ignores pauses of the demand frame loop instead of counting them as slow frames", () => {
const monitor = createQualityMonitor();
feed(monitor, 120, 1);
expect(monitor.push(3)).toBeNull();
// The window restarts after the pause: one more second is not enough.
expect(feed(monitor, 120, 1)).toEqual([]);
expect(feed(monitor, 120, 1.1)).toEqual(["fast"]);
});
});
describe("springStep", () => {
it("overshoots the target slightly and settles", () => {
let state = { value: 0, velocity: 0 };
let peak = 0;
for (let frame = 0; frame < 120; frame += 1) {
state = springStep(state.value, state.velocity, 1, 1 / 60);
peak = Math.max(peak, state.value);
}
expect(peak).toBeGreaterThan(1.005);
expect(peak).toBeLessThan(1.08);
expect(state).toEqual({ value: 1, velocity: 0 });
});
it("is most of the way there after about 350 ms", () => {
let state = { value: 0, velocity: 0 };
for (let frame = 0; frame < 21; frame += 1) state = springStep(state.value, state.velocity, 1, 1 / 60);
expect(state.value).toBeGreaterThan(0.9);
});
it("is frame-rate independent enough for a long frame", () => {
const state = springStep(0, 0, 1, 1 / 15);
expect(state.value).toBeGreaterThan(0);
expect(state.value).toBeLessThan(1.1);
});
});
@@ -0,0 +1,78 @@
// Adaptive quality and the flight spring, as pure functions.
/** High: reflective floor, MSAA, particles, dispersion, depth of field when fast. Low: none of these. */
export type StageTier = "high" | "low";
/** Below this the scene drops to the low tier (no reflector, MSAA, depth of field, particles). */
export const SLOW_FPS = 45;
/** At or above this depth of field may run in focus mode (55, so a 60 Hz screen measuring ~59.9 counts). */
export const FAST_FPS = 55;
/** Seconds of continuous rendering per verdict. */
export const QUALITY_WINDOW_SECONDS = 2;
/** A longer gap is a pause of the demand frame loop, not a slow frame. */
const PAUSE_SECONDS = 0.25;
export type QualityVerdict = "slow" | "fast";
export interface QualityMonitor {
/** One rendered frame with its delta; returns a verdict when a window completes. */
push(deltaSeconds: number): QualityVerdict | null;
}
/**
* Watches frame times while the scene actually renders (demand loop: flights,
* idle motion). Equivalent to drei's PerformanceMonitor, without counting the
* pauses between demand frames as slow frames.
*/
export function createQualityMonitor(
slowFps: number = SLOW_FPS,
fastFps: number = FAST_FPS,
windowSeconds: number = QUALITY_WINDOW_SECONDS,
): QualityMonitor {
let time = 0;
let frames = 0;
return {
push(delta) {
if (!(delta > 0) || delta > PAUSE_SECONDS) {
time = 0;
frames = 0;
return null;
}
time += delta;
frames += 1;
if (time < windowSeconds) return null;
const fps = frames / time;
time = 0;
frames = 0;
if (fps < slowFps) return "slow";
if (fps >= fastFps) return "fast";
return null;
},
};
}
/** Spring for flights at motion `full`: a touch of overshoot (damping ratio ≈ 0.72), settled in ~0.5 s. */
export const SPRING_STIFFNESS = 210;
export const SPRING_DAMPING = 21;
const SPRING_SUBSTEP = 1 / 120;
const SPRING_EPSILON = 1e-3;
export interface SpringState {
value: number;
velocity: number;
}
/** One frame of a damped spring toward `target` (semi-implicit Euler with small substeps). */
export function springStep(value: number, velocity: number, target: number, deltaSeconds: number): SpringState {
let x = value;
let v = velocity;
let left = Math.max(0, deltaSeconds);
while (left > 0) {
const dt = Math.min(SPRING_SUBSTEP, left);
v += (SPRING_STIFFNESS * (target - x) - SPRING_DAMPING * v) * dt;
x += v * dt;
left -= dt;
}
if (Math.abs(target - x) < SPRING_EPSILON && Math.abs(v) < SPRING_EPSILON * 10) return { value: target, velocity: 0 };
return { value: x, velocity: v };
}
@@ -0,0 +1,111 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MAX_TURN, SPRING_BACK_MS, createRigPointer } from "./rig-pointer";
import type { PointerSample, RigInput } from "./rig-pointer";
function setup(tilt = true) {
const input: RigInput = { turn: 0, tiltX: 0, tiltY: 0, dragging: false };
const host = {
tilt,
rect: () => ({ left: 0, top: 0, width: 400, height: 300 }),
capture: vi.fn(),
release: vi.fn(),
changed: vi.fn(),
};
const pointer = createRigPointer(input, host);
return { input, host, pointer };
}
const sample = (clientX: number, extra: Partial<PointerSample> = {}): PointerSample => ({
clientX,
clientY: 100,
pointerId: 1,
pointerType: "mouse",
buttons: 1,
...extra,
});
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe("createRigPointer", () => {
it("turns on a horizontal drag and captures the pointer, so the release is seen outside the canvas", () => {
const { input, host, pointer } = setup();
pointer.down(sample(100));
pointer.move(sample(104));
expect(input.dragging).toBe(false);
pointer.move(sample(160));
expect(input.dragging).toBe(true);
expect(host.capture).toHaveBeenCalledWith(1);
expect(input.turn).toBeGreaterThan(0);
});
it("clamps the turn to ±60°", () => {
const { input, pointer } = setup();
pointer.down(sample(0));
pointer.move(sample(2000));
expect(input.turn).toBeCloseTo(MAX_TURN, 9);
});
it("ends a mouse drag whose button was released outside (buttons = 0) and springs back after 4 s", () => {
const { input, host, pointer } = setup();
pointer.down(sample(100));
pointer.move(sample(200));
expect(input.dragging).toBe(true);
// The pointer comes back over the canvas with no button pressed.
pointer.move(sample(260, { buttons: 0 }));
vi.advanceTimersByTime(1);
expect(input.dragging).toBe(false);
expect(host.release).toHaveBeenCalledWith(1);
const turned = input.turn;
expect(turned).toBeGreaterThan(0);
vi.advanceTimersByTime(SPRING_BACK_MS);
expect(input.turn).toBe(0);
});
it("springs back 4 s after a normal release, and a new press cancels it", () => {
const { input, pointer } = setup();
pointer.down(sample(100));
pointer.move(sample(200));
pointer.up(sample(200));
vi.advanceTimersByTime(SPRING_BACK_MS - 100);
pointer.down(sample(200));
vi.advanceTimersByTime(500);
expect(input.turn).toBeGreaterThan(0);
});
it("keeps dragging true until the click after the drag has run", () => {
const { input, pointer } = setup();
pointer.down(sample(100));
pointer.move(sample(200));
pointer.up(sample(200));
expect(input.dragging).toBe(true);
vi.advanceTimersByTime(0);
expect(input.dragging).toBe(false);
});
it("tilts toward a hovering mouse only when tilt is enabled, and levels on leave", () => {
const tilted = setup(true);
tilted.pointer.move(sample(400, { buttons: 0 }));
expect(tilted.input.tiltY).toBeGreaterThan(0);
tilted.pointer.leave();
expect(tilted.input.tiltY).toBe(0);
const still = setup(false);
still.pointer.move(sample(400, { buttons: 0 }));
expect(still.input.tiltY).toBe(0);
});
it("clears its timers on dispose", () => {
const { input, pointer } = setup();
pointer.down(sample(100));
pointer.move(sample(200));
pointer.up(sample(200));
pointer.dispose();
vi.advanceTimersByTime(SPRING_BACK_MS);
expect(input.turn).toBeGreaterThan(0);
});
});
@@ -0,0 +1,118 @@
// Pointer handling of the model rig (spec 9.4), without DOM or three.js so it
// can be tested: a horizontal drag turns the model ±60° (pointer captured, so
// a release outside the canvas still ends it), 4 s after the drag it springs
// back; a hovering mouse tilts it slightly (motion `full`).
export const MAX_TURN = (60 * Math.PI) / 180;
export const MAX_TILT = (6 * Math.PI) / 180;
export const SPRING_BACK_MS = 4000;
/** Pixels of horizontal travel before a press counts as a drag. */
const DRAG_START_PX = 8;
/** A drag across the full card width turns the model by this much. */
const TURN_PER_WIDTH = Math.PI;
export interface RigInput {
turn: number;
tiltX: number;
tiltY: number;
dragging: boolean;
}
export interface PointerSample {
clientX: number;
clientY: number;
pointerId: number;
pointerType: string;
/** Pressed buttons; 0 = none (a mouse released outside the canvas). */
buttons: number;
}
export interface RigPointerHost {
/** Pointer tilt allowed (motion `full`). */
tilt: boolean;
rect(): { left: number; top: number; width: number; height: number };
capture(pointerId: number): void;
release(pointerId: number): void;
/** Something changed: request a frame. */
changed(): void;
}
export interface RigPointer {
down(event: PointerSample): void;
move(event: PointerSample): void;
up(event?: Pick<PointerSample, "pointerId">): void;
leave(): void;
dispose(): void;
}
export function clampTurn(angle: number): number {
return Math.max(-MAX_TURN, Math.min(MAX_TURN, angle));
}
export function createRigPointer(input: RigInput, host: RigPointerHost): RigPointer {
let press: { x: number; y: number; turn: number; id: number; captured: boolean } | null = null;
let springBack: ReturnType<typeof setTimeout> | null = null;
let clearDrag: ReturnType<typeof setTimeout> | null = null;
const pointer: RigPointer = {
down(event) {
press = { x: event.clientX, y: event.clientY, turn: input.turn, id: event.pointerId, captured: false };
if (springBack) clearTimeout(springBack);
springBack = null;
},
move(event) {
if (press && press.id === event.pointerId) {
// The mouse button went up outside the canvas: end the drag now.
if (event.pointerType === "mouse" && event.buttons === 0) {
pointer.up(event);
return;
}
const dx = event.clientX - press.x;
const dy = event.clientY - press.y;
if (!input.dragging && Math.abs(dx) > DRAG_START_PX && Math.abs(dx) > Math.abs(dy)) {
input.dragging = true;
press.captured = true;
host.capture(press.id);
}
if (input.dragging) {
input.turn = clampTurn(press.turn + (dx / Math.max(host.rect().width, 1)) * TURN_PER_WIDTH);
host.changed();
}
return;
}
const rect = host.rect();
if (host.tilt && event.pointerType === "mouse" && rect.width > 0 && rect.height > 0) {
const nx = ((event.clientX - rect.left) / rect.width) * 2 - 1;
const ny = ((event.clientY - rect.top) / rect.height) * 2 - 1;
input.tiltY = nx * MAX_TILT;
input.tiltX = ny * MAX_TILT * 0.5;
host.changed();
}
},
up() {
if (!press) return;
if (press.captured) host.release(press.id);
if (input.dragging) {
springBack = setTimeout(() => {
input.turn = 0;
host.changed();
}, SPRING_BACK_MS);
}
press = null;
// Let the click that follows a drag see `dragging`, then clear it.
clearDrag = setTimeout(() => {
input.dragging = false;
}, 0);
},
leave() {
input.tiltX = 0;
input.tiltY = 0;
host.changed();
},
dispose() {
if (springBack) clearTimeout(springBack);
if (clearDrag) clearTimeout(clearDrag);
},
};
return pointer;
}
@@ -0,0 +1,82 @@
import {
BackSide,
BoxGeometry,
Color,
CubeCamera,
HalfFloatType,
LinearMipmapLinearFilter,
Mesh,
MeshBasicMaterial,
PlaneGeometry,
Scene,
WebGLCubeRenderTarget,
} from "three";
import type { CubeTexture, WebGLRenderer } from "three";
// A small procedural studio, rendered once into a cube map: dark navy room,
// a white soft box above, electric-blue rim strips behind, a cyan side strip
// and a few hot spots for glints on the chamfers. Cool light only (owner
// 2026-10-16); no HDR file is downloaded.
const ENV_SIZE = 128;
interface Lightformer {
size: [number, number];
position: [number, number, number];
color: string;
intensity: number;
}
const LIGHTFORMERS: Lightformer[] = [
{ size: [9, 6], position: [-1, 9.5, -2], color: "#ffffff", intensity: 2.6 },
{ size: [1.2, 12], position: [-3.5, 1, -9.5], color: "#5a9cff", intensity: 3 },
{ size: [1.2, 12], position: [3.5, 1, -9.5], color: "#5a9cff", intensity: 3 },
{ size: [0.7, 10], position: [9.5, 1, 1], color: "#5fd2ff", intensity: 2.4 },
{ size: [12, 3], position: [0, 3, 9.5], color: "#1b3f7a", intensity: 0.8 },
{ size: [14, 14], position: [0, -9.5, 0], color: "#0b1d3d", intensity: 1 },
{ size: [0.8, 0.8], position: [-6, 8, -6], color: "#ffffff", intensity: 9 },
{ size: [0.6, 0.6], position: [6, 7, 4], color: "#dff4ff", intensity: 7 },
];
export interface StudioEnvironment {
texture: CubeTexture;
dispose(): void;
}
/** Renders the studio once; null when the renderer cannot (test renderer, lost context). */
export function createStudioEnvironment(gl: WebGLRenderer): StudioEnvironment | null {
const scene = new Scene();
const room = new Mesh(new BoxGeometry(20, 20, 20), new MeshBasicMaterial({ color: "#050b19", side: BackSide }));
scene.add(room);
for (const light of LIGHTFORMERS) {
const material = new MeshBasicMaterial({
color: new Color(light.color).multiplyScalar(light.intensity),
toneMapped: false,
});
const panel = new Mesh(new PlaneGeometry(light.size[0], light.size[1]), material);
panel.position.set(...light.position);
// lookAt points the plane's +z (its front face) at the centre of the room.
panel.lookAt(0, 0, 0);
scene.add(panel);
}
const target = new WebGLCubeRenderTarget(ENV_SIZE, {
type: HalfFloatType,
generateMipmaps: true,
minFilter: LinearMipmapLinearFilter,
});
const camera = new CubeCamera(0.1, 40, target);
try {
camera.update(gl, scene);
} catch {
target.dispose();
return null;
} finally {
scene.traverse((object) => {
if (object instanceof Mesh) {
object.geometry.dispose();
(object.material as MeshBasicMaterial).dispose();
}
});
}
return { texture: target.texture, dispose: () => target.dispose() };
}
@@ -1,33 +1,71 @@
"use client";
import { createContext, useContext, useEffect, useState } from "react";
import { createContext, useContext, useEffect, useMemo, useState } from "react";
import type { ReactNode } from "react";
import { useThree } from "@react-three/fiber";
import { Mesh } from "three";
import type { CubeTexture } from "three";
import type { Vec3 } from "@/lib/hive/layout";
import type { MotionLevel } from "@/lib/motion/motion-preference";
import { createFrameTextureCache } from "./frame-textures";
import type { FrameTextureCache } from "./frame-textures";
import { createGeometryCache } from "./part-geometry";
import type { GeometryCache } from "./part-geometry";
import type { StageTier } from "./quality";
import { createStudioEnvironment } from "./scene-env";
import { readHoloTheme } from "./theme";
import type { HoloTheme } from "./theme";
// Shared per canvas: theme (tokens read once), geometries per part kind and
// size, comb textures per frame type, the motion level. Everything is
// disposed when the canvas unmounts (spec 9.9: no leak between colonies).
// size, comb textures per frame type, the studio cube map, motion level and
// quality tier. Everything is disposed when the canvas unmounts (spec 9.9:
// no leak between colonies).
/** Mutable per-frame hand-offs between scene parts (never drive rendering on their own). */
export interface SceneSignals {
/** The coming frame was requested by the idle timer, not by an animation (quality monitor skips it). */
timerFrame: boolean;
/** World point the camera frames in focus mode (depth of field focuses there). */
focusPoint: Vec3 | null;
}
export interface SceneKit {
theme: HoloTheme;
geometries: GeometryCache;
textures: FrameTextureCache;
motion: MotionLevel;
tier: StageTier;
/** Studio reflections; null until rendered or where the renderer cannot. */
env: CubeTexture | null;
signals: SceneSignals;
}
const SceneKitContext = createContext<SceneKit | null>(null);
export function SceneKitProvider({ motion, children }: { motion: MotionLevel; children: ReactNode }) {
export interface SceneKitProviderProps {
motion: MotionLevel;
tier?: StageTier;
children: ReactNode;
}
export function SceneKitProvider({ motion, tier = "high", children }: SceneKitProviderProps) {
const gl = useThree((state) => state.gl);
const invalidate = useThree((state) => state.invalidate);
const [theme] = useState(readHoloTheme);
const [geometries] = useState(createGeometryCache);
const [textures] = useState(() => createFrameTextureCache());
const [signals] = useState<SceneSignals>(() => ({ timerFrame: false, focusPoint: null }));
const [env, setEnv] = useState<CubeTexture | null>(null);
useEffect(() => {
const studio = createStudioEnvironment(gl);
// Rendered once after mount; the materials pick it up on the next render.
// eslint-disable-next-line react-hooks/set-state-in-effect -- the cube map needs the live renderer
setEnv(studio?.texture ?? null);
invalidate();
return () => studio?.dispose();
}, [gl, invalidate]);
useEffect(
() => () => {
geometries.dispose();
@@ -35,9 +73,11 @@ export function SceneKitProvider({ motion, children }: { motion: MotionLevel; ch
},
[geometries, textures],
);
return (
<SceneKitContext.Provider value={{ theme, geometries, textures, motion }}>{children}</SceneKitContext.Provider>
const kit = useMemo<SceneKit>(
() => ({ theme, geometries, textures, motion, tier, env, signals }),
[theme, geometries, textures, motion, tier, env, signals],
);
return <SceneKitContext.Provider value={kit}>{children}</SceneKitContext.Provider>;
}
export function useSceneKit(): SceneKit {
@@ -1,6 +1,7 @@
import type { Camera, Color, Material, Mesh, Scene, ShaderMaterial, Texture, WebGLRenderer } from "three";
import type { Vec3 } from "@/lib/hive/layout";
import { cameraPosition } from "./camera-fit";
import type { SceneSignals } from "./scene-kit";
// Imperative three.js updates used from frame callbacks and effects. They live
// outside the components on purpose: scene objects are mutable by design,
@@ -17,6 +18,12 @@ export function setUniformColor(material: ShaderMaterial, name: string, color: C
if (uniform) (uniform.value as Color).copy(color);
}
/** Sets a colour uniform to the blend of `from` and `to` at `t` (0…1). */
export function setUniformMix(material: ShaderMaterial, name: string, from: Color, to: Color, t: number): void {
const uniform = material.uniforms[name];
if (uniform) (uniform.value as Color).copy(from).lerp(to, Math.min(1, Math.max(0, t)));
}
export function setMaterialOpacity(material: Material, opacity: number): void {
material.opacity = opacity;
material.visible = opacity > 0.01;
@@ -29,6 +36,22 @@ export function placeCamera(camera: Camera, center: Vec3, distance: number): voi
camera.lookAt(center[0], center[1], center[2]);
}
/** Shifts the projection vertically by `offsetY` pixels (0 clears it). */
export function offsetView(camera: Camera, width: number, height: number, offsetY: number): void {
if (!("setViewOffset" in camera) || !(width > 0) || !(height > 0)) return;
const perspective = camera as Camera & {
setViewOffset(fw: number, fh: number, x: number, y: number, w: number, h: number): void;
clearViewOffset(): void;
view: { offsetY: number; enabled: boolean } | null;
};
if (Math.abs(offsetY) < 0.5) {
if (perspective.view?.enabled) perspective.clearViewOffset();
return;
}
if (perspective.view?.enabled && Math.abs(perspective.view.offsetY - offsetY) < 0.5) return;
perspective.setViewOffset(width, height, 0, offsetY, width, height);
}
export function setSceneBackground(scene: Scene, background: Texture | Color | null): void {
scene.background = background;
}
@@ -52,3 +75,37 @@ export function placeFill(
mesh.position.set(center[0], center[1] - size[1] / 2 + height / 2, center[2]);
return height;
}
/** Points an effect with a focus target (depth of field) at a world point. */
export function setEffectTarget(effect: { target: { set(x: number, y: number, z: number): unknown } | null }, point: Vec3): void {
effect.target?.set(point[0], point[1], point[2]);
}
export function setEffectBokeh(effect: { bokehScale: number }, scale: number): void {
if (effect.bokehScale !== scale) effect.bokehScale = scale;
}
export function markTimerFrame(signals: SceneSignals, value: boolean): void {
signals.timerFrame = value;
}
export function setFocusPoint(signals: SceneSignals, point: Vec3 | null): void {
signals.focusPoint = point;
}
/** Pointer capture on the canvas (missing in some test DOMs, refused for unknown ids). */
export function capturePointer(element: HTMLElement, pointerId: number): void {
try {
element.setPointerCapture?.(pointerId);
} catch {
// The pointer is gone already; buttons === 0 on the next move ends the drag instead.
}
}
export function releasePointer(element: HTMLElement, pointerId: number): void {
try {
if (element.hasPointerCapture?.(pointerId)) element.releasePointerCapture(pointerId);
} catch {
// Already released.
}
}
@@ -17,7 +17,9 @@ import { setUniform, setUniformColor } from "./scene-mutations";
import type { SceneLabel, ScenePart } from "./scene-model";
import { useAnimatedNode } from "./use-animated-node";
import type { AnimatedValues } from "./use-animated-node";
import { GLOW } from "./use-hive-motion";
import type { NodeTarget } from "./use-hive-motion";
import { useHoverSweep, useLivingEdges } from "./use-scene-effects";
// One part of the stack or one insert: glass body, glowing edges, screens,
// an invisible hit box for pointer events, its content in focus mode and its
@@ -48,14 +50,17 @@ export interface StackPartProps {
}
export function StackPart({ part, target, focused, handlers, labels, hintFrames, children }: StackPartProps) {
const { theme, geometries, motion } = useSceneKit();
const { theme, geometries, motion, env, tier } = useSceneKit();
const group = useRef<Group>(null);
const { kind, box, state, levels } = part;
const shape = geometries.shape(shapeKey(kind, box.size, state, levels.length), () =>
partShapeSpec(kind, box.size, state, levels.length),
);
const glass = useMemo(() => createGlassMaterial(theme, { frost: shape.frost }), [theme, shape.frost]);
const glass = useMemo(
() => createGlassMaterial(theme, { frost: shape.frost, env, dispersion: tier === "high" ? 0.03 : 0 }),
[theme, shape.frost, env, tier],
);
const edges = useMemo(() => createEdgeMaterial(theme), [theme]);
const grids = useMemo(
() => shape.grids.map((grid) => createGridMaterial(theme, { cell: grid.panel.cell, bars: grid.panel.bars })),
@@ -80,6 +85,8 @@ export function StackPart({ part, target, focused, handlers, labels, hintFrames,
grids.forEach((material) => setUniform(material, "uOpacity", opacity));
};
useAnimatedNode(group, { ...target, look: 0 }, motion, apply);
useLivingEdges([edges]);
useHoverSweep(glass, target.glow >= GLOW.hover, box.size[1]);
const showHints = hintFrames && hintFrames.length > 0 && !target.content;
const sy = box.size[1];
@@ -102,6 +109,7 @@ export function StackPart({ part, target, focused, handlers, labels, hintFrames,
{/* The hit box reaches down to the rest place, so a lifted part does not slip from under the pointer. */}
<mesh
position={[0, -target.lift / 2, 0]}
scale={[box.size[0], box.size[1] + target.lift, box.size[2]]}
raycast={target.interactive ? meshRaycast : noRaycast}
onPointerOver={handlers.onPointerOver}
onPointerOut={handlers.onPointerOut}
@@ -109,7 +117,7 @@ export function StackPart({ part, target, focused, handlers, labels, hintFrames,
onClick={handlers.onClick}
userData={{ hit: part.id }}
>
<boxGeometry args={[box.size[0], box.size[1] + target.lift, box.size[2]]} />
<boxGeometry args={[1, 1, 1]} />
{/* Focused: only back faces take hits, so inserts and frames in front stay reachable. */}
<meshBasicMaterial visible={false} side={focused ? BackSide : FrontSide} />
</mesh>
@@ -6,10 +6,11 @@ import { useFrame, useThree } from "@react-three/fiber";
import type { Object3D } from "three";
import type { Vec3 } from "@/lib/hive/layout";
import type { MotionLevel } from "@/lib/motion/motion-preference";
import { springStep } from "./quality";
import { SUBTLE_FADE_SECONDS, fadeToward, stepToward } from "./use-hive-motion";
// Moves a node toward its target (spec 9.8): `full` damps position, scale and
// fades; `subtle` jumps in space and fades opacity, glow and look in 150 ms;
// Moves a node toward its target (spec 9.8): `full` flies position and scale
// on a spring with a slight overshoot and eases the fades; `subtle` jumps in space and fades opacity, glow and look in 150 ms;
// `off` sets everything at once. Frames are only requested while moving.
/** After a pause the demand loop reports a long delta; never step more than this. */
@@ -30,8 +31,16 @@ export interface AnimatedValues {
look: number;
}
function stepFade(current: number, target: number, dt: number, motion: MotionLevel): number {
if (motion === "full") return stepToward(current, target, dt, true);
/** Opacity fades a little slower than the flight at `full`, so the ghost hive eases in. */
const OPACITY_RATE = 7;
function easeToward(current: number, target: number, dt: number, rate: number): number {
const next = current + (target - current) * (1 - Math.exp(-rate * dt));
return Math.abs(target - next) < 1e-3 ? target : next;
}
function stepFade(current: number, target: number, dt: number, motion: MotionLevel, rate?: number): number {
if (motion === "full") return rate ? easeToward(current, target, dt, rate) : stepToward(current, target, dt, true);
if (motion === "subtle") return fadeToward(current, target, dt, SUBTLE_FADE_SECONDS);
return target;
}
@@ -43,7 +52,13 @@ export function useAnimatedNode(
apply: (values: AnimatedValues) => void,
): void {
const invalidate = useThree((state) => state.invalidate);
const current = useRef<{ position: Vec3; scale: number; values: AnimatedValues } | null>(null);
const current = useRef<{
position: Vec3;
velocity: Vec3;
scale: number;
scaleVelocity: number;
values: AnimatedValues;
} | null>(null);
const latestApply = useRef(apply);
useEffect(() => {
latestApply.current = apply;
@@ -61,19 +76,34 @@ export function useAnimatedNode(
const goal: AnimatedValues = { opacity: target.opacity, glow: target.glow, look: target.look ?? 0 };
if (!current.current) {
// First frame: appear in place, no fly-in from the origin.
current.current = { position: [...target.position], scale: target.scale, values: goal };
current.current = {
position: [...target.position],
velocity: [0, 0, 0],
scale: target.scale,
scaleVelocity: 0,
values: goal,
};
} else {
const dt = Math.min(delta, MAX_FRAME_SECONDS);
const damped = motion === "full";
const state = current.current;
state.position = [
stepToward(state.position[0], target.position[0], dt, damped),
stepToward(state.position[1], target.position[1], dt, damped),
stepToward(state.position[2], target.position[2], dt, damped),
];
state.scale = stepToward(state.scale, target.scale, dt, damped);
if (motion === "full") {
// Flights on a spring with a touch of overshoot.
for (let axis = 0; axis < 3; axis += 1) {
const next = springStep(state.position[axis], state.velocity[axis], target.position[axis], dt);
state.position[axis] = next.value;
state.velocity[axis] = next.velocity;
}
const nextScale = springStep(state.scale, state.scaleVelocity, target.scale, dt);
state.scale = nextScale.value;
state.scaleVelocity = nextScale.velocity;
} else {
state.position = [...target.position];
state.velocity = [0, 0, 0];
state.scale = target.scale;
state.scaleVelocity = 0;
}
state.values = {
opacity: stepFade(state.values.opacity, goal.opacity, dt, motion),
opacity: stepFade(state.values.opacity, goal.opacity, dt, motion, OPACITY_RATE),
glow: stepFade(state.values.glow, goal.glow, dt, motion),
look: stepFade(state.values.look, goal.look, dt, motion),
};
@@ -0,0 +1,48 @@
"use client";
import { useRef } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import type { ShaderMaterial } from "three";
import { useSceneKit } from "./scene-kit";
import { setUniform } from "./scene-mutations";
import { MAX_FRAME_SECONDS } from "./use-animated-node";
// Small time-driven effects. All of them only move at motion `full`; the
// pulses ride on frames the idle motion already requests, the hover sweep
// asks for its own frames for about one second.
/** Duration of one hover sweep (shimmer and scan line), seconds. */
export const SWEEP_SECONDS = 1.1;
/** Travelling light pulses along the edges. */
export function useLivingEdges(materials: ShaderMaterial[]): void {
const { motion } = useSceneKit();
useFrame((state) => {
const pulse = motion === "full" ? 1 : 0;
for (const material of materials) {
setUniform(material, "uTime", state.clock.elapsedTime);
setUniform(material, "uPulse", pulse);
}
});
}
/** A holographic shimmer and a thin scan line run once across a part when it becomes hovered. */
export function useHoverSweep(material: ShaderMaterial, active: boolean, height: number): void {
const { motion } = useSceneKit();
const invalidate = useThree((state) => state.invalidate);
const clock = useRef(0);
useFrame((_, delta) => {
if (motion !== "full" || !active) {
if (clock.current !== 0) {
clock.current = 0;
setUniform(material, "uSweep", 0);
}
return;
}
clock.current += Math.min(delta, MAX_FRAME_SECONDS);
const sweep = Math.min(clock.current / SWEEP_SECONDS, 1);
setUniform(material, "uHeight", height);
setUniform(material, "uSweep", sweep >= 1 ? 0 : sweep);
if (sweep < 1) invalidate();
});
}
@@ -4,6 +4,7 @@ import dynamic from "next/dynamic";
import { useCallback, useEffect, useRef, useState } from "react";
import type { HiveLayout } from "@/lib/hive/layout";
import type { HiveCatalog, HiveState } from "@/lib/hive/types";
import type { OverlayInsets } from "./hive-scene/camera-fit";
import { canUseWebGL, shouldUse3D } from "@/lib/hive/webgl";
import { useEffectiveMotionLevel } from "@/lib/motion/motion-preference";
import type { MotionLevel } from "@/lib/motion/motion-preference";
@@ -25,6 +26,8 @@ export interface HiveVisualSlotProps {
partName: (id: string) => string;
onSelectPart: (id: string) => void;
onSelectFrame: (boxId: string, slot: number) => void;
/** Card pixels covered by the frosted overlays; the 3D camera fits the model between them. */
insets?: OverlayInsets;
}
function allows3D(motion: MotionLevel): boolean {
@@ -42,7 +45,15 @@ function allows3D(motion: MotionLevel): boolean {
* the pointer. Any failure to keep a context leaves the SVG, which is always
* rendered below.
*/
export function HiveVisualSlot({ layout, state, catalog, partName, onSelectPart, onSelectFrame }: HiveVisualSlotProps) {
export function HiveVisualSlot({
layout,
state,
catalog,
partName,
onSelectPart,
onSelectFrame,
insets,
}: HiveVisualSlotProps) {
const motion = useEffectiveMotionLevel();
const rootRef = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
@@ -91,6 +102,7 @@ export function HiveVisualSlot({ layout, state, catalog, partName, onSelectPart,
onSelectFrame={onSelectFrame}
onReady={onReady}
onContextLost={onContextLost}
insets={insets}
/>
</div>
) : null}
@@ -0,0 +1,76 @@
"use client";
import { useMemo, useState } from "react";
import { catalogFromSystem } from "@/lib/hive/facts";
import { hiveLayout } from "@/lib/hive/layout";
import { partDisplayName } from "@/lib/hive/names";
import { withPendingOps } from "@/lib/hive/pending";
import type { HiveSetupView, HiveSystemDto } from "@/lib/hive/types";
import { HiveFactsLine } from "./hive-facts-line";
import { HiveOutline } from "./hive-outline";
import { HivePendingNote } from "./hive-pending-note";
import { HiveSvg } from "./hive-svg";
import { usePendingHiveOps } from "./use-pending-hive";
import { createHiveViewStore, HiveViewProvider } from "./use-hive-view";
const NOTHING = (): void => undefined;
export interface OfflineHiveProps {
colonyId: string;
/** The colony's setup from the pack; null when the pack knows it has none. */
setup: HiveSetupView | null;
/** The hive systems of the pack. */
systems: readonly HiveSystemDto[];
}
/**
* The hive of a colony on the offline page, read-only (spec 10): the SVG and
* the outline, with waiting changes applied. No 3D, no editing.
*/
export function OfflineHive({ colonyId, setup, systems }: OfflineHiveProps) {
if (!setup) {
return <p className="text-muted-foreground">Noch keine Beute angelegt</p>;
}
const system = systems.find((candidate) => candidate.id === setup.systemId);
if (!system) {
return <p className="text-muted-foreground">Die Beute ist nicht offline geladen.</p>;
}
return <OfflineHiveView colonyId={colonyId} setup={setup} system={system} />;
}
function OfflineHiveView({
colonyId,
setup,
system,
}: {
colonyId: string;
setup: HiveSetupView;
system: HiveSystemDto;
}) {
const [store] = useState(createHiveViewStore);
const catalog = useMemo(() => catalogFromSystem(system), [system]);
const pending = usePendingHiveOps(colonyId);
const shown = useMemo(() => withPendingOps(setup, pending, catalog), [setup, pending, catalog]);
const layout = useMemo(() => hiveLayout(shown.state, catalog), [shown.state, catalog]);
const partName = (id: string) => partDisplayName(shown.state, catalog, id);
return (
<HiveViewProvider store={store}>
<div className="flex flex-col gap-3">
<HiveFactsLine facts={shown.facts} />
<HivePendingNote colonyId={colonyId} />
<div className="max-h-72">
<HiveSvg layout={layout} partName={partName} />
</div>
<HiveOutline
state={shown.state}
catalog={catalog}
canEdit={false}
announcement=""
onEditPart={NOTHING}
onEditFrame={NOTHING}
/>
</div>
</HiveViewProvider>
);
}
@@ -96,6 +96,21 @@ describe("PartSheet", () => {
expect(save()).toBeDisabled();
});
it("does not let its submit bubble into an enclosing form", async () => {
const outer = vi.fn((event: { preventDefault: () => void }) => event.preventDefault());
const user = userEvent.setup();
render(
<form onSubmit={outer}>
<Harness partId="fe" submit={vi.fn().mockResolvedValue(undefined)} />
</form>,
);
const field = within(dialog()).getByLabelText("Flüssigfutter");
await user.clear(field);
await user.type(field, "3");
await user.click(save());
expect(outer).not.toHaveBeenCalled();
});
it("saves only the changed level as setState", async () => {
const submit = vi.fn().mockResolvedValue(undefined);
const user = userEvent.setup();
@@ -178,7 +178,9 @@ function PartForm({ located, type, catalog, startWithEvaporator, startWithFrameF
<form
className="flex flex-col gap-4 px-4 pb-4"
onSubmit={(event) => {
// Portal forms bubble their submit through the React tree (e.g. into an enclosing form).
event.preventDefault();
event.stopPropagation();
if (ops.length > 0 && valid && !pending) void save();
}}
>
@@ -9,6 +9,9 @@ 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 { queueHiveChange } from "@/lib/offline/db";
import { hasQueuedHiveChange } from "@/lib/hive/pending";
import { syncPendingEntries } from "@/lib/offline/sync";
import { HIVE_BUSY_MESSAGE, useHiveMutation } from "./use-hive-mutation";
const push = vi.fn();
@@ -20,6 +23,21 @@ vi.mock("@/lib/hive/api", async (importOriginal) => ({
postHiveChange: vi.fn(),
}));
vi.mock("@/lib/hive/pending", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/hive/pending")>()),
hasQueuedHiveChange: vi.fn(),
}));
vi.mock("@/lib/offline/sync", () => ({ syncPendingEntries: vi.fn() }));
const auth = vi.hoisted(() => ({ userId: null as string | null }));
vi.mock("@/lib/auth/auth-context", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/auth/auth-context")>()),
useCurrentUserId: () => auth.userId,
}));
vi.mock("@/lib/offline/db", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/offline/db")>()),
queueHiveChange: vi.fn(),
}));
const catalog = inlineCatalog();
const COLONY = "colony-1";
@@ -69,6 +87,10 @@ beforeEach(() => {
vi.mocked(toast).mockReset();
vi.mocked(fetchColonyTreatments).mockReset().mockResolvedValue([]);
push.mockReset();
auth.userId = null;
vi.mocked(hasQueuedHiveChange).mockReset().mockResolvedValue(false);
vi.mocked(syncPendingEntries).mockReset().mockResolvedValue({ synced: 0, failed: 0, skippedForeign: 0 });
vi.mocked(queueHiveChange).mockReset().mockResolvedValue(undefined);
});
afterEach(() => vi.useRealTimers());
@@ -129,6 +151,90 @@ describe("useHiveMutation", () => {
expect(cached()?.state.stack[2].state.syrupLiters).toBe(2);
});
it("queues a card change that fails at network level, under the same key, and keeps the loaded state", async () => {
auth.userId = "user-1";
vi.mocked(postHiveChange).mockRejectedValue(new TypeError("Failed to fetch"));
const { hook, cached } = setup();
let result: HiveSetupView | null | undefined;
await act(async () => {
result = await hook.result.current.submit(refill, "CARD", "2026-10-15");
});
const sentKey = vi.mocked(postHiveChange).mock.calls[0][2];
expect(queueHiveChange).toHaveBeenCalledWith(
{ colonyId: COLONY, occurredOn: "2026-10-15", source: "CARD", ops: refill },
sentKey,
"user-1",
);
// The card shows the queued ops on top of this state, so it must not hold them twice.
expect(cached()?.state.stack[2].state.syrupLiters).toBe(2);
expect(result?.state.stack[2].state.syrupLiters).toBe(3);
});
it("queues an online change behind earlier queued changes of the colony instead of overtaking them", async () => {
auth.userId = "user-1";
vi.mocked(hasQueuedHiveChange).mockResolvedValue(true);
const { hook, cached } = setup();
let result: HiveSetupView | null | undefined;
await act(async () => {
result = await hook.result.current.submit(refill, "CARD", "2026-10-15");
});
expect(postHiveChange).not.toHaveBeenCalled();
expect(queueHiveChange).toHaveBeenCalledWith(
{ colonyId: COLONY, occurredOn: "2026-10-15", source: "CARD", ops: refill },
expect.any(String),
"user-1",
);
expect(syncPendingEntries).toHaveBeenCalledWith("user-1", { afterChange: true });
expect(cached()?.state.stack[2].state.syrupLiters).toBe(2);
expect(result?.state.stack[2].state.syrupLiters).toBe(3);
});
it("queues behind earlier queued changes even when the card has no setup cached", async () => {
auth.userId = "user-1";
vi.mocked(hasQueuedHiveChange).mockResolvedValue(true);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);
const hook = renderHook(() => useHiveMutation(COLONY, catalog), { wrapper });
let result: HiveSetupView | null | undefined;
await act(async () => {
result = await hook.result.current.submit(refill, "CARD", "2026-10-15");
});
expect(postHiveChange).not.toHaveBeenCalled();
expect(queueHiveChange).toHaveBeenCalledTimes(1);
expect(result).toBeNull();
});
it("does not queue server refusals or editor changes", async () => {
auth.userId = "user-1";
vi.mocked(postHiveChange).mockRejectedValue(new ApiError(400, "falsch", null));
const { hook } = setup();
await act(async () => {
await expect(hook.result.current.submit(refill, "CARD", "2026-10-15")).rejects.toThrow("falsch");
});
vi.mocked(postHiveChange).mockRejectedValue(new TypeError("Failed to fetch"));
await act(async () => {
await expect(hook.result.current.submit(refill, "EDITOR", "2026-10-15")).rejects.toThrow();
});
expect(queueHiveChange).not.toHaveBeenCalled();
});
it("does not queue without a signed-in user", async () => {
vi.mocked(postHiveChange).mockRejectedValue(new TypeError("Failed to fetch"));
const { hook } = setup();
await act(async () => {
await expect(hook.result.current.submit(refill, "CARD", "2026-10-15")).rejects.toThrow();
});
expect(queueHiveChange).not.toHaveBeenCalled();
});
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)));
@@ -5,11 +5,16 @@ import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { berlinCalendarDay } from "@/lib/dates";
import { ApiError } from "@/lib/auth/api-client";
import { useCurrentUserId } from "@/lib/auth/auth-context";
import { hiveConflictOf, hiveKeys, postHiveChange } from "@/lib/hive/api";
import { deriveHiveFacts } from "@/lib/hive/facts";
import { hasQueuedHiveChange } from "@/lib/hive/pending";
import { applyHiveOps } from "@/lib/hive/ops";
import { fetchColonyTreatments } from "@/lib/treatments/api";
import { queueHiveChange } from "@/lib/offline/db";
import { newIdempotencyKey } from "@/lib/offline/idempotency";
import { syncPendingEntries } from "@/lib/offline/sync";
import { OFFLINE_QUEUE_QUERY_KEY } from "@/lib/offline/use-capture-mutation";
import type {
ClientHiveChangeSource,
HiveApplyResult,
@@ -31,7 +36,12 @@ export interface HiveConflict {
}
export interface HiveMutation {
submit(ops: HiveOp[], source: ClientHiveChangeSource, occurredOn?: string): Promise<HiveSetupView>;
/** Resolves with the saved setup; null when the change was queued and no setup is loaded to show it on. */
submit(
ops: HiveOp[],
source: ClientHiveChangeSource,
occurredOn?: string,
): Promise<HiveSetupView | null>;
conflict: HiveConflict | null;
/** "retry" sends the ops again against the current state (new key); "discard" drops them. */
resolveConflict(action: "retry" | "discard"): Promise<void>;
@@ -66,10 +76,14 @@ function withState(view: HiveSetupView, state: HiveState, version: number, catal
* (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.
* A card change that fails at network level goes into the offline queue
* `pending-hive-changes` under the same key (spec 10); the card shows it from
* there, so the cache keeps the state it had.
*/
export function useHiveMutation(colonyId: string, catalog: HiveCatalog): HiveMutation {
const queryClient = useQueryClient();
const router = useRouter();
const ownerUserId = useCurrentUserId();
const [conflict, setConflict] = useState<HiveConflict | null>(null);
const [pendingCount, setPendingCount] = useState(0);
const lastAttempt = useRef<Attempt | null>(null);
@@ -105,7 +119,7 @@ export function useHiveMutation(colonyId: string, catalog: HiveCatalog): HiveMut
);
const run = useCallback(
async (attempt: Attempt): Promise<HiveSetupView> => {
async (attempt: Attempt): Promise<HiveSetupView | null> => {
if (inFlight.current) throw new Error(HIVE_BUSY_MESSAGE);
inFlight.current = true;
const { ops, source, occurredOn } = attempt;
@@ -117,6 +131,17 @@ export function useHiveMutation(colonyId: string, catalog: HiveCatalog): HiveMut
// A running refetch must not overwrite the optimistic state.
await queryClient.cancelQueries({ queryKey });
const previous = queryClient.getQueryData<HiveSetupView>(queryKey);
// Behind an earlier queued change of this colony it must not overtake: queue it too,
// whether or not the card has a setup loaded.
if (ownerUserId && source === "CARD" && (await hasQueuedHiveChange(colonyId, ownerUserId))) {
await queueHiveChange({ colonyId, occurredOn, source, ops }, key, ownerUserId);
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
// Online again, the queue goes out in order.
void syncPendingEntries(ownerUserId, { afterChange: true }).catch(() => undefined);
if (!previous) return null;
const queued = applyHiveOps(previous.state, ops, catalog);
return queued.ok ? withState(previous, queued.state, previous.version, catalog) : previous;
}
if (previous) {
const applied = applyHiveOps(previous.state, ops, catalog);
if (applied.ok) {
@@ -153,6 +178,16 @@ export function useHiveMutation(colonyId: string, catalog: HiveCatalog): HiveMut
ops,
preview: applyHiveOps(body.current.state, ops, catalog),
});
} else if (previous && ownerUserId && source === "CARD" && !(error instanceof ApiError)) {
// No network: queue it. The pending ops are shown on top of the loaded state.
await queueHiveChange({ colonyId, occurredOn, source, ops }, key, ownerUserId);
failedKey.current = null;
lastAttempt.current = null;
if (optimistic && queryClient.getQueryData(queryKey) === optimistic) {
queryClient.setQueryData(queryKey, previous);
}
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
return optimistic ?? previous;
} else {
failedKey.current = isAmbiguousFailure(error) ? { signature, key } : null;
// Restore only if nobody changed the cache since our optimistic write; then refetch.
@@ -168,7 +203,7 @@ export function useHiveMutation(colonyId: string, catalog: HiveCatalog): HiveMut
setPendingCount((count) => count - 1);
}
},
[catalog, colonyId, queryClient, queryKey, suggestFollowUps],
[catalog, colonyId, ownerUserId, queryClient, queryKey, suggestFollowUps],
);
const submit = useCallback(
@@ -0,0 +1,18 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useCurrentUserId } from "@/lib/auth/auth-context";
import { fetchHive, hiveKeys } from "@/lib/hive/api";
/**
* The colony's hive setup. Without network the colony's offline pack answers
* (read from this device, so the query never waits for a network).
*/
export function useHiveSetup(colonyId: string) {
const userId = useCurrentUserId();
return useQuery({
queryKey: hiveKeys.detail(colonyId),
queryFn: () => fetchHive(colonyId, userId),
networkMode: "always",
});
}
@@ -2,13 +2,20 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { useCurrentUserId } from "@/lib/auth/auth-context";
import { catalogFromSystem } from "@/lib/hive/facts";
import { fetchHiveSystems, hiveKeys } from "@/lib/hive/api";
import { fetchHiveSystems, fetchHiveSystemsWithFallback, 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 });
const userId = useCurrentUserId();
return useQuery({
queryKey: hiveKeys.systems,
queryFn: () => fetchHiveSystemsWithFallback(userId, fetchHiveSystems),
// Offline the catalog of the offline packs answers from this device.
networkMode: "always",
});
}
/** Catalog of one system, stable while the systems data stays the same. */
@@ -1,7 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { act, renderHook } from "@testing-library/react";
import type { ReactNode } from "react";
import { createHiveViewStore, focusedIdOf, HiveViewProvider, useHiveView } from "./use-hive-view";
import { createHiveViewStore, focusedIdOf, HiveViewProvider, useHiveView, useHiveViewStore } from "./use-hive-view";
describe("createHiveViewStore", () => {
it("starts empty and keeps the same state object until something changes", () => {
@@ -88,6 +88,24 @@ describe("useHiveView", () => {
expect(result.current.focusedPartId).toBe("fe");
});
it("gives the store without subscribing (no re-render on view changes)", () => {
const store = createHiveViewStore();
let renders = 0;
const wrapper = ({ children }: { children: ReactNode }) => (
<HiveViewProvider store={store}>{children}</HiveViewProvider>
);
const { result } = renderHook(
() => {
renders += 1;
return useHiveViewStore();
},
{ wrapper },
);
expect(result.current).toBe(store);
act(() => store.setHovered("x"));
expect(renders).toBe(1);
});
it("throws outside a provider", () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
expect(() => renderHook(() => useHiveView())).toThrow(/HiveViewProvider/);
@@ -90,6 +90,13 @@ export function HiveViewProvider({ store, children }: { store: HiveViewStore; ch
return createElement(HiveViewContext.Provider, { value: store }, children);
}
/** The store itself, for event handlers that only act (no re-render on view changes). */
export function useHiveViewStore(): HiveViewStore {
const store = useContext(HiveViewContext);
if (!store) throw new Error("useHiveViewStore must be used inside a HiveViewProvider");
return store;
}
export function useHiveView(): HiveView {
const store = useContext(HiveViewContext);
if (!store) throw new Error("useHiveView must be used inside a HiveViewProvider");
@@ -0,0 +1,44 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useCurrentUserId } from "@/lib/auth/auth-context";
import { hasRejectedHiveChange, pendingHiveChanges } from "@/lib/hive/pending";
import type { HiveOp } from "@/lib/hive/types";
import { OFFLINE_QUEUE_QUERY_KEY } from "@/lib/offline/use-capture-mutation";
const NO_CHANGES: never[] = [];
/** The colony's waiting hive changes (queue and queued inspections), read from this device. */
function usePendingHiveChanges(colonyId: string) {
const userId = useCurrentUserId();
const query = useQuery({
// Under the queue prefix, so a queued, sent or discarded entry refreshes it.
queryKey: [...OFFLINE_QUEUE_QUERY_KEY, userId, "hive", colonyId],
queryFn: () => pendingHiveChanges(colonyId, userId ?? ""),
enabled: userId !== null && colonyId !== "",
networkMode: "always",
});
return query.data ?? NO_CHANGES;
}
/** All ops waiting for the colony, in sending order: apply them with `withPendingOps`. */
export function usePendingHiveOps(colonyId: string): HiveOp[] {
return usePendingHiveChanges(colonyId).flatMap((change) => change.ops);
}
/** How many changes of the colony's hive wait for network. */
export function usePendingHiveCount(colonyId: string): number {
return usePendingHiveChanges(colonyId).length;
}
/** True while a queued hive change of the colony was refused and waits for the user. */
export function useHasRejectedHiveChange(colonyId: string): boolean {
const userId = useCurrentUserId();
const query = useQuery({
queryKey: [...OFFLINE_QUEUE_QUERY_KEY, userId, "hive", colonyId, "rejected"],
queryFn: () => hasRejectedHiveChange(colonyId, userId ?? ""),
enabled: userId !== null && colonyId !== "",
networkMode: "always",
});
return query.data ?? false;
}
@@ -6,6 +6,7 @@ import {
discardQueueItem,
listQueue,
retryBulkWithout,
retryInspectionWithoutHiveChange,
type QueueItem,
type StoredPhoto,
} from "@/lib/offline/db";
@@ -17,6 +18,7 @@ vi.mock("@/lib/offline/db", () => ({
listQueue: vi.fn(),
discardQueueItem: vi.fn(),
retryBulkWithout: vi.fn(),
retryInspectionWithoutHiveChange: vi.fn(),
}));
vi.mock("@/lib/offline/sync", () => ({ syncPendingEntries: vi.fn() }));
vi.mock("@/lib/photos/store", async (importOriginal) => ({
@@ -93,6 +95,7 @@ describe("PendingQueueSheet", () => {
vi.mocked(listQueue).mockReset().mockResolvedValue([REJECTED, WAITING]);
vi.mocked(discardQueueItem).mockReset().mockResolvedValue(undefined);
vi.mocked(retryBulkWithout).mockReset().mockResolvedValue(undefined);
vi.mocked(retryInspectionWithoutHiveChange).mockReset().mockResolvedValue(undefined);
vi.mocked(listPendingPhotos).mockReset().mockResolvedValue([]);
vi.mocked(discardReadyPhoto).mockReset().mockResolvedValue(undefined);
vi.mocked(resetPhotoError).mockReset().mockResolvedValue(undefined);
@@ -131,6 +134,54 @@ describe("PendingQueueSheet", () => {
);
});
const FORBIDDEN_HIVE: QueueItem = {
store: "pending-inspections",
id: 11,
label: "Durchsicht",
queuedAt: "2026-10-14T08:00:00.000Z",
hasHiveChange: true,
canResendWithoutHiveChange: true,
lastError: { status: 403, message: "Forbidden", at: "2026-10-14T09:00:00.000Z" },
};
it("words a 403 on a hive change and sends the inspection without it", async () => {
vi.mocked(listQueue).mockResolvedValue([FORBIDDEN_HIVE]);
renderSheet();
const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" });
expect(await within(sheet).findByText(/Keine Berechtigung, die Beute zu ändern/)).toBeInTheDocument();
await userEvent.click(within(sheet).getByRole("button", { name: "Ohne Beute-Änderung senden" }));
await waitFor(() => expect(retryInspectionWithoutHiveChange).toHaveBeenCalledWith(11));
await waitFor(() =>
expect(syncPendingEntries).toHaveBeenCalledWith("user-1", { afterChange: true }),
);
});
it("lists a rejected hive change with the conflict text and only the discard action", async () => {
vi.mocked(listQueue).mockResolvedValue([
{
store: "pending-hive-changes",
id: 12,
label: "Beute-Änderung",
queuedAt: "2026-10-14T08:00:00.000Z",
hasHiveChange: true,
lastError: {
status: 409,
message: "Konflikt",
details: { code: "HIVE_CONFLICT", opIndex: 0, reason: "MISSING_ID: x" },
at: "2026-10-14T09:00:00.000Z",
},
},
]);
renderSheet();
const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" });
expect(await within(sheet).findByText(/Die Beute wurde inzwischen geändert/)).toBeInTheDocument();
expect(within(sheet).queryByRole("button", { name: "Ohne Beute-Änderung senden" })).not.toBeInTheDocument();
expect(within(sheet).getByRole("button", { name: /^Verwerfen/ })).toBeInTheDocument();
});
it("names the button generally when a refused colony is not known by code", async () => {
vi.mocked(listQueue).mockResolvedValue([
{ ...REJECTED_BULK, resendWithout: { colonyIds: ["c-3", "c-4"], codes: ["V-3"] } },
@@ -12,10 +12,16 @@ import {
discardQueueItem,
listQueue,
retryBulkWithout,
retryInspectionWithoutHiveChange,
type QueueError,
type QueueItem,
} from "@/lib/offline/db";
import { entriesSynced, foreignEntriesWaiting, photosAttached, rejectionText } from "@/lib/offline/queue-texts";
import {
entriesSynced,
foreignEntriesWaiting,
photosAttached,
queueRejectionText,
} from "@/lib/offline/queue-texts";
import { syncPendingEntries } from "@/lib/offline/sync";
import { OFFLINE_QUEUE_QUERY_KEY } from "@/lib/offline/use-capture-mutation";
import { discardReadyPhoto, listPendingPhotos, resetPhotoError } from "@/lib/photos/store";
@@ -72,11 +78,17 @@ function discardDescription(item: QueueItem): string {
}
/** Rejected with the server's reason, or waiting - in words, never by colour alone. */
function QueueStatus({ lastError }: { lastError?: QueueError }) {
function QueueStatus({
lastError,
changesHive = false,
}: {
lastError?: QueueError;
changesHive?: boolean;
}) {
return lastError ? (
<p className="flex items-start gap-2 text-sm text-destructive">
<AlertTriangle className="mt-0.5 size-4 shrink-0" aria-hidden />
<span>Abgelehnt: {rejectionText(lastError.message)}</span>
<span>Abgelehnt: {queueRejectionText(lastError, changesHive)}</span>
</p>
) : (
<p className="flex items-start gap-2 text-sm text-muted-foreground">
@@ -202,6 +214,25 @@ export function PendingQueueSheet({
onError: () => toast.error("Eintrag konnte nicht erneut gesendet werden."),
});
// „Ohne Beute-Änderung senden“: the hive change leaves the inspection (same
// key, rejection cleared), then a normal run sends it.
const resendWithoutHive = useMutation({
mutationFn: async (id: number) => {
await retryInspectionWithoutHiveChange(id);
return syncPendingEntries(userId, { afterChange: true });
},
networkMode: "always",
onSuccess: (result) => {
if (result.synced > 0) {
toast.success(entriesSynced(result.synced));
void queryClient.invalidateQueries();
return;
}
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
},
onError: () => toast.error("Eintrag konnte nicht erneut gesendet werden."),
});
const names = entryNames(items);
const photoNames = entryNames(
photos.map((photo) => ({ label: PHOTO_LABEL, queuedAt: photo.createdAt })),
@@ -250,7 +281,20 @@ export function PendingQueueSheet({
isPending={discard.isPending}
/>
</div>
<QueueStatus lastError={item.lastError} />
<QueueStatus lastError={item.lastError} changesHive={item.hasHiveChange} />
{item.canResendWithoutHiveChange && typeof item.id === "number" ? (
<Button
type="button"
variant="outline"
size="touch"
className="self-start"
disabled={resendWithoutHive.isPending}
onClick={() => resendWithoutHive.mutate(item.id as number)}
>
<RotateCw aria-hidden />
Ohne Beute-Änderung senden
</Button>
) : null}
{item.resendWithout && typeof item.id === "number" ? (
<ResendWithoutButton
resend={item.resendWithout}
@@ -0,0 +1,115 @@
"use client";
import type { ReactNode } from "react";
import Link from "next/link";
import { Mic, Square } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useDictation, type UseDictationResult } from "./use-dictation";
interface DictationButtonProps {
fieldLabel: string;
/** Receives the recognized text; the caller appends it, never replaces. */
onAppend(text: string): void;
}
/** The state as words; colour alone never carries it. */
function statusText(dictation: UseDictationResult): string | null {
const { state, online, onDevice, failure } = dictation;
switch (state) {
case "listening":
return "Hört zu …";
case "needs-consent":
return "Cloud-Erkennung zuerst erlauben.";
case "error":
return failure === "microphone"
? "Mikrofon nicht erlaubt. Bitte tippen."
: "Diktat nicht möglich. Bitte tippen.";
case "idle":
if (!online) {
return onDevice ? "Nur auf dem Gerät" : "Offline nicht möglich";
}
return null;
default:
return null;
}
}
/**
* Microphone button next to a free-text field. It only exists where the
* browser can recognize speech and either the device or the user's consent
* covers it.
*/
export function DictationButton({ fieldLabel, onAppend }: DictationButtonProps) {
const dictation = useDictation({ onFinal: onAppend });
const { state, interim, start, stop, support, online, onDevice } = dictation;
if (!support) {
return null;
}
if (support.isInstalledIosPwa) {
return (
<p className="text-xs text-muted-foreground">Hier lieber eine Sprachnotiz aufnehmen</p>
);
}
if (state === "unavailable") {
return null;
}
const listening = state === "listening";
const text = statusText(dictation);
const offlineBlocked = !online && !onDevice;
return (
<div className="flex shrink-0 flex-col items-end gap-1">
{listening ? (
<Button
type="button"
variant="default"
size="touch"
onClick={stop}
aria-label={`Stopp: Diktat für ${fieldLabel} beenden`}
>
<Square aria-hidden="true" />
Stopp
</Button>
) : (
<Button
type="button"
variant="outline"
size="touch"
onClick={start}
disabled={offlineBlocked}
aria-label={`Diktieren: ${fieldLabel}`}
>
<Mic aria-hidden="true" />
Diktieren
</Button>
)}
<p role="status" aria-live="polite" className="min-h-4 text-xs text-muted-foreground">
{text}
</p>
{listening && interim ? (
<p className="max-w-56 text-right text-xs italic text-muted-foreground/70">{interim}</p>
) : null}
{state === "needs-consent" ? (
<Link href="/settings/voice" className="text-xs underline underline-offset-2">
Zu „Sprache &amp; Datenschutz“
</Link>
) : null}
</div>
);
}
interface DictationRowProps extends DictationButtonProps {
children: ReactNode;
}
/** The field and its dictation button side by side. */
export function DictationRow({ children, ...button }: DictationRowProps) {
return (
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1">{children}</div>
<DictationButton {...button} />
</div>
);
}
@@ -0,0 +1,241 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { DictationButton } from "./dictation-button";
import { appendDictation } from "@/lib/voice/append-text";
import { setCloudRecognitionConsent } from "@/lib/voice/consent";
class FakeRecognition {
static instances: FakeRecognition[] = [];
static localAnswer: string | undefined = "available";
static available = vi.fn(async () => FakeRecognition.localAnswer);
lang = "";
continuous = false;
interimResults = false;
maxAlternatives = 1;
declare processLocally?: boolean;
handlers: Record<string, (event: unknown) => void> = {};
static throwOnStart = false;
start = vi.fn(() => {
if (FakeRecognition.throwOnStart) {
throw new Error("InvalidStateError");
}
});
stop = vi.fn();
abort = vi.fn();
constructor() {
FakeRecognition.instances.push(this);
}
set onresult(fn: (event: unknown) => void) {
this.handlers.result = fn;
}
set onend(fn: (event: unknown) => void) {
this.handlers.end = fn;
}
set onerror(fn: (event: unknown) => void) {
this.handlers.error = fn;
}
set onaudiostart(fn: (event: unknown) => void) {
this.handlers.audiostart = fn;
}
}
function speak(final: string, interim = "") {
const engine = FakeRecognition.instances.at(-1)!;
const results: unknown[] = [];
if (final) {
results.push(Object.assign([{ transcript: final }], { isFinal: true }));
}
if (interim) {
results.push(Object.assign([{ transcript: interim }], { isFinal: false }));
}
act(() => engine.handlers.result({ resultIndex: 0, results }));
}
function Harness({ initial = "" }: { initial?: string }) {
const [value, setValue] = useState(initial);
return (
<div>
<label htmlFor="notes-field">Notizen</label>
<input
id="notes-field"
value={value}
onChange={(event) => setValue(event.target.value)}
/>
<DictationButton
fieldLabel="Notizen"
onAppend={(text) => setValue((current) => appendDictation(current, text))}
/>
</div>
);
}
async function renderSettled(ui: React.ReactElement) {
const rendered = render(ui);
// detectSpeechSupport resolves on a microtask; let it land.
await act(async () => {});
return rendered;
}
describe("DictationButton", () => {
beforeEach(() => {
window.localStorage.clear();
FakeRecognition.instances = [];
FakeRecognition.localAnswer = "available";
FakeRecognition.available.mockClear();
(window as unknown as Record<string, unknown>).SpeechRecognition = FakeRecognition;
vi.spyOn(window.navigator, "onLine", "get").mockReturnValue(true);
});
afterEach(() => {
delete (window as unknown as Record<string, unknown>).SpeechRecognition;
vi.restoreAllMocks();
});
it("hides the button when the browser has no speech recognition", async () => {
delete (window as unknown as Record<string, unknown>).SpeechRecognition;
await renderSettled(<Harness />);
expect(screen.queryByRole("button", { name: /Diktieren/ })).not.toBeInTheDocument();
});
it("appends and never overwrites", async () => {
const user = userEvent.setup();
await renderSettled(<Harness initial="Alt" />);
await user.click(screen.getByRole("button", { name: /Diktieren/ }));
speak("neu");
expect(screen.getByLabelText("Notizen")).toHaveValue("Alt neu");
const engine = FakeRecognition.instances[0];
expect(engine.lang).toBe("de-DE");
expect(engine.continuous).toBe(false);
expect(engine.interimResults).toBe(true);
expect(engine.processLocally).toBe(true);
});
it("starts the field from empty without a leading space", async () => {
const user = userEvent.setup();
await renderSettled(<Harness />);
await user.click(screen.getByRole("button", { name: /Diktieren/ }));
speak("Königin gesehen");
expect(screen.getByLabelText("Notizen")).toHaveValue("Königin gesehen");
});
it("offers cloud recognition only after consent", async () => {
FakeRecognition.localAnswer = "unavailable";
const first = await renderSettled(<Harness />);
expect(screen.queryByRole("button", { name: /Diktieren/ })).not.toBeInTheDocument();
first.unmount();
setCloudRecognitionConsent(true);
await renderSettled(<Harness />);
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: /Diktieren/ }));
expect(FakeRecognition.instances[0].processLocally).toBeUndefined();
});
it("shows the state as text, not only colour", async () => {
const user = userEvent.setup();
await renderSettled(<Harness />);
await user.click(screen.getByRole("button", { name: /Diktieren/ }));
speak("", "und Stifte");
expect(screen.getByRole("status")).toHaveTextContent("Hört zu …");
expect(screen.getByText("und Stifte")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Stopp/ })).toBeInTheDocument();
});
it("says „Nur auf dem Gerät“ while offline", async () => {
vi.spyOn(window.navigator, "onLine", "get").mockReturnValue(false);
await renderSettled(<Harness />);
expect(screen.getByRole("status")).toHaveTextContent("Nur auf dem Gerät");
expect(screen.getByRole("button", { name: /Diktieren/ })).toBeEnabled();
});
it("disables dictation offline when only the cloud would work", async () => {
FakeRecognition.localAnswer = "downloadable";
setCloudRecognitionConsent(true);
vi.spyOn(window.navigator, "onLine", "get").mockReturnValue(false);
await renderSettled(<Harness />);
expect(screen.getByRole("button", { name: /Diktieren/ })).toBeDisabled();
expect(screen.getByRole("status")).toHaveTextContent("Offline nicht möglich");
});
it("stops on the stop button and releases the recognizer", async () => {
const user = userEvent.setup();
const { unmount } = await renderSettled(<Harness />);
await user.click(screen.getByRole("button", { name: /Diktieren/ }));
await user.click(screen.getByRole("button", { name: /Stopp/ }));
const engine = FakeRecognition.instances[0];
expect(engine.stop).toHaveBeenCalledTimes(1);
act(() => engine.handlers.end({}));
await waitFor(() =>
expect(screen.getByRole("button", { name: /Diktieren/ })).toBeInTheDocument(),
);
// A second session that is still running when the page goes away is aborted.
await user.click(screen.getByRole("button", { name: /Diktieren/ }));
const second = FakeRecognition.instances[1];
unmount();
expect(second.abort).toHaveBeenCalled();
});
it("points installed iOS PWAs to a voice note instead of the button", async () => {
vi.spyOn(window.navigator, "userAgent", "get").mockReturnValue(
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)",
);
Object.defineProperty(window.navigator, "standalone", { value: true, configurable: true });
await renderSettled(<Harness />);
expect(screen.queryByRole("button", { name: /Diktieren/ })).not.toBeInTheDocument();
expect(screen.getByText("Hier lieber eine Sprachnotiz aufnehmen")).toBeInTheDocument();
Reflect.deleteProperty(window.navigator, "standalone");
});
it("reports a refused microphone as text", async () => {
const user = userEvent.setup();
await renderSettled(<Harness />);
await user.click(screen.getByRole("button", { name: /Diktieren/ }));
act(() => FakeRecognition.instances[0].handlers.error({ error: "not-allowed" }));
expect(screen.getByRole("status")).toHaveTextContent("Mikrofon nicht erlaubt");
});
it("shows the German error text when the engine cannot be built or started", async () => {
const user = userEvent.setup();
await renderSettled(<Harness />);
FakeRecognition.throwOnStart = true;
await user.click(screen.getByRole("button", { name: /Diktieren/ }));
expect(screen.getByRole("status")).toHaveTextContent("Diktat nicht möglich");
FakeRecognition.throwOnStart = false;
});
it("re-reads the consent at start, so a withdrawal on the settings page counts", async () => {
FakeRecognition.localAnswer = "unavailable";
setCloudRecognitionConsent(true);
const user = userEvent.setup();
await renderSettled(<Harness />);
setCloudRecognitionConsent(false);
await user.click(screen.getByRole("button", { name: /Diktieren/ }));
expect(FakeRecognition.instances).toHaveLength(0);
expect(screen.getByRole("status")).toHaveTextContent("Cloud-Erkennung zuerst erlauben");
});
});
@@ -0,0 +1,167 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import {
createRecognition,
detectSpeechSupport,
type RecognitionHandle,
type SpeechSupport,
} from "@/lib/voice/speech-support";
import { getCloudRecognitionConsent } from "@/lib/voice/consent";
import { VOICE_VOCABULARY } from "@/lib/voice/vocabulary";
export type DictationState = "idle" | "listening" | "unavailable" | "needs-consent" | "error";
/** Why the last session failed; only set in state `error`. */
export type DictationFailure = "microphone" | "other";
interface UseDictationOptions {
onFinal(text: string): void;
}
export interface UseDictationResult {
state: DictationState;
interim: string;
start(): void;
stop(): void;
/** `null` until the browser has been asked. */
support: SpeechSupport | null;
/** Recognition runs on the device (no audio leaves it). */
onDevice: boolean;
online: boolean;
failure: DictationFailure | null;
}
const MICROPHONE_ERRORS = new Set(["not-allowed", "audio-capture"]);
const CONSENT_ERRORS = new Set(["service-not-allowed", "language-not-supported"]);
const QUIET_ERRORS = new Set(["no-speech", "aborted"]);
function useOnline(): boolean {
const [online, setOnline] = useState(true);
useEffect(() => {
const sync = () => setOnline(window.navigator.onLine);
sync();
window.addEventListener("online", sync);
window.addEventListener("offline", sync);
return () => {
window.removeEventListener("online", sync);
window.removeEventListener("offline", sync);
};
}, []);
return online;
}
function useSpeechSupport(): SpeechSupport | null {
const [support, setSupport] = useState<SpeechSupport | null>(null);
useEffect(() => {
let cancelled = false;
void detectSpeechSupport().then((detected) => {
if (!cancelled) {
setSupport(detected);
}
});
return () => {
cancelled = true;
};
}, []);
return support;
}
/**
* One-shot browser dictation (`continuous: false`). Recognition runs on the
* device when the engine says it can; otherwise only with the stored cloud
* consent. Results are handed out, never stored here.
*/
export function useDictation({ onFinal }: UseDictationOptions): UseDictationResult {
const support = useSpeechSupport();
const online = useOnline();
const [phase, setPhase] = useState<"idle" | "listening" | "needs-consent" | "error">("idle");
const [failure, setFailure] = useState<DictationFailure | null>(null);
const [interim, setInterim] = useState("");
const handleRef = useRef<RecognitionHandle | null>(null);
const onFinalRef = useRef(onFinal);
useEffect(() => {
onFinalRef.current = onFinal;
}, [onFinal]);
const onDevice = support?.local === "available";
const usable = support !== null && support.api && (onDevice || support.cloudAllowed);
const release = useCallback(() => {
handleRef.current = null;
setInterim("");
}, []);
const start = useCallback(() => {
if (handleRef.current || !support || !usable) {
return;
}
// The switch on the settings page may have changed since detection.
const cloudAllowed = getCloudRecognitionConsent();
if (!onDevice && !cloudAllowed) {
setPhase("needs-consent");
return;
}
let handle: RecognitionHandle | null = null;
try {
handle = createRecognition({
lang: "de-DE",
continuous: false,
interimResults: true,
maxAlternatives: 1,
processLocally: onDevice,
phrases: VOICE_VOCABULARY,
});
if (!handle) {
return;
}
handleRef.current = handle;
setFailure(null);
handle.on("result", (event) => {
setInterim(event.interimText);
if (event.finalText) {
onFinalRef.current(event.finalText);
}
});
handle.on("end", () => {
release();
setPhase((current) => (current === "listening" ? "idle" : current));
});
handle.on("error", (code) => {
release();
if (QUIET_ERRORS.has(code)) {
setPhase("idle");
} else if (CONSENT_ERRORS.has(code) && !getCloudRecognitionConsent()) {
setPhase("needs-consent");
} else {
setFailure(MICROPHONE_ERRORS.has(code) ? "microphone" : "other");
setPhase("error");
}
});
setPhase("listening");
handle.start();
} catch {
// Any failure while building or starting the engine shows as text.
release();
setFailure("other");
setPhase("error");
}
}, [support, usable, onDevice, release]);
const stop = useCallback(() => {
// The engine still delivers a pending result and then fires "end".
handleRef.current?.stop();
}, []);
useEffect(
() => () => {
handleRef.current?.abort();
handleRef.current = null;
},
[],
);
const state: DictationState = !usable && phase !== "needs-consent" ? "unavailable" : phase;
return { state, interim, start, stop, support, onDevice, online, failure };
}
@@ -1,5 +1,6 @@
import type { ApiaryStatus } from "./types";
import type { NextStep, SeasonSegment } from "@/lib/autopilot/types";
import type { HiveSetupView, HiveSystemDto } from "@/lib/hive/types";
import type { Colony, ColonyCondition, ColonyStatus } from "@/lib/colonies/types";
import type { Inspection } from "@/lib/inspections/types";
import type { Queen } from "@/lib/queens/types";
@@ -97,4 +98,11 @@ export interface OfflinePackV1 {
* returns as `steps`). Absent in packs cached before T4.
*/
nextSteps?: Record<string, NextStep[]>;
/**
* The hive setup per colony id: only colonies of this apiary that have one.
* Absent in packs cached before the hive card.
*/
hives?: Record<string, HiveSetupView>;
/** The hive systems with their part types. Absent in older packs. */
hiveCatalog?: HiveSystemDto[];
}
@@ -120,6 +120,8 @@ interface PackOptions {
inspections?: OfflinePackV1["inspections"];
tasks?: OfflinePackV1["tasks"];
inspectionsVisible?: boolean;
hives?: OfflinePackV1["hives"];
hiveCatalog?: OfflinePackV1["hiveCatalog"];
}
/** A pack as the server sends it: colonies, their overview rows and the sections. */
@@ -131,6 +133,8 @@ export function offlinePack({
inspections = [],
tasks = [],
inspectionsVisible = true,
hives,
hiveCatalog,
}: PackOptions = {}): OfflinePackV1 {
return {
version: 1,
@@ -145,6 +149,9 @@ export function offlinePack({
queens,
inspections,
tasks,
// Like the server: absent in packs from before the hive card.
...(hives ? { hives } : {}),
...(hiveCatalog ? { hiveCatalog } : {}),
};
}
+60
View File
@@ -1,11 +1,15 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ApiError, apiFetch } from "@/lib/auth/api-client";
import { listPacks } from "@/lib/apiaries/offline-pack";
import { offlinePack, packColony, storedPack } from "@/lib/apiaries/offline-pack.fixtures";
import { setupView, systemDto } from "@/components/hive/test-fixtures";
import {
createHive,
fetchHive,
fetchHiveAt,
fetchHiveHistory,
fetchHiveSystems,
fetchHiveSystemsWithFallback,
fetchHiveTemplate,
hiveConflictOf,
hiveErrorCode,
@@ -18,6 +22,17 @@ vi.mock("@/lib/auth/api-client", async (importOriginal) => {
return { ...original, apiFetch: vi.fn() };
});
vi.mock("@/lib/apiaries/offline-pack", () => ({ listPacks: vi.fn() }));
const NETWORK_DOWN = new TypeError("Failed to fetch");
const packWithHive = storedPack(
offlinePack({
colonies: [packColony("colony-1", "V-1"), packColony("colony-2", "V-2")],
hives: { "colony-1": setupView() },
hiveCatalog: [systemDto()],
}),
);
describe("hive api", () => {
beforeEach(() => {
vi.mocked(apiFetch).mockReset();
@@ -66,4 +81,49 @@ describe("hive api", () => {
expect(hiveErrorCode(new ApiError(404, "nf", { code: "HIVE_NOT_FOUND" }))).toBe("HIVE_NOT_FOUND");
expect(hiveErrorCode(new ApiError(500, "x"))).toBeNull();
});
describe("offline reading", () => {
beforeEach(() => {
vi.mocked(listPacks).mockReset().mockResolvedValue([packWithHive]);
vi.mocked(apiFetch).mockRejectedValue(NETWORK_DOWN);
});
it("answers the hive from the pack when the network fails", async () => {
await expect(fetchHive("colony-1", "user-1")).resolves.toEqual(setupView());
expect(listPacks).toHaveBeenCalledWith("user-1");
});
it("answers HIVE_NOT_FOUND for a colony the pack knows has no setup", async () => {
const error = await fetchHive("colony-2", "user-1").catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(ApiError);
expect(hiveErrorCode(error)).toBe("HIVE_NOT_FOUND");
});
it("passes the network error on when the pack cannot tell (older pack, unknown colony)", async () => {
vi.mocked(listPacks).mockResolvedValue([storedPack(offlinePack())]);
await expect(fetchHive("colony-1", "user-1")).rejects.toBe(NETWORK_DOWN);
vi.mocked(listPacks).mockResolvedValue([packWithHive]);
await expect(fetchHive("unknown", "user-1")).rejects.toBe(NETWORK_DOWN);
});
it("never covers up a server answer with the pack", async () => {
vi.mocked(apiFetch).mockRejectedValue(new ApiError(404, "nf", { code: "HIVE_NOT_FOUND" }));
await expect(fetchHive("colony-1", "user-1")).rejects.toBeInstanceOf(ApiError);
expect(listPacks).not.toHaveBeenCalled();
});
it("reads the server only without a user", async () => {
await expect(fetchHive("colony-1")).rejects.toBe(NETWORK_DOWN);
expect(listPacks).not.toHaveBeenCalled();
});
it("falls back to the catalog of the pack for the systems", async () => {
await expect(fetchHiveSystemsWithFallback("user-1")).resolves.toEqual([systemDto()]);
});
it("passes the network error on when no pack carries a catalog", async () => {
vi.mocked(listPacks).mockResolvedValue([storedPack(offlinePack())]);
await expect(fetchHiveSystemsWithFallback("user-1")).rejects.toBe(NETWORK_DOWN);
});
});
});
+33 -3
View File
@@ -1,5 +1,7 @@
import { ApiError, apiFetch } from "@/lib/auth/api-client";
import { idempotencyHeaders } from "@/lib/offline/idempotency";
import { withPackFallback } from "@/lib/offline/pack-fallback";
import { hiveCatalogFromPacks, hiveFromPacks } from "./pack";
import type {
CreateHiveInput,
HiveAtView,
@@ -19,13 +21,41 @@ export const hiveKeys = {
const colonyPath = (colonyId: string) => `/colonies/${encodeURIComponent(colonyId)}/hive`;
/** Every system with its part types (usable as a plain queryFn). */
export function fetchHiveSystems(): Promise<HiveSystemDto[]> {
return apiFetch<HiveSystemDto[]>("/hive-systems");
}
/** 404 HIVE_NOT_FOUND when the colony has no setup yet. */
export function fetchHive(colonyId: string): Promise<HiveSetupView> {
return apiFetch<HiveSetupView>(colonyPath(colonyId));
/**
* Like fetchHiveSystems; when the network fails, the catalog of the user's
* offline packs answers (spec 10).
*/
export function fetchHiveSystemsWithFallback(
ownerUserId: string | null,
fromServer: () => Promise<HiveSystemDto[]> = fetchHiveSystems,
): Promise<HiveSystemDto[]> {
if (!ownerUserId) return fromServer();
return withPackFallback(fromServer, hiveCatalogFromPacks, ownerUserId);
}
/**
* 404 HIVE_NOT_FOUND when the colony has no setup yet. Only when the network
* fails, the colony's offline pack answers - also with that 404 when the pack
* knows the colony has no setup.
*/
export function fetchHive(colonyId: string, ownerUserId: string | null = null): Promise<HiveSetupView> {
const fromServer = () => apiFetch<HiveSetupView>(colonyPath(colonyId));
if (!ownerUserId) return fromServer();
return withPackFallback(
fromServer,
(packs) => {
const packed = hiveFromPacks(packs, colonyId);
if (!packed) return null;
if (!packed.setup) throw new ApiError(404, "Hive not found", { code: "HIVE_NOT_FOUND" });
return packed.setup;
},
ownerUserId,
);
}
/** Template of a system with fresh ids; 409 TEMPLATE_NOT_SET while it is empty. */
+3 -3
View File
@@ -1,5 +1,5 @@
import { apiFetch } from "@/lib/auth/api-client";
import type { HiveBoxDepth, HivePartKind, HivePartTypeDto, HiveSystemDto, StateField } from "./types";
import type { HiveBoxDepth, HiveTemplate, HivePartKind, HivePartTypeDto, HiveSystemDto, StateField } from "./types";
// Admin writes of the hive catalog (hive-systems:manage). Reads stay in api.ts
// (`fetchHiveSystems`); callers refresh them via `hiveKeys.systems`.
@@ -12,8 +12,8 @@ export interface HiveSystemInput {
footprintWMm: number;
}
/** Template editing is not part of this screen (it is saved from the editor). */
export type HiveSystemPatch = Partial<HiveSystemInput> & { active?: boolean };
/** `template` is saved from the hive editor („Als Vorlage speichern“), not from the catalog screen. */
export type HiveSystemPatch = Partial<HiveSystemInput> & { active?: boolean; template?: HiveTemplate };
export interface HivePartTypeInput {
kind: HivePartKind;
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { offlinePack, packColony, storedPack } from "@/lib/apiaries/offline-pack.fixtures";
import { setupView, systemDto } from "@/components/hive/test-fixtures";
import { hiveCatalogFromPacks, hiveFromPacks } from "./pack";
const withHive = storedPack(
offlinePack({
apiaryId: "apiary-1",
colonies: [packColony("c1", "V-1"), packColony("c2", "V-2")],
hives: { c1: setupView(undefined, "c1") },
hiveCatalog: [systemDto()],
}),
{ savedAt: "2026-10-13T10:00:00.000Z" },
);
const oldPack = storedPack(offlinePack({ apiaryId: "apiary-2", colonies: [packColony("c9", "V-9")] }), {
savedAt: "2026-10-01T10:00:00.000Z",
});
describe("hive pack readers", () => {
it("returns the setup of a colony in the pack", () => {
expect(hiveFromPacks([withHive, oldPack], "c1")?.setup?.colonyId).toBe("c1");
});
it("tells a colony without a hive from a pack that cannot tell", () => {
expect(hiveFromPacks([withHive], "c2")).toEqual({ setup: null });
expect(hiveFromPacks([oldPack], "c9")).toBeNull();
expect(hiveFromPacks([withHive], "unknown")).toBeNull();
});
it("prefers the newest pack that lists the colony", () => {
const newer = storedPack(
offlinePack({
apiaryId: "apiary-3",
colonies: [packColony("c1", "V-1")],
hives: { c1: setupView(undefined, "c1", 9) },
}),
{ savedAt: "2026-10-14T10:00:00.000Z" },
);
expect(hiveFromPacks([withHive, newer], "c1")?.setup?.version).toBe(9);
});
it("takes the catalog from the newest pack that has one", () => {
expect(hiveCatalogFromPacks([oldPack, withHive])?.[0].id).toBe("sys");
expect(hiveCatalogFromPacks([oldPack])).toBeNull();
});
});
+30
View File
@@ -0,0 +1,30 @@
import type { StoredPack } from "@/lib/offline/db";
import type { HiveSetupView, HiveSystemDto } from "./types";
// What the offline packs say about hives (spec 10). Both fields are optional:
// a pack from before the hive card says nothing, and "nothing" is not "no hive".
function newestFirst(packs: readonly StoredPack[]): StoredPack[] {
return [...packs].sort((a, b) => b.savedAt.localeCompare(a.savedAt));
}
/**
* The colony's hive per its newest pack. `null`: no pack lists the colony or
* the pack predates hives, so it cannot tell. `{ setup: null }`: the pack
* knows the colony and the colony has no hive yet.
*/
export function hiveFromPacks(
packs: readonly StoredPack[],
colonyId: string,
): { setup: HiveSetupView | null } | null {
const holding = newestFirst(packs).find((stored) =>
stored.pack.colonies.some((colony) => colony.id === colonyId),
);
if (!holding?.pack.hives) return null;
return { setup: holding.pack.hives[colonyId] ?? null };
}
/** The hive systems of the newest pack that carries them; null when none does. */
export function hiveCatalogFromPacks(packs: readonly StoredPack[]): HiveSystemDto[] | null {
return newestFirst(packs).find((stored) => stored.pack.hiveCatalog)?.pack.hiveCatalog ?? null;
}
@@ -0,0 +1,107 @@
import "fake-indexeddb/auto";
import { IDBFactory } from "fake-indexeddb";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { frames, inlineCatalog, part } from "@/lib/hive/test-utils";
import type { HiveOp, HiveSetupView, HiveState } from "@/lib/hive/types";
import { deriveHiveFacts } from "./facts";
type DbModule = typeof import("@/lib/offline/db");
type PendingModule = typeof import("./pending");
const catalog = inlineCatalog();
const COLONY = "colony-1";
const day = "2026-10-15";
const state: HiveState = {
schemaVersion: 1,
stack: [part("f", "floor"), part("b", "box", { frames: frames(10) }), part("r", "roof")],
};
const setup: HiveSetupView = {
colonyId: COLONY,
systemId: "sys",
version: 1,
state,
facts: deriveHiveFacts(state, catalog),
updatedAt: "2026-10-15T10:00:00.000Z",
};
const label = (value: string): HiveOp => ({ op: "setLabel", id: "b", label: value });
let db: DbModule;
let pending: PendingModule;
async function hiveChange(ops: HiveOp[], owner = "user-1", colonyId = COLONY) {
await db.queueHiveChange({ colonyId, occurredOn: day, source: "CARD", ops }, `k-${Math.random()}`, owner);
}
const inspection = (colonyId = COLONY, hive?: HiveOp[]) => ({
colonyId,
date: "2026-10-15T00:00:00.000Z",
...(hive ? { hiveChange: { ops: hive } } : {}),
});
/** Entries queued in the same millisecond keep their order only by the clock moving. */
async function tick(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 3));
}
describe("pending hive changes", () => {
beforeEach(async () => {
globalThis.indexedDB = new IDBFactory();
vi.resetModules();
db = await import("@/lib/offline/db");
pending = await import("./pending");
});
it("merges both queues in queuedAt order", async () => {
await hiveChange([label("eins")]);
await tick();
await db.queueInspection(inspection(COLONY, [label("zwei")]), "k-i", "user-1");
await tick();
await hiveChange([label("drei")]);
const ops = await pending.pendingHiveOps(COLONY, "user-1");
expect(ops).toEqual([label("eins"), label("zwei"), label("drei")]);
});
it("ignores other colonies, other owners, inspections without hive change and rejected entries", async () => {
await hiveChange([label("andere Wabe")], "user-1", "colony-2");
await hiveChange([label("fremd")], "user-2");
await db.queueInspection(inspection(COLONY), "k-plain", "user-1");
await hiveChange([label("abgelehnt")]);
const rejected = (await db.getPendingEntries("pending-hive-changes")).find(
(entry) => JSON.stringify(entry.input.ops).includes("abgelehnt"),
);
await db.markFailed("pending-hive-changes", rejected?.id ?? -1, { status: 409, message: "x", at: day });
expect(await pending.pendingHiveOps(COLONY, "user-1")).toEqual([]);
expect(await pending.pendingHiveChanges(COLONY, "user-1")).toHaveLength(0);
});
it("classifies an entry as either waiting or rejected, never both", async () => {
await hiveChange([label("a")]);
expect(await pending.hasRejectedHiveChange(COLONY, "user-1")).toBe(false);
const [entry] = await db.getPendingEntries("pending-hive-changes");
await db.markFailed("pending-hive-changes", entry.id, { status: 409, message: "x", at: day });
expect(await pending.hasRejectedHiveChange(COLONY, "user-1")).toBe(true);
expect(await pending.pendingHiveChanges(COLONY, "user-1")).toHaveLength(0);
});
it("counts the waiting changes, not their ops", async () => {
await hiveChange([label("a"), label("b")]);
expect(await pending.pendingHiveChanges(COLONY, "user-1")).toHaveLength(1);
});
it("applies waiting ops to the loaded setup", () => {
const shown = pending.withPendingOps(setup, [label("Brut")], catalog);
expect(shown.state.stack[1].label).toBe("Brut");
expect(shown.version).toBe(1);
});
it("shows the setup unchanged when the ops do not fit it", () => {
const bad: HiveOp = { op: "removePart", id: "gone" };
expect(pending.withPendingOps(setup, [bad], catalog)).toBe(setup);
expect(pending.withPendingOps(setup, [], catalog)).toBe(setup);
});
});
+93
View File
@@ -0,0 +1,93 @@
import { getPendingEntries, hasHiveChange, isForeignEntry } from "@/lib/offline/db";
import type { PendingEntry } from "@/lib/offline/db";
import { deriveHiveFacts } from "./facts";
import { applyHiveOps } from "./ops";
import type { HiveCatalog, HiveOp, HiveSetupView } from "./types";
/** One waiting change of a colony's hive: a card change, or the hive part of a queued inspection. */
export interface PendingHiveChange {
ops: HiveOp[];
queuedAt: string;
}
function opsOfInspection(input: unknown): HiveOp[] {
return (input as { hiveChange?: { ops?: HiveOp[] } }).hiveChange?.ops ?? [];
}
function isWaiting(entry: PendingEntry<unknown>, ownerUserId: string): boolean {
// A rejected entry is not waiting for network: the queue sheet shows it.
return !isForeignEntry(entry, ownerUserId) && !entry.lastError;
}
/** True when a queued hive change of the colony was refused by the server and still waits for the user. */
export async function hasRejectedHiveChange(colonyId: string, ownerUserId: string): Promise<boolean> {
const [hive, inspections] = await Promise.all([
getPendingEntries("pending-hive-changes"),
getPendingEntries("pending-inspections"),
]);
const rejected = (entry: PendingEntry<{ colonyId: string }>) =>
!isForeignEntry(entry, ownerUserId) && entry.input.colonyId === colonyId && !!entry.lastError;
return hive.some(rejected) || inspections.some((entry) => rejected(entry) && hasHiveChange(entry.input));
}
/**
* True while any hive change of the colony is queued for the user, rejected
* ones included: a new change must queue behind it, not overtake it.
*/
export async function hasQueuedHiveChange(colonyId: string, ownerUserId: string): Promise<boolean> {
const [hive, inspections] = await Promise.all([
getPendingEntries("pending-hive-changes"),
getPendingEntries("pending-inspections"),
]);
const mine = (entry: PendingEntry<{ colonyId: string }>) =>
!isForeignEntry(entry, ownerUserId) && entry.input.colonyId === colonyId;
return hive.some(mine) || inspections.some((entry) => mine(entry) && hasHiveChange(entry.input));
}
/**
* The colony's waiting hive changes of the user, oldest first, from both
* sources: the queue `pending-hive-changes` and the `hiveChange` of queued
* inspections.
*/
export async function pendingHiveChanges(
colonyId: string,
ownerUserId: string,
): Promise<PendingHiveChange[]> {
const [hive, inspections] = await Promise.all([
getPendingEntries("pending-hive-changes"),
getPendingEntries("pending-inspections"),
]);
const fromHive = hive
.filter((entry) => isWaiting(entry, ownerUserId) && entry.input.colonyId === colonyId)
.map((entry) => ({ ops: entry.input.ops, queuedAt: entry.queuedAt }));
const fromInspections = inspections
.filter(
(entry) =>
isWaiting(entry, ownerUserId) &&
entry.input.colonyId === colonyId &&
hasHiveChange(entry.input),
)
.map((entry) => ({ ops: opsOfInspection(entry.input), queuedAt: entry.queuedAt }));
return [...fromHive, ...fromInspections].sort((a, b) => a.queuedAt.localeCompare(b.queuedAt));
}
/** All ops of the colony's waiting hive changes, in the order they will be sent. */
export async function pendingHiveOps(colonyId: string, ownerUserId: string): Promise<HiveOp[]> {
return (await pendingHiveChanges(colonyId, ownerUserId)).flatMap((change) => change.ops);
}
/**
* The setup with the waiting ops applied, for the card. When they do not fit
* the loaded state (it moved on), the setup is shown as it is - the queue
* reports the conflict when it is sent.
*/
export function withPendingOps(
setup: HiveSetupView,
ops: readonly HiveOp[],
catalog: HiveCatalog,
): HiveSetupView {
if (ops.length === 0) return setup;
const applied = applyHiveOps(setup.state, [...ops], catalog);
if (!applied.ok) return setup;
return { ...setup, state: applied.state, facts: deriveHiveFacts(applied.state, catalog) };
}
+8
View File
@@ -66,6 +66,14 @@ export interface HivePart {
slot?: number;
}
/** A stored template part: no ids (the server stamps fresh ones when a hive starts from it). */
export type HiveTemplatePart = Omit<HivePart, "id" | "inserts"> & { inserts?: HiveTemplatePart[] };
export interface HiveTemplate {
schemaVersion: 1;
stack: HiveTemplatePart[];
}
export interface HiveState {
schemaVersion: 1;
stack: HivePart[];
@@ -1,4 +1,5 @@
import type { ColonyCondition } from "@/lib/colonies/types";
import type { HiveOp } from "@/lib/hive/types";
export interface Inspection {
id: string;
@@ -79,6 +80,12 @@ export interface CreateInspectionInput {
* server, which answers 201 either way.
*/
applyToColony?: ColonyTakeoverField[];
/**
* Hive changes made in the „Beute“ section (1-100 ops), saved with the
* inspection in one transaction. Creating only; needs colonies:update
* (403 HIVE_FORBIDDEN otherwise). Sent only when there are ops.
*/
hiveChange?: { ops: HiveOp[] };
}
/** `null` clears an optional field, an omitted one stays unchanged. */
+92 -5
View File
@@ -95,7 +95,7 @@ describe("offline queue database", () => {
it.each([
["a fresh install (0)", 0],
["version 1", 1],
])("opening from %s creates the plant report store at version 4", async (_name, from) => {
])("opening from %s creates the plant report store at version 5", async (_name, from) => {
if (from === 1) {
const old = await openDB(DB_NAME, 1, {
upgrade(database) {
@@ -120,7 +120,7 @@ describe("offline queue database", () => {
);
const upgraded = await openDB(DB_NAME);
expect(upgraded.version).toBe(4);
expect(upgraded.version).toBe(5);
expect([...upgraded.objectStoreNames]).toContain("pending-phenology-reports");
expect(await upgraded.count("pending-phenology-reports")).toBe(1);
expect(await upgraded.count("pending-inspections")).toBe(from === 1 ? 1 : 0);
@@ -166,11 +166,11 @@ describe("offline queue database", () => {
expect((await db.listQueue("user-1")).map((item) => item.label)).toContain("Pflanzenmeldung");
const upgraded = await openDB(DB_NAME);
expect(upgraded.version).toBe(4);
expect(upgraded.version).toBe(5);
upgraded.close();
});
it("upgrading v2 → v4 keeps already queued inspections", async () => {
it("upgrading v2 → v5 keeps already queued inspections", async () => {
await createVersion2Queue({
input: INSPECTION,
idempotencyKey: "key-from-v2",
@@ -192,7 +192,7 @@ describe("offline queue database", () => {
expect(await db.summarizeQueue("user-1")).toEqual({ own: 1, failed: 0, foreign: 0 });
const upgraded = await openDB(DB_NAME);
expect(upgraded.version).toBe(4);
expect(upgraded.version).toBe(5);
expect([...upgraded.objectStoreNames]).toEqual(
expect.arrayContaining([
...VERSION_2_STORES,
@@ -201,6 +201,7 @@ describe("offline queue database", () => {
"offline-packs",
"meta",
"pending-phenology-reports",
"pending-hive-changes",
]),
);
expect([...upgraded.transaction("pending-photos").store.indexNames].sort()).toEqual([
@@ -211,6 +212,92 @@ describe("offline queue database", () => {
upgraded.close();
});
it("db upgrade 4 -> 5 keeps every store and entry and adds the hive change queue", async () => {
const old = await openDB(DB_NAME, 4, {
upgrade(database) {
for (const store of [...VERSION_2_STORES, "pending-bulk", "pending-phenology-reports"]) {
database.createObjectStore(store, { keyPath: "id", autoIncrement: true });
}
database.createObjectStore("pending-photos", { keyPath: "id" });
database.createObjectStore("offline-packs", { keyPath: "apiaryId" });
database.createObjectStore("meta");
},
});
await old.add("pending-inspections", { input: INSPECTION, queuedAt: "2026-10-01T08:00:00.000Z" });
await old.add("pending-phenology-reports", { input: {}, queuedAt: "2026-10-01T09:00:00.000Z" });
old.close();
const db = await loadDb();
await db.queueHiveChange(
{ colonyId: "colony-1", occurredOn: "2026-10-15", source: "CARD", ops: [] },
"key-h",
"user-1",
);
const [hive] = await db.getPendingEntries("pending-hive-changes");
expect(hive).toMatchObject({ idempotencyKey: "key-h", ownerUserId: "user-1" });
const upgraded = await openDB(DB_NAME);
expect(upgraded.version).toBe(5);
expect(await upgraded.count("pending-inspections")).toBe(1);
expect(await upgraded.count("pending-phenology-reports")).toBe(1);
expect([...upgraded.objectStoreNames]).toEqual(
expect.arrayContaining(["pending-bulk", "pending-photos", "offline-packs", "meta", "pending-hive-changes"]),
);
upgraded.close();
});
it("lists a hive change under its own name and marks inspections that carry one", async () => {
const db = await loadDb();
await db.queueHiveChange(
{ colonyId: "colony-1", occurredOn: "2026-10-15", source: "CARD", ops: [] },
"key-h",
"user-1",
);
await db.queueInspection({ ...INSPECTION, hiveChange: { ops: [] } }, "key-i", "user-1");
await db.queueInspection(INSPECTION, "key-j", "user-1");
const items = await db.listQueue("user-1");
expect(items.map((item) => [item.label, item.hasHiveChange ?? false]).sort()).toEqual([
["Beute-Änderung", true],
["Durchsicht", false],
["Durchsicht", true],
]);
});
it("retryInspectionWithoutHiveChange strips the hive change, clears the rejection and keeps the key", async () => {
const db = await loadDb();
await db.queueInspection({ ...INSPECTION, hiveChange: { ops: [] } }, "key-i", "user-1");
const [entry] = await db.getPendingEntries("pending-inspections");
await db.markFailed("pending-inspections", entry.id, { ...REJECTION, status: 403 });
expect((await db.listQueue("user-1"))[0].canResendWithoutHiveChange).toBe(true);
await db.retryInspectionWithoutHiveChange(entry.id);
const [after] = await db.getPendingEntries("pending-inspections");
expect(after.input).toEqual(INSPECTION);
expect(after.lastError).toBeUndefined();
expect(after.idempotencyKey).toBe("key-i");
});
it("offers „ohne Beute-Änderung“ only for a 403 or a hive conflict, not for any other rejection", async () => {
const db = await loadDb();
await db.queueInspection({ ...INSPECTION, hiveChange: { ops: [] } }, "k1", "user-1");
await db.queueInspection({ ...INSPECTION, hiveChange: { ops: [] } }, "k2", "user-1");
const [plain, conflict] = await db.getPendingEntries("pending-inspections");
await db.markFailed("pending-inspections", plain.id, REJECTION);
await db.markFailed("pending-inspections", conflict.id, {
status: 409,
message: "Konflikt",
details: { code: "HIVE_CONFLICT" },
at: REJECTION.at,
});
const items = await db.listQueue("user-1");
expect(items.map((item) => item.canResendWithoutHiveChange ?? false)).toEqual([false, true]);
});
it("summarizeQueue separates own, failed and foreign entries", async () => {
const db = await loadDb();
await db.queueInspection(INSPECTION, "key-1", "user-1");
+74 -4
View File
@@ -8,12 +8,21 @@ import { CreateVarroaMeasurementInput } from "@/lib/varroa/types";
import { CreateSwarmControlCheckInput } from "@/lib/swarm-control/types";
import { CreateHarvestInput } from "@/lib/harvests/types";
import type { CreatePhenologyReportInput } from "@/lib/phenology/types";
import type { HiveOp } from "@/lib/hive/types";
import {
colonyIdsOfBulkInput,
invalidColonyIdsOfBody,
withoutColonies,
} from "@/lib/bulk/bulk-input";
/** A change of a colony's hive made on the card, waiting for network. */
export interface HiveChangeQueueInput {
colonyId: string;
occurredOn: string;
source: "CARD";
ops: HiveOp[];
}
/** What each single-record queue (one per quick-capture form) holds. */
export interface RecordQueueInputs {
"pending-inspections": CreateInspectionInput;
@@ -23,6 +32,7 @@ export interface RecordQueueInputs {
"pending-swarm-control-checks": CreateSwarmControlCheckInput;
"pending-harvests": CreateHarvestInput;
"pending-phenology-reports": CreatePhenologyReportInput;
"pending-hive-changes": HiveChangeQueueInput;
}
export type RecordStoreName = keyof RecordQueueInputs;
/** Every store whose entries wait to be sent and are shown in the queue sheet. */
@@ -110,11 +120,11 @@ export interface OfflineDB extends DBSchema, RecordStores {
const DB_NAME = "apicore-offline";
// v1 only had "pending-inspections", v2 added the other five quick-capture
// types, v3 adds bulk entries, photos, offline packs and meta, v4 adds plant
// reports (Pflanzenentwicklung). Entries already
// reports (Pflanzenentwicklung), v5 adds hive changes (Beute). Entries already
// queued on a phone must survive every upgrade, so each store is only created
// if it doesn't exist yet (guarded by oldVersion, per idb's upgrade()
// convention), never recreated.
const DB_VERSION = 4;
const DB_VERSION = 5;
const RECORD_STORES: readonly RecordStoreName[] = [
"pending-inspections",
@@ -124,6 +134,7 @@ const RECORD_STORES: readonly RecordStoreName[] = [
"pending-swarm-control-checks",
"pending-harvests",
"pending-phenology-reports",
"pending-hive-changes",
];
const QUEUE_STORES: readonly QueueStoreName[] = [...RECORD_STORES, "pending-bulk"];
@@ -135,6 +146,7 @@ const RECORD_LABELS: Record<RecordStoreName, string> = {
"pending-swarm-control-checks": "Schwarmkontrolle",
"pending-harvests": "Ernte",
"pending-phenology-reports": "Pflanzenmeldung",
"pending-hive-changes": "Beute-Änderung",
};
const BULK_LABELS: Record<BulkKind, string> = {
treatments: "Behandlung für mehrere Völker",
@@ -183,6 +195,9 @@ export function getDb(): Promise<IDBPDatabase<OfflineDB>> {
autoIncrement: true,
});
}
if (oldVersion < 5) {
db.createObjectStore("pending-hive-changes", { keyPath: "id", autoIncrement: true });
}
},
// A newer app version in another tab wants to upgrade: step aside
// instead of blocking it. The next call here opens the new version.
@@ -282,6 +297,14 @@ export function queuePhenologyReport(
return queue("pending-phenology-reports", input, idempotencyKey, ownerUserId);
}
export function queueHiveChange(
input: HiveChangeQueueInput,
idempotencyKey: string,
ownerUserId: string,
): Promise<void> {
return queue("pending-hive-changes", input, idempotencyKey, ownerUserId);
}
/**
* One entry for several colonies. The key belongs to this exact selection of
* colonies: it is stored with the entry and sent again unchanged.
@@ -445,6 +468,37 @@ export async function summarizeQueue(currentUserId: string): Promise<QueueSummar
);
}
/** The server refused the hive part: 403, or a conflict with the hive's current state. */
function isHiveRefusal(error: QueueError | undefined): boolean {
if (!error) return false;
const code = (error.details as { code?: unknown } | null | undefined)?.code;
return error.status === 403 || code === "HIVE_CONFLICT";
}
/** A queued inspection that also carries a change of the hive. */
export function hasHiveChange(input: unknown): boolean {
return (input as { hiveChange?: unknown } | null)?.hiveChange !== undefined;
}
/**
* Ohne Beute-Änderung senden: takes the hive change out of a queued
* inspection and clears its rejection, so the next sync run sends the rest.
* The key stays: the server stored nothing for the rejected try.
*/
export async function retryInspectionWithoutHiveChange(entryId: number): Promise<void> {
const db = await getDb();
const tx = db.transaction("pending-inspections", "readwrite");
const entry = await tx.store.get(entryId);
if (entry && hasHiveChange(entry.input)) {
const input = { ...entry.input };
delete input.hiveChange;
const cleared: PendingEntry<CreateInspectionInput> = { ...entry, input };
delete cleared.lastError;
await tx.store.put(cleared);
}
await tx.done;
}
export interface QueueItem {
store: string;
id: number | string;
@@ -452,6 +506,10 @@ export interface QueueItem {
queuedAt: string;
lastError?: QueueError;
photoCount?: number;
/** The entry changes a colony's hive (a hive entry, or an inspection with a hive change). */
hasHiveChange?: boolean;
/** A rejected inspection whose hive change can be left out and the rest sent. */
canResendWithoutHiveChange?: boolean;
/**
* A rejected bulk entry that can go out without the colonies the server
* refused (`codes`: the ones named in the loaded packs, maybe fewer).
@@ -501,19 +559,31 @@ export async function listQueue(currentUserId: string): Promise<QueueItem[]> {
queuedAt: entry.queuedAt,
...(entry.lastError ? { lastError: entry.lastError } : {}),
};
if (store === "pending-hive-changes") {
return { ...item, hasHiveChange: true };
}
if (store === "pending-bulk") {
const resendWithout = await resendWithoutOf(db, entry as PendingBulkEntry);
return resendWithout ? { ...item, resendWithout } : item;
}
if (store !== "pending-inspections" || !entry.idempotencyKey) {
if (store !== "pending-inspections") {
return item;
}
const hive = hasHiveChange((entry as PendingEntry<unknown>).input)
? {
hasHiveChange: true,
...(isHiveRefusal(entry.lastError) ? { canResendWithoutHiveChange: true } : {}),
}
: {};
if (!entry.idempotencyKey) {
return { ...item, ...hive };
}
const photoCount = await db.countFromIndex(
"pending-photos",
"inspectionIdempotencyKey",
entry.idempotencyKey,
);
return photoCount > 0 ? { ...item, photoCount } : item;
return photoCount > 0 ? { ...item, ...hive, photoCount } : { ...item, ...hive };
}),
);
return items.sort((a, b) => a.queuedAt.localeCompare(b.queuedAt));
@@ -7,6 +7,7 @@ import {
foreignEntriesWaiting,
photosAttached,
photosNotTakenOver,
queueRejectionText,
rejectionText,
sessionExpiredWithEntries,
} from "./queue-texts";
@@ -56,4 +57,32 @@ describe("rejectionText", () => {
it("passes other server reasons through", () => {
expect(rejectionText("Datei zu groß")).toBe("Datei zu groß");
});
describe("queueRejectionText", () => {
const conflict = {
status: 409,
message: "Conflict",
details: { code: "HIVE_CONFLICT", opIndex: 0, reason: "MISSING_ID: no part" },
};
it("words a 403 on a hive change", () => {
expect(queueRejectionText({ status: 403, message: "Forbidden" }, true)).toBe(
"Keine Berechtigung, die Beute zu ändern.",
);
});
it("words a HIVE_CONFLICT with the reason", () => {
expect(queueRejectionText(conflict, true)).toBe(
"Die Beute wurde inzwischen geändert. Ein Teil, auf das sich die Änderung bezieht, gibt es nicht mehr.",
);
});
it("leaves every other rejection as it was", () => {
expect(queueRejectionText({ status: 403, message: "Forbidden" }, false)).toBe("Forbidden");
expect(queueRejectionText(conflict, false)).toBe("Conflict");
expect(
queueRejectionText({ status: 409, message: "Idempotency-Key reused", details: { code: "X" } }, true),
).toBe("Idempotency-Key reused");
});
});
});
@@ -1,3 +1,5 @@
import { hiveReasonText } from "@/lib/hive/reason-text";
// German counts for the offline queue: "1 Eintrag wartet", "2 Einträge warten".
function count(n: number, one: string, many: string): string {
@@ -42,6 +44,38 @@ export function rejectionText(message: string): string {
return message;
}
/** What the queue sheet needs of a rejection to word it. */
interface RejectionInfo {
status: number;
message: string;
details?: unknown;
}
export const HIVE_FORBIDDEN_TEXT = "Keine Berechtigung, die Beute zu ändern.";
/** The 409 HIVE_CONFLICT body of a rejection, if it is one. */
function hiveConflictReason(details: unknown): string | null {
const body = details as { code?: unknown; reason?: unknown } | null;
if (body?.code !== "HIVE_CONFLICT") return null;
return typeof body.reason === "string" ? body.reason : "";
}
/**
* The reason for a rejected queue entry. Entries that change a hive get their
* own words for 403 and for a conflict with the hive's current state; every
* other rejection is the server's message.
*/
export function queueRejectionText(error: RejectionInfo, changesHive: boolean): string {
if (changesHive && error.status === 403) {
return HIVE_FORBIDDEN_TEXT;
}
const reason = changesHive && error.status === 409 ? hiveConflictReason(error.details) : null;
if (reason !== null) {
return `Die Beute wurde inzwischen geändert. ${reason ? hiveReasonText(reason) : ""}`.trim();
}
return rejectionText(error.message);
}
/** Sign-out dialog: "3 Einträge sind noch nicht gesendet." */
export function entriesNotSent(n: number): string {
return n === 1 ? "1 Eintrag ist noch nicht gesendet." : `${n} Einträge sind noch nicht gesendet.`;
@@ -189,6 +189,19 @@ describe("service worker routes", () => {
expect(cachesApi("/api/v1/tasks")).toBe(true);
});
it("never caches a colony's hive setup, so the pack answers offline - but not its other hive paths", () => {
for (const path of [
"/api/v1/colonies/c-1/hive",
"/api/v1/colonies/c-1/hive/",
"/api/v1/Colonies/c-1/Hive",
]) {
expect(cachesApi(path)).toBe(false);
}
expect(cachesApi("/api/v1/colonies/c-1/hive/changes")).toBe(true);
expect(cachesApi("/api/v1/colonies/c-1/hive/at")).toBe(true);
expect(cachesApi("/api/v1/hive-systems")).toBe(true);
});
it("never caches apiaries themselves - they carry coordinates and the address", () => {
for (const path of ["/api/v1/apiaries", "/api/v1/apiaries/", "/api/v1/apiaries/a-1", "/api/v1/apiaries/a-1/"]) {
expect(cachesApi(path)).toBe(false);
Loaded 100 of 123 files, more files were not shown because too many files have changed in this diff. Show more