Files
meezi/web/dashboard/src/lib/offline/use-offline-sync.ts
T

149 lines
4.7 KiB
TypeScript
Raw Normal View History

"use client";
import { useCallback, useEffect, useRef } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useSyncQueueStore } from "@/lib/stores/sync-queue.store";
import {
enqueueOutboxOp,
getAllQueueItems,
getOutboxCount,
getQueueCount,
removeQueueItem,
} from "@/lib/offline/offline-db";
import { drainOutbox } from "@/lib/offline/outbox";
function newId(prefix: string): string {
if (prefix === "idem" && typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
}
/**
* One-time migration of any items left in the legacy POS `order_queue` into the
* generic outbox, so orders queued before this release still sync. Best-effort.
*/
async function migrateLegacyQueue(): Promise<void> {
let legacy: Awaited<ReturnType<typeof getAllQueueItems>> = [];
try {
legacy = await getAllQueueItems();
} catch {
return;
}
for (const item of legacy) {
try {
if (item.type === "create_order") {
const { cafeId, body } = item.payload as { cafeId: string; body: unknown };
await enqueueOutboxOp({
id: newId("op"),
idempotencyKey: newId("idem"),
method: "POST",
url: `/api/cafes/${cafeId}/orders`,
body,
entityType: "order",
idField: "id",
createdAt: Date.parse(item.createdAt) || Date.now(),
});
} else if (item.type === "add_items") {
const { cafeId, orderId, body } = item.payload as {
cafeId: string;
orderId: string;
body: unknown;
};
await enqueueOutboxOp({
id: newId("op"),
idempotencyKey: newId("idem"),
method: "POST",
url: `/api/cafes/${cafeId}/orders/${orderId}/items`,
body,
entityType: "order_items",
createdAt: Date.parse(item.createdAt) || Date.now(),
});
}
await removeQueueItem(item.id);
} catch {
// leave the legacy item in place; we'll try again next mount
}
}
}
/**
* Mount once in the app shell to:
* - migrate any legacy queued orders into the outbox,
* - keep the pending-count badge and online flag in sync,
* - drain the outbox when back online or the tab regains focus,
* - refresh server data once writes have synced.
*/
export function useOfflineSync() {
const { setQueueCount, setSyncing, setOnline } = useSyncQueueStore();
const queryClient = useQueryClient();
const syncLock = useRef(false);
const refreshCount = useCallback(async () => {
const n = (await getOutboxCount()) + (await getQueueCount());
setQueueCount(n);
return n;
}, [setQueueCount]);
const syncQueue = useCallback(async () => {
if (syncLock.current) return;
if (typeof navigator !== "undefined" && !navigator.onLine) return;
syncLock.current = true;
setSyncing(true);
try {
const result = await drainOutbox();
if (result.sent > 0) {
// Replace optimistic local data with the authoritative server state.
await queryClient.invalidateQueries();
}
} finally {
syncLock.current = false;
setSyncing(false);
await refreshCount();
}
}, [refreshCount, setSyncing, queryClient]);
useEffect(() => {
// Ask the browser to keep our IndexedDB (outbox + cache) from being evicted
// under storage pressure, so unsynced writes survive.
if (typeof navigator !== "undefined" && navigator.storage?.persist) {
void navigator.storage.persisted().then((granted) => {
if (!granted) void navigator.storage.persist();
});
}
void (async () => {
await migrateLegacyQueue();
await refreshCount();
// Drain anything pending if we mounted already online.
if (typeof navigator === "undefined" || navigator.onLine) void syncQueue();
})();
const handleOnline = () => {
setOnline(true);
void syncQueue();
};
const handleOffline = () => setOnline(false);
setOnline(typeof navigator !== "undefined" ? navigator.onLine : true);
window.addEventListener("online", handleOnline);
window.addEventListener("offline", handleOffline);
const handleVisibility = () => {
if (document.visibilityState === "visible" && navigator.onLine) {
void syncQueue();
}
};
document.addEventListener("visibilitychange", handleVisibility);
return () => {
window.removeEventListener("online", handleOnline);
window.removeEventListener("offline", handleOffline);
document.removeEventListener("visibilitychange", handleVisibility);
};
}, [syncQueue, setOnline, refreshCount]);
return { syncQueue, refreshCount };
}