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",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"idb": "^8.0.3",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^1.33.0",
|
||||
"next": "16.3.1",
|
||||
@@ -7433,6 +7434,12 @@
|
||||
"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": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"@tanstack/react-query": "^5.101.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"idb": "^8.0.3",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^1.33.0",
|
||||
"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 { fetchColonies } from "@/lib/colonies/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 { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -60,8 +62,15 @@ export default function NewInspectionPage() {
|
||||
toast.success("Durchsicht gespeichert");
|
||||
router.push(`/colonies/${inspection.colonyId}`);
|
||||
},
|
||||
onError: () => {
|
||||
onError: (mutationError, variables) => {
|
||||
if (mutationError instanceof ApiError) {
|
||||
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 { useAuth } from "@/lib/auth/auth-context";
|
||||
import { NotificationBell } from "@/components/notification-bell";
|
||||
import { OfflineSyncBadge } from "@/components/offline-sync-badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Sidebar } from "./sidebar";
|
||||
import { BottomNav } from "./bottom-nav";
|
||||
@@ -44,6 +45,7 @@ export default function AppLayout({ children }: { children: ReactNode }) {
|
||||
ApiCore
|
||||
</Link>
|
||||
<div className="flex shrink-0 items-center gap-2 text-sm">
|
||||
<OfflineSyncBadge />
|
||||
<NotificationBell />
|
||||
<span className="hidden max-w-40 truncate text-muted-foreground md:inline">
|
||||
{user.email}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Space_Grotesk, Inter, IBM_Plex_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { Providers } from "./providers";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
|
||||
|
||||
const spaceGrotesk = Space_Grotesk({
|
||||
variable: "--font-heading",
|
||||
@@ -35,6 +36,7 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||
<body className="min-h-full flex flex-col">
|
||||
<Providers>{children}</Providers>
|
||||
<Toaster position="top-right" richColors />
|
||||
<ServiceWorkerRegistration />
|
||||
</body>
|
||||
</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