diff --git a/backend/src/routes/agents.js b/backend/src/routes/agents.js index dd2a3cfb..675b2c6c 100644 --- a/backend/src/routes/agents.js +++ b/backend/src/routes/agents.js @@ -69,6 +69,14 @@ async function getCachedAgents() { return agentsCache; } +function getAgentTier(score) { + if (score >= 1000) return 'elite'; + if (score >= 900) return 'trusted'; + if (score >= 600) return 'established'; + if (score >= 300) return 'building'; + return 'new'; +} + function sortAgents(agents, sort) { return [...agents].sort((a, b) => { if (sort === 'score') return b.score - a.score; @@ -97,9 +105,15 @@ router.get('/agents', requireAgentsContract, async (req, res) => { const sort = ['score', 'payments', 'newest'].includes(req.query.sort) ? req.query.sort : 'score'; + const tier = ['all', 'new', 'building', 'established', 'trusted', 'elite'].includes(req.query.tier) + ? req.query.tier + : 'all'; const allAgents = await getCachedAgents(); - const sorted = sortAgents(allAgents, sort); + const filteredAgents = tier === 'all' + ? allAgents + : allAgents.filter((agent) => getAgentTier(agent.score) === tier); + const sorted = sortAgents(filteredAgents, sort); const total = sorted.length; const agents = sorted.slice(page * pageSize, (page + 1) * pageSize); diff --git a/frontend/__tests__/AgentsPage.test.tsx b/frontend/__tests__/AgentsPage.test.tsx index b821b425..db15b141 100644 --- a/frontend/__tests__/AgentsPage.test.tsx +++ b/frontend/__tests__/AgentsPage.test.tsx @@ -55,6 +55,19 @@ const mockStats: AgentStats = { totalVolumeStroops: '10000000', }; +function makeAgent(overrides: Partial = {}): AgentEntry { + return { + ...mockAgent, + ...overrides, + address: overrides.address ?? mockAgent.address, + name: overrides.name ?? mockAgent.name, + score: overrides.score ?? mockAgent.score, + total_payments: overrides.total_payments ?? mockAgent.total_payments, + successful_payments: overrides.successful_payments ?? mockAgent.successful_payments, + failed_payments: overrides.failed_payments ?? mockAgent.failed_payments, + }; +} + describe('AgentsPage retry state', () => { beforeEach(() => { jest.clearAllMocks(); @@ -81,3 +94,221 @@ describe('AgentsPage retry state', () => { expect(screen.queryByText('Network disconnected')).not.toBeInTheDocument(); }); }); + +describe('AgentsPage data rendering', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the agent grid when agents exist even if stats are unavailable', async () => { + (fetchAgents as jest.Mock).mockResolvedValue({ + agents: [makeAgent({ address: 'A', name: 'Visible Agent' })], + total: 1, + page: 0, + pageSize: PAGE_SIZE, + }); + (fetchAgentStats as jest.Mock).mockRejectedValue(new Error('Stats unavailable')); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText('Visible Agent')).toBeInTheDocument(); + }); + expect(screen.queryByText('No agents registered yet.')).not.toBeInTheDocument(); + }); +}); + +describe('AgentsPage score tier filter', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('defaults to the All filter and shows all agents', async () => { + (fetchAgents as jest.Mock).mockResolvedValue({ + agents: [ + makeAgent({ address: 'A', name: 'New Agent', score: 100, total_payments: '1' }), + makeAgent({ address: 'B', name: 'Building Agent', score: 350, total_payments: '2' }), + makeAgent({ address: 'C', name: 'Established Agent', score: 650, total_payments: '3' }), + makeAgent({ address: 'D', name: 'Trusted Agent', score: 950, total_payments: '4' }), + makeAgent({ address: 'E', name: 'Elite Agent', score: 1000, total_payments: '5' }), + ], + total: 5, + page: 0, + pageSize: PAGE_SIZE, + }); + (fetchAgentStats as jest.Mock).mockResolvedValue(mockStats); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText('New Agent')).toBeInTheDocument(); + expect(screen.getByText('Elite Agent')).toBeInTheDocument(); + }); + + expect(screen.getByRole('button', { name: 'All' })).toHaveAttribute('aria-pressed', 'true'); + }); + + it('filters agents by each score tier', async () => { + (fetchAgents as jest.Mock).mockImplementation((_page, _pageSize, _sort, tier) => { + if (tier === 'trusted') { + return Promise.resolve({ + agents: [makeAgent({ address: 'D', name: 'Trusted Agent', score: 950, total_payments: '4' })], + total: 1, + page: 0, + pageSize: PAGE_SIZE, + }); + } + + return Promise.resolve({ + agents: [ + makeAgent({ address: 'A', name: 'New Agent', score: 100, total_payments: '1' }), + makeAgent({ address: 'B', name: 'Building Agent', score: 350, total_payments: '2' }), + makeAgent({ address: 'C', name: 'Established Agent', score: 650, total_payments: '3' }), + makeAgent({ address: 'D', name: 'Trusted Agent', score: 950, total_payments: '4' }), + makeAgent({ address: 'E', name: 'Elite Agent', score: 1000, total_payments: '5' }), + ], + total: 5, + page: 0, + pageSize: PAGE_SIZE, + }); + }); + (fetchAgentStats as jest.Mock).mockResolvedValue(mockStats); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText('Trusted Agent')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: 'Trusted' })); + + await waitFor(() => { + expect(screen.getByText('Trusted Agent')).toBeInTheDocument(); + }); + expect(screen.queryByText('New Agent')).not.toBeInTheDocument(); + expect(screen.queryByText('Elite Agent')).not.toBeInTheDocument(); + }); + + it('uses the server total when a tier has matches beyond the first page', async () => { + (fetchAgents as jest.Mock).mockResolvedValue({ + agents: [makeAgent({ address: 'A', name: 'Building One', score: 350, total_payments: '2' })], + total: 13, + page: 0, + pageSize: PAGE_SIZE, + }); + (fetchAgentStats as jest.Mock).mockResolvedValue(mockStats); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText('Building One')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: 'Building' })); + + expect(fetchAgents).toHaveBeenLastCalledWith(0, PAGE_SIZE, 'score', 'building'); + expect(screen.getByText('Showing 1–12 of 13')).toBeInTheDocument(); + }); + + it('applies the selected tier filter alongside sorting', async () => { + (fetchAgents as jest.Mock).mockImplementation((_page, _pageSize, sort, tier) => { + if (tier === 'building') { + return Promise.resolve({ + agents: [ + makeAgent({ address: 'B', name: 'Building Two', score: 400, total_payments: '8' }), + makeAgent({ address: 'A', name: 'Building One', score: 350, total_payments: '2' }), + ], + total: 2, + page: 0, + pageSize: PAGE_SIZE, + }); + } + + return Promise.resolve({ + agents: [ + makeAgent({ address: 'A', name: 'Building One', score: 350, total_payments: '2' }), + makeAgent({ address: 'C', name: 'Trusted Agent', score: 950, total_payments: '4' }), + ], + total: 2, + page: 0, + pageSize: PAGE_SIZE, + }); + }); + (fetchAgentStats as jest.Mock).mockResolvedValue(mockStats); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText('Building One')).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByLabelText('Sort agents'), { target: { value: 'payments' } }); + fireEvent.click(screen.getByRole('button', { name: 'Building' })); + + await waitFor(() => { + expect(screen.getByText('Building Two')).toBeInTheDocument(); + }); + + const visibleNames = screen.getAllByRole('link', { name: /Building/i }).map((element) => element.textContent); + expect(visibleNames).toContain('Building Two'); + expect(visibleNames[0]).toBe('Building Two'); + }); + + it('shows an empty state when a selected tier has no matches', async () => { + (fetchAgents as jest.Mock).mockImplementation((_page, _pageSize, _sort, tier) => { + if (tier === 'elite') { + return Promise.resolve({ + agents: [], + total: 0, + page: 0, + pageSize: PAGE_SIZE, + }); + } + + return Promise.resolve({ + agents: [makeAgent({ address: 'A', name: 'New Agent', score: 100, total_payments: '1' })], + total: 1, + page: 0, + pageSize: PAGE_SIZE, + }); + }); + (fetchAgentStats as jest.Mock).mockResolvedValue(mockStats); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText('New Agent')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: 'Elite' })); + + await waitFor(() => { + expect(screen.getByText('No agents match the selected tier.')).toBeInTheDocument(); + }); + }); + + it('resets the filter when the All chip is selected', async () => { + (fetchAgents as jest.Mock).mockResolvedValue({ + agents: [ + makeAgent({ address: 'A', name: 'New Agent', score: 100, total_payments: '1' }), + makeAgent({ address: 'B', name: 'Trusted Agent', score: 950, total_payments: '4' }), + ], + total: 2, + page: 0, + pageSize: PAGE_SIZE, + }); + (fetchAgentStats as jest.Mock).mockResolvedValue(mockStats); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText('Trusted Agent')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: 'Trusted' })); + fireEvent.click(screen.getByRole('button', { name: 'All' })); + + expect(screen.getByText('New Agent')).toBeInTheDocument(); + expect(screen.getByText('Trusted Agent')).toBeInTheDocument(); + }); +}); diff --git a/frontend/app/agents/page.tsx b/frontend/app/agents/page.tsx index 56bb0218..b597f917 100644 --- a/frontend/app/agents/page.tsx +++ b/frontend/app/agents/page.tsx @@ -3,9 +3,9 @@ import { useEffect, useState } from 'react'; import useSWR from 'swr'; import Link from 'next/link'; -import type { AgentsResponse, AgentStats, AgentSortOption } from '@/lib/types'; +import type { AgentsResponse, AgentStats, AgentSortOption, ScoreTier } from '@/lib/types'; +import { TIER_LABELS } from '@/lib/types'; import { fetchAgents, fetchAgentStats } from '@/lib/contract'; -import { sortAgents } from '@/lib/sort'; import AgentCard from '@/components/AgentCard'; import AgentCardSkeleton from '@/components/AgentCardSkeleton'; import ScoreBadge from '@/components/ScoreBadge'; @@ -16,10 +16,19 @@ const SORTS: { label: string; value: AgentSortOption }[] = [ { label: 'Newest', value: 'newest' }, ]; +const TIER_FILTERS: { label: string; value: ScoreTier | 'all' }[] = [ + { label: 'All', value: 'all' }, + ...(['new', 'building', 'established', 'trusted', 'elite'] as const).map((value) => ({ + label: TIER_LABELS[value], + value, + })), +]; + import { PAGE_SIZE, PAGE_SIZE_OPTIONS } from '@/lib/pagination'; export default function AgentsPage() { const [sort, setSort] = useState('score'); + const [activeTier, setActiveTier] = useState('all'); const [page, setPage] = useState(0); const [pageSize, setPageSize] = useState(PAGE_SIZE); @@ -33,8 +42,8 @@ export default function AgentsPage() { isValidating, mutate, } = useSWR( - ['agents', page, pageSize, sort], - () => fetchAgents(page, pageSize, sort), + ['agents', page, pageSize, sort, activeTier], + () => fetchAgents(page, pageSize, sort, activeTier), { refreshInterval: 30_000, revalidateOnFocus: false, keepPreviousData: true } ); @@ -57,18 +66,22 @@ export default function AgentsPage() { : 'Failed to load' : null; - // Sort agents locally based on the selected sort option - const sortedAgents = sortAgents(agents, sort); + const hasAnyAgents = total > 0; + const statsAvailable = stats !== null; + const showNoAgentsState = !loading && !error && !hasAnyAgents && (!statsAvailable || (stats?.totalAgents ?? 0) === 0); + const showTierEmptyState = !loading && !error && !hasAnyAgents && statsAvailable && (stats?.totalAgents ?? 0) > 0; - // Clamp the page if the dataset shrank (e.g. agents removed between polls). + const visibleCount = total; + + // Clamp the page if the filtered dataset shrank (e.g. agents removed or tier changed). useEffect(() => { - if (!data) return; - const maxPage = data.total > 0 ? Math.max(0, Math.ceil(data.total / pageSize) - 1) : 0; + const maxPage = visibleCount > 0 ? Math.max(0, Math.ceil(visibleCount / pageSize) - 1) : 0; if (page > maxPage) setPage(maxPage); - }, [data, page, pageSize]); + }, [page, pageSize, visibleCount]); - const totalPages = Math.max(1, Math.ceil(total / pageSize)); + const totalPages = Math.max(1, Math.ceil(visibleCount / pageSize)); const pageStart = page * pageSize; + const pagedAgents = agents; function handleSortChange(next: AgentSortOption) { setSort(next); @@ -80,6 +93,11 @@ export default function AgentsPage() { setPage(0); } + function handleTierChange(next: ScoreTier | 'all') { + setActiveTier(next); + setPage(0); + } + return (
{/* Header */} @@ -137,7 +155,7 @@ export default function AgentsPage() { )} {/* Score tier legend */} -
+
Score tiers {([100, 450, 700, 950, 1000] as const).map((score) => ( @@ -147,6 +165,24 @@ export default function AgentsPage() {
+
+ {TIER_FILTERS.map((tier) => ( + + ))} +
+ {/* Content */} {loading && (
@@ -175,7 +211,7 @@ export default function AgentsPage() {
)} - {!loading && !error && total === 0 && ( + {!loading && !error && showNoAgentsState && (

No agents registered yet.

@@ -184,10 +220,23 @@ export default function AgentsPage() {
)} - {!loading && !error && total > 0 && ( + {!loading && !error && showTierEmptyState && ( +
+

No agents match the selected tier.

+ +
+ )} + + {!loading && !error && hasAnyAgents && total > 0 && (
- {sortedAgents.map((agent) => ( + {pagedAgents.map((agent) => ( ))}
@@ -195,7 +244,7 @@ export default function AgentsPage() { {totalPages > 1 && (
- Showing {pageStart + 1}–{Math.min(pageStart + pageSize, total)} of {total} + Showing {pageStart + 1}–{Math.min(pageStart + pageSize, visibleCount)} of {visibleCount}