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