diff --git a/frontend/__tests__/test/events/stats.spec.ts b/frontend/__tests__/test/events/stats.spec.ts index 2f0a3a1c7479..9d32d300d484 100644 --- a/frontend/__tests__/test/events/stats.spec.ts +++ b/frontend/__tests__/test/events/stats.spec.ts @@ -5,12 +5,13 @@ vi.mock("../../../src/ts/test/test-stats", () => ({ })); vi.mock("../../../src/ts/test/test-state", () => ({ - activeWordIndex: 0, bailedOut: false, resultCalculating: false, koreanStatus: false, })); +const mockState = vi.hoisted(() => ({ activeWordIndex: 0 })); + vi.mock("../../../src/ts/config/store", () => ({ Config: { mode: "words", funbox: [] as string[], words: 25, time: 0 }, getConfig: {}, @@ -51,6 +52,7 @@ vi.mock("../../../src/ts/test/custom-text", () => ({ vi.mock("../../../src/ts/states/test", () => ({ getCurrentQuote: () => null, + getActiveWordIndex: () => mockState.activeWordIndex, })); import { @@ -90,7 +92,6 @@ import type { } from "../../../src/ts/test/events/types"; import { Config } from "../../../src/ts/config/store"; import { Keycode } from "../../../src/ts/constants/keys"; -import * as TestState from "../../../src/ts/test/test-state"; import { words as TestWords } from "../../../src/ts/test/test-words"; import { isFunboxActiveWithProperty } from "../../../src/ts/test/funbox/list"; @@ -197,7 +198,7 @@ describe("stats.ts", () => { (Config as { funbox: string[] }).funbox = []; (Config as { words: number }).words = 25; (Config as { time: number }).time = 0; - (TestState as { activeWordIndex: number }).activeWordIndex = 0; + mockState.activeWordIndex = 0; TestWords.reset(); inputPerWord.clear(); }); @@ -1152,7 +1153,7 @@ describe("stats.ts", () => { describe("getChars", () => { it("counts all correct for a perfectly typed word", () => { pushWords("hello"); - (TestState as { activeWordIndex: number }).activeWordIndex = 0; + mockState.activeWordIndex = 0; logTestEvent("timer", 1000, timer("start", 0)); for (let i = 0; i < 5; i++) { @@ -1173,7 +1174,7 @@ describe("stats.ts", () => { it("counts incorrect chars", () => { pushWords("ab"); - (TestState as { activeWordIndex: number }).activeWordIndex = 0; + mockState.activeWordIndex = 0; logTestEvent("timer", 1000, timer("start", 0)); logTestEvent( @@ -1194,7 +1195,7 @@ describe("stats.ts", () => { it("counts extra chars", () => { pushWords("ab"); - (TestState as { activeWordIndex: number }).activeWordIndex = 0; + mockState.activeWordIndex = 0; logTestEvent("timer", 1000, timer("start", 0)); logTestEvent( @@ -1219,7 +1220,7 @@ describe("stats.ts", () => { it("counts missed chars for completed non-last words", () => { pushWords("hello", "world"); - (TestState as { activeWordIndex: number }).activeWordIndex = 1; + mockState.activeWordIndex = 1; logTestEvent("timer", 1000, timer("start", 0)); // type "hel" then space (incomplete first word) @@ -1265,7 +1266,7 @@ describe("stats.ts", () => { // Japanese IME commits words with the ideographic space U+3000, while the // target word separator is a regular space — normalize so it still counts pushWords("しり", "かこ"); - (TestState as { activeWordIndex: number }).activeWordIndex = 1; + mockState.activeWordIndex = 1; logTestEvent("timer", 1000, timer("start", 0)); logTestEvent( @@ -1305,7 +1306,7 @@ describe("stats.ts", () => { describe("getWpmHistory", () => { it("returns wpm at each timer boundary", () => { pushWords("hello"); - (TestState as { activeWordIndex: number }).activeWordIndex = 0; + mockState.activeWordIndex = 0; logTestEvent("timer", 1000, timer("start", 0)); // type "hello" in first second — 5 correct word chars @@ -1326,7 +1327,7 @@ describe("stats.ts", () => { it("returns cumulative wpm across boundaries", () => { pushWords("ab", "cd"); - (TestState as { activeWordIndex: number }).activeWordIndex = 1; + mockState.activeWordIndex = 1; logTestEvent("timer", 1000, timer("start", 0)); // type "ab " in first second — correct word @@ -1371,7 +1372,7 @@ describe("stats.ts", () => { it("counts non-last word as correct without trailing space when nospace funbox is active", () => { (Config as { funbox: string[] }).funbox = ["nospace"]; pushWords("ab", "cd"); - (TestState as { activeWordIndex: number }).activeWordIndex = 1; + mockState.activeWordIndex = 1; logTestEvent("timer", 1000, timer("start", 0)); // type "ab" then "cd" with no space between (nospace mode) @@ -1405,7 +1406,7 @@ describe("stats.ts", () => { it("counts multiline word as correct when target ends in newline", () => { pushWords("hello\n", "world"); - (TestState as { activeWordIndex: number }).activeWordIndex = 1; + mockState.activeWordIndex = 1; logTestEvent("timer", 1000, timer("start", 0)); // type "hello\n" diff --git a/frontend/__tests__/test/test-words.spec.ts b/frontend/__tests__/test/test-words.spec.ts index 8c9a759f69d4..085edf395ce3 100644 --- a/frontend/__tests__/test/test-words.spec.ts +++ b/frontend/__tests__/test/test-words.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; -vi.mock("../../src/ts/test/test-state", () => ({ - activeWordIndex: 0, +vi.mock("../../src/ts/states/test", () => ({ + getActiveWordIndex: () => 0, })); import { words } from "../../src/ts/test/test-words"; diff --git a/frontend/src/ts/input/handlers/before-delete.ts b/frontend/src/ts/input/handlers/before-delete.ts index b9fd550211da..0a7d840da133 100644 --- a/frontend/src/ts/input/handlers/before-delete.ts +++ b/frontend/src/ts/input/handlers/before-delete.ts @@ -5,7 +5,7 @@ import { getInputElementValue } from "../input-element"; import * as TestUI from "../../test/test-ui"; import { isAwaitingNextWord } from "../state"; import { getInputForWord } from "../../test/events/data"; -import { isTestActive } from "../../states/test"; +import { getActiveWordIndex, isTestActive } from "../../states/test"; export function onBeforeDelete(event: InputEvent): void { if (!isTestActive()) { @@ -30,15 +30,13 @@ export function onBeforeDelete(event: InputEvent): void { if (inputIsEmpty) { // we are on the first word, just prevent default, nothing to go back to - if (TestState.activeWordIndex === 0) { + if (getActiveWordIndex() === 0) { event.preventDefault(); return; } // this is nested because we only wanna pull the element from the dom if needed - const previousWordElement = TestUI.getWordElement( - TestState.activeWordIndex - 1, - ); + const previousWordElement = TestUI.getWordElement(getActiveWordIndex() - 1); if (previousWordElement === null) { event.preventDefault(); return; @@ -51,10 +49,9 @@ export function onBeforeDelete(event: InputEvent): void { } const confidence = Config.confidenceMode; - const previousWord = TestWords.words.get(TestState.activeWordIndex - 1); + const previousWord = TestWords.words.get(getActiveWordIndex() - 1); const previousWordCorrect = - getInputForWord(TestState.activeWordIndex - 1) === - previousWord?.textWithCommit; + getInputForWord(getActiveWordIndex() - 1) === previousWord?.textWithCommit; if (confidence === "on" && inputIsEmpty && !previousWordCorrect) { event.preventDefault(); diff --git a/frontend/src/ts/input/handlers/before-insert-text.ts b/frontend/src/ts/input/handlers/before-insert-text.ts index 458b4d374876..c383e67a30d8 100644 --- a/frontend/src/ts/input/handlers/before-insert-text.ts +++ b/frontend/src/ts/input/handlers/before-insert-text.ts @@ -6,7 +6,7 @@ import { isFunboxActiveWithProperty } from "../../test/funbox/list"; import { getInputElementValue } from "../input-element"; import { isAwaitingNextWord } from "../state"; import * as SlowTimer from "../../legacy-states/slow-timer"; -import { wordsHaveNewline } from "../../states/test"; +import { getActiveWordIndex, wordsHaveNewline } from "../../states/test"; import { shouldGoToNextWord } from "../helpers/validation"; import { getCommitCharacterType, normalizeData } from "../helpers/util"; import { getCurrentInput } from "../../test/events/data"; @@ -101,9 +101,7 @@ export function onBeforeInsertText(data: string): boolean { // because this check is expensive (causes layout reflows) // if there is pending word data, we need to account for that - const pendingWordData = TestUI.pendingWordData.get( - TestState.activeWordIndex, - ); + const pendingWordData = TestUI.pendingWordData.get(getActiveWordIndex()); const { top: topAfterAppend, height: heightAfterAppend } = TestUI.getActiveWordTopAndHeightWithDifferentData( (pendingWordData ?? inputValue) + data, diff --git a/frontend/src/ts/input/handlers/delete.ts b/frontend/src/ts/input/handlers/delete.ts index 4adecbf85512..9caa91a8f524 100644 --- a/frontend/src/ts/input/handlers/delete.ts +++ b/frontend/src/ts/input/handlers/delete.ts @@ -6,13 +6,13 @@ import { Config } from "../../config/store"; import { goToPreviousWord } from "../helpers/word-navigation"; import { DeleteInputType } from "../helpers/input-type"; import { getCurrentInput, logTestEvent } from "../../test/events/data"; -import { activeWordIndex } from "../../test/test-state"; +import { getActiveWordIndex } from "../../states/test"; export function onDelete(inputType: DeleteInputType, now: number): void { const { realInputValue } = getInputElementValue(); const inputBeforeDelete = getCurrentInput(); - const activeWordIndexBeforeDelete = activeWordIndex; + const activeWordIndexBeforeDelete = getActiveWordIndex(); const inputAfterDelete = getInputElementValue().inputValue; @@ -44,7 +44,7 @@ export function onDelete(inputType: DeleteInputType, now: number): void { const postNavInputValue = getInputElementValue().inputValue; logTestEvent("input", now, { inputType: "deleteContentBackward", - wordIndex: activeWordIndex, + wordIndex: getActiveWordIndex(), charIndex: postNavInputValue.length, inputValue: postNavInputValue, }); @@ -63,7 +63,7 @@ export function onDelete(inputType: DeleteInputType, now: number): void { const postNavInputValue = getInputElementValue().inputValue; logTestEvent("input", now, { inputType: inputType, - wordIndex: activeWordIndex, + wordIndex: getActiveWordIndex(), charIndex: postNavInputValue.length, inputValue: postNavInputValue, ...(inputBeforeDelete !== "" ? { clearedNextWord: true } : {}), diff --git a/frontend/src/ts/input/handlers/insert-text.ts b/frontend/src/ts/input/handlers/insert-text.ts index 4756bd16f725..3dab68fbc7c4 100644 --- a/frontend/src/ts/input/handlers/insert-text.ts +++ b/frontend/src/ts/input/handlers/insert-text.ts @@ -12,7 +12,6 @@ import { checkIfFinished, } from "../helpers/fail-or-finish"; import { removeLanguageSize } from "../../utils/strings"; -import * as TestState from "../../test/test-state"; import * as TestLogic from "../../test/test-logic"; import { Config } from "../../config/store"; import { flash } from "../../events/keymap"; @@ -31,7 +30,7 @@ import { shouldGoToNextWord, isCharCorrect } from "../helpers/validation"; import { getCurrentInput, logTestEvent } from "../../test/events/data"; import { getCommitCharacterType, normalizeData } from "../helpers/util"; import { areAllWordsGenerated } from "../../test/words-generator"; -import { isTestActive } from "../../states/test"; +import { getActiveWordIndex, isTestActive } from "../../states/test"; const charOverrides = new Map([ ["…", "..."], @@ -140,7 +139,7 @@ export async function onInsertText(options: OnInsertTextParams): Promise { // helper consts const lastInMultiOrSingle = lastInMultiIndex === true || lastInMultiIndex === undefined; - const wordIndex = TestState.activeWordIndex; + const wordIndex = getActiveWordIndex(); const correctShiftUsed = Config.oppositeShiftMode === "off" ? null : isCorrectShiftUsed(); const commitCharacterType = getCommitCharacterType({ diff --git a/frontend/src/ts/input/helpers/word-navigation.ts b/frontend/src/ts/input/helpers/word-navigation.ts index ceb79d63c990..4ead9cae958b 100644 --- a/frontend/src/ts/input/helpers/word-navigation.ts +++ b/frontend/src/ts/input/helpers/word-navigation.ts @@ -1,7 +1,11 @@ import { Config } from "../../config/store"; import * as TestUI from "../../test/test-ui"; import * as PaceCaret from "../../test/pace-caret"; -import * as TestState from "../../test/test-state"; +import { + decreaseActiveWordIndex, + getActiveWordIndex, + increaseActiveWordIndex, +} from "../../states/test"; import * as TestLogic from "../../test/test-logic"; import * as TestWords from "../../test/test-words"; import { @@ -42,7 +46,7 @@ export async function goToNextWord({ } if (Config.minBurst !== "off" || Config.liveBurstStyle !== "off") { - const burst = getWordBurst(buildEventLog(), TestState.activeWordIndex, now); + const burst = getWordBurst(buildEventLog(), getActiveWordIndex(), now); ret.lastBurst = burst; } @@ -51,10 +55,10 @@ export async function goToNextWord({ TestWords.words.getCurrent()?.textWithCommit ?? "", ); - const nextWord = TestWords.words.get(TestState.activeWordIndex + 1)?.text; + const nextWord = TestWords.words.get(getActiveWordIndex() + 1)?.text; if (nextWord !== undefined) Funbox.toggleScript(nextWord); - const lastWord = TestState.activeWordIndex >= TestWords.words.length - 1; + const lastWord = getActiveWordIndex() >= TestWords.words.length - 1; if (lastWord) { setAwaitingNextWord(true); showLoaderBar(); @@ -66,11 +70,11 @@ export async function goToNextWord({ } if ( - TestState.activeWordIndex < TestWords.words.length - 1 || + getActiveWordIndex() < TestWords.words.length - 1 || Config.mode === "zen" ) { ret.increasedWordIndex = true; - TestState.increaseActiveWordIndex(); + increaseActiveWordIndex(); } setInputElementValue(""); @@ -80,16 +84,16 @@ export async function goToNextWord({ } export function goToPreviousWord(inputType: DeleteInputType): void { - if (TestState.activeWordIndex === 0) { + if (getActiveWordIndex() === 0) { setInputElementValue(""); return; } TestUI.beforeTestWordChange("back", null); - TestState.decreaseActiveWordIndex(); + decreaseActiveWordIndex(); - const word = TestWords.words.get(TestState.activeWordIndex)?.text; + const word = TestWords.words.get(getActiveWordIndex())?.text; if (word !== undefined) Funbox.toggleScript(word); const nospaceEnabled = isFunboxActiveWithProperty("nospace"); @@ -97,7 +101,7 @@ export function goToPreviousWord(inputType: DeleteInputType): void { if (inputType === "deleteWordBackward") { setInputElementValue(""); } else if (inputType === "deleteContentBackward") { - const word = getInputForWord(TestState.activeWordIndex); + const word = getInputForWord(getActiveWordIndex()); if (nospaceEnabled) { // nospace has no separator, so the prior word's commit was its last // letter; a single backspace deletes that letter (same as non-nospace diff --git a/frontend/src/ts/input/listeners/composition.ts b/frontend/src/ts/input/listeners/composition.ts index 1b9dc133754c..d2d76a15c2c1 100644 --- a/frontend/src/ts/input/listeners/composition.ts +++ b/frontend/src/ts/input/listeners/composition.ts @@ -5,7 +5,11 @@ import * as TestLogic from "../../test/test-logic"; import { setLastInsertCompositionTextData } from "../state"; import { onInsertText } from "../handlers/insert-text"; import { logTestEvent } from "../../test/events/data"; -import { isTestActive, setCompositionText } from "../../states/test"; +import { + getActiveWordIndex, + isTestActive, + setCompositionText, +} from "../../states/test"; const inputEl = getInputElement(); @@ -27,7 +31,7 @@ inputEl.addEventListener("compositionstart", (event) => { logTestEvent("composition", now, { event: "start", - wordIndex: TestState.activeWordIndex, + wordIndex: getActiveWordIndex(), }); }); @@ -46,7 +50,7 @@ inputEl.addEventListener("compositionupdate", (event) => { logTestEvent("composition", now, { event: "update", data: event.data, - wordIndex: TestState.activeWordIndex, + wordIndex: getActiveWordIndex(), }); }); @@ -72,6 +76,6 @@ inputEl.addEventListener("compositionend", async (event) => { logTestEvent("composition", now, { event: "end", data: event.data, - wordIndex: TestState.activeWordIndex, + wordIndex: getActiveWordIndex(), }); }); diff --git a/frontend/src/ts/input/listeners/input.ts b/frontend/src/ts/input/listeners/input.ts index 498377d70312..0e34ba2edff9 100644 --- a/frontend/src/ts/input/listeners/input.ts +++ b/frontend/src/ts/input/listeners/input.ts @@ -12,7 +12,7 @@ import { onBeforeDelete } from "../handlers/before-delete"; 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 { getActiveWordIndex } from "../../states/test"; import { getCurrentInput } from "../../test/events/data"; import { areAllWordsGenerated } from "../../test/words-generator"; @@ -125,7 +125,7 @@ inputEl.addEventListener("input", async (event) => { inputType === "insertCompositionText" || inputType === "insertFromComposition" ) { - const allWordsTyped = activeWordIndex >= TestWords.words.length - 1; + const allWordsTyped = getActiveWordIndex() >= TestWords.words.length - 1; const inputPlusComposition = getCurrentInput() + (CompositionState.getData() ?? ""); const inputPlusCompositionIsCorrect = diff --git a/frontend/src/ts/legacy-states/time.ts b/frontend/src/ts/legacy-states/time.ts deleted file mode 100644 index c18761a1a208..000000000000 --- a/frontend/src/ts/legacy-states/time.ts +++ /dev/null @@ -1,13 +0,0 @@ -let time = 0; - -export function get(): number { - return time; -} - -export function set(number: number): void { - time = number; -} - -export function increment(): void { - time++; -} diff --git a/frontend/src/ts/states/test.ts b/frontend/src/ts/states/test.ts index 56bbed8d1190..360ac4325146 100644 --- a/frontend/src/ts/states/test.ts +++ b/frontend/src/ts/states/test.ts @@ -86,6 +86,19 @@ export const [getLastSignedOutResult, setLastSignedOutResult] = createSignal(null); export const [isTestActive, setTestActive] = createSignal(false); + +export const [ + getActiveWordIndex, + { + increase: increaseActiveWordIndex, + decrease: decreaseActiveWordIndex, + reset: resetActiveWordIndex, + }, +] = createSignalWithSetters(0)({ + increase: (set) => set((n) => n + 1), + decrease: (set) => set((n) => n - 1), + reset: (set) => set(0), +}); export const [currentLiveStats, setCurrentLiveStats] = createStore<{ wpm?: number; acc?: number; diff --git a/frontend/src/ts/test/caret.ts b/frontend/src/ts/test/caret.ts index 187f60b998ac..5649e5c7dbfb 100644 --- a/frontend/src/ts/test/caret.ts +++ b/frontend/src/ts/test/caret.ts @@ -1,6 +1,7 @@ import { Config } from "../config/store"; import { getCurrentInput } from "./events/data"; import * as TestState from "../test/test-state"; +import { getActiveWordIndex } from "../states/test"; import { configEvent } from "../events/config"; import { Caret } from "../elements/caret"; import * as CompositionState from "../legacy-states/composition"; @@ -32,7 +33,7 @@ export function resetPosition(): void { export function updatePosition(noAnim = false): void { caret.goTo({ - wordIndex: TestState.activeWordIndex, + wordIndex: getActiveWordIndex(), letterIndex: getCurrentInput().length + CompositionState.getData().length, isLanguageRightToLeft: TestState.isLanguageRightToLeft, isDirectionReversed: TestState.isDirectionReversed, diff --git a/frontend/src/ts/test/events/data.ts b/frontend/src/ts/test/events/data.ts index 013c54f4cb13..4423fc6f3674 100644 --- a/frontend/src/ts/test/events/data.ts +++ b/frontend/src/ts/test/events/data.ts @@ -20,17 +20,12 @@ 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, - activeWordIndex, - resultCalculating, -} from "../test-state"; +import { bailedOut, koreanStatus, 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 { getCurrentQuote } from "../../states/test"; +import { getActiveWordIndex, getCurrentQuote } from "../../states/test"; import { isFunboxActiveWithProperty } from "../funbox/active"; export function buildEventLog(): EventLog { @@ -207,17 +202,19 @@ export function getCurrentInput(): string { 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 ""; + if (lastWordIndex + 1 === getActiveWordIndex()) return ""; //last event is for the active word - return its snapshot if ( - lastWordIndex === activeWordIndex && + lastWordIndex === getActiveWordIndex() && last.data.inputValue !== undefined ) { return last.data.inputValue; } } - return getInputFromDom(getEventsForWord(getAllTestEvents(), activeWordIndex)); + return getInputFromDom( + getEventsForWord(getAllTestEvents(), getActiveWordIndex()), + ); } export function getInputForWord(wordIndex: number): string { diff --git a/frontend/src/ts/test/events/live-cache.ts b/frontend/src/ts/test/events/live-cache.ts index 7f66d82da7ed..52372ab297b6 100644 --- a/frontend/src/ts/test/events/live-cache.ts +++ b/frontend/src/ts/test/events/live-cache.ts @@ -48,9 +48,18 @@ export function getLiveCachedMsSinceLastInputEvent(): number | null { return cache.msSinceLastInputEvent.value; } +export function getLiveCachedTimerStartMs(): number | null { + return cache.timerStartMs; +} + export function getLiveCachedTestDurationMs(now: number): number { if (cache.timerStartMs === null) { throw new Error("Timer start ms not found in cache"); } return now - cache.timerStartMs; } + +export function getLiveCachedTestSeconds(now: number): number { + const startMs = cache.timerStartMs ?? now; + return Math.floor((now - startMs) / 1000); +} diff --git a/frontend/src/ts/test/funbox/funbox-functions.ts b/frontend/src/ts/test/funbox/funbox-functions.ts index 08be80e444d5..7430c0b6bae6 100644 --- a/frontend/src/ts/test/funbox/funbox-functions.ts +++ b/frontend/src/ts/test/funbox/funbox-functions.ts @@ -23,7 +23,7 @@ import * as JSONData from "../../utils/json-data"; import { getSection } from "../wikipedia"; import * as WeakSpot from "../weak-spot"; import * as IPAddresses from "../../utils/ip-addresses"; -import * as TestState from "../test-state"; +import { getActiveWordIndex } from "../../states/test"; import { WordGenError } from "../../utils/word-gen-error"; import { FunboxName, KeymapLayout, Layout } from "@monkeytype/schemas/configs"; import { Language, LanguageObject } from "@monkeytype/schemas/languages"; @@ -69,8 +69,8 @@ async function readAheadHandleKeydown(event: KeyboardEvent): Promise { event.key === "Backspace" && !isCorrect && (currentInput !== "" || - getInputForWord(TestState.activeWordIndex - 1) !== - TestWords.words.get(TestState.activeWordIndex - 1)?.textWithCommit || + getInputForWord(getActiveWordIndex() - 1) !== + TestWords.words.get(getActiveWordIndex() - 1)?.textWithCommit || Config.freedomMode) ) { qs("#words")?.addClass("read_ahead_disabled"); @@ -395,11 +395,9 @@ const list: Partial> = { const layouts = Config.customLayoutfluid; const outOf: number = TestWords.words.length; const wordsPerLayout = Math.floor(outOf / layouts.length); - const index = Math.floor( - (TestState.activeWordIndex + 1) / wordsPerLayout, - ); + const index = Math.floor((getActiveWordIndex() + 1) / wordsPerLayout); const mod = - wordsPerLayout - ((TestState.activeWordIndex + 1) % wordsPerLayout); + wordsPerLayout - ((getActiveWordIndex() + 1) % wordsPerLayout); if (layouts[index] as string) { if (mod <= 3 && (layouts[index + 1] as string)) { diff --git a/frontend/src/ts/test/pace-caret.ts b/frontend/src/ts/test/pace-caret.ts index 55348cf7888d..da49b8e4578b 100644 --- a/frontend/src/ts/test/pace-caret.ts +++ b/frontend/src/ts/test/pace-caret.ts @@ -13,6 +13,7 @@ import { getUserDailyBestOnce, } from "../collections/results"; import { + getActiveWordIndex, getCurrentQuote, isPaceRepeat, isTestActive, @@ -231,19 +232,19 @@ export function handleSpace(correct: boolean, currentWord: string): void { if (correct) { if ( settings !== null && - settings.wordsStatus[TestState.activeWordIndex] === true && + settings.wordsStatus[getActiveWordIndex()] === true && !Config.blindMode ) { - settings.wordsStatus[TestState.activeWordIndex] = undefined; + settings.wordsStatus[getActiveWordIndex()] = undefined; settings.correction -= currentWord.length; } } else { if ( settings !== null && - settings.wordsStatus[TestState.activeWordIndex] === undefined && + settings.wordsStatus[getActiveWordIndex()] === undefined && !Config.blindMode ) { - settings.wordsStatus[TestState.activeWordIndex] = true; + settings.wordsStatus[getActiveWordIndex()] = true; settings.correction += currentWord.length; } } diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index cabf7eb36dc4..4a6810c9afb2 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -41,6 +41,8 @@ import { setIsRepeated, setIsTestInvalid, setLastResult, + getActiveWordIndex, + resetActiveWordIndex, setLastSignedOutResult, setResultVisible, setTestActive, @@ -87,7 +89,6 @@ import { canQuickRestart } from "../utils/quick-restart"; import { animate } from "animejs"; import { setInputElementValue } from "../input/input-element"; import { debounce } from "throttle-debounce"; -import * as Time from "../legacy-states/time"; import { qs } from "../utils/dom"; import { setAccountButtonSpinner } from "../states/header"; import { Config } from "../config/store"; @@ -138,7 +139,6 @@ export function startTest(now: number): boolean { } setTestActive(true); - Time.set(0); TestTimer.clear(); for (const fb of getActiveFunboxesWithFunction("start")) { @@ -388,7 +388,7 @@ async function init(): Promise { } TestWords.words.reset(); - TestState.setActiveWordIndex(0); + resetActiveWordIndex(); showLoaderBar(); const { data: language, error } = await tryCatch( @@ -593,7 +593,7 @@ async function init(): Promise { //add word during the test export async function addWord(): Promise { if (Config.mode === "zen") { - TestUI.appendEmptyWordElement(TestState.activeWordIndex + 1); + TestUI.appendEmptyWordElement(getActiveWordIndex() + 1); return; } @@ -607,7 +607,7 @@ export async function addWord(): Promise { const toPushCount = funboxToPush?.split(":")[1]; if (toPushCount !== undefined) bound = +toPushCount - 1; - if (TestWords.words.length - (TestState.activeWordIndex + 1) > bound) { + if (TestWords.words.length - (getActiveWordIndex() + 1) > bound) { console.debug("Not adding word, enough words already"); return; } @@ -617,7 +617,7 @@ export async function addWord(): Promise { } const sectionFunbox = findSingleActiveFunboxWithFunction("pullSection"); if (sectionFunbox) { - if (TestWords.words.length - TestState.activeWordIndex < 20) { + if (TestWords.words.length - getActiveWordIndex() < 20) { const section = await sectionFunbox.functions.pullSection( Config.language, ); diff --git a/frontend/src/ts/test/test-state.ts b/frontend/src/ts/test/test-state.ts index e30f20b14095..5d7495684151 100644 --- a/frontend/src/ts/test/test-state.ts +++ b/frontend/src/ts/test/test-state.ts @@ -4,7 +4,6 @@ import { EventLog } from "./events/types"; export let bailedOut = false; export let selectedQuoteId = parseInt(localStorage.getItem("selectedQuoteId") ?? "1", 10) || 1; -export let activeWordIndex = 0; export let testInitSuccess = true; export let isLanguageRightToLeft = false; export let isDirectionReversed = false; @@ -31,18 +30,6 @@ export function setSelectedQuoteId(id: number): void { localStorage.setItem("selectedQuoteId", id.toString()); } -export function setActiveWordIndex(index: number): void { - activeWordIndex = index; -} - -export function increaseActiveWordIndex(): void { - activeWordIndex++; -} - -export function decreaseActiveWordIndex(): void { - activeWordIndex--; -} - export function setTestInitSuccess(tf: boolean): void { testInitSuccess = tf; } diff --git a/frontend/src/ts/test/test-timer.ts b/frontend/src/ts/test/test-timer.ts index f410da020534..86a93399377f 100644 --- a/frontend/src/ts/test/test-timer.ts +++ b/frontend/src/ts/test/test-timer.ts @@ -14,8 +14,6 @@ import { } from "../states/notifications"; import * as Caret from "./caret"; import * as SlowTimer from "../legacy-states/slow-timer"; -import * as TestState from "./test-state"; -import * as Time from "../legacy-states/time"; import { timerEvent } from "../events/timer"; import { highlight } from "../events/keymap"; import * as LayoutfluidFunboxTimer from "../test/funbox/layoutfluid-funbox-timer"; @@ -29,12 +27,18 @@ import { roundTo2 } from "@monkeytype/util/numbers"; import { getLiveCachedAccuracy, getLiveCachedTestDurationMs, + getLiveCachedTestSeconds, + getLiveCachedTimerStartMs, } from "./events/live-cache"; import { getChars } from "./events/stats"; import { calculateWpm } from "../utils/numbers"; -import { isTestActive, setCurrentLiveStats } from "../states/test"; +import { + getActiveWordIndex, + isTestActive, + setCurrentLiveStats, +} from "../states/test"; -let timerStartMs = 0; +let emittedTicks = 0; let stopped = true; const newTimer = createTimer({ duration: 1000, @@ -42,8 +46,14 @@ const newTimer = createTimer({ onComplete: () => { // sync guard — finish() is async and isTestActive() flips behind an await if (stopped) return; + + const timerStartMs = getLiveCachedTimerStartMs(); + if (timerStartMs === null) { + throw new Error("Timer start ms not found in cache"); + } + const now = performance.now(); - const expectedThisFireMs = timerStartMs + (Time.get() + 1) * 1000; + const expectedThisFireMs = timerStartMs + (emittedTicks + 1) * 1000; const drift = roundTo2(now - expectedThisFireMs); // animejs is rAF-quantized and can fire fractionally early — reschedule @@ -61,16 +71,16 @@ const newTimer = createTimer({ // doesn't pay N times for buildEventLog/WPM/UI. Each missed tick still // gets a step event + per-tick side effects (playTimeWarning, layoutfluid). const ticksDue = Math.floor((now - timerStartMs) / 1000); - while (!stopped && Time.get() + 1 < ticksDue) { + while (!stopped && emittedTicks + 1 < ticksDue) { console.debug( "Catching up timer, missed tick at", - Time.get() + 1, + emittedTicks + 1, "seconds", ); timerStep(now, true); logTestEvent("timer", now, { event: "step", - timer: Time.get(), + timer: emittedTicks, slowTimer: SlowTimer.get() ? true : undefined, catchup: true, }); @@ -82,7 +92,7 @@ const newTimer = createTimer({ timerStep(now, false); logTestEvent("timer", now, { event: "step", - timer: Time.get(), + timer: emittedTicks, slowTimer: SlowTimer.get() ? true : undefined, drift, }); @@ -92,7 +102,7 @@ const newTimer = createTimer({ // Anchor to the ideal grid relative to test start (not `now`) so a late // tick doesn't permanently offset every tick after it. - const expectedNextFireMs = timerStartMs + (Time.get() + 1) * 1000; + const expectedNextFireMs = timerStartMs + (emittedTicks + 1) * 1000; newTimer.duration = Math.max(0, expectedNextFireMs - now); newTimer.restart(); @@ -130,28 +140,28 @@ export function clear(logEnd = false, now = performance.now()): void { if (logEnd) { logTestEvent("timer", now, { event: "end", - timer: Time.get(), + timer: getLiveCachedTestSeconds(now), date: new Date().getTime(), }); } } -function premid(): void { +function premid(testTime: number): void { if (timerDebug) console.time("premid"); const premidSecondsLeft = document.querySelector("#premidSecondsLeft"); if (premidSecondsLeft !== null) { - premidSecondsLeft.innerHTML = (Config.time - Time.get()).toString(); + premidSecondsLeft.innerHTML = (Config.time - testTime).toString(); } if (timerDebug) console.timeEnd("premid"); } -function layoutfluid(): void { +function layoutfluid(time: number): void { if (timerDebug) console.time("layoutfluid"); + if (Config.funbox.includes("layoutfluid") && Config.mode === "time") { const layouts = Config.customLayoutfluid; const switchTime = Config.time / layouts.length; - const time = Time.get(); const index = Math.floor(time / switchTime); const layout = layouts[index]; const flooredSwitchTimes = []; @@ -200,7 +210,7 @@ function checkIfFailed( if ( Config.minWpm === "custom" && wpmAndRaw.wpm < Config.minWpmCustomSpeed && - TestState.activeWordIndex > 3 + getActiveWordIndex() > 3 ) { if (timer !== null) clearTimeout(timer); SlowTimer.clear(); @@ -219,8 +229,9 @@ function checkIfFailed( return false; } -function checkIfTimeIsUp(): void { +function checkIfTimeIsUp(testTime: number): void { if (timerDebug) console.time("times up check"); + let maxTime = undefined; if (Config.mode === "time") { @@ -228,7 +239,7 @@ function checkIfTimeIsUp(): void { } else if (Config.mode === "custom" && CustomText.getLimitMode() === "time") { maxTime = CustomText.getLimitValue(); } - if (maxTime !== undefined && maxTime !== 0 && Time.get() >= maxTime) { + if (maxTime !== undefined && maxTime !== 0 && testTime >= maxTime) { //times up if (timer !== null) clearTimeout(timer); Caret.hide(); @@ -241,7 +252,7 @@ function checkIfTimeIsUp(): void { if (timerDebug) console.timeEnd("times up check"); } -function playTimeWarning(): void { +function playTimeWarning(testTime: number): void { if (timerDebug) console.time("play timer warning"); let maxTime = undefined; @@ -254,7 +265,7 @@ function playTimeWarning(): void { if ( maxTime !== undefined && - Time.get() === maxTime - parseInt(Config.playTimeWarning, 10) + testTime === maxTime - parseInt(Config.playTimeWarning, 10) ) { void SoundController.playTimeWarning(); } @@ -272,14 +283,15 @@ export function getTimerStats(): TimerStats[] { function timerStep(now: number, catchingUp: boolean): void { if (timerDebug) console.time("timer step -----------------------------"); - Time.increment(); + emittedTicks++; + const testTime = emittedTicks; if (catchingUp) { // cheap per-tick side effects — must run for every missed tick during catch-up // so warnings/layout switches still fire on the correct seconds - if (Config.playTimeWarning !== "off") playTimeWarning(); - layoutfluid(); - checkIfTimeIsUp(); + if (Config.playTimeWarning !== "off") playTimeWarning(testTime); + layoutfluid(testTime); + checkIfTimeIsUp(testTime); } else { //calc — only the final, real-time tick pays for these const eventLog = buildEventLog(); @@ -302,7 +314,7 @@ function timerStep(now: number, catchingUp: boolean): void { //ui updates requestDebouncedAnimationFrame("test-timer.timerStep", () => { - premid(); + premid(testTime); }); // already using raf @@ -311,10 +323,10 @@ function timerStep(now: number, catchingUp: boolean): void { setCurrentLiveStats({ wpm: wpmAndRaw.wpm, acc, raw: wpmAndRaw.raw }); //logic - if (Config.playTimeWarning !== "off") playTimeWarning(); - layoutfluid(); + if (Config.playTimeWarning !== "off") playTimeWarning(testTime); + layoutfluid(testTime); const failed = checkIfFailed(wpmAndRaw, acc); - if (!failed) checkIfTimeIsUp(); + if (!failed) checkIfTimeIsUp(testTime); } if (timerDebug) console.timeEnd("timer step -----------------------------"); @@ -356,6 +368,7 @@ function checkIfTimerIsSlow(drift: number): void { export async function start(now: number): Promise { SlowTimer.clear(); slowTimerCount = 0; + emittedTicks = 0; for (const id of slowTimerNotifIds) { removeNotification(id, "clear"); } @@ -366,12 +379,11 @@ export async function start(now: number): Promise { async function _startNew(now: number): Promise { stopped = false; - timerStartMs = now; newTimer.duration = 1000; newTimer.play(); logTestEvent("timer", now, { event: "start", - timer: Time.get(), + timer: 0, date: new Date().getTime(), }); } @@ -381,7 +393,7 @@ async function _startOld(now: number): Promise { expected = now + interval; logTestEvent("timer", now, { event: "start", - timer: Time.get(), + timer: 0, date: new Date().getTime(), }); (function loop(): void { @@ -406,7 +418,7 @@ async function _startOld(now: number): Promise { logTestEvent("timer", now, { event: "step", - timer: Time.get(), + timer: getLiveCachedTestSeconds(now), drift: drift, slowTimer: SlowTimer.get() ? true : undefined, }); diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index 449ffa0ca468..adaca6fc7e04 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -60,6 +60,7 @@ import { import { getTheme } from "../states/theme"; import { skipBreakdownEvent } from "../states/header"; import { + getActiveWordIndex, getCurrentQuote, isTestActive, resetCurrentLiveStats, @@ -142,7 +143,7 @@ export function getWordElement(index: number): ElementWithUtils | null { } export function getActiveWordElement(): ElementWithUtils | null { - return getWordElement(TestState.activeWordIndex); + return getWordElement(getActiveWordIndex()); } export function updateActiveElement( @@ -605,7 +606,7 @@ export async function centerActiveLine(): Promise { const currentTop = activeWordEl.getOffsetTop(); let previousLineTop = currentTop; - for (let i = TestState.activeWordIndex - 1; i >= 0; i--) { + for (let i = getActiveWordIndex() - 1; i >= 0; i--) { previousLineTop = getWordElement(i)?.getOffsetTop() ?? currentTop; if (previousLineTop < currentTop) { await lineJump(previousLineTop, true); @@ -703,7 +704,7 @@ export function addWord( ): void { // if the current active word is the last word, we need to NOT use raf // because other ui parts depend on the word existing - if (TestState.activeWordIndex === wordIndex - 1) { + if (getActiveWordIndex() === wordIndex - 1) { wordsEl.appendHtml(buildWordHTML(word, wordIndex)); } else { requestAnimationFrame(async () => { @@ -1776,7 +1777,7 @@ export function afterTestTextInput( void updateWordLetters({ input, - wordIndex: TestState.activeWordIndex, + wordIndex: getActiveWordIndex(), compositionData: CompositionState.getData(), }); @@ -1786,7 +1787,7 @@ export function afterTestTextInput( export function afterTestCompositionUpdate(): void { void updateWordLetters({ input: getCurrentInput(), - wordIndex: TestState.activeWordIndex, + wordIndex: getActiveWordIndex(), compositionData: CompositionState.getData(), }); // correct needs to be true to get the normal click sound @@ -1796,7 +1797,7 @@ export function afterTestCompositionUpdate(): void { export function afterTestDelete(): void { void updateWordLetters({ input: getCurrentInput(), - wordIndex: TestState.activeWordIndex, + wordIndex: getActiveWordIndex(), compositionData: CompositionState.getData(), }); afterAnyTestInput("delete", null); @@ -1814,16 +1815,16 @@ export function beforeTestWordChange( if (direction === "back") { void updateWordLetters({ input: getCurrentInput(), - wordIndex: TestState.activeWordIndex, + wordIndex: getActiveWordIndex(), compositionData: CompositionState.getData(), }); } if (direction === "forward") { if (Config.blindMode) { - highlightAllLettersAsCorrect(TestState.activeWordIndex); + highlightAllLettersAsCorrect(getActiveWordIndex()); } else if (correct === false) { - highlightBadWord(TestState.activeWordIndex); + highlightBadWord(getActiveWordIndex()); } } } @@ -1864,7 +1865,7 @@ export async function afterTestWordChange( const attr = child.getAttribute("data-wordindex"); if (attr === null) continue; const wordIndex = parseInt(attr, 10); - if (wordIndex === TestState.activeWordIndex) { + if (wordIndex === getActiveWordIndex()) { deleteElements = true; } } @@ -2052,7 +2053,7 @@ configEvent.subscribe(({ key, newValue }) => { if (getActivePage() === "test") { void updateWordLetters({ input: getCurrentInput(), - wordIndex: TestState.activeWordIndex, + wordIndex: getActiveWordIndex(), compositionData: CompositionState.getData(), }); } diff --git a/frontend/src/ts/test/test-words.ts b/frontend/src/ts/test/test-words.ts index 2f692627dc6c..2469c63a7d07 100644 --- a/frontend/src/ts/test/test-words.ts +++ b/frontend/src/ts/test/test-words.ts @@ -1,4 +1,4 @@ -import * as TestState from "./test-state"; +import { getActiveWordIndex } from "../states/test"; type CommitChar = " " | "\n" | ""; @@ -47,7 +47,7 @@ class Words { } } getCurrent(): Word | undefined { - return this.list[TestState.activeWordIndex]; + return this.list[getActiveWordIndex()]; } push(word: string, sectionIndex: number): Word { let commit: CommitChar = ""; diff --git a/frontend/src/ts/test/timer-progress.ts b/frontend/src/ts/test/timer-progress.ts index ca15f28d2940..3ce869032d54 100644 --- a/frontend/src/ts/test/timer-progress.ts +++ b/frontend/src/ts/test/timer-progress.ts @@ -2,13 +2,16 @@ import { Config } from "../config/store"; import * as CustomText from "./custom-text"; import * as DateTime from "../utils/date-and-time"; import * as TestWords from "./test-words"; -import * as Time from "../legacy-states/time"; -import * as TestState from "./test-state"; import { configEvent } from "../events/config"; import { applyReducedMotion } from "../utils/misc"; import { requestDebouncedAnimationFrame } from "../utils/debounced-animation-frame"; import { animate } from "animejs"; -import { getCurrentQuote, isTestActive } from "../states/test"; +import { + getActiveWordIndex, + getCurrentQuote, + isTestActive, +} from "../states/test"; +import { getLiveCachedTestSeconds } from "./events/live-cache"; const barEl = document.querySelector("#barTimerProgress .bar") as HTMLElement; const barOpacityEl = document.querySelector( @@ -106,21 +109,20 @@ export function instantHide(): void { function getCurrentCount(): number { if (Config.mode === "custom" && CustomText.getLimitMode() === "section") { - const currentSectionIndex = TestWords.words.get( - TestState.activeWordIndex, - )?.sectionIndex; + const currentSectionIndex = + TestWords.words.get(getActiveWordIndex())?.sectionIndex; if (currentSectionIndex === undefined) { return 0; } return currentSectionIndex - 1; } else { - return TestState.activeWordIndex; + return getActiveWordIndex(); } } function setTimerHtmlToInputLength(el: HTMLElement, wrapInDiv: boolean): void { - let historyLength = `${TestState.activeWordIndex}`; + let historyLength = `${getActiveWordIndex()}`; if (wrapInDiv) { historyLength = `
${historyLength}
`; @@ -139,7 +141,7 @@ function updateTimer(el: HTMLElement, outof: number, wrapInDiv: boolean): void { export function update(): void { requestDebouncedAnimationFrame("timer-progress.update", () => { - const time = Time.get(); + const time = getLiveCachedTestSeconds(performance.now()); if ( Config.mode === "time" || (Config.mode === "custom" && CustomText.getLimitMode() === "time") @@ -213,9 +215,7 @@ export function update(): void { outof = getCurrentQuote()?.textSplit.length ?? 1; } if (Config.timerStyle === "bar") { - const percent = Math.floor( - ((TestState.activeWordIndex + 1) / outof) * 100, - ); + const percent = Math.floor(((getActiveWordIndex() + 1) / outof) * 100); animate(barEl, { width: `${percent}vw`,