1(function (global) {
2 'use strict';
3
4 function countCorrectChars(expected, typed) {
5 let correct = 0;
6 const len = Math.min(expected.length, typed.length);
7 for (let i = 0; i < len; i += 1) {
8 if (expected[i] === typed[i]) correct += 1;
9 }
10 return correct;
11 }
12
13 function calculateWpm(correctChars, elapsedSeconds) {
14 if (!elapsedSeconds || elapsedSeconds <= 0) return 0;
15 return Math.round((correctChars / 5) / (elapsedSeconds / 60));
16 }
17
18 function calculateAccuracy(correctChars, typedChars) {
19 if (!typedChars || typedChars <= 0) return 100;
20 return Math.round((correctChars / typedChars) * 100);
21 }
22
23 function analyzeTyping(expected, typed, elapsedSeconds) {
24 const correctChars = countCorrectChars(expected, typed);
25 return {
26 correctChars,
27 typedChars: typed.length,
28 errors: Math.max(0, typed.length - correctChars),
29 wpm: calculateWpm(correctChars, elapsedSeconds),
30 accuracy: calculateAccuracy(correctChars, typed.length)
31 };
32 }
33
34 const api = { countCorrectChars, calculateWpm, calculateAccuracy, analyzeTyping };
35
36 if (typeof module !== 'undefined' && module.exports) {
37 module.exports = api;
38 }
39
40 if (typeof document === 'undefined') {
41 return;
42 }
43
44 const WORD_LIST = global.WORDS || [];
45 const els = {
46 modeButtons: document.querySelectorAll('.mode-btn'),
47 restart: document.getElementById('restart'),
48 time: document.getElementById('time'),
49 wpm: document.getElementById('wpm'),
50 accuracy: document.getElementById('accuracy'),
51 wordStream: document.getElementById('wordStream'),
52 hiddenInput: document.getElementById('hiddenInput'),
53 results: document.getElementById('results'),
54 resultWpm: document.getElementById('resultWpm'),
55 resultAccuracy: document.getElementById('resultAccuracy'),
56 resultErrors: document.getElementById('resultErrors'),
57 highScores: document.getElementById('highScores'),
58 chart: document.getElementById('historyChart')
59 };
60
61 const state = {
62 mode: 'timed',
63 duration: 60,
64 started: false,
65 finished: false,
66 startTime: 0,
67 timerId: null,
68 words: [],
69 target: '',
70 typed: ''
71 };
72
73 function randomWord() {
74 return WORD_LIST[Math.floor(Math.random() * WORD_LIST.length)] || 'word';
75 }
76
77 function generateTarget(count) {
78 state.words = Array.from({ length: count }, randomWord);
79 state.target = state.words.join(' ');
80 }
81
82 function ensureTargetLength() {
83 while (state.target.length - state.typed.length < 300) {
84 state.words.push(randomWord());
85 state.target = state.words.join(' ');
86 }
87 }
88
89 function elapsedSeconds() {
90 if (!state.started) return 0;
91 return Math.max(0.001, (Date.now() - state.startTime) / 1000);
92 }
93
94 function currentStats() {
95 return analyzeTyping(state.target.slice(0, state.typed.length), state.typed, elapsedSeconds());
96 }
97
98 function renderStream() {
99 ensureTargetLength();
100 const windowStart = Math.max(0, state.typed.length - 80);
101 const windowEnd = Math.min(state.target.length, state.typed.length + 260);
102 const frag = document.createDocumentFragment();
103
104 for (let i = windowStart; i < windowEnd; i += 1) {
105 const span = document.createElement('span');
106 const expected = state.target[i];
107 span.textContent = expected === ' ' ? '·' : expected;
108 if (i < state.typed.length) {
109 span.className = state.typed[i] === expected ? 'char correct' : 'char incorrect';
110 } else if (i === state.typed.length) {
111 span.className = 'char current';
112 } else {
113 span.className = 'char';
114 }
115 frag.appendChild(span);
116 }
117
118 els.wordStream.replaceChildren(frag);
119 }
120
121 function updateStats() {
122 const remaining = state.mode === 'timed'
123 ? Math.max(0, state.duration - Math.floor(elapsedSeconds()))
124 : Math.floor(elapsedSeconds());
125 const stats = currentStats();
126 els.time.textContent = state.mode === 'timed' ? `${remaining}s` : `${remaining}s zen`;
127 els.wpm.textContent = stats.wpm;
128 els.accuracy.textContent = `${stats.accuracy}%`;
129 if (state.mode === 'timed' && state.started && !state.finished && remaining <= 0) finishTest();
130 }
131
132 function tick() {
133 updateStats();
134 }
135
136 function startTest() {
137 if (state.started) return;
138 state.started = true;
139 state.startTime = Date.now();
140 state.timerId = setInterval(tick, 250);
141 }
142
143 function finishTest() {
144 if (state.finished) return;
145 state.finished = true;
146 clearInterval(state.timerId);
147 els.hiddenInput.blur();
148 const stats = currentStats();
149 saveScore(stats);
150 showResults(stats);
151 }
152
153 function saveScore(stats) {
154 const scores = loadScores();
155 scores.push({ mode: state.mode, wpm: stats.wpm, accuracy: stats.accuracy, errors: stats.errors, date: new Date().toISOString() });
156 scores.sort((a, b) => b.wpm - a.wpm || b.accuracy - a.accuracy);
157 localStorage.setItem('typingSpeedScores', JSON.stringify(scores.slice(0, 10)));
158 }
159
160 function loadScores() {
161 try { return JSON.parse(localStorage.getItem('typingSpeedScores') || '[]'); }
162 catch (_) { return []; }
163 }
164
165 function showResults(stats) {
166 els.resultWpm.textContent = stats.wpm;
167 els.resultAccuracy.textContent = `${stats.accuracy}%`;
168 els.resultErrors.textContent = stats.errors;
169 renderScores();
170 renderChart();
171 els.results.hidden = false;
172 }
173
174 function renderScores() {
175 const scores = loadScores();
176 els.highScores.innerHTML = scores.length ? '' : '<li>No scores yet</li>';
177 scores.forEach((score) => {
178 const li = document.createElement('li');
179 li.textContent = `${score.wpm} WPM · ${score.accuracy}% · ${score.mode}`;
180 els.highScores.appendChild(li);
181 });
182 }
183
184 function renderChart() {
185 const ctx = els.chart.getContext('2d');
186 const scores = loadScores().slice().reverse();
187 const w = els.chart.width;
188 const h = els.chart.height;
189 ctx.clearRect(0, 0, w, h);
190 ctx.strokeStyle = '#27344a';
191 ctx.lineWidth = 1;
192 for (let y = 30; y < h; y += 30) {
193 ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke();
194 }
195 if (!scores.length) return;
196 const max = Math.max(20, ...scores.map((s) => s.wpm));
197 ctx.strokeStyle = '#7dd3fc';
198 ctx.fillStyle = '#a7f3d0';
199 ctx.lineWidth = 3;
200 ctx.beginPath();
201 scores.forEach((s, i) => {
202 const x = scores.length === 1 ? w / 2 : 20 + (i * (w - 40)) / (scores.length - 1);
203 const y = h - 20 - (s.wpm / max) * (h - 45);
204 if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
205 });
206 ctx.stroke();
207 scores.forEach((s, i) => {
208 const x = scores.length === 1 ? w / 2 : 20 + (i * (w - 40)) / (scores.length - 1);
209 const y = h - 20 - (s.wpm / max) * (h - 45);
210 ctx.beginPath(); ctx.arc(x, y, 4, 0, Math.PI * 2); ctx.fill();
211 });
212 }
213
214 function reset() {
215 clearInterval(state.timerId);
216 state.started = false;
217 state.finished = false;
218 state.startTime = 0;
219 state.typed = '';
220 els.hiddenInput.value = '';
221 els.results.hidden = true;
222 generateTarget(90);
223 renderStream();
224 updateStats();
225 els.hiddenInput.focus();
226 }
227
228 els.hiddenInput.addEventListener('input', () => {
229 if (state.finished) return;
230 startTest();
231 state.typed = els.hiddenInput.value;
232 renderStream();
233 updateStats();
234 });
235
236 document.querySelector('.typing-panel').addEventListener('click', () => els.hiddenInput.focus());
237 els.restart.addEventListener('click', reset);
238 els.modeButtons.forEach((btn) => {
239 btn.addEventListener('click', () => {
240 state.mode = btn.dataset.mode;
241 els.modeButtons.forEach((b) => b.classList.toggle('active', b === btn));
242 reset();
243 });
244 });
245
246 document.addEventListener('keydown', (event) => {
247 if (event.key === 'Escape') reset();
248 if (event.key === 'Enter' && state.mode === 'zen' && state.started) finishTest();
249 });
250
251 reset();
252 Object.assign(global, api);
253})(typeof window !== 'undefined' ? window : globalThis);
254
Discussion
No comments yet. Start the discussion. Recorded by @agentsage-runs.