Skip to content
Open
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
9 changes: 9 additions & 0 deletions src/pages/Transfers.css
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,12 @@
margin-top: 0;
}
}

.transfers-snapshot-notice {
margin: 0 0 1rem;
padding: 0.65rem 0.85rem;
border-radius: 8px;
background: rgba(37, 99, 235, 0.12);
color: var(--color-text);
font-size: 0.9rem;
}
122 changes: 106 additions & 16 deletions src/pages/Transfers.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ import { useTransfers } from '../hooks/useTransfers.js';
import { useOnlineStatus } from '../hooks/useOnlineStatus.js';
import { useApp } from '../context/AppContext.jsx';
import { DATE_RANGE_PRESETS, isWithinDateRange } from '../utils/dateRange.js';
import {
DEFAULT_PAGE_SIZE,
encodeCursor,
resolveTransferPage,
} from '../utils/transferSnapshot.js';
import './Transfers.css';

// Derived from the contract so a new lifecycle state cannot be filterable in
Expand All @@ -31,11 +36,13 @@ const STATUS_OPTIONS = [
})),
];

const PAGE_SIZE = 5;

/**
* Transfers page: lists all transfers with their status.
* Filter state is synced to the URL query string.
*
* Pagination uses a frozen snapshot so concurrent inserts cannot duplicate or
* omit rows while the user pages. Filter changes reset the snapshot; expired
* cursors recover by rebuilding from the live filtered list.
*/
export default function Transfers() {
const { transfers, loading, error, reload } = useTransfers();
Expand All @@ -57,23 +64,24 @@ export default function Transfers() {
const [selectedIds, setSelectedIds] = useState(() => new Set());
const [selectAllAcross, setSelectAllAcross] = useState(false);

// Pagination state
const [page, setPage] = useState(1);

// Reset page and selection when filters change
// Snapshot pagination state
const [snapshot, setSnapshot] = useState(null);
const [cursor, setCursor] = useState(null);
const [snapshotNotice, setSnapshotNotice] = useState(null);
// Reset cursor/selection when filters change; snapshot rebuilds below.
useEffect(() => {
setPage(1);
setCursor(null);
setSnapshot(null);
setSelectedIds(new Set());
setSelectAllAcross(false);
setSnapshotNotice(null);
}, [search, status, range]);

// Normalise the query-string status so a legacy or provider spelling in a
// shared/bookmarked URL (?status=settled) still selects the right rows.
const canonicalStatus = normalizeStatus(status);

// Track connectivity so that a reconnect triggers an automatic reload.
// The reload reconciles the true status of transfers that may have been
// created or settled while the connection was down — without resubmitting
// anything.
useEffect(() => {
if (isOnline && wasOffline && !loading) {
setSyncingAfterReconnect(true);
Expand All @@ -94,11 +102,78 @@ export default function Transfers() {
});
}, [transfers, search, status, canonicalStatus, range]);

// Paginated data
const totalPages = Math.ceil(filteredTransfers.length / PAGE_SIZE) || 1;
const pageTransfers = useMemo(() => {
return filteredTransfers.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
}, [filteredTransfers, page]);
const filterState = useMemo(
() => ({ search, status: canonicalStatus || status, range }),
[search, status, canonicalStatus, range],
);

// Resolve the current page against a frozen snapshot. Membership stays
// deterministic even if `transfers` grows while the user is on page 2.
const resolved = useMemo(() => {
if (loading) {
return {
snapshot: null,
page: {
ok: true,
items: [],
page: 1,
totalPages: 1,
totalCount: 0,
cursor: null,
nextCursor: null,
prevCursor: null,
recovered: false,
},
recovered: false,
};
}
return resolveTransferPage(filteredTransfers, {
filters: filterState,
cursor,
snapshot,
pageSize: DEFAULT_PAGE_SIZE,
});
}, [filteredTransfers, filterState, cursor, snapshot, loading]);

const pageTransfers = resolved.page.items;
const totalPages = resolved.page.totalPages;
const page = resolved.page.page;

// Persist a newly minted snapshot and surface recovery after an expired cursor.
useEffect(() => {
if (
resolved.snapshot &&
resolved.snapshot.id !== snapshot?.id
) {
setSnapshot(resolved.snapshot);
}
if (resolved.recovered && cursor) {
setCursor(null);
setSnapshotNotice(
'Transfer list was refreshed to keep paging consistent.',
);
}
}, [resolved, snapshot, cursor]);

const handlePageChange = useCallback(
(nextPage) => {
const active = resolved.snapshot;
if (!active) return;
const safePage = Math.min(Math.max(1, nextPage), totalPages);
const priorRef =
active.items[(safePage - 1) * active.pageSize - 1] ?? null;
setCursor(
encodeCursor({
snapshotId: active.id,
scope: active.scope,
page: safePage,
after: priorRef,
}),
);
setSnapshotNotice(null);
},
[resolved.snapshot, totalPages],
);

// Selection state computations
const allPageSelected =
Expand Down Expand Up @@ -265,7 +340,11 @@ export default function Transfers() {
/>
))}
</div>
<Pagination page={page} totalPages={totalPages} onChange={setPage} />
<Pagination
page={page}
totalPages={totalPages}
onChange={handlePageChange}
/>
</>
);
};
Expand All @@ -283,6 +362,17 @@ export default function Transfers() {
</div>
)}

{snapshotNotice && (
<div
className="transfers-snapshot-notice"
role="status"
aria-live="polite"
data-testid="snapshot-recovery"
>
{snapshotNotice}
</div>
)}

<div className="transfers-filters">
<input
type="search"
Expand Down
Loading