From 33802eb2f0fb11d23b0f2c24cc7f55b630c15f5d Mon Sep 17 00:00:00 2001 From: vilenarios Date: Tue, 20 Oct 2026 18:55:58 -0400 Subject: [PATCH 1/2] feat(account): keep top-up history for the session, with Refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Leaving the Account page threw the loaded history away, so coming back meant another wallet signature. It now lives in the query cache for the session, keyed by wallet and network: - Fetches only on a click (enabled: false): every request asks the wallet to sign. staleTime/gcTime Infinity, retry false. - "Loaded 4m ago · Refresh". Refresh fetches the first page again, not every page held (each would be another signature). - refresh-balance (top-up, share, revoke) marks it stale without fetching, from useCreditBalance, which is always mounted; the section then says the balance has changed since it loaded. Disabled queries never report isStale, so the hook reads the cache's isInvalidated. - Never persisted. useWalletAccountListener drops other wallets' history on a session change and clears the client cache with the other signer caches. - relativeTime moves to utils (buyTarget re-exports it); the status logic is a pure, tested helper. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LxLDipDTwGmYw9bgV4cjoJ --- CHANGELOG.md | 6 + CLAUDE.md | 2 + .../account/PaymentHistorySection.tsx | 80 +++++++- src/features/arns/purchase/buyTarget.ts | 34 +--- src/hooks/useCreditBalance.ts | 6 + src/hooks/usePaymentHistory.ts | 187 ++++++++++-------- src/hooks/useWalletAccountListener.ts | 17 ++ src/utils/paymentHistoryStatus.test.ts | 25 +++ src/utils/paymentHistoryStatus.ts | 25 +++ src/utils/relativeTime.ts | 30 +++ 10 files changed, 293 insertions(+), 119 deletions(-) create mode 100644 src/utils/paymentHistoryStatus.test.ts create mode 100644 src/utils/paymentHistoryStatus.ts create mode 100644 src/utils/relativeTime.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 41501981..c2cca2cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,12 @@ All notable changes to the ar.io Console are documented in this file. first and names the amount it returns. A wallet with no shares sees one line instead of an empty panel. Approval IDs are no longer shown. +- **Top-up history stays loaded for the session.** Leaving the Account page + and coming back shows it at once, without signing again. It says when it + was loaded and offers Refresh, and after a top-up, share or revoke it says + your balance has changed since. It is never saved to the browser: a reload + or another wallet asks for the signature again. + ### Fixed - **Revoking on the Account page took effect on one click** and revoked everything shared with that wallet, though the row showed one share. It now diff --git a/CLAUDE.md b/CLAUDE.md index 141e6781..b30e1e94 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -151,6 +151,8 @@ what it did. Don't reintroduce a second verification entry point. **Cache expiry:** ArNS names (24h), owned names (6h), upload status (1h confirmed, 24h finalized) +**Signature-gated reads** (top-up history, `usePaymentHistory`) are cached in TanStack Query for the session, never persisted: keyed by address, wallet type and config mode; `enabled: false` (each request asks the wallet to sign, so only a click fetches); `staleTime`/`gcTime: Infinity`; `retry: false`. `refresh-balance` marks them stale with `invalidateQueries({ refetchType: 'none' })` from `useCreditBalance`, which is always mounted, and the page offers Refresh. TanStack never reports a disabled query as stale (`isStale` checks `enabled` first), so read `getQueryState(key).isInvalidated` instead. `useWalletAccountListener` drops other wallets' entries when the session address changes, and clears the module-level client cache alongside the Ethereum and x402 ones. + ### Pages Feature Pages (`/pages`) is a no-code link-in-bio builder: users pick a template, edit profile/blocks/theme with a live preview, and publish a permanent, self-contained HTML page to Arweave with an optional ArNS domain. Pure-logic modules are node-tested (see the Vitest note in Quick Start). diff --git a/src/components/account/PaymentHistorySection.tsx b/src/components/account/PaymentHistorySection.tsx index fcda3cae..a507f492 100644 --- a/src/components/account/PaymentHistorySection.tsx +++ b/src/components/account/PaymentHistorySection.tsx @@ -10,7 +10,9 @@ import { Inbox, Download, } from 'lucide-react'; +import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; +import { relativeTime } from '@/utils/relativeTime'; import { usePaymentHistory, type PaymentHistoryItem } from '@/hooks/usePaymentHistory'; import { isCryptoPayment, @@ -221,11 +223,72 @@ function PaymentRow({ item }: { item: PaymentHistoryItem }) { ); } +// ---- freshness --------------------------------------------------------------- + +/** + * "Loaded 4m ago · Refresh", or, once a top-up, share or revoke has happened + * since, a nudge to refresh. The history never refetches on its own: each + * request asks the wallet to sign, so the user decides when. + */ +function HistoryFreshness({ + loadedAt, + stale, + refreshing, + onRefresh, +}: { + loadedAt: number; + stale: boolean; + refreshing: boolean; + onRefresh: () => void; +}) { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const t = setInterval(() => setNow(Date.now()), 30_000); + return () => clearInterval(t); + }, []); + const age = relativeTime(loadedAt, now) ?? 'just now'; + + const button = ( + + ); + + if (stale && !refreshing) { + return ( +
+ Your balance has changed since this loaded {age}. + {button} +
+ ); + } + return ( +
+ {refreshing ? 'Refreshing…' : `Loaded ${age}`} + {!refreshing && ( + <> + + {button} + + )} +
+ ); +} + // ---- section ----------------------------------------------------------------- export default function PaymentHistorySection() { const navigate = useNavigate(); - const { payments, hasMore, status, error, load, loadMore } = usePaymentHistory(); + const { payments, hasMore, status, error, load, refresh, loadMore, loadedAt, stale } = usePaymentHistory(); + // Rows stay on screen while a refresh or "Load more" runs. + const showing = status === 'loaded' || status === 'loadingMore' || status === 'refreshing'; return (
@@ -238,7 +301,7 @@ export default function PaymentHistorySection() {

Top-up History

Every credit purchase you’ve made

- {(status === 'loaded' || status === 'loadingMore') && payments.length > 0 && ( + {showing && payments.length > 0 && (
+ {showing && loadedAt !== undefined && ( + + )} + {/* Opt-in */} {status === 'idle' && (
@@ -307,7 +379,7 @@ export default function PaymentHistorySection() { )} {/* Empty */} - {(status === 'loaded' || status === 'loadingMore') && payments.length === 0 && ( + {showing && payments.length === 0 && (

No top-ups yet

@@ -325,7 +397,7 @@ export default function PaymentHistorySection() { {/* List — bounded height so a long history scrolls inside the card rather than growing the whole page. */} - {(status === 'loaded' || status === 'loadingMore') && payments.length > 0 && ( + {showing && payments.length > 0 && (
{/* pr-2 keeps the scrollbar from crowding the credits/tx column */}
diff --git a/src/features/arns/purchase/buyTarget.ts b/src/features/arns/purchase/buyTarget.ts index dcad3f68..736d5607 100644 --- a/src/features/arns/purchase/buyTarget.ts +++ b/src/features/arns/purchase/buyTarget.ts @@ -19,6 +19,7 @@ * `isValidRecordTarget` already knows how to check both. */ import { isArweaveTxId } from '../utils'; +import { relativeTime } from '@/utils/relativeTime'; /** How the buyer chose the target. */ export type BuyTargetMode = @@ -219,37 +220,8 @@ export function describeBuyTarget( return when ? `${match.label} · ${when}` : match.label; } -/** - * "2d ago" — coarse on purpose. - * - * `now` is injected rather than read from the clock so this stays pure and - * testable, matching how the rest of this module is written. A timestamp in the - * future (clock skew, a restored backup) returns undefined rather than "in 3 - * days", which would read as a bug in the row. - */ -export function relativeTime( - timestamp: number | undefined, - now: number, -): string | undefined { - if (typeof timestamp !== 'number' || !Number.isFinite(timestamp) || timestamp <= 0) { - return undefined; - } - const diff = now - timestamp; - if (diff < 0) return undefined; - - const mins = Math.floor(diff / 60_000); - if (mins < 1) return 'just now'; - if (mins < 60) return `${mins}m ago`; - const hours = Math.floor(mins / 60); - if (hours < 24) return `${hours}h ago`; - const days = Math.floor(hours / 24); - if (days < 7) return `${days}d ago`; - const weeks = Math.floor(days / 7); - if (weeks < 5) return `${weeks}w ago`; - const months = Math.floor(days / 30); - if (months < 12) return `${months}mo ago`; - return `${Math.floor(days / 365)}y ago`; -} +// Lives in utils so the Account page's payment history can use it too. +export { relativeTime }; /** * The second line of a picker row: `Site · 2d ago · Gil3ILKW…JGCdA4`. diff --git a/src/hooks/useCreditBalance.ts b/src/hooks/useCreditBalance.ts index 3276680e..de63de53 100644 --- a/src/hooks/useCreditBalance.ts +++ b/src/hooks/useCreditBalance.ts @@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useStore } from '../store/useStore'; import { getTurboBalance } from '../utils'; +import { PAYMENT_HISTORY_KEY } from './usePaymentHistory'; /** * Shared React Query hook for the user's Turbo credit balance. @@ -62,6 +63,11 @@ export function useCreditBalance() { if (timerRef.current) clearTimeout(timerRef.current); timerRef.current = setTimeout(() => { queryClient.invalidateQueries({ queryKey: ['credit-balance'] }); + // Mark the cached top-up history stale too, without fetching: every + // history request asks the wallet to sign, so the Account page offers + // a refresh instead (usePaymentHistory). Lives here because this + // listener is always mounted; a top-up happens on another page. + queryClient.invalidateQueries({ queryKey: [PAYMENT_HISTORY_KEY], refetchType: 'none' }); }, 150); }; window.addEventListener('refresh-balance', onRefresh); diff --git a/src/hooks/usePaymentHistory.ts b/src/hooks/usePaymentHistory.ts index 563e0ba4..c7383f6e 100644 --- a/src/hooks/usePaymentHistory.ts +++ b/src/hooks/usePaymentHistory.ts @@ -1,16 +1,20 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useSyncExternalStore } from 'react'; +import { useInfiniteQuery, useQueryClient, type InfiniteData } from '@tanstack/react-query'; import { TurboFactory, TurboAuthenticatedClient, ArconnectSigner } from '@ardrive/turbo-sdk/web'; import { useWallet } from '@solana/wallet-adapter-react'; import { useStore } from '@/store/useStore'; import { useTurboConfig } from '@/hooks/useTurboConfig'; import { useEthereumTurboClient } from '@/hooks/useEthereumTurboClient'; +import { paymentHistoryStatus, type PaymentHistoryStatus } from '@/utils/paymentHistoryStatus'; + +export type { PaymentHistoryStatus }; /** One row from the SDK's payment history — a discriminated union on `type`. */ export type PaymentHistoryItem = Awaited< ReturnType >['payments'][number]; -export type PaymentHistoryStatus = 'idle' | 'loading' | 'loadingMore' | 'loaded' | 'error'; +type HistoryPage = Awaited>; // Generous page size so almost every user sees their whole history in a single // signed request — pagination costs another signature (the SDK signs a fresh @@ -18,21 +22,37 @@ export type PaymentHistoryStatus = 'idle' | 'loading' | 'loadingMore' | 'loaded' // there genuinely is more. const PAGE_SIZE = 50; +/** Query key root. `useCreditBalance` marks it stale on `refresh-balance`. */ +export const PAYMENT_HISTORY_KEY = 'payment-history'; + +// The authenticated client, kept across visits to the page so "Load more" and +// Refresh don't rebuild the signer. Cleared by `useWalletAccountListener` on a +// wallet switch, like the Ethereum and x402 signer caches. +let clientCache: { key: string; client: TurboAuthenticatedClient } | null = null; + +export function clearPaymentHistoryClientCache(): void { + clientCache = null; +} + /** - * Opt-in fetch of the connected wallet's own top-up history via the turbo-sdk - * `getPaymentHistory` API. The call is authenticated and signature-scoped (the - * service reads the wallet from the request signature), so nothing loads until - * the user explicitly calls `load()` — that click is what triggers the wallet - * signature. The authenticated client is cached per session so paging reuses the - * same signer. + * The connected wallet's own top-up history via turbo-sdk `getPaymentHistory`. * - * Note: the shared Ethereum signer/client cache is cleared centrally by - * `useWalletAccountListener` on wallet switch/disconnect (same as Credit - * Sharing), so this hook only manages its own per-session client + a session - * guard that discards results from a superseded wallet. + * Every request is signed (the service reads the wallet from the signature), + * so nothing fetches on its own: not on mount, focus or reconnect, only when + * the user calls `load()`, `refresh()` or `loadMore()`. What loads is cached + * for the session in the query cache, keyed by wallet and network, so leaving + * the page and coming back shows it at once without another signature. A + * result can only land under the wallet that asked for it, and + * `useWalletAccountListener` drops other wallets' history when the session + * changes. Kept in memory only: persisting it would show it to whoever uses + * the browser next without the signature that gates it. + * + * `refresh-balance` (any top-up, share or revoke) marks it stale without + * fetching (`useCreditBalance`), so the section can offer a refresh rather + * than spring a wallet prompt. */ export function usePaymentHistory() { - const { address, walletType } = useStore(); + const { address, walletType, configMode } = useStore(); const turboConfig = useTurboConfig(); const { createEthereumTurboClient } = useEthereumTurboClient(); const { @@ -40,24 +60,13 @@ export function usePaymentHistory() { signMessage: solanaSignMessage, signTransaction: solanaSignTransaction, } = useWallet(); - - const [payments, setPayments] = useState([]); - const [cursor, setCursor] = useState(null); - const [hasMore, setHasMore] = useState(false); - const [status, setStatus] = useState('idle'); - const [error, setError] = useState(null); - - // Cache the authenticated client for the session (keyed by address) so paging - // doesn't rebuild the signer. A wallet switch invalidates it (see reset()). - const clientRef = useRef<{ address: string; client: TurboAuthenticatedClient } | null>(null); - // Monotonic session id. Bumped on every reset (which runs on wallet change), so - // a request that resolves after a switch can be discarded instead of showing - // one wallet's history under another. - const sessionRef = useRef(0); + const queryClient = useQueryClient(); + const queryKey = [PAYMENT_HISTORY_KEY, address, walletType, configMode]; + const clientKey = `${address}|${walletType}|${configMode}`; const getClient = useCallback(async (): Promise => { if (!address || !walletType) throw new Error('Wallet not connected'); - if (clientRef.current?.address === address) return clientRef.current.client; + if (clientCache?.key === clientKey) return clientCache.client; let client: TurboAuthenticatedClient; switch (walletType) { @@ -90,11 +99,12 @@ export function usePaymentHistory() { default: throw new Error(`Unsupported wallet type: ${walletType}`); } - clientRef.current = { address, client }; + clientCache = { key: clientKey, client }; return client; }, [ address, walletType, + clientKey, turboConfig, createEthereumTurboClient, solanaPublicKey, @@ -102,6 +112,22 @@ export function usePaymentHistory() { solanaSignTransaction, ]); + const query = useInfiniteQuery({ + queryKey, + // Only on an explicit click: every request asks the wallet to sign. + enabled: false, + staleTime: Infinity, + gcTime: Infinity, + // A declined signature is an answer, not a flake. + retry: false, + initialPageParam: undefined as string | undefined, + queryFn: async ({ pageParam }) => { + const turbo = await getClient(); + return turbo.getPaymentHistory(pageParam ? { limit: PAGE_SIZE, cursor: pageParam } : { limit: PAGE_SIZE }); + }, + getNextPageParam: (last) => (last.hasMore && last.cursor ? last.cursor : undefined), + }); + const toMessage = (e: unknown): string => { const msg = e instanceof Error ? e.message : String(e); if (/reject|denied|declin|cancel/i.test(msg)) { @@ -113,62 +139,55 @@ export function usePaymentHistory() { /** First page. Triggers the wallet signature. */ const load = useCallback(async () => { if (!address) return; - const session = sessionRef.current; - setStatus('loading'); - setError(null); - try { - const turbo = await getClient(); - if (session !== sessionRef.current) return; // wallet changed mid-flight - const res = await turbo.getPaymentHistory({ limit: PAGE_SIZE }); - if (session !== sessionRef.current) return; - setPayments(res.payments); - setCursor(res.cursor); - setHasMore(res.hasMore); - setStatus('loaded'); - } catch (e) { - if (session !== sessionRef.current) return; - setError(toMessage(e)); - setStatus('error'); - } - }, [address, getClient]); + await query.refetch(); + }, [address, query]); + + /** + * Fetch the first page again, dropping later pages first: refetching an + * infinite query refetches every page it holds, a signature each. + */ + const refresh = useCallback(async () => { + if (!address) return; + queryClient.setQueryData>( + [PAYMENT_HISTORY_KEY, address, walletType, configMode], + (d) => (d ? { pages: d.pages.slice(0, 1), pageParams: d.pageParams.slice(0, 1) } : d), + ); + await query.refetch(); + }, [address, walletType, configMode, query, queryClient]); /** Next page (keeps existing rows on failure). */ const loadMore = useCallback(async () => { - if (!cursor || status === 'loadingMore') return; - const session = sessionRef.current; - setStatus('loadingMore'); - setError(null); - try { - const turbo = await getClient(); - if (session !== sessionRef.current) return; - const res = await turbo.getPaymentHistory({ limit: PAGE_SIZE, cursor }); - if (session !== sessionRef.current) return; - setPayments((prev) => [...prev, ...res.payments]); - setCursor(res.cursor); - setHasMore(res.hasMore); - setStatus('loaded'); - } catch (e) { - if (session !== sessionRef.current) return; - setError(toMessage(e)); - setStatus('loaded'); - } - }, [cursor, status, getClient]); - - const reset = useCallback(() => { - sessionRef.current += 1; // invalidate any in-flight request - clientRef.current = null; - setPayments([]); - setCursor(null); - setHasMore(false); - setStatus('idle'); - setError(null); - }, []); - - // Clear everything when the wallet changes so one wallet's history never shows - // under another. - useEffect(() => { - reset(); - }, [address, walletType, reset]); - - return { payments, hasMore, status, error, load, loadMore, reset }; + if (!query.hasNextPage || query.isFetchingNextPage) return; + await query.fetchNextPage(); + }, [query]); + + // TanStack never reports a disabled query as stale (`isStale` checks + // `enabled` first), so read the invalidation `refresh-balance` leaves on it + // straight from the cache. A successful fetch clears it. + const invalidated = useSyncExternalStore( + (onChange) => queryClient.getQueryCache().subscribe(onChange), + () => queryClient.getQueryState([PAYMENT_HISTORY_KEY, address, walletType, configMode])?.isInvalidated ?? false, + ); + + const hasData = !!query.data; + const status: PaymentHistoryStatus = paymentHistoryStatus({ + hasData, + isFetching: query.isFetching, + isFetchingNextPage: query.isFetchingNextPage, + isError: query.isError, + }); + + return { + payments: query.data?.pages.flatMap((p) => p.payments) ?? [], + hasMore: !!query.hasNextPage, + status, + error: query.isError ? toMessage(query.error) : null, + /** When the cached history was fetched (ms), or undefined before the first load. */ + loadedAt: hasData ? query.dataUpdatedAt : undefined, + /** True once a top-up, share or revoke has happened since it was fetched. */ + stale: hasData && invalidated, + load, + refresh, + loadMore, + }; } diff --git a/src/hooks/useWalletAccountListener.ts b/src/hooks/useWalletAccountListener.ts index ddff1419..6e54c302 100644 --- a/src/hooks/useWalletAccountListener.ts +++ b/src/hooks/useWalletAccountListener.ts @@ -1,9 +1,11 @@ import { useEffect, useRef } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; import { useStore } from '../store/useStore'; import { useAccount } from 'wagmi'; import { useWallet } from '@solana/wallet-adapter-react'; import { clearEthereumTurboClientCache } from './useEthereumTurboClient'; import { clearX402SignerCache } from './useX402Upload'; +import { clearPaymentHistoryClientCache, PAYMENT_HISTORY_KEY } from './usePaymentHistory'; import { canRestoreSolanaSession } from '../utils/solanaSessionRestore'; /** @@ -29,6 +31,17 @@ export function useWalletAccountListener() { // Track previous connector to detect wallet app switches const prevConnectorRef = useRef(null); + // Top-up history is cached for the session per wallet (usePaymentHistory). + // When the session wallet changes, sign-out included, drop every other + // wallet's: it was shown only after that wallet signed for it. + const queryClient = useQueryClient(); + useEffect(() => { + queryClient.removeQueries({ + queryKey: [PAYMENT_HISTORY_KEY], + predicate: (q) => q.queryKey[1] !== address, + }); + }, [address, queryClient]); + // Handle RainbowKit/Wagmi session restoration on page load // When user refreshes, wagmi auto-reconnects and we need to update our store // Only restore if store already has walletType === 'ethereum' — don't auto-connect @@ -45,6 +58,7 @@ export function useWalletAccountListener() { if (address) { clearEthereumTurboClientCache(); clearX402SignerCache(); + clearPaymentHistoryClientCache(); } setAddress(ethAddress, 'ethereum'); @@ -60,6 +74,7 @@ export function useWalletAccountListener() { // Clear cached Turbo clients since we have a new wallet clearEthereumTurboClientCache(); clearX402SignerCache(); + clearPaymentHistoryClientCache(); // Update to new address setAddress(ethAddress, 'ethereum'); @@ -80,6 +95,7 @@ export function useWalletAccountListener() { // Clear cached Turbo clients when switching wallet apps clearEthereumTurboClientCache(); clearX402SignerCache(); + clearPaymentHistoryClientCache(); } prevConnectorRef.current = currentConnectorId; @@ -234,6 +250,7 @@ export function useWalletAccountListener() { // Clear cached Turbo clients since we have a new wallet clearEthereumTurboClientCache(); clearX402SignerCache(); + clearPaymentHistoryClientCache(); // Update to new address setAddress(accounts[0], 'ethereum'); diff --git a/src/utils/paymentHistoryStatus.test.ts b/src/utils/paymentHistoryStatus.test.ts new file mode 100644 index 00000000..0d1df09f --- /dev/null +++ b/src/utils/paymentHistoryStatus.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest'; +import { paymentHistoryStatus } from './paymentHistoryStatus'; + +const base = { hasData: false, isFetching: false, isFetchingNextPage: false, isError: false }; + +describe('paymentHistoryStatus', () => { + it('is idle until the user asks (nothing fetches on its own)', () => { + expect(paymentHistoryStatus(base)).toBe('idle'); + }); + + it('loads, then fails only when there is nothing to show', () => { + expect(paymentHistoryStatus({ ...base, isFetching: true })).toBe('loading'); + expect(paymentHistoryStatus({ ...base, isError: true })).toBe('error'); + }); + + it('keeps cached rows on screen through a refresh or a failed one', () => { + expect(paymentHistoryStatus({ ...base, hasData: true })).toBe('loaded'); + expect(paymentHistoryStatus({ ...base, hasData: true, isFetching: true })).toBe('refreshing'); + expect(paymentHistoryStatus({ ...base, hasData: true, isError: true })).toBe('loaded'); + }); + + it('tells "Load more" apart from a refresh', () => { + expect(paymentHistoryStatus({ ...base, hasData: true, isFetching: true, isFetchingNextPage: true })).toBe('loadingMore'); + }); +}); diff --git a/src/utils/paymentHistoryStatus.ts b/src/utils/paymentHistoryStatus.ts new file mode 100644 index 00000000..12f05882 --- /dev/null +++ b/src/utils/paymentHistoryStatus.ts @@ -0,0 +1,25 @@ +export type PaymentHistoryStatus = 'idle' | 'loading' | 'loadingMore' | 'refreshing' | 'loaded' | 'error'; + +/** + * The payment history section's state, from its query's flags. + * + * The history is cached for the session and fetched only on a click, so + * "has data" decides most of it: with rows cached, a failed refresh or "Load + * more" keeps them on screen (`loaded`, with the error shown beside them), + * and only a first load that fails is an `error` with nothing to show. + */ +export function paymentHistoryStatus(q: { + hasData: boolean; + isFetching: boolean; + isFetchingNextPage: boolean; + isError: boolean; +}): PaymentHistoryStatus { + if (q.hasData) { + if (q.isFetchingNextPage) return 'loadingMore'; + if (q.isFetching) return 'refreshing'; + return 'loaded'; + } + if (q.isFetching) return 'loading'; + if (q.isError) return 'error'; + return 'idle'; +} diff --git a/src/utils/relativeTime.ts b/src/utils/relativeTime.ts new file mode 100644 index 00000000..d8399997 --- /dev/null +++ b/src/utils/relativeTime.ts @@ -0,0 +1,30 @@ +/** + * "2d ago" — coarse on purpose. + * + * `now` is injected rather than read from the clock so this stays pure and + * testable. A timestamp in the future (clock skew, a restored backup) returns + * undefined rather than "in 3 days", which would read as a bug in the row. + */ +export function relativeTime( + timestamp: number | undefined, + now: number, +): string | undefined { + if (typeof timestamp !== 'number' || !Number.isFinite(timestamp) || timestamp <= 0) { + return undefined; + } + const diff = now - timestamp; + if (diff < 0) return undefined; + + const mins = Math.floor(diff / 60_000); + if (mins < 1) return 'just now'; + if (mins < 60) return `${mins}m ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 7) return `${days}d ago`; + const weeks = Math.floor(days / 7); + if (weeks < 5) return `${weeks}w ago`; + const months = Math.floor(days / 30); + if (months < 12) return `${months}mo ago`; + return `${Math.floor(days / 365)}y ago`; +} From 68ec886b519d8378231a553932f7082c675b8126 Mon Sep 17 00:00:00 2001 From: vilenarios Date: Tue, 20 Oct 2026 19:06:22 -0400 Subject: [PATCH 2/2] fix(account): a failed refresh keeps the history's age; no fetch cancels another - Trimming to the first page before a refresh wrote the cache, stamping it "just now" and clearing the stale mark before anything was fetched, so a declined signature left old rows reading as current. The trim keeps the old timestamp, and a failed refresh marks it stale again (CodeRabbit). - TanStack cancels a running fetch when another starts, so Load more during a Refresh (or the reverse) threw away a signature already given. All three actions wait for any fetch in flight, and the section disables the other button meanwhile (CodeRabbit). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LxLDipDTwGmYw9bgV4cjoJ --- .../account/PaymentHistorySection.tsx | 8 +++++-- src/hooks/usePaymentHistory.ts | 24 +++++++++++++++---- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/components/account/PaymentHistorySection.tsx b/src/components/account/PaymentHistorySection.tsx index a507f492..ecd2897b 100644 --- a/src/components/account/PaymentHistorySection.tsx +++ b/src/components/account/PaymentHistorySection.tsx @@ -234,11 +234,14 @@ function HistoryFreshness({ loadedAt, stale, refreshing, + busy, onRefresh, }: { loadedAt: number; stale: boolean; refreshing: boolean; + /** Another fetch (Load more) is running; Refresh would cancel it. */ + busy: boolean; onRefresh: () => void; }) { const [now, setNow] = useState(() => Date.now()); @@ -252,7 +255,7 @@ function HistoryFreshness({