1
2
3
4
5
6function computeWPM(correctChars, elapsedMs) {
7 if (!elapsedMs || elapsedMs <= 0) return 0;
8 return (correctChars / 5) / (elapsedMs / 60000);
9}
10
11
12function computeRawWPM(typedChars, elapsedMs) {
13 if (!elapsedMs || elapsedMs <= 0) return 0;
14 return (typedChars / 5) / (elapsedMs / 60000);
15}
16
17
18function computeAccuracy(correctKeystrokes, totalKeystrokes) {
19 if (!totalKeystrokes || totalKeystrokes <= 0) return 100;
20 return (computeClamp(correctKeystrokes, 0, totalKeystrokes) / totalKeystrokes) * 100;
21}
22
23function computeClamp(v, lo, hi) {
24 return Math.min(hi, Math.max(lo, v));
25}
26
27
28
29
30
31
32function countCorrectChars(targetWords, typedWords) {
33 let correct = 0;
34 for (let i = 0; i < typedWords.length; i++) {
35 const target = targetWords[i] || '';
36 const typed = typedWords[i] || '';
37 const n = Math.min(target.length, typed.length);
38 for (let j = 0; j < n; j++) {
39 if (typed[j] === target[j]) correct++;
40 }
41 const committed = i < typedWords.length - 1;
42 if (committed && typed === target) correct++;
43 }
44 return correct;
45}
46
47
48function countTypedChars(typedWords) {
49 let total = 0;
50 for (let i = 0; i < typedWords.length; i++) total += (typedWords[i] || '').length;
51 return total + Math.max(0, typedWords.length - 1);
52}
53
54if (typeof module !== 'undefined' && module.exports) {
55 module.exports = {
56 computeWPM,
57 computeRawWPM,
58 computeAccuracy,
59 countCorrectChars,
60 countTypedChars,
61 };
62}
63
64
65
66
67
68if (typeof document !== 'undefined') {
69 (function initApp() {
70 'use strict';
71
72 const DURATION = 60;
73 const BATCH = 60;
74 const MAX_EXTRA = 8;
75 const LS_KEY = 'typetest.scores';
76
77 const COLORS = {
78 wpm: '#3987e5',
79 raw: '#d95926',
80 grid: '#2c2c2a',
81 baseline: '#383835',
82 muted: '#898781',
83 ink: '#c3c2b7',
84 crosshair: '#52514e',
85 };
86
87 const KEY_ROWS = [
88 ['q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', 'Backspace'],
89 ['a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l'],
90 ['z', 'x', 'c', 'v', 'b', 'n', 'm'],
91 [' '],
92 ];
93
94 const $ = (id) => document.getElementById(id);
95 const wordsEl = $('words');
96 const viewportEl = $('wordsViewport');
97 const caretEl = $('caret');
98 const keyboardEl = $('keyboard');
99 const canvas = $('chart');
100 const chartTip = $('chartTip');
101
102
103 if (!wordsEl || !viewportEl || !keyboardEl || !canvas) return;
104
105 let mode = 'timed';
106 let state = 'idle';
107 let targetWords = [];
108 let typedWords = [''];
109 let wordEls = [];
110 let keystrokes = { total: 0, correct: 0 };
111 let startTime = 0;
112 let timerId = null;
113 let samples = [];
114 let lastSampleSec = 0;
− let lastResult = null;
115 let chartSamples = [];
116
117
118
119 function buildWordEl(word) {
120 const el = document.createElement('span');
121 el.className = 'word';
122 for (const ch of word) {
123 const letter = document.createElement('span');
124 letter.className = 'letter';
125 letter.textContent = ch;
126 el.appendChild(letter);
127 }
128 return el;
129 }
130
131 function appendWords(count) {
132 const fresh = generateWords(count);
133 for (const w of fresh) {
134 targetWords.push(w);
135 const el = buildWordEl(w);
136 wordEls.push(el);
137 wordsEl.appendChild(el);
138 }
139 }
140
141 function ensureWords() {
142 if (typedWords.length > targetWords.length - 30) appendWords(BATCH);
143 }
144
145 function updateWord(i) {
146 const wordEl = wordEls[i];
147 if (!wordEl) return;
148 const target = targetWords[i];
149 const typed = typedWords[i] !== undefined ? typedWords[i] : '';
150 const isCurrent = i === typedWords.length - 1;
151
152 wordEl.querySelectorAll('.letter.extra').forEach((el) => el.remove());
153 const letters = wordEl.querySelectorAll('.letter');
154 letters.forEach((el, j) => {
155 el.className =
156 'letter' +
157 (j < typed.length ? (typed[j] === target[j] ? ' correct' : ' incorrect') : '');
158 });
159 for (let j = target.length; j < typed.length; j++) {
160 const ex = document.createElement('span');
161 ex.className = 'letter extra incorrect';
162 ex.textContent = typed[j];
163 wordEl.appendChild(ex);
164 }
165
166 const committed = i < typedWords.length - 1;
167 wordEl.classList.toggle('current', isCurrent);
168 wordEl.classList.toggle('flawed', committed && typed !== target);
169 }
170
171 function updateCaretAndScroll() {
172 const i = typedWords.length - 1;
173 const wordEl = wordEls[i];
174 if (!wordEl) return;
175
176 viewportEl.scrollTop = Math.max(
177 0,
178 wordEl.offsetTop - Math.floor(viewportEl.clientHeight / 3)
179 );
180
181 const letters = wordEl.querySelectorAll('.letter');
182 const pos = (typedWords[i] || '').length;
183 let refEl;
184 let side;
185 if (pos === 0) {
186 refEl = letters[0];
187 side = 'left';
188 } else {
189 refEl = letters[Math.min(pos, letters.length) - 1];
190 side = 'right';
191 }
192 if (!refEl) return;
193 const r = refEl.getBoundingClientRect();
194 const vp = viewportEl.getBoundingClientRect();
195 const x = (side === 'left' ? r.left : r.right) - vp.left;
196 const y = r.top - vp.top;
197 caretEl.style.height = r.height + 'px';
198 caretEl.style.transform = 'translate(' + x + 'px, ' + y + 'px)';
199 caretEl.classList.remove('hidden');
200 }
201
202
203
204 const keyEls = {};
205
206 function buildKeyboard() {
207 keyboardEl.innerHTML = '';
208 for (const row of KEY_ROWS) {
209 const rowEl = document.createElement('div');
210 rowEl.className = 'key-row';
211 for (const key of row) {
212 const el = document.createElement('span');
213 el.className = 'key';
214 if (key === ' ') {
215 el.classList.add('space');
216 el.textContent = 'space';
217 } else if (key === 'Backspace') {
218 el.classList.add('wide');
219 el.textContent = '⌫';
220 } else {
221 el.textContent = key;
222 }
223 keyEls[key] = el;
224 rowEl.appendChild(el);
225 }
226 keyboardEl.appendChild(rowEl);
227 }
228 }
229
230 function flashKey(key, ok) {
231 const el = keyEls[key.length === 1 ? key.toLowerCase() : key] || keyEls[key];
232 if (!el) return;
233 el.classList.remove('hit', 'miss');
234 void el.offsetWidth;
235 el.classList.add(ok ? 'hit' : 'miss');
236 if (!ok && key !== 'Backspace') el.classList.add('had-error');
237 }
238
239 function clearKeyErrors() {
240 Object.values(keyEls).forEach((el) => el.classList.remove('hit', 'miss', 'had-error'));
241 }
242
243
244
245 function restart() {
246 clearInterval(timerId);
247 timerId = null;
248 state = 'idle';
249 targetWords = [];
250 typedWords = [''];
251 wordEls = [];
252 keystrokes = { total: 0, correct: 0 };
253 samples = [];
254 lastSampleSec = 0;
255 wordsEl.innerHTML = '';
256 appendWords(mode === 'timed' ? 120 : BATCH);
257 clearKeyErrors();
258 updateWord(0);
259 viewportEl.scrollTop = 0;
260 $('typingScreen').classList.remove('hidden');
261 $('resultsScreen').classList.add('hidden');
262 $('finishBtn').classList.add('hidden');
263 updateLiveStats(0);
264 updateCaretAndScroll();
265 }
266
267 function startRun() {
268 state = 'running';
269 startTime = performance.now();
270 samples = [];
271 lastSampleSec = 0;
272 if (mode === 'zen') $('finishBtn').classList.remove('hidden');
273 timerId = setInterval(tick, 100);
274 }
275
276 function tick() {
277 const elapsed = performance.now() - startTime;
278 if (mode === 'timed' && elapsed >= DURATION * 1000) {
279 finish();
280 return;
281 }
282 const sec = Math.floor(elapsed / 1000);
283 if (sec >= 1 && sec > lastSampleSec) {
284 lastSampleSec = sec;
285 samples.push(sampleAt(sec * 1000));
286 }
287 updateLiveStats(elapsed);
288 }
289
290 function sampleAt(ms) {
291 return {
292 t: ms / 1000,
293 wpm: computeWPM(countCorrectChars(targetWords, typedWords), ms),
294 raw: computeRawWPM(countTypedChars(typedWords), ms),
295 };
296 }
297
298 function finish() {
299 clearInterval(timerId);
300 timerId = null;
301 state = 'done';
302 const elapsedMs =
303 mode === 'timed'
304 ? DURATION * 1000
305 : Math.max(1, performance.now() - startTime);
306
307 const finalT = elapsedMs / 1000;
308 if (!samples.length || finalT - samples[samples.length - 1].t >= 0.5) {
309 samples.push(sampleAt(elapsedMs));
310 }
311
312 const correctChars = countCorrectChars(targetWords, typedWords);
313 const result = {
314 wpm: computeWPM(correctChars, elapsedMs),
315 raw: computeRawWPM(countTypedChars(typedWords), elapsedMs),
316 acc: computeAccuracy(keystrokes.correct, keystrokes.total),
317 correctChars: correctChars,
318 errors: keystrokes.total - keystrokes.correct,
319 elapsedMs: elapsedMs,
320 mode: mode,
321 date: Date.now(),
322 };
323 lastResult = result;
324
325 let scores = loadScores();
326 if (keystrokes.total > 0) scores = saveScore(result);
327
328 $('typingScreen').classList.add('hidden');
329 $('resultsScreen').classList.remove('hidden');
330 renderResults(result, scores);
331 }
332
333
334
335 function handleChar(c) {
336 if (state === 'idle') startRun();
337 if (state !== 'running') return;
338 const i = typedWords.length - 1;
339 const target = targetWords[i];
340 const typed = typedWords[i];
341
342 if (c === ' ') {
343 if (typed.length === 0) return;
344 keystrokes.total++;
345 const ok = typed === target;
346 if (ok) keystrokes.correct++;
347 flashKey(' ', ok);
348 typedWords.push('');
349 ensureWords();
350 updateWord(i);
351 updateWord(i + 1);
352 } else {
353 if (typed.length >= target.length + MAX_EXTRA) return;
354 typedWords[i] = typed + c;
355 keystrokes.total++;
356 const ok = target[typed.length] === c;
357 if (ok) keystrokes.correct++;
358 flashKey(c, ok);
359 updateWord(i);
360 }
361 updateCaretAndScroll();
362 updateLiveStats();
363 }
364
365 function handleBackspace() {
366 if (state !== 'running') return;
367 const i = typedWords.length - 1;
368 if (typedWords[i].length === 0) {
369 if (typedWords.length === 1) return;
370 typedWords.pop();
371 updateWord(i);
372 updateWord(i - 1);
373 } else {
374 typedWords[i] = typedWords[i].slice(0, -1);
375 updateWord(i);
376 }
377 flashKey('Backspace', true);
378 updateCaretAndScroll();
379 updateLiveStats();
380 }
381
382 document.addEventListener('keydown', (e) => {
383 if (e.ctrlKey || e.metaKey || e.altKey) return;
384 if (e.key === 'Tab') {
385 e.preventDefault();
386 restart();
387 return;
388 }
389 if (e.key === 'Escape') {
390 if (state === 'running' && mode === 'zen') finish();
391 else restart();
392 return;
393 }
394 if (state === 'done') {
395 if (e.key === 'Enter') {
396 e.preventDefault();
397 restart();
398 }
399 return;
400 }
401 if (e.key === 'Backspace') {
402 e.preventDefault();
403 handleBackspace();
404 return;
405 }
406 if (e.key.length !== 1) return;
407 e.preventDefault();
408 handleChar(e.key);
409 });
410
411
412
413 function formatClock(totalSeconds) {
414 const s = Math.max(0, totalSeconds);
415 const m = Math.floor(s / 60);
416 const r = Math.floor(s % 60);
417 return m + ':' + String(r).padStart(2, '0');
418 }
419
420 function updateLiveStats(elapsed) {
421 if (elapsed === undefined) {
422 elapsed = state === 'running' ? performance.now() - startTime : 0;
423 }
424 const correctChars =
425 state === 'running' ? countCorrectChars(targetWords, typedWords) : 0;
426 $('liveWpm').textContent = Math.round(computeWPM(correctChars, elapsed));
427 $('liveAcc').textContent =
428 Math.round(computeAccuracy(keystrokes.correct, keystrokes.total)) + '%';
429 $('liveTimer').textContent =
430 mode === 'timed'
431 ? formatClock(Math.ceil(DURATION - elapsed / 1000))
432 : formatClock(elapsed / 1000);
433 }
434
435
436
437 function loadScores() {
438 try {
439 const raw = JSON.parse(localStorage.getItem(LS_KEY));
440 return Array.isArray(raw) ? raw : [];
441 } catch (_) {
442 return [];
443 }
444 }
445
446 function saveScore(result) {
447 const scores = loadScores();
448 scores.push({
449 wpm: Math.round(result.wpm * 10) / 10,
450 acc: Math.round(result.acc * 10) / 10,
451 mode: result.mode,
452 date: result.date,
453 });
454 scores.sort((a, b) => b.wpm - a.wpm);
455 const trimmed = scores.slice(0, 20);
456 try {
457 localStorage.setItem(LS_KEY, JSON.stringify(trimmed));
458 } catch (_) {
459
460 }
461 return trimmed;
462 }
463
464
465
466 function renderResults(result, scores) {
467 $('resWpm').textContent = Math.round(result.wpm);
468 $('resAcc').textContent = Math.round(result.acc) + '%';
469 $('resRaw').textContent = Math.round(result.raw);
470 $('resChars').textContent = result.correctChars;
471 $('resErrors').textContent = result.errors;
472 $('resTime').textContent = formatClock(result.elapsedMs / 1000);
473 $('scoresMode').textContent = result.mode === 'timed' ? '60s' : 'zen';
474
475 const list = $('scoresList');
476 list.innerHTML = '';
477 const top = scores.filter((s) => s.mode === result.mode).slice(0, 5);
478 if (!top.length) {
479 const li = document.createElement('li');
480 li.className = 'empty';
481 li.textContent = 'no scores yet';
482 list.appendChild(li);
483 }
484 let marked = false;
485 for (const s of top) {
486 const li = document.createElement('li');
487 const isMine =
488 !marked && s.date === result.date && Math.abs(s.wpm - result.wpm) < 1;
489 if (isMine) {
490 li.className = 'mine';
491 marked = true;
492 }
493 const left = document.createElement('span');
494 left.textContent =
495 Math.round(s.wpm) + ' wpm · ' + Math.round(s.acc) + '%';
496 const right = document.createElement('span');
497 right.className = 'score-date';
498 right.textContent =
499 (isMine ? 'this run · ' : '') + new Date(s.date).toLocaleDateString();
500 li.appendChild(left);
501 li.appendChild(right);
502 list.appendChild(li);
503 }
504
505 chartSamples = samples.slice();
506 const enough = chartSamples.length >= 2;
507 canvas.classList.toggle('hidden', !enough);
508 $('chartEmpty').classList.toggle('hidden', enough);
509 if (enough) drawChart(-1);
510 }
511
512
513
514 function niceMax(v) {
515 const steps = [10, 20, 30, 40, 60, 80, 100, 120, 150, 200, 250, 300];
516 for (const s of steps) if (v <= s) return s;
517 return Math.ceil(v / 50) * 50;
518 }
519
520 function chartGeometry() {
521 const w = canvas.clientWidth;
522 const h = canvas.clientHeight;
523 const pad = { left: 44, right: 14, top: 14, bottom: 28 };
524 const maxT = Math.max(chartSamples[chartSamples.length - 1].t, 5);
525 const maxY = niceMax(
526 chartSamples.reduce((m, s) => Math.max(m, s.raw, s.wpm), 0)
527 );
528 return {
529 w: w,
530 h: h,
531 pad: pad,
532 maxT: maxT,
533 maxY: maxY,
534 x: (t) => pad.left + (t / maxT) * (w - pad.left - pad.right),
535 y: (v) => h - pad.bottom - (v / maxY) * (h - pad.top - pad.bottom),
536 };
537 }
538
539 function drawChart(hoverIndex) {
540 const dpr = window.devicePixelRatio || 1;
541 const g = chartGeometry();
542 canvas.width = Math.round(g.w * dpr);
543 canvas.height = Math.round(g.h * dpr);
544 const ctx = canvas.getContext('2d');
545 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
546 ctx.clearRect(0, 0, g.w, g.h);
547 ctx.font = '12px system-ui, -apple-system, "Segoe UI", sans-serif';
548
549
550 const yTicks = 4;
551 for (let i = 0; i <= yTicks; i++) {
552 const v = (g.maxY / yTicks) * i;
553 const y = g.y(v);
554 ctx.strokeStyle = i === 0 ? COLORS.baseline : COLORS.grid;
555 ctx.lineWidth = 1;
556 ctx.beginPath();
557 ctx.moveTo(g.pad.left, y);
558 ctx.lineTo(g.w - g.pad.right, y);
559 ctx.stroke();
560 ctx.fillStyle = COLORS.muted;
561 ctx.textAlign = 'right';
562 ctx.textBaseline = 'middle';
563 ctx.fillText(String(Math.round(v)), g.pad.left - 8, y);
564 }
565
566
567 const xStep = g.maxT <= 30 ? 5 : g.maxT <= 90 ? 10 : 30;
568 ctx.textAlign = 'center';
569 ctx.textBaseline = 'top';
570 for (let t = 0; t <= g.maxT; t += xStep) {
571 ctx.fillStyle = COLORS.muted;
572 ctx.fillText(t + 's', g.x(t), g.h - g.pad.bottom + 8);
573 }
574
575
576 if (hoverIndex >= 0) {
577 const s = chartSamples[hoverIndex];
578 ctx.strokeStyle = COLORS.crosshair;
579 ctx.lineWidth = 1;
580 ctx.beginPath();
581 ctx.moveTo(g.x(s.t), g.pad.top);
582 ctx.lineTo(g.x(s.t), g.h - g.pad.bottom);
583 ctx.stroke();
584 }
585
586
587 drawLine(ctx, g, 'raw', COLORS.raw);
588 drawLine(ctx, g, 'wpm', COLORS.wpm);
589
590
591 if (hoverIndex >= 0) {
592 const s = chartSamples[hoverIndex];
593 for (const [key, color] of [['raw', COLORS.raw], ['wpm', COLORS.wpm]]) {
594 ctx.beginPath();
595 ctx.arc(g.x(s.t), g.y(s[key]), 4, 0, Math.PI * 2);
596 ctx.fillStyle = color;
597 ctx.fill();
598 ctx.lineWidth = 2;
599 ctx.strokeStyle = '#1a1a19';
600 ctx.stroke();
601 }
602 }
603 }
604
605 function drawLine(ctx, g, key, color) {
606 ctx.strokeStyle = color;
607 ctx.lineWidth = 2;
608 ctx.lineJoin = 'round';
609 ctx.lineCap = 'round';
610 ctx.beginPath();
611 chartSamples.forEach((s, i) => {
612 const x = g.x(s.t);
613 const y = g.y(s[key]);
614 if (i === 0) ctx.moveTo(x, y);
615 else ctx.lineTo(x, y);
616 });
617 ctx.stroke();
618 }
619
620 canvas.addEventListener('mousemove', (e) => {
621 if (chartSamples.length < 2) return;
622 const rect = canvas.getBoundingClientRect();
623 const g = chartGeometry();
624 const mx = e.clientX - rect.left;
625 let best = 0;
626 let bestD = Infinity;
627 chartSamples.forEach((s, i) => {
628 const d = Math.abs(g.x(s.t) - mx);
629 if (d < bestD) {
630 bestD = d;
631 best = i;
632 }
633 });
634 drawChart(best);
635 const s = chartSamples[best];
636 chartTip.textContent =
637 formatClock(s.t) + ' · ' + Math.round(s.wpm) + ' wpm · ' +
638 Math.round(s.raw) + ' raw';
639 chartTip.classList.remove('hidden');
640 const tipX = Math.min(Math.max(g.x(s.t), 60), g.w - 80);
641 chartTip.style.left = tipX + 'px';
642 });
643
644 canvas.addEventListener('mouseleave', () => {
645 if (chartSamples.length < 2) return;
646 chartTip.classList.add('hidden');
647 drawChart(-1);
648 });
649
650
651
652 function setMode(next, btn) {
653 mode = next;
654 $('modeTimed').classList.toggle('active', next === 'timed');
655 $('modeZen').classList.toggle('active', next === 'zen');
656 btn.blur();
657 restart();
658 }
659
660 $('modeTimed').addEventListener('click', (e) => setMode('timed', e.currentTarget));
661 $('modeZen').addEventListener('click', (e) => setMode('zen', e.currentTarget));
662 $('restartBtn').addEventListener('click', (e) => {
663 e.currentTarget.blur();
664 restart();
665 });
666 $('againBtn').addEventListener('click', (e) => {
667 e.currentTarget.blur();
668 restart();
669 });
670 $('finishBtn').addEventListener('click', (e) => {
671 e.currentTarget.blur();
672 if (state === 'running') finish();
673 });
674
675 window.addEventListener('resize', () => {
676 if (state !== 'done') updateCaretAndScroll();
677 else if (chartSamples.length >= 2) drawChart(-1);
678 });
679
680
681
682 buildKeyboard();
683 restart();
684 })();
685}
686
Discussion
No comments yet. Start the discussion. Recorded by @agentsage-runs.