From e0be687b7c5e8326f3d28cfb146a0ddc0fb472db Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Fri, 16 Oct 2026 11:49:56 +0200 Subject: [PATCH] feat: Aufnahmeseite und Liste der Sprachnotizen (T7 L5 Task 19) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /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) --- .../src/voice/voice-error-texts.ts | 2 +- .../src/app/(app)/colonies/[id]/page.spec.tsx | 14 + .../src/app/(app)/colonies/[id]/page.tsx | 20 +- .../app/(app)/inspections/new/page.spec.tsx | 19 + .../src/app/(app)/inspections/new/page.tsx | 12 +- .../src/app/(app)/layout.spec.tsx | 5 + .../src/app/(app)/nav-config.spec.ts | 18 +- ApiCore-Frontend/src/app/(app)/nav-config.ts | 16 +- .../src/app/(app)/quick-actions.ts | 13 + .../src/app/(app)/quick-add-sheet.spec.tsx | 80 ++- .../src/app/(app)/quick-add-sheet.tsx | 10 +- .../src/app/(app)/voice/new/page.spec.tsx | 240 +++++++++ .../src/app/(app)/voice/new/page.tsx | 257 +++++++++ .../src/app/(app)/voice/page.spec.tsx | 347 ++++++++++++ ApiCore-Frontend/src/app/(app)/voice/page.tsx | 199 +++++++ .../src/components/voice/microphone-help.tsx | 73 +++ .../voice/recording-notice-dialog.tsx | 40 ++ .../components/voice/use-voice-recorder.ts | 238 +++++++++ .../components/voice/voice-capture-card.tsx | 237 +++++++++ .../components/voice/voice-confirm-dialog.tsx | 61 +++ .../voice/voice-entry-link.spec.tsx | 84 +++ .../src/components/voice/voice-entry-link.tsx | 42 ++ .../components/voice/voice-recorder.spec.tsx | 496 ++++++++++++++++++ .../src/components/voice/voice-recorder.tsx | 456 ++++++++++++++++ .../src/lib/offline/queue-texts.spec.ts | 11 + .../src/lib/offline/queue-texts.ts | 5 + .../src/lib/voice/save-voice-note.spec.ts | 52 ++ .../src/lib/voice/save-voice-note.ts | 67 +++ .../src/lib/voice/status-texts.spec.ts | 106 ++++ .../src/lib/voice/status-texts.ts | 147 ++++++ .../lib/voice/upload-pending-voice.spec.ts | 14 + .../voice/use-voice-recording-switch.spec.tsx | 49 ++ .../lib/voice/use-voice-recording-switch.ts | 49 ++ 33 files changed, 3460 insertions(+), 19 deletions(-) create mode 100644 ApiCore-Frontend/src/app/(app)/voice/new/page.spec.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/voice/new/page.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/voice/page.spec.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/voice/page.tsx create mode 100644 ApiCore-Frontend/src/components/voice/microphone-help.tsx create mode 100644 ApiCore-Frontend/src/components/voice/recording-notice-dialog.tsx create mode 100644 ApiCore-Frontend/src/components/voice/use-voice-recorder.ts create mode 100644 ApiCore-Frontend/src/components/voice/voice-capture-card.tsx create mode 100644 ApiCore-Frontend/src/components/voice/voice-confirm-dialog.tsx create mode 100644 ApiCore-Frontend/src/components/voice/voice-entry-link.spec.tsx create mode 100644 ApiCore-Frontend/src/components/voice/voice-entry-link.tsx create mode 100644 ApiCore-Frontend/src/components/voice/voice-recorder.spec.tsx create mode 100644 ApiCore-Frontend/src/components/voice/voice-recorder.tsx create mode 100644 ApiCore-Frontend/src/lib/voice/save-voice-note.spec.ts create mode 100644 ApiCore-Frontend/src/lib/voice/save-voice-note.ts create mode 100644 ApiCore-Frontend/src/lib/voice/status-texts.spec.ts create mode 100644 ApiCore-Frontend/src/lib/voice/status-texts.ts create mode 100644 ApiCore-Frontend/src/lib/voice/use-voice-recording-switch.spec.tsx create mode 100644 ApiCore-Frontend/src/lib/voice/use-voice-recording-switch.ts diff --git a/ApiCore-Backend/src/voice/voice-error-texts.ts b/ApiCore-Backend/src/voice/voice-error-texts.ts index e27a069..1d42365 100644 --- a/ApiCore-Backend/src/voice/voice-error-texts.ts +++ b/ApiCore-Backend/src/voice/voice-error-texts.ts @@ -12,7 +12,7 @@ export const VOICE_ERROR_TEXTS_DE: Readonly> = { 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: diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/page.spec.tsx index 2fd3f4f..de7435b 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/page.spec.tsx @@ -35,6 +35,11 @@ vi.mock("@/components/autopilot/next-steps-card", () => ({ NextStepsCard: ({ colonyId }: { colonyId: string }) =>

Als Nächstes für {colonyId}

, })); vi.mock("@/components/qr-code-dialog", () => ({ QrCodeDialog: () => null })); +vi.mock("@/components/voice/voice-entry-link", () => ({ + VoiceEntryLink: ({ colonyId }: { colonyId: string }) => ( + Sprachnotiz aufnehmen + ), +})); 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(); diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/page.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/page.tsx index 8eb282b..99086e3 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/page.tsx @@ -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={} primaryAction={ - + <> + + + } moreActions={[ { diff --git a/ApiCore-Frontend/src/app/(app)/inspections/new/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/inspections/new/page.spec.tsx index a71c763..1aca7db 100644 --- a/ApiCore-Frontend/src/app/(app)/inspections/new/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/inspections/new/page.spec.tsx @@ -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()), + 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(); diff --git a/ApiCore-Frontend/src/app/(app)/inspections/new/page.tsx b/ApiCore-Frontend/src/app/(app)/inspections/new/page.tsx index 1fd76e4..9a79741 100644 --- a/ApiCore-Frontend/src/app/(app)/inspections/new/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/inspections/new/page.tsx @@ -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 ? ( -

- {roundChipText(stand.round, values.colonyId)} -

+
+

+ {roundChipText(stand.round, values.colonyId)} +

+ {values.colonyId ? ( + + ) : null} +
) : null} ({ ), })); vi.mock("@/components/user-avatar", () => ({ UserAvatar: () => null })); +// The quick-add entry for voice notes asks the server; this spec is about the layout. +vi.mock("@/lib/voice/use-voice-recording-switch", () => ({ + useVoiceRecordingSwitch: () => "on", + offersRecording: () => true, +})); vi.mock("@/lib/photos/store", () => ({ purgeStaleDraftPhotos: vi.fn().mockResolvedValue(0), finishHandOvers: vi.fn().mockResolvedValue(0), diff --git a/ApiCore-Frontend/src/app/(app)/nav-config.spec.ts b/ApiCore-Frontend/src/app/(app)/nav-config.spec.ts index f83a66c..ff2a2e2 100644 --- a/ApiCore-Frontend/src/app/(app)/nav-config.spec.ts +++ b/ApiCore-Frontend/src/app/(app)/nav-config.spec.ts @@ -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"); diff --git a/ApiCore-Frontend/src/app/(app)/nav-config.ts b/ApiCore-Frontend/src/app/(app)/nav-config.ts index 2d2d1af..6ddbe31 100644 --- a/ApiCore-Frontend/src/app/(app)/nav-config.ts +++ b/ApiCore-Frontend/src/app/(app)/nav-config.ts @@ -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): 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); } diff --git a/ApiCore-Frontend/src/app/(app)/quick-actions.ts b/ApiCore-Frontend/src/app/(app)/quick-actions.ts index 8a44785..912deb4 100644 --- a/ApiCore-Frontend/src/app/(app)/quick-actions.ts +++ b/ApiCore-Frontend/src/app/(app)/quick-actions.ts @@ -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", diff --git a/ApiCore-Frontend/src/app/(app)/quick-add-sheet.spec.tsx b/ApiCore-Frontend/src/app/(app)/quick-add-sheet.spec.tsx index b1e62ef..73b6c1e 100644 --- a/ApiCore-Frontend/src/app/(app)/quick-add-sheet.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/quick-add-sheet.spec.tsx @@ -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()), + 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({ui}); +} + 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(); + + 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( + 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( + 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(); + 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(); + + await userEvent.click(screen.getByRole("button", { name: "Neu" })); + + expect(await screen.findByRole("link", { name: "Sprachnotiz" })).toBeInTheDocument(); + }); + it("hides Behandlung without treatments:create", async () => { render( - 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)) { diff --git a/ApiCore-Frontend/src/app/(app)/voice/new/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/voice/new/page.spec.tsx new file mode 100644 index 0000000..4639ad1 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/voice/new/page.spec.tsx @@ -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(); +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()), + 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 ( +
+ + + {props.blockedReason ?

{props.blockedReason}

: null} + {props.savedActions} +
+ ); + }, +})); + +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( + + + , + ); +} + +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([]); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/voice/new/page.tsx b/ApiCore-Frontend/src/app/(app)/voice/new/page.tsx new file mode 100644 index 0000000..2308d64 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/voice/new/page.tsx @@ -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 ( + + ); + } + const params = new URLSearchParams({ colonyId: next.id, round: round.apiaryId }); + return ( + + ); +} + +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(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 ( +
+ + +
+
+

{heading}

+ +
+ {editing ? ( + // Colony and date are fixed while audio is being recorded or waits to be saved. +
+ Volk und Datum ändern + + + {({ id, describedBy, invalid }) => ( + event.target.value && setDate(event.target.value)} + /> + )} + +
+ ) : null} +
+ + { + if (outcome.kind !== "rejected" && stand.round) { + setSavedRound(stand.markDone(colonyId)); + } + }} + savedActions={ + savedRound ? ( + + ) : null + } + /> + +
+

+ Worüber sprechen? +

+
    + {TOPICS.map((topic) => ( +
  • + {topic} +
  • + ))} +
+

Das ist eine Gedächtnisstütze, keine Pflicht.

+
+
+ ); +} + +function Notice({ children }: { children: string }) { + return ( +
+ +

+ {children} +

+ +
+ ); +} + +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 Für Sprachnotizen fehlt dir die Berechtigung.; + } + if (recordingSwitch === "off") { + return ( + + Sprachnotizen sind gerade ausgeschaltet. Du kannst die Durchsicht eintippen. + + ); + } + // A new colony or round remounts the form: a fresh recording, no stale state. + return ( + + ); +} diff --git a/ApiCore-Frontend/src/app/(app)/voice/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/voice/page.spec.tsx new file mode 100644 index 0000000..d54ec59 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/voice/page.spec.tsx @@ -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(); +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(); + 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()), + 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 { + 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( + + + , + ); +} + +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[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([]); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/voice/page.tsx b/ApiCore-Frontend/src/app/(app)/voice/page.tsx new file mode 100644 index 0000000..28753d4 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/voice/page.tsx @@ -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) ? ( + + ) : null; + + return ( +
+ + {allowed ? ( + <> + {recordingSwitch === "off" ? ( +

+ Sprachnotizen sind gerade ausgeschaltet. Neue Aufnahmen sind nicht möglich. + Bestehende Notizen bleiben hier sichtbar. +

+ ) : null} + + + ) : ( +

+ Für Sprachnotizen fehlt dir die Berechtigung. Bitte wende dich an die Verwaltung. +

+ )} +
+ ); +} + +function OfflineNote() { + return ( +

+ Du bist offline. Notizen vom Server erscheinen wieder, sobald du Netz hast. +

+ ); +} + +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 ; + } + // 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 ; + } + return serverFailed ? ( + void serverQuery.refetch()} + isRetrying={serverQuery.isFetching} + /> + ) : ( + + ); + } + + return ( +
+ {serverOffline ? : null} + {serverFailed ? ( + void serverQuery.refetch()} + isRetrying={serverQuery.isFetching} + /> + ) : null} +
    + {local.map((note) => ( +
  • + discardLocal.mutate(note.id)} + /> +
  • + ))} + {listed.map((item) => ( +
  • + retry.mutate(item.id)} + onDiscard={() => discard.mutate(item.id)} + /> +
  • + ))} +
+
+ ); +} diff --git a/ApiCore-Frontend/src/components/voice/microphone-help.tsx b/ApiCore-Frontend/src/components/voice/microphone-help.tsx new file mode 100644 index 0000000..cfa9193 --- /dev/null +++ b/ApiCore-Frontend/src/components/voice/microphone-help.tsx @@ -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 = { + 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 ( +
+

+ + {TITLES[reason]} +

+ {reason === "denied" ? ( + <> +

So erlaubst du es wieder:

+
+ {STEPS.map((step) => ( +
+
{step.browser}
+
{step.text}
+
+ ))} +
+ + ) : null} +
+ + {onRetry && reason !== "unsupported" ? ( + + ) : null} +
+
+ ); +} diff --git a/ApiCore-Frontend/src/components/voice/recording-notice-dialog.tsx b/ApiCore-Frontend/src/components/voice/recording-notice-dialog.tsx new file mode 100644 index 0000000..810d9c5 --- /dev/null +++ b/ApiCore-Frontend/src/components/voice/recording-notice-dialog.tsx @@ -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 ( + !next && onCancel()}> + + + Bevor du aufnimmst + {RECORDING_NOTICE} + + + + + + + + ); +} diff --git a/ApiCore-Frontend/src/components/voice/use-voice-recorder.ts b/ApiCore-Frontend/src/components/voice/use-voice-recorder.ts new file mode 100644 index 0000000..6bb3b6a --- /dev/null +++ b/ApiCore-Frontend/src/components/voice/use-voice-recorder.ts @@ -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("idle"); + const [sec, setSec] = useState(0); + const [announcement, setAnnouncement] = useState(""); + const [warning, setWarning] = useState(false); + const [recording, setRecording] = useState(null); + const handle = useRef(null); + const urlRef = useRef(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, + }; +} diff --git a/ApiCore-Frontend/src/components/voice/voice-capture-card.tsx b/ApiCore-Frontend/src/components/voice/voice-capture-card.tsx new file mode 100644 index 0000000..5d26d15 --- /dev/null +++ b/ApiCore-Frontend/src/components/voice/voice-capture-card.tsx @@ -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 = { + 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 ( +

+ + {status.label} +

+ ); +} + +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 ( + <> + + + } + > + Mehr + + + + setConfirmOpen(true)} + > + Verwerfen + + + + 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 ( + + +
+

{title}

+

{time}

+
+ + {detail ?

{detail}

: null} + {children ?
{children}
: null} +
+
+ ); +} + +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 ( + + {note.lastError ? ( + + ) : null} + + + ); +} + +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 ( + + {state.action !== "none" ? ( + + ) : null} + {state.canRetry ? ( + + ) : null} + + + ); +} diff --git a/ApiCore-Frontend/src/components/voice/voice-confirm-dialog.tsx b/ApiCore-Frontend/src/components/voice/voice-confirm-dialog.tsx new file mode 100644 index 0000000..96d5c0e --- /dev/null +++ b/ApiCore-Frontend/src/components/voice/voice-confirm-dialog.tsx @@ -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 ( + !next && onCancel()}> + + + {title} + {description} + + + + + + + + ); +} diff --git a/ApiCore-Frontend/src/components/voice/voice-entry-link.spec.tsx b/ApiCore-Frontend/src/components/voice/voice-entry-link.spec.tsx new file mode 100644 index 0000000..86ca071 --- /dev/null +++ b/ApiCore-Frontend/src/components/voice/voice-entry-link.spec.tsx @@ -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(); +vi.mock("@/lib/auth/auth-context", () => ({ + useAuth: () => ({ hasPermission: (key: string) => permissions.has(key) }), +})); +vi.mock("@/lib/voice/api", async (importOriginal) => ({ + ...(await importOriginal()), + 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( + + + , + ); +} + +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(); + }); +}); diff --git a/ApiCore-Frontend/src/components/voice/voice-entry-link.tsx b/ApiCore-Frontend/src/components/voice/voice-entry-link.tsx new file mode 100644 index 0000000..b7e8a69 --- /dev/null +++ b/ApiCore-Frontend/src/components/voice/voice-entry-link.tsx @@ -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 ( + + ); +} diff --git a/ApiCore-Frontend/src/components/voice/voice-recorder.spec.tsx b/ApiCore-Frontend/src/components/voice/voice-recorder.spec.tsx new file mode 100644 index 0000000..3f8be7c --- /dev/null +++ b/ApiCore-Frontend/src/components/voice/voice-recorder.spec.tsx @@ -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> = {}) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + , + ); +} + +async function startAndRecord(user: ReturnType) { + 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(); + 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) { + 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([]); + }); +}); diff --git a/ApiCore-Frontend/src/components/voice/voice-recorder.tsx b/ApiCore-Frontend/src/components/voice/voice-recorder.tsx new file mode 100644 index 0000000..074530c --- /dev/null +++ b/ApiCore-Frontend/src/components/voice/voice-recorder.tsx @@ -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(null); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + const [outcome, setOutcome] = useState(null); + // The recording's note on the device, once it has one: a limit stop queues at once. + const [queuedNoteId, setQueuedNoteId] = useState(null); + const resultHeading = useRef(null); + const mainButton = useRef(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 = ( +

+ {recorder.announcement} +

+ ); + + if (phase === "denied" || phase === "no-microphone" || phase === "unsupported") { + return ( + <> + {liveRegion} + + + ); + } + + if (outcome) { + const rejected = outcome.kind === "rejected"; + return ( +
+ {liveRegion} +

+ Ergebnis +

+

+ {rejected ? null : ( + + )} + {outcomeText(outcome)} +

+ {outcome.kind === "rejected" ? ( + + ) : ( + <> + {savedActions} + + + + )} +
+ ); + } + + if (phase === "recorded" && recording) { + return ( +
+ {liveRegion} +

+ Aufnahme fertig · {formatClock(recording.durationSec)} +

+ {queuedNoteId ? ( +

+ + {QUEUED_TEXT} +

+ ) : null} + {recording.url ? ( +
+ ); + } + + const recordingNow = phase === "recording"; + const starting = phase === "starting"; + const blocked = Boolean(blockedReason); + return ( +
+ {liveRegion} + {recordingNow ? ( +
+

+ + Aufnahme läuft +

+

+ {formatClock(sec)} + + {" "} + von {formatClock(MAX_RECORDING_SEC)} + +

+ {recorder.warning ?

Noch 30 Sekunden

: null} + {wakeLockMissing ? ( +

Display anlassen

+ ) : null} +
+ ) : null} + + {blockedReason ? ( +

+ {blockedReason} +

+ ) : null} + {phase === "failed" ? ( +

+ {FAILED_TEXT} +

+ ) : null} + {recordingNow ? ( + + ) : null} + setNoticeOpen(false)} + /> + setConfirming(null)} + onConfirm={() => { + setConfirming(null); + focusMainWhenIdle.current = true; + recorder.cancel(); + }} + /> +
+ ); +} diff --git a/ApiCore-Frontend/src/lib/offline/queue-texts.spec.ts b/ApiCore-Frontend/src/lib/offline/queue-texts.spec.ts index 535ed21..9cb6e3e 100644 --- a/ApiCore-Frontend/src/lib/offline/queue-texts.spec.ts +++ b/ApiCore-Frontend/src/lib/offline/queue-texts.spec.ts @@ -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.", diff --git a/ApiCore-Frontend/src/lib/offline/queue-texts.ts b/ApiCore-Frontend/src/lib/offline/queue-texts.ts index f0f7a32..1c60735 100644 --- a/ApiCore-Frontend/src/lib/offline/queue-texts.ts +++ b/ApiCore-Frontend/src/lib/offline/queue-texts.ts @@ -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 = { + 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.", diff --git a/ApiCore-Frontend/src/lib/voice/save-voice-note.spec.ts b/ApiCore-Frontend/src/lib/voice/save-voice-note.spec.ts new file mode 100644 index 0000000..f81aa4d --- /dev/null +++ b/ApiCore-Frontend/src/lib/voice/save-voice-note.spec.ts @@ -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); + }); +}); diff --git a/ApiCore-Frontend/src/lib/voice/save-voice-note.ts b/ApiCore-Frontend/src/lib/voice/save-voice-note.ts new file mode 100644 index 0000000..a07c747 --- /dev/null +++ b/ApiCore-Frontend/src/lib/voice/save-voice-note.ts @@ -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 { + 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" }; +} diff --git a/ApiCore-Frontend/src/lib/voice/status-texts.spec.ts b/ApiCore-Frontend/src/lib/voice/status-texts.spec.ts new file mode 100644 index 0000000..1b5199c --- /dev/null +++ b/ApiCore-Frontend/src/lib/voice/status-texts.spec.ts @@ -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); + }); +}); diff --git a/ApiCore-Frontend/src/lib/voice/status-texts.ts b/ApiCore-Frontend/src/lib/voice/status-texts.ts new file mode 100644 index 0000000..5eb159f --- /dev/null +++ b/ApiCore-Frontend/src/lib/voice/status-texts.ts @@ -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> = { + 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, + 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), + }; +} diff --git a/ApiCore-Frontend/src/lib/voice/upload-pending-voice.spec.ts b/ApiCore-Frontend/src/lib/voice/upload-pending-voice.spec.ts index 74688fc..5a2cfb5 100644 --- a/ApiCore-Frontend/src/lib/voice/upload-pending-voice.spec.ts +++ b/ApiCore-Frontend/src/lib/voice/upload-pending-voice.spec.ts @@ -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")); diff --git a/ApiCore-Frontend/src/lib/voice/use-voice-recording-switch.spec.tsx b/ApiCore-Frontend/src/lib/voice/use-voice-recording-switch.spec.tsx new file mode 100644 index 0000000..7f79b31 --- /dev/null +++ b/ApiCore-Frontend/src/lib/voice/use-voice-recording-switch.spec.tsx @@ -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()), + fetchVoiceConfig: vi.fn(), +})); + +function wrapper({ children }: { children: ReactNode }) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return {children}; +} + +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")); + }); +}); diff --git a/ApiCore-Frontend/src/lib/voice/use-voice-recording-switch.ts b/ApiCore-Frontend/src/lib/voice/use-voice-recording-switch.ts new file mode 100644 index 0000000..99a95eb --- /dev/null +++ b/ApiCore-Frontend/src/lib/voice/use-voice-recording-switch.ts @@ -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"; +}