Skip to content

Commit 689e4cd

Browse files
TMHSDigitalclaude
andcommitted
Track missed keys and add a Weak Keys practice category
Each run counts attempts and misses per expected character, and recorded runs add them to typingGameKeyStats. The result dialog lists the keys missed most in the run. The Weak Keys category draws common words that contain the three letters with the highest miss rate (at least five attempts each), and a result-dialog button switches to it. Fixes #19. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 83de048 commit 689e4cd

5 files changed

Lines changed: 174 additions & 6 deletions

File tree

‎README.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Open `index.html` in a browser, or serve the folder with any static file server.
1515
- **Words mode**: type a fixed 10, 25, or 50 words. The clock counts up.
1616
- **Categories**:
1717
- Common Words (the default): 200 frequent English words in random order, never the same word twice in a row. Settings can add punctuation (capitalized sentences, commas, full stops) and numbers.
18+
- Weak Keys: common words that contain the three letters you miss most often, by miss rate, among letters you have typed at least five times. Until there is enough data it uses all common words.
1819
- Quotes: 50 passages, most of them opening lines of public-domain books.
1920
- Technology: 50 quotes about programming and computing.
2021
- Code Snippets: a mix of JavaScript, CSS, SQL, and shell lines.
@@ -28,7 +29,7 @@ Open `index.html` in a browser, or serve the folder with any static file server.
2829
- **Caps Lock warning**: a notice above the text while Caps Lock is on.
2930
- **Hard Mode**: blurs the characters you have already typed, so you cannot see your mistakes. The text ahead stays readable.
3031
- **Ghost Mode**: records the timing of your best run and replays its position as a second cursor while you type.
31-
- **Results**: a modal with your WPM and accuracy; raw WPM (all typed characters, right or wrong); correct and wrong character counts; elapsed time; whether the run set a new best for its settings, and the previous best; a line chart of WPM at the end of each second of the run; a line chart of your last ten runs at the current settings once you have two or more; and a Share button. The shared text has the WPM, accuracy, settings, and a link to the site. On touch devices it opens the system share sheet; elsewhere it copies to the clipboard.
32+
- **Results**: a modal with your WPM and accuracy; raw WPM (all typed characters, right or wrong); correct and wrong character counts; elapsed time; whether the run set a new best for its settings, and the previous best; the keys you missed most in this run; a line chart of WPM at the end of each second of the run; a line chart of your last ten runs at the current settings once you have two or more; a Share button; and, once you have missed letters often enough, a button that starts a Weak Keys run. The shared text has the WPM, accuracy, settings, and a link to the site. On touch devices it opens the system share sheet; elsewhere it copies to the clipboard.
3233
- **Sound**: short tones made with the Web Audio API for keypress, mistake, and completion. Can be turned off.
3334
- **Keyboard**: Tab restarts while you are typing. Escape closes a dialog. Opening Settings during a run abandons the run.
3435
- **Focus**: if the typing input loses focus, a hint appears over the text. Pressing a letter key moves focus back to the input.
@@ -46,6 +47,7 @@ Everything is stored in `localStorage` under these keys:
4647
| `typingGameSettings` | mode, duration, word count, category, and the feature toggles |
4748
| `typingGameHistory` | last 50 runs as `{wpm, accuracy, date, mode, key}` |
4849
| `typingGameBests` | best WPM and its keystroke timings for each settings key, used by Ghost Mode |
50+
| `typingGameKeyStats` | attempts and misses for each expected character across recorded runs |
4951

5052
A settings key combines mode, length, and category, for example `time:60s:common` or `words:25w:code`. Common Words with punctuation or numbers adds them to the category, as in `time:30s:common+punctuation+numbers`. Best scores, Ghost Mode, and the chart only compare runs with the same key. Runs that finish in under a second are shown but not saved.
5153

‎index.html‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,10 +87,12 @@ <h1>TYPE<span class="accent">SPEED</span></h1>
8787
<label for="category-select" class="visually-hidden">Text category</label>
8888
<select id="category-select" class="category-select">
8989
<option value="common">Common Words</option>
90+
<option value="weak">Weak Keys</option>
9091
<option value="general">Quotes</option>
9192
<option value="technology">Technology</option>
9293
<option value="code">Code Snippets</option>
9394
</select>
95+
<p id="practice-note" class="practice-note hidden"></p>
9496
</div>
9597

9698
<div class="control-group right">
@@ -220,6 +222,8 @@ <h2 id="result-title">Result</h2>
220222
</div>
221223
</dl>
222224

225+
<p id="modal-keys" class="modal-keys"></p>
226+
223227
<p id="modal-source" class="modal-source hidden"></p>
224228

225229
<div class="chart-row">
@@ -252,6 +256,9 @@ <h3>Progress</h3>
252256

253257
<div class="modal-actions">
254258
<button id="share-btn" class="action-btn secondary">Share Result</button>
259+
<button id="practice-btn" class="action-btn secondary hidden">
260+
Practice weak keys
261+
</button>
255262
<button id="play-again-btn" class="action-btn primary">Play Again</button>
256263
</div>
257264
</div>

‎script.js‎

Lines changed: 78 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ const modalBestElement = document.getElementById('modal-best');
3030
const modalRawElement = document.getElementById('modal-raw');
3131
const modalCharsElement = document.getElementById('modal-chars');
3232
const modalTimeElement = document.getElementById('modal-time');
33+
const modalKeysElement = document.getElementById('modal-keys');
34+
const practiceBtn = document.getElementById('practice-btn');
35+
const practiceNote = document.getElementById('practice-note');
3336
const shareBtn = document.getElementById('share-btn');
3437
const playAgainBtn = document.getElementById('play-again-btn');
3538
const settingsBtn = document.getElementById('settings-btn');
@@ -62,6 +65,7 @@ const STORAGE_KEYS = {
6265
history: 'typingGameHistory',
6366
bests: 'typingGameBests',
6467
settings: 'typingGameSettings',
68+
keyStats: 'typingGameKeyStats',
6569
};
6670

6771
function loadJSON(key, fallback) {
@@ -89,7 +93,7 @@ const DEFAULT_SETTINGS = {
8993
mode: 'time', // 'time' or 'words'
9094
duration: 60, // seconds, time mode
9195
wordCount: 10, // words mode
92-
category: 'common', // 'common' or a key of QUOTES (texts.js)
96+
category: 'common', // 'common', 'weak', or a key of QUOTES (texts.js)
9397
isGhostEnabled: false,
9498
isHardMode: false,
9599
isSoundEnabled: true,
@@ -98,7 +102,7 @@ const DEFAULT_SETTINGS = {
98102
isStopOnError: false, // wrong keys do not advance the caret
99103
};
100104

101-
const CATEGORIES = ['common', ...Object.keys(QUOTES)];
105+
const CATEGORIES = ['common', 'weak', ...Object.keys(QUOTES)];
102106

103107
function sanitizeSettings(saved) {
104108
const settings = { ...DEFAULT_SETTINGS };
@@ -140,6 +144,7 @@ const runtime = {
140144
keystrokes: 0, // characters inserted, including ones later deleted
141145
mistakes: 0, // inserted characters that did not match
142146
ghostStrokes: [], // {t: ms since start, i: character index} for Ghost Mode
147+
keyStats: {}, // {[expected character]: {attempts, misses}} for this run
143148
ghostTimeouts: [],
144149
};
145150

@@ -153,6 +158,24 @@ if (!Array.isArray(runHistory)) runHistory = [];
153158
let bests = loadJSON(STORAGE_KEYS.bests, {});
154159
if (!bests || typeof bests !== 'object' || Array.isArray(bests)) bests = {};
155160

161+
// Attempts and misses per expected character across all recorded runs.
162+
let keyStats = loadJSON(STORAGE_KEYS.keyStats, {});
163+
if (!keyStats || typeof keyStats !== 'object' || Array.isArray(keyStats)) keyStats = {};
164+
165+
// Letters missed most often, by miss rate, among letters tried at least
166+
// WEAK_MIN_ATTEMPTS times. Used by the Weak Keys category.
167+
const WEAK_MIN_ATTEMPTS = 5;
168+
const WEAK_KEY_COUNT = 3;
169+
170+
function weakestKeys() {
171+
return Object.entries(keyStats)
172+
.filter(([key, stat]) => /^[a-z]$/i.test(key) && stat && stat.misses > 0)
173+
.filter(([, stat]) => stat.attempts >= WEAK_MIN_ATTEMPTS)
174+
.sort(([, a], [, b]) => b.misses / b.attempts - a.misses / a.attempts)
175+
.slice(0, WEAK_KEY_COUNT)
176+
.map(([key]) => key.toLowerCase());
177+
}
178+
156179
// Runs only compete with runs of the same mode, length, and category.
157180
function settingsKey() {
158181
const length = gameState.mode === 'time' ? `${gameState.duration}s` : `${gameState.wordCount}w`;
@@ -258,14 +281,14 @@ function pickQuote(categoryQuotes) {
258281

259282
// Random common words with no word twice in a row, plus optional
260283
// punctuation (capitalized sentences, commas, full stops) and numbers.
261-
function commonWords(count) {
284+
function commonWords(count, list = COMMON_WORDS) {
262285
const words = [];
263286
let previous = '';
264287
let sentenceStart = true;
265288
for (let n = 0; n < count; n++) {
266289
let word;
267290
do {
268-
word = COMMON_WORDS[Math.floor(Math.random() * COMMON_WORDS.length)];
291+
word = list[Math.floor(Math.random() * list.length)];
269292
} while (word === previous);
270293
previous = word;
271294

@@ -288,8 +311,19 @@ function commonWords(count) {
288311
return words.join(' ');
289312
}
290313

314+
// Common words that contain at least one of the given letters. Falls back
315+
// to the whole list when too few words match.
316+
function wordsWithKeys(keys) {
317+
const matching = COMMON_WORDS.filter((word) => keys.some((key) => word.includes(key)));
318+
return matching.length >= 10 ? matching : COMMON_WORDS;
319+
}
320+
291321
// Returns { text, source } for the next passage.
292322
function getPassage() {
323+
if (gameState.category === 'weak') {
324+
const count = gameState.mode === 'words' ? gameState.wordCount : COMMON_CHUNK;
325+
return { text: commonWords(count, wordsWithKeys(weakestKeys())), source: '' };
326+
}
293327
if (gameState.category === 'common') {
294328
const count = gameState.mode === 'words' ? gameState.wordCount : COMMON_CHUNK;
295329
return { text: commonWords(count), source: '' };
@@ -372,7 +406,12 @@ function handleInput() {
372406
const pos = unchanged + k;
373407
if (pos >= quote.length) continue;
374408
runtime.keystrokes++;
375-
if (inserted[k] !== quote[pos]) {
409+
const expected = quote[pos];
410+
const stat = runtime.keyStats[expected] || { attempts: 0, misses: 0 };
411+
runtime.keyStats[expected] = stat;
412+
stat.attempts++;
413+
if (inserted[k] !== expected) {
414+
stat.misses++;
376415
wrong++;
377416
if (firstWrong === -1) firstWrong = pos;
378417
}
@@ -525,6 +564,15 @@ function endGame(endTime = Date.now()) {
525564
runHistory.length = Math.min(runHistory.length, HISTORY_LIMIT);
526565
saveJSON(STORAGE_KEYS.history, runHistory);
527566

567+
for (const [key, stat] of Object.entries(runtime.keyStats)) {
568+
const total = keyStats[key] || { attempts: 0, misses: 0 };
569+
keyStats[key] = {
570+
attempts: total.attempts + stat.attempts,
571+
misses: total.misses + stat.misses,
572+
};
573+
}
574+
saveJSON(STORAGE_KEYS.keyStats, keyStats);
575+
528576
if (wpm > (previousBest ? previousBest.wpm : 0)) {
529577
bests[key] = { wpm, keystrokes: runtime.ghostStrokes };
530578
saveJSON(STORAGE_KEYS.bests, bests);
@@ -620,6 +668,7 @@ function resetGame() {
620668
keystrokes: 0,
621669
mistakes: 0,
622670
ghostStrokes: [],
671+
keyStats: {},
623672
});
624673

625674
runtime.timer = gameState.mode === 'time' ? gameState.duration : 0;
@@ -647,6 +696,12 @@ function updateQuickBar() {
647696
quickDuration.classList.toggle('hidden', gameState.mode !== 'time');
648697
quickWordCount.classList.toggle('hidden', gameState.mode !== 'words');
649698
ghostBadge.classList.toggle('hidden', !gameState.isGhostEnabled);
699+
700+
const weak = weakestKeys();
701+
practiceNote.classList.toggle('hidden', gameState.category !== 'weak');
702+
practiceNote.textContent = weak.length
703+
? `Practicing: ${weak.join(', ')}`
704+
: 'Not enough mistakes recorded yet. Using all common words.';
650705
}
651706

652707
function updateHighScoreDisplay() {
@@ -684,6 +739,18 @@ function showResultModal(wpm, accuracy, { isNewBest, previousBest }) {
684739
modalBestElement.textContent = `Best for these settings: ${previousBest.wpm} WPM.`;
685740
}
686741
modalBestElement.classList.toggle('new-best', isNewBest && Boolean(previousBest));
742+
743+
const missed = Object.entries(runtime.keyStats)
744+
.filter(([, stat]) => stat.misses > 0)
745+
.sort(([, a], [, b]) => b.misses - a.misses || a.attempts - b.attempts)
746+
.slice(0, 3)
747+
.map(
748+
([key, stat]) => `${key === ' ' ? 'space' : key} (${stat.misses} of ${stat.attempts})`
749+
);
750+
modalKeysElement.textContent = missed.length
751+
? `Most missed keys: ${missed.join(', ')}`
752+
: 'No missed keys.';
753+
practiceBtn.classList.toggle('hidden', weakestKeys().length === 0);
687754
modalSourceElement.textContent = runtime.quoteSource;
688755
modalSourceElement.classList.toggle('hidden', !runtime.quoteSource);
689756
resultModal.classList.add('show');
@@ -971,6 +1038,12 @@ function init() {
9711038

9721039
shareBtn.addEventListener('click', shareResult);
9731040
playAgainBtn.addEventListener('click', resetGame);
1041+
practiceBtn.addEventListener('click', () => {
1042+
gameState.category = 'weak';
1043+
categorySelect.value = 'weak';
1044+
saveSettings();
1045+
resetGame();
1046+
});
9741047

9751048
document.addEventListener('keydown', handleKeydown);
9761049

‎style.css‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -650,6 +650,7 @@ footer {
650650

651651
.modal-actions {
652652
display: flex;
653+
flex-wrap: wrap;
653654
gap: 1rem;
654655
justify-content: center;
655656
}
@@ -797,6 +798,17 @@ footer {
797798
color: var(--text-main);
798799
}
799800

801+
.modal-keys {
802+
margin: -0.75rem 0 1.25rem;
803+
font-size: 0.85rem;
804+
color: var(--text-muted);
805+
}
806+
807+
.practice-note {
808+
font-size: 0.8rem;
809+
color: var(--text-muted);
810+
}
811+
800812
.modal-source {
801813
margin: -1rem 0 1.5rem;
802814
font-size: 0.85rem;

‎tests/typespeed.spec.js‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -946,3 +946,77 @@ test('the result dialog is positioned against the viewport, not the page card',
946946
const title = await page.locator('#result-title').boundingBox();
947947
expect(title.y).toBeGreaterThanOrEqual(0);
948948
});
949+
950+
test.describe('weak keys', () => {
951+
test('counts misses per expected key and shows the most missed after a run', async ({
952+
page,
953+
}) => {
954+
await page.addInitScript(() => {
955+
localStorage.setItem('typingGameSettings', JSON.stringify({ mode: 'words' }));
956+
});
957+
await page.goto('/');
958+
const quote = await currentQuote(page);
959+
const wrong = (c) => (c === 'x' ? 'y' : 'x');
960+
961+
// Miss the first character twice, fixing it each time.
962+
await input(page).press(wrong(quote[0]));
963+
await input(page).press('Backspace');
964+
await input(page).press(wrong(quote[0]));
965+
await input(page).press('Backspace');
966+
await input(page).pressSequentially(quote, { delay: 40 });
967+
968+
const stat = await page.evaluate((key) => runtime.keyStats[key], quote[0]);
969+
expect(stat.misses).toBe(2);
970+
expect(stat.attempts).toBeGreaterThanOrEqual(3);
971+
await expect(page.locator('#modal-keys')).toHaveText(
972+
new RegExp(`^Most missed keys: ${quote[0]} \\(2 of \\d+\\)$`)
973+
);
974+
975+
// The totals are saved and survive a reload.
976+
await page.reload();
977+
const saved = await page.evaluate(
978+
(key) => JSON.parse(localStorage.getItem('typingGameKeyStats'))[key],
979+
quote[0]
980+
);
981+
expect(saved).toEqual(stat);
982+
});
983+
984+
test('Practice weak keys starts a run of words containing the weakest letters', async ({
985+
page,
986+
}) => {
987+
await page.addInitScript(() => {
988+
localStorage.setItem('typingGameSettings', JSON.stringify({ mode: 'words' }));
989+
localStorage.setItem(
990+
'typingGameKeyStats',
991+
JSON.stringify({
992+
k: { attempts: 10, misses: 5 },
993+
v: { attempts: 10, misses: 4 },
994+
e: { attempts: 100, misses: 1 },
995+
w: { attempts: 2, misses: 2 }, // too few attempts to count
996+
';': { attempts: 10, misses: 9 }, // not a letter
997+
})
998+
);
999+
});
1000+
await page.goto('/');
1001+
await input(page).pressSequentially(await currentQuote(page), { delay: 40 });
1002+
await expect(page.locator('#practice-btn')).toBeVisible();
1003+
await page.locator('#practice-btn').click();
1004+
1005+
await expect(page.locator('#category-select')).toHaveValue('weak');
1006+
await expect(page.locator('#practice-note')).toHaveText('Practicing: k, v, e');
1007+
const words = (await currentQuote(page)).split(' ');
1008+
expect(words).toHaveLength(10);
1009+
for (const word of words) expect(word).toMatch(/[kve]/);
1010+
expect(await page.evaluate(() => settingsKey())).toBe('words:10w:weak');
1011+
});
1012+
1013+
test('hides the practice button until a letter has enough misses', async ({ page }) => {
1014+
await page.addInitScript(() => {
1015+
localStorage.setItem('typingGameSettings', JSON.stringify({ mode: 'words' }));
1016+
});
1017+
await page.goto('/');
1018+
await input(page).pressSequentially(await currentQuote(page), { delay: 40 });
1019+
await expect(page.locator('#modal-keys')).toHaveText('No missed keys.');
1020+
await expect(page.locator('#practice-btn')).toBeHidden();
1021+
});
1022+
});

0 commit comments

Comments
 (0)