feat: Aufnahmeseite und Liste der Sprachnotizen (T7 L5 Task 19)

/voice/new: großer Aufnahmeknopf mit Zeit, Hinweis bei 150 s, Stopp bei
180 s; eine automatisch beendete Aufnahme liegt sofort in der
Warteschlange des Geräts (Ruling 13); Abbrechen und Neu aufnehmen
fragen nach; Hilfe bei gesperrtem Mikrofon; Fokus bleibt bei jedem
Zustandswechsel an einem Bedienelement; Volk und Datum während der
Aufnahme gesperrt, ?date= auf die letzten 7 Tage begrenzt.
/voice: wartende Notizen des Geräts zuerst, dann die des Servers mit
Status, „Prüfen“ nur bei fertigen, Verwerfen mit Rückfrage.
Einstiege über Schnellanlage, Menü (nur mit voice-captures:use und
inspections:create), Volksseite und Durchsicht. Bei ausgeschalteter
Aufnahme verschwinden die Einstiege; jeder HTTP-Fehler der
Konfiguration zählt als aus, nur fehlendes Netz als unbekannt.
Fehlertexte gleich mit dem Backend (Test).

Gates: Frontend lint, typecheck, 2979 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 11:49:56 +02:00
1 parent 462ac46d1e
commit e0be687b7c
33 files changed
+3460 -19

No files matched your search

@@ -0,0 +1,73 @@
import Link from "next/link";
import { MicOff } from "lucide-react";
import { Button } from "@/components/ui/button";
const STEPS: readonly { browser: string; text: string }[] = [
{
browser: "Chrome und Edge",
text: "Tippe in der Adresszeile auf das Symbol links neben der Adresse, dann auf „Berechtigungen“ und erlaube das Mikrofon.",
},
{
browser: "Safari am iPhone",
text: "Öffne Einstellungen, dann Safari, dann Mikrofon, und wähle „Fragen“ oder „Erlauben“.",
},
{
browser: "Firefox",
text: "Tippe in der Adresszeile auf das Symbol links neben der Adresse und hebe die Sperre für das Mikrofon auf.",
},
];
interface MicrophoneHelpProps {
reason: "denied" | "no-microphone" | "unsupported";
typeInsteadHref: string;
onRetry?(): void;
}
const TITLES: Record<MicrophoneHelpProps["reason"], string> = {
denied: "Das Mikrofon ist nicht erlaubt.",
"no-microphone": "Es wurde kein Mikrofon gefunden.",
unsupported: "Dieses Gerät kann hier keine Sprachnotiz aufnehmen.",
};
/** Mikrofon verweigert: what to do per browser, and a way to type instead. */
export function MicrophoneHelp({ reason, typeInsteadHref, onRetry }: MicrophoneHelpProps) {
return (
<section
aria-labelledby="voice-mic-help"
className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4"
>
<h2 id="voice-mic-help" className="flex items-center gap-2 font-heading text-lg font-semibold">
<MicOff className="size-5" aria-hidden />
{TITLES[reason]}
</h2>
{reason === "denied" ? (
<>
<p className="text-sm">So erlaubst du es wieder:</p>
<dl className="flex flex-col gap-2 text-sm">
{STEPS.map((step) => (
<div key={step.browser}>
<dt className="font-medium">{step.browser}</dt>
<dd className="text-muted-foreground">{step.text}</dd>
</div>
))}
</dl>
</>
) : null}
<div className="flex flex-col gap-2 sm:flex-row">
<Button
size="touch"
className="min-h-14"
nativeButton={false}
render={<Link href={typeInsteadHref} />}
>
Stattdessen tippen
</Button>
{onRetry && reason !== "unsupported" ? (
<Button type="button" size="touch" variant="outline" className="min-h-14" onClick={onRetry}>
Erneut versuchen
</Button>
) : null}
</div>
</section>
);
}
@@ -0,0 +1,40 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { RECORDING_NOTICE } from "@/app/(app)/settings/voice/voice-privacy-text";
interface RecordingNoticeDialogProps {
open: boolean;
onConfirm(): void;
onCancel(): void;
}
/** Shown once before the first recording (spec 9.5); it can be read again in the settings. */
export function RecordingNoticeDialog({ open, onConfirm, onCancel }: RecordingNoticeDialogProps) {
return (
<Dialog open={open} onOpenChange={(next) => !next && onCancel()}>
<DialogContent role="alertdialog" showCloseButton={false}>
<DialogHeader>
<DialogTitle>Bevor du aufnimmst</DialogTitle>
<DialogDescription>{RECORDING_NOTICE}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" variant="outline" size="touch" className="min-h-14" onClick={onCancel}>
Abbrechen
</Button>
<Button type="button" size="touch" className="min-h-14" onClick={onConfirm}>
Verstanden, Aufnahme starten
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,238 @@
"use client";
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import {
pickMimeType,
startRecording,
type Recording,
type RecordingHandle,
} from "@/lib/voice/recorder";
export const MAX_RECORDING_SEC = 180;
export const WARN_AT_SEC = 150;
/** Announce the running time to screen readers this often. */
export const ANNOUNCE_EVERY_SEC = 30;
/** Cancelling a recording longer than this asks first. */
export const CONFIRM_CANCEL_AFTER_SEC = 5;
export type RecorderPhase =
| "idle"
| "starting"
| "recording"
| "recorded"
| "denied"
| "no-microphone"
| "unsupported"
| "failed";
export interface FinishedRecording extends Recording {
/** Object URL for the native player; revoked on reset and unmount. */
url: string | null;
}
export interface UseVoiceRecorder {
phase: RecorderPhase;
sec: number;
/** Text for the polite live region: changes every 30 s and at start, warning and stop. */
announcement: string;
warning: boolean;
recording: FinishedRecording | null;
start(): void;
stop(): void;
cancel(): void;
reset(): void;
}
export function formatClock(totalSec: number): string {
const minutes = Math.floor(totalSec / 60);
const seconds = String(totalSec % 60).padStart(2, "0");
return `${minutes}:${seconds}`;
}
/** Spoken form of the time, for the live region. */
export function spokenDuration(totalSec: number): string {
const minutes = Math.floor(totalSec / 60);
const seconds = totalSec % 60;
const parts: string[] = [];
if (minutes > 0) {
parts.push(minutes === 1 ? "1 Minute" : `${minutes} Minuten`);
}
if (seconds > 0 || minutes === 0) {
parts.push(seconds === 1 ? "1 Sekunde" : `${seconds} Sekunden`);
}
return parts.join(" ");
}
const DENIED_ERRORS = new Set(["NotAllowedError", "SecurityError", "PermissionDeniedError"]);
const MISSING_ERRORS = new Set(["NotFoundError", "OverconstrainedError", "NotReadableError"]);
function phaseForStartError(error: unknown): RecorderPhase {
// Duck-typed: a DOMException is not an Error in every realm.
const { name = "", message = "" } = (error ?? {}) as { name?: string; message?: string };
if (DENIED_ERRORS.has(name)) {
return "denied";
}
if (MISSING_ERRORS.has(name)) {
return "no-microphone";
}
if (message === "Recording is not supported on this device") {
return "unsupported";
}
return "failed";
}
function makeUrl(blob: Blob): string | null {
return typeof URL !== "undefined" && typeof URL.createObjectURL === "function"
? URL.createObjectURL(blob)
: null;
}
function revokeUrl(url: string | null): void {
if (url && typeof URL.revokeObjectURL === "function") {
URL.revokeObjectURL(url);
}
}
const NEVER_CHANGES = () => () => undefined;
/** True when this browser cannot record voice at all (checked on the client only). */
export function useRecordingUnsupported(): boolean {
return useSyncExternalStore(
NEVER_CHANGES,
() => pickMimeType() === null,
() => false,
);
}
/**
* The recording flow as state. The microphone is asked for only in start();
* the recorder module releases it on stop, cancel, auto-stop and error.
*/
export interface UseVoiceRecorderOptions {
/** The limit (or a hidden page) ended the recording; it is finished and playable. */
onAutoStop?(recording: Recording): void;
}
export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}): UseVoiceRecorder {
const autoStopHandler = useRef(options.onAutoStop);
useEffect(() => {
autoStopHandler.current = options.onAutoStop;
});
const unsupported = useRecordingUnsupported();
const [phase, setPhase] = useState<RecorderPhase>("idle");
const [sec, setSec] = useState(0);
const [announcement, setAnnouncement] = useState("");
const [warning, setWarning] = useState(false);
const [recording, setRecording] = useState<FinishedRecording | null>(null);
const handle = useRef<RecordingHandle | null>(null);
const urlRef = useRef<string | null>(null);
// Bumped by cancel/reset/unmount so a late start or stop answer is ignored.
const generation = useRef(0);
const finish = useCallback((result: Recording, note: string) => {
revokeUrl(urlRef.current);
urlRef.current = makeUrl(result.blob);
handle.current = null;
setRecording({ ...result, url: urlRef.current });
setPhase("recorded");
setAnnouncement(`${note} Dauer ${spokenDuration(result.durationSec)}.`);
}, []);
const start = useCallback(() => {
if (handle.current) {
return;
}
const mine = ++generation.current;
setPhase("starting");
setSec(0);
setWarning(false);
setAnnouncement("");
startRecording({
maxSec: MAX_RECORDING_SEC,
warnAtSec: WARN_AT_SEC,
onTick(tick) {
if (generation.current !== mine) return;
setSec(tick);
if (tick % ANNOUNCE_EVERY_SEC === 0) {
setAnnouncement(`Aufnahme läuft, ${spokenDuration(tick)}.`);
}
},
onWarn() {
if (generation.current !== mine) return;
setWarning(true);
setAnnouncement("Noch 30 Sekunden.");
},
onAutoStop(result) {
if (generation.current !== mine) return;
finish(result, "Aufnahme beendet.");
autoStopHandler.current?.(result);
},
}).then(
(started) => {
if (generation.current !== mine) {
started.cancel();
return;
}
handle.current = started;
setPhase("recording");
setAnnouncement("Aufnahme läuft.");
},
(error: unknown) => {
if (generation.current === mine) {
setPhase(phaseForStartError(error));
}
},
);
}, [finish]);
const stop = useCallback(() => {
const active = handle.current;
if (!active) {
return;
}
const mine = generation.current;
handle.current = null;
active.stop().then(
(result) => {
if (generation.current === mine) finish(result, "Aufnahme beendet.");
},
() => {
if (generation.current === mine) setPhase("failed");
},
);
}, [finish]);
const reset = useCallback(() => {
generation.current += 1;
handle.current?.cancel();
handle.current = null;
revokeUrl(urlRef.current);
urlRef.current = null;
setRecording(null);
setSec(0);
setWarning(false);
setAnnouncement("");
setPhase("idle");
}, []);
useEffect(
() => () => {
generation.current += 1;
handle.current?.cancel();
revokeUrl(urlRef.current);
},
[],
);
return {
phase: unsupported && phase === "idle" ? "unsupported" : phase,
sec,
announcement,
warning,
recording,
start,
stop,
cancel: reset,
reset,
};
}
@@ -0,0 +1,237 @@
"use client";
import { ReactNode, useState } from "react";
import Link from "next/link";
import {
AlertTriangle,
CheckCircle2,
ChevronDown,
Clock,
Loader2,
type LucideIcon,
} from "lucide-react";
import { VOICE_TOO_MANY_OPEN_HINT, voiceRejectionText } from "@/lib/offline/queue-texts";
import type { VoiceCaptureListItem } from "@/lib/voice/api";
import type { StoredVoiceNote } from "@/lib/voice/pending-voice";
import {
VOICE_WAITING_FOR_NETWORK,
describeVoiceCapture,
type VoiceStatusIcon,
type VoiceStatusText,
} from "@/lib/voice/status-texts";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { VoiceConfirmDialog } from "./voice-confirm-dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
const STATUS_ICONS: Record<VoiceStatusIcon, LucideIcon> = {
wait: Clock,
work: Loader2,
ready: CheckCircle2,
failed: AlertTriangle,
};
const TIME_FORMAT = new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
timeZone: "Europe/Berlin",
});
function timeText(iso: string): string {
return `${TIME_FORMAT.format(new Date(iso))} Uhr`.replace(", ", " · ");
}
function StatusLine({ status }: { status: VoiceStatusText }) {
const Icon = STATUS_ICONS[status.icon];
return (
<p className="flex items-center gap-2 text-sm font-medium">
<Icon
className={`size-4 shrink-0${status.icon === "work" ? " motion-safe:animate-spin" : ""}`}
aria-hidden
/>
{status.label}
</p>
);
}
interface DiscardMenuProps {
/** Names the menu for assistive tech, e.g. "B3, 14.10. · 09:12 Uhr". */
subject: string;
onDiscard(): void;
disabled?: boolean;
}
/** „Mehr“ with „Verwerfen“, which asks first: a discarded note cannot come back. */
function DiscardMenu({ subject, onDiscard, disabled }: DiscardMenuProps) {
const [confirmOpen, setConfirmOpen] = useState(false);
return (
<>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="outline"
size="touch"
className="min-h-14"
aria-label={`Mehr zu ${subject}`}
/>
}
>
Mehr
<ChevronDown aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-auto min-w-48">
<DropdownMenuItem
className="min-h-11 px-3"
disabled={disabled}
onClick={() => setConfirmOpen(true)}
>
Verwerfen
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<VoiceConfirmDialog
open={confirmOpen}
title="Sprachnotiz verwerfen?"
description="Die Sprachnotiz wird gelöscht. Das lässt sich nicht rückgängig machen."
cancelLabel="Abbrechen"
confirmLabel="Verwerfen"
onCancel={() => setConfirmOpen(false)}
onConfirm={() => {
setConfirmOpen(false);
onDiscard();
}}
/>
</>
);
}
interface CardShellProps {
title: string;
time: string;
status: VoiceStatusText;
detail?: string | null;
children?: ReactNode;
}
function CardShell({ title, time, status, detail, children }: CardShellProps) {
return (
<Card>
<CardContent className="flex flex-col gap-3">
<div className="flex flex-wrap items-baseline justify-between gap-x-3">
<h2 className="font-heading text-lg font-semibold">{title}</h2>
<p className="text-sm text-muted-foreground">{time}</p>
</div>
<StatusLine status={status} />
{detail ? <p className="text-sm text-muted-foreground">{detail}</p> : null}
{children ? <div className="flex flex-wrap gap-2 [&>*]:flex-1 sm:[&>*]:flex-none">{children}</div> : null}
</CardContent>
</Card>
);
}
interface LocalVoiceCardProps {
note: StoredVoiceNote;
colonyName: string;
/** Whether the device is online: a waiting note is then about to be sent. */
online: boolean;
onDiscard(): void;
}
/** What a recording on this device is doing, in words. */
function localStatus(note: StoredVoiceNote, online: boolean): { status: VoiceStatusText; detail: string | null } {
if (note.lastError) {
return {
status: { label: "Fehlgeschlagen", icon: "failed" },
detail: voiceRejectionText(note.lastError),
};
}
if (note.blockedBy === "TOO_MANY_OPEN") {
return {
status: { label: "Wartet auf freien Platz", icon: "wait" },
detail: VOICE_TOO_MANY_OPEN_HINT,
};
}
if (note.state === "uploading" || online) {
return { status: { label: "Wird gesendet", icon: "work" }, detail: null };
}
return { status: VOICE_WAITING_FOR_NETWORK, detail: null };
}
/** A recording that still waits on this device. */
export function LocalVoiceCard({ note, colonyName, online, onDiscard }: LocalVoiceCardProps) {
const time = timeText(note.createdAt);
const { status, detail } = localStatus(note, online);
const again = new URLSearchParams({ colonyId: note.colonyId, date: note.inspectionDate });
return (
<CardShell title={colonyName} time={time} status={status} detail={detail}>
{note.lastError ? (
<Button
size="touch"
className="min-h-14"
nativeButton={false}
render={<Link href={`/voice/new?${again.toString()}`} />}
>
Neu aufnehmen
</Button>
) : null}
<DiscardMenu subject={`${colonyName}, ${time}`} onDiscard={onDiscard} />
</CardShell>
);
}
interface VoiceCaptureCardProps {
item: VoiceCaptureListItem;
/** False when the operator switched the evaluation off. */
extractionEnabled?: boolean;
onRetry(): void;
onDiscard(): void;
busy?: boolean;
}
/** A note the server holds. */
export function VoiceCaptureCard({
item,
extractionEnabled = true,
onRetry,
onDiscard,
busy,
}: VoiceCaptureCardProps) {
const state = describeVoiceCapture(item, extractionEnabled);
const time = timeText(item.createdAt);
const reviewHref = `/inspections/new?colonyId=${encodeURIComponent(item.colonyId)}&voice=${encodeURIComponent(item.id)}`;
return (
<CardShell title={item.colonyName} time={time} status={state.status} detail={state.detail}>
{state.action !== "none" ? (
<Button
size="touch"
className="min-h-14"
nativeButton={false}
render={<Link href={reviewHref} />}
>
{state.action === "review" ? "Prüfen" : "Text ansehen"}
</Button>
) : null}
{state.canRetry ? (
<Button
type="button"
size="touch"
variant="outline"
className="min-h-14"
disabled={busy}
onClick={onRetry}
>
Nochmal versuchen
</Button>
) : null}
<DiscardMenu subject={`${item.colonyName}, ${time}`} onDiscard={onDiscard} disabled={busy} />
</CardShell>
);
}
@@ -0,0 +1,61 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
interface VoiceConfirmDialogProps {
open: boolean;
title: string;
description: string;
/** Leaves the note as it is. */
cancelLabel: string;
confirmLabel: string;
onCancel(): void;
onConfirm(): void;
}
/**
* A question before a recording or note is thrown away. Both buttons are 56 px
* high for gloved hands; the safe answer comes first.
*/
export function VoiceConfirmDialog({
open,
title,
description,
cancelLabel,
confirmLabel,
onCancel,
onConfirm,
}: VoiceConfirmDialogProps) {
return (
<Dialog open={open} onOpenChange={(next) => !next && onCancel()}>
<DialogContent role="alertdialog" showCloseButton={false}>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" variant="outline" size="touch" className="min-h-14" onClick={onCancel}>
{cancelLabel}
</Button>
<Button
type="button"
variant="destructive"
size="touch"
className="min-h-14"
onClick={onConfirm}
>
{confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,84 @@
import { render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fetchVoiceConfig } from "@/lib/voice/api";
import { VoiceEntryLink } from "./voice-entry-link";
let permissions = new Set<string>();
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({ hasPermission: (key: string) => permissions.has(key) }),
}));
vi.mock("@/lib/voice/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/voice/api")>()),
fetchVoiceConfig: vi.fn(),
}));
const CONFIG = {
recordingEnabled: true,
extractionEnabled: true,
model: "claude-sonnet-5-5",
retention: { audioHours: 24, contentDays: 7, rowDays: 90 },
};
function renderLink(round?: string) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<VoiceEntryLink colonyId="c-1" roundApiaryId={round} />
</QueryClientProvider>,
);
}
describe("VoiceEntryLink", () => {
beforeEach(() => {
permissions = new Set(["voice-captures:use", "inspections:create"]);
vi.mocked(fetchVoiceConfig).mockReset().mockResolvedValue(CONFIG);
});
it("links to the recording page for the colony", async () => {
renderLink();
const link = await screen.findByRole("button", { name: "Sprachnotiz aufnehmen" });
expect(link).toHaveAttribute("href", "/voice/new?colonyId=c-1");
});
it("has a 56 px target", async () => {
renderLink();
expect(await screen.findByRole("button", { name: "Sprachnotiz aufnehmen" })).toHaveClass(
"min-h-14",
);
});
it("keeps the stand round", async () => {
renderLink("a-1");
const link = await screen.findByRole("button", { name: "Sprachnotiz aufnehmen" });
expect(link).toHaveAttribute("href", "/voice/new?colonyId=c-1&round=a-1");
});
it("is not there without both permissions", async () => {
permissions = new Set(["voice-captures:use"]);
renderLink();
expect(screen.queryByRole("button", { name: "Sprachnotiz aufnehmen" })).not.toBeInTheDocument();
expect(fetchVoiceConfig).not.toHaveBeenCalled();
});
it("is not there while recording is switched off", async () => {
vi.mocked(fetchVoiceConfig).mockResolvedValue({ ...CONFIG, recordingEnabled: false });
renderLink();
await waitFor(() => expect(fetchVoiceConfig).toHaveBeenCalled());
await waitFor(() =>
expect(screen.queryByRole("button", { name: "Sprachnotiz aufnehmen" })).not.toBeInTheDocument(),
);
});
it("stays when the config cannot be read", async () => {
vi.mocked(fetchVoiceConfig).mockRejectedValue(new TypeError("Failed to fetch"));
renderLink();
expect(await screen.findByRole("button", { name: "Sprachnotiz aufnehmen" })).toBeInTheDocument();
});
});
@@ -0,0 +1,42 @@
"use client";
import Link from "next/link";
import { Mic } from "lucide-react";
import { useAuth } from "@/lib/auth/auth-context";
import { offersRecording, useVoiceRecordingSwitch } from "@/lib/voice/use-voice-recording-switch";
import { Button } from "@/components/ui/button";
interface VoiceEntryLinkProps {
colonyId: string;
/** The stand round the note belongs to; keeps the round going afterwards. */
roundApiaryId?: string | null;
}
/**
* „Sprachnotiz aufnehmen“ next to „Durchsicht erfassen“. Not there without the
* rights or while the operator has recording switched off - no dead button.
*/
export function VoiceEntryLink({ colonyId, roundApiaryId }: VoiceEntryLinkProps) {
const { hasPermission } = useAuth();
const allowed = hasPermission("voice-captures:use") && hasPermission("inspections:create");
const recordingSwitch = useVoiceRecordingSwitch(allowed);
if (!allowed || !offersRecording(recordingSwitch)) {
return null;
}
const params = new URLSearchParams({ colonyId });
if (roundApiaryId) {
params.set("round", roundApiaryId);
}
return (
<Button
size="touch"
variant="outline"
className="min-h-14"
nativeButton={false}
render={<Link href={`/voice/new?${params.toString()}`} />}
>
<Mic aria-hidden />
Sprachnotiz aufnehmen
</Button>
);
}
@@ -0,0 +1,496 @@
import "fake-indexeddb/auto";
import { IDBFactory } from "fake-indexeddb";
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 axe from "axe-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Recording, StartRecordingOptions } from "@/lib/voice/recorder";
import { RECORDING_NOTICE } from "@/app/(app)/settings/voice/voice-privacy-text";
import { VoiceRecorder } from "./voice-recorder";
let connectivity: "online" | "offline" = "online";
vi.mock("@/lib/auth/auth-context", () => ({ useCurrentUserId: () => "user-1" }));
vi.mock("@/lib/offline/connectivity", () => ({ useConnectivity: () => connectivity }));
const handle = { stop: vi.fn(), cancel: vi.fn() };
let options: StartRecordingOptions;
const startRecording = vi.fn();
vi.mock("@/lib/voice/recorder", () => ({
pickMimeType: () => "audio/webm;codecs=opus",
startRecording: (opts: StartRecordingOptions) => startRecording(opts),
}));
const syncPendingEntries = vi.fn();
vi.mock("@/lib/offline/sync", () => ({
syncPendingEntries: (...args: unknown[]) => syncPendingEntries(...args),
}));
const RECORDING: Recording = {
blob: new Blob(["audio"], { type: "audio/webm;codecs=opus" }),
mime: "audio/webm;codecs=opus",
durationSec: 42,
};
function renderRecorder(props: Partial<React.ComponentProps<typeof VoiceRecorder>> = {}) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<VoiceRecorder
colonyId="colony-1"
inspectionDate="2026-10-16"
typeInsteadHref="/inspections/new?colonyId=colony-1"
{...props}
/>
</QueryClientProvider>,
);
}
async function startAndRecord(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole("button", { name: "Aufnahme starten" }));
await screen.findByRole("button", { name: "Aufnahme beenden" });
}
function tick(sec: number) {
act(() => {
for (let second = 1; second <= sec; second += 1) options.onTick(second);
});
}
function liveText(): string {
return document.querySelector('[aria-live="polite"]')?.textContent ?? "";
}
describe("VoiceRecorder", () => {
beforeEach(async () => {
globalThis.indexedDB = new IDBFactory();
// The module keeps its first connection: empty the store instead of the factory.
const { discardAllPendingVoice } = await import("@/lib/voice/pending-voice");
await discardAllPendingVoice("user-1");
window.localStorage.clear();
window.localStorage.setItem("apicore.voice.notice-seen", "1");
connectivity = "online";
handle.stop.mockReset().mockResolvedValue(RECORDING);
handle.cancel.mockReset();
startRecording.mockReset().mockImplementation(async (opts: StartRecordingOptions) => {
options = opts;
return handle;
});
syncPendingEntries.mockReset().mockResolvedValue({ synced: 0, failed: 0, skippedForeign: 0 });
});
it("shows words and not just color while recording", async () => {
const user = userEvent.setup();
renderRecorder();
await startAndRecord(user);
tick(75);
expect(screen.getByText("Aufnahme läuft", { selector: "p" })).toBeInTheDocument();
expect(screen.getByText(/1:15/)).toBeInTheDocument();
// The same place: the start button became the stop button.
expect(screen.queryByRole("button", { name: "Aufnahme starten" })).not.toBeInTheDocument();
});
it("does not pulse for people who asked for less motion", async () => {
const user = userEvent.setup();
renderRecorder();
await startAndRecord(user);
const dot = screen.getByText("Aufnahme läuft", { selector: "p" }).querySelector("span");
expect(dot?.className).toContain("motion-safe:animate-pulse");
expect(dot?.className.split(" ")).not.toContain("animate-pulse");
});
it("announces the time only every 30 seconds", async () => {
const user = userEvent.setup();
renderRecorder();
await startAndRecord(user);
const heard = new Set<string>();
for (let second = 1; second <= 61; second += 1) {
act(() => options.onTick(second));
heard.add(liveText());
}
expect([...heard]).toEqual([
"Aufnahme läuft.",
"Aufnahme läuft, 30 Sekunden.",
"Aufnahme läuft, 1 Minute.",
]);
});
it("warns 30 seconds before the end and announces it", async () => {
const user = userEvent.setup();
renderRecorder();
await startAndRecord(user);
tick(150);
act(() => options.onWarn());
expect(screen.getByText("Noch 30 Sekunden")).toBeInTheDocument();
expect(liveText()).toBe("Noch 30 Sekunden.");
});
it("keeps what was recorded when the limit stops the recording", async () => {
const user = userEvent.setup();
renderRecorder();
await startAndRecord(user);
act(() => options.onAutoStop({ ...RECORDING, durationSec: 180 }));
expect(await screen.findByRole("heading", { name: /Aufnahme fertig · 3:00/ })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Senden" })).toBeInTheDocument();
});
it("shows the denied-state with a way to type instead", async () => {
startRecording.mockRejectedValue(new DOMException("denied", "NotAllowedError"));
const user = userEvent.setup();
renderRecorder();
await user.click(screen.getByRole("button", { name: "Aufnahme starten" }));
expect(await screen.findByText("Das Mikrofon ist nicht erlaubt.")).toBeInTheDocument();
expect(screen.getByText("Safari am iPhone")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Stattdessen tippen" })).toHaveAttribute(
"href",
"/inspections/new?colonyId=colony-1",
);
});
it("shows the notice once before the first recording", async () => {
window.localStorage.clear();
const user = userEvent.setup();
const first = renderRecorder();
await user.click(screen.getByRole("button", { name: "Aufnahme starten" }));
const dialog = await screen.findByRole("alertdialog");
expect(within(dialog).getByText(RECORDING_NOTICE)).toBeInTheDocument();
expect(startRecording).not.toHaveBeenCalled();
await user.click(within(dialog).getByRole("button", { name: /Verstanden/ }));
await screen.findByRole("button", { name: "Aufnahme beenden" });
expect(startRecording).toHaveBeenCalledTimes(1);
first.unmount();
renderRecorder();
await user.click(screen.getByRole("button", { name: "Aufnahme starten" }));
expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument();
expect(startRecording).toHaveBeenCalledTimes(2);
});
it("does not record when the notice is declined", async () => {
window.localStorage.clear();
const user = userEvent.setup();
renderRecorder();
await user.click(screen.getByRole("button", { name: "Aufnahme starten" }));
await user.click(await screen.findByRole("button", { name: "Abbrechen" }));
expect(startRecording).not.toHaveBeenCalled();
expect(window.localStorage.getItem("apicore.voice.notice-seen")).toBeNull();
});
it("cancels at once when only a moment was recorded", async () => {
const user = userEvent.setup();
renderRecorder();
await startAndRecord(user);
tick(2);
await user.click(screen.getByRole("button", { name: "Abbrechen" }));
expect(handle.cancel).toHaveBeenCalledTimes(1);
expect(screen.getByRole("button", { name: "Aufnahme starten" })).toBeInTheDocument();
});
it("asks before cancelling a longer recording", async () => {
const user = userEvent.setup();
renderRecorder();
await startAndRecord(user);
tick(12);
await user.click(screen.getByRole("button", { name: "Abbrechen" }));
const dialog = await screen.findByRole("alertdialog");
expect(handle.cancel).not.toHaveBeenCalled();
await user.click(within(dialog).getByRole("button", { name: "Aufnahme löschen" }));
expect(handle.cancel).toHaveBeenCalledTimes(1);
expect(await screen.findByRole("button", { name: "Aufnahme starten" })).toBeInTheDocument();
});
it("offline the main action reads Speichern and the note lands in the queue", async () => {
connectivity = "offline";
const user = userEvent.setup();
renderRecorder();
await startAndRecord(user);
await user.click(screen.getByRole("button", { name: "Aufnahme beenden" }));
await user.click(await screen.findByRole("button", { name: "Speichern" }));
expect(
await screen.findByText("Gespeichert. Wird gesendet, sobald Netz da ist."),
).toBeInTheDocument();
const { listPendingVoice } = await import("@/lib/voice/pending-voice");
expect(await listPendingVoice("user-1")).toHaveLength(1);
expect(syncPendingEntries).not.toHaveBeenCalled();
});
it("online it sends right away and says so", async () => {
const { discardAllPendingVoice } = await import("@/lib/voice/pending-voice");
syncPendingEntries.mockImplementation(async () => {
await discardAllPendingVoice("user-1");
return { synced: 1, failed: 0, skippedForeign: 0 };
});
const onSaved = vi.fn();
const user = userEvent.setup();
renderRecorder({ onSaved });
await startAndRecord(user);
await user.click(screen.getByRole("button", { name: "Aufnahme beenden" }));
await user.click(await screen.findByRole("button", { name: "Senden" }));
expect(
await screen.findByText("Gesendet. Du bekommst eine Meldung, wenn es fertig ist."),
).toBeInTheDocument();
expect(onSaved).toHaveBeenCalledWith({ kind: "sent" });
expect(syncPendingEntries).toHaveBeenCalledWith("user-1", { afterChange: true });
});
it("says why the server refused the recording and offers a new one", async () => {
const { listPendingVoice, markVoiceFailed } = await import("@/lib/voice/pending-voice");
syncPendingEntries.mockImplementation(async () => {
const [note] = await listPendingVoice("user-1");
await markVoiceFailed(note.id, { status: 413, message: "zu groß", details: { code: "TOO_LARGE" } } as never);
return { synced: 0, failed: 1, skippedForeign: 0 };
});
const user = userEvent.setup();
renderRecorder();
await startAndRecord(user);
await user.click(screen.getByRole("button", { name: "Aufnahme beenden" }));
await user.click(await screen.findByRole("button", { name: "Senden" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Die Sprachnotiz ist zu groß");
await user.click(screen.getByRole("button", { name: "Neu aufnehmen" }));
expect(await screen.findByRole("button", { name: "Aufnahme starten" })).toBeInTheDocument();
await waitFor(async () => expect(await listPendingVoice("user-1")).toHaveLength(0));
});
describe("focus", () => {
async function recordAndFinish(user: ReturnType<typeof userEvent.setup>) {
await startAndRecord(user);
await user.click(screen.getByRole("button", { name: "Aufnahme beenden" }));
return screen.findByRole("button", { name: /Senden|Speichern/ });
}
const resultHeading = () => screen.getByRole("heading", { name: "Ergebnis" });
it("moves to the result heading after a sent note", async () => {
const { discardAllPendingVoice } = await import("@/lib/voice/pending-voice");
syncPendingEntries.mockImplementation(async () => {
await discardAllPendingVoice("user-1");
return { synced: 1, failed: 0, skippedForeign: 0 };
});
const user = userEvent.setup();
renderRecorder();
await user.click(await recordAndFinish(user));
await screen.findByText(/^Gesendet/);
await waitFor(() => expect(document.activeElement).toBe(resultHeading()));
});
it("moves to the result heading after a queued note", async () => {
connectivity = "offline";
const user = userEvent.setup();
renderRecorder();
await user.click(await recordAndFinish(user));
await screen.findByText(/^Gespeichert/);
await waitFor(() => expect(document.activeElement).toBe(resultHeading()));
});
it("moves to the result heading after a refused note", async () => {
const { listPendingVoice, markVoiceFailed } = await import("@/lib/voice/pending-voice");
syncPendingEntries.mockImplementation(async () => {
const [note] = await listPendingVoice("user-1");
await markVoiceFailed(note.id, {
status: 413,
message: "x",
details: { code: "TOO_LARGE" },
} as never);
return { synced: 0, failed: 1, skippedForeign: 0 };
});
const user = userEvent.setup();
renderRecorder();
await user.click(await recordAndFinish(user));
await screen.findByRole("alert");
await waitFor(() => expect(document.activeElement).toBe(resultHeading()));
});
it("keeps the main button focusable (aria-disabled) while the microphone starts", async () => {
startRecording.mockReturnValue(new Promise(() => undefined));
const user = userEvent.setup();
renderRecorder();
await user.click(screen.getByRole("button", { name: "Aufnahme starten" }));
const button = screen.getByRole("button", { name: "Aufnahme starten" });
expect(button).toHaveAttribute("aria-disabled", "true");
expect(button).not.toBeDisabled();
expect(document.activeElement).toBe(button);
await user.click(button);
expect(startRecording).toHaveBeenCalledTimes(1);
});
it("returns to the main button after cancelling", async () => {
const user = userEvent.setup();
renderRecorder();
await startAndRecord(user);
await user.click(screen.getByRole("button", { name: "Abbrechen" }));
const button = await screen.findByRole("button", { name: "Aufnahme starten" });
await waitFor(() => expect(document.activeElement).toBe(button));
});
});
describe("a recording the limit stopped", () => {
it("is on the device at once and shows the saved state", async () => {
const user = userEvent.setup();
renderRecorder();
await startAndRecord(user);
act(() => options.onAutoStop({ ...RECORDING, durationSec: 180 }));
expect(
await screen.findByText("Gespeichert. Wird gesendet, sobald Netz da ist."),
).toBeInTheDocument();
const { listPendingVoice } = await import("@/lib/voice/pending-voice");
expect(await listPendingVoice("user-1")).toHaveLength(1);
expect(syncPendingEntries).not.toHaveBeenCalled();
});
it("is only synced, not queued twice, when Senden is pressed", async () => {
const { listPendingVoice, discardAllPendingVoice } = await import(
"@/lib/voice/pending-voice"
);
syncPendingEntries.mockImplementation(async () => {
await discardAllPendingVoice("user-1");
return { synced: 1, failed: 0, skippedForeign: 0 };
});
const user = userEvent.setup();
renderRecorder();
await startAndRecord(user);
act(() => options.onAutoStop({ ...RECORDING, durationSec: 180 }));
await screen.findByText(/^Gespeichert/);
await user.click(screen.getByRole("button", { name: "Senden" }));
expect(await screen.findByText(/^Gesendet/)).toBeInTheDocument();
expect(syncPendingEntries).toHaveBeenCalledTimes(1);
expect(await listPendingVoice("user-1")).toHaveLength(0);
});
it("is removed from the device when the user records anew", async () => {
const user = userEvent.setup();
renderRecorder();
await startAndRecord(user);
act(() => options.onAutoStop({ ...RECORDING, durationSec: 180 }));
await screen.findByText(/^Gespeichert/);
await user.click(screen.getByRole("button", { name: "Neu aufnehmen" }));
const dialog = await screen.findByRole("alertdialog");
await user.click(within(dialog).getByRole("button", { name: "Neu aufnehmen" }));
await screen.findByRole("button", { name: "Aufnahme starten" });
const { listPendingVoice } = await import("@/lib/voice/pending-voice");
await waitFor(async () => expect(await listPendingVoice("user-1")).toHaveLength(0));
});
});
describe("leaving", () => {
function leave(): boolean {
const event = new Event("beforeunload", { cancelable: true });
window.dispatchEvent(event);
return event.defaultPrevented;
}
it("is guarded while recording and while the result is unsaved, and free otherwise", async () => {
connectivity = "offline";
const user = userEvent.setup();
renderRecorder();
expect(leave()).toBe(false);
await startAndRecord(user);
expect(leave()).toBe(true);
await user.click(screen.getByRole("button", { name: "Aufnahme beenden" }));
await screen.findByRole("button", { name: "Speichern" });
expect(leave()).toBe(true);
await user.click(screen.getByRole("button", { name: "Speichern" }));
await screen.findByText(/^Gespeichert/);
expect(leave()).toBe(false);
});
it("is free again after cancelling", async () => {
const user = userEvent.setup();
renderRecorder();
await startAndRecord(user);
await user.click(screen.getByRole("button", { name: "Abbrechen" }));
expect(leave()).toBe(false);
});
});
it("asks before throwing away a finished recording", async () => {
const user = userEvent.setup();
renderRecorder();
await startAndRecord(user);
await user.click(screen.getByRole("button", { name: "Aufnahme beenden" }));
await user.click(await screen.findByRole("button", { name: "Neu aufnehmen" }));
const dialog = await screen.findByRole("alertdialog");
expect(screen.queryByRole("button", { name: "Aufnahme starten" })).not.toBeInTheDocument();
await user.click(within(dialog).getByRole("button", { name: "Neu aufnehmen" }));
expect(await screen.findByRole("button", { name: "Aufnahme starten" })).toBeInTheDocument();
});
it("gives every dialog button a 56 px target", async () => {
window.localStorage.clear();
const user = userEvent.setup();
renderRecorder();
await user.click(screen.getByRole("button", { name: "Aufnahme starten" }));
const notice = await screen.findByRole("alertdialog");
for (const button of within(notice).getAllByRole("button")) {
expect(button).toHaveClass("min-h-14");
}
});
it("blocks recording with a reason until a colony is chosen", () => {
renderRecorder({ blockedReason: "Bitte zuerst ein Volk wählen." });
expect(screen.getByRole("button", { name: "Aufnahme starten" })).toBeDisabled();
expect(screen.getByText("Bitte zuerst ein Volk wählen.")).toBeInTheDocument();
});
it("axe finds nothing", async () => {
const user = userEvent.setup();
const { container } = renderRecorder();
const run = () => axe.run(container, { rules: { "color-contrast": { enabled: false } } });
expect((await run()).violations).toEqual([]);
await startAndRecord(user);
tick(5);
expect((await run()).violations).toEqual([]);
await user.click(screen.getByRole("button", { name: "Aufnahme beenden" }));
await screen.findByRole("button", { name: "Senden" });
expect((await run()).violations).toEqual([]);
});
});
@@ -0,0 +1,456 @@
"use client";
import { ReactNode, useEffect, useRef, useState, useSyncExternalStore } from "react";
import Link from "next/link";
import { useQueryClient } from "@tanstack/react-query";
import { CheckCircle2, Mic, Square } from "lucide-react";
import { useCurrentUserId } from "@/lib/auth/auth-context";
import { useConnectivity } from "@/lib/offline/connectivity";
import { OFFLINE_QUEUE_QUERY_KEY } from "@/lib/offline/use-capture-mutation";
import { voiceKeys } from "@/lib/voice/api";
import { hasSeenRecordingNotice, markRecordingNoticeSeen } from "@/lib/voice/consent";
import { discardPendingVoice, VoiceStorageFullError } from "@/lib/voice/pending-voice";
import type { Recording } from "@/lib/voice/recorder";
import { saveVoiceNote, type SaveVoiceOutcome } from "@/lib/voice/save-voice-note";
import { Button } from "@/components/ui/button";
import { MicrophoneHelp } from "./microphone-help";
import { RecordingNoticeDialog } from "./recording-notice-dialog";
import { VoiceConfirmDialog } from "./voice-confirm-dialog";
import {
CONFIRM_CANCEL_AFTER_SEC,
MAX_RECORDING_SEC,
formatClock,
useVoiceRecorder,
} from "./use-voice-recorder";
const SENT_TEXT = "Gesendet. Du bekommst eine Meldung, wenn es fertig ist.";
const QUEUED_TEXT = "Gespeichert. Wird gesendet, sobald Netz da ist.";
const SAVE_FAILED_TEXT =
"Die Sprachnotiz konnte nicht gespeichert werden. Bitte noch einmal versuchen.";
const FAILED_TEXT = "Die Aufnahme hat nicht geklappt. Bitte noch einmal versuchen.";
type Confirming = "cancel" | "restart" | "discard" | null;
const NEVER_CHANGES = () => () => undefined;
function useWakeLockMissing(): boolean {
return useSyncExternalStore(
NEVER_CHANGES,
() => !("wakeLock" in navigator),
() => false,
);
}
export interface VoiceRecorderProps {
colonyId: string;
/** Calendar day "YYYY-MM-DD". */
inspectionDate: string;
/** Where "Stattdessen tippen" leads: the inspection form for the same colony. */
typeInsteadHref: string;
/** Recording is off for this reason (e.g. no colony chosen yet). */
blockedReason?: string | null;
/** True while audio exists that is not on the device yet: the page locks colony and date. */
onBusyChange?(busy: boolean): void;
onSaved?(outcome: SaveVoiceOutcome): void;
/** Rendered after saving, e.g. "Weiter mit B4". */
savedActions?: ReactNode;
}
function outcomeText(outcome: SaveVoiceOutcome): string {
if (outcome.kind === "sent") return SENT_TEXT;
if (outcome.kind === "queued") {
return outcome.hint ? `${QUEUED_TEXT} ${outcome.hint}` : QUEUED_TEXT;
}
return outcome.text;
}
export function VoiceRecorder({
colonyId,
inspectionDate,
typeInsteadHref,
blockedReason = null,
onBusyChange,
onSaved,
savedActions,
}: VoiceRecorderProps) {
const userId = useCurrentUserId();
const online = useConnectivity() === "online";
const queryClient = useQueryClient();
const wakeLockMissing = useWakeLockMissing();
const [noticeOpen, setNoticeOpen] = useState(false);
const [confirming, setConfirming] = useState<Confirming>(null);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [outcome, setOutcome] = useState<SaveVoiceOutcome | null>(null);
// The recording's note on the device, once it has one: a limit stop queues at once.
const [queuedNoteId, setQueuedNoteId] = useState<string | null>(null);
const resultHeading = useRef<HTMLHeadingElement>(null);
const mainButton = useRef<HTMLButtonElement>(null);
const focusMainWhenIdle = useRef(false);
function afterQueueChange() {
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
}
/** A recording the limit ended must not depend on the user pressing "Speichern". */
function queueAtOnce(recording: Recording) {
if (!userId) return;
saveVoiceNote({
userId,
recording,
colonyId,
inspectionDate,
online: false,
onQueued: setQueuedNoteId,
}).then(afterQueueChange, (error: unknown) =>
setSaveError(error instanceof VoiceStorageFullError ? error.message : SAVE_FAILED_TEXT),
);
}
const recorder = useVoiceRecorder({ onAutoStop: queueAtOnce });
const { phase, sec, recording } = recorder;
const unsaved =
phase === "starting" ||
phase === "recording" ||
(phase === "recorded" && !outcome && !queuedNoteId);
useEffect(() => {
onBusyChange?.(unsaved);
}, [unsaved, onBusyChange]);
// Leaving the page would lose the recording (or the finished result).
useEffect(() => {
if (!unsaved) return;
const guard = (event: BeforeUnloadEvent) => {
event.preventDefault();
event.returnValue = "";
};
window.addEventListener("beforeunload", guard);
return () => window.removeEventListener("beforeunload", guard);
}, [unsaved]);
// The big button disappears after the stop and the result changes again after
// saving: keyboard focus follows to the result.
const showsResult = phase === "recorded" || outcome !== null;
useEffect(() => {
if (showsResult) resultHeading.current?.focus();
}, [phase, outcome, showsResult]);
useEffect(() => {
if (phase === "idle" && focusMainWhenIdle.current) {
focusMainWhenIdle.current = false;
mainButton.current?.focus();
}
}, [phase]);
function pressMainButton() {
if (phase === "starting") return;
if (phase === "recording") {
recorder.stop();
return;
}
if (hasSeenRecordingNotice()) {
recorder.start();
} else {
setNoticeOpen(true);
}
}
function confirmNotice() {
markRecordingNoticeSeen();
setNoticeOpen(false);
recorder.start();
}
function cancelRecording() {
if (sec > CONFIRM_CANCEL_AFTER_SEC) {
setConfirming("cancel");
} else {
focusMainWhenIdle.current = true;
recorder.cancel();
}
}
/** Clears the screen for the next recording; the saved note stays on the device. */
function startOver() {
setOutcome(null);
setSaveError(null);
setQueuedNoteId(null);
recorder.reset();
}
/** The user gives this recording up: a note it already has on the device goes too. */
async function throwAway() {
setConfirming(null);
focusMainWhenIdle.current = true;
if (queuedNoteId && userId) {
await discardPendingVoice(queuedNoteId, userId).catch(() => undefined);
afterQueueChange();
}
startOver();
}
async function startOverAfterRejection(noteId: string) {
if (userId) {
await discardPendingVoice(noteId, userId).catch(() => undefined);
afterQueueChange();
}
startOver();
}
async function save() {
if (!recording || !userId || saving) return;
setSaving(true);
setSaveError(null);
try {
const result = await saveVoiceNote({
userId,
recording,
colonyId,
inspectionDate,
online,
queuedNoteId,
onQueued: setQueuedNoteId,
});
setOutcome(result);
afterQueueChange();
void queryClient.invalidateQueries({ queryKey: voiceKeys.all });
onSaved?.(result);
} catch (error) {
setSaveError(error instanceof VoiceStorageFullError ? error.message : SAVE_FAILED_TEXT);
} finally {
setSaving(false);
}
}
const liveRegion = (
<p role="status" aria-live="polite" className="sr-only">
{recorder.announcement}
</p>
);
if (phase === "denied" || phase === "no-microphone" || phase === "unsupported") {
return (
<>
{liveRegion}
<MicrophoneHelp
reason={phase}
typeInsteadHref={typeInsteadHref}
onRetry={phase === "unsupported" ? undefined : recorder.reset}
/>
</>
);
}
if (outcome) {
const rejected = outcome.kind === "rejected";
return (
<section aria-labelledby="voice-result" className="flex flex-col gap-4">
{liveRegion}
<h2 id="voice-result" ref={resultHeading} tabIndex={-1} className="sr-only">
Ergebnis
</h2>
<p
role={rejected ? "alert" : "status"}
className="flex items-start gap-2 text-lg font-medium"
>
{rejected ? null : (
<CheckCircle2
className="mt-0.5 size-5 shrink-0 text-condition-good-foreground"
aria-hidden
/>
)}
{outcomeText(outcome)}
</p>
{outcome.kind === "rejected" ? (
<Button
type="button"
size="touch"
className="min-h-14"
onClick={() => void startOverAfterRejection(outcome.noteId)}
>
Neu aufnehmen
</Button>
) : (
<>
{savedActions}
<Button
type="button"
size="touch"
variant="outline"
className="min-h-14"
onClick={startOver}
>
Noch eine Aufnahme
</Button>
<Button
size="touch"
variant="ghost"
className="min-h-14"
nativeButton={false}
render={<Link href="/voice" />}
>
Zu den Sprachnotizen
</Button>
</>
)}
</section>
);
}
if (phase === "recorded" && recording) {
return (
<section aria-labelledby="voice-result" className="flex flex-col gap-4">
{liveRegion}
<h2
id="voice-result"
ref={resultHeading}
tabIndex={-1}
className="font-heading text-lg font-semibold"
>
Aufnahme fertig · {formatClock(recording.durationSec)}
</h2>
{queuedNoteId ? (
<p role="status" className="flex items-start gap-2 text-base font-medium">
<CheckCircle2
className="mt-0.5 size-5 shrink-0 text-condition-good-foreground"
aria-hidden
/>
{QUEUED_TEXT}
</p>
) : null}
{recording.url ? (
<audio controls src={recording.url} className="w-full" aria-label="Aufnahme anhören" />
) : null}
{saveError ? (
<p role="alert" className="text-sm text-destructive">
{saveError}
</p>
) : null}
<Button
type="button"
size="touch"
className="min-h-14 text-base"
disabled={saving || !userId}
onClick={() => void save()}
>
{saving ? "Wird gespeichert…" : online ? "Senden" : "Speichern"}
</Button>
<Button
type="button"
size="touch"
variant="outline"
className="min-h-14"
disabled={saving}
onClick={() => setConfirming("restart")}
>
Neu aufnehmen
</Button>
<Button
type="button"
size="touch"
variant="ghost"
className="min-h-14"
disabled={saving}
onClick={() => setConfirming("discard")}
>
Verwerfen
</Button>
<VoiceConfirmDialog
open={confirming === "restart" || confirming === "discard"}
title={confirming === "restart" ? "Neu aufnehmen?" : "Aufnahme verwerfen?"}
description="Die Aufnahme wird gelöscht und nicht gesendet."
cancelLabel="Behalten"
confirmLabel={confirming === "restart" ? "Neu aufnehmen" : "Verwerfen"}
onCancel={() => setConfirming(null)}
onConfirm={() => void throwAway()}
/>
</section>
);
}
const recordingNow = phase === "recording";
const starting = phase === "starting";
const blocked = Boolean(blockedReason);
return (
<section aria-label="Aufnahme" className="flex flex-col items-center gap-4">
{liveRegion}
{recordingNow ? (
<div className="flex flex-col items-center gap-1">
<p className="flex items-center gap-2 text-lg font-semibold text-destructive">
<span
aria-hidden
className="size-3 rounded-full bg-destructive motion-safe:animate-pulse"
/>
Aufnahme läuft
</p>
<p className="font-heading text-4xl font-semibold tabular-nums">
{formatClock(sec)}
<span className="text-base font-normal text-muted-foreground">
{" "}
von {formatClock(MAX_RECORDING_SEC)}
</span>
</p>
{recorder.warning ? <p className="text-base font-medium">Noch 30 Sekunden</p> : null}
{wakeLockMissing ? (
<p className="text-sm text-muted-foreground">Display anlassen</p>
) : null}
</div>
) : null}
<Button
ref={mainButton}
type="button"
size="touch"
variant={recordingNow ? "destructive" : "default"}
className="h-18 w-full max-w-sm text-lg"
// aria-disabled keeps the keyboard focus on the button while the microphone starts.
disabled={blocked}
aria-disabled={starting || undefined}
aria-describedby={blockedReason ? "voice-blocked" : undefined}
onClick={pressMainButton}
>
{recordingNow ? <Square aria-hidden /> : <Mic aria-hidden />}
{recordingNow ? "Aufnahme beenden" : "Aufnahme starten"}
</Button>
{blockedReason ? (
<p id="voice-blocked" className="text-sm text-muted-foreground">
{blockedReason}
</p>
) : null}
{phase === "failed" ? (
<p role="alert" className="text-sm text-destructive">
{FAILED_TEXT}
</p>
) : null}
{recordingNow ? (
<Button
type="button"
size="touch"
variant="outline"
className="min-h-14"
onClick={cancelRecording}
>
Abbrechen
</Button>
) : null}
<RecordingNoticeDialog
open={noticeOpen}
onConfirm={confirmNotice}
onCancel={() => setNoticeOpen(false)}
/>
<VoiceConfirmDialog
open={confirming === "cancel"}
title="Aufnahme abbrechen?"
description="Die Aufnahme wird gelöscht. Das lässt sich nicht rückgängig machen."
cancelLabel="Weiter aufnehmen"
confirmLabel="Aufnahme löschen"
onCancel={() => setConfirming(null)}
onConfirm={() => {
setConfirming(null);
focusMainWhenIdle.current = true;
recorder.cancel();
}}
/>
</section>
);
}