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
84 changes: 84 additions & 0 deletions src/__tests__/useClaims.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { act } from 'react';
import { useClaims } from '../hooks/useClaims';
import { renderHook, flushMicrotasks } from './renderHook';
import type { Claim } from '../types';

const { fetchUserClaims } = vi.hoisted(() => ({
fetchUserClaims: vi.fn(),
}));

vi.mock('@/lib/api', () => ({ fetchUserClaims }));

function makeClaim(overrides: Partial<Claim> = {}): Claim {
return {
id: 'claim-1',
policyId: 'policy-1',
claimant: 'GABCDEF1234567890',
triggerMet: true,
status: 'Pending',
submittedAt: 1_720_000_000,
processedAt: null,
...overrides,
};
}

describe('useClaims', () => {
beforeEach(() => {
fetchUserClaims.mockReset();
vi.useFakeTimers();
});

afterEach(() => {
vi.useRealTimers();
});

it('does not poll again while paused', async () => {
fetchUserClaims.mockResolvedValue([makeClaim()]);

const hook = renderHook(() => useClaims('GWALLET'));
await flushMicrotasks();
expect(fetchUserClaims).toHaveBeenCalledTimes(1);

act(() => hook.current.togglePause());
expect(hook.current.paused).toBe(true);

await act(async () => {
await vi.advanceTimersByTimeAsync(15_000);
});

// Still only the initial call -- no poll fired while paused.
expect(fetchUserClaims).toHaveBeenCalledTimes(1);
});

it('resumes polling after togglePause is called again', async () => {
fetchUserClaims.mockResolvedValue([makeClaim()]);

const hook = renderHook(() => useClaims('GWALLET'));
await flushMicrotasks();

act(() => hook.current.togglePause());
act(() => hook.current.togglePause());
expect(hook.current.paused).toBe(false);

await act(async () => {
await vi.advanceTimersByTimeAsync(15_000);
});

expect(fetchUserClaims).toHaveBeenCalledTimes(2);
});

it('counts down secondsUntilRefresh toward zero after a successful load', async () => {
fetchUserClaims.mockResolvedValue([makeClaim()]);

const hook = renderHook(() => useClaims('GWALLET'));
await flushMicrotasks();

expect(hook.current.secondsUntilRefresh).toBe(15);

await act(async () => {
await vi.advanceTimersByTimeAsync(5_000);
});

expect(hook.current.secondsUntilRefresh).toBe(10);
});
});
25 changes: 25 additions & 0 deletions src/__tests__/useKeyboardShortcut.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,31 @@ describe('useKeyboardShortcut', () => {
expect(secondHandler).toHaveBeenCalledTimes(1);
});

it('does not re-register the keydown listener on re-render when modifiers is omitted', () => {
// Regression test: `modifiers = {}` is a new object reference every
// render, so a naive [key, modifiers] dependency array tears down and
// re-registers the listener every render cycle.
const addSpy = vi.spyOn(window, 'addEventListener');
const removeSpy = vi.spyOn(window, 'removeEventListener');
const handler = vi.fn();

const hook = renderHook(() => useKeyboardShortcut('k', handler));
const initialAddCalls = addSpy.mock.calls.filter((c) => c[0] === 'keydown').length;

hook.rerender();
hook.rerender();
hook.rerender();

const addCallsAfterRerenders = addSpy.mock.calls.filter((c) => c[0] === 'keydown').length;
const removeCallsAfterRerenders = removeSpy.mock.calls.filter((c) => c[0] === 'keydown').length;

expect(addCallsAfterRerenders).toBe(initialAddCalls);
expect(removeCallsAfterRerenders).toBe(0);

addSpy.mockRestore();
removeSpy.mockRestore();
});

it('removes the listener on unmount', () => {
const handler = vi.fn();
const hook = renderHook(() => useKeyboardShortcut('k', handler));
Expand Down
27 changes: 26 additions & 1 deletion src/app/claims/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,16 @@ import { downloadClaimsCSV, downloadClaimsJSON } from '@/lib/claimsExport';

export default function ClaimsPage() {
const { address, connected } = useWallet();
const { claims, loading, error, refetch } = useClaims(address);
const {
claims,
loading,
error,
refetch,
paused,
togglePause,
secondsUntilRefresh,
secondsSinceRefresh,
} = useClaims(address);
const [refreshing, setRefreshing] = useState(false);
const [exportOpen, setExportOpen] = useState(false);

Expand Down Expand Up @@ -77,6 +86,13 @@ export default function ClaimsPage() {
)}
</div>
)}
<button
onClick={togglePause}
className="flex items-center gap-2 rounded-lg border border-white/10 px-4 py-2 text-xs text-gray-300 hover:border-white/20 hover:text-white transition-colors"
aria-label={paused ? 'Resume auto-refresh' : 'Pause auto-refresh'}
>
{paused ? '▶ Resume' : '⏸ Pause'}
</button>
<button
onClick={handleRefresh}
disabled={refreshing || loading}
Expand All @@ -86,6 +102,15 @@ export default function ClaimsPage() {
Refresh
</button>
</div>
<p className="text-[11px] text-gray-500">
{secondsSinceRefresh !== null && (
<>Last refreshed {secondsSinceRefresh}s ago</>
)}
{!paused && secondsUntilRefresh !== null && (
<> · next in {secondsUntilRefresh}s</>
)}
{paused && <> · auto-refresh paused</>}
</p>
</div>
</div>

Expand Down
4 changes: 3 additions & 1 deletion src/app/pools/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,9 @@ export default function PoolsPage() {
</div>
<div className="flex justify-between">
<dt className="text-gray-400">APY</dt>
<dd className="font-semibold text-emerald-400">{(pool.apy * 100).toFixed(1)}%</dd>
<dd className="font-semibold text-emerald-400">
{pool.apy != null ? `${(pool.apy * 100).toFixed(1)}%` : '—'}
</dd>
</div>
</dl>

Expand Down
8 changes: 4 additions & 4 deletions src/components/StatsCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@ const TREND_ARROWS = { up: '↑', down: '↓', neutral: '→' };

export function StatsCard({ label, value, sublabel, trend, trendValue, className }: StatsCardProps) {
return (
<div className={`rounded-2xl border border-white/10 bg-white/[0.03] p-5 ${className ?? ''}`}>
<p className="text-xs uppercase tracking-widest text-gray-400">{label}</p>
<p className="mt-2 text-2xl font-black text-white">{value}</p>
{sublabel && <p className="mt-1 text-xs text-gray-400">{sublabel}</p>}
<div className={`rounded-2xl border border-gray-200 dark:border-white/10 bg-gray-50 dark:bg-white/[0.03] p-5 ${className ?? ''}`}>
<p className="text-xs uppercase tracking-widest text-gray-500 dark:text-gray-400">{label}</p>
<p className="mt-2 text-2xl font-black text-gray-950 dark:text-white">{value}</p>
{sublabel && <p className="mt-1 text-xs text-gray-500 dark:text-gray-400">{sublabel}</p>}
{trend && trendValue && (
<p className={`mt-2 text-xs font-semibold ${TREND_STYLES[trend]}`}>
{TREND_ARROWS[trend]} {trendValue}
Expand Down
9 changes: 9 additions & 0 deletions src/context/ThemeContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,15 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
);
}

/**
* `darkMode: 'class'` in tailwind.config means styling should be done with
* `dark:` prefix classes everywhere, driven off the `dark`/`light` class this
* provider sets on `<html>` (#437) -- not by reading `theme` here and
* branching className strings in components. The one legitimate reason to
* read `theme` from this hook is to choose *which content* to render (e.g.
* NavBar swapping its sun/moon icon, or an aria-label's wording), which
* `dark:` classes can't express since they only toggle styles, not JSX.
*/
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used inside <ThemeProvider>');
Expand Down
48 changes: 46 additions & 2 deletions src/hooks/useClaims.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,16 @@ export function useClaims(walletAddress: string | null) {
const [claims, setClaims] = useState<Claim[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [paused, setPaused] = useState(false);
const [lastRefreshedAt, setLastRefreshedAt] = useState<number | null>(null);
// Ticks once a second so consumers can derive a live countdown from
// lastRefreshedAt without the auto-refresh interval itself needing to change.
const [, forceTick] = useState(0);
const isFirstLoad = useRef(true);
const prevWallet = useRef(walletAddress);
const refetchController = useRef<AbortController | null>(null);
const pausedRef = useRef(paused);
pausedRef.current = paused;

const load = useCallback(async (signal: AbortSignal) => {
if (!walletAddress) return;
Expand All @@ -25,6 +32,7 @@ export function useClaims(walletAddress: string | null) {
const data = await fetchUserClaims(walletAddress);
if (signal.aborted) return;
setClaims(data);
setLastRefreshedAt(Date.now());
} catch (err) {
if (signal.aborted) return;
setError(err instanceof Error ? err.message : "Failed to load claims");
Expand All @@ -40,6 +48,7 @@ export function useClaims(walletAddress: string | null) {
prevWallet.current = walletAddress;
setClaims([]);
setError(null);
setLastRefreshedAt(null);
isFirstLoad.current = true;
}, [walletAddress]);

Expand All @@ -48,9 +57,13 @@ export function useClaims(walletAddress: string | null) {
const controller = new AbortController();
void load(controller.signal);
const interval = setInterval(() => {
if (pausedRef.current) return;
if (!document.hidden) void load(controller.signal);
}, CLAIMS_REFRESH_INTERVAL_MS);
const onVisible = () => { if (!document.hidden) void load(controller.signal); };
const onVisible = () => {
if (pausedRef.current) return;
if (!document.hidden) void load(controller.signal);
};
document.addEventListener('visibilitychange', onVisible);
return () => {
controller.abort();
Expand All @@ -59,12 +72,43 @@ export function useClaims(walletAddress: string | null) {
};
}, [load, walletAddress]);

// Drives the countdown display; a no-op once paused since there's nothing
// counting down to.
useEffect(() => {
if (paused) return;
const tick = setInterval(() => forceTick((n) => n + 1), 1000);
return () => clearInterval(tick);
}, [paused]);

const refetch = useCallback(() => {
refetchController.current?.abort();
const controller = new AbortController();
refetchController.current = controller;
return load(controller.signal);
}, [load]);

return { claims, loading, error, refetch };
const togglePause = useCallback(() => {
setPaused((p) => !p);
}, []);

const secondsUntilRefresh = (() => {
if (paused || lastRefreshedAt === null) return null;
const elapsedMs = Date.now() - lastRefreshedAt;
const remainingMs = CLAIMS_REFRESH_INTERVAL_MS - elapsedMs;
return Math.max(0, Math.ceil(remainingMs / 1000));
})();

const secondsSinceRefresh =
lastRefreshedAt === null ? null : Math.max(0, Math.floor((Date.now() - lastRefreshedAt) / 1000));

return {
claims,
loading,
error,
refetch,
paused,
togglePause,
secondsUntilRefresh,
secondsSinceRefresh,
};
}
19 changes: 14 additions & 5 deletions src/hooks/useKeyboardShortcut.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,27 @@ export function useKeyboardShortcut(
const handlerRef = useRef(handler);
useEffect(() => { handlerRef.current = handler; });

// Depend on the individual modifier flags rather than the `modifiers`
// object itself: callers that omit modifiers rely on the `= {}` default,
// which is a new object reference every render, so depending on the
// object would tear down and re-register the listener every render cycle.
const ctrl = modifiers.ctrl;
const shift = modifiers.shift;
const alt = modifiers.alt;
const meta = modifiers.meta;

useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.key.toLowerCase() !== key.toLowerCase()) return;
if (modifiers.ctrl && !e.ctrlKey) return;
if (modifiers.shift && !e.shiftKey) return;
if (modifiers.alt && !e.altKey) return;
if (modifiers.meta && !e.metaKey) return;
if (ctrl && !e.ctrlKey) return;
if (shift && !e.shiftKey) return;
if (alt && !e.altKey) return;
if (meta && !e.metaKey) return;
e.preventDefault();
e.stopPropagation();
handlerRef.current();
}
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [key, modifiers]);
}, [key, ctrl, shift, alt, meta]);
}
Loading