+ {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 (
+
+ );
+}
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;
};