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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-16 09:12:04 +02:00
1 parent ab45d6e24e
commit 1a57de8573
123 files changed
+7639 -355

No files matched your search

+60
View File
@@ -1,11 +1,15 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ApiError, apiFetch } from "@/lib/auth/api-client";
import { listPacks } from "@/lib/apiaries/offline-pack";
import { offlinePack, packColony, storedPack } from "@/lib/apiaries/offline-pack.fixtures";
import { setupView, systemDto } from "@/components/hive/test-fixtures";
import {
createHive,
fetchHive,
fetchHiveAt,
fetchHiveHistory,
fetchHiveSystems,
fetchHiveSystemsWithFallback,
fetchHiveTemplate,
hiveConflictOf,
hiveErrorCode,
@@ -18,6 +22,17 @@ vi.mock("@/lib/auth/api-client", async (importOriginal) => {
return { ...original, apiFetch: vi.fn() };
});
vi.mock("@/lib/apiaries/offline-pack", () => ({ listPacks: vi.fn() }));
const NETWORK_DOWN = new TypeError("Failed to fetch");
const packWithHive = storedPack(
offlinePack({
colonies: [packColony("colony-1", "V-1"), packColony("colony-2", "V-2")],
hives: { "colony-1": setupView() },
hiveCatalog: [systemDto()],
}),
);
describe("hive api", () => {
beforeEach(() => {
vi.mocked(apiFetch).mockReset();
@@ -66,4 +81,49 @@ describe("hive api", () => {
expect(hiveErrorCode(new ApiError(404, "nf", { code: "HIVE_NOT_FOUND" }))).toBe("HIVE_NOT_FOUND");
expect(hiveErrorCode(new ApiError(500, "x"))).toBeNull();
});
describe("offline reading", () => {
beforeEach(() => {
vi.mocked(listPacks).mockReset().mockResolvedValue([packWithHive]);
vi.mocked(apiFetch).mockRejectedValue(NETWORK_DOWN);
});
it("answers the hive from the pack when the network fails", async () => {
await expect(fetchHive("colony-1", "user-1")).resolves.toEqual(setupView());
expect(listPacks).toHaveBeenCalledWith("user-1");
});
it("answers HIVE_NOT_FOUND for a colony the pack knows has no setup", async () => {
const error = await fetchHive("colony-2", "user-1").catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(ApiError);
expect(hiveErrorCode(error)).toBe("HIVE_NOT_FOUND");
});
it("passes the network error on when the pack cannot tell (older pack, unknown colony)", async () => {
vi.mocked(listPacks).mockResolvedValue([storedPack(offlinePack())]);
await expect(fetchHive("colony-1", "user-1")).rejects.toBe(NETWORK_DOWN);
vi.mocked(listPacks).mockResolvedValue([packWithHive]);
await expect(fetchHive("unknown", "user-1")).rejects.toBe(NETWORK_DOWN);
});
it("never covers up a server answer with the pack", async () => {
vi.mocked(apiFetch).mockRejectedValue(new ApiError(404, "nf", { code: "HIVE_NOT_FOUND" }));
await expect(fetchHive("colony-1", "user-1")).rejects.toBeInstanceOf(ApiError);
expect(listPacks).not.toHaveBeenCalled();
});
it("reads the server only without a user", async () => {
await expect(fetchHive("colony-1")).rejects.toBe(NETWORK_DOWN);
expect(listPacks).not.toHaveBeenCalled();
});
it("falls back to the catalog of the pack for the systems", async () => {
await expect(fetchHiveSystemsWithFallback("user-1")).resolves.toEqual([systemDto()]);
});
it("passes the network error on when no pack carries a catalog", async () => {
vi.mocked(listPacks).mockResolvedValue([storedPack(offlinePack())]);
await expect(fetchHiveSystemsWithFallback("user-1")).rejects.toBe(NETWORK_DOWN);
});
});
});
+33 -3
View File
@@ -1,5 +1,7 @@
import { ApiError, apiFetch } from "@/lib/auth/api-client";
import { idempotencyHeaders } from "@/lib/offline/idempotency";
import { withPackFallback } from "@/lib/offline/pack-fallback";
import { hiveCatalogFromPacks, hiveFromPacks } from "./pack";
import type {
CreateHiveInput,
HiveAtView,
@@ -19,13 +21,41 @@ export const hiveKeys = {
const colonyPath = (colonyId: string) => `/colonies/${encodeURIComponent(colonyId)}/hive`;
/** Every system with its part types (usable as a plain queryFn). */
export function fetchHiveSystems(): Promise<HiveSystemDto[]> {
return apiFetch<HiveSystemDto[]>("/hive-systems");
}
/** 404 HIVE_NOT_FOUND when the colony has no setup yet. */
export function fetchHive(colonyId: string): Promise<HiveSetupView> {
return apiFetch<HiveSetupView>(colonyPath(colonyId));
/**
* Like fetchHiveSystems; when the network fails, the catalog of the user's
* offline packs answers (spec 10).
*/
export function fetchHiveSystemsWithFallback(
ownerUserId: string | null,
fromServer: () => Promise<HiveSystemDto[]> = fetchHiveSystems,
): Promise<HiveSystemDto[]> {
if (!ownerUserId) return fromServer();
return withPackFallback(fromServer, hiveCatalogFromPacks, ownerUserId);
}
/**
* 404 HIVE_NOT_FOUND when the colony has no setup yet. Only when the network
* fails, the colony's offline pack answers - also with that 404 when the pack
* knows the colony has no setup.
*/
export function fetchHive(colonyId: string, ownerUserId: string | null = null): Promise<HiveSetupView> {
const fromServer = () => apiFetch<HiveSetupView>(colonyPath(colonyId));
if (!ownerUserId) return fromServer();
return withPackFallback(
fromServer,
(packs) => {
const packed = hiveFromPacks(packs, colonyId);
if (!packed) return null;
if (!packed.setup) throw new ApiError(404, "Hive not found", { code: "HIVE_NOT_FOUND" });
return packed.setup;
},
ownerUserId,
);
}
/** Template of a system with fresh ids; 409 TEMPLATE_NOT_SET while it is empty. */
+3 -3
View File
@@ -1,5 +1,5 @@
import { apiFetch } from "@/lib/auth/api-client";
import type { HiveBoxDepth, HivePartKind, HivePartTypeDto, HiveSystemDto, StateField } from "./types";
import type { HiveBoxDepth, HiveTemplate, HivePartKind, HivePartTypeDto, HiveSystemDto, StateField } from "./types";
// Admin writes of the hive catalog (hive-systems:manage). Reads stay in api.ts
// (`fetchHiveSystems`); callers refresh them via `hiveKeys.systems`.
@@ -12,8 +12,8 @@ export interface HiveSystemInput {
footprintWMm: number;
}
/** Template editing is not part of this screen (it is saved from the editor). */
export type HiveSystemPatch = Partial<HiveSystemInput> & { active?: boolean };
/** `template` is saved from the hive editor („Als Vorlage speichern“), not from the catalog screen. */
export type HiveSystemPatch = Partial<HiveSystemInput> & { active?: boolean; template?: HiveTemplate };
export interface HivePartTypeInput {
kind: HivePartKind;
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { offlinePack, packColony, storedPack } from "@/lib/apiaries/offline-pack.fixtures";
import { setupView, systemDto } from "@/components/hive/test-fixtures";
import { hiveCatalogFromPacks, hiveFromPacks } from "./pack";
const withHive = storedPack(
offlinePack({
apiaryId: "apiary-1",
colonies: [packColony("c1", "V-1"), packColony("c2", "V-2")],
hives: { c1: setupView(undefined, "c1") },
hiveCatalog: [systemDto()],
}),
{ savedAt: "2026-10-13T10:00:00.000Z" },
);
const oldPack = storedPack(offlinePack({ apiaryId: "apiary-2", colonies: [packColony("c9", "V-9")] }), {
savedAt: "2026-10-01T10:00:00.000Z",
});
describe("hive pack readers", () => {
it("returns the setup of a colony in the pack", () => {
expect(hiveFromPacks([withHive, oldPack], "c1")?.setup?.colonyId).toBe("c1");
});
it("tells a colony without a hive from a pack that cannot tell", () => {
expect(hiveFromPacks([withHive], "c2")).toEqual({ setup: null });
expect(hiveFromPacks([oldPack], "c9")).toBeNull();
expect(hiveFromPacks([withHive], "unknown")).toBeNull();
});
it("prefers the newest pack that lists the colony", () => {
const newer = storedPack(
offlinePack({
apiaryId: "apiary-3",
colonies: [packColony("c1", "V-1")],
hives: { c1: setupView(undefined, "c1", 9) },
}),
{ savedAt: "2026-10-14T10:00:00.000Z" },
);
expect(hiveFromPacks([withHive, newer], "c1")?.setup?.version).toBe(9);
});
it("takes the catalog from the newest pack that has one", () => {
expect(hiveCatalogFromPacks([oldPack, withHive])?.[0].id).toBe("sys");
expect(hiveCatalogFromPacks([oldPack])).toBeNull();
});
});
+30
View File
@@ -0,0 +1,30 @@
import type { StoredPack } from "@/lib/offline/db";
import type { HiveSetupView, HiveSystemDto } from "./types";
// What the offline packs say about hives (spec 10). Both fields are optional:
// a pack from before the hive card says nothing, and "nothing" is not "no hive".
function newestFirst(packs: readonly StoredPack[]): StoredPack[] {
return [...packs].sort((a, b) => b.savedAt.localeCompare(a.savedAt));
}
/**
* The colony's hive per its newest pack. `null`: no pack lists the colony or
* the pack predates hives, so it cannot tell. `{ setup: null }`: the pack
* knows the colony and the colony has no hive yet.
*/
export function hiveFromPacks(
packs: readonly StoredPack[],
colonyId: string,
): { setup: HiveSetupView | null } | null {
const holding = newestFirst(packs).find((stored) =>
stored.pack.colonies.some((colony) => colony.id === colonyId),
);
if (!holding?.pack.hives) return null;
return { setup: holding.pack.hives[colonyId] ?? null };
}
/** The hive systems of the newest pack that carries them; null when none does. */
export function hiveCatalogFromPacks(packs: readonly StoredPack[]): HiveSystemDto[] | null {
return newestFirst(packs).find((stored) => stored.pack.hiveCatalog)?.pack.hiveCatalog ?? null;
}
@@ -0,0 +1,107 @@
import "fake-indexeddb/auto";
import { IDBFactory } from "fake-indexeddb";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { frames, inlineCatalog, part } from "@/lib/hive/test-utils";
import type { HiveOp, HiveSetupView, HiveState } from "@/lib/hive/types";
import { deriveHiveFacts } from "./facts";
type DbModule = typeof import("@/lib/offline/db");
type PendingModule = typeof import("./pending");
const catalog = inlineCatalog();
const COLONY = "colony-1";
const day = "2026-10-15";
const state: HiveState = {
schemaVersion: 1,
stack: [part("f", "floor"), part("b", "box", { frames: frames(10) }), part("r", "roof")],
};
const setup: HiveSetupView = {
colonyId: COLONY,
systemId: "sys",
version: 1,
state,
facts: deriveHiveFacts(state, catalog),
updatedAt: "2026-10-15T10:00:00.000Z",
};
const label = (value: string): HiveOp => ({ op: "setLabel", id: "b", label: value });
let db: DbModule;
let pending: PendingModule;
async function hiveChange(ops: HiveOp[], owner = "user-1", colonyId = COLONY) {
await db.queueHiveChange({ colonyId, occurredOn: day, source: "CARD", ops }, `k-${Math.random()}`, owner);
}
const inspection = (colonyId = COLONY, hive?: HiveOp[]) => ({
colonyId,
date: "2026-10-15T00:00:00.000Z",
...(hive ? { hiveChange: { ops: hive } } : {}),
});
/** Entries queued in the same millisecond keep their order only by the clock moving. */
async function tick(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 3));
}
describe("pending hive changes", () => {
beforeEach(async () => {
globalThis.indexedDB = new IDBFactory();
vi.resetModules();
db = await import("@/lib/offline/db");
pending = await import("./pending");
});
it("merges both queues in queuedAt order", async () => {
await hiveChange([label("eins")]);
await tick();
await db.queueInspection(inspection(COLONY, [label("zwei")]), "k-i", "user-1");
await tick();
await hiveChange([label("drei")]);
const ops = await pending.pendingHiveOps(COLONY, "user-1");
expect(ops).toEqual([label("eins"), label("zwei"), label("drei")]);
});
it("ignores other colonies, other owners, inspections without hive change and rejected entries", async () => {
await hiveChange([label("andere Wabe")], "user-1", "colony-2");
await hiveChange([label("fremd")], "user-2");
await db.queueInspection(inspection(COLONY), "k-plain", "user-1");
await hiveChange([label("abgelehnt")]);
const rejected = (await db.getPendingEntries("pending-hive-changes")).find(
(entry) => JSON.stringify(entry.input.ops).includes("abgelehnt"),
);
await db.markFailed("pending-hive-changes", rejected?.id ?? -1, { status: 409, message: "x", at: day });
expect(await pending.pendingHiveOps(COLONY, "user-1")).toEqual([]);
expect(await pending.pendingHiveChanges(COLONY, "user-1")).toHaveLength(0);
});
it("classifies an entry as either waiting or rejected, never both", async () => {
await hiveChange([label("a")]);
expect(await pending.hasRejectedHiveChange(COLONY, "user-1")).toBe(false);
const [entry] = await db.getPendingEntries("pending-hive-changes");
await db.markFailed("pending-hive-changes", entry.id, { status: 409, message: "x", at: day });
expect(await pending.hasRejectedHiveChange(COLONY, "user-1")).toBe(true);
expect(await pending.pendingHiveChanges(COLONY, "user-1")).toHaveLength(0);
});
it("counts the waiting changes, not their ops", async () => {
await hiveChange([label("a"), label("b")]);
expect(await pending.pendingHiveChanges(COLONY, "user-1")).toHaveLength(1);
});
it("applies waiting ops to the loaded setup", () => {
const shown = pending.withPendingOps(setup, [label("Brut")], catalog);
expect(shown.state.stack[1].label).toBe("Brut");
expect(shown.version).toBe(1);
});
it("shows the setup unchanged when the ops do not fit it", () => {
const bad: HiveOp = { op: "removePart", id: "gone" };
expect(pending.withPendingOps(setup, [bad], catalog)).toBe(setup);
expect(pending.withPendingOps(setup, [], catalog)).toBe(setup);
});
});
+93
View File
@@ -0,0 +1,93 @@
import { getPendingEntries, hasHiveChange, isForeignEntry } from "@/lib/offline/db";
import type { PendingEntry } from "@/lib/offline/db";
import { deriveHiveFacts } from "./facts";
import { applyHiveOps } from "./ops";
import type { HiveCatalog, HiveOp, HiveSetupView } from "./types";
/** One waiting change of a colony's hive: a card change, or the hive part of a queued inspection. */
export interface PendingHiveChange {
ops: HiveOp[];
queuedAt: string;
}
function opsOfInspection(input: unknown): HiveOp[] {
return (input as { hiveChange?: { ops?: HiveOp[] } }).hiveChange?.ops ?? [];
}
function isWaiting(entry: PendingEntry<unknown>, ownerUserId: string): boolean {
// A rejected entry is not waiting for network: the queue sheet shows it.
return !isForeignEntry(entry, ownerUserId) && !entry.lastError;
}
/** True when a queued hive change of the colony was refused by the server and still waits for the user. */
export async function hasRejectedHiveChange(colonyId: string, ownerUserId: string): Promise<boolean> {
const [hive, inspections] = await Promise.all([
getPendingEntries("pending-hive-changes"),
getPendingEntries("pending-inspections"),
]);
const rejected = (entry: PendingEntry<{ colonyId: string }>) =>
!isForeignEntry(entry, ownerUserId) && entry.input.colonyId === colonyId && !!entry.lastError;
return hive.some(rejected) || inspections.some((entry) => rejected(entry) && hasHiveChange(entry.input));
}
/**
* True while any hive change of the colony is queued for the user, rejected
* ones included: a new change must queue behind it, not overtake it.
*/
export async function hasQueuedHiveChange(colonyId: string, ownerUserId: string): Promise<boolean> {
const [hive, inspections] = await Promise.all([
getPendingEntries("pending-hive-changes"),
getPendingEntries("pending-inspections"),
]);
const mine = (entry: PendingEntry<{ colonyId: string }>) =>
!isForeignEntry(entry, ownerUserId) && entry.input.colonyId === colonyId;
return hive.some(mine) || inspections.some((entry) => mine(entry) && hasHiveChange(entry.input));
}
/**
* The colony's waiting hive changes of the user, oldest first, from both
* sources: the queue `pending-hive-changes` and the `hiveChange` of queued
* inspections.
*/
export async function pendingHiveChanges(
colonyId: string,
ownerUserId: string,
): Promise<PendingHiveChange[]> {
const [hive, inspections] = await Promise.all([
getPendingEntries("pending-hive-changes"),
getPendingEntries("pending-inspections"),
]);
const fromHive = hive
.filter((entry) => isWaiting(entry, ownerUserId) && entry.input.colonyId === colonyId)
.map((entry) => ({ ops: entry.input.ops, queuedAt: entry.queuedAt }));
const fromInspections = inspections
.filter(
(entry) =>
isWaiting(entry, ownerUserId) &&
entry.input.colonyId === colonyId &&
hasHiveChange(entry.input),
)
.map((entry) => ({ ops: opsOfInspection(entry.input), queuedAt: entry.queuedAt }));
return [...fromHive, ...fromInspections].sort((a, b) => a.queuedAt.localeCompare(b.queuedAt));
}
/** All ops of the colony's waiting hive changes, in the order they will be sent. */
export async function pendingHiveOps(colonyId: string, ownerUserId: string): Promise<HiveOp[]> {
return (await pendingHiveChanges(colonyId, ownerUserId)).flatMap((change) => change.ops);
}
/**
* The setup with the waiting ops applied, for the card. When they do not fit
* the loaded state (it moved on), the setup is shown as it is - the queue
* reports the conflict when it is sent.
*/
export function withPendingOps(
setup: HiveSetupView,
ops: readonly HiveOp[],
catalog: HiveCatalog,
): HiveSetupView {
if (ops.length === 0) return setup;
const applied = applyHiveOps(setup.state, [...ops], catalog);
if (!applied.ok) return setup;
return { ...setup, state: applied.state, facts: deriveHiveFacts(applied.state, catalog) };
}
+8
View File
@@ -66,6 +66,14 @@ export interface HivePart {
slot?: number;
}
/** A stored template part: no ids (the server stamps fresh ones when a hive starts from it). */
export type HiveTemplatePart = Omit<HivePart, "id" | "inserts"> & { inserts?: HiveTemplatePart[] };
export interface HiveTemplate {
schemaVersion: 1;
stack: HiveTemplatePart[];
}
export interface HiveState {
schemaVersion: 1;
stack: HivePart[];