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"]);
+ });
+});