feat: T4 wave 6 - reminders and morning digest, next steps UI, warnings and notifications UI

- In-app reminders: task due (skips rescheduled tasks, catch-up wording)
  and a per-user morning digest step in the daily job
- Frontend: "Als Nächstes" on the colony page, "Heute" on the dashboard,
  inspection-due card, season chip, apiary overview fields, cleanup
  sheet for ended colonies
- Frontend: plausibility warnings that stay until dismissed, withdrawal
  spans in the calendar, notification bell with categories and links,
  notification settings in the profile
- ARCHITECTURE.md: T4a sections 11.1-11.10, assumptions 31-41, handler
  table and daily job step order

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 17:05:32 +02:00
1 parent ef33e5549b
commit 32c61c8e14
77 files changed
+5309 -142

No files matched your search

@@ -0,0 +1,236 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { NextStepsCard } from "./next-steps-card";
import { fetchColonyNextSteps } from "@/lib/autopilot/api";
import { colonyStepsFixture, stepFixture } from "@/lib/autopilot/step-fixtures";
import { acceptSuggestion, bulkDismissSuggestions, reopenSuggestion } from "@/lib/suggestions/api";
import { ApiError } from "@/lib/auth/api-client";
import { toast } from "sonner";
vi.mock("@/lib/autopilot/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/autopilot/api")>()),
fetchColonyNextSteps: vi.fn(),
}));
vi.mock("@/lib/suggestions/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/suggestions/api")>()),
acceptSuggestion: vi.fn(),
bulkDismissSuggestions: vi.fn(),
reopenSuggestion: vi.fn(),
}));
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({ hasPermission: () => true }),
}));
vi.mock("@/components/suggestions/cleanup-sheet", () => ({
CleanupSheet: ({ suggestionId }: { suggestionId: string | null }) =>
suggestionId ? <p>Aufräumen-Sheet {suggestionId}</p> : null,
}));
vi.mock("sonner", () => ({
toast: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn() }),
}));
function renderCard() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<NextStepsCard colonyId="c1" />
</QueryClientProvider>,
);
}
const TASK_STEP = stepFixture({
key: "task:t2",
kind: "TASK",
title: "Zwei",
reasons: [],
suggestionIds: [],
action: { type: "OPEN_TASK", taskId: "t2", href: "/tasks" },
});
describe("NextStepsCard", () => {
beforeEach(() => {
vi.mocked(fetchColonyNextSteps).mockReset();
vi.mocked(acceptSuggestion).mockReset();
vi.mocked(bulkDismissSuggestions).mockReset();
});
it("NextStepsCard shows at most 3 steps with reasons and links CAPTURE to the form with colonyId", async () => {
vi.mocked(fetchColonyNextSteps).mockResolvedValue(
colonyStepsFixture({
steps: [
stepFixture({ key: "suggestion:s1", title: "Durchsicht" }),
TASK_STEP,
stepFixture({ key: "suggestion:s3", title: "Drei", suggestionIds: ["s3"] }),
stepFixture({ key: "suggestion:s4", title: "Vier", suggestionIds: ["s4"] }),
],
}),
);
renderCard();
expect(await screen.findByText("Durchsicht")).toBeInTheDocument();
expect(screen.getAllByRole("heading", { level: 3 })).toHaveLength(3);
expect(screen.queryByText("Vier")).not.toBeInTheDocument();
expect(screen.getAllByText("Letzte Durchsicht vor 8 Tagen.").length).toBeGreaterThan(0);
expect(screen.getAllByText("In der Schwarmzeit alle 7 Tage.").length).toBeGreaterThan(0);
const first = screen.getAllByRole("heading", { level: 3 })[0].closest("li") as HTMLElement;
expect(within(first).getByRole("link", { name: "Erfassen" })).toHaveAttribute(
"href",
"/inspections/new?colonyId=c1",
);
expect(screen.getByRole("link", { name: "Aufgabe öffnen" })).toHaveAttribute("href", "/tasks");
});
it("NextStepsCard shows the withdrawal hint, not a step", async () => {
vi.mocked(fetchColonyNextSteps).mockResolvedValue(
colonyStepsFixture({ steps: [], withdrawalUntil: "2026-05-05" }),
);
renderCard();
expect(await screen.findByText("Wartezeit bis 05.05.")).toBeInTheDocument();
expect(screen.queryAllByRole("listitem")).toHaveLength(0);
});
it("accepts a step with one tap and an idempotency key", async () => {
vi.mocked(fetchColonyNextSteps).mockResolvedValue(
colonyStepsFixture({
steps: [
stepFixture({
intent: "HONEY_SUPER",
title: "Honigraum",
action: { type: "ACCEPT", suggestionId: "s1" },
}),
],
}),
);
vi.mocked(acceptSuggestion).mockResolvedValue({} as never);
renderCard();
await userEvent.click(await screen.findByRole("button", { name: "Übernehmen" }));
expect(acceptSuggestion).toHaveBeenCalledWith("s1", {}, { idempotencyKey: expect.any(String) });
});
it("dismisses a merged step with all its suggestion ids", async () => {
vi.mocked(fetchColonyNextSteps).mockResolvedValue(
colonyStepsFixture({ steps: [stepFixture({ suggestionIds: ["s1", "s2"] })] }),
);
vi.mocked(bulkDismissSuggestions).mockResolvedValue({ dismissed: 2 });
renderCard();
await userEvent.click(await screen.findByRole("button", { name: "Verwerfen" }));
expect(bulkDismissSuggestions).toHaveBeenCalledWith(["s1", "s2"], {
idempotencyKey: expect.any(String),
});
});
it("opens the cleanup sheet for a CLEANUP step", async () => {
vi.mocked(fetchColonyNextSteps).mockResolvedValue(
colonyStepsFixture({
steps: [
stepFixture({
intent: "CLEANUP",
title: "Volk aufräumen",
suggestionIds: ["s9"],
action: { type: "CLEANUP", suggestionId: "s9" },
}),
],
}),
);
renderCard();
await userEvent.click(await screen.findByRole("button", { name: "Aufräumen" }));
expect(screen.getByText("Aufräumen-Sheet s9")).toBeInTheDocument();
});
it("renders nothing while there is nothing to say", async () => {
vi.mocked(fetchColonyNextSteps).mockResolvedValue(colonyStepsFixture({ steps: [] }));
const { container } = renderCard();
await vi.waitFor(() => expect(fetchColonyNextSteps).toHaveBeenCalled());
expect(container).toBeEmptyDOMElement();
});
it("makes only the first step's action primary and the rest outline", async () => {
vi.mocked(fetchColonyNextSteps).mockResolvedValue(
colonyStepsFixture({
steps: [stepFixture({ key: "suggestion:s1" }), stepFixture({ key: "suggestion:s2", title: "Zweite", suggestionIds: ["s2"] })],
}),
);
renderCard();
const links = await screen.findAllByRole("link", { name: "Erfassen" });
expect(links[0]).toHaveClass("bg-primary");
expect(links[1]).not.toHaveClass("bg-primary");
});
it("announces only a short status, not the whole list", async () => {
vi.mocked(fetchColonyNextSteps).mockResolvedValue(colonyStepsFixture());
const { container } = renderCard();
await screen.findByText("Durchsicht");
expect(screen.getByRole("status")).toHaveTextContent("1 nächster Schritt");
expect(container.querySelector("[aria-live]")).toBeNull();
});
it("moves focus to the heading after a decision, or to the page h1 when the section is gone", async () => {
vi.mocked(bulkDismissSuggestions).mockResolvedValue({ dismissed: 1 });
vi.mocked(fetchColonyNextSteps)
.mockResolvedValueOnce(colonyStepsFixture())
.mockResolvedValueOnce(colonyStepsFixture());
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={client}>
<h1>V-01</h1>
<NextStepsCard colonyId="c1" />
</QueryClientProvider>,
);
await userEvent.click(await screen.findByRole("button", { name: "Verwerfen" }));
await vi.waitFor(() =>
expect(screen.getByRole("heading", { name: "Als Nächstes" })).toHaveFocus(),
);
vi.mocked(fetchColonyNextSteps).mockResolvedValue(colonyStepsFixture({ steps: [] }));
await userEvent.click(screen.getByRole("button", { name: "Verwerfen" }));
await vi.waitFor(() => expect(screen.getByRole("heading", { name: "V-01" })).toHaveFocus());
});
it("reports a partly failed undo as „n von m wieder geöffnet“", async () => {
vi.mocked(fetchColonyNextSteps).mockResolvedValue(
colonyStepsFixture({ steps: [stepFixture({ suggestionIds: ["s1", "s2"] })] }),
);
vi.mocked(bulkDismissSuggestions).mockResolvedValue({ dismissed: 2 });
vi.mocked(reopenSuggestion)
.mockResolvedValueOnce({} as never)
.mockRejectedValueOnce(new ApiError(409, "Der Vorschlag ist nicht mehr offen.", {}));
renderCard();
await userEvent.click(await screen.findByRole("button", { name: "Verwerfen" }));
await vi.waitFor(() => expect(toast).toHaveBeenCalledWith("Verworfen", expect.anything()));
const options = vi.mocked(toast).mock.calls.find(([text]) => text === "Verworfen")![1] as unknown as {
action: { onClick: () => void };
};
options.action.onClick();
await vi.waitFor(() => expect(toast.error).toHaveBeenCalledWith("1 von 2 wieder geöffnet"));
});
it("shows a compact hint with retry for a server error and nothing when offline", async () => {
vi.mocked(fetchColonyNextSteps).mockRejectedValue(new ApiError(500, "boom", {}));
const first = renderCard();
expect(await screen.findByRole("status")).toHaveTextContent(
"Die nächsten Schritte konnten nicht geladen werden.",
);
expect(screen.getByRole("button", { name: "Erneut versuchen" })).toBeInTheDocument();
first.unmount();
vi.mocked(fetchColonyNextSteps).mockRejectedValue(new TypeError("Failed to fetch"));
const second = renderCard();
await vi.waitFor(() => expect(fetchColonyNextSteps).toHaveBeenCalledTimes(2));
await vi.waitFor(() => expect(second.container).toBeEmptyDOMElement());
});
});
@@ -0,0 +1,223 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { Check, Eraser, ExternalLink, Hourglass, ListTodo, X } from "lucide-react";
import { CleanupSheet } from "@/components/suggestions/cleanup-sheet";
import { DueChip } from "@/components/task-status";
import { Button, buttonVariants } from "@/components/ui/button";
import { isNetworkError } from "@/lib/http/fetch-with-timeout";
import { colonyNextStepsKey, fetchColonyNextSteps } from "@/lib/autopilot/api";
import { dueChipProps, formatDayMonth } from "@/lib/autopilot/format";
import type { NextStep } from "@/lib/autopilot/types";
import { useAuth } from "@/lib/auth/auth-context";
import { todayLocalIsoDate } from "@/lib/dates";
import { INTENT_ICONS } from "@/lib/suggestions/labels";
import { useStepActions } from "./use-step-actions";
/** The colony page shows at most this many steps. */
const MAX_STEPS = 3;
const NEXT_STEPS_HEADING_ID = "colony-next-steps-heading";
interface StepActionsProps {
step: NextStep;
/** The one main button of the view; every other action is an outline button. */
primary?: boolean;
/** Offline the task list is not available: no link to it. */
hideTaskLink?: boolean;
busy?: boolean;
/** tasks:create - needed for „Übernehmen“. */
canAccept?: boolean;
/** tasks:update - needed for „Verwerfen“ and „Aufräumen“. */
canDismiss?: boolean;
onAccept?: (step: NextStep) => void;
onDismiss?: (step: NextStep) => void;
onCleanup?: (step: NextStep) => void;
}
/** The main action of a step (a real link where it navigates) plus „Verwerfen“. */
export function StepActions({
step,
primary = true,
hideTaskLink = false,
busy = false,
canAccept = true,
canDismiss = true,
onAccept,
onDismiss,
onCleanup,
}: StepActionsProps) {
const { action } = step;
const variant = primary ? "default" : "outline";
return (
<div className="flex flex-wrap items-center gap-2">
{action.type === "CAPTURE" ? (
<Link href={action.href} className={buttonVariants({ size: "touch", variant })}>
<Check aria-hidden />
Erfassen
</Link>
) : null}
{action.type === "OPEN_TASK" && !hideTaskLink ? (
<Link href={action.href} className={buttonVariants({ size: "touch", variant: "outline" })}>
<ExternalLink aria-hidden />
Aufgabe öffnen
</Link>
) : null}
{action.type === "ACCEPT" && onAccept && canAccept ? (
<Button size="touch" variant={variant} disabled={busy} onClick={() => onAccept(step)}>
<Check aria-hidden />
Übernehmen
</Button>
) : null}
{action.type === "CLEANUP" && onCleanup && canDismiss ? (
<Button size="touch" variant={variant} disabled={busy} onClick={() => onCleanup(step)}>
<Eraser aria-hidden />
Aufräumen
</Button>
) : null}
{step.dismissible && onDismiss && canDismiss ? (
<Button size="touch" variant="ghost" disabled={busy} onClick={() => onDismiss(step)}>
<X aria-hidden />
Verwerfen
</Button>
) : null}
</div>
);
}
interface NextStepsViewProps extends Omit<StepActionsProps, "step" | "busy" | "primary"> {
steps: readonly NextStep[];
/** YYYY-MM-DD; a hint („Wartezeit bis 05.05.“), never a step. */
withdrawalUntil: string | null;
busyKeys?: ReadonlySet<string>;
}
/** The steps as a list; without steps and hint nothing is rendered. */
export function NextStepsView({
steps,
withdrawalUntil,
busyKeys,
...actions
}: NextStepsViewProps) {
const [today] = useState(() => todayLocalIsoDate());
const shown = steps.slice(0, MAX_STEPS);
if (shown.length === 0 && !withdrawalUntil) return null;
return (
<section
aria-labelledby={NEXT_STEPS_HEADING_ID}
className="flex flex-col gap-3 rounded-2xl border border-primary/25 bg-card p-4 shadow-[var(--shadow-soft)] sm:p-5"
>
<h2
id={NEXT_STEPS_HEADING_ID}
tabIndex={-1}
className="font-heading text-base font-semibold outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
>
Als Nächstes
</h2>
{/* Only this short text is announced, not the whole list. */}
<p role="status" className="sr-only">
{shown.length === 1 ? "1 nächster Schritt" : `${shown.length} nächste Schritte`}
</p>
<div className="flex flex-col gap-3">
{shown.length > 0 ? (
<ol className="flex flex-col gap-3">
{shown.map((step, index) => {
const Icon = step.intent ? INTENT_ICONS[step.intent] : ListTodo;
return (
<li
key={step.key}
className="flex flex-col gap-3 rounded-xl border border-border/60 p-3"
>
<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">
<h3 className="font-medium">{step.title}</h3>
{step.reasons.length > 0 ? (
<ul className="flex flex-col gap-0.5 text-sm text-muted-foreground">
{step.reasons.slice(0, 2).map((reason) => (
<li key={reason}>{reason}</li>
))}
</ul>
) : null}
<div>
<DueChip {...dueChipProps(step.dueOn, today)} />
</div>
</div>
</div>
<StepActions
step={step}
primary={index === 0}
busy={busyKeys?.has(step.key)}
{...actions}
/>
</li>
);
})}
</ol>
) : null}
{withdrawalUntil ? (
<p className="flex items-center gap-2 text-sm text-muted-foreground">
<Hourglass className="size-4 shrink-0" aria-hidden />
Wartezeit bis {formatDayMonth(withdrawalUntil)}
</p>
) : null}
</div>
</section>
);
}
/** „Als Nächstes“ on top of the colony page: the colony's next steps from the autopilot. */
export function NextStepsCard({ colonyId }: { colonyId: string }) {
const { hasPermission } = useAuth();
const query = useQuery({
queryKey: colonyNextStepsKey(colonyId),
queryFn: () => fetchColonyNextSteps(colonyId),
});
const stepActions = useStepActions(NEXT_STEPS_HEADING_ID);
if (query.isError) {
// Offline the colony page still works; a missing hint is no news.
if (isNetworkError(query.error)) return null;
return (
<p role="status" className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
Die nächsten Schritte konnten nicht geladen werden.
<Button
type="button"
size="touch"
variant="ghost"
disabled={query.isFetching}
onClick={() => void query.refetch()}
>
Erneut versuchen
</Button>
</p>
);
}
if (!query.data) return null;
return (
<>
<NextStepsView
steps={query.data.steps}
withdrawalUntil={query.data.withdrawalUntil}
busyKeys={stepActions.busyKeys}
canAccept={hasPermission("tasks:create")}
canDismiss={hasPermission("tasks:update")}
onAccept={stepActions.accept}
onDismiss={stepActions.dismiss}
onCleanup={(step) => stepActions.openCleanup(step, colonyId)}
/>
<CleanupSheet
suggestionId={stepActions.cleanupTarget?.suggestionId ?? null}
colonyId={stepActions.cleanupTarget?.colonyId ?? null}
onClose={stepActions.closeCleanup}
onDone={() => void stepActions.afterCleanup()}
/>
</>
);
}
@@ -0,0 +1,41 @@
import { CalendarRange } from "lucide-react";
import type { SeasonSegment } from "@/lib/autopilot/types";
export const SEGMENT_LABELS: Record<SeasonSegment, string> = {
WINTERRUHE: "Winterruhe",
FRUEHJAHR: "Frühjahr",
SCHWARMZEIT: "Schwarmzeit",
SOMMER: "Sommer",
SPAETSOMMER: "Spätsommer",
HERBST: "Herbst",
};
/** „Schwarmzeit · alle 7 Tage“; without an interval just the segment. */
export function seasonChipText(season: {
segment: SeasonSegment;
intervalDays: number | null;
}): string {
const label = SEGMENT_LABELS[season.segment];
if (season.intervalDays === null) return label;
return season.intervalDays === 1
? `${label} · täglich`
: `${label} · alle ${season.intervalDays} Tage`;
}
interface SeasonChipProps {
segment: SeasonSegment;
intervalDays?: number | null;
/** Names the apiary: „Obstwiese: Schwarmzeit“. */
apiaryName?: string;
}
/** The season of an apiary as icon plus words. */
export function SeasonChip({ segment, intervalDays = null, apiaryName }: SeasonChipProps) {
const text = seasonChipText({ segment, intervalDays });
return (
<span className="inline-flex min-h-6 items-center gap-1 rounded-full bg-muted px-2.5 py-0.5 text-xs font-medium text-foreground">
<CalendarRange className="size-3.5 shrink-0" aria-hidden />
{apiaryName ? `${apiaryName}: ${text}` : text}
</span>
);
}
@@ -0,0 +1,153 @@
"use client";
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { newIdempotencyKey } from "@/lib/offline/idempotency";
import type { NextStep } from "@/lib/autopilot/types";
import {
acceptSuggestion,
bulkDismissSuggestions,
reopenSuggestion,
} from "@/lib/suggestions/api";
import { suggestionErrorText } from "@/lib/suggestions/errors";
/** 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!";
const UNDO_TOAST_MS = 8000;
/** Everything a decision can change on screen. */
const REFRESHED_KEYS = [
["colonies"],
["apiaries"],
["suggestions"],
["tasks"],
["dashboard"],
["autopilot"],
] as const;
/**
* After a decision focus goes to the section heading, or to the page's h1
* when the section is gone - never onto the removed button.
*/
function focusHeadingOrPage(headingId: string) {
requestAnimationFrame(() => {
const target = document.getElementById(headingId) ?? document.querySelector("h1");
if (!(target instanceof HTMLElement)) return;
if (!target.hasAttribute("tabindex")) target.setAttribute("tabindex", "-1");
target.focus();
});
}
class PartialReopenError extends Error {
constructor(
readonly done: number,
readonly total: number,
readonly reason: unknown,
) {
super("Not all suggestions were reopened");
}
}
export interface CleanupTarget {
suggestionId: string;
colonyId: string | null;
}
/**
* Accept, dismiss and clean up the steps of the autopilot - shared by the
* colony page and the start page. Every decision (also a failed one: a 409
* means the list is stale) reloads what it may have changed.
*/
export function useStepActions(headingId: string) {
const queryClient = useQueryClient();
const [busyKeys, setBusyKeys] = useState<ReadonlySet<string>>(new Set());
const [cleanupTarget, setCleanupTarget] = useState<CleanupTarget | null>(null);
function setBusy(key: string, busy: boolean) {
setBusyKeys((current) => {
const next = new Set(current);
if (busy) next.add(key);
else next.delete(key);
return next;
});
}
async function refresh() {
await Promise.all(
REFRESHED_KEYS.map((queryKey) => queryClient.invalidateQueries({ queryKey })),
);
}
async function settle() {
await refresh();
focusHeadingOrPage(headingId);
}
const reopen = useMutation({
mutationFn: async (ids: string[]) => {
const results = await Promise.allSettled(
ids.map((id) => reopenSuggestion(id, { idempotencyKey: newIdempotencyKey() })),
);
const failed = results.find((result) => result.status === "rejected");
if (failed) {
throw new PartialReopenError(
results.filter((result) => result.status === "fulfilled").length,
results.length,
failed.reason,
);
}
},
onSuccess: () => toast("Wieder geöffnet"),
onError: (error) => {
if (error instanceof PartialReopenError && error.done > 0) {
toast.error(`${error.done} von ${error.total} wieder geöffnet`);
} else {
toast.error(suggestionErrorText(error instanceof PartialReopenError ? error.reason : error));
}
},
onSettled: settle,
});
const accept = useMutation({
mutationFn: (step: NextStep) => {
if (step.action.type !== "ACCEPT") throw new Error("Not an ACCEPT step");
return acceptSuggestion(step.action.suggestionId, {}, { idempotencyKey: newIdempotencyKey() });
},
onMutate: (step) => setBusy(step.key, true),
onSuccess: () => toast.success("Aufgabe angelegt"),
onError: (error) => toast.error(suggestionErrorText(error)),
onSettled: async (_result, _error, step) => {
await settle();
setBusy(step.key, false);
},
});
const dismiss = useMutation({
mutationFn: (step: NextStep) =>
bulkDismissSuggestions(step.suggestionIds, { idempotencyKey: newIdempotencyKey() }),
onMutate: (step) => setBusy(step.key, true),
onSuccess: (_result, step) =>
toast("Verworfen", {
duration: UNDO_TOAST_MS,
action: { label: "Rückgängig", onClick: () => reopen.mutate(step.suggestionIds) },
classNames: { actionButton: TOAST_ACTION_CLASS },
}),
onError: (error) => toast.error(suggestionErrorText(error)),
onSettled: async (_result, _error, step) => {
await settle();
setBusy(step.key, false);
},
});
return {
busyKeys,
accept: (step: NextStep) => accept.mutate(step),
dismiss: (step: NextStep) => dismiss.mutate(step),
openCleanup: (step: NextStep, colonyId: string | null) => {
if (step.action.type === "CLEANUP") {
setCleanupTarget({ suggestionId: step.action.suggestionId, colonyId });
}
},
closeCleanup: () => setCleanupTarget(null),
cleanupTarget,
afterCleanup: settle,
};
}
@@ -181,6 +181,27 @@ describe("ChoiceGroup", () => {
});
});
describe("ChoiceGroup – disabled options", () => {
const OPTIONS: ChoiceOption<string>[] = [
{ value: "a", label: "A" },
{ value: "b", label: "B", disabled: true },
{ value: "c", label: "C" },
];
it("cannot be chosen by tap and is skipped by the arrow keys", async () => {
const onChange = vi.fn();
render(<ChoiceGroup options={OPTIONS} value="a" onChange={onChange} label="Wahl" />);
expect(screen.getByRole("radio", { name: "B" })).toBeDisabled();
await userEvent.click(screen.getByRole("radio", { name: "B" }));
expect(onChange).not.toHaveBeenCalled();
screen.getByRole("radio", { name: "A" }).focus();
await userEvent.keyboard("{ArrowRight}");
expect(onChange).toHaveBeenCalledWith("c");
});
});
describe("choiceOptions", () => {
const MOOD_LABELS = { NONE: "Keine", LOW: "Leicht", HIGH: "Stark" } as const;
@@ -17,6 +17,8 @@ export interface ChoiceOption<T extends ChoiceValue> {
srSuffix?: string;
/** Replaces the default selected look, e.g. a tone colour of a rating step. */
selectedClassName?: string;
/** Shown but not choosable; arrow keys skip it. */
disabled?: boolean;
}
/** Distinct per value and type: "null" (text) and null must not collide. */
@@ -92,9 +94,15 @@ export function ChoiceGroup<T extends ChoiceValue>({
const tabStop = checkedIndex === -1 ? 0 : checkedIndex;
function handleKeyDown(event: KeyboardEvent<HTMLButtonElement>, index: number) {
const next = targetIndex(event.key, index, options.length);
let next = targetIndex(event.key, index, options.length);
if (next === null) return;
event.preventDefault();
// Skip disabled options in the direction of travel (at most one lap).
const step = event.key === "Home" ? 1 : event.key === "End" ? -1 : (KEY_STEPS[event.key] ?? 1);
for (let tries = 0; options[next].disabled && tries < options.length; tries++) {
next = (next + step + options.length) % options.length;
}
if (options[next].disabled) return;
radioRefs.current[next]?.focus();
onChange(options[next].value);
}
@@ -120,6 +128,7 @@ export function ChoiceGroup<T extends ChoiceValue>({
type="button"
role="radio"
aria-checked={selected}
disabled={option.disabled}
tabIndex={index === tabStop ? 0 : -1}
onClick={() =>
onChange(selected && clearValue !== undefined ? clearValue : option.value)
@@ -129,6 +138,7 @@ export function ChoiceGroup<T extends ChoiceValue>({
"min-h-11 rounded-lg border px-2 text-sm outline-none motion-safe:transition-colors",
"focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50",
!selected && "border-border bg-background hover:bg-muted",
option.disabled && "cursor-not-allowed opacity-50 hover:bg-background",
selected && isUnanswered && "border-foreground/30 bg-muted font-medium text-foreground",
selected &&
!isUnanswered &&
@@ -0,0 +1,164 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { NotificationBell } from "./notification-bell";
import {
fetchNotifications,
markAllNotificationsRead,
markNotificationRead,
} from "@/lib/notifications/api";
import type { Notification } from "@/lib/notifications/types";
import { ApiError } from "@/lib/auth/api-client";
import { toast } from "sonner";
vi.mock("@/lib/notifications/api", () => ({
fetchNotifications: vi.fn(),
markNotificationRead: vi.fn(),
markAllNotificationsRead: vi.fn(),
}));
vi.mock("sonner", () => ({ toast: { error: vi.fn(), success: vi.fn() } }));
function notification(overrides: Partial<Notification>): Notification {
return {
id: "n-1",
priority: "WARNUNG",
category: "WITHDRAWAL",
message: "Wartezeit-Konflikt bei B3",
relatedType: null,
relatedId: null,
link: null,
read: false,
createdAt: "2026-08-01T10:00:00.000Z",
...overrides,
};
}
function renderBell() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
invalidate = vi.spyOn(client, "invalidateQueries");
return render(
<QueryClientProvider client={client}>
<NotificationBell />
</QueryClientProvider>,
);
}
let invalidate: ReturnType<typeof vi.spyOn>;
describe("NotificationBell", () => {
beforeEach(() => {
vi.mocked(markAllNotificationsRead).mockReset().mockResolvedValue({ updated: 2 });
vi.mocked(markNotificationRead).mockReset().mockResolvedValue({} as never);
vi.mocked(toast.error).mockReset();
vi.mocked(toast.success).mockReset();
});
it("„Alle gelesen“ calls read-all and a notification with link navigates there", async () => {
vi.mocked(fetchNotifications).mockResolvedValue([
notification({ id: "n-1", link: "/harvests/h-1" }),
notification({ id: "n-2", message: "Ohne Ziel", relatedType: "Colony", relatedId: "c-9" }),
]);
renderBell();
await userEvent.click(await screen.findByRole("button", { name: "Benachrichtigungen" }));
const linked = await screen.findByText("Wartezeit-Konflikt bei B3");
expect(linked.closest("a")).toHaveAttribute("href", "/harvests/h-1");
expect(screen.getByText("Ohne Ziel").closest("a")).toHaveAttribute("href", "/colonies/c-9");
await userEvent.click(screen.getByRole("menuitem", { name: /Alle gelesen/ }));
await waitFor(() => expect(markAllNotificationsRead).toHaveBeenCalledTimes(1));
});
it("ignores a link that is not an internal path", async () => {
vi.mocked(fetchNotifications).mockResolvedValue([
notification({ link: "//evil.example/x" }),
notification({ id: "n-2", message: "Schema", link: "https://evil.example" }),
]);
renderBell();
await userEvent.click(await screen.findByRole("button", { name: "Benachrichtigungen" }));
expect((await screen.findByText("Wartezeit-Konflikt bei B3")).closest("a")).toBeNull();
expect(screen.getByText("Schema").closest("a")).toBeNull();
});
it("hides „Alle gelesen“ when nothing is unread and names the category in words", async () => {
vi.mocked(fetchNotifications).mockResolvedValue([notification({ read: true })]);
renderBell();
await userEvent.click(await screen.findByRole("button", { name: "Benachrichtigungen" }));
expect(await screen.findByText("Wartezeit")).toBeInTheDocument();
expect(screen.queryByRole("menuitem", { name: /Alle gelesen/ })).not.toBeInTheDocument();
});
it("clicking an unread notification marks it read and its link navigates", async () => {
vi.mocked(fetchNotifications).mockResolvedValue([notification({ id: "n-7", link: "/harvests/h-7" })]);
renderBell();
await userEvent.click(await screen.findByRole("button", { name: /Benachrichtigungen/ }));
const text = await screen.findByText("Wartezeit-Konflikt bei B3");
expect(text.closest("a")).toHaveAttribute("href", "/harvests/h-7");
await userEvent.click(text);
await waitFor(() => expect(markNotificationRead).toHaveBeenCalled());
expect(vi.mocked(markNotificationRead).mock.calls[0][0]).toBe("n-7");
});
it("a 404 on mark-read refreshes the list without an error toast", async () => {
vi.mocked(fetchNotifications).mockResolvedValue([notification({})]);
vi.mocked(markNotificationRead).mockRejectedValue(new ApiError(404, "Not found"));
renderBell();
await userEvent.click(await screen.findByRole("button", { name: /Benachrichtigungen/ }));
await userEvent.click(await screen.findByText("Wartezeit-Konflikt bei B3"));
await waitFor(() => expect(invalidate).toHaveBeenCalledWith({ queryKey: ["notifications"] }));
expect(toast.error).not.toHaveBeenCalled();
});
it("another mark-read error shows one toast with a fixed id", async () => {
vi.mocked(fetchNotifications).mockResolvedValue([notification({})]);
vi.mocked(markNotificationRead).mockRejectedValue(new ApiError(500, "boom"));
renderBell();
await userEvent.click(await screen.findByRole("button", { name: /Benachrichtigungen/ }));
await userEvent.click(await screen.findByText("Wartezeit-Konflikt bei B3"));
await waitFor(() => expect(toast.error).toHaveBeenCalledTimes(1));
expect(vi.mocked(toast.error).mock.calls[0][1]).toEqual({ id: "notification-read-error" });
});
it("the badge is announced with the unread count, the digit is hidden from readers", async () => {
vi.mocked(fetchNotifications).mockResolvedValue([notification({}), notification({ id: "n-2" })]);
renderBell();
expect(await screen.findByText("2 ungelesene Benachrichtigungen")).toHaveClass("sr-only");
expect(screen.getByText("2", { selector: "[aria-hidden='true']" })).toBeInTheDocument();
});
it("read-all confirms with a toast and refreshes", async () => {
vi.mocked(fetchNotifications).mockResolvedValue([notification({})]);
renderBell();
await userEvent.click(await screen.findByRole("button", { name: /Benachrichtigungen/ }));
await userEvent.click(await screen.findByRole("menuitem", { name: /Alle gelesen/ }));
await waitFor(() => expect(toast.success).toHaveBeenCalledWith("Alle als gelesen markiert."));
expect(invalidate).toHaveBeenCalledWith({ queryKey: ["notifications"] });
});
it("read-all refreshes the list on failure too", async () => {
vi.mocked(fetchNotifications).mockResolvedValue([notification({})]);
vi.mocked(markAllNotificationsRead).mockRejectedValue(new ApiError(500, "x"));
renderBell();
await userEvent.click(await screen.findByRole("button", { name: /Benachrichtigungen/ }));
await userEvent.click(await screen.findByRole("menuitem", { name: /Alle gelesen/ }));
await waitFor(() => expect(invalidate).toHaveBeenCalledWith({ queryKey: ["notifications"] }));
});
it.each([
["/harvests/h-1?tab=a&x=1", true],
["/\t/x", false],
["//evil", false],
["/\\evil", false],
["https://evil.example", false],
["/a b", false],
[`/${"a".repeat(300)}`, false],
])("link %j is internal: %s", async (link, internal) => {
vi.mocked(fetchNotifications).mockResolvedValue([notification({ link })]);
renderBell();
await userEvent.click(await screen.findByRole("button", { name: /Benachrichtigungen/ }));
const anchor = (await screen.findByText("Wartezeit-Konflikt bei B3")).closest("a");
if (internal) expect(anchor).toHaveAttribute("href", link);
else expect(anchor).toBeNull();
});
});
@@ -2,10 +2,19 @@
import Link from "next/link";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Bell } from "lucide-react";
import { Bell, CheckCheck } from "lucide-react";
import { toast } from "sonner";
import { fetchNotifications, markNotificationRead } from "@/lib/notifications/api";
import { Notification, NotificationPriority } from "@/lib/notifications/types";
import { ApiError } from "@/lib/auth/api-client";
import {
fetchNotifications,
markAllNotificationsRead,
markNotificationRead,
} from "@/lib/notifications/api";
import {
Notification,
NotificationCategory,
NotificationPriority,
} from "@/lib/notifications/types";
import { Badge, badgeVariants } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -15,16 +24,39 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
const PRIORITY_VARIANT: Record<NotificationPriority, NonNullable<Parameters<typeof badgeVariants>[0]>["variant"]> = {
const PRIORITY_VARIANT: Record<
NotificationPriority,
NonNullable<Parameters<typeof badgeVariants>[0]>["variant"]
> = {
INFO: "secondary",
HINWEIS: "outline",
WARNUNG: "warning",
KRITISCH: "destructive",
};
const CATEGORY_LABEL: Record<NotificationCategory, string> = {
TASK_DUE: "Aufgabe",
DIGEST: "Übersicht",
SUGGESTION: "Vorschlag",
WITHDRAWAL: "Wartezeit",
SEASON: "Saison",
SYSTEM: "System",
};
/** Same rule as the backend (`assertAppLink`): an internal path, never "//host", "/\host" or a scheme. */
const INTERNAL_PATH = /^\/(?![/\\])[A-Za-z0-9\-._~/?=&%]{0,299}$/;
function isInternalPath(link: string): boolean {
return INTERNAL_PATH.test(link);
}
function notificationHref(notification: Notification): string | null {
if (notification.relatedType === "Colony") return `/colonies/${notification.relatedId}`;
if (notification.relatedType === "Apiary") return `/apiaries/${notification.relatedId}`;
if (notification.link)
return isInternalPath(notification.link) ? notification.link : null;
if (notification.relatedType === "Colony")
return `/colonies/${notification.relatedId}`;
if (notification.relatedType === "Apiary")
return `/apiaries/${notification.relatedId}`;
if (notification.relatedType === "Task") return "/tasks";
return null;
}
@@ -37,33 +69,76 @@ export function NotificationBell() {
refetchInterval: 60_000,
});
const refresh = () =>
void queryClient.invalidateQueries({ queryKey: ["notifications"] });
const readMutation = useMutation({
mutationFn: markNotificationRead,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["notifications"] });
mutationFn: (id: string) => markNotificationRead(id),
onSettled: refresh,
onError: (error) => {
// 404: the entry is gone or no longer visible; the refresh drops it.
if (error instanceof ApiError && error.status === 404) return;
toast.error("Benachrichtigung konnte nicht als gelesen markiert werden", {
id: "notification-read-error",
});
},
onError: () => toast.error("Benachrichtigung konnte nicht als gelesen markiert werden"),
});
const unreadCount = notifications?.filter((notification) => !notification.read).length ?? 0;
const readAllMutation = useMutation({
mutationFn: () => markAllNotificationsRead(),
onSuccess: () => toast.success("Alle als gelesen markiert."),
onSettled: refresh,
onError: () =>
toast.error(
"Benachrichtigungen konnten nicht als gelesen markiert werden",
{
id: "notification-read-error",
},
),
});
const unreadCount =
notifications?.filter((notification) => !notification.read).length ?? 0;
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon" className="relative" aria-label="Benachrichtigungen" />
<Button
variant="ghost"
size="icon"
className="relative"
aria-label="Benachrichtigungen"
/>
}
>
<Bell className="size-4.5" />
{unreadCount > 0 ? (
<span className="absolute -top-0.5 -right-0.5 flex size-4 items-center justify-center rounded-full bg-destructive text-[0.6rem] font-medium text-destructive-foreground">
{unreadCount > 9 ? "9+" : unreadCount}
<span aria-hidden="true">
{unreadCount > 9 ? "9+" : unreadCount}
</span>
<span className="sr-only">
{unreadCount} ungelesene Benachrichtigungen
</span>
</span>
) : null}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80">
{unreadCount > 0 ? (
<DropdownMenuItem
className="min-h-11 gap-2 px-3 font-medium"
disabled={readAllMutation.isPending}
onClick={() => readAllMutation.mutate()}
>
<CheckCheck aria-hidden />
Alle gelesen
</DropdownMenuItem>
) : null}
{!notifications || notifications.length === 0 ? (
<p className="p-3 text-sm text-muted-foreground">Keine Benachrichtigungen.</p>
<p className="p-3 text-sm text-muted-foreground">
Keine Benachrichtigungen.
</p>
) : (
notifications.slice(0, 10).map((notification) => {
const href = notificationHref(notification);
@@ -72,11 +147,18 @@ export function NotificationBell() {
className={`flex flex-col gap-1 p-2 text-sm ${notification.read ? "opacity-60" : ""}`}
>
<div className="flex items-center justify-between gap-2">
<Badge variant={PRIORITY_VARIANT[notification.priority]}>
{notification.priority}
</Badge>
<span className="flex items-center gap-2">
<Badge variant={PRIORITY_VARIANT[notification.priority]}>
{notification.priority}
</Badge>
<span className="text-xs text-muted-foreground">
{CATEGORY_LABEL[notification.category]}
</span>
</span>
<span className="text-xs text-muted-foreground">
{new Date(notification.createdAt).toLocaleDateString("de-DE")}
{new Date(notification.createdAt).toLocaleDateString(
"de-DE",
)}
</span>
</div>
<span>{notification.message}</span>
@@ -0,0 +1,228 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { CleanupSheet } from "./cleanup-sheet";
import { suggestionFixture } from "./suggestion-fixtures";
import { applyCleanup } from "@/lib/autopilot/api";
import { fetchColony } from "@/lib/colonies/api";
import { fetchQueen } from "@/lib/queens/api";
import { fetchSuggestions } from "@/lib/suggestions/api";
import { fetchTask } from "@/lib/tasks/api";
import { ApiError } from "@/lib/auth/api-client";
import type { Colony } from "@/lib/colonies/types";
import type { Queen } from "@/lib/queens/types";
import type { Task } from "@/lib/tasks/types";
const permissions = new Set<string>();
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({ hasPermission: (permission: string) => permissions.has(permission) }),
}));
vi.mock("@/lib/autopilot/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/autopilot/api")>()),
applyCleanup: vi.fn(),
}));
vi.mock("@/lib/colonies/api", () => ({ fetchColony: vi.fn() }));
vi.mock("@/lib/queens/api", () => ({ fetchQueen: vi.fn() }));
vi.mock("@/lib/tasks/api", () => ({ fetchTask: vi.fn() }));
vi.mock("@/lib/suggestions/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/suggestions/api")>()),
fetchSuggestions: vi.fn(),
}));
vi.mock("sonner", () => ({
toast: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn() }),
}));
const cleanupSuggestion = suggestionFixture({
id: "s9",
intent: "CLEANUP",
mode: "DO",
colonyId: "c1",
colonyCode: "B3",
title: "B3 aufräumen",
cleanup: { taskIds: ["t1", "t2"], queenId: "q1" },
});
function task(id: string, title: string): Task {
return { id, title, status: "OPEN" } as Task;
}
function colony(status: Colony["status"]): Colony {
return { id: "c1", code: "B3", status } as Colony;
}
function renderSheet(onClose = vi.fn(), onDone = vi.fn()) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={client}>
<CleanupSheet suggestionId="s9" colonyId="c1" onClose={onClose} onDone={onDone} />
</QueryClientProvider>,
);
return { onClose, onDone };
}
describe("CleanupSheet", () => {
beforeEach(() => {
permissions.clear();
permissions.add("tasks:update");
permissions.add("tasks:complete");
permissions.add("queens:update");
vi.mocked(fetchSuggestions).mockResolvedValue([cleanupSuggestion]);
const tasks = [task("t1", "Futter kontrollieren"), task("t2", "Varroa messen")];
vi.mocked(fetchTask).mockReset();
vi.mocked(fetchTask).mockImplementation(async (id: string) => {
const found = tasks.find((candidate) => candidate.id === id);
if (!found) throw new ApiError(404, "Not found");
return found;
});
vi.mocked(fetchColony).mockResolvedValue(colony("DECEASED"));
vi.mocked(fetchQueen).mockResolvedValue({ id: "q1", code: "Q-7", status: "ACTIVE" } as Queen);
vi.mocked(applyCleanup).mockReset();
vi.mocked(applyCleanup).mockResolvedValue({
cancelled: 2,
completed: 0,
kept: 0,
queenStatus: "DECEASED",
});
});
it("CleanupSheet preselects „Abbrechen“ and the queen status by colony status and sends apply-cleanup", async () => {
const { onClose } = renderSheet();
const first = await screen.findByRole("radiogroup", { name: /Futter kontrollieren/ });
expect(within(first).getByRole("radio", { name: "Abbrechen" })).toHaveAttribute("aria-checked", "true");
const second = screen.getByRole("radiogroup", { name: /Varroa messen/ });
await userEvent.click(within(second).getByRole("radio", { name: "Behalten" }));
const queen = await screen.findByRole("radiogroup", { name: /Königin Q-7/ });
expect(within(queen).getByRole("radio", { name: "verstorben" })).toHaveAttribute("aria-checked", "true");
await userEvent.click(screen.getByRole("button", { name: "Aufräumen" }));
await waitFor(() =>
expect(applyCleanup).toHaveBeenCalledWith(
"s9",
{
tasks: [
{ id: "t1", outcome: "CANCEL" },
{ id: "t2", outcome: "KEEP" },
],
queen: { id: "q1", status: "DECEASED" },
},
{ idempotencyKey: expect.any(String) },
),
);
await waitFor(() => expect(onClose).toHaveBeenCalled());
});
it("preselects „verkauft“ for a sold colony and „unverändert“ otherwise (the queen keeps her status)", async () => {
vi.mocked(fetchColony).mockResolvedValue(colony("SOLD"));
renderSheet();
const sold = await screen.findByRole("radiogroup", { name: /Königin Q-7/ });
await waitFor(() =>
expect(within(sold).getByRole("radio", { name: "verkauft" })).toHaveAttribute("aria-checked", "true"),
);
});
it("omits the queen from the body when she stays „unverändert“", async () => {
vi.mocked(fetchColony).mockResolvedValue(colony("DISSOLVED"));
renderSheet();
const queen = await screen.findByRole("radiogroup", { name: /Königin Q-7/ });
await waitFor(() =>
expect(within(queen).getByRole("radio", { name: "unverändert" })).toHaveAttribute("aria-checked", "true"),
);
await userEvent.click(screen.getByRole("button", { name: "Aufräumen" }));
await waitFor(() => expect(applyCleanup).toHaveBeenCalled());
expect(vi.mocked(applyCleanup).mock.calls[0][1]).toEqual({
tasks: [
{ id: "t1", outcome: "CANCEL" },
{ id: "t2", outcome: "CANCEL" },
],
});
});
it("omits the queen and says so without queens:update", async () => {
permissions.delete("queens:update");
renderSheet();
await screen.findByRole("radiogroup", { name: /Futter kontrollieren/ });
expect(screen.getByText(/Die Königin bleibt unverändert/)).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Aufräumen" }));
await waitFor(() => expect(applyCleanup).toHaveBeenCalled());
expect(vi.mocked(applyCleanup).mock.calls[0][1]).not.toHaveProperty("queen");
});
it("loads only the evidence tasks and neither lists nor sends a task that is gone or no longer open", async () => {
vi.mocked(fetchTask).mockImplementation(async (id: string) => {
if (id === "t1") throw new ApiError(404, "Not found");
return { id, title: "Varroa messen", status: "DONE" } as Task;
});
renderSheet();
expect(await screen.findByText(/„Varroa messen“ – nicht mehr offen/)).toBeInTheDocument();
expect(screen.getByText(/Eine Aufgabe gibt es nicht mehr/)).toBeInTheDocument();
expect(screen.queryByRole("radiogroup", { name: /Varroa messen/ })).not.toBeInTheDocument();
expect(fetchTask).toHaveBeenCalledTimes(2);
await userEvent.click(screen.getByRole("button", { name: "Aufräumen" }));
await waitFor(() => expect(applyCleanup).toHaveBeenCalled());
expect(vi.mocked(applyCleanup).mock.calls[0][1].tasks).toEqual([]);
});
it("explains a failed queen load, offers a retry and keeps the main button off", async () => {
vi.mocked(fetchQueen).mockRejectedValueOnce(new Error("boom"));
renderSheet();
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("Die Königin konnte nicht geladen werden.");
expect(screen.getByRole("button", { name: "Aufräumen" })).toBeDisabled();
expect(screen.getByText(/erst möglich, wenn alles geladen ist/)).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Erneut versuchen" }));
expect(await screen.findByRole("radiogroup", { name: /Königin Q-7/ })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Aufräumen" })).toBeEnabled();
});
it("shows the backend's German text for a 400 and a 403", async () => {
renderSheet();
const { toast } = await import("sonner");
vi.mocked(applyCleanup).mockRejectedValueOnce(
new ApiError(400, "Nur Aufgaben und Königin aus dem Vorschlag.", {
message: "Nur Aufgaben und Königin aus dem Vorschlag.",
}),
);
await userEvent.click(await screen.findByRole("button", { name: "Aufräumen" }));
await waitFor(() =>
expect(toast.error).toHaveBeenCalledWith("Nur Aufgaben und Königin aus dem Vorschlag."),
);
vi.mocked(applyCleanup).mockRejectedValueOnce(
new ApiError(403, "Dafür fehlt das Recht.", { message: "Dafür fehlt das Recht." }),
);
await userEvent.click(screen.getByRole("button", { name: "Aufräumen" }));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith("Dafür fehlt das Recht."));
});
it("disables „Erledigt“ with a hint without tasks:complete", async () => {
permissions.delete("tasks:complete");
renderSheet();
const first = await screen.findByRole("radiogroup", { name: /Futter kontrollieren/ });
expect(within(first).getByRole("radio", { name: "Erledigt" })).toBeDisabled();
expect(screen.getByText(/„Erledigt“ braucht das Recht/)).toBeInTheDocument();
});
it("shows the server's German text and stays open when the colony is active again", async () => {
const { onClose } = renderSheet();
const { ApiError } = await import("@/lib/auth/api-client");
vi.mocked(applyCleanup).mockRejectedValue(new ApiError(409, "Das Volk ist wieder aktiv.", {}));
const { toast } = await import("sonner");
await userEvent.click(await screen.findByRole("button", { name: "Aufräumen" }));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith("Das Volk ist wieder aktiv."));
expect(onClose).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,339 @@
"use client";
import { useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Eraser } from "lucide-react";
import { toast } from "sonner";
import { ChoiceGroup, type ChoiceOption } from "@/components/form/choice-group";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { applyCleanup } from "@/lib/autopilot/api";
import type { ApplyCleanupInput, CleanupOutcome, CleanupResult } from "@/lib/autopilot/types";
import { useAuth } from "@/lib/auth/auth-context";
import { fetchColony } from "@/lib/colonies/api";
import { newIdempotencyKey } from "@/lib/offline/idempotency";
import { fetchQueen } from "@/lib/queens/api";
import { fetchSuggestions, suggestionsKey } from "@/lib/suggestions/api";
import { suggestionErrorText } from "@/lib/suggestions/errors";
import { fetchTask } from "@/lib/tasks/api";
import type { Task } from "@/lib/tasks/types";
import { ApiError } from "@/lib/auth/api-client";
type QueenChoice = "DECEASED" | "SOLD" | "KEEP";
const TASK_OPTIONS: ChoiceOption<CleanupOutcome>[] = [
{ value: "CANCEL", label: "Abbrechen" },
{ value: "DONE", label: "Erledigt" },
{ value: "KEEP", label: "Behalten" },
];
const QUEEN_OPTIONS: ChoiceOption<QueenChoice>[] = [
{ value: "DECEASED", label: "verstorben" },
{ value: "SOLD", label: "verkauft" },
{ value: "KEEP", label: "unverändert" },
];
const REFRESHED_KEYS = [["suggestions"], ["tasks"], ["colonies"], ["dashboard"], ["autopilot"], ["queens"]] as const;
function plural(count: number, one: string, many: string): string {
return `${count} ${count === 1 ? one : many}`;
}
function resultText(result: CleanupResult): string {
const parts = [
result.cancelled > 0 ? `${plural(result.cancelled, "Aufgabe", "Aufgaben")} abgebrochen` : null,
result.completed > 0 ? `${plural(result.completed, "Aufgabe", "Aufgaben")} erledigt` : null,
result.kept > 0 ? `${result.kept} behalten` : null,
].filter(Boolean);
return parts.length > 0 ? `Aufgeräumt: ${parts.join(", ")}` : "Aufgeräumt";
}
/** The colony ended as sold or deceased: the queen most likely followed. */
function defaultQueenChoice(colonyStatus: string | undefined): QueenChoice {
if (colonyStatus === "DECEASED") return "DECEASED";
if (colonyStatus === "SOLD") return "SOLD";
return "KEEP";
}
interface EvidenceTask {
id: string;
/** Null when the task does not exist any more. */
task: Task | null;
}
/** Only the tasks of the suggestion's evidence; a 404 means the task is gone. */
async function loadEvidenceTasks(ids: readonly string[]): Promise<EvidenceTask[]> {
const results = await Promise.allSettled(ids.map((id) => fetchTask(id)));
return results.map((result, index) => {
if (result.status === "fulfilled") return { id: ids[index], task: result.value };
if (result.reason instanceof ApiError && result.reason.status === 404) {
return { id: ids[index], task: null };
}
throw result.reason;
});
}
function isOpenTask(task: Task | null): boolean {
return task !== null && (task.status === "OPEN" || task.status === "IN_PROGRESS");
}
/** The service's own German sentence for 400, 403 and 409; else the generic text. */
function cleanupErrorText(error: unknown): string {
if (error instanceof ApiError && [400, 403, 409].includes(error.status)) {
const body = error.body as { message?: unknown } | null;
if (typeof body?.message === "string" && body.message) return body.message;
}
return suggestionErrorText(error);
}
function LoadError({ what, onRetry }: { what: string; onRetry: () => void }) {
return (
<div role="alert" className="flex flex-col items-start gap-2 text-sm">
<p>{what} konnte nicht geladen werden.</p>
<Button type="button" size="touch" variant="outline" onClick={onRetry}>
Erneut versuchen
</Button>
</div>
);
}
interface CleanupSheetProps {
/** The CLEANUP suggestion; the sheet is open while this is set. */
suggestionId: string | null;
colonyId: string | null;
onClose: () => void;
/** Called after a successful clean-up (the sheet closes itself). */
onDone?: () => void;
}
/** „Aufräumen“ of an ended colony: what happens with its open tasks and its queen. */
export function CleanupSheet({ suggestionId, colonyId, onClose, onDone }: CleanupSheetProps) {
return (
<Sheet open={suggestionId !== null} onOpenChange={(open) => (open ? undefined : onClose())}>
<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 choices always start at the defaults. */}
{suggestionId !== null ? (
<CleanupForm
key={suggestionId}
suggestionId={suggestionId}
colonyId={colonyId}
onClose={onClose}
onDone={onDone}
/>
) : null}
</SheetContent>
</Sheet>
);
}
interface CleanupFormProps extends Omit<CleanupSheetProps, "suggestionId"> {
suggestionId: string;
}
function CleanupForm({ suggestionId, colonyId, onClose, onDone }: CleanupFormProps) {
const queryClient = useQueryClient();
const { hasPermission } = useAuth();
const canComplete = hasPermission("tasks:complete");
const canChangeQueen = hasPermission("queens:update");
const [outcomes, setOutcomes] = useState<Readonly<Record<string, CleanupOutcome>>>({});
const [queenOverride, setQueenOverride] = useState<QueenChoice | null>(null);
// One key per set of choices: a retry after a lost answer is not applied twice.
const idempotencyKey = useRef(newIdempotencyKey());
const suggestions = useQuery({
queryKey: suggestionsKey({ status: "OPEN", ...(colonyId ? { colonyId } : {}) }),
queryFn: () => fetchSuggestions({ status: "OPEN", ...(colonyId ? { colonyId } : {}) }),
});
const suggestion = suggestions.data?.find((candidate) => candidate.id === suggestionId) ?? null;
const taskIds = suggestion?.cleanup?.taskIds ?? [];
const queenId = canChangeQueen ? (suggestion?.cleanup?.queenId ?? null) : null;
const tasks = useQuery({
queryKey: ["tasks", "cleanup", ...taskIds],
queryFn: () => loadEvidenceTasks(taskIds),
enabled: taskIds.length > 0,
});
const colony = useQuery({
queryKey: ["colonies", colonyId],
queryFn: () => fetchColony(colonyId ?? ""),
enabled: queenId !== null && colonyId !== null,
});
const queen = useQuery({
queryKey: ["queens", queenId],
queryFn: () => fetchQueen(queenId ?? ""),
enabled: queenId !== null,
});
const evidence = tasks.data ?? [];
const taskRows = evidence.flatMap(({ id, task }) => (task && isOpenTask(task) ? [{ id, title: task.title }] : []));
const closedTasks = evidence.flatMap(({ id, task }) =>
task && !isOpenTask(task) ? [{ id, title: task.title }] : [],
);
const goneCount = evidence.filter(({ task }) => task === null).length;
const loadFailed = tasks.isError || (queenId !== null && (queen.isError || colony.isError));
const queenChoice = queenOverride ?? defaultQueenChoice(colony.data?.status);
const isReady =
suggestion !== null &&
(taskIds.length === 0 || tasks.isSuccess) &&
(queenId === null || (queen.isSuccess && (colonyId === null || colony.isSuccess)));
const mutation = useMutation({
mutationFn: (input: ApplyCleanupInput) =>
applyCleanup(suggestionId, input, { idempotencyKey: idempotencyKey.current }),
onSuccess: async (result) => {
toast.success(resultText(result));
await Promise.all(REFRESHED_KEYS.map((queryKey) => queryClient.invalidateQueries({ queryKey })));
onDone?.();
onClose();
},
onError: (error) => {
toast.error(cleanupErrorText(error));
// A 409 or 404 means the list is stale.
void queryClient.invalidateQueries({ queryKey: ["suggestions"] });
},
});
function changeOutcome(id: string, outcome: CleanupOutcome) {
idempotencyKey.current = newIdempotencyKey();
setOutcomes((current) => ({ ...current, [id]: outcome }));
}
function changeQueen(choice: QueenChoice) {
idempotencyKey.current = newIdempotencyKey();
setQueenOverride(choice);
}
function submit() {
mutation.mutate({
tasks: taskRows.map(({ id }) => ({ id, outcome: outcomes[id] ?? "CANCEL" })),
// „unverändert“ sends nothing: the queen keeps her status.
...(queenId !== null && queen.data && queenChoice !== "KEEP"
? { queen: { id: queenId, status: queenChoice } }
: {}),
});
}
const taskOptions = TASK_OPTIONS.map((option) => ({
...option,
disabled: option.value === "DONE" && !canComplete,
}));
return (
<form
onSubmit={(event) => {
event.preventDefault();
if (isReady) submit();
}}
className="flex flex-col gap-4 px-4 pb-4"
>
<SheetHeader className="px-0">
<SheetTitle>
Volk aufräumen{suggestion?.colonyCode ? ` · ${suggestion.colonyCode}` : ""}
</SheetTitle>
<SheetDescription>
Was soll mit den offenen Aufgaben und der Königin dieses beendeten Volks geschehen?
</SheetDescription>
</SheetHeader>
{suggestions.isSuccess && suggestion === null ? (
<p role="alert" className="text-sm">
Der Vorschlag ist nicht mehr offen.
</p>
) : null}
{suggestions.isError ? (
<p role="alert" className="text-sm">
Der Vorschlag konnte nicht geladen werden.
</p>
) : null}
{tasks.isError ? <LoadError what="Die Aufgaben" onRetry={() => void tasks.refetch()} /> : null}
{queenId !== null && queen.isError ? (
<LoadError what="Die Königin" onRetry={() => void queen.refetch()} />
) : null}
{queenId !== null && colony.isError ? (
<LoadError what="Das Volk" onRetry={() => void colony.refetch()} />
) : null}
{closedTasks.length > 0 || goneCount > 0 ? (
<ul className="flex flex-col gap-1 text-sm text-muted-foreground">
{closedTasks.map(({ id, title }) => (
<li key={id}>„{title}“ – nicht mehr offen</li>
))}
{goneCount > 0 ? (
<li>
{goneCount === 1 ? "Eine Aufgabe gibt es" : `${goneCount} Aufgaben gibt es`} nicht
mehr.
</li>
) : null}
</ul>
) : null}
{taskRows.length > 0 ? (
<div className="flex flex-col gap-3">
{taskRows.map(({ id, title }) => (
<div key={id} className="flex flex-col gap-1.5">
<p className="text-sm font-medium" id={`cleanup-task-${id}`}>
Aufgabe „{title}“
</p>
<ChoiceGroup
options={taskOptions}
value={outcomes[id] ?? "CANCEL"}
onChange={(outcome) => changeOutcome(id, outcome)}
labelledBy={`cleanup-task-${id}`}
className="grid-cols-3"
/>
</div>
))}
{!canComplete ? (
<p className="text-sm text-muted-foreground">
„Erledigt“ braucht das Recht „Aufgaben abschließen“.
</p>
) : null}
</div>
) : null}
{queenId !== null && queen.data ? (
<div className="flex flex-col gap-1.5">
<p className="text-sm font-medium" id="cleanup-queen">
Königin {queen.data.code}
</p>
<ChoiceGroup
options={QUEEN_OPTIONS}
value={queenChoice}
onChange={changeQueen}
labelledBy="cleanup-queen"
className="grid-cols-3"
/>
</div>
) : null}
{!canChangeQueen && suggestion?.cleanup?.queenId ? (
<p className="text-sm text-muted-foreground">
Die Königin bleibt unverändert – dafür fehlt das Recht „Königinnen bearbeiten“.
</p>
) : null}
{loadFailed ? (
<p className="text-sm text-muted-foreground">
Aufräumen ist erst möglich, wenn alles geladen ist.
</p>
) : null}
<div className="flex flex-col gap-2 sm:flex-row-reverse">
<Button type="submit" size="touch" disabled={!isReady || mutation.isPending}>
<Eraser aria-hidden />
{mutation.isPending ? "Wird gespeichert…" : "Aufräumen"}
</Button>
<SheetClose render={<Button type="button" size="touch" variant="outline" />}>
Schließen
</SheetClose>
</div>
</form>
);
}
@@ -0,0 +1,53 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { InspectionDueCard } from "./inspection-due-card";
import { readRound } from "@/lib/rounds/round-state";
import type { InspectionDueApiary } from "@/lib/autopilot/types";
vi.mock("@/lib/auth/auth-context", () => ({ useCurrentUserId: () => "user-1" }));
const entry: InspectionDueApiary = {
apiaryId: "a-1",
apiaryName: "Obstwiese",
segment: "SCHWARMZEIT",
intervalDays: 7,
colonies: [
{ id: "c-2", code: "B2", dueOn: "2026-05-10", overdueDays: 2 },
{ id: "c-1", code: "A1", dueOn: "2026-05-12", overdueDays: 0 },
],
};
describe("InspectionDueCard", () => {
beforeEach(() => window.sessionStorage.clear());
it("„Durchsicht fällig“ starts a round only with the due colonies of that apiary", async () => {
render(<InspectionDueCard entry={entry} />);
const start = screen.getByRole("link", { name: "Runde starten" });
expect(start).toHaveAttribute("href", "/inspections/new?colonyId=c-1&round=a-1");
await userEvent.click(start);
expect(readRound("user-1")).toMatchObject({
apiaryId: "a-1",
apiaryName: "Obstwiese",
colonies: [
{ id: "c-1", code: "A1" },
{ id: "c-2", code: "B2" },
],
});
});
it("names the apiary, the season with its interval and every due colony", () => {
render(<InspectionDueCard entry={entry} />);
expect(
screen.getByRole("heading", { name: /Durchsicht fällig · Obstwiese/ }),
).toBeInTheDocument();
expect(screen.getByText("Schwarmzeit · alle 7 Tage")).toBeInTheDocument();
const list = screen.getByRole("list");
expect(within(list).getByText("A1")).toBeInTheDocument();
expect(within(list).getByText("seit 2 Tagen")).toBeInTheDocument();
expect(within(list).getByText("heute")).toBeInTheDocument();
});
});
@@ -0,0 +1,83 @@
"use client";
import Link from "next/link";
import { ClipboardCheck, Play } from "lucide-react";
import { SeasonChip } from "@/components/autopilot/season-chip";
import { buttonVariants } from "@/components/ui/button";
import type { InspectionDueApiary } from "@/lib/autopilot/types";
import { useCurrentUserId } from "@/lib/auth/auth-context";
import { startRound } from "@/lib/rounds/round-state";
function overdueText(overdueDays: number): string {
if (overdueDays <= 0) return "heute";
return `seit ${overdueDays} ${overdueDays === 1 ? "Tag" : "Tagen"}`;
}
interface InspectionDueCardProps {
entry: InspectionDueApiary;
/** False when a step card above already has the main button. */
primary?: boolean;
}
/**
* „Durchsicht fällig“ of one apiary: the colonies that are due today or
* overdue. „Runde starten“ walks through exactly these, in code order.
*/
export function InspectionDueCard({ entry, primary = true }: InspectionDueCardProps) {
const userId = useCurrentUserId();
const colonies = [...entry.colonies].sort((a, b) =>
a.code.localeCompare(b.code, "de", { numeric: true }),
);
const first = colonies[0];
const headingId = `inspection-due-${entry.apiaryId}`;
const roundHref = first
? `/inspections/new?colonyId=${encodeURIComponent(first.id)}&round=${encodeURIComponent(entry.apiaryId)}`
: null;
return (
<section
aria-labelledby={headingId}
className="flex flex-col gap-3 rounded-2xl border border-border/60 bg-card p-4 shadow-[var(--shadow-soft)] sm:p-5"
>
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/12 text-primary">
<ClipboardCheck className="size-5" aria-hidden />
</span>
<h3 id={headingId} className="min-w-0 flex-1 font-medium">
Durchsicht fällig · {entry.apiaryName}
</h3>
<SeasonChip segment={entry.segment} intervalDays={entry.intervalDays} />
</div>
<ul className="flex flex-col divide-y divide-border/60">
{colonies.map((colony) => (
<li key={colony.id} className="flex items-center justify-between gap-3 py-2 text-sm">
<span className="font-medium">{colony.code}</span>
<span className="text-muted-foreground">{overdueText(colony.overdueDays)}</span>
</li>
))}
</ul>
{roundHref ? (
<Link
href={roundHref}
onClick={() => {
if (userId) {
startRound(
userId,
{ id: entry.apiaryId, name: entry.apiaryName },
colonies.map(({ id, code }) => ({ id, code })),
);
}
}}
className={buttonVariants({
size: "touch",
variant: primary ? "default" : "outline",
className: "self-start",
})}
>
<Play aria-hidden />
Runde starten
</Link>
) : null}
</section>
);
}
@@ -1,11 +1,21 @@
"use client";
import Link from "next/link";
import { ArrowRight, Check, ClipboardPlus, ListPlus, Play, Sun } from "lucide-react";
import { ArrowRight, Check, ClipboardPlus, Lightbulb, ListPlus, Play, Sun } from "lucide-react";
import { Task, TaskRelatedType } from "@/lib/tasks/types";
import { dueState, pickNextStep, relatedHref, tasksForToday } from "@/lib/tasks/today";
import { Button } from "@/components/ui/button";
import { DueChip, TaskSourceBadge, TaskStatusPill } from "@/components/task-status";
import { SeasonChip } from "@/components/autopilot/season-chip";
import { StepActions } from "@/components/autopilot/next-steps-card";
import { useStepActions } from "@/components/autopilot/use-step-actions";
import { CleanupSheet } from "@/components/suggestions/cleanup-sheet";
import { dueChipProps } from "@/lib/autopilot/format";
import type { AutopilotToday, TodayNextStep } from "@/lib/autopilot/types";
import { useAuth } from "@/lib/auth/auth-context";
import { todayLocalIsoDate } from "@/lib/dates";
import { INTENT_ICONS } from "@/lib/suggestions/labels";
import { InspectionDueCard } from "./inspection-due-card";
import { useTaskActions } from "./use-task-actions";
const TODAY_LIST_LIMIT = 5;
@@ -25,10 +35,23 @@ interface TodayPanelProps {
isError: boolean;
onRetry: () => void;
now: Date;
/** The autopilot's view of today; without it (offline, no right) the tasks alone decide. */
autopilot?: AutopilotToday | null;
}
export function TodayPanel({ tasks, isLoading, isError, onRetry, now }: TodayPanelProps) {
export function TodayPanel({
tasks: loadedTasks,
isLoading,
isError,
onRetry,
now,
autopilot = null,
}: TodayPanelProps) {
const actions = useTaskActions();
const stepActions = useStepActions("next-step-heading");
const { hasPermission } = useAuth();
// The autopilot is enough to work with when the task list is missing.
const tasks = loadedTasks ?? (autopilot ? [] : undefined);
if (isLoading) {
return (
@@ -40,7 +63,7 @@ export function TodayPanel({ tasks, isLoading, isError, onRetry, now }: TodayPan
);
}
if (isError || !tasks) {
if (!tasks || (isError && !autopilot)) {
return (
<div
role="alert"
@@ -56,26 +79,59 @@ export function TodayPanel({ tasks, isLoading, isError, onRetry, now }: TodayPan
);
}
const nextStep = pickNextStep(tasks, now);
if (!nextStep) {
const autopilotStep = autopilot?.nextStep ?? null;
const nextTask = autopilotStep ? null : pickNextStep(tasks, now);
const inspectionDue = autopilot?.inspectionDue ?? [];
// A failed task list must not read as "nothing due".
const tasksFailed = isError && !loadedTasks;
if (!autopilotStep && !nextTask && inspectionDue.length === 0 && !tasksFailed) {
return <NothingDue />;
}
const today = tasksForToday(tasks, now).filter((task) => task.id !== nextStep.id);
const headTaskId =
autopilotStep?.action.type === "OPEN_TASK" ? autopilotStep.action.taskId : nextTask?.id;
const today = tasksForToday(tasks, now).filter((task) => task.id !== headTaskId);
const visible = today.slice(0, TODAY_LIST_LIMIT);
const hidden = today.length - visible.length;
return (
<div className="flex flex-col gap-6">
<NextStepCard
task={nextStep}
now={now}
leaving={actions.leavingIds.has(nextStep.id)}
onComplete={() => actions.complete(nextStep)}
onStart={() => actions.start(nextStep)}
isStarting={actions.isStarting}
{autopilotStep ? (
<AutopilotStepCard
step={autopilotStep}
busy={stepActions.busyKeys.has(autopilotStep.key)}
canAccept={hasPermission("tasks:create")}
canDismiss={hasPermission("tasks:update")}
onAccept={stepActions.accept}
onDismiss={stepActions.dismiss}
onCleanup={(step) => stepActions.openCleanup(step, autopilotStep.colonyId)}
/>
) : nextTask ? (
<NextStepCard
task={nextTask}
now={now}
leaving={actions.leavingIds.has(nextTask.id)}
onComplete={() => actions.complete(nextTask)}
onStart={() => actions.start(nextTask)}
isStarting={actions.isStarting}
/>
) : null}
<CleanupSheet
suggestionId={stepActions.cleanupTarget?.suggestionId ?? null}
colonyId={stepActions.cleanupTarget?.colonyId ?? null}
onClose={stepActions.closeCleanup}
onDone={() => void stepActions.afterCleanup()}
/>
{inspectionDue.map((entry, index) => (
<InspectionDueCard
key={entry.apiaryId}
entry={entry}
primary={!autopilotStep && !nextTask && index === 0}
/>
))}
{autopilot ? <AutopilotChips autopilot={autopilot} /> : null}
<section aria-labelledby="today-heading" className="flex flex-col gap-2">
<div className="flex items-baseline justify-between gap-3">
<h2 id="today-heading" className="font-heading text-lg font-semibold tracking-tight">
@@ -88,7 +144,17 @@ export function TodayPanel({ tasks, isLoading, isError, onRetry, now }: TodayPan
Alle Aufgaben
</Link>
</div>
{visible.length === 0 ? (
{tasksFailed ? (
<div
role="alert"
className="flex flex-col items-start gap-2 rounded-xl border border-destructive/30 bg-destructive/5 p-3 text-sm"
>
<p>Die Aufgaben konnten gerade nicht geladen werden.</p>
<Button size="touch" variant="outline" onClick={onRetry}>
Erneut laden
</Button>
</div>
) : visible.length === 0 ? (
<p className="rounded-xl bg-card/60 px-4 py-3 text-sm text-muted-foreground">
Sonst ist für heute nichts fällig. Ein Schritt reicht.
</p>
@@ -183,6 +249,105 @@ function NextStepCard({ task, now, leaving, onComplete, onStart, isStarting }: N
);
}
interface AutopilotStepCardProps {
step: TodayNextStep;
busy: boolean;
canAccept: boolean;
canDismiss: boolean;
onAccept: (step: TodayNextStep) => void;
onDismiss: (step: TodayNextStep) => void;
onCleanup: (step: TodayNextStep) => void;
}
/** „Als Nächstes“ from the autopilot: one step across all colonies, with its reasons and action. */
function AutopilotStepCard({ step, busy, ...handlers }: AutopilotStepCardProps) {
const Icon = step.intent ? INTENT_ICONS[step.intent] : ArrowRight;
return (
<section
aria-labelledby="next-step-heading"
className="relative overflow-hidden rounded-2xl border border-primary/25 bg-card p-5 shadow-[var(--shadow-elevated)] sm:p-6"
>
<h2
id="next-step-heading"
tabIndex={-1}
className="font-heading text-base font-semibold text-muted-foreground outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
>
Als Nächstes
</h2>
<p role="status" className="sr-only">
Nächster Schritt: {step.title}
</p>
<div key={step.key} className="next-step-enter mt-2 flex flex-col gap-4">
<div className="flex flex-col gap-2">
<p className="flex items-start gap-3 font-heading text-2xl font-semibold leading-tight tracking-tight text-balance sm:text-3xl">
<Icon className="mt-1 size-6 shrink-0 text-primary" aria-hidden />
{step.title}
</p>
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-medium">
{step.colonyCode}
</span>
<DueChip {...dueChipProps(step.dueOn, todayLocalIsoDate())} />
</div>
{step.reasons.length > 0 ? (
<ul className="flex max-w-prose flex-col gap-0.5 text-sm text-muted-foreground">
{step.reasons.slice(0, 2).map((reason) => (
<li key={reason}>{reason}</li>
))}
</ul>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2">
<StepActions
step={step}
busy={busy}
canAccept={handlers.canAccept}
canDismiss={handlers.canDismiss}
onAccept={() => handlers.onAccept(step)}
onDismiss={() => handlers.onDismiss(step)}
onCleanup={() => handlers.onCleanup(step)}
/>
<Button
size="touch"
variant="ghost"
nativeButton={false}
render={<Link href={`/colonies/${encodeURIComponent(step.colonyId)}`} />}
>
Zum Volk
<ArrowRight aria-hidden />
</Button>
</div>
</div>
</section>
);
}
/** Open suggestions and the season of each apiary - small, but readable as words. */
function AutopilotChips({ autopilot }: { autopilot: AutopilotToday }) {
const showSuggestions = autopilot.tasksVisible && autopilot.suggestionsOpen > 0;
if (!showSuggestions && autopilot.seasons.length === 0) return null;
return (
<ul aria-label="Vorschläge und Saison" className="flex flex-wrap items-center gap-2">
{showSuggestions ? (
<li>
<Link
href="/suggestions"
className="inline-flex min-h-11 items-center gap-1.5 rounded-full bg-primary/12 px-4 text-sm font-medium focus-visible:outline-2 focus-visible:outline-ring"
>
<Lightbulb className="size-4" aria-hidden />
Vorschläge {autopilot.suggestionsOpen}
</Link>
</li>
) : null}
{autopilot.seasons.map((season) => (
<li key={season.apiaryId}>
<SeasonChip segment={season.segment} apiaryName={season.apiaryName} />
</li>
))}
</ul>
);
}
interface TodayRowProps {
task: Task;
now: Date;
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import { render, screen, within } from "@testing-library/react";
import { WarningList } from "./warning-list";
import type { PlausibilityWarning } from "@/lib/warnings/types";
const warnings: PlausibilityWarning[] = [
{
code: "WITHDRAWAL_ACTIVE",
severity: "WARNUNG",
message: "B3: Wartezeit nach Oxuvar endet am 20.08.2026.",
refs: [{ type: "Colony", id: "c-1" }],
},
{
code: "JAR_SIZE_DIFFERS",
severity: "HINWEIS",
message: "Die Glasgröße weicht ab.",
refs: [],
},
];
describe("WarningList", () => {
it("announces warnings with icon and word in a status region", () => {
render(<WarningList warnings={warnings} title="Bitte prüfen" />);
const region = screen.getByRole("status");
expect(region).toHaveAttribute("aria-live", "polite");
expect(within(region).getByText("Bitte prüfen")).toBeInTheDocument();
expect(within(region).getByText("Warnung")).toBeInTheDocument();
expect(within(region).getByText("Hinweis")).toBeInTheDocument();
expect(
within(region).getByText("B3: Wartezeit nach Oxuvar endet am 20.08.2026."),
).toBeInTheDocument();
expect(region.querySelectorAll("svg[aria-hidden='true']")).toHaveLength(2);
});
it("shows the word for a confirmation request", () => {
render(
<WarningList
warnings={[
{
code: "WATER_CONTENT_ABOVE_LEGAL",
severity: "BESTAETIGEN",
message: "Zu feucht.",
refs: [],
},
]}
/>,
);
expect(screen.getByText("Bestätigen")).toBeInTheDocument();
});
it("keeps the live region mounted but empty without warnings", () => {
render(<WarningList warnings={[]} />);
expect(screen.getByRole("status")).toBeEmptyDOMElement();
});
});
@@ -0,0 +1,46 @@
import { CircleAlert, Info, type LucideIcon, TriangleAlert } from "lucide-react";
import type { PlausibilityWarning, WarningSeverity } from "@/lib/warnings/types";
import { cn } from "@/lib/utils";
const SEVERITY: Record<WarningSeverity, { icon: LucideIcon; word: string; tone: string }> = {
HINWEIS: { icon: Info, word: "Hinweis", tone: "bg-muted" },
WARNUNG: { icon: TriangleAlert, word: "Warnung", tone: "bg-warning/20" },
BESTAETIGEN: { icon: CircleAlert, word: "Bestätigen", tone: "bg-destructive/15" },
};
export interface WarningListProps {
warnings: readonly PlausibilityWarning[];
title?: string;
}
/**
* Plausibility findings in a polite live region: icon and word per level, so
* the state never depends on colour. The region stays mounted when empty so
* a later warning is announced.
*/
export function WarningList({ warnings, title }: WarningListProps) {
return (
<div role="status" aria-live="polite" className={cn(warnings.length > 0 && "flex flex-col gap-2")}>
{warnings.length > 0 ? (
<>
{title ? <p className="text-sm font-semibold">{title}</p> : null}
<ul className="flex flex-col gap-1.5">
{warnings.map((warning, index) => {
const { icon: Icon, word, tone } = SEVERITY[warning.severity];
return (
<li
key={`${warning.code}-${index}`}
className={cn("flex min-h-11 items-start gap-2 rounded-lg px-3 py-2 text-sm", tone)}
>
<Icon className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
<span className="font-medium">{word}</span>
<span>{warning.message}</span>
</li>
);
})}
</ul>
</>
) : null}
</div>
);
}