diff --git a/client/src/components/admin/AdminRound/AdminRoundContent.jsx b/client/src/components/admin/AdminRound/AdminRoundContent.jsx index e3f67936..72b33531 100644 --- a/client/src/components/admin/AdminRound/AdminRoundContent.jsx +++ b/client/src/components/admin/AdminRound/AdminRoundContent.jsx @@ -76,6 +76,23 @@ function setStoredBatchResults(roundId, results) { } } +/** + * Combine results and batchResults to produce a temporary results-like array + * that has all batchResults changes applied. This allows us to display a + * warning if a result is being entered which matches a batched result exactly. + */ +function combineResultsAndBatchResults(results, batchResults) { + const combinedResults = [...results]; + batchResults.forEach((batchedResult) => { + const i = combinedResults.findIndex((res) => res.id === batchedResult.id); + combinedResults[i] = { + ...combinedResults[i], + attempts: batchedResult.attempts, + }; + }); + return combinedResults; +} + function AdminRoundContent({ round, competitionId, officialWorldRecords }) { const confirm = useConfirm(); const { enqueueSnackbar, closeSnackbar } = useSnackbar(); @@ -91,6 +108,9 @@ function AdminRoundContent({ round, competitionId, officialWorldRecords }) { const [isBatchMode, setIsBatchMode] = useState( () => batchResults.length > 0 || getStoreIsBatchMode(), ); + const [combinedResults, setCombinedResults] = useState(() => + combineResultsAndBatchResults(round.results, batchResults), + ); const formContainerRef = useRef(null); const [enterResults, { loading }] = useMutation(ENTER_RESULTS, { @@ -184,7 +204,10 @@ function AdminRoundContent({ round, competitionId, officialWorldRecords }) { useEffect(() => { setStoredBatchResults(round.id, batchResults); - }, [round.id, batchResults]); + setCombinedResults( + combineResultsAndBatchResults(round.results, batchResults), + ); + }, [round, batchResults]); return ( <> @@ -193,6 +216,7 @@ function AdminRoundContent({ round, competitionId, officialWorldRecords }) { ({ result, })); - onSubmit(attempts); + onSubmit(attempts, result.person); }); } function confirmSubmission() { + // We don't want to show a duplicate warning if the user is submitting + // already-entered results for the correct competitor. This is often used + // as a quick way to refresh data without refreshing the whole page. + const resultsToCheck = combinedResults.filter( + (res) => res.id !== result.id, + ); + const submissionWarning = attemptResultsWarning( attemptResults, eventId, officialWorldRecords, + resultsToCheck, ); if (submissionWarning) { diff --git a/client/src/lib/attempt-result.js b/client/src/lib/attempt-result.js index c3f085bb..d9ecd91d 100644 --- a/client/src/lib/attempt-result.js +++ b/client/src/lib/attempt-result.js @@ -399,6 +399,7 @@ export function attemptResultsWarning( attemptResults, eventId, officialWorldRecords = [], + results = [], ) { const skippedGapIndex = trimTrailingSkipped(attemptResults).indexOf(SKIPPED_VALUE); @@ -482,6 +483,22 @@ export function attemptResultsWarning( }; } } + + // Check whether this result is a duplicate of existing results in the same round. + // Excludes FMC and all-DNF results since ties are common. + if (eventId !== "333fm" && completeAttempts.length > 0) { + const matches = findAllMatchingResults(attemptResults, results); + if (matches.length > 0) { + const matchesString = matches + .map((match) => `${match.person.name} (${match.person.id})`) + .join(", "); + return { + description: `The result you're trying to submit matches all results for + the following competitor${matches.length > 1 ? "s" : ""}: ${matchesString}. + Please check that the results are accurate.`, + }; + } + } } return null; } @@ -545,3 +562,24 @@ function checkForDnsFollowedByValidResult(attemptResults) { index > dnsIndex && attempt !== SKIPPED_VALUE && attempt !== DNS_VALUE, ); } + +/** + * Check whether an attempt matches an existing attempt exactly. + */ +function findAllMatchingResults(attemptResults, results) { + const filteredAttemptResults = trimTrailingSkipped(attemptResults); + + const matches = results.filter((result) => { + if (result.attempts.length !== filteredAttemptResults.length) { + return false; + } + for (let i = 0; i < result.attempts.length; i++) { + if (result.attempts[i].result !== filteredAttemptResults[i]) { + return false; + } + } + return true; + }); + + return matches; +} diff --git a/client/src/lib/tests/attempt-result.test.js b/client/src/lib/tests/attempt-result.test.js index 0d8297db..b9b20a89 100644 --- a/client/src/lib/tests/attempt-result.test.js +++ b/client/src/lib/tests/attempt-result.test.js @@ -536,6 +536,61 @@ describe("attemptResultsWarning", () => { attemptResultsWarning(attemptResults, "333fm", worldRecords), ).toEqual(null); }); + + it("complains about exact duplicate results", () => { + const attemptResults = [500, 600, 700, 800, 900]; + const existingResults = [ + { + attempts: [ + { result: 500 }, + { result: 600 }, + { result: 700 }, + { result: 800 }, + { result: 900 }, + ], + person: { id: 2, name: "Person 2" }, + }, + ]; + expect( + attemptResultsWarning(attemptResults, "333", [], existingResults), + ).toMatchObject({ + description: `The result you're trying to submit matches all results for + the following competitor: Person 2 (2). + Please check that the results are accurate.`, + }); + }); + + it("does not check for duplicates in FMC", () => { + const attemptResults = [25, 26]; + const existingResults = [ + { + attempts: [{ result: 25 }, { result: 26 }], + person: { id: 2, name: "Person 2" }, + }, + ]; + expect( + attemptResultsWarning(attemptResults, "333fm", [], existingResults), + ).toEqual(null); + }); + + it("does not warn about all-DNF duplicates", () => { + const attemptResults = [-1, -1, -1, -1, -1]; + const existingResults = [ + { + attempts: [ + { result: -1 }, + { result: -1 }, + { result: -1 }, + { result: -1 }, + { result: -1 }, + ], + person: { id: 2, name: "Person 2" }, + }, + ]; + expect( + attemptResultsWarning(attemptResults, "333", [], existingResults), + ).toEqual(null); + }); }); describe("applyTimeLimit", () => {