feat: Ganze Durchsicht aus Diktat auswerten (T7 L4 Task 21)

Knopf „Ganze Durchsicht auswerten“ unter den Freitextfeldern, sobald
diktiert wurde: sendet nur unveränderte Diktat-Teile, die noch in den
Feldern stehen, mit Hinweis auf die Auswertung bei Anthropic. Länge
und Text werden genau wie im Backend normalisiert (NFKC, Steuer- und
Formatzeichen, Codepoints) und so gesendet; Idempotency-Key je Inhalt,
nach endgültigem 4xx neu. Ergebnis in der Sprachnotiz-Box der
Durchsicht, Vorschläge nur für leere Felder, nichts ohne Tipp; offline
und bei ausgeschalteter Auswertung mit Hinweis statt Knopf.

Gates: Frontend lint, typecheck, 3203 Tests, build.

Refs #27

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick.RosenloecherandClaude Opus 5.5 committed 2026-10-16 16:06:00 +02:00
1 parent 8b63c50f7a
commit f6e1f34baa
8 files changed
+936 -5

No files matched your search

@@ -0,0 +1,281 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import NewInspectionPage from "./page";
import { fetchColonies } from "@/lib/colonies/api";
import { createInspection, fetchColonyInspections } from "@/lib/inspections/api";
import { fetchApiaryWeather } from "@/lib/weather/api";
import {
type VoiceCaptureDetail,
discardVoiceCapture,
fetchVoiceCapture,
fetchVoiceConfig,
submitVoiceText,
} from "@/lib/voice/api";
import { syncPendingEntries } from "@/lib/offline/sync";
// „Ganze Durchsicht auswerten“ (Task 21): the dictated text is evaluated and the
// answer shows in the voice note box above the form.
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
useSearchParams: () => new URLSearchParams(search),
}));
let search = "colonyId=colony-1";
vi.mock("@/lib/auth/auth-context", () => ({
useCurrentUserId: () => "user-1",
useAuth: () => ({ hasPermission: () => true }),
}));
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() }));
vi.mock("@/lib/inspections/api", () => ({
createInspection: vi.fn(),
fetchColonyInspections: vi.fn(),
}));
vi.mock("@/lib/weather/api", () => ({ fetchApiaryWeather: vi.fn() }));
vi.mock("@/lib/voice/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/voice/api")>()),
fetchVoiceCapture: vi.fn(),
discardVoiceCapture: vi.fn(),
fetchVoiceConfig: vi.fn(),
submitVoiceText: vi.fn(),
}));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }));
vi.mock("@/components/hive/hive-card", () => ({ HiveCard: () => null }));
vi.mock("@/lib/hive/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/hive/api")>()),
fetchHive: vi.fn().mockRejectedValue(new Error("no hive")),
fetchHiveSystems: vi.fn().mockResolvedValue([]),
}));
vi.mock("@/lib/offline/sync", () => ({ syncPendingEntries: vi.fn() }));
vi.mock("@/lib/photos/store", () => ({
PHOTOS_PER_INSPECTION_MAX: 5,
listSessionPhotos: vi.fn().mockResolvedValue([]),
addDraftPhoto: vi.fn(),
removePhoto: vi.fn(),
handOverSessionPhotos: vi.fn().mockResolvedValue(0),
queueInspectionWithPhotos: vi.fn().mockResolvedValue(0),
moveDraftPhotos: vi.fn(),
discardSessionPhotos: vi.fn().mockResolvedValue(undefined),
}));
// The browser engine is not the subject: a button per field hands over what was "said".
const SAID = "Varroa kaum zu sehen, Futter niedrig, Volk sehr sanft";
vi.mock("@/components/voice/dictation-button", () => ({
DictationRow: ({
children,
fieldLabel,
onAppend,
}: {
children: ReactNode;
fieldLabel: string;
onAppend: (text: string) => void;
}) => (
<div>
{children}
<button type="button" onClick={() => onAppend(SAID)}>
Diktat {fieldLabel}
</button>
</div>
),
}));
const TWO_MINUTES = 120_000;
const FIVE_SECONDS = 5_000;
function textNote(extra: Partial<VoiceCaptureDetail> = {}): VoiceCaptureDetail {
return {
id: "txt-1",
colonyId: "colony-1",
colonyName: "V-01",
status: "READY",
source: "TEXT",
createdAt: "2026-10-16T08:00:00.000Z",
lastErrorCode: null,
contentExpiresAt: "2026-10-23T08:00:00.000Z",
inspectionDate: "2026-10-16",
transcript: SAID,
suggestions: [
{ field: "gentleness", value: 5, quote: "Volk sehr sanft", corrected: false, truncated: false },
{ field: "colonyStrength", value: 20, quote: "stark", corrected: false, truncated: false },
],
unclear: [],
dropped: 0,
...extra,
};
}
function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<NewInspectionPage />
</QueryClientProvider>,
);
}
const DICTATE_NOTES = { name: "Diktat Notizen" };
const EVALUATE = { name: /Ganze Durchsicht auswerten/ };
const row = (fieldLabel: string) =>
screen.queryByRole("group", { name: `Vorschlag aus Sprache: ${fieldLabel}` });
type User = ReturnType<typeof userEvent.setup>;
/** The free-text fields sit in the folded section „Weitere Angaben“. */
async function openMore(user: User) {
await user.click(await screen.findByRole("button", { name: /Weitere Angaben/ }));
}
async function dictateAndSend(user: User) {
await openMore(user);
await user.click(await screen.findByRole("button", DICTATE_NOTES));
await user.click(await screen.findByRole("button", EVALUATE));
}
describe("inspection form: Ganze Durchsicht auswerten", () => {
beforeEach(() => {
window.localStorage.clear();
window.sessionStorage.clear();
search = "colonyId=colony-1";
Object.defineProperty(window.navigator, "onLine", { value: true, configurable: true });
vi.mocked(fetchColonies).mockResolvedValue([
{ id: "colony-1", code: "V-01", status: "ACTIVE", apiaryId: "apiary-1" },
] as never);
vi.mocked(createInspection).mockReset().mockResolvedValue({ id: "i-1", colonyId: "colony-1" } as never);
vi.mocked(fetchColonyInspections).mockResolvedValue([]);
vi.mocked(fetchApiaryWeather).mockResolvedValue({ daily: [] });
vi.mocked(fetchVoiceCapture).mockReset().mockResolvedValue(textNote());
vi.mocked(discardVoiceCapture).mockReset().mockResolvedValue(undefined);
vi.mocked(submitVoiceText).mockReset().mockResolvedValue({ id: "txt-1" });
vi.mocked(fetchVoiceConfig).mockResolvedValue({
recordingEnabled: true,
extractionEnabled: true,
model: "m",
retention: { audioHours: 24, contentDays: 7, rowDays: 90 },
});
vi.mocked(syncPendingEntries).mockReset().mockResolvedValue({ synced: 0, failed: 0, skippedForeign: 0 });
});
afterEach(() => {
vi.useRealTimers();
});
it("offers the button only after something was dictated", async () => {
const user = userEvent.setup();
renderPage();
await openMore(user);
await screen.findByRole("button", DICTATE_NOTES);
expect(screen.queryByRole("button", EVALUATE)).toBeNull();
await user.click(screen.getByRole("button", DICTATE_NOTES));
expect(await screen.findByRole("button", EVALUATE)).toBeEnabled();
expect(screen.getByText(/Der Text geht zur Auswertung an Anthropic/)).toBeInTheDocument();
});
it("is not offered when the form was opened from a voice note", async () => {
search = "colonyId=colony-1&voice=cap-1";
vi.mocked(fetchVoiceCapture).mockResolvedValue(textNote({ id: "cap-1", source: "AUDIO" }));
const user = userEvent.setup();
renderPage();
await openMore(user);
await user.click(await screen.findByRole("button", DICTATE_NOTES));
expect(screen.queryByRole("button", EVALUATE)).toBeNull();
});
it("sends only the dictated text still in a field, with colony and day", async () => {
const user = userEvent.setup();
renderPage();
await dictateAndSend(user);
await waitFor(() => expect(submitVoiceText).toHaveBeenCalledTimes(1));
const [input, key] = vi.mocked(submitVoiceText).mock.calls[0];
expect(input).toMatchObject({ colonyId: "colony-1", text: SAID });
expect(input.inspectionDate).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(key).toBeTruthy();
});
it("does not send text the beekeeper deleted again", async () => {
const user = userEvent.setup();
renderPage();
await openMore(user);
await user.click(await screen.findByRole("button", DICTATE_NOTES));
await user.clear(screen.getByLabelText(/^Notizen/));
expect(screen.queryByRole("button", EVALUATE)).toBeNull();
});
it("shows the answer in the voice box with suggestions only for empty fields and moves the focus", async () => {
const user = userEvent.setup();
renderPage();
await user.type(await screen.findByLabelText(/^Volksstärke/), "12");
await dictateAndSend(user);
const heading = await screen.findByRole("heading", { name: /^Sprachnotiz vom 16\.10\./ });
expect(heading).toBeInTheDocument();
await waitFor(() => expect(document.getElementById("voice-capture-box")).toHaveFocus());
expect(row("Sanftmut")).not.toBeNull();
expect(row("Volksstärke")).toBeNull();
expect(screen.getByRole("heading", { name: /1 Vorschlag$/ })).toBeInTheDocument();
// Nothing is filled by itself.
expect(screen.getByLabelText(/^Volksstärke/)).toHaveValue("12");
});
it("polls every 5 seconds until the note is ready", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
vi.mocked(fetchVoiceCapture)
.mockResolvedValueOnce(textNote({ status: "TRANSCRIBED", suggestions: null }))
.mockResolvedValue(textNote());
renderPage();
await dictateAndSend(user);
expect(await screen.findByText("Wird noch ausgewertet")).toBeInTheDocument();
await act(async () => {
await vi.advanceTimersByTimeAsync(FIVE_SECONDS + 100);
});
expect(await screen.findByRole("heading", { name: /2 Vorschläge$/ })).toBeInTheDocument();
});
it("stops polling after two minutes and points to the notification", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
vi.mocked(fetchVoiceCapture).mockResolvedValue(textNote({ status: "EXTRACTING", suggestions: null }));
renderPage();
await dictateAndSend(user);
await screen.findByText("Wird noch ausgewertet");
await act(async () => {
await vi.advanceTimersByTimeAsync(TWO_MINUTES + 100);
});
const callsAtGiveUp = vi.mocked(fetchVoiceCapture).mock.calls.length;
await act(async () => {
await vi.advanceTimersByTimeAsync(FIVE_SECONDS * 4);
});
expect(vi.mocked(fetchVoiceCapture).mock.calls.length).toBe(callsAtGiveUp);
expect(screen.getByText(/Du bekommst eine Meldung/)).toBeInTheDocument();
});
it("saves the inspection with the text note's id", async () => {
const user = userEvent.setup();
renderPage();
await dictateAndSend(user);
await screen.findByRole("heading", { name: /^Sprachnotiz vom/ });
await user.click(screen.getByRole("button", { name: "Durchsicht speichern" }));
await waitFor(() => expect(createInspection).toHaveBeenCalled());
expect(vi.mocked(createInspection).mock.calls[0][0]).toMatchObject({ voiceCaptureId: "txt-1" });
});
it("puts the box above the form and nothing in the fields on its own", async () => {
const user = userEvent.setup();
renderPage();
await dictateAndSend(user);
const box = await screen.findByRole("region", { name: /^Sprachnotiz vom/ });
expect(within(box).getByRole("button", { name: /Alle übernehmen/ })).toBeInTheDocument();
expect(screen.getByLabelText(/^Notizen/)).toHaveValue(SAID);
});
});
@@ -21,7 +21,7 @@ import {
import { type LastInspection, useLastInspection } from "@/lib/inspections/use-last-inspection";
import type { ColonyTakeoverField, CreateInspectionInput } from "@/lib/inspections/types";
import { hiveKeys } from "@/lib/hive/api";
import { voiceKeys } from "@/lib/voice/api";
import { discardVoiceCapture, voiceKeys } from "@/lib/voice/api";
import { INSPECTION_SCALE_DIRECTIONS, INSPECTION_SCALE_LABELS } from "@/lib/inspections/scales";
import type { HiveOp } from "@/lib/hive/types";
import { useAuth, useCurrentUserId } from "@/lib/auth/auth-context";
@@ -45,8 +45,10 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { DictationRow } from "@/components/voice/dictation-button";
import { EvaluateDictationButton } from "@/components/voice/evaluate-dictation-button";
import { VoiceEntryLink } from "@/components/voice/voice-entry-link";
import { appendDictation } from "@/lib/voice/append-text";
import { emptyVoiceFields, joinDictatedText, stillPresentChunks } from "@/lib/voice/dictated-text";
import { FormBackLink } from "@/components/form/form-back-link";
import { RoundNextPanel } from "@/components/rounds/round-next-panel";
import { PageHeader } from "@/components/page-header";
@@ -76,7 +78,12 @@ import {
import { broodHintFor, broodHintLabel, hiveErrorText } from "./hive-change";
import { suggestAfterInspection } from "./hive-save-follow-ups";
import { InspectionHiveSection, useInspectionHive } from "./hive-section";
import { isGoneVoiceStatus, isVoiceColonyMismatch, useVoiceReview } from "./use-voice-review";
import {
isGoneVoiceStatus,
isVoiceColonyMismatch,
useVoiceReview,
type TextNote,
} from "./use-voice-review";
import { VoiceFieldRow, VoiceReviewBox } from "./voice-review";
/** Free-text fields that take dictation. */
@@ -248,8 +255,12 @@ function InspectionCaptureForm({
const { base: hiveBase, catalog: hiveCatalog } = useInspectionHive(values.colonyId);
const broodFromHive = broodHintFor(hiveBase, hiveOps, hiveCatalog);
// A tap on „Übernehmen“ writes like any tap; observations are never ticked for the Volkskarte.
// What was dictated into the free-text fields, and the note „Ganze Durchsicht auswerten“ made from it.
const [dictated, setDictated] = useState<string[]>([]);
const [textNote, setTextNote] = useState<TextNote | null>(null);
const voice = useVoiceReview({
voiceId,
textNote,
initialColonyId,
values,
apply: (patch) => {
@@ -340,10 +351,33 @@ function InspectionCaptureForm({
/** Dictation is added to the end of a free-text field and marks the form as changed. */
function appendDictated(key: DictatedField, text: string) {
if (text.trim() !== "") setDictated((current) => [...current, text.trim()]);
update((current) => ({ ...current, [key]: appendDictation(current[key], text) }));
setDirty(true);
}
/** The dictated text that is still written in a field: only that leaves for the evaluation. */
const evaluationText = joinDictatedText(
stillPresentChunks(dictated, [
values.varroaIssues,
values.healthIssues,
values.measures,
values.notes,
]),
);
/** The evaluation of the dictated text started: its note shows in the box above the form. */
function startedTextNote({ id }: { id: string }) {
const earlier = textNote?.id;
setTextNote({ id, emptyFields: emptyVoiceFields(values) });
if (earlier && earlier !== id) {
// The earlier text note is replaced; the server would drop it after 7 days anyway, so a
// failure here is not worth a message to the beekeeper.
discardVoiceCapture(earlier).catch(() => undefined);
}
void queryClient.invalidateQueries({ queryKey: voiceKeys.all, refetchType: "none" });
}
/** How many photos the form's photo section shows (none while it is hidden). */
function shownPhotoCount(photoSessionId: string): number {
return (
@@ -1008,6 +1042,15 @@ function InspectionCaptureForm({
</>
)}
</Field>
{voiceId === null ? (
<EvaluateDictationButton
colonyId={values.colonyId}
inspectionDate={values.date}
text={evaluationText}
busy={voice.inProgress}
onSubmitted={startedTextNote}
/>
) : null}
</FormSection>
<PhotoCaptureField
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { ApiError } from "@/lib/auth/api-client";
@@ -13,6 +13,7 @@ import {
voiceKeys,
} from "@/lib/voice/api";
import { isVoiceInProgress } from "@/lib/voice/status-texts";
import { VOICE_CAPTURE_BOX_ID } from "@/components/voice/voice-capture-box";
import {
describeFieldValue,
describeSuggestionValue,
@@ -26,6 +27,8 @@ import type { InspectionFormValues } from "./inspection-form-values";
const POLL_MS = 5000;
/** A note still unfinished after this long is not waited for any more; the page can be reopened. */
const POLL_GIVE_UP_MS = 10 * 60 * 1000;
/** A note made from dictated text is waited for this long; then the notification takes over. */
const TEXT_POLL_GIVE_UP_MS = 2 * 60 * 1000;
const NOT_FOUND = 404;
const GONE_STATUSES: readonly string[] = ["CONFIRMED", "DISCARDED", "EXPIRED"];
@@ -59,9 +62,17 @@ export function isVoiceColonyMismatch(error: unknown): boolean {
);
}
/** The note made by „Ganze Durchsicht auswerten“: it offers suggestions for the fields that were empty then. */
export interface TextNote {
id: string;
emptyFields: ReadonlySet<string>;
}
export interface UseVoiceReviewOptions {
/** `?voice=<id>`; null when the form was not opened from a voice note. */
voiceId: string | null;
/** The evaluation of the dictated text; used while `voiceId` is null. */
textNote?: TextNote | null;
/** The colony in the address: the best guess for the note's colony until it has loaded. */
initialColonyId: string;
values: InspectionFormValues;
@@ -97,8 +108,18 @@ function focusFieldOf(field: string): void {
* a tap (`apply`). The id goes to the server only while the note is still
* open and belongs to the form's colony.
*/
export function useVoiceReview({ voiceId, initialColonyId, values, apply }: UseVoiceReviewOptions) {
export function useVoiceReview({
voiceId: urlVoiceId,
textNote = null,
initialColonyId,
values,
apply,
}: UseVoiceReviewOptions) {
const queryClient = useQueryClient();
const voiceId = urlVoiceId ?? textNote?.id ?? null;
const emptyFields = urlVoiceId === null && textNote ? textNote.emptyFields : null;
const [gaveUpFor, setGaveUpFor] = useState<string | null>(null);
const textWaitEnds = emptyFields !== null;
const [settled, setSettled] = useState<ReadonlyMap<string, Settled>>(new Map());
const [leftOut, setLeftOut] = useState(false);
@@ -112,6 +133,14 @@ export function useVoiceReview({ voiceId, initialColonyId, values, apply }: UseV
const extractionEnabled = config.data?.extractionEnabled ?? true;
const [startedAt] = useState(() => Date.now());
// A text note is evaluated within moments; if it is not, the notification tells when it is.
useEffect(() => {
if (!textWaitEnds || voiceId === null) return;
const timer = window.setTimeout(() => setGaveUpFor(voiceId), TEXT_POLL_GIVE_UP_MS);
return () => window.clearTimeout(timer);
}, [textWaitEnds, voiceId]);
const waitGaveUp = textWaitEnds && gaveUpFor === voiceId;
const query = useQuery({
queryKey: voiceKeys.detail(voiceId ?? ""),
queryFn: () => fetchVoiceCapture(voiceId ?? ""),
@@ -120,11 +149,20 @@ export function useVoiceReview({ voiceId, initialColonyId, values, apply }: UseV
refetchInterval: (state) =>
state.state.data &&
isVoiceInProgress(state.state.data.status, extractionEnabled) &&
!waitGaveUp &&
Date.now() - startedAt < POLL_GIVE_UP_MS
? POLL_MS
: false,
});
const capture: VoiceCaptureDetail | undefined = leftOut ? undefined : query.data;
const loaded: VoiceCaptureDetail | undefined = leftOut ? undefined : query.data;
// Only the fields that were empty when the text was sent: nothing already answered is replaced.
const capture: VoiceCaptureDetail | undefined =
loaded && emptyFields && loaded.suggestions
? {
...loaded,
suggestions: loaded.suggestions.filter((suggestion) => emptyFields.has(suggestion.field)),
}
: loaded;
const notFound = query.error instanceof ApiError && query.error.status === NOT_FOUND;
const isGone = capture ? isGoneVoiceStatus(capture.status) : notFound;
const noteColonyId = capture?.colonyId ?? initialColonyId;
@@ -236,6 +274,17 @@ export function useVoiceReview({ voiceId, initialColonyId, values, apply }: UseV
});
const sendsId = voiceId !== null && !leftOut && !isGone && sameColony;
const inProgress = capture ? isVoiceInProgress(capture.status, extractionEnabled) : false;
// The answer to the dictated text arrives while the beekeeper works elsewhere on the form:
// the box takes the focus once, so a screen reader announces it.
const focusedFor = useRef<string | null>(null);
const answered = textWaitEnds && capture !== undefined && !inProgress;
useEffect(() => {
if (!answered || voiceId === null || focusedFor.current === voiceId) return;
focusedFor.current = voiceId;
document.getElementById(VOICE_CAPTURE_BOX_ID)?.focus();
}, [answered, voiceId]);
return {
voiceId,
@@ -248,6 +297,10 @@ export function useVoiceReview({ voiceId, initialColonyId, values, apply }: UseV
/** The id to send with the inspection, if the note should be tied to it. */
voiceCaptureId: sendsId ? voiceId : undefined,
openCount: open.length,
/** The note is still being transcribed or evaluated. */
inProgress,
/** Evaluating, and the wait for it has ended: the notification will tell when it is done. */
waitGaveUp: waitGaveUp && inProgress,
rowFor,
takeOverAll,
discard: () => discard.mutate(),
@@ -45,6 +45,7 @@ export function VoiceReviewBox({ review }: { review: VoiceReview }) {
);
}
return (
<div className="flex flex-col gap-3">
<VoiceCaptureBox
capture={capture}
openCount={review.openCount}
@@ -53,6 +54,13 @@ export function VoiceReviewBox({ review }: { review: VoiceReview }) {
discarding={review.discarding}
extractionEnabled={review.extractionEnabled}
/>
{review.waitGaveUp ? (
<p role="status" className={NOTE_CLASS}>
Die Auswertung dauert länger. Du bekommst eine Meldung, sobald sie fertig ist, und kannst
die Durchsicht so lange weiter ausfüllen.
</p>
) : null}
</div>
);
}
@@ -0,0 +1,221 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { EvaluateDictationButton } from "./evaluate-dictation-button";
import { ApiError } from "@/lib/auth/api-client";
import { fetchVoiceConfig, submitVoiceText } from "@/lib/voice/api";
import { reportRequestOutcome } from "@/lib/offline/connectivity";
let allowed = true;
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({ hasPermission: () => allowed }),
}));
vi.mock("@/lib/voice/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/voice/api")>()),
fetchVoiceConfig: vi.fn(),
submitVoiceText: vi.fn(),
}));
const TEXT = "Königin gesehen, Futter niedrig, Volk sehr sanft.";
const BUTTON = { name: /Ganze Durchsicht auswerten/ };
const CONFIG = {
recordingEnabled: true,
extractionEnabled: true,
model: "m",
retention: { audioHours: 24, contentDays: 7, rowDays: 90 },
};
type Props = React.ComponentProps<typeof EvaluateDictationButton>;
function ui(props: Partial<Props> = {}) {
return (
<EvaluateDictationButton
colonyId="colony-1"
inspectionDate="2026-10-16"
text={TEXT}
onSubmitted={vi.fn()}
{...props}
/>
);
}
function renderButton(props: Partial<Props> = {}) {
const onSubmitted = vi.fn();
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const view = render(
<QueryClientProvider client={client}>{ui({ onSubmitted, ...props })}</QueryClientProvider>,
);
return {
onSubmitted,
rerenderWith: (next: Partial<Props>) =>
view.rerender(
<QueryClientProvider client={client}>{ui({ onSubmitted, ...next })}</QueryClientProvider>,
),
...view,
};
}
describe("EvaluateDictationButton", () => {
beforeEach(() => {
allowed = true;
reportRequestOutcome("reached-server");
Object.defineProperty(window.navigator, "onLine", { value: true, configurable: true });
vi.mocked(fetchVoiceConfig).mockReset().mockResolvedValue(CONFIG);
vi.mocked(submitVoiceText).mockReset().mockResolvedValue({ id: "cap-9" });
});
it("is hidden without dictation", () => {
const { container } = renderButton({ text: "" });
expect(container).toBeEmptyDOMElement();
});
it("is hidden without the right to use voice notes", () => {
allowed = false;
const { container } = renderButton();
expect(container).toBeEmptyDOMElement();
expect(fetchVoiceConfig).not.toHaveBeenCalled();
});
it("says in one line that the text goes to Anthropic", async () => {
renderButton();
const button = await screen.findByRole("button", BUTTON);
expect(button).toHaveAccessibleDescription(/Der Text geht zur Auswertung an Anthropic/);
expect(button.className).toMatch(/min-h-14/);
});
it("is disabled offline and says why", async () => {
Object.defineProperty(window.navigator, "onLine", { value: false, configurable: true });
renderButton();
expect(await screen.findByRole("button", BUTTON)).toBeDisabled();
expect(screen.getByText(/braucht eine Verbindung/)).toBeInTheDocument();
});
it("explains instead of offering when the evaluation is switched off", async () => {
vi.mocked(fetchVoiceConfig).mockResolvedValue({ ...CONFIG, extractionEnabled: false });
renderButton();
expect(await screen.findByText(/Auswertung ist gerade ausgeschaltet/)).toBeInTheDocument();
expect(screen.queryByRole("button", BUTTON)).toBeNull();
});
it("explains when voice notes are switched off", async () => {
vi.mocked(fetchVoiceConfig).mockResolvedValue({ ...CONFIG, recordingEnabled: false });
renderButton();
expect(await screen.findByText(/Sprachnotizen sind gerade ausgeschaltet/)).toBeInTheDocument();
expect(screen.queryByRole("button", BUTTON)).toBeNull();
});
it("keeps the button when the switch cannot be asked", async () => {
vi.mocked(fetchVoiceConfig).mockRejectedValue(new TypeError("Failed to fetch"));
renderButton();
expect(await screen.findByRole("button", BUTTON)).toBeEnabled();
});
it("blocks a text that is too short or too long with a hint", async () => {
const { rerenderWith } = renderButton({ text: "zu kurz" });
expect(await screen.findByRole("button", BUTTON)).toBeDisabled();
expect(screen.getByText(/mindestens 20 Zeichen/)).toBeInTheDocument();
rerenderWith({ text: "wort ".repeat(900).trim() });
expect(await screen.findByText(/höchstens 4000 Zeichen/)).toBeInTheDocument();
expect(screen.getByRole("button", BUTTON)).toBeDisabled();
});
it("sends the dictated text once with an idempotency key and hands over the id", async () => {
const user = userEvent.setup();
const { onSubmitted } = renderButton();
await user.click(await screen.findByRole("button", BUTTON));
await waitFor(() => expect(onSubmitted).toHaveBeenCalledWith({ id: "cap-9" }));
expect(submitVoiceText).toHaveBeenCalledTimes(1);
const [input, key] = vi.mocked(submitVoiceText).mock.calls[0];
expect(input).toEqual({ colonyId: "colony-1", inspectionDate: "2026-10-16", text: TEXT });
expect(key).toMatch(/\S{8,}/);
});
it("repeats a lost send with the same key and a new send with a new one", async () => {
const user = userEvent.setup();
vi.mocked(submitVoiceText).mockRejectedValueOnce(new TypeError("Failed to fetch"));
renderButton();
await user.click(await screen.findByRole("button", BUTTON));
expect(await screen.findByRole("alert")).toHaveTextContent(/noch einmal/);
await user.click(screen.getByRole("button", BUTTON));
await waitFor(() => expect(submitVoiceText).toHaveBeenCalledTimes(2));
const keys = vi.mocked(submitVoiceText).mock.calls.map((call) => call[1]);
expect(keys[0]).toBe(keys[1]);
await waitFor(() => expect(screen.getByRole("button", BUTTON)).toBeEnabled());
await user.click(screen.getByRole("button", BUTTON));
await waitFor(() => expect(submitVoiceText).toHaveBeenCalledTimes(3));
expect(vi.mocked(submitVoiceText).mock.calls[2][1]).not.toBe(keys[1]);
});
it("shows progress in a live region while sending", async () => {
const user = userEvent.setup();
let release: (value: { id: string }) => void = () => undefined;
vi.mocked(submitVoiceText).mockReturnValue(new Promise((resolve) => (release = resolve)));
renderButton();
await user.click(await screen.findByRole("button", BUTTON));
expect(await screen.findByRole("status")).toHaveTextContent(/Wird gesendet/);
expect(screen.getByRole("button", BUTTON)).toBeDisabled();
release({ id: "cap-9" });
await waitFor(() => expect(screen.getByRole("button", BUTTON)).toBeEnabled());
});
it("asks for colony and day first when one is missing", async () => {
renderButton({ colonyId: "" });
expect(await screen.findByRole("button", BUTTON)).toBeDisabled();
expect(screen.getByText(/Volk und Datum/)).toBeInTheDocument();
});
it("checks and sends the text the way the server counts it", async () => {
const user = userEvent.setup();
const padded = "​zu kurz​".repeat(2);
const { rerenderWith } = renderButton({ text: padded });
expect(await screen.findByRole("button", BUTTON)).toBeDisabled();
const real = "Königin​ gesehen, Futter niedrig";
rerenderWith({ text: real });
await user.click(screen.getByRole("button", BUTTON));
await waitFor(() => expect(submitVoiceText).toHaveBeenCalled());
expect(vi.mocked(submitVoiceText).mock.calls[0][0].text).toBe("Königin gesehen, Futter niedrig");
});
it("says that only unchanged dictation is sent", async () => {
renderButton();
expect(await screen.findByText(/Nur unveränderte Diktat-Teile/)).toBeInTheDocument();
});
it("starts a new send after a definitive refusal, keeps the key after a network error or 5xx", async () => {
const user = userEvent.setup();
vi.mocked(submitVoiceText)
.mockRejectedValueOnce(new ApiError(429, "x", { code: "TOO_MANY_OPEN" }))
.mockRejectedValueOnce(new ApiError(503, "x", {}))
.mockRejectedValueOnce(new TypeError("Failed to fetch"))
.mockResolvedValue({ id: "cap-9" });
renderButton();
for (let i = 0; i < 4; i++) {
await user.click(await screen.findByRole("button", BUTTON));
await waitFor(() => expect(submitVoiceText).toHaveBeenCalledTimes(i + 1));
await waitFor(() => expect(screen.getByRole("button", BUTTON)).toBeEnabled());
}
const keys = vi.mocked(submitVoiceText).mock.calls.map((call) => call[1]);
expect(keys[1]).not.toBe(keys[0]);
expect(keys[2]).toBe(keys[1]);
expect(keys[3]).toBe(keys[1]);
});
it("is disabled while the earlier note is still being evaluated", async () => {
renderButton({ busy: true });
expect(await screen.findByRole("button", BUTTON)).toBeDisabled();
expect(screen.getByText(/läuft noch/)).toBeInTheDocument();
});
it.each([
[403, "VOICE_RECORDING_DISABLED", /Sprachnotizen sind gerade ausgeschaltet/],
[429, "TOO_MANY_OPEN", /Zu viele offene Sprachnotizen/],
[400, "BAD_REQUEST", /nicht gesendet werden/],
])("names refusal %s %s", async (status, code, message) => {
const user = userEvent.setup();
vi.mocked(submitVoiceText).mockRejectedValue(new ApiError(status, "x", { code }));
renderButton();
await user.click(await screen.findByRole("button", BUTTON));
expect(await screen.findByRole("alert")).toHaveTextContent(message);
});
});
@@ -0,0 +1,158 @@
"use client";
import { useId, useRef, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ApiError } from "@/lib/auth/api-client";
import { useAuth } from "@/lib/auth/auth-context";
import { newIdempotencyKey } from "@/lib/offline/idempotency";
import { useConnectivity } from "@/lib/offline/connectivity";
import { submitVoiceText } from "@/lib/voice/api";
import {
DICTATED_TEXT_MAX,
DICTATED_TEXT_MIN,
dictatedTextProblem,
normalizeForLength,
} from "@/lib/voice/dictated-text";
import { voiceErrorText } from "@/lib/voice/status-texts";
import { offersRecording, useVoiceConfig, useVoiceRecordingSwitch } from "@/lib/voice/use-voice-recording-switch";
interface EvaluateDictationButtonProps {
colonyId: string;
/** The inspection day, "YYYY-MM-DD". */
inspectionDate: string;
/** The dictated text, pieces joined and white space collapsed; "" while nothing was dictated. */
text: string;
/** A note made from an earlier send is still being evaluated. */
busy?: boolean;
onSubmitted(result: { id: string }): void;
}
const HINT_CLASS = "text-sm text-muted-foreground";
const SEND_FAILED = "Der Text konnte nicht gesendet werden. Bitte noch einmal versuchen.";
const RECORDING_OFF = "Sprachnotizen sind gerade ausgeschaltet.";
function refusalText(error: unknown): string {
if (error instanceof ApiError) {
const code = (error.body as { code?: unknown } | null)?.code;
if (code === "VOICE_RECORDING_DISABLED") return RECORDING_OFF;
if (code === "TOO_MANY_OPEN") return voiceErrorText("TOO_MANY_OPEN");
if (error.status === 429) return "Zu viele Anfragen. Bitte gleich noch einmal versuchen.";
if (error.status === 400) return "Der Text konnte nicht gesendet werden. Bitte kürzen oder ändern.";
}
return SEND_FAILED;
}
function problemText(problem: "short" | "long"): string {
return problem === "short"
? `Zu kurz zum Auswerten: mindestens ${DICTATED_TEXT_MIN} Zeichen.`
: `Zu lang zum Auswerten: höchstens ${DICTATED_TEXT_MAX} Zeichen.`;
}
/**
* „Ganze Durchsicht auswerten“ under the free-text fields: sends what was
* dictated to the server for evaluation. The text leaves for Anthropic, so
* the button says so right next to itself. It needs a connection (nothing is
* queued) and never fills a field: the answer comes as a voice note above the
* form, whose suggestions are taken over by tap.
*/
export function EvaluateDictationButton({
colonyId,
inspectionDate,
text: rawText,
busy = false,
onSubmitted,
}: EvaluateDictationButtonProps) {
// Checked and sent are the same text: the server's own normalisation.
const text = normalizeForLength(rawText);
const { hasPermission } = useAuth();
const allowed = hasPermission("voice-captures:use");
const wanted = allowed && text !== "";
const recordingSwitch = useVoiceRecordingSwitch(wanted);
const config = useVoiceConfig(wanted);
const online = useConnectivity() === "online";
const noteId = useId();
// One key per text: a repeat after a lost answer is the same send, a changed text a new one.
const pending = useRef<{ payload: string; key: string } | null>(null);
const [error, setError] = useState<string | null>(null);
const send = useMutation({
mutationFn: () => {
const payload = JSON.stringify([colonyId, inspectionDate, text]);
if (pending.current?.payload !== payload) {
pending.current = { payload, key: newIdempotencyKey() };
}
return submitVoiceText({ colonyId, inspectionDate, text }, pending.current.key);
},
onSuccess: (result) => {
pending.current = null;
setError(null);
onSubmitted(result);
},
onError: (failure) => {
// A definitive refusal ends this send; only a lost answer (network, 5xx) is repeated with its key.
if (failure instanceof ApiError && failure.status >= 400 && failure.status < 500) {
pending.current = null;
}
setError(refusalText(failure));
},
});
if (!wanted || recordingSwitch === "loading") return null;
if (!offersRecording(recordingSwitch)) {
return <p className={HINT_CLASS}>{RECORDING_OFF}</p>;
}
if (config.data?.extractionEnabled === false) {
return (
<p className={HINT_CLASS}>
Die Auswertung ist gerade ausgeschaltet. Bitte von Hand ausfüllen.
</p>
);
}
const problem = dictatedTextProblem(text);
const unplaced = colonyId === "" || inspectionDate === "";
const hint = !online
? "Die Auswertung braucht eine Verbindung."
: unplaced
? "Bitte zuerst Volk und Datum wählen."
: busy
? "Die Auswertung läuft noch."
: problem
? problemText(problem)
: null;
const disabled = !online || unplaced || busy || problem !== null || send.isPending;
return (
<div className="flex flex-col items-start gap-2">
<Button
type="button"
variant="outline"
size="touch"
className="min-h-14"
disabled={disabled}
aria-describedby={noteId}
onClick={() => {
setError(null);
send.mutate();
}}
>
<Sparkles aria-hidden="true" />
Ganze Durchsicht auswerten
</Button>
<p id={noteId} className={HINT_CLASS}>
Der Text geht zur Auswertung an Anthropic. Nur unveränderte Diktat-Teile werden gesendet.
{hint ? ` ${hint}` : ""}
</p>
<div role="status" className="text-sm">
{send.isPending ? "Wird gesendet …" : null}
</div>
{error ? (
<p role="alert" className="text-sm font-medium text-destructive">
{error}
</p>
) : null}
</div>
);
}
@@ -0,0 +1,109 @@
import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import {
DICTATED_TEXT_MAX,
DICTATED_TEXT_MIN,
dictatedTextProblem,
emptyVoiceFields,
joinDictatedText,
normalizeForLength,
stillPresentChunks,
} from "./dictated-text";
describe("joinDictatedText", () => {
it("joins the chunks with one space and keeps line feeds and inner spaces", () => {
expect(joinDictatedText([" Königin gesehen", "Futter\nniedrig "])).toBe(
"Königin gesehen Futter\nniedrig",
);
});
it("is empty without chunks", () => {
expect(joinDictatedText([])).toBe("");
});
});
describe("normalizeForLength (mirrors the backend normalizeInputText)", () => {
it("expands NFKC so the check counts what the server counts", () => {
const normalized = normalizeForLength("…".repeat(1400));
expect(normalized).toBe(".".repeat(4200));
expect(dictatedTextProblem(normalized)).toBe("long");
});
it("counts code points, not UTF-16 units", () => {
const emoji = "😀".repeat(DICTATED_TEXT_MIN);
expect(emoji.length).toBe(DICTATED_TEXT_MIN * 2);
expect(dictatedTextProblem(normalizeForLength(emoji))).toBeNull();
expect(dictatedTextProblem(normalizeForLength("😀".repeat(DICTATED_TEXT_MIN - 1)))).toBe("short");
});
it("removes zero-width and control characters before counting", () => {
const padded = "a\u200B".repeat(DICTATED_TEXT_MIN) + "\u0007";
expect(padded.length).toBeGreaterThan(DICTATED_TEXT_MIN);
expect(normalizeForLength(padded)).toBe("a".repeat(DICTATED_TEXT_MIN));
expect(dictatedTextProblem(normalizeForLength("a\u200B".repeat(10)))).toBe("short");
});
it("turns tabs, lone carriage returns and line separators into spaces and keeps line feeds", () => {
expect(normalizeForLength("a\tb\rc\u2028d\u2029e\nf\r\ng")).toBe("a b c d e\nf\r\ng".replace("\r\n", "\n"));
});
it("trims", () => {
expect(normalizeForLength(" \n text \n ")).toBe("text");
});
it("uses the same steps as the backend source", () => {
const path = resolve(process.cwd(), "../ApiCore-Backend/src/voice/core/voice-text.ts");
if (!existsSync(path)) return;
const source = readFileSync(path, "utf8");
expect(source).toContain(".normalize('NFKC')");
expect(source).toContain(String.raw`.replace(/\r(?!\n)|[\t\u2028\u2029]/g, ' ')`);
expect(source).toContain(String.raw`.replace(/[^\P{Cc}\n]/gu, '')`);
expect(source).toContain(String.raw`const FORMAT_CHARACTERS = /\p{Cf}/gu;`);
});
});
describe("dictatedTextProblem", () => {
it("accepts the limits and names the problem outside them", () => {
expect(dictatedTextProblem("a".repeat(DICTATED_TEXT_MIN))).toBeNull();
expect(dictatedTextProblem("a".repeat(DICTATED_TEXT_MAX))).toBeNull();
expect(dictatedTextProblem("a".repeat(DICTATED_TEXT_MIN - 1))).toBe("short");
expect(dictatedTextProblem("a".repeat(DICTATED_TEXT_MAX + 1))).toBe("long");
});
});
describe("stillPresentChunks", () => {
it("drops chunks the beekeeper deleted from every free-text field", () => {
const fields = ["Varroa kaum zu sehen", "", "", ""];
expect(stillPresentChunks(["Varroa kaum", "Futter niedrig"], fields)).toEqual(["Varroa kaum"]);
});
it("matches ignoring surrounding white space", () => {
expect(stillPresentChunks([" Futter niedrig "], ["Notiz: Futter niedrig."])).toEqual([
" Futter niedrig ",
]);
});
});
describe("emptyVoiceFields", () => {
it("lists the suggestible fields that hold no answer", () => {
const empty = emptyVoiceFields({
queenSeen: true,
eggsPresent: null,
gentleness: null,
colonyStrength: "",
broodFrames: "6",
feedStatus: null,
notes: " ",
measures: "Futter gegeben",
} as never);
expect(empty.has("eggsPresent")).toBe(true);
expect(empty.has("gentleness")).toBe(true);
expect(empty.has("colonyStrength")).toBe(true);
expect(empty.has("feedStatus")).toBe(true);
expect(empty.has("notes")).toBe(true);
expect(empty.has("queenSeen")).toBe(false);
expect(empty.has("broodFrames")).toBe(false);
expect(empty.has("measures")).toBe(false);
});
});
@@ -0,0 +1,58 @@
import { VOICE_FIELD_LABELS } from "./suggestions";
import type { InspectionFormValues } from "@/app/(app)/inspections/new/inspection-form-values";
/** The server takes 20 to 4000 characters (code points) after its own normalisation. */
export const DICTATED_TEXT_MIN = 20;
export const DICTATED_TEXT_MAX = 4000;
/**
* Mirrors ApiCore-Backend normalizeInputText (voice-text.ts): NFKC; tabs, lone
* carriage returns and U+2028/U+2029 become spaces; control characters except
* the line feed and format characters (zero-width, bidi marks) are removed;
* trimmed. No white space is collapsed, line feeds stay. What is checked here
* is what is sent, so client and server count the same text.
*/
export function normalizeForLength(text: string): string {
return text
.normalize("NFKC")
.replace(/\r(?!\n)|[\t\u2028\u2029]/g, " ")
.replace(/[^\P{Cc}\n]/gu, "")
.replace(/\p{Cf}/gu, "")
.trim();
}
/** The dictated pieces as one text, normalised like the server does. */
export function joinDictatedText(chunks: readonly string[]): string {
return normalizeForLength(chunks.join(" "));
}
export function dictatedTextProblem(text: string): "short" | "long" | null {
const length = Array.from(text).length;
if (length < DICTATED_TEXT_MIN) return "short";
if (length > DICTATED_TEXT_MAX) return "long";
return null;
}
/**
* Only what is still written in a free-text field leaves the device: a piece
* the beekeeper has deleted since is not sent.
*/
export function stillPresentChunks(
chunks: readonly string[],
fieldValues: readonly string[],
): string[] {
return chunks.filter((chunk) => {
const piece = chunk.trim();
return piece !== "" && fieldValues.some((value) => value.includes(piece));
});
}
function isEmptyAnswer(value: unknown): boolean {
return value === null || value === undefined || (typeof value === "string" && value.trim() === "");
}
/** The fields a suggestion may fill that hold no answer yet. */
export function emptyVoiceFields(values: InspectionFormValues): ReadonlySet<string> {
const record = values as unknown as Record<string, unknown>;
return new Set(Object.keys(VOICE_FIELD_LABELS).filter((field) => isEmptyAnswer(record[field])));
}