Compare commits

2 Commits

Author SHA1 Message Date
soroush.asadi ae5c750d34 fix(notifications): don't lose live alerts until a page refresh
CI/CD / CI · API (dotnet build + test) (push) Successful in 44s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 29s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m6s
CI/CD / CI · Admin Web (tsc) (push) Successful in 38s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 2m50s
The SignalR connection used the default auto-reconnect, which gives up after
~30s and, even when it did reconnect, never re-ran JoinCafe — so the client
dropped out of the café group and silently stopped receiving notifications until
a manual refresh. Now it retries forever (capped backoff), re-joins the group on
reconnect (and catches up via invalidate), and re-establishes the connection when
the network returns or the tab is refocused. As a safety net, the unread/bell and
tab-badge polls now run in background tabs too (refetchIntervalInBackground).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 11:28:47 +03:30
soroush.asadi f985deb233 fix(offline): stop the sync queue badge getting stuck above zero
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 <noreply@anthropic.com>
2026-06-25 11:28:47 +03:30
7 changed files with 144 additions and 62 deletions
@@ -1,61 +1,76 @@
"use client"; "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 { cn } from "@/lib/utils";
import { useSyncQueueStore } from "@/lib/stores/sync-queue.store"; import { useSyncQueueStore } from "@/lib/stores/sync-queue.store";
import { useLocale } from "next-intl"; import { getQueueCount } from "@/lib/offline/offline-db";
import { import {
getAllQueueItems, drainOutbox,
getQueueCount, getActiveOutboxCount,
removeQueueItem, getFailedOutboxCount,
markQueueItemFailed, discardFailedOps,
} from "@/lib/offline/offline-db"; } from "@/lib/offline/outbox";
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<string, unknown>);
} 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<string, unknown>
);
}
await removeQueueItem(item.id);
} catch {
await markQueueItemFailed(item.id);
}
}
} finally {
setSyncing(false);
setQueueCount(await getQueueCount());
}
}
export function SyncStatusIndicator() { export function SyncStatusIndicator() {
const { queueCount, isSyncing, isOnline, setSyncing, setQueueCount } = const {
useSyncQueueStore(); queueCount,
failedCount,
isSyncing,
isOnline,
setSyncing,
setQueueCount,
setFailedCount,
} = useSyncQueueStore();
const queryClient = useQueryClient();
const locale = useLocale(); const locale = useLocale();
const isFa = locale !== "en"; const isFa = locale !== "en";
const show = !isOnline || queueCount > 0 || isSyncing; const recount = async () => {
if (!show) return null; 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 (
<button
type="button"
onClick={() => void clearFailed()}
title={isFa ? "حذف موارد ناموفق همگام‌سازی" : "Clear failed sync items"}
className="flex cursor-pointer items-center gap-1.5 rounded-full bg-red-100 px-2.5 py-1 text-[11px] font-medium text-red-800 transition-colors hover:bg-red-200 dark:bg-red-900/30 dark:text-red-300"
>
<AlertTriangle className="h-3 w-3 shrink-0" aria-hidden />
<span>{isFa ? `${failedCount} ناموفق — پاک کردن` : `${failedCount} failed — clear`}</span>
</button>
);
}
const label = isFa const label = isFa
? !isOnline ? !isOnline
@@ -72,13 +87,9 @@ export function SyncStatusIndicator() {
return ( return (
<button <button
type="button" type="button"
onClick={() => void runManualSync(setSyncing, setQueueCount)} onClick={() => void retry()}
disabled={isSyncing || !isOnline} disabled={isSyncing || !isOnline}
title={ title={isFa ? "برای همگام‌سازی دستی کلیک کنید" : "Click to retry sync"}
isFa
? "برای همگام‌سازی دستی کلیک کنید"
: "Click to retry sync"
}
className={cn( className={cn(
"flex cursor-pointer items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors", "flex cursor-pointer items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors",
"disabled:cursor-not-allowed", "disabled:cursor-not-allowed",
@@ -40,6 +40,9 @@ export function useNotificationsFeed(options: UseNotificationsFeedOptions = {})
queryFn: () => fetchNotifications(cafeId!, unreadOnly, limit), queryFn: () => fetchNotifications(cafeId!, unreadOnly, limit),
enabled: !!cafeId, enabled: !!cafeId,
refetchInterval: 60_000, refetchInterval: 60_000,
// Keep polling even when the tab is in the background, so the unread count
// stays current if the live socket missed something — no refresh needed.
refetchIntervalInBackground: true,
}); });
const refresh = useCallback(() => { const refresh = useCallback(() => {
@@ -94,6 +94,7 @@ export function useTabBadge() {
queryFn: () => fetchNotifications(cafeId!, false, 50), queryFn: () => fetchNotifications(cafeId!, false, 50),
enabled: !!cafeId, enabled: !!cafeId,
refetchInterval: 60_000, refetchInterval: 60_000,
refetchIntervalInBackground: true, // update the tab badge even when unfocused
}); });
const unread = data?.unreadCount ?? 0; const unread = data?.unreadCount ?? 0;
+21
View File
@@ -165,3 +165,24 @@ export async function getPoisonedOps(): Promise<OutboxOp[]> {
const ops = await getOutboxOps(); const ops = await getOutboxOps();
return ops.filter((o) => o.status === "failed" && o.attempts >= MAX_ATTEMPTS); return ops.filter((o) => o.status === "failed" && o.attempts >= MAX_ATTEMPTS);
} }
function isPoisoned(o: OutboxOp): boolean {
return o.status === "failed" && o.attempts >= MAX_ATTEMPTS;
}
/** Count of ops still worth retrying (excludes poisoned) — drives the "pending" badge. */
export async function getActiveOutboxCount(): Promise<number> {
return (await getOutboxOps()).filter((o) => !isPoisoned(o)).length;
}
/** Count of poisoned ops — surfaced separately so they don't inflate "pending". */
export async function getFailedOutboxCount(): Promise<number> {
return (await getOutboxOps()).filter(isPoisoned).length;
}
/** Permanently drop poisoned ops (user-initiated "clear failed"). Returns how many. */
export async function discardFailedOps(): Promise<number> {
const poisoned = (await getOutboxOps()).filter(isPoisoned);
for (const o of poisoned) await removeOutboxOp(o.id);
return poisoned.length;
}
@@ -6,11 +6,14 @@ import { useSyncQueueStore } from "@/lib/stores/sync-queue.store";
import { import {
enqueueOutboxOp, enqueueOutboxOp,
getAllQueueItems, getAllQueueItems,
getOutboxCount,
getQueueCount, getQueueCount,
removeQueueItem, removeQueueItem,
} from "@/lib/offline/offline-db"; } from "@/lib/offline/offline-db";
import { drainOutbox } from "@/lib/offline/outbox"; import {
drainOutbox,
getActiveOutboxCount,
getFailedOutboxCount,
} from "@/lib/offline/outbox";
function newId(prefix: string): string { function newId(prefix: string): string {
if (prefix === "idem" && typeof crypto !== "undefined" && "randomUUID" in crypto) { if (prefix === "idem" && typeof crypto !== "undefined" && "randomUUID" in crypto) {
@@ -75,15 +78,18 @@ async function migrateLegacyQueue(): Promise<void> {
* - refresh server data once writes have synced. * - refresh server data once writes have synced.
*/ */
export function useOfflineSync() { export function useOfflineSync() {
const { setQueueCount, setSyncing, setOnline } = useSyncQueueStore(); const { setQueueCount, setFailedCount, setSyncing, setOnline } = useSyncQueueStore();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const syncLock = useRef(false); const syncLock = useRef(false);
const refreshCount = useCallback(async () => { const refreshCount = useCallback(async () => {
const n = (await getOutboxCount()) + (await getQueueCount()); // Pending = retryable ops only; poisoned (failed after max retries) are shown
// separately so they never inflate the badge or keep it stuck above zero.
const n = (await getActiveOutboxCount()) + (await getQueueCount());
setQueueCount(n); setQueueCount(n);
setFailedCount(await getFailedOutboxCount());
return n; return n;
}, [setQueueCount]); }, [setQueueCount, setFailedCount]);
const syncQueue = useCallback(async () => { const syncQueue = useCallback(async () => {
if (syncLock.current) return; if (syncLock.current) return;
@@ -54,10 +54,23 @@ export function useOrderAlerts() {
accessTokenFactory: () => accessTokenFactory: () =>
(typeof window !== "undefined" ? localStorage.getItem("meezi_access_token") : null) ?? "", (typeof window !== "undefined" ? localStorage.getItem("meezi_access_token") : null) ?? "",
}) })
.withAutomaticReconnect() // Retry FOREVER (capped backoff). The default policy gives up after ~30s,
// leaving the connection dead until a page refresh → missed notifications.
.withAutomaticReconnect({
nextRetryDelayInMilliseconds: (ctx) =>
Math.min(30000, 1000 * 2 ** Math.min(ctx.previousRetryCount, 5)),
})
.build(); .build();
let stopped = false; let stopped = false;
const joinCafe = () => connection.invoke("JoinCafe", cafeId).catch(() => {});
// On reconnect the server group membership is gone — re-join or we silently
// stop receiving notifications. Also catch up anything missed while down.
connection.onreconnected(() => {
void joinCafe();
void qc.invalidateQueries({ queryKey: ["notifications", cafeId] });
});
const severityFor = (type: string) => { const severityFor = (type: string) => {
if (type === "table_call_waiter") return notify.warning; if (type === "table_call_waiter") return notify.warning;
@@ -104,14 +117,36 @@ export function useOrderAlerts() {
void connection void connection
.start() .start()
.then(() => { .then(() => {
if (!stopped) return connection.invoke("JoinCafe", cafeId); if (!stopped) return joinCafe();
}) })
.catch(() => { .catch(() => {
// connection/auth failed — alerts simply won't fire; no UI breakage // connection/auth failed — alerts simply won't fire; no UI breakage
}); });
// If the connection fully dropped (gave up, or the device slept), bring it
// back when the network returns or the tab is focused again.
const ensureConnected = () => {
if (stopped) return;
if (connection.state === signalR.HubConnectionState.Disconnected) {
void connection
.start()
.then(() => {
if (!stopped) return joinCafe();
})
.then(() => qc.invalidateQueries({ queryKey: ["notifications", cafeId] }))
.catch(() => {});
}
};
const onVisible = () => {
if (document.visibilityState === "visible") ensureConnected();
};
window.addEventListener("online", ensureConnected);
document.addEventListener("visibilitychange", onVisible);
return () => { return () => {
stopped = true; stopped = true;
window.removeEventListener("online", ensureConnected);
document.removeEventListener("visibilitychange", onVisible);
void connection.stop(); void connection.stop();
}; };
}, [cafeId, locale, qc]); }, [cafeId, locale, qc]);
@@ -1,14 +1,17 @@
import { create } from "zustand"; import { create } from "zustand";
interface SyncQueueState { interface SyncQueueState {
/** Number of items waiting to be synced */ /** Number of items waiting to be synced (retryable; excludes poisoned) */
queueCount: number; queueCount: number;
/** Ops that exhausted retries and need attention (won't auto-sync) */
failedCount: number;
/** True while a sync pass is running */ /** True while a sync pass is running */
isSyncing: boolean; isSyncing: boolean;
/** Mirrors navigator.onLine (updated client-side) */ /** Mirrors navigator.onLine (updated client-side) */
isOnline: boolean; isOnline: boolean;
setQueueCount: (n: number) => void; setQueueCount: (n: number) => void;
setFailedCount: (n: number) => void;
setSyncing: (v: boolean) => void; setSyncing: (v: boolean) => void;
setOnline: (v: boolean) => void; setOnline: (v: boolean) => void;
incrementQueue: () => void; incrementQueue: () => void;
@@ -17,10 +20,12 @@ interface SyncQueueState {
export const useSyncQueueStore = create<SyncQueueState>((set) => ({ export const useSyncQueueStore = create<SyncQueueState>((set) => ({
queueCount: 0, queueCount: 0,
failedCount: 0,
isSyncing: false, isSyncing: false,
isOnline: true, // assume online until client hydrates isOnline: true, // assume online until client hydrates
setQueueCount: (n) => set({ queueCount: Math.max(0, n) }), setQueueCount: (n) => set({ queueCount: Math.max(0, n) }),
setFailedCount: (n) => set({ failedCount: Math.max(0, n) }),
setSyncing: (v) => set({ isSyncing: v }), setSyncing: (v) => set({ isSyncing: v }),
setOnline: (v) => set({ isOnline: v }), setOnline: (v) => set({ isOnline: v }),
incrementQueue: () => set((s) => ({ queueCount: s.queueCount + 1 })), incrementQueue: () => set((s) => ({ queueCount: s.queueCount + 1 })),