From 6ccc3e7ad92bcca7cf7dbf1d16850c691c71a3d0 Mon Sep 17 00:00:00 2001 From: ijeoma Date: Wed, 27 May 2026 19:36:49 +0100 Subject: [PATCH] feat: query devtools, error boundary, P&L polling, funding countdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #63 — TanStack Query DevTools in dev mode - Add @tanstack/react-query-devtools to devDependencies - Render inside QueryProvider guarded by import.meta.env.DEV; excluded from production bundle Issue #64 — Global error boundary + query error fallback UI - QueryProvider: add QueryCache onError handler that logs to console in dev and fires a Sonner toast via parseSorobanError(); auth errors (401/403/unauthorized) are silently skipped - New ErrorPage component (app/error-page.tsx) with a "Reload" button - ErrorBoundary class component wraps AppProviders; renders ErrorPage on unhandled render errors, resets state on button click Issue #65 — Real-time P&L polling for open positions - usePositions: refetchInterval 15 000 → 5 000 ms for near-real-time P&L / liquidation price updates - Add refetchIntervalInBackground: false so network requests pause when the browser tab is backgrounded; wallet disconnect already stops polling via enabled: !!account Issue #66 — Live funding countdown timer in PositionsList - New useFundingRate hook returns nextEpochTs (next 8-hour epoch) and ratePerHour; adds fundingRate query key to the central key factory - useFundingCountdown (inside PositionsList) drives a setInterval tick every second and formats the remaining time as "Xh Xm" - PositionsList: new "Next Funding" column shows the shared countdown for all open-position rows Closes #63 Closes #64 Closes #65 Closes #66 --- apps/web/package.json | 1 + apps/web/src/app/error-page.tsx | 21 ++++++++ apps/web/src/app/providers/QueryProvider.tsx | 18 ++++++- apps/web/src/app/providers/index.tsx | 38 +++++++++++--- .../components/positions/PositionsList.tsx | 49 +++++++++++++++++-- .../features/trade/hooks/useFundingRate.ts | 32 ++++++++++++ .../src/features/trade/hooks/usePositions.ts | 3 +- apps/web/src/features/trade/lib/query-keys.ts | 3 ++ 8 files changed, 151 insertions(+), 14 deletions(-) create mode 100644 apps/web/src/app/error-page.tsx create mode 100644 apps/web/src/features/trade/hooks/useFundingRate.ts diff --git a/apps/web/package.json b/apps/web/package.json index a220afd..ed69936 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -35,6 +35,7 @@ }, "devDependencies": { "@tanstack/eslint-config": "^0.3.0", + "@tanstack/react-query-devtools": "^5.100.9", "@types/node": "^25.1.0", "@types/react": "^19.2.10", "@types/react-dom": "^19.2.3", diff --git a/apps/web/src/app/error-page.tsx b/apps/web/src/app/error-page.tsx new file mode 100644 index 0000000..305f8eb --- /dev/null +++ b/apps/web/src/app/error-page.tsx @@ -0,0 +1,21 @@ +type Props = { + onReset?: () => void +} + +export function ErrorPage({ onReset }: Props) { + return ( +
+

Something went wrong

+

+ An unexpected error occurred. You can try reloading the page or contact support if the + problem persists. +

+ +
+ ) +} diff --git a/apps/web/src/app/providers/QueryProvider.tsx b/apps/web/src/app/providers/QueryProvider.tsx index afbac1b..9ce38c5 100644 --- a/apps/web/src/app/providers/QueryProvider.tsx +++ b/apps/web/src/app/providers/QueryProvider.tsx @@ -1,7 +1,22 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { QueryCache, QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { ReactQueryDevtools } from "@tanstack/react-query-devtools" import type { ReactNode } from "react" +import { toast } from "sonner" +import { parseSorobanError } from "@/lib/soroban/errors" + +function isAuthError(error: unknown): boolean { + const text = String(error).toLowerCase() + return ["401", "403", "unauthorized", "unauthenticated"].some((token) => text.includes(token)) +} export const queryClient = new QueryClient({ + queryCache: new QueryCache({ + onError(error) { + if (isAuthError(error)) return + if (import.meta.env.DEV) console.error("[query error]", error) + toast.error(parseSorobanError(error)) + }, + }), defaultOptions: { queries: { staleTime: 1000 * 30, @@ -14,6 +29,7 @@ export function QueryProvider({ children }: { children: ReactNode }) { return ( {children} + {import.meta.env.DEV && } ) } diff --git a/apps/web/src/app/providers/index.tsx b/apps/web/src/app/providers/index.tsx index 1da450f..7730c4d 100644 --- a/apps/web/src/app/providers/index.tsx +++ b/apps/web/src/app/providers/index.tsx @@ -1,9 +1,10 @@ -import { createContext, useCallback, useContext, useEffect, useRef } from "react" +import { Component, createContext, useCallback, useContext, useEffect, useRef } from "react" import { QueryProvider } from "./QueryProvider" -import type { ReactNode } from "react" +import type { ErrorInfo, ReactNode } from "react" import { useWalletStore } from "@/features/wallet/store/wallet-store" import { NETWORK } from "@/app/config/network" import { ThemeProvider } from "@/ui/theme-provider" +import { ErrorPage } from "@/app/error-page" export type WalletStatus = "disconnected" | "connecting" | "connected" | "error" @@ -97,12 +98,35 @@ export function WalletProvider({ children }: { children: ReactNode }) { ) } +type ErrorBoundaryState = { hasError: boolean } + +class ErrorBoundary extends Component<{ children: ReactNode }, ErrorBoundaryState> { + state: ErrorBoundaryState = { hasError: false } + + static getDerivedStateFromError(): ErrorBoundaryState { + return { hasError: true } + } + + componentDidCatch(error: Error, info: ErrorInfo) { + console.error("[ErrorBoundary]", error, info.componentStack) + } + + render() { + if (this.state.hasError) { + return this.setState({ hasError: false })} /> + } + return this.props.children + } +} + export function AppProviders({ children }: { children: ReactNode }) { return ( - - - {children} - - + + + + {children} + + + ) } diff --git a/apps/web/src/features/trade/components/positions/PositionsList.tsx b/apps/web/src/features/trade/components/positions/PositionsList.tsx index d983840..e85af25 100644 --- a/apps/web/src/features/trade/components/positions/PositionsList.tsx +++ b/apps/web/src/features/trade/components/positions/PositionsList.tsx @@ -1,11 +1,12 @@ -import { useState } from "react" +import { useEffect, useRef, useState } from "react" import { useQueryClient } from "@tanstack/react-query" import { Skeleton } from "@workspace/ui/components/skeleton" import { Button } from "@workspace/ui/components/button" import { Badge } from "@workspace/ui/components/badge" -import { usePositions } from "../../hooks/usePositions" +import { usePositions } from "../../hooks/usePositions" +import { useFundingRate } from "../../hooks/useFundingRate" import { createDecreaseOrder } from "../../lib/stellar" -import type {Position} from "../../hooks/usePositions"; +import type { Position } from "../../hooks/usePositions" import { formatPct, formatUsd } from "@/shared/lib/format" import { queryKeys } from "../../lib/query-keys" import { useWalletStore } from "@/features/wallet/store/wallet-store" @@ -14,8 +15,40 @@ type Props = { onSelectPosition?: (position: Position) => void } +function formatCountdown(ms: number): string { + if (ms <= 0) return "0m" + const totalSeconds = Math.floor(ms / 1000) + const h = Math.floor(totalSeconds / 3600) + const m = Math.floor((totalSeconds % 3600) / 60) + return h > 0 ? `${h}h ${m}m` : `${m}m` +} + +function useFundingCountdown(nextEpochTs: number | undefined): string { + const [remaining, setRemaining] = useState(0) + const intervalRef = useRef | null>(null) + + useEffect(() => { + if (nextEpochTs === undefined) return + + function tick() { + setRemaining(Math.max(0, nextEpochTs! - Date.now())) + } + + tick() + intervalRef.current = setInterval(tick, 1000) + + return () => { + if (intervalRef.current !== null) clearInterval(intervalRef.current) + } + }, [nextEpochTs]) + + return formatCountdown(remaining) +} + export function PositionsList({ onSelectPosition }: Props) { const { data: positions = [], isLoading } = usePositions() + const { data: fundingRate } = useFundingRate() + const countdown = useFundingCountdown(fundingRate?.nextEpochTs) const account = useWalletStore((state) => state.address) const queryClient = useQueryClient() const [closing, setClosing] = useState(null) @@ -31,14 +64,16 @@ export function PositionsList({ onSelectPosition }: Props) { marketAddress: position.marketAddress, collateralToken: position.collateralToken, collateralDeltaAmount: position.collateralAmount, - sizeDeltaUsd: position.sizeUsd, // full close + sizeDeltaUsd: position.sizeUsd, // full close isLong: position.isLong, acceptablePrice: position.markPrice, orderType: "MarketDecrease", receiveToken: position.collateralToken, }) if (account) { - await queryClient.invalidateQueries({ queryKey: queryKeys.positions("stellar-mainnet", account) }) + await queryClient.invalidateQueries({ + queryKey: queryKeys.positions("stellar-mainnet", account), + }) } } finally { setClosing(null) @@ -74,6 +109,7 @@ export function PositionsList({ onSelectPosition }: Props) { Mark Liq. PnL + Next Funding @@ -108,6 +144,9 @@ export function PositionsList({ onSelectPosition }: Props) { {formatUsd(p.pnl)} ({formatPct(p.pnlPercent)}) + + {countdown} +