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

@@ -8,11 +8,17 @@ import { NavSheet } from "@/components/nav/nav-sheet";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { PhotoThumbnail } from "@/components/photos/photo-capture-field";
import { Button } from "@/components/ui/button";
import { discardQueueItem, listQueue, type QueueError, type QueueItem } from "@/lib/offline/db";
import {
discardQueueItem,
listQueue,
retryBulkWithout,
type QueueError,
type QueueItem,
} from "@/lib/offline/db";
import { entriesSynced, foreignEntriesWaiting, photosAttached } from "@/lib/offline/queue-texts";
import { syncPendingEntries } from "@/lib/offline/sync";
import { OFFLINE_QUEUE_QUERY_KEY } from "@/lib/offline/use-capture-mutation";
import { listPendingPhotos, removePhoto, resetPhotoError } from "@/lib/photos/store";
import { discardReadyPhoto, listPendingPhotos, resetPhotoError } from "@/lib/photos/store";
interface PendingQueueSheetProps {
userId: string;
@@ -80,6 +86,33 @@ function QueueStatus({ lastError }: { lastError?: QueueError }) {
);
}
/** „Ohne V-3 erneut senden“ - or, when a code is unknown, without naming them. */
function ResendWithoutButton({
resend,
disabled,
onResend,
}: {
resend: NonNullable<QueueItem["resendWithout"]>;
disabled: boolean;
onResend: () => void;
}) {
const isNamed = resend.codes.length === resend.colonyIds.length;
const without = isNamed ? resend.codes.join(", ") : "die abgelehnten Völker";
return (
<Button
type="button"
variant="outline"
size="touch"
className="self-start"
disabled={disabled}
onClick={onResend}
>
<RotateCw aria-hidden />
Ohne {without} erneut senden
</Button>
);
}
/**
* What this device still has to send for the signed-in user: each entry with
* its state, a rejected one with the server's reason, and a way to discard it.
@@ -120,7 +153,7 @@ export function PendingQueueSheet({
});
const discardPhoto = useMutation({
mutationFn: (photoId: string) => removePhoto(photoId),
mutationFn: (photoId: string) => discardReadyPhoto(photoId),
networkMode: "always",
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
@@ -133,7 +166,8 @@ export function PendingQueueSheet({
const retryPhoto = useMutation({
mutationFn: async (photoId: string) => {
await resetPhotoError(photoId);
return syncPendingEntries(userId);
// A sync already running may have passed this photo: it runs once more.
return syncPendingEntries(userId, { afterChange: true });
},
networkMode: "always",
onSuccess: (result) => {
@@ -148,6 +182,26 @@ export function PendingQueueSheet({
onError: () => toast.error("Foto konnte nicht erneut gesendet werden."),
});
// „Ohne … erneut senden“: the refused colonies leave the bulk entry (same
// key, rejection cleared), then a normal run sends the rest.
const resendBulk = useMutation({
mutationFn: async ({ id, colonyIds }: { id: number; colonyIds: string[] }) => {
await retryBulkWithout(id, colonyIds);
// A sync already running may have passed this entry: it runs once more.
return syncPendingEntries(userId, { afterChange: true });
},
networkMode: "always",
onSuccess: (result) => {
if (result.synced > 0) {
toast.success(entriesSynced(result.synced));
void queryClient.invalidateQueries();
return;
}
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
},
onError: () => toast.error("Eintrag konnte nicht erneut gesendet werden."),
});
const names = entryNames(items);
const photoNames = entryNames(
photos.map((photo) => ({ label: PHOTO_LABEL, queuedAt: photo.createdAt })),
@@ -197,6 +251,18 @@ export function PendingQueueSheet({
/>
</div>
<QueueStatus lastError={item.lastError} />
{item.resendWithout && typeof item.id === "number" ? (
<ResendWithoutButton
resend={item.resendWithout}
disabled={resendBulk.isPending}
onResend={() =>
resendBulk.mutate({
id: item.id as number,
colonyIds: item.resendWithout?.colonyIds ?? [],
})
}
/>
) : null}
</li>
))}
{photos.map((photo, index) => (