diff --git a/apps/website/app/api/leagues/schedule-preview/route.ts b/apps/website/app/api/leagues/schedule-preview/route.ts index b174f0c39..53a9e2ddc 100644 --- a/apps/website/app/api/leagues/schedule-preview/route.ts +++ b/apps/website/app/api/leagues/schedule-preview/route.ts @@ -4,7 +4,8 @@ import { type LeagueScheduleDTO, type LeagueSchedulePreviewDTO, } from '@gridpilot/racing/application'; -import { getPreviewLeagueScheduleQuery } from '@/lib/di-container'; +import { getPreviewLeagueScheduleUseCase } from '@/lib/di-container'; +import { LeagueSchedulePreviewPresenter } from '@/lib/presenters/LeagueSchedulePreviewPresenter'; interface RequestBody { seasonStartDate?: string; @@ -73,11 +74,16 @@ export async function POST(request: NextRequest) { const schedule = toLeagueScheduleDTO(json); - const query = getPreviewLeagueScheduleQuery(); - const preview: LeagueSchedulePreviewDTO = await query.execute({ + const presenter = new LeagueSchedulePreviewPresenter(); + const useCase = getPreviewLeagueScheduleUseCase(); + useCase.execute({ schedule, maxRounds: 10, }); + const preview = presenter.getData(); + if (!preview) { + return NextResponse.json({ error: 'Failed to generate preview' }, { status: 500 }); + } return NextResponse.json(preview, { status: 200 }); } catch (error) { diff --git a/apps/website/app/api/sponsors/dashboard/route.ts b/apps/website/app/api/sponsors/dashboard/route.ts index 23d5d854e..544716e66 100644 --- a/apps/website/app/api/sponsors/dashboard/route.ts +++ b/apps/website/app/api/sponsors/dashboard/route.ts @@ -1,7 +1,8 @@ import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; import { cookies } from 'next/headers'; -import { getGetSponsorDashboardQuery } from '@/lib/di-container'; +import { getGetSponsorDashboardUseCase } from '@/lib/di-container'; +import { SponsorDashboardPresenter } from '@/lib/presenters/SponsorDashboardPresenter'; export async function GET(request: NextRequest) { try { @@ -16,8 +17,10 @@ export async function GET(request: NextRequest) { ); } - const query = getGetSponsorDashboardQuery(); - const dashboard = await query.execute({ sponsorId }); + const presenter = new SponsorDashboardPresenter(); + const useCase = getGetSponsorDashboardUseCase(); + await useCase.execute({ sponsorId }); + const dashboard = presenter.getData(); if (!dashboard) { return NextResponse.json( diff --git a/apps/website/app/api/sponsors/sponsorships/route.ts b/apps/website/app/api/sponsors/sponsorships/route.ts index 6c7349586..cf8e4a2fd 100644 --- a/apps/website/app/api/sponsors/sponsorships/route.ts +++ b/apps/website/app/api/sponsors/sponsorships/route.ts @@ -1,7 +1,8 @@ import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; import { cookies } from 'next/headers'; -import { getGetSponsorSponsorshipsQuery } from '@/lib/di-container'; +import { getGetSponsorSponsorshipsUseCase } from '@/lib/di-container'; +import { SponsorSponsorshipsPresenter } from '@/lib/presenters/SponsorSponsorshipsPresenter'; export async function GET(request: NextRequest) { try { @@ -16,8 +17,10 @@ export async function GET(request: NextRequest) { ); } - const query = getGetSponsorSponsorshipsQuery(); - const sponsorships = await query.execute({ sponsorId }); + const presenter = new SponsorSponsorshipsPresenter(); + const useCase = getGetSponsorSponsorshipsUseCase(); + await useCase.execute({ sponsorId }); + const sponsorships = presenter.getData(); if (!sponsorships) { return NextResponse.json( diff --git a/apps/website/app/drivers/[id]/page.tsx b/apps/website/app/drivers/[id]/page.tsx index 5e8891934..822aee900 100644 --- a/apps/website/app/drivers/[id]/page.tsx +++ b/apps/website/app/drivers/[id]/page.tsx @@ -40,11 +40,11 @@ import { getDriverRepository, getDriverStats, getAllDriverRankings, - getGetDriverTeamQuery, + getGetDriverTeamUseCase, getSocialRepository, getImageService, - getGetAllTeamsQuery, - getGetTeamMembersQuery, + getGetAllTeamsUseCase, + getGetTeamMembersUseCase, } from '@/lib/di-container'; import { Driver, EntityMappers, type Team } from '@gridpilot/racing'; import type { DriverDTO } from '@gridpilot/racing'; @@ -382,18 +382,23 @@ export default function DriverDetailPage({ setDriver(driverDto); // Load team data - const teamQuery = getGetDriverTeamQuery(); - const teamResult = await teamQuery.execute({ driverId }); - setTeamData(teamResult); + const teamUseCase = getGetDriverTeamUseCase(); + await teamUseCase.execute({ driverId }); + const teamViewModel = teamUseCase.presenter.getViewModel(); + setTeamData(teamViewModel.result); // Load ALL team memberships - const allTeamsQuery = getGetAllTeamsQuery(); - const allTeams = await allTeamsQuery.execute(); - const membershipsQuery = getGetTeamMembersQuery(); + const allTeamsUseCase = getGetAllTeamsUseCase(); + await allTeamsUseCase.execute(); + const allTeamsViewModel = allTeamsUseCase.presenter.getViewModel(); + const allTeams = allTeamsViewModel.teams; + const membershipsUseCase = getGetTeamMembersUseCase(); const memberships: TeamMembershipInfo[] = []; for (const team of allTeams) { - const members = await membershipsQuery.execute({ teamId: team.id }); + await membershipsUseCase.execute({ teamId: team.id }); + const membersViewModel = membershipsUseCase.presenter.getViewModel(); + const members = membersViewModel.members; const membership = members.find((m) => m.driverId === driverId); if (membership) { memberships.push({ diff --git a/apps/website/app/drivers/page.tsx b/apps/website/app/drivers/page.tsx index cec1fc337..9c6bfed74 100644 --- a/apps/website/app/drivers/page.tsx +++ b/apps/website/app/drivers/page.tsx @@ -27,27 +27,15 @@ import Button from '@/components/ui/Button'; import Input from '@/components/ui/Input'; import Card from '@/components/ui/Card'; import Heading from '@/components/ui/Heading'; -import { getDriverRepository, getDriverStats, getAllDriverRankings, getImageService } from '@/lib/di-container'; +import { getGetDriversLeaderboardUseCase } from '@/lib/di-container'; +import type { DriverLeaderboardItemViewModel, SkillLevel } from '@gridpilot/racing/application/presenters/IDriversLeaderboardPresenter'; import Image from 'next/image'; // ============================================================================ // TYPES // ============================================================================ -type SkillLevel = 'beginner' | 'intermediate' | 'advanced' | 'pro'; - -interface DriverListItem { - id: string; - name: string; - rating: number; - skillLevel: SkillLevel; - nationality: string; - racesCompleted: number; - wins: number; - podiums: number; - isActive: boolean; - rank: number; -} +type DriverListItem = DriverLeaderboardItemViewModel; // ============================================================================ // DEMO DATA @@ -87,7 +75,6 @@ interface FeaturedDriverCardProps { } function FeaturedDriverCard({ driver, position, onClick }: FeaturedDriverCardProps) { - const imageService = getImageService(); const levelConfig = SKILL_LEVELS.find((l) => l.id === driver.skillLevel); const getBorderColor = (pos: number) => { @@ -131,7 +118,7 @@ function FeaturedDriverCard({ driver, position, onClick }: FeaturedDriverCardPro {/* Avatar & Name */}
- {driver.name} + {driver.name}

@@ -236,7 +223,6 @@ interface LeaderboardPreviewProps { function LeaderboardPreview({ drivers, onDriverClick }: LeaderboardPreviewProps) { const router = useRouter(); - const imageService = getImageService(); const top5 = drivers.slice(0, 5); const getMedalColor = (position: number) => { @@ -300,7 +286,7 @@ function LeaderboardPreview({ drivers, onDriverClick }: LeaderboardPreviewProps) {/* Avatar */}
- {driver.name} + {driver.name}
{/* Info */} @@ -345,7 +331,6 @@ interface RecentActivityProps { } function RecentActivity({ drivers, onDriverClick }: RecentActivityProps) { - const imageService = getImageService(); const activeDrivers = drivers.filter((d) => d.isActive).slice(0, 6); return ( @@ -371,7 +356,7 @@ function RecentActivity({ drivers, onDriverClick }: RecentActivityProps) { className="p-3 rounded-xl bg-iron-gray/40 border border-charcoal-outline hover:border-performance-green/40 transition-all group text-center" >
- {driver.name} + {driver.name}

@@ -395,57 +380,20 @@ export default function DriversPage() { const [drivers, setDrivers] = useState([]); const [loading, setLoading] = useState(true); const [searchQuery, setSearchQuery] = useState(''); + const [totalRaces, setTotalRaces] = useState(0); + const [totalWins, setTotalWins] = useState(0); + const [activeCount, setActiveCount] = useState(0); useEffect(() => { const load = async () => { - const driverRepo = getDriverRepository(); - const allDrivers = await driverRepo.findAll(); - const rankings = getAllDriverRankings(); + const useCase = getGetDriversLeaderboardUseCase(); + await useCase.execute(); + const viewModel = useCase.presenter.getViewModel(); - const items: DriverListItem[] = allDrivers.map((driver) => { - const stats = getDriverStats(driver.id); - const rating = stats?.rating ?? 0; - const wins = stats?.wins ?? 0; - const podiums = stats?.podiums ?? 0; - const totalRaces = stats?.totalRaces ?? 0; - - let effectiveRank = Number.POSITIVE_INFINITY; - if (typeof stats?.overallRank === 'number' && stats.overallRank > 0) { - effectiveRank = stats.overallRank; - } else { - const indexInGlobal = rankings.findIndex((entry) => entry.driverId === driver.id); - if (indexInGlobal !== -1) { - effectiveRank = indexInGlobal + 1; - } - } - - const skillLevel: SkillLevel = - rating >= 3000 ? 'pro' : rating >= 2500 ? 'advanced' : rating >= 1800 ? 'intermediate' : 'beginner'; - - const isActive = rankings.some((r) => r.driverId === driver.id); - - return { - id: driver.id, - name: driver.name, - rating, - skillLevel, - nationality: driver.country, - racesCompleted: totalRaces, - wins, - podiums, - isActive, - rank: effectiveRank, - }; - }); - - // Sort by rank - items.sort((a, b) => { - const rankA = Number.isFinite(a.rank) && a.rank > 0 ? a.rank : Number.POSITIVE_INFINITY; - const rankB = Number.isFinite(b.rank) && b.rank > 0 ? b.rank : Number.POSITIVE_INFINITY; - return rankA - rankB || b.rating - a.rating; - }); - - setDrivers(items); + setDrivers(viewModel.drivers); + setTotalRaces(viewModel.totalRaces); + setTotalWins(viewModel.totalWins); + setActiveCount(viewModel.activeCount); setLoading(false); }; @@ -465,10 +413,6 @@ export default function DriversPage() { ); }); - // Stats - const totalRaces = drivers.reduce((sum, d) => sum + d.racesCompleted, 0); - const totalWins = drivers.reduce((sum, d) => sum + d.wins, 0); - const activeCount = drivers.filter((d) => d.isActive).length; // Featured drivers (top 4) const featuredDrivers = filteredDrivers.slice(0, 4); diff --git a/apps/website/app/leaderboards/drivers/page.tsx b/apps/website/app/leaderboards/drivers/page.tsx index 1d67ec42a..d0f1f5005 100644 --- a/apps/website/app/leaderboards/drivers/page.tsx +++ b/apps/website/app/leaderboards/drivers/page.tsx @@ -19,27 +19,17 @@ import { import Button from '@/components/ui/Button'; import Input from '@/components/ui/Input'; import Heading from '@/components/ui/Heading'; -import { getDriverRepository, getDriverStats, getAllDriverRankings, getImageService } from '@/lib/di-container'; +import { getGetDriversLeaderboardUseCase } from '@/lib/di-container'; +import type { DriverLeaderboardItemViewModel, SkillLevel } from '@gridpilot/racing/application/presenters/IDriversLeaderboardPresenter'; import Image from 'next/image'; // ============================================================================ // TYPES // ============================================================================ -type SkillLevel = 'beginner' | 'intermediate' | 'advanced' | 'pro'; type SortBy = 'rank' | 'rating' | 'wins' | 'podiums' | 'winRate'; -interface DriverListItem { - id: string; - name: string; - rating: number; - skillLevel: SkillLevel; - nationality: string; - racesCompleted: number; - wins: number; - podiums: number; - rank: number; -} +type DriverListItem = DriverLeaderboardItemViewModel; // ============================================================================ // SKILL LEVEL CONFIG @@ -81,7 +71,6 @@ interface TopThreePodiumProps { } function TopThreePodium({ drivers, onDriverClick }: TopThreePodiumProps) { - const imageService = getImageService(); const top3 = drivers.slice(0, 3); if (top3.length < 3) return null; @@ -122,7 +111,7 @@ function TopThreePodium({ drivers, onDriverClick }: TopThreePodiumProps) { {/* Avatar */}

{driver.name}([]); const [loading, setLoading] = useState(true); const [searchQuery, setSearchQuery] = useState(''); @@ -188,44 +176,10 @@ export default function DriverLeaderboardPage() { useEffect(() => { const load = async () => { - const driverRepo = getDriverRepository(); - const allDrivers = await driverRepo.findAll(); - const rankings = getAllDriverRankings(); - - const items: DriverListItem[] = allDrivers.map((driver) => { - const stats = getDriverStats(driver.id); - const rating = stats?.rating ?? 0; - const wins = stats?.wins ?? 0; - const podiums = stats?.podiums ?? 0; - const totalRaces = stats?.totalRaces ?? 0; - - let effectiveRank = Number.POSITIVE_INFINITY; - if (typeof stats?.overallRank === 'number' && stats.overallRank > 0) { - effectiveRank = stats.overallRank; - } else { - const indexInGlobal = rankings.findIndex((entry) => entry.driverId === driver.id); - if (indexInGlobal !== -1) { - effectiveRank = indexInGlobal + 1; - } - } - - const skillLevel: SkillLevel = - rating >= 3000 ? 'pro' : rating >= 2500 ? 'advanced' : rating >= 1800 ? 'intermediate' : 'beginner'; - - return { - id: driver.id, - name: driver.name, - rating, - skillLevel, - nationality: driver.country, - racesCompleted: totalRaces, - wins, - podiums, - rank: effectiveRank, - }; - }); - - setDrivers(items); + const useCase = getGetDriversLeaderboardUseCase(); + await useCase.execute(); + const viewModel = useCase.presenter.getViewModel(); + setDrivers(viewModel.drivers); setLoading(false); }; @@ -443,7 +397,7 @@ export default function DriverLeaderboardPage() { {/* Driver Info */}
- {driver.name} + {driver.name}

diff --git a/apps/website/app/leaderboards/page.tsx b/apps/website/app/leaderboards/page.tsx index ed0102764..f0cbc2e26 100644 --- a/apps/website/app/leaderboards/page.tsx +++ b/apps/website/app/leaderboards/page.tsx @@ -20,36 +20,18 @@ import { } from 'lucide-react'; import Button from '@/components/ui/Button'; import Heading from '@/components/ui/Heading'; -import { getDriverRepository, getDriverStats, getAllDriverRankings, getImageService, getGetAllTeamsQuery, getGetTeamMembersQuery } from '@/lib/di-container'; +import { getGetDriversLeaderboardUseCase, getGetTeamsLeaderboardUseCase } from '@/lib/di-container'; +import type { DriverLeaderboardItemViewModel, SkillLevel } from '@gridpilot/racing/application/presenters/IDriversLeaderboardPresenter'; +import type { TeamLeaderboardItemViewModel } from '@gridpilot/racing/application/presenters/ITeamsLeaderboardPresenter'; import Image from 'next/image'; -import type { Team } from '@gridpilot/racing'; // ============================================================================ // TYPES // ============================================================================ -type SkillLevel = 'beginner' | 'intermediate' | 'advanced' | 'pro'; +type DriverListItem = DriverLeaderboardItemViewModel; -interface DriverListItem { - id: string; - name: string; - rating: number; - skillLevel: SkillLevel; - nationality: string; - wins: number; - podiums: number; - rank: number; -} - -interface TeamDisplayData { - id: string; - name: string; - memberCount: number; - rating: number | null; - totalWins: number; - totalRaces: number; - performanceLevel: SkillLevel; -} +type TeamDisplayData = TeamLeaderboardItemViewModel; // ============================================================================ // SKILL LEVEL CONFIG @@ -80,7 +62,6 @@ interface DriverLeaderboardPreviewProps { function DriverLeaderboardPreview({ drivers, onDriverClick }: DriverLeaderboardPreviewProps) { const router = useRouter(); - const imageService = getImageService(); const top10 = drivers.slice(0, 10); const getMedalColor = (position: number) => { @@ -144,7 +125,7 @@ function DriverLeaderboardPreview({ drivers, onDriverClick }: DriverLeaderboardP {/* Avatar */}

- {driver.name} + {driver.name}
{/* Info */} @@ -189,7 +170,6 @@ interface TeamLeaderboardPreviewProps { function TeamLeaderboardPreview({ teams, onTeamClick }: TeamLeaderboardPreviewProps) { const router = useRouter(); - const imageService = getImageService(); const top5 = [...teams] .filter((t) => t.rating !== null) .sort((a, b) => (b.rating ?? 0) - (a.rating ?? 0)) @@ -304,99 +284,16 @@ export default function LeaderboardsPage() { useEffect(() => { const load = async () => { try { - // Load drivers - const driverRepo = getDriverRepository(); - const allDrivers = await driverRepo.findAll(); - const rankings = getAllDriverRankings(); + const driversUseCase = getGetDriversLeaderboardUseCase(); + const teamsUseCase = getGetTeamsLeaderboardUseCase(); + await driversUseCase.execute(); + await teamsUseCase.execute(); - const driverItems: DriverListItem[] = allDrivers.map((driver) => { - const stats = getDriverStats(driver.id); - const rating = stats?.rating ?? 0; - const wins = stats?.wins ?? 0; - const podiums = stats?.podiums ?? 0; + const driversViewModel = driversUseCase.presenter.getViewModel(); + const teamsViewModel = teamsUseCase.presenter.getViewModel(); - let effectiveRank = Number.POSITIVE_INFINITY; - if (typeof stats?.overallRank === 'number' && stats.overallRank > 0) { - effectiveRank = stats.overallRank; - } else { - const indexInGlobal = rankings.findIndex((entry) => entry.driverId === driver.id); - if (indexInGlobal !== -1) { - effectiveRank = indexInGlobal + 1; - } - } - - const skillLevel: SkillLevel = - rating >= 3000 ? 'pro' : rating >= 2500 ? 'advanced' : rating >= 1800 ? 'intermediate' : 'beginner'; - - return { - id: driver.id, - name: driver.name, - rating, - skillLevel, - nationality: driver.country, - wins, - podiums, - rank: effectiveRank, - }; - }); - - // Sort by rank - driverItems.sort((a, b) => { - const rankA = Number.isFinite(a.rank) && a.rank > 0 ? a.rank : Number.POSITIVE_INFINITY; - const rankB = Number.isFinite(b.rank) && b.rank > 0 ? b.rank : Number.POSITIVE_INFINITY; - return rankA - rankB || b.rating - a.rating; - }); - - // Load teams - const allTeamsQuery = getGetAllTeamsQuery(); - const teamMembersQuery = getGetTeamMembersQuery(); - const allTeams = await allTeamsQuery.execute(); - const teamData: TeamDisplayData[] = []; - - await Promise.all( - allTeams.map(async (team: Team) => { - const memberships = await teamMembersQuery.execute({ teamId: team.id }); - const memberCount = memberships.length; - - let ratingSum = 0; - let ratingCount = 0; - let totalWins = 0; - let totalRaces = 0; - - for (const membership of memberships) { - const stats = getDriverStats(membership.driverId); - if (!stats) continue; - if (typeof stats.rating === 'number') { - ratingSum += stats.rating; - ratingCount += 1; - } - totalWins += stats.wins ?? 0; - totalRaces += stats.totalRaces ?? 0; - } - - const averageRating = ratingCount > 0 ? ratingSum / ratingCount : null; - - let performanceLevel: SkillLevel = 'beginner'; - if (averageRating !== null) { - if (averageRating >= 4500) performanceLevel = 'pro'; - else if (averageRating >= 3000) performanceLevel = 'advanced'; - else if (averageRating >= 2000) performanceLevel = 'intermediate'; - } - - teamData.push({ - id: team.id, - name: team.name, - memberCount, - rating: averageRating, - totalWins, - totalRaces, - performanceLevel, - }); - }), - ); - - setDrivers(driverItems); - setTeams(teamData); + setDrivers(driversViewModel.drivers); + setTeams(teamsViewModel.teams); } catch (error) { console.error('Failed to load leaderboard data:', error); setDrivers([]); diff --git a/apps/website/app/leagues/[id]/page.tsx b/apps/website/app/leagues/[id]/page.tsx index adde5d22e..7d9ef9558 100644 --- a/apps/website/app/leagues/[id]/page.tsx +++ b/apps/website/app/leagues/[id]/page.tsx @@ -25,10 +25,10 @@ import { getLeagueRepository, getRaceRepository, getDriverRepository, - getGetLeagueScoringConfigQuery, + getGetLeagueScoringConfigUseCase, getDriverStats, getAllDriverRankings, - getGetLeagueStatsQuery, + getGetLeagueStatsUseCase, getSeasonRepository, getSponsorRepository, getSeasonSponsorshipRepository, @@ -104,7 +104,7 @@ export default function LeagueDetailPage() { const leagueRepo = getLeagueRepository(); const raceRepo = getRaceRepository(); const driverRepo = getDriverRepository(); - const leagueStatsQuery = getGetLeagueStatsQuery(); + const leagueStatsUseCase = getGetLeagueStatsUseCase(); const seasonRepo = getSeasonRepository(); const sponsorRepo = getSponsorRepository(); const sponsorshipRepo = getSeasonSponsorshipRepository(); @@ -124,9 +124,10 @@ export default function LeagueDetailPage() { setOwner(ownerData); // Load scoring configuration for the active season - const getLeagueScoringConfigQuery = getGetLeagueScoringConfigQuery(); - const scoring = await getLeagueScoringConfigQuery.execute({ leagueId }); - setScoringConfig(scoring); + const getLeagueScoringConfigUseCase = getGetLeagueScoringConfigUseCase(); + await getLeagueScoringConfigUseCase.execute({ leagueId }); + const scoringViewModel = getLeagueScoringConfigUseCase.presenter.getViewModel(); + setScoringConfig(scoringViewModel); // Load all drivers for standings and map to DTOs for UI components const allDrivers = await driverRepo.findAll(); @@ -136,11 +137,12 @@ export default function LeagueDetailPage() { setDrivers(driverDtos); - // Load league stats including average SOF from application query - const leagueStats = await leagueStatsQuery.execute({ leagueId }); - if (leagueStats) { - setAverageSOF(leagueStats.averageSOF); - setCompletedRacesCount(leagueStats.completedRaces); + // Load league stats including average SOF from application use case + await leagueStatsUseCase.execute({ leagueId }); + const leagueStatsViewModel = leagueStatsUseCase.presenter.getViewModel(); + if (leagueStatsViewModel) { + setAverageSOF(leagueStatsViewModel.averageSOF); + setCompletedRacesCount(leagueStatsViewModel.completedRaces); } else { // Fallback: count completed races manually const leagueRaces = await raceRepo.findByLeagueId(leagueId); diff --git a/apps/website/app/leagues/[id]/rulebook/page.tsx b/apps/website/app/leagues/[id]/rulebook/page.tsx index d6af992fc..4bf11a5eb 100644 --- a/apps/website/app/leagues/[id]/rulebook/page.tsx +++ b/apps/website/app/leagues/[id]/rulebook/page.tsx @@ -5,7 +5,7 @@ import { useParams } from 'next/navigation'; import Card from '@/components/ui/Card'; import { getLeagueRepository, - getGetLeagueScoringConfigQuery + getGetLeagueScoringConfigUseCase } from '@/lib/di-container'; import type { LeagueScoringConfigDTO } from '@gridpilot/racing/application/dto/LeagueScoringConfigDTO'; import type { League } from '@gridpilot/racing/domain/entities/League'; @@ -25,7 +25,7 @@ export default function LeagueRulebookPage() { async function loadData() { try { const leagueRepo = getLeagueRepository(); - const scoringQuery = getGetLeagueScoringConfigQuery(); + const scoringUseCase = getGetLeagueScoringConfigUseCase(); const leagueData = await leagueRepo.findById(leagueId); if (!leagueData) { @@ -35,8 +35,9 @@ export default function LeagueRulebookPage() { setLeague(leagueData); - const scoring = await scoringQuery.execute({ leagueId }); - setScoringConfig(scoring); + await scoringUseCase.execute({ leagueId }); + const scoringViewModel = scoringUseCase.presenter.getViewModel(); + setScoringConfig(scoringViewModel); } catch (err) { console.error('Failed to load scoring config:', err); } finally { diff --git a/apps/website/app/leagues/[id]/settings/page.tsx b/apps/website/app/leagues/[id]/settings/page.tsx index 8166b9d60..373fb30e6 100644 --- a/apps/website/app/leagues/[id]/settings/page.tsx +++ b/apps/website/app/leagues/[id]/settings/page.tsx @@ -7,11 +7,11 @@ import Button from '@/components/ui/Button'; import { getLeagueRepository, getDriverRepository, - getGetLeagueFullConfigQuery, + getGetLeagueFullConfigUseCase, getLeagueMembershipRepository, getDriverStats, getAllDriverRankings, - getListLeagueScoringPresetsQuery, + getListLeagueScoringPresetsUseCase, getTransferLeagueOwnershipUseCase } from '@/lib/di-container'; import { useEffectiveDriverId } from '@/lib/currentDriver'; @@ -59,8 +59,8 @@ export default function LeagueSettingsPage() { try { const leagueRepo = getLeagueRepository(); const driverRepo = getDriverRepository(); - const query = getGetLeagueFullConfigQuery(); - const presetsQuery = getListLeagueScoringPresetsQuery(); + const useCase = getGetLeagueFullConfigUseCase(); + const presetsUseCase = getListLeagueScoringPresetsUseCase(); const leagueData = await leagueRepo.findById(leagueId); if (!leagueData) { @@ -70,11 +70,13 @@ export default function LeagueSettingsPage() { setLeague(leagueData); - const form = await query.execute({ leagueId }); - setConfigForm(form); + await useCase.execute({ leagueId }); + const configViewModel = useCase.presenter.getViewModel(); + setConfigForm(configViewModel); - const presetsData = await presetsQuery.execute(); - setPresets(presetsData); + await presetsUseCase.execute(); + const presetsViewModel = presetsUseCase.presenter.getViewModel(); + setPresets(presetsViewModel); const entity = await driverRepo.findById(leagueData.ownerId); if (entity) { diff --git a/apps/website/app/leagues/[id]/standings/page.tsx b/apps/website/app/leagues/[id]/standings/page.tsx index 687396e3d..d38162b83 100644 --- a/apps/website/app/leagues/[id]/standings/page.tsx +++ b/apps/website/app/leagues/[id]/standings/page.tsx @@ -10,7 +10,7 @@ import { type LeagueDriverSeasonStatsDTO, } from '@gridpilot/racing'; import { - getGetLeagueDriverSeasonStatsQuery, + getGetLeagueDriverSeasonStatsUseCase, getDriverRepository, getLeagueMembershipRepository } from '@/lib/di-container'; @@ -32,12 +32,13 @@ export default function LeagueStandingsPage() { const loadData = useCallback(async () => { try { - const getLeagueDriverSeasonStatsQuery = getGetLeagueDriverSeasonStatsQuery(); + const getLeagueDriverSeasonStatsUseCase = getGetLeagueDriverSeasonStatsUseCase(); const driverRepo = getDriverRepository(); const membershipRepo = getLeagueMembershipRepository(); - const leagueStandings = await getLeagueDriverSeasonStatsQuery.execute({ leagueId }); - setStandings(leagueStandings); + await getLeagueDriverSeasonStatsUseCase.execute({ leagueId }); + const standingsViewModel = getLeagueDriverSeasonStatsUseCase.presenter.getViewModel(); + setStandings(standingsViewModel); const allDrivers = await driverRepo.findAll(); const driverDtos: DriverDTO[] = allDrivers diff --git a/apps/website/app/leagues/page.tsx b/apps/website/app/leagues/page.tsx index 9ca25aad0..f80746bfc 100644 --- a/apps/website/app/leagues/page.tsx +++ b/apps/website/app/leagues/page.tsx @@ -31,7 +31,7 @@ import Card from '@/components/ui/Card'; import Input from '@/components/ui/Input'; import Heading from '@/components/ui/Heading'; import type { LeagueSummaryDTO } from '@gridpilot/racing/application/dto/LeagueSummaryDTO'; -import { getGetAllLeaguesWithCapacityAndScoringQuery } from '@/lib/di-container'; +import { getGetAllLeaguesWithCapacityAndScoringUseCase } from '@/lib/di-container'; // ============================================================================ // TYPES @@ -389,9 +389,10 @@ export default function LeaguesPage() { const loadLeagues = async () => { try { - const query = getGetAllLeaguesWithCapacityAndScoringQuery(); - const allLeagues = await query.execute(); - setRealLeagues(allLeagues); + const useCase = getGetAllLeaguesWithCapacityAndScoringUseCase(); + await useCase.execute(); + const viewModel = useCase.presenter.getViewModel(); + setRealLeagues(viewModel); } catch (error) { console.error('Failed to load leagues:', error); } finally { diff --git a/apps/website/app/profile/page.tsx b/apps/website/app/profile/page.tsx index 905679368..5d7e17090 100644 --- a/apps/website/app/profile/page.tsx +++ b/apps/website/app/profile/page.tsx @@ -38,11 +38,11 @@ import { getDriverRepository, getDriverStats, getAllDriverRankings, - getGetDriverTeamQuery, + getGetDriverTeamUseCase, getSocialRepository, getImageService, - getGetAllTeamsQuery, - getGetTeamMembersQuery, + getGetAllTeamsUseCase, + getGetTeamMembersUseCase, } from '@/lib/di-container'; import { Driver, EntityMappers, type DriverDTO, type Team } from '@gridpilot/racing'; import CreateDriverForm from '@/components/drivers/CreateDriverForm'; @@ -381,18 +381,23 @@ export default function ProfilePage() { setDriver(driverData); // Load primary team data - const teamQuery = getGetDriverTeamQuery(); - const teamResult = await teamQuery.execute({ driverId: currentDriverId }); - setTeamData(teamResult); + const teamUseCase = getGetDriverTeamUseCase(); + await teamUseCase.execute({ driverId: currentDriverId }); + const teamViewModel = teamUseCase.presenter.getViewModel(); + setTeamData(teamViewModel.result); // Load ALL team memberships - const allTeamsQuery = getGetAllTeamsQuery(); - const allTeams = await allTeamsQuery.execute(); - const membershipsQuery = getGetTeamMembersQuery(); + const allTeamsUseCase = getGetAllTeamsUseCase(); + await allTeamsUseCase.execute(); + const allTeamsViewModel = allTeamsUseCase.presenter.getViewModel(); + const allTeams = allTeamsViewModel.teams; + const membershipsUseCase = getGetTeamMembersUseCase(); const memberships: TeamMembershipInfo[] = []; for (const team of allTeams) { - const members = await membershipsQuery.execute({ teamId: team.id }); + await membershipsUseCase.execute({ teamId: team.id }); + const membersViewModel = membershipsUseCase.presenter.getViewModel(); + const members = membersViewModel.members; const membership = members.find((m) => m.driverId === currentDriverId); if (membership) { memberships.push({ diff --git a/apps/website/app/profile/sponsorship-requests/page.tsx b/apps/website/app/profile/sponsorship-requests/page.tsx index f10996ea7..fe46bbe78 100644 --- a/apps/website/app/profile/sponsorship-requests/page.tsx +++ b/apps/website/app/profile/sponsorship-requests/page.tsx @@ -7,7 +7,7 @@ import Button from '@/components/ui/Button'; import Breadcrumbs from '@/components/layout/Breadcrumbs'; import PendingSponsorshipRequests, { type PendingRequestDTO } from '@/components/sponsors/PendingSponsorshipRequests'; import { - getGetPendingSponsorshipRequestsQuery, + getGetPendingSponsorshipRequestsUseCase, getAcceptSponsorshipRequestUseCase, getRejectSponsorshipRequestUseCase, getDriverRepository, @@ -46,7 +46,7 @@ export default function SponsorshipRequestsPage() { const teamRepo = getTeamRepository(); const leagueMembershipRepo = getLeagueMembershipRepository(); const teamMembershipRepo = getTeamMembershipRepository(); - const query = getGetPendingSponsorshipRequestsQuery(); + const useCase = getGetPendingSponsorshipRequestsUseCase(); const allSections: EntitySection[] = []; diff --git a/apps/website/app/races/[id]/page.tsx b/apps/website/app/races/[id]/page.tsx index de3c91bfe..e18edb076 100644 --- a/apps/website/app/races/[id]/page.tsx +++ b/apps/website/app/races/[id]/page.tsx @@ -17,11 +17,11 @@ import { getRaceRepository, getLeagueRepository, getDriverRepository, - getGetRaceRegistrationsQuery, - getIsDriverRegisteredForRaceQuery, + getGetRaceRegistrationsUseCase, + getIsDriverRegisteredForRaceUseCase, getRegisterForRaceUseCase, getWithdrawFromRaceUseCase, - getGetRaceWithSOFQuery, + getGetRaceWithSOFUseCase, getResultRepository, getImageService, } from '@/lib/di-container'; @@ -80,7 +80,7 @@ export default function RaceDetailPage() { try { const raceRepo = getRaceRepository(); const leagueRepo = getLeagueRepository(); - const raceWithSOFQuery = getGetRaceWithSOFQuery(); + const raceWithSOFUseCase = getGetRaceWithSOFUseCase(); const raceData = await raceRepo.findById(raceId); @@ -92,10 +92,11 @@ export default function RaceDetailPage() { setRace(raceData); - // Load race with SOF from application query - const raceWithSOF = await raceWithSOFQuery.execute({ raceId }); - if (raceWithSOF) { - setRaceSOF(raceWithSOF.strengthOfField); + // Load race with SOF from application use case + await raceWithSOFUseCase.execute({ raceId }); + const raceViewModel = raceWithSOFUseCase.presenter.getViewModel(); + if (raceViewModel) { + setRaceSOF(raceViewModel.strengthOfField); } // Load league data @@ -135,8 +136,10 @@ export default function RaceDetailPage() { try { const driverRepo = getDriverRepository(); - const raceRegistrationsQuery = getGetRaceRegistrationsQuery(); - const registeredDriverIds = await raceRegistrationsQuery.execute({ raceId }); + const raceRegistrationsUseCase = getGetRaceRegistrationsUseCase(); + await raceRegistrationsUseCase.execute({ raceId }); + const registrationsViewModel = raceRegistrationsUseCase.presenter.getViewModel(); + const registeredDriverIds = registrationsViewModel.registeredDriverIds; const drivers = await Promise.all( registeredDriverIds.map((id: string) => driverRepo.findById(id)), @@ -144,12 +147,13 @@ export default function RaceDetailPage() { const validDrivers = drivers.filter((d: Driver | null): d is Driver => d !== null); setEntryList(validDrivers); - const isRegisteredQuery = getIsDriverRegisteredForRaceQuery(); - const userIsRegistered = await isRegisteredQuery.execute({ + const isRegisteredUseCase = getIsDriverRegisteredForRaceUseCase(); + await isRegisteredUseCase.execute({ raceId, driverId: currentDriverId, }); - setIsUserRegistered(userIsRegistered); + const registrationViewModel = isRegisteredUseCase.presenter.getViewModel(); + setIsUserRegistered(registrationViewModel.isRegistered); const membership = getMembership(leagueId, currentDriverId); const isUpcoming = race?.status === 'scheduled'; diff --git a/apps/website/app/races/[id]/results/page.tsx b/apps/website/app/races/[id]/results/page.tsx index 320e4231e..2bd03b3c9 100644 --- a/apps/website/app/races/[id]/results/page.tsx +++ b/apps/website/app/races/[id]/results/page.tsx @@ -18,8 +18,8 @@ import { getResultRepository, getStandingRepository, getDriverRepository, - getGetRaceWithSOFQuery, - getGetRacePenaltiesQuery, + getGetRaceWithSOFUseCase, + getGetRacePenaltiesUseCase, } from '@/lib/di-container'; interface PenaltyData { @@ -52,7 +52,7 @@ export default function RaceResultsPage() { const leagueRepo = getLeagueRepository(); const resultRepo = getResultRepository(); const driverRepo = getDriverRepository(); - const raceWithSOFQuery = getGetRaceWithSOFQuery(); + const raceWithSOFUseCase = getGetRaceWithSOFUseCase(); const raceData = await raceRepo.findById(raceId); @@ -64,10 +64,11 @@ export default function RaceResultsPage() { setRace(raceData); - // Load race with SOF from application query - const raceWithSOF = await raceWithSOFQuery.execute({ raceId }); - if (raceWithSOF) { - setRaceSOF(raceWithSOF.strengthOfField); + // Load race with SOF from application use case + await raceWithSOFUseCase.execute({ raceId }); + const raceViewModel = raceWithSOFUseCase.presenter.getViewModel(); + if (raceViewModel) { + setRaceSOF(raceViewModel.strengthOfField); } // Load league data @@ -89,10 +90,11 @@ export default function RaceResultsPage() { // Load penalties for this race try { - const penaltiesQuery = getGetRacePenaltiesQuery(); - const penaltiesData = await penaltiesQuery.execute(raceId); + const penaltiesUseCase = getGetRacePenaltiesUseCase(); + await penaltiesUseCase.execute(raceId); + const penaltiesViewModel = penaltiesUseCase.presenter.getViewModel(); // Map the DTO to the PenaltyData interface expected by ResultsTable - setPenalties(penaltiesData.map(p => ({ + setPenalties(penaltiesViewModel.map(p => ({ driverId: p.driverId, type: p.type, value: p.value, diff --git a/apps/website/app/races/page.tsx b/apps/website/app/races/page.tsx index ab2a01e16..d7d0a7261 100644 --- a/apps/website/app/races/page.tsx +++ b/apps/website/app/races/page.tsx @@ -7,8 +7,7 @@ import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; import Heading from '@/components/ui/Heading'; import { Race, RaceStatus } from '@gridpilot/racing/domain/entities/Race'; -import { League } from '@gridpilot/racing/domain/entities/League'; -import { getRaceRepository, getLeagueRepository } from '@/lib/di-container'; +import { getGetRacesPageDataUseCase } from '@/lib/di-container'; import { Calendar, Clock, @@ -32,8 +31,13 @@ type TimeFilter = 'all' | 'upcoming' | 'live' | 'past'; export default function RacesPage() { const router = useRouter(); - const [races, setRaces] = useState([]); - const [leagues, setLeagues] = useState>(new Map()); + const [pageData, setPageData] = useState<{ + races: Array<{ race: Race; leagueName: string }>; + stats: { total: number; scheduled: number; running: number; completed: number }; + liveRaces: Array<{ race: Race; leagueName: string }>; + upcomingRaces: Array<{ race: Race; leagueName: string }>; + recentResults: Array<{ race: Race; leagueName: string }>; + } | null>(null); const [loading, setLoading] = useState(true); // Filters @@ -43,19 +47,74 @@ export default function RacesPage() { const loadRaces = async () => { try { - const raceRepo = getRaceRepository(); - const leagueRepo = getLeagueRepository(); - - const [allRaces, allLeagues] = await Promise.all([ - raceRepo.findAll(), - leagueRepo.findAll() - ]); - - setRaces(allRaces.sort((a, b) => a.scheduledAt.getTime() - b.scheduledAt.getTime())); + const useCase = getGetRacesPageDataUseCase(); + await useCase.execute(); + const data = useCase.presenter.getViewModel(); - const leagueMap = new Map(); - allLeagues.forEach(league => leagueMap.set(league.id, league)); - setLeagues(leagueMap); + // Transform ViewModel back to page format + setPageData({ + races: data.races.map(r => ({ + race: { + id: r.id, + track: r.track, + car: r.car, + scheduledAt: new Date(r.scheduledAt), + status: r.status, + leagueId: r.leagueId, + strengthOfField: r.strengthOfField, + isUpcoming: () => r.isUpcoming, + isLive: () => r.isLive, + isPast: () => r.isPast, + } as Race, + leagueName: r.leagueName, + })), + stats: data.stats, + liveRaces: data.liveRaces.map(r => ({ + race: { + id: r.id, + track: r.track, + car: r.car, + scheduledAt: new Date(r.scheduledAt), + status: r.status, + leagueId: r.leagueId, + strengthOfField: r.strengthOfField, + isUpcoming: () => r.isUpcoming, + isLive: () => r.isLive, + isPast: () => r.isPast, + } as Race, + leagueName: r.leagueName, + })), + upcomingRaces: data.upcomingThisWeek.map(r => ({ + race: { + id: r.id, + track: r.track, + car: r.car, + scheduledAt: new Date(r.scheduledAt), + status: r.status, + leagueId: r.leagueId, + strengthOfField: r.strengthOfField, + isUpcoming: () => r.isUpcoming, + isLive: () => r.isLive, + isPast: () => r.isPast, + } as Race, + leagueName: r.leagueName, + })), + recentResults: data.recentResults.map(r => ({ + race: { + id: r.id, + track: r.track, + car: r.car, + scheduledAt: new Date(r.scheduledAt), + status: r.status, + leagueId: r.leagueId, + strengthOfField: r.strengthOfField, + isUpcoming: () => r.isUpcoming, + isLive: () => r.isLive, + isPast: () => r.isPast, + } as Race, + leagueName: r.leagueName, + })), + }); } catch (err) { console.error('Failed to load races:', err); } finally { @@ -69,7 +128,9 @@ export default function RacesPage() { // Filter races const filteredRaces = useMemo(() => { - return races.filter(race => { + if (!pageData) return []; + + return pageData.races.filter(({ race }) => { // Status filter if (statusFilter !== 'all' && race.status !== statusFilter) { return false; @@ -93,53 +154,25 @@ export default function RacesPage() { return true; }); - }, [races, statusFilter, leagueFilter, timeFilter]); + }, [pageData, statusFilter, leagueFilter, timeFilter]); // Group races by date for calendar view const racesByDate = useMemo(() => { - const grouped = new Map(); - filteredRaces.forEach(race => { - const dateKey = race.scheduledAt.toISOString().split('T')[0]; + const grouped = new Map>(); + filteredRaces.forEach(item => { + const dateKey = item.race.scheduledAt.toISOString().split('T')[0]; if (!grouped.has(dateKey)) { grouped.set(dateKey, []); } - grouped.get(dateKey)!.push(race); + grouped.get(dateKey)!.push(item); }); return grouped; }, [filteredRaces]); - // Get upcoming races (next 7 days) - const upcomingRaces = useMemo(() => { - const now = new Date(); - const nextWeek = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); - return races.filter(race => - race.isUpcoming() && - race.scheduledAt >= now && - race.scheduledAt <= nextWeek - ).slice(0, 5); - }, [races]); - - // Get live races - const liveRaces = useMemo(() => { - return races.filter(race => race.isLive()); - }, [races]); - - // Get recent results - const recentResults = useMemo(() => { - return races - .filter(race => race.status === 'completed') - .sort((a, b) => b.scheduledAt.getTime() - a.scheduledAt.getTime()) - .slice(0, 3); - }, [races]); - - // Stats - const stats = useMemo(() => { - const total = races.length; - const scheduled = races.filter(r => r.status === 'scheduled').length; - const running = races.filter(r => r.status === 'running').length; - const completed = races.filter(r => r.status === 'completed').length; - return { total, scheduled, running, completed }; - }, [races]); + const upcomingRaces = pageData?.upcomingRaces ?? []; + const liveRaces = pageData?.liveRaces ?? []; + 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', { @@ -298,8 +331,8 @@ export default function RacesPage() {
- {liveRaces.map(race => ( -
( +
router.push(`/races/${race.id}`)} className="flex items-center justify-between p-4 bg-deep-graphite/80 rounded-lg border border-performance-green/20 cursor-pointer hover:border-performance-green/40 transition-all" @@ -310,7 +343,7 @@ export default function RacesPage() {

{race.track}

-

{leagues.get(race.leagueId)?.name}

+

{leagueName}

@@ -352,11 +385,14 @@ export default function RacesPage() { className="px-4 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-blue" > - {Array.from(leagues.values()).map(league => ( - - ))} + {pageData && [...new Set(pageData.races.map(r => r.race.leagueId))].map(leagueId => { + const item = pageData.races.find(r => r.race.leagueId === leagueId); + return item ? ( + + ) : null; + })}
@@ -371,7 +407,7 @@ export default function RacesPage() {

No races found

- {races.length === 0 + {pageData?.races.length === 0 ? 'No races have been scheduled yet' : 'Try adjusting your filters'}

@@ -397,10 +433,9 @@ export default function RacesPage() { {/* Races for this date */}
- {dayRaces.map(race => { + {dayRaces.map(({ race, leagueName }) => { const config = statusConfig[race.status]; const StatusIcon = config.icon; - const league = leagues.get(race.leagueId); return (
{/* League Link */} - {league && ( -
- e.stopPropagation()} - className="inline-flex items-center gap-2 text-sm text-primary-blue hover:underline" - > - - {league.name} - - -
- )} +
+ e.stopPropagation()} + className="inline-flex items-center gap-2 text-sm text-primary-blue hover:underline" + > + + {leagueName} + + +
{/* Arrow */} @@ -515,8 +548,8 @@ export default function RacesPage() {

) : (
- {upcomingRaces.map((race, index) => ( -
( +
router.push(`/races/${race.id}`)} className="flex items-center gap-3 p-2 rounded-lg hover:bg-deep-graphite cursor-pointer transition-colors" @@ -552,8 +585,8 @@ export default function RacesPage() {

) : (
- {recentResults.map(race => ( -
( +
router.push(`/races/${race.id}/results`)} className="flex items-center gap-3 p-2 rounded-lg hover:bg-deep-graphite cursor-pointer transition-colors" diff --git a/apps/website/app/sponsor/campaigns/page.tsx b/apps/website/app/sponsor/campaigns/page.tsx index d1b055f06..976902dac 100644 --- a/apps/website/app/sponsor/campaigns/page.tsx +++ b/apps/website/app/sponsor/campaigns/page.tsx @@ -32,57 +32,73 @@ interface Sponsorship { drivers: number; } -// Mock data - in production would come from repository -const MOCK_SPONSORSHIPS: Sponsorship[] = [ - { - id: 'sp-1', - leagueId: 'league-1', - leagueName: 'GT3 Pro Championship', - tier: 'main', - status: 'active', - startDate: new Date('2025-01-01'), - endDate: new Date('2025-06-30'), - price: 1200, - impressions: 45200, - drivers: 32, - }, - { - id: 'sp-2', - leagueId: 'league-2', - leagueName: 'Endurance Masters', - tier: 'main', - status: 'active', - startDate: new Date('2025-02-01'), - endDate: new Date('2025-07-31'), - price: 1000, - impressions: 38100, - drivers: 48, - }, - { - id: 'sp-3', - leagueId: 'league-3', - leagueName: 'Formula Sim Series', - tier: 'secondary', - status: 'active', - startDate: new Date('2025-03-01'), - endDate: new Date('2025-08-31'), - price: 400, - impressions: 22800, - drivers: 24, - }, - { - id: 'sp-4', - leagueId: 'league-4', - leagueName: 'Touring Car Cup', - tier: 'secondary', - status: 'pending', - startDate: new Date('2025-04-01'), - endDate: new Date('2025-09-30'), - price: 350, - impressions: 0, - drivers: 28, - }, -]; +interface SponsorshipDetailApi { + id: string; + leagueId: string; + leagueName: string; + seasonId: string; + seasonName: string; + seasonStartDate?: string; + seasonEndDate?: string; + tier: 'main' | 'secondary'; + status: string; + pricing: { + amount: number; + currency: string; + }; + metrics: { + drivers: number; + races: number; + completedRaces: number; + impressions: number; + }; + createdAt: string; + activatedAt?: string; +} + +interface SponsorSponsorshipsResponse { + sponsorId: string; + sponsorName: string; + sponsorships: SponsorshipDetailApi[]; + summary: { + totalSponsorships: number; + activeSponsorships: number; + totalInvestment: number; + totalPlatformFees: number; + currency: string; + }; +} + +function mapSponsorshipStatus(status: string): 'active' | 'pending' | 'expired' { + switch (status) { + case 'active': + return 'active'; + case 'pending': + return 'pending'; + default: + return 'expired'; + } +} + +function mapApiToSponsorships(response: SponsorSponsorshipsResponse): Sponsorship[] { + return response.sponsorships.map((s) => { + const start = s.seasonStartDate ? new Date(s.seasonStartDate) : new Date(s.createdAt); + const end = s.seasonEndDate ? new Date(s.seasonEndDate) : start; + + return { + id: s.id, + leagueId: s.leagueId, + leagueName: s.leagueName, + tier: s.tier, + status: mapSponsorshipStatus(s.status), + startDate: start, + endDate: end, + price: s.pricing.amount, + impressions: s.metrics.impressions, + drivers: s.metrics.drivers, + }; + }); +} function SponsorshipCard({ sponsorship }: { sponsorship: Sponsorship }) { const router = useRouter(); @@ -179,18 +195,59 @@ function SponsorshipCard({ sponsorship }: { sponsorship: Sponsorship }) { export default function SponsorCampaignsPage() { const router = useRouter(); const [filter, setFilter] = useState<'all' | 'active' | 'pending' | 'expired'>('all'); - - const filteredSponsorships = filter === 'all' - ? MOCK_SPONSORSHIPS - : MOCK_SPONSORSHIPS.filter(s => s.status === filter); + const [sponsorships, setSponsorships] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let isMounted = true; + + async function fetchSponsorships() { + try { + const response = await fetch('/api/sponsors/sponsorships'); + if (!response.ok) { + if (!isMounted) return; + setSponsorships([]); + return; + } + const json: SponsorSponsorshipsResponse = await response.json(); + if (!isMounted) return; + setSponsorships(mapApiToSponsorships(json)); + } catch { + if (!isMounted) return; + setSponsorships([]); + } finally { + if (isMounted) { + setLoading(false); + } + } + } + + fetchSponsorships(); + + return () => { + isMounted = false; + }; + }, []); + + const filteredSponsorships = filter === 'all' + ? sponsorships + : sponsorships.filter(s => s.status === filter); const stats = { - total: MOCK_SPONSORSHIPS.length, - active: MOCK_SPONSORSHIPS.filter(s => s.status === 'active').length, - pending: MOCK_SPONSORSHIPS.filter(s => s.status === 'pending').length, - totalInvestment: MOCK_SPONSORSHIPS.reduce((sum, s) => sum + s.price, 0), + total: sponsorships.length, + active: sponsorships.filter(s => s.status === 'active').length, + pending: sponsorships.filter(s => s.status === 'pending').length, + totalInvestment: sponsorships.reduce((sum, s) => sum + s.price, 0), }; + if (loading) { + return ( +
+

Loading sponsorships…

+
+ ); + } + return (
{/* Header */} diff --git a/apps/website/app/sponsor/dashboard/page.tsx b/apps/website/app/sponsor/dashboard/page.tsx index 24867668f..a786ef671 100644 --- a/apps/website/app/sponsor/dashboard/page.tsx +++ b/apps/website/app/sponsor/dashboard/page.tsx @@ -52,64 +52,6 @@ interface SponsorDashboardData { }; } -// Fallback mock data for demo mode -const MOCK_DASHBOARD: SponsorDashboardData = { - sponsorId: 'demo-sponsor', - sponsorName: 'Demo Sponsor', - metrics: { - impressions: 124500, - impressionsChange: 12.5, - uniqueViewers: 8420, - viewersChange: 8.3, - races: 24, - drivers: 156, - exposure: 87.5, - exposureChange: 5.2, - }, - sponsoredLeagues: [ - { - id: 'league-1', - name: 'GT3 Pro Championship', - tier: 'main', - drivers: 32, - races: 12, - impressions: 45200, - status: 'active', - }, - { - id: 'league-2', - name: 'Endurance Masters', - tier: 'main', - drivers: 48, - races: 6, - impressions: 38100, - status: 'active', - }, - { - id: 'league-3', - name: 'Formula Sim Series', - tier: 'secondary', - drivers: 24, - races: 8, - impressions: 22800, - status: 'active', - }, - { - id: 'league-4', - name: 'Touring Car Cup', - tier: 'secondary', - drivers: 28, - races: 10, - impressions: 18400, - status: 'upcoming', - }, - ], - investment: { - activeSponsorships: 4, - totalInvestment: 2400, - costPerThousandViews: 19.28, - }, -}; function MetricCard({ title, @@ -205,15 +147,13 @@ export default function SponsorDashboardPage() { try { const response = await fetch('/api/sponsors/dashboard'); if (response.ok) { - const dashboardData = await response.json(); + const dashboardData: SponsorDashboardData = await response.json(); setData(dashboardData); } else { - // Use mock data for demo mode - setData(MOCK_DASHBOARD); + setError('Failed to load sponsor dashboard'); } } catch { - // Use mock data on error - setData(MOCK_DASHBOARD); + setError('Failed to load sponsor dashboard'); } finally { setLoading(false); } @@ -230,7 +170,19 @@ export default function SponsorDashboardPage() { ); } - const dashboardData = data || MOCK_DASHBOARD; + if (!data) { + return ( +
+
+

+ {error ?? 'No sponsor dashboard data available yet.'} +

+
+
+ ); + } + + const dashboardData = data; return (
diff --git a/apps/website/app/teams/[id]/page.tsx b/apps/website/app/teams/[id]/page.tsx index 1f9c211ea..26a5da4a4 100644 --- a/apps/website/app/teams/[id]/page.tsx +++ b/apps/website/app/teams/[id]/page.tsx @@ -14,8 +14,8 @@ import TeamStandings from '@/components/teams/TeamStandings'; import TeamAdmin from '@/components/teams/TeamAdmin'; import JoinTeamButton from '@/components/teams/JoinTeamButton'; import { - getGetTeamDetailsQuery, - getGetTeamMembersQuery, + getGetTeamDetailsUseCase, + getGetTeamMembersUseCase, getTeamMembershipRepository, } from '@/lib/di-container'; import { useEffectiveDriverId } from '@/lib/currentDriver'; @@ -39,11 +39,15 @@ export default function TeamDetailPage() { const loadTeamData = useCallback(async () => { setLoading(true); try { - const detailsQuery = getGetTeamDetailsQuery(); - const membersQuery = getGetTeamMembersQuery(); + const detailsUseCase = getGetTeamDetailsUseCase(); + const membersUseCase = getGetTeamMembersUseCase(); - const details = await detailsQuery.execute({ teamId, driverId: currentDriverId }); - const teamMemberships = await membersQuery.execute({ teamId }); + await detailsUseCase.execute({ teamId, driverId: currentDriverId }); + const detailsViewModel = detailsUseCase.presenter.getViewModel(); + + await membersUseCase.execute({ teamId }); + const membersViewModel = membersUseCase.presenter.getViewModel(); + const teamMemberships = membersViewModel.members; const adminStatus = teamMemberships.some( @@ -52,7 +56,7 @@ export default function TeamDetailPage() { (m.role === 'owner' || m.role === 'manager'), ) ?? false; - setTeam(details.team); + setTeam(detailsViewModel.team); setMemberships(teamMemberships); setIsAdmin(adminStatus); } finally { diff --git a/apps/website/app/teams/leaderboard/page.tsx b/apps/website/app/teams/leaderboard/page.tsx index f52fa844b..68d424524 100644 --- a/apps/website/app/teams/leaderboard/page.tsx +++ b/apps/website/app/teams/leaderboard/page.tsx @@ -22,7 +22,7 @@ import { import Button from '@/components/ui/Button'; import Input from '@/components/ui/Input'; import Heading from '@/components/ui/Heading'; -import { getGetAllTeamsQuery, getGetTeamMembersQuery, getDriverStats } from '@/lib/di-container'; +import { getGetAllTeamsUseCase, getGetTeamMembersUseCase, getDriverStats } from '@/lib/di-container'; import type { Team } from '@gridpilot/racing'; // ============================================================================ @@ -260,15 +260,19 @@ export default function TeamLeaderboardPage() { const loadTeams = async () => { try { - const allTeamsQuery = getGetAllTeamsQuery(); - const teamMembersQuery = getGetTeamMembersQuery(); + const allTeamsUseCase = getGetAllTeamsUseCase(); + const teamMembersUseCase = getGetTeamMembersUseCase(); - const allTeams = await allTeamsQuery.execute(); + await allTeamsUseCase.execute(); + const allTeamsViewModel = allTeamsUseCase.presenter.getViewModel(); + const allTeams = allTeamsViewModel.teams; const teamData: TeamDisplayData[] = []; await Promise.all( allTeams.map(async (team: Team) => { - const memberships = await teamMembersQuery.execute({ teamId: team.id }); + await teamMembersUseCase.execute({ teamId: team.id }); + const membershipsViewModel = teamMembersUseCase.presenter.getViewModel(); + const memberships = membershipsViewModel.members; const memberCount = memberships.length; let ratingSum = 0; diff --git a/apps/website/app/teams/page.tsx b/apps/website/app/teams/page.tsx index 1e81526cd..eef4b160e 100644 --- a/apps/website/app/teams/page.tsx +++ b/apps/website/app/teams/page.tsx @@ -28,30 +28,14 @@ import Card from '@/components/ui/Card'; import Input from '@/components/ui/Input'; import Heading from '@/components/ui/Heading'; import CreateTeamForm from '@/components/teams/CreateTeamForm'; -import { getGetAllTeamsQuery, getGetTeamMembersQuery, getDriverStats } from '@/lib/di-container'; -import type { Team } from '@gridpilot/racing'; +import { getGetTeamsLeaderboardUseCase } from '@/lib/di-container'; +import type { TeamLeaderboardItemViewModel, SkillLevel } from '@gridpilot/racing/application/presenters/ITeamsLeaderboardPresenter'; // ============================================================================ // TYPES // ============================================================================ -type SkillLevel = 'beginner' | 'intermediate' | 'advanced' | 'pro'; - -interface TeamDisplayData { - id: string; - name: string; - memberCount: number; - rating: number | null; - totalWins: number; - totalRaces: number; - performanceLevel: SkillLevel; - isRecruiting: boolean; - createdAt: Date; - description?: string; - specialization?: 'endurance' | 'sprint' | 'mixed'; - region?: string; - languages?: string[]; -} +type TeamDisplayData = TeamLeaderboardItemViewModel; // ============================================================================ // SKILL LEVEL CONFIG @@ -463,59 +447,10 @@ export default function TeamsPage() { const loadTeams = async () => { try { - const allTeamsQuery = getGetAllTeamsQuery(); - const teamMembersQuery = getGetTeamMembersQuery(); - - const allTeams = await allTeamsQuery.execute(); - const teamData: TeamDisplayData[] = []; - - await Promise.all( - allTeams.map(async (team: Team) => { - const memberships = await teamMembersQuery.execute({ teamId: team.id }); - const memberCount = memberships.length; - - let ratingSum = 0; - let ratingCount = 0; - let totalWins = 0; - let totalRaces = 0; - - for (const membership of memberships) { - const stats = getDriverStats(membership.driverId); - if (!stats) continue; - - if (typeof stats.rating === 'number') { - ratingSum += stats.rating; - ratingCount += 1; - } - - totalWins += stats.wins ?? 0; - totalRaces += stats.totalRaces ?? 0; - } - - const averageRating = ratingCount > 0 ? ratingSum / ratingCount : null; - - let performanceLevel: TeamDisplayData['performanceLevel'] = 'beginner'; - if (averageRating !== null) { - if (averageRating >= 4500) performanceLevel = 'pro'; - else if (averageRating >= 3000) performanceLevel = 'advanced'; - else if (averageRating >= 2000) performanceLevel = 'intermediate'; - } - - teamData.push({ - id: team.id, - name: team.name, - memberCount, - rating: averageRating, - totalWins, - totalRaces, - performanceLevel, - isRecruiting: true, - createdAt: new Date(), - }); - }), - ); - - setRealTeams(teamData); + const useCase = getGetTeamsLeaderboardUseCase(); + await useCase.execute(); + const viewModel = useCase.presenter.getViewModel(); + setRealTeams(viewModel.teams); } catch (error) { console.error('Failed to load teams:', error); } finally { diff --git a/apps/website/components/drivers/DriverProfile.tsx b/apps/website/components/drivers/DriverProfile.tsx index 22befaca5..d28d2768d 100644 --- a/apps/website/components/drivers/DriverProfile.tsx +++ b/apps/website/components/drivers/DriverProfile.tsx @@ -8,7 +8,7 @@ import CareerHighlights from './CareerHighlights'; import DriverRankings from './DriverRankings'; import PerformanceMetrics from './PerformanceMetrics'; import { useEffect, useState } from 'react'; -import { getDriverStats, getLeagueRankings, getGetDriverTeamQuery, getAllDriverRankings } from '@/lib/di-container'; +import { getDriverStats, getLeagueRankings, getGetDriverTeamUseCase, getAllDriverRankings } from '@/lib/di-container'; import { getPrimaryLeagueIdForDriver } from '@/lib/leagueMembership'; import type { GetDriverTeamQueryResultDTO } from '@gridpilot/racing/application/dto/TeamCommandAndQueryDTO'; @@ -29,9 +29,10 @@ export default function DriverProfile({ driver, isOwnProfile = false, onEditClic useEffect(() => { const load = async () => { - const query = getGetDriverTeamQuery(); - const result = await query.execute({ driverId: driver.id }); - setTeamData(result); + const useCase = getGetDriverTeamUseCase(); + await useCase.execute({ driverId: driver.id }); + const viewModel = useCase.presenter.getViewModel(); + setTeamData(viewModel.result); }; void load(); }, [driver.id]); diff --git a/apps/website/components/leagues/LeagueSponsorshipsSection.tsx b/apps/website/components/leagues/LeagueSponsorshipsSection.tsx index e3f464bc1..201b67984 100644 --- a/apps/website/components/leagues/LeagueSponsorshipsSection.tsx +++ b/apps/website/components/leagues/LeagueSponsorshipsSection.tsx @@ -6,7 +6,7 @@ import Input from '../ui/Input'; import { DollarSign, Star, Award, Plus, X, Bell } from 'lucide-react'; import PendingSponsorshipRequests, { type PendingRequestDTO } from '../sponsors/PendingSponsorshipRequests'; import { - getGetPendingSponsorshipRequestsQuery, + getGetPendingSponsorshipRequestsUseCase, getAcceptSponsorshipRequestUseCase, getRejectSponsorshipRequestUseCase, getSeasonRepository, @@ -71,8 +71,8 @@ export function LeagueSponsorshipsSection({ setRequestsLoading(true); try { - const query = getGetPendingSponsorshipRequestsQuery(); - const result = await query.execute({ + const useCase = getGetPendingSponsorshipRequestsUseCase(); + await useCase.execute({ entityType: 'season', entityId: seasonId, }); diff --git a/apps/website/components/teams/JoinTeamButton.tsx b/apps/website/components/teams/JoinTeamButton.tsx index c0872028e..2cd8d15bd 100644 --- a/apps/website/components/teams/JoinTeamButton.tsx +++ b/apps/website/components/teams/JoinTeamButton.tsx @@ -5,7 +5,7 @@ import Button from '@/components/ui/Button'; import { getJoinTeamUseCase, getLeaveTeamUseCase, - getGetDriverTeamQuery, + getGetDriverTeamUseCase, getTeamMembershipRepository, } from '@/lib/di-container'; import { useEffectiveDriverId } from '@/lib/currentDriver'; @@ -34,11 +34,12 @@ export default function JoinTeamButton({ const m = await membershipRepo.getMembership(teamId, currentDriverId); setMembership(m); - const driverTeamQuery = getGetDriverTeamQuery(); - const driverTeam = await driverTeamQuery.execute({ driverId: currentDriverId }); - if (driverTeam) { - setCurrentTeamId(driverTeam.team.id); - setCurrentTeamName(driverTeam.team.name); + const driverTeamUseCase = getGetDriverTeamUseCase(); + await driverTeamUseCase.execute({ driverId: currentDriverId }); + const viewModel = driverTeamUseCase.presenter.getViewModel(); + if (viewModel.result) { + setCurrentTeamId(viewModel.result.team.id); + setCurrentTeamName(viewModel.result.team.name); } else { setCurrentTeamId(null); setCurrentTeamName(null); diff --git a/apps/website/components/teams/TeamAdmin.tsx b/apps/website/components/teams/TeamAdmin.tsx index 033216f2e..8bd559f97 100644 --- a/apps/website/components/teams/TeamAdmin.tsx +++ b/apps/website/components/teams/TeamAdmin.tsx @@ -6,7 +6,7 @@ import Button from '@/components/ui/Button'; import Input from '@/components/ui/Input'; import { getDriverRepository, - getGetTeamJoinRequestsQuery, + getGetTeamJoinRequestsUseCase, getApproveTeamJoinRequestUseCase, getRejectTeamJoinRequestUseCase, getUpdateTeamUseCase, @@ -36,15 +36,16 @@ export default function TeamAdmin({ team, onUpdate }: TeamAdminProps) { }, [team.id]); const loadJoinRequests = async () => { - const query = getGetTeamJoinRequestsQuery(); - const requests = await query.execute({ teamId: team.id }); - setJoinRequests(requests); + const useCase = getGetTeamJoinRequestsUseCase(); + await useCase.execute({ teamId: team.id }); + const viewModel = useCase.presenter.getViewModel(); + setJoinRequests(viewModel.requests); const driverRepo = getDriverRepository(); const allDrivers = await driverRepo.findAll(); const driverMap: Record = {}; - for (const request of requests) { + for (const request of viewModel.requests) { const driver = allDrivers.find(d => d.id === request.driverId); if (driver) { const dto = EntityMappers.toDriverDTO(driver); diff --git a/apps/website/lib/di-config.ts b/apps/website/lib/di-config.ts index d9ff0aa02..62afb9a8d 100644 --- a/apps/website/lib/di-config.ts +++ b/apps/website/lib/di-config.ts @@ -88,48 +88,77 @@ import { JoinLeagueUseCase, RegisterForRaceUseCase, WithdrawFromRaceUseCase, - IsDriverRegisteredForRaceQuery, - GetRaceRegistrationsQuery, CreateTeamUseCase, JoinTeamUseCase, LeaveTeamUseCase, ApproveTeamJoinRequestUseCase, RejectTeamJoinRequestUseCase, UpdateTeamUseCase, - GetAllTeamsQuery, - GetTeamDetailsQuery, - GetTeamMembersQuery, - GetTeamJoinRequestsQuery, - GetDriverTeamQuery, - GetLeagueStandingsQuery, - GetLeagueDriverSeasonStatsQuery, - GetAllLeaguesWithCapacityQuery, - GetAllLeaguesWithCapacityAndScoringQuery, - ListLeagueScoringPresetsQuery, - GetLeagueScoringConfigQuery, + GetAllTeamsUseCase, + GetTeamDetailsUseCase, + GetTeamMembersUseCase, + GetTeamJoinRequestsUseCase, + GetDriverTeamUseCase, CreateLeagueWithSeasonAndScoringUseCase, - GetLeagueFullConfigQuery, - GetRaceWithSOFQuery, - GetLeagueStatsQuery, FileProtestUseCase, ReviewProtestUseCase, ApplyPenaltyUseCase, - GetRaceProtestsQuery, - GetRacePenaltiesQuery, RequestProtestDefenseUseCase, SubmitProtestDefenseUseCase, - GetSponsorDashboardQuery, - GetSponsorSponsorshipsQuery, - GetPendingSponsorshipRequestsQuery, - GetEntitySponsorshipPricingQuery, + GetSponsorDashboardUseCase, + GetSponsorSponsorshipsUseCase, + GetPendingSponsorshipRequestsUseCase, + GetEntitySponsorshipPricingUseCase, ApplyForSponsorshipUseCase, AcceptSponsorshipRequestUseCase, RejectSponsorshipRequestUseCase, } from '@gridpilot/racing/application'; +import { IsDriverRegisteredForRaceUseCase } from '@gridpilot/racing/application/use-cases/IsDriverRegisteredForRaceQuery'; +import { GetRaceRegistrationsUseCase } from '@gridpilot/racing/application/use-cases/GetRaceRegistrationsQuery'; +import { GetRaceWithSOFUseCase } from '@gridpilot/racing/application/use-cases/GetRaceWithSOFQuery'; +import { GetRaceProtestsUseCase } from '@gridpilot/racing/application/use-cases/GetRaceProtestsQuery'; +import { GetRacePenaltiesUseCase } from '@gridpilot/racing/application/use-cases/GetRacePenaltiesQuery'; +import { GetLeagueStandingsUseCase } from '@gridpilot/racing/application/use-cases/GetLeagueStandingsQuery'; +import { GetLeagueDriverSeasonStatsUseCase } from '@gridpilot/racing/application/use-cases/GetLeagueDriverSeasonStatsQuery'; +import { GetAllLeaguesWithCapacityUseCase } from '@gridpilot/racing/application/use-cases/GetAllLeaguesWithCapacityQuery'; +import { GetAllLeaguesWithCapacityAndScoringUseCase } from '@gridpilot/racing/application/use-cases/GetAllLeaguesWithCapacityAndScoringQuery'; +import { ListLeagueScoringPresetsUseCase } from '@gridpilot/racing/application/use-cases/ListLeagueScoringPresetsQuery'; +import { GetLeagueScoringConfigUseCase } from '@gridpilot/racing/application/use-cases/GetLeagueScoringConfigQuery'; +import { GetLeagueFullConfigUseCase } from '@gridpilot/racing/application/use-cases/GetLeagueFullConfigQuery'; +import { GetLeagueStatsUseCase } from '@gridpilot/racing/application/use-cases/GetLeagueStatsQuery'; +import { GetRacesPageDataUseCase } from '@gridpilot/racing/application/use-cases/GetRacesPageDataUseCase'; +import { GetDriversLeaderboardUseCase } from '@gridpilot/racing/application/use-cases/GetDriversLeaderboardUseCase'; +import { GetTeamsLeaderboardUseCase } from '@gridpilot/racing/application/use-cases/GetTeamsLeaderboardUseCase'; import { TransferLeagueOwnershipUseCase } from '@gridpilot/racing/application/use-cases/TransferLeagueOwnershipUseCase'; +import { DriversLeaderboardPresenter } from '../../lib/presenters/DriversLeaderboardPresenter'; +import { TeamsLeaderboardPresenter } from '../../lib/presenters/TeamsLeaderboardPresenter'; +import { RacesPagePresenter } from '../../lib/presenters/RacesPagePresenter'; +import { AllTeamsPresenter } from '../../lib/presenters/AllTeamsPresenter'; +import { TeamDetailsPresenter } from '../../lib/presenters/TeamDetailsPresenter'; +import { TeamMembersPresenter } from '../../lib/presenters/TeamMembersPresenter'; +import { TeamJoinRequestsPresenter } from '../../lib/presenters/TeamJoinRequestsPresenter'; +import { DriverTeamPresenter } from '../../lib/presenters/DriverTeamPresenter'; +import { AllLeaguesWithCapacityPresenter } from '../../lib/presenters/AllLeaguesWithCapacityPresenter'; +import { AllLeaguesWithCapacityAndScoringPresenter } from '../../lib/presenters/AllLeaguesWithCapacityAndScoringPresenter'; +import { LeagueStatsPresenter } from '../../lib/presenters/LeagueStatsPresenter'; +import { LeagueScoringConfigPresenter } from '../../lib/presenters/LeagueScoringConfigPresenter'; +import { LeagueFullConfigPresenter } from '../../lib/presenters/LeagueFullConfigPresenter'; +import { LeagueDriverSeasonStatsPresenter } from '../../lib/presenters/LeagueDriverSeasonStatsPresenter'; +import { LeagueStandingsPresenter } from '../../lib/presenters/LeagueStandingsPresenter'; +import { LeagueScoringPresetsPresenter } from '../../lib/presenters/LeagueScoringPresetsPresenter'; +import { RaceWithSOFPresenter } from '../../lib/presenters/RaceWithSOFPresenter'; +import { RaceProtestsPresenter } from '../../lib/presenters/RaceProtestsPresenter'; +import { RacePenaltiesPresenter } from '../../lib/presenters/RacePenaltiesPresenter'; +import { RaceRegistrationsPresenter } from '../../lib/presenters/RaceRegistrationsPresenter'; +import { DriverRegistrationStatusPresenter } from '../../lib/presenters/DriverRegistrationStatusPresenter'; import type { DriverRatingProvider } from '@gridpilot/racing/application'; import type { LeagueScoringPresetProvider } from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider'; -import { PreviewLeagueScheduleQuery } from '@gridpilot/racing/application'; +import { PreviewLeagueScheduleUseCase } from '@gridpilot/racing/application'; +import { SponsorDashboardPresenter } from '../../lib/presenters/SponsorDashboardPresenter'; +import { SponsorSponsorshipsPresenter } from '../../lib/presenters/SponsorSponsorshipsPresenter'; +import { PendingSponsorshipRequestsPresenter } from '../../lib/presenters/PendingSponsorshipRequestsPresenter'; +import { EntitySponsorshipPricingPresenter } from '../../lib/presenters/EntitySponsorshipPricingPresenter'; +import { LeagueSchedulePreviewPresenter } from '../../lib/presenters/LeagueSchedulePreviewPresenter'; // Testing support import { @@ -840,24 +869,28 @@ export function configureDIContainer(): void { ); // Register queries - Racing + const driverRegistrationStatusPresenter = new DriverRegistrationStatusPresenter(); container.registerInstance( - DI_TOKENS.IsDriverRegisteredForRaceQuery, - new IsDriverRegisteredForRaceQuery(raceRegistrationRepository) + DI_TOKENS.IsDriverRegisteredForRaceUseCase, + new IsDriverRegisteredForRaceUseCase(raceRegistrationRepository, driverRegistrationStatusPresenter) ); + const raceRegistrationsPresenter = new RaceRegistrationsPresenter(); container.registerInstance( - DI_TOKENS.GetRaceRegistrationsQuery, - new GetRaceRegistrationsQuery(raceRegistrationRepository) + DI_TOKENS.GetRaceRegistrationsUseCase, + new GetRaceRegistrationsUseCase(raceRegistrationRepository, raceRegistrationsPresenter) ); + const leagueStandingsPresenter = new LeagueStandingsPresenter(); container.registerInstance( - DI_TOKENS.GetLeagueStandingsQuery, - new GetLeagueStandingsQuery(standingRepository) + DI_TOKENS.GetLeagueStandingsUseCase, + new GetLeagueStandingsUseCase(standingRepository, leagueStandingsPresenter) ); + const leagueDriverSeasonStatsPresenter = new LeagueDriverSeasonStatsPresenter(); container.registerInstance( - DI_TOKENS.GetLeagueDriverSeasonStatsQuery, - new GetLeagueDriverSeasonStatsQuery( + DI_TOKENS.GetLeagueDriverSeasonStatsUseCase, + new GetLeagueDriverSeasonStatsUseCase( standingRepository, resultRepository, penaltyRepository, @@ -875,113 +908,200 @@ export function configureDIContainer(): void { ratingChange: delta !== 0 ? delta : null, }; }, - } + }, + leagueDriverSeasonStatsPresenter ) ); + const allLeaguesWithCapacityPresenter = new AllLeaguesWithCapacityPresenter(); container.registerInstance( - DI_TOKENS.GetAllLeaguesWithCapacityQuery, - new GetAllLeaguesWithCapacityQuery(leagueRepository, leagueMembershipRepository) + DI_TOKENS.GetAllLeaguesWithCapacityUseCase, + new GetAllLeaguesWithCapacityUseCase( + leagueRepository, + leagueMembershipRepository, + allLeaguesWithCapacityPresenter + ) ); + const allLeaguesWithCapacityAndScoringPresenter = new AllLeaguesWithCapacityAndScoringPresenter(); container.registerInstance( - DI_TOKENS.GetAllLeaguesWithCapacityAndScoringQuery, - new GetAllLeaguesWithCapacityAndScoringQuery( + DI_TOKENS.GetAllLeaguesWithCapacityAndScoringUseCase, + new GetAllLeaguesWithCapacityAndScoringUseCase( leagueRepository, leagueMembershipRepository, seasonRepository, leagueScoringConfigRepository, gameRepository, - leagueScoringPresetProvider + leagueScoringPresetProvider, + allLeaguesWithCapacityAndScoringPresenter ) ); + const leagueScoringPresetsPresenter = new LeagueScoringPresetsPresenter(); container.registerInstance( - DI_TOKENS.ListLeagueScoringPresetsQuery, - new ListLeagueScoringPresetsQuery(leagueScoringPresetProvider) + DI_TOKENS.ListLeagueScoringPresetsUseCase, + new ListLeagueScoringPresetsUseCase(leagueScoringPresetProvider, leagueScoringPresetsPresenter) ); + const leagueScoringConfigPresenter = new LeagueScoringConfigPresenter(); container.registerInstance( - DI_TOKENS.GetLeagueScoringConfigQuery, - new GetLeagueScoringConfigQuery( + DI_TOKENS.GetLeagueScoringConfigUseCase, + new GetLeagueScoringConfigUseCase( leagueRepository, seasonRepository, leagueScoringConfigRepository, gameRepository, - leagueScoringPresetProvider + leagueScoringPresetProvider, + leagueScoringConfigPresenter ) ); + const leagueFullConfigPresenter = new LeagueFullConfigPresenter(); container.registerInstance( - DI_TOKENS.GetLeagueFullConfigQuery, - new GetLeagueFullConfigQuery( + DI_TOKENS.GetLeagueFullConfigUseCase, + new GetLeagueFullConfigUseCase( leagueRepository, seasonRepository, leagueScoringConfigRepository, - gameRepository + gameRepository, + leagueFullConfigPresenter ) ); + const leagueSchedulePreviewPresenter = new LeagueSchedulePreviewPresenter(); container.registerInstance( - DI_TOKENS.PreviewLeagueScheduleQuery, - new PreviewLeagueScheduleQuery() + DI_TOKENS.PreviewLeagueScheduleUseCase, + new PreviewLeagueScheduleUseCase(undefined, leagueSchedulePreviewPresenter) ); + const raceWithSOFPresenter = new RaceWithSOFPresenter(); container.registerInstance( - DI_TOKENS.GetRaceWithSOFQuery, - new GetRaceWithSOFQuery( + DI_TOKENS.GetRaceWithSOFUseCase, + new GetRaceWithSOFUseCase( raceRepository, raceRegistrationRepository, resultRepository, - driverRatingProvider + driverRatingProvider, + raceWithSOFPresenter ) ); + const leagueStatsPresenter = new LeagueStatsPresenter(); container.registerInstance( - DI_TOKENS.GetLeagueStatsQuery, - new GetLeagueStatsQuery( + DI_TOKENS.GetLeagueStatsUseCase, + new GetLeagueStatsUseCase( leagueRepository, raceRepository, resultRepository, - driverRatingProvider + driverRatingProvider, + leagueStatsPresenter ) ); - // Register queries - Teams + const racesPresenter = new RacesPagePresenter(); container.registerInstance( - DI_TOKENS.GetAllTeamsQuery, - new GetAllTeamsQuery(teamRepository) + DI_TOKENS.GetRacesPageDataUseCase, + new GetRacesPageDataUseCase(raceRepository, leagueRepository, racesPresenter) ); + // Create services for driver leaderboard query + const rankingService = { + getAllDriverRankings: () => { + const stats = getDIContainer().resolve>(DI_TOKENS.DriverStats); + return Object.entries(stats).map(([driverId, stat]) => ({ + driverId, + rating: stat.rating, + overallRank: stat.overallRank, + })).sort((a, b) => b.rating - a.rating); + } + }; + + const driverStatsService = { + getDriverStats: (driverId: string) => { + const stats = getDIContainer().resolve>(DI_TOKENS.DriverStats); + return stats[driverId] || null; + } + }; + + const imageService = getDIContainer().resolve(DI_TOKENS.ImageService); + + const driversPresenter = new DriversLeaderboardPresenter(); container.registerInstance( - DI_TOKENS.GetTeamDetailsQuery, - new GetTeamDetailsQuery(teamRepository, teamMembershipRepository) + DI_TOKENS.GetDriversLeaderboardUseCase, + new GetDriversLeaderboardUseCase( + driverRepository, + rankingService as any, + driverStatsService as any, + imageService, + driversPresenter + ) ); + const getDriverStatsAdapter = (driverId: string) => { + const stats = getDIContainer().resolve>(DI_TOKENS.DriverStats); + const stat = stats[driverId]; + if (!stat) return null; + return { + rating: stat.rating ?? null, + wins: stat.wins ?? 0, + totalRaces: stat.totalRaces ?? 0, + }; + }; + + const teamsPresenter = new TeamsLeaderboardPresenter(); container.registerInstance( - DI_TOKENS.GetTeamMembersQuery, - new GetTeamMembersQuery(teamMembershipRepository) + DI_TOKENS.GetTeamsLeaderboardUseCase, + new GetTeamsLeaderboardUseCase( + teamRepository, + teamMembershipRepository, + driverRepository, + getDriverStatsAdapter, + teamsPresenter + ) ); + // Register use cases - Teams (Query-like with Presenters) + const allTeamsPresenter = new AllTeamsPresenter(); container.registerInstance( - DI_TOKENS.GetTeamJoinRequestsQuery, - new GetTeamJoinRequestsQuery(teamMembershipRepository) + DI_TOKENS.GetAllTeamsUseCase, + new GetAllTeamsUseCase(teamRepository, teamMembershipRepository, allTeamsPresenter) ); + const teamDetailsPresenter = new TeamDetailsPresenter(); container.registerInstance( - DI_TOKENS.GetDriverTeamQuery, - new GetDriverTeamQuery(teamRepository, teamMembershipRepository) + DI_TOKENS.GetTeamDetailsUseCase, + new GetTeamDetailsUseCase(teamRepository, teamMembershipRepository, teamDetailsPresenter) + ); + + const teamMembersPresenter = new TeamMembersPresenter(); + container.registerInstance( + DI_TOKENS.GetTeamMembersUseCase, + new GetTeamMembersUseCase(teamMembershipRepository, driverRepository, imageService, teamMembersPresenter) + ); + + const teamJoinRequestsPresenter = new TeamJoinRequestsPresenter(); + container.registerInstance( + DI_TOKENS.GetTeamJoinRequestsUseCase, + new GetTeamJoinRequestsUseCase(teamMembershipRepository, driverRepository, imageService, teamJoinRequestsPresenter) + ); + + const driverTeamPresenter = new DriverTeamPresenter(); + container.registerInstance( + DI_TOKENS.GetDriverTeamUseCase, + new GetDriverTeamUseCase(teamRepository, teamMembershipRepository, driverTeamPresenter) ); // Register queries - Stewarding + const raceProtestsPresenter = new RaceProtestsPresenter(); container.registerInstance( - DI_TOKENS.GetRaceProtestsQuery, - new GetRaceProtestsQuery(protestRepository, driverRepository) + DI_TOKENS.GetRaceProtestsUseCase, + new GetRaceProtestsUseCase(protestRepository, driverRepository, raceProtestsPresenter) ); + const racePenaltiesPresenter = new RacePenaltiesPresenter(); container.registerInstance( - DI_TOKENS.GetRacePenaltiesQuery, - new GetRacePenaltiesQuery(penaltyRepository, driverRepository) + DI_TOKENS.GetRacePenaltiesUseCase, + new GetRacePenaltiesUseCase(penaltyRepository, driverRepository, racePenaltiesPresenter) ); // Register queries - Notifications @@ -990,31 +1110,35 @@ export function configureDIContainer(): void { new GetUnreadNotificationsQuery(notificationRepository) ); - // Register queries - Sponsors + // Register use cases - Sponsors const sponsorRepository = container.resolve(DI_TOKENS.SponsorRepository); const seasonSponsorshipRepository = container.resolve(DI_TOKENS.SeasonSponsorshipRepository); + const sponsorDashboardPresenter = new SponsorDashboardPresenter(); container.registerInstance( - DI_TOKENS.GetSponsorDashboardQuery, - new GetSponsorDashboardQuery( + DI_TOKENS.GetSponsorDashboardUseCase, + new GetSponsorDashboardUseCase( sponsorRepository, seasonSponsorshipRepository, seasonRepository, leagueRepository, leagueMembershipRepository, - raceRepository + raceRepository, + sponsorDashboardPresenter ) ); + const sponsorSponsorshipsPresenter = new SponsorSponsorshipsPresenter(); container.registerInstance( - DI_TOKENS.GetSponsorSponsorshipsQuery, - new GetSponsorSponsorshipsQuery( + DI_TOKENS.GetSponsorSponsorshipsUseCase, + new GetSponsorSponsorshipsUseCase( sponsorRepository, seasonSponsorshipRepository, seasonRepository, leagueRepository, leagueMembershipRepository, - raceRepository + raceRepository, + sponsorSponsorshipsPresenter ) ); @@ -1022,20 +1146,24 @@ export function configureDIContainer(): void { const sponsorshipRequestRepository = container.resolve(DI_TOKENS.SponsorshipRequestRepository); const sponsorshipPricingRepository = container.resolve(DI_TOKENS.SponsorshipPricingRepository); + const pendingSponsorshipRequestsPresenter = new PendingSponsorshipRequestsPresenter(); container.registerInstance( - DI_TOKENS.GetPendingSponsorshipRequestsQuery, - new GetPendingSponsorshipRequestsQuery( + DI_TOKENS.GetPendingSponsorshipRequestsUseCase, + new GetPendingSponsorshipRequestsUseCase( sponsorshipRequestRepository, - sponsorRepository + sponsorRepository, + pendingSponsorshipRequestsPresenter ) ); + const entitySponsorshipPricingPresenter = new EntitySponsorshipPricingPresenter(); container.registerInstance( - DI_TOKENS.GetEntitySponsorshipPricingQuery, - new GetEntitySponsorshipPricingQuery( + DI_TOKENS.GetEntitySponsorshipPricingUseCase, + new GetEntitySponsorshipPricingUseCase( sponsorshipPricingRepository, sponsorshipRequestRepository, - seasonSponsorshipRepository + seasonSponsorshipRepository, + entitySponsorshipPricingPresenter ) ); diff --git a/apps/website/lib/di-container.ts b/apps/website/lib/di-container.ts index ff2ccb402..d5986298a 100644 --- a/apps/website/lib/di-container.ts +++ b/apps/website/lib/di-container.ts @@ -41,51 +41,54 @@ import type { JoinLeagueUseCase, RegisterForRaceUseCase, WithdrawFromRaceUseCase, - IsDriverRegisteredForRaceQuery, - GetRaceRegistrationsQuery, CreateTeamUseCase, JoinTeamUseCase, LeaveTeamUseCase, ApproveTeamJoinRequestUseCase, RejectTeamJoinRequestUseCase, UpdateTeamUseCase, - GetAllTeamsQuery, - GetTeamDetailsQuery, - GetTeamMembersQuery, - GetTeamJoinRequestsQuery, - GetDriverTeamQuery, - GetLeagueStandingsQuery, - GetLeagueDriverSeasonStatsQuery, - GetAllLeaguesWithCapacityQuery, - GetAllLeaguesWithCapacityAndScoringQuery, - ListLeagueScoringPresetsQuery, - GetLeagueScoringConfigQuery, + GetAllTeamsUseCase, + GetTeamDetailsUseCase, + GetTeamMembersUseCase, + GetTeamJoinRequestsUseCase, + GetDriverTeamUseCase, CreateLeagueWithSeasonAndScoringUseCase, - GetLeagueFullConfigQuery, - GetRaceWithSOFQuery, - GetLeagueStatsQuery, FileProtestUseCase, ReviewProtestUseCase, ApplyPenaltyUseCase, - GetRaceProtestsQuery, - GetRacePenaltiesQuery, RequestProtestDefenseUseCase, SubmitProtestDefenseUseCase, - GetSponsorDashboardQuery, - GetSponsorSponsorshipsQuery, + GetSponsorDashboardUseCase, + GetSponsorSponsorshipsUseCase, ApplyForSponsorshipUseCase, AcceptSponsorshipRequestUseCase, RejectSponsorshipRequestUseCase, - GetPendingSponsorshipRequestsQuery, - GetEntitySponsorshipPricingQuery, + GetPendingSponsorshipRequestsUseCase, + GetEntitySponsorshipPricingUseCase, } from '@gridpilot/racing/application'; +import type { IsDriverRegisteredForRaceUseCase } from '@gridpilot/racing/application/use-cases/IsDriverRegisteredForRaceQuery'; +import type { GetRaceRegistrationsUseCase } from '@gridpilot/racing/application/use-cases/GetRaceRegistrationsQuery'; +import type { GetRaceWithSOFUseCase } from '@gridpilot/racing/application/use-cases/GetRaceWithSOFQuery'; +import type { GetRaceProtestsUseCase } from '@gridpilot/racing/application/use-cases/GetRaceProtestsQuery'; +import type { GetRacePenaltiesUseCase } from '@gridpilot/racing/application/use-cases/GetRacePenaltiesQuery'; +import type { GetRacesPageDataUseCase } from '@gridpilot/racing/application/use-cases/GetRacesPageDataUseCase'; +import type { GetDriversLeaderboardUseCase } from '@gridpilot/racing/application/use-cases/GetDriversLeaderboardUseCase'; +import type { GetTeamsLeaderboardUseCase } from '@gridpilot/racing/application/use-cases/GetTeamsLeaderboardUseCase'; +import type { GetLeagueStandingsUseCase } from '@gridpilot/racing/application/use-cases/GetLeagueStandingsQuery'; +import type { GetLeagueDriverSeasonStatsUseCase } from '@gridpilot/racing/application/use-cases/GetLeagueDriverSeasonStatsQuery'; +import type { GetAllLeaguesWithCapacityUseCase } from '@gridpilot/racing/application/use-cases/GetAllLeaguesWithCapacityQuery'; +import type { GetAllLeaguesWithCapacityAndScoringUseCase } from '@gridpilot/racing/application/use-cases/GetAllLeaguesWithCapacityAndScoringQuery'; +import type { ListLeagueScoringPresetsUseCase } from '@gridpilot/racing/application/use-cases/ListLeagueScoringPresetsQuery'; +import type { GetLeagueScoringConfigUseCase } from '@gridpilot/racing/application/use-cases/GetLeagueScoringConfigQuery'; +import type { GetLeagueFullConfigUseCase } from '@gridpilot/racing/application/use-cases/GetLeagueFullConfigQuery'; +import type { GetLeagueStatsUseCase } from '@gridpilot/racing/application/use-cases/GetLeagueStatsQuery'; import type { ISponsorRepository } from '@gridpilot/racing/domain/repositories/ISponsorRepository'; import type { ISeasonSponsorshipRepository } from '@gridpilot/racing/domain/repositories/ISeasonSponsorshipRepository'; import type { ISponsorshipRequestRepository } from '@gridpilot/racing/domain/repositories/ISponsorshipRequestRepository'; import type { ISponsorshipPricingRepository } from '@gridpilot/racing/domain/repositories/ISponsorshipPricingRepository'; import type { TransferLeagueOwnershipUseCase } from '@gridpilot/racing/application/use-cases/TransferLeagueOwnershipUseCase'; import type { DriverRatingProvider } from '@gridpilot/racing/application'; -import type { PreviewLeagueScheduleQuery } from '@gridpilot/racing/application'; +import type { PreviewLeagueScheduleUseCase } from '@gridpilot/racing/application'; import type { LeagueScoringPresetProvider } from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider'; import { createDemoDriverStats, getDemoLeagueRankings, type DriverStats } from '@gridpilot/testing-support'; @@ -211,64 +214,79 @@ class DIContainer { return getDIContainer().resolve(DI_TOKENS.WithdrawFromRaceUseCase); } - get isDriverRegisteredForRaceQuery(): IsDriverRegisteredForRaceQuery { + get isDriverRegisteredForRaceUseCase(): IsDriverRegisteredForRaceUseCase { this.ensureInitialized(); - return getDIContainer().resolve(DI_TOKENS.IsDriverRegisteredForRaceQuery); + return getDIContainer().resolve(DI_TOKENS.IsDriverRegisteredForRaceUseCase); } - get getRaceRegistrationsQuery(): GetRaceRegistrationsQuery { + get getRaceRegistrationsUseCase(): GetRaceRegistrationsUseCase { this.ensureInitialized(); - return getDIContainer().resolve(DI_TOKENS.GetRaceRegistrationsQuery); + return getDIContainer().resolve(DI_TOKENS.GetRaceRegistrationsUseCase); } - get getLeagueStandingsQuery(): GetLeagueStandingsQuery { + get getLeagueStandingsUseCase(): GetLeagueStandingsUseCase { this.ensureInitialized(); - return getDIContainer().resolve(DI_TOKENS.GetLeagueStandingsQuery); + return getDIContainer().resolve(DI_TOKENS.GetLeagueStandingsUseCase); } - get getLeagueDriverSeasonStatsQuery(): GetLeagueDriverSeasonStatsQuery { + get getLeagueDriverSeasonStatsUseCase(): GetLeagueDriverSeasonStatsUseCase { this.ensureInitialized(); - return getDIContainer().resolve(DI_TOKENS.GetLeagueDriverSeasonStatsQuery); + return getDIContainer().resolve(DI_TOKENS.GetLeagueDriverSeasonStatsUseCase); } - get getAllLeaguesWithCapacityQuery(): GetAllLeaguesWithCapacityQuery { + get getAllLeaguesWithCapacityUseCase(): GetAllLeaguesWithCapacityUseCase { this.ensureInitialized(); - return getDIContainer().resolve(DI_TOKENS.GetAllLeaguesWithCapacityQuery); + return getDIContainer().resolve(DI_TOKENS.GetAllLeaguesWithCapacityUseCase); } - get getAllLeaguesWithCapacityAndScoringQuery(): GetAllLeaguesWithCapacityAndScoringQuery { + get getAllLeaguesWithCapacityAndScoringUseCase(): GetAllLeaguesWithCapacityAndScoringUseCase { this.ensureInitialized(); - return getDIContainer().resolve(DI_TOKENS.GetAllLeaguesWithCapacityAndScoringQuery); + return getDIContainer().resolve(DI_TOKENS.GetAllLeaguesWithCapacityAndScoringUseCase); } - get listLeagueScoringPresetsQuery(): ListLeagueScoringPresetsQuery { + get listLeagueScoringPresetsUseCase(): ListLeagueScoringPresetsUseCase { this.ensureInitialized(); - return getDIContainer().resolve(DI_TOKENS.ListLeagueScoringPresetsQuery); + return getDIContainer().resolve(DI_TOKENS.ListLeagueScoringPresetsUseCase); } - get getLeagueScoringConfigQuery(): GetLeagueScoringConfigQuery { + get getLeagueScoringConfigUseCase(): GetLeagueScoringConfigUseCase { this.ensureInitialized(); - return getDIContainer().resolve(DI_TOKENS.GetLeagueScoringConfigQuery); + return getDIContainer().resolve(DI_TOKENS.GetLeagueScoringConfigUseCase); } - get getLeagueFullConfigQuery(): GetLeagueFullConfigQuery { + get getLeagueFullConfigUseCase(): GetLeagueFullConfigUseCase { this.ensureInitialized(); - return getDIContainer().resolve(DI_TOKENS.GetLeagueFullConfigQuery); + return getDIContainer().resolve(DI_TOKENS.GetLeagueFullConfigUseCase); } - get previewLeagueScheduleQuery(): PreviewLeagueScheduleQuery { + get previewLeagueScheduleUseCase(): PreviewLeagueScheduleUseCase { this.ensureInitialized(); - return getDIContainer().resolve(DI_TOKENS.PreviewLeagueScheduleQuery); + return getDIContainer().resolve(DI_TOKENS.PreviewLeagueScheduleUseCase); } - get getRaceWithSOFQuery(): GetRaceWithSOFQuery { + get getRaceWithSOFUseCase(): GetRaceWithSOFUseCase { this.ensureInitialized(); - return getDIContainer().resolve(DI_TOKENS.GetRaceWithSOFQuery); + return getDIContainer().resolve(DI_TOKENS.GetRaceWithSOFUseCase); } - get getLeagueStatsQuery(): GetLeagueStatsQuery { + get getLeagueStatsUseCase(): GetLeagueStatsUseCase { this.ensureInitialized(); - return getDIContainer().resolve(DI_TOKENS.GetLeagueStatsQuery); + return getDIContainer().resolve(DI_TOKENS.GetLeagueStatsUseCase); + } + + get getRacesPageDataUseCase(): GetRacesPageDataUseCase { + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.GetRacesPageDataUseCase); + } + + get getDriversLeaderboardUseCase(): GetDriversLeaderboardUseCase { + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.GetDriversLeaderboardUseCase); + } + + get getTeamsLeaderboardUseCase(): GetTeamsLeaderboardUseCase { + this.ensureInitialized(); + return getDIContainer().resolve(DI_TOKENS.GetTeamsLeaderboardUseCase); } get driverRatingProvider(): DriverRatingProvider { @@ -311,29 +329,29 @@ class DIContainer { return getDIContainer().resolve(DI_TOKENS.UpdateTeamUseCase); } - get getAllTeamsQuery(): GetAllTeamsQuery { + get getAllTeamsUseCase(): GetAllTeamsUseCase { this.ensureInitialized(); - return getDIContainer().resolve(DI_TOKENS.GetAllTeamsQuery); + return getDIContainer().resolve(DI_TOKENS.GetAllTeamsUseCase); } - get getTeamDetailsQuery(): GetTeamDetailsQuery { + get getTeamDetailsUseCase(): GetTeamDetailsUseCase { this.ensureInitialized(); - return getDIContainer().resolve(DI_TOKENS.GetTeamDetailsQuery); + return getDIContainer().resolve(DI_TOKENS.GetTeamDetailsUseCase); } - get getTeamMembersQuery(): GetTeamMembersQuery { + get getTeamMembersUseCase(): GetTeamMembersUseCase { this.ensureInitialized(); - return getDIContainer().resolve(DI_TOKENS.GetTeamMembersQuery); + return getDIContainer().resolve(DI_TOKENS.GetTeamMembersUseCase); } - get getTeamJoinRequestsQuery(): GetTeamJoinRequestsQuery { + get getTeamJoinRequestsUseCase(): GetTeamJoinRequestsUseCase { this.ensureInitialized(); - return getDIContainer().resolve(DI_TOKENS.GetTeamJoinRequestsQuery); + return getDIContainer().resolve(DI_TOKENS.GetTeamJoinRequestsUseCase); } - get getDriverTeamQuery(): GetDriverTeamQuery { + get getDriverTeamUseCase(): GetDriverTeamUseCase { this.ensureInitialized(); - return getDIContainer().resolve(DI_TOKENS.GetDriverTeamQuery); + return getDIContainer().resolve(DI_TOKENS.GetDriverTeamUseCase); } get teamRepository(): ITeamRepository { @@ -411,14 +429,14 @@ class DIContainer { return getDIContainer().resolve(DI_TOKENS.ApplyPenaltyUseCase); } - get getRaceProtestsQuery(): GetRaceProtestsQuery { + get getRaceProtestsUseCase(): GetRaceProtestsUseCase { this.ensureInitialized(); - return getDIContainer().resolve(DI_TOKENS.GetRaceProtestsQuery); + return getDIContainer().resolve(DI_TOKENS.GetRaceProtestsUseCase); } - get getRacePenaltiesQuery(): GetRacePenaltiesQuery { + get getRacePenaltiesUseCase(): GetRacePenaltiesUseCase { this.ensureInitialized(); - return getDIContainer().resolve(DI_TOKENS.GetRacePenaltiesQuery); + return getDIContainer().resolve(DI_TOKENS.GetRacePenaltiesUseCase); } get requestProtestDefenseUseCase(): RequestProtestDefenseUseCase { @@ -446,14 +464,14 @@ class DIContainer { return getDIContainer().resolve(DI_TOKENS.SeasonSponsorshipRepository); } - get getSponsorDashboardQuery(): GetSponsorDashboardQuery { + get getSponsorDashboardUseCase(): GetSponsorDashboardUseCase { this.ensureInitialized(); - return getDIContainer().resolve(DI_TOKENS.GetSponsorDashboardQuery); + return getDIContainer().resolve(DI_TOKENS.GetSponsorDashboardUseCase); } - get getSponsorSponsorshipsQuery(): GetSponsorSponsorshipsQuery { + get getSponsorSponsorshipsUseCase(): GetSponsorSponsorshipsUseCase { this.ensureInitialized(); - return getDIContainer().resolve(DI_TOKENS.GetSponsorSponsorshipsQuery); + return getDIContainer().resolve(DI_TOKENS.GetSponsorSponsorshipsUseCase); } get sponsorshipRequestRepository(): ISponsorshipRequestRepository { @@ -481,14 +499,14 @@ class DIContainer { return getDIContainer().resolve(DI_TOKENS.RejectSponsorshipRequestUseCase); } - get getPendingSponsorshipRequestsQuery(): GetPendingSponsorshipRequestsQuery { + get getPendingSponsorshipRequestsUseCase(): GetPendingSponsorshipRequestsUseCase { this.ensureInitialized(); - return getDIContainer().resolve(DI_TOKENS.GetPendingSponsorshipRequestsQuery); + return getDIContainer().resolve(DI_TOKENS.GetPendingSponsorshipRequestsUseCase); } - get getEntitySponsorshipPricingQuery(): GetEntitySponsorshipPricingQuery { + get getEntitySponsorshipPricingUseCase(): GetEntitySponsorshipPricingUseCase { this.ensureInitialized(); - return getDIContainer().resolve(DI_TOKENS.GetEntitySponsorshipPricingQuery); + return getDIContainer().resolve(DI_TOKENS.GetEntitySponsorshipPricingUseCase); } } @@ -543,57 +561,68 @@ export function getWithdrawFromRaceUseCase(): WithdrawFromRaceUseCase { return DIContainer.getInstance().withdrawFromRaceUseCase; } -export function getIsDriverRegisteredForRaceQuery(): IsDriverRegisteredForRaceQuery { - return DIContainer.getInstance().isDriverRegisteredForRaceQuery; +export function getIsDriverRegisteredForRaceUseCase(): IsDriverRegisteredForRaceUseCase { + return DIContainer.getInstance().isDriverRegisteredForRaceUseCase; } -export function getGetRaceRegistrationsQuery(): GetRaceRegistrationsQuery { - return DIContainer.getInstance().getRaceRegistrationsQuery; +export function getGetRaceRegistrationsUseCase(): GetRaceRegistrationsUseCase { + return DIContainer.getInstance().getRaceRegistrationsUseCase; } -export function getGetLeagueStandingsQuery(): GetLeagueStandingsQuery { - return DIContainer.getInstance().getLeagueStandingsQuery; +export function getGetLeagueStandingsUseCase(): GetLeagueStandingsUseCase { + return DIContainer.getInstance().getLeagueStandingsUseCase; } -export function getGetLeagueDriverSeasonStatsQuery(): GetLeagueDriverSeasonStatsQuery { - return DIContainer.getInstance().getLeagueDriverSeasonStatsQuery; +export function getGetLeagueDriverSeasonStatsUseCase(): GetLeagueDriverSeasonStatsUseCase { + return DIContainer.getInstance().getLeagueDriverSeasonStatsUseCase; } -export function getGetAllLeaguesWithCapacityQuery(): GetAllLeaguesWithCapacityQuery { - return DIContainer.getInstance().getAllLeaguesWithCapacityQuery; +export function getGetAllLeaguesWithCapacityUseCase(): GetAllLeaguesWithCapacityUseCase { + return DIContainer.getInstance().getAllLeaguesWithCapacityUseCase; } -export function getGetAllLeaguesWithCapacityAndScoringQuery(): GetAllLeaguesWithCapacityAndScoringQuery { - return DIContainer.getInstance().getAllLeaguesWithCapacityAndScoringQuery; +export function getGetAllLeaguesWithCapacityAndScoringUseCase(): GetAllLeaguesWithCapacityAndScoringUseCase { + return DIContainer.getInstance().getAllLeaguesWithCapacityAndScoringUseCase; } -export function getGetLeagueScoringConfigQuery(): GetLeagueScoringConfigQuery { - return DIContainer.getInstance().getLeagueScoringConfigQuery; +export function getGetLeagueScoringConfigUseCase(): GetLeagueScoringConfigUseCase { + return DIContainer.getInstance().getLeagueScoringConfigUseCase; } -export function getGetLeagueFullConfigQuery(): GetLeagueFullConfigQuery { - return DIContainer.getInstance().getLeagueFullConfigQuery; +export function getGetLeagueFullConfigUseCase(): GetLeagueFullConfigUseCase { + return DIContainer.getInstance().getLeagueFullConfigUseCase; } -// Placeholder export for future schedule preview API wiring. -export function getPreviewLeagueScheduleQuery(): PreviewLeagueScheduleQuery { - return DIContainer.getInstance().previewLeagueScheduleQuery; +export function getPreviewLeagueScheduleUseCase(): PreviewLeagueScheduleUseCase { + return DIContainer.getInstance().previewLeagueScheduleUseCase; } -export function getListLeagueScoringPresetsQuery(): ListLeagueScoringPresetsQuery { - return DIContainer.getInstance().listLeagueScoringPresetsQuery; +export function getListLeagueScoringPresetsUseCase(): ListLeagueScoringPresetsUseCase { + return DIContainer.getInstance().listLeagueScoringPresetsUseCase; } export function getCreateLeagueWithSeasonAndScoringUseCase(): CreateLeagueWithSeasonAndScoringUseCase { return DIContainer.getInstance().createLeagueWithSeasonAndScoringUseCase; } -export function getGetRaceWithSOFQuery(): GetRaceWithSOFQuery { - return DIContainer.getInstance().getRaceWithSOFQuery; +export function getGetRaceWithSOFUseCase(): GetRaceWithSOFUseCase { + return DIContainer.getInstance().getRaceWithSOFUseCase; } -export function getGetLeagueStatsQuery(): GetLeagueStatsQuery { - return DIContainer.getInstance().getLeagueStatsQuery; +export function getGetLeagueStatsUseCase(): GetLeagueStatsUseCase { + return DIContainer.getInstance().getLeagueStatsUseCase; +} + +export function getGetRacesPageDataUseCase(): GetRacesPageDataUseCase { + return DIContainer.getInstance().getRacesPageDataUseCase; +} + +export function getGetDriversLeaderboardUseCase(): GetDriversLeaderboardUseCase { + return DIContainer.getInstance().getDriversLeaderboardUseCase; +} + +export function getGetTeamsLeaderboardUseCase(): GetTeamsLeaderboardUseCase { + return DIContainer.getInstance().getTeamsLeaderboardUseCase; } export function getDriverRatingProvider(): DriverRatingProvider { @@ -632,24 +661,24 @@ export function getUpdateTeamUseCase(): UpdateTeamUseCase { return DIContainer.getInstance().updateTeamUseCase; } -export function getGetAllTeamsQuery(): GetAllTeamsQuery { - return DIContainer.getInstance().getAllTeamsQuery; +export function getGetAllTeamsUseCase(): GetAllTeamsUseCase { + return DIContainer.getInstance().getAllTeamsUseCase; } -export function getGetTeamDetailsQuery(): GetTeamDetailsQuery { - return DIContainer.getInstance().getTeamDetailsQuery; +export function getGetTeamDetailsUseCase(): GetTeamDetailsUseCase { + return DIContainer.getInstance().getTeamDetailsUseCase; } -export function getGetTeamMembersQuery(): GetTeamMembersQuery { - return DIContainer.getInstance().getTeamMembersQuery; +export function getGetTeamMembersUseCase(): GetTeamMembersUseCase { + return DIContainer.getInstance().getTeamMembersUseCase; } -export function getGetTeamJoinRequestsQuery(): GetTeamJoinRequestsQuery { - return DIContainer.getInstance().getTeamJoinRequestsQuery; +export function getGetTeamJoinRequestsUseCase(): GetTeamJoinRequestsUseCase { + return DIContainer.getInstance().getTeamJoinRequestsUseCase; } -export function getGetDriverTeamQuery(): GetDriverTeamQuery { - return DIContainer.getInstance().getDriverTeamQuery; +export function getGetDriverTeamUseCase(): GetDriverTeamUseCase { + return DIContainer.getInstance().getDriverTeamUseCase; } export function getFeedRepository(): IFeedRepository { @@ -708,12 +737,12 @@ export function getApplyPenaltyUseCase(): ApplyPenaltyUseCase { return DIContainer.getInstance().applyPenaltyUseCase; } -export function getGetRaceProtestsQuery(): GetRaceProtestsQuery { - return DIContainer.getInstance().getRaceProtestsQuery; +export function getGetRaceProtestsUseCase(): GetRaceProtestsUseCase { + return DIContainer.getInstance().getRaceProtestsUseCase; } -export function getGetRacePenaltiesQuery(): GetRacePenaltiesQuery { - return DIContainer.getInstance().getRacePenaltiesQuery; +export function getGetRacePenaltiesUseCase(): GetRacePenaltiesUseCase { + return DIContainer.getInstance().getRacePenaltiesUseCase; } export function getRequestProtestDefenseUseCase(): RequestProtestDefenseUseCase { @@ -736,12 +765,12 @@ export function getSeasonSponsorshipRepository(): ISeasonSponsorshipRepository { return DIContainer.getInstance().seasonSponsorshipRepository; } -export function getGetSponsorDashboardQuery(): GetSponsorDashboardQuery { - return DIContainer.getInstance().getSponsorDashboardQuery; +export function getGetSponsorDashboardUseCase(): GetSponsorDashboardUseCase { + return DIContainer.getInstance().getSponsorDashboardUseCase; } -export function getGetSponsorSponsorshipsQuery(): GetSponsorSponsorshipsQuery { - return DIContainer.getInstance().getSponsorSponsorshipsQuery; +export function getGetSponsorSponsorshipsUseCase(): GetSponsorSponsorshipsUseCase { + return DIContainer.getInstance().getSponsorSponsorshipsUseCase; } export function getSponsorshipRequestRepository(): ISponsorshipRequestRepository { @@ -764,12 +793,12 @@ export function getRejectSponsorshipRequestUseCase(): RejectSponsorshipRequestUs return DIContainer.getInstance().rejectSponsorshipRequestUseCase; } -export function getGetPendingSponsorshipRequestsQuery(): GetPendingSponsorshipRequestsQuery { - return DIContainer.getInstance().getPendingSponsorshipRequestsQuery; +export function getGetPendingSponsorshipRequestsUseCase(): GetPendingSponsorshipRequestsUseCase { + return DIContainer.getInstance().getPendingSponsorshipRequestsUseCase; } -export function getGetEntitySponsorshipPricingQuery(): GetEntitySponsorshipPricingQuery { - return DIContainer.getInstance().getEntitySponsorshipPricingQuery; +export function getGetEntitySponsorshipPricingUseCase(): GetEntitySponsorshipPricingUseCase { + return DIContainer.getInstance().getEntitySponsorshipPricingUseCase; } /** diff --git a/apps/website/lib/di-tokens.ts b/apps/website/lib/di-tokens.ts index f91b364ea..cc46c7f75 100644 --- a/apps/website/lib/di-tokens.ts +++ b/apps/website/lib/di-tokens.ts @@ -64,38 +64,41 @@ export const DI_TOKENS = { MarkNotificationReadUseCase: Symbol.for('MarkNotificationReadUseCase'), // Queries - Racing - IsDriverRegisteredForRaceQuery: Symbol.for('IsDriverRegisteredForRaceQuery'), - GetRaceRegistrationsQuery: Symbol.for('GetRaceRegistrationsQuery'), - GetLeagueStandingsQuery: Symbol.for('GetLeagueStandingsQuery'), - GetLeagueDriverSeasonStatsQuery: Symbol.for('GetLeagueDriverSeasonStatsQuery'), - GetAllLeaguesWithCapacityQuery: Symbol.for('GetAllLeaguesWithCapacityQuery'), - GetAllLeaguesWithCapacityAndScoringQuery: Symbol.for('GetAllLeaguesWithCapacityAndScoringQuery'), - ListLeagueScoringPresetsQuery: Symbol.for('ListLeagueScoringPresetsQuery'), - GetLeagueScoringConfigQuery: Symbol.for('GetLeagueScoringConfigQuery'), - GetLeagueFullConfigQuery: Symbol.for('GetLeagueFullConfigQuery'), - PreviewLeagueScheduleQuery: Symbol.for('PreviewLeagueScheduleQuery'), - GetRaceWithSOFQuery: Symbol.for('GetRaceWithSOFQuery'), - GetLeagueStatsQuery: Symbol.for('GetLeagueStatsQuery'), + IsDriverRegisteredForRaceUseCase: Symbol.for('IsDriverRegisteredForRaceUseCase'), + GetRaceRegistrationsUseCase: Symbol.for('GetRaceRegistrationsUseCase'), + GetLeagueStandingsUseCase: Symbol.for('GetLeagueStandingsUseCase'), + GetLeagueDriverSeasonStatsUseCase: Symbol.for('GetLeagueDriverSeasonStatsUseCase'), + GetAllLeaguesWithCapacityUseCase: Symbol.for('GetAllLeaguesWithCapacityUseCase'), + GetAllLeaguesWithCapacityAndScoringUseCase: Symbol.for('GetAllLeaguesWithCapacityAndScoringUseCase'), + ListLeagueScoringPresetsUseCase: Symbol.for('ListLeagueScoringPresetsUseCase'), + GetLeagueScoringConfigUseCase: Symbol.for('GetLeagueScoringConfigUseCase'), + GetLeagueFullConfigUseCase: Symbol.for('GetLeagueFullConfigUseCase'), + PreviewLeagueScheduleUseCase: Symbol.for('PreviewLeagueScheduleUseCase'), + GetRaceWithSOFUseCase: Symbol.for('GetRaceWithSOFUseCase'), + GetLeagueStatsUseCase: Symbol.for('GetLeagueStatsUseCase'), + GetRacesPageDataUseCase: Symbol.for('GetRacesPageDataUseCase'), + GetDriversLeaderboardUseCase: Symbol.for('GetDriversLeaderboardUseCase'), + GetTeamsLeaderboardUseCase: Symbol.for('GetTeamsLeaderboardUseCase'), - // Queries - Teams - GetAllTeamsQuery: Symbol.for('GetAllTeamsQuery'), - GetTeamDetailsQuery: Symbol.for('GetTeamDetailsQuery'), - GetTeamMembersQuery: Symbol.for('GetTeamMembersQuery'), - GetTeamJoinRequestsQuery: Symbol.for('GetTeamJoinRequestsQuery'), - GetDriverTeamQuery: Symbol.for('GetDriverTeamQuery'), + // Use Cases - Teams (Query-like) + GetAllTeamsUseCase: Symbol.for('GetAllTeamsUseCase'), + GetTeamDetailsUseCase: Symbol.for('GetTeamDetailsUseCase'), + GetTeamMembersUseCase: Symbol.for('GetTeamMembersUseCase'), + GetTeamJoinRequestsUseCase: Symbol.for('GetTeamJoinRequestsUseCase'), + GetDriverTeamUseCase: Symbol.for('GetDriverTeamUseCase'), // Queries - Stewarding - GetRaceProtestsQuery: Symbol.for('GetRaceProtestsQuery'), - GetRacePenaltiesQuery: Symbol.for('GetRacePenaltiesQuery'), + GetRaceProtestsUseCase: Symbol.for('GetRaceProtestsUseCase'), + GetRacePenaltiesUseCase: Symbol.for('GetRacePenaltiesUseCase'), // Queries - Notifications GetUnreadNotificationsQuery: Symbol.for('GetUnreadNotificationsQuery'), - // Queries - Sponsors - GetSponsorDashboardQuery: Symbol.for('GetSponsorDashboardQuery'), - GetSponsorSponsorshipsQuery: Symbol.for('GetSponsorSponsorshipsQuery'), - GetPendingSponsorshipRequestsQuery: Symbol.for('GetPendingSponsorshipRequestsQuery'), - GetEntitySponsorshipPricingQuery: Symbol.for('GetEntitySponsorshipPricingQuery'), + // Use Cases - Sponsors + GetSponsorDashboardUseCase: Symbol.for('GetSponsorDashboardUseCase'), + GetSponsorSponsorshipsUseCase: Symbol.for('GetSponsorSponsorshipsUseCase'), + GetPendingSponsorshipRequestsUseCase: Symbol.for('GetPendingSponsorshipRequestsUseCase'), + GetEntitySponsorshipPricingUseCase: Symbol.for('GetEntitySponsorshipPricingUseCase'), // Use Cases - Sponsorship ApplyForSponsorshipUseCase: Symbol.for('ApplyForSponsorshipUseCase'), @@ -104,6 +107,20 @@ export const DI_TOKENS = { // Data DriverStats: Symbol.for('DriverStats'), + + // Presenters - Racing + RaceWithSOFPresenter: Symbol.for('IRaceWithSOFPresenter'), + RaceProtestsPresenter: Symbol.for('IRaceProtestsPresenter'), + RacePenaltiesPresenter: Symbol.for('IRacePenaltiesPresenter'), + RaceRegistrationsPresenter: Symbol.for('IRaceRegistrationsPresenter'), + DriverRegistrationStatusPresenter: Symbol.for('IDriverRegistrationStatusPresenter'), + + // Presenters - Sponsors + SponsorDashboardPresenter: Symbol.for('ISponsorDashboardPresenter'), + SponsorSponsorshipsPresenter: Symbol.for('ISponsorSponsorshipsPresenter'), + PendingSponsorshipRequestsPresenter: Symbol.for('IPendingSponsorshipRequestsPresenter'), + EntitySponsorshipPricingPresenter: Symbol.for('IEntitySponsorshipPricingPresenter'), + LeagueSchedulePreviewPresenter: Symbol.for('ILeagueSchedulePreviewPresenter'), } as const; export type DITokens = typeof DI_TOKENS; \ No newline at end of file diff --git a/apps/website/lib/presenters/AllLeaguesWithCapacityAndScoringPresenter.ts b/apps/website/lib/presenters/AllLeaguesWithCapacityAndScoringPresenter.ts new file mode 100644 index 000000000..85e044159 --- /dev/null +++ b/apps/website/lib/presenters/AllLeaguesWithCapacityAndScoringPresenter.ts @@ -0,0 +1,112 @@ +import type { League } from '@gridpilot/racing/domain/entities/League'; +import type { + IAllLeaguesWithCapacityAndScoringPresenter, + LeagueEnrichedData, + LeagueSummaryViewModel, + AllLeaguesWithCapacityAndScoringViewModel, +} from '@gridpilot/racing/application/presenters/IAllLeaguesWithCapacityAndScoringPresenter'; + +export class AllLeaguesWithCapacityAndScoringPresenter implements IAllLeaguesWithCapacityAndScoringPresenter { + private viewModel: AllLeaguesWithCapacityAndScoringViewModel | null = null; + + present(enrichedLeagues: LeagueEnrichedData[]): AllLeaguesWithCapacityAndScoringViewModel { + const leagueItems: LeagueSummaryViewModel[] = enrichedLeagues.map((data) => { + const { league, usedDriverSlots, season, scoringConfig, game, preset } = data; + + const configuredMaxDrivers = league.settings.maxDrivers ?? usedDriverSlots; + const safeMaxDrivers = Math.max(configuredMaxDrivers, usedDriverSlots); + + const structureSummary = `Solo • ${safeMaxDrivers} drivers`; + + const qualifyingMinutes = 30; + const mainRaceMinutes = + typeof league.settings.sessionDuration === 'number' + ? league.settings.sessionDuration + : 40; + const timingSummary = `${qualifyingMinutes} min Quali • ${mainRaceMinutes} min Race`; + + let scoringSummary: LeagueSummaryViewModel['scoring'] | undefined; + let scoringPatternSummary: string | undefined; + + if (season && scoringConfig && game) { + const dropPolicySummary = + preset?.dropPolicySummary ?? this.deriveDropPolicySummary(scoringConfig); + const primaryChampionshipType = + preset?.primaryChampionshipType ?? + (scoringConfig.championships[0]?.type ?? 'driver'); + + const scoringPresetName = preset?.name ?? 'Custom'; + scoringPatternSummary = `${scoringPresetName} • ${dropPolicySummary}`; + + scoringSummary = { + gameId: game.id, + gameName: game.name, + primaryChampionshipType, + scoringPresetId: scoringConfig.scoringPresetId ?? 'custom', + scoringPresetName, + dropPolicySummary, + scoringPatternSummary, + }; + } + + return { + id: league.id, + name: league.name, + description: league.description, + ownerId: league.ownerId, + createdAt: league.createdAt, + maxDrivers: safeMaxDrivers, + usedDriverSlots, + maxTeams: undefined, + usedTeamSlots: undefined, + structureSummary, + scoringPatternSummary, + timingSummary, + scoring: scoringSummary, + }; + }); + + this.viewModel = { + leagues: leagueItems, + totalCount: leagueItems.length, + }; + + return this.viewModel; + } + + getViewModel(): AllLeaguesWithCapacityAndScoringViewModel { + if (!this.viewModel) { + throw new Error('Presenter has not been called yet'); + } + return this.viewModel; + } + + private deriveDropPolicySummary(config: { + championships: Array<{ + dropScorePolicy: { strategy: string; count?: number; dropCount?: number }; + }>; + }): string { + const championship = config.championships[0]; + if (!championship) { + return 'All results count'; + } + + const policy = championship.dropScorePolicy; + if (!policy || policy.strategy === 'none') { + return 'All results count'; + } + + if (policy.strategy === 'bestNResults' && typeof policy.count === 'number') { + return `Best ${policy.count} results count`; + } + + if ( + policy.strategy === 'dropWorstN' && + typeof policy.dropCount === 'number' + ) { + return `Worst ${policy.dropCount} results are dropped`; + } + + return 'Custom drop score rules'; + } +} \ No newline at end of file diff --git a/apps/website/lib/presenters/AllLeaguesWithCapacityPresenter.ts b/apps/website/lib/presenters/AllLeaguesWithCapacityPresenter.ts new file mode 100644 index 000000000..1d70dcc5a --- /dev/null +++ b/apps/website/lib/presenters/AllLeaguesWithCapacityPresenter.ts @@ -0,0 +1,58 @@ +import type { League } from '@gridpilot/racing/domain/entities/League'; +import type { + IAllLeaguesWithCapacityPresenter, + LeagueWithCapacityViewModel, + AllLeaguesWithCapacityViewModel, +} from '@gridpilot/racing/application/presenters/IAllLeaguesWithCapacityPresenter'; + +export class AllLeaguesWithCapacityPresenter implements IAllLeaguesWithCapacityPresenter { + private viewModel: AllLeaguesWithCapacityViewModel | null = null; + + present( + leagues: League[], + memberCounts: Map + ): AllLeaguesWithCapacityViewModel { + const leagueItems: LeagueWithCapacityViewModel[] = leagues.map((league) => { + const usedSlots = memberCounts.get(league.id) ?? 0; + + // Ensure we never expose an impossible state like 26/24: + // clamp maxDrivers to at least usedSlots at the application boundary. + const configuredMax = league.settings.maxDrivers ?? usedSlots; + const safeMaxDrivers = Math.max(configuredMax, usedSlots); + + return { + id: league.id, + name: league.name, + description: league.description, + ownerId: league.ownerId, + settings: { + ...league.settings, + maxDrivers: safeMaxDrivers, + }, + createdAt: league.createdAt.toISOString(), + socialLinks: league.socialLinks + ? { + discordUrl: league.socialLinks.discordUrl, + youtubeUrl: league.socialLinks.youtubeUrl, + websiteUrl: league.socialLinks.websiteUrl, + } + : undefined, + usedSlots, + }; + }); + + this.viewModel = { + leagues: leagueItems, + totalCount: leagueItems.length, + }; + + return this.viewModel; + } + + getViewModel(): AllLeaguesWithCapacityViewModel { + if (!this.viewModel) { + throw new Error('Presenter has not been called yet'); + } + return this.viewModel; + } +} \ No newline at end of file diff --git a/apps/website/lib/presenters/AllTeamsPresenter.ts b/apps/website/lib/presenters/AllTeamsPresenter.ts new file mode 100644 index 000000000..35b3c1bd1 --- /dev/null +++ b/apps/website/lib/presenters/AllTeamsPresenter.ts @@ -0,0 +1,38 @@ +import type { Team } from '@gridpilot/racing/domain/entities/Team'; +import type { + IAllTeamsPresenter, + TeamListItemViewModel, + AllTeamsViewModel, +} from '@gridpilot/racing/application/presenters/IAllTeamsPresenter'; + +export class AllTeamsPresenter implements IAllTeamsPresenter { + private viewModel: AllTeamsViewModel | null = null; + + present(teams: Array): AllTeamsViewModel { + const teamItems: TeamListItemViewModel[] = teams.map((team) => ({ + id: team.id, + name: team.name, + tag: team.tag, + description: team.description, + memberCount: team.memberCount ?? 0, + leagues: team.leagues, + specialization: team.specialization as 'endurance' | 'sprint' | 'mixed' | undefined, + region: team.region, + languages: team.languages, + })); + + this.viewModel = { + teams: teamItems, + totalCount: teamItems.length, + }; + + return this.viewModel; + } + + getViewModel(): AllTeamsViewModel { + if (!this.viewModel) { + throw new Error('Presenter has not been called yet'); + } + return this.viewModel; + } +} \ No newline at end of file diff --git a/apps/website/lib/presenters/DriverRegistrationStatusPresenter.ts b/apps/website/lib/presenters/DriverRegistrationStatusPresenter.ts new file mode 100644 index 000000000..fff628813 --- /dev/null +++ b/apps/website/lib/presenters/DriverRegistrationStatusPresenter.ts @@ -0,0 +1,29 @@ +import type { + IDriverRegistrationStatusPresenter, + DriverRegistrationStatusViewModel, +} from '@gridpilot/racing/application/presenters/IDriverRegistrationStatusPresenter'; + +export class DriverRegistrationStatusPresenter implements IDriverRegistrationStatusPresenter { + private viewModel: DriverRegistrationStatusViewModel | null = null; + + present( + isRegistered: boolean, + raceId: string, + driverId: string + ): DriverRegistrationStatusViewModel { + this.viewModel = { + isRegistered, + raceId, + driverId, + }; + + return this.viewModel; + } + + getViewModel(): DriverRegistrationStatusViewModel { + if (!this.viewModel) { + throw new Error('Presenter has not been called yet'); + } + return this.viewModel; + } +} \ No newline at end of file diff --git a/apps/website/lib/presenters/DriverTeamPresenter.ts b/apps/website/lib/presenters/DriverTeamPresenter.ts new file mode 100644 index 000000000..35096bb5e --- /dev/null +++ b/apps/website/lib/presenters/DriverTeamPresenter.ts @@ -0,0 +1,48 @@ +import type { Team, TeamMembership } from '@gridpilot/racing/domain/entities/Team'; +import type { + IDriverTeamPresenter, + DriverTeamViewModel, +} from '@gridpilot/racing/application/presenters/IDriverTeamPresenter'; + +export class DriverTeamPresenter implements IDriverTeamPresenter { + private viewModel: DriverTeamViewModel | null = null; + + present( + team: Team, + membership: TeamMembership, + driverId: string + ): DriverTeamViewModel { + const isOwner = team.ownerId === driverId; + const canManage = membership.role === 'owner' || membership.role === 'manager'; + + this.viewModel = { + team: { + id: team.id, + name: team.name, + tag: team.tag, + description: team.description, + ownerId: team.ownerId, + leagues: team.leagues, + specialization: team.specialization as 'endurance' | 'sprint' | 'mixed' | undefined, + region: team.region, + languages: team.languages, + }, + membership: { + role: membership.role, + joinedAt: membership.joinedAt.toISOString(), + isActive: membership.isActive, + }, + isOwner, + canManage, + }; + + return this.viewModel; + } + + getViewModel(): DriverTeamViewModel { + if (!this.viewModel) { + throw new Error('Presenter has not been called yet'); + } + return this.viewModel; + } +} \ No newline at end of file diff --git a/apps/website/lib/presenters/DriversLeaderboardPresenter.ts b/apps/website/lib/presenters/DriversLeaderboardPresenter.ts new file mode 100644 index 000000000..e5ca40ee7 --- /dev/null +++ b/apps/website/lib/presenters/DriversLeaderboardPresenter.ts @@ -0,0 +1,81 @@ +import type { Driver } from '@gridpilot/racing/domain/entities/Driver'; +import type { SkillLevel } from '@gridpilot/racing/domain/services/SkillLevelService'; +import { SkillLevelService } from '@gridpilot/racing/domain/services/SkillLevelService'; +import type { + IDriversLeaderboardPresenter, + DriverLeaderboardItemViewModel, + DriversLeaderboardViewModel, +} from '@gridpilot/racing/application/presenters/IDriversLeaderboardPresenter'; + +export class DriversLeaderboardPresenter implements IDriversLeaderboardPresenter { + private viewModel: DriversLeaderboardViewModel | null = null; + + present( + drivers: Driver[], + rankings: Array<{ driverId: string; rating: number; overallRank: number }>, + stats: Record, + avatarUrls: Record + ): DriversLeaderboardViewModel { + const items: DriverLeaderboardItemViewModel[] = drivers.map((driver) => { + const driverStats = stats[driver.id]; + const rating = driverStats?.rating ?? 0; + const wins = driverStats?.wins ?? 0; + const podiums = driverStats?.podiums ?? 0; + const totalRaces = driverStats?.totalRaces ?? 0; + + let effectiveRank = Number.POSITIVE_INFINITY; + if (typeof driverStats?.overallRank === 'number' && driverStats.overallRank > 0) { + effectiveRank = driverStats.overallRank; + } else { + const indexInGlobal = rankings.findIndex((entry) => entry.driverId === driver.id); + if (indexInGlobal !== -1) { + effectiveRank = indexInGlobal + 1; + } + } + + const skillLevel = SkillLevelService.getSkillLevel(rating); + const isActive = rankings.some((r) => r.driverId === driver.id); + + return { + id: driver.id, + name: driver.name, + rating, + skillLevel, + nationality: driver.country, + racesCompleted: totalRaces, + wins, + podiums, + isActive, + rank: effectiveRank, + avatarUrl: avatarUrls[driver.id] ?? '', + }; + }); + + items.sort((a, b) => { + const rankA = Number.isFinite(a.rank) && a.rank > 0 ? a.rank : Number.POSITIVE_INFINITY; + const rankB = Number.isFinite(b.rank) && b.rank > 0 ? b.rank : Number.POSITIVE_INFINITY; + if (rankA !== rankB) return rankA - rankB; + return b.rating - a.rating; + }); + + const totalRaces = items.reduce((sum, d) => sum + d.racesCompleted, 0); + const totalWins = items.reduce((sum, d) => sum + d.wins, 0); + const activeCount = items.filter((d) => d.isActive).length; + + this.viewModel = { + drivers: items, + totalRaces, + totalWins, + activeCount, + }; + + return this.viewModel; + } + + getViewModel(): DriversLeaderboardViewModel { + if (!this.viewModel) { + throw new Error('Presenter has not been called yet'); + } + return this.viewModel; + } +} \ No newline at end of file diff --git a/apps/website/lib/presenters/EntitySponsorshipPricingPresenter.ts b/apps/website/lib/presenters/EntitySponsorshipPricingPresenter.ts new file mode 100644 index 000000000..206f5e325 --- /dev/null +++ b/apps/website/lib/presenters/EntitySponsorshipPricingPresenter.ts @@ -0,0 +1,14 @@ +import type { IEntitySponsorshipPricingPresenter } from '@racing/application/presenters/IEntitySponsorshipPricingPresenter'; +import type { GetEntitySponsorshipPricingResultDTO } from '@racing/application/use-cases/GetEntitySponsorshipPricingQuery'; + +export class EntitySponsorshipPricingPresenter implements IEntitySponsorshipPricingPresenter { + private data: GetEntitySponsorshipPricingResultDTO | null = null; + + present(data: GetEntitySponsorshipPricingResultDTO | null): void { + this.data = data; + } + + getData(): GetEntitySponsorshipPricingResultDTO | null { + return this.data; + } +} \ No newline at end of file diff --git a/apps/website/lib/presenters/LeagueDriverSeasonStatsPresenter.ts b/apps/website/lib/presenters/LeagueDriverSeasonStatsPresenter.ts new file mode 100644 index 000000000..79bbc8918 --- /dev/null +++ b/apps/website/lib/presenters/LeagueDriverSeasonStatsPresenter.ts @@ -0,0 +1,78 @@ +import type { + ILeagueDriverSeasonStatsPresenter, + LeagueDriverSeasonStatsItemViewModel, + LeagueDriverSeasonStatsViewModel, +} from '@gridpilot/racing/application/presenters/ILeagueDriverSeasonStatsPresenter'; + +export class LeagueDriverSeasonStatsPresenter implements ILeagueDriverSeasonStatsPresenter { + private viewModel: LeagueDriverSeasonStatsViewModel | null = null; + + present( + leagueId: string, + standings: Array<{ + driverId: string; + position: number; + points: number; + racesCompleted: number; + }>, + penalties: Map, + driverResults: Map>, + driverRatings: Map + ): LeagueDriverSeasonStatsViewModel { + const stats: LeagueDriverSeasonStatsItemViewModel[] = standings.map((standing) => { + const penalty = penalties.get(standing.driverId) ?? { baseDelta: 0, bonusDelta: 0 }; + const totalPenaltyPoints = penalty.baseDelta; + const bonusPoints = penalty.bonusDelta; + + const racesCompleted = standing.racesCompleted; + const pointsPerRace = racesCompleted > 0 ? standing.points / racesCompleted : 0; + + const ratingInfo = driverRatings.get(standing.driverId) ?? { rating: null, ratingChange: null }; + + const results = driverResults.get(standing.driverId) ?? []; + let avgFinish: number | null = null; + if (results.length > 0) { + const totalPositions = results.reduce((sum, r) => sum + r.position, 0); + const avg = totalPositions / results.length; + avgFinish = Number.isFinite(avg) ? Number(avg.toFixed(2)) : null; + } + + return { + leagueId, + driverId: standing.driverId, + position: standing.position, + driverName: '', + teamId: undefined, + teamName: undefined, + totalPoints: standing.points + totalPenaltyPoints + bonusPoints, + basePoints: standing.points, + penaltyPoints: Math.abs(totalPenaltyPoints), + bonusPoints, + pointsPerRace, + racesStarted: results.length, + racesFinished: results.length, + dnfs: 0, + noShows: 0, + avgFinish, + rating: ratingInfo.rating, + ratingChange: ratingInfo.ratingChange, + }; + }); + + stats.sort((a, b) => a.position - b.position); + + this.viewModel = { + leagueId, + stats, + }; + + return this.viewModel; + } + + getViewModel(): LeagueDriverSeasonStatsViewModel { + if (!this.viewModel) { + throw new Error('Presenter has not been called yet'); + } + return this.viewModel; + } +} \ No newline at end of file diff --git a/apps/website/lib/presenters/LeagueFullConfigPresenter.ts b/apps/website/lib/presenters/LeagueFullConfigPresenter.ts new file mode 100644 index 000000000..8eb78367b --- /dev/null +++ b/apps/website/lib/presenters/LeagueFullConfigPresenter.ts @@ -0,0 +1,119 @@ +import type { DropScorePolicy } from '@gridpilot/racing/domain/value-objects/DropScorePolicy'; +import type { + ILeagueFullConfigPresenter, + LeagueFullConfigData, + LeagueConfigFormViewModel, +} from '@gridpilot/racing/application/presenters/ILeagueFullConfigPresenter'; + +export class LeagueFullConfigPresenter implements ILeagueFullConfigPresenter { + private viewModel: LeagueConfigFormViewModel | null = null; + + present(data: LeagueFullConfigData): LeagueConfigFormViewModel { + const { league, activeSeason, scoringConfig, game } = data; + + const patternId = scoringConfig?.scoringPresetId; + + const primaryChampionship = + scoringConfig && scoringConfig.championships && scoringConfig.championships.length > 0 + ? scoringConfig.championships[0] + : undefined; + + const dropPolicy = primaryChampionship?.dropScorePolicy ?? undefined; + const dropPolicyForm = this.mapDropPolicy(dropPolicy); + + const defaultQualifyingMinutes = 30; + const defaultMainRaceMinutes = 40; + const mainRaceMinutes = + typeof league.settings.sessionDuration === 'number' + ? league.settings.sessionDuration + : defaultMainRaceMinutes; + const qualifyingMinutes = defaultQualifyingMinutes; + + const roundsPlanned = 8; + + let sessionCount = 2; + if ( + primaryChampionship && + Array.isArray((primaryChampionship as any).sessionTypes) && + (primaryChampionship as any).sessionTypes.length > 0 + ) { + sessionCount = (primaryChampionship as any).sessionTypes.length; + } + + const practiceMinutes = 20; + const sprintRaceMinutes = patternId === 'sprint-main-driver' ? 20 : undefined; + + this.viewModel = { + leagueId: league.id, + basics: { + name: league.name, + description: league.description, + visibility: 'public', + gameId: game?.id ?? 'iracing', + }, + structure: { + mode: 'solo', + maxDrivers: league.settings.maxDrivers ?? 32, + maxTeams: undefined, + driversPerTeam: undefined, + multiClassEnabled: false, + }, + championships: { + enableDriverChampionship: true, + enableTeamChampionship: false, + enableNationsChampionship: false, + enableTrophyChampionship: false, + }, + scoring: { + patternId: patternId ?? undefined, + customScoringEnabled: !patternId, + }, + dropPolicy: dropPolicyForm, + timings: { + practiceMinutes, + qualifyingMinutes, + sprintRaceMinutes, + mainRaceMinutes, + sessionCount, + roundsPlanned, + }, + stewarding: { + decisionMode: 'admin_only', + requireDefense: true, + defenseTimeLimit: 48, + voteTimeLimit: 72, + protestDeadlineHours: 72, + stewardingClosesHours: 168, + notifyAccusedOnProtest: true, + notifyOnVoteRequired: true, + }, + }; + + return this.viewModel; + } + + getViewModel(): LeagueConfigFormViewModel { + if (!this.viewModel) { + throw new Error('Presenter has not been called yet'); + } + return this.viewModel; + } + + private mapDropPolicy(policy: DropScorePolicy | undefined): { strategy: string; n?: number } { + if (!policy || policy.strategy === 'none') { + return { strategy: 'none' }; + } + + if (policy.strategy === 'bestNResults') { + const n = typeof policy.count === 'number' ? policy.count : undefined; + return n !== undefined ? { strategy: 'bestNResults', n } : { strategy: 'none' }; + } + + if (policy.strategy === 'dropWorstN') { + const n = typeof policy.dropCount === 'number' ? policy.dropCount : undefined; + return n !== undefined ? { strategy: 'dropWorstN', n } : { strategy: 'none' }; + } + + return { strategy: 'none' }; + } +} \ No newline at end of file diff --git a/apps/website/lib/presenters/LeagueSchedulePreviewPresenter.ts b/apps/website/lib/presenters/LeagueSchedulePreviewPresenter.ts new file mode 100644 index 000000000..e52af34ac --- /dev/null +++ b/apps/website/lib/presenters/LeagueSchedulePreviewPresenter.ts @@ -0,0 +1,14 @@ +import type { ILeagueSchedulePreviewPresenter } from '@racing/application/presenters/ILeagueSchedulePreviewPresenter'; +import type { LeagueSchedulePreviewDTO } from '@racing/application/dto/LeagueScheduleDTO'; + +export class LeagueSchedulePreviewPresenter implements ILeagueSchedulePreviewPresenter { + private data: LeagueSchedulePreviewDTO | null = null; + + present(data: LeagueSchedulePreviewDTO): void { + this.data = data; + } + + getData(): LeagueSchedulePreviewDTO | null { + return this.data; + } +} \ No newline at end of file diff --git a/apps/website/lib/presenters/LeagueScoringConfigPresenter.ts b/apps/website/lib/presenters/LeagueScoringConfigPresenter.ts new file mode 100644 index 000000000..bc1b25cea --- /dev/null +++ b/apps/website/lib/presenters/LeagueScoringConfigPresenter.ts @@ -0,0 +1,149 @@ +import type { ChampionshipConfig } from '@gridpilot/racing/domain/value-objects/ChampionshipConfig'; +import type { BonusRule } from '@gridpilot/racing/domain/value-objects/BonusRule'; +import type { + ILeagueScoringConfigPresenter, + LeagueScoringConfigData, + LeagueScoringConfigViewModel, + LeagueScoringChampionshipViewModel, +} from '@gridpilot/racing/application/presenters/ILeagueScoringConfigPresenter'; + +export class LeagueScoringConfigPresenter implements ILeagueScoringConfigPresenter { + private viewModel: LeagueScoringConfigViewModel | null = null; + + present(data: LeagueScoringConfigData): LeagueScoringConfigViewModel { + const championships: LeagueScoringChampionshipViewModel[] = + data.championships.map((champ) => this.mapChampionship(champ)); + + const dropPolicySummary = + data.preset?.dropPolicySummary ?? + this.deriveDropPolicyDescriptionFromChampionships(data.championships); + + this.viewModel = { + leagueId: data.leagueId, + seasonId: data.seasonId, + gameId: data.gameId, + gameName: data.gameName, + scoringPresetId: data.scoringPresetId, + scoringPresetName: data.preset?.name, + dropPolicySummary, + championships, + }; + + return this.viewModel; + } + + getViewModel(): LeagueScoringConfigViewModel { + if (!this.viewModel) { + throw new Error('Presenter has not been called yet'); + } + return this.viewModel; + } + + private mapChampionship(championship: ChampionshipConfig): LeagueScoringChampionshipViewModel { + const sessionTypes = championship.sessionTypes.map((s) => s.toString()); + const pointsPreview = this.buildPointsPreview(championship.pointsTableBySessionType); + const bonusSummary = this.buildBonusSummary( + championship.bonusRulesBySessionType ?? {}, + ); + const dropPolicyDescription = this.deriveDropPolicyDescription( + championship.dropScorePolicy, + ); + + return { + id: championship.id, + name: championship.name, + type: championship.type, + sessionTypes, + pointsPreview, + bonusSummary, + dropPolicyDescription, + }; + } + + private buildPointsPreview( + tables: Record, + ): Array<{ sessionType: string; position: number; points: number }> { + const preview: Array<{ + sessionType: string; + position: number; + points: number; + }> = []; + + const maxPositions = 10; + + for (const [sessionType, table] of Object.entries(tables)) { + for (let pos = 1; pos <= maxPositions; pos++) { + const points = table.getPointsForPosition(pos); + if (points && points !== 0) { + preview.push({ + sessionType, + position: pos, + points, + }); + } + } + } + + return preview; + } + + private buildBonusSummary( + bonusRulesBySessionType: Record, + ): string[] { + const summaries: string[] = []; + + for (const [sessionType, rules] of Object.entries(bonusRulesBySessionType)) { + for (const rule of rules) { + if (rule.type === 'fastestLap') { + const base = `Fastest lap in ${sessionType}`; + if (rule.requiresFinishInTopN) { + summaries.push( + `${base} +${rule.points} points if finishing P${rule.requiresFinishInTopN} or better`, + ); + } else { + summaries.push(`${base} +${rule.points} points`); + } + } else { + summaries.push( + `${rule.type} bonus in ${sessionType} worth ${rule.points} points`, + ); + } + } + } + + return summaries; + } + + private deriveDropPolicyDescriptionFromChampionships( + championships: ChampionshipConfig[], + ): string { + const first = championships[0]; + if (!first) { + return 'All results count'; + } + return this.deriveDropPolicyDescription(first.dropScorePolicy); + } + + private deriveDropPolicyDescription(policy: { + strategy: string; + count?: number; + dropCount?: number; + }): string { + if (!policy || policy.strategy === 'none') { + return 'All results count'; + } + + if (policy.strategy === 'bestNResults' && typeof policy.count === 'number') { + return `Best ${policy.count} results count towards the championship`; + } + + if ( + policy.strategy === 'dropWorstN' && + typeof policy.dropCount === 'number' + ) { + return `Worst ${policy.dropCount} results are dropped from the championship total`; + } + + return 'Custom drop score rules apply'; + } +} \ No newline at end of file diff --git a/apps/website/lib/presenters/LeagueScoringPresetsPresenter.ts b/apps/website/lib/presenters/LeagueScoringPresetsPresenter.ts new file mode 100644 index 000000000..3ac83dfda --- /dev/null +++ b/apps/website/lib/presenters/LeagueScoringPresetsPresenter.ts @@ -0,0 +1,25 @@ +import type { LeagueScoringPresetDTO } from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider'; +import type { + ILeagueScoringPresetsPresenter, + LeagueScoringPresetsViewModel, +} from '@gridpilot/racing/application/presenters/ILeagueScoringPresetsPresenter'; + +export class LeagueScoringPresetsPresenter implements ILeagueScoringPresetsPresenter { + private viewModel: LeagueScoringPresetsViewModel | null = null; + + present(presets: LeagueScoringPresetDTO[]): LeagueScoringPresetsViewModel { + this.viewModel = { + presets, + totalCount: presets.length, + }; + + return this.viewModel; + } + + getViewModel(): LeagueScoringPresetsViewModel { + if (!this.viewModel) { + throw new Error('Presenter has not been called yet'); + } + return this.viewModel; + } +} \ No newline at end of file diff --git a/apps/website/lib/presenters/LeagueStandingsPresenter.ts b/apps/website/lib/presenters/LeagueStandingsPresenter.ts new file mode 100644 index 000000000..79f1f8c50 --- /dev/null +++ b/apps/website/lib/presenters/LeagueStandingsPresenter.ts @@ -0,0 +1,38 @@ +import type { Standing } from '@gridpilot/racing/domain/entities/Standing'; +import type { + ILeagueStandingsPresenter, + StandingItemViewModel, + LeagueStandingsViewModel, +} from '@gridpilot/racing/application/presenters/ILeagueStandingsPresenter'; + +export class LeagueStandingsPresenter implements ILeagueStandingsPresenter { + private viewModel: LeagueStandingsViewModel | null = null; + + present(standings: Standing[]): LeagueStandingsViewModel { + const standingItems: StandingItemViewModel[] = standings.map((standing) => ({ + id: standing.id, + leagueId: standing.leagueId, + seasonId: standing.seasonId, + driverId: standing.driverId, + position: standing.position, + points: standing.points, + wins: standing.wins, + podiums: standing.podiums, + racesCompleted: standing.racesCompleted, + })); + + this.viewModel = { + leagueId: standings[0]?.leagueId ?? '', + standings: standingItems, + }; + + return this.viewModel; + } + + getViewModel(): LeagueStandingsViewModel { + if (!this.viewModel) { + throw new Error('Presenter has not been called yet'); + } + return this.viewModel; + } +} \ No newline at end of file diff --git a/apps/website/lib/presenters/LeagueStatsPresenter.ts b/apps/website/lib/presenters/LeagueStatsPresenter.ts new file mode 100644 index 000000000..21789f4b4 --- /dev/null +++ b/apps/website/lib/presenters/LeagueStatsPresenter.ts @@ -0,0 +1,42 @@ +import type { + ILeagueStatsPresenter, + LeagueStatsViewModel, +} from '@gridpilot/racing/application/presenters/ILeagueStatsPresenter'; + +export class LeagueStatsPresenter implements ILeagueStatsPresenter { + private viewModel: LeagueStatsViewModel | null = null; + + present( + leagueId: string, + totalRaces: number, + completedRaces: number, + scheduledRaces: number, + sofValues: number[] + ): LeagueStatsViewModel { + const averageSOF = sofValues.length > 0 + ? Math.round(sofValues.reduce((a, b) => a + b, 0) / sofValues.length) + : null; + + const highestSOF = sofValues.length > 0 ? Math.max(...sofValues) : null; + const lowestSOF = sofValues.length > 0 ? Math.min(...sofValues) : null; + + this.viewModel = { + leagueId, + totalRaces, + completedRaces, + scheduledRaces, + averageSOF, + highestSOF, + lowestSOF, + }; + + return this.viewModel; + } + + getViewModel(): LeagueStatsViewModel { + if (!this.viewModel) { + throw new Error('Presenter has not been called yet'); + } + return this.viewModel; + } +} \ No newline at end of file diff --git a/apps/website/lib/presenters/PendingSponsorshipRequestsPresenter.ts b/apps/website/lib/presenters/PendingSponsorshipRequestsPresenter.ts new file mode 100644 index 000000000..fe9422841 --- /dev/null +++ b/apps/website/lib/presenters/PendingSponsorshipRequestsPresenter.ts @@ -0,0 +1,14 @@ +import type { IPendingSponsorshipRequestsPresenter } from '@racing/application/presenters/IPendingSponsorshipRequestsPresenter'; +import type { GetPendingSponsorshipRequestsResultDTO } from '@racing/application/use-cases/GetPendingSponsorshipRequestsQuery'; + +export class PendingSponsorshipRequestsPresenter implements IPendingSponsorshipRequestsPresenter { + private data: GetPendingSponsorshipRequestsResultDTO | null = null; + + present(data: GetPendingSponsorshipRequestsResultDTO): void { + this.data = data; + } + + getData(): GetPendingSponsorshipRequestsResultDTO | null { + return this.data; + } +} \ No newline at end of file diff --git a/apps/website/lib/presenters/RacePenaltiesPresenter.ts b/apps/website/lib/presenters/RacePenaltiesPresenter.ts new file mode 100644 index 000000000..4f59b56e1 --- /dev/null +++ b/apps/website/lib/presenters/RacePenaltiesPresenter.ts @@ -0,0 +1,60 @@ +import type { + IRacePenaltiesPresenter, + RacePenaltyViewModel, + RacePenaltiesViewModel, +} from '@gridpilot/racing/application/presenters/IRacePenaltiesPresenter'; +import type { PenaltyType, PenaltyStatus } from '@gridpilot/racing/domain/entities/Penalty'; + +export class RacePenaltiesPresenter implements IRacePenaltiesPresenter { + private viewModel: RacePenaltiesViewModel | null = null; + + present( + penalties: Array<{ + id: string; + raceId: string; + driverId: string; + type: PenaltyType; + value?: number; + reason: string; + protestId?: string; + issuedBy: string; + status: PenaltyStatus; + issuedAt: Date; + appliedAt?: Date; + notes?: string; + getDescription(): string; + }>, + driverMap: Map + ): RacePenaltiesViewModel { + const penaltyViewModels: RacePenaltyViewModel[] = penalties.map(penalty => ({ + id: penalty.id, + raceId: penalty.raceId, + driverId: penalty.driverId, + driverName: driverMap.get(penalty.driverId) || 'Unknown', + type: penalty.type, + value: penalty.value, + reason: penalty.reason, + protestId: penalty.protestId, + issuedBy: penalty.issuedBy, + issuedByName: driverMap.get(penalty.issuedBy) || 'Unknown', + status: penalty.status, + description: penalty.getDescription(), + issuedAt: penalty.issuedAt.toISOString(), + appliedAt: penalty.appliedAt?.toISOString(), + notes: penalty.notes, + })); + + this.viewModel = { + penalties: penaltyViewModels, + }; + + return this.viewModel; + } + + getViewModel(): RacePenaltiesViewModel { + if (!this.viewModel) { + throw new Error('Presenter has not been called yet'); + } + return this.viewModel; + } +} \ No newline at end of file diff --git a/apps/website/lib/presenters/RaceProtestsPresenter.ts b/apps/website/lib/presenters/RaceProtestsPresenter.ts new file mode 100644 index 000000000..195c8b039 --- /dev/null +++ b/apps/website/lib/presenters/RaceProtestsPresenter.ts @@ -0,0 +1,59 @@ +import type { + IRaceProtestsPresenter, + RaceProtestViewModel, + RaceProtestsViewModel, +} from '@gridpilot/racing/application/presenters/IRaceProtestsPresenter'; +import type { ProtestStatus, ProtestIncident } from '@gridpilot/racing/domain/entities/Protest'; + +export class RaceProtestsPresenter implements IRaceProtestsPresenter { + private viewModel: RaceProtestsViewModel | null = null; + + present( + protests: Array<{ + id: string; + raceId: string; + protestingDriverId: string; + accusedDriverId: string; + incident: ProtestIncident; + comment?: string; + proofVideoUrl?: string; + status: ProtestStatus; + reviewedBy?: string; + decisionNotes?: string; + filedAt: Date; + reviewedAt?: Date; + }>, + driverMap: Map + ): RaceProtestsViewModel { + const protestViewModels: RaceProtestViewModel[] = protests.map(protest => ({ + id: protest.id, + raceId: protest.raceId, + protestingDriverId: protest.protestingDriverId, + protestingDriverName: driverMap.get(protest.protestingDriverId) || 'Unknown', + accusedDriverId: protest.accusedDriverId, + accusedDriverName: driverMap.get(protest.accusedDriverId) || 'Unknown', + incident: protest.incident, + comment: protest.comment, + proofVideoUrl: protest.proofVideoUrl, + status: protest.status, + reviewedBy: protest.reviewedBy, + reviewedByName: protest.reviewedBy ? driverMap.get(protest.reviewedBy) : undefined, + decisionNotes: protest.decisionNotes, + filedAt: protest.filedAt.toISOString(), + reviewedAt: protest.reviewedAt?.toISOString(), + })); + + this.viewModel = { + protests: protestViewModels, + }; + + return this.viewModel; + } + + getViewModel(): RaceProtestsViewModel { + if (!this.viewModel) { + throw new Error('Presenter has not been called yet'); + } + return this.viewModel; + } +} \ No newline at end of file diff --git a/apps/website/lib/presenters/RaceRegistrationsPresenter.ts b/apps/website/lib/presenters/RaceRegistrationsPresenter.ts new file mode 100644 index 000000000..d1ae6acad --- /dev/null +++ b/apps/website/lib/presenters/RaceRegistrationsPresenter.ts @@ -0,0 +1,24 @@ +import type { + IRaceRegistrationsPresenter, + RaceRegistrationsViewModel, +} from '@gridpilot/racing/application/presenters/IRaceRegistrationsPresenter'; + +export class RaceRegistrationsPresenter implements IRaceRegistrationsPresenter { + private viewModel: RaceRegistrationsViewModel | null = null; + + present(registeredDriverIds: string[]): RaceRegistrationsViewModel { + this.viewModel = { + registeredDriverIds, + count: registeredDriverIds.length, + }; + + return this.viewModel; + } + + getViewModel(): RaceRegistrationsViewModel { + if (!this.viewModel) { + throw new Error('Presenter has not been called yet'); + } + return this.viewModel; + } +} \ No newline at end of file diff --git a/apps/website/lib/presenters/RaceWithSOFPresenter.ts b/apps/website/lib/presenters/RaceWithSOFPresenter.ts new file mode 100644 index 000000000..5a961fd22 --- /dev/null +++ b/apps/website/lib/presenters/RaceWithSOFPresenter.ts @@ -0,0 +1,49 @@ +import type { + IRaceWithSOFPresenter, + RaceWithSOFViewModel, +} from '@gridpilot/racing/application/presenters/IRaceWithSOFPresenter'; + +export class RaceWithSOFPresenter implements IRaceWithSOFPresenter { + private viewModel: RaceWithSOFViewModel | null = null; + + present( + raceId: string, + leagueId: string, + scheduledAt: Date, + track: string, + trackId: string, + car: string, + carId: string, + sessionType: string, + status: string, + strengthOfField: number | null, + registeredCount: number, + maxParticipants: number, + participantCount: number + ): RaceWithSOFViewModel { + this.viewModel = { + id: raceId, + leagueId, + scheduledAt: scheduledAt.toISOString(), + track, + trackId, + car, + carId, + sessionType, + status, + strengthOfField, + registeredCount, + maxParticipants, + participantCount, + }; + + return this.viewModel; + } + + getViewModel(): RaceWithSOFViewModel { + if (!this.viewModel) { + throw new Error('Presenter has not been called yet'); + } + return this.viewModel; + } +} \ No newline at end of file diff --git a/apps/website/lib/presenters/RacesPagePresenter.ts b/apps/website/lib/presenters/RacesPagePresenter.ts new file mode 100644 index 000000000..31e7840b2 --- /dev/null +++ b/apps/website/lib/presenters/RacesPagePresenter.ts @@ -0,0 +1,64 @@ +import type { + IRacesPagePresenter, + RacesPageViewModel, + RaceListItemViewModel, +} from '@gridpilot/racing/application/presenters/IRacesPagePresenter'; + +export class RacesPagePresenter implements IRacesPagePresenter { + private viewModel: RacesPageViewModel | null = null; + + present(races: any[]): void { + const now = new Date(); + const nextWeek = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); + + const raceViewModels: RaceListItemViewModel[] = races.map(race => ({ + id: race.id, + track: race.track, + car: race.car, + scheduledAt: race.scheduledAt, + status: race.status, + leagueId: race.leagueId, + leagueName: race.leagueName, + strengthOfField: race.strengthOfField, + isUpcoming: race.isUpcoming, + isLive: race.isLive, + isPast: race.isPast, + })); + + const stats = { + total: raceViewModels.length, + scheduled: raceViewModels.filter(r => r.status === 'scheduled').length, + running: raceViewModels.filter(r => r.status === 'running').length, + completed: raceViewModels.filter(r => r.status === 'completed').length, + }; + + const liveRaces = raceViewModels.filter(r => r.isLive); + + const upcomingThisWeek = raceViewModels + .filter(r => { + const scheduledDate = new Date(r.scheduledAt); + return r.isUpcoming && scheduledDate >= now && scheduledDate <= nextWeek; + }) + .slice(0, 5); + + const recentResults = raceViewModels + .filter(r => r.status === 'completed') + .sort((a, b) => new Date(b.scheduledAt).getTime() - new Date(a.scheduledAt).getTime()) + .slice(0, 3); + + this.viewModel = { + races: raceViewModels, + stats, + liveRaces, + upcomingThisWeek, + recentResults, + }; + } + + getViewModel(): RacesPageViewModel { + if (!this.viewModel) { + throw new Error('ViewModel not yet generated. Call present() first.'); + } + return this.viewModel; + } +} \ No newline at end of file diff --git a/apps/website/lib/presenters/SponsorDashboardPresenter.ts b/apps/website/lib/presenters/SponsorDashboardPresenter.ts new file mode 100644 index 000000000..e94abefd9 --- /dev/null +++ b/apps/website/lib/presenters/SponsorDashboardPresenter.ts @@ -0,0 +1,14 @@ +import type { ISponsorDashboardPresenter } from '@racing/application/presenters/ISponsorDashboardPresenter'; +import type { SponsorDashboardDTO } from '@racing/application/use-cases/GetSponsorDashboardQuery'; + +export class SponsorDashboardPresenter implements ISponsorDashboardPresenter { + private data: SponsorDashboardDTO | null = null; + + present(data: SponsorDashboardDTO | null): void { + this.data = data; + } + + getData(): SponsorDashboardDTO | null { + return this.data; + } +} \ No newline at end of file diff --git a/apps/website/lib/presenters/SponsorSponsorshipsPresenter.ts b/apps/website/lib/presenters/SponsorSponsorshipsPresenter.ts new file mode 100644 index 000000000..5c3890193 --- /dev/null +++ b/apps/website/lib/presenters/SponsorSponsorshipsPresenter.ts @@ -0,0 +1,14 @@ +import type { ISponsorSponsorshipsPresenter } from '@racing/application/presenters/ISponsorSponsorshipsPresenter'; +import type { SponsorSponsorshipsDTO } from '@racing/application/use-cases/GetSponsorSponsorshipsQuery'; + +export class SponsorSponsorshipsPresenter implements ISponsorSponsorshipsPresenter { + private data: SponsorSponsorshipsDTO | null = null; + + present(data: SponsorSponsorshipsDTO | null): void { + this.data = data; + } + + getData(): SponsorSponsorshipsDTO | null { + return this.data; + } +} \ No newline at end of file diff --git a/apps/website/lib/presenters/TeamDetailsPresenter.ts b/apps/website/lib/presenters/TeamDetailsPresenter.ts new file mode 100644 index 000000000..11857823e --- /dev/null +++ b/apps/website/lib/presenters/TeamDetailsPresenter.ts @@ -0,0 +1,48 @@ +import type { Team, TeamMembership } from '@gridpilot/racing/domain/entities/Team'; +import type { + ITeamDetailsPresenter, + TeamDetailsViewModel, +} from '@gridpilot/racing/application/presenters/ITeamDetailsPresenter'; + +export class TeamDetailsPresenter implements ITeamDetailsPresenter { + private viewModel: TeamDetailsViewModel | null = null; + + present( + team: Team, + membership: TeamMembership | null, + driverId: string + ): TeamDetailsViewModel { + const canManage = membership?.role === 'owner' || membership?.role === 'manager'; + + this.viewModel = { + team: { + id: team.id, + name: team.name, + tag: team.tag, + description: team.description, + ownerId: team.ownerId, + leagues: team.leagues, + specialization: team.specialization as 'endurance' | 'sprint' | 'mixed' | undefined, + region: team.region, + languages: team.languages, + }, + membership: membership + ? { + role: membership.role, + joinedAt: membership.joinedAt.toISOString(), + isActive: membership.isActive, + } + : null, + canManage, + }; + + return this.viewModel; + } + + getViewModel(): TeamDetailsViewModel { + if (!this.viewModel) { + throw new Error('Presenter has not been called yet'); + } + return this.viewModel; + } +} \ No newline at end of file diff --git a/apps/website/lib/presenters/TeamJoinRequestsPresenter.ts b/apps/website/lib/presenters/TeamJoinRequestsPresenter.ts new file mode 100644 index 000000000..854515e2a --- /dev/null +++ b/apps/website/lib/presenters/TeamJoinRequestsPresenter.ts @@ -0,0 +1,43 @@ +import type { TeamJoinRequest } from '@gridpilot/racing/domain/entities/Team'; +import type { + ITeamJoinRequestsPresenter, + TeamJoinRequestViewModel, + TeamJoinRequestsViewModel, +} from '@gridpilot/racing/application/presenters/ITeamJoinRequestsPresenter'; + +export class TeamJoinRequestsPresenter implements ITeamJoinRequestsPresenter { + private viewModel: TeamJoinRequestsViewModel | null = null; + + present( + requests: TeamJoinRequest[], + driverNames: Record, + avatarUrls: Record + ): TeamJoinRequestsViewModel { + const requestItems: TeamJoinRequestViewModel[] = requests.map((request) => ({ + requestId: request.id, + driverId: request.driverId, + driverName: driverNames[request.driverId] ?? 'Unknown Driver', + teamId: request.teamId, + status: request.status, + requestedAt: request.requestedAt.toISOString(), + avatarUrl: avatarUrls[request.driverId] ?? '', + })); + + const pendingCount = requestItems.filter((r) => r.status === 'pending').length; + + this.viewModel = { + requests: requestItems, + pendingCount, + totalCount: requestItems.length, + }; + + return this.viewModel; + } + + getViewModel(): TeamJoinRequestsViewModel { + if (!this.viewModel) { + throw new Error('Presenter has not been called yet'); + } + return this.viewModel; + } +} \ No newline at end of file diff --git a/apps/website/lib/presenters/TeamMembersPresenter.ts b/apps/website/lib/presenters/TeamMembersPresenter.ts new file mode 100644 index 000000000..dea850569 --- /dev/null +++ b/apps/website/lib/presenters/TeamMembersPresenter.ts @@ -0,0 +1,46 @@ +import type { TeamMembership } from '@gridpilot/racing/domain/entities/Team'; +import type { + ITeamMembersPresenter, + TeamMemberViewModel, + TeamMembersViewModel, +} from '@gridpilot/racing/application/presenters/ITeamMembersPresenter'; + +export class TeamMembersPresenter implements ITeamMembersPresenter { + private viewModel: TeamMembersViewModel | null = null; + + present( + memberships: TeamMembership[], + driverNames: Record, + avatarUrls: Record + ): TeamMembersViewModel { + const members: TeamMemberViewModel[] = memberships.map((membership) => ({ + driverId: membership.driverId, + driverName: driverNames[membership.driverId] ?? 'Unknown Driver', + role: membership.role, + joinedAt: membership.joinedAt.toISOString(), + isActive: membership.isActive, + avatarUrl: avatarUrls[membership.driverId] ?? '', + })); + + const ownerCount = members.filter((m) => m.role === 'owner').length; + const managerCount = members.filter((m) => m.role === 'manager').length; + const memberCount = members.filter((m) => m.role === 'member').length; + + this.viewModel = { + members, + totalCount: members.length, + ownerCount, + managerCount, + memberCount, + }; + + return this.viewModel; + } + + getViewModel(): TeamMembersViewModel { + if (!this.viewModel) { + throw new Error('Presenter has not been called yet'); + } + return this.viewModel; + } +} \ No newline at end of file diff --git a/apps/website/lib/presenters/TeamsLeaderboardPresenter.ts b/apps/website/lib/presenters/TeamsLeaderboardPresenter.ts new file mode 100644 index 000000000..028050308 --- /dev/null +++ b/apps/website/lib/presenters/TeamsLeaderboardPresenter.ts @@ -0,0 +1,42 @@ +import type { + ITeamsLeaderboardPresenter, + TeamsLeaderboardViewModel, + TeamLeaderboardItemViewModel, + SkillLevel, +} from '@gridpilot/racing/application/presenters/ITeamsLeaderboardPresenter'; + +export class TeamsLeaderboardPresenter implements ITeamsLeaderboardPresenter { + private viewModel: TeamsLeaderboardViewModel | null = null; + + present(teams: any[], recruitingCount: number): void { + this.viewModel = { + teams: teams.map((team) => this.transformTeam(team)), + recruitingCount, + }; + } + + getViewModel(): TeamsLeaderboardViewModel { + if (!this.viewModel) { + throw new Error('ViewModel not yet generated. Call present() first.'); + } + return this.viewModel; + } + + private transformTeam(team: any): TeamLeaderboardItemViewModel { + return { + id: team.id, + name: team.name, + memberCount: team.memberCount, + rating: team.rating, + totalWins: team.totalWins, + totalRaces: team.totalRaces, + performanceLevel: team.performanceLevel as SkillLevel, + isRecruiting: team.isRecruiting, + createdAt: team.createdAt, + description: team.description, + specialization: team.specialization, + region: team.region, + languages: team.languages, + }; + } +} \ No newline at end of file diff --git a/packages/racing/application/presenters/IAllLeaguesWithCapacityAndScoringPresenter.ts b/packages/racing/application/presenters/IAllLeaguesWithCapacityAndScoringPresenter.ts new file mode 100644 index 000000000..bddda7ac7 --- /dev/null +++ b/packages/racing/application/presenters/IAllLeaguesWithCapacityAndScoringPresenter.ts @@ -0,0 +1,47 @@ +import type { League } from '../../domain/entities/League'; +import type { Season } from '../../domain/entities/Season'; +import type { LeagueScoringConfig } from '../../domain/entities/LeagueScoringConfig'; +import type { Game } from '../../domain/entities/Game'; +import type { LeagueScoringPresetDTO } from '../ports/LeagueScoringPresetProvider'; + +export interface LeagueSummaryViewModel { + id: string; + name: string; + description: string; + ownerId: string; + createdAt: Date; + maxDrivers: number; + usedDriverSlots: number; + maxTeams?: number; + usedTeamSlots?: number; + structureSummary: string; + scoringPatternSummary?: string; + timingSummary: string; + scoring?: { + gameId: string; + gameName: string; + primaryChampionshipType: string; + scoringPresetId: string; + scoringPresetName: string; + dropPolicySummary: string; + scoringPatternSummary: string; + }; +} + +export interface AllLeaguesWithCapacityAndScoringViewModel { + leagues: LeagueSummaryViewModel[]; + totalCount: number; +} + +export interface LeagueEnrichedData { + league: League; + usedDriverSlots: number; + season?: Season; + scoringConfig?: LeagueScoringConfig; + game?: Game; + preset?: LeagueScoringPresetDTO; +} + +export interface IAllLeaguesWithCapacityAndScoringPresenter { + present(enrichedLeagues: LeagueEnrichedData[]): AllLeaguesWithCapacityAndScoringViewModel; +} \ No newline at end of file diff --git a/packages/racing/application/presenters/IAllLeaguesWithCapacityPresenter.ts b/packages/racing/application/presenters/IAllLeaguesWithCapacityPresenter.ts new file mode 100644 index 000000000..a8d27fed5 --- /dev/null +++ b/packages/racing/application/presenters/IAllLeaguesWithCapacityPresenter.ts @@ -0,0 +1,32 @@ +import type { League } from '../../domain/entities/League'; + +export interface LeagueWithCapacityViewModel { + id: string; + name: string; + description: string; + ownerId: string; + settings: { + maxDrivers: number; + sessionDuration?: number; + visibility?: string; + }; + createdAt: string; + socialLinks?: { + discordUrl?: string; + youtubeUrl?: string; + websiteUrl?: string; + }; + usedSlots: number; +} + +export interface AllLeaguesWithCapacityViewModel { + leagues: LeagueWithCapacityViewModel[]; + totalCount: number; +} + +export interface IAllLeaguesWithCapacityPresenter { + present( + leagues: League[], + memberCounts: Map + ): AllLeaguesWithCapacityViewModel; +} \ No newline at end of file diff --git a/packages/racing/application/presenters/IAllTeamsPresenter.ts b/packages/racing/application/presenters/IAllTeamsPresenter.ts new file mode 100644 index 000000000..4742c9e68 --- /dev/null +++ b/packages/racing/application/presenters/IAllTeamsPresenter.ts @@ -0,0 +1,22 @@ +import type { Team } from '../../domain/entities/Team'; + +export interface TeamListItemViewModel { + id: string; + name: string; + tag: string; + description: string; + memberCount: number; + leagues: string[]; + specialization?: 'endurance' | 'sprint' | 'mixed'; + region?: string; + languages?: string[]; +} + +export interface AllTeamsViewModel { + teams: TeamListItemViewModel[]; + totalCount: number; +} + +export interface IAllTeamsPresenter { + present(teams: Team[]): AllTeamsViewModel; +} \ No newline at end of file diff --git a/packages/racing/application/presenters/IDriverRegistrationStatusPresenter.ts b/packages/racing/application/presenters/IDriverRegistrationStatusPresenter.ts new file mode 100644 index 000000000..f4e15d91d --- /dev/null +++ b/packages/racing/application/presenters/IDriverRegistrationStatusPresenter.ts @@ -0,0 +1,14 @@ +export interface DriverRegistrationStatusViewModel { + isRegistered: boolean; + raceId: string; + driverId: string; +} + +export interface IDriverRegistrationStatusPresenter { + present( + isRegistered: boolean, + raceId: string, + driverId: string + ): DriverRegistrationStatusViewModel; + getViewModel(): DriverRegistrationStatusViewModel; +} \ No newline at end of file diff --git a/packages/racing/application/presenters/IDriverTeamPresenter.ts b/packages/racing/application/presenters/IDriverTeamPresenter.ts new file mode 100644 index 000000000..234178ff7 --- /dev/null +++ b/packages/racing/application/presenters/IDriverTeamPresenter.ts @@ -0,0 +1,30 @@ +import type { Team, TeamMembership } from '../../domain/entities/Team'; + +export interface DriverTeamViewModel { + team: { + id: string; + name: string; + tag: string; + description: string; + ownerId: string; + leagues: string[]; + specialization?: 'endurance' | 'sprint' | 'mixed'; + region?: string; + languages?: string[]; + }; + membership: { + role: 'owner' | 'manager' | 'member'; + joinedAt: string; + isActive: boolean; + }; + isOwner: boolean; + canManage: boolean; +} + +export interface IDriverTeamPresenter { + present( + team: Team, + membership: TeamMembership, + driverId: string + ): DriverTeamViewModel; +} \ No newline at end of file diff --git a/packages/racing/application/presenters/IDriversLeaderboardPresenter.ts b/packages/racing/application/presenters/IDriversLeaderboardPresenter.ts new file mode 100644 index 000000000..3c53f77e6 --- /dev/null +++ b/packages/racing/application/presenters/IDriversLeaderboardPresenter.ts @@ -0,0 +1,34 @@ +import type { Driver } from '../../domain/entities/Driver'; +import type { SkillLevel } from '../../domain/services/SkillLevelService'; + +export type { SkillLevel }; + +export interface DriverLeaderboardItemViewModel { + id: string; + name: string; + rating: number; + skillLevel: SkillLevel; + nationality: string; + racesCompleted: number; + wins: number; + podiums: number; + isActive: boolean; + rank: number; + avatarUrl: string; +} + +export interface DriversLeaderboardViewModel { + drivers: DriverLeaderboardItemViewModel[]; + totalRaces: number; + totalWins: number; + activeCount: number; +} + +export interface IDriversLeaderboardPresenter { + present( + drivers: Driver[], + rankings: Array<{ driverId: string; rating: number; overallRank: number }>, + stats: Record, + avatarUrls: Record + ): DriversLeaderboardViewModel; +} \ No newline at end of file diff --git a/packages/racing/application/presenters/IEntitySponsorshipPricingPresenter.ts b/packages/racing/application/presenters/IEntitySponsorshipPricingPresenter.ts new file mode 100644 index 000000000..f6ccd2b2b --- /dev/null +++ b/packages/racing/application/presenters/IEntitySponsorshipPricingPresenter.ts @@ -0,0 +1,5 @@ +import type { GetEntitySponsorshipPricingResultDTO } from '../use-cases/GetEntitySponsorshipPricingQuery'; + +export interface IEntitySponsorshipPricingPresenter { + present(data: GetEntitySponsorshipPricingResultDTO | null): void; +} \ No newline at end of file diff --git a/packages/racing/application/presenters/ILeagueDriverSeasonStatsPresenter.ts b/packages/racing/application/presenters/ILeagueDriverSeasonStatsPresenter.ts new file mode 100644 index 000000000..475421cd3 --- /dev/null +++ b/packages/racing/application/presenters/ILeagueDriverSeasonStatsPresenter.ts @@ -0,0 +1,40 @@ +export interface LeagueDriverSeasonStatsItemViewModel { + leagueId: string; + driverId: string; + position: number; + driverName: string; + teamId?: string; + teamName?: string; + totalPoints: number; + basePoints: number; + penaltyPoints: number; + bonusPoints: number; + pointsPerRace: number; + racesStarted: number; + racesFinished: number; + dnfs: number; + noShows: number; + avgFinish: number | null; + rating: number | null; + ratingChange: number | null; +} + +export interface LeagueDriverSeasonStatsViewModel { + leagueId: string; + stats: LeagueDriverSeasonStatsItemViewModel[]; +} + +export interface ILeagueDriverSeasonStatsPresenter { + present( + leagueId: string, + standings: Array<{ + driverId: string; + position: number; + points: number; + racesCompleted: number; + }>, + penalties: Map, + driverResults: Map>, + driverRatings: Map + ): LeagueDriverSeasonStatsViewModel; +} \ No newline at end of file diff --git a/packages/racing/application/presenters/ILeagueFullConfigPresenter.ts b/packages/racing/application/presenters/ILeagueFullConfigPresenter.ts new file mode 100644 index 000000000..52c1dc727 --- /dev/null +++ b/packages/racing/application/presenters/ILeagueFullConfigPresenter.ts @@ -0,0 +1,64 @@ +import type { League } from '../../domain/entities/League'; +import type { Season } from '../../domain/entities/Season'; +import type { LeagueScoringConfig } from '../../domain/entities/LeagueScoringConfig'; +import type { Game } from '../../domain/entities/Game'; + +export interface LeagueConfigFormViewModel { + leagueId: string; + basics: { + name: string; + description: string; + visibility: string; + gameId: string; + }; + structure: { + mode: string; + maxDrivers: number; + maxTeams?: number; + driversPerTeam?: number; + multiClassEnabled: boolean; + }; + championships: { + enableDriverChampionship: boolean; + enableTeamChampionship: boolean; + enableNationsChampionship: boolean; + enableTrophyChampionship: boolean; + }; + scoring: { + patternId?: string; + customScoringEnabled: boolean; + }; + dropPolicy: { + strategy: string; + n?: number; + }; + timings: { + practiceMinutes: number; + qualifyingMinutes: number; + sprintRaceMinutes?: number; + mainRaceMinutes: number; + sessionCount: number; + roundsPlanned: number; + }; + stewarding: { + decisionMode: string; + requireDefense: boolean; + defenseTimeLimit: number; + voteTimeLimit: number; + protestDeadlineHours: number; + stewardingClosesHours: number; + notifyAccusedOnProtest: boolean; + notifyOnVoteRequired: boolean; + }; +} + +export interface LeagueFullConfigData { + league: League; + activeSeason?: Season; + scoringConfig?: LeagueScoringConfig; + game?: Game; +} + +export interface ILeagueFullConfigPresenter { + present(data: LeagueFullConfigData): LeagueConfigFormViewModel; +} \ No newline at end of file diff --git a/packages/racing/application/presenters/ILeagueSchedulePreviewPresenter.ts b/packages/racing/application/presenters/ILeagueSchedulePreviewPresenter.ts new file mode 100644 index 000000000..82c011b20 --- /dev/null +++ b/packages/racing/application/presenters/ILeagueSchedulePreviewPresenter.ts @@ -0,0 +1,5 @@ +import type { LeagueSchedulePreviewDTO } from '../dto/LeagueScheduleDTO'; + +export interface ILeagueSchedulePreviewPresenter { + present(data: LeagueSchedulePreviewDTO): void; +} \ No newline at end of file diff --git a/packages/racing/application/presenters/ILeagueScoringConfigPresenter.ts b/packages/racing/application/presenters/ILeagueScoringConfigPresenter.ts new file mode 100644 index 000000000..abcc2ae1a --- /dev/null +++ b/packages/racing/application/presenters/ILeagueScoringConfigPresenter.ts @@ -0,0 +1,38 @@ +import type { ChampionshipConfig } from '../../domain/value-objects/ChampionshipConfig'; +import type { LeagueScoringPresetDTO } from '../ports/LeagueScoringPresetProvider'; + +export interface LeagueScoringChampionshipViewModel { + id: string; + name: string; + type: string; + sessionTypes: string[]; + pointsPreview: Array<{ sessionType: string; position: number; points: number }>; + bonusSummary: string[]; + dropPolicyDescription: string; +} + +export interface LeagueScoringConfigViewModel { + leagueId: string; + seasonId: string; + gameId: string; + gameName: string; + scoringPresetId?: string; + scoringPresetName?: string; + dropPolicySummary: string; + championships: LeagueScoringChampionshipViewModel[]; +} + +export interface LeagueScoringConfigData { + leagueId: string; + seasonId: string; + gameId: string; + gameName: string; + scoringPresetId?: string; + preset?: LeagueScoringPresetDTO; + championships: ChampionshipConfig[]; +} + +export interface ILeagueScoringConfigPresenter { + present(data: LeagueScoringConfigData): LeagueScoringConfigViewModel; + getViewModel(): LeagueScoringConfigViewModel; +} \ No newline at end of file diff --git a/packages/racing/application/presenters/ILeagueScoringPresetsPresenter.ts b/packages/racing/application/presenters/ILeagueScoringPresetsPresenter.ts new file mode 100644 index 000000000..9c6be3bfe --- /dev/null +++ b/packages/racing/application/presenters/ILeagueScoringPresetsPresenter.ts @@ -0,0 +1,10 @@ +import type { LeagueScoringPresetDTO } from '../ports/LeagueScoringPresetProvider'; + +export interface LeagueScoringPresetsViewModel { + presets: LeagueScoringPresetDTO[]; + totalCount: number; +} + +export interface ILeagueScoringPresetsPresenter { + present(presets: LeagueScoringPresetDTO[]): LeagueScoringPresetsViewModel; +} \ No newline at end of file diff --git a/packages/racing/application/presenters/ILeagueStandingsPresenter.ts b/packages/racing/application/presenters/ILeagueStandingsPresenter.ts new file mode 100644 index 000000000..9709888d9 --- /dev/null +++ b/packages/racing/application/presenters/ILeagueStandingsPresenter.ts @@ -0,0 +1,22 @@ +import type { Standing } from '../../domain/entities/Standing'; + +export interface StandingItemViewModel { + id: string; + leagueId: string; + seasonId: string; + driverId: string; + position: number; + points: number; + wins: number; + podiums: number; + racesCompleted: number; +} + +export interface LeagueStandingsViewModel { + leagueId: string; + standings: StandingItemViewModel[]; +} + +export interface ILeagueStandingsPresenter { + present(standings: Standing[]): LeagueStandingsViewModel; +} \ No newline at end of file diff --git a/packages/racing/application/presenters/ILeagueStatsPresenter.ts b/packages/racing/application/presenters/ILeagueStatsPresenter.ts new file mode 100644 index 000000000..3a994f683 --- /dev/null +++ b/packages/racing/application/presenters/ILeagueStatsPresenter.ts @@ -0,0 +1,20 @@ +export interface LeagueStatsViewModel { + leagueId: string; + totalRaces: number; + completedRaces: number; + scheduledRaces: number; + averageSOF: number | null; + highestSOF: number | null; + lowestSOF: number | null; +} + +export interface ILeagueStatsPresenter { + present( + leagueId: string, + totalRaces: number, + completedRaces: number, + scheduledRaces: number, + sofValues: number[] + ): LeagueStatsViewModel; + getViewModel(): LeagueStatsViewModel; +} \ No newline at end of file diff --git a/packages/racing/application/presenters/IPendingSponsorshipRequestsPresenter.ts b/packages/racing/application/presenters/IPendingSponsorshipRequestsPresenter.ts new file mode 100644 index 000000000..2c393f61d --- /dev/null +++ b/packages/racing/application/presenters/IPendingSponsorshipRequestsPresenter.ts @@ -0,0 +1,5 @@ +import type { GetPendingSponsorshipRequestsResultDTO } from '../use-cases/GetPendingSponsorshipRequestsQuery'; + +export interface IPendingSponsorshipRequestsPresenter { + present(data: GetPendingSponsorshipRequestsResultDTO): void; +} \ No newline at end of file diff --git a/packages/racing/application/presenters/IRacePenaltiesPresenter.ts b/packages/racing/application/presenters/IRacePenaltiesPresenter.ts new file mode 100644 index 000000000..4422604d4 --- /dev/null +++ b/packages/racing/application/presenters/IRacePenaltiesPresenter.ts @@ -0,0 +1,44 @@ +import type { PenaltyType, PenaltyStatus } from '../../domain/entities/Penalty'; + +export interface RacePenaltyViewModel { + id: string; + raceId: string; + driverId: string; + driverName: string; + type: PenaltyType; + value?: number; + reason: string; + protestId?: string; + issuedBy: string; + issuedByName: string; + status: PenaltyStatus; + description: string; + issuedAt: string; + appliedAt?: string; + notes?: string; +} + +export interface RacePenaltiesViewModel { + penalties: RacePenaltyViewModel[]; +} + +export interface IRacePenaltiesPresenter { + present( + penalties: Array<{ + id: string; + raceId: string; + driverId: string; + type: PenaltyType; + value?: number; + reason: string; + protestId?: string; + issuedBy: string; + status: PenaltyStatus; + issuedAt: Date; + appliedAt?: Date; + notes?: string; + getDescription(): string; + }>, + driverMap: Map + ): RacePenaltiesViewModel; +} \ No newline at end of file diff --git a/packages/racing/application/presenters/IRaceProtestsPresenter.ts b/packages/racing/application/presenters/IRaceProtestsPresenter.ts new file mode 100644 index 000000000..2b8b3d146 --- /dev/null +++ b/packages/racing/application/presenters/IRaceProtestsPresenter.ts @@ -0,0 +1,43 @@ +import type { ProtestStatus, ProtestIncident } from '../../domain/entities/Protest'; + +export interface RaceProtestViewModel { + id: string; + raceId: string; + protestingDriverId: string; + protestingDriverName: string; + accusedDriverId: string; + accusedDriverName: string; + incident: ProtestIncident; + comment?: string; + proofVideoUrl?: string; + status: ProtestStatus; + reviewedBy?: string; + reviewedByName?: string; + decisionNotes?: string; + filedAt: string; + reviewedAt?: string; +} + +export interface RaceProtestsViewModel { + protests: RaceProtestViewModel[]; +} + +export interface IRaceProtestsPresenter { + present( + protests: Array<{ + id: string; + raceId: string; + protestingDriverId: string; + accusedDriverId: string; + incident: ProtestIncident; + comment?: string; + proofVideoUrl?: string; + status: ProtestStatus; + reviewedBy?: string; + decisionNotes?: string; + filedAt: Date; + reviewedAt?: Date; + }>, + driverMap: Map + ): RaceProtestsViewModel; +} \ No newline at end of file diff --git a/packages/racing/application/presenters/IRaceRegistrationsPresenter.ts b/packages/racing/application/presenters/IRaceRegistrationsPresenter.ts new file mode 100644 index 000000000..1bfbd4b1e --- /dev/null +++ b/packages/racing/application/presenters/IRaceRegistrationsPresenter.ts @@ -0,0 +1,9 @@ +export interface RaceRegistrationsViewModel { + registeredDriverIds: string[]; + count: number; +} + +export interface IRaceRegistrationsPresenter { + present(registeredDriverIds: string[]): RaceRegistrationsViewModel; + getViewModel(): RaceRegistrationsViewModel; +} \ No newline at end of file diff --git a/packages/racing/application/presenters/IRaceWithSOFPresenter.ts b/packages/racing/application/presenters/IRaceWithSOFPresenter.ts new file mode 100644 index 000000000..5d9d1c017 --- /dev/null +++ b/packages/racing/application/presenters/IRaceWithSOFPresenter.ts @@ -0,0 +1,34 @@ +export interface RaceWithSOFViewModel { + id: string; + leagueId: string; + scheduledAt: string; + track: string; + trackId: string; + car: string; + carId: string; + sessionType: string; + status: string; + strengthOfField: number | null; + registeredCount: number; + maxParticipants: number; + participantCount: number; +} + +export interface IRaceWithSOFPresenter { + present( + raceId: string, + leagueId: string, + scheduledAt: Date, + track: string, + trackId: string, + car: string, + carId: string, + sessionType: string, + status: string, + strengthOfField: number | null, + registeredCount: number, + maxParticipants: number, + participantCount: number + ): RaceWithSOFViewModel; + getViewModel(): RaceWithSOFViewModel; +} \ No newline at end of file diff --git a/packages/racing/application/presenters/IRacesPagePresenter.ts b/packages/racing/application/presenters/IRacesPagePresenter.ts new file mode 100644 index 000000000..db7db156c --- /dev/null +++ b/packages/racing/application/presenters/IRacesPagePresenter.ts @@ -0,0 +1,31 @@ +export interface RaceListItemViewModel { + id: string; + track: string; + car: string; + scheduledAt: string; + status: 'scheduled' | 'running' | 'completed' | 'cancelled'; + leagueId: string; + leagueName: string; + strengthOfField: number | null; + isUpcoming: boolean; + isLive: boolean; + isPast: boolean; +} + +export interface RacesPageViewModel { + races: RaceListItemViewModel[]; + stats: { + total: number; + scheduled: number; + running: number; + completed: number; + }; + liveRaces: RaceListItemViewModel[]; + upcomingThisWeek: RaceListItemViewModel[]; + recentResults: RaceListItemViewModel[]; +} + +export interface IRacesPagePresenter { + present(races: any[]): void; + getViewModel(): RacesPageViewModel; +} \ No newline at end of file diff --git a/packages/racing/application/presenters/ISponsorDashboardPresenter.ts b/packages/racing/application/presenters/ISponsorDashboardPresenter.ts new file mode 100644 index 000000000..e0514aad8 --- /dev/null +++ b/packages/racing/application/presenters/ISponsorDashboardPresenter.ts @@ -0,0 +1,5 @@ +import type { SponsoredLeagueDTO, SponsorDashboardDTO } from '../use-cases/GetSponsorDashboardQuery'; + +export interface ISponsorDashboardPresenter { + present(data: SponsorDashboardDTO | null): void; +} \ No newline at end of file diff --git a/packages/racing/application/presenters/ISponsorSponsorshipsPresenter.ts b/packages/racing/application/presenters/ISponsorSponsorshipsPresenter.ts new file mode 100644 index 000000000..a234c5e2d --- /dev/null +++ b/packages/racing/application/presenters/ISponsorSponsorshipsPresenter.ts @@ -0,0 +1,5 @@ +import type { SponsorSponsorshipsDTO } from '../use-cases/GetSponsorSponsorshipsQuery'; + +export interface ISponsorSponsorshipsPresenter { + present(data: SponsorSponsorshipsDTO | null): void; +} \ No newline at end of file diff --git a/packages/racing/application/presenters/ITeamDetailsPresenter.ts b/packages/racing/application/presenters/ITeamDetailsPresenter.ts new file mode 100644 index 000000000..9aa5ea9de --- /dev/null +++ b/packages/racing/application/presenters/ITeamDetailsPresenter.ts @@ -0,0 +1,29 @@ +import type { Team, TeamMembership } from '../../domain/entities/Team'; + +export interface TeamDetailsViewModel { + team: { + id: string; + name: string; + tag: string; + description: string; + ownerId: string; + leagues: string[]; + specialization?: 'endurance' | 'sprint' | 'mixed'; + region?: string; + languages?: string[]; + }; + membership: { + role: 'owner' | 'manager' | 'member'; + joinedAt: string; + isActive: boolean; + } | null; + canManage: boolean; +} + +export interface ITeamDetailsPresenter { + present( + team: Team, + membership: TeamMembership | null, + driverId: string + ): TeamDetailsViewModel; +} \ No newline at end of file diff --git a/packages/racing/application/presenters/ITeamJoinRequestsPresenter.ts b/packages/racing/application/presenters/ITeamJoinRequestsPresenter.ts new file mode 100644 index 000000000..aae3c90b4 --- /dev/null +++ b/packages/racing/application/presenters/ITeamJoinRequestsPresenter.ts @@ -0,0 +1,25 @@ +import type { TeamJoinRequest } from '../../domain/entities/Team'; + +export interface TeamJoinRequestViewModel { + requestId: string; + driverId: string; + driverName: string; + teamId: string; + status: 'pending' | 'approved' | 'rejected'; + requestedAt: string; + avatarUrl: string; +} + +export interface TeamJoinRequestsViewModel { + requests: TeamJoinRequestViewModel[]; + pendingCount: number; + totalCount: number; +} + +export interface ITeamJoinRequestsPresenter { + present( + requests: TeamJoinRequest[], + driverNames: Record, + avatarUrls: Record + ): TeamJoinRequestsViewModel; +} \ No newline at end of file diff --git a/packages/racing/application/presenters/ITeamMembersPresenter.ts b/packages/racing/application/presenters/ITeamMembersPresenter.ts new file mode 100644 index 000000000..ec0257531 --- /dev/null +++ b/packages/racing/application/presenters/ITeamMembersPresenter.ts @@ -0,0 +1,26 @@ +import type { TeamMembership } from '../../domain/entities/Team'; + +export interface TeamMemberViewModel { + driverId: string; + driverName: string; + role: 'owner' | 'manager' | 'member'; + joinedAt: string; + isActive: boolean; + avatarUrl: string; +} + +export interface TeamMembersViewModel { + members: TeamMemberViewModel[]; + totalCount: number; + ownerCount: number; + managerCount: number; + memberCount: number; +} + +export interface ITeamMembersPresenter { + present( + memberships: TeamMembership[], + driverNames: Record, + avatarUrls: Record + ): TeamMembersViewModel; +} \ No newline at end of file diff --git a/packages/racing/application/presenters/ITeamsLeaderboardPresenter.ts b/packages/racing/application/presenters/ITeamsLeaderboardPresenter.ts new file mode 100644 index 000000000..82d772333 --- /dev/null +++ b/packages/racing/application/presenters/ITeamsLeaderboardPresenter.ts @@ -0,0 +1,27 @@ +export type SkillLevel = 'beginner' | 'intermediate' | 'advanced' | 'pro'; + +export interface TeamLeaderboardItemViewModel { + id: string; + name: string; + memberCount: number; + rating: number | null; + totalWins: number; + totalRaces: number; + performanceLevel: SkillLevel; + isRecruiting: boolean; + createdAt: Date; + description?: string; + specialization?: 'endurance' | 'sprint' | 'mixed'; + region?: string; + languages?: string[]; +} + +export interface TeamsLeaderboardViewModel { + teams: TeamLeaderboardItemViewModel[]; + recruitingCount: number; +} + +export interface ITeamsLeaderboardPresenter { + present(teams: any[], recruitingCount: number): void; + getViewModel(): TeamsLeaderboardViewModel; +} \ No newline at end of file diff --git a/packages/racing/application/use-cases/GetAllLeaguesWithCapacityAndScoringQuery.ts b/packages/racing/application/use-cases/GetAllLeaguesWithCapacityAndScoringQuery.ts index 415f0a4e6..f4ade0a45 100644 --- a/packages/racing/application/use-cases/GetAllLeaguesWithCapacityAndScoringQuery.ts +++ b/packages/racing/application/use-cases/GetAllLeaguesWithCapacityAndScoringQuery.ts @@ -3,23 +3,14 @@ import type { ILeagueMembershipRepository } from '../../domain/repositories/ILea import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository'; import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository'; import type { IGameRepository } from '../../domain/repositories/IGameRepository'; -import type { - LeagueScoringPresetProvider, - LeagueScoringPresetDTO, -} from '../ports/LeagueScoringPresetProvider'; -import type { - LeagueSummaryDTO, - LeagueSummaryScoringDTO, -} from '../dto/LeagueSummaryDTO'; +import type { LeagueScoringPresetProvider } from '../ports/LeagueScoringPresetProvider'; +import type { IAllLeaguesWithCapacityAndScoringPresenter, LeagueEnrichedData } from '../presenters/IAllLeaguesWithCapacityAndScoringPresenter'; /** - * Combined capacity + scoring summary query for leagues. - * - * Extends the behavior of GetAllLeaguesWithCapacityQuery by including - * scoring preset and game summaries when an active season and - * LeagueScoringConfig are available. + * Use Case for retrieving all leagues with capacity and scoring information. + * Orchestrates domain logic and delegates presentation to the presenter. */ -export class GetAllLeaguesWithCapacityAndScoringQuery { +export class GetAllLeaguesWithCapacityAndScoringUseCase { constructor( private readonly leagueRepository: ILeagueRepository, private readonly leagueMembershipRepository: ILeagueMembershipRepository, @@ -27,17 +18,16 @@ export class GetAllLeaguesWithCapacityAndScoringQuery { private readonly leagueScoringConfigRepository: ILeagueScoringConfigRepository, private readonly gameRepository: IGameRepository, private readonly presetProvider: LeagueScoringPresetProvider, + public readonly presenter: IAllLeaguesWithCapacityAndScoringPresenter, ) {} - async execute(): Promise { + async execute(): Promise { const leagues = await this.leagueRepository.findAll(); - const results: LeagueSummaryDTO[] = []; + const enrichedLeagues: LeagueEnrichedData[] = []; for (const league of leagues) { - const members = await this.leagueMembershipRepository.getLeagueMembers( - league.id, - ); + const members = await this.leagueMembershipRepository.getLeagueMembers(league.id); const usedDriverSlots = members.filter( (m) => @@ -48,116 +38,36 @@ export class GetAllLeaguesWithCapacityAndScoringQuery { m.role === 'member'), ).length; - const configuredMaxDrivers = league.settings.maxDrivers ?? usedDriverSlots; - const safeMaxDrivers = Math.max(configuredMaxDrivers, usedDriverSlots); + const seasons = await this.seasonRepository.findByLeagueId(league.id); + const activeSeason = seasons && seasons.length > 0 + ? seasons.find((s) => s.status === 'active') ?? seasons[0] + : undefined; - const scoringSummary = await this.buildScoringSummary(league.id); + let scoringConfig; + let game; + let preset; - const structureSummary = `Solo • ${safeMaxDrivers} drivers`; + if (activeSeason) { + scoringConfig = await this.leagueScoringConfigRepository.findBySeasonId(activeSeason.id); + if (scoringConfig) { + game = await this.gameRepository.findById(activeSeason.gameId); + const presetId = scoringConfig.scoringPresetId; + if (presetId) { + preset = this.presetProvider.getPresetById(presetId); + } + } + } - const qualifyingMinutes = 30; - const mainRaceMinutes = - typeof league.settings.sessionDuration === 'number' - ? league.settings.sessionDuration - : 40; - const timingSummary = `${qualifyingMinutes} min Quali • ${mainRaceMinutes} min Race`; - - const dto: LeagueSummaryDTO = { - id: league.id, - name: league.name, - description: league.description, - ownerId: league.ownerId, - createdAt: league.createdAt, - maxDrivers: safeMaxDrivers, + enrichedLeagues.push({ + league, usedDriverSlots, - maxTeams: undefined, - usedTeamSlots: undefined, - structureSummary, - scoringPatternSummary: scoringSummary?.scoringPatternSummary, - timingSummary, - scoring: scoringSummary, - }; - - results.push(dto); + season: activeSeason, + scoringConfig, + game, + preset, + }); } - return results; - } - - private async buildScoringSummary( - leagueId: string, - ): Promise { - const seasons = await this.seasonRepository.findByLeagueId(leagueId); - if (!seasons || seasons.length === 0) { - return undefined; - } - - const activeSeason = - seasons.find((s) => s.status === 'active') ?? seasons[0]; - - const scoringConfig = - await this.leagueScoringConfigRepository.findBySeasonId(activeSeason.id); - if (!scoringConfig) { - return undefined; - } - - const game = await this.gameRepository.findById(activeSeason.gameId); - if (!game) { - return undefined; - } - - const presetId = scoringConfig.scoringPresetId; - let preset: LeagueScoringPresetDTO | undefined; - if (presetId) { - preset = this.presetProvider.getPresetById(presetId); - } - - const dropPolicySummary = - preset?.dropPolicySummary ?? this.deriveDropPolicySummary(scoringConfig); - const primaryChampionshipType = - preset?.primaryChampionshipType ?? - (scoringConfig.championships[0]?.type ?? 'driver'); - - const scoringPresetName = preset?.name ?? 'Custom'; - const scoringPatternSummary = `${scoringPresetName} • ${dropPolicySummary}`; - - return { - gameId: game.id, - gameName: game.name, - primaryChampionshipType, - scoringPresetId: presetId ?? 'custom', - scoringPresetName, - dropPolicySummary, - scoringPatternSummary, - }; - } - - private deriveDropPolicySummary(config: { - championships: Array<{ - dropScorePolicy: { strategy: string; count?: number; dropCount?: number }; - }>; - }): string { - const championship = config.championships[0]; - if (!championship) { - return 'All results count'; - } - - const policy = championship.dropScorePolicy; - if (!policy || policy.strategy === 'none') { - return 'All results count'; - } - - if (policy.strategy === 'bestNResults' && typeof policy.count === 'number') { - return `Best ${policy.count} results count`; - } - - if ( - policy.strategy === 'dropWorstN' && - typeof policy.dropCount === 'number' - ) { - return `Worst ${policy.dropCount} results are dropped`; - } - - return 'Custom drop score rules'; + this.presenter.present(enrichedLeagues); } } \ No newline at end of file diff --git a/packages/racing/application/use-cases/GetAllLeaguesWithCapacityQuery.ts b/packages/racing/application/use-cases/GetAllLeaguesWithCapacityQuery.ts index 3a2a9184a..7b7ece98a 100644 --- a/packages/racing/application/use-cases/GetAllLeaguesWithCapacityQuery.ts +++ b/packages/racing/application/use-cases/GetAllLeaguesWithCapacityQuery.ts @@ -1,17 +1,22 @@ import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; -import type { LeagueDTO } from '../dto/LeagueDTO'; +import type { IAllLeaguesWithCapacityPresenter } from '../presenters/IAllLeaguesWithCapacityPresenter'; -export class GetAllLeaguesWithCapacityQuery { +/** + * Use Case for retrieving all leagues with capacity information. + * Orchestrates domain logic and delegates presentation to the presenter. + */ +export class GetAllLeaguesWithCapacityUseCase { constructor( private readonly leagueRepository: ILeagueRepository, private readonly leagueMembershipRepository: ILeagueMembershipRepository, + public readonly presenter: IAllLeaguesWithCapacityPresenter, ) {} - async execute(): Promise { + async execute(): Promise { const leagues = await this.leagueRepository.findAll(); - const results: LeagueDTO[] = []; + const memberCounts = new Map(); for (const league of leagues) { const members = await this.leagueMembershipRepository.getLeagueMembers(league.id); @@ -25,34 +30,9 @@ export class GetAllLeaguesWithCapacityQuery { m.role === 'member'), ).length; - // Ensure we never expose an impossible state like 26/24: - // clamp maxDrivers to at least usedSlots at the application boundary. - const configuredMax = league.settings.maxDrivers ?? usedSlots; - const safeMaxDrivers = Math.max(configuredMax, usedSlots); - - const dto: LeagueDTO = { - id: league.id, - name: league.name, - description: league.description, - ownerId: league.ownerId, - settings: { - ...league.settings, - maxDrivers: safeMaxDrivers, - }, - createdAt: league.createdAt.toISOString(), - socialLinks: league.socialLinks - ? { - discordUrl: league.socialLinks.discordUrl, - youtubeUrl: league.socialLinks.youtubeUrl, - websiteUrl: league.socialLinks.websiteUrl, - } - : undefined, - usedSlots, - }; - - results.push(dto); + memberCounts.set(league.id, usedSlots); } - return results; + this.presenter.present(leagues, memberCounts); } } \ No newline at end of file diff --git a/packages/racing/application/use-cases/GetAllTeamsQuery.ts b/packages/racing/application/use-cases/GetAllTeamsQuery.ts index 2159a8ba8..8f423e388 100644 --- a/packages/racing/application/use-cases/GetAllTeamsQuery.ts +++ b/packages/racing/application/use-cases/GetAllTeamsQuery.ts @@ -1,13 +1,32 @@ import type { ITeamRepository } from '../../domain/repositories/ITeamRepository'; -import type { GetAllTeamsQueryResultDTO } from '../dto/TeamCommandAndQueryDTO'; +import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; +import type { IAllTeamsPresenter } from '../presenters/IAllTeamsPresenter'; -export class GetAllTeamsQuery { +/** + * Use Case for retrieving all teams. + * Orchestrates domain logic and delegates presentation to the presenter. + */ +export class GetAllTeamsUseCase { constructor( private readonly teamRepository: ITeamRepository, + private readonly teamMembershipRepository: ITeamMembershipRepository, + public readonly presenter: IAllTeamsPresenter, ) {} - async execute(): Promise { + async execute(): Promise { const teams = await this.teamRepository.findAll(); - return teams; + + // Enrich teams with member counts + const enrichedTeams = await Promise.all( + teams.map(async (team) => { + const memberships = await this.teamMembershipRepository.findByTeamId(team.id); + return { + ...team, + memberCount: memberships.length, + }; + }) + ); + + this.presenter.present(enrichedTeams as any); } } \ No newline at end of file diff --git a/packages/racing/application/use-cases/GetDriverTeamQuery.ts b/packages/racing/application/use-cases/GetDriverTeamQuery.ts index 0bd62e64d..9d15d0f0a 100644 --- a/packages/racing/application/use-cases/GetDriverTeamQuery.ts +++ b/packages/racing/application/use-cases/GetDriverTeamQuery.ts @@ -1,29 +1,30 @@ import type { ITeamRepository } from '../../domain/repositories/ITeamRepository'; import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; -import type { - GetDriverTeamQueryParamsDTO, - GetDriverTeamQueryResultDTO, -} from '../dto/TeamCommandAndQueryDTO'; +import type { IDriverTeamPresenter } from '../presenters/IDriverTeamPresenter'; -export class GetDriverTeamQuery { +/** + * Use Case for retrieving a driver's team. + * Orchestrates domain logic and delegates presentation to the presenter. + */ +export class GetDriverTeamUseCase { constructor( private readonly teamRepository: ITeamRepository, private readonly membershipRepository: ITeamMembershipRepository, + public readonly presenter: IDriverTeamPresenter, ) {} - async execute(params: GetDriverTeamQueryParamsDTO): Promise { - const { driverId } = params; - + async execute(driverId: string): Promise { const membership = await this.membershipRepository.getActiveMembershipForDriver(driverId); if (!membership) { - return null; + return false; } const team = await this.teamRepository.findById(membership.teamId); if (!team) { - return null; + return false; } - return { team, membership }; + this.presenter.present(team, membership, driverId); + return true; } } \ No newline at end of file diff --git a/packages/racing/application/use-cases/GetDriversLeaderboardUseCase.ts b/packages/racing/application/use-cases/GetDriversLeaderboardUseCase.ts new file mode 100644 index 000000000..d6601f641 --- /dev/null +++ b/packages/racing/application/use-cases/GetDriversLeaderboardUseCase.ts @@ -0,0 +1,37 @@ +import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; +import type { IRankingService } from '../../domain/services/IRankingService'; +import type { IDriverStatsService } from '../../domain/services/IDriverStatsService'; +import type { IImageService } from '../../domain/services/IImageService'; +import type { IDriversLeaderboardPresenter } from '../presenters/IDriversLeaderboardPresenter'; + +/** + * Use Case for retrieving driver leaderboard data. + * Orchestrates domain logic and delegates presentation to the presenter. + */ +export class GetDriversLeaderboardUseCase { + constructor( + private readonly driverRepository: IDriverRepository, + private readonly rankingService: IRankingService, + private readonly driverStatsService: IDriverStatsService, + private readonly imageService: IImageService, + public readonly presenter: IDriversLeaderboardPresenter, + ) {} + + async execute(): Promise { + const drivers = await this.driverRepository.findAll(); + const rankings = this.rankingService.getAllDriverRankings(); + + const stats: Record = {}; + const avatarUrls: Record = {}; + + for (const driver of drivers) { + const driverStats = this.driverStatsService.getDriverStats(driver.id); + if (driverStats) { + stats[driver.id] = driverStats; + } + avatarUrls[driver.id] = this.imageService.getDriverAvatar(driver.id); + } + + this.presenter.present(drivers, rankings, stats, avatarUrls); + } +} \ No newline at end of file diff --git a/packages/racing/application/use-cases/GetEntitySponsorshipPricingQuery.ts b/packages/racing/application/use-cases/GetEntitySponsorshipPricingQuery.ts index 4d0e77091..bc59e47dd 100644 --- a/packages/racing/application/use-cases/GetEntitySponsorshipPricingQuery.ts +++ b/packages/racing/application/use-cases/GetEntitySponsorshipPricingQuery.ts @@ -1,6 +1,6 @@ /** - * Query: GetEntitySponsorshipPricingQuery - * + * Application Use Case: GetEntitySponsorshipPricingUseCase + * * Retrieves sponsorship pricing configuration for any entity. * Used by sponsors to see available slots and prices. */ @@ -10,6 +10,7 @@ import type { ISponsorshipRequestRepository } from '../../domain/repositories/IS import type { ISeasonSponsorshipRepository } from '../../domain/repositories/ISeasonSponsorshipRepository'; import type { SponsorableEntityType } from '../../domain/entities/SponsorshipRequest'; import type { SponsorshipTier } from '../../domain/entities/SeasonSponsorship'; +import type { IEntitySponsorshipPricingPresenter } from '../presenters/IEntitySponsorshipPricingPresenter'; export interface GetEntitySponsorshipPricingDTO { entityType: SponsorableEntityType; @@ -37,18 +38,20 @@ export interface GetEntitySponsorshipPricingResultDTO { secondarySlot?: SponsorshipSlotDTO; } -export class GetEntitySponsorshipPricingQuery { +export class GetEntitySponsorshipPricingUseCase { constructor( private readonly sponsorshipPricingRepo: ISponsorshipPricingRepository, private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository, private readonly seasonSponsorshipRepo: ISeasonSponsorshipRepository, + private readonly presenter: IEntitySponsorshipPricingPresenter, ) {} - async execute(dto: GetEntitySponsorshipPricingDTO): Promise { + async execute(dto: GetEntitySponsorshipPricingDTO): Promise { const pricing = await this.sponsorshipPricingRepo.findByEntity(dto.entityType, dto.entityId); if (!pricing) { - return null; + this.presenter.present(null); + return; } // Count pending requests by tier @@ -107,6 +110,6 @@ export class GetEntitySponsorshipPricingQuery { }; } - return result; + this.presenter.present(result); } } \ No newline at end of file diff --git a/packages/racing/application/use-cases/GetLeagueDriverSeasonStatsQuery.ts b/packages/racing/application/use-cases/GetLeagueDriverSeasonStatsQuery.ts index f83105eaf..81c7ea538 100644 --- a/packages/racing/application/use-cases/GetLeagueDriverSeasonStatsQuery.ts +++ b/packages/racing/application/use-cases/GetLeagueDriverSeasonStatsQuery.ts @@ -2,26 +2,31 @@ import type { IStandingRepository } from '../../domain/repositories/IStandingRep import type { IResultRepository } from '../../domain/repositories/IResultRepository'; import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository'; import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; -import type { LeagueDriverSeasonStatsDTO } from '../dto/LeagueDriverSeasonStatsDTO'; +import type { ILeagueDriverSeasonStatsPresenter } from '../presenters/ILeagueDriverSeasonStatsPresenter'; export interface DriverRatingPort { getRating(driverId: string): { rating: number | null; ratingChange: number | null }; } -export interface GetLeagueDriverSeasonStatsQueryParamsDTO { +export interface GetLeagueDriverSeasonStatsUseCaseParams { leagueId: string; } -export class GetLeagueDriverSeasonStatsQuery { +/** + * Use Case for retrieving league driver season statistics. + * Orchestrates domain logic and delegates presentation to the presenter. + */ +export class GetLeagueDriverSeasonStatsUseCase { constructor( private readonly standingRepository: IStandingRepository, private readonly resultRepository: IResultRepository, private readonly penaltyRepository: IPenaltyRepository, private readonly raceRepository: IRaceRepository, private readonly driverRatingPort: DriverRatingPort, + public readonly presenter: ILeagueDriverSeasonStatsPresenter, ) {} - async execute(params: GetLeagueDriverSeasonStatsQueryParamsDTO): Promise { + async execute(params: GetLeagueDriverSeasonStatsUseCaseParams): Promise { const { leagueId } = params; // Get standings and races for the league @@ -53,59 +58,26 @@ export class GetLeagueDriverSeasonStatsQuery { penaltiesByDriver.set(p.driverId, current); } - // Build basic stats per driver from standings - const statsByDriver = new Map(); - + // Collect driver ratings + const driverRatings = new Map(); for (const standing of standings) { - const penalty = penaltiesByDriver.get(standing.driverId) ?? { baseDelta: 0, bonusDelta: 0 }; - const totalPenaltyPoints = penalty.baseDelta; - const bonusPoints = penalty.bonusDelta; - - const racesCompleted = standing.racesCompleted; - const pointsPerRace = racesCompleted > 0 ? standing.points / racesCompleted : 0; - const ratingInfo = this.driverRatingPort.getRating(standing.driverId); - - const dto: LeagueDriverSeasonStatsDTO = { - leagueId, - driverId: standing.driverId, - position: standing.position, - driverName: '', - teamId: undefined, - teamName: undefined, - totalPoints: standing.points + totalPenaltyPoints + bonusPoints, - basePoints: standing.points, - penaltyPoints: Math.abs(totalPenaltyPoints), - bonusPoints, - pointsPerRace, - racesStarted: racesCompleted, - racesFinished: racesCompleted, - dnfs: 0, - noShows: 0, - avgFinish: null, - rating: ratingInfo.rating, - ratingChange: ratingInfo.ratingChange, - }; - - statsByDriver.set(standing.driverId, dto); + driverRatings.set(standing.driverId, ratingInfo); } - // Enhance stats with basic finish-position-based avgFinish from results - for (const [driverId, dto] of statsByDriver.entries()) { - const driverResults = await this.resultRepository.findByDriverIdAndLeagueId(driverId, leagueId); - if (driverResults.length > 0) { - const totalPositions = driverResults.reduce((sum, r) => sum + r.position, 0); - const avgFinish = totalPositions / driverResults.length; - dto.avgFinish = Number.isFinite(avgFinish) ? Number(avgFinish.toFixed(2)) : null; - dto.racesStarted = driverResults.length; - dto.racesFinished = driverResults.length; - } - statsByDriver.set(driverId, dto); + // Collect driver results + const driverResults = new Map>(); + for (const standing of standings) { + const results = await this.resultRepository.findByDriverIdAndLeagueId(standing.driverId, leagueId); + driverResults.set(standing.driverId, results); } - // Ensure ordering by position - const result = Array.from(statsByDriver.values()).sort((a, b) => a.position - b.position); - - return result; + this.presenter.present( + leagueId, + standings, + penaltiesByDriver, + driverResults, + driverRatings + ); } } \ No newline at end of file diff --git a/packages/racing/application/use-cases/GetLeagueFullConfigQuery.ts b/packages/racing/application/use-cases/GetLeagueFullConfigQuery.ts index 38b815341..31be1b682 100644 --- a/packages/racing/application/use-cases/GetLeagueFullConfigQuery.ts +++ b/packages/racing/application/use-cases/GetLeagueFullConfigQuery.ts @@ -2,153 +2,52 @@ import type { ILeagueRepository } from '../../domain/repositories/ILeagueReposit import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository'; import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository'; import type { IGameRepository } from '../../domain/repositories/IGameRepository'; -import type { ChampionshipConfig } from '../../domain/value-objects/ChampionshipConfig'; -import type { DropScorePolicy } from '../../domain/value-objects/DropScorePolicy'; -import type { - LeagueConfigFormModel, - LeagueDropPolicyFormDTO, -} from '../dto/LeagueConfigFormDTO'; +import type { ILeagueFullConfigPresenter, LeagueFullConfigData } from '../presenters/ILeagueFullConfigPresenter'; /** - * Query returning a unified LeagueConfigFormModel for a given league. - * - * First iteration focuses on: - * - Basics derived from League - * - Simple solo structure derived from League.settings.maxDrivers - * - Championships flags with driver enabled and others disabled - * - Scoring pattern id taken from LeagueScoringConfig.scoringPresetId - * - Drop policy inferred from the primary championship configuration + * Use Case for retrieving a league's full configuration. + * Orchestrates domain logic and delegates presentation to the presenter. */ -export class GetLeagueFullConfigQuery { +export class GetLeagueFullConfigUseCase { constructor( private readonly leagueRepository: ILeagueRepository, private readonly seasonRepository: ISeasonRepository, private readonly leagueScoringConfigRepository: ILeagueScoringConfigRepository, private readonly gameRepository: IGameRepository, + public readonly presenter: ILeagueFullConfigPresenter, ) {} - async execute(params: { leagueId: string }): Promise { + async execute(params: { leagueId: string }): Promise { const { leagueId } = params; const league = await this.leagueRepository.findById(leagueId); if (!league) { - return null; + throw new Error(`League ${leagueId} not found`); } const seasons = await this.seasonRepository.findByLeagueId(leagueId); const activeSeason = seasons && seasons.length > 0 ? seasons.find((s) => s.status === 'active') ?? seasons[0] - : null; - - const scoringConfig = activeSeason - ? await this.leagueScoringConfigRepository.findBySeasonId(activeSeason.id) - : null; - - const game = - activeSeason && activeSeason.gameId - ? await this.gameRepository.findById(activeSeason.gameId) - : null; - - const patternId = scoringConfig?.scoringPresetId; - - const primaryChampionship: ChampionshipConfig | undefined = - scoringConfig && scoringConfig.championships && scoringConfig.championships.length > 0 - ? scoringConfig.championships[0] : undefined; - const dropPolicy: DropScorePolicy | undefined = - primaryChampionship?.dropScorePolicy ?? undefined; + let scoringConfig; + let game; - const dropPolicyForm: LeagueDropPolicyFormDTO = this.mapDropPolicy(dropPolicy); - - const defaultQualifyingMinutes = 30; - const defaultMainRaceMinutes = 40; - const mainRaceMinutes = - typeof league.settings.sessionDuration === 'number' - ? league.settings.sessionDuration - : defaultMainRaceMinutes; - const qualifyingMinutes = defaultQualifyingMinutes; - - const roundsPlanned = 8; - - let sessionCount = 2; - if ( - primaryChampionship && - Array.isArray((primaryChampionship as any).sessionTypes) && - (primaryChampionship as any).sessionTypes.length > 0 - ) { - sessionCount = (primaryChampionship as any).sessionTypes.length; + if (activeSeason) { + scoringConfig = await this.leagueScoringConfigRepository.findBySeasonId(activeSeason.id); + if (activeSeason.gameId) { + game = await this.gameRepository.findById(activeSeason.gameId); + } } - const practiceMinutes = 20; - const sprintRaceMinutes = patternId === 'sprint-main-driver' ? 20 : undefined; - - const form: LeagueConfigFormModel = { - leagueId: league.id, - basics: { - name: league.name, - description: league.description, - visibility: 'public', // current domain model does not track visibility; default to public for now - gameId: game?.id ?? 'iracing', - }, - structure: { - // First slice: treat everything as solo structure based on maxDrivers - mode: 'solo', - maxDrivers: league.settings.maxDrivers ?? 32, - maxTeams: undefined, - driversPerTeam: undefined, - multiClassEnabled: false, - }, - championships: { - enableDriverChampionship: true, - enableTeamChampionship: false, - enableNationsChampionship: false, - enableTrophyChampionship: false, - }, - scoring: { - patternId: patternId ?? undefined, - customScoringEnabled: !patternId, - }, - dropPolicy: dropPolicyForm, - timings: { - practiceMinutes, - qualifyingMinutes, - sprintRaceMinutes, - mainRaceMinutes, - sessionCount, - roundsPlanned, - }, - stewarding: { - decisionMode: 'admin_only', - requireDefense: true, - defenseTimeLimit: 48, - voteTimeLimit: 72, - protestDeadlineHours: 72, - stewardingClosesHours: 168, - notifyAccusedOnProtest: true, - notifyOnVoteRequired: true, - }, + const data: LeagueFullConfigData = { + league, + activeSeason, + scoringConfig, + game, }; - return form; - } - - private mapDropPolicy(policy: DropScorePolicy | undefined): LeagueDropPolicyFormDTO { - if (!policy || policy.strategy === 'none') { - return { strategy: 'none' }; - } - - if (policy.strategy === 'bestNResults') { - const n = typeof policy.count === 'number' ? policy.count : undefined; - return n !== undefined ? { strategy: 'bestNResults', n } : { strategy: 'none' }; - } - - if (policy.strategy === 'dropWorstN') { - const n = typeof policy.dropCount === 'number' ? policy.dropCount : undefined; - return n !== undefined ? { strategy: 'dropWorstN', n } : { strategy: 'none' }; - } - - return { strategy: 'none' }; + this.presenter.present(data); } } \ No newline at end of file diff --git a/packages/racing/application/use-cases/GetLeagueScoringConfigQuery.ts b/packages/racing/application/use-cases/GetLeagueScoringConfigQuery.ts index 4a1c693a3..180a7ade4 100644 --- a/packages/racing/application/use-cases/GetLeagueScoringConfigQuery.ts +++ b/packages/racing/application/use-cases/GetLeagueScoringConfigQuery.ts @@ -2,41 +2,34 @@ import type { ILeagueRepository } from '../../domain/repositories/ILeagueReposit import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository'; import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository'; import type { IGameRepository } from '../../domain/repositories/IGameRepository'; -import type { LeagueScoringConfigDTO } from '../dto/LeagueScoringConfigDTO'; -import type { LeagueScoringChampionshipDTO } from '../dto/LeagueScoringConfigDTO'; -import type { - LeagueScoringPresetProvider, - LeagueScoringPresetDTO, -} from '../ports/LeagueScoringPresetProvider'; -import type { ChampionshipConfig } from '../../domain/value-objects/ChampionshipConfig'; -import type { PointsTable } from '../../domain/value-objects/PointsTable'; -import type { BonusRule } from '../../domain/value-objects/BonusRule'; +import type { LeagueScoringPresetProvider } from '../ports/LeagueScoringPresetProvider'; +import type { ILeagueScoringConfigPresenter, LeagueScoringConfigData } from '../presenters/ILeagueScoringConfigPresenter'; /** - * Query returning a league's scoring configuration for its active season. - * - * Designed for the league detail "Scoring" tab. + * Use Case for retrieving a league's scoring configuration for its active season. + * Orchestrates domain logic and delegates presentation to the presenter. */ -export class GetLeagueScoringConfigQuery { +export class GetLeagueScoringConfigUseCase { constructor( private readonly leagueRepository: ILeagueRepository, private readonly seasonRepository: ISeasonRepository, private readonly leagueScoringConfigRepository: ILeagueScoringConfigRepository, private readonly gameRepository: IGameRepository, private readonly presetProvider: LeagueScoringPresetProvider, + public readonly presenter: ILeagueScoringConfigPresenter, ) {} - async execute(params: { leagueId: string }): Promise { + async execute(params: { leagueId: string }): Promise { const { leagueId } = params; const league = await this.leagueRepository.findById(leagueId); if (!league) { - return null; + throw new Error(`League ${leagueId} not found`); } const seasons = await this.seasonRepository.findByLeagueId(leagueId); if (!seasons || seasons.length === 0) { - return null; + throw new Error(`No seasons found for league ${leagueId}`); } const activeSeason = @@ -45,146 +38,27 @@ export class GetLeagueScoringConfigQuery { const scoringConfig = await this.leagueScoringConfigRepository.findBySeasonId(activeSeason.id); if (!scoringConfig) { - return null; + throw new Error(`No scoring config found for season ${activeSeason.id}`); } const game = await this.gameRepository.findById(activeSeason.gameId); if (!game) { - return null; + throw new Error(`Game ${activeSeason.gameId} not found`); } const presetId = scoringConfig.scoringPresetId; - const preset: LeagueScoringPresetDTO | undefined = - presetId ? this.presetProvider.getPresetById(presetId) : undefined; + const preset = presetId ? this.presetProvider.getPresetById(presetId) : undefined; - const championships: LeagueScoringChampionshipDTO[] = - scoringConfig.championships.map((champ) => - this.mapChampionship(champ), - ); - - const dropPolicySummary = - preset?.dropPolicySummary ?? - this.deriveDropPolicyDescriptionFromChampionships( - scoringConfig.championships, - ); - - return { + const data: LeagueScoringConfigData = { leagueId: league.id, seasonId: activeSeason.id, gameId: game.id, gameName: game.name, scoringPresetId: presetId, - scoringPresetName: preset?.name, - dropPolicySummary, - championships, + preset, + championships: scoringConfig.championships, }; - } - private mapChampionship(championship: ChampionshipConfig): LeagueScoringChampionshipDTO { - const sessionTypes = championship.sessionTypes.map((s) => s.toString()); - const pointsPreview = this.buildPointsPreview(championship.pointsTableBySessionType); - const bonusSummary = this.buildBonusSummary( - championship.bonusRulesBySessionType ?? {}, - ); - const dropPolicyDescription = this.deriveDropPolicyDescription( - championship.dropScorePolicy, - ); - - return { - id: championship.id, - name: championship.name, - type: championship.type, - sessionTypes, - pointsPreview, - bonusSummary, - dropPolicyDescription, - }; - } - - private buildPointsPreview( - tables: Record, - ): Array<{ sessionType: string; position: number; points: number }> { - const preview: Array<{ - sessionType: string; - position: number; - points: number; - }> = []; - - const maxPositions = 10; - - for (const [sessionType, table] of Object.entries(tables)) { - for (let pos = 1; pos <= maxPositions; pos++) { - const points = table.getPointsForPosition(pos); - if (points && points !== 0) { - preview.push({ - sessionType, - position: pos, - points, - }); - } - } - } - - return preview; - } - - private buildBonusSummary( - bonusRulesBySessionType: Record, - ): string[] { - const summaries: string[] = []; - - for (const [sessionType, rules] of Object.entries(bonusRulesBySessionType)) { - for (const rule of rules) { - if (rule.type === 'fastestLap') { - const base = `Fastest lap in ${sessionType}`; - if (rule.requiresFinishInTopN) { - summaries.push( - `${base} +${rule.points} points if finishing P${rule.requiresFinishInTopN} or better`, - ); - } else { - summaries.push(`${base} +${rule.points} points`); - } - } else { - summaries.push( - `${rule.type} bonus in ${sessionType} worth ${rule.points} points`, - ); - } - } - } - - return summaries; - } - - private deriveDropPolicyDescriptionFromChampionships( - championships: ChampionshipConfig[], - ): string { - const first = championships[0]; - if (!first) { - return 'All results count'; - } - return this.deriveDropPolicyDescription(first.dropScorePolicy); - } - - private deriveDropPolicyDescription(policy: { - strategy: string; - count?: number; - dropCount?: number; - }): string { - if (!policy || policy.strategy === 'none') { - return 'All results count'; - } - - if (policy.strategy === 'bestNResults' && typeof policy.count === 'number') { - return `Best ${policy.count} results count towards the championship`; - } - - if ( - policy.strategy === 'dropWorstN' && - typeof policy.dropCount === 'number' - ) { - return `Worst ${policy.dropCount} results are dropped from the championship total`; - } - - return 'Custom drop score rules apply'; + this.presenter.present(data); } } \ No newline at end of file diff --git a/packages/racing/application/use-cases/GetLeagueStandingsQuery.ts b/packages/racing/application/use-cases/GetLeagueStandingsQuery.ts index 5e55bd26d..e964f74d8 100644 --- a/packages/racing/application/use-cases/GetLeagueStandingsQuery.ts +++ b/packages/racing/application/use-cases/GetLeagueStandingsQuery.ts @@ -1,18 +1,22 @@ import type { IStandingRepository } from '../../domain/repositories/IStandingRepository'; -import type { StandingDTO } from '../dto/StandingDTO'; -import { EntityMappers } from '../mappers/EntityMappers'; +import type { ILeagueStandingsPresenter } from '../presenters/ILeagueStandingsPresenter'; -export interface GetLeagueStandingsQueryParamsDTO { +export interface GetLeagueStandingsUseCaseParams { leagueId: string; } -export class GetLeagueStandingsQuery { +/** + * Use Case for retrieving league standings. + * Orchestrates domain logic and delegates presentation to the presenter. + */ +export class GetLeagueStandingsUseCase { constructor( private readonly standingRepository: IStandingRepository, + public readonly presenter: ILeagueStandingsPresenter, ) {} - async execute(params: GetLeagueStandingsQueryParamsDTO): Promise { + async execute(params: GetLeagueStandingsUseCaseParams): Promise { const standings = await this.standingRepository.findByLeagueId(params.leagueId); - return EntityMappers.toStandingDTOs(standings); + this.presenter.present(standings); } } \ No newline at end of file diff --git a/packages/racing/application/use-cases/GetLeagueStatsQuery.ts b/packages/racing/application/use-cases/GetLeagueStatsQuery.ts index a3e6c5eed..00c69a6f8 100644 --- a/packages/racing/application/use-cases/GetLeagueStatsQuery.ts +++ b/packages/racing/application/use-cases/GetLeagueStatsQuery.ts @@ -1,33 +1,26 @@ /** - * Application Query: GetLeagueStatsQuery - * - * Returns league statistics including average SOF across completed races. + * Use Case for retrieving league statistics. + * Orchestrates domain logic and delegates presentation to the presenter. */ import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; import type { IResultRepository } from '../../domain/repositories/IResultRepository'; import type { DriverRatingProvider } from '../ports/DriverRatingProvider'; +import type { ILeagueStatsPresenter } from '../presenters/ILeagueStatsPresenter'; import { AverageStrengthOfFieldCalculator, type StrengthOfFieldCalculator, } from '../../domain/services/StrengthOfFieldCalculator'; -export interface GetLeagueStatsQueryParams { +export interface GetLeagueStatsUseCaseParams { leagueId: string; } -export interface LeagueStatsDTO { - leagueId: string; - totalRaces: number; - completedRaces: number; - scheduledRaces: number; - averageSOF: number | null; - highestSOF: number | null; - lowestSOF: number | null; -} - -export class GetLeagueStatsQuery { +/** + * Use Case for retrieving league statistics including average SOF across completed races. + */ +export class GetLeagueStatsUseCase { private readonly sofCalculator: StrengthOfFieldCalculator; constructor( @@ -35,17 +28,18 @@ export class GetLeagueStatsQuery { private readonly raceRepository: IRaceRepository, private readonly resultRepository: IResultRepository, private readonly driverRatingProvider: DriverRatingProvider, + public readonly presenter: ILeagueStatsPresenter, sofCalculator?: StrengthOfFieldCalculator, ) { this.sofCalculator = sofCalculator ?? new AverageStrengthOfFieldCalculator(); } - async execute(params: GetLeagueStatsQueryParams): Promise { + async execute(params: GetLeagueStatsUseCaseParams): Promise { const { leagueId } = params; const league = await this.leagueRepository.findById(leagueId); if (!league) { - return null; + throw new Error(`League ${leagueId} not found`); } const races = await this.raceRepository.findByLeagueId(leagueId); @@ -78,22 +72,12 @@ export class GetLeagueStatsQuery { } } - // Calculate aggregate stats - const averageSOF = sofValues.length > 0 - ? Math.round(sofValues.reduce((a, b) => a + b, 0) / sofValues.length) - : null; - - const highestSOF = sofValues.length > 0 ? Math.max(...sofValues) : null; - const lowestSOF = sofValues.length > 0 ? Math.min(...sofValues) : null; - - return { + this.presenter.present( leagueId, - totalRaces: races.length, - completedRaces: completedRaces.length, - scheduledRaces: scheduledRaces.length, - averageSOF, - highestSOF, - lowestSOF, - }; + races.length, + completedRaces.length, + scheduledRaces.length, + sofValues + ); } } \ No newline at end of file diff --git a/packages/racing/application/use-cases/GetPendingSponsorshipRequestsQuery.ts b/packages/racing/application/use-cases/GetPendingSponsorshipRequestsQuery.ts index e459988e9..bf438ade1 100644 --- a/packages/racing/application/use-cases/GetPendingSponsorshipRequestsQuery.ts +++ b/packages/racing/application/use-cases/GetPendingSponsorshipRequestsQuery.ts @@ -1,6 +1,6 @@ /** - * Query: GetPendingSponsorshipRequestsQuery - * + * Application Use Case: GetPendingSponsorshipRequestsUseCase + * * Retrieves pending sponsorship requests for an entity owner to review. */ @@ -8,6 +8,7 @@ import type { ISponsorshipRequestRepository } from '../../domain/repositories/IS import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository'; import type { SponsorableEntityType } from '../../domain/entities/SponsorshipRequest'; import type { SponsorshipTier } from '../../domain/entities/SeasonSponsorship'; +import type { IPendingSponsorshipRequestsPresenter } from '../presenters/IPendingSponsorshipRequestsPresenter'; export interface GetPendingSponsorshipRequestsDTO { entityType: SponsorableEntityType; @@ -36,13 +37,14 @@ export interface GetPendingSponsorshipRequestsResultDTO { totalCount: number; } -export class GetPendingSponsorshipRequestsQuery { +export class GetPendingSponsorshipRequestsUseCase { constructor( private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository, private readonly sponsorRepo: ISponsorRepository, + private readonly presenter: IPendingSponsorshipRequestsPresenter, ) {} - async execute(dto: GetPendingSponsorshipRequestsDTO): Promise { + async execute(dto: GetPendingSponsorshipRequestsDTO): Promise { const requests = await this.sponsorshipRequestRepo.findPendingByEntity( dto.entityType, dto.entityId @@ -72,11 +74,11 @@ export class GetPendingSponsorshipRequestsQuery { // Sort by creation date (newest first) requestDTOs.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); - return { + this.presenter.present({ entityType: dto.entityType, entityId: dto.entityId, requests: requestDTOs, totalCount: requestDTOs.length, - }; + }); } } \ No newline at end of file diff --git a/packages/racing/application/use-cases/GetRacePenaltiesQuery.ts b/packages/racing/application/use-cases/GetRacePenaltiesQuery.ts index efbde37e4..752dff700 100644 --- a/packages/racing/application/use-cases/GetRacePenaltiesQuery.ts +++ b/packages/racing/application/use-cases/GetRacePenaltiesQuery.ts @@ -1,38 +1,22 @@ /** - * Application Query: GetRacePenaltiesQuery - * + * Use Case: GetRacePenaltiesUseCase + * * Returns all penalties applied for a specific race, with driver details. + * Orchestrates domain logic and delegates presentation to the presenter. */ import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository'; import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; -import type { PenaltyType, PenaltyStatus } from '../../domain/entities/Penalty'; +import type { IRacePenaltiesPresenter } from '../presenters/IRacePenaltiesPresenter'; -export interface RacePenaltyDTO { - id: string; - raceId: string; - driverId: string; - driverName: string; - type: PenaltyType; - value?: number; - reason: string; - protestId?: string; - issuedBy: string; - issuedByName: string; - status: PenaltyStatus; - description: string; - issuedAt: string; - appliedAt?: string; - notes?: string; -} - -export class GetRacePenaltiesQuery { +export class GetRacePenaltiesUseCase { constructor( private readonly penaltyRepository: IPenaltyRepository, private readonly driverRepository: IDriverRepository, + public readonly presenter: IRacePenaltiesPresenter, ) {} - async execute(raceId: string): Promise { + async execute(raceId: string): Promise { const penalties = await this.penaltyRepository.findByRaceId(raceId); // Load all driver details in parallel @@ -53,22 +37,6 @@ export class GetRacePenaltiesQuery { } }); - return penalties.map(penalty => ({ - id: penalty.id, - raceId: penalty.raceId, - driverId: penalty.driverId, - driverName: driverMap.get(penalty.driverId) || 'Unknown', - type: penalty.type, - value: penalty.value, - reason: penalty.reason, - protestId: penalty.protestId, - issuedBy: penalty.issuedBy, - issuedByName: driverMap.get(penalty.issuedBy) || 'Unknown', - status: penalty.status, - description: penalty.getDescription(), - issuedAt: penalty.issuedAt.toISOString(), - appliedAt: penalty.appliedAt?.toISOString(), - notes: penalty.notes, - })); + this.presenter.present(penalties, driverMap); } } \ No newline at end of file diff --git a/packages/racing/application/use-cases/GetRaceProtestsQuery.ts b/packages/racing/application/use-cases/GetRaceProtestsQuery.ts index a8dcc2223..24aee1f4e 100644 --- a/packages/racing/application/use-cases/GetRaceProtestsQuery.ts +++ b/packages/racing/application/use-cases/GetRaceProtestsQuery.ts @@ -1,38 +1,22 @@ /** - * Application Query: GetRaceProtestsQuery - * + * Use Case: GetRaceProtestsUseCase + * * Returns all protests filed for a specific race, with driver details. + * Orchestrates domain logic and delegates presentation to the presenter. */ import type { IProtestRepository } from '../../domain/repositories/IProtestRepository'; import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; -import type { ProtestStatus, ProtestIncident } from '../../domain/entities/Protest'; +import type { IRaceProtestsPresenter } from '../presenters/IRaceProtestsPresenter'; -export interface RaceProtestDTO { - id: string; - raceId: string; - protestingDriverId: string; - protestingDriverName: string; - accusedDriverId: string; - accusedDriverName: string; - incident: ProtestIncident; - comment?: string; - proofVideoUrl?: string; - status: ProtestStatus; - reviewedBy?: string; - reviewedByName?: string; - decisionNotes?: string; - filedAt: string; - reviewedAt?: string; -} - -export class GetRaceProtestsQuery { +export class GetRaceProtestsUseCase { constructor( private readonly protestRepository: IProtestRepository, private readonly driverRepository: IDriverRepository, + public readonly presenter: IRaceProtestsPresenter, ) {} - async execute(raceId: string): Promise { + async execute(raceId: string): Promise { const protests = await this.protestRepository.findByRaceId(raceId); // Load all driver details in parallel @@ -56,22 +40,6 @@ export class GetRaceProtestsQuery { } }); - return protests.map(protest => ({ - id: protest.id, - raceId: protest.raceId, - protestingDriverId: protest.protestingDriverId, - protestingDriverName: driverMap.get(protest.protestingDriverId) || 'Unknown', - accusedDriverId: protest.accusedDriverId, - accusedDriverName: driverMap.get(protest.accusedDriverId) || 'Unknown', - incident: protest.incident, - comment: protest.comment, - proofVideoUrl: protest.proofVideoUrl, - status: protest.status, - reviewedBy: protest.reviewedBy, - reviewedByName: protest.reviewedBy ? driverMap.get(protest.reviewedBy) : undefined, - decisionNotes: protest.decisionNotes, - filedAt: protest.filedAt.toISOString(), - reviewedAt: protest.reviewedAt?.toISOString(), - })); + this.presenter.present(protests, driverMap); } } \ No newline at end of file diff --git a/packages/racing/application/use-cases/GetRaceRegistrationsQuery.ts b/packages/racing/application/use-cases/GetRaceRegistrationsQuery.ts index da18381a7..9f379948f 100644 --- a/packages/racing/application/use-cases/GetRaceRegistrationsQuery.ts +++ b/packages/racing/application/use-cases/GetRaceRegistrationsQuery.ts @@ -1,17 +1,22 @@ import type { IRaceRegistrationRepository } from '@gridpilot/racing/domain/repositories/IRaceRegistrationRepository'; import type { GetRaceRegistrationsQueryParamsDTO } from '../dto/RaceRegistrationQueryDTO'; +import type { IRaceRegistrationsPresenter } from '../presenters/IRaceRegistrationsPresenter'; /** - * Query object returning registered driver IDs for a race. - * Mirrors legacy getRegisteredDrivers behavior. + * Use Case: GetRaceRegistrationsUseCase + * + * Returns registered driver IDs for a race. + * Orchestrates domain logic and delegates presentation to the presenter. */ -export class GetRaceRegistrationsQuery { +export class GetRaceRegistrationsUseCase { constructor( private readonly registrationRepository: IRaceRegistrationRepository, + public readonly presenter: IRaceRegistrationsPresenter, ) {} - async execute(params: GetRaceRegistrationsQueryParamsDTO): Promise { + async execute(params: GetRaceRegistrationsQueryParamsDTO): Promise { const { raceId } = params; - return this.registrationRepository.getRegisteredDrivers(raceId); + const registeredDriverIds = await this.registrationRepository.getRegisteredDrivers(raceId); + this.presenter.present(registeredDriverIds); } } \ No newline at end of file diff --git a/packages/racing/application/use-cases/GetRaceWithSOFQuery.ts b/packages/racing/application/use-cases/GetRaceWithSOFQuery.ts index 7ef5c9f27..c6ad29713 100644 --- a/packages/racing/application/use-cases/GetRaceWithSOFQuery.ts +++ b/packages/racing/application/use-cases/GetRaceWithSOFQuery.ts @@ -1,8 +1,9 @@ /** - * Application Query: GetRaceWithSOFQuery - * + * Use Case: GetRaceWithSOFUseCase + * * Returns race details enriched with calculated Strength of Field (SOF). * SOF is calculated from participant ratings if not already stored on the race. + * Orchestrates domain logic and delegates presentation to the presenter. */ import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; @@ -13,18 +14,13 @@ import { AverageStrengthOfFieldCalculator, type StrengthOfFieldCalculator, } from '../../domain/services/StrengthOfFieldCalculator'; -import type { RaceDTO } from '../dto/RaceDTO'; +import type { IRaceWithSOFPresenter } from '../presenters/IRaceWithSOFPresenter'; export interface GetRaceWithSOFQueryParams { raceId: string; } -export interface RaceWithSOFDTO extends Omit { - strengthOfField: number | null; - participantCount: number; -} - -export class GetRaceWithSOFQuery { +export class GetRaceWithSOFUseCase { private readonly sofCalculator: StrengthOfFieldCalculator; constructor( @@ -32,12 +28,13 @@ export class GetRaceWithSOFQuery { private readonly registrationRepository: IRaceRegistrationRepository, private readonly resultRepository: IResultRepository, private readonly driverRatingProvider: DriverRatingProvider, + public readonly presenter: IRaceWithSOFPresenter, sofCalculator?: StrengthOfFieldCalculator, ) { this.sofCalculator = sofCalculator ?? new AverageStrengthOfFieldCalculator(); } - async execute(params: GetRaceWithSOFQueryParams): Promise { + async execute(params: GetRaceWithSOFQueryParams): Promise { const { raceId } = params; const race = await this.raceRepository.findById(raceId); @@ -69,20 +66,20 @@ export class GetRaceWithSOFQuery { strengthOfField = this.sofCalculator.calculate(driverRatings); } - return { - id: race.id, - leagueId: race.leagueId, - scheduledAt: race.scheduledAt.toISOString(), - track: race.track, - trackId: race.trackId, - car: race.car, - carId: race.carId, - sessionType: race.sessionType, - status: race.status, + this.presenter.present( + race.id, + race.leagueId, + race.scheduledAt, + race.track, + race.trackId, + race.car, + race.carId, + race.sessionType, + race.status, strengthOfField, - registeredCount: race.registeredCount ?? participantIds.length, - maxParticipants: race.maxParticipants, - participantCount: participantIds.length, - }; + race.registeredCount ?? participantIds.length, + race.maxParticipants, + participantIds.length + ); } } \ No newline at end of file diff --git a/packages/racing/application/use-cases/GetRacesPageDataUseCase.ts b/packages/racing/application/use-cases/GetRacesPageDataUseCase.ts new file mode 100644 index 000000000..0564fcc7f --- /dev/null +++ b/packages/racing/application/use-cases/GetRacesPageDataUseCase.ts @@ -0,0 +1,38 @@ +import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; +import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; +import type { IRacesPagePresenter } from '@gridpilot/racing/application/presenters/IRacesPagePresenter'; + +export class GetRacesPageDataUseCase { + constructor( + private readonly raceRepository: IRaceRepository, + private readonly leagueRepository: ILeagueRepository, + public readonly presenter: IRacesPagePresenter, + ) {} + + async execute(): Promise { + const [allRaces, allLeagues] = await Promise.all([ + this.raceRepository.findAll(), + this.leagueRepository.findAll(), + ]); + + const leagueMap = new Map(allLeagues.map(l => [l.id, l.name])); + + const races = allRaces + .sort((a, b) => a.scheduledAt.getTime() - b.scheduledAt.getTime()) + .map(race => ({ + id: race.id, + track: race.track, + car: race.car, + scheduledAt: race.scheduledAt.toISOString(), + status: race.status, + leagueId: race.leagueId, + leagueName: leagueMap.get(race.leagueId) ?? 'Unknown League', + strengthOfField: race.strengthOfField, + isUpcoming: race.isUpcoming(), + isLive: race.isLive(), + isPast: race.isPast(), + })); + + this.presenter.present(races); + } +} \ No newline at end of file diff --git a/packages/racing/application/use-cases/GetSponsorDashboardQuery.ts b/packages/racing/application/use-cases/GetSponsorDashboardQuery.ts index 1fb6a5ecc..c98c06826 100644 --- a/packages/racing/application/use-cases/GetSponsorDashboardQuery.ts +++ b/packages/racing/application/use-cases/GetSponsorDashboardQuery.ts @@ -1,6 +1,6 @@ /** - * Application Query: GetSponsorDashboardQuery - * + * Application Use Case: GetSponsorDashboardUseCase + * * Returns sponsor dashboard metrics including sponsorships, impressions, and investment data. */ @@ -10,6 +10,7 @@ import type { ISeasonRepository } from '../../domain/repositories/ISeasonReposit import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; +import type { ISponsorDashboardPresenter } from '../presenters/ISponsorDashboardPresenter'; export interface GetSponsorDashboardQueryParams { sponsorId: string; @@ -46,7 +47,7 @@ export interface SponsorDashboardDTO { }; } -export class GetSponsorDashboardQuery { +export class GetSponsorDashboardUseCase { constructor( private readonly sponsorRepository: ISponsorRepository, private readonly seasonSponsorshipRepository: ISeasonSponsorshipRepository, @@ -54,14 +55,16 @@ export class GetSponsorDashboardQuery { private readonly leagueRepository: ILeagueRepository, private readonly leagueMembershipRepository: ILeagueMembershipRepository, private readonly raceRepository: IRaceRepository, + private readonly presenter: ISponsorDashboardPresenter, ) {} - async execute(params: GetSponsorDashboardQueryParams): Promise { + async execute(params: GetSponsorDashboardQueryParams): Promise { const { sponsorId } = params; const sponsor = await this.sponsorRepository.findById(sponsorId); if (!sponsor) { - return null; + this.presenter.present(null); + return; } // Get all sponsorships for this sponsor @@ -140,7 +143,7 @@ export class GetSponsorDashboardQuery { ? Math.min(100, (mainSponsorships * 30) + (sponsorships.length * 10)) : 0; - return { + this.presenter.present({ sponsorId, sponsorName: sponsor.name, metrics: { @@ -159,6 +162,6 @@ export class GetSponsorDashboardQuery { totalInvestment, costPerThousandViews: Math.round(costPerThousandViews * 100) / 100, }, - }; + }); } } \ No newline at end of file diff --git a/packages/racing/application/use-cases/GetSponsorSponsorshipsQuery.ts b/packages/racing/application/use-cases/GetSponsorSponsorshipsQuery.ts index 1d7fd94f5..77d1032d6 100644 --- a/packages/racing/application/use-cases/GetSponsorSponsorshipsQuery.ts +++ b/packages/racing/application/use-cases/GetSponsorSponsorshipsQuery.ts @@ -1,6 +1,6 @@ /** - * Application Query: GetSponsorSponsorshipsQuery - * + * Application Use Case: GetSponsorSponsorshipsUseCase + * * Returns detailed sponsorship information for a sponsor's campaigns/sponsorships page. */ @@ -11,6 +11,7 @@ import type { ILeagueRepository } from '../../domain/repositories/ILeagueReposit import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; import type { SponsorshipTier, SponsorshipStatus } from '../../domain/entities/SeasonSponsorship'; +import type { ISponsorSponsorshipsPresenter } from '../presenters/ISponsorSponsorshipsPresenter'; export interface GetSponsorSponsorshipsQueryParams { sponsorId: string; @@ -22,6 +23,8 @@ export interface SponsorshipDetailDTO { leagueName: string; seasonId: string; seasonName: string; + seasonStartDate?: Date; + seasonEndDate?: Date; tier: SponsorshipTier; status: SponsorshipStatus; pricing: { @@ -59,7 +62,7 @@ export interface SponsorSponsorshipsDTO { }; } -export class GetSponsorSponsorshipsQuery { +export class GetSponsorSponsorshipsUseCase { constructor( private readonly sponsorRepository: ISponsorRepository, private readonly seasonSponsorshipRepository: ISeasonSponsorshipRepository, @@ -67,14 +70,16 @@ export class GetSponsorSponsorshipsQuery { private readonly leagueRepository: ILeagueRepository, private readonly leagueMembershipRepository: ILeagueMembershipRepository, private readonly raceRepository: IRaceRepository, + private readonly presenter: ISponsorSponsorshipsPresenter, ) {} - async execute(params: GetSponsorSponsorshipsQueryParams): Promise { + async execute(params: GetSponsorSponsorshipsQueryParams): Promise { const { sponsorId } = params; const sponsor = await this.sponsorRepository.findById(sponsorId); if (!sponsor) { - return null; + this.presenter.present(null); + return; } // Get all sponsorships for this sponsor @@ -116,6 +121,8 @@ export class GetSponsorSponsorshipsQuery { leagueName: league.name, seasonId: season.id, seasonName: season.name, + seasonStartDate: season.startDate, + seasonEndDate: season.endDate, tier: sponsorship.tier, status: sponsorship.status, pricing: { @@ -143,7 +150,7 @@ export class GetSponsorSponsorshipsQuery { const activeSponsorships = sponsorships.filter(s => s.status === 'active').length; - return { + this.presenter.present({ sponsorId, sponsorName: sponsor.name, sponsorships: sponsorshipDetails, @@ -154,6 +161,6 @@ export class GetSponsorSponsorshipsQuery { totalPlatformFees, currency: 'USD', }, - }; + }); } } \ No newline at end of file diff --git a/packages/racing/application/use-cases/GetTeamDetailsQuery.ts b/packages/racing/application/use-cases/GetTeamDetailsQuery.ts index c84532c9a..09a63d9bf 100644 --- a/packages/racing/application/use-cases/GetTeamDetailsQuery.ts +++ b/packages/racing/application/use-cases/GetTeamDetailsQuery.ts @@ -1,19 +1,19 @@ import type { ITeamRepository } from '../../domain/repositories/ITeamRepository'; import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; -import type { - GetTeamDetailsQueryParamsDTO, - GetTeamDetailsQueryResultDTO, -} from '../dto/TeamCommandAndQueryDTO'; +import type { ITeamDetailsPresenter } from '../presenters/ITeamDetailsPresenter'; -export class GetTeamDetailsQuery { +/** + * Use Case for retrieving team details. + * Orchestrates domain logic and delegates presentation to the presenter. + */ +export class GetTeamDetailsUseCase { constructor( private readonly teamRepository: ITeamRepository, private readonly membershipRepository: ITeamMembershipRepository, + public readonly presenter: ITeamDetailsPresenter, ) {} - async execute(params: GetTeamDetailsQueryParamsDTO): Promise { - const { teamId, driverId } = params; - + async execute(teamId: string, driverId: string): Promise { const team = await this.teamRepository.findById(teamId); if (!team) { throw new Error('Team not found'); @@ -21,6 +21,6 @@ export class GetTeamDetailsQuery { const membership = await this.membershipRepository.getMembership(teamId, driverId); - return { team, membership }; + this.presenter.present(team, membership, driverId); } } \ No newline at end of file diff --git a/packages/racing/application/use-cases/GetTeamJoinRequestsQuery.ts b/packages/racing/application/use-cases/GetTeamJoinRequestsQuery.ts index 43b0aa88b..e3abffbba 100644 --- a/packages/racing/application/use-cases/GetTeamJoinRequestsQuery.ts +++ b/packages/racing/application/use-cases/GetTeamJoinRequestsQuery.ts @@ -1,14 +1,34 @@ import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; -import type { TeamJoinRequest } from '../../domain/entities/Team'; -import type { GetTeamJoinRequestsQueryParamsDTO } from '../dto/TeamCommandAndQueryDTO'; +import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; +import type { IImageService } from '../../domain/services/IImageService'; +import type { ITeamJoinRequestsPresenter } from '../presenters/ITeamJoinRequestsPresenter'; -export class GetTeamJoinRequestsQuery { +/** + * Use Case for retrieving team join requests. + * Orchestrates domain logic and delegates presentation to the presenter. + */ +export class GetTeamJoinRequestsUseCase { constructor( private readonly membershipRepository: ITeamMembershipRepository, + private readonly driverRepository: IDriverRepository, + private readonly imageService: IImageService, + public readonly presenter: ITeamJoinRequestsPresenter, ) {} - async execute(params: GetTeamJoinRequestsQueryParamsDTO): Promise { - const { teamId } = params; - return this.membershipRepository.getJoinRequests(teamId); + async execute(teamId: string): Promise { + const requests = await this.membershipRepository.getJoinRequests(teamId); + + const driverNames: Record = {}; + const avatarUrls: Record = {}; + + for (const request of requests) { + const driver = await this.driverRepository.findById(request.driverId); + if (driver) { + driverNames[request.driverId] = driver.name; + } + avatarUrls[request.driverId] = this.imageService.getDriverAvatar(request.driverId); + } + + this.presenter.present(requests, driverNames, avatarUrls); } } \ No newline at end of file diff --git a/packages/racing/application/use-cases/GetTeamMembersQuery.ts b/packages/racing/application/use-cases/GetTeamMembersQuery.ts index b98e90159..48b5573eb 100644 --- a/packages/racing/application/use-cases/GetTeamMembersQuery.ts +++ b/packages/racing/application/use-cases/GetTeamMembersQuery.ts @@ -1,14 +1,34 @@ import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; -import type { TeamMembership } from '../../domain/entities/Team'; -import type { GetTeamMembersQueryParamsDTO } from '../dto/TeamCommandAndQueryDTO'; +import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; +import type { IImageService } from '../../domain/services/IImageService'; +import type { ITeamMembersPresenter } from '../presenters/ITeamMembersPresenter'; -export class GetTeamMembersQuery { +/** + * Use Case for retrieving team members. + * Orchestrates domain logic and delegates presentation to the presenter. + */ +export class GetTeamMembersUseCase { constructor( private readonly membershipRepository: ITeamMembershipRepository, + private readonly driverRepository: IDriverRepository, + private readonly imageService: IImageService, + public readonly presenter: ITeamMembersPresenter, ) {} - async execute(params: GetTeamMembersQueryParamsDTO): Promise { - const { teamId } = params; - return this.membershipRepository.getTeamMembers(teamId); + async execute(teamId: string): Promise { + const memberships = await this.membershipRepository.getTeamMembers(teamId); + + const driverNames: Record = {}; + const avatarUrls: Record = {}; + + for (const membership of memberships) { + const driver = await this.driverRepository.findById(membership.driverId); + if (driver) { + driverNames[membership.driverId] = driver.name; + } + avatarUrls[membership.driverId] = this.imageService.getDriverAvatar(membership.driverId); + } + + this.presenter.present(memberships, driverNames, avatarUrls); } } \ No newline at end of file diff --git a/packages/racing/application/use-cases/GetTeamsLeaderboardUseCase.ts b/packages/racing/application/use-cases/GetTeamsLeaderboardUseCase.ts new file mode 100644 index 000000000..e098cb3db --- /dev/null +++ b/packages/racing/application/use-cases/GetTeamsLeaderboardUseCase.ts @@ -0,0 +1,77 @@ +import { inject, injectable } from 'tsyringe'; +import type { ITeamRepository } from '@gridpilot/racing/domain/repositories/ITeamRepository'; +import type { ITeamMembershipRepository } from '@gridpilot/racing/domain/repositories/ITeamMembershipRepository'; +import type { IDriverRepository } from '@gridpilot/racing/domain/repositories/IDriverRepository'; +import type { ITeamsLeaderboardPresenter } from '@gridpilot/racing/application/presenters/ITeamsLeaderboardPresenter'; +import { SkillLevelService } from '@gridpilot/racing/domain/services/SkillLevelService'; + +interface DriverStatsAdapter { + rating: number | null; + wins: number; + totalRaces: number; +} + +@injectable() +export class GetTeamsLeaderboardUseCase { + constructor( + @inject('ITeamRepository') private readonly teamRepository: ITeamRepository, + @inject('ITeamMembershipRepository') + private readonly teamMembershipRepository: ITeamMembershipRepository, + @inject('IDriverRepository') private readonly driverRepository: IDriverRepository, + private readonly getDriverStats: (driverId: string) => DriverStatsAdapter | null, + public readonly presenter: ITeamsLeaderboardPresenter + ) {} + + async execute(): Promise { + const allTeams = await this.teamRepository.findAll(); + const teams: any[] = []; + + await Promise.all( + allTeams.map(async (team) => { + const memberships = await this.teamMembershipRepository.findByTeamId(team.id); + const memberCount = memberships.length; + + let ratingSum = 0; + let ratingCount = 0; + let totalWins = 0; + let totalRaces = 0; + + for (const membership of memberships) { + const stats = this.getDriverStats(membership.driverId); + if (!stats) continue; + + if (typeof stats.rating === 'number') { + ratingSum += stats.rating; + ratingCount += 1; + } + + totalWins += stats.wins ?? 0; + totalRaces += stats.totalRaces ?? 0; + } + + const averageRating = ratingCount > 0 ? ratingSum / ratingCount : null; + const performanceLevel = SkillLevelService.getTeamPerformanceLevel(averageRating); + + teams.push({ + id: team.id, + name: team.name, + memberCount, + rating: averageRating, + totalWins, + totalRaces, + performanceLevel, + isRecruiting: true, + createdAt: new Date(), + description: team.description, + specialization: team.specialization as 'endurance' | 'sprint' | 'mixed' | undefined, + region: team.region, + languages: team.languages, + }); + }) + ); + + const recruitingCount = teams.filter((t) => t.isRecruiting).length; + + this.presenter.present(teams, recruitingCount); + } +} \ No newline at end of file diff --git a/packages/racing/application/use-cases/IsDriverRegisteredForRaceQuery.ts b/packages/racing/application/use-cases/IsDriverRegisteredForRaceQuery.ts index c848373a0..255f734ef 100644 --- a/packages/racing/application/use-cases/IsDriverRegisteredForRaceQuery.ts +++ b/packages/racing/application/use-cases/IsDriverRegisteredForRaceQuery.ts @@ -1,17 +1,22 @@ import type { IRaceRegistrationRepository } from '@gridpilot/racing/domain/repositories/IRaceRegistrationRepository'; import type { IsDriverRegisteredForRaceQueryParamsDTO } from '../dto/RaceRegistrationQueryDTO'; +import type { IDriverRegistrationStatusPresenter } from '../presenters/IDriverRegistrationStatusPresenter'; /** - * Read-only wrapper around IRaceRegistrationRepository.isRegistered. - * Mirrors legacy isRegistered behavior. + * Use Case: IsDriverRegisteredForRaceUseCase + * + * Checks if a driver is registered for a specific race. + * Orchestrates domain logic and delegates presentation to the presenter. */ -export class IsDriverRegisteredForRaceQuery { +export class IsDriverRegisteredForRaceUseCase { constructor( private readonly registrationRepository: IRaceRegistrationRepository, + public readonly presenter: IDriverRegistrationStatusPresenter, ) {} - async execute(params: IsDriverRegisteredForRaceQueryParamsDTO): Promise { + async execute(params: IsDriverRegisteredForRaceQueryParamsDTO): Promise { const { raceId, driverId } = params; - return this.registrationRepository.isRegistered(raceId, driverId); + const isRegistered = await this.registrationRepository.isRegistered(raceId, driverId); + this.presenter.present(isRegistered, raceId, driverId); } } \ No newline at end of file diff --git a/packages/racing/application/use-cases/ListLeagueScoringPresetsQuery.ts b/packages/racing/application/use-cases/ListLeagueScoringPresetsQuery.ts index 71e9e65f3..0ab51a1cd 100644 --- a/packages/racing/application/use-cases/ListLeagueScoringPresetsQuery.ts +++ b/packages/racing/application/use-cases/ListLeagueScoringPresetsQuery.ts @@ -1,18 +1,18 @@ -import type { - LeagueScoringPresetDTO, - LeagueScoringPresetProvider, -} from '../ports/LeagueScoringPresetProvider'; +import type { LeagueScoringPresetProvider } from '../ports/LeagueScoringPresetProvider'; +import type { ILeagueScoringPresetsPresenter } from '../presenters/ILeagueScoringPresetsPresenter'; /** - * Read-only query exposing league scoring presets for UI consumption. - * - * Backed by the in-memory preset registry via a LeagueScoringPresetProvider - * implementation in the infrastructure layer. + * Use Case for listing league scoring presets. + * Orchestrates domain logic and delegates presentation to the presenter. */ -export class ListLeagueScoringPresetsQuery { - constructor(private readonly presetProvider: LeagueScoringPresetProvider) {} +export class ListLeagueScoringPresetsUseCase { + constructor( + private readonly presetProvider: LeagueScoringPresetProvider, + public readonly presenter: ILeagueScoringPresetsPresenter, + ) {} - async execute(): Promise { - return this.presetProvider.listPresets(); + async execute(): Promise { + const presets = await this.presetProvider.listPresets(); + this.presenter.present(presets); } } \ No newline at end of file diff --git a/packages/racing/application/use-cases/PreviewLeagueScheduleQuery.ts b/packages/racing/application/use-cases/PreviewLeagueScheduleQuery.ts index 10ea8e8dc..ec1dd459a 100644 --- a/packages/racing/application/use-cases/PreviewLeagueScheduleQuery.ts +++ b/packages/racing/application/use-cases/PreviewLeagueScheduleQuery.ts @@ -1,18 +1,20 @@ import { SeasonScheduleGenerator } from '../../domain/services/SeasonScheduleGenerator'; import type { LeagueSchedulePreviewDTO, LeagueScheduleDTO } from '../dto/LeagueScheduleDTO'; import { scheduleDTOToSeasonSchedule } from '../dto/LeagueScheduleDTO'; +import type { ILeagueSchedulePreviewPresenter } from '../presenters/ILeagueSchedulePreviewPresenter'; interface PreviewLeagueScheduleQueryParams { schedule: LeagueScheduleDTO; maxRounds?: number; } -export class PreviewLeagueScheduleQuery { +export class PreviewLeagueScheduleUseCase { constructor( private readonly scheduleGenerator: typeof SeasonScheduleGenerator = SeasonScheduleGenerator, + private readonly presenter: ILeagueSchedulePreviewPresenter, ) {} - execute(params: PreviewLeagueScheduleQueryParams): LeagueSchedulePreviewDTO { + execute(params: PreviewLeagueScheduleQueryParams): void { const seasonSchedule = scheduleDTOToSeasonSchedule(params.schedule); const maxRounds = @@ -30,10 +32,10 @@ export class PreviewLeagueScheduleQuery { const summary = this.buildSummary(params.schedule, rounds); - return { + this.presenter.present({ rounds, summary, - }; + }); } private buildSummary( diff --git a/packages/racing/domain/services/SkillLevelService.ts b/packages/racing/domain/services/SkillLevelService.ts new file mode 100644 index 000000000..1231871ca --- /dev/null +++ b/packages/racing/domain/services/SkillLevelService.ts @@ -0,0 +1,32 @@ +export type SkillLevel = 'beginner' | 'intermediate' | 'advanced' | 'pro'; + +/** + * Domain service for determining skill level based on rating. + * This encapsulates the business rule for skill tier classification. + */ +export class SkillLevelService { + /** + * Map driver rating to skill level band. + * Business rule: iRating thresholds determine skill tiers. + */ + static getSkillLevel(rating: number): SkillLevel { + if (rating >= 3000) return 'pro'; + if (rating >= 2500) return 'advanced'; + if (rating >= 1800) return 'intermediate'; + return 'beginner'; + } + + /** + * Map average team rating to performance level. + * Business rule: Team ratings use higher thresholds than individual drivers. + */ + static getTeamPerformanceLevel(averageRating: number | null): SkillLevel { + if (averageRating === null) { + return 'beginner'; + } + if (averageRating >= 4500) return 'pro'; + if (averageRating >= 3000) return 'advanced'; + if (averageRating >= 2000) return 'intermediate'; + return 'beginner'; + } +} \ No newline at end of file