fix: T4a browser check findings
- 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:
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);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user