feat: Sprachnotiz in der Durchsicht, Info-Seite und Zähler (T7 L5 Tasks 20, 22)
Task 20: /inspections/new?voice=<id> zeigt die Sprachnotiz über dem Formular (Abgeschriebenes, Unklares, „Alle übernehmen“ mit Rückfrage) und Vorschläge unter den Feldern mit wörtlichem Zitat; nichts wird ohne Tipp übernommen, die Volkskarte wird nie angehakt. Bei ausgeschalteter Auswertung ist der Text offen zum Abtippen. Speichern schickt voiceCaptureId; bei 409 VOICE_CAPTURE_GONE genau ein neuer Versuch ohne Notiz mit demselben Idempotency-Key (online und aus der Warteschlange); VOICE_CAPTURE_COLONY_MISMATCH nie automatisch, sondern „Ohne Sprachnotiz speichern/senden“. Skalen aus lib/inspections/scales.ts mit Paritätstest zum Backend. Task 22: /settings/voice mit Modell und Fristen (bei ausgeschalteter Auswertung ausdrücklich: nichts geht an einen KI-Dienst), „Alle meine Sprachnotizen löschen“ auf Server und Gerät mit Anzahl; Systemkarte „Sprachnotizen“ (nur system:manage) mit Zählern und Kosten der letzten 7 Tage (Ruling 15); Service Worker cacht /api/v1/voice-captures nie, /voice und /voice/new offline erreichbar. Gates: Frontend lint, typecheck, 3106 Tests, build. Refs #27 Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
51206bea4f
commit
ce922c8569
40 files changed
+3347
-45
No files matched your search
@@ -0,0 +1,91 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ApiError, apiFetch } from "@/lib/auth/api-client";
|
||||
import { createInspection } from "./api";
|
||||
import type { CreateInspectionInput } from "./types";
|
||||
|
||||
vi.mock("@/lib/auth/api-client", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/auth/api-client")>()),
|
||||
apiFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
const INPUT: CreateInspectionInput = {
|
||||
colonyId: "colony-1",
|
||||
date: "2026-10-14",
|
||||
queenSeen: true,
|
||||
notes: "ruhig",
|
||||
voiceCaptureId: "capture-1",
|
||||
};
|
||||
|
||||
function bodyOf(call: number): Record<string, unknown> {
|
||||
const init = vi.mocked(apiFetch).mock.calls[call][1] as RequestInit;
|
||||
return JSON.parse(String(init.body)) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function keyOf(call: number): unknown {
|
||||
const init = vi.mocked(apiFetch).mock.calls[call][1] as RequestInit;
|
||||
return (init.headers as Record<string, string>)["Idempotency-Key"];
|
||||
}
|
||||
|
||||
const gone = () =>
|
||||
new ApiError(409, "Die Sprachnotiz ist nicht mehr verfügbar.", {
|
||||
code: "VOICE_CAPTURE_GONE",
|
||||
});
|
||||
|
||||
describe("createInspection", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(apiFetch).mockReset();
|
||||
});
|
||||
|
||||
it("sends the voice capture id with the inspection", async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue({ id: "i-1" });
|
||||
|
||||
const created = await createInspection(INPUT, { idempotencyKey: "key-1" });
|
||||
|
||||
expect(bodyOf(0)).toMatchObject({ voiceCaptureId: "capture-1", notes: "ruhig" });
|
||||
expect(created).not.toHaveProperty("voiceCaptureGone");
|
||||
});
|
||||
|
||||
it("a 409 VOICE_CAPTURE_GONE retries once without the id and keeps every input", async () => {
|
||||
vi.mocked(apiFetch).mockRejectedValueOnce(gone()).mockResolvedValueOnce({ id: "i-1" });
|
||||
|
||||
const created = await createInspection(INPUT, { idempotencyKey: "key-1" });
|
||||
|
||||
expect(apiFetch).toHaveBeenCalledTimes(2);
|
||||
expect(bodyOf(1)).toEqual({ ...bodyOf(0), voiceCaptureId: undefined });
|
||||
expect(bodyOf(1)).not.toHaveProperty("voiceCaptureId");
|
||||
expect(bodyOf(1)).toMatchObject({ queenSeen: true, notes: "ruhig", colonyId: "colony-1" });
|
||||
// The first try failed and released its key: a repeat after a lost answer must still find the first record.
|
||||
expect(keyOf(1)).toBe("key-1");
|
||||
expect(created).toMatchObject({ id: "i-1", voiceCaptureGone: true });
|
||||
});
|
||||
|
||||
it("retries only once", async () => {
|
||||
vi.mocked(apiFetch).mockRejectedValue(gone());
|
||||
|
||||
await expect(createInspection(INPUT, { idempotencyKey: "key-1" })).rejects.toBeInstanceOf(
|
||||
ApiError,
|
||||
);
|
||||
expect(apiFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("never retries a colony mismatch", async () => {
|
||||
const mismatch = new ApiError(409, "Andere Volk", { code: "VOICE_CAPTURE_COLONY_MISMATCH" });
|
||||
vi.mocked(apiFetch).mockRejectedValue(mismatch);
|
||||
|
||||
await expect(createInspection(INPUT)).rejects.toBe(mismatch);
|
||||
expect(apiFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not retry other conflicts or inspections without a voice note", async () => {
|
||||
vi.mocked(apiFetch).mockRejectedValue(gone());
|
||||
const withoutVoice = { ...INPUT };
|
||||
delete withoutVoice.voiceCaptureId;
|
||||
|
||||
await expect(createInspection(withoutVoice)).rejects.toBeInstanceOf(ApiError);
|
||||
expect(apiFetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.mocked(apiFetch).mockReset().mockRejectedValue(new ApiError(409, "bereits verarbeitet"));
|
||||
await expect(createInspection(INPUT)).rejects.toBeInstanceOf(ApiError);
|
||||
expect(apiFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { apiFetch } from "@/lib/auth/api-client";
|
||||
import { ApiError, apiFetch } from "@/lib/auth/api-client";
|
||||
import { CreateOptions, idempotencyHeaders } from "@/lib/offline/idempotency";
|
||||
import {
|
||||
CreateInspectionInput,
|
||||
@@ -13,9 +13,9 @@ export function fetchColonyInspections(colonyId: string): Promise<Inspection[]>
|
||||
return apiFetch<Inspection[]>(`/colonies/${encodeURIComponent(colonyId)}/inspections`);
|
||||
}
|
||||
|
||||
export function createInspection(
|
||||
function postInspection(
|
||||
input: CreateInspectionInput,
|
||||
options: CreateOptions = {},
|
||||
options: CreateOptions,
|
||||
): Promise<CreatedInspection> {
|
||||
return apiFetch<CreatedInspection>("/inspections", {
|
||||
method: "POST",
|
||||
@@ -24,6 +24,41 @@ export function createInspection(
|
||||
});
|
||||
}
|
||||
|
||||
function isVoiceCaptureGone(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof ApiError &&
|
||||
error.status === 409 &&
|
||||
(error.body as { code?: unknown } | null)?.code === "VOICE_CAPTURE_GONE"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves an inspection. When it carries a voice note that is gone by now
|
||||
* (confirmed elsewhere, discarded, expired: 409 VOICE_CAPTURE_GONE), it is
|
||||
* sent once more without the note and with the same input - what the
|
||||
* beekeeper entered is never lost for a note they may not even have used.
|
||||
* This also holds for queued inspections, which sync through this function.
|
||||
* The failed try released its key, so the repeat keeps it: a lost answer to
|
||||
* the repeat is then recognised on the next send instead of saved twice.
|
||||
* A note of another colony (VOICE_CAPTURE_COLONY_MISMATCH) is never retried
|
||||
* on its own: leaving a note out is the user's decision.
|
||||
*/
|
||||
export async function createInspection(
|
||||
input: CreateInspectionInput,
|
||||
options: CreateOptions = {},
|
||||
): Promise<CreatedInspection> {
|
||||
try {
|
||||
return await postInspection(input, options);
|
||||
} catch (error) {
|
||||
if (input.voiceCaptureId === undefined || !isVoiceCaptureGone(error)) {
|
||||
throw error;
|
||||
}
|
||||
const withoutVoice = { ...input };
|
||||
delete withoutVoice.voiceCaptureId;
|
||||
return { ...(await postInspection(withoutVoice, options)), voiceCaptureGone: true };
|
||||
}
|
||||
}
|
||||
|
||||
export function updateInspection(
|
||||
id: string,
|
||||
input: UpdateInspectionInput,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
INSPECTION_SCALE_DIRECTIONS,
|
||||
INSPECTION_SCALE_LABELS,
|
||||
type InspectionScaleField,
|
||||
} from "./scales";
|
||||
|
||||
interface ScaleFixture {
|
||||
field: string;
|
||||
storedOne: string;
|
||||
storedFive: string;
|
||||
anchors: Record<string, number>;
|
||||
}
|
||||
|
||||
// The one source both sides read: the backend prompt and this form.
|
||||
const FIXTURE_PATH = path.resolve(
|
||||
process.cwd(),
|
||||
"../ApiCore-Backend/test/fixtures/voice/inspection-scales.json",
|
||||
);
|
||||
|
||||
/** Which stored end is the good one - a fact about beekeeping, not derivable from the words. */
|
||||
const GOOD_STORED_END: Record<InspectionScaleField, 1 | 5> = {
|
||||
broodPattern: 5,
|
||||
swarmMood: 1,
|
||||
gentleness: 5,
|
||||
combBehavior: 5,
|
||||
};
|
||||
|
||||
// The frontend image builds without the backend folder: then there is nothing to compare.
|
||||
describe.skipIf(!existsSync(FIXTURE_PATH))("inspection scales", () => {
|
||||
const fixture = JSON.parse(readFileSync(FIXTURE_PATH, "utf8")) as ScaleFixture[];
|
||||
|
||||
it("scale directions equal the shared fixture", () => {
|
||||
expect(Object.keys(INSPECTION_SCALE_DIRECTIONS).sort()).toEqual(
|
||||
fixture.map((scale) => scale.field).sort(),
|
||||
);
|
||||
for (const scale of fixture) {
|
||||
const field = scale.field as InspectionScaleField;
|
||||
expect(INSPECTION_SCALE_LABELS[field], field).toEqual({
|
||||
lowLabel: scale.storedOne,
|
||||
highLabel: scale.storedFive,
|
||||
});
|
||||
expect(scale.anchors[scale.storedOne], field).toBe(1);
|
||||
expect(scale.anchors[scale.storedFive], field).toBe(5);
|
||||
expect(INSPECTION_SCALE_DIRECTIONS[field], field).toBe(
|
||||
GOOD_STORED_END[field] === 5 ? "higher-is-better" : "lower-is-better",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("inspection scales (without the fixture)", () => {
|
||||
it("mirrors only the swarm mood, whose stored 1 is the calm end", () => {
|
||||
const mirrored = Object.entries(INSPECTION_SCALE_DIRECTIONS)
|
||||
.filter(([, direction]) => direction === "lower-is-better")
|
||||
.map(([field]) => field);
|
||||
expect(mirrored).toEqual(["swarmMood"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { RatingDirection } from "@/lib/forms/rating";
|
||||
|
||||
export type InspectionScaleField = "broodPattern" | "swarmMood" | "gentleness" | "combBehavior";
|
||||
|
||||
/**
|
||||
* Words for the STORED ends of the four 1-5 scales (1 = lowLabel, 5 = highLabel).
|
||||
* The form and the voice prompt (ApiCore-Backend test/fixtures/voice/inspection-scales.json)
|
||||
* must say the same: a mismatch would turn a spoken "ruhig" into the wrong number.
|
||||
*/
|
||||
export const INSPECTION_SCALE_LABELS: Readonly<
|
||||
Record<InspectionScaleField, { lowLabel: string; highLabel: string }>
|
||||
> = {
|
||||
broodPattern: { lowLabel: "lückenhaft", highLabel: "geschlossen" },
|
||||
swarmMood: { lowLabel: "ruhig", highLabel: "stark" },
|
||||
gentleness: { lowLabel: "stechlustig", highLabel: "sehr sanft" },
|
||||
combBehavior: { lowLabel: "läuft weg", highLabel: "sitzt ruhig" },
|
||||
};
|
||||
|
||||
/** Which stored end is the good one. Only the swarm mood is stored the other way round. */
|
||||
export const INSPECTION_SCALE_DIRECTIONS: Readonly<Record<InspectionScaleField, RatingDirection>> =
|
||||
{
|
||||
broodPattern: "higher-is-better",
|
||||
swarmMood: "lower-is-better",
|
||||
gentleness: "higher-is-better",
|
||||
combBehavior: "higher-is-better",
|
||||
};
|
||||
@@ -49,6 +49,11 @@ export interface ColonyTakeover {
|
||||
/** The answer to a new inspection: the inspection plus what reached the colony card. */
|
||||
export interface CreatedInspection extends Inspection {
|
||||
colonyTakeover?: ColonyTakeover;
|
||||
/**
|
||||
* Set by the client, never by the server: the voice note was gone (409
|
||||
* VOICE_CAPTURE_GONE) and the inspection was saved once more without it.
|
||||
*/
|
||||
voiceCaptureGone?: true;
|
||||
}
|
||||
|
||||
export interface CreateInspectionInput {
|
||||
@@ -86,6 +91,12 @@ export interface CreateInspectionInput {
|
||||
* (403 HIVE_FORBIDDEN otherwise). Sent only when there are ops.
|
||||
*/
|
||||
hiveChange?: { ops: HiveOp[] };
|
||||
/**
|
||||
* The voice note this inspection was reviewed from. The server confirms it
|
||||
* and deletes its content in the same transaction (409 VOICE_CAPTURE_GONE /
|
||||
* VOICE_CAPTURE_COLONY_MISMATCH when it cannot). Creating only; never drafted.
|
||||
*/
|
||||
voiceCaptureId?: string;
|
||||
}
|
||||
|
||||
/** `null` clears an optional field, an omitted one stays unchanged. */
|
||||
|
||||
@@ -361,6 +361,48 @@ describe("offline queue database", () => {
|
||||
expect(items.map((item) => item.canResendWithoutHiveChange ?? false)).toEqual([false, true]);
|
||||
});
|
||||
|
||||
it("retryInspectionWithoutVoice strips the voice note, clears the rejection and keeps the key and the rest", async () => {
|
||||
const db = await loadDb();
|
||||
await db.queueInspection({ ...INSPECTION, voiceCaptureId: "cap-1" }, "key-v", "user-1");
|
||||
const [entry] = await db.getPendingEntries("pending-inspections");
|
||||
await db.markFailed("pending-inspections", entry.id, {
|
||||
status: 409,
|
||||
message: "Die Sprachnotiz gehört zu einem anderen Volk.",
|
||||
details: { code: "VOICE_CAPTURE_COLONY_MISMATCH" },
|
||||
at: REJECTION.at,
|
||||
});
|
||||
expect((await db.listQueue("user-1"))[0].canResendWithoutVoice).toBe(true);
|
||||
|
||||
await db.retryInspectionWithoutVoice(entry.id);
|
||||
|
||||
const [after] = await db.getPendingEntries("pending-inspections");
|
||||
expect(after.input).toEqual(INSPECTION);
|
||||
expect(after.lastError).toBeUndefined();
|
||||
expect(after.idempotencyKey).toBe("key-v");
|
||||
expect((await db.listQueue("user-1"))[0].canResendWithoutVoice).toBeUndefined();
|
||||
});
|
||||
|
||||
it("offers „ohne Sprachnotiz“ only for a colony mismatch of an inspection that carries a note", async () => {
|
||||
const db = await loadDb();
|
||||
await db.queueInspection({ ...INSPECTION, voiceCaptureId: "cap-1" }, "k1", "user-1");
|
||||
await db.queueInspection({ ...INSPECTION, voiceCaptureId: "cap-2" }, "k2", "user-1");
|
||||
await db.queueInspection(INSPECTION, "k3", "user-1");
|
||||
const [other, mismatch, plain] = await db.getPendingEntries("pending-inspections");
|
||||
await db.markFailed("pending-inspections", other.id, REJECTION);
|
||||
const mismatchError = {
|
||||
status: 409,
|
||||
message: "x",
|
||||
details: { code: "VOICE_CAPTURE_COLONY_MISMATCH" },
|
||||
at: REJECTION.at,
|
||||
};
|
||||
await db.markFailed("pending-inspections", mismatch.id, mismatchError);
|
||||
await db.markFailed("pending-inspections", plain.id, mismatchError);
|
||||
|
||||
const items = await db.listQueue("user-1");
|
||||
|
||||
expect(items.map((item) => item.canResendWithoutVoice ?? false)).toEqual([false, true, false]);
|
||||
});
|
||||
|
||||
it("summarizeQueue separates own, failed and foreign entries", async () => {
|
||||
const db = await loadDb();
|
||||
await db.queueInspection(INSPECTION, "key-1", "user-1");
|
||||
|
||||
@@ -558,6 +558,37 @@ export async function retryInspectionWithoutHiveChange(entryId: number): Promise
|
||||
await tx.done;
|
||||
}
|
||||
|
||||
/** The refusal for a voice note that belongs to another colony than the inspection. */
|
||||
function isVoiceColonyMismatch(error: QueueError | undefined): boolean {
|
||||
const code = (error?.details as { code?: unknown } | null | undefined)?.code;
|
||||
return error?.status === 409 && code === "VOICE_CAPTURE_COLONY_MISMATCH";
|
||||
}
|
||||
|
||||
/** A queued inspection that carries a voice note. */
|
||||
export function hasVoiceCapture(input: unknown): boolean {
|
||||
return (input as { voiceCaptureId?: unknown } | null)?.voiceCaptureId !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ohne Sprachnotiz senden: takes the voice note 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. Only the user chooses this -
|
||||
* a note of another colony is never left out on its own.
|
||||
*/
|
||||
export async function retryInspectionWithoutVoice(entryId: number): Promise<void> {
|
||||
const db = await getDb();
|
||||
const tx = db.transaction("pending-inspections", "readwrite");
|
||||
const entry = await tx.store.get(entryId);
|
||||
if (entry && hasVoiceCapture(entry.input)) {
|
||||
const input = { ...entry.input };
|
||||
delete input.voiceCaptureId;
|
||||
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;
|
||||
@@ -577,6 +608,8 @@ export interface QueueItem {
|
||||
hasHiveChange?: boolean;
|
||||
/** A rejected inspection whose hive change can be left out and the rest sent. */
|
||||
canResendWithoutHiveChange?: boolean;
|
||||
/** A rejected inspection whose voice note belongs to another colony: it can go without the note. */
|
||||
canResendWithoutVoice?: 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).
|
||||
@@ -674,15 +707,21 @@ export async function listQueue(currentUserId: string): Promise<QueueItem[]> {
|
||||
...(isHiveRefusal(entry.lastError) ? { canResendWithoutHiveChange: true } : {}),
|
||||
}
|
||||
: {};
|
||||
const voice =
|
||||
hasVoiceCapture((entry as PendingEntry<unknown>).input) && isVoiceColonyMismatch(entry.lastError)
|
||||
? { canResendWithoutVoice: true }
|
||||
: {};
|
||||
if (!entry.idempotencyKey) {
|
||||
return { ...item, ...hive };
|
||||
return { ...item, ...hive, ...voice };
|
||||
}
|
||||
const photoCount = await db.countFromIndex(
|
||||
"pending-photos",
|
||||
"inspectionIdempotencyKey",
|
||||
entry.idempotencyKey,
|
||||
);
|
||||
return photoCount > 0 ? { ...item, ...hive, photoCount } : { ...item, ...hive };
|
||||
return photoCount > 0
|
||||
? { ...item, ...hive, ...voice, photoCount }
|
||||
: { ...item, ...hive, ...voice };
|
||||
}),
|
||||
);
|
||||
const voiceItems = await readVoiceItems(db, currentUserId);
|
||||
|
||||
@@ -98,10 +98,12 @@ describe("service worker routes", () => {
|
||||
"/treatments/new",
|
||||
"/feedings/new",
|
||||
"/swarm-control/new",
|
||||
"/voice",
|
||||
"/voice/new",
|
||||
]) {
|
||||
expect(routes.isShellRoute(route)).toBe(true);
|
||||
}
|
||||
expect(routes.SHELL_ROUTES).toHaveLength(11);
|
||||
expect(routes.SHELL_ROUTES).toHaveLength(13);
|
||||
});
|
||||
|
||||
it("unknown routes go to /offline and remember where the user wanted to go", () => {
|
||||
@@ -224,6 +226,20 @@ describe("service worker routes", () => {
|
||||
expect(cachesApi("/api/v1/systems")).toBe(true);
|
||||
});
|
||||
|
||||
it("never caches voice notes - transcripts and suggestions are content", () => {
|
||||
for (const path of [
|
||||
"/api/v1/voice-captures",
|
||||
"/api/v1/voice-captures/",
|
||||
"/api/v1/voice-captures/c-1",
|
||||
"/api/v1/voice-captures/config",
|
||||
"/api/v1/voice-captures/stats",
|
||||
"/api/v1/voice-captures?status=READY",
|
||||
"/api/v1/Voice-Captures/c-1/retry",
|
||||
]) {
|
||||
expect(cachesApi(path)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("never caches exports and backups - full records incl. coordinates, addresses, megabytes", () => {
|
||||
for (const path of [
|
||||
"/api/v1/export/apiaries.json",
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import "fake-indexeddb/auto";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
import { beforeEach, describe, expect, it, vi, type MockedFunction } from "vitest";
|
||||
|
||||
// A queued inspection that carries a voice note (Task 20): it goes out through the real
|
||||
// createInspection, so the „note is gone“ retry holds for queued entries as well.
|
||||
|
||||
vi.mock("@/lib/auth/api-client", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/auth/api-client")>()),
|
||||
apiFetch: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/apiaries/offline-pack", () => ({ refreshPacksAfterSync: vi.fn() }));
|
||||
|
||||
type OfflineDbModule = typeof import("./db");
|
||||
type SyncModule = typeof import("./sync");
|
||||
|
||||
let db: OfflineDbModule;
|
||||
let sync: SyncModule;
|
||||
let apiFetch: MockedFunction<typeof import("@/lib/auth/api-client").apiFetch>;
|
||||
let ApiError: typeof import("@/lib/auth/api-client").ApiError;
|
||||
|
||||
const INSPECTION = {
|
||||
colonyId: "colony-1",
|
||||
date: "2026-10-14T00:00:00.000Z",
|
||||
queenSeen: true,
|
||||
voiceCaptureId: "cap-1",
|
||||
};
|
||||
|
||||
function sentBody(call: number): Record<string, unknown> {
|
||||
const init = apiFetch.mock.calls[call][1] as RequestInit;
|
||||
return JSON.parse(String(init.body)) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function sentKey(call: number): unknown {
|
||||
const init = apiFetch.mock.calls[call][1] as RequestInit;
|
||||
return (init.headers as Record<string, string>)["Idempotency-Key"];
|
||||
}
|
||||
|
||||
describe("syncing a queued inspection with a voice note", () => {
|
||||
beforeEach(async () => {
|
||||
globalThis.indexedDB = new IDBFactory();
|
||||
window.localStorage.clear();
|
||||
vi.resetModules();
|
||||
db = await import("./db");
|
||||
sync = await import("./sync");
|
||||
const client = await import("@/lib/auth/api-client");
|
||||
ApiError = client.ApiError;
|
||||
apiFetch = vi.mocked(client.apiFetch);
|
||||
apiFetch.mockReset();
|
||||
});
|
||||
|
||||
it("a queued inspection with an expired voice capture is saved without it", async () => {
|
||||
await db.queueInspection(INSPECTION, "key-queued", "user-1");
|
||||
apiFetch
|
||||
.mockRejectedValueOnce(new ApiError(409, "weg", { code: "VOICE_CAPTURE_GONE" }))
|
||||
.mockResolvedValueOnce({ id: "server-1" });
|
||||
|
||||
const result = await sync.syncPendingEntries("user-1");
|
||||
|
||||
expect(result).toMatchObject({ synced: 1, failed: 0 });
|
||||
expect(apiFetch).toHaveBeenCalledTimes(2);
|
||||
expect(sentBody(0)).toMatchObject({ voiceCaptureId: "cap-1", queenSeen: true });
|
||||
expect(sentBody(1)).toMatchObject({ queenSeen: true, colonyId: "colony-1" });
|
||||
expect(sentBody(1)).not.toHaveProperty("voiceCaptureId");
|
||||
expect(sentKey(1)).toBe("key-queued");
|
||||
expect(await db.getPendingEntries("pending-inspections")).toEqual([]);
|
||||
});
|
||||
|
||||
it("never drops a note of another colony by itself: it stays queued and can be sent without", async () => {
|
||||
await db.queueInspection(INSPECTION, "key-queued", "user-1");
|
||||
apiFetch.mockRejectedValueOnce(
|
||||
new ApiError(409, "Die Sprachnotiz gehört zu einem anderen Volk.", {
|
||||
code: "VOICE_CAPTURE_COLONY_MISMATCH",
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await sync.syncPendingEntries("user-1");
|
||||
|
||||
expect(result).toMatchObject({ synced: 0, failed: 1 });
|
||||
expect(apiFetch).toHaveBeenCalledTimes(1);
|
||||
const [item] = await db.listQueue("user-1");
|
||||
expect(item.canResendWithoutVoice).toBe(true);
|
||||
|
||||
apiFetch.mockResolvedValueOnce({ id: "server-1" });
|
||||
await db.retryInspectionWithoutVoice(item.id as number);
|
||||
const second = await sync.syncPendingEntries("user-1", { afterChange: true });
|
||||
|
||||
expect(second).toMatchObject({ synced: 1, failed: 0 });
|
||||
expect(sentBody(1)).not.toHaveProperty("voiceCaptureId");
|
||||
expect(sentKey(1)).toBe("key-queued");
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,12 @@ describe("system labels", () => {
|
||||
expect(JOB_LABELS.cleanup).toBe("Aufräumen");
|
||||
});
|
||||
|
||||
it("labels the voice transcribe job and both voice events", () => {
|
||||
expect(JOB_LABELS["voice-transcribe"]).toBe("Sprachnotizen abschreiben");
|
||||
expect(DOMAIN_EVENT_LABELS.VoiceCaptureReady).toBe("Sprachnotiz fertig");
|
||||
expect(DOMAIN_EVENT_LABELS.VoiceCaptureFailed).toBe("Sprachnotiz fehlgeschlagen");
|
||||
});
|
||||
|
||||
it("labels the run and delivery states", () => {
|
||||
expect(JOB_RUN_STATUS_LABELS.FAILED).toBe("Fehlgeschlagen");
|
||||
expect(WEBHOOK_DELIVERY_STATUS_LABELS.DELIVERED).toBe("Zugestellt");
|
||||
|
||||
@@ -8,6 +8,7 @@ export const JOB_LABELS: Record<JobName, string> = {
|
||||
cleanup: "Aufräumen",
|
||||
"app-backup": "App-Backup",
|
||||
rekey: "Schlüssel umstellen",
|
||||
"voice-transcribe": "Sprachnotizen abschreiben",
|
||||
};
|
||||
|
||||
export const JOB_RUN_STATUS_LABELS: Record<JobRunStatus, string> = {
|
||||
|
||||
@@ -4,6 +4,7 @@ export const JOB_NAMES = [
|
||||
"cleanup",
|
||||
"app-backup",
|
||||
"rekey",
|
||||
"voice-transcribe",
|
||||
] as const;
|
||||
|
||||
export type JobName = (typeof JOB_NAMES)[number];
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
discardAllVoiceCaptures,
|
||||
discardVoiceCapture,
|
||||
fetchVoiceCaptures,
|
||||
fetchVoiceStats,
|
||||
retryVoiceCapture,
|
||||
submitVoiceText,
|
||||
uploadVoiceCapture,
|
||||
@@ -62,6 +63,11 @@ describe("voice api", () => {
|
||||
expect(vi.mocked(apiFetch).mock.calls[3][0]).toBe("/voice-captures/discard-all");
|
||||
});
|
||||
|
||||
it("reads the system counters from /voice-captures/stats", async () => {
|
||||
await fetchVoiceStats();
|
||||
expect(vi.mocked(apiFetch).mock.calls[0][0]).toBe("/voice-captures/stats");
|
||||
});
|
||||
|
||||
it("has stable query keys", () => {
|
||||
expect(voiceKeys.detail("1")).toEqual(["voice-captures", "1"]);
|
||||
});
|
||||
|
||||
@@ -53,10 +53,19 @@ export interface VoiceClientConfig {
|
||||
retention: { audioHours: number; contentDays: number; rowDays: number };
|
||||
}
|
||||
|
||||
/** GET /voice-captures/stats (system:manage): counters of the last 7 days, no user data. */
|
||||
export interface VoiceCaptureStats {
|
||||
byStatus: Record<string, number>;
|
||||
byError: Record<string, number>;
|
||||
tokens: { input: number; output: number };
|
||||
byModel: Record<string, number>;
|
||||
}
|
||||
|
||||
export const voiceKeys = {
|
||||
all: ["voice-captures"] as const,
|
||||
detail: (id: string) => ["voice-captures", id] as const,
|
||||
config: ["voice-captures", "config"] as const,
|
||||
stats: ["voice-captures", "stats"] as const,
|
||||
};
|
||||
|
||||
const BASE = "/voice-captures";
|
||||
@@ -105,6 +114,10 @@ export function fetchVoiceConfig(): Promise<VoiceClientConfig> {
|
||||
return apiFetch<VoiceClientConfig>(`${BASE}/config`);
|
||||
}
|
||||
|
||||
export function fetchVoiceStats(): Promise<VoiceCaptureStats> {
|
||||
return apiFetch<VoiceCaptureStats>(`${BASE}/stats`);
|
||||
}
|
||||
|
||||
export function retryVoiceCapture(id: string): Promise<void> {
|
||||
return apiFetch<void>(idPath(id, "retry"), { method: "POST" });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { VOICE_MODEL_PRICES_USD, estimateVoiceCostUsd } from "./prices";
|
||||
|
||||
describe("voice model prices", () => {
|
||||
it("lists the three allowed models with the spec 7.1 prices", () => {
|
||||
expect(VOICE_MODEL_PRICES_USD["claude-sonnet-5-5"]).toEqual({ inputPerMillion: 2, outputPerMillion: 10 });
|
||||
expect(VOICE_MODEL_PRICES_USD["claude-opus-5-5"]).toEqual({ inputPerMillion: 4, outputPerMillion: 20 });
|
||||
expect(VOICE_MODEL_PRICES_USD["claude-haiku-5-5"]).toEqual({ inputPerMillion: 0.1, outputPerMillion: 0.5 });
|
||||
});
|
||||
|
||||
it("multiplies tokens by the price of the model", () => {
|
||||
expect(estimateVoiceCostUsd("claude-sonnet-5-5", { input: 1_000_000, output: 500_000 })).toBeCloseTo(7);
|
||||
});
|
||||
|
||||
it("is exactly 0 without tokens", () => {
|
||||
expect(estimateVoiceCostUsd("claude-sonnet-5-5", { input: 0, output: 0 })).toBe(0);
|
||||
});
|
||||
|
||||
it("returns null for a model without a price, never a guess", () => {
|
||||
expect(estimateVoiceCostUsd("unknown", { input: 10, output: 10 })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Price per million tokens in USD for the models the voice evaluation may use.
|
||||
* Source: spec docs/superpowers/specs/2026-10-14-t7-sprache-design.md section
|
||||
* 7.1 (Anthropic model list, as of 2026-10-06). Update together with
|
||||
* VOICE_MODELS in the backend (src/voice/voice-config.ts) when prices change.
|
||||
*/
|
||||
export interface VoiceModelPrice {
|
||||
inputPerMillion: number;
|
||||
outputPerMillion: number;
|
||||
}
|
||||
|
||||
export const VOICE_MODEL_PRICES_USD: Readonly<Record<string, VoiceModelPrice>> = {
|
||||
"claude-opus-5-5": { inputPerMillion: 4, outputPerMillion: 20 },
|
||||
"claude-sonnet-5-5": { inputPerMillion: 2, outputPerMillion: 10 },
|
||||
"claude-haiku-5-5": { inputPerMillion: 0.1, outputPerMillion: 0.5 },
|
||||
};
|
||||
|
||||
const TOKENS_PER_MILLION = 1_000_000;
|
||||
|
||||
/** Tokens x price; null when the model has no price in the table (never a guess). */
|
||||
export function estimateVoiceCostUsd(
|
||||
model: string,
|
||||
tokens: { input: number; output: number },
|
||||
): number | null {
|
||||
const price = VOICE_MODEL_PRICES_USD[model];
|
||||
if (!price) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
(tokens.input * price.inputPerMillion + tokens.output * price.outputPerMillion) /
|
||||
TOKENS_PER_MILLION
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { initialInspectionValues } from "@/app/(app)/inspections/new/inspection-form-values";
|
||||
import type { VoiceSuggestion } from "./api";
|
||||
import {
|
||||
describeFieldValue,
|
||||
describeSuggestionValue,
|
||||
openSuggestions,
|
||||
suggestionPatch,
|
||||
} from "./suggestions";
|
||||
|
||||
const values = initialInspectionValues("colony-1");
|
||||
|
||||
function suggestion(
|
||||
field: string,
|
||||
value: VoiceSuggestion["value"],
|
||||
extra: Partial<VoiceSuggestion> = {},
|
||||
): VoiceSuggestion {
|
||||
return { field, value, quote: "gesagt", corrected: false, truncated: false, ...extra };
|
||||
}
|
||||
|
||||
describe("describeSuggestionValue", () => {
|
||||
it.each([
|
||||
[suggestion("queenSeen", true), "Königin gesehen: Ja"],
|
||||
[suggestion("eggsPresent", false), "Stifte vorhanden: Nein"],
|
||||
[suggestion("feedStatus", "WEAK"), "Futter: Niedrig"],
|
||||
[suggestion("gentleness", 4), "Sanftmut: 4 von 5"],
|
||||
[suggestion("broodFrames", 4), "Brutwaben: 4"],
|
||||
[suggestion("colonyStrength", 1), "Volksstärke: 1 besetzte Wabe"],
|
||||
[suggestion("colonyStrength", 12), "Volksstärke: 12 besetzte Waben"],
|
||||
[suggestion("measures", "Honigraum aufgesetzt"), "Maßnahmen: Honigraum aufgesetzt"],
|
||||
])("words %j as %s", (item, expected) => {
|
||||
expect(describeSuggestionValue(item)).toBe(expected);
|
||||
});
|
||||
|
||||
it("words a scale as the buttons show it - the swarm mood is mirrored", () => {
|
||||
// Stored 1 is "ruhig", the good end, which the form shows on the right as 5.
|
||||
expect(describeSuggestionValue(suggestion("swarmMood", 1))).toBe(
|
||||
"Schwarmstimmung: 5 von 5 (ruhig)",
|
||||
);
|
||||
expect(describeFieldValue("swarmMood", 4)).toBe("2 von 5");
|
||||
});
|
||||
|
||||
it("names the end word at the two ends of a scale", () => {
|
||||
expect(describeFieldValue("gentleness", 5)).toBe("5 von 5 (sehr sanft)");
|
||||
expect(describeFieldValue("gentleness", 1)).toBe("1 von 5 (stechlustig)");
|
||||
expect(describeFieldValue("swarmMood", 5)).toBe("1 von 5 (stark)");
|
||||
expect(describeFieldValue("broodPattern", 3)).toBe("3 von 5");
|
||||
});
|
||||
});
|
||||
|
||||
describe("suggestionPatch", () => {
|
||||
it("turns each kind of value into the form's own shape", () => {
|
||||
expect(suggestionPatch(suggestion("queenSeen", true), values)).toEqual({ queenSeen: true });
|
||||
expect(suggestionPatch(suggestion("broodFrames", 4), values)).toEqual({ broodFrames: "4" });
|
||||
expect(suggestionPatch(suggestion("colonyStrength", 8), values)).toEqual({
|
||||
colonyStrength: "8",
|
||||
});
|
||||
expect(suggestionPatch(suggestion("swarmMood", 2), values)).toEqual({ swarmMood: 2 });
|
||||
expect(suggestionPatch(suggestion("feedStatus", "CRITICAL"), values)).toEqual({
|
||||
feedStatus: "CRITICAL",
|
||||
});
|
||||
});
|
||||
|
||||
it("appends free text to what is already there instead of replacing it", () => {
|
||||
expect(suggestionPatch(suggestion("notes", "Königin markiert"), values)).toEqual({
|
||||
notes: "Königin markiert",
|
||||
});
|
||||
expect(
|
||||
suggestionPatch(suggestion("notes", "Königin markiert"), { ...values, notes: "Schwarz" }),
|
||||
).toEqual({ notes: "Schwarz Königin markiert" });
|
||||
});
|
||||
|
||||
it("gives nothing for a field or value the form cannot take", () => {
|
||||
expect(suggestionPatch(suggestion("weather", "sonnig"), values)).toEqual({});
|
||||
expect(suggestionPatch(suggestion("colonyId", "x"), values)).toEqual({});
|
||||
expect(suggestionPatch(suggestion("queenSeen", "ja"), values)).toEqual({});
|
||||
expect(suggestionPatch(suggestion("gentleness", 9), values)).toEqual({});
|
||||
expect(suggestionPatch(suggestion("gentleness", 2.5), values)).toEqual({});
|
||||
expect(suggestionPatch(suggestion("colonyStrength", 41), values)).toEqual({});
|
||||
expect(suggestionPatch(suggestion("feedStatus", "ausreichend"), values)).toEqual({});
|
||||
expect(suggestionPatch(suggestion("notes", " "), values)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("openSuggestions", () => {
|
||||
const all = [
|
||||
suggestion("queenSeen", true),
|
||||
suggestion("gentleness", 4),
|
||||
suggestion("notes", "Neu"),
|
||||
suggestion("weather", "sonnig"),
|
||||
];
|
||||
|
||||
it("keeps the ones that would change something and drops unusable fields", () => {
|
||||
expect(openSuggestions(all, values, new Set()).map((item) => item.field)).toEqual([
|
||||
"queenSeen",
|
||||
"gentleness",
|
||||
"notes",
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops a field already taken or ignored and one the beekeeper already answered the same", () => {
|
||||
const answered = { ...values, queenSeen: true as boolean | null };
|
||||
expect(
|
||||
openSuggestions(all, answered, new Set(["notes"])).map((item) => item.field),
|
||||
).toEqual(["gentleness"]);
|
||||
});
|
||||
|
||||
it("skips free text the field already contains", () => {
|
||||
const filled = { ...values, notes: "Schwarz, Neu gesehen" };
|
||||
expect(openSuggestions([suggestion("notes", "Neu")], filled, new Set())).toEqual([]);
|
||||
expect(
|
||||
openSuggestions([suggestion("notes", "Neu")], { ...values, notes: "Alt" }, new Set()),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps a differing answer open - a tap decides", () => {
|
||||
const answered = { ...values, queenSeen: false as boolean | null };
|
||||
expect(openSuggestions(all, answered, new Set()).map((item) => item.field)).toContain(
|
||||
"queenSeen",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import type { InspectionFormValues } from "@/app/(app)/inspections/new/inspection-form-values";
|
||||
import { FEED_CONDITION_LABELS } from "@/lib/colonies/condition";
|
||||
import { MAX_OCCUPIED_FRAMES } from "@/lib/colonies/labels";
|
||||
import type { ColonyCondition } from "@/lib/colonies/types";
|
||||
import { toDisplayRating } from "@/lib/forms/rating";
|
||||
import {
|
||||
INSPECTION_SCALE_DIRECTIONS,
|
||||
INSPECTION_SCALE_LABELS,
|
||||
type InspectionScaleField,
|
||||
} from "@/lib/inspections/scales";
|
||||
import { appendDictation } from "./append-text";
|
||||
import type { VoiceSuggestion } from "./api";
|
||||
|
||||
/*
|
||||
* Turns what the server suggests into form values, in words, and decides what
|
||||
* is still on offer. The server already checked every value; checking again
|
||||
* here costs little and keeps a changed or damaged answer out of the form.
|
||||
* Suggested text is data: it is only ever returned as strings for React to
|
||||
* set as text.
|
||||
*/
|
||||
|
||||
type Value = VoiceSuggestion["value"];
|
||||
|
||||
/** The fields a suggestion may fill, with the words the form uses for them. */
|
||||
export const VOICE_FIELD_LABELS = {
|
||||
queenSeen: "Königin gesehen",
|
||||
eggsPresent: "Stifte vorhanden",
|
||||
openBroodPresent: "Offene Brut",
|
||||
cappedBroodPresent: "Verdeckelte Brut",
|
||||
queenCellsPresent: "Weiselzellen",
|
||||
broodPattern: "Brutbild",
|
||||
broodFrames: "Brutwaben",
|
||||
colonyStrength: "Volksstärke",
|
||||
gentleness: "Sanftmut",
|
||||
combBehavior: "Wabensitz",
|
||||
swarmMood: "Schwarmstimmung",
|
||||
feedStatus: "Futter",
|
||||
honeySuperStatus: "Honigraum",
|
||||
healthIssues: "Gesundheitsauffälligkeiten",
|
||||
varroaIssues: "Varroa-Befund",
|
||||
measures: "Maßnahmen",
|
||||
notes: "Notizen",
|
||||
} as const;
|
||||
|
||||
export type VoiceField = keyof typeof VOICE_FIELD_LABELS;
|
||||
|
||||
type FieldKind =
|
||||
| { kind: "bool" }
|
||||
| { kind: "count"; max: number }
|
||||
| { kind: "scale" }
|
||||
| { kind: "feed" }
|
||||
| { kind: "text" };
|
||||
|
||||
const BOOL: FieldKind = { kind: "bool" };
|
||||
const SCALE: FieldKind = { kind: "scale" };
|
||||
const TEXT: FieldKind = { kind: "text" };
|
||||
|
||||
const FIELD_KINDS: Record<VoiceField, FieldKind> = {
|
||||
queenSeen: BOOL,
|
||||
eggsPresent: BOOL,
|
||||
openBroodPresent: BOOL,
|
||||
cappedBroodPresent: BOOL,
|
||||
queenCellsPresent: BOOL,
|
||||
broodPattern: SCALE,
|
||||
gentleness: SCALE,
|
||||
combBehavior: SCALE,
|
||||
swarmMood: SCALE,
|
||||
broodFrames: { kind: "count", max: MAX_OCCUPIED_FRAMES },
|
||||
colonyStrength: { kind: "count", max: MAX_OCCUPIED_FRAMES },
|
||||
feedStatus: { kind: "feed" },
|
||||
honeySuperStatus: TEXT,
|
||||
healthIssues: TEXT,
|
||||
varroaIssues: TEXT,
|
||||
measures: TEXT,
|
||||
notes: TEXT,
|
||||
};
|
||||
|
||||
const SCALE_MIN = 1;
|
||||
const SCALE_MAX = 5;
|
||||
|
||||
export function isVoiceField(field: string): field is VoiceField {
|
||||
return Object.hasOwn(FIELD_KINDS, field);
|
||||
}
|
||||
|
||||
/** True for the free-text fields: their suggestions are added to what stands there. */
|
||||
export function isVoiceTextField(field: string): boolean {
|
||||
return isVoiceField(field) && FIELD_KINDS[field].kind === "text";
|
||||
}
|
||||
|
||||
function isScaleField(field: VoiceField): field is VoiceField & InspectionScaleField {
|
||||
return Object.hasOwn(INSPECTION_SCALE_DIRECTIONS, field);
|
||||
}
|
||||
|
||||
function isFeedLevel(value: Value): value is ColonyCondition {
|
||||
return typeof value === "string" && Object.hasOwn(FEED_CONDITION_LABELS, value);
|
||||
}
|
||||
|
||||
function isIntIn(value: Value, min: number, max: number): value is number {
|
||||
return typeof value === "number" && Number.isInteger(value) && value >= min && value <= max;
|
||||
}
|
||||
|
||||
/** The value as the form holds it, or undefined when the form cannot take it. */
|
||||
function formValueOf(field: VoiceField, value: Value): unknown {
|
||||
const spec = FIELD_KINDS[field];
|
||||
switch (spec.kind) {
|
||||
case "bool":
|
||||
return typeof value === "boolean" ? value : undefined;
|
||||
case "count":
|
||||
return isIntIn(value, 0, spec.max) ? String(value) : undefined;
|
||||
case "scale":
|
||||
return isIntIn(value, SCALE_MIN, SCALE_MAX) ? value : undefined;
|
||||
case "feed":
|
||||
return isFeedLevel(value) ? value : undefined;
|
||||
case "text":
|
||||
return typeof value === "string" && value.trim() !== "" ? value.trim() : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function countText(count: number, one: string, many: string): string {
|
||||
return count === 1 ? `1 ${one}` : `${count} ${many}`;
|
||||
}
|
||||
|
||||
/** A value in words, as the form's buttons word it ("Ja", "Niedrig", "4 von 5"). */
|
||||
export function describeFieldValue(field: string, value: Value): string {
|
||||
if (!isVoiceField(field)) return String(value);
|
||||
switch (FIELD_KINDS[field].kind) {
|
||||
case "bool":
|
||||
return value === true ? "Ja" : value === false ? "Nein" : String(value);
|
||||
case "scale": {
|
||||
if (!isScaleField(field) || typeof value !== "number") return String(value);
|
||||
// Mirrored scales read as the buttons show them: left is always the bad end.
|
||||
const text = `${toDisplayRating(value, INSPECTION_SCALE_DIRECTIONS[field])} von ${SCALE_MAX}`;
|
||||
// At the two ends the word says what the number means.
|
||||
const { lowLabel, highLabel } = INSPECTION_SCALE_LABELS[field];
|
||||
if (value === SCALE_MIN) return `${text} (${lowLabel})`;
|
||||
return value === SCALE_MAX ? `${text} (${highLabel})` : text;
|
||||
}
|
||||
case "feed":
|
||||
return isFeedLevel(value) ? FEED_CONDITION_LABELS[value] : String(value);
|
||||
case "count":
|
||||
return field === "colonyStrength" && typeof value === "number"
|
||||
? countText(value, "besetzte Wabe", "besetzte Waben")
|
||||
: String(value);
|
||||
case "text":
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
/** "Königin gesehen: Ja", "Futter: Niedrig", "Sanftmut: 4 von 5". */
|
||||
export function describeSuggestionValue(suggestion: VoiceSuggestion): string {
|
||||
const label = isVoiceField(suggestion.field)
|
||||
? VOICE_FIELD_LABELS[suggestion.field]
|
||||
: suggestion.field;
|
||||
return `${label}: ${describeFieldValue(suggestion.field, suggestion.value)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* What taking this suggestion over changes in the form; empty when the field
|
||||
* or value is not one the form can take. Free text is added to the end of
|
||||
* what is there (like dictation), everything else replaces the field.
|
||||
*/
|
||||
export function suggestionPatch(
|
||||
suggestion: VoiceSuggestion,
|
||||
current: InspectionFormValues,
|
||||
): Partial<InspectionFormValues> {
|
||||
const { field } = suggestion;
|
||||
if (!isVoiceField(field)) return {};
|
||||
const value = formValueOf(field, suggestion.value);
|
||||
if (value === undefined) return {};
|
||||
if (FIELD_KINDS[field].kind === "text") {
|
||||
return { [field]: appendDictation(current[field as "notes"], value as string) };
|
||||
}
|
||||
return { [field]: value };
|
||||
}
|
||||
|
||||
/**
|
||||
* The suggestions still on offer: usable, not yet taken or ignored, and not
|
||||
* already answered with the same value by hand. Free text always counts as
|
||||
* new (it is added, not compared).
|
||||
*/
|
||||
export function openSuggestions(
|
||||
all: readonly VoiceSuggestion[],
|
||||
values: InspectionFormValues,
|
||||
taken: ReadonlySet<string>,
|
||||
): VoiceSuggestion[] {
|
||||
return all.filter((suggestion) => {
|
||||
if (taken.has(suggestion.field)) return false;
|
||||
const patch: Record<string, unknown> = suggestionPatch(suggestion, values);
|
||||
const field = suggestion.field;
|
||||
if (!(field in patch)) return false;
|
||||
if (isVoiceTextField(field)) {
|
||||
// Text the field already holds is not added a second time.
|
||||
const suggested = String(suggestion.value).trim().toLowerCase();
|
||||
const held = String((values as unknown as Record<string, unknown>)[field]).toLowerCase();
|
||||
return !held.includes(suggested);
|
||||
}
|
||||
return (values as unknown as Record<string, unknown>)[field] !== patch[field];
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user