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

@@ -1,3 +1,4 @@
import "fake-indexeddb/auto";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
@@ -99,6 +100,23 @@ describe("ApiaryColoniesCard", () => {
expect(screen.getByRole("button", { name: "Runde starten" })).not.toHaveAttribute("href");
});
it("offers „Für offline laden“ in the colonies card", async () => {
vi.mocked(fetchColonyOverview).mockResolvedValue(overview([row({ id: "c-1", code: "A1" })]));
renderCard();
const heading = await screen.findByRole("heading", { name: "Völker (1)" });
const card = heading.closest("[data-slot=card]") as HTMLElement;
expect(within(card).getByRole("button", { name: "Für offline laden" })).toBeInTheDocument();
});
it("asks for the overview with the signed-in user, so it can answer from the pack offline", async () => {
vi.mocked(fetchColonyOverview).mockResolvedValue(overview([]));
renderCard();
await screen.findByText(/Noch keine Völker/);
expect(fetchColonyOverview).toHaveBeenCalledWith("a-1", "user-1");
});
it("shows a retryable error instead of an empty list when loading fails", async () => {
vi.mocked(fetchColonyOverview).mockRejectedValue(new Error("boom"));
renderCard();
@@ -3,6 +3,7 @@
import Link from "next/link";
import { ChevronRight, Play, Sprout } from "lucide-react";
import { useColonyOverview } from "@/lib/apiaries/use-colony-overview";
import type { ColonyOverview } from "@/lib/apiaries/field-types";
import { useCurrentUserId } from "@/lib/auth/auth-context";
import { COLONY_STATUS_LABELS } from "@/lib/colonies/labels";
import { colonyStatusLight } from "@/lib/colonies/status-light";
@@ -10,6 +11,7 @@ import { todayLocalIsoDate } from "@/lib/dates";
import { startRound } from "@/lib/rounds/round-state";
import { StatusLightBadge } from "@/components/colonies/status-light-badge";
import { EmptyState } from "@/components/empty-state";
import { LoadPackButton } from "@/components/offline/load-pack-button";
import { ErrorState, LoadingState } from "@/components/query-state";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -52,10 +54,49 @@ export function StartRoundButton({ apiary }: { apiary: ApiaryRef }) {
);
}
interface ColonyOverviewLinksProps {
overview: ColonyOverview;
/** Where a colony row leads - the colony page, or its offline view. */
hrefFor: (colonyId: string) => string;
}
/** One row per living colony: code, traffic light (icon and word), status and reasons. */
export function ColonyOverviewLinks({ overview, hrefFor }: ColonyOverviewLinksProps) {
const today = todayLocalIsoDate();
return overview.rows.map((row) => {
const { light, reasons } = colonyStatusLight(row, {
today,
inspectionsVisible: overview.inspectionsVisible,
});
return (
<Link
key={row.id}
href={hrefFor(row.id)}
className="flex min-h-14 items-center gap-3 rounded-xl border border-border/60 px-3 py-2 hover:bg-muted/50 focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
>
<span className="flex min-w-0 flex-1 flex-col gap-1">
<span className="flex flex-wrap items-center gap-2">
<span className="font-medium">{row.code}</span>
<StatusLightBadge light={light} />
<span className="text-sm text-muted-foreground">{COLONY_STATUS_LABELS[row.status]}</span>
</span>
{reasons.length > 0 ? (
<span className="text-sm text-muted-foreground">{reasons.join(" · ")}</span>
) : null}
</span>
<ChevronRight className="size-4 shrink-0 text-muted-foreground" aria-hidden />
</Link>
);
});
}
function colonyPageHref(colonyId: string): string {
return `/colonies/${colonyId}`;
}
export function ApiaryColoniesCard({ apiary }: { apiary: ApiaryRef }) {
const query = useColonyOverview(apiary.id);
const overview = query.data;
const today = todayLocalIsoDate();
return (
<Card>
@@ -76,31 +117,10 @@ export function ApiaryColoniesCard({ apiary }: { apiary: ApiaryRef }) {
description="Sobald ein Volk hier steht, erscheint es mit seiner Ampel."
/>
) : null}
{overview?.rows.map((row) => {
const { light, reasons } = colonyStatusLight(row, {
today,
inspectionsVisible: overview.inspectionsVisible,
});
return (
<Link
key={row.id}
href={`/colonies/${row.id}`}
className="flex min-h-14 items-center gap-3 rounded-xl border border-border/60 px-3 py-2 hover:bg-muted/50 focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
>
<span className="flex min-w-0 flex-1 flex-col gap-1">
<span className="flex flex-wrap items-center gap-2">
<span className="font-medium">{row.code}</span>
<StatusLightBadge light={light} />
<span className="text-sm text-muted-foreground">{COLONY_STATUS_LABELS[row.status]}</span>
</span>
{reasons.length > 0 ? (
<span className="text-sm text-muted-foreground">{reasons.join(" · ")}</span>
) : null}
</span>
<ChevronRight className="size-4 shrink-0 text-muted-foreground" aria-hidden />
</Link>
);
})}
{overview ? <ColonyOverviewLinks overview={overview} hrefFor={colonyPageHref} /> : null}
<div className="mt-2">
<LoadPackButton apiaryId={apiary.id} />
</div>
</CardContent>
</Card>
);
@@ -0,0 +1,48 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, render, screen } from "@testing-library/react";
import { reportRequestOutcome } from "@/lib/offline/connectivity";
import { EditTriggerButton } from "./edit-dialog-parts";
let sessionMode: "online" | "offline" = "online";
vi.mock("@/lib/auth/auth-context", () => ({ useSessionMode: () => sessionMode }));
describe("EditTriggerButton", () => {
afterEach(() => {
sessionMode = "online";
reportRequestOutcome("reached-server");
});
it("is a normal „Bearbeiten“ button while online", () => {
render(<EditTriggerButton />);
expect(screen.getByRole("button", { name: "Bearbeiten" })).toBeEnabled();
});
it("is disabled offline and says why", () => {
act(() => reportRequestOutcome("network-error"));
render(<EditTriggerButton />);
expect(screen.getByRole("button", { name: "Bearbeiten braucht Netz" })).toBeDisabled();
});
it("is disabled in an offline session even when the network looks fine", () => {
sessionMode = "offline";
render(<EditTriggerButton />);
expect(screen.getByRole("button", { name: "Bearbeiten braucht Netz" })).toBeDisabled();
});
it("stays disabled when the caller disables it, and keeps the caller's other props", () => {
render(<EditTriggerButton disabled data-testid="edit" />);
expect(screen.getByTestId("edit")).toBeDisabled();
expect(screen.getByTestId("edit")).toHaveTextContent("Bearbeiten");
});
it("wraps its text on a narrow screen instead of overflowing", () => {
act(() => reportRequestOutcome("network-error"));
render(<EditTriggerButton />);
expect(screen.getByRole("button")).toHaveClass("h-auto", "whitespace-normal");
});
});
@@ -3,16 +3,33 @@
import { ComponentProps } from "react";
import { Pencil } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useSessionMode } from "@/lib/auth/auth-context";
import { useConnectivity } from "@/lib/offline/connectivity";
import { cn } from "@/lib/utils";
/**
* The "Bearbeiten" trigger of every record in a list: at least 40 px high on
* a phone, so it can be hit with a gloved thumb.
* a phone, so it can be hit with a gloved thumb. Saved records can only be
* changed with a connection (edits are not queued), so offline - or in an
* offline session without a token - it is disabled and says why.
*/
export function EditTriggerButton(props: ComponentProps<typeof Button>) {
export function EditTriggerButton({
disabled,
className,
...props
}: ComponentProps<typeof Button>) {
const connectivity = useConnectivity();
const sessionMode = useSessionMode();
const needsNetwork = connectivity === "offline" || sessionMode === "offline";
return (
<Button variant="ghost" {...props}>
<Button
variant="ghost"
{...props}
disabled={disabled || needsNetwork}
className={cn(needsNetwork && "h-auto whitespace-normal text-left", className)}
>
<Pencil className="size-4" aria-hidden />
Bearbeiten
{needsNetwork ? "Bearbeiten braucht Netz" : "Bearbeiten"}
</Button>
);
}
@@ -1,5 +1,7 @@
import type { createFeeding } from "@/lib/feedings/api";
import type { FeedType } from "@/lib/feedings/types";
import type { BulkCreateFeedingInput } from "@/lib/bulk/api";
import type { CaptureTarget } from "@/components/form/capture-target-toggle";
import { toApiDate, todayLocalIsoDate } from "@/lib/dates";
import { emptyToNull } from "@/lib/forms/empty-to-null";
@@ -13,6 +15,10 @@ export const FEEDING_UNIT_OPTIONS: { value: string; label: string }[] = [
/** Everything the form holds, as plain values that survive a draft save. */
export interface FeedingFormValues {
colonyId: string;
/** One colony, or the same feeding for several. */
target: CaptureTarget;
bulkApiaryId: string | null;
bulkColonyIds: string[];
date: string;
feedType: FeedType;
amount: string;
@@ -25,6 +31,9 @@ export interface FeedingFormValues {
export function initialFeedingValues(colonyId: string): FeedingFormValues {
return {
colonyId,
target: "single",
bulkApiaryId: null,
bulkColonyIds: [],
date: todayLocalIsoDate(),
feedType: "SUGAR_WATER",
amount: "",
@@ -35,11 +44,9 @@ export function initialFeedingValues(colonyId: string): FeedingFormValues {
};
}
export function toFeedingInput(
values: FeedingFormValues,
): Parameters<typeof createFeeding>[0] {
/** Everything of the feeding that is the same for one colony or for many. */
function sharedFeedingFields(values: FeedingFormValues) {
return {
colonyId: values.colonyId,
date: toApiDate(values.date),
feedType: values.feedType,
amount: Number(values.amount),
@@ -49,3 +56,14 @@ export function toFeedingInput(
notes: emptyToNull(values.notes),
};
}
export function toFeedingInput(
values: FeedingFormValues,
): Parameters<typeof createFeeding>[0] {
return { colonyId: values.colonyId, ...sharedFeedingFields(values) };
}
/** One feeding for the chosen colonies; the batch number is what the user typed. */
export function toBulkFeedingInput(values: FeedingFormValues): BulkCreateFeedingInput {
return { ...sharedFeedingFields(values), colonyIds: values.bulkColonyIds };
}
@@ -1,9 +1,13 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import NewFeedingPage from "./page";
import { toast } from "sonner";
import { createFeeding } from "@/lib/feedings/api";
import { createFeedingsBulk } from "@/lib/bulk/api";
import { queueBulk } from "@/lib/offline/db";
import { fetchApiaries } from "@/lib/apiaries/api";
import { fetchColonies } from "@/lib/colonies/api";
const push = vi.fn();
@@ -13,7 +17,19 @@ vi.mock("next/navigation", () => ({
}));
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() }));
vi.mock("@/lib/feedings/api", () => ({ createFeeding: vi.fn() }));
vi.mock("@/lib/offline/db", () => ({ queueFeeding: vi.fn() }));
vi.mock("@/lib/bulk/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/bulk/api")>()),
createFeedingsBulk: vi.fn(),
}));
vi.mock("@/lib/apiaries/api", () => ({ fetchApiaries: vi.fn() }));
vi.mock("@/lib/apiaries/offline-pack", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/apiaries/offline-pack")>()),
listPacks: vi.fn().mockResolvedValue([]),
refreshPacksAfterSync: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/auth-context", () => ({ useCurrentUserId: () => "user-1" }));
vi.mock("@/lib/offline/db", () => ({ queueFeeding: vi.fn(), queueBulk: vi.fn() }));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), info: vi.fn(), error: vi.fn() } }));
function renderPage() {
const client = new QueryClient({
@@ -34,6 +50,11 @@ describe("NewFeedingPage", () => {
{ id: "colony-1", code: "V-01", status: "ACTIVE" },
] as never);
vi.mocked(createFeeding).mockReset();
vi.mocked(createFeedingsBulk).mockReset();
vi.mocked(queueBulk).mockReset().mockResolvedValue(undefined);
vi.mocked(fetchApiaries)
.mockReset()
.mockResolvedValue([{ id: "apiary-1", name: "Waldrand" }] as never);
});
it("marks what is required and keeps the extra fields folded", () => {
@@ -86,4 +107,65 @@ describe("NewFeedingPage", () => {
expect(screen.getByLabelText(/^Menge/)).toHaveFocus();
expect(createFeeding).not.toHaveBeenCalled();
});
describe("for several colonies", () => {
const BULK_COLONIES = [
{ id: "colony-1", code: "V-01", status: "ACTIVE", apiaryId: "apiary-1" },
{ id: "colony-2", code: "V-02", status: "ACTIVE", apiaryId: "apiary-1" },
{ id: "colony-3", code: "V-03", status: "QUEENLESS", apiaryId: "apiary-1" },
];
async function fillBulkForm() {
vi.mocked(fetchColonies).mockResolvedValue(BULK_COLONIES as never);
renderPage();
await userEvent.click(screen.getByRole("radio", { name: "Mehrere Völker" }));
await userEvent.click(screen.getByRole("combobox", { name: /Stand/ }));
await userEvent.click(await screen.findByRole("option", { name: "Waldrand" }));
await screen.findByText("3 von 3 Völkern gewählt");
await userEvent.click(screen.getByRole("checkbox", { name: "V-02" }));
await userEvent.type(screen.getByLabelText(/^Menge/), "2");
}
it("bulk mode sends colonyIds with the shared feeding", async () => {
vi.mocked(createFeedingsBulk).mockResolvedValue({ bulkId: "b-1", records: [] } as never);
await fillBulkForm();
await userEvent.click(screen.getByRole("button", { name: "Fütterung speichern" }));
await waitFor(() => expect(createFeedingsBulk).toHaveBeenCalledTimes(1));
const [input, options] = vi.mocked(createFeedingsBulk).mock.calls[0];
expect(input).toMatchObject({
colonyIds: ["colony-1", "colony-3"],
feedType: "SUGAR_WATER",
amount: 2,
unit: "l",
});
expect(input).not.toHaveProperty("colonyId");
expect(options?.idempotencyKey).toBeTruthy();
expect(createFeeding).not.toHaveBeenCalled();
expect(toast.success).toHaveBeenCalledWith("Fütterung für 2 Völker gespeichert");
expect(push).toHaveBeenCalledWith("/apiaries/apiary-1");
});
it("offline queues exactly one bulk entry", async () => {
vi.mocked(createFeedingsBulk).mockRejectedValue(new TypeError("Failed to fetch"));
await fillBulkForm();
await userEvent.click(screen.getByRole("button", { name: "Fütterung speichern" }));
await waitFor(() => expect(queueBulk).toHaveBeenCalledTimes(1));
expect(vi.mocked(queueBulk).mock.calls[0][0]).toBe("feedings");
expect(vi.mocked(queueBulk).mock.calls[0][1]).toMatchObject({
colonyIds: ["colony-1", "colony-3"],
});
await waitFor(() => expect(push).toHaveBeenCalledWith("/apiaries/apiary-1"));
});
it("says the batch number counts for every chosen colony", async () => {
await fillBulkForm();
await userEvent.click(screen.getByRole("button", { name: /Weitere Angaben/ }));
expect(screen.getByText(/Gilt für alle gewählten Völker\./)).toBeInTheDocument();
});
});
});
@@ -6,6 +6,15 @@ import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Utensils } from "lucide-react";
import { createFeeding } from "@/lib/feedings/api";
import { createFeedingsBulk } from "@/lib/bulk/api";
import {
bulkDestination,
bulkQueuedText,
bulkSavedText,
bulkSelectionError,
} from "@/lib/bulk/capture-texts";
import { useApiaryColonies } from "@/lib/bulk/use-apiary-colonies";
import { useBulkCapture } from "@/lib/bulk/use-bulk-capture";
import { FEED_TYPES, FEED_TYPE_LABELS } from "@/lib/feedings/labels";
import { queueFeeding } from "@/lib/offline/db";
import { useCaptureMutation } from "@/lib/offline/use-capture-mutation";
@@ -19,7 +28,8 @@ import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Field } from "@/components/form/field";
import { ChoiceGroup, choiceOptions } from "@/components/form/choice-group";
import { LivingColonySelectField } from "@/components/colony-select-field";
import { BulkSubmitNote } from "@/components/form/bulk-submit-note";
import { CaptureColonyField } from "@/components/form/capture-colony-field";
import { FormSection } from "@/components/form/form-section";
import { StickySubmit } from "@/components/form/sticky-submit";
import { DraftBanner } from "@/components/form/draft-banner";
@@ -27,17 +37,27 @@ import {
FEEDING_UNIT_OPTIONS,
FeedingFormValues,
initialFeedingValues,
toBulkFeedingInput,
toFeedingInput,
} from "./feeding-form-values";
type RequiredKey = "colonyId" | "date" | "amount";
type RequiredKey = "colonyId" | "bulkColonies" | "date" | "amount";
type FieldErrors = Partial<Record<RequiredKey, string>>;
const REQUIRED_KEYS: RequiredKey[] = ["colonyId", "date", "amount"];
const SHARED_NOTE = "Gilt für alle gewählten Völker.";
const BATCH_HINT = "Nummer der Futterpackung, meist „Ch.-B.“ oder „Lot“.";
function requiredKeys(values: FeedingFormValues): RequiredKey[] {
return [values.target === "bulk" ? "bulkColonies" : "colonyId", "date", "amount"];
}
function validate(values: FeedingFormValues): FieldErrors {
const errors: FieldErrors = {};
if (!values.colonyId) errors.colonyId = "Bitte ein Volk auswählen.";
if (values.target === "bulk") {
errors.bulkColonies = bulkSelectionError(values.bulkApiaryId, values.bulkColonyIds);
} else if (!values.colonyId) {
errors.colonyId = "Bitte ein Volk auswählen.";
}
if (!values.date) errors.date = "Bitte ein Datum angeben.";
if (!values.amount.trim()) {
errors.amount = "Bitte eine Menge angeben.";
@@ -72,11 +92,33 @@ export default function NewFeedingPage() {
) {
setValues((current) => ({ ...current, [key]: value }));
setDirty(true);
if ((REQUIRED_KEYS as string[]).includes(key)) {
setFieldErrors((current) => ({ ...current, [key]: undefined }));
}
// The colonies of a bulk capture are one field in three values.
const field = key === "bulkColonyIds" || key === "bulkApiaryId" ? "bulkColonies" : key;
setFieldErrors((current) => ({ ...current, [field]: undefined }));
}
const isBulk = values.target === "bulk";
const apiaryColonies = useApiaryColonies(isBulk ? values.bulkApiaryId : null);
const bulkCapture = useBulkCapture({
kind: "feedings",
create: createFeedingsBulk,
failureMessage: "Fütterung konnte nicht gespeichert werden.",
onSaved: (outcome, input) => {
draft.clear();
const count = input.colonyIds.length;
if (outcome.via === "queued") {
toast.info(bulkQueuedText("Fütterung", count));
} else {
for (const colonyId of input.colonyIds) {
void queryClient.invalidateQueries({ queryKey: ["colonies", colonyId, "feedings"] });
}
toast.success(bulkSavedText("Fütterung", count));
}
router.push(bulkDestination(values.bulkApiaryId));
},
});
const capture = useCaptureMutation({
create: createFeeding,
queue: queueFeeding,
@@ -106,20 +148,38 @@ export default function NewFeedingPage() {
event.preventDefault();
const errors = validate(values);
setFieldErrors(errors);
const firstInvalid = REQUIRED_KEYS.find((key) => errors[key]);
const firstInvalid = requiredKeys(values).find((key) => errors[key]);
if (firstInvalid) {
document
.querySelector<HTMLElement>(`[data-field="${firstInvalid}"]`)
?.focus();
return;
}
if (isBulk) {
bulkCapture.submit(toBulkFeedingInput(values));
return;
}
capture.submit(toFeedingInput(values));
}
/** „Ohne V-3 senden“: the refused colonies leave the selection, the rest is sent. */
function resendWithoutRefused() {
const refused = new Set(bulkCapture.invalidColonyIds);
const next = {
...values,
bulkColonyIds: values.bulkColonyIds.filter((id) => !refused.has(id)),
};
setValues(next);
if (next.bulkColonyIds.length > 0) {
bulkCapture.submit(toBulkFeedingInput(next));
}
}
function restoreDraft() {
const restored = draft.restore();
if (restored) {
setValues(restored);
// A draft from before the bulk choice existed lacks its values.
setValues({ ...initialFeedingValues(""), ...restored });
setDirty(true);
}
}
@@ -152,11 +212,21 @@ export default function NewFeedingPage() {
noValidate
>
<FormSection step={1} title="Was, welches Volk, wann?">
<LivingColonySelectField
triggerId="feeding-colony"
value={values.colonyId}
onChange={(colonyId) => set("colonyId", colonyId)}
error={fieldErrors.colonyId}
<CaptureColonyField
idPrefix="feeding"
target={values.target}
onTargetChange={(target) => set("target", target)}
colonyId={values.colonyId}
onColonyChange={(colonyId) => set("colonyId", colonyId)}
colonyError={fieldErrors.colonyId}
bulkApiaryId={values.bulkApiaryId}
onBulkApiaryChange={(apiaryId) => {
set("bulkApiaryId", apiaryId);
set("bulkColonyIds", []);
}}
bulkColonyIds={values.bulkColonyIds}
onBulkColonyIdsChange={(ids) => set("bulkColonyIds", ids)}
bulkError={fieldErrors.bulkColonies}
/>
<Field label="Datum" required error={fieldErrors.date}>
{({ id, describedBy, invalid }) => (
@@ -237,7 +307,7 @@ export default function NewFeedingPage() {
</Field>
<Field
label="Chargennummer"
hint="Nummer der Futterpackung, meist „Ch.-B.“ oder „Lot“."
hint={isBulk ? `${BATCH_HINT} ${SHARED_NOTE}` : BATCH_HINT}
>
{({ id, describedBy }) => (
<Input
@@ -262,7 +332,16 @@ export default function NewFeedingPage() {
<StickySubmit
note={
capture.submitError ? (
isBulk ? (
<BulkSubmitNote
submitError={bulkCapture.submitError}
isRetryLater={bulkCapture.isRetryLater}
invalidColonyIds={bulkCapture.invalidColonyIds}
colonies={apiaryColonies.data?.items}
isPending={bulkCapture.isPending}
onResendWithout={resendWithoutRefused}
/>
) : capture.submitError ? (
<p
role="alert"
className="text-sm font-medium text-destructive"
@@ -275,9 +354,9 @@ export default function NewFeedingPage() {
<Button
type="submit"
className="h-12 text-base"
disabled={capture.isPending}
disabled={capture.isPending || bulkCapture.isPending}
>
{capture.isPending
{capture.isPending || bulkCapture.isPending
? "Wird gespeichert…"
: "Fütterung speichern"}
</Button>
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -11,14 +11,15 @@ import type { DailyWeather } from "@/lib/weather/types";
import { toLocalIsoDate, todayLocalIsoDate } from "@/lib/dates";
import { fetchColonies } from "@/lib/colonies/api";
import { fetchColonyOverview } from "@/lib/apiaries/field-api";
import { queueInspection } from "@/lib/offline/db";
import type { StoredPhoto } from "@/lib/offline/db";
import { syncPendingEntries } from "@/lib/offline/sync";
import { OFFLINE_QUEUE_QUERY_KEY } from "@/lib/offline/use-capture-mutation";
import {
attachSessionToInspection,
attachSessionToQueuedInspection,
handOverSessionPhotos,
discardSessionPhotos,
listSessionPhotos,
releaseSessionPhotos,
moveDraftPhotos,
queueInspectionWithPhotos,
} from "@/lib/photos/store";
import { DRAFT_PREFIX } from "@/lib/forms/draft-storage";
import { initialInspectionValues } from "./inspection-form-values";
@@ -47,16 +48,15 @@ vi.mock("@/lib/inspections/api", () => ({
}));
vi.mock("@/lib/weather/api", () => ({ fetchApiaryWeather: vi.fn() }));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }));
vi.mock("@/lib/offline/db", () => ({ queueInspection: vi.fn() }));
vi.mock("@/lib/offline/sync", () => ({ syncPendingEntries: vi.fn() }));
vi.mock("@/lib/photos/store", () => ({
PHOTOS_PER_INSPECTION_MAX: 5,
listSessionPhotos: vi.fn(),
addDraftPhoto: vi.fn(),
removePhoto: vi.fn(),
attachSessionToInspection: vi.fn(),
attachSessionToQueuedInspection: vi.fn(),
releaseSessionPhotos: vi.fn(),
handOverSessionPhotos: vi.fn(),
queueInspectionWithPhotos: vi.fn(),
moveDraftPhotos: vi.fn(),
discardSessionPhotos: vi.fn(),
}));
@@ -66,11 +66,17 @@ function renderPage() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
const rendered = render(
<QueryClientProvider client={client}>
<NewInspectionPage />
</QueryClientProvider>,
);
return { ...rendered, client };
}
/** jsdom applies no CSS: a class that would take the element out of the accessibility tree. */
function hidesWhileEmpty(element: HTMLElement): boolean {
return [...element.classList].some((name) => /(^|:)hidden$/.test(name));
}
describe("NewInspectionPage", () => {
@@ -87,11 +93,12 @@ describe("NewInspectionPage", () => {
vi.mocked(fetchApiaryWeather).mockResolvedValue({ daily: [] });
vi.mocked(toast.info).mockReset();
vi.mocked(toast.success).mockReset();
vi.mocked(toast.error).mockReset();
permissions = new Set();
vi.mocked(listSessionPhotos).mockReset().mockResolvedValue([]);
vi.mocked(attachSessionToInspection).mockReset().mockResolvedValue(undefined);
vi.mocked(attachSessionToQueuedInspection).mockReset().mockResolvedValue(undefined);
vi.mocked(releaseSessionPhotos).mockReset().mockResolvedValue(undefined);
vi.mocked(handOverSessionPhotos).mockReset().mockResolvedValue(0);
vi.mocked(queueInspectionWithPhotos).mockReset().mockResolvedValue(0);
vi.mocked(moveDraftPhotos).mockReset().mockResolvedValue({ moved: 0, discarded: 0 });
vi.mocked(discardSessionPhotos).mockReset().mockResolvedValue(undefined);
vi.mocked(syncPendingEntries).mockReset().mockResolvedValue(NOTHING_SENT);
});
@@ -453,6 +460,15 @@ describe("NewInspectionPage", () => {
expect(screen.getByRole("status")).toBeEmptyDOMElement();
});
it("keeps the weather announcement region present while it is empty", () => {
renderPage();
// Hidden while empty, it would swallow the first announcement.
const live = screen.getByRole("status");
expect(live).toBeEmptyDOMElement();
expect(hidesWhileEmpty(live)).toBe(false);
});
});
describe("in a stand round", () => {
@@ -480,7 +496,6 @@ describe("NewInspectionPage", () => {
overviewOf(["colony-1", "colony-2", "colony-3"]) as never,
);
vi.mocked(createInspection).mockResolvedValue({ id: "i-1", colonyId: "colony-1" } as never);
vi.mocked(queueInspection).mockReset();
startRound("user-1", apiary, colonies);
});
@@ -519,6 +534,21 @@ describe("NewInspectionPage", () => {
expect(readRound("user-1")?.doneColonyIds).toEqual(["colony-2", "colony-3"]);
});
it("does not bring back the draft of an inspection saved right after the last change", async () => {
const draftKey = `${DRAFT_PREFIX}user-1:inspection-new`;
renderPage();
await screen.findByText("Runde Obstwiese: 1 von 3");
// Saved well within the draft's 600 ms delay; the form stays mounted behind the panel.
await userEvent.type(screen.getByLabelText(/^Wetter/), "x");
await save();
expect(await screen.findByRole("button", { name: "Weiter mit B3" })).toBeInTheDocument();
await act(() => new Promise<void>((resolve) => setTimeout(resolve, 800)));
// „Weiter mit B3“ would otherwise offer this saved entry as a draft.
expect(window.localStorage.getItem(draftKey)).toBeNull();
});
it("after save shows „Weiter mit B3“ instead of going to the colony", async () => {
renderPage();
await screen.findByText("Runde Obstwiese: 1 von 3");
@@ -558,13 +588,12 @@ describe("NewInspectionPage", () => {
it("an offline save also advances the round", async () => {
vi.mocked(createInspection).mockRejectedValue(new TypeError("Failed to fetch"));
vi.mocked(queueInspection).mockResolvedValue(undefined);
renderPage();
await screen.findByText("Runde Obstwiese: 1 von 3");
await save();
expect(await screen.findByRole("button", { name: "Weiter mit B3" })).toBeInTheDocument();
expect(queueInspection).toHaveBeenCalled();
expect(queueInspectionWithPhotos).toHaveBeenCalled();
expect(readRound("user-1")?.doneColonyIds).toEqual(["colony-1"]);
expect(push).not.toHaveBeenCalled();
});
@@ -595,10 +624,25 @@ describe("NewInspectionPage", () => {
describe("photos", () => {
const DRAFT_KEY = `${DRAFT_PREFIX}user-1:inspection-new`;
const PHOTO: StoredPhoto = {
id: "photo-1",
sessionId: "any",
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: "draft",
uploadIdempotencyKey: "upload-key-0000000001",
};
beforeAll(() => {
// jsdom has no object URLs; the thumbnails only need a string.
URL.createObjectURL = vi.fn(() => "blob:photo");
URL.revokeObjectURL = vi.fn();
});
beforeEach(() => {
permissions = new Set(["documents:upload"]);
vi.mocked(queueInspection).mockReset();
});
afterEach(() => {
@@ -621,6 +665,19 @@ describe("NewInspectionPage", () => {
);
}
/** Types into the form and waits until its draft is on the device. */
async function typeUntilDrafted() {
await userEvent.type(screen.getByLabelText(/^Wetter/), "sonnig");
await waitFor(() => expect(window.localStorage.getItem(DRAFT_KEY)).not.toBeNull(), {
timeout: 2000,
});
}
/** Lets already runnable promise callbacks run. */
function settle(): Promise<void> {
return act(() => new Promise<void>((resolve) => setTimeout(resolve, 0)));
}
async function save() {
await userEvent.click(screen.getByRole("button", { name: "Durchsicht speichern" }));
}
@@ -635,52 +692,137 @@ describe("NewInspectionPage", () => {
);
});
it("hands the photos to the saved inspection and sends them in the background", async () => {
it("hands the photos to the saved inspection, sends them and refreshes what shows them", async () => {
vi.mocked(createInspection).mockResolvedValue({ id: "i-1", colonyId: "colony-1" } as never);
renderPage();
vi.mocked(handOverSessionPhotos).mockResolvedValue(2);
vi.mocked(syncPendingEntries).mockResolvedValue({ synced: 2, failed: 0, skippedForeign: 0 });
const { client } = renderPage();
const invalidate = vi.spyOn(client, "invalidateQueries");
await waitFor(() => expect(listSessionPhotos).toHaveBeenCalled());
await save();
await waitFor(() => expect(syncPendingEntries).toHaveBeenCalledWith("user-1"));
expect(attachSessionToInspection).toHaveBeenCalledWith(shownSession(), "i-1");
expect(vi.mocked(attachSessionToInspection).mock.invocationCallOrder[0]).toBeLessThan(
await waitFor(() =>
expect(invalidate).toHaveBeenCalledWith({ queryKey: ["documents", "INSPECTION", "i-1"] }),
);
// The band's count and the inspection's document list.
expect(invalidate).toHaveBeenCalledWith({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
expect(handOverSessionPhotos).toHaveBeenCalledWith({
userId: "user-1",
sessionId: shownSession(),
inspectionId: "i-1",
});
// A sync already running may have passed these photos: it runs once more.
expect(syncPendingEntries).toHaveBeenCalledWith("user-1", { afterChange: true });
expect(vi.mocked(handOverSessionPhotos).mock.invocationCallOrder[0]).toBeLessThan(
vi.mocked(syncPendingEntries).mock.invocationCallOrder[0],
);
expect(attachSessionToQueuedInspection).not.toHaveBeenCalled();
expect(vi.mocked(syncPendingEntries).mock.invocationCallOrder[0]).toBeLessThan(
invalidate.mock.invocationCallOrder[invalidate.mock.invocationCallOrder.length - 1],
);
expect(queueInspectionWithPhotos).not.toHaveBeenCalled();
});
it("ties the photos to the queued inspection before it is queued", async () => {
it("hands over the photos of the form that was saved, even when a draft is continued meanwhile", async () => {
storeDraft("draft-session");
let finishSaving: (inspection: never) => void = () => undefined;
vi.mocked(createInspection).mockReturnValue(
new Promise((resolve) => {
finishSaving = resolve as typeof finishSaving;
}),
);
vi.mocked(handOverSessionPhotos).mockResolvedValue(0);
renderPage();
await waitFor(() => expect(listSessionPhotos).toHaveBeenCalled());
const savedFormSession = shownSession();
await save();
await userEvent.click(await screen.findByRole("button", { name: "Weitermachen" }));
await waitFor(() => expect(shownSession()).toBe("draft-session"));
await act(async () => finishSaving({ id: "i-1", colonyId: "colony-1" } as never));
await waitFor(() => expect(handOverSessionPhotos).toHaveBeenCalled());
expect(handOverSessionPhotos).toHaveBeenCalledWith({
userId: "user-1",
sessionId: savedFormSession,
inspectionId: "i-1",
});
});
it("sends nothing after an online save without photos", async () => {
vi.mocked(createInspection).mockResolvedValue({ id: "i-1", colonyId: "colony-1" } as never);
vi.mocked(handOverSessionPhotos).mockResolvedValue(0);
renderPage();
await save();
await waitFor(() => expect(handOverSessionPhotos).toHaveBeenCalled());
await settle();
expect(syncPendingEntries).not.toHaveBeenCalled();
expect(push).toHaveBeenCalledWith("/colonies/colony-1");
});
it("clears the draft of the saved inspection at once, also when its photos must wait", async () => {
vi.mocked(listSessionPhotos).mockResolvedValue([PHOTO]);
vi.mocked(createInspection).mockResolvedValue({ id: "i-1", colonyId: "colony-1" } as never);
// The hand-over stays noted; the next start or sync finishes it.
vi.mocked(handOverSessionPhotos).mockRejectedValue(
new Error("The database connection is closing."),
);
renderPage();
expect(await screen.findByRole("img", { name: "Foto 1" })).toBeInTheDocument();
await typeUntilDrafted();
await save();
// Never offered again: restoring it would save the inspection twice.
expect(window.localStorage.getItem(DRAFT_KEY)).toBeNull();
await waitFor(() => expect(toast.info).toHaveBeenCalledWith("Fotos werden später gesendet."));
expect(toast.error).not.toHaveBeenCalled();
expect(syncPendingEntries).not.toHaveBeenCalled();
});
it("queues the inspection together with its photos", async () => {
vi.mocked(createInspection).mockRejectedValue(new TypeError("Failed to fetch"));
vi.mocked(queueInspection).mockResolvedValue(undefined);
vi.mocked(queueInspectionWithPhotos).mockResolvedValue(1);
renderPage();
await waitFor(() => expect(listSessionPhotos).toHaveBeenCalled());
await save();
await waitFor(() => expect(queueInspection).toHaveBeenCalled());
const [, idempotencyKey] = vi.mocked(queueInspection).mock.calls[0];
expect(attachSessionToQueuedInspection).toHaveBeenCalledWith(shownSession(), idempotencyKey);
// A sync that sends the inspection right away already finds its photos.
expect(vi.mocked(attachSessionToQueuedInspection).mock.invocationCallOrder[0]).toBeLessThan(
vi.mocked(queueInspection).mock.invocationCallOrder[0],
await waitFor(() => expect(queueInspectionWithPhotos).toHaveBeenCalled());
expect(queueInspectionWithPhotos).toHaveBeenCalledWith(
shownSession(),
expect.objectContaining({ colonyId: "colony-1" }),
expect.any(String),
"user-1",
);
expect(handOverSessionPhotos).not.toHaveBeenCalled();
expect(toast.info).toHaveBeenCalledWith(
"Kein Netzwerk – Durchsicht wurde offline gespeichert und wird synchronisiert.",
);
expect(attachSessionToInspection).not.toHaveBeenCalled();
expect(releaseSessionPhotos).not.toHaveBeenCalled();
});
it("gives the photos back to the form when the inspection cannot be queued", async () => {
it("keeps the photos with the form when the inspection cannot be queued", async () => {
vi.mocked(createInspection).mockRejectedValue(new TypeError("Failed to fetch"));
vi.mocked(queueInspection).mockRejectedValue(new Error("QuotaExceededError"));
vi.mocked(queueInspectionWithPhotos).mockRejectedValue(new Error("QuotaExceededError"));
renderPage();
await waitFor(() => expect(listSessionPhotos).toHaveBeenCalled());
const formSession = shownSession();
await save();
expect(await screen.findByRole("alert")).toHaveTextContent(
"Durchsicht konnte nicht gespeichert werden.",
);
expect(releaseSessionPhotos).toHaveBeenCalledWith(shownSession());
expect(queueInspectionWithPhotos).toHaveBeenCalledWith(
formSession,
expect.anything(),
expect.any(String),
"user-1",
);
expect(shownSession()).toBe(formSession);
expect(push).not.toHaveBeenCalled();
});
it("„Verwerfen“ of an earlier draft also deletes its photos", async () => {
@@ -693,8 +835,14 @@ describe("NewInspectionPage", () => {
expect(window.localStorage.getItem(DRAFT_KEY)).toBeNull();
});
it("brings the photos of a restored draft back", async () => {
it("brings the photos of a restored draft back and takes the fresh form's photos along", async () => {
storeDraft("draft-session-0001");
let finishMove: (moved: { moved: number; discarded: number }) => void = () => undefined;
vi.mocked(moveDraftPhotos).mockReturnValue(
new Promise((resolve) => {
finishMove = resolve;
}),
);
renderPage();
await waitFor(() => expect(listSessionPhotos).toHaveBeenCalled());
const freshSession = shownSession();
@@ -703,9 +851,34 @@ describe("NewInspectionPage", () => {
await waitFor(() => expect(shownSession()).toBe("draft-session-0001"));
expect(screen.getByDisplayValue("8")).toBeInTheDocument();
// The fresh form's own photos give way to the restored entry, like its values.
expect(discardSessionPhotos).toHaveBeenCalledWith(freshSession);
expect(discardSessionPhotos).not.toHaveBeenCalledWith("draft-session-0001");
// Photos taken before restoring join the restored entry - never deleted unasked.
expect(moveDraftPhotos).toHaveBeenCalledWith(freshSession, "draft-session-0001");
expect(discardSessionPhotos).not.toHaveBeenCalled();
const readsBefore = vi.mocked(listSessionPhotos).mock.calls.length;
await act(async () => finishMove({ moved: 2, discarded: 0 }));
// Read again once they are there.
await waitFor(() =>
expect(vi.mocked(listSessionPhotos).mock.calls.length).toBeGreaterThan(readsBefore),
);
expect(shownSession()).toBe("draft-session-0001");
expect(toast.info).not.toHaveBeenCalled();
});
it("says how many photos did not fit into the restored draft and were deleted", async () => {
storeDraft("draft-session-0001");
vi.mocked(moveDraftPhotos).mockResolvedValue({ moved: 1, discarded: 2 });
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Weitermachen" }));
await waitFor(() =>
expect(toast.info).toHaveBeenCalledWith(
"2 Fotos wurden nicht übernommen und verworfen. Höchstens 5 Fotos je Durchsicht.",
),
);
expect(discardSessionPhotos).not.toHaveBeenCalled();
});
});
});
@@ -1,6 +1,6 @@
"use client";
import { FormEvent, useEffect, useMemo, useState } from "react";
import { FormEvent, useEffect, useMemo, useRef, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
@@ -17,17 +17,18 @@ import {
undoTakeOver,
type TakeoverField,
} from "@/lib/inspections/takeover";
import { useLastInspection } from "@/lib/inspections/use-last-inspection";
import type { CreateInspectionInput, Inspection } from "@/lib/inspections/types";
import { type LastInspection, useLastInspection } from "@/lib/inspections/use-last-inspection";
import type { CreateInspectionInput } from "@/lib/inspections/types";
import { useCurrentUserId } from "@/lib/auth/auth-context";
import { queueInspection } from "@/lib/offline/db";
import { photosNotTakenOver } from "@/lib/offline/queue-texts";
import { syncPendingEntries } from "@/lib/offline/sync";
import { useCaptureMutation } from "@/lib/offline/use-capture-mutation";
import { OFFLINE_QUEUE_QUERY_KEY, useCaptureMutation } from "@/lib/offline/use-capture-mutation";
import {
attachSessionToInspection,
attachSessionToQueuedInspection,
PHOTOS_PER_INSPECTION_MAX,
discardSessionPhotos,
releaseSessionPhotos,
handOverSessionPhotos,
moveDraftPhotos,
queueInspectionWithPhotos,
} from "@/lib/photos/store";
import { toCalendarDay, todayLocalIsoDate } from "@/lib/dates";
import { captureBackTarget } from "@/lib/forms/back-target";
@@ -51,7 +52,7 @@ import { NumberStepper } from "@/components/form/number-stepper";
import { PreviousValueHint } from "@/components/form/previous-value-hint";
import { StickySubmit } from "@/components/form/sticky-submit";
import { DraftBanner } from "@/components/form/draft-banner";
import { PhotoCaptureField } from "@/components/photos/photo-capture-field";
import { PHOTO_SESSION_QUERY_KEY, PhotoCaptureField } from "@/components/photos/photo-capture-field";
import {
FEED_STATUS_OPTIONS,
INSPECTION_OBSERVATIONS,
@@ -63,6 +64,7 @@ import {
const UNDO_TOAST_MS = 8000;
const WEATHER_UPDATED = "Wetter aktualisiert";
const WEATHER_MISSING = "Keine Wetterdaten für diesen Tag";
const PHOTOS_LATER = "Fotos werden später gesendet.";
const TAKEOVER_LABELS: Record<TakeoverField, string> = {
queenSeen: "Königin gesehen",
@@ -139,43 +141,8 @@ function useWeatherAutofill(values: InspectionFormValues, setValues: SetValues):
return `${WEATHER_UPDATED} (${day}): ${summary}`;
}
/**
* Offline save: the form's photos are tied to the queue entry's key before
* the entry exists, so a sync that sends the inspection right away already
* finds them. If queueing fails, they belong to the open form again.
*/
async function queueInspectionWithPhotos(
photoSessionId: string,
input: CreateInspectionInput,
idempotencyKey: string,
ownerUserId: string,
): Promise<void> {
await attachSessionToQueuedInspection(photoSessionId, idempotencyKey);
try {
await queueInspection(input, idempotencyKey, ownerUserId);
} catch (error) {
await releaseSessionPhotos(photoSessionId);
throw error;
}
}
/**
* Online save: the photos belong to the saved inspection and go up in the
* background - inside a sync run, so a logout's cancelSync() stops them too.
*/
async function sendPhotosOf(
photoSessionId: string,
inspectionId: string,
userId: string | null,
): Promise<void> {
await attachSessionToInspection(photoSessionId, inspectionId);
if (userId) {
await syncPendingEntries(userId);
}
}
interface LastInspectionBarProps {
last: Inspection;
last: LastInspection;
onTakeOverAll: () => void;
}
@@ -247,12 +214,72 @@ function InspectionCaptureForm({
if (key === "date") setDateError(null);
}
/** How many photos the form's photo section shows (none while it is hidden). */
function shownPhotoCount(photoSessionId: string): number {
return (
queryClient.getQueryData<unknown[]>([...PHOTO_SESSION_QUERY_KEY, photoSessionId])?.length ??
0
);
}
/**
* Online save: the photos belong to the saved inspection and go up in the
* background - inside a sync run, so a logout's cancelSync() stops them too.
* If they cannot be tied to it right now, the hand-over stays noted for this
* user: the next start or sync finishes it, and keeps the photos till then.
*/
async function handOverPhotos(photoSessionId: string, inspectionId: string): Promise<void> {
// Inside the signed-in app there always is a user; nothing is sent without one.
if (!userId) {
return;
}
let handedOver: number;
try {
handedOver = await handOverSessionPhotos({
userId,
sessionId: photoSessionId,
inspectionId,
});
} catch {
if (shownPhotoCount(photoSessionId) > 0) {
toast.info(PHOTOS_LATER);
}
return;
}
if (handedOver === 0) {
return;
}
try {
// A sync already running may have passed these photos: it runs once more.
await syncPendingEntries(userId, { afterChange: true });
} catch {
toast.error("Fotos konnten nicht gesendet werden.");
}
// The band's count, and the documents of the inspection (DocumentUploadCard).
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
void queryClient.invalidateQueries({ queryKey: ["documents", "INSPECTION", inspectionId] });
}
// The photo session of the form that was submitted: "Weitermachen" while the
// save runs swaps the form's session, and the photos of the saved entry must
// not follow it.
const submittedPhotoSession = useRef(values.photoSessionId);
const capture = useCaptureMutation({
create: createInspection,
queue: (input: CreateInspectionInput, idempotencyKey, ownerUserId) =>
queueInspectionWithPhotos(values.photoSessionId, input, idempotencyKey, ownerUserId),
// Queued together with its photos in one step: none is left waiting for
// an entry that was never queued.
queue: async (input: CreateInspectionInput, idempotencyKey, ownerUserId) => {
await queueInspectionWithPhotos(
submittedPhotoSession.current,
input,
idempotencyKey,
ownerUserId,
);
},
failureMessage: "Durchsicht konnte nicht gespeichert werden.",
onSaved: (outcome, input) => {
// At once: a draft of a saved inspection would be offered to save again.
draft.clear();
const savedOffline = outcome.via === "queued";
if (outcome.via === "queued") {
@@ -264,9 +291,7 @@ function InspectionCaptureForm({
queryKey: ["colonies", outcome.result.colonyId, "inspections"],
});
toast.success("Durchsicht gespeichert");
void sendPhotosOf(values.photoSessionId, outcome.result.id, userId).catch(() =>
toast.error("Fotos konnten nicht gesendet werden."),
);
void handOverPhotos(submittedPhotoSession.current, outcome.result.id);
}
const advanced = stand.round ? stand.markDone(input.colonyId) : null;
if (advanced) {
@@ -289,19 +314,35 @@ function InspectionCaptureForm({
document.getElementById("inspection-date")?.focus();
return;
}
submittedPhotoSession.current = values.photoSessionId;
capture.submit(toInspectionInput(values));
}
/**
* Photos taken before „Weitermachen" join the restored entry, as far as the
* photo limit allows. Those that do not fit are deleted, and the toast says
* so - the form could not show them any more.
*/
async function takePhotosAlong(fromSessionId: string, toSessionId: string): Promise<void> {
try {
const { discarded } = await moveDraftPhotos(fromSessionId, toSessionId);
if (discarded > 0) {
toast.info(
`${photosNotTakenOver(discarded)}. Höchstens ${PHOTOS_PER_INSPECTION_MAX} Fotos je Durchsicht.`,
);
}
} catch {
toast.error("Fotos konnten nicht übernommen werden.");
}
void queryClient.invalidateQueries({ queryKey: PHOTO_SESSION_QUERY_KEY });
}
function restoreDraft() {
const restored = draft.restore();
if (restored) {
const freshSessionId = values.photoSessionId;
// A draft from before photos existed has no session: the form keeps its own.
const photoSessionId = restored.photoSessionId ?? values.photoSessionId;
if (photoSessionId !== values.photoSessionId) {
// Like the typed values, photos taken before restoring give way to the
// restored entry. Unsaved photos left behind are purged after 3 days.
void discardSessionPhotos(values.photoSessionId).catch(() => undefined);
}
const photoSessionId = restored.photoSessionId ?? freshSessionId;
// A draft from before these fields existed lacks them.
setValues({
...initialInspectionValues(restored.colonyId),
@@ -310,6 +351,9 @@ function InspectionCaptureForm({
weatherAuto: restored.weatherAuto ?? (restored.weather === "" && restored.temperature === ""),
});
setDirty(true);
if (photoSessionId !== freshSessionId) {
void takePhotosAlong(freshSessionId, photoSessionId);
}
}
}
@@ -605,7 +649,8 @@ function InspectionCaptureForm({
title="Wetter"
description="Automatisch aus den Wetterdaten des Stands (Tageshöchstwert) – änderbar"
>
<p role="status" aria-live="polite" className="text-sm text-muted-foreground empty:hidden">
{/* Present while empty (only visually hidden), or its first message is not announced. */}
<p role="status" aria-live="polite" className="text-sm text-muted-foreground empty:sr-only">
{weatherNotice}
</p>
<Field label="Wetter">
+88 -5
View File
@@ -1,15 +1,19 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Card, CardContent } from "@/components/ui/card";
import { StickySubmit } from "@/components/form/sticky-submit";
import { purgeStaleDraftPhotos } from "@/lib/photos/store";
import { finishHandOvers, purgeStaleDraftPhotos } from "@/lib/photos/store";
import AppLayout from "./layout";
const pushMock = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ replace: vi.fn(), push: vi.fn() }),
useRouter: () => ({ replace: vi.fn(), push: pushMock }),
usePathname: () => "/inspections/new",
}));
let sessionMode: "online" | "offline" = "online";
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({
user: {
@@ -23,20 +27,43 @@ vi.mock("@/lib/auth/auth-context", () => ({
hasAvatar: false,
},
isLoading: false,
sessionMode,
logout: vi.fn(),
}),
}));
vi.mock("@/components/auth/logout-confirm-dialog", () => ({
LogoutConfirmDialog: ({
open,
userId,
onLoggedOut,
}: {
open: boolean;
userId: string;
onLoggedOut: () => void;
}) =>
open ? (
<div role="dialog" aria-label="Abmelden?">
{userId}
<button type="button" onClick={onLoggedOut}>
Abgemeldet
</button>
</div>
) : null,
}));
vi.mock("@/components/notification-bell", () => ({ NotificationBell: () => null }));
vi.mock("@/components/offline/offline-band", () => ({
OfflineBand: ({
userId,
pageContentRef,
syncEnabled,
}: {
userId: string;
pageContentRef: { current: HTMLElement | null };
syncEnabled?: boolean;
}) => (
<div data-testid="offline-band">
<div data-testid="offline-band" data-sync-enabled={String(syncEnabled)}>
{userId}
<button type="button" onClick={() => pageContentRef.current?.focus()}>
Fokus auf den Seiteninhalt
@@ -45,7 +72,10 @@ vi.mock("@/components/offline/offline-band", () => ({
),
}));
vi.mock("@/components/user-avatar", () => ({ UserAvatar: () => null }));
vi.mock("@/lib/photos/store", () => ({ purgeStaleDraftPhotos: vi.fn().mockResolvedValue(0) }));
vi.mock("@/lib/photos/store", () => ({
purgeStaleDraftPhotos: vi.fn().mockResolvedValue(0),
finishHandOvers: vi.fn().mockResolvedValue(0),
}));
// A box with overflow auto/scroll/hidden is a scroll container. Any such
// ancestor of the page content captures `position: sticky` (StickySubmit),
@@ -59,6 +89,11 @@ function expectNoScrollContainerAbove(element: HTMLElement) {
}
describe("AppLayout", () => {
beforeEach(() => {
sessionMode = "online";
pushMock.mockClear();
});
it("shows the offline band for the signed-in user right below the header", () => {
render(
<AppLayout>
@@ -71,6 +106,41 @@ describe("AppLayout", () => {
expect(band.previousElementSibling?.tagName).toBe("HEADER");
});
it("lets the band send only in an online session", () => {
const { unmount } = render(
<AppLayout>
<p>Seiteninhalt</p>
</AppLayout>,
);
expect(screen.getByTestId("offline-band")).toHaveAttribute("data-sync-enabled", "true");
unmount();
sessionMode = "offline";
render(
<AppLayout>
<p>Seiteninhalt</p>
</AppLayout>,
);
expect(screen.getByTestId("offline-band")).toHaveAttribute("data-sync-enabled", "false");
});
it("asks through the logout dialog and moves to the login page once signed out", async () => {
render(
<AppLayout>
<p>Seiteninhalt</p>
</AppLayout>,
);
expect(screen.queryByRole("dialog", { name: "Abmelden?" })).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Konto-Menü" }));
await userEvent.click(await screen.findByRole("menuitem", { name: "Abmelden" }));
const dialog = await screen.findByRole("dialog", { name: "Abmelden?" });
expect(dialog).toHaveTextContent("u1");
await userEvent.click(screen.getByRole("button", { name: "Abgemeldet" }));
expect(pushMock).toHaveBeenCalledWith("/login");
});
it("gives the band the page content wrapper as a stable focus target", () => {
render(
<AppLayout>
@@ -100,6 +170,19 @@ describe("AppLayout", () => {
expect(now).toBeGreaterThanOrEqual(before);
});
it("finishes the signed-in user's open photo hand-overs when the app starts", async () => {
vi.mocked(finishHandOvers).mockClear();
render(
<AppLayout>
<p>Seiteninhalt</p>
</AppLayout>,
);
// Even offline: the photos then wait as entries of their own.
await waitFor(() => expect(finishHandOvers).toHaveBeenCalledTimes(1));
expect(finishHandOvers).toHaveBeenCalledWith("u1");
});
it("does not wrap the page content in its own scroll container", () => {
render(
<AppLayout>
+28 -8
View File
@@ -1,11 +1,12 @@
"use client";
import { ReactNode, useEffect, useRef } from "react";
import { ReactNode, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Hexagon } from "lucide-react";
import { useAuth } from "@/lib/auth/auth-context";
import { purgeStaleDraftPhotos } from "@/lib/photos/store";
import { finishHandOvers, purgeStaleDraftPhotos } from "@/lib/photos/store";
import { LogoutConfirmDialog } from "@/components/auth/logout-confirm-dialog";
import { NotificationBell } from "@/components/notification-bell";
import { OfflineBand } from "@/components/offline/offline-band";
import { UserAvatar } from "@/components/user-avatar";
@@ -25,7 +26,8 @@ import { PageTransition } from "./page-transition";
export default function AppLayout({ children }: { children: ReactNode }) {
const router = useRouter();
const { user, isLoading, logout } = useAuth();
const { user, isLoading, sessionMode } = useAuth();
const [isLogoutOpen, setIsLogoutOpen] = useState(false);
// Focus lands here when the offline queue sheet closes and nothing else is left.
const pageContentRef = useRef<HTMLDivElement>(null);
@@ -41,6 +43,16 @@ export default function AppLayout({ children }: { children: ReactNode }) {
void purgeStaleDraftPhotos(Date.now()).catch(() => undefined);
}, []);
// Online saves whose photos could not be tied to their inspection at once:
// finished for the signed-in user, even offline - the photos then wait as
// entries of their own. Every sync run of that user tries again too.
const userId = user?.id;
useEffect(() => {
if (userId) {
void finishHandOvers(userId).catch(() => undefined);
}
}, [userId]);
if (isLoading || !user) {
return null;
}
@@ -69,7 +81,7 @@ export default function AppLayout({ children }: { children: ReactNode }) {
<QuickAddSheet user={user} variant="header" />
<NotificationBell />
<DropdownMenu>
<DropdownMenuTrigger className="rounded-full">
<DropdownMenuTrigger className="rounded-full" aria-label="Konto-Menü">
<UserAvatar name={user.name} hasAvatar={user.hasAvatar} size="sm" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
@@ -84,9 +96,7 @@ export default function AppLayout({ children }: { children: ReactNode }) {
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={() => {
void logout().then(() => router.push("/login"));
}}
onClick={() => setIsLogoutOpen(true)}
>
Abmelden
</DropdownMenuItem>
@@ -94,7 +104,11 @@ export default function AppLayout({ children }: { children: ReactNode }) {
</DropdownMenu>
</div>
</header>
<OfflineBand userId={user.id} pageContentRef={pageContentRef} />
<OfflineBand
userId={user.id}
pageContentRef={pageContentRef}
syncEnabled={sessionMode === "online"}
/>
<div
ref={pageContentRef}
tabIndex={-1}
@@ -105,6 +119,12 @@ export default function AppLayout({ children }: { children: ReactNode }) {
</div>
<BottomNav user={user} />
<QuickAddSheet user={user} variant="fab" />
<LogoutConfirmDialog
open={isLogoutOpen}
onOpenChange={setIsLogoutOpen}
userId={user.id}
onLoggedOut={() => router.push("/login")}
/>
</div>
);
}
@@ -46,6 +46,11 @@ describe("nav-config", () => {
expect(admin?.items.map((item) => item.href)).toEqual(["/settings/audit-log"]);
});
it("shows the System entry only with system:read", () => {
expect(hrefs([])).not.toContain("/settings/system");
expect(hrefs(["system:read"])).toContain("/settings/system");
});
it("hides items the user may not see", () => {
expect(hrefs([])).not.toContain("/winter");
expect(hrefs(["winter-checks:read"])).toContain("/winter");
@@ -1,4 +1,5 @@
import {
Activity,
BarChart3,
Boxes,
CalendarDays,
@@ -146,6 +147,12 @@ export const ADMIN_GROUP: NavGroup = {
icon: History,
permission: "audit:read",
},
{
label: "System",
href: "/settings/system",
icon: Activity,
permission: "system:read",
},
],
};
@@ -0,0 +1,86 @@
import "fake-indexeddb/auto";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { getDb } from "@/lib/offline/db";
import { deleteAllPacks } from "@/lib/apiaries/offline-pack";
import {
offlinePack,
packColony,
packTask,
storedPack,
} from "@/lib/apiaries/offline-pack.fixtures";
import OfflineApiaryPage from "./page";
let searchString = "id=apiary-1";
vi.mock("next/navigation", () => ({
useSearchParams: () => new URLSearchParams(searchString),
}));
vi.mock("@/lib/auth/auth-context", () => ({ useCurrentUserId: () => "user-1" }));
// No network: every server request fails the way it does in airplane mode.
vi.mock("@/lib/auth/api-client", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/auth/api-client")>()),
apiFetch: vi.fn(() => Promise.reject(new TypeError("Failed to fetch"))),
}));
function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<OfflineApiaryPage />
</QueryClientProvider>,
);
}
describe("OfflineApiaryPage", () => {
beforeEach(async () => {
searchString = "id=apiary-1";
await deleteAllPacks();
});
it("lists the pack's colonies with their light, linking to the offline colony view", async () => {
const pack = offlinePack({
apiaryId: "apiary-1",
apiaryName: "Obstwiese",
colonies: [packColony("colony-1", "A1"), packColony("colony-2", "A2", { status: "QUEENLESS" })],
tasks: [packTask("t-1", "Zaun prüfen", { relatedType: "APIARY", relatedId: "apiary-1" })],
});
const db = await getDb();
await db.put("offline-packs", storedPack(pack));
renderPage();
expect(await screen.findByRole("heading", { level: 1, name: "Obstwiese" })).toBeInTheDocument();
const second = screen.getByRole("link", { name: /A2/ });
expect(second).toHaveAttribute("href", "/offline/colony?id=colony-2");
expect(within(second).getByText("Kritisch")).toBeInTheDocument();
expect(second).toHaveTextContent(/Weisellos/);
expect(screen.getByText("Zaun prüfen")).toBeInTheDocument();
expect(screen.getByText(/^Geladen vor/)).toBeInTheDocument();
// The stand round works offline too: its colonies come from the pack.
await vi.waitFor(() =>
expect(screen.getByRole("button", { name: "Runde starten" })).toHaveAttribute(
"href",
"/inspections/new?colonyId=colony-1&round=apiary-1",
),
);
});
it("leads back to the apiary page once the network is back", async () => {
const db = await getDb();
await db.put("offline-packs", storedPack(offlinePack({ apiaryId: "apiary-1" })));
renderPage();
expect(await screen.findByRole("button", { name: /Mit Netz öffnen/ })).toHaveAttribute(
"href",
"/apiaries/apiary-1",
);
});
it("explains how to load an apiary that is not loaded", async () => {
searchString = "id=apiary-unknown";
renderPage();
expect(await screen.findByText("Dieser Stand ist nicht offline geladen.")).toBeInTheDocument();
expect(screen.getByText("Lade den Stand bei Netz mit „Für offline laden“.")).toBeInTheDocument();
});
});
@@ -0,0 +1,102 @@
"use client";
import { Suspense } from "react";
import { useSearchParams } from "next/navigation";
import { MapPin } from "lucide-react";
import { useCurrentUserId } from "@/lib/auth/auth-context";
import type { StoredPack } from "@/lib/offline/db";
import { DetailHeader } from "@/components/detail-header";
import { PackAgeNote, useOwnPack } from "@/components/offline/load-pack-button";
import { ColonyOverviewLinks, StartRoundButton } from "../../apiaries/[id]/apiary-colonies-card";
import {
NotLoadedOffline,
OFFLINE_HOME,
OfflineLoading,
OfflinePageFrame,
OfflineReadError,
OfflineSection,
OpenTaskList,
ReturnOnline,
returnHrefFrom,
} from "../offline-parts";
function offlineColonyHref(colonyId: string): string {
return `/offline/colony?id=${encodeURIComponent(colonyId)}`;
}
/**
* The apiary as it was loaded for offline use - where a QR/NFC link to
* /apiaries/:id lands without network. Read-only; the stand round starts
* from here like online.
*/
export default function OfflineApiaryPage() {
return (
<Suspense fallback={<OfflineLoading what="Standdaten" />}>
<OfflineApiary />
</Suspense>
);
}
function OfflineApiary() {
const searchParams = useSearchParams();
const apiaryId = searchParams.get("id") ?? "";
const returnHref = returnHrefFrom(searchParams, `/apiaries/${encodeURIComponent(apiaryId)}`);
const userId = useCurrentUserId();
const query = useOwnPack(apiaryId, userId);
if (query.isLoading) {
return <OfflineLoading what="Standdaten" />;
}
if (query.isError) {
return <OfflineReadError onRetry={() => void query.refetch()} />;
}
if (!query.data) {
return <NotLoadedOffline title="Dieser Stand ist nicht offline geladen." returnHref={returnHref} />;
}
return (
<OfflineApiaryView stored={query.data} readAt={query.dataUpdatedAt} returnHref={returnHref} />
);
}
interface OfflineApiaryViewProps {
stored: StoredPack;
/** When the pack was read from storage - the "now" of its age. */
readAt: number;
/** The page the user wanted, for when the network is back. */
returnHref: string;
}
function OfflineApiaryView({ stored, readAt, returnHref }: OfflineApiaryViewProps) {
const { apiary, overview, tasks } = stored.pack;
const apiaryTasks =
tasks?.filter((task) => task.relatedType === "APIARY" && task.relatedId === apiary.id) ?? null;
return (
<OfflinePageFrame>
<DetailHeader
icon={MapPin}
title={apiary.name}
subtitle={apiary.location ?? undefined}
primaryAction={<StartRoundButton apiary={{ id: apiary.id, name: apiary.name }} />}
backHref={OFFLINE_HOME}
backLabel="Offline geladene Stände"
/>
<ReturnOnline href={returnHref} />
<PackAgeNote stored={stored} now={readAt} />
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-2">
<OfflineSection title={`Völker (${overview.rows.length})`}>
{overview.rows.length === 0 ? (
<p className="text-muted-foreground">Keine Völker an diesem Stand.</p>
) : (
<ColonyOverviewLinks overview={overview} hrefFor={offlineColonyHref} />
)}
</OfflineSection>
{apiaryTasks !== null ? (
<OfflineSection title="Offene Aufgaben am Stand">
<OpenTaskList tasks={apiaryTasks} />
</OfflineSection>
) : null}
</div>
</OfflinePageFrame>
);
}
@@ -0,0 +1,219 @@
import "fake-indexeddb/auto";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { getDb, type StoredPack } from "@/lib/offline/db";
import { deleteAllPacks } from "@/lib/apiaries/offline-pack";
import {
DAY_MS,
MINUTE_MS,
offlinePack,
packColony,
packInspection,
packQueen,
packTask,
storedPack,
} from "@/lib/apiaries/offline-pack.fixtures";
import OfflineColonyPage from "./page";
let searchString = "id=colony-1";
vi.mock("next/navigation", () => ({
useSearchParams: () => new URLSearchParams(searchString),
}));
vi.mock("@/lib/auth/auth-context", () => ({
useCurrentUserId: () => "user-1",
useAuth: () => ({ hasPermission: () => true }),
}));
function day(isoDay: string): string {
return `${isoDay}T00:00:00.000Z`;
}
function germanDay(timestamp: number): string {
const date = new Date(timestamp);
const dd = String(date.getDate()).padStart(2, "0");
const mm = String(date.getMonth() + 1).padStart(2, "0");
return `${dd}.${mm}.${date.getFullYear()}`;
}
const COLONY = packColony("colony-1", "A1", { currentQueenId: "queen-1", strength: 9 });
function fullPack() {
return offlinePack({
apiaryId: "apiary-1",
apiaryName: "Obstwiese",
colonies: [COLONY, packColony("colony-2", "A2")],
queens: [packQueen("queen-1", "K-12", { hatchYear: 2025, breed: "Carnica" })],
inspections: [
packInspection("i-1", "colony-1", day("2026-10-01"), { colonyStrength: 9, queenSeen: true }),
packInspection("i-2", "colony-1", day("2026-09-20")),
packInspection("i-3", "colony-1", day("2026-09-10")),
packInspection("i-4", "colony-1", day("2026-08-30")),
packInspection("i-5", "colony-2", day("2026-09-25")),
],
tasks: [
packTask("t-1", "Futter nachgeben", { relatedType: "COLONY", relatedId: "colony-1", dueDate: day("2026-10-15") }),
packTask("t-2", "Anderes Volk", { relatedType: "COLONY", relatedId: "colony-2" }),
packTask("t-3", "Zaun prüfen", { relatedType: "APIARY", relatedId: "apiary-1" }),
],
});
}
async function putPacks(...packs: StoredPack[]): Promise<void> {
const db = await getDb();
await Promise.all(packs.map((pack) => db.put("offline-packs", pack)));
}
function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<OfflineColonyPage />
</QueryClientProvider>,
);
}
describe("OfflineColonyPage", () => {
beforeEach(async () => {
searchString = "id=colony-1";
await deleteAllPacks();
});
it("renders colony, queen, last three inspections and open tasks from the pack", async () => {
await putPacks(storedPack(fullPack()));
renderPage();
const title = await screen.findByRole("heading", { level: 1, name: "A1" });
const header = title.closest("header") as HTMLElement;
expect(within(header).getByText(/Obstwiese/)).toBeInTheDocument();
// The same traffic light as online: icon and word.
expect(within(header).getByText("Gut")).toBeInTheDocument();
expect(within(header).getByText("Aktiv")).toBeInTheDocument();
const colony = screen.getByRole("region", { name: "Volk" });
expect(within(colony).getByText("9 Waben")).toBeInTheDocument();
const queen = screen.getByRole("region", { name: "Königin" });
expect(within(queen).getByText("K-12")).toBeInTheDocument();
expect(within(queen).getByText(/Carnica/)).toBeInTheDocument();
expect(within(queen).getByText(/2025/)).toBeInTheDocument();
const inspections = screen.getByRole("region", { name: "Letzte Durchsichten" });
expect(within(inspections).getByText("01.10.2026")).toBeInTheDocument();
expect(within(inspections).getByText("20.09.2026")).toBeInTheDocument();
expect(within(inspections).getByText("10.09.2026")).toBeInTheDocument();
expect(within(inspections).queryByText("30.08.2026")).not.toBeInTheDocument();
expect(within(inspections).queryByText("25.09.2026")).not.toBeInTheDocument();
const tasks = screen.getByRole("region", { name: "Offene Aufgaben" });
expect(within(tasks).getByText("Futter nachgeben")).toBeInTheDocument();
expect(within(tasks).queryByText("Anderes Volk")).not.toBeInTheDocument();
expect(within(tasks).queryByText("Zaun prüfen")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: /Durchsicht erfassen/ })).toHaveAttribute(
"href",
"/inspections/new?colonyId=colony-1",
);
expect(screen.getByRole("button", { name: /Varroa-Messung/ })).toHaveAttribute(
"href",
"/varroa/new?colonyId=colony-1",
);
expect(screen.getByRole("button", { name: /Fütterung/ })).toHaveAttribute(
"href",
"/feedings/new?colonyId=colony-1",
);
// Read-only: nothing here edits the pack's records.
expect(screen.queryByRole("button", { name: /Bearbeiten/ })).not.toBeInTheDocument();
});
it("leaves out sections the user may not read", async () => {
await putPacks(storedPack({ ...fullPack(), queens: null, inspections: null, tasks: null }));
renderPage();
await screen.findByRole("heading", { level: 1, name: "A1" });
expect(screen.queryByRole("region", { name: "Königin" })).not.toBeInTheDocument();
expect(screen.queryByRole("region", { name: "Letzte Durchsichten" })).not.toBeInTheDocument();
expect(screen.queryByRole("region", { name: "Offene Aufgaben" })).not.toBeInTheDocument();
});
it("shows the not-loaded message for an unknown colony", async () => {
searchString = "id=colony-unknown";
await putPacks(
storedPack(fullPack()),
// Another account's pack never counts as loaded for this user.
storedPack(
offlinePack({ apiaryId: "apiary-9", colonies: [packColony("colony-unknown", "X1")] }),
{ ownerUserId: "user-2" },
),
);
renderPage();
expect(await screen.findByText("Dieses Volk ist nicht offline geladen.")).toBeInTheDocument();
expect(
screen.getByText("Lade den Stand bei Netz mit „Für offline laden“."),
).toBeInTheDocument();
expect(screen.queryByRole("heading", { level: 1, name: "X1" })).not.toBeInTheDocument();
});
it("shows the pack age and the stale hint", async () => {
const savedAt = Date.now() - 2 * DAY_MS;
await putPacks(storedPack(fullPack(), { savedAt: new Date(savedAt).toISOString() }));
renderPage();
expect(await screen.findByText("Geladen vor 2 Tagen")).toBeInTheDocument();
expect(
screen.getByText(`Stand-Daten von ${germanDay(savedAt)} – bei Netz neu laden`),
).toBeInTheDocument();
});
it("leads back to the page the user wanted once the network is back", async () => {
searchString = "id=colony-1&from=%2Fcolonies%2Fcolony-1%3Ftab%3Dvarroa";
await putPacks(storedPack(fullPack()));
renderPage();
const back = await screen.findByRole("button", { name: /Mit Netz öffnen/ });
expect(back).toHaveAttribute("href", "/colonies/colony-1?tab=varroa");
});
it("never follows a foreign return address - it leads to the colony's own page", async () => {
searchString = "id=colony-1&from=https%3A%2F%2Fevil.example%2Fcolonies";
await putPacks(storedPack(fullPack()));
renderPage();
const back = await screen.findByRole("button", { name: /Mit Netz öffnen/ });
expect(back).toHaveAttribute("href", "/colonies/colony-1");
});
it("offers to try again while still offline", async () => {
Object.defineProperty(window.navigator, "onLine", { configurable: true, get: () => false });
try {
await putPacks(storedPack(fullPack()));
renderPage();
const retry = await screen.findByRole("button", { name: /Erneut versuchen/ });
expect(retry).toHaveAttribute("href", "/colonies/colony-1");
} finally {
Reflect.deleteProperty(window.navigator, "onLine");
}
});
it("also offers the way back for a colony that is not loaded", async () => {
searchString = "id=colony-unknown&from=%2Fcolonies%2Fcolony-unknown";
renderPage();
await screen.findByText("Dieses Volk ist nicht offline geladen.");
expect(screen.getByRole("button", { name: /Mit Netz öffnen/ })).toHaveAttribute(
"href",
"/colonies/colony-unknown",
);
});
it("shows a fresh pack's age without the stale hint", async () => {
await putPacks(
storedPack(fullPack(), { savedAt: new Date(Date.now() - 5 * MINUTE_MS).toISOString() }),
);
renderPage();
expect(await screen.findByText("Geladen vor 5 Minuten")).toBeInTheDocument();
expect(screen.queryByText(/bei Netz neu laden/)).not.toBeInTheDocument();
});
});
@@ -0,0 +1,293 @@
"use client";
import { Suspense } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { Layers } from "lucide-react";
import { useAuth, useCurrentUserId } from "@/lib/auth/auth-context";
import {
OFFLINE_PACKS_QUERY_KEY,
findPackForColony,
type PackColony,
type PackInspection,
type PackQueen,
} from "@/lib/apiaries/offline-pack";
import {
COLONY_CONDITION_LABELS,
COLONY_STATUS_LABELS,
COLONY_TYPE_LABELS,
} from "@/lib/colonies/labels";
import { colonyStatusLight } from "@/lib/colonies/status-light";
import type { ColonyCondition } from "@/lib/colonies/types";
import { todayLocalIsoDate } from "@/lib/dates";
import type { StoredPack } from "@/lib/offline/db";
import { QUEEN_STATUS_LABELS } from "@/lib/queens/labels";
import { StatusLightBadge } from "@/components/colonies/status-light-badge";
import { DetailHeader, DetailStatus } from "@/components/detail-header";
import { PackAgeNote } from "@/components/offline/load-pack-button";
import { Button } from "@/components/ui/button";
import { QUICK_ACTIONS } from "../../quick-actions";
import {
NotLoadedOffline,
OfflineLoading,
OfflinePageFrame,
OfflineReadError,
OfflineSection,
OpenTaskList,
ReturnOnline,
calendarDayText,
returnHrefFrom,
} from "../offline-parts";
const LAST_INSPECTIONS = 3;
const INSPECTION_FORM = "/inspections/new";
// The capture forms that take a colony and open without network (app shell).
const COLONY_CAPTURE_FORMS = ["/varroa/new", "/swarm-control/new", "/treatments/new", "/feedings/new"];
/**
* A colony as it was loaded for offline use - where a QR/NFC link to
* /colonies/:id lands without network. Read-only, with the way to the
* capture forms.
*/
export default function OfflineColonyPage() {
return (
<Suspense fallback={<OfflineLoading what="Volksdaten" />}>
<OfflineColony />
</Suspense>
);
}
function OfflineColony() {
const searchParams = useSearchParams();
const colonyId = searchParams.get("id") ?? "";
const returnHref = returnHrefFrom(searchParams, `/colonies/${encodeURIComponent(colonyId)}`);
const userId = useCurrentUserId();
const query = useQuery({
queryKey: [...OFFLINE_PACKS_QUERY_KEY, userId, "colony", colonyId],
queryFn: () => findPackForColony(colonyId, userId ?? ""),
enabled: userId !== null && colonyId !== "",
// Reads this device's storage, never the network.
networkMode: "always",
});
if (query.isLoading) {
return <OfflineLoading what="Volksdaten" />;
}
if (query.isError) {
return <OfflineReadError onRetry={() => void query.refetch()} />;
}
const colony = query.data?.pack.colonies.find((candidate) => candidate.id === colonyId);
if (!query.data || !colony) {
return <NotLoadedOffline title="Dieses Volk ist nicht offline geladen." returnHref={returnHref} />;
}
return (
<OfflineColonyView
stored={query.data}
colony={colony}
readAt={query.dataUpdatedAt}
returnHref={returnHref}
/>
);
}
function captureHref(form: string, colonyId: string): string {
return `${form}?colonyId=${encodeURIComponent(colonyId)}`;
}
interface OfflineColonyViewProps {
stored: StoredPack;
colony: PackColony;
/** When the pack was read from storage - the "now" of its age. */
readAt: number;
/** The page the user wanted, for when the network is back. */
returnHref: string;
}
function OfflineColonyView({ stored, colony, readAt, returnHref }: OfflineColonyViewProps) {
const { hasPermission } = useAuth();
const { pack } = stored;
const row = pack.overview.rows.find((candidate) => candidate.id === colony.id);
const light = row
? colonyStatusLight(row, {
today: todayLocalIsoDate(),
inspectionsVisible: pack.overview.inspectionsVisible,
})
: null;
const inspection = QUICK_ACTIONS.find((action) => action.href === INSPECTION_FORM);
const queen = pack.queens?.find((candidate) => candidate.id === colony.currentQueenId) ?? null;
const tasks =
pack.tasks?.filter((task) => task.relatedType === "COLONY" && task.relatedId === colony.id) ??
null;
return (
<OfflinePageFrame>
<DetailHeader
icon={Layers}
title={colony.code}
subtitle={`${COLONY_TYPE_LABELS[colony.colonyType]} · ${pack.apiary.name}`}
status={
<>
{light ? <StatusLightBadge light={light.light} /> : null}
<DetailStatus status={colony.status} label={COLONY_STATUS_LABELS[colony.status]} />
</>
}
primaryAction={
inspection && hasPermission(inspection.permission) ? (
<Button
size="touch"
nativeButton={false}
render={<Link href={captureHref(INSPECTION_FORM, colony.id)} />}
>
<inspection.icon aria-hidden />
Durchsicht erfassen
</Button>
) : undefined
}
backHref={`/offline/apiary?id=${encodeURIComponent(stored.apiaryId)}`}
backLabel="Zum Stand"
/>
<ReturnOnline href={returnHref} />
<PackAgeNote stored={stored} now={readAt} />
{light && light.reasons.length > 0 ? (
<p className="text-sm text-muted-foreground">{light.reasons.join(" · ")}</p>
) : null}
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-2">
<ColonyFacts colony={colony} />
{pack.queens !== null ? <QueenFacts queen={queen} /> : null}
{pack.inspections !== null ? (
<LastInspections inspections={pack.inspections} colonyId={colony.id} />
) : null}
{tasks !== null ? (
<OfflineSection title="Offene Aufgaben">
<OpenTaskList tasks={tasks} />
</OfflineSection>
) : null}
<MoreCaptures colonyId={colony.id} hasPermission={hasPermission} />
</div>
</OfflinePageFrame>
);
}
function conditionText(condition: ColonyCondition | null): string {
return condition ? COLONY_CONDITION_LABELS[condition] : "–";
}
function Fact({ label, value }: { label: string; value: string }) {
return (
<div className="flex justify-between gap-3">
<dt className="text-muted-foreground">{label}</dt>
<dd className="text-right">{value}</dd>
</div>
);
}
function ColonyFacts({ colony }: { colony: PackColony }) {
return (
<OfflineSection title="Volk">
<dl className="flex flex-col gap-1.5">
<Fact label="Stärke" value={colony.strength !== null ? `${colony.strength} Waben` : "–"} />
<Fact label="Brut" value={conditionText(colony.broodStatus)} />
<Fact label="Futter" value={conditionText(colony.feedStatus)} />
<Fact label="Gesundheit" value={conditionText(colony.healthStatus)} />
<Fact label="Beute" value={colony.hiveType ?? "–"} />
</dl>
{colony.notes ? <p className="whitespace-pre-line">{colony.notes}</p> : null}
</OfflineSection>
);
}
function QueenFacts({ queen }: { queen: PackQueen | null }) {
if (!queen) {
return (
<OfflineSection title="Königin">
<p className="text-muted-foreground">Keine Königin erfasst.</p>
</OfflineSection>
);
}
const marking = [queen.markingColor, queen.markingNumber].filter(Boolean).join(" ");
return (
<OfflineSection title="Königin">
<p className="font-medium">{queen.code}</p>
<p className="text-muted-foreground">
{[
queen.hatchYear !== null ? `Jahrgang ${queen.hatchYear}` : null,
queen.breed,
marking ? `Zeichen ${marking}` : null,
QUEEN_STATUS_LABELS[queen.status],
]
.filter(Boolean)
.join(" · ")}
</p>
</OfflineSection>
);
}
function inspectionSummary(inspection: PackInspection): string {
const parts = [
inspection.colonyStrength !== null ? `${inspection.colonyStrength} Waben` : null,
inspection.broodFrames !== null ? `${inspection.broodFrames} Brutwaben` : null,
inspection.queenSeen !== null
? `Königin ${inspection.queenSeen ? "gesehen" : "nicht gesehen"}`
: null,
].filter(Boolean);
return parts.length > 0 ? parts.join(" · ") : "Ohne Angaben zu Stärke und Königin";
}
function LastInspections({
inspections,
colonyId,
}: {
inspections: readonly PackInspection[];
colonyId: string;
}) {
const latest = inspections
.filter((inspection) => inspection.colonyId === colonyId)
.sort((a, b) => b.date.localeCompare(a.date) || b.createdAt.localeCompare(a.createdAt))
.slice(0, LAST_INSPECTIONS);
return (
<OfflineSection title="Letzte Durchsichten">
{latest.length === 0 ? <p className="text-muted-foreground">Noch keine Durchsicht.</p> : null}
{latest.map((inspection) => (
<div key={inspection.id} className="rounded-xl border border-border/60 px-3 py-2">
<p className="font-medium">{calendarDayText(inspection.date)}</p>
<p className="text-muted-foreground">{inspectionSummary(inspection)}</p>
{inspection.notes ? <p className="whitespace-pre-line">{inspection.notes}</p> : null}
</div>
))}
</OfflineSection>
);
}
interface MoreCapturesProps {
colonyId: string;
hasPermission: (permission: string) => boolean;
}
function MoreCaptures({ colonyId, hasPermission }: MoreCapturesProps) {
const actions = QUICK_ACTIONS.filter(
(action) => COLONY_CAPTURE_FORMS.includes(action.href) && hasPermission(action.permission),
);
if (actions.length === 0) {
return null;
}
return (
<OfflineSection title="Weitere Erfassung">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{actions.map((action) => (
<Button
key={action.href}
size="touch"
variant="outline"
className="justify-start"
nativeButton={false}
render={<Link href={captureHref(action.href, colonyId)} />}
>
<action.icon aria-hidden />
{action.label}
</Button>
))}
</div>
</OfflineSection>
);
}
@@ -0,0 +1,142 @@
"use client";
import { ReactNode, useId } from "react";
import Link from "next/link";
import { CloudOff, RotateCw, Wifi } from "lucide-react";
import type { PackTask } from "@/lib/apiaries/offline-pack";
import { toCalendarDay } from "@/lib/dates";
import { useConnectivity } from "@/lib/offline/connectivity";
import { safeReturnPath } from "@/lib/offline/return-path";
import { TASK_PRIORITY_LABELS } from "@/lib/tasks/labels";
import { EmptyState } from "@/components/empty-state";
import { ErrorState, LoadingState } from "@/components/query-state";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
// Shared by the offline pages (/offline/apiary, /offline/colony): read-only
// views of an offline pack, opened by the service worker without network.
export const OFFLINE_HOME = "/offline";
/** A calendar-day timestamp of the API ("2026-10-01T00:00:00.000Z") as "01.10.2026". */
export function calendarDayText(isoTimestamp: string): string {
const [year, month, day] = toCalendarDay(isoTimestamp).split("-");
return `${day}.${month}.${year}`;
}
export function OfflinePageFrame({ children }: { children: ReactNode }) {
return <main className="flex flex-col gap-4 p-4 sm:p-6">{children}</main>;
}
export function OfflineLoading({ what }: { what: string }) {
return (
<OfflinePageFrame>
<LoadingState what={what} />
</OfflinePageFrame>
);
}
export function OfflineReadError({ onRetry }: { onRetry: () => void }) {
return (
<OfflinePageFrame>
<ErrorState what="Offline-Daten" onRetry={onRetry} />
</OfflinePageFrame>
);
}
/**
* Where "Mit Netz öffnen" leads: the page the user wanted (`from`, from the
* service worker - only a path of this app), else `fallbackHref`.
*/
export function returnHrefFrom(searchParams: URLSearchParams, fallbackHref: string): string {
return safeReturnPath(searchParams.get("from")) ?? fallbackHref;
}
/**
* The way back out of the offline view: once the network is back, to the
* page the user wanted; until then a new attempt. A full page load, so the
* service worker decides again.
*/
export function ReturnOnline({ href }: { href: string }) {
const isOnline = useConnectivity() === "online";
const Icon = isOnline ? Wifi : CloudOff;
return (
<div
role="status"
aria-live="polite"
className="flex flex-wrap items-center gap-x-3 gap-y-2 rounded-xl bg-muted/60 p-3 text-sm"
>
<p className="flex items-center gap-2">
<Icon className="size-4 shrink-0" aria-hidden />
{isOnline ? "Netz ist wieder da." : "Kein Netz – du siehst die gespeicherten Daten."}
</p>
<Button size="touch" variant="outline" nativeButton={false} render={<a href={href} />}>
<RotateCw aria-hidden />
{isOnline ? "Mit Netz öffnen" : "Erneut versuchen"}
</Button>
</div>
);
}
interface NotLoadedOfflineProps {
title: string;
/** The online page of the record, for when the network is back. */
returnHref: string;
}
/** What to do when the deep-linked record was never loaded for offline use. */
export function NotLoadedOffline({ title, returnHref }: NotLoadedOfflineProps) {
return (
<OfflinePageFrame>
<ReturnOnline href={returnHref} />
<EmptyState
icon={CloudOff}
title={title}
description="Lade den Stand bei Netz mit „Für offline laden“."
action={
<Button size="touch" variant="outline" nativeButton={false} render={<Link href={OFFLINE_HOME} />}>
Offline geladene Stände
</Button>
}
/>
</OfflinePageFrame>
);
}
/** A card that is a named region, so it can be found by its title. */
export function OfflineSection({ title, children }: { title: string; children: ReactNode }) {
const headingId = useId();
return (
<Card role="region" aria-labelledby={headingId}>
<CardHeader>
<CardTitle>
<h2 id={headingId}>{title}</h2>
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2 text-sm">{children}</CardContent>
</Card>
);
}
export function OpenTaskList({ tasks }: { tasks: readonly PackTask[] }) {
if (tasks.length === 0) {
return <p className="text-muted-foreground">Keine offenen Aufgaben.</p>;
}
return (
<ul className="flex flex-col gap-2">
{tasks.map((task) => (
<li key={task.id} className="rounded-xl border border-border/60 px-3 py-2">
<p className="font-medium">{task.title}</p>
<p className="text-muted-foreground">
{[
task.dueDate ? `fällig ${calendarDayText(task.dueDate)}` : null,
`Priorität ${TASK_PRIORITY_LABELS[task.priority]}`,
]
.filter(Boolean)
.join(" · ")}
</p>
</li>
))}
</ul>
);
}
@@ -0,0 +1,73 @@
import "fake-indexeddb/auto";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { getDb } from "@/lib/offline/db";
import { deleteAllPacks } from "@/lib/apiaries/offline-pack";
import { offlinePack, storedPack } from "@/lib/apiaries/offline-pack.fixtures";
import OfflinePage from "./page";
let searchString = "";
vi.mock("next/navigation", () => ({
useSearchParams: () => new URLSearchParams(searchString),
}));
vi.mock("@/lib/auth/auth-context", () => ({ useCurrentUserId: () => "user-1" }));
function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<OfflinePage />
</QueryClientProvider>,
);
}
describe("OfflinePage", () => {
beforeEach(async () => {
searchString = "";
await deleteAllPacks();
});
it("lists the apiaries loaded for offline use, by name", async () => {
const db = await getDb();
await db.put("offline-packs", storedPack(offlinePack({ apiaryId: "apiary-2", apiaryName: "Waldrand" })));
await db.put("offline-packs", storedPack(offlinePack({ apiaryId: "apiary-1", apiaryName: "Obstwiese" })));
await db.put(
"offline-packs",
storedPack(offlinePack({ apiaryId: "apiary-3", apiaryName: "Fremd" }), { ownerUserId: "user-2" }),
);
renderPage();
const links = await screen.findAllByRole("link", { name: /Obstwiese|Waldrand/ });
expect(links.map((link) => link.getAttribute("href"))).toEqual([
"/offline/apiary?id=apiary-1",
"/offline/apiary?id=apiary-2",
]);
expect(screen.queryByText("Fremd")).not.toBeInTheDocument();
});
it("explains how to prepare for offline use when nothing is loaded", async () => {
renderPage();
expect(await screen.findByText("Noch kein Stand offline geladen")).toBeInTheDocument();
expect(screen.getByText("Lade einen Stand bei Netz mit „Für offline laden“.")).toBeInTheDocument();
});
it("leads back to the page the user wanted once the network is back", async () => {
searchString = "from=%2Fstatistics%3Fyear%3D2026";
renderPage();
expect(await screen.findByRole("button", { name: /Mit Netz öffnen/ })).toHaveAttribute(
"href",
"/statistics?year=2026",
);
});
it("offers no way back to a foreign address", async () => {
searchString = "from=%2F%2Fevil.example";
renderPage();
await screen.findByText("Noch kein Stand offline geladen");
expect(screen.queryByRole("button", { name: /Mit Netz öffnen/ })).not.toBeInTheDocument();
});
});
@@ -0,0 +1,96 @@
"use client";
import { Suspense } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { ChevronRight, CloudOff, LayoutDashboard } from "lucide-react";
import { useCurrentUserId } from "@/lib/auth/auth-context";
import { safeReturnPath } from "@/lib/offline/return-path";
import { OFFLINE_PACKS_QUERY_KEY, listPacks, packAgeText } from "@/lib/apiaries/offline-pack";
import { EmptyState } from "@/components/empty-state";
import { PageHeader } from "@/components/page-header";
import { ErrorState, LoadingState } from "@/components/query-state";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ReturnOnline } from "./offline-parts";
/** The page the service worker could not open (`from`), once the network is back. */
function ReturnToWantedPage() {
const href = safeReturnPath(useSearchParams().get("from"));
return href ? <ReturnOnline href={href} /> : null;
}
/**
* Where the service worker sends a page it cannot open without network:
* the apiaries loaded for offline use. Static - the HTML is cached as part of
* the app shell and reads this device's storage only.
*/
export default function OfflinePage() {
const userId = useCurrentUserId();
const query = useQuery({
queryKey: [...OFFLINE_PACKS_QUERY_KEY, userId, "list"],
queryFn: async () =>
(await listPacks(userId ?? "")).sort((a, b) =>
a.pack.apiary.name.localeCompare(b.pack.apiary.name, "de"),
),
enabled: userId !== null,
networkMode: "always",
});
// Ages are told as of reading the packs (refreshed on every visit).
const now = query.dataUpdatedAt;
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={CloudOff}
title="Offline"
description="Ohne Netz zeigt ApiCore die Stände, die du für offline geladen hast."
/>
<Suspense fallback={null}>
<ReturnToWantedPage />
</Suspense>
<Card>
<CardHeader>
<CardTitle>
<h2>Offline geladene Stände</h2>
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{query.isLoading ? <LoadingState what="Stände" /> : null}
{query.isError ? (
<ErrorState what="Stände" onRetry={() => void query.refetch()} isRetrying={query.isFetching} />
) : null}
{query.data?.length === 0 ? (
<EmptyState
icon={CloudOff}
title="Noch kein Stand offline geladen"
description="Lade einen Stand bei Netz mit „Für offline laden“."
/>
) : null}
{query.data?.map((stored) => (
<Link
key={stored.apiaryId}
href={`/offline/apiary?id=${encodeURIComponent(stored.apiaryId)}`}
className="flex min-h-14 items-center gap-3 rounded-xl border border-border/60 px-3 py-2 hover:bg-muted/50 focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
>
<span className="flex min-w-0 flex-1 flex-col">
<span className="font-medium">{stored.pack.apiary.name}</span>
<span className="text-sm text-muted-foreground">
Geladen {packAgeText(stored.savedAt, now)}
</span>
</span>
<ChevronRight className="size-4 shrink-0 text-muted-foreground" aria-hidden />
</Link>
))}
</CardContent>
</Card>
<div>
<Button size="touch" variant="outline" nativeButton={false} render={<Link href="/dashboard" />}>
<LayoutDashboard aria-hidden />
Zur Übersicht
</Button>
</div>
</main>
);
}
@@ -0,0 +1,58 @@
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 { deactivateAccount } from "@/lib/profile/api";
import { DangerZoneSection } from "./danger-zone-section";
const pushMock = vi.fn();
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: pushMock }) }));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock("@/lib/profile/api", () => ({ deactivateAccount: vi.fn() }));
const logoutMock = vi.fn();
vi.mock("@/lib/auth/auth-context", () => ({ useAuth: () => ({ logout: logoutMock }) }));
function renderZone() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<DangerZoneSection />
</QueryClientProvider>,
);
}
async function confirmDeactivation() {
await userEvent.click(screen.getByRole("button", { name: "Konto löschen" }));
const dialog = await screen.findByRole("dialog");
await userEvent.type(within(dialog).getByLabelText("Passwort"), "secret-password");
await userEvent.click(within(dialog).getByRole("button", { name: "Konto löschen" }));
}
describe("DangerZoneSection", () => {
beforeEach(() => {
vi.clearAllMocks();
logoutMock.mockResolvedValue(undefined);
});
it("signs out and deletes the unsent entries of a deactivated account", async () => {
vi.mocked(deactivateAccount).mockResolvedValue(undefined);
renderZone();
await confirmDeactivation();
// Nobody can ever send them: they must not wait for the next account.
await waitFor(() => expect(logoutMock).toHaveBeenCalledWith({ discardQueue: true }));
expect(pushMock).toHaveBeenCalledWith("/login");
});
it("stays signed in and keeps the entries when the account could not be deactivated", async () => {
vi.mocked(deactivateAccount).mockRejectedValue(new Error("wrong password"));
renderZone();
await confirmDeactivation();
expect(await screen.findByRole("alert")).toBeInTheDocument();
expect(logoutMock).not.toHaveBeenCalled();
});
});
@@ -37,7 +37,8 @@ function DeactivateAccountDialog() {
mutationFn: () => deactivateAccount(currentPassword),
onSuccess: async () => {
toast.success("Konto deaktiviert");
await logout();
// The account is gone: its unsent entries can never be sent by anyone.
await logout({ discardQueue: true });
router.push("/login");
},
onError: () => setError("Passwort ist falsch oder das Konto kann nicht deaktiviert werden."),
@@ -0,0 +1,82 @@
import { afterEach, 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 { WebhookDeliveriesDialog } from "./webhook-deliveries-dialog";
import { fetchWebhookDeliveries } from "@/lib/webhooks/api";
vi.mock("@/lib/webhooks/api", () => ({ fetchWebhookDeliveries: vi.fn() }));
function renderDialog() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<WebhookDeliveriesDialog subscriptionId="s1" url="https://hooks.example.com/x" />
</QueryClientProvider>,
);
}
describe("WebhookDeliveriesDialog", () => {
afterEach(() => vi.clearAllMocks());
it("shows the delivery log of a webhook", async () => {
vi.mocked(fetchWebhookDeliveries).mockResolvedValue([
{
id: "d1",
event: "harvest.created",
status: "DELIVERED",
attempts: 1,
lastStatusCode: 200,
lastError: null,
createdAt: "2026-10-13T08:00:00.000Z",
deliveredAt: "2026-10-13T08:00:01.000Z",
},
{
id: "d2",
event: "task.created",
status: "FAILED",
attempts: 8,
lastStatusCode: 500,
lastError: "Zeitüberschreitung",
createdAt: "2026-10-12T08:00:00.000Z",
deliveredAt: null,
},
{
id: "d3",
event: "task.completed",
status: "PENDING",
attempts: 0,
lastStatusCode: null,
lastError: null,
createdAt: "2026-10-12T07:00:00.000Z",
deliveredAt: null,
},
]);
const user = userEvent.setup();
renderDialog();
expect(fetchWebhookDeliveries).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "Zustellprotokoll" }));
expect(await screen.findByRole("heading", { name: "Zustellprotokoll" })).toBeInTheDocument();
expect(await screen.findByText("Ernte erfasst")).toBeInTheDocument();
expect(screen.getByText("Zugestellt")).toBeInTheDocument();
expect(screen.getByText("Fehlgeschlagen")).toBeInTheDocument();
expect(screen.getByText("Offen")).toBeInTheDocument();
expect(screen.getByText("HTTP 200")).toBeInTheDocument();
expect(screen.getByText("HTTP 500")).toBeInTheDocument();
expect(screen.getByText("8 Versuche")).toBeInTheDocument();
expect(screen.getByText("1 Versuch")).toBeInTheDocument();
expect(fetchWebhookDeliveries).toHaveBeenCalledWith("s1");
});
it("says so when nothing was delivered yet", async () => {
vi.mocked(fetchWebhookDeliveries).mockResolvedValue([]);
const user = userEvent.setup();
renderDialog();
await user.click(screen.getByRole("button", { name: "Zustellprotokoll" }));
expect(await screen.findByText("Noch keine Zustellungen.")).toBeInTheDocument();
});
});
@@ -0,0 +1,98 @@
"use client";
import { useState } from "react";
import { CircleCheck, CircleX, Clock } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { fetchWebhookDeliveries } from "@/lib/webhooks/api";
import { WEBHOOK_EVENT_LABELS } from "@/lib/webhooks/labels";
import { WEBHOOK_DELIVERY_STATUS_LABELS, attemptsLabel } from "@/lib/system/labels";
import type { WebhookDeliveryStatus, WebhookEvent } from "@/lib/webhooks/types";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { QueryState } from "@/components/query-state";
const STATUS_ICONS: Record<WebhookDeliveryStatus, typeof Clock> = {
PENDING: Clock,
PROCESSING: Clock,
DELIVERED: CircleCheck,
FAILED: CircleX,
};
function DeliveryLog({ subscriptionId }: { subscriptionId: string }) {
const query = useQuery({
queryKey: ["webhook-deliveries", subscriptionId],
queryFn: () => fetchWebhookDeliveries(subscriptionId),
});
return (
<QueryState
query={query}
what="Zustellungen"
empty={<p className="text-sm text-muted-foreground">Noch keine Zustellungen.</p>}
>
{(deliveries) => (
<ul className="flex max-h-[60vh] flex-col gap-2 overflow-y-auto">
{deliveries.map((delivery) => {
const Icon = STATUS_ICONS[delivery.status];
return (
<li
key={delivery.id}
className="flex flex-col gap-1 rounded-xl border border-border/60 bg-card p-3"
>
<div className="flex flex-wrap items-center gap-2">
<Badge variant={delivery.status === "FAILED" ? "destructive" : "secondary"}>
<Icon aria-hidden />
{WEBHOOK_DELIVERY_STATUS_LABELS[delivery.status]}
</Badge>
<span className="text-sm font-medium">
{WEBHOOK_EVENT_LABELS[delivery.event as WebhookEvent] ?? delivery.event}
</span>
</div>
<p className="flex flex-wrap gap-x-3 text-sm text-muted-foreground">
<span>{new Date(delivery.createdAt).toLocaleString("de-DE")}</span>
<span>{attemptsLabel(delivery.attempts)}</span>
{delivery.lastStatusCode ? <span>HTTP {delivery.lastStatusCode}</span> : null}
</p>
{delivery.lastError ? (
<p className="break-words text-sm text-destructive">{delivery.lastError}</p>
) : null}
</li>
);
})}
</ul>
)}
</QueryState>
);
}
/** The newest deliveries of one webhook - loaded only once the dialog opens. */
export function WebhookDeliveriesDialog({
subscriptionId,
url,
}: {
subscriptionId: string;
url: string;
}) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button variant="outline" size="touch">Zustellprotokoll</Button>} />
<DialogContent>
<DialogHeader>
<DialogTitle>Zustellprotokoll</DialogTitle>
<DialogDescription className="break-all">{url}</DialogDescription>
</DialogHeader>
{open ? <DeliveryLog subscriptionId={subscriptionId} /> : null}
</DialogContent>
</Dialog>
);
}
@@ -12,6 +12,7 @@ import { EmptyState } from "@/components/empty-state";
import { QueryState } from "@/components/query-state";
import { CreateWebhookDialog } from "./create-webhook-dialog";
import { EditWebhookDialog } from "./edit-webhook-dialog";
import { WebhookDeliveriesDialog } from "./webhook-deliveries-dialog";
/** One card per connection - events in words, not technical keys. */
export function WebhooksTab() {
@@ -77,6 +78,7 @@ export function WebhooksTab() {
</div>
</div>
<div className="flex flex-wrap gap-2">
<WebhookDeliveriesDialog subscriptionId={webhook.id} url={webhook.url} />
<EditWebhookDialog webhook={webhook} />
<ConfirmDialog
triggerLabel="Löschen"
@@ -43,6 +43,12 @@ function schedule(overrides: Partial<BackupScheduleStatus> = {}): BackupSchedule
lastBackupSizeBytes: 10 * MB,
lastBackupExceedsImportLimit: false,
envOverrides: { storagePath: true, cronSchedule: true, retentionCount: true },
lastJobRun: {
status: "SUCCEEDED",
startedAt: "2026-10-13T01:00:02.000Z",
finishedAt: "2026-10-13T01:00:09.000Z",
error: null,
},
...overrides,
};
}
@@ -93,6 +99,61 @@ describe("BackupPage", () => {
expect(screen.queryByText(LIMIT_WARNING)).not.toBeInTheDocument();
});
it("shows that the schedule is checked hourly and when the job last ran", async () => {
vi.mocked(getBackupSchedule).mockResolvedValue(schedule());
renderWithClient(<BackupPage />);
const lastRun = new Date("2026-10-13T01:00:02.000Z").toLocaleString("de-DE");
expect(
await screen.findByText(`Wird stündlich geprüft · letzter Lauf ${lastRun}`),
).toBeInTheDocument();
expect(screen.queryByText(/Fehlgeschlagen/)).not.toBeInTheDocument();
});
it("shows why the last job run failed", async () => {
vi.mocked(getBackupSchedule).mockResolvedValue(
schedule({
lastJobRun: {
status: "FAILED",
startedAt: "2026-10-13T01:00:02.000Z",
finishedAt: "2026-10-13T01:00:05.000Z",
error: "EACCES: permission denied",
},
}),
);
renderWithClient(<BackupPage />);
const lastRun = new Date("2026-10-13T01:00:02.000Z").toLocaleString("de-DE");
expect(
await screen.findByText(
`Wird stündlich geprüft · letzter Lauf ${lastRun} · Fehlgeschlagen: EACCES: permission denied`,
),
).toBeInTheDocument();
});
it("says so while the job has not run yet", async () => {
vi.mocked(getBackupSchedule).mockResolvedValue(schedule({ lastJobRun: null }));
renderWithClient(<BackupPage />);
expect(
await screen.findByText("Wird stündlich geprüft · noch kein Lauf"),
).toBeInTheDocument();
});
it("labels the newest backup file as the last backup, not as the last run", async () => {
vi.mocked(getBackupSchedule).mockResolvedValue(schedule());
renderWithClient(<BackupPage />);
expect(await screen.findByText("Letzte Sicherung")).toBeInTheDocument();
expect(
screen.getByText(new Date("2026-10-12T03:00:00.000Z").toLocaleString("de-DE")),
).toBeInTheDocument();
});
it("warns after a manual export that is larger than the import limit, but still downloads it", async () => {
vi.mocked(getBackupSchedule).mockResolvedValue(schedule({ importLimitBytes: 10 }));
vi.mocked(exportBackup).mockResolvedValue({
@@ -14,7 +14,7 @@ import {
} from "@/lib/backup/api";
import { Input } from "@/components/ui/input";
import { useAuth } from "@/lib/auth/auth-context";
import { BackupScheduleStatus, BackupSettings } from "@/lib/backup/types";
import { BackupJobRunStatus, BackupScheduleStatus, BackupSettings } from "@/lib/backup/types";
import { unsealedImportWarning } from "@/lib/backup/import-summary";
import { importCsv } from "@/lib/import/api";
import { ImportEntity, ImportReport, ImportRowStatus } from "@/lib/import/types";
@@ -224,11 +224,25 @@ function BackupSection() {
function formatDateTime(value: string | null): string {
if (!value) {
return "Noch nicht gelaufen";
return "Noch keine Sicherung";
}
return new Date(value).toLocaleString("de-DE");
}
// The schedule is checked by an hourly CronJob, not by a timer in the API:
// what that job did last is what tells whether backups still happen.
function jobRunSummary(run: BackupJobRunStatus | null): string {
const checked = "Wird stündlich geprüft";
if (!run) {
return `${checked} · noch kein Lauf`;
}
const lastRun = `${checked} · letzter Lauf ${formatDateTime(run.startedAt)}`;
if (run.status === "FAILED") {
return run.error ? `${lastRun} · Fehlgeschlagen: ${run.error}` : `${lastRun} · Fehlgeschlagen`;
}
return run.status === "RUNNING" ? `${lastRun} · läuft gerade` : lastRun;
}
// Mounted only once `settings` has arrived, so its useState initial values
// already reflect the fetched data - matches the pattern already used for
// WebsiteSettingsForm in social-media-tab.tsx.
@@ -399,10 +413,21 @@ function ScheduleSection() {
<dd>{status.cronSchedule}</dd>
<dt className="text-muted-foreground">Aufbewahrung</dt>
<dd>{status.retentionCount} neueste Backups</dd>
<dt className="text-muted-foreground">Letzter Lauf</dt>
<dt className="text-muted-foreground">Letzte Sicherung</dt>
<dd>{formatDateTime(status.lastRunAt)}</dd>
</dl>
)}
{status ? (
<p
className={
status.lastJobRun?.status === "FAILED"
? "text-destructive"
: "text-xs text-muted-foreground"
}
>
{jobRunSummary(status.lastJobRun)}
</p>
) : null}
<Button
type="button"
variant="outline"
@@ -31,6 +31,18 @@ describe("SettingsLayout", () => {
expect(replaceMock).not.toHaveBeenCalled();
});
it("lets a user with only system:read into the settings area", () => {
mockUser = { permissions: ["system:read"] };
render(
<SettingsLayout>
<p>Einstellungen-Inhalt</p>
</SettingsLayout>,
);
expect(screen.getByText("Einstellungen-Inhalt")).toBeInTheDocument();
});
it("redirects to the dashboard when the user lacks every settings-area permission", () => {
mockUser = { permissions: ["colonies:read"] };
@@ -19,6 +19,7 @@ const SETTINGS_AREA_PERMISSIONS = [
"backup:configure",
"calendar:configure",
"api-keys:manage",
"system:read",
];
export default function SettingsLayout({ children }: { children: ReactNode }) {
@@ -0,0 +1,206 @@
import { afterEach, 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 SystemPage from "./page";
import { ApiError } from "@/lib/auth/api-client";
import {
fetchFailedDomainEvents,
fetchFailedWebhookDeliveries,
fetchSystemOverview,
retryDomainEvent,
retryWebhookDelivery,
} from "@/lib/system/api";
import { JOB_NAMES, type SystemOverview } from "@/lib/system/types";
vi.mock("@/lib/system/api", () => ({
fetchSystemOverview: vi.fn(),
fetchFailedDomainEvents: vi.fn(),
retryDomainEvent: vi.fn(),
fetchFailedWebhookDeliveries: vi.fn(),
retryWebhookDelivery: vi.fn(),
}));
let permissions: string[] = [];
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({ user: { permissions } }),
}));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
function overview(overrides: Partial<SystemOverview["domainEvents"]> = {}): SystemOverview {
return {
jobs: JOB_NAMES.map((job) => ({
job,
lastRun:
job === "cleanup"
? {
id: "r1",
job,
status: "FAILED" as const,
startedAt: "2026-10-13T04:00:00.000Z",
finishedAt: "2026-10-13T04:00:05.000Z",
error: "Timeout",
result: null,
}
: job === "daily-evaluation"
? {
id: "r2",
job,
status: "SUCCEEDED" as const,
startedAt: "2026-10-13T04:30:00.000Z",
finishedAt: "2026-10-13T04:30:05.000Z",
error: null,
result: { colonies: 4 },
}
: null,
})),
domainEvents: {
pending: 3,
processing: 0,
failed: 1,
oldestPendingAgeSeconds: 60,
...overrides,
},
webhookDeliveries: { pending: 0, failed: 1 },
};
}
const failedEvent = {
id: "ev1",
type: "InspectionRecorded",
aggregateType: "Inspection",
aggregateId: "i1",
occurredAt: "2026-10-13T08:00:00.000Z",
attempts: 8,
lastError: "boom",
failedHandlers: [{ handler: "automation", lastError: "boom" }],
};
function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<SystemPage />
</QueryClientProvider>,
);
}
describe("SystemPage", () => {
beforeEach(() => {
permissions = ["system:read", "system:manage"];
vi.mocked(fetchSystemOverview).mockResolvedValue(overview());
vi.mocked(fetchFailedDomainEvents).mockResolvedValue([failedEvent]);
vi.mocked(fetchFailedWebhookDeliveries).mockResolvedValue([]);
vi.mocked(retryDomainEvent).mockResolvedValue(undefined);
vi.mocked(retryWebhookDelivery).mockResolvedValue(undefined);
});
afterEach(() => vi.clearAllMocks());
it("lists every job with its last run and status label", async () => {
renderPage();
expect(await screen.findByRole("heading", { name: "System" })).toBeInTheDocument();
expect(await screen.findByText("Tägliche Auswertung")).toBeInTheDocument();
expect(screen.getByText("Aufgaben aus Vorlagen")).toBeInTheDocument();
expect(screen.getByText("Aufräumen")).toBeInTheDocument();
expect(screen.getByText("App-Backup")).toBeInTheDocument();
expect(screen.getByText("Schlüssel umstellen")).toBeInTheDocument();
expect(screen.getByText("Erfolgreich")).toBeInTheDocument();
expect(screen.getByText("Fehlgeschlagen")).toBeInTheDocument();
expect(screen.getAllByText("Noch nie gelaufen")).toHaveLength(3);
});
it("shows the queue counts without a warning while the queue keeps up", async () => {
renderPage();
expect(await screen.findByText("Offen: 3")).toBeInTheDocument();
expect(screen.getByText(/Ältestes offenes Ereignis: vor 1 Min\./)).toBeInTheDocument();
expect(
screen.queryByText("Der Worker arbeitet die Warteschlange gerade nicht ab."),
).not.toBeInTheDocument();
});
it("says so when no event is open", async () => {
vi.mocked(fetchSystemOverview).mockResolvedValue(
overview({ pending: 0, oldestPendingAgeSeconds: null }),
);
renderPage();
expect(await screen.findByText("Keine offenen Ereignisse")).toBeInTheDocument();
});
it("warns when the oldest open event is older than 10 minutes", async () => {
vi.mocked(fetchSystemOverview).mockResolvedValue(overview({ oldestPendingAgeSeconds: 601 }));
renderPage();
expect(
await screen.findByText("Der Worker arbeitet die Warteschlange gerade nicht ab."),
).toBeInTheDocument();
});
it("does not warn at exactly 10 minutes", async () => {
vi.mocked(fetchSystemOverview).mockResolvedValue(overview({ oldestPendingAgeSeconds: 600 }));
renderPage();
await screen.findByText("Offen: 3");
expect(
screen.queryByText("Der Worker arbeitet die Warteschlange gerade nicht ab."),
).not.toBeInTheDocument();
});
it("retries a failed event and refetches the overview", async () => {
const user = userEvent.setup();
renderPage();
const row = (await screen.findByText("Durchsicht erfasst")).closest("li") as HTMLElement;
await user.click(within(row).getByRole("button", { name: "Erneut versuchen" }));
await waitFor(() => expect(retryDomainEvent).toHaveBeenCalledWith("ev1"));
expect(toast.success).toHaveBeenCalledWith("Wird erneut versucht");
await waitFor(() => expect(fetchSystemOverview).toHaveBeenCalledTimes(2));
expect(fetchFailedDomainEvents).toHaveBeenCalledTimes(2);
});
it('hides "Erneut versuchen" without system:manage', async () => {
permissions = ["system:read"];
renderPage();
await screen.findByText("Durchsicht erfasst");
expect(screen.queryByRole("button", { name: "Erneut versuchen" })).not.toBeInTheDocument();
});
it("shows empty states and retries a failed webhook delivery", async () => {
vi.mocked(fetchFailedDomainEvents).mockResolvedValue([]);
vi.mocked(fetchFailedWebhookDeliveries).mockResolvedValue([
{
id: "d1",
subscriptionId: "s1",
targetHost: "hooks.example.com",
event: "harvest.created",
attempts: 8,
lastStatusCode: 500,
lastError: "HTTP 500",
createdAt: "2026-10-13T08:00:00.000Z",
},
]);
const user = userEvent.setup();
renderPage();
expect(await screen.findByText("Keine fehlgeschlagenen Ereignisse.")).toBeInTheDocument();
expect(await screen.findByText("hooks.example.com")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Erneut versuchen" }));
await waitFor(() => expect(retryWebhookDelivery).toHaveBeenCalledWith("d1"));
});
it("shows an error toast when the retry fails", async () => {
vi.mocked(retryDomainEvent).mockRejectedValue(new ApiError(409, "Konflikt"));
const user = userEvent.setup();
renderPage();
await user.click(await screen.findByRole("button", { name: "Erneut versuchen" }));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith("Konflikt"));
});
});
@@ -0,0 +1,310 @@
"use client";
import { ReactNode } from "react";
import { Activity, AlertTriangle, CircleCheck, CircleX, Loader } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
fetchFailedDomainEvents,
fetchFailedWebhookDeliveries,
fetchSystemOverview,
retryDomainEvent,
retryWebhookDelivery,
} from "@/lib/system/api";
import {
DOMAIN_EVENT_LABELS,
JOB_LABELS,
JOB_RUN_STATUS_LABELS,
QUEUE_STALE_WARNING_SECONDS,
attemptsLabel,
formatAge,
} from "@/lib/system/labels";
import type { JobRunStatus, JobRunView, SystemOverview } from "@/lib/system/types";
import { WEBHOOK_EVENT_LABELS } from "@/lib/webhooks/labels";
import type { WebhookEvent } from "@/lib/webhooks/types";
import { useAuth } from "@/lib/auth/auth-context";
import { errorMessage } from "@/lib/forms/error-message";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { PageHeader } from "@/components/page-header";
import { QueryState } from "@/components/query-state";
const SYSTEM_QUERY_KEY = ["system"] as const;
const OVERVIEW_QUERY_KEY = [...SYSTEM_QUERY_KEY, "overview"] as const;
const FAILED_EVENTS_QUERY_KEY = [...SYSTEM_QUERY_KEY, "failed-events"] as const;
const FAILED_DELIVERIES_QUERY_KEY = [...SYSTEM_QUERY_KEY, "failed-deliveries"] as const;
const STATUS_ICONS: Record<JobRunStatus, typeof CircleCheck> = {
RUNNING: Loader,
SUCCEEDED: CircleCheck,
FAILED: CircleX,
};
function formatDateTime(value: string): string {
return new Date(value).toLocaleString("de-DE");
}
function eventLabel(type: string): string {
return DOMAIN_EVENT_LABELS[type] ?? type;
}
function webhookEventLabel(event: string): string {
return WEBHOOK_EVENT_LABELS[event as WebhookEvent] ?? event;
}
/** Status in words plus an icon - never colour alone. */
function RunStatus({ status }: { status: JobRunStatus }) {
const Icon = STATUS_ICONS[status];
return (
<Badge variant={status === "FAILED" ? "destructive" : "secondary"}>
<Icon aria-hidden />
{JOB_RUN_STATUS_LABELS[status]}
</Badge>
);
}
function JobRunLine({ run }: { run: JobRunView | null }) {
if (!run) {
return <span className="text-sm text-muted-foreground">Noch nie gelaufen</span>;
}
return (
<div className="flex flex-col items-start gap-1">
<div className="flex flex-wrap items-center gap-2">
<RunStatus status={run.status} />
<span className="text-sm text-muted-foreground">{formatDateTime(run.startedAt)}</span>
</div>
{run.error ? <p className="break-words text-sm text-destructive">{run.error}</p> : null}
</div>
);
}
function JobsSection({ jobs }: { jobs: SystemOverview["jobs"] }) {
return (
<Card>
<CardHeader>
<CardTitle>Hintergrundjobs</CardTitle>
</CardHeader>
<CardContent>
<ul className="flex flex-col divide-y divide-border/60">
{jobs.map(({ job, lastRun }) => (
<li
key={job}
className="flex flex-col gap-1 py-3 first:pt-0 last:pb-0 sm:flex-row sm:items-start sm:justify-between sm:gap-4"
>
<span className="font-medium">{JOB_LABELS[job]}</span>
<JobRunLine run={lastRun} />
</li>
))}
</ul>
</CardContent>
</Card>
);
}
function StaleQueueWarning() {
return (
<div
role="alert"
className="flex items-start gap-2 rounded-xl border border-destructive/30 bg-destructive/5 p-3 text-sm"
>
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-destructive" aria-hidden />
<p className="font-medium">Der Worker arbeitet die Warteschlange gerade nicht ab.</p>
</div>
);
}
function FailedList({ emptyText, children }: { emptyText: string; children: ReactNode[] }) {
if (children.length === 0) {
return <p className="text-sm text-muted-foreground">{emptyText}</p>;
}
return <ul className="flex flex-col gap-2">{children}</ul>;
}
// No ConfirmDialog: a retry only re-queues the entry, it destroys nothing.
interface RetryButtonProps {
canManage: boolean;
isPending: boolean;
onRetry: () => void;
}
function RetryButton({ canManage, isPending, onRetry }: RetryButtonProps) {
if (!canManage) {
return null;
}
return (
<Button type="button" size="touch" variant="outline" disabled={isPending} onClick={onRetry}>
Erneut versuchen
</Button>
);
}
function useRetry(mutationFn: (id: string) => Promise<void>) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => mutationFn(id),
onSuccess: () => {
toast.success("Wird erneut versucht");
return queryClient.invalidateQueries({ queryKey: SYSTEM_QUERY_KEY });
},
onError: (error) => toast.error(errorMessage(error, "Erneuter Versuch fehlgeschlagen")),
});
}
function FailedEvents({ canManage }: { canManage: boolean }) {
const query = useQuery({ queryKey: FAILED_EVENTS_QUERY_KEY, queryFn: fetchFailedDomainEvents });
const retry = useRetry(retryDomainEvent);
return (
<QueryState query={query} what="Fehlgeschlagene Ereignisse">
{(events) => (
<FailedList emptyText="Keine fehlgeschlagenen Ereignisse.">
{events.map((event) => (
<li
key={event.id}
className="flex flex-col gap-3 rounded-xl border border-border/60 bg-card p-3 sm:flex-row sm:items-start"
>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<span className="font-medium">{eventLabel(event.type)}</span>
<span className="text-sm text-muted-foreground">
{formatDateTime(event.occurredAt)} · {attemptsLabel(event.attempts)}
</span>
{event.failedHandlers.map((handler) => (
<p key={handler.handler} className="break-words text-sm text-destructive">
{handler.handler}
{handler.lastError ? `: ${handler.lastError}` : ""}
</p>
))}
</div>
<RetryButton
canManage={canManage}
isPending={retry.isPending && retry.variables === event.id}
onRetry={() => retry.mutate(event.id)}
/>
</li>
))}
</FailedList>
)}
</QueryState>
);
}
function FailedDeliveries({ canManage }: { canManage: boolean }) {
const query = useQuery({
queryKey: FAILED_DELIVERIES_QUERY_KEY,
queryFn: fetchFailedWebhookDeliveries,
});
const retry = useRetry(retryWebhookDelivery);
return (
<QueryState query={query} what="Fehlgeschlagene Zustellungen">
{(deliveries) => (
<FailedList emptyText="Keine fehlgeschlagenen Zustellungen.">
{deliveries.map((delivery) => (
<li
key={delivery.id}
className="flex flex-col gap-3 rounded-xl border border-border/60 bg-card p-3 sm:flex-row sm:items-start"
>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<span className="break-all font-mono text-sm">{delivery.targetHost}</span>
<span className="text-sm">{webhookEventLabel(delivery.event)}</span>
<span className="text-sm text-muted-foreground">
{formatDateTime(delivery.createdAt)} · {attemptsLabel(delivery.attempts)}
{delivery.lastStatusCode ? ` · HTTP ${delivery.lastStatusCode}` : ""}
</span>
{delivery.lastError ? (
<p className="break-words text-sm text-destructive">{delivery.lastError}</p>
) : null}
</div>
<RetryButton
canManage={canManage}
isPending={retry.isPending && retry.variables === delivery.id}
onRetry={() => retry.mutate(delivery.id)}
/>
</li>
))}
</FailedList>
)}
</QueryState>
);
}
function QueueSection({
pending,
failed,
oldestPendingAgeSeconds,
canManage,
}: {
pending: number;
failed: number;
oldestPendingAgeSeconds: number | null;
canManage: boolean;
}) {
const isStale =
oldestPendingAgeSeconds !== null && oldestPendingAgeSeconds > QUEUE_STALE_WARNING_SECONDS;
return (
<Card>
<CardHeader>
<CardTitle>Ereignis-Warteschlange</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{isStale ? <StaleQueueWarning /> : null}
<div className="flex flex-col gap-1 text-sm">
{pending === 0 || oldestPendingAgeSeconds === null ? (
<p>Keine offenen Ereignisse</p>
) : (
<>
<p className="font-medium">Offen: {pending}</p>
<p className="text-muted-foreground">
Ältestes offenes Ereignis: {formatAge(oldestPendingAgeSeconds)}
</p>
</>
)}
<p className="text-muted-foreground">Fehlgeschlagen: {failed}</p>
</div>
<FailedEvents canManage={canManage} />
</CardContent>
</Card>
);
}
export default function SystemPage() {
const { user } = useAuth();
const canManage = Boolean(user?.permissions.includes("system:manage"));
const overviewQuery = useQuery({ queryKey: OVERVIEW_QUERY_KEY, queryFn: fetchSystemOverview });
return (
<div className="flex flex-col gap-6">
<PageHeader
icon={Activity}
title="System"
description="Hier siehst du, ob die Hintergrundarbeit läuft."
/>
<QueryState query={overviewQuery} what="Systemdaten">
{(overview) => (
<>
<JobsSection jobs={overview.jobs} />
<QueueSection
pending={overview.domainEvents.pending}
failed={overview.domainEvents.failed}
oldestPendingAgeSeconds={overview.domainEvents.oldestPendingAgeSeconds}
canManage={canManage}
/>
<Card>
<CardHeader>
<CardTitle>Webhook-Zustellungen</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<p className="text-sm">
Offen: {overview.webhookDeliveries.pending} · Fehlgeschlagen:{" "}
{overview.webhookDeliveries.failed}
</p>
<FailedDeliveries canManage={canManage} />
</CardContent>
</Card>
</>
)}
</QueryState>
</div>
);
}
@@ -3,8 +3,14 @@ import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider, onlineManager } from "@tanstack/react-query";
import NewTreatmentPage from "./page";
import { toast } from "sonner";
import { createTreatment, fetchTreatmentPrefill } from "@/lib/treatments/api";
import { createTreatmentsBulk } from "@/lib/bulk/api";
import { queueBulk } from "@/lib/offline/db";
import { refreshPacksAfterSync } from "@/lib/apiaries/offline-pack";
import { fetchApiaries } from "@/lib/apiaries/api";
import { fetchColonies } from "@/lib/colonies/api";
import { ApiError } from "@/lib/auth/api-client";
const push = vi.fn();
vi.mock("next/navigation", () => ({
@@ -16,7 +22,19 @@ vi.mock("@/lib/treatments/api", () => ({
createTreatment: vi.fn(),
fetchTreatmentPrefill: vi.fn(),
}));
vi.mock("@/lib/offline/db", () => ({ queueTreatment: vi.fn() }));
vi.mock("@/lib/bulk/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/bulk/api")>()),
createTreatmentsBulk: vi.fn(),
}));
vi.mock("@/lib/apiaries/api", () => ({ fetchApiaries: vi.fn() }));
vi.mock("@/lib/apiaries/offline-pack", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/apiaries/offline-pack")>()),
listPacks: vi.fn().mockResolvedValue([]),
refreshPacksAfterSync: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/auth-context", () => ({ useCurrentUserId: () => "user-1" }));
vi.mock("@/lib/offline/db", () => ({ queueTreatment: vi.fn(), queueBulk: vi.fn() }));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), info: vi.fn(), error: vi.fn() } }));
/** jsdom reports online; a test can take the device offline until it ends. */
function goOffline() {
@@ -47,6 +65,12 @@ describe("NewTreatmentPage", () => {
{ id: "colony-1", code: "V-01", status: "ACTIVE" },
] as never);
vi.mocked(createTreatment).mockReset();
vi.mocked(createTreatmentsBulk).mockReset();
vi.mocked(queueBulk).mockReset().mockResolvedValue(undefined);
vi.mocked(refreshPacksAfterSync).mockClear();
vi.mocked(fetchApiaries)
.mockReset()
.mockResolvedValue([{ id: "apiary-1", name: "Waldrand" }] as never);
vi.mocked(fetchTreatmentPrefill).mockReset().mockResolvedValue(null);
});
@@ -297,4 +321,165 @@ describe("NewTreatmentPage", () => {
expect(vi.mocked(createTreatment).mock.calls[0][0]).toMatchObject({ supplier: null });
});
});
describe("for several colonies", () => {
const BULK_COLONIES = [
{ id: "colony-1", code: "V-01", status: "ACTIVE", apiaryId: "apiary-1" },
{ id: "colony-2", code: "V-02", status: "WEAK", apiaryId: "apiary-1" },
];
const SAVED = { bulkId: "b-1", records: [] };
const SAVE = { name: "Behandlung speichern" };
async function fillBulkForm() {
vi.mocked(fetchColonies).mockResolvedValue(BULK_COLONIES as never);
renderPage();
await userEvent.click(screen.getByRole("radio", { name: "Mehrere Völker" }));
await userEvent.click(screen.getByRole("combobox", { name: /Stand/ }));
await userEvent.click(await screen.findByRole("option", { name: "Waldrand" }));
await screen.findByText("2 von 2 Völkern gewählt");
await userEvent.type(screen.getByLabelText(/^Mittel/), "Oxuvar");
await userEvent.type(screen.getByLabelText(/Chargennummer/), "Ch-4711");
}
it("bulk mode sends one POST /treatments/bulk with colonyIds and the shared fields", async () => {
vi.mocked(createTreatmentsBulk).mockResolvedValue(SAVED as never);
await fillBulkForm();
expect(screen.getByText(/Gilt für alle gewählten Völker\./)).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", SAVE));
await waitFor(() => expect(createTreatmentsBulk).toHaveBeenCalledTimes(1));
const [input, options] = vi.mocked(createTreatmentsBulk).mock.calls[0];
expect(input).toMatchObject({
colonyIds: ["colony-1", "colony-2"],
product: "Oxuvar",
batchNumber: "Ch-4711",
});
expect(input).not.toHaveProperty("colonyId");
expect(options?.idempotencyKey).toBeTruthy();
expect(createTreatment).not.toHaveBeenCalled();
expect(toast.success).toHaveBeenCalledWith("Behandlung für 2 Völker gespeichert");
expect(push).toHaveBeenCalledWith("/apiaries/apiary-1");
});
it("sends only the colonies that stay selected", async () => {
vi.mocked(createTreatmentsBulk).mockResolvedValue(SAVED as never);
await fillBulkForm();
await userEvent.click(screen.getByRole("checkbox", { name: "V-02 (Schwach)" }));
await userEvent.click(screen.getByRole("button", SAVE));
await waitFor(() => expect(createTreatmentsBulk).toHaveBeenCalledTimes(1));
expect(vi.mocked(createTreatmentsBulk).mock.calls[0][0].colonyIds).toEqual(["colony-1"]);
});
it("asks for a colony instead of sending nothing", async () => {
await fillBulkForm();
await userEvent.click(screen.getByRole("button", { name: "Alle abwählen" }));
await userEvent.click(screen.getByRole("button", SAVE));
expect(await screen.findByText("Bitte mindestens ein Volk wählen.")).toBeInTheDocument();
expect(createTreatmentsBulk).not.toHaveBeenCalled();
});
it("offline queues exactly one bulk entry", async () => {
vi.mocked(createTreatmentsBulk).mockRejectedValue(new TypeError("Failed to fetch"));
await fillBulkForm();
await userEvent.click(screen.getByRole("button", SAVE));
await waitFor(() => expect(queueBulk).toHaveBeenCalledTimes(1));
const [kind, input, key, owner] = vi.mocked(queueBulk).mock.calls[0];
expect(kind).toBe("treatments");
expect(input).toMatchObject({ colonyIds: ["colony-1", "colony-2"], product: "Oxuvar" });
expect(key).toBe(vi.mocked(createTreatmentsBulk).mock.calls[0][1]?.idempotencyKey);
expect(owner).toBe("user-1");
expect(createTreatment).not.toHaveBeenCalled();
await waitFor(() => expect(push).toHaveBeenCalledWith("/apiaries/apiary-1"));
});
it("a 400 names the ended colony by code and keeps the form", async () => {
vi.mocked(createTreatmentsBulk).mockRejectedValue(
new ApiError(400, "Einige Völker sind nicht (mehr) erfassbar", {
invalidColonyIds: ["colony-2"],
}),
);
await fillBulkForm();
await userEvent.click(screen.getByRole("button", SAVE));
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("Einige Völker sind nicht (mehr) erfassbar: V-02");
expect(screen.getByLabelText(/^Mittel/)).toHaveValue("Oxuvar");
expect(screen.getByLabelText(/Chargennummer/)).toHaveValue("Ch-4711");
expect(push).not.toHaveBeenCalled();
expect(queueBulk).not.toHaveBeenCalled();
// The pack of the refused colony is reloaded at once.
await waitFor(() =>
expect(refreshPacksAfterSync).toHaveBeenCalledWith(
"user-1",
expect.any(Number),
undefined,
{ force: true, colonyIds: ["colony-2"] },
),
);
});
it("„Ohne V-02 senden“ sends the rest under a key of its own", async () => {
vi.mocked(createTreatmentsBulk)
.mockRejectedValueOnce(
new ApiError(400, "Einige Völker sind nicht (mehr) erfassbar", {
invalidColonyIds: ["colony-2"],
}),
)
.mockResolvedValue(SAVED as never);
await fillBulkForm();
await userEvent.click(screen.getByRole("button", SAVE));
await userEvent.click(await screen.findByRole("button", { name: "Ohne V-02 senden" }));
await waitFor(() => expect(createTreatmentsBulk).toHaveBeenCalledTimes(2));
const [first, second] = vi.mocked(createTreatmentsBulk).mock.calls;
expect(second[0].colonyIds).toEqual(["colony-1"]);
expect(second[1]?.idempotencyKey).not.toBe(first[1]?.idempotencyKey);
await waitFor(() =>
expect(toast.success).toHaveBeenCalledWith("Behandlung für 1 Volk gespeichert"),
);
});
it("shows „wird bereits verarbeitet“ as a request to wait, not as an error", async () => {
vi.mocked(createTreatmentsBulk).mockRejectedValue(
new ApiError(409, "Diese Anfrage wird bereits verarbeitet - bitte kurz warten"),
);
await fillBulkForm();
await userEvent.click(screen.getByRole("button", SAVE));
expect(
await screen.findByText("Wird gerade verarbeitet. Bitte gleich noch einmal speichern."),
).toBeInTheDocument();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
it("repeats the key for the same selection and renews it for another", async () => {
vi.mocked(createTreatmentsBulk).mockRejectedValue(new ApiError(400, "Menge fehlt"));
await fillBulkForm();
const save = screen.getByRole("button", SAVE);
await userEvent.click(save);
await waitFor(() => expect(createTreatmentsBulk).toHaveBeenCalledTimes(1));
await userEvent.click(save);
await waitFor(() => expect(createTreatmentsBulk).toHaveBeenCalledTimes(2));
await userEvent.click(screen.getByRole("checkbox", { name: "V-02 (Schwach)" }));
await userEvent.click(save);
await waitFor(() => expect(createTreatmentsBulk).toHaveBeenCalledTimes(3));
const keys = vi
.mocked(createTreatmentsBulk)
.mock.calls.map(([, options]) => options?.idempotencyKey);
expect(keys[1]).toBe(keys[0]);
expect(keys[2]).not.toBe(keys[0]);
});
});
});
@@ -6,6 +6,10 @@ import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Syringe } from "lucide-react";
import { createTreatment } from "@/lib/treatments/api";
import { createTreatmentsBulk } from "@/lib/bulk/api";
import { bulkDestination, bulkQueuedText, bulkSavedText, bulkSelectionError } from "@/lib/bulk/capture-texts";
import { useApiaryColonies } from "@/lib/bulk/use-apiary-colonies";
import { useBulkCapture } from "@/lib/bulk/use-bulk-capture";
import { queueTreatment } from "@/lib/offline/db";
import { useCaptureMutation } from "@/lib/offline/use-capture-mutation";
import { captureBackTarget } from "@/lib/forms/back-target";
@@ -19,24 +23,35 @@ import { FormBackLink } from "@/components/form/form-back-link";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Field } from "@/components/form/field";
import { LivingColonySelectField } from "@/components/colony-select-field";
import { BulkSubmitNote } from "@/components/form/bulk-submit-note";
import { CaptureColonyField } from "@/components/form/capture-colony-field";
import { FormSection } from "@/components/form/form-section";
import { StickySubmit } from "@/components/form/sticky-submit";
import { DraftBanner } from "@/components/form/draft-banner";
import {
TreatmentFormValues,
initialTreatmentValues,
toBulkTreatmentInput,
toTreatmentInput,
} from "./treatment-form-values";
type RequiredKey = "colonyId" | "date" | "product";
type RequiredKey = "colonyId" | "bulkColonies" | "date" | "product";
type FieldErrors = Partial<Record<RequiredKey, string>>;
const REQUIRED_KEYS: RequiredKey[] = ["colonyId", "date", "product"];
const SHARED_NOTE = "Gilt für alle gewählten Völker.";
const BATCH_HINT = "Nummer der Packung des Mittels, meist „Ch.-B.“ oder „Lot“.";
function requiredKeys(values: TreatmentFormValues): RequiredKey[] {
return [values.target === "bulk" ? "bulkColonies" : "colonyId", "date", "product"];
}
function validate(values: TreatmentFormValues): FieldErrors {
const errors: FieldErrors = {};
if (!values.colonyId) errors.colonyId = "Bitte ein Volk auswählen.";
if (values.target === "bulk") {
errors.bulkColonies = bulkSelectionError(values.bulkApiaryId, values.bulkColonyIds);
} else if (!values.colonyId) {
errors.colonyId = "Bitte ein Volk auswählen.";
}
if (!values.date) errors.date = "Bitte ein Datum angeben.";
if (!values.product.trim()) errors.product = "Bitte das Mittel angeben.";
return errors;
@@ -69,11 +84,34 @@ export default function NewTreatmentPage() {
setValues((current) => ({ ...current, [key]: value }));
setDirty(true);
markEdited(key);
if ((REQUIRED_KEYS as string[]).includes(key)) {
setFieldErrors((current) => ({ ...current, [key]: undefined }));
}
// Whatever was wrong with the field is wrong no longer; the colonies of a
// bulk capture are one field in three values.
const field = key === "bulkColonyIds" || key === "bulkApiaryId" ? "bulkColonies" : key;
setFieldErrors((current) => ({ ...current, [field]: undefined }));
}
const isBulk = values.target === "bulk";
const apiaryColonies = useApiaryColonies(isBulk ? values.bulkApiaryId : null);
const bulkCapture = useBulkCapture({
kind: "treatments",
create: createTreatmentsBulk,
failureMessage: "Behandlung konnte nicht gespeichert werden.",
onSaved: (outcome, input) => {
draft.clear();
const count = input.colonyIds.length;
if (outcome.via === "queued") {
toast.info(bulkQueuedText("Behandlung", count));
} else {
for (const colonyId of input.colonyIds) {
void queryClient.invalidateQueries({ queryKey: ["colonies", colonyId, "treatments"] });
}
toast.success(bulkSavedText("Behandlung", count));
}
router.push(bulkDestination(values.bulkApiaryId));
},
});
const capture = useCaptureMutation({
create: createTreatment,
queue: queueTreatment,
@@ -100,20 +138,35 @@ export default function NewTreatmentPage() {
event.preventDefault();
const errors = validate(values);
setFieldErrors(errors);
const firstInvalid = REQUIRED_KEYS.find((key) => errors[key]);
const firstInvalid = requiredKeys(values).find((key) => errors[key]);
if (firstInvalid) {
document
.querySelector<HTMLElement>(`[data-field="${firstInvalid}"]`)
?.focus();
return;
}
if (isBulk) {
bulkCapture.submit(toBulkTreatmentInput(values));
return;
}
capture.submit(toTreatmentInput(values));
}
/** „Ohne V-3 senden“: the refused colonies leave the selection, the rest is sent. */
function resendWithoutRefused() {
const refused = new Set(bulkCapture.invalidColonyIds);
const next = { ...values, bulkColonyIds: values.bulkColonyIds.filter((id) => !refused.has(id)) };
setValues(next);
if (next.bulkColonyIds.length > 0) {
bulkCapture.submit(toBulkTreatmentInput(next));
}
}
function restoreDraft() {
const restored = draft.restore();
if (restored) {
setValues(restored);
// A draft from before the bulk choice existed lacks its values.
setValues({ ...initialTreatmentValues(""), ...restored });
resetPrefill();
setDirty(true);
}
@@ -148,11 +201,21 @@ export default function NewTreatmentPage() {
noValidate
>
<FormSection step={1} title="Was, welches Volk, wann?">
<LivingColonySelectField
triggerId="treatment-colony"
value={values.colonyId}
onChange={(colonyId) => set("colonyId", colonyId)}
error={fieldErrors.colonyId}
<CaptureColonyField
idPrefix="treatment"
target={values.target}
onTargetChange={(target) => set("target", target)}
colonyId={values.colonyId}
onColonyChange={(colonyId) => set("colonyId", colonyId)}
colonyError={fieldErrors.colonyId}
bulkApiaryId={values.bulkApiaryId}
onBulkApiaryChange={(apiaryId) => {
set("bulkApiaryId", apiaryId);
set("bulkColonyIds", []);
}}
bulkColonyIds={values.bulkColonyIds}
onBulkColonyIdsChange={(ids) => set("bulkColonyIds", ids)}
bulkError={fieldErrors.bulkColonies}
/>
<Field label="Datum" required error={fieldErrors.date}>
{({ id, describedBy, invalid }) => (
@@ -234,7 +297,7 @@ export default function NewTreatmentPage() {
</Field>
<Field
label="Chargennummer"
hint="Nummer der Packung des Mittels, meist „Ch.-B.“ oder „Lot“."
hint={isBulk ? `${BATCH_HINT} ${SHARED_NOTE}` : BATCH_HINT}
>
{({ id, describedBy }) => (
<Input
@@ -387,7 +450,16 @@ export default function NewTreatmentPage() {
<StickySubmit
note={
capture.submitError ? (
isBulk ? (
<BulkSubmitNote
submitError={bulkCapture.submitError}
isRetryLater={bulkCapture.isRetryLater}
invalidColonyIds={bulkCapture.invalidColonyIds}
colonies={apiaryColonies.data?.items}
isPending={bulkCapture.isPending}
onResendWithout={resendWithoutRefused}
/>
) : capture.submitError ? (
<p
role="alert"
className="text-sm font-medium text-destructive"
@@ -400,9 +472,9 @@ export default function NewTreatmentPage() {
<Button
type="submit"
className="h-12 text-base"
disabled={capture.isPending}
disabled={capture.isPending || bulkCapture.isPending}
>
{capture.isPending
{capture.isPending || bulkCapture.isPending
? "Wird gespeichert…"
: "Behandlung speichern"}
</Button>
@@ -1,10 +1,16 @@
import type { createTreatment } from "@/lib/treatments/api";
import type { BulkCreateTreatmentInput } from "@/lib/bulk/api";
import type { CaptureTarget } from "@/components/form/capture-target-toggle";
import { toApiDate, todayLocalIsoDate } from "@/lib/dates";
import { emptyToNull } from "@/lib/forms/empty-to-null";
/** Everything the form holds, as plain values that survive a draft save. */
export interface TreatmentFormValues {
colonyId: string;
/** One colony, or the same treatment for several. */
target: CaptureTarget;
bulkApiaryId: string | null;
bulkColonyIds: string[];
date: string;
startDate: string;
endDate: string;
@@ -24,6 +30,9 @@ export interface TreatmentFormValues {
export function initialTreatmentValues(colonyId: string): TreatmentFormValues {
return {
colonyId,
target: "single",
bulkApiaryId: null,
bulkColonyIds: [],
date: todayLocalIsoDate(),
startDate: "",
endDate: "",
@@ -41,11 +50,9 @@ export function initialTreatmentValues(colonyId: string): TreatmentFormValues {
};
}
export function toTreatmentInput(
values: TreatmentFormValues,
): Parameters<typeof createTreatment>[0] {
/** Everything of the treatment that is the same for one colony or for many. */
function sharedTreatmentFields(values: TreatmentFormValues) {
return {
colonyId: values.colonyId,
date: toApiDate(values.date),
startDate: emptyToNull(values.startDate, toApiDate),
endDate: emptyToNull(values.endDate, toApiDate),
@@ -62,3 +69,14 @@ export function toTreatmentInput(
prescribingVet: emptyToNull(values.prescribingVet),
};
}
export function toTreatmentInput(
values: TreatmentFormValues,
): Parameters<typeof createTreatment>[0] {
return { colonyId: values.colonyId, ...sharedTreatmentFields(values) };
}
/** One treatment for the chosen colonies; the batch number is what the user typed. */
export function toBulkTreatmentInput(values: TreatmentFormValues): BulkCreateTreatmentInput {
return { ...sharedTreatmentFields(values), colonyIds: values.bulkColonyIds };
}
@@ -1,9 +1,13 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import NewVarroaMeasurementPage from "./page";
import { toast } from "sonner";
import { createVarroaMeasurement, fetchVarroaThreshold } from "@/lib/varroa/api";
import { createVarroaBulk } from "@/lib/bulk/api";
import { queueBulk } from "@/lib/offline/db";
import { fetchApiaries } from "@/lib/apiaries/api";
import { fetchColonies } from "@/lib/colonies/api";
const push = vi.fn();
@@ -17,7 +21,19 @@ vi.mock("@/lib/varroa/api", async (importOriginal) => ({
createVarroaMeasurement: vi.fn(),
fetchVarroaThreshold: vi.fn(),
}));
vi.mock("@/lib/offline/db", () => ({ queueVarroaMeasurement: vi.fn() }));
vi.mock("@/lib/bulk/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/bulk/api")>()),
createVarroaBulk: vi.fn(),
}));
vi.mock("@/lib/apiaries/api", () => ({ fetchApiaries: vi.fn() }));
vi.mock("@/lib/apiaries/offline-pack", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/apiaries/offline-pack")>()),
listPacks: vi.fn().mockResolvedValue([]),
refreshPacksAfterSync: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/auth-context", () => ({ useCurrentUserId: () => "user-1" }));
vi.mock("@/lib/offline/db", () => ({ queueVarroaMeasurement: vi.fn(), queueBulk: vi.fn() }));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), info: vi.fn(), error: vi.fn() } }));
function renderPage() {
const client = new QueryClient({
@@ -38,6 +54,11 @@ describe("NewVarroaMeasurementPage", () => {
{ id: "colony-1", code: "V-01", status: "ACTIVE" },
] as never);
vi.mocked(createVarroaMeasurement).mockReset();
vi.mocked(createVarroaBulk).mockReset();
vi.mocked(queueBulk).mockReset().mockResolvedValue(undefined);
vi.mocked(fetchApiaries)
.mockReset()
.mockResolvedValue([{ id: "apiary-1", name: "Waldrand" }] as never);
vi.mocked(fetchVarroaThreshold).mockResolvedValue({ thresholdMitesPerDay: 10 });
});
@@ -168,4 +189,119 @@ describe("NewVarroaMeasurementPage", () => {
await userEvent.type(screen.getByLabelText(/^Tage/), "0");
expect(screen.getByRole("alert")).toHaveTextContent("größer als 0");
});
describe("for several colonies", () => {
const BULK_COLONIES = [
{ id: "colony-1", code: "V-01", status: "ACTIVE", apiaryId: "apiary-1" },
{ id: "colony-2", code: "V-02", status: "ACTIVE", apiaryId: "apiary-1" },
];
const SAVE = { name: "Messung speichern" };
async function startBulk() {
vi.mocked(fetchColonies).mockResolvedValue(BULK_COLONIES as never);
renderPage();
await userEvent.click(screen.getByRole("radio", { name: "Mehrere Völker" }));
await userEvent.click(screen.getByRole("combobox", { name: /Stand/ }));
await userEvent.click(await screen.findByRole("option", { name: "Waldrand" }));
await screen.findByText("2 von 2 Völkern gewählt");
}
it("bulk sends entries with each colony's own count", async () => {
vi.mocked(createVarroaBulk).mockResolvedValue({ bulkId: "b-1", records: [] } as never);
await startBulk();
await userEvent.type(screen.getByLabelText(/Milbenzahl V-01/), "30");
await userEvent.type(screen.getByLabelText(/Milbenzahl V-02/), "9");
await userEvent.click(screen.getByRole("radio", { name: "3 Tage" }));
await userEvent.click(screen.getByRole("button", SAVE));
await waitFor(() => expect(createVarroaBulk).toHaveBeenCalledTimes(1));
const [input, options] = vi.mocked(createVarroaBulk).mock.calls[0];
expect(input).toMatchObject({
method: "NATURAL_MITE_FALL",
durationMinutes: 4320,
entries: [
{ colonyId: "colony-1", miteCount: 30 },
{ colonyId: "colony-2", miteCount: 9 },
],
});
expect(input).not.toHaveProperty("miteCount");
expect(input).not.toHaveProperty("colonyId");
expect(options?.idempotencyKey).toBeTruthy();
expect(createVarroaMeasurement).not.toHaveBeenCalled();
expect(toast.success).toHaveBeenCalledWith("Varroamessung für 2 Völker gespeichert");
expect(push).toHaveBeenCalledWith("/apiaries/apiary-1");
});
it("has no shared mite count and shows rate and light per colony", async () => {
await startBulk();
expect(screen.queryByLabelText(/^Milbenzahl(?! V-)/)).not.toBeInTheDocument();
await userEvent.type(screen.getByLabelText(/Milbenzahl V-01/), "30");
await userEvent.type(screen.getByLabelText(/Milbenzahl V-02/), "3");
await userEvent.click(screen.getByRole("radio", { name: "3 Tage" }));
const first = screen.getByRole("group", { name: "V-01" });
const second = screen.getByRole("group", { name: "V-02" });
expect(await within(first).findByRole("status")).toHaveTextContent(
"10,0 Milben/Tag – über der Schwelle",
);
expect(within(second).getByRole("status")).toHaveTextContent(
"1,0 Milben/Tag – unter der Schwelle",
);
});
it("says in words which colonies still lack a count, and when none does", async () => {
await startBulk();
expect(screen.getByText("Noch ohne Milbenzahl: V-01, V-02")).toBeInTheDocument();
await userEvent.type(screen.getByLabelText(/Milbenzahl V-01/), "30");
expect(screen.getByText("Noch ohne Milbenzahl: V-02")).toBeInTheDocument();
await userEvent.type(screen.getByLabelText(/Milbenzahl V-02/), "9");
expect(screen.queryByText(/Noch ohne Milbenzahl/)).not.toBeInTheDocument();
expect(screen.getByText("Jedes Volk hat eine Milbenzahl.")).toBeInTheDocument();
});
it("asks for a count for every selected colony", async () => {
await startBulk();
await userEvent.type(screen.getByLabelText(/Milbenzahl V-01/), "30");
await userEvent.click(screen.getByRole("button", SAVE));
expect(await screen.findByText("Bitte die Milbenzahl für V-02 eintragen.")).toBeInTheDocument();
expect(screen.getByLabelText(/Milbenzahl V-02/)).toHaveFocus();
expect(createVarroaBulk).not.toHaveBeenCalled();
});
it("offline queues exactly one bulk entry", async () => {
vi.mocked(createVarroaBulk).mockRejectedValue(new TypeError("Failed to fetch"));
await startBulk();
await userEvent.type(screen.getByLabelText(/Milbenzahl V-01/), "30");
await userEvent.type(screen.getByLabelText(/Milbenzahl V-02/), "9");
await userEvent.click(screen.getByRole("button", SAVE));
await waitFor(() => expect(queueBulk).toHaveBeenCalledTimes(1));
expect(vi.mocked(queueBulk).mock.calls[0][0]).toBe("varroa");
expect(vi.mocked(queueBulk).mock.calls[0][1]).toMatchObject({
entries: [
{ colonyId: "colony-1", miteCount: 30 },
{ colonyId: "colony-2", miteCount: 9 },
],
});
await waitFor(() => expect(push).toHaveBeenCalledWith("/apiaries/apiary-1"));
});
it("keeps each colony's count when another colony is taken out and put back", async () => {
await startBulk();
await userEvent.type(screen.getByLabelText(/Milbenzahl V-02/), "9");
await userEvent.click(screen.getByRole("checkbox", { name: "V-02" }));
expect(screen.queryByLabelText(/Milbenzahl V-02/)).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("checkbox", { name: "V-02" }));
expect(screen.getByLabelText(/Milbenzahl V-02/)).toHaveValue(9);
});
});
});
@@ -6,6 +6,16 @@ import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Bug } from "lucide-react";
import { createVarroaMeasurement } from "@/lib/varroa/api";
import { createVarroaBulk } from "@/lib/bulk/api";
import {
bulkDestination,
bulkQueuedText,
bulkSavedText,
bulkSelectionError,
} from "@/lib/bulk/capture-texts";
import { useApiaryColonies } from "@/lib/bulk/use-apiary-colonies";
import { useBulkCapture } from "@/lib/bulk/use-bulk-capture";
import { BulkMiteCounts } from "@/components/varroa/bulk-mite-counts";
import { mitesPerDay } from "@/lib/varroa/rate";
import { emptyToNull } from "@/lib/forms/empty-to-null";
import { VarroaPeriodField } from "@/components/varroa/varroa-period-field";
@@ -24,7 +34,8 @@ import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Field } from "@/components/form/field";
import { ChoiceGroup, choiceOptions } from "@/components/form/choice-group";
import { LivingColonySelectField } from "@/components/colony-select-field";
import { BulkSubmitNote } from "@/components/form/bulk-submit-note";
import { CaptureColonyField } from "@/components/form/capture-colony-field";
import { FormSection } from "@/components/form/form-section";
import { StickySubmit } from "@/components/form/sticky-submit";
import { DraftBanner } from "@/components/form/draft-banner";
@@ -32,18 +43,29 @@ import {
VARROA_METHOD_HINTS,
VarroaFormValues,
initialVarroaValues,
toBulkVarroaInput,
toVarroaInput,
} from "./varroa-form-values";
type RequiredKey = "colonyId" | "date";
type RequiredKey = "colonyId" | "bulkColonies" | "date";
type FieldErrors = Partial<Record<RequiredKey, string>>;
/** What is missing at each colony of a bulk count, by colony id. */
type CountErrors = Record<string, string | undefined>;
const REQUIRED_KEYS: RequiredKey[] = ["colonyId", "date"];
const SHARED_NOTE = "Gilt für alle gewählten Völker.";
const VARROA_METHOD_OPTIONS = choiceOptions(VARROA_METHOD_LABELS, VARROA_METHODS);
function requiredKeys(values: VarroaFormValues): RequiredKey[] {
return [values.target === "bulk" ? "bulkColonies" : "colonyId", "date"];
}
function validate(values: VarroaFormValues): FieldErrors {
const errors: FieldErrors = {};
if (!values.colonyId) errors.colonyId = "Bitte ein Volk auswählen.";
if (values.target === "bulk") {
errors.bulkColonies = bulkSelectionError(values.bulkApiaryId, values.bulkColonyIds);
} else if (!values.colonyId) {
errors.colonyId = "Bitte ein Volk auswählen.";
}
if (!values.date) errors.date = "Bitte ein Datum angeben.";
return errors;
}
@@ -62,6 +84,7 @@ export default function NewVarroaMeasurementPage() {
);
const [dirty, setDirty] = useState(false);
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const [countErrors, setCountErrors] = useState<CountErrors>({});
const draft = useFormDraft("varroa-new", values, dirty);
function set<K extends keyof VarroaFormValues>(
@@ -70,11 +93,43 @@ export default function NewVarroaMeasurementPage() {
) {
setValues((current) => ({ ...current, [key]: value }));
setDirty(true);
if ((REQUIRED_KEYS as string[]).includes(key)) {
setFieldErrors((current) => ({ ...current, [key]: undefined }));
}
// The colonies of a bulk capture are one field in three values.
const field = key === "bulkColonyIds" || key === "bulkApiaryId" ? "bulkColonies" : key;
setFieldErrors((current) => ({ ...current, [field]: undefined }));
}
function setMiteCount(colonyId: string, count: string) {
set("bulkMiteCounts", { ...values.bulkMiteCounts, [colonyId]: count });
setCountErrors((current) => ({ ...current, [colonyId]: undefined }));
}
const isBulk = values.target === "bulk";
const apiaryColonies = useApiaryColonies(isBulk ? values.bulkApiaryId : null);
const chosenColonies = (apiaryColonies.data?.items ?? []).filter((colony) =>
values.bulkColonyIds.includes(colony.id),
);
const bulkCapture = useBulkCapture({
kind: "varroa",
create: createVarroaBulk,
failureMessage: "Varroamessung konnte nicht gespeichert werden.",
onSaved: (outcome, input) => {
draft.clear();
const count = input.entries.length;
if (outcome.via === "queued") {
toast.info(bulkQueuedText("Varroamessung", count));
} else {
for (const { colonyId } of input.entries) {
void queryClient.invalidateQueries({
queryKey: ["colonies", colonyId, "varroa-measurements"],
});
}
toast.success(bulkSavedText("Varroamessung", count));
}
router.push(bulkDestination(values.bulkApiaryId));
},
});
const capture = useCaptureMutation({
create: createVarroaMeasurement,
queue: queueVarroaMeasurement,
@@ -101,20 +156,57 @@ export default function NewVarroaMeasurementPage() {
event.preventDefault();
const errors = validate(values);
setFieldErrors(errors);
const firstInvalid = REQUIRED_KEYS.find((key) => errors[key]);
const firstInvalid = requiredKeys(values).find((key) => errors[key]);
if (firstInvalid) {
document
.querySelector<HTMLElement>(`[data-field="${firstInvalid}"]`)
?.focus();
return;
}
if (isBulk) {
submitBulk(values);
return;
}
capture.submit(toVarroaInput(values));
}
/** Every chosen colony needs its own count: an empty one would be a measurement of nothing. */
function submitBulk(current: VarroaFormValues) {
const missing = chosenColonies.filter(
(colony) => (current.bulkMiteCounts[colony.id] ?? "").trim() === "",
);
setCountErrors(
Object.fromEntries(
missing.map((colony) => [colony.id, `Bitte die Milbenzahl für ${colony.code} eintragen.`]),
),
);
if (missing.length > 0) {
document
.querySelector<HTMLElement>(`[data-field="miteCount-${missing[0].id}"]`)
?.focus();
return;
}
bulkCapture.submit(toBulkVarroaInput(current));
}
/** „Ohne V-3 senden“: the refused colonies leave the selection, the rest is sent. */
function resendWithoutRefused() {
const refused = new Set(bulkCapture.invalidColonyIds);
const next = {
...values,
bulkColonyIds: values.bulkColonyIds.filter((id) => !refused.has(id)),
};
setValues(next);
if (next.bulkColonyIds.length > 0) {
bulkCapture.submit(toBulkVarroaInput(next));
}
}
function restoreDraft() {
const restored = draft.restore();
if (restored) {
setValues(restored);
// A draft from before the bulk choice existed lacks its values.
setValues({ ...initialVarroaValues(""), ...restored });
setDirty(true);
}
}
@@ -157,11 +249,21 @@ export default function NewVarroaMeasurementPage() {
noValidate
>
<FormSection step={1} title="Welches Volk, wann?">
<LivingColonySelectField
triggerId="varroa-colony"
value={values.colonyId}
onChange={(colonyId) => set("colonyId", colonyId)}
error={fieldErrors.colonyId}
<CaptureColonyField
idPrefix="varroa"
target={values.target}
onTargetChange={(target) => set("target", target)}
colonyId={values.colonyId}
onColonyChange={(colonyId) => set("colonyId", colonyId)}
colonyError={fieldErrors.colonyId}
bulkApiaryId={values.bulkApiaryId}
onBulkApiaryChange={(apiaryId) => {
set("bulkApiaryId", apiaryId);
set("bulkColonyIds", []);
}}
bulkColonyIds={values.bulkColonyIds}
onBulkColonyIdsChange={(ids) => set("bulkColonyIds", ids)}
bulkError={fieldErrors.bulkColonies}
/>
<Field label="Datum" required error={fieldErrors.date}>
{({ id, describedBy, invalid }) => (
@@ -180,7 +282,11 @@ export default function NewVarroaMeasurementPage() {
</Field>
</FormSection>
<FormSection step={2} title="Wie gemessen?">
<FormSection
step={2}
title="Wie gemessen?"
description={isBulk ? `Methode und Zeitraum. ${SHARED_NOTE}` : undefined}
>
<Field label="Methode" group hint={VARROA_METHOD_HINTS[values.method]}>
{({ id, labelId, describedBy }) => (
<ChoiceGroup
@@ -196,7 +302,22 @@ export default function NewVarroaMeasurementPage() {
</Field>
</FormSection>
<FormSection step={3} title="Ergebnis">
<FormSection
step={3}
title="Ergebnis"
description={isBulk ? "Eine Milbenzahl je Volk." : undefined}
>
{isBulk ? (
<BulkMiteCounts
colonies={chosenColonies}
counts={values.bulkMiteCounts}
onCountChange={setMiteCount}
durationMinutes={values.durationMinutes}
showsRate={isNaturalFall}
threshold={threshold}
errors={countErrors}
/>
) : (
<Field
label="Milbenzahl"
hint={
@@ -218,6 +339,7 @@ export default function NewVarroaMeasurementPage() {
/>
)}
</Field>
)}
{isNaturalFall ? (
<>
<VarroaPeriodField
@@ -225,10 +347,7 @@ export default function NewVarroaMeasurementPage() {
onChange={(minutes) => set("durationMinutes", minutes)}
hint="Wie lange die Windel lag."
/>
<VarroaRateLight
rate={rate}
threshold={threshold}
/>
{isBulk ? null : <VarroaRateLight rate={rate} threshold={threshold} />}
</>
) : (
<Field
@@ -258,20 +377,22 @@ export default function NewVarroaMeasurementPage() {
title="Weitere Angaben"
collapsible
defaultOpen={false}
fieldCount={2}
fieldCount={isBulk ? 1 : 2}
>
<Field label="Einschätzung" hint="z. B. „unter Schadschwelle“">
{({ id, describedBy }) => (
<Input
id={id}
aria-describedby={describedBy}
className="h-11"
value={values.assessment}
onChange={(event) => set("assessment", event.target.value)}
/>
)}
</Field>
<Field label="Notizen">
{isBulk ? null : (
<Field label="Einschätzung" hint="z. B. „unter Schadschwelle“">
{({ id, describedBy }) => (
<Input
id={id}
aria-describedby={describedBy}
className="h-11"
value={values.assessment}
onChange={(event) => set("assessment", event.target.value)}
/>
)}
</Field>
)}
<Field label="Notizen" hint={isBulk ? SHARED_NOTE : undefined}>
{({ id }) => (
<Textarea
id={id}
@@ -284,7 +405,16 @@ export default function NewVarroaMeasurementPage() {
<StickySubmit
note={
capture.submitError ? (
isBulk ? (
<BulkSubmitNote
submitError={bulkCapture.submitError}
isRetryLater={bulkCapture.isRetryLater}
invalidColonyIds={bulkCapture.invalidColonyIds}
colonies={apiaryColonies.data?.items}
isPending={bulkCapture.isPending}
onResendWithout={resendWithoutRefused}
/>
) : capture.submitError ? (
<p
role="alert"
className="text-sm font-medium text-destructive"
@@ -297,9 +427,11 @@ export default function NewVarroaMeasurementPage() {
<Button
type="submit"
className="h-12 text-base"
disabled={capture.isPending}
disabled={capture.isPending || bulkCapture.isPending}
>
{capture.isPending ? "Wird gespeichert…" : "Messung speichern"}
{capture.isPending || bulkCapture.isPending
? "Wird gespeichert…"
: "Messung speichern"}
</Button>
</StickySubmit>
</form>
@@ -1,11 +1,19 @@
import type { createVarroaMeasurement } from "@/lib/varroa/api";
import type { VarroaMethod } from "@/lib/varroa/types";
import type { BulkCreateVarroaInput } from "@/lib/bulk/api";
import type { CaptureTarget } from "@/components/form/capture-target-toggle";
import { toApiDate, todayLocalIsoDate } from "@/lib/dates";
import { emptyToNull } from "@/lib/forms/empty-to-null";
/** Everything the form holds, as plain values that survive a draft save. */
export interface VarroaFormValues {
colonyId: string;
/** One colony, or one count for several - each with its own mites. */
target: CaptureTarget;
bulkApiaryId: string | null;
bulkColonyIds: string[];
/** The mite count typed for each colony of a bulk count, by colony id. */
bulkMiteCounts: Record<string, string>;
date: string;
method: VarroaMethod;
miteCount: string;
@@ -29,6 +37,10 @@ export const VARROA_METHOD_HINTS: Record<VarroaMethod, string> = {
export function initialVarroaValues(colonyId: string): VarroaFormValues {
return {
colonyId,
target: "single",
bulkApiaryId: null,
bulkColonyIds: [],
bulkMiteCounts: {},
date: todayLocalIsoDate(),
method: "NATURAL_MITE_FALL",
miteCount: "",
@@ -51,3 +63,21 @@ export function toVarroaInput(
notes: emptyToNull(values.notes),
};
}
/**
* One count for the chosen colonies: date, method, duration and notes are
* shared, every colony brings its own mites. There is no shared mite count
* and no shared assessment - a reading belongs to one colony.
*/
export function toBulkVarroaInput(values: VarroaFormValues): BulkCreateVarroaInput {
return {
date: toApiDate(values.date),
method: values.method,
durationMinutes: emptyToNull(values.durationMinutes, Number),
notes: emptyToNull(values.notes),
entries: values.bulkColonyIds.map((colonyId) => ({
colonyId,
miteCount: emptyToNull(values.bulkMiteCounts[colonyId] ?? "", Number),
})),
};
}
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { reportRequestOutcome } from "@/lib/offline/connectivity";
import LoginPage from "./page";
const pushMock = vi.fn();
@@ -14,12 +15,13 @@ vi.mock("next/navigation", () => ({
const loginMock = vi.fn();
const verifyMfaMock = vi.fn();
const completeSsoMock = vi.fn();
let signedInUser: { id: string } | null = null;
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({
login: loginMock,
verifyMfa: verifyMfaMock,
completeSso: completeSsoMock,
user: null,
user: signedInUser,
isLoading: false,
logout: vi.fn(),
}),
@@ -39,6 +41,8 @@ describe("LoginPage", () => {
loginMock.mockReset();
verifyMfaMock.mockReset();
completeSsoMock.mockReset();
signedInUser = null;
reportRequestOutcome("reached-server");
searchParamsMock.mockReturnValue(new URLSearchParams());
ssoStatusRequestMock.mockReset().mockResolvedValue(false);
ssoLoginUrlMock.mockClear();
@@ -157,4 +161,32 @@ describe("LoginPage", () => {
expect(verifyMfaMock).toHaveBeenCalledWith("sso-mfa-token", "123456");
expect(pushMock).toHaveBeenCalledWith("/dashboard");
});
it("sends someone who is already signed in on to the dashboard instead of showing the form", () => {
signedInUser = { id: "user-1" };
render(<LoginPage />);
expect(replaceMock).toHaveBeenCalledWith("/dashboard");
});
it("does not redirect while nobody is signed in", () => {
render(<LoginPage />);
expect(replaceMock).not.toHaveBeenCalledWith("/dashboard");
});
it("says that signing in needs a connection when offline, but still lets the person try", async () => {
act(() => reportRequestOutcome("network-error"));
render(<LoginPage />);
expect(screen.getByRole("status")).toHaveTextContent("Anmelden braucht eine Internetverbindung.");
expect(screen.getByRole("button", { name: /anmelden/i })).toBeEnabled();
});
it("shows no connection hint while online", () => {
render(<LoginPage />);
expect(screen.queryByText("Anmelden braucht eine Internetverbindung.")).not.toBeInTheDocument();
});
});
+17 -1
View File
@@ -6,6 +6,7 @@ import { useRouter, useSearchParams } from "next/navigation";
import { Hexagon } from "lucide-react";
import { useAuth } from "@/lib/auth/auth-context";
import { ssoLoginUrl, ssoStatusRequest } from "@/lib/auth/api";
import { useConnectivity } from "@/lib/offline/connectivity";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -40,7 +41,8 @@ export default function LoginPage() {
function LoginPageContent() {
const router = useRouter();
const searchParams = useSearchParams();
const { login, verifyMfa, completeSso } = useAuth();
const { login, verifyMfa, completeSso, user, isLoading } = useAuth();
const connectivity = useConnectivity();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [code, setCode] = useState("");
@@ -61,6 +63,15 @@ function LoginPageContent() {
// waiting on a router.replace() round trip to first clear the param.
const [isCompletingSso, setIsCompletingSso] = useState(ssoPending);
// Someone who is signed in (an offline session counts) has nothing to do
// here; signing in on top of a running session would swap the account
// under the offline queue.
useEffect(() => {
if (!isLoading && user) {
router.replace("/dashboard");
}
}, [isLoading, user, router]);
useEffect(() => {
ssoStatusRequest().then(setSsoEnabled);
ssoLoginUrl().then(setSsoLoginHref);
@@ -230,6 +241,11 @@ function LoginPageContent() {
{error}
</p>
) : null}
{/* Always in the page, so the text is announced when it appears.
The button stays usable: the network state can be stale. */}
<p role="status" className="text-sm text-muted-foreground empty:hidden">
{connectivity === "offline" ? "Anmelden braucht eine Internetverbindung." : null}
</p>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Anmelden…" : "Anmelden"}
</Button>