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

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

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

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

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

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

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

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

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

No files matched your search

@@ -1,5 +1,6 @@
import type { ApiaryStatus } from "./types";
import type { NextStep, SeasonSegment } from "@/lib/autopilot/types";
import type { HiveSetupView, HiveSystemDto } from "@/lib/hive/types";
import type { Colony, ColonyCondition, ColonyStatus } from "@/lib/colonies/types";
import type { Inspection } from "@/lib/inspections/types";
import type { Queen } from "@/lib/queens/types";
@@ -97,4 +98,11 @@ export interface OfflinePackV1 {
* returns as `steps`). Absent in packs cached before T4.
*/
nextSteps?: Record<string, NextStep[]>;
/**
* The hive setup per colony id: only colonies of this apiary that have one.
* Absent in packs cached before the hive card.
*/
hives?: Record<string, HiveSetupView>;
/** The hive systems with their part types. Absent in older packs. */
hiveCatalog?: HiveSystemDto[];
}
@@ -120,6 +120,8 @@ interface PackOptions {
inspections?: OfflinePackV1["inspections"];
tasks?: OfflinePackV1["tasks"];
inspectionsVisible?: boolean;
hives?: OfflinePackV1["hives"];
hiveCatalog?: OfflinePackV1["hiveCatalog"];
}
/** A pack as the server sends it: colonies, their overview rows and the sections. */
@@ -131,6 +133,8 @@ export function offlinePack({
inspections = [],
tasks = [],
inspectionsVisible = true,
hives,
hiveCatalog,
}: PackOptions = {}): OfflinePackV1 {
return {
version: 1,
@@ -145,6 +149,9 @@ export function offlinePack({
queens,
inspections,
tasks,
// Like the server: absent in packs from before the hive card.
...(hives ? { hives } : {}),
...(hiveCatalog ? { hiveCatalog } : {}),
};
}
+60
View File
@@ -1,11 +1,15 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ApiError, apiFetch } from "@/lib/auth/api-client";
import { listPacks } from "@/lib/apiaries/offline-pack";
import { offlinePack, packColony, storedPack } from "@/lib/apiaries/offline-pack.fixtures";
import { setupView, systemDto } from "@/components/hive/test-fixtures";
import {
createHive,
fetchHive,
fetchHiveAt,
fetchHiveHistory,
fetchHiveSystems,
fetchHiveSystemsWithFallback,
fetchHiveTemplate,
hiveConflictOf,
hiveErrorCode,
@@ -18,6 +22,17 @@ vi.mock("@/lib/auth/api-client", async (importOriginal) => {
return { ...original, apiFetch: vi.fn() };
});
vi.mock("@/lib/apiaries/offline-pack", () => ({ listPacks: vi.fn() }));
const NETWORK_DOWN = new TypeError("Failed to fetch");
const packWithHive = storedPack(
offlinePack({
colonies: [packColony("colony-1", "V-1"), packColony("colony-2", "V-2")],
hives: { "colony-1": setupView() },
hiveCatalog: [systemDto()],
}),
);
describe("hive api", () => {
beforeEach(() => {
vi.mocked(apiFetch).mockReset();
@@ -66,4 +81,49 @@ describe("hive api", () => {
expect(hiveErrorCode(new ApiError(404, "nf", { code: "HIVE_NOT_FOUND" }))).toBe("HIVE_NOT_FOUND");
expect(hiveErrorCode(new ApiError(500, "x"))).toBeNull();
});
describe("offline reading", () => {
beforeEach(() => {
vi.mocked(listPacks).mockReset().mockResolvedValue([packWithHive]);
vi.mocked(apiFetch).mockRejectedValue(NETWORK_DOWN);
});
it("answers the hive from the pack when the network fails", async () => {
await expect(fetchHive("colony-1", "user-1")).resolves.toEqual(setupView());
expect(listPacks).toHaveBeenCalledWith("user-1");
});
it("answers HIVE_NOT_FOUND for a colony the pack knows has no setup", async () => {
const error = await fetchHive("colony-2", "user-1").catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(ApiError);
expect(hiveErrorCode(error)).toBe("HIVE_NOT_FOUND");
});
it("passes the network error on when the pack cannot tell (older pack, unknown colony)", async () => {
vi.mocked(listPacks).mockResolvedValue([storedPack(offlinePack())]);
await expect(fetchHive("colony-1", "user-1")).rejects.toBe(NETWORK_DOWN);
vi.mocked(listPacks).mockResolvedValue([packWithHive]);
await expect(fetchHive("unknown", "user-1")).rejects.toBe(NETWORK_DOWN);
});
it("never covers up a server answer with the pack", async () => {
vi.mocked(apiFetch).mockRejectedValue(new ApiError(404, "nf", { code: "HIVE_NOT_FOUND" }));
await expect(fetchHive("colony-1", "user-1")).rejects.toBeInstanceOf(ApiError);
expect(listPacks).not.toHaveBeenCalled();
});
it("reads the server only without a user", async () => {
await expect(fetchHive("colony-1")).rejects.toBe(NETWORK_DOWN);
expect(listPacks).not.toHaveBeenCalled();
});
it("falls back to the catalog of the pack for the systems", async () => {
await expect(fetchHiveSystemsWithFallback("user-1")).resolves.toEqual([systemDto()]);
});
it("passes the network error on when no pack carries a catalog", async () => {
vi.mocked(listPacks).mockResolvedValue([storedPack(offlinePack())]);
await expect(fetchHiveSystemsWithFallback("user-1")).rejects.toBe(NETWORK_DOWN);
});
});
});
+33 -3
View File
@@ -1,5 +1,7 @@
import { ApiError, apiFetch } from "@/lib/auth/api-client";
import { idempotencyHeaders } from "@/lib/offline/idempotency";
import { withPackFallback } from "@/lib/offline/pack-fallback";
import { hiveCatalogFromPacks, hiveFromPacks } from "./pack";
import type {
CreateHiveInput,
HiveAtView,
@@ -19,13 +21,41 @@ export const hiveKeys = {
const colonyPath = (colonyId: string) => `/colonies/${encodeURIComponent(colonyId)}/hive`;
/** Every system with its part types (usable as a plain queryFn). */
export function fetchHiveSystems(): Promise<HiveSystemDto[]> {
return apiFetch<HiveSystemDto[]>("/hive-systems");
}
/** 404 HIVE_NOT_FOUND when the colony has no setup yet. */
export function fetchHive(colonyId: string): Promise<HiveSetupView> {
return apiFetch<HiveSetupView>(colonyPath(colonyId));
/**
* Like fetchHiveSystems; when the network fails, the catalog of the user's
* offline packs answers (spec 10).
*/
export function fetchHiveSystemsWithFallback(
ownerUserId: string | null,
fromServer: () => Promise<HiveSystemDto[]> = fetchHiveSystems,
): Promise<HiveSystemDto[]> {
if (!ownerUserId) return fromServer();
return withPackFallback(fromServer, hiveCatalogFromPacks, ownerUserId);
}
/**
* 404 HIVE_NOT_FOUND when the colony has no setup yet. Only when the network
* fails, the colony's offline pack answers - also with that 404 when the pack
* knows the colony has no setup.
*/
export function fetchHive(colonyId: string, ownerUserId: string | null = null): Promise<HiveSetupView> {
const fromServer = () => apiFetch<HiveSetupView>(colonyPath(colonyId));
if (!ownerUserId) return fromServer();
return withPackFallback(
fromServer,
(packs) => {
const packed = hiveFromPacks(packs, colonyId);
if (!packed) return null;
if (!packed.setup) throw new ApiError(404, "Hive not found", { code: "HIVE_NOT_FOUND" });
return packed.setup;
},
ownerUserId,
);
}
/** Template of a system with fresh ids; 409 TEMPLATE_NOT_SET while it is empty. */
+3 -3
View File
@@ -1,5 +1,5 @@
import { apiFetch } from "@/lib/auth/api-client";
import type { HiveBoxDepth, HivePartKind, HivePartTypeDto, HiveSystemDto, StateField } from "./types";
import type { HiveBoxDepth, HiveTemplate, HivePartKind, HivePartTypeDto, HiveSystemDto, StateField } from "./types";
// Admin writes of the hive catalog (hive-systems:manage). Reads stay in api.ts
// (`fetchHiveSystems`); callers refresh them via `hiveKeys.systems`.
@@ -12,8 +12,8 @@ export interface HiveSystemInput {
footprintWMm: number;
}
/** Template editing is not part of this screen (it is saved from the editor). */
export type HiveSystemPatch = Partial<HiveSystemInput> & { active?: boolean };
/** `template` is saved from the hive editor („Als Vorlage speichern“), not from the catalog screen. */
export type HiveSystemPatch = Partial<HiveSystemInput> & { active?: boolean; template?: HiveTemplate };
export interface HivePartTypeInput {
kind: HivePartKind;
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { offlinePack, packColony, storedPack } from "@/lib/apiaries/offline-pack.fixtures";
import { setupView, systemDto } from "@/components/hive/test-fixtures";
import { hiveCatalogFromPacks, hiveFromPacks } from "./pack";
const withHive = storedPack(
offlinePack({
apiaryId: "apiary-1",
colonies: [packColony("c1", "V-1"), packColony("c2", "V-2")],
hives: { c1: setupView(undefined, "c1") },
hiveCatalog: [systemDto()],
}),
{ savedAt: "2026-10-13T10:00:00.000Z" },
);
const oldPack = storedPack(offlinePack({ apiaryId: "apiary-2", colonies: [packColony("c9", "V-9")] }), {
savedAt: "2026-10-01T10:00:00.000Z",
});
describe("hive pack readers", () => {
it("returns the setup of a colony in the pack", () => {
expect(hiveFromPacks([withHive, oldPack], "c1")?.setup?.colonyId).toBe("c1");
});
it("tells a colony without a hive from a pack that cannot tell", () => {
expect(hiveFromPacks([withHive], "c2")).toEqual({ setup: null });
expect(hiveFromPacks([oldPack], "c9")).toBeNull();
expect(hiveFromPacks([withHive], "unknown")).toBeNull();
});
it("prefers the newest pack that lists the colony", () => {
const newer = storedPack(
offlinePack({
apiaryId: "apiary-3",
colonies: [packColony("c1", "V-1")],
hives: { c1: setupView(undefined, "c1", 9) },
}),
{ savedAt: "2026-10-14T10:00:00.000Z" },
);
expect(hiveFromPacks([withHive, newer], "c1")?.setup?.version).toBe(9);
});
it("takes the catalog from the newest pack that has one", () => {
expect(hiveCatalogFromPacks([oldPack, withHive])?.[0].id).toBe("sys");
expect(hiveCatalogFromPacks([oldPack])).toBeNull();
});
});
+30
View File
@@ -0,0 +1,30 @@
import type { StoredPack } from "@/lib/offline/db";
import type { HiveSetupView, HiveSystemDto } from "./types";
// What the offline packs say about hives (spec 10). Both fields are optional:
// a pack from before the hive card says nothing, and "nothing" is not "no hive".
function newestFirst(packs: readonly StoredPack[]): StoredPack[] {
return [...packs].sort((a, b) => b.savedAt.localeCompare(a.savedAt));
}
/**
* The colony's hive per its newest pack. `null`: no pack lists the colony or
* the pack predates hives, so it cannot tell. `{ setup: null }`: the pack
* knows the colony and the colony has no hive yet.
*/
export function hiveFromPacks(
packs: readonly StoredPack[],
colonyId: string,
): { setup: HiveSetupView | null } | null {
const holding = newestFirst(packs).find((stored) =>
stored.pack.colonies.some((colony) => colony.id === colonyId),
);
if (!holding?.pack.hives) return null;
return { setup: holding.pack.hives[colonyId] ?? null };
}
/** The hive systems of the newest pack that carries them; null when none does. */
export function hiveCatalogFromPacks(packs: readonly StoredPack[]): HiveSystemDto[] | null {
return newestFirst(packs).find((stored) => stored.pack.hiveCatalog)?.pack.hiveCatalog ?? null;
}
@@ -0,0 +1,107 @@
import "fake-indexeddb/auto";
import { IDBFactory } from "fake-indexeddb";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { frames, inlineCatalog, part } from "@/lib/hive/test-utils";
import type { HiveOp, HiveSetupView, HiveState } from "@/lib/hive/types";
import { deriveHiveFacts } from "./facts";
type DbModule = typeof import("@/lib/offline/db");
type PendingModule = typeof import("./pending");
const catalog = inlineCatalog();
const COLONY = "colony-1";
const day = "2026-10-15";
const state: HiveState = {
schemaVersion: 1,
stack: [part("f", "floor"), part("b", "box", { frames: frames(10) }), part("r", "roof")],
};
const setup: HiveSetupView = {
colonyId: COLONY,
systemId: "sys",
version: 1,
state,
facts: deriveHiveFacts(state, catalog),
updatedAt: "2026-10-15T10:00:00.000Z",
};
const label = (value: string): HiveOp => ({ op: "setLabel", id: "b", label: value });
let db: DbModule;
let pending: PendingModule;
async function hiveChange(ops: HiveOp[], owner = "user-1", colonyId = COLONY) {
await db.queueHiveChange({ colonyId, occurredOn: day, source: "CARD", ops }, `k-${Math.random()}`, owner);
}
const inspection = (colonyId = COLONY, hive?: HiveOp[]) => ({
colonyId,
date: "2026-10-15T00:00:00.000Z",
...(hive ? { hiveChange: { ops: hive } } : {}),
});
/** Entries queued in the same millisecond keep their order only by the clock moving. */
async function tick(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 3));
}
describe("pending hive changes", () => {
beforeEach(async () => {
globalThis.indexedDB = new IDBFactory();
vi.resetModules();
db = await import("@/lib/offline/db");
pending = await import("./pending");
});
it("merges both queues in queuedAt order", async () => {
await hiveChange([label("eins")]);
await tick();
await db.queueInspection(inspection(COLONY, [label("zwei")]), "k-i", "user-1");
await tick();
await hiveChange([label("drei")]);
const ops = await pending.pendingHiveOps(COLONY, "user-1");
expect(ops).toEqual([label("eins"), label("zwei"), label("drei")]);
});
it("ignores other colonies, other owners, inspections without hive change and rejected entries", async () => {
await hiveChange([label("andere Wabe")], "user-1", "colony-2");
await hiveChange([label("fremd")], "user-2");
await db.queueInspection(inspection(COLONY), "k-plain", "user-1");
await hiveChange([label("abgelehnt")]);
const rejected = (await db.getPendingEntries("pending-hive-changes")).find(
(entry) => JSON.stringify(entry.input.ops).includes("abgelehnt"),
);
await db.markFailed("pending-hive-changes", rejected?.id ?? -1, { status: 409, message: "x", at: day });
expect(await pending.pendingHiveOps(COLONY, "user-1")).toEqual([]);
expect(await pending.pendingHiveChanges(COLONY, "user-1")).toHaveLength(0);
});
it("classifies an entry as either waiting or rejected, never both", async () => {
await hiveChange([label("a")]);
expect(await pending.hasRejectedHiveChange(COLONY, "user-1")).toBe(false);
const [entry] = await db.getPendingEntries("pending-hive-changes");
await db.markFailed("pending-hive-changes", entry.id, { status: 409, message: "x", at: day });
expect(await pending.hasRejectedHiveChange(COLONY, "user-1")).toBe(true);
expect(await pending.pendingHiveChanges(COLONY, "user-1")).toHaveLength(0);
});
it("counts the waiting changes, not their ops", async () => {
await hiveChange([label("a"), label("b")]);
expect(await pending.pendingHiveChanges(COLONY, "user-1")).toHaveLength(1);
});
it("applies waiting ops to the loaded setup", () => {
const shown = pending.withPendingOps(setup, [label("Brut")], catalog);
expect(shown.state.stack[1].label).toBe("Brut");
expect(shown.version).toBe(1);
});
it("shows the setup unchanged when the ops do not fit it", () => {
const bad: HiveOp = { op: "removePart", id: "gone" };
expect(pending.withPendingOps(setup, [bad], catalog)).toBe(setup);
expect(pending.withPendingOps(setup, [], catalog)).toBe(setup);
});
});
+93
View File
@@ -0,0 +1,93 @@
import { getPendingEntries, hasHiveChange, isForeignEntry } from "@/lib/offline/db";
import type { PendingEntry } from "@/lib/offline/db";
import { deriveHiveFacts } from "./facts";
import { applyHiveOps } from "./ops";
import type { HiveCatalog, HiveOp, HiveSetupView } from "./types";
/** One waiting change of a colony's hive: a card change, or the hive part of a queued inspection. */
export interface PendingHiveChange {
ops: HiveOp[];
queuedAt: string;
}
function opsOfInspection(input: unknown): HiveOp[] {
return (input as { hiveChange?: { ops?: HiveOp[] } }).hiveChange?.ops ?? [];
}
function isWaiting(entry: PendingEntry<unknown>, ownerUserId: string): boolean {
// A rejected entry is not waiting for network: the queue sheet shows it.
return !isForeignEntry(entry, ownerUserId) && !entry.lastError;
}
/** True when a queued hive change of the colony was refused by the server and still waits for the user. */
export async function hasRejectedHiveChange(colonyId: string, ownerUserId: string): Promise<boolean> {
const [hive, inspections] = await Promise.all([
getPendingEntries("pending-hive-changes"),
getPendingEntries("pending-inspections"),
]);
const rejected = (entry: PendingEntry<{ colonyId: string }>) =>
!isForeignEntry(entry, ownerUserId) && entry.input.colonyId === colonyId && !!entry.lastError;
return hive.some(rejected) || inspections.some((entry) => rejected(entry) && hasHiveChange(entry.input));
}
/**
* True while any hive change of the colony is queued for the user, rejected
* ones included: a new change must queue behind it, not overtake it.
*/
export async function hasQueuedHiveChange(colonyId: string, ownerUserId: string): Promise<boolean> {
const [hive, inspections] = await Promise.all([
getPendingEntries("pending-hive-changes"),
getPendingEntries("pending-inspections"),
]);
const mine = (entry: PendingEntry<{ colonyId: string }>) =>
!isForeignEntry(entry, ownerUserId) && entry.input.colonyId === colonyId;
return hive.some(mine) || inspections.some((entry) => mine(entry) && hasHiveChange(entry.input));
}
/**
* The colony's waiting hive changes of the user, oldest first, from both
* sources: the queue `pending-hive-changes` and the `hiveChange` of queued
* inspections.
*/
export async function pendingHiveChanges(
colonyId: string,
ownerUserId: string,
): Promise<PendingHiveChange[]> {
const [hive, inspections] = await Promise.all([
getPendingEntries("pending-hive-changes"),
getPendingEntries("pending-inspections"),
]);
const fromHive = hive
.filter((entry) => isWaiting(entry, ownerUserId) && entry.input.colonyId === colonyId)
.map((entry) => ({ ops: entry.input.ops, queuedAt: entry.queuedAt }));
const fromInspections = inspections
.filter(
(entry) =>
isWaiting(entry, ownerUserId) &&
entry.input.colonyId === colonyId &&
hasHiveChange(entry.input),
)
.map((entry) => ({ ops: opsOfInspection(entry.input), queuedAt: entry.queuedAt }));
return [...fromHive, ...fromInspections].sort((a, b) => a.queuedAt.localeCompare(b.queuedAt));
}
/** All ops of the colony's waiting hive changes, in the order they will be sent. */
export async function pendingHiveOps(colonyId: string, ownerUserId: string): Promise<HiveOp[]> {
return (await pendingHiveChanges(colonyId, ownerUserId)).flatMap((change) => change.ops);
}
/**
* The setup with the waiting ops applied, for the card. When they do not fit
* the loaded state (it moved on), the setup is shown as it is - the queue
* reports the conflict when it is sent.
*/
export function withPendingOps(
setup: HiveSetupView,
ops: readonly HiveOp[],
catalog: HiveCatalog,
): HiveSetupView {
if (ops.length === 0) return setup;
const applied = applyHiveOps(setup.state, [...ops], catalog);
if (!applied.ok) return setup;
return { ...setup, state: applied.state, facts: deriveHiveFacts(applied.state, catalog) };
}
+8
View File
@@ -66,6 +66,14 @@ export interface HivePart {
slot?: number;
}
/** A stored template part: no ids (the server stamps fresh ones when a hive starts from it). */
export type HiveTemplatePart = Omit<HivePart, "id" | "inserts"> & { inserts?: HiveTemplatePart[] };
export interface HiveTemplate {
schemaVersion: 1;
stack: HiveTemplatePart[];
}
export interface HiveState {
schemaVersion: 1;
stack: HivePart[];
@@ -1,4 +1,5 @@
import type { ColonyCondition } from "@/lib/colonies/types";
import type { HiveOp } from "@/lib/hive/types";
export interface Inspection {
id: string;
@@ -79,6 +80,12 @@ export interface CreateInspectionInput {
* server, which answers 201 either way.
*/
applyToColony?: ColonyTakeoverField[];
/**
* Hive changes made in the „Beute“ section (1-100 ops), saved with the
* inspection in one transaction. Creating only; needs colonies:update
* (403 HIVE_FORBIDDEN otherwise). Sent only when there are ops.
*/
hiveChange?: { ops: HiveOp[] };
}
/** `null` clears an optional field, an omitted one stays unchanged. */
+92 -5
View File
@@ -95,7 +95,7 @@ describe("offline queue database", () => {
it.each([
["a fresh install (0)", 0],
["version 1", 1],
])("opening from %s creates the plant report store at version 4", async (_name, from) => {
])("opening from %s creates the plant report store at version 5", async (_name, from) => {
if (from === 1) {
const old = await openDB(DB_NAME, 1, {
upgrade(database) {
@@ -120,7 +120,7 @@ describe("offline queue database", () => {
);
const upgraded = await openDB(DB_NAME);
expect(upgraded.version).toBe(4);
expect(upgraded.version).toBe(5);
expect([...upgraded.objectStoreNames]).toContain("pending-phenology-reports");
expect(await upgraded.count("pending-phenology-reports")).toBe(1);
expect(await upgraded.count("pending-inspections")).toBe(from === 1 ? 1 : 0);
@@ -166,11 +166,11 @@ describe("offline queue database", () => {
expect((await db.listQueue("user-1")).map((item) => item.label)).toContain("Pflanzenmeldung");
const upgraded = await openDB(DB_NAME);
expect(upgraded.version).toBe(4);
expect(upgraded.version).toBe(5);
upgraded.close();
});
it("upgrading v2 → v4 keeps already queued inspections", async () => {
it("upgrading v2 → v5 keeps already queued inspections", async () => {
await createVersion2Queue({
input: INSPECTION,
idempotencyKey: "key-from-v2",
@@ -192,7 +192,7 @@ describe("offline queue database", () => {
expect(await db.summarizeQueue("user-1")).toEqual({ own: 1, failed: 0, foreign: 0 });
const upgraded = await openDB(DB_NAME);
expect(upgraded.version).toBe(4);
expect(upgraded.version).toBe(5);
expect([...upgraded.objectStoreNames]).toEqual(
expect.arrayContaining([
...VERSION_2_STORES,
@@ -201,6 +201,7 @@ describe("offline queue database", () => {
"offline-packs",
"meta",
"pending-phenology-reports",
"pending-hive-changes",
]),
);
expect([...upgraded.transaction("pending-photos").store.indexNames].sort()).toEqual([
@@ -211,6 +212,92 @@ describe("offline queue database", () => {
upgraded.close();
});
it("db upgrade 4 -> 5 keeps every store and entry and adds the hive change queue", async () => {
const old = await openDB(DB_NAME, 4, {
upgrade(database) {
for (const store of [...VERSION_2_STORES, "pending-bulk", "pending-phenology-reports"]) {
database.createObjectStore(store, { keyPath: "id", autoIncrement: true });
}
database.createObjectStore("pending-photos", { keyPath: "id" });
database.createObjectStore("offline-packs", { keyPath: "apiaryId" });
database.createObjectStore("meta");
},
});
await old.add("pending-inspections", { input: INSPECTION, queuedAt: "2026-10-01T08:00:00.000Z" });
await old.add("pending-phenology-reports", { input: {}, queuedAt: "2026-10-01T09:00:00.000Z" });
old.close();
const db = await loadDb();
await db.queueHiveChange(
{ colonyId: "colony-1", occurredOn: "2026-10-15", source: "CARD", ops: [] },
"key-h",
"user-1",
);
const [hive] = await db.getPendingEntries("pending-hive-changes");
expect(hive).toMatchObject({ idempotencyKey: "key-h", ownerUserId: "user-1" });
const upgraded = await openDB(DB_NAME);
expect(upgraded.version).toBe(5);
expect(await upgraded.count("pending-inspections")).toBe(1);
expect(await upgraded.count("pending-phenology-reports")).toBe(1);
expect([...upgraded.objectStoreNames]).toEqual(
expect.arrayContaining(["pending-bulk", "pending-photos", "offline-packs", "meta", "pending-hive-changes"]),
);
upgraded.close();
});
it("lists a hive change under its own name and marks inspections that carry one", async () => {
const db = await loadDb();
await db.queueHiveChange(
{ colonyId: "colony-1", occurredOn: "2026-10-15", source: "CARD", ops: [] },
"key-h",
"user-1",
);
await db.queueInspection({ ...INSPECTION, hiveChange: { ops: [] } }, "key-i", "user-1");
await db.queueInspection(INSPECTION, "key-j", "user-1");
const items = await db.listQueue("user-1");
expect(items.map((item) => [item.label, item.hasHiveChange ?? false]).sort()).toEqual([
["Beute-Änderung", true],
["Durchsicht", false],
["Durchsicht", true],
]);
});
it("retryInspectionWithoutHiveChange strips the hive change, clears the rejection and keeps the key", async () => {
const db = await loadDb();
await db.queueInspection({ ...INSPECTION, hiveChange: { ops: [] } }, "key-i", "user-1");
const [entry] = await db.getPendingEntries("pending-inspections");
await db.markFailed("pending-inspections", entry.id, { ...REJECTION, status: 403 });
expect((await db.listQueue("user-1"))[0].canResendWithoutHiveChange).toBe(true);
await db.retryInspectionWithoutHiveChange(entry.id);
const [after] = await db.getPendingEntries("pending-inspections");
expect(after.input).toEqual(INSPECTION);
expect(after.lastError).toBeUndefined();
expect(after.idempotencyKey).toBe("key-i");
});
it("offers „ohne Beute-Änderung“ only for a 403 or a hive conflict, not for any other rejection", async () => {
const db = await loadDb();
await db.queueInspection({ ...INSPECTION, hiveChange: { ops: [] } }, "k1", "user-1");
await db.queueInspection({ ...INSPECTION, hiveChange: { ops: [] } }, "k2", "user-1");
const [plain, conflict] = await db.getPendingEntries("pending-inspections");
await db.markFailed("pending-inspections", plain.id, REJECTION);
await db.markFailed("pending-inspections", conflict.id, {
status: 409,
message: "Konflikt",
details: { code: "HIVE_CONFLICT" },
at: REJECTION.at,
});
const items = await db.listQueue("user-1");
expect(items.map((item) => item.canResendWithoutHiveChange ?? false)).toEqual([false, true]);
});
it("summarizeQueue separates own, failed and foreign entries", async () => {
const db = await loadDb();
await db.queueInspection(INSPECTION, "key-1", "user-1");
+74 -4
View File
@@ -8,12 +8,21 @@ import { CreateVarroaMeasurementInput } from "@/lib/varroa/types";
import { CreateSwarmControlCheckInput } from "@/lib/swarm-control/types";
import { CreateHarvestInput } from "@/lib/harvests/types";
import type { CreatePhenologyReportInput } from "@/lib/phenology/types";
import type { HiveOp } from "@/lib/hive/types";
import {
colonyIdsOfBulkInput,
invalidColonyIdsOfBody,
withoutColonies,
} from "@/lib/bulk/bulk-input";
/** A change of a colony's hive made on the card, waiting for network. */
export interface HiveChangeQueueInput {
colonyId: string;
occurredOn: string;
source: "CARD";
ops: HiveOp[];
}
/** What each single-record queue (one per quick-capture form) holds. */
export interface RecordQueueInputs {
"pending-inspections": CreateInspectionInput;
@@ -23,6 +32,7 @@ export interface RecordQueueInputs {
"pending-swarm-control-checks": CreateSwarmControlCheckInput;
"pending-harvests": CreateHarvestInput;
"pending-phenology-reports": CreatePhenologyReportInput;
"pending-hive-changes": HiveChangeQueueInput;
}
export type RecordStoreName = keyof RecordQueueInputs;
/** Every store whose entries wait to be sent and are shown in the queue sheet. */
@@ -110,11 +120,11 @@ export interface OfflineDB extends DBSchema, RecordStores {
const DB_NAME = "apicore-offline";
// v1 only had "pending-inspections", v2 added the other five quick-capture
// types, v3 adds bulk entries, photos, offline packs and meta, v4 adds plant
// reports (Pflanzenentwicklung). Entries already
// reports (Pflanzenentwicklung), v5 adds hive changes (Beute). Entries already
// queued on a phone must survive every upgrade, so each store is only created
// if it doesn't exist yet (guarded by oldVersion, per idb's upgrade()
// convention), never recreated.
const DB_VERSION = 4;
const DB_VERSION = 5;
const RECORD_STORES: readonly RecordStoreName[] = [
"pending-inspections",
@@ -124,6 +134,7 @@ const RECORD_STORES: readonly RecordStoreName[] = [
"pending-swarm-control-checks",
"pending-harvests",
"pending-phenology-reports",
"pending-hive-changes",
];
const QUEUE_STORES: readonly QueueStoreName[] = [...RECORD_STORES, "pending-bulk"];
@@ -135,6 +146,7 @@ const RECORD_LABELS: Record<RecordStoreName, string> = {
"pending-swarm-control-checks": "Schwarmkontrolle",
"pending-harvests": "Ernte",
"pending-phenology-reports": "Pflanzenmeldung",
"pending-hive-changes": "Beute-Änderung",
};
const BULK_LABELS: Record<BulkKind, string> = {
treatments: "Behandlung für mehrere Völker",
@@ -183,6 +195,9 @@ export function getDb(): Promise<IDBPDatabase<OfflineDB>> {
autoIncrement: true,
});
}
if (oldVersion < 5) {
db.createObjectStore("pending-hive-changes", { keyPath: "id", autoIncrement: true });
}
},
// A newer app version in another tab wants to upgrade: step aside
// instead of blocking it. The next call here opens the new version.
@@ -282,6 +297,14 @@ export function queuePhenologyReport(
return queue("pending-phenology-reports", input, idempotencyKey, ownerUserId);
}
export function queueHiveChange(
input: HiveChangeQueueInput,
idempotencyKey: string,
ownerUserId: string,
): Promise<void> {
return queue("pending-hive-changes", input, idempotencyKey, ownerUserId);
}
/**
* One entry for several colonies. The key belongs to this exact selection of
* colonies: it is stored with the entry and sent again unchanged.
@@ -445,6 +468,37 @@ export async function summarizeQueue(currentUserId: string): Promise<QueueSummar
);
}
/** The server refused the hive part: 403, or a conflict with the hive's current state. */
function isHiveRefusal(error: QueueError | undefined): boolean {
if (!error) return false;
const code = (error.details as { code?: unknown } | null | undefined)?.code;
return error.status === 403 || code === "HIVE_CONFLICT";
}
/** A queued inspection that also carries a change of the hive. */
export function hasHiveChange(input: unknown): boolean {
return (input as { hiveChange?: unknown } | null)?.hiveChange !== undefined;
}
/**
* Ohne Beute-Änderung senden: takes the hive change out of a queued
* inspection and clears its rejection, so the next sync run sends the rest.
* The key stays: the server stored nothing for the rejected try.
*/
export async function retryInspectionWithoutHiveChange(entryId: number): Promise<void> {
const db = await getDb();
const tx = db.transaction("pending-inspections", "readwrite");
const entry = await tx.store.get(entryId);
if (entry && hasHiveChange(entry.input)) {
const input = { ...entry.input };
delete input.hiveChange;
const cleared: PendingEntry<CreateInspectionInput> = { ...entry, input };
delete cleared.lastError;
await tx.store.put(cleared);
}
await tx.done;
}
export interface QueueItem {
store: string;
id: number | string;
@@ -452,6 +506,10 @@ export interface QueueItem {
queuedAt: string;
lastError?: QueueError;
photoCount?: number;
/** The entry changes a colony's hive (a hive entry, or an inspection with a hive change). */
hasHiveChange?: boolean;
/** A rejected inspection whose hive change can be left out and the rest sent. */
canResendWithoutHiveChange?: boolean;
/**
* A rejected bulk entry that can go out without the colonies the server
* refused (`codes`: the ones named in the loaded packs, maybe fewer).
@@ -501,19 +559,31 @@ export async function listQueue(currentUserId: string): Promise<QueueItem[]> {
queuedAt: entry.queuedAt,
...(entry.lastError ? { lastError: entry.lastError } : {}),
};
if (store === "pending-hive-changes") {
return { ...item, hasHiveChange: true };
}
if (store === "pending-bulk") {
const resendWithout = await resendWithoutOf(db, entry as PendingBulkEntry);
return resendWithout ? { ...item, resendWithout } : item;
}
if (store !== "pending-inspections" || !entry.idempotencyKey) {
if (store !== "pending-inspections") {
return item;
}
const hive = hasHiveChange((entry as PendingEntry<unknown>).input)
? {
hasHiveChange: true,
...(isHiveRefusal(entry.lastError) ? { canResendWithoutHiveChange: true } : {}),
}
: {};
if (!entry.idempotencyKey) {
return { ...item, ...hive };
}
const photoCount = await db.countFromIndex(
"pending-photos",
"inspectionIdempotencyKey",
entry.idempotencyKey,
);
return photoCount > 0 ? { ...item, photoCount } : item;
return photoCount > 0 ? { ...item, ...hive, photoCount } : { ...item, ...hive };
}),
);
return items.sort((a, b) => a.queuedAt.localeCompare(b.queuedAt));
@@ -7,6 +7,7 @@ import {
foreignEntriesWaiting,
photosAttached,
photosNotTakenOver,
queueRejectionText,
rejectionText,
sessionExpiredWithEntries,
} from "./queue-texts";
@@ -56,4 +57,32 @@ describe("rejectionText", () => {
it("passes other server reasons through", () => {
expect(rejectionText("Datei zu groß")).toBe("Datei zu groß");
});
describe("queueRejectionText", () => {
const conflict = {
status: 409,
message: "Conflict",
details: { code: "HIVE_CONFLICT", opIndex: 0, reason: "MISSING_ID: no part" },
};
it("words a 403 on a hive change", () => {
expect(queueRejectionText({ status: 403, message: "Forbidden" }, true)).toBe(
"Keine Berechtigung, die Beute zu ändern.",
);
});
it("words a HIVE_CONFLICT with the reason", () => {
expect(queueRejectionText(conflict, true)).toBe(
"Die Beute wurde inzwischen geändert. Ein Teil, auf das sich die Änderung bezieht, gibt es nicht mehr.",
);
});
it("leaves every other rejection as it was", () => {
expect(queueRejectionText({ status: 403, message: "Forbidden" }, false)).toBe("Forbidden");
expect(queueRejectionText(conflict, false)).toBe("Conflict");
expect(
queueRejectionText({ status: 409, message: "Idempotency-Key reused", details: { code: "X" } }, true),
).toBe("Idempotency-Key reused");
});
});
});
@@ -1,3 +1,5 @@
import { hiveReasonText } from "@/lib/hive/reason-text";
// German counts for the offline queue: "1 Eintrag wartet", "2 Einträge warten".
function count(n: number, one: string, many: string): string {
@@ -42,6 +44,38 @@ export function rejectionText(message: string): string {
return message;
}
/** What the queue sheet needs of a rejection to word it. */
interface RejectionInfo {
status: number;
message: string;
details?: unknown;
}
export const HIVE_FORBIDDEN_TEXT = "Keine Berechtigung, die Beute zu ändern.";
/** The 409 HIVE_CONFLICT body of a rejection, if it is one. */
function hiveConflictReason(details: unknown): string | null {
const body = details as { code?: unknown; reason?: unknown } | null;
if (body?.code !== "HIVE_CONFLICT") return null;
return typeof body.reason === "string" ? body.reason : "";
}
/**
* The reason for a rejected queue entry. Entries that change a hive get their
* own words for 403 and for a conflict with the hive's current state; every
* other rejection is the server's message.
*/
export function queueRejectionText(error: RejectionInfo, changesHive: boolean): string {
if (changesHive && error.status === 403) {
return HIVE_FORBIDDEN_TEXT;
}
const reason = changesHive && error.status === 409 ? hiveConflictReason(error.details) : null;
if (reason !== null) {
return `Die Beute wurde inzwischen geändert. ${reason ? hiveReasonText(reason) : ""}`.trim();
}
return rejectionText(error.message);
}
/** Sign-out dialog: "3 Einträge sind noch nicht gesendet." */
export function entriesNotSent(n: number): string {
return n === 1 ? "1 Eintrag ist noch nicht gesendet." : `${n} Einträge sind noch nicht gesendet.`;
@@ -189,6 +189,19 @@ describe("service worker routes", () => {
expect(cachesApi("/api/v1/tasks")).toBe(true);
});
it("never caches a colony's hive setup, so the pack answers offline - but not its other hive paths", () => {
for (const path of [
"/api/v1/colonies/c-1/hive",
"/api/v1/colonies/c-1/hive/",
"/api/v1/Colonies/c-1/Hive",
]) {
expect(cachesApi(path)).toBe(false);
}
expect(cachesApi("/api/v1/colonies/c-1/hive/changes")).toBe(true);
expect(cachesApi("/api/v1/colonies/c-1/hive/at")).toBe(true);
expect(cachesApi("/api/v1/hive-systems")).toBe(true);
});
it("never caches apiaries themselves - they carry coordinates and the address", () => {
for (const path of ["/api/v1/apiaries", "/api/v1/apiaries/", "/api/v1/apiaries/a-1", "/api/v1/apiaries/a-1/"]) {
expect(cachesApi(path)).toBe(false);
@@ -8,6 +8,7 @@ vi.mock("@/lib/inspections/api", () => ({ createInspection: vi.fn() }));
vi.mock("@/lib/treatments/api", () => ({ createTreatment: vi.fn() }));
vi.mock("@/lib/documents/api", () => ({ uploadDocument: vi.fn() }));
vi.mock("@/lib/phenology/api", () => ({ createPhenologyReport: vi.fn() }));
vi.mock("@/lib/hive/api", () => ({ postHiveChange: vi.fn() }));
vi.mock("@/lib/apiaries/offline-pack", () => ({ refreshPacksAfterSync: vi.fn() }));
// What the inspection drain hands over to the photos, seen at that moment.
@@ -668,3 +669,222 @@ describe("syncPendingEntries", () => {
expect(photo).toMatchObject({ state: "ready", inspectionId: "srv-1" });
});
});
describe("drainHiveTimeline", () => {
let postHiveChange: MockedFunction<typeof import("@/lib/hive/api").postHiveChange>;
const op = (value: string) => ({ op: "setLabel" as const, id: "b", label: value });
const hiveEntry = (colonyId: string, value: string) => ({
colonyId,
occurredOn: "2026-10-15",
source: "CARD" as const,
ops: [op(value)],
});
const withHive = (colony: number, value: string) => ({
...inspection(colony),
hiveChange: { ops: [op(value)] },
});
/** Entries queued in the same millisecond would tie on queuedAt. */
const tick = () => new Promise((resolve) => setTimeout(resolve, 3));
const labelsSent = () =>
postHiveChange.mock.calls.map(([, input]) => (input.ops[0] as { label: string }).label);
beforeEach(async () => {
globalThis.indexedDB = new IDBFactory();
window.localStorage.clear();
vi.resetModules();
db = await import("./db");
sync = await import("./sync");
ApiError = (await import("@/lib/auth/api-client")).ApiError;
createInspection = vi.mocked((await import("@/lib/inspections/api")).createInspection);
postHiveChange = vi.mocked((await import("@/lib/hive/api")).postHiveChange);
// The describe above leaves a mock that resolves uploads: start from scratch.
uploadDocument = vi.mocked((await import("@/lib/documents/api")).uploadDocument);
uploadDocument.mockReset();
// The photo store mock wraps the first module instance, which keeps its
// own connection: photos of earlier tests are still there and would be uploaded.
const photoStore = await import("@/lib/photos/store");
for (const photo of await photoStore.listPendingPhotos("user-1")) {
await photoStore.discardReadyPhoto(photo.id);
}
refreshPacksAfterSync = vi.mocked(
(await import("@/lib/apiaries/offline-pack")).refreshPacksAfterSync,
);
createInspection.mockReset().mockResolvedValue({ id: "i" } as never);
postHiveChange.mockReset().mockResolvedValue({} as never);
refreshPacksAfterSync.mockReset().mockResolvedValue(undefined);
});
it("sends hive entries and inspections with a hive change in queuedAt order across both queues", async () => {
const order: string[] = [];
postHiveChange.mockImplementation(async (_c, input) => {
order.push(`hive:${(input.ops[0] as { label: string }).label}`);
return {} as never;
});
createInspection.mockImplementation(async (input) => {
order.push(`inspection:${(input.hiveChange?.ops[0] as { label: string }).label}`);
return { id: "i" } as never;
});
await db.queueHiveChange(hiveEntry("colony-1", "a"), "k1", "user-1");
await tick();
await db.queueInspection(withHive(1, "b") as never, "k2", "user-1");
await tick();
await db.queueHiveChange(hiveEntry("colony-1", "c"), "k3", "user-1");
const result = await sync.syncPendingEntries("user-1");
expect(order).toEqual(["hive:a", "inspection:b", "hive:c"]);
expect(result).toEqual({ synced: 3, failed: 0, skippedForeign: 0 });
expect(await db.getPendingEntries("pending-hive-changes")).toEqual([]);
expect(await db.getPendingEntries("pending-inspections")).toEqual([]);
});
it("sends each entry with its own idempotency key", async () => {
await db.queueHiveChange(hiveEntry("colony-1", "a"), "key-hive", "user-1");
await sync.syncPendingEntries("user-1");
expect(postHiveChange).toHaveBeenCalledWith(
"colony-1",
{ occurredOn: "2026-10-15", source: "CARD", ops: [op("a")] },
"key-hive",
);
});
it("leaves inspections with a hive change to the timeline and sends plain ones in the normal drain", async () => {
await db.queueInspection(inspection(1), "plain", "user-1");
await db.queueInspection(withHive(2, "x") as never, "with-hive", "user-1");
await sync.syncPendingEntries("user-1");
expect(createInspection).toHaveBeenCalledTimes(2);
expect(createInspection.mock.calls.map(([, options]) => options?.idempotencyKey).sort()).toEqual([
"plain",
"with-hive",
]);
});
it("holds later hive entries of the colony after a conflict, but not other colonies", async () => {
postHiveChange.mockImplementation(async (colonyId, input) => {
if (colonyId === "colony-1" && (input.ops[0] as { label: string }).label === "a") {
throw new ApiError(409, "Konflikt", { code: "HIVE_CONFLICT", opIndex: 0, reason: "MISSING_ID: x" });
}
return {} as never;
});
await db.queueHiveChange(hiveEntry("colony-1", "a"), "k1", "user-1");
await tick();
await db.queueInspection(withHive(1, "b") as never, "k2", "user-1");
await tick();
await db.queueHiveChange(hiveEntry("colony-2", "c"), "k3", "user-1");
const result = await sync.syncPendingEntries("user-1");
expect(labelsSent()).toEqual(["a", "c"]);
expect(createInspection).not.toHaveBeenCalled();
expect(result.failed).toBe(1);
const [held] = await db.getPendingEntries("pending-inspections");
expect(held.lastError).toBeUndefined();
const [rejected] = await db.getPendingEntries("pending-hive-changes");
expect(rejected.lastError).toMatchObject({ status: 409 });
});
it("keeps only the small part of a conflict body in the stored rejection", async () => {
postHiveChange.mockRejectedValue(
new ApiError(409, "Konflikt", {
code: "HIVE_CONFLICT",
opIndex: 2,
reason: "MISSING_ID: x",
current: { version: 7, state: { schemaVersion: 1, stack: [{ id: "big" }] } },
}),
);
await db.queueHiveChange(hiveEntry("colony-1", "a"), "k1", "user-1");
await sync.syncPendingEntries("user-1");
const [entry] = await db.getPendingEntries("pending-hive-changes");
expect(entry.lastError?.details).toEqual({
code: "HIVE_CONFLICT",
opIndex: 2,
reason: "MISSING_ID: x",
current: { version: 7 },
});
});
it("holds the colony after a 403 and marks the inspection with the hive change", async () => {
createInspection.mockRejectedValue(new ApiError(403, "Forbidden"));
await db.queueInspection(withHive(1, "a") as never, "k1", "user-1");
await tick();
await db.queueHiveChange(hiveEntry("colony-1", "b"), "k2", "user-1");
await sync.syncPendingEntries("user-1");
expect(postHiveChange).not.toHaveBeenCalled();
const [entry] = await db.getPendingEntries("pending-inspections");
expect(entry.lastError).toMatchObject({ status: 403 });
});
it("stops at a network error and sends the rest in the next run, in order", async () => {
postHiveChange.mockRejectedValueOnce(new TypeError("Failed to fetch"));
await db.queueHiveChange(hiveEntry("colony-1", "a"), "k1", "user-1");
await tick();
await db.queueHiveChange(hiveEntry("colony-2", "b"), "k2", "user-1");
await sync.syncPendingEntries("user-1");
expect(labelsSent()).toEqual(["a"]);
expect((await db.getPendingEntries("pending-hive-changes")).map((e) => e.lastError)).toEqual([
undefined,
undefined,
]);
await sync.syncPendingEntries("user-1");
expect(labelsSent()).toEqual(["a", "a", "b"]);
});
it("stops at a 5xx instead of marking the entry", async () => {
postHiveChange.mockRejectedValueOnce(new ApiError(500, "Boom"));
await db.queueHiveChange(hiveEntry("colony-1", "a"), "k1", "user-1");
await db.queueHiveChange(hiveEntry("colony-2", "b"), "k2", "user-1");
await sync.syncPendingEntries("user-1");
expect(labelsSent()).toEqual(["a"]);
const entries = await db.getPendingEntries("pending-hive-changes");
expect(entries.every((entry) => entry.lastError === undefined)).toBe(true);
});
it("treats a key still in progress (409) as retry later and holds the colony", async () => {
postHiveChange.mockRejectedValueOnce(
new ApiError(409, "Diese Anfrage wird bereits verarbeitet - bitte kurz warten"),
);
await db.queueHiveChange(hiveEntry("colony-1", "a"), "k1", "user-1");
await db.queueHiveChange(hiveEntry("colony-1", "b"), "k2", "user-1");
await sync.syncPendingEntries("user-1");
expect(labelsSent()).toEqual(["a"]);
expect((await db.getPendingEntries("pending-hive-changes"))[0].lastError).toBeUndefined();
});
it("never sends another account's hive entries", async () => {
await db.queueHiveChange(hiveEntry("colony-1", "a"), "k1", "user-2");
const result = await sync.syncPendingEntries("user-1");
expect(postHiveChange).not.toHaveBeenCalled();
expect(result.skippedForeign).toBe(1);
});
it("sends the inspection without its hive change after strip-and-resend, with the same key", async () => {
createInspection.mockRejectedValueOnce(new ApiError(403, "Forbidden"));
await db.queueInspection(withHive(1, "a") as never, "same-key", "user-1");
await sync.syncPendingEntries("user-1");
const [rejected] = await db.getPendingEntries("pending-inspections");
await db.retryInspectionWithoutHiveChange(rejected.id);
await sync.syncPendingEntries("user-1");
const [input, options] = createInspection.mock.calls[1];
expect(input).not.toHaveProperty("hiveChange");
expect(options?.idempotencyKey).toBe("same-key");
expect(await db.getPendingEntries("pending-inspections")).toEqual([]);
});
});
+148 -2
View File
@@ -7,6 +7,7 @@ import { createVarroaMeasurement } from "@/lib/varroa/api";
import { createSwarmControlCheck } from "@/lib/swarm-control/api";
import { createHarvest } from "@/lib/harvests/api";
import { createPhenologyReport } from "@/lib/phenology/api";
import { postHiveChange } from "@/lib/hive/api";
import { uploadDocument } from "@/lib/documents/api";
import {
createFeedingsBulk,
@@ -22,6 +23,7 @@ import {
BulkKind,
PendingBulkEntry,
PendingEntry,
QueueError,
QueueStoreName,
RecordQueueInputs,
RecordStoreName,
@@ -29,6 +31,7 @@ import {
getBulkEntry,
getPendingEntries,
getPendingEntry,
hasHiveChange,
isForeignEntry,
markFailed,
removeQueueEntry,
@@ -57,6 +60,9 @@ const UNAUTHORIZED = 401;
/** Answers that say „not now“, not „this entry is wrong“. */
const TRANSIENT_STATUSES: readonly number[] = [408, 429, 502, 503, 504];
/** From this status on the server failed, not the entry. */
const SERVER_ERROR_FROM = 500;
const NOTHING_SYNCED: SyncResult = { synced: 0, failed: 0, skippedForeign: 0 };
/** One queue's drain: its counts and the colonies the server rejected entries for. */
@@ -182,10 +188,12 @@ async function drain<Store extends RecordStoreName, Result>(
create: (input: RecordQueueInputs[Store], options: CreateOptions) => Promise<Result>,
run: SyncRun,
onCreated?: (entry: PendingEntry<RecordQueueInputs[Store]>, result: Result) => Promise<void>,
/** Entries this drain leaves to another one (the hive timeline). */
skip: (entry: PendingEntry<RecordQueueInputs[Store]>) => boolean = () => false,
): Promise<DrainResult> {
return drainQueue<PendingEntry<RecordQueueInputs[Store]>, Result>({
store,
list: () => getPendingEntries(store),
list: async () => (await getPendingEntries(store)).filter((entry) => !skip(entry)),
read: (id) => getPendingEntry(store, id),
send: (entry) => create(entry.input, { idempotencyKey: entry.idempotencyKey }),
rejectedColonyIds: (entry) => {
@@ -321,6 +329,141 @@ function colonyIdOf(input: unknown): string | null {
return typeof colonyId === "string" ? colonyId : null;
}
/**
* A HIVE_CONFLICT body carries the whole current state; the queue keeps only
* what it shows (code, op, reason, version) so IndexedDB stays small.
*/
function slimQueueError(error: QueueError): QueueError {
const body = error.details as
| { code?: unknown; opIndex?: unknown; reason?: unknown; current?: { version?: unknown } }
| null
| undefined;
if (body?.code !== "HIVE_CONFLICT") return error;
return {
...error,
details: {
code: body.code,
opIndex: body.opIndex,
reason: body.reason,
current: { version: body.current?.version },
},
};
}
/** One entry of the hive timeline: from `pending-hive-changes` or an inspection with a hive change. */
type TimelineEntry =
| { store: "pending-hive-changes"; entry: PendingEntry<RecordQueueInputs["pending-hive-changes"]> }
| { store: "pending-inspections"; entry: PendingEntry<CreateInspectionInput> };
async function readTimeline(): Promise<TimelineEntry[]> {
const [hive, inspections] = await Promise.all([
getPendingEntries("pending-hive-changes"),
getPendingEntries("pending-inspections"),
]);
return [
...hive.map((entry): TimelineEntry => ({ store: "pending-hive-changes", entry })),
...inspections
.filter((entry) => hasHiveChange(entry.input))
.map((entry): TimelineEntry => ({ store: "pending-inspections", entry })),
].sort((a, b) => a.entry.queuedAt.localeCompare(b.entry.queuedAt) || a.entry.id - b.entry.id);
}
function readTimelineEntry(item: TimelineEntry): Promise<TimelineEntry["entry"] | undefined> {
return item.store === "pending-hive-changes"
? getPendingEntry(item.store, item.entry.id)
: getPendingEntry(item.store, item.entry.id);
}
function sendTimelineEntry(item: TimelineEntry): Promise<unknown> {
const { entry } = item;
const options = { idempotencyKey: entry.idempotencyKey };
if (item.store === "pending-hive-changes") {
const { colonyId, occurredOn, source, ops } = item.entry.input;
return postHiveChange(colonyId, { occurredOn, source, ops }, entry.idempotencyKey ?? "");
}
return createInspection(item.entry.input, options);
}
/**
* Sends the hive changes and the inspections that carry one in the order they
* were made (`queuedAt`), one after the other: a hive change may rely on the
* hive an earlier inspection left. A network error or a 5xx stops the drain
* (the rest waits for the next run). Any other refusal (HIVE_CONFLICT, 403,
* 400 ...) is stored on the entry, which stays queued, and holds back the
* later entries of the same colony until the user resolves it; other colonies
* go on. A key still in progress (409) is „later“: nothing is stored, the
* colony is held for this run.
*/
async function drainTimeline(run: SyncRun): Promise<DrainResult> {
const queued = await readTimeline();
const own = queued.filter(({ entry }) => !isForeignEntry(entry, run.userId));
const held = new Set<string>();
const rejectedColonyIds: string[] = [];
let synced = 0;
let failed = 0;
let skippedForeign = queued.length - own.length;
for (const item of own) {
const entry = await readTimelineEntry(item);
if (run.isCancelled()) {
break;
}
if (!entry) {
continue;
}
if (isForeignEntry(entry, run.userId)) {
skippedForeign += 1;
continue;
}
// Stripped meanwhile („Ohne Beute-Änderung senden“): the normal drain sends it.
if (item.store === "pending-inspections" && !hasHiveChange(entry.input)) {
continue;
}
const colonyId = colonyIdOf(entry.input);
if (colonyId && held.has(colonyId)) {
continue;
}
let result: unknown;
try {
result = await sendTimelineEntry({ ...item, entry } as TimelineEntry);
} catch (error) {
if (isRetryLaterError(error)) {
if (colonyId) held.add(colonyId);
continue;
}
if (error instanceof ApiError && error.status === UNAUTHORIZED) {
break;
}
failed += 1;
if (
!(error instanceof ApiError) ||
error.status >= SERVER_ERROR_FROM ||
TRANSIENT_STATUSES.includes(error.status)
) {
break;
}
await markFailed(item.store, entry.id, slimQueueError(toQueueError(error)));
if (colonyId) {
held.add(colonyId);
rejectedColonyIds.push(colonyId);
}
continue;
}
if (item.store === "pending-inspections") {
try {
await handOverPhotos(entry as PendingEntry<CreateInspectionInput>, result as Inspection);
} catch {
failed += 1;
continue;
}
}
await removeQueueEntry(item.store, entry.id);
synced += 1;
}
return { synced, failed, skippedForeign, rejectedColonyIds };
}
function addResults(total: SyncResult, result: SyncResult): SyncResult {
return {
synced: total.synced + result.synced,
@@ -331,7 +474,10 @@ function addResults(total: SyncResult, result: SyncResult): SyncResult {
async function runSync(run: SyncRun): Promise<SyncResult> {
const records = await Promise.all([
drain("pending-inspections", createInspection, run, handOverPhotos),
drain("pending-inspections", createInspection, run, handOverPhotos, (entry) =>
hasHiveChange(entry.input),
),
drainTimeline(run),
drain("pending-treatments", createTreatment, run),
drain("pending-feedings", createFeeding, run),
drain("pending-varroa-measurements", createVarroaMeasurement, run),
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { appendDictation } from "./append-text";
describe("appendDictation", () => {
it("joins with a single space and keeps the existing text", () => {
expect(appendDictation("Alt", "neu")).toBe("Alt neu");
expect(appendDictation("Alt ", " neu ")).toBe("Alt neu");
});
it("starts from empty without a leading space", () => {
expect(appendDictation("", "neu")).toBe("neu");
});
it("ignores empty dictation", () => {
expect(appendDictation("Alt", " ")).toBe("Alt");
});
});
@@ -0,0 +1,9 @@
/** Dictation is added to the end of a field; it never replaces what is there. */
export function appendDictation(existing: string, dictated: string): string {
const addition = dictated.trim();
if (addition === "") {
return existing;
}
const base = existing.trimEnd();
return base === "" ? addition : `${base} ${addition}`;
}
@@ -0,0 +1,43 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
getCloudRecognitionConsent,
hasSeenRecordingNotice,
markRecordingNoticeSeen,
setCloudRecognitionConsent,
} from "./consent";
describe("voice consent", () => {
beforeEach(() => window.localStorage.clear());
afterEach(() => vi.restoreAllMocks());
it("defaults to no consent and no seen notice", () => {
expect(getCloudRecognitionConsent()).toBe(false);
expect(hasSeenRecordingNotice()).toBe(false);
});
it("stores and withdraws the cloud consent", () => {
setCloudRecognitionConsent(true);
expect(getCloudRecognitionConsent()).toBe(true);
setCloudRecognitionConsent(false);
expect(getCloudRecognitionConsent()).toBe(false);
});
it("remembers that the recording notice was seen", () => {
markRecordingNoticeSeen();
expect(hasSeenRecordingNotice()).toBe(true);
});
it("consent survives a throwing localStorage", () => {
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
throw new Error("blocked");
});
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("blocked");
});
expect(getCloudRecognitionConsent()).toBe(false);
expect(hasSeenRecordingNotice()).toBe(false);
expect(() => setCloudRecognitionConsent(true)).not.toThrow();
expect(() => markRecordingNoticeSeen()).not.toThrow();
});
});
+61
View File
@@ -0,0 +1,61 @@
/**
* Voice settings that live in the browser only. Every access sits in
* try/catch: private windows and blocked site data make localStorage throw,
* and the default (no consent, notice not seen) is the safe answer then.
*/
const CLOUD_CONSENT_KEY = "apicore.voice.cloud-consent";
const NOTICE_SEEN_KEY = "apicore.voice.notice-seen";
function readFlag(key: string): boolean {
try {
return window.localStorage.getItem(key) === "1";
} catch {
return false;
}
}
function writeFlag(key: string, value: boolean): void {
try {
if (value) {
window.localStorage.setItem(key, "1");
} else {
window.localStorage.removeItem(key);
}
} catch {
// Storage is a convenience here; without it the flag simply stays at its default.
}
}
export function getCloudRecognitionConsent(): boolean {
return readFlag(CLOUD_CONSENT_KEY);
}
const consentListeners = new Set<() => void>();
/** For useSyncExternalStore: re-reads after this tab or another tab changed the consent. */
export function subscribeCloudRecognitionConsent(listener: () => void): () => void {
consentListeners.add(listener);
const onStorage = (event: StorageEvent) => {
if (event.key === CLOUD_CONSENT_KEY || event.key === null) {
listener();
}
};
window.addEventListener("storage", onStorage);
return () => {
consentListeners.delete(listener);
window.removeEventListener("storage", onStorage);
};
}
export function setCloudRecognitionConsent(value: boolean): void {
writeFlag(CLOUD_CONSENT_KEY, value);
consentListeners.forEach((listener) => listener());
}
export function hasSeenRecordingNotice(): boolean {
return readFlag(NOTICE_SEEN_KEY);
}
export function markRecordingNoticeSeen(): void {
writeFlag(NOTICE_SEEN_KEY, true);
}
@@ -0,0 +1,250 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createRecognition, detectSpeechSupport } from "./speech-support";
import { setCloudRecognitionConsent } from "./consent";
class FakeRecognition {
static instances: FakeRecognition[] = [];
lang = "";
continuous = false;
interimResults = false;
maxAlternatives = 1;
declare processLocally?: boolean;
declare phrases?: unknown;
handlers: Record<string, (event: unknown) => void> = {};
start = vi.fn();
stop = vi.fn();
abort = vi.fn();
constructor() {
FakeRecognition.instances.push(this);
}
set onresult(fn: (event: unknown) => void) {
this.handlers.result = fn;
}
set onend(fn: (event: unknown) => void) {
this.handlers.end = fn;
}
set onerror(fn: (event: unknown) => void) {
this.handlers.error = fn;
}
set onaudiostart(fn: (event: unknown) => void) {
this.handlers.audiostart = fn;
}
}
/** A plain object stands in for `window`; only the members the module reads exist. */
function fakeWindow(extra: Record<string, unknown> = {}): Window {
return {
navigator: { standalone: false, userAgent: "x" },
...extra,
} as unknown as Window;
}
describe("detectSpeechSupport", () => {
beforeEach(() => {
window.localStorage.clear();
FakeRecognition.instances = [];
});
it("returns api false without SpeechRecognition and webkitSpeechRecognition", async () => {
const support = await detectSpeechSupport(fakeWindow());
expect(support).toEqual({
api: false,
local: "unknown",
cloudAllowed: false,
isInstalledIosPwa: false,
});
});
it("reports local available via SpeechRecognition.available({ langs: ['de-DE'], processLocally: true })", async () => {
const available = vi.fn().mockResolvedValue("available");
const Ctor = Object.assign(class extends FakeRecognition {}, { available });
const support = await detectSpeechSupport(fakeWindow({ SpeechRecognition: Ctor }));
expect(available).toHaveBeenCalledWith({ langs: ["de-DE"], processLocally: true });
expect(support.api).toBe(true);
expect(support.local).toBe("available");
});
it("maps downloadable and unavailable answers", async () => {
for (const answer of ["downloadable", "unavailable"] as const) {
const Ctor = Object.assign(class extends FakeRecognition {}, {
available: vi.fn().mockResolvedValue(answer),
});
const support = await detectSpeechSupport(fakeWindow({ webkitSpeechRecognition: Ctor }));
expect(support.local).toBe(answer);
}
});
it("reports unknown when available() is missing or throws", async () => {
const missing = await detectSpeechSupport(fakeWindow({ SpeechRecognition: FakeRecognition }));
expect(missing.api).toBe(true);
expect(missing.local).toBe("unknown");
const Throwing = Object.assign(class extends FakeRecognition {}, {
available: vi.fn().mockRejectedValue(new Error("nope")),
});
const thrown = await detectSpeechSupport(fakeWindow({ SpeechRecognition: Throwing }));
expect(thrown.local).toBe("unknown");
});
it("cloudAllowed follows the stored consent and defaults to false", async () => {
const win = fakeWindow({ SpeechRecognition: FakeRecognition });
expect((await detectSpeechSupport(win)).cloudAllowed).toBe(false);
setCloudRecognitionConsent(true);
expect((await detectSpeechSupport(win)).cloudAllowed).toBe(true);
});
it("detects the installed iOS PWA", async () => {
const win = fakeWindow({
SpeechRecognition: FakeRecognition,
navigator: {
standalone: true,
userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)",
},
});
expect((await detectSpeechSupport(win)).isInstalledIosPwa).toBe(true);
});
});
describe("createRecognition", () => {
beforeEach(() => {
FakeRecognition.instances = [];
});
const options = {
lang: "de-DE",
continuous: false,
interimResults: true,
maxAlternatives: 1,
processLocally: false,
} as const;
it("returns null without the API", () => {
expect(createRecognition(options, fakeWindow())).toBeNull();
});
it("sets processLocally only when requested and phrases only when supported", () => {
const win = fakeWindow({ SpeechRecognition: FakeRecognition });
createRecognition(options, win);
expect("processLocally" in FakeRecognition.instances[0]).toBe(false);
createRecognition({ ...options, processLocally: true, phrases: ["Varroa"] }, win);
const local = FakeRecognition.instances[1];
expect(local.processLocally).toBe(true);
// The fake engine does not expose phrases at runtime: it must not be invented.
expect("phrases" in local).toBe(false);
});
class WithPhrases extends FakeRecognition {
constructor() {
super();
this.phrases = [];
}
}
class FakePhrase {
constructor(
public phrase: string,
public boost: number,
) {}
}
it("builds SpeechRecognitionPhrase objects with boost 1 when the engine supports them", () => {
createRecognition(
{ ...options, phrases: ["Varroa", "Königin"] },
fakeWindow({ SpeechRecognition: WithPhrases, SpeechRecognitionPhrase: FakePhrase }),
);
expect(FakeRecognition.instances[0].phrases).toEqual([
new FakePhrase("Varroa", 1.0),
new FakePhrase("Königin", 1.0),
]);
});
it("skips phrases without a SpeechRecognitionPhrase constructor", () => {
createRecognition(
{ ...options, phrases: ["Varroa"] },
fakeWindow({ SpeechRecognition: WithPhrases }),
);
expect(FakeRecognition.instances[0].phrases).toEqual([]);
});
it("never throws when assigning phrases fails", () => {
class ThrowingSetter extends FakeRecognition {
constructor() {
super();
Object.defineProperty(this, "phrases", {
get: () => [],
set: () => {
throw new Error("rejected");
},
});
}
}
expect(() =>
createRecognition(
{ ...options, phrases: ["Varroa"] },
fakeWindow({ SpeechRecognition: ThrowingSetter, SpeechRecognitionPhrase: FakePhrase }),
),
).not.toThrow();
});
it("maps downloading to unavailable on the device", async () => {
const Ctor = Object.assign(class extends FakeRecognition {}, {
available: vi.fn().mockResolvedValue("downloading"),
});
const support = await detectSpeechSupport(fakeWindow({ SpeechRecognition: Ctor }));
expect(support.local).toBe("unavailable");
});
it("maps results into final and interim text", () => {
const handle = createRecognition(options, fakeWindow({ SpeechRecognition: FakeRecognition }));
const onResult = vi.fn();
handle?.on("result", onResult);
const final = Object.assign(
[{ transcript: "Königin gesehen" }, { transcript: "Königin gesehn" }],
{ isFinal: true },
);
const interim = Object.assign([{ transcript: " und Stifte" }], { isFinal: false });
FakeRecognition.instances[0].handlers.result({ resultIndex: 0, results: [final, interim] });
expect(onResult).toHaveBeenCalledWith({
finalText: "Königin gesehen",
interimText: "und Stifte",
alternatives: ["Königin gesehen", "Königin gesehn"],
});
});
it("forwards end, audiostart and error codes and the control calls", () => {
const handle = createRecognition(options, fakeWindow({ SpeechRecognition: FakeRecognition }));
const onEnd = vi.fn();
const onAudio = vi.fn();
const onError = vi.fn();
handle?.on("end", onEnd);
handle?.on("audiostart", onAudio);
handle?.on("error", onError);
const engine = FakeRecognition.instances[0];
engine.handlers.end({});
engine.handlers.audiostart({});
engine.handlers.error({ error: "not-allowed" });
handle?.start();
handle?.stop();
handle?.abort();
expect(onEnd).toHaveBeenCalled();
expect(onAudio).toHaveBeenCalled();
expect(onError).toHaveBeenCalledWith("not-allowed");
expect(engine.start).toHaveBeenCalled();
expect(engine.stop).toHaveBeenCalled();
expect(engine.abort).toHaveBeenCalled();
expect(engine.lang).toBe("de-DE");
expect(engine.interimResults).toBe(true);
});
});
@@ -0,0 +1,201 @@
import { getCloudRecognitionConsent } from "./consent";
export type LocalAvailability = "available" | "downloadable" | "unavailable" | "unknown";
export interface SpeechSupport {
api: boolean;
local: LocalAvailability;
cloudAllowed: boolean;
isInstalledIosPwa: boolean;
}
export interface RecognitionOptions {
lang: "de-DE";
continuous: boolean;
interimResults: boolean;
maxAlternatives: number;
processLocally: boolean;
phrases?: readonly string[];
}
export interface RecognitionResultEvent {
finalText: string;
interimText: string;
alternatives: string[];
}
export interface RecognitionHandle {
start(): void;
stop(): void;
abort(): void;
on(event: "result", fn: (e: RecognitionResultEvent) => void): void;
on(event: "end" | "audiostart", fn: () => void): void;
on(event: "error", fn: (code: string) => void): void;
}
interface RecognitionAlternative {
transcript: string;
}
interface RecognitionResult {
isFinal: boolean;
length: number;
[index: number]: RecognitionAlternative;
}
interface RawResultEvent {
resultIndex: number;
results: ArrayLike<RecognitionResult>;
}
interface RawErrorEvent {
error?: string;
}
interface RecognitionEngine {
lang: string;
continuous: boolean;
interimResults: boolean;
maxAlternatives: number;
processLocally?: boolean;
phrases?: unknown;
onresult: ((event: RawResultEvent) => void) | null;
onend: (() => void) | null;
onerror: ((event: RawErrorEvent) => void) | null;
onaudiostart: (() => void) | null;
start(): void;
stop(): void;
abort(): void;
}
type PhraseConstructor = new (phrase: string, boost: number) => unknown;
interface RecognitionConstructor {
new (): RecognitionEngine;
available?: (options: { langs: string[]; processLocally: boolean }) => Promise<unknown>;
}
const LANGUAGE = "de-DE";
function recognitionConstructor(win: Window): RecognitionConstructor | null {
const scope = win as unknown as {
SpeechRecognition?: RecognitionConstructor;
webkitSpeechRecognition?: RecognitionConstructor;
};
return scope.SpeechRecognition ?? scope.webkitSpeechRecognition ?? null;
}
function isInstalledIosPwa(win: Window): boolean {
const nav = win.navigator as Navigator & { standalone?: boolean };
return nav.standalone === true && /iPhone|iPad|iPod/.test(nav.userAgent);
}
function toAvailability(answer: unknown): LocalAvailability {
// Chrome answers "downloading" while the language pack arrives. For now that
// is treated like "unavailable" on the device: no on-device dictation yet.
if (answer === "downloading") {
return "unavailable";
}
return answer === "available" || answer === "downloadable" || answer === "unavailable"
? answer
: "unknown";
}
async function queryLocalAvailability(ctor: RecognitionConstructor): Promise<LocalAvailability> {
if (typeof ctor.available !== "function") {
return "unknown";
}
try {
return toAvailability(await ctor.available({ langs: [LANGUAGE], processLocally: true }));
} catch {
return "unknown";
}
}
export async function detectSpeechSupport(
win: Window = window,
): Promise<SpeechSupport> {
const ctor = recognitionConstructor(win);
return {
api: ctor !== null,
local: ctor ? await queryLocalAvailability(ctor) : "unknown",
cloudAllowed: getCloudRecognitionConsent(),
isInstalledIosPwa: isInstalledIosPwa(win),
};
}
function mapResult(event: RawResultEvent): RecognitionResultEvent {
let finalText = "";
let interimText = "";
const alternatives: string[] = [];
for (let index = event.resultIndex; index < event.results.length; index += 1) {
const result = event.results[index];
if (result.length === 0) {
continue;
}
if (result.isFinal) {
finalText += result[0].transcript;
for (let alt = 0; alt < result.length; alt += 1) {
alternatives.push(result[alt].transcript);
}
} else {
interimText += result[0].transcript;
}
}
return { finalText: finalText.trim(), interimText: interimText.trim(), alternatives };
}
/**
* Chrome takes contextual biasing as `SpeechRecognitionPhrase(phrase, boost)`
* objects. Without that constructor or the property the list is skipped; a
* failing assignment never breaks dictation.
*/
function applyPhrases(engine: RecognitionEngine, phrases: readonly string[], win: Window): void {
const PhraseCtor = (win as unknown as { SpeechRecognitionPhrase?: PhraseConstructor })
.SpeechRecognitionPhrase;
if (typeof PhraseCtor !== "function" || !("phrases" in engine)) {
return;
}
try {
engine.phrases = phrases.map((text) => new PhraseCtor(text, 1.0));
} catch {
// Biasing is optional; recognition works without it.
}
}
/** Wraps the browser engine; `null` when the browser has none. */
export function createRecognition(
options: RecognitionOptions,
win: Window = window,
): RecognitionHandle | null {
const Ctor = recognitionConstructor(win);
if (!Ctor) {
return null;
}
const engine = new Ctor();
engine.lang = options.lang;
engine.continuous = options.continuous;
engine.interimResults = options.interimResults;
engine.maxAlternatives = options.maxAlternatives;
if (options.processLocally) {
engine.processLocally = true;
}
if (options.phrases) {
applyPhrases(engine, options.phrases, win);
}
return {
start: () => engine.start(),
stop: () => engine.stop(),
abort: () => engine.abort(),
on(event: string, fn: (arg?: never) => void) {
const listener = fn as (arg?: unknown) => void;
if (event === "result") {
engine.onresult = (raw) => listener(mapResult(raw));
} else if (event === "end") {
engine.onend = () => listener();
} else if (event === "audiostart") {
engine.onaudiostart = () => listener();
} else if (event === "error") {
engine.onerror = (raw) => listener(raw.error ?? "unknown");
}
},
} as RecognitionHandle;
}
@@ -0,0 +1,25 @@
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { VOICE_VOCABULARY } from "./vocabulary";
// The frontend image build only sees ApiCore-Frontend, so the comparison runs
// in the monorepo checkout and is skipped inside that build.
const fixture = path.resolve(
process.cwd(),
"../ApiCore-Backend/test/fixtures/voice/vocabulary.json",
);
describe("voice vocabulary", () => {
it.skipIf(!existsSync(fixture))("VOICE_VOCABULARY equals the shared fixture", () => {
const shared: unknown = JSON.parse(readFileSync(fixture, "utf8"));
expect([...VOICE_VOCABULARY]).toEqual(shared);
});
it("contains the core beekeeping terms", () => {
for (const term of ["Königin", "Stifte", "Weiselzellen", "Varroa", "Drohnenbrut"]) {
expect(VOICE_VOCABULARY).toContain(term);
}
});
});
@@ -0,0 +1,39 @@
/**
* Beekeeping terms the recognizer should expect. Mirrors
* ApiCore-Backend/test/fixtures/voice/vocabulary.json (vocabulary.spec.ts
* compares both); the backend builds its Whisper prompt from the same list.
*/
export const VOICE_VOCABULARY: readonly string[] = [
"Königin",
"Stifte",
"Weiselzellen",
"Brutwaben",
"Futterkranz",
"Honigraum",
"Varroa",
"Schwarmstimmung",
"sanftmütig",
"Wabensitz",
"Drohnenbrut",
"Brutnest",
"Brutfläche",
"Spielnäpfchen",
"Schwarmzellen",
"Absperrgitter",
"Futterteig",
"Ableger",
"Jungvolk",
"Rähmchen",
"Mittelwand",
"Flugloch",
"Beute",
"Zarge",
"Ameisensäure",
"Oxalsäure",
"Windel",
"Faulbrut",
"Kalkbrut",
"Nosema",
"Wachsmotten",
"Pollen",
];