wpmx 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md ADDED
@@ -0,0 +1,33 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Commands
6
+
7
+ - **Run**: `bun run src/index.tsx`
8
+ - **Run (hot reload)**: `bun --hot src/index.tsx`
9
+ - **Install deps**: `bun install`
10
+ - **Test**: `bun test` (uses `bun:test`, not jest/vitest)
11
+ - **Type check**: `bunx tsc --noEmit`
12
+
13
+ Always use Bun, never Node.js, npm, or npx.
14
+
15
+ ## Architecture
16
+
17
+ wpmx is a terminal typing test built with React + [Ink](https://github.com/vadimdemedes/ink) (React renderer for CLIs).
18
+
19
+ **Screen flow**: Menu → Game → Results → (restart or menu)
20
+
21
+ - `src/app.tsx` — Screen state machine. Manages which screen is shown and passes callbacks between them.
22
+ - `src/hooks/useGame.ts` — Core game engine. All typing logic lives here: character input, word validation, backspace, scoring (WPM/accuracy). Returns state + handlers consumed by Game component.
23
+ - `src/components/Game.tsx` — Renders game UI and pipes keyboard input to useGame hooks. Handles character-by-character color feedback.
24
+ - `src/lib/storage.ts` — Persists history and settings to `~/.wpmx/`. Uses `Bun.write` for saves, `node:fs` for reads.
25
+ - `src/data/words.json` — Word list (~380 common English words).
26
+
27
+ **Key game logic** (`useGame.ts`):
28
+ - `handleChar` → records typed character in `charInputs[wordIndex]`
29
+ - `handleSpace` → validates current word (exact match), marks correct/incorrect, advances to next word
30
+ - `handleBackspace` → deletes last char, or returns to previous word if at start
31
+ - `getResults` → calculates WPM and accuracy from `charInputs` vs target words
32
+
33
+ **Scoring**: WPM = `(correctChars / 5) / (time / 60)`. Accuracy counts individual correct characters, not whole words.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 handxr (wpmx)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,43 @@
1
+ # wpmx
2
+
3
+ A minimal, fast typing test for the terminal. Built with [Bun](https://bun.sh), [React](https://react.dev), and [Ink](https://github.com/vadimdemedes/ink).
4
+
5
+ ## Features
6
+
7
+ - **15s, 30s, or 60s** timed sessions
8
+ - **Live WPM** counter as you type
9
+ - **Character-level feedback** — correct chars in green, errors in red
10
+ - **Personal best** tracking with local history
11
+ - **Vim-style navigation** — `h`/`l` to pick duration
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ bun install
17
+ ```
18
+
19
+ ## Run
20
+
21
+ ```bash
22
+ bun run src/index.tsx
23
+ ```
24
+
25
+ ## Keybindings
26
+
27
+ | Key | Action |
28
+ |-----|--------|
29
+ | `h` / `l` or `←` / `→` | Select duration |
30
+ | `Enter` | Start game |
31
+ | `Tab` | Restart |
32
+ | `Esc` | Back to menu |
33
+ | `q` | Quit |
34
+
35
+ ## How it works
36
+
37
+ Type the words as fast as you can. Errors are highlighted per-character so you can see exactly what you missed. Your WPM and accuracy are calculated at the end.
38
+
39
+ Results are saved locally at `~/.wpmx/history.json`.
40
+
41
+ ## License
42
+
43
+ MIT
package/bun.lock ADDED
@@ -0,0 +1,117 @@
1
+ {
2
+ "lockfileVersion": 1,
3
+ "configVersion": 1,
4
+ "workspaces": {
5
+ "": {
6
+ "name": "typesprint",
7
+ "dependencies": {
8
+ "ink": "^6.8.0",
9
+ "react": "^19.2.4",
10
+ },
11
+ "devDependencies": {
12
+ "@types/bun": "latest",
13
+ "@types/react": "^19.2.14",
14
+ },
15
+ "peerDependencies": {
16
+ "typescript": "^5.9.3",
17
+ },
18
+ },
19
+ },
20
+ "packages": {
21
+ "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.2.5", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw=="],
22
+
23
+ "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
24
+
25
+ "@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="],
26
+
27
+ "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
28
+
29
+ "ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="],
30
+
31
+ "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
32
+
33
+ "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
34
+
35
+ "auto-bind": ["auto-bind@5.0.1", "", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="],
36
+
37
+ "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
38
+
39
+ "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
40
+
41
+ "cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="],
42
+
43
+ "cli-cursor": ["cli-cursor@4.0.0", "", { "dependencies": { "restore-cursor": "^4.0.0" } }, "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg=="],
44
+
45
+ "cli-truncate": ["cli-truncate@5.1.1", "", { "dependencies": { "slice-ansi": "^7.1.0", "string-width": "^8.0.0" } }, "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A=="],
46
+
47
+ "code-excerpt": ["code-excerpt@4.0.0", "", { "dependencies": { "convert-to-spaces": "^2.0.1" } }, "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA=="],
48
+
49
+ "convert-to-spaces": ["convert-to-spaces@2.0.1", "", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="],
50
+
51
+ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
52
+
53
+ "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
54
+
55
+ "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="],
56
+
57
+ "es-toolkit": ["es-toolkit@1.44.0", "", {}, "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg=="],
58
+
59
+ "escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="],
60
+
61
+ "get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="],
62
+
63
+ "indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="],
64
+
65
+ "ink": ["ink@6.8.0", "", { "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.4", "ansi-escapes": "^7.3.0", "ansi-styles": "^6.2.1", "auto-bind": "^5.0.1", "chalk": "^5.6.0", "cli-boxes": "^3.0.0", "cli-cursor": "^4.0.0", "cli-truncate": "^5.1.1", "code-excerpt": "^4.0.0", "es-toolkit": "^1.39.10", "indent-string": "^5.0.0", "is-in-ci": "^2.0.0", "patch-console": "^2.0.0", "react-reconciler": "^0.33.0", "scheduler": "^0.27.0", "signal-exit": "^3.0.7", "slice-ansi": "^8.0.0", "stack-utils": "^2.0.6", "string-width": "^8.1.1", "terminal-size": "^4.0.1", "type-fest": "^5.4.1", "widest-line": "^6.0.0", "wrap-ansi": "^9.0.0", "ws": "^8.18.0", "yoga-layout": "~3.2.1" }, "peerDependencies": { "@types/react": ">=19.0.0", "react": ">=19.0.0", "react-devtools-core": ">=6.1.2" }, "optionalPeers": ["@types/react", "react-devtools-core"] }, "sha512-sbl1RdLOgkO9isK42WCZlJCFN9hb++sX9dsklOvfd1YQ3bQ2AiFu12Q6tFlr0HvEUvzraJntQCCpfEoUe9DSzA=="],
66
+
67
+ "is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="],
68
+
69
+ "is-in-ci": ["is-in-ci@2.0.0", "", { "bin": { "is-in-ci": "cli.js" } }, "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w=="],
70
+
71
+ "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
72
+
73
+ "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
74
+
75
+ "patch-console": ["patch-console@2.0.0", "", {}, "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA=="],
76
+
77
+ "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
78
+
79
+ "react-reconciler": ["react-reconciler@0.33.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="],
80
+
81
+ "restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="],
82
+
83
+ "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
84
+
85
+ "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
86
+
87
+ "slice-ansi": ["slice-ansi@8.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="],
88
+
89
+ "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="],
90
+
91
+ "string-width": ["string-width@8.2.0", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw=="],
92
+
93
+ "strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
94
+
95
+ "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
96
+
97
+ "terminal-size": ["terminal-size@4.0.1", "", {}, "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ=="],
98
+
99
+ "type-fest": ["type-fest@5.4.4", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw=="],
100
+
101
+ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
102
+
103
+ "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
104
+
105
+ "widest-line": ["widest-line@6.0.0", "", { "dependencies": { "string-width": "^8.1.0" } }, "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA=="],
106
+
107
+ "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
108
+
109
+ "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
110
+
111
+ "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="],
112
+
113
+ "cli-truncate/slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="],
114
+
115
+ "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
116
+ }
117
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "wpmx",
3
+ "version": "1.0.0",
4
+ "description": "A minimal, fast typing test for the terminal",
5
+ "type": "module",
6
+ "bin": {
7
+ "wpmx": "src/index.tsx"
8
+ },
9
+ "keywords": [
10
+ "typing",
11
+ "typing-test",
12
+ "wpm",
13
+ "terminal",
14
+ "cli",
15
+ "speed-typing"
16
+ ],
17
+ "author": "handxr",
18
+ "license": "MIT",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "https://github.com/handxr/wpmx"
22
+ },
23
+ "homepage": "https://github.com/handxr/wpmx",
24
+ "bugs": {
25
+ "url": "https://github.com/handxr/wpmx/issues"
26
+ },
27
+ "dependencies": {
28
+ "ink": "^6.8.0",
29
+ "react": "^19.2.4"
30
+ },
31
+ "devDependencies": {
32
+ "@types/bun": "latest",
33
+ "@types/react": "^19.2.14"
34
+ },
35
+ "peerDependencies": {
36
+ "typescript": "^5.9.3"
37
+ }
38
+ }
package/src/app.tsx ADDED
@@ -0,0 +1,90 @@
1
+ import { useState, useCallback } from "react";
2
+ import { useApp } from "ink";
3
+ import { Menu } from "./components/Menu.tsx";
4
+ import { Game } from "./components/Game.tsx";
5
+ import { Results } from "./components/Results.tsx";
6
+ import {
7
+ saveResult,
8
+ saveSettings,
9
+ loadSettings,
10
+ getPersonalBest,
11
+ } from "./lib/storage.ts";
12
+
13
+ type Screen = "menu" | "game" | "results";
14
+ type Duration = 15 | 30 | 60;
15
+
16
+ export type GameResults = {
17
+ wpm: number;
18
+ accuracy: number;
19
+ time: number;
20
+ };
21
+
22
+ export function App() {
23
+ const { exit } = useApp();
24
+ const settings = loadSettings();
25
+ const [screen, setScreen] = useState<Screen>("menu");
26
+ const [duration, setDuration] = useState<Duration>(settings.lastDuration);
27
+ const [results, setResults] = useState<GameResults | null>(null);
28
+ const [gameKey, setGameKey] = useState(0);
29
+
30
+ const handleStart = useCallback((d: Duration) => {
31
+ setDuration(d);
32
+ saveSettings({ lastDuration: d });
33
+ setGameKey((k) => k + 1);
34
+ setScreen("game");
35
+ }, []);
36
+
37
+ const handleFinish = useCallback(
38
+ (r: GameResults) => {
39
+ saveResult({
40
+ wpm: r.wpm,
41
+ accuracy: r.accuracy,
42
+ duration: r.time,
43
+ date: new Date().toISOString(),
44
+ });
45
+ setResults(r);
46
+ setScreen("results");
47
+ },
48
+ []
49
+ );
50
+
51
+ const handleRestart = useCallback(() => {
52
+ setGameKey((k) => k + 1);
53
+ setScreen("game");
54
+ }, []);
55
+
56
+ const handleMenu = useCallback(() => {
57
+ setScreen("menu");
58
+ }, []);
59
+
60
+ if (screen === "menu") {
61
+ return <Menu onStart={handleStart} onQuit={exit} defaultDuration={duration} />;
62
+ }
63
+
64
+ if (screen === "game") {
65
+ return (
66
+ <Game
67
+ key={gameKey}
68
+ duration={duration}
69
+ onFinish={handleFinish}
70
+ onExit={handleMenu}
71
+ onRestart={handleRestart}
72
+ />
73
+ );
74
+ }
75
+
76
+ if (screen === "results" && results) {
77
+ const pb = getPersonalBest(results.time);
78
+ return (
79
+ <Results
80
+ results={results}
81
+ personalBest={pb}
82
+ onRestart={handleRestart}
83
+ onMenu={handleMenu}
84
+ onQuit={exit}
85
+ />
86
+ );
87
+ }
88
+
89
+ return null;
90
+ }
@@ -0,0 +1,156 @@
1
+ import { Box, Text, useInput } from "ink";
2
+ import { useGame, type GameResults } from "../hooks/useGame.ts";
3
+ import { useEffect } from "react";
4
+
5
+ type GameProps = {
6
+ duration: number;
7
+ onFinish: (results: GameResults) => void;
8
+ onExit: () => void;
9
+ onRestart: () => void;
10
+ };
11
+
12
+ export function Game({ duration, onFinish, onExit, onRestart }: GameProps) {
13
+ const game = useGame(duration);
14
+
15
+ useEffect(() => {
16
+ if (game.isFinished) {
17
+ onFinish(game.getResults());
18
+ }
19
+ }, [game.isFinished]);
20
+
21
+ useInput((input, key) => {
22
+ if (key.escape) {
23
+ onExit();
24
+ return;
25
+ }
26
+ if (key.tab) {
27
+ onRestart();
28
+ return;
29
+ }
30
+ if (game.isFinished) return;
31
+
32
+ if (key.backspace || key.delete) {
33
+ game.handleBackspace();
34
+ } else if (input === " ") {
35
+ game.handleSpace();
36
+ } else if (input && !key.ctrl && !key.meta && input.length === 1) {
37
+ game.handleChar(input);
38
+ }
39
+ });
40
+
41
+ const liveWpm = game.isRunning && !game.isFinished
42
+ ? Math.round(
43
+ (() => {
44
+ let correct = 0;
45
+ for (let i = 0; i < game.currentWordIndex; i++) {
46
+ const word = game.words[i];
47
+ const input = game.charInputs[i].join("");
48
+ if (input === word) correct += word.length + 1;
49
+ else {
50
+ for (let j = 0; j < word.length; j++) {
51
+ if (input[j] === word[j]) correct++;
52
+ }
53
+ }
54
+ }
55
+ const elapsed = (duration - game.timeLeft) || 1;
56
+ return (correct / 5) / (elapsed / 60);
57
+ })()
58
+ )
59
+ : 0;
60
+
61
+ return (
62
+ <Box flexDirection="column" paddingX={2} paddingY={1}>
63
+ <Box justifyContent="space-between">
64
+ <Text color="yellow" bold>
65
+ {game.timeLeft}s
66
+ </Text>
67
+ <Text color="yellow" bold>
68
+ {liveWpm} wpm
69
+ </Text>
70
+ </Box>
71
+ <Box marginTop={1} flexWrap="wrap">
72
+ {game.words.slice(0, Math.min(game.words.length, game.currentWordIndex + 30)).map((word, wordIndex) => {
73
+ const isCurrentWord = wordIndex === game.currentWordIndex;
74
+ const isPast = wordIndex < game.currentWordIndex;
75
+ const wordResult = game.wordResults[wordIndex];
76
+
77
+ if (isPast) {
78
+ const inputChars = game.charInputs[wordIndex];
79
+ return (
80
+ <Box key={wordIndex} marginRight={1}>
81
+ {word.split("").map((char, charIndex) => {
82
+ const inputChar = inputChars[charIndex];
83
+ const isCorrect = inputChar === char;
84
+ return (
85
+ <Text key={charIndex} color={isCorrect ? "green" : "red"}>
86
+ {char}
87
+ </Text>
88
+ );
89
+ })}
90
+ {inputChars.length > word.length &&
91
+ inputChars
92
+ .slice(word.length)
93
+ .map((char, i) => (
94
+ <Text key={`extra-${i}`} color="red" strikethrough>
95
+ {char}
96
+ </Text>
97
+ ))}
98
+ </Box>
99
+ );
100
+ }
101
+
102
+ if (isCurrentWord) {
103
+ return (
104
+ <Box key={wordIndex} marginRight={1}>
105
+ {word.split("").map((char, charIndex) => {
106
+ const inputChar = game.currentInput[charIndex];
107
+ if (inputChar === undefined && charIndex === game.currentInput.length) {
108
+ return (
109
+ <Text key={charIndex} color="white" bold underline>
110
+ {char}
111
+ </Text>
112
+ );
113
+ }
114
+ if (inputChar === undefined) {
115
+ return (
116
+ <Text key={charIndex} dimColor>
117
+ {char}
118
+ </Text>
119
+ );
120
+ }
121
+ if (inputChar === char) {
122
+ return (
123
+ <Text key={charIndex} color="green">
124
+ {char}
125
+ </Text>
126
+ );
127
+ }
128
+ return (
129
+ <Text key={charIndex} color="red">
130
+ {char}
131
+ </Text>
132
+ );
133
+ })}
134
+ {game.currentInput.length > word.length &&
135
+ game.currentInput
136
+ .slice(word.length)
137
+ .split("")
138
+ .map((char, i) => (
139
+ <Text key={`extra-${i}`} color="red">
140
+ {char}
141
+ </Text>
142
+ ))}
143
+ </Box>
144
+ );
145
+ }
146
+
147
+ return (
148
+ <Box key={wordIndex} marginRight={1}>
149
+ <Text dimColor>{word}</Text>
150
+ </Box>
151
+ );
152
+ })}
153
+ </Box>
154
+ </Box>
155
+ );
156
+ }
@@ -0,0 +1,67 @@
1
+ import { useState } from "react";
2
+ import { Box, Text, useInput } from "ink";
3
+
4
+ type Duration = 15 | 30 | 60;
5
+ const DURATIONS: Duration[] = [15, 30, 60];
6
+
7
+ type MenuProps = {
8
+ onStart: (duration: Duration) => void;
9
+ onQuit: () => void;
10
+ defaultDuration: Duration;
11
+ };
12
+
13
+ export function Menu({ onStart, onQuit, defaultDuration }: MenuProps) {
14
+ const [selectedIndex, setSelectedIndex] = useState(
15
+ DURATIONS.indexOf(defaultDuration)
16
+ );
17
+
18
+ useInput((input, key) => {
19
+ if (input === "q") {
20
+ onQuit();
21
+ } else if (key.leftArrow || input === "h") {
22
+ setSelectedIndex((prev) => Math.max(0, prev - 1));
23
+ } else if (key.rightArrow || input === "l") {
24
+ setSelectedIndex((prev) => Math.min(DURATIONS.length - 1, prev + 1));
25
+ } else if (key.return) {
26
+ onStart(DURATIONS[selectedIndex]);
27
+ }
28
+ });
29
+
30
+ return (
31
+ <Box flexDirection="column" alignItems="center" paddingY={2}>
32
+ <Text bold color="yellow">
33
+ wpmx
34
+ </Text>
35
+
36
+ <Box marginTop={2} gap={2}>
37
+ {DURATIONS.map((d, i) => (
38
+ <Text
39
+ key={d}
40
+ bold={i === selectedIndex}
41
+ color={i === selectedIndex ? "white" : "gray"}
42
+ underline={i === selectedIndex}
43
+ >
44
+ {d}s
45
+ </Text>
46
+ ))}
47
+ </Box>
48
+
49
+ <Box marginTop={1}>
50
+ <Text dimColor>h / l or ← / → to choose, Enter to start</Text>
51
+ </Box>
52
+
53
+ <Box flexDirection="column" marginTop={2} gap={0}>
54
+ <Text dimColor>─── keys ───</Text>
55
+ <Text dimColor>
56
+ <Text color="gray">Tab</Text> restart game
57
+ </Text>
58
+ <Text dimColor>
59
+ <Text color="gray">Esc</Text> back to menu
60
+ </Text>
61
+ <Text dimColor>
62
+ <Text color="gray">q</Text> quit
63
+ </Text>
64
+ </Box>
65
+ </Box>
66
+ );
67
+ }
@@ -0,0 +1,58 @@
1
+ import { Box, Text, useInput } from "ink";
2
+ import type { GameResults } from "../hooks/useGame.ts";
3
+
4
+ type ResultsProps = {
5
+ results: GameResults;
6
+ personalBest: number | null;
7
+ onRestart: () => void;
8
+ onMenu: () => void;
9
+ onQuit: () => void;
10
+ };
11
+
12
+ export function Results({
13
+ results,
14
+ personalBest,
15
+ onRestart,
16
+ onMenu,
17
+ onQuit,
18
+ }: ResultsProps) {
19
+ useInput((input, key) => {
20
+ if (key.tab) {
21
+ onRestart();
22
+ } else if (key.escape) {
23
+ onMenu();
24
+ } else if (input === "q") {
25
+ onQuit();
26
+ }
27
+ });
28
+
29
+ const isNewPB = personalBest !== null && results.wpm >= personalBest;
30
+
31
+ return (
32
+ <Box flexDirection="column" alignItems="center" paddingY={2}>
33
+ <Text bold color="yellow">
34
+ wpmx
35
+ </Text>
36
+ <Box flexDirection="column" alignItems="center" marginTop={2} gap={0}>
37
+ <Text bold color="white">
38
+ {results.wpm} wpm
39
+ </Text>
40
+ {isNewPB && (
41
+ <Text color="greenBright" bold>
42
+ new personal best!
43
+ </Text>
44
+ )}
45
+ {personalBest !== null && !isNewPB && (
46
+ <Text dimColor>pb: {personalBest} wpm</Text>
47
+ )}
48
+ <Text color="white">{results.accuracy}% accuracy</Text>
49
+ <Text dimColor>{results.time}s</Text>
50
+ </Box>
51
+ <Box marginTop={2} gap={2}>
52
+ <Text dimColor>Tab → restart</Text>
53
+ <Text dimColor>Esc → menu</Text>
54
+ <Text dimColor>q → quit</Text>
55
+ </Box>
56
+ </Box>
57
+ );
58
+ }
@@ -0,0 +1,384 @@
1
+ [
2
+ "the",
3
+ "be",
4
+ "to",
5
+ "of",
6
+ "and",
7
+ "a",
8
+ "in",
9
+ "that",
10
+ "have",
11
+ "i",
12
+ "it",
13
+ "for",
14
+ "not",
15
+ "on",
16
+ "with",
17
+ "he",
18
+ "as",
19
+ "you",
20
+ "do",
21
+ "at",
22
+ "this",
23
+ "but",
24
+ "his",
25
+ "by",
26
+ "from",
27
+ "they",
28
+ "we",
29
+ "say",
30
+ "her",
31
+ "she",
32
+ "or",
33
+ "an",
34
+ "will",
35
+ "my",
36
+ "one",
37
+ "all",
38
+ "would",
39
+ "there",
40
+ "their",
41
+ "what",
42
+ "so",
43
+ "up",
44
+ "out",
45
+ "if",
46
+ "about",
47
+ "who",
48
+ "get",
49
+ "which",
50
+ "go",
51
+ "me",
52
+ "when",
53
+ "make",
54
+ "can",
55
+ "like",
56
+ "time",
57
+ "no",
58
+ "just",
59
+ "him",
60
+ "know",
61
+ "take",
62
+ "people",
63
+ "into",
64
+ "year",
65
+ "your",
66
+ "good",
67
+ "some",
68
+ "could",
69
+ "them",
70
+ "see",
71
+ "other",
72
+ "than",
73
+ "then",
74
+ "now",
75
+ "look",
76
+ "only",
77
+ "come",
78
+ "its",
79
+ "over",
80
+ "think",
81
+ "also",
82
+ "back",
83
+ "after",
84
+ "use",
85
+ "two",
86
+ "how",
87
+ "our",
88
+ "work",
89
+ "first",
90
+ "well",
91
+ "way",
92
+ "even",
93
+ "new",
94
+ "want",
95
+ "because",
96
+ "any",
97
+ "these",
98
+ "give",
99
+ "day",
100
+ "most",
101
+ "us",
102
+ "great",
103
+ "between",
104
+ "need",
105
+ "large",
106
+ "under",
107
+ "never",
108
+ "each",
109
+ "right",
110
+ "hand",
111
+ "high",
112
+ "place",
113
+ "very",
114
+ "through",
115
+ "where",
116
+ "much",
117
+ "before",
118
+ "line",
119
+ "too",
120
+ "mean",
121
+ "old",
122
+ "same",
123
+ "tell",
124
+ "boy",
125
+ "did",
126
+ "three",
127
+ "long",
128
+ "small",
129
+ "read",
130
+ "run",
131
+ "keep",
132
+ "house",
133
+ "while",
134
+ "last",
135
+ "might",
136
+ "still",
137
+ "found",
138
+ "live",
139
+ "world",
140
+ "head",
141
+ "stand",
142
+ "own",
143
+ "page",
144
+ "should",
145
+ "city",
146
+ "tree",
147
+ "cross",
148
+ "hard",
149
+ "start",
150
+ "story",
151
+ "far",
152
+ "play",
153
+ "left",
154
+ "end",
155
+ "home",
156
+ "move",
157
+ "try",
158
+ "night",
159
+ "close",
160
+ "why",
161
+ "ask",
162
+ "men",
163
+ "change",
164
+ "went",
165
+ "light",
166
+ "kind",
167
+ "off",
168
+ "point",
169
+ "turn",
170
+ "real",
171
+ "leave",
172
+ "help",
173
+ "show",
174
+ "thought",
175
+ "name",
176
+ "every",
177
+ "open",
178
+ "seem",
179
+ "together",
180
+ "next",
181
+ "white",
182
+ "children",
183
+ "begin",
184
+ "walk",
185
+ "example",
186
+ "paper",
187
+ "group",
188
+ "always",
189
+ "music",
190
+ "those",
191
+ "both",
192
+ "often",
193
+ "letter",
194
+ "until",
195
+ "mile",
196
+ "river",
197
+ "car",
198
+ "feet",
199
+ "care",
200
+ "second",
201
+ "enough",
202
+ "plant",
203
+ "food",
204
+ "add",
205
+ "almost",
206
+ "family",
207
+ "young",
208
+ "important",
209
+ "school",
210
+ "body",
211
+ "side",
212
+ "something",
213
+ "life",
214
+ "water",
215
+ "mother",
216
+ "area",
217
+ "money",
218
+ "number",
219
+ "again",
220
+ "part",
221
+ "state",
222
+ "below",
223
+ "learn",
224
+ "country",
225
+ "build",
226
+ "eye",
227
+ "miss",
228
+ "hold",
229
+ "above",
230
+ "book",
231
+ "hear",
232
+ "horse",
233
+ "face",
234
+ "door",
235
+ "sure",
236
+ "become",
237
+ "girl",
238
+ "study",
239
+ "system",
240
+ "early",
241
+ "air",
242
+ "carry",
243
+ "eat",
244
+ "room",
245
+ "friend",
246
+ "power",
247
+ "question",
248
+ "answer",
249
+ "fish",
250
+ "mountain",
251
+ "stop",
252
+ "once",
253
+ "base",
254
+ "idea",
255
+ "fact",
256
+ "four",
257
+ "cut",
258
+ "grow",
259
+ "earth",
260
+ "color",
261
+ "class",
262
+ "main",
263
+ "space",
264
+ "problem",
265
+ "rest",
266
+ "along",
267
+ "best",
268
+ "better",
269
+ "nothing",
270
+ "plan",
271
+ "free",
272
+ "test",
273
+ "table",
274
+ "half",
275
+ "order",
276
+ "fire",
277
+ "south",
278
+ "top",
279
+ "cover",
280
+ "usually",
281
+ "warm",
282
+ "fall",
283
+ "note",
284
+ "voice",
285
+ "sit",
286
+ "common",
287
+ "strong",
288
+ "town",
289
+ "fine",
290
+ "certain",
291
+ "fly",
292
+ "lead",
293
+ "cry",
294
+ "dark",
295
+ "machine",
296
+ "whole",
297
+ "dog",
298
+ "box",
299
+ "bit",
300
+ "bring",
301
+ "word",
302
+ "red",
303
+ "able",
304
+ "road",
305
+ "yet",
306
+ "ten",
307
+ "land",
308
+ "rather",
309
+ "short",
310
+ "black",
311
+ "draw",
312
+ "happen",
313
+ "watch",
314
+ "front",
315
+ "easy",
316
+ "product",
317
+ "green",
318
+ "ready",
319
+ "toward",
320
+ "island",
321
+ "record",
322
+ "true",
323
+ "during",
324
+ "hundred",
325
+ "morning",
326
+ "several",
327
+ "clear",
328
+ "travel",
329
+ "less",
330
+ "simple",
331
+ "few",
332
+ "cold",
333
+ "step",
334
+ "total",
335
+ "round",
336
+ "may",
337
+ "late",
338
+ "boat",
339
+ "war",
340
+ "ground",
341
+ "field",
342
+ "rock",
343
+ "street",
344
+ "wind",
345
+ "ship",
346
+ "figure",
347
+ "piece",
348
+ "ago",
349
+ "today",
350
+ "drive",
351
+ "list",
352
+ "done",
353
+ "set",
354
+ "love",
355
+ "deep",
356
+ "inch",
357
+ "write",
358
+ "rule",
359
+ "gun",
360
+ "remember",
361
+ "forest",
362
+ "check",
363
+ "game",
364
+ "shape",
365
+ "yes",
366
+ "hot",
367
+ "brought",
368
+ "heat",
369
+ "snow",
370
+ "fast",
371
+ "five",
372
+ "bed",
373
+ "spend",
374
+ "sing",
375
+ "listen",
376
+ "six",
377
+ "size",
378
+ "wild",
379
+ "mark",
380
+ "west",
381
+ "north",
382
+ "east",
383
+ "past"
384
+ ]
@@ -0,0 +1,180 @@
1
+ import { useState, useCallback, useEffect, useRef } from "react";
2
+ import words from "../data/words.json";
3
+
4
+ export type GameState = {
5
+ words: string[];
6
+ currentWordIndex: number;
7
+ currentInput: string;
8
+ timeLeft: number;
9
+ isRunning: boolean;
10
+ isFinished: boolean;
11
+ wordResults: Array<"correct" | "incorrect" | "pending">;
12
+ charInputs: string[][];
13
+ };
14
+
15
+ export type GameResults = {
16
+ wpm: number;
17
+ accuracy: number;
18
+ time: number;
19
+ };
20
+
21
+ function generateWords(count: number): string[] {
22
+ const result: string[] = [];
23
+ for (let i = 0; i < count; i++) {
24
+ result.push(words[Math.floor(Math.random() * words.length)]);
25
+ }
26
+ return result;
27
+ }
28
+
29
+ export function useGame(duration: number) {
30
+ const wordCount = Math.max(duration * 3, 50);
31
+ const [state, setState] = useState<GameState>({
32
+ words: generateWords(wordCount),
33
+ currentWordIndex: 0,
34
+ currentInput: "",
35
+ timeLeft: duration,
36
+ isRunning: false,
37
+ isFinished: false,
38
+ wordResults: Array(wordCount).fill("pending"),
39
+ charInputs: Array(wordCount).fill(null).map(() => []),
40
+ });
41
+
42
+ const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
43
+ const startTimeRef = useRef<number>(0);
44
+
45
+ useEffect(() => {
46
+ if (state.isRunning && !state.isFinished) {
47
+ timerRef.current = setInterval(() => {
48
+ setState((prev) => {
49
+ const newTimeLeft = prev.timeLeft - 1;
50
+ if (newTimeLeft <= 0) {
51
+ if (timerRef.current) clearInterval(timerRef.current);
52
+ return { ...prev, timeLeft: 0, isRunning: false, isFinished: true };
53
+ }
54
+ return { ...prev, timeLeft: newTimeLeft };
55
+ });
56
+ }, 1000);
57
+ }
58
+ return () => {
59
+ if (timerRef.current) clearInterval(timerRef.current);
60
+ };
61
+ }, [state.isRunning, state.isFinished]);
62
+
63
+ const startGame = useCallback(() => {
64
+ startTimeRef.current = Date.now();
65
+ setState((prev) => ({ ...prev, isRunning: true }));
66
+ }, []);
67
+
68
+ const handleChar = useCallback((char: string) => {
69
+ setState((prev) => {
70
+ if (prev.isFinished) return prev;
71
+ if (!prev.isRunning) {
72
+ startTimeRef.current = Date.now();
73
+ const newCharInputs = [...prev.charInputs];
74
+ newCharInputs[prev.currentWordIndex] = [
75
+ ...newCharInputs[prev.currentWordIndex],
76
+ char,
77
+ ];
78
+ return {
79
+ ...prev,
80
+ isRunning: true,
81
+ currentInput: prev.currentInput + char,
82
+ charInputs: newCharInputs,
83
+ };
84
+ }
85
+ const newCharInputs = [...prev.charInputs];
86
+ newCharInputs[prev.currentWordIndex] = [
87
+ ...newCharInputs[prev.currentWordIndex],
88
+ char,
89
+ ];
90
+ return {
91
+ ...prev,
92
+ currentInput: prev.currentInput + char,
93
+ charInputs: newCharInputs,
94
+ };
95
+ });
96
+ }, []);
97
+
98
+ const handleBackspace = useCallback(() => {
99
+ setState((prev) => {
100
+ if (prev.isFinished) return prev;
101
+
102
+ // If current input is empty, go back to previous word
103
+ if (prev.currentInput.length === 0) {
104
+ if (prev.currentWordIndex === 0) return prev;
105
+ const prevIndex = prev.currentWordIndex - 1;
106
+ const prevInput = prev.charInputs[prevIndex].join("");
107
+ const newWordResults = [...prev.wordResults];
108
+ newWordResults[prevIndex] = "pending";
109
+ return {
110
+ ...prev,
111
+ currentWordIndex: prevIndex,
112
+ currentInput: prevInput,
113
+ wordResults: newWordResults,
114
+ };
115
+ }
116
+
117
+ // Otherwise delete last char of current word
118
+ const newCharInputs = [...prev.charInputs];
119
+ newCharInputs[prev.currentWordIndex] = newCharInputs[
120
+ prev.currentWordIndex
121
+ ].slice(0, -1);
122
+ return {
123
+ ...prev,
124
+ currentInput: prev.currentInput.slice(0, -1),
125
+ charInputs: newCharInputs,
126
+ };
127
+ });
128
+ }, []);
129
+
130
+ const handleSpace = useCallback(() => {
131
+ setState((prev) => {
132
+ if (prev.isFinished || !prev.isRunning) return prev;
133
+ const word = prev.words[prev.currentWordIndex];
134
+ const isCorrect = prev.currentInput === word;
135
+ const newWordResults = [...prev.wordResults];
136
+ newWordResults[prev.currentWordIndex] = isCorrect
137
+ ? "correct"
138
+ : "incorrect";
139
+ return {
140
+ ...prev,
141
+ currentWordIndex: prev.currentWordIndex + 1,
142
+ currentInput: "",
143
+ wordResults: newWordResults,
144
+ };
145
+ });
146
+ }, []);
147
+
148
+ const getResults = useCallback((): GameResults => {
149
+ let correctChars = 0;
150
+ let totalChars = 0;
151
+
152
+ for (let i = 0; i < state.currentWordIndex; i++) {
153
+ const word = state.words[i];
154
+ const input = state.charInputs[i].join("");
155
+ totalChars += Math.max(word.length, input.length) + 1;
156
+ if (input === word) {
157
+ correctChars += word.length + 1;
158
+ } else {
159
+ for (let j = 0; j < word.length; j++) {
160
+ if (input[j] === word[j]) correctChars++;
161
+ }
162
+ }
163
+ }
164
+
165
+ const timeElapsed = duration;
166
+ const wpm = totalChars > 0 ? Math.round((correctChars / 5) / (timeElapsed / 60)) : 0;
167
+ const accuracy = totalChars > 0 ? Math.round((correctChars / totalChars) * 1000) / 10 : 0;
168
+
169
+ return { wpm, accuracy, time: duration };
170
+ }, [state.currentWordIndex, state.charInputs, state.words, duration]);
171
+
172
+ return {
173
+ ...state,
174
+ startGame,
175
+ handleChar,
176
+ handleBackspace,
177
+ handleSpace,
178
+ getResults,
179
+ };
180
+ }
package/src/index.tsx ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env bun
2
+ import { render } from "ink";
3
+ import { App } from "./app.tsx";
4
+
5
+ render(<App />);
@@ -0,0 +1,59 @@
1
+ import { homedir } from "os";
2
+ import { join } from "path";
3
+ import { mkdirSync, readFileSync, existsSync } from "fs";
4
+
5
+ const DIR = join(homedir(), ".wpmx");
6
+ const HISTORY_PATH = join(DIR, "history.json");
7
+ const SETTINGS_PATH = join(DIR, "settings.json");
8
+
9
+ function ensureDir() {
10
+ if (!existsSync(DIR)) {
11
+ mkdirSync(DIR, { recursive: true });
12
+ }
13
+ }
14
+
15
+ function readJSON<T>(path: string, fallback: T): T {
16
+ try {
17
+ if (!existsSync(path)) return fallback;
18
+ return JSON.parse(readFileSync(path, "utf-8"));
19
+ } catch {
20
+ return fallback;
21
+ }
22
+ }
23
+
24
+ export type HistoryEntry = {
25
+ wpm: number;
26
+ accuracy: number;
27
+ duration: number;
28
+ date: string;
29
+ };
30
+
31
+ export type Settings = {
32
+ lastDuration: 15 | 30 | 60;
33
+ };
34
+
35
+ export function getHistory(): HistoryEntry[] {
36
+ return readJSON<HistoryEntry[]>(HISTORY_PATH, []);
37
+ }
38
+
39
+ export function saveResult(entry: HistoryEntry): void {
40
+ ensureDir();
41
+ const history = getHistory();
42
+ history.push(entry);
43
+ Bun.write(HISTORY_PATH, JSON.stringify(history, null, 2));
44
+ }
45
+
46
+ export function getPersonalBest(duration: number): number | null {
47
+ const history = getHistory().filter((e) => e.duration === duration);
48
+ if (history.length === 0) return null;
49
+ return Math.max(...history.map((e) => e.wpm));
50
+ }
51
+
52
+ export function loadSettings(): Settings {
53
+ return readJSON<Settings>(SETTINGS_PATH, { lastDuration: 30 });
54
+ }
55
+
56
+ export function saveSettings(settings: Settings): void {
57
+ ensureDir();
58
+ Bun.write(SETTINGS_PATH, JSON.stringify(settings, null, 2));
59
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "lib": ["ESNext"],
4
+ "target": "ESNext",
5
+ "module": "Preserve",
6
+ "moduleDetection": "force",
7
+ "jsx": "react-jsx",
8
+ "moduleResolution": "bundler",
9
+ "allowImportingTsExtensions": true,
10
+ "verbatimModuleSyntax": true,
11
+ "strict": true,
12
+ "skipLibCheck": true,
13
+ "noEmit": true,
14
+ "baseUrl": ".",
15
+ "paths": {
16
+ "@/*": ["src/*"]
17
+ }
18
+ },
19
+ "include": ["src"]
20
+ }