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

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

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 17:05:32 +02:00
1 parent ef33e5549b
commit 32c61c8e14
77 files changed
+5309 -142

No files matched your search

@@ -6,6 +6,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ApiaryColoniesCard, StartRoundButton } from "./apiary-colonies-card";
import { fetchColonyOverview } from "@/lib/apiaries/field-api";
import { readRound } from "@/lib/rounds/round-state";
import { toLocalIsoDate } from "@/lib/dates";
import type { ColonyOverview, ColonyOverviewRow } from "@/lib/apiaries/field-types";
vi.mock("@/lib/apiaries/field-api", async (importOriginal) => ({
@@ -16,16 +17,27 @@ vi.mock("@/lib/auth/auth-context", () => ({ useCurrentUserId: () => "user-1" }))
const apiary = { id: "a-1", name: "Obstwiese" };
/** YYYY-MM-DD, `offset` days from today (local day, like the component). */
function dayFromToday(offset: number): string {
const date = new Date();
date.setDate(date.getDate() + offset);
return toLocalIsoDate(date);
}
function row(overrides: Partial<ColonyOverviewRow>): ColonyOverviewRow {
return {
id: "c-1",
code: "A1",
status: "ACTIVE",
strength: 6,
occupiedFrames: 9,
broodStatus: "GOOD",
feedStatus: "GOOD",
healthStatus: "GOOD",
lastInspectionDate: new Date().toISOString(),
inspectionDueOn: dayFromToday(5),
segment: "SOMMER",
nextStepTitle: null,
openWarnings: 0,
openCritical: 0,
...overrides,
@@ -71,6 +83,26 @@ describe("ApiaryColoniesCard", () => {
expect(within(first).getByText("Aktiv")).toBeInTheDocument();
});
it("apiary rows say „Durchsicht fällig in 2 Tagen“ and „seit 3 Tagen“ and show the next step", async () => {
vi.mocked(fetchColonyOverview).mockResolvedValue(
overview([
row({ id: "c-1", code: "A1", inspectionDueOn: dayFromToday(2), nextStepTitle: "Varroa messen" }),
row({ id: "c-2", code: "A2", inspectionDueOn: dayFromToday(-3) }),
row({ id: "c-3", code: "A3", inspectionDueOn: null }),
]),
);
renderCard();
await screen.findByRole("heading", { name: "Völker (3)" });
const first = screen.getByRole("link", { name: /A1/ });
expect(within(first).getByText("Durchsicht fällig in 2 Tagen")).toBeInTheDocument();
expect(within(first).getByText("Als Nächstes: Varroa messen")).toBeInTheDocument();
const second = screen.getByRole("link", { name: /A2/ });
expect(within(second).getByText(/Durchsicht seit 3 Tagen fällig/)).toBeInTheDocument();
const third = screen.getByRole("link", { name: /A3/ });
expect(within(third).queryByText(/Durchsicht/)).not.toBeInTheDocument();
});
it("„Runde starten\" opens the first colony's inspection with the round param", async () => {
vi.mocked(fetchColonyOverview).mockResolvedValue(
overview([row({ id: "c-1", code: "A1" }), row({ id: "c-2", code: "A2" })]),
@@ -7,6 +7,7 @@ import type { ColonyOverview } from "@/lib/apiaries/field-types";
import { useCurrentUserId } from "@/lib/auth/auth-context";
import { COLONY_STATUS_LABELS } from "@/lib/colonies/labels";
import { colonyStatusLight } from "@/lib/colonies/status-light";
import { daysBetween, inspectionDueText } from "@/lib/autopilot/format";
import { todayLocalIsoDate } from "@/lib/dates";
import { startRound } from "@/lib/rounds/round-state";
import { StatusLightBadge } from "@/components/colonies/status-light-badge";
@@ -68,6 +69,11 @@ export function ColonyOverviewLinks({ overview, hrefFor }: ColonyOverviewLinksPr
today,
inspectionsVisible: overview.inspectionsVisible,
});
// An overdue inspection is already a reason of the traffic light.
const dueText =
overview.inspectionsVisible && row.inspectionDueOn && daysBetween(today, row.inspectionDueOn) >= 0
? inspectionDueText(row.inspectionDueOn, today)
: null;
return (
<Link
key={row.id}
@@ -83,6 +89,10 @@ export function ColonyOverviewLinks({ overview, hrefFor }: ColonyOverviewLinksPr
{reasons.length > 0 ? (
<span className="text-sm text-muted-foreground">{reasons.join(" · ")}</span>
) : null}
{dueText ? <span className="text-sm text-muted-foreground">{dueText}</span> : null}
{row.nextStepTitle ? (
<span className="text-sm">Als Nächstes: {row.nextStepTitle}</span>
) : null}
</span>
<ChevronRight className="size-4 shrink-0 text-muted-foreground" aria-hidden />
</Link>
@@ -6,6 +6,7 @@ import NewBottlingBatchPage from "./page";
import { createBottlingBatch } from "@/lib/bottling/api";
import { fetchHoneyBatches } from "@/lib/honey-batches/api";
import { fetchProducts } from "@/lib/products/api";
import { toast } from "sonner";
const push = vi.fn();
vi.mock("next/navigation", () => ({
@@ -15,6 +16,9 @@ vi.mock("next/navigation", () => ({
vi.mock("@/lib/honey-batches/api", () => ({ fetchHoneyBatches: vi.fn() }));
vi.mock("@/lib/products/api", () => ({ fetchProducts: vi.fn() }));
vi.mock("@/lib/bottling/api", () => ({ createBottlingBatch: vi.fn() }));
vi.mock("sonner", () => ({
toast: { success: vi.fn(), warning: vi.fn(), info: vi.fn(), error: vi.fn() },
}));
function renderPage() {
const client = new QueryClient({
@@ -36,6 +40,8 @@ describe("NewBottlingBatchPage", () => {
] as never);
vi.mocked(fetchProducts).mockResolvedValue([] as never);
vi.mocked(createBottlingBatch).mockReset();
vi.mocked(toast.success).mockReset();
vi.mocked(toast.warning).mockReset();
});
it("marks the five required fields and explains the units", () => {
@@ -86,4 +92,32 @@ describe("NewBottlingBatchPage", () => {
expect(screen.getByLabelText(/Anzahl Gläser/)).toHaveFocus();
expect(createBottlingBatch).not.toHaveBeenCalled();
});
it("after a bottling with WITHDRAWAL_ACTIVE the toast names the colony and the end day", async () => {
vi.mocked(createBottlingBatch).mockResolvedValue({
id: "b-1",
warnings: [
{
code: "WITHDRAWAL_ACTIVE",
severity: "WARNUNG",
message: "B3: Wartezeit nach Oxuvar endet am 20.08.2026.",
refs: [{ type: "Colony", id: "c-3" }],
},
],
} as never);
renderPage();
await userEvent.type(screen.getByLabelText(/Anzahl Gläser/), "24");
await userEvent.type(screen.getByLabelText(/Mindesthaltbarkeitsdatum/), "2028-07-01");
await userEvent.click(screen.getByRole("button", { name: "Abfüllung anlegen" }));
expect(toast.warning).toHaveBeenCalledWith(
"Abfüllung angelegt · 1 Warnung",
expect.objectContaining({
description: expect.stringMatching(/B3.*20\.08\.2026/),
}),
);
expect(toast.success).not.toHaveBeenCalled();
expect(push).toHaveBeenCalledWith("/bottling/b-1");
});
});
@@ -10,6 +10,7 @@ import { fetchProducts } from "@/lib/products/api";
import { createBottlingBatch } from "@/lib/bottling/api";
import { useFormDraft } from "@/lib/forms/use-form-draft";
import { errorMessage } from "@/lib/forms/error-message";
import { toastWarnings } from "@/lib/warnings/toast-warnings";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { RecordSelect } from "@/components/form/record-select";
@@ -104,7 +105,11 @@ export default function NewBottlingBatchPage() {
draft.clear();
void queryClient.invalidateQueries({ queryKey: ["bottling-batches"] });
void queryClient.invalidateQueries({ queryKey: ["honey-batches"] });
toast.success("Abfüllung angelegt");
if (bottlingBatch.warnings?.length) {
toastWarnings(bottlingBatch.warnings, "Abfüllung angelegt");
} else {
toast.success("Abfüllung angelegt");
}
router.push(`/bottling/${bottlingBatch.id}`);
},
onError: (mutationError: Error) => {
@@ -0,0 +1,104 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import CalendarPage from "./page";
import { fetchCalendarEvents } from "@/lib/calendar/api";
import { fetchApiaries } from "@/lib/apiaries/api";
vi.mock("@/lib/calendar/api", () => ({ fetchCalendarEvents: vi.fn() }));
vi.mock("@/lib/apiaries/api", () => ({ fetchApiaries: vi.fn() }));
function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<CalendarPage />
</QueryClientProvider>,
);
}
describe("CalendarPage withdrawal spans", () => {
beforeEach(() => {
// Noon of 15 August 2026: the month grid shows August 2026.
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date("2026-08-15T12:00:00.000Z"));
vi.mocked(fetchApiaries).mockResolvedValue([] as never);
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",
},
]);
});
afterEach(() => vi.useRealTimers());
it("the calendar renders a withdrawal span across its days", async () => {
renderPage();
// The phone list repeats the span on every day from start to end, inclusive.
const rows = await screen.findAllByText("Wartezeit B3 · Oxuvar", { selector: "span.truncate" });
const phoneList = rows[0].closest("div.sm\\:hidden") as HTMLElement;
const sections = within(phoneList).getAllByRole("heading", { level: 2 });
expect(sections.map((heading) => heading.textContent)).toEqual([
expect.stringContaining("10. August"),
expect.stringContaining("11. August"),
expect.stringContaining("12. August"),
]);
expect(within(phoneList).getAllByText("Wartezeit", { selector: "span" })).toHaveLength(3);
});
it("links the span to the colony and marks its days in the grid", async () => {
renderPage();
await screen.findAllByText("Wartezeit B3 · Oxuvar", { selector: "span.truncate" });
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/ });
await userEvent.click(day);
expect(within(day).getByText("Wartezeit B3 · Oxuvar")).toBeInTheDocument();
});
it("shows only the August days of a span that starts in July", async () => {
vi.mocked(fetchCalendarEvents).mockResolvedValue([
{
id: "withdrawal:t-2",
type: "WITHDRAWAL",
date: "2026-07-30T00:00:00.000Z",
endDate: "2026-08-02T00:00:00.000Z",
title: "Wartezeit B4 · Oxuvar",
relatedType: "Colony",
relatedId: "c-4",
},
]);
renderPage();
const rows = await screen.findAllByText("Wartezeit B4 · Oxuvar", { selector: "span.truncate" });
const phoneList = rows[0].closest("div.sm\\:hidden") as HTMLElement;
const headings = within(phoneList).getAllByRole("heading", { level: 2 });
expect(headings.map((heading) => heading.textContent)).toEqual([
expect.stringContaining("1. August"),
expect.stringContaining("2. August"),
]);
});
it("renders a response without WITHDRAWAL or TREATMENT entries", async () => {
vi.mocked(fetchCalendarEvents).mockResolvedValue([
{
id: "task:1",
type: "TASK",
date: "2026-08-12T00:00:00.000Z",
title: "Beute prüfen",
relatedType: "Task",
relatedId: "t-1",
},
]);
renderPage();
expect((await screen.findAllByText("Beute prüfen", { selector: "span.truncate" })).length).toBeGreaterThan(0);
expect(screen.queryByText(/Wartezeit/)).not.toBeInTheDocument();
});
});
@@ -13,6 +13,7 @@ import {
ListTodo,
type LucideIcon,
Package,
ShieldAlert,
Syringe,
} from "lucide-react";
import { fetchCalendarEvents } from "@/lib/calendar/api";
@@ -35,8 +36,29 @@ const EVENT_META: Record<CalendarEventType, { icon: LucideIcon; label: string; t
FEEDING: { icon: Droplet, label: "Fütterung", tone: "bg-warning/20" },
HARVEST: { icon: Package, label: "Ernte", tone: "bg-primary/15" },
QUEEN_ASSIGNMENT: { icon: Crown, label: "Königin", tone: "bg-secondary/15" },
WITHDRAWAL: { icon: ShieldAlert, label: "Wartezeit", tone: "bg-warning/20" },
};
const DAY_MS = 86_400_000;
/** A span longer than this is a data error; never loop over it. */
const MAX_SPAN_DAYS = 400;
/**
* The calendar days an entry sits on. A withdrawal span covers every day from
* `date` to `endDate` (inclusive); both are stored as midnight UTC, so the
* UTC day is the calendar day. Other entries sit on their local day.
*/
function eventDayKeys(event: CalendarEvent): string[] {
if (event.type !== "WITHDRAWAL") return [toLocalIsoDate(new Date(event.date))];
const start = Date.parse(event.date.slice(0, 10));
const end = event.endDate ? Date.parse(event.endDate.slice(0, 10)) : start;
if (Number.isNaN(start) || Number.isNaN(end) || end < start) return [event.date.slice(0, 10)];
const days = Math.min(Math.round((end - start) / DAY_MS), MAX_SPAN_DAYS);
return Array.from({ length: days + 1 }, (_, offset) =>
new Date(start + offset * DAY_MS).toISOString().slice(0, 10),
);
}
function eventHref(event: CalendarEvent): string | null {
if (event.relatedType === "Task") return "/tasks";
if (event.relatedType === "Colony") return `/colonies/${event.relatedId}`;
@@ -145,10 +167,11 @@ export default function CalendarPage() {
const eventsByDay = useMemo(() => {
const map = new Map<string, CalendarEvent[]>();
for (const event of eventsQuery.data ?? []) {
const key = toLocalIsoDate(new Date(event.date));
const bucket = map.get(key) ?? [];
bucket.push(event);
map.set(key, bucket);
for (const key of eventDayKeys(event)) {
const bucket = map.get(key) ?? [];
bucket.push(event);
map.set(key, bucket);
}
}
return map;
}, [eventsQuery.data]);
@@ -24,6 +24,9 @@ vi.mock("./colony-swarm-control", () => ({ ColonySwarmControlCard: () => null })
vi.mock("./colony-treatments", () => ({ ColonyTreatmentsCard: () => null }));
vi.mock("./colony-feedings", () => ({ ColonyFeedingsCard: () => null }));
vi.mock("@/components/document-upload-card", () => ({ DocumentUploadCard: () => null }));
vi.mock("@/components/autopilot/next-steps-card", () => ({
NextStepsCard: ({ colonyId }: { colonyId: string }) => <p>Als Nächstes für {colonyId}</p>,
}));
vi.mock("@/components/qr-code-dialog", () => ({ QrCodeDialog: () => null }));
function renderPage() {
@@ -97,3 +100,24 @@ describe("ColonyDetailPage – strength", () => {
expect(screen.queryByText(/besetzte Waben|Volksstärke/)).not.toBeInTheDocument();
});
});
describe("ColonyDetailPage – Als Nächstes", () => {
it("puts the next steps of the colony on top of the page", async () => {
vi.mocked(fetchColony).mockResolvedValue({
id: "colony-1",
code: "V-01",
colonyType: "PRODUCTION_COLONY",
status: "ACTIVE",
strength: null,
occupiedFrames: null,
} as Colony);
renderPage();
const steps = await screen.findByText("Als Nächstes für colony-1");
// Above the cards: the page's first thing after the header.
const firstCard = screen.getByRole("heading", { level: 1 }).closest("header")!;
expect(
firstCard.compareDocumentPosition(steps) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
});
@@ -8,6 +8,7 @@ import { toast } from "sonner";
import { fetchColony } from "@/lib/colonies/api";
import { COLONY_STATUS_LABELS, COLONY_TYPE_LABELS, colonyStrengthText } from "@/lib/colonies/labels";
import { exportColonyAnnualReportPdf, exportColonyRecordCardPdf } from "@/lib/export/api";
import { NextStepsCard } from "@/components/autopilot/next-steps-card";
import { QrCodeDialog } from "@/components/qr-code-dialog";
import { DocumentUploadCard } from "@/components/document-upload-card";
import { DetailHeader, DetailQueryFallback, DetailStatus } from "@/components/detail-header";
@@ -84,6 +85,7 @@ export default function ColonyDetailPage() {
{...BACK}
/>
{strengthText ? <p className="text-sm text-muted-foreground">{strengthText}</p> : null}
<NextStepsCard colonyId={colony.id} />
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-3">
<div className="flex flex-col gap-4 lg:col-span-1">
<ColonyEditCard key={colony.id} colony={colony} />
@@ -0,0 +1,175 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { 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 { ApiError } from "@/lib/auth/api-client";
import type { Task } from "@/lib/tasks/types";
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({ user: { name: "Nick Test" }, hasPermission: () => true }),
useCurrentUserId: () => "user-1",
}));
vi.mock("@/lib/dashboard/api", () => ({
fetchDashboardSummary: vi.fn(),
fetchDashboardAlerts: vi.fn(),
}));
vi.mock("@/lib/tasks/api", () => ({
fetchTasks: vi.fn(),
completeTask: vi.fn(),
updateTask: vi.fn(),
}));
vi.mock("@/lib/autopilot/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/autopilot/api")>()),
fetchAutopilotToday: vi.fn(),
}));
vi.mock("sonner", () => ({
toast: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn() }),
}));
function overdueTask(): Task {
const yesterday = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString();
return {
id: "t1",
title: "Stock prüfen",
description: null,
priority: "NORMAL",
dueDate: yesterday,
status: "OPEN",
assignedUserId: null,
createdByUserId: null,
relatedType: "GENERAL",
relatedId: null,
sourceInspectionId: null,
sourceTemplateId: null,
sourceAutomationTrigger: null,
createdAt: yesterday,
completedAt: null,
};
}
function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<DashboardPage />
</QueryClientProvider>,
);
}
describe("DashboardPage – Heute", () => {
beforeEach(() => {
vi.mocked(fetchDashboardSummary).mockResolvedValue({
apiariesActive: 1,
coloniesActive: 2,
queensActive: 2,
openTasks: 1,
honeyBatchesTotal: 0,
stockItemsLowCount: 0,
} as never);
vi.mocked(fetchDashboardAlerts).mockResolvedValue([]);
vi.mocked(fetchTasks).mockResolvedValue([overdueTask()]);
vi.mocked(fetchAutopilotToday).mockReset();
});
it("the dashboard shows the next step from /autopilot/today and falls back to tasks offline", async () => {
vi.mocked(fetchAutopilotToday).mockResolvedValue(
todayFixture({
nextStep: {
...stepFixture({ title: "Durchsicht A1" }),
colonyId: "c1",
colonyCode: "A1",
},
}),
);
const { unmount } = renderPage();
const section = (await screen.findByRole("heading", { name: "Als Nächstes" })).closest("section")!;
expect(await within(section).findByText("Durchsicht A1")).toBeInTheDocument();
expect(within(section).getByText("A1")).toBeInTheDocument();
expect(within(section).getByRole("link", { name: "Erfassen" })).toHaveAttribute(
"href",
"/inspections/new?colonyId=c1",
);
unmount();
vi.mocked(fetchAutopilotToday).mockRejectedValue(new TypeError("Failed to fetch"));
renderPage();
const fallback = (await screen.findByRole("heading", { name: "Als Nächstes" })).closest("section")!;
expect(await within(fallback).findByText("Stock prüfen")).toBeInTheDocument();
});
it("shows a „Durchsicht fällig“ card per apiary, the suggestion count and the season per apiary", async () => {
vi.mocked(fetchTasks).mockResolvedValue([]);
vi.mocked(fetchAutopilotToday).mockResolvedValue(
todayFixture({
suggestionsOpen: 4,
inspectionDue: [
{
apiaryId: "a-1",
apiaryName: "Obstwiese",
segment: "SCHWARMZEIT",
intervalDays: 7,
colonies: [{ id: "c-1", code: "A1", dueOn: "2026-05-12", overdueDays: 0 }],
},
],
seasons: [{ apiaryId: "a-1", apiaryName: "Obstwiese", segment: "SCHWARMZEIT" }],
}),
);
renderPage();
expect(
await screen.findByRole("heading", { name: /Durchsicht fällig · Obstwiese/ }),
).toBeInTheDocument();
expect(screen.getByRole("link", { name: /Vorschläge 4/ })).toHaveAttribute("href", "/suggestions");
expect(screen.getByText("Obstwiese: Schwarmzeit")).toBeInTheDocument();
});
it("falls back silently for a network error and a 403, but says so for other errors and allows a retry", async () => {
vi.mocked(fetchAutopilotToday).mockRejectedValue(new ApiError(403, "Forbidden", {}));
const first = renderPage();
await screen.findByText("Stock prüfen");
expect(screen.queryByText(/Hinweise des Autopiloten/)).not.toBeInTheDocument();
first.unmount();
vi.mocked(fetchAutopilotToday).mockRejectedValue(new ApiError(500, "boom", {}));
renderPage();
expect(await screen.findByText(/Hinweise des Autopiloten/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Erneut versuchen" })).toBeInTheDocument();
expect(await screen.findByText("Stock prüfen")).toBeInTheDocument();
});
it("says the tasks failed to load instead of „nichts fällig“ when only the autopilot answered", async () => {
vi.mocked(fetchTasks).mockRejectedValue(new ApiError(500, "boom", {}));
vi.mocked(fetchAutopilotToday).mockResolvedValue(todayFixture());
renderPage();
expect(await screen.findByText("Die Aufgaben konnten gerade nicht geladen werden.")).toBeInTheDocument();
expect(screen.queryByText(/Heute ist nichts fällig/)).not.toBeInTheDocument();
});
it("keeps one primary button: „Runde starten“ is an outline button below a step card", async () => {
vi.mocked(fetchAutopilotToday).mockResolvedValue(
todayFixture({
nextStep: { ...stepFixture({ title: "Durchsicht A1" }), colonyId: "c1", colonyCode: "A1" },
inspectionDue: [
{
apiaryId: "a-1",
apiaryName: "Obstwiese",
segment: "SCHWARMZEIT",
intervalDays: 7,
colonies: [{ id: "c-1", code: "A1", dueOn: "2026-05-12", overdueDays: 0 }],
},
],
}),
);
renderPage();
expect(await screen.findByRole("link", { name: "Erfassen" })).toHaveClass("bg-primary");
expect(screen.getByRole("link", { name: "Runde starten" })).not.toHaveClass("bg-primary");
});
});
@@ -15,11 +15,15 @@ import {
ShieldAlert,
Sprout,
} from "lucide-react";
import { ApiError } from "@/lib/auth/api-client";
import { useAuth } from "@/lib/auth/auth-context";
import { isNetworkError } from "@/lib/http/fetch-with-timeout";
import { autopilotTodayKey, fetchAutopilotToday } from "@/lib/autopilot/api";
import { fetchDashboardAlerts, fetchDashboardSummary } from "@/lib/dashboard/api";
import { DashboardAlert, DashboardAlertType } from "@/lib/dashboard/types";
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 { TodayPanel } from "@/components/today/today-panel";
@@ -97,9 +101,22 @@ export default function DashboardPage() {
queryKey: ["tasks", "dashboard"],
queryFn: () => fetchTasks({}),
});
// Without the autopilot (offline, missing right) "Heute" works from the tasks alone.
const autopilotQuery = useQuery({
queryKey: autopilotTodayKey,
queryFn: fetchAutopilotToday,
retry: false,
});
// Overdue tasks already appear in "Heute"; listing them again as alerts
// would announce the same thing twice.
// Offline and a missing right are normal: "Heute" then works from the tasks.
// Any other failure is said out loud.
const autopilotError = autopilotQuery.isError ? autopilotQuery.error : null;
const isAutopilotSilent =
autopilotError !== null &&
(isNetworkError(autopilotError) ||
(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;
const firstName = user?.name.trim().split(/\s+/)[0];
@@ -145,12 +162,30 @@ export default function DashboardPage() {
</div>
</header>
{autopilotError && !isAutopilotSilent ? (
<div
role="alert"
className="flex flex-wrap items-center gap-2 rounded-xl border border-border bg-card p-3 text-sm"
>
<p>Die Hinweise des Autopiloten konnten nicht geladen werden.</p>
<Button
size="touch"
variant="outline"
disabled={autopilotQuery.isFetching}
onClick={() => void autopilotQuery.refetch()}
>
Erneut versuchen
</Button>
</div>
) : null}
<TodayPanel
tasks={tasksQuery.data}
isLoading={tasksQuery.isLoading}
isLoading={tasksQuery.isLoading || autopilotQuery.isLoading}
isError={tasksQuery.isError}
onRetry={() => void tasksQuery.refetch()}
now={now}
autopilot={autopilotQuery.data ?? null}
/>
{alertsLoading || otherAlerts.length > 0 ? (
@@ -7,6 +7,7 @@ import { createHarvest } from "@/lib/harvests/api";
import { fetchColonies } from "@/lib/colonies/api";
import { fetchApiaries } from "@/lib/apiaries/api";
import { fetchWithdrawalStatus } from "@/lib/treatments/api";
import { toast } from "sonner";
const push = vi.fn();
vi.mock("next/navigation", () => ({
@@ -18,6 +19,9 @@ vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() }));
vi.mock("@/lib/treatments/api", () => ({ fetchWithdrawalStatus: vi.fn() }));
vi.mock("@/lib/harvests/api", () => ({ createHarvest: vi.fn() }));
vi.mock("@/lib/offline/db", () => ({ queueHarvest: vi.fn() }));
vi.mock("sonner", () => ({
toast: { success: vi.fn(), warning: vi.fn(), info: vi.fn(), error: vi.fn() },
}));
function renderPage() {
const client = new QueryClient({
@@ -43,6 +47,8 @@ describe("NewHarvestPage", () => {
] as never);
vi.mocked(fetchWithdrawalStatus).mockResolvedValue([]);
vi.mocked(createHarvest).mockReset();
vi.mocked(toast.success).mockReset();
vi.mocked(toast.warning).mockReset();
});
it("marks apiary, date and colonies as required and explains gross vs net", () => {
@@ -139,4 +145,29 @@ describe("NewHarvestPage", () => {
).toHaveFocus();
expect(createHarvest).not.toHaveBeenCalled();
});
it("toasts the warnings of an online save and plain success without any", async () => {
vi.mocked(createHarvest).mockResolvedValue({
id: "h-1",
warnings: [
{
code: "WITHDRAWAL_ACTIVE",
severity: "WARNUNG",
message: "V-01: Wartezeit nach Oxuvar endet am 20.08.2026.",
refs: [],
},
],
} as never);
renderPage();
await userEvent.click(await screen.findByRole("checkbox", { name: "V-01" }));
await userEvent.click(screen.getByRole("button", { name: "Ernte speichern" }));
expect(toast.warning).toHaveBeenCalledWith(
"Ernte gespeichert · 1 Warnung",
expect.objectContaining({ description: expect.stringContaining("20.08.2026") }),
);
expect(toast.success).not.toHaveBeenCalled();
expect(push).toHaveBeenCalledWith("/harvests/h-1");
});
});
@@ -14,6 +14,7 @@ import { fetchWithdrawalStatus } from "@/lib/treatments/api";
import { todayLocalIsoDate } from "@/lib/dates";
import { queueHarvest } from "@/lib/offline/db";
import { useCaptureMutation } from "@/lib/offline/use-capture-mutation";
import { toastWarnings } from "@/lib/warnings/toast-warnings";
import { useFormDraft } from "@/lib/forms/use-form-draft";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -129,7 +130,12 @@ export default function NewHarvestPage() {
return;
}
void queryClient.invalidateQueries({ queryKey: ["harvests"] });
toast.success("Ernte gespeichert");
// Warnings come only from an online answer; a queued entry has none.
if (outcome.result.warnings?.length) {
toastWarnings(outcome.result.warnings, "Ernte gespeichert");
} else {
toast.success("Ernte gespeichert");
}
router.push(`/harvests/${outcome.result.id}`);
},
});
@@ -14,6 +14,7 @@ import {
packTask,
storedPack,
} from "@/lib/apiaries/offline-pack.fixtures";
import { stepFixture } from "@/lib/autopilot/step-fixtures";
import OfflineColonyPage from "./page";
let searchString = "id=colony-1";
@@ -79,6 +80,51 @@ describe("OfflineColonyPage", () => {
await deleteAllPacks();
});
it("shows the next steps of the pack read-only, with links to the capture forms", async () => {
await putPacks(
storedPack({
...fullPack(),
nextSteps: { "colony-1": [stepFixture({ title: "Durchsicht fällig" })] },
}),
);
renderPage();
const section = (await screen.findByRole("heading", { name: "Als Nächstes" })).closest(
"section",
) as HTMLElement;
expect(within(section).getByText("Durchsicht fällig")).toBeInTheDocument();
expect(within(section).getByRole("link", { name: "Erfassen" })).toHaveAttribute(
"href",
"/inspections/new?colonyId=c1",
);
expect(within(section).queryByRole("button")).not.toBeInTheDocument();
});
it("hides the link to the task list offline but keeps the capture links", async () => {
await putPacks(
storedPack({
...fullPack(),
nextSteps: {
"colony-1": [
stepFixture({
key: "task:t1",
kind: "TASK",
title: "Aufgabe X",
suggestionIds: [],
action: { type: "OPEN_TASK", taskId: "t1", href: "/tasks" },
}),
stepFixture({ key: "suggestion:s2", title: "Durchsicht" }),
],
},
}),
);
renderPage();
await screen.findByText("Aufgabe X");
expect(screen.queryByRole("link", { name: "Aufgabe öffnen" })).not.toBeInTheDocument();
expect(screen.getByRole("link", { name: "Erfassen" })).toBeInTheDocument();
});
it("renders colony, queen, last three inspections and open tasks from the pack", async () => {
await putPacks(storedPack(fullPack()));
renderPage();
@@ -90,7 +136,7 @@ describe("OfflineColonyPage", () => {
expect(within(header).getByText("Gut")).toBeInTheDocument();
expect(within(header).getByText("Aktiv")).toBeInTheDocument();
const colony = screen.getByRole("region", { name: "Volk" });
expect(within(colony).getByText("9 Waben")).toBeInTheDocument();
expect(within(colony).getByText("9 besetzte Waben")).toBeInTheDocument();
const queen = screen.getByRole("region", { name: "Königin" });
expect(within(queen).getByText("K-12")).toBeInTheDocument();
@@ -17,12 +17,14 @@ import {
COLONY_CONDITION_LABELS,
COLONY_STATUS_LABELS,
COLONY_TYPE_LABELS,
colonyStrengthText,
} from "@/lib/colonies/labels";
import { colonyStatusLight } from "@/lib/colonies/status-light";
import type { ColonyCondition } from "@/lib/colonies/types";
import { todayLocalIsoDate } from "@/lib/dates";
import type { StoredPack } from "@/lib/offline/db";
import { QUEEN_STATUS_LABELS } from "@/lib/queens/labels";
import { NextStepsView } from "@/components/autopilot/next-steps-card";
import { StatusLightBadge } from "@/components/colonies/status-light-badge";
import { DetailHeader, DetailStatus } from "@/components/detail-header";
import { PackAgeNote } from "@/components/offline/load-pack-button";
@@ -152,6 +154,12 @@ function OfflineColonyView({ stored, colony, readAt, returnHref }: OfflineColony
{light && light.reasons.length > 0 ? (
<p className="text-sm text-muted-foreground">{light.reasons.join(" · ")}</p>
) : null}
{/* Read-only here: only the links to the capture forms work without network. */}
<NextStepsView
steps={pack.nextSteps?.[colony.id] ?? []}
withdrawalUntil={null}
hideTaskLink
/>
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-2">
<ColonyFacts colony={colony} />
{pack.queens !== null ? <QueenFacts queen={queen} /> : null}
@@ -186,7 +194,7 @@ function ColonyFacts({ colony }: { colony: PackColony }) {
return (
<OfflineSection title="Volk">
<dl className="flex flex-col gap-1.5">
<Fact label="Stärke" value={colony.strength !== null ? `${colony.strength} Waben` : "–"} />
<Fact label="Stärke" value={colonyStrengthText(colony) ?? "–"} />
<Fact label="Brut" value={conditionText(colony.broodStatus)} />
<Fact label="Futter" value={conditionText(colony.feedStatus)} />
<Fact label="Gesundheit" value={conditionText(colony.healthStatus)} />
@@ -0,0 +1,120 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { NotificationSettingsCard } from "./notification-settings-card";
import {
fetchNotificationSettings,
updateNotificationSettings,
} from "@/lib/notification-settings/api";
import type { NotificationSettings } from "@/lib/notification-settings/types";
vi.mock("@/lib/notification-settings/api", () => ({
fetchNotificationSettings: vi.fn(),
updateNotificationSettings: vi.fn(),
}));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
const settings: NotificationSettings = {
taskDueEnabled: true,
digestEnabled: true,
digestTime: "06:30",
quietFrom: "21:00",
quietUntil: "06:30",
telegramCategories: ["TASK_DUE", "DIGEST"],
telegramDetail: true,
};
let client: QueryClient;
function renderCard() {
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<NotificationSettingsCard />
</QueryClientProvider>,
);
}
describe("NotificationSettingsCard", () => {
beforeEach(() => {
vi.mocked(fetchNotificationSettings).mockReset().mockResolvedValue(settings);
vi.mocked(updateNotificationSettings).mockReset().mockResolvedValue(settings);
});
it("saving sends only changed fields", async () => {
renderCard();
await userEvent.click(await screen.findByRole("switch", { name: "Erinnerung je Aufgabe" }));
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
await waitFor(() => expect(updateNotificationSettings).toHaveBeenCalled());
expect(updateNotificationSettings).toHaveBeenCalledWith({ taskDueEnabled: false });
});
it("rejects 04:30 with the German message and does not save", async () => {
renderCard();
const time = await screen.findByLabelText("Uhrzeit der Morgenübersicht");
await userEvent.clear(time);
await userEvent.type(time, "04:30");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(
await screen.findByText("Bitte eine Uhrzeit zwischen 05:00 und 12:00 wählen."),
).toBeInTheDocument();
expect(updateNotificationSettings).not.toHaveBeenCalled();
});
it("clears quiet hours only as a pair", async () => {
renderCard();
await userEvent.clear(await screen.findByLabelText("Ruhezeit von"));
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(
await screen.findByText("Bitte Beginn und Ende der Ruhezeit angeben oder beide leeren."),
).toBeInTheDocument();
expect(updateNotificationSettings).not.toHaveBeenCalled();
await userEvent.clear(screen.getByLabelText("Ruhezeit bis"));
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
await waitFor(() =>
expect(updateNotificationSettings).toHaveBeenCalledWith({
quietFrom: null,
quietUntil: null,
}),
);
});
it("shows the defaults 06:30 and 21:00 to 06:30", async () => {
renderCard();
expect(await screen.findByLabelText("Uhrzeit der Morgenübersicht")).toHaveValue("06:30");
expect(screen.getByLabelText("Ruhezeit von")).toHaveValue("21:00");
expect(screen.getByLabelText("Ruhezeit bis")).toHaveValue("06:30");
});
it("diffs against the values from when editing started, not a later refetch", async () => {
renderCard();
const time = await screen.findByLabelText("Uhrzeit der Morgenübersicht");
await userEvent.clear(time);
await userEvent.type(time, "07:00");
// Another device switched the task reminder off meanwhile.
act(() => client.setQueryData(["notification-settings"], { ...settings, taskDueEnabled: false }));
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
await waitFor(() => expect(updateNotificationSettings).toHaveBeenCalled());
expect(updateNotificationSettings).toHaveBeenCalledWith({ digestTime: "07:00" });
});
it("links the error texts to their inputs", async () => {
renderCard();
const time = await screen.findByLabelText("Uhrzeit der Morgenübersicht");
await userEvent.clear(time);
await userEvent.type(time, "04:30");
await userEvent.clear(screen.getByLabelText("Ruhezeit von"));
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
const digestError = await screen.findByText("Bitte eine Uhrzeit zwischen 05:00 und 12:00 wählen.");
expect(time.getAttribute("aria-describedby")).toContain(digestError.id);
const quietError = screen.getByText("Bitte Beginn und Ende der Ruhezeit angeben oder beide leeren.");
expect(screen.getByLabelText("Ruhezeit von").getAttribute("aria-describedby")).toContain(quietError.id);
expect(screen.getByLabelText("Ruhezeit bis").getAttribute("aria-describedby")).toContain(quietError.id);
});
});
@@ -0,0 +1,285 @@
"use client";
import { FormEvent, useId, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
fetchNotificationSettings,
updateNotificationSettings,
} from "@/lib/notification-settings/api";
import type { NotificationSettings } from "@/lib/notification-settings/types";
import {
settingsPatch,
validateDigestTime,
validateQuietHours,
} from "@/lib/notification-settings/validation";
import { errorMessage } from "@/lib/forms/error-message";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { ErrorState, LoadingState } from "@/components/query-state";
const QUERY_KEY = ["notification-settings"] as const;
interface SwitchRowProps {
label: string;
description?: string;
checked: boolean;
onChange: (checked: boolean) => void;
}
function SwitchRow({ label, description, checked, onChange }: SwitchRowProps) {
const id = useId();
return (
<label className="flex min-h-11 cursor-pointer items-start gap-3 rounded-xl border p-3 has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-ring">
<input
type="checkbox"
role="switch"
aria-labelledby={`${id}-label`}
aria-describedby={description ? `${id}-description` : undefined}
checked={checked}
onChange={(event) => onChange(event.target.checked)}
className="mt-1 size-4 accent-[var(--primary)]"
/>
<span className="flex flex-col">
<span id={`${id}-label`} className="font-medium">
{label}
</span>
{description ? (
<span
id={`${id}-description`}
className="text-sm text-muted-foreground"
>
{description}
</span>
) : null}
</span>
</label>
);
}
interface FormErrors {
digestTime?: string;
quiet?: string;
}
function NotificationSettingsForm({
settings,
}: {
settings: NotificationSettings;
}) {
const queryClient = useQueryClient();
const [draft, setDraft] = useState<NotificationSettings>(settings);
// The values editing started from. A refetch meanwhile must not change what
// counts as "changed": only the person's own edits go into the patch.
const baseline = useRef<NotificationSettings>(settings);
const [errors, setErrors] = useState<FormErrors>({});
const [submitError, setSubmitError] = useState<string | null>(null);
const mutation = useMutation({
mutationFn: (patch: Partial<NotificationSettings>) =>
updateNotificationSettings(patch),
onSuccess: (saved) => {
queryClient.setQueryData(QUERY_KEY, saved);
baseline.current = saved;
setDraft(saved);
toast.success("Benachrichtigungen gespeichert");
},
onError: (error: Error) =>
setSubmitError(
errorMessage(error, "Einstellungen konnten nicht gespeichert werden."),
),
});
function change<K extends keyof NotificationSettings>(
key: K,
value: NotificationSettings[K],
) {
setDraft((current) => ({ ...current, [key]: value }));
setErrors((current) => ({
...current,
...(key === "digestTime" ? { digestTime: undefined } : {}),
}));
}
function changeQuiet(key: "quietFrom" | "quietUntil", value: string) {
setDraft((current) => ({ ...current, [key]: value === "" ? null : value }));
setErrors((current) => ({ ...current, quiet: undefined }));
}
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitError(null);
const next: FormErrors = {
digestTime: validateDigestTime(draft.digestTime) ?? undefined,
quiet:
validateQuietHours(draft.quietFrom ?? "", draft.quietUntil ?? "") ??
undefined,
};
setErrors(next);
if (next.digestTime || next.quiet) return;
const patch = settingsPatch(baseline.current, draft);
if (Object.keys(patch).length === 0) {
toast.success("Nichts geändert");
return;
}
mutation.mutate(patch);
}
const hasQuietHours = draft.quietFrom !== null || draft.quietUntil !== null;
return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit} noValidate>
<SwitchRow
label="Erinnerung je Aufgabe"
description="Eine Meldung, wenn eine Aufgabe fällig wird."
checked={draft.taskDueEnabled}
onChange={(checked) => change("taskDueEnabled", checked)}
/>
<SwitchRow
label="Morgenübersicht"
description="Eine Meldung am Morgen mit allem, was heute ansteht."
checked={draft.digestEnabled}
onChange={(checked) => change("digestEnabled", checked)}
/>
<div className="flex flex-col gap-1.5">
<Label htmlFor="digest-time">Uhrzeit der Morgenübersicht</Label>
<Input
id="digest-time"
type="time"
className="h-11 sm:w-40"
value={draft.digestTime}
aria-invalid={Boolean(errors.digestTime)}
aria-describedby={
errors.digestTime
? "digest-time-hint digest-time-error"
: "digest-time-hint"
}
onChange={(event) => change("digestTime", event.target.value)}
/>
<p id="digest-time-hint" className="text-xs text-muted-foreground">
Zwischen 05:00 und 12:00 Uhr.
</p>
{errors.digestTime ? (
<p
id="digest-time-error"
role="alert"
className="text-sm font-medium text-destructive"
>
{errors.digestTime}
</p>
) : null}
</div>
<fieldset className="flex flex-col gap-1.5">
<legend className="text-sm font-medium">Ruhezeit</legend>
<p className="text-xs text-muted-foreground">
In dieser Zeit kommen keine Nachrichten außerhalb der App.
</p>
<div className="flex flex-wrap items-end gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="quiet-from">Ruhezeit von</Label>
<Input
id="quiet-from"
type="time"
className="h-11 w-36"
value={draft.quietFrom ?? ""}
aria-invalid={Boolean(errors.quiet)}
aria-describedby={errors.quiet ? "quiet-error" : undefined}
onChange={(event) => changeQuiet("quietFrom", event.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="quiet-until">Ruhezeit bis</Label>
<Input
id="quiet-until"
type="time"
className="h-11 w-36"
value={draft.quietUntil ?? ""}
aria-invalid={Boolean(errors.quiet)}
aria-describedby={errors.quiet ? "quiet-error" : undefined}
onChange={(event) =>
changeQuiet("quietUntil", event.target.value)
}
/>
</div>
{hasQuietHours ? (
<Button
type="button"
variant="ghost"
size="touch"
onClick={() => {
setDraft((current) => ({
...current,
quietFrom: null,
quietUntil: null,
}));
setErrors((current) => ({ ...current, quiet: undefined }));
}}
>
Ruhezeit leeren
</Button>
) : null}
</div>
{errors.quiet ? (
<p
id="quiet-error"
role="alert"
className="text-sm font-medium text-destructive"
>
{errors.quiet}
</p>
) : null}
</fieldset>
{submitError ? (
<p role="alert" className="text-sm font-medium text-destructive">
{submitError}
</p>
) : null}
<Button
type="submit"
size="touch"
className="self-start"
disabled={mutation.isPending}
>
{mutation.isPending ? "Speichert…" : "Speichern"}
</Button>
</form>
);
}
export function NotificationSettingsCard() {
const query = useQuery({
queryKey: QUERY_KEY,
queryFn: fetchNotificationSettings,
});
return (
<Card>
<CardHeader>
<CardTitle>Benachrichtigungen</CardTitle>
<CardDescription>
Welche Erinnerungen du bekommst und wann.
</CardDescription>
</CardHeader>
<CardContent>
{query.isLoading ? <LoadingState what="Einstellungen" /> : null}
{query.isError ? (
<ErrorState
what="Einstellungen"
onRetry={() => void query.refetch()}
isRetrying={query.isFetching}
/>
) : null}
{query.data ? <NotificationSettingsForm settings={query.data} /> : null}
</CardContent>
</Card>
);
}
@@ -27,6 +27,7 @@ import {
import { MfaSection } from "./mfa-section";
import { DangerZoneSection } from "./danger-zone-section";
import { MotionSection } from "./motion-section";
import { NotificationSettingsCard } from "./notification-settings-card";
function splitName(fullName: string): { firstName: string; lastName: string } {
const [firstName, ...rest] = fullName.trim().split(/\s+/);
@@ -302,6 +303,7 @@ export default function ProfilePage() {
<ProfileInfoSection />
<PasswordSection />
<MfaSection mfaEnabled={user.mfaEnabled} onChanged={() => void refreshUser()} />
<NotificationSettingsCard />
<MotionSection />
<DangerZoneSection />
</div>
@@ -43,6 +43,11 @@ vi.mock("@/lib/suggestions/api", async (importOriginal) => ({
reopenSuggestion: vi.fn(),
}));
vi.mock("@/components/suggestions/cleanup-sheet", () => ({
CleanupSheet: ({ suggestionId, colonyId }: { suggestionId: string | null; colonyId: string | null }) =>
suggestionId ? <p>Aufräumen-Sheet {suggestionId} für {colonyId}</p> : null,
}));
const OPEN: Suggestion[] = [
suggestionFixture({ id: "s1", colonyId: "c1", colonyCode: "A1", intent: "HONEY_SUPER" }),
suggestionFixture({ id: "s2", colonyId: "c2", colonyCode: "A2", intent: "HONEY_SUPER" }),
@@ -454,4 +459,23 @@ describe("SuggestionsPage", () => {
expect(screen.queryByRole("button", { name: "Alle übernehmen" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Verwerfen" })).not.toBeInTheDocument();
});
it("offers „Aufräumen“ for a CLEANUP suggestion and opens the cleanup sheet", async () => {
mockLists([
suggestionFixture({
id: "k1",
intent: "CLEANUP",
mode: "DO",
colonyId: "c9",
colonyCode: "B3",
title: "B3 aufräumen",
cleanup: { taskIds: ["t1"], queenId: null },
}),
]);
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Aufräumen" }));
expect(screen.getByText("Aufräumen-Sheet k1 für c9")).toBeInTheDocument();
});
});
@@ -8,6 +8,7 @@ import { toast } from "sonner";
import { EmptyState } from "@/components/empty-state";
import { PageHeader } from "@/components/page-header";
import { QueryState } from "@/components/query-state";
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";
@@ -99,6 +100,10 @@ export default function SuggestionsPage() {
const countRef = useRef<HTMLParagraphElement>(null);
const pendingFocus = useRef<FocusRequest>(null);
const [pendingIds, setPendingIds] = useState<ReadonlySet<string>>(new Set());
const [cleanupTarget, setCleanupTarget] = useState<{
id: string;
colonyId: string | null;
} | null>(null);
const openQuery = useInfiniteQuery({
queryKey: [...suggestionsKey({ status: "OPEN" }), "pages"],
@@ -314,6 +319,9 @@ export default function SuggestionsPage() {
onDismiss={(suggestion) => dismissMutation.mutate(suggestion)}
onAcceptAll={(ids) => acceptAllMutation.mutate(ids)}
onStartRound={handleStartRound}
onCleanup={(suggestion) =>
setCleanupTarget({ id: suggestion.id, colonyId: suggestion.colonyId })
}
/>
))}
{total > loaded && openQuery.hasNextPage ? (
@@ -369,6 +377,17 @@ export default function SuggestionsPage() {
) : null}
</div>
</section>
<CleanupSheet
suggestionId={cleanupTarget?.id ?? null}
colonyId={cleanupTarget?.colonyId ?? null}
onClose={() => setCleanupTarget(null)}
onDone={() => {
if (cleanupTarget) {
requestFocus({ target: neighbourOf(cleanupTarget.id), gone: cleanupTarget.id });
}
refresh();
}}
/>
</main>
);
}
@@ -0,0 +1,77 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import WinterPage from "./page";
import { fetchApiaries } from "@/lib/apiaries/api";
import { fetchColonies } from "@/lib/colonies/api";
import { fetchWinterChecks, fetchWinterSummary } from "@/lib/winter-checks/api";
import type { Apiary } from "@/lib/apiaries/types";
import type { Colony } from "@/lib/colonies/types";
let search = "";
vi.mock("next/navigation", () => ({
useSearchParams: () => new URLSearchParams(search),
}));
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({ hasPermission: () => true }),
}));
vi.mock("@/lib/winter-checks/api", () => ({
fetchWinterChecks: vi.fn(),
fetchWinterSummary: vi.fn(),
}));
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() }));
vi.mock("@/lib/apiaries/api", () => ({ fetchApiaries: vi.fn() }));
vi.mock("./winter-check-dialog", () => ({
WinterCheckDialog: ({ target }: { target: { code: string; season: string } }) => (
<p role="dialog">
Dialog {target.code} {target.season}
</p>
),
}));
function colony(id: string, code: string): Colony {
return { id, code, apiaryId: "a-1", status: "ACTIVE" } as Colony;
}
function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<WinterPage />
</QueryClientProvider>,
);
}
describe("WinterPage", () => {
beforeEach(() => {
search = "";
vi.mocked(fetchWinterChecks).mockResolvedValue([]);
vi.mocked(fetchWinterSummary).mockResolvedValue({
total: { wintered: 0, survived: 0, weak: 0, lost: 0, lostByCause: {}, lossRatePercent: null, pendingSpring: 0, isProvisional: false },
} as never);
vi.mocked(fetchColonies).mockResolvedValue([colony("c1", "A1"), colony("c2", "A2")]);
vi.mocked(fetchApiaries).mockResolvedValue([{ id: "a-1", name: "Obstwiese" } as Apiary]);
});
it("/winter?colonyId=c1&season=SPRING opens the dialog for that colony", async () => {
search = "colonyId=c1&season=SPRING";
renderPage();
expect(await screen.findByRole("dialog")).toHaveTextContent("Dialog A1 SPRING");
});
it("opens the autumn dialog for season=AUTUMN", async () => {
search = "colonyId=c2&season=AUTUMN";
renderPage();
expect(await screen.findByRole("dialog")).toHaveTextContent("Dialog A2 AUTUMN");
});
it("opens nothing without the parameters or for an unknown colony", async () => {
search = "colonyId=nope&season=SPRING";
renderPage();
await screen.findByText("A1");
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
});
+50 -5
View File
@@ -1,8 +1,9 @@
"use client";
import { useMemo, useState } from "react";
import { Suspense, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import {
ChevronLeft,
ChevronRight,
@@ -155,8 +156,19 @@ function RowActions({
);
}
/** `?colonyId=&season=` (a link from a next step) opens the dialog for that colony. */
export default function WinterPage() {
return (
<Suspense fallback={<LoadingState what="Winterdaten" />}>
<WinterContent />
</Suspense>
);
}
function WinterContent() {
const { hasPermission } = useAuth();
const searchParams = useSearchParams();
const [isDeepLinkClosed, setIsDeepLinkClosed] = useState(false);
const [winterYear, setWinterYear] = useState(() => winterYearFor(new Date()));
const [target, setTarget] = useState<WinterCheckTarget | null>(null);
const canCreate = hasPermission("winter-checks:create");
@@ -190,16 +202,41 @@ export default function WinterPage() {
0,
);
function open(row: WinterRow, season: "AUTUMN" | "SPRING", existing: WinterCheckWithColony | null) {
setTarget({
function targetFor(
row: WinterRow,
season: "AUTUMN" | "SPRING",
existing: WinterCheckWithColony | null,
): WinterCheckTarget {
return {
colonyId: row.colonyId,
code: row.code,
isLiving: row.isLiving,
season,
winterYear,
existing,
});
};
}
function open(row: WinterRow, season: "AUTUMN" | "SPRING", existing: WinterCheckWithColony | null) {
setTarget(targetFor(row, season, existing));
}
// The deep link counts once the lists are loaded; closing the dialog ends it.
const deepColonyId = searchParams.get("colonyId");
const deepSeason = searchParams.get("season");
const isDeepSeason = deepSeason === "AUTUMN" || deepSeason === "SPRING";
const isLoaded = [checksQuery, coloniesQuery, apiariesQuery].every((query) => query.isSuccess);
const deepRow =
!isDeepLinkClosed && isLoaded && deepColonyId !== null && isDeepSeason
? groups.flatMap((group) => group.rows).find((row) => row.colonyId === deepColonyId)
: undefined;
const deepExisting =
deepRow && deepSeason === "AUTUMN" ? deepRow.autumn : (deepRow?.spring ?? null);
const canOpenDeepLink = deepExisting ? canUpdate : canCreate;
const deepTarget =
deepRow && isDeepSeason && canOpenDeepLink
? targetFor(deepRow, deepSeason, deepExisting)
: null;
const activeTarget = target ?? deepTarget;
const listQueries = [checksQuery, coloniesQuery, apiariesQuery];
const isLoading = listQueries.some((query) => query.isLoading);
@@ -308,7 +345,15 @@ export default function WinterPage() {
))
)}
{target ? <WinterCheckDialog target={target} onClose={() => setTarget(null)} /> : null}
{activeTarget ? (
<WinterCheckDialog
target={activeTarget}
onClose={() => {
setTarget(null);
setIsDeepLinkClosed(true);
}}
/>
) : null}
</div>
);
}