feat: Sprachnotiz in der Durchsicht, Info-Seite und Zähler (T7 L5 Tasks 20, 22)
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:
1 parent
51206bea4f
commit
ce922c8569
40 files changed
+3347
-45
No files matched your search
@@ -24,6 +24,8 @@
|
|||||||
"/treatments/new",
|
"/treatments/new",
|
||||||
"/feedings/new",
|
"/feedings/new",
|
||||||
"/swarm-control/new",
|
"/swarm-control/new",
|
||||||
|
"/voice",
|
||||||
|
"/voice/new",
|
||||||
];
|
];
|
||||||
|
|
||||||
var OFFLINE_PAGE = "/offline";
|
var OFFLINE_PAGE = "/offline";
|
||||||
@@ -54,6 +56,10 @@
|
|||||||
// apiaries themselves: exact coordinates and the address (theft risk)
|
// apiaries themselves: exact coordinates and the address (theft risk)
|
||||||
/\/api\/v1\/apiaries\/?$/i,
|
/\/api\/v1\/apiaries\/?$/i,
|
||||||
/\/api\/v1\/apiaries\/[^/]+\/?$/i,
|
/\/api\/v1\/apiaries\/[^/]+\/?$/i,
|
||||||
|
// voice notes: transcripts and suggestions are content (health of a
|
||||||
|
// person's colonies, spoken words); the list, the detail and the status
|
||||||
|
// are current state or nothing - the device queue keeps the unsent audio
|
||||||
|
/\/api\/v1\/voice-captures(\/|$)/i,
|
||||||
// exports and backups: full records incl. coordinates and addresses,
|
// exports and backups: full records incl. coordinates and addresses,
|
||||||
// names (Bestandsbuch), megabytes
|
// names (Bestandsbuch), megabytes
|
||||||
/\/api\/v1\/export\//i,
|
/\/api\/v1\/export\//i,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
// Content version of sw-routes.js (sha256 of its LF text, first 12 hex
|
// Content version of sw-routes.js (sha256 of its LF text, first 12 hex
|
||||||
// characters; sw-routes.spec.ts checks it). A changed value changes this
|
// characters; sw-routes.spec.ts checks it). A changed value changes this
|
||||||
// file, so every browser - Safari too - installs the worker with new rules.
|
// file, so every browser - Safari too - installs the worker with new rules.
|
||||||
const SW_ROUTES_VERSION = "215afb9f84e9";
|
const SW_ROUTES_VERSION = "adc0d618eac9";
|
||||||
importScripts(`/sw-routes.js?v=${SW_ROUTES_VERSION}`);
|
importScripts(`/sw-routes.js?v=${SW_ROUTES_VERSION}`);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -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", () => {
|
describe("feedStatusOptions", () => {
|
||||||
const labels = (stored: Parameters<typeof feedStatusOptions>[0]) =>
|
const labels = (stored: Parameters<typeof feedStatusOptions>[0]) =>
|
||||||
feedStatusOptions(stored).map((option) => option.label);
|
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
|
* 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
|
* 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
|
* hive ops collected in the „Beute“ section, and the voice note the answers
|
||||||
* nothing is asked of the server that was not wanted.
|
* 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(
|
export function toCreateInspectionInput(
|
||||||
values: InspectionFormValues,
|
values: InspectionFormValues,
|
||||||
canUpdateColony: boolean,
|
canUpdateColony: boolean,
|
||||||
hiveOps: HiveOp[] = [],
|
hiveOps: HiveOp[] = [],
|
||||||
|
voiceCaptureId?: string,
|
||||||
): CreateInspectionInput {
|
): CreateInspectionInput {
|
||||||
const input: CreateInspectionInput = toInspectionInput(values);
|
const base: CreateInspectionInput = {
|
||||||
|
...toInspectionInput(values),
|
||||||
|
...(voiceCaptureId ? { voiceCaptureId } : {}),
|
||||||
|
};
|
||||||
if (!canUpdateColony) {
|
if (!canUpdateColony) {
|
||||||
return input;
|
return base;
|
||||||
}
|
}
|
||||||
const filled = new Set(takeoverFieldsFilled(values));
|
const filled = new Set(takeoverFieldsFilled(values));
|
||||||
const applyToColony = values.applyToColony.filter((field) => filled.has(field));
|
const applyToColony = values.applyToColony.filter((field) => filled.has(field));
|
||||||
return {
|
return {
|
||||||
...input,
|
...base,
|
||||||
...(applyToColony.length > 0 ? { applyToColony } : {}),
|
...(applyToColony.length > 0 ? { applyToColony } : {}),
|
||||||
...(hiveOps.length > 0 ? { hiveChange: { ops: hiveOps } } : {}),
|
...(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 LastInspection, useLastInspection } from "@/lib/inspections/use-last-inspection";
|
||||||
import type { ColonyTakeoverField, CreateInspectionInput } from "@/lib/inspections/types";
|
import type { ColonyTakeoverField, CreateInspectionInput } from "@/lib/inspections/types";
|
||||||
import { hiveKeys } from "@/lib/hive/api";
|
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 type { HiveOp } from "@/lib/hive/types";
|
||||||
import { useAuth, useCurrentUserId } from "@/lib/auth/auth-context";
|
import { useAuth, useCurrentUserId } from "@/lib/auth/auth-context";
|
||||||
import { MAX_OCCUPIED_FRAMES } from "@/lib/colonies/labels";
|
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 { DraftBanner } from "@/components/form/draft-banner";
|
||||||
import { PHOTO_SESSION_QUERY_KEY, PhotoCaptureField } from "@/components/photos/photo-capture-field";
|
import { PHOTO_SESSION_QUERY_KEY, PhotoCaptureField } from "@/components/photos/photo-capture-field";
|
||||||
import {
|
import {
|
||||||
FEED_STATUS_OPTIONS,
|
|
||||||
INSPECTION_OBSERVATIONS,
|
INSPECTION_OBSERVATIONS,
|
||||||
InspectionFormValues,
|
InspectionFormValues,
|
||||||
currentDraftValues,
|
currentDraftValues,
|
||||||
feedLegacyHint,
|
feedLegacyHint,
|
||||||
|
feedStatusOptions,
|
||||||
initialInspectionValues,
|
initialInspectionValues,
|
||||||
syncTakeoverSelection,
|
syncTakeoverSelection,
|
||||||
toCreateInspectionInput,
|
toCreateInspectionInput,
|
||||||
@@ -74,6 +76,8 @@ import {
|
|||||||
import { broodHintFor, broodHintLabel, hiveErrorText } from "./hive-change";
|
import { broodHintFor, broodHintLabel, hiveErrorText } from "./hive-change";
|
||||||
import { suggestAfterInspection } from "./hive-save-follow-ups";
|
import { suggestAfterInspection } from "./hive-save-follow-ups";
|
||||||
import { InspectionHiveSection, useInspectionHive } from "./hive-section";
|
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. */
|
/** Free-text fields that take dictation. */
|
||||||
type DictatedField = "notes" | "healthIssues" | "varroaIssues" | "measures";
|
type DictatedField = "notes" | "healthIssues" | "varroaIssues" | "measures";
|
||||||
@@ -82,6 +86,10 @@ const UNDO_TOAST_MS = 8000;
|
|||||||
const WEATHER_UPDATED = "Wetter aktualisiert";
|
const WEATHER_UPDATED = "Wetter aktualisiert";
|
||||||
const WEATHER_MISSING = "Keine Wetterdaten für diesen Tag";
|
const WEATHER_MISSING = "Keine Wetterdaten für diesen Tag";
|
||||||
const PHOTOS_LATER = "Fotos werden später gesendet.";
|
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> = {
|
const TAKEOVER_LABELS: Record<TakeoverField, string> = {
|
||||||
queenSeen: "Königin gesehen",
|
queenSeen: "Königin gesehen",
|
||||||
@@ -186,6 +194,8 @@ function LastInspectionBar({ last, onTakeOverAll }: LastInspectionBarProps) {
|
|||||||
|
|
||||||
interface InspectionCaptureFormProps {
|
interface InspectionCaptureFormProps {
|
||||||
initialColonyId: string;
|
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. */
|
/** Apiary of the stand round this form was opened from, if any. */
|
||||||
roundApiaryId: string | null;
|
roundApiaryId: string | null;
|
||||||
backTarget: { href: string; label: string };
|
backTarget: { href: string; label: string };
|
||||||
@@ -204,6 +214,7 @@ function roundChipText(round: StandRound, colonyId: string): string {
|
|||||||
|
|
||||||
function InspectionCaptureForm({
|
function InspectionCaptureForm({
|
||||||
initialColonyId,
|
initialColonyId,
|
||||||
|
voiceId,
|
||||||
roundApiaryId,
|
roundApiaryId,
|
||||||
backTarget,
|
backTarget,
|
||||||
}: InspectionCaptureFormProps) {
|
}: InspectionCaptureFormProps) {
|
||||||
@@ -236,6 +247,39 @@ function InspectionCaptureForm({
|
|||||||
const [submittedHiveOps, setSubmittedHiveOps] = useState("");
|
const [submittedHiveOps, setSubmittedHiveOps] = useState("");
|
||||||
const { base: hiveBase, catalog: hiveCatalog } = useInspectionHive(values.colonyId);
|
const { base: hiveBase, catalog: hiveCatalog } = useInspectionHive(values.colonyId);
|
||||||
const broodFromHive = broodHintFor(hiveBase, hiveOps, hiveCatalog);
|
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.
|
* Every change of the answers also keeps the Volkskarte selection in step.
|
||||||
@@ -377,6 +421,11 @@ function InspectionCaptureForm({
|
|||||||
queryKey: ["colonies", outcome.result.colonyId, "inspections"],
|
queryKey: ["colonies", outcome.result.colonyId, "inspections"],
|
||||||
});
|
});
|
||||||
toast.success("Durchsicht gespeichert");
|
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) {
|
if (input.hiveChange) {
|
||||||
void queryClient.invalidateQueries({ queryKey: hiveKeys.detail(input.colonyId) });
|
void queryClient.invalidateQueries({ queryKey: hiveKeys.detail(input.colonyId) });
|
||||||
if (hiveBase && hiveCatalog) {
|
if (hiveBase && hiveCatalog) {
|
||||||
@@ -420,9 +469,19 @@ function InspectionCaptureForm({
|
|||||||
document.getElementById("inspection-date")?.focus();
|
document.getElementById("inspection-date")?.focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
submitInspection(voice.voiceCaptureId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitInspection(voiceCaptureId: string | undefined) {
|
||||||
submittedPhotoSession.current = values.photoSessionId;
|
submittedPhotoSession.current = values.photoSessionId;
|
||||||
setSubmittedHiveOps(JSON.stringify(hiveOps));
|
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}
|
onDiscard={discardDraft}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
<div className="max-w-lg">
|
||||||
|
<VoiceReviewBox review={voice} />
|
||||||
|
</div>
|
||||||
<Card className="max-w-lg">
|
<Card className="max-w-lg">
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form
|
<form
|
||||||
@@ -625,13 +687,15 @@ function InspectionCaptureForm({
|
|||||||
>
|
>
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
{INSPECTION_OBSERVATIONS.map((observation) => (
|
{INSPECTION_OBSERVATIONS.map((observation) => (
|
||||||
<TriStateField
|
<div key={observation.key} className="flex flex-col gap-1.5">
|
||||||
key={observation.key}
|
<TriStateField
|
||||||
label={observation.label}
|
label={observation.label}
|
||||||
value={values[observation.key]}
|
value={values[observation.key]}
|
||||||
onChange={(answer) => set(observation.key, answer)}
|
onChange={(answer) => set(observation.key, answer)}
|
||||||
previous={previousFor(observation.key)}
|
previous={previousFor(observation.key)}
|
||||||
/>
|
/>
|
||||||
|
<VoiceFieldRow review={voice} field={observation.key} />
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{noQueenSigns ? (
|
{noQueenSigns ? (
|
||||||
@@ -653,10 +717,11 @@ function InspectionCaptureForm({
|
|||||||
describedBy={describedBy}
|
describedBy={describedBy}
|
||||||
value={values.broodPattern}
|
value={values.broodPattern}
|
||||||
onChange={(rating) => set("broodPattern", rating)}
|
onChange={(rating) => set("broodPattern", rating)}
|
||||||
lowLabel="lückenhaft"
|
{...INSPECTION_SCALE_LABELS.broodPattern}
|
||||||
highLabel="geschlossen"
|
direction={INSPECTION_SCALE_DIRECTIONS.broodPattern}
|
||||||
/>
|
/>
|
||||||
{hintFor("broodPattern")}
|
{hintFor("broodPattern")}
|
||||||
|
<VoiceFieldRow review={voice} field="broodPattern" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
@@ -673,6 +738,7 @@ function InspectionCaptureForm({
|
|||||||
describedBy={describedBy}
|
describedBy={describedBy}
|
||||||
/>
|
/>
|
||||||
{hintFor("broodFrames")}
|
{hintFor("broodFrames")}
|
||||||
|
<VoiceFieldRow review={voice} field="broodFrames" />
|
||||||
{broodFromHive !== null &&
|
{broodFromHive !== null &&
|
||||||
values.broodFrames !== String(broodFromHive) &&
|
values.broodFrames !== String(broodFromHive) &&
|
||||||
!(broodFromHive === 0 && values.broodFrames === "") ? (
|
!(broodFromHive === 0 && values.broodFrames === "") ? (
|
||||||
@@ -727,6 +793,7 @@ function InspectionCaptureForm({
|
|||||||
describedBy={describedBy}
|
describedBy={describedBy}
|
||||||
/>
|
/>
|
||||||
{hintFor("colonyStrength")}
|
{hintFor("colonyStrength")}
|
||||||
|
<VoiceFieldRow review={voice} field="colonyStrength" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
@@ -738,10 +805,11 @@ function InspectionCaptureForm({
|
|||||||
labelledBy={labelId}
|
labelledBy={labelId}
|
||||||
value={values.gentleness}
|
value={values.gentleness}
|
||||||
onChange={(rating) => set("gentleness", rating)}
|
onChange={(rating) => set("gentleness", rating)}
|
||||||
lowLabel="stechlustig"
|
{...INSPECTION_SCALE_LABELS.gentleness}
|
||||||
highLabel="sehr sanft"
|
direction={INSPECTION_SCALE_DIRECTIONS.gentleness}
|
||||||
/>
|
/>
|
||||||
{hintFor("gentleness")}
|
{hintFor("gentleness")}
|
||||||
|
<VoiceFieldRow review={voice} field="gentleness" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
@@ -753,10 +821,11 @@ function InspectionCaptureForm({
|
|||||||
labelledBy={labelId}
|
labelledBy={labelId}
|
||||||
value={values.combBehavior}
|
value={values.combBehavior}
|
||||||
onChange={(rating) => set("combBehavior", rating)}
|
onChange={(rating) => set("combBehavior", rating)}
|
||||||
lowLabel="läuft weg"
|
{...INSPECTION_SCALE_LABELS.combBehavior}
|
||||||
highLabel="sitzt ruhig"
|
direction={INSPECTION_SCALE_DIRECTIONS.combBehavior}
|
||||||
/>
|
/>
|
||||||
{hintFor("combBehavior")}
|
{hintFor("combBehavior")}
|
||||||
|
<VoiceFieldRow review={voice} field="combBehavior" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
@@ -768,11 +837,11 @@ function InspectionCaptureForm({
|
|||||||
labelledBy={labelId}
|
labelledBy={labelId}
|
||||||
value={values.swarmMood}
|
value={values.swarmMood}
|
||||||
onChange={(rating) => set("swarmMood", rating)}
|
onChange={(rating) => set("swarmMood", rating)}
|
||||||
direction="lower-is-better"
|
{...INSPECTION_SCALE_LABELS.swarmMood}
|
||||||
lowLabel="ruhig"
|
direction={INSPECTION_SCALE_DIRECTIONS.swarmMood}
|
||||||
highLabel="stark"
|
|
||||||
/>
|
/>
|
||||||
{hintFor("swarmMood")}
|
{hintFor("swarmMood")}
|
||||||
|
<VoiceFieldRow review={voice} field="swarmMood" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
@@ -785,7 +854,7 @@ function InspectionCaptureForm({
|
|||||||
<ChoiceGroup
|
<ChoiceGroup
|
||||||
id={id}
|
id={id}
|
||||||
labelledBy={labelId}
|
labelledBy={labelId}
|
||||||
options={FEED_STATUS_OPTIONS}
|
options={feedStatusOptions(values.feedStatus)}
|
||||||
value={values.feedStatus}
|
value={values.feedStatus}
|
||||||
onChange={(feedStatus) => set("feedStatus", feedStatus)}
|
onChange={(feedStatus) => set("feedStatus", feedStatus)}
|
||||||
className="grid-cols-2 sm:grid-cols-4"
|
className="grid-cols-2 sm:grid-cols-4"
|
||||||
@@ -797,6 +866,7 @@ function InspectionCaptureForm({
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{hintFor("feedStatus")}
|
{hintFor("feedStatus")}
|
||||||
|
<VoiceFieldRow review={voice} field="feedStatus" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
@@ -846,6 +916,7 @@ function InspectionCaptureForm({
|
|||||||
collapsible
|
collapsible
|
||||||
defaultOpen={false}
|
defaultOpen={false}
|
||||||
fieldCount={5}
|
fieldCount={5}
|
||||||
|
forceOpen={extraOpenedForVoice}
|
||||||
>
|
>
|
||||||
<Field label="Honigraum">
|
<Field label="Honigraum">
|
||||||
{({ id }) => (
|
{({ id }) => (
|
||||||
@@ -859,11 +930,13 @@ function InspectionCaptureForm({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{hintFor("honeySuperStatus")}
|
{hintFor("honeySuperStatus")}
|
||||||
|
<VoiceFieldRow review={voice} field="honeySuperStatus" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Varroa-Befund">
|
<Field label="Varroa-Befund">
|
||||||
{({ id }) => (
|
{({ id }) => (
|
||||||
|
<>
|
||||||
<DictationRow
|
<DictationRow
|
||||||
fieldLabel="Varroa-Befund"
|
fieldLabel="Varroa-Befund"
|
||||||
onAppend={(text) => appendDictated("varroaIssues", text)}
|
onAppend={(text) => appendDictated("varroaIssues", text)}
|
||||||
@@ -877,10 +950,13 @@ function InspectionCaptureForm({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</DictationRow>
|
</DictationRow>
|
||||||
|
<VoiceFieldRow review={voice} field="varroaIssues" />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Gesundheitsauffälligkeiten">
|
<Field label="Gesundheitsauffälligkeiten">
|
||||||
{({ id }) => (
|
{({ id }) => (
|
||||||
|
<>
|
||||||
<DictationRow
|
<DictationRow
|
||||||
fieldLabel="Gesundheitsauffälligkeiten"
|
fieldLabel="Gesundheitsauffälligkeiten"
|
||||||
onAppend={(text) => appendDictated("healthIssues", text)}
|
onAppend={(text) => appendDictated("healthIssues", text)}
|
||||||
@@ -894,10 +970,13 @@ function InspectionCaptureForm({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</DictationRow>
|
</DictationRow>
|
||||||
|
<VoiceFieldRow review={voice} field="healthIssues" />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Maßnahmen">
|
<Field label="Maßnahmen">
|
||||||
{({ id }) => (
|
{({ id }) => (
|
||||||
|
<>
|
||||||
<DictationRow
|
<DictationRow
|
||||||
fieldLabel="Maßnahmen"
|
fieldLabel="Maßnahmen"
|
||||||
onAppend={(text) => appendDictated("measures", text)}
|
onAppend={(text) => appendDictated("measures", text)}
|
||||||
@@ -908,10 +987,13 @@ function InspectionCaptureForm({
|
|||||||
onChange={(event) => set("measures", event.target.value)}
|
onChange={(event) => set("measures", event.target.value)}
|
||||||
/>
|
/>
|
||||||
</DictationRow>
|
</DictationRow>
|
||||||
|
<VoiceFieldRow review={voice} field="measures" />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Notizen">
|
<Field label="Notizen">
|
||||||
{({ id }) => (
|
{({ id }) => (
|
||||||
|
<>
|
||||||
<DictationRow
|
<DictationRow
|
||||||
fieldLabel="Notizen"
|
fieldLabel="Notizen"
|
||||||
onAppend={(text) => appendDictated("notes", text)}
|
onAppend={(text) => appendDictated("notes", text)}
|
||||||
@@ -922,6 +1004,8 @@ function InspectionCaptureForm({
|
|||||||
onChange={(event) => set("notes", event.target.value)}
|
onChange={(event) => set("notes", event.target.value)}
|
||||||
/>
|
/>
|
||||||
</DictationRow>
|
</DictationRow>
|
||||||
|
<VoiceFieldRow review={voice} field="notes" />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
@@ -952,12 +1036,25 @@ function InspectionCaptureForm({
|
|||||||
<StickySubmit
|
<StickySubmit
|
||||||
note={
|
note={
|
||||||
capture.submitError && hiveErrorText(capture.error) === null ? (
|
capture.submitError && hiveErrorText(capture.error) === null ? (
|
||||||
<p
|
<div className="flex flex-col items-start gap-2">
|
||||||
role="alert"
|
<p
|
||||||
className="text-sm font-medium text-destructive"
|
role="alert"
|
||||||
>
|
className="text-sm font-medium text-destructive"
|
||||||
{capture.submitError}
|
>
|
||||||
</p>
|
{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
|
) : null
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -986,12 +1083,14 @@ export default function NewInspectionPage() {
|
|||||||
);
|
);
|
||||||
const colonyId = searchParams.get("colonyId") ?? "";
|
const colonyId = searchParams.get("colonyId") ?? "";
|
||||||
const round = searchParams.get("round");
|
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 (
|
return (
|
||||||
<InspectionCaptureForm
|
<InspectionCaptureForm
|
||||||
key={`${colonyId}:${round ?? ""}`}
|
key={`${colonyId}:${round ?? ""}:${voiceId ?? ""}`}
|
||||||
initialColonyId={colonyId}
|
initialColonyId={colonyId}
|
||||||
|
voiceId={voiceId}
|
||||||
roundApiaryId={round}
|
roundApiaryId={round}
|
||||||
backTarget={backTarget}
|
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";
|
} from "@/lib/system/api";
|
||||||
import { JOB_NAMES, type SystemOverview } from "@/lib/system/types";
|
import { JOB_NAMES, type SystemOverview } from "@/lib/system/types";
|
||||||
import { fetchNotificationDeliveries } from "@/lib/telegram/api";
|
import { fetchNotificationDeliveries } from "@/lib/telegram/api";
|
||||||
|
import { fetchVoiceConfig, fetchVoiceStats } from "@/lib/voice/api";
|
||||||
|
|
||||||
vi.mock("@/lib/system/api", () => ({
|
vi.mock("@/lib/system/api", () => ({
|
||||||
fetchSystemOverview: vi.fn(),
|
fetchSystemOverview: vi.fn(),
|
||||||
@@ -27,6 +28,11 @@ vi.mock("@/lib/telegram/api", () => ({
|
|||||||
retryNotificationDelivery: vi.fn(),
|
retryNotificationDelivery: vi.fn(),
|
||||||
retryFailedNotificationDeliveries: 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[] = [];
|
let permissions: string[] = [];
|
||||||
vi.mock("@/lib/auth/auth-context", () => ({
|
vi.mock("@/lib/auth/auth-context", () => ({
|
||||||
useAuth: () => ({ user: { permissions } }),
|
useAuth: () => ({ user: { permissions } }),
|
||||||
@@ -99,6 +105,18 @@ describe("SystemPage", () => {
|
|||||||
vi.mocked(fetchFailedWebhookDeliveries).mockResolvedValue([]);
|
vi.mocked(fetchFailedWebhookDeliveries).mockResolvedValue([]);
|
||||||
vi.mocked(retryDomainEvent).mockResolvedValue(undefined);
|
vi.mocked(retryDomainEvent).mockResolvedValue(undefined);
|
||||||
vi.mocked(retryWebhookDelivery).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({
|
vi.mocked(fetchNotificationDeliveries).mockResolvedValue({
|
||||||
telegram: {
|
telegram: {
|
||||||
botNameSet: true,
|
botNameSet: true,
|
||||||
@@ -126,6 +144,20 @@ describe("SystemPage", () => {
|
|||||||
expect(fetchNotificationDeliveries).not.toHaveBeenCalled();
|
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 () => {
|
it("lists every job with its last run and status label", async () => {
|
||||||
renderPage();
|
renderPage();
|
||||||
|
|
||||||
@@ -137,7 +169,8 @@ describe("SystemPage", () => {
|
|||||||
expect(screen.getByText("Schlüssel umstellen")).toBeInTheDocument();
|
expect(screen.getByText("Schlüssel umstellen")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Erfolgreich")).toBeInTheDocument();
|
expect(screen.getByText("Erfolgreich")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Fehlgeschlagen")).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 () => {
|
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 { PageHeader } from "@/components/page-header";
|
||||||
import { QueryState } from "@/components/query-state";
|
import { QueryState } from "@/components/query-state";
|
||||||
import { TelegramCard } from "./telegram-card";
|
import { TelegramCard } from "./telegram-card";
|
||||||
|
import { VoiceCard } from "./voice-card";
|
||||||
|
|
||||||
const SYSTEM_QUERY_KEY = ["system"] as const;
|
const SYSTEM_QUERY_KEY = ["system"] as const;
|
||||||
const OVERVIEW_QUERY_KEY = [...SYSTEM_QUERY_KEY, "overview"] as const;
|
const OVERVIEW_QUERY_KEY = [...SYSTEM_QUERY_KEY, "overview"] as const;
|
||||||
@@ -305,6 +306,7 @@ export default function SystemPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
{/* The deliveries endpoint needs system:manage; a reader would only get a 403. */}
|
{/* The deliveries endpoint needs system:manage; a reader would only get a 403. */}
|
||||||
{canManage ? <TelegramCard /> : null}
|
{canManage ? <TelegramCard /> : null}
|
||||||
|
{canManage ? <VoiceCard /> : null}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</QueryState>
|
</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 { 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 userEvent from "@testing-library/user-event";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import axe from "axe-core";
|
import axe from "axe-core";
|
||||||
@@ -7,12 +7,48 @@ import VoiceSettingsPage from "./page";
|
|||||||
import { getCloudRecognitionConsent } from "@/lib/voice/consent";
|
import { getCloudRecognitionConsent } from "@/lib/voice/consent";
|
||||||
import { fetchSiteSettings } from "@/lib/site-settings/api";
|
import { fetchSiteSettings } from "@/lib/site-settings/api";
|
||||||
import type { SiteSettings } from "@/lib/site-settings/types";
|
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>();
|
let permissions = new Set<string>();
|
||||||
vi.mock("@/lib/auth/auth-context", () => ({
|
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/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 {
|
function settings(overrides: Partial<SiteSettings> = {}): SiteSettings {
|
||||||
return {
|
return {
|
||||||
@@ -33,11 +69,12 @@ function settings(overrides: Partial<SiteSettings> = {}): SiteSettings {
|
|||||||
|
|
||||||
function renderPage() {
|
function renderPage() {
|
||||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
return render(
|
const result = render(
|
||||||
<QueryClientProvider client={client}>
|
<QueryClientProvider client={client}>
|
||||||
<VoiceSettingsPage />
|
<VoiceSettingsPage />
|
||||||
</QueryClientProvider>,
|
</QueryClientProvider>,
|
||||||
);
|
);
|
||||||
|
return { ...result, client };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("VoiceSettingsPage", () => {
|
describe("VoiceSettingsPage", () => {
|
||||||
@@ -45,6 +82,106 @@ describe("VoiceSettingsPage", () => {
|
|||||||
window.localStorage.clear();
|
window.localStorage.clear();
|
||||||
permissions = new Set();
|
permissions = new Set();
|
||||||
vi.mocked(fetchSiteSettings).mockReset().mockResolvedValue(settings());
|
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 () => {
|
it("the cloud switch is off by default and stores the consent", async () => {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useSyncExternalStore } from "react";
|
import { useSyncExternalStore } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { ShieldCheck } from "lucide-react";
|
import { ShieldCheck } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { ConfirmDialog } from "@/components/confirm-dialog";
|
||||||
import { useAuth } from "@/lib/auth/auth-context";
|
import { useAuth } from "@/lib/auth/auth-context";
|
||||||
import { fetchSiteSettings } from "@/lib/site-settings/api";
|
import { fetchSiteSettings } from "@/lib/site-settings/api";
|
||||||
import {
|
import {
|
||||||
@@ -10,6 +12,15 @@ import {
|
|||||||
setCloudRecognitionConsent,
|
setCloudRecognitionConsent,
|
||||||
subscribeCloudRecognitionConsent,
|
subscribeCloudRecognitionConsent,
|
||||||
} from "@/lib/voice/consent";
|
} 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 { PageHeader } from "@/components/page-header";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import {
|
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() {
|
function HolderCard() {
|
||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
const canRead = hasPermission("site-settings:read");
|
const canRead = hasPermission("site-settings:read");
|
||||||
@@ -109,6 +242,8 @@ export default function VoiceSettingsPage() {
|
|||||||
<p className="text-sm">{RECORDING_NOTICE}</p>
|
<p className="text-sm">{RECORDING_NOTICE}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
<ConfigCard />
|
||||||
|
<DeleteAllCard />
|
||||||
<HolderCard />
|
<HolderCard />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
listQueue,
|
listQueue,
|
||||||
retryBulkWithout,
|
retryBulkWithout,
|
||||||
retryInspectionWithoutHiveChange,
|
retryInspectionWithoutHiveChange,
|
||||||
|
retryInspectionWithoutVoice,
|
||||||
type QueueItem,
|
type QueueItem,
|
||||||
type StoredPhoto,
|
type StoredPhoto,
|
||||||
} from "@/lib/offline/db";
|
} from "@/lib/offline/db";
|
||||||
@@ -20,6 +21,7 @@ vi.mock("@/lib/offline/db", () => ({
|
|||||||
discardQueueItem: vi.fn(),
|
discardQueueItem: vi.fn(),
|
||||||
retryBulkWithout: vi.fn(),
|
retryBulkWithout: vi.fn(),
|
||||||
retryInspectionWithoutHiveChange: vi.fn(),
|
retryInspectionWithoutHiveChange: vi.fn(),
|
||||||
|
retryInspectionWithoutVoice: vi.fn(),
|
||||||
}));
|
}));
|
||||||
vi.mock("@/lib/voice/pending-voice", () => ({
|
vi.mock("@/lib/voice/pending-voice", () => ({
|
||||||
discardPendingVoice: vi.fn(),
|
discardPendingVoice: vi.fn(),
|
||||||
@@ -101,6 +103,7 @@ describe("PendingQueueSheet", () => {
|
|||||||
vi.mocked(discardQueueItem).mockReset().mockResolvedValue(undefined);
|
vi.mocked(discardQueueItem).mockReset().mockResolvedValue(undefined);
|
||||||
vi.mocked(retryBulkWithout).mockReset().mockResolvedValue(undefined);
|
vi.mocked(retryBulkWithout).mockReset().mockResolvedValue(undefined);
|
||||||
vi.mocked(retryInspectionWithoutHiveChange).mockReset().mockResolvedValue(undefined);
|
vi.mocked(retryInspectionWithoutHiveChange).mockReset().mockResolvedValue(undefined);
|
||||||
|
vi.mocked(retryInspectionWithoutVoice).mockReset().mockResolvedValue(undefined);
|
||||||
vi.mocked(listPendingPhotos).mockReset().mockResolvedValue([]);
|
vi.mocked(listPendingPhotos).mockReset().mockResolvedValue([]);
|
||||||
vi.mocked(discardPendingVoice).mockReset().mockResolvedValue(undefined);
|
vi.mocked(discardPendingVoice).mockReset().mockResolvedValue(undefined);
|
||||||
vi.mocked(discardAllPendingVoice).mockReset().mockResolvedValue(2);
|
vi.mocked(discardAllPendingVoice).mockReset().mockResolvedValue(2);
|
||||||
@@ -165,6 +168,34 @@ describe("PendingQueueSheet", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("words a voice note of another colony and sends the inspection without it", async () => {
|
||||||
|
vi.mocked(listQueue).mockResolvedValue([
|
||||||
|
{
|
||||||
|
store: "pending-inspections",
|
||||||
|
id: 21,
|
||||||
|
label: "Durchsicht",
|
||||||
|
queuedAt: "2026-10-14T08:00:00.000Z",
|
||||||
|
canResendWithoutVoice: true,
|
||||||
|
lastError: {
|
||||||
|
status: 409,
|
||||||
|
message: "Die Sprachnotiz gehört zu einem anderen Volk.",
|
||||||
|
details: { code: "VOICE_CAPTURE_COLONY_MISMATCH" },
|
||||||
|
at: "2026-10-14T09:00:00.000Z",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
renderSheet();
|
||||||
|
const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" });
|
||||||
|
|
||||||
|
expect(await within(sheet).findByText(/Die Sprachnotiz gehört zu einem anderen Volk/)).toBeInTheDocument();
|
||||||
|
await userEvent.click(within(sheet).getByRole("button", { name: "Ohne Sprachnotiz senden" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(retryInspectionWithoutVoice).toHaveBeenCalledWith(21));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(syncPendingEntries).toHaveBeenCalledWith("user-1", { afterChange: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("lists a rejected hive change with the conflict text and only the discard action", async () => {
|
it("lists a rejected hive change with the conflict text and only the discard action", async () => {
|
||||||
vi.mocked(listQueue).mockResolvedValue([
|
vi.mocked(listQueue).mockResolvedValue([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
listQueue,
|
listQueue,
|
||||||
retryBulkWithout,
|
retryBulkWithout,
|
||||||
retryInspectionWithoutHiveChange,
|
retryInspectionWithoutHiveChange,
|
||||||
|
retryInspectionWithoutVoice,
|
||||||
type QueueError,
|
type QueueError,
|
||||||
type QueueItem,
|
type QueueItem,
|
||||||
} from "@/lib/offline/db";
|
} from "@/lib/offline/db";
|
||||||
@@ -289,6 +290,24 @@ export function PendingQueueSheet({
|
|||||||
onError: () => toast.error("Eintrag konnte nicht erneut gesendet werden."),
|
onError: () => toast.error("Eintrag konnte nicht erneut gesendet werden."),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// „Ohne Sprachnotiz senden“: only after the server said the note is another colony's.
|
||||||
|
const resendWithoutVoice = useMutation({
|
||||||
|
mutationFn: async (id: number) => {
|
||||||
|
await retryInspectionWithoutVoice(id);
|
||||||
|
return syncPendingEntries(userId, { afterChange: true });
|
||||||
|
},
|
||||||
|
networkMode: "always",
|
||||||
|
onSuccess: (result) => {
|
||||||
|
if (result.synced > 0) {
|
||||||
|
toast.success(entriesSynced(result.synced));
|
||||||
|
void queryClient.invalidateQueries();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Eintrag konnte nicht erneut gesendet werden."),
|
||||||
|
});
|
||||||
|
|
||||||
const records = items.filter((item) => item.store !== VOICE_STORE);
|
const records = items.filter((item) => item.store !== VOICE_STORE);
|
||||||
const voiceNotes = items.filter((item) => item.store === VOICE_STORE);
|
const voiceNotes = items.filter((item) => item.store === VOICE_STORE);
|
||||||
const names = entryNames(records);
|
const names = entryNames(records);
|
||||||
@@ -354,6 +373,19 @@ export function PendingQueueSheet({
|
|||||||
Ohne Beute-Änderung senden
|
Ohne Beute-Änderung senden
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
{item.canResendWithoutVoice && typeof item.id === "number" ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="touch"
|
||||||
|
className="self-start"
|
||||||
|
disabled={resendWithoutVoice.isPending}
|
||||||
|
onClick={() => resendWithoutVoice.mutate(item.id as number)}
|
||||||
|
>
|
||||||
|
<RotateCw aria-hidden />
|
||||||
|
Ohne Sprachnotiz senden
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
{item.resendWithout && typeof item.id === "number" ? (
|
{item.resendWithout && typeof item.id === "number" ? (
|
||||||
<ResendWithoutButton
|
<ResendWithoutButton
|
||||||
resend={item.resendWithout}
|
resend={item.resendWithout}
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { render, screen, within } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import axe from "axe-core";
|
||||||
|
import type { VoiceCaptureDetail, VoiceSuggestion } from "@/lib/voice/api";
|
||||||
|
import { VoiceCaptureBox } from "./voice-capture-box";
|
||||||
|
|
||||||
|
function suggestion(field: string, value: VoiceSuggestion["value"]): VoiceSuggestion {
|
||||||
|
return { field, value, quote: "gesagt", corrected: false, truncated: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
function capture(extra: Partial<VoiceCaptureDetail> = {}): VoiceCaptureDetail {
|
||||||
|
return {
|
||||||
|
id: "cap-1",
|
||||||
|
colonyId: "colony-1",
|
||||||
|
colonyName: "V-01",
|
||||||
|
status: "READY",
|
||||||
|
source: "AUDIO",
|
||||||
|
createdAt: "2026-10-14T08:00:00.000Z",
|
||||||
|
lastErrorCode: null,
|
||||||
|
contentExpiresAt: "2026-10-21T08:00:00.000Z",
|
||||||
|
inspectionDate: "2026-10-14",
|
||||||
|
transcript: "Königin gesehen, vier Brutwaben.",
|
||||||
|
suggestions: [suggestion("queenSeen", true), suggestion("broodFrames", 4)],
|
||||||
|
unclear: [],
|
||||||
|
dropped: 0,
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBox(
|
||||||
|
extra: Partial<VoiceCaptureDetail> = {},
|
||||||
|
handlers: { openCount?: number; onTakeOverAll?: () => void; onDiscard?: () => void } = {},
|
||||||
|
) {
|
||||||
|
const onTakeOverAll = handlers.onTakeOverAll ?? vi.fn();
|
||||||
|
const onDiscard = handlers.onDiscard ?? vi.fn();
|
||||||
|
const view = render(
|
||||||
|
<VoiceCaptureBox
|
||||||
|
capture={capture(extra)}
|
||||||
|
openCount={handlers.openCount ?? 2}
|
||||||
|
onTakeOverAll={onTakeOverAll}
|
||||||
|
onDiscard={onDiscard}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
return { ...view, onTakeOverAll, onDiscard };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("VoiceCaptureBox", () => {
|
||||||
|
it("names the day and the number of suggestions", () => {
|
||||||
|
renderBox();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Sprachnotiz vom 14.10., 2 Vorschläge" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says Vorschlag for one", () => {
|
||||||
|
renderBox({ suggestions: [suggestion("queenSeen", true)] }, { openCount: 1 });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Sprachnotiz vom 14.10., 1 Vorschlag" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Alle übernehmen asks first and names the count", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { onTakeOverAll } = renderBox({}, { openCount: 12 });
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Alle übernehmen" }));
|
||||||
|
|
||||||
|
expect(onTakeOverAll).not.toHaveBeenCalled();
|
||||||
|
const dialog = screen.getByRole("alertdialog");
|
||||||
|
expect(
|
||||||
|
within(dialog).getByText("12 Felder aus der Sprachnotiz übernehmen?"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
await user.click(within(dialog).getByRole("button", { name: "Übernehmen" }));
|
||||||
|
expect(onTakeOverAll).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Alle übernehmen can be cancelled and is gone when nothing is left to take", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { onTakeOverAll, rerender } = renderBox();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Alle übernehmen" }));
|
||||||
|
await user.click(
|
||||||
|
within(screen.getByRole("alertdialog")).getByRole("button", { name: "Abbrechen" }),
|
||||||
|
);
|
||||||
|
expect(onTakeOverAll).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<VoiceCaptureBox
|
||||||
|
capture={capture()}
|
||||||
|
openCount={0}
|
||||||
|
onTakeOverAll={onTakeOverAll}
|
||||||
|
onDiscard={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByRole("button", { name: "Alle übernehmen" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Sprachnotiz verwerfen asks first", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { onDiscard } = renderBox();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Sprachnotiz verwerfen" }));
|
||||||
|
expect(onDiscard).not.toHaveBeenCalled();
|
||||||
|
await user.click(
|
||||||
|
within(screen.getByRole("alertdialog")).getByRole("button", { name: "Verwerfen" }),
|
||||||
|
);
|
||||||
|
expect(onDiscard).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers what was said, as text", () => {
|
||||||
|
renderBox({ transcript: "<b>Königin</b> gesehen" });
|
||||||
|
|
||||||
|
const details = screen.getByText("Gesagt wurde:").closest("details");
|
||||||
|
expect(details).not.toBeNull();
|
||||||
|
expect(details).not.toHaveAttribute("open");
|
||||||
|
expect(within(details as HTMLElement).getByText("<b>Königin</b> gesehen")).toBeInTheDocument();
|
||||||
|
expect(details?.querySelector("b")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists what could not be assigned without a button", () => {
|
||||||
|
renderBox({ unclear: ["Etwas zur Futtermenge, Menge nicht genannt"] });
|
||||||
|
|
||||||
|
const list = screen.getByRole("list", { name: "Das konnte ich nicht zuordnen" });
|
||||||
|
expect(
|
||||||
|
within(list).getByText("Etwas zur Futtermenge, Menge nicht genannt"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(within(list).queryByRole("button")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says so when nothing clear was recognised and points to the text", () => {
|
||||||
|
renderBox({ suggestions: [], dropped: 3 }, { openCount: 0 });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText("Nichts Eindeutiges erkannt. Der Text steht darunter."),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Gesagt wurde:").closest("details")).toHaveAttribute("open");
|
||||||
|
expect(screen.queryByRole("button", { name: "Alle übernehmen" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["QUEUED", "Wird noch abgeschrieben"],
|
||||||
|
["TRANSCRIBING", "Wird noch abgeschrieben"],
|
||||||
|
["TRANSCRIBED", "Wird noch ausgewertet"],
|
||||||
|
["EXTRACTING", "Wird noch ausgewertet"],
|
||||||
|
] as const)("a %s note says it is still being worked on", (status, text) => {
|
||||||
|
renderBox({ status, suggestions: null, transcript: null });
|
||||||
|
|
||||||
|
expect(screen.getByText(text)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "Alle übernehmen" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a transcribed note whose evaluation is off shows the text open for typing it in", () => {
|
||||||
|
renderBox(
|
||||||
|
{ status: "TRANSCRIBED", lastErrorCode: "EXTRACT_DISABLED", suggestions: null },
|
||||||
|
{ openCount: 0 },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Auswertung gerade nicht möglich")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Die Auswertung ist ausgeschaltet/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Gesagt wurde:").closest("details")).toHaveAttribute("open");
|
||||||
|
expect(screen.getByText("Königin gesehen, vier Brutwaben.")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Sprachnotiz verwerfen" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a transcribed note shows the text-only state when the evaluation is switched off", () => {
|
||||||
|
render(
|
||||||
|
<VoiceCaptureBox
|
||||||
|
capture={capture({ status: "TRANSCRIBED", suggestions: null })}
|
||||||
|
openCount={0}
|
||||||
|
extractionEnabled={false}
|
||||||
|
onTakeOverAll={vi.fn()}
|
||||||
|
onDiscard={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Auswertung gerade nicht möglich")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Wird noch ausgewertet")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Gesagt wurde:").closest("details")).toHaveAttribute("open");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("focuses the box after Alle übernehmen, whose button is gone then", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderBox({}, { openCount: 2 });
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Alle übernehmen" }));
|
||||||
|
await user.click(
|
||||||
|
within(screen.getByRole("alertdialog")).getByRole("button", { name: "Übernehmen" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(screen.getByRole("region", { name: /^Sprachnotiz vom/ })).toHaveFocus(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a failed note with text shows the text; one without shows only the reason", () => {
|
||||||
|
const { unmount } = renderBox(
|
||||||
|
{ status: "FAILED", lastErrorCode: "EXTRACT_FAILED", suggestions: null },
|
||||||
|
{ openCount: 0 },
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Auswertung gerade nicht möglich")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Königin gesehen, vier Brutwaben.")).toBeInTheDocument();
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
renderBox(
|
||||||
|
{ status: "FAILED", lastErrorCode: "NO_SPEECH", suggestions: null, transcript: null },
|
||||||
|
{ openCount: 0 },
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/Es war nichts zu verstehen/)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Gesagt wurde:")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["CONFIRMED", "DISCARDED", "EXPIRED"] as const)(
|
||||||
|
"a %s note says it is gone and can still be left out",
|
||||||
|
(status) => {
|
||||||
|
renderBox({ status, suggestions: null, transcript: null }, { openCount: 0 });
|
||||||
|
|
||||||
|
expect(screen.getByText(/Die Sprachnotiz gibt es nicht mehr/)).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: "Sprachnotiz verwerfen" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("axe finds nothing", async () => {
|
||||||
|
const { container } = renderBox({ unclear: ["Etwas unklar"] });
|
||||||
|
|
||||||
|
const result = await axe.run(container, { rules: { "color-contrast": { enabled: false } } });
|
||||||
|
expect(result.violations).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import type { VoiceCaptureDetail } from "@/lib/voice/api";
|
||||||
|
import { voiceErrorText } from "@/lib/voice/status-texts";
|
||||||
|
import { VoiceConfirmDialog } from "./voice-confirm-dialog";
|
||||||
|
|
||||||
|
export const VOICE_CAPTURE_BOX_ID = "voice-capture-box";
|
||||||
|
|
||||||
|
interface VoiceCaptureBoxProps {
|
||||||
|
capture: VoiceCaptureDetail;
|
||||||
|
/** Suggestions still on offer (not taken, not ignored): what „Alle übernehmen“ would take. */
|
||||||
|
openCount: number;
|
||||||
|
onTakeOverAll: () => void;
|
||||||
|
onDiscard: () => void;
|
||||||
|
discarding?: boolean;
|
||||||
|
/** false: the operator switched the evaluation off; a transcribed note is then text only. */
|
||||||
|
extractionEnabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Dialog = "take-all" | "discard" | null;
|
||||||
|
|
||||||
|
const GONE_STATUSES: readonly string[] = ["CONFIRMED", "DISCARDED", "EXPIRED"];
|
||||||
|
const TRANSCRIBING_STATUSES: readonly string[] = ["QUEUED", "TRANSCRIBING"];
|
||||||
|
const EVALUATING_STATUSES: readonly string[] = ["TRANSCRIBED", "EXTRACTING"];
|
||||||
|
/** Codes after which the text exists, but no suggestions will come. */
|
||||||
|
const TEXT_ONLY_CODES: readonly string[] = ["EXTRACT_DISABLED", "DAILY_LIMIT"];
|
||||||
|
|
||||||
|
/** "2026-10-14" -> "14.10." */
|
||||||
|
function dayText(isoDay: string): string {
|
||||||
|
const [, month = "", day = ""] = isoDay.slice(0, 10).split("-");
|
||||||
|
return `${day}.${month}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function suggestionCount(count: number): string {
|
||||||
|
return count === 1 ? "1 Vorschlag" : `${count} Vorschläge`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The one line that says where this note stands, or null when its suggestions speak. */
|
||||||
|
function stateLine(
|
||||||
|
capture: VoiceCaptureDetail,
|
||||||
|
extractionEnabled: boolean,
|
||||||
|
): { text: string; detail?: string } | null {
|
||||||
|
const { status, lastErrorCode } = capture;
|
||||||
|
if (status === "TRANSCRIBED" && !extractionEnabled && !lastErrorCode) {
|
||||||
|
return { text: "Auswertung gerade nicht möglich", detail: voiceErrorText("EXTRACT_DISABLED") };
|
||||||
|
}
|
||||||
|
if (GONE_STATUSES.includes(status)) {
|
||||||
|
return { text: "Die Sprachnotiz gibt es nicht mehr. Du kannst die Durchsicht trotzdem speichern." };
|
||||||
|
}
|
||||||
|
if (status === "FAILED" && capture.transcript === null) {
|
||||||
|
return { text: voiceErrorText(lastErrorCode) };
|
||||||
|
}
|
||||||
|
if (status === "FAILED" || (status === "TRANSCRIBED" && lastErrorCode && TEXT_ONLY_CODES.includes(lastErrorCode))) {
|
||||||
|
return { text: "Auswertung gerade nicht möglich", detail: voiceErrorText(lastErrorCode) };
|
||||||
|
}
|
||||||
|
if (TRANSCRIBING_STATUSES.includes(status)) {
|
||||||
|
return { text: "Wird noch abgeschrieben" };
|
||||||
|
}
|
||||||
|
if (EVALUATING_STATUSES.includes(status)) {
|
||||||
|
return { text: "Wird noch ausgewertet" };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The voice note above the inspection form: how many suggestions it holds,
|
||||||
|
* what was said, what could not be assigned, and the two box-wide choices -
|
||||||
|
* all at once (asked first) or discard (asked first). It places nothing in the
|
||||||
|
* form itself. For a note without suggestions it is the way to read the text.
|
||||||
|
*/
|
||||||
|
export function VoiceCaptureBox({
|
||||||
|
capture,
|
||||||
|
openCount,
|
||||||
|
onTakeOverAll,
|
||||||
|
onDiscard,
|
||||||
|
discarding = false,
|
||||||
|
extractionEnabled = true,
|
||||||
|
}: VoiceCaptureBoxProps) {
|
||||||
|
const [dialog, setDialog] = useState<Dialog>(null);
|
||||||
|
const [copyNote, setCopyNote] = useState("");
|
||||||
|
const line = stateLine(capture, extractionEnabled);
|
||||||
|
const isGone = GONE_STATUSES.includes(capture.status);
|
||||||
|
const isReady = capture.status === "READY";
|
||||||
|
const suggestions = capture.suggestions ?? [];
|
||||||
|
const nothingFound = isReady && suggestions.length === 0;
|
||||||
|
const textOnly =
|
||||||
|
nothingFound ||
|
||||||
|
(line !== null && !isGone && capture.transcript !== null && line.detail !== undefined);
|
||||||
|
const heading = isReady
|
||||||
|
? `Sprachnotiz vom ${dayText(capture.inspectionDate)}, ${suggestionCount(suggestions.length)}`
|
||||||
|
: `Sprachnotiz vom ${dayText(capture.inspectionDate)}`;
|
||||||
|
|
||||||
|
async function copyText() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(capture.transcript ?? "");
|
||||||
|
setCopyNote("Text kopiert");
|
||||||
|
} catch {
|
||||||
|
setCopyNote("Kopieren nicht möglich – markiere den Text und kopiere ihn.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
setDialog(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The button that opened the dialog may be gone by now: the box takes the focus. */
|
||||||
|
function focusBox() {
|
||||||
|
window.setTimeout(() => document.getElementById(VOICE_CAPTURE_BOX_ID)?.focus(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
id={VOICE_CAPTURE_BOX_ID}
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-labelledby={`${VOICE_CAPTURE_BOX_ID}-title`}
|
||||||
|
className="flex scroll-mt-4 flex-col gap-3 rounded-xl border border-border bg-muted/40 p-3 outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||||
|
>
|
||||||
|
<h2 id={`${VOICE_CAPTURE_BOX_ID}-title`} className="font-heading text-base font-semibold">
|
||||||
|
{heading}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{line ? (
|
||||||
|
<div className="flex flex-col gap-1 text-sm" role="status">
|
||||||
|
<p className="font-medium">{line.text}</p>
|
||||||
|
{line.detail ? <p>{line.detail}</p> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{isReady && suggestions.length === 0 ? (
|
||||||
|
<p className="text-sm font-medium">Nichts Eindeutiges erkannt. Der Text steht darunter.</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{capture.transcript ? (
|
||||||
|
<details open={textOnly} className="rounded-lg border border-border bg-card p-3">
|
||||||
|
<summary className="flex min-h-11 cursor-pointer items-center text-sm font-medium">
|
||||||
|
Gesagt wurde:
|
||||||
|
</summary>
|
||||||
|
<p className="mt-2 whitespace-pre-wrap break-words text-sm select-text">{capture.transcript}</p>
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-3">
|
||||||
|
<Button type="button" variant="outline" size="touch" className="min-h-14" onClick={copyText}>
|
||||||
|
Text kopieren
|
||||||
|
</Button>
|
||||||
|
<span role="status" aria-live="polite" className="text-sm">
|
||||||
|
{copyNote}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isReady && capture.unclear.length > 0 ? (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p id={`${VOICE_CAPTURE_BOX_ID}-unclear`} className="text-sm font-medium">
|
||||||
|
Das konnte ich nicht zuordnen
|
||||||
|
</p>
|
||||||
|
<ul
|
||||||
|
aria-labelledby={`${VOICE_CAPTURE_BOX_ID}-unclear`}
|
||||||
|
className="list-disc pl-5 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
{capture.unclear.map((sentence) => (
|
||||||
|
<li key={sentence}>{sentence}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!isGone ? (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{isReady && openCount > 0 ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="touch"
|
||||||
|
className="min-h-14"
|
||||||
|
onClick={() => setDialog("take-all")}
|
||||||
|
>
|
||||||
|
Alle übernehmen
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="touch"
|
||||||
|
className="min-h-14"
|
||||||
|
disabled={discarding}
|
||||||
|
onClick={() => setDialog("discard")}
|
||||||
|
>
|
||||||
|
Sprachnotiz verwerfen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<VoiceConfirmDialog
|
||||||
|
open={dialog === "take-all"}
|
||||||
|
title={`${openCount === 1 ? "1 Feld" : `${openCount} Felder`} aus der Sprachnotiz übernehmen?`}
|
||||||
|
description="Schon ausgefüllte Felder werden ersetzt, Freitext wird angehängt. Danach kannst du jeden Wert noch ändern."
|
||||||
|
cancelLabel="Abbrechen"
|
||||||
|
confirmLabel="Übernehmen"
|
||||||
|
confirmVariant="default"
|
||||||
|
onCancel={close}
|
||||||
|
onConfirm={() => {
|
||||||
|
close();
|
||||||
|
onTakeOverAll();
|
||||||
|
focusBox();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<VoiceConfirmDialog
|
||||||
|
open={dialog === "discard"}
|
||||||
|
title="Sprachnotiz verwerfen?"
|
||||||
|
description="Text und Vorschläge werden gelöscht. Die Durchsicht bleibt, wie sie ist."
|
||||||
|
cancelLabel="Behalten"
|
||||||
|
confirmLabel="Verwerfen"
|
||||||
|
onCancel={close}
|
||||||
|
onConfirm={() => {
|
||||||
|
close();
|
||||||
|
onDiscard();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@ interface VoiceConfirmDialogProps {
|
|||||||
/** Leaves the note as it is. */
|
/** Leaves the note as it is. */
|
||||||
cancelLabel: string;
|
cancelLabel: string;
|
||||||
confirmLabel: string;
|
confirmLabel: string;
|
||||||
|
/** destructive (red) when something is thrown away; default when a choice is only confirmed. */
|
||||||
|
confirmVariant?: "destructive" | "default";
|
||||||
onCancel(): void;
|
onCancel(): void;
|
||||||
onConfirm(): void;
|
onConfirm(): void;
|
||||||
}
|
}
|
||||||
@@ -31,6 +33,7 @@ export function VoiceConfirmDialog({
|
|||||||
description,
|
description,
|
||||||
cancelLabel,
|
cancelLabel,
|
||||||
confirmLabel,
|
confirmLabel,
|
||||||
|
confirmVariant = "destructive",
|
||||||
onCancel,
|
onCancel,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
}: VoiceConfirmDialogProps) {
|
}: VoiceConfirmDialogProps) {
|
||||||
@@ -47,7 +50,7 @@ export function VoiceConfirmDialog({
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="destructive"
|
variant={confirmVariant}
|
||||||
size="touch"
|
size="touch"
|
||||||
className="min-h-14"
|
className="min-h-14"
|
||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import axe from "axe-core";
|
||||||
|
import { VoiceSuggestion, type VoiceSuggestionProps } from "./voice-suggestion";
|
||||||
|
|
||||||
|
function props(extra: Partial<VoiceSuggestionProps> = {}): VoiceSuggestionProps {
|
||||||
|
return {
|
||||||
|
fieldLabel: "Königin gesehen",
|
||||||
|
valueText: "Königin gesehen: Ja",
|
||||||
|
quote: "Königin hab ich gesehen",
|
||||||
|
onTakeOver: vi.fn(),
|
||||||
|
onEdit: vi.fn(),
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A parent that behaves like the form: taking over flips the row to „taken“. */
|
||||||
|
function Harness({ onEdit = vi.fn() }: { onEdit?: () => void }) {
|
||||||
|
const [status, setStatus] = useState<"open" | "taken" | "ignored">("open");
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<input aria-label="Feld" />
|
||||||
|
<VoiceSuggestion
|
||||||
|
{...props({ onEdit })}
|
||||||
|
status={status}
|
||||||
|
onTakeOver={() => setStatus("taken")}
|
||||||
|
onUndo={() => setStatus("open")}
|
||||||
|
onIgnore={() => setStatus("ignored")}
|
||||||
|
onRestore={() => setStatus("open")}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("VoiceSuggestion", () => {
|
||||||
|
it("shows the value in words and the quote visibly marked as spoken", () => {
|
||||||
|
render(<VoiceSuggestion {...props()} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Königin gesehen: Ja")).toBeInTheDocument();
|
||||||
|
const quote = screen.getByText(/Königin hab ich gesehen/);
|
||||||
|
expect(quote.closest("blockquote")).not.toBeNull();
|
||||||
|
expect(quote).toHaveTextContent("„Königin hab ich gesehen“");
|
||||||
|
expect(screen.getByText("Aus Sprache")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Gesagt:")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows chips as words", () => {
|
||||||
|
render(<VoiceSuggestion {...props({ chips: ["Korrigiert", "Zuvor genannt: Nein"] })} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Korrigiert")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Zuvor genannt: Nein")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the field on each button so a screen reader hears what it acts on", () => {
|
||||||
|
render(<VoiceSuggestion {...props()} />);
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: /^Übernehmen.*Königin gesehen/ })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: /^Ändern.*Königin gesehen/ })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Übernehmen sets the value, says so in a status region and keeps the focus where it was", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<Harness />);
|
||||||
|
const take = screen.getByRole("button", { name: /^Übernehmen/ });
|
||||||
|
|
||||||
|
await user.tab(); // the field
|
||||||
|
await user.tab(); // Übernehmen
|
||||||
|
expect(take).toHaveFocus();
|
||||||
|
await user.keyboard("{Enter}");
|
||||||
|
|
||||||
|
expect(screen.getByRole("status")).toHaveTextContent("Übernommen");
|
||||||
|
// The same element carries on as „Rückgängig“: the focus never falls to the page.
|
||||||
|
expect(screen.getByRole("button", { name: /^Rückgängig/ })).toHaveFocus();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Rückgängig takes the value back and offers it again", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<Harness />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: /^Übernehmen/ }));
|
||||||
|
await user.click(screen.getByRole("button", { name: /^Rückgängig/ }));
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: /^Übernehmen/ })).toHaveFocus();
|
||||||
|
expect(screen.getByRole("status")).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Ändern calls onEdit", async () => {
|
||||||
|
const onEdit = vi.fn();
|
||||||
|
render(<Harness onEdit={onEdit} />);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /^Ändern/ }));
|
||||||
|
|
||||||
|
expect(onEdit).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Ignorieren hides the choices behind one button and keeps the focus on it", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<Harness />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: /^Ignorieren/ }));
|
||||||
|
|
||||||
|
expect(screen.getByRole("status")).toHaveTextContent("Ignoriert");
|
||||||
|
expect(screen.queryByRole("button", { name: /^Ändern/ })).not.toBeInTheDocument();
|
||||||
|
const restore = screen.getByRole("button", { name: /^Wieder anzeigen/ });
|
||||||
|
expect(restore).toHaveFocus();
|
||||||
|
await user.click(restore);
|
||||||
|
expect(screen.getByRole("button", { name: /^Übernehmen/ })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("works with the keyboard only", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onEdit = vi.fn();
|
||||||
|
render(<Harness onEdit={onEdit} />);
|
||||||
|
|
||||||
|
await user.tab();
|
||||||
|
await user.tab();
|
||||||
|
await user.tab();
|
||||||
|
expect(screen.getByRole("button", { name: /^Ändern/ })).toHaveFocus();
|
||||||
|
await user.keyboard(" ");
|
||||||
|
expect(onEdit).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("suggestion text is rendered as text, never as HTML", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<VoiceSuggestion
|
||||||
|
{...props({
|
||||||
|
valueText: "Notizen: <img src=x onerror=alert(1)>",
|
||||||
|
quote: "<script>alert(1)</script>",
|
||||||
|
chips: ["<b>fett</b>"],
|
||||||
|
})}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.querySelector("img, script, b")).toBeNull();
|
||||||
|
expect(screen.getByText(/<script>alert\(1\)<\/script>/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("<b>fett</b>")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives every button at least 56 px of height", () => {
|
||||||
|
render(<Harness />);
|
||||||
|
|
||||||
|
for (const button of screen.getAllByRole("button")) {
|
||||||
|
expect(button.className).toMatch(/min-h-14/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("axe finds nothing", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { container } = render(<Harness />);
|
||||||
|
const run = () => axe.run(container, { rules: { "color-contrast": { enabled: false } } });
|
||||||
|
|
||||||
|
expect((await run()).violations).toEqual([]);
|
||||||
|
await user.click(screen.getByRole("button", { name: /^Übernehmen/ }));
|
||||||
|
expect((await run()).violations).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef } from "react";
|
||||||
|
import { Mic } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export type VoiceSuggestionStatus = "open" | "taken" | "ignored";
|
||||||
|
|
||||||
|
export interface VoiceSuggestionProps {
|
||||||
|
/** Name of the field, spoken after each button so it is unambiguous. */
|
||||||
|
fieldLabel: string;
|
||||||
|
/** The value in words, e.g. "Königin gesehen: Ja". */
|
||||||
|
valueText: string;
|
||||||
|
/** What was said, word for word. */
|
||||||
|
quote: string;
|
||||||
|
/** Short words next to the value, e.g. "Korrigiert", "Zuvor genannt: Nein". */
|
||||||
|
chips?: readonly string[];
|
||||||
|
/** open: choose; taken: value is in the field; ignored: put aside. */
|
||||||
|
status?: VoiceSuggestionStatus;
|
||||||
|
onTakeOver: () => void;
|
||||||
|
/** Puts the value in and moves the focus to the field. */
|
||||||
|
onEdit: () => void;
|
||||||
|
onUndo?: () => void;
|
||||||
|
onIgnore?: () => void;
|
||||||
|
onRestore?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_WORDS: Record<VoiceSuggestionStatus, string> = {
|
||||||
|
open: "",
|
||||||
|
taken: "Übernommen",
|
||||||
|
ignored: "Ignoriert",
|
||||||
|
};
|
||||||
|
|
||||||
|
const BUTTON_CLASS = "min-h-14";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One suggestion from a voice note, under its field: the value in words, what
|
||||||
|
* was said in quotation marks, and the choices. Nothing is filled in until
|
||||||
|
* „Übernehmen“. The first button stays the same element through every state
|
||||||
|
* (Übernehmen, Rückgängig, Wieder anzeigen), so the keyboard focus never falls
|
||||||
|
* back to the page when a choice is made. The status line is a live region
|
||||||
|
* that is always there, or its first message would not be read out.
|
||||||
|
*/
|
||||||
|
export function VoiceSuggestion({
|
||||||
|
fieldLabel,
|
||||||
|
valueText,
|
||||||
|
quote,
|
||||||
|
chips = [],
|
||||||
|
status = "open",
|
||||||
|
onTakeOver,
|
||||||
|
onEdit,
|
||||||
|
onUndo,
|
||||||
|
onIgnore,
|
||||||
|
onRestore,
|
||||||
|
}: VoiceSuggestionProps) {
|
||||||
|
const mainRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
const main = {
|
||||||
|
open: { label: "Übernehmen", onClick: onTakeOver },
|
||||||
|
taken: { label: "Rückgängig", onClick: onUndo ?? onTakeOver },
|
||||||
|
ignored: { label: "Wieder anzeigen", onClick: onRestore ?? onTakeOver },
|
||||||
|
}[status];
|
||||||
|
|
||||||
|
function ignore() {
|
||||||
|
onIgnore?.();
|
||||||
|
// The „Ignorieren“ button leaves with this click: its place is the first button.
|
||||||
|
mainRef.current?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
aria-label={`Vorschlag aus Sprache: ${fieldLabel}`}
|
||||||
|
className="flex flex-col gap-2 rounded-lg border border-l-4 border-border border-l-primary bg-muted/40 p-3"
|
||||||
|
>
|
||||||
|
<p className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||||
|
<Mic className="size-3.5 shrink-0" aria-hidden />
|
||||||
|
<span>Aus Sprache</span>
|
||||||
|
</p>
|
||||||
|
<p className="font-medium">{valueText}</p>
|
||||||
|
{status !== "ignored" ? (
|
||||||
|
<blockquote className="border-l-2 border-border pl-3 text-sm italic text-muted-foreground">
|
||||||
|
<span className="not-italic font-medium">Gesagt:</span> „{quote}“
|
||||||
|
</blockquote>
|
||||||
|
) : null}
|
||||||
|
{chips.length > 0 && status !== "ignored" ? (
|
||||||
|
<ul className="flex flex-wrap gap-1.5">
|
||||||
|
{chips.map((chip) => (
|
||||||
|
<li key={chip} className="rounded-full border border-border px-2 py-0.5 text-xs">
|
||||||
|
{chip}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button
|
||||||
|
ref={mainRef}
|
||||||
|
type="button"
|
||||||
|
variant={status === "open" ? "default" : "outline"}
|
||||||
|
size="touch"
|
||||||
|
className={BUTTON_CLASS}
|
||||||
|
onClick={main.onClick}
|
||||||
|
>
|
||||||
|
{main.label}
|
||||||
|
<span className="sr-only"> – {fieldLabel}</span>
|
||||||
|
</Button>
|
||||||
|
{status !== "ignored" ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="touch"
|
||||||
|
className={BUTTON_CLASS}
|
||||||
|
onClick={onEdit}
|
||||||
|
>
|
||||||
|
Ändern
|
||||||
|
<span className="sr-only"> – {fieldLabel}</span>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{status === "open" && onIgnore ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="touch"
|
||||||
|
className={BUTTON_CLASS}
|
||||||
|
onClick={ignore}
|
||||||
|
>
|
||||||
|
Ignorieren
|
||||||
|
<span className="sr-only"> – {fieldLabel}</span>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<span role="status" aria-live="polite" className="text-sm font-medium">
|
||||||
|
{STATUS_WORDS[status]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { ApiError, apiFetch } from "@/lib/auth/api-client";
|
||||||
|
import { createInspection } from "./api";
|
||||||
|
import type { CreateInspectionInput } from "./types";
|
||||||
|
|
||||||
|
vi.mock("@/lib/auth/api-client", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("@/lib/auth/api-client")>()),
|
||||||
|
apiFetch: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const INPUT: CreateInspectionInput = {
|
||||||
|
colonyId: "colony-1",
|
||||||
|
date: "2026-10-14",
|
||||||
|
queenSeen: true,
|
||||||
|
notes: "ruhig",
|
||||||
|
voiceCaptureId: "capture-1",
|
||||||
|
};
|
||||||
|
|
||||||
|
function bodyOf(call: number): Record<string, unknown> {
|
||||||
|
const init = vi.mocked(apiFetch).mock.calls[call][1] as RequestInit;
|
||||||
|
return JSON.parse(String(init.body)) as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyOf(call: number): unknown {
|
||||||
|
const init = vi.mocked(apiFetch).mock.calls[call][1] as RequestInit;
|
||||||
|
return (init.headers as Record<string, string>)["Idempotency-Key"];
|
||||||
|
}
|
||||||
|
|
||||||
|
const gone = () =>
|
||||||
|
new ApiError(409, "Die Sprachnotiz ist nicht mehr verfügbar.", {
|
||||||
|
code: "VOICE_CAPTURE_GONE",
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createInspection", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(apiFetch).mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends the voice capture id with the inspection", async () => {
|
||||||
|
vi.mocked(apiFetch).mockResolvedValue({ id: "i-1" });
|
||||||
|
|
||||||
|
const created = await createInspection(INPUT, { idempotencyKey: "key-1" });
|
||||||
|
|
||||||
|
expect(bodyOf(0)).toMatchObject({ voiceCaptureId: "capture-1", notes: "ruhig" });
|
||||||
|
expect(created).not.toHaveProperty("voiceCaptureGone");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a 409 VOICE_CAPTURE_GONE retries once without the id and keeps every input", async () => {
|
||||||
|
vi.mocked(apiFetch).mockRejectedValueOnce(gone()).mockResolvedValueOnce({ id: "i-1" });
|
||||||
|
|
||||||
|
const created = await createInspection(INPUT, { idempotencyKey: "key-1" });
|
||||||
|
|
||||||
|
expect(apiFetch).toHaveBeenCalledTimes(2);
|
||||||
|
expect(bodyOf(1)).toEqual({ ...bodyOf(0), voiceCaptureId: undefined });
|
||||||
|
expect(bodyOf(1)).not.toHaveProperty("voiceCaptureId");
|
||||||
|
expect(bodyOf(1)).toMatchObject({ queenSeen: true, notes: "ruhig", colonyId: "colony-1" });
|
||||||
|
// The first try failed and released its key: a repeat after a lost answer must still find the first record.
|
||||||
|
expect(keyOf(1)).toBe("key-1");
|
||||||
|
expect(created).toMatchObject({ id: "i-1", voiceCaptureGone: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries only once", async () => {
|
||||||
|
vi.mocked(apiFetch).mockRejectedValue(gone());
|
||||||
|
|
||||||
|
await expect(createInspection(INPUT, { idempotencyKey: "key-1" })).rejects.toBeInstanceOf(
|
||||||
|
ApiError,
|
||||||
|
);
|
||||||
|
expect(apiFetch).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never retries a colony mismatch", async () => {
|
||||||
|
const mismatch = new ApiError(409, "Andere Volk", { code: "VOICE_CAPTURE_COLONY_MISMATCH" });
|
||||||
|
vi.mocked(apiFetch).mockRejectedValue(mismatch);
|
||||||
|
|
||||||
|
await expect(createInspection(INPUT)).rejects.toBe(mismatch);
|
||||||
|
expect(apiFetch).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not retry other conflicts or inspections without a voice note", async () => {
|
||||||
|
vi.mocked(apiFetch).mockRejectedValue(gone());
|
||||||
|
const withoutVoice = { ...INPUT };
|
||||||
|
delete withoutVoice.voiceCaptureId;
|
||||||
|
|
||||||
|
await expect(createInspection(withoutVoice)).rejects.toBeInstanceOf(ApiError);
|
||||||
|
expect(apiFetch).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
vi.mocked(apiFetch).mockReset().mockRejectedValue(new ApiError(409, "bereits verarbeitet"));
|
||||||
|
await expect(createInspection(INPUT)).rejects.toBeInstanceOf(ApiError);
|
||||||
|
expect(apiFetch).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { apiFetch } from "@/lib/auth/api-client";
|
import { ApiError, apiFetch } from "@/lib/auth/api-client";
|
||||||
import { CreateOptions, idempotencyHeaders } from "@/lib/offline/idempotency";
|
import { CreateOptions, idempotencyHeaders } from "@/lib/offline/idempotency";
|
||||||
import {
|
import {
|
||||||
CreateInspectionInput,
|
CreateInspectionInput,
|
||||||
@@ -13,9 +13,9 @@ export function fetchColonyInspections(colonyId: string): Promise<Inspection[]>
|
|||||||
return apiFetch<Inspection[]>(`/colonies/${encodeURIComponent(colonyId)}/inspections`);
|
return apiFetch<Inspection[]>(`/colonies/${encodeURIComponent(colonyId)}/inspections`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createInspection(
|
function postInspection(
|
||||||
input: CreateInspectionInput,
|
input: CreateInspectionInput,
|
||||||
options: CreateOptions = {},
|
options: CreateOptions,
|
||||||
): Promise<CreatedInspection> {
|
): Promise<CreatedInspection> {
|
||||||
return apiFetch<CreatedInspection>("/inspections", {
|
return apiFetch<CreatedInspection>("/inspections", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -24,6 +24,41 @@ export function createInspection(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isVoiceCaptureGone(error: unknown): boolean {
|
||||||
|
return (
|
||||||
|
error instanceof ApiError &&
|
||||||
|
error.status === 409 &&
|
||||||
|
(error.body as { code?: unknown } | null)?.code === "VOICE_CAPTURE_GONE"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves an inspection. When it carries a voice note that is gone by now
|
||||||
|
* (confirmed elsewhere, discarded, expired: 409 VOICE_CAPTURE_GONE), it is
|
||||||
|
* sent once more without the note and with the same input - what the
|
||||||
|
* beekeeper entered is never lost for a note they may not even have used.
|
||||||
|
* This also holds for queued inspections, which sync through this function.
|
||||||
|
* The failed try released its key, so the repeat keeps it: a lost answer to
|
||||||
|
* the repeat is then recognised on the next send instead of saved twice.
|
||||||
|
* A note of another colony (VOICE_CAPTURE_COLONY_MISMATCH) is never retried
|
||||||
|
* on its own: leaving a note out is the user's decision.
|
||||||
|
*/
|
||||||
|
export async function createInspection(
|
||||||
|
input: CreateInspectionInput,
|
||||||
|
options: CreateOptions = {},
|
||||||
|
): Promise<CreatedInspection> {
|
||||||
|
try {
|
||||||
|
return await postInspection(input, options);
|
||||||
|
} catch (error) {
|
||||||
|
if (input.voiceCaptureId === undefined || !isVoiceCaptureGone(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const withoutVoice = { ...input };
|
||||||
|
delete withoutVoice.voiceCaptureId;
|
||||||
|
return { ...(await postInspection(withoutVoice, options)), voiceCaptureGone: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function updateInspection(
|
export function updateInspection(
|
||||||
id: string,
|
id: string,
|
||||||
input: UpdateInspectionInput,
|
input: UpdateInspectionInput,
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
INSPECTION_SCALE_DIRECTIONS,
|
||||||
|
INSPECTION_SCALE_LABELS,
|
||||||
|
type InspectionScaleField,
|
||||||
|
} from "./scales";
|
||||||
|
|
||||||
|
interface ScaleFixture {
|
||||||
|
field: string;
|
||||||
|
storedOne: string;
|
||||||
|
storedFive: string;
|
||||||
|
anchors: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The one source both sides read: the backend prompt and this form.
|
||||||
|
const FIXTURE_PATH = path.resolve(
|
||||||
|
process.cwd(),
|
||||||
|
"../ApiCore-Backend/test/fixtures/voice/inspection-scales.json",
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Which stored end is the good one - a fact about beekeeping, not derivable from the words. */
|
||||||
|
const GOOD_STORED_END: Record<InspectionScaleField, 1 | 5> = {
|
||||||
|
broodPattern: 5,
|
||||||
|
swarmMood: 1,
|
||||||
|
gentleness: 5,
|
||||||
|
combBehavior: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
// The frontend image builds without the backend folder: then there is nothing to compare.
|
||||||
|
describe.skipIf(!existsSync(FIXTURE_PATH))("inspection scales", () => {
|
||||||
|
const fixture = JSON.parse(readFileSync(FIXTURE_PATH, "utf8")) as ScaleFixture[];
|
||||||
|
|
||||||
|
it("scale directions equal the shared fixture", () => {
|
||||||
|
expect(Object.keys(INSPECTION_SCALE_DIRECTIONS).sort()).toEqual(
|
||||||
|
fixture.map((scale) => scale.field).sort(),
|
||||||
|
);
|
||||||
|
for (const scale of fixture) {
|
||||||
|
const field = scale.field as InspectionScaleField;
|
||||||
|
expect(INSPECTION_SCALE_LABELS[field], field).toEqual({
|
||||||
|
lowLabel: scale.storedOne,
|
||||||
|
highLabel: scale.storedFive,
|
||||||
|
});
|
||||||
|
expect(scale.anchors[scale.storedOne], field).toBe(1);
|
||||||
|
expect(scale.anchors[scale.storedFive], field).toBe(5);
|
||||||
|
expect(INSPECTION_SCALE_DIRECTIONS[field], field).toBe(
|
||||||
|
GOOD_STORED_END[field] === 5 ? "higher-is-better" : "lower-is-better",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("inspection scales (without the fixture)", () => {
|
||||||
|
it("mirrors only the swarm mood, whose stored 1 is the calm end", () => {
|
||||||
|
const mirrored = Object.entries(INSPECTION_SCALE_DIRECTIONS)
|
||||||
|
.filter(([, direction]) => direction === "lower-is-better")
|
||||||
|
.map(([field]) => field);
|
||||||
|
expect(mirrored).toEqual(["swarmMood"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { RatingDirection } from "@/lib/forms/rating";
|
||||||
|
|
||||||
|
export type InspectionScaleField = "broodPattern" | "swarmMood" | "gentleness" | "combBehavior";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Words for the STORED ends of the four 1-5 scales (1 = lowLabel, 5 = highLabel).
|
||||||
|
* The form and the voice prompt (ApiCore-Backend test/fixtures/voice/inspection-scales.json)
|
||||||
|
* must say the same: a mismatch would turn a spoken "ruhig" into the wrong number.
|
||||||
|
*/
|
||||||
|
export const INSPECTION_SCALE_LABELS: Readonly<
|
||||||
|
Record<InspectionScaleField, { lowLabel: string; highLabel: string }>
|
||||||
|
> = {
|
||||||
|
broodPattern: { lowLabel: "lückenhaft", highLabel: "geschlossen" },
|
||||||
|
swarmMood: { lowLabel: "ruhig", highLabel: "stark" },
|
||||||
|
gentleness: { lowLabel: "stechlustig", highLabel: "sehr sanft" },
|
||||||
|
combBehavior: { lowLabel: "läuft weg", highLabel: "sitzt ruhig" },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Which stored end is the good one. Only the swarm mood is stored the other way round. */
|
||||||
|
export const INSPECTION_SCALE_DIRECTIONS: Readonly<Record<InspectionScaleField, RatingDirection>> =
|
||||||
|
{
|
||||||
|
broodPattern: "higher-is-better",
|
||||||
|
swarmMood: "lower-is-better",
|
||||||
|
gentleness: "higher-is-better",
|
||||||
|
combBehavior: "higher-is-better",
|
||||||
|
};
|
||||||
@@ -49,6 +49,11 @@ export interface ColonyTakeover {
|
|||||||
/** The answer to a new inspection: the inspection plus what reached the colony card. */
|
/** The answer to a new inspection: the inspection plus what reached the colony card. */
|
||||||
export interface CreatedInspection extends Inspection {
|
export interface CreatedInspection extends Inspection {
|
||||||
colonyTakeover?: ColonyTakeover;
|
colonyTakeover?: ColonyTakeover;
|
||||||
|
/**
|
||||||
|
* Set by the client, never by the server: the voice note was gone (409
|
||||||
|
* VOICE_CAPTURE_GONE) and the inspection was saved once more without it.
|
||||||
|
*/
|
||||||
|
voiceCaptureGone?: true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateInspectionInput {
|
export interface CreateInspectionInput {
|
||||||
@@ -86,6 +91,12 @@ export interface CreateInspectionInput {
|
|||||||
* (403 HIVE_FORBIDDEN otherwise). Sent only when there are ops.
|
* (403 HIVE_FORBIDDEN otherwise). Sent only when there are ops.
|
||||||
*/
|
*/
|
||||||
hiveChange?: { ops: HiveOp[] };
|
hiveChange?: { ops: HiveOp[] };
|
||||||
|
/**
|
||||||
|
* The voice note this inspection was reviewed from. The server confirms it
|
||||||
|
* and deletes its content in the same transaction (409 VOICE_CAPTURE_GONE /
|
||||||
|
* VOICE_CAPTURE_COLONY_MISMATCH when it cannot). Creating only; never drafted.
|
||||||
|
*/
|
||||||
|
voiceCaptureId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `null` clears an optional field, an omitted one stays unchanged. */
|
/** `null` clears an optional field, an omitted one stays unchanged. */
|
||||||
|
|||||||
@@ -361,6 +361,48 @@ describe("offline queue database", () => {
|
|||||||
expect(items.map((item) => item.canResendWithoutHiveChange ?? false)).toEqual([false, true]);
|
expect(items.map((item) => item.canResendWithoutHiveChange ?? false)).toEqual([false, true]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("retryInspectionWithoutVoice strips the voice note, clears the rejection and keeps the key and the rest", async () => {
|
||||||
|
const db = await loadDb();
|
||||||
|
await db.queueInspection({ ...INSPECTION, voiceCaptureId: "cap-1" }, "key-v", "user-1");
|
||||||
|
const [entry] = await db.getPendingEntries("pending-inspections");
|
||||||
|
await db.markFailed("pending-inspections", entry.id, {
|
||||||
|
status: 409,
|
||||||
|
message: "Die Sprachnotiz gehört zu einem anderen Volk.",
|
||||||
|
details: { code: "VOICE_CAPTURE_COLONY_MISMATCH" },
|
||||||
|
at: REJECTION.at,
|
||||||
|
});
|
||||||
|
expect((await db.listQueue("user-1"))[0].canResendWithoutVoice).toBe(true);
|
||||||
|
|
||||||
|
await db.retryInspectionWithoutVoice(entry.id);
|
||||||
|
|
||||||
|
const [after] = await db.getPendingEntries("pending-inspections");
|
||||||
|
expect(after.input).toEqual(INSPECTION);
|
||||||
|
expect(after.lastError).toBeUndefined();
|
||||||
|
expect(after.idempotencyKey).toBe("key-v");
|
||||||
|
expect((await db.listQueue("user-1"))[0].canResendWithoutVoice).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers „ohne Sprachnotiz“ only for a colony mismatch of an inspection that carries a note", async () => {
|
||||||
|
const db = await loadDb();
|
||||||
|
await db.queueInspection({ ...INSPECTION, voiceCaptureId: "cap-1" }, "k1", "user-1");
|
||||||
|
await db.queueInspection({ ...INSPECTION, voiceCaptureId: "cap-2" }, "k2", "user-1");
|
||||||
|
await db.queueInspection(INSPECTION, "k3", "user-1");
|
||||||
|
const [other, mismatch, plain] = await db.getPendingEntries("pending-inspections");
|
||||||
|
await db.markFailed("pending-inspections", other.id, REJECTION);
|
||||||
|
const mismatchError = {
|
||||||
|
status: 409,
|
||||||
|
message: "x",
|
||||||
|
details: { code: "VOICE_CAPTURE_COLONY_MISMATCH" },
|
||||||
|
at: REJECTION.at,
|
||||||
|
};
|
||||||
|
await db.markFailed("pending-inspections", mismatch.id, mismatchError);
|
||||||
|
await db.markFailed("pending-inspections", plain.id, mismatchError);
|
||||||
|
|
||||||
|
const items = await db.listQueue("user-1");
|
||||||
|
|
||||||
|
expect(items.map((item) => item.canResendWithoutVoice ?? false)).toEqual([false, true, false]);
|
||||||
|
});
|
||||||
|
|
||||||
it("summarizeQueue separates own, failed and foreign entries", async () => {
|
it("summarizeQueue separates own, failed and foreign entries", async () => {
|
||||||
const db = await loadDb();
|
const db = await loadDb();
|
||||||
await db.queueInspection(INSPECTION, "key-1", "user-1");
|
await db.queueInspection(INSPECTION, "key-1", "user-1");
|
||||||
|
|||||||
@@ -558,6 +558,37 @@ export async function retryInspectionWithoutHiveChange(entryId: number): Promise
|
|||||||
await tx.done;
|
await tx.done;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The refusal for a voice note that belongs to another colony than the inspection. */
|
||||||
|
function isVoiceColonyMismatch(error: QueueError | undefined): boolean {
|
||||||
|
const code = (error?.details as { code?: unknown } | null | undefined)?.code;
|
||||||
|
return error?.status === 409 && code === "VOICE_CAPTURE_COLONY_MISMATCH";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A queued inspection that carries a voice note. */
|
||||||
|
export function hasVoiceCapture(input: unknown): boolean {
|
||||||
|
return (input as { voiceCaptureId?: unknown } | null)?.voiceCaptureId !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ohne Sprachnotiz senden: takes the voice note out of a queued inspection and
|
||||||
|
* clears its rejection, so the next sync run sends the rest. The key stays:
|
||||||
|
* the server stored nothing for the rejected try. Only the user chooses this -
|
||||||
|
* a note of another colony is never left out on its own.
|
||||||
|
*/
|
||||||
|
export async function retryInspectionWithoutVoice(entryId: number): Promise<void> {
|
||||||
|
const db = await getDb();
|
||||||
|
const tx = db.transaction("pending-inspections", "readwrite");
|
||||||
|
const entry = await tx.store.get(entryId);
|
||||||
|
if (entry && hasVoiceCapture(entry.input)) {
|
||||||
|
const input = { ...entry.input };
|
||||||
|
delete input.voiceCaptureId;
|
||||||
|
const cleared: PendingEntry<CreateInspectionInput> = { ...entry, input };
|
||||||
|
delete cleared.lastError;
|
||||||
|
await tx.store.put(cleared);
|
||||||
|
}
|
||||||
|
await tx.done;
|
||||||
|
}
|
||||||
|
|
||||||
export interface QueueItem {
|
export interface QueueItem {
|
||||||
store: string;
|
store: string;
|
||||||
id: number | string;
|
id: number | string;
|
||||||
@@ -577,6 +608,8 @@ export interface QueueItem {
|
|||||||
hasHiveChange?: boolean;
|
hasHiveChange?: boolean;
|
||||||
/** A rejected inspection whose hive change can be left out and the rest sent. */
|
/** A rejected inspection whose hive change can be left out and the rest sent. */
|
||||||
canResendWithoutHiveChange?: boolean;
|
canResendWithoutHiveChange?: boolean;
|
||||||
|
/** A rejected inspection whose voice note belongs to another colony: it can go without the note. */
|
||||||
|
canResendWithoutVoice?: boolean;
|
||||||
/**
|
/**
|
||||||
* A rejected bulk entry that can go out without the colonies the server
|
* A rejected bulk entry that can go out without the colonies the server
|
||||||
* refused (`codes`: the ones named in the loaded packs, maybe fewer).
|
* refused (`codes`: the ones named in the loaded packs, maybe fewer).
|
||||||
@@ -674,15 +707,21 @@ export async function listQueue(currentUserId: string): Promise<QueueItem[]> {
|
|||||||
...(isHiveRefusal(entry.lastError) ? { canResendWithoutHiveChange: true } : {}),
|
...(isHiveRefusal(entry.lastError) ? { canResendWithoutHiveChange: true } : {}),
|
||||||
}
|
}
|
||||||
: {};
|
: {};
|
||||||
|
const voice =
|
||||||
|
hasVoiceCapture((entry as PendingEntry<unknown>).input) && isVoiceColonyMismatch(entry.lastError)
|
||||||
|
? { canResendWithoutVoice: true }
|
||||||
|
: {};
|
||||||
if (!entry.idempotencyKey) {
|
if (!entry.idempotencyKey) {
|
||||||
return { ...item, ...hive };
|
return { ...item, ...hive, ...voice };
|
||||||
}
|
}
|
||||||
const photoCount = await db.countFromIndex(
|
const photoCount = await db.countFromIndex(
|
||||||
"pending-photos",
|
"pending-photos",
|
||||||
"inspectionIdempotencyKey",
|
"inspectionIdempotencyKey",
|
||||||
entry.idempotencyKey,
|
entry.idempotencyKey,
|
||||||
);
|
);
|
||||||
return photoCount > 0 ? { ...item, ...hive, photoCount } : { ...item, ...hive };
|
return photoCount > 0
|
||||||
|
? { ...item, ...hive, ...voice, photoCount }
|
||||||
|
: { ...item, ...hive, ...voice };
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const voiceItems = await readVoiceItems(db, currentUserId);
|
const voiceItems = await readVoiceItems(db, currentUserId);
|
||||||
|
|||||||
@@ -98,10 +98,12 @@ describe("service worker routes", () => {
|
|||||||
"/treatments/new",
|
"/treatments/new",
|
||||||
"/feedings/new",
|
"/feedings/new",
|
||||||
"/swarm-control/new",
|
"/swarm-control/new",
|
||||||
|
"/voice",
|
||||||
|
"/voice/new",
|
||||||
]) {
|
]) {
|
||||||
expect(routes.isShellRoute(route)).toBe(true);
|
expect(routes.isShellRoute(route)).toBe(true);
|
||||||
}
|
}
|
||||||
expect(routes.SHELL_ROUTES).toHaveLength(11);
|
expect(routes.SHELL_ROUTES).toHaveLength(13);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("unknown routes go to /offline and remember where the user wanted to go", () => {
|
it("unknown routes go to /offline and remember where the user wanted to go", () => {
|
||||||
@@ -224,6 +226,20 @@ describe("service worker routes", () => {
|
|||||||
expect(cachesApi("/api/v1/systems")).toBe(true);
|
expect(cachesApi("/api/v1/systems")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("never caches voice notes - transcripts and suggestions are content", () => {
|
||||||
|
for (const path of [
|
||||||
|
"/api/v1/voice-captures",
|
||||||
|
"/api/v1/voice-captures/",
|
||||||
|
"/api/v1/voice-captures/c-1",
|
||||||
|
"/api/v1/voice-captures/config",
|
||||||
|
"/api/v1/voice-captures/stats",
|
||||||
|
"/api/v1/voice-captures?status=READY",
|
||||||
|
"/api/v1/Voice-Captures/c-1/retry",
|
||||||
|
]) {
|
||||||
|
expect(cachesApi(path)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("never caches exports and backups - full records incl. coordinates, addresses, megabytes", () => {
|
it("never caches exports and backups - full records incl. coordinates, addresses, megabytes", () => {
|
||||||
for (const path of [
|
for (const path of [
|
||||||
"/api/v1/export/apiaries.json",
|
"/api/v1/export/apiaries.json",
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import "fake-indexeddb/auto";
|
||||||
|
import { IDBFactory } from "fake-indexeddb";
|
||||||
|
import { beforeEach, describe, expect, it, vi, type MockedFunction } from "vitest";
|
||||||
|
|
||||||
|
// A queued inspection that carries a voice note (Task 20): it goes out through the real
|
||||||
|
// createInspection, so the „note is gone“ retry holds for queued entries as well.
|
||||||
|
|
||||||
|
vi.mock("@/lib/auth/api-client", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("@/lib/auth/api-client")>()),
|
||||||
|
apiFetch: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/apiaries/offline-pack", () => ({ refreshPacksAfterSync: vi.fn() }));
|
||||||
|
|
||||||
|
type OfflineDbModule = typeof import("./db");
|
||||||
|
type SyncModule = typeof import("./sync");
|
||||||
|
|
||||||
|
let db: OfflineDbModule;
|
||||||
|
let sync: SyncModule;
|
||||||
|
let apiFetch: MockedFunction<typeof import("@/lib/auth/api-client").apiFetch>;
|
||||||
|
let ApiError: typeof import("@/lib/auth/api-client").ApiError;
|
||||||
|
|
||||||
|
const INSPECTION = {
|
||||||
|
colonyId: "colony-1",
|
||||||
|
date: "2026-10-14T00:00:00.000Z",
|
||||||
|
queenSeen: true,
|
||||||
|
voiceCaptureId: "cap-1",
|
||||||
|
};
|
||||||
|
|
||||||
|
function sentBody(call: number): Record<string, unknown> {
|
||||||
|
const init = apiFetch.mock.calls[call][1] as RequestInit;
|
||||||
|
return JSON.parse(String(init.body)) as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sentKey(call: number): unknown {
|
||||||
|
const init = apiFetch.mock.calls[call][1] as RequestInit;
|
||||||
|
return (init.headers as Record<string, string>)["Idempotency-Key"];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("syncing a queued inspection with a voice note", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
globalThis.indexedDB = new IDBFactory();
|
||||||
|
window.localStorage.clear();
|
||||||
|
vi.resetModules();
|
||||||
|
db = await import("./db");
|
||||||
|
sync = await import("./sync");
|
||||||
|
const client = await import("@/lib/auth/api-client");
|
||||||
|
ApiError = client.ApiError;
|
||||||
|
apiFetch = vi.mocked(client.apiFetch);
|
||||||
|
apiFetch.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a queued inspection with an expired voice capture is saved without it", async () => {
|
||||||
|
await db.queueInspection(INSPECTION, "key-queued", "user-1");
|
||||||
|
apiFetch
|
||||||
|
.mockRejectedValueOnce(new ApiError(409, "weg", { code: "VOICE_CAPTURE_GONE" }))
|
||||||
|
.mockResolvedValueOnce({ id: "server-1" });
|
||||||
|
|
||||||
|
const result = await sync.syncPendingEntries("user-1");
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ synced: 1, failed: 0 });
|
||||||
|
expect(apiFetch).toHaveBeenCalledTimes(2);
|
||||||
|
expect(sentBody(0)).toMatchObject({ voiceCaptureId: "cap-1", queenSeen: true });
|
||||||
|
expect(sentBody(1)).toMatchObject({ queenSeen: true, colonyId: "colony-1" });
|
||||||
|
expect(sentBody(1)).not.toHaveProperty("voiceCaptureId");
|
||||||
|
expect(sentKey(1)).toBe("key-queued");
|
||||||
|
expect(await db.getPendingEntries("pending-inspections")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never drops a note of another colony by itself: it stays queued and can be sent without", async () => {
|
||||||
|
await db.queueInspection(INSPECTION, "key-queued", "user-1");
|
||||||
|
apiFetch.mockRejectedValueOnce(
|
||||||
|
new ApiError(409, "Die Sprachnotiz gehört zu einem anderen Volk.", {
|
||||||
|
code: "VOICE_CAPTURE_COLONY_MISMATCH",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await sync.syncPendingEntries("user-1");
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ synced: 0, failed: 1 });
|
||||||
|
expect(apiFetch).toHaveBeenCalledTimes(1);
|
||||||
|
const [item] = await db.listQueue("user-1");
|
||||||
|
expect(item.canResendWithoutVoice).toBe(true);
|
||||||
|
|
||||||
|
apiFetch.mockResolvedValueOnce({ id: "server-1" });
|
||||||
|
await db.retryInspectionWithoutVoice(item.id as number);
|
||||||
|
const second = await sync.syncPendingEntries("user-1", { afterChange: true });
|
||||||
|
|
||||||
|
expect(second).toMatchObject({ synced: 1, failed: 0 });
|
||||||
|
expect(sentBody(1)).not.toHaveProperty("voiceCaptureId");
|
||||||
|
expect(sentKey(1)).toBe("key-queued");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,6 +18,12 @@ describe("system labels", () => {
|
|||||||
expect(JOB_LABELS.cleanup).toBe("Aufräumen");
|
expect(JOB_LABELS.cleanup).toBe("Aufräumen");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("labels the voice transcribe job and both voice events", () => {
|
||||||
|
expect(JOB_LABELS["voice-transcribe"]).toBe("Sprachnotizen abschreiben");
|
||||||
|
expect(DOMAIN_EVENT_LABELS.VoiceCaptureReady).toBe("Sprachnotiz fertig");
|
||||||
|
expect(DOMAIN_EVENT_LABELS.VoiceCaptureFailed).toBe("Sprachnotiz fehlgeschlagen");
|
||||||
|
});
|
||||||
|
|
||||||
it("labels the run and delivery states", () => {
|
it("labels the run and delivery states", () => {
|
||||||
expect(JOB_RUN_STATUS_LABELS.FAILED).toBe("Fehlgeschlagen");
|
expect(JOB_RUN_STATUS_LABELS.FAILED).toBe("Fehlgeschlagen");
|
||||||
expect(WEBHOOK_DELIVERY_STATUS_LABELS.DELIVERED).toBe("Zugestellt");
|
expect(WEBHOOK_DELIVERY_STATUS_LABELS.DELIVERED).toBe("Zugestellt");
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export const JOB_LABELS: Record<JobName, string> = {
|
|||||||
cleanup: "Aufräumen",
|
cleanup: "Aufräumen",
|
||||||
"app-backup": "App-Backup",
|
"app-backup": "App-Backup",
|
||||||
rekey: "Schlüssel umstellen",
|
rekey: "Schlüssel umstellen",
|
||||||
|
"voice-transcribe": "Sprachnotizen abschreiben",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const JOB_RUN_STATUS_LABELS: Record<JobRunStatus, string> = {
|
export const JOB_RUN_STATUS_LABELS: Record<JobRunStatus, string> = {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export const JOB_NAMES = [
|
|||||||
"cleanup",
|
"cleanup",
|
||||||
"app-backup",
|
"app-backup",
|
||||||
"rekey",
|
"rekey",
|
||||||
|
"voice-transcribe",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type JobName = (typeof JOB_NAMES)[number];
|
export type JobName = (typeof JOB_NAMES)[number];
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
discardAllVoiceCaptures,
|
discardAllVoiceCaptures,
|
||||||
discardVoiceCapture,
|
discardVoiceCapture,
|
||||||
fetchVoiceCaptures,
|
fetchVoiceCaptures,
|
||||||
|
fetchVoiceStats,
|
||||||
retryVoiceCapture,
|
retryVoiceCapture,
|
||||||
submitVoiceText,
|
submitVoiceText,
|
||||||
uploadVoiceCapture,
|
uploadVoiceCapture,
|
||||||
@@ -62,6 +63,11 @@ describe("voice api", () => {
|
|||||||
expect(vi.mocked(apiFetch).mock.calls[3][0]).toBe("/voice-captures/discard-all");
|
expect(vi.mocked(apiFetch).mock.calls[3][0]).toBe("/voice-captures/discard-all");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reads the system counters from /voice-captures/stats", async () => {
|
||||||
|
await fetchVoiceStats();
|
||||||
|
expect(vi.mocked(apiFetch).mock.calls[0][0]).toBe("/voice-captures/stats");
|
||||||
|
});
|
||||||
|
|
||||||
it("has stable query keys", () => {
|
it("has stable query keys", () => {
|
||||||
expect(voiceKeys.detail("1")).toEqual(["voice-captures", "1"]);
|
expect(voiceKeys.detail("1")).toEqual(["voice-captures", "1"]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -53,10 +53,19 @@ export interface VoiceClientConfig {
|
|||||||
retention: { audioHours: number; contentDays: number; rowDays: number };
|
retention: { audioHours: number; contentDays: number; rowDays: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** GET /voice-captures/stats (system:manage): counters of the last 7 days, no user data. */
|
||||||
|
export interface VoiceCaptureStats {
|
||||||
|
byStatus: Record<string, number>;
|
||||||
|
byError: Record<string, number>;
|
||||||
|
tokens: { input: number; output: number };
|
||||||
|
byModel: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
export const voiceKeys = {
|
export const voiceKeys = {
|
||||||
all: ["voice-captures"] as const,
|
all: ["voice-captures"] as const,
|
||||||
detail: (id: string) => ["voice-captures", id] as const,
|
detail: (id: string) => ["voice-captures", id] as const,
|
||||||
config: ["voice-captures", "config"] as const,
|
config: ["voice-captures", "config"] as const,
|
||||||
|
stats: ["voice-captures", "stats"] as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
const BASE = "/voice-captures";
|
const BASE = "/voice-captures";
|
||||||
@@ -105,6 +114,10 @@ export function fetchVoiceConfig(): Promise<VoiceClientConfig> {
|
|||||||
return apiFetch<VoiceClientConfig>(`${BASE}/config`);
|
return apiFetch<VoiceClientConfig>(`${BASE}/config`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchVoiceStats(): Promise<VoiceCaptureStats> {
|
||||||
|
return apiFetch<VoiceCaptureStats>(`${BASE}/stats`);
|
||||||
|
}
|
||||||
|
|
||||||
export function retryVoiceCapture(id: string): Promise<void> {
|
export function retryVoiceCapture(id: string): Promise<void> {
|
||||||
return apiFetch<void>(idPath(id, "retry"), { method: "POST" });
|
return apiFetch<void>(idPath(id, "retry"), { method: "POST" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { VOICE_MODEL_PRICES_USD, estimateVoiceCostUsd } from "./prices";
|
||||||
|
|
||||||
|
describe("voice model prices", () => {
|
||||||
|
it("lists the three allowed models with the spec 7.1 prices", () => {
|
||||||
|
expect(VOICE_MODEL_PRICES_USD["claude-sonnet-5-5"]).toEqual({ inputPerMillion: 2, outputPerMillion: 10 });
|
||||||
|
expect(VOICE_MODEL_PRICES_USD["claude-opus-5-5"]).toEqual({ inputPerMillion: 4, outputPerMillion: 20 });
|
||||||
|
expect(VOICE_MODEL_PRICES_USD["claude-haiku-5-5"]).toEqual({ inputPerMillion: 0.1, outputPerMillion: 0.5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("multiplies tokens by the price of the model", () => {
|
||||||
|
expect(estimateVoiceCostUsd("claude-sonnet-5-5", { input: 1_000_000, output: 500_000 })).toBeCloseTo(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is exactly 0 without tokens", () => {
|
||||||
|
expect(estimateVoiceCostUsd("claude-sonnet-5-5", { input: 0, output: 0 })).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for a model without a price, never a guess", () => {
|
||||||
|
expect(estimateVoiceCostUsd("unknown", { input: 10, output: 10 })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/*
|
||||||
|
* Price per million tokens in USD for the models the voice evaluation may use.
|
||||||
|
* Source: spec docs/superpowers/specs/2026-10-14-t7-sprache-design.md section
|
||||||
|
* 7.1 (Anthropic model list, as of 2026-10-06). Update together with
|
||||||
|
* VOICE_MODELS in the backend (src/voice/voice-config.ts) when prices change.
|
||||||
|
*/
|
||||||
|
export interface VoiceModelPrice {
|
||||||
|
inputPerMillion: number;
|
||||||
|
outputPerMillion: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VOICE_MODEL_PRICES_USD: Readonly<Record<string, VoiceModelPrice>> = {
|
||||||
|
"claude-opus-5-5": { inputPerMillion: 4, outputPerMillion: 20 },
|
||||||
|
"claude-sonnet-5-5": { inputPerMillion: 2, outputPerMillion: 10 },
|
||||||
|
"claude-haiku-5-5": { inputPerMillion: 0.1, outputPerMillion: 0.5 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const TOKENS_PER_MILLION = 1_000_000;
|
||||||
|
|
||||||
|
/** Tokens x price; null when the model has no price in the table (never a guess). */
|
||||||
|
export function estimateVoiceCostUsd(
|
||||||
|
model: string,
|
||||||
|
tokens: { input: number; output: number },
|
||||||
|
): number | null {
|
||||||
|
const price = VOICE_MODEL_PRICES_USD[model];
|
||||||
|
if (!price) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
(tokens.input * price.inputPerMillion + tokens.output * price.outputPerMillion) /
|
||||||
|
TOKENS_PER_MILLION
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { initialInspectionValues } from "@/app/(app)/inspections/new/inspection-form-values";
|
||||||
|
import type { VoiceSuggestion } from "./api";
|
||||||
|
import {
|
||||||
|
describeFieldValue,
|
||||||
|
describeSuggestionValue,
|
||||||
|
openSuggestions,
|
||||||
|
suggestionPatch,
|
||||||
|
} from "./suggestions";
|
||||||
|
|
||||||
|
const values = initialInspectionValues("colony-1");
|
||||||
|
|
||||||
|
function suggestion(
|
||||||
|
field: string,
|
||||||
|
value: VoiceSuggestion["value"],
|
||||||
|
extra: Partial<VoiceSuggestion> = {},
|
||||||
|
): VoiceSuggestion {
|
||||||
|
return { field, value, quote: "gesagt", corrected: false, truncated: false, ...extra };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("describeSuggestionValue", () => {
|
||||||
|
it.each([
|
||||||
|
[suggestion("queenSeen", true), "Königin gesehen: Ja"],
|
||||||
|
[suggestion("eggsPresent", false), "Stifte vorhanden: Nein"],
|
||||||
|
[suggestion("feedStatus", "WEAK"), "Futter: Niedrig"],
|
||||||
|
[suggestion("gentleness", 4), "Sanftmut: 4 von 5"],
|
||||||
|
[suggestion("broodFrames", 4), "Brutwaben: 4"],
|
||||||
|
[suggestion("colonyStrength", 1), "Volksstärke: 1 besetzte Wabe"],
|
||||||
|
[suggestion("colonyStrength", 12), "Volksstärke: 12 besetzte Waben"],
|
||||||
|
[suggestion("measures", "Honigraum aufgesetzt"), "Maßnahmen: Honigraum aufgesetzt"],
|
||||||
|
])("words %j as %s", (item, expected) => {
|
||||||
|
expect(describeSuggestionValue(item)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("words a scale as the buttons show it - the swarm mood is mirrored", () => {
|
||||||
|
// Stored 1 is "ruhig", the good end, which the form shows on the right as 5.
|
||||||
|
expect(describeSuggestionValue(suggestion("swarmMood", 1))).toBe(
|
||||||
|
"Schwarmstimmung: 5 von 5 (ruhig)",
|
||||||
|
);
|
||||||
|
expect(describeFieldValue("swarmMood", 4)).toBe("2 von 5");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the end word at the two ends of a scale", () => {
|
||||||
|
expect(describeFieldValue("gentleness", 5)).toBe("5 von 5 (sehr sanft)");
|
||||||
|
expect(describeFieldValue("gentleness", 1)).toBe("1 von 5 (stechlustig)");
|
||||||
|
expect(describeFieldValue("swarmMood", 5)).toBe("1 von 5 (stark)");
|
||||||
|
expect(describeFieldValue("broodPattern", 3)).toBe("3 von 5");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("suggestionPatch", () => {
|
||||||
|
it("turns each kind of value into the form's own shape", () => {
|
||||||
|
expect(suggestionPatch(suggestion("queenSeen", true), values)).toEqual({ queenSeen: true });
|
||||||
|
expect(suggestionPatch(suggestion("broodFrames", 4), values)).toEqual({ broodFrames: "4" });
|
||||||
|
expect(suggestionPatch(suggestion("colonyStrength", 8), values)).toEqual({
|
||||||
|
colonyStrength: "8",
|
||||||
|
});
|
||||||
|
expect(suggestionPatch(suggestion("swarmMood", 2), values)).toEqual({ swarmMood: 2 });
|
||||||
|
expect(suggestionPatch(suggestion("feedStatus", "CRITICAL"), values)).toEqual({
|
||||||
|
feedStatus: "CRITICAL",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends free text to what is already there instead of replacing it", () => {
|
||||||
|
expect(suggestionPatch(suggestion("notes", "Königin markiert"), values)).toEqual({
|
||||||
|
notes: "Königin markiert",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
suggestionPatch(suggestion("notes", "Königin markiert"), { ...values, notes: "Schwarz" }),
|
||||||
|
).toEqual({ notes: "Schwarz Königin markiert" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives nothing for a field or value the form cannot take", () => {
|
||||||
|
expect(suggestionPatch(suggestion("weather", "sonnig"), values)).toEqual({});
|
||||||
|
expect(suggestionPatch(suggestion("colonyId", "x"), values)).toEqual({});
|
||||||
|
expect(suggestionPatch(suggestion("queenSeen", "ja"), values)).toEqual({});
|
||||||
|
expect(suggestionPatch(suggestion("gentleness", 9), values)).toEqual({});
|
||||||
|
expect(suggestionPatch(suggestion("gentleness", 2.5), values)).toEqual({});
|
||||||
|
expect(suggestionPatch(suggestion("colonyStrength", 41), values)).toEqual({});
|
||||||
|
expect(suggestionPatch(suggestion("feedStatus", "ausreichend"), values)).toEqual({});
|
||||||
|
expect(suggestionPatch(suggestion("notes", " "), values)).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("openSuggestions", () => {
|
||||||
|
const all = [
|
||||||
|
suggestion("queenSeen", true),
|
||||||
|
suggestion("gentleness", 4),
|
||||||
|
suggestion("notes", "Neu"),
|
||||||
|
suggestion("weather", "sonnig"),
|
||||||
|
];
|
||||||
|
|
||||||
|
it("keeps the ones that would change something and drops unusable fields", () => {
|
||||||
|
expect(openSuggestions(all, values, new Set()).map((item) => item.field)).toEqual([
|
||||||
|
"queenSeen",
|
||||||
|
"gentleness",
|
||||||
|
"notes",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops a field already taken or ignored and one the beekeeper already answered the same", () => {
|
||||||
|
const answered = { ...values, queenSeen: true as boolean | null };
|
||||||
|
expect(
|
||||||
|
openSuggestions(all, answered, new Set(["notes"])).map((item) => item.field),
|
||||||
|
).toEqual(["gentleness"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips free text the field already contains", () => {
|
||||||
|
const filled = { ...values, notes: "Schwarz, Neu gesehen" };
|
||||||
|
expect(openSuggestions([suggestion("notes", "Neu")], filled, new Set())).toEqual([]);
|
||||||
|
expect(
|
||||||
|
openSuggestions([suggestion("notes", "Neu")], { ...values, notes: "Alt" }, new Set()),
|
||||||
|
).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a differing answer open - a tap decides", () => {
|
||||||
|
const answered = { ...values, queenSeen: false as boolean | null };
|
||||||
|
expect(openSuggestions(all, answered, new Set()).map((item) => item.field)).toContain(
|
||||||
|
"queenSeen",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import type { InspectionFormValues } from "@/app/(app)/inspections/new/inspection-form-values";
|
||||||
|
import { FEED_CONDITION_LABELS } from "@/lib/colonies/condition";
|
||||||
|
import { MAX_OCCUPIED_FRAMES } from "@/lib/colonies/labels";
|
||||||
|
import type { ColonyCondition } from "@/lib/colonies/types";
|
||||||
|
import { toDisplayRating } from "@/lib/forms/rating";
|
||||||
|
import {
|
||||||
|
INSPECTION_SCALE_DIRECTIONS,
|
||||||
|
INSPECTION_SCALE_LABELS,
|
||||||
|
type InspectionScaleField,
|
||||||
|
} from "@/lib/inspections/scales";
|
||||||
|
import { appendDictation } from "./append-text";
|
||||||
|
import type { VoiceSuggestion } from "./api";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Turns what the server suggests into form values, in words, and decides what
|
||||||
|
* is still on offer. The server already checked every value; checking again
|
||||||
|
* here costs little and keeps a changed or damaged answer out of the form.
|
||||||
|
* Suggested text is data: it is only ever returned as strings for React to
|
||||||
|
* set as text.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Value = VoiceSuggestion["value"];
|
||||||
|
|
||||||
|
/** The fields a suggestion may fill, with the words the form uses for them. */
|
||||||
|
export const VOICE_FIELD_LABELS = {
|
||||||
|
queenSeen: "Königin gesehen",
|
||||||
|
eggsPresent: "Stifte vorhanden",
|
||||||
|
openBroodPresent: "Offene Brut",
|
||||||
|
cappedBroodPresent: "Verdeckelte Brut",
|
||||||
|
queenCellsPresent: "Weiselzellen",
|
||||||
|
broodPattern: "Brutbild",
|
||||||
|
broodFrames: "Brutwaben",
|
||||||
|
colonyStrength: "Volksstärke",
|
||||||
|
gentleness: "Sanftmut",
|
||||||
|
combBehavior: "Wabensitz",
|
||||||
|
swarmMood: "Schwarmstimmung",
|
||||||
|
feedStatus: "Futter",
|
||||||
|
honeySuperStatus: "Honigraum",
|
||||||
|
healthIssues: "Gesundheitsauffälligkeiten",
|
||||||
|
varroaIssues: "Varroa-Befund",
|
||||||
|
measures: "Maßnahmen",
|
||||||
|
notes: "Notizen",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type VoiceField = keyof typeof VOICE_FIELD_LABELS;
|
||||||
|
|
||||||
|
type FieldKind =
|
||||||
|
| { kind: "bool" }
|
||||||
|
| { kind: "count"; max: number }
|
||||||
|
| { kind: "scale" }
|
||||||
|
| { kind: "feed" }
|
||||||
|
| { kind: "text" };
|
||||||
|
|
||||||
|
const BOOL: FieldKind = { kind: "bool" };
|
||||||
|
const SCALE: FieldKind = { kind: "scale" };
|
||||||
|
const TEXT: FieldKind = { kind: "text" };
|
||||||
|
|
||||||
|
const FIELD_KINDS: Record<VoiceField, FieldKind> = {
|
||||||
|
queenSeen: BOOL,
|
||||||
|
eggsPresent: BOOL,
|
||||||
|
openBroodPresent: BOOL,
|
||||||
|
cappedBroodPresent: BOOL,
|
||||||
|
queenCellsPresent: BOOL,
|
||||||
|
broodPattern: SCALE,
|
||||||
|
gentleness: SCALE,
|
||||||
|
combBehavior: SCALE,
|
||||||
|
swarmMood: SCALE,
|
||||||
|
broodFrames: { kind: "count", max: MAX_OCCUPIED_FRAMES },
|
||||||
|
colonyStrength: { kind: "count", max: MAX_OCCUPIED_FRAMES },
|
||||||
|
feedStatus: { kind: "feed" },
|
||||||
|
honeySuperStatus: TEXT,
|
||||||
|
healthIssues: TEXT,
|
||||||
|
varroaIssues: TEXT,
|
||||||
|
measures: TEXT,
|
||||||
|
notes: TEXT,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SCALE_MIN = 1;
|
||||||
|
const SCALE_MAX = 5;
|
||||||
|
|
||||||
|
export function isVoiceField(field: string): field is VoiceField {
|
||||||
|
return Object.hasOwn(FIELD_KINDS, field);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True for the free-text fields: their suggestions are added to what stands there. */
|
||||||
|
export function isVoiceTextField(field: string): boolean {
|
||||||
|
return isVoiceField(field) && FIELD_KINDS[field].kind === "text";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isScaleField(field: VoiceField): field is VoiceField & InspectionScaleField {
|
||||||
|
return Object.hasOwn(INSPECTION_SCALE_DIRECTIONS, field);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFeedLevel(value: Value): value is ColonyCondition {
|
||||||
|
return typeof value === "string" && Object.hasOwn(FEED_CONDITION_LABELS, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIntIn(value: Value, min: number, max: number): value is number {
|
||||||
|
return typeof value === "number" && Number.isInteger(value) && value >= min && value <= max;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The value as the form holds it, or undefined when the form cannot take it. */
|
||||||
|
function formValueOf(field: VoiceField, value: Value): unknown {
|
||||||
|
const spec = FIELD_KINDS[field];
|
||||||
|
switch (spec.kind) {
|
||||||
|
case "bool":
|
||||||
|
return typeof value === "boolean" ? value : undefined;
|
||||||
|
case "count":
|
||||||
|
return isIntIn(value, 0, spec.max) ? String(value) : undefined;
|
||||||
|
case "scale":
|
||||||
|
return isIntIn(value, SCALE_MIN, SCALE_MAX) ? value : undefined;
|
||||||
|
case "feed":
|
||||||
|
return isFeedLevel(value) ? value : undefined;
|
||||||
|
case "text":
|
||||||
|
return typeof value === "string" && value.trim() !== "" ? value.trim() : undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function countText(count: number, one: string, many: string): string {
|
||||||
|
return count === 1 ? `1 ${one}` : `${count} ${many}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A value in words, as the form's buttons word it ("Ja", "Niedrig", "4 von 5"). */
|
||||||
|
export function describeFieldValue(field: string, value: Value): string {
|
||||||
|
if (!isVoiceField(field)) return String(value);
|
||||||
|
switch (FIELD_KINDS[field].kind) {
|
||||||
|
case "bool":
|
||||||
|
return value === true ? "Ja" : value === false ? "Nein" : String(value);
|
||||||
|
case "scale": {
|
||||||
|
if (!isScaleField(field) || typeof value !== "number") return String(value);
|
||||||
|
// Mirrored scales read as the buttons show them: left is always the bad end.
|
||||||
|
const text = `${toDisplayRating(value, INSPECTION_SCALE_DIRECTIONS[field])} von ${SCALE_MAX}`;
|
||||||
|
// At the two ends the word says what the number means.
|
||||||
|
const { lowLabel, highLabel } = INSPECTION_SCALE_LABELS[field];
|
||||||
|
if (value === SCALE_MIN) return `${text} (${lowLabel})`;
|
||||||
|
return value === SCALE_MAX ? `${text} (${highLabel})` : text;
|
||||||
|
}
|
||||||
|
case "feed":
|
||||||
|
return isFeedLevel(value) ? FEED_CONDITION_LABELS[value] : String(value);
|
||||||
|
case "count":
|
||||||
|
return field === "colonyStrength" && typeof value === "number"
|
||||||
|
? countText(value, "besetzte Wabe", "besetzte Waben")
|
||||||
|
: String(value);
|
||||||
|
case "text":
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Königin gesehen: Ja", "Futter: Niedrig", "Sanftmut: 4 von 5". */
|
||||||
|
export function describeSuggestionValue(suggestion: VoiceSuggestion): string {
|
||||||
|
const label = isVoiceField(suggestion.field)
|
||||||
|
? VOICE_FIELD_LABELS[suggestion.field]
|
||||||
|
: suggestion.field;
|
||||||
|
return `${label}: ${describeFieldValue(suggestion.field, suggestion.value)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What taking this suggestion over changes in the form; empty when the field
|
||||||
|
* or value is not one the form can take. Free text is added to the end of
|
||||||
|
* what is there (like dictation), everything else replaces the field.
|
||||||
|
*/
|
||||||
|
export function suggestionPatch(
|
||||||
|
suggestion: VoiceSuggestion,
|
||||||
|
current: InspectionFormValues,
|
||||||
|
): Partial<InspectionFormValues> {
|
||||||
|
const { field } = suggestion;
|
||||||
|
if (!isVoiceField(field)) return {};
|
||||||
|
const value = formValueOf(field, suggestion.value);
|
||||||
|
if (value === undefined) return {};
|
||||||
|
if (FIELD_KINDS[field].kind === "text") {
|
||||||
|
return { [field]: appendDictation(current[field as "notes"], value as string) };
|
||||||
|
}
|
||||||
|
return { [field]: value };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The suggestions still on offer: usable, not yet taken or ignored, and not
|
||||||
|
* already answered with the same value by hand. Free text always counts as
|
||||||
|
* new (it is added, not compared).
|
||||||
|
*/
|
||||||
|
export function openSuggestions(
|
||||||
|
all: readonly VoiceSuggestion[],
|
||||||
|
values: InspectionFormValues,
|
||||||
|
taken: ReadonlySet<string>,
|
||||||
|
): VoiceSuggestion[] {
|
||||||
|
return all.filter((suggestion) => {
|
||||||
|
if (taken.has(suggestion.field)) return false;
|
||||||
|
const patch: Record<string, unknown> = suggestionPatch(suggestion, values);
|
||||||
|
const field = suggestion.field;
|
||||||
|
if (!(field in patch)) return false;
|
||||||
|
if (isVoiceTextField(field)) {
|
||||||
|
// Text the field already holds is not added a second time.
|
||||||
|
const suggested = String(suggestion.value).trim().toLowerCase();
|
||||||
|
const held = String((values as unknown as Record<string, unknown>)[field]).toLowerCase();
|
||||||
|
return !held.includes(suggested);
|
||||||
|
}
|
||||||
|
return (values as unknown as Record<string, unknown>)[field] !== patch[field];
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in new issue
Block a user