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,171 @@
"use client";
import { RefObject, useEffect, useRef, useState } from "react";
import { AlertTriangle, CloudOff, Send } from "lucide-react";
import { toast } from "sonner";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { SheetTrigger } from "@/components/ui/sheet";
import { cn } from "@/lib/utils";
import { useConnectivity } from "@/lib/offline/connectivity";
import { QueueSummary, summarizeQueue } from "@/lib/offline/db";
import { entriesRejected, entriesSynced, entriesWaiting } from "@/lib/offline/queue-texts";
import { resumeSync, syncPendingEntries } from "@/lib/offline/sync";
import { OFFLINE_QUEUE_QUERY_KEY } from "@/lib/offline/use-capture-mutation";
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.
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";
interface OfflineBandProps {
userId: string;
/** Focus target when the queue sheet closes and the band shows nothing. */
pageContentRef: RefObject<HTMLElement | null>;
}
/**
* One line under the header that says whether input is only stored on this
* 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) {
const queryClient = useQueryClient();
const connectivity = useConnectivity();
const statusRef = useRef<HTMLDivElement>(null);
const [sheetOpen, setSheetOpen] = useState(false);
const { data: queue = EMPTY_QUEUE } = useQuery({
queryKey: [...OFFLINE_QUEUE_QUERY_KEY, userId, "summary"],
queryFn: () => summarizeQueue(userId),
refetchInterval: COUNT_INTERVAL_MS,
// Reads this device's storage, never the network.
networkMode: "always",
});
const sync = useMutation({
mutationFn: () => syncPendingEntries(userId),
networkMode: "always",
onSuccess: (result) => {
if (result.synced > 0) {
toast.success(entriesSynced(result.synced), { id: SYNC_TOAST_ID });
// New records on the server: every list and count may be stale now.
void queryClient.invalidateQueries();
return;
}
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
},
onError: () =>
toast.error("Synchronisierung fehlgeschlagen - wird beim nächsten Versuch erneut versucht", {
id: SYNC_TOAST_ID,
}),
});
const { mutate: startSync } = sync;
// 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.
useEffect(() => {
resumeSync();
}, [userId]);
// Mount, and every switch back to online (a request reached the server again).
useEffect(() => {
if (connectivity === "online") {
startSync();
}
}, [connectivity, startSync, userId]);
// The browser's own `online` event: try even while the last request failed.
useEffect(() => {
const handleOnline = () => startSync();
window.addEventListener("online", handleOnline);
return () => window.removeEventListener("online", handleOnline);
}, [startSync]);
// 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
// there is nothing left to show.
const hasRejected = queue.failed > 0;
if (sheetOpen && queue.own === 0 && !hasRejected) {
setSheetOpen(false);
}
const isOffline = connectivity === "offline";
const hasContent = isOffline || queue.own > 0 || hasRejected;
// Back to the trigger while it exists; otherwise to the band's message, or
// to the page content when the band shows nothing.
function sheetFinalFocus(): true | HTMLElement | null {
if (hasRejected) {
return true;
}
return hasContent ? statusRef.current : pageContentRef.current;
}
// The live region is always there, so its first message is announced too.
return (
<div
ref={statusRef}
role="status"
aria-live="polite"
tabIndex={-1}
className={cn(
"outline-none focus-visible:ring-3 focus-visible:ring-ring/50",
hasContent &&
"flex flex-wrap items-center gap-x-3 gap-y-2 border-b border-border/60 px-4 py-2 text-sm sm:px-6",
hasContent && (isOffline ? "bg-warning/15" : "bg-muted/60"),
)}
>
{isOffline ? (
<p className="flex items-center gap-2 font-medium">
<CloudOff className="size-4 shrink-0" aria-hidden />
{queue.own > 0
? `Offline – ${entriesWaiting(queue.own)}`
: "Offline – Eingaben werden gespeichert und später gesendet"}
</p>
) : null}
{!isOffline && queue.own > 0 ? (
<Button
type="button"
variant="outline"
size="touch"
className={BAND_BUTTON_CLASS}
disabled={sync.isPending}
onClick={() => startSync()}
>
<Send aria-hidden />
{sync.isPending ? "Wird gesendet…" : `${entriesWaiting(queue.own)} – Jetzt senden`}
</Button>
) : null}
<PendingQueueSheet
userId={userId}
foreignCount={queue.foreign}
open={sheetOpen}
onOpenChange={setSheetOpen}
finalFocus={sheetFinalFocus}
trigger={
hasRejected ? (
<SheetTrigger
render={
<Button
type="button"
variant="destructive"
size="touch"
className={BAND_BUTTON_CLASS}
/>
}
>
<AlertTriangle aria-hidden />
{`${entriesRejected(queue.failed)} – ansehen`}
</SheetTrigger>
) : null
}
/>
</div>
);
}