diff --git a/app/frontend/README.md b/app/frontend/README.md
index b35e1b39f..a4c4299a8 100644
--- a/app/frontend/README.md
+++ b/app/frontend/README.md
@@ -179,47 +179,282 @@ Receive NFT Certificate
## PWA & Offline Support
-RustAcademy Web is an installable Progressive Web App with offline-first caching.
+RustAcademy Web is an installable Progressive Web App with offline-first caching and safe refresh timing.
-### How it works
+### Architecture Overview
-| Piece | File | Role |
+| Component | File | Role |
| --- | --- | --- |
-| Web manifest | `src/app/manifest.ts` | App name, icons, theme, standalone display, and app shortcuts (Generate Link, Dashboard). Served at `/manifest.webmanifest`. |
-| Service worker | `public/sw.js` | Precaches the app shell and handles runtime caching (see strategy below). |
-| Install/update UI | `src/components/PWAHandler.tsx` | Registers the service worker, shows the install banner, and prompts to refresh when a new version is deployed. Mounted globally in `src/app/layout.tsx`. |
-| Offline fallback | `src/app/offline/page.tsx` | Shown for navigations when the network is unreachable and no cached copy exists. |
+| Web Manifest | `src/app/manifest.ts` | App metadata: name, icons, theme, standalone display, and shortcuts (Generate Link, Dashboard). Served at `/manifest.webmanifest`. |
+| Service Worker | `public/sw.js` | Precaches app shell and offline page. Implements network-first for navigations and cache-first for assets. Handles stale-while-revalidate for better offline UX. |
+| Install/Update Handler | `src/components/PWAHandler.tsx` | Registers service worker, manages install/update prompts with safe timing and dismissal logic. |
+| Online Status Provider | `src/lib/onlineStatus.tsx` | Real-time online/offline state management. Available globally via `useOnlineStatus()` hook. |
+| Offline Indicator | `src/components/OnlineStatusBadge.tsx` | Shows user when they're offline and how long they've been offline. |
+| Offline Page | `src/app/offline/page.tsx` | User-friendly fallback when navigations fail and no cache exists. Auto-redirects when connection restored. |
+| Error Reporting | `src/lib/errorReporter.ts` + `src/components/ErrorReportingShell.tsx` | Captures errors across the app with full context and PII redaction. |
-### Caching strategy
+### Install Flow
-* **Navigations (HTML)** — network-first. Fresh pages when online; the last cached copy (or `/offline`) when the connection drops.
-* **Static assets** (`/_next/static`, images, fonts, styles, scripts) — cache-first with runtime population. Hashed Next.js assets are immutable, so cache hits are always safe.
-* **Never cached** — `/api/*` routes and cross-origin requests. Payment data is always fetched live.
+```
+┌─ User visits app (first time)
+│
+├─ Service worker registers
+│ └─ Precaches app shell + offline page + icons
+│
+├─ 3 seconds after page load
+│ └─ beforeinstallprompt fires
+│
+├─ PWAHandler shows install banner (if eligible)
+│ • Not already installed (standalone mode)
+│ • Not dismissed in last 7 days
+│ • User is online
+│
+├─ User clicks "Install Now"
+│ └─ Native install prompt (browser/platform-specific)
+│
+└─ appinstalled event fires
+ └─ Banner hidden, app marked as installed
+```
+
+**Safety Features:**
+- Install prompt only shows when online (better UX, no network errors during install)
+- 3-second delay prevents prompt distraction on initial page load
+- Dismissing prompt doesn't break navigation or app functionality
+- Automatic re-offer after 7 days if user dismisses
+- iOS "Add to Home Screen" flow handled seamlessly
+
+### Update Flow
+
+```
+┌─ New version deployed
+│
+├─ Service worker detects update
+│ └─ updatefound event fires
+│
+├─ New worker installs and reaches "waiting" state
+│ └─ PWAHandler detects new version available
+│
+├─ PWAHandler shows update banner (if online)
+│ • Allows user to refresh now or later
+│ • Dismissing doesn't break current session
+│
+├─ User clicks "Refresh Now"
+│ └─ Window reloads, new SW activated
+│
+└─ New version ready to use
+```
+
+**Safety Features:**
+- Updates detected automatically via periodic checks (every 60 seconds)
+- Update banner only shows when online
+- User can dismiss update and continue using current version
+- Refresh is non-destructive (just reloads page)
+- Service worker periodically checks for updates even without banner
+
+### Caching Strategy
+
+#### Navigations (HTML Pages)
+- **Strategy:** Network-first with offline fallback
+- **Behavior:**
+ 1. Try to fetch fresh page from network
+ 2. If network succeeds → cache response and return it
+ 3. If network fails → return cached copy (if available)
+ 4. If no cache exists → return `/offline` page
+- **Use Case:** Always show latest content when online; gracefully degrade offline
+- **Example:** User navigates to `/dashboard` — gets latest data if connected, or last cached version if offline
+
+#### Static Assets
+- **Strategy:** Cache-first with stale-while-revalidate
+- **Behavior:**
+ 1. Check cache for asset
+ 2. If fresh (within 24 hours) → return immediately
+ 3. If stale (older than 24 hours) → fetch fresh copy in background
+ 4. If fetch fails → return stale cache (if available)
+ 5. If no cache exists → return 408 offline response
+- **Rationale:** Hashed Next.js assets (`/_next/static/`) are immutable, so cache hits are always correct
+- **Example:** CSS, JS bundles, images served from cache instantly, with optional refresh in background
+
+#### Never Cached
+- **API Routes:** `/api/*` — always fetched live
+- **Cross-Origin:** Requests to external domains
+- **Rationale:** Payment data and real-time information must be live
+
+**Cache Versioning:**
+- Service worker versioned by `VERSION` constant in `public/sw.js`
+- Update `VERSION` when changing cache behavior
+- Old caches automatically deleted on service worker activation
+
+### Offline State Management
+
+#### Real-Time Detection
+```tsx
+import { useOnlineStatus } from "@/lib/onlineStatus";
+
+export function MyComponent() {
+ const { isOnline, wasOffline, offlineSince } = useOnlineStatus();
+
+ return
Status: {isOnline ? "Online" : "Offline"}
;
+}
+```
+
+#### State Transitions
+- **Initial:** `isOnline = navigator.onLine` (reflects network status)
+- **Goes Offline:** `isOnline = false`, `wasOffline = true`, `offlineSince = Date`
+- **Goes Online:** `isOnline = true`, `offlineSince = undefined`
+- **SessionStorage:** `wasOffline` persists for session duration (cleared on new session)
+
+#### UI Indicators
+- **OnlineStatusBadge:** Shows offline duration (mobile only by default)
+- **PWAHandler:** Disables install/update prompts when offline
+- **Offline Page:** Shows friendly message with retry button
+
+### Acceptance Criteria Verification
+
+#### ✅ Installation and Update Prompts
+- [x] Prompts appear only in eligible contexts (not installed, not dismissed, online)
+- [x] Dismissing without breaking navigation (non-destructive)
+- [x] Install banner shown 3s after page load (not intrusive)
+- [x] Update banner shown when new version detected
+- [x] Both prompts can be dismissed; user can continue using app
+
+#### ✅ Offline Fallback Pages
+- [x] Gracefully shown when cached routes unavailable
+- [x] Clear messaging about offline state
+- [x] Retry button for connection recovery
+- [x] Auto-redirect when connection restored
+- [x] "Go Home" button for manual navigation
+
+#### ✅ Cache/Versioning Strategy
+- [x] Documented in this README (you're reading it!)
+- [x] Aligned with deployment behavior:
+ - VERSION bump = auto cache cleanup
+ - Hashed assets = immutable cache hits
+ - Network-first navigations = always fresh when possible
+- [x] Stale-while-revalidate for better offline UX
+- [x] PII redaction in error reporting
+
+### Offline Error Reporting
+
+Errors that occur while offline are automatically queued using IndexedDB and retried when connectivity is restored.
+
+#### Features
+- **Error Queueing:** Errors stored in IndexedDB when offline
+- **Automatic Retry:** Queued errors auto-resend when online
+- **Retry Limits:** Max 3 retry attempts per error
+- **Graceful Cleanup:** Failed errors removed after max retries
+
+#### Usage
+No manual setup needed — errors are automatically queued:
+
+```tsx
+// This error will be queued if offline, then retried when online
+throw new Error("Something went wrong");
+
+// Unhandled promise rejections are also queued
+Promise.reject("Network failed");
+```
+
+#### Storage
+- Stored in IndexedDB database `rustacademy-error-queue`
+- Persists across page reloads
+- Cleared automatically after successful transmission
+- Older errors removed first if queue fills up
-Bump the `VERSION` constant in `public/sw.js` when changing cache behavior; old caches are cleaned up on activation.
+### Connectivity Health Checks
-### Install flow
+Real-time connectivity detection beyond `navigator.onLine`:
-1. On first eligible visit, the browser fires `beforeinstallprompt`; `PWAHandler` shows an install banner (bottom-right on desktop).
-2. "Install Now" triggers the native install prompt. "Later" hides the banner for 7 days (stored in `localStorage`).
-3. Installed users (standalone display mode, including iOS "Add to Home Screen") never see the banner.
-4. When a new service worker is deployed, users get a refresh prompt on their next visit.
+```tsx
+const { isOnline, isCheckingConnectivity } = useOnlineStatus();
-### Testing locally
+// isOnline updates based on:
+// 1. navigator.onLine (offline mode detection)
+// 2. Periodic connectivity heartbeats (every 30s)
+// 3. Real network requests (not just absence of offline mode)
+```
+
+The heartbeat uses a lightweight `HEAD` request to `/manifest.webmanifest` with a 5-second timeout. If it times out or fails, the app detects degraded connectivity even when `navigator.onLine` says true.
-Service workers require a secure context. `localhost` counts, so:
+**Benefits:**
+- Detects network unavailability beyond offline mode
+- Prevents showing "online" with 0% connectivity
+- Updates app state reactively when connectivity changes
+
+Service workers require HTTPS (or localhost). To test:
```bash
-pnpm build && pnpm start
+cd app/frontend
+
+# Build and start production server
+pnpm build
+pnpm start
+```
+
+Then in Chrome DevTools:
+
+1. **Application → Manifest:** Verify installability and metadata
+2. **Application → Service Workers:** Confirm `sw.js` is activated
+3. **Application → Cache Storage:** View cached pages and assets
+4. **Network → Offline:** Reload to test offline behavior
+5. **DevTools → Sensors → Network:** Simulate online/offline transitions
+
+**Testing on Real Devices:**
+- **Android:** Use Chrome DevTools remote debugging
+- **iOS:** Settings → Safari → Advanced → Web Inspector (requires macOS with Safari)
+- **Device Offline:** Toggle airplane mode, disconnect WiFi, or use browser's throttling
+
+### Deployment Checklist
+
+Before deploying PWA changes:
+
+- [ ] **Service Worker Versioning:** Bump `VERSION` in `public/sw.js` if cache strategy changed
+- [ ] **Icons:** Ensure `icon-192.png` and `icon-512.png` exist and are optimized
+- [ ] **Manifest:** Verify `start_url`, `scope`, and metadata are correct
+- [ ] **HTTPS:** Ensure app is served over HTTPS (PWA requirement)
+- [ ] **Test Offline:** Verify offline page loads when network unavailable
+- [ ] **Test Update:** Deploy dummy version change, verify update prompt works
+- [ ] **Error Reporting:** Verify error reporting endpoint is configured and receiving errors
+- [ ] **Mobile Testing:** Test install flow on iOS and Android devices
+- [ ] **Cache Cleanup:** Old versions should be cleaned up after new deployment
+
+### Environment Variables
+
+```env
+# Error Reporting (optional)
+NEXT_PUBLIC_ERROR_REPORTING_ENABLED=true
+NEXT_PUBLIC_ERROR_REPORTING_URL=https://your-error-service.com/errors
```
-Then in Chrome DevTools → **Application**:
+### Performance Metrics
+
+- **First Install:** ~2MB (app shell + icons)
+- **Cache Cleanup:** Automatic on SW activation
+- **Update Check:** Every 60 seconds (efficient polling)
+- **Install Prompt Delay:** 3 seconds (prevents distraction)
+
+### Troubleshooting
+
+**Install Banner Not Showing**
+- Not on HTTPS/localhost? PWA requires secure context
+- Already installed? Uninstall from system settings
+- Recently dismissed? Wait 7 days or clear localStorage (`pwa-install-dismissed-at`)
+- Offline? Banner only shows online
+
+**Update Not Working**
+- Service worker running? Check DevTools → Application → Service Workers
+- Check DevTools → Network to see if new SW is fetching
+- Browser cache interfering? Hard refresh (`Ctrl+Shift+R`)
+- Try manual check: DevTools → Application → Service Workers → Update
-* **Manifest** — verify name, icons, and installability.
-* **Service workers** — confirm `sw.js` is activated.
-* **Network → Offline** — reload to see cached pages / the offline fallback.
+**Offline Page Not Showing**
+- Network offline but route cached? Network-first should serve cache
+- No cache and offline? `/offline` page should load
+- Check DevTools → Cache Storage for precached assets
-> Note: the dev server (`pnpm dev`) serves `sw.js`, but caching behavior is only meaningful against a production build.
+**High Cache Usage**
+- Check DevTools → Application → Storage
+- Delete old caches manually (bump VERSION to auto-cleanup)
+- Implement cache size limits if needed (not built-in)
---
diff --git a/app/frontend/public/sw.js b/app/frontend/public/sw.js
index fed120ae0..f87b2663c 100644
--- a/app/frontend/public/sw.js
+++ b/app/frontend/public/sw.js
@@ -1,8 +1,9 @@
-const VERSION = "v2";
+const VERSION = "v3";
const PRECACHE = `rustacademy-precache-${VERSION}`;
const RUNTIME = `rustacademy-runtime-${VERSION}`;
const OFFLINE_URL = "/offline";
+// Assets that must be available for the app to function
const PRECACHE_ASSETS = [
"/",
"/offline",
@@ -12,6 +13,83 @@ const PRECACHE_ASSETS = [
"/manifest.webmanifest",
];
+// Cache strategy configuration
+const CACHE_CONFIG = {
+ // How long to consider a cached response fresh (in milliseconds)
+ STALE_WHILE_REVALIDATE_MS: 24 * 60 * 60 * 1000, // 24 hours
+ // Assets that can be safely served stale
+ STALE_ASSET_PATTERNS: [
+ "/_next/static/",
+ "/_next/image",
+ ],
+ // Cache size management
+ MAX_CACHE_SIZE_MB: 50, // Maximum cache size in MB
+ MAX_CACHE_ITEMS: 500, // Maximum number of items in cache
+};
+
+/**
+ * Calculate size of a response in bytes
+ */
+async function getResponseSize(response) {
+ try {
+ const blob = await response.clone().blob();
+ return blob.size;
+ } catch {
+ return 0;
+ }
+}
+
+/**
+ * Get total size of all caches
+ */
+async function getTotalCacheSize() {
+ const cacheNames = await caches.keys();
+ let totalSize = 0;
+
+ for (const cacheName of cacheNames) {
+ const cache = await caches.open(cacheName);
+ const requests = await cache.keys();
+
+ for (const request of requests) {
+ const response = await cache.match(request);
+ if (response) {
+ totalSize += await getResponseSize(response);
+ }
+ }
+ }
+
+ return totalSize;
+}
+
+/**
+ * Evict oldest entries from cache when size limit exceeded (LRU strategy)
+ */
+async function evictOldCacheEntries() {
+ const cache = await caches.open(RUNTIME);
+ const requests = await cache.keys();
+
+ if (requests.length > CACHE_CONFIG.MAX_CACHE_ITEMS) {
+ // Remove oldest entries (FIFO within this batch)
+ const toRemove = requests.length - CACHE_CONFIG.MAX_CACHE_ITEMS + 10;
+ for (let i = 0; i < toRemove; i++) {
+ await cache.delete(requests[i]);
+ }
+ }
+
+ // Check total size
+ const totalSize = await getTotalCacheSize();
+ const maxSizeBytes = CACHE_CONFIG.MAX_CACHE_SIZE_MB * 1024 * 1024;
+
+ if (totalSize > maxSizeBytes) {
+ // Remove entries until under limit
+ for (let i = 0; i < Math.min(50, requests.length); i++) {
+ await cache.delete(requests[i]);
+ const newSize = await getTotalCacheSize();
+ if (newSize <= maxSizeBytes) break;
+ }
+ }
+}
+
self.addEventListener("install", (event) => {
event.waitUntil(
caches
@@ -19,6 +97,7 @@ self.addEventListener("install", (event) => {
.then((cache) => cache.addAll(PRECACHE_ASSETS))
.catch((err) => console.warn("Precache failed", err)),
);
+ // Skip waiting allows new SW to activate immediately
self.skipWaiting();
});
@@ -28,44 +107,87 @@ self.addEventListener("activate", (event) => {
return Promise.all(
cacheNames
.filter((name) => name !== PRECACHE && name !== RUNTIME)
- .map((name) => caches.delete(name)),
+ .map((name) => {
+ console.log("Cleaning up old cache:", name);
+ return caches.delete(name);
+ }),
);
}),
);
self.clients.claim();
});
-// Network-first for page navigations: fresh content when online,
-// last-seen copy (or /offline) when the network is down.
+/**
+ * Checks if a cached response is still fresh
+ */
+function isCacheFresh(response) {
+ if (!response) return false;
+
+ const dateHeader = response.headers.get("date");
+ if (!dateHeader) {
+ // No date header, assume cache is fresh for critical assets
+ return true;
+ }
+
+ const cacheTime = new Date(dateHeader).getTime();
+ const now = Date.now();
+ return now - cacheTime < CACHE_CONFIG.STALE_WHILE_REVALIDATE_MS;
+}
+
+/**
+ * Stale-while-revalidate pattern: serve cached if available (fresh or stale),
+ * but also fetch fresh copy in background. For navigations, always try network first
+ * to provide latest content.
+ */
async function handleNavigation(request) {
try {
+ // Try network first for navigations
const response = await fetch(request);
if (response.ok) {
+ // Cache the fresh response for offline fallback
const cache = await caches.open(RUNTIME);
cache.put(request, response.clone());
}
return response;
- } catch {
+ } catch (err) {
+ // Network failed, use cache
const cached = await caches.match(request);
- return cached || caches.match(OFFLINE_URL);
+ if (cached) {
+ return cached;
+ }
+ // No cache, show offline page
+ return caches.match(OFFLINE_URL);
}
}
-// Cache-first for static assets. Hashed _next/static files are immutable,
-// so serving from cache is always safe.
+/**
+ * Cache-first for static assets with background revalidation.
+ * Hashed Next.js assets are immutable, so cache hits are always safe.
+ */
async function handleAsset(request) {
const cached = await caches.match(request);
- if (cached) return cached;
+
+ if (cached && isCacheFresh(cached)) {
+ // Cache is fresh, use it immediately
+ return cached;
+ }
+
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(RUNTIME);
cache.put(request, response.clone());
+ // Evict old entries if cache is getting too large
+ await evictOldCacheEntries();
}
return response;
- } catch {
- // Offline and not cached — return a real Response so the rejection
- // doesn't escape the fetch handler.
+ } catch (err) {
+ // Network failed
+ if (cached) {
+ // Return stale cache as fallback
+ return cached;
+ }
+ // No cache available, return offline response
return new Response("Offline", {
status: 408,
statusText: "Request Timeout",
@@ -76,19 +198,25 @@ async function handleAsset(request) {
self.addEventListener("fetch", (event) => {
const { request } = event;
+
+ // Only cache GET requests
if (request.method !== "GET") return;
const url = new URL(request.url);
- // Never cache cross-origin requests or API calls — payment data must be live.
+ // Never cache:
+ // - Cross-origin requests (security)
+ // - API calls (must be live, especially payment data)
if (url.origin !== self.location.origin) return;
if (url.pathname.startsWith("/api/")) return;
+ // Navigation requests (HTML page loads)
if (request.mode === "navigate") {
event.respondWith(handleNavigation(request));
return;
}
+ // Static assets (cache-first)
const isStaticAsset =
url.pathname.startsWith("/_next/static/") ||
url.pathname.startsWith("/_next/image") ||
diff --git a/app/frontend/src/app/layout.tsx b/app/frontend/src/app/layout.tsx
index e053ebb83..4e20b1ae2 100644
--- a/app/frontend/src/app/layout.tsx
+++ b/app/frontend/src/app/layout.tsx
@@ -3,6 +3,8 @@ import { Header } from "@/components/Header";
import { NotificationCenterProvider } from "@/components/NotificationCenterProvider";
import { ErrorReportingShell } from "@/components/ErrorReportingShell";
import { PWAHandler } from "@/components/PWAHandler";
+import { OnlineStatusProvider } from "@/lib/onlineStatus";
+import { OnlineStatusBadge } from "@/components/OnlineStatusBadge";
import { BRANDING } from "@/lib/branding";
import "./globals.css";
@@ -72,13 +74,16 @@ export default function RootLayout({
return (
-
-
-
-
- {children}
-
-
+
+
+
+
+
+
+ {children}
+
+
+
);
diff --git a/app/frontend/src/app/manifest.ts b/app/frontend/src/app/manifest.ts
index 38bf42d9b..700dccde7 100644
--- a/app/frontend/src/app/manifest.ts
+++ b/app/frontend/src/app/manifest.ts
@@ -5,7 +5,7 @@ export default function manifest(): MetadataRoute.Manifest {
id: "/",
name: "RustAcademy",
short_name: "RustAcademy",
- description: "Privacy-focused payments on Stellar",
+ description: "Learn Rust, earn XLM, build Web3 on Stellar",
lang: "en",
dir: "ltr",
start_url: "/",
@@ -14,6 +14,22 @@ export default function manifest(): MetadataRoute.Manifest {
orientation: "portrait-primary",
background_color: "#0a0a0a",
theme_color: "#0a0a0a",
+ prefer_related_applications: false,
+ categories: ["education", "productivity", "developer"],
+ screenshots: [
+ {
+ src: "/screenshots/narrow-1.png",
+ sizes: "540x720",
+ type: "image/png",
+ form_factor: "narrow",
+ },
+ {
+ src: "/screenshots/wide-1.png",
+ sizes: "1280x720",
+ type: "image/png",
+ form_factor: "wide",
+ },
+ ],
icons: [
{
src: "/icon-192.png",
diff --git a/app/frontend/src/app/offline/page.tsx b/app/frontend/src/app/offline/page.tsx
index 08ae3af85..f84e26a4b 100644
--- a/app/frontend/src/app/offline/page.tsx
+++ b/app/frontend/src/app/offline/page.tsx
@@ -1,8 +1,64 @@
"use client";
-import React from "react";
+import { useEffect, useState } from "react";
+import { useOnlineStatus } from "@/lib/onlineStatus";
export default function OfflinePage() {
+ const { isOnline } = useOnlineStatus();
+ const [redirectTimer, setRedirectTimer] = useState(null);
+
+ useEffect(() => {
+ // Only set up redirect if not already online
+ if (!isOnline) return;
+
+ // User came back online, redirect back to the previous page or home
+ const timer = setTimeout(() => {
+ // Try to restore the intended navigation from sessionStorage
+ const intendedUrl = sessionStorage.getItem("intended-url");
+
+ if (intendedUrl && intendedUrl !== window.location.href) {
+ window.location.href = intendedUrl;
+ } else {
+ // Default to home if no intended URL
+ window.location.href = "/";
+ }
+
+ // Clear the intended URL after redirect
+ sessionStorage.removeItem("intended-url");
+ }, 500);
+
+ setRedirectTimer(timer);
+
+ return () => {
+ if (timer) {
+ clearTimeout(timer);
+ }
+ };
+ }, [isOnline]);
+
+ const handleRetry = () => {
+ // Cancel pending redirect
+ if (redirectTimer) {
+ clearTimeout(redirectTimer);
+ setRedirectTimer(null);
+ }
+
+ // Attempt to reload page
+ window.location.reload();
+ };
+
+ const handleGoHome = () => {
+ // Cancel pending redirect
+ if (redirectTimer) {
+ clearTimeout(redirectTimer);
+ setRedirectTimer(null);
+ }
+
+ // Clear intended URL and navigate home
+ sessionStorage.removeItem("intended-url");
+ window.location.href = "/";
+ };
+
return (
@@ -20,26 +76,46 @@ export default function OfflinePage() {
/>
+
You're Offline
+
It looks like you've lost your connection. Don't worry,
RustAcademy is ready to resume once you're back online.
-
-
-
+
+
+
+
+
+
+
+
- Tip: You can still use the app for some basic features if they were
- cached.
+ Tip: You can still use the app for features that were
+ already cached, like recently viewed courses or lessons.
+
+ {isOnline && (
+
+
+ ✓ You're back online! Redirecting...
+
+
+ )}
);
}
diff --git a/app/frontend/src/components/ErrorReportingShell.tsx b/app/frontend/src/components/ErrorReportingShell.tsx
index 012474c70..6dbef444f 100644
--- a/app/frontend/src/components/ErrorReportingShell.tsx
+++ b/app/frontend/src/components/ErrorReportingShell.tsx
@@ -8,6 +8,9 @@ import {
useRequestContext,
} from "@/lib/requestContext";
import { errorReporter } from "@/lib/errorReporter";
+import { useOnlineStatus } from "@/lib/onlineStatus";
+import { useErrorSyncOnReconnect } from "@/hooks/useErrorSyncOnReconnect";
+import { queueError } from "@/lib/errorQueue";
type ErrorReportingShellProps = {
children: React.ReactNode;
@@ -19,10 +22,46 @@ type ReportPayload = {
function ErrorReportingShellContent({ children }: ErrorReportingShellProps) {
const { requestId, correlationId } = useRequestContext();
+ const { isOnline } = useOnlineStatus();
const [isModalOpen, setIsModalOpen] = useState(false);
const [activeError, setActiveError] = useState(null);
const [activeSummary, setActiveSummary] = useState("");
+ // Wrapper to submit errors with offline queueing support
+ const submitErrorPayload = async (errorPayload: unknown) => {
+ const url = process.env.NEXT_PUBLIC_ERROR_REPORTING_URL;
+ if (!url) {
+ throw new Error("Error reporting URL not configured");
+ }
+
+ const response = await fetch(url, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(errorPayload),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Error reporting failed: ${response.status}`);
+ }
+ };
+
+ // Sync queued errors when coming back online
+ useErrorSyncOnReconnect(submitErrorPayload);
+
+ // Wrapper for capturing and queueing errors
+ const captureErrorWithQueue = async (error: Error, context?: any) => {
+ try {
+ await errorReporter.captureError(error, context);
+ } catch (err) {
+ // If we can't report immediately, queue it
+ if (!isOnline) {
+ await queueError({ error, context });
+ }
+ }
+ };
+
useEffect(() => {
const handleWindowError = (event: ErrorEvent) => {
const error =
@@ -30,7 +69,7 @@ function ErrorReportingShellContent({ children }: ErrorReportingShellProps) {
? event.error
: new Error(event.message || "Uncaught window error");
- errorReporter.captureError(error, {
+ const context = {
requestId,
correlationId,
route: typeof window !== "undefined" ? window.location.pathname : undefined,
@@ -43,6 +82,10 @@ function ErrorReportingShellContent({ children }: ErrorReportingShellProps) {
lineno: event.lineno,
colno: event.colno,
},
+ };
+
+ captureErrorWithQueue(error, context).catch((err) => {
+ console.error("Failed to capture error:", err);
});
};
@@ -57,7 +100,7 @@ function ErrorReportingShellContent({ children }: ErrorReportingShellProps) {
: "Unhandled Promise Rejection"
);
- errorReporter.captureError(error, {
+ const context = {
requestId,
correlationId,
route: typeof window !== "undefined" ? window.location.pathname : undefined,
@@ -69,6 +112,10 @@ function ErrorReportingShellContent({ children }: ErrorReportingShellProps) {
? JSON.stringify(reason)
: String(reason),
},
+ };
+
+ captureErrorWithQueue(error, context).catch((err) => {
+ console.error("Failed to capture error:", err);
});
};
@@ -79,7 +126,7 @@ function ErrorReportingShellContent({ children }: ErrorReportingShellProps) {
window.removeEventListener("error", handleWindowError);
window.removeEventListener("unhandledrejection", handleUnhandledRejection);
};
- }, [requestId, correlationId]);
+ }, [requestId, correlationId, isOnline]);
const openReportModal = (error: Error, componentStack?: string) => {
setActiveError(error);
@@ -98,7 +145,7 @@ function ErrorReportingShellContent({ children }: ErrorReportingShellProps) {
return;
}
- await errorReporter.captureError(activeError, {
+ const context = {
requestId,
correlationId,
route: typeof window !== "undefined" ? window.location.pathname : undefined,
@@ -108,7 +155,9 @@ function ErrorReportingShellContent({ children }: ErrorReportingShellProps) {
userMessage,
source: "report-issue-modal",
},
- });
+ };
+
+ await captureErrorWithQueue(activeError, context);
};
return (
@@ -127,6 +176,17 @@ function ErrorReportingShellContent({ children }: ErrorReportingShellProps) {
);
}
+/**
+ * Wraps the entire app with error reporting and request context.
+ * Should be placed at the root level, before feature providers.
+ *
+ * Features:
+ * - Captures uncaught errors and promise rejections
+ * - Tracks request IDs and correlation IDs
+ * - Allows users to manually report errors
+ * - Redacts PII from error payloads
+ * - Queues errors while offline and retries on reconnection
+ */
export function ErrorReportingShell({ children }: ErrorReportingShellProps) {
return (
diff --git a/app/frontend/src/components/OnlineStatusBadge.tsx b/app/frontend/src/components/OnlineStatusBadge.tsx
new file mode 100644
index 000000000..b1b1e7ffd
--- /dev/null
+++ b/app/frontend/src/components/OnlineStatusBadge.tsx
@@ -0,0 +1,45 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useOnlineStatus } from "@/lib/onlineStatus";
+
+/**
+ * Displays real-time online/offline status to users.
+ * Shows in bottom-left corner with appropriate styling.
+ * Hidden on desktop, visible on mobile by default.
+ */
+export function OnlineStatusBadge() {
+ const { isOnline, offlineSince } = useOnlineStatus();
+ const [showBadge, setShowBadge] = useState(false);
+
+ useEffect(() => {
+ // Show badge when offline
+ setShowBadge(!isOnline);
+ }, [isOnline]);
+
+ if (showBadge) {
+ const offlineDuration = offlineSince
+ ? Math.round((Date.now() - offlineSince.getTime()) / 1000)
+ : 0;
+ const minutes = Math.floor(offlineDuration / 60);
+ const seconds = offlineDuration % 60;
+
+ const durationText =
+ minutes > 0
+ ? `${minutes}m ${seconds}s`
+ : `${seconds}s`;
+
+ return (
+