diff --git a/client/src/components/RoundResults/RoundResults.jsx b/client/src/components/RoundResults/RoundResults.jsx index 596cbf83..198b2bbd 100644 --- a/client/src/components/RoundResults/RoundResults.jsx +++ b/client/src/components/RoundResults/RoundResults.jsx @@ -2,7 +2,7 @@ import { useState, useCallback, useMemo } from "react"; import { Button, Grid, useMediaQuery } from "@mui/material"; import RoundResultsTable from "./RoundResultsTable"; import RoundResultDialog from "./RoundResultDialog"; -import { resultsForView } from "../../lib/result"; +import { resultsForView, sortResultsByColumn } from "../../lib/result"; const DEFAULT_VISIBLE_RESULTS = 100; @@ -17,6 +17,7 @@ function RoundResults({ const smScreen = useMediaQuery((theme) => theme.breakpoints.up("sm")); const [selectedResult, setSelectedResult] = useState(null); + const [sortConfig, setSortConfig] = useState(null); const [showAll, setShowAll] = useState( results.length <= DEFAULT_VISIBLE_RESULTS, ); @@ -25,6 +26,25 @@ function RoundResults({ setSelectedResult(result); }, []); + const handleSortChange = useCallback((newConfig) => { + setSortConfig((prev) => { + if (!newConfig) return null; + if ( + prev && + prev.type === newConfig.type && + prev.index === newConfig.index && + prev.field === newConfig.field + ) { + if (prev.direction === "asc") { + return { ...newConfig, direction: "desc" }; + } else { + return null; + } + } + return { ...newConfig, direction: "asc" }; + }); + }, []); + const viewResults = useMemo( () => resultsForView( @@ -37,13 +57,18 @@ function RoundResults({ [results, eventId, format, forecastView, advancementCondition], ); + const sortedResults = useMemo( + () => sortResultsByColumn(viewResults, sortConfig), + [viewResults, sortConfig], + ); + const visibleResults = useMemo(() => { if (showAll) { - return viewResults; + return sortedResults; } else { - return viewResults.slice(0, DEFAULT_VISIBLE_RESULTS); + return sortedResults.slice(0, DEFAULT_VISIBLE_RESULTS); } - }, [viewResults, showAll]); + }, [sortedResults, showAll]); return ( <> @@ -57,6 +82,8 @@ function RoundResults({ onResultClick={handleResultClick} forecastView={forecastView} advancementCondition={advancementCondition} + sortConfig={sortConfig} + onSortChange={handleSortChange} /> {!showAll && ( diff --git a/client/src/components/RoundResults/RoundResultsTable.jsx b/client/src/components/RoundResults/RoundResultsTable.jsx index 35b7e989..62fd5bde 100644 --- a/client/src/components/RoundResults/RoundResultsTable.jsx +++ b/client/src/components/RoundResults/RoundResultsTable.jsx @@ -7,6 +7,7 @@ import { TableCell, TableHead, TableRow, + TableSortLabel, Paper, useMediaQuery, } from "@mui/material"; @@ -54,6 +55,8 @@ const RoundResultsTable = memo( onResultClick, forecastView, advancementCondition, + sortConfig, + onSortChange, }) => { const smScreen = useMediaQuery((theme) => theme.breakpoints.up("sm")); const mdScreen = useMediaQuery((theme) => theme.breakpoints.up("md")); @@ -80,16 +83,44 @@ const RoundResultsTable = memo( Name {mdScreen && Country} {smScreen && - times(format.numberOfAttempts, (index) => ( - - {index + 1} + times(format.numberOfAttempts, (index) => { + const isAttemptActive = + sortConfig?.type === "attempt" && + sortConfig?.index === index; + return ( + + + onSortChange && + onSortChange({ type: "attempt", index }) + } + > + {index + 1} + + + ); + })} + {stats.map(({ name, field }) => { + const isStatActive = + sortConfig?.type === "stat" && sortConfig?.field === field; + return ( + + + onSortChange && onSortChange({ type: "stat", field }) + } + > + {name} + - ))} - {stats.map(({ name }) => ( - - {name} - - ))} + ); + })} diff --git a/client/src/lib/result.js b/client/src/lib/result.js index b0799147..66c8702c 100644 --- a/client/src/lib/result.js +++ b/client/src/lib/result.js @@ -388,3 +388,42 @@ function resultWorstPossibleAverage(result, format) { return SKIPPED_VALUE; } } + +/** + * Re-sorts results by a specific column (attempt or stat). + * + * @param {Array} results - Results array + * @param {object|null} sortConfig - { type: 'attempt'|'stat', index?: number, field?: string, direction: 'asc'|'desc' } + * @returns {Array} - Re-sorted results array + */ +export function sortResultsByColumn(results, sortConfig) { + if (!sortConfig) return results; + + const { type, index, field, direction = "asc" } = sortConfig; + + function getValue(result) { + if (type === "attempt") { + return result.attempts[index] + ? result.attempts[index].result + : SKIPPED_VALUE; + } + if (type === "stat") { + return result[field] ?? SKIPPED_VALUE; + } + return SKIPPED_VALUE; + } + + return results.slice(0).sort((a, b) => { + const valA = getValue(a); + const valB = getValue(b); + + const comp = compareAttemptResults(valA, valB); + if (comp !== 0) { + if (isComplete(valA) && !isComplete(valB)) return -1; + if (!isComplete(valA) && isComplete(valB)) return 1; + return direction === "desc" ? -comp : comp; + } + + return (a.ranking ?? Infinity) - (b.ranking ?? Infinity); + }); +} diff --git a/client/src/lib/tests/result.test.js b/client/src/lib/tests/result.test.js index 0617c59e..61af4bb0 100644 --- a/client/src/lib/tests/result.test.js +++ b/client/src/lib/tests/result.test.js @@ -4,6 +4,7 @@ import { resultsForView, orderedResultStats, timeNeededToOvertake, + sortResultsByColumn, } from "../result"; describe("orderedResultStats", () => { @@ -798,3 +799,83 @@ describe("timeNeededToOvertake", () => { ).toEqual(-1); }); }); + +describe("sortResultsByColumn", () => { + const r1 = { + id: "1", + ranking: 1, + attempts: [{ result: 25 }, { result: 30 }, { result: 28 }], + best: 25, + average: 2767, + }; + const r2 = { + id: "2", + ranking: 2, + attempts: [{ result: 22 }, { result: -1 }, { result: 24 }], + best: 22, + average: -1, + }; + const r3 = { + id: "3", + ranking: 3, + attempts: [{ result: 28 }, { result: 24 }, { result: 26 }], + best: 24, + average: 2600, + }; + + const results = [r1, r2, r3]; + + it("returns unchanged results if sortConfig is null", () => { + expect(sortResultsByColumn(results, null)).toEqual(results); + }); + + it("sorts by attempt index ascending (best solves first, DNF last)", () => { + // Attempt index 0: r1=25, r2=22, r3=28 -> r2(22), r1(25), r3(28) + const sorted = sortResultsByColumn(results, { + type: "attempt", + index: 0, + direction: "asc", + }); + expect(sorted.map((r) => r.id)).toEqual(["2", "1", "3"]); + }); + + it("sorts by attempt index with DNF placing at bottom for complete solves", () => { + // Attempt index 1: r1=30, r2=-1 (DNF), r3=24 -> r3(24), r1(30), r2(DNF) + const sorted = sortResultsByColumn(results, { + type: "attempt", + index: 1, + direction: "asc", + }); + expect(sorted.map((r) => r.id)).toEqual(["3", "1", "2"]); + }); + + it("sorts by attempt index descending (worst complete solves first, DNF last)", () => { + // Attempt index 0 desc: r1=25, r2=22, r3=28 -> r3(28), r1(25), r2(22) + const sorted = sortResultsByColumn(results, { + type: "attempt", + index: 0, + direction: "desc", + }); + expect(sorted.map((r) => r.id)).toEqual(["3", "1", "2"]); + }); + + it("sorts by stat field ascending", () => { + // Stat 'best': r1=25, r2=22, r3=24 -> r2(22), r3(24), r1(25) + const sorted = sortResultsByColumn(results, { + type: "stat", + field: "best", + direction: "asc", + }); + expect(sorted.map((r) => r.id)).toEqual(["2", "3", "1"]); + }); + + it("sorts by stat field descending", () => { + // Stat 'average': r1=2767, r2=-1, r3=2600 -> r1(2767), r3(2600), r2(-1) + const sorted = sortResultsByColumn(results, { + type: "stat", + field: "average", + direction: "desc", + }); + expect(sorted.map((r) => r.id)).toEqual(["1", "3", "2"]); + }); +});