diff --git a/frontend/package-lock.json b/frontend/package-lock.json index dda63caf..cc8e3d45 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -71,7 +71,8 @@ "prettier": "^3.4.2", "tsup": "^8.3.5", "typescript": "^5.7.2", - "vitest": "^2.1.8" + "vite": "^6.2.0", + "vitest": "^3.0.0" }, "engines": { "node": ">=18.0.0" diff --git a/frontend/src/components/chat-view.tsx b/frontend/src/components/chat-view.tsx index 04e0edf0..c53e25a0 100644 --- a/frontend/src/components/chat-view.tsx +++ b/frontend/src/components/chat-view.tsx @@ -19,6 +19,7 @@ import { } from 'lucide-react'; import { useAuth } from '@/context/auth-context'; +import { triggerBalanceRefresh } from '@/hooks/use-accounting-api'; import { formatCostPerUnit, getCostsFromSource } from '@/lib/cost-utils'; import { getChatDataSources, getChatModels } from '@/lib/endpoint-utils'; import { @@ -842,6 +843,9 @@ export function ChatView({ initialQuery }: Readonly) { ); }); } + + // Refresh balance after successful chat completion (credits may have been consumed) + triggerBalanceRefresh(); } catch (error) { // Handle SDK-specific errors let errorMessage = 'An unexpected error occurred'; diff --git a/frontend/src/hooks/use-accounting-api.ts b/frontend/src/hooks/use-accounting-api.ts index 24a2bbfa..892dedd3 100644 --- a/frontend/src/hooks/use-accounting-api.ts +++ b/frontend/src/hooks/use-accounting-api.ts @@ -25,6 +25,120 @@ import type { AccountingTransaction, AccountingUser, CreateTransactionInput } fr import { useAccountingContext } from '@/context/accounting-context'; import { syftClient } from '@/lib/sdk-client'; +// ============================================================================= +// Polling Configuration +// ============================================================================= + +/** Polling interval for balance and transactions (30 seconds) */ +const POLLING_INTERVAL_MS = 30_000; + +// ============================================================================= +// Force Refresh Event System +// ============================================================================= + +type RefreshListener = () => void; +const refreshListeners = new Set(); + +/** + * Subscribe to force refresh events. + * Returns an unsubscribe function. + */ +function subscribeToRefresh(listener: RefreshListener): () => void { + refreshListeners.add(listener); + return () => refreshListeners.delete(listener); +} + +/** + * Trigger a force refresh of all balance/transaction data. + * Call this from anywhere in the app to immediately refresh accounting data. + * + * @example + * ```tsx + * // After a successful payment + * import { triggerBalanceRefresh } from '@/hooks/use-accounting-api'; + * await processPayment(); + * triggerBalanceRefresh(); + * ``` + */ +export function triggerBalanceRefresh(): void { + for (const listener of refreshListeners) { + listener(); + } +} + +/** + * Hook to get a function that forces refresh of balance data. + * Use this when you need to trigger a refresh from a component. + * + * @example + * ```tsx + * function PaymentButton() { + * const forceRefresh = useBalanceRefresh(); + * + * const handlePayment = async () => { + * await processPayment(); + * forceRefresh(); // Immediately update balance display + * }; + * } + * ``` + */ +export function useBalanceRefresh(): () => void { + return triggerBalanceRefresh; +} + +// ============================================================================= +// Visibility-Aware Polling Utilities +// ============================================================================= + +interface PollingController { + start: () => void; + stop: () => void; + cleanup: () => void; +} + +/** + * Creates a visibility-aware polling controller. + * Pauses polling when tab is hidden, resumes when visible. + */ +function createVisibilityAwarePolling( + fetchFunction: (isPolling: boolean) => Promise, + intervalMs: number +): PollingController { + let intervalId: ReturnType | null = null; + + const start = () => { + if (intervalId) return; // Already polling + void fetchFunction(false); // Initial fetch with loading state + intervalId = setInterval(() => void fetchFunction(true), intervalMs); + }; + + const stop = () => { + if (intervalId) { + clearInterval(intervalId); + intervalId = null; + } + }; + + const handleVisibilityChange = () => { + if (document.hidden) { + stop(); + } else { + // Resume polling with immediate fetch when tab becomes visible + start(); + } + }; + + const cleanup = () => { + stop(); + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + + // Set up visibility listener + document.addEventListener('visibilitychange', handleVisibilityChange); + + return { start, stop, cleanup }; +} + // ============================================================================= // Proxy Client - Makes requests to SyftHub backend (which proxies to accounting) // ============================================================================= @@ -203,6 +317,13 @@ interface UseAccountingUserResult { /** * Hook to fetch and manage the current accounting user. + * + * Features: + * - Auto-polls every 30 seconds when tab is visible + * - Pauses polling when tab is hidden (saves bandwidth) + * - Resumes with immediate fetch when tab becomes visible + * - Supports force refresh via triggerBalanceRefresh() + * - Silent error handling during polling (doesn't disrupt UI) */ export function useAccountingUser(): UseAccountingUserResult { const { isConfigured } = useAccountingContext(); @@ -210,43 +331,78 @@ export function useAccountingUser(): UseAccountingUserResult { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const isMounted = useRef(true); + const isFetching = useRef(false); - const refetch = useCallback(async () => { - if (!isConfigured) { - setUser(null); - return; - } + // Fetch user data, with optional silent mode for polling + const fetchUser = useCallback( + async (isPolling = false) => { + if (!isConfigured) { + setUser(null); + return; + } - setIsLoading(true); - setError(null); + // Prevent concurrent fetches + if (isFetching.current) return; + isFetching.current = true; - try { - const client = getProxyClient(); - const fetchedUser = await client.getUser(); - if (isMounted.current) { - setUser(fetchedUser); + // Only show loading state on initial/manual fetch, not polling + if (!isPolling) { + setIsLoading(true); + setError(null); } - } catch (error_) { - if (isMounted.current) { - setError(error_ instanceof Error ? error_.message : 'Failed to fetch user'); - } - } finally { - if (isMounted.current) { - setIsLoading(false); + + try { + const client = getProxyClient(); + const fetchedUser = await client.getUser(); + if (isMounted.current) { + setUser(fetchedUser); + // Clear any previous error on successful fetch (self-healing) + setError(null); + } + } catch (error_) { + // Only show error to user on initial/manual fetch, not during polling + // This prevents transient network issues from disrupting the UI + if (isMounted.current && !isPolling) { + setError(error_ instanceof Error ? error_.message : 'Failed to fetch user'); + } + } finally { + isFetching.current = false; + if (isMounted.current && !isPolling) { + setIsLoading(false); + } } - } - }, [isConfigured]); + }, + [isConfigured] + ); + // Manual refetch (shows loading state) + const refetch = useCallback(async () => { + await fetchUser(false); + }, [fetchUser]); + + // Visibility-aware polling effect useEffect(() => { + if (!isConfigured) return; + isMounted.current = true; + + // Create polling controller + const polling = createVisibilityAwarePolling(fetchUser, POLLING_INTERVAL_MS); + + // Start polling if tab is currently visible + if (!document.hidden) { + polling.start(); + } + + // Subscribe to force refresh events + const unsubscribe = subscribeToRefresh(() => void fetchUser(false)); + return () => { isMounted.current = false; + polling.cleanup(); + unsubscribe(); }; - }, []); - - useEffect(() => { - void refetch(); - }, [refetch]); + }, [isConfigured, fetchUser]); return { user, isLoading, error, refetch }; } @@ -295,6 +451,14 @@ interface UseTransactionsResult { /** * Hook to fetch and manage transactions list. + * + * Features: + * - Auto-polls every 30 seconds when tab is visible (if autoFetch is true) + * - Pauses polling when tab is hidden (saves bandwidth) + * - Resumes with immediate fetch when tab becomes visible + * - Supports force refresh via triggerBalanceRefresh() + * - Silent error handling during polling (doesn't disrupt UI) + * - Pagination support via fetchMore() */ export function useTransactions(options: UseTransactionsOptions = {}): UseTransactionsResult { const { pageSize = 20, autoFetch = true } = options; @@ -304,10 +468,13 @@ export function useTransactions(options: UseTransactionsOptions = {}): UseTransa const [error, setError] = useState(null); const [hasMore, setHasMore] = useState(true); const isMounted = useRef(true); + const isFetching = useRef(false); + // Fetch more transactions (pagination) - not used during polling const fetchMore = useCallback(async () => { - if (!isConfigured || isLoading || !hasMore) return; + if (!isConfigured || isFetching.current || !hasMore) return; + isFetching.current = true; setIsLoading(true); setError(null); @@ -318,7 +485,7 @@ export function useTransactions(options: UseTransactionsOptions = {}): UseTransa const newTransactions = response.map((tx) => parseTransaction(tx)); if (isMounted.current) { - setTransactions((previous) => [...previous, ...newTransactions]); + setTransactions((previous: AccountingTransaction[]) => [...previous, ...newTransactions]); setHasMore(newTransactions.length === pageSize); } } catch (error_) { @@ -326,54 +493,86 @@ export function useTransactions(options: UseTransactionsOptions = {}): UseTransa setError(error_ instanceof Error ? error_.message : 'Failed to fetch transactions'); } } finally { + isFetching.current = false; if (isMounted.current) { setIsLoading(false); } } - }, [isConfigured, isLoading, hasMore, transactions.length, pageSize]); - - const refetch = useCallback(async () => { - if (!isConfigured) { - setTransactions([]); - return; - } + }, [isConfigured, hasMore, transactions.length, pageSize]); - setIsLoading(true); - setError(null); - setHasMore(true); + // Fetch transactions (first page), with optional silent mode for polling + const fetchTransactions = useCallback( + async (isPolling = false) => { + if (!isConfigured) { + setTransactions([]); + return; + } - try { - const client = getProxyClient(); - const response = await client.getTransactions(0, pageSize); - const newTransactions = response.map((tx) => parseTransaction(tx)); + // Prevent concurrent fetches + if (isFetching.current) return; + isFetching.current = true; - if (isMounted.current) { - setTransactions(newTransactions); - setHasMore(newTransactions.length === pageSize); + // Only show loading state on initial/manual fetch, not polling + if (!isPolling) { + setIsLoading(true); + setError(null); + setHasMore(true); } - } catch (error_) { - if (isMounted.current) { - setError(error_ instanceof Error ? error_.message : 'Failed to fetch transactions'); - } - } finally { - if (isMounted.current) { - setIsLoading(false); + + try { + const client = getProxyClient(); + const response = await client.getTransactions(0, pageSize); + const newTransactions = response.map((tx) => parseTransaction(tx)); + + if (isMounted.current) { + setTransactions(newTransactions); + setHasMore(newTransactions.length === pageSize); + // Clear any previous error on successful fetch (self-healing) + setError(null); + } + } catch (error_) { + // Only show error to user on initial/manual fetch, not during polling + if (isMounted.current && !isPolling) { + setError(error_ instanceof Error ? error_.message : 'Failed to fetch transactions'); + } + } finally { + isFetching.current = false; + if (isMounted.current && !isPolling) { + setIsLoading(false); + } } - } - }, [isConfigured, pageSize]); + }, + [isConfigured, pageSize] + ); + // Manual refetch (shows loading state) + const refetch = useCallback(async () => { + await fetchTransactions(false); + }, [fetchTransactions]); + + // Visibility-aware polling effect useEffect(() => { + if (!isConfigured || !autoFetch) return; + isMounted.current = true; + + // Create polling controller + const polling = createVisibilityAwarePolling(fetchTransactions, POLLING_INTERVAL_MS); + + // Start polling if tab is currently visible + if (!document.hidden) { + polling.start(); + } + + // Subscribe to force refresh events + const unsubscribe = subscribeToRefresh(() => void fetchTransactions(false)); + return () => { isMounted.current = false; + polling.cleanup(); + unsubscribe(); }; - }, []); - - useEffect(() => { - if (autoFetch && isConfigured) { - void refetch(); - } - }, [autoFetch, isConfigured, refetch]); + }, [isConfigured, autoFetch, fetchTransactions]); return { transactions, isLoading, error, hasMore, fetchMore, refetch }; }