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
35 changes: 31 additions & 4 deletions client/src/components/RoundResults/RoundResults.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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,
);
Expand All @@ -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(
Expand All @@ -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 (
<>
Expand All @@ -57,6 +82,8 @@ function RoundResults({
onResultClick={handleResultClick}
forecastView={forecastView}
advancementCondition={advancementCondition}
sortConfig={sortConfig}
onSortChange={handleSortChange}
/>
</Grid>
{!showAll && (
Expand Down
49 changes: 40 additions & 9 deletions client/src/components/RoundResults/RoundResultsTable.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
TableCell,
TableHead,
TableRow,
TableSortLabel,
Paper,
useMediaQuery,
} from "@mui/material";
Expand Down Expand Up @@ -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"));
Expand All @@ -80,16 +83,44 @@ const RoundResultsTable = memo(
<TableCell sx={styles.cell}>Name</TableCell>
{mdScreen && <TableCell sx={styles.cell}>Country</TableCell>}
{smScreen &&
times(format.numberOfAttempts, (index) => (
<TableCell key={index} sx={styles.cell} align="right">
{index + 1}
times(format.numberOfAttempts, (index) => {
const isAttemptActive =
sortConfig?.type === "attempt" &&
sortConfig?.index === index;
return (
<TableCell key={index} sx={styles.cell} align="right">
<TableSortLabel
active={isAttemptActive}
direction={
isAttemptActive ? sortConfig.direction : "asc"
}
onClick={() =>
onSortChange &&
onSortChange({ type: "attempt", index })
}
>
{index + 1}
</TableSortLabel>
</TableCell>
);
})}
{stats.map(({ name, field }) => {
const isStatActive =
sortConfig?.type === "stat" && sortConfig?.field === field;
return (
<TableCell key={name} sx={styles.cell} align="right">
<TableSortLabel
active={isStatActive}
direction={isStatActive ? sortConfig.direction : "asc"}
onClick={() =>
onSortChange && onSortChange({ type: "stat", field })
}
>
{name}
</TableSortLabel>
</TableCell>
))}
{stats.map(({ name }) => (
<TableCell key={name} sx={styles.cell} align="right">
{name}
</TableCell>
))}
);
})}
</TableRow>
</TableHead>
<TableBody>
Expand Down
39 changes: 39 additions & 0 deletions client/src/lib/result.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
}
81 changes: 81 additions & 0 deletions client/src/lib/tests/result.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
resultsForView,
orderedResultStats,
timeNeededToOvertake,
sortResultsByColumn,
} from "../result";

describe("orderedResultStats", () => {
Expand Down Expand Up @@ -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"]);
});
});
Loading