From 4bb441a12559ae4f29f328fe9ee71eff44c7b38b Mon Sep 17 00:00:00 2001 From: Hazem Krimi Date: Sat, 15 Feb 2025 19:06:19 +0100 Subject: [PATCH] Typing stats and code cleanup and organization --- .gitignore | 23 +++- client/.gitignore | 24 ---- client/src/App.css | 0 client/src/App.tsx | 4 +- client/src/components/Code/index.css | 10 -- client/src/components/Code/index.tsx | 127 ++++--------------- client/src/components/Code/utils.tsx | 57 +++++++++ client/src/components/Stats/index.css | 3 + client/src/components/Stats/index.tsx | 12 +- client/src/contexts/{code.tsx => typing.tsx} | 43 +++++-- client/src/main.tsx | 6 +- client/src/pages/Typing/index.css | 5 + client/src/pages/Typing/index.tsx | 52 ++++++++ 13 files changed, 209 insertions(+), 157 deletions(-) delete mode 100644 client/.gitignore delete mode 100644 client/src/App.css create mode 100644 client/src/components/Code/utils.tsx create mode 100644 client/src/components/Stats/index.css rename client/src/contexts/{code.tsx => typing.tsx} (52%) create mode 100644 client/src/pages/Typing/index.css create mode 100644 client/src/pages/Typing/index.tsx diff --git a/.gitignore b/.gitignore index b1bee9a..bfa451b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,24 @@ -/node_modules +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* +node_modules +dist +dist-ssr +*.local .env + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/client/.gitignore b/client/.gitignore deleted file mode 100644 index a547bf3..0000000 --- a/client/.gitignore +++ /dev/null @@ -1,24 +0,0 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - -node_modules -dist -dist-ssr -*.local - -# Editor directories and files -.vscode/* -!.vscode/extensions.json -.idea -.DS_Store -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? diff --git a/client/src/App.css b/client/src/App.css deleted file mode 100644 index e69de29..0000000 diff --git a/client/src/App.tsx b/client/src/App.tsx index 4a35f46..22dd136 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,7 +1,7 @@ -import Code from './components/Code'; +import Typing from 'pages/Typing'; function App() { - return ; + return ; } export default App; diff --git a/client/src/components/Code/index.css b/client/src/components/Code/index.css index aa417f2..0a87fed 100644 --- a/client/src/components/Code/index.css +++ b/client/src/components/Code/index.css @@ -1,17 +1,7 @@ -.container { - display: grid; - grid-template-columns: repeat(2, 1fr); - column-gap: 1rem; -} - .code { padding: 1rem; } -.score { - text-align: right; -} - .pending, .space { color: gray; } diff --git a/client/src/components/Code/index.tsx b/client/src/components/Code/index.tsx index 2627e70..7134614 100644 --- a/client/src/components/Code/index.tsx +++ b/client/src/components/Code/index.tsx @@ -1,62 +1,29 @@ -import { useEffect, useState } from 'react'; +import { useEffect } from 'react'; + +import { useTypingContext } from 'contexts/typing'; + import { KEYS_TO_DISABLE } from 'constants/default'; +import { renderCharacter } from './utils'; + import './index.css'; -function Code() { - // TODO: Cleanup the file and create utils for trimming the code properly. - // TODO: Create a function that combines a sequence of spaces into tabs. - const [code, setCode] = useState(''); - const [loaded, setLoaded] = useState(false); - const [startedTyping, setStartedTyping] = useState(false); - const [characters, setCharacters] = useState>([]); - const [timer, setTimer] = useState(0); - const [score, setScore] = useState(0); - const [accuracy, setAccuracy] = useState(0); +type CodeProps = { + code: string; + loaded: boolean; +} - useEffect(() => { - (async function () { - setCode(''); +function Code({code, loaded}: CodeProps) { + const { + startedTyping, + characters, + timer, - const response = await fetch( - `${import.meta.env.VITE_API_URL}/generate?lang=lisp`, - ); - - if (!response.ok || !response.body) return; - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - - while (true) { - const { value, done } = await reader.read(); - - setCode((prev) => prev + decoder.decode(value)); - - if (done) { - break; - } - } - - setCode((prev) => prev.trim()); - setLoaded(true); - })(); - }, []); - - useEffect(() => { - let interval = null; - - if (!startedTyping) { - if (interval) clearInterval(interval); - - return; - } - - interval = setInterval(() => { - setTimer(prev => prev + 1); - }, 1000); - - return () => clearInterval(interval); - }, [startedTyping]); + setStartedTyping, + setCharacters, + setScore, + setAccuracy, + } = useTypingContext(); useEffect(() => { function handleKeyPress(event: KeyboardEvent) { @@ -106,60 +73,12 @@ function Code() { setScore((typed / 5 - incorrectlyTyped) / (timer / 60)); setAccuracy(correctlyTyped / typed * 100); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [timer, characters]); - function renderCharacterClassName(index: number) { - const typed = characters[index]; - - if (loaded && index === characters.length) return 'highlight'; - if (typeof typed === 'undefined') return 'pending'; - if (typed === 'space') return 'space'; - if (!typed && /^\n$/.test(code[index])) return 'incorrect-enter'; - if (!typed && /^\s$/.test(code[index])) return 'incorrect-space'; - if (!typed) return 'incorrect'; - - return 'correct'; - } - - function renderSpacingCharacter(char: string) { - if (/^\n$/.test(char)) return '\n'; - if (/^(\s|\t)$/.test(char)) return '\u00A0'; - - return char; - } - - function renderClassName(index: number) { - return `${renderCharacterClassName(index)}`.trim(); - } - - function renderCharacter(char: string, index: number) { - const rendered = renderSpacingCharacter(char); - - if (/^\n$/.test(rendered)) { - return ( - -
-
- ); - } - - return ( - - {rendered} - - ); - } - return ( -
-
- {code.split('').map((char, index) => renderCharacter(char, index))} -
-
-

Time: {timer}

-

WPM: {Math.round(score)}

-

Accuracy: {Math.round(accuracy)}%

-
+
+ {code.split('').map((char, index) => renderCharacter(code, characters, loaded, char, index))}
); } diff --git a/client/src/components/Code/utils.tsx b/client/src/components/Code/utils.tsx new file mode 100644 index 0000000..4e939d5 --- /dev/null +++ b/client/src/components/Code/utils.tsx @@ -0,0 +1,57 @@ +function renderCharacterClassName( + code: string, + characters: Array, + loaded: boolean, + index: number, +) { + const typed = characters[index]; + + if (loaded && index === characters.length) return 'highlight'; + if (typeof typed === 'undefined') return 'pending'; + if (typed === 'space') return 'space'; + if (!typed && /^\n$/.test(code[index])) return 'incorrect-enter'; + if (!typed && /^\s$/.test(code[index])) return 'incorrect-space'; + if (!typed) return 'incorrect'; + + return 'correct'; +} + +function renderSpacingCharacter(char: string) { + if (/^\n$/.test(char)) return '\n'; + if (/^(\s|\t)$/.test(char)) return '\u00A0'; + + return char; +} + +function renderClassName( + code: string, + characters: Array, + loaded: boolean, + index: number, +) { + return `${renderCharacterClassName(code, characters, loaded, index)}`.trim(); +} + +export function renderCharacter( + code: string, + characters: Array, + loaded: boolean, + char: string, + index: number, +) { + const rendered = renderSpacingCharacter(char); + + if (/^\n$/.test(rendered)) { + return ( + +
+
+ ); + } + + return ( + + {rendered} + + ); +} diff --git a/client/src/components/Stats/index.css b/client/src/components/Stats/index.css new file mode 100644 index 0000000..65c3a4c --- /dev/null +++ b/client/src/components/Stats/index.css @@ -0,0 +1,3 @@ +.score { + text-align: right; +} diff --git a/client/src/components/Stats/index.tsx b/client/src/components/Stats/index.tsx index 7f7c610..5ac999e 100644 --- a/client/src/components/Stats/index.tsx +++ b/client/src/components/Stats/index.tsx @@ -1,9 +1,13 @@ -import { useState } from "react"; +import { useTypingContext } from "contexts/typing"; + +import './index.css'; function Score() { - const [timer] = useState(0); - const [score] = useState(0); - const [accuracy] = useState(0); + const { + timer, + score, + accuracy, + } = useTypingContext(); return (
diff --git a/client/src/contexts/code.tsx b/client/src/contexts/typing.tsx similarity index 52% rename from client/src/contexts/code.tsx rename to client/src/contexts/typing.tsx index 8c03b81..d5bc820 100644 --- a/client/src/contexts/code.tsx +++ b/client/src/contexts/typing.tsx @@ -1,58 +1,81 @@ -import { createContext, useContext, useState } from "react"; +import { createContext, useContext, useEffect, useState } from "react"; -export type CodeContextValues = { +export type TypingContextValues = { startedTyping: boolean; characters: Array; + timer: number; score: number; accuracy: number; setStartedTyping: React.Dispatch>; setCharacters: React.Dispatch>>; + setTimer: React.Dispatch>; setScore: React.Dispatch>; setAccuracy: React.Dispatch>; } -const CodeContext = createContext({ +const TypingContext = createContext({ startedTyping: false, characters: [], + timer: 0, score: 0, accuracy: 0, setStartedTyping: () => { }, setCharacters: () => { }, + setTimer: () => { }, setScore: () => { }, setAccuracy: () => { }, }); -export function useCodeContext() { - return useContext(CodeContext); +export function useTypingContext() { + return useContext(TypingContext); } -type CodeContextProviderProps = { +type TypingContextProviderProps = { children: React.ReactNode, } -function CodeContextProvider({ children }: CodeContextProviderProps) { +function TypingContextProvider({ children }: TypingContextProviderProps) { const [startedTyping, setStartedTyping] = useState(false); const [characters, setCharacters] = useState>([]); + const [timer, setTimer] = useState(0); const [score, setScore] = useState(0); const [accuracy, setAccuracy] = useState(0); + useEffect(() => { + let interval = null; + + if (!startedTyping) { + if (interval) clearInterval(interval); + + return; + } + + interval = setInterval(() => { + setTimer(prev => prev + 1); + }, 1000); + + return () => clearInterval(interval); + }, [startedTyping]); + return ( - {children} - + ) } -export default CodeContextProvider; +export default TypingContextProvider; diff --git a/client/src/main.tsx b/client/src/main.tsx index de57103..bb67a0e 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -1,6 +1,8 @@ import { createRoot } from 'react-dom/client'; -import App from './App.jsx'; + +import App from './App.tsx'; + import './index.css'; -// TODO: Bring back string mode when building and deploying +// TODO: Bring back strict mode when building and deploying createRoot(document.getElementById('root')).render(); diff --git a/client/src/pages/Typing/index.css b/client/src/pages/Typing/index.css new file mode 100644 index 0000000..2a8c521 --- /dev/null +++ b/client/src/pages/Typing/index.css @@ -0,0 +1,5 @@ +.container { + display: grid; + grid-template-columns: repeat(2, 1fr); + column-gap: 1rem; +} diff --git a/client/src/pages/Typing/index.tsx b/client/src/pages/Typing/index.tsx new file mode 100644 index 0000000..1c0886d --- /dev/null +++ b/client/src/pages/Typing/index.tsx @@ -0,0 +1,52 @@ +import { useEffect, useState } from 'react'; + +import TypingContextProvider from 'contexts/typing'; + +import Code from 'components/Code'; +import Stats from 'components/Stats'; + +import './index.css'; + +function Typing() { + const [code, setCode] = useState(''); + const [loaded, setLoaded] = useState(false); + + useEffect(() => { + (async function () { + setCode(''); + + const response = await fetch( + `${import.meta.env.VITE_API_URL}/generate?lang=lisp`, + ); + + if (!response.ok || !response.body) return; + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + + while (true) { + const { value, done } = await reader.read(); + + setCode((prev) => prev + decoder.decode(value)); + + if (done) { + break; + } + } + + setCode((prev) => prev.trim()); + setLoaded(true); + })(); + }, []); + + return ( + +
+ + +
+
+ ); +} + +export default Typing;