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:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user