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:
1 parent
ab45d6e24e
commit
1a57de8573
123 files changed
+7639
-355
No files matched your search
Generated
+1
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 & 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 & 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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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. */
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
Reference in new issue
Block a user