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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user