fix: Sprachnotizen nach Browserprüfung (Wiedergabe, Fokus, Warteschlange)
- 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:
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" })]);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user