diff --git a/src/lib/util/debounce.ts b/src/lib/util/debounce.ts new file mode 100644 index 00000000..6afd95d4 --- /dev/null +++ b/src/lib/util/debounce.ts @@ -0,0 +1,46 @@ +/** + * Creates a debounced function that delays invoking the provided function + * until after 'wait' milliseconds have elapsed since the last time it was + * invoked. + * + * I could use _.debounce(), but bringing dependency on lodash didn't feel + * justified yet. + * + * @param func The function to debounce + * @param wait The number of milliseconds to delay execution + * @returns A debounced version of the provided function + */ +function debounce void>( + func: T, + wait: number +): T & { cancel: () => void } { + let timeout: ReturnType | null = null; + + const debounced = function( + this: ThisParameterType, ...args: Parameters + ): void { + const context = this; + + const later = function() { + timeout = null; + func.apply(context, args); + }; + + if (timeout) { + clearTimeout(timeout); + } + + timeout = setTimeout(later, wait); + }; + + debounced.cancel = function() { + if (timeout) { + clearTimeout(timeout); + timeout = null; + } + }; + + return debounced as T & { cancel: () => void }; +} + +export default debounce; \ No newline at end of file diff --git a/src/routes/(app)/learn/sentence/+page.svelte b/src/routes/(app)/learn/sentence/+page.svelte index bf465a94..a7ebecfc 100644 --- a/src/routes/(app)/learn/sentence/+page.svelte +++ b/src/routes/(app)/learn/sentence/+page.svelte @@ -2,6 +2,7 @@ import { page } from "$app/stores"; import { SvelteMap } from "svelte/reactivity"; import CharRecorder from "$lib/charrecorder/CharRecorder.svelte"; + import debounce from "$lib/util/debounce"; import { ReplayRecorder } from "$lib/charrecorder/core/recorder"; import { shuffleInPlace } from "$lib/util/shuffle"; import { fade, fly, slide } from "svelte/transition"; @@ -12,6 +13,34 @@ import { browser } from "$app/environment"; import { expoOut } from "svelte/easing"; import { goto } from "$app/navigation"; + import { untrack } from "svelte"; + import { + type PageParam, + SENTENCE_TRAINER_PAGE_PARAMS, + } from "./configuration"; + import { + AVG_WORD_LENGTH, + MILLIS_IN_SECOND, + SECONDS_IN_MINUTE, + } from "./constants"; + import { pickNextWord } from "./word-selector"; + + /** + * Resolves parameter from search URL or returns default + * @param param {@link PageParam} generic parameter that can be provided + * in search url + * @return Value of the parameter converted to its type or default value + * if parameter is not present in the URL. + */ + function getParamOrDefault(param: PageParam): T { + if (browser) { + const value = $page.url.searchParams.get(param.key); + if (null !== value) { + return param.parse ? param.parse(value) : (value as unknown as T); + } + } + return param.default; + } function viaLocalStorage(key: string, initial: T) { try { @@ -21,6 +50,11 @@ } } + // Delay to ensure cursor is visible after focus is set. + // it is a workaround for conflict between goto call on sentence update + // and cursor focus when next word is selected. + const CURSOR_FOCUS_DELAY_MS = 10; + let masteryThresholds: [slow: number, fast: number, title: string][] = $state( viaLocalStorage("mastery-thresholds", [ [1500, 1050, "Words"], @@ -29,28 +63,36 @@ ]), ); - const avgWordLength = 5; - function reset() { localStorage.removeItem("mastery-thresholds"); localStorage.removeItem("idle-timeout"); window.location.reload(); } - let inputSentence = $derived( - (browser && $page.url.searchParams.get("sentence")) || "Hello World", + const inputSentence = $derived( + getParamOrDefault(SENTENCE_TRAINER_PAGE_PARAMS.sentence), ); - let wpmTarget = $derived( - (browser && Number($page.url.searchParams.get("wpm"))) || 250, + + const wpmTarget = $derived( + getParamOrDefault(SENTENCE_TRAINER_PAGE_PARAMS.wpm), ); - let devTools = $derived( - browser && $page.url.searchParams.get("dev") === "true", + + const devTools = $derived( + getParamOrDefault(SENTENCE_TRAINER_PAGE_PARAMS.showDevTools), ); - let sentenceWords = $derived(inputSentence.split(" ")); - let msPerChar = $derived((1 / ((wpmTarget / 60) * avgWordLength)) * 1000); - let totalMs = $derived(inputSentence.length * msPerChar); + + let chordInputContainer: HTMLDivElement | null = null; + + let sentenceWords = $derived(inputSentence.trim().split(/\s+/)); + + let inputSentenceLength = $derived(inputSentence.length); + let msPerChar = $derived( + (1 / ((wpmTarget / SECONDS_IN_MINUTE) * AVG_WORD_LENGTH)) * + MILLIS_IN_SECOND, + ); + let totalMs = $derived(inputSentenceLength * msPerChar); let msPerWord = $derived( - (inputSentence.length * msPerChar) / inputSentence.split(" ").length, + (inputSentenceLength * msPerChar) / sentenceWords.length, ); let currentWord = $state(""); let wordStats = new SvelteMap(); @@ -90,7 +132,7 @@ }); let words = $derived.by(() => { - const words = inputSentence.trim().split(" "); + const words = sentenceWords; switch (level) { case 0: { shuffleInPlace(words); @@ -160,18 +202,16 @@ }); function selectNextWord() { - const unmasteredWords = words - .map((it) => [it, wordMastery.get(it) ?? 0] as const) - .filter(([, it]) => it !== 1); - unmasteredWords.sort(([, a], [, b]) => a - b); - let nextWord = unmasteredWords[0]?.[0] ?? words[0] ?? "ERROR"; - for (const [word] of unmasteredWords) { - if (word === currentWord || Math.random() > 0.5) continue; - nextWord = word; - break; - } + const nextWord = pickNextWord( + words, + wordMastery, + untrack(() => currentWord), + ); currentWord = nextWord; recorder = new ReplayRecorder(nextWord); + setTimeout(() => { + chordInputContainer?.focus(); + }, CURSOR_FOCUS_DELAY_MS); } function checkInput() { @@ -215,19 +255,38 @@ idle = true; }, idleTime); } + + function updateSentence(event: Event) { + const params = new URLSearchParams(window.location.search); + params.set( + SENTENCE_TRAINER_PAGE_PARAMS.sentence.key, + (event.target as HTMLInputElement).value, + ); + goto(`?${params.toString()}`); + } + + const debouncedUpdateSentence = debounce( + updateSentence, + getParamOrDefault(SENTENCE_TRAINER_PAGE_PARAMS.textAreaDebounceInMillis), + ); + + function handleInputAreaKeyDown(event: KeyboardEvent) { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); // Prevent new line. + debouncedUpdateSentence.cancel(); // Cancel any pending debounced update + updateSentence(event); // Update immediately + } + }

Sentence Trainer

- { - const params = new URLSearchParams(window.location.search); - params.set("sentence", (it.target as HTMLInputElement).value); - goto(`?${params.toString()}`); - }} - /> +
{#each masteryThresholds as [, , title], i} @@ -371,6 +430,7 @@
{Math.round(totalMs)}ms + >ms + Char {Math.round(msPerChar)}ms + >ms + Word {Math.round(msPerWord)}ms + >ms + @@ -440,8 +500,9 @@ {Math.round(mastery * 100)}% + : 'tertiary'})" + >{Math.round(mastery * 100)}% + {#each stats as stat} {stat} {/each} @@ -560,6 +621,7 @@ opacity: 0; } } + .input { display: flex; grid-row: 1; @@ -577,6 +639,7 @@ .input-section:focus-within { outline: none; + .input { outline-color: var(--md-sys-color-primary); border-radius: 1rem; @@ -586,11 +649,4 @@ opacity: 1; } } - - input[type="text"] { - background: none; - color: inherit; - font: inherit; - border: none; - } diff --git a/src/routes/(app)/learn/sentence/configuration.ts b/src/routes/(app)/learn/sentence/configuration.ts new file mode 100644 index 00000000..0c999ae2 --- /dev/null +++ b/src/routes/(app)/learn/sentence/configuration.ts @@ -0,0 +1,29 @@ +export interface PageParam { + key: string; + default: T; + parse?: (value: string) => T; +} + +export const SENTENCE_TRAINER_PAGE_PARAMS: { + sentence: PageParam; + wpm: PageParam; + showDevTools: PageParam; + textAreaDebounceInMillis: PageParam; +} = { + sentence: { key: "sentence", default: "This text has been typed at the speed of thought" }, + wpm: { + key: "wpm", + default: 250, + parse: (value) => Number(value), + }, + showDevTools: { + key: "dev", + default: false, + parse: (value) => value === "true", + }, + textAreaDebounceInMillis: { + key: "debounceMillis", + default: 5000, + parse: (value) => Number(value), + }, +}; diff --git a/src/routes/(app)/learn/sentence/constants.ts b/src/routes/(app)/learn/sentence/constants.ts new file mode 100644 index 00000000..faf41187 --- /dev/null +++ b/src/routes/(app)/learn/sentence/constants.ts @@ -0,0 +1,8 @@ +// Domain constants +export const AVG_WORD_LENGTH = 5; +export const SECONDS_IN_MINUTE = 60; +export const MILLIS_IN_SECOND = 1000; + +// Error messages. +export const TOO_SHORT_SENTENCE_FOR_NGRAMS_MESSAGE = + "The sentence is too short to make N-Grams, please enter longer sentence"; diff --git a/src/routes/(app)/learn/sentence/word-selector.spec.ts b/src/routes/(app)/learn/sentence/word-selector.spec.ts new file mode 100644 index 00000000..2e46a9f0 --- /dev/null +++ b/src/routes/(app)/learn/sentence/word-selector.spec.ts @@ -0,0 +1,69 @@ +import { describe, it, beforeEach, expect, vi } from "vitest"; +import { pickNextWord } from "./word-selector"; +import { untrack } from "svelte"; +import { SvelteMap } from "svelte/reactivity"; +import { TOO_SHORT_SENTENCE_FOR_NGRAMS_MESSAGE } from "./constants"; + +// Mock untrack so it simply executes the callback, allowing us to spy on its usage. +vi.mock("svelte", () => ({ + untrack: vi.fn((fn: any) => fn()), +})); + +describe("pickNextWord", () => { + let words: string[]; + let wordMastery: SvelteMap; + let currentWord: string; + + beforeEach(() => { + vi.clearAllMocks(); + + // Set up sample words and mastery values. + words = ["alpha", "beta", "gamma"]; + wordMastery = new SvelteMap(); + // For this test, assume none of the words are mastered. + words.forEach((word) => wordMastery.set(word, 0)); + currentWord = "alpha"; + }); + + it("should return a word different from current", () => { + // Force Math.random() to return a predictable value. + vi.spyOn(Math, "random").mockReturnValueOnce(0.3); + + const nextWord = pickNextWord(words, wordMastery, currentWord); + + // Since currentWord ("alpha") should be skipped, we expect next word. + expect(nextWord).toBe("beta"); + }); + + it("should randomly skip words", () => { + // Force Math.random() to return a predictable value. + vi.spyOn(Math, "random").mockReturnValueOnce(0.6).mockReturnValueOnce(0.3); + + const nextWord = pickNextWord(words, wordMastery, currentWord); + + // Since currentWord ("alpha") should be skipped as current + // and "beta" should be randomly skipped we expect "gamma". + expect(nextWord).toBe("gamma"); + }); + + it("should return current word if all other words were randomly skipped", () => { + // Force Math.random() to return a predictable value. + vi.spyOn(Math, "random").mockReturnValueOnce(0.6).mockReturnValueOnce(0.6); + + const nextWord = pickNextWord(words, wordMastery, currentWord); + + // Since all other words have been randomly skipped, we expect + // current word to be returned. + expect(nextWord).toBe("alpha"); + }); + + it("current word should be passed untracked", () => { + pickNextWord(words, wordMastery, currentWord); + expect(untrack).toHaveBeenCalledTimes(0); + }); + + it("should return TOO_SHORT_SENTENCE_FOR_NGRAMS_MESSAGE if the words array is empty", () => { + const result = pickNextWord([], wordMastery, currentWord); + expect(result).toBe(TOO_SHORT_SENTENCE_FOR_NGRAMS_MESSAGE); + }); +}); diff --git a/src/routes/(app)/learn/sentence/word-selector.ts b/src/routes/(app)/learn/sentence/word-selector.ts new file mode 100644 index 00000000..21603af7 --- /dev/null +++ b/src/routes/(app)/learn/sentence/word-selector.ts @@ -0,0 +1,25 @@ +import { TOO_SHORT_SENTENCE_FOR_NGRAMS_MESSAGE } from "./constants"; +import { SvelteMap } from "svelte/reactivity"; + +export function pickNextWord( + words: string[], + wordMastery: SvelteMap, + untrackedCurrentWord: string, +) { + const unmasteredWords = words + .map((it) => [it, wordMastery.get(it) ?? 0] as const) + .filter(([, it]) => it !== 1); + unmasteredWords.sort(([, a], [, b]) => a - b); + let nextWord = + unmasteredWords[0]?.[0] ?? + words[0] ?? + TOO_SHORT_SENTENCE_FOR_NGRAMS_MESSAGE; + // This is important to break infinite loop created by + // reading and writing `currentWord` inside $effect rune + for (const [word] of unmasteredWords) { + if (word === untrackedCurrentWord || Math.random() > 0.5) continue; + nextWord = word; + break; + } + return nextWord; +}