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
148 changes: 148 additions & 0 deletions apps/web/app/dashboard/activity.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { HistoryTx } from "@/lib/history-types";
import { ActivityPanel } from "./activity";

vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/dashboard",
}));

const ACCOUNT = "GACCOUNT0000000000000000000000000000000000000000000000000AAAA";
const COUNTERPARTY = "GCOUNTERPARTY00000000000000000000000000000000000000000AAAA";
const HASH = "a".repeat(64);

function row(overrides: Partial<HistoryTx> = {}): HistoryTx {
return {
id: "soroban:CUSDC:1-0",
kind: "soroban",
direction: "out",
counterparty: COUNTERPARTY,
amount: 2_500_000n, // 0.25 USDC at 7dp
asset: { kind: "soroban", code: "USDC", decimals: 7, contractId: "CUSDC" },
timestamp: "2026-09-28T03:13:02.000Z",
status: "success",
txHash: HASH,
...overrides,
};
}

const { useActivityMock } = vi.hoisted(() => ({ useActivityMock: vi.fn() }));
vi.mock("@/lib/activity", () => ({ useActivity: useActivityMock }));

function activity(overrides: Record<string, unknown> = {}) {
return {
rows: [row()],
isPending: false,
isError: false,
isFetching: false,
isFetchingNextPage: false,
hasMore: false,
refetch: vi.fn(),
loadMore: vi.fn(),
...overrides,
};
}

beforeEach(() => {
vi.clearAllMocks();
});

describe("ActivityPanel", () => {
it("shows a loading state without blocking on history", () => {
useActivityMock.mockReturnValue(activity({ isPending: true, rows: [] }));
render(<ActivityPanel accountId={ACCOUNT} network="testnet" />);
expect(screen.getByText(/Loading activity/i)).toBeDefined();
});

it("surfaces an explicit retry state instead of an empty list when history fails", () => {
// "History that intermittently shows nothing is worse than no history."
useActivityMock.mockReturnValue(
activity({ isError: true, isPending: false, rows: [] }),
);
render(<ActivityPanel accountId={ACCOUNT} network="testnet" />);
expect(screen.getByRole("alert").textContent).toMatch(/couldn.t be loaded/i);
expect(screen.getByRole("button", { name: /retry/i })).toBeDefined();
});

it("distinguishes a genuinely empty history from a failure", () => {
useActivityMock.mockReturnValue(activity({ rows: [] }));
render(<ActivityPanel accountId={ACCOUNT} network="testnet" />);
expect(screen.getByText(/No transactions yet/i)).toBeDefined();
expect(screen.queryByRole("alert")).toBeNull();
});

it("always renders an amount together with its asset", () => {
useActivityMock.mockReturnValue(
activity({
rows: [
row(),
row({
id: "classic:2",
kind: "classic",
amount: 123_456_789n, // 12.3456789 XLM
asset: { kind: "classic", code: "XLM", decimals: 7, contractId: "" },
}),
],
}),
);
render(<ActivityPanel accountId={ACCOUNT} network="testnet" />);

const items = screen.getAllByRole("listitem");
// Every row's accessible name names the asset, so a bare number is never
// announced on its own.
expect(items[0]?.textContent).toMatch(/0\.25 USDC/);
expect(items[1]?.textContent).toMatch(/12\.3456789 XLM/);
const labels = items.map(
(item) => item.querySelector("b > span")?.getAttribute("aria-label"),
);
expect(labels).toEqual([
expect.stringContaining("0.25 USDC"),
expect.stringContaining("12.3456789 XLM"),
]);
});

it("links each row to the explorer entry for its transaction hash", () => {
useActivityMock.mockReturnValue(activity());
render(<ActivityPanel accountId={ACCOUNT} network="testnet" />);
const link = screen.getByRole("listitem") as HTMLAnchorElement;
expect(link.tagName).toBe("A");
expect(link.getAttribute("href")).toBe(
`https://stellar.expert/explorer/testnet/tx/${HASH}`,
);
expect(link.getAttribute("rel")).toBe("noreferrer");
});

it("marks a failed transaction as failed rather than hiding it", () => {
useActivityMock.mockReturnValue(activity({ rows: [row({ status: "failed" })] }));
render(<ActivityPanel accountId={ACCOUNT} network="testnet" />);
expect(screen.getByText(/failed/i)).toBeDefined();
});

it("loads older rows on demand and stops at the end of history", async () => {
const loadMore = vi.fn();
const result = activity({ hasMore: true, loadMore });
useActivityMock.mockReturnValue(result);
const { unmount } = render(<ActivityPanel accountId={ACCOUNT} network="testnet" />);

fireEvent.click(screen.getByRole("button", { name: /load older/i }));
expect(loadMore).toHaveBeenCalled();
unmount();

// Exhausted history says so instead of offering a button that does nothing.
useActivityMock.mockReturnValue(activity({ hasMore: false }));
render(<ActivityPanel accountId={ACCOUNT} network="testnet" />);
expect(screen.getByText(/end of history/i)).toBeDefined();
expect(screen.queryByRole("button", { name: /load older/i })).toBeNull();
});

it("shows direction and counterparty for an incoming transfer", async () => {
useActivityMock.mockReturnValue(
activity({ rows: [row({ direction: "in", amount: 1_000_000n })] }),
);
render(<ActivityPanel accountId={ACCOUNT} network="testnet" />);
const text = screen.getByRole("listitem").textContent ?? "";
expect(text).toMatch(/Received · USDC/);
await waitFor(() => expect(text).toContain("+0.1 USDC"));
});
});
172 changes: 172 additions & 0 deletions apps/web/app/dashboard/activity.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
"use client";

import { formatTokenAmount } from "vellar-sdk";
import type { Network } from "@vellar/types";
import { LpActionButton } from "@/app/landing/ui";
import { useActivity } from "@/lib/activity";
import type { HistoryTx } from "@/lib/history-types";
import { explorerTxUrl } from "@/lib/explorer";

// Activity panel (issue #403, technical-doc.md §5.2): per-transaction
// direction, counterparty, asset + amount, timestamp, status, and an explorer
// link for the tx hash. Rendered inside the dashboard grid — history loading
// must never block the dashboard, so this panel owns its own states and the
// page stays live while it resolves.

const MONO = "font-[family-name:var(--lp-mono)]";

export function ActivityPanel({
accountId,
network,
}: {
accountId: string;
network: Network;
}) {
const activity = useActivity(accountId, network);

if (activity.isPending) {
return (
<p className="mt-3.5! animate-pulse text-sm text-[var(--lp-ink-faint)]" aria-live="polite">
Loading activity…
</p>
);
}

if (activity.isError) {
// Transient failures are retried inside the client; a surfaced error here
// is real — show it and a retry affordance, never an empty list.
return (
<div className="mt-3.5">
<p role="alert" className="lpa-bad text-sm">
History couldn&apos;t be loaded — the network view was unavailable.
</p>
<LpActionButton
variant="outline"
size="sm"
className="mt-2.5"
onClick={() => void activity.refetch()}
>
Retry
</LpActionButton>
</div>
);
}

const { rows, hasMore, isLoadingMore, loadMore } = activity;

if (rows.length === 0) {
return (
<div className="lpa-empty mt-6">
<div className="ph" />
<p className="text-sm!">No transactions yet</p>
<p className="max-w-[220px] text-[13px] text-[var(--lp-ink-faint)]">
Payments you send or receive will appear here.
</p>
</div>
);
}

return (
<div className="mt-1.5">
<div role="list" aria-label="Transaction history">
{rows.map((row) => (
<ActivityRow key={row.id} row={row} accountId={accountId} network={network} />
))}
</div>

<div className="mt-3 flex items-center gap-3">
{hasMore ? (
<LpActionButton
variant="outline"
size="sm"
onClick={() => void loadMore()}
disabled={isLoadingMore}
>
{isLoadingMore ? "Loading…" : "Load older"}
</LpActionButton>
) : (
<span className="text-[13px] text-[var(--lp-ink-faint)]">End of history</span>
)}
<LpActionButton
variant="ghost"
size="sm"
onClick={() => void activity.refetch()}
disabled={activity.isFetching}
>
Refresh
</LpActionButton>
</div>
</div>
);
}

function ActivityRow({
row,
accountId,
network,
}: {
row: HistoryTx;
accountId: string;
network: Network;
}) {
const signed = row.direction === "out" ? "−" : row.direction === "in" ? "+" : "±";
const dirLabel =
row.direction === "out" ? "Sent" : row.direction === "in" ? "Received" : "Self";
const amount = formatTokenAmount(
row.amount < 0n ? -row.amount : row.amount,
row.asset.decimals,
);
const counterpartyLabel = shortAddress(row.counterparty || accountId);
const failed = row.status === "failed";

return (
<a
role="listitem"
href={explorerTxUrl(row.txHash, network)}
target="_blank"
rel="noreferrer"
className="lpa-tokrow"
title={`View transaction ${row.txHash}`}
>
<div className="ti" aria-hidden="true" />
<div className="tn">
<b>
{dirLabel} · {row.asset.code}
</b>
<span className={MONO}>{counterpartyLabel}</span>
<span className={MONO}>{formatTimestamp(row.timestamp)}</span>
</div>
<div className="tv">
<b className="lpa-amt text-base">
{/* The asset symbol is part of the amount's accessible name, so the
number is never announced — or read — without its unit. */}
<span aria-label={`${dirLabel} ${amount} ${row.asset.code}${failed ? ", failed" : ""}`}>
{signed}
{amount} {row.asset.code}
</span>
</b>
<span
className={`block text-right text-[11px] ${MONO} ${failed ? "lpa-bad" : "text-[var(--lp-ink-faint)]"}`}
>
{failed ? "✗ failed" : "✓ success"}
</span>
</div>
</a>
);
}

export function shortAddress(address: string): string {
if (address.length <= 12) return address;
return `${address.slice(0, 6)}…${address.slice(-6)}`;
}

export function formatTimestamp(iso: string): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return iso;
return date.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
22 changes: 22 additions & 0 deletions apps/web/app/dashboard/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,27 @@ vi.mock("@/lib/balances", () => ({
useBalances: useBalancesMock,
}));

// The activity panel owns a react-query infinite query against testnet; the
// dashboard tests are about the surrounding grid, so it is stubbed the same
// way balances is. Its own behaviour is covered in activity.test.tsx.
const { useActivityMock } = vi.hoisted(() => ({ useActivityMock: vi.fn() }));
vi.mock("@/lib/activity", () => ({
useActivity: useActivityMock,
}));

function noActivity() {
useActivityMock.mockReturnValue({
rows: [],
isPending: false,
isError: false,
isFetching: false,
isFetchingNextPage: false,
hasMore: false,
refetch: vi.fn(),
loadMore: vi.fn(),
});
}

const session: WalletSession = {
accountId: "CDASHBOARD1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ234567XYZ99",
network: "testnet",
Expand All @@ -36,6 +57,7 @@ function withBalances() {
beforeEach(() => {
vi.clearAllMocks();
window.localStorage.clear();
noActivity();
useBalancesMock.mockReturnValue({
data: undefined,
isPending: true,
Expand Down
Loading