feat: hive card with SVG, outline, edit sheets and catalog settings (Beute L2)

- lib/hive: frontend port of the hive domain (ops, validator, facts,
  layout, follow-ups, describe texts, reason texts) running the backend
  fixtures, API client
- hive card on the colony page (same height as Stammdaten): SVG view,
  accessible outline tree, frame/part sheets with optimistic save,
  conflict sheet, history with past states, init from template or
  another colony, inspection draft mode
- follow-up toasts prefilling feeding and treatment forms (treatment
  end only with an open treatment)
- settings page "Beutensysteme" (hive-systems:manage)
- NumberStepper decimal steps, reactive motion level hook, holo tokens
- spec: owner design decision 2026-10-16 (glass hologram, focus mode)

The SVG view is the fallback; the 3D scene follows in L3.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-16 09:12:03 +02:00
1 parent 0091b3d28b
commit c608f5d405
87 files changed
+9493 -43

No files matched your search

@@ -0,0 +1,26 @@
import { toApiDate } from "@/lib/dates";
/** The read side of URLSearchParams / Next's ReadonlyURLSearchParams. */
export interface ParamSource {
get(name: string): string | null;
}
const MAX_AMOUNT = 1_000_000;
/** A real calendar day "YYYY-MM-DD", else null (invalid links are ignored, never fatal). */
export function prefillDay(raw: string | null): string | null {
if (!raw) return null;
try {
toApiDate(raw);
return raw;
} catch {
return null;
}
}
/** A positive, finite number as a dot-decimal string ("2.5"), else null. */
export function prefillAmount(raw: string | null): string | null {
if (!raw || !/^\d+(\.\d+)?$/.test(raw)) return null;
const value = Number(raw);
return value > 0 && value <= MAX_AMOUNT ? String(value) : null;
}
@@ -54,3 +54,20 @@ describe.each([
expect(ratio).toBeGreaterThanOrEqual(MIN_CONTRAST);
});
});
describe.each([
["light", ":root"],
["dark", ".dark"],
])("hive label in %s mode", (_mode, selector) => {
it(`holo label text on label background is at least ${MIN_CONTRAST}:1`, () => {
const scope = block(selector);
const ratio = contrast(oklch(scope, "holo-label-fg"), oklch(scope, "holo-label-bg"));
expect(ratio).toBeGreaterThanOrEqual(MIN_CONTRAST);
});
it(`holo edge on the screen background is at least ${MIN_CONTRAST}:1`, () => {
const scope = block(selector);
const ratio = contrast(oklch(scope, "holo-edge"), oklch(scope, "holo-bg"));
expect(ratio).toBeGreaterThanOrEqual(MIN_CONTRAST);
});
});
+69
View File
@@ -0,0 +1,69 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ApiError, apiFetch } from "@/lib/auth/api-client";
import {
createHive,
fetchHive,
fetchHiveAt,
fetchHiveHistory,
fetchHiveSystems,
fetchHiveTemplate,
hiveConflictOf,
hiveErrorCode,
hiveKeys,
postHiveChange,
} from "./api";
vi.mock("@/lib/auth/api-client", async (importOriginal) => {
const original = await importOriginal<typeof import("@/lib/auth/api-client")>();
return { ...original, apiFetch: vi.fn() };
});
describe("hive api", () => {
beforeEach(() => {
vi.mocked(apiFetch).mockReset();
vi.mocked(apiFetch).mockResolvedValue(undefined);
});
it("reads systems, setup, template, history and a day", async () => {
await fetchHiveSystems();
await fetchHive("c 1");
await fetchHiveTemplate("s1");
await fetchHiveHistory("c1");
await fetchHiveHistory("c1", "abc_-");
await fetchHiveAt("c1", "2026-10-01");
expect(vi.mocked(apiFetch).mock.calls.map((call) => call[0])).toEqual([
"/hive-systems",
"/colonies/c%201/hive",
"/hive-systems/s1/template",
"/colonies/c1/hive/changes",
"/colonies/c1/hive/changes?cursor=abc_-",
"/colonies/c1/hive/at?day=2026-10-01",
]);
});
it("sends the idempotency key on create and change", async () => {
const state = { schemaVersion: 1 as const, stack: [] };
await createHive("c1", { systemId: "s", state, occurredOn: "2026-10-01" }, "key-1");
await postHiveChange("c1", { occurredOn: "2026-10-01", source: "CARD", ops: [] }, "key-2");
const [create, change] = vi.mocked(apiFetch).mock.calls;
expect(create[0]).toBe("/colonies/c1/hive");
expect(create[1]).toMatchObject({ method: "POST", headers: { "Idempotency-Key": "key-1" } });
expect(change[0]).toBe("/colonies/c1/hive/changes");
expect(change[1]).toMatchObject({ method: "POST", headers: { "Idempotency-Key": "key-2" } });
});
it("has the query keys of the plan", () => {
expect(hiveKeys.systems).toEqual(["hive-systems"]);
expect(hiveKeys.detail("c1")).toEqual(["colonies", "c1", "hive"]);
expect(hiveKeys.history("c1")).toEqual(["colonies", "c1", "hive", "history"]);
});
it("extracts the HIVE_CONFLICT body and error codes", () => {
const body = { code: "HIVE_CONFLICT", opIndex: 1, reason: "RULE_ROOF_TOP: x", current: { version: 3, state: {} } };
expect(hiveConflictOf(new ApiError(409, "Conflict", body))).toEqual(body);
expect(hiveConflictOf(new ApiError(409, "Conflict", { code: "HIVE_EXISTS" }))).toBeNull();
expect(hiveConflictOf(new Error("x"))).toBeNull();
expect(hiveErrorCode(new ApiError(404, "nf", { code: "HIVE_NOT_FOUND" }))).toBe("HIVE_NOT_FOUND");
expect(hiveErrorCode(new ApiError(500, "x"))).toBeNull();
});
});
+86
View File
@@ -0,0 +1,86 @@
import { ApiError, apiFetch } from "@/lib/auth/api-client";
import { idempotencyHeaders } from "@/lib/offline/idempotency";
import type {
CreateHiveInput,
HiveAtView,
HiveConflictBody,
HiveHistoryPage,
HiveSetupView,
HiveState,
HiveSystemDto,
PostHiveChangeInput,
} from "./types";
export const hiveKeys = {
systems: ["hive-systems"] as const,
detail: (colonyId: string) => ["colonies", colonyId, "hive"] as const,
history: (colonyId: string) => ["colonies", colonyId, "hive", "history"] as const,
};
const colonyPath = (colonyId: string) => `/colonies/${encodeURIComponent(colonyId)}/hive`;
export function fetchHiveSystems(): Promise<HiveSystemDto[]> {
return apiFetch<HiveSystemDto[]>("/hive-systems");
}
/** 404 HIVE_NOT_FOUND when the colony has no setup yet. */
export function fetchHive(colonyId: string): Promise<HiveSetupView> {
return apiFetch<HiveSetupView>(colonyPath(colonyId));
}
/** Template of a system with fresh ids; 409 TEMPLATE_NOT_SET while it is empty. */
export function fetchHiveTemplate(systemId: string): Promise<HiveState> {
return apiFetch<HiveState>(`/hive-systems/${encodeURIComponent(systemId)}/template`);
}
export function createHive(
colonyId: string,
input: CreateHiveInput,
idempotencyKey: string,
): Promise<HiveSetupView> {
return apiFetch<HiveSetupView>(colonyPath(colonyId), {
method: "POST",
headers: idempotencyHeaders({ idempotencyKey }),
body: JSON.stringify(input),
});
}
/** 409 HIVE_CONFLICT carries the current state; see `hiveConflictOf`. */
export function postHiveChange(
colonyId: string,
input: PostHiveChangeInput,
idempotencyKey: string,
): Promise<HiveSetupView> {
return apiFetch<HiveSetupView>(`${colonyPath(colonyId)}/changes`, {
method: "POST",
headers: idempotencyHeaders({ idempotencyKey }),
body: JSON.stringify(input),
});
}
export function fetchHiveHistory(colonyId: string, cursor?: string): Promise<HiveHistoryPage> {
const query = cursor ? `?cursor=${encodeURIComponent(cursor)}` : "";
return apiFetch<HiveHistoryPage>(`${colonyPath(colonyId)}/changes${query}`);
}
export function fetchHiveAt(colonyId: string, day: string): Promise<HiveAtView> {
return apiFetch<HiveAtView>(`${colonyPath(colonyId)}/at?day=${encodeURIComponent(day)}`);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
/** The HIVE_CONFLICT body of a 409 answer, or null for any other error. */
export function hiveConflictOf(error: unknown): HiveConflictBody | null {
if (!(error instanceof ApiError) || error.status !== 409) return null;
const body = error.body;
if (!isRecord(body) || body.code !== "HIVE_CONFLICT") return null;
return body as unknown as HiveConflictBody;
}
/** Machine code of an API error (HIVE_NOT_FOUND, TEMPLATE_NOT_SET, HIVE_EXISTS ...), if any. */
export function hiveErrorCode(error: unknown): string | null {
if (!(error instanceof ApiError) || !isRecord(error.body)) return null;
return typeof error.body.code === "string" ? error.body.code : null;
}
@@ -0,0 +1,54 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { apiFetch } from "@/lib/auth/api-client";
import {
createHivePartType,
createHiveSystem,
updateHivePartType,
updateHiveSystem,
} from "./catalog-api";
vi.mock("@/lib/auth/api-client", async (importOriginal) => {
const original = await importOriginal<typeof import("@/lib/auth/api-client")>();
return { ...original, apiFetch: vi.fn() };
});
describe("hive catalog api", () => {
beforeEach(() => {
vi.mocked(apiFetch).mockReset();
vi.mocked(apiFetch).mockResolvedValue(undefined);
});
it("creates a system with POST", async () => {
const input = { name: "Dadant", frameWidthMm: 435, frameHeightMm: 300, footprintLMm: 500, footprintWMm: 480 };
await createHiveSystem(input);
expect(apiFetch).toHaveBeenCalledWith("/hive-systems", {
method: "POST",
body: JSON.stringify(input),
});
});
it("patches a system by encoded id", async () => {
await updateHiveSystem("s 1", { active: false });
expect(apiFetch).toHaveBeenCalledWith("/hive-systems/s%201", {
method: "PATCH",
body: JSON.stringify({ active: false }),
});
});
it("creates a part type below its system", async () => {
const input = { kind: "BOX" as const, name: "Zarge", lengthMm: 500, widthMm: 480, heightMm: 240, frameSlots: 10 };
await createHivePartType("s1", input);
expect(apiFetch).toHaveBeenCalledWith("/hive-systems/s1/part-types", {
method: "POST",
body: JSON.stringify(input),
});
});
it("patches a part type", async () => {
await updateHivePartType("p 1", { active: false });
expect(apiFetch).toHaveBeenCalledWith("/hive-part-types/p%201", {
method: "PATCH",
body: JSON.stringify({ active: false }),
});
});
});
@@ -0,0 +1,68 @@
import { apiFetch } from "@/lib/auth/api-client";
import type { HiveBoxDepth, HivePartKind, HivePartTypeDto, HiveSystemDto, StateField } from "./types";
// Admin writes of the hive catalog (hive-systems:manage). Reads stay in api.ts
// (`fetchHiveSystems`); callers refresh them via `hiveKeys.systems`.
export interface HiveSystemInput {
name: string;
frameWidthMm: number;
frameHeightMm: number;
footprintLMm: number;
footprintWMm: number;
}
/** Template editing is not part of this screen (it is saved from the editor). */
export type HiveSystemPatch = Partial<HiveSystemInput> & { active?: boolean };
export interface HivePartTypeInput {
kind: HivePartKind;
name: string;
articleRef?: string;
lengthMm: number;
widthMm: number;
heightMm: number;
boxDepth?: HiveBoxDepth;
frameSlots?: number;
stateSchema?: StateField[];
}
/** The kind cannot change after creation; a schema may only be appended to. */
export type HivePartTypePatch = Partial<Omit<HivePartTypeInput, "kind" | "articleRef">> & {
/** null clears the article reference. */
articleRef?: string | null;
active?: boolean;
};
const systemPath = (id: string) => `/hive-systems/${encodeURIComponent(id)}`;
export function createHiveSystem(input: HiveSystemInput): Promise<HiveSystemDto> {
return apiFetch<HiveSystemDto>("/hive-systems", {
method: "POST",
body: JSON.stringify(input),
});
}
export function updateHiveSystem(id: string, patch: HiveSystemPatch): Promise<HiveSystemDto> {
return apiFetch<HiveSystemDto>(systemPath(id), {
method: "PATCH",
body: JSON.stringify(patch),
});
}
export function createHivePartType(
systemId: string,
input: HivePartTypeInput,
): Promise<HivePartTypeDto> {
return apiFetch<HivePartTypeDto>(`${systemPath(systemId)}/part-types`, {
method: "POST",
body: JSON.stringify(input),
});
}
export function updateHivePartType(id: string, patch: HivePartTypePatch): Promise<HivePartTypeDto> {
return apiFetch<HivePartTypeDto>(`/hive-part-types/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify(patch),
});
}
@@ -0,0 +1,125 @@
import { describe, expect, it } from "vitest";
import { ApiError } from "@/lib/auth/api-client";
import {
describeStateFields,
draftProblem,
emptyDraft,
fieldFromDraft,
serverMessage,
templatePartCount,
type FieldDraft,
} from "./catalog-form";
import type { StateField } from "./types";
const level = (overrides: Partial<FieldDraft> = {}): FieldDraft => ({
...emptyDraft(),
kind: "level",
key: "syrup",
label: "Flüssigfutter",
unit: "l",
max: "3",
step: "0.5",
...overrides,
});
const choice = (overrides: Partial<FieldDraft> = {}): FieldDraft => ({
...emptyDraft(),
kind: "choice",
key: "lid",
label: "Deckel",
options: [
{ value: "open", label: "offen" },
{ value: "closed", label: "zu" },
],
...overrides,
});
describe("describeStateFields", () => {
it("writes level fields as range with unit and choices with their labels", () => {
const schema: StateField[] = [
{ key: "a", label: "Flüssigfutter", kind: "level", unit: "l", max: 3, step: 0.5 },
{ key: "b", label: "Futterteig", kind: "level", unit: "kg", max: 2.5, step: 0.5 },
{ key: "c", label: "Deckel", kind: "choice", options: ["o", "z"], optionLabels: ["offen", "zu"] },
];
expect(describeStateFields(schema)).toBe("Flüssigfutter 0–3 l, Futterteig 0–2,5 kg, Deckel (offen / zu)");
});
it("says so when there are no fields", () => {
expect(describeStateFields([])).toBe("keine");
});
});
describe("draftProblem", () => {
it("accepts valid level and choice drafts", () => {
expect(draftProblem(level(), [])).toBeNull();
expect(draftProblem(choice(), [])).toBeNull();
});
it.each([
["empty key", level({ key: "" })],
["key over 40", level({ key: "k".repeat(41) })],
["empty label", level({ label: " " })],
["label over 60", level({ label: "l".repeat(61) })],
["unit over 10", level({ unit: "x".repeat(11) })],
["max zero", level({ max: "0" })],
["max empty", level({ max: "" })],
["step zero", level({ step: "0" })],
["taken key", level({ key: "taken" })],
["no options", choice({ options: [] })],
["duplicate options", choice({ options: [{ value: "a", label: "A" }, { value: "a", label: "B" }] })],
["option over 40", choice({ options: [{ value: "a".repeat(41), label: "A" }] })],
["option label empty", choice({ options: [{ value: "a", label: "" }] })],
])("rejects %s", (_name, draft) => {
expect(draftProblem(draft, ["taken"])).toEqual(expect.any(String));
});
});
describe("fieldFromDraft", () => {
it("builds a level field and drops an empty feedType", () => {
expect(fieldFromDraft(level())).toEqual({
key: "syrup",
label: "Flüssigfutter",
kind: "level",
unit: "l",
max: 3,
step: 0.5,
});
expect(fieldFromDraft(level({ feedType: "FEED_SYRUP" }))).toMatchObject({ feedType: "FEED_SYRUP" });
});
it("builds a choice field with parallel options and labels", () => {
expect(fieldFromDraft(choice())).toEqual({
key: "lid",
label: "Deckel",
kind: "choice",
options: ["open", "closed"],
optionLabels: ["offen", "zu"],
});
});
});
describe("serverMessage", () => {
it("joins the messages list of a 400", () => {
const error = new ApiError(400, "x", { code: "SCHEMA_INVALID", messages: ["a", "b"] });
expect(serverMessage(error, "fallback")).toBe("a b");
});
it("explains a non additive schema in German", () => {
const error = new ApiError(400, "x", { code: "SCHEMA_NOT_ADDITIVE", message: "english" });
expect(serverMessage(error, "fallback")).toMatch(/Zustandsfelder/);
});
it("uses the message, then the fallback", () => {
expect(serverMessage(new ApiError(409, "Name vergeben", {}), "fallback")).toBe("Name vergeben");
expect(serverMessage(new Error(""), "fallback")).toBe("fallback");
});
});
describe("templatePartCount", () => {
it("counts the stack of a template and treats everything else as none", () => {
expect(templatePartCount({ schemaVersion: 1, stack: [{}, {}, {}] })).toBe(3);
expect(templatePartCount({ schemaVersion: 1, stack: [] })).toBeNull();
expect(templatePartCount(null)).toBeNull();
expect(templatePartCount({})).toBeNull();
});
});
@@ -0,0 +1,153 @@
import { ApiError } from "@/lib/auth/api-client";
import type { FeedType } from "@/lib/feedings/types";
import type { StateField } from "./types";
// Form helpers of the catalog admin screen. The limits mirror the backend
// (hive-state-schema.ts and the part-type DTO); the server stays the authority.
export const MAX_DIMENSION_MM = 5000;
/** Upper bound of `frameSlots` in the backend DTO. */
export const MAX_FRAME_SLOTS = 20;
export const MAX_KEY_LENGTH = 40;
export const MAX_FIELD_LABEL_LENGTH = 60;
export const MAX_UNIT_LENGTH = 10;
export const MAX_OPTION_LENGTH = 40;
export interface OptionDraft {
value: string;
label: string;
}
/** A state field as typed; numbers stay text until the form is saved. */
export interface FieldDraft {
kind: "level" | "choice";
key: string;
label: string;
unit: string;
max: string;
step: string;
feedType: FeedType | "";
options: OptionDraft[];
}
export function emptyDraft(): FieldDraft {
return {
kind: "level",
key: "",
label: "",
unit: "",
max: "",
step: "",
feedType: "",
options: [{ value: "", label: "" }],
};
}
function isPositive(text: string): boolean {
const value = Number(text);
return text.trim() !== "" && Number.isFinite(value) && value > 0;
}
function levelProblem(draft: FieldDraft): string | null {
if (draft.unit.length > MAX_UNIT_LENGTH) {
return `Die Einheit darf höchstens ${MAX_UNIT_LENGTH} Zeichen haben.`;
}
if (!isPositive(draft.max)) return "Der Höchstwert muss größer als 0 sein.";
if (!isPositive(draft.step)) return "Die Schrittweite muss größer als 0 sein.";
return null;
}
function choiceProblem(draft: FieldDraft): string | null {
const { options } = draft;
if (options.length === 0) return "Eine Auswahl braucht mindestens eine Option.";
if (options.some((option) => option.value.trim() === "" || option.label.trim() === "")) {
return "Jede Option braucht einen Wert und eine Bezeichnung.";
}
if (new Set(options.map((option) => option.value)).size !== options.length) {
return "Die Werte der Optionen müssen verschieden sein.";
}
if (options.some((o) => o.value.length > MAX_OPTION_LENGTH || o.label.length > MAX_OPTION_LENGTH)) {
return `Werte und Bezeichnungen der Optionen dürfen höchstens ${MAX_OPTION_LENGTH} Zeichen haben.`;
}
return null;
}
/** First problem of a new field, or null. `takenKeys` are the keys already in the schema. */
export function draftProblem(draft: FieldDraft, takenKeys: readonly string[]): string | null {
if (draft.key.trim() === "" || draft.key.length > MAX_KEY_LENGTH) {
return `Der Schlüssel braucht 1 bis ${MAX_KEY_LENGTH} Zeichen.`;
}
if (takenKeys.includes(draft.key)) return `Der Schlüssel „${draft.key}“ ist schon vergeben.`;
if (draft.label.trim() === "" || draft.label.length > MAX_FIELD_LABEL_LENGTH) {
return `Der Feldname braucht 1 bis ${MAX_FIELD_LABEL_LENGTH} Zeichen.`;
}
return draft.kind === "level" ? levelProblem(draft) : choiceProblem(draft);
}
/** Call only for a draft that passed `draftProblem`. */
export function fieldFromDraft(draft: FieldDraft): StateField {
if (draft.kind === "choice") {
return {
key: draft.key,
label: draft.label,
kind: "choice",
options: draft.options.map((option) => option.value),
optionLabels: draft.options.map((option) => option.label),
};
}
return {
key: draft.key,
label: draft.label,
kind: "level",
unit: draft.unit,
max: Number(draft.max),
step: Number(draft.step),
...(draft.feedType === "" ? {} : { feedType: draft.feedType }),
};
}
function germanNumber(value: number): string {
return String(value).replace(".", ",");
}
/** Short text such as „Flüssigfutter 0–3 l, Futterteig 0–2,5 kg“. */
export function describeStateFields(schema: readonly StateField[]): string {
if (schema.length === 0) return "keine";
return schema
.map((field) => {
if (field.kind === "choice") return `${field.label} (${field.optionLabels.join(" / ")})`;
const unit = field.unit === "" ? "" : ` ${field.unit}`;
return `${field.label} 0–${germanNumber(field.max)}${unit}`;
})
.join(", ");
}
const SERVER_MESSAGES: Record<string, string> = {
SCHEMA_NOT_ADDITIVE:
"Bestehende Zustandsfelder bleiben unverändert, es können nur neue hinzukommen.",
};
function bodyOf(error: unknown): Record<string, unknown> {
if (!(error instanceof ApiError)) return {};
return typeof error.body === "object" && error.body !== null
? (error.body as Record<string, unknown>)
: {};
}
/** What the server said about a failed save, in one text; `fallback` when it said nothing. */
export function serverMessage(error: unknown, fallback: string): string {
const body = bodyOf(error);
if (typeof body.code === "string" && SERVER_MESSAGES[body.code]) {
return SERVER_MESSAGES[body.code];
}
if (Array.isArray(body.messages) && body.messages.length > 0) return body.messages.join(" ");
if (error instanceof Error && error.message) return error.message;
return fallback;
}
/** Parts in the stored template, or null when the system has none yet. */
export function templatePartCount(template: unknown): number | null {
if (typeof template !== "object" || template === null) return null;
const stack = (template as { stack?: unknown }).stack;
return Array.isArray(stack) && stack.length > 0 ? stack.length : null;
}
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { copyHiveFrom } from "./copy";
import { frames, inlineCatalog, part } from "./test-utils";
import type { HiveState } from "./types";
const catalog = inlineCatalog();
const source: HiveState = {
schemaVersion: 1,
stack: [
part("f", "floor", { label: "Mein Boden", inserts: [part("fi", "floor-insert")] }),
part("b", "box", {
label: "Brutraum",
frames: [
{ slot: 1, type: "BROOD", name: "Königin" },
{ slot: 2, type: "HONEY" },
{ slot: 3, type: "FOUNDATION" },
...frames(7, "NONE").map((frame) => ({ ...frame, slot: frame.slot + 3 })),
],
}),
part("fe", "feeder", { state: { syrupLiters: 2, fondantKg: 1 } }),
part("l", "inner-lid", { state: { feedHole: "CLOSED" } }),
],
};
describe("copyHiveFrom", () => {
const copy = copyHiveFrom(source, catalog);
it("resets levels to 0 and choices to the first option", () => {
expect(copy.stack[2].state).toEqual({ syrupLiters: 0, fondantKg: 0 });
expect(copy.stack[3].state).toEqual({ feedHole: "OPEN" });
});
it("removes part labels and frame names", () => {
expect(copy.stack[0].label).toBeUndefined();
expect(copy.stack[1].label).toBeUndefined();
expect(copy.stack[1].frames?.every((frame) => frame.name === undefined)).toBe(true);
});
it("turns every frame into an empty comb except foundations", () => {
const types = copy.stack[1].frames?.map((frame) => frame.type);
expect(types?.slice(0, 3)).toEqual(["EMPTY_COMB", "EMPTY_COMB", "FOUNDATION"]);
expect(types?.slice(3).every((type) => type === "EMPTY_COMB")).toBe(true);
expect(copy.stack[1].frames?.map((frame) => frame.slot)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
});
it("gives every part and insert a fresh id and keeps the stack order", () => {
const oldIds = ["f", "fi", "b", "fe", "l"];
const newIds = [copy.stack[0].id, copy.stack[0].inserts?.[0].id, copy.stack[1].id, copy.stack[2].id, copy.stack[3].id];
expect(new Set(newIds).size).toBe(5);
expect(newIds.some((id) => oldIds.includes(String(id)))).toBe(false);
expect(copy.stack.map((p) => p.partTypeId)).toEqual(["floor", "box", "feeder", "inner-lid"]);
expect(copy.stack[0].inserts?.[0].partTypeId).toBe("floor-insert");
});
it("does not change the source", () => {
expect(source.stack[1].frames?.[0]).toEqual({ slot: 1, type: "BROOD", name: "Königin" });
});
});
+36
View File
@@ -0,0 +1,36 @@
import type { FrameType, HiveCatalog, HivePart, HiveState } from "./types";
// Spec 4.4: „Wie anderes Volk“ copies the stack with state values at their
// start (0 / first option), no names, and all frames as empty combs except
// foundations. Every part gets a fresh id.
const KEPT_FRAME_TYPE: FrameType = "FOUNDATION";
const EMPTY_FRAME_TYPE: FrameType = "EMPTY_COMB";
function startState(part: HivePart, catalog: HiveCatalog): HivePart["state"] {
const schema = catalog.partTypes.get(part.partTypeId)?.stateSchema ?? [];
return Object.fromEntries(
schema.map((field) => [field.key, field.kind === "choice" ? (field.options[0] ?? "") : 0]),
);
}
function copyPart(part: HivePart, catalog: HiveCatalog): HivePart {
return {
id: crypto.randomUUID(),
partTypeId: part.partTypeId,
state: startState(part, catalog),
...(part.inserts ? { inserts: part.inserts.map((insert) => copyPart(insert, catalog)) } : {}),
...(part.frames
? {
frames: part.frames.map((frame) => ({
slot: frame.slot,
type: frame.type === KEPT_FRAME_TYPE ? KEPT_FRAME_TYPE : EMPTY_FRAME_TYPE,
})),
}
: {}),
};
}
export function copyHiveFrom(source: HiveState, catalog: HiveCatalog): HiveState {
return { schemaVersion: 1, stack: source.stack.map((part) => copyPart(part, catalog)) };
}
@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import { describeHiveOps } from "./describe";
import { inlineCatalog, part, templateState } from "./test-utils";
import type { HiveOp, HiveState } from "./types";
describe("describeHiveOps", () => {
const catalog = inlineCatalog();
function stateWithInsert(): HiveState {
const state = templateState();
state.stack[0].inserts = [part("fi", "floor-insert")];
return state;
}
it("describes removed inserts and added parts", () => {
const ops: HiveOp[] = [
{ op: "removePart", id: "fi" },
{ op: "addPart", part: part("b2", "box"), at: 2 },
];
expect(describeHiveOps(ops, stateWithInsert(), catalog)).toBe("Bausperre entfernt, Zarge hinzugefügt");
});
it("uses the label of a part when it has one", () => {
const state = templateState();
state.stack[1].label = "Honigraum 1";
expect(describeHiveOps([{ op: "removePart", id: "b" }], state, catalog)).toBe("Honigraum 1 entfernt");
expect(describeHiveOps([{ op: "movePart", id: "b", to: 2 }], state, catalog)).toBe("Honigraum 1 verschoben");
});
it("describes insert ops", () => {
expect(
describeHiveOps([{ op: "addInsert", hostId: "f", part: part("fi", "floor-insert") }], templateState(), catalog),
).toBe("Bausperre eingelegt");
expect(
describeHiveOps([{ op: "addInsert", hostId: "b", part: part("e", "evaporator") }], templateState(), catalog),
).toBe("Verdunster eingesetzt");
});
it("describes setState with unit and option labels", () => {
const state = templateState();
state.stack.splice(2, 0, part("fe", "feeder"));
expect(describeHiveOps([{ op: "setState", id: "fe", key: "syrupLiters", value: 2.5 }], state, catalog)).toBe(
"Flüssigfutter 2,5 l",
);
expect(describeHiveOps([{ op: "setState", id: "l", key: "feedHole", value: "OPEN" }], state, catalog)).toBe(
"Futterloch offen",
);
});
it("describes setFrame, setFrames and setLabel", () => {
const state = templateState();
expect(describeHiveOps([{ op: "setFrame", boxId: "b", slot: 4, type: "BROOD" }], state, catalog)).toBe(
"Rähmchen 4 in Zarge: Brutwabe",
);
expect(describeHiveOps([{ op: "setFrames", boxId: "b", frames: [] }], state, catalog)).toBe(
"Rähmchen in Zarge neu gesetzt",
);
expect(describeHiveOps([{ op: "setLabel", id: "b", label: "Brutraum" }], state, catalog)).toBe(
"Zarge heißt jetzt „Brutraum“",
);
});
it("resolves parts added earlier in the same batch", () => {
const ops: HiveOp[] = [
{ op: "addPart", part: { ...part("n", "box"), label: "Aufsatz" }, at: 2 },
{ op: "setFrame", boxId: "n", slot: 1, type: "HONEY" },
];
expect(describeHiveOps(ops, templateState(), catalog)).toBe(
"Aufsatz hinzugefügt, Rähmchen 1 in Aufsatz: Honigwabe",
);
});
it("shortens more than three ops", () => {
const ops: HiveOp[] = [1, 2, 3, 4, 5].map((slot) => ({ op: "setFrame", boxId: "b", slot, type: "BROOD" }));
expect(describeHiveOps(ops, templateState(), catalog)).toBe(
"Rähmchen 1 in Zarge: Brutwabe, Rähmchen 2 in Zarge: Brutwabe, Rähmchen 3 in Zarge: Brutwabe und 2 weitere",
);
});
});
+78
View File
@@ -0,0 +1,78 @@
import { FRAME_TYPE_LABELS, PART_KIND_LABELS } from "./labels";
import type { CatalogPartType, HiveCatalog, HiveOp, HivePart, HiveState, StateField } from "./types";
const MAX_SHOWN_OPS = 3;
interface Named {
label?: string;
type?: CatalogPartType;
}
function collect(part: HivePart, catalog: HiveCatalog, into: Map<string, Named>): void {
into.set(part.id, { label: part.label, type: catalog.partTypes.get(part.partTypeId) });
for (const insert of part.inserts ?? []) collect(insert, catalog, into);
}
function nameOf(entry: Named | undefined): string {
if (entry?.label) return entry.label;
return entry?.type ? PART_KIND_LABELS[entry.type.kind] : "Teil";
}
const numberFormat = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 3 });
function valueText(field: StateField | undefined, value: number | string): string {
if (!field) return String(value);
if (field.kind === "choice") {
const index = field.options.indexOf(String(value));
return index >= 0 ? field.optionLabels[index] : String(value);
}
const text = numberFormat.format(Number(value));
return field.unit ? `${text} ${field.unit}` : text;
}
function describeOne(op: HiveOp, known: Map<string, Named>, catalog: HiveCatalog): string {
switch (op.op) {
case "addPart": {
collect(op.part, catalog, known);
return `${nameOf(known.get(op.part.id))} hinzugefügt`;
}
case "addInsert": {
collect(op.part, catalog, known);
const name = nameOf(known.get(op.part.id));
return known.get(op.part.id)?.type?.kind === "FLOOR_INSERT" ? "Bausperre eingelegt" : `${name} eingesetzt`;
}
case "removePart":
return `${nameOf(known.get(op.id))} entfernt`;
case "movePart":
return `${nameOf(known.get(op.id))} verschoben`;
case "setState": {
const entry = known.get(op.id);
const field = entry?.type?.stateSchema.find((f) => f.key === op.key);
return `${field?.label ?? op.key} ${valueText(field, op.value)}`;
}
case "setFrame":
return `Rähmchen ${op.slot} in ${nameOf(known.get(op.boxId))}: ${FRAME_TYPE_LABELS[op.type]}`;
case "setFrames":
return `Rähmchen in ${nameOf(known.get(op.boxId))} neu gesetzt`;
case "setLabel": {
const name = nameOf(known.get(op.id));
const entry = known.get(op.id);
if (entry) entry.label = op.label ?? undefined;
return op.label === null ? `Name von ${name} entfernt` : `${name} heißt jetzt „${op.label}“`;
}
}
}
/**
* German one-line summary of a change, e.g. „Bausperre entfernt, Zarge hinzugefügt“.
* Names come from `before` plus parts the same batch adds; more than three ops
* are shortened to the first three and „und n weitere“.
*/
export function describeHiveOps(ops: HiveOp[], before: HiveState, catalog: HiveCatalog): string {
const known = new Map<string, Named>();
for (const part of before.stack) collect(part, catalog, known);
const texts = ops.map((op) => describeOne(op, known, catalog));
if (texts.length <= MAX_SHOWN_OPS) return texts.join(", ");
const rest = texts.length - MAX_SHOWN_OPS;
return `${texts.slice(0, MAX_SHOWN_OPS).join(", ")} und ${rest} weitere`;
}
+74
View File
@@ -0,0 +1,74 @@
import type {
CatalogPartType,
HiveCatalog,
HiveFacts,
HiveLevelFact,
HivePart,
HiveState,
HiveSystemDto,
} from "./types";
const UNBUILT_FRAME_TYPES = new Set(["NONE", "FOUNDATION", "DIVIDER"]);
/** Catalog of a system as the API returns it (all part types, also inactive). */
export function catalogFromSystem(system: HiveSystemDto): HiveCatalog {
const rows: CatalogPartType[] = system.partTypes.map((row) => ({
id: row.id,
kind: row.kind,
name: row.name,
lengthMm: row.lengthMm,
widthMm: row.widthMm,
heightMm: row.heightMm,
frameSlots: row.frameSlots,
stateSchema: row.stateSchema,
active: row.active,
}));
return { systemId: system.id, partTypes: new Map(rows.map((row) => [row.id, row])) };
}
function allParts(state: HiveState): HivePart[] {
return state.stack.flatMap((part) => [part, ...(part.inserts ?? [])]);
}
function levelsOf(part: HivePart, catalog: HiveCatalog): HiveLevelFact[] {
const type = catalog.partTypes.get(part.partTypeId);
if (!type) return [];
return type.stateSchema.flatMap((field) => {
if (field.kind !== "level") return [];
const raw = part.state[field.key];
return [
{
partId: part.id,
key: field.key,
label: field.label,
unit: field.unit,
value: typeof raw === "number" ? raw : 0,
max: field.max,
...(field.feedType ? { feedType: field.feedType } : {}),
},
];
});
}
/** Pure derivation of display facts (spec 5.3), same as the backend. */
export function deriveHiveFacts(state: HiveState, catalog: HiveCatalog): HiveFacts {
const parts = allParts(state);
const kindOf = (part: HivePart) => catalog.partTypes.get(part.partTypeId)?.kind;
const frames = state.stack
.filter((part) => kindOf(part) === "BOX")
.flatMap((part) => part.frames ?? []);
const evaporators = parts.filter((part) => kindOf(part) === "EVAPORATOR");
const acid = evaporators.reduce((sum, part) => {
const value = part.state.acidMl;
return sum + (typeof value === "number" ? value : 0);
}, 0);
return {
boxCount: state.stack.filter((part) => kindOf(part) === "BOX").length,
broodFrames: frames.filter((f) => f.type === "BROOD").length,
honeyFrames: frames.filter((f) => f.type === "HONEY").length,
builtFrames: frames.filter((f) => !UNBUILT_FRAME_TYPES.has(f.type)).length,
levels: parts.flatMap((part) => levelsOf(part, catalog)),
hasFloorInsert: parts.some((part) => kindOf(part) === "FLOOR_INSERT"),
evaporatorAcidMl: evaporators.length > 0 ? acid : null,
};
}
@@ -0,0 +1,117 @@
import { describe, expect, it } from "vitest";
import { applyHiveOps } from "./ops";
import { hiveFollowUps } from "./follow-ups";
import { inlineCatalog, part, templateState } from "./test-utils";
import type { HiveOp, HiveState } from "./types";
const catalog = inlineCatalog();
function withFeeder(): HiveState {
const state = templateState();
state.stack.splice(2, 0, part("fe", "feeder"));
return state;
}
function run(before: HiveState, ops: HiveOp[]) {
const result = applyHiveOps(before, ops, catalog);
if (!result.ok) throw new Error(result.reason);
return hiveFollowUps(ops, before, result.state, catalog);
}
describe("hiveFollowUps", () => {
it("suggests a feeding when a feeder level rises", () => {
const ops: HiveOp[] = [{ op: "setState", id: "fe", key: "syrupLiters", value: 3 }];
expect(run(withFeeder(), ops)).toEqual([
{ kind: "feeding", amount: 3, unit: "l", feedType: "SUGAR_WATER" },
]);
});
it("uses the increase and the feed type of the field", () => {
const before = withFeeder();
before.stack[2].state = { fondantKg: 0.5 };
const ops: HiveOp[] = [{ op: "setState", id: "fe", key: "fondantKg", value: 2 }];
expect(run(before, ops)).toEqual([{ kind: "feeding", amount: 1.5, unit: "kg", feedType: "FEED_PASTE" }]);
});
it("suggests nothing when a level falls or stays", () => {
const before = withFeeder();
before.stack[2].state = { syrupLiters: 2 };
expect(run(before, [{ op: "setState", id: "fe", key: "syrupLiters", value: 1 }])).toEqual([]);
expect(run(before, [{ op: "setState", id: "fe", key: "syrupLiters", value: 2 }])).toEqual([]);
});
it("nets several ops on the same level into one suggestion", () => {
const ops: HiveOp[] = [
{ op: "setState", id: "fe", key: "syrupLiters", value: 1 },
{ op: "setState", id: "fe", key: "syrupLiters", value: 2 },
];
expect(run(withFeeder(), ops)).toEqual([{ kind: "feeding", amount: 2, unit: "l", feedType: "SUGAR_WATER" }]);
});
it("suggests a treatment for an evaporator added with acid", () => {
const evaporator = { ...part("ev", "evaporator"), state: { acidMl: 250 } };
const ops: HiveOp[] = [{ op: "addInsert", hostId: "b", part: evaporator }];
expect(run(templateState(), ops)).toEqual([{ kind: "treatment", amountMl: 250 }]);
});
it("suggests nothing for an evaporator added without acid", () => {
const ops: HiveOp[] = [{ op: "addInsert", hostId: "b", part: part("ev", "evaporator") }];
expect(run(templateState(), ops)).toEqual([]);
});
it("suggests a treatment when the acid level rises", () => {
const before = templateState();
before.stack[1].inserts = [{ ...part("ev", "evaporator"), state: { acidMl: 100 } }];
const ops: HiveOp[] = [{ op: "setState", id: "ev", key: "acidMl", value: 250 }];
expect(run(before, ops)).toEqual([{ kind: "treatment", amountMl: 150 }]);
});
it("suggests ending the treatment when an evaporator is removed", () => {
const before = templateState();
before.stack[1].inserts = [part("ev", "evaporator")];
expect(run(before, [{ op: "removePart", id: "ev" }])).toEqual([{ kind: "endTreatment" }]);
});
it("gives one treatment when an evaporator is added and raised in one batch", () => {
const evaporator = { ...part("ev", "evaporator"), state: { acidMl: 250 } };
const ops: HiveOp[] = [
{ op: "addInsert", hostId: "b", part: evaporator },
{ op: "setState", id: "ev", key: "acidMl", value: 300 },
];
expect(run(templateState(), ops)).toEqual([{ kind: "treatment", amountMl: 300 }]);
});
it("gives one treatment when an evaporator is added empty and then raised", () => {
const ops: HiveOp[] = [
{ op: "addInsert", hostId: "b", part: part("ev", "evaporator") },
{ op: "setState", id: "ev", key: "acidMl", value: 250 },
];
expect(run(templateState(), ops)).toEqual([{ kind: "treatment", amountMl: 250 }]);
});
it("gives nothing when a level is raised and lowered again in one batch", () => {
const ops: HiveOp[] = [
{ op: "setState", id: "fe", key: "syrupLiters", value: 3 },
{ op: "setState", id: "fe", key: "syrupLiters", value: 0 },
];
expect(run(withFeeder(), ops)).toEqual([]);
});
it("suggests the net filled amount for a feeder added and filled in the same batch", () => {
const filled = { ...part("fe2", "feeder"), state: { syrupLiters: 2 } };
const ops: HiveOp[] = [
{ op: "addPart", part: filled, at: 2 },
{ op: "setState", id: "fe2", key: "syrupLiters", value: 3 },
];
expect(run(templateState(), ops)).toEqual([
{ kind: "feeding", amount: 3, unit: "l", feedType: "SUGAR_WATER" },
]);
});
it("suggests a feeding for a feeder added already filled", () => {
const filled = { ...part("fe2", "feeder"), state: { fondantKg: 1.5 } };
expect(run(templateState(), [{ op: "addPart", part: filled, at: 2 }])).toEqual([
{ kind: "feeding", amount: 1.5, unit: "kg", feedType: "FEED_PASTE" },
]);
});
});
+111
View File
@@ -0,0 +1,111 @@
import type { FeedType } from "@/lib/feedings/types";
import type { CatalogPartType, HiveCatalog, HiveOp, HivePart, HiveState, LevelField } from "./types";
// Spec section 8: suggestions only. Nothing here books a feeding or treatment.
export type FollowUp =
| { kind: "feeding"; amount: number; unit: "l" | "kg"; feedType: FeedType }
| { kind: "treatment"; amountMl: number }
| { kind: "endTreatment" };
const FEED_TYPES: readonly FeedType[] = ["SUGAR_WATER", "FEED_SYRUP", "FEED_PASTE", "OWN_HONEY", "OTHER"];
const ACID_KEY = "acidMl";
const ROUNDING = 1000;
function findPart(state: HiveState, id: string): HivePart | undefined {
for (const part of state.stack) {
if (part.id === id) return part;
const insert = part.inserts?.find((candidate) => candidate.id === id);
if (insert) return insert;
}
return undefined;
}
function typeOf(part: HivePart | undefined, catalog: HiveCatalog): CatalogPartType | undefined {
return part ? catalog.partTypes.get(part.partTypeId) : undefined;
}
function levelOf(part: HivePart | undefined, key: string): number {
const raw = part?.state[key];
return typeof raw === "number" ? raw : 0;
}
function increase(before: HivePart | undefined, after: HivePart, key: string): number {
return Math.round((levelOf(after, key) - levelOf(before, key)) * ROUNDING) / ROUNDING;
}
function feedingFor(
field: LevelField,
amount: number,
): FollowUp | null {
const feedType = FEED_TYPES.find((candidate) => candidate === field.feedType);
if (!feedType || (field.unit !== "l" && field.unit !== "kg")) return null;
return { kind: "feeding", amount, unit: field.unit, feedType };
}
function treatmentFor(before: HivePart | undefined, after: HivePart): FollowUp | null {
// A part absent in `before` (added in this batch) counts as 0 before.
const delta = increase(before, after, ACID_KEY);
return delta > 0 ? { kind: "treatment", amountMl: delta } : null;
}
function feedingForPart(
before: HivePart | undefined,
after: HivePart,
key: string,
catalog: HiveCatalog,
): FollowUp | null {
// A feeder added in this batch counts as 0 before: filling it is a feeding.
const field = typeOf(after, catalog)?.stateSchema.find((f) => f.key === key);
const delta = increase(before, after, key);
return field?.kind === "level" && delta > 0 ? feedingFor(field, delta) : null;
}
/**
* Suggestions after applied ops (spec section 8). Computed from the net
* per-part, per-level difference between `before` and `after`, so several ops
* on one level (also add-then-raise) give exactly one suggestion. A part added
* in the batch counts as 0 before, for evaporators and feeders alike.
*/
export function hiveFollowUps(
ops: HiveOp[],
before: HiveState,
after: HiveState,
catalog: HiveCatalog,
): FollowUp[] {
const out: FollowUp[] = [];
const seen = new Set<string>();
const once = (id: string, key: string): boolean => {
const dedupeKey = `${id}/${key}`;
if (seen.has(dedupeKey)) return false;
seen.add(dedupeKey);
return true;
};
for (const op of ops) {
const followUps: (FollowUp | null)[] = [];
if (op.op === "setState" && once(op.id, op.key)) {
const now = findPart(after, op.id);
const was = findPart(before, op.id);
const kind = typeOf(now, catalog)?.kind;
if (now && kind === "EVAPORATOR" && op.key === ACID_KEY) followUps.push(treatmentFor(was, now));
else if (now && kind === "FEEDER") followUps.push(feedingForPart(was, now, op.key, catalog));
} else if (op.op === "addInsert" || op.op === "addPart") {
const now = findPart(after, op.part.id);
const type = typeOf(now, catalog);
if (now && type?.kind === "EVAPORATOR" && once(now.id, ACID_KEY)) {
followUps.push(treatmentFor(findPart(before, now.id), now));
} else if (now && type?.kind === "FEEDER") {
for (const field of type.stateSchema) {
if (field.kind === "level" && once(now.id, field.key)) {
followUps.push(feedingForPart(findPart(before, now.id), now, field.key, catalog));
}
}
}
} else if (op.op === "removePart") {
if (typeOf(findPart(before, op.id), catalog)?.kind === "EVAPORATOR") followUps.push({ kind: "endTreatment" });
}
for (const followUp of followUps) if (followUp) out.push(followUp);
}
return out;
}
+30
View File
@@ -0,0 +1,30 @@
import type { FrameType, HivePartKind } from "./types";
export const FRAME_TYPE_LABELS: Record<FrameType, string> = {
BROOD: "Brutwabe",
DRONE: "Drohnenwabe",
HONEY: "Honigwabe",
POLLEN: "Pollenwabe",
FEED: "Futterwabe",
FOUNDATION: "Mittelwand",
EMPTY_COMB: "Leerwabe",
DRONE_FRAME: "Baurahmen",
DIVIDER: "Schied",
NONE: "leer",
};
/** Short generic names, used when a part has no label of its own. */
export const PART_KIND_LABELS: Record<HivePartKind, string> = {
FLOOR: "Boden",
BOX: "Zarge",
DIVIDER_BOARD: "Zwischenboden",
QUEEN_EXCLUDER: "Absperrgitter",
FEEDER: "Futterzarge",
COVER_SHEET: "Abdeckfolie",
PROPOLIS_SCREEN: "Propolisgitter",
INNER_LID: "Holzdeckel",
INSULATION: "Dämmung",
ROOF: "Dach",
FLOOR_INSERT: "Bausperre",
EVAPORATOR: "Verdunster",
};
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import { hiveLayout } from "./layout";
import { inlineCatalog, part, templateState } from "./test-utils";
describe("hiveLayout", () => {
const catalog = inlineCatalog();
it("computes the template height with the roof overlapping by 35 mm", () => {
const layout = hiveLayout(templateState(), catalog);
expect(layout.bounds.h).toBeCloseTo(4.31, 9);
expect(layout.bounds.w).toBeCloseTo(5.25, 9);
expect(layout.bounds.d).toBeCloseTo(4.3, 9);
});
it("stacks parts on +y from 0 with centres and sizes in units of 100 mm", () => {
const [floor, box, cover] = hiveLayout(templateState(), catalog).parts;
expect(floor.center).toEqual([0, 0.6, 0]);
expect(floor.size).toEqual([5.25, 1.2, 4.3]);
expect(floor.stackIndex).toBe(0);
expect(box.center[1]).toBeCloseTo(1.2 + 1.15, 9);
expect(cover.center[1]).toBeCloseTo(3.5 + 0.005, 9);
expect(box.stackIndex).toBe(1);
});
it("lets the roof overlap the part below while using its own height", () => {
const layout = hiveLayout(templateState(), catalog);
const roof = layout.parts[layout.parts.length - 1];
const below = layout.parts[layout.parts.length - 2];
const belowTop = below.center[1] + below.size[1] / 2;
const roofBottom = roof.center[1] - roof.size[1] / 2;
expect(belowTop - roofBottom).toBeCloseTo(0.35, 9);
expect(roof.size[1]).toBeCloseTo(0.6, 9);
});
it("places 10 frames evenly across the inner width of the box", () => {
const layout = hiveLayout(templateState(), catalog);
expect(layout.frames).toHaveLength(10);
const inner = 5.25 - 0.4;
const xs = layout.frames.map((f) => f.center[0]);
expect(xs[0]).toBeCloseTo(-inner / 2 + inner / 20, 9);
expect(xs[9]).toBeCloseTo(inner / 2 - inner / 20, 9);
const pitch = inner / 10;
xs.slice(1).forEach((x, i) => expect(x - xs[i]).toBeCloseTo(pitch, 9));
layout.frames.forEach((f, i) => {
expect(f.boxId).toBe("b");
expect(f.slot).toBe(i + 1);
expect(f.size[0]).toBeCloseTo(0.25, 9);
expect(f.size[1]).toBeCloseTo(2.2, 9);
});
});
it("puts the floor insert 5 mm above the floor bottom and the evaporator on the box bottom", () => {
const state = templateState();
state.stack[0].inserts = [part("fi", "floor-insert")];
state.stack[1].inserts = [part("ev", "evaporator")];
const layout = hiveLayout(state, catalog);
const insert = layout.inserts.find((i) => i.id === "fi")!;
const evaporator = layout.inserts.find((i) => i.id === "ev")!;
expect(insert.center[1]).toBeCloseTo(0.05, 9);
expect(insert.kind).toBe("FLOOR_INSERT");
expect(evaporator.center[1]).toBeCloseTo(1.2 + 0.6, 9);
expect(evaporator.stackIndex).toBe(1);
});
it("returns an empty layout for an empty stack", () => {
const layout = hiveLayout({ schemaVersion: 1, stack: [] }, catalog);
expect(layout).toEqual({ parts: [], inserts: [], frames: [], bounds: { w: 0, h: 0, d: 0 } });
});
});
+127
View File
@@ -0,0 +1,127 @@
import type { CatalogPartType, FrameType, HiveCatalog, HivePart, HivePartKind, HiveState } from "./types";
// Layout math shared by the 3D scene, the SVG fallback and the tests.
// 1 unit = 100 mm; x = length, y = height (stack grows on +y from 0), z = width.
// The model is centred at x = z = 0.
export type Vec3 = [number, number, number];
export interface PartBox {
id: string;
partTypeId: string;
kind: HivePartKind;
center: Vec3;
size: Vec3;
/** Stack index; an insert carries the index of its host. */
stackIndex: number;
}
export interface FrameBox {
boxId: string;
slot: number;
type: FrameType;
name?: string;
center: Vec3;
size: Vec3;
}
export interface HiveLayout {
parts: PartBox[];
inserts: PartBox[];
frames: FrameBox[];
bounds: { w: number; h: number; d: number };
}
const UNIT_MM = 100;
const ROOF_OVERLAP_MM = 35;
const BOX_WALL_MM = 20;
const FRAME_THICKNESS_MM = 25;
const FRAME_HEIGHT_MARGIN_MM = 10;
const FLOOR_INSERT_LIFT_MM = 5;
const mm = (value: number): number => value / UNIT_MM;
function sizeOf(type: CatalogPartType): Vec3 {
return [mm(type.lengthMm), mm(type.heightMm), mm(type.widthMm)];
}
function insertBox(
insert: HivePart,
type: CatalogPartType,
host: PartBox,
): PartBox {
const size = sizeOf(type);
const hostBottom = host.center[1] - host.size[1] / 2;
// FLOOR_INSERT lies just above the floor's bottom area (its centre y = floor
// bottom + 5 mm, y is always a centre); every other insert
// (the EVAPORATOR) stands on the bottom of the box it sits in.
const centerY =
type.kind === "FLOOR_INSERT" ? hostBottom + mm(FLOOR_INSERT_LIFT_MM) : hostBottom + size[1] / 2;
return {
id: insert.id,
partTypeId: insert.partTypeId,
kind: type.kind,
center: [0, centerY, 0],
size,
stackIndex: host.stackIndex,
};
}
function frameBoxes(part: HivePart, type: CatalogPartType, box: PartBox): FrameBox[] {
const frames = part.frames ?? [];
if (frames.length === 0) return [];
const inner = box.size[0] - 2 * mm(BOX_WALL_MM);
const pitch = inner / frames.length;
const size: Vec3 = [
mm(FRAME_THICKNESS_MM),
mm(type.heightMm - FRAME_HEIGHT_MARGIN_MM),
box.size[2] - 2 * mm(BOX_WALL_MM),
];
return frames.map((frame, index) => ({
boxId: part.id,
slot: frame.slot,
type: frame.type,
...(frame.name !== undefined ? { name: frame.name } : {}),
center: [-inner / 2 + pitch * (index + 0.5), box.center[1], 0],
size,
}));
}
/** Pure layout of a hive state in 3D units (1 = 100 mm). Unknown part types are skipped. */
export function hiveLayout(state: HiveState, catalog: HiveCatalog): HiveLayout {
const parts: PartBox[] = [];
const inserts: PartBox[] = [];
const frames: FrameBox[] = [];
let top = 0;
let maxTop = 0;
let width = 0;
let depth = 0;
state.stack.forEach((part, stackIndex) => {
const type = catalog.partTypes.get(part.partTypeId);
if (!type) return;
const size = sizeOf(type);
const bottom = type.kind === "ROOF" ? top - mm(ROOF_OVERLAP_MM) : top;
const box: PartBox = {
id: part.id,
partTypeId: part.partTypeId,
kind: type.kind,
center: [0, bottom + size[1] / 2, 0],
size,
stackIndex,
};
parts.push(box);
top = bottom + size[1];
maxTop = Math.max(maxTop, top);
width = Math.max(width, size[0]);
depth = Math.max(depth, size[2]);
for (const insert of part.inserts ?? []) {
const insertType = catalog.partTypes.get(insert.partTypeId);
if (insertType) inserts.push(insertBox(insert, insertType, box));
}
if (type.kind === "BOX") frames.push(...frameBoxes(part, type, box));
});
return { parts, inserts, frames, bounds: { w: width, h: maxTop, d: depth } };
}
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { partDisplayName } from "./names";
import { frames, inlineCatalog, part, templateState } from "./test-utils";
import type { HiveState } from "./types";
const catalog = inlineCatalog();
describe("partDisplayName", () => {
it("uses the kind label when a kind occurs once", () => {
expect(partDisplayName(templateState(), catalog, "f")).toBe("Boden");
expect(partDisplayName(templateState(), catalog, "b")).toBe("Zarge");
});
it("numbers same-kind parts from bottom to top", () => {
const state: HiveState = {
schemaVersion: 1,
stack: [part("f", "floor"), part("b1", "box", { frames: frames(10) }), part("b2", "box", { frames: frames(10) })],
};
expect(partDisplayName(state, catalog, "b1")).toBe("Zarge 1");
expect(partDisplayName(state, catalog, "b2")).toBe("Zarge 2");
});
it("prefers a custom label and does not number it", () => {
const state: HiveState = {
schemaVersion: 1,
stack: [part("b1", "box", { label: "Brutraum" }), part("b2", "box")],
};
expect(partDisplayName(state, catalog, "b1")).toBe("Brutraum");
expect(partDisplayName(state, catalog, "b2")).toBe("Zarge 2");
});
it("finds inserts and falls back for unknown ids", () => {
const state: HiveState = {
schemaVersion: 1,
stack: [part("b1", "box", { inserts: [part("e1", "evaporator")] })],
};
expect(partDisplayName(state, catalog, "e1")).toBe("Verdunster");
expect(partDisplayName(state, catalog, "nope")).toBe("Teil");
});
});
+38
View File
@@ -0,0 +1,38 @@
import { PART_KIND_LABELS } from "./labels";
import type { HiveCatalog, HivePart, HivePartKind, HiveState } from "./types";
const FALLBACK_NAME = "Teil";
interface Entry {
part: HivePart;
kind: HivePartKind | undefined;
}
/** Every part of the stack and its inserts, bottom to top (inserts after their host). */
function flatten(state: HiveState, catalog: HiveCatalog): Entry[] {
const entries: Entry[] = [];
const visit = (part: HivePart): void => {
entries.push({ part, kind: catalog.partTypes.get(part.partTypeId)?.kind });
for (const insert of part.inserts ?? []) visit(insert);
};
state.stack.forEach(visit);
return entries;
}
/**
* Name shown in the UI: the part's own label, else the kind label, numbered
* from bottom to top when the kind occurs more than once ("Zarge 1", "Zarge 2").
* Shared by the outline, the 3D labels and the sheets.
*/
export function partDisplayName(state: HiveState, catalog: HiveCatalog, partId: string): string {
const entries = flatten(state, catalog);
const entry = entries.find((candidate) => candidate.part.id === partId);
if (!entry) return FALLBACK_NAME;
if (entry.part.label) return entry.part.label;
if (!entry.kind) return FALLBACK_NAME;
const base = PART_KIND_LABELS[entry.kind];
const sameKind = entries.filter((candidate) => candidate.kind === entry.kind);
if (sameKind.length < 2) return base;
return `${base} ${sameKind.indexOf(entry) + 1}`;
}
+147
View File
@@ -0,0 +1,147 @@
import { describe, expect, it } from "vitest";
import { applyHiveOps } from "./ops";
import { deriveHiveFacts } from "./facts";
import { validateHiveState } from "./validate";
import {
fixtureDirExists,
inlineCatalog,
loadFixtureCases,
loadFixtureCatalog,
part,
templateState,
} from "./test-utils";
function deepFreeze<T>(value: T): T {
if (typeof value === "object" && value !== null && !Object.isFrozen(value)) {
Object.freeze(value);
Object.values(value).forEach(deepFreeze);
}
return value;
}
// Runs the shared backend fixtures; skipped when the image build does not see
// the backend folder (same pattern as lib/label-templates/placeholder-keys.spec.ts).
const hasFixtures = fixtureDirExists();
const cases = hasFixtures ? loadFixtureCases() : [];
describe("applyHiveOps backend fixtures", () => {
it.skipIf(!hasFixtures)("finds at least 20 cases", () => {
expect(cases.length).toBeGreaterThanOrEqual(20);
});
it.skipIf(!hasFixtures).each(cases.map((c) => [c.file, c.data] as const))("%s", (_file, data) => {
const catalog = loadFixtureCatalog(data.inactivePartTypeIds);
const result = applyHiveOps(
deepFreeze(structuredClone(data.state)),
deepFreeze(structuredClone(data.ops)),
catalog,
);
if (data.expect.ok) {
expect(result).toEqual({ ok: true, state: data.expect.state });
expect(validateHiveState(data.expect.state, catalog)).toEqual([]);
} else {
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.opIndex).toBe(data.expect.opIndex);
expect(result.reason.split(":")[0]).toBe(data.expect.reasonCode);
}
}
});
});
describe("applyHiveOps (inline catalog)", () => {
const catalog = inlineCatalog();
it("does not mutate a deep-frozen input", () => {
const state = deepFreeze(templateState());
const result = applyHiveOps(
state,
deepFreeze([{ op: "setLabel" as const, id: "b", label: "Brut" }]),
catalog,
);
expect(result.ok).toBe(true);
});
it("keeps a frame name when setFrame sends no name and removes it for null", () => {
const named = applyHiveOps(
templateState(),
[{ op: "setFrame", boxId: "b", slot: 2, type: "BROOD", name: "Königin" }],
catalog,
);
if (!named.ok) throw new Error(named.reason);
const kept = applyHiveOps(named.state, [{ op: "setFrame", boxId: "b", slot: 2, type: "HONEY" }], catalog);
const removed = applyHiveOps(
named.state,
[{ op: "setFrame", boxId: "b", slot: 2, type: "HONEY", name: null }],
catalog,
);
expect(kept.ok && kept.state.stack[1].frames?.[1]).toEqual({ slot: 2, type: "HONEY", name: "Königin" });
expect(removed.ok && removed.state.stack[1].frames?.[1]).toEqual({ slot: 2, type: "HONEY" });
});
it("never leaves an empty inserts array", () => {
const added = applyHiveOps(
templateState(),
[{ op: "addInsert", hostId: "b", part: part("e", "evaporator") }],
catalog,
);
if (!added.ok) throw new Error(added.reason);
const removed = applyHiveOps(added.state, [{ op: "removePart", id: "e" }], catalog);
expect(removed.ok && "inserts" in removed.state.stack[1]).toBe(false);
});
it("reports the failing op index and a reason code", () => {
const result = applyHiveOps(
templateState(),
[{ op: "setLabel", id: "b", label: "ok" }, { op: "removePart", id: "f" }],
catalog,
);
expect(result).toMatchObject({ ok: false, opIndex: 1 });
expect(!result.ok && result.reason.startsWith("RULE_FLOOR_BOTTOM")).toBe(true);
});
it("rejects more than 100 ops with LIMIT_OPS", () => {
const ops = Array.from({ length: 101 }, () => ({ op: "setLabel" as const, id: "b", label: null }));
const result = applyHiveOps(templateState(), ops, catalog);
expect(!result.ok && result.reason.startsWith("LIMIT_OPS")).toBe(true);
});
it("measures label length in UTF-16 code units", () => {
const label = "😀".repeat(21); // 42 code units, 21 code points
const result = applyHiveOps(templateState(), [{ op: "setLabel", id: "b", label }], catalog);
expect(!result.ok && result.reason.startsWith("LIMIT_TEXT")).toBe(true);
});
it("reports STATE_STEP beyond the 1e-9 tolerance and accepts inside it", () => {
const base = applyHiveOps(
templateState(),
[{ op: "addPart", part: part("fe", "feeder"), at: 2 }],
catalog,
);
if (!base.ok) throw new Error(base.reason);
const inside = applyHiveOps(base.state, [{ op: "setState", id: "fe", key: "syrupLiters", value: 1 + 1e-10 }], catalog);
const outside = applyHiveOps(base.state, [{ op: "setState", id: "fe", key: "syrupLiters", value: 1.0001 }], catalog);
expect(inside.ok).toBe(true);
expect(!outside.ok && outside.reason.startsWith("STATE_STEP")).toBe(true);
});
});
describe("deriveHiveFacts", () => {
const catalog = inlineCatalog();
it("lists every level field even at 0 and counts frames", () => {
const state = templateState();
state.stack.splice(2, 0, part("fe", "feeder"));
state.stack[1].frames = state.stack[1].frames!.map((f, i) =>
i < 3 ? { ...f, type: "BROOD" } : i < 5 ? { ...f, type: "HONEY" } : f,
);
const facts = deriveHiveFacts(state, catalog);
expect(facts).toMatchObject({ boxCount: 1, broodFrames: 3, honeyFrames: 2, builtFrames: 5 });
expect(facts.levels.map((l) => [l.key, l.value, l.feedType])).toEqual([
["syrupLiters", 0, "SUGAR_WATER"],
["fondantKg", 0, "FEED_PASTE"],
]);
expect(facts.hasFloorInsert).toBe(false);
expect(facts.evaporatorAcidMl).toBeNull();
});
});
+197
View File
@@ -0,0 +1,197 @@
import {
MAX_OPS,
type HiveApplyResult,
type HiveCatalog,
type HiveFrame,
type HiveOp,
type HivePart,
type HiveState,
} from "./types";
import { validateHiveState } from "./validate";
// Port of ApiCore-Backend/src/hive/hive-ops.ts: same semantics, same reason
// codes. Checked against the shared fixtures in ApiCore-Backend/test/fixtures.
interface Location {
part: HivePart;
list: HivePart[];
index: number;
isInsert: boolean;
}
/** Result of one op on the working copy: a reason string, or null on success. */
type OpOutcome = string | null;
function locate(state: HiveState, id: unknown): Location | null {
for (let index = 0; index < state.stack.length; index++) {
const part = state.stack[index];
if (part.id === id) return { part, list: state.stack, index, isInsert: false };
const inserts = part.inserts ?? [];
for (let i = 0; i < inserts.length; i++) {
if (inserts[i].id === id) return { part: inserts[i], list: inserts, index: i, isInsert: true };
}
}
return null;
}
function missing(id: unknown): string {
return `MISSING_ID: no part with id ${String(id)}`;
}
function clamp(value: unknown, max: number): number {
if (typeof value !== "number" || Number.isNaN(value)) return max;
return Math.min(Math.max(Math.trunc(value), 0), max);
}
/** Catalog membership and active check for a part to be added (and its inserts). */
function checkAddable(part: HivePart, catalog: HiveCatalog): OpOutcome {
if (typeof part !== "object" || part === null) return "MISSING_ID: part is not an object";
const type = catalog.partTypes.get(part.partTypeId);
if (!type) return `FOREIGN_PART_TYPE: ${String(part.partTypeId)} is not in this system`;
if (!type.active) return `INACTIVE_PART_TYPE: ${type.id} is inactive`;
for (const insert of part.inserts ?? []) {
const nested = checkAddable(insert, catalog);
if (nested) return nested;
}
return null;
}
/** Deep copy of an incoming part without empty `inserts` arrays. */
function normalisedCopy(part: HivePart): HivePart {
const copy = structuredClone(part);
if (Array.isArray(copy.inserts)) {
if (copy.inserts.length === 0) delete copy.inserts;
else copy.inserts = copy.inserts.map(normalisedCopy);
}
return copy;
}
type OpOf<K extends HiveOp["op"]> = Extract<HiveOp, { op: K }>;
function applyAddPart(state: HiveState, op: OpOf<"addPart">, catalog: HiveCatalog): OpOutcome {
const problem = checkAddable(op.part, catalog);
if (problem) return problem;
state.stack.splice(clamp(op.at, state.stack.length), 0, normalisedCopy(op.part));
return null;
}
function applyAddInsert(state: HiveState, op: OpOf<"addInsert">, catalog: HiveCatalog): OpOutcome {
const host = locate(state, op.hostId);
if (!host || host.isInsert) return missing(op.hostId);
const problem = checkAddable(op.part, catalog);
if (problem) return problem;
host.part.inserts = [...(host.part.inserts ?? []), normalisedCopy(op.part)];
return null;
}
function applyRemovePart(state: HiveState, op: OpOf<"removePart">): OpOutcome {
const found = locate(state, op.id);
if (!found) return missing(op.id);
found.list.splice(found.index, 1);
if (found.isInsert) {
const host = state.stack.find((p) => p.inserts === found.list);
if (host && found.list.length === 0) delete host.inserts;
}
return null;
}
function applyMovePart(state: HiveState, op: OpOf<"movePart">): OpOutcome {
const found = locate(state, op.id);
if (!found) return missing(op.id);
if (found.isInsert) return `RULE_INSERT_HOST: ${op.id} is an insert and cannot be moved`;
const [moved] = state.stack.splice(found.index, 1);
state.stack.splice(clamp(op.to, state.stack.length), 0, moved);
return null;
}
function applySetState(state: HiveState, op: OpOf<"setState">): OpOutcome {
const found = locate(state, op.id);
if (!found) return missing(op.id);
found.part.state = { ...found.part.state, [op.key]: op.value };
return null;
}
function locateBox(state: HiveState, boxId: unknown): Location | string {
const found = locate(state, boxId);
if (!found) return missing(boxId);
if (found.isInsert || !Array.isArray(found.part.frames)) {
return `FRAMES_LENGTH: ${String(boxId)} is not a box with frames`;
}
return found;
}
function applySetFrame(state: HiveState, op: OpOf<"setFrame">): OpOutcome {
const box = locateBox(state, op.boxId);
if (typeof box === "string") return box;
const frames = box.part.frames as HiveFrame[];
if (!Number.isInteger(op.slot) || op.slot < 1 || op.slot > frames.length) {
return `FRAMES_LENGTH: slot ${String(op.slot)} is outside 1..${frames.length}`;
}
// name: undefined keeps the current name, null removes it.
const current = frames[op.slot - 1];
const name = op.name === undefined ? current.name : (op.name ?? undefined);
frames[op.slot - 1] =
name === undefined
? { slot: op.slot, type: op.type }
: { slot: op.slot, type: op.type, name };
return null;
}
function applySetFrames(state: HiveState, op: OpOf<"setFrames">): OpOutcome {
const box = locateBox(state, op.boxId);
if (typeof box === "string") return box;
box.part.frames = structuredClone(op.frames);
return null;
}
function applySetLabel(state: HiveState, op: OpOf<"setLabel">): OpOutcome {
const found = locate(state, op.id);
if (!found) return missing(op.id);
if (op.label === null) delete found.part.label;
else found.part.label = op.label;
return null;
}
function applyOne(state: HiveState, op: HiveOp, catalog: HiveCatalog): OpOutcome {
switch (op?.op) {
case "addPart":
return applyAddPart(state, op, catalog);
case "addInsert":
return applyAddInsert(state, op, catalog);
case "removePart":
return applyRemovePart(state, op);
case "movePart":
return applyMovePart(state, op);
case "setState":
return applySetState(state, op);
case "setFrame":
return applySetFrame(state, op);
case "setFrames":
return applySetFrames(state, op);
case "setLabel":
return applySetLabel(state, op);
default:
return "UNKNOWN_OP: unsupported operation";
}
}
/**
* Applies ops in order and validates the whole state after each one.
* Never mutates its inputs: it works on a private deep copy.
*/
export function applyHiveOps(state: HiveState, ops: HiveOp[], catalog: HiveCatalog): HiveApplyResult {
if (ops.length > MAX_OPS) {
return {
ok: false,
opIndex: MAX_OPS,
reason: `LIMIT_OPS: at most ${MAX_OPS} operations per request`,
};
}
const working = structuredClone(state);
for (let opIndex = 0; opIndex < ops.length; opIndex++) {
const failure =
applyOne(working, ops[opIndex], catalog) ?? validateHiveState(working, catalog)[0] ?? null;
if (failure) return { ok: false, opIndex, reason: failure };
}
return { ok: true, state: working };
}
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { hiveReasonText } from "./reason-text";
describe("hiveReasonText", () => {
it("maps a reason code to a German sentence", () => {
expect(hiveReasonText("RULE_FLOOR_BOTTOM: the stack needs exactly one floor")).toBe(
"Der Boden muss ganz unten liegen.",
);
expect(hiveReasonText("MISSING_ID: no part with id x")).toContain("gibt es nicht mehr");
});
it("covers every code the ops and validator can produce", () => {
for (const code of [
"MISSING_ID", "DUPLICATE_ID", "FOREIGN_PART_TYPE", "INACTIVE_PART_TYPE", "FRAMES_LENGTH",
"RULE_INSERT_HOST", "RULE_INSERT_LIMIT", "RULE_FLOOR_BOTTOM", "RULE_ROOF_TOP",
"STATE_OUT_OF_RANGE", "STATE_STEP", "STATE_UNKNOWN_KEY", "UNKNOWN_FIELD", "UNKNOWN_OP",
"LIMIT_OPS", "LIMIT_PARTS", "LIMIT_TEXT",
]) {
expect(hiveReasonText(`${code}: x`)).not.toBe("Diese Änderung passt nicht zum Aufbau.");
}
});
it("falls back for unknown reasons", () => {
expect(hiveReasonText("WHATEVER: x")).toBe("Diese Änderung passt nicht zum Aufbau.");
});
});
@@ -0,0 +1,27 @@
const FALLBACK = "Diese Änderung passt nicht zum Aufbau.";
const TEXTS: Record<string, string> = {
MISSING_ID: "Ein Teil, auf das sich die Änderung bezieht, gibt es nicht mehr.",
DUPLICATE_ID: "Ein Teil kommt doppelt vor.",
FOREIGN_PART_TYPE: "Diese Teile-Art gehört nicht zum Beutensystem.",
INACTIVE_PART_TYPE: "Diese Teile-Art ist ausgeblendet.",
FRAMES_LENGTH: "Dieses Rähmchen gibt es in der Zarge nicht.",
RULE_INSERT_HOST: "Dieses Teil passt nicht an diese Stelle.",
RULE_INSERT_LIMIT: "Davon passt nur eines an diese Stelle.",
RULE_FLOOR_BOTTOM: "Der Boden muss ganz unten liegen.",
RULE_ROOF_TOP: "Das Dach muss ganz oben liegen.",
STATE_OUT_OF_RANGE: "Dieser Wert ist nicht erlaubt.",
STATE_STEP: "Dieser Wert passt nicht zur Schrittweite.",
STATE_UNKNOWN_KEY: "Dieses Feld gibt es bei diesem Teil nicht.",
UNKNOWN_FIELD: "Die Änderung enthält ein unbekanntes Feld.",
UNKNOWN_OP: "Diese Änderung wird nicht unterstützt.",
LIMIT_OPS: "Zu viele Änderungen auf einmal.",
LIMIT_PARTS: "Der Aufbau darf höchstens 30 Teile haben.",
LIMIT_TEXT: "Der Name ist zu lang.",
};
/** German sentence for a reason such as „RULE_FLOOR_BOTTOM: …“ from applyHiveOps or the server. */
export function hiveReasonText(reason: string): string {
const code = reason.split(":")[0].trim();
return TEXTS[code] ?? FALLBACK;
}
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import { deriveHiveFacts } from "./facts";
import { hiveFactsText, shortGermanDay, sourceLabel } from "./summary";
import { frames, inlineCatalog, part } from "./test-utils";
import type { HiveState } from "./types";
const catalog = inlineCatalog();
describe("shortGermanDay", () => {
it("writes 2026-10-12 as 12.10.", () => {
expect(shortGermanDay("2026-10-12")).toBe("12.10.");
});
});
describe("sourceLabel", () => {
it("names the sources the way the beekeeper knows them", () => {
expect(sourceLabel("CARD")).toBe("Karte");
expect(sourceLabel("EDITOR")).toBe("Umbau");
expect(sourceLabel("INSPECTION")).toBe("Durchsicht");
expect(sourceLabel("INIT")).toBe("Angelegt");
});
});
describe("hiveFactsText", () => {
it("lists boxes, brood, feed and the floor insert", () => {
const state: HiveState = {
schemaVersion: 1,
stack: [
part("f", "floor", { inserts: [part("fi", "floor-insert")] }),
part("b1", "box", { frames: frames(3, "BROOD") }),
part("b2", "box", { frames: frames(3, "BROOD") }),
part("fe", "feeder", { state: { syrupLiters: 3, fondantKg: 0 } }),
part("fe2", "feeder", { state: { syrupLiters: 2, fondantKg: 0 } }),
],
};
expect(hiveFactsText(deriveHiveFacts(state, catalog))).toBe(
"2 Zargen · 6 Brutwaben · Futter 5 l · Bausperre drin",
);
});
it("uses singular forms and leaves out what is not there", () => {
const state: HiveState = {
schemaVersion: 1,
stack: [part("b1", "box", { frames: frames(1, "BROOD") })],
};
expect(hiveFactsText(deriveHiveFacts(state, catalog))).toBe("1 Zarge · 1 Brutwabe");
});
it("shows acid of an evaporator", () => {
const state: HiveState = {
schemaVersion: 1,
stack: [part("b1", "box", { frames: frames(1), inserts: [part("ev", "evaporator", { state: { acidMl: 40 } })] })],
};
expect(hiveFactsText(deriveHiveFacts(state, catalog))).toContain("Säure 40 ml");
});
});
+49
View File
@@ -0,0 +1,49 @@
import type { HiveChangeSource, HiveFacts } from "./types";
const SOURCE_LABELS: Record<HiveChangeSource, string> = {
INIT: "Angelegt",
CARD: "Karte",
EDITOR: "Umbau",
INSPECTION: "Durchsicht",
AI_SUGGESTION: "Vorschlag",
};
export function sourceLabel(source: HiveChangeSource): string {
return SOURCE_LABELS[source];
}
/** "2026-10-12" -> "12.10." */
export function shortGermanDay(day: string): string {
const [, month, date] = day.split("-");
return `${date}.${month}.`;
}
const numberFormat = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 3 });
function plural(count: number, one: string, many: string): string {
return `${count} ${count === 1 ? one : many}`;
}
/** Feed levels (liquid and paste) summed per unit: "5 l" or "5 l, 2 kg". */
function feedText(facts: HiveFacts): string | null {
const perUnit = new Map<string, number>();
for (const level of facts.levels) {
if (!level.feedType || level.value <= 0) continue;
perUnit.set(level.unit, (perUnit.get(level.unit) ?? 0) + level.value);
}
if (perUnit.size === 0) return null;
const parts = [...perUnit].map(([unit, value]) => `${numberFormat.format(value)} ${unit}`);
return `Futter ${parts.join(", ")}`;
}
/** "2 Zargen · 6 Brutwaben · Futter 5 l · Bausperre drin" (spec 9.4); empty parts are left out. */
export function hiveFactsText(facts: HiveFacts): string {
const parts: (string | null)[] = [
plural(facts.boxCount, "Zarge", "Zargen"),
facts.broodFrames > 0 ? plural(facts.broodFrames, "Brutwabe", "Brutwaben") : null,
feedText(facts),
facts.evaporatorAcidMl !== null ? `Säure ${numberFormat.format(facts.evaporatorAcidMl)} ml` : null,
facts.hasFloorInsert ? "Bausperre drin" : null,
];
return parts.filter((part): part is string => part !== null).join(" · ");
}
+125
View File
@@ -0,0 +1,125 @@
import { existsSync, readFileSync, readdirSync } from "node:fs";
import path from "node:path";
import { catalogFromRows } from "./types";
import type { CatalogPartType, HiveCatalog, HiveFrame, HiveOp, HivePart, HiveState } from "./types";
// Test helpers shared by the hive specs. The backend fixtures sit next to the
// frontend in the monorepo; vitest runs from ApiCore-Frontend. The frontend
// image build only sees ApiCore-Frontend, so fixture specs skip there.
export const FIXTURE_DIR = path.resolve(process.cwd(), "../ApiCore-Backend/test/fixtures/hive-ops");
export interface FixtureCase {
name: string;
inactivePartTypeIds?: string[];
state: HiveState;
ops: HiveOp[];
expect:
| { ok: true; state: HiveState }
| { ok: false; opIndex: number; reasonCode: string };
}
interface CatalogFile {
systemId: string;
partTypes: CatalogPartType[];
}
export function fixtureDirExists(): boolean {
return existsSync(FIXTURE_DIR);
}
export function loadFixtureCases(): { file: string; data: FixtureCase }[] {
return readdirSync(FIXTURE_DIR)
.filter((file) => file.endsWith(".json") && file !== "catalog.json")
.sort()
.map((file) => ({
file,
data: JSON.parse(readFileSync(path.join(FIXTURE_DIR, file), "utf8")) as FixtureCase,
}));
}
export function loadFixtureCatalog(inactivePartTypeIds: string[] = []): HiveCatalog {
const file = JSON.parse(readFileSync(path.join(FIXTURE_DIR, "catalog.json"), "utf8")) as CatalogFile;
const rows = file.partTypes.map((row) => ({
...row,
active: row.active && !inactivePartTypeIds.includes(row.id),
}));
return catalogFromRows(rows, file.systemId);
}
// ---- Inline catalog for specs that must run without the backend folder ----
const noState: CatalogPartType["stateSchema"] = [];
function type(
id: string,
kind: CatalogPartType["kind"],
lengthMm: number,
widthMm: number,
heightMm: number,
extra: Partial<CatalogPartType> = {},
): CatalogPartType {
return {
id,
kind,
name: id,
lengthMm,
widthMm,
heightMm,
frameSlots: null,
stateSchema: noState,
active: true,
...extra,
};
}
/** Mirrors the Zander/Liebig catalog the backend fixtures use. */
export function inlineCatalog(): HiveCatalog {
return catalogFromRows(
[
type("floor", "FLOOR", 525, 430, 120),
type("floor-insert", "FLOOR_INSERT", 475, 375, 5),
type("box", "BOX", 525, 430, 230, { frameSlots: 10 }),
type("cover", "COVER_SHEET", 478, 378, 1),
type("feeder", "FEEDER", 520, 420, 70, {
stateSchema: [
{ key: "syrupLiters", label: "Flüssigfutter", kind: "level", unit: "l", max: 3, step: 0.5, feedType: "SUGAR_WATER" },
{ key: "fondantKg", label: "Futterteig", kind: "level", unit: "kg", max: 2.5, step: 0.5, feedType: "FEED_PASTE" },
],
}),
type("inner-lid", "INNER_LID", 525, 430, 35, {
stateSchema: [
{ key: "feedHole", label: "Futterloch", kind: "choice", options: ["OPEN", "CLOSED"], optionLabels: ["offen", "zu"] },
],
}),
type("insulation", "INSULATION", 465, 430, 20),
type("roof", "ROOF", 525, 430, 60),
type("evaporator", "EVAPORATOR", 200, 150, 120, {
stateSchema: [{ key: "acidMl", label: "Säure", kind: "level", unit: "ml", max: 300, step: 10 }],
}),
],
"sys",
);
}
export function frames(count: number, kind: HiveFrame["type"] = "FOUNDATION"): HiveFrame[] {
return Array.from({ length: count }, (_, index) => ({ slot: index + 1, type: kind }));
}
export function part(id: string, partTypeId: string, extra: Partial<HivePart> = {}): HivePart {
return { id, partTypeId, state: {}, ...extra };
}
/** Template of the system: floor, full box, cover, inner lid, insulation, roof. */
export function templateState(): HiveState {
return {
schemaVersion: 1,
stack: [
part("f", "floor"),
part("b", "box", { frames: frames(10) }),
part("c", "cover"),
part("l", "inner-lid"),
part("i", "insulation"),
part("r", "roof"),
],
};
}
+234
View File
@@ -0,0 +1,234 @@
// Frontend mirror of ApiCore-Backend/src/hive/hive-types.ts and the API
// shapes (spec 5.2, 6.1). Declared here on purpose: the Docker build only
// sees ApiCore-Frontend, so nothing is imported from the backend folder.
export type HivePartKind =
| "FLOOR"
| "BOX"
| "DIVIDER_BOARD"
| "QUEEN_EXCLUDER"
| "FEEDER"
| "COVER_SHEET"
| "PROPOLIS_SCREEN"
| "INNER_LID"
| "INSULATION"
| "ROOF"
| "FLOOR_INSERT"
| "EVAPORATOR";
export type HiveBoxDepth = "FULL" | "SHALLOW";
export type FrameType =
| "BROOD"
| "DRONE"
| "HONEY"
| "POLLEN"
| "FEED"
| "FOUNDATION"
| "EMPTY_COMB"
| "DRONE_FRAME"
| "DIVIDER"
| "NONE";
export const FRAME_TYPES: readonly FrameType[] = [
"BROOD",
"DRONE",
"HONEY",
"POLLEN",
"FEED",
"FOUNDATION",
"EMPTY_COMB",
"DRONE_FRAME",
"DIVIDER",
"NONE",
];
export const MAX_STACK_PARTS = 30;
export const MAX_OPS = 100;
export const MAX_TEXT_LENGTH = 40;
export const LEVEL_TOLERANCE = 1e-9;
export interface HiveFrame {
slot: number;
type: FrameType;
name?: string;
}
export interface HivePart {
id: string;
partTypeId: string;
label?: string;
state: Record<string, number | string>;
inserts?: HivePart[];
frames?: HiveFrame[];
}
export interface HiveState {
schemaVersion: 1;
stack: HivePart[];
}
export type HiveOp =
| { op: "addPart"; part: HivePart; at: number }
| { op: "addInsert"; hostId: string; part: HivePart }
| { op: "removePart"; id: string }
| { op: "movePart"; id: string; to: number }
| { op: "setState"; id: string; key: string; value: number | string }
| {
op: "setFrame";
boxId: string;
slot: number;
type: FrameType;
name?: string | null;
}
| { op: "setFrames"; boxId: string; frames: HiveFrame[] }
| { op: "setLabel"; id: string; label: string | null };
export interface LevelField {
key: string;
label: string;
kind: "level";
unit: string;
max: number;
step: number;
feedType?: string;
}
export interface ChoiceField {
key: string;
label: string;
kind: "choice";
options: string[];
optionLabels: string[];
}
export type StateField = LevelField | ChoiceField;
export interface CatalogPartType {
id: string;
kind: HivePartKind;
name: string;
lengthMm: number;
widthMm: number;
heightMm: number;
frameSlots: number | null;
stateSchema: StateField[];
active: boolean;
}
export interface HiveCatalog {
systemId: string;
partTypes: Map<string, CatalogPartType>;
}
export function catalogFromRows(rows: CatalogPartType[], systemId: string): HiveCatalog {
return { systemId, partTypes: new Map(rows.map((row) => [row.id, row])) };
}
export interface HiveLevelFact {
partId: string;
key: string;
label: string;
unit: string;
value: number;
max: number;
feedType?: string;
}
export interface HiveFacts {
boxCount: number;
broodFrames: number;
honeyFrames: number;
builtFrames: number;
levels: HiveLevelFact[];
hasFloorInsert: boolean;
evaporatorAcidMl: number | null;
}
export type HiveApplyResult =
| { ok: true; state: HiveState }
| { ok: false; opIndex: number; reason: string };
// ---- API shapes (hive.service.ts, hive-catalog.service.ts) ----
export type HiveChangeSource = "INIT" | "CARD" | "EDITOR" | "INSPECTION" | "AI_SUGGESTION";
/** Sources a client may send; INIT and INSPECTION are set by the server. */
export type ClientHiveChangeSource = "CARD" | "EDITOR";
export interface HivePartTypeDto extends CatalogPartType {
systemId: string;
articleRef: string | null;
boxDepth: HiveBoxDepth | null;
createdAt: string;
updatedAt: string;
}
export interface HiveSystemDto {
id: string;
name: string;
frameWidthMm: number;
frameHeightMm: number;
footprintLMm: number;
footprintWMm: number;
template: unknown;
active: boolean;
partTypes: HivePartTypeDto[];
createdAt: string;
updatedAt: string;
}
export interface HiveSetupView {
colonyId: string;
systemId: string;
version: number;
state: HiveState;
facts: HiveFacts;
updatedAt: string;
}
export interface HiveChangeListItem {
id: string;
version: number;
occurredOn: string;
source: HiveChangeSource;
inspectionId: string | null;
userId: string;
userName: string;
ops: unknown;
note: string | null;
createdAt: string;
}
export interface HiveHistoryPage {
items: HiveChangeListItem[];
nextCursor: string | null;
}
export interface HiveAtView {
version: number;
state: HiveState;
facts: HiveFacts;
occurredOn: string;
}
export interface CreateHiveInput {
systemId: string;
state: HiveState;
occurredOn: string;
}
export interface PostHiveChangeInput {
occurredOn: string;
source: ClientHiveChangeSource;
ops: HiveOp[];
note?: string;
}
/** Body of the 409 HIVE_CONFLICT answer. */
export interface HiveConflictBody {
code: "HIVE_CONFLICT";
opIndex: number;
reason: string;
current: { version: number; state: HiveState };
}
+217
View File
@@ -0,0 +1,217 @@
import {
FRAME_TYPES,
LEVEL_TOLERANCE,
MAX_STACK_PARTS,
MAX_TEXT_LENGTH,
type CatalogPartType,
type HiveCatalog,
type StateField,
} from "./types";
// Port of ApiCore-Backend/src/hive/hive-validate.ts. Check order decides which
// code is reported first: root shape (schemaVersion must be 1 ->
// STATE_OUT_OF_RANGE; extra root keys -> UNKNOWN_FIELD), LIMIT_PARTS, then per
// stack part: id, unknown fields (UNKNOWN_FIELD), type, label, state, frames
// (per frame: slot, unknown fields, type, name), inserts (each insert in the
// same order), then FLOOR, then ROOF. Text lengths are UTF-16 code units
// (`.length`).
const ROOT_KEYS = ["schemaVersion", "stack"];
const PART_KEYS = ["id", "partTypeId", "label", "state", "inserts", "frames"];
const FRAME_KEYS = ["slot", "type", "name"];
type Obj = Record<string, unknown>;
const isObj = (value: unknown): value is Obj =>
typeof value === "object" && value !== null && !Array.isArray(value);
function unknownFields(obj: Obj, allowed: string[], where: string, out: string[]): void {
for (const key of Object.keys(obj)) {
if (!allowed.includes(key)) out.push(`UNKNOWN_FIELD: ${where}.${key}`);
}
}
/** Checks one state value against its schema field; returns a reason or null. */
export function checkStateValue(field: StateField, value: unknown, where: string): string | null {
if (field.kind === "choice") {
if (typeof value !== "string" || !field.options.includes(value)) {
return `STATE_OUT_OF_RANGE: ${where} must be one of ${field.options.join("/")}`;
}
return null;
}
if (typeof value !== "number" || !Number.isFinite(value)) {
return `STATE_OUT_OF_RANGE: ${where} must be a number`;
}
if (value < 0 || value > field.max) {
return `STATE_OUT_OF_RANGE: ${where} must be between 0 and ${field.max}`;
}
const steps = value / field.step;
if (Math.abs(steps - Math.round(steps)) * field.step > LEVEL_TOLERANCE) {
return `STATE_STEP: ${where} must be a multiple of ${field.step}`;
}
return null;
}
function validateState(part: Obj, type: CatalogPartType, where: string, out: string[]): void {
if (!isObj(part.state)) {
out.push(`STATE_OUT_OF_RANGE: ${where} has no state object`);
return;
}
for (const [key, value] of Object.entries(part.state)) {
const field = type.stateSchema.find((f) => f.key === key);
if (!field) {
out.push(`STATE_UNKNOWN_KEY: ${where} has unknown key ${key}`);
continue;
}
const problem = checkStateValue(field, value, `${where}.${key}`);
if (problem) out.push(problem);
}
}
function validateFrame(frame: unknown, index: number, where: string, out: string[]): void {
if (!isObj(frame) || frame.slot !== index + 1) {
out.push(`FRAMES_LENGTH: ${where} frame ${index + 1} has a wrong slot`);
return;
}
unknownFields(frame, FRAME_KEYS, `${where}.frames[${index}]`, out);
if (!(FRAME_TYPES as readonly unknown[]).includes(frame.type)) {
out.push(`STATE_OUT_OF_RANGE: ${where} frame ${index + 1} has an unknown type`);
}
if (frame.name === undefined) return;
if (typeof frame.name !== "string") {
out.push(`STATE_OUT_OF_RANGE: ${where} frame ${index + 1} name must be a string`);
} else if (frame.name.length > MAX_TEXT_LENGTH) {
out.push(`LIMIT_TEXT: ${where} frame ${index + 1} name is too long`);
}
}
function validateFrames(part: Obj, type: CatalogPartType, where: string, out: string[]): void {
const slots = type.kind === "BOX" ? (type.frameSlots ?? 0) : 0;
const frames = part.frames;
if (type.kind !== "BOX") {
if (frames !== undefined && !(Array.isArray(frames) && frames.length === 0)) {
out.push(`FRAMES_LENGTH: ${where} is not a box and cannot have frames`);
}
return;
}
const list: unknown = frames === undefined && slots === 0 ? [] : frames;
if (!Array.isArray(list) || list.length !== slots) {
out.push(`FRAMES_LENGTH: ${where} needs exactly ${slots} frames`);
return;
}
list.forEach((frame: unknown, index) => validateFrame(frame, index, where, out));
}
interface WalkContext {
catalog: HiveCatalog;
seenIds: Set<string>;
out: string[];
}
/** Checks id, catalog membership, label, state, frames. Returns the type or null. */
function validatePartCommon(
part: unknown,
where: string,
ctx: WalkContext,
): { part: Obj; type: CatalogPartType } | null {
const { out } = ctx;
if (!isObj(part)) {
out.push(`MISSING_ID: ${where} is not an object`);
return null;
}
if (typeof part.id !== "string" || part.id === "") {
out.push(`MISSING_ID: ${where} has no id`);
} else if (ctx.seenIds.has(part.id)) {
out.push(`DUPLICATE_ID: ${part.id}`);
} else {
ctx.seenIds.add(part.id);
}
unknownFields(part, PART_KEYS, where, out);
const type =
typeof part.partTypeId === "string" ? ctx.catalog.partTypes.get(part.partTypeId) : undefined;
if (!type) {
out.push(`FOREIGN_PART_TYPE: ${where} part type is not in this system`);
return null;
}
if (part.label !== undefined) {
if (typeof part.label !== "string") {
out.push(`STATE_OUT_OF_RANGE: ${where} label must be a string`);
} else if (part.label.length > MAX_TEXT_LENGTH) {
out.push(`LIMIT_TEXT: ${where} label is too long`);
}
}
validateState(part, type, where, out);
validateFrames(part, type, where, out);
return { part, type };
}
function allowedInsertKind(hostType: CatalogPartType): string | null {
if (hostType.kind === "FLOOR") return "FLOOR_INSERT";
if (hostType.kind === "BOX") return "EVAPORATOR";
return null;
}
function validateInserts(host: Obj, hostType: CatalogPartType, where: string, ctx: WalkContext): void {
const { out } = ctx;
const inserts = host.inserts;
if (inserts === undefined) return;
if (!Array.isArray(inserts)) {
out.push(`RULE_INSERT_HOST: ${where} inserts must be a list`);
return;
}
const allowed = allowedInsertKind(hostType);
if (inserts.length > 0 && allowed === null) {
out.push(`RULE_INSERT_HOST: ${where} cannot hold inserts`);
}
if (allowed !== null && inserts.length > 1) {
out.push(`RULE_INSERT_LIMIT: ${where} holds at most one ${allowed}`);
}
inserts.forEach((insert: unknown, index) => {
const here = `${where}.inserts[${index}]`;
const checked = validatePartCommon(insert, here, ctx);
if (!checked) return;
if (allowed !== null && checked.type.kind !== allowed) {
out.push(`RULE_INSERT_HOST: ${here} must be ${allowed}`);
}
const nested = checked.part.inserts;
if (nested !== undefined && (!Array.isArray(nested) || nested.length > 0)) {
out.push(`RULE_INSERT_HOST: ${here} cannot hold inserts`);
}
});
}
function validateStackRules(kinds: (CatalogPartType | null)[], out: string[]): void {
const floors = kinds.filter((t) => t?.kind === "FLOOR").length;
if (floors !== 1 || kinds[0]?.kind !== "FLOOR") {
out.push("RULE_FLOOR_BOTTOM: the stack needs exactly one floor at the bottom");
}
const roofIndexes = kinds.map((t, i) => (t?.kind === "ROOF" ? i : -1)).filter((i) => i >= 0);
if (roofIndexes.length > 1 || (roofIndexes.length === 1 && roofIndexes[0] !== kinds.length - 1)) {
out.push("RULE_ROOF_TOP: the roof must be the single top part");
}
}
/** Validates a whole hive state. Empty result means valid. */
export function validateHiveState(state: unknown, catalog: HiveCatalog): string[] {
const out: string[] = [];
if (!isObj(state) || state.schemaVersion !== 1 || !Array.isArray(state.stack)) {
return ["STATE_OUT_OF_RANGE: state needs schemaVersion 1 and a stack list"];
}
unknownFields(state, ROOT_KEYS, "state", out);
if (state.stack.length > MAX_STACK_PARTS) {
out.push(`LIMIT_PARTS: at most ${MAX_STACK_PARTS} parts in the stack`);
}
const ctx: WalkContext = { catalog, seenIds: new Set(), out };
const types: (CatalogPartType | null)[] = [];
state.stack.forEach((entry: unknown, index) => {
const where = `stack[${index}]`;
const checked = validatePartCommon(entry, where, ctx);
types.push(checked?.type ?? null);
if (!checked) return;
if (checked.type.kind === "FLOOR_INSERT" || checked.type.kind === "EVAPORATOR") {
out.push(`RULE_INSERT_HOST: ${where} must be an insert, not a stack part`);
}
validateInserts(checked.part, checked.type, where, ctx);
});
validateStackRules(types, out);
return out;
}
@@ -0,0 +1,108 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { useEffectiveMotionLevel, useMotionPreference } from "./motion-preference";
type Listener = () => void;
const originalMatchMedia = window.matchMedia;
function mockReduceMotion(initial: boolean) {
let matches = initial;
const listeners = new Set<Listener>();
const mql = {
get matches() {
return matches;
},
media: "(prefers-reduced-motion: reduce)",
addEventListener: (_: string, listener: Listener) => listeners.add(listener),
removeEventListener: (_: string, listener: Listener) => listeners.delete(listener),
};
window.matchMedia = (() => mql) as unknown as typeof window.matchMedia;
return {
listenerCount: () => listeners.size,
set(next: boolean) {
matches = next;
listeners.forEach((listener) => listener());
},
};
}
describe("useEffectiveMotionLevel", () => {
beforeEach(() => window.localStorage.clear());
afterEach(() => {
window.matchMedia = originalMatchMedia;
});
it("returns subtle when reduce-motion matches and the stored level is full", () => {
mockReduceMotion(true);
window.localStorage.setItem("apicore.motion", "full");
const { result } = renderHook(() => useEffectiveMotionLevel());
expect(result.current).toBe("subtle");
});
it("updates after the media change event", () => {
const media = mockReduceMotion(false);
const { result } = renderHook(() => useEffectiveMotionLevel());
expect(result.current).toBe("full");
act(() => media.set(true));
expect(result.current).toBe("subtle");
});
it("updates on storage events for the motion key", () => {
mockReduceMotion(false);
const { result } = renderHook(() => useEffectiveMotionLevel());
act(() => {
window.localStorage.setItem("apicore.motion", "off");
window.dispatchEvent(new StorageEvent("storage", { key: "apicore.motion" }));
});
expect(result.current).toBe("off");
});
it("updates when setLevel of useMotionPreference is called in the same tab", () => {
mockReduceMotion(false);
const effective = renderHook(() => useEffectiveMotionLevel());
const preference = renderHook(() => useMotionPreference());
act(() => preference.result.current[1]("off"));
expect(effective.result.current).toBe("off");
});
it("stops listening after unmount", () => {
const media = mockReduceMotion(false);
const remove = vi.spyOn(window, "removeEventListener");
const { unmount } = renderHook(() => useEffectiveMotionLevel());
expect(media.listenerCount()).toBe(1);
unmount();
expect(media.listenerCount()).toBe(0);
const removed = remove.mock.calls.map(([type]) => type);
expect(removed).toContain("storage");
expect(removed).toContain("apicore:motion");
remove.mockRestore();
});
it("falls back to the applied document level when storage is unreadable", () => {
mockReduceMotion(false);
document.documentElement.dataset.motion = "off";
const getItem = vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
throw new Error("blocked");
});
try {
const { result } = renderHook(() => useEffectiveMotionLevel());
expect(result.current).toBe("off");
} finally {
getItem.mockRestore();
delete document.documentElement.dataset.motion;
}
});
});
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useState } from "react";
import { useCallback, useState, useSyncExternalStore } from "react";
/**
* How much the interface moves. A per-device preference (localStorage), not
@@ -20,6 +20,9 @@ export const MOTION_LEVELS: MotionLevel[] = ["full", "subtle", "off"];
export const DEFAULT_MOTION_LEVEL: MotionLevel = "full";
const STORAGE_KEY = "apicore.motion";
const REDUCE_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
/** Fired on window by setLevel (after applying); "storage" never fires in the own tab. */
export const MOTION_EVENT = "apicore:motion";
function isMotionLevel(value: unknown): value is MotionLevel {
return value === "full" || value === "subtle" || value === "off";
@@ -30,8 +33,11 @@ export function readMotionLevel(): MotionLevel {
const stored = window.localStorage.getItem(STORAGE_KEY);
return isMotionLevel(stored) ? stored : DEFAULT_MOTION_LEVEL;
} catch {
// Private mode / blocked storage: fall back to the default.
return DEFAULT_MOTION_LEVEL;
// Private mode / blocked storage: keep what is already applied to the
// document (setLevel still applies it), else the default.
const applied =
typeof document === "undefined" ? undefined : document.documentElement.dataset.motion;
return isMotionLevel(applied) ? applied : DEFAULT_MOTION_LEVEL;
}
}
@@ -43,10 +49,36 @@ export function applyMotionLevel(level: MotionLevel): void {
export function effectiveMotionLevel(level: MotionLevel = readMotionLevel()): MotionLevel {
const systemReduces =
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
window.matchMedia(REDUCE_MOTION_QUERY).matches;
return systemReduces && level === "full" ? "subtle" : level;
}
function subscribeToMotionLevel(onChange: () => void): () => void {
const media =
typeof window.matchMedia === "function" ? window.matchMedia(REDUCE_MOTION_QUERY) : null;
const onStorage = (event: StorageEvent) => {
// key === null: storage was cleared.
if (event.key === null || event.key === STORAGE_KEY) onChange();
};
media?.addEventListener("change", onChange);
window.addEventListener("storage", onStorage);
window.addEventListener(MOTION_EVENT, onChange);
return () => {
media?.removeEventListener("change", onChange);
window.removeEventListener("storage", onStorage);
window.removeEventListener(MOTION_EVENT, onChange);
};
}
/** Reactive effective level: follows the system setting, other tabs and setLevel. */
export function useEffectiveMotionLevel(): MotionLevel {
return useSyncExternalStore(
subscribeToMotionLevel,
() => effectiveMotionLevel(),
() => DEFAULT_MOTION_LEVEL,
);
}
export function useMotionPreference(): [MotionLevel, (level: MotionLevel) => void] {
// Lazy read: the app shell renders only on the client after login, and the
// document attribute itself is applied once in Providers.
@@ -62,6 +94,7 @@ export function useMotionPreference(): [MotionLevel, (level: MotionLevel) => voi
} catch {
// Not persisted - still applies for this visit.
}
window.dispatchEvent(new Event(MOTION_EVENT));
}, []);
return [level, setLevel];