diff --git a/src/hooks/useAccessibility.js b/src/hooks/useAccessibility.js deleted file mode 100644 index d6c1a082..00000000 --- a/src/hooks/useAccessibility.js +++ /dev/null @@ -1,46 +0,0 @@ -import { useEffect, useCallback } from 'react'; -import { announceToScreenReader, setFocus, registerShortcut } from '../utils/accessibility'; - -/** - * useAccessibility - React hook for accessibility features - * Handles focus management, keyboard shortcuts, and screen reader announcements - */ -export const useAccessibility = (elementId = null) => { - // Announce a message to screen readers - const announce = useCallback((message, polite = false) => { - announceToScreenReader(message, polite ? 'polite' : 'assertive'); - }, []); - - // Set focus to an element - const setElementFocus = useCallback((target) => { - if (typeof target === 'string') { - setFocus(target); - } else if (target instanceof HTMLElement) { - target.focus(); - } - }, []); - - // Register a keyboard shortcut - const registerAccessibilityShortcut = useCallback((key, handler, options = {}) => { - return registerShortcut(key, handler, { - ...options, - category: 'accessibility' - }); - }, []); - - // Auto-focus element on mount if elementId provided - useEffect(() => { - if (elementId) { - const timer = setTimeout(() => setFocus(elementId), 0); - return () => clearTimeout(timer); - } - }, [elementId]); - - return { - announce, - setFocus: setElementFocus, - registerShortcut: registerAccessibilityShortcut - }; -}; - -export default useAccessibility; diff --git a/src/hooks/useAccessibility.ts b/src/hooks/useAccessibility.ts new file mode 100644 index 00000000..3b4c8f7d --- /dev/null +++ b/src/hooks/useAccessibility.ts @@ -0,0 +1,44 @@ +import { useEffect, useCallback } from 'react'; +import { announceToScreenReader, setFocus, registerShortcut } from '../utils/accessibility'; + +export interface UseAccessibilityReturn { + announce: (message: string, polite?: boolean) => void; + setFocus: (target: string | HTMLElement) => void; + registerShortcut: (key: string, handler: () => void, options?: Record) => () => void; +} + +export const useAccessibility = (elementId: string | null = null): UseAccessibilityReturn => { + const announce = useCallback((message: string, polite = false) => { + announceToScreenReader(message, polite ? 'polite' : 'assertive'); + }, []); + + const setElementFocus = useCallback((target: string | HTMLElement) => { + if (typeof target === 'string') { + setFocus(target); + } else { + target.focus(); + } + }, []); + + const registerAccessibilityShortcut = useCallback( + (key: string, handler: () => void, options: Record = {}) => { + return registerShortcut(key, handler, { ...options, category: 'accessibility' }); + }, + [], + ); + + useEffect(() => { + if (elementId) { + const timer = setTimeout(() => setFocus(elementId), 0); + return () => clearTimeout(timer); + } + }, [elementId]); + + return { + announce, + setFocus: setElementFocus, + registerShortcut: registerAccessibilityShortcut, + }; +}; + +export default useAccessibility; diff --git a/src/hooks/useAnalytics.js b/src/hooks/useAnalytics.js deleted file mode 100644 index a2d3d13e..00000000 --- a/src/hooks/useAnalytics.js +++ /dev/null @@ -1,24 +0,0 @@ -import { useMemo } from "react"; -import { useStore } from "../lib/store"; -import { buildAnalyticsSnapshot } from "../lib/analytics"; - -export function useAnalytics() { - const { - accountData, - transactions, - operations, - networkStats, - } = useStore(); - - return useMemo(() => { - return buildAnalyticsSnapshot({ - accountData, - transactions, - operations, - networkStats, - recentLedgers: [], - }); - }, [accountData, transactions, operations, networkStats]); -} - -export default useAnalytics; diff --git a/src/hooks/useAnalytics.ts b/src/hooks/useAnalytics.ts new file mode 100644 index 00000000..ba8f8829 --- /dev/null +++ b/src/hooks/useAnalytics.ts @@ -0,0 +1,30 @@ +import { useMemo } from 'react'; +import { useStore } from '../lib/store'; +import { buildAnalyticsSnapshot } from '../lib/analytics'; + +export interface AnalyticsSnapshot { + account: Record; + transactions: Record; + network: Record; + activity: unknown[]; + risks: Record; + generatedAt: string; +} + +export function useAnalytics(): AnalyticsSnapshot { + const { accountData, transactions, operations, networkStats } = useStore(); + + return useMemo( + () => + buildAnalyticsSnapshot({ + accountData, + transactions, + operations, + networkStats, + recentLedgers: [], + }), + [accountData, transactions, operations, networkStats], + ); +} + +export default useAnalytics; diff --git a/src/hooks/useAssetUsdEstimates.js b/src/hooks/useAssetUsdEstimates.js deleted file mode 100644 index 1339eb22..00000000 --- a/src/hooks/useAssetUsdEstimates.js +++ /dev/null @@ -1,96 +0,0 @@ -import { useEffect, useState } from 'react' -import { fetchAssetPrice, fetchXLMPrice } from '../lib/stellar' - -function getBalanceEstimateKey(balance) { - if (balance.asset_type === 'native') return 'native' - return `${balance.asset_type}:${balance.asset_code || ''}:${balance.asset_issuer || ''}` -} - -export function formatEstimatedUsd(value) { - return new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - minimumFractionDigits: 2, - maximumFractionDigits: 4, - }).format(value) -} - -export default function useAssetUsdEstimates({ balances = [], connectedAddress, network, refreshKey }) { - const [estimates, setEstimates] = useState({}) - const balanceSignature = balances - .map((balance) => `${getBalanceEstimateKey(balance)}:${balance.balance}`) - .join('|') - - useEffect(() => { - if (!connectedAddress || balances.length === 0) { - setEstimates({}) - return - } - - let cancelled = false - - const loadEstimates = async () => { - setEstimates({}) - - try { - const xlmPrice = await fetchXLMPrice() - const pricedBalances = await Promise.all( - balances.map(async (balance) => { - const amount = parseFloat(balance.balance) - - if (!Number.isFinite(amount)) return null - - if (balance.asset_type === 'native') { - return [ - getBalanceEstimateKey(balance), - { - usd: amount * xlmPrice.usd, - amount, - priceXlm: 1, - priceUsd: xlmPrice.usd, - }, - ] - } - - try { - const assetPrice = await fetchAssetPrice(balance, network) - if (!assetPrice) return null - - return [ - getBalanceEstimateKey(balance), - { - usd: amount * assetPrice.xlm * xlmPrice.usd, - amount, - priceXlm: assetPrice.xlm, - priceUsd: assetPrice.xlm * xlmPrice.usd, - }, - ] - } catch { - return null - } - }) - ) - - if (cancelled) return - - setEstimates( - Object.fromEntries(pricedBalances.filter(Boolean)) - ) - } catch { - if (!cancelled) { - setEstimates({}) - } - } - } - - loadEstimates() - - return () => { - cancelled = true - } - }, [balanceSignature, connectedAddress, network, refreshKey]) - - return { - getEstimate: (balance) => estimates[getBalanceEstimateKey(balance)] || null, - } -} diff --git a/src/hooks/useAssetUsdEstimates.ts b/src/hooks/useAssetUsdEstimates.ts new file mode 100644 index 00000000..f98a6d80 --- /dev/null +++ b/src/hooks/useAssetUsdEstimates.ts @@ -0,0 +1,106 @@ +import { useEffect, useState } from 'react'; +import { fetchAssetPrice, fetchXLMPrice } from '../lib/stellar'; +import type { Horizon } from '@stellar/stellar-sdk'; + +export interface AssetEstimate { + usd: number; + amount: number; + priceXlm: number; + priceUsd: number; +} + +export interface UseAssetUsdEstimatesParams { + balances?: Horizon.HorizonApi.BalanceLine[]; + connectedAddress: string | null; + network: string; + refreshKey?: unknown; +} + +export interface UseAssetUsdEstimatesReturn { + getEstimate: (balance: Horizon.HorizonApi.BalanceLine) => AssetEstimate | null; +} + +function getBalanceEstimateKey(balance: Horizon.HorizonApi.BalanceLine): string { + if (balance.asset_type === 'native') return 'native'; + const b = balance as Horizon.HorizonApi.BalanceLineAsset; + return `${balance.asset_type}:${b.asset_code ?? ''}:${b.asset_issuer ?? ''}`; +} + +export function formatEstimatedUsd(value: number): string { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 2, + maximumFractionDigits: 4, + }).format(value); +} + +export default function useAssetUsdEstimates({ + balances = [], + connectedAddress, + network, + refreshKey, +}: UseAssetUsdEstimatesParams): UseAssetUsdEstimatesReturn { + const [estimates, setEstimates] = useState>({}); + + const balanceSignature = balances + .map((b) => `${getBalanceEstimateKey(b)}:${b.balance}`) + .join('|'); + + useEffect(() => { + if (!connectedAddress || balances.length === 0) { + setEstimates({}); + return; + } + + let cancelled = false; + + const loadEstimates = async () => { + setEstimates({}); + try { + const xlmPrice = await fetchXLMPrice(); + const pricedBalances = await Promise.all( + balances.map(async (balance) => { + const amount = parseFloat(balance.balance); + if (!Number.isFinite(amount)) return null; + + if (balance.asset_type === 'native') { + return [ + getBalanceEstimateKey(balance), + { usd: amount * xlmPrice.usd, amount, priceXlm: 1, priceUsd: xlmPrice.usd }, + ] as const; + } + + try { + const assetPrice = await fetchAssetPrice(balance, network); + if (!assetPrice) return null; + return [ + getBalanceEstimateKey(balance), + { + usd: amount * assetPrice.xlm * xlmPrice.usd, + amount, + priceXlm: assetPrice.xlm, + priceUsd: assetPrice.xlm * xlmPrice.usd, + }, + ] as const; + } catch { + return null; + } + }), + ); + + if (cancelled) return; + setEstimates(Object.fromEntries(pricedBalances.filter(Boolean) as [string, AssetEstimate][])); + } catch { + if (!cancelled) setEstimates({}); + } + }; + + loadEstimates(); + return () => { cancelled = true; }; + }, [balanceSignature, connectedAddress, network, refreshKey]); // eslint-disable-line react-hooks/exhaustive-deps + + return { + getEstimate: (balance) => estimates[getBalanceEstimateKey(balance)] ?? null, + }; +} diff --git a/src/hooks/useAudit.js b/src/hooks/useAudit.js deleted file mode 100644 index d0dcddc5..00000000 --- a/src/hooks/useAudit.js +++ /dev/null @@ -1,123 +0,0 @@ -/** - * Audit Hooks (#118) - * - * - useAuditLog: subscribe to the audit ring buffer with filters - * - useAuditAction: stable callback that records a fixed action type - * - useSecurityMonitor: live alerts from securityEvents - * - useAuditStats: aggregated counts for dashboards - */ - -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { - getAuditEntries, - getAuditStats, - recordAudit, - subscribeAudit, -} from '../utils/audit.js'; -import { - trackSecurityEvent, - subscribeSecurityAlerts, -} from '../lib/securityEvents.js'; - -/** - * Subscribe to audit entries with optional filters. - * Re-renders whenever a matching new entry is recorded. - * - * @param {object} [filters] Same shape as getAuditEntries - * @param {{ pollMs?: number }} [opts] - */ -export function useAuditLog(filters = {}, opts = {}) { - const { pollMs = 0 } = opts; - // Stabilise filter object across renders - const filterKey = JSON.stringify(filters); - const stableFilters = useMemo(() => filters, [filterKey]); // eslint-disable-line react-hooks/exhaustive-deps - - const [entries, setEntries] = useState(() => getAuditEntries(stableFilters)); - - const refresh = useCallback(() => { - setEntries(getAuditEntries(stableFilters)); - }, [stableFilters]); - - useEffect(() => { - refresh(); - const unsub = subscribeAudit((entry) => { - // Cheap pre-filter: only refresh if the new entry could match - if (stableFilters.category && entry.category !== stableFilters.category) return; - if (stableFilters.severity && entry.severity !== stableFilters.severity) return; - refresh(); - }); - - let interval; - if (pollMs > 0) interval = setInterval(refresh, pollMs); - - return () => { - unsub(); - if (interval) clearInterval(interval); - }; - }, [refresh, stableFilters, pollMs]); - - return { entries, refresh }; -} - -/** - * Returns a stable callback that records an audit entry with a preset action. - * Useful for buttons or event handlers that always log the same action. - * - * @example - * const logExport = useAuditAction('data.export', { category: 'export' }); - * - */ -export function useAuditAction(action, defaults = {}) { - return useCallback( - (overrides = {}) => - recordAudit({ action, ...defaults, ...overrides }), - [action, JSON.stringify(defaults)], // eslint-disable-line react-hooks/exhaustive-deps - ); -} - -/** - * Returns a stable callback for tracking security events. - * - * @example - * const trackLoginFail = useSecurityEvent(SecurityEventType.AUTH_LOGIN_FAILED); - * trackLoginFail({ actor: address, metadata: { reason: 'bad-sig' } }); - */ -export function useSecurityEvent(eventType, defaults = {}) { - return useCallback( - (overrides = {}) => trackSecurityEvent(eventType, { ...defaults, ...overrides }), - [eventType, JSON.stringify(defaults)], // eslint-disable-line react-hooks/exhaustive-deps - ); -} - -/** - * Subscribe to live security alerts (anomaly detector output). - * Returns the latest N alerts in chronological order (newest first). - */ -export function useSecurityMonitor(maxAlerts = 20) { - const [alerts, setAlerts] = useState([]); - - useEffect(() => { - const unsub = subscribeSecurityAlerts((alert) => { - setAlerts((prev) => [{ ...alert, at: Date.now() }, ...prev].slice(0, maxAlerts)); - }); - return unsub; - }, [maxAlerts]); - - const clear = useCallback(() => setAlerts([]), []); - - return { alerts, clear }; -} - -/** - * Aggregated audit stats for dashboards. Refreshes on every new entry. - */ -export function useAuditStats() { - const [stats, setStats] = useState(() => getAuditStats()); - - useEffect(() => { - const unsub = subscribeAudit(() => setStats(getAuditStats())); - return unsub; - }, []); - - return stats; -} diff --git a/src/hooks/useAudit.ts b/src/hooks/useAudit.ts new file mode 100644 index 00000000..04638f60 --- /dev/null +++ b/src/hooks/useAudit.ts @@ -0,0 +1,132 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + getAuditEntries, + getAuditStats, + recordAudit, + subscribeAudit, +} from '../utils/audit.js'; +import { trackSecurityEvent, subscribeSecurityAlerts } from '../lib/securityEvents.js'; + +export interface AuditEntry { + id: string; + action: string; + category?: string; + severity?: string; + timestamp: number; + metadata?: Record; + [key: string]: unknown; +} + +export interface AuditFilters { + category?: string; + severity?: string; + [key: string]: unknown; +} + +export interface AuditStats { + total: number; + bySeverity: Record; + byCategory: Record; + [key: string]: unknown; +} + +export interface SecurityAlert { + type: string; + at: number; + [key: string]: unknown; +} + +export interface UseAuditLogReturn { + entries: AuditEntry[]; + refresh: () => void; +} + +export interface UseAuditLogOptions { + pollMs?: number; +} + +export function useAuditLog( + filters: AuditFilters = {}, + opts: UseAuditLogOptions = {}, +): UseAuditLogReturn { + const { pollMs = 0 } = opts; + const filterKey = JSON.stringify(filters); + const stableFilters = useMemo(() => filters, [filterKey]); // eslint-disable-line react-hooks/exhaustive-deps + + const [entries, setEntries] = useState(() => getAuditEntries(stableFilters)); + + const refresh = useCallback(() => { + setEntries(getAuditEntries(stableFilters)); + }, [stableFilters]); + + useEffect(() => { + refresh(); + const unsub = subscribeAudit((entry: AuditEntry) => { + if (stableFilters.category && entry.category !== stableFilters.category) return; + if (stableFilters.severity && entry.severity !== stableFilters.severity) return; + refresh(); + }); + + let interval: ReturnType | undefined; + if (pollMs > 0) interval = setInterval(refresh, pollMs); + + return () => { + unsub(); + if (interval) clearInterval(interval); + }; + }, [refresh, stableFilters, pollMs]); + + return { entries, refresh }; +} + +export function useAuditAction( + action: string, + defaults: Record = {}, +): (overrides?: Record) => void { + return useCallback( + (overrides: Record = {}) => recordAudit({ action, ...defaults, ...overrides }), + [action, JSON.stringify(defaults)], // eslint-disable-line react-hooks/exhaustive-deps + ); +} + +export function useSecurityEvent( + eventType: string, + defaults: Record = {}, +): (overrides?: Record) => void { + return useCallback( + (overrides: Record = {}) => + trackSecurityEvent(eventType, { ...defaults, ...overrides }), + [eventType, JSON.stringify(defaults)], // eslint-disable-line react-hooks/exhaustive-deps + ); +} + +export interface UseSecurityMonitorReturn { + alerts: SecurityAlert[]; + clear: () => void; +} + +export function useSecurityMonitor(maxAlerts = 20): UseSecurityMonitorReturn { + const [alerts, setAlerts] = useState([]); + + useEffect(() => { + const unsub = subscribeSecurityAlerts((alert: Omit) => { + setAlerts((prev) => [{ ...alert, at: Date.now() }, ...prev].slice(0, maxAlerts)); + }); + return unsub; + }, [maxAlerts]); + + const clear = useCallback(() => setAlerts([]), []); + + return { alerts, clear }; +} + +export function useAuditStats(): AuditStats { + const [stats, setStats] = useState(() => getAuditStats()); + + useEffect(() => { + const unsub = subscribeAudit(() => setStats(getAuditStats())); + return unsub; + }, []); + + return stats; +} diff --git a/src/hooks/useCachedData.js b/src/hooks/useCachedData.js deleted file mode 100644 index 1f825205..00000000 --- a/src/hooks/useCachedData.js +++ /dev/null @@ -1,375 +0,0 @@ -/** - * useCachedData — React hooks for intelligent data fetching with caching - * - * Hooks exported: - * useCachedData — generic SWR-style fetch with L1/L2 cache - * useCachedAccount — Stellar account with tag-based invalidation - * useCachedTransactions — paginated transaction history - * useCachedNetworkStats — network stats with short TTL - * useCachedPaginatedData — generic paginated fetch - * useCachedItem — single-item fetch by id - * useOfflineStatus — online/offline state + queue length - * useCacheStats — live cache statistics for a debug panel - */ - -import { useState, useEffect, useCallback, useRef, useSyncExternalStore } from 'react'; -import cache, { TTL, isOffline } from '../lib/cache.js'; -import { - getCachedApiResponse, - setCachedApiResponse, - getOfflineQueue, -} from '../lib/storage.js'; - -// ─── Internal helpers ───────────────────────────────────────────────────────── - -function noop() {} - -/** - * Deduplicate in-flight requests: if two hooks request the same key - * simultaneously, only one network call is made. - */ -const _inflight = new Map(); // key → Promise - -async function deduplicatedFetch(key, fetcher) { - if (_inflight.has(key)) return _inflight.get(key); - const p = fetcher().finally(() => _inflight.delete(key)); - _inflight.set(key, p); - return p; -} - -// ─── useCachedData ──────────────────────────────────────────────────────────── - -/** - * Generic SWR-style hook. - * - * @param {string|null} cacheKey Unique key. Pass null to skip fetching. - * @param {Function} fetchFn async () => data - * @param {object} [opts] - * @param {number} [opts.ttl] TTL in ms (default: TTL.ACCOUNT) - * @param {string[]} [opts.tags] Cache tags for invalidation - * @param {boolean} [opts.enabled] Set false to pause fetching - * @param {boolean} [opts.persist] Also read/write IndexedDB - * @param {number} [opts.refreshInterval] Auto-refresh interval in ms - * @param {Array} [opts.deps] Extra deps that trigger refetch - * @param {Function} [opts.onSuccess] (data) => void - * @param {Function} [opts.onError] (err) => void - * - * @returns {{ data, loading, error, stale, source, refetch, invalidate }} - */ -export function useCachedData(cacheKey, fetchFn, opts = {}) { - const { - ttl = TTL.ACCOUNT, - tags = [], - enabled = true, - persist = false, - refreshInterval = 0, - deps = [], - onSuccess = noop, - onError = noop, - } = opts; - - const [data, setData] = useState(() => (cacheKey ? cache.get(cacheKey) : null)); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [stale, setStale] = useState(false); - const [source, setSource] = useState('init'); - - const mountedRef = useRef(true); - const fetchFnRef = useRef(fetchFn); - fetchFnRef.current = fetchFn; - - const doFetch = useCallback(async (skipCache = false) => { - if (!cacheKey || !enabled) return; - - // 1. Try L1 memory cache - if (!skipCache) { - const { value, stale: isStale, source: src } = await cache.getWithFallback(cacheKey); - if (value !== null) { - if (mountedRef.current) { - setData(value); - setStale(isStale); - setSource(src); - setLoading(false); - setError(null); - } - if (!isStale) return; // Fresh — no network needed - // Stale — continue to background refresh below - } - - // 2. Try L2 IndexedDB if persist=true - if (persist && !value) { - const stored = await getCachedApiResponse(cacheKey); - if (stored !== null) { - if (mountedRef.current) { - setData(stored); - setStale(true); - setSource('indexeddb'); - setLoading(false); - } - // Still refresh in background - } - } - } - - // 3. Network fetch (deduplicated) - if (!mountedRef.current) return; - setLoading(true); - - try { - const fresh = await deduplicatedFetch(cacheKey, () => fetchFnRef.current()); - cache.set(cacheKey, fresh, ttl, tags); - if (persist) setCachedApiResponse(cacheKey, fresh, ttl).catch(noop); - - if (mountedRef.current) { - setData(fresh); - setStale(false); - setSource('network'); - setLoading(false); - setError(null); - onSuccess(fresh); - } - } catch (err) { - if (mountedRef.current) { - setError(err); - setLoading(false); - onError(err); - } - } - }, [cacheKey, enabled, persist, ttl, tags.join(',')]); // eslint-disable-line - - // Initial fetch + dep changes - useEffect(() => { - mountedRef.current = true; - doFetch(); - return () => { mountedRef.current = false; }; - }, [cacheKey, enabled, ...deps]); // eslint-disable-line - - // Auto-refresh interval - useEffect(() => { - if (!refreshInterval || !enabled || !cacheKey) return; - const id = setInterval(() => doFetch(true), refreshInterval); - return () => clearInterval(id); - }, [refreshInterval, enabled, cacheKey]); // eslint-disable-line - - // Subscribe to cache updates from other hooks / background refreshes - useEffect(() => { - if (!cacheKey) return; - return cache.subscribe(cacheKey, (value) => { - if (mountedRef.current) { - setData(value); - setStale(false); - setSource('subscription'); - } - }); - }, [cacheKey]); - - const refetch = useCallback(() => doFetch(true), [doFetch]); - const invalidate = useCallback(() => { - if (cacheKey) cache.delete(cacheKey); - doFetch(true); - }, [cacheKey, doFetch]); - - return { data, loading, error, stale, source, refetch, invalidate }; -} - -// ─── useCachedAccount ───────────────────────────────────────────────────────── - -/** - * Fetch and cache a Stellar account. Automatically invalidates when - * the connected address or network changes. - * - * @param {string|null} publicKey - * @param {string} network - * @param {Function} fetcher async (publicKey, network) => accountData - */ -export function useCachedAccount(publicKey, network, fetcher) { - const key = publicKey ? `account:${publicKey}:${network}` : null; - - return useCachedData( - key, - useCallback(() => fetcher(publicKey, network), [publicKey, network]), // eslint-disable-line - { - ttl: TTL.ACCOUNT, - tags: ['account', `account:${publicKey}`], - persist: true, - enabled: !!publicKey, - } - ); -} - -// ─── useCachedTransactions ──────────────────────────────────────────────────── - -/** - * Paginated transaction history with cursor-based paging. - * - * @param {string|null} publicKey - * @param {string} network - * @param {Function} fetcher async (publicKey, network, limit, cursor) => { records, nextCursor, hasMore } - * @param {number} [limit] - */ -export function useCachedTransactions(publicKey, network, fetcher, limit = 20) { - const [cursor, setCursor] = useState(null); - const [allData, setAllData] = useState([]); - const [hasMore, setHasMore] = useState(true); - - const key = publicKey ? `transactions:${publicKey}:${network}:${limit}:${cursor}` : null; - - const { data, loading, error, refetch } = useCachedData( - key, - useCallback( - () => fetcher(publicKey, network, limit, cursor), - [publicKey, network, limit, cursor] // eslint-disable-line - ), - { ttl: TTL.TRANSACTIONS, tags: ['transactions', `account:${publicKey}`], enabled: !!publicKey } - ); - - useEffect(() => { - if (!data) return; - const records = data.records || data; - setAllData((prev) => { - const ids = new Set(prev.map((r) => r.id)); - return [...prev, ...records.filter((r) => !ids.has(r.id))]; - }); - setHasMore(data.hasMore ?? records.length === limit); - }, [data]); - - // Reset when account/network changes - useEffect(() => { - setAllData([]); - setCursor(null); - setHasMore(true); - }, [publicKey, network]); - - const loadMore = useCallback(() => { - if (!loading && hasMore && data?.nextCursor) { - setCursor(data.nextCursor); - } - }, [loading, hasMore, data]); - - return { data: allData, loading, error, hasMore, loadMore, refetch }; -} - -// ─── useCachedNetworkStats ──────────────────────────────────────────────────── - -/** - * Network stats with a short TTL and optional auto-refresh. - * - * @param {string} network - * @param {Function} fetcher async (network) => stats - * @param {number} [refreshInterval] ms between auto-refreshes (0 = off) - */ -export function useCachedNetworkStats(network, fetcher, refreshInterval = 0) { - const key = `networkStats:${network}`; - return useCachedData( - key, - useCallback(() => fetcher(network), [network]), // eslint-disable-line - { ttl: TTL.LEDGER, tags: ['network'], refreshInterval } - ); -} - -// ─── useCachedPaginatedData ─────────────────────────────────────────────────── - -/** - * Generic paginated hook (page-number based). - */ -export function useCachedPaginatedData(cacheKey, fetchFn, opts = {}) { - const { limit = 20, ...rest } = opts; - const [page, setPage] = useState(1); - const [hasMore, setHasMore] = useState(true); - - const pagedKey = cacheKey ? `${cacheKey}:p${page}:l${limit}` : null; - - const result = useCachedData( - pagedKey, - useCallback(() => fetchFn({ page, limit }), [page, limit]), // eslint-disable-line - { ...rest } - ); - - useEffect(() => { - if (result.data && result.data.length < limit) setHasMore(false); - }, [result.data, limit]); - - const loadMore = useCallback(() => { - if (!result.loading && hasMore) setPage((p) => p + 1); - }, [result.loading, hasMore]); - - return { ...result, data: result.data || [], page, limit, hasMore, loadMore, setPage }; -} - -// ─── useCachedItem ──────────────────────────────────────────────────────────── - -/** - * Fetch a single item by id with caching. - */ -export function useCachedItem(cacheKeyPrefix, id, fetchFn, opts = {}) { - const key = id != null ? `${cacheKeyPrefix}:${id}` : null; - return useCachedData( - key, - useCallback(() => fetchFn(id), [id]), // eslint-disable-line - { enabled: id != null, ...opts } - ); -} - -// ─── useOfflineStatus ───────────────────────────────────────────────────────── - -/** - * Returns { online, queueLength } and updates reactively. - */ -export function useOfflineStatus() { - const [online, setOnline] = useState(() => - typeof navigator !== 'undefined' ? navigator.onLine : true - ); - const [queueLength, setQueueLength] = useState(0); - - useEffect(() => { - const onOnline = () => setOnline(true); - const onOffline = () => setOnline(false); - window.addEventListener('online', onOnline); - window.addEventListener('offline', onOffline); - return () => { - window.removeEventListener('online', onOnline); - window.removeEventListener('offline', onOffline); - }; - }, []); - - // Poll queue length every 5 s - useEffect(() => { - const refresh = () => getOfflineQueue().then((q) => setQueueLength(q.length)).catch(noop); - refresh(); - const id = setInterval(refresh, 5_000); - return () => clearInterval(id); - }, []); - - return { online, queueLength }; -} - -// ─── useCacheStats ──────────────────────────────────────────────────────────── - -/** - * Live cache statistics — useful for a debug/diagnostics panel. - * Refreshes every `interval` ms. - */ -export function useCacheStats(interval = 2_000) { - const [stats, setStats] = useState(() => cache.getStats()); - - useEffect(() => { - const id = setInterval(() => setStats(cache.getStats()), interval); - return () => clearInterval(id); - }, [interval]); - - const clearAll = useCallback(() => { - cache.clear(); - setStats(cache.getStats()); - }, []); - - const invalidateTag = useCallback((tag) => { - cache.invalidateTag(tag); - setStats(cache.getStats()); - }, []); - - return { stats, clearAll, invalidateTag }; -} - -// ─── Default export ─────────────────────────────────────────────────────────── - -export default useCachedData; diff --git a/src/hooks/useCachedData.ts b/src/hooks/useCachedData.ts new file mode 100644 index 00000000..b31ae6a7 --- /dev/null +++ b/src/hooks/useCachedData.ts @@ -0,0 +1,355 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import cache, { TTL, isOffline } from '../lib/cache.js'; +import { + getCachedApiResponse, + setCachedApiResponse, + getOfflineQueue, +} from '../lib/storage.js'; + +function noop() {} + +const _inflight = new Map>(); + +async function deduplicatedFetch(key: string, fetcher: () => Promise): Promise { + if (_inflight.has(key)) return _inflight.get(key) as Promise; + const p = fetcher().finally(() => _inflight.delete(key)); + _inflight.set(key, p as Promise); + return p; +} + +export interface UseCachedDataOptions { + ttl?: number; + tags?: string[]; + enabled?: boolean; + persist?: boolean; + refreshInterval?: number; + deps?: unknown[]; + onSuccess?: (data: T) => void; + onError?: (err: Error) => void; +} + +export interface UseCachedDataReturn { + data: T | null; + loading: boolean; + error: Error | null; + stale: boolean; + source: string; + refetch: () => void; + invalidate: () => void; +} + +export function useCachedData( + cacheKey: string | null, + fetchFn: () => Promise, + opts: UseCachedDataOptions = {}, +): UseCachedDataReturn { + const { + ttl = TTL.ACCOUNT, + tags = [], + enabled = true, + persist = false, + refreshInterval = 0, + deps = [], + onSuccess = noop, + onError = noop, + } = opts; + + const [data, setData] = useState(() => (cacheKey ? cache.get(cacheKey) : null)); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [stale, setStale] = useState(false); + const [source, setSource] = useState('init'); + + const mountedRef = useRef(true); + const fetchFnRef = useRef(fetchFn); + fetchFnRef.current = fetchFn; + + const doFetch = useCallback( + async (skipCache = false) => { + if (!cacheKey || !enabled) return; + + if (!skipCache) { + const { value, stale: isStale, source: src } = await cache.getWithFallback(cacheKey); + if (value !== null) { + if (mountedRef.current) { + setData(value as T); + setStale(isStale); + setSource(src); + setLoading(false); + setError(null); + } + if (!isStale) return; + } + + if (persist && !value) { + const stored = await getCachedApiResponse(cacheKey); + if (stored !== null) { + if (mountedRef.current) { + setData(stored as T); + setStale(true); + setSource('indexeddb'); + setLoading(false); + } + } + } + } + + if (!mountedRef.current) return; + setLoading(true); + + try { + const fresh = await deduplicatedFetch(cacheKey, () => fetchFnRef.current()); + cache.set(cacheKey, fresh, ttl, tags); + if (persist) setCachedApiResponse(cacheKey, fresh, ttl).catch(noop); + + if (mountedRef.current) { + setData(fresh as T); + setStale(false); + setSource('network'); + setLoading(false); + setError(null); + (onSuccess as (d: T) => void)(fresh as T); + } + } catch (err) { + if (mountedRef.current) { + setError(err as Error); + setLoading(false); + (onError as (e: Error) => void)(err as Error); + } + } + }, + [cacheKey, enabled, persist, ttl, tags.join(',')], // eslint-disable-line react-hooks/exhaustive-deps + ); + + useEffect(() => { + mountedRef.current = true; + doFetch(); + return () => { mountedRef.current = false; }; + }, [cacheKey, enabled, ...deps]); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + if (!refreshInterval || !enabled || !cacheKey) return; + const id = setInterval(() => doFetch(true), refreshInterval); + return () => clearInterval(id); + }, [refreshInterval, enabled, cacheKey]); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + if (!cacheKey) return; + return cache.subscribe(cacheKey, (value: unknown) => { + if (mountedRef.current) { + setData(value as T); + setStale(false); + setSource('subscription'); + } + }); + }, [cacheKey]); + + const refetch = useCallback(() => doFetch(true), [doFetch]); + const invalidate = useCallback(() => { + if (cacheKey) cache.delete(cacheKey); + doFetch(true); + }, [cacheKey, doFetch]); + + return { data, loading, error, stale, source, refetch, invalidate }; +} + +export function useCachedAccount( + publicKey: string | null, + network: string, + fetcher: (publicKey: string, network: string) => Promise, +): UseCachedDataReturn { + const key = publicKey ? `account:${publicKey}:${network}` : null; + return useCachedData( + key, + useCallback(() => fetcher(publicKey!, network), [publicKey, network]), // eslint-disable-line react-hooks/exhaustive-deps + { ttl: TTL.ACCOUNT, tags: ['account', `account:${publicKey}`], persist: true, enabled: !!publicKey }, + ); +} + +export interface PaginatedResult { + records?: T[]; + nextCursor?: string | null; + hasMore?: boolean; +} + +export interface UseCachedTransactionsReturn extends Omit, 'data'> { + data: T[]; + hasMore: boolean; + loadMore: () => void; +} + +export function useCachedTransactions( + publicKey: string | null, + network: string, + fetcher: (publicKey: string, network: string, limit: number, cursor: string | null) => Promise>, + limit = 20, +): UseCachedTransactionsReturn { + const [cursor, setCursor] = useState(null); + const [allData, setAllData] = useState([]); + const [hasMore, setHasMore] = useState(true); + + const key = publicKey ? `transactions:${publicKey}:${network}:${limit}:${cursor}` : null; + + const { data, loading, error, refetch } = useCachedData>( + key, + useCallback(() => fetcher(publicKey!, network, limit, cursor), [publicKey, network, limit, cursor]), // eslint-disable-line react-hooks/exhaustive-deps + { ttl: TTL.TRANSACTIONS, tags: ['transactions', `account:${publicKey}`], enabled: !!publicKey }, + ); + + useEffect(() => { + if (!data) return; + const records = (data.records ?? (data as unknown as T[])); + setAllData((prev) => { + const ids = new Set(prev.map((r) => r.id)); + return [...prev, ...records.filter((r) => !ids.has(r.id))]; + }); + setHasMore(data.hasMore ?? records.length === limit); + }, [data, limit]); + + useEffect(() => { + setAllData([]); + setCursor(null); + setHasMore(true); + }, [publicKey, network]); + + const loadMore = useCallback(() => { + if (!loading && hasMore && data?.nextCursor) setCursor(data.nextCursor); + }, [loading, hasMore, data]); + + return { data: allData, loading, error, hasMore, loadMore, refetch, stale: false, source: 'cache', invalidate: refetch }; +} + +export function useCachedNetworkStats( + network: string, + fetcher: (network: string) => Promise, + refreshInterval = 0, +): UseCachedDataReturn { + const key = `networkStats:${network}`; + return useCachedData( + key, + useCallback(() => fetcher(network), [network]), // eslint-disable-line react-hooks/exhaustive-deps + { ttl: TTL.LEDGER, tags: ['network'], refreshInterval }, + ); +} + +export interface UseCachedPaginatedDataReturn extends UseCachedDataReturn { + data: T[]; + page: number; + limit: number; + hasMore: boolean; + loadMore: () => void; + setPage: (page: number) => void; +} + +export function useCachedPaginatedData( + cacheKey: string | null, + fetchFn: (params: { page: number; limit: number }) => Promise, + opts: UseCachedDataOptions & { limit?: number } = {}, +): UseCachedPaginatedDataReturn { + const { limit = 20, ...rest } = opts; + const [page, setPage] = useState(1); + const [hasMore, setHasMore] = useState(true); + + const pagedKey = cacheKey ? `${cacheKey}:p${page}:l${limit}` : null; + + const result = useCachedData( + pagedKey, + useCallback(() => fetchFn({ page, limit }), [page, limit]), // eslint-disable-line react-hooks/exhaustive-deps + rest, + ); + + useEffect(() => { + if (result.data && result.data.length < limit) setHasMore(false); + }, [result.data, limit]); + + const loadMore = useCallback(() => { + if (!result.loading && hasMore) setPage((p) => p + 1); + }, [result.loading, hasMore]); + + return { ...result, data: result.data ?? [], page, limit, hasMore, loadMore, setPage }; +} + +export function useCachedItem( + cacheKeyPrefix: string, + id: string | number | null | undefined, + fetchFn: (id: string | number) => Promise, + opts: UseCachedDataOptions = {}, +): UseCachedDataReturn { + const key = id != null ? `${cacheKeyPrefix}:${id}` : null; + return useCachedData( + key, + useCallback(() => fetchFn(id!), [id]), // eslint-disable-line react-hooks/exhaustive-deps + { enabled: id != null, ...opts }, + ); +} + +export interface UseOfflineStatusReturn { + online: boolean; + queueLength: number; +} + +export function useOfflineStatus(): UseOfflineStatusReturn { + const [online, setOnline] = useState(() => + typeof navigator !== 'undefined' ? navigator.onLine : true, + ); + const [queueLength, setQueueLength] = useState(0); + + useEffect(() => { + const onOnline = () => setOnline(true); + const onOffline = () => setOnline(false); + window.addEventListener('online', onOnline); + window.addEventListener('offline', onOffline); + return () => { + window.removeEventListener('online', onOnline); + window.removeEventListener('offline', onOffline); + }; + }, []); + + useEffect(() => { + const refresh = () => + getOfflineQueue() + .then((q: unknown[]) => setQueueLength(q.length)) + .catch(noop); + refresh(); + const id = setInterval(refresh, 5_000); + return () => clearInterval(id); + }, []); + + return { online, queueLength }; +} + +export interface CacheStats { + size: number; + hits: number; + misses: number; + [key: string]: unknown; +} + +export interface UseCacheStatsReturn { + stats: CacheStats; + clearAll: () => void; + invalidateTag: (tag: string) => void; +} + +export function useCacheStats(interval = 2_000): UseCacheStatsReturn { + const [stats, setStats] = useState(() => cache.getStats()); + + useEffect(() => { + const id = setInterval(() => setStats(cache.getStats()), interval); + return () => clearInterval(id); + }, [interval]); + + const clearAll = useCallback(() => { + cache.clear(); + setStats(cache.getStats()); + }, []); + + const invalidateTag = useCallback((tag: string) => { + cache.invalidateTag(tag); + setStats(cache.getStats()); + }, []); + + return { stats, clearAll, invalidateTag }; +} + +export default useCachedData; diff --git a/src/hooks/useCollaboration.js b/src/hooks/useCollaboration.js deleted file mode 100644 index 7ef3ff47..00000000 --- a/src/hooks/useCollaboration.js +++ /dev/null @@ -1,101 +0,0 @@ -import { useState, useEffect, useCallback, useRef } from 'react' -import { getCollaborationSocket } from '../lib/websocket' -import { - createPresenceRecord, - mergePresence, - pruneStalePresence, - buildCursorPayload, - mergeSharedViewState, -} from '../utils/collaboration' - -/** - * useCollaboration (#112) - * - * Connects to the collaboration WebSocket and exposes: - * - `peers` — Map of sessionId → PresenceRecord for other connected users - * - `connected` — whether the socket is currently open - * - `broadcastTab` — share the active tab with other collaborators - * - `broadcastCursor` — share pointer position (normalised 0–1) - * - `sharedState` — merged view state from all peers - * - * @param {string | null} wsUrl WebSocket endpoint URL - * @param {string | null} connectedAddress Current user's Stellar address - */ -export function useCollaboration(wsUrl, connectedAddress) { - const [connected, setConnected] = useState(false) - const [peers, setPeers] = useState(new Map()) - const [sharedState, setSharedState] = useState({}) - const socketRef = useRef(null) - - useEffect(() => { - if (!wsUrl) return - - const socket = getCollaborationSocket(wsUrl) - socketRef.current = socket - - const unsubConnected = socket.on('connected', ({ sessionId }) => { - setConnected(true) - // Announce our presence - socket.send('presence:join', createPresenceRecord(sessionId, connectedAddress)) - }) - - const unsubDisconnected = socket.on('disconnected', () => setConnected(false)) - - const unsubPresenceJoin = socket.on('presence:join', (msg) => { - setPeers((prev) => mergePresence(prev, msg.payload)) - }) - - const unsubPresenceLeave = socket.on('presence:leave', (msg) => { - setPeers((prev) => { - const next = new Map(prev) - next.delete(msg.payload?.sessionId) - return next - }) - }) - - const unsubHeartbeat = socket.on('presence:heartbeat', (msg) => { - setPeers((prev) => mergePresence(prev, { ...msg.payload, lastSeen: Date.now() })) - }) - - const unsubViewState = socket.on('view:state', (msg) => { - setSharedState((prev) => mergeSharedViewState(prev, msg.payload ?? {})) - }) - - // Prune stale peers every 15 seconds - const pruneInterval = setInterval(() => { - setPeers((prev) => pruneStalePresence(prev, 30_000)) - }, 15_000) - - // Send heartbeat every 10 seconds - const heartbeatInterval = setInterval(() => { - if (socket.connected) { - socket.send('presence:heartbeat', { - sessionId: socket.sessionId, - address: connectedAddress ?? null, - lastSeen: Date.now(), - }) - } - }, 10_000) - - return () => { - unsubConnected() - unsubDisconnected() - unsubPresenceJoin() - unsubPresenceLeave() - unsubHeartbeat() - unsubViewState() - clearInterval(pruneInterval) - clearInterval(heartbeatInterval) - } - }, [wsUrl, connectedAddress]) - - const broadcastTab = useCallback((tab) => { - socketRef.current?.send('view:state', { activeTab: tab }) - }, []) - - const broadcastCursor = useCallback((x, y) => { - socketRef.current?.send('cursor:move', buildCursorPayload(x, y)) - }, []) - - return { connected, peers, sharedState, broadcastTab, broadcastCursor } -} diff --git a/src/hooks/useCollaboration.ts b/src/hooks/useCollaboration.ts new file mode 100644 index 00000000..60f67ff4 --- /dev/null +++ b/src/hooks/useCollaboration.ts @@ -0,0 +1,104 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { getCollaborationSocket } from '../lib/websocket'; +import { + createPresenceRecord, + mergePresence, + pruneStalePresence, + buildCursorPayload, + mergeSharedViewState, +} from '../utils/collaboration'; + +export interface PresenceRecord { + sessionId: string; + address: string | null; + joinedAt: number; + lastSeen: number; + cursor: { x: number; y: number } | null; +} + +export interface UseCollaborationReturn { + connected: boolean; + peers: Map; + sharedState: Record; + broadcastTab: (tab: string) => void; + broadcastCursor: (x: number, y: number) => void; +} + +export function useCollaboration( + wsUrl: string | null, + connectedAddress: string | null, +): UseCollaborationReturn { + const [connected, setConnected] = useState(false); + const [peers, setPeers] = useState>(new Map()); + const [sharedState, setSharedState] = useState>({}); + const socketRef = useRef | null>(null); + + useEffect(() => { + if (!wsUrl) return; + + const socket = getCollaborationSocket(wsUrl); + socketRef.current = socket; + + const unsubConnected = socket.on('connected', ({ sessionId }: { sessionId: string }) => { + setConnected(true); + socket.send('presence:join', createPresenceRecord(sessionId, connectedAddress)); + }); + + const unsubDisconnected = socket.on('disconnected', () => setConnected(false)); + + const unsubPresenceJoin = socket.on('presence:join', (msg: { payload: PresenceRecord }) => { + setPeers((prev) => mergePresence(prev, msg.payload)); + }); + + const unsubPresenceLeave = socket.on('presence:leave', (msg: { payload?: { sessionId: string } }) => { + setPeers((prev) => { + const next = new Map(prev); + next.delete(msg.payload?.sessionId ?? ''); + return next; + }); + }); + + const unsubHeartbeat = socket.on('presence:heartbeat', (msg: { payload: PresenceRecord }) => { + setPeers((prev) => mergePresence(prev, { ...msg.payload, lastSeen: Date.now() })); + }); + + const unsubViewState = socket.on('view:state', (msg: { payload?: Record }) => { + setSharedState((prev) => mergeSharedViewState(prev, msg.payload ?? {})); + }); + + const pruneInterval = setInterval(() => { + setPeers((prev) => pruneStalePresence(prev, 30_000)); + }, 15_000); + + const heartbeatInterval = setInterval(() => { + if (socket.connected) { + socket.send('presence:heartbeat', { + sessionId: socket.sessionId, + address: connectedAddress ?? null, + lastSeen: Date.now(), + }); + } + }, 10_000); + + return () => { + unsubConnected(); + unsubDisconnected(); + unsubPresenceJoin(); + unsubPresenceLeave(); + unsubHeartbeat(); + unsubViewState(); + clearInterval(pruneInterval); + clearInterval(heartbeatInterval); + }; + }, [wsUrl, connectedAddress]); + + const broadcastTab = useCallback((tab: string) => { + socketRef.current?.send('view:state', { activeTab: tab }); + }, []); + + const broadcastCursor = useCallback((x: number, y: number) => { + socketRef.current?.send('cursor:move', buildCursorPayload(x, y)); + }, []); + + return { connected, peers, sharedState, broadcastTab, broadcastCursor }; +} diff --git a/src/hooks/useDataExport.js b/src/hooks/useDataExport.ts similarity index 55% rename from src/hooks/useDataExport.js rename to src/hooks/useDataExport.ts index cb99f733..aa5730f3 100644 --- a/src/hooks/useDataExport.js +++ b/src/hooks/useDataExport.ts @@ -1,39 +1,32 @@ -/** - * useDataExport hook (#114). - * - * Provides export/import actions bound to the live Zustand store state. - */ - -import { useCallback, useState } from "react"; -import { useStore } from "../lib/store"; +import { useCallback, useState } from 'react'; +import { useStore } from '../lib/store'; import { buildBackupPayload, exportJson, exportCsv, flattenTransaction, flattenBalance, -} from "../utils/export"; -import { readFileAsText, parseBackup, validateBackupPayload, applyBackupToStore } from "../lib/import"; +} from '../utils/export'; +import { readFileAsText, parseBackup, validateBackupPayload, applyBackupToStore } from '../lib/import'; + +export interface UseDataExportReturn { + isExporting: boolean; + isImporting: boolean; + exportError: string | null; + importError: string | null; + importSuccess: boolean; + exportDashboard: () => void; + exportTransactions: (transactions: Record[]) => void; + exportBalances: (balances: Record[]) => void; + importBackup: (file: File) => Promise; +} -/** - * @returns {{ - * isExporting: boolean, - * isImporting: boolean, - * exportError: string|null, - * importError: string|null, - * importSuccess: boolean, - * exportDashboard: () => void, - * exportTransactions: (transactions: Object[]) => void, - * exportBalances: (balances: Object[]) => void, - * importBackup: (file: File) => Promise, - * }} - */ -export function useDataExport() { +export function useDataExport(): UseDataExportReturn { const store = useStore(); const [isExporting, setIsExporting] = useState(false); const [isImporting, setIsImporting] = useState(false); - const [exportError, setExportError] = useState(null); - const [importError, setImportError] = useState(null); + const [exportError, setExportError] = useState(null); + const [importError, setImportError] = useState(null); const [importSuccess, setImportSuccess] = useState(false); const exportDashboard = useCallback(() => { @@ -41,45 +34,41 @@ export function useDataExport() { setExportError(null); try { const payload = buildBackupPayload(store); - const slug = store.connectedAddress - ? store.connectedAddress.slice(0, 6) - : "dashboard"; + const slug = store.connectedAddress ? store.connectedAddress.slice(0, 6) : 'dashboard'; exportJson(payload, `stellar-${slug}-backup`); } catch (err) { - setExportError(err.message); + setExportError((err as Error).message); } finally { setIsExporting(false); } }, [store]); - const exportTransactions = useCallback((transactions) => { + const exportTransactions = useCallback((transactions: Record[]) => { setIsExporting(true); setExportError(null); try { - const rows = (transactions || []).map(flattenTransaction); - exportCsv(rows, "stellar-transactions"); + exportCsv((transactions ?? []).map(flattenTransaction), 'stellar-transactions'); } catch (err) { - setExportError(err.message); + setExportError((err as Error).message); } finally { setIsExporting(false); } }, []); - const exportBalances = useCallback((balances) => { + const exportBalances = useCallback((balances: Record[]) => { setIsExporting(true); setExportError(null); try { - const rows = (balances || []).map(flattenBalance); - exportCsv(rows, "stellar-balances"); + exportCsv((balances ?? []).map(flattenBalance), 'stellar-balances'); } catch (err) { - setExportError(err.message); + setExportError((err as Error).message); } finally { setIsExporting(false); } }, []); const importBackup = useCallback( - async (file) => { + async (file: File) => { setIsImporting(true); setImportError(null); setImportSuccess(false); @@ -92,13 +81,13 @@ export function useDataExport() { } const validationErrors = validateBackupPayload(result.data); if (validationErrors.length > 0) { - setImportError(validationErrors.join(" ")); + setImportError(validationErrors.join(' ')); return; } applyBackupToStore(result.data, store); setImportSuccess(true); } catch (err) { - setImportError(err.message); + setImportError((err as Error).message); } finally { setIsImporting(false); } diff --git a/src/hooks/useErrorHandler.js b/src/hooks/useErrorHandler.js deleted file mode 100644 index c92ad9ab..00000000 --- a/src/hooks/useErrorHandler.js +++ /dev/null @@ -1,187 +0,0 @@ -import { useState, useCallback } from 'react'; -import { handleGlobalError, retryWithBackoff } from '../utils/errorHandler'; -import { addBreadcrumb } from '../lib/errorReporting'; - -/** - * Custom hook for handling errors in components - */ -export function useErrorHandler(context = 'Component') { - const [error, setError] = useState(null); - const [isRetrying, setIsRetrying] = useState(false); - const [retryCount, setRetryCount] = useState(0); - - const handleError = useCallback((error, additionalContext = {}) => { - const errorDetails = handleGlobalError(error, context, additionalContext); - setError(errorDetails); - return errorDetails; - }, [context]); - - const clearError = useCallback(() => { - setError(null); - setRetryCount(0); - }, []); - - const retryOperation = useCallback(async (operation, maxAttempts = 3) => { - if (!operation) return; - - setIsRetrying(true); - - try { - const result = await retryWithBackoff(operation, maxAttempts, context); - clearError(); - addBreadcrumb(`Retry successful in ${context}`, 'success'); - return result; - } catch (retryError) { - const errorDetails = handleError(retryError, { - isRetry: true, - originalError: error?.originalError - }); - setRetryCount(prev => prev + 1); - throw retryError; - } finally { - setIsRetrying(false); - } - }, [context, error, handleError, clearError]); - - const withErrorHandling = useCallback((asyncOperation) => { - return async (...args) => { - try { - clearError(); - const result = await asyncOperation(...args); - addBreadcrumb(`Operation successful in ${context}`, 'info'); - return result; - } catch (error) { - handleError(error); - throw error; - } - }; - }, [context, handleError, clearError]); - - return { - error, - isRetrying, - retryCount, - handleError, - clearError, - retryOperation, - withErrorHandling, - hasError: !!error - }; -} - -/** - * Hook for handling async operations with automatic error handling - */ -export function useAsyncOperation(operation, dependencies = []) { - const [data, setData] = useState(null); - const [loading, setLoading] = useState(false); - const { error, handleError, clearError, retryOperation } = useErrorHandler('AsyncOperation'); - - const execute = useCallback(async (...args) => { - setLoading(true); - clearError(); - - try { - const result = await operation(...args); - setData(result); - return result; - } catch (error) { - handleError(error); - throw error; - } finally { - setLoading(false); - } - }, [operation, handleError, clearError, ...dependencies]); - - const retry = useCallback(async (...args) => { - return retryOperation(() => execute(...args)); - }, [retryOperation, execute]); - - return { - data, - loading, - error, - execute, - retry, - clearError - }; -} - -/** - * Hook for form validation with error handling - */ -export function useFormValidation(validationRules = {}) { - const [errors, setErrors] = useState({}); - const [touched, setTouched] = useState({}); - const { handleError } = useErrorHandler('FormValidation'); - - const validate = useCallback((values) => { - const newErrors = {}; - - Object.keys(validationRules).forEach(field => { - const rules = validationRules[field]; - const value = values[field]; - - if (rules.required && (!value || value.toString().trim() === '')) { - newErrors[field] = rules.required === true ? 'This field is required' : rules.required; - } else if (value && rules.pattern && !rules.pattern.test(value)) { - newErrors[field] = rules.patternMessage || 'Invalid format'; - } else if (value && rules.minLength && value.length < rules.minLength) { - newErrors[field] = `Minimum length is ${rules.minLength}`; - } else if (value && rules.maxLength && value.length > rules.maxLength) { - newErrors[field] = `Maximum length is ${rules.maxLength}`; - } else if (rules.custom) { - try { - const customError = rules.custom(value, values); - if (customError) { - newErrors[field] = customError; - } - } catch (error) { - handleError(error, { field, value }); - newErrors[field] = 'Validation error occurred'; - } - } - }); - - setErrors(newErrors); - return Object.keys(newErrors).length === 0; - }, [validationRules, handleError]); - - const setFieldTouched = useCallback((field, isTouched = true) => { - setTouched(prev => ({ ...prev, [field]: isTouched })); - }, []); - - const setFieldError = useCallback((field, error) => { - setErrors(prev => ({ ...prev, [field]: error })); - }, []); - - const clearFieldError = useCallback((field) => { - setErrors(prev => { - const newErrors = { ...prev }; - delete newErrors[field]; - return newErrors; - }); - }, []); - - const clearAllErrors = useCallback(() => { - setErrors({}); - setTouched({}); - }, []); - - const getFieldError = useCallback((field) => { - return touched[field] ? errors[field] : null; - }, [errors, touched]); - - return { - errors, - touched, - validate, - setFieldTouched, - setFieldError, - clearFieldError, - clearAllErrors, - getFieldError, - hasErrors: Object.keys(errors).length > 0, - hasFieldError: (field) => !!getFieldError(field) - }; -} \ No newline at end of file diff --git a/src/hooks/useErrorHandler.ts b/src/hooks/useErrorHandler.ts new file mode 100644 index 00000000..dfde851e --- /dev/null +++ b/src/hooks/useErrorHandler.ts @@ -0,0 +1,223 @@ +import { useState, useCallback } from 'react'; +import { handleGlobalError, retryWithBackoff } from '../utils/errorHandler'; +import { addBreadcrumb } from '../lib/errorReporting'; + +export interface ErrorDetails { + originalError: unknown; + message: string; + category: string; + severity: string; + isRetryable: boolean; + context: string; + timestamp: string; + [key: string]: unknown; +} + +export interface UseErrorHandlerReturn { + error: ErrorDetails | null; + isRetrying: boolean; + retryCount: number; + hasError: boolean; + handleError: (error: unknown, additionalContext?: Record) => ErrorDetails; + clearError: () => void; + retryOperation: (operation: () => Promise, maxAttempts?: number) => Promise; + withErrorHandling: (fn: (...args: T) => Promise) => (...args: T) => Promise; +} + +export function useErrorHandler(context = 'Component'): UseErrorHandlerReturn { + const [error, setError] = useState(null); + const [isRetrying, setIsRetrying] = useState(false); + const [retryCount, setRetryCount] = useState(0); + + const handleError = useCallback( + (err: unknown, additionalContext: Record = {}) => { + const errorDetails = handleGlobalError(err, context, additionalContext); + setError(errorDetails); + return errorDetails; + }, + [context], + ); + + const clearError = useCallback(() => { + setError(null); + setRetryCount(0); + }, []); + + const retryOperation = useCallback( + async (operation: () => Promise, maxAttempts = 3): Promise => { + setIsRetrying(true); + try { + const result = await retryWithBackoff(operation, maxAttempts, context); + clearError(); + addBreadcrumb(`Retry successful in ${context}`, 'success'); + return result; + } catch (retryError) { + handleError(retryError, { isRetry: true, originalError: error?.originalError }); + setRetryCount((prev) => prev + 1); + throw retryError; + } finally { + setIsRetrying(false); + } + }, + [context, error, handleError, clearError], + ); + + const withErrorHandling = useCallback( + (asyncOperation: (...args: T) => Promise) => + async (...args: T) => { + try { + clearError(); + const result = await asyncOperation(...args); + addBreadcrumb(`Operation successful in ${context}`, 'info'); + return result; + } catch (err) { + handleError(err); + throw err; + } + }, + [context, handleError, clearError], + ); + + return { error, isRetrying, retryCount, handleError, clearError, retryOperation, withErrorHandling, hasError: !!error }; +} + +export interface UseAsyncOperationReturn { + data: T | null; + loading: boolean; + error: ErrorDetails | null; + execute: (...args: unknown[]) => Promise; + retry: (...args: unknown[]) => Promise; + clearError: () => void; +} + +export function useAsyncOperation( + operation: (...args: unknown[]) => Promise, + dependencies: unknown[] = [], +): UseAsyncOperationReturn { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const { error, handleError, clearError, retryOperation } = useErrorHandler('AsyncOperation'); + + const execute = useCallback( + async (...args: unknown[]) => { + setLoading(true); + clearError(); + try { + const result = await operation(...args); + setData(result); + return result; + } catch (err) { + handleError(err); + throw err; + } finally { + setLoading(false); + } + }, + [operation, handleError, clearError, ...dependencies], // eslint-disable-line react-hooks/exhaustive-deps + ); + + const retry = useCallback( + async (...args: unknown[]) => retryOperation(() => execute(...args)), + [retryOperation, execute], + ); + + return { data, loading, error, execute, retry, clearError }; +} + +export interface ValidationRule { + required?: boolean | string; + pattern?: RegExp; + patternMessage?: string; + minLength?: number; + maxLength?: number; + custom?: (value: unknown, values: Record) => string | null | undefined; +} + +export interface UseFormValidationReturn { + errors: Record; + touched: Record; + hasErrors: boolean; + validate: (values: Record) => boolean; + setFieldTouched: (field: string, isTouched?: boolean) => void; + setFieldError: (field: string, error: string) => void; + clearFieldError: (field: string) => void; + clearAllErrors: () => void; + getFieldError: (field: string) => string | undefined; + hasFieldError: (field: string) => boolean; +} + +export function useFormValidation( + validationRules: Record = {}, +): UseFormValidationReturn { + const [errors, setErrors] = useState>({}); + const [touched, setTouched] = useState>({}); + const { handleError } = useErrorHandler('FormValidation'); + + const validate = useCallback( + (values: Record) => { + const newErrors: Record = {}; + + Object.keys(validationRules).forEach((field) => { + const rules = validationRules[field]; + const value = values[field]; + + if (rules.required && (!value || String(value).trim() === '')) { + newErrors[field] = rules.required === true ? 'This field is required' : (rules.required as string); + } else if (value && rules.pattern && !rules.pattern.test(String(value))) { + newErrors[field] = rules.patternMessage ?? 'Invalid format'; + } else if (value && rules.minLength && String(value).length < rules.minLength) { + newErrors[field] = `Minimum length is ${rules.minLength}`; + } else if (value && rules.maxLength && String(value).length > rules.maxLength) { + newErrors[field] = `Maximum length is ${rules.maxLength}`; + } else if (rules.custom) { + try { + const customError = rules.custom(value, values); + if (customError) newErrors[field] = customError; + } catch (err) { + handleError(err, { field, value }); + newErrors[field] = 'Validation error occurred'; + } + } + }); + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }, + [validationRules, handleError], + ); + + const setFieldTouched = useCallback((field: string, isTouched = true) => { + setTouched((prev) => ({ ...prev, [field]: isTouched })); + }, []); + + const setFieldError = useCallback((field: string, error: string) => { + setErrors((prev) => ({ ...prev, [field]: error })); + }, []); + + const clearFieldError = useCallback((field: string) => { + setErrors((prev) => { const next = { ...prev }; delete next[field]; return next; }); + }, []); + + const clearAllErrors = useCallback(() => { + setErrors({}); + setTouched({}); + }, []); + + const getFieldError = useCallback( + (field: string) => (touched[field] ? errors[field] : undefined), + [errors, touched], + ); + + return { + errors, + touched, + validate, + setFieldTouched, + setFieldError, + clearFieldError, + clearAllErrors, + getFieldError, + hasErrors: Object.keys(errors).length > 0, + hasFieldError: (field) => !!getFieldError(field), + }; +} diff --git a/src/hooks/useMonitoring.js b/src/hooks/useMonitoring.js deleted file mode 100644 index a4d5f355..00000000 --- a/src/hooks/useMonitoring.js +++ /dev/null @@ -1,71 +0,0 @@ -import { useEffect, useMemo, useState } from "react"; -import { - collectHealthSnapshot, - collectSystemHealthSnapshot, - computeHealthScore, - watchErrors, -} from "../utils/monitoring"; -import { alertCenter, evaluateAlertRules } from "../lib/alerts"; - -export function useMonitoring(pollIntervalMs = 15000) { - const [snapshot, setSnapshot] = useState(() => ({ - ...collectHealthSnapshot(), - networkHealth: [], - latencyHistory: [], - })); - const [errors, setErrors] = useState([]); - const [alerts, setAlerts] = useState([]); - - useEffect(() => { - const stopErrorWatch = watchErrors((error) => { - setErrors((prev) => [error, ...prev].slice(0, 30)); - }); - - let active = true; - - const refreshSnapshot = async () => { - setSnapshot((current) => ({ - ...current, - ...collectHealthSnapshot(), - })); - - try { - const systemSnapshot = await collectSystemHealthSnapshot(); - if (!active) return; - setSnapshot(systemSnapshot); - } catch (error) { - if (!active) return; - console.warn('Unable to refresh system health snapshot:', error); - } - }; - - refreshSnapshot(); - const id = setInterval(refreshSnapshot, pollIntervalMs); - - const unsubscribeAlerts = alertCenter.subscribe((items) => setAlerts(items)); - - return () => { - active = false; - stopErrorWatch(); - clearInterval(id); - unsubscribeAlerts(); - }; - }, [pollIntervalMs]); - - const score = useMemo(() => computeHealthScore(snapshot), [snapshot]); - - useEffect(() => { - alertCenter.push(evaluateAlertRules(snapshot, score)); - }, [snapshot, score]); - - return { - snapshot, - score, - alerts, - errors, - clearAlert: (id) => alertCenter.clear(id), - resetAlerts: () => alertCenter.reset(), - }; -} - -export default useMonitoring; diff --git a/src/hooks/useMonitoring.ts b/src/hooks/useMonitoring.ts new file mode 100644 index 00000000..71ba009e --- /dev/null +++ b/src/hooks/useMonitoring.ts @@ -0,0 +1,92 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + collectHealthSnapshot, + collectSystemHealthSnapshot, + computeHealthScore, + watchErrors, +} from '../utils/monitoring'; +import { alertCenter, evaluateAlertRules } from '../lib/alerts'; + +export interface HealthSnapshot { + timestamp: string; + online: boolean; + memory: Record | null; + navigation: Record | null; + networkHealth: unknown[]; + latencyHistory: unknown[]; + [key: string]: unknown; +} + +export interface AlertItem { + id: string; + severity: string; + message: string; + [key: string]: unknown; +} + +export interface UseMonitoringReturn { + snapshot: HealthSnapshot; + score: number; + alerts: AlertItem[]; + errors: Error[]; + clearAlert: (id: string) => void; + resetAlerts: () => void; +} + +export function useMonitoring(pollIntervalMs = 15000): UseMonitoringReturn { + const [snapshot, setSnapshot] = useState(() => ({ + ...collectHealthSnapshot(), + networkHealth: [], + latencyHistory: [], + })); + const [errors, setErrors] = useState([]); + const [alerts, setAlerts] = useState([]); + + useEffect(() => { + const stopErrorWatch = watchErrors((error: Error) => { + setErrors((prev) => [error, ...prev].slice(0, 30)); + }); + + let active = true; + + const refreshSnapshot = async () => { + setSnapshot((current) => ({ ...current, ...collectHealthSnapshot() })); + try { + const systemSnapshot = await collectSystemHealthSnapshot(); + if (!active) return; + setSnapshot(systemSnapshot); + } catch (error) { + if (!active) return; + console.warn('Unable to refresh system health snapshot:', error); + } + }; + + refreshSnapshot(); + const id = setInterval(refreshSnapshot, pollIntervalMs); + const unsubscribeAlerts = alertCenter.subscribe((items: AlertItem[]) => setAlerts(items)); + + return () => { + active = false; + stopErrorWatch(); + clearInterval(id); + unsubscribeAlerts(); + }; + }, [pollIntervalMs]); + + const score = useMemo(() => computeHealthScore(snapshot), [snapshot]); + + useEffect(() => { + alertCenter.push(evaluateAlertRules(snapshot, score)); + }, [snapshot, score]); + + return { + snapshot, + score, + alerts, + errors, + clearAlert: (id: string) => alertCenter.clear(id), + resetAlerts: () => alertCenter.reset(), + }; +} + +export default useMonitoring; diff --git a/src/hooks/useNotifications.js b/src/hooks/useNotifications.js deleted file mode 100644 index 05894ad5..00000000 --- a/src/hooks/useNotifications.js +++ /dev/null @@ -1,47 +0,0 @@ -import { useCallback } from 'react'; -import { useStore } from '../lib/store'; -import { generateId, NOTIFICATION_DEFAULT_TIMEOUT } from '../lib/notifications'; - -export const useNotifications = () => { - const { notifications, addNotification, removeNotification } = useStore(); - - const notify = useCallback( - (type, title, message, timeout = NOTIFICATION_DEFAULT_TIMEOUT) => { - const id = generateId(); - - addNotification({ - id, - type, - title, - message, - timeout - }); - - if (timeout !== 0) { - setTimeout(() => { - removeNotification(id); - }, timeout); - } - - return id; - }, - [addNotification, removeNotification] - ); - - const success = useCallback((title, message, timeout) => notify('success', title, message, timeout), [notify]); - const error = useCallback((title, message, timeout) => notify('error', title, message, timeout), [notify]); - const info = useCallback((title, message, timeout) => notify('info', title, message, timeout), [notify]); - const warning = useCallback((title, message, timeout) => notify('warning', title, message, timeout), [notify]); - - return { - notifications, - notify, - success, - error, - info, - warning, - remove: removeNotification - }; -}; - -export default useNotifications; diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts new file mode 100644 index 00000000..7230f303 --- /dev/null +++ b/src/hooks/useNotifications.ts @@ -0,0 +1,41 @@ +import { useCallback } from 'react'; +import { useStore } from '../lib/store'; +import { generateId, NOTIFICATION_DEFAULT_TIMEOUT } from '../lib/notifications'; +import type { Notification } from '../lib/store'; + +export type NotificationType = 'success' | 'error' | 'info' | 'warning'; + +export interface UseNotificationsReturn { + notifications: Notification[]; + notify: (type: NotificationType, title: string, message: string, timeout?: number) => string; + success: (title: string, message: string, timeout?: number) => string; + error: (title: string, message: string, timeout?: number) => string; + info: (title: string, message: string, timeout?: number) => string; + warning: (title: string, message: string, timeout?: number) => string; + remove: (id: string) => void; +} + +export const useNotifications = (): UseNotificationsReturn => { + const { notifications, addNotification, removeNotification } = useStore(); + + const notify = useCallback( + (type: NotificationType, title: string, message: string, timeout = NOTIFICATION_DEFAULT_TIMEOUT): string => { + const id = generateId(); + addNotification({ id, type, title, message, timeout }); + if (timeout !== 0) { + setTimeout(() => removeNotification(id), timeout); + } + return id; + }, + [addNotification, removeNotification], + ); + + const success = useCallback((title: string, message: string, timeout?: number) => notify('success', title, message, timeout), [notify]); + const error = useCallback((title: string, message: string, timeout?: number) => notify('error', title, message, timeout), [notify]); + const info = useCallback((title: string, message: string, timeout?: number) => notify('info', title, message, timeout), [notify]); + const warning = useCallback((title: string, message: string, timeout?: number) => notify('warning', title, message, timeout), [notify]); + + return { notifications, notify, success, error, info, warning, remove: removeNotification }; +}; + +export default useNotifications; diff --git a/src/hooks/usePerformance.js b/src/hooks/usePerformance.js deleted file mode 100644 index 75536a82..00000000 --- a/src/hooks/usePerformance.js +++ /dev/null @@ -1,96 +0,0 @@ -import { useEffect, useCallback, useState } from 'react'; -import { initPerformanceMonitoring } from '../lib/performanceMonitoring'; -import { trackPerformanceMetric, trackPageView } from '../utils/analytics'; - -/** - * usePerformance - React hook for performance monitoring and Core Web Vitals - * Measures and reports performance metrics and user interactions - */ -export const usePerformance = (componentName = null) => { - const [metrics, setMetrics] = useState(null); - const [vitals, setVitals] = useState(null); - - // Initialize performance monitoring - useEffect(() => { - initPerformanceMonitoring(); - }, []); - - // Measure component render time - const startTimer = useCallback(() => { - return performance.now(); - }, []); - - const endTimer = useCallback((startTime, metricName) => { - const duration = performance.now() - startTime; - trackPerformanceMetric(metricName || componentName, duration, 'ms'); - return duration; - }, [componentName]); - - // Track API call duration - const measureApiCall = useCallback(async (apiFunction, endpoint, method = 'GET') => { - const startTime = startTimer(); - try { - const result = await apiFunction(); - const duration = endTimer(startTime, `api_call_${endpoint}`); - trackPerformanceMetric(`${method} ${endpoint}`, duration, 'ms'); - return result; - } catch (error) { - const duration = endTimer(startTime, `api_call_${endpoint}_error`); - trackPerformanceMetric(`${method} ${endpoint} (error)`, duration, 'ms'); - throw error; - } - }, [startTimer, endTimer]); - - // Get current performance metrics - const getMetrics = useCallback(() => { - if (typeof window === 'undefined') return null; - - const perfData = window.performance; - if (!perfData) return null; - - const navigation = perfData.getEntriesByType('navigation')[0]; - if (!navigation) return null; - - return { - domContentLoaded: navigation.domContentLoadedEventEnd - navigation.domContentLoadedEventStart, - loadComplete: navigation.loadEventEnd - navigation.loadEventStart, - ttfb: navigation.responseStart - navigation.requestStart, - domInteractive: navigation.domInteractive - navigation.fetchStart, - resourcesCount: perfData.getEntriesByType('resource').length, - totalResourceSize: perfData.getEntriesByType('resource').reduce( - (sum, r) => sum + (r.transferSize || 0), - 0 - ), - }; - }, []); - - // Update metrics periodically - useEffect(() => { - const newMetrics = getMetrics(); - setMetrics(newMetrics); - - const interval = setInterval(() => { - setMetrics(getMetrics()); - }, 5000); - - return () => clearInterval(interval); - }, [getMetrics]); - - // Track page view on mount - useEffect(() => { - if (typeof window !== 'undefined' && componentName) { - trackPageView(window.location.pathname, componentName); - } - }, [componentName]); - - return { - startTimer, - endTimer, - measureApiCall, - metrics, - vitals, - getMetrics, - }; -}; - -export default usePerformance; diff --git a/src/hooks/usePerformance.ts b/src/hooks/usePerformance.ts new file mode 100644 index 00000000..eb635fe3 --- /dev/null +++ b/src/hooks/usePerformance.ts @@ -0,0 +1,89 @@ +import { useEffect, useCallback, useState } from 'react'; +import { initPerformanceMonitoring } from '../lib/performanceMonitoring'; +import { trackPerformanceMetric, trackPageView } from '../utils/analytics'; + +export interface PerformanceMetrics { + domContentLoaded: number; + loadComplete: number; + ttfb: number; + domInteractive: number; + resourcesCount: number; + totalResourceSize: number; +} + +export interface UsePerformanceReturn { + startTimer: () => number; + endTimer: (startTime: number, metricName?: string) => number; + measureApiCall: (apiFunction: () => Promise, endpoint: string, method?: string) => Promise; + metrics: PerformanceMetrics | null; + vitals: null; + getMetrics: () => PerformanceMetrics | null; +} + +export const usePerformance = (componentName: string | null = null): UsePerformanceReturn => { + const [metrics, setMetrics] = useState(null); + + useEffect(() => { + initPerformanceMonitoring(); + }, []); + + const startTimer = useCallback((): number => performance.now(), []); + + const endTimer = useCallback( + (startTime: number, metricName?: string): number => { + const duration = performance.now() - startTime; + trackPerformanceMetric(metricName ?? componentName, duration, 'ms'); + return duration; + }, + [componentName], + ); + + const measureApiCall = useCallback( + async (apiFunction: () => Promise, endpoint: string, method = 'GET'): Promise => { + const startTime = startTimer(); + try { + const result = await apiFunction(); + const duration = endTimer(startTime, `api_call_${endpoint}`); + trackPerformanceMetric(`${method} ${endpoint}`, duration, 'ms'); + return result; + } catch (error) { + const duration = endTimer(startTime, `api_call_${endpoint}_error`); + trackPerformanceMetric(`${method} ${endpoint} (error)`, duration, 'ms'); + throw error; + } + }, + [startTimer, endTimer], + ); + + const getMetrics = useCallback((): PerformanceMetrics | null => { + if (typeof window === 'undefined') return null; + const navigation = window.performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined; + if (!navigation) return null; + return { + domContentLoaded: navigation.domContentLoadedEventEnd - navigation.domContentLoadedEventStart, + loadComplete: navigation.loadEventEnd - navigation.loadEventStart, + ttfb: navigation.responseStart - navigation.requestStart, + domInteractive: navigation.domInteractive - navigation.fetchStart, + resourcesCount: window.performance.getEntriesByType('resource').length, + totalResourceSize: window.performance + .getEntriesByType('resource') + .reduce((sum, r) => sum + ((r as PerformanceResourceTiming).transferSize ?? 0), 0), + }; + }, []); + + useEffect(() => { + setMetrics(getMetrics()); + const interval = setInterval(() => setMetrics(getMetrics()), 5000); + return () => clearInterval(interval); + }, [getMetrics]); + + useEffect(() => { + if (typeof window !== 'undefined' && componentName) { + trackPageView(window.location.pathname, componentName); + } + }, [componentName]); + + return { startTimer, endTimer, measureApiCall, metrics, vitals: null, getMetrics }; +}; + +export default usePerformance; diff --git a/src/hooks/usePersistedState.js b/src/hooks/usePersistedState.js deleted file mode 100644 index a8c9c65f..00000000 --- a/src/hooks/usePersistedState.js +++ /dev/null @@ -1,71 +0,0 @@ -import { useState, useEffect, useCallback, useRef } from 'react' -import { getStoredValue, setStoredValue } from '../lib/storage' -import { onStateChange, syncState, resolveStateConflict } from '../utils/stateSync' - -/** - * Custom hook for state that persists in IndexedDB with cross-tab sync (#105). - * - * Features: - * - Hydrates from IndexedDB on mount - * - Writes to IndexedDB and broadcasts on update - * - Subscribes to cross-tab changes and merges via last-writer-wins - * - Falls back to in-memory state if IndexedDB is unavailable - * - * @param {string} key Storage key - * @param {*} defaultValue Default value when no persisted value exists - * @returns {[value, update, loaded]} - * - value — current state value - * - update — setter (accepts value or updater function) - * - loaded — true once the initial IDB hydration is complete - */ -export function usePersistedState(key, defaultValue) { - const [value, setValue] = useState(defaultValue) - const [loaded, setLoaded] = useState(false) - const valueRef = useRef(defaultValue) - - // Keep a ref in sync so the cross-tab handler always sees the latest value - useEffect(() => { valueRef.current = value }, [value]) - - // Hydrate from IndexedDB on mount - useEffect(() => { - let cancelled = false - getStoredValue(key).then((stored) => { - if (!cancelled && stored !== null) { - setValue(stored) - valueRef.current = stored - } - if (!cancelled) setLoaded(true) - }).catch(() => { - if (!cancelled) setLoaded(true) - }) - return () => { cancelled = true } - }, [key]) - - // Subscribe to cross-tab state changes (#105) - useEffect(() => { - const unsubscribe = onStateChange((changedKey, incomingValue) => { - if (changedKey !== key) return - setValue((current) => { - const merged = resolveStateConflict(current, incomingValue) - valueRef.current = merged - return merged - }) - }) - return unsubscribe - }, [key]) - - const update = useCallback((newValue) => { - setValue((prev) => { - const resolved = typeof newValue === 'function' ? newValue(prev) : newValue - valueRef.current = resolved - // Persist and broadcast to other tabs - syncState(key, resolved).catch(() => { - // Fallback: at least persist locally - setStoredValue(key, resolved).catch(() => {}) - }) - return resolved - }) - }, [key]) - - return [value, update, loaded] -} diff --git a/src/hooks/usePersistedState.ts b/src/hooks/usePersistedState.ts new file mode 100644 index 00000000..b769e5ea --- /dev/null +++ b/src/hooks/usePersistedState.ts @@ -0,0 +1,58 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { getStoredValue, setStoredValue } from '../lib/storage'; +import { onStateChange, syncState, resolveStateConflict } from '../utils/stateSync'; + +export type PersistedStateUpdater = T | ((prev: T) => T); + +export function usePersistedState( + key: string, + defaultValue: T, +): [T, (newValue: PersistedStateUpdater) => void, boolean] { + const [value, setValue] = useState(defaultValue); + const [loaded, setLoaded] = useState(false); + const valueRef = useRef(defaultValue); + + useEffect(() => { valueRef.current = value; }, [value]); + + useEffect(() => { + let cancelled = false; + getStoredValue(key) + .then((stored: T | null) => { + if (!cancelled && stored !== null) { + setValue(stored); + valueRef.current = stored; + } + if (!cancelled) setLoaded(true); + }) + .catch(() => { if (!cancelled) setLoaded(true); }); + return () => { cancelled = true; }; + }, [key]); + + useEffect(() => { + const unsubscribe = onStateChange((changedKey: string, incomingValue: T) => { + if (changedKey !== key) return; + setValue((current) => { + const merged = resolveStateConflict(current, incomingValue); + valueRef.current = merged; + return merged; + }); + }); + return unsubscribe; + }, [key]); + + const update = useCallback( + (newValue: PersistedStateUpdater) => { + setValue((prev) => { + const resolved = typeof newValue === 'function' ? (newValue as (p: T) => T)(prev) : newValue; + valueRef.current = resolved; + syncState(key, resolved).catch(() => { + setStoredValue(key, resolved).catch(() => {}); + }); + return resolved; + }); + }, + [key], + ); + + return [value, update, loaded]; +} diff --git a/src/hooks/useSearch.js b/src/hooks/useSearch.js deleted file mode 100644 index 60f2b2db..00000000 --- a/src/hooks/useSearch.js +++ /dev/null @@ -1,81 +0,0 @@ -import { useMemo, useState } from 'react'; -import { useStore } from '../lib/store'; -import { globalSearch, loadSavedSearches, saveSearch, deleteSavedSearch } from '../utils/search'; -import { applyTransactionFilters, applyOperationFilters } from '../lib/filters'; - -export function useSearch() { - const { transactions, operations, connectedAddress, searchFilters, setSearchFilters } = - useStore(); - const [query, setQuery] = useState(''); - const [savedSearches, setSavedSearches] = useState(() => loadSavedSearches()); - - const dataset = useMemo(() => { - const tx = applyTransactionFilters(transactions, searchFilters).map((item) => ({ - id: `tx-${item.id}`, - type: 'transaction', - hash: item.hash, - memo: item.memo || '', - created_at: item.created_at, - label: item.hash, - meta: `${item.operation_count || 0} ops`, - })); - - const ops = applyOperationFilters(operations, searchFilters).map((item) => ({ - id: `op-${item.id}`, - type: 'operation', - hash: item.transaction_hash || item.id, - memo: '', - created_at: item.created_at, - label: `${item.type} ${item.id}`, - meta: item.from || item.to || '', - })); - - const account = connectedAddress - ? [ - { - id: `account-${connectedAddress}`, - type: 'account', - hash: connectedAddress, - memo: '', - created_at: '', - label: connectedAddress, - meta: 'Connected wallet', - }, - ] - : []; - - return [...account, ...tx, ...ops]; - }, [transactions, operations, connectedAddress, searchFilters]); - - const results = useMemo(() => { - return globalSearch(dataset, query, ['label', 'meta', 'memo', 'hash']).slice(0, 25); - }, [dataset, query]); - - function saveCurrentSearch(name) { - setSavedSearches(saveSearch(name, query, searchFilters)); - } - - function removeSavedSearch(name) { - setSavedSearches(deleteSavedSearch(name)); - } - - function applySavedSearch(entry) { - if (!entry) return; - setQuery(entry.query || ''); - setSearchFilters(entry.filters || searchFilters); - } - - return { - query, - setQuery, - filters: searchFilters, - setFilters: setSearchFilters, - results, - savedSearches, - saveCurrentSearch, - removeSavedSearch, - applySavedSearch, - }; -} - -export default useSearch; diff --git a/src/hooks/useSearch.ts b/src/hooks/useSearch.ts new file mode 100644 index 00000000..d421fa63 --- /dev/null +++ b/src/hooks/useSearch.ts @@ -0,0 +1,90 @@ +import { useMemo, useState } from 'react'; +import { useStore } from '../lib/store'; +import { globalSearch, loadSavedSearches, saveSearch, deleteSavedSearch } from '../utils/search'; +import { applyTransactionFilters, applyOperationFilters } from '../lib/filters'; +import type { SearchFilters } from '../lib/store'; + +export interface SearchDataItem { + id: string; + type: 'transaction' | 'operation' | 'account'; + hash: string; + memo: string; + created_at: string; + label: string; + meta: string; +} + +export interface SavedSearch { + name: string; + query: string; + filters: SearchFilters; +} + +export interface UseSearchReturn { + query: string; + setQuery: (q: string) => void; + filters: SearchFilters; + setFilters: (filters: SearchFilters) => void; + results: SearchDataItem[]; + savedSearches: SavedSearch[]; + saveCurrentSearch: (name: string) => void; + removeSavedSearch: (name: string) => void; + applySavedSearch: (entry: SavedSearch | null) => void; +} + +export function useSearch(): UseSearchReturn { + const { transactions, operations, connectedAddress, searchFilters, setSearchFilters } = useStore(); + const [query, setQuery] = useState(''); + const [savedSearches, setSavedSearches] = useState(() => loadSavedSearches()); + + const dataset = useMemo(() => { + const tx = applyTransactionFilters(transactions, searchFilters).map((item: Record) => ({ + id: `tx-${item.id}`, + type: 'transaction' as const, + hash: String(item.hash ?? ''), + memo: String(item.memo ?? ''), + created_at: String(item.created_at ?? ''), + label: String(item.hash ?? ''), + meta: `${item.operation_count ?? 0} ops`, + })); + + const ops = applyOperationFilters(operations, searchFilters).map((item: Record) => ({ + id: `op-${item.id}`, + type: 'operation' as const, + hash: String(item.transaction_hash ?? item.id ?? ''), + memo: '', + created_at: String(item.created_at ?? ''), + label: `${item.type} ${item.id}`, + meta: String(item.from ?? item.to ?? ''), + })); + + const account: SearchDataItem[] = connectedAddress + ? [{ id: `account-${connectedAddress}`, type: 'account', hash: connectedAddress, memo: '', created_at: '', label: connectedAddress, meta: 'Connected wallet' }] + : []; + + return [...account, ...tx, ...ops]; + }, [transactions, operations, connectedAddress, searchFilters]); + + const results = useMemo( + () => globalSearch(dataset, query, ['label', 'meta', 'memo', 'hash']).slice(0, 25), + [dataset, query], + ); + + function saveCurrentSearch(name: string) { + setSavedSearches(saveSearch(name, query, searchFilters)); + } + + function removeSavedSearch(name: string) { + setSavedSearches(deleteSavedSearch(name)); + } + + function applySavedSearch(entry: SavedSearch | null) { + if (!entry) return; + setQuery(entry.query ?? ''); + setSearchFilters(entry.filters ?? searchFilters); + } + + return { query, setQuery, filters: searchFilters, setFilters: setSearchFilters, results, savedSearches, saveCurrentSearch, removeSavedSearch, applySavedSearch }; +} + +export default useSearch; diff --git a/src/hooks/useSettings.js b/src/hooks/useSettings.js deleted file mode 100644 index 2c7760cb..00000000 --- a/src/hooks/useSettings.js +++ /dev/null @@ -1,69 +0,0 @@ -import { useMemo, useState } from "react"; -import { - getActiveProfileName, - loadConfigProfiles, - setActiveProfileName, - upsertProfile, - removeProfile, - getEnvironmentConfig, -} from "../lib/config"; -import { - loadPreferences, - savePreferences, - updatePreference, -} from "../utils/preferences"; - -export function useSettings() { - const [profiles, setProfiles] = useState(() => loadConfigProfiles()); - const [activeProfileName, setActiveNameState] = useState(() => getActiveProfileName()); - const [preferences, setPreferences] = useState(() => loadPreferences()); - - const activeProfile = useMemo(() => { - return ( - profiles.find((profile) => profile.name === activeProfileName) || { - name: "default", - config: getEnvironmentConfig(), - } - ); - }, [profiles, activeProfileName]); - - function setActiveProfile(name) { - setActiveProfileName(name); - setActiveNameState(name); - } - - function saveProfile(name, config) { - const nextProfiles = upsertProfile(name, config); - setProfiles(nextProfiles); - setActiveProfile(name); - } - - function deleteProfile(name) { - const nextProfiles = removeProfile(name); - setProfiles(nextProfiles); - const nextActive = getActiveProfileName(); - setActiveNameState(nextActive); - } - - function setAllPreferences(nextPreferences) { - setPreferences(savePreferences(nextPreferences)); - } - - function setPreference(key, value) { - setPreferences(updatePreference(key, value)); - } - - return { - profiles, - activeProfile, - activeProfileName, - setActiveProfile, - saveProfile, - deleteProfile, - preferences, - setAllPreferences, - setPreference, - }; -} - -export default useSettings; diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts new file mode 100644 index 00000000..7991c12f --- /dev/null +++ b/src/hooks/useSettings.ts @@ -0,0 +1,82 @@ +import { useMemo, useState } from 'react'; +import { + getActiveProfileName, + loadConfigProfiles, + setActiveProfileName, + upsertProfile, + removeProfile, + getEnvironmentConfig, +} from '../lib/config'; +import { loadPreferences, savePreferences, updatePreference } from '../utils/preferences'; + +export interface AppConfig { + refreshIntervalMs: number; + enableRealtime: boolean; + enablePricePolling: boolean; + maxResults: number; + environment: string; + [key: string]: unknown; +} + +export interface ConfigProfile { + name: string; + config: AppConfig; +} + +export interface AppPreferences { + compactMode: boolean; + showAdvancedPanels: boolean; + autoRefreshDashboard: boolean; + defaultSearchScope: string; + [key: string]: unknown; +} + +export interface UseSettingsReturn { + profiles: ConfigProfile[]; + activeProfile: ConfigProfile; + activeProfileName: string; + setActiveProfile: (name: string) => void; + saveProfile: (name: string, config: AppConfig) => void; + deleteProfile: (name: string) => void; + preferences: AppPreferences; + setAllPreferences: (prefs: AppPreferences) => void; + setPreference: (key: string, value: unknown) => void; +} + +export function useSettings(): UseSettingsReturn { + const [profiles, setProfiles] = useState(() => loadConfigProfiles()); + const [activeProfileName, setActiveNameState] = useState(() => getActiveProfileName()); + const [preferences, setPreferences] = useState(() => loadPreferences()); + + const activeProfile = useMemo( + () => profiles.find((p) => p.name === activeProfileName) ?? { name: 'default', config: getEnvironmentConfig() }, + [profiles, activeProfileName], + ); + + function setActiveProfile(name: string) { + setActiveProfileName(name); + setActiveNameState(name); + } + + function saveProfile(name: string, config: AppConfig) { + setProfiles(upsertProfile(name, config)); + setActiveProfile(name); + } + + function deleteProfile(name: string) { + setProfiles(removeProfile(name)); + setActiveNameState(getActiveProfileName()); + } + + function setAllPreferences(nextPreferences: AppPreferences) { + setPreferences(savePreferences(nextPreferences)); + } + + function setPreference(key: string, value: unknown) { + setPreferences(updatePreference(key, value)); + } + + return { profiles, activeProfile, activeProfileName, setActiveProfile, saveProfile, deleteProfile, preferences, setAllPreferences, setPreference }; +} + +export default useSettings; diff --git a/src/hooks/useTranslation.js b/src/hooks/useTranslation.js deleted file mode 100644 index 4d98d1fe..00000000 --- a/src/hooks/useTranslation.js +++ /dev/null @@ -1,120 +0,0 @@ -import { useCallback } from "react"; -import { useTranslation as useI18nextTranslation } from "react-i18next"; -import { useI18nContext } from "../components/I18nProvider.jsx"; -import { RTL_LANGUAGES } from "../i18n/index.js"; - -/** - * useTranslation - * - * A thin wrapper around react-i18next's `useTranslation` that also exposes - * the language-switching helpers from `I18nProvider`. - * - * Usage: - * ```jsx - * const { t, currentLanguage, changeLanguage, supportedLanguages } = useTranslation(); - * - * // Translate a key - * t('common.loading') // → "Loading..." - * - * // Interpolation - * t('connect.successMessage', { address }) // → "Successfully connected to G..." - * - * // Namespace (defaults to 'translation') - * t('nav.overview') // → "Overview" - * - * // Switch language - * changeLanguage('es') - * ``` - * - * @param {string} [ns='translation'] - Optional i18next namespace override - * @returns {{ - * t: import('i18next').TFunction, - * i18n: import('i18next').i18n, - * currentLanguage: string, - * changeLanguage: (code: string) => Promise, - * supportedLanguages: Array<{ code: string, label: string, nativeLabel: string }>, - * isRTL: boolean, - * ready: boolean, - * }} - */ -export function useTranslation(ns = "translation") { - const { t, i18n, ready } = useI18nextTranslation(ns); - const { currentLanguage, changeLanguage, supportedLanguages, isRTL } = - useI18nContext(); - - /** - * Safe translate — returns the key itself when a translation is missing, - * which prevents blank UI during hot reloads or missing keys in dev. - */ - const safeT = useCallback( - (key, options) => { - const result = t(key, options); - return result ?? key; - }, - [t], - ); - - /** - * Pluralize helper (#107). - * Delegates to i18next count interpolation: - * tPlural('transactions.count', 3) → uses key 'transactions.count_one' or 'transactions.count_other' - * - * @param {string} key - * @param {number} count - * @param {Record} [extra] Additional interpolation values - */ - const tPlural = useCallback( - (key, count, extra = {}) => safeT(key, { count, ...extra }), - [safeT], - ); - - /** - * Format a number according to the current locale (#107). - * @param {number} value - * @param {Intl.NumberFormatOptions} [opts] - */ - const formatNumber = useCallback( - (value, opts = {}) => { - try { - return new Intl.NumberFormat(currentLanguage, opts).format(value); - } catch { - return String(value); - } - }, - [currentLanguage], - ); - - /** - * Format a date according to the current locale (#107). - * @param {Date | string | number} date - * @param {Intl.DateTimeFormatOptions} [opts] - */ - const formatDate = useCallback( - (date, opts = { dateStyle: "medium" }) => { - try { - return new Intl.DateTimeFormat(currentLanguage, opts).format(new Date(date)); - } catch { - return String(date); - } - }, - [currentLanguage], - ); - - /** true if the active language is RTL (#107) */ - const isRTLActive = isRTL || RTL_LANGUAGES.has(currentLanguage); - - return { - t: safeT, - tPlural, - formatNumber, - formatDate, - i18n, - ready, - currentLanguage, - changeLanguage, - supportedLanguages, - isRTL: isRTLActive, - }; -} - -export default useTranslation; diff --git a/src/hooks/useTranslation.ts b/src/hooks/useTranslation.ts new file mode 100644 index 00000000..dfc5c830 --- /dev/null +++ b/src/hooks/useTranslation.ts @@ -0,0 +1,70 @@ +import { useCallback } from 'react'; +import { useTranslation as useI18nextTranslation } from 'react-i18next'; +import { useI18nContext } from '../components/I18nProvider.jsx'; +import { RTL_LANGUAGES } from '../i18n/index.js'; + +export interface SupportedLanguage { + code: string; + label: string; + nativeLabel: string; +} + +export interface UseTranslationReturn { + t: (key: string, options?: Record) => string; + tPlural: (key: string, count: number, extra?: Record) => string; + formatNumber: (value: number, opts?: Intl.NumberFormatOptions) => string; + formatDate: (date: Date | string | number, opts?: Intl.DateTimeFormatOptions) => string; + i18n: unknown; + ready: boolean; + currentLanguage: string; + changeLanguage: (code: string) => Promise; + supportedLanguages: SupportedLanguage[]; + isRTL: boolean; +} + +export function useTranslation(ns = 'translation'): UseTranslationReturn { + const { t, i18n, ready } = useI18nextTranslation(ns); + const { currentLanguage, changeLanguage, supportedLanguages, isRTL } = useI18nContext(); + + const safeT = useCallback( + (key: string, options?: Record): string => { + const result = t(key, options); + return result ?? key; + }, + [t], + ); + + const tPlural = useCallback( + (key: string, count: number, extra: Record = {}): string => + safeT(key, { count, ...extra }), + [safeT], + ); + + const formatNumber = useCallback( + (value: number, opts: Intl.NumberFormatOptions = {}): string => { + try { + return new Intl.NumberFormat(currentLanguage, opts).format(value); + } catch { + return String(value); + } + }, + [currentLanguage], + ); + + const formatDate = useCallback( + (date: Date | string | number, opts: Intl.DateTimeFormatOptions = { dateStyle: 'medium' }): string => { + try { + return new Intl.DateTimeFormat(currentLanguage, opts).format(new Date(date)); + } catch { + return String(date); + } + }, + [currentLanguage], + ); + + const isRTLActive = isRTL || RTL_LANGUAGES.has(currentLanguage); + + return { t: safeT, tPlural, formatNumber, formatDate, i18n, ready, currentLanguage, changeLanguage, supportedLanguages, isRTL: isRTLActive }; +} + +export default useTranslation;