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
+944 -13

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,14 +45,22 @@ export function VoiceReviewBox({ review }: { review: VoiceReview }) {
);
}
return (
<VoiceCaptureBox
capture={capture}
openCount={review.openCount}
onTakeOverAll={review.takeOverAll}
onDiscard={review.discard}
discarding={review.discarding}
extractionEnabled={review.extractionEnabled}
/>
<div className="flex flex-col gap-3">
<VoiceCaptureBox
capture={capture}
openCount={review.openCount}
onTakeOverAll={review.takeOverAll}
onDiscard={review.discard}
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>
);
}