diff --git a/.gitignore b/.gitignore index a547bf3..60a6626 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ pnpm-debug.log* lerna-debug.log* node_modules +node_modules/ dist dist-ssr *.local diff --git a/package-lock.json b/package-lock.json index a02bdbb..ad0cb2c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "t15-project-1", "version": "0.0.0", "dependencies": { + "@tanstack/react-query": "^5.102.8", "react": "^19.2.8", "react-dom": "^19.2.8" }, @@ -848,6 +849,32 @@ "dev": true, "license": "MIT" }, + "node_modules/@tanstack/query-core": { + "version": "5.102.8", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.102.8.tgz", + "integrity": "sha512-ZNjkJ33CqvPNec/6lZBnHqLc3EVGPZ9ySLhYahU9TcuRFdmwXewuj0c4hwSWcGHqEUwcSrKeZ+oGcvPBqXcQcg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.102.8", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.102.8.tgz", + "integrity": "sha512-TYBea4OuXWD7MhaSHq069TWbFe7rcwWN6kzT7JF0OKi1K6c1gTv2IzD6A6ExJsCMozdkqBWeuIUZmu4KQg0O5A==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.102.8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", diff --git a/package.json b/package.json index 56c652a..182dc0d 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "@tanstack/react-query": "^5.102.8", "react": "^19.2.8", "react-dom": "^19.2.8" }, diff --git a/src/App.css b/src/App.css index da4c9bc..9fd856e 100644 --- a/src/App.css +++ b/src/App.css @@ -51,3 +51,76 @@ body { .header { align-self: center; } + +.meeting-list { + width: 100%; + max-width: 760px; + margin-top: 2rem; +} + +.meeting { + margin-bottom: 0.75rem; + border: 1px solid #656581; + border-radius: 0.5rem; + background-color: #15151e; +} + +.meeting summary { + padding: 1rem; + cursor: pointer; +} + +.meeting summary:focus-visible { + outline: 2px solid #aaaaff; + outline-offset: 2px; + border-radius: 0.5rem; +} + +.meeting-title { + font-size: 1.125rem; + font-weight: 600; +} + +.meeting-meta { + display: block; + margin-top: 0.25rem; + color: #c6c6d8; + font-size: 0.875rem; +} + +.meeting-sessions { + padding: 0 1rem 1rem; +} + +.meeting-session { + border-top: 1px solid #45455e; +} + +.meeting-session summary { + padding: 0.75rem 0; + font-weight: 600; +} + +.meeting-session time { + display: inline-block; + margin-left: 0.75rem; + color: #c6c6d8; + font-size: 0.875rem; + font-weight: 400; +} + +.session-results { + list-style: none; + padding: 0; + margin: 0 0 1rem; +} + +.session-results li { + display: flex; + align-items: center; + gap: 0.5rem; + border: 1px solid #45455e; + border-radius: 0.375rem; + padding: 0.5rem; + margin-bottom: 0.5rem; +} diff --git a/src/App.tsx b/src/App.tsx index 44fe85b..e44d72b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,40 +1,145 @@ import './App.css' -import api, { type Driver } from './api/api' -import { useEffect, useState } from "react"; +import api from './api/api' +import type { Session, SessionResult } from './api/api' +import { useQuery } from '@tanstack/react-query' import DriverCard from './components/DriverCard' +const sessionDate = new Intl.DateTimeFormat('en-GB', { dateStyle: 'medium', timeZone: 'UTC' }) + function App() { - const { getAllDrivers } = api() - const [drivers, setDrivers] = useState([]) - - useEffect(() => { - async function loadDrivers() { - const allDrivers = await getAllDrivers() - setDrivers(allDrivers) - console.log(allDrivers) - } - loadDrivers() - }, []) + const { getDriversWithStandings, getAllTeamStandings, getAllSessionResults, getAllSessions, getAllMeetings } = api() - return ( - <> + const {data: driversWithStandings = [], isPending: driversWithStandingsPending, isError: driversWithStandingsError} = useQuery({ + queryKey: ['driversWithStandings'], + queryFn: getDriversWithStandings + }) + + const {data: teamStandings = [], isPending: teamStandingsPending, isError: teamStandingsError} = useQuery({ + queryKey: ['teamStandings'], + queryFn: getAllTeamStandings, + enabled: !driversWithStandingsPending + }) + + const {data: allSessions = [], isPending: sessionsPending, isError: sessionsError} = useQuery({ + queryKey: ['sessions', 2026], + queryFn: getAllSessions, + enabled: !driversWithStandingsPending + }) + + const {data: allSessionResults = [], isPending: sessionResultsPending, isError: sessionResultsError} = useQuery({ + queryKey: ['sessionResults', 2026, allSessions.map((session) => session.session_key)], + queryFn: () => getAllSessionResults(allSessions), + enabled: !sessionsPending && !sessionsError + }) + + const {data: allMeetings = [], isPending: meetingsPending, isError: meetingsError} = useQuery({ + queryKey: ['meetings', 2026], + queryFn: getAllMeetings, + enabled: !driversWithStandingsPending + }) + const meetingsByKey = new Map(allMeetings.map((meeting) => [meeting.meeting_key, meeting])) + const sessionsByMeeting = new Map() + const orderedSessions = [...allSessions].sort((a, b) => Date.parse(a.date_start) - Date.parse(b.date_start)) + for (const session of orderedSessions) { + const sessions = sessionsByMeeting.get(session.meeting_key) ?? [] + sessions.push(session) + sessionsByMeeting.set(session.meeting_key, sessions) + } + const driverNames = new Map(driversWithStandings.map((driver) => [driver.driver_number, driver.full_name])) + const resultsBySession = new Map() + for (const result of allSessionResults) { + const results = resultsBySession.get(result.session_key) ?? [] + results.push(result) + resultsBySession.set(result.session_key, results) + } + for (const results of resultsBySession.values()) { + results.sort((a, b) => (a.position ?? Infinity) - (b.position ?? Infinity)) + } + + return ( + <>

F1 The Website

- {drivers.map((driver) => ( - + { driversWithStandingsPending ? "loading drivers..." : driversWithStandingsError ? "Could not load drivers." : + driversWithStandings.map((driver) => ( team.team_name === driver.team_name)} + teamStandingsPending={teamStandingsPending} + teamStandingsError={teamStandingsError} driver={{ name: driver.full_name, number: driver.driver_number, headshot_url: driver.headshot_url, - team_colour: driver.team_colour + team_colour: driver.team_colour, + team_name: driver.team_name, + points_current: driver.points_current, + position_current: driver.position_current }} /> - ))} + )) + }
+ {sessionsError || sessionResultsError ? ( +

Could not load session results.

+ ) : sessionsPending || sessionResultsPending || meetingsPending ? ( +

Loading session results...

+ ) : allSessions.length === 0 ? ( +

No sessions available yet.

+ ) : ( +
+

2026 meetings

+ {meetingsError &&

Meeting names could not be loaded. Showing circuits and dates.

} + {Array.from(sessionsByMeeting, ([meetingKey, sessions]) => { + const meeting = meetingsByKey.get(meetingKey) + const firstSession = sessions[0] + const lastSession = sessions[sessions.length - 1] + + return ( +
+ + {meeting?.meeting_name ?? firstSession.circuit_short_name} + + {firstSession.country_name} · {firstSession.circuit_short_name} · {' '} + {sessionDate.format(new Date(meeting?.date_start ?? firstSession.date_start))} – {' '} + {sessionDate.format(new Date(meeting?.date_end ?? lastSession.date_end))} + + +
+ {sessions.map((session) => { + const results = resultsBySession.get(session.session_key) ?? [] + + return ( +
+ + {session.session_name} + + + {results.length === 0 ? ( +

{session.is_cancelled ? 'Session cancelled.' : 'Results not available yet.'}

+ ) : ( +
    + {results.map((result) => ( +
  • + Pos {result.position ?? '—'}: + {driverNames.get(result.driver_number) ?? `Driver ${result.driver_number}`} +
  • + ))} +
+ )} +
+ ) + })} +
+
+ ) + })} +
+ )} ) } diff --git a/src/api/api.tsx b/src/api/api.tsx index 5cae7e4..c5dbd94 100644 --- a/src/api/api.tsx +++ b/src/api/api.tsx @@ -13,20 +13,203 @@ export interface Driver { team_name: string; } +export interface Session { + circuit_key: number; + circuit_short_name: string; + country_code: string; + country_key: number; + country_name: string; + date_end: string; + date_start: string; + gmt_offset: string; + is_cancelled: boolean; + location: string; + meeting_key: number; + session_key: number; + session_name: string; + session_type: string; + year: number; +} + +export interface SessionResult { + dnf: boolean; + dns: boolean; + dsq: boolean; + driver_number: number; + duration: number | (number | null)[] | null; + gap_to_leader: number | string | (number | string | null)[] | null; + number_of_laps: number; + meeting_key: number; + position: number | null; + session_key: number; +} + +export interface Meeting { + meeting_key: number; + meeting_name: string; + country_name: string; + circuit_short_name: string; + date_start: string; + date_end: string; +} + +interface RaceSession { + session_key: number; + date_end: string; +} + +export interface DriverStandings { + driver_number: number; + position_current: number; + points_current: number; + session_key: number; + meeting_key: number; +} + +export interface DriverWithStandings extends Driver { + position_current: number + points_current: number +} + +export interface TeamStandings { + team_name: string; + position_start: number; + position_current: number; + points_start: number; + points_current: number; + session_key: number; + meeting_key: number; +} + +// Share cached data and request spacing across every api() instance. +const apiCache = new Map() +const pendingRequests = new Map>() +let requestQueue: Promise = Promise.resolve() +let nextRequestAt = 0 + +function fetchApi(url: string, errorMessage: string): Promise { + const cached = apiCache.get(url) + if (cached && cached.expiresAt > Date.now()) { + return Promise.resolve(cached.data as T) + } + const pending = pendingRequests.get(url) + if (pending) { return pending as Promise } + + const request = requestQueue.then(async () => { + // Stay below OpenF1's free limits of 3 requests/second and 30/minute. + const delay = nextRequestAt - Date.now() + if (delay > 0) { await new Promise((resolve) => setTimeout(resolve, delay)) } + nextRequestAt = Date.now() + 2100 + const response = await fetch(url) + if (response.status === 429) { + const retryAfter = response.headers.get('Retry-After') + const retryAt = retryAfter + ? (Number.isFinite(Number(retryAfter)) + ? Date.now() + Number(retryAfter) * 1000 + : Date.parse(retryAfter)) + : NaN + nextRequestAt = Math.max(nextRequestAt, Number.isFinite(retryAt) ? retryAt : Date.now() + 60000) + } + if (!response.ok) { throw new Error(`${errorMessage} (${response.status})`) } + const data: T = await response.json() + apiCache.set(url, { data, expiresAt: Date.now() + 60000 }) + return data + }).finally(() => { + pendingRequests.delete(url) + }) + + pendingRequests.set(url, request) + requestQueue = request.then(() => {}, () => {}) + return request +} + export default function api() { + const API = "https://api.openf1.org/v1" + async function getAllMeetings(): Promise { + return fetchApi(`${API}/meetings?year=2026`, "Could not fetch meetings") + } - async function getAllDrivers() { - const result = await fetch(`${API}/drivers?session_key=latest`); - if (!result.ok) { throw new Error("Failed to fetch drivers"); } - const drivers: Driver[] = await result.json(); - return drivers + async function getAllSessions(): Promise { //Fetches every 2026 session that has started so far. + const sessions = await fetchApi(`${API}/sessions?year=2026`, "Could not fetch sessions") + const now = Date.now() + console.log(sessions.filter((session) => new Date(session.date_start).getTime() <= now)) + return sessions.filter((session) => new Date(session.date_start).getTime() <= now) } - return { + async function getAllSessionResults(sessions?: Session[]): Promise { + sessions = sessions ?? await getAllSessions() + if (sessions.length === 0) { return [] } - getAllDrivers, + // Fetch the key range in one request, then keep only the requested sessions. + const sessionKeys = new Set(sessions.map((session) => session.session_key)) + const params = new URLSearchParams({ + 'session_key>': String(Math.min(...sessionKeys)), + 'session_key<': String(Math.max(...sessionKeys)), + }) + const results = await fetchApi(`${API}/session_result?${params}`, "Could not fetch session results") + return results.filter((sessionResult) => sessionKeys.has(sessionResult.session_key)) + } + + async function getLatestRaceSessionKey(){ //Fetches latest race that has completed, because latest session may not have started. + const races = await fetchApi(`${API}/sessions?year=2026&session_type=Race`, "Could not fetch race sessions") + const completedRaces = races.filter( + (race) => new Date(race.date_end) < new Date() + ) + + const latestRace = completedRaces.at(-1) + if (!latestRace) { throw new Error("No completed race session found") } + console.log(latestRace.session_key) + return latestRace.session_key + } + async function getAllDrivers(): Promise { //fetch all driver info + return fetchApi(`${API}/drivers?session_key=latest`, "Failed to fetch drivers") + } + + async function getAllStandings( ) : Promise{ //fetches all standings for all drivers + const sessionKey = await getLatestRaceSessionKey() + return fetchApi(`${API}/championship_drivers?session_key=${sessionKey}`, "Could not fetch driver standings data") + } + + async function getAllTeamStandings(): Promise { //fetches all team standings + const sessionKey = await getLatestRaceSessionKey() + return fetchApi(`${API}/championship_teams?session_key=${sessionKey}`, "Could not fetch team standings data") + } + + async function getStandingsByTeamName(teamName: string): Promise { //fetches standings for a single team + const sessionKey = await getLatestRaceSessionKey() + return fetchApi( + `${API}/championship_teams?session_key=${sessionKey}&team_name=${encodeURIComponent(teamName)}`, + "Could not fetch team standings data" + ) + } + + async function getDriversWithStandings(){ //combines the driver info and driver standings into a single array. + const drivers = await getAllDrivers() + const standings = await getAllStandings() + const combinedDrivers = drivers.map((driver) => { + const standing = standings.find( + (standing) => standing.driver_number === driver.driver_number + ) + return { + ...driver, + position_current: standing?.position_current ?? 0, + points_current: standing?.points_current ?? 0 + } + }) + return combinedDrivers + } + + return { + getAllMeetings, + getAllSessions, + getAllSessionResults, + getAllDrivers, + getAllStandings, + getDriversWithStandings, + getStandingsByTeamName, + getAllTeamStandings, }; -} \ No newline at end of file +} diff --git a/src/components/DriverCard.tsx b/src/components/DriverCard.tsx index 12a6dc8..f8a4e14 100644 --- a/src/components/DriverCard.tsx +++ b/src/components/DriverCard.tsx @@ -1,21 +1,39 @@ +import type { TeamStandings } from '../api/api' + interface DriverProps { name: string; number: number; headshot_url: string; team_colour: string; - + points_current: number; + position_current: number; + team_name: string; } +interface DriverCardProps { + driver: DriverProps; + teamStanding?: TeamStandings; + teamStandingsPending: boolean; + teamStandingsError: boolean; +} -export default function DriverCard({ driver }: { driver: DriverProps }) { +export default function DriverCard({ driver, teamStanding, teamStandingsPending, teamStandingsError }: DriverCardProps) { return (

{driver.number}

{driver.name}

{driver.name} +

{driver.points_current}

+

{driver.position_current}

+

+ Team points: {teamStandingsPending + ? 'Loading...' + : teamStandingsError + ? 'Could not load points' + : teamStanding?.points_current ?? 'Unavailable'} +

) } - diff --git a/src/main.tsx b/src/main.tsx index bef5202..c3210eb 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,10 +1,16 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import './index.css' import App from './App.tsx' +const queryClient = new QueryClient() + createRoot(document.getElementById('root')!).render( + - + + + , )