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