feat: platform backbone and field capture complete (checkpoint)

Platform backbone (plan 2026-10-13-plattform-rueckgrat), all tasks reviewed:
- webhooks with delivery table, retries and stable delivery id
- calendar handlers; a finished task removes its Outlook event
- tasks, automations, inspections, varroa, treatments, feedings,
  harvests, colonies, wintering, stock and bottling record domain events
  in the same transaction; legacy post-commit calls removed
- row locks through one helper (lockRowForUpdate, lockRowsForShare,
  lockRowsForUpdate)
- job framework with JobRun, cli exit codes, cleanup, task templates,
  daily evaluation, hourly app-backup and the rekey job
- system API and page "Verwaltung > System" with retry
- @nestjs/schedule removed, guard tests against in-process schedulers

Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), all tasks reviewed:
- inspection photo offline without metadata
- bulk capture for treatments, feedings and varroa (backend + frontend)
- offline stand mode with service worker, offline pages and packs
- offline session, cold start and logout on shared devices

Docs: ARCHITECTURE.md assumptions and sections updated, TASKS.md.
Gates: backend lint, typecheck, 1770 unit, 420 e2e, build, no schema
drift; frontend lint, typecheck, 913 tests, build. Not rolled out.

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 11:36:38 +02:00
1 parent c01a38835a
commit cdd30cf3ee
288 files changed
+39165 -2853

No files matched your search

+88 -5
View File
@@ -1,15 +1,19 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Card, CardContent } from "@/components/ui/card";
import { StickySubmit } from "@/components/form/sticky-submit";
import { purgeStaleDraftPhotos } from "@/lib/photos/store";
import { finishHandOvers, purgeStaleDraftPhotos } from "@/lib/photos/store";
import AppLayout from "./layout";
const pushMock = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ replace: vi.fn(), push: vi.fn() }),
useRouter: () => ({ replace: vi.fn(), push: pushMock }),
usePathname: () => "/inspections/new",
}));
let sessionMode: "online" | "offline" = "online";
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({
user: {
@@ -23,20 +27,43 @@ vi.mock("@/lib/auth/auth-context", () => ({
hasAvatar: false,
},
isLoading: false,
sessionMode,
logout: vi.fn(),
}),
}));
vi.mock("@/components/auth/logout-confirm-dialog", () => ({
LogoutConfirmDialog: ({
open,
userId,
onLoggedOut,
}: {
open: boolean;
userId: string;
onLoggedOut: () => void;
}) =>
open ? (
<div role="dialog" aria-label="Abmelden?">
{userId}
<button type="button" onClick={onLoggedOut}>
Abgemeldet
</button>
</div>
) : null,
}));
vi.mock("@/components/notification-bell", () => ({ NotificationBell: () => null }));
vi.mock("@/components/offline/offline-band", () => ({
OfflineBand: ({
userId,
pageContentRef,
syncEnabled,
}: {
userId: string;
pageContentRef: { current: HTMLElement | null };
syncEnabled?: boolean;
}) => (
<div data-testid="offline-band">
<div data-testid="offline-band" data-sync-enabled={String(syncEnabled)}>
{userId}
<button type="button" onClick={() => pageContentRef.current?.focus()}>
Fokus auf den Seiteninhalt
@@ -45,7 +72,10 @@ vi.mock("@/components/offline/offline-band", () => ({
),
}));
vi.mock("@/components/user-avatar", () => ({ UserAvatar: () => null }));
vi.mock("@/lib/photos/store", () => ({ purgeStaleDraftPhotos: vi.fn().mockResolvedValue(0) }));
vi.mock("@/lib/photos/store", () => ({
purgeStaleDraftPhotos: vi.fn().mockResolvedValue(0),
finishHandOvers: vi.fn().mockResolvedValue(0),
}));
// A box with overflow auto/scroll/hidden is a scroll container. Any such
// ancestor of the page content captures `position: sticky` (StickySubmit),
@@ -59,6 +89,11 @@ function expectNoScrollContainerAbove(element: HTMLElement) {
}
describe("AppLayout", () => {
beforeEach(() => {
sessionMode = "online";
pushMock.mockClear();
});
it("shows the offline band for the signed-in user right below the header", () => {
render(
<AppLayout>
@@ -71,6 +106,41 @@ describe("AppLayout", () => {
expect(band.previousElementSibling?.tagName).toBe("HEADER");
});
it("lets the band send only in an online session", () => {
const { unmount } = render(
<AppLayout>
<p>Seiteninhalt</p>
</AppLayout>,
);
expect(screen.getByTestId("offline-band")).toHaveAttribute("data-sync-enabled", "true");
unmount();
sessionMode = "offline";
render(
<AppLayout>
<p>Seiteninhalt</p>
</AppLayout>,
);
expect(screen.getByTestId("offline-band")).toHaveAttribute("data-sync-enabled", "false");
});
it("asks through the logout dialog and moves to the login page once signed out", async () => {
render(
<AppLayout>
<p>Seiteninhalt</p>
</AppLayout>,
);
expect(screen.queryByRole("dialog", { name: "Abmelden?" })).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Konto-Menü" }));
await userEvent.click(await screen.findByRole("menuitem", { name: "Abmelden" }));
const dialog = await screen.findByRole("dialog", { name: "Abmelden?" });
expect(dialog).toHaveTextContent("u1");
await userEvent.click(screen.getByRole("button", { name: "Abgemeldet" }));
expect(pushMock).toHaveBeenCalledWith("/login");
});
it("gives the band the page content wrapper as a stable focus target", () => {
render(
<AppLayout>
@@ -100,6 +170,19 @@ describe("AppLayout", () => {
expect(now).toBeGreaterThanOrEqual(before);
});
it("finishes the signed-in user's open photo hand-overs when the app starts", async () => {
vi.mocked(finishHandOvers).mockClear();
render(
<AppLayout>
<p>Seiteninhalt</p>
</AppLayout>,
);
// Even offline: the photos then wait as entries of their own.
await waitFor(() => expect(finishHandOvers).toHaveBeenCalledTimes(1));
expect(finishHandOvers).toHaveBeenCalledWith("u1");
});
it("does not wrap the page content in its own scroll container", () => {
render(
<AppLayout>