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 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 };
− lastResult = result;
−
323 let scores = loadScores();
324 if (keystrokes.total > 0) scores = saveScore(result);
325
326 $('typingScreen').classList.add('hidden');
327 $('resultsScreen').classList.remove('hidden');
328 renderResults(result, scores);
329 }
330
331
332
333 function handleChar(c) {
334 if (state === 'idle') startRun();
335 if (state !== 'running') return;
336 const i = typedWords.length - 1;
337 const target = targetWords[i];
338 const typed = typedWords[i];
339
340 if (c === ' ') {
341 if (typed.length === 0) return;
342 keystrokes.total++;
343 const ok = typed === target;
344 if (ok) keystrokes.correct++;
345 flashKey(' ', ok);
346 typedWords.push('');
347 ensureWords();
348 updateWord(i);
349 updateWord(i + 1);
350 } else {
351 if (typed.length >= target.length + MAX_EXTRA) return;
352 typedWords[i] = typed + c;
353 keystrokes.total++;
354 const ok = target[typed.length] === c;
355 if (ok) keystrokes.correct++;
356 flashKey(c, ok);
357 updateWord(i);
358 }
359 updateCaretAndScroll();
360 updateLiveStats();
361 }
362
363 function handleBackspace() {
364 if (state !== 'running') return;
365 const i = typedWords.length - 1;
366 if (typedWords[i].length === 0) {
367 if (typedWords.length === 1) return;
368 typedWords.pop();
369 updateWord(i);
370 updateWord(i - 1);
371 } else {
372 typedWords[i] = typedWords[i].slice(0, -1);
373 updateWord(i);
374 }
375 flashKey('Backspace', true);
376 updateCaretAndScroll();
377 updateLiveStats();
378 }
379
380 document.addEventListener('keydown', (e) => {
381 if (e.ctrlKey || e.metaKey || e.altKey) return;
382 if (e.key === 'Tab') {
383 e.preventDefault();
384 restart();
385 return;
386 }
387 if (e.key === 'Escape') {
388 if (state === 'running' && mode === 'zen') finish();
389 else restart();
390 return;
391 }
392 if (state === 'done') {
393 if (e.key === 'Enter') {
394 e.preventDefault();
395 restart();
396 }
397 return;
398 }
399 if (e.key === 'Backspace') {
400 e.preventDefault();
401 handleBackspace();
402 return;
403 }
404 if (e.key.length !== 1) return;
405 e.preventDefault();
406 handleChar(e.key);
407 });
408
409
410
411 function formatClock(totalSeconds) {
412 const s = Math.max(0, totalSeconds);
413 const m = Math.floor(s / 60);
414 const r = Math.floor(s % 60);
415 return m + ':' + String(r).padStart(2, '0');
416 }
417
418 function updateLiveStats(elapsed) {
419 if (elapsed === undefined) {
420 elapsed = state === 'running' ? performance.now() - startTime : 0;
421 }
422 const correctChars =
423 state === 'running' ? countCorrectChars(targetWords, typedWords) : 0;
424 $('liveWpm').textContent = Math.round(computeWPM(correctChars, elapsed));
425 $('liveAcc').textContent =
426 Math.round(computeAccuracy(keystrokes.correct, keystrokes.total)) + '%';
427 $('liveTimer').textContent =
428 mode === 'timed'
429 ? formatClock(Math.ceil(DURATION - elapsed / 1000))
430 : formatClock(elapsed / 1000);
431 }
432
433
434
435 function loadScores() {
436 try {
437 const raw = JSON.parse(localStorage.getItem(LS_KEY));
438 return Array.isArray(raw) ? raw : [];
439 } catch (_) {
440 return [];
441 }
442 }
443
444 function saveScore(result) {
445 const scores = loadScores();
446 scores.push({
447 wpm: Math.round(result.wpm * 10) / 10,
448 acc: Math.round(result.acc * 10) / 10,
449 mode: result.mode,
450 date: result.date,
451 });
452 scores.sort((a, b) => b.wpm - a.wpm);
453 const trimmed = scores.slice(0, 20);
454 try {
455 localStorage.setItem(LS_KEY, JSON.stringify(trimmed));
456 } catch (_) {
457
458 }
459 return trimmed;
460 }
461
462
463
464 function renderResults(result, scores) {
465 $('resWpm').textContent = Math.round(result.wpm);
466 $('resAcc').textContent = Math.round(result.acc) + '%';
467 $('resRaw').textContent = Math.round(result.raw);
468 $('resChars').textContent = result.correctChars;
469 $('resErrors').textContent = result.errors;
470 $('resTime').textContent = formatClock(result.elapsedMs / 1000);
471 $('scoresMode').textContent = result.mode === 'timed' ? '60s' : 'zen';
472
473 const list = $('scoresList');
474 list.innerHTML = '';
475 const top = scores.filter((s) => s.mode === result.mode).slice(0, 5);
476 if (!top.length) {
477 const li = document.createElement('li');
478 li.className = 'empty';
479 li.textContent = 'no scores yet';
480 list.appendChild(li);
481 }
482 let marked = false;
483 for (const s of top) {
484 const li = document.createElement('li');
485 const isMine =
486 !marked && s.date === result.date && Math.abs(s.wpm - result.wpm) < 1;
487 if (isMine) {
488 li.className = 'mine';
489 marked = true;
490 }
491 const left = document.createElement('span');
492 left.textContent =
493 Math.round(s.wpm) + ' wpm · ' + Math.round(s.acc) + '%';
494 const right = document.createElement('span');
495 right.className = 'score-date';
496 right.textContent =
497 (isMine ? 'this run · ' : '') + new Date(s.date).toLocaleDateString();
498 li.appendChild(left);
499 li.appendChild(right);
500 list.appendChild(li);
501 }
502
503 chartSamples = samples.slice();
504 const enough = chartSamples.length >= 2;
505 canvas.classList.toggle('hidden', !enough);
506 $('chartEmpty').classList.toggle('hidden', enough);
507 if (enough) drawChart(-1);
508 }
509
510
511
512 function niceMax(v) {
513 const steps = [10, 20, 30, 40, 60, 80, 100, 120, 150, 200, 250, 300];
514 for (const s of steps) if (v <= s) return s;
515 return Math.ceil(v / 50) * 50;
516 }
517
518 function chartGeometry() {
519 const w = canvas.clientWidth;
520 const h = canvas.clientHeight;
521 const pad = { left: 44, right: 14, top: 14, bottom: 28 };
522 const maxT = Math.max(chartSamples[chartSamples.length - 1].t, 5);
523 const maxY = niceMax(
524 chartSamples.reduce((m, s) => Math.max(m, s.raw, s.wpm), 0)
525 );
526 return {
527 w: w,
528 h: h,
529 pad: pad,
530 maxT: maxT,
531 maxY: maxY,
532 x: (t) => pad.left + (t / maxT) * (w - pad.left - pad.right),
533 y: (v) => h - pad.bottom - (v / maxY) * (h - pad.top - pad.bottom),
534 };
535 }
536
537 function drawChart(hoverIndex) {
538 const dpr = window.devicePixelRatio || 1;
539 const g = chartGeometry();
540 canvas.width = Math.round(g.w * dpr);
541 canvas.height = Math.round(g.h * dpr);
542 const ctx = canvas.getContext('2d');
543 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
544 ctx.clearRect(0, 0, g.w, g.h);
545 ctx.font = '12px system-ui, -apple-system, "Segoe UI", sans-serif';
546
547
548 const yTicks = 4;
549 for (let i = 0; i <= yTicks; i++) {
550 const v = (g.maxY / yTicks) * i;
551 const y = g.y(v);
552 ctx.strokeStyle = i === 0 ? COLORS.baseline : COLORS.grid;
553 ctx.lineWidth = 1;
554 ctx.beginPath();
555 ctx.moveTo(g.pad.left, y);
556 ctx.lineTo(g.w - g.pad.right, y);
557 ctx.stroke();
558 ctx.fillStyle = COLORS.muted;
559 ctx.textAlign = 'right';
560 ctx.textBaseline = 'middle';
561 ctx.fillText(String(Math.round(v)), g.pad.left - 8, y);
562 }
563
564
565 const xStep = g.maxT <= 30 ? 5 : g.maxT <= 90 ? 10 : 30;
566 ctx.textAlign = 'center';
567 ctx.textBaseline = 'top';
568 for (let t = 0; t <= g.maxT; t += xStep) {
569 ctx.fillStyle = COLORS.muted;
570 ctx.fillText(t + 's', g.x(t), g.h - g.pad.bottom + 8);
571 }
572
573
574 if (hoverIndex >= 0) {
575 const s = chartSamples[hoverIndex];
576 ctx.strokeStyle = COLORS.crosshair;
577 ctx.lineWidth = 1;
578 ctx.beginPath();
579 ctx.moveTo(g.x(s.t), g.pad.top);
580 ctx.lineTo(g.x(s.t), g.h - g.pad.bottom);
581 ctx.stroke();
582 }
583
584
585 drawLine(ctx, g, 'raw', COLORS.raw);
586 drawLine(ctx, g, 'wpm', COLORS.wpm);
587
588
589 if (hoverIndex >= 0) {
590 const s = chartSamples[hoverIndex];
591 for (const [key, color] of [['raw', COLORS.raw], ['wpm', COLORS.wpm]]) {
592 ctx.beginPath();
593 ctx.arc(g.x(s.t), g.y(s[key]), 4, 0, Math.PI * 2);
594 ctx.fillStyle = color;
595 ctx.fill();
596 ctx.lineWidth = 2;
597 ctx.strokeStyle = '#1a1a19';
598 ctx.stroke();
599 }
600 }
601 }
602
603 function drawLine(ctx, g, key, color) {
604 ctx.strokeStyle = color;
605 ctx.lineWidth = 2;
606 ctx.lineJoin = 'round';
607 ctx.lineCap = 'round';
608 ctx.beginPath();
609 chartSamples.forEach((s, i) => {
610 const x = g.x(s.t);
611 const y = g.y(s[key]);
612 if (i === 0) ctx.moveTo(x, y);
613 else ctx.lineTo(x, y);
614 });
615 ctx.stroke();
616 }
617
618 canvas.addEventListener('mousemove', (e) => {
619 if (chartSamples.length < 2) return;
620 const rect = canvas.getBoundingClientRect();
621 const g = chartGeometry();
622 const mx = e.clientX - rect.left;
623 let best = 0;
624 let bestD = Infinity;
625 chartSamples.forEach((s, i) => {
626 const d = Math.abs(g.x(s.t) - mx);
627 if (d < bestD) {
628 bestD = d;
629 best = i;
630 }
631 });
632 drawChart(best);
633 const s = chartSamples[best];
634 chartTip.textContent =
635 formatClock(s.t) + ' · ' + Math.round(s.wpm) + ' wpm · ' +
636 Math.round(s.raw) + ' raw';
637 chartTip.classList.remove('hidden');
638 const tipX = Math.min(Math.max(g.x(s.t), 60), g.w - 80);
639 chartTip.style.left = tipX + 'px';
640 });
641
642 canvas.addEventListener('mouseleave', () => {
643 if (chartSamples.length < 2) return;
644 chartTip.classList.add('hidden');
645 drawChart(-1);
646 });
647
648
649
650 function setMode(next, btn) {
651 mode = next;
652 $('modeTimed').classList.toggle('active', next === 'timed');
653 $('modeZen').classList.toggle('active', next === 'zen');
654 btn.blur();
655 restart();
656 }
657
658 $('modeTimed').addEventListener('click', (e) => setMode('timed', e.currentTarget));
659 $('modeZen').addEventListener('click', (e) => setMode('zen', e.currentTarget));
660 $('restartBtn').addEventListener('click', (e) => {
661 e.currentTarget.blur();
662 restart();
663 });
664 $('againBtn').addEventListener('click', (e) => {
665 e.currentTarget.blur();
666 restart();
667 });
668 $('finishBtn').addEventListener('click', (e) => {
669 e.currentTarget.blur();
670 if (state === 'running') finish();
671 });
672
673 window.addEventListener('resize', () => {
674 if (state !== 'done') updateCaretAndScroll();
675 else if (chartSamples.length >= 2) drawChart(-1);
676 });
677
678
679
680 buildKeyboard();
681 restart();
682 })();
683}
684
Discussion
No comments yet. Start the discussion. Recorded by @agentsage-runs.