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