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:
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,
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
|
||||
interface DueDateSheetProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
description?: string;
|
||||
/** Name of the confirm button: „Übernehmen“ or „Aufgabe anlegen“. */
|
||||
confirmLabel: string;
|
||||
/** YYYY-MM-DD */
|
||||
initialDate: string;
|
||||
/** Called with the chosen day (YYYY-MM-DD, never empty). */
|
||||
onConfirm: (dueDate: string) => void;
|
||||
}
|
||||
|
||||
/** A small bottom sheet that asks for one day, then confirms. */
|
||||
export function DueDateSheet(props: DueDateSheetProps) {
|
||||
return (
|
||||
<Sheet open={props.open} onOpenChange={props.onOpenChange}>
|
||||
<SheetContent
|
||||
side="bottom"
|
||||
showCloseButton={false}
|
||||
className="max-h-[85dvh] gap-0 overflow-y-auto rounded-t-2xl pb-(--safe-area-bottom)"
|
||||
>
|
||||
{/* Remounted per opening, so the field always starts at the suggested day. */}
|
||||
{props.open ? <DueDateForm {...props} /> : null}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
function DueDateForm({ title, description, confirmLabel, initialDate, onConfirm }: DueDateSheetProps) {
|
||||
const [date, setDate] = useState(initialDate);
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (date) onConfirm(date);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4 px-4 pb-4">
|
||||
<SheetHeader className="px-0">
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
{description ? <SheetDescription>{description}</SheetDescription> : null}
|
||||
</SheetHeader>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="suggestion-due-date">Termin</Label>
|
||||
<Input
|
||||
id="suggestion-due-date"
|
||||
type="date"
|
||||
className="h-11 sm:h-11"
|
||||
value={date}
|
||||
required
|
||||
onChange={(event) => setDate(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row-reverse">
|
||||
<Button type="submit" size="touch" disabled={!date}>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
<SheetClose render={<Button type="button" size="touch" variant="outline" />}>
|
||||
Abbrechen
|
||||
</SheetClose>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { fetchSuggestionSummary } from "@/lib/suggestions/api";
|
||||
import { NavCounter } from "./nav-counter";
|
||||
|
||||
vi.mock("@/lib/suggestions/api", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/suggestions/api")>()),
|
||||
fetchSuggestionSummary: vi.fn(),
|
||||
}));
|
||||
|
||||
function renderCounter() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<NavCounter counter="suggestions" />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("NavCounter", () => {
|
||||
it("shows the open suggestions with a spoken label", async () => {
|
||||
vi.mocked(fetchSuggestionSummary).mockResolvedValue({
|
||||
open: 4,
|
||||
byPriority: { LOW: 0, NORMAL: 4, HIGH: 0, URGENT: 0 },
|
||||
});
|
||||
renderCounter();
|
||||
|
||||
expect(await screen.findByText("4")).toBeInTheDocument();
|
||||
const hidden = screen.getByText("offen");
|
||||
expect(hidden).toHaveClass("sr-only");
|
||||
expect(hidden.parentElement).not.toHaveAttribute("aria-label");
|
||||
});
|
||||
|
||||
it("shows nothing for zero or while loading", async () => {
|
||||
vi.mocked(fetchSuggestionSummary).mockResolvedValue({
|
||||
open: 0,
|
||||
byPriority: { LOW: 0, NORMAL: 0, HIGH: 0, URGENT: 0 },
|
||||
});
|
||||
renderCounter();
|
||||
|
||||
await vi.waitFor(() => expect(fetchSuggestionSummary).toHaveBeenCalled());
|
||||
expect(screen.queryByText("0")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows nothing when the count cannot be loaded", async () => {
|
||||
vi.mocked(fetchSuggestionSummary).mockRejectedValue(new Error("offline"));
|
||||
renderCounter();
|
||||
|
||||
await vi.waitFor(() => expect(fetchSuggestionSummary).toHaveBeenCalled());
|
||||
expect(screen.queryByText("offen")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { useSuggestionSummary } from "@/lib/suggestions/use-suggestion-summary";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const MAX_SHOWN = 99;
|
||||
|
||||
function SuggestionsCounter({ active }: { active: boolean }) {
|
||||
const { data } = useSuggestionSummary();
|
||||
const open = data?.open ?? 0;
|
||||
if (open <= 0) return null;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto inline-flex min-w-5 items-center justify-center rounded-full px-1.5 text-xs font-semibold tabular-nums",
|
||||
active ? "bg-primary-foreground/20 text-primary-foreground" : "bg-primary/15 text-foreground",
|
||||
)}
|
||||
>
|
||||
{open > MAX_SHOWN ? `${MAX_SHOWN}+` : open}
|
||||
<span className="sr-only"> offen</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** The number next to a navigation entry; renders nothing for zero or on error. */
|
||||
export function NavCounter({ counter, active = false }: { counter: "suggestions"; active?: boolean }) {
|
||||
return counter === "suggestions" ? <SuggestionsCounter active={active} /> : null;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { SuggestionCard } from "./suggestion-card";
|
||||
import { suggestionFixture } from "./suggestion-fixtures";
|
||||
|
||||
function setup(overrides = {}, handlers: Partial<Parameters<typeof SuggestionCard>[0]> = {}) {
|
||||
const onAccept = vi.fn();
|
||||
const onDismiss = vi.fn();
|
||||
render(
|
||||
<SuggestionCard
|
||||
suggestion={suggestionFixture(overrides)}
|
||||
onAccept={onAccept}
|
||||
onDismiss={onDismiss}
|
||||
{...handlers}
|
||||
/>,
|
||||
);
|
||||
return { onAccept, onDismiss };
|
||||
}
|
||||
|
||||
describe("SuggestionCard", () => {
|
||||
it("the main button is named „Übernehmen“ for PLAN and „Erfassen“ for DO; touch targets are 44 px", () => {
|
||||
setup({ mode: "PLAN" });
|
||||
expect(screen.getByRole("button", { name: "Übernehmen" })).toHaveClass("h-11");
|
||||
expect(screen.getByRole("button", { name: "Verwerfen" })).toHaveClass("h-11");
|
||||
expect(screen.getByRole("button", { name: "Weitere Aktionen" })).toHaveClass("size-11");
|
||||
});
|
||||
|
||||
it("shows „Erfassen“ as a real link to the capture form for DO", () => {
|
||||
setup({ mode: "DO", intent: "VARROA_MEASURE", colonyId: "c7" });
|
||||
|
||||
const link = screen.getByRole("link", { name: "Erfassen" });
|
||||
expect(link).toHaveAttribute("href", "/varroa/new?colonyId=c7");
|
||||
expect(link).toHaveClass("h-11");
|
||||
expect(screen.queryByRole("button", { name: "Erfassen" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Übernehmen" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows title, both reasons, the colony and the due state", () => {
|
||||
setup();
|
||||
|
||||
expect(screen.getByText("Honigraum prüfen")).toBeInTheDocument();
|
||||
expect(screen.getByText("Kirsche blüht.")).toBeInTheDocument();
|
||||
expect(screen.getByText("A1 besetzt 9 Waben.")).toBeInTheDocument();
|
||||
expect(screen.getByText("A1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("accepts and dismisses with one tap", async () => {
|
||||
const { onAccept, onDismiss } = setup();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Übernehmen" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Verwerfen" }));
|
||||
|
||||
expect(onAccept).toHaveBeenCalledWith(expect.objectContaining({ id: "s1" }), undefined);
|
||||
expect(onDismiss).toHaveBeenCalledWith(expect.objectContaining({ id: "s1" }));
|
||||
});
|
||||
|
||||
it("„Termin ändern“ (PLAN) accepts with the chosen day", async () => {
|
||||
const { onAccept } = setup({ mode: "PLAN", dueDate: "2026-05-12" });
|
||||
|
||||
await userEvent.click(screen.getByRole("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");
|
||||
expect(input).toHaveValue("2026-05-12");
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, "2026-06-01");
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: "Übernehmen" }));
|
||||
|
||||
expect(onAccept).toHaveBeenCalledWith(expect.objectContaining({ id: "s1" }), "2026-06-01");
|
||||
});
|
||||
|
||||
it("„Als Aufgabe planen“ (DO) needs a day before it accepts", async () => {
|
||||
const { onAccept } = setup({ mode: "DO", intent: "INSPECT", dueDate: null });
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Weitere Aktionen" }));
|
||||
await userEvent.click(await screen.findByRole("menuitem", { name: "Als Aufgabe planen" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
const input = within(dialog).getByLabelText("Termin");
|
||||
await userEvent.clear(input);
|
||||
expect(within(dialog).getByRole("button", { name: "Aufgabe anlegen" })).toBeDisabled();
|
||||
await userEvent.type(input, "2026-06-02");
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: "Aufgabe anlegen" }));
|
||||
|
||||
expect(onAccept).toHaveBeenCalledWith(expect.objectContaining({ id: "s1" }), "2026-06-02");
|
||||
});
|
||||
|
||||
it("offers „Aufräumen“ for a cleanup suggestion", async () => {
|
||||
const onCleanup = vi.fn();
|
||||
setup(
|
||||
{ mode: "DO", intent: "CLEANUP", cleanup: { taskIds: ["t1"], queenId: null } },
|
||||
{ onCleanup },
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Aufräumen" }));
|
||||
|
||||
expect(onCleanup).toHaveBeenCalledWith(expect.objectContaining({ id: "s1" }));
|
||||
});
|
||||
|
||||
it("disables the actions while busy", () => {
|
||||
setup({}, { busy: true });
|
||||
|
||||
expect(screen.getByRole("button", { name: "Übernehmen" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Verwerfen" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("hides „Übernehmen“ and the menu without tasks:create", () => {
|
||||
setup({}, { canAccept: false });
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Übernehmen" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Weitere Aktionen" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Verwerfen" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides „Verwerfen“ without tasks:update", () => {
|
||||
setup({}, { canDismiss: false });
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Verwerfen" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Übernehmen" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("makes the title focusable for focus management", () => {
|
||||
setup();
|
||||
|
||||
const heading = screen.getByRole("heading", { name: "Honigraum prüfen" });
|
||||
expect(heading).toHaveAttribute("tabindex", "-1");
|
||||
expect(heading).toHaveAttribute("data-suggestion-heading", "s1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { AlertTriangle, Check, EllipsisVertical, X } from "lucide-react";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { DueChip } from "@/components/task-status";
|
||||
import { captureHref, INTENT_ICONS } from "@/lib/suggestions/labels";
|
||||
import type { Suggestion } from "@/lib/suggestions/types";
|
||||
import { TASK_PRIORITY_LABELS } from "@/lib/tasks/labels";
|
||||
import { dueState } from "@/lib/tasks/today";
|
||||
import { todayLocalIsoDate } from "@/lib/dates";
|
||||
import { DueDateSheet } from "./due-date-sheet";
|
||||
|
||||
export interface SuggestionCardProps {
|
||||
suggestion: Suggestion;
|
||||
/** A decision for this very suggestion is running. */
|
||||
busy?: boolean;
|
||||
/** tasks:create - needed for „Übernehmen“ and every way to plan a task. */
|
||||
canAccept?: boolean;
|
||||
/** tasks:update - needed for „Verwerfen“. */
|
||||
canDismiss?: boolean;
|
||||
/** `dueDate` is undefined for the suggestion's own day, else YYYY-MM-DD. */
|
||||
onAccept: (suggestion: Suggestion, dueDate?: string) => void;
|
||||
onDismiss: (suggestion: Suggestion) => void;
|
||||
/** „Aufräumen“ for a CLEANUP suggestion; without a handler the button is not offered. */
|
||||
onCleanup?: (suggestion: Suggestion) => void;
|
||||
}
|
||||
|
||||
type MenuAction = { label: string; sheetTitle: string; confirmLabel: string };
|
||||
|
||||
function menuActionOf(suggestion: Suggestion): MenuAction | null {
|
||||
if (suggestion.mode === "PLAN") {
|
||||
return { label: "Termin ändern", sheetTitle: "Termin ändern", confirmLabel: "Übernehmen" };
|
||||
}
|
||||
if (suggestion.intent === "CLEANUP") return null;
|
||||
return { label: "Als Aufgabe planen", sheetTitle: "Als Aufgabe planen", confirmLabel: "Aufgabe anlegen" };
|
||||
}
|
||||
|
||||
function MainAction({ suggestion, busy, canAccept = true, onAccept, onCleanup }: SuggestionCardProps) {
|
||||
if (suggestion.intent === "CLEANUP") {
|
||||
if (!onCleanup) return null;
|
||||
return (
|
||||
<Button size="touch" disabled={busy} onClick={() => onCleanup(suggestion)}>
|
||||
<Check aria-hidden />
|
||||
Aufräumen
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
if (suggestion.mode === "DO") {
|
||||
return (
|
||||
<Link
|
||||
href={captureHref(suggestion.intent, suggestion.colonyId)}
|
||||
className={buttonVariants({ size: "touch" })}
|
||||
>
|
||||
<Check aria-hidden />
|
||||
Erfassen
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
if (!canAccept) return null;
|
||||
return (
|
||||
<Button size="touch" disabled={busy} onClick={() => onAccept(suggestion, undefined)}>
|
||||
<Check aria-hidden />
|
||||
Übernehmen
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function SuggestionCard(props: SuggestionCardProps) {
|
||||
const { suggestion, busy, canAccept = true, canDismiss = true, onAccept, onDismiss } = props;
|
||||
const [now] = useState(() => new Date());
|
||||
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
||||
const Icon = INTENT_ICONS[suggestion.intent];
|
||||
const menuAction = canAccept ? menuActionOf(suggestion) : null;
|
||||
const isUrgent = suggestion.priority === "HIGH" || suggestion.priority === "URGENT";
|
||||
|
||||
return (
|
||||
<article className="flex flex-col gap-3 rounded-2xl border bg-card p-4 shadow-[var(--shadow-soft)]">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/12 text-primary">
|
||||
<Icon className="size-5" aria-hidden />
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<h3
|
||||
tabIndex={-1}
|
||||
data-suggestion-heading={suggestion.id}
|
||||
className="font-medium outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
>
|
||||
{suggestion.title}
|
||||
</h3>
|
||||
{suggestion.colonyCode ? (
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-medium">
|
||||
{suggestion.colonyCode}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{suggestion.reasons.length > 0 ? (
|
||||
<ul className="flex flex-col gap-0.5 text-sm text-muted-foreground">
|
||||
{suggestion.reasons.map((reason) => (
|
||||
<li key={reason}>{reason}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<DueChip state={dueState(suggestion, now)} dueDate={suggestion.dueDate} />
|
||||
{isUrgent ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-warning/20 px-2 py-0.5 text-xs font-medium">
|
||||
<AlertTriangle className="size-3.5" aria-hidden />
|
||||
{TASK_PRIORITY_LABELS[suggestion.priority]}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<MainAction {...props} />
|
||||
{canDismiss ? (
|
||||
<Button
|
||||
size="touch"
|
||||
variant="ghost"
|
||||
disabled={busy}
|
||||
onClick={() => onDismiss(suggestion)}
|
||||
>
|
||||
<X aria-hidden />
|
||||
Verwerfen
|
||||
</Button>
|
||||
) : null}
|
||||
{menuAction ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="size-11"
|
||||
disabled={busy}
|
||||
aria-label="Weitere Aktionen"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EllipsisVertical className="size-5" aria-hidden />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem className="min-h-11" onClick={() => setIsSheetOpen(true)}>
|
||||
{menuAction.label}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</div>
|
||||
{menuAction ? (
|
||||
<DueDateSheet
|
||||
open={isSheetOpen}
|
||||
onOpenChange={setIsSheetOpen}
|
||||
title={menuAction.sheetTitle}
|
||||
description={suggestion.title}
|
||||
confirmLabel={menuAction.confirmLabel}
|
||||
initialDate={suggestion.dueDate ?? todayLocalIsoDate()}
|
||||
onConfirm={(dueDate) => {
|
||||
setIsSheetOpen(false);
|
||||
onAccept(suggestion, dueDate);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Suggestion } from "@/lib/suggestions/types";
|
||||
|
||||
/** A complete suggestion for specs; override what the test is about. */
|
||||
export function suggestionFixture(overrides: Partial<Suggestion> = {}): Suggestion {
|
||||
return {
|
||||
id: "s1",
|
||||
ruleKey: "SEASON_AUTOPILOT",
|
||||
source: "AUTOPILOT",
|
||||
mode: "PLAN",
|
||||
intent: "HONEY_SUPER",
|
||||
status: "OPEN",
|
||||
priority: "NORMAL",
|
||||
relatedType: "COLONY",
|
||||
relatedId: "c1",
|
||||
colonyId: "c1",
|
||||
colonyCode: "A1",
|
||||
apiaryId: "a1",
|
||||
apiaryName: "Hang",
|
||||
title: "Honigraum prüfen",
|
||||
reasons: ["Kirsche blüht.", "A1 besetzt 9 Waben."],
|
||||
dueDate: "2026-05-12",
|
||||
expiresAt: null,
|
||||
plan: null,
|
||||
decidedAt: null,
|
||||
decisionNote: null,
|
||||
createdAt: "2026-05-01T00:00:00.000Z",
|
||||
cleanup: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { groupSuggestions } from "@/lib/suggestions/grouping";
|
||||
import type { Suggestion } from "@/lib/suggestions/types";
|
||||
import { SuggestionGroup } from "./suggestion-group";
|
||||
import { suggestionFixture } from "./suggestion-fixtures";
|
||||
|
||||
function setup(list: Suggestion[]) {
|
||||
const [group] = groupSuggestions(list);
|
||||
const handlers = {
|
||||
onAccept: vi.fn(),
|
||||
onDismiss: vi.fn(),
|
||||
onAcceptAll: vi.fn(),
|
||||
onStartRound: vi.fn(),
|
||||
};
|
||||
render(<SuggestionGroup group={group} {...handlers} />);
|
||||
return handlers;
|
||||
}
|
||||
|
||||
const THREE_PLAN = [
|
||||
suggestionFixture({ id: "s1", colonyId: "c1", colonyCode: "A1" }),
|
||||
suggestionFixture({ id: "s2", colonyId: "c2", colonyCode: "A2" }),
|
||||
suggestionFixture({ id: "s3", colonyId: "c3", colonyCode: "A3" }),
|
||||
];
|
||||
|
||||
describe("SuggestionGroup", () => {
|
||||
it("names the stand and shows a single suggestion as a card", () => {
|
||||
setup([suggestionFixture()]);
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Hang" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Übernehmen" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("combines a PLAN intent into one row with „Alle übernehmen“", async () => {
|
||||
const { onAcceptAll } = setup(THREE_PLAN);
|
||||
|
||||
expect(screen.getByText("Honigraum prüfen · 3 Völker")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Alle übernehmen" }));
|
||||
|
||||
expect(onAcceptAll).toHaveBeenCalledWith(["s1", "s2", "s3"]);
|
||||
});
|
||||
|
||||
it("expands the single colonies with an aria-expanded button", async () => {
|
||||
setup(THREE_PLAN);
|
||||
|
||||
const toggle = screen.getByRole("button", { name: /Einzeln anzeigen/ });
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
expect(screen.queryByText("Kirsche blüht.")).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(toggle);
|
||||
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||
expect(screen.getAllByText("Kirsche blüht.")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("offers „Runde starten“ for combined inspections instead of „Alle übernehmen“", async () => {
|
||||
const { onStartRound } = setup([
|
||||
suggestionFixture({ id: "s1", mode: "DO", intent: "INSPECT", colonyId: "c2", colonyCode: "A2" }),
|
||||
suggestionFixture({ id: "s2", mode: "DO", intent: "INSPECT", colonyId: "c1", colonyCode: "A1" }),
|
||||
]);
|
||||
|
||||
expect(screen.getByText("Durchsicht fällig · 2 Völker")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Alle übernehmen" })).not.toBeInTheDocument();
|
||||
const link = screen.getByRole("link", { name: "Runde starten" });
|
||||
expect(link).toHaveAttribute("href", "/inspections/new?colonyId=c1&round=a1");
|
||||
await userEvent.click(link);
|
||||
|
||||
expect(onStartRound).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([expect.objectContaining({ id: "s1" })]),
|
||||
);
|
||||
});
|
||||
|
||||
it("has no „Alle übernehmen“ and no round for DO groups of other intents", () => {
|
||||
setup([
|
||||
suggestionFixture({ id: "s1", mode: "DO", intent: "FEED", colonyId: "c1" }),
|
||||
suggestionFixture({ id: "s2", mode: "DO", intent: "FEED", colonyId: "c2", colonyCode: "A2" }),
|
||||
]);
|
||||
|
||||
expect(screen.getByText("Füttern · 2 Völker")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Alle übernehmen" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("link", { name: "Runde starten" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("counts distinct colonies, and says „Vorschläge“ when there are none", () => {
|
||||
const noColony = { colonyId: null, colonyCode: null, apiaryId: null, apiaryName: null };
|
||||
setup([
|
||||
suggestionFixture({ id: "s1", colonyId: "c1" }),
|
||||
suggestionFixture({ id: "s2", colonyId: "c1" }),
|
||||
]);
|
||||
expect(screen.getByText("Honigraum prüfen · 1 Volk")).toBeInTheDocument();
|
||||
cleanup();
|
||||
|
||||
setup([
|
||||
suggestionFixture({ id: "s1", intent: "STOCK_REORDER", ...noColony }),
|
||||
suggestionFixture({ id: "s2", intent: "STOCK_REORDER", ...noColony }),
|
||||
]);
|
||||
expect(screen.getByText("Nachbestellen · 2 Vorschläge")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to a plain inspection link when no round can be started", () => {
|
||||
const [group] = groupSuggestions([
|
||||
suggestionFixture({ id: "s1", mode: "DO", intent: "INSPECT", colonyId: "c2", colonyCode: "A2" }),
|
||||
suggestionFixture({ id: "s2", mode: "DO", intent: "INSPECT", colonyId: "c1", colonyCode: "A1" }),
|
||||
]);
|
||||
render(
|
||||
<SuggestionGroup
|
||||
group={group}
|
||||
canStartRound={false}
|
||||
onAccept={vi.fn()}
|
||||
onDismiss={vi.fn()}
|
||||
onAcceptAll={vi.fn()}
|
||||
onStartRound={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const link = screen.getByRole("link", { name: "Runde starten" });
|
||||
expect(link).toHaveAttribute("href", "/inspections/new?colonyId=c1");
|
||||
});
|
||||
|
||||
it("disables only the cards that are busy", () => {
|
||||
const [group] = groupSuggestions([
|
||||
suggestionFixture({ id: "s1", colonyId: "c1", intent: "FEED" }),
|
||||
suggestionFixture({ id: "s2", colonyId: "c2", intent: "WINTER_CHECK" }),
|
||||
]);
|
||||
render(
|
||||
<SuggestionGroup
|
||||
group={group}
|
||||
busyIds={new Set(["s1"])}
|
||||
onAccept={vi.fn()}
|
||||
onDismiss={vi.fn()}
|
||||
onAcceptAll={vi.fn()}
|
||||
onStartRound={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const accept = screen.getAllByRole("button", { name: "Übernehmen" });
|
||||
expect(accept[0]).toBeDisabled();
|
||||
expect(accept[1]).toBeEnabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Check, ChevronDown, Play } from "lucide-react";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { INTENT_ICONS, INTENT_LABELS } from "@/lib/suggestions/labels";
|
||||
import { roundTarget, type SuggestionGroupModel } from "@/lib/suggestions/grouping";
|
||||
import type { Suggestion, SuggestionIntent } from "@/lib/suggestions/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SuggestionCard } from "./suggestion-card";
|
||||
|
||||
export interface SuggestionGroupProps {
|
||||
group: SuggestionGroupModel;
|
||||
/** Suggestions with a running decision; only their cards wait. */
|
||||
busyIds?: ReadonlySet<string>;
|
||||
canAccept?: boolean;
|
||||
canDismiss?: boolean;
|
||||
/** False without a signed-in user: „Runde starten“ then only opens the first inspection. */
|
||||
canStartRound?: boolean;
|
||||
onAccept: (suggestion: Suggestion, dueDate?: string) => void;
|
||||
onDismiss: (suggestion: Suggestion) => void;
|
||||
onAcceptAll: (ids: string[]) => void;
|
||||
/** Called when „Runde starten“ is tapped; the link then opens the first inspection. */
|
||||
onStartRound: (suggestions: Suggestion[]) => void;
|
||||
onCleanup?: (suggestion: Suggestion) => void;
|
||||
}
|
||||
|
||||
interface CombinedRowProps extends Omit<SuggestionGroupProps, "group"> {
|
||||
intent: SuggestionIntent;
|
||||
suggestions: Suggestion[];
|
||||
}
|
||||
|
||||
/** „n Völker“ when the rows belong to colonies, else „n Vorschläge“. */
|
||||
function countLabel(suggestions: readonly Suggestion[]): string {
|
||||
const colonies = new Set(
|
||||
suggestions.flatMap((suggestion) => (suggestion.colonyId ? [suggestion.colonyId] : [])),
|
||||
);
|
||||
if (colonies.size === 0) return `${suggestions.length} Vorschläge`;
|
||||
return colonies.size === 1 ? "1 Volk" : `${colonies.size} Völker`;
|
||||
}
|
||||
|
||||
function CombinedRow({
|
||||
intent,
|
||||
suggestions,
|
||||
busyIds,
|
||||
canAccept = true,
|
||||
canDismiss = true,
|
||||
canStartRound = true,
|
||||
...handlers
|
||||
}: CombinedRowProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const listId = useId();
|
||||
const Icon = INTENT_ICONS[intent];
|
||||
const round = intent === "INSPECT" ? roundTarget(suggestions) : null;
|
||||
const canAcceptAll =
|
||||
canAccept && suggestions.every((suggestion) => suggestion.mode === "PLAN");
|
||||
const isBusy = suggestions.some((suggestion) => busyIds?.has(suggestion.id));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-2xl border bg-card p-4 shadow-[var(--shadow-soft)]">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/12 text-primary">
|
||||
<Icon className="size-5" aria-hidden />
|
||||
</span>
|
||||
<h3 className="min-w-0 flex-1 font-medium">
|
||||
{INTENT_LABELS[intent]} · {countLabel(suggestions)}
|
||||
</h3>
|
||||
{round ? (
|
||||
<Link
|
||||
href={canStartRound ? round.href : round.plainHref}
|
||||
onClick={() => {
|
||||
if (canStartRound) handlers.onStartRound(suggestions);
|
||||
}}
|
||||
className={buttonVariants({ size: "touch" })}
|
||||
>
|
||||
<Play aria-hidden />
|
||||
Runde starten
|
||||
</Link>
|
||||
) : canAcceptAll ? (
|
||||
<Button
|
||||
size="touch"
|
||||
disabled={isBusy}
|
||||
onClick={() => handlers.onAcceptAll(suggestions.map((suggestion) => suggestion.id))}
|
||||
>
|
||||
<Check aria-hidden />
|
||||
Alle übernehmen
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="min-h-11 self-start"
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={listId}
|
||||
onClick={() => setIsOpen((open) => !open)}
|
||||
>
|
||||
<ChevronDown
|
||||
aria-hidden
|
||||
className={cn("transition-transform", isOpen && "rotate-180")}
|
||||
/>
|
||||
{isOpen ? "Einzeln ausblenden" : "Einzeln anzeigen"}
|
||||
</Button>
|
||||
<div id={listId} hidden={!isOpen} className="flex flex-col gap-3">
|
||||
{isOpen
|
||||
? suggestions.map((suggestion) => (
|
||||
<SuggestionCard
|
||||
key={suggestion.id}
|
||||
suggestion={suggestion}
|
||||
busy={busyIds?.has(suggestion.id)}
|
||||
canAccept={canAccept}
|
||||
canDismiss={canDismiss}
|
||||
onAccept={handlers.onAccept}
|
||||
onDismiss={handlers.onDismiss}
|
||||
onCleanup={handlers.onCleanup}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** All open suggestions of one stand (or the farm), same intents folded into one row. */
|
||||
export function SuggestionGroup({ group, ...handlers }: SuggestionGroupProps) {
|
||||
return (
|
||||
<section className="flex flex-col gap-3">
|
||||
<h2 className="font-heading text-lg font-semibold">{group.label}</h2>
|
||||
{group.rows.map((row) =>
|
||||
row.kind === "single" ? (
|
||||
<SuggestionCard
|
||||
key={row.suggestion.id}
|
||||
suggestion={row.suggestion}
|
||||
busy={handlers.busyIds?.has(row.suggestion.id)}
|
||||
canAccept={handlers.canAccept}
|
||||
canDismiss={handlers.canDismiss}
|
||||
onAccept={handlers.onAccept}
|
||||
onDismiss={handlers.onDismiss}
|
||||
onCleanup={handlers.onCleanup}
|
||||
/>
|
||||
) : (
|
||||
<CombinedRow
|
||||
key={`${group.key}:${row.intent}`}
|
||||
intent={row.intent}
|
||||
suggestions={row.suggestions}
|
||||
{...handlers}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -39,10 +39,11 @@ export function TaskStatusPill({ status, className }: { status: TaskStatus; clas
|
||||
export function TaskSourceBadge({
|
||||
task,
|
||||
}: {
|
||||
task: Pick<Task, "sourceAutomationTrigger" | "sourceTemplateId" | "sourceInspectionId">;
|
||||
task: Pick<Task, "sourceAutomationTrigger" | "sourceTemplateId" | "sourceInspectionId"> &
|
||||
Partial<Pick<Task, "sourceSuggestionId">>;
|
||||
}) {
|
||||
const source = task.sourceAutomationTrigger
|
||||
? { icon: Sparkles, label: "Vorschlag", title: "Automatisch vorgeschlagen – noch nichts erledigt" }
|
||||
const source = task.sourceSuggestionId || task.sourceAutomationTrigger
|
||||
? { icon: Sparkles, label: "Aus Vorschlag", title: "Aus einem übernommenen Vorschlag" }
|
||||
: task.sourceTemplateId
|
||||
? { icon: Repeat, label: "Wiederkehrend", title: "Aus einer wiederkehrenden Vorlage" }
|
||||
: task.sourceInspectionId
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { BulkMiteCounts } from "./bulk-mite-counts";
|
||||
import { fetchVarroaThresholds } from "@/lib/varroa/api";
|
||||
import type { VarroaBands } from "@/lib/varroa/bands";
|
||||
|
||||
vi.mock("@/lib/varroa/api", () => ({ fetchVarroaThresholds: vi.fn() }));
|
||||
|
||||
const BANDS: VarroaBands = {
|
||||
day: "2026-08-10",
|
||||
bands: {
|
||||
NATURAL_MITE_FALL: {
|
||||
method: "NATURAL_MITE_FALL",
|
||||
unit: "MITES_PER_DAY",
|
||||
yellowFrom: 5,
|
||||
redAbove: 10,
|
||||
reason: "NATURAL_FALL_SEASON",
|
||||
youngColony: false,
|
||||
},
|
||||
},
|
||||
postTreatmentUntil: null,
|
||||
active: true,
|
||||
};
|
||||
|
||||
function renderCounts(counts: Record<string, string>, sampleBees = "") {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<BulkMiteCounts
|
||||
colonies={[
|
||||
{ id: "c-1", code: "V-01" },
|
||||
{ id: "c-2", code: "V-02" },
|
||||
]}
|
||||
counts={counts}
|
||||
onCountChange={() => undefined}
|
||||
method="NATURAL_MITE_FALL"
|
||||
date="2026-08-10"
|
||||
durationMinutes="4320"
|
||||
sampleBees={sampleBees}
|
||||
errors={{}}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("BulkMiteCounts", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchVarroaThresholds).mockReset().mockResolvedValue(BANDS);
|
||||
});
|
||||
|
||||
it("renders one count per colony and says who still lacks one", () => {
|
||||
renderCounts({ "c-1": "30" });
|
||||
expect(screen.getByLabelText(/Milbenzahl V-01/)).toHaveValue(30);
|
||||
expect(screen.getByLabelText(/Milbenzahl V-02/)).toHaveValue(null);
|
||||
expect(screen.getByText("Noch ohne Milbenzahl: V-02")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("rates each colony against the bands of its own id", async () => {
|
||||
renderCounts({ "c-1": "36", "c-2": "3" });
|
||||
const first = screen.getByRole("group", { name: "V-01" });
|
||||
expect(await within(first).findByText(/Kritisch/)).toBeInTheDocument();
|
||||
expect(fetchVarroaThresholds).toHaveBeenCalledWith("c-1", "2026-08-10");
|
||||
expect(fetchVarroaThresholds).toHaveBeenCalledWith("c-2", "2026-08-10");
|
||||
});
|
||||
|
||||
it("shows the plain value when the thresholds cannot be loaded", async () => {
|
||||
vi.mocked(fetchVarroaThresholds).mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
renderCounts({ "c-1": "36" });
|
||||
const first = screen.getByRole("group", { name: "V-01" });
|
||||
expect(await within(first).findByText("12,0 Milben/Tag")).toBeInTheDocument();
|
||||
expect(first).not.toHaveTextContent("Kritisch");
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,10 @@ import { Field } from "@/components/form/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { VarroaRateLight } from "@/components/varroa/varroa-rate-light";
|
||||
import { emptyToNull } from "@/lib/forms/empty-to-null";
|
||||
import { infestationPercent, usesSample } from "@/lib/varroa/bands";
|
||||
import { mitesPerDay } from "@/lib/varroa/rate";
|
||||
import type { VarroaMethod } from "@/lib/varroa/types";
|
||||
import { useVarroaBands } from "@/lib/varroa/use-varroa-bands";
|
||||
|
||||
interface BulkMiteCountsProps {
|
||||
/** The chosen colonies, in the order they are listed. */
|
||||
@@ -12,24 +15,54 @@ interface BulkMiteCountsProps {
|
||||
/** The typed count of each colony, by id. */
|
||||
counts: Readonly<Record<string, string>>;
|
||||
onCountChange: (colonyId: string, count: string) => void;
|
||||
method: VarroaMethod;
|
||||
date: string;
|
||||
/** Shared by all colonies; with a natural mite fall it gives each one its rate. */
|
||||
durationMinutes: string;
|
||||
showsRate: boolean;
|
||||
threshold: number | null;
|
||||
/** Shared sample size; with a sample method it gives each one its percentage. */
|
||||
sampleBees: string;
|
||||
errors: Readonly<Record<string, string | undefined>>;
|
||||
}
|
||||
|
||||
/** The live value of one colony against the bands of its own type. */
|
||||
function ColonyRate({
|
||||
colonyId,
|
||||
method,
|
||||
date,
|
||||
miteCount,
|
||||
durationMinutes,
|
||||
sampleBees,
|
||||
}: {
|
||||
colonyId: string;
|
||||
method: VarroaMethod;
|
||||
date: string;
|
||||
miteCount: number | null;
|
||||
durationMinutes: number | null;
|
||||
sampleBees: number | null;
|
||||
}) {
|
||||
const bands = useVarroaBands(colonyId, date);
|
||||
const value =
|
||||
method === "NATURAL_MITE_FALL"
|
||||
? mitesPerDay(miteCount, durationMinutes)
|
||||
: usesSample(method)
|
||||
? infestationPercent(miteCount, sampleBees)
|
||||
: null;
|
||||
if (value === null) return null;
|
||||
return <VarroaRateLight method={method} value={value} bands={bands} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* One mite count per colony - there is no shared count - and, for a natural
|
||||
* mite fall, the rate with its traffic light right under each count.
|
||||
* mite fall or a sample method, the value with its traffic light right under each count.
|
||||
*/
|
||||
export function BulkMiteCounts({
|
||||
colonies,
|
||||
counts,
|
||||
onCountChange,
|
||||
method,
|
||||
date,
|
||||
durationMinutes,
|
||||
showsRate,
|
||||
threshold,
|
||||
sampleBees,
|
||||
errors,
|
||||
}: BulkMiteCountsProps) {
|
||||
const missing = colonies.filter(({ id }) => (counts[id] ?? "").trim() === "");
|
||||
@@ -70,15 +103,14 @@ export function BulkMiteCounts({
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
{showsRate ? (
|
||||
<VarroaRateLight
|
||||
rate={mitesPerDay(
|
||||
emptyToNull(count, Number),
|
||||
emptyToNull(durationMinutes, Number),
|
||||
)}
|
||||
threshold={threshold}
|
||||
/>
|
||||
) : null}
|
||||
<ColonyRate
|
||||
colonyId={id}
|
||||
method={method}
|
||||
date={date}
|
||||
miteCount={emptyToNull(count, Number)}
|
||||
durationMinutes={emptyToNull(durationMinutes, Number)}
|
||||
sampleBees={emptyToNull(sampleBees, Number)}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { ChoiceGroup } from "@/components/form/choice-group";
|
||||
import { Field } from "@/components/form/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { SAMPLE_BEES_CHOICE } from "@/lib/varroa/bands";
|
||||
|
||||
const MAX_SAMPLE_BEES = 2000;
|
||||
const CHIP = [{ value: SAMPLE_BEES_CHOICE, label: `${SAMPLE_BEES_CHOICE} Bienen (≈ 50 g)` }];
|
||||
|
||||
/**
|
||||
* How many bees the sample held (powdered sugar, washing). The chip is only
|
||||
* a shortcut: nothing is preselected, the number stays the single source.
|
||||
*/
|
||||
export function SampleBeesField({
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (sampleBees: string) => void;
|
||||
error?: string | null;
|
||||
}) {
|
||||
const chosen = Number(value) === SAMPLE_BEES_CHOICE ? SAMPLE_BEES_CHOICE : null;
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Field
|
||||
label="Probe (Bienen)"
|
||||
hint="Daraus rechnet die App den Befall in Prozent."
|
||||
error={error}
|
||||
>
|
||||
{({ id, describedBy, invalid }) => (
|
||||
<Input
|
||||
id={id}
|
||||
data-field="sampleBees"
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={MAX_SAMPLE_BEES}
|
||||
className="h-11"
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<ChoiceGroup
|
||||
label="Übliche Probe"
|
||||
options={CHIP}
|
||||
value={chosen as number | null}
|
||||
onChange={(next) => onChange(next === null ? "" : String(next))}
|
||||
clearValue={null}
|
||||
className="grid-cols-1 sm:grid-cols-2"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { VarroaRateLight } from "./varroa-rate-light";
|
||||
import type { VarroaBands } from "@/lib/varroa/bands";
|
||||
|
||||
const BANDS: VarroaBands = {
|
||||
day: "2026-08-10",
|
||||
bands: {
|
||||
NATURAL_MITE_FALL: {
|
||||
method: "NATURAL_MITE_FALL",
|
||||
unit: "MITES_PER_DAY",
|
||||
yellowFrom: 5,
|
||||
redAbove: 10,
|
||||
reason: "NATURAL_FALL_SEASON",
|
||||
youngColony: false,
|
||||
},
|
||||
},
|
||||
postTreatmentUntil: null,
|
||||
active: true,
|
||||
};
|
||||
|
||||
describe("VarroaRateLight", () => {
|
||||
it("keeps one status element while the content changes", () => {
|
||||
const { rerender } = render(<VarroaRateLight method="NATURAL_MITE_FALL" value={null} bands={BANDS} />);
|
||||
const status = screen.getByRole("status");
|
||||
expect(status).toHaveAttribute("aria-live", "polite");
|
||||
expect(status).toBeEmptyDOMElement();
|
||||
|
||||
rerender(<VarroaRateLight method="NATURAL_MITE_FALL" value={12.4} bands={BANDS} />);
|
||||
expect(screen.getByRole("status")).toBe(status);
|
||||
expect(status).toHaveTextContent("12,4 Milben/Tag – Kritisch (Grenze 10, LWG)");
|
||||
|
||||
rerender(<VarroaRateLight method="NATURAL_MITE_FALL" value={3} bands={null} />);
|
||||
expect(screen.getByRole("status")).toBe(status);
|
||||
expect(status).toHaveTextContent(/^3,0 Milben\/Tag$/);
|
||||
expect(screen.getAllByRole("status")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("names every state in words, not by colour alone", () => {
|
||||
const { rerender } = render(<VarroaRateLight method="NATURAL_MITE_FALL" value={1} bands={BANDS} />);
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Gut");
|
||||
rerender(<VarroaRateLight method="NATURAL_MITE_FALL" value={6} bands={BANDS} />);
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Achtung");
|
||||
rerender(
|
||||
<VarroaRateLight method="NATURAL_MITE_FALL" value={6} bands={{ ...BANDS, postTreatmentUntil: "2026-08-16" }} />,
|
||||
);
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Behandlungseffekt – Nachkontrolle ab 16.08.");
|
||||
});
|
||||
|
||||
it("shows only the value for a method without a band", () => {
|
||||
render(<VarroaRateLight method="POWDERED_SUGAR" value={2.4} bands={BANDS} />);
|
||||
expect(screen.getByRole("status")).toHaveTextContent(/^2,4 %$/);
|
||||
});
|
||||
});
|
||||
@@ -1,55 +1,62 @@
|
||||
import { AlertTriangle, CheckCircle2, OctagonAlert } from "lucide-react";
|
||||
import { AlertTriangle, CheckCircle2, Hourglass, OctagonAlert } from "lucide-react";
|
||||
import { RATING_TONE_CLASS } from "@/lib/forms/rating";
|
||||
import { formatMitesPerDay, formatThreshold } from "@/lib/varroa/format";
|
||||
import { VarroaLight, varroaLight } from "@/lib/varroa/rate";
|
||||
import { VarroaBands, VarroaNote, liveNote, valueText } from "@/lib/varroa/bands";
|
||||
import { formatMitesPerDay } from "@/lib/varroa/format";
|
||||
import type { VarroaMethod } from "@/lib/varroa/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const LIGHTS: Record<VarroaLight, { Icon: typeof CheckCircle2; tone: string }> = {
|
||||
const NOTE_LOOK: Record<VarroaNote["kind"], { Icon: typeof CheckCircle2; tone: string }> = {
|
||||
green: { Icon: CheckCircle2, tone: RATING_TONE_CLASS[4] },
|
||||
yellow: { Icon: AlertTriangle, tone: RATING_TONE_CLASS[3] },
|
||||
red: { Icon: OctagonAlert, tone: RATING_TONE_CLASS[1] },
|
||||
effect: { Icon: Hourglass, tone: "border-border bg-muted text-foreground" },
|
||||
};
|
||||
|
||||
function lightText(light: VarroaLight, threshold: number): string {
|
||||
if (light === "red") return "über der Schwelle, Behandlung prüfen";
|
||||
if (light === "yellow") return "nahe an der Schwelle";
|
||||
return `unter der Schwelle (${formatThreshold(threshold)}/Tag)`;
|
||||
/** Icon + words for a light or the treatment effect; never colour alone. */
|
||||
export function VarroaNoteLabel({
|
||||
note,
|
||||
prefix,
|
||||
className,
|
||||
}: {
|
||||
note: VarroaNote;
|
||||
/** The value the note belongs to, e.g. "12,4 Milben/Tag". */
|
||||
prefix?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const { Icon, tone } = NOTE_LOOK[note.kind];
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"flex min-h-11 items-center gap-2 rounded-lg border px-3 text-sm font-medium",
|
||||
tone,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Icon aria-hidden className="size-5 shrink-0" />
|
||||
<span>{prefix ? `${prefix} – ${note.text}` : note.text}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The live result of the count. The state is always spelled out and has an
|
||||
* icon, so it never depends on the colour alone.
|
||||
* The live result of the count against the bands of the chosen day. Without
|
||||
* a band (rule off, offline, method without a light) only the value shows.
|
||||
*/
|
||||
export function VarroaRateLight({
|
||||
rate,
|
||||
threshold,
|
||||
method,
|
||||
value,
|
||||
bands,
|
||||
}: {
|
||||
rate: number | null;
|
||||
threshold: number | null;
|
||||
method: VarroaMethod;
|
||||
value: number | null;
|
||||
bands: VarroaBands | null;
|
||||
}) {
|
||||
const light = varroaLight(rate, threshold);
|
||||
if (rate === null) {
|
||||
return <div role="status" aria-live="polite" />;
|
||||
}
|
||||
const base = `${formatMitesPerDay(rate)} Milben/Tag`;
|
||||
if (light === null || threshold === null) {
|
||||
return (
|
||||
<p role="status" aria-live="polite" className="text-sm font-medium">
|
||||
{base}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
const { Icon, tone } = LIGHTS[light];
|
||||
const base = value === null ? null : valueText(method, value, formatMitesPerDay);
|
||||
const note = value === null ? null : liveNote(method, value, bands);
|
||||
// One element for the whole life of the form, so screen readers announce changes.
|
||||
return (
|
||||
<p
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={cn("flex min-h-11 items-center gap-2 rounded-lg border px-3 text-sm font-medium", tone)}
|
||||
>
|
||||
<Icon aria-hidden className="size-5 shrink-0" />
|
||||
<span>
|
||||
{base} – {lightText(light, threshold)}
|
||||
</span>
|
||||
<p role="status" aria-live="polite" className={base !== null && note === null ? "text-sm font-medium" : undefined}>
|
||||
{base === null ? null : note === null ? base : <VarroaNoteLabel note={note} prefix={base} />}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { toApiDate, toLocalIsoDate, todayLocalIsoDate } from "./dates";
|
||||
import { berlinCalendarDay, toApiDate, toLocalIsoDate, todayLocalIsoDate } from "./dates";
|
||||
|
||||
/**
|
||||
* The beekeeper's phone runs in Europe/Berlin. Between 00:00 and 02:00 local
|
||||
@@ -69,3 +69,12 @@ describe("local calendar dates", () => {
|
||||
expect(() => toApiDate("2026-02-30")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("berlinCalendarDay", () => {
|
||||
it("is the Berlin day whatever the device time zone", () => {
|
||||
expect(berlinCalendarDay("2026-08-31T22:30:00.000Z")).toBe("2026-09-01");
|
||||
expect(berlinCalendarDay("2026-12-31T23:30:00.000Z")).toBe("2027-01-01");
|
||||
expect(berlinCalendarDay("2026-08-31T21:59:59.000Z")).toBe("2026-08-31");
|
||||
expect(berlinCalendarDay("2026-06-15T00:00:00.000Z")).toBe("2026-06-15");
|
||||
});
|
||||
});
|
||||
@@ -42,3 +42,15 @@ const CALENDAR_DAY_LENGTH = 10;
|
||||
export function toCalendarDay(isoTimestamp: string): string {
|
||||
return isoTimestamp.slice(0, CALENDAR_DAY_LENGTH);
|
||||
}
|
||||
|
||||
const BERLIN_DAY = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "Europe/Berlin",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
|
||||
/** The Europe/Berlin calendar day of an instant, as "YYYY-MM-DD" (the backend's day). */
|
||||
export function berlinCalendarDay(isoTimestamp: string): string {
|
||||
return BERLIN_DAY.format(new Date(isoTimestamp));
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { apiFetch } from "@/lib/auth/api-client";
|
||||
import { CreateOptions, idempotencyHeaders } from "@/lib/offline/idempotency";
|
||||
import type {
|
||||
AcceptSuggestionResult,
|
||||
Suggestion,
|
||||
SuggestionQuery,
|
||||
SuggestionSummary,
|
||||
} from "./types";
|
||||
|
||||
export const SUGGESTIONS_KEY = "suggestions";
|
||||
|
||||
/** Rows per page of the open list. */
|
||||
export const SUGGESTION_PAGE_SIZE = 200;
|
||||
|
||||
export function suggestionsKey(query?: SuggestionQuery) {
|
||||
return [SUGGESTIONS_KEY, query ?? {}] as const;
|
||||
}
|
||||
|
||||
export const suggestionSummaryKey = [SUGGESTIONS_KEY, "summary"] as const;
|
||||
|
||||
export function fetchSuggestions(query: SuggestionQuery = {}): Promise<Suggestion[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (query.status) params.set("status", query.status);
|
||||
if (query.colonyId) params.set("colonyId", query.colonyId);
|
||||
if (query.apiaryId) params.set("apiaryId", query.apiaryId);
|
||||
if (query.limit) params.set("limit", String(query.limit));
|
||||
if (query.offset) params.set("offset", String(query.offset));
|
||||
const search = params.toString();
|
||||
return apiFetch<Suggestion[]>(`/suggestions${search ? `?${search}` : ""}`);
|
||||
}
|
||||
|
||||
export function fetchSuggestionSummary(): Promise<SuggestionSummary> {
|
||||
return apiFetch<SuggestionSummary>("/suggestions/summary");
|
||||
}
|
||||
|
||||
function post<T>(path: string, body: unknown, options: CreateOptions): Promise<T> {
|
||||
return apiFetch<T>(path, {
|
||||
method: "POST",
|
||||
headers: idempotencyHeaders(options),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function suggestionPath(id: string, action: string): string {
|
||||
return `/suggestions/${encodeURIComponent(id)}/${action}`;
|
||||
}
|
||||
|
||||
/** `dueDate` is a plain day (YYYY-MM-DD); without one the suggestion's own day is used. */
|
||||
export function acceptSuggestion(
|
||||
id: string,
|
||||
input: { dueDate?: string | null },
|
||||
options: CreateOptions = {},
|
||||
): Promise<AcceptSuggestionResult> {
|
||||
return post(suggestionPath(id, "accept"), input, options);
|
||||
}
|
||||
|
||||
/** 1 to 50 PLAN suggestions, all or nothing. */
|
||||
export function bulkAcceptSuggestions(
|
||||
ids: string[],
|
||||
options: CreateOptions = {},
|
||||
): Promise<AcceptSuggestionResult[]> {
|
||||
return post("/suggestions/bulk-accept", { ids }, options);
|
||||
}
|
||||
|
||||
export function dismissSuggestion(
|
||||
id: string,
|
||||
note: string | null,
|
||||
options: CreateOptions = {},
|
||||
): Promise<Suggestion> {
|
||||
return post(suggestionPath(id, "dismiss"), note ? { note } : {}, options);
|
||||
}
|
||||
|
||||
export function bulkDismissSuggestions(
|
||||
ids: string[],
|
||||
options: CreateOptions = {},
|
||||
): Promise<{ dismissed: number }> {
|
||||
return post("/suggestions/bulk-dismiss", { ids }, options);
|
||||
}
|
||||
|
||||
/** Only possible within 30 days after the decision. */
|
||||
export function reopenSuggestion(id: string, options: CreateOptions = {}): Promise<Suggestion> {
|
||||
return post(suggestionPath(id, "reopen"), {}, options);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import { suggestionErrorText } from "./errors";
|
||||
|
||||
describe("suggestionErrorText", () => {
|
||||
it("uses the server's German text for a 409", () => {
|
||||
expect(suggestionErrorText(new ApiError(409, "Der Vorschlag ist nicht mehr offen."))).toBe(
|
||||
"Der Vorschlag ist nicht mehr offen.",
|
||||
);
|
||||
});
|
||||
|
||||
it("maps a Nest 400 with a message array to a German fallback", () => {
|
||||
const error = new ApiError(400, "dueDate must match", { message: ["dueDate must match"] });
|
||||
expect(suggestionErrorText(error)).toBe("Die Eingabe ist ungültig.");
|
||||
});
|
||||
|
||||
it("reads issues[].message when present", () => {
|
||||
const error = new ApiError(400, "Bad", {
|
||||
message: "Bad",
|
||||
issues: [{ field: "ids", message: "Bitte ein Datum wählen." }],
|
||||
});
|
||||
expect(suggestionErrorText(error)).toBe("Bitte ein Datum wählen.");
|
||||
});
|
||||
|
||||
it("explains a 404", () => {
|
||||
expect(suggestionErrorText(new ApiError(404, "Not Found"))).toBe(
|
||||
"Der Vorschlag ist nicht mehr da.",
|
||||
);
|
||||
});
|
||||
|
||||
it("says nothing was saved when offline", () => {
|
||||
expect(suggestionErrorText(new TypeError("Failed to fetch"))).toBe(
|
||||
"Keine Verbindung – nichts gespeichert.",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back for anything else", () => {
|
||||
expect(suggestionErrorText(new Error("x"))).toBe(
|
||||
"Das hat nicht geklappt. Bitte noch einmal versuchen.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import { isNetworkError } from "@/lib/http/fetch-with-timeout";
|
||||
|
||||
export const OFFLINE_TEXT = "Keine Verbindung – nichts gespeichert.";
|
||||
const INVALID_TEXT = "Die Eingabe ist ungültig.";
|
||||
const GONE_TEXT = "Der Vorschlag ist nicht mehr da.";
|
||||
const GENERIC_TEXT = "Das hat nicht geklappt. Bitte noch einmal versuchen.";
|
||||
|
||||
function firstIssueMessage(body: unknown): string | null {
|
||||
if (typeof body !== "object" || body === null || !("issues" in body)) return null;
|
||||
const { issues } = body as { issues: unknown };
|
||||
if (!Array.isArray(issues)) return null;
|
||||
for (const issue of issues) {
|
||||
const message = (issue as { message?: unknown } | null)?.message;
|
||||
if (typeof message === "string" && message) return message;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A user-facing German text for a failed decision. Service answers (409,
|
||||
* `issues[]`) are German already; Nest's DTO errors (`message: string[]`,
|
||||
* English) are replaced.
|
||||
*/
|
||||
export function suggestionErrorText(error: unknown): string {
|
||||
if (error instanceof ApiError) {
|
||||
if (error.status === 404) return GONE_TEXT;
|
||||
const issue = firstIssueMessage(error.body);
|
||||
if (issue) return issue;
|
||||
if (error.status === 400) return INVALID_TEXT;
|
||||
if (error.status === 409) return error.message;
|
||||
return GENERIC_TEXT;
|
||||
}
|
||||
return isNetworkError(error) ? OFFLINE_TEXT : GENERIC_TEXT;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { groupSuggestions, roundTarget } from "./grouping";
|
||||
import type { Suggestion } from "./types";
|
||||
|
||||
function suggestion(overrides: Partial<Suggestion>): Suggestion {
|
||||
return {
|
||||
id: "s",
|
||||
ruleKey: "INSPECTION_INTERVAL",
|
||||
source: "AUTOPILOT",
|
||||
mode: "DO",
|
||||
intent: "INSPECT",
|
||||
status: "OPEN",
|
||||
priority: "NORMAL",
|
||||
relatedType: "COLONY",
|
||||
relatedId: null,
|
||||
colonyId: "c1",
|
||||
colonyCode: "A1",
|
||||
apiaryId: "a1",
|
||||
apiaryName: "Hang",
|
||||
title: "Durchsicht fällig",
|
||||
reasons: [],
|
||||
dueDate: null,
|
||||
expiresAt: null,
|
||||
plan: null,
|
||||
decidedAt: null,
|
||||
decisionNote: null,
|
||||
createdAt: "2026-05-01T00:00:00.000Z",
|
||||
cleanup: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("groupSuggestions", () => {
|
||||
it("groups by apiary, then „Betrieb“, and combines the same intent per apiary", () => {
|
||||
const list = [
|
||||
suggestion({ id: "1", colonyId: "c1", colonyCode: "A1" }),
|
||||
suggestion({ id: "2", intent: "HONEY_SUPER", mode: "PLAN", apiaryId: "a2", apiaryName: "Aue", colonyId: "c9", colonyCode: "B1" }),
|
||||
suggestion({ id: "3", colonyId: "c2", colonyCode: "A2" }),
|
||||
suggestion({ id: "4", intent: "STOCK_REORDER", mode: "PLAN", apiaryId: null, apiaryName: null, colonyId: null, colonyCode: null }),
|
||||
];
|
||||
|
||||
const groups = groupSuggestions(list);
|
||||
|
||||
expect(groups.map((g) => g.label)).toEqual(["Aue", "Hang", "Betrieb"]);
|
||||
const hang = groups[1];
|
||||
expect(hang.apiaryId).toBe("a1");
|
||||
expect(hang.rows).toHaveLength(1);
|
||||
const [row] = hang.rows;
|
||||
expect(row.kind).toBe("combined");
|
||||
if (row.kind === "combined") {
|
||||
expect(row.intent).toBe("INSPECT");
|
||||
expect(row.suggestions.map((s) => s.id)).toEqual(["1", "3"]);
|
||||
}
|
||||
expect(groups[0].rows[0].kind).toBe("single");
|
||||
expect(groups[2].apiaryId).toBeNull();
|
||||
expect(groups[2].key).toBe("operation");
|
||||
});
|
||||
|
||||
it("keeps the order of the list inside a group", () => {
|
||||
const groups = groupSuggestions([
|
||||
suggestion({ id: "1", intent: "FEED", colonyId: "c1" }),
|
||||
suggestion({ id: "2", intent: "INSPECT", colonyId: "c2" }),
|
||||
suggestion({ id: "3", intent: "FEED", colonyId: "c3" }),
|
||||
]);
|
||||
|
||||
const kinds = groups[0].rows.map((r) => (r.kind === "combined" ? r.intent : r.suggestion.intent));
|
||||
expect(kinds).toEqual(["FEED", "INSPECT"]);
|
||||
});
|
||||
|
||||
it("returns no groups for an empty list", () => {
|
||||
expect(groupSuggestions([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("roundTarget", () => {
|
||||
it("lists the due colonies in code order and leads to the first one", () => {
|
||||
const target = roundTarget([
|
||||
suggestion({ id: "1", colonyId: "c10", colonyCode: "A10" }),
|
||||
suggestion({ id: "2", colonyId: "c2", colonyCode: "A2" }),
|
||||
suggestion({ id: "3", colonyId: "c2", colonyCode: "A2" }),
|
||||
]);
|
||||
|
||||
expect(target?.apiary).toEqual({ id: "a1", name: "Hang" });
|
||||
expect(target?.colonies).toEqual([
|
||||
{ id: "c2", code: "A2" },
|
||||
{ id: "c10", code: "A10" },
|
||||
]);
|
||||
expect(target?.href).toBe("/inspections/new?colonyId=c2&round=a1");
|
||||
});
|
||||
|
||||
it("is null without a colony at an apiary", () => {
|
||||
expect(roundTarget([suggestion({ apiaryId: null, apiaryName: null })])).toBeNull();
|
||||
expect(roundTarget([])).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { Suggestion, SuggestionIntent } from "./types";
|
||||
|
||||
export type SuggestionRow =
|
||||
| { kind: "single"; suggestion: Suggestion }
|
||||
| { kind: "combined"; intent: SuggestionIntent; suggestions: Suggestion[] };
|
||||
|
||||
export interface SuggestionGroupModel {
|
||||
key: string;
|
||||
label: string;
|
||||
apiaryId: string | null;
|
||||
rows: SuggestionRow[];
|
||||
}
|
||||
|
||||
const OPERATION_KEY = "operation";
|
||||
const OPERATION_LABEL = "Betrieb";
|
||||
|
||||
function compareText(a: string, b: string): number {
|
||||
return a.localeCompare(b, "de", { numeric: true });
|
||||
}
|
||||
|
||||
function rowsOf(list: readonly Suggestion[]): SuggestionRow[] {
|
||||
const byIntent = new Map<SuggestionIntent, Suggestion[]>();
|
||||
for (const suggestion of list) {
|
||||
byIntent.set(suggestion.intent, [...(byIntent.get(suggestion.intent) ?? []), suggestion]);
|
||||
}
|
||||
const rows: SuggestionRow[] = [];
|
||||
const done = new Set<SuggestionIntent>();
|
||||
for (const suggestion of list) {
|
||||
if (done.has(suggestion.intent)) continue;
|
||||
done.add(suggestion.intent);
|
||||
const same = byIntent.get(suggestion.intent) ?? [];
|
||||
rows.push(
|
||||
same.length > 1
|
||||
? { kind: "combined", intent: suggestion.intent, suggestions: same }
|
||||
: { kind: "single", suggestion },
|
||||
);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* One group per stand (by name), then „Betrieb“ for everything without a
|
||||
* stand. Inside a group the API's order (rank) is kept; the same intent is
|
||||
* one row, placed where its first suggestion is.
|
||||
*/
|
||||
export function groupSuggestions(list: readonly Suggestion[]): SuggestionGroupModel[] {
|
||||
const stands = new Map<string, Suggestion[]>();
|
||||
const operation: Suggestion[] = [];
|
||||
for (const suggestion of list) {
|
||||
if (suggestion.apiaryId === null) {
|
||||
operation.push(suggestion);
|
||||
} else {
|
||||
stands.set(suggestion.apiaryId, [...(stands.get(suggestion.apiaryId) ?? []), suggestion]);
|
||||
}
|
||||
}
|
||||
const groups: SuggestionGroupModel[] = [...stands.entries()].map(([apiaryId, members]) => ({
|
||||
key: apiaryId,
|
||||
label: members[0].apiaryName ?? "Stand",
|
||||
apiaryId,
|
||||
rows: rowsOf(members),
|
||||
}));
|
||||
groups.sort((a, b) => compareText(a.label, b.label));
|
||||
if (operation.length > 0) {
|
||||
groups.push({ key: OPERATION_KEY, label: OPERATION_LABEL, apiaryId: null, rows: rowsOf(operation) });
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
export interface RoundTarget {
|
||||
apiary: { id: string; name: string };
|
||||
colonies: { id: string; code: string }[];
|
||||
/** The first inspection of the round. */
|
||||
href: string;
|
||||
/** The first inspection without round parameters (no signed-in user to keep the round). */
|
||||
plainHref: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The stand round for due inspections of one stand: the colonies once each,
|
||||
* in code order. Null without a colony at a stand.
|
||||
*/
|
||||
export function roundTarget(suggestions: readonly Suggestion[]): RoundTarget | null {
|
||||
const colonies = new Map<string, string>();
|
||||
let apiary: RoundTarget["apiary"] | null = null;
|
||||
for (const suggestion of suggestions) {
|
||||
if (suggestion.apiaryId === null || suggestion.colonyId === null) continue;
|
||||
apiary ??= { id: suggestion.apiaryId, name: suggestion.apiaryName ?? "Stand" };
|
||||
if (suggestion.apiaryId === apiary.id) {
|
||||
colonies.set(suggestion.colonyId, suggestion.colonyCode ?? suggestion.colonyId);
|
||||
}
|
||||
}
|
||||
if (apiary === null) return null;
|
||||
const sorted = [...colonies.entries()]
|
||||
.map(([id, code]) => ({ id, code }))
|
||||
.sort((a, b) => compareText(a.code, b.code));
|
||||
const href = `/inspections/new?colonyId=${encodeURIComponent(sorted[0].id)}&round=${encodeURIComponent(apiary.id)}`;
|
||||
const plainHref = `/inspections/new?colonyId=${encodeURIComponent(sorted[0].id)}`;
|
||||
return { apiary, colonies: sorted, href, plainHref };
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { captureHref, INTENT_ICONS, INTENT_LABELS } from "./labels";
|
||||
import { SUGGESTION_INTENTS } from "./types";
|
||||
|
||||
describe("suggestion labels", () => {
|
||||
it("every intent has a label and an icon", () => {
|
||||
for (const intent of SUGGESTION_INTENTS) {
|
||||
expect(INTENT_LABELS[intent]).toBeTruthy();
|
||||
expect(INTENT_ICONS[intent]).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("opens the matching capture form with the colony", () => {
|
||||
expect(captureHref("INSPECT", "c 1")).toBe("/inspections/new?colonyId=c%201");
|
||||
expect(captureHref("VARROA_MEASURE", "c1")).toBe("/varroa/new?colonyId=c1");
|
||||
expect(captureHref("VARROA_RECHECK", "c1")).toBe("/varroa/new?colonyId=c1");
|
||||
expect(captureHref("FEED", "c1")).toBe("/feedings/new?colonyId=c1");
|
||||
expect(captureHref("HONEY_SUPER", "c1")).toBe("/inspections/new?colonyId=c1");
|
||||
});
|
||||
|
||||
it("opens the winter page for a winter check and omits a missing colony", () => {
|
||||
expect(captureHref("WINTER_CHECK", "c1")).toBe("/winter");
|
||||
expect(captureHref("INSPECT", null)).toBe("/inspections/new");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
Bug,
|
||||
CalendarCheck,
|
||||
ClipboardCheck,
|
||||
Crown,
|
||||
Droplet,
|
||||
Eraser,
|
||||
HeartPulse,
|
||||
RefreshCw,
|
||||
ShoppingCart,
|
||||
Snowflake,
|
||||
Split,
|
||||
Sprout,
|
||||
Sun,
|
||||
Syringe,
|
||||
UserCheck,
|
||||
Utensils,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import type { SuggestionIntent } from "./types";
|
||||
|
||||
/** Used for combined rows („Durchsicht fällig · 6 Völker“) and as the icon's name. */
|
||||
export const INTENT_LABELS: Record<SuggestionIntent, string> = {
|
||||
INSPECT: "Durchsicht fällig",
|
||||
SWARM_CHECK: "Schwarmkontrolle",
|
||||
QUEEN_CHECK: "Königin prüfen",
|
||||
QUEEN_ACCEPTANCE: "Zusetzen prüfen",
|
||||
QUEEN_REPLACE: "Königin ersetzen",
|
||||
SPLIT_PLAN: "Ableger prüfen",
|
||||
VARROA_MEASURE: "Varroa messen",
|
||||
VARROA_RECHECK: "Varroa nachkontrollieren",
|
||||
VARROA_TREAT: "Behandlung prüfen",
|
||||
SUMMER_TREATMENT: "Sommerbehandlung",
|
||||
FEED: "Füttern",
|
||||
HONEY_SUPER: "Honigraum prüfen",
|
||||
WINTER_CHECK: "Winterkontrolle",
|
||||
HEALTH_CHECK: "Gesundheit prüfen",
|
||||
FOLLOW_UP: "Nachkontrolle",
|
||||
STOCK_REORDER: "Nachbestellen",
|
||||
CLEANUP: "Aufräumen",
|
||||
};
|
||||
|
||||
export const INTENT_ICONS: Record<SuggestionIntent, LucideIcon> = {
|
||||
INSPECT: ClipboardCheck,
|
||||
SWARM_CHECK: Sprout,
|
||||
QUEEN_CHECK: Crown,
|
||||
QUEEN_ACCEPTANCE: UserCheck,
|
||||
QUEEN_REPLACE: RefreshCw,
|
||||
SPLIT_PLAN: Split,
|
||||
VARROA_MEASURE: Bug,
|
||||
VARROA_RECHECK: Bug,
|
||||
VARROA_TREAT: Syringe,
|
||||
SUMMER_TREATMENT: Sun,
|
||||
FEED: Utensils,
|
||||
HONEY_SUPER: Droplet,
|
||||
WINTER_CHECK: Snowflake,
|
||||
HEALTH_CHECK: HeartPulse,
|
||||
FOLLOW_UP: CalendarCheck,
|
||||
STOCK_REORDER: ShoppingCart,
|
||||
CLEANUP: Eraser,
|
||||
};
|
||||
|
||||
/** The form a DO suggestion („Erfassen“) opens; the inspection form for the rest. */
|
||||
const CAPTURE_PATHS: Partial<Record<SuggestionIntent, string>> = {
|
||||
VARROA_MEASURE: "/varroa/new",
|
||||
VARROA_RECHECK: "/varroa/new",
|
||||
FEED: "/feedings/new",
|
||||
WINTER_CHECK: "/winter",
|
||||
};
|
||||
|
||||
/** Winter checks are entered from the winter page, which has no colony parameter. */
|
||||
const PATHS_WITHOUT_COLONY = new Set(["/winter"]);
|
||||
|
||||
const DEFAULT_CAPTURE_PATH = "/inspections/new";
|
||||
|
||||
export function captureHref(intent: SuggestionIntent, colonyId: string | null): string {
|
||||
const path = CAPTURE_PATHS[intent] ?? DEFAULT_CAPTURE_PATH;
|
||||
if (!colonyId || PATHS_WITHOUT_COLONY.has(path)) return path;
|
||||
return `${path}?colonyId=${encodeURIComponent(colonyId)}`;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { Task, TaskPriority, TaskRelatedType } from "@/lib/tasks/types";
|
||||
|
||||
export const SUGGESTION_INTENTS = [
|
||||
"INSPECT",
|
||||
"SWARM_CHECK",
|
||||
"QUEEN_CHECK",
|
||||
"QUEEN_ACCEPTANCE",
|
||||
"QUEEN_REPLACE",
|
||||
"SPLIT_PLAN",
|
||||
"VARROA_MEASURE",
|
||||
"VARROA_RECHECK",
|
||||
"VARROA_TREAT",
|
||||
"SUMMER_TREATMENT",
|
||||
"FEED",
|
||||
"HONEY_SUPER",
|
||||
"WINTER_CHECK",
|
||||
"HEALTH_CHECK",
|
||||
"FOLLOW_UP",
|
||||
"STOCK_REORDER",
|
||||
"CLEANUP",
|
||||
] as const;
|
||||
|
||||
export type SuggestionIntent = (typeof SUGGESTION_INTENTS)[number];
|
||||
|
||||
export type SuggestionStatus =
|
||||
| "OPEN"
|
||||
| "ACCEPTED"
|
||||
| "DISMISSED"
|
||||
| "RESOLVED"
|
||||
| "EXPIRED"
|
||||
| "OBSOLETE";
|
||||
|
||||
/** PLAN: „Übernehmen“ plans a task. DO: „Erfassen“ opens the form. */
|
||||
export type SuggestionMode = "PLAN" | "DO";
|
||||
|
||||
export type SuggestionSource = "RULE" | "CHAIN" | "AUTOPILOT";
|
||||
|
||||
export interface SuggestionPlanStep {
|
||||
step: string;
|
||||
offsetDays: number;
|
||||
windowDays?: number;
|
||||
}
|
||||
|
||||
/** Mirror of the API's SuggestionView (ApiCore-Backend/src/suggestions). */
|
||||
export interface Suggestion {
|
||||
id: string;
|
||||
ruleKey: string;
|
||||
source: SuggestionSource;
|
||||
mode: SuggestionMode;
|
||||
intent: SuggestionIntent;
|
||||
status: SuggestionStatus;
|
||||
priority: TaskPriority;
|
||||
relatedType: TaskRelatedType;
|
||||
relatedId: string | null;
|
||||
colonyId: string | null;
|
||||
colonyCode: string | null;
|
||||
apiaryId: string | null;
|
||||
apiaryName: string | null;
|
||||
title: string;
|
||||
reasons: string[];
|
||||
/** YYYY-MM-DD */
|
||||
dueDate: string | null;
|
||||
/** YYYY-MM-DD */
|
||||
expiresAt: string | null;
|
||||
plan: SuggestionPlanStep[] | null;
|
||||
decidedAt: string | null;
|
||||
decisionNote: string | null;
|
||||
createdAt: string;
|
||||
/** Only for CLEANUP suggestions. */
|
||||
cleanup: { taskIds: string[]; queenId: string | null } | null;
|
||||
}
|
||||
|
||||
export interface SuggestionSummary {
|
||||
open: number;
|
||||
byPriority: Record<TaskPriority, number>;
|
||||
}
|
||||
|
||||
export interface SuggestionQuery {
|
||||
status?: "OPEN" | "DISMISSED";
|
||||
colonyId?: string;
|
||||
apiaryId?: string;
|
||||
/** Rows per call (1 to 500, the API default is 200). */
|
||||
limit?: number;
|
||||
/** Skips this many rows of the sorted list (paging). */
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface AcceptSuggestionResult {
|
||||
suggestion: Suggestion;
|
||||
tasks: Task[];
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchSuggestionSummary, suggestionSummaryKey } from "./api";
|
||||
|
||||
const REFRESH_MS = 60_000;
|
||||
|
||||
/** The open count for the navigation; refreshed like the notification bell. */
|
||||
export function useSuggestionSummary() {
|
||||
return useQuery({
|
||||
queryKey: suggestionSummaryKey,
|
||||
queryFn: fetchSuggestionSummary,
|
||||
refetchInterval: REFRESH_MS,
|
||||
});
|
||||
}
|
||||
@@ -25,6 +25,9 @@ export interface Task {
|
||||
// Set when an automation rule suggested the task (not a person) - e.g.
|
||||
// "STOCK_ITEM_LOW". Shown as a suggestion, never as work already done.
|
||||
sourceAutomationTrigger: string | null;
|
||||
// Set when the task was planned from an accepted suggestion. Optional: older
|
||||
// cached tasks lack the field.
|
||||
sourceSuggestionId?: string | null;
|
||||
createdAt: string;
|
||||
completedAt: string | null;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { apiFetch } from "@/lib/auth/api-client";
|
||||
import { CreateOptions, idempotencyHeaders } from "@/lib/offline/idempotency";
|
||||
import type { VarroaBands, VarroaThresholdSegments } from "./bands";
|
||||
import {
|
||||
CreateVarroaMeasurementInput,
|
||||
UpdateVarroaMeasurementInput,
|
||||
@@ -31,6 +32,19 @@ export function updateVarroaMeasurement(
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchVarroaThreshold(): Promise<{ thresholdMitesPerDay: number | null }> {
|
||||
return apiFetch<{ thresholdMitesPerDay: number | null }>("/varroa-measurements/threshold");
|
||||
/** The bands in force on a day (for a colony's type); `date` is YYYY-MM-DD. */
|
||||
export function fetchVarroaThresholds(colonyId: string | null, date: string): Promise<VarroaBands> {
|
||||
const query = new URLSearchParams({ date });
|
||||
if (colonyId) query.set("colonyId", colonyId);
|
||||
return apiFetch<VarroaBands>(`/varroa-measurements/thresholds?${query.toString()}`);
|
||||
}
|
||||
|
||||
/** The bands over a range of days (YYYY-MM-DD, at most 731 days) in one request. */
|
||||
export function fetchVarroaThresholdRange(
|
||||
colonyId: string,
|
||||
from: string,
|
||||
to: string,
|
||||
): Promise<VarroaThresholdSegments> {
|
||||
const query = new URLSearchParams({ colonyId, from, to });
|
||||
return apiFetch<VarroaThresholdSegments>(`/varroa-measurements/thresholds?${query.toString()}`);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
SAMPLE_BEES_ERROR,
|
||||
infestationPercent,
|
||||
liveNote,
|
||||
ratingNote,
|
||||
sampleBeesError,
|
||||
segmentAt,
|
||||
varroaLight,
|
||||
type VarroaBand,
|
||||
type VarroaBands,
|
||||
} from "./bands";
|
||||
|
||||
const NATURAL: VarroaBand = {
|
||||
method: "NATURAL_MITE_FALL",
|
||||
unit: "MITES_PER_DAY",
|
||||
yellowFrom: 5,
|
||||
redAbove: 10,
|
||||
reason: "NATURAL_FALL_SEASON",
|
||||
youngColony: false,
|
||||
};
|
||||
const SUGAR: VarroaBand = {
|
||||
method: "POWDERED_SUGAR",
|
||||
unit: "PERCENT",
|
||||
yellowFrom: 2,
|
||||
redAbove: 5,
|
||||
reason: "INFESTATION_AUTUMN",
|
||||
youngColony: false,
|
||||
};
|
||||
const AUGUST: VarroaBands = {
|
||||
day: "2026-08-10",
|
||||
bands: { NATURAL_MITE_FALL: NATURAL, POWDERED_SUGAR: SUGAR },
|
||||
postTreatmentUntil: null,
|
||||
active: true,
|
||||
};
|
||||
|
||||
describe("varroaLight", () => {
|
||||
it("10,0/Tag in August is yellow, 10,1 red", () => {
|
||||
expect(varroaLight(10, NATURAL)).toBe("yellow");
|
||||
expect(varroaLight(10.1, NATURAL)).toBe("red");
|
||||
expect(varroaLight(4.9, NATURAL)).toBe("green");
|
||||
expect(varroaLight(5, NATURAL)).toBe("yellow");
|
||||
});
|
||||
|
||||
it("has no yellow stage without yellowFrom", () => {
|
||||
const winter = { ...NATURAL, yellowFrom: null, redAbove: 0.5 };
|
||||
expect(varroaLight(0.5, winter)).toBe("green");
|
||||
expect(varroaLight(0.6, winter)).toBe("red");
|
||||
});
|
||||
|
||||
it("has no light without a value or band", () => {
|
||||
expect(varroaLight(null, NATURAL)).toBeNull();
|
||||
expect(varroaLight(3, null)).toBeNull();
|
||||
expect(varroaLight(3, undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("infestationPercent", () => {
|
||||
it("is mites per sample in percent, rounded to two decimals", () => {
|
||||
expect(infestationPercent(12, 500)).toBe(2.4);
|
||||
expect(infestationPercent(1, 300)).toBe(0.33);
|
||||
expect(infestationPercent(null, 500)).toBeNull();
|
||||
expect(infestationPercent(12, null)).toBeNull();
|
||||
expect(infestationPercent(12, 0)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("liveNote", () => {
|
||||
it("names the light with word and limit", () => {
|
||||
expect(liveNote("NATURAL_MITE_FALL", 12.4, AUGUST)).toEqual({
|
||||
kind: "red",
|
||||
text: "Kritisch (Grenze 10, LWG)",
|
||||
});
|
||||
expect(liveNote("POWDERED_SUGAR", 2.4, AUGUST)).toEqual({
|
||||
kind: "yellow",
|
||||
text: "Achtung (Grenze 5 %, LWG)",
|
||||
});
|
||||
expect(liveNote("NATURAL_MITE_FALL", 1, AUGUST)?.text).toMatch(/^Gut/);
|
||||
});
|
||||
|
||||
it("powdered sugar in April has no light", () => {
|
||||
const april: VarroaBands = { ...AUGUST, day: "2026-04-10", bands: { NATURAL_MITE_FALL: NATURAL } };
|
||||
expect(liveNote("POWDERED_SUGAR", 8, april)).toBeNull();
|
||||
});
|
||||
|
||||
it("shows „Behandlungseffekt – Nachkontrolle ab 16.08.“ inside the post-treatment window", () => {
|
||||
const effect = { ...AUGUST, postTreatmentUntil: "2026-08-16" };
|
||||
expect(liveNote("NATURAL_MITE_FALL", 12, effect)).toEqual({
|
||||
kind: "effect",
|
||||
text: "Behandlungseffekt – Nachkontrolle ab 16.08.",
|
||||
});
|
||||
expect(liveNote("DRONE_BROOD_CONTROL", 12, effect)).toBeNull();
|
||||
});
|
||||
|
||||
it("has no note without bands", () => {
|
||||
expect(liveNote("NATURAL_MITE_FALL", 12, null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ratingNote", () => {
|
||||
it("renders a stored rating", () => {
|
||||
expect(
|
||||
ratingNote({ light: "green", reasonCode: "NATURAL_FALL_SEASON", band: NATURAL, postTreatmentUntil: null }),
|
||||
).toEqual({ kind: "green", text: "Gut (Grenze 10, LWG)" });
|
||||
expect(
|
||||
ratingNote({ light: "POST_TREATMENT", reasonCode: "POST_TREATMENT", band: null, postTreatmentUntil: "2026-08-16" })
|
||||
?.text,
|
||||
).toBe("Behandlungseffekt – Nachkontrolle ab 16.08.");
|
||||
expect(ratingNote({ light: null, reasonCode: null, band: null, postTreatmentUntil: null })).toBeNull();
|
||||
expect(ratingNote(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("segmentAt", () => {
|
||||
const segments = [
|
||||
{ from: "2026-10-01", to: "2026-10-31", bands: {}, postTreatmentUntil: null, active: true },
|
||||
{ from: "2026-11-01", to: "2026-11-30", bands: {}, postTreatmentUntil: null, active: true },
|
||||
];
|
||||
|
||||
it("finds the segment at its first and last day", () => {
|
||||
expect(segmentAt(segments, "2026-10-01")).toBe(segments[0]);
|
||||
expect(segmentAt(segments, "2026-10-31")).toBe(segments[0]);
|
||||
expect(segmentAt(segments, "2026-11-01")).toBe(segments[1]);
|
||||
expect(segmentAt(segments, "2026-11-30")).toBe(segments[1]);
|
||||
});
|
||||
|
||||
it("returns null outside the covered range", () => {
|
||||
expect(segmentAt(segments, "2026-09-30")).toBeNull();
|
||||
expect(segmentAt(segments, "2026-12-01")).toBeNull();
|
||||
expect(segmentAt([], "2026-10-01")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sampleBeesError", () => {
|
||||
it("accepts an empty field and whole numbers from 1 to 2000", () => {
|
||||
for (const ok of ["", " ", "1", "500", "2000"]) expect(sampleBeesError(ok)).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses everything else with one sentence", () => {
|
||||
for (const bad of ["0", "2001", "-5", "1.5", "abc", "1e3"]) {
|
||||
expect(sampleBeesError(bad)).toBe(SAMPLE_BEES_ERROR);
|
||||
}
|
||||
expect(SAMPLE_BEES_ERROR).toBe("Bitte 1 bis 2000 Bienen angeben.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("infestationPercent out of range", () => {
|
||||
it("has no value for a sample outside 1 to 2000 or with a fraction", () => {
|
||||
expect(infestationPercent(12, 2001)).toBeNull();
|
||||
expect(infestationPercent(12, 1.5)).toBeNull();
|
||||
expect(infestationPercent(12, 2000)).toBe(0.6);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import type { VarroaMethod } from "./types";
|
||||
import { formatPercent, formatThreshold } from "./format";
|
||||
|
||||
export type VarroaLight = "green" | "yellow" | "red";
|
||||
|
||||
export interface VarroaBand {
|
||||
method: VarroaMethod;
|
||||
unit: "MITES_PER_DAY" | "PERCENT";
|
||||
/** null: no yellow stage (winter). */
|
||||
yellowFrom: number | null;
|
||||
redAbove: number;
|
||||
/** Reason code of the band (season, winter, summer, autumn). */
|
||||
reason: string;
|
||||
youngColony: boolean;
|
||||
}
|
||||
|
||||
export interface VarroaBands {
|
||||
day: string;
|
||||
bands: Partial<Record<VarroaMethod, VarroaBand>>;
|
||||
/** Recheck day while a treatment effect is in force, otherwise null. */
|
||||
postTreatmentUntil: string | null;
|
||||
/** false while the rule is switched off. */
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
/** The bands for a stretch of days; consecutive segments cover a range without gaps. */
|
||||
export interface VarroaThresholdSegment {
|
||||
/** YYYY-MM-DD, inclusive. */
|
||||
from: string;
|
||||
/** YYYY-MM-DD, inclusive. */
|
||||
to: string;
|
||||
bands: VarroaBands["bands"];
|
||||
postTreatmentUntil: string | null;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface VarroaThresholdSegments {
|
||||
segments: VarroaThresholdSegment[];
|
||||
}
|
||||
|
||||
/** The segment that holds `day` (YYYY-MM-DD), inclusive at both ends; null outside. */
|
||||
export function segmentAt(
|
||||
segments: readonly VarroaThresholdSegment[],
|
||||
day: string,
|
||||
): VarroaThresholdSegment | null {
|
||||
return segments.find((segment) => segment.from <= day && day <= segment.to) ?? null;
|
||||
}
|
||||
|
||||
/** The rating the backend stores with every measurement of a colony. */
|
||||
export interface VarroaRating {
|
||||
light: VarroaLight | "POST_TREATMENT" | null;
|
||||
reasonCode: string | null;
|
||||
band: VarroaBand | null;
|
||||
postTreatmentUntil: string | null;
|
||||
}
|
||||
|
||||
/** What the visible note is: a light or the treatment effect. */
|
||||
export interface VarroaNote {
|
||||
kind: VarroaLight | "effect";
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** Chip „500 Bienen (≈ 50 g)“; never preselected. */
|
||||
export const SAMPLE_BEES_CHOICE = 500;
|
||||
|
||||
const MIN_SAMPLE_BEES = 1;
|
||||
const MAX_SAMPLE_BEES = 2000;
|
||||
export const SAMPLE_BEES_ERROR = "Bitte 1 bis 2000 Bienen angeben.";
|
||||
|
||||
const LIGHT_WORDS: Record<VarroaLight, string> = {
|
||||
green: "Gut",
|
||||
yellow: "Achtung",
|
||||
red: "Kritisch",
|
||||
};
|
||||
|
||||
const SAMPLE_METHODS: readonly VarroaMethod[] = ["POWDERED_SUGAR", "WASHING_METHOD"];
|
||||
|
||||
/** Only powdered sugar and washing count a sample of bees. */
|
||||
export function usesSample(method: VarroaMethod): boolean {
|
||||
return SAMPLE_METHODS.includes(method);
|
||||
}
|
||||
|
||||
function isValidSample(value: number): boolean {
|
||||
return Number.isInteger(value) && value >= MIN_SAMPLE_BEES && value <= MAX_SAMPLE_BEES;
|
||||
}
|
||||
|
||||
/** The message for a typed sample size; an empty field is fine (the sample is optional). */
|
||||
export function sampleBeesError(text: string): string | null {
|
||||
if (text.trim() === "") return null;
|
||||
return /^\d+$/.test(text.trim()) && isValidSample(Number(text)) ? null : SAMPLE_BEES_ERROR;
|
||||
}
|
||||
|
||||
function roundTwo(value: number): number {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
/** Red above `redAbove`, yellow from `yellowFrom`, otherwise green. */
|
||||
export function varroaLight(
|
||||
value: number | null,
|
||||
band: VarroaBand | null | undefined,
|
||||
): VarroaLight | null {
|
||||
if (value === null || !band) return null;
|
||||
if (value > band.redAbove) return "red";
|
||||
if (band.yellowFrom !== null && value >= band.yellowFrom) return "yellow";
|
||||
return "green";
|
||||
}
|
||||
|
||||
/** Mites per sample in percent, rounded to two decimals as the backend stores it. */
|
||||
export function infestationPercent(
|
||||
miteCount: number | null,
|
||||
sampleBees: number | null,
|
||||
): number | null {
|
||||
if (miteCount === null || sampleBees === null) return null;
|
||||
if (!Number.isFinite(miteCount) || !isValidSample(sampleBees)) return null;
|
||||
return roundTwo((miteCount / sampleBees) * 100);
|
||||
}
|
||||
|
||||
function limitText(band: VarroaBand): string {
|
||||
const limit = formatThreshold(band.redAbove);
|
||||
return band.unit === "PERCENT" ? `Grenze ${limit} %, LWG` : `Grenze ${limit}, LWG`;
|
||||
}
|
||||
|
||||
/** "Behandlungseffekt – Nachkontrolle ab 16.08." from "2026-08-16". */
|
||||
export function postTreatmentText(until: string): string {
|
||||
return `Behandlungseffekt – Nachkontrolle ab ${until.slice(8, 10)}.${until.slice(5, 7)}.`;
|
||||
}
|
||||
|
||||
function lightNote(light: VarroaLight, band: VarroaBand): VarroaNote {
|
||||
return { kind: light, text: `${LIGHT_WORDS[light]} (${limitText(band)})` };
|
||||
}
|
||||
|
||||
/** The note of a stored measurement; null without a light. */
|
||||
export function ratingNote(rating: VarroaRating | null | undefined): VarroaNote | null {
|
||||
if (!rating?.light) return null;
|
||||
if (rating.light === "POST_TREATMENT") {
|
||||
return rating.postTreatmentUntil
|
||||
? { kind: "effect", text: postTreatmentText(rating.postTreatmentUntil) }
|
||||
: null;
|
||||
}
|
||||
return rating.band ? lightNote(rating.light, rating.band) : null;
|
||||
}
|
||||
|
||||
/** The note for a value being typed, against the bands of the chosen day. */
|
||||
export function liveNote(
|
||||
method: VarroaMethod,
|
||||
value: number | null,
|
||||
bands: VarroaBands | null | undefined,
|
||||
): VarroaNote | null {
|
||||
if (!bands || method === "DRONE_BROOD_CONTROL") return null;
|
||||
if (bands.postTreatmentUntil !== null) {
|
||||
return { kind: "effect", text: postTreatmentText(bands.postTreatmentUntil) };
|
||||
}
|
||||
const band = bands.bands[method];
|
||||
const light = varroaLight(value, band);
|
||||
return light && band ? lightNote(light, band) : null;
|
||||
}
|
||||
|
||||
/** "12,4 Milben/Tag" or "2,4 %"; other methods show the bare number. */
|
||||
export function valueText(
|
||||
method: VarroaMethod,
|
||||
value: number,
|
||||
formatRate: (rate: number) => string,
|
||||
): string {
|
||||
if (method === "NATURAL_MITE_FALL") return `${formatRate(value)} Milben/Tag`;
|
||||
if (usesSample(method)) return `${formatPercent(value)} %`;
|
||||
return formatRate(value);
|
||||
}
|
||||
@@ -1,15 +1,22 @@
|
||||
const ONE_DECIMAL = new Intl.NumberFormat("de-DE", {
|
||||
// The backend stores two decimals; show them when they matter (10,04 is red).
|
||||
const RATE_FORMAT = new Intl.NumberFormat("de-DE", {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
const THRESHOLD_FORMAT = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 1 });
|
||||
const THRESHOLD_FORMAT = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 2 });
|
||||
const PERCENT_FORMAT = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 2 });
|
||||
|
||||
/** "10,0" */
|
||||
/** "10,0" or "10,04" */
|
||||
export function formatMitesPerDay(rate: number): string {
|
||||
return ONE_DECIMAL.format(rate);
|
||||
return RATE_FORMAT.format(rate);
|
||||
}
|
||||
|
||||
/** "10" or "7,5" */
|
||||
/** "10" or "7,5" or "0,25" */
|
||||
export function formatThreshold(threshold: number): string {
|
||||
return THRESHOLD_FORMAT.format(threshold);
|
||||
}
|
||||
|
||||
/** "2,4" (without the percent sign) */
|
||||
export function formatPercent(percent: number): string {
|
||||
return PERCENT_FORMAT.format(percent);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mitesPerDay, varroaLight } from "./rate";
|
||||
import { mitesPerDay } from "./rate";
|
||||
|
||||
describe("varroa rate", () => {
|
||||
it("computes mites per day like the backend", () => {
|
||||
@@ -9,11 +9,7 @@ describe("varroa rate", () => {
|
||||
expect(mitesPerDay(null, 4320)).toBeNull();
|
||||
});
|
||||
|
||||
it("derives the traffic light from the threshold", () => {
|
||||
expect(varroaLight(10, 10)).toBe("red");
|
||||
expect(varroaLight(5, 10)).toBe("yellow");
|
||||
expect(varroaLight(4.9, 10)).toBe("green");
|
||||
expect(varroaLight(10, null)).toBeNull();
|
||||
expect(varroaLight(null, 10)).toBeNull();
|
||||
it("rounds to two decimals as the backend stores it", () => {
|
||||
expect(mitesPerDay(7203, 720 * 1440)).toBe(10);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,6 @@
|
||||
import { MINUTES_PER_DAY } from "./period";
|
||||
|
||||
/** Share of the threshold from which the light turns yellow. */
|
||||
export const VARROA_WARN_FRACTION = 0.5;
|
||||
|
||||
export type VarroaLight = "green" | "yellow" | "red";
|
||||
|
||||
/** Natural mite fall as mites per day; same formula as the backend. */
|
||||
/** Natural mite fall as mites per day, rounded to two decimals like the stored value. */
|
||||
export function mitesPerDay(
|
||||
miteCount: number | null,
|
||||
durationMinutes: number | null,
|
||||
@@ -13,12 +8,5 @@ export function mitesPerDay(
|
||||
if (miteCount === null || durationMinutes === null) return null;
|
||||
if (!Number.isFinite(miteCount) || !Number.isFinite(durationMinutes)) return null;
|
||||
if (durationMinutes <= 0) return null;
|
||||
return (miteCount / durationMinutes) * MINUTES_PER_DAY;
|
||||
}
|
||||
|
||||
export function varroaLight(rate: number | null, threshold: number | null): VarroaLight | null {
|
||||
if (rate === null || threshold === null) return null;
|
||||
if (rate >= threshold) return "red";
|
||||
if (rate >= threshold * VARROA_WARN_FRACTION) return "yellow";
|
||||
return "green";
|
||||
return Math.round((miteCount / durationMinutes) * MINUTES_PER_DAY * 100) / 100;
|
||||
}
|
||||
@@ -4,6 +4,8 @@ export type VarroaMethod =
|
||||
| "WASHING_METHOD"
|
||||
| "DRONE_BROOD_CONTROL";
|
||||
|
||||
import type { VarroaRating } from "./bands";
|
||||
|
||||
export interface VarroaMeasurement {
|
||||
id: string;
|
||||
colonyId: string;
|
||||
@@ -12,10 +14,15 @@ export interface VarroaMeasurement {
|
||||
method: VarroaMethod;
|
||||
durationMinutes: number | null;
|
||||
miteCount: number | null;
|
||||
/** Sample size in bees (powdered sugar, washing). */
|
||||
sampleBees: number | null;
|
||||
/** Mites per day (natural fall) or percent (sample methods). */
|
||||
calculatedValue: number | null;
|
||||
assessment: string | null;
|
||||
notes: string | null;
|
||||
createdAt: string;
|
||||
/** Only on a colony's list; absent on create and update responses. */
|
||||
rating?: VarroaRating | null;
|
||||
}
|
||||
|
||||
/** Optional fields: `null` or omitted both mean "no value". */
|
||||
@@ -25,6 +32,7 @@ export interface CreateVarroaMeasurementInput {
|
||||
method: VarroaMethod;
|
||||
durationMinutes?: number | null;
|
||||
miteCount?: number | null;
|
||||
sampleBees?: number | null;
|
||||
calculatedValue?: number | null;
|
||||
assessment?: string | null;
|
||||
notes?: string | null;
|
||||
@@ -38,6 +46,7 @@ export interface UpdateVarroaMeasurementInput {
|
||||
method?: VarroaMethod;
|
||||
durationMinutes?: number | null;
|
||||
miteCount?: number | null;
|
||||
sampleBees?: number | null;
|
||||
calculatedValue?: number | null;
|
||||
assessment?: string | null;
|
||||
notes?: string | null;
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
import { invalidateVarroaBands, useVarroaBands } from "./use-varroa-bands";
|
||||
import { fetchVarroaThresholds } from "./api";
|
||||
import type { VarroaBands } from "./bands";
|
||||
|
||||
vi.mock("./api", () => ({ fetchVarroaThresholds: vi.fn() }));
|
||||
|
||||
const BANDS: VarroaBands = { day: "2026-08-10", bands: {}, postTreatmentUntil: null, active: true };
|
||||
|
||||
function setup() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||
);
|
||||
return { client, wrapper };
|
||||
}
|
||||
|
||||
describe("useVarroaBands", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchVarroaThresholds).mockReset();
|
||||
});
|
||||
|
||||
it("asks nothing without a colony", () => {
|
||||
const { wrapper } = setup();
|
||||
const { result } = renderHook(() => useVarroaBands(null, "2026-08-10"), { wrapper });
|
||||
expect(result.current).toBeNull();
|
||||
expect(fetchVarroaThresholds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("asks nothing for a day that is not complete", () => {
|
||||
const { wrapper } = setup();
|
||||
renderHook(() => useVarroaBands("c-1", "2026-08"), { wrapper });
|
||||
expect(fetchVarroaThresholds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns the bands of the colony and day", async () => {
|
||||
vi.mocked(fetchVarroaThresholds).mockResolvedValue(BANDS);
|
||||
const { wrapper } = setup();
|
||||
const { result } = renderHook(() => useVarroaBands("c-1", "2026-08-10"), { wrapper });
|
||||
await waitFor(() => expect(result.current).toEqual(BANDS));
|
||||
expect(fetchVarroaThresholds).toHaveBeenCalledWith("c-1", "2026-08-10");
|
||||
});
|
||||
|
||||
it("stays null without a crash when the request fails", async () => {
|
||||
vi.mocked(fetchVarroaThresholds).mockImplementation(async () => {
|
||||
throw new TypeError("Failed to fetch");
|
||||
});
|
||||
const { client, wrapper } = setup();
|
||||
const { result } = renderHook(() => useVarroaBands("c-1", "2026-08-10"), { wrapper });
|
||||
await waitFor(() =>
|
||||
expect(client.getQueryState(["varroa", "thresholds", "c-1", "2026-08-10"])?.status).toBe("error"),
|
||||
);
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalidateVarroaBands", () => {
|
||||
it("marks single-day and range keys stale and leaves other keys alone", async () => {
|
||||
const { client } = setup();
|
||||
client.setQueryData(["varroa", "thresholds", "c-1", "2026-08-10"], BANDS);
|
||||
client.setQueryData(["varroa", "thresholds", "c-1", "2024-08-01", "2026-07-31"], { segments: [] });
|
||||
client.setQueryData(["colonies", "c-1", "treatments"], []);
|
||||
|
||||
await invalidateVarroaBands(client);
|
||||
|
||||
expect(client.getQueryState(["varroa", "thresholds", "c-1", "2026-08-10"])?.isInvalidated).toBe(true);
|
||||
expect(
|
||||
client.getQueryState(["varroa", "thresholds", "c-1", "2024-08-01", "2026-07-31"])?.isInvalidated,
|
||||
).toBe(true);
|
||||
expect(client.getQueryState(["colonies", "c-1", "treatments"])?.isInvalidated).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { QueryClient, useQuery } from "@tanstack/react-query";
|
||||
import type { VarroaBands } from "./bands";
|
||||
import { fetchVarroaThresholds } from "./api";
|
||||
|
||||
const ONE_HOUR_MS = 60 * 60 * 1000;
|
||||
const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
/** The bands for a colony and day; null while unknown, offline, without a colony or a valid day. */
|
||||
export function useVarroaBands(colonyId: string | null, date: string): VarroaBands | null {
|
||||
const query = useQuery({
|
||||
queryKey: ["varroa", "thresholds", colonyId, date],
|
||||
queryFn: () => fetchVarroaThresholds(colonyId, date),
|
||||
enabled: colonyId !== null && DAY_PATTERN.test(date),
|
||||
staleTime: ONE_HOUR_MS,
|
||||
});
|
||||
return query.data ?? null;
|
||||
}
|
||||
|
||||
/** The bands depend on treatments and on the rule: after either changes, every cached band is stale. */
|
||||
export function invalidateVarroaBands(queryClient: QueryClient): Promise<void> {
|
||||
return queryClient.invalidateQueries({ queryKey: ["varroa", "thresholds"] });
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchVarroaThreshold } from "./api";
|
||||
|
||||
const ONE_HOUR_MS = 60 * 60 * 1000;
|
||||
|
||||
/** The configured mites-per-day threshold; null while unknown or when the rule is off. */
|
||||
export function useVarroaThreshold(): number | null {
|
||||
const query = useQuery({
|
||||
queryKey: ["varroa", "threshold"],
|
||||
queryFn: fetchVarroaThreshold,
|
||||
staleTime: ONE_HOUR_MS,
|
||||
});
|
||||
return query.data?.thresholdMitesPerDay ?? null;
|
||||
}
|
||||
Reference in new issue
Block a user