diff --git a/ApiCore-Frontend/package-lock.json b/ApiCore-Frontend/package-lock.json index 43f032d..1c4b4b4 100644 --- a/ApiCore-Frontend/package-lock.json +++ b/ApiCore-Frontend/package-lock.json @@ -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", diff --git a/ApiCore-Frontend/package.json b/ApiCore-Frontend/package.json index 27ecfb0..a6f4451 100644 --- a/ApiCore-Frontend/package.json +++ b/ApiCore-Frontend/package.json @@ -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", diff --git a/ApiCore-Frontend/public/sw.js b/ApiCore-Frontend/public/sw.js new file mode 100644 index 0000000..dc253e0 --- /dev/null +++ b/ApiCore-Frontend/public/sw.js @@ -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())), + ); +}); diff --git a/ApiCore-Frontend/src/app/(app)/inspections/new/page.tsx b/ApiCore-Frontend/src/app/(app)/inspections/new/page.tsx index cc0e54c..da3c8f2 100644 --- a/ApiCore-Frontend/src/app/(app)/inspections/new/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/inspections/new/page.tsx @@ -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: () => { - setError("Durchsicht konnte nicht gespeichert werden."); + 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}`); + }); }, }); diff --git a/ApiCore-Frontend/src/app/(app)/layout.tsx b/ApiCore-Frontend/src/app/(app)/layout.tsx index 8ff827e..5a018dc 100644 --- a/ApiCore-Frontend/src/app/(app)/layout.tsx +++ b/ApiCore-Frontend/src/app/(app)/layout.tsx @@ -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
+ {user.email} diff --git a/ApiCore-Frontend/src/app/layout.tsx b/ApiCore-Frontend/src/app/layout.tsx index e608fce..05cf5ae 100644 --- a/ApiCore-Frontend/src/app/layout.tsx +++ b/ApiCore-Frontend/src/app/layout.tsx @@ -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<"/">) { {children} + ); diff --git a/ApiCore-Frontend/src/components/offline-sync-badge.tsx b/ApiCore-Frontend/src/components/offline-sync-badge.tsx new file mode 100644 index 0000000..34e1df4 --- /dev/null +++ b/ApiCore-Frontend/src/components/offline-sync-badge.tsx @@ -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 ( + syncMutation.mutate()} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + syncMutation.mutate(); + } + }} + > + {syncMutation.isPending ? ( + + ) : ( + + )} + {pending} ausstehend + + ); +} diff --git a/ApiCore-Frontend/src/components/service-worker-registration.tsx b/ApiCore-Frontend/src/components/service-worker-registration.tsx new file mode 100644 index 0000000..a6a88d2 --- /dev/null +++ b/ApiCore-Frontend/src/components/service-worker-registration.tsx @@ -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; +} diff --git a/ApiCore-Frontend/src/lib/offline/db.ts b/ApiCore-Frontend/src/lib/offline/db.ts new file mode 100644 index 0000000..d65813c --- /dev/null +++ b/ApiCore-Frontend/src/lib/offline/db.ts @@ -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> | null = null; + +function getDb(): Promise> { + if (!dbPromise) { + dbPromise = openDB(DB_NAME, DB_VERSION, { + upgrade(db) { + db.createObjectStore(STORE_NAME, { keyPath: "id", autoIncrement: true }); + }, + }); + } + return dbPromise; +} + +export async function queueInspection(input: CreateInspectionInput): Promise { + const db = await getDb(); + await db.add(STORE_NAME, { input, queuedAt: new Date().toISOString() } as PendingInspection); +} + +export async function getPendingInspections(): Promise { + const db = await getDb(); + return db.getAll(STORE_NAME); +} + +export async function removePendingInspection(id: number): Promise { + const db = await getDb(); + await db.delete(STORE_NAME, id); +} + +export async function countPendingInspections(): Promise { + const db = await getDb(); + return db.count(STORE_NAME); +} diff --git a/ApiCore-Frontend/src/lib/offline/sync.spec.ts b/ApiCore-Frontend/src/lib/offline/sync.spec.ts new file mode 100644 index 0000000..90cf21d --- /dev/null +++ b/ApiCore-Frontend/src/lib/offline/sync.spec.ts @@ -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); + }); +}); diff --git a/ApiCore-Frontend/src/lib/offline/sync.ts b/ApiCore-Frontend/src/lib/offline/sync.ts new file mode 100644 index 0000000..517eafe --- /dev/null +++ b/ApiCore-Frontend/src/lib/offline/sync.ts @@ -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 | null = null; + +export function syncPendingInspections(): Promise { + if (!inFlightSync) { + inFlightSync = runSync().finally(() => { + inFlightSync = null; + }); + } + return inFlightSync; +} + +async function runSync(): Promise { + 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 }; +}