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

@@ -12,7 +12,7 @@ export const VOICE_ERROR_TEXTS_DE: Readonly<Record<VoiceErrorCode, string>> = {
NO_SPEECH: 'Es war nichts zu verstehen. Bitte noch einmal aufnehmen.',
TOO_LONG: 'Die Aufnahme ist zu lang. Bitte kürzer aufnehmen.',
TRANSCRIBE_FAILED:
'Die Aufnahme konnte nicht abgeschrieben werden. Bitte noch einmal aufnehmen.',
'Die Aufnahme konnte nicht abgeschrieben werden. Bitte „Nochmal versuchen“ oder noch einmal aufnehmen.',
AUDIO_EXPIRED:
'Die Aufnahme wurde nach 24 Stunden gelöscht. Bitte noch einmal aufnehmen.',
EXTRACT_DISABLED:
@@ -35,6 +35,11 @@ vi.mock("@/components/autopilot/next-steps-card", () => ({
NextStepsCard: ({ colonyId }: { colonyId: string }) => <p>Als Nächstes für {colonyId}</p>,
}));
vi.mock("@/components/qr-code-dialog", () => ({ QrCodeDialog: () => null }));
vi.mock("@/components/voice/voice-entry-link", () => ({
VoiceEntryLink: ({ colonyId }: { colonyId: string }) => (
<a href={`/voice/new?colonyId=${colonyId}`}>Sprachnotiz aufnehmen</a>
),
}));
function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
@@ -99,6 +104,15 @@ describe("ColonyDetailPage – strength", () => {
expect(await screen.findByText("Volksstärke (alt): 7 von 10")).toBeInTheDocument();
});
it("offers a voice note next to „Durchsicht erfassen“", async () => {
vi.mocked(fetchColony).mockResolvedValue(colony);
renderPage();
const voice = await screen.findByRole("link", { name: "Sprachnotiz aufnehmen" });
expect(voice).toHaveAttribute("href", `/voice/new?colonyId=${colony.id}`);
expect(screen.getByRole("button", { name: "Durchsicht erfassen" })).toBeInTheDocument();
});
it("shows no strength line when none is stored", async () => {
vi.mocked(fetchColony).mockResolvedValue(colony);
renderPage();
@@ -13,6 +13,7 @@ import { NextStepsCard } from "@/components/autopilot/next-steps-card";
import { QrCodeDialog } from "@/components/qr-code-dialog";
import { DocumentUploadCard } from "@/components/document-upload-card";
import { DetailHeader, DetailQueryFallback, DetailStatus } from "@/components/detail-header";
import { VoiceEntryLink } from "@/components/voice/voice-entry-link";
import { Button } from "@/components/ui/button";
import { ColonyEditCard } from "./colony-edit-card";
import { ColonyActions } from "./colony-actions";
@@ -60,14 +61,17 @@ export default function ColonyDetailPage() {
subtitle={COLONY_TYPE_LABELS[colony.colonyType]}
status={<DetailStatus status={colony.status} label={COLONY_STATUS_LABELS[colony.status]} />}
primaryAction={
<Button
size="touch"
nativeButton={false}
render={<Link href={`/inspections/new?colonyId=${colony.id}`} />}
>
<ClipboardCheck aria-hidden />
Durchsicht erfassen
</Button>
<>
<VoiceEntryLink colonyId={colony.id} />
<Button
size="touch"
nativeButton={false}
render={<Link href={`/inspections/new?colonyId=${colony.id}`} />}
>
<ClipboardCheck aria-hidden />
Durchsicht erfassen
</Button>
</>
}
moreActions={[
{
@@ -24,6 +24,7 @@ import {
import { DRAFT_PREFIX } from "@/lib/forms/draft-storage";
import { initialInspectionValues } from "./inspection-form-values";
import { markColonyDone, readRound, startRound } from "@/lib/rounds/round-state";
import { fetchVoiceConfig } from "@/lib/voice/api";
const push = vi.fn();
let searchString = "colonyId=colony-1";
@@ -42,6 +43,10 @@ vi.mock("@/lib/apiaries/field-api", async (importOriginal) => ({
fetchColonyOverview: vi.fn(),
}));
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() }));
vi.mock("@/lib/voice/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/voice/api")>()),
fetchVoiceConfig: vi.fn(),
}));
vi.mock("@/lib/inspections/api", () => ({
createInspection: vi.fn(),
fetchColonyInspections: vi.fn(),
@@ -739,6 +744,20 @@ describe("NewInspectionPage", () => {
await userEvent.click(screen.getByRole("button", { name: "Durchsicht speichern" }));
}
it("offers a voice note for the colony of the round next to the chip", async () => {
permissions = new Set(["voice-captures:use", "inspections:create"]);
vi.mocked(fetchVoiceConfig).mockResolvedValue({
recordingEnabled: true,
extractionEnabled: true,
model: "claude-sonnet-5-5",
retention: { audioHours: 24, contentDays: 7, rowDays: 90 },
});
renderPage();
const link = await screen.findByRole("button", { name: "Sprachnotiz aufnehmen" });
expect(link).toHaveAttribute("href", "/voice/new?colonyId=colony-1&round=apiary-1");
});
it("shows where in the round the beekeeper is", async () => {
renderPage();
@@ -43,6 +43,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { DictationRow } from "@/components/voice/dictation-button";
import { VoiceEntryLink } from "@/components/voice/voice-entry-link";
import { appendDictation } from "@/lib/voice/append-text";
import { FormBackLink } from "@/components/form/form-back-link";
import { RoundNextPanel } from "@/components/rounds/round-next-panel";
@@ -564,9 +565,14 @@ function InspectionCaptureForm({
draft={draft}
/>
{stand.round ? (
<p className="w-fit rounded-full bg-muted px-3 py-1 text-sm font-medium">
{roundChipText(stand.round, values.colonyId)}
</p>
<div className="flex flex-wrap items-center gap-3">
<p className="w-fit rounded-full bg-muted px-3 py-1 text-sm font-medium">
{roundChipText(stand.round, values.colonyId)}
</p>
{values.colonyId ? (
<VoiceEntryLink colonyId={values.colonyId} roundApiaryId={stand.round.apiaryId} />
) : null}
</div>
) : null}
<PageHeader
icon={ClipboardCheck}
@@ -74,6 +74,11 @@ vi.mock("@/components/offline/offline-band", () => ({
),
}));
vi.mock("@/components/user-avatar", () => ({ UserAvatar: () => null }));
// The quick-add entry for voice notes asks the server; this spec is about the layout.
vi.mock("@/lib/voice/use-voice-recording-switch", () => ({
useVoiceRecordingSwitch: () => "on",
offersRecording: () => true,
}));
vi.mock("@/lib/photos/store", () => ({
purgeStaleDraftPhotos: vi.fn().mockResolvedValue(0),
finishHandOvers: vi.fn().mockResolvedValue(0),
@@ -7,9 +7,10 @@ import {
} from "./nav-config";
const ALL_ITEMS = [...NAV_GROUPS, ADMIN_GROUP].flatMap((group) => group.items);
const ALL_PERMISSIONS = ALL_ITEMS.flatMap((item) =>
item.permission === undefined ? [] : [item.permission].flat(),
);
const ALL_PERMISSIONS = ALL_ITEMS.flatMap((item) => [
...(item.permission === undefined ? [] : [item.permission].flat()),
...(item.alsoRequires ?? []),
]);
function hrefs(permissions: string[]): string[] {
return visibleNavGroups({ permissions })
@@ -72,6 +73,17 @@ describe("nav-config", () => {
expect(item?.counter).toBe("suggestions");
});
it("shows „Sprachnotizen“ only with voice-captures:use AND inspections:create", () => {
expect(hrefs([])).not.toContain("/voice");
expect(hrefs(["voice-captures:use"])).not.toContain("/voice");
expect(hrefs(["inspections:create"])).not.toContain("/voice");
const work = visibleNavGroups({
permissions: ["voice-captures:use", "inspections:create"],
}).find((group) => group.label === "Arbeit");
const item = work?.items.find((entry) => entry.href === "/voice");
expect(item?.label).toBe("Sprachnotizen");
});
it("shows Prüfbereitschaft only with compliance:read", () => {
expect(hrefs([])).not.toContain("/compliance");
expect(hrefs(["compliance:read"])).toContain("/compliance");
+15 -1
View File
@@ -16,6 +16,7 @@ import {
ListTodo,
type LucideIcon,
MapPin,
Mic,
Package,
Scissors,
Search,
@@ -44,6 +45,8 @@ export interface NavItem {
// together several previously-separate settings pages, each gated by its
// own permission.
permission?: string | string[];
/** Permissions that are ALL needed on top of `permission` (which is any-of). */
alsoRequires?: string[];
/** A number shown next to the label; the data comes from the matching hook. */
counter?: "suggestions";
}
@@ -98,6 +101,13 @@ export const NAV_GROUPS: NavGroup[] = [
counter: "suggestions",
},
{ label: "Kalender", href: "/calendar", icon: CalendarDays },
{
label: "Sprachnotizen",
href: "/voice",
icon: Mic,
permission: "voice-captures:use",
alsoRequires: ["inspections:create"],
},
],
},
{
@@ -204,7 +214,11 @@ export function visibleNavGroups(user: Pick<AuthUser, "permissions">): NavGroup[
return [...NAV_GROUPS, ADMIN_GROUP]
.map((group) => ({
...group,
items: group.items.filter((item) => isVisible(user, item.permission)),
items: group.items.filter(
(item) =>
isVisible(user, item.permission) &&
(item.alsoRequires ?? []).every((key) => user.permissions.includes(key)),
),
}))
.filter((group) => group.items.length > 0);
}
@@ -3,6 +3,7 @@ import {
ClipboardCheck,
ListTodo,
type LucideIcon,
Mic,
Scissors,
Sprout,
Syringe,
@@ -15,6 +16,10 @@ export interface QuickAction {
icon: LucideIcon;
/** Creating needs this permission; actions without it are not offered. */
permission: string;
/** A second permission the action also needs (the note ends in an inspection). */
alsoPermission?: string;
/** Offered only while the operator allows voice recording (GET /voice-captures/config). */
needsVoiceRecording?: boolean;
}
export const QUICK_ACTIONS: readonly QuickAction[] = [
@@ -24,6 +29,14 @@ export const QUICK_ACTIONS: readonly QuickAction[] = [
icon: ClipboardCheck,
permission: "inspections:create",
},
{
label: "Sprachnotiz",
href: "/voice/new",
icon: Mic,
permission: "voice-captures:use",
alsoPermission: "inspections:create",
needsVoiceRecording: true,
},
{
label: "Varroa-Messung",
href: "/varroa/new",
@@ -1,5 +1,8 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { render as rtlRender, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactElement } from "react";
import { fetchVoiceConfig } from "@/lib/voice/api";
import userEvent from "@testing-library/user-event";
import { QUICK_ACTIONS } from "./quick-actions";
import { QuickAddSheet } from "./quick-add-sheet";
@@ -9,11 +12,29 @@ vi.mock("next/navigation", () => ({
usePathname: () => mockPathname,
}));
vi.mock("@/lib/voice/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/voice/api")>()),
fetchVoiceConfig: vi.fn(),
}));
const ALL = QUICK_ACTIONS.map((action) => action.permission);
const CONFIG = {
recordingEnabled: true,
extractionEnabled: true,
model: "claude-sonnet-5-5",
retention: { audioHours: 24, contentDays: 7, rowDays: 90 },
};
function render(ui: ReactElement) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return rtlRender(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
}
describe("QuickAddSheet", () => {
beforeEach(() => {
mockPathname = "/dashboard";
vi.mocked(fetchVoiceConfig).mockReset().mockResolvedValue(CONFIG);
});
it("lists actions with icons as links", async () => {
@@ -25,9 +46,66 @@ describe("QuickAddSheet", () => {
expect(link).toHaveAttribute("href", "/inspections/new");
expect(link).toHaveClass("min-h-14");
expect(link.querySelector("svg.size-6")).not.toBeNull();
await screen.findByRole("link", { name: "Sprachnotiz" });
expect(screen.getAllByRole("link")).toHaveLength(QUICK_ACTIONS.length);
});
it("offers Sprachnotiz with a microphone icon when recording is on", async () => {
render(<QuickAddSheet user={{ permissions: ALL }} variant="fab" />);
await userEvent.click(screen.getByRole("button", { name: "Neu" }));
const link = await screen.findByRole("link", { name: "Sprachnotiz" });
expect(link).toHaveAttribute("href", "/voice/new");
expect(link.querySelector("svg.lucide-mic")).not.toBeNull();
});
it("the quick-add entry is hidden without permission or when recording is disabled", async () => {
// Without voice-captures:use the entry is not offered and the config is never asked.
const { unmount } = render(
<QuickAddSheet
user={{ permissions: ALL.filter((p) => p !== "voice-captures:use") }}
variant="fab"
/>,
);
await userEvent.click(screen.getByRole("button", { name: "Neu" }));
await screen.findByRole("link", { name: "Durchsicht" });
expect(screen.queryByRole("link", { name: "Sprachnotiz" })).not.toBeInTheDocument();
expect(fetchVoiceConfig).not.toHaveBeenCalled();
unmount();
// Without inspections:create the note could never be reviewed.
const second = render(
<QuickAddSheet
user={{ permissions: ALL.filter((p) => p !== "inspections:create") }}
variant="fab"
/>,
);
await userEvent.click(screen.getByRole("button", { name: "Neu" }));
await screen.findByRole("link", { name: "Behandlung" });
expect(screen.queryByRole("link", { name: "Sprachnotiz" })).not.toBeInTheDocument();
second.unmount();
// The operator switched recording off: no dead entry.
vi.mocked(fetchVoiceConfig).mockResolvedValue({ ...CONFIG, recordingEnabled: false });
render(<QuickAddSheet user={{ permissions: ALL }} variant="fab" />);
await waitFor(() => expect(fetchVoiceConfig).toHaveBeenCalled());
await userEvent.click(screen.getByRole("button", { name: "Neu" }));
await screen.findByRole("link", { name: "Durchsicht" });
await waitFor(() =>
expect(screen.queryByRole("link", { name: "Sprachnotiz" })).not.toBeInTheDocument(),
);
});
it("keeps the entry when the config cannot be read (offline recording works)", async () => {
vi.mocked(fetchVoiceConfig).mockRejectedValue(new TypeError("Failed to fetch"));
render(<QuickAddSheet user={{ permissions: ALL }} variant="fab" />);
await userEvent.click(screen.getByRole("button", { name: "Neu" }));
expect(await screen.findByRole("link", { name: "Sprachnotiz" })).toBeInTheDocument();
});
it("hides Behandlung without treatments:create", async () => {
render(
<QuickAddSheet
@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
import { SheetTrigger } from "@/components/ui/sheet";
import type { AuthUser } from "@/lib/auth/auth-context";
import { NAV_SHEET_ROW_CLASS, NavSheet } from "@/components/nav/nav-sheet";
import { offersRecording, useVoiceRecordingSwitch } from "@/lib/voice/use-voice-recording-switch";
import { QUICK_ACTIONS } from "./quick-actions";
// Create and edit forms end in a save bar (StickySubmit) right where the
@@ -36,8 +37,13 @@ interface QuickAddSheetProps {
export function QuickAddSheet({ user, variant }: QuickAddSheetProps) {
const pathname = usePathname();
const [open, setOpen] = useState(false);
const actions = QUICK_ACTIONS.filter((action) =>
user.permissions.includes(action.permission),
const mayRecord = user.permissions.includes("voice-captures:use");
const recording = offersRecording(useVoiceRecordingSwitch(mayRecord));
const actions = QUICK_ACTIONS.filter(
(action) =>
user.permissions.includes(action.permission) &&
(!action.alsoPermission || user.permissions.includes(action.alsoPermission)) &&
(!action.needsVoiceRecording || recording),
);
if (!hasQuickAddButton(pathname, user.permissions)) {
@@ -0,0 +1,240 @@
import { render, screen, 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 { toLocalIsoDate, todayLocalIsoDate } from "@/lib/dates";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { StandRound } from "@/lib/rounds/round-state";
import { fetchVoiceConfig } from "@/lib/voice/api";
import type { VoiceRecorderProps } from "@/components/voice/voice-recorder";
import NewVoiceNotePage from "./page";
let query = new URLSearchParams();
vi.mock("next/navigation", () => ({
useSearchParams: () => query,
useRouter: () => ({ push: vi.fn() }),
}));
let permissions = new Set<string>();
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({ hasPermission: (key: string) => permissions.has(key) }),
useCurrentUserId: () => "user-1",
}));
vi.mock("@/lib/colonies/api", () => ({
fetchColonies: vi.fn().mockResolvedValue([
{ id: "c-1", code: "B3", status: "ACTIVE", apiaryId: "a-1" },
{ id: "c-2", code: "B4", status: "ACTIVE", apiaryId: "a-1" },
]),
}));
vi.mock("@/lib/voice/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/voice/api")>()),
fetchVoiceConfig: vi.fn(),
}));
const ROUND: StandRound = {
version: 1,
apiaryId: "a-1",
apiaryName: "Hang",
colonies: [
{ id: "c-1", code: "B3" },
{ id: "c-2", code: "B4" },
],
doneColonyIds: [],
startedAt: "2026-10-16T08:00:00.000Z",
};
let activeRound: StandRound | null = null;
const markDone = vi.fn();
vi.mock("@/lib/rounds/use-stand-round", () => ({
useStandRound: () => ({
round: activeRound,
markDone,
next: (round: StandRound) =>
round.colonies.find((colony) => !round.doneColonyIds.includes(colony.id)) ?? null,
end: vi.fn(),
}),
}));
let recorderProps: VoiceRecorderProps;
vi.mock("@/components/voice/voice-recorder", () => ({
VoiceRecorder: (props: VoiceRecorderProps) => {
recorderProps = props;
return (
<div>
<button type="button" onClick={() => props.onBusyChange?.(true)}>
Sperren (Test)
</button>
<button type="button" onClick={() => props.onSaved?.({ kind: "sent" })}>
Aufnahme speichern (Test)
</button>
{props.blockedReason ? <p>{props.blockedReason}</p> : null}
{props.savedActions}
</div>
);
},
}));
const CONFIG = {
recordingEnabled: true,
extractionEnabled: true,
model: "claude-sonnet-5-5",
retention: { audioHours: 24, contentDays: 7, rowDays: 90 },
};
function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<NewVoiceNotePage />
</QueryClientProvider>,
);
}
describe("NewVoiceNotePage", () => {
beforeEach(() => {
query = new URLSearchParams();
activeRound = null;
markDone.mockReset();
permissions = new Set(["voice-captures:use", "inspections:create"]);
vi.mocked(fetchVoiceConfig).mockReset().mockResolvedValue(CONFIG);
});
it("reads the colony and date from the link and names them in the header", async () => {
query = new URLSearchParams({ colonyId: "c-1", date: "2026-10-14" });
renderPage();
expect(await screen.findByText("B3 · 14.10.")).toBeInTheDocument();
expect(recorderProps).toMatchObject({ colonyId: "c-1", inspectionDate: "2026-10-14" });
expect(recorderProps.blockedReason).toBeNull();
expect(screen.getByText("Sprich in ganzen Sätzen, nur du.")).toBeInTheDocument();
});
const daysAgo = (days: number) => toLocalIsoDate(new Date(Date.now() - days * 86_400_000));
it.each([
["a date older than a week", daysAgo(9)],
["a date in the future", toLocalIsoDate(new Date(Date.now() + 3 * 86_400_000))],
["something that is no date", "morgen"],
["a day that does not exist", "2026-02-31"],
])("falls back to today for %s", async (_label, date) => {
query = new URLSearchParams({ colonyId: "c-1", date });
renderPage();
await screen.findByText(/B3 ·/);
expect(recorderProps.inspectionDate).toBe(todayLocalIsoDate());
});
it("keeps a date from the last seven days", async () => {
query = new URLSearchParams({ colonyId: "c-1", date: daysAgo(5) });
renderPage();
await screen.findByText(/B3 ·/);
expect(recorderProps.inspectionDate).toBe(daysAgo(5));
});
it("locks colony and date while a recording is running or unsaved", async () => {
query = new URLSearchParams({ colonyId: "c-1" });
const user = userEvent.setup();
renderPage();
await user.click(await screen.findByRole("button", { name: "Ändern" }));
expect(screen.getByLabelText(/Datum/, { selector: "input" })).toBeEnabled();
await user.click(screen.getByRole("button", { name: "Sperren (Test)" }));
expect(screen.getByLabelText(/Datum/, { selector: "input" })).toBeDisabled();
expect(screen.getByRole("combobox", { name: /Volk/ })).toBeDisabled();
});
it("lists what to talk about", async () => {
query = new URLSearchParams({ colonyId: "c-1" });
renderPage();
const topics = within(await screen.findByRole("list")).getAllByRole("listitem");
expect(topics.map((topic) => topic.textContent)).toEqual([
"Königin",
"Stifte",
"Brut",
"Waben",
"Sanftmut",
"Futter",
"Honigraum",
"Varroa",
"Maßnahmen",
]);
});
it("asks for a colony first when the link names none", async () => {
renderPage();
expect(await screen.findByText("Volk wählen", { selector: "p" })).toBeInTheDocument();
expect(screen.getByText("Bitte zuerst ein Volk wählen.")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Ändern" })).toHaveAttribute("aria-expanded", "true");
});
it("lets the colony and the date be changed", async () => {
query = new URLSearchParams({ colonyId: "c-1", date: "2026-10-14" });
const user = userEvent.setup();
renderPage();
await user.click(await screen.findByRole("button", { name: "Ändern" }));
expect(screen.getByLabelText(/Datum/, { selector: "input" })).toHaveValue("2026-10-14");
expect(screen.getByRole("combobox", { name: /Volk/ })).toBeInTheDocument();
});
it("says so, with a way to type, when recording is switched off", async () => {
vi.mocked(fetchVoiceConfig).mockResolvedValue({ ...CONFIG, recordingEnabled: false });
query = new URLSearchParams({ colonyId: "c-1" });
renderPage();
expect(await screen.findByRole("alert")).toHaveTextContent(
"Sprachnotizen sind gerade ausgeschaltet",
);
expect(screen.getByRole("button", { name: "Durchsicht eintippen" })).toHaveAttribute(
"href",
"/inspections/new",
);
});
it("says so without the permission", () => {
permissions = new Set(["voice-captures:use"]);
renderPage();
expect(screen.getByRole("alert")).toHaveTextContent("fehlt dir die Berechtigung");
expect(fetchVoiceConfig).not.toHaveBeenCalled();
});
it("sends 'Stattdessen tippen' to the inspection form of the same colony and round", async () => {
query = new URLSearchParams({ colonyId: "c-1", round: "a-1" });
activeRound = ROUND;
renderPage();
await screen.findByText(/B3 ·/);
expect(recorderProps.typeInsteadHref).toBe("/inspections/new?colonyId=c-1&round=a-1");
});
it("offers the next colony of the round after saving", async () => {
query = new URLSearchParams({ colonyId: "c-1", round: "a-1" });
activeRound = ROUND;
markDone.mockReturnValue({ ...ROUND, doneColonyIds: ["c-1"] });
const user = userEvent.setup();
renderPage();
await user.click(await screen.findByRole("button", { name: "Aufnahme speichern (Test)" }));
expect(markDone).toHaveBeenCalledWith("c-1");
expect(screen.getByRole("button", { name: /Weiter mit B4/ })).toHaveAttribute(
"href",
"/voice/new?colonyId=c-2&round=a-1",
);
});
it("axe finds nothing", async () => {
query = new URLSearchParams({ colonyId: "c-1", date: "2026-10-14" });
const { container } = renderPage();
await screen.findByText("B3 · 14.10.");
const results = await axe.run(container, { rules: { "color-contrast": { enabled: false } } });
expect(results.violations).toEqual([]);
});
});
@@ -0,0 +1,257 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { ArrowRight, Mic } from "lucide-react";
import { fetchColonies } from "@/lib/colonies/api";
import { useAuth } from "@/lib/auth/auth-context";
import { toLocalIsoDate, todayLocalIsoDate } from "@/lib/dates";
import { useStandRound } from "@/lib/rounds/use-stand-round";
import type { StandRound } from "@/lib/rounds/round-state";
import { useVoiceRecordingSwitch } from "@/lib/voice/use-voice-recording-switch";
import { Field } from "@/components/form/field";
import { FormBackLink } from "@/components/form/form-back-link";
import { LivingColonySelectField } from "@/components/colony-select-field";
import { PageHeader } from "@/components/page-header";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { VoiceRecorder } from "@/components/voice/voice-recorder";
const TOPICS = [
"Königin",
"Stifte",
"Brut",
"Waben",
"Sanftmut",
"Futter",
"Honigraum",
"Varroa",
"Maßnahmen",
] as const;
/** The server takes notes for the last seven days at most. */
const MAX_DAYS_BACK = 7;
const DAY_MS = 24 * 60 * 60 * 1000;
const NO_COLONY_REASON = "Bitte zuerst ein Volk wählen.";
/** "2026-10-14" as "14.10." */
function shortDay(isoDate: string): string {
const [, month, day] = isoDate.split("-");
return month && day ? `${day}.${month}.` : "";
}
function earliestDay(): string {
return toLocalIsoDate(new Date(Date.now() - MAX_DAYS_BACK * DAY_MS));
}
const CALENDAR_DAY = /^\d{4}-\d{2}-\d{2}$/;
/** The day from the link if it is a real day in the last seven days, else today. */
function validDay(requested: string | null): string {
const today = todayLocalIsoDate();
if (!requested || !CALENDAR_DAY.test(requested)) return today;
const exists = toLocalIsoDate(new Date(`${requested}T12:00:00`)) === requested;
return exists && requested >= earliestDay() && requested <= today ? requested : today;
}
function backTarget(colonyId: string): { href: string; label: string } {
return colonyId
? { href: `/colonies/${encodeURIComponent(colonyId)}`, label: "Zum Volk" }
: { href: "/voice", label: "Zu den Sprachnotizen" };
}
function inspectionHref(colonyId: string, roundApiaryId: string | null): string {
const params = new URLSearchParams();
if (colonyId) params.set("colonyId", colonyId);
if (roundApiaryId) params.set("round", roundApiaryId);
const query = params.toString();
return query ? `/inspections/new?${query}` : "/inspections/new";
}
interface RoundNextProps {
round: StandRound;
next: { id: string; code: string } | null;
onEnd(): void;
}
function RoundNext({ round, next, onEnd }: RoundNextProps) {
if (!next) {
return (
<Button
size="touch"
className="min-h-14 text-base"
nativeButton={false}
render={<Link href={`/apiaries/${encodeURIComponent(round.apiaryId)}`} onClick={onEnd} />}
>
Runde abgeschlossen – zum Stand
</Button>
);
}
const params = new URLSearchParams({ colonyId: next.id, round: round.apiaryId });
return (
<Button
size="touch"
className="min-h-14 text-base"
nativeButton={false}
render={<Link href={`/voice/new?${params.toString()}`} />}
>
Weiter mit {next.code}
<ArrowRight aria-hidden />
</Button>
);
}
interface CaptureFormProps {
initialColonyId: string;
initialDate: string;
roundApiaryId: string | null;
}
function VoiceCaptureForm({ initialColonyId, initialDate, roundApiaryId }: CaptureFormProps) {
const [colonyId, setColonyId] = useState(initialColonyId);
const [date, setDate] = useState(initialDate);
const [editing, setEditing] = useState(initialColonyId === "");
const [busy, setBusy] = useState(false);
const [savedRound, setSavedRound] = useState<StandRound | null>(null);
const stand = useStandRound(roundApiaryId, colonyId);
const { data: colonies } = useQuery({ queryKey: ["colonies"], queryFn: () => fetchColonies() });
const colonyCode = colonies?.find((colony) => colony.id === colonyId)?.code;
const heading = colonyId ? `${colonyCode ?? "Volk"} · ${shortDay(date)}` : "Volk wählen";
const target = backTarget(initialColonyId);
return (
<main className="flex flex-col gap-5 p-4 sm:p-6">
<FormBackLink href={target.href} label={target.label} dirty={busy} />
<PageHeader icon={Mic} title="Sprachnotiz" description="Sprich in ganzen Sätzen, nur du." />
<section aria-label="Volk und Datum" className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<p className="font-heading text-xl font-semibold">{heading}</p>
<Button
type="button"
size="touch"
variant="outline"
className="min-h-14"
disabled={busy}
aria-expanded={editing}
onClick={() => setEditing((open) => !open)}
>
Ändern
</Button>
</div>
{editing ? (
// Colony and date are fixed while audio is being recorded or waits to be saved.
<fieldset
disabled={busy}
className="flex min-w-0 flex-col gap-3 rounded-2xl border border-border bg-card p-4"
>
<legend className="sr-only">Volk und Datum ändern</legend>
<LivingColonySelectField
triggerId="voice-colony"
value={colonyId}
onChange={setColonyId}
/>
<Field id="voice-date" label="Datum" required>
{({ id, describedBy, invalid }) => (
<Input
id={id}
type="date"
value={date}
min={earliestDay()}
max={todayLocalIsoDate()}
aria-describedby={describedBy}
aria-invalid={invalid || undefined}
onChange={(event) => event.target.value && setDate(event.target.value)}
/>
)}
</Field>
</fieldset>
) : null}
</section>
<VoiceRecorder
colonyId={colonyId}
inspectionDate={date}
typeInsteadHref={inspectionHref(colonyId, roundApiaryId)}
blockedReason={colonyId ? null : NO_COLONY_REASON}
onBusyChange={setBusy}
onSaved={(outcome) => {
if (outcome.kind !== "rejected" && stand.round) {
setSavedRound(stand.markDone(colonyId));
}
}}
savedActions={
savedRound ? (
<RoundNext round={savedRound} next={stand.next(savedRound)} onEnd={stand.end} />
) : null
}
/>
<section aria-labelledby="voice-topics" className="flex flex-col gap-2">
<h2 id="voice-topics" className="text-sm font-medium text-muted-foreground">
Worüber sprechen?
</h2>
<ul className="flex flex-wrap gap-2">
{TOPICS.map((topic) => (
<li key={topic} className="rounded-full bg-muted px-3 py-1 text-sm">
{topic}
</li>
))}
</ul>
<p className="text-sm text-muted-foreground">Das ist eine Gedächtnisstütze, keine Pflicht.</p>
</section>
</main>
);
}
function Notice({ children }: { children: string }) {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader icon={Mic} title="Sprachnotiz" />
<p role="alert" className="rounded-xl border border-border bg-card p-4 text-sm">
{children}
</p>
<Button
size="touch"
className="min-h-14 self-start"
nativeButton={false}
render={<Link href="/inspections/new" />}
>
Durchsicht eintippen
</Button>
</main>
);
}
export default function NewVoiceNotePage() {
const { hasPermission } = useAuth();
const searchParams = useSearchParams();
const allowed = hasPermission("voice-captures:use") && hasPermission("inspections:create");
const recordingSwitch = useVoiceRecordingSwitch(allowed);
const colonyId = searchParams.get("colonyId") ?? "";
const round = searchParams.get("round");
const date = validDay(searchParams.get("date"));
if (!allowed) {
return <Notice>Für Sprachnotizen fehlt dir die Berechtigung.</Notice>;
}
if (recordingSwitch === "off") {
return (
<Notice>
Sprachnotizen sind gerade ausgeschaltet. Du kannst die Durchsicht eintippen.
</Notice>
);
}
// A new colony or round remounts the form: a fresh recording, no stale state.
return (
<VoiceCaptureForm
key={`${colonyId}:${round ?? ""}:${date}`}
initialColonyId={colonyId}
initialDate={date}
roundApiaryId={round}
/>
);
}
@@ -0,0 +1,347 @@
import "fake-indexeddb/auto";
import { IDBFactory } from "fake-indexeddb";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider, onlineManager } from "@tanstack/react-query";
import { toast } from "sonner";
import axe from "axe-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
discardVoiceCapture,
fetchVoiceCaptures,
fetchVoiceConfig,
retryVoiceCapture,
type VoiceCaptureListItem,
} from "@/lib/voice/api";
import {
discardAllPendingVoice,
discardPendingVoice,
markVoiceBlocked,
markVoiceFailed,
queueVoiceNote,
} from "@/lib/voice/pending-voice";
import VoiceListPage from "./page";
let permissions = new Set<string>();
let connectivity: "online" | "offline" = "online";
vi.mock("@/lib/offline/connectivity", () => ({ useConnectivity: () => connectivity }));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock("@/lib/voice/pending-voice", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/voice/pending-voice")>();
return { ...actual, discardPendingVoice: vi.fn(actual.discardPendingVoice) };
});
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({ hasPermission: (key: string) => permissions.has(key) }),
useCurrentUserId: () => "user-1",
}));
vi.mock("@/lib/colonies/api", () => ({
fetchColonies: vi.fn().mockResolvedValue([
{ id: "c-1", code: "B3" },
{ id: "c-2", code: "B4" },
]),
}));
vi.mock("@/lib/voice/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/voice/api")>()),
fetchVoiceCaptures: vi.fn(),
fetchVoiceConfig: vi.fn(),
retryVoiceCapture: vi.fn(),
discardVoiceCapture: vi.fn(),
}));
const CONFIG = {
recordingEnabled: true,
extractionEnabled: true,
model: "claude-sonnet-5-5",
retention: { audioHours: 24, contentDays: 7, rowDays: 90 },
};
function item(overrides: Partial<VoiceCaptureListItem>): VoiceCaptureListItem {
return {
id: "v-1",
colonyId: "c-1",
colonyName: "B3",
status: "READY",
source: "AUDIO",
createdAt: "2026-10-14T10:12:00.000Z",
lastErrorCode: null,
contentExpiresAt: "2026-10-21T10:12:00.000Z",
...overrides,
};
}
function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<VoiceListPage />
</QueryClientProvider>,
);
}
describe("VoiceListPage", () => {
beforeEach(async () => {
globalThis.indexedDB = new IDBFactory();
// The module keeps its first connection: empty the store instead of the factory.
await discardAllPendingVoice("user-1");
connectivity = "online";
vi.mocked(toast.error).mockReset();
permissions = new Set(["voice-captures:use", "inspections:create"]);
vi.mocked(fetchVoiceConfig).mockReset().mockResolvedValue(CONFIG);
vi.mocked(fetchVoiceCaptures).mockReset().mockResolvedValue([]);
vi.mocked(retryVoiceCapture).mockReset().mockResolvedValue(undefined);
vi.mocked(discardVoiceCapture).mockReset().mockResolvedValue(undefined);
});
it("the list shows status words and Prüfen only when ready", async () => {
vi.mocked(fetchVoiceCaptures).mockResolvedValue([
item({ id: "v-1", status: "READY" }),
item({ id: "v-2", colonyId: "c-2", colonyName: "B4", status: "QUEUED" }),
item({ id: "v-3", colonyName: "B5", status: "EXTRACTING" }),
item({ id: "v-4", colonyName: "B6", status: "FAILED", lastErrorCode: "NO_SPEECH" }),
item({ id: "v-5", colonyName: "B7", status: "CONFIRMED" }),
]);
renderPage();
await screen.findByText("Fertig – prüfen");
expect(screen.getByText("Wird abgeschrieben")).toBeInTheDocument();
expect(screen.getByText("Wird ausgewertet")).toBeInTheDocument();
expect(screen.getByText("Fehlgeschlagen")).toBeInTheDocument();
expect(
screen.getByText("Es war nichts zu verstehen. Bitte noch einmal aufnehmen."),
).toBeInTheDocument();
// A confirmed note is done and not listed.
expect(screen.queryByText("B7")).not.toBeInTheDocument();
const review = screen.getAllByRole("button", { name: "Prüfen" });
expect(review).toHaveLength(1);
expect(review[0]).toHaveAttribute("href", "/inspections/new?colonyId=c-1&voice=v-1");
});
it("offers Text ansehen when the evaluation is off but the text exists", async () => {
vi.mocked(fetchVoiceCaptures).mockResolvedValue([
item({ status: "TRANSCRIBED", lastErrorCode: "EXTRACT_DISABLED" }),
]);
renderPage();
expect(await screen.findByText("Text liegt vor")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Text ansehen" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Prüfen" })).not.toBeInTheDocument();
});
it("shows the empty state", async () => {
renderPage();
expect(await screen.findByText("Noch keine Sprachnotizen.")).toBeInTheDocument();
});
it("shows a failed load as an error, not as empty", async () => {
vi.mocked(fetchVoiceCaptures).mockRejectedValue(new Error("boom"));
renderPage();
expect(await screen.findByRole("alert")).toHaveTextContent("konnten nicht geladen werden");
expect(screen.queryByText("Noch keine Sprachnotizen.")).not.toBeInTheDocument();
});
it("lists recordings that wait on the device first, with their own status", async () => {
connectivity = "offline";
vi.mocked(fetchVoiceCaptures).mockResolvedValue([item({ id: "v-1", colonyName: "B9" })]);
await queueVoiceNote({
ownerUserId: "user-1",
blob: new Blob(["a"]),
mime: "audio/webm",
durationSec: 20,
colonyId: "c-2",
inspectionDate: "2026-10-16",
});
renderPage();
const items = await screen.findAllByRole("listitem");
expect(within(items[0]).getByText("B4")).toBeInTheDocument();
expect(within(items[0]).getByText("Wartet auf Netz")).toBeInTheDocument();
expect(within(items[1]).getByText("B9")).toBeInTheDocument();
});
it("asks before discarding and then discards", async () => {
vi.mocked(fetchVoiceCaptures).mockResolvedValue([item({})]);
const user = userEvent.setup();
renderPage();
await user.click(await screen.findByRole("button", { name: /Mehr zu B3/ }));
await user.click(await screen.findByRole("menuitem", { name: "Verwerfen" }));
const dialog = await screen.findByRole("alertdialog");
expect(discardVoiceCapture).not.toHaveBeenCalled();
await user.click(within(dialog).getByRole("button", { name: "Verwerfen" }));
await waitFor(() => expect(discardVoiceCapture).toHaveBeenCalledWith("v-1"));
});
it("retries a failed evaluation", async () => {
vi.mocked(fetchVoiceCaptures).mockResolvedValue([
item({ status: "FAILED", lastErrorCode: "EXTRACT_FAILED" }),
]);
const user = userEvent.setup();
renderPage();
await user.click(await screen.findByRole("button", { name: "Nochmal versuchen" }));
await waitFor(() => expect(retryVoiceCapture).toHaveBeenCalledWith("v-1"));
});
it("offers no retry when nothing is left to evaluate", async () => {
vi.mocked(fetchVoiceCaptures).mockResolvedValue([
item({ status: "FAILED", lastErrorCode: "AUDIO_EXPIRED" }),
]);
renderPage();
await screen.findByText("Fehlgeschlagen");
expect(screen.queryByRole("button", { name: "Nochmal versuchen" })).not.toBeInTheDocument();
});
it("offers no new note when recording is switched off, and says so", async () => {
vi.mocked(fetchVoiceConfig).mockResolvedValue({ ...CONFIG, recordingEnabled: false });
renderPage();
expect(await screen.findByText(/Sprachnotizen sind gerade ausgeschaltet/)).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Neue Sprachnotiz/ })).not.toBeInTheDocument();
});
it("offers a new note when recording is on", async () => {
renderPage();
const link = await screen.findByRole("button", { name: /Neue Sprachnotiz/ });
expect(link).toHaveAttribute("href", "/voice/new");
});
it("says so without the permission", () => {
permissions = new Set(["voice-captures:use"]);
renderPage();
expect(screen.getByRole("alert")).toHaveTextContent("fehlt dir die Berechtigung");
expect(fetchVoiceCaptures).not.toHaveBeenCalled();
});
describe("recordings on the device", () => {
async function queue(overrides: Partial<Parameters<typeof queueVoiceNote>[0]> = {}) {
return queueVoiceNote({
ownerUserId: "user-1",
blob: new Blob(["a"]),
mime: "audio/webm",
durationSec: 20,
colonyId: "c-2",
inspectionDate: "2026-10-12",
...overrides,
});
}
it("says Wartet auf Netz offline and Wird gesendet online", async () => {
await queue();
connectivity = "offline";
const view = renderPage();
expect(await screen.findByText("Wartet auf Netz")).toBeInTheDocument();
view.unmount();
connectivity = "online";
renderPage();
expect(await screen.findByText("Wird gesendet")).toBeInTheDocument();
expect(screen.queryByText("Wartet auf Netz")).not.toBeInTheDocument();
});
it("says why a note waits when the server holds too many open notes", async () => {
const note = await queue();
await markVoiceBlocked(note.id, "TOO_MANY_OPEN");
renderPage();
expect(await screen.findByText("Wartet auf freien Platz")).toBeInTheDocument();
expect(screen.getByText(/Zu viele offene Sprachnotizen/)).toBeInTheDocument();
});
it("shows a refused note as failed, keeps its day for a new recording", async () => {
const note = await queue();
await markVoiceFailed(note.id, {
status: 413,
message: "x",
details: { code: "TOO_LARGE" },
at: "2026-10-16T08:00:00.000Z",
} as never);
renderPage();
expect(await screen.findByText("Fehlgeschlagen")).toBeInTheDocument();
expect(screen.getByText(/Die Sprachnotiz ist zu groß/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Neu aufnehmen" })).toHaveAttribute(
"href",
"/voice/new?colonyId=c-2&date=2026-10-12",
);
});
it("shows a fixed text when discarding on the device fails", async () => {
await queue();
vi.mocked(discardPendingVoice).mockRejectedValueOnce(new Error("db gone"));
const user = userEvent.setup();
renderPage();
await user.click(await screen.findByRole("button", { name: /Mehr zu B4/ }));
await user.click(await screen.findByRole("menuitem", { name: "Verwerfen" }));
await user.click(
within(await screen.findByRole("alertdialog")).getByRole("button", { name: "Verwerfen" }),
);
await waitFor(() =>
expect(toast.error).toHaveBeenCalledWith("Die Sprachnotiz konnte nicht verworfen werden."),
);
});
});
it("says offline instead of showing an error when the server cannot be asked", async () => {
onlineManager.setOnline(false);
try {
renderPage();
expect(await screen.findByText(/Du bist offline/)).toBeInTheDocument();
expect(screen.queryByText(/konnten nicht geladen werden/)).not.toBeInTheDocument();
expect(screen.queryByText("Noch keine Sprachnotizen.")).not.toBeInTheDocument();
} finally {
onlineManager.setOnline(true);
}
});
it("reads a transcribed note as Text liegt vor when the evaluation is off", async () => {
vi.mocked(fetchVoiceConfig).mockResolvedValue({ ...CONFIG, extractionEnabled: false });
vi.mocked(fetchVoiceCaptures).mockResolvedValue([item({ status: "TRANSCRIBED" })]);
renderPage();
expect(await screen.findByText("Text liegt vor")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Text ansehen" })).toBeInTheDocument();
});
it("lets the user read the text of a failed evaluation and try again", async () => {
vi.mocked(fetchVoiceCaptures).mockResolvedValue([
item({ status: "FAILED", lastErrorCode: "EXTRACT_FAILED" }),
]);
renderPage();
expect(await screen.findByRole("button", { name: "Text ansehen" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Nochmal versuchen" })).toBeInTheDocument();
});
it("gives the actions a 56 px target", async () => {
vi.mocked(fetchVoiceCaptures).mockResolvedValue([item({})]);
renderPage();
expect(await screen.findByRole("button", { name: /Neue Sprachnotiz/ })).toHaveClass("min-h-14");
expect(screen.getByRole("button", { name: "Prüfen" })).toHaveClass("min-h-14");
});
it("axe finds nothing", async () => {
vi.mocked(fetchVoiceCaptures).mockResolvedValue([
item({}),
item({ id: "v-2", colonyName: "B4", status: "FAILED", lastErrorCode: "EXTRACT_FAILED" }),
]);
const { container } = renderPage();
await screen.findByText("Fertig – prüfen");
const results = await axe.run(container, { rules: { "color-contrast": { enabled: false } } });
expect(results.violations).toEqual([]);
});
});
@@ -0,0 +1,199 @@
"use client";
import Link from "next/link";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Mic } from "lucide-react";
import { toast } from "sonner";
import { isNetworkError } from "@/lib/http/fetch-with-timeout";
import { fetchColonies } from "@/lib/colonies/api";
import { useAuth, useCurrentUserId } from "@/lib/auth/auth-context";
import { OFFLINE_QUEUE_QUERY_KEY } from "@/lib/offline/use-capture-mutation";
import {
discardVoiceCapture,
fetchVoiceCaptures,
retryVoiceCapture,
voiceKeys,
type VoiceCaptureListItem,
} from "@/lib/voice/api";
import { discardPendingVoice, listPendingVoice } from "@/lib/voice/pending-voice";
import { isListedVoiceStatus, isVoiceInProgress } from "@/lib/voice/status-texts";
import { useConnectivity } from "@/lib/offline/connectivity";
import {
offersRecording,
useVoiceConfig,
useVoiceRecordingSwitch,
} from "@/lib/voice/use-voice-recording-switch";
import { LocalVoiceCard, VoiceCaptureCard } from "@/components/voice/voice-capture-card";
import { EmptyState } from "@/components/empty-state";
import { PageHeader } from "@/components/page-header";
import { ErrorState, LoadingState } from "@/components/query-state";
import { Button } from "@/components/ui/button";
const POLL_MS = 15_000;
const LOCAL_POLL_MS = 5_000;
const PENDING_KEY = [...OFFLINE_QUEUE_QUERY_KEY, "voice-notes"] as const;
export default function VoiceListPage() {
const { hasPermission } = useAuth();
const allowed = hasPermission("voice-captures:use") && hasPermission("inspections:create");
const recordingSwitch = useVoiceRecordingSwitch(allowed);
const newAction =
allowed && offersRecording(recordingSwitch) ? (
<Button
size="touch"
className="min-h-14"
nativeButton={false}
render={<Link href="/voice/new" />}
>
<Mic aria-hidden />
Neue Sprachnotiz
</Button>
) : null;
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={Mic}
title="Sprachnotizen"
description="Aufnahmen, die noch warten, und fertige Vorschläge zum Prüfen."
actions={newAction}
/>
{allowed ? (
<>
{recordingSwitch === "off" ? (
<p role="status" className="rounded-xl border border-border bg-card p-4 text-sm">
Sprachnotizen sind gerade ausgeschaltet. Neue Aufnahmen sind nicht möglich.
Bestehende Notizen bleiben hier sichtbar.
</p>
) : null}
<VoiceNotes />
</>
) : (
<p role="alert" className="rounded-xl border border-border bg-card p-4 text-sm">
Für Sprachnotizen fehlt dir die Berechtigung. Bitte wende dich an die Verwaltung.
</p>
)}
</main>
);
}
function OfflineNote() {
return (
<p role="status" className="rounded-xl border border-border bg-card p-4 text-sm">
Du bist offline. Notizen vom Server erscheinen wieder, sobald du Netz hast.
</p>
);
}
function VoiceNotes() {
const userId = useCurrentUserId();
const queryClient = useQueryClient();
const online = useConnectivity() === "online";
// Unknown (not loaded yet, offline) counts as "on": the common case.
const extractionEnabled = useVoiceConfig(true).data?.extractionEnabled ?? true;
const serverQuery = useQuery({
queryKey: voiceKeys.all,
queryFn: () => fetchVoiceCaptures(),
refetchInterval: (query) =>
query.state.data?.some((item) => isVoiceInProgress(item.status, extractionEnabled))
? POLL_MS
: false,
});
const localQuery = useQuery({
queryKey: [...PENDING_KEY, userId],
queryFn: () => listPendingVoice(userId ?? ""),
enabled: userId !== null,
// Reads this device's storage, never the network.
networkMode: "always",
refetchInterval: (query) => (query.state.data?.length ? LOCAL_POLL_MS : false),
});
const coloniesQuery = useQuery({ queryKey: ["colonies"], queryFn: () => fetchColonies() });
const retry = useMutation({
mutationFn: (id: string) => retryVoiceCapture(id),
onSuccess: () => void queryClient.invalidateQueries({ queryKey: voiceKeys.all }),
onError: () => toast.error("Das hat nicht geklappt. Bitte gleich noch einmal versuchen."),
});
const discard = useMutation({
mutationFn: (id: string) => discardVoiceCapture(id),
onSuccess: () => void queryClient.invalidateQueries({ queryKey: voiceKeys.all }),
onError: () => toast.error("Die Sprachnotiz konnte nicht verworfen werden."),
});
const discardLocal = useMutation({
mutationFn: (id: string) => discardPendingVoice(id, userId ?? ""),
onSuccess: () => void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY }),
onError: () => toast.error("Die Sprachnotiz konnte nicht verworfen werden."),
});
const colonyName = (colonyId: string): string =>
coloniesQuery.data?.find((colony) => colony.id === colonyId)?.code ?? "Volk";
const local = localQuery.data ?? [];
const listed: VoiceCaptureListItem[] = (serverQuery.data ?? []).filter((item) =>
isListedVoiceStatus(item.status),
);
if (serverQuery.isLoading && local.length === 0) {
return <LoadingState what="Sprachnotizen" />;
}
// No answer because there is no network: not an error, the notes appear with the network.
const serverOffline =
serverQuery.data === undefined &&
(serverQuery.fetchStatus === "paused" || isNetworkError(serverQuery.error));
const serverFailed = serverQuery.data === undefined && !serverQuery.isLoading && !serverOffline;
if (local.length === 0 && listed.length === 0) {
if (serverOffline) {
return <OfflineNote />;
}
return serverFailed ? (
<ErrorState
what="Sprachnotizen"
onRetry={() => void serverQuery.refetch()}
isRetrying={serverQuery.isFetching}
/>
) : (
<EmptyState
icon={Mic}
title="Noch keine Sprachnotizen."
description="Nimm beim Volk eine Sprachnotiz auf. Sie erscheint hier, bis du sie geprüft hast."
/>
);
}
return (
<div className="flex flex-col gap-3">
{serverOffline ? <OfflineNote /> : null}
{serverFailed ? (
<ErrorState
what="Sprachnotizen vom Server"
onRetry={() => void serverQuery.refetch()}
isRetrying={serverQuery.isFetching}
/>
) : null}
<ul className="flex flex-col gap-3" aria-label="Sprachnotizen">
{local.map((note) => (
<li key={note.id}>
<LocalVoiceCard
note={note}
colonyName={colonyName(note.colonyId)}
online={online}
onDiscard={() => discardLocal.mutate(note.id)}
/>
</li>
))}
{listed.map((item) => (
<li key={item.id}>
<VoiceCaptureCard
item={item}
extractionEnabled={extractionEnabled}
busy={retry.isPending || discard.isPending}
onRetry={() => retry.mutate(item.id)}
onDiscard={() => discard.mutate(item.id)}
/>
</li>
))}
</ul>
</div>
);
}
@@ -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>
);
}
@@ -104,6 +104,17 @@ describe("rejectionText", () => {
expect(voiceRejectionText({ status, message: "x", details: { code }, at })).toBe(text);
});
it("words a switched-off recording as a definitive refusal", () => {
expect(
voiceRejectionText({
status: 403,
message: "x",
details: { code: "VOICE_RECORDING_DISABLED" },
at,
}),
).toBe("Sprachnotizen sind gerade ausgeschaltet. Bitte tippe die Durchsicht ein.");
});
it("falls back on the status and never shows the raw server message", () => {
expect(voiceRejectionText({ status: 403, message: "Forbidden", at })).toBe(
"Keine Berechtigung für Sprachnotizen.",
@@ -101,7 +101,12 @@ const VOICE_NO_LONGER_FITS = "Die Sprachnotiz passt nicht mehr zur Durchsicht. B
export const VOICE_TOO_MANY_OPEN_HINT =
"Zu viele offene Sprachnotizen – sie werden später gesendet.";
/** The operator switched voice recording off; the one place this sentence lives. */
export const VOICE_RECORDING_DISABLED_TEXT =
"Sprachnotizen sind gerade ausgeschaltet. Bitte tippe die Durchsicht ein.";
const VOICE_REJECTION_BY_CODE: Record<string, string> = {
VOICE_RECORDING_DISABLED: VOICE_RECORDING_DISABLED_TEXT,
TOO_LARGE: "Die Sprachnotiz ist zu groß. Bitte kürzer aufnehmen.",
UNSUPPORTED_AUDIO: "Dieses Audioformat wird nicht unterstützt. Bitte neu aufnehmen.",
TOO_MANY_OPEN: "Zu viele offene Sprachnotizen. Erst auswerten oder verwerfen.",
@@ -0,0 +1,52 @@
import "fake-indexeddb/auto";
import { IDBFactory } from "fake-indexeddb";
import { beforeEach, describe, expect, it, vi } from "vitest";
const syncPendingEntries = vi.fn();
vi.mock("@/lib/offline/sync", () => ({
syncPendingEntries: (...args: unknown[]) => syncPendingEntries(...args),
}));
const recording = {
blob: new Blob(["audio"], { type: "audio/webm" }),
mime: "audio/webm",
durationSec: 30,
};
const base = { userId: "user-1", recording, colonyId: "c-1", inspectionDate: "2026-10-16" };
let listPendingVoice: typeof import("./pending-voice").listPendingVoice;
let saveVoiceNote: typeof import("./save-voice-note").saveVoiceNote;
describe("saveVoiceNote", () => {
beforeEach(async () => {
globalThis.indexedDB = new IDBFactory();
vi.resetModules();
({ listPendingVoice } = await import("./pending-voice"));
({ saveVoiceNote } = await import("./save-voice-note"));
syncPendingEntries.mockReset().mockResolvedValue({ synced: 0, failed: 0, skippedForeign: 0 });
});
it("queues offline without syncing and reports the note id", async () => {
const onQueued = vi.fn();
const outcome = await saveVoiceNote({ ...base, online: false, onQueued });
expect(outcome).toEqual({ kind: "queued" });
const [note] = await listPendingVoice("user-1");
expect(onQueued).toHaveBeenCalledWith(note.id);
expect(syncPendingEntries).not.toHaveBeenCalled();
});
it("does not queue a second note when the same recording is saved again", async () => {
syncPendingEntries.mockRejectedValueOnce(new Error("sync broke"));
const onQueued = vi.fn();
const first = await saveVoiceNote({ ...base, online: true, onQueued });
expect(first).toEqual({ kind: "queued" });
const queuedNoteId = onQueued.mock.calls[0][0] as string;
await saveVoiceNote({ ...base, online: true, queuedNoteId });
expect(await listPendingVoice("user-1")).toHaveLength(1);
expect(syncPendingEntries).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,67 @@
import { syncPendingEntries } from "@/lib/offline/sync";
import { VOICE_TOO_MANY_OPEN_HINT, voiceRejectionText } from "@/lib/offline/queue-texts";
import type { Recording } from "./recorder";
import { getPendingVoice, queueVoiceNote } from "./pending-voice";
/*
* Saving a finished recording: it always goes to the device queue first, so a
* lost connection never loses it. Online, the sync is asked to send it right
* away; what the server did is read back from the queue.
*/
export type SaveVoiceOutcome =
| { kind: "sent" }
| { kind: "queued"; hint?: string }
| { kind: "rejected"; noteId: string; text: string };
export interface SaveVoiceNoteInput {
userId: string;
recording: Recording;
colonyId: string;
inspectionDate: string;
online: boolean;
/** The note this recording already has on the device (a retry must not queue a second one). */
queuedNoteId?: string | null;
/** Called as soon as the recording is on the device. */
onQueued?(noteId: string): void;
}
export async function saveVoiceNote(input: SaveVoiceNoteInput): Promise<SaveVoiceOutcome> {
const { userId, recording, colonyId, inspectionDate, online, onQueued } = input;
let noteId = input.queuedNoteId ?? null;
if (!noteId) {
const note = await queueVoiceNote({
ownerUserId: userId,
blob: recording.blob,
mime: recording.mime,
durationSec: recording.durationSec,
colonyId,
inspectionDate,
});
noteId = note.id;
onQueued?.(noteId);
}
if (!online) {
return { kind: "queued" };
}
try {
await syncPendingEntries(userId, { afterChange: true });
} catch {
// The note is safe in the queue; the next sync run sends it.
return { kind: "queued" };
}
let after;
try {
after = await getPendingVoice(noteId);
} catch {
// Unknown what the sync did; the note id is kept, so a retry only reads again.
return { kind: "queued" };
}
if (!after) {
return { kind: "sent" };
}
if (after.lastError) {
return { kind: "rejected", noteId, text: voiceRejectionText(after.lastError) };
}
return after.blockedBy ? { kind: "queued", hint: VOICE_TOO_MANY_OPEN_HINT } : { kind: "queued" };
}
@@ -0,0 +1,106 @@
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
VOICE_ERROR_TEXTS_DE,
VOICE_STATUS_TEXTS_DE,
VOICE_WAITING_FOR_NETWORK,
describeVoiceCapture,
isListedVoiceStatus,
isVoiceInProgress,
voiceErrorText,
} from "./status-texts";
const backendTexts = path.resolve(process.cwd(), "../ApiCore-Backend/src/voice/voice-error-texts.ts");
describe("voice status texts", () => {
// Same sentences as the backend (notifications) say.
it.skipIf(!existsSync(backendTexts))("equals the backend error texts", () => {
const source = readFileSync(backendTexts, "utf8");
const backend = new Map(
[...source.matchAll(/([A-Z_]+):\s*'([^']*)'/g)].map((m) => [m[1], m[2]]),
);
expect(backend.size).toBeGreaterThan(10);
for (const [code, text] of backend) {
expect(VOICE_ERROR_TEXTS_DE[code], code).toBe(text);
}
});
it("points at the retry button where the list offers one", () => {
expect(VOICE_ERROR_TEXTS_DE.TRANSCRIBE_FAILED).toContain("Nochmal versuchen");
expect(VOICE_ERROR_TEXTS_DE.EXTRACT_FAILED).toContain("erneut auswerten");
});
it("lets the user read the text of a failed evaluation", () => {
for (const lastErrorCode of ["EXTRACT_FAILED", "EXTRACT_INVALID", "REFUSED"]) {
expect(describeVoiceCapture({ status: "FAILED", lastErrorCode }).action).toBe("read-text");
}
expect(describeVoiceCapture({ status: "FAILED", lastErrorCode: "NO_SPEECH" }).action).toBe("none");
});
it("says Text liegt vor when the evaluation is switched off", () => {
const state = describeVoiceCapture({ status: "TRANSCRIBED", lastErrorCode: null }, false);
expect(state.status.label).toBe("Text liegt vor");
expect(state.action).toBe("read-text");
expect(describeVoiceCapture({ status: "TRANSCRIBED", lastErrorCode: null }, true).status.label).toBe(
"Wird ausgewertet",
);
expect(isVoiceInProgress("TRANSCRIBED", false)).toBe(false);
expect(isVoiceInProgress("TRANSCRIBED", true)).toBe(true);
});
it("names every listed state in words with an icon", () => {
expect(VOICE_WAITING_FOR_NETWORK.label).toBe("Wartet auf Netz");
expect(VOICE_STATUS_TEXTS_DE.QUEUED.label).toBe("Wird abgeschrieben");
expect(VOICE_STATUS_TEXTS_DE.EXTRACTING.label).toBe("Wird ausgewertet");
expect(VOICE_STATUS_TEXTS_DE.READY.label).toBe("Fertig – prüfen");
expect(VOICE_STATUS_TEXTS_DE.FAILED.label).toBe("Fehlgeschlagen");
for (const entry of Object.values(VOICE_STATUS_TEXTS_DE)) {
expect(entry.icon).toBeTruthy();
}
});
it("offers Prüfen only for a ready note", () => {
expect(describeVoiceCapture({ status: "READY", lastErrorCode: null }).action).toBe("review");
for (const status of ["QUEUED", "TRANSCRIBING", "TRANSCRIBED", "EXTRACTING", "FAILED"] as const) {
expect(describeVoiceCapture({ status, lastErrorCode: null }).action).toBe("none");
}
});
it("lets the user read the text when the evaluation is off", () => {
const state = describeVoiceCapture({ status: "TRANSCRIBED", lastErrorCode: "EXTRACT_DISABLED" });
expect(state.action).toBe("read-text");
expect(state.status.label).toBe("Text liegt vor");
expect(state.detail).toBe(VOICE_ERROR_TEXTS_DE.EXTRACT_DISABLED);
});
it("allows another try only where one can help", () => {
const canRetry = (lastErrorCode: string) =>
describeVoiceCapture({ status: "FAILED", lastErrorCode }).canRetry;
expect(canRetry("EXTRACT_FAILED")).toBe(true);
expect(canRetry("EXTRACT_INVALID")).toBe(true);
expect(canRetry("NO_SPEECH")).toBe(false);
expect(canRetry("AUDIO_EXPIRED")).toBe(false);
expect(describeVoiceCapture({ status: "READY", lastErrorCode: null }).canRetry).toBe(false);
});
it("uses fixed sentences for codes, never the server message", () => {
expect(voiceErrorText("TOO_LONG")).toBe(VOICE_ERROR_TEXTS_DE.TOO_LONG);
expect(voiceErrorText("SOMETHING_NEW")).toContain("nicht verarbeitet");
expect(voiceErrorText(null)).toContain("nicht verarbeitet");
});
it("lists open and failed notes and polls only the running ones", () => {
expect(isListedVoiceStatus("READY")).toBe(true);
expect(isListedVoiceStatus("FAILED")).toBe(true);
expect(isListedVoiceStatus("CONFIRMED")).toBe(false);
expect(isListedVoiceStatus("DISCARDED")).toBe(false);
expect(isListedVoiceStatus("EXPIRED")).toBe(false);
expect(isVoiceInProgress("EXTRACTING")).toBe(true);
expect(isVoiceInProgress("READY")).toBe(false);
});
});
@@ -0,0 +1,147 @@
import { VOICE_RECORDING_DISABLED_TEXT } from "@/lib/offline/queue-texts";
import type { VoiceCaptureListItem, VoiceCaptureStatus } from "./api";
/*
* Words for the voice note list. The state always has a word and an icon
* name; colour never carries it alone. Error texts are fixed sentences per
* server code (mirror of the backend's VOICE_ERROR_TEXTS_DE), never server
* text, so nothing from a note's content can appear here.
*/
export type VoiceStatusIcon = "wait" | "work" | "ready" | "failed";
export interface VoiceStatusText {
label: string;
icon: VoiceStatusIcon;
}
/** A note that still sits on this device, not yet on the server. */
export const VOICE_WAITING_FOR_NETWORK: VoiceStatusText = {
label: "Wartet auf Netz",
icon: "wait",
};
export const VOICE_STATUS_TEXTS_DE: Readonly<
Record<"QUEUED" | "TRANSCRIBING" | "TRANSCRIBED" | "EXTRACTING" | "READY" | "FAILED", VoiceStatusText>
> = {
QUEUED: { label: "Wird abgeschrieben", icon: "work" },
TRANSCRIBING: { label: "Wird abgeschrieben", icon: "work" },
TRANSCRIBED: { label: "Wird ausgewertet", icon: "work" },
EXTRACTING: { label: "Wird ausgewertet", icon: "work" },
READY: { label: "Fertig – prüfen", icon: "ready" },
FAILED: { label: "Fehlgeschlagen", icon: "failed" },
};
/** Statuses the list shows; confirmed, discarded and expired notes are done. */
const LISTED: readonly VoiceCaptureStatus[] = [
"QUEUED",
"TRANSCRIBING",
"TRANSCRIBED",
"EXTRACTING",
"READY",
"FAILED",
];
/** Statuses a poll should wait out. */
const IN_PROGRESS: readonly VoiceCaptureStatus[] = [
"QUEUED",
"TRANSCRIBING",
"TRANSCRIBED",
"EXTRACTING",
];
export function isListedVoiceStatus(status: VoiceCaptureStatus): boolean {
return LISTED.includes(status);
}
/** With the evaluation switched off a transcribed note is done: nothing more will happen. */
export function isVoiceInProgress(status: VoiceCaptureStatus, extractionEnabled = true): boolean {
if (status === "TRANSCRIBED" && !extractionEnabled) {
return false;
}
return IN_PROGRESS.includes(status);
}
/**
* Same sentences as the backend's VOICE_ERROR_TEXTS_DE (a spec compares them).
*/
export const VOICE_ERROR_TEXTS_DE: Readonly<Record<string, string>> = {
UNSUPPORTED_AUDIO: "Das Audioformat wird nicht unterstützt.",
TOO_LARGE: "Die Aufnahme ist zu groß. Bitte kürzer aufnehmen.",
TOO_MANY_OPEN: "Zu viele offene Sprachnotizen. Erst auswerten oder verwerfen.",
NO_SPEECH: "Es war nichts zu verstehen. Bitte noch einmal aufnehmen.",
TOO_LONG: "Die Aufnahme ist zu lang. Bitte kürzer aufnehmen.",
TRANSCRIBE_FAILED:
"Die Aufnahme konnte nicht abgeschrieben werden. Bitte „Nochmal versuchen“ oder noch einmal aufnehmen.",
AUDIO_EXPIRED: "Die Aufnahme wurde nach 24 Stunden gelöscht. Bitte noch einmal aufnehmen.",
EXTRACT_DISABLED: "Die Auswertung ist ausgeschaltet. Bitte die Durchsicht von Hand ausfüllen.",
EXTRACT_RETRY: "Die Auswertung wird noch einmal versucht.",
EXTRACT_FAILED:
"Die Auswertung hat nicht geklappt. Bitte erneut auswerten oder von Hand ausfüllen.",
DAILY_LIMIT: "Das Tageslimit für die Auswertung ist erreicht. Bitte morgen erneut auswerten.",
EXTRACT_INVALID:
"Die Auswertung war nicht brauchbar. Bitte erneut auswerten oder von Hand ausfüllen.",
REFUSED: "Die Auswertung wurde abgelehnt. Bitte die Durchsicht von Hand ausfüllen.",
VOICE_CAPTURE_GONE: "Die Sprachnotiz ist nicht mehr vorhanden.",
VOICE_RECORDING_DISABLED: VOICE_RECORDING_DISABLED_TEXT,
};
const GENERIC_FAILURE = "Die Sprachnotiz konnte nicht verarbeitet werden. Bitte noch einmal aufnehmen.";
/** Codes after which a second try of the same note can work. */
const RETRYABLE_CODES: readonly string[] = ["EXTRACT_FAILED", "EXTRACT_INVALID", "TRANSCRIBE_FAILED"];
/** Codes with which the text exists but could not be evaluated: the form still shows it. */
const TEXT_ONLY_CODES: readonly string[] = ["EXTRACT_DISABLED", "DAILY_LIMIT"];
/** Failed evaluations still leave the transcript to read. */
const TEXT_KEPT_ON_FAILURE: readonly string[] = ["EXTRACT_FAILED", "EXTRACT_INVALID", "REFUSED"];
export function voiceErrorText(code: string | null): string {
return (code && VOICE_ERROR_TEXTS_DE[code]) || GENERIC_FAILURE;
}
export type VoiceCardAction = "review" | "read-text" | "none";
export interface VoiceCardState {
status: VoiceStatusText;
/** One short sentence for a failure or a pause; null when nothing is wrong. */
detail: string | null;
action: VoiceCardAction;
canRetry: boolean;
}
/**
* What the card of a server-side note says and offers. `extractionEnabled`
* false (the operator switched the evaluation off) turns a plain TRANSCRIBED
* note into „Text liegt vor“.
*/
export function describeVoiceCapture(
item: Pick<VoiceCaptureListItem, "status" | "lastErrorCode">,
extractionEnabled = true,
): VoiceCardState {
const code = item.lastErrorCode;
const textOnly =
item.status === "TRANSCRIBED" && ((code && TEXT_ONLY_CODES.includes(code)) || !extractionEnabled);
if (textOnly) {
return {
status: { label: "Text liegt vor", icon: "wait" },
detail: code && TEXT_ONLY_CODES.includes(code) ? voiceErrorText(code) : null,
action: "read-text",
canRetry: false,
};
}
const status = VOICE_STATUS_TEXTS_DE[item.status as keyof typeof VOICE_STATUS_TEXTS_DE];
let action: VoiceCardAction = "none";
if (item.status === "READY") {
action = "review";
} else if (item.status === "FAILED" && code !== null && TEXT_KEPT_ON_FAILURE.includes(code)) {
action = "read-text";
}
return {
status: status ?? VOICE_STATUS_TEXTS_DE.FAILED,
detail: item.status === "FAILED" ? voiceErrorText(code) : null,
action,
canRetry: item.status === "FAILED" && code !== null && RETRYABLE_CODES.includes(code),
};
}
@@ -96,6 +96,20 @@ describe("uploadPendingVoice", () => {
expect(notes.every((n) => n.lastError === undefined)).toBe(true);
});
it("keeps a 403 VOICE_RECORDING_DISABLED as a definitive refusal on the note", async () => {
const note = await pending.queueVoiceNote(input());
uploadVoiceCapture.mockRejectedValueOnce(
new ApiError(403, "off", { code: "VOICE_RECORDING_DISABLED" }),
);
const result = await upload.uploadPendingVoice("user-1", never);
expect(result.failed).toBe(1);
const [kept] = await pending.listPendingVoice("user-1");
expect(kept.id).toBe(note.id);
expect(kept.lastError?.status).toBe(403);
});
it("a 409 'still being processed' skips that note and goes on", async () => {
await pending.queueVoiceNote(input());
await pending.queueVoiceNote(input("user-1", "colony-2"));
@@ -0,0 +1,49 @@
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ApiError } from "@/lib/auth/api-client";
import { fetchVoiceConfig } from "./api";
import { useVoiceRecordingSwitch } from "./use-voice-recording-switch";
vi.mock("./api", async (importOriginal) => ({
...(await importOriginal<typeof import("./api")>()),
fetchVoiceConfig: vi.fn(),
}));
function wrapper({ children }: { children: ReactNode }) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}
describe("useVoiceRecordingSwitch", () => {
beforeEach(() => {
vi.mocked(fetchVoiceConfig).mockReset();
});
it("is unknown when the server cannot be reached", async () => {
vi.mocked(fetchVoiceConfig).mockRejectedValue(new TypeError("Failed to fetch"));
const { result } = renderHook(() => useVoiceRecordingSwitch(true), { wrapper });
await waitFor(() => expect(result.current).toBe("unknown"));
});
it.each([403, 404, 500])("is off when the server answers %s", async (status) => {
vi.mocked(fetchVoiceConfig).mockRejectedValue(new ApiError(status, "no"));
const { result } = renderHook(() => useVoiceRecordingSwitch(true), { wrapper });
await waitFor(() => expect(result.current).toBe("off"));
});
it("follows recordingEnabled", async () => {
vi.mocked(fetchVoiceConfig).mockResolvedValue({
recordingEnabled: true,
extractionEnabled: true,
model: "m",
retention: { audioHours: 24, contentDays: 7, rowDays: 90 },
});
const { result } = renderHook(() => useVoiceRecordingSwitch(true), { wrapper });
await waitFor(() => expect(result.current).toBe("on"));
});
});
@@ -0,0 +1,49 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { isNetworkError } from "@/lib/http/fetch-with-timeout";
import { fetchVoiceConfig, voiceKeys } from "./api";
/**
* Whether the operator allows voice recording:
* - "loading": the answer is on its way; entry points wait, so a disabled
* feature never flashes a button;
* - "on" / "off": the server's answer;
* - "unknown": the server could not be asked (offline, no connection). Recording and
* the local queue work offline, so entry points stay.
*/
export type RecordingSwitch = "loading" | "on" | "off" | "unknown";
const CONFIG_STALE_MS = 5 * 60 * 1000;
/** The server's voice settings, shared by every entry point (one request, cached). */
export function useVoiceConfig(enabled: boolean) {
return useQuery({
queryKey: voiceKeys.config,
queryFn: fetchVoiceConfig,
enabled,
staleTime: CONFIG_STALE_MS,
retry: false,
});
}
export function useVoiceRecordingSwitch(enabled: boolean): RecordingSwitch {
const query = useVoiceConfig(enabled);
if (query.data) {
return query.data.recordingEnabled ? "on" : "off";
}
// Only "could not ask" keeps the entries. A server answer without a config
// (403, 404, 5xx: feature missing, forbidden, broken) counts as off.
if (query.fetchStatus === "paused" || (query.isError && isNetworkError(query.error))) {
return "unknown";
}
if (query.isError) {
return "off";
}
return "loading";
}
/** Entry points are offered unless the answer is pending or says no. */
export function offersRecording(recordingSwitch: RecordingSwitch): boolean {
return recordingSwitch === "on" || recordingSwitch === "unknown";
}