From 0f2998790087290b08cdcbc1bf02ba312ec02cfa Mon Sep 17 00:00:00 2001 From: Abdullah Azmi Date: Mon, 19 Oct 2026 02:22:37 +0000 Subject: [PATCH] feat(frontend): add stale price tooltip warning in FiatEquivalent when feed is older than 5 minutes Expose `lastUpdatedAt` from `useTokenPrice` and display an amber info indicator with an accessible tooltip (`Price estimate last updated X minutes ago`) in `FiatEquivalent` whenever `Date.now() - lastUpdatedAt > 5 * 60 * 1000`. Fixes #1640 --- frontend/src/components/FiatEquivalent.tsx | 96 ++++++++++++++-- .../__tests__/FiatEquivalent.test.tsx | 105 ++++++++++++++++++ frontend/src/hooks/useTokenPrice.ts | 18 ++- 3 files changed, 208 insertions(+), 11 deletions(-) create mode 100644 frontend/src/components/__tests__/FiatEquivalent.test.tsx diff --git a/frontend/src/components/FiatEquivalent.tsx b/frontend/src/components/FiatEquivalent.tsx index ad2b8473..ac5b2573 100644 --- a/frontend/src/components/FiatEquivalent.tsx +++ b/frontend/src/components/FiatEquivalent.tsx @@ -1,9 +1,33 @@ "use client"; - import { useTokenPrice, formatFiatAmount, formatRatePerPeriod } from "@/hooks/useTokenPrice"; import { useSettings } from "@/hooks/useSettings"; +export const STALE_PRICE_THRESHOLD_MS = 5 * 60 * 1000; + +export function getPriceStaleness( + lastUpdatedAt: number | null | undefined, + nowMs: number = Date.now(), +): { + isStale: boolean; + staleMinutes: number; + tooltip: string | null; +} { + if (typeof lastUpdatedAt !== "number" || !Number.isFinite(lastUpdatedAt)) { + return { isStale: false, staleMinutes: 0, tooltip: null }; + } + const ageMs = nowMs - lastUpdatedAt; + if (ageMs <= STALE_PRICE_THRESHOLD_MS) { + return { isStale: false, staleMinutes: 0, tooltip: null }; + } + const staleMinutes = Math.max(1, Math.floor(ageMs / 60_000)); + return { + isStale: true, + staleMinutes, + tooltip: `Price estimate last updated ${staleMinutes} minutes ago`, + }; +} + interface FiatEquivalentProps { tokenSymbol: string; amount?: string | bigint; @@ -28,7 +52,11 @@ export function FiatEquivalent({ className = "", }: FiatEquivalentProps) { const { displayCurrency } = useSettings(); - const { data: tokenPrice, isLoading } = useTokenPrice({ tokenSymbol }); + const { + data: tokenPrice, + isLoading, + lastUpdatedAt: hookLastUpdatedAt, + } = useTokenPrice({ tokenSymbol }); if (isLoading || !tokenPrice) { return ( @@ -39,12 +67,62 @@ export function FiatEquivalent({ } // Determine currency from display setting - const currency = displayCurrency === "USD" || displayCurrency === "XLM" || displayCurrency === "USDC" - ? "USD" - : displayCurrency as "USD" | "EUR" | "GBP"; + const currency = + displayCurrency === "USD" || displayCurrency === "XLM" || displayCurrency === "USDC" + ? "USD" + : (displayCurrency as "USD" | "EUR" | "GBP"); const priceKey = `price${currency}` as keyof typeof tokenPrice; - const tokenPriceForCurrency = tokenPrice[priceKey] as number || tokenPrice.priceUSD; + const candidatePrice = tokenPrice[priceKey] ?? tokenPrice.priceUSD; + const tokenPriceForCurrency = + typeof candidatePrice === "number" && Number.isFinite(candidatePrice) + ? candidatePrice + : 0; + + const resolvedLastUpdatedAt = + typeof hookLastUpdatedAt === "number" && Number.isFinite(hookLastUpdatedAt) + ? hookLastUpdatedAt + : typeof tokenPrice.timestamp === "number" && Number.isFinite(tokenPrice.timestamp) + ? tokenPrice.timestamp + : null; + + const { isStale, tooltip: staleTooltip } = getPriceStaleness( + resolvedLastUpdatedAt, + ); + + const staleWarningIndicator = + isStale && staleTooltip ? ( + + + + {staleTooltip} + + + ) : null; return (
@@ -65,12 +143,13 @@ export function FiatEquivalent({ currency )} {currency}) + {staleWarningIndicator}
)} {/* Rate display */} {(showRate || showDaily || showMonthly) && ratePerSecond && ( -
+
{showRate && ( @@ -123,6 +202,7 @@ export function FiatEquivalent({ )} + {!amount && staleWarningIndicator}
)}
@@ -165,4 +245,4 @@ export function FiatBadge({ ); -} +} \ No newline at end of file diff --git a/frontend/src/components/__tests__/FiatEquivalent.test.tsx b/frontend/src/components/__tests__/FiatEquivalent.test.tsx new file mode 100644 index 00000000..b42dd688 --- /dev/null +++ b/frontend/src/components/__tests__/FiatEquivalent.test.tsx @@ -0,0 +1,105 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; + +const mockUseTokenPrice = vi.fn(); + +vi.mock("@/hooks/useTokenPrice", async () => { + const actual = await vi.importActual( + "@/hooks/useTokenPrice", + ); + return { + ...actual, + useTokenPrice: (...args: unknown[]) => mockUseTokenPrice(...args), + }; +}); + +vi.mock("@/hooks/useSettings", () => ({ + useSettings: () => ({ displayCurrency: "USD" }), +})); + +import { FiatEquivalent } from "../FiatEquivalent"; + +describe("FiatEquivalent stale price warning", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders fresh quotes (< 5 minutes old) without a warning indicator", () => { + const freshTimestamp = Date.now() - 2 * 60 * 1000; // 2 minutes ago + mockUseTokenPrice.mockReturnValue({ + data: { + tokenSymbol: "XLM", + priceUSD: 0.12, + priceEUR: 0.11, + priceGBP: 0.09, + timestamp: freshTimestamp, + }, + isLoading: false, + lastUpdatedAt: freshTimestamp, + }); + + render(); + + expect(screen.getByText(/1,000\.00 XLM/)).toBeInTheDocument(); + expect(screen.getByText(/\(\$120\.00 USD\)|≈ \$120\.00 USD/)).toBeInTheDocument(); + expect(screen.queryByTestId("stale-price-warning")).not.toBeInTheDocument(); + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + }); + + it("displays an amber info indicator and accessible tooltip when quote is > 5 minutes old", () => { + const staleTimestamp = Date.now() - 8 * 60 * 1000; // 8 minutes ago + mockUseTokenPrice.mockReturnValue({ + data: { + tokenSymbol: "XLM", + priceUSD: 0.12, + priceEUR: 0.11, + priceGBP: 0.09, + timestamp: staleTimestamp, + }, + isLoading: false, + lastUpdatedAt: staleTimestamp, + }); + + render(); + + const warning = screen.getByTestId("stale-price-warning"); + expect(warning).toBeInTheDocument(); + expect(warning).toHaveAttribute( + "title", + "Price estimate last updated 8 minutes ago", + ); + expect(screen.getByRole("tooltip")).toHaveTextContent( + "Price estimate last updated 8 minutes ago", + ); + }); + + it("gracefully handles null or missing price data without crashing", () => { + mockUseTokenPrice.mockReturnValue({ + data: null, + isLoading: false, + lastUpdatedAt: null, + }); + + const { container, rerender } = render( + , + ); + expect(container.querySelector(".animate-pulse")).toBeInTheDocument(); + expect(screen.queryByTestId("stale-price-warning")).not.toBeInTheDocument(); + + // Missing timestamp / lastUpdatedAt on returned object should also render safely without warning + mockUseTokenPrice.mockReturnValue({ + data: { + tokenSymbol: "USDC", + priceUSD: 1.0, + priceEUR: 0.92, + priceGBP: 0.79, + }, + isLoading: false, + lastUpdatedAt: null, + }); + + rerender(); + expect(screen.getByText(/500\.00 USDC/)).toBeInTheDocument(); + expect(screen.queryByTestId("stale-price-warning")).not.toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/frontend/src/hooks/useTokenPrice.ts b/frontend/src/hooks/useTokenPrice.ts index ae779aa3..967cca02 100644 --- a/frontend/src/hooks/useTokenPrice.ts +++ b/frontend/src/hooks/useTokenPrice.ts @@ -20,7 +20,7 @@ const TOKEN_MAPPING: Record = const CACHE_TTL_MS = 60 * 1000; // 60 seconds -interface TokenPrice { +export interface TokenPrice { tokenSymbol: string; priceUSD: number; priceEUR: number; @@ -38,7 +38,7 @@ interface UseTokenPriceOptions { * Returns stablecoin parity for USDC/EURC when live data is unavailable. */ export function useTokenPrice({ tokenSymbol, enabled = true }: UseTokenPriceOptions) { - return useQuery({ + const query = useQuery({ queryKey: ["tokenPrice", tokenSymbol], queryFn: async () => { const mapping = TOKEN_MAPPING[tokenSymbol.toUpperCase()]; @@ -102,6 +102,18 @@ export function useTokenPrice({ tokenSymbol, enabled = true }: UseTokenPriceOpti enabled, refetchOnWindowFocus: false, }); + + const lastUpdatedAt: number | null = + typeof query.data?.timestamp === "number" && Number.isFinite(query.data.timestamp) + ? query.data.timestamp + : query.dataUpdatedAt > 0 + ? query.dataUpdatedAt + : null; + + return { + ...query, + lastUpdatedAt, + }; } /** @@ -159,4 +171,4 @@ export function formatRatePerPeriod( const fiatRate = ratePerSecond * tokenPrice * multipliers[period]; return formatFiatAmount(fiatRate, currency); -} +} \ No newline at end of file