diff --git a/adapters/bootstrap/racing/RacingSeed.ts b/adapters/bootstrap/racing/RacingSeed.ts index 62ceb4fa6..27208b113 100644 --- a/adapters/bootstrap/racing/RacingSeed.ts +++ b/adapters/bootstrap/racing/RacingSeed.ts @@ -68,10 +68,10 @@ export type RacingSeedOptions = { }; export const racingSeedDefaults: Readonly< - Required + Omit, 'baseDate'> & { baseDate: () => Date } > = { driverCount: 150, // Increased from 100 to 150 - baseDate: new Date(), + baseDate: () => new Date(), persistence: 'inmemory', }; @@ -82,7 +82,7 @@ class RacingSeedFactory { constructor(options: RacingSeedOptions) { this.driverCount = options.driverCount ?? racingSeedDefaults.driverCount; - this.baseDate = options.baseDate ?? racingSeedDefaults.baseDate; + this.baseDate = options.baseDate ?? racingSeedDefaults.baseDate(); this.persistence = options.persistence ?? racingSeedDefaults.persistence; } diff --git a/apps/website/client-wrapper/RacesAllPageClient.tsx b/apps/website/client-wrapper/RacesAllPageClient.tsx index 79b82341a..1975f0be0 100644 --- a/apps/website/client-wrapper/RacesAllPageClient.tsx +++ b/apps/website/client-wrapper/RacesAllPageClient.tsx @@ -1,72 +1,67 @@ 'use client'; -import { useState } from 'react'; +import { useState, useMemo } from 'react'; import { useRouter } from 'next/navigation'; -import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper'; -import { RacesAllTemplate } from '@/templates/RacesAllTemplate'; +import { RacesIndexTemplate } from '@/templates/RacesIndexTemplate'; import { useAllRacesPageData } from '@/hooks/race/useAllRacesPageData'; -import { type RacesViewData, type RaceViewData } from '@/lib/view-data/RacesViewData'; -import { Flag } from 'lucide-react'; -import { routes } from '@/lib/routing/RouteConfig'; +import type { RacesViewData, RaceViewData } from '@/lib/view-data/RacesViewData'; import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts'; - -const ITEMS_PER_PAGE = 10; +import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper'; +import { Flag } from 'lucide-react'; export function RacesAllPageClient({ viewData: initialViewData }: ClientWrapperProps) { const router = useRouter(); - - // Client-side state for filters and pagination - const [currentPage, setCurrentPage] = useState(1); - const [statusFilter, setStatusFilter] = useState<'scheduled' | 'running' | 'completed' | 'cancelled' | 'all'>('all'); + const [statusFilter, setStatusFilter] = useState('all'); const [leagueFilter, setLeagueFilter] = useState('all'); - const [searchQuery, setSearchQuery] = useState(''); - const [showFilters, setShowFilters] = useState(false); + const [timeFilter, setTimeFilter] = useState('upcoming'); const [showFilterModal, setShowFilterModal] = useState(false); // Use React Query hook const { data: pageData, isLoading, error, refetch } = useAllRacesPageData(initialViewData); - // Transform data - const races: RaceViewData[] = pageData?.races ?? []; + const filteredRaces = useMemo(() => { + const now = new Date(); + const races: RaceViewData[] = pageData?.races ?? []; + return races.filter((race) => { + if (statusFilter !== 'all' && race.status.toLowerCase() !== statusFilter.toLowerCase()) return false; + if (leagueFilter !== 'all' && race.leagueId !== leagueFilter) return false; + + const scheduledAt = new Date(race.scheduledAt); + const isActuallyUpcoming = scheduledAt > now && race.status.toLowerCase() === 'scheduled'; + const isActuallyLive = race.status.toLowerCase() === 'running'; + const isActuallyPast = scheduledAt < now || race.status.toLowerCase() === 'completed' || race.status.toLowerCase() === 'cancelled'; - // Filter and paginate (Note: This should be done by API per contract) - const filteredRaces = races.filter((race: RaceViewData) => { - if (statusFilter !== 'all' && race.status !== statusFilter) { - return false; - } + if (timeFilter === 'upcoming' && !isActuallyUpcoming) return false; + if (timeFilter === 'live' && !isActuallyLive) return false; + if (timeFilter === 'past' && !isActuallyPast) return false; + return true; + }); + }, [pageData?.races, statusFilter, leagueFilter, timeFilter]); - if (leagueFilter !== 'all' && race.leagueId !== leagueFilter) { - return false; - } + const nextUpRace = useMemo(() => { + const now = new Date(); + return filteredRaces.find(r => new Date(r.scheduledAt) > now && r.status.toLowerCase() === 'scheduled'); + }, [filteredRaces]); - if (searchQuery) { - const query = searchQuery.toLowerCase(); - const matchesTrack = race.track.toLowerCase().includes(query); - const matchesCar = race.car.toLowerCase().includes(query); - const matchesLeague = race.leagueName?.toLowerCase().includes(query); - if (!matchesTrack && !matchesCar && !matchesLeague) { - return false; + const racesByDate = useMemo(() => { + const grouped = new Map(); + + filteredRaces.forEach((race) => { + const dateKey = race.scheduledAt.split('T')[0]!; + if (!grouped.has(dateKey)) { + grouped.set(dateKey, []); } - } + grouped.get(dateKey)!.push(race); + }); - return true; - }); - - const totalPages = Math.ceil(filteredRaces.length / ITEMS_PER_PAGE); - const paginatedRaces = filteredRaces.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE); - - // Actions - const handleRaceClick = (raceId: string) => { - router.push(routes.race.detail(raceId)); - }; - - const handleLeagueClick = (leagueId: string) => { - router.push(routes.league.detail(leagueId)); - }; - - const handlePageChange = (page: number) => { - setCurrentPage(page); - }; + return Array.from(grouped.entries()) + .sort(([a], [b]) => timeFilter === 'past' ? b.localeCompare(a) : a.localeCompare(b)) + .map(([dateKey, dayRaces]) => ({ + dateKey, + dateLabel: dayRaces[0]?.scheduledAtLabel || '', + races: dayRaces, + })); + }, [filteredRaces, timeFilter]); return ( pageData ? ( - router.push(`/races/${id}`)} /> ) : null} loading={{ variant: 'skeleton', message: 'Loading races...' }} diff --git a/apps/website/client-wrapper/RacesPageClient.tsx b/apps/website/client-wrapper/RacesPageClient.tsx index 5770ad78f..ee0c7040b 100644 --- a/apps/website/client-wrapper/RacesPageClient.tsx +++ b/apps/website/client-wrapper/RacesPageClient.tsx @@ -2,30 +2,54 @@ import { useState, useMemo } from 'react'; import { useRouter } from 'next/navigation'; -import { RacesTemplate, type TimeFilter, type RaceStatusFilter } from '@/templates/RacesTemplate'; +import { RacesIndexTemplate } from '@/templates/RacesIndexTemplate'; import type { RacesViewData } from '@/lib/view-data/RacesViewData'; import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts'; export function RacesPageClient({ viewData }: ClientWrapperProps) { const router = useRouter(); - const [statusFilter, setStatusFilter] = useState('all'); + const [statusFilter, setStatusFilter] = useState('all'); const [leagueFilter, setLeagueFilter] = useState('all'); - const [timeFilter, setTimeFilter] = useState('upcoming'); + const [timeFilter, setTimeFilter] = useState('upcoming'); const [showFilterModal, setShowFilterModal] = useState(false); const filteredRaces = useMemo(() => { + const now = new Date(); return viewData.races.filter((race) => { - if (statusFilter !== 'all' && race.status !== statusFilter) return false; + // Status filter: case-insensitive match + if (statusFilter !== 'all' && race.status.toLowerCase() !== statusFilter.toLowerCase()) return false; + + // League filter if (leagueFilter !== 'all' && race.leagueId !== leagueFilter) return false; - if (timeFilter === 'upcoming' && !race.isUpcoming) return false; - if (timeFilter === 'live' && !race.isLive) return false; - if (timeFilter === 'past' && !race.isPast) return false; + + // Time filter: ensure we are checking the correct flags + // Note: we also check the actual date to be safe against stale API flags + const scheduledAt = new Date(race.scheduledAt); + const isActuallyUpcoming = scheduledAt > now && race.status.toLowerCase() === 'scheduled'; + const isActuallyLive = race.status.toLowerCase() === 'running'; + const isActuallyPast = scheduledAt < now || race.status.toLowerCase() === 'completed' || race.status.toLowerCase() === 'cancelled'; + + if (timeFilter === 'upcoming' && !isActuallyUpcoming) return false; + if (timeFilter === 'live' && !isActuallyLive) return false; + if (timeFilter === 'past' && !isActuallyPast) return false; + return true; }); }, [viewData.races, statusFilter, leagueFilter, timeFilter]); + const nextUpRace = useMemo(() => { + const now = new Date(); + // Find the first upcoming race in the filtered list + return filteredRaces.find(r => new Date(r.scheduledAt) > now && r.status.toLowerCase() === 'scheduled'); + }, [filteredRaces]); + const racesByDate = useMemo(() => { const grouped = new Map(); + + // If we have a "Next Up" race and we are in upcoming view, we might want to exclude it from the list + // to avoid duplication, but usually it's better to keep the list complete. + // For this redesign, we keep the list complete for scanning. + filteredRaces.forEach((race) => { const dateKey = race.scheduledAt.split('T')[0]!; if (!grouped.has(dateKey)) { @@ -33,19 +57,23 @@ export function RacesPageClient({ viewData }: ClientWrapperProps) } grouped.get(dateKey)!.push(race); }); - return Array.from(grouped.entries()).map(([dateKey, dayRaces]) => ({ - dateKey, - dateLabel: dayRaces[0]?.scheduledAtLabel || '', - races: dayRaces, - })); - }, [filteredRaces]); + + return Array.from(grouped.entries()) + .sort(([a], [b]) => timeFilter === 'past' ? b.localeCompare(a) : a.localeCompare(b)) + .map(([dateKey, dayRaces]) => ({ + dateKey, + dateLabel: dayRaces[0]?.scheduledAtLabel || '', + races: dayRaces, + })); + }, [filteredRaces, timeFilter]); return ( - ) showFilterModal={showFilterModal} setShowFilterModal={setShowFilterModal} onRaceClick={(id) => router.push(`/races/${id}`)} - onLeagueClick={(id) => router.push(`/leagues/${id}`)} - onWithdraw={(id) => console.log('Withdraw', id)} - onCancel={(id) => console.log('Cancel', id)} /> ); } diff --git a/apps/website/components/races/LiveRaceItem.tsx b/apps/website/components/races/LiveRaceItem.tsx index d67740aac..76ab06c48 100644 --- a/apps/website/components/races/LiveRaceItem.tsx +++ b/apps/website/components/races/LiveRaceItem.tsx @@ -1,9 +1,10 @@ +'use client'; - -import { Box } from '@/ui/Box'; import { Heading } from '@/ui/Heading'; import { Icon } from '@/ui/Icon'; import { Text } from '@/ui/Text'; +import { Stack } from '@/ui/Stack'; +import { Panel } from '@/ui/Panel'; import { ChevronRight, PlayCircle } from 'lucide-react'; interface LiveRaceItemProps { @@ -14,30 +15,21 @@ interface LiveRaceItemProps { export function LiveRaceItem({ track, leagueName, onClick }: LiveRaceItemProps) { return ( - - - - - - - {track} - {leagueName} - - - - + + + + + {track} + {leagueName} + + + + + ); } diff --git a/apps/website/components/races/LiveRacesBanner.tsx b/apps/website/components/races/LiveRacesBanner.tsx index 45d311f09..de9f7b7c6 100644 --- a/apps/website/components/races/LiveRacesBanner.tsx +++ b/apps/website/components/races/LiveRacesBanner.tsx @@ -1,9 +1,12 @@ - +'use client'; import { LiveRaceItem } from '@/components/races/LiveRaceItem'; import type { RaceViewData } from '@/lib/view-data/RacesViewData'; import { Stack } from '@/ui/Stack'; import { Text } from '@/ui/Text'; +import { Panel } from '@/ui/Panel'; +import { Icon } from '@/ui/Icon'; +import { Zap } from 'lucide-react'; interface LiveRacesBannerProps { liveRaces: RaceViewData[]; @@ -14,45 +17,26 @@ export function LiveRacesBanner({ liveRaces, onRaceClick }: LiveRacesBannerProps if (liveRaces.length === 0) return null; return ( - - - - - - - - LIVE NOW - + + + + + + Live Sessions + - + {liveRaces.map((race) => ( onRaceClick(race.id)} /> ))} - + ); } diff --git a/apps/website/components/races/NextUpRacePanel.tsx b/apps/website/components/races/NextUpRacePanel.tsx new file mode 100644 index 000000000..319ddae40 --- /dev/null +++ b/apps/website/components/races/NextUpRacePanel.tsx @@ -0,0 +1,77 @@ +'use client'; + +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { Box } from '@/ui/Box'; +import { Surface } from '@/ui/Surface'; +import { Icon } from '@/ui/Icon'; +import { Clock, MapPin, Car, ChevronRight } from 'lucide-react'; +import { Button } from '@/ui/Button'; +import Link from 'next/link'; + +interface NextUpRacePanelProps { + race: any; + onRaceClick: (id: string) => void; +} + +export function NextUpRacePanel({ race, onRaceClick }: NextUpRacePanelProps) { + if (!race) return null; + + return ( + + + Next Up + + + { + e.preventDefault(); + onRaceClick(race.id); + }} + cursor="pointer" + hoverBg="rgba(255, 255, 255, 0.02)" + display="block" + style={{ textDecoration: 'none', color: 'inherit' }} + > + + + + + {race.leagueName} + + + {race.track} + + + + + + + {race.timeLabel} + + + + {race.car} + + + + + + + + + ); +} diff --git a/apps/website/components/races/RaceFilterBar.tsx b/apps/website/components/races/RaceFilterBar.tsx index 48b2734cf..c01cb14a4 100644 --- a/apps/website/components/races/RaceFilterBar.tsx +++ b/apps/website/components/races/RaceFilterBar.tsx @@ -1,4 +1,4 @@ - +'use client'; import type { TimeFilter } from '@/templates/RacesTemplate'; import { Button } from '@/ui/Button'; @@ -6,6 +6,8 @@ import { Card } from '@/ui/Card'; import { FilterGroup } from '@/ui/FilterGroup'; import { Select } from '@/ui/Select'; import { Stack } from '@/ui/Stack'; +import { Icon } from '@/ui/Icon'; +import { SlidersHorizontal } from 'lucide-react'; interface RaceFilterBarProps { timeFilter: TimeFilter; @@ -31,32 +33,36 @@ export function RaceFilterBar({ const timeOptions = [ { id: 'upcoming', label: 'Upcoming' }, - { id: 'live', label: 'Live', indicatorColor: 'bg-performance-green' }, + { id: 'live', label: 'Live' }, { id: 'past', label: 'Past' }, { id: 'all', label: 'All' }, ]; return ( - - - setTimeFilter(id as TimeFilter)} - /> + + + + setTimeFilter(id as TimeFilter)} + /> - setLeagueFilter(e.target.value)} + options={leagueOptions} + fullWidth={false} + /> + diff --git a/apps/website/components/races/RaceFilterModal.tsx b/apps/website/components/races/RaceFilterModal.tsx index a69d2c952..d635dded4 100644 --- a/apps/website/components/races/RaceFilterModal.tsx +++ b/apps/website/components/races/RaceFilterModal.tsx @@ -7,6 +7,7 @@ import { Modal } from '@/components/shared/Modal'; import { Stack } from '@/ui/Stack'; import { Select } from '@/ui/Select'; import { Text } from '@/ui/Text'; +import { StatusDot } from '@/ui/StatusDot'; import { Filter, Search } from 'lucide-react'; export type TimeFilter = 'all' | 'upcoming' | 'live' | 'past'; @@ -48,9 +49,9 @@ export function RaceFilterModal({ isOpen={isOpen} onOpenChange={(open) => !open && onClose()} title="Filters" - icon={} + icon={} > - + {/* Search */} {showSearch && ( setSearchQuery(e.target.value)} placeholder="Track, car, or league..." - icon={} + icon={} /> )} {/* Time Filter */} {showTimeFilter && ( - - Time - + + Time + {(['upcoming', 'live', 'past', 'all'] as TimeFilter[]).map(filter => ( ))} @@ -111,7 +116,7 @@ export function RaceFilterModal({ {/* Clear Filters */} {(statusFilter !== 'all' || leagueFilter !== 'all' || searchQuery || (showTimeFilter && timeFilter !== 'upcoming')) && (