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

@@ -17,7 +17,8 @@ import { PendingQueueSheet } from "./pending-queue-sheet";
const COUNT_INTERVAL_MS = 15_000;
const EMPTY_QUEUE: QueueSummary = { own: 0, failed: 0, foreign: 0 };
// Both auto-sync triggers can fire together; they share one sync run
// (syncPendingEntries), and one toast id keeps it to one message.
// (syncPendingEntries), even one already running - they change nothing that
// is to be sent - and one toast id keeps it to one message.
const SYNC_TOAST_ID = "offline-sync";
// Wraps on narrow phones instead of overflowing; never below 44 px.
const BAND_BUTTON_CLASS = "h-auto min-h-11 whitespace-normal text-left";
@@ -26,6 +27,12 @@ interface OfflineBandProps {
userId: string;
/** Focus target when the queue sheet closes and the band shows nothing. */
pageContentRef: RefObject<HTMLElement | null>;
/**
* False in an offline session (opened from the cached identity, no access
* token): the band then shows what waits but neither opens the sync gate
* nor sends - the server would only answer 401.
*/
syncEnabled?: boolean;
}
/**
@@ -33,7 +40,7 @@ interface OfflineBandProps {
* device, how much still waits to be sent and what the server rejected.
* Sends by itself as soon as the connection is back.
*/
export function OfflineBand({ userId, pageContentRef }: OfflineBandProps) {
export function OfflineBand({ userId, pageContentRef, syncEnabled = true }: OfflineBandProps) {
const queryClient = useQueryClient();
const connectivity = useConnectivity();
const statusRef = useRef<HTMLDivElement>(null);
@@ -68,24 +75,31 @@ export function OfflineBand({ userId, pageContentRef }: OfflineBandProps) {
// The band only exists for a signed-in user, and the auth context sets that
// user's access token before the user: the moment to reopen sync after a
// logout's cancelSync(). Declared first, so it runs before the sync below.
// logout's cancelSync(). Not without a token (offline session): the gate
// stays closed until the session is online. Declared first, so it runs
// before the sync below.
useEffect(() => {
resumeSync();
}, [userId]);
if (syncEnabled) {
resumeSync();
}
}, [userId, syncEnabled]);
// Mount, and every switch back to online (a request reached the server again).
useEffect(() => {
if (connectivity === "online") {
if (syncEnabled && connectivity === "online") {
startSync();
}
}, [connectivity, startSync, userId]);
}, [connectivity, startSync, userId, syncEnabled]);
// The browser's own `online` event: try even while the last request failed.
useEffect(() => {
if (!syncEnabled) {
return;
}
const handleOnline = () => startSync();
window.addEventListener("online", handleOnline);
return () => window.removeEventListener("online", handleOnline);
}, [startSync]);
}, [startSync, syncEnabled]);
// The sheet stays open while the user still has entries in it (waiting
// ones too, after the last rejected one was discarded), and closes once
@@ -95,7 +109,8 @@ export function OfflineBand({ userId, pageContentRef }: OfflineBandProps) {
setSheetOpen(false);
}
const isOffline = connectivity === "offline";
// An offline session cannot send, whatever the network says.
const isOffline = connectivity === "offline" || !syncEnabled;
const hasContent = isOffline || queue.own > 0 || hasRejected;
// Back to the trigger while it exists; otherwise to the band's message, or