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

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

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

No files matched your search

@@ -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();
});
});