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 +33 -0
- package/LICENSE +21 -0
- package/README.md +43 -0
- package/bun.lock +117 -0
- package/package.json +38 -0
- package/src/app.tsx +90 -0
- package/src/components/Game.tsx +156 -0
- package/src/components/Menu.tsx +67 -0
- package/src/components/Results.tsx +58 -0
- package/src/data/words.json +384 -0
- package/src/hooks/useGame.ts +180 -0
- package/src/index.tsx +5 -0
- package/src/lib/storage.ts +59 -0
- package/tsconfig.json +20 -0
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,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
|
+
}
|