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:
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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user