Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e614ded967 | ||
|
|
0c7147f626 | ||
|
|
560799c723 | ||
|
|
ce472164b5 |
No files matched your search
@@ -22,7 +22,7 @@ jobs:
|
|||||||
REGISTRY_PUSH: gitea:80
|
REGISTRY_PUSH: gitea:80
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
|
|
||||||
- name: Image-Namen in Kleinbuchstaben ermitteln
|
- name: Image-Namen in Kleinbuchstaben ermitteln
|
||||||
# OCI-Registries erlauben nur Kleinbuchstaben im Repository-Pfad;
|
# OCI-Registries erlauben nur Kleinbuchstaben im Repository-Pfad;
|
||||||
@@ -31,7 +31,7 @@ jobs:
|
|||||||
run: echo "name=$(echo '${{ gitea.repository_owner }}/apicore-backend' | tr '[:upper:]' '[:lower:]')" >> "$GITEA_OUTPUT"
|
run: echo "name=$(echo '${{ gitea.repository_owner }}/apicore-backend' | tr '[:upper:]' '[:lower:]')" >> "$GITEA_OUTPUT"
|
||||||
|
|
||||||
- name: Docker Buildx einrichten
|
- name: Docker Buildx einrichten
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||||
with:
|
with:
|
||||||
# REGISTRY_PUSH ist Klartext-HTTP (Gitea-Service intern ohne TLS)
|
# REGISTRY_PUSH ist Klartext-HTTP (Gitea-Service intern ohne TLS)
|
||||||
# statt HTTPS — BuildKit lehnt das ohne diese Freigabe ab.
|
# statt HTTPS — BuildKit lehnt das ohne diese Freigabe ab.
|
||||||
@@ -40,7 +40,7 @@ jobs:
|
|||||||
http = true
|
http = true
|
||||||
|
|
||||||
- name: Bei der Gitea-Container-Registry anmelden
|
- name: Bei der Gitea-Container-Registry anmelden
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY_PUSH }}
|
registry: ${{ env.REGISTRY_PUSH }}
|
||||||
username: ${{ gitea.actor }}
|
username: ${{ gitea.actor }}
|
||||||
@@ -50,7 +50,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Metadaten (Tags/Labels) ermitteln
|
- name: Metadaten (Tags/Labels) ermitteln
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
|
||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY_PUSH }}/${{ steps.image.outputs.name }}
|
images: ${{ env.REGISTRY_PUSH }}/${{ steps.image.outputs.name }}
|
||||||
tags: |
|
tags: |
|
||||||
@@ -58,7 +58,7 @@ jobs:
|
|||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
|
|
||||||
- name: Image bauen und pushen
|
- name: Image bauen und pushen
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||||
with:
|
with:
|
||||||
context: ./ApiCore-Backend
|
context: ./ApiCore-Backend
|
||||||
push: true
|
push: true
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ jobs:
|
|||||||
REGISTRY_PUSH: gitea:80
|
REGISTRY_PUSH: gitea:80
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
|
|
||||||
- name: Image-Namen in Kleinbuchstaben ermitteln
|
- name: Image-Namen in Kleinbuchstaben ermitteln
|
||||||
# OCI-Registries erlauben nur Kleinbuchstaben im Repository-Pfad;
|
# OCI-Registries erlauben nur Kleinbuchstaben im Repository-Pfad;
|
||||||
@@ -31,7 +31,7 @@ jobs:
|
|||||||
run: echo "name=$(echo '${{ gitea.repository_owner }}/apicore-frontend' | tr '[:upper:]' '[:lower:]')" >> "$GITEA_OUTPUT"
|
run: echo "name=$(echo '${{ gitea.repository_owner }}/apicore-frontend' | tr '[:upper:]' '[:lower:]')" >> "$GITEA_OUTPUT"
|
||||||
|
|
||||||
- name: Docker Buildx einrichten
|
- name: Docker Buildx einrichten
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||||
with:
|
with:
|
||||||
# REGISTRY_PUSH ist Klartext-HTTP (Gitea-Service intern ohne TLS)
|
# REGISTRY_PUSH ist Klartext-HTTP (Gitea-Service intern ohne TLS)
|
||||||
# statt HTTPS — BuildKit lehnt das ohne diese Freigabe ab.
|
# statt HTTPS — BuildKit lehnt das ohne diese Freigabe ab.
|
||||||
@@ -40,7 +40,7 @@ jobs:
|
|||||||
http = true
|
http = true
|
||||||
|
|
||||||
- name: Bei der Gitea-Container-Registry anmelden
|
- name: Bei der Gitea-Container-Registry anmelden
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY_PUSH }}
|
registry: ${{ env.REGISTRY_PUSH }}
|
||||||
username: ${{ gitea.actor }}
|
username: ${{ gitea.actor }}
|
||||||
@@ -50,7 +50,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Metadaten (Tags/Labels) ermitteln
|
- name: Metadaten (Tags/Labels) ermitteln
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
|
||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY_PUSH }}/${{ steps.image.outputs.name }}
|
images: ${{ env.REGISTRY_PUSH }}/${{ steps.image.outputs.name }}
|
||||||
tags: |
|
tags: |
|
||||||
@@ -58,7 +58,7 @@ jobs:
|
|||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
|
|
||||||
- name: Image bauen und pushen
|
- name: Image bauen und pushen
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||||
with:
|
with:
|
||||||
context: ./ApiCore-Frontend
|
context: ./ApiCore-Frontend
|
||||||
push: true
|
push: true
|
||||||
|
|||||||
@@ -32,10 +32,10 @@ jobs:
|
|||||||
YQ_SHA256: c5f056448f973ae7d39b5401949648a78f2dc1947d6a8eb65be60d5c504b9385
|
YQ_SHA256: c5f056448f973ae7d39b5401949648a78f2dc1947d6a8eb65be60d5c504b9385
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
|
|
||||||
- name: Node einrichten
|
- name: Node einrichten
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,32 @@ describe("LogoutConfirmDialog", () => {
|
|||||||
expect(screen.getByRole("button", { name: "Abbrechen" })).toBeVisible();
|
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 () => {
|
it("deletes the entries only after the destructive button was pressed", async () => {
|
||||||
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 });
|
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 });
|
||||||
renderDialog();
|
renderDialog();
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
import { useAuth } from "@/lib/auth/auth-context";
|
import { useAuth } from "@/lib/auth/auth-context";
|
||||||
import { useConnectivity } from "@/lib/offline/connectivity";
|
import { useConnectivity } from "@/lib/offline/connectivity";
|
||||||
import { summarizeQueue } from "@/lib/offline/db";
|
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";
|
import { syncPendingEntries } from "@/lib/offline/sync";
|
||||||
|
|
||||||
interface LogoutConfirmDialogProps {
|
interface LogoutConfirmDialogProps {
|
||||||
@@ -24,14 +24,20 @@ interface LogoutConfirmDialogProps {
|
|||||||
onLoggedOut: () => void;
|
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. */
|
/** 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 {
|
try {
|
||||||
const { own, failed } = await summarizeQueue(userId);
|
const { own, failed, voice = 0 } = await summarizeQueue(userId);
|
||||||
return own + failed;
|
return { entries: own + failed, voice };
|
||||||
} catch {
|
} catch {
|
||||||
// The queue cannot be read, so it cannot be lost either.
|
// 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) {
|
}: LogoutConfirmDialogProps) {
|
||||||
const { logout, sessionMode } = useAuth();
|
const { logout, sessionMode } = useAuth();
|
||||||
const connectivity = useConnectivity();
|
const connectivity = useConnectivity();
|
||||||
const [unsent, setUnsent] = useState<number | null>(null);
|
const [unsent, setUnsent] = useState<Unsent | null>(null);
|
||||||
const [isBusy, setIsBusy] = useState(false);
|
const [isBusy, setIsBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [wasOpen, setWasOpen] = useState(open);
|
const [wasOpen, setWasOpen] = useState(open);
|
||||||
@@ -81,14 +87,14 @@ export function LogoutConfirmDialog({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let isCurrent = true;
|
let isCurrent = true;
|
||||||
void countUnsent(userId).then((count) => {
|
void countUnsent(userId).then((counted) => {
|
||||||
if (!isCurrent) {
|
if (!isCurrent) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (count === 0) {
|
if (counted.entries === 0) {
|
||||||
void signOut(false);
|
void signOut(false);
|
||||||
} else {
|
} else {
|
||||||
setUnsent(count);
|
setUnsent(counted);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
@@ -107,7 +113,7 @@ export function LogoutConfirmDialog({
|
|||||||
// Whatever could not be sent is counted below.
|
// Whatever could not be sent is counted below.
|
||||||
}
|
}
|
||||||
const remaining = await countUnsent(userId);
|
const remaining = await countUnsent(userId);
|
||||||
if (remaining === 0) {
|
if (remaining.entries === 0) {
|
||||||
await signOut(false);
|
await signOut(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -117,7 +123,7 @@ export function LogoutConfirmDialog({
|
|||||||
|
|
||||||
// An offline session has no token: nothing can be sent, whatever the network says.
|
// An offline session has no token: nothing can be sent, whatever the network says.
|
||||||
const isOnline = connectivity === "online" && sessionMode !== "offline";
|
const isOnline = connectivity === "online" && sessionMode !== "offline";
|
||||||
const hasEntries = unsent !== null && unsent > 0;
|
const hasEntries = unsent !== null && unsent.entries > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open && (hasEntries || error !== null)} onOpenChange={onOpenChange}>
|
<Dialog open={open && (hasEntries || error !== null)} onOpenChange={onOpenChange}>
|
||||||
@@ -126,7 +132,9 @@ export function LogoutConfirmDialog({
|
|||||||
<DialogTitle>Abmelden?</DialogTitle>
|
<DialogTitle>Abmelden?</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{hasEntries
|
{hasEntries
|
||||||
? `${entriesNotSent(unsent)} ${
|
? `${entriesNotSent(unsent.entries)} ${
|
||||||
|
unsent.voice > 0 ? `${voiceNotesLostOnLogout(unsent.voice)} ` : ""
|
||||||
|
}${
|
||||||
isOnline
|
isOnline
|
||||||
? "Sende sie zuerst, oder melde dich ab und lösche sie."
|
? "Sende sie zuerst, oder melde dich ab und lösche sie."
|
||||||
: "Ohne Netz kannst du sie nicht senden. Beim Abmelden werden sie gelöscht."
|
: "Ohne Netz kannst du sie nicht senden. Beim Abmelden werden sie gelöscht."
|
||||||
|
|||||||
@@ -99,6 +99,50 @@ describe("OfflineBand", () => {
|
|||||||
expect(syncPendingEntries).not.toHaveBeenCalled();
|
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 () => {
|
it("says „1 Eintrag wartet\" for a single entry", async () => {
|
||||||
setOnLine(false);
|
setOnLine(false);
|
||||||
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 });
|
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 { cn } from "@/lib/utils";
|
||||||
import { useConnectivity } from "@/lib/offline/connectivity";
|
import { useConnectivity } from "@/lib/offline/connectivity";
|
||||||
import { QueueSummary, summarizeQueue } from "@/lib/offline/db";
|
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 { resumeSync, syncPendingEntries } from "@/lib/offline/sync";
|
||||||
import { OFFLINE_QUEUE_QUERY_KEY } from "@/lib/offline/use-capture-mutation";
|
import { OFFLINE_QUEUE_QUERY_KEY } from "@/lib/offline/use-capture-mutation";
|
||||||
import { PendingQueueSheet } from "./pending-queue-sheet";
|
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.
|
// An offline session cannot send, whatever the network says.
|
||||||
const isOffline = connectivity === "offline" || !syncEnabled;
|
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;
|
const hasContent = isOffline || queue.own > 0 || hasRejected;
|
||||||
|
|
||||||
// Back to the trigger while it exists; otherwise to the band's message, or
|
// 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">
|
<p className="flex items-center gap-2 font-medium">
|
||||||
<CloudOff className="size-4 shrink-0" aria-hidden />
|
<CloudOff className="size-4 shrink-0" aria-hidden />
|
||||||
{queue.own > 0
|
{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."}
|
: "Offline – Erfassungen werden gespeichert und später gesendet. Vorschläge und Aufgaben brauchen eine Verbindung."}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -157,6 +164,9 @@ export function OfflineBand({ userId, pageContentRef, syncEnabled = true }: Offl
|
|||||||
{sync.isPending ? "Wird gesendet…" : `${entriesWaiting(queue.own)} – Jetzt senden`}
|
{sync.isPending ? "Wird gesendet…" : `${entriesWaiting(queue.own)} – Jetzt senden`}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
{!isOffline && queue.own > 0 && queue.voiceWaiting ? (
|
||||||
|
<span>{voiceSuffix.trim()}</span>
|
||||||
|
) : null}
|
||||||
<PendingQueueSheet
|
<PendingQueueSheet
|
||||||
userId={userId}
|
userId={userId}
|
||||||
foreignCount={queue.foreign}
|
foreignCount={queue.foreign}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
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 userEvent from "@testing-library/user-event";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
type StoredPhoto,
|
type StoredPhoto,
|
||||||
} from "@/lib/offline/db";
|
} from "@/lib/offline/db";
|
||||||
import { syncPendingEntries } from "@/lib/offline/sync";
|
import { syncPendingEntries } from "@/lib/offline/sync";
|
||||||
|
import { discardAllPendingVoice, discardPendingVoice } from "@/lib/voice/pending-voice";
|
||||||
import { discardReadyPhoto, listPendingPhotos, resetPhotoError } from "@/lib/photos/store";
|
import { discardReadyPhoto, listPendingPhotos, resetPhotoError } from "@/lib/photos/store";
|
||||||
import { PendingQueueSheet } from "./pending-queue-sheet";
|
import { PendingQueueSheet } from "./pending-queue-sheet";
|
||||||
|
|
||||||
@@ -20,6 +21,10 @@ vi.mock("@/lib/offline/db", () => ({
|
|||||||
retryBulkWithout: vi.fn(),
|
retryBulkWithout: vi.fn(),
|
||||||
retryInspectionWithoutHiveChange: 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/offline/sync", () => ({ syncPendingEntries: vi.fn() }));
|
||||||
vi.mock("@/lib/photos/store", async (importOriginal) => ({
|
vi.mock("@/lib/photos/store", async (importOriginal) => ({
|
||||||
...(await importOriginal<typeof import("@/lib/photos/store")>()),
|
...(await importOriginal<typeof import("@/lib/photos/store")>()),
|
||||||
@@ -97,6 +102,8 @@ describe("PendingQueueSheet", () => {
|
|||||||
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(listPendingPhotos).mockReset().mockResolvedValue([]);
|
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(discardReadyPhoto).mockReset().mockResolvedValue(undefined);
|
||||||
vi.mocked(resetPhotoError).mockReset().mockResolvedValue(undefined);
|
vi.mocked(resetPhotoError).mockReset().mockResolvedValue(undefined);
|
||||||
vi.mocked(syncPendingEntries)
|
vi.mocked(syncPendingEntries)
|
||||||
@@ -337,4 +344,147 @@ describe("PendingQueueSheet", () => {
|
|||||||
expect(await within(sheet).findByText(text)).toBeInTheDocument();
|
expect(await within(sheet).findByText(text)).toBeInTheDocument();
|
||||||
expect(within(sheet).queryByRole("button", { name: /Verwerfen/ })).not.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";
|
"use client";
|
||||||
|
|
||||||
import type { ComponentProps, ReactNode } from "react";
|
import { useRef, type ComponentProps, type MouseEvent, type ReactNode } from "react";
|
||||||
import { AlertTriangle, Camera, Clock, RotateCw } from "lucide-react";
|
import Link from "next/link";
|
||||||
|
import { AlertTriangle, Camera, Clock, Mic, RotateCw } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { NavSheet } from "@/components/nav/nav-sheet";
|
import { NavSheet } from "@/components/nav/nav-sheet";
|
||||||
import { ConfirmDialog } from "@/components/confirm-dialog";
|
import { ConfirmDialog } from "@/components/confirm-dialog";
|
||||||
import { PhotoThumbnail } from "@/components/photos/photo-capture-field";
|
import { PhotoThumbnail } from "@/components/photos/photo-capture-field";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
discardQueueItem,
|
discardQueueItem,
|
||||||
listQueue,
|
listQueue,
|
||||||
@@ -21,9 +22,12 @@ import {
|
|||||||
foreignEntriesWaiting,
|
foreignEntriesWaiting,
|
||||||
photosAttached,
|
photosAttached,
|
||||||
queueRejectionText,
|
queueRejectionText,
|
||||||
|
VOICE_TOO_MANY_OPEN_HINT,
|
||||||
|
voiceRejectionText,
|
||||||
} from "@/lib/offline/queue-texts";
|
} from "@/lib/offline/queue-texts";
|
||||||
import { syncPendingEntries } from "@/lib/offline/sync";
|
import { syncPendingEntries } from "@/lib/offline/sync";
|
||||||
import { OFFLINE_QUEUE_QUERY_KEY } from "@/lib/offline/use-capture-mutation";
|
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";
|
import { discardReadyPhoto, listPendingPhotos, resetPhotoError } from "@/lib/photos/store";
|
||||||
|
|
||||||
interface PendingQueueSheetProps {
|
interface PendingQueueSheetProps {
|
||||||
@@ -44,6 +48,30 @@ interface Named {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const PHOTO_LABEL = "Foto";
|
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 {
|
function queuedAtLabel(queuedAt: string): string {
|
||||||
return new Date(queuedAt).toLocaleString("de-DE", { dateStyle: "short", timeStyle: "short" });
|
return new Date(queuedAt).toLocaleString("de-DE", { dateStyle: "short", timeStyle: "short" });
|
||||||
@@ -81,14 +109,19 @@ function discardDescription(item: QueueItem): string {
|
|||||||
function QueueStatus({
|
function QueueStatus({
|
||||||
lastError,
|
lastError,
|
||||||
changesHive = false,
|
changesHive = false,
|
||||||
|
isVoice = false,
|
||||||
}: {
|
}: {
|
||||||
lastError?: QueueError;
|
lastError?: QueueError;
|
||||||
changesHive?: boolean;
|
changesHive?: boolean;
|
||||||
|
isVoice?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return lastError ? (
|
return lastError ? (
|
||||||
<p className="flex items-start gap-2 text-sm text-destructive">
|
<p className="flex items-start gap-2 text-sm text-destructive">
|
||||||
<AlertTriangle className="mt-0.5 size-4 shrink-0" aria-hidden />
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<p className="flex items-start gap-2 text-sm text-muted-foreground">
|
<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."),
|
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({
|
const discardPhoto = useMutation({
|
||||||
mutationFn: (photoId: string) => discardReadyPhoto(photoId),
|
mutationFn: (photoId: string) => discardReadyPhoto(photoId),
|
||||||
networkMode: "always",
|
networkMode: "always",
|
||||||
@@ -233,7 +289,10 @@ export function PendingQueueSheet({
|
|||||||
onError: () => toast.error("Eintrag konnte nicht erneut gesendet werden."),
|
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(
|
const photoNames = entryNames(
|
||||||
photos.map((photo) => ({ label: PHOTO_LABEL, queuedAt: photo.createdAt })),
|
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>
|
<p className="text-sm text-muted-foreground">Von dir wartet nichts mehr.</p>
|
||||||
) : null}
|
) : null}
|
||||||
<ul className="flex flex-col gap-2">
|
<ul className="flex flex-col gap-2">
|
||||||
{items.map((item, index) => (
|
{records.map((item, index) => (
|
||||||
<li
|
<li
|
||||||
key={`${item.store}-${item.id}`}
|
key={`${item.store}-${item.id}`}
|
||||||
className="flex flex-col gap-2 rounded-xl border border-border/60 p-3"
|
className="flex flex-col gap-2 rounded-xl border border-border/60 p-3"
|
||||||
@@ -357,6 +416,87 @@ export function PendingQueueSheet({
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</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 ? (
|
{foreignCount > 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">{foreignEntriesWaiting(foreignCount)}</p>
|
<p className="text-sm text-muted-foreground">{foreignEntriesWaiting(foreignCount)}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ describe("offline queue database", () => {
|
|||||||
it.each([
|
it.each([
|
||||||
["a fresh install (0)", 0],
|
["a fresh install (0)", 0],
|
||||||
["version 1", 1],
|
["version 1", 1],
|
||||||
])("opening from %s creates the plant report store at version 5", async (_name, from) => {
|
])("opening from %s creates the plant report store at version 6", async (_name, from) => {
|
||||||
if (from === 1) {
|
if (from === 1) {
|
||||||
const old = await openDB(DB_NAME, 1, {
|
const old = await openDB(DB_NAME, 1, {
|
||||||
upgrade(database) {
|
upgrade(database) {
|
||||||
@@ -120,7 +120,7 @@ describe("offline queue database", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const upgraded = await openDB(DB_NAME);
|
const upgraded = await openDB(DB_NAME);
|
||||||
expect(upgraded.version).toBe(5);
|
expect(upgraded.version).toBe(6);
|
||||||
expect([...upgraded.objectStoreNames]).toContain("pending-phenology-reports");
|
expect([...upgraded.objectStoreNames]).toContain("pending-phenology-reports");
|
||||||
expect(await upgraded.count("pending-phenology-reports")).toBe(1);
|
expect(await upgraded.count("pending-phenology-reports")).toBe(1);
|
||||||
expect(await upgraded.count("pending-inspections")).toBe(from === 1 ? 1 : 0);
|
expect(await upgraded.count("pending-inspections")).toBe(from === 1 ? 1 : 0);
|
||||||
@@ -166,11 +166,11 @@ describe("offline queue database", () => {
|
|||||||
expect((await db.listQueue("user-1")).map((item) => item.label)).toContain("Pflanzenmeldung");
|
expect((await db.listQueue("user-1")).map((item) => item.label)).toContain("Pflanzenmeldung");
|
||||||
|
|
||||||
const upgraded = await openDB(DB_NAME);
|
const upgraded = await openDB(DB_NAME);
|
||||||
expect(upgraded.version).toBe(5);
|
expect(upgraded.version).toBe(6);
|
||||||
upgraded.close();
|
upgraded.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("upgrading v2 → v5 keeps already queued inspections", async () => {
|
it("upgrading v2 → v6 keeps already queued inspections", async () => {
|
||||||
await createVersion2Queue({
|
await createVersion2Queue({
|
||||||
input: INSPECTION,
|
input: INSPECTION,
|
||||||
idempotencyKey: "key-from-v2",
|
idempotencyKey: "key-from-v2",
|
||||||
@@ -192,7 +192,7 @@ describe("offline queue database", () => {
|
|||||||
expect(await db.summarizeQueue("user-1")).toEqual({ own: 1, failed: 0, foreign: 0 });
|
expect(await db.summarizeQueue("user-1")).toEqual({ own: 1, failed: 0, foreign: 0 });
|
||||||
|
|
||||||
const upgraded = await openDB(DB_NAME);
|
const upgraded = await openDB(DB_NAME);
|
||||||
expect(upgraded.version).toBe(5);
|
expect(upgraded.version).toBe(6);
|
||||||
expect([...upgraded.objectStoreNames]).toEqual(
|
expect([...upgraded.objectStoreNames]).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
...VERSION_2_STORES,
|
...VERSION_2_STORES,
|
||||||
@@ -237,7 +237,7 @@ describe("offline queue database", () => {
|
|||||||
expect(hive).toMatchObject({ idempotencyKey: "key-h", ownerUserId: "user-1" });
|
expect(hive).toMatchObject({ idempotencyKey: "key-h", ownerUserId: "user-1" });
|
||||||
|
|
||||||
const upgraded = await openDB(DB_NAME);
|
const upgraded = await openDB(DB_NAME);
|
||||||
expect(upgraded.version).toBe(5);
|
expect(upgraded.version).toBe(6);
|
||||||
expect(await upgraded.count("pending-inspections")).toBe(1);
|
expect(await upgraded.count("pending-inspections")).toBe(1);
|
||||||
expect(await upgraded.count("pending-phenology-reports")).toBe(1);
|
expect(await upgraded.count("pending-phenology-reports")).toBe(1);
|
||||||
expect([...upgraded.objectStoreNames]).toEqual(
|
expect([...upgraded.objectStoreNames]).toEqual(
|
||||||
@@ -246,6 +246,69 @@ describe("offline queue database", () => {
|
|||||||
upgraded.close();
|
upgraded.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("upgrading to the new version keeps queued inspections, photos and phenology reports", async () => {
|
||||||
|
const old = await openDB(DB_NAME, 5, {
|
||||||
|
upgrade(database) {
|
||||||
|
for (const store of [
|
||||||
|
...VERSION_2_STORES,
|
||||||
|
"pending-bulk",
|
||||||
|
"pending-phenology-reports",
|
||||||
|
"pending-hive-changes",
|
||||||
|
]) {
|
||||||
|
database.createObjectStore(store, { keyPath: "id", autoIncrement: true });
|
||||||
|
}
|
||||||
|
const photos = database.createObjectStore("pending-photos", { keyPath: "id" });
|
||||||
|
photos.createIndex("sessionId", "sessionId");
|
||||||
|
photos.createIndex("inspectionIdempotencyKey", "inspectionIdempotencyKey");
|
||||||
|
photos.createIndex("ownerUserId", "ownerUserId");
|
||||||
|
database.createObjectStore("offline-packs", { keyPath: "apiaryId" });
|
||||||
|
database.createObjectStore("meta");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await old.add("pending-inspections", { input: INSPECTION, ownerUserId: "user-1", queuedAt: "2026-10-01T08:00:00.000Z" });
|
||||||
|
await old.add("pending-phenology-reports", { input: {}, ownerUserId: "user-1", queuedAt: "2026-10-01T09:00:00.000Z" });
|
||||||
|
await old.add("pending-hive-changes", { input: {}, ownerUserId: "user-1", queuedAt: "2026-10-01T10:00:00.000Z" });
|
||||||
|
await old.put("pending-photos", photo("p1", "key-1"));
|
||||||
|
old.close();
|
||||||
|
|
||||||
|
const db = await loadDb();
|
||||||
|
expect(await db.getPendingEntries("pending-inspections")).toHaveLength(1);
|
||||||
|
|
||||||
|
const upgraded = await openDB(DB_NAME);
|
||||||
|
expect(upgraded.version).toBe(6);
|
||||||
|
expect(await upgraded.count("pending-phenology-reports")).toBe(1);
|
||||||
|
expect(await upgraded.count("pending-hive-changes")).toBe(1);
|
||||||
|
expect(await upgraded.count("pending-photos")).toBe(1);
|
||||||
|
expect([...upgraded.objectStoreNames]).toContain("pending-voice");
|
||||||
|
expect([...upgraded.transaction("pending-voice").store.indexNames]).toEqual(["ownerUserId"]);
|
||||||
|
upgraded.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clearQueueOf removes one owner's voice notes and keeps everyone else's", async () => {
|
||||||
|
const db = await loadDb();
|
||||||
|
await db.getDb();
|
||||||
|
const raw = await openDB(DB_NAME);
|
||||||
|
const note = (id: string, ownerUserId: string) => ({
|
||||||
|
id,
|
||||||
|
ownerUserId,
|
||||||
|
blob: new Blob(["x"]),
|
||||||
|
mime: "audio/webm;codecs=opus",
|
||||||
|
durationSec: 5,
|
||||||
|
colonyId: "c-1",
|
||||||
|
inspectionDate: "2026-10-16",
|
||||||
|
createdAt: "2026-10-16T08:00:00.000Z",
|
||||||
|
uploadIdempotencyKey: `u-${id}`,
|
||||||
|
state: "recorded",
|
||||||
|
});
|
||||||
|
await raw.put("pending-voice", note("v1", "user-1") as never);
|
||||||
|
await raw.put("pending-voice", note("v2", "user-2") as never);
|
||||||
|
|
||||||
|
await db.clearQueueOf("user-1");
|
||||||
|
|
||||||
|
expect(await raw.getAllKeys("pending-voice")).toEqual(["v2"]);
|
||||||
|
raw.close();
|
||||||
|
});
|
||||||
|
|
||||||
it("lists a hive change under its own name and marks inspections that carry one", async () => {
|
it("lists a hive change under its own name and marks inspections that carry one", async () => {
|
||||||
const db = await loadDb();
|
const db = await loadDb();
|
||||||
await db.queueHiveChange(
|
await db.queueHiveChange(
|
||||||
@@ -565,3 +628,68 @@ describe("bulk entries in the offline queue", () => {
|
|||||||
expect(await onlyBulkEntry()).toBeUndefined();
|
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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -95,6 +95,23 @@ export interface StoredPhoto {
|
|||||||
lastError?: QueueError;
|
lastError?: QueueError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A voice note recorded on this device, waiting to be uploaded (Sprachnotiz). */
|
||||||
|
export interface StoredVoiceNote {
|
||||||
|
id: string;
|
||||||
|
ownerUserId: string;
|
||||||
|
blob: Blob;
|
||||||
|
mime: string;
|
||||||
|
durationSec: number;
|
||||||
|
colonyId: string;
|
||||||
|
inspectionDate: string;
|
||||||
|
createdAt: string;
|
||||||
|
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 {
|
export interface StoredPack {
|
||||||
apiaryId: string;
|
apiaryId: string;
|
||||||
ownerUserId: string;
|
ownerUserId: string;
|
||||||
@@ -113,6 +130,7 @@ export interface OfflineDB extends DBSchema, RecordStores {
|
|||||||
value: StoredPhoto;
|
value: StoredPhoto;
|
||||||
indexes: { sessionId: string; inspectionIdempotencyKey: string; ownerUserId: string };
|
indexes: { sessionId: string; inspectionIdempotencyKey: string; ownerUserId: string };
|
||||||
};
|
};
|
||||||
|
"pending-voice": { key: string; value: StoredVoiceNote; indexes: { ownerUserId: string } };
|
||||||
"offline-packs": { key: string; value: StoredPack };
|
"offline-packs": { key: string; value: StoredPack };
|
||||||
meta: { key: string; value: unknown };
|
meta: { key: string; value: unknown };
|
||||||
}
|
}
|
||||||
@@ -120,11 +138,12 @@ export interface OfflineDB extends DBSchema, RecordStores {
|
|||||||
const DB_NAME = "apicore-offline";
|
const DB_NAME = "apicore-offline";
|
||||||
// v1 only had "pending-inspections", v2 added the other five quick-capture
|
// v1 only had "pending-inspections", v2 added the other five quick-capture
|
||||||
// types, v3 adds bulk entries, photos, offline packs and meta, v4 adds plant
|
// types, v3 adds bulk entries, photos, offline packs and meta, v4 adds plant
|
||||||
// reports (Pflanzenentwicklung), v5 adds hive changes (Beute). Entries already
|
// reports (Pflanzenentwicklung), v5 adds hive changes (Beute), v6 adds voice
|
||||||
|
// notes (Sprache). Entries already
|
||||||
// queued on a phone must survive every upgrade, so each store is only created
|
// queued on a phone must survive every upgrade, so each store is only created
|
||||||
// if it doesn't exist yet (guarded by oldVersion, per idb's upgrade()
|
// if it doesn't exist yet (guarded by oldVersion, per idb's upgrade()
|
||||||
// convention), never recreated.
|
// convention), never recreated.
|
||||||
const DB_VERSION = 5;
|
const DB_VERSION = 6;
|
||||||
|
|
||||||
const RECORD_STORES: readonly RecordStoreName[] = [
|
const RECORD_STORES: readonly RecordStoreName[] = [
|
||||||
"pending-inspections",
|
"pending-inspections",
|
||||||
@@ -198,6 +217,10 @@ export function getDb(): Promise<IDBPDatabase<OfflineDB>> {
|
|||||||
if (oldVersion < 5) {
|
if (oldVersion < 5) {
|
||||||
db.createObjectStore("pending-hive-changes", { keyPath: "id", autoIncrement: true });
|
db.createObjectStore("pending-hive-changes", { keyPath: "id", autoIncrement: true });
|
||||||
}
|
}
|
||||||
|
if (oldVersion < 6) {
|
||||||
|
const voice = db.createObjectStore("pending-voice", { keyPath: "id" });
|
||||||
|
voice.createIndex("ownerUserId", "ownerUserId");
|
||||||
|
}
|
||||||
},
|
},
|
||||||
// A newer app version in another tab wants to upgrade: step aside
|
// A newer app version in another tab wants to upgrade: step aside
|
||||||
// instead of blocking it. The next call here opens the new version.
|
// instead of blocking it. The next call here opens the new version.
|
||||||
@@ -436,6 +459,10 @@ export interface QueueSummary {
|
|||||||
failed: number;
|
failed: number;
|
||||||
/** Entries of another account on this device - never sent from here. */
|
/** Entries of another account on this device - never sent from here. */
|
||||||
foreign: number;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -449,23 +476,55 @@ async function readPhotosWaitingOnTheirOwn(): Promise<StoredPhoto[]> {
|
|||||||
return photos.filter((photo) => photo.state === "ready");
|
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> {
|
export async function summarizeQueue(currentUserId: string): Promise<QueueSummary> {
|
||||||
|
const voiceNotes = await readVoiceMeta();
|
||||||
const waiting: { ownerUserId?: string; lastError?: QueueError }[] = [
|
const waiting: { ownerUserId?: string; lastError?: QueueError }[] = [
|
||||||
...(await readQueue()).map(({ entry }) => entry),
|
...(await readQueue()).map(({ entry }) => entry),
|
||||||
...(await readPhotosWaitingOnTheirOwn()),
|
...(await readPhotosWaitingOnTheirOwn()),
|
||||||
|
// Recordings wait on their own and count like entries (foreign ones as a number only).
|
||||||
|
...voiceNotes,
|
||||||
];
|
];
|
||||||
return waiting.reduce<QueueSummary>(
|
const summary = waiting.reduce<QueueSummary>(
|
||||||
(summary, entry) => {
|
(total, entry) => {
|
||||||
if (isForeignEntry(entry, currentUserId)) {
|
if (isForeignEntry(entry, currentUserId)) {
|
||||||
return { ...summary, foreign: summary.foreign + 1 };
|
return { ...total, foreign: total.foreign + 1 };
|
||||||
}
|
}
|
||||||
if (entry.lastError) {
|
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 },
|
{ 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. */
|
/** The server refused the hive part: 403, or a conflict with the hive's current state. */
|
||||||
@@ -506,6 +565,14 @@ export interface QueueItem {
|
|||||||
queuedAt: string;
|
queuedAt: string;
|
||||||
lastError?: QueueError;
|
lastError?: QueueError;
|
||||||
photoCount?: number;
|
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). */
|
/** The entry changes a colony's hive (a hive entry, or an inspection with a hive change). */
|
||||||
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. */
|
||||||
@@ -545,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. */
|
/** The signed-in user's waiting and rejected entries, oldest first. */
|
||||||
export async function listQueue(currentUserId: string): Promise<QueueItem[]> {
|
export async function listQueue(currentUserId: string): Promise<QueueItem[]> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
@@ -586,7 +685,8 @@ export async function listQueue(currentUserId: string): Promise<QueueItem[]> {
|
|||||||
return photoCount > 0 ? { ...item, ...hive, photoCount } : { ...item, ...hive };
|
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 {
|
function asQueueStore(store: string): QueueStoreName {
|
||||||
@@ -620,10 +720,10 @@ export async function discardQueueItem(item: QueueItem): Promise<void> {
|
|||||||
await tx.done;
|
await tx.done;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Deletes everything one account queued on this device, photos included. */
|
/** Deletes everything one account queued on this device, photos and voice notes included. */
|
||||||
export async function clearQueueOf(ownerUserId: string): Promise<void> {
|
export async function clearQueueOf(ownerUserId: string): Promise<void> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const tx = db.transaction([...QUEUE_STORES, "pending-photos"], "readwrite");
|
const tx = db.transaction([...QUEUE_STORES, "pending-photos", "pending-voice"], "readwrite");
|
||||||
const queues = QUEUE_STORES.map(async (store) => {
|
const queues = QUEUE_STORES.map(async (store) => {
|
||||||
const objectStore = tx.objectStore(store);
|
const objectStore = tx.objectStore(store);
|
||||||
const entries: QueuedValue[] = await objectStore.getAll();
|
const entries: QueuedValue[] = await objectStore.getAll();
|
||||||
@@ -635,6 +735,12 @@ export async function clearQueueOf(ownerUserId: string): Promise<void> {
|
|||||||
});
|
});
|
||||||
const photos = tx.objectStore("pending-photos");
|
const photos = tx.objectStore("pending-photos");
|
||||||
const photoIds = await photos.index("ownerUserId").getAllKeys(ownerUserId);
|
const photoIds = await photos.index("ownerUserId").getAllKeys(ownerUserId);
|
||||||
await Promise.all([...queues, ...photoIds.map((photoId) => photos.delete(photoId))]);
|
const voice = tx.objectStore("pending-voice");
|
||||||
|
const voiceIds = await voice.index("ownerUserId").getAllKeys(ownerUserId);
|
||||||
|
await Promise.all([
|
||||||
|
...queues,
|
||||||
|
...photoIds.map((photoId) => photos.delete(photoId)),
|
||||||
|
...voiceIds.map((voiceId) => voice.delete(voiceId)),
|
||||||
|
]);
|
||||||
await tx.done;
|
await tx.done;
|
||||||
}
|
}
|
||||||
@@ -49,6 +49,11 @@ vi.mock("@/lib/photos/store", () => ({
|
|||||||
uploadReadyPhotos: vi.fn(async () => ({ synced: 0, failed: 0, skippedForeign: 0 })),
|
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) => ({
|
vi.mock("@/lib/auth/api-client", async (importOriginal) => ({
|
||||||
...(await importOriginal<typeof import("@/lib/auth/api-client")>()),
|
...(await importOriginal<typeof import("@/lib/auth/api-client")>()),
|
||||||
apiFetch: vi.fn(async () => ({ id: "saved" })),
|
apiFetch: vi.fn(async () => ({ id: "saved" })),
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import {
|
|||||||
queueRejectionText,
|
queueRejectionText,
|
||||||
rejectionText,
|
rejectionText,
|
||||||
sessionExpiredWithEntries,
|
sessionExpiredWithEntries,
|
||||||
|
voiceNotesAmong,
|
||||||
|
voiceNotesLostOnLogout,
|
||||||
|
voiceRejectionText,
|
||||||
} from "./queue-texts";
|
} from "./queue-texts";
|
||||||
|
|
||||||
describe("queue texts", () => {
|
describe("queue texts", () => {
|
||||||
@@ -85,4 +88,40 @@ describe("rejectionText", () => {
|
|||||||
).toBe("Idempotency-Key reused");
|
).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 { hiveReasonText } from "@/lib/hive/reason-text";
|
||||||
|
import type { QueueError } from "./db";
|
||||||
|
|
||||||
// German counts for the offline queue: "1 Eintrag wartet", "2 Einträge warten".
|
// 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.`;
|
const rest = n === 1 ? "1 Eintrag wartet und wird danach gesendet." : `${n} Einträge warten und werden danach gesendet.`;
|
||||||
return `${expired} ${rest}`;
|
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/documents/api", () => ({ uploadDocument: vi.fn() }));
|
||||||
vi.mock("@/lib/phenology/api", () => ({ createPhenologyReport: vi.fn() }));
|
vi.mock("@/lib/phenology/api", () => ({ createPhenologyReport: vi.fn() }));
|
||||||
vi.mock("@/lib/hive/api", () => ({ postHiveChange: 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() }));
|
vi.mock("@/lib/apiaries/offline-pack", () => ({ refreshPacksAfterSync: vi.fn() }));
|
||||||
|
|
||||||
// What the inspection drain hands over to the photos, seen at that moment.
|
// 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([]);
|
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 () => {
|
it("offline the plant report is queued and synced later with its key", async () => {
|
||||||
const report = {
|
const report = {
|
||||||
phase: "SALWEIDE",
|
phase: "SALWEIDE",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
} from "@/lib/bulk/api";
|
} from "@/lib/bulk/api";
|
||||||
import { refreshPacksAfterSync } from "@/lib/apiaries/offline-pack";
|
import { refreshPacksAfterSync } from "@/lib/apiaries/offline-pack";
|
||||||
import type { CreateInspectionInput, Inspection } from "@/lib/inspections/types";
|
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 { finishHandOvers, resolveQueuedInspection, uploadReadyPhotos } from "@/lib/photos/store";
|
||||||
import {
|
import {
|
||||||
BulkKind,
|
BulkKind,
|
||||||
@@ -493,6 +494,9 @@ async function runSync(run: SyncRun): Promise<SyncResult> {
|
|||||||
await finishHandOvers(run.userId);
|
await finishHandOvers(run.userId);
|
||||||
}
|
}
|
||||||
const photos = await uploadReadyPhotos(run.userId, uploadDocument, run.isCancelled);
|
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
|
// 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)
|
// 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
|
// 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, { force: true, colonyIds })
|
||||||
: refreshPacksAfterSync(run.userId, Date.now(), run.isCancelled));
|
: refreshPacksAfterSync(run.userId, Date.now(), run.isCancelled));
|
||||||
}
|
}
|
||||||
return [...records, photos].reduce(addResults, NOTHING_SYNCED);
|
return [...records, photos, voice].reduce(addResults, NOTHING_SYNCED);
|
||||||
}
|
}
|
||||||
@@ -63,6 +63,8 @@ export const DOMAIN_EVENT_LABELS: Record<string, string> = {
|
|||||||
ApiaryChanged: "Stand geändert",
|
ApiaryChanged: "Stand geändert",
|
||||||
TskReferenceDayReached: "TSK-Stichtag erreicht",
|
TskReferenceDayReached: "TSK-Stichtag erreicht",
|
||||||
HiveChanged: "Beute geändert",
|
HiveChanged: "Beute geändert",
|
||||||
|
VoiceCaptureReady: "Sprachnotiz fertig",
|
||||||
|
VoiceCaptureFailed: "Sprachnotiz fehlgeschlagen",
|
||||||
};
|
};
|
||||||
|
|
||||||
/** The oldest open event is this old (seconds) before the worker counts as stuck. */
|
/** The oldest open event is this old (seconds) before the worker counts as stuck. */
|
||||||
|
|||||||
@@ -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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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" });
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import "fake-indexeddb/auto";
|
||||||
|
import { IDBFactory } from "fake-indexeddb";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
type Module = typeof import("./pending-voice");
|
||||||
|
|
||||||
|
async function load(): Promise<Module> {
|
||||||
|
vi.resetModules();
|
||||||
|
return import("./pending-voice");
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = (ownerUserId = "user-1") => ({
|
||||||
|
ownerUserId,
|
||||||
|
blob: new Blob(["audio"], { type: "audio/webm;codecs=opus" }),
|
||||||
|
mime: "audio/webm;codecs=opus",
|
||||||
|
durationSec: 42,
|
||||||
|
colonyId: "colony-1",
|
||||||
|
inspectionDate: "2026-10-16",
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pending voice notes", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
globalThis.indexedDB = new IDBFactory();
|
||||||
|
vi.stubGlobal("navigator", { storage: { persist: vi.fn().mockResolvedValue(true) } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("queues a note with its own id and upload key", async () => {
|
||||||
|
const voice = await load();
|
||||||
|
|
||||||
|
const note = await voice.queueVoiceNote(input());
|
||||||
|
|
||||||
|
expect(note).toMatchObject({ ownerUserId: "user-1", state: "recorded", durationSec: 42 });
|
||||||
|
expect(note.id).toBeTruthy();
|
||||||
|
expect(note.uploadIdempotencyKey).toBeTruthy();
|
||||||
|
expect(note.uploadIdempotencyKey).not.toBe(note.id);
|
||||||
|
expect(await voice.listPendingVoice("user-1")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks the browser to keep the storage, once", async () => {
|
||||||
|
const voice = await load();
|
||||||
|
|
||||||
|
await voice.queueVoiceNote(input());
|
||||||
|
await voice.queueVoiceNote(input());
|
||||||
|
|
||||||
|
expect(navigator.storage.persist).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("works when the browser has no persist", async () => {
|
||||||
|
vi.stubGlobal("navigator", {});
|
||||||
|
const voice = await load();
|
||||||
|
|
||||||
|
await expect(voice.queueVoiceNote(input())).resolves.toMatchObject({ state: "recorded" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never lists another user's notes", async () => {
|
||||||
|
const voice = await load();
|
||||||
|
await voice.queueVoiceNote(input("user-1"));
|
||||||
|
await voice.queueVoiceNote(input("user-2"));
|
||||||
|
await voice.queueVoiceNote(input("user-2"));
|
||||||
|
|
||||||
|
expect((await voice.listPendingVoice("user-1")).map((n) => n.ownerUserId)).toEqual(["user-1"]);
|
||||||
|
expect(await voice.countForeignVoice("user-1")).toBe(2);
|
||||||
|
expect(await voice.countForeignVoice("user-3")).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists oldest first", async () => {
|
||||||
|
const voice = await load();
|
||||||
|
const first = await voice.queueVoiceNote(input());
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
|
const second = await voice.queueVoiceNote(input());
|
||||||
|
|
||||||
|
expect((await voice.listPendingVoice("user-1")).map((n) => n.id)).toEqual([first.id, second.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("discards one note and leaves the rest", async () => {
|
||||||
|
const voice = await load();
|
||||||
|
const first = await voice.queueVoiceNote(input());
|
||||||
|
const second = await voice.queueVoiceNote(input());
|
||||||
|
|
||||||
|
await voice.discardPendingVoice(first.id, "user-1");
|
||||||
|
|
||||||
|
expect((await voice.listPendingVoice("user-1")).map((n) => n.id)).toEqual([second.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not discard another user's note", async () => {
|
||||||
|
const voice = await load();
|
||||||
|
const foreign = await voice.queueVoiceNote(input("user-2"));
|
||||||
|
|
||||||
|
await voice.discardPendingVoice(foreign.id, "user-1");
|
||||||
|
|
||||||
|
expect(await voice.listPendingVoice("user-2")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("discards all notes of one owner only", async () => {
|
||||||
|
const voice = await load();
|
||||||
|
await voice.queueVoiceNote(input("user-1"));
|
||||||
|
await voice.queueVoiceNote(input("user-1"));
|
||||||
|
await voice.queueVoiceNote(input("user-2"));
|
||||||
|
|
||||||
|
expect(await voice.discardAllPendingVoice("user-1")).toBe(2);
|
||||||
|
|
||||||
|
expect(await voice.listPendingVoice("user-1")).toEqual([]);
|
||||||
|
expect(await voice.countForeignVoice("user-1")).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a new note when storage is full and keeps the stored ones", async () => {
|
||||||
|
const voice = await load();
|
||||||
|
const kept = await voice.queueVoiceNote(input());
|
||||||
|
const db = await import("@/lib/offline/db");
|
||||||
|
const connection = await db.getDb();
|
||||||
|
const spy = vi
|
||||||
|
.spyOn(connection, "add")
|
||||||
|
.mockRejectedValue(new DOMException("full", "QuotaExceededError"));
|
||||||
|
|
||||||
|
await expect(voice.queueVoiceNote(input())).rejects.toBeInstanceOf(voice.VoiceStorageFullError);
|
||||||
|
|
||||||
|
spy.mockRestore();
|
||||||
|
expect((await voice.listPendingVoice("user-1")).map((n) => n.id)).toEqual([kept.id]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { getDb, type QueueError, type StoredVoiceNote } from "@/lib/offline/db";
|
||||||
|
import { newIdempotencyKey } from "@/lib/offline/idempotency";
|
||||||
|
|
||||||
|
export type { StoredVoiceNote } from "@/lib/offline/db";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Voice notes recorded on this device. The audio stays here until the sync
|
||||||
|
* uploads it and is deleted on success, on discard and when the account signs
|
||||||
|
* out (db.clearQueueOf). It is never logged and never leaves the device
|
||||||
|
* before the user's upload.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 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 Sprachnotiz.");
|
||||||
|
this.name = "VoiceStorageFullError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NewVoiceNote = Omit<
|
||||||
|
StoredVoiceNote,
|
||||||
|
"id" | "createdAt" | "uploadIdempotencyKey" | "state"
|
||||||
|
>;
|
||||||
|
|
||||||
|
let persistAsked = false;
|
||||||
|
|
||||||
|
/** Asks once per page for storage the browser will not clear on its own. Best effort. */
|
||||||
|
async function askForPersistentStorage(): Promise<void> {
|
||||||
|
if (persistAsked) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
persistAsked = true;
|
||||||
|
try {
|
||||||
|
await navigator.storage?.persist?.();
|
||||||
|
} catch {
|
||||||
|
// Without persistent storage the note is still saved; the browser may clear it under pressure.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isQuotaError(error: unknown): boolean {
|
||||||
|
return error instanceof DOMException && error.name === "QuotaExceededError";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queueVoiceNote(input: NewVoiceNote): Promise<StoredVoiceNote> {
|
||||||
|
await askForPersistentStorage();
|
||||||
|
const note: StoredVoiceNote = {
|
||||||
|
...input,
|
||||||
|
id: newIdempotencyKey(),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
uploadIdempotencyKey: newIdempotencyKey(),
|
||||||
|
state: "recorded",
|
||||||
|
};
|
||||||
|
const db = await getDb();
|
||||||
|
try {
|
||||||
|
await db.add("pending-voice", note);
|
||||||
|
} catch (error) {
|
||||||
|
if (isQuotaError(error)) {
|
||||||
|
throw new VoiceStorageFullError();
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return note;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The signed-in user's notes, oldest first. Another account's notes never appear. */
|
||||||
|
export async function listPendingVoice(currentUserId: string): Promise<StoredVoiceNote[]> {
|
||||||
|
const db = await getDb();
|
||||||
|
const notes = await db.getAllFromIndex("pending-voice", "ownerUserId", currentUserId);
|
||||||
|
return notes.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How many notes of other accounts wait on this device - shown as a number only. */
|
||||||
|
export async function countForeignVoice(currentUserId: string): Promise<number> {
|
||||||
|
const db = await getDb();
|
||||||
|
const all = await db.getAllKeys("pending-voice");
|
||||||
|
const own = await db.countFromIndex("pending-voice", "ownerUserId", currentUserId);
|
||||||
|
return all.length - own;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deletes one note, but only if it belongs to `ownerUserId`. */
|
||||||
|
export async function discardPendingVoice(id: string, ownerUserId: string): Promise<void> {
|
||||||
|
const db = await getDb();
|
||||||
|
const tx = db.transaction("pending-voice", "readwrite");
|
||||||
|
const note = await tx.store.get(id);
|
||||||
|
if (note?.ownerUserId === ownerUserId) {
|
||||||
|
await tx.store.delete(id);
|
||||||
|
}
|
||||||
|
await tx.done;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deletes every note one account recorded on this device; returns how many. */
|
||||||
|
export async function discardAllPendingVoice(ownerUserId: string): Promise<number> {
|
||||||
|
const db = await getDb();
|
||||||
|
const tx = db.transaction("pending-voice", "readwrite");
|
||||||
|
const ids = await tx.store.index("ownerUserId").getAllKeys(ownerUserId);
|
||||||
|
await Promise.all(ids.map((id) => tx.store.delete(id)));
|
||||||
|
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,301 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { pickMimeType, startRecording } from "./recorder";
|
||||||
|
|
||||||
|
class FakeRecorder {
|
||||||
|
static instances: FakeRecorder[] = [];
|
||||||
|
static supported = new Set(["audio/webm;codecs=opus", "audio/mp4"]);
|
||||||
|
static isTypeSupported(type: string): boolean {
|
||||||
|
return FakeRecorder.supported.has(type);
|
||||||
|
}
|
||||||
|
state: "inactive" | "recording" = "inactive";
|
||||||
|
mimeType: string;
|
||||||
|
ondataavailable: ((event: { data: Blob }) => void) | null = null;
|
||||||
|
onstop: (() => void) | null = null;
|
||||||
|
onerror: ((event: unknown) => void) | null = null;
|
||||||
|
constructor(
|
||||||
|
public stream: unknown,
|
||||||
|
public options: { mimeType: string; audioBitsPerSecond: number },
|
||||||
|
) {
|
||||||
|
this.mimeType = options.mimeType;
|
||||||
|
FakeRecorder.instances.push(this);
|
||||||
|
}
|
||||||
|
static failStart = false;
|
||||||
|
static emptyData = false;
|
||||||
|
start(): void {
|
||||||
|
if (FakeRecorder.failStart) throw new Error("start failed");
|
||||||
|
this.state = "recording";
|
||||||
|
}
|
||||||
|
stop(): void {
|
||||||
|
this.state = "inactive";
|
||||||
|
this.ondataavailable?.({ data: new Blob(FakeRecorder.emptyData ? [] : ["audio-bytes"], { type: this.mimeType }) });
|
||||||
|
this.onstop?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeStream() {
|
||||||
|
const handlers: Array<() => void> = [];
|
||||||
|
const track = {
|
||||||
|
stop: vi.fn(),
|
||||||
|
addEventListener: (_type: string, handler: () => void) => handlers.push(handler),
|
||||||
|
end: () => handlers.forEach((handler) => handler()),
|
||||||
|
};
|
||||||
|
return { track, stream: { getTracks: () => [track] } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = () => ({
|
||||||
|
maxSec: 180,
|
||||||
|
warnAtSec: 150,
|
||||||
|
onTick: vi.fn(),
|
||||||
|
onWarn: vi.fn(),
|
||||||
|
onAutoStop: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pickMimeType", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
FakeRecorder.supported = new Set(["audio/webm;codecs=opus", "audio/mp4"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers webm with opus", () => {
|
||||||
|
expect(pickMimeType({ MediaRecorder: FakeRecorder } as never)).toBe("audio/webm;codecs=opus");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to mp4 on Safari", () => {
|
||||||
|
FakeRecorder.supported = new Set(["audio/mp4"]);
|
||||||
|
expect(pickMimeType({ MediaRecorder: FakeRecorder } as never)).toBe("audio/mp4");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null without MediaRecorder or a known type", () => {
|
||||||
|
expect(pickMimeType({} as never)).toBeNull();
|
||||||
|
FakeRecorder.supported = new Set();
|
||||||
|
expect(pickMimeType({ MediaRecorder: FakeRecorder } as never)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("startRecording", () => {
|
||||||
|
let getUserMedia: ReturnType<typeof vi.fn>;
|
||||||
|
let track: ReturnType<typeof makeStream>["track"];
|
||||||
|
let wakeRelease: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
FakeRecorder.instances = [];
|
||||||
|
FakeRecorder.failStart = false;
|
||||||
|
FakeRecorder.emptyData = false;
|
||||||
|
FakeRecorder.supported = new Set(["audio/webm;codecs=opus", "audio/mp4"]);
|
||||||
|
wakeRelease = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const made = makeStream();
|
||||||
|
track = made.track;
|
||||||
|
getUserMedia = vi.fn().mockResolvedValue(made.stream);
|
||||||
|
vi.stubGlobal("MediaRecorder", FakeRecorder);
|
||||||
|
vi.stubGlobal("navigator", {
|
||||||
|
mediaDevices: { getUserMedia },
|
||||||
|
wakeLock: { request: vi.fn().mockResolvedValue({ release: wakeRelease }) },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cleans up when the recorder cannot start", async () => {
|
||||||
|
FakeRecorder.failStart = true;
|
||||||
|
const removeSpy = vi.spyOn(document, "removeEventListener");
|
||||||
|
const opts = options();
|
||||||
|
|
||||||
|
await expect(startRecording(opts)).rejects.toThrow("start failed");
|
||||||
|
|
||||||
|
expect(track.stop).toHaveBeenCalledTimes(1);
|
||||||
|
expect(removeSpy).toHaveBeenCalledWith("visibilitychange", expect.any(Function));
|
||||||
|
await vi.advanceTimersByTimeAsync(3000);
|
||||||
|
expect(opts.onTick).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("releases a wake lock that arrives after the recording ended", async () => {
|
||||||
|
let grant: (lock: unknown) => void = () => undefined;
|
||||||
|
const request = vi.fn().mockReturnValue(new Promise((resolve) => (grant = resolve)));
|
||||||
|
vi.stubGlobal("navigator", { mediaDevices: { getUserMedia }, wakeLock: { request } });
|
||||||
|
const handle = await startRecording(options());
|
||||||
|
handle.cancel();
|
||||||
|
|
||||||
|
grant({ release: wakeRelease });
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
|
||||||
|
expect(wakeRelease).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("releases the wake lock, the listener and the timer after stop", async () => {
|
||||||
|
const removeSpy = vi.spyOn(document, "removeEventListener");
|
||||||
|
const opts = options();
|
||||||
|
const handle = await startRecording(opts);
|
||||||
|
await vi.advanceTimersByTimeAsync(2000);
|
||||||
|
|
||||||
|
await handle.stop();
|
||||||
|
await vi.advanceTimersByTimeAsync(5000);
|
||||||
|
|
||||||
|
expect(wakeRelease).toHaveBeenCalledTimes(1);
|
||||||
|
expect(removeSpy).toHaveBeenCalledWith("visibilitychange", expect.any(Function));
|
||||||
|
expect(opts.onTick).toHaveBeenCalledTimes(2);
|
||||||
|
expect(vi.getTimerCount()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not raise an unhandled rejection when onAutoStop throws", async () => {
|
||||||
|
const opts = options();
|
||||||
|
opts.onAutoStop.mockImplementation(() => {
|
||||||
|
throw new Error("handler failed");
|
||||||
|
});
|
||||||
|
const unhandled = vi.fn();
|
||||||
|
process.on("unhandledRejection", unhandled);
|
||||||
|
await startRecording(opts);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(180_000);
|
||||||
|
await vi.advanceTimersByTimeAsync(10);
|
||||||
|
|
||||||
|
process.off("unhandledRejection", unhandled);
|
||||||
|
expect(opts.onAutoStop).toHaveBeenCalledTimes(1);
|
||||||
|
expect(unhandled).not.toHaveBeenCalled();
|
||||||
|
expect(track.stop).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an empty recording", async () => {
|
||||||
|
FakeRecorder.emptyData = true;
|
||||||
|
const handle = await startRecording(options());
|
||||||
|
|
||||||
|
await expect(handle.stop()).rejects.toThrow("empty");
|
||||||
|
expect(track.stop).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects stop after cancel", async () => {
|
||||||
|
const handle = await startRecording(options());
|
||||||
|
handle.cancel();
|
||||||
|
|
||||||
|
await expect(handle.stop()).rejects.toThrow("cancelled");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats an unplugged microphone like an error", async () => {
|
||||||
|
const handle = await startRecording(options());
|
||||||
|
|
||||||
|
track.end();
|
||||||
|
|
||||||
|
await expect(handle.stop()).rejects.toThrow("failed");
|
||||||
|
expect(track.stop).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requests the microphone only on start and releases it on stop", async () => {
|
||||||
|
expect(getUserMedia).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const handle = await startRecording(options());
|
||||||
|
expect(getUserMedia).toHaveBeenCalledWith({ audio: { channelCount: 1 } });
|
||||||
|
expect(FakeRecorder.instances[0].options).toEqual({
|
||||||
|
mimeType: "audio/webm;codecs=opus",
|
||||||
|
audioBitsPerSecond: 24000,
|
||||||
|
});
|
||||||
|
expect(track.stop).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(3000);
|
||||||
|
const recording = await handle.stop();
|
||||||
|
|
||||||
|
expect(recording.mime).toBe("audio/webm;codecs=opus");
|
||||||
|
expect(recording.durationSec).toBe(3);
|
||||||
|
expect(recording.blob.size).toBeGreaterThan(0);
|
||||||
|
expect(track.stop).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("releases the microphone on cancel and delivers nothing", async () => {
|
||||||
|
const opts = options();
|
||||||
|
const handle = await startRecording(opts);
|
||||||
|
|
||||||
|
handle.cancel();
|
||||||
|
await vi.advanceTimersByTimeAsync(5000);
|
||||||
|
|
||||||
|
expect(track.stop).toHaveBeenCalledTimes(1);
|
||||||
|
expect(opts.onAutoStop).not.toHaveBeenCalled();
|
||||||
|
expect(opts.onTick).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports every second", async () => {
|
||||||
|
const opts = options();
|
||||||
|
await startRecording(opts);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(2000);
|
||||||
|
|
||||||
|
expect(opts.onTick.mock.calls.map(([sec]) => sec)).toEqual([1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warns at 150 seconds", async () => {
|
||||||
|
const opts = options();
|
||||||
|
await startRecording(opts);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(149_000);
|
||||||
|
expect(opts.onWarn).not.toHaveBeenCalled();
|
||||||
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
|
|
||||||
|
expect(opts.onWarn).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops at 180 seconds and keeps the recording", async () => {
|
||||||
|
const opts = options();
|
||||||
|
await startRecording(opts);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(180_000);
|
||||||
|
|
||||||
|
expect(opts.onAutoStop).toHaveBeenCalledTimes(1);
|
||||||
|
const recording = opts.onAutoStop.mock.calls[0][0];
|
||||||
|
expect(recording.durationSec).toBe(180);
|
||||||
|
expect(recording.blob.size).toBeGreaterThan(0);
|
||||||
|
expect(track.stop).toHaveBeenCalledTimes(1);
|
||||||
|
await vi.advanceTimersByTimeAsync(5000);
|
||||||
|
expect(opts.onTick).toHaveBeenCalledTimes(180);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves what was recorded when the page goes to the background", async () => {
|
||||||
|
const opts = options();
|
||||||
|
await startRecording(opts);
|
||||||
|
await vi.advanceTimersByTimeAsync(7000);
|
||||||
|
|
||||||
|
Object.defineProperty(document, "visibilityState", { value: "hidden", configurable: true });
|
||||||
|
document.dispatchEvent(new Event("visibilitychange"));
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
|
||||||
|
expect(opts.onAutoStop).toHaveBeenCalledTimes(1);
|
||||||
|
expect(opts.onAutoStop.mock.calls[0][0].durationSec).toBe(7);
|
||||||
|
expect(track.stop).toHaveBeenCalledTimes(1);
|
||||||
|
Object.defineProperty(document, "visibilityState", { value: "visible", configurable: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("releases the microphone when the recorder cannot be created", async () => {
|
||||||
|
class Broken extends FakeRecorder {
|
||||||
|
constructor() {
|
||||||
|
super(null, { mimeType: "", audioBitsPerSecond: 0 });
|
||||||
|
throw new Error("boom");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vi.stubGlobal("MediaRecorder", Broken);
|
||||||
|
|
||||||
|
await expect(startRecording(options())).rejects.toThrow("boom");
|
||||||
|
expect(track.stop).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects without a supported type before asking for the microphone", async () => {
|
||||||
|
FakeRecorder.supported = new Set();
|
||||||
|
|
||||||
|
await expect(startRecording(options())).rejects.toThrow();
|
||||||
|
expect(getUserMedia).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes a denied microphone through", async () => {
|
||||||
|
getUserMedia.mockRejectedValue(new DOMException("denied", "NotAllowedError"));
|
||||||
|
|
||||||
|
await expect(startRecording(options())).rejects.toMatchObject({ name: "NotAllowedError" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("releases the microphone and rejects stop when the recorder fails", async () => {
|
||||||
|
const handle = await startRecording(options());
|
||||||
|
const recorder = FakeRecorder.instances[0];
|
||||||
|
|
||||||
|
recorder.onerror?.({});
|
||||||
|
|
||||||
|
await expect(handle.stop()).rejects.toThrow();
|
||||||
|
expect(track.stop).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
/*
|
||||||
|
* Records a voice note with MediaRecorder. The microphone is asked for only
|
||||||
|
* here, when the user taps record, and every track is stopped again on stop,
|
||||||
|
* cancel, auto-stop and error. The audio leaves this module only as the
|
||||||
|
* Recording the caller receives; nothing about it is logged.
|
||||||
|
*/
|
||||||
|
export interface Recording {
|
||||||
|
blob: Blob;
|
||||||
|
mime: string;
|
||||||
|
durationSec: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecordingHandle {
|
||||||
|
stop(): Promise<Recording>;
|
||||||
|
cancel(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StartRecordingOptions {
|
||||||
|
maxSec: number;
|
||||||
|
warnAtSec: number;
|
||||||
|
onTick(sec: number): void;
|
||||||
|
onWarn(): void;
|
||||||
|
onAutoStop(recording: Recording): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIME_CANDIDATES = ["audio/webm;codecs=opus", "audio/mp4"] as const;
|
||||||
|
const AUDIO_BITS_PER_SECOND = 24000;
|
||||||
|
const TICK_MS = 1000;
|
||||||
|
|
||||||
|
/** Anything that carries a MediaRecorder constructor: window, or a stand-in in tests. */
|
||||||
|
export interface RecorderHost {
|
||||||
|
MediaRecorder?: { isTypeSupported(type: string): boolean };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The first audio type this browser can record, or null (no voice notes then). */
|
||||||
|
export function pickMimeType(win?: RecorderHost): string | null {
|
||||||
|
const target = win ?? (typeof window === "undefined" ? undefined : window);
|
||||||
|
const Recorder = (target as RecorderHost | undefined)?.MediaRecorder;
|
||||||
|
if (typeof Recorder?.isTypeSupported !== "function") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return MIME_CANDIDATES.find((type) => Recorder.isTypeSupported(type)) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseTracks(stream: MediaStream): void {
|
||||||
|
for (const track of stream.getTracks()) {
|
||||||
|
track.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestWakeLock(): Promise<WakeLockSentinel | null> {
|
||||||
|
try {
|
||||||
|
return (await navigator.wakeLock?.request("screen")) ?? null;
|
||||||
|
} catch {
|
||||||
|
// A wake lock is a convenience; recording works without it.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseWakeLock(lock: WakeLockSentinel | null): void {
|
||||||
|
lock?.release().catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startRecording(opts: StartRecordingOptions): Promise<RecordingHandle> {
|
||||||
|
const mime = pickMimeType(globalThis as RecorderHost);
|
||||||
|
if (!mime) {
|
||||||
|
throw new Error("Recording is not supported on this device");
|
||||||
|
}
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1 } });
|
||||||
|
let recorder: MediaRecorder;
|
||||||
|
try {
|
||||||
|
recorder = new MediaRecorder(stream, {
|
||||||
|
mimeType: mime,
|
||||||
|
audioBitsPerSecond: AUDIO_BITS_PER_SECOND,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
releaseTracks(stream);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks: Blob[] = [];
|
||||||
|
const startedAt = Date.now();
|
||||||
|
let ending = false;
|
||||||
|
let cancelled = false;
|
||||||
|
let wakeLock: WakeLockSentinel | null = null;
|
||||||
|
let resolveRecording: (recording: Recording) => void = () => undefined;
|
||||||
|
let rejectRecording: (error: Error) => void = () => undefined;
|
||||||
|
const result = new Promise<Recording>((resolve, reject) => {
|
||||||
|
resolveRecording = resolve;
|
||||||
|
rejectRecording = reject;
|
||||||
|
});
|
||||||
|
// A rejection nobody awaits (error after an auto-stop) must stay quiet.
|
||||||
|
result.catch(() => undefined);
|
||||||
|
|
||||||
|
let sec = 0;
|
||||||
|
const onVisibility = (): void => {
|
||||||
|
if (document.visibilityState === "hidden") {
|
||||||
|
autoStop();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
sec += 1;
|
||||||
|
opts.onTick(sec);
|
||||||
|
if (sec === opts.warnAtSec) {
|
||||||
|
opts.onWarn();
|
||||||
|
}
|
||||||
|
if (sec >= opts.maxSec) {
|
||||||
|
autoStop();
|
||||||
|
}
|
||||||
|
}, TICK_MS);
|
||||||
|
|
||||||
|
let cleanedUp = false;
|
||||||
|
const cleanUp = (): void => {
|
||||||
|
if (cleanedUp) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cleanedUp = true;
|
||||||
|
clearInterval(timer);
|
||||||
|
document.removeEventListener("visibilitychange", onVisibility);
|
||||||
|
releaseTracks(stream);
|
||||||
|
releaseWakeLock(wakeLock);
|
||||||
|
};
|
||||||
|
|
||||||
|
recorder.ondataavailable = (event) => {
|
||||||
|
if (event.data.size > 0) {
|
||||||
|
chunks.push(event.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
recorder.onstop = () => {
|
||||||
|
cleanUp();
|
||||||
|
if (cancelled) {
|
||||||
|
rejectRecording(new Error("Recording cancelled"));
|
||||||
|
} else if (chunks.length === 0) {
|
||||||
|
rejectRecording(new Error("Recording is empty"));
|
||||||
|
} else {
|
||||||
|
resolveRecording({
|
||||||
|
blob: new Blob(chunks, { type: mime }),
|
||||||
|
mime,
|
||||||
|
durationSec: Math.min(opts.maxSec, Math.round((Date.now() - startedAt) / TICK_MS)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const fail = (): void => {
|
||||||
|
cleanUp();
|
||||||
|
rejectRecording(new Error("Recording failed"));
|
||||||
|
};
|
||||||
|
recorder.onerror = fail;
|
||||||
|
// An unplugged microphone ends its tracks without a recorder error.
|
||||||
|
for (const track of stream.getTracks()) {
|
||||||
|
track.addEventListener("ended", fail);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stop = (): Promise<Recording> => {
|
||||||
|
if (!ending) {
|
||||||
|
ending = true;
|
||||||
|
if (recorder.state !== "inactive") {
|
||||||
|
recorder.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
function autoStop(): void {
|
||||||
|
if (!ending) {
|
||||||
|
stop().then(
|
||||||
|
(recording) => {
|
||||||
|
try {
|
||||||
|
opts.onAutoStop(recording);
|
||||||
|
} catch {
|
||||||
|
// A failing handler must not become an unhandled rejection;
|
||||||
|
// the microphone is already released.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("visibilitychange", onVisibility);
|
||||||
|
try {
|
||||||
|
recorder.start();
|
||||||
|
} catch (error) {
|
||||||
|
cleanUp();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
void requestWakeLock().then((lock) => {
|
||||||
|
wakeLock = lock;
|
||||||
|
if (ending || cleanedUp) {
|
||||||
|
releaseWakeLock(lock);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
stop,
|
||||||
|
cancel(): void {
|
||||||
|
if (ending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ending = true;
|
||||||
|
cancelled = true;
|
||||||
|
if (recorder.state !== "inactive") {
|
||||||
|
recorder.stop();
|
||||||
|
} else {
|
||||||
|
cleanUp();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
Reference in new issue
Block a user