feat: Aufnahmeseite und Liste der Sprachnotizen (T7 L5 Task 19)
/voice/new: großer Aufnahmeknopf mit Zeit, Hinweis bei 150 s, Stopp bei 180 s; eine automatisch beendete Aufnahme liegt sofort in der Warteschlange des Geräts (Ruling 13); Abbrechen und Neu aufnehmen fragen nach; Hilfe bei gesperrtem Mikrofon; Fokus bleibt bei jedem Zustandswechsel an einem Bedienelement; Volk und Datum während der Aufnahme gesperrt, ?date= auf die letzten 7 Tage begrenzt. /voice: wartende Notizen des Geräts zuerst, dann die des Servers mit Status, „Prüfen“ nur bei fertigen, Verwerfen mit Rückfrage. Einstiege über Schnellanlage, Menü (nur mit voice-captures:use und inspections:create), Volksseite und Durchsicht. Bei ausgeschalteter Aufnahme verschwinden die Einstiege; jeder HTTP-Fehler der Konfiguration zählt als aus, nur fehlendes Netz als unbekannt. Fehlertexte gleich mit dem Backend (Test). Gates: Frontend lint, typecheck, 2979 Tests, build. Refs #27 Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
462ac46d1e
commit
e0be687b7c
33 files changed
+3460
-19
No files matched your search
@@ -0,0 +1,52 @@
|
||||
import "fake-indexeddb/auto";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const syncPendingEntries = vi.fn();
|
||||
vi.mock("@/lib/offline/sync", () => ({
|
||||
syncPendingEntries: (...args: unknown[]) => syncPendingEntries(...args),
|
||||
}));
|
||||
|
||||
const recording = {
|
||||
blob: new Blob(["audio"], { type: "audio/webm" }),
|
||||
mime: "audio/webm",
|
||||
durationSec: 30,
|
||||
};
|
||||
const base = { userId: "user-1", recording, colonyId: "c-1", inspectionDate: "2026-10-16" };
|
||||
|
||||
let listPendingVoice: typeof import("./pending-voice").listPendingVoice;
|
||||
let saveVoiceNote: typeof import("./save-voice-note").saveVoiceNote;
|
||||
|
||||
describe("saveVoiceNote", () => {
|
||||
beforeEach(async () => {
|
||||
globalThis.indexedDB = new IDBFactory();
|
||||
vi.resetModules();
|
||||
({ listPendingVoice } = await import("./pending-voice"));
|
||||
({ saveVoiceNote } = await import("./save-voice-note"));
|
||||
syncPendingEntries.mockReset().mockResolvedValue({ synced: 0, failed: 0, skippedForeign: 0 });
|
||||
});
|
||||
|
||||
it("queues offline without syncing and reports the note id", async () => {
|
||||
const onQueued = vi.fn();
|
||||
|
||||
const outcome = await saveVoiceNote({ ...base, online: false, onQueued });
|
||||
|
||||
expect(outcome).toEqual({ kind: "queued" });
|
||||
const [note] = await listPendingVoice("user-1");
|
||||
expect(onQueued).toHaveBeenCalledWith(note.id);
|
||||
expect(syncPendingEntries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not queue a second note when the same recording is saved again", async () => {
|
||||
syncPendingEntries.mockRejectedValueOnce(new Error("sync broke"));
|
||||
const onQueued = vi.fn();
|
||||
const first = await saveVoiceNote({ ...base, online: true, onQueued });
|
||||
expect(first).toEqual({ kind: "queued" });
|
||||
const queuedNoteId = onQueued.mock.calls[0][0] as string;
|
||||
|
||||
await saveVoiceNote({ ...base, online: true, queuedNoteId });
|
||||
|
||||
expect(await listPendingVoice("user-1")).toHaveLength(1);
|
||||
expect(syncPendingEntries).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { syncPendingEntries } from "@/lib/offline/sync";
|
||||
import { VOICE_TOO_MANY_OPEN_HINT, voiceRejectionText } from "@/lib/offline/queue-texts";
|
||||
import type { Recording } from "./recorder";
|
||||
import { getPendingVoice, queueVoiceNote } from "./pending-voice";
|
||||
|
||||
/*
|
||||
* Saving a finished recording: it always goes to the device queue first, so a
|
||||
* lost connection never loses it. Online, the sync is asked to send it right
|
||||
* away; what the server did is read back from the queue.
|
||||
*/
|
||||
|
||||
export type SaveVoiceOutcome =
|
||||
| { kind: "sent" }
|
||||
| { kind: "queued"; hint?: string }
|
||||
| { kind: "rejected"; noteId: string; text: string };
|
||||
|
||||
export interface SaveVoiceNoteInput {
|
||||
userId: string;
|
||||
recording: Recording;
|
||||
colonyId: string;
|
||||
inspectionDate: string;
|
||||
online: boolean;
|
||||
/** The note this recording already has on the device (a retry must not queue a second one). */
|
||||
queuedNoteId?: string | null;
|
||||
/** Called as soon as the recording is on the device. */
|
||||
onQueued?(noteId: string): void;
|
||||
}
|
||||
|
||||
export async function saveVoiceNote(input: SaveVoiceNoteInput): Promise<SaveVoiceOutcome> {
|
||||
const { userId, recording, colonyId, inspectionDate, online, onQueued } = input;
|
||||
let noteId = input.queuedNoteId ?? null;
|
||||
if (!noteId) {
|
||||
const note = await queueVoiceNote({
|
||||
ownerUserId: userId,
|
||||
blob: recording.blob,
|
||||
mime: recording.mime,
|
||||
durationSec: recording.durationSec,
|
||||
colonyId,
|
||||
inspectionDate,
|
||||
});
|
||||
noteId = note.id;
|
||||
onQueued?.(noteId);
|
||||
}
|
||||
if (!online) {
|
||||
return { kind: "queued" };
|
||||
}
|
||||
try {
|
||||
await syncPendingEntries(userId, { afterChange: true });
|
||||
} catch {
|
||||
// The note is safe in the queue; the next sync run sends it.
|
||||
return { kind: "queued" };
|
||||
}
|
||||
let after;
|
||||
try {
|
||||
after = await getPendingVoice(noteId);
|
||||
} catch {
|
||||
// Unknown what the sync did; the note id is kept, so a retry only reads again.
|
||||
return { kind: "queued" };
|
||||
}
|
||||
if (!after) {
|
||||
return { kind: "sent" };
|
||||
}
|
||||
if (after.lastError) {
|
||||
return { kind: "rejected", noteId, text: voiceRejectionText(after.lastError) };
|
||||
}
|
||||
return after.blockedBy ? { kind: "queued", hint: VOICE_TOO_MANY_OPEN_HINT } : { kind: "queued" };
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
VOICE_ERROR_TEXTS_DE,
|
||||
VOICE_STATUS_TEXTS_DE,
|
||||
VOICE_WAITING_FOR_NETWORK,
|
||||
describeVoiceCapture,
|
||||
isListedVoiceStatus,
|
||||
isVoiceInProgress,
|
||||
voiceErrorText,
|
||||
} from "./status-texts";
|
||||
|
||||
const backendTexts = path.resolve(process.cwd(), "../ApiCore-Backend/src/voice/voice-error-texts.ts");
|
||||
|
||||
describe("voice status texts", () => {
|
||||
// Same sentences as the backend (notifications) say.
|
||||
it.skipIf(!existsSync(backendTexts))("equals the backend error texts", () => {
|
||||
const source = readFileSync(backendTexts, "utf8");
|
||||
const backend = new Map(
|
||||
[...source.matchAll(/([A-Z_]+):\s*'([^']*)'/g)].map((m) => [m[1], m[2]]),
|
||||
);
|
||||
expect(backend.size).toBeGreaterThan(10);
|
||||
|
||||
for (const [code, text] of backend) {
|
||||
expect(VOICE_ERROR_TEXTS_DE[code], code).toBe(text);
|
||||
}
|
||||
});
|
||||
|
||||
it("points at the retry button where the list offers one", () => {
|
||||
expect(VOICE_ERROR_TEXTS_DE.TRANSCRIBE_FAILED).toContain("Nochmal versuchen");
|
||||
expect(VOICE_ERROR_TEXTS_DE.EXTRACT_FAILED).toContain("erneut auswerten");
|
||||
});
|
||||
|
||||
it("lets the user read the text of a failed evaluation", () => {
|
||||
for (const lastErrorCode of ["EXTRACT_FAILED", "EXTRACT_INVALID", "REFUSED"]) {
|
||||
expect(describeVoiceCapture({ status: "FAILED", lastErrorCode }).action).toBe("read-text");
|
||||
}
|
||||
expect(describeVoiceCapture({ status: "FAILED", lastErrorCode: "NO_SPEECH" }).action).toBe("none");
|
||||
});
|
||||
|
||||
it("says Text liegt vor when the evaluation is switched off", () => {
|
||||
const state = describeVoiceCapture({ status: "TRANSCRIBED", lastErrorCode: null }, false);
|
||||
|
||||
expect(state.status.label).toBe("Text liegt vor");
|
||||
expect(state.action).toBe("read-text");
|
||||
expect(describeVoiceCapture({ status: "TRANSCRIBED", lastErrorCode: null }, true).status.label).toBe(
|
||||
"Wird ausgewertet",
|
||||
);
|
||||
expect(isVoiceInProgress("TRANSCRIBED", false)).toBe(false);
|
||||
expect(isVoiceInProgress("TRANSCRIBED", true)).toBe(true);
|
||||
});
|
||||
|
||||
it("names every listed state in words with an icon", () => {
|
||||
expect(VOICE_WAITING_FOR_NETWORK.label).toBe("Wartet auf Netz");
|
||||
expect(VOICE_STATUS_TEXTS_DE.QUEUED.label).toBe("Wird abgeschrieben");
|
||||
expect(VOICE_STATUS_TEXTS_DE.EXTRACTING.label).toBe("Wird ausgewertet");
|
||||
expect(VOICE_STATUS_TEXTS_DE.READY.label).toBe("Fertig – prüfen");
|
||||
expect(VOICE_STATUS_TEXTS_DE.FAILED.label).toBe("Fehlgeschlagen");
|
||||
for (const entry of Object.values(VOICE_STATUS_TEXTS_DE)) {
|
||||
expect(entry.icon).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("offers Prüfen only for a ready note", () => {
|
||||
expect(describeVoiceCapture({ status: "READY", lastErrorCode: null }).action).toBe("review");
|
||||
for (const status of ["QUEUED", "TRANSCRIBING", "TRANSCRIBED", "EXTRACTING", "FAILED"] as const) {
|
||||
expect(describeVoiceCapture({ status, lastErrorCode: null }).action).toBe("none");
|
||||
}
|
||||
});
|
||||
|
||||
it("lets the user read the text when the evaluation is off", () => {
|
||||
const state = describeVoiceCapture({ status: "TRANSCRIBED", lastErrorCode: "EXTRACT_DISABLED" });
|
||||
|
||||
expect(state.action).toBe("read-text");
|
||||
expect(state.status.label).toBe("Text liegt vor");
|
||||
expect(state.detail).toBe(VOICE_ERROR_TEXTS_DE.EXTRACT_DISABLED);
|
||||
});
|
||||
|
||||
it("allows another try only where one can help", () => {
|
||||
const canRetry = (lastErrorCode: string) =>
|
||||
describeVoiceCapture({ status: "FAILED", lastErrorCode }).canRetry;
|
||||
|
||||
expect(canRetry("EXTRACT_FAILED")).toBe(true);
|
||||
expect(canRetry("EXTRACT_INVALID")).toBe(true);
|
||||
expect(canRetry("NO_SPEECH")).toBe(false);
|
||||
expect(canRetry("AUDIO_EXPIRED")).toBe(false);
|
||||
expect(describeVoiceCapture({ status: "READY", lastErrorCode: null }).canRetry).toBe(false);
|
||||
});
|
||||
|
||||
it("uses fixed sentences for codes, never the server message", () => {
|
||||
expect(voiceErrorText("TOO_LONG")).toBe(VOICE_ERROR_TEXTS_DE.TOO_LONG);
|
||||
expect(voiceErrorText("SOMETHING_NEW")).toContain("nicht verarbeitet");
|
||||
expect(voiceErrorText(null)).toContain("nicht verarbeitet");
|
||||
});
|
||||
|
||||
it("lists open and failed notes and polls only the running ones", () => {
|
||||
expect(isListedVoiceStatus("READY")).toBe(true);
|
||||
expect(isListedVoiceStatus("FAILED")).toBe(true);
|
||||
expect(isListedVoiceStatus("CONFIRMED")).toBe(false);
|
||||
expect(isListedVoiceStatus("DISCARDED")).toBe(false);
|
||||
expect(isListedVoiceStatus("EXPIRED")).toBe(false);
|
||||
expect(isVoiceInProgress("EXTRACTING")).toBe(true);
|
||||
expect(isVoiceInProgress("READY")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { VOICE_RECORDING_DISABLED_TEXT } from "@/lib/offline/queue-texts";
|
||||
import type { VoiceCaptureListItem, VoiceCaptureStatus } from "./api";
|
||||
|
||||
/*
|
||||
* Words for the voice note list. The state always has a word and an icon
|
||||
* name; colour never carries it alone. Error texts are fixed sentences per
|
||||
* server code (mirror of the backend's VOICE_ERROR_TEXTS_DE), never server
|
||||
* text, so nothing from a note's content can appear here.
|
||||
*/
|
||||
|
||||
export type VoiceStatusIcon = "wait" | "work" | "ready" | "failed";
|
||||
|
||||
export interface VoiceStatusText {
|
||||
label: string;
|
||||
icon: VoiceStatusIcon;
|
||||
}
|
||||
|
||||
/** A note that still sits on this device, not yet on the server. */
|
||||
export const VOICE_WAITING_FOR_NETWORK: VoiceStatusText = {
|
||||
label: "Wartet auf Netz",
|
||||
icon: "wait",
|
||||
};
|
||||
|
||||
export const VOICE_STATUS_TEXTS_DE: Readonly<
|
||||
Record<"QUEUED" | "TRANSCRIBING" | "TRANSCRIBED" | "EXTRACTING" | "READY" | "FAILED", VoiceStatusText>
|
||||
> = {
|
||||
QUEUED: { label: "Wird abgeschrieben", icon: "work" },
|
||||
TRANSCRIBING: { label: "Wird abgeschrieben", icon: "work" },
|
||||
TRANSCRIBED: { label: "Wird ausgewertet", icon: "work" },
|
||||
EXTRACTING: { label: "Wird ausgewertet", icon: "work" },
|
||||
READY: { label: "Fertig – prüfen", icon: "ready" },
|
||||
FAILED: { label: "Fehlgeschlagen", icon: "failed" },
|
||||
};
|
||||
|
||||
/** Statuses the list shows; confirmed, discarded and expired notes are done. */
|
||||
const LISTED: readonly VoiceCaptureStatus[] = [
|
||||
"QUEUED",
|
||||
"TRANSCRIBING",
|
||||
"TRANSCRIBED",
|
||||
"EXTRACTING",
|
||||
"READY",
|
||||
"FAILED",
|
||||
];
|
||||
|
||||
/** Statuses a poll should wait out. */
|
||||
const IN_PROGRESS: readonly VoiceCaptureStatus[] = [
|
||||
"QUEUED",
|
||||
"TRANSCRIBING",
|
||||
"TRANSCRIBED",
|
||||
"EXTRACTING",
|
||||
];
|
||||
|
||||
export function isListedVoiceStatus(status: VoiceCaptureStatus): boolean {
|
||||
return LISTED.includes(status);
|
||||
}
|
||||
|
||||
/** With the evaluation switched off a transcribed note is done: nothing more will happen. */
|
||||
export function isVoiceInProgress(status: VoiceCaptureStatus, extractionEnabled = true): boolean {
|
||||
if (status === "TRANSCRIBED" && !extractionEnabled) {
|
||||
return false;
|
||||
}
|
||||
return IN_PROGRESS.includes(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Same sentences as the backend's VOICE_ERROR_TEXTS_DE (a spec compares them).
|
||||
*/
|
||||
export const VOICE_ERROR_TEXTS_DE: Readonly<Record<string, string>> = {
|
||||
UNSUPPORTED_AUDIO: "Das Audioformat wird nicht unterstützt.",
|
||||
TOO_LARGE: "Die Aufnahme ist zu groß. Bitte kürzer aufnehmen.",
|
||||
TOO_MANY_OPEN: "Zu viele offene Sprachnotizen. Erst auswerten oder verwerfen.",
|
||||
NO_SPEECH: "Es war nichts zu verstehen. Bitte noch einmal aufnehmen.",
|
||||
TOO_LONG: "Die Aufnahme ist zu lang. Bitte kürzer aufnehmen.",
|
||||
TRANSCRIBE_FAILED:
|
||||
"Die Aufnahme konnte nicht abgeschrieben werden. Bitte „Nochmal versuchen“ oder noch einmal aufnehmen.",
|
||||
AUDIO_EXPIRED: "Die Aufnahme wurde nach 24 Stunden gelöscht. Bitte noch einmal aufnehmen.",
|
||||
EXTRACT_DISABLED: "Die Auswertung ist ausgeschaltet. Bitte die Durchsicht von Hand ausfüllen.",
|
||||
EXTRACT_RETRY: "Die Auswertung wird noch einmal versucht.",
|
||||
EXTRACT_FAILED:
|
||||
"Die Auswertung hat nicht geklappt. Bitte erneut auswerten oder von Hand ausfüllen.",
|
||||
DAILY_LIMIT: "Das Tageslimit für die Auswertung ist erreicht. Bitte morgen erneut auswerten.",
|
||||
EXTRACT_INVALID:
|
||||
"Die Auswertung war nicht brauchbar. Bitte erneut auswerten oder von Hand ausfüllen.",
|
||||
REFUSED: "Die Auswertung wurde abgelehnt. Bitte die Durchsicht von Hand ausfüllen.",
|
||||
VOICE_CAPTURE_GONE: "Die Sprachnotiz ist nicht mehr vorhanden.",
|
||||
VOICE_RECORDING_DISABLED: VOICE_RECORDING_DISABLED_TEXT,
|
||||
};
|
||||
|
||||
const GENERIC_FAILURE = "Die Sprachnotiz konnte nicht verarbeitet werden. Bitte noch einmal aufnehmen.";
|
||||
|
||||
/** Codes after which a second try of the same note can work. */
|
||||
const RETRYABLE_CODES: readonly string[] = ["EXTRACT_FAILED", "EXTRACT_INVALID", "TRANSCRIBE_FAILED"];
|
||||
|
||||
/** Codes with which the text exists but could not be evaluated: the form still shows it. */
|
||||
const TEXT_ONLY_CODES: readonly string[] = ["EXTRACT_DISABLED", "DAILY_LIMIT"];
|
||||
|
||||
/** Failed evaluations still leave the transcript to read. */
|
||||
const TEXT_KEPT_ON_FAILURE: readonly string[] = ["EXTRACT_FAILED", "EXTRACT_INVALID", "REFUSED"];
|
||||
|
||||
export function voiceErrorText(code: string | null): string {
|
||||
return (code && VOICE_ERROR_TEXTS_DE[code]) || GENERIC_FAILURE;
|
||||
}
|
||||
|
||||
export type VoiceCardAction = "review" | "read-text" | "none";
|
||||
|
||||
export interface VoiceCardState {
|
||||
status: VoiceStatusText;
|
||||
/** One short sentence for a failure or a pause; null when nothing is wrong. */
|
||||
detail: string | null;
|
||||
action: VoiceCardAction;
|
||||
canRetry: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the card of a server-side note says and offers. `extractionEnabled`
|
||||
* false (the operator switched the evaluation off) turns a plain TRANSCRIBED
|
||||
* note into „Text liegt vor“.
|
||||
*/
|
||||
export function describeVoiceCapture(
|
||||
item: Pick<VoiceCaptureListItem, "status" | "lastErrorCode">,
|
||||
extractionEnabled = true,
|
||||
): VoiceCardState {
|
||||
const code = item.lastErrorCode;
|
||||
const textOnly =
|
||||
item.status === "TRANSCRIBED" && ((code && TEXT_ONLY_CODES.includes(code)) || !extractionEnabled);
|
||||
if (textOnly) {
|
||||
return {
|
||||
status: { label: "Text liegt vor", icon: "wait" },
|
||||
detail: code && TEXT_ONLY_CODES.includes(code) ? voiceErrorText(code) : null,
|
||||
action: "read-text",
|
||||
canRetry: false,
|
||||
};
|
||||
}
|
||||
const status = VOICE_STATUS_TEXTS_DE[item.status as keyof typeof VOICE_STATUS_TEXTS_DE];
|
||||
let action: VoiceCardAction = "none";
|
||||
if (item.status === "READY") {
|
||||
action = "review";
|
||||
} else if (item.status === "FAILED" && code !== null && TEXT_KEPT_ON_FAILURE.includes(code)) {
|
||||
action = "read-text";
|
||||
}
|
||||
return {
|
||||
status: status ?? VOICE_STATUS_TEXTS_DE.FAILED,
|
||||
detail: item.status === "FAILED" ? voiceErrorText(code) : null,
|
||||
action,
|
||||
canRetry: item.status === "FAILED" && code !== null && RETRYABLE_CODES.includes(code),
|
||||
};
|
||||
}
|
||||
@@ -96,6 +96,20 @@ describe("uploadPendingVoice", () => {
|
||||
expect(notes.every((n) => n.lastError === undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a 403 VOICE_RECORDING_DISABLED as a definitive refusal on the note", async () => {
|
||||
const note = await pending.queueVoiceNote(input());
|
||||
uploadVoiceCapture.mockRejectedValueOnce(
|
||||
new ApiError(403, "off", { code: "VOICE_RECORDING_DISABLED" }),
|
||||
);
|
||||
|
||||
const result = await upload.uploadPendingVoice("user-1", never);
|
||||
|
||||
expect(result.failed).toBe(1);
|
||||
const [kept] = await pending.listPendingVoice("user-1");
|
||||
expect(kept.id).toBe(note.id);
|
||||
expect(kept.lastError?.status).toBe(403);
|
||||
});
|
||||
|
||||
it("a 409 'still being processed' skips that note and goes on", async () => {
|
||||
await pending.queueVoiceNote(input());
|
||||
await pending.queueVoiceNote(input("user-1", "colony-2"));
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import { fetchVoiceConfig } from "./api";
|
||||
import { useVoiceRecordingSwitch } from "./use-voice-recording-switch";
|
||||
|
||||
vi.mock("./api", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./api")>()),
|
||||
fetchVoiceConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
describe("useVoiceRecordingSwitch", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchVoiceConfig).mockReset();
|
||||
});
|
||||
|
||||
it("is unknown when the server cannot be reached", async () => {
|
||||
vi.mocked(fetchVoiceConfig).mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
const { result } = renderHook(() => useVoiceRecordingSwitch(true), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current).toBe("unknown"));
|
||||
});
|
||||
|
||||
it.each([403, 404, 500])("is off when the server answers %s", async (status) => {
|
||||
vi.mocked(fetchVoiceConfig).mockRejectedValue(new ApiError(status, "no"));
|
||||
const { result } = renderHook(() => useVoiceRecordingSwitch(true), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current).toBe("off"));
|
||||
});
|
||||
|
||||
it("follows recordingEnabled", async () => {
|
||||
vi.mocked(fetchVoiceConfig).mockResolvedValue({
|
||||
recordingEnabled: true,
|
||||
extractionEnabled: true,
|
||||
model: "m",
|
||||
retention: { audioHours: 24, contentDays: 7, rowDays: 90 },
|
||||
});
|
||||
const { result } = renderHook(() => useVoiceRecordingSwitch(true), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current).toBe("on"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { isNetworkError } from "@/lib/http/fetch-with-timeout";
|
||||
import { fetchVoiceConfig, voiceKeys } from "./api";
|
||||
|
||||
/**
|
||||
* Whether the operator allows voice recording:
|
||||
* - "loading": the answer is on its way; entry points wait, so a disabled
|
||||
* feature never flashes a button;
|
||||
* - "on" / "off": the server's answer;
|
||||
* - "unknown": the server could not be asked (offline, no connection). Recording and
|
||||
* the local queue work offline, so entry points stay.
|
||||
*/
|
||||
export type RecordingSwitch = "loading" | "on" | "off" | "unknown";
|
||||
|
||||
const CONFIG_STALE_MS = 5 * 60 * 1000;
|
||||
|
||||
/** The server's voice settings, shared by every entry point (one request, cached). */
|
||||
export function useVoiceConfig(enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: voiceKeys.config,
|
||||
queryFn: fetchVoiceConfig,
|
||||
enabled,
|
||||
staleTime: CONFIG_STALE_MS,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useVoiceRecordingSwitch(enabled: boolean): RecordingSwitch {
|
||||
const query = useVoiceConfig(enabled);
|
||||
if (query.data) {
|
||||
return query.data.recordingEnabled ? "on" : "off";
|
||||
}
|
||||
// Only "could not ask" keeps the entries. A server answer without a config
|
||||
// (403, 404, 5xx: feature missing, forbidden, broken) counts as off.
|
||||
if (query.fetchStatus === "paused" || (query.isError && isNetworkError(query.error))) {
|
||||
return "unknown";
|
||||
}
|
||||
if (query.isError) {
|
||||
return "off";
|
||||
}
|
||||
return "loading";
|
||||
}
|
||||
|
||||
/** Entry points are offered unless the answer is pending or says no. */
|
||||
export function offersRecording(recordingSwitch: RecordingSwitch): boolean {
|
||||
return recordingSwitch === "on" || recordingSwitch === "unknown";
|
||||
}
|
||||
Reference in new issue
Block a user