feat: offline-fähige PWA mit IndexedDB-Sync für Durchsichten
Service Worker (public/sw.js) cached die App-Shell für Seiten-GETs (network-first, cache-fallback). Durchsichten, die offline gespeichert werden, landen in einer IndexedDB-Warteschlange (idb) statt zu scheitern und werden automatisch synchronisiert, sobald wieder Netzwerk verfügbar ist (Mount + online-Event), mit manuellem Retry über ein Badge im App-Header. Ein modulweiter In-Flight-Lock in sync.ts verhindert doppelte Synchronisierung bei gleichzeitigen Aufrufen (gefunden über React-StrictMode-Doppel-Mount, das ohne den Lock dieselbe Durchsicht zweimal anlegte).
This commit is contained in:
1 parent
20239885b4
commit
eb66c4bd53
11 files changed
+319
-2
No files matched your search
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { CloudOff, RefreshCw } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { countPendingInspections } from "@/lib/offline/db";
|
||||
import { syncPendingInspections } from "@/lib/offline/sync";
|
||||
|
||||
const PENDING_QUERY_KEY = ["offline-pending-inspections"];
|
||||
|
||||
export function OfflineSyncBadge() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: pending = 0 } = useQuery({
|
||||
queryKey: PENDING_QUERY_KEY,
|
||||
queryFn: countPendingInspections,
|
||||
refetchInterval: 15000,
|
||||
});
|
||||
|
||||
const syncMutation = useMutation({
|
||||
mutationFn: syncPendingInspections,
|
||||
onSuccess: (result) => {
|
||||
void queryClient.invalidateQueries({ queryKey: PENDING_QUERY_KEY });
|
||||
if (result.synced > 0) {
|
||||
toast.success(`${result.synced} Durchsicht(en) synchronisiert`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
function handleOnline() {
|
||||
syncMutation.mutate();
|
||||
}
|
||||
window.addEventListener("online", handleOnline);
|
||||
if (navigator.onLine) {
|
||||
syncMutation.mutate();
|
||||
}
|
||||
return () => window.removeEventListener("online", handleOnline);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
if (pending === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant="warning"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="cursor-pointer"
|
||||
title="Ausstehende Durchsichten synchronisieren"
|
||||
onClick={() => syncMutation.mutate()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
syncMutation.mutate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{syncMutation.isPending ? (
|
||||
<RefreshCw className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<CloudOff className="size-3.5" />
|
||||
)}
|
||||
{pending} ausstehend
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function ServiceWorkerRegistration() {
|
||||
useEffect(() => {
|
||||
if (!("serviceWorker" in navigator)) {
|
||||
return;
|
||||
}
|
||||
navigator.serviceWorker.register("/sw.js").catch(() => {
|
||||
// offline shell caching is a progressive enhancement; ignore registration failures
|
||||
});
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
Reference in new issue
Block a user