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
@@ -7,6 +7,7 @@ import {
|
||||
listQueue,
|
||||
retryBulkWithout,
|
||||
retryInspectionWithoutHiveChange,
|
||||
retryInspectionWithoutVoice,
|
||||
type QueueItem,
|
||||
type StoredPhoto,
|
||||
} from "@/lib/offline/db";
|
||||
@@ -20,6 +21,7 @@ vi.mock("@/lib/offline/db", () => ({
|
||||
discardQueueItem: vi.fn(),
|
||||
retryBulkWithout: vi.fn(),
|
||||
retryInspectionWithoutHiveChange: vi.fn(),
|
||||
retryInspectionWithoutVoice: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/voice/pending-voice", () => ({
|
||||
discardPendingVoice: vi.fn(),
|
||||
@@ -101,6 +103,7 @@ describe("PendingQueueSheet", () => {
|
||||
vi.mocked(discardQueueItem).mockReset().mockResolvedValue(undefined);
|
||||
vi.mocked(retryBulkWithout).mockReset().mockResolvedValue(undefined);
|
||||
vi.mocked(retryInspectionWithoutHiveChange).mockReset().mockResolvedValue(undefined);
|
||||
vi.mocked(retryInspectionWithoutVoice).mockReset().mockResolvedValue(undefined);
|
||||
vi.mocked(listPendingPhotos).mockReset().mockResolvedValue([]);
|
||||
vi.mocked(discardPendingVoice).mockReset().mockResolvedValue(undefined);
|
||||
vi.mocked(discardAllPendingVoice).mockReset().mockResolvedValue(2);
|
||||
@@ -165,6 +168,34 @@ describe("PendingQueueSheet", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("words a voice note of another colony and sends the inspection without it", async () => {
|
||||
vi.mocked(listQueue).mockResolvedValue([
|
||||
{
|
||||
store: "pending-inspections",
|
||||
id: 21,
|
||||
label: "Durchsicht",
|
||||
queuedAt: "2026-10-14T08:00:00.000Z",
|
||||
canResendWithoutVoice: true,
|
||||
lastError: {
|
||||
status: 409,
|
||||
message: "Die Sprachnotiz gehört zu einem anderen Volk.",
|
||||
details: { code: "VOICE_CAPTURE_COLONY_MISMATCH" },
|
||||
at: "2026-10-14T09:00:00.000Z",
|
||||
},
|
||||
},
|
||||
]);
|
||||
renderSheet();
|
||||
const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" });
|
||||
|
||||
expect(await within(sheet).findByText(/Die Sprachnotiz gehört zu einem anderen Volk/)).toBeInTheDocument();
|
||||
await userEvent.click(within(sheet).getByRole("button", { name: "Ohne Sprachnotiz senden" }));
|
||||
|
||||
await waitFor(() => expect(retryInspectionWithoutVoice).toHaveBeenCalledWith(21));
|
||||
await waitFor(() =>
|
||||
expect(syncPendingEntries).toHaveBeenCalledWith("user-1", { afterChange: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it("lists a rejected hive change with the conflict text and only the discard action", async () => {
|
||||
vi.mocked(listQueue).mockResolvedValue([
|
||||
{
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
listQueue,
|
||||
retryBulkWithout,
|
||||
retryInspectionWithoutHiveChange,
|
||||
retryInspectionWithoutVoice,
|
||||
type QueueError,
|
||||
type QueueItem,
|
||||
} from "@/lib/offline/db";
|
||||
@@ -289,6 +290,24 @@ export function PendingQueueSheet({
|
||||
onError: () => toast.error("Eintrag konnte nicht erneut gesendet werden."),
|
||||
});
|
||||
|
||||
// „Ohne Sprachnotiz senden“: only after the server said the note is another colony's.
|
||||
const resendWithoutVoice = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
await retryInspectionWithoutVoice(id);
|
||||
return syncPendingEntries(userId, { afterChange: true });
|
||||
},
|
||||
networkMode: "always",
|
||||
onSuccess: (result) => {
|
||||
if (result.synced > 0) {
|
||||
toast.success(entriesSynced(result.synced));
|
||||
void queryClient.invalidateQueries();
|
||||
return;
|
||||
}
|
||||
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
|
||||
},
|
||||
onError: () => toast.error("Eintrag konnte nicht erneut gesendet werden."),
|
||||
});
|
||||
|
||||
const records = items.filter((item) => item.store !== VOICE_STORE);
|
||||
const voiceNotes = items.filter((item) => item.store === VOICE_STORE);
|
||||
const names = entryNames(records);
|
||||
@@ -354,6 +373,19 @@ export function PendingQueueSheet({
|
||||
Ohne Beute-Änderung senden
|
||||
</Button>
|
||||
) : null}
|
||||
{item.canResendWithoutVoice && typeof item.id === "number" ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="touch"
|
||||
className="self-start"
|
||||
disabled={resendWithoutVoice.isPending}
|
||||
onClick={() => resendWithoutVoice.mutate(item.id as number)}
|
||||
>
|
||||
<RotateCw aria-hidden />
|
||||
Ohne Sprachnotiz senden
|
||||
</Button>
|
||||
) : null}
|
||||
{item.resendWithout && typeof item.id === "number" ? (
|
||||
<ResendWithoutButton
|
||||
resend={item.resendWithout}
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import axe from "axe-core";
|
||||
import type { VoiceCaptureDetail, VoiceSuggestion } from "@/lib/voice/api";
|
||||
import { VoiceCaptureBox } from "./voice-capture-box";
|
||||
|
||||
function suggestion(field: string, value: VoiceSuggestion["value"]): VoiceSuggestion {
|
||||
return { field, value, quote: "gesagt", corrected: false, truncated: false };
|
||||
}
|
||||
|
||||
function capture(extra: Partial<VoiceCaptureDetail> = {}): VoiceCaptureDetail {
|
||||
return {
|
||||
id: "cap-1",
|
||||
colonyId: "colony-1",
|
||||
colonyName: "V-01",
|
||||
status: "READY",
|
||||
source: "AUDIO",
|
||||
createdAt: "2026-10-14T08:00:00.000Z",
|
||||
lastErrorCode: null,
|
||||
contentExpiresAt: "2026-10-21T08:00:00.000Z",
|
||||
inspectionDate: "2026-10-14",
|
||||
transcript: "Königin gesehen, vier Brutwaben.",
|
||||
suggestions: [suggestion("queenSeen", true), suggestion("broodFrames", 4)],
|
||||
unclear: [],
|
||||
dropped: 0,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function renderBox(
|
||||
extra: Partial<VoiceCaptureDetail> = {},
|
||||
handlers: { openCount?: number; onTakeOverAll?: () => void; onDiscard?: () => void } = {},
|
||||
) {
|
||||
const onTakeOverAll = handlers.onTakeOverAll ?? vi.fn();
|
||||
const onDiscard = handlers.onDiscard ?? vi.fn();
|
||||
const view = render(
|
||||
<VoiceCaptureBox
|
||||
capture={capture(extra)}
|
||||
openCount={handlers.openCount ?? 2}
|
||||
onTakeOverAll={onTakeOverAll}
|
||||
onDiscard={onDiscard}
|
||||
/>,
|
||||
);
|
||||
return { ...view, onTakeOverAll, onDiscard };
|
||||
}
|
||||
|
||||
describe("VoiceCaptureBox", () => {
|
||||
it("names the day and the number of suggestions", () => {
|
||||
renderBox();
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Sprachnotiz vom 14.10., 2 Vorschläge" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says Vorschlag for one", () => {
|
||||
renderBox({ suggestions: [suggestion("queenSeen", true)] }, { openCount: 1 });
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Sprachnotiz vom 14.10., 1 Vorschlag" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Alle übernehmen asks first and names the count", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onTakeOverAll } = renderBox({}, { openCount: 12 });
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Alle übernehmen" }));
|
||||
|
||||
expect(onTakeOverAll).not.toHaveBeenCalled();
|
||||
const dialog = screen.getByRole("alertdialog");
|
||||
expect(
|
||||
within(dialog).getByText("12 Felder aus der Sprachnotiz übernehmen?"),
|
||||
).toBeInTheDocument();
|
||||
await user.click(within(dialog).getByRole("button", { name: "Übernehmen" }));
|
||||
expect(onTakeOverAll).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("Alle übernehmen can be cancelled and is gone when nothing is left to take", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onTakeOverAll, rerender } = renderBox();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Alle übernehmen" }));
|
||||
await user.click(
|
||||
within(screen.getByRole("alertdialog")).getByRole("button", { name: "Abbrechen" }),
|
||||
);
|
||||
expect(onTakeOverAll).not.toHaveBeenCalled();
|
||||
|
||||
rerender(
|
||||
<VoiceCaptureBox
|
||||
capture={capture()}
|
||||
openCount={0}
|
||||
onTakeOverAll={onTakeOverAll}
|
||||
onDiscard={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: "Alle übernehmen" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Sprachnotiz verwerfen asks first", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onDiscard } = renderBox();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Sprachnotiz verwerfen" }));
|
||||
expect(onDiscard).not.toHaveBeenCalled();
|
||||
await user.click(
|
||||
within(screen.getByRole("alertdialog")).getByRole("button", { name: "Verwerfen" }),
|
||||
);
|
||||
expect(onDiscard).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("offers what was said, as text", () => {
|
||||
renderBox({ transcript: "<b>Königin</b> gesehen" });
|
||||
|
||||
const details = screen.getByText("Gesagt wurde:").closest("details");
|
||||
expect(details).not.toBeNull();
|
||||
expect(details).not.toHaveAttribute("open");
|
||||
expect(within(details as HTMLElement).getByText("<b>Königin</b> gesehen")).toBeInTheDocument();
|
||||
expect(details?.querySelector("b")).toBeNull();
|
||||
});
|
||||
|
||||
it("lists what could not be assigned without a button", () => {
|
||||
renderBox({ unclear: ["Etwas zur Futtermenge, Menge nicht genannt"] });
|
||||
|
||||
const list = screen.getByRole("list", { name: "Das konnte ich nicht zuordnen" });
|
||||
expect(
|
||||
within(list).getByText("Etwas zur Futtermenge, Menge nicht genannt"),
|
||||
).toBeInTheDocument();
|
||||
expect(within(list).queryByRole("button")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says so when nothing clear was recognised and points to the text", () => {
|
||||
renderBox({ suggestions: [], dropped: 3 }, { openCount: 0 });
|
||||
|
||||
expect(
|
||||
screen.getByText("Nichts Eindeutiges erkannt. Der Text steht darunter."),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Gesagt wurde:").closest("details")).toHaveAttribute("open");
|
||||
expect(screen.queryByRole("button", { name: "Alle übernehmen" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["QUEUED", "Wird noch abgeschrieben"],
|
||||
["TRANSCRIBING", "Wird noch abgeschrieben"],
|
||||
["TRANSCRIBED", "Wird noch ausgewertet"],
|
||||
["EXTRACTING", "Wird noch ausgewertet"],
|
||||
] as const)("a %s note says it is still being worked on", (status, text) => {
|
||||
renderBox({ status, suggestions: null, transcript: null });
|
||||
|
||||
expect(screen.getByText(text)).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Alle übernehmen" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("a transcribed note whose evaluation is off shows the text open for typing it in", () => {
|
||||
renderBox(
|
||||
{ status: "TRANSCRIBED", lastErrorCode: "EXTRACT_DISABLED", suggestions: null },
|
||||
{ openCount: 0 },
|
||||
);
|
||||
|
||||
expect(screen.getByText("Auswertung gerade nicht möglich")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Die Auswertung ist ausgeschaltet/)).toBeInTheDocument();
|
||||
expect(screen.getByText("Gesagt wurde:").closest("details")).toHaveAttribute("open");
|
||||
expect(screen.getByText("Königin gesehen, vier Brutwaben.")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Sprachnotiz verwerfen" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("a transcribed note shows the text-only state when the evaluation is switched off", () => {
|
||||
render(
|
||||
<VoiceCaptureBox
|
||||
capture={capture({ status: "TRANSCRIBED", suggestions: null })}
|
||||
openCount={0}
|
||||
extractionEnabled={false}
|
||||
onTakeOverAll={vi.fn()}
|
||||
onDiscard={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Auswertung gerade nicht möglich")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Wird noch ausgewertet")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Gesagt wurde:").closest("details")).toHaveAttribute("open");
|
||||
});
|
||||
|
||||
it("focuses the box after Alle übernehmen, whose button is gone then", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderBox({}, { openCount: 2 });
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Alle übernehmen" }));
|
||||
await user.click(
|
||||
within(screen.getByRole("alertdialog")).getByRole("button", { name: "Übernehmen" }),
|
||||
);
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(screen.getByRole("region", { name: /^Sprachnotiz vom/ })).toHaveFocus(),
|
||||
);
|
||||
});
|
||||
|
||||
it("a failed note with text shows the text; one without shows only the reason", () => {
|
||||
const { unmount } = renderBox(
|
||||
{ status: "FAILED", lastErrorCode: "EXTRACT_FAILED", suggestions: null },
|
||||
{ openCount: 0 },
|
||||
);
|
||||
expect(screen.getByText("Auswertung gerade nicht möglich")).toBeInTheDocument();
|
||||
expect(screen.getByText("Königin gesehen, vier Brutwaben.")).toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
renderBox(
|
||||
{ status: "FAILED", lastErrorCode: "NO_SPEECH", suggestions: null, transcript: null },
|
||||
{ openCount: 0 },
|
||||
);
|
||||
expect(screen.getByText(/Es war nichts zu verstehen/)).toBeInTheDocument();
|
||||
expect(screen.queryByText("Gesagt wurde:")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each(["CONFIRMED", "DISCARDED", "EXPIRED"] as const)(
|
||||
"a %s note says it is gone and can still be left out",
|
||||
(status) => {
|
||||
renderBox({ status, suggestions: null, transcript: null }, { openCount: 0 });
|
||||
|
||||
expect(screen.getByText(/Die Sprachnotiz gibt es nicht mehr/)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Sprachnotiz verwerfen" }),
|
||||
).not.toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it("axe finds nothing", async () => {
|
||||
const { container } = renderBox({ unclear: ["Etwas unklar"] });
|
||||
|
||||
const result = await axe.run(container, { rules: { "color-contrast": { enabled: false } } });
|
||||
expect(result.violations).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { VoiceCaptureDetail } from "@/lib/voice/api";
|
||||
import { voiceErrorText } from "@/lib/voice/status-texts";
|
||||
import { VoiceConfirmDialog } from "./voice-confirm-dialog";
|
||||
|
||||
export const VOICE_CAPTURE_BOX_ID = "voice-capture-box";
|
||||
|
||||
interface VoiceCaptureBoxProps {
|
||||
capture: VoiceCaptureDetail;
|
||||
/** Suggestions still on offer (not taken, not ignored): what „Alle übernehmen“ would take. */
|
||||
openCount: number;
|
||||
onTakeOverAll: () => void;
|
||||
onDiscard: () => void;
|
||||
discarding?: boolean;
|
||||
/** false: the operator switched the evaluation off; a transcribed note is then text only. */
|
||||
extractionEnabled?: boolean;
|
||||
}
|
||||
|
||||
type Dialog = "take-all" | "discard" | null;
|
||||
|
||||
const GONE_STATUSES: readonly string[] = ["CONFIRMED", "DISCARDED", "EXPIRED"];
|
||||
const TRANSCRIBING_STATUSES: readonly string[] = ["QUEUED", "TRANSCRIBING"];
|
||||
const EVALUATING_STATUSES: readonly string[] = ["TRANSCRIBED", "EXTRACTING"];
|
||||
/** Codes after which the text exists, but no suggestions will come. */
|
||||
const TEXT_ONLY_CODES: readonly string[] = ["EXTRACT_DISABLED", "DAILY_LIMIT"];
|
||||
|
||||
/** "2026-10-14" -> "14.10." */
|
||||
function dayText(isoDay: string): string {
|
||||
const [, month = "", day = ""] = isoDay.slice(0, 10).split("-");
|
||||
return `${day}.${month}.`;
|
||||
}
|
||||
|
||||
function suggestionCount(count: number): string {
|
||||
return count === 1 ? "1 Vorschlag" : `${count} Vorschläge`;
|
||||
}
|
||||
|
||||
/** The one line that says where this note stands, or null when its suggestions speak. */
|
||||
function stateLine(
|
||||
capture: VoiceCaptureDetail,
|
||||
extractionEnabled: boolean,
|
||||
): { text: string; detail?: string } | null {
|
||||
const { status, lastErrorCode } = capture;
|
||||
if (status === "TRANSCRIBED" && !extractionEnabled && !lastErrorCode) {
|
||||
return { text: "Auswertung gerade nicht möglich", detail: voiceErrorText("EXTRACT_DISABLED") };
|
||||
}
|
||||
if (GONE_STATUSES.includes(status)) {
|
||||
return { text: "Die Sprachnotiz gibt es nicht mehr. Du kannst die Durchsicht trotzdem speichern." };
|
||||
}
|
||||
if (status === "FAILED" && capture.transcript === null) {
|
||||
return { text: voiceErrorText(lastErrorCode) };
|
||||
}
|
||||
if (status === "FAILED" || (status === "TRANSCRIBED" && lastErrorCode && TEXT_ONLY_CODES.includes(lastErrorCode))) {
|
||||
return { text: "Auswertung gerade nicht möglich", detail: voiceErrorText(lastErrorCode) };
|
||||
}
|
||||
if (TRANSCRIBING_STATUSES.includes(status)) {
|
||||
return { text: "Wird noch abgeschrieben" };
|
||||
}
|
||||
if (EVALUATING_STATUSES.includes(status)) {
|
||||
return { text: "Wird noch ausgewertet" };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The voice note above the inspection form: how many suggestions it holds,
|
||||
* what was said, what could not be assigned, and the two box-wide choices -
|
||||
* all at once (asked first) or discard (asked first). It places nothing in the
|
||||
* form itself. For a note without suggestions it is the way to read the text.
|
||||
*/
|
||||
export function VoiceCaptureBox({
|
||||
capture,
|
||||
openCount,
|
||||
onTakeOverAll,
|
||||
onDiscard,
|
||||
discarding = false,
|
||||
extractionEnabled = true,
|
||||
}: VoiceCaptureBoxProps) {
|
||||
const [dialog, setDialog] = useState<Dialog>(null);
|
||||
const [copyNote, setCopyNote] = useState("");
|
||||
const line = stateLine(capture, extractionEnabled);
|
||||
const isGone = GONE_STATUSES.includes(capture.status);
|
||||
const isReady = capture.status === "READY";
|
||||
const suggestions = capture.suggestions ?? [];
|
||||
const nothingFound = isReady && suggestions.length === 0;
|
||||
const textOnly =
|
||||
nothingFound ||
|
||||
(line !== null && !isGone && capture.transcript !== null && line.detail !== undefined);
|
||||
const heading = isReady
|
||||
? `Sprachnotiz vom ${dayText(capture.inspectionDate)}, ${suggestionCount(suggestions.length)}`
|
||||
: `Sprachnotiz vom ${dayText(capture.inspectionDate)}`;
|
||||
|
||||
async function copyText() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(capture.transcript ?? "");
|
||||
setCopyNote("Text kopiert");
|
||||
} catch {
|
||||
setCopyNote("Kopieren nicht möglich – markiere den Text und kopiere ihn.");
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
setDialog(null);
|
||||
}
|
||||
|
||||
/** The button that opened the dialog may be gone by now: the box takes the focus. */
|
||||
function focusBox() {
|
||||
window.setTimeout(() => document.getElementById(VOICE_CAPTURE_BOX_ID)?.focus(), 0);
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
id={VOICE_CAPTURE_BOX_ID}
|
||||
tabIndex={-1}
|
||||
aria-labelledby={`${VOICE_CAPTURE_BOX_ID}-title`}
|
||||
className="flex scroll-mt-4 flex-col gap-3 rounded-xl border border-border bg-muted/40 p-3 outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
>
|
||||
<h2 id={`${VOICE_CAPTURE_BOX_ID}-title`} className="font-heading text-base font-semibold">
|
||||
{heading}
|
||||
</h2>
|
||||
|
||||
{line ? (
|
||||
<div className="flex flex-col gap-1 text-sm" role="status">
|
||||
<p className="font-medium">{line.text}</p>
|
||||
{line.detail ? <p>{line.detail}</p> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{isReady && suggestions.length === 0 ? (
|
||||
<p className="text-sm font-medium">Nichts Eindeutiges erkannt. Der Text steht darunter.</p>
|
||||
) : null}
|
||||
|
||||
{capture.transcript ? (
|
||||
<details open={textOnly} className="rounded-lg border border-border bg-card p-3">
|
||||
<summary className="flex min-h-11 cursor-pointer items-center text-sm font-medium">
|
||||
Gesagt wurde:
|
||||
</summary>
|
||||
<p className="mt-2 whitespace-pre-wrap break-words text-sm select-text">{capture.transcript}</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-3">
|
||||
<Button type="button" variant="outline" size="touch" className="min-h-14" onClick={copyText}>
|
||||
Text kopieren
|
||||
</Button>
|
||||
<span role="status" aria-live="polite" className="text-sm">
|
||||
{copyNote}
|
||||
</span>
|
||||
</div>
|
||||
</details>
|
||||
) : null}
|
||||
|
||||
{isReady && capture.unclear.length > 0 ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<p id={`${VOICE_CAPTURE_BOX_ID}-unclear`} className="text-sm font-medium">
|
||||
Das konnte ich nicht zuordnen
|
||||
</p>
|
||||
<ul
|
||||
aria-labelledby={`${VOICE_CAPTURE_BOX_ID}-unclear`}
|
||||
className="list-disc pl-5 text-sm text-muted-foreground"
|
||||
>
|
||||
{capture.unclear.map((sentence) => (
|
||||
<li key={sentence}>{sentence}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isGone ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{isReady && openCount > 0 ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="touch"
|
||||
className="min-h-14"
|
||||
onClick={() => setDialog("take-all")}
|
||||
>
|
||||
Alle übernehmen
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="touch"
|
||||
className="min-h-14"
|
||||
disabled={discarding}
|
||||
onClick={() => setDialog("discard")}
|
||||
>
|
||||
Sprachnotiz verwerfen
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<VoiceConfirmDialog
|
||||
open={dialog === "take-all"}
|
||||
title={`${openCount === 1 ? "1 Feld" : `${openCount} Felder`} aus der Sprachnotiz übernehmen?`}
|
||||
description="Schon ausgefüllte Felder werden ersetzt, Freitext wird angehängt. Danach kannst du jeden Wert noch ändern."
|
||||
cancelLabel="Abbrechen"
|
||||
confirmLabel="Übernehmen"
|
||||
confirmVariant="default"
|
||||
onCancel={close}
|
||||
onConfirm={() => {
|
||||
close();
|
||||
onTakeOverAll();
|
||||
focusBox();
|
||||
}}
|
||||
/>
|
||||
<VoiceConfirmDialog
|
||||
open={dialog === "discard"}
|
||||
title="Sprachnotiz verwerfen?"
|
||||
description="Text und Vorschläge werden gelöscht. Die Durchsicht bleibt, wie sie ist."
|
||||
cancelLabel="Behalten"
|
||||
confirmLabel="Verwerfen"
|
||||
onCancel={close}
|
||||
onConfirm={() => {
|
||||
close();
|
||||
onDiscard();
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,8 @@ interface VoiceConfirmDialogProps {
|
||||
/** Leaves the note as it is. */
|
||||
cancelLabel: string;
|
||||
confirmLabel: string;
|
||||
/** destructive (red) when something is thrown away; default when a choice is only confirmed. */
|
||||
confirmVariant?: "destructive" | "default";
|
||||
onCancel(): void;
|
||||
onConfirm(): void;
|
||||
}
|
||||
@@ -31,6 +33,7 @@ export function VoiceConfirmDialog({
|
||||
description,
|
||||
cancelLabel,
|
||||
confirmLabel,
|
||||
confirmVariant = "destructive",
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: VoiceConfirmDialogProps) {
|
||||
@@ -47,7 +50,7 @@ export function VoiceConfirmDialog({
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
variant={confirmVariant}
|
||||
size="touch"
|
||||
className="min-h-14"
|
||||
onClick={onConfirm}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useState } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import axe from "axe-core";
|
||||
import { VoiceSuggestion, type VoiceSuggestionProps } from "./voice-suggestion";
|
||||
|
||||
function props(extra: Partial<VoiceSuggestionProps> = {}): VoiceSuggestionProps {
|
||||
return {
|
||||
fieldLabel: "Königin gesehen",
|
||||
valueText: "Königin gesehen: Ja",
|
||||
quote: "Königin hab ich gesehen",
|
||||
onTakeOver: vi.fn(),
|
||||
onEdit: vi.fn(),
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
/** A parent that behaves like the form: taking over flips the row to „taken“. */
|
||||
function Harness({ onEdit = vi.fn() }: { onEdit?: () => void }) {
|
||||
const [status, setStatus] = useState<"open" | "taken" | "ignored">("open");
|
||||
return (
|
||||
<>
|
||||
<input aria-label="Feld" />
|
||||
<VoiceSuggestion
|
||||
{...props({ onEdit })}
|
||||
status={status}
|
||||
onTakeOver={() => setStatus("taken")}
|
||||
onUndo={() => setStatus("open")}
|
||||
onIgnore={() => setStatus("ignored")}
|
||||
onRestore={() => setStatus("open")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe("VoiceSuggestion", () => {
|
||||
it("shows the value in words and the quote visibly marked as spoken", () => {
|
||||
render(<VoiceSuggestion {...props()} />);
|
||||
|
||||
expect(screen.getByText("Königin gesehen: Ja")).toBeInTheDocument();
|
||||
const quote = screen.getByText(/Königin hab ich gesehen/);
|
||||
expect(quote.closest("blockquote")).not.toBeNull();
|
||||
expect(quote).toHaveTextContent("„Königin hab ich gesehen“");
|
||||
expect(screen.getByText("Aus Sprache")).toBeInTheDocument();
|
||||
expect(screen.getByText("Gesagt:")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows chips as words", () => {
|
||||
render(<VoiceSuggestion {...props({ chips: ["Korrigiert", "Zuvor genannt: Nein"] })} />);
|
||||
|
||||
expect(screen.getByText("Korrigiert")).toBeInTheDocument();
|
||||
expect(screen.getByText("Zuvor genannt: Nein")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("names the field on each button so a screen reader hears what it acts on", () => {
|
||||
render(<VoiceSuggestion {...props()} />);
|
||||
|
||||
expect(screen.getByRole("button", { name: /^Übernehmen.*Königin gesehen/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /^Ändern.*Königin gesehen/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Übernehmen sets the value, says so in a status region and keeps the focus where it was", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness />);
|
||||
const take = screen.getByRole("button", { name: /^Übernehmen/ });
|
||||
|
||||
await user.tab(); // the field
|
||||
await user.tab(); // Übernehmen
|
||||
expect(take).toHaveFocus();
|
||||
await user.keyboard("{Enter}");
|
||||
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Übernommen");
|
||||
// The same element carries on as „Rückgängig“: the focus never falls to the page.
|
||||
expect(screen.getByRole("button", { name: /^Rückgängig/ })).toHaveFocus();
|
||||
});
|
||||
|
||||
it("Rückgängig takes the value back and offers it again", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /^Übernehmen/ }));
|
||||
await user.click(screen.getByRole("button", { name: /^Rückgängig/ }));
|
||||
|
||||
expect(screen.getByRole("button", { name: /^Übernehmen/ })).toHaveFocus();
|
||||
expect(screen.getByRole("status")).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("Ändern calls onEdit", async () => {
|
||||
const onEdit = vi.fn();
|
||||
render(<Harness onEdit={onEdit} />);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /^Ändern/ }));
|
||||
|
||||
expect(onEdit).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("Ignorieren hides the choices behind one button and keeps the focus on it", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /^Ignorieren/ }));
|
||||
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Ignoriert");
|
||||
expect(screen.queryByRole("button", { name: /^Ändern/ })).not.toBeInTheDocument();
|
||||
const restore = screen.getByRole("button", { name: /^Wieder anzeigen/ });
|
||||
expect(restore).toHaveFocus();
|
||||
await user.click(restore);
|
||||
expect(screen.getByRole("button", { name: /^Übernehmen/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("works with the keyboard only", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onEdit = vi.fn();
|
||||
render(<Harness onEdit={onEdit} />);
|
||||
|
||||
await user.tab();
|
||||
await user.tab();
|
||||
await user.tab();
|
||||
expect(screen.getByRole("button", { name: /^Ändern/ })).toHaveFocus();
|
||||
await user.keyboard(" ");
|
||||
expect(onEdit).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("suggestion text is rendered as text, never as HTML", () => {
|
||||
const { container } = render(
|
||||
<VoiceSuggestion
|
||||
{...props({
|
||||
valueText: "Notizen: <img src=x onerror=alert(1)>",
|
||||
quote: "<script>alert(1)</script>",
|
||||
chips: ["<b>fett</b>"],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelector("img, script, b")).toBeNull();
|
||||
expect(screen.getByText(/<script>alert\(1\)<\/script>/)).toBeInTheDocument();
|
||||
expect(screen.getByText("<b>fett</b>")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("gives every button at least 56 px of height", () => {
|
||||
render(<Harness />);
|
||||
|
||||
for (const button of screen.getAllByRole("button")) {
|
||||
expect(button.className).toMatch(/min-h-14/);
|
||||
}
|
||||
});
|
||||
|
||||
it("axe finds nothing", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(<Harness />);
|
||||
const run = () => axe.run(container, { rules: { "color-contrast": { enabled: false } } });
|
||||
|
||||
expect((await run()).violations).toEqual([]);
|
||||
await user.click(screen.getByRole("button", { name: /^Übernehmen/ }));
|
||||
expect((await run()).violations).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { Mic } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export type VoiceSuggestionStatus = "open" | "taken" | "ignored";
|
||||
|
||||
export interface VoiceSuggestionProps {
|
||||
/** Name of the field, spoken after each button so it is unambiguous. */
|
||||
fieldLabel: string;
|
||||
/** The value in words, e.g. "Königin gesehen: Ja". */
|
||||
valueText: string;
|
||||
/** What was said, word for word. */
|
||||
quote: string;
|
||||
/** Short words next to the value, e.g. "Korrigiert", "Zuvor genannt: Nein". */
|
||||
chips?: readonly string[];
|
||||
/** open: choose; taken: value is in the field; ignored: put aside. */
|
||||
status?: VoiceSuggestionStatus;
|
||||
onTakeOver: () => void;
|
||||
/** Puts the value in and moves the focus to the field. */
|
||||
onEdit: () => void;
|
||||
onUndo?: () => void;
|
||||
onIgnore?: () => void;
|
||||
onRestore?: () => void;
|
||||
}
|
||||
|
||||
const STATUS_WORDS: Record<VoiceSuggestionStatus, string> = {
|
||||
open: "",
|
||||
taken: "Übernommen",
|
||||
ignored: "Ignoriert",
|
||||
};
|
||||
|
||||
const BUTTON_CLASS = "min-h-14";
|
||||
|
||||
/**
|
||||
* One suggestion from a voice note, under its field: the value in words, what
|
||||
* was said in quotation marks, and the choices. Nothing is filled in until
|
||||
* „Übernehmen“. The first button stays the same element through every state
|
||||
* (Übernehmen, Rückgängig, Wieder anzeigen), so the keyboard focus never falls
|
||||
* back to the page when a choice is made. The status line is a live region
|
||||
* that is always there, or its first message would not be read out.
|
||||
*/
|
||||
export function VoiceSuggestion({
|
||||
fieldLabel,
|
||||
valueText,
|
||||
quote,
|
||||
chips = [],
|
||||
status = "open",
|
||||
onTakeOver,
|
||||
onEdit,
|
||||
onUndo,
|
||||
onIgnore,
|
||||
onRestore,
|
||||
}: VoiceSuggestionProps) {
|
||||
const mainRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const main = {
|
||||
open: { label: "Übernehmen", onClick: onTakeOver },
|
||||
taken: { label: "Rückgängig", onClick: onUndo ?? onTakeOver },
|
||||
ignored: { label: "Wieder anzeigen", onClick: onRestore ?? onTakeOver },
|
||||
}[status];
|
||||
|
||||
function ignore() {
|
||||
onIgnore?.();
|
||||
// The „Ignorieren“ button leaves with this click: its place is the first button.
|
||||
mainRef.current?.focus();
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={`Vorschlag aus Sprache: ${fieldLabel}`}
|
||||
className="flex flex-col gap-2 rounded-lg border border-l-4 border-border border-l-primary bg-muted/40 p-3"
|
||||
>
|
||||
<p className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
<Mic className="size-3.5 shrink-0" aria-hidden />
|
||||
<span>Aus Sprache</span>
|
||||
</p>
|
||||
<p className="font-medium">{valueText}</p>
|
||||
{status !== "ignored" ? (
|
||||
<blockquote className="border-l-2 border-border pl-3 text-sm italic text-muted-foreground">
|
||||
<span className="not-italic font-medium">Gesagt:</span> „{quote}“
|
||||
</blockquote>
|
||||
) : null}
|
||||
{chips.length > 0 && status !== "ignored" ? (
|
||||
<ul className="flex flex-wrap gap-1.5">
|
||||
{chips.map((chip) => (
|
||||
<li key={chip} className="rounded-full border border-border px-2 py-0.5 text-xs">
|
||||
{chip}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
ref={mainRef}
|
||||
type="button"
|
||||
variant={status === "open" ? "default" : "outline"}
|
||||
size="touch"
|
||||
className={BUTTON_CLASS}
|
||||
onClick={main.onClick}
|
||||
>
|
||||
{main.label}
|
||||
<span className="sr-only"> – {fieldLabel}</span>
|
||||
</Button>
|
||||
{status !== "ignored" ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="touch"
|
||||
className={BUTTON_CLASS}
|
||||
onClick={onEdit}
|
||||
>
|
||||
Ändern
|
||||
<span className="sr-only"> – {fieldLabel}</span>
|
||||
</Button>
|
||||
) : null}
|
||||
{status === "open" && onIgnore ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="touch"
|
||||
className={BUTTON_CLASS}
|
||||
onClick={ignore}
|
||||
>
|
||||
Ignorieren
|
||||
<span className="sr-only"> – {fieldLabel}</span>
|
||||
</Button>
|
||||
) : null}
|
||||
<span role="status" aria-live="polite" className="text-sm font-medium">
|
||||
{STATUS_WORDS[status]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user