feat: T4 wave 4 - rules suggest, takeover, chains, autopilot A1-A3, suggestions page, varroa bands UI

- Automation rules propose suggestions instead of creating tasks;
  notify per rule config; corrected varroa measurements refresh or
  resolve their suggestion
- Inspection values can be taken over into the colony card
  (occupiedFrames 0-40, strength deprecated); warning counts use the
  same visibility rule as the notification list
- Biotech chains: swarm check after queen cells, queen acceptance
  check, split schedules (raise own, queen cell, queen)
- Season autopilot core with A1 inspection due, A2 varroa measurement,
  A3 recheck after treatment; colony sync and daily step
- Frontend: suggestions page with groups, bulk accept, undo, paging,
  focus management and permission gating
- Frontend: varroa traffic light from seasonal bands, sample size,
  one range request for the colony chart
- Notifications: mark-read answers 404 for rows the user may no
  longer see

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 15:22:36 +02:00
1 parent 64a2b7dad9
commit 02eb16924e
142 files changed
+11847 -1251

No files matched your search

@@ -2,12 +2,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ColonyVarroaCard } from "./colony-varroa";
import { fetchColonyVarroaMeasurements, fetchVarroaThreshold } from "@/lib/varroa/api";
import { fetchColonyVarroaMeasurements, fetchVarroaThresholdRange } from "@/lib/varroa/api";
import type { VarroaBand, VarroaRating, VarroaThresholdSegments } from "@/lib/varroa/bands";
import type { VarroaMeasurement, VarroaMethod } from "@/lib/varroa/types";
vi.mock("@/lib/varroa/api", () => ({
fetchColonyVarroaMeasurements: vi.fn(),
fetchVarroaThreshold: vi.fn(),
fetchVarroaThresholdRange: vi.fn(),
}));
vi.mock("./varroa-edit-dialog", () => ({ VarroaEditDialog: () => null }));
@@ -16,6 +17,7 @@ function measurement(
date: string,
method: VarroaMethod,
calculatedValue: number | null,
rating: VarroaRating | null = null,
): VarroaMeasurement {
return {
id,
@@ -25,13 +27,39 @@ function measurement(
method,
durationMinutes: 4320,
miteCount: 12,
sampleBees: null,
calculatedValue,
assessment: null,
notes: null,
createdAt: `${date}T00:00:00.000Z`,
rating,
};
}
const SEASON: VarroaBand = {
method: "NATURAL_MITE_FALL",
unit: "MITES_PER_DAY",
yellowFrom: 5,
redAbove: 10,
reason: "NATURAL_FALL_SEASON",
youngColony: false,
};
const WINTER: VarroaBand = { ...SEASON, yellowFrom: null, redAbove: 0.5, reason: "NATURAL_FALL_WINTER" };
/** Winter from 1 November, season before; one segment per side of the change inside the range. */
function segmentsFor(from: string, to: string): VarroaThresholdSegments {
const part = (start: string, end: string, band: VarroaBand) => ({
from: start,
to: end,
bands: { NATURAL_MITE_FALL: band },
postTreatmentUntil: null,
active: true,
});
if (to < "2026-11-01") return { segments: [part(from, to, SEASON)] };
if (from >= "2026-11-01") return { segments: [part(from, to, WINTER)] };
return { segments: [part(from, "2026-10-31", SEASON), part("2026-11-01", to, WINTER)] };
}
const MEASUREMENTS = [
measurement("a", "2026-06-01", "NATURAL_MITE_FALL", 4),
measurement("b", "2026-06-08", "POWDERED_SUGAR", 99),
@@ -50,7 +78,10 @@ function renderCard() {
describe("ColonyVarroaCard trend", () => {
beforeEach(() => {
vi.mocked(fetchColonyVarroaMeasurements).mockResolvedValue(MEASUREMENTS);
vi.mocked(fetchVarroaThreshold).mockResolvedValue({ thresholdMitesPerDay: 10 });
vi.mocked(fetchVarroaThresholdRange).mockReset();
vi.mocked(fetchVarroaThresholdRange).mockImplementation(async (_colonyId, from, to) =>
segmentsFor(from, to),
);
});
it("the chart ignores powdered sugar counts", async () => {
@@ -59,18 +90,62 @@ describe("ColonyVarroaCard trend", () => {
expect(container.querySelectorAll("svg circle")).toHaveLength(2);
});
it("draws the threshold line", async () => {
it("the chart draws the band as a monthly step line with an accessible summary", async () => {
const { container } = renderCard();
expect(await screen.findByText("Schwelle 10/Tag")).toBeInTheDocument();
expect(container.querySelector("svg line[stroke-dasharray]")).not.toBeNull();
expect(await screen.findByText("Grenze 10/Tag")).toBeInTheDocument();
const red = container.querySelector("svg path[data-band='red']");
expect(red).not.toBeNull();
expect(container.querySelector("svg path[data-band='yellow']")).not.toBeNull();
expect(vi.mocked(fetchVarroaThresholdRange)).toHaveBeenCalledWith("c-1", "2026-06-01", "2026-06-30");
const chart = screen.getByRole("img");
expect(chart).toHaveAccessibleName(/Letzte Messung 8,0 Milben\/Tag am 15\.06\.2026, Grenze 10\/Tag/);
expect(chart).toHaveAccessibleDescription(/2 Messungen.*Rote Grenze je Monat: Juni 10/);
});
it("has an accessible summary", async () => {
it("steps down in winter and leaves a gap where there is no yellow stage", async () => {
vi.mocked(fetchColonyVarroaMeasurements).mockResolvedValue([
measurement("a", "2026-10-20", "NATURAL_MITE_FALL", 4),
measurement("c", "2026-11-20", "NATURAL_MITE_FALL", 0.2),
]);
const { container } = renderCard();
await screen.findByText("Grenze 0,5/Tag");
const red = container.querySelector("svg path[data-band='red']")?.getAttribute("d") ?? "";
expect(red).toMatch(/^M[\d.]+,[\d.]+ H[\d.]+ V[\d.]+ H[\d.]+$/);
const yellow = container.querySelector("svg path[data-band='yellow']")?.getAttribute("d") ?? "";
expect(yellow.match(/M/g)).toHaveLength(1);
expect(yellow).not.toContain("V");
expect(screen.getByRole("img")).toHaveAccessibleDescription(/Oktober 10, November 0,5/);
});
it("asks once for a 24-month trend", async () => {
vi.mocked(fetchColonyVarroaMeasurements).mockResolvedValue([
measurement("a", "2024-08-10", "NATURAL_MITE_FALL", 4),
measurement("b", "2026-07-20", "NATURAL_MITE_FALL", 6),
]);
renderCard();
await screen.findByText("Schwelle 10/Tag");
const chart = screen.getByRole("img");
expect(chart).toHaveAccessibleName(/Letzte Messung 8,0 Milben\/Tag am 15\.06\.2026, Schwelle 10\/Tag/);
expect(chart).toHaveAccessibleDescription(/2 Messungen/);
await screen.findByText("Grenze 10/Tag");
expect(vi.mocked(fetchVarroaThresholdRange)).toHaveBeenCalledTimes(1);
expect(vi.mocked(fetchVarroaThresholdRange)).toHaveBeenCalledWith("c-1", "2024-08-01", "2026-07-31");
});
it("rates a late-evening UTC measurement on its Berlin day and month", async () => {
vi.mocked(fetchColonyVarroaMeasurements).mockResolvedValue([
{ ...measurement("a", "2026-08-31", "NATURAL_MITE_FALL", 4), date: "2026-08-31T22:30:00.000Z" },
measurement("b", "2026-09-10", "NATURAL_MITE_FALL", 6),
]);
renderCard();
await screen.findByText("Grenze 10/Tag");
expect(vi.mocked(fetchVarroaThresholdRange)).toHaveBeenCalledWith("c-1", "2026-09-01", "2026-09-30");
expect(screen.getByRole("img")).toHaveAccessibleDescription(/2 Messungen von 01.09.2026 bis 10.09.2026/);
});
it("draws no band while the thresholds are unknown", async () => {
vi.mocked(fetchVarroaThresholdRange).mockRejectedValue(new Error("offline"));
const { container } = renderCard();
await screen.findByRole("img");
expect(container.querySelector("svg path[data-band]")).toBeNull();
expect(screen.getByRole("img")).toHaveAccessibleName(/Letzte Messung 8,0 Milben\/Tag am 15\.06\.2026$/);
});
it("shows only the value as text with fewer than two natural-fall points", async () => {
@@ -90,4 +165,27 @@ describe("ColonyVarroaCard trend", () => {
await screen.findByRole("img");
expect(container.querySelectorAll("svg circle")).toHaveLength(3);
});
it("names the stored rating of each measurement in words", async () => {
vi.mocked(fetchColonyVarroaMeasurements).mockResolvedValue([
measurement("a", "2026-08-01", "NATURAL_MITE_FALL", 12.4, {
light: "red",
reasonCode: "NATURAL_FALL_SEASON",
band: SEASON,
postTreatmentUntil: null,
}),
measurement("b", "2026-08-10", "NATURAL_MITE_FALL", 8, {
light: "POST_TREATMENT",
reasonCode: "POST_TREATMENT",
band: null,
postTreatmentUntil: "2026-08-16",
}),
{ ...measurement("c", "2026-08-12", "POWDERED_SUGAR", 2.4), sampleBees: 500 },
]);
renderCard();
expect(await screen.findByText("Kritisch (Grenze 10, LWG)")).toBeInTheDocument();
expect(screen.getByText("Behandlungseffekt – Nachkontrolle ab 16.08.")).toBeInTheDocument();
expect(screen.getByText(/Probe 500 Bienen · 2,4 %/)).toBeInTheDocument();
expect(screen.getByText(/12 Milben · 12,4 Milben\/Tag/)).toBeInTheDocument();
});
});
@@ -4,11 +4,13 @@ import Link from "next/link";
import { useId } from "react";
import { Bug } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { fetchColonyVarroaMeasurements } from "@/lib/varroa/api";
import { fetchColonyVarroaMeasurements, fetchVarroaThresholdRange } from "@/lib/varroa/api";
import { VarroaBand, ratingNote, segmentAt, valueText } from "@/lib/varroa/bands";
import { VarroaMeasurement } from "@/lib/varroa/types";
import { formatMitesPerDay, formatThreshold } from "@/lib/varroa/format";
import { useVarroaThreshold } from "@/lib/varroa/use-varroa-threshold";
import { berlinCalendarDay } from "@/lib/dates";
import { VARROA_METHOD_LABELS } from "@/lib/varroa/labels";
import { VarroaNoteLabel } from "@/components/varroa/varroa-rate-light";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/empty-state";
import { QueryState } from "@/components/query-state";
@@ -23,42 +25,147 @@ const PAD_BOTTOM = 12;
const MARKER_RADIUS = 4;
const LABEL_GAP = 4;
const MAX_BAND_MONTHS = 24;
const ONE_HOUR_MS = 60 * 60 * 1000;
const DAY_MS = 24 * 60 * 60 * 1000;
interface TrendPoint {
id: string;
time: number;
rate: number;
}
/** The band of the natural mite fall in force during one calendar month. */
interface MonthLimit {
/** First day of the month, UTC, in ms. */
start: number;
/** First day of the next month, UTC, in ms. */
end: number;
band: VarroaBand | null;
}
interface TimeRange {
from: number;
to: number;
}
type LimitKey = "redAbove" | "yellowFrom";
const DAY_FORMAT = new Intl.DateTimeFormat("de-DE", {
timeZone: "UTC",
day: "2-digit",
month: "2-digit",
year: "numeric",
});
const MONTH_FORMAT = new Intl.DateTimeFormat("de-DE", { timeZone: "UTC", month: "long" });
function formatDate(time: number): string {
return DAY_FORMAT.format(new Date(time));
}
/** Only the natural mite fall is a rate per day; other methods are not comparable. */
/**
* Only the natural mite fall is a rate per day; other methods are not comparable.
* A point sits at midnight UTC of its Berlin calendar day, the day the backend rates it on.
*/
function trendPoints(measurements: VarroaMeasurement[]): TrendPoint[] {
return measurements
.filter((m) => m.method === "NATURAL_MITE_FALL" && m.calculatedValue !== null)
.map((m) => ({ id: m.id, time: new Date(m.date).getTime(), rate: m.calculatedValue as number }))
.map((m) => ({ id: m.id, time: Date.parse(`${berlinCalendarDay(m.date)}T00:00:00Z`), rate: m.calculatedValue as number }))
.sort((left, right) => left.time - right.time);
}
function summaryOf(last: TrendPoint, threshold: number | null): string {
function nextMonthStart(start: number): number {
const date = new Date(start);
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 1);
}
/** The first days of the months the points span, the latest MAX_BAND_MONTHS at most. */
function monthStarts(points: TrendPoint[]): number[] {
if (points.length === 0) return [];
const first = new Date(points[0].time);
const last = new Date(points[points.length - 1].time);
const stop = Date.UTC(last.getUTCFullYear(), last.getUTCMonth(), 1);
const starts: number[] = [];
let cursor = Date.UTC(first.getUTCFullYear(), first.getUTCMonth(), 1);
while (cursor <= stop) {
starts.push(cursor);
cursor = nextMonthStart(cursor);
}
return starts.slice(-MAX_BAND_MONTHS);
}
function isoDay(time: number): string {
return new Date(time).toISOString().slice(0, 10);
}
/** The natural-fall band of each month of the trend: one request for the whole range. */
function useMonthLimits(colonyId: string, points: TrendPoint[]): MonthLimit[] {
const starts = monthStarts(points);
const from = starts.length > 0 ? isoDay(starts[0]) : "";
const to = starts.length > 0 ? isoDay(nextMonthStart(starts[starts.length - 1]) - DAY_MS) : "";
const query = useQuery({
queryKey: ["varroa", "thresholds", colonyId, from, to],
queryFn: () => fetchVarroaThresholdRange(colonyId, from, to),
enabled: starts.length > 0,
staleTime: ONE_HOUR_MS,
});
const segments = query.data?.segments ?? [];
return starts.map((start) => ({
start,
end: nextMonthStart(start),
band: segmentAt(segments, isoDay(start))?.bands.NATURAL_MITE_FALL ?? null,
}));
}
/**
* A step line through the monthly limits: flat inside a month, a vertical
* step at the month change, a gap where a month has no such limit.
*/
function stepPath(
limits: MonthLimit[],
key: LimitKey,
xOf: (time: number) => number,
yOf: (value: number) => number,
range: TimeRange,
): string {
const parts: string[] = [];
let open = false;
for (const { start, end, band } of limits) {
const value = band ? band[key] : null;
const from = Math.max(start, range.from);
const to = Math.min(end, range.to);
if (value === null || to < from) {
open = false;
continue;
}
const y = yOf(value);
parts.push(open ? `V${y} H${xOf(to)}` : `M${xOf(from)},${y} H${xOf(to)}`);
open = true;
}
return parts.join(" ");
}
function monthLimitsText(limits: MonthLimit[], range: TimeRange): string {
return limits
.filter(({ band, start, end }) => band && end > range.from && start <= range.to)
.map(
({ start, band }) =>
`${MONTH_FORMAT.format(new Date(start))} ${formatThreshold((band as VarroaBand).redAbove)}`,
)
.join(", ");
}
function summaryOf(last: TrendPoint, lastLimit: VarroaBand | null): string {
const base = `Letzte Messung ${formatMitesPerDay(last.rate)} Milben/Tag am ${formatDate(last.time)}`;
return threshold === null ? base : `${base}, Schwelle ${formatThreshold(threshold)}/Tag`;
return lastLimit ? `${base}, Grenze ${formatThreshold(lastLimit.redAbove)}/Tag` : base;
}
function VarroaTrendChart({
points,
threshold,
limits,
}: {
points: TrendPoint[];
threshold: number | null;
limits: MonthLimit[];
}) {
const titleId = useId();
const descId = useId();
@@ -66,19 +173,26 @@ function VarroaTrendChart({
if (!last) {
return null;
}
const summary = summaryOf(last, threshold);
const lastLimit =
limits.find(({ start, end }) => last.time >= start && last.time < end)?.band ?? null;
const summary = summaryOf(last, lastLimit);
if (points.length < 2) {
return <p className="font-medium">{summary}</p>;
}
const first = points[0];
const yMax = Math.max(...points.map((p) => p.rate), threshold ?? 0, 1);
const range = { from: first.time, to: last.time };
const limitValues = limits.flatMap(({ band }) => (band ? [band.redAbove] : []));
const yMax = Math.max(...points.map((p) => p.rate), ...limitValues, 1);
const timeRange = last.time - first.time || 1;
const usableWidth = CHART_WIDTH - PAD_X * 2;
const usableHeight = CHART_HEIGHT - PAD_TOP - PAD_BOTTOM;
const xOf = (time: number) => PAD_X + ((time - first.time) / timeRange) * usableWidth;
const yOf = (rate: number) => PAD_TOP + usableHeight - (rate / yMax) * usableHeight;
const line = points.map((p) => `${xOf(p.time)},${yOf(p.rate)}`).join(" ");
const redPath = stepPath(limits, "redAbove", xOf, yOf, range);
const yellowPath = stepPath(limits, "yellowFrom", xOf, yOf, range);
const monthly = monthLimitsText(limits, range);
return (
<svg
@@ -90,30 +204,31 @@ function VarroaTrendChart({
>
<title id={titleId}>{`Milbenfall pro Tag. ${summary}`}</title>
<desc id={descId}>
{`${points.length} Messungen von ${formatDate(first.time)} bis ${formatDate(last.time)}. ${summary}`}
{`${points.length} Messungen von ${formatDate(first.time)} bis ${formatDate(last.time)}. ${summary}.${
monthly ? ` Rote Grenze je Monat: ${monthly}.` : ""
}`}
</desc>
{threshold !== null ? (
<g className="text-muted-foreground">
<line
x1={PAD_X}
x2={CHART_WIDTH - PAD_X}
y1={yOf(threshold)}
y2={yOf(threshold)}
stroke="currentColor"
strokeWidth={1.5}
strokeDasharray="6 4"
/>
<text
x={CHART_WIDTH - PAD_X}
y={yOf(threshold) - LABEL_GAP}
textAnchor="end"
fontSize={10}
fill="currentColor"
>
{`Schwelle ${formatThreshold(threshold)}/Tag`}
</text>
{redPath || yellowPath ? (
<g className="text-muted-foreground" fill="none" stroke="currentColor">
{yellowPath ? (
<path data-band="yellow" d={yellowPath} strokeWidth={1} strokeDasharray="2 3" />
) : null}
{redPath ? (
<path data-band="red" d={redPath} strokeWidth={1.5} strokeDasharray="6 4" />
) : null}
</g>
) : null}
{lastLimit ? (
<text
x={CHART_WIDTH - PAD_X}
y={yOf(lastLimit.redAbove) - LABEL_GAP}
textAnchor="end"
fontSize={10}
className="fill-muted-foreground"
>
{`Grenze ${formatThreshold(lastLimit.redAbove)}/Tag`}
</text>
) : null}
<polyline points={line} fill="none" stroke="currentColor" strokeWidth={2} />
{points.map((p) => (
<circle
@@ -130,13 +245,28 @@ function VarroaTrendChart({
);
}
function MeasurementDetails({ measurement }: { measurement: VarroaMeasurement }) {
const note = ratingNote(measurement.rating);
return (
<>
<p className="text-muted-foreground">
{measurement.miteCount !== null ? `${measurement.miteCount} Milben` : "—"}
{measurement.sampleBees !== null ? ` · Probe ${measurement.sampleBees} Bienen` : ""}
{measurement.calculatedValue !== null
? ` · ${valueText(measurement.method, measurement.calculatedValue, formatMitesPerDay)}`
: ""}
</p>
{note ? <VarroaNoteLabel note={note} className="mt-2" /> : null}
</>
);
}
export function ColonyVarroaCard({ colonyId }: { colonyId: string }) {
const measurementsQuery = useQuery({
queryKey: ["colonies", colonyId, "varroa-measurements"],
queryFn: () => fetchColonyVarroaMeasurements(colonyId),
});
const threshold = useVarroaThreshold();
const limits = useMonthLimits(colonyId, trendPoints(measurementsQuery.data ?? []));
return (
<Card>
@@ -165,7 +295,7 @@ export function ColonyVarroaCard({ colonyId }: { colonyId: string }) {
>
{(measurements) => (
<>
<VarroaTrendChart points={trendPoints(measurements)} threshold={threshold} />
<VarroaTrendChart points={trendPoints(measurements)} limits={limits} />
{measurements.map((measurement) => (
<div key={measurement.id} className="rounded-md border p-3">
<div className="flex items-center justify-between">
@@ -175,12 +305,7 @@ export function ColonyVarroaCard({ colonyId }: { colonyId: string }) {
</p>
<VarroaEditDialog measurement={measurement} />
</div>
<p className="text-muted-foreground">
{measurement.miteCount !== null ? `${measurement.miteCount} Milben` : "—"}
{measurement.calculatedValue !== null
? ` · ${measurement.calculatedValue.toFixed(1)} Milben/Tag`
: ""}
</p>
<MeasurementDetails measurement={measurement} />
{measurement.assessment ? <p>Einschätzung: {measurement.assessment}</p> : null}
</div>
))}
@@ -3,6 +3,7 @@
import { FormEvent, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { updateTreatment } from "@/lib/treatments/api";
import { invalidateVarroaBands } from "@/lib/varroa/use-varroa-bands";
import { Treatment } from "@/lib/treatments/types";
import { emptyToNull } from "@/lib/forms/empty-to-null";
import { errorMessage } from "@/lib/forms/error-message";
@@ -81,6 +82,8 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
queryKey: ["colonies", affectedColonyId, "treatments"],
});
}
// A treatment moves the post-treatment window of the varroa lights.
void invalidateVarroaBands(queryClient);
setOpen(false);
},
onError: (error) =>
@@ -18,6 +18,7 @@ const measurement: VarroaMeasurement = {
method: "NATURAL_MITE_FALL",
durationMinutes: 4320,
miteCount: 12,
sampleBees: null,
calculatedValue: 4,
assessment: "mittel",
notes: "Windel verrutscht",
@@ -90,11 +91,46 @@ describe("VarroaEditDialog", () => {
expect(input).toMatchObject({ durationMinutes: 2000 });
});
it("keeps the minutes field for other methods", async () => {
await openDialog({ ...measurement, method: "WASHING_METHOD" });
it("keeps the minutes field for the drone brood check", async () => {
await openDialog({ ...measurement, method: "DRONE_BROOD_CONTROL" });
expect(screen.getByLabelText(/^Zeitraum \(Minuten\)/)).toHaveValue(4320);
});
it("the sample methods have no minutes field, a stored value is sent back", async () => {
await openDialog({ ...measurement, method: "WASHING_METHOD", sampleBees: 300 });
expect(screen.queryByLabelText(/^Zeitraum \(Minuten\)/)).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(vi.mocked(updateVarroaMeasurement).mock.calls[0][1]).toMatchObject({ durationMinutes: 4320 });
});
it.each(["0", "2001", "2.5"])("refuses a sample of %s and sends nothing", async (bad) => {
await openDialog({ ...measurement, method: "POWDERED_SUGAR", sampleBees: 300 });
const field = screen.getByLabelText(/Probe \(Bienen\)/);
await userEvent.clear(field);
await userEvent.type(field, bad);
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(screen.getByText("Bitte 1 bis 2000 Bienen angeben.")).toBeInTheDocument();
expect(updateVarroaMeasurement).not.toHaveBeenCalled();
});
it("a stored sampleBees is sent back unchanged from the edit dialog", async () => {
await openDialog({ ...measurement, method: "POWDERED_SUGAR", sampleBees: 300, calculatedValue: 4 });
expect(screen.getByLabelText(/Probe \(Bienen\)/)).toHaveValue(300);
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
const [, input] = vi.mocked(updateVarroaMeasurement).mock.calls[0];
expect(input).toMatchObject({ sampleBees: 300, miteCount: 12 });
});
it("shows no sample field for the natural mite fall and sends null", async () => {
await openDialog();
expect(screen.queryByLabelText(/Probe \(Bienen\)/)).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(vi.mocked(updateVarroaMeasurement).mock.calls[0][1]).toMatchObject({ sampleBees: null });
});
it("explains that a manual mites-per-day value takes precedence", async () => {
await openDialog();
expect(screen.getByText(/beim Speichern .* neu berechnet/)).toBeInTheDocument();
@@ -28,6 +28,8 @@ import {
} from "./edit-dialog-parts";
import { toApiDate } from "@/lib/dates";
import { VarroaPeriodField } from "@/components/varroa/varroa-period-field";
import { SampleBeesField } from "@/components/varroa/sample-bees-field";
import { sampleBeesError, usesSample } from "@/lib/varroa/bands";
export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasurement }) {
const queryClient = useQueryClient();
@@ -36,6 +38,7 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme
const [date, setDate] = useState(measurement.date.slice(0, 10));
const [method, setMethod] = useState<VarroaMethod>(measurement.method);
const [miteCount, setMiteCount] = useState(measurement.miteCount?.toString() ?? "");
const [sampleBees, setSampleBees] = useState(measurement.sampleBees?.toString() ?? "");
const [durationMinutes, setDurationMinutes] = useState(
measurement.durationMinutes?.toString() ?? "",
);
@@ -45,8 +48,12 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme
const [assessment, setAssessment] = useState(measurement.assessment ?? "");
const [notes, setNotes] = useState(measurement.notes ?? "");
const [dateError, setDateError] = useState<string | null>(null);
const [sampleError, setSampleError] = useState<string | null>(null);
const [submitError, setSubmitError] = useState<string | null>(null);
const isNaturalFall = method === "NATURAL_MITE_FALL";
const hasSample = usesSample(method);
const mutation = useMutation({
mutationFn: () =>
updateVarroaMeasurement(measurement.id, {
@@ -54,6 +61,7 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme
date: toApiDate(date),
method,
miteCount: emptyToNull(miteCount, Number),
sampleBees: hasSample ? emptyToNull(sampleBees, Number) : null,
durationMinutes: emptyToNull(durationMinutes, Number),
calculatedValue: emptyToNull(calculatedValue, Number),
assessment: emptyToNull(assessment),
@@ -73,7 +81,6 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme
setSubmitError(errorMessage(error, "Varroamessung konnte nicht gespeichert werden.")),
});
const isNaturalFall = method === "NATURAL_MITE_FALL";
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
@@ -82,6 +89,12 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme
setDateError(DATE_REQUIRED_MESSAGE);
return;
}
const invalidSample = hasSample ? sampleBeesError(sampleBees) : null;
setSampleError(invalidSample);
if (invalidSample) {
document.querySelector<HTMLElement>('[data-field="sampleBees"]')?.focus();
return;
}
mutation.mutate();
}
@@ -147,7 +160,7 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme
/>
)}
</Field>
{isNaturalFall ? null : (
{isNaturalFall || hasSample ? null : (
<Field label="Zeitraum (Minuten)">
{({ id }) => (
<Input
@@ -163,9 +176,19 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme
</Field>
)}
</div>
{hasSample ? (
<SampleBeesField
value={sampleBees}
onChange={(next) => {
setSampleBees(next);
setSampleError(null);
}}
error={sampleError}
/>
) : null}
<Field
label="Milben pro Tag"
hint="Wird beim Speichern aus Milbenzahl und Zeitraum neu berechnet – ein eigener, geänderter Wert hat Vorrang."
label={hasSample ? "Befall (%)" : "Milben pro Tag"}
hint={`Wird beim Speichern aus Milbenzahl und ${hasSample ? "Probe" : "Zeitraum"} neu berechnet – ein eigener, geänderter Wert hat Vorrang.`}
>
{({ id }) => (
<Input
@@ -6,6 +6,7 @@ import { usePathname } from "next/navigation";
import { Menu } from "lucide-react";
import { SheetTrigger } from "@/components/ui/sheet";
import { cn } from "@/lib/utils";
import { NavCounter } from "@/components/suggestions/nav-counter";
import type { AuthUser } from "@/lib/auth/auth-context";
import { BOTTOM_NAV_HREFS, isNavActive, visibleNavGroups } from "./nav-config";
import { NAV_SHEET_ROW_CLASS, NavSheet } from "@/components/nav/nav-sheet";
@@ -70,6 +71,7 @@ export function MoreSheet({ user }: { user: Pick<AuthUser, "permissions"> }) {
>
<Icon className="size-6 shrink-0" aria-hidden="true" />
{item.label}
{item.counter ? <NavCounter counter={item.counter} active={isActive} /> : null}
</Link>
);
})}
@@ -55,4 +55,14 @@ describe("nav-config", () => {
expect(hrefs([])).not.toContain("/winter");
expect(hrefs(["winter-checks:read"])).toContain("/winter");
});
it("„Vorschläge“ needs tasks:read and shows the open count", () => {
expect(hrefs([])).not.toContain("/suggestions");
const work = visibleNavGroups({ permissions: ["tasks:read"] }).find(
(group) => group.label === "Arbeit",
);
const item = work?.items.find((entry) => entry.href === "/suggestions");
expect(item?.label).toBe("Vorschläge");
expect(item?.counter).toBe("suggestions");
});
});
@@ -11,6 +11,7 @@ import {
History,
Layers,
LayoutDashboard,
Lightbulb,
ListTodo,
type LucideIcon,
MapPin,
@@ -41,6 +42,8 @@ export interface NavItem {
// together several previously-separate settings pages, each gated by its
// own permission.
permission?: string | string[];
/** A number shown next to the label; the data comes from the matching hook. */
counter?: "suggestions";
}
export interface NavGroup {
@@ -79,6 +82,13 @@ export const NAV_GROUPS: NavGroup[] = [
label: "Arbeit",
items: [
{ label: "Aufgaben", href: "/tasks", icon: ListTodo },
{
label: "Vorschläge",
href: "/suggestions",
icon: Lightbulb,
permission: "tasks:read",
counter: "suggestions",
},
{ label: "Kalender", href: "/calendar", icon: CalendarDays },
],
},
@@ -31,6 +31,8 @@ function rule(
};
}
let lastClient: QueryClient;
function renderTab(rules: AutomationRule[]) {
vi.mocked(fetchAutomationRules).mockResolvedValue(rules);
vi.mocked(updateAutomationRule).mockImplementation(
@@ -38,6 +40,7 @@ function renderTab(rules: AutomationRule[]) {
({ ...rules.find((r) => r.id === id)!, ...input }) as AutomationRule,
);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
lastClient = client;
return render(
<QueryClientProvider client={client}>
<AutomationRulesTab />
@@ -93,6 +96,19 @@ describe("varroa rule form", () => {
expect(config.notify).toBe(true);
});
it("saving the varroa rule marks the cached thresholds stale", async () => {
const user = userEvent.setup();
renderTab([VARROA]);
lastClient.setQueryData(["varroa", "thresholds", "c-1", "2026-08-10"], { bands: {} });
const view = await card(VARROA_TITLE);
await user.click(view.getByRole("button", { name: "Speichern" }));
await waitFor(() =>
expect(lastClient.getQueryState(["varroa", "thresholds", "c-1", "2026-08-10"])?.isInvalidated).toBe(true),
);
});
it("a 400 issue appears under its field", async () => {
const user = userEvent.setup();
renderTab([VARROA]);
@@ -18,6 +18,7 @@ import { RuleFormProps } from "./rule-forms/rule-form-parts";
import { SeasonAutopilotForm } from "./rule-forms/season-autopilot-form";
import { SplitScheduleForm } from "./rule-forms/split-schedule-form";
import { RuleConfigValue } from "./rule-forms/use-rule-draft";
import { invalidateVarroaBands } from "@/lib/varroa/use-varroa-bands";
import { VarroaThresholdForm } from "./rule-forms/varroa-threshold-form";
import { WaterContentForm } from "./rule-forms/water-content-form";
@@ -54,6 +55,9 @@ export function SpecialRuleCard({ rule }: { rule: AutomationRule }) {
onSuccess: () => {
setIssues({});
void queryClient.invalidateQueries({ queryKey: ["automation-rules"] });
if (rule.triggerType === "VARROA_THRESHOLD") {
void invalidateVarroaBands(queryClient);
}
toast.success("Regel gespeichert");
},
onError: (error) => {
@@ -4,6 +4,7 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import { FileClock, Hexagon } from "lucide-react";
import { cn } from "@/lib/utils";
import { NavCounter } from "@/components/suggestions/nav-counter";
import type { AuthUser } from "@/lib/auth/auth-context";
import { isNavActive, type NavItem, visibleNavGroups } from "./nav-config";
@@ -28,6 +29,7 @@ function SidebarLink({ item, pathname }: { item: NavItem; pathname: string }) {
)}
/>
<span className="truncate">{item.label}</span>
{item.counter ? <NavCounter counter={item.counter} active={isActive} /> : null}
</Link>
);
}
@@ -0,0 +1,457 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { toast } from "sonner";
import { suggestionFixture } from "@/components/suggestions/suggestion-fixtures";
import { Toaster } from "@/components/ui/sonner";
import { ApiError } from "@/lib/auth/api-client";
import {
acceptSuggestion,
bulkAcceptSuggestions,
dismissSuggestion,
fetchSuggestions,
fetchSuggestionSummary,
reopenSuggestion,
} from "@/lib/suggestions/api";
import { readRound } from "@/lib/rounds/round-state";
import type { Suggestion } from "@/lib/suggestions/types";
import SuggestionsPage from "./page";
const auth = vi.hoisted(() => ({
user: { id: "user-1" } as { id: string } | null,
permissions: new Set(["tasks:read", "tasks:create", "tasks:update"]),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
}));
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({
user: auth.user,
hasPermission: (permission: string) => auth.permissions.has(permission),
}),
}));
vi.mock("@/lib/suggestions/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/suggestions/api")>()),
fetchSuggestions: vi.fn(),
fetchSuggestionSummary: vi.fn(),
acceptSuggestion: vi.fn(),
bulkAcceptSuggestions: vi.fn(),
dismissSuggestion: vi.fn(),
bulkDismissSuggestions: vi.fn(),
reopenSuggestion: vi.fn(),
}));
const OPEN: Suggestion[] = [
suggestionFixture({ id: "s1", colonyId: "c1", colonyCode: "A1", intent: "HONEY_SUPER" }),
suggestionFixture({ id: "s2", colonyId: "c2", colonyCode: "A2", intent: "HONEY_SUPER" }),
suggestionFixture({
id: "s3",
mode: "PLAN",
intent: "STOCK_REORDER",
apiaryId: null,
apiaryName: null,
colonyId: null,
colonyCode: null,
title: "Gläser nachbestellen",
reasons: [],
}),
];
const SINGLE = OPEN[2];
function mockLists(
open: Suggestion[],
options: { dismissed?: Suggestion[]; pageSize?: number; total?: number } = {},
) {
const { dismissed = [], pageSize = Number.POSITIVE_INFINITY, total = open.length } = options;
vi.mocked(fetchSuggestions).mockImplementation(async (query) =>
query?.status === "DISMISSED"
? dismissed
: open.slice(query?.offset ?? 0, (query?.offset ?? 0) + pageSize),
);
vi.mocked(fetchSuggestionSummary).mockResolvedValue({
open: total,
byPriority: { LOW: 0, NORMAL: total, HIGH: 0, URGENT: 0 },
});
}
function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={client}>
<SuggestionsPage />
<Toaster />
</QueryClientProvider>,
);
}
function manyPlan(count: number): Suggestion[] {
return Array.from({ length: count }, (_, index) =>
suggestionFixture({ id: `m${index}`, colonyId: `c${index}`, colonyCode: `B${index}` }),
);
}
// jsdom has no matchMedia; Sonner's Toaster asks for the color scheme.
Object.defineProperty(window, "matchMedia", {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: () => undefined,
removeEventListener: () => undefined,
addListener: () => undefined,
removeListener: () => undefined,
dispatchEvent: () => false,
}),
});
// Sonner captures the pointer for swiping a toast.
HTMLElement.prototype.setPointerCapture = () => undefined;
HTMLElement.prototype.releasePointerCapture = () => undefined;
beforeEach(() => {
vi.clearAllMocks();
toast.dismiss();
window.sessionStorage.clear();
auth.user = { id: "user-1" };
auth.permissions = new Set(["tasks:read", "tasks:create", "tasks:update"]);
});
describe("SuggestionsPage", () => {
it("shows „3 Vorschläge“ and the explanation", async () => {
mockLists(OPEN);
renderPage();
expect(await screen.findByText("3 Vorschläge")).toBeInTheDocument();
expect(
screen.getByText("Übernehmen plant eine Aufgabe. Verwerfen blendet den Vorschlag aus."),
).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Hang" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Betrieb" })).toBeInTheDocument();
});
it("shows a row once when two pages overlap", async () => {
const rows = manyPlan(201);
vi.mocked(fetchSuggestions).mockImplementation(async (query) =>
query?.offset ? [rows[199], rows[200]] : rows.slice(0, 200),
);
vi.mocked(fetchSuggestionSummary).mockResolvedValue({
open: 201,
byPriority: { LOW: 0, NORMAL: 201, HIGH: 0, URGENT: 0 },
});
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Mehr laden" }));
expect(await screen.findByText("201 Vorschläge")).toBeInTheDocument();
});
it("does not offer more when the last page was short", async () => {
mockLists(OPEN, { total: 5 });
renderPage();
expect(await screen.findByText("3 von 5 Vorschlägen")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Mehr laden" })).not.toBeInTheDocument();
});
it("keeps the count region mounted while loading and when empty", async () => {
mockLists([]);
renderPage();
expect(screen.getByTestId("suggestion-count")).toHaveAttribute("role", "status");
expect(await screen.findByText("Keine offenen Vorschläge.")).toBeInTheDocument();
expect(screen.getByTestId("suggestion-count")).toBeInTheDocument();
});
it("counts from the summary and loads more pages with an offset", async () => {
mockLists(manyPlan(201), { pageSize: 200 });
renderPage();
expect(await screen.findByText("200 von 201 Vorschlägen")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Mehr laden" }));
expect(await screen.findByText("201 Vorschläge")).toBeInTheDocument();
expect(fetchSuggestions).toHaveBeenCalledWith({ status: "OPEN", limit: 200, offset: 200 });
expect(screen.queryByRole("button", { name: "Mehr laden" })).not.toBeInTheDocument();
});
it("„Alle übernehmen“ sends one bulk-accept with the group ids", async () => {
mockLists(OPEN);
vi.mocked(bulkAcceptSuggestions).mockResolvedValue([]);
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Alle übernehmen" }));
await waitFor(() => expect(bulkAcceptSuggestions).toHaveBeenCalledTimes(1));
expect(vi.mocked(bulkAcceptSuggestions).mock.calls[0][0]).toEqual(["s1", "s2"]);
expect(vi.mocked(bulkAcceptSuggestions).mock.calls[0][1]).toEqual({
idempotencyKey: expect.any(String),
});
expect(await screen.findByText("2 Aufgaben angelegt")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Ansehen" })).toBeInTheDocument();
});
it("reports how many were created when a later bulk chunk fails", async () => {
mockLists(manyPlan(51));
vi.mocked(bulkAcceptSuggestions)
.mockResolvedValueOnce([])
.mockRejectedValueOnce(new ApiError(409, "Der Vorschlag ist nicht mehr offen."));
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Alle übernehmen" }));
expect(
await screen.findByText("50 von 51 angelegt. Rest nicht übernommen – bitte erneut versuchen."),
).toBeInTheDocument();
expect(bulkAcceptSuggestions).toHaveBeenCalledTimes(2);
expect(vi.mocked(bulkAcceptSuggestions).mock.calls[0][0]).toHaveLength(50);
expect(vi.mocked(bulkAcceptSuggestions).mock.calls[1][0]).toHaveLength(1);
const keys = vi.mocked(bulkAcceptSuggestions).mock.calls.map((call) => call[1]?.idempotencyKey);
expect(new Set(keys).size).toBe(2);
await waitFor(() => expect(vi.mocked(fetchSuggestions).mock.calls.length).toBeGreaterThan(1));
});
it("accepting a single suggestion says „Aufgabe angelegt“ with „Ansehen“", async () => {
mockLists([SINGLE]);
vi.mocked(acceptSuggestion).mockResolvedValue({ suggestion: SINGLE, tasks: [] });
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Übernehmen" }));
expect(await screen.findByText("Aufgabe angelegt")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Ansehen" })).toBeInTheDocument();
expect(acceptSuggestion).toHaveBeenCalledWith("s3", {}, { idempotencyKey: expect.any(String) });
});
it("the due-date sheet accepts with the chosen day", async () => {
mockLists([SINGLE]);
vi.mocked(acceptSuggestion).mockResolvedValue({ suggestion: SINGLE, tasks: [] });
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Weitere Aktionen" }));
await userEvent.click(await screen.findByRole("menuitem", { name: "Termin ändern" }));
const dialog = await screen.findByRole("dialog");
const input = within(dialog).getByLabelText("Termin");
await userEvent.clear(input);
await userEvent.type(input, "2026-06-01");
await userEvent.click(within(dialog).getByRole("button", { name: "Übernehmen" }));
await waitFor(() =>
expect(acceptSuggestion).toHaveBeenCalledWith(
"s3",
{ dueDate: "2026-06-01" },
{ idempotencyKey: expect.any(String) },
),
);
});
it("„Verwerfen“ shows „Verworfen“ and the tapped „Rückgängig“ reopens", async () => {
mockLists([SINGLE]);
vi.mocked(dismissSuggestion).mockResolvedValue({ ...SINGLE, status: "DISMISSED" });
vi.mocked(reopenSuggestion).mockResolvedValue(SINGLE);
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Verwerfen" }));
expect(await screen.findByText("Verworfen")).toBeInTheDocument();
expect(dismissSuggestion).toHaveBeenCalledWith("s3", null, {
idempotencyKey: expect.any(String),
});
const undo = screen.getByRole("button", { name: "Rückgängig" });
expect(undo.className).toContain("min-h-11");
await userEvent.click(undo);
await waitFor(() =>
expect(reopenSuggestion).toHaveBeenCalledWith("s3", { idempotencyKey: expect.any(String) }),
);
});
it("explains it when „Rückgängig“ answers 409", async () => {
mockLists([SINGLE]);
vi.mocked(dismissSuggestion).mockResolvedValue({ ...SINGLE, status: "DISMISSED" });
vi.mocked(reopenSuggestion).mockRejectedValue(
new ApiError(409, "Für dieses Volk ist schon ein Vorschlag „Honigraum prüfen“ offen."),
);
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Verwerfen" }));
await userEvent.click(await screen.findByRole("button", { name: "Rückgängig" }));
expect(
await screen.findByText("Für dieses Volk ist schon ein Vorschlag „Honigraum prüfen“ offen."),
).toBeInTheDocument();
});
it("explains a 409 in the server's words and reloads", async () => {
mockLists([SINGLE]);
vi.mocked(acceptSuggestion).mockRejectedValue(
new ApiError(409, "Der Vorschlag ist nicht mehr offen."),
);
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Übernehmen" }));
expect(await screen.findByText("Der Vorschlag ist nicht mehr offen.")).toBeInTheDocument();
await waitFor(() => expect(vi.mocked(fetchSuggestions).mock.calls.length).toBeGreaterThan(1));
});
it("shows German texts for a 400 array, a 404 and a lost connection", async () => {
mockLists([SINGLE]);
vi.mocked(acceptSuggestion)
.mockRejectedValueOnce(new ApiError(400, "dueDate must match", { message: ["dueDate must match"] }))
.mockRejectedValueOnce(new ApiError(404, "Not Found"))
.mockRejectedValueOnce(new TypeError("Failed to fetch"));
renderPage();
const accept = await screen.findByRole("button", { name: "Übernehmen" });
await userEvent.click(accept);
expect(await screen.findByText("Die Eingabe ist ungültig.")).toBeInTheDocument();
await userEvent.click(await screen.findByRole("button", { name: "Übernehmen" }));
expect(await screen.findByText("Der Vorschlag ist nicht mehr da.")).toBeInTheDocument();
await userEvent.click(await screen.findByRole("button", { name: "Übernehmen" }));
expect(await screen.findByText("Keine Verbindung – nichts gespeichert.")).toBeInTheDocument();
});
it("moves focus to the next card after a decision", async () => {
mockLists([SINGLE, OPEN[0]]);
vi.mocked(dismissSuggestion).mockResolvedValue({ ...OPEN[0], status: "DISMISSED" });
renderPage();
const buttons = await screen.findAllByRole("button", { name: "Verwerfen" });
mockLists([SINGLE]);
await userEvent.click(buttons[0]);
await waitFor(() =>
expect(screen.getByRole("heading", { name: "Gläser nachbestellen" })).toHaveFocus(),
);
});
it("does not move focus after a failed decision, even when the list changes", async () => {
mockLists([SINGLE, OPEN[0]]);
vi.mocked(dismissSuggestion).mockRejectedValue(new ApiError(409, "Der Vorschlag ist nicht mehr offen."));
renderPage();
const buttons = await screen.findAllByRole("button", { name: "Verwerfen" });
mockLists([SINGLE]);
await userEvent.click(buttons[0]);
await screen.findByText("Der Vorschlag ist nicht mehr offen.");
await waitFor(() => expect(screen.queryByRole("heading", { name: "Honigraum prüfen" })).not.toBeInTheDocument());
expect(screen.getByTestId("suggestion-count")).not.toHaveFocus();
expect(screen.getByRole("heading", { name: "Gläser nachbestellen" })).not.toHaveFocus();
});
it("moves focus to the count region when the last card is gone", async () => {
mockLists([SINGLE]);
vi.mocked(dismissSuggestion).mockResolvedValue({ ...SINGLE, status: "DISMISSED" });
renderPage();
const dismiss = await screen.findByRole("button", { name: "Verwerfen" });
mockLists([]);
await userEvent.click(dismiss);
await waitFor(() => expect(screen.getByTestId("suggestion-count")).toHaveFocus());
});
it("focuses the restored card after „Rückgängig“", async () => {
mockLists([SINGLE, OPEN[0]]);
vi.mocked(dismissSuggestion).mockResolvedValue({ ...OPEN[0], status: "DISMISSED" });
vi.mocked(reopenSuggestion).mockResolvedValue(OPEN[0]);
renderPage();
const buttons = await screen.findAllByRole("button", { name: "Verwerfen" });
mockLists([SINGLE]);
await userEvent.click(buttons[0]);
const undo = await screen.findByRole("button", { name: "Rückgängig" });
mockLists([SINGLE, OPEN[0]]);
await userEvent.click(undo);
await waitFor(() =>
expect(screen.getByRole("heading", { name: "Honigraum prüfen" })).toHaveFocus(),
);
});
it("lists dismissed suggestions with „Wieder öffnen“", async () => {
const dismissed = suggestionFixture({
id: "d1",
status: "DISMISSED",
title: "Varroa messen",
decidedAt: "2026-05-10T08:00:00.000Z",
});
mockLists(OPEN, { dismissed: [dismissed] });
vi.mocked(reopenSuggestion).mockResolvedValue(dismissed);
renderPage();
const toggle = await screen.findByRole("button", { name: "Verworfene (30 Tage)" });
expect(toggle).toHaveAttribute("aria-expanded", "false");
await userEvent.click(toggle);
expect(toggle).toHaveAttribute("aria-expanded", "true");
const list = await screen.findByRole("list", { name: "Verworfene Vorschläge" });
expect(within(list).getByText("Varroa messen")).toBeInTheDocument();
await userEvent.click(within(list).getByRole("button", { name: "Wieder öffnen" }));
await waitFor(() =>
expect(reopenSuggestion).toHaveBeenCalledWith("d1", { idempotencyKey: expect.any(String) }),
);
});
it("shows „Keine offenen Vorschläge.“ when empty", async () => {
mockLists([]);
renderPage();
expect(await screen.findByText("Keine offenen Vorschläge.")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Verworfene (30 Tage)" })).toBeInTheDocument();
});
it("shows an error with retry instead of an empty list when loading fails", async () => {
vi.mocked(fetchSuggestions).mockRejectedValue(new Error("boom"));
vi.mocked(fetchSuggestionSummary).mockRejectedValue(new Error("boom"));
renderPage();
expect(await screen.findByRole("alert")).toHaveTextContent("Vorschläge konnten nicht geladen werden");
expect(screen.queryByText("Keine offenen Vorschläge.")).not.toBeInTheDocument();
});
it("„Runde starten“ starts a round with the due colonies in code order", async () => {
mockLists([
suggestionFixture({ id: "i1", mode: "DO", intent: "INSPECT", colonyId: "c10", colonyCode: "A10" }),
suggestionFixture({ id: "i2", mode: "DO", intent: "INSPECT", colonyId: "c2", colonyCode: "A2" }),
]);
renderPage();
const link = await screen.findByRole("link", { name: "Runde starten" });
expect(link).toHaveAttribute("href", "/inspections/new?colonyId=c2&round=a1");
await userEvent.click(link);
const round = readRound("user-1");
expect(round?.apiaryId).toBe("a1");
expect(round?.apiaryName).toBe("Hang");
expect(round?.colonies.map((c) => c.code)).toEqual(["A2", "A10"]);
});
it("„Runde starten“ without a user is a plain link to the first inspection", async () => {
auth.user = null;
mockLists([
suggestionFixture({ id: "i1", mode: "DO", intent: "INSPECT", colonyId: "c10", colonyCode: "A10" }),
suggestionFixture({ id: "i2", mode: "DO", intent: "INSPECT", colonyId: "c2", colonyCode: "A2" }),
]);
renderPage();
const link = await screen.findByRole("link", { name: "Runde starten" });
expect(link).toHaveAttribute("href", "/inspections/new?colonyId=c2");
});
it("hides the buttons the user may not use", async () => {
auth.permissions = new Set(["tasks:read"]);
mockLists(OPEN);
renderPage();
await screen.findByText("3 Vorschläge");
expect(screen.queryByRole("button", { name: "Übernehmen" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Alle übernehmen" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Verwerfen" })).not.toBeInTheDocument();
});
});
@@ -0,0 +1,408 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { ChevronDown, Lightbulb, RotateCcw } from "lucide-react";
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { EmptyState } from "@/components/empty-state";
import { PageHeader } from "@/components/page-header";
import { QueryState } from "@/components/query-state";
import { SuggestionGroup } from "@/components/suggestions/suggestion-group";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/lib/auth/auth-context";
import { newIdempotencyKey } from "@/lib/offline/idempotency";
import { startRound } from "@/lib/rounds/round-state";
import {
acceptSuggestion,
bulkAcceptSuggestions,
dismissSuggestion,
fetchSuggestions,
reopenSuggestion,
SUGGESTION_PAGE_SIZE,
SUGGESTIONS_KEY,
suggestionsKey,
} from "@/lib/suggestions/api";
import { suggestionErrorText } from "@/lib/suggestions/errors";
import { groupSuggestions, roundTarget } from "@/lib/suggestions/grouping";
import { INTENT_ICONS } from "@/lib/suggestions/labels";
import type { Suggestion } from "@/lib/suggestions/types";
import { useSuggestionSummary } from "@/lib/suggestions/use-suggestion-summary";
const EXPLANATION = "Übernehmen plant eine Aufgabe. Verwerfen blendet den Vorschlag aus.";
const UNDO_TOAST_MS = 8000;
/** The API takes 1 to 50 ids per bulk call. */
const BULK_LIMIT = 50;
const TASKS_HREF = "/tasks";
/** Sonner's own buttons are 24 px high; actions in a toast need 44 px. */
const TOAST_ACTION_CLASS = "h-11! min-h-11! px-4!";
/**
* What has to get focus once the list shows the result of a decision.
* `gone` is the decided suggestion: while it is still listed, nothing moved.
*/
type FocusTarget = { id: string } | "list";
type FocusRequest = { target: FocusTarget; gone: string | null } | null;
/** A request nobody answered (the list did not change) is dropped after this. */
const FOCUS_REQUEST_TTL_MS = 1000;
class PartialBulkError extends Error {
constructor(
readonly done: number,
readonly total: number,
readonly reason: unknown,
) {
super("Not all suggestions were accepted");
}
}
function countText(loaded: number, total: number): string {
if (loaded === 0) return "";
if (total > loaded) return `${loaded} von ${total} Vorschlägen`;
return total === 1 ? "1 Vorschlag" : `${total} Vorschläge`;
}
function taskCountText(count: number): string {
return count === 1 ? "Aufgabe angelegt" : `${count} Aufgaben angelegt`;
}
function chunk<T>(items: readonly T[], size: number): T[][] {
const chunks: T[][] = [];
for (let index = 0; index < items.length; index += size) {
chunks.push(items.slice(index, index + size));
}
return chunks;
}
function formatDay(iso: string | null): string {
return iso ? new Date(iso).toLocaleDateString("de-DE", { day: "numeric", month: "numeric" }) : "";
}
/** The card heading that follows (else precedes) the one of `id` on the page. */
function neighbourOf(id: string): FocusTarget {
const ids = [...document.querySelectorAll<HTMLElement>("[data-suggestion-heading]")].map(
(element) => element.dataset.suggestionHeading ?? "",
);
const index = ids.indexOf(id);
const next = index === -1 ? undefined : (ids[index + 1] ?? ids[index - 1]);
return next ? { id: next } : "list";
}
export default function SuggestionsPage() {
const router = useRouter();
const queryClient = useQueryClient();
const { user, hasPermission } = useAuth();
const userId = user?.id ?? null;
const canAccept = hasPermission("tasks:create");
const canDismiss = hasPermission("tasks:update");
const [showDismissed, setShowDismissed] = useState(false);
const countRef = useRef<HTMLParagraphElement>(null);
const pendingFocus = useRef<FocusRequest>(null);
const [pendingIds, setPendingIds] = useState<ReadonlySet<string>>(new Set());
const openQuery = useInfiniteQuery({
queryKey: [...suggestionsKey({ status: "OPEN" }), "pages"],
initialPageParam: 0,
queryFn: ({ pageParam }) =>
fetchSuggestions({
status: "OPEN",
limit: SUGGESTION_PAGE_SIZE,
...(pageParam ? { offset: pageParam } : {}),
}),
getNextPageParam: (lastPage, pages) =>
lastPage.length < SUGGESTION_PAGE_SIZE ? undefined : pages.flat().length,
});
// Pages can overlap when rows move between two loads: one row per id.
const rows = useMemo(
() =>
openQuery.data
? [...new Map(openQuery.data.pages.flat().map((row) => [row.id, row])).values()]
: undefined,
[openQuery.data],
);
const summary = useSuggestionSummary();
const dismissedQuery = useQuery({
queryKey: suggestionsKey({ status: "DISMISSED" }),
queryFn: () => fetchSuggestions({ status: "DISMISSED" }),
enabled: showDismissed,
});
// Focus follows the decision: the list has changed when `rows` does.
useEffect(() => {
const request = pendingFocus.current;
if (!request || !rows) return;
// The decided suggestion is still there: the list has not caught up yet.
if (request.gone && rows.some((row) => row.id === request.gone)) return;
pendingFocus.current = null;
// A frame later, so a closing sheet cannot restore focus over it.
requestAnimationFrame(() => {
const { target } = request;
const heading =
target === "list"
? null
: document.querySelector<HTMLElement>(
`[data-suggestion-heading="${CSS.escape(target.id)}"]`,
);
(heading ?? countRef.current)?.focus();
});
}, [rows]);
function requestFocus(request: NonNullable<FocusRequest>) {
pendingFocus.current = request;
window.setTimeout(() => {
if (pendingFocus.current === request) pendingFocus.current = null;
}, FOCUS_REQUEST_TTL_MS);
}
function markPending(ids: readonly string[]) {
setPendingIds((current) => new Set([...current, ...ids]));
}
function unmarkPending(ids: readonly string[]) {
setPendingIds((current) => new Set([...current].filter((id) => !ids.includes(id))));
}
// After any decision the lists, the count in the navigation and the tasks
// are reloaded - also after a failure, because a 409 means the list is stale.
function refresh() {
void queryClient.invalidateQueries({ queryKey: [SUGGESTIONS_KEY] });
void queryClient.invalidateQueries({ queryKey: ["tasks"] });
void queryClient.invalidateQueries({ queryKey: ["dashboard"] });
}
function handleError(error: unknown) {
pendingFocus.current = null;
if (error instanceof PartialBulkError && error.done > 0) {
toast.error(
`${error.done} von ${error.total} angelegt. Rest nicht übernommen – bitte erneut versuchen.`,
);
} else {
toast.error(suggestionErrorText(error instanceof PartialBulkError ? error.reason : error));
}
refresh();
}
const viewTasks = {
label: "Ansehen",
onClick: () => router.push(TASKS_HREF),
};
const acceptMutation = useMutation({
mutationFn: ({ suggestion, dueDate }: { suggestion: Suggestion; dueDate?: string }) =>
acceptSuggestion(suggestion.id, dueDate ? { dueDate } : {}, {
idempotencyKey: newIdempotencyKey(),
}),
onSuccess: (_result, { suggestion }) => {
requestFocus({ target: neighbourOf(suggestion.id), gone: suggestion.id });
refresh();
toast.success("Aufgabe angelegt", {
action: viewTasks,
classNames: { actionButton: TOAST_ACTION_CLASS },
});
},
onMutate: ({ suggestion }) => markPending([suggestion.id]),
onSettled: (_result, _error, { suggestion }) => unmarkPending([suggestion.id]),
onError: handleError,
});
const acceptAllMutation = useMutation({
mutationFn: async (ids: string[]) => {
// One key per chunk, fixed before the first call.
const parts = chunk(ids, BULK_LIMIT).map((part) => ({
ids: part,
idempotencyKey: newIdempotencyKey(),
}));
let done = 0;
for (const part of parts) {
try {
await bulkAcceptSuggestions(part.ids, { idempotencyKey: part.idempotencyKey });
} catch (error) {
throw new PartialBulkError(done, ids.length, error);
}
done += part.ids.length;
}
return done;
},
onSuccess: (count) => {
requestFocus({ target: "list", gone: null });
refresh();
toast.success(taskCountText(count), {
action: viewTasks,
classNames: { actionButton: TOAST_ACTION_CLASS },
});
},
onMutate: (ids) => markPending(ids),
onSettled: (_result, _error, ids) => unmarkPending(ids),
onError: handleError,
});
const reopenMutation = useMutation({
mutationFn: (id: string) => reopenSuggestion(id, { idempotencyKey: newIdempotencyKey() }),
onSuccess: (_result, id) => {
requestFocus({ target: { id }, gone: null });
refresh();
toast("Wieder geöffnet");
},
onMutate: (id) => markPending([id]),
onSettled: (_result, _error, id) => unmarkPending([id]),
onError: handleError,
});
const dismissMutation = useMutation({
mutationFn: (suggestion: Suggestion) =>
dismissSuggestion(suggestion.id, null, { idempotencyKey: newIdempotencyKey() }),
onSuccess: (_result, suggestion) => {
requestFocus({ target: neighbourOf(suggestion.id), gone: suggestion.id });
refresh();
toast("Verworfen", {
duration: UNDO_TOAST_MS,
action: { label: "Rückgängig", onClick: () => reopenMutation.mutate(suggestion.id) },
classNames: { actionButton: TOAST_ACTION_CLASS },
});
},
onMutate: (suggestion) => markPending([suggestion.id]),
onSettled: (_result, _error, suggestion) => unmarkPending([suggestion.id]),
onError: handleError,
});
function handleStartRound(suggestions: Suggestion[]) {
const target = roundTarget(suggestions);
if (target && userId) startRound(userId, target.apiary, target.colonies);
}
const loaded = rows?.length ?? 0;
const total = Math.max(summary.data?.open ?? loaded, loaded);
return (
<main className="flex flex-col gap-5 p-4 sm:p-6">
<PageHeader
icon={Lightbulb}
title="Vorschläge"
description={EXPLANATION}
gradient="var(--gradient-sky)"
/>
<p
ref={countRef}
tabIndex={-1}
role="status"
aria-live="polite"
data-testid="suggestion-count"
className="min-h-5 text-sm font-medium outline-none"
>
{countText(loaded, total)}
</p>
<QueryState
query={{
data: rows,
isLoading: openQuery.isLoading,
isError: openQuery.isError,
isFetching: openQuery.isFetching,
refetch: openQuery.refetch,
}}
what="Vorschläge"
empty={<EmptyState icon={Lightbulb} title="Keine offenen Vorschläge." />}
>
{(list) => (
<>
{groupSuggestions(list).map((group) => (
<SuggestionGroup
key={group.key}
group={group}
busyIds={pendingIds}
canAccept={canAccept}
canDismiss={canDismiss}
canStartRound={userId !== null}
onAccept={(suggestion, dueDate) => acceptMutation.mutate({ suggestion, dueDate })}
onDismiss={(suggestion) => dismissMutation.mutate(suggestion)}
onAcceptAll={(ids) => acceptAllMutation.mutate(ids)}
onStartRound={handleStartRound}
/>
))}
{total > loaded && openQuery.hasNextPage ? (
<Button
size="touch"
variant="outline"
className="self-start"
disabled={openQuery.isFetchingNextPage}
onClick={() => void openQuery.fetchNextPage()}
>
Mehr laden
</Button>
) : null}
</>
)}
</QueryState>
<section className="flex flex-col gap-3">
<Button
variant="ghost"
className="min-h-11 self-start"
aria-expanded={showDismissed}
aria-controls="dismissed-suggestions"
onClick={() => setShowDismissed((shown) => !shown)}
>
<ChevronDown
aria-hidden
className={showDismissed ? "rotate-180 transition-transform" : "transition-transform"}
/>
Verworfene (30 Tage)
</Button>
<div id="dismissed-suggestions" hidden={!showDismissed}>
{showDismissed ? (
<QueryState
query={dismissedQuery}
what="Verworfene Vorschläge"
empty={<p className="text-sm text-muted-foreground">Keine verworfenen Vorschläge.</p>}
>
{(list) => (
<ul aria-label="Verworfene Vorschläge" className="flex flex-col gap-2">
{list.map((suggestion) => (
<DismissedRow
key={suggestion.id}
suggestion={suggestion}
busy={pendingIds.has(suggestion.id)}
canReopen={canDismiss}
onReopen={() => reopenMutation.mutate(suggestion.id)}
/>
))}
</ul>
)}
</QueryState>
) : null}
</div>
</section>
</main>
);
}
function DismissedRow({
suggestion,
busy,
canReopen,
onReopen,
}: {
suggestion: Suggestion;
busy: boolean;
canReopen: boolean;
onReopen: () => void;
}) {
const Icon = INTENT_ICONS[suggestion.intent];
const where = [suggestion.colonyCode, suggestion.apiaryName].filter(Boolean).join(" · ");
return (
<li className="flex flex-wrap items-center gap-3 rounded-xl border bg-card p-3">
<Icon className="size-5 shrink-0 text-muted-foreground" aria-hidden />
<div className="min-w-0 flex-1">
<p className="font-medium">{suggestion.title}</p>
<p className="text-sm text-muted-foreground">
{[where, suggestion.decidedAt ? `verworfen am ${formatDay(suggestion.decidedAt)}` : ""]
.filter(Boolean)
.join(" · ")}
</p>
</div>
{canReopen ? (
<Button size="touch" variant="outline" disabled={busy} onClick={onReopen}>
<RotateCcw aria-hidden />
Wieder öffnen
</Button>
) : null}
</li>
);
}
@@ -121,6 +121,26 @@ describe("NewTreatmentPage", () => {
expect(push).toHaveBeenCalledWith("/colonies/colony-1");
});
it("marks the varroa thresholds stale after a saved treatment", async () => {
vi.mocked(createTreatment).mockResolvedValue({ id: "t-1", colonyId: "colony-1" } as never);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
client.setQueryData(["varroa", "thresholds", "colony-1", "2026-08-10"], { bands: {} });
render(
<QueryClientProvider client={client}>
<NewTreatmentPage />
</QueryClientProvider>,
);
await userEvent.type(screen.getByLabelText(/^Mittel/), "Oxuvar");
await userEvent.click(screen.getByRole("button", { name: "Behandlung speichern" }));
await waitFor(() =>
expect(
client.getQueryState(["varroa", "thresholds", "colony-1", "2026-08-10"])?.isInvalidated,
).toBe(true),
);
});
it("shows an inline error and focuses the product field when it is missing", async () => {
renderPage();
await userEvent.click(
@@ -6,6 +6,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Syringe } from "lucide-react";
import { createTreatment } from "@/lib/treatments/api";
import { invalidateVarroaBands } from "@/lib/varroa/use-varroa-bands";
import { createTreatmentsBulk } from "@/lib/bulk/api";
import { bulkDestination, bulkQueuedText, bulkSavedText, bulkSelectionError } from "@/lib/bulk/capture-texts";
import { useApiaryColonies } from "@/lib/bulk/use-apiary-colonies";
@@ -106,6 +107,7 @@ export default function NewTreatmentPage() {
for (const colonyId of input.colonyIds) {
void queryClient.invalidateQueries({ queryKey: ["colonies", colonyId, "treatments"] });
}
void invalidateVarroaBands(queryClient);
toast.success(bulkSavedText("Behandlung", count));
}
router.push(bulkDestination(values.bulkApiaryId));
@@ -129,6 +131,7 @@ export default function NewTreatmentPage() {
void queryClient.invalidateQueries({
queryKey: ["colonies", treatment.colonyId, "treatments"],
});
void invalidateVarroaBands(queryClient);
toast.success("Behandlung gespeichert");
router.push(`/colonies/${treatment.colonyId}`);
},
@@ -4,7 +4,7 @@ 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 { createVarroaMeasurement, fetchVarroaThresholds } from "@/lib/varroa/api";
import { createVarroaBulk } from "@/lib/bulk/api";
import { queueBulk } from "@/lib/offline/db";
import { fetchApiaries } from "@/lib/apiaries/api";
@@ -19,7 +19,7 @@ vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() }));
vi.mock("@/lib/varroa/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/varroa/api")>()),
createVarroaMeasurement: vi.fn(),
fetchVarroaThreshold: vi.fn(),
fetchVarroaThresholds: vi.fn(),
}));
vi.mock("@/lib/bulk/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/bulk/api")>()),
@@ -35,6 +35,29 @@ 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() } }));
const NATURAL_BAND = {
method: "NATURAL_MITE_FALL" as const,
unit: "MITES_PER_DAY" as const,
yellowFrom: 5,
redAbove: 10,
reason: "NATURAL_FALL_SEASON",
youngColony: false,
};
const SUGAR_BAND = {
method: "POWDERED_SUGAR" as const,
unit: "PERCENT" as const,
yellowFrom: 2,
redAbove: 5,
reason: "INFESTATION_AUTUMN",
youngColony: false,
};
const BANDS = {
day: "2026-08-10",
bands: { NATURAL_MITE_FALL: NATURAL_BAND, POWDERED_SUGAR: SUGAR_BAND },
postTreatmentUntil: null,
active: true,
};
function renderPage() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
@@ -59,7 +82,7 @@ describe("NewVarroaMeasurementPage", () => {
vi.mocked(fetchApiaries)
.mockReset()
.mockResolvedValue([{ id: "apiary-1", name: "Waldrand" }] as never);
vi.mocked(fetchVarroaThreshold).mockResolvedValue({ thresholdMitesPerDay: 10 });
vi.mocked(fetchVarroaThresholds).mockResolvedValue(BANDS);
});
it("marks colony and date as required and explains the daily mite fall", () => {
@@ -80,7 +103,7 @@ describe("NewVarroaMeasurementPage", () => {
renderPage();
await userEvent.click(
screen.getByRole("radio", { name: "Puderzuckermethode" }),
screen.getByRole("radio", { name: "Drohnenbrutentnahme" }),
);
await userEvent.type(screen.getByLabelText(/Milbenzahl/), "12");
await userEvent.type(screen.getByLabelText(/^Zeitraum/), "5");
@@ -91,7 +114,7 @@ describe("NewVarroaMeasurementPage", () => {
const [input, options] = vi.mocked(createVarroaMeasurement).mock.calls[0];
expect(input).toMatchObject({
colonyId: "colony-1",
method: "POWDERED_SUGAR",
method: "DRONE_BROOD_CONTROL",
miteCount: 12,
durationMinutes: 5,
});
@@ -138,28 +161,156 @@ describe("NewVarroaMeasurementPage", () => {
});
});
it('shows „10,0 Milben/Tag – über der Schwelle" live', async () => {
it('shows „12,0 Milben/Tag – Kritisch (Grenze 10, LWG)“ live', async () => {
renderPage();
await fillMiteCount("36");
await userEvent.click(screen.getByRole("radio", { name: "3 Tage" }));
expect(
await screen.findByText(/12,0 Milben\/Tag – Kritisch \(Grenze 10, LWG\)/),
).toBeInTheDocument();
expect(vi.mocked(fetchVarroaThresholds)).toHaveBeenCalledWith(
"colony-1",
expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/),
);
});
it("10,0 per day is yellow, not red", async () => {
renderPage();
await fillMiteCount("30");
await userEvent.click(screen.getByRole("radio", { name: "3 Tage" }));
expect(
await screen.findByText(/10,0 Milben\/Tag – über der Schwelle, Behandlung prüfen/),
).toBeInTheDocument();
expect(screen.getByRole("status")).toHaveTextContent("über der Schwelle");
expect(await screen.findByText(/10,0 Milben\/Tag – Achtung/)).toBeInTheDocument();
});
it("rate without traffic light when the rule is inactive", async () => {
vi.mocked(fetchVarroaThreshold).mockResolvedValue({ thresholdMitesPerDay: null });
vi.mocked(fetchVarroaThresholds).mockResolvedValue({ ...BANDS, bands: {}, active: false });
renderPage();
await fillMiteCount("30");
await userEvent.click(screen.getByRole("radio", { name: "3 Tage" }));
const status = await screen.findByText(/10,0 Milben\/Tag/);
expect(status).not.toHaveTextContent("Schwelle");
expect(status).not.toHaveTextContent("Grenze");
});
it("shows „Behandlungseffekt – Nachkontrolle ab 16.08.“ inside the post-treatment window", async () => {
vi.mocked(fetchVarroaThresholds).mockResolvedValue({ ...BANDS, postTreatmentUntil: "2026-08-16" });
renderPage();
await fillMiteCount("36");
await userEvent.click(screen.getByRole("radio", { name: "3 Tage" }));
expect(await screen.findByText(/Behandlungseffekt – Nachkontrolle ab 16\.08\./)).toBeInTheDocument();
expect(screen.getByRole("status")).not.toHaveTextContent("Kritisch");
});
describe("sample of bees", () => {
it("the sample field appears only for powdered sugar and washing, the chip sets 500 without preselection", async () => {
renderPage();
expect(screen.queryByLabelText(/Probe \(Bienen\)/)).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("radio", { name: "Puderzuckermethode" }));
expect(screen.getByLabelText(/Probe \(Bienen\)/)).toHaveValue(null);
const chip = screen.getByRole("radio", { name: "500 Bienen (≈ 50 g)" });
expect(chip).toHaveAttribute("aria-checked", "false");
await userEvent.click(chip);
expect(screen.getByLabelText(/Probe \(Bienen\)/)).toHaveValue(500);
expect(chip).toHaveAttribute("aria-checked", "true");
await userEvent.click(screen.getByRole("radio", { name: "Waschmethode" }));
expect(screen.getByLabelText(/Probe \(Bienen\)/)).toBeInTheDocument();
await userEvent.click(screen.getByRole("radio", { name: "Drohnenbrutentnahme" }));
expect(screen.queryByLabelText(/Probe \(Bienen\)/)).not.toBeInTheDocument();
});
it("shows the percentage with its light and sends sampleBees", async () => {
vi.mocked(createVarroaMeasurement).mockResolvedValue({ id: "v", colonyId: "colony-1" } as never);
renderPage();
await userEvent.click(screen.getByRole("radio", { name: "Puderzuckermethode" }));
await userEvent.type(screen.getByLabelText(/^Milbenzahl/), "12");
await userEvent.click(screen.getByRole("radio", { name: "500 Bienen (≈ 50 g)" }));
expect(await screen.findByText(/2,4 % – Achtung \(Grenze 5 %, LWG\)/)).toBeInTheDocument();
await submit();
expect(vi.mocked(createVarroaMeasurement).mock.calls[0][0]).toMatchObject({
method: "POWDERED_SUGAR",
miteCount: 12,
sampleBees: 500,
});
});
it.each(["0", "2001", "1.5"])("refuses a sample of %s and sends nothing", async (bad) => {
renderPage();
await userEvent.click(screen.getByRole("radio", { name: "Waschmethode" }));
await userEvent.type(screen.getByLabelText(/^Milbenzahl/), "12");
await userEvent.type(screen.getByLabelText(/Probe \(Bienen\)/), bad);
await submit();
expect(screen.getByText("Bitte 1 bis 2000 Bienen angeben.")).toBeInTheDocument();
expect(screen.getByLabelText(/Probe \(Bienen\)/)).toHaveFocus();
expect(createVarroaMeasurement).not.toHaveBeenCalled();
});
it("an invalid sample gives no percentage and no light", async () => {
renderPage();
await userEvent.click(screen.getByRole("radio", { name: "Puderzuckermethode" }));
await userEvent.type(screen.getByLabelText(/^Milbenzahl/), "12");
await userEvent.type(screen.getByLabelText(/Probe \(Bienen\)/), "5000");
expect(screen.getByRole("status")).toBeEmptyDOMElement();
});
it("refuses an invalid shared sample for several colonies and queues nothing", async () => {
vi.mocked(fetchColonies).mockResolvedValue([
{ id: "colony-1", code: "V-01", status: "ACTIVE", apiaryId: "apiary-1" },
{ id: "colony-2", code: "V-02", status: "ACTIVE", apiaryId: "apiary-1" },
] 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.click(screen.getByRole("radio", { name: "Waschmethode" }));
await userEvent.type(screen.getByLabelText(/Milbenzahl V-01/), "4");
await userEvent.type(screen.getByLabelText(/Milbenzahl V-02/), "5");
await userEvent.type(screen.getByLabelText(/Probe \(Bienen\)/), "0");
await submit();
expect(screen.getByText("Bitte 1 bis 2000 Bienen angeben.")).toBeInTheDocument();
expect(createVarroaBulk).not.toHaveBeenCalled();
expect(queueBulk).not.toHaveBeenCalled();
});
it("does not ask for thresholds without a colony", async () => {
vi.mocked(fetchVarroaThresholds).mockClear();
vi.mocked(fetchColonies).mockResolvedValue([
{ id: "colony-1", code: "V-01", status: "ACTIVE", apiaryId: "apiary-1" },
] as never);
renderPage();
await userEvent.click(screen.getByRole("radio", { name: "Mehrere Völker" }));
await new Promise((resolve) => setTimeout(resolve, 20));
expect(
vi.mocked(fetchVarroaThresholds).mock.calls.filter(([colonyId]) => colonyId === null),
).toHaveLength(0);
});
it("does not send a sample for the natural mite fall", async () => {
vi.mocked(createVarroaMeasurement).mockResolvedValue({ id: "v", colonyId: "colony-1" } as never);
renderPage();
await userEvent.click(screen.getByRole("radio", { name: "Waschmethode" }));
await userEvent.type(screen.getByLabelText(/Probe \(Bienen\)/), "300");
await userEvent.click(screen.getByRole("radio", { name: "Natürlicher Milbenfall" }));
await submit();
expect(vi.mocked(createVarroaMeasurement).mock.calls[0][0].sampleBees).toBeNull();
});
});
it("the sample methods have no minutes field", async () => {
renderPage();
for (const name of ["Puderzuckermethode", "Waschmethode"]) {
await userEvent.click(screen.getByRole("radio", { name }));
expect(screen.queryByLabelText(/Zeitraum \(Minuten\)/)).not.toBeInTheDocument();
}
});
it("other methods keep the minutes field", async () => {
renderPage();
await userEvent.click(screen.getByRole("radio", { name: "Waschmethode" }));
await userEvent.click(screen.getByRole("radio", { name: "Drohnenbrutentnahme" }));
expect(screen.getByLabelText(/Zeitraum \(Minuten\)/)).toBeInTheDocument();
expect(screen.queryByRole("radio", { name: "3 Tage" })).not.toBeInTheDocument();
});
@@ -244,10 +395,10 @@ describe("NewVarroaMeasurementPage", () => {
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",
"10,0 Milben/Tag – Achtung (Grenze 10, LWG)",
);
expect(within(second).getByRole("status")).toHaveTextContent(
"1,0 Milben/Tag – unter der Schwelle",
"1,0 Milben/Tag – Gut (Grenze 10, LWG)",
);
});
@@ -17,10 +17,12 @@ 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 { infestationPercent, sampleBeesError, usesSample } from "@/lib/varroa/bands";
import { emptyToNull } from "@/lib/forms/empty-to-null";
import { VarroaPeriodField } from "@/components/varroa/varroa-period-field";
import { VarroaRateLight } from "@/components/varroa/varroa-rate-light";
import { useVarroaThreshold } from "@/lib/varroa/use-varroa-threshold";
import { useVarroaBands } from "@/lib/varroa/use-varroa-bands";
import { SampleBeesField } from "@/components/varroa/sample-bees-field";
import { VARROA_METHODS, VARROA_METHOD_LABELS } from "@/lib/varroa/labels";
import { queueVarroaMeasurement } from "@/lib/offline/db";
import { useCaptureMutation } from "@/lib/offline/use-capture-mutation";
@@ -47,7 +49,7 @@ import {
toVarroaInput,
} from "./varroa-form-values";
type RequiredKey = "colonyId" | "bulkColonies" | "date";
type RequiredKey = "colonyId" | "bulkColonies" | "date" | "sampleBees";
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>;
@@ -56,7 +58,7 @@ 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"];
return [values.target === "bulk" ? "bulkColonies" : "colonyId", "date", "sampleBees"];
}
function validate(values: VarroaFormValues): FieldErrors {
@@ -67,6 +69,9 @@ function validate(values: VarroaFormValues): FieldErrors {
errors.colonyId = "Bitte ein Volk auswählen.";
}
if (!values.date) errors.date = "Bitte ein Datum angeben.";
if (usesSample(values.method)) {
errors.sampleBees = sampleBeesError(values.sampleBees) ?? undefined;
}
return errors;
}
@@ -212,13 +217,14 @@ export default function NewVarroaMeasurementPage() {
}
const isNaturalFall = values.method === "NATURAL_MITE_FALL";
const threshold = useVarroaThreshold();
const rate = isNaturalFall
? mitesPerDay(
emptyToNull(values.miteCount, Number),
emptyToNull(values.durationMinutes, Number),
)
: null;
const hasSample = usesSample(values.method);
const bands = useVarroaBands(isBulk ? null : values.colonyId || null, values.date);
const miteCount = emptyToNull(values.miteCount, Number);
const liveValue = isNaturalFall
? mitesPerDay(miteCount, emptyToNull(values.durationMinutes, Number))
: hasSample
? infestationPercent(miteCount, emptyToNull(values.sampleBees, Number))
: null;
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
@@ -312,9 +318,10 @@ export default function NewVarroaMeasurementPage() {
colonies={chosenColonies}
counts={values.bulkMiteCounts}
onCountChange={setMiteCount}
method={values.method}
date={values.date}
durationMinutes={values.durationMinutes}
showsRate={isNaturalFall}
threshold={threshold}
sampleBees={values.sampleBees}
errors={countErrors}
/>
) : (
@@ -340,6 +347,13 @@ export default function NewVarroaMeasurementPage() {
)}
</Field>
)}
{hasSample ? (
<SampleBeesField
value={values.sampleBees}
onChange={(sampleBees) => set("sampleBees", sampleBees)}
error={fieldErrors.sampleBees}
/>
) : null}
{isNaturalFall ? (
<>
<VarroaPeriodField
@@ -347,9 +361,8 @@ export default function NewVarroaMeasurementPage() {
onChange={(minutes) => set("durationMinutes", minutes)}
hint="Wie lange die Windel lag."
/>
{isBulk ? null : <VarroaRateLight rate={rate} threshold={threshold} />}
</>
) : (
) : hasSample ? null : (
<Field
label="Zeitraum (Minuten)"
hint="Wie lange die Messung gedauert hat."
@@ -370,6 +383,9 @@ export default function NewVarroaMeasurementPage() {
)}
</Field>
)}
{isBulk ? null : (
<VarroaRateLight method={values.method} value={liveValue} bands={bands} />
)}
</FormSection>
<FormSection
@@ -3,6 +3,7 @@ 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 { usesSample } from "@/lib/varroa/bands";
import { emptyToNull } from "@/lib/forms/empty-to-null";
/** Everything the form holds, as plain values that survive a draft save. */
@@ -17,6 +18,8 @@ export interface VarroaFormValues {
date: string;
method: VarroaMethod;
miteCount: string;
/** Sample size in bees; only used by powdered sugar and washing. */
sampleBees: string;
durationMinutes: string;
assessment: string;
notes: string;
@@ -44,12 +47,23 @@ export function initialVarroaValues(colonyId: string): VarroaFormValues {
date: todayLocalIsoDate(),
method: "NATURAL_MITE_FALL",
miteCount: "",
sampleBees: "",
durationMinutes: "",
assessment: "",
notes: "",
};
}
/** The sample only counts for the methods that use one; a stale value is not sent. */
/** Powdered sugar and washing are counted per sample, not over a period. */
function durationOf(values: VarroaFormValues): number | null {
return usesSample(values.method) ? null : emptyToNull(values.durationMinutes, Number);
}
function sampleBeesOf(values: VarroaFormValues): number | null {
return usesSample(values.method) ? emptyToNull(values.sampleBees, Number) : null;
}
export function toVarroaInput(
values: VarroaFormValues,
): Parameters<typeof createVarroaMeasurement>[0] {
@@ -58,7 +72,8 @@ export function toVarroaInput(
date: toApiDate(values.date),
method: values.method,
miteCount: emptyToNull(values.miteCount, Number),
durationMinutes: emptyToNull(values.durationMinutes, Number),
sampleBees: sampleBeesOf(values),
durationMinutes: durationOf(values),
assessment: emptyToNull(values.assessment),
notes: emptyToNull(values.notes),
};
@@ -73,7 +88,8 @@ export function toBulkVarroaInput(values: VarroaFormValues): BulkCreateVarroaInp
return {
date: toApiDate(values.date),
method: values.method,
durationMinutes: emptyToNull(values.durationMinutes, Number),
durationMinutes: durationOf(values),
sampleBees: sampleBeesOf(values),
notes: emptyToNull(values.notes),
entries: values.bulkColonyIds.map((colonyId) => ({
colonyId,