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

@@ -4,7 +4,8 @@ import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import NewBottlingBatchPage from "./page";
import { createBottlingBatch } from "@/lib/bottling/api";
import { fetchHoneyBatches } from "@/lib/honey-batches/api";
import { fetchHoneyBatches, fetchHoneyBatchTraceability } from "@/lib/honey-batches/api";
import { fetchWithdrawalStatus } from "@/lib/treatments/api";
import { fetchProducts } from "@/lib/products/api";
import { toast } from "sonner";
@@ -13,7 +14,11 @@ vi.mock("next/navigation", () => ({
useRouter: () => ({ push }),
useSearchParams: () => new URLSearchParams("honeyBatchId=hb-1"),
}));
vi.mock("@/lib/honey-batches/api", () => ({ fetchHoneyBatches: vi.fn() }));
vi.mock("@/lib/honey-batches/api", () => ({
fetchHoneyBatches: vi.fn(),
fetchHoneyBatchTraceability: vi.fn(),
}));
vi.mock("@/lib/treatments/api", () => ({ fetchWithdrawalStatus: vi.fn() }));
vi.mock("@/lib/products/api", () => ({ fetchProducts: vi.fn() }));
vi.mock("@/lib/bottling/api", () => ({ createBottlingBatch: vi.fn() }));
vi.mock("sonner", () => ({
@@ -39,6 +44,8 @@ describe("NewBottlingBatchPage", () => {
{ id: "hb-1", batchNumber: "2026-01", amount: 40 },
] as never);
vi.mocked(fetchProducts).mockResolvedValue([] as never);
vi.mocked(fetchHoneyBatchTraceability).mockResolvedValue({ harvests: [] } as never);
vi.mocked(fetchWithdrawalStatus).mockResolvedValue([]);
vi.mocked(createBottlingBatch).mockReset();
vi.mocked(toast.success).mockReset();
vi.mocked(toast.warning).mockReset();
@@ -100,7 +107,7 @@ describe("NewBottlingBatchPage", () => {
{
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-3" }],
},
],
@@ -114,10 +121,45 @@ describe("NewBottlingBatchPage", () => {
expect(toast.warning).toHaveBeenCalledWith(
"Abfüllung angelegt · 1 Warnung",
expect.objectContaining({
description: expect.stringMatching(/B3.*20\.08\.2026/),
description: expect.stringMatching(/B3.*20.08./),
}),
);
expect(toast.success).not.toHaveBeenCalled();
expect(push).toHaveBeenCalledWith("/bottling/b-1");
});
it("shows the withdrawal of a colony behind the batch once, before saving", async () => {
vi.mocked(fetchHoneyBatchTraceability).mockResolvedValue({
harvests: [
{ id: "h-1", date: "2026-10-14T00:00:00.000Z", apiaryId: "a-1", colonies: [{ id: "c-2", code: "V-02" }] },
{ id: "h-2", date: "2026-10-15T00:00:00.000Z", apiaryId: "a-1", colonies: [{ id: "c-2", code: "V-02" }, { id: "c-3", code: "V-03" }] },
],
} as never);
vi.mocked(fetchWithdrawalStatus).mockImplementation(async (ids) =>
ids.includes("c-2")
? [{ colonyId: "c-2", activeUntil: "2026-10-27T00:00:00.000Z", lastBlockedDay: "2026-10-26", withdrawalText: "bis einschließlich 26.10. – ab 27.10. wieder erntefähig", product: "Oxuvar" }]
: [],
);
renderPage();
const hints = await screen.findAllByText(/V-02: Wartezeit nach Oxuvar bis einschließlich 26\.10\./);
expect(hints).toHaveLength(1);
expect(hints[0]).toHaveTextContent("ab 27.10. wieder erntefähig");
expect(fetchWithdrawalStatus).toHaveBeenCalledWith(expect.arrayContaining(["c-2"]), "2026-10-14");
});
it("shows no hint when the batch has no harvest inside a window", async () => {
renderPage();
await screen.findByRole("button", { name: "Abfüllung anlegen" });
expect(screen.queryByText(/Wartezeit nach/)).not.toBeInTheDocument();
});
it("says so when the withdrawal check fails instead of showing nothing", async () => {
vi.mocked(fetchHoneyBatchTraceability).mockResolvedValue({
harvests: [{ id: "h-1", date: "2026-10-14T00:00:00.000Z", apiaryId: "a-1", colonies: [{ id: "c-2", code: "V-02" }] }],
} as never);
vi.mocked(fetchWithdrawalStatus).mockRejectedValue(new Error("offline"));
renderPage();
expect(await screen.findByText("Wartezeit konnte nicht geprüft werden")).toBeInTheDocument();
});
});
@@ -4,10 +4,11 @@ import { FormEvent, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Package } from "lucide-react";
import { AlertTriangle, Package } from "lucide-react";
import { fetchHoneyBatches } from "@/lib/honey-batches/api";
import { fetchProducts } from "@/lib/products/api";
import { createBottlingBatch } from "@/lib/bottling/api";
import { useBottlingWithdrawalHints } from "@/lib/bottling/use-withdrawal-hints";
import { useFormDraft } from "@/lib/forms/use-form-draft";
import { errorMessage } from "@/lib/forms/error-message";
import { toastWarnings } from "@/lib/warnings/toast-warnings";
@@ -88,6 +89,10 @@ export default function NewBottlingBatchPage() {
queryFn: () => fetchProducts({ status: "ACTIVE" }),
});
const { hints: withdrawalHints, failed: withdrawalCheckFailed } = useBottlingWithdrawalHints(
values.honeyBatchId,
);
function set<K extends keyof BottlingFormValues>(
key: K,
value: BottlingFormValues[K],
@@ -197,6 +202,27 @@ export default function NewBottlingBatchPage() {
/>
)}
</Field>
{withdrawalCheckFailed ? (
<p role="alert" className="flex items-start gap-2 rounded-lg bg-warning/15 p-3 text-sm">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-warning" aria-hidden />
Wartezeit konnte nicht geprüft werden
</p>
) : null}
{withdrawalHints.length > 0 ? (
<div className="flex flex-col gap-1.5 rounded-lg bg-warning/15 p-3 text-sm">
<p className="flex items-center gap-2 font-medium">
<AlertTriangle className="size-4 shrink-0 text-warning" aria-hidden />
Honig aus einer Wartezeit
</p>
<ul className="flex flex-col gap-1">
{withdrawalHints.map((hint) => (
<li key={`${hint.colonyId}-${hint.product}-${hint.withdrawalText}`}>
{hint.colonyCode}: Wartezeit nach {hint.product} {hint.withdrawalText}.
</li>
))}
</ul>
</div>
) : null}
<Field label="Datum" required error={fieldErrors.date}>
{({ id, describedBy, invalid }) => (
<Input
@@ -59,7 +59,7 @@ describe("CalendarPage withdrawal spans", () => {
const link = screen.getAllByRole("link", { name: /Wartezeit B3/ })[0];
expect(link).toHaveAttribute("href", "/colonies/c-3");
const day = screen.getByRole("button", { name: /Dienstag, 11\. August.*1 Einträge/ });
const day = screen.getByRole("button", { name: /Dienstag, 11\. August.*1 Eintrag$/ });
await userEvent.click(day);
expect(within(day).getByText("Wartezeit B3 · Oxuvar")).toBeInTheDocument();
});
@@ -102,3 +102,144 @@ describe("CalendarPage withdrawal spans", () => {
expect(screen.queryByText(/Wartezeit/)).not.toBeInTheDocument();
});
});
describe("CalendarPage entries", () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date("2026-08-15T12:00:00.000Z"));
vi.mocked(fetchApiaries).mockResolvedValue([] as never);
});
afterEach(() => vi.useRealTimers());
it("names the colony or apiary on rows that do not carry it in their title", async () => {
vi.mocked(fetchCalendarEvents).mockResolvedValue([
{
id: "task:1",
type: "TASK",
date: "2026-08-12T00:00:00.000Z",
title: "Fütterung erforderlich: Futterstatus kritisch.",
relatedType: "Task",
relatedId: "t-1",
context: "V-07",
},
{
id: "h:1",
type: "HARVEST",
date: "2026-08-12T00:00:00.000Z",
title: "Ernte",
relatedType: "Apiary",
relatedId: "a-1",
context: "Teststand Nord",
},
{
id: "i:1",
type: "INSPECTION",
date: "2026-08-12T00:00:00.000Z",
title: "Durchsicht V-01",
relatedType: "Colony",
relatedId: "c-1",
context: "V-01",
},
] as never);
renderPage();
const phoneList = (await screen.findAllByText("Ernte", { selector: "span.truncate" }))[0].closest(
"div.sm\\:hidden",
) as HTMLElement;
expect(within(phoneList).getByText("V-07")).toBeInTheDocument();
expect(within(phoneList).getByText("Teststand Nord")).toBeInTheDocument();
// The title already says V-01: no second mention.
expect(within(phoneList).queryByText("V-01")).not.toBeInTheDocument();
});
it("gives month chips the full text as title and accessible name", async () => {
vi.mocked(fetchCalendarEvents).mockResolvedValue([
{
id: "withdrawal:t-1",
type: "WITHDRAWAL",
date: "2026-08-10T00:00:00.000Z",
endDate: "2026-08-12T00:00:00.000Z",
title: "Wartezeit B3 · Oxuvar",
relatedType: "Colony",
relatedId: "c-3",
detail: "bis einschließlich 12.08. – ab 13.08. wieder erntefähig",
},
]);
renderPage();
const day = await screen.findByRole("button", { name: /Dienstag, 11\. August/ });
const chip = within(day).getByText("Wartezeit B3 · Oxuvar").closest("span[title]") as HTMLElement;
expect(chip).toHaveAttribute(
"title",
"Wartezeit B3 · Oxuvar – Wartezeit bis einschließlich 12.08. – ab 13.08. wieder erntefähig",
);
});
it("labels the end of a withdrawal span in its rows", async () => {
vi.mocked(fetchCalendarEvents).mockResolvedValue([
{
id: "withdrawal:t-1",
type: "WITHDRAWAL",
date: "2026-08-10T00:00:00.000Z",
endDate: "2026-08-12T00:00:00.000Z",
title: "Wartezeit B3 · Oxuvar",
relatedType: "Colony",
relatedId: "c-3",
detail: "bis einschließlich 12.08. – ab 13.08. wieder erntefähig",
},
]);
renderPage();
const link = (await screen.findAllByRole("link", { name: /Wartezeit B3/ }))[0];
expect(link).toHaveTextContent("Wartezeit bis einschließlich 12.08. – ab 13.08. wieder erntefähig");
});
it("counts a day with several entries in the plural", async () => {
vi.mocked(fetchCalendarEvents).mockResolvedValue([
{ id: "a", type: "TASK", date: "2026-08-12T00:00:00.000Z", title: "A", relatedType: "Task", relatedId: "1" },
{ id: "b", type: "TASK", date: "2026-08-12T00:00:00.000Z", title: "B", relatedType: "Task", relatedId: "2" },
]);
renderPage();
expect(await screen.findByRole("button", { name: /Mittwoch, 12\. August.*2 Einträge/ })).toBeInTheDocument();
});
it("matches the colony code as a whole word (V-1 is not hidden by V-10)", async () => {
vi.mocked(fetchCalendarEvents).mockResolvedValue([
{
id: "i:1",
type: "INSPECTION",
date: "2026-08-12T00:00:00.000Z",
title: "Durchsicht V-10",
relatedType: "Colony",
relatedId: "c-1",
context: "V-1",
},
]);
renderPage();
const row = (await screen.findAllByRole("link", { name: /Durchsicht V-10/ }))[0];
expect(within(row).getByText("V-1", { selector: "span.shrink-0" })).toBeInTheDocument();
});
it("says the withdrawal span once on its first day and marks the following days compactly", async () => {
vi.mocked(fetchCalendarEvents).mockResolvedValue([
{
id: "withdrawal:t-1",
type: "WITHDRAWAL",
date: "2026-08-10T00:00:00.000Z",
endDate: "2026-08-12T00:00:00.000Z",
title: "Wartezeit B3 · Oxuvar",
relatedType: "Colony",
relatedId: "c-3",
context: "B3",
product: "Ameisensäure 60 %",
detail: "bis einschließlich 12.08. – ab 13.08. wieder erntefähig",
},
]);
renderPage();
const rows = await screen.findAllByText("Wartezeit B3 · Oxuvar", { selector: "span.truncate" });
const phoneList = rows[0].closest("div.sm\\:hidden") as HTMLElement;
expect(within(phoneList).getAllByText(/wieder erntefähig/)).toHaveLength(1);
expect(within(phoneList).getAllByText(/Wartezeit läuft weiter/)).toHaveLength(2);
// The product is on its own line, not cut off with the title.
expect(within(phoneList).getByText(/Ameisensäure 60 %/)).toBeInTheDocument();
expect(within(phoneList).getAllByRole("link", { name: /Wartezeit/ })).toHaveLength(3);
});
});
@@ -26,6 +26,7 @@ import { ErrorState, LoadingState } from "@/components/query-state";
import { RecordSelect } from "@/components/form/record-select";
import { cn } from "@/lib/utils";
import { toLocalIsoDate } from "@/lib/dates";
import { entryCountText } from "@/lib/text/plural";
const WEEKDAYS = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
@@ -59,6 +60,36 @@ function eventDayKeys(event: CalendarEvent): string[] {
);
}
/** True when `word` stands in `text` as a whole token: "V-1" is not said by "V-10". */
function mentionsWord(text: string, word: string): boolean {
const isWordChar = (char: string | undefined) => char !== undefined && /[A-Za-z0-9_-]/.test(char);
let from = text.indexOf(word);
while (from !== -1) {
const end = from + word.length;
if (!isWordChar(text[from - 1]) && !isWordChar(text[end])) return true;
from = text.indexOf(word, from + 1);
}
return false;
}
/** Colony code or apiary name, unless the title already says it. */
function eventContext(event: CalendarEvent): string | null {
const context = event.context || null;
if (!context) return null;
return mentionsWord(event.title, context) ? null : context;
}
/** For a withdrawal span: until when honey is blocked and when it is free again. */
function eventNote(event: CalendarEvent): string | null {
if (event.type !== "WITHDRAWAL") return null;
return event.detail ? `Wartezeit ${event.detail}` : null;
}
/** Everything a row says, as one text - the tooltip of a truncated month chip. */
function eventFullText(event: CalendarEvent): string {
return [event.title, eventContext(event), eventNote(event)].filter(Boolean).join(" – ");
}
function eventHref(event: CalendarEvent): string | null {
if (event.relatedType === "Task") return "/tasks";
if (event.relatedType === "Colony") return `/colonies/${event.relatedId}`;
@@ -82,15 +113,36 @@ function dayHeading(date: Date): string {
return date.toLocaleDateString("de-DE", { weekday: "long", day: "numeric", month: "long" });
}
function EventRow({ event }: { event: CalendarEvent }) {
function EventRow({ event, isContinuation }: { event: CalendarEvent; isContinuation: boolean }) {
const meta = EVENT_META[event.type];
const Icon = meta.icon;
const href = eventHref(event);
const content = (
<span className={cn("flex min-h-11 items-center gap-2 rounded-lg px-3 text-sm", meta.tone)}>
const context = eventContext(event);
const note = isContinuation ? null : eventNote(event);
const product = isContinuation ? null : (event.product ?? null);
const content = isContinuation ? (
<span className={cn("flex min-h-11 items-center gap-2 rounded-lg px-3 text-xs", meta.tone)}>
<Icon className="size-4 shrink-0" aria-hidden />
<span className="shrink-0 font-medium text-muted-foreground">{meta.label}</span>
<span className="truncate text-muted-foreground">
{[context, event.title].filter(Boolean).join(" · ")} – Wartezeit läuft weiter
</span>
</span>
) : (
<span className={cn("flex min-h-11 items-center gap-2 rounded-lg px-3 py-1 text-sm", meta.tone)}>
<Icon className="size-4 shrink-0" aria-hidden />
<span className="shrink-0 text-xs font-medium text-muted-foreground">{meta.label}</span>
<span className="truncate">{event.title}</span>
<span className="flex min-w-0 flex-col">
<span className="flex min-w-0 items-baseline gap-2">
{context ? <span className="shrink-0 font-medium">{context}</span> : null}
<span className="truncate">{event.title}</span>
</span>
{product || note ? (
<span className="text-xs text-muted-foreground">
{[product, note].filter(Boolean).join(" – ")}
</span>
) : null}
</span>
</span>
);
return href ? (
@@ -106,10 +158,13 @@ function DayEvents({
date,
events,
isToday,
isContinuation = () => false,
}: {
date: Date;
events: CalendarEvent[];
isToday: boolean;
/** True for a withdrawal span that already showed in full on an earlier day of the list. */
isContinuation?: (event: CalendarEvent) => boolean;
}) {
return (
<section className="flex flex-col gap-2">
@@ -127,7 +182,7 @@ function DayEvents({
<ul className="flex flex-col gap-1.5">
{events.map((event) => (
<li key={event.id}>
<EventRow event={event} />
<EventRow event={event} isContinuation={isContinuation(event)} />
</li>
))}
</ul>
@@ -180,6 +235,14 @@ export default function CalendarPage() {
(day) =>
day.getMonth() === month.getMonth() && (eventsByDay.get(toLocalIsoDate(day))?.length ?? 0) > 0,
);
// A withdrawal span is told in full on the first listed day, then only marked.
const firstShownDay = new Map<string, string>();
for (const day of daysOfMonthWithEvents) {
const key = toLocalIsoDate(day);
for (const event of eventsByDay.get(key) ?? []) {
if (event.type === "WITHDRAWAL" && !firstShownDay.has(event.id)) firstShownDay.set(event.id, key);
}
}
const selectedDay = gridDays.find((day) => toLocalIsoDate(day) === selectedKey);
function goToMonth(offset: number) {
@@ -258,6 +321,9 @@ export default function CalendarPage() {
date={day}
events={eventsByDay.get(toLocalIsoDate(day)) ?? []}
isToday={toLocalIsoDate(day) === todayKey}
isContinuation={(event) =>
event.type === "WITHDRAWAL" && firstShownDay.get(event.id) !== toLocalIsoDate(day)
}
/>
))
)}
@@ -286,7 +352,7 @@ export default function CalendarPage() {
type="button"
onClick={() => setSelectedKey(key)}
aria-pressed={isSelected}
aria-label={`${dayHeading(day)}${isToday ? ", heute" : ""}: ${dayEvents.length} Einträge`}
aria-label={`${dayHeading(day)}${isToday ? ", heute" : ""}: ${entryCountText(dayEvents.length)}`}
className={cn(
"flex min-h-24 flex-col gap-1 border-r border-b p-1.5 text-left last:border-r-0",
isCurrentMonth ? "bg-card" : "bg-muted/40",
@@ -317,13 +383,16 @@ export default function CalendarPage() {
return (
<span
key={event.id}
title={eventFullText(event)}
className={cn(
"flex items-center gap-1 truncate rounded px-1 py-0.5 text-xs",
meta.tone,
)}
>
<Icon className="size-3 shrink-0" aria-hidden />
<span className="truncate">{event.title}</span>
<span className="truncate">
{[eventContext(event), event.title].filter(Boolean).join(" · ")}
</span>
</span>
);
})}
@@ -121,6 +121,7 @@ describe("ColonyActions – colonies that need attention", () => {
for (const name of ["Umsetzen", "Ableger bilden", "Vereinigen", "Auflösen"]) {
expect(screen.getByRole("button", { name })).toBeEnabled();
expect(screen.getByRole("button", { name })).toHaveClass("min-h-11");
}
expect(screen.queryByRole("button", { name: "Reaktivieren" })).not.toBeInTheDocument();
});
@@ -95,7 +95,7 @@ function MoveDialog({ colony }: { colony: Colony }) {
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button variant="outline" disabled={!isLivingColony(colony)}>
<Button variant="outline" className="min-h-11" disabled={!isLivingColony(colony)}>
Umsetzen
</Button>
}
@@ -216,7 +216,7 @@ function SplitDialog({ colony }: { colony: Colony }) {
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button variant="outline" disabled={!isLivingColony(colony)}>
<Button variant="outline" className="min-h-11" disabled={!isLivingColony(colony)}>
Ableger bilden
</Button>
}
@@ -325,7 +325,7 @@ function AssignQueenDialog({ colony }: { colony: Colony }) {
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button variant="outline" disabled={!isLivingColony(colony)}>
<Button variant="outline" className="min-h-11" disabled={!isLivingColony(colony)}>
{currentQueen ? `Königin wechseln (${currentQueen.code})` : "Königin zuweisen"}
</Button>
}
@@ -398,7 +398,7 @@ function MergeDialog({ colony }: { colony: Colony }) {
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button variant="outline" disabled={!isLivingColony(colony)}>
<Button variant="outline" className="min-h-11" disabled={!isLivingColony(colony)}>
Vereinigen
</Button>
}
@@ -461,6 +461,7 @@ function DissolveButton({ colony }: { colony: Colony }) {
return (
<ConfirmDialog
triggerLabel="Auflösen"
triggerClassName="min-h-11"
triggerDisabled={!isLivingColony(colony) || mutation.isPending}
title="Volk auflösen?"
description={`„${colony.code}“ wird als aufgelöst markiert und aus den aktiven Völkern entfernt. Die Historie bleibt erhalten und das Volk kann jederzeit wieder reaktiviert werden.`}
@@ -4,6 +4,7 @@ import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ColonyEditCard } from "./colony-edit-card";
import { updateColony } from "@/lib/colonies/api";
import { fetchApiaries } from "@/lib/apiaries/api";
import type { Colony } from "@/lib/colonies/types";
vi.mock("@/lib/colonies/api", () => ({ updateColony: vi.fn() }));
@@ -228,4 +229,19 @@ describe("ColonyEditCard – legacy strength after clearing", () => {
expect(vi.mocked(updateColony).mock.calls[1][1]).not.toHaveProperty("strength");
});
it("shows the cached apiary name read-only while the apiary list cannot be loaded", async () => {
vi.mocked(fetchApiaries).mockRejectedValueOnce(new Error("offline"));
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
client.setQueryData(["apiaries", "apiary-1"], { id: "apiary-1", name: "Obstwiese" });
render(
<QueryClientProvider client={client}>
<ColonyEditCard colony={colony} />
</QueryClientProvider>,
);
expect(await screen.findByText("Obstwiese")).toBeInTheDocument();
expect(screen.queryByRole("combobox", { name: /Bienenstand/ })).not.toBeInTheDocument();
expect(screen.queryByText("Wird geladen…")).not.toBeInTheDocument();
});
});
@@ -89,10 +89,18 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
);
const [notes, setNotes] = useState(colony.notes ?? "");
const { data: apiaries } = useQuery({
const apiariesQuery = useQuery({
queryKey: ["apiaries"],
queryFn: () => fetchApiaries(),
});
const apiaries = apiariesQuery.data;
// Without the list (offline, or it failed) the select would sit on "Wird
// geladen…" for good: show the apiary by its cached name instead.
const apiaryListUnavailable = !apiaries && apiariesQuery.fetchStatus !== "fetching";
const cachedApiaryName =
apiaries?.find((apiary) => apiary.id === colony.apiaryId)?.name ??
queryClient.getQueryData<{ name: string }>(["apiaries", colony.apiaryId])?.name ??
null;
const updateMutation = useMutation({
mutationFn: () =>
@@ -136,15 +144,21 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="colony-apiary">Bienenstand</Label>
<RecordSelect
id="colony-apiary"
value={apiaryId}
// A colony always stands somewhere: there is no "none" to pick.
onChange={(value) => setApiaryId(value || apiaryId)}
items={apiaries}
getLabel={(apiary) => apiary.name}
placeholder="Bienenstand wählen"
/>
{apiaryListUnavailable ? (
<p id="colony-apiary" className="min-h-11 content-center text-sm">
{cachedApiaryName ?? "Bienenstand derzeit nicht ladbar"}
</p>
) : (
<RecordSelect
id="colony-apiary"
value={apiaryId}
// A colony always stands somewhere: there is no "none" to pick.
onChange={(value) => setApiaryId(value || apiaryId)}
items={apiaries}
getLabel={(apiary) => apiary.name}
placeholder="Bienenstand wählen"
/>
)}
<p className="text-xs text-muted-foreground">
Eine Korrektur wird wie ein Umsetzen in der Standort-Historie
festgehalten.
@@ -57,7 +57,7 @@ export function ColonyFeedingsCard({ colonyId }: { colonyId: string }) {
{(feedings) =>
feedings.map((feeding) => (
<div key={feeding.id} className="rounded-md border p-3">
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center justify-between gap-x-2">
<p className="font-medium">
{new Date(feeding.date).toLocaleDateString("de-DE")} ·{" "}
{FEED_TYPE_LABELS[feeding.feedType]}
@@ -44,7 +44,7 @@ export function ColonyInspectionsCard({ colonyId }: { colonyId: string }) {
{(inspections) =>
inspections.map((inspection) => (
<div key={inspection.id} className="rounded-md border p-3">
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center justify-between gap-x-2">
<p className="font-medium">
{new Date(inspection.date).toLocaleDateString("de-DE")}
</p>
@@ -46,7 +46,7 @@ export function ColonySwarmControlCard({ colonyId }: { colonyId: string }) {
{(checks) =>
checks.map((check) => (
<div key={check.id} className="rounded-md border p-3">
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center justify-between gap-x-2">
<p className="font-medium">{new Date(check.date).toLocaleDateString("de-DE")}</p>
<SwarmControlEditDialog check={check} />
</div>
@@ -44,7 +44,7 @@ export function ColonyTreatmentsCard({ colonyId }: { colonyId: string }) {
{(treatments) =>
treatments.map((treatment) => (
<div key={treatment.id} className="rounded-md border p-3">
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center justify-between gap-x-2">
<p className="font-medium">
{new Date(treatment.date).toLocaleDateString("de-DE")} · {treatment.product}
</p>
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { render, screen, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ColonyVarroaCard } from "./colony-varroa";
import { fetchColonyVarroaMeasurements, fetchVarroaThresholdRange } from "@/lib/varroa/api";
@@ -188,4 +188,33 @@ describe("ColonyVarroaCard trend", () => {
expect(screen.getByText(/Probe 500 Bienen · 2,4 %/)).toBeInTheDocument();
expect(screen.getByText(/12 Milben · 12,4 Milben\/Tag/)).toBeInTheDocument();
});
it("lets a row's heading and edit button wrap instead of overflowing the card", async () => {
renderCard();
await screen.findByRole("img");
const row = screen.getAllByText(/^\d{1,2}\.\d{1,2}\.\d{4} · /)[0].parentElement;
expect(row).toHaveClass("flex-wrap");
});
it("lists the measurements newest first", async () => {
renderCard();
await screen.findByRole("img");
const dates = screen
.getAllByText(/^\d{1,2}\.\d{1,2}\.\d{4} · /)
.map((node) => node.textContent?.split(" · ")[0]);
expect(dates).toEqual(["15.6.2026", "8.6.2026", "1.6.2026"]);
});
it("labels the date axis and explains the lines in a legend", async () => {
renderCard();
const chart = await screen.findByRole("img");
const axis = within(chart as unknown as HTMLElement);
expect(axis.getByText("01.06.2026")).toBeInTheDocument();
expect(axis.getByText("15.06.2026")).toBeInTheDocument();
await screen.findByText("Rote Grenze");
const legend = screen.getByRole("list", { name: "Legende" });
expect(within(legend).getByText("Milbenfall pro Tag")).toBeInTheDocument();
expect(within(legend).getByText("Rote Grenze")).toBeInTheDocument();
expect(within(legend).getByText("Gelbe Grenze")).toBeInTheDocument();
});
});
@@ -18,12 +18,13 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { VarroaEditDialog } from "./varroa-edit-dialog";
const CHART_WIDTH = 300;
const CHART_HEIGHT = 120;
const CHART_HEIGHT = 136;
const PAD_X = 12;
const PAD_TOP = 12;
const PAD_BOTTOM = 12;
const PAD_BOTTOM = 28;
const MARKER_RADIUS = 4;
const LABEL_GAP = 4;
const AXIS_LABEL_SIZE = 10;
const MAX_BAND_MONTHS = 24;
const ONE_HOUR_MS = 60 * 60 * 1000;
@@ -74,6 +75,13 @@ function trendPoints(measurements: VarroaMeasurement[]): TrendPoint[] {
.sort((left, right) => left.time - right.time);
}
/** Newest first, like the inspections; on the same day the later entry comes first. */
function newestFirst(measurements: VarroaMeasurement[]): VarroaMeasurement[] {
return [...measurements].sort(
(left, right) => right.date.localeCompare(left.date) || right.createdAt.localeCompare(left.createdAt),
);
}
function nextMonthStart(start: number): number {
const date = new Date(start);
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 1);
@@ -194,57 +202,118 @@ function VarroaTrendChart({
const yellowPath = stepPath(limits, "yellowFrom", xOf, yOf, range);
const monthly = monthLimitsText(limits, range);
const axisY = CHART_HEIGHT - PAD_BOTTOM + MARKER_RADIUS * 2;
const labelY = CHART_HEIGHT - LABEL_GAP;
return (
<svg
viewBox={`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`}
className="w-full text-primary"
role="img"
aria-labelledby={titleId}
aria-describedby={descId}
>
<title id={titleId}>{`Milbenfall pro Tag. ${summary}`}</title>
<desc id={descId}>
{`${points.length} Messungen von ${formatDate(first.time)} bis ${formatDate(last.time)}. ${summary}.${
monthly ? ` Rote Grenze je Monat: ${monthly}.` : ""
}`}
</desc>
{redPath || yellowPath ? (
<g className="text-muted-foreground" fill="none" stroke="currentColor">
{yellowPath ? (
<path data-band="yellow" d={yellowPath} strokeWidth={1} strokeDasharray="2 3" />
) : null}
{redPath ? (
<path data-band="red" d={redPath} strokeWidth={1.5} strokeDasharray="6 4" />
) : null}
</g>
) : null}
{lastLimit ? (
<div className="flex flex-col gap-2">
<svg
viewBox={`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`}
className="w-full text-primary"
role="img"
aria-labelledby={titleId}
aria-describedby={descId}
>
<title id={titleId}>{`Milbenfall pro Tag. ${summary}`}</title>
<desc id={descId}>
{`${points.length} Messungen von ${formatDate(first.time)} bis ${formatDate(last.time)}. ${summary}.${
monthly ? ` Rote Grenze je Monat: ${monthly}.` : ""
}`}
</desc>
{redPath || yellowPath ? (
<g className="text-muted-foreground" fill="none" stroke="currentColor">
{yellowPath ? (
<path data-band="yellow" d={yellowPath} strokeWidth={1} strokeDasharray="2 3" />
) : null}
{redPath ? (
<path data-band="red" d={redPath} strokeWidth={1.5} strokeDasharray="6 4" />
) : null}
</g>
) : null}
{lastLimit ? (
<text
x={CHART_WIDTH - PAD_X}
y={yOf(lastLimit.redAbove) - LABEL_GAP}
textAnchor="end"
fontSize={10}
className="fill-muted-foreground"
>
{`Grenze ${formatThreshold(lastLimit.redAbove)}/Tag`}
</text>
) : null}
<line
x1={PAD_X}
x2={CHART_WIDTH - PAD_X}
y1={axisY}
y2={axisY}
stroke="currentColor"
strokeWidth={0.5}
className="text-muted-foreground"
/>
<text x={PAD_X} y={labelY} fontSize={AXIS_LABEL_SIZE} className="fill-muted-foreground">
{formatDate(first.time)}
</text>
<text
x={CHART_WIDTH - PAD_X}
y={yOf(lastLimit.redAbove) - LABEL_GAP}
y={labelY}
textAnchor="end"
fontSize={10}
fontSize={AXIS_LABEL_SIZE}
className="fill-muted-foreground"
>
{`Grenze ${formatThreshold(lastLimit.redAbove)}/Tag`}
{formatDate(last.time)}
</text>
) : null}
<polyline points={line} fill="none" stroke="currentColor" strokeWidth={2} />
{points.map((p) => (
<circle
key={p.id}
cx={xOf(p.time)}
cy={yOf(p.rate)}
r={MARKER_RADIUS}
fill="currentColor"
className="stroke-background"
strokeWidth={2}
/>
))}
<polyline points={line} fill="none" stroke="currentColor" strokeWidth={2} />
{points.map((p) => (
<circle
key={p.id}
cx={xOf(p.time)}
cy={yOf(p.rate)}
r={MARKER_RADIUS}
fill="currentColor"
className="stroke-background"
strokeWidth={2}
/>
))}
</svg>
<ChartLegend hasRed={redPath !== ""} hasYellow={yellowPath !== ""} />
</div>
);
}
function LegendSwatch({ dash, width }: { dash?: string; width: number }) {
return (
<svg width="24" height="8" viewBox="0 0 24 8" aria-hidden className="shrink-0">
<line x1="0" x2="24" y1="4" y2="4" stroke="currentColor" strokeWidth={width} strokeDasharray={dash} />
</svg>
);
}
/** Says what each line of the chart is - the limit lines are dashed and not obvious. */
function ChartLegend({ hasRed, hasYellow }: { hasRed: boolean; hasYellow: boolean }) {
return (
<ul aria-label="Legende" className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<li className="flex items-center gap-1.5">
<span className="text-primary">
<LegendSwatch width={2} />
</span>
<span>Milbenfall pro Tag</span>
</li>
{hasRed ? (
<li className="flex items-center gap-1.5">
<LegendSwatch width={1.5} dash="6 4" />
<span>Rote Grenze</span>
</li>
) : null}
{hasYellow ? (
<li className="flex items-center gap-1.5">
<LegendSwatch width={1} dash="2 3" />
<span>Gelbe Grenze</span>
</li>
) : null}
</ul>
);
}
function MeasurementDetails({ measurement }: { measurement: VarroaMeasurement }) {
const note = ratingNote(measurement.rating);
return (
@@ -296,9 +365,9 @@ export function ColonyVarroaCard({ colonyId }: { colonyId: string }) {
{(measurements) => (
<>
<VarroaTrendChart points={trendPoints(measurements)} limits={limits} />
{measurements.map((measurement) => (
{newestFirst(measurements).map((measurement) => (
<div key={measurement.id} className="rounded-md border p-3">
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center justify-between gap-x-2">
<p className="font-medium">
{new Date(measurement.date).toLocaleDateString("de-DE")} ·{" "}
{VARROA_METHOD_LABELS[measurement.method]}
@@ -39,10 +39,33 @@ describe("EditTriggerButton", () => {
expect(screen.getByTestId("edit")).toHaveTextContent("Bearbeiten");
});
it("wraps its text on a narrow screen instead of overflowing", () => {
it("names the reason in visible text next to the offline button", () => {
act(() => reportRequestOutcome("network-error"));
render(<EditTriggerButton />);
expect(screen.getByRole("button")).toHaveClass("h-auto", "whitespace-normal");
expect(screen.getByText("Nur mit Verbindung")).toBeVisible();
});
it("is at least 44 px high", () => {
render(<EditTriggerButton />);
expect(screen.getByRole("button")).toHaveClass("min-h-11");
});
it("keeps its label on one line so it never crowds the heading next to it", () => {
act(() => reportRequestOutcome("network-error"));
render(<EditTriggerButton />);
expect(screen.getByRole("button")).toHaveClass("shrink-0", "whitespace-nowrap");
expect(screen.getByRole("button")).not.toHaveClass("whitespace-normal");
});
it("lets the offline button and its hint wrap inside the card instead of overflowing it", () => {
act(() => reportRequestOutcome("network-error"));
render(<EditTriggerButton />);
const wrapper = screen.getByText("Nur mit Verbindung").parentElement;
expect(wrapper).toHaveClass("ml-auto", "max-w-full", "flex-wrap");
expect(wrapper).not.toHaveClass("shrink-0");
});
});
@@ -1,7 +1,7 @@
"use client";
import { ComponentProps } from "react";
import { Pencil } from "lucide-react";
import { Pencil, WifiOff } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useSessionMode } from "@/lib/auth/auth-context";
import { useConnectivity } from "@/lib/offline/connectivity";
@@ -11,7 +11,10 @@ import { cn } from "@/lib/utils";
* The "Bearbeiten" trigger of every record in a list: at least 40 px high on
* a phone, so it can be hit with a gloved thumb. Saved records can only be
* changed with a connection (edits are not queued), so offline - or in an
* offline session without a token - it is disabled and says why.
* offline session without a token - it is disabled and says why: a "no
* network" icon and a tooltip for the eye, the full sentence for screen
* readers. The visible label stays one short word so it never pushes the
* heading of its row out of the way on a phone.
*/
export function EditTriggerButton({
disabled,
@@ -21,17 +24,30 @@ export function EditTriggerButton({
const connectivity = useConnectivity();
const sessionMode = useSessionMode();
const needsNetwork = connectivity === "offline" || sessionMode === "offline";
return (
const button = (
<Button
variant="ghost"
{...props}
disabled={disabled || needsNetwork}
className={cn(needsNetwork && "h-auto whitespace-normal text-left", className)}
title={needsNetwork ? "Bearbeiten braucht Netz" : props.title}
aria-label={needsNetwork ? "Bearbeiten braucht Netz" : props["aria-label"]}
className={cn("min-h-11", needsNetwork && "shrink-0 whitespace-nowrap", className)}
>
<Pencil className="size-4" aria-hidden />
{needsNetwork ? "Bearbeiten braucht Netz" : "Bearbeiten"}
{needsNetwork ? (
<WifiOff className="size-4" aria-hidden />
) : (
<Pencil className="size-4" aria-hidden />
)}
Bearbeiten
</Button>
);
if (!needsNetwork) return button;
return (
<span className="ml-auto inline-flex max-w-full flex-wrap items-center justify-end gap-x-2">
{button}
<span className="text-xs text-muted-foreground">Nur mit Verbindung</span>
</span>
);
}
interface DialogSubmitBarProps {
@@ -1,12 +1,14 @@
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, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import DashboardPage from "./page";
import { fetchAutopilotToday } from "@/lib/autopilot/api";
import { stepFixture, todayFixture } from "@/lib/autopilot/step-fixtures";
import { fetchDashboardAlerts, fetchDashboardSummary } from "@/lib/dashboard/api";
import { fetchTasks } from "@/lib/tasks/api";
import { fetchColonies } from "@/lib/colonies/api";
import { ApiError } from "@/lib/auth/api-client";
import { reportRequestOutcome } from "@/lib/offline/connectivity";
import type { Task } from "@/lib/tasks/types";
vi.mock("@/lib/auth/auth-context", () => ({
@@ -26,6 +28,7 @@ vi.mock("@/lib/autopilot/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/autopilot/api")>()),
fetchAutopilotToday: vi.fn(),
}));
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() }));
vi.mock("sonner", () => ({
toast: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn() }),
}));
@@ -61,6 +64,10 @@ function renderPage() {
}
describe("DashboardPage – Heute", () => {
afterEach(() => {
act(() => reportRequestOutcome("reached-server"));
});
beforeEach(() => {
vi.mocked(fetchDashboardSummary).mockResolvedValue({
apiariesActive: 1,
@@ -172,4 +179,77 @@ describe("DashboardPage – Heute", () => {
expect(await screen.findByRole("link", { name: "Erfassen" })).toHaveClass("bg-primary");
expect(screen.getByRole("link", { name: "Runde starten" })).not.toHaveClass("bg-primary");
});
it("offline: shows the „Stand“ of the cached data and switches off the decisions of the next step", async () => {
vi.mocked(fetchAutopilotToday).mockResolvedValue(
todayFixture({
nextStep: {
...stepFixture({
title: "Honigraum",
intent: "HONEY_SUPER",
action: { type: "ACCEPT", suggestionId: "s1" },
}),
colonyId: "c1",
colonyCode: "A1",
},
}),
);
act(() => reportRequestOutcome("network-error"));
renderPage();
expect(await screen.findByRole("button", { name: "Übernehmen" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Verwerfen" })).toBeDisabled();
expect(screen.getAllByText("Nur mit Verbindung").length).toBeGreaterThan(0);
expect(screen.getAllByText(/^Stand: \d{2}\.\d{2}\. \d{2}:\d{2}$/)).toHaveLength(1);
});
it("online: shows no „Stand“", async () => {
vi.mocked(fetchAutopilotToday).mockResolvedValue(todayFixture({}));
renderPage();
await screen.findByRole("heading", { name: "Überblick" });
expect(screen.queryByText(/^Stand:/)).not.toBeInTheDocument();
});
it("offline: task checkboxes and „Erledigt“/„Beginnen“ are off with „Nur mit Verbindung“ (task changes are not queued)", async () => {
vi.mocked(fetchAutopilotToday).mockRejectedValue(new TypeError("Failed to fetch"));
vi.mocked(fetchTasks).mockResolvedValue([
overdueTask(),
{ ...overdueTask(), id: "t2", title: "Zweite Aufgabe" },
]);
act(() => reportRequestOutcome("network-error"));
renderPage();
expect(await screen.findByRole("button", { name: "Erledigt" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Beginnen" })).toBeDisabled();
expect(screen.getByRole("checkbox", { name: /Zweite Aufgabe/ })).toBeDisabled();
expect(screen.getAllByText("Nur mit Verbindung").length).toBeGreaterThan(0);
});
it("gives the dashboard text links a 44 px target", async () => {
vi.mocked(fetchAutopilotToday).mockResolvedValue(todayFixture({}));
renderPage();
expect(await screen.findByRole("link", { name: "Alle Aufgaben" })).toHaveClass("min-h-11");
});
it("names the colony in „Außerdem heute“ rows from the colonies list", async () => {
vi.mocked(fetchAutopilotToday).mockResolvedValue(todayFixture({}));
vi.mocked(fetchColonies).mockResolvedValue([{ id: "col-7", code: "V-07" }] as never);
vi.mocked(fetchTasks).mockResolvedValue([
overdueTask(),
{
...overdueTask(),
id: "t3",
title: "Fütterung erforderlich",
relatedType: "COLONY",
relatedId: "col-7",
},
]);
renderPage();
const row = (await screen.findByText("Fütterung erforderlich")).closest("li") as HTMLElement;
expect(await within(row).findByText("V-07")).toBeInTheDocument();
expect(fetchColonies).toHaveBeenCalledTimes(1);
});
});
@@ -25,6 +25,7 @@ import { fetchTasks } from "@/lib/tasks/api";
import { tasksForToday } from "@/lib/tasks/today";
import { Button } from "@/components/ui/button";
import { HoneycombPattern } from "@/components/honeycomb-pattern";
import { StandNote } from "@/components/offline/stand-note";
import { TodayPanel } from "@/components/today/today-panel";
function alertHref(alert: DashboardAlert): string | null {
@@ -89,14 +90,16 @@ const ALERT_META: Record<
export default function DashboardPage() {
const { user } = useAuth();
const [now] = useState(() => new Date());
const { data: summary } = useQuery({
const summaryQuery = useQuery({
queryKey: ["dashboard", "summary"],
queryFn: fetchDashboardSummary,
});
const { data: alerts, isLoading: alertsLoading } = useQuery({
const { data: summary } = summaryQuery;
const alertsQuery = useQuery({
queryKey: ["dashboard", "alerts"],
queryFn: fetchDashboardAlerts,
});
const { data: alerts, isLoading: alertsLoading } = alertsQuery;
const tasksQuery = useQuery({
queryKey: ["tasks", "dashboard"],
queryFn: () => fetchTasks({}),
@@ -119,6 +122,14 @@ export default function DashboardPage() {
(autopilotError instanceof ApiError && autopilotError.status === 403));
const otherAlerts = (alerts ?? []).filter((alert) => alert.type !== "OVERDUE_TASK");
const todayCount = tasksQuery.data ? tasksForToday(tasksQuery.data, now).length : null;
// Offline everything shown is from the cache: the oldest part sets the „Stand“.
const loadedAt = [summaryQuery, alertsQuery, tasksQuery, autopilotQuery]
.map((query) => query.dataUpdatedAt)
.filter((updatedAt) => updatedAt > 0);
const hasStaleData = [summaryQuery, alertsQuery, tasksQuery, autopilotQuery].some(
(query) => query.isError && query.data !== undefined,
);
const standAt = loadedAt.length > 0 ? Math.min(...loadedAt) : 0;
const firstName = user?.name.trim().split(/\s+/)[0];
const tiles: StatTile[] = summary
@@ -161,6 +172,7 @@ export default function DashboardPage() {
</p>
</div>
</header>
<StandNote updatedAt={standAt} isStale={hasStaleData} className="-mt-5" />
{autopilotError && !isAutopilotSilent ? (
<div
@@ -211,7 +223,7 @@ export default function DashboardPage() {
</span>
{href ? (
<Link
className="shrink-0 rounded-lg px-2 py-2 font-medium text-primary underline-offset-4 hover:underline"
className="inline-flex min-h-11 shrink-0 items-center rounded-lg px-2 font-medium text-primary underline-offset-4 hover:underline"
href={href}
>
Ansehen
@@ -100,7 +100,7 @@ describe("NewHarvestPage", () => {
it("checks the withdrawal period at the harvest date and again when it changes", async () => {
vi.mocked(fetchWithdrawalStatus).mockImplementation(async (_ids, at) =>
at === "2026-07-01"
? [{ colonyId: "colony-2", activeUntil: "2026-07-10T00:00:00.000Z", product: "Oxuvar" }]
? [{ colonyId: "colony-2", activeUntil: "2026-07-10T00:00:00.000Z", lastBlockedDay: "2026-07-09", withdrawalText: "bis einschließlich 09.07. – ab 10.07. wieder erntefähig", product: "Oxuvar" }]
: [],
);
renderPage();
@@ -114,7 +114,7 @@ describe("NewHarvestPage", () => {
await userEvent.clear(date);
await userEvent.type(date, "2026-07-01");
expect(await screen.findByText(/Wartezeit \(Oxuvar\) bis 10\.7\.2026/)).toBeInTheDocument();
expect(await screen.findByText(/Wartezeit nach Oxuvar bis einschließlich 09\.07\. – ab 10\.07\. wieder erntefähig/)).toBeInTheDocument();
expect(fetchWithdrawalStatus).toHaveBeenLastCalledWith(
["colony-1", "colony-2"],
"2026-07-01",
@@ -153,7 +153,7 @@ describe("NewHarvestPage", () => {
{
code: "WITHDRAWAL_ACTIVE",
severity: "WARNUNG",
message: "V-01: Wartezeit nach Oxuvar endet am 20.08.2026.",
message: "V-01: Wartezeit nach Oxuvar bis einschließlich 19.08. – ab 20.08. wieder erntefähig.",
refs: [],
},
],
@@ -165,7 +165,7 @@ describe("NewHarvestPage", () => {
expect(toast.warning).toHaveBeenCalledWith(
"Ernte gespeichert · 1 Warnung",
expect.objectContaining({ description: expect.stringContaining("20.08.2026") }),
expect.objectContaining({ description: expect.stringContaining("20.08.") }),
);
expect(toast.success).not.toHaveBeenCalled();
expect(push).toHaveBeenCalledWith("/harvests/h-1");
@@ -270,10 +270,8 @@ export default function NewHarvestPage() {
className="size-3.5 shrink-0"
aria-hidden
/>
Wartezeit ({withdrawal.product}) bis{" "}
{new Date(
withdrawal.activeUntil,
).toLocaleDateString("de-DE")}
Wartezeit nach {withdrawal.product}{" "}
{withdrawal.withdrawalText}
</p>
) : null}
</div>
+46 -2
View File
@@ -7,12 +7,14 @@ import { finishHandOvers, purgeStaleDraftPhotos } from "@/lib/photos/store";
import AppLayout from "./layout";
const pushMock = vi.fn();
let pathname = "/inspections/new";
vi.mock("next/navigation", () => ({
useRouter: () => ({ replace: vi.fn(), push: pushMock }),
usePathname: () => "/inspections/new",
usePathname: () => pathname,
}));
let sessionMode: "online" | "offline" = "online";
let permissions: string[] = [];
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({
@@ -22,7 +24,7 @@ vi.mock("@/lib/auth/auth-context", () => ({
name: "Test Imker",
roleKey: "admin",
roleName: "Admin",
permissions: [],
permissions,
mfaEnabled: false,
hasAvatar: false,
},
@@ -91,9 +93,51 @@ function expectNoScrollContainerAbove(element: HTMLElement) {
describe("AppLayout", () => {
beforeEach(() => {
sessionMode = "online";
pathname = "/inspections/new";
permissions = [];
pushMock.mockClear();
});
it("leaves room under the page for the quick-add button on a phone, so it covers no action", () => {
pathname = "/suggestions";
permissions = ["inspections:create"];
render(
<AppLayout>
<p>Seiteninhalt</p>
</AppLayout>,
);
const content = screen.getByText("Seiteninhalt").closest("[tabindex='-1']") as HTMLElement;
// FAB top edge = nav + 1rem gap + 3rem button = nav + 4rem; padding nav + 4.5rem leaves 0.5rem free
// below the last button of the page, so the FAB never covers it at the end of the scroll.
expect(content.className).toContain("pb-[calc(var(--bottom-nav-height)+--spacing(18))]");
expect(18 * 0.25).toBeGreaterThan(1 + 3);
});
it("adds no extra room on a form page (the quick-add button is hidden there) or without the button", () => {
permissions = ["inspections:create"];
const { unmount } = render(
<AppLayout>
<p>Seiteninhalt</p>
</AppLayout>,
);
expect(screen.getByText("Seiteninhalt").closest("[tabindex='-1']")?.className).not.toContain(
"--spacing(18)",
);
unmount();
pathname = "/suggestions";
permissions = [];
render(
<AppLayout>
<p>Seiteninhalt</p>
</AppLayout>,
);
expect(screen.getByText("Seiteninhalt").closest("[tabindex='-1']")?.className).not.toContain(
"--spacing(18)",
);
});
it("shows the offline band for the signed-in user right below the header", () => {
render(
<AppLayout>
+10 -3
View File
@@ -2,7 +2,7 @@
import { ReactNode, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { usePathname, useRouter } from "next/navigation";
import { Hexagon } from "lucide-react";
import { useAuth } from "@/lib/auth/auth-context";
import { finishHandOvers, purgeStaleDraftPhotos } from "@/lib/photos/store";
@@ -21,11 +21,12 @@ import {
} from "@/components/ui/dropdown-menu";
import { Sidebar } from "./sidebar";
import { BottomNav } from "./bottom-nav";
import { QuickAddSheet } from "./quick-add-sheet";
import { hasQuickAddButton, QuickAddSheet } from "./quick-add-sheet";
import { PageTransition } from "./page-transition";
export default function AppLayout({ children }: { children: ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const { user, isLoading, sessionMode } = useAuth();
const [isLogoutOpen, setIsLogoutOpen] = useState(false);
// Focus lands here when the offline queue sheet closes and nothing else is left.
@@ -57,6 +58,12 @@ export default function AppLayout({ children }: { children: ReactNode }) {
return null;
}
// The phone's quick-add button floats 16 px above the bottom nav and is
// 48 px high: without room under the page it would cover the last buttons.
const contentPaddingClass = hasQuickAddButton(pathname, user.permissions)
? "pb-[calc(var(--bottom-nav-height)+--spacing(18))]"
: "pb-(--bottom-nav-height)";
// The page scrolls on the window. No ancestor of the page content may be a
// scroll container (overflow auto/scroll/hidden): it would capture
// `position: sticky` and the StickySubmit save bar would never stick.
@@ -112,7 +119,7 @@ export default function AppLayout({ children }: { children: ReactNode }) {
<div
ref={pageContentRef}
tabIndex={-1}
className="flex-1 pb-(--bottom-nav-height) outline-none sm:pb-(--safe-area-bottom)"
className={`flex-1 ${contentPaddingClass} outline-none sm:pb-(--safe-area-bottom)`}
>
<PageTransition>{children}</PageTransition>
</div>
@@ -18,6 +18,14 @@ import { QUICK_ACTIONS } from "./quick-actions";
// quick action anyway - and the header button hides with it for consistency.
const FORM_ROUTE = /\/(new|edit)(\/|$)/;
/** Whether the floating/header button is there at all on this route for this user. */
export function hasQuickAddButton(pathname: string, permissions: readonly string[]): boolean {
return (
!FORM_ROUTE.test(pathname) &&
QUICK_ACTIONS.some((action) => permissions.includes(action.permission))
);
}
interface QuickAddSheetProps {
user: Pick<AuthUser, "permissions">;
/** "fab" floats above the phone bottom nav, "header" sits in the desktop header. */
@@ -32,7 +40,7 @@ export function QuickAddSheet({ user, variant }: QuickAddSheetProps) {
user.permissions.includes(action.permission),
);
if (FORM_ROUTE.test(pathname) || actions.length === 0) {
if (!hasQuickAddButton(pathname, user.permissions)) {
return null;
}
@@ -48,9 +48,13 @@ function renderTab(rules: AutomationRule[]) {
);
}
/** The rule cards start collapsed: this finds one and opens it. */
async function card(title: string) {
const heading = await screen.findByText(title);
return within(heading.closest("[data-slot='card']") as HTMLElement);
const element = heading.closest("[data-slot='card']") as HTMLElement;
const toggle = within(element).getByRole("button", { expanded: false });
await userEvent.click(toggle);
return within(element);
}
// Tests read nested keys of what was sent; the shape is checked by the matchers.
@@ -277,3 +281,43 @@ describe("other rule forms", () => {
expect(savedConfig()).toEqual({ notify: true, dueInDays: 2 });
});
});
describe("collapsible rule cards", () => {
it("starts every card collapsed with a summary line and opens one at a time", async () => {
const user = userEvent.setup();
renderTab([
rule("FEED_STATUS_CRITICAL", { notify: true, dueInDays: 2 }),
rule("WITHDRAWAL_ENDED", { notify: true }),
]);
const first = (await screen.findByText("Futterstatus kritisch")).closest(
"[data-slot='card']",
) as HTMLElement;
const second = (await screen.findByText("Wartezeit zu Ende")).closest("[data-slot='card']") as HTMLElement;
expect(within(first).getAllByRole("button", { expanded: false })).toHaveLength(1);
expect(within(first).queryByLabelText("Aufgabentext")).not.toBeVisible();
expect(within(first).getByText(/^Frist 2 Tage · meldet zusätzlich$/)).toBeInTheDocument();
await user.click(within(first).getByRole("button", { expanded: false }));
expect(within(first).getByLabelText("Aufgabentext")).toBeVisible();
await user.click(within(second).getByRole("button", { expanded: false }));
expect(within(second).getByRole("checkbox", { name: "Zusätzlich melden" })).toBeVisible();
// Opening the second one closes the first.
expect(within(first).getByRole("button", { expanded: false })).toBeInTheDocument();
});
it("edits the task text in a multi-line field and explains „Zusätzlich melden“", async () => {
renderTab([rule("FEED_STATUS_CRITICAL", { notify: false })]);
const view = await card("Futterstatus kritisch");
expect(view.getByLabelText("Aufgabentext").tagName).toBe("TEXTAREA");
expect(view.getByText(/Zusätzlich eine Meldung an alle, die den Vorschlag sehen dürfen/)).toBeInTheDocument();
});
it("makes Deaktivieren and Speichern at least 44 px high", async () => {
renderTab([rule("FEED_STATUS_CRITICAL", { notify: false })]);
const view = await card("Futterstatus kritisch");
expect(view.getByRole("button", { name: "Deaktivieren" })).toHaveClass("min-h-11");
expect(view.getByRole("button", { name: "Speichern" })).toHaveClass("min-h-11");
});
});
@@ -10,7 +10,6 @@ import {
} from "@/lib/automation/api";
import {
RULE_DESCRIPTIONS,
RULE_LABELS,
SIMPLE_RULE_NOTIFY_DEFAULTS,
issuesByField,
} from "@/lib/automation/rule-configs";
@@ -23,12 +22,12 @@ import { errorMessage } from "@/lib/forms/error-message";
import { emptyToNull } from "@/lib/forms/empty-to-null";
import { formatGeneratedNumber } from "@/lib/automation/number-format";
import { ToggleChip } from "@/components/form/toggle-chip";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
import { RuleCardShell } from "./rule-card-shell";
import { hasRuleForm, SpecialRuleCard } from "./special-rule-card";
const NUMBER_FORMAT_TRIGGER_TYPES: AutomationTriggerType[] = [
@@ -95,7 +94,15 @@ function ruleNotify(rule: AutomationRule): boolean {
: (SIMPLE_RULE_NOTIFY_DEFAULTS[rule.triggerType] ?? false);
}
function RuleCard({ rule }: { rule: AutomationRule }) {
function RuleCard({
rule,
open,
onToggle,
}: {
rule: AutomationRule;
open: boolean;
onToggle: () => void;
}) {
const queryClient = useQueryClient();
const [actionTemplate, setActionTemplate] = useState(rule.actionTemplate ?? "");
const [notify, setNotify] = useState(ruleNotify(rule));
@@ -162,14 +169,7 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
}
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>{RULE_LABELS[rule.triggerType]}</CardTitle>
<Badge variant={rule.active ? "default" : "secondary"}>
{rule.active ? "Aktiv" : "Inaktiv"}
</Badge>
</CardHeader>
<CardContent className="flex flex-col gap-4 text-sm">
<RuleCardShell rule={rule} open={open} onToggle={onToggle}>
<p className="text-muted-foreground">{RULE_DESCRIPTIONS[rule.triggerType]}</p>
{numberFormatRule ? (
<div className="flex flex-col gap-2">
@@ -227,8 +227,9 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
) : (
<div className="flex flex-col gap-2">
<Label htmlFor={`template-${rule.id}`}>Aufgabentext</Label>
<Input
<Textarea
id={`template-${rule.id}`}
rows={3}
value={actionTemplate}
onChange={(event) => setActionTemplate(event.target.value)}
/>
@@ -278,23 +279,32 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
</p>
))}
{!numberFormatRule ? (
<ToggleChip label="Zusätzlich melden" checked={notify}
onChange={(checked) => {
clearIssues("notify");
setNotify(checked);
}}
/>
<div className="flex flex-col gap-1">
<ToggleChip
label="Zusätzlich melden"
checked={notify}
describedBy={`notify-hint-${rule.id}`}
onChange={(checked) => {
clearIssues("notify");
setNotify(checked);
}}
/>
<p id={`notify-hint-${rule.id}`} className="text-xs text-muted-foreground">
Zusätzlich eine Meldung an alle, die den Vorschlag sehen dürfen.
</p>
</div>
) : null}
<div className="flex justify-between gap-2">
<Button
type="button"
variant="outline"
className="min-h-11"
disabled={mutation.isPending}
onClick={() => mutation.mutate({ active: !rule.active })}
>
{rule.active ? "Deaktivieren" : "Aktivieren"}
</Button>
<Button type="button" disabled={mutation.isPending} onClick={handleSave}>
<Button type="button" className="min-h-11" disabled={mutation.isPending} onClick={handleSave}>
{mutation.isPending ? "Speichert…" : "Speichern"}
</Button>
</div>
@@ -310,12 +320,12 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
onConfirm={() => resetMutation.mutate()}
/>
) : null}
</CardContent>
</Card>
</RuleCardShell>
);
}
export function AutomationRulesTab() {
const [openRuleId, setOpenRuleId] = useState<string | null>(null);
const { data: rules, isLoading, isError } = useQuery({
queryKey: ["automation-rules"],
queryFn: fetchAutomationRules,
@@ -332,11 +342,15 @@ export function AutomationRulesTab() {
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-3">
{rules?.map((rule) =>
hasRuleForm(rule.triggerType) ? (
<SpecialRuleCard key={rule.id} rule={rule} />
) : (
<RuleCard key={rule.id} rule={rule} />
),
{
const open = openRuleId === rule.id;
const onToggle = () => setOpenRuleId(open ? null : rule.id);
return hasRuleForm(rule.triggerType) ? (
<SpecialRuleCard key={rule.id} rule={rule} open={open} onToggle={onToggle} />
) : (
<RuleCard key={rule.id} rule={rule} open={open} onToggle={onToggle} />
);
},
)}
</div>
</div>
@@ -0,0 +1,53 @@
"use client";
import { ReactNode, useId } from "react";
import { ChevronDown } from "lucide-react";
import { RULE_LABELS, ruleSummary } from "@/lib/automation/rule-configs";
import { AutomationRule } from "@/lib/automation/types";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { cn } from "@/lib/utils";
interface RuleCardShellProps {
rule: AutomationRule;
open: boolean;
onToggle: () => void;
children: ReactNode;
}
/**
* A rule as a collapsible card: the header (title, state, one summary line)
* is always there, the settings open on demand. Closed content stays in the
* page (hidden), so unsaved edits survive closing and opening again.
*/
export function RuleCardShell({ rule, open, onToggle, children }: RuleCardShellProps) {
const contentId = useId();
return (
<Card>
<button
type="button"
aria-expanded={open}
aria-controls={contentId}
onClick={onToggle}
className="flex min-h-11 w-full items-start gap-3 rounded-xl px-4 py-1 text-left focus-visible:ring-3 focus-visible:ring-ring/50"
>
<span className="flex min-w-0 flex-1 flex-col gap-1">
<span className="flex items-center justify-between gap-2">
<span className="font-heading text-base font-medium">{RULE_LABELS[rule.triggerType]}</span>
<Badge variant={rule.active ? "default" : "secondary"}>
{rule.active ? "Aktiv" : "Inaktiv"}
</Badge>
</span>
<span className="line-clamp-2 text-xs text-muted-foreground">{ruleSummary(rule)}</span>
</span>
<ChevronDown
className={cn("mt-1 size-4 shrink-0 motion-safe:transition-transform", open && "rotate-180")}
aria-hidden
/>
</button>
<CardContent id={contentId} hidden={!open} className="flex flex-col gap-4 text-sm">
{children}
</CardContent>
</Card>
);
}
@@ -48,7 +48,9 @@ function renderTab(rules: AutomationRule[]) {
async function card(title: string) {
const heading = await screen.findByText(title);
return within(heading.closest("[data-slot='card']") as HTMLElement);
const element = heading.closest("[data-slot='card']") as HTMLElement;
await userEvent.click(within(element).getByRole("button", { expanded: false }));
return within(element);
}
// Tests read nested keys of what was sent; the shape is checked by the matchers.
@@ -274,7 +276,7 @@ describe("a refreshed rule", () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const wrap = (current: AutomationRule) => (
<QueryClientProvider client={client}>
<SpecialRuleCard rule={current} />
<SpecialRuleCard rule={current} open={false} onToggle={() => {}} />
</QueryClientProvider>
);
const first = rule("PHENOLOGY", null);
@@ -299,7 +301,7 @@ describe("fix round 2", () => {
vi.mocked(updateAutomationRule).mockResolvedValue(phenology);
const wrap = (current: AutomationRule) => (
<QueryClientProvider client={client}>
<SpecialRuleCard rule={current} />
<SpecialRuleCard rule={current} open={false} onToggle={() => {}} />
</QueryClientProvider>
);
const view = render(wrap(phenology));
@@ -11,7 +11,7 @@ import { AutomationTriggerType } from "@/lib/automation/types";
import { RuleDraft } from "./use-rule-draft";
export const NOTIFY_LABEL = "Zusätzlich melden";
export const NOTIFY_HINT = "Du bekommst dann zusätzlich eine Meldung.";
export const NOTIFY_HINT = "Zusätzlich eine Meldung an alle, die den Vorschlag sehen dürfen.";
/** The switch every rule with a notification has. */
export function NotifyToggle({
@@ -166,7 +166,7 @@ export function RuleFormShell({ draft, pending, onSubmit, children }: RuleFormSh
</ul>
) : null}
<div className="flex justify-end">
<Button type="submit" disabled={pending}>
<Button type="submit" className="min-h-11" disabled={pending}>
{pending ? "Speichert…" : "Speichern"}
</Button>
</div>
@@ -4,12 +4,11 @@ import { ComponentType, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { updateAutomationRule } from "@/lib/automation/api";
import { RULE_DESCRIPTIONS, RULE_LABELS, issuesByField } from "@/lib/automation/rule-configs";
import { RULE_DESCRIPTIONS, issuesByField } from "@/lib/automation/rule-configs";
import { AutomationRule, AutomationTriggerType } from "@/lib/automation/types";
import { errorMessage } from "@/lib/forms/error-message";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { RuleCardShell } from "./rule-card-shell";
import { ChainForm } from "./rule-forms/chain-form";
import { InspectionIntervalForm } from "./rule-forms/inspection-interval-form";
import { NotifyForm } from "./rule-forms/notify-form";
@@ -44,7 +43,15 @@ export function hasRuleForm(trigger: AutomationTriggerType): boolean {
return trigger in RULE_FORMS;
}
export function SpecialRuleCard({ rule }: { rule: AutomationRule }) {
export function SpecialRuleCard({
rule,
open,
onToggle,
}: {
rule: AutomationRule;
open: boolean;
onToggle: () => void;
}) {
const queryClient = useQueryClient();
const [issues, setIssues] = useState<Record<string, string>>({});
const Form = RULE_FORMS[rule.triggerType] as ComponentType<RuleFormProps>;
@@ -67,14 +74,7 @@ export function SpecialRuleCard({ rule }: { rule: AutomationRule }) {
});
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>{RULE_LABELS[rule.triggerType]}</CardTitle>
<Badge variant={rule.active ? "default" : "secondary"}>
{rule.active ? "Aktiv" : "Inaktiv"}
</Badge>
</CardHeader>
<CardContent className="flex flex-col gap-4 text-sm">
<RuleCardShell rule={rule} open={open} onToggle={onToggle}>
<p className="text-muted-foreground">{RULE_DESCRIPTIONS[rule.triggerType]}</p>
<Form
key={JSON.stringify(rule.config)}
@@ -87,6 +87,7 @@ export function SpecialRuleCard({ rule }: { rule: AutomationRule }) {
<Button
type="button"
variant="outline"
className="min-h-11"
disabled={mutation.isPending}
onClick={() => {
setIssues({});
@@ -96,7 +97,6 @@ export function SpecialRuleCard({ rule }: { rule: AutomationRule }) {
{rule.active ? "Deaktivieren" : "Aktivieren"}
</Button>
</div>
</CardContent>
</Card>
</RuleCardShell>
);
}
@@ -1,11 +1,12 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
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 { toast } from "sonner";
import { suggestionFixture } from "@/components/suggestions/suggestion-fixtures";
import { Toaster } from "@/components/ui/sonner";
import { ApiError } from "@/lib/auth/api-client";
import { reportRequestOutcome } from "@/lib/offline/connectivity";
import {
acceptSuggestion,
bulkAcceptSuggestions,
@@ -116,6 +117,10 @@ Object.defineProperty(window, "matchMedia", {
HTMLElement.prototype.setPointerCapture = () => undefined;
HTMLElement.prototype.releasePointerCapture = () => undefined;
afterEach(() => {
act(() => reportRequestOutcome("reached-server"));
});
beforeEach(() => {
vi.clearAllMocks();
toast.dismiss();
@@ -478,4 +483,49 @@ describe("SuggestionsPage", () => {
expect(screen.getByText("Aufräumen-Sheet k1 für c9")).toBeInTheDocument();
});
it("offline: marks the list with its „Stand“ and switches off every decision", async () => {
const dismissed = suggestionFixture({ id: "d1", status: "DISMISSED", title: "Varroa messen" });
mockLists(OPEN, { dismissed: [dismissed] });
act(() => reportRequestOutcome("network-error"));
renderPage();
expect(await screen.findByText("3 Vorschläge")).toBeInTheDocument();
expect(screen.getByText(/^Stand: \d{2}\.\d{2}\. \d{2}:\d{2}$/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Alle übernehmen" })).toBeDisabled();
await userEvent.click(screen.getByRole("button", { name: "Verworfene (30 Tage)" }));
const list = await screen.findByRole("list", { name: "Verworfene Vorschläge" });
expect(within(list).getByRole("button", { name: "Wieder öffnen" })).toBeDisabled();
expect(screen.getAllByText("Nur mit Verbindung").length).toBeGreaterThan(0);
});
it("the „Verworfene“ chevron moves only where motion is allowed", async () => {
mockLists(OPEN);
renderPage();
const toggle = await screen.findByRole("button", { name: "Verworfene (30 Tage)" });
const chevron = toggle.querySelector("svg");
expect(chevron).toHaveClass("motion-safe:transition-transform");
expect(chevron).not.toHaveClass("transition-transform");
});
it("online: a failed reload with cached data shows the „Stand“ of that data", async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
mockLists(OPEN);
render(
<QueryClientProvider client={client}>
<SuggestionsPage />
</QueryClientProvider>,
);
await screen.findByText("3 Vorschläge");
expect(screen.queryByText(/^Stand:/)).not.toBeInTheDocument();
vi.mocked(fetchSuggestions).mockRejectedValue(new TypeError("Failed to fetch"));
await act(async () => {
await client.refetchQueries({ queryKey: ["suggestions"] });
});
expect(await screen.findByText(/^Stand: \d{2}\.\d{2}\. \d{2}:\d{2}$/)).toBeInTheDocument();
expect(screen.getByText("3 Vorschläge")).toBeInTheDocument();
});
});
@@ -8,10 +8,13 @@ import { toast } from "sonner";
import { EmptyState } from "@/components/empty-state";
import { PageHeader } from "@/components/page-header";
import { QueryState } from "@/components/query-state";
import { OnlineOnlyHint, StandNote } from "@/components/offline/stand-note";
import { CleanupSheet } from "@/components/suggestions/cleanup-sheet";
import { SuggestionGroup } from "@/components/suggestions/suggestion-group";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/lib/auth/auth-context";
import { cn } from "@/lib/utils";
import { useConnectivity } from "@/lib/offline/connectivity";
import { newIdempotencyKey } from "@/lib/offline/idempotency";
import { startRound } from "@/lib/rounds/round-state";
import {
@@ -35,8 +38,6 @@ const UNDO_TOAST_MS = 8000;
/** The API takes 1 to 50 ids per bulk call. */
const BULK_LIMIT = 50;
const TASKS_HREF = "/tasks";
/** Sonner's own buttons are 24 px high; actions in a toast need 44 px. */
const TOAST_ACTION_CLASS = "h-11! min-h-11! px-4!";
/**
* What has to get focus once the list shows the result of a decision.
@@ -196,10 +197,7 @@ export default function SuggestionsPage() {
onSuccess: (_result, { suggestion }) => {
requestFocus({ target: neighbourOf(suggestion.id), gone: suggestion.id });
refresh();
toast.success("Aufgabe angelegt", {
action: viewTasks,
classNames: { actionButton: TOAST_ACTION_CLASS },
});
toast.success("Aufgabe angelegt", { action: viewTasks });
},
onMutate: ({ suggestion }) => markPending([suggestion.id]),
onSettled: (_result, _error, { suggestion }) => unmarkPending([suggestion.id]),
@@ -227,10 +225,7 @@ export default function SuggestionsPage() {
onSuccess: (count) => {
requestFocus({ target: "list", gone: null });
refresh();
toast.success(taskCountText(count), {
action: viewTasks,
classNames: { actionButton: TOAST_ACTION_CLASS },
});
toast.success(taskCountText(count), { action: viewTasks });
},
onMutate: (ids) => markPending(ids),
onSettled: (_result, _error, ids) => unmarkPending(ids),
@@ -258,7 +253,6 @@ export default function SuggestionsPage() {
toast("Verworfen", {
duration: UNDO_TOAST_MS,
action: { label: "Rückgängig", onClick: () => reopenMutation.mutate(suggestion.id) },
classNames: { actionButton: TOAST_ACTION_CLASS },
});
},
onMutate: (suggestion) => markPending([suggestion.id]),
@@ -293,6 +287,11 @@ export default function SuggestionsPage() {
>
{countText(loaded, total)}
</p>
<StandNote
updatedAt={openQuery.dataUpdatedAt}
isStale={openQuery.isError && openQuery.data !== undefined}
className="-mt-3"
/>
<QueryState
query={{
@@ -349,7 +348,7 @@ export default function SuggestionsPage() {
>
<ChevronDown
aria-hidden
className={showDismissed ? "rotate-180 transition-transform" : "transition-transform"}
className={cn("motion-safe:transition-transform", showDismissed && "rotate-180")}
/>
Verworfene (30 Tage)
</Button>
@@ -403,6 +402,7 @@ function DismissedRow({
canReopen: boolean;
onReopen: () => void;
}) {
const isOffline = useConnectivity() === "offline";
const Icon = INTENT_ICONS[suggestion.intent];
const where = [suggestion.colonyCode, suggestion.apiaryName].filter(Boolean).join(" · ");
return (
@@ -417,10 +417,13 @@ function DismissedRow({
</p>
</div>
{canReopen ? (
<Button size="touch" variant="outline" disabled={busy} onClick={onReopen}>
<RotateCcw aria-hidden />
Wieder öffnen
</Button>
<div className="flex flex-col items-start gap-1">
<Button size="touch" variant="outline" disabled={busy || isOffline} onClick={onReopen}>
<RotateCcw aria-hidden />
Wieder öffnen
</Button>
<OnlineOnlyHint />
</div>
) : null}
</li>
);
+9
View File
@@ -197,6 +197,15 @@
--header-clearance: 4.5rem;
/* StickySubmit with its 48px button (~4.6rem) plus breathing room. */
--sticky-submit-clearance: 5.5rem;
/*
Toasts sit above the sticky save bar. Below sm also above the bottom nav,
which carries the quick-add button (nav + 1rem + 3rem, less than the bar).
*/
--toast-offset-bottom: calc(var(--bottom-nav-height) + var(--sticky-submit-clearance) + 0.5rem);
@variant sm {
--toast-offset-bottom: calc(var(--safe-area-bottom) + var(--sticky-submit-clearance) + 0.5rem);
}
}
@layer base {
+1 -1
View File
@@ -43,7 +43,7 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
>
<body className="min-h-full flex flex-col">
<Providers>{children}</Providers>
<Toaster position="top-right" richColors />
<Toaster richColors />
<ServiceWorkerRegistration />
</body>
</html>
@@ -0,0 +1,16 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const css = readFileSync(join(__dirname, "globals.css"), "utf-8");
describe("toast offset", () => {
it("clears bottom nav and save bar below sm, and only the save bar from sm up", () => {
expect(css).toMatch(
/--toast-offset-bottom:\s*calc\(var\(--bottom-nav-height\) \+ var\(--sticky-submit-clearance\) \+ 0\.5rem\)/,
);
expect(css).toMatch(
/@variant sm \{\s*--toast-offset-bottom:\s*calc\(var\(--safe-area-bottom\) \+ var\(--sticky-submit-clearance\) \+ 0\.5rem\)/,
);
});
});