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

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

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 15:22:36 +02:00
1 parent 64a2b7dad9
commit 02eb16924e
142 files changed
+11847 -1251

No files matched your search

@@ -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>
);
}