feat: Sprachnotizen in Synchronisierung, Warteschlange und Abmelden (T7 L5 Task 18)
Backend Image / build-and-push (push) Successful in 7m6s
Frontend Image / build-and-push (push) Successful in 15m27s
Manifests / validate (push) Successful in 16s

Wartende Sprachnotizen werden nach Einträgen und Fotos hochgeladen, je
Notiz ein fester Idempotency-Key; endgültige 4xx markieren die Notiz
mit festem deutschem Text, 401/408/429/5xx/Netz warten auf den nächsten
Lauf, „wird bearbeitet“ wird übersprungen. Warteschlangen-Sheet mit
Abschnitt „Sprachnotizen“ (Verwerfen je Notiz nur für den Besitzer,
alle verwerfen mit Rückfrage, Hinweis bei zu vielen offenen Notizen),
Offline-Band zählt nur wartende Notizen, Abmelden fragt bei wartenden
Notizen nach. Zählen liest keine Audiodaten.

Gates: Frontend lint, typecheck, 2886 Tests.

Refs #27

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-16 11:10:14 +02:00
1 parent 0c7147f626
commit e614ded967
18 files changed
+1201 -30

No files matched your search

@@ -66,6 +66,32 @@ describe("LogoutConfirmDialog", () => {
expect(screen.getByRole("button", { name: "Abbrechen" })).toBeVisible();
});
it("asks to discard waiting voice notes and says they cannot be restored", async () => {
vi.mocked(summarizeQueue).mockResolvedValue({ own: 3, failed: 0, foreign: 0, voice: 2 });
renderDialog();
const dialog = await screen.findByRole("dialog", { name: "Abmelden?" });
expect(dialog).toHaveTextContent("3 Einträge sind noch nicht gesendet.");
expect(dialog).toHaveTextContent("Darunter 2 Sprachnotizen");
expect(dialog).toHaveTextContent("nicht wiederherstellen");
expect(logoutMock).not.toHaveBeenCalled();
await userEvent.click(
screen.getByRole("button", { name: "Abmelden und Einträge löschen" }),
);
await waitFor(() => expect(logoutMock).toHaveBeenCalledWith({ discardQueue: true }));
});
it("does not mention recordings when there are none", async () => {
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 });
renderDialog();
expect(await screen.findByRole("dialog", { name: "Abmelden?" })).not.toHaveTextContent(
"Sprachnotiz",
);
});
it("deletes the entries only after the destructive button was pressed", async () => {
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 });
renderDialog();
@@ -13,7 +13,7 @@ import {
import { useAuth } from "@/lib/auth/auth-context";
import { useConnectivity } from "@/lib/offline/connectivity";
import { summarizeQueue } from "@/lib/offline/db";
import { entriesNotSent } from "@/lib/offline/queue-texts";
import { entriesNotSent, voiceNotesLostOnLogout } from "@/lib/offline/queue-texts";
import { syncPendingEntries } from "@/lib/offline/sync";
interface LogoutConfirmDialogProps {
@@ -24,14 +24,20 @@ interface LogoutConfirmDialogProps {
onLoggedOut: () => void;
}
interface Unsent {
entries: number;
/** Of the entries: the voice recordings, which cannot be recreated. */
voice: number;
}
/** Waiting and rejected entries of the person; owner-less legacy ones count as theirs. */
async function countUnsent(userId: string): Promise<number> {
async function countUnsent(userId: string): Promise<Unsent> {
try {
const { own, failed } = await summarizeQueue(userId);
return own + failed;
const { own, failed, voice = 0 } = await summarizeQueue(userId);
return { entries: own + failed, voice };
} catch {
// The queue cannot be read, so it cannot be lost either.
return 0;
return { entries: 0, voice: 0 };
}
}
@@ -49,7 +55,7 @@ export function LogoutConfirmDialog({
}: LogoutConfirmDialogProps) {
const { logout, sessionMode } = useAuth();
const connectivity = useConnectivity();
const [unsent, setUnsent] = useState<number | null>(null);
const [unsent, setUnsent] = useState<Unsent | null>(null);
const [isBusy, setIsBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [wasOpen, setWasOpen] = useState(open);
@@ -81,14 +87,14 @@ export function LogoutConfirmDialog({
return;
}
let isCurrent = true;
void countUnsent(userId).then((count) => {
void countUnsent(userId).then((counted) => {
if (!isCurrent) {
return;
}
if (count === 0) {
if (counted.entries === 0) {
void signOut(false);
} else {
setUnsent(count);
setUnsent(counted);
}
});
return () => {
@@ -107,7 +113,7 @@ export function LogoutConfirmDialog({
// Whatever could not be sent is counted below.
}
const remaining = await countUnsent(userId);
if (remaining === 0) {
if (remaining.entries === 0) {
await signOut(false);
return;
}
@@ -117,7 +123,7 @@ export function LogoutConfirmDialog({
// An offline session has no token: nothing can be sent, whatever the network says.
const isOnline = connectivity === "online" && sessionMode !== "offline";
const hasEntries = unsent !== null && unsent > 0;
const hasEntries = unsent !== null && unsent.entries > 0;
return (
<Dialog open={open && (hasEntries || error !== null)} onOpenChange={onOpenChange}>
@@ -126,7 +132,9 @@ export function LogoutConfirmDialog({
<DialogTitle>Abmelden?</DialogTitle>
<DialogDescription>
{hasEntries
? `${entriesNotSent(unsent)} ${
? `${entriesNotSent(unsent.entries)} ${
unsent.voice > 0 ? `${voiceNotesLostOnLogout(unsent.voice)} ` : ""
}${
isOnline
? "Sende sie zuerst, oder melde dich ab und lösche sie."
: "Ohne Netz kannst du sie nicht senden. Beim Abmelden werden sie gelöscht."
@@ -99,6 +99,50 @@ describe("OfflineBand", () => {
expect(syncPendingEntries).not.toHaveBeenCalled();
});
it("counts voice notes in the waiting entries", async () => {
setOnLine(false);
vi.mocked(summarizeQueue).mockResolvedValue({ own: 3, failed: 0, foreign: 0, voice: 2, voiceWaiting: 2 });
renderBand();
await waitFor(() =>
expect(screen.getByRole("status")).toHaveTextContent(
"Offline – 3 Einträge warten (davon 2 Sprachnotizen)",
),
);
});
it("does not count a rejected voice note as waiting", async () => {
setOnLine(false);
vi.mocked(summarizeQueue).mockResolvedValue({
own: 1,
failed: 1,
foreign: 0,
voice: 2,
voiceWaiting: 1,
});
renderBand();
await waitFor(() =>
expect(screen.getByRole("status")).toHaveTextContent(
"Offline – 1 Eintrag wartet (davon 1 Sprachnotiz)",
),
);
});
it("names the voice notes next to „Jetzt senden\" when online", async () => {
setOnLine(true);
vi.mocked(summarizeQueue).mockResolvedValue({ own: 2, failed: 0, foreign: 0, voice: 1, voiceWaiting: 1 });
renderBand();
expect(
await screen.findByRole("button", { name: "2 Einträge warten – Jetzt senden" }),
).toBeVisible();
expect(screen.getByRole("status")).toHaveTextContent("davon 1 Sprachnotiz");
});
it("says „1 Eintrag wartet\" for a single entry", async () => {
setOnLine(false);
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 });
@@ -9,7 +9,12 @@ import { SheetTrigger } from "@/components/ui/sheet";
import { cn } from "@/lib/utils";
import { useConnectivity } from "@/lib/offline/connectivity";
import { QueueSummary, summarizeQueue } from "@/lib/offline/db";
import { entriesRejected, entriesSynced, entriesWaiting } from "@/lib/offline/queue-texts";
import {
entriesRejected,
entriesSynced,
entriesWaiting,
voiceNotesAmong,
} from "@/lib/offline/queue-texts";
import { resumeSync, syncPendingEntries } from "@/lib/offline/sync";
import { OFFLINE_QUEUE_QUERY_KEY } from "@/lib/offline/use-capture-mutation";
import { PendingQueueSheet } from "./pending-queue-sheet";
@@ -111,6 +116,8 @@ export function OfflineBand({ userId, pageContentRef, syncEnabled = true }: Offl
// An offline session cannot send, whatever the network says.
const isOffline = connectivity === "offline" || !syncEnabled;
// „(davon 2 Sprachnotizen)“: voice notes are part of the counts above.
const voiceSuffix = queue.voiceWaiting ? ` (${voiceNotesAmong(queue.voiceWaiting)})` : "";
const hasContent = isOffline || queue.own > 0 || hasRejected;
// Back to the trigger while it exists; otherwise to the band's message, or
@@ -140,7 +147,7 @@ export function OfflineBand({ userId, pageContentRef, syncEnabled = true }: Offl
<p className="flex items-center gap-2 font-medium">
<CloudOff className="size-4 shrink-0" aria-hidden />
{queue.own > 0
? `Offline – ${entriesWaiting(queue.own)}`
? `Offline – ${entriesWaiting(queue.own)}${voiceSuffix}`
: "Offline – Erfassungen werden gespeichert und später gesendet. Vorschläge und Aufgaben brauchen eine Verbindung."}
</p>
) : null}
@@ -157,6 +164,9 @@ export function OfflineBand({ userId, pageContentRef, syncEnabled = true }: Offl
{sync.isPending ? "Wird gesendet…" : `${entriesWaiting(queue.own)} – Jetzt senden`}
</Button>
) : null}
{!isOffline && queue.own > 0 && queue.voiceWaiting ? (
<span>{voiceSuffix.trim()}</span>
) : null}
<PendingQueueSheet
userId={userId}
foreignCount={queue.foreign}
@@ -1,5 +1,5 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
@@ -11,6 +11,7 @@ import {
type StoredPhoto,
} from "@/lib/offline/db";
import { syncPendingEntries } from "@/lib/offline/sync";
import { discardAllPendingVoice, discardPendingVoice } from "@/lib/voice/pending-voice";
import { discardReadyPhoto, listPendingPhotos, resetPhotoError } from "@/lib/photos/store";
import { PendingQueueSheet } from "./pending-queue-sheet";
@@ -20,6 +21,10 @@ vi.mock("@/lib/offline/db", () => ({
retryBulkWithout: vi.fn(),
retryInspectionWithoutHiveChange: vi.fn(),
}));
vi.mock("@/lib/voice/pending-voice", () => ({
discardPendingVoice: vi.fn(),
discardAllPendingVoice: vi.fn(),
}));
vi.mock("@/lib/offline/sync", () => ({ syncPendingEntries: vi.fn() }));
vi.mock("@/lib/photos/store", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/photos/store")>()),
@@ -97,6 +102,8 @@ describe("PendingQueueSheet", () => {
vi.mocked(retryBulkWithout).mockReset().mockResolvedValue(undefined);
vi.mocked(retryInspectionWithoutHiveChange).mockReset().mockResolvedValue(undefined);
vi.mocked(listPendingPhotos).mockReset().mockResolvedValue([]);
vi.mocked(discardPendingVoice).mockReset().mockResolvedValue(undefined);
vi.mocked(discardAllPendingVoice).mockReset().mockResolvedValue(2);
vi.mocked(discardReadyPhoto).mockReset().mockResolvedValue(undefined);
vi.mocked(resetPhotoError).mockReset().mockResolvedValue(undefined);
vi.mocked(syncPendingEntries)
@@ -337,4 +344,147 @@ describe("PendingQueueSheet", () => {
expect(await within(sheet).findByText(text)).toBeInTheDocument();
expect(within(sheet).queryByRole("button", { name: /Verwerfen/ })).not.toBeInTheDocument();
});
describe("voice notes", () => {
const VOICE_WAITING: QueueItem = {
store: "pending-voice",
id: "voice-1",
label: "Sprachnotiz",
queuedAt: "2026-10-16T08:00:00.000Z",
voice: { colonyId: "colony-1", inspectionDate: "2026-10-16", durationSec: 75 },
};
const VOICE_REJECTED: QueueItem = {
...VOICE_WAITING,
id: "voice-2",
queuedAt: "2026-10-16T08:05:00.000Z",
lastError: {
status: 415,
message: "Unsupported",
details: { code: "UNSUPPORTED_AUDIO" },
at: "2026-10-16T08:06:00.000Z",
},
};
it("shows a voice note with Erneut aufnehmen and Verwerfen", async () => {
vi.mocked(listQueue).mockResolvedValue([VOICE_REJECTED]);
renderSheet();
const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" });
expect(await within(sheet).findByText("Sprachnotiz")).toBeVisible();
expect(within(sheet).getByText(/Dauer 1:15/)).toBeVisible();
expect(
within(sheet).getByText(/Dieses Audioformat wird nicht unterstützt/),
).toBeVisible();
const again = within(sheet).getByRole("link", { name: /^Erneut aufnehmen/ });
expect(again).toHaveAttribute("href", "/voice/new?colonyId=colony-1");
expect(within(sheet).getByRole("button", { name: /^Verwerfen: Sprachnotiz vom/ })).toBeVisible();
});
it("offers Erneut aufnehmen only for a rejected note", async () => {
vi.mocked(listQueue).mockResolvedValue([VOICE_WAITING]);
renderSheet();
const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" });
expect(await within(sheet).findByText("Wartet auf Senden")).toBeVisible();
expect(within(sheet).queryByRole("link", { name: /Erneut aufnehmen/ })).toBeNull();
});
it("discards one note owner-scoped after confirmation", async () => {
vi.mocked(listQueue).mockResolvedValue([VOICE_WAITING]);
renderSheet();
const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" });
await userEvent.click(
await within(sheet).findByRole("button", { name: /^Verwerfen: Sprachnotiz vom/ }),
);
await userEvent.click(
within(await screen.findByRole("alertdialog")).getByRole("button", { name: "Verwerfen" }),
);
await waitFor(() => expect(discardPendingVoice).toHaveBeenCalledWith("voice-1", "user-1"));
expect(discardQueueItem).not.toHaveBeenCalled();
});
it("discards all recordings of the user at once", async () => {
vi.mocked(listQueue).mockResolvedValue([VOICE_WAITING, VOICE_REJECTED]);
renderSheet();
const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" });
await userEvent.click(
await within(sheet).findByRole("button", { name: "Alle Sprachnotizen verwerfen" }),
);
await userEvent.click(
within(await screen.findByRole("alertdialog")).getByRole("button", {
name: "Alle verwerfen",
}),
);
await waitFor(() => expect(discardAllPendingVoice).toHaveBeenCalledWith("user-1"));
});
it("does not delete the refused note on a modified click", async () => {
vi.mocked(listQueue).mockResolvedValue([VOICE_REJECTED]);
renderSheet();
const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" });
const link = await within(sheet).findByRole("link", { name: /^Erneut aufnehmen/ });
document.addEventListener("click", (event) => event.preventDefault(), { once: true, capture: false });
fireEvent.click(link, { ctrlKey: true });
fireEvent.click(link, { metaKey: true });
fireEvent.click(link, { shiftKey: true });
fireEvent.click(link, { button: 1 });
expect(discardPendingVoice).not.toHaveBeenCalled();
});
it("deletes the refused note on a plain click", async () => {
vi.mocked(listQueue).mockResolvedValue([VOICE_REJECTED]);
renderSheet();
const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" });
const link = await within(sheet).findByRole("link", { name: /^Erneut aufnehmen/ });
document.addEventListener("click", (event) => event.preventDefault(), { once: true, capture: false });
await userEvent.click(link);
await waitFor(() => expect(discardPendingVoice).toHaveBeenCalledWith("voice-2", "user-1"));
});
it("moves focus to the Sprachnotizen heading after a single discard", async () => {
vi.mocked(listQueue).mockResolvedValue([VOICE_WAITING, VOICE_REJECTED]);
renderSheet();
const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" });
await userEvent.click(
(await within(sheet).findAllByRole("button", { name: /^Verwerfen: Sprachnotiz vom/ }))[0],
);
await userEvent.click(
within(await screen.findByRole("alertdialog")).getByRole("button", { name: "Verwerfen" }),
);
await waitFor(() =>
expect(within(sheet).getByRole("heading", { name: "Sprachnotizen" })).toHaveFocus(),
);
});
it("tells without a toast that too many open notes will be sent later", async () => {
vi.mocked(listQueue).mockResolvedValue([
{ ...VOICE_WAITING, voice: { ...VOICE_WAITING.voice!, blockedBy: "TOO_MANY_OPEN" } },
]);
renderSheet();
const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" });
expect(
await within(sheet).findByText("Zu viele offene Sprachnotizen – sie werden später gesendet."),
).toBeVisible();
});
it("shows only the count of another account's notes", async () => {
vi.mocked(listQueue).mockResolvedValue([VOICE_WAITING]);
renderSheet(2);
const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" });
expect(await within(sheet).findByText("2 Einträge eines anderen Kontos warten")).toBeVisible();
expect(within(sheet).getAllByText("Sprachnotiz")).toHaveLength(1);
});
});
});
@@ -1,13 +1,14 @@
"use client";
import type { ComponentProps, ReactNode } from "react";
import { AlertTriangle, Camera, Clock, RotateCw } from "lucide-react";
import { useRef, type ComponentProps, type MouseEvent, type ReactNode } from "react";
import Link from "next/link";
import { AlertTriangle, Camera, Clock, Mic, RotateCw } from "lucide-react";
import { toast } from "sonner";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { NavSheet } from "@/components/nav/nav-sheet";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { PhotoThumbnail } from "@/components/photos/photo-capture-field";
import { Button } from "@/components/ui/button";
import { Button, buttonVariants } from "@/components/ui/button";
import {
discardQueueItem,
listQueue,
@@ -21,9 +22,12 @@ import {
foreignEntriesWaiting,
photosAttached,
queueRejectionText,
VOICE_TOO_MANY_OPEN_HINT,
voiceRejectionText,
} from "@/lib/offline/queue-texts";
import { syncPendingEntries } from "@/lib/offline/sync";
import { OFFLINE_QUEUE_QUERY_KEY } from "@/lib/offline/use-capture-mutation";
import { discardAllPendingVoice, discardPendingVoice } from "@/lib/voice/pending-voice";
import { discardReadyPhoto, listPendingPhotos, resetPhotoError } from "@/lib/photos/store";
interface PendingQueueSheetProps {
@@ -44,6 +48,30 @@ interface Named {
}
const PHOTO_LABEL = "Foto";
const VOICE_STORE = "pending-voice";
/** 75 seconds as "1:15". */
function durationLabel(totalSec: number): string {
const seconds = Math.round(totalSec);
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
}
/** True for a plain left click; ctrl, meta, shift, alt and middle click open a link elsewhere. */
function isPlainLeftClick(event: MouseEvent): boolean {
return (
event.button === 0 &&
!event.defaultPrevented &&
!event.metaKey &&
!event.ctrlKey &&
!event.shiftKey &&
!event.altKey
);
}
/** The recording page, for the colony of a rejected note. */
function recordAgainHref(item: QueueItem): string {
return `/voice/new?${new URLSearchParams({ colonyId: item.voice?.colonyId ?? "" }).toString()}`;
}
function queuedAtLabel(queuedAt: string): string {
return new Date(queuedAt).toLocaleString("de-DE", { dateStyle: "short", timeStyle: "short" });
@@ -81,14 +109,19 @@ function discardDescription(item: QueueItem): string {
function QueueStatus({
lastError,
changesHive = false,
isVoice = false,
}: {
lastError?: QueueError;
changesHive?: boolean;
isVoice?: boolean;
}) {
return lastError ? (
<p className="flex items-start gap-2 text-sm text-destructive">
<AlertTriangle className="mt-0.5 size-4 shrink-0" aria-hidden />
<span>Abgelehnt: {queueRejectionText(lastError, changesHive)}</span>
<span>
Abgelehnt:{" "}
{isVoice ? voiceRejectionText(lastError) : queueRejectionText(lastError, changesHive)}
</span>
</p>
) : (
<p className="flex items-start gap-2 text-sm text-muted-foreground">
@@ -164,6 +197,29 @@ export function PendingQueueSheet({
onError: () => toast.error("Eintrag konnte nicht verworfen werden."),
});
const voiceHeadingRef = useRef<HTMLHeadingElement>(null);
const discardVoice = useMutation({
mutationFn: (noteId: string) => discardPendingVoice(noteId, userId),
networkMode: "always",
onSuccess: () => {
// The discard button is gone: keep the focus inside the list.
voiceHeadingRef.current?.focus();
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
toast.success("Sprachnotiz verworfen");
},
onError: () => toast.error("Sprachnotiz konnte nicht verworfen werden."),
});
const discardAllVoice = useMutation({
mutationFn: () => discardAllPendingVoice(userId),
networkMode: "always",
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
toast.success("Sprachnotizen verworfen");
},
onError: () => toast.error("Sprachnotizen konnten nicht verworfen werden."),
});
const discardPhoto = useMutation({
mutationFn: (photoId: string) => discardReadyPhoto(photoId),
networkMode: "always",
@@ -233,7 +289,10 @@ export function PendingQueueSheet({
onError: () => toast.error("Eintrag konnte nicht erneut gesendet werden."),
});
const names = entryNames(items);
const records = items.filter((item) => item.store !== VOICE_STORE);
const voiceNotes = items.filter((item) => item.store === VOICE_STORE);
const names = entryNames(records);
const voiceNames = entryNames(voiceNotes);
const photoNames = entryNames(
photos.map((photo) => ({ label: PHOTO_LABEL, queuedAt: photo.createdAt })),
);
@@ -251,7 +310,7 @@ export function PendingQueueSheet({
<p className="text-sm text-muted-foreground">Von dir wartet nichts mehr.</p>
) : null}
<ul className="flex flex-col gap-2">
{items.map((item, index) => (
{records.map((item, index) => (
<li
key={`${item.store}-${item.id}`}
className="flex flex-col gap-2 rounded-xl border border-border/60 p-3"
@@ -357,6 +416,87 @@ export function PendingQueueSheet({
</li>
))}
</ul>
{voiceNotes.length > 0 ? (
<section aria-labelledby="queue-voice-heading" className="flex flex-col gap-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3
id="queue-voice-heading"
ref={voiceHeadingRef}
tabIndex={-1}
className="font-medium outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
>
Sprachnotizen
</h3>
{voiceNotes.length > 1 ? (
<ConfirmDialog
triggerLabel="Alle Sprachnotizen verwerfen"
triggerVariant="ghost"
triggerSize="touch"
title="Alle Sprachnotizen verwerfen?"
description="Die Sprachnotizen werden von diesem Gerät gelöscht und nicht mehr gesendet."
confirmLabel="Alle verwerfen"
onConfirm={() => discardAllVoice.mutate()}
isPending={discardAllVoice.isPending}
/>
) : null}
</div>
{voiceNotes.some((item) => item.voice?.blockedBy === "TOO_MANY_OPEN") ? (
<p role="status" className="text-sm text-muted-foreground">
{VOICE_TOO_MANY_OPEN_HINT}
</p>
) : null}
<ul className="flex flex-col gap-2">
{voiceNotes.map((item, index) => (
<li
key={`voice-${item.id}`}
className="flex flex-col gap-2 rounded-xl border border-border/60 p-3"
>
<div className="flex items-start gap-3">
<Mic className="mt-0.5 size-5 shrink-0 text-muted-foreground" aria-hidden />
<div className="min-w-0">
<p className="font-medium">{item.label}</p>
<p className="text-sm text-muted-foreground">
Aufgenommen am {queuedAtLabel(item.queuedAt)}
{item.voice ? ` · Dauer ${durationLabel(item.voice.durationSec)}` : ""}
</p>
</div>
</div>
<QueueStatus lastError={item.lastError} isVoice />
<div className="flex flex-wrap gap-2">
{item.lastError ? (
<Link
href={recordAgainHref(item)}
className={buttonVariants({ variant: "outline", size: "touch" })}
aria-label={`Erneut aufnehmen: ${voiceNames[index]}`}
onClick={(event) => {
// Only a plain click stays in this tab; the refused audio is of no
// use any more and the new recording replaces it.
if (!isPlainLeftClick(event)) return;
discardVoice.mutate(String(item.id));
onOpenChange(false);
}}
>
<RotateCw aria-hidden />
Erneut aufnehmen
</Link>
) : null}
<ConfirmDialog
triggerLabel="Verwerfen"
triggerAriaLabel={`Verwerfen: ${voiceNames[index]}`}
triggerVariant="ghost"
triggerSize="touch"
title={`${voiceNames[index]} verwerfen?`}
description="Die Sprachnotiz wird von diesem Gerät gelöscht und nicht mehr gesendet."
confirmLabel="Verwerfen"
onConfirm={() => discardVoice.mutate(String(item.id))}
isPending={discardVoice.isPending}
/>
</div>
</li>
))}
</ul>
</section>
) : null}
{foreignCount > 0 ? (
<p className="text-sm text-muted-foreground">{foreignEntriesWaiting(foreignCount)}</p>
) : null}
@@ -628,3 +628,68 @@ describe("bulk entries in the offline queue", () => {
expect(await onlyBulkEntry()).toBeUndefined();
});
});
describe("voice notes in the offline queue", () => {
beforeEach(() => {
globalThis.indexedDB = new IDBFactory();
});
function note(id: string, ownerUserId: string, lastError?: typeof REJECTION) {
return {
id,
ownerUserId,
blob: new Blob(["audio"]),
mime: "audio/webm;codecs=opus",
durationSec: 30,
colonyId: "colony-1",
inspectionDate: "2026-10-16",
createdAt: "2026-10-16T08:00:00.000Z",
uploadIdempotencyKey: `upload-${id}`,
state: "recorded" as const,
...(lastError ? { lastError } : {}),
};
}
async function storeNotes() {
const db = await loadDb();
await db.queueInspection(INSPECTION, "key-1", "user-1");
const raw = await openDB(DB_NAME);
await raw.put("pending-voice", note("v1", "user-1"));
await raw.put("pending-voice", note("v2", "user-1", REJECTION));
await raw.put("pending-voice", note("v3", "user-2"));
raw.close();
return db;
}
it("summarizeQueue counts waiting, rejected and foreign voice notes", async () => {
const db = await storeNotes();
expect(await db.summarizeQueue("user-1")).toEqual({
own: 2,
failed: 1,
foreign: 1,
voice: 2,
voiceWaiting: 1,
});
expect(await db.summarizeQueue("user-2")).toEqual({
own: 1,
failed: 0,
foreign: 3,
voice: 1,
voiceWaiting: 1,
});
});
it("listQueue lists the user's voice notes and never another account's", async () => {
const db = await storeNotes();
const items = (await db.listQueue("user-1")).filter((item) => item.store === "pending-voice");
expect(items.map((item) => item.id)).toEqual(["v1", "v2"]);
expect(items[0]).toMatchObject({
label: "Sprachnotiz",
voice: { colonyId: "colony-1", inspectionDate: "2026-10-16", durationSec: 30 },
});
expect(items[1].lastError).toMatchObject({ status: 400 });
});
});
+85 -6
View File
@@ -108,6 +108,8 @@ export interface StoredVoiceNote {
uploadIdempotencyKey: string;
state: "recorded" | "uploading";
lastError?: QueueError;
/** The last try was told "later": too many open notes on the server. */
blockedBy?: "TOO_MANY_OPEN";
}
export interface StoredPack {
@@ -457,6 +459,10 @@ export interface QueueSummary {
failed: number;
/** Entries of another account on this device - never sent from here. */
foreign: number;
/** Of `own` and `failed`: the user's voice notes. Left out when there are none. */
voice?: number;
/** Of `own` only: voice notes still waiting to be sent (not rejected). Left out when none. */
voiceWaiting?: number;
}
/**
@@ -470,23 +476,55 @@ async function readPhotosWaitingOnTheirOwn(): Promise<StoredPhoto[]> {
return photos.filter((photo) => photo.state === "ready");
}
/**
* What counting needs of a voice note. Read through a cursor that keeps only
* these fields: no audio Blob is held (browsers hand out a Blob as a handle to
* its file, the bytes are not read), and no schema change is needed.
*/
interface VoiceMeta {
ownerUserId: string;
lastError?: QueueError;
}
async function readVoiceMeta(): Promise<VoiceMeta[]> {
const db = await getDb();
const metas: VoiceMeta[] = [];
let cursor = await db.transaction("pending-voice").store.openCursor();
while (cursor) {
const { ownerUserId, lastError } = cursor.value;
metas.push(lastError ? { ownerUserId, lastError } : { ownerUserId });
cursor = await cursor.continue();
}
return metas;
}
export async function summarizeQueue(currentUserId: string): Promise<QueueSummary> {
const voiceNotes = await readVoiceMeta();
const waiting: { ownerUserId?: string; lastError?: QueueError }[] = [
...(await readQueue()).map(({ entry }) => entry),
...(await readPhotosWaitingOnTheirOwn()),
// Recordings wait on their own and count like entries (foreign ones as a number only).
...voiceNotes,
];
return waiting.reduce<QueueSummary>(
(summary, entry) => {
const summary = waiting.reduce<QueueSummary>(
(total, entry) => {
if (isForeignEntry(entry, currentUserId)) {
return { ...summary, foreign: summary.foreign + 1 };
return { ...total, foreign: total.foreign + 1 };
}
if (entry.lastError) {
return { ...summary, failed: summary.failed + 1 };
return { ...total, failed: total.failed + 1 };
}
return { ...summary, own: summary.own + 1 };
return { ...total, own: total.own + 1 };
},
{ own: 0, failed: 0, foreign: 0 },
);
const ownVoice = voiceNotes.filter((note) => !isForeignEntry(note, currentUserId));
const voiceWaiting = ownVoice.filter((note) => !note.lastError).length;
return {
...summary,
...(ownVoice.length > 0 ? { voice: ownVoice.length } : {}),
...(voiceWaiting > 0 ? { voiceWaiting } : {}),
};
}
/** The server refused the hive part: 403, or a conflict with the hive's current state. */
@@ -527,6 +565,14 @@ export interface QueueItem {
queuedAt: string;
lastError?: QueueError;
photoCount?: number;
/** A recorded voice note (store `pending-voice`, string id): its colony, inspection day and length. */
voice?: {
colonyId: string;
inspectionDate: string;
durationSec: number;
/** The server said "later" (too many open notes). */
blockedBy?: "TOO_MANY_OPEN";
};
/** The entry changes a colony's hive (a hive entry, or an inspection with a hive change). */
hasHiveChange?: boolean;
/** A rejected inspection whose hive change can be left out and the rest sent. */
@@ -566,6 +612,38 @@ async function resendWithoutOf(
};
}
const VOICE_LABEL = "Sprachnotiz";
/** The user's voice notes as queue items, read by cursor without keeping the audio. */
async function readVoiceItems(
db: IDBPDatabase<OfflineDB>,
ownerUserId: string,
): Promise<QueueItem[]> {
const items: QueueItem[] = [];
let cursor = await db
.transaction("pending-voice")
.store.index("ownerUserId")
.openCursor(IDBKeyRange.only(ownerUserId));
while (cursor) {
const note = cursor.value;
items.push({
store: "pending-voice",
id: note.id,
label: VOICE_LABEL,
queuedAt: note.createdAt,
voice: {
colonyId: note.colonyId,
inspectionDate: note.inspectionDate,
durationSec: note.durationSec,
...(note.blockedBy ? { blockedBy: note.blockedBy } : {}),
},
...(note.lastError ? { lastError: note.lastError } : {}),
});
cursor = await cursor.continue();
}
return items;
}
/** The signed-in user's waiting and rejected entries, oldest first. */
export async function listQueue(currentUserId: string): Promise<QueueItem[]> {
const db = await getDb();
@@ -607,7 +685,8 @@ export async function listQueue(currentUserId: string): Promise<QueueItem[]> {
return photoCount > 0 ? { ...item, ...hive, photoCount } : { ...item, ...hive };
}),
);
return items.sort((a, b) => a.queuedAt.localeCompare(b.queuedAt));
const voiceItems = await readVoiceItems(db, currentUserId);
return [...items, ...voiceItems].sort((a, b) => a.queuedAt.localeCompare(b.queuedAt));
}
function asQueueStore(store: string): QueueStoreName {
@@ -49,6 +49,11 @@ vi.mock("@/lib/photos/store", () => ({
uploadReadyPhotos: vi.fn(async () => ({ synced: 0, failed: 0, skippedForeign: 0 })),
}));
// Recordings follow in lib/voice/upload-pending-voice.spec.ts.
vi.mock("@/lib/voice/upload-pending-voice", () => ({
uploadPendingVoice: vi.fn(async () => ({ synced: 0, failed: 0, skippedForeign: 0 })),
}));
vi.mock("@/lib/auth/api-client", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/auth/api-client")>()),
apiFetch: vi.fn(async () => ({ id: "saved" })),
@@ -10,6 +10,9 @@ import {
queueRejectionText,
rejectionText,
sessionExpiredWithEntries,
voiceNotesAmong,
voiceNotesLostOnLogout,
voiceRejectionText,
} from "./queue-texts";
describe("queue texts", () => {
@@ -85,4 +88,40 @@ describe("rejectionText", () => {
).toBe("Idempotency-Key reused");
});
});
describe("voice notes", () => {
const at = "2026-10-16T08:00:00.000Z";
it.each([
[413, "TOO_LARGE", "Die Sprachnotiz ist zu groß. Bitte kürzer aufnehmen."],
[415, "UNSUPPORTED_AUDIO", "Dieses Audioformat wird nicht unterstützt. Bitte neu aufnehmen."],
[
429,
"TOO_MANY_OPEN",
"Zu viele offene Sprachnotizen. Erst auswerten oder verwerfen.",
],
])("words %i %s in German", (status, code, text) => {
expect(voiceRejectionText({ status, message: "x", details: { code }, at })).toBe(text);
});
it("falls back on the status and never shows the raw server message", () => {
expect(voiceRejectionText({ status: 403, message: "Forbidden", at })).toBe(
"Keine Berechtigung für Sprachnotizen.",
);
const neutral = "Die Sprachnotiz passt nicht mehr zur Durchsicht. Bitte neu aufnehmen.";
expect(voiceRejectionText({ status: 404, message: "Not Found", at })).toBe(neutral);
expect(voiceRejectionText({ status: 400, message: "bad", at })).toBe(neutral);
});
it("names recordings in the sign-out warning", () => {
expect(voiceNotesLostOnLogout(1)).toBe(
"Darunter 1 Sprachnotiz. Gelöschte Sprachnotizen lassen sich nicht wiederherstellen.",
);
});
it("counts recordings among the waiting entries", () => {
expect(voiceNotesAmong(1)).toBe("davon 1 Sprachnotiz");
expect(voiceNotesAmong(3)).toBe("davon 3 Sprachnotizen");
});
});
});
@@ -1,4 +1,5 @@
import { hiveReasonText } from "@/lib/hive/reason-text";
import type { QueueError } from "./db";
// German counts for the offline queue: "1 Eintrag wartet", "2 Einträge warten".
@@ -90,3 +91,53 @@ export function sessionExpiredWithEntries(n: number): string {
const rest = n === 1 ? "1 Eintrag wartet und wird danach gesendet." : `${n} Einträge warten und werden danach gesendet.`;
return `${expired} ${rest}`;
}
/** What the queue sheet needs of a rejected voice upload. */
type VoiceRejection = Pick<QueueError, "status"> & Partial<QueueError>;
const VOICE_NO_LONGER_FITS = "Die Sprachnotiz passt nicht mehr zur Durchsicht. Bitte neu aufnehmen.";
/** A run stopped because the server holds too many open notes; they go later. */
export const VOICE_TOO_MANY_OPEN_HINT =
"Zu viele offene Sprachnotizen – sie werden später gesendet.";
const VOICE_REJECTION_BY_CODE: Record<string, string> = {
TOO_LARGE: "Die Sprachnotiz ist zu groß. Bitte kürzer aufnehmen.",
UNSUPPORTED_AUDIO: "Dieses Audioformat wird nicht unterstützt. Bitte neu aufnehmen.",
TOO_MANY_OPEN: "Zu viele offene Sprachnotizen. Erst auswerten oder verwerfen.",
VOICE_CAPTURE_GONE: "Die Sprachnotiz gibt es nicht mehr.",
};
/**
* Why the server refused a recording, in words the beekeeper can act on.
* Fixed texts by the server's error code, then by status; never the server's
* own message, so nothing unexpected reaches the screen.
*/
export function voiceRejectionText(error: VoiceRejection): string {
const code = (error.details as { code?: unknown } | null | undefined)?.code;
if (typeof code === "string" && code in VOICE_REJECTION_BY_CODE) {
return VOICE_REJECTION_BY_CODE[code];
}
switch (error.status) {
case 403:
return "Keine Berechtigung für Sprachnotizen.";
case 413:
return VOICE_REJECTION_BY_CODE.TOO_LARGE;
case 415:
return VOICE_REJECTION_BY_CODE.UNSUPPORTED_AUDIO;
default:
// 400 without a code (colony gone, date too old) and anything unforeseen.
return VOICE_NO_LONGER_FITS;
}
}
/** „davon 2 Sprachnotizen“ - the part of the waiting entries that are voice notes. */
export function voiceNotesAmong(n: number): string {
return `davon ${count(n, "Sprachnotiz", "Sprachnotizen")}`;
}
/** Sign-out dialog: recordings are deleted for good, so they are named. */
export function voiceNotesLostOnLogout(n: number): string {
const recordings = count(n, "Sprachnotiz", "Sprachnotizen");
return `Darunter ${recordings}. Gelöschte Sprachnotizen lassen sich nicht wiederherstellen.`;
}
@@ -9,6 +9,7 @@ vi.mock("@/lib/treatments/api", () => ({ createTreatment: vi.fn() }));
vi.mock("@/lib/documents/api", () => ({ uploadDocument: vi.fn() }));
vi.mock("@/lib/phenology/api", () => ({ createPhenologyReport: vi.fn() }));
vi.mock("@/lib/hive/api", () => ({ postHiveChange: vi.fn() }));
vi.mock("@/lib/voice/api", () => ({ uploadVoiceCapture: vi.fn() }));
vi.mock("@/lib/apiaries/offline-pack", () => ({ refreshPacksAfterSync: vi.fn() }));
// What the inspection drain hands over to the photos, seen at that moment.
@@ -131,6 +132,66 @@ describe("syncPendingEntries", () => {
expect(await db.getPendingEntries("pending-inspections")).toEqual([]);
});
it("uploads recorded voice notes after the records, inside the run", async () => {
const order: string[] = [];
const { uploadVoiceCapture } = await import("@/lib/voice/api");
vi.mocked(uploadVoiceCapture).mockReset().mockImplementation(async () => {
order.push("voice");
return { id: "cap-1", status: "QUEUED" as const };
});
createInspection.mockImplementation(async () => {
order.push("inspection");
return { id: "x" } as never;
});
await db.queueInspection(inspection(1), "key-1", "user-1");
const raw = await openDB("apicore-offline");
await raw.put("pending-voice", {
id: "v1",
ownerUserId: "user-1",
blob: new Blob(["audio"]),
mime: "audio/webm",
durationSec: 20,
colonyId: "colony-1",
inspectionDate: "2026-10-16",
createdAt: "2026-10-16T08:00:00.000Z",
uploadIdempotencyKey: "upload-v1",
state: "recorded",
});
raw.close();
const result = await sync.syncPendingEntries("user-1");
expect(order).toEqual(["inspection", "voice"]);
expect(result).toEqual({ synced: 2, failed: 0, skippedForeign: 0 });
});
it("a cancelled run sends no voice note", async () => {
const { uploadVoiceCapture } = await import("@/lib/voice/api");
vi.mocked(uploadVoiceCapture).mockReset();
await db.getPendingEntries("pending-inspections");
const raw = await openDB("apicore-offline");
await raw.put("pending-voice", {
id: "v1",
ownerUserId: "user-1",
blob: new Blob(["audio"]),
mime: "audio/webm",
durationSec: 20,
colonyId: "colony-1",
inspectionDate: "2026-10-16",
createdAt: "2026-10-16T08:00:00.000Z",
uploadIdempotencyKey: "upload-v1",
state: "recorded",
});
raw.close();
const run = sync.syncPendingEntries("user-1");
await sync.cancelSync();
await run;
sync.resumeSync();
expect(uploadVoiceCapture).not.toHaveBeenCalled();
});
it("offline the plant report is queued and synced later with its key", async () => {
const report = {
phase: "SALWEIDE",
+5 -1
View File
@@ -18,6 +18,7 @@ import {
} from "@/lib/bulk/api";
import { refreshPacksAfterSync } from "@/lib/apiaries/offline-pack";
import type { CreateInspectionInput, Inspection } from "@/lib/inspections/types";
import { uploadPendingVoice } from "@/lib/voice/upload-pending-voice";
import { finishHandOvers, resolveQueuedInspection, uploadReadyPhotos } from "@/lib/photos/store";
import {
BulkKind,
@@ -493,6 +494,9 @@ async function runSync(run: SyncRun): Promise<SyncResult> {
await finishHandOvers(run.userId);
}
const photos = await uploadReadyPhotos(run.userId, uploadDocument, run.isCancelled);
// Recordings after records and photos: they are the largest, and a weak
// connection should deliver the small things first.
const voice = await uploadPendingVoice(run.userId, run.isCancelled);
// Packs last, quietly and only with the run user's token: what the server
// has just accepted or rejected (a colony ended since the pack was loaded)
// shows up offline too - the packs of rejected colonies at once, not only
@@ -503,5 +507,5 @@ async function runSync(run: SyncRun): Promise<SyncResult> {
? refreshPacksAfterSync(run.userId, Date.now(), run.isCancelled, { force: true, colonyIds })
: refreshPacksAfterSync(run.userId, Date.now(), run.isCancelled));
}
return [...records, photos].reduce(addResults, NOTHING_SYNCED);
return [...records, photos, voice].reduce(addResults, NOTHING_SYNCED);
}
@@ -0,0 +1,68 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { apiFetch, apiFetchFormData } from "@/lib/auth/api-client";
import {
discardAllVoiceCaptures,
discardVoiceCapture,
fetchVoiceCaptures,
retryVoiceCapture,
submitVoiceText,
uploadVoiceCapture,
voiceKeys,
} from "./api";
vi.mock("@/lib/auth/api-client", () => ({ apiFetch: vi.fn(), apiFetchFormData: vi.fn() }));
describe("voice api", () => {
beforeEach(() => {
vi.mocked(apiFetch).mockReset().mockResolvedValue(undefined);
vi.mocked(apiFetchFormData).mockReset().mockResolvedValue({ id: "c1", status: "QUEUED" });
});
it("uploads the audio as multipart with the note's Idempotency-Key", async () => {
const blob = new Blob(["a"], { type: "audio/webm;codecs=opus" });
await uploadVoiceCapture({
id: "n1",
ownerUserId: "u1",
blob,
mime: blob.type,
durationSec: 42.4,
colonyId: "col-1",
inspectionDate: "2026-10-16",
createdAt: "2026-10-16T08:00:00.000Z",
uploadIdempotencyKey: "key-1",
state: "recorded",
});
const [path, form, options] = vi.mocked(apiFetchFormData).mock.calls[0];
expect(path).toBe("/voice-captures");
const data = form as FormData;
expect(data.get("colonyId")).toBe("col-1");
expect(data.get("inspectionDate")).toBe("2026-10-16");
expect(data.get("durationSec")).toBe("42");
expect(data.get("audio")).toBeInstanceOf(Blob);
expect(options?.headers).toEqual({ "Idempotency-Key": "key-1" });
});
it("sends text with its key", async () => {
await submitVoiceText({ colonyId: "c", inspectionDate: "2026-10-16", text: "x" }, "k");
const [path, init] = vi.mocked(apiFetch).mock.calls[0];
expect(path).toBe("/voice-captures/text");
expect(init).toMatchObject({ method: "POST", headers: { "Idempotency-Key": "k" } });
});
it("lists by status and acts on one capture", async () => {
await fetchVoiceCaptures("READY");
expect(vi.mocked(apiFetch).mock.calls[0][0]).toBe("/voice-captures?status=READY");
await retryVoiceCapture("a b");
expect(vi.mocked(apiFetch).mock.calls[1][0]).toBe("/voice-captures/a%20b/retry");
await discardVoiceCapture("x");
expect(vi.mocked(apiFetch).mock.calls[2][0]).toBe("/voice-captures/x/discard");
await discardAllVoiceCaptures();
expect(vi.mocked(apiFetch).mock.calls[3][0]).toBe("/voice-captures/discard-all");
});
it("has stable query keys", () => {
expect(voiceKeys.detail("1")).toEqual(["voice-captures", "1"]);
});
});
+118
View File
@@ -0,0 +1,118 @@
import { apiFetch, apiFetchFormData } from "@/lib/auth/api-client";
import { idempotencyHeaders } from "@/lib/offline/idempotency";
import type { StoredVoiceNote } from "@/lib/offline/db";
/*
* Client for /voice-captures. Shapes mirror ApiCore-Backend voice-capture-view.ts.
* Nothing here logs: audio, transcripts and suggestions are content.
*/
export type VoiceCaptureStatus =
| "QUEUED"
| "TRANSCRIBING"
| "TRANSCRIBED"
| "EXTRACTING"
| "READY"
| "FAILED"
| "CONFIRMED"
| "DISCARDED"
| "EXPIRED";
export interface VoiceCaptureListItem {
id: string;
colonyId: string;
colonyName: string;
status: VoiceCaptureStatus;
source: "AUDIO" | "TEXT";
createdAt: string;
lastErrorCode: string | null;
contentExpiresAt: string;
}
export interface VoiceSuggestion {
field: string;
value: number | boolean | string;
quote: string;
corrected: boolean;
truncated: boolean;
previous?: { value: number | boolean | string; quote: string };
}
export interface VoiceCaptureDetail extends VoiceCaptureListItem {
inspectionDate: string;
transcript: string | null;
suggestions: VoiceSuggestion[] | null;
unclear: string[];
dropped: number;
}
export interface VoiceClientConfig {
recordingEnabled: boolean;
extractionEnabled: boolean;
model: string;
retention: { audioHours: number; contentDays: number; rowDays: number };
}
export const voiceKeys = {
all: ["voice-captures"] as const,
detail: (id: string) => ["voice-captures", id] as const,
config: ["voice-captures", "config"] as const,
};
const BASE = "/voice-captures";
const idPath = (id: string, action?: string): string =>
`${BASE}/${encodeURIComponent(id)}${action ? `/${action}` : ""}`;
/**
* Uploads one stored recording. The note's own key makes a repeat after a lost
* answer return the first capture instead of creating a second one.
*/
export function uploadVoiceCapture(
note: StoredVoiceNote,
): Promise<{ id: string; status: "QUEUED" }> {
const formData = new FormData();
formData.append("colonyId", note.colonyId);
formData.append("inspectionDate", note.inspectionDate);
formData.append("durationSec", String(Math.max(1, Math.round(note.durationSec))));
formData.append("audio", note.blob, "aufnahme");
return apiFetchFormData<{ id: string; status: "QUEUED" }>(BASE, formData, {
headers: idempotencyHeaders({ idempotencyKey: note.uploadIdempotencyKey }),
});
}
export function submitVoiceText(
input: { colonyId: string; inspectionDate: string; text: string },
idempotencyKey: string,
): Promise<{ id: string }> {
return apiFetch<{ id: string }>(`${BASE}/text`, {
method: "POST",
headers: idempotencyHeaders({ idempotencyKey }),
body: JSON.stringify(input),
});
}
export function fetchVoiceCaptures(status?: string): Promise<VoiceCaptureListItem[]> {
const query = status ? `?${new URLSearchParams({ status }).toString()}` : "";
return apiFetch<VoiceCaptureListItem[]>(`${BASE}${query}`);
}
export function fetchVoiceCapture(id: string): Promise<VoiceCaptureDetail> {
return apiFetch<VoiceCaptureDetail>(idPath(id));
}
export function fetchVoiceConfig(): Promise<VoiceClientConfig> {
return apiFetch<VoiceClientConfig>(`${BASE}/config`);
}
export function retryVoiceCapture(id: string): Promise<void> {
return apiFetch<void>(idPath(id, "retry"), { method: "POST" });
}
export function discardVoiceCapture(id: string): Promise<void> {
return apiFetch<void>(idPath(id, "discard"), { method: "POST" });
}
export function discardAllVoiceCaptures(): Promise<{ discarded: number }> {
return apiFetch<{ discarded: number }>(`${BASE}/discard-all`, { method: "POST" });
}
@@ -1,4 +1,4 @@
import { getDb, type StoredVoiceNote } from "@/lib/offline/db";
import { getDb, type QueueError, type StoredVoiceNote } from "@/lib/offline/db";
import { newIdempotencyKey } from "@/lib/offline/idempotency";
export type { StoredVoiceNote } from "@/lib/offline/db";
@@ -13,7 +13,7 @@ export type { StoredVoiceNote } from "@/lib/offline/db";
/** The device had no room left for another recording. */
export class VoiceStorageFullError extends Error {
constructor() {
super("Auf dem Gerät ist kein Platz mehr für die Aufnahme.");
super("Auf dem Gerät ist kein Platz mehr für die Sprachnotiz.");
this.name = "VoiceStorageFullError";
}
}
@@ -98,3 +98,33 @@ export async function discardAllPendingVoice(ownerUserId: string): Promise<numbe
await tx.done;
return ids.length;
}
/** One note by id, or undefined when it is gone (discarded meanwhile). */
export async function getPendingVoice(id: string): Promise<StoredVoiceNote | undefined> {
const db = await getDb();
return db.get("pending-voice", id);
}
/** Books the server's refusal on a note that still exists; the audio stays for the user to see. */
export async function markVoiceFailed(id: string, error: QueueError): Promise<void> {
const db = await getDb();
const tx = db.transaction("pending-voice", "readwrite");
const note = await tx.store.get(id);
if (note) {
const rejected: StoredVoiceNote = { ...note, lastError: error, state: "recorded" };
delete rejected.blockedBy;
await tx.store.put(rejected);
}
await tx.done;
}
/** Notes that the server could take later (too many open): the sheet says so. */
export async function markVoiceBlocked(id: string, blockedBy: "TOO_MANY_OPEN"): Promise<void> {
const db = await getDb();
const tx = db.transaction("pending-voice", "readwrite");
const note = await tx.store.get(id);
if (note) {
await tx.store.put({ ...note, blockedBy });
}
await tx.done;
}
@@ -0,0 +1,192 @@
import "fake-indexeddb/auto";
import { IDBFactory } from "fake-indexeddb";
import { beforeEach, describe, expect, it, vi, type MockedFunction } from "vitest";
vi.mock("./api", () => ({ uploadVoiceCapture: vi.fn() }));
type Upload = MockedFunction<typeof import("./api").uploadVoiceCapture>;
let pending: typeof import("./pending-voice");
let upload: typeof import("./upload-pending-voice");
let uploadVoiceCapture: Upload;
let ApiError: typeof import("@/lib/auth/api-client").ApiError;
const input = (ownerUserId = "user-1", colonyId = "colony-1") => ({
ownerUserId,
blob: new Blob(["audio"], { type: "audio/webm;codecs=opus" }),
mime: "audio/webm;codecs=opus",
durationSec: 42,
colonyId,
inspectionDate: "2026-10-16",
});
const never = () => false;
describe("uploadPendingVoice", () => {
beforeEach(async () => {
globalThis.indexedDB = new IDBFactory();
vi.stubGlobal("navigator", { storage: { persist: vi.fn().mockResolvedValue(true) } });
vi.resetModules();
pending = await import("./pending-voice");
upload = await import("./upload-pending-voice");
uploadVoiceCapture = vi.mocked((await import("./api")).uploadVoiceCapture);
ApiError = (await import("@/lib/auth/api-client")).ApiError;
uploadVoiceCapture.mockReset();
uploadVoiceCapture.mockResolvedValue({ id: "cap-1", status: "QUEUED" });
});
it("sends a recorded note with its stable key", async () => {
const note = await pending.queueVoiceNote(input());
const result = await upload.uploadPendingVoice("user-1", never);
expect(result).toEqual({ synced: 1, failed: 0, skippedForeign: 0 });
expect(uploadVoiceCapture).toHaveBeenCalledTimes(1);
expect(uploadVoiceCapture.mock.calls[0][0].uploadIdempotencyKey).toBe(
note.uploadIdempotencyKey,
);
});
it("deletes the local audio after a 2xx", async () => {
await pending.queueVoiceNote(input());
await upload.uploadPendingVoice("user-1", never);
expect(await pending.listPendingVoice("user-1")).toEqual([]);
});
it("sends the same key again after a lost answer", async () => {
const note = await pending.queueVoiceNote(input());
uploadVoiceCapture.mockRejectedValueOnce(new TypeError("Failed to fetch"));
await upload.uploadPendingVoice("user-1", never);
await upload.uploadPendingVoice("user-1", never);
const keys = uploadVoiceCapture.mock.calls.map(([n]) => n.uploadIdempotencyKey);
expect(keys).toEqual([note.uploadIdempotencyKey, note.uploadIdempotencyKey]);
expect(await pending.listPendingVoice("user-1")).toEqual([]);
});
it("keeps 4xx rejections as lastError and does not send them again", async () => {
await pending.queueVoiceNote(input());
uploadVoiceCapture.mockRejectedValueOnce(
new ApiError(415, "Dieses Audioformat wird nicht unterstützt.", {
code: "UNSUPPORTED_AUDIO",
}),
);
const first = await upload.uploadPendingVoice("user-1", never);
const second = await upload.uploadPendingVoice("user-1", never);
const [note] = await pending.listPendingVoice("user-1");
expect(note.lastError).toMatchObject({ status: 415, details: { code: "UNSUPPORTED_AUDIO" } });
expect(first.failed).toBe(1);
expect(second).toEqual({ synced: 0, failed: 0, skippedForeign: 0 });
expect(uploadVoiceCapture).toHaveBeenCalledTimes(1);
});
it.each([429, 503, 408])("treats %i as try later and keeps the rest", async (status) => {
await pending.queueVoiceNote(input());
await pending.queueVoiceNote(input("user-1", "colony-2"));
uploadVoiceCapture.mockRejectedValueOnce(new ApiError(status, "später"));
const result = await upload.uploadPendingVoice("user-1", never);
expect(result.synced).toBe(0);
const notes = await pending.listPendingVoice("user-1");
expect(notes).toHaveLength(2);
expect(notes.every((n) => n.lastError === undefined)).toBe(true);
});
it("a 409 'still being processed' skips that note and goes on", async () => {
await pending.queueVoiceNote(input());
await pending.queueVoiceNote(input("user-1", "colony-2"));
uploadVoiceCapture.mockRejectedValueOnce(
new ApiError(409, "Diese Anfrage wird bereits verarbeitet - bitte kurz warten"),
);
const result = await upload.uploadPendingVoice("user-1", never);
expect(uploadVoiceCapture).toHaveBeenCalledTimes(2);
expect(result).toEqual({ synced: 1, failed: 0, skippedForeign: 0 });
const [left] = await pending.listPendingVoice("user-1");
expect(left.lastError).toBeUndefined();
});
it("any other 409 is a real refusal and is kept on the note", async () => {
await pending.queueVoiceNote(input());
uploadVoiceCapture.mockRejectedValueOnce(new ApiError(409, "Schlüssel schon benutzt"));
await upload.uploadPendingVoice("user-1", never);
const [note] = await pending.listPendingVoice("user-1");
expect(note.lastError).toMatchObject({ status: 409 });
});
it("remembers that a run stopped on TOO_MANY_OPEN", async () => {
await pending.queueVoiceNote(input());
uploadVoiceCapture.mockRejectedValueOnce(
new ApiError(429, "x", { code: "TOO_MANY_OPEN" }),
);
await upload.uploadPendingVoice("user-1", never);
const [note] = await pending.listPendingVoice("user-1");
expect(note.blockedBy).toBe("TOO_MANY_OPEN");
expect(note.lastError).toBeUndefined();
});
it("stops on a network error and keeps the rest", async () => {
await pending.queueVoiceNote(input());
await pending.queueVoiceNote(input("user-1", "colony-2"));
uploadVoiceCapture.mockRejectedValue(new TypeError("Failed to fetch"));
const result = await upload.uploadPendingVoice("user-1", never);
expect(uploadVoiceCapture).toHaveBeenCalledTimes(1);
expect(result.failed).toBe(1);
expect(await pending.listPendingVoice("user-1")).toHaveLength(2);
});
it("stops on 401 without marking the note", async () => {
await pending.queueVoiceNote(input());
uploadVoiceCapture.mockRejectedValue(new ApiError(401, "Nicht angemeldet"));
await upload.uploadPendingVoice("user-1", never);
const [note] = await pending.listPendingVoice("user-1");
expect(note.lastError).toBeUndefined();
});
it("never sends another user's recordings", async () => {
await pending.queueVoiceNote(input("user-2"));
const result = await upload.uploadPendingVoice("user-1", never);
expect(uploadVoiceCapture).not.toHaveBeenCalled();
expect(result.skippedForeign).toBe(1);
expect(await pending.listPendingVoice("user-2")).toHaveLength(1);
});
it("a cancelled run sends no voice note", async () => {
await pending.queueVoiceNote(input());
await upload.uploadPendingVoice("user-1", () => true);
expect(uploadVoiceCapture).not.toHaveBeenCalled();
expect(await pending.listPendingVoice("user-1")).toHaveLength(1);
});
it("skips a note discarded while the run was going", async () => {
const first = await pending.queueVoiceNote(input());
await pending.queueVoiceNote(input("user-1", "colony-2"));
uploadVoiceCapture.mockImplementationOnce(async () => {
const [, second] = await pending.listPendingVoice("user-1");
await pending.discardPendingVoice(second.id, "user-1");
return { id: first.id, status: "QUEUED" as const };
});
const result = await upload.uploadPendingVoice("user-1", never);
expect(uploadVoiceCapture).toHaveBeenCalledTimes(1);
expect(result.synced).toBe(1);
});
});
@@ -0,0 +1,81 @@
import { ApiError } from "@/lib/auth/api-client";
import { isRetryLaterError } from "@/lib/bulk/api";
import { toQueueError } from "@/lib/offline/db";
import type { SyncResult } from "@/lib/offline/sync";
import { uploadVoiceCapture } from "./api";
import {
countForeignVoice,
discardPendingVoice,
getPendingVoice,
listPendingVoice,
markVoiceBlocked,
markVoiceFailed,
} from "./pending-voice";
const UNAUTHORIZED = 401;
/** 4xx answers that say „not now“, not „this note is wrong“. */
const LATER_STATUSES: readonly number[] = [408, 429];
const TOO_MANY_OPEN = "TOO_MANY_OPEN";
const SERVER_ERROR_FROM = 500;
function hasCode(error: unknown, code: string): boolean {
return (
error instanceof ApiError && (error.body as { code?: unknown } | null | undefined)?.code === code
);
}
/**
* Sends the run user's recorded voice notes one after the other, oldest first.
* 2xx deletes the local audio at once. A definitive refusal (4xx) is stored on
* the note, which then waits for the user and is not sent again. A network
* error, 5xx, 408 and 429 stop the run: the rest waits, the same key
* goes out next time. A 409 „still being processed“ skips that note only. 401 stops the run and leaves the note alone.
* Another account's notes are never sent, only counted.
*/
export async function uploadPendingVoice(
userId: string,
isCancelled: () => boolean,
): Promise<SyncResult> {
const own = (await listPendingVoice(userId)).filter((note) => !note.lastError);
let synced = 0;
let failed = 0;
const skippedForeign = await countForeignVoice(userId);
for (const { id } of own) {
const note = await getPendingVoice(id);
if (isCancelled()) {
break;
}
if (!note || note.lastError || note.ownerUserId !== userId) {
continue;
}
try {
await uploadVoiceCapture(note);
} catch (error) {
if (isRetryLaterError(error)) {
continue;
}
if (error instanceof ApiError && error.status === UNAUTHORIZED) {
break;
}
failed += 1;
const isLater =
!(error instanceof ApiError) ||
error.status >= SERVER_ERROR_FROM ||
LATER_STATUSES.includes(error.status);
if (isLater) {
if (hasCode(error, TOO_MANY_OPEN)) {
await markVoiceBlocked(id, TOO_MANY_OPEN);
}
break;
}
await markVoiceFailed(id, toQueueError(error));
continue;
}
// Deleted by id; the user may have discarded it meanwhile - then nothing is left to do.
await discardPendingVoice(id, userId);
synced += 1;
}
return { synced, failed, skippedForeign };
}