Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions src/hooks/useOnlineStatus.ts
Original file line number Diff line number Diff line change
@@ -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<boolean>(
typeof navigator !== "undefined" ? navigator.onLine : true
);
const [pendingCount, setPendingCount] = useState<number>(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 };
}
132 changes: 130 additions & 2 deletions src/services/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,40 @@
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<T = unknown> {
key: string;
value: T;
timestamp: number;
ttl: number;
}

interface QueueEntry {
key: string;
value: unknown;
timestamp: number;
retryCount: number;
}

let dbInstance: IDBPDatabase | null = null;

async function getDb(): Promise<IDBPDatabase> {
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;
Expand Down Expand Up @@ -55,6 +68,10 @@ export async function cacheSet<T>(
ttlMs = 5 * 60 * 1000
): Promise<void> {
try {
if (typeof navigator !== "undefined" && !navigator.onLine) {
await enqueueOfflineWrite(key, value);
return;
}
const db = await getDb();
const entry: CacheEntry<T> = {
key,
Expand Down Expand Up @@ -112,3 +129,114 @@ export async function cacheGetBulk<T>(keys: string[]): Promise<Map<string, T>> {
}
return results;
}

let writeBatch: Array<{ key: string; value: unknown; ttlMs: number }> = [];
let writeBatchScheduled = false;

async function flushWriteBatch(): Promise<void> {
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<T>(
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<void> {
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<number> {
try {
const db = await getDb();
return await db.count("offline-queue");
} catch {
return 0;
}
}

export async function replayOfflineQueue(): Promise<void> {
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
}
}
Loading