From 92323197a77793ccfd230e32e8677c0e57a63494 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Fri, 16 Oct 2026 12:22:47 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20Sprachnotizen=20nach=20Browserpr=C3=BCfu?= =?UTF-8?q?ng=20(Wiedergabe,=20Fokus,=20Warteschlange)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CSP erlaubt media-src 'self' blob:, damit die eigene Aufnahme abspielbar ist; Dauer kommt vom Recorder statt aus den Metadaten - Fokus nach gesperrtem Mikrofon auf der Hilfe, nach Verwerfen in /voice auf der Liste - automatisch beendete Aufnahme läuft über denselben Speicherweg wie Senden, kein zweites Speichern; Volk und Datum bleiben bis „Noch eine Aufnahme“ gesperrt - Offline-Band öffnet die Warteschlange auch ohne Ablehnung, wenn Sprachnotizen warten; leere Liste offline mit Text - Abmelde-Dialog und Zurück-Link mit 56 px, Schließen-Knopf der Dialoge 44 px Gates: Frontend lint, typecheck, 2991 Tests, build. Refs #27 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/app/(app)/voice/new/page.spec.tsx | 25 +++++ .../src/app/(app)/voice/new/page.tsx | 11 +- .../src/app/(app)/voice/page.spec.tsx | 27 ++++- ApiCore-Frontend/src/app/(app)/voice/page.tsx | 103 +++++++++--------- .../auth/logout-confirm-dialog.spec.tsx | 14 ++- .../components/auth/logout-confirm-dialog.tsx | 10 +- .../components/form/form-back-link.spec.tsx | 6 + .../src/components/form/form-back-link.tsx | 10 +- .../components/offline/offline-band.spec.tsx | 19 ++++ .../src/components/offline/offline-band.tsx | 14 +++ ApiCore-Frontend/src/components/ui/dialog.tsx | 2 +- .../src/components/voice/microphone-help.tsx | 20 +++- .../components/voice/voice-recorder.spec.tsx | 78 +++++++++---- .../src/components/voice/voice-recorder.tsx | 74 ++++++------- ApiCore-Frontend/src/proxy.spec.ts | 19 ++++ ApiCore-Frontend/src/proxy.ts | 1 + 16 files changed, 314 insertions(+), 119 deletions(-) create mode 100644 ApiCore-Frontend/src/proxy.spec.ts diff --git a/ApiCore-Frontend/src/app/(app)/voice/new/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/voice/new/page.spec.tsx index 4639ad1..41fd797 100644 --- a/ApiCore-Frontend/src/app/(app)/voice/new/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/voice/new/page.spec.tsx @@ -63,6 +63,9 @@ vi.mock("@/components/voice/voice-recorder", () => ({ + @@ -144,6 +147,28 @@ describe("NewVoiceNotePage", () => { expect(screen.getByRole("combobox", { name: /Volk/ })).toBeDisabled(); }); + it("keeps colony and date fixed to the saved note until the user records anew", async () => { + query = new URLSearchParams({ colonyId: "c-1" }); + const user = userEvent.setup(); + renderPage(); + await user.click(await screen.findByRole("button", { name: "Ändern" })); + + await user.click(screen.getByRole("button", { name: "Aufnahme speichern (Test)" })); + expect(screen.getByRole("button", { name: "Ändern" })).toBeDisabled(); + expect(screen.getByLabelText(/Datum/, { selector: "input" })).toBeDisabled(); + + await user.click(screen.getByRole("button", { name: "Zurücksetzen (Test)" })); + expect(screen.getByRole("button", { name: "Ändern" })).toBeEnabled(); + expect(screen.getByLabelText(/Datum/, { selector: "input" })).toBeEnabled(); + }); + + it("has a 56 px back link", async () => { + query = new URLSearchParams({ colonyId: "c-1" }); + renderPage(); + + expect(await screen.findByRole("link", { name: /Zum Volk/ })).toHaveClass("min-h-14"); + }); + it("lists what to talk about", async () => { query = new URLSearchParams({ colonyId: "c-1" }); renderPage(); diff --git a/ApiCore-Frontend/src/app/(app)/voice/new/page.tsx b/ApiCore-Frontend/src/app/(app)/voice/new/page.tsx index 2308d64..8c78977 100644 --- a/ApiCore-Frontend/src/app/(app)/voice/new/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/voice/new/page.tsx @@ -114,6 +114,9 @@ function VoiceCaptureForm({ initialColonyId, initialDate, roundApiaryId }: Captu const [date, setDate] = useState(initialDate); const [editing, setEditing] = useState(initialColonyId === ""); const [busy, setBusy] = useState(false); + // A saved note belongs to its colony and day: changing them needs a new recording. + const [saved, setSaved] = useState(false); + const locked = busy || saved; const [savedRound, setSavedRound] = useState(null); const stand = useStandRound(roundApiaryId, colonyId); const { data: colonies } = useQuery({ queryKey: ["colonies"], queryFn: () => fetchColonies() }); @@ -124,7 +127,7 @@ function VoiceCaptureForm({ initialColonyId, initialDate, roundApiaryId }: Captu return (
- +
@@ -134,7 +137,7 @@ function VoiceCaptureForm({ initialColonyId, initialDate, roundApiaryId }: Captu size="touch" variant="outline" className="min-h-14" - disabled={busy} + disabled={locked} aria-expanded={editing} onClick={() => setEditing((open) => !open)} > @@ -144,7 +147,7 @@ function VoiceCaptureForm({ initialColonyId, initialDate, roundApiaryId }: Captu {editing ? ( // Colony and date are fixed while audio is being recorded or waits to be saved.
Volk und Datum ändern @@ -177,7 +180,9 @@ function VoiceCaptureForm({ initialColonyId, initialDate, roundApiaryId }: Captu typeInsteadHref={inspectionHref(colonyId, roundApiaryId)} blockedReason={colonyId ? null : NO_COLONY_REASON} onBusyChange={setBusy} + onReset={() => setSaved(false)} onSaved={(outcome) => { + setSaved(true); if (outcome.kind !== "rejected" && stand.round) { setSavedRound(stand.markDone(colonyId)); } diff --git a/ApiCore-Frontend/src/app/(app)/voice/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/voice/page.spec.tsx index d54ec59..829ffd7 100644 --- a/ApiCore-Frontend/src/app/(app)/voice/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/voice/page.spec.tsx @@ -299,12 +299,37 @@ describe("VoiceListPage", () => { 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(); + // Nothing on the device either: the empty state stays, with the offline hint. + expect(screen.getByText("Noch keine Sprachnotizen.")).toBeInTheDocument(); } finally { onlineManager.setOnline(true); } }); + it("moves focus to the list heading after the last device note is discarded", async () => { + connectivity = "offline"; + await queueVoiceNote({ + ownerUserId: "user-1", + blob: new Blob(["a"]), + mime: "audio/webm", + durationSec: 20, + colonyId: "c-2", + inspectionDate: "2026-10-12", + }); + const user = userEvent.setup(); + renderPage(); + + await user.click(await screen.findByRole("button", { name: /Mehr zu B4/ })); + await user.click(await screen.findByRole("menuitem", { name: "Verwerfen" })); + await user.click( + within(await screen.findByRole("alertdialog")).getByRole("button", { name: "Verwerfen" }), + ); + + const heading = await screen.findByRole("heading", { name: "Liste der Sprachnotizen" }); + await waitFor(() => expect(document.activeElement).toBe(heading)); + expect(await screen.findByText("Noch keine Sprachnotizen.")).toBeInTheDocument(); + }); + it("reads a transcribed note as Text liegt vor when the evaluation is off", async () => { vi.mocked(fetchVoiceConfig).mockResolvedValue({ ...CONFIG, extractionEnabled: false }); vi.mocked(fetchVoiceCaptures).mockResolvedValue([item({ status: "TRANSCRIBED" })]); diff --git a/ApiCore-Frontend/src/app/(app)/voice/page.tsx b/ApiCore-Frontend/src/app/(app)/voice/page.tsx index 28753d4..a1da353 100644 --- a/ApiCore-Frontend/src/app/(app)/voice/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/voice/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { useRef } from "react"; import Link from "next/link"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Mic } from "lucide-react"; @@ -111,6 +112,10 @@ function VoiceNotes() { }); const coloniesQuery = useQuery({ queryKey: ["colonies"], queryFn: () => fetchColonies() }); + const listHeading = useRef(null); + // The card with the focus is gone after a discard: the focus goes to the list's heading. + const focusList = () => listHeading.current?.focus(); + const retry = useMutation({ mutationFn: (id: string) => retryVoiceCapture(id), onSuccess: () => void queryClient.invalidateQueries({ queryKey: voiceKeys.all }), @@ -118,12 +123,18 @@ function VoiceNotes() { }); const discard = useMutation({ mutationFn: (id: string) => discardVoiceCapture(id), - onSuccess: () => void queryClient.invalidateQueries({ queryKey: voiceKeys.all }), + onSuccess: () => { + focusList(); + void queryClient.invalidateQueries({ queryKey: voiceKeys.all }); + }, onError: () => toast.error("Die Sprachnotiz konnte nicht verworfen werden."), }); const discardLocal = useMutation({ mutationFn: (id: string) => discardPendingVoice(id, userId ?? ""), - onSuccess: () => void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY }), + onSuccess: () => { + focusList(); + void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY }); + }, onError: () => toast.error("Die Sprachnotiz konnte nicht verworfen werden."), }); @@ -134,66 +145,60 @@ function VoiceNotes() { 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} - /> - ) : ( - - ); - } + const isLoading = serverQuery.isLoading && local.length === 0; + const isEmpty = local.length === 0 && listed.length === 0; return (
- {serverOffline ? : null} - {serverFailed ? ( +

+ Liste der Sprachnotizen +

+ {isLoading ? : null} + {!isLoading && serverOffline ? : null} + {!isLoading && 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)} - /> -
  • - ))} -
+ {!isLoading && isEmpty && !serverFailed ? ( + + ) : null} + {isEmpty ? 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/auth/logout-confirm-dialog.spec.tsx b/ApiCore-Frontend/src/components/auth/logout-confirm-dialog.spec.tsx index 6fd5e71..f8ac024 100644 --- a/ApiCore-Frontend/src/components/auth/logout-confirm-dialog.spec.tsx +++ b/ApiCore-Frontend/src/components/auth/logout-confirm-dialog.spec.tsx @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { act, render, screen, waitFor } from "@testing-library/react"; +import { act, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { reportRequestOutcome } from "@/lib/offline/connectivity"; import { summarizeQueue } from "@/lib/offline/db"; @@ -66,6 +66,18 @@ describe("LogoutConfirmDialog", () => { expect(screen.getByRole("button", { name: "Abbrechen" })).toBeVisible(); }); + it("has 56 px buttons and a 44 px close target", async () => { + vi.mocked(summarizeQueue).mockResolvedValue({ own: 2, failed: 0, foreign: 0 }); + + renderDialog(); + + const dialog = await screen.findByRole("dialog", { name: "Abmelden?" }); + for (const name of ["Abbrechen", "Erst senden", "Abmelden und Einträge löschen"]) { + expect(screen.getByRole("button", { name })).toHaveClass("min-h-14"); + } + expect(within(dialog).getByRole("button", { name: "Close" })).toHaveClass("size-11"); + }); + it("asks to discard waiting voice notes and says they cannot be restored", async () => { vi.mocked(summarizeQueue).mockResolvedValue({ own: 3, failed: 0, foreign: 0, voice: 2 }); diff --git a/ApiCore-Frontend/src/components/auth/logout-confirm-dialog.tsx b/ApiCore-Frontend/src/components/auth/logout-confirm-dialog.tsx index 43eaecb..b3e4847 100644 --- a/ApiCore-Frontend/src/components/auth/logout-confirm-dialog.tsx +++ b/ApiCore-Frontend/src/components/auth/logout-confirm-dialog.tsx @@ -152,13 +152,20 @@ export function LogoutConfirmDialog({ type="button" variant="outline" size="touch" + className="min-h-14" disabled={isBusy} onClick={() => onOpenChange(false)} > Abbrechen {hasEntries && isOnline ? ( - ) : null} @@ -166,6 +173,7 @@ export function LogoutConfirmDialog({ type="button" variant="destructive" size="touch" + className="min-h-14" disabled={isBusy} onClick={() => void signOut(hasEntries)} > diff --git a/ApiCore-Frontend/src/components/form/form-back-link.spec.tsx b/ApiCore-Frontend/src/components/form/form-back-link.spec.tsx index 6890f7d..07c99b2 100644 --- a/ApiCore-Frontend/src/components/form/form-back-link.spec.tsx +++ b/ApiCore-Frontend/src/components/form/form-back-link.spec.tsx @@ -20,6 +20,12 @@ describe("FormBackLink", () => { ); }); + it("takes extra classes for a bigger target", () => { + render(); + + expect(screen.getByRole("link", { name: /Zum Volk/ })).toHaveClass("min-h-14"); + }); + it("asks before leaving and flushes the draft", async () => { const flush = vi.fn(); render( diff --git a/ApiCore-Frontend/src/components/form/form-back-link.tsx b/ApiCore-Frontend/src/components/form/form-back-link.tsx index d1c9de4..7d774fa 100644 --- a/ApiCore-Frontend/src/components/form/form-back-link.tsx +++ b/ApiCore-Frontend/src/components/form/form-back-link.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { ArrowLeft } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; import { Dialog, DialogContent, @@ -21,6 +22,8 @@ export interface FormBackLinkProps { dirty: boolean; /** The form's draft; flushed so leaving does not race the autosave delay. */ draft?: { flush: () => void }; + /** Extra classes, e.g. a bigger target on screens used with gloves. */ + className?: string; } const NOT_PLAIN_CLICK = (event: MouseEvent) => @@ -31,7 +34,7 @@ const NOT_PLAIN_CLICK = (event: MouseEvent) => * untouched; once something was typed it asks first, saying honestly whether * the input stays as a draft on this device or is lost. */ -export function FormBackLink({ href, label, dirty, draft }: FormBackLinkProps) { +export function FormBackLink({ href, label, dirty, draft, className }: FormBackLinkProps) { const router = useRouter(); const [open, setOpen] = useState(false); @@ -53,7 +56,10 @@ export function FormBackLink({ href, label, dirty, draft }: FormBackLinkProps) {