From 1077063d2ad5594a26ab959d62905e7e1630a1bf Mon Sep 17 00:00:00 2001 From: Ludvig Zapffe Date: Wed, 16 Sep 2026 13:58:45 +0200 Subject: [PATCH 1/2] feat: add detailed driver overview and selection functionality --- src/App.css | 111 +++++++++++++++++++++++++++++++- src/App.tsx | 74 +++++++++++++++------ src/components/DriverCard.tsx | 17 +++-- src/components/DriverDetail.tsx | 110 +++++++++++++++++++++++++++++++ 4 files changed, 287 insertions(+), 25 deletions(-) create mode 100644 src/components/DriverDetail.tsx diff --git a/src/App.css b/src/App.css index 9fd856e..1de9cb7 100644 --- a/src/App.css +++ b/src/App.css @@ -20,6 +20,12 @@ body { padding: 10px; margin: 9px; + + cursor: pointer; +} + +.driver-card-selected { + box-shadow: 0 0 0 3px white; } .driver-card img { @@ -35,10 +41,11 @@ body { font-weight: 400; } -.standing-drivers { +.content-row { display: flex; - flex-direction: column; align-items: flex-start; + gap: 2rem; + flex-wrap: wrap; } .standing-drivers { @@ -52,6 +59,106 @@ body { align-self: center; } +.driver-detail { + background-color: #15151e; + border-radius: 30px; + + color: white; + font-family: "Titillium Web", sans-serif; + + width: 360px; + padding: 2rem; + margin: 10px; + + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + + position: sticky; + top: 1rem; +} + +.driver-detail-nav { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; +} + +.driver-detail-nav-button { + background-color: transparent; + border: 1px solid grey; + border-radius: 10px; + color: white; + font-size: 1rem; + cursor: pointer; +} + +.driver-detail-nav-button:disabled { + color: grey; + border-color: grey; + cursor: not-allowed; +} + +.driver-detail-photo { + height: 240px; + width: auto; + max-width: 100%; + border-radius: 25px; +} + +.driver-detail-heading { + display: flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; + gap: 1rem; +} + +.driver-detail-name { + font-size: 22px; + font-weight: 400; + margin: 0; + text-align: center; +} + +.driver-detail-number { + font-size: 50px; + font-weight: 700; +} + +.driver-detail-stats { + display: flex; + flex-direction: column; + width: 100%; + margin-top: 0.5rem; +} + +.driver-detail-stat-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.65rem 0; +} + +.driver-detail-stat-label { + color: grey; + font-size: 1rem; +} + +.driver-detail-stat-value { + font-size: 1.25rem; + font-weight: 600; +} + +.driver-detail-divider { + width: 100%; + margin: 0 auto; + border-top: 1px solid grey; +} + .meeting-list { width: 100%; max-width: 760px; diff --git a/src/App.tsx b/src/App.tsx index e44d72b..62bbe91 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,9 +1,11 @@ import './App.css' +import { 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' +import DriverDetail from './components/DriverDetail' const sessionDate = new Intl.DateTimeFormat('en-GB', { dateStyle: 'medium', timeZone: 'UTC' }) @@ -15,6 +17,15 @@ function App() { queryFn: getDriversWithStandings }) + const [selectedIndex, setSelectedIndex] = useState(0) + const clampedIndex = driversWithStandings.length ? Math.min(selectedIndex, driversWithStandings.length - 1) : 0 + const selectedDriver = driversWithStandings[clampedIndex] + + function selectDriverByNumber(driverNumber: number) { + const index = driversWithStandings.findIndex((driver) => driver.driver_number === driverNumber) + if (index !== -1) { setSelectedIndex(index) } + } + const {data: teamStandings = [], isPending: teamStandingsPending, isError: teamStandingsError} = useQuery({ queryKey: ['teamStandings'], queryFn: getAllTeamStandings, @@ -61,25 +72,50 @@ function App() { return ( <> -
-

F1 The Website

- { 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_name: driver.team_name, - points_current: driver.points_current, - position_current: driver.position_current - }} /> - )) - } +

F1 The Website

+
+
+ { driversWithStandingsPending ? "loading drivers..." : driversWithStandingsError ? "Could not load drivers." : + driversWithStandings.map((driver) => ( + team.team_name === driver.team_name)} + teamStandingsPending={teamStandingsPending} + teamStandingsError={teamStandingsError} + isSelected={selectedDriver?.driver_number === driver.driver_number} + onSelect={() => selectDriverByNumber(driver.driver_number)} + driver={{ + name: driver.full_name, + number: driver.driver_number, + headshot_url: driver.headshot_url, + team_colour: driver.team_colour, + team_name: driver.team_name, + points_current: driver.points_current, + position_current: driver.position_current + }} /> + )) + } +
+ + team.team_name === selectedDriver.team_name) : undefined} + teamStandingsPending={teamStandingsPending} + teamStandingsError={teamStandingsError} + hasPrev={clampedIndex > 0} + hasNext={clampedIndex < driversWithStandings.length - 1} + onPrev={() => setSelectedIndex((index) => Math.max(0, index - 1))} + onNext={() => setSelectedIndex((index) => Math.min(driversWithStandings.length - 1, index + 1))} + />
{sessionsError || sessionResultsError ? ( diff --git a/src/components/DriverCard.tsx b/src/components/DriverCard.tsx index f8a4e14..f5b4a73 100644 --- a/src/components/DriverCard.tsx +++ b/src/components/DriverCard.tsx @@ -1,6 +1,9 @@ import type { TeamStandings } from '../api/api' -interface DriverProps { +// Default "avatar" used when api does not return a driver photo +const DEFAULT_AVATAR_URL = 'https://canvas.ntnu.no/images/messages/avatar-50.png' + +export interface DriverProps { name: string; number: number; headshot_url: string; @@ -16,15 +19,21 @@ interface DriverCardProps { teamStanding?: TeamStandings; teamStandingsPending: boolean; teamStandingsError: boolean; + isSelected?: boolean; + onSelect?: () => void; } -export default function DriverCard({ driver, teamStanding, teamStandingsPending, teamStandingsError }: DriverCardProps) { +export default function DriverCard({ driver, teamStanding, teamStandingsPending, teamStandingsError, isSelected, onSelect }: DriverCardProps) { return ( -
+

{driver.number}

{driver.name}

- {driver.name} + {driver.name}

{driver.points_current}

{driver.position_current}

diff --git a/src/components/DriverDetail.tsx b/src/components/DriverDetail.tsx new file mode 100644 index 0000000..79e5fcd --- /dev/null +++ b/src/components/DriverDetail.tsx @@ -0,0 +1,110 @@ +import type { TeamStandings } from '../api/api' +import type { DriverProps } from './DriverCard' + +// Default "avatar" used when api does not return a driver photo +const DEFAULT_AVATAR_URL = 'https://canvas.ntnu.no/images/messages/avatar-50.png' + +interface DriverDetailProps { + driver?: DriverProps; + teamStanding?: TeamStandings; + teamStandingsPending: boolean; + teamStandingsError: boolean; + isLoading: boolean; + isError: boolean; + hasPrev: boolean; + hasNext: boolean; + onPrev: () => void; + onNext: () => void; +} + +export default function DriverDetail({ + driver, + teamStanding, + teamStandingsPending, + teamStandingsError, + isLoading, + isError, + hasPrev, + hasNext, + onPrev, + onNext, +}: DriverDetailProps) { + + return ( +

+
+ + +
+ + {isLoading ? ( +

Loading driver...

+ ) : isError ? ( +

Could not load driver details.

+ ) : !driver ? ( +

No driver selected.

+ ) : ( + <> + {driver.name} { event.currentTarget.src = DEFAULT_AVATAR_URL }} + /> + +
+

{driver.name}

+ {driver.number} +
+ +
+
+ Team + {driver.team_name} +
+
+ +
+ Position + {driver.position_current || '—'} +
+
+ +
+ Driver points + {driver.points_current} +
+
+ +
+ Team points + + {teamStandingsPending + ? 'Loading...' + : teamStandingsError + ? 'Unavailable' + : teamStanding?.points_current ?? 'Unavailable'} + +
+
+ + )} +
+ ) +} From 0f95f7ac3971af182c1bc745c13ab871f012e3e8 Mon Sep 17 00:00:00 2001 From: Ludvig Zapffe Date: Wed, 16 Sep 2026 14:25:14 +0200 Subject: [PATCH 2/2] Merge branch dev, combining driver detail panel, favorites, and search/filter/sort --- node_modules/.package-lock.json | 26 ++ node_modules/.vite/deps/_metadata.json | 26 +- src/App.css | 15 + src/App.tsx | 367 ++++++++++++++++++------- src/api/api.tsx | 4 +- src/components/DriverCard.tsx | 30 +- src/components/SearchAndFilter.tsx | 62 +++++ 7 files changed, 419 insertions(+), 111 deletions(-) create mode 100644 src/components/SearchAndFilter.tsx diff --git a/node_modules/.package-lock.json b/node_modules/.package-lock.json index d4c0342..b270bfd 100644 --- a/node_modules/.package-lock.json +++ b/node_modules/.package-lock.json @@ -570,6 +570,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/node_modules/.vite/deps/_metadata.json b/node_modules/.vite/deps/_metadata.json index 9da5a4c..4a2b13f 100644 --- a/node_modules/.vite/deps/_metadata.json +++ b/node_modules/.vite/deps/_metadata.json @@ -1,43 +1,49 @@ { - "hash": "cbab395f", + "hash": "dc2b6b92", "configHash": "e7f202bd", - "lockfileHash": "4ccba3ff", - "browserHash": "da41ee4c", + "lockfileHash": "558c6ded", + "browserHash": "dec6a747", "optimized": { + "@tanstack/react-query": { + "src": "../../@tanstack/react-query/build/modern/index.js", + "file": "@tanstack_react-query.js", + "fileHash": "9b4be17a", + "needsInterop": false + }, "react-dom": { "src": "../../react-dom/index.js", "file": "react-dom.js", - "fileHash": "390c96fc", + "fileHash": "1c0d4d92", "needsInterop": true }, "react-dom/client": { "src": "../../react-dom/client.js", "file": "react-dom_client.js", - "fileHash": "fd29f39a", + "fileHash": "13bb0152", "needsInterop": true }, "react": { "src": "../../react/index.js", "file": "react.js", - "fileHash": "3bb5d513", + "fileHash": "4f799b01", "needsInterop": true }, "react/jsx-dev-runtime": { "src": "../../react/jsx-dev-runtime.js", "file": "react_jsx-dev-runtime.js", - "fileHash": "ff3b1fc9", + "fileHash": "e387570b", "needsInterop": true }, "react/jsx-runtime": { "src": "../../react/jsx-runtime.js", "file": "react_jsx-runtime.js", - "fileHash": "cacf0771", + "fileHash": "7ac592c3", "needsInterop": true } }, "chunks": { - "rolldown-runtime-BvCyGRYZ": { - "file": "rolldown-runtime-BvCyGRYZ.js", + "rolldown-runtime-BPOCksWG": { + "file": "rolldown-runtime-BPOCksWG.js", "isDynamicEntry": false } } diff --git a/src/App.css b/src/App.css index 1de9cb7..1d1a421 100644 --- a/src/App.css +++ b/src/App.css @@ -231,3 +231,18 @@ body { padding: 0.5rem; margin-bottom: 0.5rem; } + +.favorite-button { + color: orange; + background-color: #15151e; + padding: 0.5rem 1rem; + border-radius: 0.375rem; + cursor: pointer; + border: none; + font-size: 1.7rem; +} +.favorite-button:hover { + color: white; +} + + diff --git a/src/App.tsx b/src/App.tsx index 62bbe91..7312224 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,120 +1,270 @@ -import './App.css' -import { useState } from 'react' -import api from './api/api' -import type { Session, SessionResult } from './api/api' -import { useQuery } from '@tanstack/react-query' +import "./App.css"; +import api from "./api/api"; +import type { Session, SessionResult } from "./api/api"; +import { useQuery } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; -import DriverCard from './components/DriverCard' -import DriverDetail from './components/DriverDetail' +import DriverCard from "./components/DriverCard"; +import SearchAndFilter from "./components/SearchAndFilter"; +import DriverDetail from "./components/DriverDetail"; -const sessionDate = new Intl.DateTimeFormat('en-GB', { dateStyle: 'medium', timeZone: 'UTC' }) +const sessionDate = new Intl.DateTimeFormat("en-GB", { + dateStyle: "medium", + timeZone: "UTC", +}); + +type SortOrder = "none" | "asc" | "desc"; function App() { - const { getDriversWithStandings, getAllTeamStandings, getAllSessionResults, getAllSessions, getAllMeetings } = api() + const [favorites, setFavorites] = useState(() => + JSON.parse(localStorage.getItem("favorites") ?? "[]"), + ); + const [showFavorites, setShowFavorites] = useState(false); - const {data: driversWithStandings = [], isPending: driversWithStandingsPending, isError: driversWithStandingsError} = useQuery({ - queryKey: ['driversWithStandings'], - queryFn: getDriversWithStandings - }) + useEffect(() => { + localStorage.setItem("favorites", JSON.stringify(favorites)); + }, [favorites]); - const [selectedIndex, setSelectedIndex] = useState(0) - const clampedIndex = driversWithStandings.length ? Math.min(selectedIndex, driversWithStandings.length - 1) : 0 - const selectedDriver = driversWithStandings[clampedIndex] + function toggleFavorite(driverNumber: number) { + setFavorites((current) => { + if (current.includes(driverNumber)) { + return current.filter((number) => number !== driverNumber); + } - function selectDriverByNumber(driverNumber: number) { - const index = driversWithStandings.findIndex((driver) => driver.driver_number === driverNumber) - if (index !== -1) { setSelectedIndex(index) } + return [...current, driverNumber]; + }); } - const {data: teamStandings = [], isPending: teamStandingsPending, isError: teamStandingsError} = useQuery({ - queryKey: ['teamStandings'], + const [search, setSearch] = useState( + () => sessionStorage.getItem("search") ?? "", + ); + const [team, setTeam] = useState( + () => sessionStorage.getItem("team") ?? "all", + ); + + const [sortOrder, setSortOrder] = useState( + () => (sessionStorage.getItem("sortOrder") as SortOrder) ?? "none", + ); + + useEffect(() => { + sessionStorage.setItem("search", search); + }, [search]); + + useEffect(() => { + sessionStorage.setItem("team", team); + }, [team]); + + useEffect(() => { + sessionStorage.setItem("sortOrder", sortOrder); + }, [sortOrder]); + + const { + getDriversWithStandings, + getAllTeamStandings, + getAllSessionResults, + getAllSessions, + getAllMeetings, + } = api(); + + 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 - }) + enabled: !driversWithStandingsPending, + }); - const {data: allSessions = [], isPending: sessionsPending, isError: sessionsError} = useQuery({ - queryKey: ['sessions', 2026], + const { + data: allSessions = [], + isPending: sessionsPending, + isError: sessionsError, + } = useQuery({ + queryKey: ["sessions", 2026], queryFn: getAllSessions, - enabled: !driversWithStandingsPending - }) + enabled: !driversWithStandingsPending, + }); - const {data: allSessionResults = [], isPending: sessionResultsPending, isError: sessionResultsError} = useQuery({ - queryKey: ['sessionResults', 2026, allSessions.map((session) => session.session_key)], + const { + data: allSessionResults = [], + isPending: sessionResultsPending, + isError: sessionResultsError, + } = useQuery({ + queryKey: [ + "sessionResults", + 2026, + allSessions.map((session) => session.session_key), + ], queryFn: () => getAllSessionResults(allSessions), - enabled: !sessionsPending && !sessionsError - }) + enabled: !sessionsPending && !sessionsError, + }); - const {data: allMeetings = [], isPending: meetingsPending, isError: meetingsError} = useQuery({ - queryKey: ['meetings', 2026], + const { + data: allMeetings = [], + isPending: meetingsPending, + isError: meetingsError, + } = useQuery({ + queryKey: ["meetings", 2026], queryFn: getAllMeetings, - enabled: !driversWithStandingsPending - }) + enabled: !driversWithStandingsPending, + }); + + const filteredDrivers = driversWithStandings + .filter((driver) => { + const matchesSearch = driver.full_name + .toLowerCase() + .includes(search.toLowerCase()); + const matchesTeam = team === "all" || driver.team_name === team; + const matchesFavorites = + !showFavorites || favorites.includes(driver.driver_number); + return matchesSearch && matchesTeam && matchesFavorites; + }) + .sort((a, b) => { + if (sortOrder === "asc") { + return b.position_current - a.position_current; + } else if (sortOrder === "desc") { + return a.position_current - b.position_current; + } else { + return 0; // No sorting, maintain original order + } + }); + + // Detail panel selection: Previous/Next step through the currently filtered + // list only, so it stays in sync with whatever the left list is showing. + const [selectedIndex, setSelectedIndex] = useState(0); + const clampedIndex = filteredDrivers.length + ? Math.min(selectedIndex, filteredDrivers.length - 1) + : 0; + const selectedDriver = filteredDrivers[clampedIndex]; - 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)) + function selectDriverByNumber(driverNumber: number) { + const index = filteredDrivers.findIndex( + (driver) => driver.driver_number === driverNumber, + ); + if (index !== -1) { + setSelectedIndex(index); + } + } + + 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 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() + 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) + 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)) + results.sort((a, b) => (a.position ?? Infinity) - (b.position ?? Infinity)); } return ( <>

F1 The Website

+ + driver.team_name)), + )} + /> +
- { driversWithStandingsPending ? "loading drivers..." : driversWithStandingsError ? "Could not load drivers." : - driversWithStandings.map((driver) => ( - team.team_name === driver.team_name)} - teamStandingsPending={teamStandingsPending} - teamStandingsError={teamStandingsError} - isSelected={selectedDriver?.driver_number === driver.driver_number} - onSelect={() => selectDriverByNumber(driver.driver_number)} - driver={{ - name: driver.full_name, - number: driver.driver_number, - headshot_url: driver.headshot_url, - team_colour: driver.team_colour, - team_name: driver.team_name, - points_current: driver.points_current, - position_current: driver.position_current - }} /> - )) - } + {driversWithStandingsPending + ? "loading drivers..." + : driversWithStandingsError + ? "Could not load drivers." + : filteredDrivers.length === 0 + ? "No drivers match your filters." + : filteredDrivers.map((driver) => ( + toggleFavorite(driver.driver_number)} + isSelected={selectedDriver?.driver_number === driver.driver_number} + onSelect={() => selectDriverByNumber(driver.driver_number)} + teamStanding={teamStandings.find( + (team) => 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_name: driver.team_name, + points_current: driver.points_current, + position_current: driver.position_current, + }} + /> + ))}
team.team_name === selectedDriver.team_name) : undefined} + driver={ + selectedDriver + ? { + name: selectedDriver.full_name, + number: selectedDriver.driver_number, + headshot_url: selectedDriver.headshot_url, + team_colour: selectedDriver.team_colour, + team_name: selectedDriver.team_name, + points_current: selectedDriver.points_current, + position_current: selectedDriver.position_current, + } + : undefined + } + teamStanding={ + selectedDriver + ? teamStandings.find((team) => team.team_name === selectedDriver.team_name) + : undefined + } teamStandingsPending={teamStandingsPending} teamStandingsError={teamStandingsError} hasPrev={clampedIndex > 0} - hasNext={clampedIndex < driversWithStandings.length - 1} + hasNext={clampedIndex < filteredDrivers.length - 1} onPrev={() => setSelectedIndex((index) => Math.max(0, index - 1))} - onNext={() => setSelectedIndex((index) => Math.min(driversWithStandings.length - 1, index + 1))} + onNext={() => + setSelectedIndex((index) => Math.min(filteredDrivers.length - 1, index + 1)) + } />
@@ -127,28 +277,44 @@ function App() { ) : (

2026 meetings

- {meetingsError &&

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

} + {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] + const meeting = meetingsByKey.get(meetingKey); + const firstSession = sessions[0]; + const lastSession = sessions[sessions.length - 1]; return (
- {meeting?.meeting_name ?? firstSession.circuit_short_name} + + {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))} + {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) ?? [] + const results = + resultsBySession.get(session.session_key) ?? []; return ( -
+
{session.session_name} {results.length === 0 ? ( -

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

+

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

) : (
    {results.map((result) => ( -
  • - Pos {result.position ?? '—'}: - {driverNames.get(result.driver_number) ?? `Driver ${result.driver_number}`} +
  • + Pos {result.position ?? "—"}: + + {driverNames.get(result.driver_number) ?? + `Driver ${result.driver_number}`} +
  • ))}
)}
- ) + ); })}
- ) + ); })}
)} - ) + ); } -export default App +export default App; diff --git a/src/api/api.tsx b/src/api/api.tsx index c5dbd94..e2d4e67 100644 --- a/src/api/api.tsx +++ b/src/api/api.tsx @@ -124,7 +124,7 @@ function fetchApi(url: string, errorMessage: string): Promise { } export default function api() { - + const API = "https://api.openf1.org/v1" async function getAllMeetings(): Promise { @@ -157,7 +157,7 @@ export default function api() { 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) diff --git a/src/components/DriverCard.tsx b/src/components/DriverCard.tsx index f5b4a73..67c688e 100644 --- a/src/components/DriverCard.tsx +++ b/src/components/DriverCard.tsx @@ -19,11 +19,22 @@ interface DriverCardProps { teamStanding?: TeamStandings; teamStandingsPending: boolean; teamStandingsError: boolean; + isFavorite: boolean; + onToggleFavorite: () => void; isSelected?: boolean; onSelect?: () => void; } -export default function DriverCard({ driver, teamStanding, teamStandingsPending, teamStandingsError, isSelected, onSelect }: DriverCardProps) { +export default function DriverCard({ + driver, + teamStanding, + teamStandingsPending, + teamStandingsError, + isFavorite, + onToggleFavorite, + isSelected, + onSelect +}: DriverCardProps) { return (
+

{driver.number}

{driver.name}

{driver.name} @@ -40,8 +64,8 @@ export default function DriverCard({ driver, teamStanding, teamStandingsPending, Team points: {teamStandingsPending ? 'Loading...' : teamStandingsError - ? 'Could not load points' - : teamStanding?.points_current ?? 'Unavailable'} + ? 'Could not load points' + : teamStanding?.points_current ?? 'Unavailable'}

) diff --git a/src/components/SearchAndFilter.tsx b/src/components/SearchAndFilter.tsx new file mode 100644 index 0000000..2b5789a --- /dev/null +++ b/src/components/SearchAndFilter.tsx @@ -0,0 +1,62 @@ +interface SearchAndFilterProps { + showFavorites: boolean; + onShowFavoritesChange: (value: boolean) => void; + search: string; + onSearchChange: (value: string) => void; + team: string; + onTeamChange: (value: string) => void; + teams: string[]; + sortOrder: "none" | "asc" | "desc"; + onSortOrderChange: (value: "none" | "asc" | "desc") => void; +} + +function SearchAndFilter({ + showFavorites, + onShowFavoritesChange, + search, + onSearchChange, + team, + onTeamChange, + teams, + sortOrder, + onSortOrderChange, +}: SearchAndFilterProps) { + return ( +
+ onSearchChange(e.target.value)} + /> + + + + +
+ ); +} + +export default SearchAndFilter;