diff --git a/src/app/globals.scss b/src/app/globals.scss index 1795d7d..ce059ab 100644 --- a/src/app/globals.scss +++ b/src/app/globals.scss @@ -65,6 +65,7 @@ body { display: flex; flex-grow: 1; flex-basis: 0; + align-items: center; } &:first-child { diff --git a/src/app/page.tsx b/src/app/page.tsx index d9565cf..f56aaca 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,15 +1,10 @@ import { Typer } from "@/components/Typer"; +import { randomizeWords } from "@/data/common"; import words from "@/data/common"; -function randomizeWords() { - return new Array(500) - .fill(0) - .map((_) => Math.floor(Math.random() * words.length)) - .map((n) => words[n].toLowerCase()); -} - async function getData() { - return randomizeWords(); + const seed = Math.floor(Math.random() * 1000); + return randomizeWords(words, seed); } export default async function Home() { diff --git a/src/components/Chart.tsx b/src/components/Chart.tsx deleted file mode 100644 index d608370..0000000 --- a/src/components/Chart.tsx +++ /dev/null @@ -1,117 +0,0 @@ -type Serie = { - label: string; - data: Point[]; -}; - -type Point = { - x: number; - y: number; - label: string; -}; - -type ChartProps = { - type: "line" | "point"; - series: Serie[]; - width: number; - height: number; - xLabel: string; - yLabel: string; - padding: number; -}; - -const COLORS = ["red-500", "red-500", "red-500", "red-500", "red-500"]; - -export function Chart({ - series, - width, - height, - xLabel = "X", - yLabel = "Y", - type = "line", - padding = 1, -}: ChartProps) { - const xMax = Math.max(...series.flatMap((s) => s.data).map((d) => d.x)); - const yMax = Math.max(...series.flatMap((s) => s.data).map((d) => d.y)); - - function getChartX(x: number) { - return (x / xMax) * width; - } - - function getChartY(y: number) { - return height - (y / yMax) * height; - } - - function getMax(arr: number[]) { - return arr.reduce((max, curr) => { - if (max > curr) { - return max; - } - - return curr; - }); - } - - return ( - - {series.map((serie, index) => { - const seriesColor = COLORS[index % COLORS.length]; - - return ( - - {serie.data.map((point, index) => ( - <> - - {point.label} - - - {type === "line" && index > 0 && ( - - )} - - ))} - - ); - })} - - - {yLabel} - - - - {xLabel} - - - - - - - - ); -} diff --git a/src/components/Keyboard.tsx b/src/components/Keyboard.tsx index d969970..2c30276 100644 --- a/src/components/Keyboard.tsx +++ b/src/components/Keyboard.tsx @@ -1,3 +1,5 @@ +"use client"; + type KeyboardLayoutProps = { highlight: string; isCorrectChar: boolean; diff --git a/src/components/Stats.tsx b/src/components/Stats.tsx index 01d95ac..6956733 100644 --- a/src/components/Stats.tsx +++ b/src/components/Stats.tsx @@ -1,6 +1,9 @@ +"use client"; + import { Run } from "@/models/run"; -import { Chart } from "./Chart"; -import { DEBUG } from "./Typer"; +import { Chart, Point, Serie } from "./chart/Chart"; +import { getSlots } from "@/lib/stats/type-speed"; +import { addSeconds } from "@/lib/date/date"; type StatsProps = { history: Run[]; @@ -13,6 +16,18 @@ export function Stats({ history }: StatsProps) { 0 ); + const errors = run.correctWords.reduce((sum, curr) => sum + curr.errors, 0); + const worst = run.correctWords.reduce((maxErrorsWord, currentWord) => { + if (currentWord.errors > maxErrorsWord.errors) { + return currentWord; + } + + return maxErrorsWord; + }, run.correctWords[0]).word; + + const accuracy = + (100 * correctWordsTotalLength) / (correctWordsTotalLength + errors); + return ( <>
@@ -27,24 +42,30 @@ export function Stats({ history }: StatsProps) {

{correctWordsTotalLength} chars

-

{run.errors.toFixed(0)} errors

-

- {( - (100 * correctWordsTotalLength) / - (correctWordsTotalLength + run.errors) - ).toFixed(0)} - % accuracy -

+

{errors.toFixed(0)} errors

+

{accuracy.toFixed(0)}% accuracy

-
- {run.correctWords.map((c) => { +
+ {run.correctWords.map((c, index) => { + const previousWordTimestamp = run.correctWords[index - 1] + ? run.correctWords[index - 1].endTimestamp + : run.startDate; + const timeMillis = - c.endTimestamp.getTime() - c.startTimestamp.getTime(); + c.endTimestamp.getTime() - previousWordTimestamp.getTime(); + + const className = + worst === c.word ? "text-red-400" : "text-slate-400"; return ( -
- {c.word} ({timeMillis}ms) +
+ {c.word} ({timeMillis} ms + {worst === c.word ? `, ${c.errors} errors` : ``}){" "}
); })} @@ -53,17 +74,23 @@ export function Stats({ history }: StatsProps) { {false && ( ({ - label: c.word, - x: c.endTimestamp.getTime() - run.startDate.getTime(), - y: c.endTimestamp.getTime() - c.startTimestamp.getTime(), - })), + data: run.correctWords.map((c, index) => { + const previousWordTimestamp = run.correctWords[index - 1] + ? run.correctWords[index - 1].endTimestamp.getTime() + : run.startDate.getTime(); + + return { + label: c.word, + x: c.endTimestamp.getTime() - run.startDate.getTime(), + y: c.endTimestamp.getTime() - previousWordTimestamp, + }; + }), }, ]} xLabel={"t"} @@ -75,7 +102,7 @@ export function Stats({ history }: StatsProps) { ); } - const countSeries = history.map((run) => { + const countSeries: Serie[] = history.map((run) => { return { label: run.startDate.getTime().toString(), data: run.correctWords.reduce( @@ -95,24 +122,58 @@ export function Stats({ history }: StatsProps) { x: 0, y: 0, }, - ] as { x: number; y: number; label: string }[] + ] as Point[] ), }; }); + const wpmSeries: Serie[] = history.map((run) => { + const slots = getSlots( + run.correctWords.map((c) => c.endTimestamp), + run.startDate, + addSeconds(run.startDate, run.timeLimitSeconds), + 5 + ); + + return { + label: run.startDate.getTime().toString(), + data: slots.map((s, index) => ({ + x: index, + y: s, + label: `${s} wpm`, + })), + }; + }); + return ( <> - {DEBUG && ( + { ( + + {p.x} {p.y} + + )} xLabel={"t"} - yLabel={"w"} + yLabel={"wpm"} padding={5} /> - )} + } + + {history .sort((a, b) => b.startDate.getTime() - a.startDate.getTime()) diff --git a/src/components/Tooltip.tsx b/src/components/Tooltip.tsx new file mode 100644 index 0000000..1cd3dcf --- /dev/null +++ b/src/components/Tooltip.tsx @@ -0,0 +1,17 @@ +import { HTMLProps } from "react"; + +type TooltipProps = HTMLProps; + +export function Tooltip({ children }: TooltipProps) { + return ( +
+ {children} +
+ ); +} diff --git a/src/components/Typed.tsx b/src/components/Typed.tsx index 6a08a27..4b89a00 100644 --- a/src/components/Typed.tsx +++ b/src/components/Typed.tsx @@ -1,3 +1,5 @@ +"use client"; + type TypedProps = { typedRaw: string }; export function Typed({ typedRaw }: TypedProps) { diff --git a/src/components/Typer.tsx b/src/components/Typer.tsx index a99d438..b923704 100644 --- a/src/components/Typer.tsx +++ b/src/components/Typer.tsx @@ -1,12 +1,16 @@ "use client"; import { KeyboardEvent, useReducer, useRef, useState } from "react"; -import { incorrectCharacterIndex, lastCorrectIndex } from "@/lib/words"; -import { BACKSPACE, mapKeyToChar } from "@/lib/key-to-char"; -import { sleepStep } from "@/lib/sleep"; +import { sleepStep } from "@/lib/sleep/sleep"; import { CompletedWord, Run } from "@/models/run"; import { Stats } from "./Stats"; import { KeyboardLayout } from "./Keyboard"; +import { getMockHistory } from "@/data/mockState"; +import { BACKSPACE, mapKeyToChar } from "@/lib/typer/key-to-char"; +import { + incorrectCharacterIndex, + lastCorrectIndex, +} from "@/lib/typer/correct-chars"; const FONT_SIZE = typeof window !== "undefined" @@ -20,7 +24,7 @@ const FONT_SIZE = export const DEBUG = typeof window !== "undefined" && window.location.href.includes("localhost"); -export const TIME_LIMIT = DEBUG ? 5 : 60; +export const TIME_LIMIT = DEBUG ? 30 : 60; const CHAR_WIDTH = FONT_SIZE / 2; const START_WORD_INDEX = 10; const WORDS = 7; @@ -35,7 +39,7 @@ type RunningState = "RESET" | "RUNNING" | "STOPPED"; type ReducerState = { runningState: RunningState; - history: Run[]; + completedRuns: Run[]; }; type ReducerAction = { action: RunningState }; @@ -45,62 +49,60 @@ export function Typer({ words }: TyperProps) { const [typedRaw, setTypedRaw] = useState(""); const [typedWord, setTypedWord] = useState(""); const [timeStep, setStepTime] = useState(new Date()); - const [targetWordIndex, setTargetWordIndex] = useState(START_WORD_INDEX); - const [wordStartTimestamp, setWordStartTimestamp] = useState(new Date()); const [runStartDate, setRunStartDate] = useState(undefined); - const [runErrors, setRunErrors] = useState(0); + const [currentWordIndex, setCurrentWordIndex] = useState(START_WORD_INDEX); + const [currentWordErrors, setCurrentWordErrors] = useState(0); + const [runCorrectWords, setRunCorrectWords] = useState([]); - const [state, dispatch] = useReducer(reducer, { + const [typerState, dispatchTyperState] = useReducer(stateReducer, { runningState: "RESET", - history: [], + completedRuns: getMockHistory(), }); - const targetWord = words[targetWordIndex]; + const targetWord = words[currentWordIndex]; const incorrectCharIndex = incorrectCharacterIndex(typedWord, targetWord); - function reducer( + function stateReducer( prevState: ReducerState, action: ReducerAction ): ReducerState { + debug(action.action); + switch (action.action) { case "RESET": - debug("RESET"); - return { - history: prevState.history, + completedRuns: prevState.completedRuns, runningState: "RESET", }; case "RUNNING": - debug("RUNNING"); return { - history: prevState.history, + completedRuns: prevState.completedRuns, runningState: "RUNNING", }; case "STOPPED": - debug("STOPPED"); + const newHistoryState: Run[] = [ + ...prevState.completedRuns, + { + correctWords: runCorrectWords, + startDate: runStartDate!, + timeLimitSeconds: TIME_LIMIT, + }, + ]; + return { runningState: "STOPPED", - history: [ - ...prevState.history, - { - correctWords: runCorrectWords, - errors: runErrors, - startDate: runStartDate!, - timeLimitSeconds: TIME_LIMIT, - }, - ], + completedRuns: newHistoryState, }; } } function reset() { - dispatch({ action: "RESET" }); + dispatchTyperState({ action: "RESET" }); setTypedWord(() => ""); setRunCorrectWords([]); - setRunErrors(0); setRunStartDate(undefined); setTimeout(() => { if (inputRef.current) inputRef.current.focus(); @@ -121,7 +123,7 @@ export function Typer({ words }: TyperProps) { lastCorrectIndex(typedWord + char, targetWord) === typedWord.length; if (char !== BACKSPACE && !correctChar) { - setRunErrors(() => runErrors + 1); + setCurrentWordErrors(() => currentWordErrors + 1); } switch (e.key) { @@ -133,15 +135,15 @@ export function Typer({ words }: TyperProps) { case "Shift": case " ": if (correctWord) { - setTargetWordIndex(() => targetWordIndex + 1); + setCurrentWordIndex(() => currentWordIndex + 1); setTypedWord(() => ""); - setWordStartTimestamp(() => new Date()); + setCurrentWordErrors(() => 0); setRunCorrectWords(() => [ ...runCorrectWords, { + errors: currentWordErrors, word: targetWord, endTimestamp: new Date(), - startTimestamp: wordStartTimestamp!, }, ]); } @@ -150,11 +152,11 @@ export function Typer({ words }: TyperProps) { } function isStopped() { - return state.runningState === "STOPPED"; + return typerState.runningState === "STOPPED"; } function isRunning() { - return state.runningState === "RUNNING"; + return typerState.runningState === "RUNNING"; } function shouldClearInput(e: React.ChangeEvent) { @@ -166,23 +168,22 @@ export function Typer({ words }: TyperProps) { debug("RUNNING"); const start = new Date(); setRunStartDate(() => start); - setWordStartTimestamp(() => new Date()); - dispatch({ action: "RUNNING" }); + dispatchTyperState({ action: "RUNNING" }); await sleepStep(TIME_LIMIT * 1000, 50, () => { setStepTime(() => new Date()); }); - dispatch({ action: "STOPPED" }); + dispatchTyperState({ action: "STOPPED" }); await sleepStep(3000, 100, () => {}); reset(); } - function debug(state: string) { - console.log(state, { + function debug(s: string) { + console.log(s, { correctWords: runCorrectWords, - errors: runErrors, + errors: currentWordErrors, startDate: runStartDate, time: timeStep, }); @@ -205,7 +206,10 @@ export function Typer({ words }: TyperProps) { } function getElapsedMilliseconds() { - if (state.runningState === "STOPPED" || state.runningState === "RESET") { + if ( + typerState.runningState === "STOPPED" || + typerState.runningState === "RESET" + ) { return TIME_LIMIT * 1000; } @@ -217,11 +221,11 @@ export function Typer({ words }: TyperProps) { } function getScrollX() { - const characters = words.slice(0, targetWordIndex).reduce((sum, curr) => { + const characters = words.slice(0, currentWordIndex).reduce((sum, curr) => { return sum + curr.length; }, 0); const textWidth = characters * CHAR_WIDTH; - const spacesWidth = targetWordIndex * CHAR_WIDTH; + const spacesWidth = currentWordIndex * CHAR_WIDTH; const scrollX = -1 * (textWidth + spacesWidth); return scrollX; } @@ -250,7 +254,7 @@ export function Typer({ words }: TyperProps) { return ( {char} @@ -261,7 +265,7 @@ export function Typer({ words }: TyperProps) { return ( {char} @@ -279,8 +283,15 @@ export function Typer({ words }: TyperProps) { } function renderWord(word: string, wordIndex: number) { + const fontSize = (1.5 * wordIndex) / WORDS_AHEAD; + const style = {}; // { fontSize: `${fontSize}em` }; + return ( - + {word} ); @@ -289,8 +300,8 @@ export function Typer({ words }: TyperProps) { function renderDebug() { return (
-

{state.runningState}

-

{state.history.length} runs

+

{typerState.runningState}

+

{typerState.completedRuns.length} runs

); } @@ -302,14 +313,16 @@ export function Typer({ words }: TyperProps) {
{words - .slice(targetWordIndex - WORDS_BEHIND, targetWordIndex) + .slice(currentWordIndex - WORDS_BEHIND, currentWordIndex) .map((w, index) => renderWord(w, index))}
-
{renderCurrentWord()}
+
+ {renderCurrentWord()} +
{words - .slice(targetWordIndex + 1, targetWordIndex + WORDS_AHEAD + 1) - .map((w, index) => renderWord(w, index))} + .slice(currentWordIndex + 1, currentWordIndex + WORDS_AHEAD + 1) + .map((w, index) => renderWord(w, WORDS_AHEAD - index - 1))}
); @@ -351,10 +364,10 @@ export function Typer({ words }: TyperProps) {
- +
diff --git a/src/components/chart/Chart.tsx b/src/components/chart/Chart.tsx new file mode 100644 index 0000000..2e3d907 --- /dev/null +++ b/src/components/chart/Chart.tsx @@ -0,0 +1,179 @@ +"use client"; + +import { DEBUG } from "../Typer"; +import { bezierPath } from "./bezier"; + +export type Serie = { + label: string; + data: Point[]; +}; + +export type Point = { + x: number; + y: number; + label: string; +}; + +type ChartProps = { + type: "line" | "curve"; + series: Serie[]; + width: number; + height: number; + xLabel: string; + yLabel: string; + padding: number; + className?: string; + renderPoint?: (point: Point) => JSX.Element; + renderPointLabel?: (point: Point) => JSX.Element; +}; + +const COLORS = ["red-500", "red-500", "red-500", "red-500", "red-500"]; + +export function Chart({ + className = "", + series, + width, + height, + xLabel = "X", + yLabel = "Y", + type = "line", + renderPointLabel = (point) => <>{point.label}, + padding = 1, +}: ChartProps) { + const xMax = Math.max(...series.flatMap((s) => s.data).map((d) => d.x)); + const yMax = Math.max(...series.flatMap((s) => s.data).map((d) => d.y)); + + function getChartX(x: number) { + return (x / xMax) * width; + } + + function getChartY(y: number) { + return height - (y / yMax) * height; + } + + function getChartCoordinates(points: Point[]) { + return points.reduce((sum, curr) => { + const x = getChartX(curr.x); + const y = getChartY(curr.y); + return [...sum, x, y]; + }, [] as number[]); + } + + function renderDebug() { + return [ + { x: 0, y: 2 }, + { x: width, y: height - 2 }, + { x: 0, y: height - 2 }, + { x: width, y: 2 }, + ].map((point) => ( + + ({point.x},{point.y}) + + )); + } + + function renderCurve(serie: Point[], label: string, seriesColor: string) { + const path = bezierPath(serie); + + return ( + + + + {serie.map((point) => { + return ( + + {point.label} + + ); + })} + + ); + } + + function renderLine(points: Point[], label: string, seriesColor: string) { + return ( + + {points.map((p, index) => { + return ( + + ); + })} + + ); + } + + return ( + + <>{false && renderDebug()} + + {series.map((serie, index) => { + const seriesColor = COLORS[index % COLORS.length]; + const serieChartCoordinates = serie.data + .map((s) => ({ + label: s.label, + y: getChartY(s.y), + x: getChartX(s.x), + })) + .sort((a, b) => a.x - b.x); + + switch (type) { + case "curve": + return renderCurve(serieChartCoordinates, serie.label, seriesColor); + case "line": + return renderLine(serieChartCoordinates, serie.label, seriesColor); + default: + throw new Error("No chart type set"); + } + })} + + + {yLabel} + + + + {xLabel} + + + + + + + + ); +} diff --git a/src/components/chart/bezier.ts b/src/components/chart/bezier.ts new file mode 100644 index 0000000..5e796da --- /dev/null +++ b/src/components/chart/bezier.ts @@ -0,0 +1,55 @@ +import { Point } from "./Chart"; + +export function bezierPath(points: Point[]) { + return points.reduce( + (acc, point, i, a) => + i === 0 + ? `M ${point.x},${point.y}` + : `${acc} ${bezierCommand(point, i, a)}`, + "" + ); +} + +function line(pointA: Point, pointB: Point) { + const lengthX = pointB.x - pointA.x; + const lengthY = pointB.y - pointA.y; + + return { + length: Math.sqrt(Math.pow(lengthX, 2) + Math.pow(lengthY, 2)), + angle: Math.atan2(lengthY, lengthX), + }; +} + +function controlPoint( + current: Point, + previous: Point, + next: Point, + reverse: boolean = false, + smoothing = 0.2 +) { + const o = line(previous, next); + const angle = o.angle + (reverse ? Math.PI : 0); + const length = o.length * smoothing; + const x = current.x + Math.cos(angle) * length; + const y = current.y + Math.sin(angle) * length; + return [x, y]; +} + +function bezierCommand(point: Point, i: number, a: Point[]) { + // start control point + const [cpsX, cpsY] = controlPoint( + a[Math.max(i - 1, 0)], + a[Math.max(i - 2, 0)], + point + ); + + // end control point + const [cpeX, cpeY] = controlPoint( + point, + a[Math.max(i - 1, 0)], + a[Math.min(i + 1, a.length - 1)], + true + ); + + return `C ${cpsX}, ${cpsY} ${cpeX}, ${cpeY} ${point.x}, ${point.x}`; +} diff --git a/src/data/common.ts b/src/data/common.ts index 803834a..e57b631 100644 --- a/src/data/common.ts +++ b/src/data/common.ts @@ -1,3 +1,18 @@ +import { getRandomGenerator } from "@/lib/random"; + +export function randomizeWords( + words: string[], + limit: number = 500, + seed: number = 1 +) { + const random = getRandomGenerator(seed); + + return new Array(limit) + .fill(0) + .map((_) => Math.floor(random() * words.length)) + .map((n) => words[n].toLowerCase()); +} + const common = [ "a", "able", diff --git a/src/data/mockState.ts b/src/data/mockState.ts new file mode 100644 index 0000000..bd1fa18 --- /dev/null +++ b/src/data/mockState.ts @@ -0,0 +1,67 @@ +import { Run } from "@/models/run"; +import common, { randomizeWords } from "./common"; +import { addMilliseconds, addSeconds } from "@/lib/date/date"; +import { getRandom } from "@/lib/random"; + +export function getMockHistory() { + const startDate = new Date(); + const runs = [ + getMockRun(60 + Math.floor(getRandom() * 20), startDate), + getMockRun( + 90 + Math.floor(getRandom() * 30), + addSeconds(startDate, (60 + Math.random() * 180) * 1000) + ), + getMockRun( + 100 + Math.floor(getRandom() * 40), + addSeconds(startDate, (240 + Math.random() * 180) * 1000) + ), + ]; + + return [getMockRun(5, startDate)]; +} + +export function getMockRun( + numberOfWords: number, + startDate: Date, + seed = 2 +): Run { + const randomWords = randomizeWords(common, numberOfWords, seed); + const wordTimePadding = (60 * 1000) / randomWords.length; + + const correctWordsSorted = randomWords + .reduce( + (sum, curr, index) => { + const previousWordEnddate = sum[index] + ? sum[index].endTimestamp + : startDate; + + const endTimestamp = addMilliseconds( + previousWordEnddate, + 125 + wordTimePadding * Math.random() + ); + + return [ + ...sum, + { + errors: Math.floor(Math.random() * Math.random() * curr.length), + word: curr, + endTimestamp, + }, + ]; + }, + [ + { + errors: 0, + word: randomWords[Math.floor(getRandom() * randomWords.length)], + endTimestamp: addMilliseconds(startDate, 125 + wordTimePadding), + }, + ] + ) + .sort((a, b) => a.endTimestamp.getTime() - b.endTimestamp.getTime()); + + return { + correctWords: correctWordsSorted, + startDate: startDate, + timeLimitSeconds: 60, + }; +} diff --git a/src/lib/date/date.ts b/src/lib/date/date.ts new file mode 100644 index 0000000..8bfdbe0 --- /dev/null +++ b/src/lib/date/date.ts @@ -0,0 +1,7 @@ +export function addMilliseconds(date: Date, milliSeconds: number) { + return new Date(date.getTime() + milliSeconds); +} + +export function addSeconds(date: Date, seconds: number) { + return new Date(date.getTime() + seconds * 1000); +} diff --git a/src/lib/random.ts b/src/lib/random.ts new file mode 100644 index 0000000..a8be274 --- /dev/null +++ b/src/lib/random.ts @@ -0,0 +1,15 @@ +export function getRandomGenerator(seed: number = 1) { + // https://stackoverflow.com/questions/521295/seeding-the-random-number-generator-in-javascript + return function () { + var t = (seed += 0x6d2b79f5); + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +const generator = getRandomGenerator(); + +export function getRandom() { + return generator(); +} diff --git a/src/lib/sleep.ts b/src/lib/sleep/sleep.ts similarity index 100% rename from src/lib/sleep.ts rename to src/lib/sleep/sleep.ts diff --git a/src/lib/stats/type-speed.test.ts b/src/lib/stats/type-speed.test.ts new file mode 100644 index 0000000..02a3c0e --- /dev/null +++ b/src/lib/stats/type-speed.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "@jest/globals"; +import { getSlots } from "./type-speed"; + +describe("getSlots", () => { + test("should return frames with the correct words within each frame", () => { + const dates = [ + new Date("2023-07-10 10:00:01"), + new Date("2023-07-10 10:00:01"), + new Date("2023-07-10 10:00:01"), + new Date("2023-07-10 10:00:01"), + new Date("2023-07-10 10:00:02"), + new Date("2023-07-10 10:00:03"), + ]; + + const frameFrequencies = getSlots( + dates, + new Date("2023-07-10 10:00:00"), + new Date("2023-07-10 10:00:10"), + 10 + ); + + expect(frameFrequencies.length).toBe(10); + expect(frameFrequencies[0]).toBe(0); + expect(frameFrequencies[1]).toBe(4); + expect(frameFrequencies[2]).toBe(1); + expect(frameFrequencies[3]).toBe(1); + }); +}); diff --git a/src/lib/stats/type-speed.ts b/src/lib/stats/type-speed.ts new file mode 100644 index 0000000..0b08212 --- /dev/null +++ b/src/lib/stats/type-speed.ts @@ -0,0 +1,38 @@ +function getSampleIndexer( + startDate: Date, + endDate: Date, + samples: number = 60 +) { + const diffMilliseconds = endDate.getTime() - startDate.getTime(); + const frameSizeMilliseconds = diffMilliseconds / samples; + const dateFrames = new Array(samples).fill(0).map((_, index) => { + return { + start: new Date(startDate.getTime() + frameSizeMilliseconds * index), + end: new Date(startDate.getTime() + frameSizeMilliseconds * (index + 1)), + }; + }); + + return (date: Date) => { + return dateFrames.findIndex((f) => { + return ( + date.getTime() >= f.start.getTime() && date.getTime() < f.end.getTime() + ); + }); + }; +} + +export function getSlots( + dates: Date[], + startDate: Date, + endDate: Date, + samples: number = 60 +) { + const findSlotIndex = getSampleIndexer(startDate, endDate, samples); + const arr = new Array(samples).fill(0); + + return dates.reduce((sum, curr) => { + const index = findSlotIndex(curr); + sum[index]++; + return sum; + }, arr); +} diff --git a/src/lib/words.test.ts b/src/lib/typer/correct-chars.test.ts similarity index 97% rename from src/lib/words.test.ts rename to src/lib/typer/correct-chars.test.ts index d20e692..36d0b49 100644 --- a/src/lib/words.test.ts +++ b/src/lib/typer/correct-chars.test.ts @@ -1,4 +1,4 @@ -import { incorrectCharacterIndex, lastCorrectIndex } from "./words"; +import { incorrectCharacterIndex, lastCorrectIndex } from "./correct-chars"; import { describe, expect, test } from "@jest/globals"; describe("incorrectCharacterIndex", () => { diff --git a/src/lib/words.ts b/src/lib/typer/correct-chars.ts similarity index 100% rename from src/lib/words.ts rename to src/lib/typer/correct-chars.ts diff --git a/src/lib/key-to-char.ts b/src/lib/typer/key-to-char.ts similarity index 100% rename from src/lib/key-to-char.ts rename to src/lib/typer/key-to-char.ts diff --git a/src/models/run.ts b/src/models/run.ts index 22fc323..a1c7461 100644 --- a/src/models/run.ts +++ b/src/models/run.ts @@ -1,12 +1,11 @@ export type Run = { startDate: Date; - errors: number; correctWords: CompletedWord[]; timeLimitSeconds: number; }; export type CompletedWord = { + errors: number; word: string; - startTimestamp: Date; endTimestamp: Date; };