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
+318
-1
No files matched your search
Generated
+7
@@ -12,6 +12,7 @@
|
|||||||
"@tanstack/react-query": "^5.101.4",
|
"@tanstack/react-query": "^5.101.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"idb": "^8.0.3",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^1.33.0",
|
"lucide-react": "^1.33.0",
|
||||||
"next": "16.3.1",
|
"next": "16.3.1",
|
||||||
@@ -7433,6 +7434,12 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/idb": {
|
||||||
|
"version": "8.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz",
|
||||||
|
"integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/ignore": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"@tanstack/react-query": "^5.101.4",
|
"@tanstack/react-query": "^5.101.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"idb": "^8.0.3",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^1.33.0",
|
"lucide-react": "^1.33.0",
|
||||||
"next": "16.3.1",
|
"next": "16.3.1",
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
const CACHE_NAME = "apicore-shell-v1";
|
||||||
|
|
||||||
|
self.addEventListener("install", () => {
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("activate", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches
|
||||||
|
.keys()
|
||||||
|
.then((keys) =>
|
||||||
|
Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))),
|
||||||
|
)
|
||||||
|
.then(() => self.clients.claim()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("fetch", (event) => {
|
||||||
|
const { request } = event;
|
||||||
|
|
||||||
|
if (request.method !== "GET") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
if (url.origin !== self.location.origin || url.pathname.startsWith("/api")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.respondWith(
|
||||||
|
fetch(request)
|
||||||
|
.then((response) => {
|
||||||
|
const responseClone = response.clone();
|
||||||
|
caches.open(CACHE_NAME).then((cache) => cache.put(request, responseClone));
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
.catch(() => caches.match(request).then((cached) => cached || Response.error())),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -7,6 +7,8 @@ import { toast } from "sonner";
|
|||||||
import { ClipboardCheck } from "lucide-react";
|
import { ClipboardCheck } from "lucide-react";
|
||||||
import { fetchColonies } from "@/lib/colonies/api";
|
import { fetchColonies } from "@/lib/colonies/api";
|
||||||
import { createInspection } from "@/lib/inspections/api";
|
import { createInspection } from "@/lib/inspections/api";
|
||||||
|
import { ApiError } from "@/lib/auth/api-client";
|
||||||
|
import { queueInspection } from "@/lib/offline/db";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -60,8 +62,15 @@ export default function NewInspectionPage() {
|
|||||||
toast.success("Durchsicht gespeichert");
|
toast.success("Durchsicht gespeichert");
|
||||||
router.push(`/colonies/${inspection.colonyId}`);
|
router.push(`/colonies/${inspection.colonyId}`);
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: (mutationError, variables) => {
|
||||||
|
if (mutationError instanceof ApiError) {
|
||||||
setError("Durchsicht konnte nicht gespeichert werden.");
|
setError("Durchsicht konnte nicht gespeichert werden.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void queueInspection(variables).then(() => {
|
||||||
|
toast.info("Kein Netzwerk – Durchsicht wurde offline gespeichert und wird synchronisiert.");
|
||||||
|
router.push(`/colonies/${variables.colonyId}`);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useRouter } from "next/navigation";
|
|||||||
import { Hexagon } from "lucide-react";
|
import { Hexagon } from "lucide-react";
|
||||||
import { useAuth } from "@/lib/auth/auth-context";
|
import { useAuth } from "@/lib/auth/auth-context";
|
||||||
import { NotificationBell } from "@/components/notification-bell";
|
import { NotificationBell } from "@/components/notification-bell";
|
||||||
|
import { OfflineSyncBadge } from "@/components/offline-sync-badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Sidebar } from "./sidebar";
|
import { Sidebar } from "./sidebar";
|
||||||
import { BottomNav } from "./bottom-nav";
|
import { BottomNav } from "./bottom-nav";
|
||||||
@@ -44,6 +45,7 @@ export default function AppLayout({ children }: { children: ReactNode }) {
|
|||||||
ApiCore
|
ApiCore
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex shrink-0 items-center gap-2 text-sm">
|
<div className="flex shrink-0 items-center gap-2 text-sm">
|
||||||
|
<OfflineSyncBadge />
|
||||||
<NotificationBell />
|
<NotificationBell />
|
||||||
<span className="hidden max-w-40 truncate text-muted-foreground md:inline">
|
<span className="hidden max-w-40 truncate text-muted-foreground md:inline">
|
||||||
{user.email}
|
{user.email}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Space_Grotesk, Inter, IBM_Plex_Mono } from "next/font/google";
|
|||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { Providers } from "./providers";
|
import { Providers } from "./providers";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
|
||||||
|
|
||||||
const spaceGrotesk = Space_Grotesk({
|
const spaceGrotesk = Space_Grotesk({
|
||||||
variable: "--font-heading",
|
variable: "--font-heading",
|
||||||
@@ -35,6 +36,7 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
|
|||||||
<body className="min-h-full flex flex-col">
|
<body className="min-h-full flex flex-col">
|
||||||
<Providers>{children}</Providers>
|
<Providers>{children}</Providers>
|
||||||
<Toaster position="top-right" richColors />
|
<Toaster position="top-right" richColors />
|
||||||
|
<ServiceWorkerRegistration />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { openDB, DBSchema, IDBPDatabase } from "idb";
|
||||||
|
import { CreateInspectionInput } from "@/lib/inspections/types";
|
||||||
|
|
||||||
|
interface OfflineDB extends DBSchema {
|
||||||
|
"pending-inspections": {
|
||||||
|
key: number;
|
||||||
|
value: PendingInspection;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PendingInspection {
|
||||||
|
id: number;
|
||||||
|
input: CreateInspectionInput;
|
||||||
|
queuedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DB_NAME = "apicore-offline";
|
||||||
|
const DB_VERSION = 1;
|
||||||
|
const STORE_NAME = "pending-inspections";
|
||||||
|
|
||||||
|
let dbPromise: Promise<IDBPDatabase<OfflineDB>> | null = null;
|
||||||
|
|
||||||
|
function getDb(): Promise<IDBPDatabase<OfflineDB>> {
|
||||||
|
if (!dbPromise) {
|
||||||
|
dbPromise = openDB<OfflineDB>(DB_NAME, DB_VERSION, {
|
||||||
|
upgrade(db) {
|
||||||
|
db.createObjectStore(STORE_NAME, { keyPath: "id", autoIncrement: true });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return dbPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queueInspection(input: CreateInspectionInput): Promise<void> {
|
||||||
|
const db = await getDb();
|
||||||
|
await db.add(STORE_NAME, { input, queuedAt: new Date().toISOString() } as PendingInspection);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPendingInspections(): Promise<PendingInspection[]> {
|
||||||
|
const db = await getDb();
|
||||||
|
return db.getAll(STORE_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removePendingInspection(id: number): Promise<void> {
|
||||||
|
const db = await getDb();
|
||||||
|
await db.delete(STORE_NAME, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function countPendingInspections(): Promise<number> {
|
||||||
|
const db = await getDb();
|
||||||
|
return db.count(STORE_NAME);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||||
|
import { ApiError } from "@/lib/auth/api-client";
|
||||||
|
import { createInspection } from "@/lib/inspections/api";
|
||||||
|
import { getPendingInspections, removePendingInspection } from "./db";
|
||||||
|
import { syncPendingInspections } from "./sync";
|
||||||
|
|
||||||
|
vi.mock("@/lib/inspections/api", () => ({
|
||||||
|
createInspection: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./db", () => ({
|
||||||
|
getPendingInspections: vi.fn(),
|
||||||
|
removePendingInspection: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockedCreateInspection = vi.mocked(createInspection);
|
||||||
|
const mockedGetPending = vi.mocked(getPendingInspections);
|
||||||
|
const mockedRemovePending = vi.mocked(removePendingInspection);
|
||||||
|
|
||||||
|
function pendingEntry(id: number) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
input: { colonyId: `colony-${id}`, date: "2026-01-01T00:00:00.000Z" },
|
||||||
|
queuedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("syncPendingInspections", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("syncs every queued entry and removes it from the queue", async () => {
|
||||||
|
mockedGetPending.mockResolvedValue([pendingEntry(1), pendingEntry(2)]);
|
||||||
|
mockedCreateInspection.mockResolvedValue({ id: "x" } as never);
|
||||||
|
|
||||||
|
const result = await syncPendingInspections();
|
||||||
|
|
||||||
|
expect(result).toEqual({ synced: 2, failed: 0 });
|
||||||
|
expect(mockedRemovePending).toHaveBeenCalledWith(1);
|
||||||
|
expect(mockedRemovePending).toHaveBeenCalledWith(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a rejected entry queued and continues with the rest on a server-side ApiError", async () => {
|
||||||
|
mockedGetPending.mockResolvedValue([pendingEntry(1), pendingEntry(2)]);
|
||||||
|
mockedCreateInspection
|
||||||
|
.mockRejectedValueOnce(new ApiError(400, "invalid colony"))
|
||||||
|
.mockResolvedValueOnce({ id: "x" } as never);
|
||||||
|
|
||||||
|
const result = await syncPendingInspections();
|
||||||
|
|
||||||
|
expect(result).toEqual({ synced: 1, failed: 1 });
|
||||||
|
expect(mockedRemovePending).not.toHaveBeenCalledWith(1);
|
||||||
|
expect(mockedRemovePending).toHaveBeenCalledWith(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops immediately on a network failure, leaving the rest queued", async () => {
|
||||||
|
mockedGetPending.mockResolvedValue([pendingEntry(1), pendingEntry(2)]);
|
||||||
|
mockedCreateInspection.mockRejectedValueOnce(new TypeError("Failed to fetch"));
|
||||||
|
|
||||||
|
const result = await syncPendingInspections();
|
||||||
|
|
||||||
|
expect(result).toEqual({ synced: 0, failed: 1 });
|
||||||
|
expect(mockedCreateInspection).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockedRemovePending).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shares a single in-flight run across concurrent callers instead of double-processing the queue", async () => {
|
||||||
|
mockedGetPending.mockResolvedValue([pendingEntry(1)]);
|
||||||
|
mockedCreateInspection.mockResolvedValue({ id: "x" } as never);
|
||||||
|
|
||||||
|
const [first, second] = await Promise.all([syncPendingInspections(), syncPendingInspections()]);
|
||||||
|
|
||||||
|
expect(first).toBe(second);
|
||||||
|
expect(mockedGetPending).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockedCreateInspection).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { ApiError } from "@/lib/auth/api-client";
|
||||||
|
import { createInspection } from "@/lib/inspections/api";
|
||||||
|
import { getPendingInspections, removePendingInspection } from "./db";
|
||||||
|
|
||||||
|
export interface SyncResult {
|
||||||
|
synced: number;
|
||||||
|
failed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let inFlightSync: Promise<SyncResult> | null = null;
|
||||||
|
|
||||||
|
export function syncPendingInspections(): Promise<SyncResult> {
|
||||||
|
if (!inFlightSync) {
|
||||||
|
inFlightSync = runSync().finally(() => {
|
||||||
|
inFlightSync = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return inFlightSync;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSync(): Promise<SyncResult> {
|
||||||
|
const pending = await getPendingInspections();
|
||||||
|
let synced = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
for (const entry of pending) {
|
||||||
|
try {
|
||||||
|
await createInspection(entry.input);
|
||||||
|
await removePendingInspection(entry.id);
|
||||||
|
synced += 1;
|
||||||
|
} catch (error) {
|
||||||
|
failed += 1;
|
||||||
|
if (!(error instanceof ApiError)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { synced, failed };
|
||||||
|
}
|
||||||
Reference in new issue
Block a user