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:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-16 11:49:56 +02:00
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");
+15 -1
View File
@@ -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>
);
}