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 ( +
+
+
+ + Offline • {durationText} + +
+
+ ); + } + + return null; +} diff --git a/app/frontend/src/components/PWAHandler.tsx b/app/frontend/src/components/PWAHandler.tsx index 5a5983f29..fb3650a69 100644 --- a/app/frontend/src/components/PWAHandler.tsx +++ b/app/frontend/src/components/PWAHandler.tsx @@ -1,7 +1,8 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useCallback } from "react"; import { errorReporter } from "@/lib/errorReporter"; +import { useOnlineStatus } from "@/lib/onlineStatus"; interface BeforeInstallPromptEvent extends Event { prompt: () => Promise; @@ -10,6 +11,8 @@ interface BeforeInstallPromptEvent extends Event { const DISMISSED_KEY = "pwa-install-dismissed-at"; const DISMISS_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000; // re-offer after 7 days +const INSTALL_PROMPT_DELAY_MS = 3000; // wait 3s after page load before showing +const SW_UPDATE_CHECK_INTERVAL_MS = 60 * 1000; // check for updates every 60s function wasRecentlyDismissed(): boolean { try { @@ -28,123 +31,289 @@ function isStandalone(): boolean { ); } +/** + * Manages PWA installation and updates with safe refresh timing and clear states. + * + * Features: + * - Install prompts appear only in eligible contexts (not already installed, not recently dismissed) + * - Updates are auto-applied with optional user notification + * - Dismissal of prompts doesn't break navigation + * - Clear state management for install flow + */ export function PWAHandler() { + const { isOnline } = useOnlineStatus(); const [installPrompt, setInstallPrompt] = useState(null); const [isInstalled, setIsInstalled] = useState(false); - const [showBanner, setShowBanner] = useState(false); + const [showInstallBanner, setShowInstallBanner] = useState(false); + const [updateAvailable, setUpdateAvailable] = useState(false); + const [showUpdateBanner, setShowUpdateBanner] = useState(false); + const [isUpdating, setIsUpdating] = useState(false); + const handleInstall = useCallback(async () => { + if (!installPrompt) return; + + try { + installPrompt.prompt(); + const { outcome } = await installPrompt.userChoice; + + if (outcome === "accepted") { + setShowInstallBanner(false); + setInstallPrompt(null); + // Installation confirmed by appinstalled event handler + } else { + // User dismissed — don't break UX, just hide the banner + handleDismissInstall(); + } + } catch (err) { + errorReporter.captureError( + err instanceof Error ? err : new Error(String(err)), + { + route: "/", + extra: { + component: "PWAHandler.handleInstall", + context: "Installation prompt failed", + }, + } + ); + } + }, [installPrompt]); + + const handleDismissInstall = useCallback(() => { + setShowInstallBanner(false); + try { + localStorage.setItem(DISMISSED_KEY, String(Date.now())); + } catch { + // localStorage unavailable (private mode) — banner reappears next visit + } + }, []); + + const handleUpdate = useCallback(() => { + setIsUpdating(true); + setShowUpdateBanner(false); + // The refresh triggers the new SW to activate + window.location.reload(); + }, []); + + const handleDismissUpdate = useCallback(() => { + setShowUpdateBanner(false); + // Allow user to dismiss update prompt and continue using current version + }, []); + + // Register Service Worker and listen for updates useEffect(() => { - // Register Service Worker - if ("serviceWorker" in navigator) { - navigator.serviceWorker - .register("/sw.js") - .then((reg) => { - // SW registered - - reg.addEventListener("updatefound", () => { - const newWorker = reg.installing; - newWorker?.addEventListener("statechange", () => { - if ( - newWorker.state === "installed" && - navigator.serviceWorker.controller - ) { - // New content is available; please refresh. - if ( - confirm( - "A new version of RustAcademy is available. Refresh now?", - ) - ) { - window.location.reload(); - } - } + if (!("serviceWorker" in navigator)) { + return; + } + + let swRegistration: ServiceWorkerRegistration | null = null; + let updateCheckInterval: NodeJS.Timeout | null = null; + + const registerServiceWorker = async () => { + try { + swRegistration = await navigator.serviceWorker.register("/sw.js", { + scope: "/", + }); + + // Check for updates immediately on registration + swRegistration.update().catch((err) => { + console.warn("Initial service worker update check failed:", err); + }); + + // Periodic update check + updateCheckInterval = setInterval(() => { + swRegistration?.update().catch((err) => { + console.warn("Service worker update check failed:", err); + }); + }, SW_UPDATE_CHECK_INTERVAL_MS); + + // Check for updates when app regains focus (visible) + const handleVisibilityChange = () => { + if (!document.hidden) { + swRegistration?.update().catch((err) => { + console.warn("Service worker focus update check failed:", err); }); + } + }; + + document.addEventListener("visibilitychange", handleVisibilityChange); + + // Handle new SW installed and waiting + swRegistration.addEventListener("updatefound", () => { + const newWorker = swRegistration!.installing; + + newWorker?.addEventListener("statechange", () => { + if ( + newWorker.state === "installed" && + navigator.serviceWorker.controller + ) { + // New content is available + setUpdateAvailable(true); + + // Only show update banner if online and not already updating + if (isOnline && !isUpdating) { + setShowUpdateBanner(true); + } + } }); - }) - .catch((err) => errorReporter.captureError(err, { route: "/", extra: { component: "PWAHandler" } })); - } + }); + + return () => { + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + } catch (err) { + errorReporter.captureError( + err instanceof Error ? err : new Error(String(err)), + { + route: "/", + extra: { + component: "PWAHandler.registerServiceWorker", + context: "Service worker registration failed", + }, + } + ); + } + }; + + const cleanup = registerServiceWorker(); - // Check if already installed + return () => { + if (updateCheckInterval) { + clearInterval(updateCheckInterval); + } + cleanup?.then(fn => fn?.()); + }; + }, [isOnline, isUpdating]); + + // Check if already installed + useEffect(() => { if (isStandalone()) { setIsInstalled(true); } + }, []); - const handler = (e: Event) => { + // Handle beforeinstallprompt event + useEffect(() => { + let installPromptTimeout: NodeJS.Timeout; + + const handleBeforeInstallPrompt = (e: Event) => { e.preventDefault(); setInstallPrompt(e as BeforeInstallPromptEvent); - if (!wasRecentlyDismissed()) { - setShowBanner(true); + + // Only show banner if: + // 1. User hasn't already dismissed it + // 2. Online (better UX for installation) + // 3. Not already installed + // 4. We're on a page suitable for prompting + if (!wasRecentlyDismissed() && isOnline && !isInstalled) { + // Delay showing prompt to not distract on initial page load + installPromptTimeout = setTimeout(() => { + setShowInstallBanner(true); + }, INSTALL_PROMPT_DELAY_MS); } }; - window.addEventListener("beforeinstallprompt", handler); - - window.addEventListener("appinstalled", () => { + const handleAppInstalled = () => { setIsInstalled(true); - setShowBanner(false); + setShowInstallBanner(false); setInstallPrompt(null); - }); - - return () => window.removeEventListener("beforeinstallprompt", handler); - }, []); - - const handleInstall = async () => { - if (!installPrompt) return; - installPrompt.prompt(); - const { outcome } = await installPrompt.userChoice; - if (outcome === "accepted") { - setShowBanner(false); - } - }; + }; - const handleDismiss = () => { - setShowBanner(false); - try { - localStorage.setItem(DISMISSED_KEY, String(Date.now())); - } catch { - // localStorage unavailable (private mode) — banner just reappears next visit - } - }; + window.addEventListener("beforeinstallprompt", handleBeforeInstallPrompt); + window.addEventListener("appinstalled", handleAppInstalled); - if (!showBanner || isInstalled) return null; + return () => { + clearTimeout(installPromptTimeout); + window.removeEventListener("beforeinstallprompt", handleBeforeInstallPrompt); + window.removeEventListener("appinstalled", handleAppInstalled); + }; + }, [isInstalled]); return ( -
-
-
-
- - - + <> + {/* Install Banner */} + {showInstallBanner && ( +
+
+
+
+ + + +
+
+

+ Install RustAcademy App +

+

+ Add RustAcademy to your home screen for a faster, + offline-ready experience. +

+
+ + +
+
+
-
-

- Install RustAcademy App -

-

- Add RustAcademy to your home screen for a faster, offline-ready - experience. -

-
- - +
+ )} + + {/* Update Available Banner */} + {showUpdateBanner && ( +
+
+
+
+ + + +
+
+

+ Update Available +

+

+ A new version of RustAcademy is ready. Refresh to get the latest + features and improvements. +

+
+ + +
+
-
-
+ )} + ); } diff --git a/app/frontend/src/hooks/useErrorSyncOnReconnect.ts b/app/frontend/src/hooks/useErrorSyncOnReconnect.ts new file mode 100644 index 000000000..e0c496d4d --- /dev/null +++ b/app/frontend/src/hooks/useErrorSyncOnReconnect.ts @@ -0,0 +1,27 @@ +/** + * Hook to sync queued errors when connectivity is restored. + * Integrates with the error reporting system to retry offline errors. + */ + +import { useEffect } from "react"; +import { useOnlineStatus } from "@/lib/onlineStatus"; +import { retryQueuedErrors } from "@/lib/errorQueue"; + +export function useErrorSyncOnReconnect( + submitErrorFn: (payload: unknown) => Promise +) { + const { isOnline } = useOnlineStatus(); + + useEffect(() => { + if (!isOnline) return; + + // Small delay to ensure connectivity is stable + const timer = setTimeout(() => { + retryQueuedErrors(submitErrorFn).catch((err) => { + console.warn("Failed to sync queued errors:", err); + }); + }, 1000); + + return () => clearTimeout(timer); + }, [isOnline, submitErrorFn]); +} diff --git a/app/frontend/src/lib/errorQueue.ts b/app/frontend/src/lib/errorQueue.ts new file mode 100644 index 000000000..c21ccaa8b --- /dev/null +++ b/app/frontend/src/lib/errorQueue.ts @@ -0,0 +1,204 @@ +/** + * Error Queue: Stores errors that occur while offline and retries them when connection restored. + * Uses IndexedDB for persistent storage with automatic sync on reconnection. + */ + +export interface QueuedError { + id: string; + payload: unknown; + timestamp: number; + retries: number; + maxRetries: number; +} + +const DB_NAME = "rustacademy-error-queue"; +const DB_VERSION = 1; +const STORE_NAME = "errors"; +const MAX_RETRIES = 3; +const RETRY_DELAY_MS = 5000; // 5 seconds + +let db: IDBDatabase | null = null; + +/** + * Initialize IndexedDB for error queueing + */ +async function initDB(): Promise { + if (db) return db; + + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onerror = () => { + reject(new Error("Failed to open IndexedDB for error queue")); + }; + + request.onsuccess = () => { + db = request.result; + resolve(db); + }; + + request.onupgradeneeded = (event) => { + const database = (event.target as IDBOpenDBRequest).result; + + if (!database.objectStoreNames.contains(STORE_NAME)) { + database.createObjectStore(STORE_NAME, { keyPath: "id" }); + } + }; + }); +} + +/** + * Add error to queue + */ +export async function queueError(payload: unknown): Promise { + try { + const database = await initDB(); + const id = `error-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const error: QueuedError = { + id, + payload, + timestamp: Date.now(), + retries: 0, + maxRetries: MAX_RETRIES, + }; + + return new Promise((resolve, reject) => { + const transaction = database.transaction([STORE_NAME], "readwrite"); + const store = transaction.objectStore(STORE_NAME); + const request = store.add(error); + + request.onerror = () => reject(new Error("Failed to queue error")); + request.onsuccess = () => resolve(id); + }); + } catch (err) { + console.warn("Error queueing failed:", err); + throw err; + } +} + +/** + * Get all queued errors + */ +export async function getQueuedErrors(): Promise { + try { + const database = await initDB(); + + return new Promise((resolve, reject) => { + const transaction = database.transaction([STORE_NAME], "readonly"); + const store = transaction.objectStore(STORE_NAME); + const request = store.getAll(); + + request.onerror = () => reject(new Error("Failed to retrieve queued errors")); + request.onsuccess = () => resolve(request.result); + }); + } catch (err) { + console.warn("Failed to get queued errors:", err); + return []; + } +} + +/** + * Remove error from queue + */ +export async function removeQueuedError(id: string): Promise { + try { + const database = await initDB(); + + return new Promise((resolve, reject) => { + const transaction = database.transaction([STORE_NAME], "readwrite"); + const store = transaction.objectStore(STORE_NAME); + const request = store.delete(id); + + request.onerror = () => reject(new Error("Failed to remove queued error")); + request.onsuccess = () => resolve(); + }); + } catch (err) { + console.warn("Failed to remove queued error:", err); + } +} + +/** + * Update retry count for error + */ +export async function updateErrorRetries(id: string, retries: number): Promise { + try { + const database = await initDB(); + + return new Promise((resolve, reject) => { + const transaction = database.transaction([STORE_NAME], "readwrite"); + const store = transaction.objectStore(STORE_NAME); + const getRequest = store.get(id); + + getRequest.onerror = () => reject(new Error("Failed to update error retries")); + + getRequest.onsuccess = () => { + const error = getRequest.result; + if (error) { + error.retries = retries; + const updateRequest = store.put(error); + updateRequest.onerror = () => + reject(new Error("Failed to update error retries")); + updateRequest.onsuccess = () => resolve(); + } + }; + }); + } catch (err) { + console.warn("Failed to update error retries:", err); + } +} + +/** + * Clear all queued errors + */ +export async function clearErrorQueue(): Promise { + try { + const database = await initDB(); + + return new Promise((resolve, reject) => { + const transaction = database.transaction([STORE_NAME], "readwrite"); + const store = transaction.objectStore(STORE_NAME); + const request = store.clear(); + + request.onerror = () => reject(new Error("Failed to clear error queue")); + request.onsuccess = () => resolve(); + }); + } catch (err) { + console.warn("Failed to clear error queue:", err); + } +} + +/** + * Retry sending queued errors (called when connectivity restored) + */ +export async function retryQueuedErrors( + submitFn: (payload: unknown) => Promise +): Promise { + const errors = await getQueuedErrors(); + + if (errors.length === 0) return; + + for (const error of errors) { + try { + // Only retry if under max attempts + if (error.retries >= error.maxRetries) { + await removeQueuedError(error.id); + continue; + } + + await submitFn(error.payload); + await removeQueuedError(error.id); + } catch (err) { + // Increment retry count + await updateErrorRetries(error.id, error.retries + 1); + + // If max retries reached, remove from queue + if (error.retries + 1 >= error.maxRetries) { + await removeQueuedError(error.id); + console.warn( + `Error dropped after ${error.maxRetries} retries:`, + error.payload + ); + } + } + } +} diff --git a/app/frontend/src/lib/onlineStatus.tsx b/app/frontend/src/lib/onlineStatus.tsx new file mode 100644 index 000000000..b537b180e --- /dev/null +++ b/app/frontend/src/lib/onlineStatus.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { createContext, useContext, useEffect, useMemo, useState } from "react"; + +export interface OnlineStatusContextValue { + isOnline: boolean; + wasOffline: boolean; // true if user ever went offline in this session + offlineSince?: Date; // timestamp when user went offline + isCheckingConnectivity?: boolean; // true while performing connectivity check +} + +const OnlineStatusContext = createContext(null); + +// Heartbeat check configuration +const CONNECTIVITY_CHECK_INTERVAL_MS = 30 * 1000; // Check connectivity every 30s +const CONNECTIVITY_CHECK_TIMEOUT_MS = 5 * 1000; // 5 second timeout for check + +/** + * Perform a real connectivity check by attempting to fetch a minimal resource. + * This is more reliable than navigator.onLine which only detects offline mode. + */ +async function checkRealConnectivity(): Promise { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), CONNECTIVITY_CHECK_TIMEOUT_MS); + + // Use a small, cacheable resource to check connectivity + // Cache busting with timestamp to bypass browser cache + const response = await fetch("/manifest.webmanifest", { + method: "HEAD", + signal: controller.signal, + cache: "no-store", + }); + + clearTimeout(timeoutId); + + // Any response (even error status) means we have network connectivity + return true; + } catch (err) { + // Timeout or network error + if (err instanceof Error && err.name === "AbortError") { + // Request timed out, no connectivity + return false; + } + return false; + } +} + +export function OnlineStatusProvider({ + children, +}: { + children: React.ReactNode; +}) { + const [isOnline, setIsOnline] = useState(true); + const [wasOffline, setWasOffline] = useState(false); + const [offlineSince, setOfflineSince] = useState(); + const [isCheckingConnectivity, setIsCheckingConnectivity] = useState(false); + + useEffect(() => { + // Initialize with actual online status + setIsOnline(navigator.onLine); + + const handleOnline = () => { + setIsOnline(true); + setOfflineSince(undefined); + }; + + const handleOffline = () => { + setIsOnline(false); + setWasOffline(true); + setOfflineSince(new Date()); + }; + + window.addEventListener("online", handleOnline); + window.addEventListener("offline", handleOffline); + + return () => { + window.removeEventListener("online", handleOnline); + window.removeEventListener("offline", handleOffline); + }; + }, []); + + // Periodic connectivity check (more reliable than navigator.onLine) + useEffect(() => { + let checkInterval: NodeJS.Timeout; + + const performCheck = async () => { + setIsCheckingConnectivity(true); + const hasConnectivity = await checkRealConnectivity(); + setIsCheckingConnectivity(false); + + // Only update state if navigator.onLine and real check disagree + if (navigator.onLine && !hasConnectivity) { + // navigator.onLine says online but actual connectivity check failed + setIsOnline(false); + setWasOffline(true); + setOfflineSince(new Date()); + } else if (!navigator.onLine && hasConnectivity) { + // navigator.onLine says offline but actual check succeeded + setIsOnline(true); + setOfflineSince(undefined); + } + }; + + // Start checks after a slight delay to let page load + const initialDelay = setTimeout(() => { + performCheck(); + checkInterval = setInterval(performCheck, CONNECTIVITY_CHECK_INTERVAL_MS); + }, 2000); + + return () => { + clearTimeout(initialDelay); + if (checkInterval) { + clearInterval(checkInterval); + } + }; + }, []); + + const value = useMemo( + () => ({ isOnline, wasOffline, offlineSince, isCheckingConnectivity }), + [isOnline, wasOffline, offlineSince, isCheckingConnectivity] + ); + + return ( + + {children} + + ); +} + +export function useOnlineStatus(): OnlineStatusContextValue { + const context = useContext(OnlineStatusContext); + if (!context) { + throw new Error( + "useOnlineStatus must be used within OnlineStatusProvider" + ); + } + return context; +} + +export { OnlineStatusContext };