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

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

Gates: Frontend lint, typecheck, 2979 Tests, build.

Refs #27

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-16 11:49:56 +02:00
1 parent 462ac46d1e
commit e0be687b7c
33 files changed
+3460 -19

No files matched your search

@@ -0,0 +1,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";
}