From 02987f60c80abc36c219b0cd2839ecc7a2c5f1f2 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Wed, 21 Jan 2026 17:50:02 +0100 Subject: [PATCH] website refactor --- .../AllLeaguesWithCapacityAndScoringDTO.ts | 15 + .../league/dtos/LeagueSeasonSummaryDTO.ts | 16 +- .../domain/league/dtos/LeagueStandingDTO.ts | 15 +- ...lLeaguesWithCapacityAndScoringPresenter.ts | 4 + .../presenters/GetLeagueSeasonsPresenter.ts | 48 +++ .../presenters/LeagueStandingsPresenter.ts | 4 + apps/website/app/leagues/[id]/page.tsx | 15 +- .../app/leagues/[id]/schedule/page.tsx | 28 +- .../leagues/AdminQuickViewWidgets.tsx | 182 +++++++++++ .../leagues/EnhancedLeagueSchedulePanel.tsx | 283 ++++++++++++++++++ .../leagues/LeagueStandingsTable.tsx | 8 + .../leagues/NextRaceCountdownWidget.tsx | 185 ++++++++++++ .../components/leagues/RaceDetailModal.tsx | 254 ++++++++++++++++ .../leagues/SeasonProgressWidget.tsx | 91 ++++++ .../view-data/LeagueDetailViewDataBuilder.ts | 47 ++- .../LeagueScheduleViewDataBuilder.ts | 10 +- .../LeagueStandingsViewDataBuilder.ts | 10 +- .../lib/services/leagues/LeagueService.ts | 4 +- .../leagues/LeagueStandingsService.ts | 3 + .../types/generated/LeagueSeasonSummaryDTO.ts | 4 + .../lib/types/generated/LeagueStandingDTO.ts | 3 + .../LeagueWithCapacityAndScoringDTO.ts | 3 + .../lib/view-data/LeagueDetailViewData.ts | 32 ++ .../lib/view-data/LeagueStandingsViewData.ts | 8 + .../leagues/LeagueScheduleViewData.ts | 9 + .../templates/LeagueOverviewTemplate.tsx | 67 ++++- .../templates/LeagueScheduleTemplate.tsx | 130 +++++++- .../templates/LeagueStandingsTemplate.tsx | 86 +++++- plans/league-pages-enhancement.md | 144 +++++++++ 29 files changed, 1673 insertions(+), 35 deletions(-) create mode 100644 apps/website/components/leagues/AdminQuickViewWidgets.tsx create mode 100644 apps/website/components/leagues/EnhancedLeagueSchedulePanel.tsx create mode 100644 apps/website/components/leagues/NextRaceCountdownWidget.tsx create mode 100644 apps/website/components/leagues/RaceDetailModal.tsx create mode 100644 apps/website/components/leagues/SeasonProgressWidget.tsx create mode 100644 plans/league-pages-enhancement.md diff --git a/apps/api/src/domain/league/dtos/AllLeaguesWithCapacityAndScoringDTO.ts b/apps/api/src/domain/league/dtos/AllLeaguesWithCapacityAndScoringDTO.ts index 1722995df..59a94b1bc 100644 --- a/apps/api/src/domain/league/dtos/AllLeaguesWithCapacityAndScoringDTO.ts +++ b/apps/api/src/domain/league/dtos/AllLeaguesWithCapacityAndScoringDTO.ts @@ -121,6 +121,21 @@ export class LeagueWithCapacityAndScoringDTO { @IsOptional() @IsString() logoUrl?: string | null; + + @ApiProperty({ required: false }) + @IsOptional() + @IsNumber() + pendingJoinRequestsCount?: number; + + @ApiProperty({ required: false }) + @IsOptional() + @IsNumber() + pendingProtestsCount?: number; + + @ApiProperty({ required: false }) + @IsOptional() + @IsNumber() + walletBalance?: number; } export class AllLeaguesWithCapacityAndScoringDTO { diff --git a/apps/api/src/domain/league/dtos/LeagueSeasonSummaryDTO.ts b/apps/api/src/domain/league/dtos/LeagueSeasonSummaryDTO.ts index 71018f12b..f2ce1e2e5 100644 --- a/apps/api/src/domain/league/dtos/LeagueSeasonSummaryDTO.ts +++ b/apps/api/src/domain/league/dtos/LeagueSeasonSummaryDTO.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsBoolean, IsDate, IsOptional, IsString } from 'class-validator'; +import { IsBoolean, IsDate, IsNumber, IsOptional, IsString } from 'class-validator'; export class LeagueSeasonSummaryDTO { @ApiProperty() @@ -34,4 +34,18 @@ export class LeagueSeasonSummaryDTO { @ApiProperty() @IsBoolean() isParallelActive!: boolean; + + @ApiProperty() + @IsNumber() + totalRaces!: number; + + @ApiProperty() + @IsNumber() + completedRaces!: number; + + @ApiProperty({ required: false }) + @IsOptional() + @IsDate() + @Type(() => Date) + nextRaceAt?: Date; } \ No newline at end of file diff --git a/apps/api/src/domain/league/dtos/LeagueStandingDTO.ts b/apps/api/src/domain/league/dtos/LeagueStandingDTO.ts index 29c8d8348..abc6774e3 100644 --- a/apps/api/src/domain/league/dtos/LeagueStandingDTO.ts +++ b/apps/api/src/domain/league/dtos/LeagueStandingDTO.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsNumber, IsString, ValidateNested } from 'class-validator'; +import { IsArray, IsNumber, IsString, ValidateNested } from 'class-validator'; import { DriverDTO } from '../../driver/dtos/DriverDTO'; export class LeagueStandingDTO { @@ -32,4 +32,17 @@ export class LeagueStandingDTO { @ApiProperty() @IsNumber() races!: number; + + @ApiProperty() + @IsNumber() + positionChange!: number; + + @ApiProperty() + @IsNumber() + lastRacePoints!: number; + + @ApiProperty() + @IsArray() + @IsString({ each: true }) + droppedRaceIds!: string[]; } \ No newline at end of file diff --git a/apps/api/src/domain/league/presenters/AllLeaguesWithCapacityAndScoringPresenter.ts b/apps/api/src/domain/league/presenters/AllLeaguesWithCapacityAndScoringPresenter.ts index 5d347eb77..640b194e6 100644 --- a/apps/api/src/domain/league/presenters/AllLeaguesWithCapacityAndScoringPresenter.ts +++ b/apps/api/src/domain/league/presenters/AllLeaguesWithCapacityAndScoringPresenter.ts @@ -87,6 +87,10 @@ export class AllLeaguesWithCapacityAndScoringPresenter : {}), ...(timingSummary ? { timingSummary } : {}), ...(logoUrl !== undefined ? { logoUrl } : {}), + // Add mock data for new fields + pendingJoinRequestsCount: Math.floor(Math.random() * 5), + pendingProtestsCount: Math.floor(Math.random() * 3), + walletBalance: Math.floor(Math.random() * 1000), }; }) ); diff --git a/apps/api/src/domain/league/presenters/GetLeagueSeasonsPresenter.ts b/apps/api/src/domain/league/presenters/GetLeagueSeasonsPresenter.ts index 29219be78..d5cf39ff5 100644 --- a/apps/api/src/domain/league/presenters/GetLeagueSeasonsPresenter.ts +++ b/apps/api/src/domain/league/presenters/GetLeagueSeasonsPresenter.ts @@ -22,6 +22,54 @@ export class GetLeagueSeasonsPresenter implements Presenter; @@ -35,6 +36,12 @@ export async function generateMetadata({ params }: Props): Promise { export default async function Page({ params }: Props) { const { id } = await params; + + // Get current user session + const sessionGateway = new SessionGateway(); + const session = await sessionGateway.getSession(); + const currentDriverId = session?.user?.primaryDriverId; + // Execute the PageQuery const result = await LeagueDetailPageQuery.execute(id); @@ -63,6 +70,12 @@ export default async function Page({ params }: Props) { const data = result.unwrap(); const league = data.league; + // Determine if current user is owner or admin + const isOwnerOrAdmin = currentDriverId + ? currentDriverId === league.ownerId || + data.memberships.members?.some(m => m.driverId === currentDriverId && m.role === 'admin') + : false; + // Build ViewData using the builder const viewData = LeagueDetailViewDataBuilder.build({ league: data.league, @@ -84,7 +97,7 @@ export default async function Page({ params }: Props) { return ( <> - + ); } diff --git a/apps/website/app/leagues/[id]/schedule/page.tsx b/apps/website/app/leagues/[id]/schedule/page.tsx index 88a05d3d1..c297703bc 100644 --- a/apps/website/app/leagues/[id]/schedule/page.tsx +++ b/apps/website/app/leagues/[id]/schedule/page.tsx @@ -21,11 +21,29 @@ export default async function LeagueSchedulePage({ params }: Props) { notFound(); } // For serverError, show the template with empty data - return ; + return {}} + onWithdraw={async () => {}} + onEdit={() => {}} + onReschedule={() => {}} + onResultsClick={() => {}} + />; } - return ; + const viewData = result.unwrap(); + + return {}} + onWithdraw={async () => {}} + onEdit={() => {}} + onReschedule={() => {}} + onResultsClick={() => {}} + />; } \ No newline at end of file diff --git a/apps/website/components/leagues/AdminQuickViewWidgets.tsx b/apps/website/components/leagues/AdminQuickViewWidgets.tsx new file mode 100644 index 000000000..db91c94f4 --- /dev/null +++ b/apps/website/components/leagues/AdminQuickViewWidgets.tsx @@ -0,0 +1,182 @@ +'use client'; + +import { Button } from '@/ui/Button'; +import { Icon } from '@/ui/Icon'; +import { Link } from '@/ui/Link'; +import { Stack } from '@/ui/Stack'; +import { Surface } from '@/ui/Surface'; +import { Text } from '@/ui/Text'; +import { AlertTriangle, DollarSign, Shield, Wallet } from 'lucide-react'; + +interface AdminQuickViewWidgetsProps { + leagueId: string; + walletBalance?: number; + pendingProtestsCount?: number; + pendingJoinRequestsCount?: number; + isOwnerOrAdmin: boolean; +} + +export function AdminQuickViewWidgets({ + leagueId, + walletBalance = 0, + pendingProtestsCount = 0, + pendingJoinRequestsCount = 0, + isOwnerOrAdmin, +}: AdminQuickViewWidgetsProps) { + if (!isOwnerOrAdmin) { + return null; + } + + return ( + + {/* Wallet Preview */} + + + + + + + + + Wallet Balance + + + ${walletBalance.toFixed(2)} + + + + + + + + + + + + + {/* Stewarding Quick-View */} + + + + + + + + + Stewarding Queue + + + {pendingProtestsCount} + + + + + {pendingProtestsCount > 0 ? ( + + + + + + ) : ( + + No pending protests + + )} + + + + {/* Join Requests Preview */} + {pendingJoinRequestsCount > 0 && ( + + + + + + + + + Join Requests + + + {pendingJoinRequestsCount} + + + + + + + + + + + + )} + + ); +} diff --git a/apps/website/components/leagues/EnhancedLeagueSchedulePanel.tsx b/apps/website/components/leagues/EnhancedLeagueSchedulePanel.tsx new file mode 100644 index 000000000..94008ca80 --- /dev/null +++ b/apps/website/components/leagues/EnhancedLeagueSchedulePanel.tsx @@ -0,0 +1,283 @@ +'use client'; + +import React, { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { routes } from '@/lib/routing/RouteConfig'; +import { Box } from '@/ui/Box'; +import { Text } from '@/ui/Text'; +import { Stack } from '@/ui/Stack'; +import { Button } from '@/ui/Button'; +import { Icon } from '@/ui/Icon'; +import { Badge } from '@/ui/Badge'; +import { Group } from '@/ui/Group'; +import { Surface } from '@/ui/Surface'; +import { ChevronDown, ChevronUp, Calendar, CheckCircle, Trophy, Edit, Clock } from 'lucide-react'; +import { DateDisplay } from '@/lib/display-objects/DateDisplay'; + +interface RaceEvent { + id: string; + name: string; + track?: string; + car?: string; + sessionType?: string; + scheduledAt: string; + status: 'scheduled' | 'completed'; + strengthOfField?: number; + isUserRegistered?: boolean; + canRegister?: boolean; + canEdit?: boolean; + canReschedule?: boolean; +} + +interface EnhancedLeagueSchedulePanelProps { + events: RaceEvent[]; + leagueId: string; + currentDriverId?: string; + isAdmin: boolean; + onRegister: (raceId: string) => void; + onWithdraw: (raceId: string) => void; + onEdit: (raceId: string) => void; + onReschedule: (raceId: string) => void; + onRaceDetail: (raceId: string) => void; + onResultsClick: (raceId: string) => void; +} + +interface MonthGroup { + month: string; + year: number; + races: RaceEvent[]; +} + +export function EnhancedLeagueSchedulePanel({ + events, + leagueId, + currentDriverId, + isAdmin, + onRegister, + onWithdraw, + onEdit, + onReschedule, + onRaceDetail, + onResultsClick, +}: EnhancedLeagueSchedulePanelProps) { + const router = useRouter(); + const [expandedMonths, setExpandedMonths] = useState>(new Set()); + + // Group races by month + const groupRacesByMonth = (): MonthGroup[] => { + const groups = new Map(); + + events.forEach(event => { + const date = new Date(event.scheduledAt); + const monthKey = `${date.getFullYear()}-${date.getMonth()}`; + const monthName = date.toLocaleDateString('en-US', { month: 'long', year: 'numeric' }); + + if (!groups.has(monthKey)) { + groups.set(monthKey, { + month: monthName, + year: date.getFullYear(), + races: [], + }); + } + + groups.get(monthKey)!.races.push(event); + }); + + return Array.from(groups.values()).sort((a, b) => { + if (a.year !== b.year) return b.year - a.year; + return b.month.localeCompare(a.month); + }); + }; + + const toggleMonth = (monthKey: string) => { + setExpandedMonths(prev => { + const newSet = new Set(prev); + if (newSet.has(monthKey)) { + newSet.delete(monthKey); + } else { + newSet.add(monthKey); + } + return newSet; + }); + }; + + const getRaceStatusBadge = (status: 'scheduled' | 'completed') => { + if (status === 'completed') { + return Completed; + } + return Scheduled; + }; + + const formatTime = (scheduledAt: string) => { + return DateDisplay.formatDateTime(scheduledAt); + }; + + const groups = groupRacesByMonth(); + + if (events.length === 0) { + return ( + + No races scheduled for this season. + + ); + } + + return ( + + {groups.map((group, groupIndex) => { + const monthKey = `${group.year}-${groupIndex}`; + const isExpanded = expandedMonths.has(monthKey); + + return ( + + {/* Month Header */} + toggleMonth(monthKey)} + > + + + + {group.month} + + + {group.races.length} {group.races.length === 1 ? 'Race' : 'Races'} + + + + + + {/* Race List */} + {isExpanded && ( + + + {group.races.map((race, raceIndex) => ( + + + {/* Race Info */} + + + + + {race.name || `Race ${race.id.substring(0, 4)}`} + + {getRaceStatusBadge(race.status)} + + + + {race.track || 'TBA'} + + {race.car && ( + + {race.car} + + )} + {race.sessionType && ( + + {race.sessionType} + + )} + + + + + {formatTime(race.scheduledAt)} + + + + + + {/* Action Buttons */} + + {race.status === 'scheduled' && ( + <> + {!race.isUserRegistered && race.canRegister && ( + + )} + {race.isUserRegistered && ( + + )} + {race.canEdit && ( + + )} + {race.canReschedule && ( + + )} + + )} + + {race.status === 'completed' && ( + <> + + + )} + + {/* Always show detail button */} + + + + + ))} + + + )} + + ); + })} + + ); +} diff --git a/apps/website/components/leagues/LeagueStandingsTable.tsx b/apps/website/components/leagues/LeagueStandingsTable.tsx index 397784ce8..84c6b6ce2 100644 --- a/apps/website/components/leagues/LeagueStandingsTable.tsx +++ b/apps/website/components/leagues/LeagueStandingsTable.tsx @@ -7,6 +7,10 @@ import { useRouter } from 'next/navigation'; import { routes } from '@/lib/routing/RouteConfig'; import { Box } from '@/ui/Box'; import { Text } from '@/ui/Text'; +import { Group } from '@/ui/Group'; +import { Badge } from '@/ui/Badge'; +import { Icon } from '@/ui/Icon'; +import { Droplet, XCircle } from 'lucide-react'; interface StandingEntry { position: number; @@ -19,6 +23,9 @@ interface StandingEntry { races: number; avgFinish: number | null; gap: string; + positionChange: number; + lastRacePoints: number; + droppedRaceIds: string[]; } interface LeagueStandingsTableProps { @@ -44,6 +51,7 @@ export function LeagueStandingsTable({ standings }: LeagueStandingsTableProps) { key={entry.driverId || entry.driverName} id={entry.driverId || ''} rank={entry.position} + rankDelta={entry.positionChange} name={entry.driverName} avatarUrl="" // Not provided in StandingEntry nationality="INT" diff --git a/apps/website/components/leagues/NextRaceCountdownWidget.tsx b/apps/website/components/leagues/NextRaceCountdownWidget.tsx new file mode 100644 index 000000000..693a5e36e --- /dev/null +++ b/apps/website/components/leagues/NextRaceCountdownWidget.tsx @@ -0,0 +1,185 @@ +'use client'; + +import { Badge } from '@/ui/Badge'; +import { Button } from '@/ui/Button'; +import { Icon } from '@/ui/Icon'; +import { Link } from '@/ui/Link'; +import { Stack } from '@/ui/Stack'; +import { Surface } from '@/ui/Surface'; +import { Text } from '@/ui/Text'; +import { Calendar, Clock, MapPin, type LucideIcon } from 'lucide-react'; +import { useEffect, useState } from 'react'; + +interface NextRaceCountdownWidgetProps { + raceId: string; + raceName: string; + date: string; + track?: string; + car?: string; + isRegistered?: boolean; +} + +interface CountdownState { + days: number; + hours: number; + minutes: number; + seconds: number; +} + +export function NextRaceCountdownWidget({ + raceId, + raceName, + date, + track, + car, + isRegistered = false, +}: NextRaceCountdownWidgetProps) { + const [countdown, setCountdown] = useState(null); + const [isExpired, setIsExpired] = useState(false); + + useEffect(() => { + const calculateCountdown = () => { + const now = new Date(); + const raceDate = new Date(date); + const diff = raceDate.getTime() - now.getTime(); + + if (diff <= 0) { + setIsExpired(true); + setCountdown({ days: 0, hours: 0, minutes: 0, seconds: 0 }); + return; + } + + const days = Math.floor(diff / (1000 * 60 * 60 * 24)); + const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); + const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); + const seconds = Math.floor((diff % (1000 * 60)) / 1000); + + setCountdown({ days, hours, minutes, seconds }); + setIsExpired(false); + }; + + calculateCountdown(); + const interval = setInterval(calculateCountdown, 1000); + return () => clearInterval(interval); + }, [date]); + + const formatTime = (value: number) => value.toString().padStart(2, '0'); + + return ( + + + + {/* Header */} + + + Next Race + + {isRegistered && ( + + Registered + + )} + + + {/* Race Info */} + + + {raceName} + + {track && ( + + + + {track} + + + )} + {car && ( + + + + {car} + + + )} + + + {/* Countdown Timer */} + + + {isExpired ? 'Race Started' : 'Starts in'} + + {countdown && ( + + + + {formatTime(countdown.days)} + + Days + + : + + + {formatTime(countdown.hours)} + + Hours + + : + + + {formatTime(countdown.minutes)} + + Mins + + : + + + {formatTime(countdown.seconds)} + + Secs + + + )} + + + {/* Actions */} + + + + + + + + ); +} diff --git a/apps/website/components/leagues/RaceDetailModal.tsx b/apps/website/components/leagues/RaceDetailModal.tsx new file mode 100644 index 000000000..2e537ecdb --- /dev/null +++ b/apps/website/components/leagues/RaceDetailModal.tsx @@ -0,0 +1,254 @@ +'use client'; + +import React from 'react'; +import { Box } from '@/ui/Box'; +import { Text } from '@/ui/Text'; +import { Stack } from '@/ui/Stack'; +import { Group } from '@/ui/Group'; +import { Surface } from '@/ui/Surface'; +import { Icon } from '@/ui/Icon'; +import { Button } from '@/ui/Button'; +import { Badge } from '@/ui/Badge'; +import { + Calendar, + Clock, + Car, + MapPin, + Thermometer, + Droplets, + Wind, + Cloud, + X, + Trophy, + CheckCircle +} from 'lucide-react'; +import { DateDisplay } from '@/lib/display-objects/DateDisplay'; + +interface RaceDetailModalProps { + race: { + id: string; + name: string; + track?: string; + car?: string; + sessionType?: string; + scheduledAt: string; + status: 'scheduled' | 'completed'; + strengthOfField?: number; + isUserRegistered?: boolean; + canRegister?: boolean; + }; + isOpen: boolean; + onClose: () => void; + onRegister?: () => void; + onWithdraw?: () => void; + onResultsClick?: () => void; +} + +export function RaceDetailModal({ + race, + isOpen, + onClose, + onRegister, + onWithdraw, + onResultsClick, +}: RaceDetailModalProps) { + if (!isOpen) return null; + + const formatTime = (scheduledAt: string) => { + return DateDisplay.formatDateTime(scheduledAt); + }; + + const getStatusBadge = (status: 'scheduled' | 'completed') => { + if (status === 'completed') { + return Completed; + } + return Scheduled; + }; + + return ( + + e.stopPropagation()} + > + + {/* Header */} + + + + {race.name || `Race ${race.id.substring(0, 4)}`} + + {getStatusBadge(race.status)} + + + + + {/* Content */} + + + {/* Basic Info */} + + + Race Details + + + + + + {race.track || 'TBA'} + + + + + + {race.car || 'TBA'} + + + + + + {formatTime(race.scheduledAt)} + + + {race.sessionType && ( + + + + {race.sessionType} + + + )} + + + + {/* Weather Info (Mock Data) */} + + + Weather Conditions + + + + + Air: 24°C + + + + Track: 31°C + + + + Humidity: 45% + + + + Wind: 12 km/h NW + + + + Partly Cloudy + + + + + {/* Car Classes */} + + + Car Classes + + + GT3 + GT4 + TCR + + + + {/* Strength of Field */} + {race.strengthOfField && ( + + + Strength of Field + + + + + {race.strengthOfField.toFixed(1)} / 10.0 + + + + )} + + {/* Action Buttons */} + {race.status === 'scheduled' && ( + + {!race.isUserRegistered && race.canRegister && onRegister && ( + + )} + {race.isUserRegistered && onWithdraw && ( + + )} + + )} + + {race.status === 'completed' && onResultsClick && ( + + )} + + + + + + ); +} diff --git a/apps/website/components/leagues/SeasonProgressWidget.tsx b/apps/website/components/leagues/SeasonProgressWidget.tsx new file mode 100644 index 000000000..767cb5bce --- /dev/null +++ b/apps/website/components/leagues/SeasonProgressWidget.tsx @@ -0,0 +1,91 @@ +'use client'; + +import { ProgressBar } from '@/ui/ProgressBar'; +import { Stack } from '@/ui/Stack'; +import { Surface } from '@/ui/Surface'; +import { Text } from '@/ui/Text'; +import { Trophy } from 'lucide-react'; + +interface SeasonProgressWidgetProps { + completedRaces: number; + totalRaces: number; + percentage: number; +} + +export function SeasonProgressWidget({ + completedRaces, + totalRaces, + percentage, +}: SeasonProgressWidgetProps) { + return ( + + + {/* Header */} + + + + + + + Season Progress + + + Race {completedRaces} of {totalRaces} + + + + + {/* Progress Bar */} + + + + + {percentage}% Complete + + + {completedRaces}/{totalRaces} Races + + + + + {/* Visual Indicator */} + + + {percentage >= 100 + ? 'Season Complete! 🏆' + : percentage >= 50 + ? 'Over halfway there! 🚀' + : 'Season underway! 🏁'} + + + + + ); +} diff --git a/apps/website/lib/builders/view-data/LeagueDetailViewDataBuilder.ts b/apps/website/lib/builders/view-data/LeagueDetailViewDataBuilder.ts index ef65cae15..488fa4630 100644 --- a/apps/website/lib/builders/view-data/LeagueDetailViewDataBuilder.ts +++ b/apps/website/lib/builders/view-data/LeagueDetailViewDataBuilder.ts @@ -3,7 +3,7 @@ import type { LeagueMembershipsDTO } from '@/lib/types/generated/LeagueMembershi import type { RaceDTO } from '@/lib/types/generated/RaceDTO'; import type { GetDriverOutputDTO } from '@/lib/types/generated/GetDriverOutputDTO'; import type { LeagueScoringConfigDTO } from '@/lib/types/generated/LeagueScoringConfigDTO'; -import type { LeagueDetailViewData, LeagueInfoData, LiveRaceData, DriverSummaryData, SponsorInfo } from '@/lib/view-data/LeagueDetailViewData'; +import type { LeagueDetailViewData, LeagueInfoData, LiveRaceData, DriverSummaryData, SponsorInfo, NextRaceInfo, SeasonProgress, RecentResult } from '@/lib/view-data/LeagueDetailViewData'; /** * LeagueDetailViewDataBuilder @@ -138,6 +138,45 @@ export class LeagueDetailViewDataBuilder { profileUrl: `/drivers/${m.driverId}`, })); + // Calculate next race (first upcoming race) + const now = new Date(); + const nextRace: NextRaceInfo | undefined = races + .filter(r => new Date(r.date) > now) + .sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()) + .map(r => ({ + id: r.id, + name: r.name, + date: r.date, + track: (r as any).track, + car: (r as any).car, + }))[0]; + + // Calculate season progress (completed races vs total races) + const completedRaces = races.filter(r => { + const raceDate = new Date(r.date); + return raceDate < now; + }).length; + const totalRaces = races.length; + const percentage = totalRaces > 0 ? Math.round((completedRaces / totalRaces) * 100) : 0; + const seasonProgress: SeasonProgress = { + completedRaces, + totalRaces, + percentage, + }; + + // Get recent results (top 3 from last completed race) + const recentResults: RecentResult[] = races + .filter(r => new Date(r.date) < now) + .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()) + .slice(0, 3) + .map(r => ({ + raceId: r.id, + raceName: r.name, + position: (r as any).position || 0, + points: (r as any).points || 0, + finishedAt: r.date, + })); + return { leagueId: league.id, name: league.name, @@ -151,6 +190,12 @@ export class LeagueDetailViewDataBuilder { stewardSummaries, memberSummaries, sponsorInsights: null, // Only for sponsor mode + nextRace, + seasonProgress, + recentResults, + walletBalance: league.walletBalance, + pendingProtestsCount: league.pendingProtestsCount, + pendingJoinRequestsCount: league.pendingJoinRequestsCount, }; } } \ No newline at end of file diff --git a/apps/website/lib/builders/view-data/LeagueScheduleViewDataBuilder.ts b/apps/website/lib/builders/view-data/LeagueScheduleViewDataBuilder.ts index 04db0d9ed..57b1e6884 100644 --- a/apps/website/lib/builders/view-data/LeagueScheduleViewDataBuilder.ts +++ b/apps/website/lib/builders/view-data/LeagueScheduleViewDataBuilder.ts @@ -2,7 +2,7 @@ import { LeagueScheduleViewData } from '@/lib/view-data/leagues/LeagueScheduleVi import { LeagueScheduleApiDto } from '@/lib/types/tbd/LeagueScheduleApiDto'; export class LeagueScheduleViewDataBuilder { - static build(apiDto: LeagueScheduleApiDto): LeagueScheduleViewData { + static build(apiDto: LeagueScheduleApiDto, currentDriverId?: string, isAdmin: boolean = false): LeagueScheduleViewData { const now = new Date(); return { @@ -22,8 +22,16 @@ export class LeagueScheduleViewDataBuilder { isPast, isUpcoming, status: isPast ? 'completed' : 'scheduled', + // Registration info (would come from API in real implementation) + isUserRegistered: false, + canRegister: isUpcoming, + // Admin info + canEdit: isAdmin, + canReschedule: isAdmin, }; }), + currentDriverId, + isAdmin, }; } } \ No newline at end of file diff --git a/apps/website/lib/builders/view-data/LeagueStandingsViewDataBuilder.ts b/apps/website/lib/builders/view-data/LeagueStandingsViewDataBuilder.ts index 4b6dfa59e..72fe81921 100644 --- a/apps/website/lib/builders/view-data/LeagueStandingsViewDataBuilder.ts +++ b/apps/website/lib/builders/view-data/LeagueStandingsViewDataBuilder.ts @@ -20,7 +20,8 @@ export class LeagueStandingsViewDataBuilder { static build( standingsDto: LeagueStandingsApiDto, membershipsDto: LeagueMembershipsApiDto, - leagueId: string + leagueId: string, + isTeamChampionship: boolean = false ): LeagueStandingsViewData { const standings = standingsDto.standings || []; const members = membershipsDto.members || []; @@ -35,6 +36,12 @@ export class LeagueStandingsViewDataBuilder { avgFinish: null, // Not in DTO penaltyPoints: 0, // Not in DTO bonusPoints: 0, // Not in DTO + // New fields from Phase 3 + positionChange: standing.positionChange || 0, + lastRacePoints: standing.lastRacePoints || 0, + droppedRaceIds: standing.droppedRaceIds || [], + wins: standing.wins || 0, + podiums: standing.podiums || 0, })); // Extract unique drivers from standings @@ -70,6 +77,7 @@ export class LeagueStandingsViewDataBuilder { leagueId, currentDriverId: null, // Would need to get from auth isAdmin: false, // Would need to check permissions + isTeamChampionship: isTeamChampionship, }; } } \ No newline at end of file diff --git a/apps/website/lib/services/leagues/LeagueService.ts b/apps/website/lib/services/leagues/LeagueService.ts index d958e9db6..a7d250a6a 100644 --- a/apps/website/lib/services/leagues/LeagueService.ts +++ b/apps/website/lib/services/leagues/LeagueService.ts @@ -1,7 +1,6 @@ import { DriversApiClient } from "@/lib/api/drivers/DriversApiClient"; import { LeaguesApiClient } from "@/lib/api/leagues/LeaguesApiClient"; import { RacesApiClient } from "@/lib/api/races/RacesApiClient"; -import { SponsorsApiClient } from "@/lib/api/sponsors/SponsorsApiClient"; import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl'; import { isProductionEnvironment } from '@/lib/config/env'; import { Result } from '@/lib/contracts/Result'; @@ -27,6 +26,7 @@ import type { UpdateLeagueScheduleRaceInputDTO } from '@/lib/types/generated/Upd import type { MembershipRole } from "@/lib/types/MembershipRole"; import { injectable, unmanaged } from 'inversify'; +// TODO these data interfaces violate our architecture, see VIEW_DATA export interface LeagueScheduleAdminData { leagueId: string; seasonId: string; @@ -61,7 +61,6 @@ export class LeagueService implements Service { private readonly baseUrl: string; private apiClient: LeaguesApiClient; private driversApiClient: DriversApiClient; - private sponsorsApiClient: SponsorsApiClient; private racesApiClient: RacesApiClient; constructor(@unmanaged() apiClient?: LeaguesApiClient) { @@ -81,7 +80,6 @@ export class LeagueService implements Service { } this.driversApiClient = new DriversApiClient(baseUrl, errorReporter, logger); - this.sponsorsApiClient = new SponsorsApiClient(baseUrl, errorReporter, logger); this.racesApiClient = new RacesApiClient(baseUrl, errorReporter, logger); } diff --git a/apps/website/lib/services/leagues/LeagueStandingsService.ts b/apps/website/lib/services/leagues/LeagueStandingsService.ts index 4eaf546dc..5ca9c35ba 100644 --- a/apps/website/lib/services/leagues/LeagueStandingsService.ts +++ b/apps/website/lib/services/leagues/LeagueStandingsService.ts @@ -41,6 +41,9 @@ export class LeagueStandingsService implements Service { wins: s.wins, podiums: s.podiums, races: s.races, + positionChange: s.positionChange, + lastRacePoints: s.lastRacePoints, + droppedRaceIds: s.droppedRaceIds, })), }; diff --git a/apps/website/lib/types/generated/LeagueSeasonSummaryDTO.ts b/apps/website/lib/types/generated/LeagueSeasonSummaryDTO.ts index 48ec47550..9c6e50a4f 100644 --- a/apps/website/lib/types/generated/LeagueSeasonSummaryDTO.ts +++ b/apps/website/lib/types/generated/LeagueSeasonSummaryDTO.ts @@ -15,4 +15,8 @@ export interface LeagueSeasonSummaryDTO { endDate?: string; isPrimary: boolean; isParallelActive: boolean; + totalRaces: number; + completedRaces: number; + /** Format: date-time */ + nextRaceAt?: string; } diff --git a/apps/website/lib/types/generated/LeagueStandingDTO.ts b/apps/website/lib/types/generated/LeagueStandingDTO.ts index 0475db9ee..0884ebff8 100644 --- a/apps/website/lib/types/generated/LeagueStandingDTO.ts +++ b/apps/website/lib/types/generated/LeagueStandingDTO.ts @@ -15,4 +15,7 @@ export interface LeagueStandingDTO { wins: number; podiums: number; races: number; + positionChange: number; + lastRacePoints: number; + droppedRaceIds: string[]; } diff --git a/apps/website/lib/types/generated/LeagueWithCapacityAndScoringDTO.ts b/apps/website/lib/types/generated/LeagueWithCapacityAndScoringDTO.ts index d2fb74406..72787e04f 100644 --- a/apps/website/lib/types/generated/LeagueWithCapacityAndScoringDTO.ts +++ b/apps/website/lib/types/generated/LeagueWithCapacityAndScoringDTO.ts @@ -22,4 +22,7 @@ export interface LeagueWithCapacityAndScoringDTO { scoring?: LeagueCapacityAndScoringSummaryScoringDTO; timingSummary?: string; logoUrl?: string; + pendingJoinRequestsCount?: number; + pendingProtestsCount?: number; + walletBalance?: number; } diff --git a/apps/website/lib/view-data/LeagueDetailViewData.ts b/apps/website/lib/view-data/LeagueDetailViewData.ts index fadcf35ae..ebcb02b5b 100644 --- a/apps/website/lib/view-data/LeagueDetailViewData.ts +++ b/apps/website/lib/view-data/LeagueDetailViewData.ts @@ -65,6 +65,28 @@ export interface SponsorshipSlot { benefits: string[]; } +export interface NextRaceInfo { + id: string; + name: string; + date: string; + track?: string; + car?: string; +} + +export interface SeasonProgress { + completedRaces: number; + totalRaces: number; + percentage: number; +} + +export interface RecentResult { + raceId: string; + raceName: string; + position: number; + points: number; + finishedAt: string; +} + export interface LeagueDetailViewData extends ViewData { // Basic info leagueId: string; @@ -104,4 +126,14 @@ export interface LeagueDetailViewData extends ViewData { metrics: SponsorMetric[]; slots: SponsorshipSlot[]; } | null; + + // New fields for enhanced league pages + nextRace?: NextRaceInfo; + seasonProgress?: SeasonProgress; + recentResults?: RecentResult[]; + + // Admin fields + walletBalance?: number; + pendingProtestsCount?: number; + pendingJoinRequestsCount?: number; } diff --git a/apps/website/lib/view-data/LeagueStandingsViewData.ts b/apps/website/lib/view-data/LeagueStandingsViewData.ts index 30f4f53c9..2e0580de0 100644 --- a/apps/website/lib/view-data/LeagueStandingsViewData.ts +++ b/apps/website/lib/view-data/LeagueStandingsViewData.ts @@ -13,6 +13,12 @@ export interface StandingEntryData { penaltyPoints: number; bonusPoints: number; teamName?: string; + // New fields from Phase 3 + positionChange: number; + lastRacePoints: number; + droppedRaceIds: string[]; + wins: number; + podiums: number; } export interface DriverData { @@ -39,4 +45,6 @@ export interface LeagueStandingsViewData { leagueId: string; currentDriverId: string | null; isAdmin: boolean; + // New fields for team standings toggle + isTeamChampionship?: boolean; } \ No newline at end of file diff --git a/apps/website/lib/view-data/leagues/LeagueScheduleViewData.ts b/apps/website/lib/view-data/leagues/LeagueScheduleViewData.ts index 2b7656ef5..d78834f31 100644 --- a/apps/website/lib/view-data/leagues/LeagueScheduleViewData.ts +++ b/apps/website/lib/view-data/leagues/LeagueScheduleViewData.ts @@ -11,5 +11,14 @@ export interface LeagueScheduleViewData { isUpcoming: boolean; status: 'scheduled' | 'completed'; strengthOfField?: number; + // Registration info + isUserRegistered?: boolean; + canRegister?: boolean; + // Admin info + canEdit?: boolean; + canReschedule?: boolean; }>; + // User permissions + currentDriverId?: string; + isAdmin: boolean; } \ No newline at end of file diff --git a/apps/website/templates/LeagueOverviewTemplate.tsx b/apps/website/templates/LeagueOverviewTemplate.tsx index c1f2f177b..e79ae8665 100644 --- a/apps/website/templates/LeagueOverviewTemplate.tsx +++ b/apps/website/templates/LeagueOverviewTemplate.tsx @@ -1,6 +1,10 @@ 'use client'; +import { AdminQuickViewWidgets } from '@/components/leagues/AdminQuickViewWidgets'; +import { LeagueActivityFeed } from '@/components/leagues/LeagueActivityFeed'; import { LeagueLogo } from '@/components/leagues/LeagueLogo'; +import { NextRaceCountdownWidget } from '@/components/leagues/NextRaceCountdownWidget'; +import { SeasonProgressWidget } from '@/components/leagues/SeasonProgressWidget'; import type { LeagueDetailViewData } from '@/lib/view-data/LeagueDetailViewData'; import { Box } from '@/ui/Box'; import { Stack } from '@/ui/Stack'; @@ -10,18 +14,19 @@ import { Calendar, Shield, Trophy, Users, type LucideIcon } from 'lucide-react'; interface LeagueOverviewTemplateProps { viewData: LeagueDetailViewData; + isOwnerOrAdmin: boolean; } -export function LeagueOverviewTemplate({ viewData }: LeagueOverviewTemplateProps) { +export function LeagueOverviewTemplate({ viewData, isOwnerOrAdmin }: LeagueOverviewTemplateProps) { return ( {/* Header with Logo */} - @@ -34,6 +39,41 @@ export function LeagueOverviewTemplate({ viewData }: LeagueOverviewTemplateProps {/* Main Content */} + {/* Next Race Section */} + {viewData.nextRace && ( + + Next Race + + + )} + + {/* Season Progress Section */} + {viewData.seasonProgress && ( + + Season Progress + + + )} + + {/* League Activity Feed */} + + Recent Activity + + + + + + {/* About the League */} About the League @@ -43,6 +83,7 @@ export function LeagueOverviewTemplate({ viewData }: LeagueOverviewTemplateProps + {/* Quick Stats */} Quick Stats @@ -97,6 +138,20 @@ export function LeagueOverviewTemplate({ viewData }: LeagueOverviewTemplateProps {/* Sidebar */} + {/* Admin Quick-View Widgets */} + {isOwnerOrAdmin && ( + + Admin Tools + + + )} + Management diff --git a/apps/website/templates/LeagueScheduleTemplate.tsx b/apps/website/templates/LeagueScheduleTemplate.tsx index 8fed33d5c..73d9248d6 100644 --- a/apps/website/templates/LeagueScheduleTemplate.tsx +++ b/apps/website/templates/LeagueScheduleTemplate.tsx @@ -1,35 +1,143 @@ 'use client'; -import { LeagueSchedulePanel } from '@/components/leagues/LeagueSchedulePanel'; +import { useState } from 'react'; +import { EnhancedLeagueSchedulePanel } from '@/components/leagues/EnhancedLeagueSchedulePanel'; +import { RaceDetailModal } from '@/components/leagues/RaceDetailModal'; import type { LeagueScheduleViewData } from '@/lib/view-data/leagues/LeagueScheduleViewData'; import { Box } from '@/ui/Box'; import { Text } from '@/ui/Text'; - +import { Button } from '@/ui/Button'; +import { Icon } from '@/ui/Icon'; +import { Group } from '@/ui/Group'; +import { Calendar, Plus } from 'lucide-react'; import { DateDisplay } from '@/lib/display-objects/DateDisplay'; interface LeagueScheduleTemplateProps { viewData: LeagueScheduleViewData; + onRegister: (raceId: string) => Promise; + onWithdraw: (raceId: string) => Promise; + onEdit: (raceId: string) => void; + onReschedule: (raceId: string) => void; + onResultsClick: (raceId: string) => void; + onCreateRace?: () => void; } -export function LeagueScheduleTemplate({ viewData }: LeagueScheduleTemplateProps) { +export function LeagueScheduleTemplate({ + viewData, + onRegister, + onWithdraw, + onEdit, + onReschedule, + onResultsClick, + onCreateRace +}: LeagueScheduleTemplateProps) { + const [selectedRace, setSelectedRace] = useState<{ + id: string; + name: string; + track?: string; + car?: string; + sessionType?: string; + scheduledAt: string; + status: 'scheduled' | 'completed'; + strengthOfField?: number; + isUserRegistered?: boolean; + canRegister?: boolean; + } | null>(null); + const [modalOpen, setModalOpen] = useState(false); + const events = viewData.races.map(race => ({ id: race.id, - title: race.name || `Race ${race.id.substring(0, 4)}`, - trackName: race.track || 'TBA', - date: race.scheduledAt, - time: DateDisplay.formatDateTime(race.scheduledAt), - status: (race.status === 'completed' ? 'completed' : 'upcoming') as any, - strengthOfField: race.strengthOfField + name: race.name || `Race ${race.id.substring(0, 4)}`, + track: race.track || 'TBA', + car: race.car, + sessionType: race.sessionType, + scheduledAt: race.scheduledAt, + status: race.status, + strengthOfField: race.strengthOfField, + isUserRegistered: race.isUserRegistered, + canRegister: race.canRegister, + canEdit: race.canEdit, + canReschedule: race.canReschedule, })); + const handleRaceDetail = (raceId: string) => { + const race = viewData.races.find(r => r.id === raceId); + if (race) { + setSelectedRace({ + id: race.id, + name: race.name || `Race ${race.id.substring(0, 4)}`, + track: race.track, + car: race.car, + sessionType: race.sessionType, + scheduledAt: race.scheduledAt, + status: race.status, + strengthOfField: race.strengthOfField, + isUserRegistered: race.isUserRegistered, + canRegister: race.canRegister, + }); + setModalOpen(true); + } + }; + + const handleCloseModal = () => { + setModalOpen(false); + setSelectedRace(null); + }; + + const handleRegister = async (raceId: string) => { + await onRegister(raceId); + setModalOpen(false); + }; + + const handleWithdraw = async (raceId: string) => { + await onWithdraw(raceId); + setModalOpen(false); + }; + return ( - Race Schedule + + + Race Schedule + + {viewData.isAdmin && onCreateRace && ( + + )} + Upcoming and past events for this season. - + + + {selectedRace && ( + handleRegister(selectedRace.id)} + onWithdraw={() => handleWithdraw(selectedRace.id)} + onResultsClick={() => onResultsClick(selectedRace.id)} + /> + )} ); } diff --git a/apps/website/templates/LeagueStandingsTemplate.tsx b/apps/website/templates/LeagueStandingsTemplate.tsx index 85267d4bd..d315f8321 100644 --- a/apps/website/templates/LeagueStandingsTemplate.tsx +++ b/apps/website/templates/LeagueStandingsTemplate.tsx @@ -1,19 +1,29 @@ 'use client'; +import { useState } from 'react'; import { LeagueStandingsTable } from '@/components/leagues/LeagueStandingsTable'; import type { LeagueStandingsViewData } from '@/lib/view-data/LeagueStandingsViewData'; import { Box } from '@/ui/Box'; import { Text } from '@/ui/Text'; +import { Group } from '@/ui/Group'; +import { Button } from '@/ui/Button'; +import { Icon } from '@/ui/Icon'; +import { Surface } from '@/ui/Surface'; +import { Trophy, Users, Calendar, Award } from 'lucide-react'; interface LeagueStandingsTemplateProps { viewData: LeagueStandingsViewData; loading?: boolean; + onToggleTeamChampionship?: () => void; } export function LeagueStandingsTemplate({ viewData, loading = false, + onToggleTeamChampionship, }: LeagueStandingsTemplateProps) { + const [showTeamStandings, setShowTeamStandings] = useState(false); + if (loading) { return ( @@ -31,21 +41,89 @@ export function LeagueStandingsTemplate({ driverName: driver?.name || 'Unknown Driver', driverId: entry.driverId, points: entry.totalPoints, - wins: 0, // Placeholder - podiums: 0, // Placeholder + wins: entry.wins, + podiums: entry.podiums, races: entry.racesStarted, avgFinish: entry.avgFinish, - gap: entry.position === 1 ? '—' : `-${viewData.standings[0].totalPoints - entry.totalPoints}` + gap: entry.position === 1 ? '—' : `-${viewData.standings[0].totalPoints - entry.totalPoints}`, + positionChange: entry.positionChange, + lastRacePoints: entry.lastRacePoints, + droppedRaceIds: entry.droppedRaceIds, }; }); + // Calculate championship stats + const championshipStats = { + totalRaces: viewData.standings[0]?.racesStarted || 0, + totalDrivers: viewData.standings.length, + topWins: Math.max(...viewData.standings.map(s => s.wins)), + topPodiums: Math.max(...viewData.standings.map(s => s.podiums)), + }; + return ( - Championship Standings + + + Championship Standings + + {viewData.isTeamChampionship && onToggleTeamChampionship && ( + + )} + Official points classification for the current season. + {/* Championship Stats */} + + + + + + Total Races + {championshipStats.totalRaces} + + + + + + + + Total Drivers + {championshipStats.totalDrivers} + + + + + + + + Most Wins + {championshipStats.topWins} + + + + + + + + Most Podiums + {championshipStats.topPodiums} + + + + + ); diff --git a/plans/league-pages-enhancement.md b/plans/league-pages-enhancement.md new file mode 100644 index 000000000..c65bb87bb --- /dev/null +++ b/plans/league-pages-enhancement.md @@ -0,0 +1,144 @@ +# Plan: League Pages Enhancement + +## 1. Analysis of Current State + +### Leagues Discovery Page (`/leagues`) +- **Current**: Basic grid of league cards with search and category filters. +- **Gaps**: + - No "Featured" or "Promoted" leagues section. + - Limited metadata in cards (missing next race info, active season status). + - No "Quick Join" or "Follow" actions directly from the list. + - Categories are hardcoded in the client. + +### League Detail Page (`/leagues/[id]`) +- **Current**: Tabbed layout (Overview, Schedule, Standings, Roster, Rulebook). +- **Gaps**: + - **Overview**: Very static. Missing "Next Race" countdown, "Recent Results" snippet, and "Active Season" progress. + - **Schedule**: Simple list. Missing "Register" buttons for upcoming races, "View Results" for past races. + - **Standings**: Basic table. Missing "Trend" indicators (up/down positions), "Last Race" points. + - **Roster**: Simple list. Missing driver stats (rating, safety, performance). + - **Functionality**: No "Join League" flow visible in the current barebones implementation (though components exist). + +## 2. Proposed Enhancements + +### A. Leagues Discovery Page +- **Featured Section**: Add a top section for high-utilization or promoted infrastructure. +- **Enhanced Cards**: Include "Next Race" date and "Active Drivers" count. +- **Dynamic Categories**: Move category definitions to the API or a shared config. + +### B. League Overview (The "Command Center") +- **Next Race Widget**: High-visibility countdown to the next scheduled event with a "Register" button. +- **Season Progress**: Visual bar showing how far along the current season is (e.g., "Race 4 of 12"). +- **Recent Results Snippet**: Top 3 from the last completed race. +- **Activity Feed**: Integration of `LeagueActivityFeed` component to show recent joins, results, and announcements. +- **Wallet & Sponsorship Preview**: For admins, show a summary of league funds and active sponsorship slots using `LeagueSponsorshipsSection`. +- **Stewarding Quick-View**: Show pending protests count and a link to the stewarding queue. + +### C. League Schedule +- **Interactive Timeline**: Group races by month or season. +- **Actionable Items**: "Register" for upcoming, "View Results" for completed. +- **Race Detail Modals**: Use `RaceDetailTemplate` or a modal to show track details, weather, and car classes. +- **Admin Controls**: Inline "Edit" or "Reschedule" buttons for authorized users. + +### D. League Standings +- **Trend Indicators**: Show position changes since the last race. +- **Championship Stats**: Integrate `LeagueChampionshipStats` for wins, podiums, and average finish. +- **Team Standings**: Ensure team-based championships are togglable. +- **Drop Weeks Visualization**: Clearly mark which races are currently being dropped from a driver's total. + +### E. League Roster +- **Driver Cards**: Use a more detailed card format showing GridPilot Rating and recent form. +- **Admin Actions**: Quick access to "Promote", "Remove", or "Message" for authorized users. +- **Join Requests**: Integrated `JoinRequestsPanel` for admins to manage pending applications. + +### F. Rulebook & Governance +- **Structured Rules**: Use `LeagueRulesPanel` to display code of conduct and sporting regulations. +- **Governance Transparency**: Show the stewarding decision mode (Committee vs Single Steward) using `LeagueStewardingSection`. + +## 3. UI/UX & Design Streamlining +- **Telemetry Aesthetic**: Align all league pages with the "Modern Precision" theme (Deep Graphite, Primary Blue, Performance Green). +- **Consistent Primitives**: Ensure all components use the established UI primitives (`Surface`, `Stack`, `Text`, `Icon`). +- **Responsive Density**: Maintain high data density on desktop while ensuring readability on mobile. + +## 4. Data & API Requirements + +### Core/API Additions +- **Next Race Info**: API should return the single next upcoming race for a league in the summary/detail DTO. +- **Season Progress**: Add `totalRaces` and `completedRaces` to the `LeagueSeasonSummaryDTO`. +- **Standings Trends**: Add `positionChange` (number) and `lastRacePoints` (number) to `LeagueStandingDTO`. +- **Activity Feed**: Ensure the `/leagues/[id]/activity` endpoint is fully functional. + +### View Model Updates +- **LeagueSummaryViewModel**: Add `nextRaceAt`, `activeDriversCount`. +- **LeagueDetailViewData**: Add `nextRace`, `seasonProgress`, `recentResults`. + +## 5. Proposed DTO Changes (Technical) + +### `LeagueSeasonSummaryDTO` +```typescript +export class LeagueSeasonSummaryDTO { + // ... existing fields + @ApiProperty() + totalRaces!: number; + + @ApiProperty() + completedRaces!: number; + + @ApiProperty({ required: false }) + nextRaceAt?: Date; +} +``` + +### `LeagueStandingDTO` +```typescript +export class LeagueStandingDTO { + // ... existing fields + @ApiProperty({ description: 'Position change since last race' }) + positionChange!: number; + + @ApiProperty({ description: 'Points earned in the last race' }) + lastRacePoints!: number; + + @ApiProperty({ type: [String], description: 'IDs of races that were dropped' }) + droppedRaceIds!: string[]; +} +``` + +### `LeagueSummaryDTO` (or `LeagueWithCapacityAndScoringDTO`) +```typescript +export class LeagueWithCapacityAndScoringDTO { + // ... existing fields + @ApiProperty({ required: false }) + pendingJoinRequestsCount?: number; + + @ApiProperty({ required: false }) + pendingProtestsCount?: number; + + @ApiProperty({ required: false }) + walletBalance?: number; +} +``` + +## 6. Implementation Steps (Todo List) + +- [ ] **Phase 1: Data Foundation** + - [ ] Update `LeagueDetailData` and `LeagueDetailViewData` interfaces. + - [ ] Enhance `LeagueDetailViewDataBuilder` to compute next race and season progress. + - [ ] (Optional) Add mock data to `LeagueService` for new fields if API isn't ready. + +- [ ] **Phase 2: Overview Page Overhaul** + - [ ] Implement "Next Race" countdown widget. + - [ ] Add "Season Progress" component. + - [ ] Integrate `LeagueActivityFeed`. + +- [ ] **Phase 3: Schedule & Standings Polish** + - [ ] Update `LeagueScheduleTemplate` with registration actions. + - [ ] Enhance `LeagueStandingsTemplate` with trend indicators and stats. + +- [ ] **Phase 4: Discovery Page Refinement** + - [ ] Update `LeaguesTemplate` with a "Featured" section. + - [ ] Enhance `LeagueCard` with more metadata. + +- [ ] **Phase 5: Final Streamlining** + - [ ] Audit all league pages for theme consistency. + - [ ] Ensure mobile responsiveness across all new widgets.