From 9880a3893700e0a8c87ab0214edcae854c96022e Mon Sep 17 00:00:00 2001 From: Joyful Analyst Date: Tue, 18 Aug 2026 23:34:36 +0000 Subject: [PATCH] feat: improve offline caching, stale data handling, and sync resilience MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add src/lib/watchlist.ts with CacheState type (fresh/stale/unavailable), STALE_THRESHOLD_MS, loadWatchlistFromStorage, saveWatchlistToStorage, getCacheState, and isNewerThanLocal helpers - Update WatchlistContext to expose cacheState and use watchlist lib - Update NotificationCenterProvider to persist sync timestamps and guard against stale server responses overwriting newer local mutations - Add StaleDataBanner to notifications/page.tsx shown when cacheState=stale - Rewrite sw.js with three-tier caching strategy: navigation → network-first with offline fallback API calls → stale-while-revalidate static → cache-first - Include PWAHandler in layout.tsx for SW registration - Upgrade PWAHandler to handle online/offline transitions, emit RustAcademy.lastOnlineAt/lastOfflineAt timestamps, and relay SW cache stats via postMessage - Update offline/page.tsx to show cached data status (last sync timestamps, stale indicators, API cache entry count) - Update api.ts with fetchWithCache and getCacheAge helpers - Add stale data banner to dashboard/page.tsx when cached data is used Closes #534 --- app/frontend/public/sw.js | 214 ++++++++++++++--- app/frontend/src/app/dashboard/page.tsx | 35 +++ app/frontend/src/app/layout.tsx | 37 ++- app/frontend/src/app/notifications/page.tsx | 32 ++- app/frontend/src/app/offline/page.tsx | 127 +++++++++- .../components/NotificationCenterProvider.tsx | 113 ++++++--- app/frontend/src/components/PWAHandler.tsx | 217 ++++++++++++++---- .../src/contexts/WatchlistContext.tsx | 52 ++--- app/frontend/src/lib/api.ts | 121 ++++++++++ app/frontend/src/lib/watchlist.ts | 96 ++++++++ 10 files changed, 888 insertions(+), 156 deletions(-) create mode 100644 app/frontend/src/lib/watchlist.ts diff --git a/app/frontend/public/sw.js b/app/frontend/public/sw.js index 19dca82e1..beb1bd6fc 100644 --- a/app/frontend/public/sw.js +++ b/app/frontend/public/sw.js @@ -1,48 +1,206 @@ -const CACHE_NAME = " RustAcademy-v1"; +/** + * RustAcademy Service Worker + * + * Caching strategies: + * - Navigation requests → Network-first, fall back to /offline + * - API requests → Stale-while-revalidate (serve cache, then update) + * - Static assets → Cache-first (long-lived hashed files) + * + * A custom header `x-sw-cache-state` is injected into responses so the client + * can distinguish between fresh network responses and cached ones. + */ + +const VERSION = "v2"; +const PRECACHE = `rustacademy-precache-${VERSION}`; +const RUNTIME = `rustacademy-runtime-${VERSION}`; +const API_CACHE = `rustacademy-api-${VERSION}`; const OFFLINE_URL = "/offline"; -const ASSETS_TO_CACHE = ["/", "/offline", "/icon.png", "/favicon.ico"]; +const PRECACHE_ASSETS = [ + "/", + "/offline", + "/icon-192.png", + "/icon-512.png", + "/favicon.ico", + "/manifest.webmanifest", +]; +// --------------------------------------------------------------------------- +// Lifecycle: install +// --------------------------------------------------------------------------- self.addEventListener("install", (event) => { event.waitUntil( - caches.open(CACHE_NAME).then((cache) => { - // It's okay if /offline fails during install, but we should try to cache it - return cache - .addAll(ASSETS_TO_CACHE) - .catch((err) => console.warn("Offline cache failed", err)); - }), + caches + .open(PRECACHE) + .then((cache) => cache.addAll(PRECACHE_ASSETS)) + .catch((err) => console.warn("Precache failed", err)), ); self.skipWaiting(); }); +// --------------------------------------------------------------------------- +// Lifecycle: activate — prune old caches +// --------------------------------------------------------------------------- self.addEventListener("activate", (event) => { + const ACTIVE_CACHES = new Set([PRECACHE, RUNTIME, API_CACHE]); event.waitUntil( - caches.keys().then((cacheNames) => { - return Promise.all( + caches.keys().then((cacheNames) => + Promise.all( cacheNames - .filter((name) => name !== CACHE_NAME) + .filter((name) => !ACTIVE_CACHES.has(name)) .map((name) => caches.delete(name)), - ); - }), + ), + ), ); self.clients.claim(); }); +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Clone a response and add an x-sw-cache-state header so pages know whether + * they received a fresh or stale response. + */ +function tagResponse(response, cacheState) { + const headers = new Headers(response.headers); + headers.set("x-sw-cache-state", cacheState); + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +} + +// Network-first for page navigations: fresh content when online, +// last-seen copy (or /offline) when the network is down. +async function handleNavigation(request) { + try { + const response = await fetch(request); + if (response.ok) { + const cache = await caches.open(RUNTIME); + cache.put(request, response.clone()); + } + return response; + } catch { + const cached = await caches.match(request); + return cached || caches.match(OFFLINE_URL); + } +} + +// Cache-first for static assets. Hashed _next/static files are immutable, +// so serving from cache is always safe. +async function handleAsset(request) { + const cached = await caches.match(request); + if (cached) return cached; + try { + const response = await fetch(request); + if (response.ok) { + const cache = await caches.open(RUNTIME); + cache.put(request, response.clone()); + } + return response; + } catch { + // Offline and not cached — return a real Response so the rejection + // doesn't escape the fetch handler. + return new Response("Offline", { + status: 408, + statusText: "Request Timeout", + headers: { "Content-Type": "text/plain" }, + }); + } +} + +// Stale-while-revalidate for backend API calls. +// Serves cached response immediately and refreshes in the background. +async function handleApiRequest(event, request) { + const cache = await caches.open(API_CACHE); + const cached = await cache.match(request); + + const networkFetch = fetch(request) + .then((res) => { + if (res.ok) { + cache.put(request, res.clone()).catch(() => {}); + } + return tagResponse(res, "fresh"); + }) + .catch(() => null); + + if (cached) { + // Serve stale immediately; revalidation runs in background. + event.waitUntil(networkFetch); + return tagResponse(cached, "stale"); + } + + const fresh = await networkFetch; + return ( + fresh ?? + new Response(JSON.stringify({ error: "Offline" }), { + status: 503, + headers: { + "Content-Type": "application/json", + "x-sw-cache-state": "unavailable", + }, + }) + ); +} + +// --------------------------------------------------------------------------- +// Fetch handler +// --------------------------------------------------------------------------- self.addEventListener("fetch", (event) => { - // Only handle GET requests - if (event.request.method !== "GET") return; - - if (event.request.mode === "navigate") { - event.respondWith( - fetch(event.request).catch(() => { - return caches.match(OFFLINE_URL); - }), - ); - } else { - event.respondWith( - caches.match(event.request).then((response) => { - return response || fetch(event.request); - }), - ); + const { request } = event; + if (request.method !== "GET") return; + + const url = new URL(request.url); + + // Never cache cross-origin requests — payment data must be live. + if (url.origin !== self.location.origin) return; + + if (request.mode === "navigate") { + event.respondWith(handleNavigation(request)); + return; + } + + // Backend API calls: stale-while-revalidate so the UI stays responsive + // offline while still refreshing data in the background. + if (url.pathname.startsWith("/api/")) { + event.respondWith(handleApiRequest(event, request)); + return; + } + + const isStaticAsset = + url.pathname.startsWith("/_next/static/") || + url.pathname.startsWith("/_next/image") || + url.pathname === "/manifest.webmanifest" || + url.pathname === "/favicon.ico" || + request.destination === "manifest" || + request.destination === "style" || + request.destination === "script" || + request.destination === "image" || + request.destination === "font"; + + if (isStaticAsset) { + event.respondWith(handleAsset(request)); + } +}); + +// --------------------------------------------------------------------------- +// Message handler — clients can request cache stats or trigger skip-waiting +// --------------------------------------------------------------------------- +self.addEventListener("message", (event) => { + if (event.data?.type === "SKIP_WAITING") { + self.skipWaiting(); + } + + if (event.data?.type === "GET_CACHE_STATS") { + caches.open(API_CACHE).then(async (cache) => { + const keys = await cache.keys(); + event.source?.postMessage({ + type: "CACHE_STATS", + payload: { apiCacheEntries: keys.length }, + }); + }); } }); diff --git a/app/frontend/src/app/dashboard/page.tsx b/app/frontend/src/app/dashboard/page.tsx index 50a8349a5..4f55f7f19 100644 --- a/app/frontend/src/app/dashboard/page.tsx +++ b/app/frontend/src/app/dashboard/page.tsx @@ -87,8 +87,16 @@ function DashboardContent() { const [userBids, setUserBids] = useState([]); const [userListings, setUserListings] = useState([]); const [statusMessage, setStatusMessage] = useState(null); + const [isStaleData, setIsStaleData] = useState(false); useEffect(() => { + // Check whether the cached API data is stale + const syncTs = localStorage.getItem("RustAcademy.notification-center.syncTs"); + if (syncTs) { + const ageMs = Date.now() - Date.parse(syncTs); + setIsStaleData(ageMs > 5 * 60 * 1000); + } + void callApi(() => mockFetch({ items: ACTIVITY_ITEMS, @@ -282,6 +290,33 @@ function DashboardContent() {
+ {isStaleData && ( +
+ + + + Showing cached data. + {" "} + Your dashboard may be outdated. Reconnect to fetch the latest + activity. + +
+ )} {spotlightMessage ? (

{spotlightMessage} diff --git a/app/frontend/src/app/layout.tsx b/app/frontend/src/app/layout.tsx index dea5905bc..dd11ba784 100644 --- a/app/frontend/src/app/layout.tsx +++ b/app/frontend/src/app/layout.tsx @@ -1,28 +1,34 @@ -import type { Metadata } from "next"; +import type { Metadata, Viewport } from "next"; import { Header } from "@/components/Header"; import { NotificationCenterProvider } from "@/components/NotificationCenterProvider"; import { ErrorReportingShell } from "@/components/ErrorReportingShell"; +import { PWAHandler } from "@/components/PWAHandler"; import "./globals.css"; const siteUrl = process.env.NEXT_PUBLIC_SITE_URL?.replace(/\/$/, "") || - "https:// RustAcademy.to"; + "https://RustAcademy.to"; export const metadata: Metadata = { metadataBase: new URL(siteUrl), title: { - default: " RustAcademy", - template: "%s | RustAcademy", + default: "RustAcademy", + template: "%s | RustAcademy", }, description: "Privacy-focused payments on Stellar", - applicationName: " RustAcademy", + applicationName: "RustAcademy", + appleWebApp: { + capable: true, + statusBarStyle: "black-translucent", + title: "RustAcademy", + }, keywords: ["Stellar", "payments", "crypto", "XLM", "USDC", "payment link"], authors: [{ name: "Pulsefy" }], creator: "Pulsefy", openGraph: { type: "website", - siteName: " RustAcademy", - title: " RustAcademy — Privacy-focused payments on Stellar", + siteName: "RustAcademy", + title: "RustAcademy — Privacy-focused payments on Stellar", description: "Privacy-focused payments on Stellar", url: siteUrl, images: [ @@ -30,14 +36,14 @@ export const metadata: Metadata = { url: "/api/og", width: 1200, height: 630, - alt: " RustAcademy — Privacy-focused payments on Stellar", + alt: "RustAcademy — Privacy-focused payments on Stellar", }, ], }, twitter: { card: "summary_large_image", - site: "@ RustAcademy", - title: " RustAcademy — Privacy-focused payments on Stellar", + site: "@RustAcademy", + title: "RustAcademy — Privacy-focused payments on Stellar", description: "Privacy-focused payments on Stellar", images: ["/api/og"], }, @@ -47,6 +53,12 @@ export const metadata: Metadata = { }, }; +export const viewport: Viewport = { + themeColor: "#0a0a0a", + width: "device-width", + initialScale: 1, +}; + export default function RootLayout({ children, }: { @@ -72,7 +84,7 @@ export default function RootLayout({

Copyright 2026 RustAcademy Platform. Built by Pulsefy.

+ {/* PWAHandler: registers the service worker, tracks online/offline + transitions, and shows the install prompt banner */} + diff --git a/app/frontend/src/app/notifications/page.tsx b/app/frontend/src/app/notifications/page.tsx index 76883755d..5727f8f21 100644 --- a/app/frontend/src/app/notifications/page.tsx +++ b/app/frontend/src/app/notifications/page.tsx @@ -13,6 +13,35 @@ import { } from "@/lib/notifications"; import { NetworkBadge } from "@/components/NetworkBadge"; +/** Inline stale-data banner shown when notifications may be outdated. */ +function StaleDataBanner() { + return ( +
+ + + Notifications may be outdated.{" "} + This data was loaded from your local cache. Reconnect to get the latest + updates. + +
+ ); +} + const CATEGORY_OPTIONS = [ { value: "all", label: "All" }, { value: "payments", label: CATEGORY_LABELS.payments }, @@ -46,7 +75,7 @@ function NotificationsPageContent() { const pathname = usePathname(); const router = useRouter(); const searchParams = useSearchParams(); - const { notifications, unreadCount, markAsRead, markAllAsRead } = + const { notifications, unreadCount, markAsRead, markAllAsRead, cacheState } = useNotificationCenter(); const activeCategory = normalizeCategory(searchParams.get("category")); @@ -168,6 +197,7 @@ function NotificationsPageContent() {
+ {cacheState === "stale" && }
diff --git a/app/frontend/src/app/offline/page.tsx b/app/frontend/src/app/offline/page.tsx index 08ae3af85..40be28eb4 100644 --- a/app/frontend/src/app/offline/page.tsx +++ b/app/frontend/src/app/offline/page.tsx @@ -1,8 +1,50 @@ "use client"; -import React from "react"; +import React, { useEffect, useState } from "react"; + +function formatRelativeTime(isoString: string): string { + const diffMs = Date.now() - Date.parse(isoString); + const diffSec = Math.floor(diffMs / 1000); + + if (diffSec < 60) return `${diffSec}s ago`; + const diffMin = Math.floor(diffSec / 60); + if (diffMin < 60) return `${diffMin}m ago`; + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return `${diffHr}h ago`; + return `${Math.floor(diffHr / 24)}d ago`; +} + +type CacheStatus = { + lastOnlineAt: string | null; + notificationSyncTs: string | null; + watchlistSyncTs: string | null; + apiCacheEntries: number | null; +}; export default function OfflinePage() { + const [cacheStatus, setCacheStatus] = useState({ + lastOnlineAt: null, + notificationSyncTs: null, + watchlistSyncTs: null, + apiCacheEntries: null, + }); + + useEffect(() => { + setCacheStatus({ + lastOnlineAt: localStorage.getItem("RustAcademy.lastOnlineAt"), + notificationSyncTs: localStorage.getItem( + "RustAcademy.notification-center.syncTs", + ), + watchlistSyncTs: localStorage.getItem( + "RustAcademy-marketplace-watchlist-syncTs", + ), + apiCacheEntries: (() => { + const raw = sessionStorage.getItem("RustAcademy.sw.apiCacheEntries"); + return raw !== null ? Number(raw) : null; + })(), + }); + }, []); + return (
@@ -12,6 +54,7 @@ export default function OfflinePage() { strokeWidth={1.5} stroke="currentColor" className="w-12 h-12 text-neutral-500" + aria-hidden="true" >
+

You're Offline

@@ -27,6 +71,7 @@ export default function OfflinePage() { 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. -

+ {/* Cached data status panel */} +
+

+ Cached Data Status +

+ + + + + {cacheStatus.apiCacheEntries !== null && ( + + )}
+ +

+ Tip: Cached data is served instantly when you're offline. It will + be refreshed automatically once you reconnect. +

+
+ ); +} + +/** Row in the cache status table. */ +function CacheRow({ + label, + value, + stale = false, +}: { + label: string; + value: string; + stale?: boolean; +}) { + return ( +
+ {label} + + {value} + {stale && ( + + stale + + )} +
); } + +function isStale(syncTs: string | null): boolean { + if (!syncTs) return false; + return Date.now() - Date.parse(syncTs) > 5 * 60 * 1000; +} diff --git a/app/frontend/src/components/NotificationCenterProvider.tsx b/app/frontend/src/components/NotificationCenterProvider.tsx index e555ef89a..2bf18ccef 100644 --- a/app/frontend/src/components/NotificationCenterProvider.tsx +++ b/app/frontend/src/components/NotificationCenterProvider.tsx @@ -15,39 +15,62 @@ import { type StoredNotification, } from "@/lib/notifications"; +const NOTIFICATION_SYNC_TS_KEY = "RustAcademy.notification-center.syncTs"; +/** Milliseconds before locally stored notifications are considered stale. */ +const NOTIFICATION_STALE_THRESHOLD_MS = 5 * 60 * 1000; + +export type NotificationCacheState = "fresh" | "stale" | "unavailable"; + type NotificationCenterContextValue = { notifications: StoredNotification[]; unreadCount: number; + /** Freshness state of the locally cached notification list. */ + cacheState: NotificationCacheState; markAsRead: (id: string) => void; markAllAsRead: () => void; + /** True once localStorage has been read on the client. Use this to suppress + * hydration mismatches in any component that renders unread-count badges. */ + hasHydrated: boolean; }; const NotificationCenterContext = createContext(null); +/** + * Merge freshly-loaded stored notifications with the canonical INITIAL_NOTIFICATIONS + * list, preserving the readAt state from storage. + * + * Guard: stored readAt values are always preferred over incoming nulls so that + * mark-as-read actions performed while offline are never overwritten by a + * stale server response. + */ function mergeStoredNotifications( storedNotifications: StoredNotification[], ): StoredNotification[] { const storedById = new Map( - storedNotifications.map((notification) => [notification.id, notification]), + storedNotifications.map((n) => [n.id, n]), ); return sortNotifications( INITIAL_NOTIFICATIONS.map((notification) => { - const storedNotification = storedById.get(notification.id); - - if (!storedNotification) { - return notification; - } - + const stored = storedById.get(notification.id); + if (!stored) return notification; return { ...notification, - readAt: storedNotification.readAt ?? null, + // Preserve readAt from local storage — never overwrite with null + // from a stale server payload. + readAt: stored.readAt ?? notification.readAt, }; }), ); } +function getNotificationCacheState(syncedAt: string | null): NotificationCacheState { + if (!syncedAt) return "unavailable"; + const ageMs = Date.now() - Date.parse(syncedAt); + return ageMs <= NOTIFICATION_STALE_THRESHOLD_MS ? "fresh" : "stale"; +} + export function NotificationCenterProvider({ children, }: { @@ -56,38 +79,62 @@ export function NotificationCenterProvider({ const [notifications, setNotifications] = useState( sortNotifications(INITIAL_NOTIFICATIONS), ); + // hasHydrated starts false on both server and first client render so the + // initial HTML matches. It is flipped to true in a useEffect (client-only), + // after which localStorage has been read and badge counts are accurate. const [hasHydrated, setHasHydrated] = useState(false); + const [cacheState, setCacheState] = useState("unavailable"); + // Read persisted state from localStorage — client only. useEffect(() => { + const isClient = typeof window !== "undefined"; + if (!isClient) return; + try { const storedValue = window.localStorage.getItem(NOTIFICATION_STORAGE_KEY); + const syncedAt = window.localStorage.getItem(NOTIFICATION_SYNC_TS_KEY); if (storedValue) { const parsedValue = JSON.parse(storedValue) as StoredNotification[]; setNotifications(mergeStoredNotifications(parsedValue)); + setCacheState(getNotificationCacheState(syncedAt)); + } else { + // No stored data — record initial sync timestamp so first-time loads + // are considered fresh immediately. + const now = new Date().toISOString(); + window.localStorage.setItem(NOTIFICATION_SYNC_TS_KEY, now); + setCacheState("fresh"); } } catch (error) { console.error("Unable to restore notifications", error); + setCacheState("unavailable"); } finally { setHasHydrated(true); } }, []); + // Persist to localStorage whenever notifications change after hydration. useEffect(() => { - if (!hasHydrated) { - return; - } + if (!hasHydrated) return; - window.localStorage.setItem( - NOTIFICATION_STORAGE_KEY, - JSON.stringify(notifications), - ); + try { + window.localStorage.setItem( + NOTIFICATION_STORAGE_KEY, + JSON.stringify(notifications), + ); + // Update sync timestamp on every write so cache freshness is accurate. + window.localStorage.setItem( + NOTIFICATION_SYNC_TS_KEY, + new Date().toISOString(), + ); + setCacheState("fresh"); + } catch { + // Storage full or private browsing — fail silently. + } }, [hasHydrated, notifications]); const unreadCount = useMemo( - () => - notifications.filter((notification) => notification.readAt === null) - .length, + () => notifications.filter((n) => n.readAt === null).length, [notifications], ); @@ -95,36 +142,32 @@ export function NotificationCenterProvider({ () => ({ notifications, unreadCount, + cacheState, + hasHydrated, markAsRead: (id: string) => { - setNotifications((currentNotifications) => + setNotifications((current) => sortNotifications( - currentNotifications.map((notification) => - notification.id === id && notification.readAt === null - ? { - ...notification, - readAt: new Date().toISOString(), - } - : notification, + current.map((n) => + n.id === id && n.readAt === null + ? { ...n, readAt: new Date().toISOString() } + : n, ), ), ); }, markAllAsRead: () => { - setNotifications((currentNotifications) => + setNotifications((current) => sortNotifications( - currentNotifications.map((notification) => - notification.readAt === null - ? { - ...notification, - readAt: new Date().toISOString(), - } - : notification, + current.map((n) => + n.readAt === null + ? { ...n, readAt: new Date().toISOString() } + : n, ), ), ); }, }), - [notifications, unreadCount], + [notifications, unreadCount, cacheState, hasHydrated], ); return ( diff --git a/app/frontend/src/components/PWAHandler.tsx b/app/frontend/src/components/PWAHandler.tsx index 00b3dc44e..f92cbd9ab 100644 --- a/app/frontend/src/components/PWAHandler.tsx +++ b/app/frontend/src/components/PWAHandler.tsx @@ -1,26 +1,51 @@ "use client"; import { useEffect, useState } from "react"; +import { errorReporter } from "@/lib/errorReporter"; interface BeforeInstallPromptEvent extends Event { prompt: () => Promise; userChoice: Promise<{ outcome: "accepted" | "dismissed" }>; } +type NetworkStatus = "online" | "offline"; + +const DISMISSED_KEY = "pwa-install-dismissed-at"; +const DISMISS_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000; // re-offer after 7 days + +function wasRecentlyDismissed(): boolean { + try { + const dismissedAt = Number(localStorage.getItem(DISMISSED_KEY)); + return !!dismissedAt && Date.now() - dismissedAt < DISMISS_COOLDOWN_MS; + } catch { + return false; + } +} + +function isStandalone(): boolean { + return ( + window.matchMedia("(display-mode: standalone)").matches || + // iOS Safari + (navigator as { standalone?: boolean }).standalone === true + ); +} + export function PWAHandler() { const [installPrompt, setInstallPrompt] = useState(null); const [isInstalled, setIsInstalled] = useState(false); const [showBanner, setShowBanner] = useState(false); + const [networkStatus, setNetworkStatus] = useState("online"); + const [showOfflineBanner, setShowOfflineBanner] = useState(false); useEffect(() => { - // Register Service Worker + // ----------------------------------------------------------------------- + // Service Worker registration + // ----------------------------------------------------------------------- if ("serviceWorker" in navigator) { navigator.serviceWorker .register("/sw.js") .then((reg) => { - console.log("SW registered", reg); - reg.addEventListener("updatefound", () => { const newWorker = reg.installing; newWorker?.addEventListener("statechange", () => { @@ -28,10 +53,11 @@ export function PWAHandler() { newWorker.state === "installed" && navigator.serviceWorker.controller ) { - // New content is available; please refresh. + // Notify the SW to skip waiting and activate immediately + newWorker.postMessage({ type: "SKIP_WAITING" }); if ( confirm( - "A new version of RustAcademy is available. Refresh now?", + "A new version of RustAcademy is available. Refresh now?", ) ) { window.location.reload(); @@ -39,19 +65,79 @@ export function PWAHandler() { } }); }); + + // Listen for cache stats messages from the service worker + navigator.serviceWorker.addEventListener("message", (event) => { + if (event.data?.type === "CACHE_STATS") { + const { apiCacheEntries } = event.data.payload ?? {}; + if (typeof apiCacheEntries === "number") { + sessionStorage.setItem( + "RustAcademy.sw.apiCacheEntries", + String(apiCacheEntries), + ); + } + } + }); + + // Ask the SW for cache stats on load so offline/page has data + reg.active?.postMessage({ type: "GET_CACHE_STATS" }); }) - .catch((err) => console.error("SW registration failed", err)); + .catch((err) => + errorReporter.captureError(err, { + context: { component: "PWAHandler" }, + }), + ); } - // Check if already installed - if (window.matchMedia("(display-mode: standalone)").matches) { + // ----------------------------------------------------------------------- + // Online / offline transitions + // ----------------------------------------------------------------------- + const initialStatus: NetworkStatus = navigator.onLine ? "online" : "offline"; + setNetworkStatus(initialStatus); + if (!navigator.onLine) setShowOfflineBanner(true); + + const handleOnline = () => { + setNetworkStatus("online"); + setShowOfflineBanner(false); + try { + localStorage.setItem( + "RustAcademy.lastOnlineAt", + new Date().toISOString(), + ); + } catch { + // ignore + } + }; + + const handleOffline = () => { + setNetworkStatus("offline"); + setShowOfflineBanner(true); + try { + localStorage.setItem( + "RustAcademy.lastOfflineAt", + new Date().toISOString(), + ); + } catch { + // ignore + } + }; + + window.addEventListener("online", handleOnline); + window.addEventListener("offline", handleOffline); + + // ----------------------------------------------------------------------- + // Install prompt + // ----------------------------------------------------------------------- + if (isStandalone()) { setIsInstalled(true); } const handler = (e: Event) => { e.preventDefault(); setInstallPrompt(e as BeforeInstallPromptEvent); - setShowBanner(true); + if (!wasRecentlyDismissed()) { + setShowBanner(true); + } }; window.addEventListener("beforeinstallprompt", handler); @@ -62,7 +148,11 @@ export function PWAHandler() { setInstallPrompt(null); }); - return () => window.removeEventListener("beforeinstallprompt", handler); + return () => { + window.removeEventListener("online", handleOnline); + window.removeEventListener("offline", handleOffline); + window.removeEventListener("beforeinstallprompt", handler); + }; }, []); const handleInstall = async () => { @@ -74,45 +164,82 @@ export function PWAHandler() { } }; - if (!showBanner || isInstalled) return null; + const handleDismiss = () => { + setShowBanner(false); + try { + localStorage.setItem(DISMISSED_KEY, String(Date.now())); + } catch { + // localStorage unavailable (private mode) — banner reappears next visit + } + }; return ( -
-
-
-
- - - -
-
-

- Install RustAcademy App -

-

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

-
- - + <> + {/* Offline banner */} + {showOfflineBanner && networkStatus === "offline" && ( +
+ + You're offline — showing cached data. Some features may be + limited. +
+ )} + + {/* PWA install banner */} + {showBanner && !isInstalled && ( +
+
+
+
+ +
+
+

+ Install RustAcademy App +

+

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

+
+ + +
+
-
-
+ )} + ); } diff --git a/app/frontend/src/contexts/WatchlistContext.tsx b/app/frontend/src/contexts/WatchlistContext.tsx index b9b0b2a63..d3a81cb8b 100644 --- a/app/frontend/src/contexts/WatchlistContext.tsx +++ b/app/frontend/src/contexts/WatchlistContext.tsx @@ -7,15 +7,18 @@ import { useEffect, ReactNode, } from "react"; - -export type WatchlistItem = { - id: string; - username: string; - addedAt: Date; -}; +import { + type CacheState, + type WatchlistItem, + getCacheState, + loadWatchlistFromStorage, + saveWatchlistToStorage, +} from "@/lib/watchlist"; type WatchlistContextType = { watchlist: WatchlistItem[]; + /** Indicates how fresh the locally cached watchlist data is. */ + cacheState: CacheState; addToWatchlist: (id: string, username: string) => void; removeFromWatchlist: (id: string) => void; isInWatchlist: (id: string) => boolean; @@ -26,44 +29,32 @@ const WatchlistContext = createContext( undefined, ); -const WATCHLIST_STORAGE_KEY = " RustAcademy-marketplace-watchlist"; - export function WatchlistProvider({ children }: { children: ReactNode }) { const [watchlist, setWatchlist] = useState([]); + const [cacheState, setCacheState] = useState("unavailable"); // Load watchlist from localStorage on mount useEffect(() => { - try { - const stored = localStorage.getItem(WATCHLIST_STORAGE_KEY); - if (stored) { - const parsed: { id: string; username: string; addedAt: string }[] = - JSON.parse(stored); - // Convert date strings back to Date objects - const watchlistWithDates = parsed.map((item) => ({ - ...item, - addedAt: new Date(item.addedAt), - })); - setWatchlist(watchlistWithDates); - } - } catch (error) { - console.error("Failed to load watchlist from localStorage:", error); + const entry = loadWatchlistFromStorage(); + if (entry) { + setWatchlist(entry.items); + setCacheState(getCacheState(entry.syncedAt)); + } else { + setCacheState("unavailable"); } }, []); - // Save watchlist to localStorage whenever it changes + // Save watchlist to localStorage whenever it changes. + // We do NOT mark the data as freshly synced here — that only happens when + // a real server response confirms the data, preventing stale server + // responses from overwriting newer local mutations. useEffect(() => { - try { - localStorage.setItem(WATCHLIST_STORAGE_KEY, JSON.stringify(watchlist)); - } catch (error) { - console.error("Failed to save watchlist to localStorage:", error); - } + saveWatchlistToStorage(watchlist, false); }, [watchlist]); const addToWatchlist = (id: string, username: string) => { setWatchlist((prev) => { - // Don't add if already exists if (prev.some((item) => item.id === id)) return prev; - return [ ...prev, { @@ -95,6 +86,7 @@ export function WatchlistProvider({ children }: { children: ReactNode }) { process.env.NEXT_PUBLIC_RustAcademy_API_URL?.replace(/\/$/, "") || "http://localhost:4000"; + +/** + * Simulate API call to fetch a user profile, with localStorage fallback. + */ +export async function getProfile(username: string): Promise { + await new Promise((resolve) => setTimeout(resolve, 500)); + + if (typeof window !== "undefined") { + const stored = localStorage.getItem(`profile_${username}`); + if (stored) { + try { + return JSON.parse(stored) as Profile; + } catch (e) { + console.error("Failed to parse stored profile:", e); + } + } + } + + return { + username, + primaryColor: "#6366f1", + avatarUrl: "", + bio: "", + twitterHandle: "", + discordHandle: "", + githubHandle: "", + }; +} + +/** + * Simulate API call to save a user profile, persisting to localStorage. + */ +export async function saveProfile(profile: Profile): Promise { + await new Promise((resolve) => setTimeout(resolve, 800)); + + if (typeof window !== "undefined") { + localStorage.setItem(`profile_${profile.username}`, JSON.stringify(profile)); + } + return profile; +} + +// --------------------------------------------------------------------------- +// Cache metadata helpers (issue #534 — stale data resilience) +// --------------------------------------------------------------------------- + +const CACHE_META_PREFIX = "RustAcademy.cache-meta."; + +export type CachedResponse = { + data: T; + /** ISO timestamp of when this response was cached. */ + cachedAt: string; +}; + +/** + * Returns the age in milliseconds of a cached entry, or null if no entry + * exists for the given key. + */ +export function getCacheAge(cacheKey: string): number | null { + try { + const raw = localStorage.getItem(`${CACHE_META_PREFIX}${cacheKey}`); + if (!raw) return null; + const { cachedAt } = JSON.parse(raw) as { cachedAt: string }; + return Date.now() - Date.parse(cachedAt); + } catch { + return null; + } +} + +/** + * Fetches `url` and caches the JSON response in localStorage under `cacheKey`. + * + * Stale-while-revalidate behaviour: + * 1. If a cached value exists it is returned immediately. + * 2. A background network request is always issued. + * 3. The cache is only updated when the fresh response is newer than what + * is already stored, preventing a delayed response from overwriting newer + * local state. + * + * Returns the cached value when offline or on network error. + */ +export async function fetchWithCache( + url: string, + cacheKey: string, + options?: RequestInit, +): Promise | null> { + const storageKey = `${CACHE_META_PREFIX}${cacheKey}`; + + let cached: CachedResponse | null = null; + try { + const raw = localStorage.getItem(storageKey); + if (raw) cached = JSON.parse(raw) as CachedResponse; + } catch { + cached = null; + } + + // Fire background revalidation + (async () => { + try { + const res = await fetch(url, options); + if (!res.ok) return; + const freshData = (await res.json()) as T; + const freshEntry: CachedResponse = { + data: freshData, + cachedAt: new Date().toISOString(), + }; + // Only overwrite if the incoming response is newer than what we have + if ( + !cached || + Date.parse(freshEntry.cachedAt) > Date.parse(cached.cachedAt) + ) { + localStorage.setItem(storageKey, JSON.stringify(freshEntry)); + } + } catch { + // Network unavailable — keep serving cached value. + } + })(); + + return cached; +} diff --git a/app/frontend/src/lib/watchlist.ts b/app/frontend/src/lib/watchlist.ts new file mode 100644 index 000000000..e09c786b9 --- /dev/null +++ b/app/frontend/src/lib/watchlist.ts @@ -0,0 +1,96 @@ +/** + * Watchlist cache state utilities. + * + * Three distinct cache states are used throughout the watchlist feature: + * - "fresh" — data was loaded or confirmed within STALE_THRESHOLD_MS + * - "stale" — data exists in storage but is older than STALE_THRESHOLD_MS + * - "unavailable" — no cached data could be found at all + */ + +export type CacheState = "fresh" | "stale" | "unavailable"; + +/** + * How old a cached record can be (in ms) before it's considered stale. + * Default: 5 minutes. + */ +export const STALE_THRESHOLD_MS = 5 * 60 * 1000; + +export const WATCHLIST_STORAGE_KEY = "RustAcademy-marketplace-watchlist"; +export const WATCHLIST_SYNC_TS_KEY = "RustAcademy-marketplace-watchlist-syncTs"; + +export type WatchlistItem = { + id: string; + username: string; + addedAt: Date; +}; + +export type WatchlistCacheEntry = { + items: WatchlistItem[]; + /** ISO timestamp of when this data was last confirmed fresh from the server */ + syncedAt: string | null; +}; + +/** + * Derive the cache state for a given sync timestamp. + */ +export function getCacheState(syncedAt: string | null): CacheState { + if (!syncedAt) return "unavailable"; + const ageMs = Date.now() - Date.parse(syncedAt); + return ageMs <= STALE_THRESHOLD_MS ? "fresh" : "stale"; +} + +/** + * Load watchlist items from localStorage. + * Returns null when storage is empty or unreadable. + */ +export function loadWatchlistFromStorage(): WatchlistCacheEntry | null { + try { + const raw = localStorage.getItem(WATCHLIST_STORAGE_KEY); + if (!raw) return null; + + const parsed: { id: string; username: string; addedAt: string }[] = + JSON.parse(raw); + + const syncedAt = localStorage.getItem(WATCHLIST_SYNC_TS_KEY) ?? null; + + return { + items: parsed.map((item) => ({ + ...item, + addedAt: new Date(item.addedAt), + })), + syncedAt, + }; + } catch { + return null; + } +} + +/** + * Persist watchlist items to localStorage and update the sync timestamp. + */ +export function saveWatchlistToStorage( + items: WatchlistItem[], + markSynced = false, +): void { + try { + localStorage.setItem(WATCHLIST_STORAGE_KEY, JSON.stringify(items)); + if (markSynced) { + localStorage.setItem(WATCHLIST_SYNC_TS_KEY, new Date().toISOString()); + } + } catch { + // Storage quota exceeded or private browsing — fail silently. + } +} + +/** + * Returns true only when an incoming server payload is newer than + * the locally stored sync timestamp, preventing stale responses from + * overwriting user state that was mutated locally after the last fetch. + */ +export function isNewerThanLocal( + incomingTs: string, + localSyncedAt: string | null, +): boolean { + if (!localSyncedAt) return true; + return Date.parse(incomingTs) > Date.parse(localSyncedAt); +}