From ebd06613fda19632b10a877e44bb744db4d8ca43 Mon Sep 17 00:00:00 2001 From: Wilhelm Svensgaard Rolstad Date: Mon, 14 Sep 2026 17:18:42 +0200 Subject: [PATCH 1/2] feat: expanded the api service to include driver standings and team standings as well as implementing TanStackQuery --- .gitignore | 1 + package-lock.json | 27 ++++++++++ package.json | 1 + src/App.tsx | 42 ++++++++------- src/api/api.tsx | 98 +++++++++++++++++++++++++++++++++-- src/components/DriverCard.tsx | 24 +++++++-- src/main.tsx | 8 ++- 7 files changed, 174 insertions(+), 27 deletions(-) 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.tsx b/src/App.tsx index 44fe85b..f1b8fc1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,40 +1,44 @@ import './App.css' -import api, { type Driver } from './api/api' -import { useEffect, useState } from "react"; +import api from './api/api' +import { useQuery } from '@tanstack/react-query' import DriverCard from './components/DriverCard' function App() { - const { getAllDrivers } = api() - const [drivers, setDrivers] = useState([]) + const { getDriversWithStandings, getAllTeamStandings } = api() - useEffect(() => { - async function loadDrivers() { - const allDrivers = await getAllDrivers() - setDrivers(allDrivers) - console.log(allDrivers) - } - loadDrivers() - }, []) + const {data: driversWithStandings = [], isPending: driversWithStandingsPending, isError: driversWithStandingsError} = useQuery({ + queryKey: ['driversWithStandings'], + queryFn: getDriversWithStandings + }) + + const {data: teamStandings = [], isPending: teamStandingsPending, isError: teamStandingsError} = useQuery({ + queryKey: ['teamStandings'], + queryFn: getAllTeamStandings + }) 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 }} /> - ))} + )) + }
- ) } diff --git a/src/api/api.tsx b/src/api/api.tsx index 5cae7e4..48d5bb9 100644 --- a/src/api/api.tsx +++ b/src/api/api.tsx @@ -13,20 +13,110 @@ export interface Driver { team_name: 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; +} + export default function api() { + const API = "https://api.openf1.org/v1" + async function getLatestRaceSessionKey(){ //Fetches latest race that has completed, because latest session may not have started. + const result = await fetch(`${API}/sessions?year=2026&session_type=Race`) + if(!result.ok){throw new Error("error")} + const races: RaceSession[] = await result.json() + 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() { + async function getAllDrivers(): Promise { //fetch all driver info 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 } - return { + async function getAllStandings( ) : Promise{ //fetches all standings for all drivers + const sessionKey = await getLatestRaceSessionKey() + const result = await fetch(`${API}/championship_drivers?session_key=${sessionKey}`) + if(!result.ok){throw new Error("Could not fetch driver standings data")} + const data: DriverStandings[] = await result.json() + return data + } - getAllDrivers, + async function getAllTeamStandings(): Promise { //fetches all team standings + const sessionKey = await getLatestRaceSessionKey() + const result = await fetch(`${API}/championship_teams?session_key=${sessionKey}`) + if (!result.ok) { + throw new Error(`Could not fetch team standings data (${result.status})`) + } + const data: TeamStandings[] = await result.json() + return data + } + async function getStandingsByTeamName(teamName: string): Promise { //fetches standings for a single team + const sessionKey = await getLatestRaceSessionKey() + const result = await fetch( + `${API}/championship_teams?session_key=${sessionKey}&team_name=${encodeURIComponent(teamName)}` + ) + if (!result.ok) { + throw new Error(`Could not fetch team standings data (${result.status})`) + } + const data: TeamStandings[] = await result.json() + return 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 { + 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( + - + + + , ) From 897c2294266572034e6e98bfb4ec52f3479b8f0f Mon Sep 17 00:00:00 2001 From: Wilhelm Svensgaard Rolstad Date: Tue, 15 Sep 2026 13:02:08 +0200 Subject: [PATCH 2/2] feat: added apis for every meet, session, and driver results incase we want to use this under each driver information --- src/App.css | 73 +++++++++++++++++++++++++ src/App.tsx | 105 +++++++++++++++++++++++++++++++++++- src/api/api.tsx | 141 +++++++++++++++++++++++++++++++++++++++--------- 3 files changed, 293 insertions(+), 26 deletions(-) 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 f1b8fc1..e44d72b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,11 +1,14 @@ import './App.css' 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 { getDriversWithStandings, getAllTeamStandings } = api() + const { getDriversWithStandings, getAllTeamStandings, getAllSessionResults, getAllSessions, getAllMeetings } = api() const {data: driversWithStandings = [], isPending: driversWithStandingsPending, isError: driversWithStandingsError} = useQuery({ queryKey: ['driversWithStandings'], @@ -14,9 +17,48 @@ function App() { const {data: teamStandings = [], isPending: teamStandingsPending, isError: teamStandingsError} = useQuery({ queryKey: ['teamStandings'], - queryFn: getAllTeamStandings + 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 ( <>
@@ -39,6 +81,65 @@ function App() { )) }
+ + {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 48d5bb9..c5dbd94 100644 --- a/src/api/api.tsx +++ b/src/api/api.tsx @@ -13,6 +13,46 @@ 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; @@ -41,14 +81,79 @@ export interface TeamStandings { 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 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) + } + + async function getAllSessionResults(sessions?: Session[]): Promise { + sessions = sessions ?? await getAllSessions() + if (sessions.length === 0) { return [] } + + // 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 result = await fetch(`${API}/sessions?year=2026&session_type=Race`) - if(!result.ok){throw new Error("error")} - const races: RaceSession[] = await result.json() + 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() ) @@ -60,40 +165,25 @@ export default function api() { } async function getAllDrivers(): Promise { //fetch all driver info - 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 + 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() - const result = await fetch(`${API}/championship_drivers?session_key=${sessionKey}`) - if(!result.ok){throw new Error("Could not fetch driver standings data")} - const data: DriverStandings[] = await result.json() - return data + 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() - const result = await fetch(`${API}/championship_teams?session_key=${sessionKey}`) - if (!result.ok) { - throw new Error(`Could not fetch team standings data (${result.status})`) - } - const data: TeamStandings[] = await result.json() - return data + 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() - const result = await fetch( - `${API}/championship_teams?session_key=${sessionKey}&team_name=${encodeURIComponent(teamName)}` + return fetchApi( + `${API}/championship_teams?session_key=${sessionKey}&team_name=${encodeURIComponent(teamName)}`, + "Could not fetch team standings data" ) - if (!result.ok) { - throw new Error(`Could not fetch team standings data (${result.status})`) - } - const data: TeamStandings[] = await result.json() - return data } async function getDriversWithStandings(){ //combines the driver info and driver standings into a single array. @@ -113,6 +203,9 @@ export default function api() { } return { + getAllMeetings, + getAllSessions, + getAllSessionResults, getAllDrivers, getAllStandings, getDriversWithStandings,