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

@@ -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.",
],
},
];