Compare commits

...
4 Commits
Author SHA1 Message Date
Nick SzaunigandClaude Opus 5.5 92323197a7 fix: Sprachnotizen nach Browserprüfung (Wiedergabe, Fokus, Warteschlange)
Backend Image / build-and-push (push) Successful in 6m2s
Frontend Image / build-and-push (push) Failing after 12m25s
Manifests / validate (push) Successful in 16s
- CSP erlaubt media-src 'self' blob:, damit die eigene Aufnahme
  abspielbar ist; Dauer kommt vom Recorder statt aus den Metadaten
- Fokus nach gesperrtem Mikrofon auf der Hilfe, nach Verwerfen in
  /voice auf der Liste
- automatisch beendete Aufnahme läuft über denselben Speicherweg wie
  Senden, kein zweites Speichern; Volk und Datum bleiben bis „Noch eine
  Aufnahme“ gesperrt
- Offline-Band öffnet die Warteschlange auch ohne Ablehnung, wenn
  Sprachnotizen warten; leere Liste offline mit Text
- Abmelde-Dialog und Zurück-Link mit 56 px, Schließen-Knopf der Dialoge
  44 px

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

Refs #27

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 12:22:47 +02:00
Nick SzaunigandClaude Opus 5.5 e0be687b7c 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]>
2026-10-16 11:49:56 +02:00
Nick SzaunigandClaude Opus 5.5 462ac46d1e fix: Sprachnotizen werden bei ausgeschalteter Aufnahme abgelehnt
POST /voice-captures und /voice-captures/text antworten mit 403
VOICE_RECORDING_DISABLED („Sprachnotizen sind gerade ausgeschaltet.“),
solange VOICE_RECORDING_ENABLED nicht true ist; Liste, Abruf,
Wiederholen und Verwerfen bleiben möglich (Ruling 14).

Refs #27

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 11:36:47 +02:00
Nick SzaunigandClaude Opus 5.5 484d799a36 fix: Whisper liest /work/in nur, Restrisiko des Abschreib-Pods dokumentiert
Der Decoder-Container mountet /work/in schreibgeschützt und nur
/work/out beschreibbar (Policy-Test); ingest ignoriert Ergebnisse zu
Aufnahmen außerhalb des Manifests (Test). ARCHITECTURE Annahme 106
beschreibt das getragene Restrisiko (DNS im Pod-Netz, Sicht auf den
eigenen Lauf) und stärkere Optionen für später.

Refs #27

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 11:16:59 +02:00
50 changed files with 3775 additions and 29 deletions

No files matched your search

+1
View File
@@ -228,6 +228,7 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
103. **Die Historienzeilen haben keine Folgeknöpfe**: Eine Zeile der Beute-Historie zeigt die Änderung und den Stand an diesem Tag, bietet aber keine Vorschläge wie „Futter nachlegen“ an, weil ein Eintrag den Zustand davor nicht trägt; die Vorschläge erscheinen nur als Hinweis (Toast) direkt nach dem Speichern. Kosten bei falscher Annahme: Ein weggewischter Hinweis ist aus der Historie nicht wiederzufinden; Abhilfe wäre, `stateBefore` im Protokoll zu speichern (Migration). Abschnitt 13.1. 103. **Die Historienzeilen haben keine Folgeknöpfe**: Eine Zeile der Beute-Historie zeigt die Änderung und den Stand an diesem Tag, bietet aber keine Vorschläge wie „Futter nachlegen“ an, weil ein Eintrag den Zustand davor nicht trägt; die Vorschläge erscheinen nur als Hinweis (Toast) direkt nach dem Speichern. Kosten bei falscher Annahme: Ein weggewischter Hinweis ist aus der Historie nicht wiederzufinden; Abhilfe wäre, `stateBefore` im Protokoll zu speichern (Migration). Abschnitt 13.1.
104. **Der Editor hält den Stand fest, auf dem der Entwurf begann**: Beim ersten Schritt merkt sich `useHiveDraft` den geladenen Aufbau (`snapshot`) und zeigt die Vorschau darauf; ändert sich der Aufbau auf dem Server inzwischen, erscheint ein Hinweis, und beim Speichern wendet der Server die Operationen auf den aktuellen Stand an. Passen sie nicht mehr, öffnet sich das Konflikt-Sheet, es wird nichts gespeichert. Kosten bei falscher Annahme: Die Vorschau im Editor weicht nach einer gleichzeitigen Änderung vom gespeicherten Ergebnis ab; der Konflikt-Weg fängt Unverträgliches ab. Abschnitt 13.1. 104. **Der Editor hält den Stand fest, auf dem der Entwurf begann**: Beim ersten Schritt merkt sich `useHiveDraft` den geladenen Aufbau (`snapshot`) und zeigt die Vorschau darauf; ändert sich der Aufbau auf dem Server inzwischen, erscheint ein Hinweis, und beim Speichern wendet der Server die Operationen auf den aktuellen Stand an. Passen sie nicht mehr, öffnet sich das Konflikt-Sheet, es wird nichts gespeichert. Kosten bei falscher Annahme: Die Vorschau im Editor weicht nach einer gleichzeitigen Änderung vom gespeicherten Ergebnis ab; der Konflikt-Weg fängt Unverträgliches ab. Abschnitt 13.1.
105. **Der Zoom der 3D-Szene (0,6 bis 2,5) kommt mit dem Ziehen und Ablegen**: Die Szene hat Drehen und Fokus auf ein Teil, aber noch keinen frei wählbaren Zoom; der Bereich ist der Aufgabe für Ziehen und Ablegen zugeordnet (Task 18). Kosten bei falscher Annahme: Auf kleinen Bildschirmen fehlt zunächst ein Zoom; der Browser-Zoom und die Listenansicht (Gliederung) bleiben als Weg. Abschnitt 13.1. 105. **Der Zoom der 3D-Szene (0,6 bis 2,5) kommt mit dem Ziehen und Ablegen**: Die Szene hat Drehen und Fokus auf ein Teil, aber noch keinen frei wählbaren Zoom; der Bereich ist der Aufgabe für Ziehen und Ablegen zugeordnet (Task 18). Kosten bei falscher Annahme: Auf kleinen Bildschirmen fehlt zunächst ein Zoom; der Browser-Zoom und die Listenansicht (Gliederung) bleiben als Weg. Abschnitt 13.1.
106. **Restrisiko im Abschreib-Pod wird getragen**: Der Container `whisper` (CronJob `voice-transcribe`, Komponente `deploy/k8s/components/voice`) dekodiert nicht vertrauenswürdige Audiodateien mit libav/PyAV und CTranslate2. Er teilt das Pod-Netz; die NetworkPolicy `apicore-jobs` erlaubt nur Postgres (5432) und DNS. Ein kompromittierter Decoder könnte über DNS-Anfragen langsam Daten nach außen tragen und Postgres erreichen, hat aber keine Zugangsdaten (kein `envFrom`, kein `secretKeyRef`, kein Service-Account-Token, eigenes `/tmp`, damit er `ingest` mit den Secrets nichts unterschieben kann). Er sieht Ton und Abschriften seines Laufs (höchstens 5 Aufnahmen, 600 s) und könnte deren Ergebnisse verfälschen; `/work/in` ist für ihn nur lesbar, und `ingest` übernimmt nur Ergebnisse zu Aufnahmen des eigenen Manifests mit passendem `claimToken`, prüft jedes Ergebnis streng, und nichts geht ohne Klick in ein Formular. Akzeptiert nach Spec 9.2: ein Betrieb mit eigenen Nutzern, keine Geheimnisse im Decoder, kein Internet, Schaden auf einen Lauf begrenzt. Das CronJob-Objekt trägt bewusst `app: apicore-voice-job` (nur das Pod-Template `app: apicore-job`), damit der gemeinsame Job-Patch keinen weiteren Container einsetzt; `av` ist wegen faster-whisper 1.2.1 auf 18.1.0 festgelegt. Kosten bei falscher Annahme: Abschriften eines Laufs könnten bei einer Lücke im Decoder abfließen oder vertauscht werden. Stärkere Optionen später: eigener Pod für den Decoder mit deny-all-NetworkPolicy (auch ohne DNS) und Übergabe-Volume, oder ein Subprozess je Aufnahme mit eigener Sandbox.
## 7. Kubernetes ## 7. Kubernetes
@@ -67,6 +67,7 @@ describe('VoiceCapturesService', () => {
jest.useFakeTimers({ now: new Date('2026-10-16T10:00:00.000Z') }); jest.useFakeTimers({ now: new Date('2026-10-16T10:00:00.000Z') });
process.env.JWT_ACCESS_SECRET = 'j'.repeat(40); process.env.JWT_ACCESS_SECRET = 'j'.repeat(40);
process.env.VOICE_ENCRYPTION_KEY = 'v'.repeat(40); process.env.VOICE_ENCRYPTION_KEY = 'v'.repeat(40);
process.env.VOICE_RECORDING_ENABLED = 'true';
counts = { open: 0, today: 0, minute: 0 }; counts = { open: 0, today: 0, minute: 0 };
const findFirst = jest.fn(); const findFirst = jest.fn();
tx = { tx = {
@@ -254,6 +255,30 @@ describe('VoiceCapturesService', () => {
}); });
}); });
describe('recording switch', () => {
it.each(['upload', 'text'])(
'refuses %s with 403 VOICE_RECORDING_DISABLED before any work',
async (kind) => {
process.env.VOICE_RECORDING_ENABLED = 'false';
const call =
kind === 'upload'
? service.upload('user-1', file(WEBM), uploadDto)
: service.submitText('user-1', {
colonyId: 'colony-1',
inspectionDate: '2026-10-16',
text: SECRET_TEXT,
});
const error = await failureOf(call);
expect(error.getStatus()).toBe(403);
expect(codeOf(error)).toBe('VOICE_RECORDING_DISABLED');
expect((error.getResponse() as { message: string }).message).toBe(
'Sprachnotizen sind gerade ausgeschaltet.',
);
expect(prisma.$transaction).not.toHaveBeenCalled();
},
);
});
describe('submitText', () => { describe('submitText', () => {
const textDto = { const textDto = {
colonyId: 'colony-1', colonyId: 'colony-1',
@@ -1,6 +1,7 @@
import { import {
BadRequestException, BadRequestException,
ConflictException, ConflictException,
ForbiddenException,
HttpException, HttpException,
HttpStatus, HttpStatus,
Injectable, Injectable,
@@ -21,7 +22,7 @@ import { openTranscript, sealTranscript } from '../core/transcript';
import { openVoiceText, sealVoiceBytes, voiceAad } from '../core/voice-crypto'; import { openVoiceText, sealVoiceBytes, voiceAad } from '../core/voice-crypto';
import { normalizeInputText } from '../core/voice-text'; import { normalizeInputText } from '../core/voice-text';
import { voiceConfig } from '../voice-config'; import { voiceConfig } from '../voice-config';
import { VoiceErrorCode } from '../voice-error-codes'; import { VOICE_RECORDING_DISABLED, VoiceErrorCode } from '../voice-error-codes';
import { SubmitVoiceTextDto } from './dto/submit-voice-text.dto'; import { SubmitVoiceTextDto } from './dto/submit-voice-text.dto';
import { UploadVoiceCaptureDto } from './dto/upload-voice-capture.dto'; import { UploadVoiceCaptureDto } from './dto/upload-voice-capture.dto';
import { import {
@@ -107,6 +108,17 @@ function gone(): ConflictException {
}); });
} }
/** New notes are refused while the switch is off; reading and cleaning up stay open. */
function assertRecordingEnabled(): void {
if (!voiceConfig().recordingEnabled) {
throw new ForbiddenException({
statusCode: HttpStatus.FORBIDDEN,
code: VOICE_RECORDING_DISABLED,
message: 'Sprachnotizen sind gerade ausgeschaltet.',
});
}
}
function tooManyOpen(): HttpException { function tooManyOpen(): HttpException {
const code: VoiceErrorCode = 'TOO_MANY_OPEN'; const code: VoiceErrorCode = 'TOO_MANY_OPEN';
return new HttpException( return new HttpException(
@@ -153,6 +165,7 @@ export class VoiceCapturesService {
file: Express.Multer.File, file: Express.Multer.File,
dto: UploadVoiceCaptureDto, dto: UploadVoiceCaptureDto,
): Promise<{ id: string; status: 'QUEUED' }> { ): Promise<{ id: string; status: 'QUEUED' }> {
assertRecordingEnabled();
if (file.size > MAX_AUDIO_BYTES || file.buffer.length > MAX_AUDIO_BYTES) { if (file.size > MAX_AUDIO_BYTES || file.buffer.length > MAX_AUDIO_BYTES) {
throw new PayloadTooLargeException({ throw new PayloadTooLargeException({
statusCode: HttpStatus.PAYLOAD_TOO_LARGE, statusCode: HttpStatus.PAYLOAD_TOO_LARGE,
@@ -200,6 +213,7 @@ export class VoiceCapturesService {
userId: string, userId: string,
dto: SubmitVoiceTextDto, dto: SubmitVoiceTextDto,
): Promise<{ id: string; status: 'TRANSCRIBED' }> { ): Promise<{ id: string; status: 'TRANSCRIBED' }> {
assertRecordingEnabled();
const text = normalizeInputText(dto.text); const text = normalizeInputText(dto.text);
const length = Array.from(text).length; const length = Array.from(text).length;
if (length < MIN_TEXT_CHARS || length > MAX_TEXT_CHARS) { if (length < MIN_TEXT_CHARS || length > MAX_TEXT_CHARS) {
@@ -175,6 +175,18 @@ describe('voice work dir', () => {
expect([...results.values()]).toEqual([null, null, null]); expect([...results.values()]).toEqual([null, null, null]);
}); });
it('ignores result files for captures that are not in the manifest', async () => {
const manifest = await writeInputs(dir, [
{ claim: CLAIM('ck1'), audio: Buffer.from('a') },
]);
await writeOut('ck1', result('ck1'));
await writeOut('planted', result('planted'));
const results = await readResults(dir, manifest);
expect([...results.keys()]).toEqual(['ck1']);
});
it('clears input and output', async () => { it('clears input and output', async () => {
await writeInputs(dir, [{ claim: CLAIM('ck1'), audio: Buffer.from('a') }]); await writeInputs(dir, [{ claim: CLAIM('ck1'), audio: Buffer.from('a') }]);
await writeOut('ck1', result('ck1')); await writeOut('ck1', result('ck1'));
@@ -26,3 +26,9 @@ export type VoiceErrorCode = (typeof VOICE_ERROR_CODES)[number];
* VOICE_ERROR_CODES: it is never stored in lastErrorCode. * VOICE_ERROR_CODES: it is never stored in lastErrorCode.
*/ */
export const VOICE_CAPTURE_COLONY_MISMATCH = 'VOICE_CAPTURE_COLONY_MISMATCH'; export const VOICE_CAPTURE_COLONY_MISMATCH = 'VOICE_CAPTURE_COLONY_MISMATCH';
/**
* Answered by upload and text when VOICE_RECORDING_ENABLED is off. Not in
* VOICE_ERROR_CODES: it is never stored in lastErrorCode.
*/
export const VOICE_RECORDING_DISABLED = 'VOICE_RECORDING_DISABLED';
@@ -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.', NO_SPEECH: 'Es war nichts zu verstehen. Bitte noch einmal aufnehmen.',
TOO_LONG: 'Die Aufnahme ist zu lang. Bitte kürzer aufnehmen.', TOO_LONG: 'Die Aufnahme ist zu lang. Bitte kürzer aufnehmen.',
TRANSCRIBE_FAILED: 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: AUDIO_EXPIRED:
'Die Aufnahme wurde nach 24 Stunden gelöscht. Bitte noch einmal aufnehmen.', 'Die Aufnahme wurde nach 24 Stunden gelöscht. Bitte noch einmal aufnehmen.',
EXTRACT_DISABLED: EXTRACT_DISABLED:
@@ -120,7 +120,11 @@ describe('Voice captures (e2e)', () => {
tokens[name] = (await login(app, emailOf(name), password)).accessToken; tokens[name] = (await login(app, emailOf(name), password)).accessToken;
} }
const originalRecording = process.env.VOICE_RECORDING_ENABLED;
beforeAll(async () => { beforeAll(async () => {
// Notes are only accepted while recording is on; this file turns it on.
process.env.VOICE_RECORDING_ENABLED = 'true';
app = await createTestApp(); app = await createTestApp();
prisma = app.get(PrismaService); prisma = app.get(PrismaService);
@@ -208,6 +212,11 @@ describe('Voice captures (e2e)', () => {
where: { name: `Voice E2E Stand ${suffix}` }, where: { name: `Voice E2E Stand ${suffix}` },
}); });
await app.close(); await app.close();
if (originalRecording === undefined) {
delete process.env.VOICE_RECORDING_ENABLED;
} else {
process.env.VOICE_RECORDING_ENABLED = originalRecording;
}
}); });
const ownRows = (name: string) => const ownRows = (name: string) =>
@@ -280,6 +289,35 @@ describe('Voice captures (e2e)', () => {
expect(rows[0].TimeZone).toBe('UTC'); expect(rows[0].TimeZone).toBe('UTC');
}); });
describe('recording switch', () => {
it('refuses new notes while off but still lists, reads and discards', async () => {
const id = ((await sendText('alice').expect(201)).body as CaptureBody).id;
process.env.VOICE_RECORDING_ENABLED = 'false';
try {
const up = await upload('alice').expect(403);
expect(up.body).toMatchObject({
code: 'VOICE_RECORDING_DISABLED',
message: 'Sprachnotizen sind gerade ausgeschaltet.',
});
await sendText('alice').expect(403);
await request(server())
.get('/api/v1/voice-captures')
.set(as('alice'))
.expect(200);
await request(server())
.get(`/api/v1/voice-captures/${id}`)
.set(as('alice'))
.expect(200);
await request(server())
.post(`/api/v1/voice-captures/${id}/discard`)
.set(as('alice'))
.expect(204);
} finally {
process.env.VOICE_RECORDING_ENABLED = 'true';
}
});
});
describe('upload', () => { describe('upload', () => {
it('stores the audio sealed and answers QUEUED', async () => { it('stores the audio sealed and answers QUEUED', async () => {
const response = await upload('alice').expect(201); const response = await upload('alice').expect(201);
@@ -35,6 +35,11 @@ vi.mock("@/components/autopilot/next-steps-card", () => ({
NextStepsCard: ({ colonyId }: { colonyId: string }) => <p>Als Nächstes für {colonyId}</p>, NextStepsCard: ({ colonyId }: { colonyId: string }) => <p>Als Nächstes für {colonyId}</p>,
})); }));
vi.mock("@/components/qr-code-dialog", () => ({ QrCodeDialog: () => null })); 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() { function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); 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(); 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 () => { it("shows no strength line when none is stored", async () => {
vi.mocked(fetchColony).mockResolvedValue(colony); vi.mocked(fetchColony).mockResolvedValue(colony);
renderPage(); renderPage();
@@ -13,6 +13,7 @@ import { NextStepsCard } from "@/components/autopilot/next-steps-card";
import { QrCodeDialog } from "@/components/qr-code-dialog"; import { QrCodeDialog } from "@/components/qr-code-dialog";
import { DocumentUploadCard } from "@/components/document-upload-card"; import { DocumentUploadCard } from "@/components/document-upload-card";
import { DetailHeader, DetailQueryFallback, DetailStatus } from "@/components/detail-header"; import { DetailHeader, DetailQueryFallback, DetailStatus } from "@/components/detail-header";
import { VoiceEntryLink } from "@/components/voice/voice-entry-link";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ColonyEditCard } from "./colony-edit-card"; import { ColonyEditCard } from "./colony-edit-card";
import { ColonyActions } from "./colony-actions"; import { ColonyActions } from "./colony-actions";
@@ -60,14 +61,17 @@ export default function ColonyDetailPage() {
subtitle={COLONY_TYPE_LABELS[colony.colonyType]} subtitle={COLONY_TYPE_LABELS[colony.colonyType]}
status={<DetailStatus status={colony.status} label={COLONY_STATUS_LABELS[colony.status]} />} status={<DetailStatus status={colony.status} label={COLONY_STATUS_LABELS[colony.status]} />}
primaryAction={ primaryAction={
<Button <>
size="touch" <VoiceEntryLink colonyId={colony.id} />
nativeButton={false} <Button
render={<Link href={`/inspections/new?colonyId=${colony.id}`} />} size="touch"
> nativeButton={false}
<ClipboardCheck aria-hidden /> render={<Link href={`/inspections/new?colonyId=${colony.id}`} />}
Durchsicht erfassen >
</Button> <ClipboardCheck aria-hidden />
Durchsicht erfassen
</Button>
</>
} }
moreActions={[ moreActions={[
{ {
@@ -24,6 +24,7 @@ import {
import { DRAFT_PREFIX } from "@/lib/forms/draft-storage"; import { DRAFT_PREFIX } from "@/lib/forms/draft-storage";
import { initialInspectionValues } from "./inspection-form-values"; import { initialInspectionValues } from "./inspection-form-values";
import { markColonyDone, readRound, startRound } from "@/lib/rounds/round-state"; import { markColonyDone, readRound, startRound } from "@/lib/rounds/round-state";
import { fetchVoiceConfig } from "@/lib/voice/api";
const push = vi.fn(); const push = vi.fn();
let searchString = "colonyId=colony-1"; let searchString = "colonyId=colony-1";
@@ -42,6 +43,10 @@ vi.mock("@/lib/apiaries/field-api", async (importOriginal) => ({
fetchColonyOverview: vi.fn(), fetchColonyOverview: vi.fn(),
})); }));
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: 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", () => ({ vi.mock("@/lib/inspections/api", () => ({
createInspection: vi.fn(), createInspection: vi.fn(),
fetchColonyInspections: vi.fn(), fetchColonyInspections: vi.fn(),
@@ -739,6 +744,20 @@ describe("NewInspectionPage", () => {
await userEvent.click(screen.getByRole("button", { name: "Durchsicht speichern" })); 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 () => { it("shows where in the round the beekeeper is", async () => {
renderPage(); renderPage();
@@ -43,6 +43,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { DictationRow } from "@/components/voice/dictation-button"; import { DictationRow } from "@/components/voice/dictation-button";
import { VoiceEntryLink } from "@/components/voice/voice-entry-link";
import { appendDictation } from "@/lib/voice/append-text"; import { appendDictation } from "@/lib/voice/append-text";
import { FormBackLink } from "@/components/form/form-back-link"; import { FormBackLink } from "@/components/form/form-back-link";
import { RoundNextPanel } from "@/components/rounds/round-next-panel"; import { RoundNextPanel } from "@/components/rounds/round-next-panel";
@@ -564,9 +565,14 @@ function InspectionCaptureForm({
draft={draft} draft={draft}
/> />
{stand.round ? ( {stand.round ? (
<p className="w-fit rounded-full bg-muted px-3 py-1 text-sm font-medium"> <div className="flex flex-wrap items-center gap-3">
{roundChipText(stand.round, values.colonyId)} <p className="w-fit rounded-full bg-muted px-3 py-1 text-sm font-medium">
</p> {roundChipText(stand.round, values.colonyId)}
</p>
{values.colonyId ? (
<VoiceEntryLink colonyId={values.colonyId} roundApiaryId={stand.round.apiaryId} />
) : null}
</div>
) : null} ) : null}
<PageHeader <PageHeader
icon={ClipboardCheck} icon={ClipboardCheck}
@@ -74,6 +74,11 @@ vi.mock("@/components/offline/offline-band", () => ({
), ),
})); }));
vi.mock("@/components/user-avatar", () => ({ UserAvatar: () => null })); 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", () => ({ vi.mock("@/lib/photos/store", () => ({
purgeStaleDraftPhotos: vi.fn().mockResolvedValue(0), purgeStaleDraftPhotos: vi.fn().mockResolvedValue(0),
finishHandOvers: vi.fn().mockResolvedValue(0), finishHandOvers: vi.fn().mockResolvedValue(0),
@@ -7,9 +7,10 @@ import {
} from "./nav-config"; } from "./nav-config";
const ALL_ITEMS = [...NAV_GROUPS, ADMIN_GROUP].flatMap((group) => group.items); const ALL_ITEMS = [...NAV_GROUPS, ADMIN_GROUP].flatMap((group) => group.items);
const ALL_PERMISSIONS = ALL_ITEMS.flatMap((item) => const ALL_PERMISSIONS = ALL_ITEMS.flatMap((item) => [
item.permission === undefined ? [] : [item.permission].flat(), ...(item.permission === undefined ? [] : [item.permission].flat()),
); ...(item.alsoRequires ?? []),
]);
function hrefs(permissions: string[]): string[] { function hrefs(permissions: string[]): string[] {
return visibleNavGroups({ permissions }) return visibleNavGroups({ permissions })
@@ -72,6 +73,17 @@ describe("nav-config", () => {
expect(item?.counter).toBe("suggestions"); 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", () => { it("shows Prüfbereitschaft only with compliance:read", () => {
expect(hrefs([])).not.toContain("/compliance"); expect(hrefs([])).not.toContain("/compliance");
expect(hrefs(["compliance:read"])).toContain("/compliance"); expect(hrefs(["compliance:read"])).toContain("/compliance");
+15 -1
View File
@@ -16,6 +16,7 @@ import {
ListTodo, ListTodo,
type LucideIcon, type LucideIcon,
MapPin, MapPin,
Mic,
Package, Package,
Scissors, Scissors,
Search, Search,
@@ -44,6 +45,8 @@ export interface NavItem {
// together several previously-separate settings pages, each gated by its // together several previously-separate settings pages, each gated by its
// own permission. // own permission.
permission?: string | string[]; 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. */ /** A number shown next to the label; the data comes from the matching hook. */
counter?: "suggestions"; counter?: "suggestions";
} }
@@ -98,6 +101,13 @@ export const NAV_GROUPS: NavGroup[] = [
counter: "suggestions", counter: "suggestions",
}, },
{ label: "Kalender", href: "/calendar", icon: CalendarDays }, { 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] return [...NAV_GROUPS, ADMIN_GROUP]
.map((group) => ({ .map((group) => ({
...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); .filter((group) => group.items.length > 0);
} }
@@ -3,6 +3,7 @@ import {
ClipboardCheck, ClipboardCheck,
ListTodo, ListTodo,
type LucideIcon, type LucideIcon,
Mic,
Scissors, Scissors,
Sprout, Sprout,
Syringe, Syringe,
@@ -15,6 +16,10 @@ export interface QuickAction {
icon: LucideIcon; icon: LucideIcon;
/** Creating needs this permission; actions without it are not offered. */ /** Creating needs this permission; actions without it are not offered. */
permission: string; 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[] = [ export const QUICK_ACTIONS: readonly QuickAction[] = [
@@ -24,6 +29,14 @@ export const QUICK_ACTIONS: readonly QuickAction[] = [
icon: ClipboardCheck, icon: ClipboardCheck,
permission: "inspections:create", permission: "inspections:create",
}, },
{
label: "Sprachnotiz",
href: "/voice/new",
icon: Mic,
permission: "voice-captures:use",
alsoPermission: "inspections:create",
needsVoiceRecording: true,
},
{ {
label: "Varroa-Messung", label: "Varroa-Messung",
href: "/varroa/new", href: "/varroa/new",
@@ -1,5 +1,8 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; 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 userEvent from "@testing-library/user-event";
import { QUICK_ACTIONS } from "./quick-actions"; import { QUICK_ACTIONS } from "./quick-actions";
import { QuickAddSheet } from "./quick-add-sheet"; import { QuickAddSheet } from "./quick-add-sheet";
@@ -9,11 +12,29 @@ vi.mock("next/navigation", () => ({
usePathname: () => mockPathname, 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 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", () => { describe("QuickAddSheet", () => {
beforeEach(() => { beforeEach(() => {
mockPathname = "/dashboard"; mockPathname = "/dashboard";
vi.mocked(fetchVoiceConfig).mockReset().mockResolvedValue(CONFIG);
}); });
it("lists actions with icons as links", async () => { it("lists actions with icons as links", async () => {
@@ -25,9 +46,66 @@ describe("QuickAddSheet", () => {
expect(link).toHaveAttribute("href", "/inspections/new"); expect(link).toHaveAttribute("href", "/inspections/new");
expect(link).toHaveClass("min-h-14"); expect(link).toHaveClass("min-h-14");
expect(link.querySelector("svg.size-6")).not.toBeNull(); expect(link.querySelector("svg.size-6")).not.toBeNull();
await screen.findByRole("link", { name: "Sprachnotiz" });
expect(screen.getAllByRole("link")).toHaveLength(QUICK_ACTIONS.length); 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 () => { it("hides Behandlung without treatments:create", async () => {
render( render(
<QuickAddSheet <QuickAddSheet
@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
import { SheetTrigger } from "@/components/ui/sheet"; import { SheetTrigger } from "@/components/ui/sheet";
import type { AuthUser } from "@/lib/auth/auth-context"; import type { AuthUser } from "@/lib/auth/auth-context";
import { NAV_SHEET_ROW_CLASS, NavSheet } from "@/components/nav/nav-sheet"; 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"; import { QUICK_ACTIONS } from "./quick-actions";
// Create and edit forms end in a save bar (StickySubmit) right where the // 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) { export function QuickAddSheet({ user, variant }: QuickAddSheetProps) {
const pathname = usePathname(); const pathname = usePathname();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const actions = QUICK_ACTIONS.filter((action) => const mayRecord = user.permissions.includes("voice-captures:use");
user.permissions.includes(action.permission), 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)) { if (!hasQuickAddButton(pathname, user.permissions)) {
@@ -0,0 +1,265 @@
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.onReset?.()}>
Zurücksetzen (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("keeps colony and date fixed to the saved note until the user records anew", async () => {
query = new URLSearchParams({ colonyId: "c-1" });
const user = userEvent.setup();
renderPage();
await user.click(await screen.findByRole("button", { name: "Ändern" }));
await user.click(screen.getByRole("button", { name: "Aufnahme speichern (Test)" }));
expect(screen.getByRole("button", { name: "Ändern" })).toBeDisabled();
expect(screen.getByLabelText(/Datum/, { selector: "input" })).toBeDisabled();
await user.click(screen.getByRole("button", { name: "Zurücksetzen (Test)" }));
expect(screen.getByRole("button", { name: "Ändern" })).toBeEnabled();
expect(screen.getByLabelText(/Datum/, { selector: "input" })).toBeEnabled();
});
it("has a 56 px back link", async () => {
query = new URLSearchParams({ colonyId: "c-1" });
renderPage();
expect(await screen.findByRole("link", { name: /Zum Volk/ })).toHaveClass("min-h-14");
});
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,262 @@
"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);
// A saved note belongs to its colony and day: changing them needs a new recording.
const [saved, setSaved] = useState(false);
const locked = busy || saved;
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} className="min-h-14" />
<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={locked}
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={locked}
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}
onReset={() => setSaved(false)}
onSaved={(outcome) => {
setSaved(true);
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,372 @@
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();
// Nothing on the device either: the empty state stays, with the offline hint.
expect(screen.getByText("Noch keine Sprachnotizen.")).toBeInTheDocument();
} finally {
onlineManager.setOnline(true);
}
});
it("moves focus to the list heading after the last device note is discarded", async () => {
connectivity = "offline";
await queueVoiceNote({
ownerUserId: "user-1",
blob: new Blob(["a"]),
mime: "audio/webm",
durationSec: 20,
colonyId: "c-2",
inspectionDate: "2026-10-12",
});
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" }),
);
const heading = await screen.findByRole("heading", { name: "Liste der Sprachnotizen" });
await waitFor(() => expect(document.activeElement).toBe(heading));
expect(await screen.findByText("Noch keine Sprachnotizen.")).toBeInTheDocument();
});
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,204 @@
"use client";
import { useRef } from "react";
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 listHeading = useRef<HTMLHeadingElement>(null);
// The card with the focus is gone after a discard: the focus goes to the list's heading.
const focusList = () => listHeading.current?.focus();
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: () => {
focusList();
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: () => {
focusList();
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),
);
// 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;
const isLoading = serverQuery.isLoading && local.length === 0;
const isEmpty = local.length === 0 && listed.length === 0;
return (
<div className="flex flex-col gap-3">
<h2 ref={listHeading} tabIndex={-1} className="sr-only">
Liste der Sprachnotizen
</h2>
{isLoading ? <LoadingState what="Sprachnotizen" /> : null}
{!isLoading && serverOffline ? <OfflineNote /> : null}
{!isLoading && serverFailed ? (
<ErrorState
what={isEmpty ? "Sprachnotizen" : "Sprachnotizen vom Server"}
onRetry={() => void serverQuery.refetch()}
isRetrying={serverQuery.isFetching}
/>
) : null}
{!isLoading && isEmpty && !serverFailed ? (
<EmptyState
icon={Mic}
title="Noch keine Sprachnotizen."
description="Nimm beim Volk eine Sprachnotiz auf. Sie erscheint hier, bis du sie geprüft hast."
/>
) : null}
{isEmpty ? 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>
);
}
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, render, screen, waitFor } from "@testing-library/react"; import { act, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { reportRequestOutcome } from "@/lib/offline/connectivity"; import { reportRequestOutcome } from "@/lib/offline/connectivity";
import { summarizeQueue } from "@/lib/offline/db"; import { summarizeQueue } from "@/lib/offline/db";
@@ -66,6 +66,18 @@ describe("LogoutConfirmDialog", () => {
expect(screen.getByRole("button", { name: "Abbrechen" })).toBeVisible(); expect(screen.getByRole("button", { name: "Abbrechen" })).toBeVisible();
}); });
it("has 56 px buttons and a 44 px close target", async () => {
vi.mocked(summarizeQueue).mockResolvedValue({ own: 2, failed: 0, foreign: 0 });
renderDialog();
const dialog = await screen.findByRole("dialog", { name: "Abmelden?" });
for (const name of ["Abbrechen", "Erst senden", "Abmelden und Einträge löschen"]) {
expect(screen.getByRole("button", { name })).toHaveClass("min-h-14");
}
expect(within(dialog).getByRole("button", { name: "Close" })).toHaveClass("size-11");
});
it("asks to discard waiting voice notes and says they cannot be restored", async () => { it("asks to discard waiting voice notes and says they cannot be restored", async () => {
vi.mocked(summarizeQueue).mockResolvedValue({ own: 3, failed: 0, foreign: 0, voice: 2 }); vi.mocked(summarizeQueue).mockResolvedValue({ own: 3, failed: 0, foreign: 0, voice: 2 });
@@ -152,13 +152,20 @@ export function LogoutConfirmDialog({
type="button" type="button"
variant="outline" variant="outline"
size="touch" size="touch"
className="min-h-14"
disabled={isBusy} disabled={isBusy}
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
> >
Abbrechen Abbrechen
</Button> </Button>
{hasEntries && isOnline ? ( {hasEntries && isOnline ? (
<Button type="button" size="touch" disabled={isBusy} onClick={() => void sendFirst()}> <Button
type="button"
size="touch"
className="min-h-14"
disabled={isBusy}
onClick={() => void sendFirst()}
>
Erst senden Erst senden
</Button> </Button>
) : null} ) : null}
@@ -166,6 +173,7 @@ export function LogoutConfirmDialog({
type="button" type="button"
variant="destructive" variant="destructive"
size="touch" size="touch"
className="min-h-14"
disabled={isBusy} disabled={isBusy}
onClick={() => void signOut(hasEntries)} onClick={() => void signOut(hasEntries)}
> >
@@ -20,6 +20,12 @@ describe("FormBackLink", () => {
); );
}); });
it("takes extra classes for a bigger target", () => {
render(<FormBackLink href="/voice" label="Zum Volk" dirty={false} className="min-h-14" />);
expect(screen.getByRole("link", { name: /Zum Volk/ })).toHaveClass("min-h-14");
});
it("asks before leaving and flushes the draft", async () => { it("asks before leaving and flushes the draft", async () => {
const flush = vi.fn(); const flush = vi.fn();
render( render(
@@ -5,6 +5,7 @@ import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -21,6 +22,8 @@ export interface FormBackLinkProps {
dirty: boolean; dirty: boolean;
/** The form's draft; flushed so leaving does not race the autosave delay. */ /** The form's draft; flushed so leaving does not race the autosave delay. */
draft?: { flush: () => void }; draft?: { flush: () => void };
/** Extra classes, e.g. a bigger target on screens used with gloves. */
className?: string;
} }
const NOT_PLAIN_CLICK = (event: MouseEvent) => const NOT_PLAIN_CLICK = (event: MouseEvent) =>
@@ -31,7 +34,7 @@ const NOT_PLAIN_CLICK = (event: MouseEvent) =>
* untouched; once something was typed it asks first, saying honestly whether * untouched; once something was typed it asks first, saying honestly whether
* the input stays as a draft on this device or is lost. * the input stays as a draft on this device or is lost.
*/ */
export function FormBackLink({ href, label, dirty, draft }: FormBackLinkProps) { export function FormBackLink({ href, label, dirty, draft, className }: FormBackLinkProps) {
const router = useRouter(); const router = useRouter();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -53,7 +56,10 @@ export function FormBackLink({ href, label, dirty, draft }: FormBackLinkProps) {
<Link <Link
href={href} href={href}
onClick={handleClick} onClick={handleClick}
className="-ml-2 inline-flex min-h-11 w-fit items-center gap-1.5 rounded-lg px-2 text-sm font-medium text-muted-foreground hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none" className={cn(
"-ml-2 inline-flex min-h-11 w-fit items-center gap-1.5 rounded-lg px-2 text-sm font-medium text-muted-foreground hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none",
className,
)}
> >
<ArrowLeft className="size-4" aria-hidden="true" /> <ArrowLeft className="size-4" aria-hidden="true" />
{label} {label}
@@ -143,6 +143,25 @@ describe("OfflineBand", () => {
expect(screen.getByRole("status")).toHaveTextContent("davon 1 Sprachnotiz"); expect(screen.getByRole("status")).toHaveTextContent("davon 1 Sprachnotiz");
}); });
it("opens the queue from the band when voice notes wait, offline too", async () => {
setOnLine(false);
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0, voice: 1, voiceWaiting: 1 });
renderBand();
expect(await screen.findByRole("button", { name: "Warteschlange ansehen" })).toBeVisible();
});
it("offers no queue button when nothing voice-related waits", async () => {
setOnLine(true);
vi.mocked(summarizeQueue).mockResolvedValue({ own: 2, failed: 0, foreign: 0 });
renderBand();
await screen.findByRole("button", { name: "2 Einträge warten – Jetzt senden" });
expect(screen.queryByRole("button", { name: "Warteschlange ansehen" })).not.toBeInTheDocument();
});
it("says „1 Eintrag wartet\" for a single entry", async () => { it("says „1 Eintrag wartet\" for a single entry", async () => {
setOnLine(false); setOnLine(false);
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 }); vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 });
@@ -188,6 +188,20 @@ export function OfflineBand({ userId, pageContentRef, syncEnabled = true }: Offl
<AlertTriangle aria-hidden /> <AlertTriangle aria-hidden />
{`${entriesRejected(queue.failed)} – ansehen`} {`${entriesRejected(queue.failed)} – ansehen`}
</SheetTrigger> </SheetTrigger>
) : queue.voiceWaiting ? (
// Waiting voice notes cannot be seen anywhere else but the list: the sheet opens from here too.
<SheetTrigger
render={
<Button
type="button"
variant="outline"
size="touch"
className={BAND_BUTTON_CLASS}
/>
}
>
Warteschlange ansehen
</SheetTrigger>
) : null ) : null
} }
/> />
@@ -65,7 +65,7 @@ function DialogContent({
render={ render={
<Button <Button
variant="ghost" variant="ghost"
className="absolute top-2 right-2" className="absolute top-1 right-1 size-11"
size="icon-sm" size="icon-sm"
/> />
} }
@@ -0,0 +1,89 @@
import { useEffect, useRef } from "react";
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;
/** The user just tried to record: the heading takes the focus. */
focusOnShow?: boolean;
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,
focusOnShow = false,
onRetry,
}: MicrophoneHelpProps) {
const heading = useRef<HTMLHeadingElement>(null);
useEffect(() => {
if (focusOnShow) heading.current?.focus();
}, [focusOnShow]);
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"
ref={heading}
tabIndex={-1}
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,532 @@
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.findByText(/^Gespeichert/)).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 saved like a press on Senden and then offers no second save", async () => {
const { listPendingVoice } = await import("@/lib/voice/pending-voice");
const onSaved = vi.fn();
const user = userEvent.setup();
renderRecorder({ onSaved });
await startAndRecord(user);
act(() => options.onAutoStop({ ...RECORDING, durationSec: 180 }));
expect(
await screen.findByText("Gespeichert. Wird gesendet, sobald Netz da ist."),
).toBeInTheDocument();
expect(await listPendingVoice("user-1")).toHaveLength(1);
expect(syncPendingEntries).toHaveBeenCalledTimes(1);
expect(onSaved).toHaveBeenCalledWith({ kind: "queued" });
expect(screen.queryByRole("button", { name: /^(Senden|Speichern)$/ })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Noch eine Aufnahme" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Zu den Sprachnotizen" })).toHaveAttribute(
"href",
"/voice",
);
});
it("says Gesendet when the sync took it at once", 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 startAndRecord(user);
act(() => options.onAutoStop({ ...RECORDING, durationSec: 180 }));
expect(await screen.findByText(/^Gesendet/)).toBeInTheDocument();
});
it("stays offline on the device only and does not sync", async () => {
connectivity = "offline";
const user = userEvent.setup();
renderRecorder();
await startAndRecord(user);
act(() => options.onAutoStop({ ...RECORDING, durationSec: 180 }));
await screen.findByText(/^Gespeichert/);
expect(syncPendingEntries).not.toHaveBeenCalled();
});
it("keeps the note on the device when the user records anew", async () => {
const { listPendingVoice } = await import("@/lib/voice/pending-voice");
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: "Noch eine Aufnahme" }));
await screen.findByRole("button", { name: "Aufnahme starten" });
expect(await listPendingVoice("user-1")).toHaveLength(1);
});
});
it("shows the recorder's own duration, not the media element's", async () => {
const user = userEvent.setup();
renderRecorder();
await startAndRecord(user);
await user.click(screen.getByRole("button", { name: "Aufnahme beenden" }));
const heading = await screen.findByRole("heading", { name: /Aufnahme fertig · 0:42/ });
expect(heading).toBeInTheDocument();
const audio = document.querySelector("audio");
expect(audio).toHaveAttribute("aria-label", "Aufnahme anhören, Dauer 0:42");
});
describe("when the microphone cannot be used", () => {
it("moves focus to the help heading after a refused start", async () => {
startRecording.mockRejectedValue(new DOMException("denied", "NotAllowedError"));
const user = userEvent.setup();
renderRecorder();
await user.click(screen.getByRole("button", { name: "Aufnahme starten" }));
const heading = await screen.findByRole("heading", { name: "Das Mikrofon ist nicht erlaubt." });
await waitFor(() => expect(document.activeElement).toBe(heading));
});
});
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,454 @@
"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;
/** The user cleared the saved result to record anew. */
onReset?(): 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,
onReset,
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);
// The help for a refused microphone takes the focus only after the user tried.
const [tried, setTried] = useState(false);
function afterQueueChange() {
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
}
/** Puts the recording on the device and, online, sends it - for Senden and for a limit stop alike. */
async function persist(finished: Recording) {
if (!userId) return;
setSaving(true);
setSaveError(null);
try {
const result = await saveVoiceNote({
userId,
recording: finished,
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 recorder = useVoiceRecorder({ onAutoStop: (finished) => void persist(finished) });
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;
setTried(true);
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();
onReset?.();
}
/** 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();
}
function save() {
if (recording && !saving) void persist(recording);
}
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}
focusOnShow={tried}
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, Dauer ${formatClock(recording.durationSec)}`} />
) : 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={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); 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", () => { it("falls back on the status and never shows the raw server message", () => {
expect(voiceRejectionText({ status: 403, message: "Forbidden", at })).toBe( expect(voiceRejectionText({ status: 403, message: "Forbidden", at })).toBe(
"Keine Berechtigung für Sprachnotizen.", "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 = export const VOICE_TOO_MANY_OPEN_HINT =
"Zu viele offene Sprachnotizen – sie werden später gesendet."; "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> = { 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.", TOO_LARGE: "Die Sprachnotiz ist zu groß. Bitte kürzer aufnehmen.",
UNSUPPORTED_AUDIO: "Dieses Audioformat wird nicht unterstützt. Bitte neu aufnehmen.", UNSUPPORTED_AUDIO: "Dieses Audioformat wird nicht unterstützt. Bitte neu aufnehmen.",
TOO_MANY_OPEN: "Zu viele offene Sprachnotizen. Erst auswerten oder verwerfen.", 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); 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 () => { it("a 409 'still being processed' skips that note and goes on", async () => {
await pending.queueVoiceNote(input()); await pending.queueVoiceNote(input());
await pending.queueVoiceNote(input("user-1", "colony-2")); 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";
}
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { proxy } from "./proxy";
function csp(): string {
return proxy().headers.get("Content-Security-Policy") ?? "";
}
describe("proxy security headers", () => {
it("lets the page play its own voice recordings (blob:) and nothing broader", () => {
const directives = csp().split(";").map((part) => part.trim());
expect(directives).toContain("media-src 'self' blob:");
expect(directives.filter((part) => part.startsWith("media-src"))).toHaveLength(1);
});
it("keeps the default of the same origin only", () => {
expect(csp()).toContain("default-src 'self'");
});
});
+1
View File
@@ -37,6 +37,7 @@ function cspHeader(): string {
script-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline';
style-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';
img-src 'self' blob: data:; img-src 'self' blob: data:;
media-src 'self' blob:;
font-src 'self'; font-src 'self';
connect-src 'self' ${apiOrigin()}; connect-src 'self' ${apiOrigin()};
object-src 'none'; object-src 'none';
@@ -119,9 +119,16 @@ spec:
seccompProfile: seccompProfile:
type: RuntimeDefault type: RuntimeDefault
volumeMounts: volumeMounts:
# Der ganze Mount, Schreibrecht fuer /work/out. # Eingabe nur lesbar (Manifest und Ton kann der Decoder nicht
# veraendern), Ausgabe schreibbar. fetch legt in/ und out/ an
# (clearWorkDir, Modus 0700, uid 1000), bevor dieser Container startet.
- name: work - name: work
mountPath: /work mountPath: /work/in
subPath: in
readOnly: true
- name: work
mountPath: /work/out
subPath: out
- name: whisper-tmp - name: whisper-tmp
mountPath: /tmp mountPath: /tmp
resources: resources:
+9 -2
View File
@@ -581,7 +581,7 @@ test('whisper container has no env from secrets, no secretKeyRef and no service
} }
assert.deepEqual( assert.deepEqual(
(whisper.volumeMounts ?? []).map((m) => m.mountPath).sort(), (whisper.volumeMounts ?? []).map((m) => m.mountPath).sort(),
['/tmp', '/work'], ['/tmp', '/work/in', '/work/out'],
'whisper mounts more than the work volume and /tmp', 'whisper mounts more than the work volume and /tmp',
); );
assert.equal(whisper.securityContext.runAsNonRoot, true); assert.equal(whisper.securityContext.runAsNonRoot, true);
@@ -629,11 +629,18 @@ test('work volume is a memory emptyDir of at most 32Mi, mounted in all three con
assert.ok(work?.emptyDir, 'work volume missing or not an emptyDir'); assert.ok(work?.emptyDir, 'work volume missing or not an emptyDir');
assert.equal(work.emptyDir.medium, 'Memory', 'work volume is not memory-backed'); assert.equal(work.emptyDir.medium, 'Memory', 'work volume is not memory-backed');
assert.equal(work.emptyDir.sizeLimit, '32Mi'); assert.equal(work.emptyDir.sizeLimit, '32Mi');
for (const name of ['fetch', 'whisper', 'ingest']) { for (const name of ['fetch', 'ingest']) {
const mount = (voiceContainer(pod, name).volumeMounts ?? []).find((m) => m.name === 'work'); const mount = (voiceContainer(pod, name).volumeMounts ?? []).find((m) => m.name === 'work');
assert.equal(mount?.mountPath, '/work', `${name} does not mount work on /work`); assert.equal(mount?.mountPath, '/work', `${name} does not mount work on /work`);
assert.ok(!mount.readOnly, `${name} mounts work read-only`); assert.ok(!mount.readOnly, `${name} mounts work read-only`);
} }
// whisper: /work/in read-only, /work/out writable, nothing else of the work volume.
const whisperWork = (voiceContainer(pod, 'whisper').volumeMounts ?? []).filter((m) => m.name === 'work');
assert.deepEqual(
whisperWork.map((m) => [m.mountPath, m.subPath, m.readOnly ?? false]).sort(),
[['/work/in', 'in', true], ['/work/out', 'out', false]].sort(),
'whisper must see /work/in read-only and /work/out writable only',
);
// fetch, whisper and ingest share the files (0700/0600): one uid, one fsGroup. // fetch, whisper and ingest share the files (0700/0600): one uid, one fsGroup.
assert.equal(pod.spec.securityContext.runAsUser, 1000); assert.equal(pod.spec.securityContext.runAsUser, 1000);
assert.equal(pod.spec.securityContext.fsGroup, 1000); assert.equal(pod.spec.securityContext.fsGroup, 1000);