diff --git a/backend/src/api/controllers/result.ts b/backend/src/api/controllers/result.ts index e2d61d6866a9..59396abbd705 100644 --- a/backend/src/api/controllers/result.ts +++ b/backend/src/api/controllers/result.ts @@ -43,7 +43,6 @@ import { GetResultsResponse, UpdateResultTagsRequest, UpdateResultTagsResponse, - ReportCompletedEventMismatchRequest, } from "@monkeytype/contracts/results"; import { CompletedEvent, @@ -185,48 +184,6 @@ export async function updateTags( }); } -export async function reportCompletedEventMismatch( - req: MonkeyRequest, -): Promise { - const { uid } = req.ctx.decodedToken; - const { - notMatching, - mismatchedKeys, - groupKey, - language, - mode, - mode2, - difficulty, - duration, - funboxes, - version, - eventLog, - } = req.body; - // Logger.warning( - // `Completed event mismatch for uid ${uid}: ${notMatching.join(", ")}`, - // ); - // Logger.warning(`Old CE: ${JSON.stringify(ce)}`); - // Logger.warning(`New CE: ${JSON.stringify(ce2)}`); - void addLog( - "completed_event_mismatch", - { - notMatching, - mismatchedKeys, - groupKey, - language, - mode, - mode2, - difficulty, - duration, - funboxes, - version, - eventLog, - }, - uid, - ); - return new MonkeyResponse("Mismatch reported", null); -} - export async function addResult( req: MonkeyRequest, ): Promise { diff --git a/backend/src/api/routes/results.ts b/backend/src/api/routes/results.ts index 2f512e7214e1..179aa7fc7cea 100644 --- a/backend/src/api/routes/results.ts +++ b/backend/src/api/routes/results.ts @@ -17,10 +17,6 @@ export default s.router(resultsContract, { updateTags: { handler: async (r) => callController(ResultController.updateTags)(r), }, - reportCompletedEventMismatch: { - handler: async (r) => - callController(ResultController.reportCompletedEventMismatch)(r), - }, deleteAll: { handler: async (r) => callController(ResultController.deleteAll)(r), }, diff --git a/frontend/__tests__/test/events/data.spec.ts b/frontend/__tests__/test/events/data.spec.ts index f419634b5d4f..3cd8f6625bb5 100644 --- a/frontend/__tests__/test/events/data.spec.ts +++ b/frontend/__tests__/test/events/data.spec.ts @@ -57,7 +57,7 @@ function timerData( if (event === "step") { return { event, timer, drift: 0 }; } - return { event, timer }; + return { event, timer, date: 0 }; } describe("data.ts", () => { @@ -285,18 +285,8 @@ describe("data.ts", () => { logTestEvent("input", 1010, inputData({ wordIndex: 0, charIndex: 0 })); logTestEvent("input", 1100, inputData({ wordIndex: 0, charIndex: 1 })); - const perWord = getEventsPerWord(getAllTestEvents(), undefined, 50); - expect(perWord.get(0)).toHaveLength(1); - }); - - it("respects startMs", () => { - logTestEvent("input", 1010, inputData({ wordIndex: 0, charIndex: 0 })); - logTestEvent("input", 1100, inputData({ wordIndex: 0, charIndex: 1 })); - const perWord = getEventsPerWord(getAllTestEvents(), 50); expect(perWord.get(0)).toHaveLength(1); - const first = perWord.get(0)?.[0]; - expect(first?.type === "input" && first.data.charIndex).toBe(1); }); }); diff --git a/frontend/__tests__/test/events/stats.spec.ts b/frontend/__tests__/test/events/stats.spec.ts index 20e8a6e7e666..15746a67b528 100644 --- a/frontend/__tests__/test/events/stats.spec.ts +++ b/frontend/__tests__/test/events/stats.spec.ts @@ -56,7 +56,6 @@ import { getBurstHistory, getTestDurationMs, getAccuracy, - getKeypressSpacing, getKeypressOverlap, getErrorCountHistory, getAfkDuration, @@ -66,6 +65,8 @@ import { getInputHistory, getWpmHistory, __testing as statsTesting, + getCorrectedWordsHistory, + getKeypressSpacing, } from "../../../src/ts/test/events/stats"; import type { InputEventData, @@ -137,7 +138,7 @@ function timer( ? { event, timer: timerVal, catchup: true } : { 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, @@ -1441,4 +1442,319 @@ describe("stats.ts", () => { expect(statsTesting.inferActiveWordIndex(eventsPerWord)).toBe(1); }); }); + + 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(getCorrectedWordsHistory(buildEventLog())).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(getCorrectedWordsHistory(buildEventLog())).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(getCorrectedWordsHistory(buildEventLog())).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(getCorrectedWordsHistory(buildEventLog())).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 = getCorrectedWordsHistory(buildEventLog()); + expect(result[0]).toEqual("ab"); + expect(result[1]).toEqual("xy"); + }); + + it("ignores the space that commits a word", () => { + 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" }), + ); + // committing space — must not appear in the corrected word + logTestEvent( + "input", + 1300, + input({ + charIndex: 4, + wordIndex: 0, + data: " ", + commitsWord: true, + }), + ); + + expect(getCorrectedWordsHistory(buildEventLog())).toEqual(["test"]); + }); + }); }); diff --git a/frontend/src/ts/commandline/lists.ts b/frontend/src/ts/commandline/lists.ts index 14a455c6441e..6780991f828f 100644 --- a/frontend/src/ts/commandline/lists.ts +++ b/frontend/src/ts/commandline/lists.ts @@ -24,11 +24,10 @@ import { randomizeTheme } from "../controllers/theme-controller"; import { showModal } from "../states/modals"; import { showErrorNotification, - showSuccessNotification, clearAllNotifications, + showSuccessNotification, } from "../states/notifications"; import * as VideoAdPopup from "../popups/video-ad-popup"; -import * as TestStats from "../test/test-stats"; import { Command, CommandlineListKey, CommandsSubgroup } from "./types"; import { buildCommandForConfigKey } from "./util"; import { CommandlineConfigMetadataObject } from "./commandline-metadata"; @@ -40,7 +39,7 @@ import { showFpsCounter, } from "../components/layout/overlays/FpsCounter"; import { applyConfigFromJson } from "../config/lifecycle"; -import { buildEventLog } from "../test/events/data"; +import { lastEventLog } from "../test/test-state"; const challengesPromise = JSONData.getChallengeList(); challengesPromise @@ -291,29 +290,16 @@ export const commands: CommandsSubgroup = { }, { 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: "copyResultData", - display: "Copy event log (result data)", + display: "Copy last event log (result data)", alias: "stats events", icon: "fa-cog", visible: false, + available: (): boolean => { + return lastEventLog !== null; + }, exec: async (): Promise => { navigator.clipboard - .writeText(JSON.stringify(buildEventLog())) + .writeText(JSON.stringify(lastEventLog)) .then(() => { showSuccessNotification("Copied to clipboard"); }) diff --git a/frontend/src/ts/commandline/lists/result-screen.ts b/frontend/src/ts/commandline/lists/result-screen.ts index 320fcbf98467..df7fe2a203ca 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 { getInputHistory } 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...", @@ -139,11 +139,16 @@ const commands: Command[] = [ display: "Copy words to clipboard", icon: "fa-copy", exec: (): void => { - const words = ( + if (TestState.lastEventLog === null) { + showErrorNotification("No event log found!"); + return; + } + + const inputHistory = getInputHistory(TestState.lastEventLog); + const words = Config.mode === "zen" - ? getInputHistory() - : TestWords.words.list.slice(0, getInputHistory().length) - ).join(" "); + ? inputHistory.join("") + : TestWords.words.list.slice(0, inputHistory.length).join(" "); navigator.clipboard.writeText(words).then( () => { diff --git a/frontend/src/ts/components/modals/EventLogViewerModal.tsx b/frontend/src/ts/components/modals/EventLogViewerModal.tsx index a9946ec8fe83..71ab910ac2dc 100644 --- a/frontend/src/ts/components/modals/EventLogViewerModal.tsx +++ b/frontend/src/ts/components/modals/EventLogViewerModal.tsx @@ -258,7 +258,7 @@ export function EventLogViewerModal(): JSXElement { @@ -826,9 +826,9 @@ function PreviewContent(props: { }; return ( -
+
{/* HEADER */} -
+
- {/* VIDEO VIEWER PANEL */} -
-
-
Viewer
-
+ {/* SIDE-BY-SIDE: video | words | events */} +
+ {/* VIDEO VIEWER PANEL */} +
+
+
Viewer
+
+ + +
+
+ + No video loaded +
+ } + > +
+ +
seekVideoMs(Number(e.currentTarget.value))} + class="w-full" /> - +
+
+ {currentFrameIndex() !== null + ? `${currentFrameIndex()} (${(videoFrameTimeMs() ?? 0).toFixed(2)}ms @ ${videoFps().toFixed(2)}fps)` + : "—"} +
+
+
-
- - No video loaded -
- } - > -
- -
- seekVideoMs(Number(e.currentTarget.value))} - class="w-full" - /> -
-
- {currentFrameIndex() !== null - ? `${currentFrameIndex()} (${(videoFrameTimeMs() ?? 0).toFixed(2)}ms @ ${videoFps().toFixed(2)}fps)` - : "—"}
-
+ + +
+
Marks
-
- - -
-
Marks
- +
+ )} + +
+
+ +
+ + {/* INSPECTOR: words */} +
+
Words
+
(wordsScrollEl = el)} + class="min-h-0 flex-1 overflow-auto rounded bg-bg" + > + + + + + + + + + + + {(word, i) => ( + - × - - + + + + + )} + + +
#targetinput
{i()}{visualizeWhitespace(word)} + {visualizeWhitespace(finalInputs[i()] ?? "")} +
+
+
+ + {/* INSPECTOR: events */} +
+
+
+ Events ({filteredEvents().length}/{props.ctx.events.length}) +
+
+ + {(t) => ( +
- +
(eventsScrollEl = el)} + class="min-h-0 flex-1 overflow-auto rounded bg-bg" + > + + + + + + + + + + + + {({ event, originalIndex }, i) => ( + currentMs() && "opacity-40", + )} + > + + + + + + )} + + +
timetypemarkdata
+ {event.testMs.toFixed(2)} + {event.type} + + + {JSON.stringify(event.data)} +
+
+
+
+ + {/* INSPECTOR: simulated input */} +
+
+ Simulated input +
+
+ {simulatedInput()} +
{/* TIMELINE PANEL */} -
+
Timeline
@@ -1104,179 +1282,6 @@ function PreviewContent(props: { marks={timelineMarks()} />
- - {/* INSPECTOR: simulated input */} -
-
- Simulated input -
-
- {simulatedInput()} -
-
- - {/* INSPECTOR: words */} -
-
Words
-
(wordsScrollEl = el)} - class="max-h-64 overflow-auto rounded bg-bg" - > - - - - - - - - - - - {(word, i) => ( - - - - - - )} - - -
#targetinput
{i()}{visualizeWhitespace(word)} - {visualizeWhitespace(finalInputs[i()] ?? "")} -
-
-
- - {/* INSPECTOR: events */} -
-
-
- Events ({filteredEvents().length}/{props.ctx.events.length}) -
-
- - {(t) => ( -
-
-
(eventsScrollEl = el)} - class="max-h-96 overflow-auto rounded bg-bg" - > - - - - - - - - - - - - {({ event, originalIndex }, i) => ( - currentMs() && "opacity-40", - )} - > - - - - - - )} - - -
timetypemarkdata
- {event.testMs.toFixed(2)} - {event.type} - - - {JSON.stringify(event.data)} -
-
-
); } diff --git a/frontend/src/ts/controllers/chart-controller.ts b/frontend/src/ts/controllers/chart-controller.ts index 572c5060ee24..636a0c51e09e 100644 --- a/frontend/src/ts/controllers/chart-controller.ts +++ b/frontend/src/ts/controllers/chart-controller.ts @@ -58,13 +58,14 @@ 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 { getWordIndexesForSecond } from "../test/events/stats"; +import { lastEventLog } from "../test/test-state"; export class ChartWithUpdateColors< TType extends ChartType = ChartType, @@ -271,11 +272,15 @@ export const result = new ChartWithUpdateColors< callbacks: { afterLabel: function (ti): string { if (prevTi === ti) return ""; + if (lastEventLog === null) return ""; + prevTi = ti; try { const keypressIndex = Math.round(parseFloat(ti.label)) - 1; - const wordsToHighlight = - TestInput.errorHistory[keypressIndex]?.words; + const wordsToHighlight = getWordIndexesForSecond( + lastEventLog, + keypressIndex, + ); const unique = [...new Set(wordsToHighlight)]; const firstHighlightWordIndex = unique[0]; diff --git a/frontend/src/ts/index.ts b/frontend/src/ts/index.ts index b9e848c87352..a358f76bfdb1 100644 --- a/frontend/src/ts/index.ts +++ b/frontend/src/ts/index.ts @@ -15,8 +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"; import { onAuthStateChanged } from "./auth"; @@ -87,8 +85,6 @@ addToGlobal({ snapshot: DB.getSnapshot, config: Config, glarsesMode: enable, - stats: TestStats.getStats, - replay: Replay.getReplayExport, enableTimerDebug: TestTimer.enableTimerDebug, getTimerStats: TestTimer.getTimerStats, toggleSmoothedBurst: Result.toggleSmoothedBurst, diff --git a/frontend/src/ts/input/handlers/before-delete.ts b/frontend/src/ts/input/handlers/before-delete.ts index bf40582731aa..b8462972ee0a 100644 --- a/frontend/src/ts/input/handlers/before-delete.ts +++ b/frontend/src/ts/input/handlers/before-delete.ts @@ -4,7 +4,7 @@ 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/test-input"; +import { getInputForWord } from "../../test/events/data"; export function onBeforeDelete(event: InputEvent): void { if (!TestState.isActive) { @@ -51,7 +51,7 @@ export function onBeforeDelete(event: InputEvent): void { const confidence = Config.confidenceMode; const previousWordCorrect = - (getInputForWord(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/handlers/before-insert-text.ts b/frontend/src/ts/input/handlers/before-insert-text.ts index 0c0efdb91681..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 { getCurrentInput } 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"; diff --git a/frontend/src/ts/input/handlers/delete.ts b/frontend/src/ts/input/handlers/delete.ts index 5bd4809e5322..8eedfb2c1a31 100644 --- a/frontend/src/ts/input/handlers/delete.ts +++ b/frontend/src/ts/input/handlers/delete.ts @@ -1,14 +1,11 @@ import * as TestUI from "../../test/test-ui"; import * as TestWords from "../../test/test-words"; -import * as TestInput from "../../test/test-input"; -import { getCurrentInput } 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 { @@ -17,17 +14,12 @@ export function onDelete(inputType: DeleteInputType, now: number): void { const inputBeforeDelete = getCurrentInput(); const activeWordIndexBeforeDelete = activeWordIndex; - TestInput.input.syncWithInputElement(); - - const inputAfterDelete = getCurrentInput(); - - Replay.addReplayEvent("setLetterIndex", getCurrentInput().length); - TestInput.setCurrentNotAfk(); + const inputAfterDelete = getInputElementValue().inputValue; const beforeDeleteOnlyTabs = /^\t*$/.test(inputBeforeDelete); const allTabsCorrect = TestWords.words .getCurrentText() - .startsWith(getCurrentInput()); + .startsWith(inputAfterDelete); //special check for code languages if ( diff --git a/frontend/src/ts/input/handlers/insert-text.ts b/frontend/src/ts/input/handlers/insert-text.ts index 4d6d46d404f5..b60d863e689c 100644 --- a/frontend/src/ts/input/handlers/insert-text.ts +++ b/frontend/src/ts/input/handlers/insert-text.ts @@ -1,7 +1,5 @@ import * as TestUI from "../../test/test-ui"; import * as TestWords from "../../test/test-words"; -import * as TestInput from "../../test/test-input"; -import { getCurrentInput } from "../../test/test-input"; import { getInputElementValue, replaceInputElementLastValueChar, @@ -20,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"; @@ -38,7 +35,7 @@ import { isCharCorrect, shouldInsertSpaceCharacter, } from "../helpers/validation"; -import { logTestEvent } from "../../test/events/data"; +import { getCurrentInput, logTestEvent } from "../../test/events/data"; const charOverrides = new Map([ ["…", "..."], @@ -67,10 +64,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; @@ -183,39 +177,16 @@ export async function onInsertText(options: OnInsertTextParams): Promise { !removeLastChar && (((charIsSpace || charIsNewline) && !shouldInsertSpace) || noSpaceForce); - // update test input state - if (!charIsSpace || shouldInsertSpace) { - TestInput.input.syncWithInputElement(); - } - - // general per keypress updates - TestInput.setCurrentNotAfk(); - Replay.addReplayEvent(correct ? "correctLetter" : "incorrectLetter", data); - TestInput.incrementAccuracy(correct); - WeakSpot.updateScore(data, correct); - TestInput.incrementKeypressCount(); - TestInput.pushKeypressWord(wordIndex); - if (!correct) { - TestInput.incrementKeypressErrors(); - TestInput.pushMissedWord(TestWords.words.getCurrentText()); - } if (Config.keymapMode === "react") { flash(data, correct); } - if (testInput.length === 0 && !isCompositionEnding) { - TestInput.setBurstStart(now); - } - if (!shouldGoToNextWord) { - TestInput.corrected.update(data, correct); - } if (removeLastChar) { replaceInputElementLastValueChar(""); - TestInput.input.syncWithInputElement(); } // capture DOM before goToNextWord clears it for the new word - const inputValueAfterEvent = getCurrentInput(); + const inputValueAfterEvent = getInputElementValue().inputValue; // Log the event BEFORE goToNextWord so readers inside the navigation // (e.g. beforeTestWordChange's updateWordLetters, getWordBurst) see the @@ -229,15 +200,15 @@ export async function onInsertText(options: OnInsertTextParams): Promise { charIndex: testInput.length, isCompositionEnding: isCompositionEnding ? true : undefined, inputStopped: removeLastChar ? true : undefined, - // when shouldInsertSpace is true, the space char was already inserted via - // syncWithInputElement above — only append " " for the advance-space case, - // else recorded inputValue ends up with a doubled trailing space. - inputValue: - inputValueAfterEvent + (charIsSpace && !shouldInsertSpace ? " " : ""), + // inputValue is captured from the input element after this event (before goToNextWord clears it). + inputValue: inputValueAfterEvent, commitsWord: shouldGoToNextWord ? true : undefined, lastWord: wordIndex === TestWords.words.length - 1 ? true : undefined, }); + // this needs to be called after event logging + WeakSpot.updateScore(data, correct); + // going to next word let increasedWordIndex: null | boolean = null; let lastBurst: null | number = null; @@ -342,8 +313,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/handlers/keydown.ts b/frontend/src/ts/input/handlers/keydown.ts index 780e8dd8f997..4f0d480e2df6 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"; @@ -134,9 +133,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 6bf90eda40c8..2652404fe493 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 ? true : undefined, diff --git a/frontend/src/ts/input/helpers/word-navigation.ts b/frontend/src/ts/input/helpers/word-navigation.ts index 27ec9c8fec62..947d77541858 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"; @@ -9,13 +8,13 @@ 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"; +import { buildEventLog, getInputForWord } from "../../test/events/data"; type GoToNextWordParams = { correctInsert: boolean; @@ -27,7 +26,7 @@ type GoToNextWordParams = { type GoToNextWordReturn = { increasedWordIndex: boolean; - lastBurst: number; + lastBurst: number | null; }; export async function goToNextWord({ @@ -36,9 +35,9 @@ export async function goToNextWord({ zenNewline, now, }: GoToNextWordParams): Promise { - const ret = { + const ret: GoToNextWordReturn = { increasedWordIndex: false, - lastBurst: 0, + lastBurst: null, }; TestUI.beforeTestWordChange( @@ -47,28 +46,19 @@ 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(); } - //burst calculation and fail - const burst: number = TestStats.calculateBurst(now); - TestInput.pushBurstToHistory(burst); - ret.lastBurst = burst; + if (Config.minBurst !== "off" || Config.liveBurstStyle !== "off") { + const burst = getWordBurst(buildEventLog(), TestState.activeWordIndex, now); + ret.lastBurst = burst; + } PaceCaret.handleSpace(correctInsert, TestWords.words.getCurrentText()); Funbox.toggleScript(TestWords.words.getText(TestState.activeWordIndex + 1)); - TestInput.input.pushHistory(); - TestInput.corrected.pushHistory(); - const lastWord = TestState.activeWordIndex >= TestWords.words.length - 1; if (lastWord) { setAwaitingNextWord(true); @@ -89,8 +79,7 @@ export async function goToNextWord({ } setInputElementValue(""); - TestInput.input.syncWithInputElement(); - void TestUI.afterTestWordChange("forward"); + void TestUI.afterTestWordChange("forward", ret.lastBurst); return ret; } @@ -101,17 +90,12 @@ export function goToPreviousWord( ): void { if (TestState.activeWordIndex === 0) { setInputElementValue(""); - TestInput.input.syncWithInputElement(); return; } TestUI.beforeTestWordChange("back", null, forceUpdateActiveWordLetters); - Replay.addReplayEvent("backWord"); - - const word = TestInput.input.popHistory(); TestState.decreaseActiveWordIndex(); - TestInput.corrected.popHistory(); Funbox.toggleScript(TestWords.words.getText(TestState.activeWordIndex)); @@ -120,6 +104,7 @@ export function goToPreviousWord( if (inputType === "deleteWordBackward") { setInputElementValue(""); } else if (inputType === "deleteContentBackward") { + const word = getInputForWord(TestState.activeWordIndex); if (nospaceEnabled) { setInputElementValue(word.slice(0, -1)); } else if (word.endsWith("\n")) { @@ -128,7 +113,5 @@ export function goToPreviousWord( setInputElementValue(word); } } - TestInput.input.syncWithInputElement(); - void TestUI.afterTestWordChange("back"); } diff --git a/frontend/src/ts/input/listeners/composition.ts b/frontend/src/ts/input/listeners/composition.ts index 5fdbb6e36044..c144fe3bd334 100644 --- a/frontend/src/ts/input/listeners/composition.ts +++ b/frontend/src/ts/input/listeners/composition.ts @@ -2,8 +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 { getCurrentInput } from "../../test/test-input"; import { setLastInsertCompositionTextData } from "../state"; import * as CompositionDisplay from "../../elements/composition-display"; import { onInsertText } from "../handlers/insert-text"; @@ -26,9 +24,6 @@ inputEl.addEventListener("compositionstart", (event) => { if (!TestState.isActive) { TestLogic.startTest(now); } - if (getCurrentInput().length === 0) { - TestInput.setBurstStart(now); - } logTestEvent("composition", now, { event: "start", diff --git a/frontend/src/ts/input/listeners/input.ts b/frontend/src/ts/input/listeners/input.ts index 4eaecb26bbeb..c080c0dee8d2 100644 --- a/frontend/src/ts/input/listeners/input.ts +++ b/frontend/src/ts/input/listeners/input.ts @@ -9,12 +9,12 @@ import { import * as TestUI from "../../test/test-ui"; import { onBeforeInsertText } from "../handlers/before-insert-text"; import { onBeforeDelete } from "../handlers/before-delete"; -import { getCurrentInput } from "../../test/test-input"; import * as TestWords from "../../test/test-words"; import * as CompositionState from "../../legacy-states/composition"; import * as TestState from "../../test/test-state"; import { activeWordIndex } from "../../test/test-state"; import { areAllTestWordsGenerated } from "../../test/test-logic"; +import { getCurrentInput } from "../../test/events/data"; const inputEl = getInputElement(); diff --git a/frontend/src/ts/test/caret.ts b/frontend/src/ts/test/caret.ts index cff1856c799b..187f60b998ac 100644 --- a/frontend/src/ts/test/caret.ts +++ b/frontend/src/ts/test/caret.ts @@ -1,10 +1,10 @@ import { Config } from "../config/store"; +import { getCurrentInput } from "./events/data"; import * as TestState from "../test/test-state"; import { configEvent } from "../events/config"; import { Caret } from "../elements/caret"; import * as CompositionState from "../legacy-states/composition"; import { qsr } from "../utils/dom"; -import { getCurrentInput } from "./test-input"; export function stopAnimation(): void { caret.stopBlinking(); diff --git a/frontend/src/ts/test/events/data.ts b/frontend/src/ts/test/events/data.ts index 0bd68b7d37b1..b9e12b7dd2d2 100644 --- a/frontend/src/ts/test/events/data.ts +++ b/frontend/src/ts/test/events/data.ts @@ -16,16 +16,22 @@ import { TimerEvent, TimerEventData, } from "./types"; -import { keysToTrack } from "./helpers"; +import { getEventsForWord, getInputFromDom, keysToTrack } from "./helpers"; +import { recordEventForCache, resetLiveCache } from "./live-cache"; import { Keycode } from "../../constants/keys"; import { isSafeNumber, mean, roundTo2 } from "@monkeytype/util/numbers"; -import { bailedOut, koreanStatus, resultCalculating } from "../test-state"; +import { + bailedOut, + koreanStatus, + activeWordIndex, + resultCalculating, +} from "../test-state"; import * as TestWords from "../test-words"; import { Config } from "../../config/store"; import * as CustomText from "../../test/custom-text"; import { getMode2 } from "../../utils/misc"; -import { isFunboxActiveWithProperty } from "../funbox/list"; import { getCurrentQuote } from "../../states/test"; +import { isFunboxActiveWithProperty } from "../funbox/active"; export function buildEventLog(): EventLog { const context = { @@ -161,19 +167,23 @@ export function logTestEvent( data: { ...data, code: key }, }); } else if (type === "timer") { - timerEvents.push({ + const event: TimerEvent = { type, ms: now, testMs: 0, data: eventData as TimerEventData, - }); + }; + timerEvents.push(event); + recordEventForCache(event); } else if (type === "input") { - inputEvents.push({ + const event: InputEvent = { type, ms: now, testMs: 0, data: eventData as InputEventData, - }); + }; + inputEvents.push(event); + recordEventForCache(event); } else if (type === "composition") { compositionEvents.push({ type, @@ -191,6 +201,31 @@ function invalidateCache(): void { cachedAllEvents = undefined; } +export function getCurrentInput(): string { + const last = inputEvents[inputEvents.length - 1]; + + if (last !== undefined) { + const lastWordIndex = last.data.wordIndex; + //just advanced to a new word - no input event for it yet + if (lastWordIndex + 1 === activeWordIndex) return ""; + //last event is for the active word - return its snapshot + if ( + lastWordIndex === activeWordIndex && + last.data.inputValue !== undefined + ) { + return last.data.inputValue; + } + } + + return getInputFromDom(getEventsForWord(getAllTestEvents(), activeWordIndex)); +} + +export function getInputForWord(wordIndex: number): string { + return getInputFromDom( + getEventsForWord(getAllTestEvents(), wordIndex), + ).trimEnd(); +} + export function cleanupData(): void { const timerStart = timerEvents.find((e) => e.data.event === "start"); const timerEnd = timerEvents.find((e) => e.data.event === "end"); @@ -217,11 +252,18 @@ export function cleanupData(): void { keyupEvents = keyupEvents.filter( (e) => e.ms <= timerEnd.ms || !postEndKeydownCodes.has(e.data.code), ); + recomputeLiveCache(); } invalidateCache(); } +function recomputeLiveCache(): void { + resetLiveCache(); + for (const e of inputEvents) recordEventForCache(e); + for (const e of timerEvents) recordEventForCache(e); +} + export function getAllTestEvents(): TestEventNoMs[] { if (cachedAllEvents !== undefined) return cachedAllEvents; @@ -325,6 +367,7 @@ export function resetTestEvents(): void { invalidateCache(); pressedKeys = new Map(); noCodeIndex = 0; + resetLiveCache(); } export function getPressedKeys(): Map< diff --git a/frontend/src/ts/test/events/helpers.ts b/frontend/src/ts/test/events/helpers.ts index fecb45369005..d236f88a6101 100644 --- a/frontend/src/ts/test/events/helpers.ts +++ b/frontend/src/ts/test/events/helpers.ts @@ -187,7 +187,6 @@ export function findInputValueMismatches( export function getEventsPerWord( events: TestEventNoMs[], - startMs?: number, testMsLimit?: number, ): Map { let eventsPerWordIndex: Map = new Map(); @@ -196,10 +195,6 @@ export function getEventsPerWord( continue; } - if (startMs !== undefined && event.testMs < startMs) { - continue; - } - if (testMsLimit !== undefined && event.testMs > testMsLimit) { break; } @@ -212,3 +207,17 @@ export function getEventsPerWord( } return eventsPerWordIndex; } + +export function getEventsForWord( + events: TestEventNoMs[], + wordIndex: number, +): TestEventNoMs[] { + const result: TestEventNoMs[] = []; + for (const event of events) { + if (!("wordIndex" in event.data)) continue; + if (event.data.wordIndex === wordIndex) { + result.push(event); + } + } + return result; +} diff --git a/frontend/src/ts/test/events/live-cache.ts b/frontend/src/ts/test/events/live-cache.ts new file mode 100644 index 000000000000..7f66d82da7ed --- /dev/null +++ b/frontend/src/ts/test/events/live-cache.ts @@ -0,0 +1,56 @@ +import { roundTo2 } from "@monkeytype/util/numbers"; +import { TestEvent } from "./types"; + +// Running tallies maintained as events arrive, so live readers don't rescan +// the event log. For replay, derive from the event log directly. +const cache = { + correctInputs: 0, + totalInputs: 0, + timerStartMs: null as number | null, + msSinceLastInputEvent: { + value: null as number | null, + lastEventMs: null as number | null, + }, +}; + +export function resetLiveCache(): void { + cache.correctInputs = 0; + cache.totalInputs = 0; + cache.timerStartMs = null; + cache.msSinceLastInputEvent.value = null; + cache.msSinceLastInputEvent.lastEventMs = null; +} + +export function recordEventForCache(event: TestEvent): void { + if (event.type === "input") { + if ("correct" in event.data) { + cache.totalInputs++; + if (event.data.correct) cache.correctInputs++; + } + if (cache.msSinceLastInputEvent.lastEventMs !== null) { + cache.msSinceLastInputEvent.value = roundTo2( + event.ms - cache.msSinceLastInputEvent.lastEventMs, + ); + } + cache.msSinceLastInputEvent.lastEventMs = event.ms; + } else if (event.type === "timer" && event.data.event === "start") { + cache.timerStartMs = event.ms; + } +} + +export function getLiveCachedAccuracy(): number { + return cache.totalInputs === 0 + ? 100 + : (cache.correctInputs / cache.totalInputs) * 100; +} + +export function getLiveCachedMsSinceLastInputEvent(): number | null { + return cache.msSinceLastInputEvent.value; +} + +export function getLiveCachedTestDurationMs(now: number): number { + if (cache.timerStartMs === null) { + throw new Error("Timer start ms not found in cache"); + } + return now - cache.timerStartMs; +} diff --git a/frontend/src/ts/test/events/stats.ts b/frontend/src/ts/test/events/stats.ts index e1c96bf26abc..e05b4a3320d3 100644 --- a/frontend/src/ts/test/events/stats.ts +++ b/frontend/src/ts/test/events/stats.ts @@ -1,5 +1,5 @@ import { CharCounts, countChars } from "../../utils/strings"; -import { getEventsPerWord, getInputFromDom } from "./helpers"; +import { getEventsForWord, getEventsPerWord, getInputFromDom } from "./helpers"; import { calculateWpm } from "../../utils/numbers"; import { roundTo2 } from "@monkeytype/util/numbers"; import { EventLog, TestEventNoMs } from "./types"; @@ -314,6 +314,34 @@ export function getTestDurationMs(eventLog: EventLog): number { return end; } +export function getDateBasedTestDurationMs(eventLog: EventLog): number { + let start: number | undefined; + let end: number | undefined; + + for (const event of eventLog.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( eventLog: EventLog, wordIndex: number, @@ -348,6 +376,75 @@ function getTargetWord( } } +function computeBurst(events: TestEventNoMs[], now?: number): number { + const inputEvents = events.filter((e) => e.type === "input"); + const input = getInputFromDom(inputEvents); + + let inputLength = input.length; + if (!input.endsWith(" ") && !input.endsWith("\n")) { + inputLength += 1; // account for trigger char (space/newline) on word submit + } + + let firstKeypressTime: number | undefined; + let lastKeypressTime: number | undefined; + + for (const event of events) { + if ( + event.type === "composition" && + event.data.event === "start" && + firstKeypressTime === undefined + ) { + firstKeypressTime = event.testMs; + } + + if ( + event.type === "input" && + (event.data.inputType === "insertText" || + event.data.inputType === "insertCompositionText") + ) { + if (event.data.charIndex === 0 && firstKeypressTime === undefined) { + firstKeypressTime = event.testMs; + } + if (firstKeypressTime !== undefined) { + lastKeypressTime = event.testMs; + } + } + } + + if (firstKeypressTime === undefined || input.length === 0) { + return 0; + } + + if (lastKeypressTime !== undefined && lastKeypressTime < firstKeypressTime) { + lastKeypressTime = undefined; + } + + const endTime = lastKeypressTime ?? now ?? performance.now(); + + const durationSeconds = (endTime - firstKeypressTime) / 1000; + if (durationSeconds <= 0) return Infinity; + + return Math.round(calculateWpm(inputLength, durationSeconds)); +} + +export function getWordBurst( + eventLog: EventLog, + wordIndex: number, + now?: number, +): number { + const events = getEventsForWord(eventLog.events, wordIndex); + return computeBurst(events, now); +} + +export function getWordBurstHistory(eventLog: EventLog): number[] { + const eventsPerWord = getEventsPerWord(eventLog.events); + const burstHistory: number[] = []; + for (let i = 0; i < eventsPerWord.size; i++) { + burstHistory.push(computeBurst(eventsPerWord.get(i) ?? [])); + } + return burstHistory; +} + function countCharsForWordIndex( eventLog: EventLog, wordIndex: number, @@ -393,16 +490,20 @@ function inferActiveWordIndex( return maxWordIndex; } -export function getChars(eventLog: EventLog): CharCounts { +export function getChars( + eventLog: EventLog, + countPartialLastWord = false, + testMs?: number, +): CharCounts { const { events, context } = eventLog; const { bailedOut } = context; const isTimed = isTimedTest(eventLog); - const eventsPerWord = getEventsPerWord(events); + const eventsPerWord = getEventsPerWord(events, testMs); const lastWordIndex = inferActiveWordIndex(eventsPerWord); - const countPartial = isTimed || bailedOut; + const countPartial = isTimed || bailedOut || countPartialLastWord; const acc: CharCounts = { allCorrect: 0, @@ -464,7 +565,10 @@ export function getInputHistory(eventLog: EventLog): string[] { return history; } -export function getAccuracy(eventLog: EventLog): { +export function getAccuracy( + eventLog: EventLog, + testMs?: number, +): { correct: number; incorrect: number; percentage: number; @@ -475,6 +579,7 @@ export function getAccuracy(eventLog: EventLog): { let incorrect = 0; for (const event of events) { + if (testMs !== undefined && event.testMs > testMs) break; if (event.type !== "input") continue; if (!("correct" in event.data)) { @@ -548,6 +653,31 @@ export function getKeypressOverlap(eventLog: EventLog): number { return roundTo2(overlap); } +export function getWordIndexesForSecond( + eventLog: EventLog, + second: number, +): number[] { + const { events } = eventLog; + const boundaries = getTimerBoundaries(eventLog); + + 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 ("wordIndex" in event.data) { + wordIndexes.add(event.data.wordIndex); + } + } + + return [...wordIndexes]; +} + export function getErrorCountHistory(eventLog: EventLog): number[] { const { counts } = countPerInterval( eventLog, @@ -747,6 +877,73 @@ export function getKeypressDurations(eventLog: EventLog): number[] { return durations; } +export function getMissedWords(eventLog: EventLog): Record { + const missedWords: Record = Object.create(null) as Record< + string, + number + >; + + for (const event of eventLog.events) { + if ( + event.type === "input" && + event.data.inputType === "insertText" && + !event.data.correct + ) { + const word = eventLog.context.targetWords[event.data.wordIndex]; + if (word === undefined) continue; + missedWords[word] = (missedWords[word] ?? 0) + 1; + } + } + + return missedWords; +} + +export function getCorrectedWordsHistory(eventLog: EventLog): string[] { + const ev = getEventsPerWord(eventLog.events); + 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.type !== "input") continue; + if ( + event.data.inputType === "insertText" || + event.data.inputType === "insertCompositionText" + ) { + if ( + event.data.inputStopped || + (event.data.data === " " && event.data.commitsWord) + ) { + 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, getLaggedTimerBoundaries, diff --git a/frontend/src/ts/test/events/types.ts b/frontend/src/ts/test/events/types.ts index a7e835f2f9e7..919f20bf5ca6 100644 --- a/frontend/src/ts/test/events/types.ts +++ b/frontend/src/ts/test/events/types.ts @@ -83,6 +83,7 @@ export type TimerEventData = | { event: "start" | "end"; timer: number; + date: number; }; export type InputEvent = EventProps<"input", InputEventData>; diff --git a/frontend/src/ts/test/funbox/active.ts b/frontend/src/ts/test/funbox/active.ts new file mode 100644 index 000000000000..65f6723e881d --- /dev/null +++ b/frontend/src/ts/test/funbox/active.ts @@ -0,0 +1,19 @@ +import { Config } from "../../config/store"; +import { FunboxProperty, getFunboxObject } from "@monkeytype/funbox"; +import { FunboxName } from "@monkeytype/schemas/configs"; + +const metadata = getFunboxObject(); + +export function getActiveFunboxNames(): FunboxName[] { + return Config.funbox ?? []; +} + +export function isFunboxActive(funbox: FunboxName): boolean { + return getActiveFunboxNames().includes(funbox); +} + +export function isFunboxActiveWithProperty(property: FunboxProperty): boolean { + return getActiveFunboxNames().some((name) => + metadata[name]?.properties?.includes(property), + ); +} diff --git a/frontend/src/ts/test/funbox/funbox-functions.ts b/frontend/src/ts/test/funbox/funbox-functions.ts index 25775d523657..28e4f8b834fb 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 { getCurrentInput, getInputForWord } from "../test-input"; +import { getCurrentInput, getInputForWord } from "../events/data"; import * as LayoutfluidFunboxTimer from "./layoutfluid-funbox-timer"; import { highlight } from "../../events/keymap"; import * as MemoryTimer from "./memory-funbox-timer"; @@ -53,7 +53,7 @@ export type FunboxFunctions = { async function readAheadHandleKeydown(event: KeyboardEvent): Promise { const currentInput = getCurrentInput(); - const inputCurrentChar = (currentInput ?? "").slice(-1); + const inputCurrentChar = currentInput.slice(-1); const wordCurrentChar = TestWords.words .getCurrentText() .slice(currentInput.length - 1, currentInput.length); diff --git a/frontend/src/ts/test/funbox/list.ts b/frontend/src/ts/test/funbox/list.ts index 6447d89eb3fc..266813190bbf 100644 --- a/frontend/src/ts/test/funbox/list.ts +++ b/frontend/src/ts/test/funbox/list.ts @@ -1,4 +1,3 @@ -import { Config } from "../../config/store"; import { FunboxMetadata, getFunboxObject, @@ -7,6 +6,13 @@ import { import { FunboxFunctions, getFunboxFunctions } from "./funbox-functions"; import { FunboxName } from "@monkeytype/schemas/configs"; +import { + getActiveFunboxNames, + isFunboxActive, + isFunboxActiveWithProperty, +} from "./active"; + +export { getActiveFunboxNames, isFunboxActive, isFunboxActiveWithProperty }; type FunboxMetadataWithFunctions = FunboxMetadata & { functions?: FunboxFunctions; @@ -48,10 +54,6 @@ export function getActiveFunboxes(): FunboxMetadataWithFunctions[] { return get(getActiveFunboxNames()); } -export function getActiveFunboxNames(): FunboxName[] { - return Config.funbox ?? []; -} - /** * Get all active funboxes defining the given property * @param property @@ -80,24 +82,6 @@ export function findSingleActiveFunboxWithProperty( ); } -/** - * Check if there is an active funbox with the given property name - * @param property property name - * @returns - */ -export function isFunboxActiveWithProperty(property: FunboxProperty): boolean { - return getActiveFunboxesWithProperty(property).length > 0; -} - -/** - * Check if the given funbox is active - * @param funbox funbox name - * @returns true if the funbox is active, false otherwise - */ -export function isFunboxActive(funbox: FunboxName): boolean { - return getActiveFunboxNames().includes(funbox); -} - type MandatoryFunboxFunction = Exclude< FunboxFunctions[F], undefined diff --git a/frontend/src/ts/test/practise-words.ts b/frontend/src/ts/test/practise-words.ts index c89bcce099af..49a384d8f8bd 100644 --- a/frontend/src/ts/test/practise-words.ts +++ b/frontend/src/ts/test/practise-words.ts @@ -4,12 +4,16 @@ 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 { getMissedWords, getInputHistory } from "./test-input"; import { configEvent } from "../events/config"; import { Mode } from "@monkeytype/schemas/shared"; import { CustomTextSettings } from "@monkeytype/schemas/results"; +import { + getInputHistory, + getMissedWords, + getWordBurstHistory, +} from "./events/stats"; import { setCustomTextIndicator } from "../states/core"; +import { lastEventLog } from "./test-state"; type Before = { mode: Mode | null; @@ -29,6 +33,7 @@ export function init( missed: "off" | "words" | "biwords", slow: boolean, ): boolean { + if (lastEventLog === null) return false; if (Config.mode === "zen") return false; let limit; if ((missed === "words" && !slow) || (missed === "off" && slow)) { @@ -38,7 +43,7 @@ export function init( limit = 10; } - const missedWords = getMissedWords(); + const missedWords = getMissedWords(lastEventLog); // missed word, previous word, count let sortableMissedWords: [string, number][] = []; @@ -91,12 +96,11 @@ export function init( if (slow) { const typedWords = TestWords.words .getText() - .slice(0, getInputHistory().length - 1); + .slice(0, getInputHistory(lastEventLog).length - 1); - sortableSlowWords = typedWords.map((e, i) => [ - e, - TestInput.burstHistory[i] ?? 0, - ]); + const burstHistory = getWordBurstHistory(lastEventLog); + + 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-ui.ts similarity index 66% rename from frontend/src/ts/test/replay.ts rename to frontend/src/ts/test/replay-ui.ts index 05a19e649155..ac0e43ada6f6 100644 --- a/frontend/src/ts/test/replay.ts +++ b/frontend/src/ts/test/replay-ui.ts @@ -1,8 +1,15 @@ 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"; +import * as TestWords from "./test-words"; +import { + buildEventLog, + getAllTestEvents, + getInputForWord, +} from "./events/data"; +import { getInputHistory, getWpmHistory } from "./events/stats"; + type ReplayAction = | "correctLetter" | "incorrectLetter" @@ -19,21 +26,86 @@ type Replay = { let wordsList: string[] = []; let replayData: Replay[] = []; -let replayStartTime = 0; -let replayRecording = true; +let wpmHistory: number[] = []; 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(buildEventLog()); + return TestWords.words.list.slice(); +} + +function deriveReplayActions(): Replay[] { + const events = getAllTestEvents(); + const actions: Replay[] = []; + let prevWordIndex: number | undefined; + + for (const event of events) { + if (event.type !== "input") continue; + 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 { @@ -44,7 +116,6 @@ function initializeReplayPrompt(): void { replayWordsElement.innerHTML = ""; let wordCount = 0; replayData.forEach((item) => { - //trim wordsList for timed tests if (item.action === "backWord") { wordCount--; } else if ( @@ -79,13 +150,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 { @@ -113,7 +182,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() ?? ""; @@ -128,7 +196,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(); @@ -170,7 +237,6 @@ function loadOldReplay(): number { wordPos < targetWordPos || (wordPos === targetWordPos && curPos < targetCurPos) ) { - //quickly display everything up to the target handleDisplayLogic(item, true); startingIndex = i + 1; } @@ -182,7 +248,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; @@ -190,14 +256,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(); @@ -206,29 +271,16 @@ function toggleReplayDisplay(): void { } } -function startReplayRecording(): void { - replayData = []; - replayStartTime = performance.now(); - replayRecording = true; +function refreshReplayFromEvents(): void { + wordsList = getWordsList(); + replayData = deriveReplayActions(); + wpmHistory = getWpmHistory(buildEventLog()); 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 = TestInput.wpmHistory[time - 1] ?? 0; + const wpm = wpmHistory[time - 1] ?? 0; const statsString = `${wpm}wpm\t${time}s`; qs("#replayStats")?.setText(statsString); } @@ -237,13 +289,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; @@ -252,7 +302,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, ); @@ -279,37 +329,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"); @@ -329,11 +368,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/result.ts b/frontend/src/ts/test/result.ts index 8eee8ed25745..27a98d5d9e88 100644 --- a/frontend/src/ts/test/result.ts +++ b/frontend/src/ts/test/result.ts @@ -26,8 +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 TestStats from "./test-stats"; import * as TestUI from "./test-ui"; import * as TodayTracker from "./today-tracker"; import { configEvent } from "../events/config"; @@ -60,6 +58,11 @@ import * as ConnectionState from "../legacy-states/connection"; import { qs, qsa } from "../utils/dom"; import { getTheme } from "../states/theme"; import { getCurrentQuote, isTestInvalid } from "../states/test"; +import { + getAccuracy, + getRawHistory, + getTimerBoundaryLabels, +} from "./events/stats"; let result: CompletedEvent; let minChartVal: number; @@ -94,7 +97,7 @@ export function toggleUserFakeChartData(): void { let resultAnnotation: AnnotationOptions<"line">[] = []; async function updateChartData(): Promise { - if (result.chartData === "toolong") { + if (result.chartData === "toolong" || TestState.lastEventLog === null) { ChartController.result.getDataset("wpm").data = []; ChartController.result.getDataset("raw").data = []; ChartController.result.getDataset("burst").data = []; @@ -106,15 +109,7 @@ async function updateChartData(): Promise { ChartController.result.getScale("wpm").title.text = typingSpeedUnit.fullUnitString; - let labels = []; - - for (let i = 1; i <= TestInput.wpmHistory.length; i++) { - if (TestStats.lastSecondNotRound && i === TestInput.wpmHistory.length) { - labels.push(Numbers.roundTo2(result.testDuration).toString()); - } else { - labels.push(i.toString()); - } - } + const labels = getTimerBoundaryLabels(TestState.lastEventLog, false); const chartData1 = [ ...result.chartData.wpm.map((a) => @@ -122,11 +117,9 @@ async function updateChartData(): Promise { ), ]; - const chartData2 = [ - ...TestInput.rawHistory.map((a) => - Numbers.roundTo2(typingSpeedUnit.fromWpm(a)), - ), - ]; + const chartData2 = getRawHistory(TestState.lastEventLog).map((a) => + Numbers.roundTo2(typingSpeedUnit.fromWpm(a)), + ); const valueWindow = Math.max(...result.chartData.burst) * 0.25; let smoothedBurst = Arrays.smoothWithValueWindow( @@ -139,16 +132,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) { @@ -356,61 +339,65 @@ function updateWpmAndAcc(): void { result.acc === 100 ? "100%" : Format.accuracy(result.acc), ); - if (Config.alwaysShowDecimalPlaces) { - if (Config.typingSpeedUnit !== "wpm") { - qs("#result .stats .wpm .bottom")?.setAttribute( - "aria-label", - `${result.wpm.toFixed(2)} wpm`, - ); - qs("#result .stats .raw .bottom")?.setAttribute( + if (TestState.lastEventLog !== null) { + const acc = getAccuracy(TestState.lastEventLog); + if (Config.alwaysShowDecimalPlaces) { + if (Config.typingSpeedUnit !== "wpm") { + qs("#result .stats .wpm .bottom")?.setAttribute( + "aria-label", + `${result.wpm.toFixed(2)} wpm`, + ); + qs("#result .stats .raw .bottom")?.setAttribute( + "aria-label", + `${result.rawWpm.toFixed(2)} wpm`, + ); + } else { + qs("#result .stats .wpm .bottom")?.removeAttribute("aria-label"); + qs("#result .stats .raw .bottom")?.removeAttribute("aria-label"); + } + + let time = `${Numbers.roundTo2(result.testDuration).toFixed(2)}s`; + if (result.testDuration > 61) { + time = DateTime.secondsToString(Numbers.roundTo2(result.testDuration)); + } + qs("#result .stats .time .bottom .text")?.setText(time); + // qs("#result .stats .acc .bottom")?.removeAttribute("aria-label"); + + qs("#result .stats .acc .bottom")?.setAttribute( "aria-label", - `${result.rawWpm.toFixed(2)} wpm`, + `${acc.correct} correct\n${acc.incorrect} incorrect`, ); } else { - qs("#result .stats .wpm .bottom")?.removeAttribute("aria-label"); - qs("#result .stats .raw .bottom")?.removeAttribute("aria-label"); - } + //not showing decimal places + const decimalsAndSuffix = { + showDecimalPlaces: true, + suffix: ` ${Config.typingSpeedUnit}`, + }; + let wpmHover = Format.typingSpeed(result.wpm, decimalsAndSuffix); + let rawWpmHover = Format.typingSpeed(result.rawWpm, decimalsAndSuffix); - let time = `${Numbers.roundTo2(result.testDuration).toFixed(2)}s`; - if (result.testDuration > 61) { - time = DateTime.secondsToString(Numbers.roundTo2(result.testDuration)); - } - qs("#result .stats .time .bottom .text")?.setText(time); - // qs("#result .stats .acc .bottom")?.removeAttribute("aria-label"); + if (Config.typingSpeedUnit !== "wpm") { + wpmHover += ` (${result.wpm.toFixed(2)} wpm)`; + rawWpmHover += ` (${result.rawWpm.toFixed(2)} wpm)`; + } - qs("#result .stats .acc .bottom")?.setAttribute( - "aria-label", - `${TestInput.accuracy.correct} correct\n${TestInput.accuracy.incorrect} incorrect`, - ); - } else { - //not showing decimal places - const decimalsAndSuffix = { - showDecimalPlaces: true, - suffix: ` ${Config.typingSpeedUnit}`, - }; - let wpmHover = Format.typingSpeed(result.wpm, decimalsAndSuffix); - let rawWpmHover = Format.typingSpeed(result.rawWpm, decimalsAndSuffix); + qs("#result .stats .wpm .bottom")?.setAttribute("aria-label", wpmHover); + qs("#result .stats .raw .bottom")?.setAttribute( + "aria-label", + rawWpmHover, + ); - if (Config.typingSpeedUnit !== "wpm") { - wpmHover += ` (${result.wpm.toFixed(2)} wpm)`; - rawWpmHover += ` (${result.rawWpm.toFixed(2)} wpm)`; + qs("#result .stats .acc .bottom") + ?.setAttribute( + "aria-label", + `${ + result.acc === 100 + ? "100%" + : Format.percentage(result.acc, { showDecimalPlaces: true }) + }\n${acc.correct} correct\n${acc.incorrect} incorrect`, + ) + ?.setAttribute("data-balloon-break", ""); } - - qs("#result .stats .wpm .bottom")?.setAttribute("aria-label", wpmHover); - qs("#result .stats .raw .bottom")?.setAttribute("aria-label", rawWpmHover); - - qs("#result .stats .acc .bottom") - ?.setAttribute( - "aria-label", - `${ - result.acc === 100 - ? "100%" - : Format.percentage(result.acc, { showDecimalPlaces: true }) - }\n${TestInput.accuracy.correct} correct\n${ - TestInput.accuracy.incorrect - } incorrect`, - ) - ?.setAttribute("data-balloon-break", ""); } } diff --git a/frontend/src/ts/test/test-input.ts b/frontend/src/ts/test/test-input.ts deleted file mode 100644 index 19c45833c1b3..000000000000 --- a/frontend/src/ts/test/test-input.ts +++ /dev/null @@ -1,569 +0,0 @@ -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"; - -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[]; -}; - -class Input { - current: string; - private history: string[]; - constructor() { - this.current = ""; - this.history = []; - } - - reset(): void { - this.current = ""; - this.history = []; - } - - resetHistory(): void { - 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; - } -} - -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 = ""; - } -} - -let keyDownData: Record = {}; - -export const input = new Input(); -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( - null, -) as MissedWordsType; -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, -}; -export let wpmHistory: number[] = []; -export let rawHistory: number[] = []; -export let burstHistory: number[] = []; -export let errorHistory: ErrorHistoryObject[] = []; -let currentErrorHistory: ErrorHistoryObject = { - count: 0, - words: [], -}; - -export let afkHistory: boolean[] = []; -let currentAfk = true; - -export function incrementKeypressCount(): void { - currentKeypressCount++; -} - -export function setCurrentNotAfk(): void { - currentAfk = false; -} - -export function incrementKeypressErrors(): void { - currentErrorHistory.count++; -} - -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; -} - -export function pushAfkToHistory(): void { - afkHistory.push(currentAfk); - currentAfk = true; -} - -export function pushErrorToHistory(): void { - errorHistory.push(currentErrorHistory); - currentErrorHistory = { - count: 0, - words: [], - }; -} - -export function incrementAccuracy(correctincorrect: boolean): void { - if (correctincorrect) { - accuracy.correct++; - } else { - accuracy.incorrect++; - } -} - -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; - } else { - (missedWords[word] as number) += 1; - } -} - -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 = []; - currentAfk = true; - errorHistory = []; - currentErrorHistory = { - count: 0, - words: [], - }; - currentBurstStart = 0; - missedWords = Object.create(null) as MissedWordsType; - accuracy = { - correct: 0, - incorrect: 0, - }; - - resetKeypressTimings(); -} - -export function getCurrentInput(): string { - return input.current; -} - -export function getInputForWord(wordIndex: number): string | undefined { - return input.get(wordIndex); -} - -export function resetCurrentInput(): void { - input.current = ""; -} - -export function getMissedWords(): MissedWordsType { - return missedWords; -} - -export function getInputHistory(): string[] { - return input.getHistory(); -} diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 25bede817bfe..faf53594d367 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -11,7 +11,6 @@ import { showSuccessNotification, } from "../states/notifications"; import * as CustomText from "./custom-text"; -import * as TestStats from "./test-stats"; import * as PractiseWords from "./practise-words"; import * as ShiftTracker from "./shift-tracker"; import * as AltTracker from "./alt-tracker"; @@ -20,7 +19,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 "./replay-ui"; import { __nonReactive } from "../collections/tags"; import * as TodayTracker from "./today-tracker"; import * as ChallengeContoller from "../controllers/challenge-controller"; @@ -49,12 +48,6 @@ import { setWordsHaveTab, } from "../states/test"; import { restartTestEvent } from "../events/test"; -import * as TestInput from "./test-input"; -import { - getCurrentInput, - resetCurrentInput, - getInputHistory, -} from "./test-input"; import * as TestWords from "./test-words"; import * as WordsGenerator from "./words-generator"; import * as TestState from "./test-state"; @@ -109,19 +102,23 @@ import { getKeypressDurations, getChars, getBurstHistory, - getRawHistory, getLastKeypressToEndMs, getStartToFirstKeypressMs, getTestDurationMs, getAccuracy, - getKeypressSpacing, getKeypressOverlap, getErrorCountHistory, getWpmHistory, getAfkDuration, + getDateBasedTestDurationMs, + getInputHistory, getKeypressesPerSecond, - getInputHistory as getEventsInputHistory, + getKeypressSpacing, } from "./events/stats"; +import { + getLiveCachedAccuracy, + getLiveCachedTestDurationMs, +} from "./events/live-cache"; import { calculateWpm } from "../utils/numbers"; import { isDevEnvironment } from "../utils/env"; import { EventLog } from "./events/types"; @@ -193,9 +190,6 @@ export function startTest(now: number): boolean { } TestState.setActive(true); - Replay.startReplayRecording(); - Replay.replayGetWordsList(TestWords.words.list); - TestInput.carryoverFirstKeypress(); Time.set(0); TestTimer.clear(); @@ -209,7 +203,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(now); TestUI.onTestStart(); return true; @@ -290,14 +283,12 @@ export function restart(options = {} as RestartOptions): void { } if (Config.resultSaving) { - TestInput.pushKeypressesToHistory(); - TestInput.pushErrorToHistory(); - TestInput.pushAfkToHistory(); - const testSeconds = TestStats.calculateTestSeconds(performance.now()); - const afkseconds = TestStats.calculateAfkSeconds(testSeconds); + const liveEventLog = buildEventLog(); + const testSeconds = getLiveCachedTestDurationMs(performance.now()) / 1000; + const afkseconds = getAfkDuration(liveEventLog); let tt = Numbers.roundTo2(testSeconds - afkseconds); if (tt < 0) tt = 0; - const acc = Numbers.roundTo2(TestStats.calculateAccuracy()); + const acc = Numbers.roundTo2(getLiveCachedAccuracy()); pushIncompleteTest({ acc, seconds: tt }); } } @@ -342,14 +333,10 @@ export function restart(options = {} as RestartOptions): void { resetTestEvents(); TestTimer.clear(); setIsTestInvalid(false); - TestStats.restart(); - TestInput.restart(); - TestInput.corrected.reset(); ShiftTracker.reset(); AltTracker.reset(); Caret.hide(); TestState.setActive(false); - Replay.stopReplayRecording(); Replay.pauseReplay(); TestState.setBailedOut(false); Caret.resetPosition(); @@ -451,11 +438,8 @@ async function init(): Promise { return false; } - Replay.stopReplayRecording(); TestWords.words.reset(); TestState.setActiveWordIndex(0); - TestInput.input.resetHistory(); - resetCurrentInput(); showLoaderBar(); const { data: language, error } = await tryCatch( @@ -788,682 +772,6 @@ export async function retrySavingResult(): Promise { } 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) { - chartErr.push(error.count ?? 0); - } - - const chartData = { - wpm: TestInput.wpmHistory, - burst: rawPerSecond, - err: chartErr, - }; - - //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 = TestStats.calculateAfkSeconds(duration); - let language = Config.language; - if (Config.mode === "quote") { - language = Strings.removeLanguageSize(Config.language); - } - - const quoteLength = getCurrentQuote()?.group ?? -1; - - const completedEvent: Omit = { - wpm: stats.wpm, - rawWpm: stats.wpmRaw, - charStats: [ - stats.correctChars, - stats.incorrectChars, - stats.extraChars, - stats.missedChars, - ], - charTotal: stats.allChars, - acc: stats.acc, - mode: Config.mode, - mode2: Misc.getMode2(Config, getCurrentQuote()), - 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: TestInput.keypressTimings.spacing.array, - keyDuration: TestInput.keypressTimings.duration.array, - keyOverlap: Numbers.roundTo2(TestInput.keyOverlap.total), - lastKeyToEnd: lkte, - startToFirstKey: stfk, - consistency: consistency, - wpmConsistency: wpmConsistency, - keyConsistency: keyConsistency, - 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 = isDevEnvironment() || false; - -// window.ce2 = buildCompletedEvent2; - -function compareCompletedEvents( - ce: Omit, -): void { - const start = performance.now(); - - const eventLog = buildEventLog(); - - const ce2 = buildCompletedEvent2(eventLog); - 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) { - if ( - key === "timestamp" || - key === "keyDuration" || - key === "keySpacing" || - key === "chartData" || - key === "consistency" || - key === "keyConsistency" || - key === "keyOverlap" - ) { - continue; - } - // if ( - // key === "keyDuration" || - // key === "keySpacing" || - // key === "afkDuration" || - // key === "chartData" - // ) { - // continue; - // } - - let val1 = ce[key]; - let val2 = ce2[key]; - - //@ts-expect-error temp - 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.error( - `Completed event mismatch on key ${key}: ${mismatchCount}/${total} elements differ`, - a, - b, - ); - if (mismatchCount > 1) { - notMatching.push( - `${key} (${mismatchCount}/${total} elements differ)`, - ); - mismatchedKeys.push(key); - } - } else { - console.debug(`Completed event match on key ${key}:`, a); - } - 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 { - if (TestWords.words.list.length <= 25) { - notMatching.push( - `charStats (${diffs.join(", ")}) words '${TestWords.words.list.join("_")}' input '${getInputHistory().join("_")}'`, - ); - } else { - notMatching.push(`charStats (${diffs.join(", ")})`); - } - mismatchedKeys.push("charStats"); - console.error(`Completed event mismatch on key charStats:`, a, b); - } - continue; - } - - ///@ts-expect-error temp - 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 === "keyConsistency") { - // continue; - // } - - if (key === "wpm" || key === "rawWpm") { - val1 = Numbers.roundTo2(val1 as number); - val2 = Numbers.roundTo2(val2 as number); - const diff = Numbers.roundTo2(Math.abs(val1 - val2)); - if (diff <= 0.01) { - console.debug(`Completed event match on key ${key}:`, val1); - } else { - notMatching.push(`${key} (off by ${diff})`); - mismatchedKeys.push(key); - console.error(`Completed event mismatch on key ${key}:`, val1, val2); - } - 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, - // }; - // } - - //@ts-expect-error temp - 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") { - 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); - const diff = Numbers.roundTo2(Math.abs(a - b)); - if (a !== b && diff >= 0.5) { - const dir = a > b ? "ce1 larger" : "ce2 larger"; - notMatching.push(`${key} (off by ${diff}, ${dir}, ${a} vs ${b})`); - 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); - } - } - - { - const a = TestInput.keypressCountHistory; - const b = getKeypressesPerSecond(eventLog); - const aTotal = a.reduce((acc, val) => { - if (val === undefined) return acc; - return acc + val; - }, 0); - const bTotal = b.reduce((acc, val) => { - if (val === undefined) return acc; - return acc + val; - }, 0); - if ( - a.length === b.length && - (a.every((val, i) => val === b[i]) || aTotal === bTotal) - ) { - console.debug(`Completed event match on key keypressCountHistory:`, a); - } else { - if (a.length !== b.length) { - notMatching.push( - `keypressCountHistory (length differs ${a.length} vs ${b.length})`, - ); - mismatchedKeys.push("keypressCountHistory_length"); - console.error( - `Completed event length mismatch on key keypressCountHistory: ${a.length} vs ${b.length}`, - ); - } else { - notMatching.push( - `keypressCountHistory (values differ) (total ${aTotal} vs ${bTotal})`, - ); - 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(eventLog).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, - ); - } - } - - { - const dur = (ce2.keyDuration === "toolong" ? [] : ce2.keyDuration).reduce( - (acc, val) => { - if (val === undefined) return acc; - return acc + val; - }, - 0, - ); - const over = ce2.keyOverlap; - const sp = (ce2.keySpacing === "toolong" ? [] : ce2.keySpacing).reduce( - (acc, val) => { - if (val === undefined) return acc; - return acc + val; - }, - 0, - ); - const space = ce2.startToFirstKey + ce2.lastKeyToEnd; - const total = Numbers.roundTo2((space + sp) / 1000); - const delta = Numbers.roundTo2(Math.abs(ce2.testDuration - total)); - if (delta >= 0.1) { - notMatching.push( - `testDuration vs key timings (difference of ${delta} seconds)`, - ); - mismatchedKeys.push("testDuration_keyTimings"); - console.error( - `Completed event mismatch on testDuration vs key timings: testDuration ${ce2.testDuration} vs total key timings ${total}`, - { - testDuration: ce2.testDuration, - keyTimingsTotal: total, - keyDuration: dur, - keyOverlap: over, - keySpacing: sp, - startToFirstKey: ce2.startToFirstKey, - lastKeyToEnd: ce2.lastKeyToEnd, - }, - ); - } - } - - { - const a = TestInput.rawHistory; - const b = getRawHistory(eventLog); - if (a.length === b.length && a.every((val, i) => val === b[i])) { - console.debug(`Completed event match on rawHistory:`, a); - } else { - const len = Math.min(a.length, b.length); - const diffs: number[] = []; - for (let i = 0; i < len; i++) { - const av = a[i] as number; - const bv = b[i] as number; - const denom = Math.abs(av); - if (denom === 0) { - if (bv !== 0) diffs.push(100); - continue; - } - diffs.push((Math.abs(av - bv) / denom) * 100); - } - const avg = diffs.length - ? diffs.reduce((acc, v) => acc + v, 0) / diffs.length - : 0; - const avgRounded = Numbers.roundTo2(avg); - if (avg < 1) { - console.debug( - `Completed event match on rawHistory (avg ${avgRounded}% difference within tolerance):`, - a, - ); - } else { - notMatching.push( - `rawHistory (avg ${avgRounded}% difference): ${JSON.stringify(a)} vs ${JSON.stringify(b)}`, - ); - mismatchedKeys.push("rawHistory"); - console.error( - `Completed event mismatch on rawHistory (avg ${avgRounded}% difference):`, - a, - b, - ); - } - } - } - - { - if (ce.chartData !== "toolong") { - const a = ce.chartData.wpm; - const b = getWpmHistory(eventLog); - if (a.length === b.length && a.every((val, i) => val === b[i])) { - console.debug(`Completed event match on chartData.wpm:`, a); - } else { - const len = Math.min(a.length, b.length); - const diffs: number[] = []; - for (let i = 0; i < len; i++) { - const av = a[i] as number; - const bv = b[i] as number; - const denom = Math.abs(av); - if (denom === 0) { - if (bv !== 0) diffs.push(100); - continue; - } - diffs.push((Math.abs(av - bv) / denom) * 100); - } - const avg = diffs.length - ? diffs.reduce((acc, v) => acc + v, 0) / diffs.length - : 0; - const avgRounded = Numbers.roundTo2(avg); - if (avg < 1) { - console.debug( - `Completed event match on chartData.wpm (avg ${avgRounded}% difference within tolerance):`, - a, - ); - } else { - notMatching.push( - `chartData.wpm (avg ${avgRounded}% difference): ${JSON.stringify(a)} vs ${JSON.stringify(b)}`, - ); - mismatchedKeys.push("chartData.wpm"); - console.error( - `Completed event mismatch on chartData.wpm (avg ${avgRounded}% difference):`, - a, - b, - ); - } - } - } - } - - { - const a = getInputHistory().join(" "); - const noSpace = isFunboxActiveWithProperty("nospace"); - if (!a.includes("\n") && !noSpace) { - const b = getEventsInputHistory(eventLog).join(""); - if (a === b) { - console.debug(`Completed event match on input history:`, a); - } else { - notMatching.push(`input history (values differ)`); - mismatchedKeys.push("inputHistory"); - console.error( - `Completed event mismatch on input history:`, - getInputHistory(), - getEventsInputHistory(eventLog), - ); - } - } - } - - if (notMatching.length === 0) { - if (ALWAYSREPORT) { - showSuccessNotification("Completed events match", { important: true }); - } - } else { - let ignoreMismatch = false; - // if ( - // mismatchedKeys.includes("testDuration") && - // Math.abs(ce2.testDuration - ce.testDuration) <= 0.2 - // ) { - // ignoreMismatch = true; - // console.warn("Ignoring completed event mismatch on testDuration", { - // ceTestDuration: ce.testDuration, - // ce2TestDuration: ce2.testDuration, - // }); - // } - // if (mismatchedKeys.includes("keyOverlap")) { - // ignoreMismatch = true; - // console.warn("Ignoring completed event mismatch on keyOverlap", { - // ceKeyOverlap: ce.keyOverlap, - // ce2KeyOverlap: ce2.keyOverlap, - // }); - // } - // if ( - // mismatchedKeys.includes("afkDuration") && - // Math.abs(ce2.afkDuration - ce.afkDuration) <= 1 - // ) { - // ignoreMismatch = true; - // console.warn("Ignoring completed event mismatch on afkDuration", { - // ceAfkDuration: ce.afkDuration, - // ce2AfkDuration: ce2.afkDuration, - // }); - // } - - // if ( - // mismatchedKeys.includes("chartData.wpm") && - // mismatchedKeys.length === 1 - // ) { - // ignoreMismatch = true; - // } - - if (Config.mode !== "time" || (Config.time !== 15 && Config.time !== 60)) { - ignoreMismatch = true; - } - - if (ALWAYSREPORT) { - if (ignoreMismatch) { - showNoticeNotification( - `Completed event ok with ignored mismatches: ${notMatching.join(", ")}`, - { important: true }, - ); - } else { - showErrorNotification( - `Completed event mismatch: ${notMatching.join(", ")}`, - { important: true }, - ); - } - } - if (!ignoreMismatch) { - 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, - funboxes: getActiveFunboxNames().join(","), - version: 30, - eventLog, - // ce: ce as Record, - // ce2: ce2 as Record, - }, - }) - .catch(() => { - // - }); - } - } - - console.debug("Completed event object2", ce2); -} - -function buildCompletedEvent2( eventLog: EventLog, ): Omit { const chars = getChars(eventLog); @@ -1582,7 +890,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, @@ -1601,96 +908,21 @@ export async function finish(difficultyFailed = false): Promise { setIsRepeated(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) { - TestInput.input.pushHistory(); - TestInput.corrected.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(getInputHistory()); - } - - 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); - Replay.stopReplayRecording(); cleanupData(); - // logEventsDataToTheConsoleTable(); - - //need one more calculation for the last word if test auto ended - if (TestInput.burstHistory.length !== getInputHistory()?.length) { - const burst = TestStats.calculateBurst(now); - TestInput.pushBurstToHistory(burst); - } - - //remove afk from zen - if (Config.mode === "zen" || TestState.bailedOut) { - TestStats.removeAfkData(); - } - - // 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); - - // 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 - ) { - const wpmAndRaw = TestStats.calculateWpmAndRaw(); - TestInput.pushToWpmHistory(wpmAndRaw.wpm); - TestInput.pushToRawHistory(wpmAndRaw.raw); - 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, - ); + if (isDevEnvironment()) { + logEventsDataToTheConsoleTable(); } - const ce = buildCompletedEvent(stats, rawPerSecond); + const eventLog = buildEventLog(); + const ce = buildCompletedEvent(eventLog); + PaceCaret.setLastTestWpm(ce.wpm); console.debug("Completed event object", ce); @@ -1721,21 +953,22 @@ export async function finish(difficultyFailed = false): Promise { const completedEvent = structuredClone(ce) as CompletedEvent; + TestState.setLastEventLog(eventLog); setLastResult(structuredClone(completedEvent)); ///////// completed event ready //afk check - const kps = TestInput.afkHistory.slice(-5); - let afkDetected = kps.length > 0 && kps.every((afk) => afk); - + let afkDetected = getKeypressesPerSecond(eventLog) + .slice(-5) + .every((kps) => kps === 0); if (TestState.bailedOut) afkDetected = false; const mode2Number = parseInt(completedEvent.mode2); let tooShort = false; //fail checks - const dateDur = (TestStats.end3 - TestStats.start3) / 1000; + const dateDur = getDateBasedTestDurationMs(eventLog) / 1000; if ( Config.mode === "time" && !TestState.bailedOut && @@ -1819,20 +1052,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 (isRepeated() || difficultyFailed) { if (Config.resultSaving) { const testSeconds = completedEvent.testDuration; @@ -1850,11 +1069,11 @@ export async function finish(difficultyFailed = false): Promise { // Let's update the custom text progress if ( TestState.bailedOut || - getInputHistory().length < TestWords.words.length + getInputHistory(eventLog).length < TestWords.words.length ) { // They bailed out - const history = getInputHistory(); + const history = getInputHistory(eventLog); let historyLength = history?.length; const wordIndex = historyLength - 1; @@ -1964,8 +1183,6 @@ async function saveResult( delete result.hash; result.hash = objectHash(result); - console.trace(); - setAccountButtonSpinner(true); const response = await Ape.results.add({ body: { result } }); @@ -2083,11 +1300,6 @@ async function saveResult( 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-screenshot.ts b/frontend/src/ts/test/test-screenshot.ts index 6e0def0152c3..fe457cea5e4e 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 "./replay-ui"; import { getActivePage, isAuthenticated, diff --git a/frontend/src/ts/test/test-state.ts b/frontend/src/ts/test/test-state.ts index 416d995b4d94..72ed351cd9eb 100644 --- a/frontend/src/ts/test/test-state.ts +++ b/frontend/src/ts/test/test-state.ts @@ -1,4 +1,5 @@ import { promiseWithResolvers } from "../utils/misc"; +import { EventLog } from "./events/types"; export let isActive = false; export let bailedOut = false; @@ -12,6 +13,11 @@ export let testRestarting = false; export let resultVisible = false; export let resultCalculating = false; export let koreanStatus = false; +export let lastEventLog: EventLog | null = null; + +export function setLastEventLog(log: EventLog): void { + lastEventLog = log; +} export function setKoreanStatus(val: boolean): void { koreanStatus = val; diff --git a/frontend/src/ts/test/test-stats.ts b/frontend/src/ts/test/test-stats.ts deleted file mode 100644 index 9adb97dc60ad..000000000000 --- a/frontend/src/ts/test/test-stats.ts +++ /dev/null @@ -1,358 +0,0 @@ -import Hangul from "hangul-js"; -import { Config } from "../config/store"; -import * as TestInput from "./test-input"; -import { getCurrentInput, getInputHistory } from "./test-input"; -import * as TestWords from "./test-words"; -import * as TestState from "./test-state"; -import * as Numbers from "@monkeytype/util/numbers"; -import * as CustomText from "./custom-text"; -import { getLastResult } from "../states/test"; -import { countChars as countCharsUtils, getLastChar } from "../utils/strings"; -import { isFunboxActiveWithProperty } from "./funbox/list"; - -type CharCount = { - correctWordChars: number; - allCorrectChars: number; - incorrectChars: number; - extraChars: number; - missedChars: number; -}; - -export type Stats = { - wpm: number; - wpmRaw: number; - acc: number; - correctChars: number; - incorrectChars: number; - missedChars: number; - extraChars: number; - allChars: number; - time: number; -}; - -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 = { - lastResult: getLastResult(), - start, - end, - start3, - 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, - keypressTimings: TestInput.keypressTimings, - keyOverlap: TestInput.keyOverlap, - wordsHistory: TestWords.words.list.slice(0, getInputHistory().length), - inputHistory: getInputHistory(), - }; - - 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; -} - -export function restart(): void { - start = 0; - end = 0; - start2 = 0; - end2 = 0; - start3 = 0; - end3 = 0; - lastSecondNotRound = false; -} - -export function calculateTestSeconds(now?: number): number { - let duration = (end - start) / 1000; - - if (now !== undefined) { - duration = (now - start) / 1000; - } - - return duration; -} - -export function calculateWpmAndRaw( - withDecimalPoints?: true, - final = false, - testSecondsOverride?: number, - charsOverride?: CharCount, -): { - wpm: number; - raw: number; -} { - const testSeconds = - testSecondsOverride ?? - calculateTestSeconds(TestState.isActive ? performance.now() : end); - - const chars = charsOverride ?? countChars(final); - const wpm = Numbers.roundTo2( - (chars.correctWordChars * (60 / testSeconds)) / 5, - ); - const raw = Numbers.roundTo2( - ((chars.allCorrectChars + 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(); - end3 = new Date().getTime(); -} - -export function setStart(s: number): void { - start = s; - start2 = Date.now(); - start3 = new Date().getTime(); -} - -export function calculateAfkSeconds(testSeconds: number): number { - let extraAfk = 0; - if (testSeconds !== undefined) { - extraAfk = Math.round(testSeconds) - TestInput.keypressCountHistory.length; - 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; -} - -export function calculateBurst(endTime: number = performance.now()): number { - const containsKorean = TestState.koreanStatus; - const timeToWrite = (endTime - TestInput.currentBurstStart) / 1000; - if (timeToWrite <= 0) return 0; - let wordLength: number; - wordLength = !containsKorean - ? getCurrentInput().length - : Hangul.disassemble(getCurrentInput()).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 / - (TestInput.accuracy.correct + TestInput.accuracy.incorrect)) * - 100; - return isNaN(acc) ? 100 : acc; -} - -export function removeAfkData(): void { - const testSeconds = calculateTestSeconds(); - TestInput.keypressCountHistory.splice(testSeconds); - TestInput.wpmHistory.splice(testSeconds); - TestInput.rawHistory.splice(testSeconds); -} - -function getInputWords(): string[] { - const containsKorean = TestState.koreanStatus; - - let inputWords = [...getInputHistory()]; - - if (TestState.isActive) { - inputWords.push(getCurrentInput()); - } - - if (containsKorean) { - inputWords = inputWords.map((w) => Hangul.disassemble(w).join("")); - } - - for (let i = 0; i < inputWords.length - 1; i++) { - if ( - getLastChar(inputWords[i] as string) !== "\n" && - !isFunboxActiveWithProperty("nospace") - ) { - inputWords[i] += " "; - } - } - - return inputWords; -} - -function getTargetWords(): string[] { - const containsKorean = TestState.koreanStatus; - - let targetWords = [ - ...(Config.mode === "zen" ? getInputHistory() : TestWords.words.list), - ]; - - if (TestState.isActive) { - targetWords.push( - Config.mode === "zen" - ? getCurrentInput() - : TestWords.words.getCurrentText(), - ); - } - - if (containsKorean) { - targetWords = targetWords.map((w) => Hangul.disassemble(w).join("")); - } - - for (let i = 0; i < targetWords.length - 1; i++) { - if ( - getLastChar(targetWords[i] as string) !== "\n" && - !isFunboxActiveWithProperty("nospace") - ) { - targetWords[i] += " "; - } - } - - return targetWords; -} - -function countChars(final = false): CharCount { - let correctWordChars = 0; - let correctChars = 0; - let incorrectChars = 0; - let extraChars = 0; - let missedChars = 0; - - const inputWords = getInputWords(); - const targetWords = getTargetWords(); - - const isTimedTest = - Config.mode === "time" || - (Config.mode === "custom" && CustomText.getLimit().mode === "time"); - - for (let i = 0; i < inputWords.length; i++) { - const inputWord = inputWords[i] as string; - let targetWord = targetWords[i] as string; - const isLastInputWord = i === inputWords.length - 1; - - // getTargetWords appends a delimiter to every word except the last in the - // generated list; for the last input word (active in timed/mid-test, or - // the actual last word) drop that delimiter so overshoot counts as extra - if (isLastInputWord && targetWord.endsWith(" ")) { - targetWord = targetWord.slice(0, -1); - } - - const { correctWord, allCorrect, incorrect, missed, extra } = - countCharsUtils( - inputWord, - targetWord, - isLastInputWord && ((isTimedTest && final) || !final), - ); - - correctWordChars += correctWord; - correctChars += allCorrect; - incorrectChars += incorrect; - extraChars += extra; - missedChars += missed; - } - - return { - correctWordChars: correctWordChars, - allCorrectChars: correctChars, - incorrectChars: - Config.mode === "zen" ? TestInput.accuracy.incorrect : incorrectChars, - extraChars: extraChars, - missedChars: missedChars, - }; -} - -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, - ); - } - - const chars = countChars(true); - const { wpm, raw } = calculateWpmAndRaw(true, true, testSeconds, chars); - 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, - missedChars: chars.missedChars, - extraChars: chars.extraChars, - allChars: chars.allCorrectChars + chars.incorrectChars + chars.extraChars, - time: Numbers.roundTo2(testSeconds), - }; - console.debug("Result stats", ret); - return ret; -} diff --git a/frontend/src/ts/test/test-timer.ts b/frontend/src/ts/test/test-timer.ts index 6613237d4e38..6a1b45dda7a4 100644 --- a/frontend/src/ts/test/test-timer.ts +++ b/frontend/src/ts/test/test-timer.ts @@ -6,12 +6,8 @@ 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 { getCurrentInput } 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 +25,14 @@ 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 { buildEventLog, getCurrentInput, logTestEvent } from "./events/data"; +import { roundTo2 } from "@monkeytype/util/numbers"; +import { + getLiveCachedAccuracy, + getLiveCachedTestDurationMs, +} from "./events/live-cache"; +import { getChars } from "./events/stats"; +import { calculateWpm } from "../utils/numbers"; let timerStartMs = 0; let stopped = true; @@ -41,7 +44,7 @@ const newTimer = createTimer({ if (stopped) return; const now = performance.now(); const expectedThisFireMs = timerStartMs + (Time.get() + 1) * 1000; - const drift = Numbers.roundTo2(now - expectedThisFireMs); + const drift = roundTo2(now - expectedThisFireMs); // animejs is rAF-quantized and can fire fractionally early — reschedule // the remainder; bounded by rAF granularity, can't tight-loop @@ -128,6 +131,7 @@ export function clear(logEnd = false, now = performance.now()): void { logTestEvent("timer", now, { event: "end", timer: Time.get(), + date: new Date().getTime(), }); } } @@ -142,17 +146,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; @@ -160,13 +153,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") { @@ -216,9 +202,6 @@ function checkIfFailed( acc: number, ): boolean { if (timerDebug) console.time("fail conditions"); - TestInput.pushKeypressesToHistory(); - TestInput.pushErrorToHistory(); - TestInput.pushAfkToHistory(); if ( Config.minWpm === "custom" && wpmAndRaw.wpm < Config.minWpmCustomSpeed && @@ -254,8 +237,6 @@ function checkIfTimeIsUp(): void { //times up if (timer !== null) clearTimeout(timer); Caret.hide(); - TestInput.input.pushHistory(); - TestInput.corrected.pushHistory(); SlowTimer.clear(); slowTimerCount = 0; timerEvent.dispatch({ key: "finish" }); @@ -293,7 +274,7 @@ export function getTimerStats(): TimerStats[] { return timerStats; } -function timerStep(_now: number, catchingUp: boolean): void { +function timerStep(now: number, catchingUp: boolean): void { if (timerDebug) console.time("timer step -----------------------------"); Time.increment(); @@ -306,8 +287,23 @@ function timerStep(_now: number, catchingUp: boolean): void { checkIfTimeIsUp(); } else { //calc — only the final, real-time tick pays for these - const wpmAndRaw = calculateWpmRaw(); - const acc = calculateAcc(); + const eventLog = buildEventLog(); + + const chars = getChars(eventLog, true); + + const currentTestDurationMs = getLiveCachedTestDurationMs(now); + const acc = getLiveCachedAccuracy(); + const wpmAndRaw = { + wpm: Math.round( + calculateWpm(chars.correctWord, currentTestDurationMs / 1000), + ), + raw: Math.round( + calculateWpm( + chars.allCorrect + chars.extra + chars.incorrect, + currentTestDurationMs / 1000, + ), + ), + }; //ui updates requestDebouncedAnimationFrame("test-timer.timerStep", () => { @@ -381,15 +377,17 @@ async function _startNew(now: number): Promise { logTestEvent("timer", now, { event: "start", timer: Time.get(), + date: new Date().getTime(), }); } -async function _startOld(): Promise { +async function _startOld(now: number): Promise { timerStats = []; - expected = TestStats.start + interval; - logTestEvent("timer", performance.now(), { + expected = now + interval; + logTestEvent("timer", now, { event: "start", timer: Time.get(), + date: new Date().getTime(), }); (function loop(): void { const delay = expected - performance.now(); @@ -399,7 +397,7 @@ async function _startOld(): Promise { expected: expected, nextDelay: delay, }); - const drift = Numbers.roundTo2(Math.abs(interval - delay)); + const drift = roundTo2(Math.abs(interval - delay)); checkIfTimerIsSlow(drift); timer = setTimeout(function () { if (!TestState.isActive) { diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index 1bc5b2ed6825..6820ecb931f9 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -6,12 +6,8 @@ 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, - getInputHistory, - getInputForWord, -} from "./test-input"; +import { getCurrentInput } from "./events/data"; +import { getLiveCachedAccuracy } from "./events/live-cache"; import * as CustomText from "./custom-text"; import * as Caret from "./caret"; import * as OutOfFocus from "./out-of-focus"; @@ -39,7 +35,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"; @@ -72,6 +67,12 @@ import { import { getTheme } from "../states/theme"; import { skipBreakdownEvent } from "../states/header"; import { getCurrentQuote, wordsHaveNewline } from "../states/test"; +import { + getCorrectedWordsHistory, + getInputHistory, + getMissedWords, + getWordBurstHistory, +} from "./events/stats"; export const updateHintsPositionDebounced = Misc.debounceUntilResolved( updateHintsPosition, @@ -1319,10 +1320,19 @@ async function loadWordsHistory(): Promise { const wordsContainer = qs("#resultWordsHistory .words"); wordsContainer?.empty(); - const inputHistoryLength = getInputHistory().length; + if (TestState.lastEventLog === null) { + return false; + } + + const inputHistory = getInputHistory(TestState.lastEventLog).map((i) => + i.trimEnd(), + ); + const burstHistory = getWordBurstHistory(TestState.lastEventLog); + const correctedHistory = getCorrectedWordsHistory(TestState.lastEventLog); + const inputHistoryLength = inputHistory.length; for (let i = 0; i < inputHistoryLength + 2; i++) { - const input = getInputForWord(i); - const corrected = TestInput.corrected.getHistory(i); + const input = inputHistory[i]; + const corrected = correctedHistory[i]; const word = TestWords.words.getText(i) ?? ""; const koreanRegex = /[\uac00-\ud7af]|[\u1100-\u11ff]|[\u3130-\u318f]|[\ua960-\ua97f]|[\ud7b0-\ud7ff]/; @@ -1357,7 +1367,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)); } @@ -1466,10 +1476,13 @@ export async function toggleResultWords(noAnimation = false): Promise { } export async function applyBurstHeatmap(): Promise { + if (TestState.lastEventLog === null) return; + if (Config.burstHeatmap) { qsa("#resultWordsHistory .heatmapLegend")?.show(); - let burstlist = [...TestInput.burstHistory]; + const burstHistory = getWordBurstHistory(TestState.lastEventLog); + let burstlist = [...burstHistory]; burstlist = burstlist.map((x) => (x >= 1000 ? Infinity : x)); @@ -1744,8 +1757,10 @@ function afterAnyTestInput( void SoundController.playClick(); } - const acc: number = Numbers.roundTo2(TestStats.calculateAccuracy()); - if (!isNaN(acc)) LiveAcc.update(acc); + const acc = Numbers.roundTo2(getLiveCachedAccuracy()); + if (!isNaN(acc)) { + LiveAcc.update(acc); + } if (Config.mode !== "time") { TimerProgress.update(); @@ -1821,11 +1836,10 @@ export function beforeTestWordChange( if ( (Config.stopOnError === "letter" && (correct || correct === null)) || nospaceEnabled || - forceUpdateActiveWordLetters || - Config.strictSpace + forceUpdateActiveWordLetters ) { void updateWordLetters({ - input: getCurrentInput(), + input: direction === "back" ? "" : getCurrentInput(), wordIndex: TestState.activeWordIndex, compositionData: CompositionState.getData(), }); @@ -1842,14 +1856,14 @@ export function beforeTestWordChange( export async function afterTestWordChange( direction: "forward" | "back", + lastBurst?: number | null, ): Promise { updateActiveElement({ direction, }); Caret.updatePosition(); - const lastBurst = TestInput.burstHistory[TestInput.burstHistory.length - 1]; - if (Numbers.isSafeNumber(lastBurst)) { + if (lastBurst !== null && Numbers.isSafeNumber(lastBurst)) { void LiveBurst.update(Math.round(lastBurst)); } if (direction === "forward") { @@ -1944,24 +1958,26 @@ export function onTestFinish(): void { } qs(".pageTest #copyWordsListButton")?.on("click", async () => { + if (TestState.lastEventLog === null) return; let words; if (Config.mode === "zen") { - words = getInputHistory().join(" "); + words = getInputHistory(TestState.lastEventLog).join(""); } else { words = TestWords.words .getText() - .slice(0, getInputHistory().length) + .slice(0, getInputHistory(TestState.lastEventLog).length) .join(" "); } await copyToClipboard(words); }); qs(".pageTest #copyMissedWordsListButton")?.on("click", async () => { + if (TestState.lastEventLog === null) return; let words; if (Config.mode === "zen") { - words = getInputHistory().join(" "); + words = getInputHistory(TestState.lastEventLog).join(""); } else { - words = Object.keys(TestInput.missedWords ?? {}).join(" "); + words = Object.keys(getMissedWords(TestState.lastEventLog)).join(" "); } await copyToClipboard(words); }); diff --git a/frontend/src/ts/test/weak-spot.ts b/frontend/src/ts/test/weak-spot.ts index b248ddb6f46c..6dd2270fd8da 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 { getLiveCachedMsSinceLastInputEvent } from "./events/live-cache"; import { Wordset } from "./wordset"; // Changes how quickly it 'learns' scores - very roughly the score for a char @@ -33,11 +33,11 @@ class Score { } export function updateScore(char: string, isCorrect: boolean): void { - const timings = TestInput.keypressTimings.spacing.array; - if (timings.length === 0 || typeof timings === "string") { + const spacing = getLiveCachedMsSinceLastInputEvent(); + if (spacing === null) { return; } - let score = timings[timings.length - 1] as number; + let score = spacing; if (!isCorrect) { score += incorrectPenalty; } diff --git a/package.json b/package.json index 039dfb08482c..e5e4308a7fb1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "monkeytype", - "version": "26.21.0", + "version": "26.26.0", "private": true, "license": "GPL-3.0", "type": "module", diff --git a/packages/contracts/src/rate-limit/index.ts b/packages/contracts/src/rate-limit/index.ts index cd4577fd22da..26bd7ae4aa2f 100644 --- a/packages/contracts/src/rate-limit/index.ts +++ b/packages/contracts/src/rate-limit/index.ts @@ -165,11 +165,6 @@ export const limits = { max: 10, }, - resultsMismatchReport: { - window: 5 * 60 * 1000, // 5 minutes - max: 1, - }, - resultsLeaderboardGet: { window: "hour", max: 60, diff --git a/packages/contracts/src/results.ts b/packages/contracts/src/results.ts index 253fc647af3e..cec92f4dadfc 100644 --- a/packages/contracts/src/results.ts +++ b/packages/contracts/src/results.ts @@ -13,12 +13,6 @@ import { ResultMinifiedSchema, ResultSchema, } from "@monkeytype/schemas/results"; -import { LanguageSchema } from "@monkeytype/schemas/languages"; -import { - DifficultySchema, - Mode2Schema, - ModeSchema, -} from "@monkeytype/schemas/shared"; import { IdSchema } from "@monkeytype/schemas/util"; export const GetResultsQuerySchema = z.object({ @@ -65,29 +59,6 @@ export const AddResultRequestSchema = z.object({ }); export type AddResultRequest = z.infer; -export const ReportCompletedEventMismatchRequestSchema = z.object({ - notMatching: z.array(z.string().max(10000)).max(50), - mismatchedKeys: z.array(z.string().max(10000)).max(50), - groupKey: z.string().max(500), - language: LanguageSchema.optional(), - mode: ModeSchema.optional(), - mode2: Mode2Schema.optional(), - difficulty: DifficultySchema.optional(), - duration: z.number().max(200).optional(), - funboxes: z.string().max(100).optional(), - version: z.literal(30), - eventLog: z.object({ - version: z.number(), - context: z.record(z.unknown()), - events: z.array(z.record(z.unknown())).max(10000), - }), - // ce: z.record(z.unknown()), - // ce2: z.record(z.unknown()), -}); -export type ReportCompletedEventMismatchRequest = z.infer< - typeof ReportCompletedEventMismatchRequestSchema ->; - export const AddResultResponseSchema = responseWithData( PostResultResponseSchema, ); @@ -190,20 +161,6 @@ export const resultsContract = c.router( rateLimit: "resultsTagsUpdate", }), }, - reportCompletedEventMismatch: { - summary: "report completed event mismatch", - description: - "Report a mismatch between old and new completed event builders.", - method: "POST", - path: "/mismatch", - body: ReportCompletedEventMismatchRequestSchema.strict(), - responses: { - 200: MonkeyResponseSchema, - }, - metadata: meta({ - rateLimit: "resultsMismatchReport", - }), - }, deleteAll: { summary: "delete all results", description: "Delete all results for the current user",