diff --git a/package.json b/package.json index 310bd8a..880f46b 100644 --- a/package.json +++ b/package.json @@ -29,11 +29,11 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "@tanstack/react-query": "^5.90.5", "classnames": "^2.5.1", "react": "^19.2.0", "react-dom": "^19.2.0", - "react-markdown": "^10.1.0" + "react-markdown": "^10.1.0", + "zustand": "^5.0.11" }, "devDependencies": { "@eslint/js": "^9.38.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ffd5047..9e11be1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,9 +17,6 @@ importers: '@dnd-kit/utilities': specifier: ^3.2.2 version: 3.2.2(react@19.2.0) - '@tanstack/react-query': - specifier: ^5.90.5 - version: 5.90.5(react@19.2.0) classnames: specifier: ^2.5.1 version: 2.5.1 @@ -32,6 +29,9 @@ importers: react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.2.2)(react@19.2.0) + zustand: + specifier: ^5.0.11 + version: 5.0.11(@types/react@19.2.2)(react@19.2.0) devDependencies: '@eslint/js': specifier: ^9.38.0 @@ -1097,14 +1097,6 @@ packages: '@swc/types@0.1.25': resolution: {integrity: sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==} - '@tanstack/query-core@5.90.5': - resolution: {integrity: sha512-wLamYp7FaDq6ZnNehypKI5fNvxHPfTYylE0m/ZpuuzJfJqhR5Pxg9gvGBHZx4n7J+V5Rg5mZxHHTlv25Zt5u+w==} - - '@tanstack/react-query@5.90.5': - resolution: {integrity: sha512-pN+8UWpxZkEJ/Rnnj2v2Sxpx1WFlaa9L6a4UO89p6tTQbeo+m0MS8oYDjbggrR8QcTyjKoYWKS3xJQGr3ExT8Q==} - peerDependencies: - react: ^18 || ^19 - '@ts-morph/common@0.27.0': resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} @@ -3127,6 +3119,24 @@ packages: zod@4.1.12: resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==} + zustand@5.0.11: + resolution: {integrity: sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -4027,13 +4037,6 @@ snapshots: dependencies: '@swc/counter': 0.1.3 - '@tanstack/query-core@5.90.5': {} - - '@tanstack/react-query@5.90.5(react@19.2.0)': - dependencies: - '@tanstack/query-core': 5.90.5 - react: 19.2.0 - '@ts-morph/common@0.27.0': dependencies: fast-glob: 3.3.3 @@ -6523,4 +6526,9 @@ snapshots: zod@4.1.12: {} + zustand@5.0.11(@types/react@19.2.2)(react@19.2.0): + optionalDependencies: + '@types/react': 19.2.2 + react: 19.2.0 + zwitch@2.0.4: {} diff --git a/src/App.tsx b/src/App.tsx index c704309..fb16d43 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,13 +1,14 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useEffect } from "react"; import Main from "./Main"; +import { useStore } from "./store"; export default function App() { - const queryClient = new QueryClient(); + const fetchAll = useStore((s) => s.fetchAll); - return ( - -
- - ); + useEffect(() => { + fetchAll(); + }, [fetchAll]); + + return
; } diff --git a/src/api/api.ts b/src/api/api.ts index 9970ba3..0e77b27 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -2,7 +2,7 @@ const baseUrl = "/api"; const currentYear = new Date().getFullYear(); // const currentYear = 2024; -export const apiPaths = { +const apiPaths = { driverStandings: `${currentYear}/driverstandings/`, constructorStandings: `${currentYear}/constructorstandings/`, raceSchedule: `${currentYear}/races/`, @@ -11,31 +11,9 @@ export const apiPaths = { constructors: `${currentYear}/constructors/`, } as const; -async function get(path: string) { - const response = await fetch(`${baseUrl}/${path}`); - return response.json(); -} - -export async function getDriverStandings() { - return get(apiPaths.driverStandings); -} +export type ApiEndpoint = keyof typeof apiPaths; -export async function getConstructorStandings() { - return get(apiPaths.constructorStandings); -} - -export async function getDrivers() { - return get(apiPaths.drivers); -} - -export async function getConstructors() { - return get(apiPaths.constructors); -} - -export async function getRaceSchedule() { - return get(apiPaths.raceSchedule); -} - -export async function getRaceResults() { - return get(apiPaths.raceResults); +export async function fetchApi(endpoint: ApiEndpoint): Promise { + const response = await fetch(`${baseUrl}/${apiPaths[endpoint]}`); + return response.json(); } diff --git a/src/api/transforms.ts b/src/api/transforms.ts new file mode 100644 index 0000000..809182f --- /dev/null +++ b/src/api/transforms.ts @@ -0,0 +1,126 @@ +import { + IConstructorStanding, + IConstructorStandings, + IDriverStanding, + IDriverStandings, + IRace, + IRaceSchedule, + ITime, +} from "../types/api"; +import { + RaceEvent, + RaceTable, + RaceType, + StandingsList, +} from "../types/entities"; + +export function transformDriverStandings(data: IDriverStandings) { + if (!data?.MRData?.StandingsTable?.StandingsLists?.length) { + return; + } + + const [standingsList] = data.MRData.StandingsTable.StandingsLists; + + const driverStandings: StandingsList = { + ...standingsList, + round: Number(standingsList?.round), + DriverStandings: standingsList.DriverStandings.map( + (standing: IDriverStanding) => ({ + ...standing, + points: Number(standing.points), + position: Number(standing.position), + wins: Number(standing.wins), + Driver: { + ...standing.Driver, + permanentNumber: Number(standing.Driver.permanentNumber), + }, + }), + ), + }; + + return driverStandings; +} + +export function transformConstructorStandings(data?: IConstructorStandings) { + if (!data?.MRData?.StandingsTable?.StandingsLists?.length) { + return; + } + + const [standingsList] = data.MRData.StandingsTable.StandingsLists; + + const constructorStandings: StandingsList = { + ...standingsList, + round: Number(standingsList.round), + ConstructorStandings: standingsList.ConstructorStandings.map( + (standing: IConstructorStanding) => ({ + ...standing, + position: Number(standing.position), + points: Number(standing.points), + wins: Number(standing.wins), + }), + ), + }; + + return constructorStandings; +} + +function dateTimeToDate(dateTime: ITime): Date { + return new Date(`${dateTime.date}T${dateTime.time}`); +} + +export function transformRaceSchedule( + responseData: IRaceSchedule, +): RaceTable | undefined { + if (!Array.isArray(responseData?.MRData?.RaceTable?.Races)) { + return; + } + + const raceTable = responseData.MRData.RaceTable; + + return { + ...raceTable, + season: Number(raceTable.season), + Races: raceTable.Races.map((race: IRace) => ({ + ...race, + round: Number(race.round), + FirstPractice: dateTimeToDate(race.FirstPractice), + Qualifying: dateTimeToDate(race.Qualifying), + SecondPractice: race.SecondPractice + ? dateTimeToDate(race.SecondPractice) + : undefined, + ThirdPractice: race.ThirdPractice + ? dateTimeToDate(race.ThirdPractice) + : undefined, + Sprint: race.Sprint ? dateTimeToDate(race.Sprint) : undefined, + Circuit: { + ...race.Circuit, + Location: { + ...race.Circuit.Location, + lat: Number(race.Circuit.Location.lat), + long: Number(race.Circuit.Location.long), + }, + }, + })), + }; +} + +export function getEventsSchedule(raceSchedule: RaceTable): RaceEvent[] { + return raceSchedule.Races.reduce((eventList: RaceEvent[], current) => { + const id = current.round.toString(); + + if (current.Sprint) { + eventList.push({ + Race: current, + eventType: RaceType.SPRINT_RACE, + id: id + "-s", + }); + } + + eventList.push({ + Race: current, + eventType: RaceType.GRAND_PRIX, + id, + }); + return eventList; + }, []); +} diff --git a/src/api/useConstructorStandings.ts b/src/api/useConstructorStandings.ts deleted file mode 100644 index 485f8b0..0000000 --- a/src/api/useConstructorStandings.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; - -import { IConstructorStanding, IConstructorStandings } from "../types/api"; -import { StandingsList } from "../types/entities"; -import { getConstructorStandings } from "./api"; - -function transformConstructorStandings(data?: IConstructorStandings) { - if (!data?.MRData?.StandingsTable?.StandingsLists?.length) { - return; - } - - const [standingsList] = data.MRData.StandingsTable.StandingsLists; - - const constructorStandings: StandingsList = { - ...standingsList, - round: Number(standingsList.round), - ConstructorStandings: standingsList.ConstructorStandings.map( - (standing: IConstructorStanding) => ({ - ...standing, - position: Number(standing.position), - points: Number(standing.points), - wins: Number(standing.wins), - }), - ), - }; - - return constructorStandings; -} - -export function useConstructorStandings() { - const { data, isLoading, isError } = useQuery({ - queryKey: ["constructorStandings"], - queryFn: getConstructorStandings, - }); - - const constructorStandings = transformConstructorStandings(data); - - return { constructorStandings, isLoading, isError }; -} diff --git a/src/api/useConstructors.ts b/src/api/useConstructors.ts deleted file mode 100644 index f995b3c..0000000 --- a/src/api/useConstructors.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; - -import { IConstructor } from "../types/api"; -import { getConstructors } from "./api"; - -export function useConstructors() { - const { - data: constructors, - isLoading, - isError, - } = useQuery({ - queryKey: ["constructors"], - queryFn: getConstructors, - }); - - return { constructors, isLoading, isError }; -} diff --git a/src/api/useDriverStandings.ts b/src/api/useDriverStandings.ts deleted file mode 100644 index 78ea329..0000000 --- a/src/api/useDriverStandings.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { useMemo } from "react"; - -import { IDriverStanding, IDriverStandings } from "../types/api"; -import { StandingsList } from "../types/entities"; -import { getDriverStandings } from "./api"; - -function transformDriverStandings(data: IDriverStandings) { - if (!data?.MRData?.StandingsTable?.StandingsLists?.length) { - return; - } - - const [standingsList] = data.MRData.StandingsTable.StandingsLists; - - const driverStandings: StandingsList = { - ...standingsList, - round: Number(standingsList?.round), - DriverStandings: standingsList.DriverStandings.map( - (standing: IDriverStanding) => ({ - ...standing, - points: Number(standing.points), - position: Number(standing.position), - wins: Number(standing.wins), - Driver: { - ...standing.Driver, - permanentNumber: Number(standing.Driver.permanentNumber), - }, - }), - ), - }; - - return driverStandings; -} - -export function useDriverStandings() { - const { data, isLoading, isError } = useQuery({ - queryKey: ["driverStandings"], - queryFn: getDriverStandings, - }); - - const driverStandings = data ? transformDriverStandings(data) : undefined; - - return { driverStandings, isLoading, isError }; -} - -export function useDefaultRaceResult() { - const { driverStandings } = useDriverStandings(); - - return useMemo(() => { - if (!driverStandings?.DriverStandings) { - return []; - } - - const standings = driverStandings.DriverStandings; - return standings.map((driverStanding, index) => ({ - Driver: driverStanding.Driver, - Constructors: driverStanding.Constructors, - fastestLap: index === 0, - })); - }, [driverStandings?.DriverStandings]); -} diff --git a/src/api/useDrivers.ts b/src/api/useDrivers.ts deleted file mode 100644 index da71fb5..0000000 --- a/src/api/useDrivers.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; - -import { IDriver } from "../types/api"; -import { getDrivers } from "./api"; - -export function useDrivers() { - const { - data: drivers, - isLoading, - isError, - } = useQuery({ - queryKey: ["drivers"], - queryFn: getDrivers, - }); - - return { drivers, isLoading, isError }; -} diff --git a/src/api/useRaceSchedule.ts b/src/api/useRaceSchedule.ts deleted file mode 100644 index 9343279..0000000 --- a/src/api/useRaceSchedule.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; - -import { IRace, IRaceSchedule, ITime } from "../types/api"; -import { RaceEvent, RaceTable, RaceType } from "../types/entities"; -import { getRaceSchedule } from "./api"; - -function dateTimeToDate(dateTime: ITime): Date { - return new Date(`${dateTime.date}T${dateTime.time}`); -} - -function transformRaceSchedule( - responseData: IRaceSchedule, -): RaceTable | undefined { - if (!Array.isArray(responseData?.MRData?.RaceTable?.Races)) { - return; - } - - const raceTable = responseData.MRData.RaceTable; - - return { - ...raceTable, - season: Number(raceTable.season), - Races: raceTable.Races.map((race: IRace) => ({ - ...race, - round: Number(race.round), - FirstPractice: dateTimeToDate(race.FirstPractice), - Qualifying: dateTimeToDate(race.Qualifying), - SecondPractice: race.SecondPractice - ? dateTimeToDate(race.SecondPractice) - : undefined, - ThirdPractice: race.ThirdPractice - ? dateTimeToDate(race.ThirdPractice) - : undefined, - Sprint: race.Sprint ? dateTimeToDate(race.Sprint) : undefined, - Circuit: { - ...race.Circuit, - Location: { - ...race.Circuit.Location, - lat: Number(race.Circuit.Location.lat), - long: Number(race.Circuit.Location.long), - }, - }, - })), - }; -} - -function getEventsSchedule(raceSchedule: RaceTable): RaceEvent[] { - return raceSchedule.Races.reduce((eventList: RaceEvent[], current) => { - const id = current.round.toString(); - - if (current.Sprint) { - eventList.push({ - Race: current, - eventType: RaceType.SPRINT_RACE, - id: id + "-s", - }); - } - - eventList.push({ - Race: current, - eventType: RaceType.GRAND_PRIX, - id, - }); - return eventList; - }, []); -} - -export function useRaceSchedule() { - const { data, isLoading } = useQuery({ - queryKey: ["raceSchedule"], - queryFn: getRaceSchedule, - }); - - const raceScheduleObject = data ? transformRaceSchedule(data) : undefined; - - const eventSchedule = raceScheduleObject - ? getEventsSchedule(raceScheduleObject) - : []; - - return { - raceSchedule: raceScheduleObject, - eventSchedule, - isLoading, - }; -} diff --git a/src/changelog/Changelog.tsx b/src/changelog/Changelog.tsx index 3c2e581..4eb8095 100644 --- a/src/changelog/Changelog.tsx +++ b/src/changelog/Changelog.tsx @@ -1,13 +1,16 @@ -import { useQuery } from "@tanstack/react-query"; +import { useEffect } from "react"; import Markdown from "react-markdown"; import { css } from "../../styled-system/css"; +import { useStore } from "../store"; export function Changelog() { - const { data: changelog } = useQuery({ - queryKey: ["changelog"], - queryFn: () => fetch("/changelog.md").then((res) => res.text()), - }); + const changelog = useStore((s) => s.changelog); + const fetchChangelog = useStore((s) => s.fetchChangelog); + + useEffect(() => { + fetchChangelog(); + }, [fetchChangelog]); if (!changelog) return null; diff --git a/src/components/StandingsController.tsx b/src/components/StandingsController.tsx index b3ac8db..c7f247c 100644 --- a/src/components/StandingsController.tsx +++ b/src/components/StandingsController.tsx @@ -1,9 +1,7 @@ import { useState } from "react"; -import { useConstructorStandings } from "../api/useConstructorStandings"; -import { useDriverStandings } from "../api/useDriverStandings"; -import { useRaceSchedule } from "../api/useRaceSchedule"; import { getStandingsAfterRounds } from "../services/standings"; +import { useStore } from "../store"; import { RaceType, UpcomingRaceResult } from "../types/entities"; import { LoadingLayout } from "./LoadingLayout"; import { NoSeasonData } from "./NoSeasonData"; @@ -11,24 +9,15 @@ import { Standings } from "./Standings/Standings"; import { UpcomingRaceResultList } from "./UpcomingRaceResults/UpcomingRaceResultList"; export function StandingsController() { - const { - raceSchedule, - eventSchedule, - isLoading: isRaceScheduleLoading, - } = useRaceSchedule(); - const { driverStandings, isLoading: isDriverStandingsLoading } = - useDriverStandings(); - const { constructorStandings, isLoading: isConstructorStandingsLoading } = - useConstructorStandings(); + const raceSchedule = useStore((s) => s.raceSchedule); + const eventSchedule = useStore((s) => s.eventSchedule); + const driverStandings = useStore((s) => s.driverStandings); + const constructorStandings = useStore((s) => s.constructorStandings); + const isLoading = useStore((s) => s.isLoading); const [upcomingRaceResultList, setUpcomingRaceResultList] = useState< UpcomingRaceResult[] >([]); - - const isLoading = - isRaceScheduleLoading || - isDriverStandingsLoading || - isConstructorStandingsLoading; const hasRequiredData = raceSchedule && driverStandings?.DriverStandings && diff --git a/src/store.ts b/src/store.ts new file mode 100644 index 0000000..0378e15 --- /dev/null +++ b/src/store.ts @@ -0,0 +1,79 @@ +import { create } from "zustand"; + +import { fetchApi } from "./api/api"; +import { + getEventsSchedule, + transformConstructorStandings, + transformDriverStandings, + transformRaceSchedule, +} from "./api/transforms"; +import { + IConstructorStandings, + IDriverStandings, + IRaceSchedule, +} from "./types/api"; +import { RaceEvent, RaceTable, StandingsList } from "./types/entities"; + +interface F1Store { + driverStandings: StandingsList | undefined; + constructorStandings: StandingsList | undefined; + raceSchedule: RaceTable | undefined; + eventSchedule: RaceEvent[]; + changelog: string | undefined; + isLoading: boolean; + isError: boolean; + fetchAll: () => Promise; + fetchChangelog: () => Promise; +} + +export const useStore = create((set, get) => ({ + driverStandings: undefined, + constructorStandings: undefined, + raceSchedule: undefined, + eventSchedule: [], + changelog: undefined, + isLoading: false, + isError: false, + + fetchAll: async () => { + if (get().isLoading || get().driverStandings) return; + + set({ isLoading: true, isError: false }); + + try { + const [driverData, constructorData, raceData] = await Promise.all([ + fetchApi("driverStandings"), + fetchApi("constructorStandings"), + fetchApi("raceSchedule"), + ]); + + const driverStandings = transformDriverStandings(driverData); + const constructorStandings = + transformConstructorStandings(constructorData); + const raceSchedule = transformRaceSchedule(raceData); + const eventSchedule = raceSchedule ? getEventsSchedule(raceSchedule) : []; + + set({ + driverStandings, + constructorStandings, + raceSchedule, + eventSchedule, + isLoading: false, + }); + } catch { + set({ isLoading: false, isError: true }); + } + }, + + fetchChangelog: async () => { + if (get().changelog) return; + + try { + const response = await fetch("/changelog.md"); + const text = await response.text(); + set({ changelog: text }); + } catch { + // Changelog is non-critical, silently fail + } + }, +}));