feat: platform backbone and field capture complete (checkpoint)

Platform backbone (plan 2026-10-13-plattform-rueckgrat), all tasks reviewed:
- webhooks with delivery table, retries and stable delivery id
- calendar handlers; a finished task removes its Outlook event
- tasks, automations, inspections, varroa, treatments, feedings,
  harvests, colonies, wintering, stock and bottling record domain events
  in the same transaction; legacy post-commit calls removed
- row locks through one helper (lockRowForUpdate, lockRowsForShare,
  lockRowsForUpdate)
- job framework with JobRun, cli exit codes, cleanup, task templates,
  daily evaluation, hourly app-backup and the rekey job
- system API and page "Verwaltung > System" with retry
- @nestjs/schedule removed, guard tests against in-process schedulers

Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), all tasks reviewed:
- inspection photo offline without metadata
- bulk capture for treatments, feedings and varroa (backend + frontend)
- offline stand mode with service worker, offline pages and packs
- offline session, cold start and logout on shared devices

Docs: ARCHITECTURE.md assumptions and sections updated, TASKS.md.
Gates: backend lint, typecheck, 1770 unit, 420 e2e, build, no schema
drift; frontend lint, typecheck, 913 tests, build. Not rolled out.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 11:36:38 +02:00
1 parent c01a38835a
commit cdd30cf3ee
288 files changed
+39165 -2853

No files matched your search

@@ -0,0 +1,190 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { reportRequestOutcome } from "@/lib/offline/connectivity";
import { summarizeQueue } from "@/lib/offline/db";
import { syncPendingEntries } from "@/lib/offline/sync";
import { LogoutConfirmDialog } from "./logout-confirm-dialog";
const logoutMock = vi.fn();
let sessionMode: "online" | "offline" = "online";
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({ logout: logoutMock, sessionMode }),
}));
vi.mock("@/lib/offline/db", () => ({ summarizeQueue: vi.fn() }));
vi.mock("@/lib/offline/sync", () => ({ syncPendingEntries: vi.fn() }));
const NOTHING_WAITS = { own: 0, failed: 0, foreign: 0 };
const onOpenChange = vi.fn();
const onLoggedOut = vi.fn();
function renderDialog() {
return render(
<LogoutConfirmDialog
open
onOpenChange={onOpenChange}
userId="user-1"
onLoggedOut={onLoggedOut}
/>,
);
}
describe("LogoutConfirmDialog", () => {
beforeEach(() => {
vi.clearAllMocks();
logoutMock.mockResolvedValue(undefined);
vi.mocked(summarizeQueue).mockResolvedValue(NOTHING_WAITS);
vi.mocked(syncPendingEntries).mockResolvedValue({ synced: 0, failed: 0, skippedForeign: 0 });
reportRequestOutcome("reached-server");
sessionMode = "online";
});
afterEach(() => {
reportRequestOutcome("reached-server");
vi.restoreAllMocks();
});
it("logs out directly when nothing waits", async () => {
renderDialog();
await waitFor(() => expect(logoutMock).toHaveBeenCalledWith({ discardQueue: false }));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
await waitFor(() => expect(onLoggedOut).toHaveBeenCalled());
});
it("asks before deleting unsent entries", async () => {
vi.mocked(summarizeQueue).mockResolvedValue({ own: 2, failed: 1, foreign: 5 });
renderDialog();
const dialog = await screen.findByRole("dialog", { name: "Abmelden?" });
// Waiting and rejected entries of the person count; other accounts' do not.
expect(dialog).toHaveTextContent("3 Einträge sind noch nicht gesendet.");
expect(logoutMock).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: "Erst senden" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Abmelden und Einträge löschen" })).toBeVisible();
expect(screen.getByRole("button", { name: "Abbrechen" })).toBeVisible();
});
it("deletes the entries only after the destructive button was pressed", async () => {
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 });
renderDialog();
await userEvent.click(await screen.findByRole("button", { name: "Abmelden und Einträge löschen" }));
await waitFor(() => expect(logoutMock).toHaveBeenCalledWith({ discardQueue: true }));
await waitFor(() => expect(onLoggedOut).toHaveBeenCalled());
});
it("cancel leaves everything as it is", async () => {
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 });
renderDialog();
await userEvent.click(await screen.findByRole("button", { name: "Abbrechen" }));
expect(onOpenChange).toHaveBeenCalledWith(false);
expect(logoutMock).not.toHaveBeenCalled();
expect(syncPendingEntries).not.toHaveBeenCalled();
});
it("sends first and logs out once everything is gone", async () => {
vi.mocked(summarizeQueue)
.mockResolvedValueOnce({ own: 2, failed: 0, foreign: 0 })
.mockResolvedValue(NOTHING_WAITS);
renderDialog();
await userEvent.click(await screen.findByRole("button", { name: "Erst senden" }));
await waitFor(() => expect(syncPendingEntries).toHaveBeenCalledWith("user-1"));
await waitFor(() => expect(logoutMock).toHaveBeenCalledWith({ discardQueue: false }));
});
it("stays open and says how many still wait when sending was not enough", async () => {
vi.mocked(summarizeQueue)
.mockResolvedValueOnce({ own: 2, failed: 0, foreign: 0 })
.mockResolvedValue({ own: 0, failed: 1, foreign: 0 });
renderDialog();
await userEvent.click(await screen.findByRole("button", { name: "Erst senden" }));
await waitFor(() =>
expect(screen.getByRole("dialog", { name: "Abmelden?" })).toHaveTextContent(
"1 Eintrag ist noch nicht gesendet.",
),
);
expect(logoutMock).not.toHaveBeenCalled();
});
it("offers no sending without network and warns that signing out deletes the entries", async () => {
vi.mocked(summarizeQueue).mockResolvedValue({ own: 2, failed: 0, foreign: 0 });
act(() => reportRequestOutcome("network-error"));
renderDialog();
const dialog = await screen.findByRole("dialog", { name: "Abmelden?" });
expect(screen.queryByRole("button", { name: "Erst senden" })).not.toBeInTheDocument();
expect(dialog).toHaveTextContent("Ohne Netz");
});
it("offline logout marks the logout as pending (through the auth context)", async () => {
// The pending mark is the auth context's job; the dialog only asks for the logout.
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 });
act(() => reportRequestOutcome("network-error"));
renderDialog();
await userEvent.click(await screen.findByRole("button", { name: "Abmelden und Einträge löschen" }));
await waitFor(() => expect(logoutMock).toHaveBeenCalledWith({ discardQueue: true }));
});
it("shows an error and stays open when the logout fails", async () => {
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 });
logoutMock.mockRejectedValue(new Error("boom"));
renderDialog();
await userEvent.click(await screen.findByRole("button", { name: "Abmelden und Einträge löschen" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Abmelden hat nicht geklappt");
expect(onLoggedOut).not.toHaveBeenCalled();
});
it("does nothing while it is closed", () => {
render(
<LogoutConfirmDialog
open={false}
onOpenChange={onOpenChange}
userId="user-1"
onLoggedOut={onLoggedOut}
/>,
);
expect(summarizeQueue).not.toHaveBeenCalled();
expect(logoutMock).not.toHaveBeenCalled();
});
it("offers no sending in an offline session, even when the network looks fine", async () => {
sessionMode = "offline";
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 });
renderDialog();
const dialog = await screen.findByRole("dialog", { name: "Abmelden?" });
expect(screen.queryByRole("button", { name: "Erst senden" })).not.toBeInTheDocument();
expect(dialog).toHaveTextContent("Ohne Netz");
});
it("shows the error with a retry when the direct logout of an empty queue fails", async () => {
logoutMock.mockRejectedValueOnce(new Error("boom"));
renderDialog();
const dialog = await screen.findByRole("dialog", { name: "Abmelden?" });
expect(await screen.findByRole("alert")).toHaveTextContent("Abmelden hat nicht geklappt");
expect(dialog).not.toHaveTextContent("noch nicht gesendet");
await userEvent.click(screen.getByRole("button", { name: "Erneut versuchen" }));
await waitFor(() => expect(logoutMock).toHaveBeenCalledTimes(2));
expect(logoutMock).toHaveBeenLastCalledWith({ discardQueue: false });
await waitFor(() => expect(onLoggedOut).toHaveBeenCalled());
});
});
@@ -0,0 +1,170 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useAuth } from "@/lib/auth/auth-context";
import { useConnectivity } from "@/lib/offline/connectivity";
import { summarizeQueue } from "@/lib/offline/db";
import { entriesNotSent } from "@/lib/offline/queue-texts";
import { syncPendingEntries } from "@/lib/offline/sync";
interface LogoutConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
userId: string;
/** The person is signed out: the caller moves on to the login page. */
onLoggedOut: () => void;
}
/** Waiting and rejected entries of the person; owner-less legacy ones count as theirs. */
async function countUnsent(userId: string): Promise<number> {
try {
const { own, failed } = await summarizeQueue(userId);
return own + failed;
} catch {
// The queue cannot be read, so it cannot be lost either.
return 0;
}
}
/**
* Signing out deletes the person's unsent entries: on a shared phone they must
* not be sent under the next account. So with entries waiting it asks first
* (send them, delete them, or stay); with nothing waiting it signs out at once.
* Renders nothing while closed.
*/
export function LogoutConfirmDialog({
open,
onOpenChange,
userId,
onLoggedOut,
}: LogoutConfirmDialogProps) {
const { logout, sessionMode } = useAuth();
const connectivity = useConnectivity();
const [unsent, setUnsent] = useState<number | null>(null);
const [isBusy, setIsBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [wasOpen, setWasOpen] = useState(open);
// Closed: forget what the last opening found (adjusted while rendering).
if (wasOpen !== open) {
setWasOpen(open);
if (!open) {
setUnsent(null);
setError(null);
}
}
async function signOut(discardQueue: boolean) {
setIsBusy(true);
setError(null);
try {
await logout({ discardQueue });
onOpenChange(false);
onLoggedOut();
} catch {
setError("Abmelden hat nicht geklappt. Bitte noch einmal versuchen.");
setIsBusy(false);
}
}
useEffect(() => {
if (!open) {
return;
}
let isCurrent = true;
void countUnsent(userId).then((count) => {
if (!isCurrent) {
return;
}
if (count === 0) {
void signOut(false);
} else {
setUnsent(count);
}
});
return () => {
isCurrent = false;
};
// signOut only reads props that are part of this effect's trigger.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, userId]);
async function sendFirst() {
setIsBusy(true);
setError(null);
try {
await syncPendingEntries(userId);
} catch {
// Whatever could not be sent is counted below.
}
const remaining = await countUnsent(userId);
if (remaining === 0) {
await signOut(false);
return;
}
setUnsent(remaining);
setIsBusy(false);
}
// An offline session has no token: nothing can be sent, whatever the network says.
const isOnline = connectivity === "online" && sessionMode !== "offline";
const hasEntries = unsent !== null && unsent > 0;
return (
<Dialog open={open && (hasEntries || error !== null)} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Abmelden?</DialogTitle>
<DialogDescription>
{hasEntries
? `${entriesNotSent(unsent)} ${
isOnline
? "Sende sie zuerst, oder melde dich ab und lösche sie."
: "Ohne Netz kannst du sie nicht senden. Beim Abmelden werden sie gelöscht."
}`
: "Du bist noch angemeldet."}
</DialogDescription>
</DialogHeader>
{error ? (
<p role="alert" className="text-sm font-medium text-destructive">
{error}
</p>
) : null}
<DialogFooter>
<Button
type="button"
variant="outline"
size="touch"
disabled={isBusy}
onClick={() => onOpenChange(false)}
>
Abbrechen
</Button>
{hasEntries && isOnline ? (
<Button type="button" size="touch" disabled={isBusy} onClick={() => void sendFirst()}>
Erst senden
</Button>
) : null}
<Button
type="button"
variant="destructive"
size="touch"
disabled={isBusy}
onClick={() => void signOut(hasEntries)}
>
{hasEntries ? "Abmelden und Einträge löschen" : "Erneut versuchen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -6,8 +6,16 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ColonySelectField, LivingColonySelectField } from "./colony-select-field";
import { fetchColonies } from "@/lib/colonies/api";
import type { ColonyStatus } from "@/lib/colonies/types";
import { ApiError } from "@/lib/auth/api-client";
import { listPacks } from "@/lib/apiaries/offline-pack";
import { offlinePack, packColony, storedPack } from "@/lib/apiaries/offline-pack.fixtures";
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() }));
vi.mock("@/lib/auth/auth-context", () => ({ useCurrentUserId: () => "user-1" }));
vi.mock("@/lib/apiaries/offline-pack", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/apiaries/offline-pack")>()),
listPacks: vi.fn(),
}));
function colony(id: string, code: string, status: ColonyStatus) {
return { id, code, status, apiaryId: "apiary-1" };
@@ -44,9 +52,73 @@ async function openOptions() {
return screen.findAllByRole("option");
}
async function openOptionsOf(index: number) {
await userEvent.click(screen.getAllByRole("combobox")[index]);
return screen.findAllByRole("option");
}
describe("LivingColonySelectField", () => {
beforeEach(() => {
vi.mocked(fetchColonies).mockResolvedValue(COLONIES as never);
vi.mocked(listPacks).mockReset().mockResolvedValue([]);
});
it("offers the pack's living colonies while offline", async () => {
vi.mocked(fetchColonies).mockRejectedValue(new TypeError("Failed to fetch"));
vi.mocked(listPacks).mockResolvedValue([
storedPack(
offlinePack({
apiaryId: "apiary-1",
colonies: [packColony("p-2", "B2"), packColony("p-1", "B1", { status: "WEAK" })],
}),
),
storedPack(
offlinePack({
apiaryId: "apiary-2",
colonies: [packColony("p-3", "C1", { status: "DISSOLVED" }), packColony("p-4", "C2")],
}),
),
]);
withClient(<LivingHarness />);
expect(
await screen.findByText("Offline: nur Völker der geladenen Stände."),
).toBeInTheDocument();
const options = await openOptions();
expect(options.map((option) => option.textContent)).toEqual(["B1 (Schwach)", "B2", "C2"]);
expect(listPacks).toHaveBeenCalledWith("user-1");
});
it("tells how to work offline when no apiary was loaded for offline use", async () => {
vi.mocked(fetchColonies).mockRejectedValue(new TypeError("Failed to fetch"));
withClient(<LivingHarness />);
expect(
await screen.findByText("Kein Netz. Lade den Stand vorher mit „Für offline laden“."),
).toBeInTheDocument();
});
it("shares the one colony request with every other colony list on the page", async () => {
vi.mocked(fetchColonies).mockClear();
withClient(
<>
<LivingHarness />
<AllHarness initial="" />
</>,
);
await waitFor(() => expect(screen.getAllByRole("combobox")[0]).toHaveTextContent("Volk wählen"));
await openOptionsOf(0);
expect(fetchColonies).toHaveBeenCalledTimes(1);
expect(listPacks).not.toHaveBeenCalled();
});
it("never offers pack colonies when the server refused the list", async () => {
vi.mocked(fetchColonies).mockRejectedValue(new ApiError(403, "Forbidden"));
withClient(<LivingHarness />);
expect(await screen.findByText(/Völker konnten nicht geladen werden/)).toBeInTheDocument();
expect(listPacks).not.toHaveBeenCalled();
});
it("offers weak, queenless and conspicuous colonies - not only active ones", async () => {
@@ -5,25 +5,89 @@ import { fetchColonies } from "@/lib/colonies/api";
import { colonyOptionLabel } from "@/lib/colonies/labels";
import { isLivingColony } from "@/lib/colonies/status";
import type { Colony } from "@/lib/colonies/types";
import { useCurrentUserId } from "@/lib/auth/auth-context";
import {
OFFLINE_PACKS_QUERY_KEY,
listPacks,
livingColoniesFromPacks,
} from "@/lib/apiaries/offline-pack";
import { isNetworkError } from "@/lib/http/fetch-with-timeout";
import { Field } from "@/components/form/field";
import { RecordSelect } from "@/components/form/record-select";
const PLACEHOLDER = "Volk wählen";
const LOADING_PLACEHOLDER = "Völker werden geladen…";
const LOAD_ERROR_HINT = "Völker konnten nicht geladen werden – bitte die Seite neu laden.";
const OFFLINE_PACK_HINT = "Offline: nur Völker der geladenen Stände.";
const OFFLINE_NOT_LOADED_HINT = "Kein Netz. Lade den Stand vorher mit „Für offline laden“.";
/** What a colony choice needs - a server colony or one from an offline pack. */
type ColonyChoice = Pick<Colony, "id" | "code" | "status" | "apiaryId">;
function useColonies() {
return useQuery({ queryKey: ["colonies"], queryFn: () => fetchColonies() });
}
type ChoicesHint = "load-error" | "offline-not-loaded" | "from-packs";
const HINT_TEXTS: Record<ChoicesHint, string> = {
"load-error": LOAD_ERROR_HINT,
"offline-not-loaded": OFFLINE_NOT_LOADED_HINT,
"from-packs": OFFLINE_PACK_HINT,
};
interface LivingColonyChoices {
colonies: readonly ColonyChoice[] | undefined;
isLoading: boolean;
hint: ChoicesHint | null;
}
/**
* Every colony from the shared ["colonies"] query - one request for every
* colony list on the page. Only when that failed for lack of network (never
* on a server refusal) the living colonies of the user's offline packs step
* in, from their own query: pack colonies are partial records and never go
* into the shared cache.
*/
function useLivingColonyChoices(): LivingColonyChoices {
const userId = useCurrentUserId();
const server = useColonies();
// failureReason: offline the retry pauses, so `error` stays unset.
const isOffline =
server.data === undefined && isNetworkError(server.error ?? server.failureReason);
const packs = useQuery({
queryKey: [...OFFLINE_PACKS_QUERY_KEY, userId, "living-colonies"],
queryFn: async () => livingColoniesFromPacks(await listPacks(userId ?? "")),
enabled: isOffline && userId !== null,
// Reads this device's storage, never the network.
networkMode: "always",
});
if (!isOffline) {
return {
colonies: server.data,
isLoading: server.isLoading,
hint: server.isError && server.data === undefined ? "load-error" : null,
};
}
const packColonies = packs.data && packs.data.length > 0 ? packs.data : undefined;
let hint: ChoicesHint | null = "offline-not-loaded";
if (packColonies) {
hint = "from-packs";
} else if (packs.isLoading) {
hint = null;
}
return { colonies: packColonies, isLoading: packs.isLoading, hint };
}
interface ColonyChoicesProps {
id: string;
value: string;
onChange: (colonyId: string) => void;
/** The colonies on offer; `undefined` while loading. */
options: readonly Colony[] | undefined;
options: readonly ColonyChoice[] | undefined;
/** Every known colony, so a chosen one is named even if it is not offered. */
colonies: readonly Colony[] | undefined;
colonies: readonly ColonyChoice[] | undefined;
isLoading: boolean;
dataField?: string;
describedBy?: string;
@@ -65,6 +129,9 @@ interface LivingColonySelectFieldProps {
* Weak, conspicuous and queenless colonies are included - they need the most
* attention - and none that has ended. A colony handed in by a link that has
* ended since is still named, just not offered.
*
* Without network it offers the living colonies of the apiaries loaded for
* offline use ("Für offline laden") and says so.
*/
export function LivingColonySelectField({
triggerId,
@@ -76,18 +143,25 @@ export function LivingColonySelectField({
emptyHint = "Noch keine aktiven Völker – zuerst unter „Völker“ eines anlegen.",
dataField = "colonyId",
}: LivingColonySelectFieldProps) {
const { data: colonies, isLoading, isError } = useColonies();
const { colonies, isLoading, hint: choicesHint } = useLivingColonyChoices();
const living = colonies?.filter(
(colony) => isLivingColony(colony) && colony.id !== excludeId,
);
function hint(): string | undefined {
if (choicesHint) {
return HINT_TEXTS[choicesHint];
}
return living?.length === 0 ? emptyHint : undefined;
}
return (
<Field
id={triggerId}
label={label}
required
error={error}
hint={isError ? LOAD_ERROR_HINT : living?.length === 0 ? emptyHint : undefined}
hint={hint()}
>
{({ id, describedBy, invalid }) => (
<ColonyChoices
@@ -0,0 +1,69 @@
"use client";
import { Button } from "@/components/ui/button";
import type { BulkColony } from "@/lib/bulk/use-apiary-colonies";
interface BulkSubmitNoteProps {
submitError: string | null;
isRetryLater: boolean;
/** Colonies the server refused, by id. */
invalidColonyIds: readonly string[];
/** The colonies on offer, to name the refused ones by code. */
colonies: readonly Pick<BulkColony, "id" | "code">[] | undefined;
isPending: boolean;
/** Takes the refused colonies out of the selection and saves again. */
onResendWithout: () => void;
}
function codesOf(
ids: readonly string[],
colonies: BulkSubmitNoteProps["colonies"],
): string[] {
return ids.flatMap((id) => colonies?.find((colony) => colony.id === id)?.code ?? []);
}
/**
* What stands above the save button of a bulk capture. A refusal names the
* colonies by code and offers „Ohne … senden“; „wird bereits verarbeitet“ is
* only a request to wait. Null when there is nothing to say.
*/
export function BulkSubmitNote({
submitError,
isRetryLater,
invalidColonyIds,
colonies,
isPending,
onResendWithout,
}: BulkSubmitNoteProps) {
if (isRetryLater) {
return (
<p role="status" className="text-sm font-medium">
Wird gerade verarbeitet. Bitte gleich noch einmal speichern.
</p>
);
}
if (!submitError) {
return null;
}
const codes = codesOf(invalidColonyIds, colonies);
const hasRefused = invalidColonyIds.length > 0;
return (
<>
<p role="alert" className="text-sm font-medium text-destructive">
{submitError}
{codes.length > 0 ? `: ${codes.join(", ")}` : ""}
</p>
{hasRefused ? (
<Button
type="button"
variant="outline"
size="touch"
disabled={isPending}
onClick={onResendWithout}
>
{codes.length > 0 ? `Ohne ${codes.join(", ")} senden` : "Ohne diese Völker senden"}
</Button>
) : null}
</>
);
}
@@ -0,0 +1,61 @@
"use client";
import { LivingColonySelectField } from "@/components/colony-select-field";
import { CaptureTarget, CaptureTargetToggle } from "@/components/form/capture-target-toggle";
import { ColonyMultiSelect } from "@/components/form/colony-multi-select";
interface CaptureColonyFieldProps {
/** Prefix of the element ids, e.g. "treatment". */
idPrefix: string;
target: CaptureTarget;
onTargetChange: (target: CaptureTarget) => void;
colonyId: string;
onColonyChange: (colonyId: string) => void;
colonyError?: string | null;
bulkApiaryId: string | null;
onBulkApiaryChange: (apiaryId: string | null) => void;
bulkColonyIds: readonly string[];
onBulkColonyIdsChange: (ids: string[]) => void;
bulkError?: string | null;
}
/**
* The „Für“ choice of a capture form and what it needs: the single colony
* select, or the stand with its colonies for the same entry on several.
*/
export function CaptureColonyField({
idPrefix,
target,
onTargetChange,
colonyId,
onColonyChange,
colonyError,
bulkApiaryId,
onBulkApiaryChange,
bulkColonyIds,
onBulkColonyIdsChange,
bulkError,
}: CaptureColonyFieldProps) {
return (
<>
<CaptureTargetToggle value={target} onChange={onTargetChange} />
{target === "single" ? (
<LivingColonySelectField
triggerId={`${idPrefix}-colony`}
value={colonyId}
onChange={onColonyChange}
error={colonyError}
/>
) : (
<ColonyMultiSelect
id={`${idPrefix}-colonies`}
apiaryId={bulkApiaryId}
onApiaryChange={onBulkApiaryChange}
selectedIds={bulkColonyIds}
onChange={onBulkColonyIdsChange}
error={bulkError}
/>
)}
</>
);
}
@@ -0,0 +1,39 @@
"use client";
import { useId } from "react";
import { ChoiceGroup, ChoiceOption } from "@/components/form/choice-group";
/** One colony, or the same capture for several colonies at once. */
export type CaptureTarget = "single" | "bulk";
const TARGET_OPTIONS: ChoiceOption<CaptureTarget>[] = [
{ value: "single", label: "Ein Volk" },
{ value: "bulk", label: "Mehrere Völker" },
];
interface CaptureTargetToggleProps {
value: CaptureTarget;
onChange: (target: CaptureTarget) => void;
}
/**
* „Für: Ein Volk / Mehrere Völker“ at the top of a capture form. Always one
* of the two, so it is not marked as required or optional.
*/
export function CaptureTargetToggle({ value, onChange }: CaptureTargetToggleProps) {
const labelId = useId();
return (
<div className="flex flex-col gap-1.5">
<span id={labelId} className="text-sm font-medium">
Für
</span>
<ChoiceGroup
labelledBy={labelId}
options={TARGET_OPTIONS}
value={value}
onChange={onChange}
className="grid-cols-2"
/>
</div>
);
}
@@ -0,0 +1,233 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useState } from "react";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ColonyMultiSelect } from "./colony-multi-select";
import { fetchColonies } from "@/lib/colonies/api";
import { fetchApiaries } from "@/lib/apiaries/api";
import { listPacks } from "@/lib/apiaries/offline-pack";
import { offlinePack, packColony, storedPack } from "@/lib/apiaries/offline-pack.fixtures";
import type { ColonyStatus } from "@/lib/colonies/types";
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() }));
vi.mock("@/lib/apiaries/api", () => ({ fetchApiaries: vi.fn() }));
vi.mock("@/lib/auth/auth-context", () => ({ useCurrentUserId: () => "user-1" }));
vi.mock("@/lib/apiaries/offline-pack", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/apiaries/offline-pack")>()),
listPacks: vi.fn(),
}));
function colony(id: string, code: string, status: ColonyStatus, apiaryId = "apiary-1") {
return { id, code, status, apiaryId };
}
const COLONIES = [
colony("c-1", "V-01", "ACTIVE"),
colony("c-2", "V-02", "WEAK"),
colony("c-3", "V-03", "QUEENLESS"),
colony("c-4", "V-04", "DISSOLVED"),
colony("c-5", "W-01", "ACTIVE", "apiary-2"),
];
function Harness({
initialApiaryId = null,
initialIds = [],
error,
onChangeSpy,
}: {
initialApiaryId?: string | null;
initialIds?: string[];
error?: string | null;
onChangeSpy?: (ids: string[]) => void;
}) {
const [apiaryId, setApiaryId] = useState<string | null>(initialApiaryId);
const [ids, setIds] = useState<string[]>(initialIds);
return (
<ColonyMultiSelect
id="bulk-colonies"
apiaryId={apiaryId}
onApiaryChange={setApiaryId}
selectedIds={ids}
onChange={(next) => {
onChangeSpy?.(next);
setIds(next);
}}
error={error}
/>
);
}
function renderSelect(props: Parameters<typeof Harness>[0] = {}) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<Harness {...props} />
</QueryClientProvider>,
);
}
async function chooseApiary(name: string) {
await userEvent.click(screen.getByRole("combobox", { name: /Stand/ }));
await userEvent.click(await screen.findByRole("option", { name }));
}
describe("ColonyMultiSelect", () => {
beforeEach(() => {
vi.mocked(fetchApiaries)
.mockReset()
.mockResolvedValue([
{ id: "apiary-1", name: "Waldrand" },
{ id: "apiary-2", name: "Obstwiese" },
] as never);
vi.mocked(fetchColonies)
.mockReset()
.mockImplementation((async (filter?: { apiaryId?: string }) =>
COLONIES.filter((item) => item.apiaryId === filter?.apiaryId)) as never);
vi.mocked(listPacks).mockReset().mockResolvedValue([]);
});
it("choosing an apiary preselects all its living colonies", async () => {
renderSelect();
await chooseApiary("Waldrand");
const boxes = await screen.findAllByRole("checkbox");
expect(boxes.map((box) => box.getAttribute("aria-checked"))).toEqual(["true", "true", "true"]);
// The weak and the queenless colony are in, the dissolved one never appears.
expect(screen.getByRole("checkbox", { name: "V-02 (Schwach)" })).toBeInTheDocument();
expect(screen.getByRole("checkbox", { name: "V-03 (Weisellos)" })).toBeInTheDocument();
expect(screen.queryByRole("checkbox", { name: /V-04/ })).not.toBeInTheDocument();
expect(screen.queryByRole("checkbox", { name: /W-01/ })).not.toBeInTheDocument();
expect(fetchColonies).toHaveBeenCalledWith({ apiaryId: "apiary-1" });
expect(await screen.findByText("3 von 3 Völkern gewählt")).toBeInTheDocument();
});
it("deselecting one keeps the rest", async () => {
const onChange = vi.fn();
renderSelect({ onChangeSpy: onChange });
await chooseApiary("Waldrand");
await screen.findByText("3 von 3 Völkern gewählt");
await userEvent.click(screen.getByRole("checkbox", { name: "V-02 (Schwach)" }));
expect(onChange).toHaveBeenLastCalledWith(["c-1", "c-3"]);
expect(screen.getByRole("checkbox", { name: "V-02 (Schwach)" })).toHaveAttribute(
"aria-checked",
"false",
);
expect(screen.getByRole("checkbox", { name: "V-01" })).toHaveAttribute("aria-checked", "true");
expect(screen.getByText("2 von 3 Völkern gewählt")).toBeInTheDocument();
});
it("rows are 56 px and checkboxes are named by colony code", async () => {
renderSelect({ initialApiaryId: "apiary-1", initialIds: ["c-1"] });
const box = await screen.findByRole("checkbox", { name: "V-01" });
expect(box.closest("label")).toHaveClass("min-h-14");
expect(screen.getAllByRole("checkbox").map((item) => item.closest("label"))).toSatisfy(
(rows: (HTMLElement | null)[]) => rows.every((row) => row?.classList.contains("min-h-14")),
);
});
it("toggles a colony when its row text is tapped", async () => {
renderSelect({ initialApiaryId: "apiary-1", initialIds: ["c-2"] });
await screen.findByRole("checkbox", { name: "V-01" });
await userEvent.click(screen.getByText("V-01"));
expect(screen.getByRole("checkbox", { name: "V-01" })).toHaveAttribute("aria-checked", "true");
});
it("Alle abwählen and Alle wählen set the whole apiary at once", async () => {
renderSelect();
await chooseApiary("Waldrand");
await screen.findByText("3 von 3 Völkern gewählt");
await userEvent.click(screen.getByRole("button", { name: "Alle abwählen" }));
expect(screen.getByText("0 von 3 Völkern gewählt")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Alle wählen" }));
expect(screen.getByText("3 von 3 Völkern gewählt")).toBeInTheDocument();
});
it("keeps a restored selection instead of preselecting everything", async () => {
renderSelect({ initialApiaryId: "apiary-1", initialIds: ["c-3"] });
expect(await screen.findByText("1 von 3 Völkern gewählt")).toBeInTheDocument();
expect(screen.getByRole("checkbox", { name: "V-03 (Weisellos)" })).toHaveAttribute(
"aria-checked",
"true",
);
});
it("preselects the colonies of the other apiary after switching", async () => {
renderSelect();
await chooseApiary("Waldrand");
await screen.findByText("3 von 3 Völkern gewählt");
await chooseApiary("Obstwiese");
expect(await screen.findByRole("checkbox", { name: "W-01" })).toHaveAttribute(
"aria-checked",
"true",
);
expect(screen.queryByRole("checkbox", { name: "V-01" })).not.toBeInTheDocument();
expect(screen.getByText("1 von 1 Volk gewählt")).toBeInTheDocument();
});
it("offers the loaded packs while offline", async () => {
vi.mocked(fetchApiaries).mockRejectedValue(new TypeError("Failed to fetch"));
vi.mocked(fetchColonies).mockRejectedValue(new TypeError("Failed to fetch"));
vi.mocked(listPacks).mockResolvedValue([
storedPack(
offlinePack({
apiaryId: "apiary-1",
apiaryName: "Waldrand",
colonies: [packColony("p-1", "B1"), packColony("p-2", "B2", { status: "DISSOLVED" })],
}),
),
]);
renderSelect();
await chooseApiary("Waldrand");
expect(await screen.findByRole("checkbox", { name: "B1" })).toHaveAttribute(
"aria-checked",
"true",
);
expect(screen.queryByRole("checkbox", { name: "B2" })).not.toBeInTheDocument();
expect(screen.getByText("Offline: nur Völker der geladenen Stände.")).toBeInTheDocument();
});
it("tells how to work offline when no apiary was loaded", async () => {
vi.mocked(fetchApiaries).mockRejectedValue(new TypeError("Failed to fetch"));
renderSelect();
expect(
await screen.findByText("Kein Netz. Lade den Stand vorher mit „Für offline laden“."),
).toBeInTheDocument();
});
it("shows the error next to the list", async () => {
renderSelect({ error: "Bitte mindestens ein Volk wählen." });
expect(await screen.findByText("Bitte mindestens ein Volk wählen.")).toBeInTheDocument();
});
it("names the limit when more than 100 colonies are selected", async () => {
const many = Array.from({ length: 101 }, (_, index) =>
colony(`c-${index}`, `V-${index}`, "ACTIVE"),
);
vi.mocked(fetchColonies).mockResolvedValue(many as never);
renderSelect();
await chooseApiary("Waldrand");
await waitFor(() =>
expect(screen.getByText("Höchstens 100 Völker auf einmal.")).toBeInTheDocument(),
);
expect(screen.getByText("101 von 101 Völkern gewählt")).toBeInTheDocument();
});
});
@@ -0,0 +1,180 @@
"use client";
import { useEffect, useId, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Field } from "@/components/form/field";
import { RecordSelect } from "@/components/form/record-select";
import { colonyOptionLabel } from "@/lib/colonies/labels";
import { BULK_MAX_COLONIES } from "@/lib/bulk/api";
import { useApiaryChoices, useApiaryColonies } from "@/lib/bulk/use-apiary-colonies";
import { isNetworkError } from "@/lib/http/fetch-with-timeout";
const APIARY_PLACEHOLDER = "Stand wählen";
const LOADING_PLACEHOLDER = "Stände werden geladen…";
const LOAD_ERROR_HINT = "Stände konnten nicht geladen werden – bitte die Seite neu laden.";
const COLONIES_ERROR_HINT = "Völker konnten nicht geladen werden – bitte die Seite neu laden.";
const OFFLINE_PACK_HINT = "Offline: nur Völker der geladenen Stände.";
const OFFLINE_NOT_LOADED_HINT = "Kein Netz. Lade den Stand vorher mit „Für offline laden“.";
const NO_COLONIES_HINT = "In diesem Stand gibt es noch keine aktiven Völker.";
const MAX_COLONIES_ERROR = `Höchstens ${BULK_MAX_COLONIES} Völker auf einmal.`;
export interface ColonyMultiSelectProps {
/** Base id; the stand select carries `${id}-apiary`. */
id: string;
apiaryId: string | null;
onApiaryChange: (apiaryId: string | null) => void;
selectedIds: readonly string[];
onChange: (ids: string[]) => void;
error?: string | null;
}
function countText(selected: number, total: number): string {
return `${selected} von ${total} ${total === 1 ? "Volk" : "Völkern"} gewählt`;
}
function errorHint(error: unknown, noun: "Stände" | "Völker"): string {
if (isNetworkError(error)) return OFFLINE_NOT_LOADED_HINT;
return noun === "Stände" ? LOAD_ERROR_HINT : COLONIES_ERROR_HINT;
}
/**
* Picks the colonies one capture is recorded for: first the stand, which
* selects all its living colonies at once (ended ones never appear), then
* single colonies can be taken out. Like the single-colony select it reads
* the loaded offline packs when there is no network.
*/
export function ColonyMultiSelect({
id,
apiaryId,
onApiaryChange,
selectedIds,
onChange,
error,
}: ColonyMultiSelectProps) {
const apiaries = useApiaryChoices();
const colonies = useApiaryColonies(apiaryId);
const groupLabelId = useId();
const list = colonies.data?.items;
// The stand a selection was made or restored for: only a different stand
// preselects again, so a restored draft keeps its own choice.
const preselectedFor = useRef<string | null>(selectedIds.length > 0 ? apiaryId : null);
useEffect(() => {
if (!apiaryId || !list) return;
if (preselectedFor.current !== apiaryId) {
preselectedFor.current = apiaryId;
onChange(list.map((colony) => colony.id));
return;
}
// A colony that is no longer on offer (ended since a draft) is not sent.
const offered = new Set(list.map((colony) => colony.id));
const kept = selectedIds.filter((colonyId) => offered.has(colonyId));
if (kept.length !== selectedIds.length) {
onChange(kept);
}
}, [apiaryId, list, selectedIds, onChange]);
function hint(): string | undefined {
if (apiaries.isError) return errorHint(apiaries.error, "Stände");
if (apiaryId && colonies.isError) return errorHint(colonies.error, "Völker");
if (apiaries.data?.source === "packs" || colonies.data?.source === "packs") {
return OFFLINE_PACK_HINT;
}
return undefined;
}
const offered = new Set((list ?? []).map((colony) => colony.id));
const selectedCount = selectedIds.filter((colonyId) => offered.has(colonyId)).length;
const shownError = selectedIds.length > BULK_MAX_COLONIES ? MAX_COLONIES_ERROR : error;
function toggle(colonyId: string, isChecked: boolean) {
const next = isChecked
? [...selectedIds, colonyId]
: selectedIds.filter((selectedId) => selectedId !== colonyId);
// Keep the order of the list, not the order of tapping.
onChange((list ?? []).map((colony) => colony.id).filter((item) => next.includes(item)));
}
return (
<div className="flex flex-col gap-4">
<Field id={`${id}-apiary`} label="Stand" required hint={hint()}>
{({ id: triggerId, describedBy, invalid }) => (
<RecordSelect
id={triggerId}
value={apiaryId ?? ""}
onChange={(next) => onApiaryChange(next === "" ? null : next)}
items={apiaries.data?.items}
dataField="bulkColonies"
getLabel={(apiary) => apiary.name}
placeholder={apiaries.isLoading ? LOADING_PLACEHOLDER : APIARY_PLACEHOLDER}
describedBy={describedBy}
invalid={invalid}
/>
)}
</Field>
{list ? (
<div
role="group"
aria-labelledby={groupLabelId}
aria-describedby={shownError ? `${id}-error` : undefined}
className="flex flex-col gap-2"
>
<div className="flex flex-wrap items-center justify-between gap-2">
<span id={groupLabelId} className="text-sm font-medium">
Völker
</span>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
size="touch"
onClick={() => onChange(list.map((colony) => colony.id))}
>
Alle wählen
</Button>
<Button type="button" variant="outline" size="touch" onClick={() => onChange([])}>
Alle abwählen
</Button>
</div>
</div>
{list.length === 0 ? (
<p className="text-sm text-muted-foreground">{NO_COLONIES_HINT}</p>
) : (
<ul className="flex flex-col gap-2">
{list.map((colony) => (
<li key={colony.id}>
<label
htmlFor={`${id}-${colony.id}`}
className="flex min-h-14 cursor-pointer items-center gap-3 rounded-lg border border-border px-3 has-[:focus-visible]:ring-3 has-[:focus-visible]:ring-ring/50"
>
<Checkbox
id={`${id}-${colony.id}`}
className="size-6"
aria-labelledby={`${id}-${colony.id}-label`}
checked={selectedIds.includes(colony.id)}
onCheckedChange={(isChecked) => toggle(colony.id, isChecked)}
/>
<span id={`${id}-${colony.id}-label`} className="text-base">
{colonyOptionLabel(colony)}
</span>
</label>
</li>
))}
</ul>
)}
<p role="status" aria-live="polite" className="text-sm font-medium">
{countText(selectedCount, list.length)}
</p>
</div>
) : null}
{shownError ? (
<p id={`${id}-error`} role="alert" className="text-sm font-medium text-destructive">
{shownError}
</p>
) : null}
</div>
);
}
@@ -0,0 +1,149 @@
import "fake-indexeddb/auto";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { toast } from "sonner";
import { ApiError, apiFetch } from "@/lib/auth/api-client";
import { deleteAllPacks, getPack } from "@/lib/apiaries/offline-pack";
import { DAY_MS, offlinePack, storedPack } from "@/lib/apiaries/offline-pack.fixtures";
import { getDb } from "@/lib/offline/db";
import { LoadPackButton } from "./load-pack-button";
vi.mock("@/lib/auth/api-client", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/auth/api-client")>()),
apiFetch: vi.fn(),
}));
vi.mock("@/lib/auth/auth-context", () => ({ useCurrentUserId: () => "user-1" }));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
const postMessage = vi.fn();
function renderButton() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<LoadPackButton apiaryId="apiary-1" />
</QueryClientProvider>,
);
}
describe("LoadPackButton", () => {
beforeEach(async () => {
vi.mocked(apiFetch).mockReset();
vi.mocked(toast.error).mockReset();
postMessage.mockReset();
Object.defineProperty(navigator, "serviceWorker", {
configurable: true,
value: { ready: Promise.resolve({ active: { postMessage } }) },
});
await deleteAllPacks();
});
afterEach(() => {
Reflect.deleteProperty(navigator, "serviceWorker");
});
it("loads the apiary for offline use and then says how long ago", async () => {
vi.mocked(apiFetch).mockResolvedValue(offlinePack({ apiaryId: "apiary-1" }));
renderButton();
await userEvent.click(screen.getByRole("button", { name: "Für offline laden" }));
expect(await screen.findByText("Geladen vor weniger als einer Minute")).toBeInTheDocument();
expect(screen.getByRole("status")).toHaveTextContent("Geladen vor weniger als einer Minute");
expect((await getPack("apiary-1"))?.ownerUserId).toBe("user-1");
// The app shell is refreshed too, so the offline pages open without network.
await vi.waitFor(() => expect(postMessage).toHaveBeenCalledWith({ type: "WARM_SHELL" }));
});
it("asks once that this device's offline data is kept, not evicted", async () => {
const persist = vi.fn(async () => true);
const persisted = vi.fn(async () => false);
Object.defineProperty(navigator, "storage", {
configurable: true,
value: { persist, persisted },
});
try {
vi.mocked(apiFetch).mockResolvedValue(offlinePack({ apiaryId: "apiary-1" }));
renderButton();
await userEvent.click(screen.getByRole("button", { name: "Für offline laden" }));
await vi.waitFor(() => expect(persist).toHaveBeenCalledTimes(1));
} finally {
Reflect.deleteProperty(navigator, "storage");
}
});
it("does not ask again when the storage is already kept", async () => {
const persist = vi.fn(async () => true);
Object.defineProperty(navigator, "storage", {
configurable: true,
value: { persist, persisted: vi.fn(async () => true) },
});
try {
vi.mocked(apiFetch).mockResolvedValue(offlinePack({ apiaryId: "apiary-1" }));
renderButton();
await userEvent.click(screen.getByRole("button", { name: "Für offline laden" }));
await screen.findByText("Geladen vor weniger als einer Minute");
expect(persist).not.toHaveBeenCalled();
} finally {
Reflect.deleteProperty(navigator, "storage");
}
});
it("shows the stale hint for a pack older than 24 h", async () => {
const db = await getDb();
await db.put(
"offline-packs",
storedPack(offlinePack({ apiaryId: "apiary-1" }), {
savedAt: new Date(Date.now() - 3 * DAY_MS).toISOString(),
}),
);
renderButton();
expect(await screen.findByText("Geladen vor 3 Tagen")).toBeInTheDocument();
expect(screen.getByText(/^Stand-Daten von .* – bei Netz neu laden$/)).toBeInTheDocument();
});
it("treats another account's pack as not loaded", async () => {
const db = await getDb();
await db.put(
"offline-packs",
storedPack(offlinePack({ apiaryId: "apiary-1" }), { ownerUserId: "user-2" }),
);
renderButton();
await screen.findByRole("button", { name: "Für offline laden" });
expect(screen.queryByText(/^Geladen vor/)).not.toBeInTheDocument();
});
it("says so when there is no network", async () => {
vi.mocked(apiFetch).mockRejectedValue(new TypeError("Failed to fetch"));
renderButton();
await userEvent.click(screen.getByRole("button", { name: "Für offline laden" }));
await vi.waitFor(() =>
expect(toast.error).toHaveBeenCalledWith("Kein Netz – der Stand konnte nicht geladen werden."),
);
expect(await getPack("apiary-1")).toBeNull();
expect(postMessage).not.toHaveBeenCalled();
});
it("names a server refusal differently from a missing network", async () => {
vi.mocked(apiFetch).mockRejectedValue(new ApiError(403, "Forbidden"));
renderButton();
await userEvent.click(screen.getByRole("button", { name: "Für offline laden" }));
await vi.waitFor(() =>
expect(toast.error).toHaveBeenCalledWith(
"Stand konnte nicht für offline geladen werden. Bitte noch einmal versuchen.",
),
);
});
});
@@ -0,0 +1,127 @@
"use client";
import { AlertTriangle, Download } from "lucide-react";
import { toast } from "sonner";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { useCurrentUserId } from "@/lib/auth/auth-context";
import {
OFFLINE_PACKS_QUERY_KEY,
getOwnPack,
isPackStale,
loadPack,
ownPackQueryKey,
packAgeText,
packDayText,
} from "@/lib/apiaries/offline-pack";
import { isNetworkError } from "@/lib/http/fetch-with-timeout";
import { useConnectivity } from "@/lib/offline/connectivity";
import type { StoredPack } from "@/lib/offline/db";
const NO_NETWORK_TEXT = "Kein Netz – der Stand konnte nicht geladen werden.";
const LOAD_FAILED_TEXT = "Stand konnte nicht für offline geladen werden. Bitte noch einmal versuchen.";
interface PackAgeNoteProps {
stored: StoredPack;
/** When the pack was read from storage (the query's `dataUpdatedAt`). */
now: number;
}
/**
* When the pack was loaded, and from 24 h on that its data is old - in words
* and with an icon, never by colour alone.
*/
export function PackAgeNote({ stored, now }: PackAgeNoteProps) {
return (
<div className="flex flex-col gap-1 text-sm">
<p className="text-muted-foreground">Geladen {packAgeText(stored.savedAt, now)}</p>
{isPackStale(stored, now) ? (
<p className="flex items-center gap-1.5 font-medium">
<AlertTriangle className="size-4 shrink-0 text-warning" aria-hidden />
{`Stand-Daten von ${packDayText(stored.savedAt)} – bei Netz neu laden`}
</p>
) : null}
</div>
);
}
/** Asks the service worker to refresh the app shell, so the offline pages open without network. */
function requestShellWarmUp(): void {
if (typeof navigator === "undefined" || !("serviceWorker" in navigator)) {
return;
}
void navigator.serviceWorker.ready
.then((registration) => registration.active?.postMessage({ type: "WARM_SHELL" }))
.catch(() => undefined);
}
/**
* Asks the browser once to keep this site's storage (packs, the offline
* queue) instead of evicting it under storage pressure. Best effort.
*/
function requestPersistentStorage(): void {
const storage = typeof navigator === "undefined" ? undefined : navigator.storage;
if (!storage?.persist || !storage.persisted) {
return;
}
void storage
.persisted()
.then((isPersisted) => (isPersisted ? undefined : storage.persist()))
.catch(() => undefined);
}
/** The pack of this apiary, if the signed-in user loaded it. */
export function useOwnPack(apiaryId: string, userId: string | null) {
return useQuery({
queryKey: ownPackQueryKey(userId, apiaryId),
queryFn: () => getOwnPack(apiaryId, userId ?? ""),
enabled: userId !== null && apiaryId !== "",
// Reads this device's storage, never the network.
networkMode: "always",
});
}
/**
* „Für offline laden“: keeps the apiary's colonies, queens, last inspections
* and open tasks on this device for work without reception, and says how
* old what is kept is.
*/
export function LoadPackButton({ apiaryId }: { apiaryId: string }) {
const userId = useCurrentUserId();
const queryClient = useQueryClient();
const isOffline = useConnectivity() === "offline";
const { data: stored, dataUpdatedAt } = useOwnPack(apiaryId, userId);
const load = useMutation({
mutationFn: (ownerUserId: string) => loadPack(apiaryId, ownerUserId),
networkMode: "always",
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: OFFLINE_PACKS_QUERY_KEY });
requestShellWarmUp();
requestPersistentStorage();
},
onError: (error) => toast.error(isNetworkError(error) ? NO_NETWORK_TEXT : LOAD_FAILED_TEXT),
});
return (
<div className="flex flex-col items-start gap-2">
<Button
type="button"
variant="outline"
size="touch"
disabled={userId === null || isOffline || load.isPending}
onClick={() => {
if (userId) load.mutate(userId);
}}
>
<Download aria-hidden />
{load.isPending ? "Wird geladen…" : "Für offline laden"}
</Button>
{/* Always mounted, so a newly loaded pack is announced. */}
<div role="status" aria-live="polite">
{stored ? <PackAgeNote stored={stored} now={dataUpdatedAt} /> : null}
{isOffline ? <p className="text-sm text-muted-foreground">Laden braucht Netz.</p> : null}
</div>
</div>
);
}
@@ -34,11 +34,11 @@ function setOnLine(value: boolean) {
}
/** The band as the layout places it: above the page content wrapper. */
function BandOnPage() {
function BandOnPage({ syncEnabled }: { syncEnabled?: boolean }) {
const pageContentRef = useRef<HTMLDivElement>(null);
return (
<>
<OfflineBand userId="user-1" pageContentRef={pageContentRef} />
<OfflineBand userId="user-1" pageContentRef={pageContentRef} syncEnabled={syncEnabled} />
<div ref={pageContentRef} tabIndex={-1} data-testid="page-content">
Seiteninhalt
</div>
@@ -46,11 +46,11 @@ function BandOnPage() {
);
}
function renderBand() {
function renderBand(props: { syncEnabled?: boolean } = {}) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<BandOnPage />
<BandOnPage {...props} />
</QueryClientProvider>,
);
}
@@ -260,4 +260,39 @@ describe("OfflineBand", () => {
await waitFor(() => expect(syncPendingEntries).toHaveBeenCalledWith("user-1"));
});
describe("in an offline session (no access token)", () => {
it("neither opens the sync gate nor sends, even when the connection is back", async () => {
setOnLine(true);
vi.mocked(summarizeQueue).mockResolvedValue({ own: 2, failed: 0, foreign: 0 });
renderBand({ syncEnabled: false });
await screen.findByText("Offline – 2 Einträge warten");
act(() => {
window.dispatchEvent(new Event("online"));
});
expect(resumeSync).not.toHaveBeenCalled();
expect(syncPendingEntries).not.toHaveBeenCalled();
expect(screen.queryByRole("button", { name: /Jetzt senden/ })).not.toBeInTheDocument();
});
it("opens the gate and sends as soon as the session is online again", async () => {
setOnLine(true);
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 });
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const view = (syncEnabled: boolean) => (
<QueryClientProvider client={client}>
<BandOnPage syncEnabled={syncEnabled} />
</QueryClientProvider>
);
const { rerender } = render(view(false));
await screen.findByText("Offline – 1 Eintrag wartet");
rerender(view(true));
await waitFor(() => expect(syncPendingEntries).toHaveBeenCalledWith("user-1"));
expect(resumeSync).toHaveBeenCalled();
});
});
});
@@ -17,7 +17,8 @@ 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.
// (syncPendingEntries), even one already running - they change nothing that
// is to be sent - 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";
@@ -26,6 +27,12 @@ interface OfflineBandProps {
userId: string;
/** Focus target when the queue sheet closes and the band shows nothing. */
pageContentRef: RefObject<HTMLElement | null>;
/**
* False in an offline session (opened from the cached identity, no access
* token): the band then shows what waits but neither opens the sync gate
* nor sends - the server would only answer 401.
*/
syncEnabled?: boolean;
}
/**
@@ -33,7 +40,7 @@ interface OfflineBandProps {
* 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) {
export function OfflineBand({ userId, pageContentRef, syncEnabled = true }: OfflineBandProps) {
const queryClient = useQueryClient();
const connectivity = useConnectivity();
const statusRef = useRef<HTMLDivElement>(null);
@@ -68,24 +75,31 @@ export function OfflineBand({ userId, pageContentRef }: OfflineBandProps) {
// 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.
// logout's cancelSync(). Not without a token (offline session): the gate
// stays closed until the session is online. Declared first, so it runs
// before the sync below.
useEffect(() => {
resumeSync();
}, [userId]);
if (syncEnabled) {
resumeSync();
}
}, [userId, syncEnabled]);
// Mount, and every switch back to online (a request reached the server again).
useEffect(() => {
if (connectivity === "online") {
if (syncEnabled && connectivity === "online") {
startSync();
}
}, [connectivity, startSync, userId]);
}, [connectivity, startSync, userId, syncEnabled]);
// The browser's own `online` event: try even while the last request failed.
useEffect(() => {
if (!syncEnabled) {
return;
}
const handleOnline = () => startSync();
window.addEventListener("online", handleOnline);
return () => window.removeEventListener("online", handleOnline);
}, [startSync]);
}, [startSync, syncEnabled]);
// 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
@@ -95,7 +109,8 @@ export function OfflineBand({ userId, pageContentRef }: OfflineBandProps) {
setSheetOpen(false);
}
const isOffline = connectivity === "offline";
// An offline session cannot send, whatever the network says.
const isOffline = connectivity === "offline" || !syncEnabled;
const hasContent = isOffline || queue.own > 0 || hasRejected;
// Back to the trigger while it exists; otherwise to the band's message, or
@@ -2,20 +2,27 @@ 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 {
discardQueueItem,
listQueue,
retryBulkWithout,
type QueueItem,
type StoredPhoto,
} from "@/lib/offline/db";
import { syncPendingEntries } from "@/lib/offline/sync";
import { listPendingPhotos, removePhoto, resetPhotoError } from "@/lib/photos/store";
import { discardReadyPhoto, listPendingPhotos, resetPhotoError } from "@/lib/photos/store";
import { PendingQueueSheet } from "./pending-queue-sheet";
vi.mock("@/lib/offline/db", () => ({
listQueue: vi.fn(),
discardQueueItem: vi.fn(),
retryBulkWithout: 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(),
discardReadyPhoto: vi.fn(),
resetPhotoError: vi.fn(),
}));
@@ -85,14 +92,67 @@ describe("PendingQueueSheet", () => {
beforeEach(() => {
vi.mocked(listQueue).mockReset().mockResolvedValue([REJECTED, WAITING]);
vi.mocked(discardQueueItem).mockReset().mockResolvedValue(undefined);
vi.mocked(retryBulkWithout).mockReset().mockResolvedValue(undefined);
vi.mocked(listPendingPhotos).mockReset().mockResolvedValue([]);
vi.mocked(removePhoto).mockReset().mockResolvedValue(undefined);
vi.mocked(discardReadyPhoto).mockReset().mockResolvedValue(undefined);
vi.mocked(resetPhotoError).mockReset().mockResolvedValue(undefined);
vi.mocked(syncPendingEntries)
.mockReset()
.mockResolvedValue({ synced: 0, failed: 0, skippedForeign: 0 });
});
const REJECTED_BULK: QueueItem = {
store: "pending-bulk",
id: 9,
label: "Behandlung für mehrere Völker",
queuedAt: "2026-10-14T08:00:00.000Z",
lastError: {
status: 400,
message: "Einige Völker sind nicht (mehr) erfassbar",
at: "2026-10-14T09:00:00.000Z",
},
resendWithout: { colonyIds: ["c-3"], codes: ["V-3"] },
};
it("„Ohne V-3 erneut senden“ resends the remaining ids with the same key", async () => {
vi.mocked(listQueue).mockResolvedValue([REJECTED_BULK]);
renderSheet();
const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" });
await userEvent.click(await within(sheet).findByRole("button", { name: /^Ohne V-3 erneut senden/ }));
// The entry keeps its key; only the refused colony leaves, then a run sends it.
await waitFor(() => expect(retryBulkWithout).toHaveBeenCalledWith(9, ["c-3"]));
await waitFor(() =>
expect(syncPendingEntries).toHaveBeenCalledWith("user-1", { afterChange: true }),
);
expect(vi.mocked(retryBulkWithout).mock.invocationCallOrder[0]).toBeLessThan(
vi.mocked(syncPendingEntries).mock.invocationCallOrder[0],
);
});
it("names the button generally when a refused colony is not known by code", async () => {
vi.mocked(listQueue).mockResolvedValue([
{ ...REJECTED_BULK, resendWithout: { colonyIds: ["c-3", "c-4"], codes: ["V-3"] } },
]);
renderSheet();
expect(
await screen.findByRole("button", { name: /^Ohne die abgelehnten Völker erneut senden/ }),
).toBeInTheDocument();
});
it("offers no resend for a waiting bulk entry or a single rejected entry", async () => {
vi.mocked(listQueue).mockResolvedValue([
{ ...REJECTED_BULK, lastError: undefined, resendWithout: undefined },
REJECTED,
]);
renderSheet();
await screen.findAllByText("Behandlung für mehrere Völker");
expect(screen.queryByRole("button", { name: /erneut senden/ })).not.toBeInTheDocument();
});
it("shows „+ 2 Fotos“ at a queued inspection and discards them with it", async () => {
vi.mocked(listQueue).mockResolvedValue([{ ...REJECTED, photoCount: 2 }]);
renderSheet();
@@ -126,7 +186,10 @@ describe("PendingQueueSheet", () => {
await userEvent.click(retry[0]);
await waitFor(() => expect(syncPendingEntries).toHaveBeenCalledWith("user-1"));
// It changed what is to be sent: a sync already running gets one more run.
await waitFor(() =>
expect(syncPendingEntries).toHaveBeenCalledWith("user-1", { afterChange: true }),
);
expect(resetPhotoError).toHaveBeenCalledWith("photo-1");
expect(vi.mocked(resetPhotoError).mock.invocationCallOrder[0]).toBeLessThan(
vi.mocked(syncPendingEntries).mock.invocationCallOrder[0],
@@ -144,7 +207,7 @@ describe("PendingQueueSheet", () => {
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"));
await waitFor(() => expect(discardReadyPhoto).toHaveBeenCalledWith("photo-1"));
expect(within(sheet).queryByText("Von dir wartet nichts mehr.")).not.toBeInTheDocument();
});
@@ -8,11 +8,17 @@ 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 {
discardQueueItem,
listQueue,
retryBulkWithout,
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";
import { discardReadyPhoto, listPendingPhotos, resetPhotoError } from "@/lib/photos/store";
interface PendingQueueSheetProps {
userId: string;
@@ -80,6 +86,33 @@ function QueueStatus({ lastError }: { lastError?: QueueError }) {
);
}
/** „Ohne V-3 erneut senden“ - or, when a code is unknown, without naming them. */
function ResendWithoutButton({
resend,
disabled,
onResend,
}: {
resend: NonNullable<QueueItem["resendWithout"]>;
disabled: boolean;
onResend: () => void;
}) {
const isNamed = resend.codes.length === resend.colonyIds.length;
const without = isNamed ? resend.codes.join(", ") : "die abgelehnten Völker";
return (
<Button
type="button"
variant="outline"
size="touch"
className="self-start"
disabled={disabled}
onClick={onResend}
>
<RotateCw aria-hidden />
Ohne {without} erneut senden
</Button>
);
}
/**
* 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.
@@ -120,7 +153,7 @@ export function PendingQueueSheet({
});
const discardPhoto = useMutation({
mutationFn: (photoId: string) => removePhoto(photoId),
mutationFn: (photoId: string) => discardReadyPhoto(photoId),
networkMode: "always",
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
@@ -133,7 +166,8 @@ export function PendingQueueSheet({
const retryPhoto = useMutation({
mutationFn: async (photoId: string) => {
await resetPhotoError(photoId);
return syncPendingEntries(userId);
// A sync already running may have passed this photo: it runs once more.
return syncPendingEntries(userId, { afterChange: true });
},
networkMode: "always",
onSuccess: (result) => {
@@ -148,6 +182,26 @@ export function PendingQueueSheet({
onError: () => toast.error("Foto konnte nicht erneut gesendet werden."),
});
// „Ohne … erneut senden“: the refused colonies leave the bulk entry (same
// key, rejection cleared), then a normal run sends the rest.
const resendBulk = useMutation({
mutationFn: async ({ id, colonyIds }: { id: number; colonyIds: string[] }) => {
await retryBulkWithout(id, colonyIds);
// A sync already running may have passed this entry: it runs once more.
return syncPendingEntries(userId, { afterChange: true });
},
networkMode: "always",
onSuccess: (result) => {
if (result.synced > 0) {
toast.success(entriesSynced(result.synced));
void queryClient.invalidateQueries();
return;
}
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
},
onError: () => toast.error("Eintrag konnte nicht erneut gesendet werden."),
});
const names = entryNames(items);
const photoNames = entryNames(
photos.map((photo) => ({ label: PHOTO_LABEL, queuedAt: photo.createdAt })),
@@ -197,6 +251,18 @@ export function PendingQueueSheet({
/>
</div>
<QueueStatus lastError={item.lastError} />
{item.resendWithout && typeof item.id === "number" ? (
<ResendWithoutButton
resend={item.resendWithout}
disabled={resendBulk.isPending}
onResend={() =>
resendBulk.mutate({
id: item.id as number,
colonyIds: item.resendWithout?.colonyIds ?? [],
})
}
/>
) : null}
</li>
))}
{photos.map((photo, index) => (
@@ -3,7 +3,7 @@ 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 { addDraftPhoto, attachSessionToQueuedInspection, listSessionPhotos } from "@/lib/photos/store";
import { PhotoMetadataError, preparePhoto } from "@/lib/photos/reencode";
import { PhotoCaptureField } from "./photo-capture-field";
@@ -72,6 +72,16 @@ describe("PhotoCaptureField", () => {
expect(screen.getByRole("button", { name: "Foto aufnehmen" })).toBeInTheDocument();
});
it("keeps its announcement region present while it is empty", () => {
renderField();
// Hidden while empty, it would swallow the first message (jsdom applies no CSS).
const live = screen.getByRole("status");
expect(live).toHaveAttribute("aria-live", "polite");
expect(live).toBeEmptyDOMElement();
expect([...live.classList].some((name) => /(^|:)hidden$/.test(name))).toBe(false);
});
it("is hidden without documents:upload", () => {
permissions = new Set();
const { container } = renderField();
@@ -129,6 +139,19 @@ describe("PhotoCaptureField", () => {
expect(onChange).toHaveBeenCalled();
});
it("offers „Entfernen“ only for photos that are not saved yet", async () => {
await storePhotos(1);
await attachSessionToQueuedInspection(sessionId, "inspection-key");
await storePhotos(1);
renderField();
expect(await screen.findAllByRole("img", { name: /^Foto \d$/ })).toHaveLength(2);
// Foto 1 already belongs to an inspection in the queue; only Foto 2 is the form's own.
expect(screen.queryByRole("button", { name: "Entfernen: Foto 1" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Entfernen: Foto 2" })).toBeInTheDocument();
expect(screen.getByText("Gespeichert")).toBeInTheDocument();
});
it("keeps nothing when the location data could not be removed, and says so", async () => {
vi.mocked(preparePhoto).mockRejectedValue(new PhotoMetadataError());
const { container } = renderField();
@@ -177,17 +177,24 @@ function PhotoCapture({ step, sessionId, date, onChange }: PhotoCaptureFieldProp
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}
/>
{/* A photo of a saved or queued inspection is never removed from a form. */}
{photo.state === "draft" ? (
<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}
/>
) : (
<p className="flex min-h-11 items-center justify-center text-sm text-muted-foreground">
Gespeichert
</p>
)}
</li>
);
})}
@@ -214,11 +221,12 @@ function PhotoCapture({ step, sessionId, date, onChange }: PhotoCaptureFieldProp
aria-hidden
onChange={handleFile}
/>
{/* Present while empty (only visually hidden), or its first message is not announced. */}
<p
role="status"
aria-live="polite"
className={cn(
"text-sm empty:hidden",
"text-sm empty:sr-only",
notice.isError ? "font-medium text-destructive" : "text-muted-foreground",
)}
>
@@ -0,0 +1,89 @@
"use client";
import { Field } from "@/components/form/field";
import { Input } from "@/components/ui/input";
import { VarroaRateLight } from "@/components/varroa/varroa-rate-light";
import { emptyToNull } from "@/lib/forms/empty-to-null";
import { mitesPerDay } from "@/lib/varroa/rate";
interface BulkMiteCountsProps {
/** The chosen colonies, in the order they are listed. */
colonies: readonly { id: string; code: string }[];
/** The typed count of each colony, by id. */
counts: Readonly<Record<string, string>>;
onCountChange: (colonyId: string, count: string) => void;
/** Shared by all colonies; with a natural mite fall it gives each one its rate. */
durationMinutes: string;
showsRate: boolean;
threshold: number | null;
errors: Readonly<Record<string, string | undefined>>;
}
/**
* One mite count per colony - there is no shared count - and, for a natural
* mite fall, the rate with its traffic light right under each count.
*/
export function BulkMiteCounts({
colonies,
counts,
onCountChange,
durationMinutes,
showsRate,
threshold,
errors,
}: BulkMiteCountsProps) {
const missing = colonies.filter(({ id }) => (counts[id] ?? "").trim() === "");
return (
<div className="flex flex-col gap-4">
<p role="status" aria-live="polite" className="text-sm font-medium">
{missing.length > 0
? `Noch ohne Milbenzahl: ${missing.map(({ code }) => code).join(", ")}`
: colonies.length > 0
? "Jedes Volk hat eine Milbenzahl."
: ""}
</p>
<ul className="flex flex-col gap-4">
{colonies.map(({ id, code }) => {
const count = counts[id] ?? "";
return (
<li key={id}>
<div
role="group"
aria-label={code}
className="flex flex-col gap-2"
>
<Field label={`Milbenzahl ${code}`} required error={errors[id]}>
{({ id: inputId, describedBy, invalid }) => (
<Input
id={inputId}
data-field={`miteCount-${id}`}
aria-describedby={describedBy}
aria-invalid={invalid}
type="number"
inputMode="numeric"
min={0}
className="h-11"
value={count}
onChange={(event) =>
onCountChange(id, event.target.value)
}
/>
)}
</Field>
{showsRate ? (
<VarroaRateLight
rate={mitesPerDay(
emptyToNull(count, Number),
emptyToNull(durationMinutes, Number),
)}
threshold={threshold}
/>
) : null}
</div>
</li>
);
})}
</ul>
</div>
);
}