feat: platform backbone and field capture, first part (checkpoint)

Platform backbone (plan 2026-10-13-plattform-rueckgrat), reviewed tasks:
- run roles api/worker/job/all with shared startup checks (Task 1)
- schema and migration for DomainEvent, deliveries, JobRun, ThrottlerHit (Task 2)
- audit, notifications and domain events written in the same transaction (Task 3)
- dispatcher with SKIP LOCKED claims, retries and stale release (Task 4)
- worker building blocks: tick, LISTEN/NOTIFY wake-up, health (Task 5)
- worker process and entry points (Task 6)
- throttler counters in Postgres (Task 19)
- separate keys per purpose with key ring (Task 20)
- public origin page without exact location (Task 22)
- shop keys split into read and write (Task 23)
- Kustomize base, components, overlay encry, CI validation (Task 26)

Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), reviewed tasks:
- brood frames, read endpoints, apiary overview and offline pack
- network basics, offline queue v3, navigation sheets, form building blocks
- new inspection with last-time hints and weather, varroa per day,
  treatment prefill, apiary page with round
- inspection photo (Task 10) is implemented, review pending

Not rolled out. Remaining tasks follow in later commits.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-13 17:57:54 +02:00
1 parent 10cbe9bf2d
commit c01a38835a
340 files changed
+25695 -1808

No files matched your search

@@ -0,0 +1,263 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useRef } from "react";
import { act, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { discardQueueItem, listQueue, summarizeQueue, type QueueItem } from "@/lib/offline/db";
import { resumeSync, syncPendingEntries } from "@/lib/offline/sync";
import { OfflineBand } from "./offline-band";
vi.mock("@/lib/offline/db", () => ({
summarizeQueue: vi.fn(),
listQueue: vi.fn(),
discardQueueItem: vi.fn(),
}));
vi.mock("@/lib/offline/sync", () => ({ syncPendingEntries: vi.fn(), resumeSync: vi.fn() }));
const NOTHING_SENT = { synced: 0, failed: 0, skippedForeign: 0 };
const REJECTED: QueueItem = {
store: "pending-inspections",
id: 7,
label: "Durchsicht",
queuedAt: "2026-10-13T08:15:00.000Z",
lastError: { status: 400, message: "Volk V-03 ist beendet.", at: "2026-10-13T09:00:00.000Z" },
};
const WAITING: QueueItem = {
store: "pending-feedings",
id: 3,
label: "Fütterung",
queuedAt: "2026-10-13T08:20:00.000Z",
};
function setOnLine(value: boolean) {
vi.spyOn(window.navigator, "onLine", "get").mockReturnValue(value);
}
/** The band as the layout places it: above the page content wrapper. */
function BandOnPage() {
const pageContentRef = useRef<HTMLDivElement>(null);
return (
<>
<OfflineBand userId="user-1" pageContentRef={pageContentRef} />
<div ref={pageContentRef} tabIndex={-1} data-testid="page-content">
Seiteninhalt
</div>
</>
);
}
function renderBand() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<BandOnPage />
</QueryClientProvider>,
);
}
/** Opens the sheet from the rejected entries and discards the rejected one. */
async function discardRejectedEntry(nextSummary: { own: number; failed: number; foreign: number }) {
await userEvent.click(
await screen.findByRole("button", { name: "1 Eintrag abgelehnt – ansehen" }),
);
const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" });
vi.mocked(summarizeQueue).mockResolvedValue(nextSummary);
vi.mocked(listQueue).mockResolvedValue(nextSummary.own > 0 ? [WAITING] : []);
await userEvent.click(
await within(sheet).findByRole("button", { name: /^Verwerfen: Durchsicht vom / }),
);
const confirm = await screen.findByRole("dialog", { name: /^Durchsicht vom .+ verwerfen\?$/ });
await userEvent.click(within(confirm).getByRole("button", { name: "Verwerfen" }));
await waitFor(() => expect(discardQueueItem).toHaveBeenCalledWith(REJECTED));
return sheet;
}
describe("OfflineBand", () => {
beforeEach(() => {
vi.mocked(summarizeQueue).mockReset();
vi.mocked(listQueue).mockReset().mockResolvedValue([]);
vi.mocked(discardQueueItem).mockReset().mockResolvedValue(undefined);
vi.mocked(syncPendingEntries).mockReset().mockResolvedValue(NOTHING_SENT);
vi.mocked(resumeSync).mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("announces „Offline – 2 Einträge warten\" in a status region", async () => {
setOnLine(false);
vi.mocked(summarizeQueue).mockResolvedValue({ own: 2, failed: 0, foreign: 0 });
renderBand();
await waitFor(() =>
expect(screen.getByRole("status")).toHaveTextContent("Offline – 2 Einträge warten"),
);
expect(screen.getByRole("status")).toHaveAttribute("aria-live", "polite");
expect(summarizeQueue).toHaveBeenCalledWith("user-1");
expect(syncPendingEntries).not.toHaveBeenCalled();
});
it("says „1 Eintrag wartet\" for a single entry", async () => {
setOnLine(false);
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 });
renderBand();
await waitFor(() =>
expect(screen.getByRole("status")).toHaveTextContent("Offline – 1 Eintrag wartet"),
);
});
it("tells that input is kept while offline with an empty queue", async () => {
setOnLine(false);
vi.mocked(summarizeQueue).mockResolvedValue({ own: 0, failed: 0, foreign: 0 });
renderBand();
expect(await screen.findByRole("status")).toHaveTextContent(
"Offline – Eingaben werden gespeichert und später gesendet",
);
});
it("keeps an empty status region online with an empty queue", async () => {
setOnLine(true);
vi.mocked(summarizeQueue).mockResolvedValue({ own: 0, failed: 0, foreign: 3 });
renderBand();
await waitFor(() => expect(summarizeQueue).toHaveBeenCalled());
await act(async () => {});
// The live region must exist before its first message, or that message is not announced.
const status = screen.getByRole("status");
expect(status).toHaveAttribute("aria-live", "polite");
expect(status).toBeEmptyDOMElement();
});
it("„Jetzt senden\" starts syncPendingEntries", async () => {
setOnLine(true);
vi.mocked(summarizeQueue).mockResolvedValue({ own: 2, failed: 0, foreign: 0 });
renderBand();
const button = await screen.findByRole("button", { name: "2 Einträge warten – Jetzt senden" });
// Online on mount the band already tries once by itself.
await waitFor(() => expect(syncPendingEntries).toHaveBeenCalledTimes(1));
await userEvent.click(button);
await waitFor(() => expect(syncPendingEntries).toHaveBeenCalledTimes(2));
expect(syncPendingEntries).toHaveBeenLastCalledWith("user-1");
});
it("its button is at least 44 px", async () => {
setOnLine(true);
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 1, foreign: 0 });
renderBand();
expect(
await screen.findByRole("button", { name: "1 Eintrag wartet – Jetzt senden" }),
).toHaveClass("min-h-11");
expect(screen.getByRole("button", { name: "1 Eintrag abgelehnt – ansehen" })).toHaveClass(
"min-h-11",
);
});
it("opens the queue sheet from the rejected entries", async () => {
setOnLine(true);
vi.mocked(summarizeQueue).mockResolvedValue({ own: 0, failed: 2, foreign: 0 });
renderBand();
await userEvent.click(
await screen.findByRole("button", { name: "2 Einträge abgelehnt – ansehen" }),
);
expect(await screen.findByRole("dialog", { name: "Wartende Einträge" })).toBeInTheDocument();
expect(listQueue).toHaveBeenCalledWith("user-1");
});
it("closes the sheet once nothing is left and keeps focus in the band while it has content", async () => {
setOnLine(false);
vi.mocked(summarizeQueue).mockResolvedValue({ own: 0, failed: 1, foreign: 0 });
vi.mocked(listQueue).mockResolvedValue([REJECTED]);
renderBand();
await discardRejectedEntry({ own: 0, failed: 0, foreign: 0 });
await waitFor(() =>
expect(screen.queryByRole("dialog", { name: "Wartende Einträge" })).not.toBeInTheDocument(),
);
// Offline, the band still says so - focus lands on that message.
await waitFor(() => expect(screen.getByRole("status")).toHaveFocus());
});
it("moves focus to the page content when the band has nothing left to show", async () => {
setOnLine(true);
vi.mocked(summarizeQueue).mockResolvedValue({ own: 0, failed: 1, foreign: 0 });
vi.mocked(listQueue).mockResolvedValue([REJECTED]);
renderBand();
await discardRejectedEntry({ own: 0, failed: 0, foreign: 0 });
await waitFor(() =>
expect(screen.queryByRole("dialog", { name: "Wartende Einträge" })).not.toBeInTheDocument(),
);
await waitFor(() => expect(screen.getByTestId("page-content")).toHaveFocus());
expect(screen.getByRole("status")).toBeEmptyDOMElement();
});
it("keeps the sheet open with the waiting entries after the last rejected one is discarded", async () => {
setOnLine(true);
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 1, foreign: 0 });
vi.mocked(listQueue).mockResolvedValue([REJECTED, WAITING]);
renderBand();
const sheet = await discardRejectedEntry({ own: 1, failed: 0, foreign: 0 });
await waitFor(() =>
expect(screen.queryByRole("button", { name: /abgelehnt – ansehen/ })).not.toBeInTheDocument(),
);
expect(within(sheet).getByText("Fütterung")).toBeInTheDocument();
expect(screen.getByRole("dialog", { name: "Wartende Einträge" })).toBeInTheDocument();
await userEvent.click(within(sheet).getByRole("button", { name: "Schließen" }));
await waitFor(() =>
expect(screen.queryByRole("dialog", { name: "Wartende Einträge" })).not.toBeInTheDocument(),
);
// The trigger is gone; focus goes to the band, which still shows the
// waiting entry (Base UI picks its first focusable element, the send button).
await waitFor(() =>
expect(screen.getByRole("status")).toContainElement(document.activeElement as HTMLElement),
);
expect(screen.getByRole("button", { name: "1 Eintrag wartet – Jetzt senden" })).toHaveFocus();
});
it("opens sync for the signed-in user before its first automatic sync", async () => {
setOnLine(true);
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 });
renderBand();
await waitFor(() => expect(syncPendingEntries).toHaveBeenCalledWith("user-1"));
expect(resumeSync).toHaveBeenCalledTimes(1);
expect(vi.mocked(resumeSync).mock.invocationCallOrder[0]).toBeLessThan(
vi.mocked(syncPendingEntries).mock.invocationCallOrder[0],
);
});
it("sends by itself as soon as the connection is back", async () => {
setOnLine(false);
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 });
renderBand();
await screen.findByText("Offline – 1 Eintrag wartet");
expect(syncPendingEntries).not.toHaveBeenCalled();
setOnLine(true);
act(() => {
window.dispatchEvent(new Event("online"));
});
await waitFor(() => expect(syncPendingEntries).toHaveBeenCalledWith("user-1"));
});
});