Typing stats and code cleanup and organization

This commit is contained in:
2025-02-15 19:06:19 +01:00
parent 4fc0fbf1ed
commit 4bb441a125
13 changed files with 209 additions and 157 deletions
+22 -1
View File
@@ -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 .env
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
-24
View File
@@ -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?
View File
+2 -2
View File
@@ -1,7 +1,7 @@
import Code from './components/Code'; import Typing from 'pages/Typing';
function App() { function App() {
return <Code />; return <Typing />;
} }
export default App; export default App;
-10
View File
@@ -1,17 +1,7 @@
.container {
display: grid;
grid-template-columns: repeat(2, 1fr);
column-gap: 1rem;
}
.code { .code {
padding: 1rem; padding: 1rem;
} }
.score {
text-align: right;
}
.pending, .space { .pending, .space {
color: gray; color: gray;
} }
+23 -104
View File
@@ -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 { KEYS_TO_DISABLE } from 'constants/default';
import { renderCharacter } from './utils';
import './index.css'; import './index.css';
function Code() { type CodeProps = {
// TODO: Cleanup the file and create utils for trimming the code properly. code: string;
// TODO: Create a function that combines a sequence of spaces into tabs. loaded: boolean;
const [code, setCode] = useState<string>(''); }
const [loaded, setLoaded] = useState<boolean>(false);
const [startedTyping, setStartedTyping] = useState<boolean>(false);
const [characters, setCharacters] = useState<Array<boolean | 'space'>>([]);
const [timer, setTimer] = useState<number>(0);
const [score, setScore] = useState<number>(0);
const [accuracy, setAccuracy] = useState<number>(0);
useEffect(() => { function Code({code, loaded}: CodeProps) {
(async function () { const {
setCode(''); startedTyping,
characters,
timer,
const response = await fetch( setStartedTyping,
`${import.meta.env.VITE_API_URL}/generate?lang=lisp`, setCharacters,
); setScore,
setAccuracy,
if (!response.ok || !response.body) return; } = useTypingContext();
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]);
useEffect(() => { useEffect(() => {
function handleKeyPress(event: KeyboardEvent) { function handleKeyPress(event: KeyboardEvent) {
@@ -106,60 +73,12 @@ function Code() {
setScore((typed / 5 - incorrectlyTyped) / (timer / 60)); setScore((typed / 5 - incorrectlyTyped) / (timer / 60));
setAccuracy(correctlyTyped / typed * 100); setAccuracy(correctlyTyped / typed * 100);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [timer, characters]); }, [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 (
<span className={renderClassName(index)} key={char + index}>
<br />
</span>
);
}
return (
<span className={renderClassName(index)} key={char + index}>
{rendered}
</span>
);
}
return ( return (
<div className='container'> <div className='code'>
<div className='code'> {code.split('').map((char, index) => renderCharacter(code, characters, loaded, char, index))}
{code.split('').map((char, index) => renderCharacter(char, index))}
</div>
<div className='score'>
<p>Time: {timer}</p>
<p>WPM: {Math.round(score)}</p>
<p>Accuracy: {Math.round(accuracy)}%</p>
</div>
</div> </div>
); );
} }
+57
View File
@@ -0,0 +1,57 @@
function renderCharacterClassName(
code: string,
characters: Array<boolean | 'space'>,
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<boolean | 'space'>,
loaded: boolean,
index: number,
) {
return `${renderCharacterClassName(code, characters, loaded, index)}`.trim();
}
export function renderCharacter(
code: string,
characters: Array<boolean | 'space'>,
loaded: boolean,
char: string,
index: number,
) {
const rendered = renderSpacingCharacter(char);
if (/^\n$/.test(rendered)) {
return (
<span className={renderClassName(code, characters, loaded, index)} key={char + index}>
<br />
</span>
);
}
return (
<span className={renderClassName(code, characters, loaded, index)} key={char + index}>
{rendered}
</span>
);
}
+3
View File
@@ -0,0 +1,3 @@
.score {
text-align: right;
}
+8 -4
View File
@@ -1,9 +1,13 @@
import { useState } from "react"; import { useTypingContext } from "contexts/typing";
import './index.css';
function Score() { function Score() {
const [timer] = useState<number>(0); const {
const [score] = useState<number>(0); timer,
const [accuracy] = useState<number>(0); score,
accuracy,
} = useTypingContext();
return ( return (
<div className='score'> <div className='score'>
@@ -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; startedTyping: boolean;
characters: Array<boolean | 'space'>; characters: Array<boolean | 'space'>;
timer: number;
score: number; score: number;
accuracy: number; accuracy: number;
setStartedTyping: React.Dispatch<React.SetStateAction<boolean>>; setStartedTyping: React.Dispatch<React.SetStateAction<boolean>>;
setCharacters: React.Dispatch<React.SetStateAction<Array<boolean | 'space'>>>; setCharacters: React.Dispatch<React.SetStateAction<Array<boolean | 'space'>>>;
setTimer: React.Dispatch<React.SetStateAction<number>>;
setScore: React.Dispatch<React.SetStateAction<number>>; setScore: React.Dispatch<React.SetStateAction<number>>;
setAccuracy: React.Dispatch<React.SetStateAction<number>>; setAccuracy: React.Dispatch<React.SetStateAction<number>>;
} }
const CodeContext = createContext<CodeContextValues>({ const TypingContext = createContext<TypingContextValues>({
startedTyping: false, startedTyping: false,
characters: [], characters: [],
timer: 0,
score: 0, score: 0,
accuracy: 0, accuracy: 0,
setStartedTyping: () => { }, setStartedTyping: () => { },
setCharacters: () => { }, setCharacters: () => { },
setTimer: () => { },
setScore: () => { }, setScore: () => { },
setAccuracy: () => { }, setAccuracy: () => { },
}); });
export function useCodeContext() { export function useTypingContext() {
return useContext(CodeContext); return useContext(TypingContext);
} }
type CodeContextProviderProps = { type TypingContextProviderProps = {
children: React.ReactNode, children: React.ReactNode,
} }
function CodeContextProvider({ children }: CodeContextProviderProps) { function TypingContextProvider({ children }: TypingContextProviderProps) {
const [startedTyping, setStartedTyping] = useState<boolean>(false); const [startedTyping, setStartedTyping] = useState<boolean>(false);
const [characters, setCharacters] = useState<Array<boolean | 'space'>>([]); const [characters, setCharacters] = useState<Array<boolean | 'space'>>([]);
const [timer, setTimer] = useState<number>(0);
const [score, setScore] = useState<number>(0); const [score, setScore] = useState<number>(0);
const [accuracy, setAccuracy] = useState<number>(0); const [accuracy, setAccuracy] = useState<number>(0);
useEffect(() => {
let interval = null;
if (!startedTyping) {
if (interval) clearInterval(interval);
return;
}
interval = setInterval(() => {
setTimer(prev => prev + 1);
}, 1000);
return () => clearInterval(interval);
}, [startedTyping]);
return ( return (
<CodeContext.Provider value={{ <TypingContext.Provider value={{
startedTyping, startedTyping,
characters, characters,
timer,
score, score,
accuracy, accuracy,
setStartedTyping, setStartedTyping,
setCharacters, setCharacters,
setTimer,
setScore, setScore,
setAccuracy, setAccuracy,
}}> }}>
{children} {children}
</CodeContext.Provider> </TypingContext.Provider>
) )
} }
export default CodeContextProvider; export default TypingContextProvider;
+4 -2
View File
@@ -1,6 +1,8 @@
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import App from './App.tsx';
import './index.css'; 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(<App />); createRoot(document.getElementById('root')).render(<App />);
+5
View File
@@ -0,0 +1,5 @@
.container {
display: grid;
grid-template-columns: repeat(2, 1fr);
column-gap: 1rem;
}
+52
View File
@@ -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<string>('');
const [loaded, setLoaded] = useState<boolean>(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 (
<TypingContextProvider>
<div className='container'>
<Code code={code} loaded={loaded} />
<Stats />
</div>
</TypingContextProvider>
);
}
export default Typing;