This commit is contained in:
2025-12-11 21:06:25 +01:00
parent c49ea2598d
commit ec3ddc3a5c
227 changed files with 3496 additions and 2083 deletions

View File

@@ -9,6 +9,7 @@ import Button from '@/components/ui/Button';
import Breadcrumbs from '@/components/layout/Breadcrumbs';
import SponsorInsightsCard, { useSponsorMode, MetricBuilders, SlotTemplates } from '@/components/sponsors/SponsorInsightsCard';
import { getImageService } from '@/lib/di-container';
import { TeamMembersPresenter } from '@/lib/presenters/TeamMembersPresenter';
import TeamRoster from '@/components/teams/TeamRoster';
import TeamStandings from '@/components/teams/TeamStandings';
import TeamAdmin from '@/components/teams/TeamAdmin';
@@ -19,9 +20,17 @@ import {
getTeamMembershipRepository,
} from '@/lib/di-container';
import { useEffectiveDriverId } from '@/lib/currentDriver';
import type { Team, TeamMembership, TeamRole } from '@gridpilot/racing';
import type { Team } from '@gridpilot/racing';
import { Users, Trophy, TrendingUp, Star, Zap } from 'lucide-react';
type TeamRole = 'owner' | 'manager' | 'driver';
interface TeamMembership {
driverId: string;
role: TeamRole;
joinedAt: Date;
}
type Tab = 'overview' | 'roster' | 'standings' | 'admin';
export default function TeamDetailPage() {
@@ -42,16 +51,32 @@ export default function TeamDetailPage() {
const detailsUseCase = getGetTeamDetailsUseCase();
const membersUseCase = getGetTeamMembersUseCase();
await detailsUseCase.execute({ teamId, driverId: currentDriverId });
const detailsViewModel = detailsUseCase.presenter.getViewModel();
await membersUseCase.execute({ teamId });
const membersViewModel = membersUseCase.presenter.getViewModel();
const teamMemberships = membersViewModel.members;
await detailsUseCase.execute(teamId, currentDriverId);
const detailsPresenter = detailsUseCase.presenter;
const detailsViewModel = detailsPresenter
? (detailsPresenter as any).getViewModel?.() as { team: Team } | null
: null;
if (!detailsViewModel) {
setTeam(null);
setMemberships([]);
setIsAdmin(false);
return;
}
const teamMembersPresenter = new TeamMembersPresenter();
await membersUseCase.execute({ teamId }, teamMembersPresenter);
const membersViewModel = teamMembersPresenter.getViewModel();
const teamMemberships: TeamMembership[] = (membersViewModel?.members ?? []).map((m) => ({
driverId: m.driverId,
role: m.role as TeamRole,
joinedAt: new Date(m.joinedAt),
}));
const adminStatus =
teamMemberships.some(
(m) =>
(m: TeamMembership) =>
m.driverId === currentDriverId &&
(m.role === 'owner' || m.role === 'manager'),
) ?? false;

View File

@@ -23,6 +23,7 @@ import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import Heading from '@/components/ui/Heading';
import { getGetTeamsLeaderboardUseCase } from '@/lib/di-container';
import { TeamsLeaderboardPresenter } from '@/lib/presenters/TeamsLeaderboardPresenter';
import type {
TeamLeaderboardItemViewModel,
SkillLevel,
@@ -36,6 +37,23 @@ type SortBy = 'rating' | 'wins' | 'winRate' | 'races';
type TeamDisplayData = TeamLeaderboardItemViewModel;
const getSafeRating = (team: TeamDisplayData): number => {
const value = typeof team.rating === 'number' ? team.rating : 0;
return Number.isFinite(value) ? value : 0;
};
const getSafeTotalWins = (team: TeamDisplayData): number => {
const raw = team.totalWins;
const value = typeof raw === 'number' ? raw : 0;
return Number.isFinite(value) ? value : 0;
};
const getSafeTotalRaces = (team: TeamDisplayData): number => {
const raw = team.totalRaces;
const value = typeof raw === 'number' ? raw : 0;
return Number.isFinite(value) ? value : 0;
};
// ============================================================================
// SKILL LEVEL CONFIG
// ============================================================================
@@ -103,11 +121,15 @@ interface TopThreePodiumProps {
}
function TopThreePodium({ teams, onTeamClick }: TopThreePodiumProps) {
const top3 = teams.slice(0, 3);
if (top3.length < 3) return null;
const top3 = teams.slice(0, 3) as [TeamDisplayData, TeamDisplayData, TeamDisplayData];
if (teams.length < 3) return null;
// Display order: 2nd, 1st, 3rd
const podiumOrder = [top3[1], top3[0], top3[2]];
const podiumOrder: [TeamDisplayData, TeamDisplayData, TeamDisplayData] = [
top3[1],
top3[0],
top3[2],
];
const podiumHeights = ['h-28', 'h-36', 'h-20'];
const podiumPositions = [2, 1, 3];
@@ -159,7 +181,7 @@ function TopThreePodium({ teams, onTeamClick }: TopThreePodiumProps) {
<div className="flex items-end justify-center gap-4 md:gap-8">
{podiumOrder.map((team, index) => {
const position = podiumPositions[index];
const position = podiumPositions[index] ?? 0;
const levelConfig = SKILL_LEVELS.find((l) => l.id === team.performanceLevel);
const LevelIcon = levelConfig?.icon || Shield;
@@ -172,7 +194,7 @@ function TopThreePodium({ teams, onTeamClick }: TopThreePodiumProps) {
>
{/* Team card */}
<div
className={`relative mb-4 p-4 rounded-xl bg-gradient-to-br ${getGradient(position)} border ${getBorderColor(position)} transition-all group-hover:scale-105 group-hover:shadow-lg`}
className={`relative mb-4 p-4 rounded-xl bg-gradient-to-br ${getGradient(position ?? 0)} border ${getBorderColor(position ?? 0)} transition-all group-hover:scale-105 group-hover:shadow-lg`}
>
{/* Crown for 1st place */}
{position === 1 && (
@@ -198,14 +220,14 @@ function TopThreePodium({ teams, onTeamClick }: TopThreePodiumProps) {
{/* Rating */}
<p className={`text-lg md:text-xl font-mono font-bold ${getPositionColor(position)} text-center`}>
{team.rating?.toLocaleString() ?? '—'}
{getSafeRating(team).toLocaleString()}
</p>
{/* Stats row */}
<div className="flex items-center justify-center gap-3 mt-2 text-xs text-gray-400">
<span className="flex items-center gap-1">
<Trophy className="w-3 h-3 text-performance-green" />
{team.totalWins}
{getSafeTotalWins(team)}
</span>
<span className="flex items-center gap-1">
<Users className="w-3 h-3 text-purple-400" />
@@ -246,9 +268,14 @@ export default function TeamLeaderboardPage() {
const loadTeams = async () => {
try {
const useCase = getGetTeamsLeaderboardUseCase();
await useCase.execute();
const viewModel = useCase.presenter.getViewModel();
setTeams(viewModel.teams);
const presenter = new TeamsLeaderboardPresenter();
await useCase.execute(undefined as void, presenter);
const viewModel = presenter.getViewModel();
if (viewModel) {
setTeams(viewModel.teams);
}
} catch (error) {
console.error('Failed to load teams:', error);
} finally {
@@ -286,17 +313,30 @@ export default function TeamLeaderboardPage() {
})
.sort((a, b) => {
switch (sortBy) {
case 'rating':
return (b.rating ?? 0) - (a.rating ?? 0);
case 'wins':
return b.totalWins - a.totalWins;
case 'rating': {
const aRating = getSafeRating(a);
const bRating = getSafeRating(b);
return bRating - aRating;
}
case 'wins': {
const aWinsSort = getSafeTotalWins(a);
const bWinsSort = getSafeTotalWins(b);
return bWinsSort - aWinsSort;
}
case 'winRate': {
const aRate = a.totalRaces > 0 ? a.totalWins / a.totalRaces : 0;
const bRate = b.totalRaces > 0 ? b.totalWins / b.totalRaces : 0;
const aRaces = getSafeTotalRaces(a);
const bRaces = getSafeTotalRaces(b);
const aWins = getSafeTotalWins(a);
const bWins = getSafeTotalWins(b);
const aRate = aRaces > 0 ? aWins / aRaces : 0;
const bRate = bRaces > 0 ? bWins / bRaces : 0;
return bRate - aRate;
}
case 'races':
return b.totalRaces - a.totalRaces;
case 'races': {
const aRacesSort = getSafeTotalRaces(a);
const bRacesSort = getSafeTotalRaces(b);
return bRacesSort - aRacesSort;
}
default:
return 0;
}
@@ -468,7 +508,10 @@ export default function TeamLeaderboardPage() {
<span className="text-xs text-gray-500">Total Wins</span>
</div>
<p className="text-2xl font-bold text-white">
{filteredAndSortedTeams.reduce((sum, t) => sum + t.totalWins, 0)}
{filteredAndSortedTeams.reduce<number>(
(sum, t) => sum + getSafeTotalWins(t),
0,
)}
</p>
</div>
<div className="p-4 rounded-xl bg-iron-gray/30 border border-charcoal-outline">
@@ -477,7 +520,10 @@ export default function TeamLeaderboardPage() {
<span className="text-xs text-gray-500">Total Races</span>
</div>
<p className="text-2xl font-bold text-white">
{filteredAndSortedTeams.reduce((sum, t) => sum + t.totalRaces, 0)}
{filteredAndSortedTeams.reduce<number>(
(sum, t) => sum + getSafeTotalRaces(t),
0,
)}
</p>
</div>
</div>
@@ -499,7 +545,10 @@ export default function TeamLeaderboardPage() {
{filteredAndSortedTeams.map((team, index) => {
const levelConfig = SKILL_LEVELS.find((l) => l.id === team.performanceLevel);
const LevelIcon = levelConfig?.icon || Shield;
const winRate = team.totalRaces > 0 ? ((team.totalWins / team.totalRaces) * 100).toFixed(1) : '0.0';
const totalRaces = getSafeTotalRaces(team);
const totalWins = getSafeTotalWins(team);
const winRate =
totalRaces > 0 ? ((totalWins / totalRaces) * 100).toFixed(1) : '0.0';
return (
<button
@@ -565,15 +614,19 @@ export default function TeamLeaderboardPage() {
{/* Rating */}
<div className="col-span-2 lg:col-span-1 flex items-center justify-center">
<span className={`font-mono font-semibold ${sortBy === 'rating' ? 'text-purple-400' : 'text-white'}`}>
{team.rating?.toLocaleString() ?? '—'}
<span
className={`font-mono font-semibold ${
sortBy === 'rating' ? 'text-purple-400' : 'text-white'
}`}
>
{getSafeRating(team).toLocaleString()}
</span>
</div>
{/* Wins */}
<div className="col-span-2 lg:col-span-1 flex items-center justify-center">
<span className={`font-mono font-semibold ${sortBy === 'wins' ? 'text-purple-400' : 'text-white'}`}>
{team.totalWins}
{getSafeTotalWins(team)}
</span>
</div>

View File

@@ -29,6 +29,7 @@ import Input from '@/components/ui/Input';
import Heading from '@/components/ui/Heading';
import CreateTeamForm from '@/components/teams/CreateTeamForm';
import { getGetTeamsLeaderboardUseCase } from '@/lib/di-container';
import { TeamsLeaderboardPresenter } from '@/lib/presenters/TeamsLeaderboardPresenter';
import type { TeamLeaderboardItemViewModel, SkillLevel } from '@gridpilot/racing/application/presenters/ITeamsLeaderboardPresenter';
// ============================================================================
@@ -204,7 +205,7 @@ function SkillLevelSection({ level, teams, onTeamClick, defaultExpanded = false
key={team.id}
id={team.id}
name={team.name}
description={team.description}
description={team.description ?? ''}
memberCount={team.memberCount}
rating={team.rating}
totalWins={team.totalWins}
@@ -212,7 +213,7 @@ function SkillLevelSection({ level, teams, onTeamClick, defaultExpanded = false
performanceLevel={team.performanceLevel}
isRecruiting={team.isRecruiting}
specialization={team.specialization}
region={team.region}
region={team.region ?? ''}
languages={team.languages}
onClick={() => onTeamClick(team.id)}
/>
@@ -449,11 +450,16 @@ export default function TeamsPage() {
const loadTeams = async () => {
try {
const useCase = getGetTeamsLeaderboardUseCase();
await useCase.execute();
const viewModel = useCase.presenter.getViewModel();
setRealTeams(viewModel.teams);
setGroupsBySkillLevel(viewModel.groupsBySkillLevel);
setTopTeams(viewModel.topTeams);
const presenter = new TeamsLeaderboardPresenter();
await useCase.execute(undefined as void, presenter);
const viewModel = presenter.getViewModel();
if (viewModel) {
setRealTeams(viewModel.teams);
setGroupsBySkillLevel(viewModel.groupsBySkillLevel);
setTopTeams(viewModel.topTeams);
}
} catch (error) {
console.error('Failed to load teams:', error);
} finally {