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
@@ -12,7 +12,7 @@ export const VOICE_ERROR_TEXTS_DE: Readonly<Record<VoiceErrorCode, string>> = {
|
||||
NO_SPEECH: 'Es war nichts zu verstehen. Bitte noch einmal aufnehmen.',
|
||||
TOO_LONG: 'Die Aufnahme ist zu lang. Bitte kürzer aufnehmen.',
|
||||
TRANSCRIBE_FAILED:
|
||||
'Die Aufnahme konnte nicht abgeschrieben werden. Bitte noch einmal aufnehmen.',
|
||||
'Die Aufnahme konnte nicht abgeschrieben werden. Bitte „Nochmal versuchen“ oder noch einmal aufnehmen.',
|
||||
AUDIO_EXPIRED:
|
||||
'Die Aufnahme wurde nach 24 Stunden gelöscht. Bitte noch einmal aufnehmen.',
|
||||
EXTRACT_DISABLED:
|
||||
|
||||
@@ -35,6 +35,11 @@ vi.mock("@/components/autopilot/next-steps-card", () => ({
|
||||
NextStepsCard: ({ colonyId }: { colonyId: string }) => <p>Als Nächstes für {colonyId}</p>,
|
||||
}));
|
||||
vi.mock("@/components/qr-code-dialog", () => ({ QrCodeDialog: () => null }));
|
||||
vi.mock("@/components/voice/voice-entry-link", () => ({
|
||||
VoiceEntryLink: ({ colonyId }: { colonyId: string }) => (
|
||||
<a href={`/voice/new?colonyId=${colonyId}`}>Sprachnotiz aufnehmen</a>
|
||||
),
|
||||
}));
|
||||
|
||||
function renderPage() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
@@ -99,6 +104,15 @@ describe("ColonyDetailPage – strength", () => {
|
||||
expect(await screen.findByText("Volksstärke (alt): 7 von 10")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers a voice note next to „Durchsicht erfassen“", async () => {
|
||||
vi.mocked(fetchColony).mockResolvedValue(colony);
|
||||
renderPage();
|
||||
|
||||
const voice = await screen.findByRole("link", { name: "Sprachnotiz aufnehmen" });
|
||||
expect(voice).toHaveAttribute("href", `/voice/new?colonyId=${colony.id}`);
|
||||
expect(screen.getByRole("button", { name: "Durchsicht erfassen" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows no strength line when none is stored", async () => {
|
||||
vi.mocked(fetchColony).mockResolvedValue(colony);
|
||||
renderPage();
|
||||
|
||||
@@ -13,6 +13,7 @@ import { NextStepsCard } from "@/components/autopilot/next-steps-card";
|
||||
import { QrCodeDialog } from "@/components/qr-code-dialog";
|
||||
import { DocumentUploadCard } from "@/components/document-upload-card";
|
||||
import { DetailHeader, DetailQueryFallback, DetailStatus } from "@/components/detail-header";
|
||||
import { VoiceEntryLink } from "@/components/voice/voice-entry-link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ColonyEditCard } from "./colony-edit-card";
|
||||
import { ColonyActions } from "./colony-actions";
|
||||
@@ -60,14 +61,17 @@ export default function ColonyDetailPage() {
|
||||
subtitle={COLONY_TYPE_LABELS[colony.colonyType]}
|
||||
status={<DetailStatus status={colony.status} label={COLONY_STATUS_LABELS[colony.status]} />}
|
||||
primaryAction={
|
||||
<Button
|
||||
size="touch"
|
||||
nativeButton={false}
|
||||
render={<Link href={`/inspections/new?colonyId=${colony.id}`} />}
|
||||
>
|
||||
<ClipboardCheck aria-hidden />
|
||||
Durchsicht erfassen
|
||||
</Button>
|
||||
<>
|
||||
<VoiceEntryLink colonyId={colony.id} />
|
||||
<Button
|
||||
size="touch"
|
||||
nativeButton={false}
|
||||
render={<Link href={`/inspections/new?colonyId=${colony.id}`} />}
|
||||
>
|
||||
<ClipboardCheck aria-hidden />
|
||||
Durchsicht erfassen
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
moreActions={[
|
||||
{
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { DRAFT_PREFIX } from "@/lib/forms/draft-storage";
|
||||
import { initialInspectionValues } from "./inspection-form-values";
|
||||
import { markColonyDone, readRound, startRound } from "@/lib/rounds/round-state";
|
||||
import { fetchVoiceConfig } from "@/lib/voice/api";
|
||||
|
||||
const push = vi.fn();
|
||||
let searchString = "colonyId=colony-1";
|
||||
@@ -42,6 +43,10 @@ vi.mock("@/lib/apiaries/field-api", async (importOriginal) => ({
|
||||
fetchColonyOverview: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() }));
|
||||
vi.mock("@/lib/voice/api", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/voice/api")>()),
|
||||
fetchVoiceConfig: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/inspections/api", () => ({
|
||||
createInspection: vi.fn(),
|
||||
fetchColonyInspections: vi.fn(),
|
||||
@@ -739,6 +744,20 @@ describe("NewInspectionPage", () => {
|
||||
await userEvent.click(screen.getByRole("button", { name: "Durchsicht speichern" }));
|
||||
}
|
||||
|
||||
it("offers a voice note for the colony of the round next to the chip", async () => {
|
||||
permissions = new Set(["voice-captures:use", "inspections:create"]);
|
||||
vi.mocked(fetchVoiceConfig).mockResolvedValue({
|
||||
recordingEnabled: true,
|
||||
extractionEnabled: true,
|
||||
model: "claude-sonnet-5-5",
|
||||
retention: { audioHours: 24, contentDays: 7, rowDays: 90 },
|
||||
});
|
||||
renderPage();
|
||||
|
||||
const link = await screen.findByRole("button", { name: "Sprachnotiz aufnehmen" });
|
||||
expect(link).toHaveAttribute("href", "/voice/new?colonyId=colony-1&round=apiary-1");
|
||||
});
|
||||
|
||||
it("shows where in the round the beekeeper is", async () => {
|
||||
renderPage();
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { DictationRow } from "@/components/voice/dictation-button";
|
||||
import { VoiceEntryLink } from "@/components/voice/voice-entry-link";
|
||||
import { appendDictation } from "@/lib/voice/append-text";
|
||||
import { FormBackLink } from "@/components/form/form-back-link";
|
||||
import { RoundNextPanel } from "@/components/rounds/round-next-panel";
|
||||
@@ -564,9 +565,14 @@ function InspectionCaptureForm({
|
||||
draft={draft}
|
||||
/>
|
||||
{stand.round ? (
|
||||
<p className="w-fit rounded-full bg-muted px-3 py-1 text-sm font-medium">
|
||||
{roundChipText(stand.round, values.colonyId)}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<p className="w-fit rounded-full bg-muted px-3 py-1 text-sm font-medium">
|
||||
{roundChipText(stand.round, values.colonyId)}
|
||||
</p>
|
||||
{values.colonyId ? (
|
||||
<VoiceEntryLink colonyId={values.colonyId} roundApiaryId={stand.round.apiaryId} />
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<PageHeader
|
||||
icon={ClipboardCheck}
|
||||
|
||||
@@ -74,6 +74,11 @@ vi.mock("@/components/offline/offline-band", () => ({
|
||||
),
|
||||
}));
|
||||
vi.mock("@/components/user-avatar", () => ({ UserAvatar: () => null }));
|
||||
// The quick-add entry for voice notes asks the server; this spec is about the layout.
|
||||
vi.mock("@/lib/voice/use-voice-recording-switch", () => ({
|
||||
useVoiceRecordingSwitch: () => "on",
|
||||
offersRecording: () => true,
|
||||
}));
|
||||
vi.mock("@/lib/photos/store", () => ({
|
||||
purgeStaleDraftPhotos: vi.fn().mockResolvedValue(0),
|
||||
finishHandOvers: vi.fn().mockResolvedValue(0),
|
||||
|
||||
@@ -7,9 +7,10 @@ import {
|
||||
} from "./nav-config";
|
||||
|
||||
const ALL_ITEMS = [...NAV_GROUPS, ADMIN_GROUP].flatMap((group) => group.items);
|
||||
const ALL_PERMISSIONS = ALL_ITEMS.flatMap((item) =>
|
||||
item.permission === undefined ? [] : [item.permission].flat(),
|
||||
);
|
||||
const ALL_PERMISSIONS = ALL_ITEMS.flatMap((item) => [
|
||||
...(item.permission === undefined ? [] : [item.permission].flat()),
|
||||
...(item.alsoRequires ?? []),
|
||||
]);
|
||||
|
||||
function hrefs(permissions: string[]): string[] {
|
||||
return visibleNavGroups({ permissions })
|
||||
@@ -72,6 +73,17 @@ describe("nav-config", () => {
|
||||
expect(item?.counter).toBe("suggestions");
|
||||
});
|
||||
|
||||
it("shows „Sprachnotizen“ only with voice-captures:use AND inspections:create", () => {
|
||||
expect(hrefs([])).not.toContain("/voice");
|
||||
expect(hrefs(["voice-captures:use"])).not.toContain("/voice");
|
||||
expect(hrefs(["inspections:create"])).not.toContain("/voice");
|
||||
const work = visibleNavGroups({
|
||||
permissions: ["voice-captures:use", "inspections:create"],
|
||||
}).find((group) => group.label === "Arbeit");
|
||||
const item = work?.items.find((entry) => entry.href === "/voice");
|
||||
expect(item?.label).toBe("Sprachnotizen");
|
||||
});
|
||||
|
||||
it("shows Prüfbereitschaft only with compliance:read", () => {
|
||||
expect(hrefs([])).not.toContain("/compliance");
|
||||
expect(hrefs(["compliance:read"])).toContain("/compliance");
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
ListTodo,
|
||||
type LucideIcon,
|
||||
MapPin,
|
||||
Mic,
|
||||
Package,
|
||||
Scissors,
|
||||
Search,
|
||||
@@ -44,6 +45,8 @@ export interface NavItem {
|
||||
// together several previously-separate settings pages, each gated by its
|
||||
// own permission.
|
||||
permission?: string | string[];
|
||||
/** Permissions that are ALL needed on top of `permission` (which is any-of). */
|
||||
alsoRequires?: string[];
|
||||
/** A number shown next to the label; the data comes from the matching hook. */
|
||||
counter?: "suggestions";
|
||||
}
|
||||
@@ -98,6 +101,13 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
counter: "suggestions",
|
||||
},
|
||||
{ label: "Kalender", href: "/calendar", icon: CalendarDays },
|
||||
{
|
||||
label: "Sprachnotizen",
|
||||
href: "/voice",
|
||||
icon: Mic,
|
||||
permission: "voice-captures:use",
|
||||
alsoRequires: ["inspections:create"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -204,7 +214,11 @@ export function visibleNavGroups(user: Pick<AuthUser, "permissions">): NavGroup[
|
||||
return [...NAV_GROUPS, ADMIN_GROUP]
|
||||
.map((group) => ({
|
||||
...group,
|
||||
items: group.items.filter((item) => isVisible(user, item.permission)),
|
||||
items: group.items.filter(
|
||||
(item) =>
|
||||
isVisible(user, item.permission) &&
|
||||
(item.alsoRequires ?? []).every((key) => user.permissions.includes(key)),
|
||||
),
|
||||
}))
|
||||
.filter((group) => group.items.length > 0);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ClipboardCheck,
|
||||
ListTodo,
|
||||
type LucideIcon,
|
||||
Mic,
|
||||
Scissors,
|
||||
Sprout,
|
||||
Syringe,
|
||||
@@ -15,6 +16,10 @@ export interface QuickAction {
|
||||
icon: LucideIcon;
|
||||
/** Creating needs this permission; actions without it are not offered. */
|
||||
permission: string;
|
||||
/** A second permission the action also needs (the note ends in an inspection). */
|
||||
alsoPermission?: string;
|
||||
/** Offered only while the operator allows voice recording (GET /voice-captures/config). */
|
||||
needsVoiceRecording?: boolean;
|
||||
}
|
||||
|
||||
export const QUICK_ACTIONS: readonly QuickAction[] = [
|
||||
@@ -24,6 +29,14 @@ export const QUICK_ACTIONS: readonly QuickAction[] = [
|
||||
icon: ClipboardCheck,
|
||||
permission: "inspections:create",
|
||||
},
|
||||
{
|
||||
label: "Sprachnotiz",
|
||||
href: "/voice/new",
|
||||
icon: Mic,
|
||||
permission: "voice-captures:use",
|
||||
alsoPermission: "inspections:create",
|
||||
needsVoiceRecording: true,
|
||||
},
|
||||
{
|
||||
label: "Varroa-Messung",
|
||||
href: "/varroa/new",
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { render as rtlRender, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactElement } from "react";
|
||||
import { fetchVoiceConfig } from "@/lib/voice/api";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QUICK_ACTIONS } from "./quick-actions";
|
||||
import { QuickAddSheet } from "./quick-add-sheet";
|
||||
@@ -9,11 +12,29 @@ vi.mock("next/navigation", () => ({
|
||||
usePathname: () => mockPathname,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/voice/api", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/voice/api")>()),
|
||||
fetchVoiceConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
const ALL = QUICK_ACTIONS.map((action) => action.permission);
|
||||
|
||||
const CONFIG = {
|
||||
recordingEnabled: true,
|
||||
extractionEnabled: true,
|
||||
model: "claude-sonnet-5-5",
|
||||
retention: { audioHours: 24, contentDays: 7, rowDays: 90 },
|
||||
};
|
||||
|
||||
function render(ui: ReactElement) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return rtlRender(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
describe("QuickAddSheet", () => {
|
||||
beforeEach(() => {
|
||||
mockPathname = "/dashboard";
|
||||
vi.mocked(fetchVoiceConfig).mockReset().mockResolvedValue(CONFIG);
|
||||
});
|
||||
|
||||
it("lists actions with icons as links", async () => {
|
||||
@@ -25,9 +46,66 @@ describe("QuickAddSheet", () => {
|
||||
expect(link).toHaveAttribute("href", "/inspections/new");
|
||||
expect(link).toHaveClass("min-h-14");
|
||||
expect(link.querySelector("svg.size-6")).not.toBeNull();
|
||||
await screen.findByRole("link", { name: "Sprachnotiz" });
|
||||
expect(screen.getAllByRole("link")).toHaveLength(QUICK_ACTIONS.length);
|
||||
});
|
||||
|
||||
it("offers Sprachnotiz with a microphone icon when recording is on", async () => {
|
||||
render(<QuickAddSheet user={{ permissions: ALL }} variant="fab" />);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Neu" }));
|
||||
|
||||
const link = await screen.findByRole("link", { name: "Sprachnotiz" });
|
||||
expect(link).toHaveAttribute("href", "/voice/new");
|
||||
expect(link.querySelector("svg.lucide-mic")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("the quick-add entry is hidden without permission or when recording is disabled", async () => {
|
||||
// Without voice-captures:use the entry is not offered and the config is never asked.
|
||||
const { unmount } = render(
|
||||
<QuickAddSheet
|
||||
user={{ permissions: ALL.filter((p) => p !== "voice-captures:use") }}
|
||||
variant="fab"
|
||||
/>,
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Neu" }));
|
||||
await screen.findByRole("link", { name: "Durchsicht" });
|
||||
expect(screen.queryByRole("link", { name: "Sprachnotiz" })).not.toBeInTheDocument();
|
||||
expect(fetchVoiceConfig).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
|
||||
// Without inspections:create the note could never be reviewed.
|
||||
const second = render(
|
||||
<QuickAddSheet
|
||||
user={{ permissions: ALL.filter((p) => p !== "inspections:create") }}
|
||||
variant="fab"
|
||||
/>,
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Neu" }));
|
||||
await screen.findByRole("link", { name: "Behandlung" });
|
||||
expect(screen.queryByRole("link", { name: "Sprachnotiz" })).not.toBeInTheDocument();
|
||||
second.unmount();
|
||||
|
||||
// The operator switched recording off: no dead entry.
|
||||
vi.mocked(fetchVoiceConfig).mockResolvedValue({ ...CONFIG, recordingEnabled: false });
|
||||
render(<QuickAddSheet user={{ permissions: ALL }} variant="fab" />);
|
||||
await waitFor(() => expect(fetchVoiceConfig).toHaveBeenCalled());
|
||||
await userEvent.click(screen.getByRole("button", { name: "Neu" }));
|
||||
await screen.findByRole("link", { name: "Durchsicht" });
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("link", { name: "Sprachnotiz" })).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the entry when the config cannot be read (offline recording works)", async () => {
|
||||
vi.mocked(fetchVoiceConfig).mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
render(<QuickAddSheet user={{ permissions: ALL }} variant="fab" />);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Neu" }));
|
||||
|
||||
expect(await screen.findByRole("link", { name: "Sprachnotiz" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides Behandlung without treatments:create", async () => {
|
||||
render(
|
||||
<QuickAddSheet
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { SheetTrigger } from "@/components/ui/sheet";
|
||||
import type { AuthUser } from "@/lib/auth/auth-context";
|
||||
import { NAV_SHEET_ROW_CLASS, NavSheet } from "@/components/nav/nav-sheet";
|
||||
import { offersRecording, useVoiceRecordingSwitch } from "@/lib/voice/use-voice-recording-switch";
|
||||
import { QUICK_ACTIONS } from "./quick-actions";
|
||||
|
||||
// Create and edit forms end in a save bar (StickySubmit) right where the
|
||||
@@ -36,8 +37,13 @@ interface QuickAddSheetProps {
|
||||
export function QuickAddSheet({ user, variant }: QuickAddSheetProps) {
|
||||
const pathname = usePathname();
|
||||
const [open, setOpen] = useState(false);
|
||||
const actions = QUICK_ACTIONS.filter((action) =>
|
||||
user.permissions.includes(action.permission),
|
||||
const mayRecord = user.permissions.includes("voice-captures:use");
|
||||
const recording = offersRecording(useVoiceRecordingSwitch(mayRecord));
|
||||
const actions = QUICK_ACTIONS.filter(
|
||||
(action) =>
|
||||
user.permissions.includes(action.permission) &&
|
||||
(!action.alsoPermission || user.permissions.includes(action.alsoPermission)) &&
|
||||
(!action.needsVoiceRecording || recording),
|
||||
);
|
||||
|
||||
if (!hasQuickAddButton(pathname, user.permissions)) {
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import axe from "axe-core";
|
||||
import { toLocalIsoDate, todayLocalIsoDate } from "@/lib/dates";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { StandRound } from "@/lib/rounds/round-state";
|
||||
import { fetchVoiceConfig } from "@/lib/voice/api";
|
||||
import type { VoiceRecorderProps } from "@/components/voice/voice-recorder";
|
||||
import NewVoiceNotePage from "./page";
|
||||
|
||||
let query = new URLSearchParams();
|
||||
vi.mock("next/navigation", () => ({
|
||||
useSearchParams: () => query,
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}));
|
||||
|
||||
let permissions = new Set<string>();
|
||||
vi.mock("@/lib/auth/auth-context", () => ({
|
||||
useAuth: () => ({ hasPermission: (key: string) => permissions.has(key) }),
|
||||
useCurrentUserId: () => "user-1",
|
||||
}));
|
||||
vi.mock("@/lib/colonies/api", () => ({
|
||||
fetchColonies: vi.fn().mockResolvedValue([
|
||||
{ id: "c-1", code: "B3", status: "ACTIVE", apiaryId: "a-1" },
|
||||
{ id: "c-2", code: "B4", status: "ACTIVE", apiaryId: "a-1" },
|
||||
]),
|
||||
}));
|
||||
vi.mock("@/lib/voice/api", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/voice/api")>()),
|
||||
fetchVoiceConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
const ROUND: StandRound = {
|
||||
version: 1,
|
||||
apiaryId: "a-1",
|
||||
apiaryName: "Hang",
|
||||
colonies: [
|
||||
{ id: "c-1", code: "B3" },
|
||||
{ id: "c-2", code: "B4" },
|
||||
],
|
||||
doneColonyIds: [],
|
||||
startedAt: "2026-10-16T08:00:00.000Z",
|
||||
};
|
||||
let activeRound: StandRound | null = null;
|
||||
const markDone = vi.fn();
|
||||
vi.mock("@/lib/rounds/use-stand-round", () => ({
|
||||
useStandRound: () => ({
|
||||
round: activeRound,
|
||||
markDone,
|
||||
next: (round: StandRound) =>
|
||||
round.colonies.find((colony) => !round.doneColonyIds.includes(colony.id)) ?? null,
|
||||
end: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
let recorderProps: VoiceRecorderProps;
|
||||
vi.mock("@/components/voice/voice-recorder", () => ({
|
||||
VoiceRecorder: (props: VoiceRecorderProps) => {
|
||||
recorderProps = props;
|
||||
return (
|
||||
<div>
|
||||
<button type="button" onClick={() => props.onBusyChange?.(true)}>
|
||||
Sperren (Test)
|
||||
</button>
|
||||
<button type="button" onClick={() => props.onSaved?.({ kind: "sent" })}>
|
||||
Aufnahme speichern (Test)
|
||||
</button>
|
||||
{props.blockedReason ? <p>{props.blockedReason}</p> : null}
|
||||
{props.savedActions}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
const CONFIG = {
|
||||
recordingEnabled: true,
|
||||
extractionEnabled: true,
|
||||
model: "claude-sonnet-5-5",
|
||||
retention: { audioHours: 24, contentDays: 7, rowDays: 90 },
|
||||
};
|
||||
|
||||
function renderPage() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<NewVoiceNotePage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("NewVoiceNotePage", () => {
|
||||
beforeEach(() => {
|
||||
query = new URLSearchParams();
|
||||
activeRound = null;
|
||||
markDone.mockReset();
|
||||
permissions = new Set(["voice-captures:use", "inspections:create"]);
|
||||
vi.mocked(fetchVoiceConfig).mockReset().mockResolvedValue(CONFIG);
|
||||
});
|
||||
|
||||
it("reads the colony and date from the link and names them in the header", async () => {
|
||||
query = new URLSearchParams({ colonyId: "c-1", date: "2026-10-14" });
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("B3 · 14.10.")).toBeInTheDocument();
|
||||
expect(recorderProps).toMatchObject({ colonyId: "c-1", inspectionDate: "2026-10-14" });
|
||||
expect(recorderProps.blockedReason).toBeNull();
|
||||
expect(screen.getByText("Sprich in ganzen Sätzen, nur du.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const daysAgo = (days: number) => toLocalIsoDate(new Date(Date.now() - days * 86_400_000));
|
||||
|
||||
it.each([
|
||||
["a date older than a week", daysAgo(9)],
|
||||
["a date in the future", toLocalIsoDate(new Date(Date.now() + 3 * 86_400_000))],
|
||||
["something that is no date", "morgen"],
|
||||
["a day that does not exist", "2026-02-31"],
|
||||
])("falls back to today for %s", async (_label, date) => {
|
||||
query = new URLSearchParams({ colonyId: "c-1", date });
|
||||
renderPage();
|
||||
|
||||
await screen.findByText(/B3 ·/);
|
||||
expect(recorderProps.inspectionDate).toBe(todayLocalIsoDate());
|
||||
});
|
||||
|
||||
it("keeps a date from the last seven days", async () => {
|
||||
query = new URLSearchParams({ colonyId: "c-1", date: daysAgo(5) });
|
||||
renderPage();
|
||||
|
||||
await screen.findByText(/B3 ·/);
|
||||
expect(recorderProps.inspectionDate).toBe(daysAgo(5));
|
||||
});
|
||||
|
||||
it("locks colony and date while a recording is running or unsaved", async () => {
|
||||
query = new URLSearchParams({ colonyId: "c-1" });
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
await user.click(await screen.findByRole("button", { name: "Ändern" }));
|
||||
expect(screen.getByLabelText(/Datum/, { selector: "input" })).toBeEnabled();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Sperren (Test)" }));
|
||||
|
||||
expect(screen.getByLabelText(/Datum/, { selector: "input" })).toBeDisabled();
|
||||
expect(screen.getByRole("combobox", { name: /Volk/ })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("lists what to talk about", async () => {
|
||||
query = new URLSearchParams({ colonyId: "c-1" });
|
||||
renderPage();
|
||||
|
||||
const topics = within(await screen.findByRole("list")).getAllByRole("listitem");
|
||||
expect(topics.map((topic) => topic.textContent)).toEqual([
|
||||
"Königin",
|
||||
"Stifte",
|
||||
"Brut",
|
||||
"Waben",
|
||||
"Sanftmut",
|
||||
"Futter",
|
||||
"Honigraum",
|
||||
"Varroa",
|
||||
"Maßnahmen",
|
||||
]);
|
||||
});
|
||||
|
||||
it("asks for a colony first when the link names none", async () => {
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("Volk wählen", { selector: "p" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Bitte zuerst ein Volk wählen.")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Ändern" })).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
|
||||
it("lets the colony and the date be changed", async () => {
|
||||
query = new URLSearchParams({ colonyId: "c-1", date: "2026-10-14" });
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "Ändern" }));
|
||||
|
||||
expect(screen.getByLabelText(/Datum/, { selector: "input" })).toHaveValue("2026-10-14");
|
||||
expect(screen.getByRole("combobox", { name: /Volk/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says so, with a way to type, when recording is switched off", async () => {
|
||||
vi.mocked(fetchVoiceConfig).mockResolvedValue({ ...CONFIG, recordingEnabled: false });
|
||||
query = new URLSearchParams({ colonyId: "c-1" });
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Sprachnotizen sind gerade ausgeschaltet",
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Durchsicht eintippen" })).toHaveAttribute(
|
||||
"href",
|
||||
"/inspections/new",
|
||||
);
|
||||
});
|
||||
|
||||
it("says so without the permission", () => {
|
||||
permissions = new Set(["voice-captures:use"]);
|
||||
renderPage();
|
||||
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("fehlt dir die Berechtigung");
|
||||
expect(fetchVoiceConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends 'Stattdessen tippen' to the inspection form of the same colony and round", async () => {
|
||||
query = new URLSearchParams({ colonyId: "c-1", round: "a-1" });
|
||||
activeRound = ROUND;
|
||||
renderPage();
|
||||
|
||||
await screen.findByText(/B3 ·/);
|
||||
expect(recorderProps.typeInsteadHref).toBe("/inspections/new?colonyId=c-1&round=a-1");
|
||||
});
|
||||
|
||||
it("offers the next colony of the round after saving", async () => {
|
||||
query = new URLSearchParams({ colonyId: "c-1", round: "a-1" });
|
||||
activeRound = ROUND;
|
||||
markDone.mockReturnValue({ ...ROUND, doneColonyIds: ["c-1"] });
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "Aufnahme speichern (Test)" }));
|
||||
|
||||
expect(markDone).toHaveBeenCalledWith("c-1");
|
||||
expect(screen.getByRole("button", { name: /Weiter mit B4/ })).toHaveAttribute(
|
||||
"href",
|
||||
"/voice/new?colonyId=c-2&round=a-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("axe finds nothing", async () => {
|
||||
query = new URLSearchParams({ colonyId: "c-1", date: "2026-10-14" });
|
||||
const { container } = renderPage();
|
||||
await screen.findByText("B3 · 14.10.");
|
||||
|
||||
const results = await axe.run(container, { rules: { "color-contrast": { enabled: false } } });
|
||||
|
||||
expect(results.violations).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowRight, Mic } from "lucide-react";
|
||||
import { fetchColonies } from "@/lib/colonies/api";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { toLocalIsoDate, todayLocalIsoDate } from "@/lib/dates";
|
||||
import { useStandRound } from "@/lib/rounds/use-stand-round";
|
||||
import type { StandRound } from "@/lib/rounds/round-state";
|
||||
import { useVoiceRecordingSwitch } from "@/lib/voice/use-voice-recording-switch";
|
||||
import { Field } from "@/components/form/field";
|
||||
import { FormBackLink } from "@/components/form/form-back-link";
|
||||
import { LivingColonySelectField } from "@/components/colony-select-field";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { VoiceRecorder } from "@/components/voice/voice-recorder";
|
||||
|
||||
const TOPICS = [
|
||||
"Königin",
|
||||
"Stifte",
|
||||
"Brut",
|
||||
"Waben",
|
||||
"Sanftmut",
|
||||
"Futter",
|
||||
"Honigraum",
|
||||
"Varroa",
|
||||
"Maßnahmen",
|
||||
] as const;
|
||||
|
||||
/** The server takes notes for the last seven days at most. */
|
||||
const MAX_DAYS_BACK = 7;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const NO_COLONY_REASON = "Bitte zuerst ein Volk wählen.";
|
||||
|
||||
/** "2026-10-14" as "14.10." */
|
||||
function shortDay(isoDate: string): string {
|
||||
const [, month, day] = isoDate.split("-");
|
||||
return month && day ? `${day}.${month}.` : "";
|
||||
}
|
||||
|
||||
function earliestDay(): string {
|
||||
return toLocalIsoDate(new Date(Date.now() - MAX_DAYS_BACK * DAY_MS));
|
||||
}
|
||||
|
||||
const CALENDAR_DAY = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
/** The day from the link if it is a real day in the last seven days, else today. */
|
||||
function validDay(requested: string | null): string {
|
||||
const today = todayLocalIsoDate();
|
||||
if (!requested || !CALENDAR_DAY.test(requested)) return today;
|
||||
const exists = toLocalIsoDate(new Date(`${requested}T12:00:00`)) === requested;
|
||||
return exists && requested >= earliestDay() && requested <= today ? requested : today;
|
||||
}
|
||||
|
||||
function backTarget(colonyId: string): { href: string; label: string } {
|
||||
return colonyId
|
||||
? { href: `/colonies/${encodeURIComponent(colonyId)}`, label: "Zum Volk" }
|
||||
: { href: "/voice", label: "Zu den Sprachnotizen" };
|
||||
}
|
||||
|
||||
function inspectionHref(colonyId: string, roundApiaryId: string | null): string {
|
||||
const params = new URLSearchParams();
|
||||
if (colonyId) params.set("colonyId", colonyId);
|
||||
if (roundApiaryId) params.set("round", roundApiaryId);
|
||||
const query = params.toString();
|
||||
return query ? `/inspections/new?${query}` : "/inspections/new";
|
||||
}
|
||||
|
||||
interface RoundNextProps {
|
||||
round: StandRound;
|
||||
next: { id: string; code: string } | null;
|
||||
onEnd(): void;
|
||||
}
|
||||
|
||||
function RoundNext({ round, next, onEnd }: RoundNextProps) {
|
||||
if (!next) {
|
||||
return (
|
||||
<Button
|
||||
size="touch"
|
||||
className="min-h-14 text-base"
|
||||
nativeButton={false}
|
||||
render={<Link href={`/apiaries/${encodeURIComponent(round.apiaryId)}`} onClick={onEnd} />}
|
||||
>
|
||||
Runde abgeschlossen – zum Stand
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
const params = new URLSearchParams({ colonyId: next.id, round: round.apiaryId });
|
||||
return (
|
||||
<Button
|
||||
size="touch"
|
||||
className="min-h-14 text-base"
|
||||
nativeButton={false}
|
||||
render={<Link href={`/voice/new?${params.toString()}`} />}
|
||||
>
|
||||
Weiter mit {next.code}
|
||||
<ArrowRight aria-hidden />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
interface CaptureFormProps {
|
||||
initialColonyId: string;
|
||||
initialDate: string;
|
||||
roundApiaryId: string | null;
|
||||
}
|
||||
|
||||
function VoiceCaptureForm({ initialColonyId, initialDate, roundApiaryId }: CaptureFormProps) {
|
||||
const [colonyId, setColonyId] = useState(initialColonyId);
|
||||
const [date, setDate] = useState(initialDate);
|
||||
const [editing, setEditing] = useState(initialColonyId === "");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [savedRound, setSavedRound] = useState<StandRound | null>(null);
|
||||
const stand = useStandRound(roundApiaryId, colonyId);
|
||||
const { data: colonies } = useQuery({ queryKey: ["colonies"], queryFn: () => fetchColonies() });
|
||||
const colonyCode = colonies?.find((colony) => colony.id === colonyId)?.code;
|
||||
|
||||
const heading = colonyId ? `${colonyCode ?? "Volk"} · ${shortDay(date)}` : "Volk wählen";
|
||||
const target = backTarget(initialColonyId);
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-5 p-4 sm:p-6">
|
||||
<FormBackLink href={target.href} label={target.label} dirty={busy} />
|
||||
<PageHeader icon={Mic} title="Sprachnotiz" description="Sprich in ganzen Sätzen, nur du." />
|
||||
<section aria-label="Volk und Datum" className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="font-heading text-xl font-semibold">{heading}</p>
|
||||
<Button
|
||||
type="button"
|
||||
size="touch"
|
||||
variant="outline"
|
||||
className="min-h-14"
|
||||
disabled={busy}
|
||||
aria-expanded={editing}
|
||||
onClick={() => setEditing((open) => !open)}
|
||||
>
|
||||
Ändern
|
||||
</Button>
|
||||
</div>
|
||||
{editing ? (
|
||||
// Colony and date are fixed while audio is being recorded or waits to be saved.
|
||||
<fieldset
|
||||
disabled={busy}
|
||||
className="flex min-w-0 flex-col gap-3 rounded-2xl border border-border bg-card p-4"
|
||||
>
|
||||
<legend className="sr-only">Volk und Datum ändern</legend>
|
||||
<LivingColonySelectField
|
||||
triggerId="voice-colony"
|
||||
value={colonyId}
|
||||
onChange={setColonyId}
|
||||
/>
|
||||
<Field id="voice-date" label="Datum" required>
|
||||
{({ id, describedBy, invalid }) => (
|
||||
<Input
|
||||
id={id}
|
||||
type="date"
|
||||
value={date}
|
||||
min={earliestDay()}
|
||||
max={todayLocalIsoDate()}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid || undefined}
|
||||
onChange={(event) => event.target.value && setDate(event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</fieldset>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<VoiceRecorder
|
||||
colonyId={colonyId}
|
||||
inspectionDate={date}
|
||||
typeInsteadHref={inspectionHref(colonyId, roundApiaryId)}
|
||||
blockedReason={colonyId ? null : NO_COLONY_REASON}
|
||||
onBusyChange={setBusy}
|
||||
onSaved={(outcome) => {
|
||||
if (outcome.kind !== "rejected" && stand.round) {
|
||||
setSavedRound(stand.markDone(colonyId));
|
||||
}
|
||||
}}
|
||||
savedActions={
|
||||
savedRound ? (
|
||||
<RoundNext round={savedRound} next={stand.next(savedRound)} onEnd={stand.end} />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<section aria-labelledby="voice-topics" className="flex flex-col gap-2">
|
||||
<h2 id="voice-topics" className="text-sm font-medium text-muted-foreground">
|
||||
Worüber sprechen?
|
||||
</h2>
|
||||
<ul className="flex flex-wrap gap-2">
|
||||
{TOPICS.map((topic) => (
|
||||
<li key={topic} className="rounded-full bg-muted px-3 py-1 text-sm">
|
||||
{topic}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-sm text-muted-foreground">Das ist eine Gedächtnisstütze, keine Pflicht.</p>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function Notice({ children }: { children: string }) {
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<PageHeader icon={Mic} title="Sprachnotiz" />
|
||||
<p role="alert" className="rounded-xl border border-border bg-card p-4 text-sm">
|
||||
{children}
|
||||
</p>
|
||||
<Button
|
||||
size="touch"
|
||||
className="min-h-14 self-start"
|
||||
nativeButton={false}
|
||||
render={<Link href="/inspections/new" />}
|
||||
>
|
||||
Durchsicht eintippen
|
||||
</Button>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NewVoiceNotePage() {
|
||||
const { hasPermission } = useAuth();
|
||||
const searchParams = useSearchParams();
|
||||
const allowed = hasPermission("voice-captures:use") && hasPermission("inspections:create");
|
||||
const recordingSwitch = useVoiceRecordingSwitch(allowed);
|
||||
|
||||
const colonyId = searchParams.get("colonyId") ?? "";
|
||||
const round = searchParams.get("round");
|
||||
const date = validDay(searchParams.get("date"));
|
||||
|
||||
if (!allowed) {
|
||||
return <Notice>Für Sprachnotizen fehlt dir die Berechtigung.</Notice>;
|
||||
}
|
||||
if (recordingSwitch === "off") {
|
||||
return (
|
||||
<Notice>
|
||||
Sprachnotizen sind gerade ausgeschaltet. Du kannst die Durchsicht eintippen.
|
||||
</Notice>
|
||||
);
|
||||
}
|
||||
// A new colony or round remounts the form: a fresh recording, no stale state.
|
||||
return (
|
||||
<VoiceCaptureForm
|
||||
key={`${colonyId}:${round ?? ""}:${date}`}
|
||||
initialColonyId={colonyId}
|
||||
initialDate={date}
|
||||
roundApiaryId={round}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
import "fake-indexeddb/auto";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider, onlineManager } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import axe from "axe-core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
discardVoiceCapture,
|
||||
fetchVoiceCaptures,
|
||||
fetchVoiceConfig,
|
||||
retryVoiceCapture,
|
||||
type VoiceCaptureListItem,
|
||||
} from "@/lib/voice/api";
|
||||
import {
|
||||
discardAllPendingVoice,
|
||||
discardPendingVoice,
|
||||
markVoiceBlocked,
|
||||
markVoiceFailed,
|
||||
queueVoiceNote,
|
||||
} from "@/lib/voice/pending-voice";
|
||||
import VoiceListPage from "./page";
|
||||
|
||||
let permissions = new Set<string>();
|
||||
let connectivity: "online" | "offline" = "online";
|
||||
vi.mock("@/lib/offline/connectivity", () => ({ useConnectivity: () => connectivity }));
|
||||
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
vi.mock("@/lib/voice/pending-voice", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/voice/pending-voice")>();
|
||||
return { ...actual, discardPendingVoice: vi.fn(actual.discardPendingVoice) };
|
||||
});
|
||||
vi.mock("@/lib/auth/auth-context", () => ({
|
||||
useAuth: () => ({ hasPermission: (key: string) => permissions.has(key) }),
|
||||
useCurrentUserId: () => "user-1",
|
||||
}));
|
||||
vi.mock("@/lib/colonies/api", () => ({
|
||||
fetchColonies: vi.fn().mockResolvedValue([
|
||||
{ id: "c-1", code: "B3" },
|
||||
{ id: "c-2", code: "B4" },
|
||||
]),
|
||||
}));
|
||||
vi.mock("@/lib/voice/api", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/voice/api")>()),
|
||||
fetchVoiceCaptures: vi.fn(),
|
||||
fetchVoiceConfig: vi.fn(),
|
||||
retryVoiceCapture: vi.fn(),
|
||||
discardVoiceCapture: vi.fn(),
|
||||
}));
|
||||
|
||||
const CONFIG = {
|
||||
recordingEnabled: true,
|
||||
extractionEnabled: true,
|
||||
model: "claude-sonnet-5-5",
|
||||
retention: { audioHours: 24, contentDays: 7, rowDays: 90 },
|
||||
};
|
||||
|
||||
function item(overrides: Partial<VoiceCaptureListItem>): VoiceCaptureListItem {
|
||||
return {
|
||||
id: "v-1",
|
||||
colonyId: "c-1",
|
||||
colonyName: "B3",
|
||||
status: "READY",
|
||||
source: "AUDIO",
|
||||
createdAt: "2026-10-14T10:12:00.000Z",
|
||||
lastErrorCode: null,
|
||||
contentExpiresAt: "2026-10-21T10:12:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<VoiceListPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("VoiceListPage", () => {
|
||||
beforeEach(async () => {
|
||||
globalThis.indexedDB = new IDBFactory();
|
||||
// The module keeps its first connection: empty the store instead of the factory.
|
||||
await discardAllPendingVoice("user-1");
|
||||
connectivity = "online";
|
||||
vi.mocked(toast.error).mockReset();
|
||||
permissions = new Set(["voice-captures:use", "inspections:create"]);
|
||||
vi.mocked(fetchVoiceConfig).mockReset().mockResolvedValue(CONFIG);
|
||||
vi.mocked(fetchVoiceCaptures).mockReset().mockResolvedValue([]);
|
||||
vi.mocked(retryVoiceCapture).mockReset().mockResolvedValue(undefined);
|
||||
vi.mocked(discardVoiceCapture).mockReset().mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("the list shows status words and Prüfen only when ready", async () => {
|
||||
vi.mocked(fetchVoiceCaptures).mockResolvedValue([
|
||||
item({ id: "v-1", status: "READY" }),
|
||||
item({ id: "v-2", colonyId: "c-2", colonyName: "B4", status: "QUEUED" }),
|
||||
item({ id: "v-3", colonyName: "B5", status: "EXTRACTING" }),
|
||||
item({ id: "v-4", colonyName: "B6", status: "FAILED", lastErrorCode: "NO_SPEECH" }),
|
||||
item({ id: "v-5", colonyName: "B7", status: "CONFIRMED" }),
|
||||
]);
|
||||
renderPage();
|
||||
|
||||
await screen.findByText("Fertig – prüfen");
|
||||
expect(screen.getByText("Wird abgeschrieben")).toBeInTheDocument();
|
||||
expect(screen.getByText("Wird ausgewertet")).toBeInTheDocument();
|
||||
expect(screen.getByText("Fehlgeschlagen")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Es war nichts zu verstehen. Bitte noch einmal aufnehmen."),
|
||||
).toBeInTheDocument();
|
||||
// A confirmed note is done and not listed.
|
||||
expect(screen.queryByText("B7")).not.toBeInTheDocument();
|
||||
|
||||
const review = screen.getAllByRole("button", { name: "Prüfen" });
|
||||
expect(review).toHaveLength(1);
|
||||
expect(review[0]).toHaveAttribute("href", "/inspections/new?colonyId=c-1&voice=v-1");
|
||||
});
|
||||
|
||||
it("offers Text ansehen when the evaluation is off but the text exists", async () => {
|
||||
vi.mocked(fetchVoiceCaptures).mockResolvedValue([
|
||||
item({ status: "TRANSCRIBED", lastErrorCode: "EXTRACT_DISABLED" }),
|
||||
]);
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("Text liegt vor")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Text ansehen" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Prüfen" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the empty state", async () => {
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("Noch keine Sprachnotizen.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a failed load as an error, not as empty", async () => {
|
||||
vi.mocked(fetchVoiceCaptures).mockRejectedValue(new Error("boom"));
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("konnten nicht geladen werden");
|
||||
expect(screen.queryByText("Noch keine Sprachnotizen.")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists recordings that wait on the device first, with their own status", async () => {
|
||||
connectivity = "offline";
|
||||
vi.mocked(fetchVoiceCaptures).mockResolvedValue([item({ id: "v-1", colonyName: "B9" })]);
|
||||
await queueVoiceNote({
|
||||
ownerUserId: "user-1",
|
||||
blob: new Blob(["a"]),
|
||||
mime: "audio/webm",
|
||||
durationSec: 20,
|
||||
colonyId: "c-2",
|
||||
inspectionDate: "2026-10-16",
|
||||
});
|
||||
renderPage();
|
||||
|
||||
const items = await screen.findAllByRole("listitem");
|
||||
expect(within(items[0]).getByText("B4")).toBeInTheDocument();
|
||||
expect(within(items[0]).getByText("Wartet auf Netz")).toBeInTheDocument();
|
||||
expect(within(items[1]).getByText("B9")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("asks before discarding and then discards", async () => {
|
||||
vi.mocked(fetchVoiceCaptures).mockResolvedValue([item({})]);
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: /Mehr zu B3/ }));
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Verwerfen" }));
|
||||
const dialog = await screen.findByRole("alertdialog");
|
||||
expect(discardVoiceCapture).not.toHaveBeenCalled();
|
||||
await user.click(within(dialog).getByRole("button", { name: "Verwerfen" }));
|
||||
|
||||
await waitFor(() => expect(discardVoiceCapture).toHaveBeenCalledWith("v-1"));
|
||||
});
|
||||
|
||||
it("retries a failed evaluation", async () => {
|
||||
vi.mocked(fetchVoiceCaptures).mockResolvedValue([
|
||||
item({ status: "FAILED", lastErrorCode: "EXTRACT_FAILED" }),
|
||||
]);
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "Nochmal versuchen" }));
|
||||
|
||||
await waitFor(() => expect(retryVoiceCapture).toHaveBeenCalledWith("v-1"));
|
||||
});
|
||||
|
||||
it("offers no retry when nothing is left to evaluate", async () => {
|
||||
vi.mocked(fetchVoiceCaptures).mockResolvedValue([
|
||||
item({ status: "FAILED", lastErrorCode: "AUDIO_EXPIRED" }),
|
||||
]);
|
||||
renderPage();
|
||||
|
||||
await screen.findByText("Fehlgeschlagen");
|
||||
expect(screen.queryByRole("button", { name: "Nochmal versuchen" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers no new note when recording is switched off, and says so", async () => {
|
||||
vi.mocked(fetchVoiceConfig).mockResolvedValue({ ...CONFIG, recordingEnabled: false });
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText(/Sprachnotizen sind gerade ausgeschaltet/)).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /Neue Sprachnotiz/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers a new note when recording is on", async () => {
|
||||
renderPage();
|
||||
|
||||
const link = await screen.findByRole("button", { name: /Neue Sprachnotiz/ });
|
||||
expect(link).toHaveAttribute("href", "/voice/new");
|
||||
});
|
||||
|
||||
it("says so without the permission", () => {
|
||||
permissions = new Set(["voice-captures:use"]);
|
||||
renderPage();
|
||||
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("fehlt dir die Berechtigung");
|
||||
expect(fetchVoiceCaptures).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
describe("recordings on the device", () => {
|
||||
async function queue(overrides: Partial<Parameters<typeof queueVoiceNote>[0]> = {}) {
|
||||
return queueVoiceNote({
|
||||
ownerUserId: "user-1",
|
||||
blob: new Blob(["a"]),
|
||||
mime: "audio/webm",
|
||||
durationSec: 20,
|
||||
colonyId: "c-2",
|
||||
inspectionDate: "2026-10-12",
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
it("says Wartet auf Netz offline and Wird gesendet online", async () => {
|
||||
await queue();
|
||||
connectivity = "offline";
|
||||
const view = renderPage();
|
||||
expect(await screen.findByText("Wartet auf Netz")).toBeInTheDocument();
|
||||
view.unmount();
|
||||
|
||||
connectivity = "online";
|
||||
renderPage();
|
||||
expect(await screen.findByText("Wird gesendet")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Wartet auf Netz")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says why a note waits when the server holds too many open notes", async () => {
|
||||
const note = await queue();
|
||||
await markVoiceBlocked(note.id, "TOO_MANY_OPEN");
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("Wartet auf freien Platz")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Zu viele offene Sprachnotizen/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a refused note as failed, keeps its day for a new recording", async () => {
|
||||
const note = await queue();
|
||||
await markVoiceFailed(note.id, {
|
||||
status: 413,
|
||||
message: "x",
|
||||
details: { code: "TOO_LARGE" },
|
||||
at: "2026-10-16T08:00:00.000Z",
|
||||
} as never);
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("Fehlgeschlagen")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Die Sprachnotiz ist zu groß/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Neu aufnehmen" })).toHaveAttribute(
|
||||
"href",
|
||||
"/voice/new?colonyId=c-2&date=2026-10-12",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows a fixed text when discarding on the device fails", async () => {
|
||||
await queue();
|
||||
vi.mocked(discardPendingVoice).mockRejectedValueOnce(new Error("db gone"));
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: /Mehr zu B4/ }));
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Verwerfen" }));
|
||||
await user.click(
|
||||
within(await screen.findByRole("alertdialog")).getByRole("button", { name: "Verwerfen" }),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(toast.error).toHaveBeenCalledWith("Die Sprachnotiz konnte nicht verworfen werden."),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("says offline instead of showing an error when the server cannot be asked", async () => {
|
||||
onlineManager.setOnline(false);
|
||||
try {
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText(/Du bist offline/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/konnten nicht geladen werden/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Noch keine Sprachnotizen.")).not.toBeInTheDocument();
|
||||
} finally {
|
||||
onlineManager.setOnline(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("reads a transcribed note as Text liegt vor when the evaluation is off", async () => {
|
||||
vi.mocked(fetchVoiceConfig).mockResolvedValue({ ...CONFIG, extractionEnabled: false });
|
||||
vi.mocked(fetchVoiceCaptures).mockResolvedValue([item({ status: "TRANSCRIBED" })]);
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("Text liegt vor")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Text ansehen" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lets the user read the text of a failed evaluation and try again", async () => {
|
||||
vi.mocked(fetchVoiceCaptures).mockResolvedValue([
|
||||
item({ status: "FAILED", lastErrorCode: "EXTRACT_FAILED" }),
|
||||
]);
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByRole("button", { name: "Text ansehen" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Nochmal versuchen" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("gives the actions a 56 px target", async () => {
|
||||
vi.mocked(fetchVoiceCaptures).mockResolvedValue([item({})]);
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByRole("button", { name: /Neue Sprachnotiz/ })).toHaveClass("min-h-14");
|
||||
expect(screen.getByRole("button", { name: "Prüfen" })).toHaveClass("min-h-14");
|
||||
});
|
||||
|
||||
it("axe finds nothing", async () => {
|
||||
vi.mocked(fetchVoiceCaptures).mockResolvedValue([
|
||||
item({}),
|
||||
item({ id: "v-2", colonyName: "B4", status: "FAILED", lastErrorCode: "EXTRACT_FAILED" }),
|
||||
]);
|
||||
const { container } = renderPage();
|
||||
await screen.findByText("Fertig – prüfen");
|
||||
|
||||
const results = await axe.run(container, { rules: { "color-contrast": { enabled: false } } });
|
||||
|
||||
expect(results.violations).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Mic } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { isNetworkError } from "@/lib/http/fetch-with-timeout";
|
||||
import { fetchColonies } from "@/lib/colonies/api";
|
||||
import { useAuth, useCurrentUserId } from "@/lib/auth/auth-context";
|
||||
import { OFFLINE_QUEUE_QUERY_KEY } from "@/lib/offline/use-capture-mutation";
|
||||
import {
|
||||
discardVoiceCapture,
|
||||
fetchVoiceCaptures,
|
||||
retryVoiceCapture,
|
||||
voiceKeys,
|
||||
type VoiceCaptureListItem,
|
||||
} from "@/lib/voice/api";
|
||||
import { discardPendingVoice, listPendingVoice } from "@/lib/voice/pending-voice";
|
||||
import { isListedVoiceStatus, isVoiceInProgress } from "@/lib/voice/status-texts";
|
||||
import { useConnectivity } from "@/lib/offline/connectivity";
|
||||
import {
|
||||
offersRecording,
|
||||
useVoiceConfig,
|
||||
useVoiceRecordingSwitch,
|
||||
} from "@/lib/voice/use-voice-recording-switch";
|
||||
import { LocalVoiceCard, VoiceCaptureCard } from "@/components/voice/voice-capture-card";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { ErrorState, LoadingState } from "@/components/query-state";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const POLL_MS = 15_000;
|
||||
const LOCAL_POLL_MS = 5_000;
|
||||
const PENDING_KEY = [...OFFLINE_QUEUE_QUERY_KEY, "voice-notes"] as const;
|
||||
|
||||
export default function VoiceListPage() {
|
||||
const { hasPermission } = useAuth();
|
||||
const allowed = hasPermission("voice-captures:use") && hasPermission("inspections:create");
|
||||
const recordingSwitch = useVoiceRecordingSwitch(allowed);
|
||||
|
||||
const newAction =
|
||||
allowed && offersRecording(recordingSwitch) ? (
|
||||
<Button
|
||||
size="touch"
|
||||
className="min-h-14"
|
||||
nativeButton={false}
|
||||
render={<Link href="/voice/new" />}
|
||||
>
|
||||
<Mic aria-hidden />
|
||||
Neue Sprachnotiz
|
||||
</Button>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<PageHeader
|
||||
icon={Mic}
|
||||
title="Sprachnotizen"
|
||||
description="Aufnahmen, die noch warten, und fertige Vorschläge zum Prüfen."
|
||||
actions={newAction}
|
||||
/>
|
||||
{allowed ? (
|
||||
<>
|
||||
{recordingSwitch === "off" ? (
|
||||
<p role="status" className="rounded-xl border border-border bg-card p-4 text-sm">
|
||||
Sprachnotizen sind gerade ausgeschaltet. Neue Aufnahmen sind nicht möglich.
|
||||
Bestehende Notizen bleiben hier sichtbar.
|
||||
</p>
|
||||
) : null}
|
||||
<VoiceNotes />
|
||||
</>
|
||||
) : (
|
||||
<p role="alert" className="rounded-xl border border-border bg-card p-4 text-sm">
|
||||
Für Sprachnotizen fehlt dir die Berechtigung. Bitte wende dich an die Verwaltung.
|
||||
</p>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function OfflineNote() {
|
||||
return (
|
||||
<p role="status" className="rounded-xl border border-border bg-card p-4 text-sm">
|
||||
Du bist offline. Notizen vom Server erscheinen wieder, sobald du Netz hast.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function VoiceNotes() {
|
||||
const userId = useCurrentUserId();
|
||||
const queryClient = useQueryClient();
|
||||
const online = useConnectivity() === "online";
|
||||
// Unknown (not loaded yet, offline) counts as "on": the common case.
|
||||
const extractionEnabled = useVoiceConfig(true).data?.extractionEnabled ?? true;
|
||||
|
||||
const serverQuery = useQuery({
|
||||
queryKey: voiceKeys.all,
|
||||
queryFn: () => fetchVoiceCaptures(),
|
||||
refetchInterval: (query) =>
|
||||
query.state.data?.some((item) => isVoiceInProgress(item.status, extractionEnabled))
|
||||
? POLL_MS
|
||||
: false,
|
||||
});
|
||||
const localQuery = useQuery({
|
||||
queryKey: [...PENDING_KEY, userId],
|
||||
queryFn: () => listPendingVoice(userId ?? ""),
|
||||
enabled: userId !== null,
|
||||
// Reads this device's storage, never the network.
|
||||
networkMode: "always",
|
||||
refetchInterval: (query) => (query.state.data?.length ? LOCAL_POLL_MS : false),
|
||||
});
|
||||
const coloniesQuery = useQuery({ queryKey: ["colonies"], queryFn: () => fetchColonies() });
|
||||
|
||||
const retry = useMutation({
|
||||
mutationFn: (id: string) => retryVoiceCapture(id),
|
||||
onSuccess: () => void queryClient.invalidateQueries({ queryKey: voiceKeys.all }),
|
||||
onError: () => toast.error("Das hat nicht geklappt. Bitte gleich noch einmal versuchen."),
|
||||
});
|
||||
const discard = useMutation({
|
||||
mutationFn: (id: string) => discardVoiceCapture(id),
|
||||
onSuccess: () => void queryClient.invalidateQueries({ queryKey: voiceKeys.all }),
|
||||
onError: () => toast.error("Die Sprachnotiz konnte nicht verworfen werden."),
|
||||
});
|
||||
const discardLocal = useMutation({
|
||||
mutationFn: (id: string) => discardPendingVoice(id, userId ?? ""),
|
||||
onSuccess: () => void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY }),
|
||||
onError: () => toast.error("Die Sprachnotiz konnte nicht verworfen werden."),
|
||||
});
|
||||
|
||||
const colonyName = (colonyId: string): string =>
|
||||
coloniesQuery.data?.find((colony) => colony.id === colonyId)?.code ?? "Volk";
|
||||
const local = localQuery.data ?? [];
|
||||
const listed: VoiceCaptureListItem[] = (serverQuery.data ?? []).filter((item) =>
|
||||
isListedVoiceStatus(item.status),
|
||||
);
|
||||
|
||||
if (serverQuery.isLoading && local.length === 0) {
|
||||
return <LoadingState what="Sprachnotizen" />;
|
||||
}
|
||||
// No answer because there is no network: not an error, the notes appear with the network.
|
||||
const serverOffline =
|
||||
serverQuery.data === undefined &&
|
||||
(serverQuery.fetchStatus === "paused" || isNetworkError(serverQuery.error));
|
||||
const serverFailed = serverQuery.data === undefined && !serverQuery.isLoading && !serverOffline;
|
||||
if (local.length === 0 && listed.length === 0) {
|
||||
if (serverOffline) {
|
||||
return <OfflineNote />;
|
||||
}
|
||||
return serverFailed ? (
|
||||
<ErrorState
|
||||
what="Sprachnotizen"
|
||||
onRetry={() => void serverQuery.refetch()}
|
||||
isRetrying={serverQuery.isFetching}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={Mic}
|
||||
title="Noch keine Sprachnotizen."
|
||||
description="Nimm beim Volk eine Sprachnotiz auf. Sie erscheint hier, bis du sie geprüft hast."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{serverOffline ? <OfflineNote /> : null}
|
||||
{serverFailed ? (
|
||||
<ErrorState
|
||||
what="Sprachnotizen vom Server"
|
||||
onRetry={() => void serverQuery.refetch()}
|
||||
isRetrying={serverQuery.isFetching}
|
||||
/>
|
||||
) : null}
|
||||
<ul className="flex flex-col gap-3" aria-label="Sprachnotizen">
|
||||
{local.map((note) => (
|
||||
<li key={note.id}>
|
||||
<LocalVoiceCard
|
||||
note={note}
|
||||
colonyName={colonyName(note.colonyId)}
|
||||
online={online}
|
||||
onDiscard={() => discardLocal.mutate(note.id)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
{listed.map((item) => (
|
||||
<li key={item.id}>
|
||||
<VoiceCaptureCard
|
||||
item={item}
|
||||
extractionEnabled={extractionEnabled}
|
||||
busy={retry.isPending || discard.isPending}
|
||||
onRetry={() => retry.mutate(item.id)}
|
||||
onDiscard={() => discard.mutate(item.id)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import Link from "next/link";
|
||||
import { MicOff } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const STEPS: readonly { browser: string; text: string }[] = [
|
||||
{
|
||||
browser: "Chrome und Edge",
|
||||
text: "Tippe in der Adresszeile auf das Symbol links neben der Adresse, dann auf „Berechtigungen“ und erlaube das Mikrofon.",
|
||||
},
|
||||
{
|
||||
browser: "Safari am iPhone",
|
||||
text: "Öffne Einstellungen, dann Safari, dann Mikrofon, und wähle „Fragen“ oder „Erlauben“.",
|
||||
},
|
||||
{
|
||||
browser: "Firefox",
|
||||
text: "Tippe in der Adresszeile auf das Symbol links neben der Adresse und hebe die Sperre für das Mikrofon auf.",
|
||||
},
|
||||
];
|
||||
|
||||
interface MicrophoneHelpProps {
|
||||
reason: "denied" | "no-microphone" | "unsupported";
|
||||
typeInsteadHref: string;
|
||||
onRetry?(): void;
|
||||
}
|
||||
|
||||
const TITLES: Record<MicrophoneHelpProps["reason"], string> = {
|
||||
denied: "Das Mikrofon ist nicht erlaubt.",
|
||||
"no-microphone": "Es wurde kein Mikrofon gefunden.",
|
||||
unsupported: "Dieses Gerät kann hier keine Sprachnotiz aufnehmen.",
|
||||
};
|
||||
|
||||
/** Mikrofon verweigert: what to do per browser, and a way to type instead. */
|
||||
export function MicrophoneHelp({ reason, typeInsteadHref, onRetry }: MicrophoneHelpProps) {
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="voice-mic-help"
|
||||
className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4"
|
||||
>
|
||||
<h2 id="voice-mic-help" className="flex items-center gap-2 font-heading text-lg font-semibold">
|
||||
<MicOff className="size-5" aria-hidden />
|
||||
{TITLES[reason]}
|
||||
</h2>
|
||||
{reason === "denied" ? (
|
||||
<>
|
||||
<p className="text-sm">So erlaubst du es wieder:</p>
|
||||
<dl className="flex flex-col gap-2 text-sm">
|
||||
{STEPS.map((step) => (
|
||||
<div key={step.browser}>
|
||||
<dt className="font-medium">{step.browser}</dt>
|
||||
<dd className="text-muted-foreground">{step.text}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</>
|
||||
) : null}
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Button
|
||||
size="touch"
|
||||
className="min-h-14"
|
||||
nativeButton={false}
|
||||
render={<Link href={typeInsteadHref} />}
|
||||
>
|
||||
Stattdessen tippen
|
||||
</Button>
|
||||
{onRetry && reason !== "unsupported" ? (
|
||||
<Button type="button" size="touch" variant="outline" className="min-h-14" onClick={onRetry}>
|
||||
Erneut versuchen
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { RECORDING_NOTICE } from "@/app/(app)/settings/voice/voice-privacy-text";
|
||||
|
||||
interface RecordingNoticeDialogProps {
|
||||
open: boolean;
|
||||
onConfirm(): void;
|
||||
onCancel(): void;
|
||||
}
|
||||
|
||||
/** Shown once before the first recording (spec 9.5); it can be read again in the settings. */
|
||||
export function RecordingNoticeDialog({ open, onConfirm, onCancel }: RecordingNoticeDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => !next && onCancel()}>
|
||||
<DialogContent role="alertdialog" showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Bevor du aufnimmst</DialogTitle>
|
||||
<DialogDescription>{RECORDING_NOTICE}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" size="touch" className="min-h-14" onClick={onCancel}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="button" size="touch" className="min-h-14" onClick={onConfirm}>
|
||||
Verstanden, Aufnahme starten
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
import {
|
||||
pickMimeType,
|
||||
startRecording,
|
||||
type Recording,
|
||||
type RecordingHandle,
|
||||
} from "@/lib/voice/recorder";
|
||||
|
||||
export const MAX_RECORDING_SEC = 180;
|
||||
export const WARN_AT_SEC = 150;
|
||||
/** Announce the running time to screen readers this often. */
|
||||
export const ANNOUNCE_EVERY_SEC = 30;
|
||||
/** Cancelling a recording longer than this asks first. */
|
||||
export const CONFIRM_CANCEL_AFTER_SEC = 5;
|
||||
|
||||
export type RecorderPhase =
|
||||
| "idle"
|
||||
| "starting"
|
||||
| "recording"
|
||||
| "recorded"
|
||||
| "denied"
|
||||
| "no-microphone"
|
||||
| "unsupported"
|
||||
| "failed";
|
||||
|
||||
export interface FinishedRecording extends Recording {
|
||||
/** Object URL for the native player; revoked on reset and unmount. */
|
||||
url: string | null;
|
||||
}
|
||||
|
||||
export interface UseVoiceRecorder {
|
||||
phase: RecorderPhase;
|
||||
sec: number;
|
||||
/** Text for the polite live region: changes every 30 s and at start, warning and stop. */
|
||||
announcement: string;
|
||||
warning: boolean;
|
||||
recording: FinishedRecording | null;
|
||||
start(): void;
|
||||
stop(): void;
|
||||
cancel(): void;
|
||||
reset(): void;
|
||||
}
|
||||
|
||||
export function formatClock(totalSec: number): string {
|
||||
const minutes = Math.floor(totalSec / 60);
|
||||
const seconds = String(totalSec % 60).padStart(2, "0");
|
||||
return `${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
/** Spoken form of the time, for the live region. */
|
||||
export function spokenDuration(totalSec: number): string {
|
||||
const minutes = Math.floor(totalSec / 60);
|
||||
const seconds = totalSec % 60;
|
||||
const parts: string[] = [];
|
||||
if (minutes > 0) {
|
||||
parts.push(minutes === 1 ? "1 Minute" : `${minutes} Minuten`);
|
||||
}
|
||||
if (seconds > 0 || minutes === 0) {
|
||||
parts.push(seconds === 1 ? "1 Sekunde" : `${seconds} Sekunden`);
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
const DENIED_ERRORS = new Set(["NotAllowedError", "SecurityError", "PermissionDeniedError"]);
|
||||
const MISSING_ERRORS = new Set(["NotFoundError", "OverconstrainedError", "NotReadableError"]);
|
||||
|
||||
function phaseForStartError(error: unknown): RecorderPhase {
|
||||
// Duck-typed: a DOMException is not an Error in every realm.
|
||||
const { name = "", message = "" } = (error ?? {}) as { name?: string; message?: string };
|
||||
if (DENIED_ERRORS.has(name)) {
|
||||
return "denied";
|
||||
}
|
||||
if (MISSING_ERRORS.has(name)) {
|
||||
return "no-microphone";
|
||||
}
|
||||
if (message === "Recording is not supported on this device") {
|
||||
return "unsupported";
|
||||
}
|
||||
return "failed";
|
||||
}
|
||||
|
||||
function makeUrl(blob: Blob): string | null {
|
||||
return typeof URL !== "undefined" && typeof URL.createObjectURL === "function"
|
||||
? URL.createObjectURL(blob)
|
||||
: null;
|
||||
}
|
||||
|
||||
function revokeUrl(url: string | null): void {
|
||||
if (url && typeof URL.revokeObjectURL === "function") {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
const NEVER_CHANGES = () => () => undefined;
|
||||
|
||||
/** True when this browser cannot record voice at all (checked on the client only). */
|
||||
export function useRecordingUnsupported(): boolean {
|
||||
return useSyncExternalStore(
|
||||
NEVER_CHANGES,
|
||||
() => pickMimeType() === null,
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The recording flow as state. The microphone is asked for only in start();
|
||||
* the recorder module releases it on stop, cancel, auto-stop and error.
|
||||
*/
|
||||
export interface UseVoiceRecorderOptions {
|
||||
/** The limit (or a hidden page) ended the recording; it is finished and playable. */
|
||||
onAutoStop?(recording: Recording): void;
|
||||
}
|
||||
|
||||
export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}): UseVoiceRecorder {
|
||||
const autoStopHandler = useRef(options.onAutoStop);
|
||||
useEffect(() => {
|
||||
autoStopHandler.current = options.onAutoStop;
|
||||
});
|
||||
const unsupported = useRecordingUnsupported();
|
||||
const [phase, setPhase] = useState<RecorderPhase>("idle");
|
||||
const [sec, setSec] = useState(0);
|
||||
const [announcement, setAnnouncement] = useState("");
|
||||
const [warning, setWarning] = useState(false);
|
||||
const [recording, setRecording] = useState<FinishedRecording | null>(null);
|
||||
const handle = useRef<RecordingHandle | null>(null);
|
||||
const urlRef = useRef<string | null>(null);
|
||||
// Bumped by cancel/reset/unmount so a late start or stop answer is ignored.
|
||||
const generation = useRef(0);
|
||||
|
||||
const finish = useCallback((result: Recording, note: string) => {
|
||||
revokeUrl(urlRef.current);
|
||||
urlRef.current = makeUrl(result.blob);
|
||||
handle.current = null;
|
||||
setRecording({ ...result, url: urlRef.current });
|
||||
setPhase("recorded");
|
||||
setAnnouncement(`${note} Dauer ${spokenDuration(result.durationSec)}.`);
|
||||
}, []);
|
||||
|
||||
const start = useCallback(() => {
|
||||
if (handle.current) {
|
||||
return;
|
||||
}
|
||||
const mine = ++generation.current;
|
||||
setPhase("starting");
|
||||
setSec(0);
|
||||
setWarning(false);
|
||||
setAnnouncement("");
|
||||
startRecording({
|
||||
maxSec: MAX_RECORDING_SEC,
|
||||
warnAtSec: WARN_AT_SEC,
|
||||
onTick(tick) {
|
||||
if (generation.current !== mine) return;
|
||||
setSec(tick);
|
||||
if (tick % ANNOUNCE_EVERY_SEC === 0) {
|
||||
setAnnouncement(`Aufnahme läuft, ${spokenDuration(tick)}.`);
|
||||
}
|
||||
},
|
||||
onWarn() {
|
||||
if (generation.current !== mine) return;
|
||||
setWarning(true);
|
||||
setAnnouncement("Noch 30 Sekunden.");
|
||||
},
|
||||
onAutoStop(result) {
|
||||
if (generation.current !== mine) return;
|
||||
finish(result, "Aufnahme beendet.");
|
||||
autoStopHandler.current?.(result);
|
||||
},
|
||||
}).then(
|
||||
(started) => {
|
||||
if (generation.current !== mine) {
|
||||
started.cancel();
|
||||
return;
|
||||
}
|
||||
handle.current = started;
|
||||
setPhase("recording");
|
||||
setAnnouncement("Aufnahme läuft.");
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (generation.current === mine) {
|
||||
setPhase(phaseForStartError(error));
|
||||
}
|
||||
},
|
||||
);
|
||||
}, [finish]);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
const active = handle.current;
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
const mine = generation.current;
|
||||
handle.current = null;
|
||||
active.stop().then(
|
||||
(result) => {
|
||||
if (generation.current === mine) finish(result, "Aufnahme beendet.");
|
||||
},
|
||||
() => {
|
||||
if (generation.current === mine) setPhase("failed");
|
||||
},
|
||||
);
|
||||
}, [finish]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
generation.current += 1;
|
||||
handle.current?.cancel();
|
||||
handle.current = null;
|
||||
revokeUrl(urlRef.current);
|
||||
urlRef.current = null;
|
||||
setRecording(null);
|
||||
setSec(0);
|
||||
setWarning(false);
|
||||
setAnnouncement("");
|
||||
setPhase("idle");
|
||||
}, []);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
generation.current += 1;
|
||||
handle.current?.cancel();
|
||||
revokeUrl(urlRef.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
phase: unsupported && phase === "idle" ? "unsupported" : phase,
|
||||
sec,
|
||||
announcement,
|
||||
warning,
|
||||
recording,
|
||||
start,
|
||||
stop,
|
||||
cancel: reset,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
Clock,
|
||||
Loader2,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { VOICE_TOO_MANY_OPEN_HINT, voiceRejectionText } from "@/lib/offline/queue-texts";
|
||||
import type { VoiceCaptureListItem } from "@/lib/voice/api";
|
||||
import type { StoredVoiceNote } from "@/lib/voice/pending-voice";
|
||||
import {
|
||||
VOICE_WAITING_FOR_NETWORK,
|
||||
describeVoiceCapture,
|
||||
type VoiceStatusIcon,
|
||||
type VoiceStatusText,
|
||||
} from "@/lib/voice/status-texts";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { VoiceConfirmDialog } from "./voice-confirm-dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
const STATUS_ICONS: Record<VoiceStatusIcon, LucideIcon> = {
|
||||
wait: Clock,
|
||||
work: Loader2,
|
||||
ready: CheckCircle2,
|
||||
failed: AlertTriangle,
|
||||
};
|
||||
|
||||
const TIME_FORMAT = new Intl.DateTimeFormat("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZone: "Europe/Berlin",
|
||||
});
|
||||
|
||||
function timeText(iso: string): string {
|
||||
return `${TIME_FORMAT.format(new Date(iso))} Uhr`.replace(", ", " · ");
|
||||
}
|
||||
|
||||
function StatusLine({ status }: { status: VoiceStatusText }) {
|
||||
const Icon = STATUS_ICONS[status.icon];
|
||||
return (
|
||||
<p className="flex items-center gap-2 text-sm font-medium">
|
||||
<Icon
|
||||
className={`size-4 shrink-0${status.icon === "work" ? " motion-safe:animate-spin" : ""}`}
|
||||
aria-hidden
|
||||
/>
|
||||
{status.label}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
interface DiscardMenuProps {
|
||||
/** Names the menu for assistive tech, e.g. "B3, 14.10. · 09:12 Uhr". */
|
||||
subject: string;
|
||||
onDiscard(): void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/** „Mehr“ with „Verwerfen“, which asks first: a discarded note cannot come back. */
|
||||
function DiscardMenu({ subject, onDiscard, disabled }: DiscardMenuProps) {
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="touch"
|
||||
className="min-h-14"
|
||||
aria-label={`Mehr zu ${subject}`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Mehr
|
||||
<ChevronDown aria-hidden />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-auto min-w-48">
|
||||
<DropdownMenuItem
|
||||
className="min-h-11 px-3"
|
||||
disabled={disabled}
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
>
|
||||
Verwerfen
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<VoiceConfirmDialog
|
||||
open={confirmOpen}
|
||||
title="Sprachnotiz verwerfen?"
|
||||
description="Die Sprachnotiz wird gelöscht. Das lässt sich nicht rückgängig machen."
|
||||
cancelLabel="Abbrechen"
|
||||
confirmLabel="Verwerfen"
|
||||
onCancel={() => setConfirmOpen(false)}
|
||||
onConfirm={() => {
|
||||
setConfirmOpen(false);
|
||||
onDiscard();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface CardShellProps {
|
||||
title: string;
|
||||
time: string;
|
||||
status: VoiceStatusText;
|
||||
detail?: string | null;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
function CardShell({ title, time, status, detail, children }: CardShellProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-x-3">
|
||||
<h2 className="font-heading text-lg font-semibold">{title}</h2>
|
||||
<p className="text-sm text-muted-foreground">{time}</p>
|
||||
</div>
|
||||
<StatusLine status={status} />
|
||||
{detail ? <p className="text-sm text-muted-foreground">{detail}</p> : null}
|
||||
{children ? <div className="flex flex-wrap gap-2 [&>*]:flex-1 sm:[&>*]:flex-none">{children}</div> : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface LocalVoiceCardProps {
|
||||
note: StoredVoiceNote;
|
||||
colonyName: string;
|
||||
/** Whether the device is online: a waiting note is then about to be sent. */
|
||||
online: boolean;
|
||||
onDiscard(): void;
|
||||
}
|
||||
|
||||
/** What a recording on this device is doing, in words. */
|
||||
function localStatus(note: StoredVoiceNote, online: boolean): { status: VoiceStatusText; detail: string | null } {
|
||||
if (note.lastError) {
|
||||
return {
|
||||
status: { label: "Fehlgeschlagen", icon: "failed" },
|
||||
detail: voiceRejectionText(note.lastError),
|
||||
};
|
||||
}
|
||||
if (note.blockedBy === "TOO_MANY_OPEN") {
|
||||
return {
|
||||
status: { label: "Wartet auf freien Platz", icon: "wait" },
|
||||
detail: VOICE_TOO_MANY_OPEN_HINT,
|
||||
};
|
||||
}
|
||||
if (note.state === "uploading" || online) {
|
||||
return { status: { label: "Wird gesendet", icon: "work" }, detail: null };
|
||||
}
|
||||
return { status: VOICE_WAITING_FOR_NETWORK, detail: null };
|
||||
}
|
||||
|
||||
/** A recording that still waits on this device. */
|
||||
export function LocalVoiceCard({ note, colonyName, online, onDiscard }: LocalVoiceCardProps) {
|
||||
const time = timeText(note.createdAt);
|
||||
const { status, detail } = localStatus(note, online);
|
||||
const again = new URLSearchParams({ colonyId: note.colonyId, date: note.inspectionDate });
|
||||
return (
|
||||
<CardShell title={colonyName} time={time} status={status} detail={detail}>
|
||||
{note.lastError ? (
|
||||
<Button
|
||||
size="touch"
|
||||
className="min-h-14"
|
||||
nativeButton={false}
|
||||
render={<Link href={`/voice/new?${again.toString()}`} />}
|
||||
>
|
||||
Neu aufnehmen
|
||||
</Button>
|
||||
) : null}
|
||||
<DiscardMenu subject={`${colonyName}, ${time}`} onDiscard={onDiscard} />
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
|
||||
interface VoiceCaptureCardProps {
|
||||
item: VoiceCaptureListItem;
|
||||
/** False when the operator switched the evaluation off. */
|
||||
extractionEnabled?: boolean;
|
||||
onRetry(): void;
|
||||
onDiscard(): void;
|
||||
busy?: boolean;
|
||||
}
|
||||
|
||||
/** A note the server holds. */
|
||||
export function VoiceCaptureCard({
|
||||
item,
|
||||
extractionEnabled = true,
|
||||
onRetry,
|
||||
onDiscard,
|
||||
busy,
|
||||
}: VoiceCaptureCardProps) {
|
||||
const state = describeVoiceCapture(item, extractionEnabled);
|
||||
const time = timeText(item.createdAt);
|
||||
const reviewHref = `/inspections/new?colonyId=${encodeURIComponent(item.colonyId)}&voice=${encodeURIComponent(item.id)}`;
|
||||
return (
|
||||
<CardShell title={item.colonyName} time={time} status={state.status} detail={state.detail}>
|
||||
{state.action !== "none" ? (
|
||||
<Button
|
||||
size="touch"
|
||||
className="min-h-14"
|
||||
nativeButton={false}
|
||||
render={<Link href={reviewHref} />}
|
||||
>
|
||||
{state.action === "review" ? "Prüfen" : "Text ansehen"}
|
||||
</Button>
|
||||
) : null}
|
||||
{state.canRetry ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="touch"
|
||||
variant="outline"
|
||||
className="min-h-14"
|
||||
disabled={busy}
|
||||
onClick={onRetry}
|
||||
>
|
||||
Nochmal versuchen
|
||||
</Button>
|
||||
) : null}
|
||||
<DiscardMenu subject={`${item.colonyName}, ${time}`} onDiscard={onDiscard} disabled={busy} />
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
interface VoiceConfirmDialogProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
/** Leaves the note as it is. */
|
||||
cancelLabel: string;
|
||||
confirmLabel: string;
|
||||
onCancel(): void;
|
||||
onConfirm(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A question before a recording or note is thrown away. Both buttons are 56 px
|
||||
* high for gloved hands; the safe answer comes first.
|
||||
*/
|
||||
export function VoiceConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
cancelLabel,
|
||||
confirmLabel,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: VoiceConfirmDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => !next && onCancel()}>
|
||||
<DialogContent role="alertdialog" showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" size="touch" className="min-h-14" onClick={onCancel}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="touch"
|
||||
className="min-h-14"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fetchVoiceConfig } from "@/lib/voice/api";
|
||||
import { VoiceEntryLink } from "./voice-entry-link";
|
||||
|
||||
let permissions = new Set<string>();
|
||||
vi.mock("@/lib/auth/auth-context", () => ({
|
||||
useAuth: () => ({ hasPermission: (key: string) => permissions.has(key) }),
|
||||
}));
|
||||
vi.mock("@/lib/voice/api", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/voice/api")>()),
|
||||
fetchVoiceConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
const CONFIG = {
|
||||
recordingEnabled: true,
|
||||
extractionEnabled: true,
|
||||
model: "claude-sonnet-5-5",
|
||||
retention: { audioHours: 24, contentDays: 7, rowDays: 90 },
|
||||
};
|
||||
|
||||
function renderLink(round?: string) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<VoiceEntryLink colonyId="c-1" roundApiaryId={round} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("VoiceEntryLink", () => {
|
||||
beforeEach(() => {
|
||||
permissions = new Set(["voice-captures:use", "inspections:create"]);
|
||||
vi.mocked(fetchVoiceConfig).mockReset().mockResolvedValue(CONFIG);
|
||||
});
|
||||
|
||||
it("links to the recording page for the colony", async () => {
|
||||
renderLink();
|
||||
|
||||
const link = await screen.findByRole("button", { name: "Sprachnotiz aufnehmen" });
|
||||
expect(link).toHaveAttribute("href", "/voice/new?colonyId=c-1");
|
||||
});
|
||||
|
||||
it("has a 56 px target", async () => {
|
||||
renderLink();
|
||||
|
||||
expect(await screen.findByRole("button", { name: "Sprachnotiz aufnehmen" })).toHaveClass(
|
||||
"min-h-14",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the stand round", async () => {
|
||||
renderLink("a-1");
|
||||
|
||||
const link = await screen.findByRole("button", { name: "Sprachnotiz aufnehmen" });
|
||||
expect(link).toHaveAttribute("href", "/voice/new?colonyId=c-1&round=a-1");
|
||||
});
|
||||
|
||||
it("is not there without both permissions", async () => {
|
||||
permissions = new Set(["voice-captures:use"]);
|
||||
renderLink();
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Sprachnotiz aufnehmen" })).not.toBeInTheDocument();
|
||||
expect(fetchVoiceConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is not there while recording is switched off", async () => {
|
||||
vi.mocked(fetchVoiceConfig).mockResolvedValue({ ...CONFIG, recordingEnabled: false });
|
||||
renderLink();
|
||||
|
||||
await waitFor(() => expect(fetchVoiceConfig).toHaveBeenCalled());
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("button", { name: "Sprachnotiz aufnehmen" })).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("stays when the config cannot be read", async () => {
|
||||
vi.mocked(fetchVoiceConfig).mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
renderLink();
|
||||
|
||||
expect(await screen.findByRole("button", { name: "Sprachnotiz aufnehmen" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Mic } from "lucide-react";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { offersRecording, useVoiceRecordingSwitch } from "@/lib/voice/use-voice-recording-switch";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface VoiceEntryLinkProps {
|
||||
colonyId: string;
|
||||
/** The stand round the note belongs to; keeps the round going afterwards. */
|
||||
roundApiaryId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* „Sprachnotiz aufnehmen“ next to „Durchsicht erfassen“. Not there without the
|
||||
* rights or while the operator has recording switched off - no dead button.
|
||||
*/
|
||||
export function VoiceEntryLink({ colonyId, roundApiaryId }: VoiceEntryLinkProps) {
|
||||
const { hasPermission } = useAuth();
|
||||
const allowed = hasPermission("voice-captures:use") && hasPermission("inspections:create");
|
||||
const recordingSwitch = useVoiceRecordingSwitch(allowed);
|
||||
if (!allowed || !offersRecording(recordingSwitch)) {
|
||||
return null;
|
||||
}
|
||||
const params = new URLSearchParams({ colonyId });
|
||||
if (roundApiaryId) {
|
||||
params.set("round", roundApiaryId);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
size="touch"
|
||||
variant="outline"
|
||||
className="min-h-14"
|
||||
nativeButton={false}
|
||||
render={<Link href={`/voice/new?${params.toString()}`} />}
|
||||
>
|
||||
<Mic aria-hidden />
|
||||
Sprachnotiz aufnehmen
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
import "fake-indexeddb/auto";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
import { act, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import axe from "axe-core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Recording, StartRecordingOptions } from "@/lib/voice/recorder";
|
||||
import { RECORDING_NOTICE } from "@/app/(app)/settings/voice/voice-privacy-text";
|
||||
import { VoiceRecorder } from "./voice-recorder";
|
||||
|
||||
let connectivity: "online" | "offline" = "online";
|
||||
vi.mock("@/lib/auth/auth-context", () => ({ useCurrentUserId: () => "user-1" }));
|
||||
vi.mock("@/lib/offline/connectivity", () => ({ useConnectivity: () => connectivity }));
|
||||
|
||||
const handle = { stop: vi.fn(), cancel: vi.fn() };
|
||||
let options: StartRecordingOptions;
|
||||
const startRecording = vi.fn();
|
||||
vi.mock("@/lib/voice/recorder", () => ({
|
||||
pickMimeType: () => "audio/webm;codecs=opus",
|
||||
startRecording: (opts: StartRecordingOptions) => startRecording(opts),
|
||||
}));
|
||||
const syncPendingEntries = vi.fn();
|
||||
vi.mock("@/lib/offline/sync", () => ({
|
||||
syncPendingEntries: (...args: unknown[]) => syncPendingEntries(...args),
|
||||
}));
|
||||
|
||||
const RECORDING: Recording = {
|
||||
blob: new Blob(["audio"], { type: "audio/webm;codecs=opus" }),
|
||||
mime: "audio/webm;codecs=opus",
|
||||
durationSec: 42,
|
||||
};
|
||||
|
||||
function renderRecorder(props: Partial<React.ComponentProps<typeof VoiceRecorder>> = {}) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<VoiceRecorder
|
||||
colonyId="colony-1"
|
||||
inspectionDate="2026-10-16"
|
||||
typeInsteadHref="/inspections/new?colonyId=colony-1"
|
||||
{...props}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
async function startAndRecord(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.click(screen.getByRole("button", { name: "Aufnahme starten" }));
|
||||
await screen.findByRole("button", { name: "Aufnahme beenden" });
|
||||
}
|
||||
|
||||
function tick(sec: number) {
|
||||
act(() => {
|
||||
for (let second = 1; second <= sec; second += 1) options.onTick(second);
|
||||
});
|
||||
}
|
||||
|
||||
function liveText(): string {
|
||||
return document.querySelector('[aria-live="polite"]')?.textContent ?? "";
|
||||
}
|
||||
|
||||
describe("VoiceRecorder", () => {
|
||||
beforeEach(async () => {
|
||||
globalThis.indexedDB = new IDBFactory();
|
||||
// The module keeps its first connection: empty the store instead of the factory.
|
||||
const { discardAllPendingVoice } = await import("@/lib/voice/pending-voice");
|
||||
await discardAllPendingVoice("user-1");
|
||||
window.localStorage.clear();
|
||||
window.localStorage.setItem("apicore.voice.notice-seen", "1");
|
||||
connectivity = "online";
|
||||
handle.stop.mockReset().mockResolvedValue(RECORDING);
|
||||
handle.cancel.mockReset();
|
||||
startRecording.mockReset().mockImplementation(async (opts: StartRecordingOptions) => {
|
||||
options = opts;
|
||||
return handle;
|
||||
});
|
||||
syncPendingEntries.mockReset().mockResolvedValue({ synced: 0, failed: 0, skippedForeign: 0 });
|
||||
});
|
||||
|
||||
it("shows words and not just color while recording", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderRecorder();
|
||||
|
||||
await startAndRecord(user);
|
||||
tick(75);
|
||||
|
||||
expect(screen.getByText("Aufnahme läuft", { selector: "p" })).toBeInTheDocument();
|
||||
expect(screen.getByText(/1:15/)).toBeInTheDocument();
|
||||
// The same place: the start button became the stop button.
|
||||
expect(screen.queryByRole("button", { name: "Aufnahme starten" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not pulse for people who asked for less motion", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderRecorder();
|
||||
|
||||
await startAndRecord(user);
|
||||
|
||||
const dot = screen.getByText("Aufnahme läuft", { selector: "p" }).querySelector("span");
|
||||
expect(dot?.className).toContain("motion-safe:animate-pulse");
|
||||
expect(dot?.className.split(" ")).not.toContain("animate-pulse");
|
||||
});
|
||||
|
||||
it("announces the time only every 30 seconds", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderRecorder();
|
||||
await startAndRecord(user);
|
||||
|
||||
const heard = new Set<string>();
|
||||
for (let second = 1; second <= 61; second += 1) {
|
||||
act(() => options.onTick(second));
|
||||
heard.add(liveText());
|
||||
}
|
||||
|
||||
expect([...heard]).toEqual([
|
||||
"Aufnahme läuft.",
|
||||
"Aufnahme läuft, 30 Sekunden.",
|
||||
"Aufnahme läuft, 1 Minute.",
|
||||
]);
|
||||
});
|
||||
|
||||
it("warns 30 seconds before the end and announces it", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderRecorder();
|
||||
await startAndRecord(user);
|
||||
|
||||
tick(150);
|
||||
act(() => options.onWarn());
|
||||
|
||||
expect(screen.getByText("Noch 30 Sekunden")).toBeInTheDocument();
|
||||
expect(liveText()).toBe("Noch 30 Sekunden.");
|
||||
});
|
||||
|
||||
it("keeps what was recorded when the limit stops the recording", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderRecorder();
|
||||
await startAndRecord(user);
|
||||
|
||||
act(() => options.onAutoStop({ ...RECORDING, durationSec: 180 }));
|
||||
|
||||
expect(await screen.findByRole("heading", { name: /Aufnahme fertig · 3:00/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Senden" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the denied-state with a way to type instead", async () => {
|
||||
startRecording.mockRejectedValue(new DOMException("denied", "NotAllowedError"));
|
||||
const user = userEvent.setup();
|
||||
renderRecorder();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Aufnahme starten" }));
|
||||
|
||||
expect(await screen.findByText("Das Mikrofon ist nicht erlaubt.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Safari am iPhone")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Stattdessen tippen" })).toHaveAttribute(
|
||||
"href",
|
||||
"/inspections/new?colonyId=colony-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the notice once before the first recording", async () => {
|
||||
window.localStorage.clear();
|
||||
const user = userEvent.setup();
|
||||
const first = renderRecorder();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Aufnahme starten" }));
|
||||
|
||||
const dialog = await screen.findByRole("alertdialog");
|
||||
expect(within(dialog).getByText(RECORDING_NOTICE)).toBeInTheDocument();
|
||||
expect(startRecording).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(within(dialog).getByRole("button", { name: /Verstanden/ }));
|
||||
await screen.findByRole("button", { name: "Aufnahme beenden" });
|
||||
expect(startRecording).toHaveBeenCalledTimes(1);
|
||||
|
||||
first.unmount();
|
||||
renderRecorder();
|
||||
await user.click(screen.getByRole("button", { name: "Aufnahme starten" }));
|
||||
expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument();
|
||||
expect(startRecording).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not record when the notice is declined", async () => {
|
||||
window.localStorage.clear();
|
||||
const user = userEvent.setup();
|
||||
renderRecorder();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Aufnahme starten" }));
|
||||
await user.click(await screen.findByRole("button", { name: "Abbrechen" }));
|
||||
|
||||
expect(startRecording).not.toHaveBeenCalled();
|
||||
expect(window.localStorage.getItem("apicore.voice.notice-seen")).toBeNull();
|
||||
});
|
||||
|
||||
it("cancels at once when only a moment was recorded", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderRecorder();
|
||||
await startAndRecord(user);
|
||||
tick(2);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Abbrechen" }));
|
||||
|
||||
expect(handle.cancel).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByRole("button", { name: "Aufnahme starten" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("asks before cancelling a longer recording", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderRecorder();
|
||||
await startAndRecord(user);
|
||||
tick(12);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Abbrechen" }));
|
||||
const dialog = await screen.findByRole("alertdialog");
|
||||
expect(handle.cancel).not.toHaveBeenCalled();
|
||||
await user.click(within(dialog).getByRole("button", { name: "Aufnahme löschen" }));
|
||||
|
||||
expect(handle.cancel).toHaveBeenCalledTimes(1);
|
||||
expect(await screen.findByRole("button", { name: "Aufnahme starten" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offline the main action reads Speichern and the note lands in the queue", async () => {
|
||||
connectivity = "offline";
|
||||
const user = userEvent.setup();
|
||||
renderRecorder();
|
||||
await startAndRecord(user);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Aufnahme beenden" }));
|
||||
await user.click(await screen.findByRole("button", { name: "Speichern" }));
|
||||
|
||||
expect(
|
||||
await screen.findByText("Gespeichert. Wird gesendet, sobald Netz da ist."),
|
||||
).toBeInTheDocument();
|
||||
const { listPendingVoice } = await import("@/lib/voice/pending-voice");
|
||||
expect(await listPendingVoice("user-1")).toHaveLength(1);
|
||||
expect(syncPendingEntries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("online it sends right away and says so", async () => {
|
||||
const { discardAllPendingVoice } = await import("@/lib/voice/pending-voice");
|
||||
syncPendingEntries.mockImplementation(async () => {
|
||||
await discardAllPendingVoice("user-1");
|
||||
return { synced: 1, failed: 0, skippedForeign: 0 };
|
||||
});
|
||||
const onSaved = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
renderRecorder({ onSaved });
|
||||
await startAndRecord(user);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Aufnahme beenden" }));
|
||||
await user.click(await screen.findByRole("button", { name: "Senden" }));
|
||||
|
||||
expect(
|
||||
await screen.findByText("Gesendet. Du bekommst eine Meldung, wenn es fertig ist."),
|
||||
).toBeInTheDocument();
|
||||
expect(onSaved).toHaveBeenCalledWith({ kind: "sent" });
|
||||
expect(syncPendingEntries).toHaveBeenCalledWith("user-1", { afterChange: true });
|
||||
});
|
||||
|
||||
it("says why the server refused the recording and offers a new one", async () => {
|
||||
const { listPendingVoice, markVoiceFailed } = await import("@/lib/voice/pending-voice");
|
||||
syncPendingEntries.mockImplementation(async () => {
|
||||
const [note] = await listPendingVoice("user-1");
|
||||
await markVoiceFailed(note.id, { status: 413, message: "zu groß", details: { code: "TOO_LARGE" } } as never);
|
||||
return { synced: 0, failed: 1, skippedForeign: 0 };
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
renderRecorder();
|
||||
await startAndRecord(user);
|
||||
await user.click(screen.getByRole("button", { name: "Aufnahme beenden" }));
|
||||
await user.click(await screen.findByRole("button", { name: "Senden" }));
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Die Sprachnotiz ist zu groß");
|
||||
await user.click(screen.getByRole("button", { name: "Neu aufnehmen" }));
|
||||
|
||||
expect(await screen.findByRole("button", { name: "Aufnahme starten" })).toBeInTheDocument();
|
||||
await waitFor(async () => expect(await listPendingVoice("user-1")).toHaveLength(0));
|
||||
});
|
||||
|
||||
|
||||
describe("focus", () => {
|
||||
async function recordAndFinish(user: ReturnType<typeof userEvent.setup>) {
|
||||
await startAndRecord(user);
|
||||
await user.click(screen.getByRole("button", { name: "Aufnahme beenden" }));
|
||||
return screen.findByRole("button", { name: /Senden|Speichern/ });
|
||||
}
|
||||
const resultHeading = () => screen.getByRole("heading", { name: "Ergebnis" });
|
||||
|
||||
it("moves to the result heading after a sent note", async () => {
|
||||
const { discardAllPendingVoice } = await import("@/lib/voice/pending-voice");
|
||||
syncPendingEntries.mockImplementation(async () => {
|
||||
await discardAllPendingVoice("user-1");
|
||||
return { synced: 1, failed: 0, skippedForeign: 0 };
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
renderRecorder();
|
||||
|
||||
await user.click(await recordAndFinish(user));
|
||||
|
||||
await screen.findByText(/^Gesendet/);
|
||||
await waitFor(() => expect(document.activeElement).toBe(resultHeading()));
|
||||
});
|
||||
|
||||
it("moves to the result heading after a queued note", async () => {
|
||||
connectivity = "offline";
|
||||
const user = userEvent.setup();
|
||||
renderRecorder();
|
||||
|
||||
await user.click(await recordAndFinish(user));
|
||||
|
||||
await screen.findByText(/^Gespeichert/);
|
||||
await waitFor(() => expect(document.activeElement).toBe(resultHeading()));
|
||||
});
|
||||
|
||||
it("moves to the result heading after a refused note", async () => {
|
||||
const { listPendingVoice, markVoiceFailed } = await import("@/lib/voice/pending-voice");
|
||||
syncPendingEntries.mockImplementation(async () => {
|
||||
const [note] = await listPendingVoice("user-1");
|
||||
await markVoiceFailed(note.id, {
|
||||
status: 413,
|
||||
message: "x",
|
||||
details: { code: "TOO_LARGE" },
|
||||
} as never);
|
||||
return { synced: 0, failed: 1, skippedForeign: 0 };
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
renderRecorder();
|
||||
|
||||
await user.click(await recordAndFinish(user));
|
||||
|
||||
await screen.findByRole("alert");
|
||||
await waitFor(() => expect(document.activeElement).toBe(resultHeading()));
|
||||
});
|
||||
|
||||
it("keeps the main button focusable (aria-disabled) while the microphone starts", async () => {
|
||||
startRecording.mockReturnValue(new Promise(() => undefined));
|
||||
const user = userEvent.setup();
|
||||
renderRecorder();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Aufnahme starten" }));
|
||||
|
||||
const button = screen.getByRole("button", { name: "Aufnahme starten" });
|
||||
expect(button).toHaveAttribute("aria-disabled", "true");
|
||||
expect(button).not.toBeDisabled();
|
||||
expect(document.activeElement).toBe(button);
|
||||
await user.click(button);
|
||||
expect(startRecording).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns to the main button after cancelling", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderRecorder();
|
||||
await startAndRecord(user);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Abbrechen" }));
|
||||
|
||||
const button = await screen.findByRole("button", { name: "Aufnahme starten" });
|
||||
await waitFor(() => expect(document.activeElement).toBe(button));
|
||||
});
|
||||
});
|
||||
|
||||
describe("a recording the limit stopped", () => {
|
||||
it("is on the device at once and shows the saved state", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderRecorder();
|
||||
await startAndRecord(user);
|
||||
|
||||
act(() => options.onAutoStop({ ...RECORDING, durationSec: 180 }));
|
||||
|
||||
expect(
|
||||
await screen.findByText("Gespeichert. Wird gesendet, sobald Netz da ist."),
|
||||
).toBeInTheDocument();
|
||||
const { listPendingVoice } = await import("@/lib/voice/pending-voice");
|
||||
expect(await listPendingVoice("user-1")).toHaveLength(1);
|
||||
expect(syncPendingEntries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is only synced, not queued twice, when Senden is pressed", async () => {
|
||||
const { listPendingVoice, discardAllPendingVoice } = await import(
|
||||
"@/lib/voice/pending-voice"
|
||||
);
|
||||
syncPendingEntries.mockImplementation(async () => {
|
||||
await discardAllPendingVoice("user-1");
|
||||
return { synced: 1, failed: 0, skippedForeign: 0 };
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
renderRecorder();
|
||||
await startAndRecord(user);
|
||||
act(() => options.onAutoStop({ ...RECORDING, durationSec: 180 }));
|
||||
await screen.findByText(/^Gespeichert/);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Senden" }));
|
||||
|
||||
expect(await screen.findByText(/^Gesendet/)).toBeInTheDocument();
|
||||
expect(syncPendingEntries).toHaveBeenCalledTimes(1);
|
||||
expect(await listPendingVoice("user-1")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("is removed from the device when the user records anew", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderRecorder();
|
||||
await startAndRecord(user);
|
||||
act(() => options.onAutoStop({ ...RECORDING, durationSec: 180 }));
|
||||
await screen.findByText(/^Gespeichert/);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Neu aufnehmen" }));
|
||||
const dialog = await screen.findByRole("alertdialog");
|
||||
await user.click(within(dialog).getByRole("button", { name: "Neu aufnehmen" }));
|
||||
|
||||
await screen.findByRole("button", { name: "Aufnahme starten" });
|
||||
const { listPendingVoice } = await import("@/lib/voice/pending-voice");
|
||||
await waitFor(async () => expect(await listPendingVoice("user-1")).toHaveLength(0));
|
||||
});
|
||||
});
|
||||
|
||||
describe("leaving", () => {
|
||||
function leave(): boolean {
|
||||
const event = new Event("beforeunload", { cancelable: true });
|
||||
window.dispatchEvent(event);
|
||||
return event.defaultPrevented;
|
||||
}
|
||||
|
||||
it("is guarded while recording and while the result is unsaved, and free otherwise", async () => {
|
||||
connectivity = "offline";
|
||||
const user = userEvent.setup();
|
||||
renderRecorder();
|
||||
expect(leave()).toBe(false);
|
||||
|
||||
await startAndRecord(user);
|
||||
expect(leave()).toBe(true);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Aufnahme beenden" }));
|
||||
await screen.findByRole("button", { name: "Speichern" });
|
||||
expect(leave()).toBe(true);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Speichern" }));
|
||||
await screen.findByText(/^Gespeichert/);
|
||||
expect(leave()).toBe(false);
|
||||
});
|
||||
|
||||
it("is free again after cancelling", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderRecorder();
|
||||
await startAndRecord(user);
|
||||
await user.click(screen.getByRole("button", { name: "Abbrechen" }));
|
||||
|
||||
expect(leave()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("asks before throwing away a finished recording", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderRecorder();
|
||||
await startAndRecord(user);
|
||||
await user.click(screen.getByRole("button", { name: "Aufnahme beenden" }));
|
||||
await user.click(await screen.findByRole("button", { name: "Neu aufnehmen" }));
|
||||
|
||||
const dialog = await screen.findByRole("alertdialog");
|
||||
expect(screen.queryByRole("button", { name: "Aufnahme starten" })).not.toBeInTheDocument();
|
||||
await user.click(within(dialog).getByRole("button", { name: "Neu aufnehmen" }));
|
||||
|
||||
expect(await screen.findByRole("button", { name: "Aufnahme starten" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("gives every dialog button a 56 px target", async () => {
|
||||
window.localStorage.clear();
|
||||
const user = userEvent.setup();
|
||||
renderRecorder();
|
||||
await user.click(screen.getByRole("button", { name: "Aufnahme starten" }));
|
||||
const notice = await screen.findByRole("alertdialog");
|
||||
for (const button of within(notice).getAllByRole("button")) {
|
||||
expect(button).toHaveClass("min-h-14");
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks recording with a reason until a colony is chosen", () => {
|
||||
renderRecorder({ blockedReason: "Bitte zuerst ein Volk wählen." });
|
||||
|
||||
expect(screen.getByRole("button", { name: "Aufnahme starten" })).toBeDisabled();
|
||||
expect(screen.getByText("Bitte zuerst ein Volk wählen.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("axe finds nothing", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = renderRecorder();
|
||||
const run = () => axe.run(container, { rules: { "color-contrast": { enabled: false } } });
|
||||
|
||||
expect((await run()).violations).toEqual([]);
|
||||
await startAndRecord(user);
|
||||
tick(5);
|
||||
expect((await run()).violations).toEqual([]);
|
||||
await user.click(screen.getByRole("button", { name: "Aufnahme beenden" }));
|
||||
await screen.findByRole("button", { name: "Senden" });
|
||||
expect((await run()).violations).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,456 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
import Link from "next/link";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { CheckCircle2, Mic, Square } from "lucide-react";
|
||||
import { useCurrentUserId } from "@/lib/auth/auth-context";
|
||||
import { useConnectivity } from "@/lib/offline/connectivity";
|
||||
import { OFFLINE_QUEUE_QUERY_KEY } from "@/lib/offline/use-capture-mutation";
|
||||
import { voiceKeys } from "@/lib/voice/api";
|
||||
import { hasSeenRecordingNotice, markRecordingNoticeSeen } from "@/lib/voice/consent";
|
||||
import { discardPendingVoice, VoiceStorageFullError } from "@/lib/voice/pending-voice";
|
||||
import type { Recording } from "@/lib/voice/recorder";
|
||||
import { saveVoiceNote, type SaveVoiceOutcome } from "@/lib/voice/save-voice-note";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { MicrophoneHelp } from "./microphone-help";
|
||||
import { RecordingNoticeDialog } from "./recording-notice-dialog";
|
||||
import { VoiceConfirmDialog } from "./voice-confirm-dialog";
|
||||
import {
|
||||
CONFIRM_CANCEL_AFTER_SEC,
|
||||
MAX_RECORDING_SEC,
|
||||
formatClock,
|
||||
useVoiceRecorder,
|
||||
} from "./use-voice-recorder";
|
||||
|
||||
const SENT_TEXT = "Gesendet. Du bekommst eine Meldung, wenn es fertig ist.";
|
||||
const QUEUED_TEXT = "Gespeichert. Wird gesendet, sobald Netz da ist.";
|
||||
const SAVE_FAILED_TEXT =
|
||||
"Die Sprachnotiz konnte nicht gespeichert werden. Bitte noch einmal versuchen.";
|
||||
const FAILED_TEXT = "Die Aufnahme hat nicht geklappt. Bitte noch einmal versuchen.";
|
||||
|
||||
type Confirming = "cancel" | "restart" | "discard" | null;
|
||||
|
||||
const NEVER_CHANGES = () => () => undefined;
|
||||
|
||||
function useWakeLockMissing(): boolean {
|
||||
return useSyncExternalStore(
|
||||
NEVER_CHANGES,
|
||||
() => !("wakeLock" in navigator),
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
|
||||
export interface VoiceRecorderProps {
|
||||
colonyId: string;
|
||||
/** Calendar day "YYYY-MM-DD". */
|
||||
inspectionDate: string;
|
||||
/** Where "Stattdessen tippen" leads: the inspection form for the same colony. */
|
||||
typeInsteadHref: string;
|
||||
/** Recording is off for this reason (e.g. no colony chosen yet). */
|
||||
blockedReason?: string | null;
|
||||
/** True while audio exists that is not on the device yet: the page locks colony and date. */
|
||||
onBusyChange?(busy: boolean): void;
|
||||
onSaved?(outcome: SaveVoiceOutcome): void;
|
||||
/** Rendered after saving, e.g. "Weiter mit B4". */
|
||||
savedActions?: ReactNode;
|
||||
}
|
||||
|
||||
function outcomeText(outcome: SaveVoiceOutcome): string {
|
||||
if (outcome.kind === "sent") return SENT_TEXT;
|
||||
if (outcome.kind === "queued") {
|
||||
return outcome.hint ? `${QUEUED_TEXT} ${outcome.hint}` : QUEUED_TEXT;
|
||||
}
|
||||
return outcome.text;
|
||||
}
|
||||
|
||||
export function VoiceRecorder({
|
||||
colonyId,
|
||||
inspectionDate,
|
||||
typeInsteadHref,
|
||||
blockedReason = null,
|
||||
onBusyChange,
|
||||
onSaved,
|
||||
savedActions,
|
||||
}: VoiceRecorderProps) {
|
||||
const userId = useCurrentUserId();
|
||||
const online = useConnectivity() === "online";
|
||||
const queryClient = useQueryClient();
|
||||
const wakeLockMissing = useWakeLockMissing();
|
||||
const [noticeOpen, setNoticeOpen] = useState(false);
|
||||
const [confirming, setConfirming] = useState<Confirming>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [outcome, setOutcome] = useState<SaveVoiceOutcome | null>(null);
|
||||
// The recording's note on the device, once it has one: a limit stop queues at once.
|
||||
const [queuedNoteId, setQueuedNoteId] = useState<string | null>(null);
|
||||
const resultHeading = useRef<HTMLHeadingElement>(null);
|
||||
const mainButton = useRef<HTMLButtonElement>(null);
|
||||
const focusMainWhenIdle = useRef(false);
|
||||
|
||||
function afterQueueChange() {
|
||||
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
|
||||
}
|
||||
|
||||
/** A recording the limit ended must not depend on the user pressing "Speichern". */
|
||||
function queueAtOnce(recording: Recording) {
|
||||
if (!userId) return;
|
||||
saveVoiceNote({
|
||||
userId,
|
||||
recording,
|
||||
colonyId,
|
||||
inspectionDate,
|
||||
online: false,
|
||||
onQueued: setQueuedNoteId,
|
||||
}).then(afterQueueChange, (error: unknown) =>
|
||||
setSaveError(error instanceof VoiceStorageFullError ? error.message : SAVE_FAILED_TEXT),
|
||||
);
|
||||
}
|
||||
|
||||
const recorder = useVoiceRecorder({ onAutoStop: queueAtOnce });
|
||||
const { phase, sec, recording } = recorder;
|
||||
|
||||
const unsaved =
|
||||
phase === "starting" ||
|
||||
phase === "recording" ||
|
||||
(phase === "recorded" && !outcome && !queuedNoteId);
|
||||
useEffect(() => {
|
||||
onBusyChange?.(unsaved);
|
||||
}, [unsaved, onBusyChange]);
|
||||
|
||||
// Leaving the page would lose the recording (or the finished result).
|
||||
useEffect(() => {
|
||||
if (!unsaved) return;
|
||||
const guard = (event: BeforeUnloadEvent) => {
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
};
|
||||
window.addEventListener("beforeunload", guard);
|
||||
return () => window.removeEventListener("beforeunload", guard);
|
||||
}, [unsaved]);
|
||||
|
||||
// The big button disappears after the stop and the result changes again after
|
||||
// saving: keyboard focus follows to the result.
|
||||
const showsResult = phase === "recorded" || outcome !== null;
|
||||
useEffect(() => {
|
||||
if (showsResult) resultHeading.current?.focus();
|
||||
}, [phase, outcome, showsResult]);
|
||||
|
||||
useEffect(() => {
|
||||
if (phase === "idle" && focusMainWhenIdle.current) {
|
||||
focusMainWhenIdle.current = false;
|
||||
mainButton.current?.focus();
|
||||
}
|
||||
}, [phase]);
|
||||
|
||||
function pressMainButton() {
|
||||
if (phase === "starting") return;
|
||||
if (phase === "recording") {
|
||||
recorder.stop();
|
||||
return;
|
||||
}
|
||||
if (hasSeenRecordingNotice()) {
|
||||
recorder.start();
|
||||
} else {
|
||||
setNoticeOpen(true);
|
||||
}
|
||||
}
|
||||
|
||||
function confirmNotice() {
|
||||
markRecordingNoticeSeen();
|
||||
setNoticeOpen(false);
|
||||
recorder.start();
|
||||
}
|
||||
|
||||
function cancelRecording() {
|
||||
if (sec > CONFIRM_CANCEL_AFTER_SEC) {
|
||||
setConfirming("cancel");
|
||||
} else {
|
||||
focusMainWhenIdle.current = true;
|
||||
recorder.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
/** Clears the screen for the next recording; the saved note stays on the device. */
|
||||
function startOver() {
|
||||
setOutcome(null);
|
||||
setSaveError(null);
|
||||
setQueuedNoteId(null);
|
||||
recorder.reset();
|
||||
}
|
||||
|
||||
/** The user gives this recording up: a note it already has on the device goes too. */
|
||||
async function throwAway() {
|
||||
setConfirming(null);
|
||||
focusMainWhenIdle.current = true;
|
||||
if (queuedNoteId && userId) {
|
||||
await discardPendingVoice(queuedNoteId, userId).catch(() => undefined);
|
||||
afterQueueChange();
|
||||
}
|
||||
startOver();
|
||||
}
|
||||
|
||||
async function startOverAfterRejection(noteId: string) {
|
||||
if (userId) {
|
||||
await discardPendingVoice(noteId, userId).catch(() => undefined);
|
||||
afterQueueChange();
|
||||
}
|
||||
startOver();
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!recording || !userId || saving) return;
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
const result = await saveVoiceNote({
|
||||
userId,
|
||||
recording,
|
||||
colonyId,
|
||||
inspectionDate,
|
||||
online,
|
||||
queuedNoteId,
|
||||
onQueued: setQueuedNoteId,
|
||||
});
|
||||
setOutcome(result);
|
||||
afterQueueChange();
|
||||
void queryClient.invalidateQueries({ queryKey: voiceKeys.all });
|
||||
onSaved?.(result);
|
||||
} catch (error) {
|
||||
setSaveError(error instanceof VoiceStorageFullError ? error.message : SAVE_FAILED_TEXT);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const liveRegion = (
|
||||
<p role="status" aria-live="polite" className="sr-only">
|
||||
{recorder.announcement}
|
||||
</p>
|
||||
);
|
||||
|
||||
if (phase === "denied" || phase === "no-microphone" || phase === "unsupported") {
|
||||
return (
|
||||
<>
|
||||
{liveRegion}
|
||||
<MicrophoneHelp
|
||||
reason={phase}
|
||||
typeInsteadHref={typeInsteadHref}
|
||||
onRetry={phase === "unsupported" ? undefined : recorder.reset}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (outcome) {
|
||||
const rejected = outcome.kind === "rejected";
|
||||
return (
|
||||
<section aria-labelledby="voice-result" className="flex flex-col gap-4">
|
||||
{liveRegion}
|
||||
<h2 id="voice-result" ref={resultHeading} tabIndex={-1} className="sr-only">
|
||||
Ergebnis
|
||||
</h2>
|
||||
<p
|
||||
role={rejected ? "alert" : "status"}
|
||||
className="flex items-start gap-2 text-lg font-medium"
|
||||
>
|
||||
{rejected ? null : (
|
||||
<CheckCircle2
|
||||
className="mt-0.5 size-5 shrink-0 text-condition-good-foreground"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
{outcomeText(outcome)}
|
||||
</p>
|
||||
{outcome.kind === "rejected" ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="touch"
|
||||
className="min-h-14"
|
||||
onClick={() => void startOverAfterRejection(outcome.noteId)}
|
||||
>
|
||||
Neu aufnehmen
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
{savedActions}
|
||||
<Button
|
||||
type="button"
|
||||
size="touch"
|
||||
variant="outline"
|
||||
className="min-h-14"
|
||||
onClick={startOver}
|
||||
>
|
||||
Noch eine Aufnahme
|
||||
</Button>
|
||||
<Button
|
||||
size="touch"
|
||||
variant="ghost"
|
||||
className="min-h-14"
|
||||
nativeButton={false}
|
||||
render={<Link href="/voice" />}
|
||||
>
|
||||
Zu den Sprachnotizen
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (phase === "recorded" && recording) {
|
||||
return (
|
||||
<section aria-labelledby="voice-result" className="flex flex-col gap-4">
|
||||
{liveRegion}
|
||||
<h2
|
||||
id="voice-result"
|
||||
ref={resultHeading}
|
||||
tabIndex={-1}
|
||||
className="font-heading text-lg font-semibold"
|
||||
>
|
||||
Aufnahme fertig · {formatClock(recording.durationSec)}
|
||||
</h2>
|
||||
{queuedNoteId ? (
|
||||
<p role="status" className="flex items-start gap-2 text-base font-medium">
|
||||
<CheckCircle2
|
||||
className="mt-0.5 size-5 shrink-0 text-condition-good-foreground"
|
||||
aria-hidden
|
||||
/>
|
||||
{QUEUED_TEXT}
|
||||
</p>
|
||||
) : null}
|
||||
{recording.url ? (
|
||||
<audio controls src={recording.url} className="w-full" aria-label="Aufnahme anhören" />
|
||||
) : null}
|
||||
{saveError ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{saveError}
|
||||
</p>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
size="touch"
|
||||
className="min-h-14 text-base"
|
||||
disabled={saving || !userId}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{saving ? "Wird gespeichert…" : online ? "Senden" : "Speichern"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="touch"
|
||||
variant="outline"
|
||||
className="min-h-14"
|
||||
disabled={saving}
|
||||
onClick={() => setConfirming("restart")}
|
||||
>
|
||||
Neu aufnehmen
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="touch"
|
||||
variant="ghost"
|
||||
className="min-h-14"
|
||||
disabled={saving}
|
||||
onClick={() => setConfirming("discard")}
|
||||
>
|
||||
Verwerfen
|
||||
</Button>
|
||||
<VoiceConfirmDialog
|
||||
open={confirming === "restart" || confirming === "discard"}
|
||||
title={confirming === "restart" ? "Neu aufnehmen?" : "Aufnahme verwerfen?"}
|
||||
description="Die Aufnahme wird gelöscht und nicht gesendet."
|
||||
cancelLabel="Behalten"
|
||||
confirmLabel={confirming === "restart" ? "Neu aufnehmen" : "Verwerfen"}
|
||||
onCancel={() => setConfirming(null)}
|
||||
onConfirm={() => void throwAway()}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const recordingNow = phase === "recording";
|
||||
const starting = phase === "starting";
|
||||
const blocked = Boolean(blockedReason);
|
||||
return (
|
||||
<section aria-label="Aufnahme" className="flex flex-col items-center gap-4">
|
||||
{liveRegion}
|
||||
{recordingNow ? (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<p className="flex items-center gap-2 text-lg font-semibold text-destructive">
|
||||
<span
|
||||
aria-hidden
|
||||
className="size-3 rounded-full bg-destructive motion-safe:animate-pulse"
|
||||
/>
|
||||
Aufnahme läuft
|
||||
</p>
|
||||
<p className="font-heading text-4xl font-semibold tabular-nums">
|
||||
{formatClock(sec)}
|
||||
<span className="text-base font-normal text-muted-foreground">
|
||||
{" "}
|
||||
von {formatClock(MAX_RECORDING_SEC)}
|
||||
</span>
|
||||
</p>
|
||||
{recorder.warning ? <p className="text-base font-medium">Noch 30 Sekunden</p> : null}
|
||||
{wakeLockMissing ? (
|
||||
<p className="text-sm text-muted-foreground">Display anlassen</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<Button
|
||||
ref={mainButton}
|
||||
type="button"
|
||||
size="touch"
|
||||
variant={recordingNow ? "destructive" : "default"}
|
||||
className="h-18 w-full max-w-sm text-lg"
|
||||
// aria-disabled keeps the keyboard focus on the button while the microphone starts.
|
||||
disabled={blocked}
|
||||
aria-disabled={starting || undefined}
|
||||
aria-describedby={blockedReason ? "voice-blocked" : undefined}
|
||||
onClick={pressMainButton}
|
||||
>
|
||||
{recordingNow ? <Square aria-hidden /> : <Mic aria-hidden />}
|
||||
{recordingNow ? "Aufnahme beenden" : "Aufnahme starten"}
|
||||
</Button>
|
||||
{blockedReason ? (
|
||||
<p id="voice-blocked" className="text-sm text-muted-foreground">
|
||||
{blockedReason}
|
||||
</p>
|
||||
) : null}
|
||||
{phase === "failed" ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{FAILED_TEXT}
|
||||
</p>
|
||||
) : null}
|
||||
{recordingNow ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="touch"
|
||||
variant="outline"
|
||||
className="min-h-14"
|
||||
onClick={cancelRecording}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
) : null}
|
||||
<RecordingNoticeDialog
|
||||
open={noticeOpen}
|
||||
onConfirm={confirmNotice}
|
||||
onCancel={() => setNoticeOpen(false)}
|
||||
/>
|
||||
<VoiceConfirmDialog
|
||||
open={confirming === "cancel"}
|
||||
title="Aufnahme abbrechen?"
|
||||
description="Die Aufnahme wird gelöscht. Das lässt sich nicht rückgängig machen."
|
||||
cancelLabel="Weiter aufnehmen"
|
||||
confirmLabel="Aufnahme löschen"
|
||||
onCancel={() => setConfirming(null)}
|
||||
onConfirm={() => {
|
||||
setConfirming(null);
|
||||
focusMainWhenIdle.current = true;
|
||||
recorder.cancel();
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -104,6 +104,17 @@ describe("rejectionText", () => {
|
||||
expect(voiceRejectionText({ status, message: "x", details: { code }, at })).toBe(text);
|
||||
});
|
||||
|
||||
it("words a switched-off recording as a definitive refusal", () => {
|
||||
expect(
|
||||
voiceRejectionText({
|
||||
status: 403,
|
||||
message: "x",
|
||||
details: { code: "VOICE_RECORDING_DISABLED" },
|
||||
at,
|
||||
}),
|
||||
).toBe("Sprachnotizen sind gerade ausgeschaltet. Bitte tippe die Durchsicht ein.");
|
||||
});
|
||||
|
||||
it("falls back on the status and never shows the raw server message", () => {
|
||||
expect(voiceRejectionText({ status: 403, message: "Forbidden", at })).toBe(
|
||||
"Keine Berechtigung für Sprachnotizen.",
|
||||
|
||||
@@ -101,7 +101,12 @@ const VOICE_NO_LONGER_FITS = "Die Sprachnotiz passt nicht mehr zur Durchsicht. B
|
||||
export const VOICE_TOO_MANY_OPEN_HINT =
|
||||
"Zu viele offene Sprachnotizen – sie werden später gesendet.";
|
||||
|
||||
/** The operator switched voice recording off; the one place this sentence lives. */
|
||||
export const VOICE_RECORDING_DISABLED_TEXT =
|
||||
"Sprachnotizen sind gerade ausgeschaltet. Bitte tippe die Durchsicht ein.";
|
||||
|
||||
const VOICE_REJECTION_BY_CODE: Record<string, string> = {
|
||||
VOICE_RECORDING_DISABLED: VOICE_RECORDING_DISABLED_TEXT,
|
||||
TOO_LARGE: "Die Sprachnotiz ist zu groß. Bitte kürzer aufnehmen.",
|
||||
UNSUPPORTED_AUDIO: "Dieses Audioformat wird nicht unterstützt. Bitte neu aufnehmen.",
|
||||
TOO_MANY_OPEN: "Zu viele offene Sprachnotizen. Erst auswerten oder verwerfen.",
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import "fake-indexeddb/auto";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const syncPendingEntries = vi.fn();
|
||||
vi.mock("@/lib/offline/sync", () => ({
|
||||
syncPendingEntries: (...args: unknown[]) => syncPendingEntries(...args),
|
||||
}));
|
||||
|
||||
const recording = {
|
||||
blob: new Blob(["audio"], { type: "audio/webm" }),
|
||||
mime: "audio/webm",
|
||||
durationSec: 30,
|
||||
};
|
||||
const base = { userId: "user-1", recording, colonyId: "c-1", inspectionDate: "2026-10-16" };
|
||||
|
||||
let listPendingVoice: typeof import("./pending-voice").listPendingVoice;
|
||||
let saveVoiceNote: typeof import("./save-voice-note").saveVoiceNote;
|
||||
|
||||
describe("saveVoiceNote", () => {
|
||||
beforeEach(async () => {
|
||||
globalThis.indexedDB = new IDBFactory();
|
||||
vi.resetModules();
|
||||
({ listPendingVoice } = await import("./pending-voice"));
|
||||
({ saveVoiceNote } = await import("./save-voice-note"));
|
||||
syncPendingEntries.mockReset().mockResolvedValue({ synced: 0, failed: 0, skippedForeign: 0 });
|
||||
});
|
||||
|
||||
it("queues offline without syncing and reports the note id", async () => {
|
||||
const onQueued = vi.fn();
|
||||
|
||||
const outcome = await saveVoiceNote({ ...base, online: false, onQueued });
|
||||
|
||||
expect(outcome).toEqual({ kind: "queued" });
|
||||
const [note] = await listPendingVoice("user-1");
|
||||
expect(onQueued).toHaveBeenCalledWith(note.id);
|
||||
expect(syncPendingEntries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not queue a second note when the same recording is saved again", async () => {
|
||||
syncPendingEntries.mockRejectedValueOnce(new Error("sync broke"));
|
||||
const onQueued = vi.fn();
|
||||
const first = await saveVoiceNote({ ...base, online: true, onQueued });
|
||||
expect(first).toEqual({ kind: "queued" });
|
||||
const queuedNoteId = onQueued.mock.calls[0][0] as string;
|
||||
|
||||
await saveVoiceNote({ ...base, online: true, queuedNoteId });
|
||||
|
||||
expect(await listPendingVoice("user-1")).toHaveLength(1);
|
||||
expect(syncPendingEntries).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { syncPendingEntries } from "@/lib/offline/sync";
|
||||
import { VOICE_TOO_MANY_OPEN_HINT, voiceRejectionText } from "@/lib/offline/queue-texts";
|
||||
import type { Recording } from "./recorder";
|
||||
import { getPendingVoice, queueVoiceNote } from "./pending-voice";
|
||||
|
||||
/*
|
||||
* Saving a finished recording: it always goes to the device queue first, so a
|
||||
* lost connection never loses it. Online, the sync is asked to send it right
|
||||
* away; what the server did is read back from the queue.
|
||||
*/
|
||||
|
||||
export type SaveVoiceOutcome =
|
||||
| { kind: "sent" }
|
||||
| { kind: "queued"; hint?: string }
|
||||
| { kind: "rejected"; noteId: string; text: string };
|
||||
|
||||
export interface SaveVoiceNoteInput {
|
||||
userId: string;
|
||||
recording: Recording;
|
||||
colonyId: string;
|
||||
inspectionDate: string;
|
||||
online: boolean;
|
||||
/** The note this recording already has on the device (a retry must not queue a second one). */
|
||||
queuedNoteId?: string | null;
|
||||
/** Called as soon as the recording is on the device. */
|
||||
onQueued?(noteId: string): void;
|
||||
}
|
||||
|
||||
export async function saveVoiceNote(input: SaveVoiceNoteInput): Promise<SaveVoiceOutcome> {
|
||||
const { userId, recording, colonyId, inspectionDate, online, onQueued } = input;
|
||||
let noteId = input.queuedNoteId ?? null;
|
||||
if (!noteId) {
|
||||
const note = await queueVoiceNote({
|
||||
ownerUserId: userId,
|
||||
blob: recording.blob,
|
||||
mime: recording.mime,
|
||||
durationSec: recording.durationSec,
|
||||
colonyId,
|
||||
inspectionDate,
|
||||
});
|
||||
noteId = note.id;
|
||||
onQueued?.(noteId);
|
||||
}
|
||||
if (!online) {
|
||||
return { kind: "queued" };
|
||||
}
|
||||
try {
|
||||
await syncPendingEntries(userId, { afterChange: true });
|
||||
} catch {
|
||||
// The note is safe in the queue; the next sync run sends it.
|
||||
return { kind: "queued" };
|
||||
}
|
||||
let after;
|
||||
try {
|
||||
after = await getPendingVoice(noteId);
|
||||
} catch {
|
||||
// Unknown what the sync did; the note id is kept, so a retry only reads again.
|
||||
return { kind: "queued" };
|
||||
}
|
||||
if (!after) {
|
||||
return { kind: "sent" };
|
||||
}
|
||||
if (after.lastError) {
|
||||
return { kind: "rejected", noteId, text: voiceRejectionText(after.lastError) };
|
||||
}
|
||||
return after.blockedBy ? { kind: "queued", hint: VOICE_TOO_MANY_OPEN_HINT } : { kind: "queued" };
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
VOICE_ERROR_TEXTS_DE,
|
||||
VOICE_STATUS_TEXTS_DE,
|
||||
VOICE_WAITING_FOR_NETWORK,
|
||||
describeVoiceCapture,
|
||||
isListedVoiceStatus,
|
||||
isVoiceInProgress,
|
||||
voiceErrorText,
|
||||
} from "./status-texts";
|
||||
|
||||
const backendTexts = path.resolve(process.cwd(), "../ApiCore-Backend/src/voice/voice-error-texts.ts");
|
||||
|
||||
describe("voice status texts", () => {
|
||||
// Same sentences as the backend (notifications) say.
|
||||
it.skipIf(!existsSync(backendTexts))("equals the backend error texts", () => {
|
||||
const source = readFileSync(backendTexts, "utf8");
|
||||
const backend = new Map(
|
||||
[...source.matchAll(/([A-Z_]+):\s*'([^']*)'/g)].map((m) => [m[1], m[2]]),
|
||||
);
|
||||
expect(backend.size).toBeGreaterThan(10);
|
||||
|
||||
for (const [code, text] of backend) {
|
||||
expect(VOICE_ERROR_TEXTS_DE[code], code).toBe(text);
|
||||
}
|
||||
});
|
||||
|
||||
it("points at the retry button where the list offers one", () => {
|
||||
expect(VOICE_ERROR_TEXTS_DE.TRANSCRIBE_FAILED).toContain("Nochmal versuchen");
|
||||
expect(VOICE_ERROR_TEXTS_DE.EXTRACT_FAILED).toContain("erneut auswerten");
|
||||
});
|
||||
|
||||
it("lets the user read the text of a failed evaluation", () => {
|
||||
for (const lastErrorCode of ["EXTRACT_FAILED", "EXTRACT_INVALID", "REFUSED"]) {
|
||||
expect(describeVoiceCapture({ status: "FAILED", lastErrorCode }).action).toBe("read-text");
|
||||
}
|
||||
expect(describeVoiceCapture({ status: "FAILED", lastErrorCode: "NO_SPEECH" }).action).toBe("none");
|
||||
});
|
||||
|
||||
it("says Text liegt vor when the evaluation is switched off", () => {
|
||||
const state = describeVoiceCapture({ status: "TRANSCRIBED", lastErrorCode: null }, false);
|
||||
|
||||
expect(state.status.label).toBe("Text liegt vor");
|
||||
expect(state.action).toBe("read-text");
|
||||
expect(describeVoiceCapture({ status: "TRANSCRIBED", lastErrorCode: null }, true).status.label).toBe(
|
||||
"Wird ausgewertet",
|
||||
);
|
||||
expect(isVoiceInProgress("TRANSCRIBED", false)).toBe(false);
|
||||
expect(isVoiceInProgress("TRANSCRIBED", true)).toBe(true);
|
||||
});
|
||||
|
||||
it("names every listed state in words with an icon", () => {
|
||||
expect(VOICE_WAITING_FOR_NETWORK.label).toBe("Wartet auf Netz");
|
||||
expect(VOICE_STATUS_TEXTS_DE.QUEUED.label).toBe("Wird abgeschrieben");
|
||||
expect(VOICE_STATUS_TEXTS_DE.EXTRACTING.label).toBe("Wird ausgewertet");
|
||||
expect(VOICE_STATUS_TEXTS_DE.READY.label).toBe("Fertig – prüfen");
|
||||
expect(VOICE_STATUS_TEXTS_DE.FAILED.label).toBe("Fehlgeschlagen");
|
||||
for (const entry of Object.values(VOICE_STATUS_TEXTS_DE)) {
|
||||
expect(entry.icon).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("offers Prüfen only for a ready note", () => {
|
||||
expect(describeVoiceCapture({ status: "READY", lastErrorCode: null }).action).toBe("review");
|
||||
for (const status of ["QUEUED", "TRANSCRIBING", "TRANSCRIBED", "EXTRACTING", "FAILED"] as const) {
|
||||
expect(describeVoiceCapture({ status, lastErrorCode: null }).action).toBe("none");
|
||||
}
|
||||
});
|
||||
|
||||
it("lets the user read the text when the evaluation is off", () => {
|
||||
const state = describeVoiceCapture({ status: "TRANSCRIBED", lastErrorCode: "EXTRACT_DISABLED" });
|
||||
|
||||
expect(state.action).toBe("read-text");
|
||||
expect(state.status.label).toBe("Text liegt vor");
|
||||
expect(state.detail).toBe(VOICE_ERROR_TEXTS_DE.EXTRACT_DISABLED);
|
||||
});
|
||||
|
||||
it("allows another try only where one can help", () => {
|
||||
const canRetry = (lastErrorCode: string) =>
|
||||
describeVoiceCapture({ status: "FAILED", lastErrorCode }).canRetry;
|
||||
|
||||
expect(canRetry("EXTRACT_FAILED")).toBe(true);
|
||||
expect(canRetry("EXTRACT_INVALID")).toBe(true);
|
||||
expect(canRetry("NO_SPEECH")).toBe(false);
|
||||
expect(canRetry("AUDIO_EXPIRED")).toBe(false);
|
||||
expect(describeVoiceCapture({ status: "READY", lastErrorCode: null }).canRetry).toBe(false);
|
||||
});
|
||||
|
||||
it("uses fixed sentences for codes, never the server message", () => {
|
||||
expect(voiceErrorText("TOO_LONG")).toBe(VOICE_ERROR_TEXTS_DE.TOO_LONG);
|
||||
expect(voiceErrorText("SOMETHING_NEW")).toContain("nicht verarbeitet");
|
||||
expect(voiceErrorText(null)).toContain("nicht verarbeitet");
|
||||
});
|
||||
|
||||
it("lists open and failed notes and polls only the running ones", () => {
|
||||
expect(isListedVoiceStatus("READY")).toBe(true);
|
||||
expect(isListedVoiceStatus("FAILED")).toBe(true);
|
||||
expect(isListedVoiceStatus("CONFIRMED")).toBe(false);
|
||||
expect(isListedVoiceStatus("DISCARDED")).toBe(false);
|
||||
expect(isListedVoiceStatus("EXPIRED")).toBe(false);
|
||||
expect(isVoiceInProgress("EXTRACTING")).toBe(true);
|
||||
expect(isVoiceInProgress("READY")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { VOICE_RECORDING_DISABLED_TEXT } from "@/lib/offline/queue-texts";
|
||||
import type { VoiceCaptureListItem, VoiceCaptureStatus } from "./api";
|
||||
|
||||
/*
|
||||
* Words for the voice note list. The state always has a word and an icon
|
||||
* name; colour never carries it alone. Error texts are fixed sentences per
|
||||
* server code (mirror of the backend's VOICE_ERROR_TEXTS_DE), never server
|
||||
* text, so nothing from a note's content can appear here.
|
||||
*/
|
||||
|
||||
export type VoiceStatusIcon = "wait" | "work" | "ready" | "failed";
|
||||
|
||||
export interface VoiceStatusText {
|
||||
label: string;
|
||||
icon: VoiceStatusIcon;
|
||||
}
|
||||
|
||||
/** A note that still sits on this device, not yet on the server. */
|
||||
export const VOICE_WAITING_FOR_NETWORK: VoiceStatusText = {
|
||||
label: "Wartet auf Netz",
|
||||
icon: "wait",
|
||||
};
|
||||
|
||||
export const VOICE_STATUS_TEXTS_DE: Readonly<
|
||||
Record<"QUEUED" | "TRANSCRIBING" | "TRANSCRIBED" | "EXTRACTING" | "READY" | "FAILED", VoiceStatusText>
|
||||
> = {
|
||||
QUEUED: { label: "Wird abgeschrieben", icon: "work" },
|
||||
TRANSCRIBING: { label: "Wird abgeschrieben", icon: "work" },
|
||||
TRANSCRIBED: { label: "Wird ausgewertet", icon: "work" },
|
||||
EXTRACTING: { label: "Wird ausgewertet", icon: "work" },
|
||||
READY: { label: "Fertig – prüfen", icon: "ready" },
|
||||
FAILED: { label: "Fehlgeschlagen", icon: "failed" },
|
||||
};
|
||||
|
||||
/** Statuses the list shows; confirmed, discarded and expired notes are done. */
|
||||
const LISTED: readonly VoiceCaptureStatus[] = [
|
||||
"QUEUED",
|
||||
"TRANSCRIBING",
|
||||
"TRANSCRIBED",
|
||||
"EXTRACTING",
|
||||
"READY",
|
||||
"FAILED",
|
||||
];
|
||||
|
||||
/** Statuses a poll should wait out. */
|
||||
const IN_PROGRESS: readonly VoiceCaptureStatus[] = [
|
||||
"QUEUED",
|
||||
"TRANSCRIBING",
|
||||
"TRANSCRIBED",
|
||||
"EXTRACTING",
|
||||
];
|
||||
|
||||
export function isListedVoiceStatus(status: VoiceCaptureStatus): boolean {
|
||||
return LISTED.includes(status);
|
||||
}
|
||||
|
||||
/** With the evaluation switched off a transcribed note is done: nothing more will happen. */
|
||||
export function isVoiceInProgress(status: VoiceCaptureStatus, extractionEnabled = true): boolean {
|
||||
if (status === "TRANSCRIBED" && !extractionEnabled) {
|
||||
return false;
|
||||
}
|
||||
return IN_PROGRESS.includes(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Same sentences as the backend's VOICE_ERROR_TEXTS_DE (a spec compares them).
|
||||
*/
|
||||
export const VOICE_ERROR_TEXTS_DE: Readonly<Record<string, string>> = {
|
||||
UNSUPPORTED_AUDIO: "Das Audioformat wird nicht unterstützt.",
|
||||
TOO_LARGE: "Die Aufnahme ist zu groß. Bitte kürzer aufnehmen.",
|
||||
TOO_MANY_OPEN: "Zu viele offene Sprachnotizen. Erst auswerten oder verwerfen.",
|
||||
NO_SPEECH: "Es war nichts zu verstehen. Bitte noch einmal aufnehmen.",
|
||||
TOO_LONG: "Die Aufnahme ist zu lang. Bitte kürzer aufnehmen.",
|
||||
TRANSCRIBE_FAILED:
|
||||
"Die Aufnahme konnte nicht abgeschrieben werden. Bitte „Nochmal versuchen“ oder noch einmal aufnehmen.",
|
||||
AUDIO_EXPIRED: "Die Aufnahme wurde nach 24 Stunden gelöscht. Bitte noch einmal aufnehmen.",
|
||||
EXTRACT_DISABLED: "Die Auswertung ist ausgeschaltet. Bitte die Durchsicht von Hand ausfüllen.",
|
||||
EXTRACT_RETRY: "Die Auswertung wird noch einmal versucht.",
|
||||
EXTRACT_FAILED:
|
||||
"Die Auswertung hat nicht geklappt. Bitte erneut auswerten oder von Hand ausfüllen.",
|
||||
DAILY_LIMIT: "Das Tageslimit für die Auswertung ist erreicht. Bitte morgen erneut auswerten.",
|
||||
EXTRACT_INVALID:
|
||||
"Die Auswertung war nicht brauchbar. Bitte erneut auswerten oder von Hand ausfüllen.",
|
||||
REFUSED: "Die Auswertung wurde abgelehnt. Bitte die Durchsicht von Hand ausfüllen.",
|
||||
VOICE_CAPTURE_GONE: "Die Sprachnotiz ist nicht mehr vorhanden.",
|
||||
VOICE_RECORDING_DISABLED: VOICE_RECORDING_DISABLED_TEXT,
|
||||
};
|
||||
|
||||
const GENERIC_FAILURE = "Die Sprachnotiz konnte nicht verarbeitet werden. Bitte noch einmal aufnehmen.";
|
||||
|
||||
/** Codes after which a second try of the same note can work. */
|
||||
const RETRYABLE_CODES: readonly string[] = ["EXTRACT_FAILED", "EXTRACT_INVALID", "TRANSCRIBE_FAILED"];
|
||||
|
||||
/** Codes with which the text exists but could not be evaluated: the form still shows it. */
|
||||
const TEXT_ONLY_CODES: readonly string[] = ["EXTRACT_DISABLED", "DAILY_LIMIT"];
|
||||
|
||||
/** Failed evaluations still leave the transcript to read. */
|
||||
const TEXT_KEPT_ON_FAILURE: readonly string[] = ["EXTRACT_FAILED", "EXTRACT_INVALID", "REFUSED"];
|
||||
|
||||
export function voiceErrorText(code: string | null): string {
|
||||
return (code && VOICE_ERROR_TEXTS_DE[code]) || GENERIC_FAILURE;
|
||||
}
|
||||
|
||||
export type VoiceCardAction = "review" | "read-text" | "none";
|
||||
|
||||
export interface VoiceCardState {
|
||||
status: VoiceStatusText;
|
||||
/** One short sentence for a failure or a pause; null when nothing is wrong. */
|
||||
detail: string | null;
|
||||
action: VoiceCardAction;
|
||||
canRetry: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the card of a server-side note says and offers. `extractionEnabled`
|
||||
* false (the operator switched the evaluation off) turns a plain TRANSCRIBED
|
||||
* note into „Text liegt vor“.
|
||||
*/
|
||||
export function describeVoiceCapture(
|
||||
item: Pick<VoiceCaptureListItem, "status" | "lastErrorCode">,
|
||||
extractionEnabled = true,
|
||||
): VoiceCardState {
|
||||
const code = item.lastErrorCode;
|
||||
const textOnly =
|
||||
item.status === "TRANSCRIBED" && ((code && TEXT_ONLY_CODES.includes(code)) || !extractionEnabled);
|
||||
if (textOnly) {
|
||||
return {
|
||||
status: { label: "Text liegt vor", icon: "wait" },
|
||||
detail: code && TEXT_ONLY_CODES.includes(code) ? voiceErrorText(code) : null,
|
||||
action: "read-text",
|
||||
canRetry: false,
|
||||
};
|
||||
}
|
||||
const status = VOICE_STATUS_TEXTS_DE[item.status as keyof typeof VOICE_STATUS_TEXTS_DE];
|
||||
let action: VoiceCardAction = "none";
|
||||
if (item.status === "READY") {
|
||||
action = "review";
|
||||
} else if (item.status === "FAILED" && code !== null && TEXT_KEPT_ON_FAILURE.includes(code)) {
|
||||
action = "read-text";
|
||||
}
|
||||
return {
|
||||
status: status ?? VOICE_STATUS_TEXTS_DE.FAILED,
|
||||
detail: item.status === "FAILED" ? voiceErrorText(code) : null,
|
||||
action,
|
||||
canRetry: item.status === "FAILED" && code !== null && RETRYABLE_CODES.includes(code),
|
||||
};
|
||||
}
|
||||
@@ -96,6 +96,20 @@ describe("uploadPendingVoice", () => {
|
||||
expect(notes.every((n) => n.lastError === undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a 403 VOICE_RECORDING_DISABLED as a definitive refusal on the note", async () => {
|
||||
const note = await pending.queueVoiceNote(input());
|
||||
uploadVoiceCapture.mockRejectedValueOnce(
|
||||
new ApiError(403, "off", { code: "VOICE_RECORDING_DISABLED" }),
|
||||
);
|
||||
|
||||
const result = await upload.uploadPendingVoice("user-1", never);
|
||||
|
||||
expect(result.failed).toBe(1);
|
||||
const [kept] = await pending.listPendingVoice("user-1");
|
||||
expect(kept.id).toBe(note.id);
|
||||
expect(kept.lastError?.status).toBe(403);
|
||||
});
|
||||
|
||||
it("a 409 'still being processed' skips that note and goes on", async () => {
|
||||
await pending.queueVoiceNote(input());
|
||||
await pending.queueVoiceNote(input("user-1", "colony-2"));
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import { fetchVoiceConfig } from "./api";
|
||||
import { useVoiceRecordingSwitch } from "./use-voice-recording-switch";
|
||||
|
||||
vi.mock("./api", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./api")>()),
|
||||
fetchVoiceConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
describe("useVoiceRecordingSwitch", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchVoiceConfig).mockReset();
|
||||
});
|
||||
|
||||
it("is unknown when the server cannot be reached", async () => {
|
||||
vi.mocked(fetchVoiceConfig).mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
const { result } = renderHook(() => useVoiceRecordingSwitch(true), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current).toBe("unknown"));
|
||||
});
|
||||
|
||||
it.each([403, 404, 500])("is off when the server answers %s", async (status) => {
|
||||
vi.mocked(fetchVoiceConfig).mockRejectedValue(new ApiError(status, "no"));
|
||||
const { result } = renderHook(() => useVoiceRecordingSwitch(true), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current).toBe("off"));
|
||||
});
|
||||
|
||||
it("follows recordingEnabled", async () => {
|
||||
vi.mocked(fetchVoiceConfig).mockResolvedValue({
|
||||
recordingEnabled: true,
|
||||
extractionEnabled: true,
|
||||
model: "m",
|
||||
retention: { audioHours: 24, contentDays: 7, rowDays: 90 },
|
||||
});
|
||||
const { result } = renderHook(() => useVoiceRecordingSwitch(true), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current).toBe("on"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { isNetworkError } from "@/lib/http/fetch-with-timeout";
|
||||
import { fetchVoiceConfig, voiceKeys } from "./api";
|
||||
|
||||
/**
|
||||
* Whether the operator allows voice recording:
|
||||
* - "loading": the answer is on its way; entry points wait, so a disabled
|
||||
* feature never flashes a button;
|
||||
* - "on" / "off": the server's answer;
|
||||
* - "unknown": the server could not be asked (offline, no connection). Recording and
|
||||
* the local queue work offline, so entry points stay.
|
||||
*/
|
||||
export type RecordingSwitch = "loading" | "on" | "off" | "unknown";
|
||||
|
||||
const CONFIG_STALE_MS = 5 * 60 * 1000;
|
||||
|
||||
/** The server's voice settings, shared by every entry point (one request, cached). */
|
||||
export function useVoiceConfig(enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: voiceKeys.config,
|
||||
queryFn: fetchVoiceConfig,
|
||||
enabled,
|
||||
staleTime: CONFIG_STALE_MS,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useVoiceRecordingSwitch(enabled: boolean): RecordingSwitch {
|
||||
const query = useVoiceConfig(enabled);
|
||||
if (query.data) {
|
||||
return query.data.recordingEnabled ? "on" : "off";
|
||||
}
|
||||
// Only "could not ask" keeps the entries. A server answer without a config
|
||||
// (403, 404, 5xx: feature missing, forbidden, broken) counts as off.
|
||||
if (query.fetchStatus === "paused" || (query.isError && isNetworkError(query.error))) {
|
||||
return "unknown";
|
||||
}
|
||||
if (query.isError) {
|
||||
return "off";
|
||||
}
|
||||
return "loading";
|
||||
}
|
||||
|
||||
/** Entry points are offered unless the answer is pending or says no. */
|
||||
export function offersRecording(recordingSwitch: RecordingSwitch): boolean {
|
||||
return recordingSwitch === "on" || recordingSwitch === "unknown";
|
||||
}
|
||||
Reference in new issue
Block a user