From f985deb2335706dcad07fc0b2dbb56d356ad3677 Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Thu, 25 Jun 2026 11:28:47 +0330 Subject: [PATCH] fix(offline): stop the sync queue badge getting stuck above zero MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs made "N در صف" persist even when online: - The badge counted poisoned ops (failed after 5 retries, never removed), so it never returned to 0. Now the badge counts only retryable (active) ops; poisoned ops are tracked separately as failedCount and surfaced as a red "N failed — clear" chip the user can tap to discard them. - The manual-retry click drained the LEGACY order_queue, not the real outbox the app actually uses — so clicking did nothing for stuck items. It now drains the outbox (drainOutbox), invalidates queries on success, and recounts. Co-Authored-By: Claude Opus 4.8 --- .../layout/sync-status-indicator.tsx | 119 ++++++++++-------- web/dashboard/src/lib/offline/outbox.ts | 21 ++++ .../src/lib/offline/use-offline-sync.ts | 16 ++- .../src/lib/stores/sync-queue.store.ts | 7 +- 4 files changed, 103 insertions(+), 60 deletions(-) diff --git a/web/dashboard/src/components/layout/sync-status-indicator.tsx b/web/dashboard/src/components/layout/sync-status-indicator.tsx index 4a3f5d9..e018c0a 100644 --- a/web/dashboard/src/components/layout/sync-status-indicator.tsx +++ b/web/dashboard/src/components/layout/sync-status-indicator.tsx @@ -1,61 +1,76 @@ "use client"; -import { WifiOff, CloudUpload, RefreshCw } from "lucide-react"; +import { WifiOff, CloudUpload, RefreshCw, AlertTriangle } from "lucide-react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useLocale } from "next-intl"; import { cn } from "@/lib/utils"; import { useSyncQueueStore } from "@/lib/stores/sync-queue.store"; -import { useLocale } from "next-intl"; +import { getQueueCount } from "@/lib/offline/offline-db"; import { - getAllQueueItems, - getQueueCount, - removeQueueItem, - markQueueItemFailed, -} from "@/lib/offline/offline-db"; -import { apiPost } from "@/lib/api/client"; - -/** Manual retry — fires one sync pass immediately (used as onClick). */ -async function runManualSync( - setSyncing: (v: boolean) => void, - setQueueCount: (n: number) => void -) { - if (!navigator.onLine) return; - setSyncing(true); - try { - const items = await getAllQueueItems(); - for (const item of items) { - try { - if (item.type === "create_order") { - const { cafeId, body } = item.payload as { cafeId: string; body: unknown }; - await apiPost(`/api/cafes/${cafeId}/orders`, body as Record); - } else if (item.type === "add_items") { - const { cafeId, orderId, body } = item.payload as { - cafeId: string; - orderId: string; - body: unknown; - }; - await apiPost( - `/api/cafes/${cafeId}/orders/${orderId}/items`, - body as Record - ); - } - await removeQueueItem(item.id); - } catch { - await markQueueItemFailed(item.id); - } - } - } finally { - setSyncing(false); - setQueueCount(await getQueueCount()); - } -} + drainOutbox, + getActiveOutboxCount, + getFailedOutboxCount, + discardFailedOps, +} from "@/lib/offline/outbox"; export function SyncStatusIndicator() { - const { queueCount, isSyncing, isOnline, setSyncing, setQueueCount } = - useSyncQueueStore(); + const { + queueCount, + failedCount, + isSyncing, + isOnline, + setSyncing, + setQueueCount, + setFailedCount, + } = useSyncQueueStore(); + const queryClient = useQueryClient(); const locale = useLocale(); const isFa = locale !== "en"; - const show = !isOnline || queueCount > 0 || isSyncing; - if (!show) return null; + const recount = async () => { + setQueueCount((await getActiveOutboxCount()) + (await getQueueCount())); + setFailedCount(await getFailedOutboxCount()); + }; + + // Manual retry — drains the REAL outbox (the engine the app actually uses), + // then refreshes server data and the counts. + const retry = async () => { + if (typeof navigator !== "undefined" && !navigator.onLine) return; + if (isSyncing) return; + setSyncing(true); + try { + const res = await drainOutbox(); + if (res.sent > 0) await queryClient.invalidateQueries(); + } finally { + setSyncing(false); + await recount(); + } + }; + + // Poisoned ops can never sync (permanent 4xx) — let the user clear them so the + // badge doesn't sit stuck forever. + const clearFailed = async () => { + await discardFailedOps(); + await recount(); + }; + + const showPending = !isOnline || queueCount > 0 || isSyncing; + const showFailed = !showPending && failedCount > 0; + if (!showPending && !showFailed) return null; + + if (showFailed) { + return ( + + ); + } const label = isFa ? !isOnline @@ -72,13 +87,9 @@ export function SyncStatusIndicator() { return (