feat: Aufnahmeseite und Liste der Sprachnotizen (T7 L5 Task 19)
/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) <[email protected]>
This commit is contained in:
1 parent
462ac46d1e
commit
e0be687b7c
33 files changed
+3460
-19
No files matched your search
@@ -35,6 +35,11 @@ vi.mock("@/components/autopilot/next-steps-card", () => ({
|
||||
NextStepsCard: ({ colonyId }: { colonyId: string }) => <p>Als Nächstes für {colonyId}</p>,
|
||||
}));
|
||||
vi.mock("@/components/qr-code-dialog", () => ({ QrCodeDialog: () => null }));
|
||||
vi.mock("@/components/voice/voice-entry-link", () => ({
|
||||
VoiceEntryLink: ({ colonyId }: { colonyId: string }) => (
|
||||
<a href={`/voice/new?colonyId=${colonyId}`}>Sprachnotiz aufnehmen</a>
|
||||
),
|
||||
}));
|
||||
|
||||
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();
|
||||
|
||||
@@ -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={<DetailStatus status={colony.status} label={COLONY_STATUS_LABELS[colony.status]} />}
|
||||
primaryAction={
|
||||
<Button
|
||||
size="touch"
|
||||
nativeButton={false}
|
||||
render={<Link href={`/inspections/new?colonyId=${colony.id}`} />}
|
||||
>
|
||||
<ClipboardCheck aria-hidden />
|
||||
Durchsicht erfassen
|
||||
</Button>
|
||||
<>
|
||||
<VoiceEntryLink colonyId={colony.id} />
|
||||
<Button
|
||||
size="touch"
|
||||
nativeButton={false}
|
||||
render={<Link href={`/inspections/new?colonyId=${colony.id}`} />}
|
||||
>
|
||||
<ClipboardCheck aria-hidden />
|
||||
Durchsicht erfassen
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
moreActions={[
|
||||
{
|
||||
|
||||
@@ -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<typeof import("@/lib/voice/api")>()),
|
||||
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();
|
||||
|
||||
|
||||
@@ -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 ? (
|
||||
<p className="w-fit rounded-full bg-muted px-3 py-1 text-sm font-medium">
|
||||
{roundChipText(stand.round, values.colonyId)}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<p className="w-fit rounded-full bg-muted px-3 py-1 text-sm font-medium">
|
||||
{roundChipText(stand.round, values.colonyId)}
|
||||
</p>
|
||||
{values.colonyId ? (
|
||||
<VoiceEntryLink colonyId={values.colonyId} roundApiaryId={stand.round.apiaryId} />
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<PageHeader
|
||||
icon={ClipboardCheck}
|
||||
|
||||
@@ -74,6 +74,11 @@ vi.mock("@/components/offline/offline-band", () => ({
|
||||
),
|
||||
}));
|
||||
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),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<AuthUser, "permissions">): 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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<typeof import("@/lib/voice/api")>()),
|
||||
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(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
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(<QuickAddSheet user={{ permissions: ALL }} variant="fab" />);
|
||||
|
||||
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(
|
||||
<QuickAddSheet
|
||||
user={{ permissions: ALL.filter((p) => 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(
|
||||
<QuickAddSheet
|
||||
user={{ permissions: ALL.filter((p) => 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(<QuickAddSheet user={{ permissions: ALL }} variant="fab" />);
|
||||
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(<QuickAddSheet user={{ permissions: ALL }} variant="fab" />);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Neu" }));
|
||||
|
||||
expect(await screen.findByRole("link", { name: "Sprachnotiz" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides Behandlung without treatments:create", async () => {
|
||||
render(
|
||||
<QuickAddSheet
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { SheetTrigger } from "@/components/ui/sheet";
|
||||
import type { AuthUser } from "@/lib/auth/auth-context";
|
||||
import { NAV_SHEET_ROW_CLASS, NavSheet } from "@/components/nav/nav-sheet";
|
||||
import { offersRecording, useVoiceRecordingSwitch } from "@/lib/voice/use-voice-recording-switch";
|
||||
import { QUICK_ACTIONS } from "./quick-actions";
|
||||
|
||||
// Create and edit forms end in a save bar (StickySubmit) right where the
|
||||
@@ -36,8 +37,13 @@ interface QuickAddSheetProps {
|
||||
export function QuickAddSheet({ user, variant }: QuickAddSheetProps) {
|
||||
const pathname = usePathname();
|
||||
const [open, setOpen] = useState(false);
|
||||
const actions = QUICK_ACTIONS.filter((action) =>
|
||||
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)) {
|
||||
|
||||
@@ -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<string>();
|
||||
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<typeof import("@/lib/voice/api")>()),
|
||||
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 (
|
||||
<div>
|
||||
<button type="button" onClick={() => props.onBusyChange?.(true)}>
|
||||
Sperren (Test)
|
||||
</button>
|
||||
<button type="button" onClick={() => props.onSaved?.({ kind: "sent" })}>
|
||||
Aufnahme speichern (Test)
|
||||
</button>
|
||||
{props.blockedReason ? <p>{props.blockedReason}</p> : null}
|
||||
{props.savedActions}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
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(
|
||||
<QueryClientProvider client={client}>
|
||||
<NewVoiceNotePage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<Button
|
||||
size="touch"
|
||||
className="min-h-14 text-base"
|
||||
nativeButton={false}
|
||||
render={<Link href={`/apiaries/${encodeURIComponent(round.apiaryId)}`} onClick={onEnd} />}
|
||||
>
|
||||
Runde abgeschlossen – zum Stand
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
const params = new URLSearchParams({ colonyId: next.id, round: round.apiaryId });
|
||||
return (
|
||||
<Button
|
||||
size="touch"
|
||||
className="min-h-14 text-base"
|
||||
nativeButton={false}
|
||||
render={<Link href={`/voice/new?${params.toString()}`} />}
|
||||
>
|
||||
Weiter mit {next.code}
|
||||
<ArrowRight aria-hidden />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
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<StandRound | null>(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 (
|
||||
<main className="flex flex-col gap-5 p-4 sm:p-6">
|
||||
<FormBackLink href={target.href} label={target.label} dirty={busy} />
|
||||
<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">
|
||||
<p className="font-heading text-xl font-semibold">{heading}</p>
|
||||
<Button
|
||||
type="button"
|
||||
size="touch"
|
||||
variant="outline"
|
||||
className="min-h-14"
|
||||
disabled={busy}
|
||||
aria-expanded={editing}
|
||||
onClick={() => setEditing((open) => !open)}
|
||||
>
|
||||
Ändern
|
||||
</Button>
|
||||
</div>
|
||||
{editing ? (
|
||||
// Colony and date are fixed while audio is being recorded or waits to be saved.
|
||||
<fieldset
|
||||
disabled={busy}
|
||||
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>
|
||||
<LivingColonySelectField
|
||||
triggerId="voice-colony"
|
||||
value={colonyId}
|
||||
onChange={setColonyId}
|
||||
/>
|
||||
<Field id="voice-date" label="Datum" required>
|
||||
{({ id, describedBy, invalid }) => (
|
||||
<Input
|
||||
id={id}
|
||||
type="date"
|
||||
value={date}
|
||||
min={earliestDay()}
|
||||
max={todayLocalIsoDate()}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid || undefined}
|
||||
onChange={(event) => event.target.value && setDate(event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</fieldset>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<VoiceRecorder
|
||||
colonyId={colonyId}
|
||||
inspectionDate={date}
|
||||
typeInsteadHref={inspectionHref(colonyId, roundApiaryId)}
|
||||
blockedReason={colonyId ? null : NO_COLONY_REASON}
|
||||
onBusyChange={setBusy}
|
||||
onSaved={(outcome) => {
|
||||
if (outcome.kind !== "rejected" && stand.round) {
|
||||
setSavedRound(stand.markDone(colonyId));
|
||||
}
|
||||
}}
|
||||
savedActions={
|
||||
savedRound ? (
|
||||
<RoundNext round={savedRound} next={stand.next(savedRound)} onEnd={stand.end} />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<section aria-labelledby="voice-topics" className="flex flex-col gap-2">
|
||||
<h2 id="voice-topics" className="text-sm font-medium text-muted-foreground">
|
||||
Worüber sprechen?
|
||||
</h2>
|
||||
<ul className="flex flex-wrap gap-2">
|
||||
{TOPICS.map((topic) => (
|
||||
<li key={topic} className="rounded-full bg-muted px-3 py-1 text-sm">
|
||||
{topic}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-sm text-muted-foreground">Das ist eine Gedächtnisstütze, keine Pflicht.</p>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function Notice({ children }: { children: string }) {
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<PageHeader icon={Mic} title="Sprachnotiz" />
|
||||
<p role="alert" className="rounded-xl border border-border bg-card p-4 text-sm">
|
||||
{children}
|
||||
</p>
|
||||
<Button
|
||||
size="touch"
|
||||
className="min-h-14 self-start"
|
||||
nativeButton={false}
|
||||
render={<Link href="/inspections/new" />}
|
||||
>
|
||||
Durchsicht eintippen
|
||||
</Button>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
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 <Notice>Für Sprachnotizen fehlt dir die Berechtigung.</Notice>;
|
||||
}
|
||||
if (recordingSwitch === "off") {
|
||||
return (
|
||||
<Notice>
|
||||
Sprachnotizen sind gerade ausgeschaltet. Du kannst die Durchsicht eintippen.
|
||||
</Notice>
|
||||
);
|
||||
}
|
||||
// A new colony or round remounts the form: a fresh recording, no stale state.
|
||||
return (
|
||||
<VoiceCaptureForm
|
||||
key={`${colonyId}:${round ?? ""}:${date}`}
|
||||
initialColonyId={colonyId}
|
||||
initialDate={date}
|
||||
roundApiaryId={round}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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<string>();
|
||||
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<typeof import("@/lib/voice/pending-voice")>();
|
||||
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<typeof import("@/lib/voice/api")>()),
|
||||
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>): 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(
|
||||
<QueryClientProvider client={client}>
|
||||
<VoiceListPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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<Parameters<typeof queueVoiceNote>[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([]);
|
||||
});
|
||||
});
|
||||
@@ -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) ? (
|
||||
<Button
|
||||
size="touch"
|
||||
className="min-h-14"
|
||||
nativeButton={false}
|
||||
render={<Link href="/voice/new" />}
|
||||
>
|
||||
<Mic aria-hidden />
|
||||
Neue Sprachnotiz
|
||||
</Button>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<PageHeader
|
||||
icon={Mic}
|
||||
title="Sprachnotizen"
|
||||
description="Aufnahmen, die noch warten, und fertige Vorschläge zum Prüfen."
|
||||
actions={newAction}
|
||||
/>
|
||||
{allowed ? (
|
||||
<>
|
||||
{recordingSwitch === "off" ? (
|
||||
<p role="status" className="rounded-xl border border-border bg-card p-4 text-sm">
|
||||
Sprachnotizen sind gerade ausgeschaltet. Neue Aufnahmen sind nicht möglich.
|
||||
Bestehende Notizen bleiben hier sichtbar.
|
||||
</p>
|
||||
) : null}
|
||||
<VoiceNotes />
|
||||
</>
|
||||
) : (
|
||||
<p role="alert" className="rounded-xl border border-border bg-card p-4 text-sm">
|
||||
Für Sprachnotizen fehlt dir die Berechtigung. Bitte wende dich an die Verwaltung.
|
||||
</p>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function OfflineNote() {
|
||||
return (
|
||||
<p role="status" className="rounded-xl border border-border bg-card p-4 text-sm">
|
||||
Du bist offline. Notizen vom Server erscheinen wieder, sobald du Netz hast.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
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 <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."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{serverOffline ? <OfflineNote /> : null}
|
||||
{serverFailed ? (
|
||||
<ErrorState
|
||||
what="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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user