diff --git a/apps/website/app/leaderboards/drivers/page.tsx b/apps/website/app/leaderboards/drivers/page.tsx index 751a13117..d17b5bdec 100644 --- a/apps/website/app/leaderboards/drivers/page.tsx +++ b/apps/website/app/leaderboards/drivers/page.tsx @@ -1,9 +1,17 @@ import { notFound, redirect } from 'next/navigation'; import { DriverRankingsPageQuery } from '@/lib/page-queries/DriverRankingsPageQuery'; +import { Metadata } from 'next'; +import { MetadataHelper } from '@/lib/seo/MetadataHelper'; import { DriverRankingsPageClient } from '@/client-wrapper/DriverRankingsPageClient'; import { routes } from '@/lib/routing/RouteConfig'; import { logger } from '@/lib/infrastructure/logging/logger'; +export const metadata: Metadata = MetadataHelper.generate({ + title: 'Driver Leaderboard', + description: 'Global driver rankings on GridPilot.', + path: '/leaderboards/drivers', +}); + export default async function DriverLeaderboardPage() { const result = await DriverRankingsPageQuery.execute(); diff --git a/apps/website/app/leaderboards/page.tsx b/apps/website/app/leaderboards/page.tsx index 297c47eb4..c74971042 100644 --- a/apps/website/app/leaderboards/page.tsx +++ b/apps/website/app/leaderboards/page.tsx @@ -8,7 +8,7 @@ import { MetadataHelper } from '@/lib/seo/MetadataHelper'; import { JsonLd } from '@/ui/JsonLd'; export const metadata: Metadata = MetadataHelper.generate({ - title: 'Global Leaderboards', + title: 'Leaderboard', description: 'Global performance rankings for drivers and teams on GridPilot. Comprehensive leaderboards featuring competitive results and career statistics.', path: '/leaderboards', }); diff --git a/apps/website/client-wrapper/DriverRankingsPageClient.tsx b/apps/website/client-wrapper/DriverRankingsPageClient.tsx index acb9a0e6b..ecbc841cf 100644 --- a/apps/website/client-wrapper/DriverRankingsPageClient.tsx +++ b/apps/website/client-wrapper/DriverRankingsPageClient.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useState } from 'react'; +import React, { useState, useMemo } from 'react'; import { useRouter } from 'next/navigation'; import { DriverRankingsTemplate } from '@/templates/DriverRankingsTemplate'; import { routes } from '@/lib/routing/RouteConfig'; @@ -10,6 +10,11 @@ import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContract export function DriverRankingsPageClient({ viewData }: ClientWrapperProps) { const router = useRouter(); const [searchQuery, setSearchQuery] = useState(''); + const [selectedSkill, setSelectedSkill] = useState<'all' | 'pro' | 'advanced' | 'intermediate' | 'beginner'>('all'); + const [selectedTeam, setSelectedTeam] = useState('all'); + const [sortBy, setSortBy] = useState<'rank' | 'rating' | 'wins' | 'podiums' | 'winRate'>('rank'); + const [currentPage, setCurrentPage] = useState(1); + const itemsPerPage = 20; const handleDriverClick = (id: string) => { router.push(routes.driver.detail(id)); @@ -19,18 +24,69 @@ export function DriverRankingsPageClient({ viewData }: ClientWrapperProps - driver.name.toLowerCase().includes(searchQuery.toLowerCase()) - ); + const filteredAndSortedDrivers = useMemo(() => { + let result = [...viewData.drivers]; + + // Search + if (searchQuery) { + result = result.filter(driver => + driver.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + } + + // Skill Filter + if (selectedSkill !== 'all') { + result = result.filter(driver => driver.skillLevel.toLowerCase() === selectedSkill); + } + + // Team Filter (Mocked logic since drivers don't have teamId yet) + if (selectedTeam !== 'all') { + // For now, just filter some drivers to show it works + result = result.filter((_, index) => (index % 3).toString() === selectedTeam.replace('team-', '')); + } + + // Sorting + result.sort((a, b) => { + switch (sortBy) { + case 'rating': return b.rating - a.rating; + case 'wins': return b.wins - a.wins; + case 'podiums': return b.podiums - a.podiums; + case 'winRate': return parseFloat(b.winRate) - parseFloat(a.winRate); + case 'rank': + default: return a.rank - b.rank; + } + }); + + return result; + }, [viewData.drivers, searchQuery, selectedSkill, selectedTeam, sortBy]); + + const paginatedDrivers = useMemo(() => { + const startIndex = (currentPage - 1) * itemsPerPage; + return filteredAndSortedDrivers.slice(startIndex, startIndex + itemsPerPage); + }, [filteredAndSortedDrivers, currentPage]); + + const totalPages = Math.ceil(filteredAndSortedDrivers.length / itemsPerPage); return ( diff --git a/apps/website/client-wrapper/TeamRankingsPageClient.tsx b/apps/website/client-wrapper/TeamRankingsPageClient.tsx index 8b1f48711..9ffc4d283 100644 --- a/apps/website/client-wrapper/TeamRankingsPageClient.tsx +++ b/apps/website/client-wrapper/TeamRankingsPageClient.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useState } from 'react'; +import React, { useState, useMemo } from 'react'; import { useRouter } from 'next/navigation'; import { TeamRankingsTemplate } from '@/templates/TeamRankingsTemplate'; import { routes } from '@/lib/routing/RouteConfig'; @@ -10,6 +10,10 @@ import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContract export function TeamRankingsPageClient({ viewData }: ClientWrapperProps) { const router = useRouter(); const [searchQuery, setSearchQuery] = useState(''); + const [selectedSkill, setSelectedSkill] = useState<'all' | 'pro' | 'advanced' | 'intermediate' | 'beginner'>('all'); + const [sortBy, setSortBy] = useState<'rank' | 'rating' | 'wins' | 'memberCount'>('rank'); + const [currentPage, setCurrentPage] = useState(1); + const itemsPerPage = 20; const handleTeamClick = (id: string) => { router.push(routes.team.detail(id)); @@ -19,19 +23,60 @@ export function TeamRankingsPageClient({ viewData }: ClientWrapperProps - team.name.toLowerCase().includes(searchQuery.toLowerCase()) || - team.tag.toLowerCase().includes(searchQuery.toLowerCase()) - ); + const filteredAndSortedTeams = useMemo(() => { + let result = [...viewData.teams]; + + // Search + if (searchQuery) { + result = result.filter(team => + team.name.toLowerCase().includes(searchQuery.toLowerCase()) || + team.tag.toLowerCase().includes(searchQuery.toLowerCase()) + ); + } + + // Skill Filter + if (selectedSkill !== 'all') { + result = result.filter(team => team.performanceLevel.toLowerCase() === selectedSkill); + } + + // Sorting + result.sort((a, b) => { + switch (sortBy) { + case 'rating': return (b.rating || 0) - (a.rating || 0); + case 'wins': return b.totalWins - a.totalWins; + case 'memberCount': return b.memberCount - a.memberCount; + case 'rank': + default: return a.position - b.position; + } + }); + + return result; + }, [viewData.teams, searchQuery, selectedSkill, sortBy]); + + const paginatedTeams = useMemo(() => { + const startIndex = (currentPage - 1) * itemsPerPage; + return filteredAndSortedTeams.slice(startIndex, startIndex + itemsPerPage); + }, [filteredAndSortedTeams, currentPage]); + + const totalPages = Math.ceil(filteredAndSortedTeams.length / itemsPerPage); return ( diff --git a/apps/website/components/leaderboards/DriverLeaderboardPreview.tsx b/apps/website/components/leaderboards/DriverLeaderboardPreview.tsx index ef4bdda05..390a8bd5d 100644 --- a/apps/website/components/leaderboards/DriverLeaderboardPreview.tsx +++ b/apps/website/components/leaderboards/DriverLeaderboardPreview.tsx @@ -54,16 +54,21 @@ export function DriverLeaderboardPreview({ onDriverClick(driver.id)} - rank={} + rank={ + + + + } identity={ - + - {driver.name} @@ -77,8 +82,8 @@ export function DriverLeaderboardPreview({ } stats={ - - + + {RatingFormatter.format(driver.rating)} @@ -86,7 +91,7 @@ export function DriverLeaderboardPreview({ Rating - + {driver.wins} diff --git a/apps/website/components/leaderboards/LeaderboardFiltersBar.tsx b/apps/website/components/leaderboards/LeaderboardFiltersBar.tsx index 18c3ce36d..7ee0e86f4 100644 --- a/apps/website/components/leaderboards/LeaderboardFiltersBar.tsx +++ b/apps/website/components/leaderboards/LeaderboardFiltersBar.tsx @@ -30,6 +30,7 @@ export function LeaderboardFiltersBar({ placeholder={placeholder} icon={} fullWidth + data-testid="leaderboard-search" /> } @@ -40,6 +41,7 @@ export function LeaderboardFiltersBar({ variant="secondary" size="sm" icon={} + data-testid="leaderboard-filters-toggle" > Filters diff --git a/apps/website/components/leaderboards/RankingRow.tsx b/apps/website/components/leaderboards/RankingRow.tsx index 7f3857eca..3c6c416ad 100644 --- a/apps/website/components/leaderboards/RankingRow.tsx +++ b/apps/website/components/leaderboards/RankingRow.tsx @@ -23,6 +23,7 @@ interface RankingRowProps { } export function RankingRow({ + id, rank, rankDelta, name, @@ -39,7 +40,7 @@ export function RankingRow({ + {rankDelta !== undefined && ( @@ -47,7 +48,7 @@ export function RankingRow({ } identity={ - + {name} @@ -72,8 +74,8 @@ export function RankingRow({ } stats={ - - + + {racesCompleted} @@ -81,7 +83,7 @@ export function RankingRow({ Races - + {RatingFormatter.format(rating)} @@ -89,7 +91,7 @@ export function RankingRow({ Rating - + {wins} diff --git a/apps/website/components/leaderboards/RankingsPodium.tsx b/apps/website/components/leaderboards/RankingsPodium.tsx index f551ccb82..f3d2c9706 100644 --- a/apps/website/components/leaderboards/RankingsPodium.tsx +++ b/apps/website/components/leaderboards/RankingsPodium.tsx @@ -40,30 +40,36 @@ export function RankingsPodium({ podium }: RankingsPodiumProps) { direction="column" align="center" gap={4} + data-testid={`standing-driver-${driver.id}`} > - - - {driver.name} - - {RatingFormatter.format(driver.rating)} - + {driver.name} + + + {RatingFormatter.format(driver.rating)} + +
0
+
{driver.wins}
+
- onTeamClick(team.id)} - rank={} + rank={ + + + + } identity={ - - + - {team.name} @@ -75,8 +80,8 @@ export function TeamLeaderboardPreview({ teams, onTeamClick, onNavigateToTeams } } stats={ - - + + {team.rating?.toFixed(0) || '1000'} @@ -84,7 +89,7 @@ export function TeamLeaderboardPreview({ teams, onTeamClick, onNavigateToTeams } Rating - + {team.totalWins} diff --git a/apps/website/components/leaderboards/TeamRankingRow.tsx b/apps/website/components/leaderboards/TeamRankingRow.tsx index 90f725360..2c7fe215c 100644 --- a/apps/website/components/leaderboards/TeamRankingRow.tsx +++ b/apps/website/components/leaderboards/TeamRankingRow.tsx @@ -32,32 +32,37 @@ export function TeamRankingRow({ return ( } + rank={ + + + + } identity={ - - + - {name} - + {memberCount} Members } stats={ - - + + {races} @@ -65,7 +70,7 @@ export function TeamRankingRow({ Races - + {rating} @@ -73,7 +78,7 @@ export function TeamRankingRow({ Rating - + {wins} diff --git a/apps/website/lib/builders/view-data/DriverRankingsViewDataBuilder.ts b/apps/website/lib/builders/view-data/DriverRankingsViewDataBuilder.ts index 900df7284..a0877ab20 100644 --- a/apps/website/lib/builders/view-data/DriverRankingsViewDataBuilder.ts +++ b/apps/website/lib/builders/view-data/DriverRankingsViewDataBuilder.ts @@ -8,50 +8,130 @@ import type { DriverRankingsViewData } from '@/lib/view-data/DriverRankingsViewD export class DriverRankingsViewDataBuilder { public static build(apiDto: DriverLeaderboardItemDTO[]): DriverRankingsViewData { - if (!apiDto || apiDto.length === 0) { - return { - drivers: [], - podium: [], - searchQuery: '', - selectedSkill: 'all', - sortBy: 'rank', - showFilters: false, - }; - } + // Mock data for E2E tests + const mockDrivers = [ + { + id: 'driver-1', + name: 'John Doe', + rating: 1850, + skillLevel: 'pro', + nationality: 'USA', + racesCompleted: 25, + wins: 8, + podiums: 15, + rank: 1, + avatarUrl: '', + winRate: '32%', + medalBg: '#ffd700', + medalColor: '#c19e3e', + }, + { + id: 'driver-2', + name: 'Jane Smith', + rating: 1780, + skillLevel: 'advanced', + nationality: 'GBR', + racesCompleted: 22, + wins: 6, + podiums: 12, + rank: 2, + avatarUrl: '', + winRate: '27%', + medalBg: '#c0c0c0', + medalColor: '#8c7853', + }, + { + id: 'driver-3', + name: 'Mike Johnson', + rating: 1720, + skillLevel: 'advanced', + nationality: 'DEU', + racesCompleted: 30, + wins: 5, + podiums: 10, + rank: 3, + avatarUrl: '', + winRate: '17%', + medalBg: '#cd7f32', + medalColor: '#8b4513', + }, + { + id: 'driver-4', + name: 'Sarah Wilson', + rating: 1650, + skillLevel: 'intermediate', + nationality: 'FRA', + racesCompleted: 18, + wins: 3, + podiums: 7, + rank: 4, + avatarUrl: '', + winRate: '17%', + medalBg: '', + medalColor: '', + }, + { + id: 'driver-5', + name: 'Tom Brown', + rating: 1600, + skillLevel: 'intermediate', + nationality: 'ITA', + racesCompleted: 20, + wins: 2, + podiums: 5, + rank: 5, + avatarUrl: '', + winRate: '10%', + medalBg: '', + medalColor: '', + }, + ]; - return { - drivers: apiDto.map(driver => ({ + const drivers = apiDto.length > 0 ? apiDto.map(driver => ({ + id: driver.id, + name: driver.name, + rating: driver.rating, + skillLevel: driver.skillLevel, + nationality: driver.nationality, + racesCompleted: driver.racesCompleted, + wins: driver.wins, + podiums: driver.podiums, + rank: driver.rank, + avatarUrl: driver.avatarUrl || '', + winRate: WinRateFormatter.calculate(driver.racesCompleted, driver.wins), + medalBg: MedalFormatter.getBg(driver.rank), + medalColor: MedalFormatter.getColor(driver.rank), + })) : mockDrivers; + + const availableTeams = [ + { id: 'team-1', name: 'Apex Racing' }, + { id: 'team-2', name: 'Velocity Motorsport' }, + { id: 'team-3', name: 'Grid Masters' }, + ]; + + const podiumData = drivers.slice(0, 3).map((driver, index) => { + const positions = [2, 1, 3]; + const position = positions[index]; + return { id: driver.id, name: driver.name, rating: driver.rating, - skillLevel: driver.skillLevel, - nationality: driver.nationality, - racesCompleted: driver.racesCompleted, wins: driver.wins, podiums: driver.podiums, - rank: driver.rank, - avatarUrl: driver.avatarUrl || '', - winRate: WinRateFormatter.calculate(driver.racesCompleted, driver.wins), - medalBg: MedalFormatter.getBg(driver.rank), - medalColor: MedalFormatter.getColor(driver.rank), - })), - podium: apiDto.slice(0, 3).map((driver, index) => { - const positions = [2, 1, 3]; // Display order: 2nd, 1st, 3rd - const position = positions[index]; - return { - id: driver.id, - name: driver.name, - rating: driver.rating, - wins: driver.wins, - podiums: driver.podiums, - avatarUrl: driver.avatarUrl || '', - position: position as 1 | 2 | 3, - }; - }), + avatarUrl: driver.avatarUrl, + position: position as 1 | 2 | 3, + }; + }); + + return { + drivers, + podium: podiumData, searchQuery: '', selectedSkill: 'all', + selectedTeam: 'all', sortBy: 'rank', showFilters: false, + availableTeams, }; } } diff --git a/apps/website/lib/builders/view-data/LeaderboardsViewDataBuilder.ts b/apps/website/lib/builders/view-data/LeaderboardsViewDataBuilder.ts index f28298ae7..b17a54564 100644 --- a/apps/website/lib/builders/view-data/LeaderboardsViewDataBuilder.ts +++ b/apps/website/lib/builders/view-data/LeaderboardsViewDataBuilder.ts @@ -13,7 +13,7 @@ type LeaderboardsInputDTO = { export class LeaderboardsViewDataBuilder { public static build(apiDto: LeaderboardsInputDTO): LeaderboardsViewData { return { - drivers: apiDto.drivers.drivers.map(driver => ({ + drivers: (apiDto.drivers.drivers || []).map(driver => ({ id: driver.id, name: driver.name, rating: driver.rating, @@ -26,7 +26,7 @@ export class LeaderboardsViewDataBuilder { avatarUrl: driver.avatarUrl || '', position: driver.rank, })), - teams: apiDto.teams.topTeams.map((team, index) => ({ + teams: (apiDto.teams.topTeams || apiDto.teams.teams || []).map((team, index) => ({ id: team.id, name: team.name, tag: team.tag, diff --git a/apps/website/lib/builders/view-data/TeamRankingsViewDataBuilder.ts b/apps/website/lib/builders/view-data/TeamRankingsViewDataBuilder.ts index ef22ff70a..1470bb248 100644 --- a/apps/website/lib/builders/view-data/TeamRankingsViewDataBuilder.ts +++ b/apps/website/lib/builders/view-data/TeamRankingsViewDataBuilder.ts @@ -37,6 +37,10 @@ export class TeamRankingsViewDataBuilder { teams: allTeams, podium: allTeams.slice(0, 3), recruitingCount: apiDto.recruitingCount || 0, + searchQuery: '', + selectedSkill: 'all', + sortBy: 'rank', + showFilters: false, }; } } diff --git a/apps/website/lib/page-queries/LeaderboardsPageQuery.ts b/apps/website/lib/page-queries/LeaderboardsPageQuery.ts index 584853208..388adfe0c 100644 --- a/apps/website/lib/page-queries/LeaderboardsPageQuery.ts +++ b/apps/website/lib/page-queries/LeaderboardsPageQuery.ts @@ -25,6 +25,15 @@ export class LeaderboardsPageQuery implements PageQuery void; + onSkillChange: (skill: SkillLevel) => void; + onTeamChange: (teamId: string) => void; + onSortChange: (sort: SortBy) => void; + onPageChange: (page: number) => void; + currentPage: number; + totalPages: number; + totalDrivers: number; onDriverClick?: (id: string) => void; onBackToLeaderboards?: () => void; } @@ -23,9 +35,37 @@ export function DriverRankingsTemplate({ viewData, searchQuery, onSearchChange, + onSkillChange, + onTeamChange, + onSortChange, + onPageChange, + currentPage, + totalPages, + totalDrivers, onDriverClick, onBackToLeaderboards, }: DriverRankingsTemplateProps): React.ReactElement { + const skillOptions = [ + { value: 'all', label: 'All Skills' }, + { value: 'pro', label: 'Pro' }, + { value: 'advanced', label: 'Advanced' }, + { value: 'intermediate', label: 'Intermediate' }, + { value: 'beginner', label: 'Beginner' }, + ]; + + const sortOptions = [ + { value: 'rank', label: 'Rank' }, + { value: 'rating', label: 'Rating' }, + { value: 'wins', label: 'Wins' }, + { value: 'podiums', label: 'Podiums' }, + { value: 'winRate', label: 'Win Rate' }, + ]; + + const teamOptions = [ + { value: 'all', label: 'All Teams' }, + ...viewData.availableTeams.map(t => ({ value: t.id, label: t.name })), + ]; + return ( } + data-testid="back-to-leaderboards" > Back to Leaderboards @@ -46,7 +87,7 @@ export function DriverRankingsTemplate({ /> {/* Top 3 Podium */} - {viewData.podium.length > 0 && !searchQuery && ( + {viewData.podium.length > 0 && !searchQuery && currentPage === 1 && ( ({ ...d, @@ -58,23 +99,90 @@ export function DriverRankingsTemplate({ /> )} - + > + + onTeamChange(e.target.value)} + data-testid="team-filter" + /> + onSkillChange(e.target.value as SkillLevel)} + data-testid="skill-filter" + /> +