From ab1a24c5dc134bfab9a0de5d781788e308663eaf Mon Sep 17 00:00:00 2001 From: Feyisara2108 Date: Mon, 30 Mar 2026 03:31:39 +0100 Subject: [PATCH 1/3] #587 feat(frontend): add offline/network error handling with graceful degradation and auto-retry # Conflicts: # frontend/src/app/layout.tsx --- .../global_ui/NetworkStatusBanner.tsx | 76 +++++++++++++++++++ .../components/providers/QueryProvider.tsx | 24 ++++-- frontend/src/app/layout.tsx | 2 + 3 files changed, 96 insertions(+), 6 deletions(-) create mode 100644 frontend/src/app/components/global_ui/NetworkStatusBanner.tsx diff --git a/frontend/src/app/components/global_ui/NetworkStatusBanner.tsx b/frontend/src/app/components/global_ui/NetworkStatusBanner.tsx new file mode 100644 index 00000000..0b3ee443 --- /dev/null +++ b/frontend/src/app/components/global_ui/NetworkStatusBanner.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { WifiOff, RefreshCw, X } from "lucide-react"; +import { motion, AnimatePresence } from "framer-motion"; + +/** + * Global banner to notify users of network connectivity issues. + * Provides a clear recovery path and explains that cached data is being used. + */ +export function NetworkStatusBanner() { + const [isOffline, setIsOffline] = useState(false); + const [isVisible, setIsVisible] = useState(true); + + useEffect(() => { + const handleOnline = () => { + setIsOffline(false); + setIsVisible(true); + }; + const handleOffline = () => { + setIsOffline(true); + setIsVisible(true); + }; + + window.addEventListener("online", handleOnline); + window.addEventListener("offline", handleOffline); + + // Initial check on mount + if (typeof navigator !== "undefined") { + setIsOffline(!navigator.onLine); + } + + return () => { + window.removeEventListener("online", handleOnline); + window.removeEventListener("offline", handleOffline); + }; + }, []); + + if (!isOffline || !isVisible) return null; + + return ( + + +
+
+ + + You are currently offline. Displaying cached data. Some features may be limited. + +
+
+ + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/app/components/providers/QueryProvider.tsx b/frontend/src/app/components/providers/QueryProvider.tsx index 361aebd8..4d717518 100644 --- a/frontend/src/app/components/providers/QueryProvider.tsx +++ b/frontend/src/app/components/providers/QueryProvider.tsx @@ -27,12 +27,24 @@ export function QueryProvider({ children }: QueryProviderProps) { new QueryClient({ defaultOptions: { queries: { - // Data is considered fresh for 60 seconds — avoids unnecessary refetches - staleTime: 60 * 1000, - // Retry failed requests once before showing an error - retry: 1, - // Refetch when the browser window regains focus - refetchOnWindowFocus: true, + staleTime: 5 * 60 * 1000, // 5 minutes: UI stays responsive with cached data + gcTime: 30 * 60 * 1000, // 30 minutes: Keep data in memory even when inactive + retry: (failureCount, error: unknown) => { + const errorMessage = error instanceof Error ? error.message : String(error); + // Safe check for browser environment + const isOffline = typeof window !== 'undefined' && !navigator.onLine; + + // Always retry on network errors or when offline + if (isOffline || errorMessage.includes('Network') || errorMessage.includes('fetch')) { + return true; + } + + // Standard retry for other transient errors (up to 3 times) + return failureCount < 3; + }, + retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000), + refetchOnWindowFocus: true, // Re-validate data when user returns to tab + refetchOnReconnect: 'always', // Force retry when connection returns }, mutations: { // Retry failed mutations once diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 04663b50..3d1a9f67 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -8,6 +8,7 @@ import { Toaster } from "./components/ui/Toaster"; import { LevelUpModal } from "./components/gamification/LevelUpModal"; import { GlobalXPGain } from "./components/global_ui/GlobalXPGain"; import { ErrorBoundary } from "./components/global_ui/ErrorBoundary"; +import { NetworkStatusBanner } from "./components/global_ui/NetworkStatusBanner"; import { NextIntlClientProvider } from "next-intl"; import { THEME_STORAGE_KEY } from "./lib/theme"; @@ -46,6 +47,7 @@ export default async function RootLayout({ + From 28035a989c90912f2f6f328ebc438fa7f583bf31 Mon Sep 17 00:00:00 2001 From: Feyisara2108 Date: Mon, 30 Mar 2026 03:36:06 +0100 Subject: [PATCH 2/3] style: remove trailing whitespace from query provider --- frontend/src/app/components/providers/QueryProvider.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/components/providers/QueryProvider.tsx b/frontend/src/app/components/providers/QueryProvider.tsx index 4d717518..7991eb18 100644 --- a/frontend/src/app/components/providers/QueryProvider.tsx +++ b/frontend/src/app/components/providers/QueryProvider.tsx @@ -33,12 +33,12 @@ export function QueryProvider({ children }: QueryProviderProps) { const errorMessage = error instanceof Error ? error.message : String(error); // Safe check for browser environment const isOffline = typeof window !== 'undefined' && !navigator.onLine; - + // Always retry on network errors or when offline if (isOffline || errorMessage.includes('Network') || errorMessage.includes('fetch')) { - return true; + return true; } - + // Standard retry for other transient errors (up to 3 times) return failureCount < 3; }, From 9d7e4712a9366cfe170a512674025122f7a3c066 Mon Sep 17 00:00:00 2001 From: Feyisara2108 Date: Mon, 30 Mar 2026 05:46:19 +0100 Subject: [PATCH 3/3] test(backend): align event indexer test with bulk score updates --- backend/src/__tests__/eventIndexer.test.ts | 70 ++++++++++------------ 1 file changed, 32 insertions(+), 38 deletions(-) diff --git a/backend/src/__tests__/eventIndexer.test.ts b/backend/src/__tests__/eventIndexer.test.ts index 269bd317..cd280bd1 100644 --- a/backend/src/__tests__/eventIndexer.test.ts +++ b/backend/src/__tests__/eventIndexer.test.ts @@ -9,6 +9,7 @@ const mockGetScoreConfig = jest.fn(() => ({ repaymentDelta: 15, defaultPenalty: 50, })); +const mockUpdateUserScoresBulk = jest.fn<() => Promise>().mockResolvedValue(undefined); jest.unstable_mockModule("../db/connection.js", () => ({ query: mockQuery, @@ -32,6 +33,10 @@ jest.unstable_mockModule("../services/sorobanService.js", () => ({ sorobanService: { getScoreConfig: mockGetScoreConfig }, })); +jest.unstable_mockModule("../services/scoresService.js", () => ({ + updateUserScoresBulk: mockUpdateUserScoresBulk, +})); + jest.unstable_mockModule("../utils/logger.js", () => ({ default: { info: jest.fn(), @@ -126,7 +131,6 @@ describe("EventIndexer", () => { const borrowerRepaid = makeAddress(); const borrowerDefaulted = makeAddress(); const insertedLoanEvents: unknown[][] = []; - const scoreUpdates: unknown[][] = []; mockQuery.mockImplementation(async (sql: string, params: unknown[] = []) => { if (sql === "BEGIN" || sql === "COMMIT") { @@ -138,11 +142,6 @@ describe("EventIndexer", () => { return { rows: [{ event_id: params[0] }], rowCount: 1 }; } - if (sql.includes("INSERT INTO scores")) { - scoreUpdates.push(params); - return { rows: [], rowCount: 1 }; - } - return { rows: [], rowCount: 0 }; }); @@ -208,10 +207,13 @@ describe("EventIndexer", () => { expect(insertedLoanEvents[3]?.[2]).toBe(9); expect(insertedLoanEvents[3]?.[3]).toBe(borrowerDefaulted); - expect(scoreUpdates).toEqual([ - [borrowerRepaid, 515, 15], - [borrowerDefaulted, 450, -50], - ]); + expect(mockUpdateUserScoresBulk).toHaveBeenCalledTimes(1); + expect(mockUpdateUserScoresBulk).toHaveBeenCalledWith( + new Map([ + [borrowerRepaid, 15], + [borrowerDefaulted, -50], + ]), + ); expect(mockGetScoreConfig).toHaveBeenCalledTimes(2); expect(mockDispatch).toHaveBeenCalledTimes(4); expect(mockBroadcast).toHaveBeenCalledTimes(4); @@ -236,10 +238,6 @@ describe("EventIndexer", () => { }; } - if (sql.includes("INSERT INTO scores")) { - return { rows: [], rowCount: 1 }; - } - return { rows: [], rowCount: 0 }; }); @@ -269,6 +267,8 @@ describe("EventIndexer", () => { expect(mockBroadcast).toHaveBeenCalledTimes(1); expect(mockCreateNotification).toHaveBeenCalledTimes(1); expect(mockGetScoreConfig).toHaveBeenCalledTimes(1); + expect(mockUpdateUserScoresBulk).toHaveBeenCalledTimes(1); + expect(mockUpdateUserScoresBulk).toHaveBeenCalledWith(new Map([[borrower, 15]])); }); it("initializes missing indexer state and persists the last indexed ledger during polling", async () => { @@ -279,9 +279,8 @@ describe("EventIndexer", () => { return { rows: [], rowCount: 0 }; } - if (sql.includes("INSERT INTO indexer_state")) { - stateWrites.push(Number(params[0] ?? 0)); - return { rows: [], rowCount: 1 }; + if (sql.includes("INSERT INTO indexer_state") && params.length === 0) { + return { rows: [{ last_indexed_ledger: 0 }], rowCount: 1 }; } if (sql.includes("UPDATE indexer_state")) { @@ -289,37 +288,32 @@ describe("EventIndexer", () => { return { rows: [], rowCount: 1 }; } - if (sql === "BEGIN" || sql === "COMMIT") { - return { rows: [], rowCount: 0 }; - } - - if (sql.includes("INSERT INTO loan_events")) { - return { rows: [{ event_id: params[0] }], rowCount: 1 }; - } - return { rows: [], rowCount: 0 }; }); const indexer = new EventIndexer({ rpcUrl: "https://rpc.test", contractId: "CINDEXERTEST", + pollIntervalMs: 5, + batchSize: 50, }); - (indexer as { running: boolean }).running = true; - (indexer as { - rpc: { - getLatestLedger: () => Promise<{ sequence: number }>; - getEvents: () => Promise<{ events: unknown[] }>; - }; - }).rpc = { - getLatestLedger: jest.fn().mockResolvedValue({ sequence: 15 }), - getEvents: jest.fn().mockResolvedValue({ - events: [makeRawEvent({ id: "evt-poll", ledger: 15, type: "LoanRequested" })], - }), + const processChunk = jest + .spyOn(indexer as unknown as { processChunk: (start: number, end: number) => Promise }, "processChunk") + .mockResolvedValue({ + lastProcessedLedger: 25, + fetchedEvents: 0, + insertedEvents: 0, + }); + + (indexer as { rpc: { getLatestLedger: () => Promise<{ sequence: number }> } }).rpc = { + getLatestLedger: jest.fn().mockResolvedValue({ sequence: 25 }), }; - await (indexer as { pollOnce: () => Promise }).pollOnce(); + await indexer.start(); + indexer.stop(); - expect(stateWrites).toEqual([0, 15]); + expect(processChunk).toHaveBeenCalledWith(1, 25); + expect(stateWrites).toContain(25); }); });