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:
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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { 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 { NextStepsCard } from "./next-steps-card";
|
||||
import { fetchColonyNextSteps } from "@/lib/autopilot/api";
|
||||
import { colonyStepsFixture, stepFixture } from "@/lib/autopilot/step-fixtures";
|
||||
import { acceptSuggestion, bulkDismissSuggestions, reopenSuggestion } from "@/lib/suggestions/api";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import { toast } from "sonner";
|
||||
|
||||
vi.mock("@/lib/autopilot/api", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/autopilot/api")>()),
|
||||
fetchColonyNextSteps: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/suggestions/api", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/suggestions/api")>()),
|
||||
acceptSuggestion: vi.fn(),
|
||||
bulkDismissSuggestions: vi.fn(),
|
||||
reopenSuggestion: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/auth/auth-context", () => ({
|
||||
useAuth: () => ({ hasPermission: () => true }),
|
||||
}));
|
||||
vi.mock("@/components/suggestions/cleanup-sheet", () => ({
|
||||
CleanupSheet: ({ suggestionId }: { suggestionId: string | null }) =>
|
||||
suggestionId ? <p>Aufräumen-Sheet {suggestionId}</p> : null,
|
||||
}));
|
||||
vi.mock("sonner", () => ({
|
||||
toast: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn() }),
|
||||
}));
|
||||
|
||||
function renderCard() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<NextStepsCard colonyId="c1" />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const TASK_STEP = stepFixture({
|
||||
key: "task:t2",
|
||||
kind: "TASK",
|
||||
title: "Zwei",
|
||||
reasons: [],
|
||||
suggestionIds: [],
|
||||
action: { type: "OPEN_TASK", taskId: "t2", href: "/tasks" },
|
||||
});
|
||||
|
||||
describe("NextStepsCard", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchColonyNextSteps).mockReset();
|
||||
vi.mocked(acceptSuggestion).mockReset();
|
||||
vi.mocked(bulkDismissSuggestions).mockReset();
|
||||
});
|
||||
|
||||
it("NextStepsCard shows at most 3 steps with reasons and links CAPTURE to the form with colonyId", async () => {
|
||||
vi.mocked(fetchColonyNextSteps).mockResolvedValue(
|
||||
colonyStepsFixture({
|
||||
steps: [
|
||||
stepFixture({ key: "suggestion:s1", title: "Durchsicht" }),
|
||||
TASK_STEP,
|
||||
stepFixture({ key: "suggestion:s3", title: "Drei", suggestionIds: ["s3"] }),
|
||||
stepFixture({ key: "suggestion:s4", title: "Vier", suggestionIds: ["s4"] }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
renderCard();
|
||||
|
||||
expect(await screen.findByText("Durchsicht")).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("heading", { level: 3 })).toHaveLength(3);
|
||||
expect(screen.queryByText("Vier")).not.toBeInTheDocument();
|
||||
expect(screen.getAllByText("Letzte Durchsicht vor 8 Tagen.").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("In der Schwarmzeit alle 7 Tage.").length).toBeGreaterThan(0);
|
||||
const first = screen.getAllByRole("heading", { level: 3 })[0].closest("li") as HTMLElement;
|
||||
expect(within(first).getByRole("link", { name: "Erfassen" })).toHaveAttribute(
|
||||
"href",
|
||||
"/inspections/new?colonyId=c1",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "Aufgabe öffnen" })).toHaveAttribute("href", "/tasks");
|
||||
});
|
||||
|
||||
it("NextStepsCard shows the withdrawal hint, not a step", async () => {
|
||||
vi.mocked(fetchColonyNextSteps).mockResolvedValue(
|
||||
colonyStepsFixture({ steps: [], withdrawalUntil: "2026-05-05" }),
|
||||
);
|
||||
renderCard();
|
||||
|
||||
expect(await screen.findByText("Wartezeit bis 05.05.")).toBeInTheDocument();
|
||||
expect(screen.queryAllByRole("listitem")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("accepts a step with one tap and an idempotency key", async () => {
|
||||
vi.mocked(fetchColonyNextSteps).mockResolvedValue(
|
||||
colonyStepsFixture({
|
||||
steps: [
|
||||
stepFixture({
|
||||
intent: "HONEY_SUPER",
|
||||
title: "Honigraum",
|
||||
action: { type: "ACCEPT", suggestionId: "s1" },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
vi.mocked(acceptSuggestion).mockResolvedValue({} as never);
|
||||
renderCard();
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Übernehmen" }));
|
||||
|
||||
expect(acceptSuggestion).toHaveBeenCalledWith("s1", {}, { idempotencyKey: expect.any(String) });
|
||||
});
|
||||
|
||||
it("dismisses a merged step with all its suggestion ids", async () => {
|
||||
vi.mocked(fetchColonyNextSteps).mockResolvedValue(
|
||||
colonyStepsFixture({ steps: [stepFixture({ suggestionIds: ["s1", "s2"] })] }),
|
||||
);
|
||||
vi.mocked(bulkDismissSuggestions).mockResolvedValue({ dismissed: 2 });
|
||||
renderCard();
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Verwerfen" }));
|
||||
|
||||
expect(bulkDismissSuggestions).toHaveBeenCalledWith(["s1", "s2"], {
|
||||
idempotencyKey: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it("opens the cleanup sheet for a CLEANUP step", async () => {
|
||||
vi.mocked(fetchColonyNextSteps).mockResolvedValue(
|
||||
colonyStepsFixture({
|
||||
steps: [
|
||||
stepFixture({
|
||||
intent: "CLEANUP",
|
||||
title: "Volk aufräumen",
|
||||
suggestionIds: ["s9"],
|
||||
action: { type: "CLEANUP", suggestionId: "s9" },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
renderCard();
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Aufräumen" }));
|
||||
|
||||
expect(screen.getByText("Aufräumen-Sheet s9")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders nothing while there is nothing to say", async () => {
|
||||
vi.mocked(fetchColonyNextSteps).mockResolvedValue(colonyStepsFixture({ steps: [] }));
|
||||
const { container } = renderCard();
|
||||
|
||||
await vi.waitFor(() => expect(fetchColonyNextSteps).toHaveBeenCalled());
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("makes only the first step's action primary and the rest outline", async () => {
|
||||
vi.mocked(fetchColonyNextSteps).mockResolvedValue(
|
||||
colonyStepsFixture({
|
||||
steps: [stepFixture({ key: "suggestion:s1" }), stepFixture({ key: "suggestion:s2", title: "Zweite", suggestionIds: ["s2"] })],
|
||||
}),
|
||||
);
|
||||
renderCard();
|
||||
|
||||
const links = await screen.findAllByRole("link", { name: "Erfassen" });
|
||||
expect(links[0]).toHaveClass("bg-primary");
|
||||
expect(links[1]).not.toHaveClass("bg-primary");
|
||||
});
|
||||
|
||||
it("announces only a short status, not the whole list", async () => {
|
||||
vi.mocked(fetchColonyNextSteps).mockResolvedValue(colonyStepsFixture());
|
||||
const { container } = renderCard();
|
||||
|
||||
await screen.findByText("Durchsicht");
|
||||
expect(screen.getByRole("status")).toHaveTextContent("1 nächster Schritt");
|
||||
expect(container.querySelector("[aria-live]")).toBeNull();
|
||||
});
|
||||
|
||||
it("moves focus to the heading after a decision, or to the page h1 when the section is gone", async () => {
|
||||
vi.mocked(bulkDismissSuggestions).mockResolvedValue({ dismissed: 1 });
|
||||
vi.mocked(fetchColonyNextSteps)
|
||||
.mockResolvedValueOnce(colonyStepsFixture())
|
||||
.mockResolvedValueOnce(colonyStepsFixture());
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<h1>V-01</h1>
|
||||
<NextStepsCard colonyId="c1" />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Verwerfen" }));
|
||||
await vi.waitFor(() =>
|
||||
expect(screen.getByRole("heading", { name: "Als Nächstes" })).toHaveFocus(),
|
||||
);
|
||||
|
||||
vi.mocked(fetchColonyNextSteps).mockResolvedValue(colonyStepsFixture({ steps: [] }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Verwerfen" }));
|
||||
await vi.waitFor(() => expect(screen.getByRole("heading", { name: "V-01" })).toHaveFocus());
|
||||
});
|
||||
|
||||
it("reports a partly failed undo as „n von m wieder geöffnet“", async () => {
|
||||
vi.mocked(fetchColonyNextSteps).mockResolvedValue(
|
||||
colonyStepsFixture({ steps: [stepFixture({ suggestionIds: ["s1", "s2"] })] }),
|
||||
);
|
||||
vi.mocked(bulkDismissSuggestions).mockResolvedValue({ dismissed: 2 });
|
||||
vi.mocked(reopenSuggestion)
|
||||
.mockResolvedValueOnce({} as never)
|
||||
.mockRejectedValueOnce(new ApiError(409, "Der Vorschlag ist nicht mehr offen.", {}));
|
||||
renderCard();
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Verwerfen" }));
|
||||
await vi.waitFor(() => expect(toast).toHaveBeenCalledWith("Verworfen", expect.anything()));
|
||||
const options = vi.mocked(toast).mock.calls.find(([text]) => text === "Verworfen")![1] as unknown as {
|
||||
action: { onClick: () => void };
|
||||
};
|
||||
options.action.onClick();
|
||||
|
||||
await vi.waitFor(() => expect(toast.error).toHaveBeenCalledWith("1 von 2 wieder geöffnet"));
|
||||
});
|
||||
|
||||
it("shows a compact hint with retry for a server error and nothing when offline", async () => {
|
||||
vi.mocked(fetchColonyNextSteps).mockRejectedValue(new ApiError(500, "boom", {}));
|
||||
const first = renderCard();
|
||||
|
||||
expect(await screen.findByRole("status")).toHaveTextContent(
|
||||
"Die nächsten Schritte konnten nicht geladen werden.",
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Erneut versuchen" })).toBeInTheDocument();
|
||||
first.unmount();
|
||||
|
||||
vi.mocked(fetchColonyNextSteps).mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
const second = renderCard();
|
||||
await vi.waitFor(() => expect(fetchColonyNextSteps).toHaveBeenCalledTimes(2));
|
||||
await vi.waitFor(() => expect(second.container).toBeEmptyDOMElement());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Check, Eraser, ExternalLink, Hourglass, ListTodo, X } from "lucide-react";
|
||||
import { CleanupSheet } from "@/components/suggestions/cleanup-sheet";
|
||||
import { DueChip } from "@/components/task-status";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { isNetworkError } from "@/lib/http/fetch-with-timeout";
|
||||
import { colonyNextStepsKey, fetchColonyNextSteps } from "@/lib/autopilot/api";
|
||||
import { dueChipProps, formatDayMonth } from "@/lib/autopilot/format";
|
||||
import type { NextStep } from "@/lib/autopilot/types";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { todayLocalIsoDate } from "@/lib/dates";
|
||||
import { INTENT_ICONS } from "@/lib/suggestions/labels";
|
||||
import { useStepActions } from "./use-step-actions";
|
||||
|
||||
/** The colony page shows at most this many steps. */
|
||||
const MAX_STEPS = 3;
|
||||
|
||||
const NEXT_STEPS_HEADING_ID = "colony-next-steps-heading";
|
||||
|
||||
interface StepActionsProps {
|
||||
step: NextStep;
|
||||
/** The one main button of the view; every other action is an outline button. */
|
||||
primary?: boolean;
|
||||
/** Offline the task list is not available: no link to it. */
|
||||
hideTaskLink?: boolean;
|
||||
busy?: boolean;
|
||||
/** tasks:create - needed for „Übernehmen“. */
|
||||
canAccept?: boolean;
|
||||
/** tasks:update - needed for „Verwerfen“ and „Aufräumen“. */
|
||||
canDismiss?: boolean;
|
||||
onAccept?: (step: NextStep) => void;
|
||||
onDismiss?: (step: NextStep) => void;
|
||||
onCleanup?: (step: NextStep) => void;
|
||||
}
|
||||
|
||||
/** The main action of a step (a real link where it navigates) plus „Verwerfen“. */
|
||||
export function StepActions({
|
||||
step,
|
||||
primary = true,
|
||||
hideTaskLink = false,
|
||||
busy = false,
|
||||
canAccept = true,
|
||||
canDismiss = true,
|
||||
onAccept,
|
||||
onDismiss,
|
||||
onCleanup,
|
||||
}: StepActionsProps) {
|
||||
const { action } = step;
|
||||
const variant = primary ? "default" : "outline";
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{action.type === "CAPTURE" ? (
|
||||
<Link href={action.href} className={buttonVariants({ size: "touch", variant })}>
|
||||
<Check aria-hidden />
|
||||
Erfassen
|
||||
</Link>
|
||||
) : null}
|
||||
{action.type === "OPEN_TASK" && !hideTaskLink ? (
|
||||
<Link href={action.href} className={buttonVariants({ size: "touch", variant: "outline" })}>
|
||||
<ExternalLink aria-hidden />
|
||||
Aufgabe öffnen
|
||||
</Link>
|
||||
) : null}
|
||||
{action.type === "ACCEPT" && onAccept && canAccept ? (
|
||||
<Button size="touch" variant={variant} disabled={busy} onClick={() => onAccept(step)}>
|
||||
<Check aria-hidden />
|
||||
Übernehmen
|
||||
</Button>
|
||||
) : null}
|
||||
{action.type === "CLEANUP" && onCleanup && canDismiss ? (
|
||||
<Button size="touch" variant={variant} disabled={busy} onClick={() => onCleanup(step)}>
|
||||
<Eraser aria-hidden />
|
||||
Aufräumen
|
||||
</Button>
|
||||
) : null}
|
||||
{step.dismissible && onDismiss && canDismiss ? (
|
||||
<Button size="touch" variant="ghost" disabled={busy} onClick={() => onDismiss(step)}>
|
||||
<X aria-hidden />
|
||||
Verwerfen
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface NextStepsViewProps extends Omit<StepActionsProps, "step" | "busy" | "primary"> {
|
||||
steps: readonly NextStep[];
|
||||
/** YYYY-MM-DD; a hint („Wartezeit bis 05.05.“), never a step. */
|
||||
withdrawalUntil: string | null;
|
||||
busyKeys?: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
/** The steps as a list; without steps and hint nothing is rendered. */
|
||||
export function NextStepsView({
|
||||
steps,
|
||||
withdrawalUntil,
|
||||
busyKeys,
|
||||
...actions
|
||||
}: NextStepsViewProps) {
|
||||
const [today] = useState(() => todayLocalIsoDate());
|
||||
const shown = steps.slice(0, MAX_STEPS);
|
||||
if (shown.length === 0 && !withdrawalUntil) return null;
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby={NEXT_STEPS_HEADING_ID}
|
||||
className="flex flex-col gap-3 rounded-2xl border border-primary/25 bg-card p-4 shadow-[var(--shadow-soft)] sm:p-5"
|
||||
>
|
||||
<h2
|
||||
id={NEXT_STEPS_HEADING_ID}
|
||||
tabIndex={-1}
|
||||
className="font-heading text-base font-semibold outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
>
|
||||
Als Nächstes
|
||||
</h2>
|
||||
{/* Only this short text is announced, not the whole list. */}
|
||||
<p role="status" className="sr-only">
|
||||
{shown.length === 1 ? "1 nächster Schritt" : `${shown.length} nächste Schritte`}
|
||||
</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
{shown.length > 0 ? (
|
||||
<ol className="flex flex-col gap-3">
|
||||
{shown.map((step, index) => {
|
||||
const Icon = step.intent ? INTENT_ICONS[step.intent] : ListTodo;
|
||||
return (
|
||||
<li
|
||||
key={step.key}
|
||||
className="flex flex-col gap-3 rounded-xl border border-border/60 p-3"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/12 text-primary">
|
||||
<Icon className="size-5" aria-hidden />
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<h3 className="font-medium">{step.title}</h3>
|
||||
{step.reasons.length > 0 ? (
|
||||
<ul className="flex flex-col gap-0.5 text-sm text-muted-foreground">
|
||||
{step.reasons.slice(0, 2).map((reason) => (
|
||||
<li key={reason}>{reason}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
<div>
|
||||
<DueChip {...dueChipProps(step.dueOn, today)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<StepActions
|
||||
step={step}
|
||||
primary={index === 0}
|
||||
busy={busyKeys?.has(step.key)}
|
||||
{...actions}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
) : null}
|
||||
{withdrawalUntil ? (
|
||||
<p className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Hourglass className="size-4 shrink-0" aria-hidden />
|
||||
Wartezeit bis {formatDayMonth(withdrawalUntil)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** „Als Nächstes“ on top of the colony page: the colony's next steps from the autopilot. */
|
||||
export function NextStepsCard({ colonyId }: { colonyId: string }) {
|
||||
const { hasPermission } = useAuth();
|
||||
const query = useQuery({
|
||||
queryKey: colonyNextStepsKey(colonyId),
|
||||
queryFn: () => fetchColonyNextSteps(colonyId),
|
||||
});
|
||||
const stepActions = useStepActions(NEXT_STEPS_HEADING_ID);
|
||||
|
||||
if (query.isError) {
|
||||
// Offline the colony page still works; a missing hint is no news.
|
||||
if (isNetworkError(query.error)) return null;
|
||||
return (
|
||||
<p role="status" className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
Die nächsten Schritte konnten nicht geladen werden.
|
||||
<Button
|
||||
type="button"
|
||||
size="touch"
|
||||
variant="ghost"
|
||||
disabled={query.isFetching}
|
||||
onClick={() => void query.refetch()}
|
||||
>
|
||||
Erneut versuchen
|
||||
</Button>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (!query.data) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextStepsView
|
||||
steps={query.data.steps}
|
||||
withdrawalUntil={query.data.withdrawalUntil}
|
||||
busyKeys={stepActions.busyKeys}
|
||||
canAccept={hasPermission("tasks:create")}
|
||||
canDismiss={hasPermission("tasks:update")}
|
||||
onAccept={stepActions.accept}
|
||||
onDismiss={stepActions.dismiss}
|
||||
onCleanup={(step) => stepActions.openCleanup(step, colonyId)}
|
||||
/>
|
||||
<CleanupSheet
|
||||
suggestionId={stepActions.cleanupTarget?.suggestionId ?? null}
|
||||
colonyId={stepActions.cleanupTarget?.colonyId ?? null}
|
||||
onClose={stepActions.closeCleanup}
|
||||
onDone={() => void stepActions.afterCleanup()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { CalendarRange } from "lucide-react";
|
||||
import type { SeasonSegment } from "@/lib/autopilot/types";
|
||||
|
||||
export const SEGMENT_LABELS: Record<SeasonSegment, string> = {
|
||||
WINTERRUHE: "Winterruhe",
|
||||
FRUEHJAHR: "Frühjahr",
|
||||
SCHWARMZEIT: "Schwarmzeit",
|
||||
SOMMER: "Sommer",
|
||||
SPAETSOMMER: "Spätsommer",
|
||||
HERBST: "Herbst",
|
||||
};
|
||||
|
||||
/** „Schwarmzeit · alle 7 Tage“; without an interval just the segment. */
|
||||
export function seasonChipText(season: {
|
||||
segment: SeasonSegment;
|
||||
intervalDays: number | null;
|
||||
}): string {
|
||||
const label = SEGMENT_LABELS[season.segment];
|
||||
if (season.intervalDays === null) return label;
|
||||
return season.intervalDays === 1
|
||||
? `${label} · täglich`
|
||||
: `${label} · alle ${season.intervalDays} Tage`;
|
||||
}
|
||||
|
||||
interface SeasonChipProps {
|
||||
segment: SeasonSegment;
|
||||
intervalDays?: number | null;
|
||||
/** Names the apiary: „Obstwiese: Schwarmzeit“. */
|
||||
apiaryName?: string;
|
||||
}
|
||||
|
||||
/** The season of an apiary as icon plus words. */
|
||||
export function SeasonChip({ segment, intervalDays = null, apiaryName }: SeasonChipProps) {
|
||||
const text = seasonChipText({ segment, intervalDays });
|
||||
return (
|
||||
<span className="inline-flex min-h-6 items-center gap-1 rounded-full bg-muted px-2.5 py-0.5 text-xs font-medium text-foreground">
|
||||
<CalendarRange className="size-3.5 shrink-0" aria-hidden />
|
||||
{apiaryName ? `${apiaryName}: ${text}` : text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { newIdempotencyKey } from "@/lib/offline/idempotency";
|
||||
import type { NextStep } from "@/lib/autopilot/types";
|
||||
import {
|
||||
acceptSuggestion,
|
||||
bulkDismissSuggestions,
|
||||
reopenSuggestion,
|
||||
} from "@/lib/suggestions/api";
|
||||
import { suggestionErrorText } from "@/lib/suggestions/errors";
|
||||
|
||||
/** Sonner's own buttons are 24 px high; actions in a toast need 44 px. */
|
||||
const TOAST_ACTION_CLASS = "h-11! min-h-11! px-4!";
|
||||
const UNDO_TOAST_MS = 8000;
|
||||
/** Everything a decision can change on screen. */
|
||||
const REFRESHED_KEYS = [
|
||||
["colonies"],
|
||||
["apiaries"],
|
||||
["suggestions"],
|
||||
["tasks"],
|
||||
["dashboard"],
|
||||
["autopilot"],
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* After a decision focus goes to the section heading, or to the page's h1
|
||||
* when the section is gone - never onto the removed button.
|
||||
*/
|
||||
function focusHeadingOrPage(headingId: string) {
|
||||
requestAnimationFrame(() => {
|
||||
const target = document.getElementById(headingId) ?? document.querySelector("h1");
|
||||
if (!(target instanceof HTMLElement)) return;
|
||||
if (!target.hasAttribute("tabindex")) target.setAttribute("tabindex", "-1");
|
||||
target.focus();
|
||||
});
|
||||
}
|
||||
|
||||
class PartialReopenError extends Error {
|
||||
constructor(
|
||||
readonly done: number,
|
||||
readonly total: number,
|
||||
readonly reason: unknown,
|
||||
) {
|
||||
super("Not all suggestions were reopened");
|
||||
}
|
||||
}
|
||||
|
||||
export interface CleanupTarget {
|
||||
suggestionId: string;
|
||||
colonyId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept, dismiss and clean up the steps of the autopilot - shared by the
|
||||
* colony page and the start page. Every decision (also a failed one: a 409
|
||||
* means the list is stale) reloads what it may have changed.
|
||||
*/
|
||||
export function useStepActions(headingId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
const [busyKeys, setBusyKeys] = useState<ReadonlySet<string>>(new Set());
|
||||
const [cleanupTarget, setCleanupTarget] = useState<CleanupTarget | null>(null);
|
||||
|
||||
function setBusy(key: string, busy: boolean) {
|
||||
setBusyKeys((current) => {
|
||||
const next = new Set(current);
|
||||
if (busy) next.add(key);
|
||||
else next.delete(key);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
async function refresh() {
|
||||
await Promise.all(
|
||||
REFRESHED_KEYS.map((queryKey) => queryClient.invalidateQueries({ queryKey })),
|
||||
);
|
||||
}
|
||||
async function settle() {
|
||||
await refresh();
|
||||
focusHeadingOrPage(headingId);
|
||||
}
|
||||
|
||||
const reopen = useMutation({
|
||||
mutationFn: async (ids: string[]) => {
|
||||
const results = await Promise.allSettled(
|
||||
ids.map((id) => reopenSuggestion(id, { idempotencyKey: newIdempotencyKey() })),
|
||||
);
|
||||
const failed = results.find((result) => result.status === "rejected");
|
||||
if (failed) {
|
||||
throw new PartialReopenError(
|
||||
results.filter((result) => result.status === "fulfilled").length,
|
||||
results.length,
|
||||
failed.reason,
|
||||
);
|
||||
}
|
||||
},
|
||||
onSuccess: () => toast("Wieder geöffnet"),
|
||||
onError: (error) => {
|
||||
if (error instanceof PartialReopenError && error.done > 0) {
|
||||
toast.error(`${error.done} von ${error.total} wieder geöffnet`);
|
||||
} else {
|
||||
toast.error(suggestionErrorText(error instanceof PartialReopenError ? error.reason : error));
|
||||
}
|
||||
},
|
||||
onSettled: settle,
|
||||
});
|
||||
|
||||
const accept = useMutation({
|
||||
mutationFn: (step: NextStep) => {
|
||||
if (step.action.type !== "ACCEPT") throw new Error("Not an ACCEPT step");
|
||||
return acceptSuggestion(step.action.suggestionId, {}, { idempotencyKey: newIdempotencyKey() });
|
||||
},
|
||||
onMutate: (step) => setBusy(step.key, true),
|
||||
onSuccess: () => toast.success("Aufgabe angelegt"),
|
||||
onError: (error) => toast.error(suggestionErrorText(error)),
|
||||
onSettled: async (_result, _error, step) => {
|
||||
await settle();
|
||||
setBusy(step.key, false);
|
||||
},
|
||||
});
|
||||
|
||||
const dismiss = useMutation({
|
||||
mutationFn: (step: NextStep) =>
|
||||
bulkDismissSuggestions(step.suggestionIds, { idempotencyKey: newIdempotencyKey() }),
|
||||
onMutate: (step) => setBusy(step.key, true),
|
||||
onSuccess: (_result, step) =>
|
||||
toast("Verworfen", {
|
||||
duration: UNDO_TOAST_MS,
|
||||
action: { label: "Rückgängig", onClick: () => reopen.mutate(step.suggestionIds) },
|
||||
classNames: { actionButton: TOAST_ACTION_CLASS },
|
||||
}),
|
||||
onError: (error) => toast.error(suggestionErrorText(error)),
|
||||
onSettled: async (_result, _error, step) => {
|
||||
await settle();
|
||||
setBusy(step.key, false);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
busyKeys,
|
||||
accept: (step: NextStep) => accept.mutate(step),
|
||||
dismiss: (step: NextStep) => dismiss.mutate(step),
|
||||
openCleanup: (step: NextStep, colonyId: string | null) => {
|
||||
if (step.action.type === "CLEANUP") {
|
||||
setCleanupTarget({ suggestionId: step.action.suggestionId, colonyId });
|
||||
}
|
||||
},
|
||||
closeCleanup: () => setCleanupTarget(null),
|
||||
cleanupTarget,
|
||||
afterCleanup: settle,
|
||||
};
|
||||
}
|
||||
@@ -181,6 +181,27 @@ describe("ChoiceGroup", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChoiceGroup – disabled options", () => {
|
||||
const OPTIONS: ChoiceOption<string>[] = [
|
||||
{ value: "a", label: "A" },
|
||||
{ value: "b", label: "B", disabled: true },
|
||||
{ value: "c", label: "C" },
|
||||
];
|
||||
|
||||
it("cannot be chosen by tap and is skipped by the arrow keys", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ChoiceGroup options={OPTIONS} value="a" onChange={onChange} label="Wahl" />);
|
||||
|
||||
expect(screen.getByRole("radio", { name: "B" })).toBeDisabled();
|
||||
await userEvent.click(screen.getByRole("radio", { name: "B" }));
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
|
||||
screen.getByRole("radio", { name: "A" }).focus();
|
||||
await userEvent.keyboard("{ArrowRight}");
|
||||
expect(onChange).toHaveBeenCalledWith("c");
|
||||
});
|
||||
});
|
||||
|
||||
describe("choiceOptions", () => {
|
||||
const MOOD_LABELS = { NONE: "Keine", LOW: "Leicht", HIGH: "Stark" } as const;
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ export interface ChoiceOption<T extends ChoiceValue> {
|
||||
srSuffix?: string;
|
||||
/** Replaces the default selected look, e.g. a tone colour of a rating step. */
|
||||
selectedClassName?: string;
|
||||
/** Shown but not choosable; arrow keys skip it. */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/** Distinct per value and type: "null" (text) and null must not collide. */
|
||||
@@ -92,9 +94,15 @@ export function ChoiceGroup<T extends ChoiceValue>({
|
||||
const tabStop = checkedIndex === -1 ? 0 : checkedIndex;
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent<HTMLButtonElement>, index: number) {
|
||||
const next = targetIndex(event.key, index, options.length);
|
||||
let next = targetIndex(event.key, index, options.length);
|
||||
if (next === null) return;
|
||||
event.preventDefault();
|
||||
// Skip disabled options in the direction of travel (at most one lap).
|
||||
const step = event.key === "Home" ? 1 : event.key === "End" ? -1 : (KEY_STEPS[event.key] ?? 1);
|
||||
for (let tries = 0; options[next].disabled && tries < options.length; tries++) {
|
||||
next = (next + step + options.length) % options.length;
|
||||
}
|
||||
if (options[next].disabled) return;
|
||||
radioRefs.current[next]?.focus();
|
||||
onChange(options[next].value);
|
||||
}
|
||||
@@ -120,6 +128,7 @@ export function ChoiceGroup<T extends ChoiceValue>({
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
disabled={option.disabled}
|
||||
tabIndex={index === tabStop ? 0 : -1}
|
||||
onClick={() =>
|
||||
onChange(selected && clearValue !== undefined ? clearValue : option.value)
|
||||
@@ -129,6 +138,7 @@ export function ChoiceGroup<T extends ChoiceValue>({
|
||||
"min-h-11 rounded-lg border px-2 text-sm outline-none motion-safe:transition-colors",
|
||||
"focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50",
|
||||
!selected && "border-border bg-background hover:bg-muted",
|
||||
option.disabled && "cursor-not-allowed opacity-50 hover:bg-background",
|
||||
selected && isUnanswered && "border-foreground/30 bg-muted font-medium text-foreground",
|
||||
selected &&
|
||||
!isUnanswered &&
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { NotificationBell } from "./notification-bell";
|
||||
import {
|
||||
fetchNotifications,
|
||||
markAllNotificationsRead,
|
||||
markNotificationRead,
|
||||
} from "@/lib/notifications/api";
|
||||
import type { Notification } from "@/lib/notifications/types";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import { toast } from "sonner";
|
||||
|
||||
vi.mock("@/lib/notifications/api", () => ({
|
||||
fetchNotifications: vi.fn(),
|
||||
markNotificationRead: vi.fn(),
|
||||
markAllNotificationsRead: vi.fn(),
|
||||
}));
|
||||
vi.mock("sonner", () => ({ toast: { error: vi.fn(), success: vi.fn() } }));
|
||||
|
||||
function notification(overrides: Partial<Notification>): Notification {
|
||||
return {
|
||||
id: "n-1",
|
||||
priority: "WARNUNG",
|
||||
category: "WITHDRAWAL",
|
||||
message: "Wartezeit-Konflikt bei B3",
|
||||
relatedType: null,
|
||||
relatedId: null,
|
||||
link: null,
|
||||
read: false,
|
||||
createdAt: "2026-08-01T10:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderBell() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
invalidate = vi.spyOn(client, "invalidateQueries");
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<NotificationBell />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
let invalidate: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
describe("NotificationBell", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(markAllNotificationsRead).mockReset().mockResolvedValue({ updated: 2 });
|
||||
vi.mocked(markNotificationRead).mockReset().mockResolvedValue({} as never);
|
||||
vi.mocked(toast.error).mockReset();
|
||||
vi.mocked(toast.success).mockReset();
|
||||
});
|
||||
|
||||
it("„Alle gelesen“ calls read-all and a notification with link navigates there", async () => {
|
||||
vi.mocked(fetchNotifications).mockResolvedValue([
|
||||
notification({ id: "n-1", link: "/harvests/h-1" }),
|
||||
notification({ id: "n-2", message: "Ohne Ziel", relatedType: "Colony", relatedId: "c-9" }),
|
||||
]);
|
||||
renderBell();
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Benachrichtigungen" }));
|
||||
|
||||
const linked = await screen.findByText("Wartezeit-Konflikt bei B3");
|
||||
expect(linked.closest("a")).toHaveAttribute("href", "/harvests/h-1");
|
||||
expect(screen.getByText("Ohne Ziel").closest("a")).toHaveAttribute("href", "/colonies/c-9");
|
||||
|
||||
await userEvent.click(screen.getByRole("menuitem", { name: /Alle gelesen/ }));
|
||||
await waitFor(() => expect(markAllNotificationsRead).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("ignores a link that is not an internal path", async () => {
|
||||
vi.mocked(fetchNotifications).mockResolvedValue([
|
||||
notification({ link: "//evil.example/x" }),
|
||||
notification({ id: "n-2", message: "Schema", link: "https://evil.example" }),
|
||||
]);
|
||||
renderBell();
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Benachrichtigungen" }));
|
||||
expect((await screen.findByText("Wartezeit-Konflikt bei B3")).closest("a")).toBeNull();
|
||||
expect(screen.getByText("Schema").closest("a")).toBeNull();
|
||||
});
|
||||
|
||||
it("hides „Alle gelesen“ when nothing is unread and names the category in words", async () => {
|
||||
vi.mocked(fetchNotifications).mockResolvedValue([notification({ read: true })]);
|
||||
renderBell();
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Benachrichtigungen" }));
|
||||
expect(await screen.findByText("Wartezeit")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("menuitem", { name: /Alle gelesen/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clicking an unread notification marks it read and its link navigates", async () => {
|
||||
vi.mocked(fetchNotifications).mockResolvedValue([notification({ id: "n-7", link: "/harvests/h-7" })]);
|
||||
renderBell();
|
||||
await userEvent.click(await screen.findByRole("button", { name: /Benachrichtigungen/ }));
|
||||
const text = await screen.findByText("Wartezeit-Konflikt bei B3");
|
||||
expect(text.closest("a")).toHaveAttribute("href", "/harvests/h-7");
|
||||
await userEvent.click(text);
|
||||
await waitFor(() => expect(markNotificationRead).toHaveBeenCalled());
|
||||
expect(vi.mocked(markNotificationRead).mock.calls[0][0]).toBe("n-7");
|
||||
});
|
||||
|
||||
it("a 404 on mark-read refreshes the list without an error toast", async () => {
|
||||
vi.mocked(fetchNotifications).mockResolvedValue([notification({})]);
|
||||
vi.mocked(markNotificationRead).mockRejectedValue(new ApiError(404, "Not found"));
|
||||
renderBell();
|
||||
await userEvent.click(await screen.findByRole("button", { name: /Benachrichtigungen/ }));
|
||||
await userEvent.click(await screen.findByText("Wartezeit-Konflikt bei B3"));
|
||||
await waitFor(() => expect(invalidate).toHaveBeenCalledWith({ queryKey: ["notifications"] }));
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("another mark-read error shows one toast with a fixed id", async () => {
|
||||
vi.mocked(fetchNotifications).mockResolvedValue([notification({})]);
|
||||
vi.mocked(markNotificationRead).mockRejectedValue(new ApiError(500, "boom"));
|
||||
renderBell();
|
||||
await userEvent.click(await screen.findByRole("button", { name: /Benachrichtigungen/ }));
|
||||
await userEvent.click(await screen.findByText("Wartezeit-Konflikt bei B3"));
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledTimes(1));
|
||||
expect(vi.mocked(toast.error).mock.calls[0][1]).toEqual({ id: "notification-read-error" });
|
||||
});
|
||||
|
||||
it("the badge is announced with the unread count, the digit is hidden from readers", async () => {
|
||||
vi.mocked(fetchNotifications).mockResolvedValue([notification({}), notification({ id: "n-2" })]);
|
||||
renderBell();
|
||||
expect(await screen.findByText("2 ungelesene Benachrichtigungen")).toHaveClass("sr-only");
|
||||
expect(screen.getByText("2", { selector: "[aria-hidden='true']" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("read-all confirms with a toast and refreshes", async () => {
|
||||
vi.mocked(fetchNotifications).mockResolvedValue([notification({})]);
|
||||
renderBell();
|
||||
await userEvent.click(await screen.findByRole("button", { name: /Benachrichtigungen/ }));
|
||||
await userEvent.click(await screen.findByRole("menuitem", { name: /Alle gelesen/ }));
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith("Alle als gelesen markiert."));
|
||||
expect(invalidate).toHaveBeenCalledWith({ queryKey: ["notifications"] });
|
||||
});
|
||||
|
||||
it("read-all refreshes the list on failure too", async () => {
|
||||
vi.mocked(fetchNotifications).mockResolvedValue([notification({})]);
|
||||
vi.mocked(markAllNotificationsRead).mockRejectedValue(new ApiError(500, "x"));
|
||||
renderBell();
|
||||
await userEvent.click(await screen.findByRole("button", { name: /Benachrichtigungen/ }));
|
||||
await userEvent.click(await screen.findByRole("menuitem", { name: /Alle gelesen/ }));
|
||||
await waitFor(() => expect(invalidate).toHaveBeenCalledWith({ queryKey: ["notifications"] }));
|
||||
});
|
||||
|
||||
it.each([
|
||||
["/harvests/h-1?tab=a&x=1", true],
|
||||
["/\t/x", false],
|
||||
["//evil", false],
|
||||
["/\\evil", false],
|
||||
["https://evil.example", false],
|
||||
["/a b", false],
|
||||
[`/${"a".repeat(300)}`, false],
|
||||
])("link %j is internal: %s", async (link, internal) => {
|
||||
vi.mocked(fetchNotifications).mockResolvedValue([notification({ link })]);
|
||||
renderBell();
|
||||
await userEvent.click(await screen.findByRole("button", { name: /Benachrichtigungen/ }));
|
||||
const anchor = (await screen.findByText("Wartezeit-Konflikt bei B3")).closest("a");
|
||||
if (internal) expect(anchor).toHaveAttribute("href", link);
|
||||
else expect(anchor).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -2,10 +2,19 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Bell } from "lucide-react";
|
||||
import { Bell, CheckCheck } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { fetchNotifications, markNotificationRead } from "@/lib/notifications/api";
|
||||
import { Notification, NotificationPriority } from "@/lib/notifications/types";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import {
|
||||
fetchNotifications,
|
||||
markAllNotificationsRead,
|
||||
markNotificationRead,
|
||||
} from "@/lib/notifications/api";
|
||||
import {
|
||||
Notification,
|
||||
NotificationCategory,
|
||||
NotificationPriority,
|
||||
} from "@/lib/notifications/types";
|
||||
import { Badge, badgeVariants } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -15,16 +24,39 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
const PRIORITY_VARIANT: Record<NotificationPriority, NonNullable<Parameters<typeof badgeVariants>[0]>["variant"]> = {
|
||||
const PRIORITY_VARIANT: Record<
|
||||
NotificationPriority,
|
||||
NonNullable<Parameters<typeof badgeVariants>[0]>["variant"]
|
||||
> = {
|
||||
INFO: "secondary",
|
||||
HINWEIS: "outline",
|
||||
WARNUNG: "warning",
|
||||
KRITISCH: "destructive",
|
||||
};
|
||||
|
||||
const CATEGORY_LABEL: Record<NotificationCategory, string> = {
|
||||
TASK_DUE: "Aufgabe",
|
||||
DIGEST: "Übersicht",
|
||||
SUGGESTION: "Vorschlag",
|
||||
WITHDRAWAL: "Wartezeit",
|
||||
SEASON: "Saison",
|
||||
SYSTEM: "System",
|
||||
};
|
||||
|
||||
/** Same rule as the backend (`assertAppLink`): an internal path, never "//host", "/\host" or a scheme. */
|
||||
const INTERNAL_PATH = /^\/(?![/\\])[A-Za-z0-9\-._~/?=&%]{0,299}$/;
|
||||
|
||||
function isInternalPath(link: string): boolean {
|
||||
return INTERNAL_PATH.test(link);
|
||||
}
|
||||
|
||||
function notificationHref(notification: Notification): string | null {
|
||||
if (notification.relatedType === "Colony") return `/colonies/${notification.relatedId}`;
|
||||
if (notification.relatedType === "Apiary") return `/apiaries/${notification.relatedId}`;
|
||||
if (notification.link)
|
||||
return isInternalPath(notification.link) ? notification.link : null;
|
||||
if (notification.relatedType === "Colony")
|
||||
return `/colonies/${notification.relatedId}`;
|
||||
if (notification.relatedType === "Apiary")
|
||||
return `/apiaries/${notification.relatedId}`;
|
||||
if (notification.relatedType === "Task") return "/tasks";
|
||||
return null;
|
||||
}
|
||||
@@ -37,33 +69,76 @@ export function NotificationBell() {
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
const refresh = () =>
|
||||
void queryClient.invalidateQueries({ queryKey: ["notifications"] });
|
||||
|
||||
const readMutation = useMutation({
|
||||
mutationFn: markNotificationRead,
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["notifications"] });
|
||||
mutationFn: (id: string) => markNotificationRead(id),
|
||||
onSettled: refresh,
|
||||
onError: (error) => {
|
||||
// 404: the entry is gone or no longer visible; the refresh drops it.
|
||||
if (error instanceof ApiError && error.status === 404) return;
|
||||
toast.error("Benachrichtigung konnte nicht als gelesen markiert werden", {
|
||||
id: "notification-read-error",
|
||||
});
|
||||
},
|
||||
onError: () => toast.error("Benachrichtigung konnte nicht als gelesen markiert werden"),
|
||||
});
|
||||
|
||||
const unreadCount = notifications?.filter((notification) => !notification.read).length ?? 0;
|
||||
const readAllMutation = useMutation({
|
||||
mutationFn: () => markAllNotificationsRead(),
|
||||
onSuccess: () => toast.success("Alle als gelesen markiert."),
|
||||
onSettled: refresh,
|
||||
onError: () =>
|
||||
toast.error(
|
||||
"Benachrichtigungen konnten nicht als gelesen markiert werden",
|
||||
{
|
||||
id: "notification-read-error",
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
const unreadCount =
|
||||
notifications?.filter((notification) => !notification.read).length ?? 0;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="icon" className="relative" aria-label="Benachrichtigungen" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="relative"
|
||||
aria-label="Benachrichtigungen"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Bell className="size-4.5" />
|
||||
{unreadCount > 0 ? (
|
||||
<span className="absolute -top-0.5 -right-0.5 flex size-4 items-center justify-center rounded-full bg-destructive text-[0.6rem] font-medium text-destructive-foreground">
|
||||
{unreadCount > 9 ? "9+" : unreadCount}
|
||||
<span aria-hidden="true">
|
||||
{unreadCount > 9 ? "9+" : unreadCount}
|
||||
</span>
|
||||
<span className="sr-only">
|
||||
{unreadCount} ungelesene Benachrichtigungen
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-80">
|
||||
{unreadCount > 0 ? (
|
||||
<DropdownMenuItem
|
||||
className="min-h-11 gap-2 px-3 font-medium"
|
||||
disabled={readAllMutation.isPending}
|
||||
onClick={() => readAllMutation.mutate()}
|
||||
>
|
||||
<CheckCheck aria-hidden />
|
||||
Alle gelesen
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{!notifications || notifications.length === 0 ? (
|
||||
<p className="p-3 text-sm text-muted-foreground">Keine Benachrichtigungen.</p>
|
||||
<p className="p-3 text-sm text-muted-foreground">
|
||||
Keine Benachrichtigungen.
|
||||
</p>
|
||||
) : (
|
||||
notifications.slice(0, 10).map((notification) => {
|
||||
const href = notificationHref(notification);
|
||||
@@ -72,11 +147,18 @@ export function NotificationBell() {
|
||||
className={`flex flex-col gap-1 p-2 text-sm ${notification.read ? "opacity-60" : ""}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Badge variant={PRIORITY_VARIANT[notification.priority]}>
|
||||
{notification.priority}
|
||||
</Badge>
|
||||
<span className="flex items-center gap-2">
|
||||
<Badge variant={PRIORITY_VARIANT[notification.priority]}>
|
||||
{notification.priority}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{CATEGORY_LABEL[notification.category]}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(notification.createdAt).toLocaleDateString("de-DE")}
|
||||
{new Date(notification.createdAt).toLocaleDateString(
|
||||
"de-DE",
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<span>{notification.message}</span>
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { CleanupSheet } from "./cleanup-sheet";
|
||||
import { suggestionFixture } from "./suggestion-fixtures";
|
||||
import { applyCleanup } from "@/lib/autopilot/api";
|
||||
import { fetchColony } from "@/lib/colonies/api";
|
||||
import { fetchQueen } from "@/lib/queens/api";
|
||||
import { fetchSuggestions } from "@/lib/suggestions/api";
|
||||
import { fetchTask } from "@/lib/tasks/api";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import type { Colony } from "@/lib/colonies/types";
|
||||
import type { Queen } from "@/lib/queens/types";
|
||||
import type { Task } from "@/lib/tasks/types";
|
||||
|
||||
const permissions = new Set<string>();
|
||||
vi.mock("@/lib/auth/auth-context", () => ({
|
||||
useAuth: () => ({ hasPermission: (permission: string) => permissions.has(permission) }),
|
||||
}));
|
||||
vi.mock("@/lib/autopilot/api", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/autopilot/api")>()),
|
||||
applyCleanup: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/colonies/api", () => ({ fetchColony: vi.fn() }));
|
||||
vi.mock("@/lib/queens/api", () => ({ fetchQueen: vi.fn() }));
|
||||
vi.mock("@/lib/tasks/api", () => ({ fetchTask: vi.fn() }));
|
||||
vi.mock("@/lib/suggestions/api", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/suggestions/api")>()),
|
||||
fetchSuggestions: vi.fn(),
|
||||
}));
|
||||
vi.mock("sonner", () => ({
|
||||
toast: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn() }),
|
||||
}));
|
||||
|
||||
const cleanupSuggestion = suggestionFixture({
|
||||
id: "s9",
|
||||
intent: "CLEANUP",
|
||||
mode: "DO",
|
||||
colonyId: "c1",
|
||||
colonyCode: "B3",
|
||||
title: "B3 aufräumen",
|
||||
cleanup: { taskIds: ["t1", "t2"], queenId: "q1" },
|
||||
});
|
||||
|
||||
function task(id: string, title: string): Task {
|
||||
return { id, title, status: "OPEN" } as Task;
|
||||
}
|
||||
|
||||
function colony(status: Colony["status"]): Colony {
|
||||
return { id: "c1", code: "B3", status } as Colony;
|
||||
}
|
||||
|
||||
function renderSheet(onClose = vi.fn(), onDone = vi.fn()) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<CleanupSheet suggestionId="s9" colonyId="c1" onClose={onClose} onDone={onDone} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return { onClose, onDone };
|
||||
}
|
||||
|
||||
describe("CleanupSheet", () => {
|
||||
beforeEach(() => {
|
||||
permissions.clear();
|
||||
permissions.add("tasks:update");
|
||||
permissions.add("tasks:complete");
|
||||
permissions.add("queens:update");
|
||||
vi.mocked(fetchSuggestions).mockResolvedValue([cleanupSuggestion]);
|
||||
const tasks = [task("t1", "Futter kontrollieren"), task("t2", "Varroa messen")];
|
||||
vi.mocked(fetchTask).mockReset();
|
||||
vi.mocked(fetchTask).mockImplementation(async (id: string) => {
|
||||
const found = tasks.find((candidate) => candidate.id === id);
|
||||
if (!found) throw new ApiError(404, "Not found");
|
||||
return found;
|
||||
});
|
||||
vi.mocked(fetchColony).mockResolvedValue(colony("DECEASED"));
|
||||
vi.mocked(fetchQueen).mockResolvedValue({ id: "q1", code: "Q-7", status: "ACTIVE" } as Queen);
|
||||
vi.mocked(applyCleanup).mockReset();
|
||||
vi.mocked(applyCleanup).mockResolvedValue({
|
||||
cancelled: 2,
|
||||
completed: 0,
|
||||
kept: 0,
|
||||
queenStatus: "DECEASED",
|
||||
});
|
||||
});
|
||||
|
||||
it("CleanupSheet preselects „Abbrechen“ and the queen status by colony status and sends apply-cleanup", async () => {
|
||||
const { onClose } = renderSheet();
|
||||
|
||||
const first = await screen.findByRole("radiogroup", { name: /Futter kontrollieren/ });
|
||||
expect(within(first).getByRole("radio", { name: "Abbrechen" })).toHaveAttribute("aria-checked", "true");
|
||||
const second = screen.getByRole("radiogroup", { name: /Varroa messen/ });
|
||||
await userEvent.click(within(second).getByRole("radio", { name: "Behalten" }));
|
||||
const queen = await screen.findByRole("radiogroup", { name: /Königin Q-7/ });
|
||||
expect(within(queen).getByRole("radio", { name: "verstorben" })).toHaveAttribute("aria-checked", "true");
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Aufräumen" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(applyCleanup).toHaveBeenCalledWith(
|
||||
"s9",
|
||||
{
|
||||
tasks: [
|
||||
{ id: "t1", outcome: "CANCEL" },
|
||||
{ id: "t2", outcome: "KEEP" },
|
||||
],
|
||||
queen: { id: "q1", status: "DECEASED" },
|
||||
},
|
||||
{ idempotencyKey: expect.any(String) },
|
||||
),
|
||||
);
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("preselects „verkauft“ for a sold colony and „unverändert“ otherwise (the queen keeps her status)", async () => {
|
||||
vi.mocked(fetchColony).mockResolvedValue(colony("SOLD"));
|
||||
renderSheet();
|
||||
const sold = await screen.findByRole("radiogroup", { name: /Königin Q-7/ });
|
||||
await waitFor(() =>
|
||||
expect(within(sold).getByRole("radio", { name: "verkauft" })).toHaveAttribute("aria-checked", "true"),
|
||||
);
|
||||
});
|
||||
|
||||
it("omits the queen from the body when she stays „unverändert“", async () => {
|
||||
vi.mocked(fetchColony).mockResolvedValue(colony("DISSOLVED"));
|
||||
renderSheet();
|
||||
const queen = await screen.findByRole("radiogroup", { name: /Königin Q-7/ });
|
||||
await waitFor(() =>
|
||||
expect(within(queen).getByRole("radio", { name: "unverändert" })).toHaveAttribute("aria-checked", "true"),
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Aufräumen" }));
|
||||
|
||||
await waitFor(() => expect(applyCleanup).toHaveBeenCalled());
|
||||
expect(vi.mocked(applyCleanup).mock.calls[0][1]).toEqual({
|
||||
tasks: [
|
||||
{ id: "t1", outcome: "CANCEL" },
|
||||
{ id: "t2", outcome: "CANCEL" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("omits the queen and says so without queens:update", async () => {
|
||||
permissions.delete("queens:update");
|
||||
renderSheet();
|
||||
|
||||
await screen.findByRole("radiogroup", { name: /Futter kontrollieren/ });
|
||||
expect(screen.getByText(/Die Königin bleibt unverändert/)).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Aufräumen" }));
|
||||
|
||||
await waitFor(() => expect(applyCleanup).toHaveBeenCalled());
|
||||
expect(vi.mocked(applyCleanup).mock.calls[0][1]).not.toHaveProperty("queen");
|
||||
});
|
||||
|
||||
it("loads only the evidence tasks and neither lists nor sends a task that is gone or no longer open", async () => {
|
||||
vi.mocked(fetchTask).mockImplementation(async (id: string) => {
|
||||
if (id === "t1") throw new ApiError(404, "Not found");
|
||||
return { id, title: "Varroa messen", status: "DONE" } as Task;
|
||||
});
|
||||
renderSheet();
|
||||
|
||||
expect(await screen.findByText(/„Varroa messen“ – nicht mehr offen/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Eine Aufgabe gibt es nicht mehr/)).toBeInTheDocument();
|
||||
expect(screen.queryByRole("radiogroup", { name: /Varroa messen/ })).not.toBeInTheDocument();
|
||||
expect(fetchTask).toHaveBeenCalledTimes(2);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Aufräumen" }));
|
||||
await waitFor(() => expect(applyCleanup).toHaveBeenCalled());
|
||||
expect(vi.mocked(applyCleanup).mock.calls[0][1].tasks).toEqual([]);
|
||||
});
|
||||
|
||||
it("explains a failed queen load, offers a retry and keeps the main button off", async () => {
|
||||
vi.mocked(fetchQueen).mockRejectedValueOnce(new Error("boom"));
|
||||
renderSheet();
|
||||
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert).toHaveTextContent("Die Königin konnte nicht geladen werden.");
|
||||
expect(screen.getByRole("button", { name: "Aufräumen" })).toBeDisabled();
|
||||
expect(screen.getByText(/erst möglich, wenn alles geladen ist/)).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Erneut versuchen" }));
|
||||
expect(await screen.findByRole("radiogroup", { name: /Königin Q-7/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Aufräumen" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("shows the backend's German text for a 400 and a 403", async () => {
|
||||
renderSheet();
|
||||
const { toast } = await import("sonner");
|
||||
vi.mocked(applyCleanup).mockRejectedValueOnce(
|
||||
new ApiError(400, "Nur Aufgaben und Königin aus dem Vorschlag.", {
|
||||
message: "Nur Aufgaben und Königin aus dem Vorschlag.",
|
||||
}),
|
||||
);
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Aufräumen" }));
|
||||
await waitFor(() =>
|
||||
expect(toast.error).toHaveBeenCalledWith("Nur Aufgaben und Königin aus dem Vorschlag."),
|
||||
);
|
||||
|
||||
vi.mocked(applyCleanup).mockRejectedValueOnce(
|
||||
new ApiError(403, "Dafür fehlt das Recht.", { message: "Dafür fehlt das Recht." }),
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Aufräumen" }));
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith("Dafür fehlt das Recht."));
|
||||
});
|
||||
|
||||
it("disables „Erledigt“ with a hint without tasks:complete", async () => {
|
||||
permissions.delete("tasks:complete");
|
||||
renderSheet();
|
||||
|
||||
const first = await screen.findByRole("radiogroup", { name: /Futter kontrollieren/ });
|
||||
expect(within(first).getByRole("radio", { name: "Erledigt" })).toBeDisabled();
|
||||
expect(screen.getByText(/„Erledigt“ braucht das Recht/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the server's German text and stays open when the colony is active again", async () => {
|
||||
const { onClose } = renderSheet();
|
||||
const { ApiError } = await import("@/lib/auth/api-client");
|
||||
vi.mocked(applyCleanup).mockRejectedValue(new ApiError(409, "Das Volk ist wieder aktiv.", {}));
|
||||
const { toast } = await import("sonner");
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Aufräumen" }));
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith("Das Volk ist wieder aktiv."));
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,339 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Eraser } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { ChoiceGroup, type ChoiceOption } from "@/components/form/choice-group";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { applyCleanup } from "@/lib/autopilot/api";
|
||||
import type { ApplyCleanupInput, CleanupOutcome, CleanupResult } from "@/lib/autopilot/types";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { fetchColony } from "@/lib/colonies/api";
|
||||
import { newIdempotencyKey } from "@/lib/offline/idempotency";
|
||||
import { fetchQueen } from "@/lib/queens/api";
|
||||
import { fetchSuggestions, suggestionsKey } from "@/lib/suggestions/api";
|
||||
import { suggestionErrorText } from "@/lib/suggestions/errors";
|
||||
import { fetchTask } from "@/lib/tasks/api";
|
||||
import type { Task } from "@/lib/tasks/types";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
|
||||
type QueenChoice = "DECEASED" | "SOLD" | "KEEP";
|
||||
|
||||
const TASK_OPTIONS: ChoiceOption<CleanupOutcome>[] = [
|
||||
{ value: "CANCEL", label: "Abbrechen" },
|
||||
{ value: "DONE", label: "Erledigt" },
|
||||
{ value: "KEEP", label: "Behalten" },
|
||||
];
|
||||
|
||||
const QUEEN_OPTIONS: ChoiceOption<QueenChoice>[] = [
|
||||
{ value: "DECEASED", label: "verstorben" },
|
||||
{ value: "SOLD", label: "verkauft" },
|
||||
{ value: "KEEP", label: "unverändert" },
|
||||
];
|
||||
|
||||
const REFRESHED_KEYS = [["suggestions"], ["tasks"], ["colonies"], ["dashboard"], ["autopilot"], ["queens"]] as const;
|
||||
|
||||
function plural(count: number, one: string, many: string): string {
|
||||
return `${count} ${count === 1 ? one : many}`;
|
||||
}
|
||||
|
||||
function resultText(result: CleanupResult): string {
|
||||
const parts = [
|
||||
result.cancelled > 0 ? `${plural(result.cancelled, "Aufgabe", "Aufgaben")} abgebrochen` : null,
|
||||
result.completed > 0 ? `${plural(result.completed, "Aufgabe", "Aufgaben")} erledigt` : null,
|
||||
result.kept > 0 ? `${result.kept} behalten` : null,
|
||||
].filter(Boolean);
|
||||
return parts.length > 0 ? `Aufgeräumt: ${parts.join(", ")}` : "Aufgeräumt";
|
||||
}
|
||||
|
||||
/** The colony ended as sold or deceased: the queen most likely followed. */
|
||||
function defaultQueenChoice(colonyStatus: string | undefined): QueenChoice {
|
||||
if (colonyStatus === "DECEASED") return "DECEASED";
|
||||
if (colonyStatus === "SOLD") return "SOLD";
|
||||
return "KEEP";
|
||||
}
|
||||
|
||||
interface EvidenceTask {
|
||||
id: string;
|
||||
/** Null when the task does not exist any more. */
|
||||
task: Task | null;
|
||||
}
|
||||
|
||||
/** Only the tasks of the suggestion's evidence; a 404 means the task is gone. */
|
||||
async function loadEvidenceTasks(ids: readonly string[]): Promise<EvidenceTask[]> {
|
||||
const results = await Promise.allSettled(ids.map((id) => fetchTask(id)));
|
||||
return results.map((result, index) => {
|
||||
if (result.status === "fulfilled") return { id: ids[index], task: result.value };
|
||||
if (result.reason instanceof ApiError && result.reason.status === 404) {
|
||||
return { id: ids[index], task: null };
|
||||
}
|
||||
throw result.reason;
|
||||
});
|
||||
}
|
||||
|
||||
function isOpenTask(task: Task | null): boolean {
|
||||
return task !== null && (task.status === "OPEN" || task.status === "IN_PROGRESS");
|
||||
}
|
||||
|
||||
/** The service's own German sentence for 400, 403 and 409; else the generic text. */
|
||||
function cleanupErrorText(error: unknown): string {
|
||||
if (error instanceof ApiError && [400, 403, 409].includes(error.status)) {
|
||||
const body = error.body as { message?: unknown } | null;
|
||||
if (typeof body?.message === "string" && body.message) return body.message;
|
||||
}
|
||||
return suggestionErrorText(error);
|
||||
}
|
||||
|
||||
function LoadError({ what, onRetry }: { what: string; onRetry: () => void }) {
|
||||
return (
|
||||
<div role="alert" className="flex flex-col items-start gap-2 text-sm">
|
||||
<p>{what} konnte nicht geladen werden.</p>
|
||||
<Button type="button" size="touch" variant="outline" onClick={onRetry}>
|
||||
Erneut versuchen
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CleanupSheetProps {
|
||||
/** The CLEANUP suggestion; the sheet is open while this is set. */
|
||||
suggestionId: string | null;
|
||||
colonyId: string | null;
|
||||
onClose: () => void;
|
||||
/** Called after a successful clean-up (the sheet closes itself). */
|
||||
onDone?: () => void;
|
||||
}
|
||||
|
||||
/** „Aufräumen“ of an ended colony: what happens with its open tasks and its queen. */
|
||||
export function CleanupSheet({ suggestionId, colonyId, onClose, onDone }: CleanupSheetProps) {
|
||||
return (
|
||||
<Sheet open={suggestionId !== null} onOpenChange={(open) => (open ? undefined : onClose())}>
|
||||
<SheetContent
|
||||
side="bottom"
|
||||
showCloseButton={false}
|
||||
className="max-h-[85dvh] gap-0 overflow-y-auto rounded-t-2xl pb-(--safe-area-bottom)"
|
||||
>
|
||||
{/* Remounted per opening, so the choices always start at the defaults. */}
|
||||
{suggestionId !== null ? (
|
||||
<CleanupForm
|
||||
key={suggestionId}
|
||||
suggestionId={suggestionId}
|
||||
colonyId={colonyId}
|
||||
onClose={onClose}
|
||||
onDone={onDone}
|
||||
/>
|
||||
) : null}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
interface CleanupFormProps extends Omit<CleanupSheetProps, "suggestionId"> {
|
||||
suggestionId: string;
|
||||
}
|
||||
|
||||
function CleanupForm({ suggestionId, colonyId, onClose, onDone }: CleanupFormProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { hasPermission } = useAuth();
|
||||
const canComplete = hasPermission("tasks:complete");
|
||||
const canChangeQueen = hasPermission("queens:update");
|
||||
const [outcomes, setOutcomes] = useState<Readonly<Record<string, CleanupOutcome>>>({});
|
||||
const [queenOverride, setQueenOverride] = useState<QueenChoice | null>(null);
|
||||
// One key per set of choices: a retry after a lost answer is not applied twice.
|
||||
const idempotencyKey = useRef(newIdempotencyKey());
|
||||
|
||||
const suggestions = useQuery({
|
||||
queryKey: suggestionsKey({ status: "OPEN", ...(colonyId ? { colonyId } : {}) }),
|
||||
queryFn: () => fetchSuggestions({ status: "OPEN", ...(colonyId ? { colonyId } : {}) }),
|
||||
});
|
||||
const suggestion = suggestions.data?.find((candidate) => candidate.id === suggestionId) ?? null;
|
||||
const taskIds = suggestion?.cleanup?.taskIds ?? [];
|
||||
const queenId = canChangeQueen ? (suggestion?.cleanup?.queenId ?? null) : null;
|
||||
|
||||
const tasks = useQuery({
|
||||
queryKey: ["tasks", "cleanup", ...taskIds],
|
||||
queryFn: () => loadEvidenceTasks(taskIds),
|
||||
enabled: taskIds.length > 0,
|
||||
});
|
||||
const colony = useQuery({
|
||||
queryKey: ["colonies", colonyId],
|
||||
queryFn: () => fetchColony(colonyId ?? ""),
|
||||
enabled: queenId !== null && colonyId !== null,
|
||||
});
|
||||
const queen = useQuery({
|
||||
queryKey: ["queens", queenId],
|
||||
queryFn: () => fetchQueen(queenId ?? ""),
|
||||
enabled: queenId !== null,
|
||||
});
|
||||
|
||||
const evidence = tasks.data ?? [];
|
||||
const taskRows = evidence.flatMap(({ id, task }) => (task && isOpenTask(task) ? [{ id, title: task.title }] : []));
|
||||
const closedTasks = evidence.flatMap(({ id, task }) =>
|
||||
task && !isOpenTask(task) ? [{ id, title: task.title }] : [],
|
||||
);
|
||||
const goneCount = evidence.filter(({ task }) => task === null).length;
|
||||
const loadFailed = tasks.isError || (queenId !== null && (queen.isError || colony.isError));
|
||||
const queenChoice = queenOverride ?? defaultQueenChoice(colony.data?.status);
|
||||
const isReady =
|
||||
suggestion !== null &&
|
||||
(taskIds.length === 0 || tasks.isSuccess) &&
|
||||
(queenId === null || (queen.isSuccess && (colonyId === null || colony.isSuccess)));
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (input: ApplyCleanupInput) =>
|
||||
applyCleanup(suggestionId, input, { idempotencyKey: idempotencyKey.current }),
|
||||
onSuccess: async (result) => {
|
||||
toast.success(resultText(result));
|
||||
await Promise.all(REFRESHED_KEYS.map((queryKey) => queryClient.invalidateQueries({ queryKey })));
|
||||
onDone?.();
|
||||
onClose();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(cleanupErrorText(error));
|
||||
// A 409 or 404 means the list is stale.
|
||||
void queryClient.invalidateQueries({ queryKey: ["suggestions"] });
|
||||
},
|
||||
});
|
||||
|
||||
function changeOutcome(id: string, outcome: CleanupOutcome) {
|
||||
idempotencyKey.current = newIdempotencyKey();
|
||||
setOutcomes((current) => ({ ...current, [id]: outcome }));
|
||||
}
|
||||
function changeQueen(choice: QueenChoice) {
|
||||
idempotencyKey.current = newIdempotencyKey();
|
||||
setQueenOverride(choice);
|
||||
}
|
||||
function submit() {
|
||||
mutation.mutate({
|
||||
tasks: taskRows.map(({ id }) => ({ id, outcome: outcomes[id] ?? "CANCEL" })),
|
||||
// „unverändert“ sends nothing: the queen keeps her status.
|
||||
...(queenId !== null && queen.data && queenChoice !== "KEEP"
|
||||
? { queen: { id: queenId, status: queenChoice } }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
const taskOptions = TASK_OPTIONS.map((option) => ({
|
||||
...option,
|
||||
disabled: option.value === "DONE" && !canComplete,
|
||||
}));
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (isReady) submit();
|
||||
}}
|
||||
className="flex flex-col gap-4 px-4 pb-4"
|
||||
>
|
||||
<SheetHeader className="px-0">
|
||||
<SheetTitle>
|
||||
Volk aufräumen{suggestion?.colonyCode ? ` · ${suggestion.colonyCode}` : ""}
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
Was soll mit den offenen Aufgaben und der Königin dieses beendeten Volks geschehen?
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
{suggestions.isSuccess && suggestion === null ? (
|
||||
<p role="alert" className="text-sm">
|
||||
Der Vorschlag ist nicht mehr offen.
|
||||
</p>
|
||||
) : null}
|
||||
{suggestions.isError ? (
|
||||
<p role="alert" className="text-sm">
|
||||
Der Vorschlag konnte nicht geladen werden.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{tasks.isError ? <LoadError what="Die Aufgaben" onRetry={() => void tasks.refetch()} /> : null}
|
||||
{queenId !== null && queen.isError ? (
|
||||
<LoadError what="Die Königin" onRetry={() => void queen.refetch()} />
|
||||
) : null}
|
||||
{queenId !== null && colony.isError ? (
|
||||
<LoadError what="Das Volk" onRetry={() => void colony.refetch()} />
|
||||
) : null}
|
||||
{closedTasks.length > 0 || goneCount > 0 ? (
|
||||
<ul className="flex flex-col gap-1 text-sm text-muted-foreground">
|
||||
{closedTasks.map(({ id, title }) => (
|
||||
<li key={id}>„{title}“ – nicht mehr offen</li>
|
||||
))}
|
||||
{goneCount > 0 ? (
|
||||
<li>
|
||||
{goneCount === 1 ? "Eine Aufgabe gibt es" : `${goneCount} Aufgaben gibt es`} nicht
|
||||
mehr.
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{taskRows.length > 0 ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
{taskRows.map(({ id, title }) => (
|
||||
<div key={id} className="flex flex-col gap-1.5">
|
||||
<p className="text-sm font-medium" id={`cleanup-task-${id}`}>
|
||||
Aufgabe „{title}“
|
||||
</p>
|
||||
<ChoiceGroup
|
||||
options={taskOptions}
|
||||
value={outcomes[id] ?? "CANCEL"}
|
||||
onChange={(outcome) => changeOutcome(id, outcome)}
|
||||
labelledBy={`cleanup-task-${id}`}
|
||||
className="grid-cols-3"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{!canComplete ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
„Erledigt“ braucht das Recht „Aufgaben abschließen“.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{queenId !== null && queen.data ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p className="text-sm font-medium" id="cleanup-queen">
|
||||
Königin {queen.data.code}
|
||||
</p>
|
||||
<ChoiceGroup
|
||||
options={QUEEN_OPTIONS}
|
||||
value={queenChoice}
|
||||
onChange={changeQueen}
|
||||
labelledBy="cleanup-queen"
|
||||
className="grid-cols-3"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{!canChangeQueen && suggestion?.cleanup?.queenId ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Die Königin bleibt unverändert – dafür fehlt das Recht „Königinnen bearbeiten“.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{loadFailed ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Aufräumen ist erst möglich, wenn alles geladen ist.
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex flex-col gap-2 sm:flex-row-reverse">
|
||||
<Button type="submit" size="touch" disabled={!isReady || mutation.isPending}>
|
||||
<Eraser aria-hidden />
|
||||
{mutation.isPending ? "Wird gespeichert…" : "Aufräumen"}
|
||||
</Button>
|
||||
<SheetClose render={<Button type="button" size="touch" variant="outline" />}>
|
||||
Schließen
|
||||
</SheetClose>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { InspectionDueCard } from "./inspection-due-card";
|
||||
import { readRound } from "@/lib/rounds/round-state";
|
||||
import type { InspectionDueApiary } from "@/lib/autopilot/types";
|
||||
|
||||
vi.mock("@/lib/auth/auth-context", () => ({ useCurrentUserId: () => "user-1" }));
|
||||
|
||||
const entry: InspectionDueApiary = {
|
||||
apiaryId: "a-1",
|
||||
apiaryName: "Obstwiese",
|
||||
segment: "SCHWARMZEIT",
|
||||
intervalDays: 7,
|
||||
colonies: [
|
||||
{ id: "c-2", code: "B2", dueOn: "2026-05-10", overdueDays: 2 },
|
||||
{ id: "c-1", code: "A1", dueOn: "2026-05-12", overdueDays: 0 },
|
||||
],
|
||||
};
|
||||
|
||||
describe("InspectionDueCard", () => {
|
||||
beforeEach(() => window.sessionStorage.clear());
|
||||
|
||||
it("„Durchsicht fällig“ starts a round only with the due colonies of that apiary", async () => {
|
||||
render(<InspectionDueCard entry={entry} />);
|
||||
|
||||
const start = screen.getByRole("link", { name: "Runde starten" });
|
||||
expect(start).toHaveAttribute("href", "/inspections/new?colonyId=c-1&round=a-1");
|
||||
await userEvent.click(start);
|
||||
|
||||
expect(readRound("user-1")).toMatchObject({
|
||||
apiaryId: "a-1",
|
||||
apiaryName: "Obstwiese",
|
||||
colonies: [
|
||||
{ id: "c-1", code: "A1" },
|
||||
{ id: "c-2", code: "B2" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("names the apiary, the season with its interval and every due colony", () => {
|
||||
render(<InspectionDueCard entry={entry} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", { name: /Durchsicht fällig · Obstwiese/ }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Schwarmzeit · alle 7 Tage")).toBeInTheDocument();
|
||||
const list = screen.getByRole("list");
|
||||
expect(within(list).getByText("A1")).toBeInTheDocument();
|
||||
expect(within(list).getByText("seit 2 Tagen")).toBeInTheDocument();
|
||||
expect(within(list).getByText("heute")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { ClipboardCheck, Play } from "lucide-react";
|
||||
import { SeasonChip } from "@/components/autopilot/season-chip";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import type { InspectionDueApiary } from "@/lib/autopilot/types";
|
||||
import { useCurrentUserId } from "@/lib/auth/auth-context";
|
||||
import { startRound } from "@/lib/rounds/round-state";
|
||||
|
||||
function overdueText(overdueDays: number): string {
|
||||
if (overdueDays <= 0) return "heute";
|
||||
return `seit ${overdueDays} ${overdueDays === 1 ? "Tag" : "Tagen"}`;
|
||||
}
|
||||
|
||||
interface InspectionDueCardProps {
|
||||
entry: InspectionDueApiary;
|
||||
/** False when a step card above already has the main button. */
|
||||
primary?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* „Durchsicht fällig“ of one apiary: the colonies that are due today or
|
||||
* overdue. „Runde starten“ walks through exactly these, in code order.
|
||||
*/
|
||||
export function InspectionDueCard({ entry, primary = true }: InspectionDueCardProps) {
|
||||
const userId = useCurrentUserId();
|
||||
const colonies = [...entry.colonies].sort((a, b) =>
|
||||
a.code.localeCompare(b.code, "de", { numeric: true }),
|
||||
);
|
||||
const first = colonies[0];
|
||||
const headingId = `inspection-due-${entry.apiaryId}`;
|
||||
const roundHref = first
|
||||
? `/inspections/new?colonyId=${encodeURIComponent(first.id)}&round=${encodeURIComponent(entry.apiaryId)}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby={headingId}
|
||||
className="flex flex-col gap-3 rounded-2xl border border-border/60 bg-card p-4 shadow-[var(--shadow-soft)] sm:p-5"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/12 text-primary">
|
||||
<ClipboardCheck className="size-5" aria-hidden />
|
||||
</span>
|
||||
<h3 id={headingId} className="min-w-0 flex-1 font-medium">
|
||||
Durchsicht fällig · {entry.apiaryName}
|
||||
</h3>
|
||||
<SeasonChip segment={entry.segment} intervalDays={entry.intervalDays} />
|
||||
</div>
|
||||
<ul className="flex flex-col divide-y divide-border/60">
|
||||
{colonies.map((colony) => (
|
||||
<li key={colony.id} className="flex items-center justify-between gap-3 py-2 text-sm">
|
||||
<span className="font-medium">{colony.code}</span>
|
||||
<span className="text-muted-foreground">{overdueText(colony.overdueDays)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{roundHref ? (
|
||||
<Link
|
||||
href={roundHref}
|
||||
onClick={() => {
|
||||
if (userId) {
|
||||
startRound(
|
||||
userId,
|
||||
{ id: entry.apiaryId, name: entry.apiaryName },
|
||||
colonies.map(({ id, code }) => ({ id, code })),
|
||||
);
|
||||
}
|
||||
}}
|
||||
className={buttonVariants({
|
||||
size: "touch",
|
||||
variant: primary ? "default" : "outline",
|
||||
className: "self-start",
|
||||
})}
|
||||
>
|
||||
<Play aria-hidden />
|
||||
Runde starten
|
||||
</Link>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { ArrowRight, Check, ClipboardPlus, ListPlus, Play, Sun } from "lucide-react";
|
||||
import { ArrowRight, Check, ClipboardPlus, Lightbulb, ListPlus, Play, Sun } from "lucide-react";
|
||||
import { Task, TaskRelatedType } from "@/lib/tasks/types";
|
||||
import { dueState, pickNextStep, relatedHref, tasksForToday } from "@/lib/tasks/today";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DueChip, TaskSourceBadge, TaskStatusPill } from "@/components/task-status";
|
||||
import { SeasonChip } from "@/components/autopilot/season-chip";
|
||||
import { StepActions } from "@/components/autopilot/next-steps-card";
|
||||
import { useStepActions } from "@/components/autopilot/use-step-actions";
|
||||
import { CleanupSheet } from "@/components/suggestions/cleanup-sheet";
|
||||
import { dueChipProps } from "@/lib/autopilot/format";
|
||||
import type { AutopilotToday, TodayNextStep } from "@/lib/autopilot/types";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { todayLocalIsoDate } from "@/lib/dates";
|
||||
import { INTENT_ICONS } from "@/lib/suggestions/labels";
|
||||
import { InspectionDueCard } from "./inspection-due-card";
|
||||
import { useTaskActions } from "./use-task-actions";
|
||||
|
||||
const TODAY_LIST_LIMIT = 5;
|
||||
@@ -25,10 +35,23 @@ interface TodayPanelProps {
|
||||
isError: boolean;
|
||||
onRetry: () => void;
|
||||
now: Date;
|
||||
/** The autopilot's view of today; without it (offline, no right) the tasks alone decide. */
|
||||
autopilot?: AutopilotToday | null;
|
||||
}
|
||||
|
||||
export function TodayPanel({ tasks, isLoading, isError, onRetry, now }: TodayPanelProps) {
|
||||
export function TodayPanel({
|
||||
tasks: loadedTasks,
|
||||
isLoading,
|
||||
isError,
|
||||
onRetry,
|
||||
now,
|
||||
autopilot = null,
|
||||
}: TodayPanelProps) {
|
||||
const actions = useTaskActions();
|
||||
const stepActions = useStepActions("next-step-heading");
|
||||
const { hasPermission } = useAuth();
|
||||
// The autopilot is enough to work with when the task list is missing.
|
||||
const tasks = loadedTasks ?? (autopilot ? [] : undefined);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -40,7 +63,7 @@ export function TodayPanel({ tasks, isLoading, isError, onRetry, now }: TodayPan
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !tasks) {
|
||||
if (!tasks || (isError && !autopilot)) {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
@@ -56,26 +79,59 @@ export function TodayPanel({ tasks, isLoading, isError, onRetry, now }: TodayPan
|
||||
);
|
||||
}
|
||||
|
||||
const nextStep = pickNextStep(tasks, now);
|
||||
if (!nextStep) {
|
||||
const autopilotStep = autopilot?.nextStep ?? null;
|
||||
const nextTask = autopilotStep ? null : pickNextStep(tasks, now);
|
||||
const inspectionDue = autopilot?.inspectionDue ?? [];
|
||||
// A failed task list must not read as "nothing due".
|
||||
const tasksFailed = isError && !loadedTasks;
|
||||
if (!autopilotStep && !nextTask && inspectionDue.length === 0 && !tasksFailed) {
|
||||
return <NothingDue />;
|
||||
}
|
||||
|
||||
const today = tasksForToday(tasks, now).filter((task) => task.id !== nextStep.id);
|
||||
const headTaskId =
|
||||
autopilotStep?.action.type === "OPEN_TASK" ? autopilotStep.action.taskId : nextTask?.id;
|
||||
const today = tasksForToday(tasks, now).filter((task) => task.id !== headTaskId);
|
||||
const visible = today.slice(0, TODAY_LIST_LIMIT);
|
||||
const hidden = today.length - visible.length;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<NextStepCard
|
||||
task={nextStep}
|
||||
now={now}
|
||||
leaving={actions.leavingIds.has(nextStep.id)}
|
||||
onComplete={() => actions.complete(nextStep)}
|
||||
onStart={() => actions.start(nextStep)}
|
||||
isStarting={actions.isStarting}
|
||||
{autopilotStep ? (
|
||||
<AutopilotStepCard
|
||||
step={autopilotStep}
|
||||
busy={stepActions.busyKeys.has(autopilotStep.key)}
|
||||
canAccept={hasPermission("tasks:create")}
|
||||
canDismiss={hasPermission("tasks:update")}
|
||||
onAccept={stepActions.accept}
|
||||
onDismiss={stepActions.dismiss}
|
||||
onCleanup={(step) => stepActions.openCleanup(step, autopilotStep.colonyId)}
|
||||
/>
|
||||
) : nextTask ? (
|
||||
<NextStepCard
|
||||
task={nextTask}
|
||||
now={now}
|
||||
leaving={actions.leavingIds.has(nextTask.id)}
|
||||
onComplete={() => actions.complete(nextTask)}
|
||||
onStart={() => actions.start(nextTask)}
|
||||
isStarting={actions.isStarting}
|
||||
/>
|
||||
) : null}
|
||||
<CleanupSheet
|
||||
suggestionId={stepActions.cleanupTarget?.suggestionId ?? null}
|
||||
colonyId={stepActions.cleanupTarget?.colonyId ?? null}
|
||||
onClose={stepActions.closeCleanup}
|
||||
onDone={() => void stepActions.afterCleanup()}
|
||||
/>
|
||||
|
||||
{inspectionDue.map((entry, index) => (
|
||||
<InspectionDueCard
|
||||
key={entry.apiaryId}
|
||||
entry={entry}
|
||||
primary={!autopilotStep && !nextTask && index === 0}
|
||||
/>
|
||||
))}
|
||||
{autopilot ? <AutopilotChips autopilot={autopilot} /> : null}
|
||||
|
||||
<section aria-labelledby="today-heading" className="flex flex-col gap-2">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<h2 id="today-heading" className="font-heading text-lg font-semibold tracking-tight">
|
||||
@@ -88,7 +144,17 @@ export function TodayPanel({ tasks, isLoading, isError, onRetry, now }: TodayPan
|
||||
Alle Aufgaben
|
||||
</Link>
|
||||
</div>
|
||||
{visible.length === 0 ? (
|
||||
{tasksFailed ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex flex-col items-start gap-2 rounded-xl border border-destructive/30 bg-destructive/5 p-3 text-sm"
|
||||
>
|
||||
<p>Die Aufgaben konnten gerade nicht geladen werden.</p>
|
||||
<Button size="touch" variant="outline" onClick={onRetry}>
|
||||
Erneut laden
|
||||
</Button>
|
||||
</div>
|
||||
) : visible.length === 0 ? (
|
||||
<p className="rounded-xl bg-card/60 px-4 py-3 text-sm text-muted-foreground">
|
||||
Sonst ist für heute nichts fällig. Ein Schritt reicht.
|
||||
</p>
|
||||
@@ -183,6 +249,105 @@ function NextStepCard({ task, now, leaving, onComplete, onStart, isStarting }: N
|
||||
);
|
||||
}
|
||||
|
||||
interface AutopilotStepCardProps {
|
||||
step: TodayNextStep;
|
||||
busy: boolean;
|
||||
canAccept: boolean;
|
||||
canDismiss: boolean;
|
||||
onAccept: (step: TodayNextStep) => void;
|
||||
onDismiss: (step: TodayNextStep) => void;
|
||||
onCleanup: (step: TodayNextStep) => void;
|
||||
}
|
||||
|
||||
/** „Als Nächstes“ from the autopilot: one step across all colonies, with its reasons and action. */
|
||||
function AutopilotStepCard({ step, busy, ...handlers }: AutopilotStepCardProps) {
|
||||
const Icon = step.intent ? INTENT_ICONS[step.intent] : ArrowRight;
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="next-step-heading"
|
||||
className="relative overflow-hidden rounded-2xl border border-primary/25 bg-card p-5 shadow-[var(--shadow-elevated)] sm:p-6"
|
||||
>
|
||||
<h2
|
||||
id="next-step-heading"
|
||||
tabIndex={-1}
|
||||
className="font-heading text-base font-semibold text-muted-foreground outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
>
|
||||
Als Nächstes
|
||||
</h2>
|
||||
<p role="status" className="sr-only">
|
||||
Nächster Schritt: {step.title}
|
||||
</p>
|
||||
<div key={step.key} className="next-step-enter mt-2 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="flex items-start gap-3 font-heading text-2xl font-semibold leading-tight tracking-tight text-balance sm:text-3xl">
|
||||
<Icon className="mt-1 size-6 shrink-0 text-primary" aria-hidden />
|
||||
{step.title}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-medium">
|
||||
{step.colonyCode}
|
||||
</span>
|
||||
<DueChip {...dueChipProps(step.dueOn, todayLocalIsoDate())} />
|
||||
</div>
|
||||
{step.reasons.length > 0 ? (
|
||||
<ul className="flex max-w-prose flex-col gap-0.5 text-sm text-muted-foreground">
|
||||
{step.reasons.slice(0, 2).map((reason) => (
|
||||
<li key={reason}>{reason}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StepActions
|
||||
step={step}
|
||||
busy={busy}
|
||||
canAccept={handlers.canAccept}
|
||||
canDismiss={handlers.canDismiss}
|
||||
onAccept={() => handlers.onAccept(step)}
|
||||
onDismiss={() => handlers.onDismiss(step)}
|
||||
onCleanup={() => handlers.onCleanup(step)}
|
||||
/>
|
||||
<Button
|
||||
size="touch"
|
||||
variant="ghost"
|
||||
nativeButton={false}
|
||||
render={<Link href={`/colonies/${encodeURIComponent(step.colonyId)}`} />}
|
||||
>
|
||||
Zum Volk
|
||||
<ArrowRight aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Open suggestions and the season of each apiary - small, but readable as words. */
|
||||
function AutopilotChips({ autopilot }: { autopilot: AutopilotToday }) {
|
||||
const showSuggestions = autopilot.tasksVisible && autopilot.suggestionsOpen > 0;
|
||||
if (!showSuggestions && autopilot.seasons.length === 0) return null;
|
||||
return (
|
||||
<ul aria-label="Vorschläge und Saison" className="flex flex-wrap items-center gap-2">
|
||||
{showSuggestions ? (
|
||||
<li>
|
||||
<Link
|
||||
href="/suggestions"
|
||||
className="inline-flex min-h-11 items-center gap-1.5 rounded-full bg-primary/12 px-4 text-sm font-medium focus-visible:outline-2 focus-visible:outline-ring"
|
||||
>
|
||||
<Lightbulb className="size-4" aria-hidden />
|
||||
Vorschläge {autopilot.suggestionsOpen}
|
||||
</Link>
|
||||
</li>
|
||||
) : null}
|
||||
{autopilot.seasons.map((season) => (
|
||||
<li key={season.apiaryId}>
|
||||
<SeasonChip segment={season.segment} apiaryName={season.apiaryName} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
interface TodayRowProps {
|
||||
task: Task;
|
||||
now: Date;
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { WarningList } from "./warning-list";
|
||||
import type { PlausibilityWarning } from "@/lib/warnings/types";
|
||||
|
||||
const warnings: PlausibilityWarning[] = [
|
||||
{
|
||||
code: "WITHDRAWAL_ACTIVE",
|
||||
severity: "WARNUNG",
|
||||
message: "B3: Wartezeit nach Oxuvar endet am 20.08.2026.",
|
||||
refs: [{ type: "Colony", id: "c-1" }],
|
||||
},
|
||||
{
|
||||
code: "JAR_SIZE_DIFFERS",
|
||||
severity: "HINWEIS",
|
||||
message: "Die Glasgröße weicht ab.",
|
||||
refs: [],
|
||||
},
|
||||
];
|
||||
|
||||
describe("WarningList", () => {
|
||||
it("announces warnings with icon and word in a status region", () => {
|
||||
render(<WarningList warnings={warnings} title="Bitte prüfen" />);
|
||||
|
||||
const region = screen.getByRole("status");
|
||||
expect(region).toHaveAttribute("aria-live", "polite");
|
||||
expect(within(region).getByText("Bitte prüfen")).toBeInTheDocument();
|
||||
expect(within(region).getByText("Warnung")).toBeInTheDocument();
|
||||
expect(within(region).getByText("Hinweis")).toBeInTheDocument();
|
||||
expect(
|
||||
within(region).getByText("B3: Wartezeit nach Oxuvar endet am 20.08.2026."),
|
||||
).toBeInTheDocument();
|
||||
expect(region.querySelectorAll("svg[aria-hidden='true']")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("shows the word for a confirmation request", () => {
|
||||
render(
|
||||
<WarningList
|
||||
warnings={[
|
||||
{
|
||||
code: "WATER_CONTENT_ABOVE_LEGAL",
|
||||
severity: "BESTAETIGEN",
|
||||
message: "Zu feucht.",
|
||||
refs: [],
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Bestätigen")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the live region mounted but empty without warnings", () => {
|
||||
render(<WarningList warnings={[]} />);
|
||||
expect(screen.getByRole("status")).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { CircleAlert, Info, type LucideIcon, TriangleAlert } from "lucide-react";
|
||||
import type { PlausibilityWarning, WarningSeverity } from "@/lib/warnings/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const SEVERITY: Record<WarningSeverity, { icon: LucideIcon; word: string; tone: string }> = {
|
||||
HINWEIS: { icon: Info, word: "Hinweis", tone: "bg-muted" },
|
||||
WARNUNG: { icon: TriangleAlert, word: "Warnung", tone: "bg-warning/20" },
|
||||
BESTAETIGEN: { icon: CircleAlert, word: "Bestätigen", tone: "bg-destructive/15" },
|
||||
};
|
||||
|
||||
export interface WarningListProps {
|
||||
warnings: readonly PlausibilityWarning[];
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plausibility findings in a polite live region: icon and word per level, so
|
||||
* the state never depends on colour. The region stays mounted when empty so
|
||||
* a later warning is announced.
|
||||
*/
|
||||
export function WarningList({ warnings, title }: WarningListProps) {
|
||||
return (
|
||||
<div role="status" aria-live="polite" className={cn(warnings.length > 0 && "flex flex-col gap-2")}>
|
||||
{warnings.length > 0 ? (
|
||||
<>
|
||||
{title ? <p className="text-sm font-semibold">{title}</p> : null}
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{warnings.map((warning, index) => {
|
||||
const { icon: Icon, word, tone } = SEVERITY[warning.severity];
|
||||
return (
|
||||
<li
|
||||
key={`${warning.code}-${index}`}
|
||||
className={cn("flex min-h-11 items-start gap-2 rounded-lg px-3 py-2 text-sm", tone)}
|
||||
>
|
||||
<Icon className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
|
||||
<span className="font-medium">{word}</span>
|
||||
<span>{warning.message}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ApiaryStatus } from "./types";
|
||||
import type { NextStep, SeasonSegment } from "@/lib/autopilot/types";
|
||||
import type { Colony, ColonyCondition, ColonyStatus } from "@/lib/colonies/types";
|
||||
import type { Inspection } from "@/lib/inspections/types";
|
||||
import type { Queen } from "@/lib/queens/types";
|
||||
@@ -12,14 +13,24 @@ export interface ColonyOverviewRow {
|
||||
code: string;
|
||||
status: ColonyStatus;
|
||||
strength: number | null;
|
||||
occupiedFrames: number | null;
|
||||
broodStatus: ColonyCondition | null;
|
||||
feedStatus: ColonyCondition | null;
|
||||
healthStatus: ColonyCondition | null;
|
||||
/** ISO timestamp; always null when the caller may not read inspections. */
|
||||
lastInspectionDate: string | null;
|
||||
/** Unread WARNUNG notifications visible to the caller. */
|
||||
/**
|
||||
* The day the next inspection is due (YYYY-MM-DD); null in the winter
|
||||
* rest, when the interval rule is off and without `inspections:read`.
|
||||
*/
|
||||
inspectionDueOn: string | null;
|
||||
/** The season segment of the apiary today; null when it is unknown. */
|
||||
segment: SeasonSegment | null;
|
||||
/** Title of the first next step; null without one or without `tasks:read`. */
|
||||
nextStepTitle: string | null;
|
||||
/** Open suggestions of priority HIGH the caller may see. */
|
||||
openWarnings: number;
|
||||
/** Unread KRITISCH notifications visible to the caller. */
|
||||
/** Open suggestions of priority URGENT the caller may see. */
|
||||
openCritical: number;
|
||||
}
|
||||
|
||||
@@ -54,6 +65,7 @@ export interface OfflinePackV1 {
|
||||
| "colonyType"
|
||||
| "hiveType"
|
||||
| "strength"
|
||||
| "occupiedFrames"
|
||||
| "broodStatus"
|
||||
| "feedStatus"
|
||||
| "healthStatus"
|
||||
@@ -80,4 +92,9 @@ export interface OfflinePackV1 {
|
||||
| "relatedId"
|
||||
>[]
|
||||
| null;
|
||||
/**
|
||||
* The next steps per colony id (what `GET /colonies/:id/next-steps`
|
||||
* returns as `steps`). Absent in packs cached before T4.
|
||||
*/
|
||||
nextSteps?: Record<string, NextStep[]>;
|
||||
}
|
||||
@@ -20,6 +20,7 @@ export function packColony(id: string, code: string, overrides: Partial<PackColo
|
||||
colonyType: "PRODUCTION_COLONY",
|
||||
hiveType: "Zander",
|
||||
strength: 8,
|
||||
occupiedFrames: 9,
|
||||
broodStatus: "GOOD",
|
||||
feedStatus: "GOOD",
|
||||
healthStatus: "GOOD",
|
||||
@@ -35,10 +36,14 @@ export function overviewRow(colony: PackColony, overrides: Partial<ColonyOvervie
|
||||
code: colony.code,
|
||||
status: colony.status,
|
||||
strength: colony.strength,
|
||||
occupiedFrames: colony.occupiedFrames,
|
||||
broodStatus: colony.broodStatus,
|
||||
feedStatus: colony.feedStatus,
|
||||
healthStatus: colony.healthStatus,
|
||||
lastInspectionDate: new Date().toISOString(),
|
||||
inspectionDueOn: null,
|
||||
segment: null,
|
||||
nextStepTitle: null,
|
||||
openWarnings: 0,
|
||||
openCritical: 0,
|
||||
...overrides,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { apiFetch } from "@/lib/auth/api-client";
|
||||
import { CreateOptions, idempotencyHeaders } from "@/lib/offline/idempotency";
|
||||
import type { AutopilotToday, ApplyCleanupInput, CleanupResult, ColonyNextSteps } from "./types";
|
||||
|
||||
export function colonyNextStepsKey(colonyId: string) {
|
||||
return ["colonies", colonyId, "next-steps"] as const;
|
||||
}
|
||||
|
||||
export const autopilotTodayKey = ["autopilot", "today"] as const;
|
||||
|
||||
export function fetchColonyNextSteps(colonyId: string): Promise<ColonyNextSteps> {
|
||||
return apiFetch<ColonyNextSteps>(`/colonies/${encodeURIComponent(colonyId)}/next-steps`);
|
||||
}
|
||||
|
||||
export function fetchAutopilotToday(): Promise<AutopilotToday> {
|
||||
return apiFetch<AutopilotToday>("/autopilot/today");
|
||||
}
|
||||
|
||||
/** Ends the leftovers of an ended colony; only ids from the suggestion's evidence. */
|
||||
export function applyCleanup(
|
||||
suggestionId: string,
|
||||
input: ApplyCleanupInput,
|
||||
options: CreateOptions = {},
|
||||
): Promise<CleanupResult> {
|
||||
return apiFetch<CleanupResult>(`/suggestions/${encodeURIComponent(suggestionId)}/apply-cleanup`, {
|
||||
method: "POST",
|
||||
headers: idempotencyHeaders(options),
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { daysBetween, dueChipProps, formatDayMonth, inspectionDueText } from "./format";
|
||||
|
||||
describe("autopilot format helpers", () => {
|
||||
it("formats a day as TT.MM.", () => {
|
||||
expect(formatDayMonth("2026-05-05")).toBe("05.05.");
|
||||
});
|
||||
|
||||
it("counts whole days across a month and a DST change", () => {
|
||||
expect(daysBetween("2026-03-28", "2026-04-02")).toBe(5);
|
||||
expect(daysBetween("2026-10-14", "2026-10-12")).toBe(-2);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["2026-10-16", "Durchsicht fällig in 2 Tagen"],
|
||||
["2026-10-15", "Durchsicht fällig in 1 Tag"],
|
||||
["2026-10-14", "Durchsicht heute fällig"],
|
||||
["2026-10-13", "Durchsicht seit 1 Tag fällig"],
|
||||
["2026-10-11", "Durchsicht seit 3 Tagen fällig"],
|
||||
])("says when the inspection due on %s is due", (dueOn, text) => {
|
||||
expect(inspectionDueText(dueOn, "2026-10-14")).toBe(text);
|
||||
});
|
||||
|
||||
it("maps a due day to the state the DueChip knows", () => {
|
||||
expect(dueChipProps(null, "2026-10-14")).toEqual({ state: "none", dueDate: null });
|
||||
expect(dueChipProps("2026-10-13", "2026-10-14").state).toBe("overdue");
|
||||
expect(dueChipProps("2026-10-14", "2026-10-14").state).toBe("today");
|
||||
expect(dueChipProps("2026-10-15", "2026-10-14")).toEqual({
|
||||
state: "upcoming",
|
||||
dueDate: "2026-10-15T00:00:00.000Z",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { DueState } from "@/lib/tasks/today";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const CALENDAR_DAY = /^(\d{4})-(\d{2})-(\d{2})$/;
|
||||
|
||||
/** "2026-05-05" as „05.05.“ */
|
||||
export function formatDayMonth(day: string): string {
|
||||
const match = CALENDAR_DAY.exec(day);
|
||||
return match ? `${match[3]}.${match[2]}.` : day;
|
||||
}
|
||||
|
||||
/** Whole days from `from` to `to` (both YYYY-MM-DD); negative when `to` is earlier. */
|
||||
export function daysBetween(from: string, to: string): number {
|
||||
return Math.round((Date.parse(`${to}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`)) / DAY_MS);
|
||||
}
|
||||
|
||||
function plural(count: number, one: string, many: string): string {
|
||||
return `${count} ${count === 1 ? one : many}`;
|
||||
}
|
||||
|
||||
/** „Durchsicht fällig in 2 Tagen“, „Durchsicht heute fällig“, „Durchsicht seit 3 Tagen fällig“. */
|
||||
export function inspectionDueText(dueOn: string, today: string): string {
|
||||
const days = daysBetween(today, dueOn);
|
||||
if (days === 0) return "Durchsicht heute fällig";
|
||||
if (days > 0) return `Durchsicht fällig in ${plural(days, "Tag", "Tagen")}`;
|
||||
return `Durchsicht seit ${plural(-days, "Tag", "Tagen")} fällig`;
|
||||
}
|
||||
|
||||
/** What `DueChip` takes for a plain day (YYYY-MM-DD) and today (YYYY-MM-DD). */
|
||||
export function dueChipProps(
|
||||
dueOn: string | null,
|
||||
today: string,
|
||||
): { state: DueState; dueDate: string | null } {
|
||||
if (!dueOn) return { state: "none", dueDate: null };
|
||||
const days = daysBetween(today, dueOn);
|
||||
const state: DueState = days < 0 ? "overdue" : days === 0 ? "today" : "upcoming";
|
||||
return { state, dueDate: `${dueOn}T00:00:00.000Z` };
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Test data for next steps - shared by the specs, never imported by app code.
|
||||
import type { AutopilotToday, ColonyNextSteps, NextStep } from "./types";
|
||||
|
||||
export function stepFixture(overrides: Partial<NextStep> = {}): NextStep {
|
||||
return {
|
||||
key: "suggestion:s1",
|
||||
kind: "SUGGESTION",
|
||||
intent: "INSPECT",
|
||||
title: "Durchsicht",
|
||||
reasons: ["Letzte Durchsicht vor 8 Tagen.", "In der Schwarmzeit alle 7 Tage."],
|
||||
dueOn: "2026-05-12",
|
||||
urgency: "TODAY",
|
||||
suggestionIds: ["s1"],
|
||||
action: { type: "CAPTURE", form: "inspection", href: "/inspections/new?colonyId=c1" },
|
||||
dismissible: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function colonyStepsFixture(overrides: Partial<ColonyNextSteps> = {}): ColonyNextSteps {
|
||||
return {
|
||||
colonyId: "c1",
|
||||
segment: "SCHWARMZEIT",
|
||||
inspectionDueOn: "2026-05-12",
|
||||
withdrawalUntil: null,
|
||||
steps: [stepFixture()],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function todayFixture(overrides: Partial<AutopilotToday> = {}): AutopilotToday {
|
||||
return {
|
||||
nextStep: null,
|
||||
inspectionDue: [],
|
||||
suggestionsOpen: 0,
|
||||
seasons: [],
|
||||
tasksVisible: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { QueenStatus } from "@/lib/queens/types";
|
||||
import type { SuggestionIntent } from "@/lib/suggestions/types";
|
||||
|
||||
// Mirrors ApiCore-Backend/src/autopilot (next-step-ranking.ts, next-steps.service.ts,
|
||||
// dto/next-steps-response.dto.ts) - keep both in step.
|
||||
|
||||
export const SEASON_SEGMENTS = [
|
||||
"WINTERRUHE",
|
||||
"FRUEHJAHR",
|
||||
"SCHWARMZEIT",
|
||||
"SOMMER",
|
||||
"SPAETSOMMER",
|
||||
"HERBST",
|
||||
] as const;
|
||||
export type SeasonSegment = (typeof SEASON_SEGMENTS)[number];
|
||||
|
||||
export type NextStepForm = "inspection" | "varroa" | "feeding" | "treatment" | "winter-check";
|
||||
|
||||
export type NextStepAction =
|
||||
| { type: "CAPTURE"; form: NextStepForm; href: string }
|
||||
| { type: "ACCEPT"; suggestionId: string }
|
||||
| { type: "CLEANUP"; suggestionId: string }
|
||||
| { type: "OPEN_TASK"; taskId: string; href: string };
|
||||
|
||||
export type NextStepUrgency = "OVERDUE" | "TODAY" | "SOON" | "LATER" | "NONE";
|
||||
|
||||
export interface NextStep {
|
||||
/** 'task:<id>' | 'suggestion:<id>' */
|
||||
key: string;
|
||||
kind: "TASK" | "SUGGESTION";
|
||||
intent: SuggestionIntent | null;
|
||||
title: string;
|
||||
/** At most 2 short sentences. */
|
||||
reasons: string[];
|
||||
/** YYYY-MM-DD */
|
||||
dueOn: string | null;
|
||||
urgency: NextStepUrgency;
|
||||
/** The suggestions behind the step (all members of a merged step); [] for a task. */
|
||||
suggestionIds: string[];
|
||||
action: NextStepAction;
|
||||
/** True if any member suggestion may be dismissed. */
|
||||
dismissible: boolean;
|
||||
}
|
||||
|
||||
export interface ColonyNextSteps {
|
||||
colonyId: string;
|
||||
segment: SeasonSegment;
|
||||
inspectionDueOn: string | null;
|
||||
/** YYYY-MM-DD; the last day of a withdrawal period (a hint, not a step). */
|
||||
withdrawalUntil: string | null;
|
||||
/** At most 3. */
|
||||
steps: NextStep[];
|
||||
}
|
||||
|
||||
export interface TodayNextStep extends NextStep {
|
||||
colonyId: string;
|
||||
colonyCode: string;
|
||||
}
|
||||
|
||||
export interface DueColony {
|
||||
id: string;
|
||||
code: string;
|
||||
dueOn: string;
|
||||
overdueDays: number;
|
||||
}
|
||||
|
||||
export interface InspectionDueApiary {
|
||||
apiaryId: string;
|
||||
apiaryName: string;
|
||||
segment: SeasonSegment;
|
||||
intervalDays: number | null;
|
||||
colonies: DueColony[];
|
||||
}
|
||||
|
||||
export interface SeasonOfApiary {
|
||||
apiaryId: string;
|
||||
apiaryName: string;
|
||||
segment: SeasonSegment;
|
||||
}
|
||||
|
||||
export interface AutopilotToday {
|
||||
nextStep: TodayNextStep | null;
|
||||
inspectionDue: InspectionDueApiary[];
|
||||
suggestionsOpen: number;
|
||||
seasons: SeasonOfApiary[];
|
||||
tasksVisible: boolean;
|
||||
}
|
||||
|
||||
export type CleanupOutcome = "CANCEL" | "DONE" | "KEEP";
|
||||
|
||||
export interface ApplyCleanupInput {
|
||||
tasks: { id: string; outcome: CleanupOutcome }[];
|
||||
queen?: { id: string; status: QueenStatus } | null;
|
||||
}
|
||||
|
||||
export interface CleanupResult {
|
||||
cancelled: number;
|
||||
completed: number;
|
||||
kept: number;
|
||||
queenStatus: QueenStatus | null;
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
export interface BottlingBatch {
|
||||
import type { WithWarnings } from "@/lib/warnings/types";
|
||||
|
||||
export interface BottlingBatch extends WithWarnings {
|
||||
id: string;
|
||||
honeyBatchId: string;
|
||||
productId: string | null;
|
||||
|
||||
@@ -4,12 +4,15 @@ export type CalendarEventType =
|
||||
| "TREATMENT"
|
||||
| "FEEDING"
|
||||
| "HARVEST"
|
||||
| "QUEEN_ASSIGNMENT";
|
||||
| "QUEEN_ASSIGNMENT"
|
||||
| "WITHDRAWAL";
|
||||
|
||||
export interface CalendarEvent {
|
||||
id: string;
|
||||
type: CalendarEventType;
|
||||
date: string;
|
||||
/** WITHDRAWAL only: the last day of the span, inclusive. */
|
||||
endDate?: string;
|
||||
title: string;
|
||||
relatedType: string;
|
||||
relatedId: string;
|
||||
|
||||
@@ -10,10 +10,14 @@ function row(overrides: Partial<ColonyOverviewRow> = {}): ColonyOverviewRow {
|
||||
code: "V-1",
|
||||
status: "ACTIVE",
|
||||
strength: 6,
|
||||
occupiedFrames: 9,
|
||||
broodStatus: "GOOD",
|
||||
feedStatus: "GOOD",
|
||||
healthStatus: "GOOD",
|
||||
lastInspectionDate: "2026-10-10T00:00:00.000Z",
|
||||
inspectionDueOn: "2026-10-17",
|
||||
segment: "HERBST",
|
||||
nextStepTitle: null,
|
||||
openWarnings: 0,
|
||||
openCritical: 0,
|
||||
...overrides,
|
||||
@@ -32,17 +36,24 @@ describe("colonyStatusLight", () => {
|
||||
["a weak colony", row({ status: "WEAK" }), "yellow", ["Volk schwach"]],
|
||||
["a conspicuous colony", row({ status: "CONSPICUOUS" }), "yellow", ["Volk auffällig"]],
|
||||
["weak feed", row({ feedStatus: "WEAK" }), "yellow", ["Futter schwach"]],
|
||||
["15 days without inspection", row({ lastInspectionDate: "2026-09-28T00:00:00.000Z" }), "yellow", ["15 Tage ohne Durchsicht"]],
|
||||
["14 days without inspection", row({ lastInspectionDate: "2026-09-29T00:00:00.000Z" }), "green", []],
|
||||
["never inspected", row({ lastInspectionDate: null }), "yellow", ["Noch keine Durchsicht"]],
|
||||
["an inspection due today", row({ inspectionDueOn: TODAY }), "green", []],
|
||||
["an inspection due tomorrow", row({ inspectionDueOn: "2026-10-14" }), "green", []],
|
||||
["an inspection one day overdue", row({ inspectionDueOn: "2026-10-12" }), "yellow", ["Durchsicht seit 1 Tag fällig"]],
|
||||
["an inspection three days overdue", row({ inspectionDueOn: "2026-10-10" }), "yellow", ["Durchsicht seit 3 Tagen fällig"]],
|
||||
["no due day (winter rest)", row({ inspectionDueOn: null, lastInspectionDate: null }), "green", []],
|
||||
["one warning", row({ openWarnings: 1 }), "yellow", ["1 Warnung"]],
|
||||
["two warnings", row({ openWarnings: 2 }), "yellow", ["2 Warnungen"]],
|
||||
] as const)("%s", (_name, input, light, reasons) => {
|
||||
expect(colonyStatusLight(input, visible)).toEqual({ light, reasons });
|
||||
});
|
||||
|
||||
it("ignores the inspection age when the caller may not read inspections", () => {
|
||||
const result = colonyStatusLight(row({ lastInspectionDate: null }), {
|
||||
it("status light turns yellow once inspectionDueOn has passed", () => {
|
||||
expect(colonyStatusLight(row({ inspectionDueOn: "2026-10-12" }), visible).light).toBe("yellow");
|
||||
expect(colonyStatusLight(row({ inspectionDueOn: TODAY }), visible).light).toBe("green");
|
||||
});
|
||||
|
||||
it("ignores the due day when the caller may not read inspections", () => {
|
||||
const result = colonyStatusLight(row({ inspectionDueOn: "2026-10-01" }), {
|
||||
today: TODAY,
|
||||
inspectionsVisible: false,
|
||||
});
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import type { ColonyOverviewRow } from "@/lib/apiaries/field-types";
|
||||
import { daysSince } from "@/lib/inspections/takeover";
|
||||
import { daysBetween, inspectionDueText } from "@/lib/autopilot/format";
|
||||
|
||||
export type StatusLight = "green" | "yellow" | "red";
|
||||
|
||||
export const INSPECTION_STALE_DAYS = 14;
|
||||
|
||||
export interface StatusLightResult {
|
||||
light: StatusLight;
|
||||
reasons: string[];
|
||||
@@ -38,22 +36,21 @@ function conditionFindings(row: ColonyOverviewRow): Finding[] {
|
||||
return findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Yellow from the day after `inspectionDueOn`. A pack cached before T4 has no
|
||||
* due day - then nothing is said instead of a false alarm.
|
||||
*/
|
||||
function inspectionFinding(row: ColonyOverviewRow, today: string): Finding | null {
|
||||
if (row.lastInspectionDate === null) {
|
||||
return { light: "yellow", reason: "Noch keine Durchsicht" };
|
||||
}
|
||||
const days = daysSince(row.lastInspectionDate, today);
|
||||
if (days > INSPECTION_STALE_DAYS) {
|
||||
return { light: "yellow", reason: plural(days, "Tag ohne Durchsicht", "Tage ohne Durchsicht") };
|
||||
}
|
||||
return null;
|
||||
const dueOn = row.inspectionDueOn;
|
||||
if (!dueOn || daysBetween(dueOn, today) <= 0) return null;
|
||||
return { light: "yellow", reason: inspectionDueText(dueOn, today) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The traffic light of one living colony: the worst finding wins. Reasons are
|
||||
* plain text (the badge also shows icon and word, never colour alone), critical
|
||||
* ones first. The inspection age counts only when the caller may read
|
||||
* inspections - otherwise "never inspected" would be a false alarm.
|
||||
* ones first. The due inspection counts only when the caller may read
|
||||
* inspections (the due day is derived from the last inspection).
|
||||
*/
|
||||
export function colonyStatusLight(
|
||||
row: ColonyOverviewRow,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { WithWarnings } from "@/lib/warnings/types";
|
||||
|
||||
export interface HarvestColonyEntry {
|
||||
id: string;
|
||||
colonyId: string;
|
||||
@@ -7,7 +9,7 @@ export interface HarvestColonyEntry {
|
||||
};
|
||||
}
|
||||
|
||||
export interface Harvest {
|
||||
export interface Harvest extends WithWarnings {
|
||||
id: string;
|
||||
apiaryId: string;
|
||||
userId: string;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { apiFetch } from "@/lib/auth/api-client";
|
||||
import type { NotificationSettings } from "./types";
|
||||
|
||||
const PATH = "/profile/notification-settings";
|
||||
|
||||
export function fetchNotificationSettings(): Promise<NotificationSettings> {
|
||||
return apiFetch<NotificationSettings>(PATH);
|
||||
}
|
||||
|
||||
/** Send only what changed; an omitted field stays as it is. */
|
||||
export function updateNotificationSettings(
|
||||
patch: Partial<NotificationSettings>,
|
||||
): Promise<NotificationSettings> {
|
||||
return apiFetch<NotificationSettings>(PATH, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { NotificationCategory } from "@/lib/notifications/types";
|
||||
|
||||
/** The signed-in user's own settings (GET/PATCH /profile/notification-settings). */
|
||||
export interface NotificationSettings {
|
||||
taskDueEnabled: boolean;
|
||||
digestEnabled: boolean;
|
||||
/** HH:MM, 05:00 to 12:00 (Europe/Berlin). */
|
||||
digestTime: string;
|
||||
/** HH:MM or null; quiet hours are set or cleared only as a pair. */
|
||||
quietFrom: string | null;
|
||||
quietUntil: string | null;
|
||||
telegramCategories: NotificationCategory[];
|
||||
telegramDetail: boolean;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { settingsPatch, validateDigestTime, validateQuietHours } from "./validation";
|
||||
import type { NotificationSettings } from "./types";
|
||||
|
||||
const base: NotificationSettings = {
|
||||
taskDueEnabled: true,
|
||||
digestEnabled: true,
|
||||
digestTime: "06:30",
|
||||
quietFrom: "21:00",
|
||||
quietUntil: "06:30",
|
||||
telegramCategories: ["TASK_DUE", "DIGEST"],
|
||||
telegramDetail: true,
|
||||
};
|
||||
|
||||
describe("validateDigestTime", () => {
|
||||
it("rejects 04:30 with the German message", () => {
|
||||
expect(validateDigestTime("04:30")).toBe(
|
||||
"Bitte eine Uhrzeit zwischen 05:00 und 12:00 wählen.",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects 12:01, empty and malformed values", () => {
|
||||
expect(validateDigestTime("12:01")).not.toBeNull();
|
||||
expect(validateDigestTime("")).not.toBeNull();
|
||||
expect(validateDigestTime("abc")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("accepts the bounds", () => {
|
||||
expect(validateDigestTime("05:00")).toBeNull();
|
||||
expect(validateDigestTime("12:00")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateQuietHours", () => {
|
||||
it("clears only as a pair", () => {
|
||||
expect(validateQuietHours("", "")).toBeNull();
|
||||
expect(validateQuietHours("21:00", "06:30")).toBeNull();
|
||||
expect(validateQuietHours("21:00", "")).toBe(
|
||||
"Bitte Beginn und Ende der Ruhezeit angeben oder beide leeren.",
|
||||
);
|
||||
expect(validateQuietHours("", "06:30")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("settingsPatch", () => {
|
||||
it("contains only changed fields", () => {
|
||||
expect(settingsPatch(base, { ...base, digestTime: "07:00" })).toEqual({
|
||||
digestTime: "07:00",
|
||||
});
|
||||
});
|
||||
|
||||
it("is empty when nothing changed", () => {
|
||||
expect(settingsPatch(base, { ...base })).toEqual({});
|
||||
});
|
||||
|
||||
it("sends null for both quiet fields when cleared", () => {
|
||||
expect(settingsPatch(base, { ...base, quietFrom: null, quietUntil: null })).toEqual({
|
||||
quietFrom: null,
|
||||
quietUntil: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { NotificationSettings } from "./types";
|
||||
|
||||
export const DIGEST_TIME_MIN = "05:00";
|
||||
export const DIGEST_TIME_MAX = "12:00";
|
||||
export const DIGEST_TIME_MESSAGE = "Bitte eine Uhrzeit zwischen 05:00 und 12:00 wählen.";
|
||||
export const QUIET_PAIR_MESSAGE = "Bitte Beginn und Ende der Ruhezeit angeben oder beide leeren.";
|
||||
|
||||
const TIME_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||
|
||||
/** HH:MM strings of one day compare correctly as text. */
|
||||
export function validateDigestTime(value: string): string | null {
|
||||
if (!TIME_PATTERN.test(value) || value < DIGEST_TIME_MIN || value > DIGEST_TIME_MAX) {
|
||||
return DIGEST_TIME_MESSAGE;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Quiet hours are both set or both empty. */
|
||||
export function validateQuietHours(from: string, until: string): string | null {
|
||||
return Boolean(from) === Boolean(until) ? null : QUIET_PAIR_MESSAGE;
|
||||
}
|
||||
|
||||
/** Only the fields that differ from what the server last sent. */
|
||||
export function settingsPatch(
|
||||
original: NotificationSettings,
|
||||
draft: NotificationSettings,
|
||||
): Partial<NotificationSettings> {
|
||||
const patch: Partial<NotificationSettings> = {};
|
||||
if (draft.taskDueEnabled !== original.taskDueEnabled) patch.taskDueEnabled = draft.taskDueEnabled;
|
||||
if (draft.digestEnabled !== original.digestEnabled) patch.digestEnabled = draft.digestEnabled;
|
||||
if (draft.digestTime !== original.digestTime) patch.digestTime = draft.digestTime;
|
||||
if (draft.quietFrom !== original.quietFrom) patch.quietFrom = draft.quietFrom;
|
||||
if (draft.quietUntil !== original.quietUntil) patch.quietUntil = draft.quietUntil;
|
||||
return patch;
|
||||
}
|
||||
@@ -8,3 +8,8 @@ export function fetchNotifications(unreadOnly = false): Promise<Notification[]>
|
||||
export function markNotificationRead(id: string): Promise<Notification> {
|
||||
return apiFetch<Notification>(`/notifications/${encodeURIComponent(id)}/read`, { method: "POST" });
|
||||
}
|
||||
|
||||
/** Marks all of the user's visible unread notifications as read. */
|
||||
export function markAllNotificationsRead(): Promise<{ updated: number }> {
|
||||
return apiFetch<{ updated: number }>("/notifications/read-all", { method: "POST" });
|
||||
}
|
||||
@@ -1,11 +1,22 @@
|
||||
export type NotificationPriority = "INFO" | "HINWEIS" | "WARNUNG" | "KRITISCH";
|
||||
|
||||
export type NotificationCategory =
|
||||
| "TASK_DUE"
|
||||
| "DIGEST"
|
||||
| "SUGGESTION"
|
||||
| "WITHDRAWAL"
|
||||
| "SEASON"
|
||||
| "SYSTEM";
|
||||
|
||||
export interface Notification {
|
||||
id: string;
|
||||
priority: NotificationPriority;
|
||||
category: NotificationCategory;
|
||||
message: string;
|
||||
relatedType: string | null;
|
||||
relatedId: string | null;
|
||||
/** Relative app path (starts with "/") or null; opened as an internal link. */
|
||||
link: string | null;
|
||||
read: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -18,8 +18,14 @@ describe("suggestion labels", () => {
|
||||
expect(captureHref("HONEY_SUPER", "c1")).toBe("/inspections/new?colonyId=c1");
|
||||
});
|
||||
|
||||
it("opens the winter page for a winter check and omits a missing colony", () => {
|
||||
expect(captureHref("WINTER_CHECK", "c1")).toBe("/winter");
|
||||
it("opens the winter page of that colony with the season, and omits a missing colony", () => {
|
||||
expect(captureHref("WINTER_CHECK", "c1", new Date(2026, 9, 14))).toBe(
|
||||
"/winter?colonyId=c1&season=AUTUMN",
|
||||
);
|
||||
expect(captureHref("WINTER_CHECK", "c1", new Date(2027, 2, 14))).toBe(
|
||||
"/winter?colonyId=c1&season=SPRING",
|
||||
);
|
||||
expect(captureHref("WINTER_CHECK", null)).toBe("/winter");
|
||||
expect(captureHref("INSPECT", null)).toBe("/inspections/new");
|
||||
});
|
||||
});
|
||||
@@ -68,13 +68,23 @@ const CAPTURE_PATHS: Partial<Record<SuggestionIntent, string>> = {
|
||||
WINTER_CHECK: "/winter",
|
||||
};
|
||||
|
||||
/** Winter checks are entered from the winter page, which has no colony parameter. */
|
||||
const PATHS_WITHOUT_COLONY = new Set(["/winter"]);
|
||||
|
||||
const DEFAULT_CAPTURE_PATH = "/inspections/new";
|
||||
|
||||
export function captureHref(intent: SuggestionIntent, colonyId: string | null): string {
|
||||
/** From July on the winter check is the autumn one (as the autopilot's own link says). */
|
||||
const FIRST_AUTUMN_MONTH = 7;
|
||||
|
||||
export function captureHref(
|
||||
intent: SuggestionIntent,
|
||||
colonyId: string | null,
|
||||
now: Date = new Date(),
|
||||
): string {
|
||||
const path = CAPTURE_PATHS[intent] ?? DEFAULT_CAPTURE_PATH;
|
||||
if (!colonyId || PATHS_WITHOUT_COLONY.has(path)) return path;
|
||||
return `${path}?colonyId=${encodeURIComponent(colonyId)}`;
|
||||
if (!colonyId) return path;
|
||||
const query = `colonyId=${encodeURIComponent(colonyId)}`;
|
||||
if (path === "/winter") {
|
||||
// The winter page opens the dialog of that colony for the season.
|
||||
const season = now.getMonth() + 1 >= FIRST_AUTUMN_MONTH ? "AUTUMN" : "SPRING";
|
||||
return `${path}?${query}&season=${season}`;
|
||||
}
|
||||
return `${path}?${query}`;
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { apiFetch } from "@/lib/auth/api-client";
|
||||
import { CreateTaskInput, Task, TaskFilter, UpdateTaskInput } from "./types";
|
||||
|
||||
export function fetchTask(id: string): Promise<Task> {
|
||||
return apiFetch<Task>(`/tasks/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
export function fetchTasks(filter: TaskFilter): Promise<Task[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (filter.status) params.set("status", filter.status);
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { toast } from "sonner";
|
||||
import { toastWarnings } from "./toast-warnings";
|
||||
import type { PlausibilityWarning } from "./types";
|
||||
|
||||
vi.mock("sonner", () => ({ toast: { warning: vi.fn() } }));
|
||||
|
||||
const withdrawal: PlausibilityWarning = {
|
||||
code: "WITHDRAWAL_ACTIVE",
|
||||
severity: "WARNUNG",
|
||||
message: "B3: Wartezeit nach Oxuvar endet am 20.08.2026.",
|
||||
refs: [],
|
||||
};
|
||||
|
||||
describe("toastWarnings", () => {
|
||||
beforeEach(() => vi.mocked(toast.warning).mockReset());
|
||||
|
||||
it("says Gespeichert with the count and names the texts", () => {
|
||||
toastWarnings([withdrawal]);
|
||||
expect(toast.warning).toHaveBeenCalledWith(
|
||||
"Gespeichert · 1 Warnung",
|
||||
expect.objectContaining({
|
||||
description: "B3: Wartezeit nach Oxuvar endet am 20.08.2026.",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a WARNUNG toast until dismissed, one line per warning, with a 44 px close action", () => {
|
||||
toastWarnings([withdrawal, { ...withdrawal, message: "Zweiter Text." }]);
|
||||
const options = vi.mocked(toast.warning).mock.calls[0][1] as unknown as {
|
||||
duration: number;
|
||||
closeButton: boolean;
|
||||
description: string;
|
||||
classNames: Record<string, string>;
|
||||
action: { label: string };
|
||||
};
|
||||
expect(options.duration).toBe(Infinity);
|
||||
expect(options.closeButton).toBe(true);
|
||||
expect(options.description).toBe(`${withdrawal.message}\nZweiter Text.`);
|
||||
expect(options.classNames.description).toContain("whitespace-pre-line");
|
||||
expect(options.action.label).toBe("Verstanden");
|
||||
expect(options.classNames.actionButton).toContain("min-h-11");
|
||||
});
|
||||
|
||||
it("keeps a BESTAETIGEN toast too but lets a plain HINWEIS time out", () => {
|
||||
toastWarnings([{ ...withdrawal, severity: "BESTAETIGEN" }]);
|
||||
expect(
|
||||
(vi.mocked(toast.warning).mock.calls[0][1] as { duration?: number })
|
||||
.duration,
|
||||
).toBe(Infinity);
|
||||
vi.mocked(toast.warning).mockReset();
|
||||
toastWarnings([{ ...withdrawal, severity: "HINWEIS" }]);
|
||||
expect(
|
||||
(vi.mocked(toast.warning).mock.calls[0][1] as { duration?: number })
|
||||
.duration,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("starts the title with the given saved label", () => {
|
||||
toastWarnings([withdrawal], "Abfüllung angelegt");
|
||||
expect(vi.mocked(toast.warning).mock.calls[0][0]).toBe(
|
||||
"Abfüllung angelegt · 1 Warnung",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the plural for several warnings", () => {
|
||||
toastWarnings([withdrawal, { ...withdrawal, message: "Zweiter Text." }]);
|
||||
expect(vi.mocked(toast.warning).mock.calls[0][0]).toBe(
|
||||
"Gespeichert · 2 Warnungen",
|
||||
);
|
||||
});
|
||||
|
||||
it("stays silent without warnings", () => {
|
||||
toastWarnings([]);
|
||||
expect(toast.warning).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { toast } from "sonner";
|
||||
import type { PlausibilityWarning } from "./types";
|
||||
|
||||
/** Warnings about food safety must not time out: they stay until dismissed. */
|
||||
function mustStay(warnings: readonly PlausibilityWarning[]): boolean {
|
||||
return warnings.some(
|
||||
(warning) =>
|
||||
warning.severity === "WARNUNG" || warning.severity === "BESTAETIGEN",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* After a successful save: "<saved> · 1 Warnung" plus one line per text. The
|
||||
* record is saved either way; warnings only point at something to check.
|
||||
* A WARNUNG or BESTAETIGEN stays until the person dismisses it. Does nothing
|
||||
* for an empty list - the caller shows its own success then.
|
||||
*/
|
||||
export function toastWarnings(
|
||||
warnings: readonly PlausibilityWarning[],
|
||||
savedLabel = "Gespeichert",
|
||||
): void {
|
||||
if (warnings.length === 0) return;
|
||||
const count = warnings.length;
|
||||
const title = `${savedLabel} \u00b7 ${count} ${count === 1 ? "Warnung" : "Warnungen"}`;
|
||||
const description = warnings.map((warning) => warning.message).join("\n");
|
||||
if (!mustStay(warnings)) {
|
||||
toast.warning(title, {
|
||||
description,
|
||||
classNames: { description: "whitespace-pre-line" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast.warning(title, {
|
||||
description,
|
||||
duration: Infinity,
|
||||
closeButton: true,
|
||||
action: { label: "Verstanden", onClick: () => undefined },
|
||||
classNames: {
|
||||
description: "whitespace-pre-line",
|
||||
actionButton: "min-h-11 min-w-11",
|
||||
closeButton: "size-11",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export type WarningSeverity = "HINWEIS" | "WARNUNG" | "BESTAETIGEN";
|
||||
|
||||
/** One format for every plausibility finding on honey records (spec 10.3). */
|
||||
export interface PlausibilityWarning {
|
||||
code: string;
|
||||
severity: WarningSeverity;
|
||||
message: string;
|
||||
refs: { type: string; id: string }[];
|
||||
}
|
||||
|
||||
/** A save response may carry warnings; they never block the save. */
|
||||
export interface WithWarnings {
|
||||
warnings?: PlausibilityWarning[];
|
||||
}
|
||||
Reference in new issue
Block a user