diff --git a/frontend/src/ts/controllers/input-controller.ts b/frontend/src/ts/controllers/input-controller.ts index 74de37c171de..7cd5811d15e9 100644 --- a/frontend/src/ts/controllers/input-controller.ts +++ b/frontend/src/ts/controllers/input-controller.ts @@ -1182,11 +1182,9 @@ $("#wordsInput").on("keydown", (event) => { } const now = performance.now(); - setTimeout(() => { - const eventCode = - event.code === "" || event.key === "Unidentified" ? "NoCode" : event.code; - TestInput.recordKeydownTime(now, eventCode); - }, 0); + const eventCode = + event.code === "" || event.key === "Unidentified" ? "NoCode" : event.code; + TestInput.recordKeydownTime(now, eventCode); }); $("#wordsInput").on("keyup", (event) => { @@ -1213,11 +1211,9 @@ $("#wordsInput").on("keyup", (event) => { } const now = performance.now(); - setTimeout(() => { - const eventCode = - event.code === "" || event.key === "Unidentified" ? "NoCode" : event.code; - TestInput.recordKeyupTime(now, eventCode); - }, 0); + const eventCode = + event.code === "" || event.key === "Unidentified" ? "NoCode" : event.code; + TestInput.recordKeyupTime(now, eventCode); }); $("#wordsInput").on("keyup", (event) => { diff --git a/frontend/src/ts/test/test-input.ts b/frontend/src/ts/test/test-input.ts index 7f11d9784e5a..c45ac2f8b42a 100644 --- a/frontend/src/ts/test/test-input.ts +++ b/frontend/src/ts/test/test-input.ts @@ -262,16 +262,37 @@ export function forceKeyup(now: number): void { //using mean here because for words mode, the last keypress ends the test. //if we then force keyup on that last keypress, it will record a duration of 0 //skewing the average and standard deviation - const avg = roundTo2(mean(keypressTimings.duration.array)); - const keysOrder = Object.entries(keyDownData); - keysOrder.sort((a, b) => a[1].timestamp - b[1].timestamp); - for (const keyOrder of keysOrder) { - recordKeyupTime(now, keyOrder[0]); + + const indexesToRemove = new Set( + Object.values(keyDownData).map((data) => data.index) + ); + + const keypressDurations = keypressTimings.duration.array.filter( + (_, index) => !indexesToRemove.has(index) + ); + if (keypressDurations.length === 0) { + // this means the test ended while all keys were still held - probably safe to ignore + // since this will result in a "too short" test anyway + return; } - const last = lastElementFromArray(keysOrder)?.[0] as string; - const index = keyDownData[last]?.index; - if (last !== undefined && index !== undefined) { + + const avg = roundTo2(mean(keypressDurations)); + + const orderedKeys = Object.entries(keyDownData).sort( + (a, b) => a[1].timestamp - b[1].timestamp + ); + + for (const [key, { index }] of orderedKeys) { keypressTimings.duration.array[index] = avg; + + if (key === "NoCode") { + noCodeIndex--; + } + + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete keyDownData[key]; + + updateOverlap(now); } } @@ -350,6 +371,21 @@ function updateOverlap(now: number): void { } export function resetKeypressTimings(): void { + //because keydown triggers before input, we need to grab the first keypress data here and carry it over + + //take the key with the largest index + const lastKey = Object.keys(keyDownData).reduce((a, b) => { + const aIndex = keyDownData[a]?.index; + const bIndex = keyDownData[b]?.index; + if (aIndex === undefined) return b; + if (bIndex === undefined) return a; + return aIndex > bIndex ? a : b; + }); + + //get the data + const lastKeyData = keyDownData[lastKey]; + + //reset keypressTimings = { spacing: { first: -1, @@ -366,6 +402,26 @@ export function resetKeypressTimings(): void { }; keyDownData = {}; noCodeIndex = 0; + + //carry over + if (lastKeyData !== undefined) { + keypressTimings = { + spacing: { + first: lastKeyData.timestamp, + last: lastKeyData.timestamp, + array: [], + }, + duration: { + array: [0], + }, + }; + keyDownData[lastKey] = { + timestamp: lastKeyData.timestamp, + // make sure to set it to the first index + index: 0, + }; + } + console.debug("Keypress timings reset"); } @@ -413,14 +469,4 @@ export function restart(): void { correct: 0, incorrect: 0, }; - keypressTimings = { - spacing: { - first: -1, - last: -1, - array: [], - }, - duration: { - array: [], - }, - }; } diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 0a4780b2bb21..d92e2af0d3cb 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -967,7 +967,7 @@ export async function finish(difficultyFailed = false): Promise { } // stats - const stats = TestStats.calculateStats(); + const stats = TestStats.calculateFinalStats(); if (stats.time % 1 !== 0 && Config.mode !== "time") { TestStats.setLastSecondNotRound(); } diff --git a/frontend/src/ts/test/test-stats.ts b/frontend/src/ts/test/test-stats.ts index e00f151b8b22..5246d2ebf609 100644 --- a/frontend/src/ts/test/test-stats.ts +++ b/frontend/src/ts/test/test-stats.ts @@ -7,6 +7,7 @@ import * as TestState from "./test-state"; import * as Numbers from "@monkeytype/util/numbers"; import { CompletedEvent, IncompleteTest } from "@monkeytype/schemas/results"; import { isFunboxActiveWithProperty } from "./funbox/list"; +import * as CustomText from "./custom-text"; type CharCount = { spaces: number; @@ -144,14 +145,17 @@ export function calculateTestSeconds(now?: number): number { } } -export function calculateWpmAndRaw(withDecimalPoints?: true): { +export function calculateWpmAndRaw( + withDecimalPoints?: true, + final = false +): { wpm: number; raw: number; } { const testSeconds = calculateTestSeconds( TestState.isActive ? performance.now() : end ); - const chars = countChars(); + const chars = countChars(final); const wpm = Numbers.roundTo2( ((chars.correctWordChars + chars.correctSpaces) * (60 / testSeconds)) / 5 ); @@ -280,7 +284,7 @@ function getTargetWords(): string[] { return targetWords; } -function countChars(): CharCount { +function countChars(final = false): CharCount { let correctWordChars = 0; let correctChars = 0; let incorrectChars = 0; @@ -343,7 +347,13 @@ function countChars(): CharCount { } correctChars += toAdd.correct; incorrectChars += toAdd.incorrect; - if (i === inputWords.length - 1) { + + const isTimedTest = + Config.mode === "time" || + (Config.mode === "custom" && CustomText.getLimit().mode === "time"); + const shouldCountPartialLastWord = !final || (final && isTimedTest); + + if (i === inputWords.length - 1 && shouldCountPartialLastWord) { //last word - check if it was all correct - add to correct word chars if (toAdd.incorrect === 0) correctWordChars += toAdd.correct; } else { @@ -370,7 +380,7 @@ function countChars(): CharCount { }; } -export function calculateStats(): Stats { +export function calculateFinalStats(): Stats { console.debug("Calculating result stats"); let testSeconds = calculateTestSeconds(); console.debug( @@ -398,8 +408,10 @@ export function calculateStats(): Stats { testSeconds ); } - const chars = countChars(); - const { wpm, raw } = calculateWpmAndRaw(true); + + //todo: this counts chars twice - once here and once in calculateWpmAndRaw + const chars = countChars(true); + const { wpm, raw } = calculateWpmAndRaw(true, true); const acc = Numbers.roundTo2(calculateAccuracy()); const ret = { wpm: isNaN(wpm) ? 0 : wpm,