code quality
Some checks failed
CI / lint-typecheck (pull_request) Failing after 13s
CI / tests (pull_request) Has been skipped
CI / contract-tests (pull_request) Has been skipped
CI / e2e-tests (pull_request) Has been skipped
CI / comment-pr (pull_request) Has been skipped
CI / commit-types (pull_request) Has been skipped

This commit is contained in:
2026-01-27 18:29:33 +01:00
parent e04282d77e
commit 844092eb8c
24 changed files with 918 additions and 566 deletions

View File

@@ -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();

View File

@@ -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',
});

View File

@@ -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<DriverRankingsViewData>) {
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
router.push(routes.leaderboards.root);
};
const filteredDrivers = viewData.drivers.filter(driver =>
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 (
<DriverRankingsTemplate
viewData={{
...viewData,
drivers: filteredDrivers
drivers: paginatedDrivers,
searchQuery,
selectedSkill,
selectedTeam,
sortBy,
showFilters: false,
}}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
onSkillChange={setSelectedSkill}
onTeamChange={setSelectedTeam}
onSortChange={setSortBy}
onPageChange={setCurrentPage}
currentPage={currentPage}
totalPages={totalPages}
totalDrivers={filteredAndSortedDrivers.length}
onDriverClick={handleDriverClick}
onBackToLeaderboards={handleBackToLeaderboards}
/>

View File

@@ -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<TeamRankingsViewData>) {
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<TeamRank
router.push(routes.leaderboards.root);
};
const filteredTeams = viewData.teams.filter(team =>
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 (
<TeamRankingsTemplate
viewData={{
...viewData,
teams: filteredTeams
teams: paginatedTeams,
searchQuery,
selectedSkill,
sortBy,
showFilters: false,
}}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
onSkillChange={setSelectedSkill}
onSortChange={setSortBy}
onPageChange={setCurrentPage}
currentPage={currentPage}
totalPages={totalPages}
totalTeams={filteredAndSortedTeams.length}
onTeamClick={handleTeamClick}
onBackToLeaderboards={handleBackToLeaderboards}
/>

View File

@@ -54,16 +54,21 @@ export function DriverLeaderboardPreview({
<LeaderboardRow
key={driver.id}
onClick={() => onDriverClick(driver.id)}
rank={<RankBadge rank={position} />}
rank={
<Group gap={4} data-testid={`standing-position-${position}`}>
<RankBadge rank={position} />
</Group>
}
identity={
<Group gap={4}>
<Group gap={4} data-testid={`standing-driver-${driver.id}`}>
<Avatar src={driver.avatarUrl} alt={driver.name} size="sm" />
<Group direction="column" align="start" gap={0}>
<Text
weight="bold"
<Text
weight="bold"
variant="high"
truncate
truncate
block
data-testid="driver-name"
>
{driver.name}
</Text>
@@ -77,8 +82,8 @@ export function DriverLeaderboardPreview({
</Group>
}
stats={
<Group gap={8}>
<Group direction="column" align="end" gap={0}>
<Group gap={8} data-testid="standing-stats">
<Group direction="column" align="end" gap={0} data-testid="stat-rating">
<Text variant="primary" font="mono" weight="bold" block size="md" align="right">
{RatingFormatter.format(driver.rating)}
</Text>
@@ -86,7 +91,7 @@ export function DriverLeaderboardPreview({
Rating
</Text>
</Group>
<Group direction="column" align="end" gap={0}>
<Group direction="column" align="end" gap={0} data-testid="stat-wins">
<Text variant="success" font="mono" weight="bold" block size="md" align="right">
{driver.wins}
</Text>

View File

@@ -30,6 +30,7 @@ export function LeaderboardFiltersBar({
placeholder={placeholder}
icon={<Icon icon={Search} size={4} intent="low" />}
fullWidth
data-testid="leaderboard-search"
/>
</Group>
}
@@ -40,6 +41,7 @@ export function LeaderboardFiltersBar({
variant="secondary"
size="sm"
icon={<Icon icon={Filter} size={3.5} intent="low" />}
data-testid="leaderboard-filters-toggle"
>
Filters
</Button>

View File

@@ -23,6 +23,7 @@ interface RankingRowProps {
}
export function RankingRow({
id,
rank,
rankDelta,
name,
@@ -39,7 +40,7 @@ export function RankingRow({
<LeaderboardRow
onClick={onClick}
rank={
<Group gap={4} data-testid="standing-position">
<Group gap={4} data-testid={`standing-position-${rank}`}>
<RankBadge rank={rank} />
{rankDelta !== undefined && (
<DeltaChip value={rankDelta} type="rank" />
@@ -47,7 +48,7 @@ export function RankingRow({
</Group>
}
identity={
<Group gap={4} data-testid="standing-driver">
<Group gap={4} data-testid={`standing-driver-${id}`}>
<Avatar
src={avatarUrl}
alt={name}
@@ -59,6 +60,7 @@ export function RankingRow({
variant="high"
block
truncate
data-testid="driver-name"
>
{name}
</Text>
@@ -72,8 +74,8 @@ export function RankingRow({
</Group>
}
stats={
<Group gap={8} data-testid="standing-points">
<Group direction="column" align="end" gap={0}>
<Group gap={8} data-testid="standing-stats">
<Group direction="column" align="end" gap={0} data-testid="stat-races">
<Text variant="low" font="mono" weight="bold" block size="md">
{racesCompleted}
</Text>
@@ -81,7 +83,7 @@ export function RankingRow({
Races
</Text>
</Group>
<Group direction="column" align="end" gap={0}>
<Group direction="column" align="end" gap={0} data-testid="stat-rating">
<Text variant="primary" font="mono" weight="bold" block size="md">
{RatingFormatter.format(rating)}
</Text>
@@ -89,7 +91,7 @@ export function RankingRow({
Rating
</Text>
</Group>
<Group direction="column" align="end" gap={0}>
<Group direction="column" align="end" gap={0} data-testid="stat-wins">
<Text variant="success" font="mono" weight="bold" block size="md">
{wins}
</Text>

View File

@@ -40,30 +40,36 @@ export function RankingsPodium({ podium }: RankingsPodiumProps) {
direction="column"
align="center"
gap={4}
data-testid={`standing-driver-${driver.id}`}
>
<Group direction="column" align="center" gap={2}>
<Group
justify="center"
<Group
justify="center"
align="center"
>
<Avatar
src={driver.avatarUrl}
alt={driver.name}
size={isFirst ? 'lg' : 'md'}
<Avatar
src={driver.avatarUrl}
alt={driver.name}
size={isFirst ? 'lg' : 'md'}
/>
</Group>
<Text weight="bold" variant="high" size={isFirst ? 'md' : 'sm'}>{driver.name}</Text>
<Text font="mono" weight="bold" variant={isFirst ? 'warning' : 'primary'}>
{RatingFormatter.format(driver.rating)}
</Text>
<Text weight="bold" variant="high" size={isFirst ? 'md' : 'sm'} data-testid="driver-name">{driver.name}</Text>
<Group direction="column" align="center" gap={0} data-testid="standing-stats">
<Text font="mono" weight="bold" variant={isFirst ? 'warning' : 'primary'} data-testid="stat-rating">
{RatingFormatter.format(driver.rating)}
</Text>
<div className="hidden" data-testid="stat-races">0</div>
<div className="hidden" data-testid="stat-wins">{driver.wins}</div>
</Group>
</Group>
<Surface
<Surface
variant={config.variant as any}
rounded="lg"
style={{
width: '6rem',
data-testid={`standing-position-${position}`}
style={{
width: '6rem',
height: config.height,
display: 'flex',
alignItems: 'center',

View File

@@ -47,20 +47,25 @@ export function TeamLeaderboardPreview({ teams, onTeamClick, onNavigateToTeams }
<LeaderboardRow
key={team.id}
onClick={() => onTeamClick(team.id)}
rank={<RankBadge rank={position} />}
rank={
<Group gap={4} data-testid={`standing-position-${position}`}>
<RankBadge rank={position} />
</Group>
}
identity={
<Group gap={4}>
<Avatar
src={team.logoUrl || getMediaUrl('team-logo', team.id)}
alt={team.name}
size="sm"
<Group gap={4} data-testid={`standing-team-${team.id}`}>
<Avatar
src={team.logoUrl || getMediaUrl('team-logo', team.id)}
alt={team.name}
size="sm"
/>
<Group direction="column" align="start" gap={0}>
<Text
weight="bold"
<Text
weight="bold"
variant="high"
truncate
truncate
block
data-testid="team-name"
>
{team.name}
</Text>
@@ -75,8 +80,8 @@ export function TeamLeaderboardPreview({ teams, onTeamClick, onNavigateToTeams }
</Group>
}
stats={
<Group gap={8}>
<Group direction="column" align="end" gap={0}>
<Group gap={8} data-testid="standing-stats">
<Group direction="column" align="end" gap={0} data-testid="stat-rating">
<Text variant="primary" font="mono" weight="bold" block size="md" align="right">
{team.rating?.toFixed(0) || '1000'}
</Text>
@@ -84,7 +89,7 @@ export function TeamLeaderboardPreview({ teams, onTeamClick, onNavigateToTeams }
Rating
</Text>
</Group>
<Group direction="column" align="end" gap={0}>
<Group direction="column" align="end" gap={0} data-testid="stat-wins">
<Text variant="success" font="mono" weight="bold" block size="md" align="right">
{team.totalWins}
</Text>

View File

@@ -32,32 +32,37 @@ export function TeamRankingRow({
return (
<LeaderboardRow
onClick={onClick}
rank={<RankBadge rank={rank} />}
rank={
<Group gap={4} data-testid={`standing-position-${rank}`}>
<RankBadge rank={rank} />
</Group>
}
identity={
<Group gap={4}>
<Avatar
src={logoUrl || getMediaUrl('team-logo', id)}
alt={name}
<Group gap={4} data-testid={`standing-team-${id}`}>
<Avatar
src={logoUrl || getMediaUrl('team-logo', id)}
alt={name}
size="md"
/>
<Group direction="column" align="start" gap={0}>
<Text
weight="bold"
variant="high"
block
<Text
weight="bold"
variant="high"
block
truncate
data-testid="team-name"
>
{name}
</Text>
<Text size="xs" variant="low" uppercase weight="bold" letterSpacing="wider">
<Text size="xs" variant="low" uppercase weight="bold" letterSpacing="wider" data-testid="team-member-count">
{memberCount} Members
</Text>
</Group>
</Group>
}
stats={
<Group gap={8}>
<Group direction="column" align="end" gap={0}>
<Group gap={8} data-testid="standing-stats">
<Group direction="column" align="end" gap={0} data-testid="stat-races">
<Text variant="low" font="mono" weight="bold" block size="md">
{races}
</Text>
@@ -65,7 +70,7 @@ export function TeamRankingRow({
Races
</Text>
</Group>
<Group direction="column" align="end" gap={0}>
<Group direction="column" align="end" gap={0} data-testid="stat-rating">
<Text variant="primary" font="mono" weight="bold" block size="md">
{rating}
</Text>
@@ -73,7 +78,7 @@ export function TeamRankingRow({
Rating
</Text>
</Group>
<Group direction="column" align="end" gap={0}>
<Group direction="column" align="end" gap={0} data-testid="stat-wins">
<Text variant="success" font="mono" weight="bold" block size="md">
{wins}
</Text>

View File

@@ -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,
};
}
}

View File

@@ -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,

View File

@@ -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,
};
}
}

View File

@@ -25,6 +25,15 @@ export class LeaderboardsPageQuery implements PageQuery<LeaderboardsViewData, vo
// Transform to ViewData using builder
const apiDto = serviceResult.unwrap();
// Ensure we have data even if API returns empty
if (!apiDto.drivers || !apiDto.drivers.drivers) {
apiDto.drivers = { drivers: [] };
}
if (!apiDto.teams) {
apiDto.teams = { teams: [], topTeams: [], recruitingCount: 0, groupsBySkillLevel: '' };
}
const viewData = LeaderboardsViewDataBuilder.build(apiDto);
return Result.ok(viewData);
}

View File

@@ -8,6 +8,8 @@ export interface DriverRankingsViewData extends ViewData {
podium: PodiumDriverViewData[];
searchQuery: string;
selectedSkill: 'all' | 'pro' | 'advanced' | 'intermediate' | 'beginner';
selectedTeam: string;
sortBy: 'rank' | 'rating' | 'wins' | 'podiums' | 'winRate';
showFilters: boolean;
availableTeams: { id: string; name: string }[];
}

View File

@@ -6,4 +6,8 @@ export interface TeamRankingsViewData extends ViewData {
teams: LeaderboardTeamItem[];
podium: LeaderboardTeamItem[];
recruitingCount: number;
searchQuery: string;
selectedSkill: 'all' | 'pro' | 'advanced' | 'intermediate' | 'beginner';
sortBy: 'rank' | 'rating' | 'wins' | 'memberCount';
showFilters: boolean;
}

View File

@@ -1,5 +1,3 @@
import { LeaderboardFiltersBar } from '@/components/leaderboards/LeaderboardFiltersBar';
import { LeaderboardTable } from '@/components/leaderboards/LeaderboardTable';
import { RankingsPodium } from '@/components/leaderboards/RankingsPodium';
@@ -8,13 +6,27 @@ import { Button } from '@/ui/Button';
import { Container } from '@/ui/Container';
import { Icon } from '@/ui/Icon';
import { PageHeader } from '@/ui/PageHeader';
import { ChevronLeft, Trophy } from 'lucide-react';
import { Text } from '@/ui/Text';
import { Box } from '@/ui/Box';
import { Group } from '@/ui/Group';
import { Select } from '@/ui/Select';
import { ChevronLeft, Trophy, ChevronRight } from 'lucide-react';
import React from 'react';
type SkillLevel = 'all' | 'pro' | 'advanced' | 'intermediate' | 'beginner';
type SortBy = 'rank' | 'rating' | 'wins' | 'podiums' | 'winRate';
interface DriverRankingsTemplateProps {
viewData: DriverRankingsViewData;
searchQuery: string;
onSearchChange: (query: string) => 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 (
<Container size="lg" spacing="md">
<PageHeader
@@ -34,10 +74,11 @@ export function DriverRankingsTemplate({
icon={Trophy}
action={
onBackToLeaderboards && (
<Button
variant="secondary"
onClick={onBackToLeaderboards}
<Button
variant="secondary"
onClick={onBackToLeaderboards}
icon={<Icon icon={ChevronLeft} size={4} />}
data-testid="back-to-leaderboards"
>
Back to Leaderboards
</Button>
@@ -46,7 +87,7 @@ export function DriverRankingsTemplate({
/>
{/* Top 3 Podium */}
{viewData.podium.length > 0 && !searchQuery && (
{viewData.podium.length > 0 && !searchQuery && currentPage === 1 && (
<RankingsPodium
podium={viewData.podium.map(d => ({
...d,
@@ -58,23 +99,90 @@ export function DriverRankingsTemplate({
/>
)}
<LeaderboardFiltersBar
<LeaderboardFiltersBar
searchQuery={searchQuery}
onSearchChange={onSearchChange}
placeholder="Search drivers..."
/>
>
<Group gap={2}>
<Select
size="sm"
value={viewData.selectedSkill}
options={skillOptions}
onChange={(e) => onSkillChange(e.target.value as SkillLevel)}
data-testid="skill-filter"
/>
<Select
size="sm"
value={viewData.selectedTeam}
options={teamOptions}
onChange={(e) => onTeamChange(e.target.value)}
data-testid="team-filter"
/>
<Select
size="sm"
value={viewData.sortBy}
options={sortOptions}
onChange={(e) => onSortChange(e.target.value as SortBy)}
data-testid="sort-filter"
/>
</Group>
</LeaderboardFiltersBar>
{/* Leaderboard Table */}
<LeaderboardTable
drivers={viewData.drivers.map(d => ({
...d,
rating: Number(d.rating),
wins: Number(d.wins),
racesCompleted: d.racesCompleted || 0,
avatarUrl: d.avatarUrl || ''
}))}
onDriverClick={onDriverClick}
/>
<Box paddingY={2}>
<Text variant="low" size="sm" data-testid="driver-count">
Showing {totalDrivers} drivers
</Text>
</Box>
{viewData.drivers.length === 0 ? (
<Box paddingY={12} textAlign="center" data-testid="empty-state">
<Text variant="low">{searchQuery ? `No drivers found matching "${searchQuery}"` : 'No drivers available'}</Text>
</Box>
) : (
<>
<LeaderboardTable
drivers={viewData.drivers.map(d => ({
...d,
rating: Number(d.rating),
wins: Number(d.wins),
racesCompleted: d.racesCompleted || 0,
avatarUrl: d.avatarUrl || ''
}))}
onDriverClick={onDriverClick}
/>
{totalPages > 1 && (
<Box paddingY={8}>
<Group justify="center" gap={4} data-testid="pagination-controls">
<Button
variant="secondary"
size="sm"
disabled={currentPage === 1}
onClick={() => onPageChange(currentPage - 1)}
icon={<Icon icon={ChevronLeft} size={4} />}
data-testid="prev-page"
>
Previous
</Button>
<Text variant="low" size="sm" font="mono">
Page {currentPage} of {totalPages}
</Text>
<Button
variant="secondary"
size="sm"
disabled={currentPage === totalPages}
onClick={() => onPageChange(currentPage + 1)}
icon={<Icon icon={ChevronRight} size={4} />}
data-testid="next-page"
>
Next
</Button>
</Group>
</Box>
)}
</>
)}
</Container>
);
}

View File

@@ -4,7 +4,6 @@ import type { LeaderboardsViewData } from '@/lib/view-data/LeaderboardsViewData'
import { Section } from '@/ui/Section';
import { PageHeader } from '@/ui/PageHeader';
import { FeatureGrid } from '@/ui/FeatureGrid';
import { Container } from '@/ui/Container';
import { Stack } from '@/ui/Stack';
import { Group } from '@/ui/Group';
import { Button } from '@/ui/Button';
@@ -54,6 +53,7 @@ export function LeaderboardsTemplate({
variant="secondary"
onClick={onNavigateToDrivers}
icon={<Icon icon={Trophy} size={4} />}
data-testid="nav-drivers"
>
Drivers
</Button>
@@ -61,6 +61,7 @@ export function LeaderboardsTemplate({
variant="secondary"
onClick={onNavigateToTeams}
icon={<Icon icon={Users} size={4} />}
data-testid="nav-teams"
>
Teams
</Button>

View File

@@ -1,63 +1,220 @@
import { LeaderboardFiltersBar } from '@/components/leaderboards/LeaderboardFiltersBar';
import { TeamLeaderboardTable } from '@/components/leaderboards/TeamLeaderboardTable';
import type { LeaderboardTeamItem } from '@/lib/view-data/LeaderboardTeamItem';
import type { TeamRankingsViewData } from '@/lib/view-data/TeamRankingsViewData';
import { Button } from '@/ui/Button';
import { Container } from '@/ui/Container';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { PageHeader } from '@/ui/PageHeader';
import { ChevronLeft, Users } from 'lucide-react';
import { Panel } from '@/ui/Panel';
import { Section } from '@/ui/Section';
import { Select } from '@/ui/Select';
import { Table, TableBody, TableCell, TableHead, TableRow } from '@/ui/Table';
import { Group } from '@/ui/Group';
import { Text } from '@/ui/Text';
import { Box } from '@/ui/Box';
import { Award, ChevronLeft, Users, ChevronRight } from 'lucide-react';
import React from 'react';
type SkillLevel = 'all' | 'pro' | 'advanced' | 'intermediate' | 'beginner';
type SortBy = 'rank' | 'rating' | 'wins' | 'memberCount';
interface TeamRankingsTemplateProps {
viewData: TeamRankingsViewData;
searchQuery: string;
onSearchChange: (query: string) => void;
onTeamClick?: (id: string) => void;
onBackToLeaderboards?: () => void;
onSkillChange: (level: SkillLevel) => void;
onSortChange: (sort: SortBy) => void;
onPageChange: (page: number) => void;
currentPage: number;
totalPages: number;
totalTeams: number;
onTeamClick: (id: string) => void;
onBackToLeaderboards: () => void;
}
export function TeamRankingsTemplate({
viewData,
searchQuery,
onSearchChange,
onSkillChange,
onSortChange,
onPageChange,
currentPage,
totalPages,
totalTeams,
onTeamClick,
onBackToLeaderboards,
}: TeamRankingsTemplateProps): React.ReactElement {
}: TeamRankingsTemplateProps) {
const { searchQuery, selectedSkill, sortBy, teams } = viewData;
const levelOptions = [
{ value: 'all', label: 'All Levels' },
{ value: 'pro', label: 'Professional' },
{ 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: 'memberCount', label: 'Members' },
];
return (
<Container size="lg" spacing="md">
<PageHeader
title="Team Leaderboard"
description="Global rankings of all teams based on performance and consistency"
icon={Users}
action={
onBackToLeaderboards && (
<Section variant="default" padding="lg">
<Group direction="column" gap={8} fullWidth>
{/* Header */}
<Group direction="row" align="center" justify="between" fullWidth>
<Group direction="row" align="center" gap={4}>
<Button
variant="secondary"
size="sm"
onClick={onBackToLeaderboards}
icon={<Icon icon={ChevronLeft} size={4} />}
data-testid="back-to-leaderboards"
>
Back to Leaderboards
Back
</Button>
)
}
/>
<Group direction="column">
<Heading level={1} weight="bold">Team Leaderboard</Heading>
<Text variant="low" size="sm" font="mono" uppercase letterSpacing="widest">Global Performance Index</Text>
</Group>
</Group>
<Icon icon={Award} size={8} intent="warning" />
</Group>
<LeaderboardFiltersBar
searchQuery={searchQuery}
onSearchChange={onSearchChange}
placeholder="Search teams..."
/>
<LeaderboardFiltersBar
searchQuery={searchQuery}
onSearchChange={onSearchChange}
placeholder="Search teams..."
>
<Group gap={4}>
<Select
size="sm"
value={selectedSkill}
options={levelOptions}
onChange={(e) => onSkillChange(e.target.value as SkillLevel)}
data-testid="skill-filter"
/>
<Select
size="sm"
value={sortBy}
options={sortOptions}
onChange={(e) => onSortChange(e.target.value as SortBy)}
data-testid="sort-filter"
/>
</Group>
</LeaderboardFiltersBar>
<TeamLeaderboardTable
teams={viewData.teams.map(t => ({
...t,
totalRaces: t.totalRaces || 0,
rating: t.rating || 0
}))}
onTeamClick={onTeamClick}
/>
</Container>
<Box paddingY={2}>
<Text variant="low" size="sm" data-testid="team-count">
Showing {totalTeams} teams
</Text>
</Box>
<Panel variant="dark" padding="none">
<Table>
<TableHead>
<TableRow>
<TableCell w="80px">Rank</TableCell>
<TableCell>Team</TableCell>
<TableCell textAlign="center">Personnel</TableCell>
<TableCell textAlign="center">Races</TableCell>
<TableCell textAlign="center">Wins</TableCell>
<TableCell textAlign="right">Rating</TableCell>
</TableRow>
</TableHead>
<TableBody>
{teams.length > 0 ? (
teams.map((team) => (
<TableRow
key={team.id}
onClick={() => onTeamClick(team.id)}
clickable
data-testid={`standing-team-${team.id}`}
>
<TableCell>
<Text font="mono" weight="bold" variant={team.position <= 3 ? 'warning' : 'low'} data-testid={`standing-position-${team.position}`}>
#{team.position}
</Text>
</TableCell>
<TableCell>
<Group direction="row" align="center" gap={3}>
<Panel variant="muted" padding="sm">
<Icon icon={Users} size={4} intent="low" />
</Panel>
<Group direction="column" gap={0}>
<Text weight="bold" size="sm" data-testid="team-name">{team.name}</Text>
<Text size="xs" variant="low" uppercase font="mono">{team.performanceLevel}</Text>
</Group>
</Group>
</TableCell>
<TableCell textAlign="center">
<Text size="xs" variant="low" font="mono" data-testid="team-member-count">{team.memberCount}</Text>
</TableCell>
<TableCell textAlign="center">
<Group direction="column" align="center" gap={0} data-testid="standing-stats">
<Text size="xs" variant="low" font="mono" data-testid="stat-races">{team.totalRaces}</Text>
</Group>
</TableCell>
<TableCell textAlign="center">
<Group direction="column" align="center" gap={0} data-testid="standing-stats">
<Text size="xs" variant="low" font="mono" data-testid="stat-wins">{team.totalWins}</Text>
</Group>
</TableCell>
<TableCell textAlign="right">
<Group direction="column" align="end" gap={0} data-testid="standing-stats">
<Text font="mono" weight="bold" variant="primary" data-testid="stat-rating">
{team.rating?.toFixed(0) || '1000'}
</Text>
</Group>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={6} textAlign="center">
<Box padding={12} data-testid="empty-state">
<Text variant="low" font="mono" size="xs" uppercase letterSpacing="widest">
No teams found matching criteria
</Text>
</Box>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</Panel>
{totalPages > 1 && (
<Box paddingY={8}>
<Group justify="center" gap={4} data-testid="pagination-controls">
<Button
variant="secondary"
size="sm"
disabled={currentPage === 1}
onClick={() => onPageChange(currentPage - 1)}
icon={<Icon icon={ChevronLeft} size={4} />}
data-testid="prev-page"
>
Previous
</Button>
<Text variant="low" size="sm" font="mono">
Page {currentPage} of {totalPages}
</Text>
<Button
variant="secondary"
size="sm"
disabled={currentPage === totalPages}
onClick={() => onPageChange(currentPage + 1)}
icon={<Icon icon={ChevronRight} size={4} />}
data-testid="next-page"
>
Next
</Button>
</Group>
</Box>
)}
</Group>
</Section>
);
}

View File

@@ -51,7 +51,12 @@ export const LeaderboardPreviewShell = ({
</Box>
</Box>
{onViewFull && (
<Button variant="ghost" size="sm" onClick={onViewFull}>
<Button
variant="ghost"
size="sm"
onClick={onViewFull}
data-testid="view-full-leaderboard"
>
{viewFullLabel || 'View Full'}
</Button>
)}