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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user