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

@@ -27,7 +27,10 @@ export async function POST(request: Request) {
return jsonError(400, 'Invalid request body');
}
const email = (body as any)?.email;
const email =
typeof body === 'object' && body !== null && 'email' in body
? (body as { email: unknown }).email
: undefined;
if (typeof email !== 'string' || !email.trim()) {
return jsonError(400, 'Invalid email address');

View File

@@ -9,7 +9,8 @@ export async function GET(request: Request) {
const url = new URL(request.url);
const code = url.searchParams.get('code') ?? undefined;
const state = url.searchParams.get('state') ?? undefined;
const returnTo = url.searchParams.get('returnTo') ?? undefined;
const rawReturnTo = url.searchParams.get('returnTo');
const returnTo = rawReturnTo ?? undefined;
if (!code || !state) {
return NextResponse.redirect('/auth/iracing');
@@ -23,7 +24,8 @@ export async function GET(request: Request) {
}
const authService = getAuthService();
await authService.loginWithIracingCallback({ code, state, returnTo });
const loginInput = returnTo ? { code, state, returnTo } : { code, state };
await authService.loginWithIracingCallback(loginInput);
cookieStore.delete(STATE_COOKIE);

View File

@@ -1,6 +1,6 @@
'use client';
import { useState, useEffect, use } from 'react';
import { useState, useEffect } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { useRouter, useParams } from 'next/navigation';
@@ -43,12 +43,16 @@ import {
getGetAllTeamsUseCase,
getGetTeamMembersUseCase,
} from '@/lib/di-container';
import { AllTeamsPresenter } from '@/lib/presenters/AllTeamsPresenter';
import { TeamMembersPresenter } from '@/lib/presenters/TeamMembersPresenter';
import { Driver, EntityMappers, type Team } from '@gridpilot/racing';
import type { DriverDTO } from '@gridpilot/racing';
import type { ProfileOverviewViewModel } from '@gridpilot/racing/application/presenters/IProfileOverviewPresenter';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import Breadcrumbs from '@/components/layout/Breadcrumbs';
import type { GetDriverTeamQueryResultDTO } from '@gridpilot/racing/application/dto/TeamCommandAndQueryDTO';
import type { TeamMemberViewModel } from '@gridpilot/racing/application/presenters/ITeamMembersPresenter';
// ============================================================================
// TYPES
@@ -134,14 +138,22 @@ function getDemoExtendedProfile(driverId: string): DriverExtendedProfile {
const timezones = ['EST (UTC-5)', 'CET (UTC+1)', 'PST (UTC-8)', 'GMT (UTC+0)', 'JST (UTC+9)'];
const hours = ['Evenings (18:00-23:00)', 'Weekends only', 'Late nights (22:00-02:00)', 'Flexible schedule'];
const socialHandles = socialOptions[hash % socialOptions.length] ?? [];
const achievements = achievementSets[hash % achievementSets.length] ?? [];
const racingStyle = styles[hash % styles.length] ?? 'Consistent Pacer';
const favoriteTrack = tracks[hash % tracks.length] ?? 'Unknown Track';
const favoriteCar = cars[hash % cars.length] ?? 'Unknown Car';
const timezone = timezones[hash % timezones.length] ?? 'UTC';
const availableHours = hours[hash % hours.length] ?? 'Flexible schedule';
return {
socialHandles: socialOptions[hash % socialOptions.length],
achievements: achievementSets[hash % achievementSets.length],
racingStyle: styles[hash % styles.length],
favoriteTrack: tracks[hash % tracks.length],
favoriteCar: cars[hash % cars.length],
timezone: timezones[hash % timezones.length],
availableHours: hours[hash % hours.length],
socialHandles,
achievements,
racingStyle,
favoriteTrack,
favoriteCar,
timezone,
availableHours,
lookingForTeam: hash % 3 === 0,
openToRequests: hash % 2 === 0,
};
@@ -301,11 +313,46 @@ function HorizontalBarChart({ data, maxValue }: BarChartProps) {
// MAIN PAGE
// ============================================================================
export default function DriverDetailPage({
searchParams,
}: {
searchParams: any;
}) {
interface DriverProfileStatsViewModel {
rating: number;
wins: number;
podiums: number;
dnfs: number;
totalRaces: number;
avgFinish: number;
bestFinish: number;
worstFinish: number;
consistency: number;
percentile: number;
}
interface DriverProfileFriendViewModel {
id: string;
name: string;
country: string;
}
interface DriverProfileExtendedViewModel extends DriverExtendedProfile {}
interface DriverProfileViewModel {
currentDriver?: {
id: string;
name: string;
iracingId?: string | null;
country: string;
bio?: string | null;
joinedAt: string | Date;
globalRank?: number;
totalDrivers?: number;
};
stats?: DriverProfileStatsViewModel;
extendedProfile?: DriverProfileExtendedViewModel;
socialSummary?: {
friends: DriverProfileFriendViewModel[];
};
}
export default function DriverDetailPage() {
const router = useRouter();
const params = useParams();
const driverId = params.id as string;
@@ -318,24 +365,16 @@ export default function DriverDetailPage({
const [allTeamMemberships, setAllTeamMemberships] = useState<TeamMembershipInfo[]>([]);
const [friends, setFriends] = useState<Driver[]>([]);
const [friendRequestSent, setFriendRequestSent] = useState(false);
const [profileData, setProfileData] = useState<any>(null);
const [profileData, setProfileData] = useState<ProfileOverviewViewModel | null>(null);
const unwrappedSearchParams = use(searchParams) as URLSearchParams | undefined;
const from =
typeof unwrappedSearchParams?.get === 'function'
? unwrappedSearchParams.get('from') ?? undefined
const search =
typeof window !== 'undefined'
? new URLSearchParams(window.location.search)
: undefined;
const leagueId =
typeof unwrappedSearchParams?.get === 'function'
? unwrappedSearchParams.get('leagueId') ?? undefined
: undefined;
const raceId =
typeof unwrappedSearchParams?.get === 'function'
? unwrappedSearchParams.get('raceId') ?? undefined
: undefined;
const from = search?.get('from') ?? undefined;
const leagueId = search?.get('leagueId') ?? undefined;
const raceId = search?.get('raceId') ?? undefined;
let backLink: string | null = null;
@@ -362,8 +401,7 @@ export default function DriverDetailPage({
try {
// Use GetProfileOverviewUseCase to load all profile data
const profileUseCase = getGetProfileOverviewUseCase();
await profileUseCase.execute({ driverId });
const profileViewModel = profileUseCase.presenter.getViewModel();
const profileViewModel = await profileUseCase.execute({ driverId });
if (!profileViewModel || !profileViewModel.currentDriver) {
setError('Driver not found');
@@ -375,7 +413,7 @@ export default function DriverDetailPage({
const driverData: DriverDTO = {
id: profileViewModel.currentDriver.id,
name: profileViewModel.currentDriver.name,
iracingId: profileViewModel.currentDriver.iracingId,
iracingId: profileViewModel.currentDriver.iracingId ?? '',
country: profileViewModel.currentDriver.country,
bio: profileViewModel.currentDriver.bio || '',
joinedAt: profileViewModel.currentDriver.joinedAt,
@@ -383,30 +421,37 @@ export default function DriverDetailPage({
setDriver(driverData);
setProfileData(profileViewModel);
// Load team data
const teamUseCase = getGetDriverTeamUseCase();
await teamUseCase.execute({ driverId });
const teamViewModel = teamUseCase.presenter.getViewModel();
setTeamData(teamViewModel.result);
// Load ALL team memberships
// Load ALL team memberships using caller-owned presenters
const allTeamsUseCase = getGetAllTeamsUseCase();
await allTeamsUseCase.execute();
const allTeamsViewModel = allTeamsUseCase.presenter.getViewModel();
const allTeams = allTeamsViewModel.teams;
const membershipsUseCase = getGetTeamMembersUseCase();
const allTeamsPresenter = new AllTeamsPresenter();
await allTeamsUseCase.execute(undefined as void, allTeamsPresenter);
const allTeamsViewModel = allTeamsPresenter.getViewModel();
const allTeams = allTeamsViewModel?.teams ?? [];
const membershipsUseCase = getGetTeamMembersUseCase();
const memberships: TeamMembershipInfo[] = [];
for (const team of allTeams) {
await membershipsUseCase.execute({ teamId: team.id });
const membersViewModel = membershipsUseCase.presenter.getViewModel();
const members = membersViewModel.members;
const membership = members.find((m) => m.driverId === driverId);
const teamMembersPresenter = new TeamMembersPresenter();
await membershipsUseCase.execute({ teamId: team.id }, teamMembersPresenter);
const membersResult = teamMembersPresenter.getViewModel();
const members = membersResult?.members ?? [];
const membership = members.find(
(member: TeamMemberViewModel) => member.driverId === driverId,
);
if (membership) {
memberships.push({
team,
team: {
id: team.id,
name: team.name,
tag: team.tag,
description: team.description,
ownerId: '',
leagues: team.leagues,
createdAt: new Date(),
} as Team,
role: membership.role,
joinedAt: membership.joinedAt,
joinedAt: new Date(membership.joinedAt),
});
}
}
@@ -459,7 +504,30 @@ export default function DriverDetailPage({
);
}
const extendedProfile = profileData?.extendedProfile || getDemoExtendedProfile(driver.id);
const demoExtended = getDemoExtendedProfile(driver.id);
const extendedProfile: DriverExtendedProfile = {
socialHandles: profileData?.extendedProfile?.socialHandles ?? demoExtended.socialHandles,
achievements:
profileData?.extendedProfile?.achievements
? profileData.extendedProfile.achievements.map((achievement) => ({
id: achievement.id,
title: achievement.title,
description: achievement.description,
icon: achievement.icon,
rarity: achievement.rarity,
earnedAt: new Date(achievement.earnedAt),
}))
: demoExtended.achievements,
racingStyle: profileData?.extendedProfile?.racingStyle ?? demoExtended.racingStyle,
favoriteTrack: profileData?.extendedProfile?.favoriteTrack ?? demoExtended.favoriteTrack,
favoriteCar: profileData?.extendedProfile?.favoriteCar ?? demoExtended.favoriteCar,
timezone: profileData?.extendedProfile?.timezone ?? demoExtended.timezone,
availableHours: profileData?.extendedProfile?.availableHours ?? demoExtended.availableHours,
lookingForTeam:
profileData?.extendedProfile?.lookingForTeam ?? demoExtended.lookingForTeam,
openToRequests:
profileData?.extendedProfile?.openToRequests ?? demoExtended.openToRequests,
};
const stats = profileData?.stats || null;
const globalRank = profileData?.currentDriver?.globalRank || 1;
@@ -627,7 +695,7 @@ export default function DriverDetailPage({
<div className="mt-6 pt-6 border-t border-charcoal-outline/50">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-gray-500 mr-2">Connect:</span>
{extendedProfile.socialHandles.map((social) => {
{extendedProfile.socialHandles.map((social: SocialHandle) => {
const Icon = getSocialIcon(social.platform);
return (
<a
@@ -724,7 +792,7 @@ export default function DriverDetailPage({
</div>
<div className="flex gap-6">
<CircularProgress
value={stats.consistency}
value={stats.consistency ?? 0}
max={100}
label="Consistency"
color="text-primary-blue"
@@ -766,7 +834,9 @@ export default function DriverDetailPage({
<Target className="w-4 h-4 text-primary-blue" />
<span className="text-xs text-gray-500 uppercase">Avg Finish</span>
</div>
<p className="text-2xl font-bold text-primary-blue">P{stats.avgFinish.toFixed(1)}</p>
<p className="text-2xl font-bold text-primary-blue">
P{(stats.avgFinish ?? 0).toFixed(1)}
</p>
</div>
</div>
</div>
@@ -888,7 +958,7 @@ export default function DriverDetailPage({
<span className="ml-auto text-sm text-gray-500">{extendedProfile.achievements.length} earned</span>
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{extendedProfile.achievements.map((achievement) => {
{extendedProfile.achievements.map((achievement: Achievement) => {
const Icon = getAchievementIcon(achievement.icon);
const rarityClasses = getRarityColor(achievement.rarity);
return (
@@ -1033,7 +1103,9 @@ export default function DriverDetailPage({
<div className="text-xs text-gray-400 uppercase">Best Finish</div>
</div>
<div className="p-4 rounded-xl bg-gradient-to-br from-primary-blue/20 to-primary-blue/5 border border-primary-blue/30 text-center">
<div className="text-4xl font-bold text-primary-blue mb-1">P{stats.avgFinish.toFixed(1)}</div>
<div className="text-4xl font-bold text-primary-blue mb-1">
P{(stats.avgFinish ?? 0).toFixed(1)}
</div>
<div className="text-xs text-gray-400 uppercase">Avg Finish</div>
</div>
<div className="p-4 rounded-xl bg-gradient-to-br from-warning-amber/20 to-warning-amber/5 border border-warning-amber/30 text-center">

View File

@@ -71,11 +71,15 @@ interface TopThreePodiumProps {
}
function TopThreePodium({ drivers, onDriverClick }: TopThreePodiumProps) {
const top3 = drivers.slice(0, 3);
if (drivers.length < 3) return null;
if (top3.length < 3) return null;
const top3 = drivers.slice(0, 3) as [DriverListItem, DriverListItem, DriverListItem];
const podiumOrder = [top3[1], top3[0], top3[2]]; // 2nd, 1st, 3rd
const podiumOrder: [DriverListItem, DriverListItem, DriverListItem] = [
top3[1],
top3[0],
top3[2],
]; // 2nd, 1st, 3rd
const podiumHeights = ['h-32', 'h-40', 'h-24'];
const podiumColors = [
'from-gray-400/20 to-gray-500/10 border-gray-400/40',

View File

@@ -21,6 +21,7 @@ import {
import Button from '@/components/ui/Button';
import Heading from '@/components/ui/Heading';
import { getGetDriversLeaderboardUseCase, getGetTeamsLeaderboardUseCase } from '@/lib/di-container';
import { TeamsLeaderboardPresenter } from '@/lib/presenters/TeamsLeaderboardPresenter';
import type { DriverLeaderboardItemViewModel, SkillLevel } from '@gridpilot/racing/application/presenters/IDriversLeaderboardPresenter';
import type { TeamLeaderboardItemViewModel } from '@gridpilot/racing/application/presenters/ITeamsLeaderboardPresenter';
import Image from 'next/image';
@@ -286,14 +287,16 @@ export default function LeaderboardsPage() {
try {
const driversUseCase = getGetDriversLeaderboardUseCase();
const teamsUseCase = getGetTeamsLeaderboardUseCase();
const teamsPresenter = new TeamsLeaderboardPresenter();
await driversUseCase.execute();
await teamsUseCase.execute();
await teamsUseCase.execute(undefined as void, teamsPresenter);
const driversViewModel = driversUseCase.presenter.getViewModel();
const teamsViewModel = teamsUseCase.presenter.getViewModel();
const teamsViewModel = teamsPresenter.getViewModel();
setDrivers(driversViewModel.drivers);
setTeams(teamsViewModel.teams);
setTeams(teamsViewModel ? teamsViewModel.teams : []);
} catch (error) {
console.error('Failed to load leaderboard data:', error);
setDrivers([]);

View File

@@ -19,8 +19,8 @@ import type { League } from '@gridpilot/racing/domain/entities/League';
// Main sponsor info for "by XYZ" display
interface MainSponsorInfo {
name: string;
logoUrl?: string;
websiteUrl?: string;
logoUrl: string;
websiteUrl: string;
}
export default function LeagueLayout({
@@ -80,8 +80,8 @@ export default function LeagueLayout({
if (sponsor) {
setMainSponsor({
name: sponsor.name,
logoUrl: sponsor.logoUrl,
websiteUrl: sponsor.websiteUrl,
logoUrl: sponsor.logoUrl ?? '',
websiteUrl: sponsor.websiteUrl ?? '',
});
}
}

View File

@@ -127,7 +127,7 @@ export default function LeagueDetailPage() {
const getLeagueScoringConfigUseCase = getGetLeagueScoringConfigUseCase();
await getLeagueScoringConfigUseCase.execute({ leagueId });
const scoringViewModel = getLeagueScoringConfigUseCase.presenter.getViewModel();
setScoringConfig(scoringViewModel);
setScoringConfig(scoringViewModel as unknown as LeagueScoringConfigDTO);
// Load all drivers for standings and map to DTOs for UI components
const allDrivers = await driverRepo.findAll();
@@ -157,23 +157,23 @@ export default function LeagueDetailPage() {
if (activeSeason) {
const sponsorships = await sponsorshipRepo.findBySeasonId(activeSeason.id);
const activeSponsorships = sponsorships.filter(s => s.status === 'active');
const activeSponsorships = sponsorships.filter((s) => s.status === 'active');
const sponsorInfos: SponsorInfo[] = [];
for (const sponsorship of activeSponsorships) {
const sponsor = await sponsorRepo.findById(sponsorship.sponsorId);
if (sponsor) {
// Get tagline from demo data if available
const demoSponsors = (await import('@gridpilot/testing-support')).sponsors;
const demoSponsor = demoSponsors.find((s: any) => s.id === sponsor.id);
const testingSupportModule = await import('@gridpilot/testing-support');
const demoSponsors = testingSupportModule.sponsors as Array<{ id: string; tagline?: string }>;
const demoSponsor = demoSponsors.find((demo) => demo.id === sponsor.id);
sponsorInfos.push({
id: sponsor.id,
name: sponsor.name,
logoUrl: sponsor.logoUrl,
websiteUrl: sponsor.websiteUrl,
logoUrl: sponsor.logoUrl ?? '',
websiteUrl: sponsor.websiteUrl ?? '',
tier: sponsorship.tier,
tagline: demoSponsor?.tagline,
tagline: demoSponsor?.tagline ?? '',
});
}
}

View File

@@ -37,7 +37,7 @@ export default function LeagueRulebookPage() {
await scoringUseCase.execute({ leagueId });
const scoringViewModel = scoringUseCase.presenter.getViewModel();
setScoringConfig(scoringViewModel);
setScoringConfig(scoringViewModel as unknown as LeagueScoringConfigDTO);
} catch (err) {
console.error('Failed to load scoring config:', err);
} finally {

View File

@@ -14,6 +14,8 @@ import {
getListLeagueScoringPresetsUseCase,
getTransferLeagueOwnershipUseCase
} from '@/lib/di-container';
import { LeagueFullConfigPresenter } from '@/lib/presenters/LeagueFullConfigPresenter';
import { LeagueScoringPresetsPresenter } from '@/lib/presenters/LeagueScoringPresetsPresenter';
import { useEffectiveDriverId } from '@/lib/currentDriver';
import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles';
import { ScoringPatternSection, ChampionshipsSection } from '@/components/leagues/LeagueScoringSection';
@@ -70,13 +72,17 @@ export default function LeagueSettingsPage() {
setLeague(leagueData);
await useCase.execute({ leagueId });
const configViewModel = useCase.presenter.getViewModel();
setConfigForm(configViewModel);
const configPresenter = new LeagueFullConfigPresenter();
await useCase.execute({ leagueId }, configPresenter);
const configViewModel = configPresenter.getViewModel();
if (configViewModel) {
setConfigForm(configViewModel as LeagueConfigFormModel);
}
await presetsUseCase.execute();
const presetsViewModel = presetsUseCase.presenter.getViewModel();
setPresets(presetsViewModel);
const presetsPresenter = new LeagueScoringPresetsPresenter();
await presetsUseCase.execute(undefined as void, presetsPresenter);
const presetsViewModel = presetsPresenter.getViewModel();
setPresets(presetsViewModel.presets);
const entity = await driverRepo.findById(leagueData.ownerId);
if (entity) {

View File

@@ -37,8 +37,15 @@ export default function LeagueStandingsPage() {
const membershipRepo = getLeagueMembershipRepository();
await getLeagueDriverSeasonStatsUseCase.execute({ leagueId });
const standingsViewModel = getLeagueDriverSeasonStatsUseCase.presenter.getViewModel();
setStandings(standingsViewModel);
type GetLeagueDriverSeasonStatsUseCaseType = {
presenter: {
getViewModel(): { stats: LeagueDriverSeasonStatsDTO[] };
};
};
const typedUseCase =
getLeagueDriverSeasonStatsUseCase as GetLeagueDriverSeasonStatsUseCaseType;
const standingsViewModel = typedUseCase.presenter.getViewModel();
setStandings(standingsViewModel.stats);
const allDrivers = await driverRepo.findAll();
const driverDtos: DriverDTO[] = allDrivers
@@ -48,8 +55,19 @@ export default function LeagueStandingsPage() {
// Load league memberships from repository (consistent with other data)
const allMemberships = await membershipRepo.getLeagueMembers(leagueId);
// Convert to the format expected by StandingsTable
const membershipData: LeagueMembership[] = allMemberships.map(m => ({
type RawMembership = {
id: string | number;
leagueId: string;
driverId: string;
role: MembershipRole;
status: LeagueMembership['status'];
joinedAt: string | Date;
};
// Convert to the format expected by StandingsTable (website-level LeagueMembership)
const membershipData: LeagueMembership[] = (allMemberships as RawMembership[]).map((m) => ({
id: String(m.id),
leagueId: m.leagueId,
driverId: m.driverId,
role: m.role,

View File

@@ -246,12 +246,15 @@ export default function ProtestReviewPage() {
});
const selectedPenalty = PENALTY_TYPES.find(p => p.type === penaltyType);
const penaltyValueToUse =
selectedPenalty && selectedPenalty.requiresValue ? penaltyValue : 0;
await penaltyUseCase.execute({
raceId: protest.raceId,
driverId: protest.accusedDriverId,
stewardId: currentDriverId,
type: penaltyType,
value: selectedPenalty?.requiresValue ? penaltyValue : undefined,
value: penaltyValueToUse,
reason: protest.incident.description,
protestId: protest.id,
notes: stewardNotes,

View File

@@ -35,8 +35,8 @@ export default function CreateLeaguePage() {
const handleStepChange = (stepName: StepName) => {
const params = new URLSearchParams(
searchParams && typeof (searchParams as any).toString === 'function'
? (searchParams as any).toString()
searchParams && typeof searchParams.toString === 'function'
? searchParams.toString()
: '',
);
params.set('step', stepName);

View File

@@ -391,8 +391,11 @@ export default function LeaguesPage() {
try {
const useCase = getGetAllLeaguesWithCapacityAndScoringUseCase();
await useCase.execute();
const viewModel = useCase.presenter.getViewModel();
setRealLeagues(viewModel);
const presenter = useCase.presenter as unknown as {
getViewModel(): { leagues: LeagueSummaryDTO[] };
};
const viewModel = presenter.getViewModel();
setRealLeagues(viewModel.leagues);
} catch (error) {
console.error('Failed to load leagues:', error);
} finally {

View File

@@ -284,15 +284,14 @@ export default function ProfilePage() {
// Use GetProfileOverviewUseCase to load all profile data
const profileUseCase = getGetProfileOverviewUseCase();
await profileUseCase.execute({ driverId: currentDriverId });
const profileViewModel = profileUseCase.presenter.getViewModel();
const profileViewModel = await profileUseCase.execute({ driverId: currentDriverId });
if (profileViewModel && profileViewModel.currentDriver) {
// Set driver from ViewModel instead of direct repository access
const driverData: DriverDTO = {
id: profileViewModel.currentDriver.id,
name: profileViewModel.currentDriver.name,
iracingId: profileViewModel.currentDriver.iracingId,
iracingId: profileViewModel.currentDriver.iracingId ?? '',
country: profileViewModel.currentDriver.country,
bio: profileViewModel.currentDriver.bio || '',
joinedAt: profileViewModel.currentDriver.joinedAt,
@@ -335,11 +334,14 @@ export default function ProfilePage() {
try {
const updateProfileUseCase = getUpdateDriverProfileUseCase();
const updatedDto = await updateProfileUseCase.execute({
driverId: driver.id,
bio: updates.bio,
country: updates.country,
});
const input: { driverId: string; bio?: string; country?: string } = { driverId: driver.id };
if (typeof updates.bio === 'string') {
input.bio = updates.bio;
}
if (typeof updates.country === 'string') {
input.country = updates.country;
}
const updatedDto = await updateProfileUseCase.execute(input);
if (updatedDto) {
setDriver(updatedDto);
@@ -468,7 +470,9 @@ export default function ProfilePage() {
<>
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-primary-blue/10 border border-primary-blue/30">
<Star className="w-4 h-4 text-primary-blue" />
<span className="font-mono font-bold text-primary-blue">{stats.rating}</span>
<span className="font-mono font-bold text-primary-blue">
{stats.rating ?? 0}
</span>
<span className="text-xs text-gray-400">Rating</span>
</div>
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-yellow-400/10 border border-yellow-400/30">
@@ -644,7 +648,7 @@ export default function ProfilePage() {
</div>
<div className="flex gap-6">
<CircularProgress
value={stats.consistency}
value={stats.consistency ?? 0}
max={100}
label="Consistency"
color="text-primary-blue"
@@ -684,7 +688,9 @@ export default function ProfilePage() {
<Target className="w-4 h-4 text-primary-blue" />
<span className="text-xs text-gray-500 uppercase">Avg Finish</span>
</div>
<p className="text-2xl font-bold text-primary-blue">P{stats.avgFinish.toFixed(1)}</p>
<p className="text-2xl font-bold text-primary-blue">
P{(stats.avgFinish ?? 0).toFixed(1)}
</p>
</div>
</div>
</div>
@@ -758,7 +764,9 @@ export default function ProfilePage() {
<div className="text-xs text-gray-500 uppercase tracking-wider">Podiums</div>
</div>
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline text-center">
<div className="text-3xl font-bold text-primary-blue mb-1">{stats.consistency}%</div>
<div className="text-3xl font-bold text-primary-blue mb-1">
{stats.consistency ?? 0}%
</div>
<div className="text-xs text-gray-500 uppercase tracking-wider">Consistency</div>
</div>
</div>
@@ -863,30 +871,35 @@ export default function ProfilePage() {
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{extendedProfile.achievements.map((achievement) => {
const Icon = getAchievementIcon(achievement.icon);
const rarityClasses = getRarityColor(achievement.rarity);
return (
<div
key={achievement.id}
className={`p-4 rounded-xl border ${rarityClasses} transition-all hover:scale-105`}
>
<div className="flex items-start gap-3">
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${rarityClasses.split(' ')[1]}`}>
<Icon className={`w-5 h-5 ${rarityClasses.split(' ')[0]}`} />
</div>
<div className="flex-1 min-w-0">
<p className="text-white font-semibold text-sm">{achievement.title}</p>
<p className="text-gray-400 text-xs mt-0.5">{achievement.description}</p>
<p className="text-gray-500 text-xs mt-1">
{new Date(achievement.earnedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
</p>
const Icon = getAchievementIcon(achievement.icon);
const rarityClasses = getRarityColor(achievement.rarity);
return (
<div
key={achievement.id}
className={`p-4 rounded-xl border ${rarityClasses} transition-all hover:scale-105`}
>
<div className="flex items-start gap-3">
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${rarityClasses.split(' ')[1]}`}>
<Icon className={`w-5 h-5 ${rarityClasses.split(' ')[0]}`} />
</div>
<div className="flex-1 min-w-0">
<p className="text-white font-semibold text-sm">{achievement.title}</p>
<p className="text-gray-400 text-xs mt-0.5">{achievement.description}</p>
<p className="text-gray-500 text-xs mt-1">
{new Date(achievement.earnedAt).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</p>
</div>
</div>
</div>
</div>
);
})}
</div>
</Card>
);
})}
</div>
</Card>
)}
{/* Friends Preview */}
{socialSummary && socialSummary.friends.length > 0 && (
@@ -987,7 +1000,9 @@ export default function ProfilePage() {
<Activity className="w-4 h-4 text-primary-blue" />
<span className="text-xs text-gray-500 uppercase">Consistency</span>
</div>
<p className="text-2xl font-bold text-primary-blue">{stats.consistency}%</p>
<p className="text-2xl font-bold text-primary-blue">
{stats.consistency ?? 0}%
</p>
</div>
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline">
<div className="flex items-center gap-2 mb-2">
@@ -1015,7 +1030,9 @@ export default function ProfilePage() {
<div className="text-xs text-gray-400 uppercase">Best Finish</div>
</div>
<div className="p-4 rounded-xl bg-gradient-to-br from-primary-blue/20 to-primary-blue/5 border border-primary-blue/30 text-center">
<div className="text-4xl font-bold text-primary-blue mb-1">P{stats.avgFinish.toFixed(1)}</div>
<div className="text-4xl font-bold text-primary-blue mb-1">
P{(stats.avgFinish ?? 0).toFixed(1)}
</div>
<div className="text-xs text-gray-400 uppercase">Avg Finish</div>
</div>
<div className="p-4 rounded-xl bg-gradient-to-br from-warning-amber/20 to-warning-amber/5 border border-warning-amber/30 text-center">
@@ -1044,7 +1061,9 @@ export default function ProfilePage() {
</div>
<div className="p-6 rounded-xl bg-gradient-to-br from-primary-blue/20 to-primary-blue/5 border border-primary-blue/30 text-center">
<Star className="w-8 h-8 text-primary-blue mx-auto mb-3" />
<div className="text-3xl font-bold text-primary-blue mb-1">{stats.rating}</div>
<div className="text-3xl font-bold text-primary-blue mb-1">
{stats.rating ?? 0}
</div>
<div className="text-sm text-gray-400">Rating</div>
</div>
<div className="p-6 rounded-xl bg-gradient-to-br from-purple-400/20 to-purple-600/5 border border-purple-400/30 text-center">

View File

@@ -16,6 +16,7 @@ import {
getLeagueMembershipRepository,
getTeamMembershipRepository,
} from '@/lib/di-container';
import { PendingSponsorshipRequestsPresenter } from '@/lib/presenters/PendingSponsorshipRequestsPresenter';
import { useEffectiveDriverId } from '@/lib/currentDriver';
import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles';
import { Handshake, User, Users, Trophy, ChevronRight, Building, AlertTriangle } from 'lucide-react';
@@ -51,12 +52,17 @@ export default function SponsorshipRequestsPage() {
const allSections: EntitySection[] = [];
// 1. Driver's own sponsorship requests
const driverResult = await query.execute({
entityType: 'driver',
entityId: currentDriverId,
});
const driverPresenter = new PendingSponsorshipRequestsPresenter();
await useCase.execute(
{
entityType: 'driver',
entityId: currentDriverId,
},
driverPresenter,
);
const driverResult = driverPresenter.getViewModel();
if (driverResult.requests.length > 0) {
if (driverResult && driverResult.requests.length > 0) {
const driver = await driverRepo.findById(currentDriverId);
allSections.push({
entityType: 'driver',
@@ -74,12 +80,17 @@ export default function SponsorshipRequestsPage() {
// Load sponsorship requests for this league's active season
try {
// For simplicity, we'll query by season entityType - in production you'd get the active season ID
const leagueResult = await query.execute({
entityType: 'season',
entityId: league.id, // Using league ID as a proxy for now
});
const leaguePresenter = new PendingSponsorshipRequestsPresenter();
await useCase.execute(
{
entityType: 'season',
entityId: league.id, // Using league ID as a proxy for now
},
leaguePresenter,
);
const leagueResult = leaguePresenter.getViewModel();
if (leagueResult.requests.length > 0) {
if (leagueResult && leagueResult.requests.length > 0) {
allSections.push({
entityType: 'season',
entityId: league.id,
@@ -98,12 +109,17 @@ export default function SponsorshipRequestsPage() {
for (const team of allTeams) {
const membership = await teamMembershipRepo.getMembership(team.id, currentDriverId);
if (membership && (membership.role === 'owner' || membership.role === 'manager')) {
const teamResult = await query.execute({
entityType: 'team',
entityId: team.id,
});
const teamPresenter = new PendingSponsorshipRequestsPresenter();
await useCase.execute(
{
entityType: 'team',
entityId: team.id,
},
teamPresenter,
);
const teamResult = teamPresenter.getViewModel();
if (teamResult.requests.length > 0) {
if (teamResult && teamResult.requests.length > 0) {
allSections.push({
entityType: 'team',
entityId: team.id,
@@ -138,11 +154,14 @@ export default function SponsorshipRequestsPage() {
const handleReject = async (requestId: string, reason?: string) => {
const useCase = getRejectSponsorshipRequestUseCase();
await useCase.execute({
const input: { requestId: string; respondedBy: string; reason?: string } = {
requestId,
respondedBy: currentDriverId,
reason,
});
};
if (typeof reason === 'string') {
input.reason = reason;
}
await useCase.execute(input);
await loadAllRequests();
};

View File

@@ -928,7 +928,7 @@ export default function RaceDetailPage() {
isOpen={showProtestModal}
onClose={() => setShowProtestModal(false)}
raceId={race.id}
leagueId={league?.id}
leagueId={league ? league.id : ''}
protestingDriverId={currentDriverId}
participants={entryList.map(d => ({ id: d.id, name: d.name }))}
/>

View File

@@ -115,13 +115,17 @@ export default function RaceResultsPage() {
setPointsSystem(viewModel.pointsSystem);
setFastestLapTime(viewModel.fastestLapTime);
setCurrentDriverId(viewModel.currentDriverId);
setPenalties(
viewModel.penalties.map((p) => ({
const mappedPenalties: PenaltyData[] = viewModel.penalties.map((p) => {
const base: PenaltyData = {
driverId: p.driverId,
type: p.type as PenaltyTypeDTO,
value: p.value,
})),
);
};
if (typeof p.value === 'number') {
return { ...base, value: p.value };
}
return base;
});
setPenalties(mappedPenalties);
}
try {
@@ -287,9 +291,9 @@ export default function RaceResultsPage() {
results={results}
drivers={drivers}
pointsSystem={pointsSystem}
fastestLapTime={fastestLapTime}
fastestLapTime={fastestLapTime ?? 0}
penalties={penalties}
currentDriverId={currentDriverId}
currentDriverId={currentDriverId ?? ''}
/>
) : (
<>

View File

@@ -31,6 +31,8 @@ import {
} from '@/lib/di-container';
import { useEffectiveDriverId } from '@/lib/currentDriver';
import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles';
import { RaceProtestsPresenter } from '@/lib/presenters/RaceProtestsPresenter';
import { RacePenaltiesPresenter } from '@/lib/presenters/RacePenaltiesPresenter';
import type { RaceProtestViewModel } from '@gridpilot/racing/application/presenters/IRaceProtestsPresenter';
import type { RacePenaltyViewModel } from '@gridpilot/racing/application/presenters/IRacePenaltiesPresenter';
import type { League } from '@gridpilot/racing/domain/entities/League';
@@ -41,7 +43,9 @@ export default function RaceStewardingPage() {
const router = useRouter();
const raceId = params.id as string;
const currentDriverId = useEffectiveDriverId();
const driversById: Record<string, { name?: string }> = {};
const [race, setRace] = useState<Race | null>(null);
const [league, setLeague] = useState<League | null>(null);
const [protests, setProtests] = useState<RaceProtestViewModel[]>([]);
@@ -78,13 +82,15 @@ export default function RaceStewardingPage() {
setIsAdmin(membership ? isLeagueAdminOrHigherRole(membership.role) : false);
}
await protestsUseCase.execute(raceId);
const protestsViewModel = protestsUseCase.presenter.getViewModel();
setProtests(protestsViewModel.protests);
const protestsPresenter = new RaceProtestsPresenter();
await protestsUseCase.execute({ raceId }, protestsPresenter);
const protestsViewModel = protestsPresenter.getViewModel();
setProtests(protestsViewModel?.protests ?? []);
await penaltiesUseCase.execute(raceId);
const penaltiesViewModel = penaltiesUseCase.presenter.getViewModel();
setPenalties(penaltiesViewModel.penalties);
const penaltiesPresenter = new RacePenaltiesPresenter();
await penaltiesUseCase.execute({ raceId }, penaltiesPresenter);
const penaltiesViewModel = penaltiesPresenter.getViewModel();
setPenalties(penaltiesViewModel?.penalties ?? []);
} catch (err) {
console.error('Failed to load data:', err);
} finally {

View File

@@ -105,8 +105,9 @@ export default function AllRacesPage() {
setCurrentPage(1);
}, [statusFilter, leagueFilter, searchQuery]);
const formatDate = (date: Date) => {
return new Date(date).toLocaleDateString('en-US', {
const formatDate = (date: Date | string) => {
const d = typeof date === 'string' ? new Date(date) : date;
return d.toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
@@ -114,8 +115,9 @@ export default function AllRacesPage() {
});
};
const formatTime = (date: Date) => {
return new Date(date).toLocaleTimeString('en-US', {
const formatTime = (date: Date | string) => {
const d = typeof date === 'string' ? new Date(date) : date;
return d.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
});

View File

@@ -93,8 +93,11 @@ export default function RacesPage() {
// Group races by date for calendar view
const racesByDate = useMemo(() => {
const grouped = new Map<string, RaceListItemViewModel[]>();
filteredRaces.forEach(race => {
const dateKey = new Date(race.scheduledAt).toISOString().split('T')[0];
filteredRaces.forEach((race) => {
if (typeof race.scheduledAt !== 'string') {
return;
}
const dateKey = race.scheduledAt.split('T')[0]!;
if (!grouped.has(dateKey)) {
grouped.set(dateKey, []);
}
@@ -108,23 +111,26 @@ export default function RacesPage() {
const recentResults = pageData?.recentResults ?? [];
const stats = pageData?.stats ?? { total: 0, scheduled: 0, running: 0, completed: 0 };
const formatDate = (date: Date) => {
return new Date(date).toLocaleDateString('en-US', {
const formatDate = (date: Date | string) => {
const d = typeof date === 'string' ? new Date(date) : date;
return d.toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
});
};
const formatTime = (date: Date) => {
return new Date(date).toLocaleTimeString('en-US', {
const formatTime = (date: Date | string) => {
const d = typeof date === 'string' ? new Date(date) : date;
return d.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
});
};
const formatFullDate = (date: Date) => {
return new Date(date).toLocaleDateString('en-US', {
const formatFullDate = (date: Date | string) => {
const d = typeof date === 'string' ? new Date(date) : date;
return d.toLocaleDateString('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric',
@@ -132,9 +138,10 @@ export default function RacesPage() {
});
};
const getRelativeTime = (date: Date) => {
const getRelativeTime = (date?: Date | string) => {
if (!date) return '';
const now = new Date();
const targetDate = new Date(date);
const targetDate = typeof date === 'string' ? new Date(date) : date;
const diffMs = targetDate.getTime() - now.getTime();
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
@@ -144,7 +151,7 @@ export default function RacesPage() {
if (diffHours < 24) return `In ${diffHours}h`;
if (diffDays === 1) return 'Tomorrow';
if (diffDays < 7) return `In ${diffDays} days`;
return formatDate(date);
return formatDate(targetDate);
};
const statusConfig = {
@@ -368,6 +375,9 @@ export default function RacesPage() {
{/* Races for this date */}
<div className="space-y-2">
{dayRaces.map((race) => {
if (!race.scheduledAt) {
return null;
}
const config = statusConfig[race.status];
const StatusIcon = config.icon;
@@ -385,9 +395,13 @@ export default function RacesPage() {
<div className="flex items-start gap-4">
{/* Time Column */}
<div className="flex-shrink-0 text-center min-w-[60px]">
<p className="text-lg font-bold text-white">{formatTime(new Date(race.scheduledAt))}</p>
<p className="text-lg font-bold text-white">
{formatTime(race.scheduledAt)}
</p>
<p className={`text-xs ${config.color}`}>
{race.status === 'running' ? 'LIVE' : getRelativeTime(new Date(race.scheduledAt))}
{race.status === 'running'
? 'LIVE'
: getRelativeTime(race.scheduledAt)}
</p>
</div>
@@ -427,7 +441,7 @@ export default function RacesPage() {
{/* League Link */}
<div className="mt-3 pt-3 border-t border-charcoal-outline/50">
<Link
href={`/leagues/${race.leagueId}`}
href={`/leagues/${race.leagueId ?? ''}`}
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-2 text-sm text-primary-blue hover:underline"
>
@@ -482,24 +496,30 @@ export default function RacesPage() {
</p>
) : (
<div className="space-y-3">
{upcomingRaces.map((race) => (
<div
key={race.id}
onClick={() => router.push(`/races/${race.id}`)}
className="flex items-center gap-3 p-2 rounded-lg hover:bg-deep-graphite cursor-pointer transition-colors"
>
<div className="flex-shrink-0 w-10 h-10 bg-primary-blue/10 rounded-lg flex items-center justify-center">
<span className="text-sm font-bold text-primary-blue">
{new Date(race.scheduledAt).getDate()}
</span>
{upcomingRaces.map((race) => {
if (!race.scheduledAt) {
return null;
}
const scheduledAtDate = new Date(race.scheduledAt);
return (
<div
key={race.id}
onClick={() => router.push(`/races/${race.id}`)}
className="flex items-center gap-3 p-2 rounded-lg hover:bg-deep-graphite cursor-pointer transition-colors"
>
<div className="flex-shrink-0 w-10 h-10 bg-primary-blue/10 rounded-lg flex items-center justify-center">
<span className="text-sm font-bold text-primary-blue">
{scheduledAtDate.getDate()}
</span>
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-white truncate">{race.track}</p>
<p className="text-xs text-gray-500">{formatTime(scheduledAtDate)}</p>
</div>
<ChevronRight className="w-4 h-4 text-gray-500" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-white truncate">{race.track}</p>
<p className="text-xs text-gray-500">{formatTime(new Date(race.scheduledAt))}</p>
</div>
<ChevronRight className="w-4 h-4 text-gray-500" />
</div>
))}
);
})}
</div>
)}
</Card>

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 {