Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions dash/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"pako": "2.1.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-icons": "^5.5.0",
"sharp": "0.34.1",
"zod": "3.23.8",
"zustand": "5.0.3"
Expand Down
188 changes: 158 additions & 30 deletions dash/src/components/dashboard/LeaderBoard.tsx
Original file line number Diff line number Diff line change
@@ -1,49 +1,160 @@
import { AnimatePresence, LayoutGroup } from "motion/react";
import clsx from "clsx";
import { BiSortAlt2, BiSortDown, BiSortUp } from "react-icons/bi";

import { useSettingsStore } from "@/stores/useSettingsStore";
import { useDataStore } from "@/stores/useDataStore";
import { useSortingStore, type SortingCriteria } from "@/stores/useSortingStore";

import { sortPos } from "@/lib/sorting";
import { sortDrivers } from "@/lib/sorting";

import Driver from "@/components/driver/Driver";
import Select from "@/components/ui/Select";

const sortOptions = [
{ label: "Position", value: "position" as SortingCriteria },
{ label: "Best Lap", value: "bestLap" as SortingCriteria },
{ label: "Last Lap", value: "lastLap" as SortingCriteria },
{ label: "Pit Status", value: "pitStatus" as SortingCriteria },
{ label: "Position Change", value: "positionChange" as SortingCriteria },
{ label: "Sector 1", value: "sector1" as SortingCriteria },
{ label: "Sector 2", value: "sector2" as SortingCriteria },
{ label: "Sector 3", value: "sector3" as SortingCriteria },
{ label: "Tyre Age", value: "tyreAge" as SortingCriteria },
];

const columnSortMapping: Record<string, SortingCriteria> = {
Position: "position",
Tire: "tyreAge",
Info: "positionChange",
Gap: "position",
LapTime: "bestLap",
Sectors: "sector1",
};

export default function LeaderBoard() {
const drivers = useDataStore((state) => state?.driverList);
const driversTiming = useDataStore((state) => state?.timingData);
const driversAppTiming = useDataStore((state) => state?.timingAppData);

const showTableHeader = useSettingsStore((state) => state.tableHeaders);
const sortCriteria = useSortingStore((state) => state.criteria);
const sortDirection = useSortingStore((state) => state.direction);
const showSortOptions = useSortingStore((state) => state.showSortOptions);
const setSortCriteria = useSortingStore((state) => state.setCriteria);
const toggleDirection = useSortingStore((state) => state.toggleDirection);
const toggleSortOptions = useSortingStore((state) => state.toggleSortOptions);
const setSort = useSortingStore((state) => state.setSort);

return (
<div className="flex w-fit flex-col gap-0.5">
{showTableHeader && <TableHeaders />}

{(!drivers || !driversTiming) &&
new Array(20).fill("").map((_, index) => <SkeletonDriver key={`driver.loading.${index}`} />)}

<LayoutGroup key="drivers">
{drivers && driversTiming && (
<AnimatePresence>
{Object.values(driversTiming.lines)
.sort(sortPos)
.map((timingDriver, index) => (
<Driver
key={`leaderBoard.driver.${timingDriver.racingNumber}`}
position={index + 1}
driver={drivers[timingDriver.racingNumber]}
timingDriver={timingDriver}
/>
))}
</AnimatePresence>
<div className="flex flex-col">
<div className="mb-2 flex h-10 items-center gap-2 px-2">
<button
onClick={toggleSortOptions}
className="flex items-center gap-1 rounded bg-zinc-800 px-3 py-1 text-sm font-medium text-zinc-300 hover:bg-zinc-700"
title="Toggle sort options"
>
<BiSortAlt2 className="h-4 w-4" />
<span>Sort</span>
</button>

{sortDirection === "asc" ? (
<BiSortUp
className="h-5 w-5 cursor-pointer text-zinc-400 hover:text-zinc-200"
onClick={toggleDirection}
title="Sort Direction: Ascending"
/>
) : (
<BiSortDown
className="h-5 w-5 cursor-pointer text-zinc-400 hover:text-zinc-200"
onClick={toggleDirection}
title="Sort Direction: Descending"
/>
)}

{showSortOptions && (
<div className="w-48">
<Select<SortingCriteria>
placeholder="Sort by"
options={sortOptions}
selected={sortCriteria}
setSelected={(value) => value && setSortCriteria(value)}
/>
</div>
)}
</LayoutGroup>
</div>

<div className="flex w-fit flex-col gap-0.5">
{showTableHeader && (
<TableHeaders currentSort={sortCriteria} direction={sortDirection} onSortChange={setSort} />
)}

{(!drivers || !driversTiming) &&
new Array(20).fill("").map((_, index) => <SkeletonDriver key={`driver.loading.${index}`} />)}

<LayoutGroup key="drivers">
{drivers && driversTiming && (
<AnimatePresence>
{Object.values(driversTiming.lines)
.sort((a, b) =>
sortDrivers(
sortCriteria,
sortDirection,
a,
b,
driversAppTiming?.lines[a.racingNumber],
driversAppTiming?.lines[b.racingNumber],
),
)
.map((timingDriver, index) => (
<Driver
key={`leaderBoard.driver.${timingDriver.racingNumber}`}
position={index + 1}
driver={drivers[timingDriver.racingNumber]}
timingDriver={timingDriver}
/>
))}
</AnimatePresence>
)}
</LayoutGroup>
</div>
</div>
);
}

const TableHeaders = () => {
type TableHeadersProps = {
currentSort: SortingCriteria;
direction: "asc" | "desc";
onSortChange: (criteria: SortingCriteria) => void;
};

const TableHeaders = ({ currentSort, direction, onSortChange }: TableHeadersProps) => {
const carMetrics = useSettingsStore((state) => state.carMetrics);

const renderSortIcon = (column: string) => {
const criteria = columnSortMapping[column];
if (!criteria || currentSort !== criteria) return null;

return direction === "asc" ? (
<BiSortUp className="ml-1 inline h-4 w-4" />
) : (
<BiSortDown className="ml-1 inline h-4 w-4" />
);
};

const createClickHandler = (column: string) => {
const criteria = columnSortMapping[column];
if (!criteria) return undefined;

return () => onSortChange(criteria);
};

const headerClass = (column: string) =>
clsx(
"cursor-pointer hover:text-zinc-300 transition-colors duration-150 flex items-center",
columnSortMapping[column] && currentSort === columnSortMapping[column] ? "text-sky-400" : "",
);

return (
<div
className="grid items-center gap-2 p-1 px-2 text-sm font-medium text-zinc-500"
Expand All @@ -53,14 +164,31 @@ const TableHeaders = () => {
: "5.5rem 3.5rem 5.5rem 4rem 5rem 5.5rem auto",
}}
>
<p>Position</p>
<div className={headerClass("Position")} onClick={createClickHandler("Position")}>
<span>Position</span>
{renderSortIcon("Position")}
</div>
<p>DRS</p>
<p>Tire</p>
<p>Info</p>
<p>Gap</p>
<p>LapTime</p>
<p>Sectors</p>
{carMetrics && <p>Car Metrics</p>}
<div className={headerClass("Tire")} onClick={createClickHandler("Tire")}>
<span>Tire</span>
{renderSortIcon("Tire")}
</div>
<div className={headerClass("Info")} onClick={createClickHandler("Info")}>
<span>Info</span>
{renderSortIcon("Info")}
</div>
<div className={headerClass("Gap")} onClick={createClickHandler("Gap")}>
<span>Gap</span>
{renderSortIcon("Gap")}
</div>
<div className={headerClass("LapTime")} onClick={createClickHandler("LapTime")}>
<span>LapTime</span>
{renderSortIcon("LapTime")}
</div>
<div className={headerClass("Sectors")} onClick={createClickHandler("Sectors")}>
<span>Sectors</span>
{renderSortIcon("Sectors")}
</div>
</div>
);
};
Expand Down
68 changes: 33 additions & 35 deletions dash/src/components/ui/Select.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
"use client";

import { Combobox, ComboboxButton, ComboboxInput, ComboboxOption, ComboboxOptions } from "@headlessui/react";
import { useState } from "react";
import { Combobox, ComboboxButton, ComboboxInput, ComboboxOption, ComboboxOptions } from "@headlessui/react";
import clsx from "clsx";
import { BiChevronDown } from "react-icons/bi";

type Option<T> = {
value: T;
Expand All @@ -11,54 +12,51 @@ type Option<T> = {

type Props<T> = {
placeholder?: string;

options: Option<T>[];

selected: T | null;
setSelected: (value: T | null) => void;
};

export default function Select<T>({ placeholder, options, selected, setSelected }: Props<T>) {
const [query, setQuery] = useState("");

const selectedOption = options.find((option) => option.value === selected);

const filteredOptions =
query === "" ? options : options.filter((option) => option.label.toLowerCase().includes(query.toLowerCase()));

return (
<Combobox value={selected} onChange={(value) => setSelected(value)} onClose={() => setQuery("")}>
<Combobox value={selected} onChange={setSelected} nullable>
<div className="relative">
<ComboboxInput
placeholder={placeholder}
className={clsx(
"w-full rounded-lg border-none bg-white/5 py-1.5 pr-8 pl-3 text-sm/6 text-white",
"focus:outline-hidden data-focus:outline-2 data-focus:-outline-offset-2 data-focus:outline-white/25",
)}
displayValue={(option: Option<T> | null) => option?.label ?? ""}
onChange={(event) => setQuery(event.target.value)}
/>
<ComboboxButton className="group absolute inset-y-0 right-0 px-2.5">
{/* <ChevronDownIcon className="size-4 fill-white/60 group-data-hover:fill-white" /> */}
</ComboboxButton>
<div className="relative w-full rounded-lg bg-zinc-800">
<ComboboxInput
className="w-full rounded-lg border-none bg-zinc-800 py-1.5 pr-8 pl-3 text-sm/6 text-white focus:outline-none"
displayValue={() => selectedOption?.label || ""}
placeholder={placeholder || "Select option"}
onChange={(event) => setQuery(event.target.value)}
/>
<ComboboxButton className="absolute inset-y-0 right-0 flex items-center pr-2">
<BiChevronDown className="h-5 w-5 text-zinc-400" aria-hidden="true" />
</ComboboxButton>
</div>
<ComboboxOptions className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-800 py-1 text-sm shadow-lg focus:outline-none">
{filteredOptions.map((option, index) => (
<ComboboxOption
key={index}
value={option.value}
className={({ active, selected }) =>
clsx(
"relative cursor-default py-2 pr-9 pl-3 select-none",
active ? "bg-zinc-700 text-white" : "text-zinc-300",
selected && "bg-zinc-700",
)
}
>
{option.label}
</ComboboxOption>
))}
</ComboboxOptions>
</div>

<ComboboxOptions
anchor="bottom"
className={clsx(
"w-[var(--input-width)] rounded-xl border border-white/5 bg-white/5 p-1 [--anchor-gap:var(--spacing-1)] empty:invisible",
"transition duration-100 ease-in data-leave:data-closed:opacity-0",
)}
>
{filteredOptions.map((option, idx) => (
<ComboboxOption
key={idx}
value={option.value}
className="group flex cursor-pointer items-center gap-2 rounded-lg px-3 py-1.5 select-none data-focus:bg-white/10"
>
{/* <CheckIcon className="invisible size-4 fill-white group-data-selected:visible" /> */}
<div className="text-sm/6 text-white">{option.label}</div>
</ComboboxOption>
))}
</ComboboxOptions>
</Combobox>
);
}
Loading