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:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user