fix: Sprachnotizen nach Browserprüfung (Wiedergabe, Fokus, Warteschlange)
Backend Image / build-and-push (push) Successful in 6m2s
Frontend Image / build-and-push (push) Failing after 12m25s
Manifests / validate (push) Successful in 16s

- CSP erlaubt media-src 'self' blob:, damit die eigene Aufnahme
  abspielbar ist; Dauer kommt vom Recorder statt aus den Metadaten
- Fokus nach gesperrtem Mikrofon auf der Hilfe, nach Verwerfen in
  /voice auf der Liste
- automatisch beendete Aufnahme läuft über denselben Speicherweg wie
  Senden, kein zweites Speichern; Volk und Datum bleiben bis „Noch eine
  Aufnahme“ gesperrt
- Offline-Band öffnet die Warteschlange auch ohne Ablehnung, wenn
  Sprachnotizen warten; leere Liste offline mit Text
- Abmelde-Dialog und Zurück-Link mit 56 px, Schließen-Knopf der Dialoge
  44 px

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

Refs #27

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-16 12:22:47 +02:00
1 parent e0be687b7c
commit 92323197a7
16 files changed
+314 -119

No files matched your search

@@ -63,6 +63,9 @@ vi.mock("@/components/voice/voice-recorder", () => ({
<button type="button" onClick={() => props.onBusyChange?.(true)}>
Sperren (Test)
</button>
<button type="button" onClick={() => props.onReset?.()}>
Zurücksetzen (Test)
</button>
<button type="button" onClick={() => props.onSaved?.({ kind: "sent" })}>
Aufnahme speichern (Test)
</button>
@@ -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();
@@ -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<StandRound | null>(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 (
<main className="flex flex-col gap-5 p-4 sm:p-6">
<FormBackLink href={target.href} label={target.label} dirty={busy} />
<FormBackLink href={target.href} label={target.label} dirty={busy} className="min-h-14" />
<PageHeader icon={Mic} title="Sprachnotiz" description="Sprich in ganzen Sätzen, nur du." />
<section aria-label="Volk und Datum" className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-3">
@@ -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.
<fieldset
disabled={busy}
disabled={locked}
className="flex min-w-0 flex-col gap-3 rounded-2xl border border-border bg-card p-4"
>
<legend className="sr-only">Volk und Datum ändern</legend>
@@ -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));
}
@@ -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" })]);
+54 -49
View File
@@ -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<HTMLHeadingElement>(null);
// The card with the focus is gone after a discard: the focus goes to the list's heading.
const focusList = () => listHeading.current?.focus();
const retry = useMutation({
mutationFn: (id: string) => retryVoiceCapture(id),
onSuccess: () => void queryClient.invalidateQueries({ queryKey: voiceKeys.all }),
@@ -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 <LoadingState what="Sprachnotizen" />;
}
// No answer because there is no network: not an error, the notes appear with the network.
const serverOffline =
serverQuery.data === undefined &&
(serverQuery.fetchStatus === "paused" || isNetworkError(serverQuery.error));
const serverFailed = serverQuery.data === undefined && !serverQuery.isLoading && !serverOffline;
if (local.length === 0 && listed.length === 0) {
if (serverOffline) {
return <OfflineNote />;
}
return serverFailed ? (
<ErrorState
what="Sprachnotizen"
onRetry={() => void serverQuery.refetch()}
isRetrying={serverQuery.isFetching}
/>
) : (
<EmptyState
icon={Mic}
title="Noch keine Sprachnotizen."
description="Nimm beim Volk eine Sprachnotiz auf. Sie erscheint hier, bis du sie geprüft hast."
/>
);
}
const isLoading = serverQuery.isLoading && local.length === 0;
const isEmpty = local.length === 0 && listed.length === 0;
return (
<div className="flex flex-col gap-3">
{serverOffline ? <OfflineNote /> : null}
{serverFailed ? (
<h2 ref={listHeading} tabIndex={-1} className="sr-only">
Liste der Sprachnotizen
</h2>
{isLoading ? <LoadingState what="Sprachnotizen" /> : null}
{!isLoading && serverOffline ? <OfflineNote /> : null}
{!isLoading && serverFailed ? (
<ErrorState
what="Sprachnotizen vom Server"
what={isEmpty ? "Sprachnotizen" : "Sprachnotizen vom Server"}
onRetry={() => void serverQuery.refetch()}
isRetrying={serverQuery.isFetching}
/>
) : null}
<ul className="flex flex-col gap-3" aria-label="Sprachnotizen">
{local.map((note) => (
<li key={note.id}>
<LocalVoiceCard
note={note}
colonyName={colonyName(note.colonyId)}
online={online}
onDiscard={() => discardLocal.mutate(note.id)}
/>
</li>
))}
{listed.map((item) => (
<li key={item.id}>
<VoiceCaptureCard
item={item}
extractionEnabled={extractionEnabled}
busy={retry.isPending || discard.isPending}
onRetry={() => retry.mutate(item.id)}
onDiscard={() => discard.mutate(item.id)}
/>
</li>
))}
</ul>
{!isLoading && isEmpty && !serverFailed ? (
<EmptyState
icon={Mic}
title="Noch keine Sprachnotizen."
description="Nimm beim Volk eine Sprachnotiz auf. Sie erscheint hier, bis du sie geprüft hast."
/>
) : null}
{isEmpty ? null : (
<ul className="flex flex-col gap-3" aria-label="Sprachnotizen">
{local.map((note) => (
<li key={note.id}>
<LocalVoiceCard
note={note}
colonyName={colonyName(note.colonyId)}
online={online}
onDiscard={() => discardLocal.mutate(note.id)}
/>
</li>
))}
{listed.map((item) => (
<li key={item.id}>
<VoiceCaptureCard
item={item}
extractionEnabled={extractionEnabled}
busy={retry.isPending || discard.isPending}
onRetry={() => retry.mutate(item.id)}
onDiscard={() => discard.mutate(item.id)}
/>
</li>
))}
</ul>
)}
</div>
);
}