feat: Sprachnotiz in der Durchsicht, Info-Seite und Zähler (T7 L5 Tasks 20, 22)
Backend Image / build-and-push (push) Successful in 5m31s
Frontend Image / build-and-push (push) Failing after 18m45s

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:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-16 13:03:09 +02:00
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>
);
}