From 8a6fcdb667a02176e2f7788a7cde32d34623f0af Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 30 May 2026 18:48:12 +0200 Subject: [PATCH 01/41] yeet wpm history, raw history, burst history, old burst calculation use new system for current wpm, raw, acc, burst --- frontend/src/ts/input/handlers/insert-text.ts | 4 +- .../src/ts/input/helpers/word-navigation.ts | 9 +- .../src/ts/input/listeners/composition.ts | 4 - frontend/src/ts/test/events/stats.ts | 114 +++++++++++++++++- frontend/src/ts/test/practise-words.ts | 8 +- frontend/src/ts/test/replay.ts | 3 +- frontend/src/ts/test/result.ts | 10 +- frontend/src/ts/test/test-input.ts | 31 +---- frontend/src/ts/test/test-logic.ts | 13 +- frontend/src/ts/test/test-stats.ts | 25 ---- frontend/src/ts/test/test-timer.ts | 14 +-- frontend/src/ts/test/test-ui.ts | 13 +- 12 files changed, 139 insertions(+), 109 deletions(-) diff --git a/frontend/src/ts/input/handlers/insert-text.ts b/frontend/src/ts/input/handlers/insert-text.ts index 6145641c5a49..54c8208a3bab 100644 --- a/frontend/src/ts/input/handlers/insert-text.ts +++ b/frontend/src/ts/input/handlers/insert-text.ts @@ -175,9 +175,6 @@ export async function onInsertText(options: OnInsertTextParams): Promise { if (Config.keymapMode === "react") { flash(data, correct); } - if (testInput.length === 0 && !isCompositionEnding) { - TestInput.setBurstStart(now); - } if (!shouldGoToNextWord) { TestInput.corrected.update(data, correct); } @@ -221,6 +218,7 @@ export async function onInsertText(options: OnInsertTextParams): Promise { correctInsert: correct, isCompositionEnding: isCompositionEnding === true, zenNewline: charIsNewline && Config.mode === "zen", + now, }); lastBurst = result.lastBurst; increasedWordIndex = result.increasedWordIndex; diff --git a/frontend/src/ts/input/helpers/word-navigation.ts b/frontend/src/ts/input/helpers/word-navigation.ts index 897d115e65c0..c1f5e99077a0 100644 --- a/frontend/src/ts/input/helpers/word-navigation.ts +++ b/frontend/src/ts/input/helpers/word-navigation.ts @@ -9,19 +9,20 @@ import { getActiveFunboxesWithFunction, isFunboxActiveWithProperty, } from "../../test/funbox/list"; -import * as TestStats from "../../test/test-stats"; import * as Replay from "../../test/replay"; import * as Funbox from "../../test/funbox/funbox"; import { showLoaderBar, hideLoaderBar } from "../../states/loader-bar"; import { setInputElementValue } from "../input-element"; import { setAwaitingNextWord } from "../state"; import { DeleteInputType } from "./input-type"; +import { getWordBurst } from "../../test/events/stats"; type GoToNextWordParams = { correctInsert: boolean; // this is used to tell test ui to update the word before moving to the next word (in case of a composition that ends with a space) isCompositionEnding: boolean; zenNewline?: boolean; + now: number; }; type GoToNextWordReturn = { @@ -33,6 +34,7 @@ export async function goToNextWord({ correctInsert, isCompositionEnding, zenNewline, + now, }: GoToNextWordParams): Promise { const ret = { increasedWordIndex: false, @@ -56,8 +58,7 @@ export async function goToNextWord({ } //burst calculation and fail - const burst: number = TestStats.calculateBurst(); - TestInput.pushBurstToHistory(burst); + const burst = getWordBurst(TestState.activeWordIndex, now); ret.lastBurst = burst; PaceCaret.handleSpace(correctInsert, TestWords.words.getCurrentText()); @@ -88,7 +89,7 @@ export async function goToNextWord({ setInputElementValue(""); TestInput.input.syncWithInputElement(); - void TestUI.afterTestWordChange("forward"); + void TestUI.afterTestWordChange("forward", burst); return ret; } diff --git a/frontend/src/ts/input/listeners/composition.ts b/frontend/src/ts/input/listeners/composition.ts index cec96ead8fad..00c75128d13c 100644 --- a/frontend/src/ts/input/listeners/composition.ts +++ b/frontend/src/ts/input/listeners/composition.ts @@ -2,7 +2,6 @@ import { getInputElement } from "../input-element"; import * as CompositionState from "../../legacy-states/composition"; import * as TestState from "../../test/test-state"; import * as TestLogic from "../../test/test-logic"; -import * as TestInput from "../../test/test-input"; import { setLastInsertCompositionTextData } from "../state"; import * as CompositionDisplay from "../../elements/composition-display"; import { onInsertText } from "../handlers/insert-text"; @@ -25,9 +24,6 @@ inputEl.addEventListener("compositionstart", (event) => { if (!TestState.isActive) { TestLogic.startTest(now); } - if (TestInput.input.current.length === 0) { - TestInput.setBurstStart(now); - } logTestEvent("composition", now, { event: "start", diff --git a/frontend/src/ts/test/events/stats.ts b/frontend/src/ts/test/events/stats.ts index 3f8ae474ba7b..5c18b3ede77b 100644 --- a/frontend/src/ts/test/events/stats.ts +++ b/frontend/src/ts/test/events/stats.ts @@ -183,6 +183,28 @@ export function getRawPerSecond(): number[] { }); } +export function getCurrentTestDurationMs(now: number): number { + const events = getAllTestEvents(); + + let start: number | undefined; + + for (const event of events) { + if ( + start === undefined && + event.type === "timer" && + event.data.event === "start" + ) { + start = event.ms; + } + } + + if (start === undefined) { + return 0; + } + + return now - start; +} + export function getTestDurationMs(): number { const events = getAllTestEvents(); @@ -243,12 +265,100 @@ function getTargetWord( } } -export function getChars(): CharCounts { +export function getCurrentWpmAndRaw(now?: number): { + wpm: number; + raw: number; +} { + const chars = getChars(true); + const currentTestDurationMs = getCurrentTestDurationMs( + now ?? performance.now(), + ); + const wpm = Math.round( + calculateWpm(chars.correctWord, currentTestDurationMs / 1000), + ); + const raw = Math.round( + calculateWpm( + chars.allCorrect + chars.extra + chars.incorrect, + currentTestDurationMs / 1000, + ), + ); + return { wpm, raw }; +} + +export function getCurrentAccuracy(): number { + const events = getAllTestEvents(); + + let correct = 0; + let total = 0; + + for (const event of events) { + if (event.type === "input" && "correct" in event.data) { + total++; + if (event.data.correct) { + correct++; + } + } + } + + return total === 0 ? 100 : (correct / total) * 100; +} + +export function getWordBurst(wordIndex: number, now?: number): number { + //todo: composition start must be the start time for burst calculation + + const events = getInputEventsPerWord().get(wordIndex) ?? []; + + const input = getSimulatedInput(events); + + let inputLength = input.length; + if (!input.endsWith(" ")) { + inputLength += 1; // account for space that will be added on word submit + } + + let firstKeypressTime: number | undefined; + let lastKeypressTime: number | undefined; + + for (const event of events) { + if (event.type === "input" && event.data.inputType === "insertText") { + if (event.data.charIndex === 0) { + firstKeypressTime = event.ms; + } + if (event.data.data === " ") { + lastKeypressTime = event.ms; + } + } + } + + if (firstKeypressTime === undefined || input.length === 0) { + return 0; + } + + if (lastKeypressTime !== undefined && lastKeypressTime < firstKeypressTime) { + lastKeypressTime = undefined; + } + + let endTime = lastKeypressTime ?? now ?? performance.now(); + + const durationSeconds = (endTime - firstKeypressTime) / 1000; + if (durationSeconds <= 0) return Infinity; + + return Math.round(calculateWpm(inputLength, durationSeconds)); +} + +export function getBurstHistory(): number[] { + let burstHistory: number[] = []; + for (let i = 0; i < TestWords.words.length; i++) { + burstHistory.push(getWordBurst(i)); + } + return burstHistory; +} + +export function getChars(countPartialLastWord = false): CharCounts { const eventsPerWordIndex = getInputEventsPerWord(); const isTimedTest = Config.mode === "time" || (Config.mode === "custom" && CustomText.getLimit().mode === "time"); - const shouldCountPartialLastWord = isTimedTest; + const shouldCountPartialLastWord = isTimedTest || countPartialLastWord; let allCorrect = 0; let correctWord = 0; diff --git a/frontend/src/ts/test/practise-words.ts b/frontend/src/ts/test/practise-words.ts index b1ee407c3dfd..9e209662486d 100644 --- a/frontend/src/ts/test/practise-words.ts +++ b/frontend/src/ts/test/practise-words.ts @@ -9,6 +9,7 @@ import { configEvent } from "../events/config"; import { setCustomTextName } from "../legacy-states/custom-text-name"; import { Mode } from "@monkeytype/schemas/shared"; import { CustomTextSettings } from "@monkeytype/schemas/results"; +import { getBurstHistory } from "./events/stats"; type Before = { mode: Mode | null; @@ -90,10 +91,9 @@ export function init( .getText() .slice(0, TestInput.input.getHistory().length - 1); - sortableSlowWords = typedWords.map((e, i) => [ - e, - TestInput.burstHistory[i] ?? 0, - ]); + const burstHistory = getBurstHistory(); + + sortableSlowWords = typedWords.map((e, i) => [e, burstHistory[i] ?? 0]); sortableSlowWords.sort((a, b) => { return a[1] - b[1]; }); diff --git a/frontend/src/ts/test/replay.ts b/frontend/src/ts/test/replay.ts index 05a19e649155..f66d82fae4e2 100644 --- a/frontend/src/ts/test/replay.ts +++ b/frontend/src/ts/test/replay.ts @@ -1,5 +1,4 @@ import * as Sound from "../controllers/sound-controller"; -import * as TestInput from "./test-input"; import * as Arrays from "../utils/arrays"; import { qs, qsr } from "../utils/dom"; import { Config } from "../config/store"; @@ -228,7 +227,7 @@ function addReplayEvent(action: ReplayAction, value?: number | string): void { } function updateStatsString(time: number): void { - const wpm = TestInput.wpmHistory[time - 1] ?? 0; + const wpm = 0; const statsString = `${wpm}wpm\t${time}s`; qs("#replayStats")?.setText(statsString); } diff --git a/frontend/src/ts/test/result.ts b/frontend/src/ts/test/result.ts index 65e7dda1e126..e5fcaf183807 100644 --- a/frontend/src/ts/test/result.ts +++ b/frontend/src/ts/test/result.ts @@ -110,8 +110,8 @@ async function updateChartData(): Promise { let labels = []; - for (let i = 1; i <= TestInput.wpmHistory.length; i++) { - if (TestStats.lastSecondNotRound && i === TestInput.wpmHistory.length) { + for (let i = 1; i <= 0; i++) { + if (TestStats.lastSecondNotRound && i === 0) { labels.push(Numbers.roundTo2(result.testDuration).toString()); } else { labels.push(i.toString()); @@ -124,11 +124,7 @@ async function updateChartData(): Promise { ), ]; - const chartData2 = [ - ...TestInput.rawHistory.map((a) => - Numbers.roundTo2(typingSpeedUnit.fromWpm(a)), - ), - ]; + const chartData2: number[] = []; const valueWindow = Math.max(...result.chartData.burst) * 0.25; let smoothedBurst = Arrays.smoothWithValueWindow( diff --git a/frontend/src/ts/test/test-input.ts b/frontend/src/ts/test/test-input.ts index e74f8ba05b07..55834d328730 100644 --- a/frontend/src/ts/test/test-input.ts +++ b/frontend/src/ts/test/test-input.ts @@ -1,6 +1,5 @@ import { lastElementFromArray } from "../utils/arrays"; import { mean, roundTo2 } from "@monkeytype/util/numbers"; -import * as TestState from "./test-state"; import { Config } from "../config/store"; import { getInputElementValue } from "../input/input-element"; @@ -211,7 +210,6 @@ export const corrected = new Corrected(); export let keypressCountHistory: number[] = []; let currentKeypressCount = 0; -export let currentBurstStart = 0; type MissedWordsType = Record; // We're using Object.create(null) to make sure that __proto__ won't have any special meaning when it's used to index the missedWords object (so if a user mistypes the word __proto__ it will appear in the practise words test) export let missedWords: MissedWordsType = Object.create( @@ -235,9 +233,7 @@ export let keyOverlap = { total: 0, lastStartTime: -1, }; -export let wpmHistory: number[] = []; -export let rawHistory: number[] = []; -export let burstHistory: number[] = []; + export let errorHistory: ErrorHistoryObject[] = []; let currentErrorHistory: ErrorHistoryObject = { count: 0, @@ -263,10 +259,6 @@ export function pushKeypressWord(wordIndex: number): void { currentErrorHistory.words.push(wordIndex); } -export function setBurstStart(time: number): void { - currentBurstStart = time; -} - export function pushKeypressesToHistory(): void { keypressCountHistory.push(currentKeypressCount); currentKeypressCount = 0; @@ -518,27 +510,7 @@ export function pushMissedWord(word: string): void { } } -export function pushToWpmHistory(wpm: number): void { - wpmHistory.push(wpm); -} - -export function pushToRawHistory(raw: number): void { - rawHistory.push(raw); -} - -export function pushBurstToHistory(speed: number): void { - if (burstHistory[TestState.activeWordIndex] === undefined) { - burstHistory.push(speed); - } else { - //repeated word - override - burstHistory[TestState.activeWordIndex] = speed; - } -} - export function restart(): void { - wpmHistory = []; - rawHistory = []; - burstHistory = []; keypressCountHistory = []; currentKeypressCount = 0; afkHistory = []; @@ -548,7 +520,6 @@ export function restart(): void { count: 0, words: [], }; - currentBurstStart = 0; missedWords = Object.create(null) as MissedWordsType; accuracy = { correct: 0, diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 337e19eb11c9..fed7988a3b17 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -820,7 +820,7 @@ function buildCompletedEvent( } const chartData = { - wpm: TestInput.wpmHistory, + wpm: [], burst: rawPerSecond, err: chartErr, }; @@ -905,7 +905,7 @@ function buildCompletedEvent( return completedEvent; } -const ALWAYSREPORT = false; +const ALWAYSREPORT = true; function compareCompletedEvents( ce: Omit, @@ -1341,12 +1341,6 @@ export async function finish(difficultyFailed = false): Promise { // logEventsDataToTheConsoleTable(); - //need one more calculation for the last word if test auto ended - if (TestInput.burstHistory.length !== TestInput.input.getHistory()?.length) { - const burst = TestStats.calculateBurst(now); - TestInput.pushBurstToHistory(burst); - } - //remove afk from zen if (Config.mode === "zen" || TestState.bailedOut) { TestStats.removeAfkData(); @@ -1372,9 +1366,6 @@ export async function finish(difficultyFailed = false): Promise { !difficultyFailed && Math.round(stats.time % 1) >= 0.5 ) { - const wpmAndRaw = TestStats.calculateWpmAndRaw(); - TestInput.pushToWpmHistory(wpmAndRaw.wpm); - TestInput.pushToRawHistory(wpmAndRaw.raw); TestInput.pushKeypressesToHistory(); TestInput.pushErrorToHistory(); TestInput.pushAfkToHistory(); diff --git a/frontend/src/ts/test/test-stats.ts b/frontend/src/ts/test/test-stats.ts index 9d7701fc45f7..c4fec1ef09c3 100644 --- a/frontend/src/ts/test/test-stats.ts +++ b/frontend/src/ts/test/test-stats.ts @@ -47,11 +47,7 @@ export function getStats(): unknown { end3, afkHistory: TestInput.afkHistory, errorHistory: TestInput.errorHistory, - wpmHistory: TestInput.wpmHistory, - rawHistory: TestInput.rawHistory, - burstHistory: TestInput.burstHistory, keypressCountHistory: TestInput.keypressCountHistory, - currentBurstStart: TestInput.currentBurstStart, lastSecondNotRound, missedWords: TestInput.missedWords, accuracy: TestInput.accuracy, @@ -178,25 +174,6 @@ export function setLastSecondNotRound(): void { lastSecondNotRound = true; } -export function calculateBurst(endTime: number = performance.now()): number { - const containsKorean = TestInput.input.getKoreanStatus(); - const timeToWrite = (endTime - TestInput.currentBurstStart) / 1000; - if (timeToWrite <= 0) return 0; - let wordLength: number; - wordLength = !containsKorean - ? TestInput.input.current.length - : Hangul.disassemble(TestInput.input.current).length; - if (wordLength === 0) { - wordLength = !containsKorean - ? (TestInput.input.getHistoryLast()?.length ?? 0) - : (Hangul.disassemble(TestInput.input.getHistoryLast() as string) - ?.length ?? 0); - } - if (wordLength === 0) return 0; - const speed = Numbers.roundTo2((wordLength * (60 / timeToWrite)) / 5); - return Math.round(speed); -} - export function calculateAccuracy(): number { const acc = (TestInput.accuracy.correct / @@ -208,8 +185,6 @@ export function calculateAccuracy(): number { export function removeAfkData(): void { const testSeconds = calculateTestSeconds(); TestInput.keypressCountHistory.splice(testSeconds); - TestInput.wpmHistory.splice(testSeconds); - TestInput.rawHistory.splice(testSeconds); } function getInputWords(): string[] { diff --git a/frontend/src/ts/test/test-timer.ts b/frontend/src/ts/test/test-timer.ts index b7467e0dab62..3d112948cc13 100644 --- a/frontend/src/ts/test/test-timer.ts +++ b/frontend/src/ts/test/test-timer.ts @@ -29,6 +29,7 @@ import { clearLowFpsMode, setLowFpsMode } from "../anim"; import { createTimer } from "animejs"; import { requestDebouncedAnimationFrame } from "../utils/debounced-animation-frame"; import { logTestEvent } from "./events/data"; +import { getCurrentWpmAndRaw } from "./events/stats"; let lastLoop = 0; const newTimer = createTimer({ @@ -100,17 +101,6 @@ function premid(): void { if (timerDebug) console.timeEnd("premid"); } -function calculateWpmRaw(): { wpm: number; raw: number } { - if (timerDebug) console.time("calculate wpm and raw"); - const wpmAndRaw = TestStats.calculateWpmAndRaw(); - if (timerDebug) console.timeEnd("calculate wpm and raw"); - if (timerDebug) console.time("push to history"); - TestInput.pushToWpmHistory(wpmAndRaw.wpm); - TestInput.pushToRawHistory(wpmAndRaw.raw); - if (timerDebug) console.timeEnd("push to history"); - return wpmAndRaw; -} - function monkey(wpmAndRaw: { wpm: number; raw: number }): void { if (timerDebug) console.time("update monkey"); const num = Config.blindMode ? wpmAndRaw.raw : wpmAndRaw.wpm; @@ -258,7 +248,7 @@ function timerStep(): void { //calc Time.increment(); - const wpmAndRaw = calculateWpmRaw(); + const wpmAndRaw = getCurrentWpmAndRaw(); const acc = calculateAcc(); //ui updates diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index ffc105d1dfda..05296b60b8ae 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -34,7 +34,6 @@ import { } from "../utils/debounced-animation-frame"; import * as SoundController from "../controllers/sound-controller"; import * as Numbers from "@monkeytype/util/numbers"; -import * as TestStats from "./test-stats"; import { highlight } from "../events/keymap"; import * as LiveAcc from "./live-acc"; import * as Focus from "../test/focus"; @@ -69,6 +68,7 @@ import { import { getTheme } from "../states/theme"; import { skipBreakdownEvent } from "../states/header"; import { wordsHaveNewline } from "../states/test"; +import { getBurstHistory, getCurrentAccuracy } from "./events/stats"; export const updateHintsPositionDebounced = Misc.debounceUntilResolved( updateHintsPosition, @@ -1312,6 +1312,8 @@ async function loadWordsHistory(): Promise { const wordsContainer = qs("#resultWordsHistory .words"); wordsContainer?.empty(); + const burstHistory = getBurstHistory(); + const inputHistoryLength = TestInput.input.getHistory().length; for (let i = 0; i < inputHistoryLength + 2; i++) { const input = TestInput.input.getHistory(i); @@ -1350,7 +1352,7 @@ async function loadWordsHistory(): Promise { wordEl.classList.add("error"); } - const burstValue = TestInput.burstHistory[i]; + const burstValue = burstHistory[i]; if (burstValue !== undefined) { wordEl.setAttribute("burst", String(burstValue)); } @@ -1459,7 +1461,8 @@ export async function applyBurstHeatmap(): Promise { if (Config.burstHeatmap) { qsa("#resultWordsHistory .heatmapLegend")?.show(); - let burstlist = [...TestInput.burstHistory]; + const burstHistory = getBurstHistory(); + let burstlist = [...burstHistory]; burstlist = burstlist.map((x) => (x >= 1000 ? Infinity : x)); @@ -1734,7 +1737,7 @@ function afterAnyTestInput( void SoundController.playClick(); } - const acc: number = Numbers.roundTo2(TestStats.calculateAccuracy()); + const acc: number = Numbers.roundTo2(getCurrentAccuracy()); if (!isNaN(acc)) LiveAcc.update(acc); if (Config.mode !== "time") { @@ -1832,13 +1835,13 @@ export function beforeTestWordChange( export async function afterTestWordChange( direction: "forward" | "back", + lastBurst?: number, ): Promise { updateActiveElement({ direction, }); Caret.updatePosition(); - const lastBurst = TestInput.burstHistory[TestInput.burstHistory.length - 1]; if (Numbers.isSafeNumber(lastBurst)) { void LiveBurst.update(Math.round(lastBurst)); } From ee43b07fa4e7ea020a6d06655b0ef2dc12b4365e Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 30 May 2026 19:04:34 +0200 Subject: [PATCH 02/41] burst improve perf --- frontend/src/ts/test/events/stats.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/frontend/src/ts/test/events/stats.ts b/frontend/src/ts/test/events/stats.ts index 5c18b3ede77b..efda7750be1e 100644 --- a/frontend/src/ts/test/events/stats.ts +++ b/frontend/src/ts/test/events/stats.ts @@ -303,11 +303,8 @@ export function getCurrentAccuracy(): number { return total === 0 ? 100 : (correct / total) * 100; } -export function getWordBurst(wordIndex: number, now?: number): number { - //todo: composition start must be the start time for burst calculation - - const events = getInputEventsPerWord().get(wordIndex) ?? []; - +//todo: composition start must be the start time for burst calculation +function computeBurst(events: InputEvent[], now?: number): number { const input = getSimulatedInput(events); let inputLength = input.length; @@ -337,7 +334,7 @@ export function getWordBurst(wordIndex: number, now?: number): number { lastKeypressTime = undefined; } - let endTime = lastKeypressTime ?? now ?? performance.now(); + const endTime = lastKeypressTime ?? now ?? performance.now(); const durationSeconds = (endTime - firstKeypressTime) / 1000; if (durationSeconds <= 0) return Infinity; @@ -345,10 +342,16 @@ export function getWordBurst(wordIndex: number, now?: number): number { return Math.round(calculateWpm(inputLength, durationSeconds)); } +export function getWordBurst(wordIndex: number, now?: number): number { + const events = getInputEventsPerWord().get(wordIndex) ?? []; + return computeBurst(events, now); +} + export function getBurstHistory(): number[] { - let burstHistory: number[] = []; + const eventsPerWord = getInputEventsPerWord(); + const burstHistory: number[] = []; for (let i = 0; i < TestWords.words.length; i++) { - burstHistory.push(getWordBurst(i)); + burstHistory.push(computeBurst(eventsPerWord.get(i) ?? [])); } return burstHistory; } From 4f6f87cf8d0c49aa94a0546edf421f58b1f77ae4 Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 30 May 2026 19:53:33 +0200 Subject: [PATCH 03/41] yeet keypress timings --- frontend/src/ts/input/handlers/keydown.ts | 4 - frontend/src/ts/input/handlers/keyup.ts | 2 - frontend/src/ts/test/events/data.ts | 6 +- frontend/src/ts/test/test-input.ts | 321 +--------------------- frontend/src/ts/test/test-logic.ts | 48 +--- frontend/src/ts/test/test-stats.ts | 30 -- frontend/src/ts/test/weak-spot.ts | 4 +- 7 files changed, 13 insertions(+), 402 deletions(-) diff --git a/frontend/src/ts/input/handlers/keydown.ts b/frontend/src/ts/input/handlers/keydown.ts index a2beca801445..9194d3ca7419 100644 --- a/frontend/src/ts/input/handlers/keydown.ts +++ b/frontend/src/ts/input/handlers/keydown.ts @@ -1,5 +1,4 @@ import { Config } from "../../config/store"; -import * as TestInput from "../../test/test-input"; import * as TestLogic from "../../test/test-logic"; import { getCharFromEvent } from "../../test/layout-emulator"; import * as Monkey from "../../test/monkey"; @@ -133,9 +132,6 @@ export async function onKeydown(event: KeyboardEvent): Promise { } const now = performance.now(); - if (!TestState.resultCalculating) { - TestInput.recordKeydownTime(now, event); - } logTestEvent("keydown", now, { code: getTestEventCode(event), diff --git a/frontend/src/ts/input/handlers/keyup.ts b/frontend/src/ts/input/handlers/keyup.ts index 2e04d12a7aba..5da7d8a86985 100644 --- a/frontend/src/ts/input/handlers/keyup.ts +++ b/frontend/src/ts/input/handlers/keyup.ts @@ -1,12 +1,10 @@ import { Config } from "../../config/store"; -import * as TestInput from "../../test/test-input"; import * as Monkey from "../../test/monkey"; import { logTestEvent } from "../../test/events/data"; import { getTestEventCode } from "../../test/events/helpers"; export async function onKeyup(event: KeyboardEvent): Promise { const now = performance.now(); - TestInput.recordKeyupTime(now, event); logTestEvent("keyup", now, { code: getTestEventCode(event), ctrl: event.ctrlKey, diff --git a/frontend/src/ts/test/events/data.ts b/frontend/src/ts/test/events/data.ts index ebda2b4c76ba..01cf95e30aa8 100644 --- a/frontend/src/ts/test/events/data.ts +++ b/frontend/src/ts/test/events/data.ts @@ -14,7 +14,6 @@ import { TimerEventData, } from "./types"; import { keysToTrack } from "./helpers"; -import { start } from "../test-stats"; import { Keycode } from "../../constants/keys"; import { roundTo2 } from "@monkeytype/util/numbers"; import { resultCalculating } from "../test-state"; @@ -222,6 +221,9 @@ export function cleanupData(): void { export function getAllTestEvents(): TestEvent[] { if (cachedAllEvents !== undefined) return cachedAllEvents; + const startEventMs = + timerEvents.find((e) => e.data.event === "start")?.ms ?? 0; + // cachedAllEvents = testData300; // return cachedAllEvents; cachedAllEvents = [ @@ -237,7 +239,7 @@ export function getAllTestEvents(): TestEvent[] { (a.type === "timer" ? 1 : 0) - (b.type === "timer" ? 1 : 0), ) .map((event) => { - event.testMs = roundTo2(event.ms - start); + event.testMs = roundTo2(event.ms - startEventMs); return event; }); diff --git a/frontend/src/ts/test/test-input.ts b/frontend/src/ts/test/test-input.ts index 55834d328730..6e71e785626a 100644 --- a/frontend/src/ts/test/test-input.ts +++ b/frontend/src/ts/test/test-input.ts @@ -1,95 +1,6 @@ import { lastElementFromArray } from "../utils/arrays"; -import { mean, roundTo2 } from "@monkeytype/util/numbers"; -import { Config } from "../config/store"; import { getInputElementValue } from "../input/input-element"; -const keysToTrack = new Set([ - "NumpadMultiply", - "NumpadSubtract", - "NumpadAdd", - "NumpadDecimal", - "NumpadEqual", - "NumpadDivide", - "Numpad0", - "Numpad1", - "Numpad2", - "Numpad3", - "Numpad4", - "Numpad5", - "Numpad6", - "Numpad7", - "Numpad8", - "Numpad9", - "Backquote", - "Digit1", - "Digit2", - "Digit3", - "Digit4", - "Digit5", - "Digit6", - "Digit7", - "Digit8", - "Digit9", - "Digit0", - "Minus", - "Equal", - "KeyQ", - "KeyW", - "KeyE", - "KeyR", - "KeyT", - "KeyY", - "KeyU", - "KeyI", - "KeyO", - "KeyP", - "BracketLeft", - "BracketRight", - "Backslash", - "KeyA", - "KeyS", - "KeyD", - "KeyF", - "KeyG", - "KeyH", - "KeyJ", - "KeyK", - "KeyL", - "Semicolon", - "Quote", - "IntlBackslash", - "KeyZ", - "KeyX", - "KeyC", - "KeyV", - "KeyB", - "KeyN", - "KeyM", - "Comma", - "Period", - "Slash", - "Space", - "Enter", - "Tab", - "NoCode", //android (smells) and some keyboards might send no location data - need to use this as a fallback -]); - -type KeypressTimings = { - spacing: { - first: number; - last: number; - array: number[]; - }; - duration: { - array: number[]; - }; -}; - -type Keydata = { - timestamp: number; - index: number; -}; - type ErrorHistoryObject = { count: number; words: number[]; @@ -203,8 +114,6 @@ class Corrected { } } -let keyDownData: Record = {}; - export const input = new Input(); export const corrected = new Corrected(); @@ -219,16 +128,7 @@ export let accuracy = { correct: 0, incorrect: 0, }; -export let keypressTimings: KeypressTimings = { - spacing: { - first: -1, - last: -1, - array: [], - }, - duration: { - array: [], - }, -}; + export let keyOverlap = { total: 0, lastStartTime: -1, @@ -285,223 +185,6 @@ export function incrementAccuracy(correctincorrect: boolean): void { } } -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 indexesToRemove = new Set( - Object.values(keyDownData).map((data) => data.index), - ); - - const keypressDurations = keypressTimings.duration.array.filter( - (_, index) => !indexesToRemove.has(index), - ); - let avg: number; - 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 - // or we should use a magic number - avg = 80; - } else { - 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--; - } - - // oxlint-disable-next-line no-dynamic-delete - delete keyDownData[key]; - - updateOverlap(now); - } -} - -function getEventCode(event: KeyboardEvent): string { - if (event.code === "NumpadEnter" && Config.funbox.includes("58008")) { - return "Space"; - } - - if (event.code.includes("Arrow") && Config.funbox.includes("arrows")) { - return "NoCode"; - } - - if ( - event.code === "" || - event.code === undefined || - event.key === "Unidentified" - ) { - return "NoCode"; - } - - return event.code; -} - -let noCodeIndex = 0; -export function recordKeyupTime(now: number, event: KeyboardEvent): void { - if (event.repeat) { - console.log( - "Keyup not recorded - repeat", - event.key, - event.code, - //ignore for logging - // oxlint-disable-next-line no-deprecated - event.which, - ); - return; - } - - let key = getEventCode(event); - - if (!keysToTrack.has(key)) return; - - if (key === "NoCode") { - noCodeIndex--; - key = `NoCode${noCodeIndex}`; - } - - const keyDownDataForKey = keyDownData[key]; - - if (keyDownDataForKey === undefined) return; - - const diff = Math.abs(keyDownDataForKey.timestamp - now); - keypressTimings.duration.array[keyDownDataForKey.index] = diff; - - console.debug("Keyup recorded", key, diff); - // oxlint-disable-next-line no-dynamic-delete - delete keyDownData[key]; - - updateOverlap(now); -} - -export function recordKeydownTime(now: number, event: KeyboardEvent): void { - if (event.repeat) { - console.log( - "Keydown not recorded - repeat", - event.key, - event.code, - //ignore for logging - // oxlint-disable-next-line no-deprecated - event.which, - ); - return; - } - - let key = getEventCode(event); - - if (!keysToTrack.has(key)) { - console.debug("Keydown not recorded - not tracked", key); - return; - } - - if (keyDownData[key] !== undefined) { - console.debug("Key already down", key); - return; - } - - if (key === "NoCode") { - key = `NoCode${noCodeIndex}`; - noCodeIndex++; - } - - keyDownData[key] = { - timestamp: now, - index: keypressTimings.duration.array.length, - }; - keypressTimings.duration.array.push(0); - - updateOverlap(keyDownData[key]?.timestamp as number); - - if (keypressTimings.spacing.last !== -1) { - const diff = Math.abs(now - keypressTimings.spacing.last); - keypressTimings.spacing.array.push(roundTo2(diff)); - console.debug("Keydown recorded", key, diff); - } - keypressTimings.spacing.last = now; - if (keypressTimings.spacing.first === -1) { - keypressTimings.spacing.first = now; - console.debug("First keydown recorded", key, now); - } -} - -function updateOverlap(now: number): void { - const keys = Object.keys(keyDownData); - if (keys.length > 1) { - if (keyOverlap.lastStartTime === -1) { - keyOverlap.lastStartTime = now; - } - } else { - if (keyOverlap.lastStartTime !== -1) { - keyOverlap.total += now - keyOverlap.lastStartTime; - keyOverlap.lastStartTime = -1; - } - } -} - -export function carryoverFirstKeypress(): 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]; - - // 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, - }; - } -} - -function resetKeypressTimings(): void { - keypressTimings = { - spacing: { - first: -1, - last: -1, - array: [], - }, - duration: { - array: [], - }, - }; - keyOverlap = { - total: 0, - lastStartTime: -1, - }; - keyDownData = {}; - noCodeIndex = 0; - - console.debug("Keypress timings reset"); -} - export function pushMissedWord(word: string): void { if (!Object.keys(missedWords).includes(word)) { missedWords[word] = 1; @@ -525,6 +208,4 @@ export function restart(): void { correct: 0, incorrect: 0, }; - - resetKeypressTimings(); } diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index fed7988a3b17..33d5350fdc79 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -177,7 +177,6 @@ export function startTest(now: number): boolean { TestState.setActive(true); Replay.startReplayRecording(); Replay.replayGetWordsList(TestWords.words.list); - TestInput.carryoverFirstKeypress(); Time.set(0); TestTimer.clear(); @@ -776,43 +775,14 @@ function buildCompletedEvent( stats: TestStats.Stats, rawPerSecond: number[], ): Omit { - //build completed event object - let stfk = Numbers.roundTo2( - TestInput.keypressTimings.spacing.first - TestStats.start, - ); - if (stfk < 0 || Config.mode === "zen") { - stfk = 0; - } - - let lkte = Numbers.roundTo2( - TestStats.end - TestInput.keypressTimings.spacing.last, - ); - if (lkte < 0 || Config.mode === "zen") { - lkte = 0; - } - //consistency const stddev = Numbers.stdDev(rawPerSecond); const avg = Numbers.mean(rawPerSecond); let consistency = Numbers.roundTo2(Numbers.kogasa(stddev / avg)); - let keyConsistencyArray = TestInput.keypressTimings.spacing.array.slice(); - if (keyConsistencyArray.length > 0) { - keyConsistencyArray = keyConsistencyArray.slice( - 0, - keyConsistencyArray.length - 1, - ); - } - let keyConsistency = Numbers.roundTo2( - Numbers.kogasa( - Numbers.stdDev(keyConsistencyArray) / Numbers.mean(keyConsistencyArray), - ), - ); + if (!consistency || isNaN(consistency)) { consistency = 0; } - if (!keyConsistency || isNaN(keyConsistency)) { - keyConsistency = 0; - } const chartErr = []; for (const error of TestInput.errorHistory) { @@ -882,14 +852,14 @@ function buildCompletedEvent( difficulty: Config.difficulty, blindMode: Config.blindMode, tags: activeTagsIds, - keySpacing: TestInput.keypressTimings.spacing.array, - keyDuration: TestInput.keypressTimings.duration.array, + keySpacing: [], + keyDuration: [], keyOverlap: Numbers.roundTo2(TestInput.keyOverlap.total), - lastKeyToEnd: lkte, - startToFirstKey: stfk, + lastKeyToEnd: 0, + startToFirstKey: 0, consistency: consistency, wpmConsistency: wpmConsistency, - keyConsistency: keyConsistency, + keyConsistency: 0, funbox: Config.funbox, bailedOut: TestState.bailedOut, chartData: chartData, @@ -1324,14 +1294,8 @@ export async function finish(difficultyFailed = false): Promise { Replay.replayGetWordsList(TestInput.input.getHistory()); } - TestInput.forceKeyup(now); //this ensures that the last keypress(es) are registered forceReleaseAllKeys(); - const endAfkSeconds = (now - TestInput.keypressTimings.spacing.last) / 1000; - if ((Config.mode === "zen" || TestState.bailedOut) && endAfkSeconds < 7) { - TestStats.setEnd(TestInput.keypressTimings.spacing.last); - } - setResultVisible(true); TestState.setResultVisible(true); TestState.setActive(false); diff --git a/frontend/src/ts/test/test-stats.ts b/frontend/src/ts/test/test-stats.ts index c4fec1ef09c3..2dd2c53752e5 100644 --- a/frontend/src/ts/test/test-stats.ts +++ b/frontend/src/ts/test/test-stats.ts @@ -51,7 +51,6 @@ export function getStats(): unknown { lastSecondNotRound, missedWords: TestInput.missedWords, accuracy: TestInput.accuracy, - keypressTimings: TestInput.keypressTimings, keyOverlap: TestInput.keyOverlap, wordsHistory: TestWords.words.list.slice( 0, @@ -60,35 +59,6 @@ export function getStats(): unknown { inputHistory: TestInput.input.getHistory(), }; - try { - // @ts-expect-error --- - ret.keypressTimings.spacing.average = - TestInput.keypressTimings.spacing.array.reduce( - (previous, current) => (current += previous), - ) / TestInput.keypressTimings.spacing.array.length; - - // @ts-expect-error --- - ret.keypressTimings.spacing.sd = Numbers.stdDev( - TestInput.keypressTimings.spacing.array, - ); - } catch (e) { - // - } - try { - // @ts-expect-error --- - ret.keypressTimings.duration.average = - TestInput.keypressTimings.duration.array.reduce( - (previous, current) => (current += previous), - ) / TestInput.keypressTimings.duration.array.length; - - // @ts-expect-error --- - ret.keypressTimings.duration.sd = Numbers.stdDev( - TestInput.keypressTimings.duration.array, - ); - } catch (e) { - // - } - return ret; } diff --git a/frontend/src/ts/test/weak-spot.ts b/frontend/src/ts/test/weak-spot.ts index b248ddb6f46c..9bfce4c53a0a 100644 --- a/frontend/src/ts/test/weak-spot.ts +++ b/frontend/src/ts/test/weak-spot.ts @@ -1,4 +1,4 @@ -import * as TestInput from "./test-input"; +import { getKeypressSpacing } from "./events/stats"; import { Wordset } from "./wordset"; // Changes how quickly it 'learns' scores - very roughly the score for a char @@ -33,7 +33,7 @@ class Score { } export function updateScore(char: string, isCorrect: boolean): void { - const timings = TestInput.keypressTimings.spacing.array; + const timings = getKeypressSpacing(); if (timings.length === 0 || typeof timings === "string") { return; } From 99a7953217f65787223e2f39c31a3465ed2f0b34 Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 30 May 2026 19:58:59 +0200 Subject: [PATCH 04/41] yeet keypresscounthistory --- frontend/src/ts/input/handlers/insert-text.ts | 1 - frontend/src/ts/test/test-input.ts | 13 ---- frontend/src/ts/test/test-logic.ts | 68 +------------------ frontend/src/ts/test/test-stats.ts | 8 +-- frontend/src/ts/test/test-timer.ts | 1 - 5 files changed, 2 insertions(+), 89 deletions(-) diff --git a/frontend/src/ts/input/handlers/insert-text.ts b/frontend/src/ts/input/handlers/insert-text.ts index 54c8208a3bab..c99361d3c6ed 100644 --- a/frontend/src/ts/input/handlers/insert-text.ts +++ b/frontend/src/ts/input/handlers/insert-text.ts @@ -166,7 +166,6 @@ export async function onInsertText(options: OnInsertTextParams): Promise { Replay.addReplayEvent(correct ? "correctLetter" : "incorrectLetter", data); TestInput.incrementAccuracy(correct); WeakSpot.updateScore(data, correct); - TestInput.incrementKeypressCount(); TestInput.pushKeypressWord(wordIndex); if (!correct) { TestInput.incrementKeypressErrors(); diff --git a/frontend/src/ts/test/test-input.ts b/frontend/src/ts/test/test-input.ts index 6e71e785626a..1082ea11cb08 100644 --- a/frontend/src/ts/test/test-input.ts +++ b/frontend/src/ts/test/test-input.ts @@ -117,8 +117,6 @@ class Corrected { export const input = new Input(); export const corrected = new Corrected(); -export let keypressCountHistory: number[] = []; -let currentKeypressCount = 0; type MissedWordsType = Record; // We're using Object.create(null) to make sure that __proto__ won't have any special meaning when it's used to index the missedWords object (so if a user mistypes the word __proto__ it will appear in the practise words test) export let missedWords: MissedWordsType = Object.create( @@ -143,10 +141,6 @@ let currentErrorHistory: ErrorHistoryObject = { export let afkHistory: boolean[] = []; let currentAfk = true; -export function incrementKeypressCount(): void { - currentKeypressCount++; -} - export function setCurrentNotAfk(): void { currentAfk = false; } @@ -159,11 +153,6 @@ export function pushKeypressWord(wordIndex: number): void { currentErrorHistory.words.push(wordIndex); } -export function pushKeypressesToHistory(): void { - keypressCountHistory.push(currentKeypressCount); - currentKeypressCount = 0; -} - export function pushAfkToHistory(): void { afkHistory.push(currentAfk); currentAfk = true; @@ -194,8 +183,6 @@ export function pushMissedWord(word: string): void { } export function restart(): void { - keypressCountHistory = []; - currentKeypressCount = 0; afkHistory = []; currentAfk = true; errorHistory = []; diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 33d5350fdc79..827ee6614f17 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -104,7 +104,6 @@ import { getWpmHistory, getAfkDuration, forceReleaseAllKeys, - getKeypressesPerSecond, } from "./events/stats"; import { calculateWpm } from "../utils/numbers"; @@ -274,7 +273,6 @@ export function restart(options = {} as RestartOptions): void { } if (Config.resultSaving) { - TestInput.pushKeypressesToHistory(); TestInput.pushErrorToHistory(); TestInput.pushAfkToHistory(); const testSeconds = TestStats.calculateTestSeconds(performance.now()); @@ -1071,44 +1069,6 @@ function compareCompletedEvents( } } - { - const a = TestInput.keypressCountHistory; - const b = getKeypressesPerSecond(); - if (a.length === b.length && a.every((val, i) => val === b[i])) { - console.debug(`Completed event match on key keypressCountHistory:`, a); - } else { - notMatching.push(`keypressCountHistory (values differ)`); - mismatchedKeys.push("keypressCountHistory"); - console.error( - `Completed event mismatch on key keypressCountHistory:`, - a, - b, - ); - } - } - - { - const a = TestInput.keypressCountHistory.reduce((acc, val) => { - if (val === undefined) return acc; - return acc + val; - }, 0); - const b = getKeypressesPerSecond().reduce((acc, val) => { - if (val === undefined) return acc; - return acc + val; - }, 0); - if (a === b) { - console.debug(`Completed event match on totalKeypressCountHistory:`, a); - } else { - notMatching.push(`totalKeypressCountHistory (${a} vs ${b})`); - mismatchedKeys.push("totalKeypressCountHistory"); - console.error( - `Completed event mismatch on totalKeypressCountHistory:`, - a, - b, - ); - } - } - if (notMatching.length === 0) { if (ALWAYSREPORT) { showSuccessNotification("Completed events match", { important: true }); @@ -1305,11 +1265,6 @@ export async function finish(difficultyFailed = false): Promise { // logEventsDataToTheConsoleTable(); - //remove afk from zen - if (Config.mode === "zen" || TestState.bailedOut) { - TestStats.removeAfkData(); - } - // stats const stats = TestStats.calculateFinalStats(); if ( @@ -1330,31 +1285,11 @@ export async function finish(difficultyFailed = false): Promise { !difficultyFailed && Math.round(stats.time % 1) >= 0.5 ) { - TestInput.pushKeypressesToHistory(); TestInput.pushErrorToHistory(); TestInput.pushAfkToHistory(); } - const rawPerSecond = TestInput.keypressCountHistory.map((count) => - Math.round((count / 5) * 60), - ); - - //adjust last second if last second is not round - // if (TestStats.lastSecondNotRound && stats.time % 1 >= 0.1) { - if ( - Config.mode !== "time" && - TestStats.lastSecondNotRound && - stats.time % 1 >= 0.5 - ) { - const timescale = 1 / (stats.time % 1); - - //multiply last element of rawBefore by scale, and round it - rawPerSecond[rawPerSecond.length - 1] = Math.round( - (rawPerSecond[rawPerSecond.length - 1] as number) * timescale, - ); - } - - const ce = buildCompletedEvent(stats, rawPerSecond); + const ce = buildCompletedEvent(stats, []); console.debug("Completed event object", ce); @@ -1749,7 +1684,6 @@ export function fail(reason: string): void { failReason = reason; // input.pushHistory(); // corrected.pushHistory(); - TestInput.pushKeypressesToHistory(); TestInput.pushErrorToHistory(); TestInput.pushAfkToHistory(); void finish(true); diff --git a/frontend/src/ts/test/test-stats.ts b/frontend/src/ts/test/test-stats.ts index 2dd2c53752e5..d7b1915733c8 100644 --- a/frontend/src/ts/test/test-stats.ts +++ b/frontend/src/ts/test/test-stats.ts @@ -47,7 +47,6 @@ export function getStats(): unknown { end3, afkHistory: TestInput.afkHistory, errorHistory: TestInput.errorHistory, - keypressCountHistory: TestInput.keypressCountHistory, lastSecondNotRound, missedWords: TestInput.missedWords, accuracy: TestInput.accuracy, @@ -127,7 +126,7 @@ export function setStart(s: number): void { export function calculateAfkSeconds(testSeconds: number): number { let extraAfk = 0; if (testSeconds !== undefined) { - extraAfk = Math.round(testSeconds) - TestInput.keypressCountHistory.length; + extraAfk = Math.round(testSeconds); if (extraAfk < 0) extraAfk = 0; // console.log("-- extra afk debug"); // console.log("should be " + Math.ceil(testSeconds)); @@ -152,11 +151,6 @@ export function calculateAccuracy(): number { return isNaN(acc) ? 100 : acc; } -export function removeAfkData(): void { - const testSeconds = calculateTestSeconds(); - TestInput.keypressCountHistory.splice(testSeconds); -} - function getInputWords(): string[] { const containsKorean = TestInput.input.getKoreanStatus(); diff --git a/frontend/src/ts/test/test-timer.ts b/frontend/src/ts/test/test-timer.ts index 3d112948cc13..11b444398a9d 100644 --- a/frontend/src/ts/test/test-timer.ts +++ b/frontend/src/ts/test/test-timer.ts @@ -166,7 +166,6 @@ function checkIfFailed( acc: number, ): boolean { if (timerDebug) console.time("fail conditions"); - TestInput.pushKeypressesToHistory(); TestInput.pushErrorToHistory(); TestInput.pushAfkToHistory(); if ( From c2bb6619d7d9c05fe9fef36d4c40394203aaef6f Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 30 May 2026 20:01:53 +0200 Subject: [PATCH 05/41] yeet afk history --- frontend/src/ts/input/handlers/delete.ts | 1 - frontend/src/ts/input/handlers/insert-text.ts | 1 - frontend/src/ts/test/test-input.ts | 14 -------------- frontend/src/ts/test/test-logic.ts | 10 +++------- frontend/src/ts/test/test-stats.ts | 17 ----------------- frontend/src/ts/test/test-timer.ts | 1 - 6 files changed, 3 insertions(+), 41 deletions(-) diff --git a/frontend/src/ts/input/handlers/delete.ts b/frontend/src/ts/input/handlers/delete.ts index 8d2a44340a47..f9a93c885a6d 100644 --- a/frontend/src/ts/input/handlers/delete.ts +++ b/frontend/src/ts/input/handlers/delete.ts @@ -19,7 +19,6 @@ export function onDelete(inputType: DeleteInputType, now: number): void { TestInput.input.syncWithInputElement(); Replay.addReplayEvent("setLetterIndex", TestInput.input.current.length); - TestInput.setCurrentNotAfk(); const beforeDeleteOnlyTabs = /^\t*$/.test(inputBeforeDelete); const allTabsCorrect = TestWords.words diff --git a/frontend/src/ts/input/handlers/insert-text.ts b/frontend/src/ts/input/handlers/insert-text.ts index c99361d3c6ed..d3b77ce0ae7e 100644 --- a/frontend/src/ts/input/handlers/insert-text.ts +++ b/frontend/src/ts/input/handlers/insert-text.ts @@ -162,7 +162,6 @@ export async function onInsertText(options: OnInsertTextParams): Promise { } // general per keypress updates - TestInput.setCurrentNotAfk(); Replay.addReplayEvent(correct ? "correctLetter" : "incorrectLetter", data); TestInput.incrementAccuracy(correct); WeakSpot.updateScore(data, correct); diff --git a/frontend/src/ts/test/test-input.ts b/frontend/src/ts/test/test-input.ts index 1082ea11cb08..8a5269e4bda4 100644 --- a/frontend/src/ts/test/test-input.ts +++ b/frontend/src/ts/test/test-input.ts @@ -138,13 +138,6 @@ let currentErrorHistory: ErrorHistoryObject = { words: [], }; -export let afkHistory: boolean[] = []; -let currentAfk = true; - -export function setCurrentNotAfk(): void { - currentAfk = false; -} - export function incrementKeypressErrors(): void { currentErrorHistory.count++; } @@ -153,11 +146,6 @@ export function pushKeypressWord(wordIndex: number): void { currentErrorHistory.words.push(wordIndex); } -export function pushAfkToHistory(): void { - afkHistory.push(currentAfk); - currentAfk = true; -} - export function pushErrorToHistory(): void { errorHistory.push(currentErrorHistory); currentErrorHistory = { @@ -183,8 +171,6 @@ export function pushMissedWord(word: string): void { } export function restart(): void { - afkHistory = []; - currentAfk = true; errorHistory = []; currentErrorHistory = { count: 0, diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 827ee6614f17..99009484121d 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -274,9 +274,8 @@ export function restart(options = {} as RestartOptions): void { if (Config.resultSaving) { TestInput.pushErrorToHistory(); - TestInput.pushAfkToHistory(); const testSeconds = TestStats.calculateTestSeconds(performance.now()); - const afkseconds = TestStats.calculateAfkSeconds(testSeconds); + const afkseconds = 0; let tt = Numbers.roundTo2(testSeconds - afkseconds); if (tt < 0) tt = 0; const acc = Numbers.roundTo2(TestStats.calculateAccuracy()); @@ -816,7 +815,7 @@ function buildCompletedEvent( .map((tag) => tag._id); const duration = parseFloat(stats.time.toString()); - const afkDuration = TestStats.calculateAfkSeconds(duration); + const afkDuration = 0; let language = Config.language; if (Config.mode === "quote") { language = Strings.removeLanguageSize(Config.language); @@ -1286,7 +1285,6 @@ export async function finish(difficultyFailed = false): Promise { Math.round(stats.time % 1) >= 0.5 ) { TestInput.pushErrorToHistory(); - TestInput.pushAfkToHistory(); } const ce = buildCompletedEvent(stats, []); @@ -1325,9 +1323,8 @@ export async function finish(difficultyFailed = false): Promise { ///////// completed event ready //afk check - const kps = TestInput.afkHistory.slice(-5); - let afkDetected = kps.length > 0 && kps.every((afk) => afk); + let afkDetected = false; if (TestState.bailedOut) afkDetected = false; const mode2Number = parseInt(completedEvent.mode2); @@ -1685,7 +1682,6 @@ export function fail(reason: string): void { // input.pushHistory(); // corrected.pushHistory(); TestInput.pushErrorToHistory(); - TestInput.pushAfkToHistory(); void finish(true); } diff --git a/frontend/src/ts/test/test-stats.ts b/frontend/src/ts/test/test-stats.ts index d7b1915733c8..57573990629a 100644 --- a/frontend/src/ts/test/test-stats.ts +++ b/frontend/src/ts/test/test-stats.ts @@ -45,7 +45,6 @@ export function getStats(): unknown { end, start3, end3, - afkHistory: TestInput.afkHistory, errorHistory: TestInput.errorHistory, lastSecondNotRound, missedWords: TestInput.missedWords, @@ -123,22 +122,6 @@ export function setStart(s: number): void { start3 = new Date().getTime(); } -export function calculateAfkSeconds(testSeconds: number): number { - let extraAfk = 0; - if (testSeconds !== undefined) { - extraAfk = Math.round(testSeconds); - if (extraAfk < 0) extraAfk = 0; - // console.log("-- extra afk debug"); - // console.log("should be " + Math.ceil(testSeconds)); - // console.log(keypressPerSecond.length); - // console.log( - // `gonna add extra ${extraAfk} seconds of afk because of no keypress data` - // ); - } - const ret = TestInput.afkHistory.filter((afk) => afk).length; - return ret + extraAfk; -} - export function setLastSecondNotRound(): void { lastSecondNotRound = true; } diff --git a/frontend/src/ts/test/test-timer.ts b/frontend/src/ts/test/test-timer.ts index 11b444398a9d..fd977b128cd7 100644 --- a/frontend/src/ts/test/test-timer.ts +++ b/frontend/src/ts/test/test-timer.ts @@ -167,7 +167,6 @@ function checkIfFailed( ): boolean { if (timerDebug) console.time("fail conditions"); TestInput.pushErrorToHistory(); - TestInput.pushAfkToHistory(); if ( Config.minWpm === "custom" && wpmAndRaw.wpm < Config.minWpmCustomSpeed && From 1dae6e422b0e047a30b4ad1b4ba9351327d319f7 Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 30 May 2026 20:23:19 +0200 Subject: [PATCH 06/41] missing after last yeet --- frontend/src/ts/test/events/stats.ts | 56 ++++++++++++++++++++++++++++ frontend/src/ts/test/result.ts | 5 ++- frontend/src/ts/test/test-logic.ts | 20 ++-------- 3 files changed, 63 insertions(+), 18 deletions(-) diff --git a/frontend/src/ts/test/events/stats.ts b/frontend/src/ts/test/events/stats.ts index efda7750be1e..02ba48298cb6 100644 --- a/frontend/src/ts/test/events/stats.ts +++ b/frontend/src/ts/test/events/stats.ts @@ -556,6 +556,62 @@ export function getWpmHistory(): number[] { return wpmHistory; } +export function getRawHistory(): number[] { + const events = getAllTestEvents(); + const timerBoundaries = getTimerBoundaries(events); + const wpmHistory: number[] = []; + + for (const boundary of timerBoundaries) { + const eventsPerWord = getInputEventsPerWord(undefined, boundary); + + // Compute simulated inputs first so we can determine the effective last word + const wordInputs = new Map< + number, + { input: string; events: InputEvent[] } + >(); + let maxWordIndex = 0; + for (const [k, wordEvents] of eventsPerWord) { + const input = getSimulatedInput(wordEvents); + wordInputs.set(k, { input, events: wordEvents }); + // Only count words with non-empty input for maxWordIndex, + // so that fully-deleted words don't prevent earlier words + // from being treated as the last word + if (input.length > 0 && k > maxWordIndex) maxWordIndex = k; + } + + let totalCorrect = 0; + for (const [wordIndex, { input, events: wordEvents }] of wordInputs) { + if (input.length === 0) continue; + + const lastEvt = wordEvents[wordEvents.length - 1]; + let adjustedMax = maxWordIndex; + if ( + lastEvt !== undefined && + lastEvt.data.inputType === "insertText" && + lastEvt.data.data === " " + ) { + adjustedMax = maxWordIndex + 1; + } + const lastWord = wordIndex === adjustedMax; + + const trimmed = lastWord ? input.trimEnd() : input; + const targetWord = + Config.mode === "zen" + ? trimmed + : TestWords.words.getText(wordIndex) + (lastWord ? "" : " "); + + const count = countChars(trimmed, targetWord, lastWord, true); + + totalCorrect += count.allCorrect + count.extra + count.incorrect; + } + + const durationSeconds = boundary / 1000; + wpmHistory.push(Math.round(calculateWpm(totalCorrect, durationSeconds))); + } + + return wpmHistory; +} + export function getAfkDuration(): number { const { counts } = countPerInterval( (e) => e.type === "keydown" || e.type === "input", diff --git a/frontend/src/ts/test/result.ts b/frontend/src/ts/test/result.ts index e5fcaf183807..6e0e9e056123 100644 --- a/frontend/src/ts/test/result.ts +++ b/frontend/src/ts/test/result.ts @@ -62,6 +62,7 @@ import { currentQuote } from "./test-words"; import { qs, qsa } from "../utils/dom"; import { getTheme } from "../states/theme"; import { isTestInvalid } from "../states/test"; +import { getRawHistory, getWpmHistory } from "./events/stats"; let result: CompletedEvent; let minChartVal: number; @@ -110,7 +111,7 @@ async function updateChartData(): Promise { let labels = []; - for (let i = 1; i <= 0; i++) { + for (let i = 1; i <= getWpmHistory().length; i++) { if (TestStats.lastSecondNotRound && i === 0) { labels.push(Numbers.roundTo2(result.testDuration).toString()); } else { @@ -124,7 +125,7 @@ async function updateChartData(): Promise { ), ]; - const chartData2: number[] = []; + const chartData2: number[] = getRawHistory(); const valueWindow = Math.max(...result.chartData.burst) * 0.25; let smoothedBurst = Arrays.smoothWithValueWindow( diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 99009484121d..6a38c7f0a82a 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -781,15 +781,12 @@ function buildCompletedEvent( consistency = 0; } - const chartErr = []; - for (const error of TestInput.errorHistory) { - chartErr.push(error.count ?? 0); - } + const wpmHistory = getWpmHistory(); const chartData = { - wpm: [], + wpm: wpmHistory, burst: rawPerSecond, - err: chartErr, + err: getErrorCountHistory(), }; //wpm consistency @@ -1278,16 +1275,7 @@ export async function finish(difficultyFailed = false): Promise { PaceCaret.setLastTestWpm(stats.wpm); - // if the last second was not rounded, add another data point to the history - if ( - TestStats.lastSecondNotRound && - !difficultyFailed && - Math.round(stats.time % 1) >= 0.5 - ) { - TestInput.pushErrorToHistory(); - } - - const ce = buildCompletedEvent(stats, []); + const ce = buildCompletedEvent(stats, getRawPerSecond()); console.debug("Completed event object", ce); From 1e01096321e3783b83493ef987c0a23a84a85ab1 Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 30 May 2026 20:23:25 +0200 Subject: [PATCH 07/41] yeet error history --- .../src/ts/controllers/chart-controller.ts | 4 +-- frontend/src/ts/input/handlers/insert-text.ts | 2 -- frontend/src/ts/test/events/stats.ts | 26 +++++++++++++++ frontend/src/ts/test/test-input.ts | 32 ------------------- frontend/src/ts/test/test-logic.ts | 4 --- frontend/src/ts/test/test-stats.ts | 1 - frontend/src/ts/test/test-timer.ts | 1 - 7 files changed, 28 insertions(+), 42 deletions(-) diff --git a/frontend/src/ts/controllers/chart-controller.ts b/frontend/src/ts/controllers/chart-controller.ts index 572c5060ee24..d10d31dd0895 100644 --- a/frontend/src/ts/controllers/chart-controller.ts +++ b/frontend/src/ts/controllers/chart-controller.ts @@ -58,13 +58,13 @@ Chart.defaults.elements.line.fill = "origin"; import "chartjs-adapter-date-fns"; import { Config } from "../config/store"; import { configEvent } from "../events/config"; -import * as TestInput from "../test/test-input"; import * as Arrays from "../utils/arrays"; import { blendTwoHexColors } from "../utils/colors"; import { typedKeys } from "../utils/misc"; import { getTheme } from "../states/theme"; import { Theme } from "../constants/themes"; import { createDebouncedEffectOn } from "../hooks/effects"; +import { getIncorrectWordIndexesForSecond } from "../test/events/stats"; export class ChartWithUpdateColors< TType extends ChartType = ChartType, @@ -275,7 +275,7 @@ export const result = new ChartWithUpdateColors< try { const keypressIndex = Math.round(parseFloat(ti.label)) - 1; const wordsToHighlight = - TestInput.errorHistory[keypressIndex]?.words; + getIncorrectWordIndexesForSecond(keypressIndex); const unique = [...new Set(wordsToHighlight)]; const firstHighlightWordIndex = unique[0]; diff --git a/frontend/src/ts/input/handlers/insert-text.ts b/frontend/src/ts/input/handlers/insert-text.ts index d3b77ce0ae7e..c69c7a492076 100644 --- a/frontend/src/ts/input/handlers/insert-text.ts +++ b/frontend/src/ts/input/handlers/insert-text.ts @@ -165,9 +165,7 @@ export async function onInsertText(options: OnInsertTextParams): Promise { Replay.addReplayEvent(correct ? "correctLetter" : "incorrectLetter", data); TestInput.incrementAccuracy(correct); WeakSpot.updateScore(data, correct); - TestInput.pushKeypressWord(wordIndex); if (!correct) { - TestInput.incrementKeypressErrors(); TestInput.pushMissedWord(TestWords.words.getCurrentText()); } if (Config.keymapMode === "react") { diff --git a/frontend/src/ts/test/events/stats.ts b/frontend/src/ts/test/events/stats.ts index 02ba48298cb6..87d8036ffe31 100644 --- a/frontend/src/ts/test/events/stats.ts +++ b/frontend/src/ts/test/events/stats.ts @@ -488,6 +488,32 @@ export function getKeypressOverlap(): number { return roundTo2(overlap); } +export function getIncorrectWordIndexesForSecond(second: number): number[] { + const events = getAllTestEvents(); + const boundaries = getTimerBoundaries(events); + + const boundary = boundaries[second]; + if (boundary === undefined) return []; + + const prevBoundary = second > 0 ? boundaries[second - 1] : undefined; + const wordIndexes = new Set(); + + for (const event of events) { + if (prevBoundary !== undefined && event.testMs <= prevBoundary) continue; + if (event.testMs > boundary) break; + + if ( + event.type === "input" && + event.data.inputType === "insertText" && + !event.data.correct + ) { + wordIndexes.add(event.data.wordIndex); + } + } + + return [...wordIndexes]; +} + export function getErrorCountHistory(): number[] { const { counts } = countPerInterval( (e) => diff --git a/frontend/src/ts/test/test-input.ts b/frontend/src/ts/test/test-input.ts index 8a5269e4bda4..3481864241ea 100644 --- a/frontend/src/ts/test/test-input.ts +++ b/frontend/src/ts/test/test-input.ts @@ -1,11 +1,6 @@ import { lastElementFromArray } from "../utils/arrays"; import { getInputElementValue } from "../input/input-element"; -type ErrorHistoryObject = { - count: number; - words: number[]; -}; - class Input { current: string; private history: string[]; @@ -132,28 +127,6 @@ export let keyOverlap = { lastStartTime: -1, }; -export let errorHistory: ErrorHistoryObject[] = []; -let currentErrorHistory: ErrorHistoryObject = { - count: 0, - words: [], -}; - -export function incrementKeypressErrors(): void { - currentErrorHistory.count++; -} - -export function pushKeypressWord(wordIndex: number): void { - currentErrorHistory.words.push(wordIndex); -} - -export function pushErrorToHistory(): void { - errorHistory.push(currentErrorHistory); - currentErrorHistory = { - count: 0, - words: [], - }; -} - export function incrementAccuracy(correctincorrect: boolean): void { if (correctincorrect) { accuracy.correct++; @@ -171,11 +144,6 @@ export function pushMissedWord(word: string): void { } export function restart(): void { - errorHistory = []; - currentErrorHistory = { - count: 0, - words: [], - }; missedWords = Object.create(null) as MissedWordsType; accuracy = { correct: 0, diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 6a38c7f0a82a..62950afcfc05 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -273,7 +273,6 @@ export function restart(options = {} as RestartOptions): void { } if (Config.resultSaving) { - TestInput.pushErrorToHistory(); const testSeconds = TestStats.calculateTestSeconds(performance.now()); const afkseconds = 0; let tt = Numbers.roundTo2(testSeconds - afkseconds); @@ -1667,9 +1666,6 @@ async function saveResult( export function fail(reason: string): void { failReason = reason; - // input.pushHistory(); - // corrected.pushHistory(); - TestInput.pushErrorToHistory(); void finish(true); } diff --git a/frontend/src/ts/test/test-stats.ts b/frontend/src/ts/test/test-stats.ts index 57573990629a..19f28e10983c 100644 --- a/frontend/src/ts/test/test-stats.ts +++ b/frontend/src/ts/test/test-stats.ts @@ -45,7 +45,6 @@ export function getStats(): unknown { end, start3, end3, - errorHistory: TestInput.errorHistory, lastSecondNotRound, missedWords: TestInput.missedWords, accuracy: TestInput.accuracy, diff --git a/frontend/src/ts/test/test-timer.ts b/frontend/src/ts/test/test-timer.ts index fd977b128cd7..6d0c2b31fcdb 100644 --- a/frontend/src/ts/test/test-timer.ts +++ b/frontend/src/ts/test/test-timer.ts @@ -166,7 +166,6 @@ function checkIfFailed( acc: number, ): boolean { if (timerDebug) console.time("fail conditions"); - TestInput.pushErrorToHistory(); if ( Config.minWpm === "custom" && wpmAndRaw.wpm < Config.minWpmCustomSpeed && From 5d3bd0766199036a2a8da01a8574491327908073 Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 30 May 2026 20:26:03 +0200 Subject: [PATCH 08/41] fix word highlight --- .../src/ts/controllers/chart-controller.ts | 5 ++--- frontend/src/ts/test/events/stats.ts | 22 +++++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/frontend/src/ts/controllers/chart-controller.ts b/frontend/src/ts/controllers/chart-controller.ts index d10d31dd0895..2142abf340c4 100644 --- a/frontend/src/ts/controllers/chart-controller.ts +++ b/frontend/src/ts/controllers/chart-controller.ts @@ -64,7 +64,7 @@ import { typedKeys } from "../utils/misc"; import { getTheme } from "../states/theme"; import { Theme } from "../constants/themes"; import { createDebouncedEffectOn } from "../hooks/effects"; -import { getIncorrectWordIndexesForSecond } from "../test/events/stats"; +import { getWordIndexesForSecond } from "../test/events/stats"; export class ChartWithUpdateColors< TType extends ChartType = ChartType, @@ -274,8 +274,7 @@ export const result = new ChartWithUpdateColors< prevTi = ti; try { const keypressIndex = Math.round(parseFloat(ti.label)) - 1; - const wordsToHighlight = - getIncorrectWordIndexesForSecond(keypressIndex); + const wordsToHighlight = getWordIndexesForSecond(keypressIndex); const unique = [...new Set(wordsToHighlight)]; const firstHighlightWordIndex = unique[0]; diff --git a/frontend/src/ts/test/events/stats.ts b/frontend/src/ts/test/events/stats.ts index 87d8036ffe31..8fce0d898e12 100644 --- a/frontend/src/ts/test/events/stats.ts +++ b/frontend/src/ts/test/events/stats.ts @@ -514,6 +514,28 @@ export function getIncorrectWordIndexesForSecond(second: number): number[] { return [...wordIndexes]; } +export function getWordIndexesForSecond(second: number): number[] { + const events = getAllTestEvents(); + const boundaries = getTimerBoundaries(events); + + const boundary = boundaries[second]; + if (boundary === undefined) return []; + + const prevBoundary = second > 0 ? boundaries[second - 1] : undefined; + const wordIndexes = new Set(); + + for (const event of events) { + if (prevBoundary !== undefined && event.testMs <= prevBoundary) continue; + if (event.testMs > boundary) break; + + if (event.type === "input" && event.data.inputType === "insertText") { + wordIndexes.add(event.data.wordIndex); + } + } + + return [...wordIndexes]; +} + export function getErrorCountHistory(): number[] { const { counts } = countPerInterval( (e) => From 709e2ba866516c8af08f8e759ab853e3e0c03310 Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 30 May 2026 20:26:57 +0200 Subject: [PATCH 09/41] fix --- frontend/src/ts/test/test-logic.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 62950afcfc05..dce93c72cea9 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -274,7 +274,7 @@ export function restart(options = {} as RestartOptions): void { if (Config.resultSaving) { const testSeconds = TestStats.calculateTestSeconds(performance.now()); - const afkseconds = 0; + const afkseconds = getAfkDuration(); let tt = Numbers.roundTo2(testSeconds - afkseconds); if (tt < 0) tt = 0; const acc = Numbers.roundTo2(TestStats.calculateAccuracy()); From 1c616931ce7c56e99991837831b5f0e9338b4a5a Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 30 May 2026 20:28:46 +0200 Subject: [PATCH 10/41] yeet comparison --- frontend/src/ts/test/test-logic.ts | 357 +---------------------------- 1 file changed, 3 insertions(+), 354 deletions(-) diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index dce93c72cea9..b6a3fadb7512 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -85,11 +85,7 @@ import { qs } from "../utils/dom"; import { setAccountButtonSpinner } from "../states/header"; import { Config } from "../config/store"; import { setQuoteLengthAll, toggleFunbox, setConfig } from "../config/setters"; -import { - resetTestEvents, - cleanupData, - logEventsDataToTheConsoleTable, -} from "./events/data"; +import { resetTestEvents, cleanupData } from "./events/data"; import { getKeypressDurations, getChars, @@ -767,340 +763,7 @@ export async function retrySavingResult(): Promise { await saveResult(completedEvent, true); } -function buildCompletedEvent( - stats: TestStats.Stats, - rawPerSecond: number[], -): Omit { - //consistency - const stddev = Numbers.stdDev(rawPerSecond); - const avg = Numbers.mean(rawPerSecond); - let consistency = Numbers.roundTo2(Numbers.kogasa(stddev / avg)); - - if (!consistency || isNaN(consistency)) { - consistency = 0; - } - - const wpmHistory = getWpmHistory(); - - const chartData = { - wpm: wpmHistory, - burst: rawPerSecond, - err: getErrorCountHistory(), - }; - - //wpm consistency - const stddev3 = Numbers.stdDev(chartData.wpm ?? []); - const avg3 = Numbers.mean(chartData.wpm ?? []); - const wpmCons = Numbers.roundTo2(Numbers.kogasa(stddev3 / avg3)); - const wpmConsistency = isNaN(wpmCons) ? 0 : wpmCons; - - let customText: CompletedEventCustomText | undefined = undefined; - if (Config.mode === "custom") { - const temp = CustomText.getData(); - customText = { - textLen: temp.text.length, - mode: temp.mode, - pipeDelimiter: temp.pipeDelimiter, - limit: temp.limit, - }; - } - - //tags - const activeTagsIds: string[] = __nonReactive - .getActiveTags() - .map((tag) => tag._id); - - const duration = parseFloat(stats.time.toString()); - const afkDuration = 0; - let language = Config.language; - if (Config.mode === "quote") { - language = Strings.removeLanguageSize(Config.language); - } - - const quoteLength = TestWords.currentQuote?.group ?? -1; - - const completedEvent: Omit = { - wpm: stats.wpm, - rawWpm: stats.wpmRaw, - charStats: [ - stats.correctChars + stats.correctSpaces, - stats.incorrectChars, - stats.extraChars, - stats.missedChars, - ], - charTotal: stats.allChars, - acc: stats.acc, - mode: Config.mode, - mode2: Misc.getMode2(Config, TestWords.currentQuote), - quoteLength: quoteLength, - punctuation: Config.punctuation, - numbers: Config.numbers, - lazyMode: Config.lazyMode, - timestamp: Date.now(), - language: language, - restartCount: getRestartCount(), - incompleteTests: getIncompleteTests(), - incompleteTestSeconds: - getIncompleteSeconds() < 0 ? 0 : Numbers.roundTo2(getIncompleteSeconds()), - difficulty: Config.difficulty, - blindMode: Config.blindMode, - tags: activeTagsIds, - keySpacing: [], - keyDuration: [], - keyOverlap: Numbers.roundTo2(TestInput.keyOverlap.total), - lastKeyToEnd: 0, - startToFirstKey: 0, - consistency: consistency, - wpmConsistency: wpmConsistency, - keyConsistency: 0, - funbox: Config.funbox, - bailedOut: TestState.bailedOut, - chartData: chartData, - customText: customText, - testDuration: duration, - afkDuration: afkDuration, - stopOnLetter: Config.stopOnError === "letter", - }; - - if (completedEvent.mode !== "custom") delete completedEvent.customText; - if (completedEvent.mode !== "quote") delete completedEvent.quoteLength; - - return completedEvent; -} - -const ALWAYSREPORT = true; - -function compareCompletedEvents( - ce: Omit, -): void { - const start = performance.now(); - const ce2 = buildCompletedEvent2(); - const end = performance.now(); - - console.debug( - `Built completed event 2 in ${Numbers.roundTo2(end - start)} ms`, - ); - - //compare ce and ce2, log differences - const notMatching: string[] = []; - const mismatchedKeys: string[] = []; - const ceKeys = Object.keys(ce) as (keyof typeof ce)[]; - for (const key of ceKeys) { - let val1 = ce[key]; - let val2 = ce2[key]; - - if (key === "keyDuration" || key === "keySpacing") { - const a = (val1 as number[]).map((v) => Numbers.roundTo2(v)); - const b = (val2 as number[]).map((v) => Numbers.roundTo2(v)); - const total = Math.max(a.length, b.length); - let mismatchCount = 0; - if (a.length !== b.length) { - mismatchCount = total; - console.error( - `Completed event length mismatch on key ${key}: ${a.length} vs ${b.length}`, - ); - } else { - for (let i = 0; i < total; i++) { - if (a[i] !== b[i]) mismatchCount++; - } - } - if (mismatchCount === 0) { - console.debug(`Completed event match on key ${key}:`, a); - } else { - notMatching.push(`${key} (${mismatchCount}/${total} elements differ)`); - mismatchedKeys.push(key); - console.error( - `Completed event mismatch on key ${key}: ${mismatchCount}/${total} elements differ`, - a, - b, - ); - } - continue; - } - - if (key === "charStats") { - const a = val1 as number[]; - const b = val2 as number[]; - const labels = ["correct", "incorrect", "extra", "missed"]; - const diffs: string[] = []; - for (let i = 0; i < Math.max(a.length, b.length); i++) { - if (a[i] !== b[i]) { - const label = labels[i] ?? `[${i}]`; - diffs.push(`${label}: ${a[i]} vs ${b[i]}`); - } - } - if (diffs.length === 0) { - console.debug(`Completed event match on key charStats:`, a); - } else { - notMatching.push(`charStats (${diffs.join(", ")})`); - mismatchedKeys.push("charStats"); - console.error(`Completed event mismatch on key charStats:`, a, b); - } - continue; - } - - if (key === "keyOverlap") { - val1 = Numbers.roundTo2(val1 as number); - val2 = Numbers.roundTo2(val2 as number); - } - - if (key === "timestamp") { - continue; - } - - if (key === "consistency") { - continue; - } - - // if (key === "chartData") { - // val1 = { - // //@ts-expect-error temp - // // eslint-disable-next-line - // wpm: (val1 as CompletedEvent["chartData"]).wpm.map((v) => - // // eslint-disable-next-line - // Math.round(v), - // ), - // //@ts-expect-error temp - // // eslint-disable-next-line - // burst: (val1 as CompletedEvent["chartData"]).burst, - // //@ts-expect-error temp - // // eslint-disable-next-line - // err: (val1 as CompletedEvent["chartData"]).err, - // }; - // val2 = { - // //@ts-expect-error temp - // // eslint-disable-next-line - // wpm: (val2 as CompletedEvent["chartData"]).wpm.map((v) => - // // eslint-disable-next-line - // Math.round(v), - // ), - // //@ts-expect-error temp - // // eslint-disable-next-line - // burst: (val2 as CompletedEvent["chartData"]).burst, - // //@ts-expect-error temp - // // eslint-disable-next-line - // err: (val2 as CompletedEvent["chartData"]).err, - // }; - // } - - if (key === "chartData") { - const v1 = val1 as CompletedEvent["chartData"]; - const v2 = val2 as CompletedEvent["chartData"]; - - if (v1 === "toolong" || v2 === "toolong") { - if (v1 === v2) { - console.debug( - `Completed event match on key chartData: both are "toolong"`, - ); - } else { - notMatching.push("chartData (one is 'toolong' and the other is not)"); - mismatchedKeys.push("chartData"); - console.error( - `Completed event mismatch on key chartData: one is "toolong" and the other is not`, - v1, - v2, - ); - } - continue; - } - - for (const field of ["wpm", "err"] as const) { - const a = v1[field]; - const b = v2[field]; - const withinTolerance = - a.length === b.length && - a.every((val, i) => { - if (val === 0 && b[i] === 0) return true; - const ref = Math.max(Math.abs(val), Math.abs(b[i] ?? 0)); - return Math.abs(val - (b[i] ?? 0)) / ref <= 0.05; - }); - if (withinTolerance) { - console.debug(`Completed event match on key chartData.${field}:`, a); - } else { - notMatching.push(`chartData.${field} (values differ)`); - mismatchedKeys.push(`chartData.${field}`); - console.error( - `Completed event mismatch on key chartData.${field}:`, - a, - b, - ); - } - } - } else if (key === "wpmConsistency" || key === "keyConsistency") { - const a = val1 as number; - const b = val2 as number; - const ref = Math.max( - Numbers.roundTo2(Math.abs(a)), - Numbers.roundTo2(Math.abs(b)), - ); - const within = (a === 0 && b === 0) || Math.abs(a - b) / ref <= 0.05; - if (within) { - console.debug(`Completed event match on key ${key}:`, a); - } else { - const diff = Numbers.roundTo2(Math.abs(a - b)); - const dir = a > b ? "ce1 larger" : "ce2 larger"; - notMatching.push(`${key} (off by ${diff}, ${dir})`); - mismatchedKeys.push(key); - console.error(`Completed event mismatch on key ${key}:`, a, b); - } - } else if (typeof val1 === "number" && typeof val2 === "number") { - const a = Numbers.roundTo2(val1); - const b = Numbers.roundTo2(val2); - if (a !== b) { - const diff = Numbers.roundTo2(Math.abs(a - b)); - const dir = a > b ? "ce1 larger" : "ce2 larger"; - notMatching.push(`${key} (off by ${diff}, ${dir})`); - mismatchedKeys.push(key); - console.error(`Completed event mismatch on key ${key}:`, a, b); - } else { - console.debug(`Completed event match on key ${key}:`, a); - } - } else if (JSON.stringify(val1) !== JSON.stringify(val2)) { - notMatching.push(`${key} (values differ)`); - mismatchedKeys.push(key); - console.error(`Completed event mismatch on key ${key}:`, val1, val2); - } else { - console.debug(`Completed event match on key ${key}:`, val1); - } - } - - if (notMatching.length === 0) { - if (ALWAYSREPORT) { - showSuccessNotification("Completed events match", { important: true }); - } - } else { - if (ALWAYSREPORT) { - showErrorNotification( - `Completed event mismatch: ${notMatching.join(", ")}`, - { important: true }, - ); - } - mismatchedKeys.sort(); - const groupKey = mismatchedKeys.join(","); - Ape.results - .reportCompletedEventMismatch({ - body: { - notMatching, - mismatchedKeys, - groupKey, - language: ce.language, - mode: ce.mode, - mode2: ce.mode2, - difficulty: ce.difficulty, - duration: ce.testDuration, - // ce: ce as Record, - // ce2: ce2 as Record, - }, - }) - .catch(() => { - // - }); - } - - console.debug("Completed event object2", ce2); -} - -function buildCompletedEvent2(): Omit { +function buildCompletedEvent(): Omit { const chars = getChars(); //tags @@ -1274,7 +937,7 @@ export async function finish(difficultyFailed = false): Promise { PaceCaret.setLastTestWpm(stats.wpm); - const ce = buildCompletedEvent(stats, getRawPerSecond()); + const ce = buildCompletedEvent(); console.debug("Completed event object", ce); @@ -1402,20 +1065,6 @@ export async function finish(difficultyFailed = false): Promise { // test is valid - if (ALWAYSREPORT) { - logEventsDataToTheConsoleTable(); - } - - if ( - (getAuthenticatedUser() !== null && - !dontSave && - !difficultyFailed && - Config.resultSaving) || - ALWAYSREPORT - ) { - compareCompletedEvents(ce); - } - if (TestState.isRepeated || difficultyFailed) { if (Config.resultSaving) { const testSeconds = completedEvent.testDuration; From 2a453413f6e9a04f99b5ce3e7985ecf660fa0e6e Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 30 May 2026 20:36:20 +0200 Subject: [PATCH 11/41] fix --- frontend/src/ts/test/result.ts | 25 +++++++------------------ 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/frontend/src/ts/test/result.ts b/frontend/src/ts/test/result.ts index 6e0e9e056123..8f9941b85663 100644 --- a/frontend/src/ts/test/result.ts +++ b/frontend/src/ts/test/result.ts @@ -27,7 +27,6 @@ import * as Arrays from "../utils/arrays"; import { get as getTypingSpeedUnit } from "../utils/typing-speed-units"; import * as PbCrown from "./pb-crown"; import * as TestInput from "./test-input"; -import * as TestStats from "./test-stats"; import * as TestUI from "./test-ui"; import * as TodayTracker from "./today-tracker"; import { configEvent } from "../events/config"; @@ -62,7 +61,7 @@ import { currentQuote } from "./test-words"; import { qs, qsa } from "../utils/dom"; import { getTheme } from "../states/theme"; import { isTestInvalid } from "../states/test"; -import { getRawHistory, getWpmHistory } from "./events/stats"; +import { getRawHistory } from "./events/stats"; let result: CompletedEvent; let minChartVal: number; @@ -111,12 +110,12 @@ async function updateChartData(): Promise { let labels = []; - for (let i = 1; i <= getWpmHistory().length; i++) { - if (TestStats.lastSecondNotRound && i === 0) { - labels.push(Numbers.roundTo2(result.testDuration).toString()); - } else { - labels.push(i.toString()); - } + for (let i = 1; i <= Math.floor(result.testDuration); i++) { + labels.push(i.toString()); + } + + if (result.testDuration % 1 >= 0.5) { + labels.push(Numbers.roundTo2(result.testDuration).toString()); } const chartData1 = [ @@ -138,16 +137,6 @@ async function updateChartData(): Promise { ...smoothedBurst.map((a) => Numbers.roundTo2(typingSpeedUnit.fromWpm(a))), ]; - if ( - Config.mode !== "time" && - TestStats.lastSecondNotRound && - result.testDuration % 1 < 0.5 - ) { - labels.pop(); - chartData1.pop(); - chartData2.pop(); - } - const subcolor = getTheme().sub; if (Config.funbox.length > 0) { From fd3985418202a32f0df130196c895ca6b0e23fbd Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 30 May 2026 20:37:19 +0200 Subject: [PATCH 12/41] yeet last second not round --- frontend/src/ts/test/test-logic.ts | 11 ----------- frontend/src/ts/test/test-stats.ts | 7 ------- 2 files changed, 18 deletions(-) diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index b6a3fadb7512..7fc3c3944b26 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -923,18 +923,7 @@ export async function finish(difficultyFailed = false): Promise { // logEventsDataToTheConsoleTable(); - // stats const stats = TestStats.calculateFinalStats(); - if ( - stats.time % 1 !== 0 && - !( - Config.mode === "time" || - (Config.mode === "custom" && CustomText.getLimitMode() === "time") - ) - ) { - TestStats.setLastSecondNotRound(); - } - PaceCaret.setLastTestWpm(stats.wpm); const ce = buildCompletedEvent(); diff --git a/frontend/src/ts/test/test-stats.ts b/frontend/src/ts/test/test-stats.ts index 19f28e10983c..82f375c48389 100644 --- a/frontend/src/ts/test/test-stats.ts +++ b/frontend/src/ts/test/test-stats.ts @@ -36,7 +36,6 @@ export type Stats = { export let start: number, end: number; export let start2: number, end2: number; export let start3: number, end3: number; -export let lastSecondNotRound = false; export function getStats(): unknown { const ret = { @@ -45,7 +44,6 @@ export function getStats(): unknown { end, start3, end3, - lastSecondNotRound, missedWords: TestInput.missedWords, accuracy: TestInput.accuracy, keyOverlap: TestInput.keyOverlap, @@ -66,7 +64,6 @@ export function restart(): void { end2 = 0; start3 = 0; end3 = 0; - lastSecondNotRound = false; } export function calculateTestSeconds(now?: number): number { @@ -121,10 +118,6 @@ export function setStart(s: number): void { start3 = new Date().getTime(); } -export function setLastSecondNotRound(): void { - lastSecondNotRound = true; -} - export function calculateAccuracy(): number { const acc = (TestInput.accuracy.correct / From e61c110e7b0a9b4f588c8d3a49226dff23dede83 Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 30 May 2026 20:38:51 +0200 Subject: [PATCH 13/41] yeet stats --- frontend/src/ts/test/test-logic.ts | 4 +- frontend/src/ts/test/test-stats.ts | 237 ----------------------------- 2 files changed, 1 insertion(+), 240 deletions(-) diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 7fc3c3944b26..099ae52998ec 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -923,10 +923,8 @@ export async function finish(difficultyFailed = false): Promise { // logEventsDataToTheConsoleTable(); - const stats = TestStats.calculateFinalStats(); - PaceCaret.setLastTestWpm(stats.wpm); - const ce = buildCompletedEvent(); + PaceCaret.setLastTestWpm(ce.wpm); console.debug("Completed event object", ce); diff --git a/frontend/src/ts/test/test-stats.ts b/frontend/src/ts/test/test-stats.ts index 82f375c48389..77f88a2385d9 100644 --- a/frontend/src/ts/test/test-stats.ts +++ b/frontend/src/ts/test/test-stats.ts @@ -1,24 +1,7 @@ -import Hangul from "hangul-js"; -import { Config } from "../config/store"; -import * as Strings from "../utils/strings"; import * as TestInput from "./test-input"; import * as TestWords from "./test-words"; -import * as TestState from "./test-state"; -import * as Numbers from "@monkeytype/util/numbers"; -import { isFunboxActiveWithProperty } from "./funbox/list"; -import * as CustomText from "./custom-text"; import { getLastResult } from "../states/test"; -type CharCount = { - spaces: number; - correctWordChars: number; - allCorrectChars: number; - incorrectChars: number; - extraChars: number; - missedChars: number; - correctSpaces: number; -}; - export type Stats = { wpm: number; wpmRaw: number; @@ -76,36 +59,6 @@ export function calculateTestSeconds(now?: number): number { return duration; } -export function calculateWpmAndRaw( - withDecimalPoints?: true, - final = false, - testSecondsOverride?: number, -): { - wpm: number; - raw: number; -} { - const testSeconds = - testSecondsOverride ?? - calculateTestSeconds(TestState.isActive ? performance.now() : end); - - const chars = countChars(final); - const wpm = Numbers.roundTo2( - ((chars.correctWordChars + chars.correctSpaces) * (60 / testSeconds)) / 5, - ); - const raw = Numbers.roundTo2( - ((chars.allCorrectChars + - chars.spaces + - chars.incorrectChars + - chars.extraChars) * - (60 / testSeconds)) / - 5, - ); - return { - wpm: withDecimalPoints ? wpm : Math.round(wpm), - raw: withDecimalPoints ? raw : Math.round(raw), - }; -} - export function setEnd(e: number): void { end = e; end2 = Date.now(); @@ -125,193 +78,3 @@ export function calculateAccuracy(): number { 100; return isNaN(acc) ? 100 : acc; } - -function getInputWords(): string[] { - const containsKorean = TestInput.input.getKoreanStatus(); - - let inputWords = [...TestInput.input.getHistory()]; - - if (TestState.isActive) { - inputWords.push(TestInput.input.current); - } - - if (containsKorean) { - inputWords = inputWords.map((w) => Hangul.disassemble(w).join("")); - } - - return inputWords; -} - -function getTargetWords(): string[] { - const containsKorean = TestInput.input.getKoreanStatus(); - - let targetWords = [ - ...(Config.mode === "zen" - ? TestInput.input.getHistory() - : TestWords.words.list), - ]; - - if (TestState.isActive) { - targetWords.push( - Config.mode === "zen" - ? TestInput.input.current - : TestWords.words.getCurrentText(), - ); - } - - if (containsKorean) { - targetWords = targetWords.map((w) => Hangul.disassemble(w).join("")); - } - - return targetWords; -} - -function countChars(final = false): CharCount { - let correctWordChars = 0; - let correctChars = 0; - let incorrectChars = 0; - let extraChars = 0; - let missedChars = 0; - let spaces = 0; - let correctspaces = 0; - - const inputWords = getInputWords(); - const targetWords = getTargetWords(); - - for (let i = 0; i < inputWords.length; i++) { - const inputWord = inputWords[i] as string; - const targetWord = targetWords[i] as string; - - if (inputWord === targetWord) { - //the word is correct - correctWordChars += targetWord.length; - correctChars += targetWord.length; - if ( - i < inputWords.length - 1 && - Strings.getLastChar(inputWord) !== "\n" - ) { - correctspaces++; - } - } else if (inputWord.length >= targetWord.length) { - //too many chars - for (let c = 0; c < inputWord.length; c++) { - if (c < targetWord.length) { - //on char that still has a word list pair - if (inputWord[c] === targetWord[c]) { - correctChars++; - } else { - incorrectChars++; - } - } else { - //on char that is extra - extraChars++; - } - } - } else { - //not enough chars - const toAdd = { - correct: 0, - incorrect: 0, - missed: 0, - }; - for (let c = 0; c < targetWord.length; c++) { - if (c < inputWord.length) { - //on char that still has a word list pair - if (inputWord[c] === targetWord[c]) { - toAdd.correct++; - } else { - toAdd.incorrect++; - } - } else { - //on char that is extra - toAdd.missed++; - } - } - correctChars += toAdd.correct; - incorrectChars += toAdd.incorrect; - - 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 { - missedChars += toAdd.missed; - } - } - if (i < inputWords.length - 1) { - spaces++; - } - } - if (isFunboxActiveWithProperty("nospace")) { - spaces = 0; - correctspaces = 0; - } - return { - spaces: spaces, - correctWordChars: correctWordChars, - allCorrectChars: correctChars, - incorrectChars: - Config.mode === "zen" ? TestInput.accuracy.incorrect : incorrectChars, - extraChars: extraChars, - missedChars: missedChars, - correctSpaces: correctspaces, - }; -} - -export function calculateFinalStats(): Stats { - console.debug("Calculating result stats"); - let testSeconds = calculateTestSeconds(); - console.debug( - "Test seconds", - testSeconds, - " (date based) ", - (end2 - start2) / 1000, - " (performance.now based)", - (end3 - start3) / 1000, - " (new Date based)", - ); - console.debug( - "Test seconds", - Numbers.roundTo1(testSeconds), - " (date based) ", - Numbers.roundTo1((end2 - start2) / 1000), - " (performance.now based)", - Numbers.roundTo1((end3 - start3) / 1000), - " (new Date based)", - ); - if (Config.mode !== "custom") { - testSeconds = Numbers.roundTo2(testSeconds); - console.debug( - "Mode is not custom - rounding to 2. New time: ", - testSeconds, - ); - } - - //todo: this counts chars twice - once here and once in calculateWpmAndRaw - const chars = countChars(true); - const { wpm, raw } = calculateWpmAndRaw(true, true, testSeconds); - const acc = Numbers.roundTo2(calculateAccuracy()); - const ret = { - wpm: isNaN(wpm) ? 0 : wpm, - wpmRaw: isNaN(raw) ? 0 : raw, - acc: acc, - correctChars: chars.correctWordChars, - incorrectChars: chars.incorrectChars + chars.spaces - chars.correctSpaces, - missedChars: chars.missedChars, - extraChars: chars.extraChars, - allChars: - chars.allCorrectChars + - chars.spaces + - chars.incorrectChars + - chars.extraChars, - time: Numbers.roundTo2(testSeconds), - spaces: chars.spaces, - correctSpaces: chars.correctSpaces, - }; - console.debug("Result stats", ret); - return ret; -} From 7fd6d17bc547d22fbb0c18bc6160acc85ac5108f Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 30 May 2026 20:39:38 +0200 Subject: [PATCH 14/41] yeet acc --- frontend/src/ts/test/test-logic.ts | 3 ++- frontend/src/ts/test/test-stats.ts | 8 -------- frontend/src/ts/test/test-timer.ts | 12 ++---------- 3 files changed, 4 insertions(+), 19 deletions(-) diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 099ae52998ec..03d25eedb9e0 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -100,6 +100,7 @@ import { getWpmHistory, getAfkDuration, forceReleaseAllKeys, + getCurrentAccuracy, } from "./events/stats"; import { calculateWpm } from "../utils/numbers"; @@ -273,7 +274,7 @@ export function restart(options = {} as RestartOptions): void { const afkseconds = getAfkDuration(); let tt = Numbers.roundTo2(testSeconds - afkseconds); if (tt < 0) tt = 0; - const acc = Numbers.roundTo2(TestStats.calculateAccuracy()); + const acc = Numbers.roundTo2(getCurrentAccuracy()); pushIncompleteTest({ acc, seconds: tt }); } } diff --git a/frontend/src/ts/test/test-stats.ts b/frontend/src/ts/test/test-stats.ts index 77f88a2385d9..c8722216cc11 100644 --- a/frontend/src/ts/test/test-stats.ts +++ b/frontend/src/ts/test/test-stats.ts @@ -70,11 +70,3 @@ export function setStart(s: number): void { start2 = Date.now(); start3 = new Date().getTime(); } - -export function calculateAccuracy(): number { - const acc = - (TestInput.accuracy.correct / - (TestInput.accuracy.correct + TestInput.accuracy.incorrect)) * - 100; - return isNaN(acc) ? 100 : acc; -} diff --git a/frontend/src/ts/test/test-timer.ts b/frontend/src/ts/test/test-timer.ts index 6d0c2b31fcdb..39f0bcc7cd96 100644 --- a/frontend/src/ts/test/test-timer.ts +++ b/frontend/src/ts/test/test-timer.ts @@ -10,7 +10,6 @@ import * as TestStats from "./test-stats"; import * as TestInput from "./test-input"; import * as TestWords from "./test-words"; import * as Monkey from "./monkey"; -import * as Numbers from "@monkeytype/util/numbers"; import { showNoticeNotification, showErrorNotification, @@ -29,7 +28,7 @@ import { clearLowFpsMode, setLowFpsMode } from "../anim"; import { createTimer } from "animejs"; import { requestDebouncedAnimationFrame } from "../utils/debounced-animation-frame"; import { logTestEvent } from "./events/data"; -import { getCurrentWpmAndRaw } from "./events/stats"; +import { getCurrentAccuracy, getCurrentWpmAndRaw } from "./events/stats"; let lastLoop = 0; const newTimer = createTimer({ @@ -108,13 +107,6 @@ function monkey(wpmAndRaw: { wpm: number; raw: number }): void { if (timerDebug) console.timeEnd("update monkey"); } -function calculateAcc(): number { - if (timerDebug) console.time("calculate acc"); - const acc = Numbers.roundTo2(TestStats.calculateAccuracy()); - if (timerDebug) console.timeEnd("calculate acc"); - return acc; -} - function layoutfluid(): void { if (timerDebug) console.time("layoutfluid"); if (Config.funbox.includes("layoutfluid") && Config.mode === "time") { @@ -246,7 +238,7 @@ function timerStep(): void { //calc Time.increment(); const wpmAndRaw = getCurrentWpmAndRaw(); - const acc = calculateAcc(); + const acc = getCurrentAccuracy(); //ui updates requestDebouncedAnimationFrame("test-timer.timerStep", () => { From 48a17b4addd99aab2e0f003afeea6ed4a56deddb Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 30 May 2026 20:40:22 +0200 Subject: [PATCH 15/41] yeet test seconds --- frontend/src/ts/test/test-logic.ts | 3 ++- frontend/src/ts/test/test-stats.ts | 10 ---------- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 03d25eedb9e0..12342401ad28 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -101,6 +101,7 @@ import { getAfkDuration, forceReleaseAllKeys, getCurrentAccuracy, + getCurrentTestDurationMs, } from "./events/stats"; import { calculateWpm } from "../utils/numbers"; @@ -270,7 +271,7 @@ export function restart(options = {} as RestartOptions): void { } if (Config.resultSaving) { - const testSeconds = TestStats.calculateTestSeconds(performance.now()); + const testSeconds = getCurrentTestDurationMs(performance.now()) / 1000; const afkseconds = getAfkDuration(); let tt = Numbers.roundTo2(testSeconds - afkseconds); if (tt < 0) tt = 0; diff --git a/frontend/src/ts/test/test-stats.ts b/frontend/src/ts/test/test-stats.ts index c8722216cc11..113e67318262 100644 --- a/frontend/src/ts/test/test-stats.ts +++ b/frontend/src/ts/test/test-stats.ts @@ -49,16 +49,6 @@ export function restart(): void { end3 = 0; } -export function calculateTestSeconds(now?: number): number { - let duration = (end - start) / 1000; - - if (now !== undefined) { - duration = (now - start) / 1000; - } - - return duration; -} - export function setEnd(e: number): void { end = e; end2 = Date.now(); From 66bdfe362857910a95e2ca27fe4a20e148d6941d Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 30 May 2026 20:46:03 +0200 Subject: [PATCH 16/41] fully yeet stats file --- frontend/__tests__/test/events/stats.spec.ts | 2 +- frontend/src/ts/commandline/lists.ts | 35 ++++++----- frontend/src/ts/index.ts | 2 - frontend/src/ts/test/events/stats.ts | 30 ++++++++++ frontend/src/ts/test/events/types.ts | 1 + frontend/src/ts/test/test-logic.ts | 9 +-- frontend/src/ts/test/test-stats.ts | 62 -------------------- frontend/src/ts/test/test-timer.ts | 5 +- 8 files changed, 55 insertions(+), 91 deletions(-) delete mode 100644 frontend/src/ts/test/test-stats.ts diff --git a/frontend/__tests__/test/events/stats.spec.ts b/frontend/__tests__/test/events/stats.spec.ts index 3c7d03a55a32..ea23e582d634 100644 --- a/frontend/__tests__/test/events/stats.spec.ts +++ b/frontend/__tests__/test/events/stats.spec.ts @@ -112,7 +112,7 @@ function timer( if (event === "step") { return { event, timer: timerVal, drift: 0 }; } - return { event, timer: timerVal }; + return { event, timer: timerVal, date: 0 }; } // Helper: sets up a basic test with timer start, steps at 1s intervals, diff --git a/frontend/src/ts/commandline/lists.ts b/frontend/src/ts/commandline/lists.ts index e7e62a75f307..fd4c83bffa46 100644 --- a/frontend/src/ts/commandline/lists.ts +++ b/frontend/src/ts/commandline/lists.ts @@ -24,11 +24,9 @@ import { randomizeTheme } from "../controllers/theme-controller"; import { showModal } from "../states/modals"; import { showErrorNotification, - showSuccessNotification, clearAllNotifications, } from "../states/notifications"; import * as VideoAdPopup from "../popups/video-ad-popup"; -import * as TestStats from "../test/test-stats"; import { Command, CommandsSubgroup } from "./types"; import { buildCommandForConfigKey } from "./util"; import { CommandlineConfigMetadataObject } from "./commandline-metadata"; @@ -288,22 +286,23 @@ export const commands: CommandsSubgroup = { alert(await caches.keys()); }, }, - { - id: "copyResultStats", - display: "Copy result stats", - icon: "fa-cog", - visible: false, - exec: async (): Promise => { - navigator.clipboard - .writeText(JSON.stringify(TestStats.getStats())) - .then(() => { - showSuccessNotification("Copied to clipboard"); - }) - .catch((e: unknown) => { - showErrorNotification("Failed to copy to clipboard", { error: e }); - }); - }, - }, + // todo: bring back? + // { + // id: "copyResultStats", + // display: "Copy result stats", + // icon: "fa-cog", + // visible: false, + // exec: async (): Promise => { + // navigator.clipboard + // .writeText(JSON.stringify(TestStats.getStats())) + // .then(() => { + // showSuccessNotification("Copied to clipboard"); + // }) + // .catch((e: unknown) => { + // showErrorNotification("Failed to copy to clipboard", { error: e }); + // }); + // }, + // }, { id: "fpsCounter", display: "FPS counter...", diff --git a/frontend/src/ts/index.ts b/frontend/src/ts/index.ts index 10a5f7efaf93..2e1ccac678b0 100644 --- a/frontend/src/ts/index.ts +++ b/frontend/src/ts/index.ts @@ -15,7 +15,6 @@ import * as DB from "./db"; import "./ui"; import "./controllers/ad-controller"; import { Config } from "./config/store"; -import * as TestStats from "./test/test-stats"; import * as Replay from "./test/replay"; import * as TestTimer from "./test/test-timer"; import * as Result from "./test/result"; @@ -88,7 +87,6 @@ addToGlobal({ snapshot: DB.getSnapshot, config: Config, glarsesMode: enable, - stats: TestStats.getStats, replay: Replay.getReplayExport, enableTimerDebug: TestTimer.enableTimerDebug, getTimerStats: TestTimer.getTimerStats, diff --git a/frontend/src/ts/test/events/stats.ts b/frontend/src/ts/test/events/stats.ts index 8fce0d898e12..47f814e56466 100644 --- a/frontend/src/ts/test/events/stats.ts +++ b/frontend/src/ts/test/events/stats.ts @@ -246,6 +246,36 @@ export function getTestDurationMs(): number { return end; } +export function getDateBasedTestDurationMs(): number { + const events = getAllTestEvents(); + + let start: number | undefined; + let end: number | undefined; + + for (const event of events) { + if ( + start === undefined && + event.type === "timer" && + event.data.event === "start" + ) { + start = event.data.date; + } + if ( + end === undefined && + event.type === "timer" && + event.data.event === "end" + ) { + end = event.data.date; + } + } + + if (start === undefined || end === undefined) { + return 0; + } + + return end - start; +} + function getTargetWord( wordIndex: number, simulatedInput: string, diff --git a/frontend/src/ts/test/events/types.ts b/frontend/src/ts/test/events/types.ts index 94712b8dac63..b2db3ac86c92 100644 --- a/frontend/src/ts/test/events/types.ts +++ b/frontend/src/ts/test/events/types.ts @@ -65,6 +65,7 @@ export type TimerEventData = | { event: "start" | "end"; timer: number; + date: number; }; export type InputEvent = EventProps<"input", InputEventData>; diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 12342401ad28..e749b2d9ba2e 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -12,7 +12,6 @@ import { } from "../states/notifications"; import * as CustomText from "./custom-text"; import * as CustomTextState from "../legacy-states/custom-text-name"; -import * as TestStats from "./test-stats"; import * as PractiseWords from "./practise-words"; import * as ShiftTracker from "./shift-tracker"; import * as AltTracker from "./alt-tracker"; @@ -102,6 +101,7 @@ import { forceReleaseAllKeys, getCurrentAccuracy, getCurrentTestDurationMs, + getDateBasedTestDurationMs, } from "./events/stats"; import { calculateWpm } from "../utils/numbers"; @@ -160,7 +160,7 @@ export function setNotSignedInUidAndHash(uid: string): void { notSignedInLastResult.hash = objectHash(notSignedInLastResult); } -export function startTest(now: number): boolean { +export function startTest(_now: number): boolean { if (PageTransition.get()) { return false; } @@ -190,7 +190,6 @@ export function startTest(now: number): boolean { } } catch (e) {} //use a recursive self-adjusting timer to avoid time drift - TestStats.setStart(now); void TestTimer.start(); TestUI.onTestStart(); return true; @@ -319,7 +318,6 @@ export function restart(options = {} as RestartOptions): void { resetTestEvents(); TestTimer.clear(); setIsTestInvalid(false); - TestStats.restart(); TestInput.restart(); TestInput.corrected.reset(); ShiftTracker.reset(); @@ -881,7 +879,6 @@ export async function finish(difficultyFailed = false): Promise { TestState.setResultCalculating(true); const now = performance.now(); TestTimer.clear(true, now); - TestStats.setEnd(now); // fade out the test and show loading // because the css animation has a delay, @@ -970,7 +967,7 @@ export async function finish(difficultyFailed = false): Promise { let tooShort = false; //fail checks - const dateDur = (TestStats.end3 - TestStats.start3) / 1000; + const dateDur = getDateBasedTestDurationMs() / 1000; if ( Config.mode === "time" && !TestState.bailedOut && diff --git a/frontend/src/ts/test/test-stats.ts b/frontend/src/ts/test/test-stats.ts deleted file mode 100644 index 113e67318262..000000000000 --- a/frontend/src/ts/test/test-stats.ts +++ /dev/null @@ -1,62 +0,0 @@ -import * as TestInput from "./test-input"; -import * as TestWords from "./test-words"; -import { getLastResult } from "../states/test"; - -export type Stats = { - wpm: number; - wpmRaw: number; - acc: number; - correctChars: number; - incorrectChars: number; - missedChars: number; - extraChars: number; - allChars: number; - time: number; - spaces: number; - correctSpaces: number; -}; - -export let start: number, end: number; -export let start2: number, end2: number; -export let start3: number, end3: number; - -export function getStats(): unknown { - const ret = { - lastResult: getLastResult(), - start, - end, - start3, - end3, - missedWords: TestInput.missedWords, - accuracy: TestInput.accuracy, - keyOverlap: TestInput.keyOverlap, - wordsHistory: TestWords.words.list.slice( - 0, - TestInput.input.getHistory().length, - ), - inputHistory: TestInput.input.getHistory(), - }; - - return ret; -} - -export function restart(): void { - start = 0; - end = 0; - start2 = 0; - end2 = 0; - start3 = 0; - end3 = 0; -} - -export function setEnd(e: number): void { - end = e; - end2 = Date.now(); - end3 = new Date().getTime(); -} - -export function setStart(s: number): void { - start = s; - start2 = Date.now(); - start3 = new Date().getTime(); -} diff --git a/frontend/src/ts/test/test-timer.ts b/frontend/src/ts/test/test-timer.ts index 39f0bcc7cd96..23161a62d9e4 100644 --- a/frontend/src/ts/test/test-timer.ts +++ b/frontend/src/ts/test/test-timer.ts @@ -6,7 +6,6 @@ import { setConfig } from "../config/setters"; import * as CustomText from "./custom-text"; import * as TimerProgress from "./timer-progress"; import * as LiveSpeed from "./live-speed"; -import * as TestStats from "./test-stats"; import * as TestInput from "./test-input"; import * as TestWords from "./test-words"; import * as Monkey from "./monkey"; @@ -86,6 +85,7 @@ export function clear(logEnd = false, now = performance.now()): void { logTestEvent("timer", now, { event: "end", timer: Time.get(), + date: new Date().getTime(), }); } } @@ -308,12 +308,13 @@ async function _startNew(): Promise { logTestEvent("timer", performance.now(), { event: "start", timer: Time.get(), + date: new Date().getTime(), }); } async function _startOld(): Promise { timerStats = []; - expected = TestStats.start + interval; + // expected = TestStats.start + interval; logTestEvent("timer", performance.now(), { event: "start", timer: Time.get(), From fa4e20b595166fa3eb546a7e9f7c0e39ae497a29 Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 30 May 2026 20:50:31 +0200 Subject: [PATCH 17/41] yeet acc --- frontend/src/ts/test/result.ts | 10 ++++------ frontend/src/ts/test/test-input.ts | 16 ---------------- 2 files changed, 4 insertions(+), 22 deletions(-) diff --git a/frontend/src/ts/test/result.ts b/frontend/src/ts/test/result.ts index 8f9941b85663..743d79da11b7 100644 --- a/frontend/src/ts/test/result.ts +++ b/frontend/src/ts/test/result.ts @@ -26,7 +26,6 @@ import * as Numbers from "@monkeytype/util/numbers"; import * as Arrays from "../utils/arrays"; import { get as getTypingSpeedUnit } from "../utils/typing-speed-units"; import * as PbCrown from "./pb-crown"; -import * as TestInput from "./test-input"; import * as TestUI from "./test-ui"; import * as TodayTracker from "./today-tracker"; import { configEvent } from "../events/config"; @@ -61,7 +60,7 @@ import { currentQuote } from "./test-words"; import { qs, qsa } from "../utils/dom"; import { getTheme } from "../states/theme"; import { isTestInvalid } from "../states/test"; -import { getRawHistory } from "./events/stats"; +import { getAccuracy, getRawHistory } from "./events/stats"; let result: CompletedEvent; let minChartVal: number; @@ -344,6 +343,7 @@ function updateWpmAndAcc(): void { result.acc === 100 ? "100%" : Format.accuracy(result.acc), ); + const acc = getAccuracy(); if (Config.alwaysShowDecimalPlaces) { if (Config.typingSpeedUnit !== "wpm") { qs("#result .stats .wpm .bottom")?.setAttribute( @@ -368,7 +368,7 @@ function updateWpmAndAcc(): void { qs("#result .stats .acc .bottom")?.setAttribute( "aria-label", - `${TestInput.accuracy.correct} correct\n${TestInput.accuracy.incorrect} incorrect`, + `${acc.correct} correct\n${acc.incorrect} incorrect`, ); } else { //not showing decimal places @@ -394,9 +394,7 @@ function updateWpmAndAcc(): void { result.acc === 100 ? "100%" : Format.percentage(result.acc, { showDecimalPlaces: true }) - }\n${TestInput.accuracy.correct} correct\n${ - TestInput.accuracy.incorrect - } incorrect`, + }\n${acc.correct} correct\n${acc.incorrect} incorrect`, ) ?.setAttribute("data-balloon-break", ""); } diff --git a/frontend/src/ts/test/test-input.ts b/frontend/src/ts/test/test-input.ts index 3481864241ea..c2cd2643414c 100644 --- a/frontend/src/ts/test/test-input.ts +++ b/frontend/src/ts/test/test-input.ts @@ -117,24 +117,12 @@ type MissedWordsType = Record; export let missedWords: MissedWordsType = Object.create( null, ) as MissedWordsType; -export let accuracy = { - correct: 0, - incorrect: 0, -}; export let keyOverlap = { total: 0, lastStartTime: -1, }; -export function incrementAccuracy(correctincorrect: boolean): void { - if (correctincorrect) { - accuracy.correct++; - } else { - accuracy.incorrect++; - } -} - export function pushMissedWord(word: string): void { if (!Object.keys(missedWords).includes(word)) { missedWords[word] = 1; @@ -145,8 +133,4 @@ export function pushMissedWord(word: string): void { export function restart(): void { missedWords = Object.create(null) as MissedWordsType; - accuracy = { - correct: 0, - incorrect: 0, - }; } From 2cf2bf8df85e9272ac88b6e5eba7bcfe6315c750 Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 30 May 2026 20:50:42 +0200 Subject: [PATCH 18/41] yeet --- frontend/src/ts/test/test-input.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/frontend/src/ts/test/test-input.ts b/frontend/src/ts/test/test-input.ts index c2cd2643414c..c133cb699fb3 100644 --- a/frontend/src/ts/test/test-input.ts +++ b/frontend/src/ts/test/test-input.ts @@ -118,11 +118,6 @@ export let missedWords: MissedWordsType = Object.create( null, ) as MissedWordsType; -export let keyOverlap = { - total: 0, - lastStartTime: -1, -}; - export function pushMissedWord(word: string): void { if (!Object.keys(missedWords).includes(word)) { missedWords[word] = 1; From 297775f27f7d3b4f3b5e258242002d32a16e0f50 Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 30 May 2026 20:51:21 +0200 Subject: [PATCH 19/41] fixes --- frontend/__tests__/test/events/data.spec.ts | 2 +- frontend/src/ts/input/handlers/insert-text.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/frontend/__tests__/test/events/data.spec.ts b/frontend/__tests__/test/events/data.spec.ts index 14cbaaaccd79..572f3aa9f1e5 100644 --- a/frontend/__tests__/test/events/data.spec.ts +++ b/frontend/__tests__/test/events/data.spec.ts @@ -63,7 +63,7 @@ function timerData( if (event === "step") { return { event, timer, drift: 0 }; } - return { event, timer }; + return { event, timer, date: 0 }; } describe("data.ts", () => { diff --git a/frontend/src/ts/input/handlers/insert-text.ts b/frontend/src/ts/input/handlers/insert-text.ts index c69c7a492076..0605cfeb6e71 100644 --- a/frontend/src/ts/input/handlers/insert-text.ts +++ b/frontend/src/ts/input/handlers/insert-text.ts @@ -163,7 +163,6 @@ export async function onInsertText(options: OnInsertTextParams): Promise { // general per keypress updates Replay.addReplayEvent(correct ? "correctLetter" : "incorrectLetter", data); - TestInput.incrementAccuracy(correct); WeakSpot.updateScore(data, correct); if (!correct) { TestInput.pushMissedWord(TestWords.words.getCurrentText()); From c9fa677281d656b49ff48f35e35d95f85094d27f Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 30 May 2026 20:55:42 +0200 Subject: [PATCH 20/41] yeet missed words --- frontend/src/ts/input/handlers/insert-text.ts | 3 --- frontend/src/ts/test/events/stats.ts | 23 +++++++++++++++++++ frontend/src/ts/test/practise-words.ts | 10 ++++---- frontend/src/ts/test/test-input.ts | 18 --------------- frontend/src/ts/test/test-logic.ts | 1 - frontend/src/ts/test/test-ui.ts | 8 +++++-- 6 files changed, 35 insertions(+), 28 deletions(-) diff --git a/frontend/src/ts/input/handlers/insert-text.ts b/frontend/src/ts/input/handlers/insert-text.ts index 0605cfeb6e71..bb89fdb7d654 100644 --- a/frontend/src/ts/input/handlers/insert-text.ts +++ b/frontend/src/ts/input/handlers/insert-text.ts @@ -164,9 +164,6 @@ export async function onInsertText(options: OnInsertTextParams): Promise { // general per keypress updates Replay.addReplayEvent(correct ? "correctLetter" : "incorrectLetter", data); WeakSpot.updateScore(data, correct); - if (!correct) { - TestInput.pushMissedWord(TestWords.words.getCurrentText()); - } if (Config.keymapMode === "react") { flash(data, correct); } diff --git a/frontend/src/ts/test/events/stats.ts b/frontend/src/ts/test/events/stats.ts index 47f814e56466..519580ce8e7d 100644 --- a/frontend/src/ts/test/events/stats.ts +++ b/frontend/src/ts/test/events/stats.ts @@ -753,6 +753,29 @@ export function forceReleaseAllKeys(): void { } } +export function getMissedWords(): Record { + const events = getAllTestEvents(); + + const missedWords: Record = {}; + + for (const event of events) { + if ( + event.type === "input" && + event.data.inputType === "insertText" && + !event.data.correct + ) { + const word = TestWords.words.getText(event.data.wordIndex); + if (missedWords[word] === undefined) { + missedWords[word] = 1; + } else { + missedWords[word]++; + } + } + } + + return missedWords; +} + export const __testing = { getTimerBoundaries, }; diff --git a/frontend/src/ts/test/practise-words.ts b/frontend/src/ts/test/practise-words.ts index 9e209662486d..097fcbce04c9 100644 --- a/frontend/src/ts/test/practise-words.ts +++ b/frontend/src/ts/test/practise-words.ts @@ -9,7 +9,7 @@ import { configEvent } from "../events/config"; import { setCustomTextName } from "../legacy-states/custom-text-name"; import { Mode } from "@monkeytype/schemas/shared"; import { CustomTextSettings } from "@monkeytype/schemas/results"; -import { getBurstHistory } from "./events/stats"; +import { getBurstHistory, getMissedWords } from "./events/stats"; type Before = { mode: Mode | null; @@ -38,11 +38,13 @@ export function init( limit = 10; } + const missedWords = getMissedWords(); + // missed word, previous word, count let sortableMissedWords: [string, number][] = []; if (missed === "words") { - Object.keys(TestInput.missedWords).forEach((missedWord) => { - const missedWordCount = TestInput.missedWords[missedWord]; + Object.keys(missedWords).forEach((missedWord) => { + const missedWordCount = missedWords[missedWord]; if (missedWordCount !== undefined) { sortableMissedWords.push([missedWord, missedWordCount]); } @@ -57,7 +59,7 @@ export function init( if (missed === "biwords") { for (let i = 0; i < TestWords.words.length; i++) { const missedWord = TestWords.words.getText(i); - const missedWordCount = TestInput.missedWords[missedWord]; + const missedWordCount = missedWords[missedWord]; if (missedWordCount !== undefined) { if (i === 0) { sortableMissedBiwords.push([missedWord, "", missedWordCount]); diff --git a/frontend/src/ts/test/test-input.ts b/frontend/src/ts/test/test-input.ts index c133cb699fb3..60ddb3e5b8b4 100644 --- a/frontend/src/ts/test/test-input.ts +++ b/frontend/src/ts/test/test-input.ts @@ -111,21 +111,3 @@ class Corrected { export const input = new Input(); export const corrected = new Corrected(); - -type MissedWordsType = Record; -// We're using Object.create(null) to make sure that __proto__ won't have any special meaning when it's used to index the missedWords object (so if a user mistypes the word __proto__ it will appear in the practise words test) -export let missedWords: MissedWordsType = Object.create( - null, -) as MissedWordsType; - -export function pushMissedWord(word: string): void { - if (!Object.keys(missedWords).includes(word)) { - missedWords[word] = 1; - } else { - (missedWords[word] as number) += 1; - } -} - -export function restart(): void { - missedWords = Object.create(null) as MissedWordsType; -} diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index e749b2d9ba2e..e27e1705d840 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -318,7 +318,6 @@ export function restart(options = {} as RestartOptions): void { resetTestEvents(); TestTimer.clear(); setIsTestInvalid(false); - TestInput.restart(); TestInput.corrected.reset(); ShiftTracker.reset(); AltTracker.reset(); diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index 05296b60b8ae..dd991f6c722a 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -68,7 +68,11 @@ import { import { getTheme } from "../states/theme"; import { skipBreakdownEvent } from "../states/header"; import { wordsHaveNewline } from "../states/test"; -import { getBurstHistory, getCurrentAccuracy } from "./events/stats"; +import { + getBurstHistory, + getCurrentAccuracy, + getMissedWords, +} from "./events/stats"; export const updateHintsPositionDebounced = Misc.debounceUntilResolved( updateHintsPosition, @@ -1962,7 +1966,7 @@ qs(".pageTest #copyMissedWordsListButton")?.on("click", async () => { if (Config.mode === "zen") { words = TestInput.input.getHistory().join(" "); } else { - words = Object.keys(TestInput.missedWords ?? {}).join(" "); + words = (Object.keys(getMissedWords()) ?? {}).join(" "); } await copyToClipboard(words); }); From 442ddb4e86c2b7d0f9e4283e047742488dd8adb0 Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 30 May 2026 21:53:04 +0200 Subject: [PATCH 21/41] yeet correcred --- frontend/__tests__/test/events/stats.spec.ts | 279 ++++++++++++++++++ frontend/src/ts/input/handlers/insert-text.ts | 3 - .../src/ts/input/helpers/word-navigation.ts | 2 - frontend/src/ts/test/events/stats.ts | 40 +++ frontend/src/ts/test/test-input.ts | 96 +++--- frontend/src/ts/test/test-logic.ts | 10 +- frontend/src/ts/test/test-timer.ts | 1 - frontend/src/ts/test/test-ui.ts | 5 +- 8 files changed, 376 insertions(+), 60 deletions(-) diff --git a/frontend/__tests__/test/events/stats.spec.ts b/frontend/__tests__/test/events/stats.spec.ts index ea23e582d634..4a647dd650b8 100644 --- a/frontend/__tests__/test/events/stats.spec.ts +++ b/frontend/__tests__/test/events/stats.spec.ts @@ -55,6 +55,7 @@ import { getChars, getWpmHistory, forceReleaseAllKeys, + getCorrectedWords, __testing as statsTesting, } from "../../../src/ts/test/events/stats"; import type { @@ -677,6 +678,284 @@ describe("stats.ts", () => { }); }); + describe("getCorrectedWords", () => { + it("returns input as-is when no corrections made", () => { + logTestEvent("timer", 1000, timer("start", 0)); + logTestEvent( + "input", + 1100, + input({ charIndex: 0, wordIndex: 0, data: "t" }), + ); + logTestEvent( + "input", + 1150, + input({ charIndex: 1, wordIndex: 0, data: "e" }), + ); + logTestEvent( + "input", + 1200, + input({ charIndex: 2, wordIndex: 0, data: "s" }), + ); + logTestEvent( + "input", + 1250, + input({ charIndex: 3, wordIndex: 0, data: "t" }), + ); + + expect(getCorrectedWords()).toEqual(["test"]); + }); + + it("returns last deleted char per position (xact -> fact)", () => { + logTestEvent("timer", 1000, timer("start", 0)); + // type "xact" + logTestEvent( + "input", + 1100, + input({ charIndex: 0, wordIndex: 0, data: "x" }), + ); + logTestEvent( + "input", + 1150, + input({ charIndex: 1, wordIndex: 0, data: "a" }), + ); + logTestEvent( + "input", + 1200, + input({ charIndex: 2, wordIndex: 0, data: "c" }), + ); + logTestEvent( + "input", + 1250, + input({ charIndex: 3, wordIndex: 0, data: "t" }), + ); + // delete all + logTestEvent("input", 1300, { + charIndex: 3, + wordIndex: 0, + inputType: "deleteContentBackward", + } as InputEventData); + logTestEvent("input", 1350, { + charIndex: 2, + wordIndex: 0, + inputType: "deleteContentBackward", + } as InputEventData); + logTestEvent("input", 1400, { + charIndex: 1, + wordIndex: 0, + inputType: "deleteContentBackward", + } as InputEventData); + logTestEvent("input", 1450, { + charIndex: 0, + wordIndex: 0, + inputType: "deleteContentBackward", + } as InputEventData); + // type "fact" + logTestEvent( + "input", + 1500, + input({ charIndex: 0, wordIndex: 0, data: "f" }), + ); + logTestEvent( + "input", + 1550, + input({ charIndex: 1, wordIndex: 0, data: "a" }), + ); + logTestEvent( + "input", + 1600, + input({ charIndex: 2, wordIndex: 0, data: "c" }), + ); + logTestEvent( + "input", + 1650, + input({ charIndex: 3, wordIndex: 0, data: "t" }), + ); + + expect(getCorrectedWords()).toEqual(["xact"]); + }); + + it("returns last deleted char per position across multiple corrections (xest -> west -> test)", () => { + logTestEvent("timer", 1000, timer("start", 0)); + // type "xest" + logTestEvent( + "input", + 1100, + input({ charIndex: 0, wordIndex: 0, data: "x" }), + ); + logTestEvent( + "input", + 1150, + input({ charIndex: 1, wordIndex: 0, data: "e" }), + ); + logTestEvent( + "input", + 1200, + input({ charIndex: 2, wordIndex: 0, data: "s" }), + ); + logTestEvent( + "input", + 1250, + input({ charIndex: 3, wordIndex: 0, data: "t" }), + ); + // delete all + logTestEvent("input", 1300, { + charIndex: 3, + wordIndex: 0, + inputType: "deleteWordBackward", + } as InputEventData); + // type "west" + logTestEvent( + "input", + 1400, + input({ charIndex: 0, wordIndex: 0, data: "w" }), + ); + logTestEvent( + "input", + 1450, + input({ charIndex: 1, wordIndex: 0, data: "e" }), + ); + logTestEvent( + "input", + 1500, + input({ charIndex: 2, wordIndex: 0, data: "s" }), + ); + logTestEvent( + "input", + 1550, + input({ charIndex: 3, wordIndex: 0, data: "t" }), + ); + // delete all + logTestEvent("input", 1600, { + charIndex: 3, + wordIndex: 0, + inputType: "deleteWordBackward", + } as InputEventData); + // type "test" + logTestEvent( + "input", + 1700, + input({ charIndex: 0, wordIndex: 0, data: "t" }), + ); + logTestEvent( + "input", + 1750, + input({ charIndex: 1, wordIndex: 0, data: "e" }), + ); + logTestEvent( + "input", + 1800, + input({ charIndex: 2, wordIndex: 0, data: "s" }), + ); + logTestEvent( + "input", + 1850, + input({ charIndex: 3, wordIndex: 0, data: "t" }), + ); + + expect(getCorrectedWords()).toEqual(["west"]); + }); + + it("handles partial correction (tset -> delete last 2 -> st)", () => { + logTestEvent("timer", 1000, timer("start", 0)); + // type "tset" + logTestEvent( + "input", + 1100, + input({ charIndex: 0, wordIndex: 0, data: "t" }), + ); + logTestEvent( + "input", + 1150, + input({ charIndex: 1, wordIndex: 0, data: "s" }), + ); + logTestEvent( + "input", + 1200, + input({ charIndex: 2, wordIndex: 0, data: "e" }), + ); + logTestEvent( + "input", + 1250, + input({ charIndex: 3, wordIndex: 0, data: "t" }), + ); + // delete last 2 + logTestEvent("input", 1300, { + charIndex: 3, + wordIndex: 0, + inputType: "deleteContentBackward", + } as InputEventData); + logTestEvent("input", 1350, { + charIndex: 2, + wordIndex: 0, + inputType: "deleteContentBackward", + } as InputEventData); + // type "st" + logTestEvent( + "input", + 1400, + input({ charIndex: 2, wordIndex: 0, data: "s" }), + ); + logTestEvent( + "input", + 1450, + input({ charIndex: 3, wordIndex: 0, data: "t" }), + ); + + // pos 0: "t" never deleted, pos 1: "s" never deleted, pos 2: "e" deleted, pos 3: "t" deleted + expect(getCorrectedWords()).toEqual(["tset"]); + }); + + it("handles multiple words", () => { + logTestEvent("timer", 1000, timer("start", 0)); + // word 0: type "ab" correctly + logTestEvent( + "input", + 1100, + input({ charIndex: 0, wordIndex: 0, data: "a" }), + ); + logTestEvent( + "input", + 1150, + input({ charIndex: 1, wordIndex: 0, data: "b" }), + ); + // word 1: type "xy", delete both, type "zw" + logTestEvent( + "input", + 1200, + input({ charIndex: 0, wordIndex: 1, data: "x" }), + ); + logTestEvent( + "input", + 1250, + input({ charIndex: 1, wordIndex: 1, data: "y" }), + ); + logTestEvent("input", 1300, { + charIndex: 1, + wordIndex: 1, + inputType: "deleteContentBackward", + } as InputEventData); + logTestEvent("input", 1350, { + charIndex: 1, + wordIndex: 1, + inputType: "deleteContentBackward", + } as InputEventData); + logTestEvent( + "input", + 1400, + input({ charIndex: 0, wordIndex: 1, data: "z" }), + ); + logTestEvent( + "input", + 1450, + input({ charIndex: 1, wordIndex: 1, data: "w" }), + ); + + const result = getCorrectedWords(); + expect(result[0]).toEqual("ab"); + expect(result[1]).toEqual("xy"); + }); + }); + describe("forceReleaseAllKeys", () => { it("creates synthetic keyup events for pressed keys", () => { logTestEvent("timer", 1000, timer("start", 0)); diff --git a/frontend/src/ts/input/handlers/insert-text.ts b/frontend/src/ts/input/handlers/insert-text.ts index bb89fdb7d654..f474e4d06e30 100644 --- a/frontend/src/ts/input/handlers/insert-text.ts +++ b/frontend/src/ts/input/handlers/insert-text.ts @@ -167,9 +167,6 @@ export async function onInsertText(options: OnInsertTextParams): Promise { if (Config.keymapMode === "react") { flash(data, correct); } - if (!shouldGoToNextWord) { - TestInput.corrected.update(data, correct); - } // handing cases where last char needs to be removed // this is here and not in beforeInsertText because we want to penalize for incorrect spaces diff --git a/frontend/src/ts/input/helpers/word-navigation.ts b/frontend/src/ts/input/helpers/word-navigation.ts index c1f5e99077a0..de09a57d32f0 100644 --- a/frontend/src/ts/input/helpers/word-navigation.ts +++ b/frontend/src/ts/input/helpers/word-navigation.ts @@ -66,7 +66,6 @@ export async function goToNextWord({ Funbox.toggleScript(TestWords.words.getText(TestState.activeWordIndex + 1)); TestInput.input.pushHistory(); - TestInput.corrected.pushHistory(); const lastWord = TestState.activeWordIndex >= TestWords.words.length - 1; if (lastWord) { @@ -110,7 +109,6 @@ export function goToPreviousWord( const word = TestInput.input.popHistory(); TestState.decreaseActiveWordIndex(); - TestInput.corrected.popHistory(); Funbox.toggleScript(TestWords.words.getText(TestState.activeWordIndex)); diff --git a/frontend/src/ts/test/events/stats.ts b/frontend/src/ts/test/events/stats.ts index 519580ce8e7d..0bb62a745931 100644 --- a/frontend/src/ts/test/events/stats.ts +++ b/frontend/src/ts/test/events/stats.ts @@ -776,6 +776,46 @@ export function getMissedWords(): Record { return missedWords; } +export function getCorrectedWords(): string[] { + const ev = getInputEventsPerWord(); + const correctedWords: string[] = []; + + for (const [, events] of ev.entries()) { + const correctedChars: string[] = []; + const currentChars: string[] = []; + let cursorPos = 0; + + for (const event of events) { + if ( + event.data.inputType === "insertText" || + event.data.inputType === "insertCompositionText" + ) { + if (event.data.inputStopped || event.data.data === " ") continue; + currentChars[cursorPos] = event.data.data; + cursorPos++; + } else if (event.data.inputType === "deleteContentBackward") { + if (cursorPos > 0) { + cursorPos--; + correctedChars[cursorPos] = currentChars[cursorPos] ?? ""; + } + } else if (event.data.inputType === "deleteWordBackward") { + while (cursorPos > 0) { + cursorPos--; + correctedChars[cursorPos] = currentChars[cursorPos] ?? ""; + } + } + } + + const result: string[] = []; + for (let i = 0; i < currentChars.length; i++) { + result.push(correctedChars[i] ?? currentChars[i] ?? ""); + } + correctedWords.push(result.join("")); + } + + return correctedWords; +} + export const __testing = { getTimerBoundaries, }; diff --git a/frontend/src/ts/test/test-input.ts b/frontend/src/ts/test/test-input.ts index 60ddb3e5b8b4..881ba08a3383 100644 --- a/frontend/src/ts/test/test-input.ts +++ b/frontend/src/ts/test/test-input.ts @@ -61,53 +61,53 @@ class Input { } } -class Corrected { - current: string; - private history: string[]; - constructor() { - this.current = ""; - this.history = []; - } - - reset(): void { - this.history = []; - this.current = ""; - } - - update(char: string, correct: boolean): void { - if (this.current === "") { - this.current += input.current; - } else { - const currCorrectedTestInputLength = this.current.length; - - const charIndex = input.current.length - 1; - - if (charIndex >= currCorrectedTestInputLength) { - this.current += char; - } else if (!correct) { - this.current = - this.current.substring(0, charIndex) + - char + - this.current.substring(charIndex + 1); - } - } - } - - getHistory(i: number): string | undefined { - return this.history[i]; - } - - popHistory(): string { - const popped = this.history.pop() ?? ""; - this.current = popped; - return popped; - } - - pushHistory(): void { - this.history.push(this.current); - this.current = ""; - } -} +// class Corrected { +// current: string; +// private history: string[]; +// constructor() { +// this.current = ""; +// this.history = []; +// } + +// reset(): void { +// this.history = []; +// this.current = ""; +// } + +// update(char: string, correct: boolean): void { +// if (this.current === "") { +// this.current += input.current; +// } else { +// const currCorrectedTestInputLength = this.current.length; + +// const charIndex = input.current.length - 1; + +// if (charIndex >= currCorrectedTestInputLength) { +// this.current += char; +// } else if (!correct) { +// this.current = +// this.current.substring(0, charIndex) + +// char + +// this.current.substring(charIndex + 1); +// } +// } +// } + +// getHistory(i: number): string | undefined { +// return this.history[i]; +// } + +// popHistory(): string { +// const popped = this.history.pop() ?? ""; +// this.current = popped; +// return popped; +// } + +// pushHistory(): void { +// this.history.push(this.current); +// this.current = ""; +// } +// } export const input = new Input(); -export const corrected = new Corrected(); +// export const corrected = new Corrected(); diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index e27e1705d840..438dc20ca596 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -84,7 +84,11 @@ import { qs } from "../utils/dom"; import { setAccountButtonSpinner } from "../states/header"; import { Config } from "../config/store"; import { setQuoteLengthAll, toggleFunbox, setConfig } from "../config/setters"; -import { resetTestEvents, cleanupData } from "./events/data"; +import { + resetTestEvents, + cleanupData, + logEventsDataToTheConsoleTable, +} from "./events/data"; import { getKeypressDurations, getChars, @@ -318,7 +322,6 @@ export function restart(options = {} as RestartOptions): void { resetTestEvents(); TestTimer.clear(); setIsTestInvalid(false); - TestInput.corrected.reset(); ShiftTracker.reset(); AltTracker.reset(); Caret.hide(); @@ -900,7 +903,6 @@ export async function finish(difficultyFailed = false): Promise { // we need to push the current input to history if (TestInput.input.current.length !== 0) { TestInput.input.pushHistory(); - TestInput.corrected.pushHistory(); Replay.replayGetWordsList(TestInput.input.getHistory()); } @@ -919,7 +921,7 @@ export async function finish(difficultyFailed = false): Promise { cleanupData(); - // logEventsDataToTheConsoleTable(); + logEventsDataToTheConsoleTable(); const ce = buildCompletedEvent(); PaceCaret.setLastTestWpm(ce.wpm); diff --git a/frontend/src/ts/test/test-timer.ts b/frontend/src/ts/test/test-timer.ts index 23161a62d9e4..e3e6a87fd6dd 100644 --- a/frontend/src/ts/test/test-timer.ts +++ b/frontend/src/ts/test/test-timer.ts @@ -194,7 +194,6 @@ function checkIfTimeIsUp(): void { if (timer !== null) clearTimeout(timer); Caret.hide(); TestInput.input.pushHistory(); - TestInput.corrected.pushHistory(); SlowTimer.clear(); slowTimerCount = 0; timerEvent.dispatch({ key: "finish" }); diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index dd991f6c722a..b556abcf4d7c 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -70,6 +70,7 @@ import { skipBreakdownEvent } from "../states/header"; import { wordsHaveNewline } from "../states/test"; import { getBurstHistory, + getCorrectedWords, getCurrentAccuracy, getMissedWords, } from "./events/stats"; @@ -1317,11 +1318,11 @@ async function loadWordsHistory(): Promise { wordsContainer?.empty(); const burstHistory = getBurstHistory(); - + const correctedHistory = getCorrectedWords(); const inputHistoryLength = TestInput.input.getHistory().length; for (let i = 0; i < inputHistoryLength + 2; i++) { const input = TestInput.input.getHistory(i); - const corrected = TestInput.corrected.getHistory(i); + const corrected = correctedHistory[i]; const word = TestWords.words.getText(i) ?? ""; const koreanRegex = /[\uac00-\ud7af]|[\u1100-\u11ff]|[\u3130-\u318f]|[\ua960-\ua97f]|[\ud7b0-\ud7ff]/; From 36b525158d30e59d9151fda499b628f2c088df51 Mon Sep 17 00:00:00 2001 From: Miodec Date: Sun, 31 May 2026 00:15:19 +0200 Subject: [PATCH 22/41] input history --- frontend/src/ts/test/events/stats.ts | 12 ++++++++++++ frontend/src/ts/test/test-logic.ts | 2 ++ 2 files changed, 14 insertions(+) diff --git a/frontend/src/ts/test/events/stats.ts b/frontend/src/ts/test/events/stats.ts index 0bb62a745931..7e9968e97323 100644 --- a/frontend/src/ts/test/events/stats.ts +++ b/frontend/src/ts/test/events/stats.ts @@ -438,6 +438,18 @@ export function getChars(countPartialLastWord = false): CharCounts { }; } +export function getInputHistory(): string[] { + const eventsPerWordIndex = getInputEventsPerWord(); + const history: string[] = []; + + for (const events of eventsPerWordIndex.values()) { + const simulatedInput = getSimulatedInput(events); + history.push(simulatedInput.trimEnd()); + } + + return history; +} + export function getAccuracy(): { correct: number; incorrect: number; diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 438dc20ca596..79c59bbd6d82 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -106,6 +106,7 @@ import { getCurrentAccuracy, getCurrentTestDurationMs, getDateBasedTestDurationMs, + getInputHistory, } from "./events/stats"; import { calculateWpm } from "../utils/numbers"; @@ -922,6 +923,7 @@ export async function finish(difficultyFailed = false): Promise { cleanupData(); logEventsDataToTheConsoleTable(); + console.log(getInputHistory()); const ce = buildCompletedEvent(); PaceCaret.setLastTestWpm(ce.wpm); From 9883c9b729bd6ea444d821fbabf92fbb68643676 Mon Sep 17 00:00:00 2001 From: Miodec Date: Sun, 31 May 2026 00:39:13 +0200 Subject: [PATCH 23/41] yeet input history --- .../src/ts/commandline/lists/result-screen.ts | 6 +- .../src/ts/input/handlers/before-delete.ts | 4 +- .../src/ts/input/helpers/word-navigation.ts | 6 +- frontend/src/ts/test/events/data.ts | 24 +++++++ frontend/src/ts/test/events/stats.ts | 10 ++- .../src/ts/test/funbox/funbox-functions.ts | 5 +- frontend/src/ts/test/practise-words.ts | 9 ++- frontend/src/ts/test/test-input.ts | 67 +++++++++---------- frontend/src/ts/test/test-logic.ts | 16 ++--- frontend/src/ts/test/test-timer.ts | 2 +- frontend/src/ts/test/test-ui.ts | 18 ++--- frontend/src/ts/test/timer-progress.ts | 5 +- 12 files changed, 103 insertions(+), 69 deletions(-) diff --git a/frontend/src/ts/commandline/lists/result-screen.ts b/frontend/src/ts/commandline/lists/result-screen.ts index 1debee3ac804..2f5017aabd4d 100644 --- a/frontend/src/ts/commandline/lists/result-screen.ts +++ b/frontend/src/ts/commandline/lists/result-screen.ts @@ -5,13 +5,13 @@ import { showErrorNotification, showSuccessNotification, } from "../../states/notifications"; -import * as TestInput from "../../test/test-input"; import * as TestState from "../../test/test-state"; import * as TestWords from "../../test/test-words"; import { Config } from "../../config/store"; import * as PractiseWords from "../../test/practise-words"; import { Command, CommandsSubgroup } from "../types"; import * as TestScreenshot from "../../test/test-screenshot"; +import { getInputHistory } from "../../test/events/stats"; const practiceSubgroup: CommandsSubgroup = { title: "Practice words...", @@ -141,8 +141,8 @@ const commands: Command[] = [ exec: (): void => { const words = ( Config.mode === "zen" - ? TestInput.input.getHistory() - : TestWords.words.list.slice(0, TestInput.input.getHistory().length) + ? getInputHistory() + : TestWords.words.list.slice(0, getInputHistory().length) ).join(" "); navigator.clipboard.writeText(words).then( diff --git a/frontend/src/ts/input/handlers/before-delete.ts b/frontend/src/ts/input/handlers/before-delete.ts index 3dc7c77a1c73..8809a264383d 100644 --- a/frontend/src/ts/input/handlers/before-delete.ts +++ b/frontend/src/ts/input/handlers/before-delete.ts @@ -1,10 +1,10 @@ import { Config } from "../../config/store"; -import * as TestInput from "../../test/test-input"; import * as TestState from "../../test/test-state"; import * as TestWords from "../../test/test-words"; import { getInputElementValue } from "../input-element"; import * as TestUI from "../../test/test-ui"; import { isAwaitingNextWord } from "../state"; +import { getInputForWord } from "../../test/events/stats"; export function onBeforeDelete(event: InputEvent): void { if (!TestState.isActive) { @@ -45,7 +45,7 @@ export function onBeforeDelete(event: InputEvent): void { const confidence = Config.confidenceMode; const previousWordCorrect = - (TestInput.input.get(TestState.activeWordIndex - 1) ?? "") === + getInputForWord(TestState.activeWordIndex - 1) === TestWords.words.getText(TestState.activeWordIndex - 1); if (confidence === "on" && inputIsEmpty && !previousWordCorrect) { diff --git a/frontend/src/ts/input/helpers/word-navigation.ts b/frontend/src/ts/input/helpers/word-navigation.ts index de09a57d32f0..2a5ab05dc118 100644 --- a/frontend/src/ts/input/helpers/word-navigation.ts +++ b/frontend/src/ts/input/helpers/word-navigation.ts @@ -15,7 +15,7 @@ import { showLoaderBar, hideLoaderBar } from "../../states/loader-bar"; import { setInputElementValue } from "../input-element"; import { setAwaitingNextWord } from "../state"; import { DeleteInputType } from "./input-type"; -import { getWordBurst } from "../../test/events/stats"; +import { getInputForWord, getWordBurst } from "../../test/events/stats"; type GoToNextWordParams = { correctInsert: boolean; @@ -65,7 +65,7 @@ export async function goToNextWord({ Funbox.toggleScript(TestWords.words.getText(TestState.activeWordIndex + 1)); - TestInput.input.pushHistory(); + // TestInput.input.pushHistory(); const lastWord = TestState.activeWordIndex >= TestWords.words.length - 1; if (lastWord) { @@ -107,7 +107,7 @@ export function goToPreviousWord( Replay.addReplayEvent("backWord"); - const word = TestInput.input.popHistory(); + const word = getInputForWord(TestState.activeWordIndex); TestState.decreaseActiveWordIndex(); Funbox.toggleScript(TestWords.words.getText(TestState.activeWordIndex)); diff --git a/frontend/src/ts/test/events/data.ts b/frontend/src/ts/test/events/data.ts index 01cf95e30aa8..0d4e4ac2a38d 100644 --- a/frontend/src/ts/test/events/data.ts +++ b/frontend/src/ts/test/events/data.ts @@ -310,6 +310,30 @@ export function getPressedKeys(): Map< return pressedKeys; } +export function getInputEventsForWord(wordIndex: number): InputEvent[] { + const events = getAllTestEvents(); + const result: InputEvent[] = []; + for (const event of events) { + if (event.type !== "input") continue; + + let eventWordIndex = event.data.wordIndex; + + if ( + (event.data.inputType === "deleteWordBackward" || + event.data.inputType === "deleteContentBackward") && + event.data.charIndex === 0 && + eventWordIndex > 0 + ) { + eventWordIndex -= 1; + } + + if (eventWordIndex === wordIndex) { + result.push(event); + } + } + return result; +} + export function getInputEventsPerWord( startMs?: number, testMsLimit?: number, diff --git a/frontend/src/ts/test/events/stats.ts b/frontend/src/ts/test/events/stats.ts index 7e9968e97323..9ba1bdeb89fe 100644 --- a/frontend/src/ts/test/events/stats.ts +++ b/frontend/src/ts/test/events/stats.ts @@ -1,6 +1,7 @@ import { getAllTestEvents, getInputEvents, + getInputEventsForWord, getInputEventsPerWord, getPressedKeys, logTestEvent, @@ -373,7 +374,7 @@ function computeBurst(events: InputEvent[], now?: number): number { } export function getWordBurst(wordIndex: number, now?: number): number { - const events = getInputEventsPerWord().get(wordIndex) ?? []; + const events = getInputEventsForWord(wordIndex); return computeBurst(events, now); } @@ -438,7 +439,14 @@ export function getChars(countPartialLastWord = false): CharCounts { }; } +export function getInputForWord(wordIndex: number): string { + const events = getInputEventsForWord(wordIndex); + return getSimulatedInput(events).trimEnd(); +} + export function getInputHistory(): string[] { + console.log("getting input history"); + console.trace("getting input"); const eventsPerWordIndex = getInputEventsPerWord(); const history: string[] = []; diff --git a/frontend/src/ts/test/funbox/funbox-functions.ts b/frontend/src/ts/test/funbox/funbox-functions.ts index 78377be3a229..512975f1bd29 100644 --- a/frontend/src/ts/test/funbox/funbox-functions.ts +++ b/frontend/src/ts/test/funbox/funbox-functions.ts @@ -28,6 +28,7 @@ import { WordGenError } from "../../utils/word-gen-error"; import { FunboxName, KeymapLayout, Layout } from "@monkeytype/schemas/configs"; import { Language, LanguageObject } from "@monkeytype/schemas/languages"; import { qs } from "../../utils/dom"; +import { getInputForWord } from "../events/stats"; export type FunboxFunctions = { getWord?: (wordset?: Wordset, wordIndex?: number) => string; @@ -62,7 +63,7 @@ async function readAheadHandleKeydown(event: KeyboardEvent): Promise { event.key === "Backspace" && !isCorrect && (TestInput.input.current !== "" || - TestInput.input.getHistory(TestState.activeWordIndex - 1) !== + getInputForWord(TestState.activeWordIndex - 1) !== TestWords.words.getText(TestState.activeWordIndex - 1) || Config.freedomMode) ) { @@ -425,7 +426,7 @@ const list: Partial> = { const outOf: number = TestWords.words.length; const wordsPerLayout = Math.floor(outOf / layouts.length); const index = Math.floor( - (TestInput.input.getHistory().length + 1) / wordsPerLayout, + (TestState.activeWordIndex + 1) / wordsPerLayout, ); const mod = wordsPerLayout - ((TestState.activeWordIndex + 1) % wordsPerLayout); diff --git a/frontend/src/ts/test/practise-words.ts b/frontend/src/ts/test/practise-words.ts index 097fcbce04c9..35d57cf57a4b 100644 --- a/frontend/src/ts/test/practise-words.ts +++ b/frontend/src/ts/test/practise-words.ts @@ -4,12 +4,15 @@ import { showNoticeNotification } from "../states/notifications"; import { Config } from "../config/store"; import { setConfig } from "../config/setters"; import * as CustomText from "./custom-text"; -import * as TestInput from "./test-input"; import { configEvent } from "../events/config"; import { setCustomTextName } from "../legacy-states/custom-text-name"; import { Mode } from "@monkeytype/schemas/shared"; import { CustomTextSettings } from "@monkeytype/schemas/results"; -import { getBurstHistory, getMissedWords } from "./events/stats"; +import { + getBurstHistory, + getInputHistory, + getMissedWords, +} from "./events/stats"; type Before = { mode: Mode | null; @@ -91,7 +94,7 @@ export function init( if (slow) { const typedWords = TestWords.words .getText() - .slice(0, TestInput.input.getHistory().length - 1); + .slice(0, getInputHistory().length - 1); const burstHistory = getBurstHistory(); diff --git a/frontend/src/ts/test/test-input.ts b/frontend/src/ts/test/test-input.ts index 881ba08a3383..f779b8ebeea1 100644 --- a/frontend/src/ts/test/test-input.ts +++ b/frontend/src/ts/test/test-input.ts @@ -1,24 +1,23 @@ -import { lastElementFromArray } from "../utils/arrays"; import { getInputElementValue } from "../input/input-element"; class Input { current: string; - private history: string[]; + // private history: string[]; koreanStatus: boolean; constructor() { this.current = ""; - this.history = []; + // this.history = []; this.koreanStatus = false; } reset(): void { this.current = ""; - this.history = []; + // this.history = []; } - resetHistory(): void { - this.history = []; - } + // resetHistory(): void { + // this.history = []; + // } setKoreanStatus(val: boolean): void { this.koreanStatus = val; @@ -28,33 +27,33 @@ class Input { return this.koreanStatus; } - pushHistory(): void { - this.history.push(this.current); - this.current = ""; - } - - popHistory(): string { - const ret = this.history.pop() ?? ""; - return ret; - } - - get(index: number): string | undefined { - return this.history[index]; - } - - getHistory(): string[]; - getHistory(i: number): string | undefined; - getHistory(i?: number): unknown { - if (i === undefined) { - return this.history; - } else { - return this.history[i]; - } - } - - getHistoryLast(): string | undefined { - return lastElementFromArray(this.history); - } + // pushHistory(): void { + // this.history.push(this.current); + // this.current = ""; + // } + + // popHistory(): string { + // const ret = this.history.pop() ?? ""; + // return ret; + // } + + // get(index: number): string | undefined { + // return this.history[index]; + // } + + // getHistory(): string[]; + // getHistory(i: number): string | undefined; + // getHistory(i?: number): unknown { + // if (i === undefined) { + // return this.history; + // } else { + // return this.history[i]; + // } + // } + + // getHistoryLast(): string | undefined { + // return lastElementFromArray(this.history); + // } syncWithInputElement(): void { this.current = getInputElementValue().inputValue; diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 79c59bbd6d82..ffa69262d441 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -432,7 +432,7 @@ async function init(): Promise { Replay.stopReplayRecording(); TestWords.words.reset(); TestState.setActiveWordIndex(0); - TestInput.input.resetHistory(); + // TestInput.input.resetHistory(); TestInput.input.current = ""; showLoaderBar(); @@ -654,7 +654,7 @@ export function areAllTestWordsGenerated(): boolean { //add word during the test export async function addWord(): Promise { if (Config.mode === "zen") { - TestUI.appendEmptyWordElement(); + TestUI.appendEmptyWordElement(TestState.activeWordIndex + 1); return; } @@ -668,7 +668,7 @@ export async function addWord(): Promise { const toPushCount = funboxToPush?.split(":")[1]; if (toPushCount !== undefined) bound = +toPushCount - 1; - if (TestWords.words.length - TestInput.input.getHistory().length > bound) { + if (TestWords.words.length - TestState.activeWordIndex > bound) { console.debug("Not adding word, enough words already"); return; } @@ -903,14 +903,14 @@ export async function finish(difficultyFailed = false): Promise { // in case the tests ends with a keypress (not a word submission) // we need to push the current input to history if (TestInput.input.current.length !== 0) { - TestInput.input.pushHistory(); - Replay.replayGetWordsList(TestInput.input.getHistory()); + // TestInput.input.pushHistory(); + Replay.replayGetWordsList(getInputHistory()); } // in zen mode, ensure the replay words list reflects the typed input history // even if the current input was empty at finish (e.g., after submitting a word). if (Config.mode === "zen") { - Replay.replayGetWordsList(TestInput.input.getHistory()); + Replay.replayGetWordsList(getInputHistory()); } forceReleaseAllKeys(); @@ -1071,11 +1071,11 @@ export async function finish(difficultyFailed = false): Promise { // Let's update the custom text progress if ( TestState.bailedOut || - TestInput.input.getHistory().length < TestWords.words.length + getInputHistory().length < TestWords.words.length ) { // They bailed out - const history = TestInput.input.getHistory(); + const history = getInputHistory(); let historyLength = history?.length; const wordIndex = historyLength - 1; diff --git a/frontend/src/ts/test/test-timer.ts b/frontend/src/ts/test/test-timer.ts index e3e6a87fd6dd..32cb7e556716 100644 --- a/frontend/src/ts/test/test-timer.ts +++ b/frontend/src/ts/test/test-timer.ts @@ -193,7 +193,7 @@ function checkIfTimeIsUp(): void { //times up if (timer !== null) clearTimeout(timer); Caret.hide(); - TestInput.input.pushHistory(); + // TestInput.input.pushHistory(); SlowTimer.clear(); slowTimerCount = 0; timerEvent.dispatch({ key: "finish" }); diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index b556abcf4d7c..58b1d1dd4ba5 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -72,6 +72,7 @@ import { getBurstHistory, getCorrectedWords, getCurrentAccuracy, + getInputHistory, getMissedWords, } from "./events/stats"; @@ -498,7 +499,7 @@ function showWords(): void { wordsEl.setHtml(""); if (Config.mode === "zen") { - appendEmptyWordElement(); + appendEmptyWordElement(0); } else { let wordsHTML = ""; for (let i = 0; i < TestWords.words.length; i++) { @@ -514,9 +515,7 @@ function showWords(): void { PaceCaret.resetCaretPosition(); } -export function appendEmptyWordElement( - index = TestInput.input.getHistory().length, -): void { +export function appendEmptyWordElement(index: number): void { wordsEl.appendHtml( `
`, ); @@ -1317,11 +1316,12 @@ async function loadWordsHistory(): Promise { const wordsContainer = qs("#resultWordsHistory .words"); wordsContainer?.empty(); + const inputHistory = getInputHistory(); const burstHistory = getBurstHistory(); const correctedHistory = getCorrectedWords(); - const inputHistoryLength = TestInput.input.getHistory().length; + const inputHistoryLength = inputHistory.length; for (let i = 0; i < inputHistoryLength + 2; i++) { - const input = TestInput.input.getHistory(i); + const input = inputHistory[i]; const corrected = correctedHistory[i]; const word = TestWords.words.getText(i) ?? ""; const koreanRegex = @@ -1952,11 +1952,11 @@ export function onTestFinish(): void { qs(".pageTest #copyWordsListButton")?.on("click", async () => { let words; if (Config.mode === "zen") { - words = TestInput.input.getHistory().join(" "); + words = getInputHistory().join(" "); } else { words = TestWords.words .getText() - .slice(0, TestInput.input.getHistory().length) + .slice(0, getInputHistory().length) .join(" "); } await copyToClipboard(words); @@ -1965,7 +1965,7 @@ qs(".pageTest #copyWordsListButton")?.on("click", async () => { qs(".pageTest #copyMissedWordsListButton")?.on("click", async () => { let words; if (Config.mode === "zen") { - words = TestInput.input.getHistory().join(" "); + words = getInputHistory().join(" "); } else { words = (Object.keys(getMissedWords()) ?? {}).join(" "); } diff --git a/frontend/src/ts/test/timer-progress.ts b/frontend/src/ts/test/timer-progress.ts index 89a41bc521e9..717748e9bbbc 100644 --- a/frontend/src/ts/test/timer-progress.ts +++ b/frontend/src/ts/test/timer-progress.ts @@ -2,7 +2,6 @@ import { Config } from "../config/store"; import * as CustomText from "./custom-text"; import * as DateTime from "../utils/date-and-time"; import * as TestWords from "./test-words"; -import * as TestInput from "./test-input"; import * as Time from "../legacy-states/time"; import * as TestState from "./test-state"; import { configEvent } from "../events/config"; @@ -111,12 +110,12 @@ function getCurrentCount(): number { 1 ); } else { - return TestInput.input.getHistory().length; + return TestState.activeWordIndex; } } function setTimerHtmlToInputLength(el: HTMLElement, wrapInDiv: boolean): void { - let historyLength = `${TestInput.input.getHistory().length}`; + let historyLength = `${TestState.activeWordIndex}`; if (wrapInDiv) { historyLength = `
${historyLength}
`; From e1a354073dc6e76495540c150de181d85e5b95ec Mon Sep 17 00:00:00 2001 From: Miodec Date: Sun, 31 May 2026 00:39:41 +0200 Subject: [PATCH 24/41] yeet --- frontend/src/ts/test/test-input.ts | 81 ------------------------------ 1 file changed, 81 deletions(-) diff --git a/frontend/src/ts/test/test-input.ts b/frontend/src/ts/test/test-input.ts index f779b8ebeea1..418385951dee 100644 --- a/frontend/src/ts/test/test-input.ts +++ b/frontend/src/ts/test/test-input.ts @@ -15,10 +15,6 @@ class Input { // this.history = []; } - // resetHistory(): void { - // this.history = []; - // } - setKoreanStatus(val: boolean): void { this.koreanStatus = val; } @@ -27,86 +23,9 @@ class Input { return this.koreanStatus; } - // pushHistory(): void { - // this.history.push(this.current); - // this.current = ""; - // } - - // popHistory(): string { - // const ret = this.history.pop() ?? ""; - // return ret; - // } - - // get(index: number): string | undefined { - // return this.history[index]; - // } - - // getHistory(): string[]; - // getHistory(i: number): string | undefined; - // getHistory(i?: number): unknown { - // if (i === undefined) { - // return this.history; - // } else { - // return this.history[i]; - // } - // } - - // getHistoryLast(): string | undefined { - // return lastElementFromArray(this.history); - // } - syncWithInputElement(): void { this.current = getInputElementValue().inputValue; } } -// class Corrected { -// current: string; -// private history: string[]; -// constructor() { -// this.current = ""; -// this.history = []; -// } - -// reset(): void { -// this.history = []; -// this.current = ""; -// } - -// update(char: string, correct: boolean): void { -// if (this.current === "") { -// this.current += input.current; -// } else { -// const currCorrectedTestInputLength = this.current.length; - -// const charIndex = input.current.length - 1; - -// if (charIndex >= currCorrectedTestInputLength) { -// this.current += char; -// } else if (!correct) { -// this.current = -// this.current.substring(0, charIndex) + -// char + -// this.current.substring(charIndex + 1); -// } -// } -// } - -// getHistory(i: number): string | undefined { -// return this.history[i]; -// } - -// popHistory(): string { -// const popped = this.history.pop() ?? ""; -// this.current = popped; -// return popped; -// } - -// pushHistory(): void { -// this.history.push(this.current); -// this.current = ""; -// } -// } - export const input = new Input(); -// export const corrected = new Corrected(); From 358e88caf0a5e98195facd18d6cae4628aadd07b Mon Sep 17 00:00:00 2001 From: Miodec Date: Sun, 31 May 2026 00:41:06 +0200 Subject: [PATCH 25/41] move korean state --- frontend/src/ts/test/test-input.ts | 10 ---------- frontend/src/ts/test/test-logic.ts | 4 ++-- frontend/src/ts/test/test-state.ts | 5 +++++ 3 files changed, 7 insertions(+), 12 deletions(-) diff --git a/frontend/src/ts/test/test-input.ts b/frontend/src/ts/test/test-input.ts index 418385951dee..79a5aa7ddf87 100644 --- a/frontend/src/ts/test/test-input.ts +++ b/frontend/src/ts/test/test-input.ts @@ -3,11 +3,9 @@ import { getInputElementValue } from "../input/input-element"; class Input { current: string; // private history: string[]; - koreanStatus: boolean; constructor() { this.current = ""; // this.history = []; - this.koreanStatus = false; } reset(): void { @@ -15,14 +13,6 @@ class Input { // this.history = []; } - setKoreanStatus(val: boolean): void { - this.koreanStatus = val; - } - - getKoreanStatus(): boolean { - return this.koreanStatus; - } - syncWithInputElement(): void { this.current = getInputElementValue().inputValue; } diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index ffa69262d441..8bfbb070f4bb 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -332,7 +332,7 @@ export function restart(options = {} as RestartOptions): void { TestState.setBailedOut(false); Caret.resetPosition(); PaceCaret.reset(); - TestInput.input.setKoreanStatus(false); + TestState.setKoreanStatus(false); clearQuoteStats(); CompositionState.setComposing(false); CompositionState.setData(""); @@ -593,7 +593,7 @@ async function init(): Promise { /[\uac00-\ud7af]|[\u1100-\u11ff]|[\u3130-\u318f]|[\ua960-\ua97f]|[\ud7b0-\ud7ff]/g, ) ) { - TestInput.input.setKoreanStatus(true); + TestState.setKoreanStatus(true); } for (let i = 0; i < generatedWords.length; i++) { diff --git a/frontend/src/ts/test/test-state.ts b/frontend/src/ts/test/test-state.ts index 82c27c083657..831c101d08ec 100644 --- a/frontend/src/ts/test/test-state.ts +++ b/frontend/src/ts/test/test-state.ts @@ -13,6 +13,11 @@ export let isDirectionReversed = false; export let testRestarting = false; export let resultVisible = false; export let resultCalculating = false; +export let koreanStatus = false; + +export function setKoreanStatus(val: boolean): void { + koreanStatus = val; +} export function setRepeated(tf: boolean): void { isRepeated = tf; From b3f3df0912ae7876cc8c15a36f7af40290fddda4 Mon Sep 17 00:00:00 2001 From: Miodec Date: Mon, 1 Jun 2026 10:06:31 +0200 Subject: [PATCH 26/41] early return --- frontend/src/ts/input/handlers/before-delete.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/frontend/src/ts/input/handlers/before-delete.ts b/frontend/src/ts/input/handlers/before-delete.ts index 8809a264383d..d623cdd610a5 100644 --- a/frontend/src/ts/input/handlers/before-delete.ts +++ b/frontend/src/ts/input/handlers/before-delete.ts @@ -28,6 +28,12 @@ export function onBeforeDelete(event: InputEvent): void { const inputIsEmpty = inputValue === ""; if (inputIsEmpty) { + // we are on the first word, just prevent default, nothing to go back to + if (TestState.activeWordIndex === 0) { + event.preventDefault(); + return; + } + // this is nested because we only wanna pull the element from the dom if needed const previousWordElement = TestUI.getWordElement( TestState.activeWordIndex - 1, From e85e14929e95f6a18a2d7124d259a6cf83de7e3c Mon Sep 17 00:00:00 2001 From: Miodec Date: Mon, 1 Jun 2026 13:40:33 +0200 Subject: [PATCH 27/41] boom --- .../__tests__/test/events/helpers.spec.ts | 267 ++++++++++++++++-- .../ts/input/handlers/before-insert-text.ts | 8 +- frontend/src/ts/input/handlers/delete.ts | 66 +++-- frontend/src/ts/input/handlers/insert-text.ts | 49 ++-- frontend/src/ts/input/helpers/validation.ts | 4 +- .../src/ts/input/helpers/word-navigation.ts | 9 +- frontend/src/ts/input/listeners/input.ts | 4 +- frontend/src/ts/test/caret.ts | 5 +- frontend/src/ts/test/events/data.ts | 35 +-- frontend/src/ts/test/events/helpers.ts | 97 ++++++- frontend/src/ts/test/events/stats.ts | 18 +- frontend/src/ts/test/events/types.ts | 16 +- .../src/ts/test/funbox/funbox-functions.ts | 13 +- frontend/src/ts/test/test-input.ts | 21 -- frontend/src/ts/test/test-logic.ts | 9 +- frontend/src/ts/test/test-timer.ts | 8 +- frontend/src/ts/test/test-ui.ts | 18 +- 17 files changed, 458 insertions(+), 189 deletions(-) delete mode 100644 frontend/src/ts/test/test-input.ts diff --git a/frontend/__tests__/test/events/helpers.spec.ts b/frontend/__tests__/test/events/helpers.spec.ts index c79ec075c45d..93b8adeb10e3 100644 --- a/frontend/__tests__/test/events/helpers.spec.ts +++ b/frontend/__tests__/test/events/helpers.spec.ts @@ -6,7 +6,9 @@ vi.mock("../../../src/ts/config/store", () => ({ })); import { - getSimulatedInput, + findInputValueMismatches, + getInputFromDom, + getInputFromEvents, getTestEventCode, } from "../../../src/ts/test/events/helpers"; import type { InputEvent } from "../../../src/ts/test/events/types"; @@ -91,65 +93,67 @@ function reset(): void { wordIndex = 0; } -describe("getSimulatedInput", () => { +describe("getInputFromEvents", () => { beforeEach(() => { reset(); }); it("builds string from insertText events", () => { - expect(getSimulatedInput([...insert("hello")])).toBe("hello"); + expect(getInputFromEvents([...insert("hello")])).toBe("hello"); }); it("builds string from insertText events with trailing space", () => { - expect(getSimulatedInput([...insert("hello ")])).toBe("hello "); + expect(getInputFromEvents([...insert("hello ")])).toBe("hello "); }); it("handles deleteContentBackward", () => { - expect(getSimulatedInput([...insert("abc"), ...deleteBackward()])).toBe( + expect(getInputFromEvents([...insert("abc"), ...deleteBackward()])).toBe( "ab", ); }); it("handles deleteContentBackward after space", () => { - expect(getSimulatedInput([...insert("abc "), ...deleteBackward()])).toBe( + expect(getInputFromEvents([...insert("abc "), ...deleteBackward()])).toBe( "abc", ); }); it("handles multiple deletes", () => { - expect(getSimulatedInput([...insert("ab"), ...deleteBackward(2)])).toBe(""); + expect(getInputFromEvents([...insert("ab"), ...deleteBackward(2)])).toBe( + "", + ); }); it("handles multiple deletes after space", () => { - expect(getSimulatedInput([...insert("ab "), ...deleteBackward(2)])).toBe( + expect(getInputFromEvents([...insert("ab "), ...deleteBackward(2)])).toBe( "a", ); }); it("handles deleteWordBackward", () => { - expect(getSimulatedInput([...insert("hello"), deleteWordBackward()])).toBe( + expect(getInputFromEvents([...insert("hello"), deleteWordBackward()])).toBe( "", ); }); it("handles deleteWordBackward after space", () => { - expect(getSimulatedInput([...insert("hello "), deleteWordBackward()])).toBe( - "", - ); + expect( + getInputFromEvents([...insert("hello "), deleteWordBackward()]), + ).toBe(""); }); it("returns empty string for no events", () => { - expect(getSimulatedInput([])).toBe(""); + expect(getInputFromEvents([])).toBe(""); }); it("handles deleteContentBackward on empty string", () => { const events = [...deleteBackward()]; - expect(getSimulatedInput(events)).toBe(""); + expect(getInputFromEvents(events)).toBe(""); }); it("skips inputStopped events", () => { expect( - getSimulatedInput([ + getInputFromEvents([ ...insert("he"), ...insert("x", "insertText", { inputStopped: true }), ...insert("llo"), @@ -157,12 +161,241 @@ describe("getSimulatedInput", () => { ).toBe("hello"); }); + it("handles deleteContentBackward within the same word correctly", () => { + expect(getInputFromEvents([...insert("a a"), deleteWordBackward()])).toBe( + "a ", + ); + }); + + it("handles deleteWordBackward with multiple internal spaces", () => { + expect( + getInputFromEvents([...insert("foo bar baz"), deleteWordBackward()]), + ).toBe("foo bar "); + }); + + it("handles deleteWordBackward with trailing space after multiple words", () => { + expect( + getInputFromEvents([...insert("foo bar "), deleteWordBackward()]), + ).toBe("foo "); + }); + + it("handles consecutive deleteWordBackward events", () => { + expect( + getInputFromEvents([ + ...insert("foo bar baz"), + deleteWordBackward(), + deleteWordBackward(), + ]), + ).toBe("foo "); + }); + + it("handles deleteWordBackward on empty string", () => { + expect(getInputFromEvents([deleteWordBackward()])).toBe(""); + }); + + it("handles deleteWordBackward on only whitespace", () => { + expect(getInputFromEvents([...insert(" "), deleteWordBackward()])).toBe( + "", + ); + }); + + it("ignores recorded inputValue (pure op-based simulation)", () => { + const events: InputEvent[] = [ + ...insert("hello"), + { + type: "input", + ms: 100, + testMs: 100, + data: { + inputType: "deleteWordBackward", + charIndex: 5, + wordIndex: 0, + inputValue: "RECORDED_BUT_IGNORED", + }, + }, + ]; + // pure simulation: deleteWordBackward on "hello" → "" + expect(getInputFromEvents(events)).toBe(""); + }); +}); + +describe("getInputFromDom", () => { + beforeEach(() => { + reset(); + }); + + it("falls through to op-based logic when inputValue is absent", () => { + expect(getInputFromDom([...insert("hello")])).toBe("hello"); + }); + + it("uses recorded inputValue when present, overriding op-based logic", () => { + const events: InputEvent[] = [ + ...insert("hello"), + { + type: "input", + ms: 100, + testMs: 100, + data: { + inputType: "deleteWordBackward", + charIndex: 5, + wordIndex: 0, + inputValue: "he", + }, + }, + ]; + // op-based would yield "", but inputValue is truth + expect(getInputFromDom(events)).toBe("he"); + }); + + it("uses latest event's inputValue across multiple recorded events", () => { + const events: InputEvent[] = [ + ...insert("hello"), + { + type: "input", + ms: 100, + testMs: 100, + data: { + inputType: "deleteContentBackward", + charIndex: 5, + wordIndex: 0, + inputValue: "hi", + }, + }, + ]; + expect(getInputFromDom(events)).toBe("hi"); + }); + + it("mixes captured and op-based across events", () => { + const events: InputEvent[] = [ + ...insert("ab"), // no inputValue, op = "ab" + { + type: "input", + ms: 100, + testMs: 100, + data: { + inputType: "insertText", + data: "c", + charIndex: 2, + wordIndex: 0, + correct: true, + isCompositionEnding: false, + inputStopped: false, + inputValue: "abc", + }, + }, + // next event has no inputValue, falls through to op (append "d") + { + type: "input", + ms: 110, + testMs: 110, + data: { + inputType: "insertText", + data: "d", + charIndex: 3, + wordIndex: 0, + correct: true, + isCompositionEnding: false, + inputStopped: false, + }, + }, + ]; + expect(getInputFromDom(events)).toBe("abcd"); + }); +}); + +describe("findInputValueMismatches", () => { + beforeEach(() => { + reset(); + }); + + it("returns empty when no events have recorded inputValue", () => { + expect(findInputValueMismatches([...insert("hello")])).toEqual([]); + }); + + it("returns empty when recorded values match derivation", () => { + const events: InputEvent[] = [ + { + type: "input", + ms: 10, + testMs: 10, + data: { + inputType: "insertText", + data: "a", + charIndex: 0, + wordIndex: 0, + correct: true, + isCompositionEnding: false, + inputStopped: false, + inputValue: "a", + }, + }, + { + type: "input", + ms: 20, + testMs: 20, + data: { + inputType: "insertText", + data: "b", + charIndex: 1, + wordIndex: 0, + correct: true, + isCompositionEnding: false, + inputStopped: false, + inputValue: "ab", + }, + }, + ]; + expect(findInputValueMismatches(events)).toEqual([]); + }); + + it("returns mismatches when recorded value differs from derivation", () => { + const events: InputEvent[] = [ + { + type: "input", + ms: 10, + testMs: 10, + data: { + inputType: "insertText", + data: "a", + charIndex: 0, + wordIndex: 0, + correct: true, + isCompositionEnding: false, + inputStopped: false, + inputValue: "DIFFERENT", + }, + }, + ]; + expect(findInputValueMismatches(events)).toEqual([ + { index: 0, derived: "a", recorded: "DIFFERENT" }, + ]); + }); + + it("skips events without inputValue, still tracks ones with it", () => { + const events: InputEvent[] = [ + ...insert("hello"), // no inputValue on these + { + type: "input", + ms: 100, + testMs: 100, + data: { + inputType: "deleteContentBackward", + charIndex: 5, + wordIndex: 0, + inputValue: "hell", + }, + }, + ]; + // derivation: "hello" then slice = "hell". Recorded = "hell". Match. + expect(findInputValueMismatches(events)).toEqual([]); + }); + // it("handles insertCompositionText events", () => { // const events = [ // ...insert("k", "insertCompositionText"), // ...insert("ka", "insertCompositionText"), // ]; - // expect(getSimulatedInput(events)).toBe("ka"); + // expect(getInputFromEvents(events)).toBe("ka"); // }); // it("handles composition followed by regular text", () => { @@ -171,7 +404,7 @@ describe("getSimulatedInput", () => { // ...insert("ka", "insertCompositionText"), // ...insert("b"), // ]; - // expect(getSimulatedInput(events)).toBe("kab"); + // expect(getInputFromEvents(events)).toBe("kab"); // }); }); diff --git a/frontend/src/ts/input/handlers/before-insert-text.ts b/frontend/src/ts/input/handlers/before-insert-text.ts index 3b96d6be403c..3c5f56419e6e 100644 --- a/frontend/src/ts/input/handlers/before-insert-text.ts +++ b/frontend/src/ts/input/handlers/before-insert-text.ts @@ -1,5 +1,5 @@ import { Config } from "../../config/store"; -import * as TestInput from "../../test/test-input"; +import { getCurrentInput } from "../../test/events/data"; import * as TestState from "../../test/test-state"; import * as TestUI from "../../test/test-ui"; import * as TestWords from "../../test/test-words"; @@ -61,7 +61,7 @@ export function onBeforeInsertText(data: string): boolean { // block input if the word is too long const inputLimit = Config.mode === "zen" ? 30 : TestWords.words.getCurrentText().length + 20; - const overLimit = TestInput.input.current.length >= inputLimit; + const overLimit = getCurrentInput().length >= inputLimit; if (overLimit && (shouldInsertSpaceAsCharacter === true || !dataIsSpace)) { console.error("Hitting word limit"); return true; @@ -71,7 +71,7 @@ export function onBeforeInsertText(data: string): boolean { // this will not work for the first word of each line, but that has a low chance of happening const dataIsNotFalsy = data !== null && data !== ""; const inputIsLongerThanOrEqualToWord = - TestInput.input.current.length >= TestWords.words.getCurrentText().length; + getCurrentInput().length >= TestWords.words.getCurrentText().length; if ( !SlowTimer.get() && // don't do this check if slow timer is active @@ -91,7 +91,7 @@ export function onBeforeInsertText(data: string): boolean { ); const { top: topAfterAppend, height: heightAfterAppend } = TestUI.getActiveWordTopAndHeightWithDifferentData( - (pendingWordData ?? TestInput.input.current) + data, + (pendingWordData ?? getCurrentInput()) + data, ); if (topAfterAppend > TestUI.activeWordTop) { //word jumped to next line diff --git a/frontend/src/ts/input/handlers/delete.ts b/frontend/src/ts/input/handlers/delete.ts index f9a93c885a6d..f6e382c20056 100644 --- a/frontend/src/ts/input/handlers/delete.ts +++ b/frontend/src/ts/input/handlers/delete.ts @@ -1,29 +1,28 @@ import * as TestUI from "../../test/test-ui"; import * as TestWords from "../../test/test-words"; -import * as TestInput from "../../test/test-input"; import { getInputElementValue, setInputElementValue } from "../input-element"; import * as Replay from "../../test/replay"; import { Config } from "../../config/store"; import { goToPreviousWord } from "../helpers/word-navigation"; import { DeleteInputType } from "../helpers/input-type"; -import { logTestEvent } from "../../test/events/data"; +import { getCurrentInput, logTestEvent } from "../../test/events/data"; import { activeWordIndex } from "../../test/test-state"; export function onDelete(inputType: DeleteInputType, now: number): void { const { realInputValue } = getInputElementValue(); - const inputBeforeDelete = TestInput.input.current; + const inputBeforeDelete = getCurrentInput(); const activeWordIndexBeforeDelete = activeWordIndex; - TestInput.input.syncWithInputElement(); + const inputAfterDelete = getInputElementValue().inputValue; - Replay.addReplayEvent("setLetterIndex", TestInput.input.current.length); + Replay.addReplayEvent("setLetterIndex", inputAfterDelete.length); const beforeDeleteOnlyTabs = /^\t*$/.test(inputBeforeDelete); const allTabsCorrect = TestWords.words .getCurrentText() - .startsWith(TestInput.input.current); + .startsWith(inputAfterDelete); //special check for code languages if ( @@ -32,25 +31,52 @@ export function onDelete(inputType: DeleteInputType, now: number): void { inputBeforeDelete.length > 0 && beforeDeleteOnlyTabs && allTabsCorrect - // (TestInput.input.getHistory(TestState.activeWordIndex - 1) !== - // TestWords.words.get(TestState.activeWordIndex - 1) || - // Config.freedomMode) ) { + // Clear N+1's tabs (the word the user was in) + logTestEvent("input", now, { + inputType: "deleteWordBackward", + wordIndex: activeWordIndexBeforeDelete, + charIndex: inputBeforeDelete.length, + inputValue: "", + }); + setInputElementValue(""); - TestInput.input.syncWithInputElement(); goToPreviousWord(inputType, true); - } else { - //normal backspace - if (realInputValue === "") { - goToPreviousWord(inputType); - } + + // Record the resulting state of the previous word (newline removed) + const postNavInputValue = getInputElementValue().inputValue; + logTestEvent("input", now, { + inputType: "deleteContentBackward", + wordIndex: activeWordIndex, + charIndex: postNavInputValue.length, + inputValue: postNavInputValue, + }); + + TestUI.afterTestDelete(); + return; } - logTestEvent("input", now, { - inputType: inputType, - wordIndex: activeWordIndexBeforeDelete, - charIndex: inputBeforeDelete.length, - }); + //normal backspace + if (realInputValue === "") { + goToPreviousWord(inputType); + + // Record the resulting state of the destination word + const postNavInputValue = getInputElementValue().inputValue; + logTestEvent("input", now, { + inputType: inputType, + wordIndex: activeWordIndex, + charIndex: postNavInputValue.length, + inputValue: postNavInputValue, + }); + } else { + // Delete within current word + logTestEvent("input", now, { + inputType: inputType, + wordIndex: activeWordIndexBeforeDelete, + charIndex: inputBeforeDelete.length, + inputValue: inputAfterDelete, + }); + } TestUI.afterTestDelete(); } diff --git a/frontend/src/ts/input/handlers/insert-text.ts b/frontend/src/ts/input/handlers/insert-text.ts index f474e4d06e30..8e7ad6e722d1 100644 --- a/frontend/src/ts/input/handlers/insert-text.ts +++ b/frontend/src/ts/input/handlers/insert-text.ts @@ -1,6 +1,5 @@ import * as TestUI from "../../test/test-ui"; import * as TestWords from "../../test/test-words"; -import * as TestInput from "../../test/test-input"; import { getInputElementValue, replaceInputElementLastValueChar, @@ -37,7 +36,7 @@ import { isCharCorrect, shouldInsertSpaceCharacter, } from "../helpers/validation"; -import { logTestEvent } from "../../test/events/data"; +import { getCurrentInput, logTestEvent } from "../../test/events/data"; const charOverrides = new Map([ ["…", "..."], @@ -66,10 +65,7 @@ export async function onInsertText(options: OnInsertTextParams): Promise { if (options.data.length > 1) { // remove the entire data from the input value - // make sure to not call TestInput.input.syncWithInputElement in here - // it will be updated later in the body of onInsertText setInputElementValue(inputValue.slice(0, -options.data.length)); - TestInput.input.syncWithInputElement(); for (let i = 0; i < options.data.length; i++) { const char = options.data[i] as string; @@ -86,8 +82,7 @@ export async function onInsertText(options: OnInsertTextParams): Promise { const charOverride = charOverrides.get(options.data); if ( charOverride !== undefined && - TestWords.words.getCurrentText()[TestInput.input.current.length] !== - options.data + TestWords.words.getCurrentText()[getCurrentInput().length] !== options.data ) { // replace the data with the override setInputElementValue( @@ -101,7 +96,7 @@ export async function onInsertText(options: OnInsertTextParams): Promise { } // input and target word - const testInput = TestInput.input.current; + const testInput = getCurrentInput(); const currentWord = TestWords.words.getCurrentText(); // if the character is visually equal, replace it with the target character @@ -156,11 +151,6 @@ export async function onInsertText(options: OnInsertTextParams): Promise { const shouldGoToNextWord = ((charIsSpace || charIsNewline) && !shouldInsertSpace) || noSpaceForce; - // update test input state - if (!charIsSpace || shouldInsertSpace) { - TestInput.input.syncWithInputElement(); - } - // general per keypress updates Replay.addReplayEvent(correct ? "correctLetter" : "incorrectLetter", data); WeakSpot.updateScore(data, correct); @@ -196,9 +186,26 @@ export async function onInsertText(options: OnInsertTextParams): Promise { if (removeLastChar) { replaceInputElementLastValueChar(""); - TestInput.input.syncWithInputElement(); } + // capture DOM before goToNextWord clears it for the new word + const inputValueAfterEvent = getInputElementValue().inputValue; + + // Log the event BEFORE goToNextWord so readers inside the navigation + // (e.g. beforeTestWordChange's updateWordLetters, getWordBurst) see the + // completed event in derivation. Otherwise the just-typed trigger char + // (space/newline) is missing — visible as missing \n element in zen mode. + logTestEvent("input", now, { + inputType: "insertText", + data, + correct, + wordIndex, + charIndex: testInput.length, + isCompositionEnding: isCompositionEnding === true, + inputStopped: removeLastChar, + inputValue: inputValueAfterEvent, + }); + // going to next word let increasedWordIndex: null | boolean = null; let lastBurst: null | number = null; @@ -213,16 +220,6 @@ export async function onInsertText(options: OnInsertTextParams): Promise { increasedWordIndex = result.increasedWordIndex; } - logTestEvent("input", now, { - inputType: "insertText", - data, - correct, - wordIndex, - charIndex: testInput.length, - isCompositionEnding: isCompositionEnding === true, - inputStopped: removeLastChar, - }); - /* Probably a good place to explain what the heck is going on with all these space related variables: - spaceOrNewLine: did the user input a space or a new line? @@ -236,7 +233,7 @@ export async function onInsertText(options: OnInsertTextParams): Promise { //this COULD be the next word because we are awaiting goToNextWord const nextWord = TestWords.words.getCurrentText(); const doesNextWordHaveTab = /^\t+/.test(nextWord); - const isCurrentCharTab = nextWord[TestInput.input.current.length] === "\t"; + const isCurrentCharTab = nextWord[getCurrentInput().length] === "\t"; //code mode - auto insert tabs if ( @@ -313,8 +310,6 @@ export async function emulateInsertText( } // default is prevented so we need to manually update the input value. - // remember to not call TestInput.input.syncWithInputElement in here - // it will be called later be updated in onInsertText appendToInputElementValue(options.data); await onInsertText(options); diff --git a/frontend/src/ts/input/helpers/validation.ts b/frontend/src/ts/input/helpers/validation.ts index 5ab38fe05725..58b7580a4fa3 100644 --- a/frontend/src/ts/input/helpers/validation.ts +++ b/frontend/src/ts/input/helpers/validation.ts @@ -5,7 +5,7 @@ import { isSpace } from "../../utils/strings"; * Check if the input data is correct * @param options - Options object * @param options.data - Input data - * @param options.inputValue - Current input value (use TestInput.input.current, not input element value) + * @param options.inputValue - Current input value (use getCurrentInput(), not input element value) * @param options.targetWord - Target word * @param options.correctShiftUsed - Whether the correct shift state was used. Null means disabled */ @@ -47,7 +47,7 @@ export function isCharCorrect(options: { * as a "control character" (moving to the next word) * @param options - Options object * @param options.data - Input data - * @param options.inputValue - Current input value (use TestInput.input.current, not input element value) + * @param options.inputValue - Current input value (use getCurrentInput(), not input element value) * @param options.targetWord - Target word * @returns Boolean if data is space, null if not */ diff --git a/frontend/src/ts/input/helpers/word-navigation.ts b/frontend/src/ts/input/helpers/word-navigation.ts index 2a5ab05dc118..f52ec19e068f 100644 --- a/frontend/src/ts/input/helpers/word-navigation.ts +++ b/frontend/src/ts/input/helpers/word-navigation.ts @@ -1,5 +1,4 @@ import { Config } from "../../config/store"; -import * as TestInput from "../../test/test-input"; import * as TestUI from "../../test/test-ui"; import * as PaceCaret from "../../test/pace-caret"; import * as TestState from "../../test/test-state"; @@ -65,8 +64,6 @@ export async function goToNextWord({ Funbox.toggleScript(TestWords.words.getText(TestState.activeWordIndex + 1)); - // TestInput.input.pushHistory(); - const lastWord = TestState.activeWordIndex >= TestWords.words.length - 1; if (lastWord) { setAwaitingNextWord(true); @@ -87,7 +84,6 @@ export async function goToNextWord({ } setInputElementValue(""); - TestInput.input.syncWithInputElement(); void TestUI.afterTestWordChange("forward", burst); return ret; @@ -99,7 +95,6 @@ export function goToPreviousWord( ): void { if (TestState.activeWordIndex === 0) { setInputElementValue(""); - TestInput.input.syncWithInputElement(); return; } @@ -107,8 +102,8 @@ export function goToPreviousWord( Replay.addReplayEvent("backWord"); - const word = getInputForWord(TestState.activeWordIndex); TestState.decreaseActiveWordIndex(); + const word = getInputForWord(TestState.activeWordIndex); Funbox.toggleScript(TestWords.words.getText(TestState.activeWordIndex)); @@ -125,7 +120,5 @@ export function goToPreviousWord( setInputElementValue(word); } } - TestInput.input.syncWithInputElement(); - void TestUI.afterTestWordChange("back"); } diff --git a/frontend/src/ts/input/listeners/input.ts b/frontend/src/ts/input/listeners/input.ts index 7f697c2697d8..14bc1a7092f1 100644 --- a/frontend/src/ts/input/listeners/input.ts +++ b/frontend/src/ts/input/listeners/input.ts @@ -9,11 +9,11 @@ import { import * as TestUI from "../../test/test-ui"; import { onBeforeInsertText } from "../handlers/before-insert-text"; import { onBeforeDelete } from "../handlers/before-delete"; -import * as TestInput from "../../test/test-input"; import * as TestWords from "../../test/test-words"; import * as CompositionState from "../../legacy-states/composition"; import { activeWordIndex } from "../../test/test-state"; import { areAllTestWordsGenerated } from "../../test/test-logic"; +import { getCurrentInput } from "../../test/events/data"; const inputEl = getInputElement(); @@ -123,7 +123,7 @@ inputEl.addEventListener("input", async (event) => { ) { const allWordsTyped = activeWordIndex >= TestWords.words.length - 1; const inputPlusComposition = - TestInput.input.current + (CompositionState.getData() ?? ""); + getCurrentInput() + (CompositionState.getData() ?? ""); const inputPlusCompositionIsCorrect = TestWords.words.getCurrentText() === inputPlusComposition; diff --git a/frontend/src/ts/test/caret.ts b/frontend/src/ts/test/caret.ts index bbb380838266..187f60b998ac 100644 --- a/frontend/src/ts/test/caret.ts +++ b/frontend/src/ts/test/caret.ts @@ -1,5 +1,5 @@ import { Config } from "../config/store"; -import * as TestInput from "./test-input"; +import { getCurrentInput } from "./events/data"; import * as TestState from "../test/test-state"; import { configEvent } from "../events/config"; import { Caret } from "../elements/caret"; @@ -33,8 +33,7 @@ export function resetPosition(): void { export function updatePosition(noAnim = false): void { caret.goTo({ wordIndex: TestState.activeWordIndex, - letterIndex: - TestInput.input.current.length + CompositionState.getData().length, + letterIndex: getCurrentInput().length + CompositionState.getData().length, isLanguageRightToLeft: TestState.isLanguageRightToLeft, isDirectionReversed: TestState.isDirectionReversed, animate: Config.smoothCaret !== "off" && !noAnim, diff --git a/frontend/src/ts/test/events/data.ts b/frontend/src/ts/test/events/data.ts index 0d4e4ac2a38d..698071e14864 100644 --- a/frontend/src/ts/test/events/data.ts +++ b/frontend/src/ts/test/events/data.ts @@ -13,10 +13,10 @@ import { TimerEvent, TimerEventData, } from "./types"; -import { keysToTrack } from "./helpers"; +import { getInputFromDom, keysToTrack } from "./helpers"; import { Keycode } from "../../constants/keys"; import { roundTo2 } from "@monkeytype/util/numbers"; -import { resultCalculating } from "../test-state"; +import { activeWordIndex, resultCalculating } from "../test-state"; let keydownEvents: KeydownEvent[] = []; let keyupEvents: KeyupEvent[] = []; @@ -142,6 +142,10 @@ function invalidateCache(): void { cachedAllEvents = undefined; } +export function getCurrentInput(): string { + return getInputFromDom(getInputEventsForWord(activeWordIndex)); +} + export function cleanupData(): void { invalidateCache(); getAllTestEvents(); @@ -315,19 +319,7 @@ export function getInputEventsForWord(wordIndex: number): InputEvent[] { const result: InputEvent[] = []; for (const event of events) { if (event.type !== "input") continue; - - let eventWordIndex = event.data.wordIndex; - - if ( - (event.data.inputType === "deleteWordBackward" || - event.data.inputType === "deleteContentBackward") && - event.data.charIndex === 0 && - eventWordIndex > 0 - ) { - eventWordIndex -= 1; - } - - if (eventWordIndex === wordIndex) { + if (event.data.wordIndex === wordIndex) { result.push(event); } } @@ -353,18 +345,7 @@ export function getInputEventsPerWord( break; } - let wordIndex = event.data.wordIndex; - - //special case for delete events on the 0th index - // because they affect the previous word - so we need to attribute them to the previous word - if ( - (event.data.inputType === "deleteWordBackward" || - event.data.inputType === "deleteContentBackward") && - event.data.charIndex === 0 && - wordIndex > 0 - ) { - wordIndex -= 1; - } + const wordIndex = event.data.wordIndex; const existing = eventsPerWordIndex.get(wordIndex) ?? []; existing.push(event); diff --git a/frontend/src/ts/test/events/helpers.ts b/frontend/src/ts/test/events/helpers.ts index 415b4916520b..b5d7186f05e4 100644 --- a/frontend/src/ts/test/events/helpers.ts +++ b/frontend/src/ts/test/events/helpers.ts @@ -93,25 +93,92 @@ export function getTestEventCode(event: KeyboardEvent): Keycode | "NoCode" { return event.code as Keycode; } -export function getSimulatedInput(events: InputEvent[]): string { - let simulatedInput = ""; +export function applyOp(input: string, event: InputEvent): string { + if (event.data.inputType === "insertText") { + if (event.data.inputStopped) return input; + return input + event.data.data; + } + if (event.data.inputType === "insertCompositionText") { + if (event.data.inputStopped) return input; + return input + event.data.data; + } + if (event.data.inputType === "deleteContentBackward") { + return input.slice(0, -1); + } + if (event.data.inputType === "deleteWordBackward") { + return input.replace(/(?:\S+\s*|\s+)$/, ""); + } + return input; +} +/** + * Derives input by applying each event's operation in order. Ignores the + * recorded inputValue field. Use for verification, tests, or fallback — + * not as source of truth. + */ +export function getInputFromEvents(events: InputEvent[]): string { + let input = ""; for (const event of events) { - if (event.data.inputType === "insertText") { - if (event.data.inputStopped) continue; - simulatedInput += event.data.data; - } - if (event.data.inputType === "insertCompositionText") { - if (event.data.inputStopped) continue; - simulatedInput += event.data.data; - } - if (event.data.inputType === "deleteContentBackward") { - simulatedInput = simulatedInput.slice(0, -1); + input = applyOp(input, event); + } + return input; +} + +/** + * Reads input from the DOM snapshots captured on each event (inputValue), + * falling back to op-based derivation for events without a snapshot. + * Use this whenever you need the actual current/past input state. + * + * Walks backward to find the latest event with a captured inputValue, then + * replays any subsequent events forward — O(1) when the last event has a + * snapshot (the common case), O(n) worst case. + */ +export function getInputFromDom(events: InputEvent[]): string { + for (let i = events.length - 1; i >= 0; i--) { + const event = events[i] as InputEvent; + if (event.data.inputValue !== undefined) { + let input = event.data.inputValue; + for (let j = i + 1; j < events.length; j++) { + input = applyOp(input, events[j] as InputEvent); + } + return input; } - if (event.data.inputType === "deleteWordBackward") { - simulatedInput = ""; + } + return getInputFromEvents(events); +} + +export type InputValueMismatch = { + index: number; + derived: string; + recorded: string; +}; + +/** + * Compares event-derived input against the recorded DOM snapshot at each + * event. Returns the indices where event-derivation disagreed with what the + * DOM captured. Useful for catching op-logic bugs or capture-timing bugs. + */ +export function findInputValueMismatches( + events: InputEvent[], +): InputValueMismatch[] { + const mismatches: InputValueMismatch[] = []; + let derived = ""; + + for (let i = 0; i < events.length; i++) { + const event = events[i] as InputEvent; + derived = applyOp(derived, event); + + if ( + event.data.inputValue !== undefined && + event.data.inputValue !== derived + ) { + mismatches.push({ + index: i, + derived, + recorded: event.data.inputValue, + }); } } - return simulatedInput; + return mismatches; } diff --git a/frontend/src/ts/test/events/stats.ts b/frontend/src/ts/test/events/stats.ts index 9ba1bdeb89fe..189e2aaf623d 100644 --- a/frontend/src/ts/test/events/stats.ts +++ b/frontend/src/ts/test/events/stats.ts @@ -9,7 +9,7 @@ import { import * as TestWords from "../../test/test-words"; import { CharCounts, countChars, getLastChar } from "../../utils/strings"; import * as CustomText from "../../test/custom-text"; -import { getSimulatedInput } from "./helpers"; +import { getInputFromDom } from "./helpers"; import { activeWordIndex, bailedOut } from "../test-state"; import { calculateWpm } from "../../utils/numbers"; import { mean, roundTo2 } from "@monkeytype/util/numbers"; @@ -336,11 +336,11 @@ export function getCurrentAccuracy(): number { //todo: composition start must be the start time for burst calculation function computeBurst(events: InputEvent[], now?: number): number { - const input = getSimulatedInput(events); + const input = getInputFromDom(events); let inputLength = input.length; - if (!input.endsWith(" ")) { - inputLength += 1; // account for space that will be added on word submit + if (!input.endsWith(" ") && !input.endsWith("\n")) { + inputLength += 1; // account for trigger char (space/newline) on word submit } let firstKeypressTime: number | undefined; @@ -403,7 +403,7 @@ export function getChars(countPartialLastWord = false): CharCounts { for (const [wordIndex, events] of eventsPerWordIndex.entries()) { const lastWord = wordIndex === activeWordIndex; - let simulatedInput = getSimulatedInput(events); + let simulatedInput = getInputFromDom(events); if (lastWord) { //remove trailing space for last word @@ -441,7 +441,7 @@ export function getChars(countPartialLastWord = false): CharCounts { export function getInputForWord(wordIndex: number): string { const events = getInputEventsForWord(wordIndex); - return getSimulatedInput(events).trimEnd(); + return getInputFromDom(events).trimEnd(); } export function getInputHistory(): string[] { @@ -451,7 +451,7 @@ export function getInputHistory(): string[] { const history: string[] = []; for (const events of eventsPerWordIndex.values()) { - const simulatedInput = getSimulatedInput(events); + const simulatedInput = getInputFromDom(events); history.push(simulatedInput.trimEnd()); } @@ -611,7 +611,7 @@ export function getWpmHistory(): number[] { >(); let maxWordIndex = 0; for (const [k, wordEvents] of eventsPerWord) { - const input = getSimulatedInput(wordEvents); + const input = getInputFromDom(wordEvents); wordInputs.set(k, { input, events: wordEvents }); // Only count words with non-empty input for maxWordIndex, // so that fully-deleted words don't prevent earlier words @@ -669,7 +669,7 @@ export function getRawHistory(): number[] { >(); let maxWordIndex = 0; for (const [k, wordEvents] of eventsPerWord) { - const input = getSimulatedInput(wordEvents); + const input = getInputFromDom(wordEvents); wordInputs.set(k, { input, events: wordEvents }); // Only count words with non-empty input for maxWordIndex, // so that fully-deleted words don't prevent earlier words diff --git a/frontend/src/ts/test/events/types.ts b/frontend/src/ts/test/events/types.ts index b2db3ac86c92..9b62d6b26e85 100644 --- a/frontend/src/ts/test/events/types.ts +++ b/frontend/src/ts/test/events/types.ts @@ -70,21 +70,23 @@ export type TimerEventData = export type InputEvent = EventProps<"input", InputEventData>; -export type InputEventData = { +type BaseInputEventData = { charIndex: number; wordIndex: number; -} & ( - | { + inputValue?: string; +}; + +export type InputEventData = + | (BaseInputEventData & { inputType: InsertInputType; data: string; correct: boolean; isCompositionEnding: boolean; inputStopped: boolean; - } - | { + }) + | (BaseInputEventData & { inputType: DeleteInputType; - } -); + }); export type CompositionTestEvent = EventProps< "composition", diff --git a/frontend/src/ts/test/funbox/funbox-functions.ts b/frontend/src/ts/test/funbox/funbox-functions.ts index 512975f1bd29..cc7f696e3a26 100644 --- a/frontend/src/ts/test/funbox/funbox-functions.ts +++ b/frontend/src/ts/test/funbox/funbox-functions.ts @@ -14,7 +14,7 @@ import { } from "../../states/notifications"; import * as DDR from "../../utils/ddr"; import * as TestWords from "../test-words"; -import * as TestInput from "../test-input"; +import { getCurrentInput } from "../events/data"; import * as LayoutfluidFunboxTimer from "./layoutfluid-funbox-timer"; import { highlight } from "../../events/keymap"; import * as MemoryTimer from "./memory-funbox-timer"; @@ -53,16 +53,17 @@ export type FunboxFunctions = { }; async function readAheadHandleKeydown(event: KeyboardEvent): Promise { - const inputCurrentChar = (TestInput.input.current ?? "").slice(-1); + const currentInput = getCurrentInput(); + const inputCurrentChar = currentInput.slice(-1); const wordCurrentChar = TestWords.words .getCurrentText() - .slice(TestInput.input.current.length - 1, TestInput.input.current.length); + .slice(currentInput.length - 1, currentInput.length); const isCorrect = inputCurrentChar === wordCurrentChar; if ( event.key === "Backspace" && !isCorrect && - (TestInput.input.current !== "" || + (currentInput !== "" || getInputForWord(TestState.activeWordIndex - 1) !== TestWords.words.getText(TestState.activeWordIndex - 1) || Config.freedomMode) @@ -453,9 +454,7 @@ const list: Partial> = { } setTimeout(() => { highlight( - TestWords.words - .getCurrentText() - .charAt(TestInput.input.current.length), + TestWords.words.getCurrentText().charAt(getCurrentInput().length), ); }, 1); } diff --git a/frontend/src/ts/test/test-input.ts b/frontend/src/ts/test/test-input.ts deleted file mode 100644 index 79a5aa7ddf87..000000000000 --- a/frontend/src/ts/test/test-input.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { getInputElementValue } from "../input/input-element"; - -class Input { - current: string; - // private history: string[]; - constructor() { - this.current = ""; - // this.history = []; - } - - reset(): void { - this.current = ""; - // this.history = []; - } - - syncWithInputElement(): void { - this.current = getInputElementValue().inputValue; - } -} - -export const input = new Input(); diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 8bfbb070f4bb..24067e33744e 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -40,7 +40,6 @@ import { setWordsHaveTab, } from "../states/test"; import { restartTestEvent } from "../events/test"; -import * as TestInput from "./test-input"; import * as TestWords from "./test-words"; import * as WordsGenerator from "./words-generator"; import * as TestState from "./test-state"; @@ -87,6 +86,7 @@ import { setQuoteLengthAll, toggleFunbox, setConfig } from "../config/setters"; import { resetTestEvents, cleanupData, + getCurrentInput, logEventsDataToTheConsoleTable, } from "./events/data"; import { @@ -432,8 +432,6 @@ async function init(): Promise { Replay.stopReplayRecording(); TestWords.words.reset(); TestState.setActiveWordIndex(0); - // TestInput.input.resetHistory(); - TestInput.input.current = ""; showLoaderBar(); const { data: language, error } = await tryCatch( @@ -902,8 +900,7 @@ export async function finish(difficultyFailed = false): Promise { // in case the tests ends with a keypress (not a word submission) // we need to push the current input to history - if (TestInput.input.current.length !== 0) { - // TestInput.input.pushHistory(); + if (getCurrentInput().length !== 0) { Replay.replayGetWordsList(getInputHistory()); } @@ -1054,6 +1051,8 @@ export async function finish(difficultyFailed = false): Promise { // test is valid + logEventsDataToTheConsoleTable(); + if (TestState.isRepeated || difficultyFailed) { if (Config.resultSaving) { const testSeconds = completedEvent.testDuration; diff --git a/frontend/src/ts/test/test-timer.ts b/frontend/src/ts/test/test-timer.ts index 32cb7e556716..2f2829ff67f2 100644 --- a/frontend/src/ts/test/test-timer.ts +++ b/frontend/src/ts/test/test-timer.ts @@ -6,7 +6,6 @@ import { setConfig } from "../config/setters"; import * as CustomText from "./custom-text"; import * as TimerProgress from "./timer-progress"; import * as LiveSpeed from "./live-speed"; -import * as TestInput from "./test-input"; import * as TestWords from "./test-words"; import * as Monkey from "./monkey"; import { @@ -26,7 +25,7 @@ import * as SoundController from "../controllers/sound-controller"; import { clearLowFpsMode, setLowFpsMode } from "../anim"; import { createTimer } from "animejs"; import { requestDebouncedAnimationFrame } from "../utils/debounced-animation-frame"; -import { logTestEvent } from "./events/data"; +import { getCurrentInput, logTestEvent } from "./events/data"; import { getCurrentAccuracy, getCurrentWpmAndRaw } from "./events/stats"; let lastLoop = 0; @@ -142,9 +141,7 @@ function layoutfluid(): void { if (Config.keymapMode === "next") { setTimeout(() => { highlight( - TestWords.words - .getCurrentText() - .charAt(TestInput.input.current.length), + TestWords.words.getCurrentText().charAt(getCurrentInput().length), ); }, 1); } @@ -193,7 +190,6 @@ function checkIfTimeIsUp(): void { //times up if (timer !== null) clearTimeout(timer); Caret.hide(); - // TestInput.input.pushHistory(); SlowTimer.clear(); slowTimerCount = 0; timerEvent.dispatch({ key: "finish" }); diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index 58b1d1dd4ba5..3c01671eee76 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -6,7 +6,7 @@ import { import { Config } from "../config/store"; import { setConfig } from "../config/setters"; import * as TestWords from "./test-words"; -import * as TestInput from "./test-input"; +import { getCurrentInput } from "./events/data"; import * as CustomText from "./custom-text"; import * as Caret from "./caret"; import * as OutOfFocus from "./out-of-focus"; @@ -1072,7 +1072,7 @@ export async function scrollTape(noAnimation = false): Promise { /* calculate current word width to add to #words margin */ let currentWordWidth = 0; - const inputLength = TestInput.input.current.length; + const inputLength = getCurrentInput().length; if (Config.tapeMode === "letter" && inputLength > 0) { const letters = activeWordEl.qsa("letter"); let lastPositiveLetterWidth = 0; @@ -1293,7 +1293,7 @@ function buildWordLettersHTML( }`; } } else { - if (inputCharacters[c] === TestInput.input.current) { + if (inputCharacters[c] === getCurrentInput()) { out += `${ wordCharacters[c] }`; @@ -1751,7 +1751,7 @@ function afterAnyTestInput( if (Config.keymapMode === "next") { highlight( - TestWords.words.getCurrentText().charAt(TestInput.input.current.length), + TestWords.words.getCurrentText().charAt(getCurrentInput().length), ); } @@ -1772,7 +1772,7 @@ export function afterTestTextInput( if (!increasedWordIndex) { void updateWordLetters({ - input: inputOverride ?? TestInput.input.current, + input: inputOverride ?? getCurrentInput(), wordIndex: TestState.activeWordIndex, compositionData: CompositionState.getData(), }); @@ -1783,7 +1783,7 @@ export function afterTestTextInput( export function afterTestCompositionUpdate(): void { void updateWordLetters({ - input: TestInput.input.current, + input: getCurrentInput(), wordIndex: TestState.activeWordIndex, compositionData: CompositionState.getData(), }); @@ -1793,7 +1793,7 @@ export function afterTestCompositionUpdate(): void { export function afterTestDelete(): void { void updateWordLetters({ - input: TestInput.input.current, + input: getCurrentInput(), wordIndex: TestState.activeWordIndex, compositionData: CompositionState.getData(), }); @@ -1823,7 +1823,7 @@ export function beforeTestWordChange( Config.strictSpace ) { void updateWordLetters({ - input: TestInput.input.current, + input: getCurrentInput(), wordIndex: TestState.activeWordIndex, compositionData: CompositionState.getData(), }); @@ -2058,7 +2058,7 @@ configEvent.subscribe(({ key, newValue }) => { if (key === "highlightMode") { if (getActivePage() === "test") { void updateWordLetters({ - input: TestInput.input.current, + input: getCurrentInput(), wordIndex: TestState.activeWordIndex, compositionData: CompositionState.getData(), }); From 4cbde4983cc49b1dc66f71d62b54021dd61e19e6 Mon Sep 17 00:00:00 2001 From: Miodec Date: Mon, 1 Jun 2026 13:55:33 +0200 Subject: [PATCH 28/41] yeet replay --- frontend/src/ts/index.ts | 2 - frontend/src/ts/input/handlers/delete.ts | 3 - frontend/src/ts/input/handlers/insert-text.ts | 2 - .../src/ts/input/helpers/word-navigation.ts | 9 - .../test/{replay.ts => events/replay-ui.ts} | 169 ++++++++++-------- frontend/src/ts/test/test-logic.ts | 20 +-- frontend/src/ts/test/test-screenshot.ts | 2 +- 7 files changed, 99 insertions(+), 108 deletions(-) rename frontend/src/ts/test/{replay.ts => events/replay-ui.ts} (66%) diff --git a/frontend/src/ts/index.ts b/frontend/src/ts/index.ts index 2e1ccac678b0..9021650cbd66 100644 --- a/frontend/src/ts/index.ts +++ b/frontend/src/ts/index.ts @@ -15,7 +15,6 @@ import * as DB from "./db"; import "./ui"; import "./controllers/ad-controller"; import { Config } from "./config/store"; -import * as Replay from "./test/replay"; import * as TestTimer from "./test/test-timer"; import * as Result from "./test/result"; import { onAuthStateChanged } from "./auth"; @@ -87,7 +86,6 @@ addToGlobal({ snapshot: DB.getSnapshot, config: Config, glarsesMode: enable, - replay: Replay.getReplayExport, enableTimerDebug: TestTimer.enableTimerDebug, getTimerStats: TestTimer.getTimerStats, toggleSmoothedBurst: Result.toggleSmoothedBurst, diff --git a/frontend/src/ts/input/handlers/delete.ts b/frontend/src/ts/input/handlers/delete.ts index f6e382c20056..20a4a267aea5 100644 --- a/frontend/src/ts/input/handlers/delete.ts +++ b/frontend/src/ts/input/handlers/delete.ts @@ -2,7 +2,6 @@ import * as TestUI from "../../test/test-ui"; import * as TestWords from "../../test/test-words"; import { getInputElementValue, setInputElementValue } from "../input-element"; -import * as Replay from "../../test/replay"; import { Config } from "../../config/store"; import { goToPreviousWord } from "../helpers/word-navigation"; import { DeleteInputType } from "../helpers/input-type"; @@ -17,8 +16,6 @@ export function onDelete(inputType: DeleteInputType, now: number): void { const inputAfterDelete = getInputElementValue().inputValue; - Replay.addReplayEvent("setLetterIndex", inputAfterDelete.length); - const beforeDeleteOnlyTabs = /^\t*$/.test(inputBeforeDelete); const allTabsCorrect = TestWords.words .getCurrentText() diff --git a/frontend/src/ts/input/handlers/insert-text.ts b/frontend/src/ts/input/handlers/insert-text.ts index 8e7ad6e722d1..53af39c1fdb9 100644 --- a/frontend/src/ts/input/handlers/insert-text.ts +++ b/frontend/src/ts/input/handlers/insert-text.ts @@ -18,7 +18,6 @@ import { findSingleActiveFunboxWithFunction, isFunboxActiveWithProperty, } from "../../test/funbox/list"; -import * as Replay from "../../test/replay"; import { Config } from "../../config/store"; import { flash } from "../../events/keymap"; import * as WeakSpot from "../../test/weak-spot"; @@ -152,7 +151,6 @@ export async function onInsertText(options: OnInsertTextParams): Promise { ((charIsSpace || charIsNewline) && !shouldInsertSpace) || noSpaceForce; // general per keypress updates - Replay.addReplayEvent(correct ? "correctLetter" : "incorrectLetter", data); WeakSpot.updateScore(data, correct); if (Config.keymapMode === "react") { flash(data, correct); diff --git a/frontend/src/ts/input/helpers/word-navigation.ts b/frontend/src/ts/input/helpers/word-navigation.ts index f52ec19e068f..783abaaed183 100644 --- a/frontend/src/ts/input/helpers/word-navigation.ts +++ b/frontend/src/ts/input/helpers/word-navigation.ts @@ -8,7 +8,6 @@ import { getActiveFunboxesWithFunction, isFunboxActiveWithProperty, } from "../../test/funbox/list"; -import * as Replay from "../../test/replay"; import * as Funbox from "../../test/funbox/funbox"; import { showLoaderBar, hideLoaderBar } from "../../states/loader-bar"; import { setInputElementValue } from "../input-element"; @@ -46,12 +45,6 @@ export async function goToNextWord({ isCompositionEnding || zenNewline === true, ); - if (correctInsert) { - Replay.addReplayEvent("submitCorrectWord"); - } else { - Replay.addReplayEvent("submitErrorWord"); - } - for (const fb of getActiveFunboxesWithFunction("handleSpace")) { fb.functions.handleSpace(); } @@ -100,8 +93,6 @@ export function goToPreviousWord( TestUI.beforeTestWordChange("back", null, forceUpdateActiveWordLetters); - Replay.addReplayEvent("backWord"); - TestState.decreaseActiveWordIndex(); const word = getInputForWord(TestState.activeWordIndex); diff --git a/frontend/src/ts/test/replay.ts b/frontend/src/ts/test/events/replay-ui.ts similarity index 66% rename from frontend/src/ts/test/replay.ts rename to frontend/src/ts/test/events/replay-ui.ts index f66d82fae4e2..bb3afcce893a 100644 --- a/frontend/src/ts/test/replay.ts +++ b/frontend/src/ts/test/events/replay-ui.ts @@ -1,7 +1,11 @@ -import * as Sound from "../controllers/sound-controller"; -import * as Arrays from "../utils/arrays"; -import { qs, qsr } from "../utils/dom"; -import { Config } from "../config/store"; +import * as Sound from "../../controllers/sound-controller"; +import * as Arrays from "../../utils/arrays"; +import { qs, qsr } from "../../utils/dom"; +import { Config } from "../../config/store"; +import * as TestWords from "../test-words"; +import { getInputEvents } from "./data"; +import { getInputHistory, getInputForWord } from "./stats"; + type ReplayAction = | "correctLetter" | "incorrectLetter" @@ -18,21 +22,84 @@ type Replay = { let wordsList: string[] = []; let replayData: Replay[] = []; -let replayStartTime = 0; -let replayRecording = true; let wordPos = 0; let curPos = 0; let targetWordPos = 0; let targetCurPos = 0; let timeoutList: NodeJS.Timeout[] = []; let stopwatchList: NodeJS.Timeout[] = []; -const toggleButton = document.getElementById("playpauseReplayButton") - ?.children[0]; + +const toggleButton = (): Element | undefined => + document.getElementById("playpauseReplayButton")?.children[0]; const replayEl = qsr(".pageTest #resultReplay"); -function replayGetWordsList(wordsListFromScript: string[]): void { - wordsList = wordsListFromScript; +function getWordsList(): string[] { + if (Config.mode === "zen") return getInputHistory(); + return TestWords.words.list.slice(); +} + +function deriveReplayActions(): Replay[] { + const events = getInputEvents(); + const actions: Replay[] = []; + let prevWordIndex: number | undefined; + + for (const event of events) { + const wi = event.data.wordIndex; + + if (prevWordIndex !== undefined && wi !== prevWordIndex) { + if (wi > prevWordIndex) { + const typed = getInputForWord(prevWordIndex); + const target = + Config.mode === "zen" + ? typed + : TestWords.words.getText(prevWordIndex); + const correct = typed === target; + actions.push({ + action: correct ? "submitCorrectWord" : "submitErrorWord", + time: event.testMs, + }); + } else { + actions.push({ action: "backWord", time: event.testMs }); + } + } + + if ( + event.data.inputType === "insertText" || + event.data.inputType === "insertCompositionText" + ) { + if (event.data.inputStopped) { + prevWordIndex = wi; + continue; + } + actions.push({ + action: event.data.correct ? "correctLetter" : "incorrectLetter", + value: event.data.data, + time: event.testMs, + }); + } else if ( + event.data.inputType === "deleteContentBackward" || + event.data.inputType === "deleteWordBackward" + ) { + if (prevWordIndex !== undefined && wi < prevWordIndex) { + // word transition already emitted backWord above + } else { + const newCharIndex = + event.data.inputValue !== undefined + ? event.data.inputValue.length + : event.data.charIndex; + actions.push({ + action: "setLetterIndex", + value: newCharIndex, + time: event.testMs, + }); + } + } + + prevWordIndex = wi; + } + + return actions; } function initializeReplayPrompt(): void { @@ -43,7 +110,6 @@ function initializeReplayPrompt(): void { replayWordsElement.innerHTML = ""; let wordCount = 0; replayData.forEach((item) => { - //trim wordsList for timed tests if (item.action === "backWord") { wordCount--; } else if ( @@ -78,13 +144,11 @@ export function pauseReplay(): void { targetCurPos = curPos; targetWordPos = wordPos; - if (toggleButton === undefined) return; + const btn = toggleButton(); + if (btn === undefined) return; - toggleButton.className = "fas fa-play"; - (toggleButton.parentNode as Element)?.setAttribute( - "aria-label", - "Resume replay", - ); + btn.className = "fas fa-play"; + (btn.parentNode as Element)?.setAttribute("aria-label", "Resume replay"); } function playSound(error = false): void { @@ -112,7 +176,6 @@ function handleDisplayLogic(item: Replay, nosound = false): void { if (!nosound) playSound(true); let myElement; if (curPos >= activeWord.children.length) { - //if letter is an extra myElement = document.createElement("letter"); myElement?.classList.add("extra"); myElement.innerHTML = item.value?.toString() ?? ""; @@ -127,7 +190,6 @@ function handleDisplayLogic(item: Replay, nosound = false): void { ) { if (!nosound) playSound(); curPos = item.value; - // remove all letters from cursor to end of word for (const myElement of [...activeWord.children].slice(curPos)) { if (myElement?.classList.contains("extra")) { myElement.remove(); @@ -169,7 +231,6 @@ function loadOldReplay(): number { wordPos < targetWordPos || (wordPos === targetWordPos && curPos < targetCurPos) ) { - //quickly display everything up to the target handleDisplayLogic(item, true); startingIndex = i + 1; } @@ -181,7 +242,7 @@ function loadOldReplay(): number { throw new Error("Failed to load old replay: datatime is undefined"); } - const time = Math.floor(datatime / 1000); + const time = Math.max(0, Math.floor(datatime / 1000)); updateStatsString(time); return startingIndex; @@ -189,14 +250,13 @@ function loadOldReplay(): number { function toggleReplayDisplay(): void { if (replayEl.isHidden()) { + refreshReplayFromEvents(); initializeReplayPrompt(); loadOldReplay(); - //show void replayEl.slideDown(250); } else { - //hide if ( - (toggleButton?.parentNode as Element)?.getAttribute("aria-label") !== + (toggleButton()?.parentNode as Element)?.getAttribute("aria-label") !== "Start replay" ) { pauseReplay(); @@ -205,27 +265,13 @@ function toggleReplayDisplay(): void { } } -function startReplayRecording(): void { - replayData = []; - replayStartTime = performance.now(); - replayRecording = true; +function refreshReplayFromEvents(): void { + wordsList = getWordsList(); + replayData = deriveReplayActions(); targetCurPos = 0; targetWordPos = 0; } -function stopReplayRecording(): void { - replayRecording = false; -} - -function addReplayEvent(action: ReplayAction, value?: number | string): void { - if (!replayRecording) { - return; - } - - const timeDelta = performance.now() - replayStartTime; - replayData.push({ action: action, value: value, time: timeDelta }); -} - function updateStatsString(time: number): void { const wpm = 0; const statsString = `${wpm}wpm\t${time}s`; @@ -236,13 +282,11 @@ function playReplay(): void { curPos = 0; wordPos = 0; - if (toggleButton === undefined) return; + const btn = toggleButton(); + if (btn === undefined) return; - toggleButton.className = "fas fa-pause"; - (toggleButton.parentNode as Element)?.setAttribute( - "aria-label", - "Pause replay", - ); + btn.className = "fas fa-pause"; + (btn.parentNode as Element)?.setAttribute("aria-label", "Pause replay"); initializeReplayPrompt(); const startingIndex = loadOldReplay(); const lastTime = replayData[startingIndex]?.time; @@ -251,7 +295,7 @@ function playReplay(): void { throw new Error("Failed to play replay: lastTime is undefined"); } - let swTime = Math.round(lastTime / 1000); //starting time + let swTime = Math.round(lastTime / 1000); const swEndTime = Math.round( (Arrays.lastElementFromArray(replayData) as Replay).time / 1000, ); @@ -278,37 +322,26 @@ function playReplay(): void { timeoutList.push( setTimeout( () => { - //after the replay has finished, this will run targetCurPos = 0; targetWordPos = 0; - toggleButton.className = "fas fa-play"; - (toggleButton.parentNode as Element).setAttribute( - "aria-label", - "Start replay", - ); + btn.className = "fas fa-play"; + (btn.parentNode as Element).setAttribute("aria-label", "Start replay"); }, (Arrays.lastElementFromArray(replayData) as Replay).time - lastTime, ), ); } -function getReplayExport(): string { - return JSON.stringify({ - replayData: replayData, - wordsList: wordsList, - }); -} - qs(".pageTest #playpauseReplayButton")?.on("click", () => { - if (toggleButton?.className === "fas fa-play") { + const btn = toggleButton(); + if (btn?.className === "fas fa-play") { playReplay(); - } else if (toggleButton?.className === "fas fa-pause") { + } else if (btn?.className === "fas fa-pause") { pauseReplay(); } }); qs("#replayWords")?.onChild("click", "letter", (event) => { - //allows user to click on the place they want to start their replay at pauseReplay(); const replayWords = qs("#replayWords"); @@ -328,11 +361,3 @@ qs("#replayWords")?.onChild("click", "letter", (event) => { qs(".pageTest")?.onChild("click", "#watchReplayButton", () => { toggleReplayDisplay(); }); - -export { - startReplayRecording, - stopReplayRecording, - addReplayEvent, - replayGetWordsList, - getReplayExport, -}; diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 24067e33744e..f68e55fec2b4 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -20,7 +20,7 @@ import * as PaceCaret from "./pace-caret"; import * as Caret from "./caret"; import * as TestTimer from "./test-timer"; import * as DB from "../db"; -import * as Replay from "./replay"; +import * as Replay from "./events/replay-ui"; import { __nonReactive } from "../collections/tags"; import * as TodayTracker from "./today-tracker"; import * as ChallengeContoller from "../controllers/challenge-controller"; @@ -86,7 +86,6 @@ import { setQuoteLengthAll, toggleFunbox, setConfig } from "../config/setters"; import { resetTestEvents, cleanupData, - getCurrentInput, logEventsDataToTheConsoleTable, } from "./events/data"; import { @@ -177,8 +176,6 @@ export function startTest(_now: number): boolean { } TestState.setActive(true); - Replay.startReplayRecording(); - Replay.replayGetWordsList(TestWords.words.list); Time.set(0); TestTimer.clear(); @@ -327,7 +324,6 @@ export function restart(options = {} as RestartOptions): void { AltTracker.reset(); Caret.hide(); TestState.setActive(false); - Replay.stopReplayRecording(); Replay.pauseReplay(); TestState.setBailedOut(false); Caret.resetPosition(); @@ -429,7 +425,6 @@ async function init(): Promise { return false; } - Replay.stopReplayRecording(); TestWords.words.reset(); TestState.setActiveWordIndex(0); @@ -898,24 +893,11 @@ export async function finish(difficultyFailed = false): Promise { TestState.setRepeated(false); } - // in case the tests ends with a keypress (not a word submission) - // we need to push the current input to history - if (getCurrentInput().length !== 0) { - Replay.replayGetWordsList(getInputHistory()); - } - - // in zen mode, ensure the replay words list reflects the typed input history - // even if the current input was empty at finish (e.g., after submitting a word). - if (Config.mode === "zen") { - Replay.replayGetWordsList(getInputHistory()); - } - forceReleaseAllKeys(); setResultVisible(true); TestState.setResultVisible(true); TestState.setActive(false); - Replay.stopReplayRecording(); cleanupData(); diff --git a/frontend/src/ts/test/test-screenshot.ts b/frontend/src/ts/test/test-screenshot.ts index acdf7fdf4f0c..f375d9f439eb 100644 --- a/frontend/src/ts/test/test-screenshot.ts +++ b/frontend/src/ts/test/test-screenshot.ts @@ -1,5 +1,5 @@ import { showLoaderBar, hideLoaderBar } from "../states/loader-bar"; -import * as Replay from "./replay"; +import * as Replay from "./events/replay-ui"; import { getActivePage, isAuthenticated, From b08aeb8b4a59f7d3af3a2ad08d7e86b2183157be Mon Sep 17 00:00:00 2001 From: Miodec Date: Mon, 1 Jun 2026 15:32:09 +0200 Subject: [PATCH 29/41] pass now from input to timer start --- frontend/src/ts/test/test-logic.ts | 4 ++-- frontend/src/ts/test/test-timer.ts | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index f68e55fec2b4..3996e6266ae2 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -164,7 +164,7 @@ export function setNotSignedInUidAndHash(uid: string): void { notSignedInLastResult.hash = objectHash(notSignedInLastResult); } -export function startTest(_now: number): boolean { +export function startTest(now: number): boolean { if (PageTransition.get()) { return false; } @@ -192,7 +192,7 @@ export function startTest(_now: number): boolean { } } catch (e) {} //use a recursive self-adjusting timer to avoid time drift - void TestTimer.start(); + void TestTimer.start(now); TestUI.onTestStart(); return true; } diff --git a/frontend/src/ts/test/test-timer.ts b/frontend/src/ts/test/test-timer.ts index 2f2829ff67f2..118ad2e6e554 100644 --- a/frontend/src/ts/test/test-timer.ts +++ b/frontend/src/ts/test/test-timer.ts @@ -287,20 +287,20 @@ function checkIfTimerIsSlow(drift: number): void { } } -export async function start(): Promise { +export async function start(now: number): Promise { SlowTimer.clear(); slowTimerCount = 0; for (const id of slowTimerNotifIds) { removeNotification(id, "clear"); } slowTimerNotifIds = []; - void _startNew(); + void _startNew(now); // void _startOld(); } -async function _startNew(): Promise { +async function _startNew(now: number): Promise { newTimer.play(); - logTestEvent("timer", performance.now(), { + logTestEvent("timer", now, { event: "start", timer: Time.get(), date: new Date().getTime(), From bb555f362b4aac701225d0531b5ec7fb81d93fc5 Mon Sep 17 00:00:00 2001 From: Miodec Date: Mon, 1 Jun 2026 15:33:15 +0200 Subject: [PATCH 30/41] bring command back --- frontend/src/ts/commandline/lists.ts | 42 +++++++++++++++++----------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/frontend/src/ts/commandline/lists.ts b/frontend/src/ts/commandline/lists.ts index fd4c83bffa46..72b86d0a5f3c 100644 --- a/frontend/src/ts/commandline/lists.ts +++ b/frontend/src/ts/commandline/lists.ts @@ -22,9 +22,11 @@ import * as getErrorMessage from "../utils/error"; import * as JSONData from "../utils/json-data"; import { randomizeTheme } from "../controllers/theme-controller"; import { showModal } from "../states/modals"; +import * as TestWords from "../test/test-words"; import { showErrorNotification, clearAllNotifications, + showSuccessNotification, } from "../states/notifications"; import * as VideoAdPopup from "../popups/video-ad-popup"; import { Command, CommandsSubgroup } from "./types"; @@ -38,6 +40,7 @@ import { showFpsCounter, } from "../components/layout/overlays/FpsCounter"; import { applyConfigFromJson } from "../config/lifecycle"; +import { getAllTestEvents } from "../test/events/data"; const challengesPromise = JSONData.getChallengeList(); challengesPromise @@ -286,23 +289,28 @@ export const commands: CommandsSubgroup = { alert(await caches.keys()); }, }, - // todo: bring back? - // { - // id: "copyResultStats", - // display: "Copy result stats", - // icon: "fa-cog", - // visible: false, - // exec: async (): Promise => { - // navigator.clipboard - // .writeText(JSON.stringify(TestStats.getStats())) - // .then(() => { - // showSuccessNotification("Copied to clipboard"); - // }) - // .catch((e: unknown) => { - // showErrorNotification("Failed to copy to clipboard", { error: e }); - // }); - // }, - // }, + { + id: "copyResultStats", + display: "Copy result data", + alias: "stats events", + icon: "fa-cog", + visible: false, + exec: async (): Promise => { + navigator.clipboard + .writeText( + JSON.stringify({ + events: getAllTestEvents(), + words: TestWords.words, + }), + ) + .then(() => { + showSuccessNotification("Copied to clipboard"); + }) + .catch((e: unknown) => { + showErrorNotification("Failed to copy to clipboard", { error: e }); + }); + }, + }, { id: "fpsCounter", display: "FPS counter...", From 999fb3bc340f65bdd64116d1f21f0f9a50fa4622 Mon Sep 17 00:00:00 2001 From: Miodec Date: Mon, 1 Jun 2026 15:42:49 +0200 Subject: [PATCH 31/41] just use active word index --- frontend/src/ts/test/timer-progress.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/frontend/src/ts/test/timer-progress.ts b/frontend/src/ts/test/timer-progress.ts index 89a41bc521e9..717748e9bbbc 100644 --- a/frontend/src/ts/test/timer-progress.ts +++ b/frontend/src/ts/test/timer-progress.ts @@ -2,7 +2,6 @@ import { Config } from "../config/store"; import * as CustomText from "./custom-text"; import * as DateTime from "../utils/date-and-time"; import * as TestWords from "./test-words"; -import * as TestInput from "./test-input"; import * as Time from "../legacy-states/time"; import * as TestState from "./test-state"; import { configEvent } from "../events/config"; @@ -111,12 +110,12 @@ function getCurrentCount(): number { 1 ); } else { - return TestInput.input.getHistory().length; + return TestState.activeWordIndex; } } function setTimerHtmlToInputLength(el: HTMLElement, wrapInDiv: boolean): void { - let historyLength = `${TestInput.input.getHistory().length}`; + let historyLength = `${TestState.activeWordIndex}`; if (wrapInDiv) { historyLength = `
${historyLength}
`; From eede7eedeb87d636df3545be78bfdfc5f81b6149 Mon Sep 17 00:00:00 2001 From: Miodec Date: Mon, 1 Jun 2026 15:45:02 +0200 Subject: [PATCH 32/41] always require param --- frontend/src/ts/test/test-logic.ts | 2 +- frontend/src/ts/test/test-ui.ts | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 337e19eb11c9..0cd9cb8a1bad 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -660,7 +660,7 @@ export function areAllTestWordsGenerated(): boolean { //add word during the test export async function addWord(): Promise { if (Config.mode === "zen") { - TestUI.appendEmptyWordElement(); + TestUI.appendEmptyWordElement(TestState.activeWordIndex + 1); return; } diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index ffc105d1dfda..cd1dec9b948d 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -493,7 +493,7 @@ function showWords(): void { wordsEl.setHtml(""); if (Config.mode === "zen") { - appendEmptyWordElement(); + appendEmptyWordElement(0); } else { let wordsHTML = ""; for (let i = 0; i < TestWords.words.length; i++) { @@ -509,9 +509,7 @@ function showWords(): void { PaceCaret.resetCaretPosition(); } -export function appendEmptyWordElement( - index = TestInput.input.getHistory().length, -): void { +export function appendEmptyWordElement(index: number): void { wordsEl.appendHtml( `
`, ); From ba8b8ac698194d8eafb1dafe46a7a75c75f7dc16 Mon Sep 17 00:00:00 2001 From: Miodec Date: Mon, 1 Jun 2026 15:46:07 +0200 Subject: [PATCH 33/41] accept now from input event --- frontend/src/ts/test/test-logic.ts | 2 +- frontend/src/ts/test/test-timer.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 0cd9cb8a1bad..5a46d90fa356 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -195,7 +195,7 @@ export function startTest(now: number): boolean { } catch (e) {} //use a recursive self-adjusting timer to avoid time drift TestStats.setStart(now); - void TestTimer.start(); + void TestTimer.start(now); TestUI.onTestStart(); return true; } diff --git a/frontend/src/ts/test/test-timer.ts b/frontend/src/ts/test/test-timer.ts index b7467e0dab62..849c4e269d7d 100644 --- a/frontend/src/ts/test/test-timer.ts +++ b/frontend/src/ts/test/test-timer.ts @@ -313,20 +313,20 @@ function checkIfTimerIsSlow(drift: number): void { } } -export async function start(): Promise { +export async function start(now: number): Promise { SlowTimer.clear(); slowTimerCount = 0; for (const id of slowTimerNotifIds) { removeNotification(id, "clear"); } slowTimerNotifIds = []; - void _startNew(); + void _startNew(now); // void _startOld(); } -async function _startNew(): Promise { +async function _startNew(now: number): Promise { newTimer.play(); - logTestEvent("timer", performance.now(), { + logTestEvent("timer", now, { event: "start", timer: Time.get(), }); From 580724ba3350945278f4639edf4eeca9755b2467 Mon Sep 17 00:00:00 2001 From: Miodec Date: Mon, 1 Jun 2026 15:48:11 +0200 Subject: [PATCH 34/41] move korean state to test state --- frontend/src/ts/test/test-input.ts | 10 ---------- frontend/src/ts/test/test-logic.ts | 4 ++-- frontend/src/ts/test/test-state.ts | 5 +++++ frontend/src/ts/test/test-stats.ts | 6 +++--- 4 files changed, 10 insertions(+), 15 deletions(-) diff --git a/frontend/src/ts/test/test-input.ts b/frontend/src/ts/test/test-input.ts index e74f8ba05b07..d1b0cf76139f 100644 --- a/frontend/src/ts/test/test-input.ts +++ b/frontend/src/ts/test/test-input.ts @@ -99,11 +99,9 @@ type ErrorHistoryObject = { class Input { current: string; private history: string[]; - koreanStatus: boolean; constructor() { this.current = ""; this.history = []; - this.koreanStatus = false; } reset(): void { @@ -115,14 +113,6 @@ class Input { this.history = []; } - setKoreanStatus(val: boolean): void { - this.koreanStatus = val; - } - - getKoreanStatus(): boolean { - return this.koreanStatus; - } - pushHistory(): void { this.history.push(this.current); this.current = ""; diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 5a46d90fa356..c2feda0e11f3 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -338,7 +338,7 @@ export function restart(options = {} as RestartOptions): void { TestState.setBailedOut(false); Caret.resetPosition(); PaceCaret.reset(); - TestInput.input.setKoreanStatus(false); + TestState.setKoreanStatus(false); clearQuoteStats(); CompositionState.setComposing(false); CompositionState.setData(""); @@ -599,7 +599,7 @@ async function init(): Promise { /[\uac00-\ud7af]|[\u1100-\u11ff]|[\u3130-\u318f]|[\ua960-\ua97f]|[\ud7b0-\ud7ff]/g, ) ) { - TestInput.input.setKoreanStatus(true); + TestState.setKoreanStatus(true); } for (let i = 0; i < generatedWords.length; i++) { diff --git a/frontend/src/ts/test/test-state.ts b/frontend/src/ts/test/test-state.ts index 82c27c083657..831c101d08ec 100644 --- a/frontend/src/ts/test/test-state.ts +++ b/frontend/src/ts/test/test-state.ts @@ -13,6 +13,11 @@ export let isDirectionReversed = false; export let testRestarting = false; export let resultVisible = false; export let resultCalculating = false; +export let koreanStatus = false; + +export function setKoreanStatus(val: boolean): void { + koreanStatus = val; +} export function setRepeated(tf: boolean): void { isRepeated = tf; diff --git a/frontend/src/ts/test/test-stats.ts b/frontend/src/ts/test/test-stats.ts index 9d7701fc45f7..807962f76b3e 100644 --- a/frontend/src/ts/test/test-stats.ts +++ b/frontend/src/ts/test/test-stats.ts @@ -179,7 +179,7 @@ export function setLastSecondNotRound(): void { } export function calculateBurst(endTime: number = performance.now()): number { - const containsKorean = TestInput.input.getKoreanStatus(); + const containsKorean = TestState.koreanStatus; const timeToWrite = (endTime - TestInput.currentBurstStart) / 1000; if (timeToWrite <= 0) return 0; let wordLength: number; @@ -213,7 +213,7 @@ export function removeAfkData(): void { } function getInputWords(): string[] { - const containsKorean = TestInput.input.getKoreanStatus(); + const containsKorean = TestState.koreanStatus; let inputWords = [...TestInput.input.getHistory()]; @@ -229,7 +229,7 @@ function getInputWords(): string[] { } function getTargetWords(): string[] { - const containsKorean = TestInput.input.getKoreanStatus(); + const containsKorean = TestState.koreanStatus; let targetWords = [ ...(Config.mode === "zen" From 47f076e78b4cecaa88da13c778a9aa23941521a6 Mon Sep 17 00:00:00 2001 From: Miodec Date: Mon, 1 Jun 2026 15:49:47 +0200 Subject: [PATCH 35/41] simpler --- frontend/src/ts/test/test-logic.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index c2feda0e11f3..ad43d55ff73e 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -674,7 +674,7 @@ export async function addWord(): Promise { const toPushCount = funboxToPush?.split(":")[1]; if (toPushCount !== undefined) bound = +toPushCount - 1; - if (TestWords.words.length - TestInput.input.getHistory().length > bound) { + if (TestWords.words.length - TestState.activeWordIndex > bound) { console.debug("Not adding word, enough words already"); return; } From 4478f513b67c451040e83d97ba2f147fbc3e6da6 Mon Sep 17 00:00:00 2001 From: Miodec Date: Mon, 1 Jun 2026 15:51:40 +0200 Subject: [PATCH 36/41] simpler --- frontend/src/ts/test/funbox/funbox-functions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/ts/test/funbox/funbox-functions.ts b/frontend/src/ts/test/funbox/funbox-functions.ts index 78377be3a229..484fc3bb2053 100644 --- a/frontend/src/ts/test/funbox/funbox-functions.ts +++ b/frontend/src/ts/test/funbox/funbox-functions.ts @@ -425,7 +425,7 @@ const list: Partial> = { const outOf: number = TestWords.words.length; const wordsPerLayout = Math.floor(outOf / layouts.length); const index = Math.floor( - (TestInput.input.getHistory().length + 1) / wordsPerLayout, + (TestState.activeWordIndex + 1) / wordsPerLayout, ); const mod = wordsPerLayout - ((TestState.activeWordIndex + 1) % wordsPerLayout); From 0ca74825cd9205494942655b4826ab929872d963 Mon Sep 17 00:00:00 2001 From: Miodec Date: Mon, 1 Jun 2026 15:58:32 +0200 Subject: [PATCH 37/41] accept dom value --- .../__tests__/test/events/helpers.spec.ts | 269 ++++++++++++++++-- frontend/src/ts/test/events/data.ts | 12 + frontend/src/ts/test/events/helpers.ts | 97 ++++++- frontend/src/ts/test/events/stats.ts | 12 +- frontend/src/ts/test/events/types.ts | 16 +- 5 files changed, 362 insertions(+), 44 deletions(-) diff --git a/frontend/__tests__/test/events/helpers.spec.ts b/frontend/__tests__/test/events/helpers.spec.ts index c79ec075c45d..6cd541f717b9 100644 --- a/frontend/__tests__/test/events/helpers.spec.ts +++ b/frontend/__tests__/test/events/helpers.spec.ts @@ -6,7 +6,9 @@ vi.mock("../../../src/ts/config/store", () => ({ })); import { - getSimulatedInput, + findInputValueMismatches, + getInputFromDom, + getInputFromEvents, getTestEventCode, } from "../../../src/ts/test/events/helpers"; import type { InputEvent } from "../../../src/ts/test/events/types"; @@ -91,65 +93,67 @@ function reset(): void { wordIndex = 0; } -describe("getSimulatedInput", () => { +describe("getInputFromEvents", () => { beforeEach(() => { reset(); }); it("builds string from insertText events", () => { - expect(getSimulatedInput([...insert("hello")])).toBe("hello"); + expect(getInputFromEvents([...insert("hello")])).toBe("hello"); }); it("builds string from insertText events with trailing space", () => { - expect(getSimulatedInput([...insert("hello ")])).toBe("hello "); + expect(getInputFromEvents([...insert("hello ")])).toBe("hello "); }); it("handles deleteContentBackward", () => { - expect(getSimulatedInput([...insert("abc"), ...deleteBackward()])).toBe( + expect(getInputFromEvents([...insert("abc"), ...deleteBackward()])).toBe( "ab", ); }); it("handles deleteContentBackward after space", () => { - expect(getSimulatedInput([...insert("abc "), ...deleteBackward()])).toBe( + expect(getInputFromEvents([...insert("abc "), ...deleteBackward()])).toBe( "abc", ); }); it("handles multiple deletes", () => { - expect(getSimulatedInput([...insert("ab"), ...deleteBackward(2)])).toBe(""); + expect(getInputFromEvents([...insert("ab"), ...deleteBackward(2)])).toBe( + "", + ); }); it("handles multiple deletes after space", () => { - expect(getSimulatedInput([...insert("ab "), ...deleteBackward(2)])).toBe( + expect(getInputFromEvents([...insert("ab "), ...deleteBackward(2)])).toBe( "a", ); }); it("handles deleteWordBackward", () => { - expect(getSimulatedInput([...insert("hello"), deleteWordBackward()])).toBe( + expect(getInputFromEvents([...insert("hello"), deleteWordBackward()])).toBe( "", ); }); it("handles deleteWordBackward after space", () => { - expect(getSimulatedInput([...insert("hello "), deleteWordBackward()])).toBe( - "", - ); + expect( + getInputFromEvents([...insert("hello "), deleteWordBackward()]), + ).toBe(""); }); it("returns empty string for no events", () => { - expect(getSimulatedInput([])).toBe(""); + expect(getInputFromEvents([])).toBe(""); }); it("handles deleteContentBackward on empty string", () => { const events = [...deleteBackward()]; - expect(getSimulatedInput(events)).toBe(""); + expect(getInputFromEvents(events)).toBe(""); }); it("skips inputStopped events", () => { expect( - getSimulatedInput([ + getInputFromEvents([ ...insert("he"), ...insert("x", "insertText", { inputStopped: true }), ...insert("llo"), @@ -157,21 +161,248 @@ describe("getSimulatedInput", () => { ).toBe("hello"); }); + it("handles deleteContentBackward within the same word correctly", () => { + expect(getInputFromEvents([...insert("a a"), deleteWordBackward()])).toBe( + "a ", + ); + }); + + it("handles deleteWordBackward with multiple internal spaces", () => { + expect( + getInputFromEvents([...insert("foo bar baz"), deleteWordBackward()]), + ).toBe("foo bar "); + }); + + it("handles deleteWordBackward with trailing space after multiple words", () => { + expect( + getInputFromEvents([...insert("foo bar "), deleteWordBackward()]), + ).toBe("foo "); + }); + + it("handles consecutive deleteWordBackward events", () => { + expect( + getInputFromEvents([ + ...insert("foo bar baz"), + deleteWordBackward(), + deleteWordBackward(), + ]), + ).toBe("foo "); + }); + + it("handles deleteWordBackward on empty string", () => { + expect(getInputFromEvents([deleteWordBackward()])).toBe(""); + }); + + it("handles deleteWordBackward on only whitespace", () => { + expect(getInputFromEvents([...insert(" "), deleteWordBackward()])).toBe( + "", + ); + }); + + it("ignores recorded inputValue (pure op-based simulation)", () => { + const events: InputEvent[] = [ + ...insert("hello"), + { + type: "input", + ms: 100, + testMs: 100, + data: { + inputType: "deleteWordBackward", + charIndex: 5, + wordIndex: 0, + inputValue: "RECORDED_BUT_IGNORED", + }, + }, + ]; + // pure simulation: deleteWordBackward on "hello" → "" + expect(getInputFromEvents(events)).toBe(""); + }); +}); + +describe("getInputFromDom", () => { + beforeEach(() => { + reset(); + }); + + it("falls through to op-based logic when inputValue is absent", () => { + expect(getInputFromDom([...insert("hello")])).toBe("hello"); + }); + + it("uses recorded inputValue when present, overriding op-based logic", () => { + const events: InputEvent[] = [ + ...insert("hello"), + { + type: "input", + ms: 100, + testMs: 100, + data: { + inputType: "deleteWordBackward", + charIndex: 5, + wordIndex: 0, + inputValue: "he", + }, + }, + ]; + // op-based would yield "", but inputValue is truth + expect(getInputFromDom(events)).toBe("he"); + }); + + it("uses latest event's inputValue across multiple recorded events", () => { + const events: InputEvent[] = [ + ...insert("hello"), + { + type: "input", + ms: 100, + testMs: 100, + data: { + inputType: "deleteContentBackward", + charIndex: 5, + wordIndex: 0, + inputValue: "hi", + }, + }, + ]; + expect(getInputFromDom(events)).toBe("hi"); + }); + + it("mixes captured and op-based across events", () => { + const events: InputEvent[] = [ + ...insert("ab"), // no inputValue, op = "ab" + { + type: "input", + ms: 100, + testMs: 100, + data: { + inputType: "insertText", + data: "c", + charIndex: 2, + wordIndex: 0, + correct: true, + isCompositionEnding: false, + inputStopped: false, + inputValue: "abc", + }, + }, + // next event has no inputValue, falls through to op (append "d") + { + type: "input", + ms: 110, + testMs: 110, + data: { + inputType: "insertText", + data: "d", + charIndex: 3, + wordIndex: 0, + correct: true, + isCompositionEnding: false, + inputStopped: false, + }, + }, + ]; + expect(getInputFromDom(events)).toBe("abcd"); + }); +}); + +describe("findInputValueMismatches", () => { + beforeEach(() => { + reset(); + }); + + it("returns empty when no events have recorded inputValue", () => { + expect(findInputValueMismatches([...insert("hello")])).toEqual([]); + }); + + it("returns empty when recorded values match derivation", () => { + const events: InputEvent[] = [ + { + type: "input", + ms: 10, + testMs: 10, + data: { + inputType: "insertText", + data: "a", + charIndex: 0, + wordIndex: 0, + correct: true, + isCompositionEnding: false, + inputStopped: false, + inputValue: "a", + }, + }, + { + type: "input", + ms: 20, + testMs: 20, + data: { + inputType: "insertText", + data: "b", + charIndex: 1, + wordIndex: 0, + correct: true, + isCompositionEnding: false, + inputStopped: false, + inputValue: "ab", + }, + }, + ]; + expect(findInputValueMismatches(events)).toEqual([]); + }); + + it("returns mismatches when recorded value differs from derivation", () => { + const events: InputEvent[] = [ + { + type: "input", + ms: 10, + testMs: 10, + data: { + inputType: "insertText", + data: "a", + charIndex: 0, + wordIndex: 0, + correct: true, + isCompositionEnding: false, + inputStopped: false, + inputValue: "DIFFERENT", + }, + }, + ]; + expect(findInputValueMismatches(events)).toEqual([ + { index: 0, derived: "a", recorded: "DIFFERENT" }, + ]); + }); + + it("skips events without inputValue, still tracks ones with it", () => { + const events: InputEvent[] = [ + ...insert("hello"), // no inputValue on these + { + type: "input", + ms: 100, + testMs: 100, + data: { + inputType: "deleteContentBackward", + charIndex: 5, + wordIndex: 0, + inputValue: "hell", + }, + }, + ]; + // derivation: "hello" then slice = "hell". Recorded = "hell". Match. + expect(findInputValueMismatches(events)).toEqual([]); + }); + // it("handles insertCompositionText events", () => { // const events = [ // ...insert("k", "insertCompositionText"), // ...insert("ka", "insertCompositionText"), // ]; - // expect(getSimulatedInput(events)).toBe("ka"); + // expect(getInputFromEvents(events)).toBe("ka"); // }); // it("handles composition followed by regular text", () => { - // const events = [ - // ...insert("k", "insertCompositionText"), // ...insert("ka", "insertCompositionText"), // ...insert("b"), // ]; - // expect(getSimulatedInput(events)).toBe("kab"); + // expect(getInputFromEvents(events)).toBe("kab"); // }); }); diff --git a/frontend/src/ts/test/events/data.ts b/frontend/src/ts/test/events/data.ts index ebda2b4c76ba..74bc64f01705 100644 --- a/frontend/src/ts/test/events/data.ts +++ b/frontend/src/ts/test/events/data.ts @@ -308,6 +308,18 @@ export function getPressedKeys(): Map< return pressedKeys; } +export function getInputEventsForWord(wordIndex: number): InputEvent[] { + const events = getAllTestEvents(); + const result: InputEvent[] = []; + for (const event of events) { + if (event.type !== "input") continue; + if (event.data.wordIndex === wordIndex) { + result.push(event); + } + } + return result; +} + export function getInputEventsPerWord( startMs?: number, testMsLimit?: number, diff --git a/frontend/src/ts/test/events/helpers.ts b/frontend/src/ts/test/events/helpers.ts index 415b4916520b..b5d7186f05e4 100644 --- a/frontend/src/ts/test/events/helpers.ts +++ b/frontend/src/ts/test/events/helpers.ts @@ -93,25 +93,92 @@ export function getTestEventCode(event: KeyboardEvent): Keycode | "NoCode" { return event.code as Keycode; } -export function getSimulatedInput(events: InputEvent[]): string { - let simulatedInput = ""; +export function applyOp(input: string, event: InputEvent): string { + if (event.data.inputType === "insertText") { + if (event.data.inputStopped) return input; + return input + event.data.data; + } + if (event.data.inputType === "insertCompositionText") { + if (event.data.inputStopped) return input; + return input + event.data.data; + } + if (event.data.inputType === "deleteContentBackward") { + return input.slice(0, -1); + } + if (event.data.inputType === "deleteWordBackward") { + return input.replace(/(?:\S+\s*|\s+)$/, ""); + } + return input; +} +/** + * Derives input by applying each event's operation in order. Ignores the + * recorded inputValue field. Use for verification, tests, or fallback — + * not as source of truth. + */ +export function getInputFromEvents(events: InputEvent[]): string { + let input = ""; for (const event of events) { - if (event.data.inputType === "insertText") { - if (event.data.inputStopped) continue; - simulatedInput += event.data.data; - } - if (event.data.inputType === "insertCompositionText") { - if (event.data.inputStopped) continue; - simulatedInput += event.data.data; - } - if (event.data.inputType === "deleteContentBackward") { - simulatedInput = simulatedInput.slice(0, -1); + input = applyOp(input, event); + } + return input; +} + +/** + * Reads input from the DOM snapshots captured on each event (inputValue), + * falling back to op-based derivation for events without a snapshot. + * Use this whenever you need the actual current/past input state. + * + * Walks backward to find the latest event with a captured inputValue, then + * replays any subsequent events forward — O(1) when the last event has a + * snapshot (the common case), O(n) worst case. + */ +export function getInputFromDom(events: InputEvent[]): string { + for (let i = events.length - 1; i >= 0; i--) { + const event = events[i] as InputEvent; + if (event.data.inputValue !== undefined) { + let input = event.data.inputValue; + for (let j = i + 1; j < events.length; j++) { + input = applyOp(input, events[j] as InputEvent); + } + return input; } - if (event.data.inputType === "deleteWordBackward") { - simulatedInput = ""; + } + return getInputFromEvents(events); +} + +export type InputValueMismatch = { + index: number; + derived: string; + recorded: string; +}; + +/** + * Compares event-derived input against the recorded DOM snapshot at each + * event. Returns the indices where event-derivation disagreed with what the + * DOM captured. Useful for catching op-logic bugs or capture-timing bugs. + */ +export function findInputValueMismatches( + events: InputEvent[], +): InputValueMismatch[] { + const mismatches: InputValueMismatch[] = []; + let derived = ""; + + for (let i = 0; i < events.length; i++) { + const event = events[i] as InputEvent; + derived = applyOp(derived, event); + + if ( + event.data.inputValue !== undefined && + event.data.inputValue !== derived + ) { + mismatches.push({ + index: i, + derived, + recorded: event.data.inputValue, + }); } } - return simulatedInput; + return mismatches; } diff --git a/frontend/src/ts/test/events/stats.ts b/frontend/src/ts/test/events/stats.ts index 3f8ae474ba7b..a56a34760352 100644 --- a/frontend/src/ts/test/events/stats.ts +++ b/frontend/src/ts/test/events/stats.ts @@ -1,6 +1,7 @@ import { getAllTestEvents, getInputEvents, + getInputEventsForWord, getInputEventsPerWord, getPressedKeys, logTestEvent, @@ -8,7 +9,7 @@ import { import * as TestWords from "../../test/test-words"; import { CharCounts, countChars, getLastChar } from "../../utils/strings"; import * as CustomText from "../../test/custom-text"; -import { getSimulatedInput } from "./helpers"; +import { getInputFromDom } from "./helpers"; import { activeWordIndex, bailedOut } from "../test-state"; import { calculateWpm } from "../../utils/numbers"; import { mean, roundTo2 } from "@monkeytype/util/numbers"; @@ -259,7 +260,7 @@ export function getChars(): CharCounts { for (const [wordIndex, events] of eventsPerWordIndex.entries()) { const lastWord = wordIndex === activeWordIndex; - let simulatedInput = getSimulatedInput(events); + let simulatedInput = getInputFromDom(events); if (lastWord) { //remove trailing space for last word @@ -295,6 +296,11 @@ export function getChars(): CharCounts { }; } +export function getInputForWord(wordIndex: number): string { + const events = getInputEventsForWord(wordIndex); + return getInputFromDom(events).trimEnd(); +} + export function getAccuracy(): { correct: number; incorrect: number; @@ -400,7 +406,7 @@ export function getWpmHistory(): number[] { >(); let maxWordIndex = 0; for (const [k, wordEvents] of eventsPerWord) { - const input = getSimulatedInput(wordEvents); + const input = getInputFromDom(wordEvents); wordInputs.set(k, { input, events: wordEvents }); // Only count words with non-empty input for maxWordIndex, // so that fully-deleted words don't prevent earlier words diff --git a/frontend/src/ts/test/events/types.ts b/frontend/src/ts/test/events/types.ts index 94712b8dac63..1d7b64370f94 100644 --- a/frontend/src/ts/test/events/types.ts +++ b/frontend/src/ts/test/events/types.ts @@ -69,21 +69,23 @@ export type TimerEventData = export type InputEvent = EventProps<"input", InputEventData>; -export type InputEventData = { +type BaseInputEventData = { charIndex: number; wordIndex: number; -} & ( - | { + inputValue?: string; +}; + +export type InputEventData = + | (BaseInputEventData & { inputType: InsertInputType; data: string; correct: boolean; isCompositionEnding: boolean; inputStopped: boolean; - } - | { + }) + | (BaseInputEventData & { inputType: DeleteInputType; - } -); + }); export type CompositionTestEvent = EventProps< "composition", From 61d880e6b44a61507cdcb2c7c104fa1c61f882eb Mon Sep 17 00:00:00 2001 From: Miodec Date: Mon, 1 Jun 2026 16:05:00 +0200 Subject: [PATCH 38/41] pass now through, log earlier --- frontend/src/ts/input/handlers/insert-text.ts | 29 ++++++++++++------- .../src/ts/input/helpers/word-navigation.ts | 4 ++- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/frontend/src/ts/input/handlers/insert-text.ts b/frontend/src/ts/input/handlers/insert-text.ts index 6145641c5a49..64a831d2557d 100644 --- a/frontend/src/ts/input/handlers/insert-text.ts +++ b/frontend/src/ts/input/handlers/insert-text.ts @@ -213,6 +213,24 @@ export async function onInsertText(options: OnInsertTextParams): Promise { TestInput.input.syncWithInputElement(); } + // capture DOM before goToNextWord clears it for the new word + const inputValueAfterEvent = TestInput.input.current; + + // Log the event BEFORE goToNextWord so readers inside the navigation + // (e.g. beforeTestWordChange's updateWordLetters, getWordBurst) see the + // completed event in derivation. Otherwise the just-typed trigger char + // (space/newline) is missing — visible as missing \n element in zen mode. + logTestEvent("input", now, { + inputType: "insertText", + data, + correct, + wordIndex, + charIndex: testInput.length, + isCompositionEnding: isCompositionEnding === true, + inputStopped: removeLastChar, + inputValue: inputValueAfterEvent, + }); + // going to next word let increasedWordIndex: null | boolean = null; let lastBurst: null | number = null; @@ -221,21 +239,12 @@ export async function onInsertText(options: OnInsertTextParams): Promise { correctInsert: correct, isCompositionEnding: isCompositionEnding === true, zenNewline: charIsNewline && Config.mode === "zen", + now, }); lastBurst = result.lastBurst; increasedWordIndex = result.increasedWordIndex; } - logTestEvent("input", now, { - inputType: "insertText", - data, - correct, - wordIndex, - charIndex: testInput.length, - isCompositionEnding: isCompositionEnding === true, - inputStopped: removeLastChar, - }); - /* Probably a good place to explain what the heck is going on with all these space related variables: - spaceOrNewLine: did the user input a space or a new line? diff --git a/frontend/src/ts/input/helpers/word-navigation.ts b/frontend/src/ts/input/helpers/word-navigation.ts index 897d115e65c0..27ec9c8fec62 100644 --- a/frontend/src/ts/input/helpers/word-navigation.ts +++ b/frontend/src/ts/input/helpers/word-navigation.ts @@ -22,6 +22,7 @@ type GoToNextWordParams = { // this is used to tell test ui to update the word before moving to the next word (in case of a composition that ends with a space) isCompositionEnding: boolean; zenNewline?: boolean; + now: number; }; type GoToNextWordReturn = { @@ -33,6 +34,7 @@ export async function goToNextWord({ correctInsert, isCompositionEnding, zenNewline, + now, }: GoToNextWordParams): Promise { const ret = { increasedWordIndex: false, @@ -56,7 +58,7 @@ export async function goToNextWord({ } //burst calculation and fail - const burst: number = TestStats.calculateBurst(); + const burst: number = TestStats.calculateBurst(now); TestInput.pushBurstToHistory(burst); ret.lastBurst = burst; From 21e41461f948f58b3a2d944cf9722d1d0a8c59be Mon Sep 17 00:00:00 2001 From: Miodec Date: Mon, 1 Jun 2026 16:11:51 +0200 Subject: [PATCH 39/41] start time --- frontend/src/ts/test/events/data.ts | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/frontend/src/ts/test/events/data.ts b/frontend/src/ts/test/events/data.ts index 74bc64f01705..f58938c61be2 100644 --- a/frontend/src/ts/test/events/data.ts +++ b/frontend/src/ts/test/events/data.ts @@ -14,7 +14,6 @@ import { TimerEventData, } from "./types"; import { keysToTrack } from "./helpers"; -import { start } from "../test-stats"; import { Keycode } from "../../constants/keys"; import { roundTo2 } from "@monkeytype/util/numbers"; import { resultCalculating } from "../test-state"; @@ -222,6 +221,9 @@ export function cleanupData(): void { export function getAllTestEvents(): TestEvent[] { if (cachedAllEvents !== undefined) return cachedAllEvents; + const startEventMs = + timerEvents.find((e) => e.data.event === "start")?.ms ?? 0; + // cachedAllEvents = testData300; // return cachedAllEvents; cachedAllEvents = [ @@ -237,7 +239,7 @@ export function getAllTestEvents(): TestEvent[] { (a.type === "timer" ? 1 : 0) - (b.type === "timer" ? 1 : 0), ) .map((event) => { - event.testMs = roundTo2(event.ms - start); + event.testMs = roundTo2(event.ms - startEventMs); return event; }); @@ -339,18 +341,7 @@ export function getInputEventsPerWord( break; } - let wordIndex = event.data.wordIndex; - - //special case for delete events on the 0th index - // because they affect the previous word - so we need to attribute them to the previous word - if ( - (event.data.inputType === "deleteWordBackward" || - event.data.inputType === "deleteContentBackward") && - event.data.charIndex === 0 && - wordIndex > 0 - ) { - wordIndex -= 1; - } + const wordIndex = event.data.wordIndex; const existing = eventsPerWordIndex.get(wordIndex) ?? []; existing.push(event); From 92383be952fb41d05e939491a35238eeda1e4389 Mon Sep 17 00:00:00 2001 From: Miodec Date: Mon, 1 Jun 2026 16:13:32 +0200 Subject: [PATCH 40/41] delete events --- frontend/src/ts/input/handlers/delete.ts | 57 ++++++++++++++++++------ 1 file changed, 43 insertions(+), 14 deletions(-) diff --git a/frontend/src/ts/input/handlers/delete.ts b/frontend/src/ts/input/handlers/delete.ts index 8d2a44340a47..fa244fbe5454 100644 --- a/frontend/src/ts/input/handlers/delete.ts +++ b/frontend/src/ts/input/handlers/delete.ts @@ -18,6 +18,8 @@ export function onDelete(inputType: DeleteInputType, now: number): void { TestInput.input.syncWithInputElement(); + const inputAfterDelete = TestInput.input.current; + Replay.addReplayEvent("setLetterIndex", TestInput.input.current.length); TestInput.setCurrentNotAfk(); @@ -33,25 +35,52 @@ export function onDelete(inputType: DeleteInputType, now: number): void { inputBeforeDelete.length > 0 && beforeDeleteOnlyTabs && allTabsCorrect - // (TestInput.input.getHistory(TestState.activeWordIndex - 1) !== - // TestWords.words.get(TestState.activeWordIndex - 1) || - // Config.freedomMode) ) { + // Clear N+1's tabs (the word the user was in) + logTestEvent("input", now, { + inputType: "deleteWordBackward", + wordIndex: activeWordIndexBeforeDelete, + charIndex: inputBeforeDelete.length, + inputValue: "", + }); + setInputElementValue(""); - TestInput.input.syncWithInputElement(); goToPreviousWord(inputType, true); - } else { - //normal backspace - if (realInputValue === "") { - goToPreviousWord(inputType); - } + + // Record the resulting state of the previous word (newline removed) + const postNavInputValue = getInputElementValue().inputValue; + logTestEvent("input", now, { + inputType: "deleteContentBackward", + wordIndex: activeWordIndex, + charIndex: postNavInputValue.length, + inputValue: postNavInputValue, + }); + + TestUI.afterTestDelete(); + return; } - logTestEvent("input", now, { - inputType: inputType, - wordIndex: activeWordIndexBeforeDelete, - charIndex: inputBeforeDelete.length, - }); + //normal backspace + if (realInputValue === "") { + goToPreviousWord(inputType); + + // Record the resulting state of the destination word + const postNavInputValue = getInputElementValue().inputValue; + logTestEvent("input", now, { + inputType: inputType, + wordIndex: activeWordIndex, + charIndex: postNavInputValue.length, + inputValue: postNavInputValue, + }); + } else { + // Delete within current word + logTestEvent("input", now, { + inputType: inputType, + wordIndex: activeWordIndexBeforeDelete, + charIndex: inputBeforeDelete.length, + inputValue: inputAfterDelete, + }); + } TestUI.afterTestDelete(); } From e44ff3bc9ff6bee111b3ffb96df18f95ab15cac4 Mon Sep 17 00:00:00 2001 From: Miodec Date: Mon, 1 Jun 2026 16:13:53 +0200 Subject: [PATCH 41/41] early --- frontend/src/ts/input/handlers/before-delete.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/frontend/src/ts/input/handlers/before-delete.ts b/frontend/src/ts/input/handlers/before-delete.ts index 3dc7c77a1c73..85ddc1c7e55a 100644 --- a/frontend/src/ts/input/handlers/before-delete.ts +++ b/frontend/src/ts/input/handlers/before-delete.ts @@ -28,6 +28,12 @@ export function onBeforeDelete(event: InputEvent): void { const inputIsEmpty = inputValue === ""; if (inputIsEmpty) { + // we are on the first word, just prevent default, nothing to go back to + if (TestState.activeWordIndex === 0) { + event.preventDefault(); + return; + } + // this is nested because we only wanna pull the element from the dom if needed const previousWordElement = TestUI.getWordElement( TestState.activeWordIndex - 1,