From 9f58de8195e47fcdc8e2fc8b2cb6b94fceee94f3 Mon Sep 17 00:00:00 2001 From: Ayodele Daniel <74737098+temi-Dee@users.noreply.github.com> Date: Mon, 1 Jun 2026 12:37:29 +0000 Subject: [PATCH] feat: add per-widget refresh controls with stale-data indicators (#546) - Add RefreshControl + stale indicator to VaultDashboard stats panel (TVL/APY widget) using usePolling(refresh, 30s) from VaultContext - Add RefreshControl + stale indicator to Analytics page stats section using the same VaultContext refresh/lastUpdate - Add RefreshControl + stale indicator to APYTrendChart with a local refresh function that updates the lastUpdated timestamp - Fix useStaleIndicator: cache snapshot result to prevent useSyncExternalStore infinite re-render loop when getSnapshot returns new object references - Memoize lastUpdate in VaultContext to prevent unnecessary re-renders Closes #546 --- frontend/src/components/APYTrendChart.tsx | 53 +++++++++++++++++++++- frontend/src/components/VaultDashboard.tsx | 43 ++++++++++++++++++ frontend/src/context/VaultContext.tsx | 3 +- frontend/src/hooks/useStaleIndicator.ts | 44 ++++++++++++------ frontend/src/pages/Analytics.tsx | 37 ++++++++++++++- 5 files changed, 162 insertions(+), 18 deletions(-) diff --git a/frontend/src/components/APYTrendChart.tsx b/frontend/src/components/APYTrendChart.tsx index 819b3dff..fe24b64d 100644 --- a/frontend/src/components/APYTrendChart.tsx +++ b/frontend/src/components/APYTrendChart.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from "react"; +import React, { useMemo, useState, useCallback } from "react"; import { LineChart, Line, @@ -13,6 +13,9 @@ import { TrendingUp } from "./icons"; import { usePreferencesContext } from "../context/PreferencesContext"; import { formatDate } from "../lib/formatters"; import { type TimeRange, getCutoffDate, getNow } from "../lib/dateUtils"; +import RefreshControl from "./RefreshControl"; +import { usePolling } from "../hooks/usePolling"; +import { useStaleIndicator } from "../hooks/useStaleIndicator"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -110,9 +113,26 @@ const APYTrendChart: React.FC = ({ data = ALL_HISTORY }) => const [activeRange, setActiveRange] = useState("1M"); /** Which comparison windows are overlaid */ const [comparedRanges, setComparedRanges] = useState>(new Set(["7D"])); + const [lastUpdated, setLastUpdated] = useState(() => new Date()); + const [isRefetching, setIsRefetching] = useState(false); const isTest = process.env.NODE_ENV === "test"; + const refreshFn = useCallback(async () => { + setIsRefetching(true); + // APY data is static/mock; just update the timestamp to reflect a manual refresh + await new Promise((resolve) => setTimeout(resolve, 300)); + setLastUpdated(new Date()); + setIsRefetching(false); + }, []); + + const polling = usePolling(refreshFn, { + interval: 60000, + pauseOnHidden: true, + pauseOnOffline: true, + }); + const { isStale, ageText } = useStaleIndicator(lastUpdated); + /** Slice data to the active range */ const baseData = useMemo(() => { if (activeRange === "ALL") return data; @@ -286,6 +306,37 @@ const APYTrendChart: React.FC = ({ data = ALL_HISTORY }) => })} + {/* Per-widget refresh control + stale indicator */} +
+ + {isStale && ageText && ( +
+ + Data may be stale · {ageText} +
+ )} +
+ {/* Chart */}
{isTest ? ( diff --git a/frontend/src/components/VaultDashboard.tsx b/frontend/src/components/VaultDashboard.tsx index d8e5d3e1..15d3addd 100644 --- a/frontend/src/components/VaultDashboard.tsx +++ b/frontend/src/components/VaultDashboard.tsx @@ -34,6 +34,9 @@ import { TransactionConfirmationModal } from "./TransactionConfirmationModal"; import { useTranslation } from "../i18n"; import { networkConfig } from "../config/network"; import { useDashboardUrlState, type TransactionTab, type TransactionStep } from "../hooks/useDashboardUrlState"; +import RefreshControl from "./RefreshControl"; +import { usePolling } from "../hooks/usePolling"; +import { useStaleIndicator } from "../hooks/useStaleIndicator"; /** * Visual indicator for the 3-step transaction wizard. @@ -191,10 +194,19 @@ const VaultDashboard: React.FC = ({ utilization, isCapWarning, isCapReached, + lastUpdate, + refresh, } = useVault(); const toast = useToast(); const delayedLoading = useDelayedLoading(isLoading); + const statsPolling = usePolling(refresh, { + interval: 30000, + pauseOnHidden: true, + pauseOnOffline: true, + }); + const { isStale: statsIsStale, ageText: statsAgeText } = useStaleIndicator(lastUpdate); + const availableBalance = walletAddress ? usdcBalance : 0; const transactionSchema = React.useMemo>(() => ({ @@ -508,6 +520,37 @@ const VaultDashboard: React.FC = ({ }} /> + {/* Per-widget refresh control + stale indicator for stats panel */} +
+ + {statsIsStale && statsAgeText && ( +
+ + Data may be stale · {statsAgeText} +
+ )} +
+
= ({ // Normalize any query error so consumers can render an API status banner. const error: ApiError | null = queryError ? normalizeApiError(queryError) : null; - const lastUpdate = new Date(summary.updatedAt); + const lastUpdate = useMemo(() => new Date(summary.updatedAt), [summary.updatedAt]); const utilization = summary.depositCap > 0 ? summary.tvl / summary.depositCap : 0; const isCapWarning = utilization > 0.9 && utilization < 1.0; diff --git a/frontend/src/hooks/useStaleIndicator.ts b/frontend/src/hooks/useStaleIndicator.ts index 1aaadbff..68cd2cc1 100644 --- a/frontend/src/hooks/useStaleIndicator.ts +++ b/frontend/src/hooks/useStaleIndicator.ts @@ -1,4 +1,4 @@ -import { useSyncExternalStore, useCallback } from 'react'; +import { useSyncExternalStore, useCallback, useRef } from 'react'; /** Threshold in ms after which data is considered stale for display purposes. */ const STALE_THRESHOLD_MS = 60_000; // 1 minute @@ -15,27 +15,41 @@ export interface StaleIndicatorResult { ageText: string; } +function computeSnapshot(lastUpdated: Date | null | undefined): StaleIndicatorResult { + if (!lastUpdated) return { isStale: false, ageText: '' }; + + const ageMs = Date.now() - lastUpdated.getTime(); + const isStale = ageMs > STALE_THRESHOLD_MS; + + const seconds = Math.floor(ageMs / 1000); + let ageText = ''; + if (seconds < 60) { + ageText = 'just now'; + } else { + const minutes = Math.floor(seconds / 60); + ageText = minutes === 1 ? '1 min ago' : `${minutes} min ago`; + } + + return { isStale, ageText }; +} + /** * Derives a live-updating stale indicator from a `lastUpdated` timestamp. * Re-evaluates every 15 seconds via `useSyncExternalStore`. */ export function useStaleIndicator(lastUpdated: Date | null | undefined): StaleIndicatorResult { + // Cache the last returned snapshot so useSyncExternalStore gets a stable + // reference when the computed value hasn't changed (avoids infinite loops). + const cacheRef = useRef(null); + const getSnapshot = useCallback((): StaleIndicatorResult => { - if (!lastUpdated) return { isStale: false, ageText: '' }; - - const ageMs = Date.now() - lastUpdated.getTime(); - const isStale = ageMs > STALE_THRESHOLD_MS; - - const seconds = Math.floor(ageMs / 1000); - let ageText = ''; - if (seconds < 60) { - ageText = 'just now'; - } else { - const minutes = Math.floor(seconds / 60); - ageText = minutes === 1 ? '1 min ago' : `${minutes} min ago`; + const next = computeSnapshot(lastUpdated); + const prev = cacheRef.current; + if (prev && prev.isStale === next.isStale && prev.ageText === next.ageText) { + return prev; } - - return { isStale, ageText }; + cacheRef.current = next; + return next; }, [lastUpdated]); return useSyncExternalStore(subscribeToTime, getSnapshot, getSnapshot); diff --git a/frontend/src/pages/Analytics.tsx b/frontend/src/pages/Analytics.tsx index 8a1e8569..c1dd8673 100644 --- a/frontend/src/pages/Analytics.tsx +++ b/frontend/src/pages/Analytics.tsx @@ -7,9 +7,14 @@ import Skeleton from "../components/Skeleton"; import EmptyState from "../components/ui/EmptyState"; import APYTrendChart from "../components/APYTrendChart"; import { useNavigate } from "react-router-dom"; +import RefreshControl from "../components/RefreshControl"; +import { usePolling } from "../hooks/usePolling"; +import { useStaleIndicator } from "../hooks/useStaleIndicator"; const Analytics: React.FC = () => { - const { formattedTvl, tvl, summary, error, isLoading } = useVault(); + const { formattedTvl, tvl, summary, error, isLoading, lastUpdate, refresh } = useVault(); + const polling = usePolling(refresh, { interval: 30000, pauseOnHidden: true, pauseOnOffline: true }); + const { isStale, ageText } = useStaleIndicator(lastUpdate); const navigate = useNavigate(); /** @@ -40,6 +45,36 @@ const Analytics: React.FC = () => { {hasData ? ( <> + {/* Per-widget refresh control + stale indicator for analytics stats */} +
+ + {isStale && ageText && ( +
+ + Data may be stale · {ageText} +
+ )} +