feat: Sprachnotiz in der Durchsicht, Info-Seite und Zähler (T7 L5 Tasks 20, 22)
Backend Image / build-and-push (push) Successful in 5m31s
Frontend Image / build-and-push (push) Failing after 18m45s

Task 20: /inspections/new?voice=<id> zeigt die Sprachnotiz über dem
Formular (Abgeschriebenes, Unklares, „Alle übernehmen“ mit Rückfrage)
und Vorschläge unter den Feldern mit wörtlichem Zitat; nichts wird ohne
Tipp übernommen, die Volkskarte wird nie angehakt. Bei ausgeschalteter
Auswertung ist der Text offen zum Abtippen. Speichern schickt
voiceCaptureId; bei 409 VOICE_CAPTURE_GONE genau ein neuer Versuch ohne
Notiz mit demselben Idempotency-Key (online und aus der Warteschlange);
VOICE_CAPTURE_COLONY_MISMATCH nie automatisch, sondern „Ohne
Sprachnotiz speichern/senden“. Skalen aus lib/inspections/scales.ts mit
Paritätstest zum Backend.

Task 22: /settings/voice mit Modell und Fristen (bei ausgeschalteter
Auswertung ausdrücklich: nichts geht an einen KI-Dienst), „Alle meine
Sprachnotizen löschen“ auf Server und Gerät mit Anzahl; Systemkarte
„Sprachnotizen“ (nur system:manage) mit Zählern und Kosten der letzten
7 Tage (Ruling 15); Service Worker cacht /api/v1/voice-captures nie,
/voice und /voice/new offline erreichbar.

Gates: Frontend lint, typecheck, 3106 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 13:03:09 +02:00
1 parent 51206bea4f
commit ce922c8569
40 files changed
+3347 -45

No files matched your search

@@ -235,6 +235,24 @@ describe("toCreateInspectionInput with hive ops", () => {
});
});
describe("toCreateInspectionInput with a voice note", () => {
const values = { ...initialInspectionValues("c-1"), date: "2026-10-01" };
it("carries voiceCaptureId only when there is a note, for any permission", () => {
expect(toCreateInspectionInput(values, true, [], "cap-1").voiceCaptureId).toBe("cap-1");
expect(toCreateInspectionInput(values, false, [], "cap-1").voiceCaptureId).toBe("cap-1");
expect(toCreateInspectionInput(values, true, [], undefined)).not.toHaveProperty(
"voiceCaptureId",
);
expect(toCreateInspectionInput(values, true)).not.toHaveProperty("voiceCaptureId");
});
it("keeps the note out of the form values and so out of the draft", () => {
expect(initialInspectionValues("c-1")).not.toHaveProperty("voiceCaptureId");
expect(toInspectionInput(values)).not.toHaveProperty("voiceCaptureId");
});
});
describe("feedStatusOptions", () => {
const labels = (stored: Parameters<typeof feedStatusOptions>[0]) =>
feedStatusOptions(stored).map((option) => option.label);
@@ -240,22 +240,28 @@ export function syncTakeoverSelection(
/**
* What a new inspection sends: the correction input plus, when the beekeeper
* may update colonies, the checked Volkskarte fields that have a value and the
* hive ops collected in the „Beute“ section. Both are omitted when empty, so
* nothing is asked of the server that was not wanted.
* hive ops collected in the „Beute“ section, and the voice note the answers
* were reviewed from. All are omitted when empty, so nothing is asked of the
* server that was not wanted. The voice note id is not a form value: it never
* enters a draft.
*/
export function toCreateInspectionInput(
values: InspectionFormValues,
canUpdateColony: boolean,
hiveOps: HiveOp[] = [],
voiceCaptureId?: string,
): CreateInspectionInput {
const input: CreateInspectionInput = toInspectionInput(values);
const base: CreateInspectionInput = {
...toInspectionInput(values),
...(voiceCaptureId ? { voiceCaptureId } : {}),
};
if (!canUpdateColony) {
return input;
return base;
}
const filled = new Set(takeoverFieldsFilled(values));
const applyToColony = values.applyToColony.filter((field) => filled.has(field));
return {
...input,
...base,
...(applyToColony.length > 0 ? { applyToColony } : {}),
...(hiveOps.length > 0 ? { hiveChange: { ops: hiveOps } } : {}),
};
@@ -0,0 +1,565 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor, 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 { toast } from "sonner";
import NewInspectionPage from "./page";
import { ApiError } from "@/lib/auth/api-client";
import { fetchColonies } from "@/lib/colonies/api";
import { createInspection, fetchColonyInspections } from "@/lib/inspections/api";
import { fetchApiaryWeather } from "@/lib/weather/api";
import {
type VoiceCaptureDetail,
type VoiceSuggestion,
discardVoiceCapture,
fetchVoiceCapture,
fetchVoiceConfig,
voiceKeys,
} from "@/lib/voice/api";
import { syncPendingEntries } from "@/lib/offline/sync";
// The voice note in the inspection form (Task 20): suggestions, take-over, save with the note.
const push = vi.fn();
let search = "colonyId=colony-1&voice=cap-1";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push }),
useSearchParams: () => new URLSearchParams(search),
}));
let canUpdateColony = false;
vi.mock("@/lib/auth/auth-context", () => ({
useCurrentUserId: () => "user-1",
useAuth: () => ({ hasPermission: () => canUpdateColony }),
}));
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() }));
vi.mock("@/lib/inspections/api", () => ({
createInspection: vi.fn(),
fetchColonyInspections: vi.fn(),
}));
vi.mock("@/lib/weather/api", () => ({ fetchApiaryWeather: vi.fn() }));
vi.mock("@/lib/voice/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/voice/api")>()),
fetchVoiceCapture: vi.fn(),
discardVoiceCapture: vi.fn(),
fetchVoiceConfig: vi.fn(),
}));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }));
vi.mock("@/components/hive/hive-card", () => ({ HiveCard: () => null }));
vi.mock("@/lib/hive/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/hive/api")>()),
fetchHive: vi.fn().mockRejectedValue(new Error("no hive")),
fetchHiveSystems: vi.fn().mockResolvedValue([]),
}));
vi.mock("@/lib/offline/sync", () => ({ syncPendingEntries: vi.fn() }));
vi.mock("@/lib/photos/store", () => ({
PHOTOS_PER_INSPECTION_MAX: 5,
listSessionPhotos: vi.fn().mockResolvedValue([]),
addDraftPhoto: vi.fn(),
removePhoto: vi.fn(),
handOverSessionPhotos: vi.fn().mockResolvedValue(0),
queueInspectionWithPhotos: vi.fn().mockResolvedValue(0),
moveDraftPhotos: vi.fn(),
discardSessionPhotos: vi.fn().mockResolvedValue(undefined),
}));
function suggestion(
field: string,
value: VoiceSuggestion["value"],
quote: string,
extra: Partial<VoiceSuggestion> = {},
): VoiceSuggestion {
return { field, value, quote, corrected: false, truncated: false, ...extra };
}
function note(extra: Partial<VoiceCaptureDetail> = {}): VoiceCaptureDetail {
return {
id: "cap-1",
colonyId: "colony-1",
colonyName: "V-01",
status: "READY",
source: "AUDIO",
createdAt: "2026-10-12T08:00:00.000Z",
lastErrorCode: null,
contentExpiresAt: "2026-10-19T08:00:00.000Z",
inspectionDate: "2026-10-12",
transcript: "Königin hab ich gesehen, sehr sanft, Futter niedrig.",
suggestions: [
suggestion("queenSeen", true, "Königin hab ich gesehen"),
suggestion("gentleness", 5, "sehr sanft"),
suggestion("feedStatus", "WEAK", "Futter niedrig"),
],
unclear: [],
dropped: 0,
...extra,
};
}
let client: QueryClient;
function renderPage() {
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<NewInspectionPage />
</QueryClientProvider>,
);
}
const radio = (group: string | RegExp, name: string) =>
within(screen.getByRole("radiogroup", { name: group })).getByRole("radio", { name });
/** The row of one field: found by the name its group gets. */
const row = (fieldLabel: string) =>
screen.getByRole("group", { name: `Vorschlag aus Sprache: ${fieldLabel}` });
async function loaded() {
await screen.findByRole("heading", { name: /^Sprachnotiz vom/ });
}
const SAVE = { name: "Durchsicht speichern" };
describe("inspection form with a voice note", () => {
beforeEach(() => {
window.localStorage.clear();
window.sessionStorage.clear();
search = "colonyId=colony-1&voice=cap-1";
canUpdateColony = false;
push.mockReset();
vi.mocked(fetchColonies).mockResolvedValue([
{ id: "colony-1", code: "V-01", status: "ACTIVE", apiaryId: "apiary-1" },
] as never);
vi.mocked(createInspection).mockReset().mockResolvedValue({ id: "i-1", colonyId: "colony-1" } as never);
vi.mocked(fetchColonyInspections).mockResolvedValue([]);
vi.mocked(fetchApiaryWeather).mockResolvedValue({ daily: [] });
vi.mocked(fetchVoiceCapture).mockReset().mockResolvedValue(note());
vi.mocked(discardVoiceCapture).mockReset().mockResolvedValue(undefined);
vi.mocked(fetchVoiceConfig).mockResolvedValue({
recordingEnabled: true,
extractionEnabled: true,
model: "m",
retention: { audioHours: 24, contentDays: 7, rowDays: 90 },
});
vi.mocked(syncPendingEntries).mockReset().mockResolvedValue({ synced: 0, failed: 0, skippedForeign: 0 });
vi.mocked(toast.info).mockReset();
vi.mocked(toast.success).mockReset();
});
it("takes colony and date from the loaded note and fills no field", async () => {
renderPage();
await loaded();
expect(screen.getByLabelText(/^Datum/)).toHaveValue("2026-10-12");
expect(fetchVoiceCapture).toHaveBeenCalledWith("cap-1");
});
it("leaves the field empty until Übernehmen", async () => {
renderPage();
await loaded();
expect(radio("Königin gesehen", "Nicht geprüft")).toHaveAttribute("aria-checked", "true");
expect(radio(/^Sanftmut/, "5 von 5")).toHaveAttribute("aria-checked", "false");
expect(within(row("Königin gesehen")).getByText("Königin gesehen: Ja")).toBeInTheDocument();
expect(within(row("Königin gesehen")).getByText(/„Königin hab ich gesehen“/)).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Sprachnotiz vom 12.10., 3 Vorschläge" })).toBeInTheDocument();
});
it("Übernehmen sets the value and moves no focus", async () => {
const user = userEvent.setup();
renderPage();
await loaded();
const take = within(row("Königin gesehen")).getByRole("button", { name: /^Übernehmen/ });
take.focus();
await user.keyboard("{Enter}");
expect(radio("Königin gesehen", "Ja")).toHaveAttribute("aria-checked", "true");
expect(within(row("Königin gesehen")).getByRole("status")).toHaveTextContent("Übernommen");
expect(within(row("Königin gesehen")).getByRole("button", { name: /^Rückgängig/ })).toHaveFocus();
});
it("Rückgängig puts the earlier answer back", async () => {
const user = userEvent.setup();
renderPage();
await loaded();
await user.click(within(row("Königin gesehen")).getByRole("button", { name: /^Übernehmen/ }));
await user.click(within(row("Königin gesehen")).getByRole("button", { name: /^Rückgängig/ }));
expect(radio("Königin gesehen", "Nicht geprüft")).toHaveAttribute("aria-checked", "true");
expect(within(row("Königin gesehen")).getByRole("button", { name: /^Übernehmen/ })).toBeInTheDocument();
});
it("Ändern focuses the field", async () => {
const user = userEvent.setup();
renderPage();
await loaded();
await user.click(within(row("Sanftmut")).getByRole("button", { name: /^Ändern/ }));
await waitFor(() => expect(radio(/^Sanftmut/, "5 von 5")).toHaveFocus());
expect(radio(/^Sanftmut/, "5 von 5")).toHaveAttribute("aria-checked", "true");
});
it("a value changed by hand is the beekeeper's own again", async () => {
const user = userEvent.setup();
renderPage();
await loaded();
await user.click(within(row("Sanftmut")).getByRole("button", { name: /^Übernehmen/ }));
await user.click(radio(/^Sanftmut/, "3 von 5"));
expect(screen.queryByRole("group", { name: "Vorschlag aus Sprache: Sanftmut" })).not.toBeInTheDocument();
});
it("Ignorieren puts a suggestion aside and sends nothing for it", async () => {
const user = userEvent.setup();
renderPage();
await loaded();
await user.click(within(row("Sanftmut")).getByRole("button", { name: /^Ignorieren/ }));
await user.click(screen.getByRole("button", SAVE));
expect(within(row("Sanftmut")).getByRole("status")).toHaveTextContent("Ignoriert");
const [input] = vi.mocked(createInspection).mock.calls[0];
expect(input.gentleness).toBeNull();
});
it("Alle übernehmen asks first and names the count", async () => {
const user = userEvent.setup();
renderPage();
await loaded();
await user.click(screen.getByRole("button", { name: "Alle übernehmen" }));
const dialog = screen.getByRole("alertdialog");
expect(within(dialog).getByText("3 Felder aus der Sprachnotiz übernehmen?")).toBeInTheDocument();
// The dialog hides the form from assistive technology; nothing changed behind it.
expect(
within(screen.getByRole("radiogroup", { name: "Königin gesehen", hidden: true })).getByRole(
"radio",
{ name: "Nicht geprüft", hidden: true },
),
).toHaveAttribute("aria-checked", "true");
await user.click(within(dialog).getByRole("button", { name: "Übernehmen" }));
expect(radio("Königin gesehen", "Ja")).toHaveAttribute("aria-checked", "true");
expect(radio(/^Sanftmut/, "5 von 5")).toHaveAttribute("aria-checked", "true");
expect(radio(/^Futterstatus/, "Niedrig")).toHaveAttribute("aria-checked", "true");
expect(screen.queryByRole("button", { name: "Alle übernehmen" })).not.toBeInTheDocument();
});
it("takes a feed level the three buttons do not offer", async () => {
vi.mocked(fetchVoiceCapture).mockResolvedValue(
note({ suggestions: [suggestion("feedStatus", "EXCELLENT", "Futter reichlich")] }),
);
const user = userEvent.setup();
renderPage();
await loaded();
await user.click(within(row("Futter")).getByRole("button", { name: /^Übernehmen/ }));
expect(radio(/^Futterstatus/, "Reichlich")).toHaveAttribute("aria-checked", "true");
});
it("works with the keyboard only", async () => {
const user = userEvent.setup();
renderPage();
await loaded();
const take = within(row("Königin gesehen")).getByRole("button", { name: /^Übernehmen/ });
for (let tabs = 0; tabs < 40 && document.activeElement !== take; tabs += 1) {
await user.tab();
}
expect(take).toHaveFocus();
await user.keyboard("{Enter}");
expect(radio("Königin gesehen", "Ja")).toHaveAttribute("aria-checked", "true");
});
it("both hints stand one below the other and neither fills the field", async () => {
vi.mocked(fetchColonyInspections).mockResolvedValue([
{
id: "last",
colonyId: "colony-1",
userId: "u",
date: "2026-10-01T00:00:00.000Z",
queenSeen: false,
} as never,
]);
renderPage();
await loaded();
const previous = await screen.findByText(/Letztes Mal: Nein/);
const voice = row("Königin gesehen");
expect(previous.compareDocumentPosition(voice) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(radio("Königin gesehen", "Nicht geprüft")).toHaveAttribute("aria-checked", "true");
});
it("adds free text to the field and opens the folded section for it", async () => {
vi.mocked(fetchVoiceCapture).mockResolvedValue(
note({ suggestions: [suggestion("measures", "Honigraum aufgesetzt", "Honigraum drauf")] }),
);
const user = userEvent.setup();
renderPage();
await loaded();
expect(screen.getByRole("button", { name: /Weitere Angaben/ })).toHaveAttribute("aria-expanded", "true");
await user.click(within(row("Maßnahmen")).getByRole("button", { name: /^Übernehmen/ }));
expect(screen.getByLabelText(/^Maßnahmen/)).toHaveValue("Honigraum aufgesetzt");
});
it("saves with voiceCaptureId and shows no suggestion afterwards", async () => {
const user = userEvent.setup();
renderPage();
await loaded();
await user.click(within(row("Königin gesehen")).getByRole("button", { name: /^Übernehmen/ }));
await user.click(screen.getByRole("button", SAVE));
await waitFor(() => expect(push).toHaveBeenCalledWith("/colonies/colony-1"));
const [input] = vi.mocked(createInspection).mock.calls[0];
expect(input).toMatchObject({ voiceCaptureId: "cap-1", queenSeen: true, gentleness: null });
expect(client.getQueryState(voiceKeys.detail("cap-1"))?.isInvalidated).toBe(true);
});
it("tells the beekeeper when the note was gone and the inspection was saved without it", async () => {
vi.mocked(createInspection).mockResolvedValue({
id: "i-1",
colonyId: "colony-1",
voiceCaptureGone: true,
} as never);
const user = userEvent.setup();
renderPage();
await loaded();
await user.click(screen.getByRole("button", SAVE));
await waitFor(() =>
expect(toast.info).toHaveBeenCalledWith(
"Sprachnotiz war abgelaufen, Durchsicht ohne sie gespeichert",
),
);
});
it("a note of another colony offers to save without it and does not retry by itself", async () => {
vi.mocked(createInspection).mockRejectedValueOnce(
new ApiError(409, "Die Sprachnotiz gehört zu einem anderen Volk.", {
code: "VOICE_CAPTURE_COLONY_MISMATCH",
}),
);
const user = userEvent.setup();
renderPage();
await loaded();
await user.click(screen.getByRole("button", SAVE));
expect(await screen.findByRole("alert")).toHaveTextContent(/anderen Volk/);
expect(createInspection).toHaveBeenCalledTimes(1);
await user.click(screen.getByRole("button", { name: "Ohne Sprachnotiz speichern" }));
await waitFor(() => expect(createInspection).toHaveBeenCalledTimes(2));
expect(vi.mocked(createInspection).mock.calls[1][0]).not.toHaveProperty("voiceCaptureId");
});
it("Sprachnotiz verwerfen discards the note and saves without it", async () => {
const user = userEvent.setup();
renderPage();
await loaded();
await user.click(screen.getByRole("button", { name: "Sprachnotiz verwerfen" }));
await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Verwerfen" }));
await waitFor(() => expect(discardVoiceCapture).toHaveBeenCalledWith("cap-1"));
await waitFor(() =>
expect(screen.queryByRole("heading", { name: /^Sprachnotiz vom/ })).not.toBeInTheDocument(),
);
await user.click(screen.getByRole("button", SAVE));
expect(vi.mocked(createInspection).mock.calls[0][0]).not.toHaveProperty("voiceCaptureId");
});
it("a transcribed note without evaluation shows the text and still ties the note to the save", async () => {
vi.mocked(fetchVoiceCapture).mockResolvedValue(
note({ status: "TRANSCRIBED", lastErrorCode: "EXTRACT_DISABLED", suggestions: null }),
);
const user = userEvent.setup();
renderPage();
await loaded();
expect(screen.getByText("Auswertung gerade nicht möglich")).toBeInTheDocument();
expect(screen.getByText(/Königin hab ich gesehen, sehr sanft/)).toBeVisible();
expect(screen.queryByRole("group", { name: /^Vorschlag aus Sprache/ })).not.toBeInTheDocument();
await user.click(radio("Königin gesehen", "Ja"));
await user.click(screen.getByRole("button", SAVE));
expect(vi.mocked(createInspection).mock.calls[0][0]).toMatchObject({
voiceCaptureId: "cap-1",
queenSeen: true,
});
});
it("a failed note with text is read the same way", async () => {
vi.mocked(fetchVoiceCapture).mockResolvedValue(
note({ status: "FAILED", lastErrorCode: "EXTRACT_FAILED", suggestions: null }),
);
renderPage();
await loaded();
expect(screen.getByText(/Königin hab ich gesehen, sehr sanft/)).toBeVisible();
});
it("does not send a note that is gone", async () => {
vi.mocked(fetchVoiceCapture).mockResolvedValue(
note({ status: "CONFIRMED", suggestions: null, transcript: null }),
);
const user = userEvent.setup();
renderPage();
expect(await screen.findByText(/Die Sprachnotiz gibt es nicht mehr/)).toBeInTheDocument();
await user.click(screen.getByRole("button", SAVE));
expect(vi.mocked(createInspection).mock.calls[0][0]).not.toHaveProperty("voiceCaptureId");
});
it("does not send a note that cannot be found", async () => {
vi.mocked(fetchVoiceCapture).mockRejectedValue(new ApiError(404, "nicht gefunden"));
const user = userEvent.setup();
renderPage();
expect(await screen.findByText(/Die Sprachnotiz gibt es nicht mehr/)).toBeInTheDocument();
await user.click(screen.getByRole("button", SAVE));
expect(vi.mocked(createInspection).mock.calls[0][0]).not.toHaveProperty("voiceCaptureId");
});
it("keeps working when the note cannot be loaded, and still sends its id", async () => {
vi.mocked(fetchVoiceCapture).mockRejectedValue(new Error("offline"));
const user = userEvent.setup();
renderPage();
expect(await screen.findByText(/Die Sprachnotiz konnte nicht geladen werden/)).toBeInTheDocument();
await user.click(screen.getByRole("button", SAVE));
expect(vi.mocked(createInspection).mock.calls[0][0]).toMatchObject({ voiceCaptureId: "cap-1" });
});
it("ignores a suggestion for a field the form does not have", async () => {
vi.mocked(fetchVoiceCapture).mockResolvedValue(
note({ suggestions: [suggestion("weather", "sonnig", "sonnig"), suggestion("gentleness", 99, "x")] }),
);
renderPage();
await loaded();
expect(screen.queryByRole("group", { name: /^Vorschlag aus Sprache/ })).not.toBeInTheDocument();
});
it("follows the colony of the note when the address names another", async () => {
vi.mocked(fetchColonies).mockResolvedValue([
{ id: "colony-1", code: "V-01", status: "ACTIVE", apiaryId: "apiary-1" },
{ id: "colony-2", code: "V-02", status: "ACTIVE", apiaryId: "apiary-1" },
] as never);
search = "colonyId=colony-2&voice=cap-1";
const user = userEvent.setup();
renderPage();
// The note says colony-1: the form follows it.
await loaded();
expect(screen.queryByText(/gehört zum Volk/)).not.toBeInTheDocument();
await user.click(screen.getByRole("button", SAVE));
expect(vi.mocked(createInspection).mock.calls[0][0]).toMatchObject({
colonyId: "colony-1",
voiceCaptureId: "cap-1",
});
});
it("neither Übernehmen nor Alle übernehmen ticks the Volkskarte", async () => {
canUpdateColony = true;
vi.mocked(fetchVoiceCapture).mockResolvedValue(
note({
suggestions: [
suggestion("colonyStrength", 8, "acht Waben"),
suggestion("feedStatus", "WEAK", "Futter niedrig"),
suggestion("broodPattern", 4, "Brutbild gut"),
],
}),
);
const user = userEvent.setup();
renderPage();
await loaded();
await user.click(within(row("Volksstärke")).getByRole("button", { name: /^Übernehmen/ }));
await user.click(screen.getByRole("button", { name: "Alle übernehmen" }));
await user.click(
within(screen.getByRole("alertdialog")).getByRole("button", { name: "Übernehmen" }),
);
await user.click(screen.getByRole("button", SAVE));
const input = vi.mocked(createInspection).mock.calls[0][0];
expect(input).toMatchObject({ colonyStrength: 8, feedStatus: "WEAK", broodPattern: 4 });
expect(input).not.toHaveProperty("applyToColony");
});
it("text-only when the evaluation is off: no waiting sentence, the text is open", async () => {
vi.mocked(fetchVoiceConfig).mockResolvedValue({
recordingEnabled: true,
extractionEnabled: false,
model: "m",
retention: { audioHours: 24, contentDays: 7, rowDays: 90 },
});
vi.mocked(fetchVoiceCapture).mockResolvedValue(
note({ status: "TRANSCRIBED", suggestions: null }),
);
renderPage();
await loaded();
expect(await screen.findByText("Auswertung gerade nicht möglich")).toBeInTheDocument();
expect(screen.queryByText("Wird noch ausgewertet")).not.toBeInTheDocument();
expect(screen.getByText(/Königin hab ich gesehen, sehr sanft/)).toBeVisible();
});
it("focuses the box after Alle übernehmen and the first field after discarding", async () => {
const user = userEvent.setup();
renderPage();
await loaded();
await user.click(screen.getByRole("button", { name: "Alle übernehmen" }));
await user.click(
within(screen.getByRole("alertdialog")).getByRole("button", { name: "Übernehmen" }),
);
await waitFor(() =>
expect(screen.getByRole("region", { name: /^Sprachnotiz vom/ })).toHaveFocus(),
);
await user.click(screen.getByRole("button", { name: "Sprachnotiz verwerfen" }));
await user.click(
within(screen.getByRole("alertdialog")).getByRole("button", { name: "Verwerfen" }),
);
await waitFor(() => expect(document.getElementById("inspection-colony")).toHaveFocus());
});
it("takes neither colony nor day from a note that is gone", async () => {
vi.mocked(fetchVoiceCapture).mockResolvedValue(
note({
status: "CONFIRMED",
suggestions: null,
transcript: null,
inspectionDate: "2020-01-02",
}),
);
renderPage();
expect(await screen.findByText(/Die Sprachnotiz gibt es nicht mehr/)).toBeInTheDocument();
expect(screen.getByLabelText(/^Datum/)).not.toHaveValue("2020-01-02");
});
it("axe finds nothing", async () => {
const user = userEvent.setup();
const { container } = renderPage();
await loaded();
const run = () => axe.run(container, { rules: { "color-contrast": { enabled: false } } });
expect((await run()).violations).toEqual([]);
await user.click(within(row("Königin gesehen")).getByRole("button", { name: /^Übernehmen/ }));
expect((await run()).violations).toEqual([]);
});
});
@@ -21,6 +21,8 @@ import {
import { type LastInspection, useLastInspection } from "@/lib/inspections/use-last-inspection";
import type { ColonyTakeoverField, CreateInspectionInput } from "@/lib/inspections/types";
import { hiveKeys } from "@/lib/hive/api";
import { voiceKeys } from "@/lib/voice/api";
import { INSPECTION_SCALE_DIRECTIONS, INSPECTION_SCALE_LABELS } from "@/lib/inspections/scales";
import type { HiveOp } from "@/lib/hive/types";
import { useAuth, useCurrentUserId } from "@/lib/auth/auth-context";
import { MAX_OCCUPIED_FRAMES } from "@/lib/colonies/labels";
@@ -62,11 +64,11 @@ import { StickySubmit } from "@/components/form/sticky-submit";
import { DraftBanner } from "@/components/form/draft-banner";
import { PHOTO_SESSION_QUERY_KEY, PhotoCaptureField } from "@/components/photos/photo-capture-field";
import {
FEED_STATUS_OPTIONS,
INSPECTION_OBSERVATIONS,
InspectionFormValues,
currentDraftValues,
feedLegacyHint,
feedStatusOptions,
initialInspectionValues,
syncTakeoverSelection,
toCreateInspectionInput,
@@ -74,6 +76,8 @@ import {
import { broodHintFor, broodHintLabel, hiveErrorText } from "./hive-change";
import { suggestAfterInspection } from "./hive-save-follow-ups";
import { InspectionHiveSection, useInspectionHive } from "./hive-section";
import { isGoneVoiceStatus, isVoiceColonyMismatch, useVoiceReview } from "./use-voice-review";
import { VoiceFieldRow, VoiceReviewBox } from "./voice-review";
/** Free-text fields that take dictation. */
type DictatedField = "notes" | "healthIssues" | "varroaIssues" | "measures";
@@ -82,6 +86,10 @@ const UNDO_TOAST_MS = 8000;
const WEATHER_UPDATED = "Wetter aktualisiert";
const WEATHER_MISSING = "Keine Wetterdaten für diesen Tag";
const PHOTOS_LATER = "Fotos werden später gesendet.";
const VOICE_GONE_NOTICE = "Sprachnotiz war abgelaufen, Durchsicht ohne sie gespeichert";
/** The free-text fields in „Weitere Angaben“, which is folded until a suggestion needs it. */
const EXTRA_VOICE_FIELDS = ["honeySuperStatus", "varroaIssues", "healthIssues", "measures", "notes"];
const TAKEOVER_LABELS: Record<TakeoverField, string> = {
queenSeen: "Königin gesehen",
@@ -186,6 +194,8 @@ function LastInspectionBar({ last, onTakeOverAll }: LastInspectionBarProps) {
interface InspectionCaptureFormProps {
initialColonyId: string;
/** The voice note (`?voice=`) this inspection is reviewed from, if any. */
voiceId: string | null;
/** Apiary of the stand round this form was opened from, if any. */
roundApiaryId: string | null;
backTarget: { href: string; label: string };
@@ -204,6 +214,7 @@ function roundChipText(round: StandRound, colonyId: string): string {
function InspectionCaptureForm({
initialColonyId,
voiceId,
roundApiaryId,
backTarget,
}: InspectionCaptureFormProps) {
@@ -236,6 +247,39 @@ function InspectionCaptureForm({
const [submittedHiveOps, setSubmittedHiveOps] = useState("");
const { base: hiveBase, catalog: hiveCatalog } = useInspectionHive(values.colonyId);
const broodFromHive = broodHintFor(hiveBase, hiveOps, hiveCatalog);
// A tap on „Übernehmen“ writes like any tap; observations are never ticked for the Volkskarte.
const voice = useVoiceReview({
voiceId,
initialColonyId,
values,
apply: (patch) => {
update((current) => ({ ...current, ...patch }), { autoTick: false });
setDirty(true);
},
});
const noteToApply = voice.capture;
const [appliedNoteId, setAppliedNoteId] = useState<string | null>(null);
const [extraOpenedForVoice, setExtraOpenedForVoice] = useState(false);
if (!extraOpenedForVoice && EXTRA_VOICE_FIELDS.some((field) => voice.rowFor(field) !== null)) {
setExtraOpenedForVoice(true);
}
// Colony and day come from the note, once, as long as the beekeeper has not started on the form.
// A note that is gone says nothing about this inspection.
if (
noteToApply &&
appliedNoteId !== noteToApply.id &&
!dirty &&
!isGoneVoiceStatus(noteToApply.status)
) {
setAppliedNoteId(noteToApply.id);
const day = noteToApply.inspectionDate.slice(0, 10);
update((current) => ({ ...current, colonyId: noteToApply.colonyId, date: day }), {
autoTick: false,
});
setHiveOps([]);
setHiveCardGeneration((generation) => generation + 1);
}
/**
* Every change of the answers also keeps the Volkskarte selection in step.
@@ -377,6 +421,11 @@ function InspectionCaptureForm({
queryKey: ["colonies", outcome.result.colonyId, "inspections"],
});
toast.success("Durchsicht gespeichert");
if (input.voiceCaptureId) {
// The server confirmed the note and deleted its content: lists must not show it as open.
void queryClient.invalidateQueries({ queryKey: voiceKeys.all, refetchType: "none" });
}
if (outcome.result.voiceCaptureGone) toast.info(VOICE_GONE_NOTICE);
if (input.hiveChange) {
void queryClient.invalidateQueries({ queryKey: hiveKeys.detail(input.colonyId) });
if (hiveBase && hiveCatalog) {
@@ -420,9 +469,19 @@ function InspectionCaptureForm({
document.getElementById("inspection-date")?.focus();
return;
}
submitInspection(voice.voiceCaptureId);
}
function submitInspection(voiceCaptureId: string | undefined) {
submittedPhotoSession.current = values.photoSessionId;
setSubmittedHiveOps(JSON.stringify(hiveOps));
capture.submit(toCreateInspectionInput(values, canUpdateColony, hiveOps));
capture.submit(toCreateInspectionInput(values, canUpdateColony, hiveOps, voiceCaptureId));
}
/** „Ohne Sprachnotiz speichern“: after the server refused the note, the same entries go without it. */
function saveWithoutVoice() {
voice.leaveOut();
submitInspection(undefined);
}
/**
@@ -587,6 +646,9 @@ function InspectionCaptureForm({
onDiscard={discardDraft}
/>
) : null}
<div className="max-w-lg">
<VoiceReviewBox review={voice} />
</div>
<Card className="max-w-lg">
<CardContent>
<form
@@ -625,13 +687,15 @@ function InspectionCaptureForm({
>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{INSPECTION_OBSERVATIONS.map((observation) => (
<TriStateField
key={observation.key}
label={observation.label}
value={values[observation.key]}
onChange={(answer) => set(observation.key, answer)}
previous={previousFor(observation.key)}
/>
<div key={observation.key} className="flex flex-col gap-1.5">
<TriStateField
label={observation.label}
value={values[observation.key]}
onChange={(answer) => set(observation.key, answer)}
previous={previousFor(observation.key)}
/>
<VoiceFieldRow review={voice} field={observation.key} />
</div>
))}
</div>
{noQueenSigns ? (
@@ -653,10 +717,11 @@ function InspectionCaptureForm({
describedBy={describedBy}
value={values.broodPattern}
onChange={(rating) => set("broodPattern", rating)}
lowLabel="lückenhaft"
highLabel="geschlossen"
{...INSPECTION_SCALE_LABELS.broodPattern}
direction={INSPECTION_SCALE_DIRECTIONS.broodPattern}
/>
{hintFor("broodPattern")}
<VoiceFieldRow review={voice} field="broodPattern" />
</>
)}
</Field>
@@ -673,6 +738,7 @@ function InspectionCaptureForm({
describedBy={describedBy}
/>
{hintFor("broodFrames")}
<VoiceFieldRow review={voice} field="broodFrames" />
{broodFromHive !== null &&
values.broodFrames !== String(broodFromHive) &&
!(broodFromHive === 0 && values.broodFrames === "") ? (
@@ -727,6 +793,7 @@ function InspectionCaptureForm({
describedBy={describedBy}
/>
{hintFor("colonyStrength")}
<VoiceFieldRow review={voice} field="colonyStrength" />
</>
)}
</Field>
@@ -738,10 +805,11 @@ function InspectionCaptureForm({
labelledBy={labelId}
value={values.gentleness}
onChange={(rating) => set("gentleness", rating)}
lowLabel="stechlustig"
highLabel="sehr sanft"
{...INSPECTION_SCALE_LABELS.gentleness}
direction={INSPECTION_SCALE_DIRECTIONS.gentleness}
/>
{hintFor("gentleness")}
<VoiceFieldRow review={voice} field="gentleness" />
</>
)}
</Field>
@@ -753,10 +821,11 @@ function InspectionCaptureForm({
labelledBy={labelId}
value={values.combBehavior}
onChange={(rating) => set("combBehavior", rating)}
lowLabel="läuft weg"
highLabel="sitzt ruhig"
{...INSPECTION_SCALE_LABELS.combBehavior}
direction={INSPECTION_SCALE_DIRECTIONS.combBehavior}
/>
{hintFor("combBehavior")}
<VoiceFieldRow review={voice} field="combBehavior" />
</>
)}
</Field>
@@ -768,11 +837,11 @@ function InspectionCaptureForm({
labelledBy={labelId}
value={values.swarmMood}
onChange={(rating) => set("swarmMood", rating)}
direction="lower-is-better"
lowLabel="ruhig"
highLabel="stark"
{...INSPECTION_SCALE_LABELS.swarmMood}
direction={INSPECTION_SCALE_DIRECTIONS.swarmMood}
/>
{hintFor("swarmMood")}
<VoiceFieldRow review={voice} field="swarmMood" />
</>
)}
</Field>
@@ -785,7 +854,7 @@ function InspectionCaptureForm({
<ChoiceGroup
id={id}
labelledBy={labelId}
options={FEED_STATUS_OPTIONS}
options={feedStatusOptions(values.feedStatus)}
value={values.feedStatus}
onChange={(feedStatus) => set("feedStatus", feedStatus)}
className="grid-cols-2 sm:grid-cols-4"
@@ -797,6 +866,7 @@ function InspectionCaptureForm({
</p>
) : null}
{hintFor("feedStatus")}
<VoiceFieldRow review={voice} field="feedStatus" />
</>
)}
</Field>
@@ -846,6 +916,7 @@ function InspectionCaptureForm({
collapsible
defaultOpen={false}
fieldCount={5}
forceOpen={extraOpenedForVoice}
>
<Field label="Honigraum">
{({ id }) => (
@@ -859,11 +930,13 @@ function InspectionCaptureForm({
}
/>
{hintFor("honeySuperStatus")}
<VoiceFieldRow review={voice} field="honeySuperStatus" />
</>
)}
</Field>
<Field label="Varroa-Befund">
{({ id }) => (
<>
<DictationRow
fieldLabel="Varroa-Befund"
onAppend={(text) => appendDictated("varroaIssues", text)}
@@ -877,10 +950,13 @@ function InspectionCaptureForm({
}
/>
</DictationRow>
<VoiceFieldRow review={voice} field="varroaIssues" />
</>
)}
</Field>
<Field label="Gesundheitsauffälligkeiten">
{({ id }) => (
<>
<DictationRow
fieldLabel="Gesundheitsauffälligkeiten"
onAppend={(text) => appendDictated("healthIssues", text)}
@@ -894,10 +970,13 @@ function InspectionCaptureForm({
}
/>
</DictationRow>
<VoiceFieldRow review={voice} field="healthIssues" />
</>
)}
</Field>
<Field label="Maßnahmen">
{({ id }) => (
<>
<DictationRow
fieldLabel="Maßnahmen"
onAppend={(text) => appendDictated("measures", text)}
@@ -908,10 +987,13 @@ function InspectionCaptureForm({
onChange={(event) => set("measures", event.target.value)}
/>
</DictationRow>
<VoiceFieldRow review={voice} field="measures" />
</>
)}
</Field>
<Field label="Notizen">
{({ id }) => (
<>
<DictationRow
fieldLabel="Notizen"
onAppend={(text) => appendDictated("notes", text)}
@@ -922,6 +1004,8 @@ function InspectionCaptureForm({
onChange={(event) => set("notes", event.target.value)}
/>
</DictationRow>
<VoiceFieldRow review={voice} field="notes" />
</>
)}
</Field>
</FormSection>
@@ -952,12 +1036,25 @@ function InspectionCaptureForm({
<StickySubmit
note={
capture.submitError && hiveErrorText(capture.error) === null ? (
<p
role="alert"
className="text-sm font-medium text-destructive"
>
{capture.submitError}
</p>
<div className="flex flex-col items-start gap-2">
<p
role="alert"
className="text-sm font-medium text-destructive"
>
{capture.submitError}
</p>
{isVoiceColonyMismatch(capture.error) ? (
<Button
type="button"
variant="outline"
className="min-h-14"
disabled={capture.isPending}
onClick={saveWithoutVoice}
>
Ohne Sprachnotiz speichern
</Button>
) : null}
</div>
) : null
}
>
@@ -986,12 +1083,14 @@ export default function NewInspectionPage() {
);
const colonyId = searchParams.get("colonyId") ?? "";
const round = searchParams.get("round");
const voiceId = searchParams.get("voice");
// A new colony or round remounts the form: fresh entry, fresh idempotency key.
// A new colony, round or voice note remounts the form: fresh entry, fresh idempotency key.
return (
<InspectionCaptureForm
key={`${colonyId}:${round ?? ""}`}
key={`${colonyId}:${round ?? ""}:${voiceId ?? ""}`}
initialColonyId={colonyId}
voiceId={voiceId}
roundApiaryId={round}
backTarget={backTarget}
/>
@@ -0,0 +1,98 @@
import type { ReactNode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderHook } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fetchVoiceCapture, fetchVoiceConfig, type VoiceCaptureDetail } from "@/lib/voice/api";
import { initialInspectionValues } from "./inspection-form-values";
import { useVoiceReview } from "./use-voice-review";
vi.mock("@/lib/voice/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/voice/api")>()),
fetchVoiceCapture: vi.fn(),
fetchVoiceConfig: vi.fn(),
discardVoiceCapture: vi.fn(),
}));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
function note(status: VoiceCaptureDetail["status"]): VoiceCaptureDetail {
return {
id: "cap-1",
colonyId: "colony-1",
colonyName: "V-01",
status,
source: "AUDIO",
createdAt: "2026-10-12T08:00:00.000Z",
lastErrorCode: null,
contentExpiresAt: "2026-10-19T08:00:00.000Z",
inspectionDate: "2026-10-12",
transcript: "Text",
suggestions: null,
unclear: [],
dropped: 0,
};
}
const values = initialInspectionValues("colony-1");
function mount() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);
return renderHook(
() => useVoiceReview({ voiceId: "cap-1", initialColonyId: "colony-1", values, apply: vi.fn() }),
{ wrapper },
);
}
const FIVE_SECONDS = 5000;
const config = (extractionEnabled: boolean) =>
({ recordingEnabled: true, extractionEnabled, model: "m", retention: {} }) as never;
describe("useVoiceReview polling", () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.mocked(fetchVoiceConfig).mockResolvedValue(config(true));
});
afterEach(() => {
vi.useRealTimers();
vi.mocked(fetchVoiceCapture).mockReset();
});
it("polling stops once the note is not in progress", async () => {
vi.mocked(fetchVoiceCapture).mockResolvedValue(note("EXTRACTING"));
mount();
await vi.advanceTimersByTimeAsync(FIVE_SECONDS * 2 + 100);
expect(vi.mocked(fetchVoiceCapture).mock.calls.length).toBeGreaterThanOrEqual(2);
vi.mocked(fetchVoiceCapture).mockResolvedValue(note("READY"));
await vi.advanceTimersByTimeAsync(FIVE_SECONDS + 100);
const afterReady = vi.mocked(fetchVoiceCapture).mock.calls.length;
await vi.advanceTimersByTimeAsync(FIVE_SECONDS * 4);
expect(vi.mocked(fetchVoiceCapture).mock.calls.length).toBe(afterReady);
});
it("does not poll a transcribed note while the evaluation is switched off", async () => {
vi.mocked(fetchVoiceConfig).mockResolvedValue(config(false));
vi.mocked(fetchVoiceCapture).mockResolvedValue(note("TRANSCRIBED"));
mount();
await vi.advanceTimersByTimeAsync(500);
const first = vi.mocked(fetchVoiceCapture).mock.calls.length;
await vi.advanceTimersByTimeAsync(FIVE_SECONDS * 4);
expect(vi.mocked(fetchVoiceCapture).mock.calls.length).toBe(first);
});
it("gives up after ten minutes", async () => {
vi.mocked(fetchVoiceCapture).mockResolvedValue(note("EXTRACTING"));
mount();
await vi.advanceTimersByTimeAsync(10 * 60 * 1000 + FIVE_SECONDS);
const atCap = vi.mocked(fetchVoiceCapture).mock.calls.length;
await vi.advanceTimersByTimeAsync(FIVE_SECONDS * 6);
expect(vi.mocked(fetchVoiceCapture).mock.calls.length).toBe(atCap);
});
});
@@ -0,0 +1,260 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { ApiError } from "@/lib/auth/api-client";
import {
type VoiceCaptureDetail,
type VoiceSuggestion,
discardVoiceCapture,
fetchVoiceCapture,
fetchVoiceConfig,
voiceKeys,
} from "@/lib/voice/api";
import { isVoiceInProgress } from "@/lib/voice/status-texts";
import {
describeFieldValue,
describeSuggestionValue,
isVoiceField,
openSuggestions,
suggestionPatch,
VOICE_FIELD_LABELS,
} from "@/lib/voice/suggestions";
import type { InspectionFormValues } from "./inspection-form-values";
const POLL_MS = 5000;
/** A note still unfinished after this long is not waited for any more; the page can be reopened. */
const POLL_GIVE_UP_MS = 10 * 60 * 1000;
const NOT_FOUND = 404;
const GONE_STATUSES: readonly string[] = ["CONFIRMED", "DISCARDED", "EXPIRED"];
/** Confirmed, discarded or expired: nothing of it is left to use. */
export function isGoneVoiceStatus(status: string): boolean {
return GONE_STATUSES.includes(status);
}
type Settled = { kind: "taken"; before: unknown; after: unknown } | { kind: "ignored" };
/** What the row under one field needs. */
export interface VoiceFieldRowModel {
fieldLabel: string;
valueText: string;
quote: string;
chips: string[];
status: "open" | "taken" | "ignored";
onTakeOver: () => void;
onEdit: () => void;
onUndo: () => void;
onIgnore: () => void;
onRestore: () => void;
}
/** "Ohne Sprachnotiz speichern" is offered after this refusal. */
export function isVoiceColonyMismatch(error: unknown): boolean {
return (
error instanceof ApiError &&
error.status === 409 &&
(error.body as { code?: unknown } | null)?.code === "VOICE_CAPTURE_COLONY_MISMATCH"
);
}
export interface UseVoiceReviewOptions {
/** `?voice=<id>`; null when the form was not opened from a voice note. */
voiceId: string | null;
/** The colony in the address: the best guess for the note's colony until it has loaded. */
initialColonyId: string;
values: InspectionFormValues;
/** Writes into the form like a tap does (marks it changed, never ticks the Volkskarte). */
apply: (patch: Partial<InspectionFormValues>) => void;
}
function chipsOf(suggestion: VoiceSuggestion): string[] {
return [
...(suggestion.corrected ? ["Korrigiert"] : []),
...(suggestion.truncated ? ["Gekürzt"] : []),
...(suggestion.previous
? [`Zuvor genannt: ${describeFieldValue(suggestion.field, suggestion.previous.value)}`]
: []),
];
}
/** The control of a field, found from its suggestion row: the row sits inside the field's block. */
function focusFieldOf(field: string): void {
const row = document.querySelector(`[data-voice-row="${field}"]`);
const block = row?.parentElement;
if (!block) return;
const target = [...block.querySelectorAll<HTMLElement>(
'input:not([disabled]), textarea:not([disabled]), [role="radio"][tabindex="0"]',
)].find((element) => !row.contains(element));
target?.focus();
}
/**
* The voice note behind `?voice=<id>`, as the inspection form uses it: the
* loaded note, one row per suggestion with its state, and the box-wide
* choices. Nothing is applied by itself: every value reaches the form through
* a tap (`apply`). The id goes to the server only while the note is still
* open and belongs to the form's colony.
*/
export function useVoiceReview({ voiceId, initialColonyId, values, apply }: UseVoiceReviewOptions) {
const queryClient = useQueryClient();
const [settled, setSettled] = useState<ReadonlyMap<string, Settled>>(new Map());
const [leftOut, setLeftOut] = useState(false);
// Until the switch is known the evaluation counts as on: the box says "wird ausgewertet" at worst.
const config = useQuery({
queryKey: voiceKeys.config,
queryFn: fetchVoiceConfig,
enabled: voiceId !== null && !leftOut,
retry: false,
});
const extractionEnabled = config.data?.extractionEnabled ?? true;
const [startedAt] = useState(() => Date.now());
const query = useQuery({
queryKey: voiceKeys.detail(voiceId ?? ""),
queryFn: () => fetchVoiceCapture(voiceId ?? ""),
enabled: voiceId !== null && !leftOut,
retry: false,
refetchInterval: (state) =>
state.state.data &&
isVoiceInProgress(state.state.data.status, extractionEnabled) &&
Date.now() - startedAt < POLL_GIVE_UP_MS
? POLL_MS
: false,
});
const capture: VoiceCaptureDetail | undefined = leftOut ? undefined : query.data;
const notFound = query.error instanceof ApiError && query.error.status === NOT_FOUND;
const isGone = capture ? isGoneVoiceStatus(capture.status) : notFound;
const noteColonyId = capture?.colonyId ?? initialColonyId;
const sameColony = values.colonyId === noteColonyId;
const suggestions = capture?.status === "READY" && sameColony ? (capture.suggestions ?? []) : [];
const settledFields = new Set(settled.keys());
const open = openSuggestions(suggestions, values, settledFields);
const valueOf = (field: string): unknown =>
(values as unknown as Record<string, unknown>)[field];
function settle(field: string, entry: Settled) {
setSettled((current) => new Map(current).set(field, entry));
}
function unsettle(field: string) {
setSettled((current) => {
const next = new Map(current);
next.delete(field);
return next;
});
}
function takeOver(suggestion: VoiceSuggestion): Partial<InspectionFormValues> {
const patch = suggestionPatch(suggestion, values);
const field = suggestion.field;
if (!(field in patch)) return {};
apply(patch);
settle(field, {
kind: "taken",
before: valueOf(field),
after: (patch as Record<string, unknown>)[field],
});
return patch;
}
function takeOverAll() {
const merged = open.reduce<Partial<InspectionFormValues>>(
(all, suggestion) => ({ ...all, ...suggestionPatch(suggestion, values) }),
{},
);
apply(merged);
setSettled((current) => {
const next = new Map(current);
for (const suggestion of open) {
next.set(suggestion.field, {
kind: "taken",
before: valueOf(suggestion.field),
after: (merged as Record<string, unknown>)[suggestion.field],
});
}
return next;
});
}
function undo(field: string, entry: Settled) {
if (entry.kind === "taken" && valueOf(field) === entry.after) {
apply({ [field]: entry.before } as Partial<InspectionFormValues>);
}
unsettle(field);
}
function rowFor(field: string): VoiceFieldRowModel | null {
if (!isVoiceField(field)) return null;
const suggestion = suggestions.find((item) => item.field === field);
if (!suggestion) return null;
const entry = settled.get(field);
let status: VoiceFieldRowModel["status"] | null = null;
if (entry?.kind === "taken") {
// Edited by hand since: the value is the beekeeper's own now, no mark, no row.
status = valueOf(field) === entry.after ? "taken" : null;
} else if (entry?.kind === "ignored") {
status = "ignored";
} else if (open.includes(suggestion)) {
status = "open";
}
if (status === null) return null;
return {
fieldLabel: VOICE_FIELD_LABELS[field],
valueText: describeSuggestionValue(suggestion),
quote: suggestion.quote,
chips: chipsOf(suggestion),
status,
onTakeOver: () => {
takeOver(suggestion);
},
onEdit: () => {
if (entry?.kind !== "taken") takeOver(suggestion);
// After the commit: a roving-focus group only then knows which button is the chosen one.
window.setTimeout(() => focusFieldOf(field), 0);
},
onUndo: () => entry && undo(field, entry),
onIgnore: () => settle(field, { kind: "ignored" }),
onRestore: () => unsettle(field),
};
}
const discard = useMutation({
mutationFn: () => discardVoiceCapture(voiceId ?? ""),
onSuccess: () => {
setLeftOut(true);
toast.success("Sprachnotiz verworfen");
// The box and its buttons are gone: the focus goes to the first field of the form.
window.setTimeout(() => document.getElementById("inspection-colony")?.focus(), 0);
void queryClient.invalidateQueries({ queryKey: voiceKeys.all });
},
onError: () => toast.error("Sprachnotiz konnte nicht verworfen werden."),
});
const sendsId = voiceId !== null && !leftOut && !isGone && sameColony;
return {
voiceId,
capture,
extractionEnabled,
isLoading: voiceId !== null && !leftOut && query.isPending,
loadFailed: voiceId !== null && !leftOut && query.isError && !notFound,
isGone: voiceId !== null && !leftOut && isGone,
sameColony,
/** The id to send with the inspection, if the note should be tied to it. */
voiceCaptureId: sendsId ? voiceId : undefined,
openCount: open.length,
rowFor,
takeOverAll,
discard: () => discard.mutate(),
discarding: discard.isPending,
/** „Ohne Sprachnotiz speichern“: the note stays, but this save no longer carries it. */
leaveOut: () => setLeftOut(true),
};
}
export type VoiceReview = ReturnType<typeof useVoiceReview>;
@@ -0,0 +1,79 @@
"use client";
import { VoiceCaptureBox } from "@/components/voice/voice-capture-box";
import { VoiceSuggestion } from "@/components/voice/voice-suggestion";
import type { VoiceReview } from "./use-voice-review";
const NOTE_CLASS = "rounded-xl bg-muted/50 p-3 text-sm";
/**
* Above the form: the voice note behind `?voice=`. While it loads, when it
* cannot be read, when it is gone or belongs to another colony, one sentence
* says so - the form works as without a note in every one of these cases.
*/
export function VoiceReviewBox({ review }: { review: VoiceReview }) {
if (review.voiceId === null) return null;
if (review.isLoading) {
return (
<p role="status" className={NOTE_CLASS}>
Sprachnotiz wird geladen…
</p>
);
}
if (review.loadFailed) {
return (
<p role="status" className={NOTE_CLASS}>
Die Sprachnotiz konnte nicht geladen werden. Du kannst die Durchsicht trotzdem eintippen.
</p>
);
}
if (review.isGone) {
return (
<p role="status" className={NOTE_CLASS}>
Die Sprachnotiz gibt es nicht mehr. Du kannst die Durchsicht trotzdem speichern.
</p>
);
}
const { capture } = review;
if (!capture) return null;
if (!review.sameColony) {
return (
<p role="status" className={NOTE_CLASS}>
Die Sprachnotiz gehört zum Volk {capture.colonyName}. Für dieses Volk wird sie nicht
verwendet.
</p>
);
}
return (
<VoiceCaptureBox
capture={capture}
openCount={review.openCount}
onTakeOverAll={review.takeOverAll}
onDiscard={review.discard}
discarding={review.discarding}
extractionEnabled={review.extractionEnabled}
/>
);
}
/** The suggestion for one field, under the field and under its „Letztes Mal“ hint; nothing when there is none. */
export function VoiceFieldRow({ review, field }: { review: VoiceReview; field: string }) {
const row = review.rowFor(field);
if (!row) return null;
return (
<div data-voice-row={field}>
<VoiceSuggestion
fieldLabel={row.fieldLabel}
valueText={row.valueText}
quote={row.quote}
chips={row.chips}
status={row.status}
onTakeOver={row.onTakeOver}
onEdit={row.onEdit}
onUndo={row.onUndo}
onIgnore={row.onIgnore}
onRestore={row.onRestore}
/>
</div>
);
}
@@ -14,6 +14,7 @@ import {
} from "@/lib/system/api";
import { JOB_NAMES, type SystemOverview } from "@/lib/system/types";
import { fetchNotificationDeliveries } from "@/lib/telegram/api";
import { fetchVoiceConfig, fetchVoiceStats } from "@/lib/voice/api";
vi.mock("@/lib/system/api", () => ({
fetchSystemOverview: vi.fn(),
@@ -27,6 +28,11 @@ vi.mock("@/lib/telegram/api", () => ({
retryNotificationDelivery: vi.fn(),
retryFailedNotificationDeliveries: vi.fn(),
}));
vi.mock("@/lib/voice/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/voice/api")>()),
fetchVoiceStats: vi.fn(),
fetchVoiceConfig: vi.fn(),
}));
let permissions: string[] = [];
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({ user: { permissions } }),
@@ -99,6 +105,18 @@ describe("SystemPage", () => {
vi.mocked(fetchFailedWebhookDeliveries).mockResolvedValue([]);
vi.mocked(retryDomainEvent).mockResolvedValue(undefined);
vi.mocked(retryWebhookDelivery).mockResolvedValue(undefined);
vi.mocked(fetchVoiceStats).mockResolvedValue({
byStatus: { READY: 1 },
byError: {},
tokens: { input: 0, output: 0 },
byModel: {},
});
vi.mocked(fetchVoiceConfig).mockResolvedValue({
recordingEnabled: true,
extractionEnabled: true,
model: "claude-sonnet-5-5",
retention: { audioHours: 24, contentDays: 7, rowDays: 90 },
});
vi.mocked(fetchNotificationDeliveries).mockResolvedValue({
telegram: {
botNameSet: true,
@@ -126,6 +144,20 @@ describe("SystemPage", () => {
expect(fetchNotificationDeliveries).not.toHaveBeenCalled();
});
it("shows the voice card only with system:manage (the stats endpoint needs it)", async () => {
renderPage();
expect(await screen.findByText("Sprachnotizen")).toBeInTheDocument();
});
it("does not show or load the voice card with system:read only", async () => {
permissions = ["system:read"];
renderPage();
await screen.findByText("Offen: 3");
expect(screen.queryByText("Sprachnotizen")).not.toBeInTheDocument();
expect(fetchVoiceStats).not.toHaveBeenCalled();
});
it("lists every job with its last run and status label", async () => {
renderPage();
@@ -137,7 +169,8 @@ describe("SystemPage", () => {
expect(screen.getByText("Schlüssel umstellen")).toBeInTheDocument();
expect(screen.getByText("Erfolgreich")).toBeInTheDocument();
expect(screen.getByText("Fehlgeschlagen")).toBeInTheDocument();
expect(screen.getAllByText("Noch nie gelaufen")).toHaveLength(3);
expect(screen.getByText("Sprachnotizen abschreiben")).toBeInTheDocument();
expect(screen.getAllByText("Noch nie gelaufen")).toHaveLength(4);
});
it("shows the queue counts without a warning while the queue keeps up", async () => {
@@ -30,6 +30,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { PageHeader } from "@/components/page-header";
import { QueryState } from "@/components/query-state";
import { TelegramCard } from "./telegram-card";
import { VoiceCard } from "./voice-card";
const SYSTEM_QUERY_KEY = ["system"] as const;
const OVERVIEW_QUERY_KEY = [...SYSTEM_QUERY_KEY, "overview"] as const;
@@ -305,6 +306,7 @@ export default function SystemPage() {
</Card>
{/* The deliveries endpoint needs system:manage; a reader would only get a 403. */}
{canManage ? <TelegramCard /> : null}
{canManage ? <VoiceCard /> : null}
</>
)}
</QueryState>
@@ -0,0 +1,105 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { VoiceCard } from "./voice-card";
import { fetchVoiceConfig, fetchVoiceStats, type VoiceCaptureStats } from "@/lib/voice/api";
vi.mock("@/lib/voice/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/voice/api")>()),
fetchVoiceStats: vi.fn(),
fetchVoiceConfig: vi.fn(),
}));
const stats: VoiceCaptureStats = {
byStatus: { QUEUED: 2, READY: 5, FAILED: 3 },
byError: { TRANSCRIBE_FAILED: 2, NO_SPEECH: 1 },
tokens: { input: 0, output: 0 },
byModel: {},
};
function renderCard() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<VoiceCard />
</QueryClientProvider>,
);
}
describe("VoiceCard", () => {
beforeEach(() => {
vi.mocked(fetchVoiceStats).mockResolvedValue(stats);
vi.mocked(fetchVoiceConfig).mockResolvedValue({
recordingEnabled: true,
extractionEnabled: true,
model: "claude-sonnet-5-5",
retention: { audioHours: 24, contentDays: 7, rowDays: 90 },
});
});
afterEach(() => vi.clearAllMocks());
it("the system card shows counts without user names", async () => {
renderCard();
expect(await screen.findByText("Sprachnotizen")).toBeInTheDocument();
expect(await screen.findByText("Fertig – prüfen")).toBeInTheDocument();
const text = document.body.textContent ?? "";
expect(text).toContain("Fertig – prüfen5");
expect(text).toContain("Fehlgeschlagen3");
expect(text).toContain("TRANSCRIBE_FAILED");
expect(text).toContain("Die Aufnahme konnte nicht abgeschrieben werden.");
expect(Object.keys(stats)).not.toContain("users");
});
it("shows the cost as 0 cleanly while no tokens were used", async () => {
renderCard();
expect(await screen.findByText(/0,00\s*\$/)).toBeInTheDocument();
expect(screen.getByText(/claude-sonnet-5-5/)).toBeInTheDocument();
});
it("multiplies the tokens by the price of the configured model", async () => {
vi.mocked(fetchVoiceStats).mockResolvedValue({
...stats,
tokens: { input: 1_000_000, output: 500_000 },
});
renderCard();
expect(await screen.findByText(/7,00\s*\$/)).toBeInTheDocument();
});
it("says so when the model has no price instead of guessing", async () => {
vi.mocked(fetchVoiceConfig).mockResolvedValue({
recordingEnabled: true,
extractionEnabled: true,
model: "claude-other",
retention: { audioHours: 24, contentDays: 7, rowDays: 90 },
});
vi.mocked(fetchVoiceStats).mockResolvedValue({ ...stats, tokens: { input: 5, output: 5 } });
renderCard();
expect(await screen.findByText(/kein Preis hinterlegt/i)).toBeInTheDocument();
});
it("notes when other models than the configured one were used", async () => {
vi.mocked(fetchVoiceStats).mockResolvedValue({
...stats,
byModel: { "claude-sonnet-5-5": 4, "claude-haiku-5-5": 1 },
});
renderCard();
expect(await screen.findByText(/Schätzung nur für claude-sonnet-5-5/)).toBeInTheDocument();
});
it("shows an empty state without notes", async () => {
vi.mocked(fetchVoiceStats).mockResolvedValue({
byStatus: {},
byError: {},
tokens: { input: 0, output: 0 },
byModel: {},
});
renderCard();
expect(await screen.findByText(/Keine Sprachnotizen/)).toBeInTheDocument();
});
});
@@ -0,0 +1,129 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { QueryState } from "@/components/query-state";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
fetchVoiceConfig,
fetchVoiceStats,
voiceKeys,
type VoiceCaptureStats,
} from "@/lib/voice/api";
import { estimateVoiceCostUsd } from "@/lib/voice/prices";
import { VOICE_ERROR_TEXTS_DE } from "@/lib/voice/status-texts";
/** Words for every server status (the stats count all of them, also the finished ones). */
const STATUS_LABELS_DE: Readonly<Record<string, string>> = {
QUEUED: "Wartet auf Abschrift",
TRANSCRIBING: "Wird abgeschrieben",
TRANSCRIBED: "Abgeschrieben",
EXTRACTING: "Wird ausgewertet",
READY: "Fertig – prüfen",
FAILED: "Fehlgeschlagen",
CONFIRMED: "Übernommen",
DISCARDED: "Verworfen",
EXPIRED: "Abgelaufen",
};
const STATUS_ORDER = Object.keys(STATUS_LABELS_DE);
const usd = new Intl.NumberFormat("de-DE", { style: "currency", currency: "USD" });
const integer = new Intl.NumberFormat("de-DE");
function sortedEntries(counts: Record<string, number>, order?: string[]): [string, number][] {
const entries = Object.entries(counts);
if (!order) {
return entries.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
}
return entries.sort((a, b) => order.indexOf(a[0]) - order.indexOf(b[0]));
}
function CountList({ entries, label }: { entries: [string, number][]; label: (key: string) => string }) {
return (
<ul className="flex flex-col divide-y divide-border/60">
{entries.map(([key, count]) => (
<li key={key} className="flex items-baseline justify-between gap-4 py-2 text-sm">
<span className="min-w-0 break-words">{label(key)}</span>
<span className="shrink-0 font-medium tabular-nums">{count}</span>
</li>
))}
</ul>
);
}
function otherModelUsed(stats: VoiceCaptureStats, model: string | undefined): boolean {
return Object.keys(stats.byModel).some((used) => used !== model);
}
function costLine(stats: VoiceCaptureStats, model: string | undefined): string {
if (!model) {
return "Das Modell ist nicht bekannt.";
}
const cost = estimateVoiceCostUsd(model, stats.tokens);
if (cost === null) {
return `Für das Modell ${model} ist kein Preis hinterlegt.`;
}
return `${usd.format(cost)} (Modell ${model})`;
}
/**
* Counters per status and error code of the last 7 days, and the estimated
* evaluation cost. Only counts: no names, no content. Needs system:manage
* (the stats endpoint); the caller shows it only then.
*/
export function VoiceCard() {
const statsQuery = useQuery({ queryKey: voiceKeys.stats, queryFn: fetchVoiceStats });
const configQuery = useQuery({ queryKey: voiceKeys.config, queryFn: fetchVoiceConfig });
return (
<Card>
<CardHeader>
<CardTitle>Sprachnotizen</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<QueryState query={statsQuery} what="Sprachnotizen-Zähler">
{(stats) => {
const statuses = sortedEntries(stats.byStatus, STATUS_ORDER);
const errors = sortedEntries(stats.byError);
return (
<>
<p className="text-sm text-muted-foreground">Die letzten 7 Tage.</p>
{statuses.length === 0 ? (
<p className="text-sm text-muted-foreground">Keine Sprachnotizen.</p>
) : (
<CountList entries={statuses} label={(key) => STATUS_LABELS_DE[key] ?? key} />
)}
{errors.length > 0 ? (
<div className="flex flex-col gap-1">
<h3 className="text-sm font-medium">Fehlercodes</h3>
<CountList
entries={errors}
label={(key) => `${key}: ${VOICE_ERROR_TEXTS_DE[key] ?? "Unbekannter Fehler."}`}
/>
</div>
) : null}
<div className="flex flex-col gap-1 text-sm">
<h3 className="font-medium">Geschätzte Kosten der Auswertung</h3>
<p>
{integer.format(stats.tokens.input)} Token Eingabe,{" "}
{integer.format(stats.tokens.output)} Token Ausgabe
</p>
<p className="font-medium">{costLine(stats, configQuery.data?.model)}</p>
{otherModelUsed(stats, configQuery.data?.model) ? (
<p className="text-muted-foreground">
Schätzung nur für {configQuery.data?.model}: in diesen Tagen lief auch ein
anderes Modell.
</p>
) : null}
<p className="text-muted-foreground">
Preis je Token aus der Preisliste des Modells, nicht aus der Rechnung.
</p>
</div>
</>
);
}}
</QueryState>
</CardContent>
</Card>
);
}
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import axe from "axe-core";
@@ -7,12 +7,48 @@ import VoiceSettingsPage from "./page";
import { getCloudRecognitionConsent } from "@/lib/voice/consent";
import { fetchSiteSettings } from "@/lib/site-settings/api";
import type { SiteSettings } from "@/lib/site-settings/types";
import {
discardAllVoiceCaptures,
fetchVoiceCaptures,
fetchVoiceConfig,
type VoiceCaptureListItem,
} from "@/lib/voice/api";
import { OFFLINE_QUEUE_QUERY_KEY } from "@/lib/offline/use-capture-mutation";
import { toast } from "sonner";
import { discardAllPendingVoice, listPendingVoice } from "@/lib/voice/pending-voice";
let permissions = new Set<string>();
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({ hasPermission: (permission: string) => permissions.has(permission) }),
useAuth: () => ({
user: { id: "u-1" },
hasPermission: (permission: string) => permissions.has(permission),
}),
}));
vi.mock("@/lib/site-settings/api", () => ({ fetchSiteSettings: vi.fn() }));
vi.mock("@/lib/voice/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/voice/api")>()),
fetchVoiceConfig: vi.fn(),
fetchVoiceCaptures: vi.fn(),
discardAllVoiceCaptures: vi.fn(),
}));
vi.mock("@/lib/voice/pending-voice", () => ({
listPendingVoice: vi.fn(),
discardAllPendingVoice: vi.fn(),
}));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
function listItem(id: string, status: VoiceCaptureListItem["status"]): VoiceCaptureListItem {
return {
id,
colonyId: "c-1",
colonyName: "V1",
status,
source: "AUDIO",
createdAt: "2026-10-16T08:00:00.000Z",
lastErrorCode: null,
contentExpiresAt: "2026-10-23T08:00:00.000Z",
};
}
function settings(overrides: Partial<SiteSettings> = {}): SiteSettings {
return {
@@ -33,11 +69,12 @@ function settings(overrides: Partial<SiteSettings> = {}): SiteSettings {
function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
const result = render(
<QueryClientProvider client={client}>
<VoiceSettingsPage />
</QueryClientProvider>,
);
return { ...result, client };
}
describe("VoiceSettingsPage", () => {
@@ -45,6 +82,106 @@ describe("VoiceSettingsPage", () => {
window.localStorage.clear();
permissions = new Set();
vi.mocked(fetchSiteSettings).mockReset().mockResolvedValue(settings());
vi.mocked(fetchVoiceConfig).mockReset().mockResolvedValue({
recordingEnabled: true,
extractionEnabled: true,
model: "claude-sonnet-5-5",
retention: { audioHours: 24, contentDays: 7, rowDays: 90 },
});
vi.mocked(fetchVoiceCaptures)
.mockReset()
.mockResolvedValue([listItem("a", "READY"), listItem("b", "FAILED"), listItem("c", "CONFIRMED")]);
vi.mocked(listPendingVoice).mockReset().mockResolvedValue([{ id: "n1" }] as never);
vi.mocked(discardAllVoiceCaptures).mockReset().mockResolvedValue({ discarded: 2 });
vi.mocked(discardAllPendingVoice).mockReset().mockResolvedValue(1);
});
it("shows the model id and the retention numbers from the server", async () => {
renderPage();
expect(await screen.findByText("claude-sonnet-5-5")).toBeInTheDocument();
expect(screen.getByText(/Aufnahme: 24 Stunden/)).toBeInTheDocument();
expect(screen.getByText(/Text und Auswertung: 7 Tage/)).toBeInTheDocument();
expect(screen.getByText(/Eintrag ohne Inhalt: 90 Tage/)).toBeInTheDocument();
});
it("says the evaluation is off and names no model while extraction is disabled", async () => {
vi.mocked(fetchVoiceConfig).mockResolvedValue({
recordingEnabled: true,
extractionEnabled: false,
model: "claude-sonnet-5-5",
retention: { audioHours: 24, contentDays: 7, rowDays: 90 },
});
renderPage();
expect(await screen.findByText(/Die Auswertung ist ausgeschaltet/)).toBeInTheDocument();
expect(screen.getByText(/nur das Abschreiben läuft im eigenen Cluster/)).toBeInTheDocument();
expect(screen.queryByText("claude-sonnet-5-5")).not.toBeInTheDocument();
expect(screen.getByText(/Aufnahme: 24 Stunden/)).toBeInTheDocument();
});
it("says so when the server values cannot be loaded", async () => {
vi.mocked(fetchVoiceConfig).mockRejectedValue(new Error("offline"));
renderPage();
expect(await screen.findByText(/Die Werte konnten nicht geladen werden/)).toBeInTheDocument();
});
it("delete all asks with the count and empties server and device", async () => {
const user = userEvent.setup();
renderPage();
const trigger = await screen.findByRole("button", { name: "Alle meine Sprachnotizen löschen" });
await waitFor(() => expect(trigger).toBeEnabled());
await user.click(trigger);
const dialog = await screen.findByRole("alertdialog");
// 2 open on the server (READY, FAILED; CONFIRMED is done) + 1 on this device
expect(dialog).toHaveTextContent("3 Sprachnotizen");
expect(discardAllVoiceCaptures).not.toHaveBeenCalled();
await user.click(within(dialog).getByRole("button", { name: "Alle löschen" }));
await waitFor(() => expect(discardAllVoiceCaptures).toHaveBeenCalledTimes(1));
expect(discardAllPendingVoice).toHaveBeenCalledWith("u-1");
});
it("still empties this device when the server cannot be reached", async () => {
vi.mocked(discardAllVoiceCaptures).mockRejectedValue(new Error("offline"));
const user = userEvent.setup();
renderPage();
const trigger = await screen.findByRole("button", { name: "Alle meine Sprachnotizen löschen" });
await waitFor(() => expect(trigger).toBeEnabled());
await user.click(trigger);
await user.click(within(await screen.findByRole("alertdialog")).getByRole("button", { name: "Alle löschen" }));
await waitFor(() => expect(discardAllPendingVoice).toHaveBeenCalledWith("u-1"));
await waitFor(() =>
expect(toast.error).toHaveBeenCalledWith(expect.stringMatching(/Gerät[\s\S]*gelöscht[\s\S]*Server/)),
);
});
it("refreshes the offline queue after delete all", async () => {
const user = userEvent.setup();
const { client } = renderPage();
const spy = vi.spyOn(client, "invalidateQueries");
const trigger = await screen.findByRole("button", { name: "Alle meine Sprachnotizen löschen" });
await waitFor(() => expect(trigger).toBeEnabled());
await user.click(trigger);
await user.click(within(await screen.findByRole("alertdialog")).getByRole("button", { name: "Alle löschen" }));
await waitFor(() => expect(spy).toHaveBeenCalledWith({ queryKey: OFFLINE_QUEUE_QUERY_KEY }));
});
it("disables delete all when there is nothing to delete", async () => {
vi.mocked(fetchVoiceCaptures).mockResolvedValue([listItem("c", "CONFIRMED")]);
vi.mocked(listPendingVoice).mockResolvedValue([]);
renderPage();
expect(await screen.findByText(/Keine offenen Sprachnotizen/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Alle meine Sprachnotizen löschen" })).toBeDisabled();
});
it("the cloud switch is off by default and stores the consent", async () => {
@@ -1,8 +1,10 @@
"use client";
import { useSyncExternalStore } from "react";
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ShieldCheck } from "lucide-react";
import { toast } from "sonner";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { useAuth } from "@/lib/auth/auth-context";
import { fetchSiteSettings } from "@/lib/site-settings/api";
import {
@@ -10,6 +12,15 @@ import {
setCloudRecognitionConsent,
subscribeCloudRecognitionConsent,
} from "@/lib/voice/consent";
import {
discardAllVoiceCaptures,
fetchVoiceCaptures,
fetchVoiceConfig,
voiceKeys,
} from "@/lib/voice/api";
import { OFFLINE_QUEUE_QUERY_KEY } from "@/lib/offline/use-capture-mutation";
import { discardAllPendingVoice, listPendingVoice } from "@/lib/voice/pending-voice";
import { isListedVoiceStatus } from "@/lib/voice/status-texts";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import {
@@ -61,6 +72,128 @@ function CloudConsentCard() {
);
}
function ConfigCard() {
const { data, isError } = useQuery({ queryKey: voiceKeys.config, queryFn: fetchVoiceConfig });
return (
<Card>
<CardContent className="flex flex-col gap-2">
<h2 className="font-heading text-lg font-semibold">Modell und Fristen</h2>
{data ? (
<>
{data.extractionEnabled ? (
<p className="text-sm">
Die Auswertung läuft mit dem Modell{" "}
<code className="rounded bg-muted px-1 font-mono">{data.model}</code>.
</p>
) : (
<p className="text-sm">
Die Auswertung ist ausgeschaltet. Es wird nichts an einen KI-Dienst gesendet; nur
das Abschreiben läuft im eigenen Cluster.
</p>
)}
<ul className="list-disc pl-5 text-sm">
<li>Aufnahme: {data.retention.audioHours} Stunden</li>
<li>Text und Auswertung: {data.retention.contentDays} Tage</li>
<li>Eintrag ohne Inhalt: {data.retention.rowDays} Tage</li>
</ul>
</>
) : (
<p className="text-sm text-muted-foreground">
{isError
? "Die Werte konnten nicht geladen werden. Es gelten die Fristen oben."
: "Werte werden geladen …"}
</p>
)}
</CardContent>
</Card>
);
}
function failureMessage(serverFailed: boolean, deviceFailed: boolean): string {
if (serverFailed && deviceFailed) {
return "Es konnte nichts gelöscht werden: weder auf dem Server noch auf diesem Gerät. Bitte später noch einmal versuchen.";
}
return serverFailed
? "Auf diesem Gerät sind die Sprachnotizen gelöscht, der Server war nicht erreichbar. Bitte später noch einmal versuchen."
: "Auf dem Server sind die Sprachnotizen gelöscht, auf diesem Gerät konnten sie nicht gelöscht werden.";
}
function noteCount(count: number): string {
return count === 1 ? "1 Sprachnotiz" : `${count} Sprachnotizen`;
}
/** Counts what delete-all would remove: open notes on the server plus unsent ones on this device. */
function DeleteAllCard() {
const { user } = useAuth();
const userId = user?.id ?? "";
const queryClient = useQueryClient();
const server = useQuery({
queryKey: [...voiceKeys.all, "open-count"],
queryFn: async () => {
const items = await fetchVoiceCaptures();
return items.filter((item) => isListedVoiceStatus(item.status)).length;
},
});
const device = useQuery({
queryKey: ["pending-voice", userId, "count"],
queryFn: async () => (await listPendingVoice(userId)).length,
enabled: userId !== "",
});
const serverCount = server.data ?? 0;
const deviceCount = device.data ?? 0;
const total = serverCount + deviceCount;
const isLoading = server.isPending || (userId !== "" && device.isPending);
const discardAll = useMutation({
// Both sides always run: a server that cannot be reached must not keep the device's copies.
mutationFn: async () => {
const [serverResult, deviceResult] = await Promise.allSettled([
discardAllVoiceCaptures(),
discardAllPendingVoice(userId),
]);
return { serverFailed: serverResult.status === "rejected", deviceFailed: deviceResult.status === "rejected" };
},
onSuccess: ({ serverFailed, deviceFailed }) => {
if (serverFailed || deviceFailed) {
toast.error(failureMessage(serverFailed, deviceFailed));
} else {
toast.success("Alle Sprachnotizen gelöscht");
}
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
return queryClient.invalidateQueries({ queryKey: voiceKeys.all });
},
onSettled: () => queryClient.invalidateQueries({ queryKey: ["pending-voice"] }),
});
return (
<Card>
<CardContent className="flex flex-col gap-3">
<h2 className="font-heading text-lg font-semibold">Meine Sprachnotizen löschen</h2>
<p className="text-sm text-muted-foreground">
{isLoading
? "Anzahl wird ermittelt …"
: total === 0
? "Keine offenen Sprachnotizen."
: `Offen: ${noteCount(serverCount)} auf dem Server, ${noteCount(deviceCount)} auf diesem Gerät.`}
</p>
<div>
<ConfirmDialog
triggerLabel="Alle meine Sprachnotizen löschen"
triggerSize="touch"
triggerDisabled={isLoading || total === 0}
title="Alle Sprachnotizen löschen?"
description={`${noteCount(total)} werden endgültig gelöscht: Aufnahme, Text und Auswertung. Bereits gespeicherte Durchsichten bleiben unverändert.`}
confirmLabel="Alle löschen"
isPending={discardAll.isPending}
onConfirm={() => discardAll.mutate()}
/>
</div>
</CardContent>
</Card>
);
}
function HolderCard() {
const { hasPermission } = useAuth();
const canRead = hasPermission("site-settings:read");
@@ -109,6 +242,8 @@ export default function VoiceSettingsPage() {
<p className="text-sm">{RECORDING_NOTICE}</p>
</CardContent>
</Card>
<ConfigCard />
<DeleteAllCard />
<HolderCard />
</main>
);