Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 52 additions & 1 deletion frontend/src/components/APYTrendChart.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useMemo, useState } from "react";
import React, { useMemo, useState, useCallback } from "react";
import {
LineChart,
Line,
Expand All @@ -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 ────────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -110,9 +113,26 @@ const APYTrendChart: React.FC<APYTrendChartProps> = ({ data = ALL_HISTORY }) =>
const [activeRange, setActiveRange] = useState<TimeRange>("1M");
/** Which comparison windows are overlaid */
const [comparedRanges, setComparedRanges] = useState<Set<TimeRange>>(new Set(["7D"]));
const [lastUpdated, setLastUpdated] = useState<Date>(() => 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<void>((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;
Expand Down Expand Up @@ -286,6 +306,37 @@ const APYTrendChart: React.FC<APYTrendChartProps> = ({ data = ALL_HISTORY }) =>
})}
</div>

{/* Per-widget refresh control + stale indicator */}
<div style={{ marginBottom: "16px" }}>
<RefreshControl
isPolling={polling.isPolling}
isPaused={polling.isPaused}
pauseReason={polling.pauseReason}
onPause={polling.pause}
onResume={polling.resume}
onRefresh={polling.forceRefresh}
isRefetching={isRefetching}
lastUpdated={lastUpdated}
/>
{isStale && ageText && (
<div
role="status"
aria-live="polite"
style={{
marginTop: "6px",
display: "flex",
alignItems: "center",
gap: "6px",
fontSize: "0.75rem",
color: "var(--text-warning, #f59e0b)",
}}
>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: "var(--text-warning, #f59e0b)", flexShrink: 0 }} />
Data may be stale · {ageText}
</div>
)}
</div>

{/* Chart */}
<div style={{ height: "240px", position: "relative" }}>
{isTest ? (
Expand Down
43 changes: 43 additions & 0 deletions frontend/src/components/VaultDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,13 @@
import { useSlippage } from "../hooks/useSlippage";
import HelpIcon from "./ui/HelpIcon";
import EmptyState from "./ui/EmptyState";
import { TransactionConfirmationModal } from "./TransactionConfirmationModal";

Check failure on line 33 in frontend/src/components/VaultDashboard.tsx

View workflow job for this annotation

GitHub Actions / Frontend lint + test

'TransactionConfirmationModal' is defined but never used
import { useTranslation } from "../i18n";

Check failure on line 34 in frontend/src/components/VaultDashboard.tsx

View workflow job for this annotation

GitHub Actions / Frontend lint + test

'useTranslation' is defined but never used
import { networkConfig } from "../config/network";

Check failure on line 35 in frontend/src/components/VaultDashboard.tsx

View workflow job for this annotation

GitHub Actions / Frontend lint + test

'networkConfig' is defined but never used
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.
Expand Down Expand Up @@ -191,10 +194,19 @@
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<ValidationSchema<{ amount: string }>>(() => ({
Expand Down Expand Up @@ -227,7 +239,7 @@

const {
values,
errors,

Check failure on line 242 in frontend/src/components/VaultDashboard.tsx

View workflow job for this annotation

GitHub Actions / Frontend lint + test

'errors' is assigned a value but never used
touched,
handleChange,
handleBlur,
Expand Down Expand Up @@ -508,6 +520,37 @@
}}
/>

{/* Per-widget refresh control + stale indicator for stats panel */}
<div style={{ marginBottom: "16px" }}>
<RefreshControl
isPolling={statsPolling.isPolling}
isPaused={statsPolling.isPaused}
pauseReason={statsPolling.pauseReason}
onPause={statsPolling.pause}
onResume={statsPolling.resume}
onRefresh={statsPolling.forceRefresh}
isRefetching={isLoading}
lastUpdated={lastUpdate}
/>
{statsIsStale && statsAgeText && (
<div
role="status"
aria-live="polite"
style={{
marginTop: "6px",
display: "flex",
alignItems: "center",
gap: "6px",
fontSize: "0.75rem",
color: "var(--text-warning, #f59e0b)",
}}
>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: "var(--text-warning, #f59e0b)", flexShrink: 0 }} />
Data may be stale · {statsAgeText}
</div>
)}
</div>

<div className="vault-stats-meta flex gap-xl" style={{ marginBottom: "32px" }}>
<div>
<div
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/context/VaultContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, {
createContext,
useContext,
useEffect,
useMemo,
} from "react";
import { subscribeToApiTelemetry, normalizeApiError } from "../lib/api";
import type { ApiError } from "../lib/api";
Expand Down Expand Up @@ -75,7 +76,7 @@ export const VaultProvider: React.FC<{ children: React.ReactNode }> = ({
// 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;
Expand Down
44 changes: 29 additions & 15 deletions frontend/src/hooks/useStaleIndicator.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<StaleIndicatorResult | null>(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);
Expand Down
37 changes: 36 additions & 1 deletion frontend/src/pages/Analytics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

/**
Expand Down Expand Up @@ -40,6 +45,36 @@ const Analytics: React.FC = () => {

{hasData ? (
<>
{/* Per-widget refresh control + stale indicator for analytics stats */}
<div style={{ marginBottom: "16px" }}>
<RefreshControl
isPolling={polling.isPolling}
isPaused={polling.isPaused}
pauseReason={polling.pauseReason}
onPause={polling.pause}
onResume={polling.resume}
onRefresh={polling.forceRefresh}
isRefetching={isLoading}
lastUpdated={lastUpdate}
/>
{isStale && ageText && (
<div
role="status"
aria-live="polite"
style={{
marginTop: "6px",
display: "flex",
alignItems: "center",
gap: "6px",
fontSize: "0.75rem",
color: "var(--text-warning, #f59e0b)",
}}
>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: "var(--text-warning, #f59e0b)", flexShrink: 0 }} />
Data may be stale · {ageText}
</div>
)}
</div>
<div className="flex gap-lg" style={{ flexWrap: 'wrap' }}>
<div className="glass-panel" style={{ flex: '1 1 300px', padding: '24px', background: 'var(--bg-muted)' }}>
<div className="text-body-sm" style={{ color: 'var(--text-secondary)', display: 'flex', justifyContent: 'space-between' }}>
Expand Down
Loading