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
@@ -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 });
|
||||
|
||||
|
||||
@@ -152,13 +152,20 @@ export function LogoutConfirmDialog({
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="touch"
|
||||
className="min-h-14"
|
||||
disabled={isBusy}
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
{hasEntries && isOnline ? (
|
||||
<Button type="button" size="touch" disabled={isBusy} onClick={() => void sendFirst()}>
|
||||
<Button
|
||||
type="button"
|
||||
size="touch"
|
||||
className="min-h-14"
|
||||
disabled={isBusy}
|
||||
onClick={() => void sendFirst()}
|
||||
>
|
||||
Erst senden
|
||||
</Button>
|
||||
) : null}
|
||||
@@ -166,6 +173,7 @@ export function LogoutConfirmDialog({
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="touch"
|
||||
className="min-h-14"
|
||||
disabled={isBusy}
|
||||
onClick={() => void signOut(hasEntries)}
|
||||
>
|
||||
|
||||
@@ -20,6 +20,12 @@ describe("FormBackLink", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("takes extra classes for a bigger target", () => {
|
||||
render(<FormBackLink href="/voice" label="Zum Volk" dirty={false} className="min-h-14" />);
|
||||
|
||||
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(
|
||||
|
||||
@@ -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) {
|
||||
<Link
|
||||
href={href}
|
||||
onClick={handleClick}
|
||||
className="-ml-2 inline-flex min-h-11 w-fit items-center gap-1.5 rounded-lg px-2 text-sm font-medium text-muted-foreground hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
|
||||
className={cn(
|
||||
"-ml-2 inline-flex min-h-11 w-fit items-center gap-1.5 rounded-lg px-2 text-sm font-medium text-muted-foreground hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<ArrowLeft className="size-4" aria-hidden="true" />
|
||||
{label}
|
||||
|
||||
@@ -143,6 +143,25 @@ describe("OfflineBand", () => {
|
||||
expect(screen.getByRole("status")).toHaveTextContent("davon 1 Sprachnotiz");
|
||||
});
|
||||
|
||||
it("opens the queue from the band when voice notes wait, offline too", async () => {
|
||||
setOnLine(false);
|
||||
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0, voice: 1, voiceWaiting: 1 });
|
||||
|
||||
renderBand();
|
||||
|
||||
expect(await screen.findByRole("button", { name: "Warteschlange ansehen" })).toBeVisible();
|
||||
});
|
||||
|
||||
it("offers no queue button when nothing voice-related waits", async () => {
|
||||
setOnLine(true);
|
||||
vi.mocked(summarizeQueue).mockResolvedValue({ own: 2, failed: 0, foreign: 0 });
|
||||
|
||||
renderBand();
|
||||
|
||||
await screen.findByRole("button", { name: "2 Einträge warten – Jetzt senden" });
|
||||
expect(screen.queryByRole("button", { name: "Warteschlange ansehen" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says „1 Eintrag wartet\" for a single entry", async () => {
|
||||
setOnLine(false);
|
||||
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 });
|
||||
|
||||
@@ -188,6 +188,20 @@ export function OfflineBand({ userId, pageContentRef, syncEnabled = true }: Offl
|
||||
<AlertTriangle aria-hidden />
|
||||
{`${entriesRejected(queue.failed)} – ansehen`}
|
||||
</SheetTrigger>
|
||||
) : queue.voiceWaiting ? (
|
||||
// Waiting voice notes cannot be seen anywhere else but the list: the sheet opens from here too.
|
||||
<SheetTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="touch"
|
||||
className={BAND_BUTTON_CLASS}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Warteschlange ansehen
|
||||
</SheetTrigger>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -65,7 +65,7 @@ function DialogContent({
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-2 right-2"
|
||||
className="absolute top-1 right-1 size-11"
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import { MicOff } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -20,6 +21,8 @@ const STEPS: readonly { browser: string; text: string }[] = [
|
||||
interface MicrophoneHelpProps {
|
||||
reason: "denied" | "no-microphone" | "unsupported";
|
||||
typeInsteadHref: string;
|
||||
/** The user just tried to record: the heading takes the focus. */
|
||||
focusOnShow?: boolean;
|
||||
onRetry?(): void;
|
||||
}
|
||||
|
||||
@@ -30,13 +33,26 @@ const TITLES: Record<MicrophoneHelpProps["reason"], string> = {
|
||||
};
|
||||
|
||||
/** Mikrofon verweigert: what to do per browser, and a way to type instead. */
|
||||
export function MicrophoneHelp({ reason, typeInsteadHref, onRetry }: MicrophoneHelpProps) {
|
||||
export function MicrophoneHelp({
|
||||
reason,
|
||||
typeInsteadHref,
|
||||
focusOnShow = false,
|
||||
onRetry,
|
||||
}: MicrophoneHelpProps) {
|
||||
const heading = useRef<HTMLHeadingElement>(null);
|
||||
useEffect(() => {
|
||||
if (focusOnShow) heading.current?.focus();
|
||||
}, [focusOnShow]);
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="voice-mic-help"
|
||||
className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4"
|
||||
>
|
||||
<h2 id="voice-mic-help" className="flex items-center gap-2 font-heading text-lg font-semibold">
|
||||
<h2
|
||||
id="voice-mic-help"
|
||||
ref={heading}
|
||||
tabIndex={-1}
|
||||
className="flex items-center gap-2 font-heading text-lg font-semibold">
|
||||
<MicOff className="size-5" aria-hidden />
|
||||
{TITLES[reason]}
|
||||
</h2>
|
||||
|
||||
@@ -139,8 +139,7 @@ describe("VoiceRecorder", () => {
|
||||
|
||||
act(() => options.onAutoStop({ ...RECORDING, durationSec: 180 }));
|
||||
|
||||
expect(await screen.findByRole("heading", { name: /Aufnahme fertig · 3:00/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Senden" })).toBeInTheDocument();
|
||||
expect(await screen.findByText(/^Gespeichert/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the denied-state with a way to type instead", async () => {
|
||||
@@ -360,9 +359,11 @@ describe("VoiceRecorder", () => {
|
||||
});
|
||||
|
||||
describe("a recording the limit stopped", () => {
|
||||
it("is on the device at once and shows the saved state", async () => {
|
||||
it("is saved like a press on Senden and then offers no second save", async () => {
|
||||
const { listPendingVoice } = await import("@/lib/voice/pending-voice");
|
||||
const onSaved = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
renderRecorder();
|
||||
renderRecorder({ onSaved });
|
||||
await startAndRecord(user);
|
||||
|
||||
act(() => options.onAutoStop({ ...RECORDING, durationSec: 180 }));
|
||||
@@ -370,15 +371,19 @@ describe("VoiceRecorder", () => {
|
||||
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();
|
||||
expect(syncPendingEntries).toHaveBeenCalledTimes(1);
|
||||
expect(onSaved).toHaveBeenCalledWith({ kind: "queued" });
|
||||
expect(screen.queryByRole("button", { name: /^(Senden|Speichern)$/ })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Noch eine Aufnahme" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Zu den Sprachnotizen" })).toHaveAttribute(
|
||||
"href",
|
||||
"/voice",
|
||||
);
|
||||
});
|
||||
|
||||
it("is only synced, not queued twice, when Senden is pressed", async () => {
|
||||
const { listPendingVoice, discardAllPendingVoice } = await import(
|
||||
"@/lib/voice/pending-voice"
|
||||
);
|
||||
it("says Gesendet when the sync took it at once", async () => {
|
||||
const { discardAllPendingVoice } = await import("@/lib/voice/pending-voice");
|
||||
syncPendingEntries.mockImplementation(async () => {
|
||||
await discardAllPendingVoice("user-1");
|
||||
return { synced: 1, failed: 0, skippedForeign: 0 };
|
||||
@@ -386,30 +391,61 @@ describe("VoiceRecorder", () => {
|
||||
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" }));
|
||||
act(() => options.onAutoStop({ ...RECORDING, durationSec: 180 }));
|
||||
|
||||
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 () => {
|
||||
it("stays offline on the device only and does not sync", async () => {
|
||||
connectivity = "offline";
|
||||
const user = userEvent.setup();
|
||||
renderRecorder();
|
||||
await startAndRecord(user);
|
||||
|
||||
act(() => options.onAutoStop({ ...RECORDING, durationSec: 180 }));
|
||||
|
||||
await screen.findByText(/^Gespeichert/);
|
||||
expect(syncPendingEntries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the note on the device when the user records anew", async () => {
|
||||
const { listPendingVoice } = await import("@/lib/voice/pending-voice");
|
||||
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 user.click(screen.getByRole("button", { name: "Noch eine Aufnahme" }));
|
||||
|
||||
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));
|
||||
expect(await listPendingVoice("user-1")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the recorder's own duration, not the media element's", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderRecorder();
|
||||
await startAndRecord(user);
|
||||
await user.click(screen.getByRole("button", { name: "Aufnahme beenden" }));
|
||||
|
||||
const heading = await screen.findByRole("heading", { name: /Aufnahme fertig · 0:42/ });
|
||||
expect(heading).toBeInTheDocument();
|
||||
const audio = document.querySelector("audio");
|
||||
expect(audio).toHaveAttribute("aria-label", "Aufnahme anhören, Dauer 0:42");
|
||||
});
|
||||
|
||||
describe("when the microphone cannot be used", () => {
|
||||
it("moves focus to the help heading after a refused start", async () => {
|
||||
startRecording.mockRejectedValue(new DOMException("denied", "NotAllowedError"));
|
||||
const user = userEvent.setup();
|
||||
renderRecorder();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Aufnahme starten" }));
|
||||
|
||||
const heading = await screen.findByRole("heading", { name: "Das Mikrofon ist nicht erlaubt." });
|
||||
await waitFor(() => expect(document.activeElement).toBe(heading));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -52,6 +52,8 @@ export interface VoiceRecorderProps {
|
||||
/** 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;
|
||||
/** The user cleared the saved result to record anew. */
|
||||
onReset?(): void;
|
||||
/** Rendered after saving, e.g. "Weiter mit B4". */
|
||||
savedActions?: ReactNode;
|
||||
}
|
||||
@@ -71,6 +73,7 @@ export function VoiceRecorder({
|
||||
blockedReason = null,
|
||||
onBusyChange,
|
||||
onSaved,
|
||||
onReset,
|
||||
savedActions,
|
||||
}: VoiceRecorderProps) {
|
||||
const userId = useCurrentUserId();
|
||||
@@ -87,27 +90,40 @@ export function VoiceRecorder({
|
||||
const resultHeading = useRef<HTMLHeadingElement>(null);
|
||||
const mainButton = useRef<HTMLButtonElement>(null);
|
||||
const focusMainWhenIdle = useRef(false);
|
||||
// The help for a refused microphone takes the focus only after the user tried.
|
||||
const [tried, setTried] = useState(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) {
|
||||
/** Puts the recording on the device and, online, sends it - for Senden and for a limit stop alike. */
|
||||
async function persist(finished: 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),
|
||||
);
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
const result = await saveVoiceNote({
|
||||
userId,
|
||||
recording: finished,
|
||||
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 recorder = useVoiceRecorder({ onAutoStop: queueAtOnce });
|
||||
const recorder = useVoiceRecorder({ onAutoStop: (finished) => void persist(finished) });
|
||||
const { phase, sec, recording } = recorder;
|
||||
|
||||
const unsaved =
|
||||
@@ -145,6 +161,7 @@ export function VoiceRecorder({
|
||||
|
||||
function pressMainButton() {
|
||||
if (phase === "starting") return;
|
||||
setTried(true);
|
||||
if (phase === "recording") {
|
||||
recorder.stop();
|
||||
return;
|
||||
@@ -177,6 +194,7 @@ export function VoiceRecorder({
|
||||
setSaveError(null);
|
||||
setQueuedNoteId(null);
|
||||
recorder.reset();
|
||||
onReset?.();
|
||||
}
|
||||
|
||||
/** The user gives this recording up: a note it already has on the device goes too. */
|
||||
@@ -198,29 +216,8 @@ export function VoiceRecorder({
|
||||
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);
|
||||
}
|
||||
function save() {
|
||||
if (recording && !saving) void persist(recording);
|
||||
}
|
||||
|
||||
const liveRegion = (
|
||||
@@ -236,6 +233,7 @@ export function VoiceRecorder({
|
||||
<MicrophoneHelp
|
||||
reason={phase}
|
||||
typeInsteadHref={typeInsteadHref}
|
||||
focusOnShow={tried}
|
||||
onRetry={phase === "unsupported" ? undefined : recorder.reset}
|
||||
/>
|
||||
</>
|
||||
@@ -320,7 +318,7 @@ export function VoiceRecorder({
|
||||
</p>
|
||||
) : null}
|
||||
{recording.url ? (
|
||||
<audio controls src={recording.url} className="w-full" aria-label="Aufnahme anhören" />
|
||||
<audio controls src={recording.url} className="w-full" aria-label={`Aufnahme anhören, Dauer ${formatClock(recording.durationSec)}`} />
|
||||
) : null}
|
||||
{saveError ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
@@ -332,7 +330,7 @@ export function VoiceRecorder({
|
||||
size="touch"
|
||||
className="min-h-14 text-base"
|
||||
disabled={saving || !userId}
|
||||
onClick={() => void save()}
|
||||
onClick={save}
|
||||
>
|
||||
{saving ? "Wird gespeichert…" : online ? "Senden" : "Speichern"}
|
||||
</Button>
|
||||
|
||||
Reference in new issue
Block a user