From 3c4296ae2e2ec1a60b7d9a2b78215705c1d1f532 Mon Sep 17 00:00:00 2001 From: Miodec Date: Wed, 3 Jun 2026 12:59:02 +0200 Subject: [PATCH 1/6] chore: fix korean calculation --- frontend/src/ts/test/events/stats.ts | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/frontend/src/ts/test/events/stats.ts b/frontend/src/ts/test/events/stats.ts index aed9388f442a..03a8c93e7068 100644 --- a/frontend/src/ts/test/events/stats.ts +++ b/frontend/src/ts/test/events/stats.ts @@ -10,12 +10,13 @@ import * as TestWords from "../../test/test-words"; import { CharCounts, countChars, getLastChar } from "../../utils/strings"; import * as CustomText from "../../test/custom-text"; import { getInputFromDom } from "./helpers"; -import { activeWordIndex, bailedOut } from "../test-state"; +import { activeWordIndex, bailedOut, koreanStatus } from "../test-state"; import { calculateWpm } from "../../utils/numbers"; import { mean, roundTo2 } from "@monkeytype/util/numbers"; import { InputEvent, TestEvent } from "./types"; import { Config } from "../../config/store"; import { isFunboxActiveWithProperty } from "../funbox/list"; +import Hangul from "hangul-js"; function getTimerBoundaries(events: TestEvent[]): number[] { const boundaries: number[] = []; @@ -273,12 +274,20 @@ export function getChars(): CharCounts { let simulatedInput = getInputFromDom(events); + if (koreanStatus) { + simulatedInput = Hangul.disassemble(simulatedInput).join(""); + } + if (lastWord) { //remove trailing space for last word simulatedInput = simulatedInput.trimEnd(); } - const targetWord = getTargetWord(wordIndex, simulatedInput, lastWord); + let targetWord = getTargetWord(wordIndex, simulatedInput, lastWord); + + if (koreanStatus) { + targetWord = Hangul.disassemble(targetWord).join(""); + } const charCounts = countChars( simulatedInput, @@ -426,7 +435,8 @@ export function getWpmHistory(): number[] { } let totalCorrect = 0; - for (const [wordIndex, { input, events: wordEvents }] of wordInputs) { + for (const [wordIndex, { input: inp, events: wordEvents }] of wordInputs) { + let input = inp; if (input.length === 0) continue; const lastEvt = wordEvents[wordEvents.length - 1]; @@ -440,8 +450,15 @@ export function getWpmHistory(): number[] { } const lastWord = wordIndex === adjustedMax; + if (koreanStatus) { + input = Hangul.disassemble(input).join(""); + } + const trimmed = lastWord ? input.trimEnd() : input; - const targetWord = getTargetWord(wordIndex, trimmed, lastWord); + let targetWord = getTargetWord(wordIndex, trimmed, lastWord); + if (koreanStatus) { + targetWord = Hangul.disassemble(targetWord).join(""); + } totalCorrect += countChars( trimmed, targetWord, From 532320b37e39d8edcaf3c6441e69072b416e0cbe Mon Sep 17 00:00:00 2001 From: Miodec Date: Wed, 3 Jun 2026 17:03:27 +0200 Subject: [PATCH 2/6] chore: refactor character counting logic and improve active word inference --- frontend/__tests__/test/events/stats.spec.ts | 1 + frontend/src/ts/test/events/stats.ts | 149 ++++++++----------- 2 files changed, 64 insertions(+), 86 deletions(-) diff --git a/frontend/__tests__/test/events/stats.spec.ts b/frontend/__tests__/test/events/stats.spec.ts index bffcc1fbafee..f600d03db16f 100644 --- a/frontend/__tests__/test/events/stats.spec.ts +++ b/frontend/__tests__/test/events/stats.spec.ts @@ -8,6 +8,7 @@ vi.mock("../../../src/ts/test/test-state", () => ({ activeWordIndex: 0, bailedOut: false, resultCalculating: false, + koreanStatus: false, })); vi.mock("../../../src/ts/config/store", () => ({ diff --git a/frontend/src/ts/test/events/stats.ts b/frontend/src/ts/test/events/stats.ts index 03a8c93e7068..7e7270a19b6c 100644 --- a/frontend/src/ts/test/events/stats.ts +++ b/frontend/src/ts/test/events/stats.ts @@ -256,64 +256,86 @@ function getTargetWord( } } -export function getChars(): CharCounts { - const eventsPerWordIndex = getInputEventsPerWord(); - const isTimedTest = - Config.mode === "time" || - (Config.mode === "custom" && CustomText.getLimit().mode === "time"); - const shouldCountPartialLastWord = isTimedTest; - - let allCorrect = 0; - let correctWord = 0; - let incorrect = 0; - let extra = 0; - let missed = 0; +function countCharsForWords( + eventsPerWord: Map, + lastWordIndex: number, + shouldCountPartialLastWord: boolean, +): CharCounts { + const acc: CharCounts = { + allCorrect: 0, + correctWord: 0, + incorrect: 0, + extra: 0, + missed: 0, + }; - for (const [wordIndex, events] of eventsPerWordIndex.entries()) { - const lastWord = wordIndex === activeWordIndex; + for (const [wordIndex, events] of eventsPerWord) { + const lastWord = wordIndex === lastWordIndex; let simulatedInput = getInputFromDom(events); - if (koreanStatus) { simulatedInput = Hangul.disassemble(simulatedInput).join(""); } - if (lastWord) { - //remove trailing space for last word simulatedInput = simulatedInput.trimEnd(); } let targetWord = getTargetWord(wordIndex, simulatedInput, lastWord); - if (koreanStatus) { targetWord = Hangul.disassemble(targetWord).join(""); } - const charCounts = countChars( + const c = countChars( simulatedInput, targetWord, lastWord, shouldCountPartialLastWord, ); + acc.allCorrect += c.allCorrect; + acc.correctWord += c.correctWord; + acc.incorrect += c.incorrect; + acc.extra += c.extra; + acc.missed += c.missed; - allCorrect += charCounts.allCorrect; - correctWord += charCounts.correctWord; - incorrect += charCounts.incorrect; - extra += charCounts.extra; - missed += charCounts.missed; + if (lastWord) break; + } - if (lastWord) { - break; + return acc; +} + +function inferActiveWordIndex( + eventsPerWord: Map, +): number { + let maxWordIndex = -1; + let lastWordEvents: InputEvent[] | undefined; + for (const [k, wordEvents] of eventsPerWord) { + if (getInputFromDom(wordEvents).length > 0 && k > maxWordIndex) { + maxWordIndex = k; + lastWordEvents = wordEvents; } } + if (lastWordEvents === undefined) return 0; + const lastEvt = lastWordEvents[lastWordEvents.length - 1]; + // committed trailing space → cursor advanced to the next word + if ( + lastEvt !== undefined && + lastEvt.data.inputType === "insertText" && + lastEvt.data.data === " " + ) { + return maxWordIndex + 1; + } + return maxWordIndex; +} - return { - allCorrect: allCorrect, - correctWord: correctWord, - incorrect: incorrect, - extra: extra, - missed: missed, - }; +export function getChars(): CharCounts { + const isTimedTest = + Config.mode === "time" || + (Config.mode === "custom" && CustomText.getLimit().mode === "time"); + return countCharsForWords( + getInputEventsPerWord(), + activeWordIndex, + isTimedTest, + ); } export function getInputForWord(wordIndex: number): string { @@ -413,62 +435,17 @@ export function getErrorCountHistory(): number[] { export function getWpmHistory(): number[] { const events = getAllTestEvents(); - const timerBoundaries = getTimerBoundaries(events); const wpmHistory: number[] = []; - for (const boundary of timerBoundaries) { + for (const boundary of getTimerBoundaries(events)) { 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 = 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 - // from being treated as the last word - if (input.length > 0 && k > maxWordIndex) maxWordIndex = k; - } - - let totalCorrect = 0; - for (const [wordIndex, { input: inp, events: wordEvents }] of wordInputs) { - let input = inp; - 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; - - if (koreanStatus) { - input = Hangul.disassemble(input).join(""); - } - - const trimmed = lastWord ? input.trimEnd() : input; - let targetWord = getTargetWord(wordIndex, trimmed, lastWord); - if (koreanStatus) { - targetWord = Hangul.disassemble(targetWord).join(""); - } - totalCorrect += countChars( - trimmed, - targetWord, - lastWord, - true, - ).correctWord; - } - - const durationSeconds = boundary / 1000; - wpmHistory.push(Math.round(calculateWpm(totalCorrect, durationSeconds))); + const lastWordIndex = inferActiveWordIndex(eventsPerWord); + const { correctWord } = countCharsForWords( + eventsPerWord, + lastWordIndex, + true, + ); + wpmHistory.push(Math.round(calculateWpm(correctWord, boundary / 1000))); } return wpmHistory; From 109a57828049c50c1e94a010f6a443cf91d90cdf Mon Sep 17 00:00:00 2001 From: Miodec Date: Wed, 3 Jun 2026 17:23:44 +0200 Subject: [PATCH 3/6] chore: different log for length mismatch --- frontend/src/ts/test/test-logic.ts | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index c3685172aa69..07733b33ba0e 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -1114,13 +1114,21 @@ function compareCompletedEvents( 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, - ); + if (a.length !== b.length) { + notMatching.push(`keypressCountHistory (length differs)`); + mismatchedKeys.push("keypressCountHistory_length"); + console.error( + `Completed event length mismatch on key keypressCountHistory: ${a.length} vs ${b.length}`, + ); + } else { + notMatching.push(`keypressCountHistory (values differ)`); + mismatchedKeys.push("keypressCountHistory"); + console.error( + `Completed event mismatch on key keypressCountHistory:`, + a, + b, + ); + } } } From f55e792cf501b44af06b40bc57d330f2840a1f87 Mon Sep 17 00:00:00 2001 From: Miodec Date: Wed, 3 Jun 2026 17:31:13 +0200 Subject: [PATCH 4/6] chore: add boundary checks for timer gaps in getTimerBoundaries function --- frontend/__tests__/test/events/stats.spec.ts | 36 ++++++++++++++++++++ frontend/src/ts/test/events/stats.ts | 6 +++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/frontend/__tests__/test/events/stats.spec.ts b/frontend/__tests__/test/events/stats.spec.ts index f600d03db16f..4f584e34e305 100644 --- a/frontend/__tests__/test/events/stats.spec.ts +++ b/frontend/__tests__/test/events/stats.spec.ts @@ -181,6 +181,28 @@ describe("stats.ts", () => { expect(statsTesting.getTimerBoundaries(events)).toEqual([1000]); }); + it("includes end boundary when gap rounds to 0.5s via roundTo2", () => { + // 496ms gap: roundTo2(0.496) = 0.5 so this should be treated as a 0.5s remainder + logTestEvent("timer", 1000, timer("start", 0)); + logTestEvent("timer", 2000, timer("step", 1)); + logTestEvent("timer", 2496, timer("end", 1)); + + const events = getAllTestEvents(); + // end testMs=1496, last step testMs=1000 — gap 496ms rounds to 0.50s + expect(statsTesting.getTimerBoundaries(events)).toEqual([1000, 1496]); + }); + + it("skips end boundary when gap rounds below 0.5s via roundTo2", () => { + // 494ms gap: roundTo2(0.494) = 0.49 so this should not be an extra boundary + logTestEvent("timer", 1000, timer("start", 0)); + logTestEvent("timer", 2000, timer("step", 1)); + logTestEvent("timer", 2494, timer("end", 1)); + + const events = getAllTestEvents(); + // end testMs=1494, last step testMs=1000 — gap 494ms rounds to 0.49s + expect(statsTesting.getTimerBoundaries(events)).toEqual([1000]); + }); + it("excludes short trailing interval (<500ms) for non-round test duration", () => { // 1.35s test: step at 1s, end at 1.35s — remainder 350ms < 500 logTestEvent("timer", 1000, timer("start", 0)); @@ -502,6 +524,20 @@ describe("stats.ts", () => { logTestEvent("input", 1200, input()); expect(getKeypressesPerSecond()).toEqual([]); }); + + it("counts keypresses in last partial second when gap rounds to 0.5s", () => { + // mirrors the totalKeypressCountHistory mismatch: legacy pushes for roundTo2 >= 0.5, + // but the old boundary check (>= 500ms) skips a 496ms tail + logTestEvent("timer", 1000, timer("start", 0)); + logTestEvent("input", 1200, input()); // first second + logTestEvent("timer", 2000, timer("step", 1)); + logTestEvent("input", 2200, input({ charIndex: 1 })); // 496ms tail + logTestEvent("input", 2400, input({ charIndex: 2 })); + logTestEvent("timer", 2496, timer("end", 1)); + + // gap = 496ms, roundTo2(0.496) = 0.5 → end boundary added → [1, 2] + expect(getKeypressesPerSecond()).toEqual([1, 2]); + }); }); describe("getTargetWord", () => { diff --git a/frontend/src/ts/test/events/stats.ts b/frontend/src/ts/test/events/stats.ts index 7e7270a19b6c..8972823aadd0 100644 --- a/frontend/src/ts/test/events/stats.ts +++ b/frontend/src/ts/test/events/stats.ts @@ -48,7 +48,11 @@ function getTimerBoundaries(events: TestEvent[]): number[] { if (endMs !== undefined) { const last = boundaries[boundaries.length - 1]; - if (endMs - (last ?? 0) >= 500) { + // Must match the legacy condition: Math.round(roundTo2(testSeconds) % 1) >= 0.5. + // A naive ">= 500ms" check disagrees when the gap is in [495ms, 500ms) — roundTo2 + // rounds that fraction up to 0.50s and the legacy system pushes an extra bucket, + // but a raw millisecond comparison would skip the boundary. + if (roundTo2((endMs - (last ?? 0)) / 1000) >= 0.5) { boundaries.push(endMs); } } From eb20f7beea327716519e81b7800feb5bb79fc2ff Mon Sep 17 00:00:00 2001 From: Miodec Date: Wed, 3 Jun 2026 17:45:22 +0200 Subject: [PATCH 5/6] chore: add version property --- frontend/src/ts/test/test-logic.ts | 1 + packages/contracts/src/results.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 07733b33ba0e..18d51ac36017 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -1222,6 +1222,7 @@ function compareCompletedEvents( difficulty: ce.difficulty, duration: ce.testDuration, funboxes: getActiveFunboxNames().join(","), + version: 1, // ce: ce as Record, // ce2: ce2 as Record, }, diff --git a/packages/contracts/src/results.ts b/packages/contracts/src/results.ts index 1c4ff5900d3d..c693f3c54f4e 100644 --- a/packages/contracts/src/results.ts +++ b/packages/contracts/src/results.ts @@ -75,6 +75,7 @@ export const ReportCompletedEventMismatchRequestSchema = z.object({ difficulty: DifficultySchema.optional(), duration: z.number().max(200).optional(), funboxes: z.string().max(100).optional(), + version: z.number(), // ce: z.record(z.unknown()), // ce2: z.record(z.unknown()), }); From c0facdbd17b2f959a803797639442e14b42b7d4b Mon Sep 17 00:00:00 2001 From: Miodec Date: Wed, 3 Jun 2026 18:25:27 +0200 Subject: [PATCH 6/6] chore: add version to log --- backend/src/api/controllers/result.ts | 2 ++ packages/contracts/src/results.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/src/api/controllers/result.ts b/backend/src/api/controllers/result.ts index 28fdf24b8aba..c119b5e5a969 100644 --- a/backend/src/api/controllers/result.ts +++ b/backend/src/api/controllers/result.ts @@ -199,6 +199,7 @@ export async function reportCompletedEventMismatch( difficulty, duration, funboxes, + version, } = req.body; // Logger.warning( // `Completed event mismatch for uid ${uid}: ${notMatching.join(", ")}`, @@ -217,6 +218,7 @@ export async function reportCompletedEventMismatch( difficulty, duration, funboxes, + version, }, uid, ); diff --git a/packages/contracts/src/results.ts b/packages/contracts/src/results.ts index c693f3c54f4e..aa4dfa0ff2c6 100644 --- a/packages/contracts/src/results.ts +++ b/packages/contracts/src/results.ts @@ -75,7 +75,7 @@ export const ReportCompletedEventMismatchRequestSchema = z.object({ difficulty: DifficultySchema.optional(), duration: z.number().max(200).optional(), funboxes: z.string().max(100).optional(), - version: z.number(), + version: z.literal(1), // ce: z.record(z.unknown()), // ce2: z.record(z.unknown()), });