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