@@ -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 */}
-
+
{/* 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"
>
@@ -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 */}
([]);
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 */}
-
+
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 */}
-
+
{/* 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
- {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