diff --git a/src/components/ActivityTimeline.test.tsx b/src/components/ActivityTimeline.test.tsx index 6e77c88..0809aa6 100644 --- a/src/components/ActivityTimeline.test.tsx +++ b/src/components/ActivityTimeline.test.tsx @@ -486,4 +486,296 @@ describe("ActivityTimeline", () => { const filterBtn = screen.getByRole("button", { name: /toggle filters/i }); expect(filterBtn).toBeInTheDocument(); }); + + // ── Status filter chips (issue #960) ──────────────────────────────────── + + describe("status filter", () => { + function makeMixedStatusGroup(): TimelineGroup[] { + return [ + { + date: "2026-07-26", + transactions: [ + makeGroupedTx({ hash: "hash-ok", status: "success" }), + makeGroupedTx({ hash: "hash-bad", status: "failed" }), + ], + }, + ]; + } + + it("shows all transactions when the All chip is selected (default)", async () => { + mockGetTimeline(makeMixedStatusGroup(), 2); + vi.mocked(useSorokit).mockReturnValue( + DEFAULT_CONTEXT as unknown as ReturnType, + ); + + render(); + await waitFor(() => { + expect(screen.getAllByTitle("Copy transaction hash")).toHaveLength(2); + }); + }); + + it("shows only successful transactions when the Succeeded chip is selected", async () => { + mockGetTimeline(makeMixedStatusGroup(), 2); + vi.mocked(useSorokit).mockReturnValue( + DEFAULT_CONTEXT as unknown as ReturnType, + ); + + render(); + await waitFor(() => { + expect(screen.getAllByTitle("Copy transaction hash")).toHaveLength(2); + }); + + fireEvent.click(screen.getByRole("button", { name: "Succeeded" })); + + await waitFor(() => { + expect(screen.getAllByTitle("Copy transaction hash")).toHaveLength(1); + }); + }); + + it("shows only failed transactions when the Unsuccessful chip is selected", async () => { + mockGetTimeline(makeMixedStatusGroup(), 2); + vi.mocked(useSorokit).mockReturnValue( + DEFAULT_CONTEXT as unknown as ReturnType, + ); + + render(); + await waitFor(() => { + expect(screen.getAllByTitle("Copy transaction hash")).toHaveLength(2); + }); + + fireEvent.click(screen.getByRole("button", { name: "Unsuccessful" })); + + await waitFor(() => { + const rows = screen.getAllByTitle("Copy transaction hash"); + expect(rows).toHaveLength(1); + }); + }); + + it("returns to showing all transactions when the All chip is reselected", async () => { + mockGetTimeline(makeMixedStatusGroup(), 2); + vi.mocked(useSorokit).mockReturnValue( + DEFAULT_CONTEXT as unknown as ReturnType, + ); + + render(); + await waitFor(() => { + expect(screen.getAllByTitle("Copy transaction hash")).toHaveLength(2); + }); + + fireEvent.click(screen.getByRole("button", { name: "Succeeded" })); + await waitFor(() => { + expect(screen.getAllByTitle("Copy transaction hash")).toHaveLength(1); + }); + + fireEvent.click(screen.getByRole("button", { name: "All" })); + await waitFor(() => { + expect(screen.getAllByTitle("Copy transaction hash")).toHaveLength(2); + }); + }); + + it("shows 'No transactions match your filters' when a status filter leaves nothing", async () => { + mockGetTimeline( + [ + { + date: "2026-07-26", + transactions: [makeGroupedTx({ status: "success" })], + }, + ], + 1, + ); + vi.mocked(useSorokit).mockReturnValue( + DEFAULT_CONTEXT as unknown as ReturnType, + ); + + render(); + await waitFor(() => { + expect(screen.getAllByTitle("Copy transaction hash")).toHaveLength(1); + }); + + fireEvent.click(screen.getByRole("button", { name: "Unsuccessful" })); + + await waitFor(() => { + expect(screen.getByText("No transactions match your filters")).toBeInTheDocument(); + }); + }); + + it("marks the active status chip with aria-pressed=true", async () => { + mockGetTimeline(makeMixedStatusGroup(), 2); + vi.mocked(useSorokit).mockReturnValue( + DEFAULT_CONTEXT as unknown as ReturnType, + ); + + render(); + await waitFor(() => { + expect(screen.getAllByTitle("Copy transaction hash")).toHaveLength(2); + }); + + expect(screen.getByRole("button", { name: "All" })).toHaveAttribute( + "aria-pressed", + "true", + ); + + fireEvent.click(screen.getByRole("button", { name: "Succeeded" })); + expect(screen.getByRole("button", { name: "Succeeded" })).toHaveAttribute( + "aria-pressed", + "true", + ); + expect(screen.getByRole("button", { name: "All" })).toHaveAttribute( + "aria-pressed", + "false", + ); + }); + }); + + // ── Relative date labels: Today / Yesterday (issue #960) ──────────────── + + describe("relative date labels", () => { + function isoDateDaysAgo(daysAgo: number): string { + const d = new Date(); + d.setDate(d.getDate() - daysAgo); + return d.toISOString().slice(0, 10); + } + + it("labels today's group as 'Today' instead of the full date", async () => { + const groups: TimelineGroup[] = [ + { + date: isoDateDaysAgo(0), + transactions: [makeGroupedTx({ date: isoDateDaysAgo(0) })], + }, + ]; + mockGetTimeline(groups, 1); + vi.mocked(useSorokit).mockReturnValue( + DEFAULT_CONTEXT as unknown as ReturnType, + ); + + render(); + await waitFor(() => { + expect(screen.getByText("Today")).toBeInTheDocument(); + }); + }); + + it("labels yesterday's group as 'Yesterday' instead of the full date", async () => { + const groups: TimelineGroup[] = [ + { + date: isoDateDaysAgo(1), + transactions: [makeGroupedTx({ date: isoDateDaysAgo(1) })], + }, + ]; + mockGetTimeline(groups, 1); + vi.mocked(useSorokit).mockReturnValue( + DEFAULT_CONTEXT as unknown as ReturnType, + ); + + render(); + await waitFor(() => { + expect(screen.getByText("Yesterday")).toBeInTheDocument(); + }); + }); + + it("labels a group from several days ago with its full weekday/month/day date", async () => { + const longAgo = isoDateDaysAgo(10); + const groups: TimelineGroup[] = [ + { + date: longAgo, + transactions: [makeGroupedTx({ date: longAgo })], + }, + ]; + mockGetTimeline(groups, 1); + vi.mocked(useSorokit).mockReturnValue( + DEFAULT_CONTEXT as unknown as ReturnType, + ); + + render(); + await waitFor(() => { + const expectedLabel = new Date(longAgo).toLocaleDateString("en-US", { + weekday: "long", + month: "long", + day: "numeric", + year: "numeric", + }); + expect(screen.getByText(expectedLabel)).toBeInTheDocument(); + expect(screen.queryByText("Today")).not.toBeInTheDocument(); + expect(screen.queryByText("Yesterday")).not.toBeInTheDocument(); + }); + }); + }); + + // ── Large-list rendering performance (issue #960) ─────────────────────── + + describe("large list rendering", () => { + it("marks each transaction row with content-visibility so off-screen rows skip layout/paint", async () => { + const manyTransactions = Array.from({ length: 50 }, (_, i) => + makeGroupedTx({ hash: `hash-${i}`, status: "success" }), + ); + const groups: TimelineGroup[] = [{ date: "2026-07-26", transactions: manyTransactions }]; + mockGetTimeline(groups, 50); + vi.mocked(useSorokit).mockReturnValue( + DEFAULT_CONTEXT as unknown as ReturnType, + ); + + const { container } = render(); + await waitFor(() => { + expect(screen.getAllByTitle("Copy transaction hash")).toHaveLength(50); + }); + + const rowWrappers = container.querySelectorAll('[style*="content-visibility"]'); + expect(rowWrappers.length).toBe(50); + for (const wrapper of rowWrappers) { + expect((wrapper as HTMLElement).style.contentVisibility).toBe("auto"); + } + }); + + it("switches an expanded row's content-visibility to visible so its operations stay rendered", async () => { + const groups: TimelineGroup[] = [ + { + date: "2026-07-26", + transactions: [ + makeGroupedTx({ + hash: "hash-expand-me", + operationCount: 1, + operations: [makeOperation({ type: "payment" })], + }), + ], + }, + ]; + mockGetTimeline(groups, 1); + vi.mocked(useSorokit).mockReturnValue( + DEFAULT_CONTEXT as unknown as ReturnType, + ); + + const { container } = render(); + await waitFor(() => { + expect(screen.getAllByTitle("Copy transaction hash")).toHaveLength(1); + }); + + const wrapper = container.querySelector('[style*="content-visibility"]') as HTMLElement; + expect(wrapper.style.contentVisibility).toBe("auto"); + + fireEvent.click(screen.getByRole("button", { name: /1 operations/i })); + + await waitFor(() => { + expect(wrapper.style.contentVisibility).toBe("visible"); + }); + }); + }); + + // ── Sticky date headers (issue #960) ───────────────────────────────────── + + describe("sticky date headers", () => { + it("applies sticky positioning to every date group header, not just the first", async () => { + const groups = makeTimelineGroups(3); + mockGetTimeline(groups, 6); + vi.mocked(useSorokit).mockReturnValue( + DEFAULT_CONTEXT as unknown as ReturnType, + ); + + const { container } = render(); + await waitFor(() => { + expect(screen.getAllByTitle("Copy transaction hash")).toHaveLength(6); + }); + + const headers = container.querySelectorAll(".sticky.top-0"); + expect(headers.length).toBe(groups.length); + }); + }); }); \ No newline at end of file diff --git a/src/components/ActivityTimeline.tsx b/src/components/ActivityTimeline.tsx index 1135083..dda6915 100644 --- a/src/components/ActivityTimeline.tsx +++ b/src/components/ActivityTimeline.tsx @@ -19,6 +19,7 @@ import type { GroupedTransaction, Operation, TimelineFilter, TimelineGroup } fro import { cn, truncateAddress } from "@/lib/utils"; const PAGE_SIZE = 10; +const MS_PER_DAY = 86_400_000; const OPERATION_TYPES = [ { value: "all", label: "All Operations" }, { value: "payment", label: "Payments" }, @@ -32,9 +33,35 @@ const OPERATION_TYPES = [ { value: "allow_trust", label: "Allow Trust" }, { value: "account_creator", label: "Account Creator" }, ]; +// Labels are deliberately distinct from the row-level "Success"/"Failed" +// badge text (e.g. "Succeeded" rather than "Success") so a test asserting +// on that badge text can't accidentally match one of these filter chips +// instead, which would let it pass before the real transaction data has +// even loaded. +const STATUS_FILTER_CHIPS = [ + { value: "all", label: "All" }, + { value: "success", label: "Succeeded" }, + { value: "failed", label: "Unsuccessful" }, +] as const; -function formatDateLabel(dateStr: string): string { +/** + * Formats a group's date as "Today", "Yesterday", or a full date (issue + * #960): a bare weekday/month/day label gives no sense of recency at a + * glance, and the two most recent days are the ones a user checks most + * often. Compares local calendar days, matching how `group.date` is + * produced from local-time buckets server-side. + */ +function formatDateLabel(dateStr: string, now: Date = new Date()): string { const d = new Date(dateStr); + const startOfDay = (date: Date) => + new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime(); + const daysAgo = Math.round( + (startOfDay(now) - startOfDay(d)) / MS_PER_DAY, + ); + + if (daysAgo === 0) return "Today"; + if (daysAgo === 1) return "Yesterday"; + return d.toLocaleDateString("en-US", { weekday: "long", month: "long", @@ -159,7 +186,17 @@ function GroupedTxRow({ tx, onCopy, explorerUrl }: { tx: GroupedTransaction; onC }, []); return ( -
+
({ + ...group, + transactions: group.transactions.filter((tx) => tx.status === statusFilter), + })) + .filter((group) => group.transactions.length > 0); +} + export function ActivityTimeline({ className }: { className?: string }) { const { address, isConnected, network, client } = useSorokit(); const [groups, setGroups] = useState([]); @@ -266,13 +324,15 @@ export function ActivityTimeline({ className }: { className?: string }) { const [dateFrom, setDateFrom] = useState(""); const [dateTo, setDateTo] = useState(""); const [searchInput, setSearchInput] = useState(""); + const [statusFilter, setStatusFilter] = useState("all"); const abortRef = useRef(null); const loadTimeline = useCallback(() => { if (!address || !client) return; abortRef.current?.abort(); - abortRef.current = new AbortController(); + const controller = new AbortController(); + abortRef.current = controller; setLoading(true); setError(null); @@ -293,6 +353,10 @@ export function ActivityTimeline({ className }: { className?: string }) { filters: f, }) .then(({ data, error: err, total: t }) => { + // A newer request (or unmount) has already superseded this one; + // applying its response now would overwrite fresher state with + // stale data. + if (controller.signal.aborted) return; if (err) { setError(err); return; @@ -302,9 +366,11 @@ export function ActivityTimeline({ className }: { className?: string }) { setError(null); }) .catch((err) => { + if (controller.signal.aborted) return; setError(err instanceof Error ? err.message : "Failed to load activity"); }) .finally(() => { + if (controller.signal.aborted) return; setLoading(false); }); }, [address, client, page, filters, dateFrom, dateTo, searchInput]); @@ -316,10 +382,16 @@ export function ActivityTimeline({ className }: { className?: string }) { }, 0); return () => { window.clearTimeout(timerId); + // Unmounting (or a dependency change about to re-run this effect) + // invalidates any fetch loadTimeline already kicked off; its + // eventual response is handled by the aborted-signal check inside + // loadTimeline itself. + abortRef.current?.abort(); }; }, [address, loadTimeline]); const totalPages = Math.ceil(total / PAGE_SIZE); + const visibleGroups = filterGroupsByStatus(groups, statusFilter); const handleCopyHash = useCallback((hash: string) => { navigator.clipboard.writeText(hash).catch(() => {}); @@ -330,6 +402,7 @@ export function ActivityTimeline({ className }: { className?: string }) { setDateFrom(""); setDateTo(""); setSearchInput(""); + setStatusFilter("all"); setPage(1); }, []); @@ -337,7 +410,8 @@ export function ActivityTimeline({ className }: { className?: string }) { filters.operationType !== "all" || dateFrom !== "" || dateTo !== "" || - searchInput !== ""; + searchInput !== "" || + statusFilter !== "all"; return (
+
+ {STATUS_FILTER_CHIPS.map((chip) => ( + + ))} +
+ {filterOpen && (
@@ -470,31 +566,25 @@ export function ActivityTimeline({ className }: { className?: string }) { ))}
- ) : groups.length === 0 ? ( + ) : visibleGroups.length === 0 ? (

{hasActiveFilters ? "No transactions match your filters" : "No activity yet"}

) : ( <>
- {groups.map((group, groupIndex) => ( + {visibleGroups.map((group) => (
- {groupIndex > 0 && ( -
-
- - {formatDateLabel(group.date)} - -
-
- )} - {groupIndex === 0 && ( -
- - {formatDateLabel(group.date)} - -
- )} + {/* Sticky so the date a transaction belongs to stays + visible while scrolling through a long group (issue + #960). */} +
+
+ + {formatDateLabel(group.date)} + +
+
{group.transactions.map((tx) => { const url = explorerTxUrl(network?.name ?? null, tx.hash); return ( diff --git a/src/components/TransactionHistory.tsx b/src/components/TransactionHistory.tsx index ab77705..4dfe683 100644 --- a/src/components/TransactionHistory.tsx +++ b/src/components/TransactionHistory.tsx @@ -5,6 +5,7 @@ import { CheckmarkCircle01Icon, CoinsSwapIcon, ContractsIcon, + Copy01Icon, GiftIcon, Search01Icon, } from "@hugeicons/core-free-icons"; @@ -217,8 +218,11 @@ export const TxRow = memo(function TxRow({ className="flex items-center justify-between px-5 py-3.5 border-b border-line last:border-0 gap-4 hover:bg-surface-2 transition-colors cursor-pointer" >
- {/* Status icon */} + {/* Status icon: purely decorative, hidden from the accessibility + tree since the Success/Failed badge below already announces + the same status as real text (issue #961). */}