fix: T4a browser check findings
Backend Image / build-and-push (push) Failing after 2m45s
Frontend Image / build-and-push (push) Successful in 5m52s

- Withdrawal wording from one backend helper everywhere ("bis
  einschließlich TT.MM. – ab TT.MM. wieder erntefähig", year when it
  crosses New Year); withdrawal-status returns lastBlockedDay and the
  ready text, the frontend no longer computes dates
- Calendar events name colony or apiary, withdrawal events carry
  product and detail; compact withdrawal rows on mobile
- One bottling warning per colony and treatment; pre-save hint
- Offline: online-only actions disabled with a reason, cached data
  shows its timestamp, open sheets block submit offline
- Mobile: no horizontal scroll at 360 px, touch targets >= 44 px,
  toasts and the add button no longer cover content
- Cleanup sheet without destructive preselection; natural sort of
  colony codes; collapsible automation rule cards; varroa list newest
  first with chart axes; text fixes

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 18:26:09 +02:00
1 parent 32c61c8e14
commit c2f71bcb03
101 files changed
+2189 -271

No files matched your search

@@ -1,12 +1,13 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, render, screen, waitFor, 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 { colonyNextStepsKey, 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 { reportRequestOutcome } from "@/lib/offline/connectivity";
import { toast } from "sonner";
vi.mock("@/lib/autopilot/api", async (importOriginal) => ({
@@ -49,6 +50,10 @@ const TASK_STEP = stepFixture({
});
describe("NextStepsCard", () => {
afterEach(() => {
act(() => reportRequestOutcome("reached-server"));
});
beforeEach(() => {
vi.mocked(fetchColonyNextSteps).mockReset();
vi.mocked(acceptSuggestion).mockReset();
@@ -233,4 +238,47 @@ describe("NextStepsCard", () => {
await vi.waitFor(() => expect(fetchColonyNextSteps).toHaveBeenCalledTimes(2));
await vi.waitFor(() => expect(second.container).toBeEmptyDOMElement());
});
it("offline: decision buttons are off with „Nur mit Verbindung“ and the data says since when", async () => {
vi.mocked(fetchColonyNextSteps).mockResolvedValue(
colonyStepsFixture({
steps: [
stepFixture({
intent: "HONEY_SUPER",
title: "Honigraum",
action: { type: "ACCEPT", suggestionId: "s1" },
}),
],
}),
);
act(() => reportRequestOutcome("network-error"));
renderCard();
expect(await screen.findByRole("button", { name: "Übernehmen" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Verwerfen" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Übernehmen" })).toHaveAttribute(
"aria-describedby",
screen.getByText("Nur mit Verbindung").id,
);
expect(screen.getByText(/^Stand: \d{2}\.\d{2}\. \d{2}:\d{2}$/)).toBeInTheDocument();
});
it("keeps showing the cached steps when the reload fails at network level", async () => {
vi.mocked(fetchColonyNextSteps).mockRejectedValue(new TypeError("Failed to fetch"));
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
client.setQueryData(
colonyNextStepsKey("c1"),
colonyStepsFixture({ steps: [stepFixture({ title: "Aus dem Cache" })] }),
);
render(
<QueryClientProvider client={client}>
<NextStepsCard colonyId="c1" />
</QueryClientProvider>,
);
await waitFor(() => expect(fetchColonyNextSteps).toHaveBeenCalled());
expect(await screen.findByText("Aus dem Cache")).toBeInTheDocument();
// Online, but the reload failed: the cached steps say since when.
expect(await screen.findByText(/^Stand: \d{2}\.\d{2}\. \d{2}:\d{2}$/)).toBeInTheDocument();
});
});
@@ -4,6 +4,7 @@ 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 { OnlineOnlyHint, StandNote, useOnlineOnly } from "@/components/offline/stand-note";
import { CleanupSheet } from "@/components/suggestions/cleanup-sheet";
import { DueChip } from "@/components/task-status";
import { Button, buttonVariants } from "@/components/ui/button";
@@ -51,6 +52,11 @@ export function StepActions({
}: StepActionsProps) {
const { action } = step;
const variant = primary ? "default" : "outline";
// Deciding needs the server; captures and links work offline.
const { isOffline, hintId, describedBy } = useOnlineOnly();
const hasAccept = action.type === "ACCEPT" && Boolean(onAccept) && canAccept;
const hasCleanup = action.type === "CLEANUP" && Boolean(onCleanup) && canDismiss;
const hasDismiss = step.dismissible && Boolean(onDismiss) && canDismiss;
return (
<div className="flex flex-wrap items-center gap-2">
{action.type === "CAPTURE" ? (
@@ -66,23 +72,42 @@ export function StepActions({
</Link>
) : null}
{action.type === "ACCEPT" && onAccept && canAccept ? (
<Button size="touch" variant={variant} disabled={busy} onClick={() => onAccept(step)}>
<Button
size="touch"
variant={variant}
disabled={busy || isOffline}
aria-describedby={describedBy}
onClick={() => onAccept(step)}
>
<Check aria-hidden />
Übernehmen
</Button>
) : null}
{action.type === "CLEANUP" && onCleanup && canDismiss ? (
<Button size="touch" variant={variant} disabled={busy} onClick={() => onCleanup(step)}>
<Button
size="touch"
variant={variant}
disabled={busy || isOffline}
aria-describedby={describedBy}
onClick={() => onCleanup(step)}
>
<Eraser aria-hidden />
Aufräumen
</Button>
) : null}
{step.dismissible && onDismiss && canDismiss ? (
<Button size="touch" variant="ghost" disabled={busy} onClick={() => onDismiss(step)}>
<Button
size="touch"
variant="ghost"
disabled={busy || isOffline}
aria-describedby={describedBy}
onClick={() => onDismiss(step)}
>
<X aria-hidden />
Verwerfen
</Button>
) : null}
{hasAccept || hasCleanup || hasDismiss ? <OnlineOnlyHint id={hintId} className="basis-full" /> : null}
</div>
);
}
@@ -180,7 +205,8 @@ export function NextStepsCard({ colonyId }: { colonyId: string }) {
});
const stepActions = useStepActions(NEXT_STEPS_HEADING_ID);
if (query.isError) {
// A failed reload keeps what the cache has: the steps stay, marked with their „Stand“.
if (query.isError && !query.data) {
// Offline the colony page still works; a missing hint is no news.
if (isNetworkError(query.error)) return null;
return (
@@ -202,6 +228,7 @@ export function NextStepsCard({ colonyId }: { colonyId: string }) {
return (
<>
<StandNote updatedAt={query.dataUpdatedAt} isStale={query.isError} />
<NextStepsView
steps={query.data.steps}
withdrawalUntil={query.data.withdrawalUntil}
@@ -12,8 +12,6 @@ import {
} 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 = [
@@ -128,7 +126,6 @@ export function useStepActions(headingId: string) {
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) => {
@@ -69,6 +69,11 @@ describe("DocumentUploadCard", () => {
vi.mocked(toast.error).mockReset();
});
it("has an upload button of at least 44 px", () => {
renderCard();
expect(screen.getByRole("button", { name: "Datei hochladen" })).toHaveClass("min-h-11");
});
it("re-encodes images before upload, PDFs unchanged", async () => {
const { container } = renderCard();
const photo = new File(["png with metadata"], "Beute 3.png", { type: "image/png" });
@@ -208,6 +208,7 @@ export function DocumentUploadCard({
<Button
size="sm"
variant="outline"
className="min-h-11"
disabled={uploadMutation.isPending}
onClick={() => fileInputRef.current?.click()}
>
@@ -110,15 +110,17 @@ describe("OfflineBand", () => {
);
});
it("tells that input is kept while offline with an empty queue", async () => {
it("tells what is kept while offline and that decisions need a connection", async () => {
setOnLine(false);
vi.mocked(summarizeQueue).mockResolvedValue({ own: 0, failed: 0, foreign: 0 });
renderBand();
expect(await screen.findByRole("status")).toHaveTextContent(
"Offline – Eingaben werden gespeichert und später gesendet",
const status = await screen.findByRole("status");
expect(status).toHaveTextContent(
"Offline – Erfassungen werden gespeichert und später gesendet. Vorschläge und Aufgaben brauchen eine Verbindung.",
);
expect(status).not.toHaveTextContent("Eingaben werden gespeichert");
});
it("keeps an empty status region online with an empty queue", async () => {
@@ -141,7 +141,7 @@ export function OfflineBand({ userId, pageContentRef, syncEnabled = true }: Offl
<CloudOff className="size-4 shrink-0" aria-hidden />
{queue.own > 0
? `Offline – ${entriesWaiting(queue.own)}`
: "Offline – Eingaben werden gespeichert und später gesendet"}
: "Offline – Erfassungen werden gespeichert und später gesendet. Vorschläge und Aufgaben brauchen eine Verbindung."}
</p>
) : null}
{!isOffline && queue.own > 0 ? (
@@ -0,0 +1,57 @@
import { afterEach, describe, expect, it } from "vitest";
import { act, render, screen } from "@testing-library/react";
import { reportRequestOutcome } from "@/lib/offline/connectivity";
import { formatStand } from "@/lib/offline/stand";
import { OnlineOnlyHint, StandNote } from "./stand-note";
const UPDATED_AT = new Date(2026, 9, 14, 9, 5).getTime();
afterEach(() => {
act(() => reportRequestOutcome("reached-server"));
});
describe("formatStand", () => {
it("formats the time of the cached data as „Stand: TT.MM. HH:mm“", () => {
expect(formatStand(UPDATED_AT)).toBe("Stand: 14.10. 09:05");
});
});
describe("StandNote", () => {
it("shows nothing while online", () => {
render(<StandNote updatedAt={UPDATED_AT} />);
expect(screen.queryByText(/Stand:/)).not.toBeInTheDocument();
});
it("shows when the data is from while offline", () => {
act(() => reportRequestOutcome("network-error"));
render(<StandNote updatedAt={UPDATED_AT} />);
expect(screen.getByText("Stand: 14.10. 09:05")).toBeInTheDocument();
});
it("shows the Stand online when the reload failed and cached data is shown (stale)", () => {
render(<StandNote updatedAt={UPDATED_AT} isStale />);
expect(screen.getByText("Stand: 14.10. 09:05")).toBeInTheDocument();
});
it("shows nothing without a time (data never loaded)", () => {
act(() => reportRequestOutcome("network-error"));
render(<StandNote updatedAt={0} />);
expect(screen.queryByText(/Stand:/)).not.toBeInTheDocument();
});
});
describe("OnlineOnlyHint", () => {
it("says „Nur mit Verbindung“ only offline", () => {
const { rerender } = render(<OnlineOnlyHint id="h" />);
expect(screen.queryByText("Nur mit Verbindung")).not.toBeInTheDocument();
act(() => reportRequestOutcome("network-error"));
rerender(<OnlineOnlyHint id="h" />);
expect(screen.getByText("Nur mit Verbindung")).toHaveAttribute("id", "h");
});
});
@@ -0,0 +1,46 @@
"use client";
import { useId } from "react";
import { useConnectivity } from "@/lib/offline/connectivity";
import { formatStand } from "@/lib/offline/stand";
import { cn } from "@/lib/utils";
interface StandNoteProps {
/** `dataUpdatedAt` of the query (ms); 0 = never loaded. */
updatedAt: number;
/** Online, but the last reload failed and the cached data is shown. */
isStale?: boolean;
className?: string;
}
/** Data from the cache may be old (offline, or a failed reload): say since when. */
export function StandNote({ updatedAt, isStale = false, className }: StandNoteProps) {
const connectivity = useConnectivity();
if ((connectivity !== "offline" && !isStale) || updatedAt <= 0) return null;
return <p className={cn("text-sm text-muted-foreground", className)}>{formatStand(updatedAt)}</p>;
}
/**
* Offline state plus the id of the „Nur mit Verbindung“ hint: put `describedBy`
* on every button the hint explains, and render `<OnlineOnlyHint id={hintId} />`.
*/
export function useOnlineOnly(): {
isOffline: boolean;
hintId: string;
describedBy: string | undefined;
} {
const hintId = useId();
const isOffline = useConnectivity() === "offline";
return { isOffline, hintId, describedBy: isOffline ? hintId : undefined };
}
/** Offline, a decision that needs the server is off; the hint says why. */
export function OnlineOnlyHint({ id, className }: { id?: string; className?: string }) {
const connectivity = useConnectivity();
if (connectivity !== "offline") return null;
return (
<p id={id} className={cn("text-sm text-muted-foreground", className)}>
Nur mit Verbindung
</p>
);
}
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import { act, 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";
@@ -10,6 +10,7 @@ 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 { reportRequestOutcome } from "@/lib/offline/connectivity";
import type { Colony } from "@/lib/colonies/types";
import type { Queen } from "@/lib/queens/types";
import type { Task } from "@/lib/tasks/types";
@@ -61,6 +62,14 @@ function renderSheet(onClose = vi.fn(), onDone = vi.fn()) {
return { onClose, onDone };
}
/** Waits for the task rows and gives each one the same choice. */
async function chooseForAllTasks(label: string) {
await screen.findByRole("radiogroup", { name: /Futter kontrollieren/ });
for (const group of screen.getAllByRole("radiogroup", { name: /^Aufgabe/ })) {
await userEvent.click(within(group).getByRole("radio", { name: label }));
}
}
describe("CleanupSheet", () => {
beforeEach(() => {
permissions.clear();
@@ -86,12 +95,18 @@ describe("CleanupSheet", () => {
});
});
it("CleanupSheet preselects „Abbrechen“ and the queen status by colony status and sends apply-cleanup", async () => {
it("CleanupSheet starts without a task choice, keeps the button off until every task has one 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");
for (const radio of within(first).getAllByRole("radio")) {
expect(radio).toHaveAttribute("aria-checked", "false");
}
const second = screen.getByRole("radiogroup", { name: /Varroa messen/ });
expect(screen.getByRole("button", { name: "Aufräumen" })).toBeDisabled();
expect(screen.getByText(/Bitte für jede Aufgabe etwas wählen/)).toBeInTheDocument();
await userEvent.click(within(first).getByRole("radio", { name: "Aufgabe abbrechen" }));
expect(screen.getByRole("button", { name: "Aufräumen" })).toBeDisabled();
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");
@@ -131,6 +146,7 @@ describe("CleanupSheet", () => {
expect(within(queen).getByRole("radio", { name: "unverändert" })).toHaveAttribute("aria-checked", "true"),
);
await chooseForAllTasks("Aufgabe abbrechen");
await userEvent.click(screen.getByRole("button", { name: "Aufräumen" }));
await waitFor(() => expect(applyCleanup).toHaveBeenCalled());
@@ -148,6 +164,7 @@ describe("CleanupSheet", () => {
await screen.findByRole("radiogroup", { name: /Futter kontrollieren/ });
expect(screen.getByText(/Die Königin bleibt unverändert/)).toBeInTheDocument();
await chooseForAllTasks("Aufgabe abbrechen");
await userEvent.click(screen.getByRole("button", { name: "Aufräumen" }));
await waitFor(() => expect(applyCleanup).toHaveBeenCalled());
@@ -182,6 +199,7 @@ describe("CleanupSheet", () => {
await userEvent.click(screen.getByRole("button", { name: "Erneut versuchen" }));
expect(await screen.findByRole("radiogroup", { name: /Königin Q-7/ })).toBeInTheDocument();
await chooseForAllTasks("Aufgabe abbrechen");
expect(screen.getByRole("button", { name: "Aufräumen" })).toBeEnabled();
});
@@ -193,6 +211,7 @@ describe("CleanupSheet", () => {
message: "Nur Aufgaben und Königin aus dem Vorschlag.",
}),
);
await chooseForAllTasks("Aufgabe abbrechen");
await userEvent.click(await screen.findByRole("button", { name: "Aufräumen" }));
await waitFor(() =>
expect(toast.error).toHaveBeenCalledWith("Nur Aufgaben und Königin aus dem Vorschlag."),
@@ -220,9 +239,32 @@ describe("CleanupSheet", () => {
vi.mocked(applyCleanup).mockRejectedValue(new ApiError(409, "Das Volk ist wieder aktiv.", {}));
const { toast } = await import("sonner");
await chooseForAllTasks("Aufgabe abbrechen");
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();
});
it("keeps „Aufgabe abbrechen“ apart from the close button „Schließen“", async () => {
renderSheet();
const first = await screen.findByRole("radiogroup", { name: /Futter kontrollieren/ });
expect(within(first).getByRole("radio", { name: "Aufgabe abbrechen" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Schließen" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Abbrechen" })).not.toBeInTheDocument();
});
it("switches off the submit button in an open sheet when the connection drops", async () => {
renderSheet();
await chooseForAllTasks("Aufgabe abbrechen");
expect(screen.getByRole("button", { name: "Aufräumen" })).toBeEnabled();
act(() => reportRequestOutcome("network-error"));
const submit = screen.getByRole("button", { name: "Aufräumen" });
expect(submit).toBeDisabled();
expect(submit).toHaveAttribute("aria-describedby", screen.getByText("Nur mit Verbindung").id);
act(() => reportRequestOutcome("reached-server"));
});
});
@@ -5,6 +5,7 @@ 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 { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { Button } from "@/components/ui/button";
import {
Sheet,
@@ -28,8 +29,8 @@ import { ApiError } from "@/lib/auth/api-client";
type QueenChoice = "DECEASED" | "SOLD" | "KEEP";
const TASK_OPTIONS: ChoiceOption<CleanupOutcome>[] = [
{ value: "CANCEL", label: "Abbrechen" },
const TASK_OPTIONS: ChoiceOption<CleanupOutcome | null>[] = [
{ value: "CANCEL", label: "Aufgabe abbrechen" },
{ value: "DONE", label: "Erledigt" },
{ value: "KEEP", label: "Behalten" },
];
@@ -122,7 +123,7 @@ export function CleanupSheet({ suggestionId, colonyId, onClose, onDone }: Cleanu
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. */}
{/* Remounted per opening, so the choices always start empty. */}
{suggestionId !== null ? (
<CleanupForm
key={suggestionId}
@@ -143,9 +144,11 @@ interface CleanupFormProps extends Omit<CleanupSheetProps, "suggestionId"> {
function CleanupForm({ suggestionId, colonyId, onClose, onDone }: CleanupFormProps) {
const queryClient = useQueryClient();
const { isOffline, hintId, describedBy } = useOnlineOnly();
const { hasPermission } = useAuth();
const canComplete = hasPermission("tasks:complete");
const canChangeQueen = hasPermission("queens:update");
// No row starts with a choice: cancelling a task must be a decision.
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.
@@ -183,8 +186,10 @@ function CleanupForm({ suggestionId, colonyId, onClose, onDone }: CleanupFormPro
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 hasAllChoices = taskRows.every(({ id }) => outcomes[id] !== undefined);
const isReady =
suggestion !== null &&
hasAllChoices &&
(taskIds.length === 0 || tasks.isSuccess) &&
(queenId === null || (queen.isSuccess && (colonyId === null || colony.isSuccess)));
@@ -214,7 +219,10 @@ function CleanupForm({ suggestionId, colonyId, onClose, onDone }: CleanupFormPro
}
function submit() {
mutation.mutate({
tasks: taskRows.map(({ id }) => ({ id, outcome: outcomes[id] ?? "CANCEL" })),
tasks: taskRows.flatMap(({ id }) => {
const outcome = outcomes[id];
return outcome ? [{ id, outcome }] : [];
}),
// „unverändert“ sends nothing: the queen keeps her status.
...(queenId !== null && queen.data && queenChoice !== "KEEP"
? { queen: { id: queenId, status: queenChoice } }
@@ -231,7 +239,7 @@ function CleanupForm({ suggestionId, colonyId, onClose, onDone }: CleanupFormPro
<form
onSubmit={(event) => {
event.preventDefault();
if (isReady) submit();
if (isReady && !isOffline) submit();
}}
className="flex flex-col gap-4 px-4 pb-4"
>
@@ -285,13 +293,18 @@ function CleanupForm({ suggestionId, colonyId, onClose, onDone }: CleanupFormPro
</p>
<ChoiceGroup
options={taskOptions}
value={outcomes[id] ?? "CANCEL"}
onChange={(outcome) => changeOutcome(id, outcome)}
value={outcomes[id] ?? null}
onChange={(outcome) => {
if (outcome !== null) changeOutcome(id, outcome);
}}
labelledBy={`cleanup-task-${id}`}
className="grid-cols-3"
/>
</div>
))}
{!hasAllChoices ? (
<p className="text-sm text-muted-foreground">Bitte für jede Aufgabe etwas wählen.</p>
) : null}
{!canComplete ? (
<p className="text-sm text-muted-foreground">
„Erledigt“ braucht das Recht „Aufgaben abschließen“.
@@ -325,8 +338,14 @@ function CleanupForm({ suggestionId, colonyId, onClose, onDone }: CleanupFormPro
Aufräumen ist erst möglich, wenn alles geladen ist.
</p>
) : null}
<OnlineOnlyHint id={hintId} />
<div className="flex flex-col gap-2 sm:flex-row-reverse">
<Button type="submit" size="touch" disabled={!isReady || mutation.isPending}>
<Button
type="submit"
size="touch"
disabled={!isReady || mutation.isPending || isOffline}
aria-describedby={describedBy}
>
<Eraser aria-hidden />
{mutation.isPending ? "Wird gespeichert…" : "Aufräumen"}
</Button>
@@ -0,0 +1,45 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { reportRequestOutcome } from "@/lib/offline/connectivity";
import { DueDateSheet } from "./due-date-sheet";
afterEach(() => {
act(() => reportRequestOutcome("reached-server"));
});
function setup() {
const onConfirm = vi.fn();
render(
<DueDateSheet
open
onOpenChange={() => undefined}
title="Termin ändern"
confirmLabel="Übernehmen"
initialDate="2026-05-12"
onConfirm={onConfirm}
/>,
);
return onConfirm;
}
describe("DueDateSheet", () => {
it("confirms with the day online", async () => {
const onConfirm = setup();
await userEvent.click(screen.getByRole("button", { name: "Übernehmen" }));
expect(onConfirm).toHaveBeenCalledWith("2026-05-12");
});
it("switches off the confirm button with „Nur mit Verbindung“ when the connection drops in the open sheet", async () => {
const onConfirm = setup();
act(() => reportRequestOutcome("network-error"));
const confirm = screen.getByRole("button", { name: "Übernehmen" });
expect(confirm).toBeDisabled();
expect(confirm).toHaveAttribute("aria-describedby", screen.getByText("Nur mit Verbindung").id);
expect(onConfirm).not.toHaveBeenCalled();
});
});
@@ -1,6 +1,7 @@
"use client";
import { FormEvent, useState } from "react";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -44,10 +45,11 @@ export function DueDateSheet(props: DueDateSheetProps) {
function DueDateForm({ title, description, confirmLabel, initialDate, onConfirm }: DueDateSheetProps) {
const [date, setDate] = useState(initialDate);
const { isOffline, hintId, describedBy } = useOnlineOnly();
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (date) onConfirm(date);
if (date && !isOffline) onConfirm(date);
}
return (
@@ -67,8 +69,9 @@ function DueDateForm({ title, description, confirmLabel, initialDate, onConfirm
onChange={(event) => setDate(event.target.value)}
/>
</div>
<OnlineOnlyHint id={hintId} />
<div className="flex flex-col gap-2 sm:flex-row-reverse">
<Button type="submit" size="touch" disabled={!date}>
<Button type="submit" size="touch" disabled={!date || isOffline} aria-describedby={describedBy}>
{confirmLabel}
</Button>
<SheetClose render={<Button type="button" size="touch" variant="outline" />}>
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { reportRequestOutcome } from "@/lib/offline/connectivity";
import { SuggestionCard } from "./suggestion-card";
import { suggestionFixture } from "./suggestion-fixtures";
@@ -18,12 +19,31 @@ function setup(overrides = {}, handlers: Partial<Parameters<typeof SuggestionCar
return { onAccept, onDismiss };
}
afterEach(() => {
act(() => reportRequestOutcome("reached-server"));
});
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");
const menu = screen.getByRole("button", { name: "Weitere Aktionen" });
expect(menu).toHaveClass("h-11");
expect(menu).toHaveClass("w-11");
expect(menu).toHaveClass("shrink-0");
});
it("fits the three buttons into 296 px (360 px phone minus page and card padding) and wraps instead of overflowing", () => {
setup({ mode: "PLAN" });
// Widths at 0.95rem text: Übernehmen ~136, Verwerfen without icon ~89, menu 44, two gaps 16 = 285.
const row = screen.getByRole("button", { name: "Übernehmen" }).parentElement as HTMLElement;
expect(row).toHaveClass("flex-wrap");
expect(row).not.toHaveClass("flex-nowrap");
expect(screen.getByRole("button", { name: "Verwerfen" }).querySelector("svg")).toHaveClass("hidden");
expect(screen.getByRole("button", { name: "Weitere Aktionen" })).toHaveClass("ml-auto");
expect(screen.getByRole("article")).toHaveClass("min-w-0");
});
it("shows „Erfassen“ as a real link to the capture form for DO", () => {
@@ -126,4 +146,54 @@ describe("SuggestionCard", () => {
expect(heading).toHaveAttribute("tabindex", "-1");
expect(heading).toHaveAttribute("data-suggestion-heading", "s1");
});
describe("offline", () => {
function goOffline() {
act(() => reportRequestOutcome("network-error"));
}
it("switches off the decisions with the hint „Nur mit Verbindung“", () => {
goOffline();
setup({ mode: "PLAN" });
expect(screen.getByRole("button", { name: "Übernehmen" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Verwerfen" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Weitere Aktionen" })).toBeDisabled();
expect(screen.getByText("Nur mit Verbindung")).toBeInTheDocument();
});
it("leaves „Erfassen“ alone: captures are queued", () => {
goOffline();
setup({ mode: "DO", intent: "VARROA_MEASURE", colonyId: "c7" });
expect(screen.getByRole("link", { name: "Erfassen" })).toHaveAttribute(
"href",
"/varroa/new?colonyId=c7",
);
});
it("switches off „Aufräumen“", () => {
goOffline();
setup({ intent: "CLEANUP", mode: "DO" }, { onCleanup: vi.fn() });
expect(screen.getByRole("button", { name: "Aufräumen" })).toBeDisabled();
});
it("ties the hint to the disabled buttons with aria-describedby", () => {
goOffline();
setup({ mode: "PLAN" });
const hint = screen.getByText("Nur mit Verbindung");
for (const name of ["Übernehmen", "Verwerfen", "Weitere Aktionen"]) {
expect(screen.getByRole("button", { name })).toHaveAttribute("aria-describedby", hint.id);
}
expect(hint.id).not.toBe("");
});
it("shows no hint online", () => {
setup({ mode: "PLAN" });
expect(screen.queryByText("Nur mit Verbindung")).not.toBeInTheDocument();
});
});
});
@@ -11,6 +11,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { DueChip } from "@/components/task-status";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { captureHref, INTENT_ICONS } from "@/lib/suggestions/labels";
import type { Suggestion } from "@/lib/suggestions/types";
import { TASK_PRIORITY_LABELS } from "@/lib/tasks/labels";
@@ -43,11 +44,25 @@ function menuActionOf(suggestion: Suggestion): MenuAction | null {
return { label: "Als Aufgabe planen", sheetTitle: "Als Aufgabe planen", confirmLabel: "Aufgabe anlegen" };
}
function MainAction({ suggestion, busy, canAccept = true, onAccept, onCleanup }: SuggestionCardProps) {
/** Offline, deciding is off: the server has to confirm it. */
function MainAction({
suggestion,
busy,
canAccept = true,
isOffline,
describedBy,
onAccept,
onCleanup,
}: SuggestionCardProps & { isOffline: boolean; describedBy?: string }) {
if (suggestion.intent === "CLEANUP") {
if (!onCleanup) return null;
return (
<Button size="touch" disabled={busy} onClick={() => onCleanup(suggestion)}>
<Button
size="touch"
disabled={busy || isOffline}
aria-describedby={describedBy}
onClick={() => onCleanup(suggestion)}
>
<Check aria-hidden />
Aufräumen
</Button>
@@ -66,7 +81,13 @@ function MainAction({ suggestion, busy, canAccept = true, onAccept, onCleanup }:
}
if (!canAccept) return null;
return (
<Button size="touch" disabled={busy} onClick={() => onAccept(suggestion, undefined)}>
<Button
size="touch"
className="px-3.5"
disabled={busy || isOffline}
aria-describedby={describedBy}
onClick={() => onAccept(suggestion, undefined)}
>
<Check aria-hidden />
Übernehmen
</Button>
@@ -79,10 +100,14 @@ export function SuggestionCard(props: SuggestionCardProps) {
const [isSheetOpen, setIsSheetOpen] = useState(false);
const Icon = INTENT_ICONS[suggestion.intent];
const menuAction = canAccept ? menuActionOf(suggestion) : null;
const { isOffline, hintId, describedBy } = useOnlineOnly();
const mainAction =
suggestion.intent === "CLEANUP" ? Boolean(props.onCleanup) : suggestion.mode === "PLAN" && canAccept;
const hasOnlineOnlyButton = mainAction || canDismiss || menuAction !== 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)]">
<article className="flex min-w-0 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 />
@@ -120,16 +145,24 @@ export function SuggestionCard(props: SuggestionCardProps) {
</div>
</div>
</div>
{/*
360 px phone: 360 - 2*16 (page) - 2*16 (card) = 296 px. Übernehmen ~136 +
Verwerfen without icon ~89 + menu 44 + 2 gaps 16 = 285 px: all three fit in
one row. If a longer label ever does not, the row wraps instead of pushing
the menu out of the card.
*/}
<div className="flex flex-wrap items-center gap-2">
<MainAction {...props} />
<MainAction {...props} isOffline={isOffline} describedBy={describedBy} />
{canDismiss ? (
<Button
size="touch"
variant="ghost"
disabled={busy}
className="px-3"
disabled={busy || isOffline}
aria-describedby={describedBy}
onClick={() => onDismiss(suggestion)}
>
<X aria-hidden />
<X aria-hidden className="hidden min-[420px]:block" />
Verwerfen
</Button>
) : null}
@@ -138,9 +171,11 @@ export function SuggestionCard(props: SuggestionCardProps) {
<DropdownMenuTrigger
render={
<Button
size="touch"
variant="ghost"
className="size-11"
disabled={busy}
className="ml-auto w-11 shrink-0 px-0"
disabled={busy || isOffline}
aria-describedby={describedBy}
aria-label="Weitere Aktionen"
/>
}
@@ -155,6 +190,7 @@ export function SuggestionCard(props: SuggestionCardProps) {
</DropdownMenu>
) : null}
</div>
{hasOnlineOnlyButton ? <OnlineOnlyHint id={hintId} /> : null}
{menuAction ? (
<DueDateSheet
open={isSheetOpen}
@@ -1,8 +1,9 @@
import { describe, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { act, 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 { reportRequestOutcome } from "@/lib/offline/connectivity";
import { SuggestionGroup } from "./suggestion-group";
import { suggestionFixture } from "./suggestion-fixtures";
@@ -138,4 +139,25 @@ describe("SuggestionGroup", () => {
expect(accept[0]).toBeDisabled();
expect(accept[1]).toBeEnabled();
});
it("switches off „Alle übernehmen“ offline and says why", () => {
act(() => reportRequestOutcome("network-error"));
try {
setup(THREE_PLAN);
const button = screen.getByRole("button", { name: "Alle übernehmen" });
expect(button).toBeDisabled();
expect(button).toHaveAttribute("aria-describedby", screen.getByText("Nur mit Verbindung").id);
} finally {
act(() => reportRequestOutcome("reached-server"));
}
});
it("the chevron moves only where motion is allowed", () => {
setup(THREE_PLAN);
const chevron = screen.getByRole("button", { name: /Einzeln anzeigen/ }).querySelector("svg");
expect(chevron).toHaveClass("motion-safe:transition-transform");
expect(chevron).not.toHaveClass("transition-transform");
});
});
@@ -3,6 +3,7 @@
import { useId, useState } from "react";
import Link from "next/link";
import { Check, ChevronDown, Play } from "lucide-react";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { Button, buttonVariants } from "@/components/ui/button";
import { INTENT_ICONS, INTENT_LABELS } from "@/lib/suggestions/labels";
import { roundTarget, type SuggestionGroupModel } from "@/lib/suggestions/grouping";
@@ -55,6 +56,7 @@ function CombinedRow({
const round = intent === "INSPECT" ? roundTarget(suggestions) : null;
const canAcceptAll =
canAccept && suggestions.every((suggestion) => suggestion.mode === "PLAN");
const { isOffline, hintId, describedBy } = useOnlineOnly();
const isBusy = suggestions.some((suggestion) => busyIds?.has(suggestion.id));
return (
@@ -80,7 +82,8 @@ function CombinedRow({
) : canAcceptAll ? (
<Button
size="touch"
disabled={isBusy}
disabled={isBusy || isOffline}
aria-describedby={describedBy}
onClick={() => handlers.onAcceptAll(suggestions.map((suggestion) => suggestion.id))}
>
<Check aria-hidden />
@@ -88,6 +91,7 @@ function CombinedRow({
</Button>
) : null}
</div>
{canAcceptAll && !round ? <OnlineOnlyHint id={hintId} /> : null}
<Button
variant="ghost"
className="min-h-11 self-start"
@@ -97,7 +101,7 @@ function CombinedRow({
>
<ChevronDown
aria-hidden
className={cn("transition-transform", isOpen && "rotate-180")}
className={cn("motion-safe:transition-transform", isOpen && "rotate-180")}
/>
{isOpen ? "Einzeln ausblenden" : "Einzeln anzeigen"}
</Button>
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { TabSwitcher } from "./tab-switcher";
describe("TabSwitcher", () => {
it("makes every tab at least 44 px high for a thumb", () => {
render(
<TabSwitcher
tabs={[
{ value: "a", label: "A" },
{ value: "b", label: "B" },
]}
value="a"
onChange={() => {}}
/>,
);
for (const tab of screen.getAllByRole("tab")) {
expect(tab).toHaveClass("min-h-11", "sm:min-h-9");
}
});
});
@@ -20,7 +20,7 @@ export function TabSwitcher<T extends string>({ tabs, value, onChange }: TabSwit
aria-selected={value === tab.value}
onClick={() => onChange(tab.value)}
className={cn(
"rounded-lg px-4 py-1.5 text-sm font-medium transition-colors",
"min-h-11 sm:min-h-9 rounded-lg px-4 py-1.5 text-sm font-medium transition-colors motion-reduce:transition-none",
value === tab.value
? "bg-card text-foreground shadow-[var(--shadow-soft)]"
: "text-muted-foreground hover:text-foreground",
@@ -1,11 +1,13 @@
"use client";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
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 { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { SeasonChip } from "@/components/autopilot/season-chip";
import { StepActions } from "@/components/autopilot/next-steps-card";
import { useStepActions } from "@/components/autopilot/use-step-actions";
@@ -13,6 +15,7 @@ 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 { fetchColonies } from "@/lib/colonies/api";
import { todayLocalIsoDate } from "@/lib/dates";
import { INTENT_ICONS } from "@/lib/suggestions/labels";
import { InspectionDueCard } from "./inspection-due-card";
@@ -20,6 +23,20 @@ import { useTaskActions } from "./use-task-actions";
const TODAY_LIST_LIMIT = 5;
/**
* Code per colony id for the task rows: the colonies list is one request
* (shared with the colonies page), asked for only when a task belongs to a colony.
*/
function useColonyCodes(tasks: Task[] | undefined): ReadonlyMap<string, string> {
const hasColonyTask = tasks?.some((task) => task.relatedType === "COLONY" && task.relatedId) ?? false;
const query = useQuery({
queryKey: ["colonies"],
queryFn: () => fetchColonies({}),
enabled: hasColonyTask,
});
return new Map((query.data ?? []).map((colony) => [colony.id, colony.code]));
}
const OPEN_RELATED_LABEL: Record<TaskRelatedType, string> = {
COLONY: "Zum Volk",
APIARY: "Zum Bienenstand",
@@ -50,6 +67,7 @@ export function TodayPanel({
const actions = useTaskActions();
const stepActions = useStepActions("next-step-heading");
const { hasPermission } = useAuth();
const colonyCodes = useColonyCodes(loadedTasks);
// The autopilot is enough to work with when the task list is missing.
const tasks = loadedTasks ?? (autopilot ? [] : undefined);
@@ -139,7 +157,7 @@ export function TodayPanel({
</h2>
<Link
href="/tasks"
className="text-sm font-medium text-primary underline-offset-4 hover:underline"
className="inline-flex min-h-11 items-center text-sm font-medium text-primary underline-offset-4 hover:underline"
>
Alle Aufgaben
</Link>
@@ -166,6 +184,7 @@ export function TodayPanel({
task={task}
now={now}
leaving={actions.leavingIds.has(task.id)}
colonyCode={task.relatedType === "COLONY" && task.relatedId ? colonyCodes.get(task.relatedId) : undefined}
onComplete={() => actions.complete(task)}
/>
))}
@@ -174,7 +193,7 @@ export function TodayPanel({
{hidden > 0 ? (
<Link
href="/tasks"
className="self-start text-sm text-muted-foreground underline-offset-4 hover:underline"
className="inline-flex min-h-11 items-center self-start text-sm text-muted-foreground underline-offset-4 hover:underline"
>
+ {hidden} weitere für heute
</Link>
@@ -195,6 +214,7 @@ interface NextStepCardProps {
function NextStepCard({ task, now, leaving, onComplete, onStart, isStarting }: NextStepCardProps) {
const href = relatedHref(task);
const { isOffline, hintId, describedBy } = useOnlineOnly();
return (
<section
aria-labelledby="next-step-heading"
@@ -222,7 +242,12 @@ function NextStepCard({ task, now, leaving, onComplete, onStart, isStarting }: N
) : null}
</div>
<div className="flex flex-wrap gap-2">
<Button size="touch" onClick={onComplete} disabled={leaving}>
<Button
size="touch"
onClick={onComplete}
disabled={leaving || isOffline}
aria-describedby={describedBy}
>
<Check aria-hidden />
{leaving ? "Wird gespeichert…" : "Erledigt"}
</Button>
@@ -231,7 +256,8 @@ function NextStepCard({ task, now, leaving, onComplete, onStart, isStarting }: N
size="touch"
variant="outline"
onClick={onStart}
disabled={isStarting || leaving}
disabled={isStarting || leaving || isOffline}
aria-describedby={describedBy}
>
<Play aria-hidden />
Beginnen
@@ -243,6 +269,7 @@ function NextStepCard({ task, now, leaving, onComplete, onStart, isStarting }: N
<ArrowRight aria-hidden />
</Button>
) : null}
<OnlineOnlyHint id={hintId} className="basis-full" />
</div>
</div>
</section>
@@ -352,11 +379,14 @@ interface TodayRowProps {
task: Task;
now: Date;
leaving: boolean;
/** Code of the colony the task is about, when known. */
colonyCode?: string;
onComplete: () => void;
}
function TodayRow({ task, now, leaving, onComplete }: TodayRowProps) {
function TodayRow({ task, now, leaving, colonyCode, onComplete }: TodayRowProps) {
const href = relatedHref(task);
const { isOffline, hintId, describedBy } = useOnlineOnly();
return (
<li className="today-row" data-leaving={leaving}>
<div>
@@ -367,7 +397,8 @@ function TodayRow({ task, now, leaving, onComplete }: TodayRowProps) {
aria-checked={leaving}
aria-label={`„${task.title}“ als erledigt markieren`}
className="honey-check"
disabled={leaving}
disabled={leaving || isOffline}
aria-describedby={describedBy}
onClick={onComplete}
>
<svg
@@ -385,10 +416,16 @@ function TodayRow({ task, now, leaving, onComplete }: TodayRowProps) {
<div className="flex min-w-0 flex-1 flex-col gap-1 py-1">
<span className="line-clamp-2 font-medium">{task.title}</span>
<span className="flex flex-wrap items-center gap-1.5">
{colonyCode ? (
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-medium">
{colonyCode}
</span>
) : null}
{task.status === "IN_PROGRESS" ? <TaskStatusPill status={task.status} /> : null}
<DueChip state={dueState(task, now)} dueDate={task.dueDate} />
<TaskSourceBadge task={task} />
</span>
<OnlineOnlyHint id={hintId} />
</div>
{href ? (
<Link
@@ -0,0 +1,65 @@
import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { act, render, screen } from "@testing-library/react";
import { toast } from "sonner";
import { Toaster } from "./sonner";
beforeAll(() => {
// jsdom has no matchMedia; Sonner's Toaster asks for the color scheme.
Object.defineProperty(window, "matchMedia", {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: () => undefined,
removeEventListener: () => undefined,
addListener: () => undefined,
removeListener: () => undefined,
dispatchEvent: () => false,
}),
});
});
afterEach(() => {
act(() => {
toast.dismiss();
});
});
describe("Toaster", () => {
it("sits at the bottom with a phone offset above the bottom nav and the quick-add button", async () => {
const { container } = render(<Toaster />);
act(() => {
toast("Verworfen");
});
await screen.findByText("Verworfen");
const list = container.ownerDocument.querySelector("[data-sonner-toaster]") as HTMLElement;
expect(list).toHaveAttribute("data-y-position", "bottom");
// One logic for both breakpoints: the CSS variable follows the app's sm breakpoint (640 px).
expect(list.style.getPropertyValue("--mobile-offset-bottom")).toBe("var(--toast-offset-bottom)");
expect(list.style.getPropertyValue("--offset-bottom")).toBe("var(--toast-offset-bottom)");
});
it("makes the action button of every toast 44 px high", async () => {
render(<Toaster />);
act(() => {
toast("Verworfen", { action: { label: "Rückgängig", onClick: () => undefined } });
});
const action = await screen.findByRole("button", { name: "Rückgängig" });
expect(action.className).toContain("h-11!");
expect(action.className).toContain("min-w-11!");
});
it("makes the close button a 44 px target", async () => {
render(<Toaster closeButton />);
act(() => {
toast("Hinweis");
});
await screen.findByText("Hinweis");
const close = document.querySelector("[data-close-button]") as HTMLElement;
expect(close.className).toContain("size-11!");
});
});
@@ -4,6 +4,13 @@ import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
// Toasts sit at the bottom: at the top they cover the page heading and the
// notification bell. One offset for every width: --toast-offset-bottom
// (globals.css) clears the bottom nav and the quick-add button below the app's
// sm breakpoint (640 px) and the sticky save bar everywhere. Sonner switches to
// its phone layout at 600 px, so both offsets take the same variable.
const TOAST_OFFSET = { bottom: "var(--toast-offset-bottom)", left: 16, right: 16 };
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
@@ -11,6 +18,9 @@ const Toaster = ({ ...props }: ToasterProps) => {
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
position="bottom-right"
offset={TOAST_OFFSET}
mobileOffset={TOAST_OFFSET}
icons={{
success: (
<CircleCheckIcon className="size-4" />
@@ -39,6 +49,10 @@ const Toaster = ({ ...props }: ToasterProps) => {
toastOptions={{
classNames: {
toast: "cn-toast",
// Sonner's own buttons are 24 px high; a tap target needs 44 px.
actionButton: "h-11! min-h-11! min-w-11! px-4!",
closeButton: "size-11!",
cancelButton: "h-11! min-h-11! min-w-11! px-4!",
},
}}
{...props}
@@ -7,7 +7,7 @@ const warnings: PlausibilityWarning[] = [
{
code: "WITHDRAWAL_ACTIVE",
severity: "WARNUNG",
message: "B3: Wartezeit nach Oxuvar endet am 20.08.2026.",
message: "B3: Wartezeit nach Oxuvar bis einschließlich 19.08. – ab 20.08. wieder erntefähig.",
refs: [{ type: "Colony", id: "c-1" }],
},
{
@@ -28,7 +28,7 @@ describe("WarningList", () => {
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."),
within(region).getByText("B3: Wartezeit nach Oxuvar bis einschließlich 19.08. – ab 20.08. wieder erntefähig."),
).toBeInTheDocument();
expect(region.querySelectorAll("svg[aria-hidden='true']")).toHaveLength(2);
});