diff --git a/src/hooks/useOnlineStatus.ts b/src/hooks/useOnlineStatus.ts new file mode 100644 index 0000000..fb2dbe0 --- /dev/null +++ b/src/hooks/useOnlineStatus.ts @@ -0,0 +1,45 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { replayOfflineQueue, getOfflineQueueLength } from "@/services/cache"; + +interface OnlineStatus { + isOnline: boolean; + pendingCount: number; +} + +export function useOnlineStatus(): OnlineStatus { + const [isOnline, setIsOnline] = useState( + typeof navigator !== "undefined" ? navigator.onLine : true + ); + const [pendingCount, setPendingCount] = useState(0); + + const handleOnline = useCallback(() => { + setIsOnline(true); + void replayOfflineQueue(); + void getOfflineQueueLength().then(setPendingCount); + }, []); + + const handleOffline = useCallback(() => { + setIsOnline(false); + }, []); + + useEffect(() => { + void getOfflineQueueLength().then(setPendingCount); + + window.addEventListener("online", handleOnline); + window.addEventListener("offline", handleOffline); + + const poll = setInterval(() => { + void getOfflineQueueLength().then(setPendingCount); + }, 10_000); + + return () => { + window.removeEventListener("online", handleOnline); + window.removeEventListener("offline", handleOffline); + clearInterval(poll); + }; + }, [handleOnline, handleOffline]); + + return { isOnline, pendingCount }; +} diff --git a/src/services/cache.ts b/src/services/cache.ts index 285d5ef..5ce11ad 100644 --- a/src/services/cache.ts +++ b/src/services/cache.ts @@ -3,9 +3,12 @@ import { openDB, type IDBPDatabase } from "idb"; const DB_NAME = "utility-cache"; -const DB_VERSION = 1; +const DB_VERSION = 2; const CACHE_PREFIX = "utility"; +const RETRY_DELAYS = [1_000, 5_000, 15_000, 30_000, 60_000]; +const MAX_RETRIES = 5; + interface CacheEntry { key: string; value: T; @@ -13,17 +16,27 @@ interface CacheEntry { ttl: number; } +interface QueueEntry { + key: string; + value: unknown; + timestamp: number; + retryCount: number; +} + let dbInstance: IDBPDatabase | null = null; async function getDb(): Promise { if (dbInstance) return dbInstance; dbInstance = await openDB(DB_NAME, DB_VERSION, { - upgrade(db) { + upgrade(db, _oldVersion) { if (!db.objectStoreNames.contains("kv")) { const store = db.createObjectStore("kv", { keyPath: "key" }); store.createIndex("timestamp", "timestamp"); store.createIndex("ttl", "ttl"); } + if (!db.objectStoreNames.contains("offline-queue")) { + db.createObjectStore("offline-queue", { keyPath: "key" }); + } }, }); return dbInstance; @@ -55,6 +68,10 @@ export async function cacheSet( ttlMs = 5 * 60 * 1000 ): Promise { try { + if (typeof navigator !== "undefined" && !navigator.onLine) { + await enqueueOfflineWrite(key, value); + return; + } const db = await getDb(); const entry: CacheEntry = { key, @@ -112,3 +129,114 @@ export async function cacheGetBulk(keys: string[]): Promise> { } return results; } + +let writeBatch: Array<{ key: string; value: unknown; ttlMs: number }> = []; +let writeBatchScheduled = false; + +async function flushWriteBatch(): Promise { + const batch = writeBatch; + writeBatch = []; + writeBatchScheduled = false; + try { + const db = await getDb(); + const tx = db.transaction("kv", "readwrite"); + const now = Date.now(); + for (const { key, value, ttlMs } of batch) { + tx.store.put({ key, value, timestamp: now, ttl: ttlMs } satisfies CacheEntry); + } + await tx.done; + } catch { + // silently fail + } +} + +export function cacheSetBulk( + entries: Array<{ key: string; value: T; ttlMs?: number }> +): void { + for (const { key, value, ttlMs } of entries) { + writeBatch.push({ + key, + value, + ttlMs: ttlMs ?? 5 * 60 * 1000, + }); + } + if (!writeBatchScheduled) { + writeBatchScheduled = true; + requestAnimationFrame(() => { + void flushWriteBatch(); + }); + } +} + +export async function enqueueOfflineWrite( + key: string, + value: unknown +): Promise { + try { + const db = await getDb(); + const entries = await db.getAll("offline-queue"); + const existing = entries.find((e) => e.key === key); + const now = Date.now(); + if (existing) { + await db.put("offline-queue", { + ...existing, + value, + timestamp: now, + } satisfies QueueEntry); + } else { + await db.add("offline-queue", { + key, + value, + timestamp: now, + retryCount: 0, + } satisfies QueueEntry); + } + } catch { + // silently fail + } +} + +export async function getOfflineQueueLength(): Promise { + try { + const db = await getDb(); + return await db.count("offline-queue"); + } catch { + return 0; + } +} + +export async function replayOfflineQueue(): Promise { + try { + const db = await getDb(); + const entries = await db.getAll("offline-queue"); + entries.sort((a, b) => a.timestamp - b.timestamp); + + for (const entry of entries) { + try { + const cacheEntry: CacheEntry = { + key: entry.key, + value: entry.value, + timestamp: Date.now(), + ttl: 5 * 60 * 1000, + }; + await db.put("kv", cacheEntry); + await db.delete("offline-queue", entry.key); + } catch { + if (entry.retryCount < MAX_RETRIES) { + await db.put("offline-queue", { + ...entry, + retryCount: entry.retryCount + 1, + } satisfies QueueEntry); + const delay = + RETRY_DELAYS[Math.min(entry.retryCount, RETRY_DELAYS.length - 1)]; + setTimeout(() => { + void replayOfflineQueue(); + }, delay); + } + break; + } + } + } catch { + // silently fail + } +}