feat: platform backbone and field capture, first part (checkpoint)

Platform backbone (plan 2026-10-13-plattform-rueckgrat), reviewed tasks:
- run roles api/worker/job/all with shared startup checks (Task 1)
- schema and migration for DomainEvent, deliveries, JobRun, ThrottlerHit (Task 2)
- audit, notifications and domain events written in the same transaction (Task 3)
- dispatcher with SKIP LOCKED claims, retries and stale release (Task 4)
- worker building blocks: tick, LISTEN/NOTIFY wake-up, health (Task 5)
- worker process and entry points (Task 6)
- throttler counters in Postgres (Task 19)
- separate keys per purpose with key ring (Task 20)
- public origin page without exact location (Task 22)
- shop keys split into read and write (Task 23)
- Kustomize base, components, overlay encry, CI validation (Task 26)

Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), reviewed tasks:
- brood frames, read endpoints, apiary overview and offline pack
- network basics, offline queue v3, navigation sheets, form building blocks
- new inspection with last-time hints and weather, varroa per day,
  treatment prefill, apiary page with round
- inspection photo (Task 10) is implemented, review pending

Not rolled out. Remaining tasks follow in later commits.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-13 17:57:54 +02:00
1 parent 10cbe9bf2d
commit c01a38835a
340 files changed
+25695 -1808

No files matched your search

@@ -0,0 +1,22 @@
import { AlertTriangle, CheckCircle2, XCircle, type LucideIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { COLONY_CONDITION_TONE_CLASS } from "@/lib/colonies/labels";
import type { StatusLight } from "@/lib/colonies/status-light";
import { cn } from "@/lib/utils";
const LIGHTS: Record<StatusLight, { label: string; icon: LucideIcon; tone: string }> = {
green: { label: "Gut", icon: CheckCircle2, tone: COLONY_CONDITION_TONE_CLASS.GOOD },
yellow: { label: "Achtung", icon: AlertTriangle, tone: COLONY_CONDITION_TONE_CLASS.AVERAGE },
red: { label: "Kritisch", icon: XCircle, tone: COLONY_CONDITION_TONE_CLASS.CRITICAL },
};
/** The traffic light as icon plus word - the colour only underlines it. */
export function StatusLightBadge({ light, className }: { light: StatusLight; className?: string }) {
const { label, icon: Icon, tone } = LIGHTS[light];
return (
<Badge className={cn(tone, className)}>
<Icon aria-hidden />
{label}
</Badge>
);
}
@@ -15,6 +15,11 @@ import type { VariantProps } from "class-variance-authority";
interface ConfirmDialogProps {
triggerLabel: string;
/**
* Accessible name when several triggers share one visible label (e.g. a
* „Verwerfen" per list row). Start it with the visible label (WCAG 2.5.3).
*/
triggerAriaLabel?: string;
triggerVariant?: VariantProps<typeof buttonVariants>["variant"];
triggerSize?: VariantProps<typeof buttonVariants>["size"];
triggerClassName?: string;
@@ -29,6 +34,7 @@ interface ConfirmDialogProps {
export function ConfirmDialog({
triggerLabel,
triggerAriaLabel,
triggerVariant = "outline",
triggerSize = "default",
triggerClassName,
@@ -49,6 +55,7 @@ export function ConfirmDialog({
variant={triggerVariant}
size={triggerSize}
className={cn(triggerClassName)}
aria-label={triggerAriaLabel}
disabled={triggerDisabled}
onClick={() => setOpen(true)}
>
@@ -0,0 +1,131 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { toast } from "sonner";
import { fetchDocuments, updateDocument, uploadDocument } from "@/lib/documents/api";
import { MAX_DOCUMENT_UPLOAD_BYTES } from "@/lib/documents/limits";
import { PhotoTooLargeError, preparePhoto } from "@/lib/photos/reencode";
import { DocumentUploadCard } from "./document-upload-card";
vi.mock("@/lib/documents/api", () => ({
fetchDocuments: vi.fn(),
uploadDocument: vi.fn(),
updateDocument: vi.fn(),
deleteDocument: vi.fn(),
downloadDocument: vi.fn(),
}));
vi.mock("@/lib/photos/reencode", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/photos/reencode")>()),
preparePhoto: vi.fn(),
}));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
const CLEAN_PHOTO = new Blob(["clean jpeg"], { type: "image/jpeg" });
const STORED = {
id: "doc-1",
entityType: "INSPECTION" as const,
entityId: "i-1",
fileName: "rahmen.jpg",
mimeType: "image/jpeg",
sizeBytes: 2048,
uploadedByUserId: "user-1",
createdAt: "2026-10-13T10:00:00.000Z",
};
function fileInput(container: HTMLElement): HTMLInputElement {
const input = container.querySelector<HTMLInputElement>('input[type="file"]');
if (!input) throw new Error("no file input");
return input;
}
function renderCard(onOuterSubmit?: () => void) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const card = <DocumentUploadCard entityType="INSPECTION" entityId="i-1" />;
return render(
<QueryClientProvider client={client}>
{onOuterSubmit ? (
<form
onSubmit={(event) => {
event.preventDefault();
onOuterSubmit();
}}
>
{card}
</form>
) : (
card
)}
</QueryClientProvider>,
);
}
describe("DocumentUploadCard", () => {
beforeEach(() => {
vi.mocked(fetchDocuments).mockReset().mockResolvedValue([]);
vi.mocked(uploadDocument).mockReset().mockResolvedValue(STORED);
vi.mocked(updateDocument).mockReset().mockResolvedValue(STORED);
vi.mocked(preparePhoto).mockReset().mockResolvedValue(CLEAN_PHOTO);
vi.mocked(toast.error).mockReset();
});
it("re-encodes images before upload, PDFs unchanged", async () => {
const { container } = renderCard();
const photo = new File(["png with metadata"], "Beute 3.png", { type: "image/png" });
const invoice = new File(["%PDF-1.7"], "rechnung.pdf", { type: "application/pdf" });
await userEvent.upload(fileInput(container), photo);
await waitFor(() => expect(uploadDocument).toHaveBeenCalledTimes(1));
await userEvent.upload(fileInput(container), invoice);
await waitFor(() => expect(uploadDocument).toHaveBeenCalledTimes(2));
expect(preparePhoto).toHaveBeenCalledTimes(1);
expect(preparePhoto).toHaveBeenCalledWith(photo);
expect(uploadDocument).toHaveBeenNthCalledWith(1, "INSPECTION", "i-1", CLEAN_PHOTO, {
fileName: "Beute 3.jpg",
});
expect(uploadDocument).toHaveBeenNthCalledWith(2, "INSPECTION", "i-1", invoice);
});
it("does not upload a photo that stays too large, and says why", async () => {
vi.mocked(preparePhoto).mockRejectedValue(new PhotoTooLargeError());
const { container } = renderCard();
await userEvent.upload(
fileInput(container),
new File(["huge"], "panorama.jpg", { type: "image/jpeg" }),
);
await waitFor(() =>
expect(toast.error).toHaveBeenCalledWith("Foto zu groß (max. 10 MB)"),
);
expect(uploadDocument).not.toHaveBeenCalled();
});
it("rejects a PDF above the server's upload limit before sending it", async () => {
const { container } = renderCard();
const invoice = new File(["%PDF-1.7"], "scan.pdf", { type: "application/pdf" });
Object.defineProperty(invoice, "size", { value: MAX_DOCUMENT_UPLOAD_BYTES + 1 });
await userEvent.upload(fileInput(container), invoice);
expect(toast.error).toHaveBeenCalledWith("Datei zu groß (max. 10 MB)");
expect(uploadDocument).not.toHaveBeenCalled();
});
it("renaming a file never submits the form the card sits in", async () => {
vi.mocked(fetchDocuments).mockResolvedValue([STORED]);
const outerSubmit = vi.fn();
renderCard(outerSubmit);
await userEvent.click(await screen.findByRole("button", { name: "Umbenennen" }));
const dialog = await screen.findByRole("dialog", { name: "Datei umbenennen" });
await userEvent.clear(within(dialog).getByLabelText("Dateiname"));
await userEvent.type(within(dialog).getByLabelText("Dateiname"), "brutwabe.jpg{Enter}");
await waitFor(() =>
expect(updateDocument).toHaveBeenCalledWith("doc-1", { fileName: "brutwabe.jpg" }),
);
expect(outerSubmit).not.toHaveBeenCalled();
});
});
@@ -12,6 +12,8 @@ import {
uploadDocument,
} from "@/lib/documents/api";
import { DocumentEntityType, DocumentSummary } from "@/lib/documents/types";
import { MAX_DOCUMENT_UPLOAD_BYTES } from "@/lib/documents/limits";
import { PhotoMetadataError, PhotoTooLargeError, preparePhoto } from "@/lib/photos/reencode";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
@@ -28,7 +30,42 @@ import { ConfirmDialog } from "@/components/confirm-dialog";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
const ALLOWED_MIME_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "application/pdf"]);
const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024;
const TOO_LARGE_MESSAGE = "Datei zu groß (max. 10 MB)";
function isImage(file: File): boolean {
return file.type.startsWith("image/");
}
/** "Beute 3.png" -> "Beute 3.jpg": a re-encoded image is always a JPEG. */
function jpegName(fileName: string): string {
const dot = fileName.lastIndexOf(".");
return `${dot > 0 ? fileName.slice(0, dot) : fileName}.jpg`;
}
/**
* Images are re-encoded first (preparePhoto): what reaches the server never
* carries the camera's EXIF/GPS - an apiary's position is theft-relevant.
* PDFs go up unchanged.
*/
async function uploadFile(
entityType: DocumentEntityType,
entityId: string,
file: File,
): Promise<DocumentSummary> {
if (!isImage(file)) {
return uploadDocument(entityType, entityId, file);
}
const photo = await preparePhoto(file);
return uploadDocument(entityType, entityId, photo, { fileName: jpegName(file.name) });
}
function uploadErrorMessage(error: unknown): string {
if (error instanceof PhotoTooLargeError) return "Foto zu groß (max. 10 MB)";
if (error instanceof PhotoMetadataError) {
return "Foto wurde nicht hochgeladen: Die Standortdaten ließen sich nicht entfernen.";
}
return "Datei konnte nicht hochgeladen werden";
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
@@ -60,6 +97,9 @@ function RenameDocumentDialog({ document }: { document: DocumentSummary }) {
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
// The dialog sits in a portal, but React still bubbles the submit up the
// component tree: it must not also submit a form the card is placed in.
event.stopPropagation();
mutation.mutate();
}
@@ -127,12 +167,12 @@ export function DocumentUploadCard({
});
const uploadMutation = useMutation({
mutationFn: (file: File) => uploadDocument(entityType, entityId, file),
mutationFn: (file: File) => uploadFile(entityType, entityId, file),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey });
toast.success("Datei hochgeladen");
},
onError: () => toast.error("Datei konnte nicht hochgeladen werden"),
onError: (error) => toast.error(uploadErrorMessage(error)),
});
const deleteMutation = useMutation({
@@ -152,8 +192,10 @@ export function DocumentUploadCard({
toast.error("Nicht unterstützter Dateityp (nur JPEG, PNG, WebP, PDF)");
return;
}
if (file.size > MAX_FILE_SIZE_BYTES) {
toast.error("Datei zu groß (max. 10 MB)");
// An image may shrink below the limit when it is re-encoded; preparePhoto
// checks the result.
if (!isImage(file) && file.size > MAX_DOCUMENT_UPLOAD_BYTES) {
toast.error(TOO_LARGE_MESSAGE);
return;
}
uploadMutation.mutate(file);
@@ -15,6 +15,8 @@ export interface ChoiceOption<T extends ChoiceValue> {
* the visible label (voice control users say what they see).
*/
srSuffix?: string;
/** Replaces the default selected look, e.g. a tone colour of a rating step. */
selectedClassName?: string;
}
/** Distinct per value and type: "null" (text) and null must not collide. */
@@ -131,6 +133,7 @@ export function ChoiceGroup<T extends ChoiceValue>({
selected &&
!isUnanswered &&
"border-primary bg-primary font-medium text-primary-foreground",
selected && option.selectedClassName,
optionClassName,
)}
>
@@ -0,0 +1,58 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FormBackLink } from "./form-back-link";
const push = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push }),
}));
describe("FormBackLink", () => {
beforeEach(() => push.mockReset());
it("is a plain link when nothing was typed", () => {
render(<FormBackLink href="/colonies" label="Zu den Völkern" dirty={false} />);
expect(screen.getByRole("link", { name: /Zu den Völkern/ })).toHaveAttribute(
"href",
"/colonies",
);
});
it("asks before leaving and flushes the draft", async () => {
const flush = vi.fn();
render(
<FormBackLink href="/colonies" label="Zu den Völkern" dirty draft={{ flush }} />,
);
await userEvent.click(screen.getByRole("link", { name: /Zu den Völkern/ }));
expect(push).not.toHaveBeenCalled();
expect(
await screen.findByText(/Deine Eingaben bleiben als Entwurf auf diesem Gerät\./),
).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Verlassen" }));
expect(flush).toHaveBeenCalledTimes(1);
expect(push).toHaveBeenCalledWith("/colonies");
});
it("stays on the form with Weiter bearbeiten", async () => {
render(<FormBackLink href="/colonies" label="Zu den Völkern" dirty />);
await userEvent.click(screen.getByRole("link", { name: /Zu den Völkern/ }));
await userEvent.click(await screen.findByRole("button", { name: "Weiter bearbeiten" }));
expect(push).not.toHaveBeenCalled();
});
it("warns that input is lost when there is no draft", async () => {
render(<FormBackLink href="/colonies" label="Zu den Völkern" dirty />);
await userEvent.click(screen.getByRole("link", { name: /Zu den Völkern/ }));
expect(await screen.findByText(/Deine Eingaben gehen verloren\./)).toBeInTheDocument();
});
});
@@ -0,0 +1,83 @@
"use client";
import { MouseEvent, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
export interface FormBackLinkProps {
href: string;
label: string;
/** Something was typed in: leaving needs a confirmation. */
dirty: boolean;
/** The form's draft; flushed so leaving does not race the autosave delay. */
draft?: { flush: () => void };
}
const NOT_PLAIN_CLICK = (event: MouseEvent) =>
event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
/**
* „Zurück" above a new-form's header. A plain link while the form is
* untouched; once something was typed it asks first, saying honestly whether
* the input stays as a draft on this device or is lost.
*/
export function FormBackLink({ href, label, dirty, draft }: FormBackLinkProps) {
const router = useRouter();
const [open, setOpen] = useState(false);
function handleClick(event: MouseEvent<HTMLAnchorElement>) {
// Modified clicks open a new tab: the form here stays untouched.
if (!dirty || NOT_PLAIN_CLICK(event)) return;
event.preventDefault();
setOpen(true);
}
function leave() {
draft?.flush();
setOpen(false);
router.push(href);
}
return (
<>
<Link
href={href}
onClick={handleClick}
className="-ml-2 inline-flex min-h-11 w-fit items-center gap-1.5 rounded-lg px-2 text-sm font-medium text-muted-foreground hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
>
<ArrowLeft className="size-4" aria-hidden="true" />
{label}
</Link>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle>Formular verlassen?</DialogTitle>
<DialogDescription>
{draft
? "Deine Eingaben bleiben als Entwurf auf diesem Gerät."
: "Deine Eingaben gehen verloren."}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" onClick={() => setOpen(false)}>
Weiter bearbeiten
</Button>
<Button type="button" variant="outline" onClick={leave}>
Verlassen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,111 @@
import { describe, expect, it } from "vitest";
import { useState } from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { NumberStepper } from "./number-stepper";
function Harness({ initial = "" }: { initial?: string }) {
const [value, setValue] = useState(initial);
return (
<NumberStepper
id="mites"
value={value}
onChange={setValue}
unitLabel="Milben"
decrementLabel="Eine Milbe weniger"
incrementLabel="Eine Milbe mehr"
/>
);
}
describe("NumberStepper", () => {
it('„+" from empty gives 1', async () => {
render(<Harness />);
await userEvent.click(screen.getByRole("button", { name: /Eine Milbe mehr/ }));
expect(screen.getByRole("textbox")).toHaveValue("1");
});
it('„−" is disabled at 0', () => {
render(<Harness initial="0" />);
expect(screen.getByRole("button", { name: /Eine Milbe weniger/ })).toBeDisabled();
});
it('„−" is disabled while empty', () => {
render(<Harness />);
expect(screen.getByRole("button", { name: /Eine Milbe weniger/ })).toBeDisabled();
});
it('typing 7 then „+" gives 8', async () => {
render(<Harness />);
await userEvent.type(screen.getByRole("textbox"), "7");
await userEvent.click(screen.getByRole("button", { name: /Eine Milbe mehr/ }));
expect(screen.getByRole("textbox")).toHaveValue("8");
});
it('„−" steps down from 8 to 7', async () => {
render(<Harness initial="8" />);
await userEvent.click(screen.getByRole("button", { name: /Eine Milbe weniger/ }));
expect(screen.getByRole("textbox")).toHaveValue("7");
});
it("drops non-digits while typing", async () => {
render(<Harness />);
await userEvent.type(screen.getByRole("textbox"), "1a2");
expect(screen.getByRole("textbox")).toHaveValue("12");
});
it('„leeren" clears to empty and only shows with a value', async () => {
render(<Harness />);
expect(screen.queryByRole("button", { name: /leeren/ })).not.toBeInTheDocument();
await userEvent.type(screen.getByRole("textbox"), "5");
await userEvent.click(screen.getByRole("button", { name: /leeren/ }));
expect(screen.getByRole("textbox")).toHaveValue("");
});
it("moves focus to the input when „−“ turns disabled", async () => {
render(<Harness initial="1" />);
await userEvent.click(screen.getByRole("button", { name: /Eine Milbe weniger/ }));
expect(screen.getByRole("textbox")).toHaveValue("0");
expect(screen.getByRole("textbox")).toHaveFocus();
});
it("moves focus to the input after „leeren“", async () => {
render(<Harness initial="5" />);
await userEvent.click(screen.getByRole("button", { name: /leeren/ }));
expect(screen.getByRole("textbox")).toHaveFocus();
});
it("caps the value so „+“ never produces huge numbers", async () => {
render(<Harness initial="9999" />);
expect(screen.getByRole("textbox")).toHaveAttribute("maxlength", "4");
await userEvent.click(screen.getByRole("button", { name: /Eine Milbe mehr/ }));
expect(screen.getByRole("textbox")).toHaveValue("9999");
});
it("buttons are named and 44 px", () => {
render(<Harness initial="3" />);
for (const name of [/Eine Milbe weniger/, /Eine Milbe mehr/, /leeren/]) {
expect(screen.getByRole("button", { name })).toHaveClass("min-h-11");
}
expect(screen.getByRole("textbox")).toHaveAccessibleDescription("Milben");
});
});
@@ -0,0 +1,123 @@
"use client";
import { ChangeEvent, useRef } from "react";
import { Input } from "@/components/ui/input";
export interface NumberStepperProps {
id: string;
/** "" = not counted. */
value: string;
onChange: (value: string) => void;
min?: number;
step?: number;
/** Visible unit next to the field, e.g. "Milben"; the name comes from the Field label. */
unitLabel: string;
decrementLabel: string;
incrementLabel: string;
describedBy?: string;
invalid?: boolean;
}
const STEP_BUTTON_CLASS =
"inline-flex min-h-11 min-w-11 items-center justify-center rounded-xl border border-input bg-card text-xl font-semibold outline-none motion-safe:transition-colors hover:bg-muted focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50";
const MAX_DIGITS = 4;
const MAX_VALUE = 10 ** MAX_DIGITS - 1;
function digitsOnly(text: string): string {
return text.replace(/\D/g, "");
}
/**
* A count with big plus and minus buttons for gloved fingers; the field stays
* typable. Whole counts only: digits only (no negatives or decimals), at most
* 4 digits. Empty means "not counted": "+" then starts at min + step, "−" is
* off, and "leeren" goes back to empty (a 0 would read as "counted: none").
*/
export function NumberStepper({
id,
value,
onChange,
min = 0,
step = 1,
unitLabel,
decrementLabel,
incrementLabel,
describedBy,
invalid,
}: NumberStepperProps) {
const unitId = `${id}-unit`;
const current = value === "" ? null : Number(value);
const canDecrement = current !== null && current - step >= min;
const inputRef = useRef<HTMLInputElement>(null);
function increment() {
onChange(String(Math.min(MAX_VALUE, current === null ? min + step : current + step)));
}
function decrement() {
if (!canDecrement) return;
const next = current - step;
onChange(String(next));
// A button that turns disabled would drop keyboard focus to the page.
if (next - step < min) inputRef.current?.focus();
}
function clear() {
onChange("");
inputRef.current?.focus();
}
function handleInput(event: ChangeEvent<HTMLInputElement>) {
onChange(digitsOnly(event.target.value));
}
return (
<div className="flex flex-col items-start gap-1">
<div className="flex items-center gap-2">
<button
type="button"
onClick={decrement}
disabled={!canDecrement}
className={STEP_BUTTON_CLASS}
>
−<span className="sr-only"> {decrementLabel}</span>
</button>
<Input
id={id}
ref={inputRef}
type="text"
maxLength={MAX_DIGITS}
inputMode="numeric"
autoComplete="off"
value={value}
onChange={handleInput}
aria-describedby={[describedBy, unitId].filter(Boolean).join(" ")}
aria-invalid={invalid || undefined}
className="w-20 text-center text-lg font-semibold sm:h-11"
/>
<button
type="button"
onClick={increment}
className={STEP_BUTTON_CLASS}
>
+<span className="sr-only"> {incrementLabel}</span>
</button>
<span id={unitId} className="text-sm text-muted-foreground">
{unitLabel}
</span>
</div>
{value !== "" ? (
<button
type="button"
onClick={clear}
className="min-h-11 rounded-lg px-2 text-sm text-muted-foreground underline underline-offset-4 outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
>
leeren{" "}
<span className="sr-only">– {unitLabel}</span>
</button>
) : null}
</div>
);
}
@@ -0,0 +1,22 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { PreviousValueHint } from "./previous-value-hint";
describe("PreviousValueHint", () => {
it("announces and takes the value over", async () => {
const onTakeOver = vi.fn();
render(
<PreviousValueHint fieldLabel="Königin gesehen" valueLabel="Ja" onTakeOver={onTakeOver} />,
);
expect(screen.getByText(/Letztes Mal: Ja/)).toBeInTheDocument();
const button = screen.getByRole("button", { name: /übernehmen/ });
expect(button).toHaveAccessibleName("übernehmen – Königin gesehen");
expect(button).toHaveClass("min-h-11");
await userEvent.click(button);
expect(onTakeOver).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,40 @@
"use client";
import { cn } from "@/lib/utils";
export interface PreviousValueHintProps {
/** Name of the field, spoken after "übernehmen" so the button is unambiguous. */
fieldLabel: string;
/** The earlier answer in words, e.g. "Ja" or "4 von 5". */
valueLabel: string;
onTakeOver: () => void;
className?: string;
}
/**
* "Letztes Mal: Ja · übernehmen". Observations are never prefilled - what
* the beekeeper did not look at today must not read as seen. The last value
* is only shown, and one tap takes it over.
*/
export function PreviousValueHint({
fieldLabel,
valueLabel,
onTakeOver,
className,
}: PreviousValueHintProps) {
return (
<p className={cn("flex flex-wrap items-center gap-x-2 text-sm text-muted-foreground", className)}>
<span>
Letztes Mal: {valueLabel} <span aria-hidden="true">·</span>
</span>
<button
type="button"
onClick={onTakeOver}
className="min-h-11 rounded-lg px-2 font-medium text-foreground underline underline-offset-4 outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
>
übernehmen{" "}
<span className="sr-only">– {fieldLabel}</span>
</button>
</p>
);
}
@@ -0,0 +1,94 @@
import { describe, expect, it, vi } from "vitest";
import { useState } from "react";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { RatingScale } from "./rating-scale";
import type { RatingDirection } from "@/lib/forms/rating";
function Harness({
initial,
direction,
onChange,
}: {
initial: number | null;
direction?: RatingDirection;
onChange?: (value: number | null) => void;
}) {
const [value, setValue] = useState(initial);
return (
<RatingScale
id="mood"
value={value}
direction={direction}
lowLabel="ruhig"
highLabel="stark"
onChange={(next) => {
setValue(next);
onChange?.(next);
}}
/>
);
}
describe("RatingScale", () => {
it("lower-is-better shows stored 5 as display 1 on the left", () => {
render(<Harness initial={5} direction="lower-is-better" />);
const radios = within(screen.getByRole("radiogroup")).getAllByRole("radio");
expect(radios[0]).toHaveAttribute("aria-checked", "true");
expect(radios[0]).toHaveTextContent("1");
});
it("tapping display 5 stores 1", async () => {
const onChange = vi.fn();
render(<Harness initial={null} direction="lower-is-better" onChange={onChange} />);
await userEvent.click(screen.getAllByRole("radio")[4]);
expect(onChange).toHaveBeenLastCalledWith(1);
});
it("keeps stored values unchanged for higher-is-better", async () => {
const onChange = vi.fn();
render(<Harness initial={null} onChange={onChange} />);
await userEvent.click(screen.getAllByRole("radio")[3]);
expect(onChange).toHaveBeenLastCalledWith(4);
});
it("tapping the chosen value again clears to null", async () => {
const onChange = vi.fn();
render(<Harness initial={3} onChange={onChange} />);
await userEvent.click(screen.getAllByRole("radio")[2]);
expect(onChange).toHaveBeenLastCalledWith(null);
});
it("the selected option carries the tone of its display value", () => {
const { unmount } = render(<Harness initial={5} direction="lower-is-better" />);
expect(screen.getAllByRole("radio")[0]).toHaveClass("bg-condition-critical");
unmount();
render(<Harness initial={5} />);
expect(screen.getAllByRole("radio")[4]).toHaveClass("bg-condition-excellent");
});
it("lower-is-better swaps the stored end words so the bad end stays left", async () => {
const onChange = vi.fn();
render(<Harness initial={null} direction="lower-is-better" onChange={onChange} />);
expect(screen.getByText("1 = stark")).toBeInTheDocument();
expect(screen.getByText("5 = ruhig")).toBeInTheDocument();
await userEvent.click(screen.getAllByRole("radio")[0]);
expect(onChange).toHaveBeenLastCalledWith(5);
});
it("higher-is-better labels the stored ends as given", () => {
render(<Harness initial={null} />);
expect(screen.getByText("1 = ruhig")).toBeInTheDocument();
expect(screen.getByText("5 = stark")).toBeInTheDocument();
});
});
@@ -1,18 +1,36 @@
"use client";
import {
RATING_TONE_CLASS,
toDisplayRating,
toStoredRating,
type RatingDirection,
} from "@/lib/forms/rating";
import { ChoiceGroup, type ChoiceOption } from "./choice-group";
const RATING_OPTIONS: readonly ChoiceOption<number | null>[] = [1, 2, 3, 4, 5].map(
(rating) => ({ value: rating, label: String(rating), srSuffix: "von 5" }),
);
const DISPLAY_STEPS = [1, 2, 3, 4, 5] as const;
const RATING_OPTIONS: readonly ChoiceOption<number | null>[] = DISPLAY_STEPS.map((step) => ({
value: step,
label: String(step),
srSuffix: "von 5",
selectedClassName: RATING_TONE_CLASS[step],
}));
interface RatingScaleProps {
id: string;
/** Stored value; with `direction` it is mirrored for display only. */
value: number | null;
onChange: (value: number | null) => void;
/** Words for the ends, e.g. "stechlustig" / "sehr sanft". */
/**
* Words for the STORED ends (lowLabel = stored 1, highLabel = stored 5).
* With `direction="lower-is-better"` they are shown swapped, so the bad end
* stays on the left.
*/
lowLabel: string;
highLabel: string;
/** Which stored end is good; the good end is always shown on the right. */
direction?: RatingDirection;
describedBy?: string;
/** Id of the visible label, so the group is announced by name. */
labelledBy?: string;
@@ -20,8 +38,9 @@ interface RatingScaleProps {
/**
* 1-5 as five large buttons instead of a number field: one tap with gloves,
* no keyboard, and the meaning of both ends stays on screen. Tapping the
* chosen value again clears it.
* no keyboard, and the meaning of both ends stays on screen. Left is always
* the bad end, right the good one, and a chosen step takes the tone of its
* displayed value. Tapping the chosen value again clears it.
*/
export function RatingScale({
id,
@@ -29,9 +48,12 @@ export function RatingScale({
onChange,
lowLabel,
highLabel,
direction = "higher-is-better",
describedBy,
labelledBy,
}: RatingScaleProps) {
const [leftLabel, rightLabel] =
direction === "lower-is-better" ? [highLabel, lowLabel] : [lowLabel, highLabel];
return (
<div className="flex flex-col gap-1">
<ChoiceGroup
@@ -39,15 +61,15 @@ export function RatingScale({
labelledBy={labelledBy}
describedBy={describedBy}
options={RATING_OPTIONS}
value={value}
value={toDisplayRating(value, direction)}
clearValue={null}
onChange={onChange}
onChange={(display) => onChange(toStoredRating(display, direction))}
className="grid-cols-5"
optionClassName="text-base font-semibold"
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>1 = {lowLabel}</span>
<span>5 = {highLabel}</span>
<span>1 = {leftLabel}</span>
<span>5 = {rightLabel}</span>
</div>
</div>
);
@@ -57,4 +57,31 @@ describe("TriStateField", () => {
expect(screen.getByRole("radio", { name: "Nein" })).toHaveAttribute("aria-checked", "true");
});
it("shows „Letztes Mal: Ja · übernehmen“ and takes it over", async () => {
const onChange = vi.fn();
render(
<TriStateField
label="Königin gesehen"
value={null}
onChange={onChange}
previous={{ valueLabel: "Ja", onTakeOver: () => onChange(true) }}
/>,
);
expect(screen.getByText(/Letztes Mal: Ja/)).toBeInTheDocument();
expect(screen.getByRole("radio", { name: "Nicht geprüft" })).toHaveAttribute(
"aria-checked",
"true",
);
await userEvent.click(screen.getByRole("button", { name: /übernehmen/ }));
expect(onChange).toHaveBeenCalledWith(true);
});
it("shows no hint without a previous value", () => {
render(<Harness initial={null} onChange={vi.fn()} />);
expect(screen.queryByText(/Letztes Mal/)).not.toBeInTheDocument();
});
});
@@ -2,6 +2,7 @@
import { useId } from "react";
import { ChoiceGroup, type ChoiceOption } from "./choice-group";
import { PreviousValueHint } from "./previous-value-hint";
export const TRI_STATE_OPTIONS: readonly ChoiceOption<boolean | null>[] = [
{ value: true, label: "Ja" },
@@ -13,6 +14,8 @@ interface TriStateFieldProps {
label: string;
value: boolean | null;
onChange: (value: boolean | null) => void;
/** Last time's answer, offered for one-tap take-over - never prefilled. */
previous?: { valueLabel: string; onTakeOver: () => void } | null;
}
/**
@@ -20,7 +23,7 @@ interface TriStateFieldProps {
* "Nicht geprüft" (null), so only what the beekeeper actually looked at is
* saved as a finding - an untouched "no" would otherwise read as "seen: none".
*/
export function TriStateField({ label, value, onChange }: TriStateFieldProps) {
export function TriStateField({ label, value, onChange, previous }: TriStateFieldProps) {
const labelId = useId();
return (
<div className="flex flex-col gap-1.5">
@@ -34,6 +37,13 @@ export function TriStateField({ label, value, onChange }: TriStateFieldProps) {
onChange={onChange}
className="grid-cols-3"
/>
{previous ? (
<PreviousValueHint
fieldLabel={label}
valueLabel={previous.valueLabel}
onTakeOver={previous.onTakeOver}
/>
) : null}
</div>
);
}
@@ -0,0 +1,70 @@
"use client";
import type { ComponentProps, ReactNode } from "react";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetClose,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
/** A tappable row in a nav sheet: 56px high for gloved or hurried thumbs. */
export const NAV_SHEET_ROW_CLASS =
"flex min-h-14 items-center gap-3 rounded-xl px-3 text-base font-medium focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none";
interface NavSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** The `SheetTrigger` that opens the sheet. */
trigger: ReactNode;
title: string;
/** Extra classes for the sheet panel. */
className?: string;
/** Where focus goes on close; by default back to the trigger. */
finalFocus?: ComponentProps<typeof SheetContent>["finalFocus"];
children: ReactNode;
}
/**
* The shared bottom sheet of „Mehr", „+ Neu" and the offline queue: a title,
* a German close button and a scroll area that clears the iOS home indicator.
*/
export function NavSheet({
open,
onOpenChange,
trigger,
title,
className,
finalFocus,
children,
}: NavSheetProps) {
return (
<Sheet open={open} onOpenChange={onOpenChange}>
{trigger}
<SheetContent
side="bottom"
// The built-in close button is English; ours is German and 44px high.
showCloseButton={false}
finalFocus={finalFocus}
className={[
"max-h-[85dvh] gap-0 overflow-y-auto rounded-t-2xl pb-(--safe-area-bottom)",
className,
]
.filter(Boolean)
.join(" ")}
>
<SheetHeader className="flex-row items-center justify-between">
<SheetTitle>{title}</SheetTitle>
<SheetClose
render={<Button variant="ghost" size="sm" className="min-h-11" />}
>
Schließen
</SheetClose>
</SheetHeader>
{children}
</SheetContent>
</Sheet>
);
}
@@ -1,78 +0,0 @@
"use client";
import { useEffect } from "react";
import { CloudOff, RefreshCw } from "lucide-react";
import { toast } from "sonner";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Badge } from "@/components/ui/badge";
import { countAllPending } from "@/lib/offline/db";
import { syncPendingInspections } from "@/lib/offline/sync";
const PENDING_QUERY_KEY = ["offline-pending-entries"];
export function OfflineSyncBadge() {
const queryClient = useQueryClient();
const { data: pending = 0 } = useQuery({
queryKey: PENDING_QUERY_KEY,
queryFn: countAllPending,
refetchInterval: 15000,
});
const syncMutation = useMutation({
mutationFn: syncPendingInspections,
onSuccess: (result) => {
void queryClient.invalidateQueries({ queryKey: PENDING_QUERY_KEY });
if (result.synced > 0) {
toast.success(`${result.synced} Eintrag/Einträge synchronisiert`);
}
if (result.failed > 0) {
toast.error(
`${result.failed} Eintrag/Einträge konnten nicht synchronisiert werden und bleiben in der Warteschlange`,
);
}
},
onError: () =>
toast.error("Synchronisierung fehlgeschlagen - wird beim nächsten Versuch erneut versucht"),
});
useEffect(() => {
function handleOnline() {
syncMutation.mutate();
}
window.addEventListener("online", handleOnline);
if (navigator.onLine) {
syncMutation.mutate();
}
return () => window.removeEventListener("online", handleOnline);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (pending === 0) {
return null;
}
return (
<Badge
variant="warning"
role="button"
tabIndex={0}
className="cursor-pointer"
title="Ausstehende Einträge synchronisieren"
onClick={() => syncMutation.mutate()}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
syncMutation.mutate();
}
}}
>
{syncMutation.isPending ? (
<RefreshCw className="size-3.5 animate-spin" />
) : (
<CloudOff className="size-3.5" />
)}
{pending} ausstehend
</Badge>
);
}
@@ -0,0 +1,263 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useRef } from "react";
import { act, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { discardQueueItem, listQueue, summarizeQueue, type QueueItem } from "@/lib/offline/db";
import { resumeSync, syncPendingEntries } from "@/lib/offline/sync";
import { OfflineBand } from "./offline-band";
vi.mock("@/lib/offline/db", () => ({
summarizeQueue: vi.fn(),
listQueue: vi.fn(),
discardQueueItem: vi.fn(),
}));
vi.mock("@/lib/offline/sync", () => ({ syncPendingEntries: vi.fn(), resumeSync: vi.fn() }));
const NOTHING_SENT = { synced: 0, failed: 0, skippedForeign: 0 };
const REJECTED: QueueItem = {
store: "pending-inspections",
id: 7,
label: "Durchsicht",
queuedAt: "2026-10-13T08:15:00.000Z",
lastError: { status: 400, message: "Volk V-03 ist beendet.", at: "2026-10-13T09:00:00.000Z" },
};
const WAITING: QueueItem = {
store: "pending-feedings",
id: 3,
label: "Fütterung",
queuedAt: "2026-10-13T08:20:00.000Z",
};
function setOnLine(value: boolean) {
vi.spyOn(window.navigator, "onLine", "get").mockReturnValue(value);
}
/** The band as the layout places it: above the page content wrapper. */
function BandOnPage() {
const pageContentRef = useRef<HTMLDivElement>(null);
return (
<>
<OfflineBand userId="user-1" pageContentRef={pageContentRef} />
<div ref={pageContentRef} tabIndex={-1} data-testid="page-content">
Seiteninhalt
</div>
</>
);
}
function renderBand() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<BandOnPage />
</QueryClientProvider>,
);
}
/** Opens the sheet from the rejected entries and discards the rejected one. */
async function discardRejectedEntry(nextSummary: { own: number; failed: number; foreign: number }) {
await userEvent.click(
await screen.findByRole("button", { name: "1 Eintrag abgelehnt – ansehen" }),
);
const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" });
vi.mocked(summarizeQueue).mockResolvedValue(nextSummary);
vi.mocked(listQueue).mockResolvedValue(nextSummary.own > 0 ? [WAITING] : []);
await userEvent.click(
await within(sheet).findByRole("button", { name: /^Verwerfen: Durchsicht vom / }),
);
const confirm = await screen.findByRole("dialog", { name: /^Durchsicht vom .+ verwerfen\?$/ });
await userEvent.click(within(confirm).getByRole("button", { name: "Verwerfen" }));
await waitFor(() => expect(discardQueueItem).toHaveBeenCalledWith(REJECTED));
return sheet;
}
describe("OfflineBand", () => {
beforeEach(() => {
vi.mocked(summarizeQueue).mockReset();
vi.mocked(listQueue).mockReset().mockResolvedValue([]);
vi.mocked(discardQueueItem).mockReset().mockResolvedValue(undefined);
vi.mocked(syncPendingEntries).mockReset().mockResolvedValue(NOTHING_SENT);
vi.mocked(resumeSync).mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("announces „Offline – 2 Einträge warten\" in a status region", async () => {
setOnLine(false);
vi.mocked(summarizeQueue).mockResolvedValue({ own: 2, failed: 0, foreign: 0 });
renderBand();
await waitFor(() =>
expect(screen.getByRole("status")).toHaveTextContent("Offline – 2 Einträge warten"),
);
expect(screen.getByRole("status")).toHaveAttribute("aria-live", "polite");
expect(summarizeQueue).toHaveBeenCalledWith("user-1");
expect(syncPendingEntries).not.toHaveBeenCalled();
});
it("says „1 Eintrag wartet\" for a single entry", async () => {
setOnLine(false);
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 });
renderBand();
await waitFor(() =>
expect(screen.getByRole("status")).toHaveTextContent("Offline – 1 Eintrag wartet"),
);
});
it("tells that input is kept while offline with an empty queue", async () => {
setOnLine(false);
vi.mocked(summarizeQueue).mockResolvedValue({ own: 0, failed: 0, foreign: 0 });
renderBand();
expect(await screen.findByRole("status")).toHaveTextContent(
"Offline – Eingaben werden gespeichert und später gesendet",
);
});
it("keeps an empty status region online with an empty queue", async () => {
setOnLine(true);
vi.mocked(summarizeQueue).mockResolvedValue({ own: 0, failed: 0, foreign: 3 });
renderBand();
await waitFor(() => expect(summarizeQueue).toHaveBeenCalled());
await act(async () => {});
// The live region must exist before its first message, or that message is not announced.
const status = screen.getByRole("status");
expect(status).toHaveAttribute("aria-live", "polite");
expect(status).toBeEmptyDOMElement();
});
it("„Jetzt senden\" starts syncPendingEntries", async () => {
setOnLine(true);
vi.mocked(summarizeQueue).mockResolvedValue({ own: 2, failed: 0, foreign: 0 });
renderBand();
const button = await screen.findByRole("button", { name: "2 Einträge warten – Jetzt senden" });
// Online on mount the band already tries once by itself.
await waitFor(() => expect(syncPendingEntries).toHaveBeenCalledTimes(1));
await userEvent.click(button);
await waitFor(() => expect(syncPendingEntries).toHaveBeenCalledTimes(2));
expect(syncPendingEntries).toHaveBeenLastCalledWith("user-1");
});
it("its button is at least 44 px", async () => {
setOnLine(true);
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 1, foreign: 0 });
renderBand();
expect(
await screen.findByRole("button", { name: "1 Eintrag wartet – Jetzt senden" }),
).toHaveClass("min-h-11");
expect(screen.getByRole("button", { name: "1 Eintrag abgelehnt – ansehen" })).toHaveClass(
"min-h-11",
);
});
it("opens the queue sheet from the rejected entries", async () => {
setOnLine(true);
vi.mocked(summarizeQueue).mockResolvedValue({ own: 0, failed: 2, foreign: 0 });
renderBand();
await userEvent.click(
await screen.findByRole("button", { name: "2 Einträge abgelehnt – ansehen" }),
);
expect(await screen.findByRole("dialog", { name: "Wartende Einträge" })).toBeInTheDocument();
expect(listQueue).toHaveBeenCalledWith("user-1");
});
it("closes the sheet once nothing is left and keeps focus in the band while it has content", async () => {
setOnLine(false);
vi.mocked(summarizeQueue).mockResolvedValue({ own: 0, failed: 1, foreign: 0 });
vi.mocked(listQueue).mockResolvedValue([REJECTED]);
renderBand();
await discardRejectedEntry({ own: 0, failed: 0, foreign: 0 });
await waitFor(() =>
expect(screen.queryByRole("dialog", { name: "Wartende Einträge" })).not.toBeInTheDocument(),
);
// Offline, the band still says so - focus lands on that message.
await waitFor(() => expect(screen.getByRole("status")).toHaveFocus());
});
it("moves focus to the page content when the band has nothing left to show", async () => {
setOnLine(true);
vi.mocked(summarizeQueue).mockResolvedValue({ own: 0, failed: 1, foreign: 0 });
vi.mocked(listQueue).mockResolvedValue([REJECTED]);
renderBand();
await discardRejectedEntry({ own: 0, failed: 0, foreign: 0 });
await waitFor(() =>
expect(screen.queryByRole("dialog", { name: "Wartende Einträge" })).not.toBeInTheDocument(),
);
await waitFor(() => expect(screen.getByTestId("page-content")).toHaveFocus());
expect(screen.getByRole("status")).toBeEmptyDOMElement();
});
it("keeps the sheet open with the waiting entries after the last rejected one is discarded", async () => {
setOnLine(true);
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 1, foreign: 0 });
vi.mocked(listQueue).mockResolvedValue([REJECTED, WAITING]);
renderBand();
const sheet = await discardRejectedEntry({ own: 1, failed: 0, foreign: 0 });
await waitFor(() =>
expect(screen.queryByRole("button", { name: /abgelehnt – ansehen/ })).not.toBeInTheDocument(),
);
expect(within(sheet).getByText("Fütterung")).toBeInTheDocument();
expect(screen.getByRole("dialog", { name: "Wartende Einträge" })).toBeInTheDocument();
await userEvent.click(within(sheet).getByRole("button", { name: "Schließen" }));
await waitFor(() =>
expect(screen.queryByRole("dialog", { name: "Wartende Einträge" })).not.toBeInTheDocument(),
);
// The trigger is gone; focus goes to the band, which still shows the
// waiting entry (Base UI picks its first focusable element, the send button).
await waitFor(() =>
expect(screen.getByRole("status")).toContainElement(document.activeElement as HTMLElement),
);
expect(screen.getByRole("button", { name: "1 Eintrag wartet – Jetzt senden" })).toHaveFocus();
});
it("opens sync for the signed-in user before its first automatic sync", async () => {
setOnLine(true);
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 });
renderBand();
await waitFor(() => expect(syncPendingEntries).toHaveBeenCalledWith("user-1"));
expect(resumeSync).toHaveBeenCalledTimes(1);
expect(vi.mocked(resumeSync).mock.invocationCallOrder[0]).toBeLessThan(
vi.mocked(syncPendingEntries).mock.invocationCallOrder[0],
);
});
it("sends by itself as soon as the connection is back", async () => {
setOnLine(false);
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 });
renderBand();
await screen.findByText("Offline – 1 Eintrag wartet");
expect(syncPendingEntries).not.toHaveBeenCalled();
setOnLine(true);
act(() => {
window.dispatchEvent(new Event("online"));
});
await waitFor(() => expect(syncPendingEntries).toHaveBeenCalledWith("user-1"));
});
});
@@ -0,0 +1,171 @@
"use client";
import { RefObject, useEffect, useRef, useState } from "react";
import { AlertTriangle, CloudOff, Send } from "lucide-react";
import { toast } from "sonner";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { SheetTrigger } from "@/components/ui/sheet";
import { cn } from "@/lib/utils";
import { useConnectivity } from "@/lib/offline/connectivity";
import { QueueSummary, summarizeQueue } from "@/lib/offline/db";
import { entriesRejected, entriesSynced, entriesWaiting } from "@/lib/offline/queue-texts";
import { resumeSync, syncPendingEntries } from "@/lib/offline/sync";
import { OFFLINE_QUEUE_QUERY_KEY } from "@/lib/offline/use-capture-mutation";
import { PendingQueueSheet } from "./pending-queue-sheet";
const COUNT_INTERVAL_MS = 15_000;
const EMPTY_QUEUE: QueueSummary = { own: 0, failed: 0, foreign: 0 };
// Both auto-sync triggers can fire together; they share one sync run
// (syncPendingEntries), and one toast id keeps it to one message.
const SYNC_TOAST_ID = "offline-sync";
// Wraps on narrow phones instead of overflowing; never below 44 px.
const BAND_BUTTON_CLASS = "h-auto min-h-11 whitespace-normal text-left";
interface OfflineBandProps {
userId: string;
/** Focus target when the queue sheet closes and the band shows nothing. */
pageContentRef: RefObject<HTMLElement | null>;
}
/**
* One line under the header that says whether input is only stored on this
* device, how much still waits to be sent and what the server rejected.
* Sends by itself as soon as the connection is back.
*/
export function OfflineBand({ userId, pageContentRef }: OfflineBandProps) {
const queryClient = useQueryClient();
const connectivity = useConnectivity();
const statusRef = useRef<HTMLDivElement>(null);
const [sheetOpen, setSheetOpen] = useState(false);
const { data: queue = EMPTY_QUEUE } = useQuery({
queryKey: [...OFFLINE_QUEUE_QUERY_KEY, userId, "summary"],
queryFn: () => summarizeQueue(userId),
refetchInterval: COUNT_INTERVAL_MS,
// Reads this device's storage, never the network.
networkMode: "always",
});
const sync = useMutation({
mutationFn: () => syncPendingEntries(userId),
networkMode: "always",
onSuccess: (result) => {
if (result.synced > 0) {
toast.success(entriesSynced(result.synced), { id: SYNC_TOAST_ID });
// New records on the server: every list and count may be stale now.
void queryClient.invalidateQueries();
return;
}
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
},
onError: () =>
toast.error("Synchronisierung fehlgeschlagen - wird beim nächsten Versuch erneut versucht", {
id: SYNC_TOAST_ID,
}),
});
const { mutate: startSync } = sync;
// The band only exists for a signed-in user, and the auth context sets that
// user's access token before the user: the moment to reopen sync after a
// logout's cancelSync(). Declared first, so it runs before the sync below.
useEffect(() => {
resumeSync();
}, [userId]);
// Mount, and every switch back to online (a request reached the server again).
useEffect(() => {
if (connectivity === "online") {
startSync();
}
}, [connectivity, startSync, userId]);
// The browser's own `online` event: try even while the last request failed.
useEffect(() => {
const handleOnline = () => startSync();
window.addEventListener("online", handleOnline);
return () => window.removeEventListener("online", handleOnline);
}, [startSync]);
// The sheet stays open while the user still has entries in it (waiting
// ones too, after the last rejected one was discarded), and closes once
// there is nothing left to show.
const hasRejected = queue.failed > 0;
if (sheetOpen && queue.own === 0 && !hasRejected) {
setSheetOpen(false);
}
const isOffline = connectivity === "offline";
const hasContent = isOffline || queue.own > 0 || hasRejected;
// Back to the trigger while it exists; otherwise to the band's message, or
// to the page content when the band shows nothing.
function sheetFinalFocus(): true | HTMLElement | null {
if (hasRejected) {
return true;
}
return hasContent ? statusRef.current : pageContentRef.current;
}
// The live region is always there, so its first message is announced too.
return (
<div
ref={statusRef}
role="status"
aria-live="polite"
tabIndex={-1}
className={cn(
"outline-none focus-visible:ring-3 focus-visible:ring-ring/50",
hasContent &&
"flex flex-wrap items-center gap-x-3 gap-y-2 border-b border-border/60 px-4 py-2 text-sm sm:px-6",
hasContent && (isOffline ? "bg-warning/15" : "bg-muted/60"),
)}
>
{isOffline ? (
<p className="flex items-center gap-2 font-medium">
<CloudOff className="size-4 shrink-0" aria-hidden />
{queue.own > 0
? `Offline – ${entriesWaiting(queue.own)}`
: "Offline – Eingaben werden gespeichert und später gesendet"}
</p>
) : null}
{!isOffline && queue.own > 0 ? (
<Button
type="button"
variant="outline"
size="touch"
className={BAND_BUTTON_CLASS}
disabled={sync.isPending}
onClick={() => startSync()}
>
<Send aria-hidden />
{sync.isPending ? "Wird gesendet…" : `${entriesWaiting(queue.own)} – Jetzt senden`}
</Button>
) : null}
<PendingQueueSheet
userId={userId}
foreignCount={queue.foreign}
open={sheetOpen}
onOpenChange={setSheetOpen}
finalFocus={sheetFinalFocus}
trigger={
hasRejected ? (
<SheetTrigger
render={
<Button
type="button"
variant="destructive"
size="touch"
className={BAND_BUTTON_CLASS}
/>
}
>
<AlertTriangle aria-hidden />
{`${entriesRejected(queue.failed)} – ansehen`}
</SheetTrigger>
) : null
}
/>
</div>
);
}
@@ -0,0 +1,226 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { discardQueueItem, listQueue, type QueueItem, type StoredPhoto } from "@/lib/offline/db";
import { syncPendingEntries } from "@/lib/offline/sync";
import { listPendingPhotos, removePhoto, resetPhotoError } from "@/lib/photos/store";
import { PendingQueueSheet } from "./pending-queue-sheet";
vi.mock("@/lib/offline/db", () => ({
listQueue: vi.fn(),
discardQueueItem: vi.fn(),
}));
vi.mock("@/lib/offline/sync", () => ({ syncPendingEntries: vi.fn() }));
vi.mock("@/lib/photos/store", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/photos/store")>()),
listPendingPhotos: vi.fn(),
removePhoto: vi.fn(),
resetPhotoError: vi.fn(),
}));
const REJECTED_PHOTO: StoredPhoto = {
id: "photo-1",
sessionId: "session-1",
ownerUserId: "user-1",
blob: new Blob(["jpeg"], { type: "image/jpeg" }),
fileName: "durchsicht-2026-10-13-1.jpg",
createdAt: "2026-10-13T08:16:00.000Z",
state: "ready",
inspectionId: "srv-1",
uploadIdempotencyKey: "upload-key-0000000001",
lastError: { status: 413, message: "Datei zu groß", at: "2026-10-13T09:00:00.000Z" },
};
const WAITING_PHOTO: StoredPhoto = {
...REJECTED_PHOTO,
id: "photo-2",
createdAt: "2026-10-13T08:17:00.000Z",
lastError: undefined,
};
const REJECTED: QueueItem = {
store: "pending-inspections",
id: 7,
label: "Durchsicht",
queuedAt: "2026-10-13T08:15:00.000Z",
lastError: { status: 400, message: "Volk V-03 ist beendet.", at: "2026-10-13T09:00:00.000Z" },
};
const WAITING: QueueItem = {
store: "pending-feedings",
id: 3,
label: "Fütterung",
queuedAt: "2026-10-13T08:20:00.000Z",
};
/** How the sheet names an entry's capture time (local time zone, with seconds). */
function shown(item: QueueItem): string {
return new Date(item.queuedAt).toLocaleString("de-DE", {
dateStyle: "short",
timeStyle: "medium",
});
}
function renderSheet(foreignCount = 0) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<PendingQueueSheet
userId="user-1"
foreignCount={foreignCount}
open
onOpenChange={vi.fn()}
trigger={null}
/>
</QueryClientProvider>,
);
}
describe("PendingQueueSheet", () => {
beforeAll(() => {
// jsdom has no object URLs; the photo thumbnails only need a string.
URL.createObjectURL = vi.fn(() => "blob:photo");
URL.revokeObjectURL = vi.fn();
});
beforeEach(() => {
vi.mocked(listQueue).mockReset().mockResolvedValue([REJECTED, WAITING]);
vi.mocked(discardQueueItem).mockReset().mockResolvedValue(undefined);
vi.mocked(listPendingPhotos).mockReset().mockResolvedValue([]);
vi.mocked(removePhoto).mockReset().mockResolvedValue(undefined);
vi.mocked(resetPhotoError).mockReset().mockResolvedValue(undefined);
vi.mocked(syncPendingEntries)
.mockReset()
.mockResolvedValue({ synced: 0, failed: 0, skippedForeign: 0 });
});
it("shows „+ 2 Fotos“ at a queued inspection and discards them with it", async () => {
vi.mocked(listQueue).mockResolvedValue([{ ...REJECTED, photoCount: 2 }]);
renderSheet();
const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" });
expect(await within(sheet).findByText("+ 2 Fotos")).toBeInTheDocument();
await userEvent.click(
within(sheet).getByRole("button", { name: `Verwerfen: Durchsicht vom ${shown(REJECTED)}` }),
);
const confirm = await screen.findByRole("dialog", {
name: `Durchsicht vom ${shown(REJECTED)} verwerfen?`,
});
expect(confirm).toHaveTextContent("Der Eintrag und seine 2 Fotos werden von diesem Gerät gelöscht");
});
it("lets a rejected photo be sent again", async () => {
vi.mocked(listQueue).mockResolvedValue([]);
vi.mocked(listPendingPhotos).mockResolvedValue([REJECTED_PHOTO, WAITING_PHOTO]);
renderSheet();
const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" });
expect(await within(sheet).findByText("Abgelehnt: Datei zu groß")).toBeInTheDocument();
expect(within(sheet).getAllByText("Foto zur Durchsicht")).toHaveLength(2);
expect(listPendingPhotos).toHaveBeenCalledWith("user-1");
// Only the rejected photo needs the user; the waiting one is sent by itself.
const retry = within(sheet).getAllByRole("button", { name: /^Erneut senden: / });
expect(retry).toHaveLength(1);
expect(retry[0]).toHaveAccessibleName(
`Erneut senden: Foto vom ${shown({ ...REJECTED, queuedAt: REJECTED_PHOTO.createdAt })}`,
);
await userEvent.click(retry[0]);
await waitFor(() => expect(syncPendingEntries).toHaveBeenCalledWith("user-1"));
expect(resetPhotoError).toHaveBeenCalledWith("photo-1");
expect(vi.mocked(resetPhotoError).mock.invocationCallOrder[0]).toBeLessThan(
vi.mocked(syncPendingEntries).mock.invocationCallOrder[0],
);
});
it("discards a photo after confirmation", async () => {
vi.mocked(listQueue).mockResolvedValue([]);
vi.mocked(listPendingPhotos).mockResolvedValue([REJECTED_PHOTO]);
renderSheet();
const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" });
const name = `Foto vom ${shown({ ...REJECTED, queuedAt: REJECTED_PHOTO.createdAt })}`;
await userEvent.click(await within(sheet).findByRole("button", { name: `Verwerfen: ${name}` }));
const confirm = await screen.findByRole("dialog", { name: `${name} verwerfen?` });
await userEvent.click(within(confirm).getByRole("button", { name: "Verwerfen" }));
await waitFor(() => expect(removePhoto).toHaveBeenCalledWith("photo-1"));
expect(within(sheet).queryByText("Von dir wartet nichts mehr.")).not.toBeInTheDocument();
});
it("lists a rejected entry with the server message and can discard it after confirmation", async () => {
renderSheet();
const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" });
expect(await within(sheet).findByText(/Volk V-03 ist beendet\./)).toBeInTheDocument();
expect(within(sheet).getByText(/Abgelehnt/)).toBeInTheDocument();
expect(within(sheet).getByText("Fütterung")).toBeInTheDocument();
expect(listQueue).toHaveBeenCalledWith("user-1");
const discardRejected = within(sheet).getByRole("button", {
name: `Verwerfen: Durchsicht vom ${shown(REJECTED)}`,
});
await userEvent.click(discardRejected);
expect(discardQueueItem).not.toHaveBeenCalled();
const confirm = await screen.findByRole("dialog", {
name: `Durchsicht vom ${shown(REJECTED)} verwerfen?`,
});
await userEvent.click(within(confirm).getByRole("button", { name: "Verwerfen" }));
await waitFor(() => expect(discardQueueItem).toHaveBeenCalledWith(REJECTED));
});
it("names every discard button after its entry, starting with the visible word", async () => {
renderSheet();
const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" });
const buttons = await within(sheet).findAllByRole("button", { name: /^Verwerfen: / });
expect(buttons.map((button) => button.getAttribute("aria-label"))).toEqual([
`Verwerfen: Durchsicht vom ${shown(REJECTED)}`,
`Verwerfen: Fütterung vom ${shown(WAITING)}`,
]);
buttons.forEach((button) => expect(button).toHaveTextContent(/^Verwerfen$/));
});
it("tells apart two entries of the same type from the same minute", async () => {
const at = (iso: string, id: number): QueueItem => ({
store: "pending-inspections",
id,
label: "Durchsicht",
queuedAt: iso,
});
vi.mocked(listQueue).mockResolvedValue([
at("2026-10-13T08:15:05.000Z", 1),
at("2026-10-13T08:15:40.000Z", 2),
at("2026-10-13T08:15:40.000Z", 3),
]);
renderSheet();
const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" });
const names = (await within(sheet).findAllByRole("button", { name: /^Verwerfen: / })).map(
(button) => button.getAttribute("aria-label"),
);
expect(new Set(names).size).toBe(3);
expect(names[2]).toMatch(/\(2\)$/);
await userEvent.click(within(sheet).getAllByRole("button", { name: /^Verwerfen: / })[2]);
expect(
await screen.findByRole("dialog", { name: `${names[2]?.replace("Verwerfen: ", "")} verwerfen?` }),
).toBeInTheDocument();
});
it.each([
[1, "1 Eintrag eines anderen Kontos wartet"],
[2, "2 Einträge eines anderen Kontos warten"],
])("shows %i entries of another account only as a count", async (count, text) => {
vi.mocked(listQueue).mockResolvedValue([]);
renderSheet(count);
const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" });
expect(await within(sheet).findByText(text)).toBeInTheDocument();
expect(within(sheet).queryByRole("button", { name: /Verwerfen/ })).not.toBeInTheDocument();
});
});
@@ -0,0 +1,256 @@
"use client";
import type { ComponentProps, ReactNode } from "react";
import { AlertTriangle, Camera, Clock, RotateCw } from "lucide-react";
import { toast } from "sonner";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { NavSheet } from "@/components/nav/nav-sheet";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { PhotoThumbnail } from "@/components/photos/photo-capture-field";
import { Button } from "@/components/ui/button";
import { discardQueueItem, listQueue, type QueueError, type QueueItem } from "@/lib/offline/db";
import { entriesSynced, foreignEntriesWaiting, photosAttached } from "@/lib/offline/queue-texts";
import { syncPendingEntries } from "@/lib/offline/sync";
import { OFFLINE_QUEUE_QUERY_KEY } from "@/lib/offline/use-capture-mutation";
import { listPendingPhotos, removePhoto, resetPhotoError } from "@/lib/photos/store";
interface PendingQueueSheetProps {
userId: string;
/** Entries of another account on this device - shown only as a count. */
foreignCount: number;
open: boolean;
onOpenChange: (open: boolean) => void;
/** The `SheetTrigger` that opens the sheet, or null when opened elsewhere. */
trigger: ReactNode;
/** Where focus goes on close; by default back to the trigger. */
finalFocus?: ComponentProps<typeof NavSheet>["finalFocus"];
}
interface Named {
label: string;
queuedAt: string;
}
const PHOTO_LABEL = "Foto";
function queuedAtLabel(queuedAt: string): string {
return new Date(queuedAt).toLocaleString("de-DE", { dateStyle: "short", timeStyle: "short" });
}
/**
* „Durchsicht vom 13.10.26, 10:15:05" - a name per entry for its buttons and
* dialog. Seconds tell apart entries of the same minute; a running number
* „(2)" those queued in the same second.
*/
function entryNames(items: readonly Named[]): string[] {
const seen = new Map<string, number>();
return items.map((item) => {
const time = new Date(item.queuedAt).toLocaleString("de-DE", {
dateStyle: "short",
timeStyle: "medium",
});
const name = `${item.label} vom ${time}`;
const occurrence = (seen.get(name) ?? 0) + 1;
seen.set(name, occurrence);
return occurrence === 1 ? name : `${name} (${occurrence})`;
});
}
/** A queued inspection takes its photos along when it is discarded. */
function discardDescription(item: QueueItem): string {
if (!item.photoCount) {
return "Der Eintrag wird von diesem Gerät gelöscht und nicht mehr gesendet.";
}
const photos = item.photoCount === 1 ? "sein Foto" : `seine ${item.photoCount} Fotos`;
return `Der Eintrag und ${photos} werden von diesem Gerät gelöscht und nicht mehr gesendet.`;
}
/** Rejected with the server's reason, or waiting - in words, never by colour alone. */
function QueueStatus({ lastError }: { lastError?: QueueError }) {
return lastError ? (
<p className="flex items-start gap-2 text-sm text-destructive">
<AlertTriangle className="mt-0.5 size-4 shrink-0" aria-hidden />
<span>Abgelehnt: {lastError.message}</span>
</p>
) : (
<p className="flex items-start gap-2 text-sm text-muted-foreground">
<Clock className="mt-0.5 size-4 shrink-0" aria-hidden />
<span>Wartet auf Senden</span>
</p>
);
}
/**
* What this device still has to send for the signed-in user: each entry with
* its state, a rejected one with the server's reason, and a way to discard it.
* Photos whose inspection is already on the server are listed on their own;
* a rejected one can be sent again.
*/
export function PendingQueueSheet({
userId,
foreignCount,
open,
onOpenChange,
trigger,
finalFocus,
}: PendingQueueSheetProps) {
const queryClient = useQueryClient();
const { data: items = [], isSuccess } = useQuery({
queryKey: [...OFFLINE_QUEUE_QUERY_KEY, userId, "items"],
queryFn: () => listQueue(userId),
enabled: open,
// Reads this device's storage, never the network.
networkMode: "always",
});
const { data: photos = [] } = useQuery({
queryKey: [...OFFLINE_QUEUE_QUERY_KEY, userId, "photos"],
queryFn: () => listPendingPhotos(userId),
enabled: open,
networkMode: "always",
});
const discard = useMutation({
mutationFn: (item: QueueItem) => discardQueueItem(item),
networkMode: "always",
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
toast.success("Eintrag verworfen");
},
onError: () => toast.error("Eintrag konnte nicht verworfen werden."),
});
const discardPhoto = useMutation({
mutationFn: (photoId: string) => removePhoto(photoId),
networkMode: "always",
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
toast.success("Foto verworfen");
},
onError: () => toast.error("Foto konnte nicht verworfen werden."),
});
// „Erneut senden": clear the rejection, then a normal sync run sends it.
const retryPhoto = useMutation({
mutationFn: async (photoId: string) => {
await resetPhotoError(photoId);
return syncPendingEntries(userId);
},
networkMode: "always",
onSuccess: (result) => {
if (result.synced > 0) {
toast.success(entriesSynced(result.synced));
// New files on the server: document lists may be stale now.
void queryClient.invalidateQueries();
return;
}
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
},
onError: () => toast.error("Foto konnte nicht erneut gesendet werden."),
});
const names = entryNames(items);
const photoNames = entryNames(
photos.map((photo) => ({ label: PHOTO_LABEL, queuedAt: photo.createdAt })),
);
return (
<NavSheet
open={open}
onOpenChange={onOpenChange}
trigger={trigger}
title="Wartende Einträge"
finalFocus={finalFocus}
>
<div className="flex flex-col gap-3 px-4 pb-4">
{isSuccess && items.length === 0 && photos.length === 0 ? (
<p className="text-sm text-muted-foreground">Von dir wartet nichts mehr.</p>
) : null}
<ul className="flex flex-col gap-2">
{items.map((item, index) => (
<li
key={`${item.store}-${item.id}`}
className="flex flex-col gap-2 rounded-xl border border-border/60 p-3"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="font-medium">{item.label}</p>
<p className="text-sm text-muted-foreground">
Erfasst am {queuedAtLabel(item.queuedAt)}
</p>
{item.photoCount ? (
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Camera className="size-4 shrink-0" aria-hidden />
{photosAttached(item.photoCount)}
</p>
) : null}
</div>
<ConfirmDialog
triggerLabel="Verwerfen"
triggerAriaLabel={`Verwerfen: ${names[index]}`}
triggerVariant="ghost"
triggerSize="touch"
title={`${names[index]} verwerfen?`}
description={discardDescription(item)}
confirmLabel="Verwerfen"
onConfirm={() => discard.mutate(item)}
isPending={discard.isPending}
/>
</div>
<QueueStatus lastError={item.lastError} />
</li>
))}
{photos.map((photo, index) => (
<li
key={`photo-${photo.id}`}
className="flex flex-col gap-2 rounded-xl border border-border/60 p-3"
>
<div className="flex items-start gap-3">
<PhotoThumbnail
blob={photo.blob}
alt=""
className="size-14 shrink-0 rounded-lg border border-border/60 object-cover"
/>
<div className="min-w-0">
<p className="font-medium">Foto zur Durchsicht</p>
<p className="text-sm text-muted-foreground">
Aufgenommen am {queuedAtLabel(photo.createdAt)}
</p>
</div>
</div>
<QueueStatus lastError={photo.lastError} />
<div className="flex flex-wrap gap-2">
{photo.lastError ? (
<Button
type="button"
variant="outline"
size="touch"
aria-label={`Erneut senden: ${photoNames[index]}`}
disabled={retryPhoto.isPending}
onClick={() => retryPhoto.mutate(photo.id)}
>
<RotateCw aria-hidden />
Erneut senden
</Button>
) : null}
<ConfirmDialog
triggerLabel="Verwerfen"
triggerAriaLabel={`Verwerfen: ${photoNames[index]}`}
triggerVariant="ghost"
triggerSize="touch"
title={`${photoNames[index]} verwerfen?`}
description="Das Foto wird von diesem Gerät gelöscht und nicht mehr gesendet."
confirmLabel="Verwerfen"
onConfirm={() => discardPhoto.mutate(photo.id)}
isPending={discardPhoto.isPending}
/>
</div>
</li>
))}
</ul>
{foreignCount > 0 ? (
<p className="text-sm text-muted-foreground">{foreignEntriesWaiting(foreignCount)}</p>
) : null}
</div>
</NavSheet>
);
}
@@ -0,0 +1,144 @@
import "fake-indexeddb/auto";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { addDraftPhoto, listSessionPhotos } from "@/lib/photos/store";
import { PhotoMetadataError, preparePhoto } from "@/lib/photos/reencode";
import { PhotoCaptureField } from "./photo-capture-field";
let permissions = new Set<string>();
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({ hasPermission: (permission: string) => permissions.has(permission) }),
useCurrentUserId: () => "user-1",
}));
vi.mock("@/lib/photos/reencode", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/photos/reencode")>()),
preparePhoto: vi.fn(),
}));
const CLEAN_PHOTO = new Blob(["clean jpeg"], { type: "image/jpeg" });
function cameraFile(): File {
return new File(["raw camera jpeg with gps"], "IMG_0001.jpg", { type: "image/jpeg" });
}
function cameraInput(container: HTMLElement): HTMLInputElement {
const input = container.querySelector<HTMLInputElement>('input[type="file"]');
if (!input) throw new Error("no camera input");
return input;
}
let sessionId: string;
const onChange = vi.fn();
function renderField() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<PhotoCaptureField step={7} sessionId={sessionId} date="2026-10-13" onChange={onChange} />
</QueryClientProvider>,
);
}
async function storePhotos(count: number) {
for (let index = 0; index < count; index++) {
await addDraftPhoto({ sessionId, ownerUserId: "user-1", blob: CLEAN_PHOTO, date: "2026-10-13" });
}
}
describe("PhotoCaptureField", () => {
beforeAll(() => {
// jsdom has no object URLs; the thumbnails only need a string.
URL.createObjectURL = vi.fn(() => "blob:photo");
URL.revokeObjectURL = vi.fn();
});
beforeEach(() => {
// Every test works on its own capture session.
sessionId = crypto.randomUUID();
permissions = new Set(["documents:upload"]);
vi.mocked(preparePhoto).mockReset().mockResolvedValue(CLEAN_PHOTO);
onChange.mockReset();
});
it('input accepts image/* with capture="environment"', () => {
const { container } = renderField();
const input = cameraInput(container);
expect(input).toHaveAttribute("accept", "image/*");
expect(input).toHaveAttribute("capture", "environment");
expect(screen.getByRole("button", { name: "Foto aufnehmen" })).toBeInTheDocument();
});
it("is hidden without documents:upload", () => {
permissions = new Set();
const { container } = renderField();
expect(screen.queryByRole("button", { name: "Foto aufnehmen" })).not.toBeInTheDocument();
expect(screen.queryByText("Fotos")).not.toBeInTheDocument();
expect(container.querySelector('input[type="file"]')).toBeNull();
});
it("stores a taken photo right away, re-encoded, and shows it", async () => {
const { container } = renderField();
const file = cameraFile();
await userEvent.upload(cameraInput(container), file);
expect(await screen.findByRole("img", { name: "Foto 1" })).toBeInTheDocument();
expect(preparePhoto).toHaveBeenCalledWith(file);
const [stored] = await listSessionPhotos(sessionId);
expect(stored).toMatchObject({
ownerUserId: "user-1",
state: "draft",
fileName: "durchsicht-2026-10-13-1.jpg",
});
expect(onChange).toHaveBeenCalled();
expect(screen.getByRole("status")).toHaveTextContent("Foto 1 gespeichert");
});
it("blocks a 6th photo with a message", async () => {
await storePhotos(5);
const { container } = renderField();
expect(await screen.findAllByRole("img", { name: /^Foto \d$/ })).toHaveLength(5);
const openCamera = vi.spyOn(cameraInput(container), "click");
await userEvent.click(screen.getByRole("button", { name: "Foto aufnehmen" }));
expect(openCamera).not.toHaveBeenCalled();
expect(screen.getByRole("status")).toHaveTextContent("Höchstens 5 Fotos je Durchsicht");
// Even a file that still arrives is not kept.
await userEvent.upload(cameraInput(container), cameraFile());
expect(preparePhoto).not.toHaveBeenCalled();
expect(await listSessionPhotos(sessionId)).toHaveLength(5);
});
it("removing a photo deletes it from the store", async () => {
await storePhotos(2);
renderField();
expect(await screen.findAllByRole("img", { name: /^Foto \d$/ })).toHaveLength(2);
await userEvent.click(screen.getByRole("button", { name: "Entfernen: Foto 1" }));
const confirm = await screen.findByRole("dialog", { name: "Foto 1 entfernen?" });
await userEvent.click(within(confirm).getByRole("button", { name: "Entfernen" }));
await waitFor(async () => expect(await listSessionPhotos(sessionId)).toHaveLength(1));
await waitFor(() => expect(screen.getAllByRole("img", { name: /^Foto \d$/ })).toHaveLength(1));
expect(onChange).toHaveBeenCalled();
});
it("keeps nothing when the location data could not be removed, and says so", async () => {
vi.mocked(preparePhoto).mockRejectedValue(new PhotoMetadataError());
const { container } = renderField();
await userEvent.upload(cameraInput(container), cameraFile());
await waitFor(() =>
expect(screen.getByRole("status")).toHaveTextContent("Foto wurde nicht gespeichert"),
);
expect(await listSessionPhotos(sessionId)).toEqual([]);
expect(screen.queryByRole("img")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,229 @@
"use client";
import { ChangeEvent, useCallback, useRef, useState } from "react";
import { Camera } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useAuth, useCurrentUserId } from "@/lib/auth/auth-context";
import { todayLocalIsoDate } from "@/lib/dates";
import { PhotoMetadataError, PhotoTooLargeError, preparePhoto } from "@/lib/photos/reencode";
import {
PHOTOS_PER_INSPECTION_MAX,
addDraftPhoto,
listSessionPhotos,
removePhoto,
} from "@/lib/photos/store";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { FormSection } from "@/components/form/form-section";
/** Prefix of the queries that read one form's photos from this device. */
export const PHOTO_SESSION_QUERY_KEY = ["photo-session"] as const;
const LIMIT_MESSAGE = `Höchstens ${PHOTOS_PER_INSPECTION_MAX} Fotos je Durchsicht. Entferne eines, um ein neues aufzunehmen.`;
interface Notice {
text: string;
isError: boolean;
}
const NO_NOTICE: Notice = { text: "", isError: false };
function captureError(error: unknown): Notice {
if (error instanceof PhotoTooLargeError) {
return { text: "Das Foto ist zu groß. Bitte noch einmal aufnehmen.", isError: true };
}
if (error instanceof PhotoMetadataError) {
return {
text: "Foto wurde nicht gespeichert: Die Standortdaten ließen sich nicht entfernen.",
isError: true,
};
}
return { text: "Foto konnte nicht gespeichert werden.", isError: true };
}
interface PhotoThumbnailProps {
blob: Blob;
alt: string;
className?: string;
}
/** A photo stored on this device, shown from an object URL. */
export function PhotoThumbnail({ blob, alt, className }: PhotoThumbnailProps) {
// The URL lives exactly as long as the mounted image (ref cleanup), also
// through StrictMode's unmount and remount.
const showBlob = useCallback(
(image: HTMLImageElement | null) => {
if (!image) return;
const url = URL.createObjectURL(blob);
image.src = url;
return () => URL.revokeObjectURL(url);
},
[blob],
);
// eslint-disable-next-line @next/next/no-img-element -- blob: object URL, next/image cannot handle this
return <img ref={showBlob} alt={alt} className={className} />;
}
interface PhotoCaptureFieldProps {
/** Position of the section in its form. */
step?: number;
/** The form's photo session (`photoSessionId` in its draft values). */
sessionId: string;
/** The inspection date (yyyy-mm-dd), part of each file name. */
date: string;
/** A photo was taken or removed: the form now has something to keep. */
onChange?: () => void;
}
/**
* „Foto aufnehmen" in an inspection form. Each photo is re-encoded without
* EXIF/GPS and stored on the device at once, so it is kept with the form
* draft and works without a network. Uploaded after the inspection is saved.
* Shown only to users who may upload documents.
*/
export function PhotoCaptureField(props: PhotoCaptureFieldProps) {
const { hasPermission } = useAuth();
if (!hasPermission("documents:upload")) {
return null;
}
return <PhotoCapture {...props} />;
}
function PhotoCapture({ step, sessionId, date, onChange }: PhotoCaptureFieldProps) {
const queryClient = useQueryClient();
const ownerUserId = useCurrentUserId();
const inputRef = useRef<HTMLInputElement>(null);
const [notice, setNotice] = useState<Notice>(NO_NOTICE);
const queryKey = [...PHOTO_SESSION_QUERY_KEY, sessionId];
const { data: photos = [] } = useQuery({
queryKey,
queryFn: () => listSessionPhotos(sessionId),
// Reads this device's storage, never the network.
networkMode: "always",
});
const isFull = photos.length >= PHOTOS_PER_INSPECTION_MAX;
async function showStoredPhotos(): Promise<number> {
await queryClient.invalidateQueries({ queryKey });
return queryClient.getQueryData<unknown[]>(queryKey)?.length ?? 0;
}
const add = useMutation({
networkMode: "always",
mutationFn: async (file: File) => {
// Never store a photo without an owner: it would follow the next user.
if (!ownerUserId) {
throw new Error("No signed-in user");
}
const blob = await preparePhoto(file);
return addDraftPhoto({ sessionId, ownerUserId, blob, date: date || todayLocalIsoDate() });
},
onSuccess: async () => {
onChange?.();
const count = await showStoredPhotos();
setNotice({ text: `Foto ${count} gespeichert.`, isError: false });
},
onError: (error) => setNotice(captureError(error)),
});
const remove = useMutation({
networkMode: "always",
mutationFn: (id: string) => removePhoto(id),
onSuccess: async () => {
onChange?.();
await showStoredPhotos();
setNotice({ text: "Foto entfernt.", isError: false });
},
onError: () => setNotice({ text: "Foto konnte nicht entfernt werden.", isError: true }),
});
function openCamera() {
if (isFull) {
setNotice({ text: LIMIT_MESSAGE, isError: true });
return;
}
setNotice(NO_NOTICE);
inputRef.current?.click();
}
function handleFile(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
// Cleared, so the same picture can be chosen again after removing it.
event.target.value = "";
if (!file) return;
if (isFull) {
setNotice({ text: LIMIT_MESSAGE, isError: true });
return;
}
add.mutate(file);
}
return (
<FormSection
step={step}
title="Fotos"
description={`Bis zu ${PHOTOS_PER_INSPECTION_MAX} Fotos. Standortdaten werden vor dem Speichern entfernt.`}
>
{photos.length > 0 ? (
<ul className="grid grid-cols-3 gap-3" aria-label="Fotos dieser Durchsicht">
{photos.map((photo, index) => {
const name = `Foto ${index + 1}`;
return (
<li key={photo.id} className="flex flex-col gap-1">
<PhotoThumbnail
blob={photo.blob}
alt={name}
className="aspect-square w-full rounded-lg border border-border/60 object-cover"
/>
<ConfirmDialog
triggerLabel="Entfernen"
triggerAriaLabel={`Entfernen: ${name}`}
triggerVariant="ghost"
triggerSize="touch"
title={`${name} entfernen?`}
description="Das Foto wird von diesem Gerät gelöscht."
confirmLabel="Entfernen"
onConfirm={() => remove.mutate(photo.id)}
isPending={remove.isPending}
/>
</li>
);
})}
</ul>
) : null}
<Button
type="button"
variant="outline"
size="touch"
className="w-fit"
disabled={add.isPending}
onClick={openCamera}
>
<Camera aria-hidden />
{add.isPending ? "Foto wird gespeichert…" : "Foto aufnehmen"}
</Button>
<input
ref={inputRef}
type="file"
accept="image/*"
capture="environment"
className="hidden"
tabIndex={-1}
aria-hidden
onChange={handleFile}
/>
<p
role="status"
aria-live="polite"
className={cn(
"text-sm empty:hidden",
notice.isError ? "font-medium text-destructive" : "text-muted-foreground",
)}
>
{notice.text}
</p>
</FormSection>
);
}
@@ -0,0 +1,53 @@
"use client";
import { ArrowRight, CheckCircle2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
interface RoundNextPanelProps {
/** Next colony to do, or null when the round is complete. */
next: { id: string; code: string } | null;
/** Colonies inspected in this round (for the closing message). */
doneCount: number;
/** Saved without a network: it syncs later. */
savedOffline: boolean;
onNext: () => void;
onEnd: () => void;
}
/** After a save in a round: one main step - on to the next colony. */
export function RoundNextPanel({ next, doneCount, savedOffline, onNext, onEnd }: RoundNextPanelProps) {
return (
<Card className="max-w-lg">
<CardContent className="flex flex-col gap-4">
<p role="status" className="flex items-center gap-2 text-lg font-medium">
<CheckCircle2 className="size-5 text-condition-good-foreground" aria-hidden />
Gespeichert
</p>
{savedOffline ? (
<p className="text-sm text-muted-foreground">
Kein Netzwerk – die Durchsicht wird synchronisiert, sobald du wieder Netz hast.
</p>
) : null}
{next ? (
<>
<Button type="button" size="touch" className="h-12 text-base" onClick={onNext}>
Weiter mit {next.code}
<ArrowRight aria-hidden />
</Button>
<Button type="button" size="touch" variant="outline" onClick={onEnd}>
Runde beenden
</Button>
</>
) : (
<>
<p>Runde abgeschlossen – alle {doneCount} Völker</p>
<Button type="button" size="touch" className="h-12 text-base" onClick={onEnd}>
Zum Stand
</Button>
</>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,18 @@
/** Visible note that a value is a suggestion and not something the user entered. */
export function PrefillNote({ id, source }: { id: string; source: string | undefined }) {
if (!source) return null;
return (
<p id={`${id}-prefill`} className="text-xs font-medium text-muted-foreground">
aus letzter Behandlung mit {source}
</p>
);
}
/** The control's aria-describedby, extended by the prefill note when one is shown. */
export function describedByWith(
id: string,
describedBy: string | undefined,
source: string | undefined,
): string | undefined {
return [describedBy, source ? `${id}-prefill` : undefined].filter(Boolean).join(" ") || undefined;
}
@@ -0,0 +1,107 @@
"use client";
import { useState } from "react";
import { Input } from "@/components/ui/input";
import { Field } from "@/components/form/field";
import { ChoiceGroup, ChoiceOption } from "@/components/form/choice-group";
import {
PERIOD_DAY_CHOICES,
PeriodChoice,
daysToMinutes,
formatDayCount,
parseDays,
periodChoiceOf,
} from "@/lib/varroa/period";
const PERIOD_OPTIONS: ChoiceOption<PeriodChoice>[] = [
...PERIOD_DAY_CHOICES.map((days) => ({
value: days,
label: days === 1 ? "1 Tag" : `${days} Tage`,
})),
{ value: "other" as const, label: "anders" },
];
interface VarroaPeriodFieldProps {
/** Stored minutes as text; this stays the only source of truth. */
minutes: string;
onChange: (minutes: string) => void;
hint?: string;
}
/**
* How long the board lay, picked in days. Still stores minutes: an old value
* that is not a whole choice is shown as "anders" and kept exactly until the
* user types a new number of days.
*/
export function VarroaPeriodField({ minutes, onChange, hint }: VarroaPeriodFieldProps) {
const [otherChosen, setOtherChosen] = useState(false);
const [daysText, setDaysText] = useState<string | null>(null);
const derived = periodChoiceOf(minutes);
const selected: PeriodChoice | null = otherChosen ? "other" : derived;
const storedMinutes = Number(minutes);
const showsStored = daysText === null && derived === "other" && Number.isFinite(storedMinutes);
const daysValue = daysText ?? (showsStored ? formatDayCount(storedMinutes) : "");
function choose(next: PeriodChoice | null) {
if (next === "other") {
setOtherChosen(true);
setDaysText(Number.isFinite(storedMinutes) && storedMinutes > 0 ? formatDayCount(storedMinutes) : "");
return;
}
setOtherChosen(false);
setDaysText(null);
onChange(next === null ? "" : String(daysToMinutes(next)));
}
const daysInvalid = daysValue.trim() !== "" && parseDays(daysValue) === null;
function typeDays(text: string) {
setDaysText(text);
const days = parseDays(text);
onChange(days === null ? "" : String(daysToMinutes(days)));
}
return (
<div className="flex flex-col gap-3">
<Field label="Zeitraum" group hint={hint}>
{({ id, labelId, describedBy }) => (
<ChoiceGroup
id={id}
labelledBy={labelId}
describedBy={describedBy}
options={PERIOD_OPTIONS}
value={selected}
onChange={choose}
clearValue={null}
className="grid-cols-3 sm:grid-cols-5"
/>
)}
</Field>
{selected === "other" ? (
<Field
label="Tage"
error={daysInvalid ? "Bitte eine Zahl größer als 0 eingeben, z. B. 1,5." : null}
hint={
showsStored
? `anders: ${formatDayCount(storedMinutes)} Tage (${storedMinutes} Minuten)`
: "z. B. 1,5 für anderthalb Tage"
}
>
{({ id, describedBy, invalid }) => (
<Input
id={id}
aria-describedby={describedBy}
aria-invalid={invalid}
type="text"
inputMode="decimal"
className="h-11"
value={daysValue}
onChange={(event) => typeDays(event.target.value)}
/>
)}
</Field>
) : null}
</div>
);
}
@@ -0,0 +1,55 @@
import { AlertTriangle, CheckCircle2, OctagonAlert } from "lucide-react";
import { RATING_TONE_CLASS } from "@/lib/forms/rating";
import { formatMitesPerDay, formatThreshold } from "@/lib/varroa/format";
import { VarroaLight, varroaLight } from "@/lib/varroa/rate";
import { cn } from "@/lib/utils";
const LIGHTS: Record<VarroaLight, { Icon: typeof CheckCircle2; tone: string }> = {
green: { Icon: CheckCircle2, tone: RATING_TONE_CLASS[4] },
yellow: { Icon: AlertTriangle, tone: RATING_TONE_CLASS[3] },
red: { Icon: OctagonAlert, tone: RATING_TONE_CLASS[1] },
};
function lightText(light: VarroaLight, threshold: number): string {
if (light === "red") return "über der Schwelle, Behandlung prüfen";
if (light === "yellow") return "nahe an der Schwelle";
return `unter der Schwelle (${formatThreshold(threshold)}/Tag)`;
}
/**
* The live result of the count. The state is always spelled out and has an
* icon, so it never depends on the colour alone.
*/
export function VarroaRateLight({
rate,
threshold,
}: {
rate: number | null;
threshold: number | null;
}) {
const light = varroaLight(rate, threshold);
if (rate === null) {
return <div role="status" aria-live="polite" />;
}
const base = `${formatMitesPerDay(rate)} Milben/Tag`;
if (light === null || threshold === null) {
return (
<p role="status" aria-live="polite" className="text-sm font-medium">
{base}
</p>
);
}
const { Icon, tone } = LIGHTS[light];
return (
<p
role="status"
aria-live="polite"
className={cn("flex min-h-11 items-center gap-2 rounded-lg border px-3 text-sm font-medium", tone)}
>
<Icon aria-hidden className="size-5 shrink-0" />
<span>
{base} – {lightText(light, threshold)}
</span>
</p>
);
}