diff --git a/frontend/public/sw.js b/frontend/public/sw.js new file mode 100644 index 0000000..8cbb4a2 --- /dev/null +++ b/frontend/public/sw.js @@ -0,0 +1,118 @@ +// ArenaX Service Worker +// Strategies: +// - Static/JS/CSS assets → Cache-First (immutable) +// - API GET requests → Network-First, fallback to cache (5-min TTL) +// - Everything else → Network-First, fallback to /offline + +const CACHE_VERSION = "v1"; +const STATIC_CACHE = `arenax-static-${CACHE_VERSION}`; +const API_CACHE = `arenax-api-${CACHE_VERSION}`; +const OFFLINE_URL = "/offline"; + +const PRECACHE_ASSETS = ["/", OFFLINE_URL, "/manifest.json"]; + +// ─── Install ──────────────────────────────────────────────────────────────── +self.addEventListener("install", (event) => { + event.waitUntil( + caches + .open(STATIC_CACHE) + .then((cache) => cache.addAll(PRECACHE_ASSETS)) + .then(() => self.skipWaiting()) + ); +}); + +// ─── Activate ─────────────────────────────────────────────────────────────── +self.addEventListener("activate", (event) => { + const keep = [STATIC_CACHE, API_CACHE]; + event.waitUntil( + caches + .keys() + .then((keys) => + Promise.all(keys.filter((k) => !keep.includes(k)).map((k) => caches.delete(k))) + ) + .then(() => self.clients.claim()) + ); +}); + +// ─── Fetch ────────────────────────────────────────────────────────────────── +self.addEventListener("fetch", (event) => { + const { request } = event; + const url = new URL(request.url); + + // Skip non-GET and cross-origin + if (request.method !== "GET" || url.origin !== self.location.origin) return; + + // Static assets → Cache-First + if ( + url.pathname.startsWith("/_next/static/") || + url.pathname.startsWith("/icons/") || + url.pathname.match(/\.(js|css|woff2?|png|svg|ico)$/) + ) { + event.respondWith(cacheFirst(request, STATIC_CACHE)); + return; + } + + // API GET → Network-First, fallback to stale cache + if (url.pathname.startsWith("/api/")) { + event.respondWith(networkFirstWithCache(request, API_CACHE, 5 * 60)); + return; + } + + // Navigation → Network-First, fallback to offline page + if (request.mode === "navigate") { + event.respondWith(navigationHandler(request)); + return; + } +}); + +// ─── Strategies ───────────────────────────────────────────────────────────── +async function cacheFirst(request, cacheName) { + const cached = await caches.match(request); + if (cached) return cached; + const response = await fetch(request); + if (response.ok) { + const cache = await caches.open(cacheName); + cache.put(request, response.clone()); + } + return response; +} + +async function networkFirstWithCache(request, cacheName, maxAgeSecs) { + const cache = await caches.open(cacheName); + try { + const response = await fetch(request); + if (response.ok) { + // Stamp with fetch time for TTL enforcement + const headers = new Headers(response.headers); + headers.set("x-sw-fetched-at", String(Date.now())); + const stamped = new Response(await response.clone().arrayBuffer(), { + status: response.status, + statusText: response.statusText, + headers, + }); + cache.put(request, stamped); + } + return response; + } catch { + const cached = await cache.match(request); + if (cached) { + const fetchedAt = Number(cached.headers.get("x-sw-fetched-at") ?? 0); + if (Date.now() - fetchedAt < maxAgeSecs * 1000) return cached; + } + return new Response(JSON.stringify({ error: "offline", code: 503 }), { + status: 503, + headers: { "Content-Type": "application/json" }, + }); + } +} + +async function navigationHandler(request) { + try { + const response = await fetch(request); + return response; + } catch { + const cached = await caches.match(request); + if (cached) return cached; + return caches.match(OFFLINE_URL); + } +} diff --git a/frontend/src/__tests__/offline-sync.test.ts b/frontend/src/__tests__/offline-sync.test.ts new file mode 100644 index 0000000..a01b918 --- /dev/null +++ b/frontend/src/__tests__/offline-sync.test.ts @@ -0,0 +1,177 @@ +/** + * Tests for offline-first architecture: + * - SyncQueue (enqueue, LWW dedup, flush, ordering) + * - useNetworkStatus hook + * - AnalyticsQueue + */ + +// ─── Mock IndexedDB via offlineStorage ────────────────────────────────────── +const store: Record = {}; + +jest.mock("@/lib/offlineStorage", () => ({ + idbGet: jest.fn(async (key: string) => store[key]), + idbSet: jest.fn(async (key: string, value: unknown) => { + store[key] = value; + }), + idbDelete: jest.fn(async (key: string) => { + delete store[key]; + }), + idbClear: jest.fn(async () => { + Object.keys(store).forEach((k) => delete store[k]); + }), +})); + +// ─── Imports ──────────────────────────────────────────────────────────────── +import { + enqueueSync, + flushSyncQueue, + getSyncQueueLength, +} from "@/lib/syncQueue"; +import { enqueueAnalytics, flushAnalyticsQueue } from "@/lib/analyticsQueue"; +import { renderHook, act } from "@testing-library/react"; +import { useNetworkStatus } from "@/hooks/useNetworkStatus"; + +// ─── Helpers ──────────────────────────────────────────────────────────────── +beforeEach(() => { + // Reset in-memory store between tests + Object.keys(store).forEach((k) => delete store[k]); + jest.restoreAllMocks(); + // Restore navigator.onLine + Object.defineProperty(navigator, "onLine", { value: true, configurable: true }); +}); + +// ── SyncQueue ──────────────────────────────────────────────────────────────── +describe("syncQueue", () => { + it("enqueues an item and reports pending count", async () => { + await enqueueSync({ url: "/api/matches/1/report", method: "POST", body: '{"score":10}' }); + expect(await getSyncQueueLength()).toBe(1); + }); + + it("applies Last-Write-Wins for same url+method", async () => { + await enqueueSync({ url: "/api/matches/1/report", method: "POST", body: '{"score":10}' }); + await enqueueSync({ url: "/api/matches/1/report", method: "POST", body: '{"score":20}' }); + + expect(await getSyncQueueLength()).toBe(1); + // The queue in the store should hold the second (newer) body + const { idbGet } = jest.requireMock("@/lib/offlineStorage"); + const queue = await idbGet("offline:sync-queue"); + expect(queue[0].body).toBe('{"score":20}'); + }); + + it("flushes items in chronological order and clears on 2xx", async () => { + const fetchOrder: string[] = []; + global.fetch = jest.fn(async (url: string) => { + fetchOrder.push(url as string); + return { ok: true, status: 200 } as Response; + }) as jest.Mock; + + // Add two items with different timestamps + await enqueueSync({ url: "/api/a", method: "POST" }); + await new Promise((r) => setTimeout(r, 5)); // ensure ts difference + await enqueueSync({ url: "/api/b", method: "POST" }); + + await flushSyncQueue(); + + expect(fetchOrder).toEqual(["/api/a", "/api/b"]); + expect(await getSyncQueueLength()).toBe(0); + }); + + it("stops flushing if network is unavailable", async () => { + global.fetch = jest.fn().mockRejectedValue(new TypeError("Network error")) as jest.Mock; + + await enqueueSync({ url: "/api/c", method: "POST" }); + await flushSyncQueue(); + + // Item should remain queued + expect(await getSyncQueueLength()).toBe(1); + }); + + it("removes 409 conflict responses (server resolved it)", async () => { + global.fetch = jest.fn(async () => ({ ok: false, status: 409 } as Response)) as jest.Mock; + + await enqueueSync({ url: "/api/d", method: "POST" }); + await flushSyncQueue(); + + expect(await getSyncQueueLength()).toBe(0); + }); +}); + +// ── useNetworkStatus ───────────────────────────────────────────────────────── +describe("useNetworkStatus", () => { + it("returns true when navigator.onLine is true", () => { + Object.defineProperty(navigator, "onLine", { value: true, configurable: true }); + const { result } = renderHook(() => useNetworkStatus()); + expect(result.current.isOnline).toBe(true); + }); + + it("updates to false when offline event fires", () => { + Object.defineProperty(navigator, "onLine", { value: true, configurable: true }); + const { result } = renderHook(() => useNetworkStatus()); + + act(() => { + window.dispatchEvent(new Event("offline")); + }); + + expect(result.current.isOnline).toBe(false); + }); + + it("updates back to true when online event fires", () => { + Object.defineProperty(navigator, "onLine", { value: false, configurable: true }); + const { result } = renderHook(() => useNetworkStatus()); + + act(() => { + window.dispatchEvent(new Event("online")); + }); + + expect(result.current.isOnline).toBe(true); + }); +}); + +// ── AnalyticsQueue ─────────────────────────────────────────────────────────── +describe("analyticsQueue", () => { + it("enqueues analytics events", async () => { + await enqueueAnalytics({ name: "page_view", timestamp: Date.now() }); + const { idbGet } = jest.requireMock("@/lib/offlineStorage"); + const queue = await idbGet("offline:analytics-queue"); + expect(queue).toHaveLength(1); + expect(queue[0].name).toBe("page_view"); + }); + + it("flushes via sendBeacon when available and clears queue", async () => { + await enqueueAnalytics({ name: "match_start", timestamp: Date.now() }); + + const mockSendBeacon = jest.fn(() => true); + Object.defineProperty(navigator, "sendBeacon", { + value: mockSendBeacon, + configurable: true, + }); + + await flushAnalyticsQueue(); + + expect(mockSendBeacon).toHaveBeenCalledWith( + "/api/analytics/events", + expect.stringContaining("match_start") + ); + + const { idbGet } = jest.requireMock("@/lib/offlineStorage"); + expect(await idbGet("offline:analytics-queue")).toHaveLength(0); + }); + + it("falls back to fetch when sendBeacon fails", async () => { + await enqueueAnalytics({ name: "tournament_join", timestamp: Date.now() }); + + Object.defineProperty(navigator, "sendBeacon", { + value: jest.fn(() => false), + configurable: true, + }); + + global.fetch = jest.fn(async () => ({ ok: true } as Response)) as jest.Mock; + + await flushAnalyticsQueue(); + + expect(global.fetch).toHaveBeenCalledWith( + "/api/analytics/events", + expect.objectContaining({ method: "POST" }) + ); + }); +}); diff --git a/frontend/src/app/offline/page.tsx b/frontend/src/app/offline/page.tsx new file mode 100644 index 0000000..76c7505 --- /dev/null +++ b/frontend/src/app/offline/page.tsx @@ -0,0 +1,18 @@ +export default function OfflinePage() { + return ( +
+ +

You're offline

+

+ No internet connection detected. Check your network and try again. + Changes you make will sync automatically when you reconnect. +

+ +
+ ); +} diff --git a/frontend/src/components/offline/OfflineBanner.tsx b/frontend/src/components/offline/OfflineBanner.tsx new file mode 100644 index 0000000..6aebe01 --- /dev/null +++ b/frontend/src/components/offline/OfflineBanner.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { useOffline } from "@/contexts/OfflineContext"; + +export function OfflineBanner() { + const { isOnline, isSyncing, pendingCount } = useOffline(); + + if (isOnline && !isSyncing) return null; + + return ( +
+ {isSyncing ? ( + <> + + Syncing changes{pendingCount > 0 ? ` (${pendingCount} left)` : ""}… + + ) : ( + <> + + You are offline + {pendingCount > 0 && ` · ${pendingCount} change${pendingCount > 1 ? "s" : ""} pending`} + + )} +
+ ); +} diff --git a/frontend/src/contexts/OfflineContext.tsx b/frontend/src/contexts/OfflineContext.tsx new file mode 100644 index 0000000..d52c1f2 --- /dev/null +++ b/frontend/src/contexts/OfflineContext.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { + createContext, + useContext, + useEffect, + useRef, + useState, + useCallback, + ReactNode, +} from "react"; +import { useNetworkStatus } from "@/hooks/useNetworkStatus"; +import { + enqueueSync, + flushSyncQueue, + getSyncQueueLength, + SyncItem, +} from "@/lib/syncQueue"; +import { flushAnalyticsQueue, enqueueAnalytics } from "@/lib/analyticsQueue"; + +interface OfflineContextValue { + isOnline: boolean; + isSyncing: boolean; + pendingCount: number; + queueMutation: (item: Omit) => Promise; + trackEvent: (name: string, props?: Record) => void; +} + +const OfflineContext = createContext(undefined); + +export function useOffline(): OfflineContextValue { + const ctx = useContext(OfflineContext); + if (!ctx) throw new Error("useOffline must be used inside OfflineProvider"); + return ctx; +} + +export function OfflineProvider({ children }: { children: ReactNode }) { + const { isOnline } = useNetworkStatus(); + const [isSyncing, setIsSyncing] = useState(false); + const [pendingCount, setPendingCount] = useState(0); + const prevOnline = useRef(isOnline); + + // Refresh pending count whenever online state changes + useEffect(() => { + getSyncQueueLength().then(setPendingCount); + }, [isOnline]); + + // When coming back online, flush both queues + useEffect(() => { + if (isOnline && !prevOnline.current) { + setIsSyncing(true); + Promise.all([ + flushSyncQueue((remaining) => setPendingCount(remaining)), + flushAnalyticsQueue(), + ]).finally(() => { + setIsSyncing(false); + getSyncQueueLength().then(setPendingCount); + }); + } + prevOnline.current = isOnline; + }, [isOnline]); + + const queueMutation = useCallback( + async (item: Omit) => { + await enqueueSync(item); + setPendingCount((n) => n + 1); + }, + [] + ); + + const trackEvent = useCallback( + (name: string, props?: Record) => { + enqueueAnalytics({ name, props, timestamp: Date.now() }); + if (isOnline) flushAnalyticsQueue(); + }, + [isOnline] + ); + + return ( + + {children} + + ); +} diff --git a/frontend/src/hooks/useNetworkStatus.ts b/frontend/src/hooks/useNetworkStatus.ts new file mode 100644 index 0000000..b7a3ad0 --- /dev/null +++ b/frontend/src/hooks/useNetworkStatus.ts @@ -0,0 +1,24 @@ +"use client"; + +import { useEffect, useState } from "react"; + +export function useNetworkStatus() { + const [isOnline, setIsOnline] = useState( + typeof navigator !== "undefined" ? navigator.onLine : true + ); + + useEffect(() => { + const up = () => setIsOnline(true); + const down = () => setIsOnline(false); + + window.addEventListener("online", up); + window.addEventListener("offline", down); + + return () => { + window.removeEventListener("online", up); + window.removeEventListener("offline", down); + }; + }, []); + + return { isOnline }; +} diff --git a/frontend/src/lib/analyticsQueue.ts b/frontend/src/lib/analyticsQueue.ts new file mode 100644 index 0000000..c1914d3 --- /dev/null +++ b/frontend/src/lib/analyticsQueue.ts @@ -0,0 +1,61 @@ +"use client"; + +import { idbGet, idbSet } from "./offlineStorage"; + +const ANALYTICS_KEY = "offline:analytics-queue"; + +export interface AnalyticsEvent { + name: string; + props?: Record; + timestamp: number; +} + +async function readQueue(): Promise { + return (await idbGet(ANALYTICS_KEY)) ?? []; +} + +async function writeQueue(queue: AnalyticsEvent[]): Promise { + await idbSet(ANALYTICS_KEY, queue); +} + +export async function enqueueAnalytics(event: AnalyticsEvent): Promise { + const queue = await readQueue(); + queue.push(event); + await writeQueue(queue); +} + +/** + * Flush queued analytics events. + * Replace the `sendBeacon` target with your actual analytics endpoint. + */ +export async function flushAnalyticsQueue(): Promise { + const queue = await readQueue(); + if (queue.length === 0) return; + + const endpoint = "/api/analytics/events"; + + try { + const sent = navigator.sendBeacon( + endpoint, + JSON.stringify({ events: queue }) + ); + if (sent) { + await writeQueue([]); + return; + } + } catch { + // sendBeacon not available or failed, fall through to fetch + } + + try { + const res = await fetch(endpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ events: queue }), + keepalive: true, + }); + if (res.ok) await writeQueue([]); + } catch { + // Network still down – will retry on next flush + } +} diff --git a/frontend/src/lib/offlineStorage.ts b/frontend/src/lib/offlineStorage.ts new file mode 100644 index 0000000..d01bec7 --- /dev/null +++ b/frontend/src/lib/offlineStorage.ts @@ -0,0 +1,65 @@ +/** + * Lightweight IndexedDB wrapper for offline-first storage. + * Stores key-value pairs across a named object store. + */ + +const DB_NAME = "arenax-offline"; +const DB_VERSION = 1; +const STORE_NAME = "kv"; + +function openDB(): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, DB_VERSION); + req.onupgradeneeded = () => req.result.createObjectStore(STORE_NAME); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +export async function idbGet(key: string): Promise { + const db = await openDB(); + return new Promise((resolve, reject) => { + const req = db + .transaction(STORE_NAME, "readonly") + .objectStore(STORE_NAME) + .get(key); + req.onsuccess = () => resolve(req.result as T | undefined); + req.onerror = () => reject(req.error); + }); +} + +export async function idbSet(key: string, value: T): Promise { + const db = await openDB(); + return new Promise((resolve, reject) => { + const req = db + .transaction(STORE_NAME, "readwrite") + .objectStore(STORE_NAME) + .put(value, key); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + }); +} + +export async function idbDelete(key: string): Promise { + const db = await openDB(); + return new Promise((resolve, reject) => { + const req = db + .transaction(STORE_NAME, "readwrite") + .objectStore(STORE_NAME) + .delete(key); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + }); +} + +export async function idbClear(): Promise { + const db = await openDB(); + return new Promise((resolve, reject) => { + const req = db + .transaction(STORE_NAME, "readwrite") + .objectStore(STORE_NAME) + .clear(); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + }); +} diff --git a/frontend/src/lib/syncQueue.ts b/frontend/src/lib/syncQueue.ts new file mode 100644 index 0000000..a9158da --- /dev/null +++ b/frontend/src/lib/syncQueue.ts @@ -0,0 +1,94 @@ +"use client"; + +import { idbGet, idbSet } from "./offlineStorage"; + +const QUEUE_KEY = "offline:sync-queue"; + +export interface SyncItem { + id: string; + url: string; + method: string; + body?: string; + headers?: Record; + /** Unix ms timestamp – used for Last-Write-Wins conflict resolution */ + timestamp: number; +} + +async function readQueue(): Promise { + return (await idbGet(QUEUE_KEY)) ?? []; +} + +async function writeQueue(queue: SyncItem[]): Promise { + await idbSet(QUEUE_KEY, queue); +} + +/** Enqueue a mutation that should be replayed when back online. */ +export async function enqueueSync( + item: Omit +): Promise { + const queue = await readQueue(); + + // Last-Write-Wins: if the same URL+method already exists, replace with newer timestamp + const idx = queue.findIndex( + (q) => q.url === item.url && q.method === item.method + ); + + const entry: SyncItem = { + ...item, + id: crypto.randomUUID(), + timestamp: Date.now(), + }; + + if (idx >= 0) { + queue.splice(idx, 1, entry); + } else { + queue.push(entry); + } + + await writeQueue(queue); +} + +/** Replay all queued mutations in chronological order. Removes each on success. */ +export async function flushSyncQueue( + onProgress?: (remaining: number) => void +): Promise { + let queue = await readQueue(); + // Ensure chronological order + queue.sort((a, b) => a.timestamp - b.timestamp); + + for (const item of [...queue]) { + try { + const token = + typeof localStorage !== "undefined" + ? (localStorage.getItem("auth_token") ?? + sessionStorage.getItem("auth_token")) + : null; + + const headers: Record = { + "Content-Type": "application/json", + ...item.headers, + }; + if (token) headers.Authorization = `Bearer ${token}`; + + const res = await fetch(item.url, { + method: item.method, + body: item.body, + headers, + }); + + // 2xx or 409 (conflict already resolved server-side) → remove from queue + if (res.ok || res.status === 409) { + queue = queue.filter((q) => q.id !== item.id); + await writeQueue(queue); + onProgress?.(queue.length); + } + } catch { + // Network still unavailable – stop and try again later + break; + } + } +} + +export async function getSyncQueueLength(): Promise { + return (await readQueue()).length; +} diff --git a/server/package.json b/server/package.json index e5d34dd..2521af6 100644 --- a/server/package.json +++ b/server/package.json @@ -38,7 +38,11 @@ "helmet": "^7.1.0", "hpp": "^0.2.3", "ioredis": "^5.3.2", +<<<<<<< HEAD + "kafkajs": "^2.2.4", +======= "jsonata": "^2.2.1", +>>>>>>> origin/main "jsonwebtoken": "^9.0.2", "nodemailer": "^8.0.9", "opossum": "^10.0.0",