typefaster-cli 0.2.0__tar.gz → 0.3.1__tar.gz

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.
Files changed (168) hide show
  1. typefaster_cli-0.3.1/.github/CODEOWNERS +3 -0
  2. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/.github/workflows/ci.yml +10 -10
  3. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/.github/workflows/codeql.yml +3 -3
  4. typefaster_cli-0.3.1/.github/workflows/release.yml +145 -0
  5. typefaster_cli-0.3.1/CLAUDE.md +110 -0
  6. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/CONTRIBUTING.md +19 -0
  7. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/PKG-INFO +15 -5
  8. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/README.md +14 -4
  9. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/__init__.py +1 -1
  10. typefaster_cli-0.3.1/client/typefaster/domain/drills.py +39 -0
  11. typefaster_cli-0.3.1/client/typefaster/domain/keyboard.py +74 -0
  12. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/domain/models.py +2 -0
  13. typefaster_cli-0.3.1/client/typefaster/domain/text_modifiers.py +34 -0
  14. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/domain/typing_engine.py +19 -0
  15. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/infra/config.py +3 -0
  16. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/infra/migrations.py +13 -0
  17. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/infra/quote_loader.py +13 -0
  18. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/infra/repository.py +5 -0
  19. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/infra/sqlite_repository.py +34 -0
  20. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/net/api.py +27 -2
  21. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/net/commands.py +1 -2
  22. typefaster_cli-0.3.1/client/typefaster/net/token_store.py +93 -0
  23. typefaster_cli-0.3.1/client/typefaster/services/coach_service.py +52 -0
  24. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/services/container.py +9 -1
  25. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/services/daily_service.py +13 -1
  26. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/services/race_service.py +60 -5
  27. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/ui/app.py +9 -0
  28. typefaster_cli-0.3.1/client/typefaster/ui/screens/coach.py +112 -0
  29. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/ui/screens/daily.py +4 -0
  30. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/ui/screens/main_menu.py +1 -0
  31. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/ui/screens/online_race.py +9 -3
  32. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/ui/screens/results.py +16 -0
  33. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/ui/screens/settings.py +13 -0
  34. typefaster_cli-0.3.1/docs/ddos-protection.md +94 -0
  35. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/pyproject.toml +1 -1
  36. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/server/Dockerfile +5 -2
  37. typefaster_cli-0.3.1/server/app/abuse.py +81 -0
  38. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/server/app/config.py +7 -0
  39. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/server/app/main.py +31 -12
  40. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/server/app/repositories.py +14 -0
  41. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/server/app/routers/lobbies.py +10 -3
  42. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/server/app/routers/oauth.py +1 -1
  43. typefaster_cli-0.3.1/server/tests/test_abuse.py +87 -0
  44. typefaster_cli-0.3.1/tests/integration/test_daily_streak.py +53 -0
  45. typefaster_cli-0.3.1/tests/integration/test_migrations.py +49 -0
  46. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/tests/integration/test_race_service.py +51 -0
  47. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/tests/integration/test_sqlite_repository.py +33 -0
  48. typefaster_cli-0.3.1/tests/integration/test_ui_smoke.py +165 -0
  49. typefaster_cli-0.3.1/tests/unit/test_coach.py +63 -0
  50. typefaster_cli-0.3.1/tests/unit/test_drills.py +44 -0
  51. typefaster_cli-0.3.1/tests/unit/test_net.py +153 -0
  52. typefaster_cli-0.3.1/tests/unit/test_text_modifiers.py +36 -0
  53. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/tests/unit/test_typing_engine.py +31 -0
  54. typefaster_cli-0.2.0/.github/workflows/release.yml +0 -82
  55. typefaster_cli-0.2.0/client/typefaster/net/token_store.py +0 -49
  56. typefaster_cli-0.2.0/tests/integration/test_migrations.py +0 -26
  57. typefaster_cli-0.2.0/tests/integration/test_ui_smoke.py +0 -55
  58. typefaster_cli-0.2.0/tests/unit/test_net.py +0 -43
  59. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/.dockerignore +0 -0
  60. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/.env.example +0 -0
  61. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  62. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  63. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  64. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  65. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/.github/dependabot.yml +0 -0
  66. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/.gitignore +0 -0
  67. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/LICENSE +0 -0
  68. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/Makefile +0 -0
  69. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/SECURITY.md +0 -0
  70. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/Dockerfile +0 -0
  71. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/__main__.py +0 -0
  72. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/assets/__init__.py +0 -0
  73. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/assets/quotes.json +0 -0
  74. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/cli.py +0 -0
  75. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/domain/__init__.py +0 -0
  76. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/domain/anti_cheat.py +0 -0
  77. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/domain/calculators.py +0 -0
  78. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/domain/errors.py +0 -0
  79. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/domain/ghost.py +0 -0
  80. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/infra/__init__.py +0 -0
  81. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/infra/clock.py +0 -0
  82. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/infra/db.py +0 -0
  83. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/infra/paths.py +0 -0
  84. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/infra/replay_store.py +0 -0
  85. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/net/__init__.py +0 -0
  86. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/services/__init__.py +0 -0
  87. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/services/ghost_service.py +0 -0
  88. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/services/profile_service.py +0 -0
  89. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/services/stats_service.py +0 -0
  90. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/ui/__init__.py +0 -0
  91. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/ui/online_app.py +0 -0
  92. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/ui/screens/__init__.py +0 -0
  93. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/ui/screens/_base.py +0 -0
  94. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/ui/screens/account.py +0 -0
  95. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/ui/screens/help.py +0 -0
  96. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/ui/screens/history.py +0 -0
  97. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/ui/screens/leaderboard.py +0 -0
  98. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/ui/screens/lobby_browser.py +0 -0
  99. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/ui/screens/practice.py +0 -0
  100. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/ui/screens/profile.py +0 -0
  101. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/ui/screens/race.py +0 -0
  102. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/ui/screens/stats.py +0 -0
  103. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/ui/theme.py +0 -0
  104. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/ui/widgets/__init__.py +0 -0
  105. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/ui/widgets/bigtext.py +0 -0
  106. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/ui/widgets/live_stats.py +0 -0
  107. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/ui/widgets/progress_bars.py +0 -0
  108. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/client/typefaster/ui/widgets/typing_field.py +0 -0
  109. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/docker-compose.yml +0 -0
  110. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/docs/DEEPDIVE.md +0 -0
  111. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/docs/INSTALL.md +0 -0
  112. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/docs/RELEASING.md +0 -0
  113. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/docs/SECURITY-REVIEW.md +0 -0
  114. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/docs/api-spec.md +0 -0
  115. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/docs/architecture.md +0 -0
  116. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/docs/deploy-fly.md +0 -0
  117. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/docs/deploy-oracle.md +0 -0
  118. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/docs/deployment.md +0 -0
  119. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/docs/online-setup.md +0 -0
  120. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/docs/redis-schema.md +0 -0
  121. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/docs/roadmap.md +0 -0
  122. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/docs/sqlite-schema.md +0 -0
  123. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/docs/ui-design.md +0 -0
  124. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/docs/websocket-protocol.md +0 -0
  125. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/fly.toml +0 -0
  126. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/infra/README.md +0 -0
  127. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/infra/nginx/nginx.conf +0 -0
  128. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/infra/redis/redis.conf +0 -0
  129. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/packaging/homebrew/typefaster.rb +0 -0
  130. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/scripts/deploy-oracle.sh +0 -0
  131. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/scripts/seed_quotes.py +0 -0
  132. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/server/app/__init__.py +0 -0
  133. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/server/app/deps.py +0 -0
  134. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/server/app/logging_config.py +0 -0
  135. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/server/app/quotes.py +0 -0
  136. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/server/app/redis_keys.py +0 -0
  137. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/server/app/routers/__init__.py +0 -0
  138. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/server/app/routers/auth.py +0 -0
  139. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/server/app/routers/health.py +0 -0
  140. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/server/app/routers/leaderboards.py +0 -0
  141. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/server/app/security.py +0 -0
  142. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/server/app/ws/__init__.py +0 -0
  143. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/server/app/ws/manager.py +0 -0
  144. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/server/pyproject.toml +0 -0
  145. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/server/tests/__init__.py +0 -0
  146. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/server/tests/conftest.py +0 -0
  147. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/server/tests/test_auth.py +0 -0
  148. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/server/tests/test_health_leaderboards.py +0 -0
  149. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/server/tests/test_lobbies.py +0 -0
  150. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/server/tests/test_oauth.py +0 -0
  151. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/server/tests/test_scoring_anticheat.py +0 -0
  152. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/server/tests/test_ws_race.py +0 -0
  153. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/shared/pyproject.toml +0 -0
  154. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/shared/typefaster_shared/__init__.py +0 -0
  155. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/shared/typefaster_shared/anti_cheat.py +0 -0
  156. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/shared/typefaster_shared/dto.py +0 -0
  157. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/shared/typefaster_shared/events.py +0 -0
  158. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/shared/typefaster_shared/scoring.py +0 -0
  159. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/tests/conftest.py +0 -0
  160. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/tests/fixtures/.gitkeep +0 -0
  161. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/tests/integration/test_container_and_cli.py +0 -0
  162. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/tests/integration/test_profile_stats.py +0 -0
  163. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/tests/integration/test_race_timing.py +0 -0
  164. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/tests/unit/test_anti_cheat.py +0 -0
  165. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/tests/unit/test_calculators.py +0 -0
  166. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/tests/unit/test_config.py +0 -0
  167. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/tests/unit/test_ghost.py +0 -0
  168. {typefaster_cli-0.2.0 → typefaster_cli-0.3.1}/tests/unit/test_quote_loader.py +0 -0
@@ -0,0 +1,3 @@
1
+ # Every change requests a review from the project owner by default.
2
+ # https://docs.github.com/articles/about-code-owners
3
+ * @Anoshor
@@ -18,10 +18,10 @@ jobs:
18
18
  matrix:
19
19
  python-version: ["3.11", "3.12"]
20
20
  steps:
21
- - uses: actions/checkout@v4
21
+ - uses: actions/checkout@v7
22
22
 
23
23
  - name: Set up Python ${{ matrix.python-version }}
24
- uses: actions/setup-python@v5
24
+ uses: actions/setup-python@v6
25
25
  with:
26
26
  python-version: ${{ matrix.python-version }}
27
27
  cache: pip
@@ -43,7 +43,7 @@ jobs:
43
43
 
44
44
  - name: Upload coverage artifact
45
45
  if: matrix.python-version == '3.12'
46
- uses: actions/upload-artifact@v4
46
+ uses: actions/upload-artifact@v7
47
47
  with:
48
48
  name: coverage
49
49
  path: coverage.xml
@@ -52,8 +52,8 @@ jobs:
52
52
  name: server lint · test
53
53
  runs-on: ubuntu-latest
54
54
  steps:
55
- - uses: actions/checkout@v4
56
- - uses: actions/setup-python@v5
55
+ - uses: actions/checkout@v7
56
+ - uses: actions/setup-python@v6
57
57
  with:
58
58
  python-version: "3.12"
59
59
  cache: pip
@@ -70,8 +70,8 @@ jobs:
70
70
  name: dependency audit (pip-audit)
71
71
  runs-on: ubuntu-latest
72
72
  steps:
73
- - uses: actions/checkout@v4
74
- - uses: actions/setup-python@v5
73
+ - uses: actions/checkout@v7
74
+ - uses: actions/setup-python@v6
75
75
  with:
76
76
  python-version: "3.12"
77
77
  cache: pip
@@ -86,14 +86,14 @@ jobs:
86
86
  runs-on: ubuntu-latest
87
87
  needs: [quality, server]
88
88
  steps:
89
- - uses: actions/checkout@v4
90
- - uses: actions/setup-python@v5
89
+ - uses: actions/checkout@v7
90
+ - uses: actions/setup-python@v6
91
91
  with:
92
92
  python-version: "3.12"
93
93
  cache: pip
94
94
  - run: python -m pip install build
95
95
  - run: python -m build
96
- - uses: actions/upload-artifact@v4
96
+ - uses: actions/upload-artifact@v7
97
97
  with:
98
98
  name: dist
99
99
  path: dist/*
@@ -16,8 +16,8 @@ jobs:
16
16
  actions: read
17
17
  contents: read
18
18
  steps:
19
- - uses: actions/checkout@v4
20
- - uses: github/codeql-action/init@v3
19
+ - uses: actions/checkout@v7
20
+ - uses: github/codeql-action/init@v4
21
21
  with:
22
22
  languages: python
23
- - uses: github/codeql-action/analyze@v3
23
+ - uses: github/codeql-action/analyze@v4
@@ -0,0 +1,145 @@
1
+ name: Release
2
+
3
+ # Publishes to PyPI and creates a GitHub Release whenever you push a version tag
4
+ # (e.g. `git tag v0.1.0 && git push origin v0.1.0`).
5
+ #
6
+ # Uses PyPI Trusted Publishing (OIDC) — no API tokens stored in the repo. You
7
+ # must configure the trusted publisher once on PyPI (see docs/RELEASING.md).
8
+
9
+ on:
10
+ push:
11
+ tags: ["v*"]
12
+
13
+ permissions:
14
+ contents: write # create the GitHub Release
15
+ id-token: write # OIDC token for PyPI Trusted Publishing
16
+ packages: write # push the server image to GHCR
17
+
18
+ jobs:
19
+ build:
20
+ runs-on: ubuntu-latest
21
+ steps:
22
+ - uses: actions/checkout@v7
23
+ - uses: actions/setup-python@v6
24
+ with:
25
+ python-version: "3.12"
26
+ - run: python -m pip install build
27
+ - run: python -m build # creates dist/*.whl and dist/*.tar.gz
28
+ - run: python -m pip install twine && twine check dist/*
29
+ - uses: actions/upload-artifact@v7
30
+ with:
31
+ name: dist
32
+ path: dist/*
33
+
34
+ publish-pypi:
35
+ needs: build
36
+ runs-on: ubuntu-latest
37
+ environment: pypi # optional: add manual approval in repo settings
38
+ steps:
39
+ - uses: actions/download-artifact@v8
40
+ with:
41
+ name: dist
42
+ path: dist
43
+ - uses: pypa/gh-action-pypi-publish@release/v1
44
+
45
+ github-release:
46
+ needs: build
47
+ runs-on: ubuntu-latest
48
+ steps:
49
+ - uses: actions/download-artifact@v8
50
+ with:
51
+ name: dist
52
+ path: dist
53
+ - uses: softprops/action-gh-release@v3
54
+ with:
55
+ generate_release_notes: true
56
+ files: dist/*
57
+
58
+ bump-homebrew:
59
+ name: bump Homebrew tap formula
60
+ needs: publish-pypi # only bump once the sdist is live on PyPI
61
+ runs-on: ubuntu-latest
62
+ steps:
63
+ - name: Check out the tap repo
64
+ uses: actions/checkout@v7
65
+ with:
66
+ repository: Anoshor/homebrew-typefaster
67
+ token: ${{ secrets.HOMEBREW_TAP_TOKEN }} # fine-grained PAT, contents:write on the tap
68
+ path: tap
69
+
70
+ - name: Update url + sha256 from PyPI
71
+ env:
72
+ VERSION: ${{ github.ref_name }} # e.g. v0.2.1
73
+ run: |
74
+ python3 - <<'PY'
75
+ import json, os, re, sys, time, urllib.request
76
+
77
+ version = os.environ["VERSION"].lstrip("v")
78
+ api = f"https://pypi.org/pypi/typefaster-cli/{version}/json"
79
+
80
+ # PyPI's JSON index can lag a few seconds behind the upload; retry.
81
+ data = None
82
+ for attempt in range(10):
83
+ try:
84
+ with urllib.request.urlopen(api) as r:
85
+ data = json.load(r)
86
+ break
87
+ except Exception as e: # noqa: BLE001
88
+ print(f"attempt {attempt+1}: {e}; retrying in 6s")
89
+ time.sleep(6)
90
+ if data is None:
91
+ sys.exit(f"could not fetch {api}")
92
+
93
+ sdist = next(u for u in data["urls"] if u["packagetype"] == "sdist")
94
+ url, sha = sdist["url"], sdist["digests"]["sha256"]
95
+ print(f"new url: {url}\nnew sha: {sha}")
96
+
97
+ path = "tap/Formula/typefaster.rb"
98
+ text = open(path, encoding="utf-8").read()
99
+ # Only touch the top-level url/sha256 (everything before the first resource).
100
+ head, sep, rest = text.partition("\n resource ")
101
+ head = re.sub(r'url "[^"]*"', f'url "{url}"', head, count=1)
102
+ head = re.sub(r'sha256 "[^"]*"', f'sha256 "{sha}"', head, count=1)
103
+ open(path, "w", encoding="utf-8").write(head + sep + rest)
104
+ PY
105
+
106
+ - name: Commit & push the bump
107
+ working-directory: tap
108
+ env:
109
+ VERSION: ${{ github.ref_name }}
110
+ run: |
111
+ git config user.name "github-actions[bot]"
112
+ git config user.email "github-actions[bot]@users.noreply.github.com"
113
+ if git diff --quiet; then
114
+ echo "Formula already up to date — nothing to bump."
115
+ exit 0
116
+ fi
117
+ git add Formula/typefaster.rb
118
+ git commit -m "typefaster ${VERSION#v}"
119
+ git push
120
+
121
+ server-image:
122
+ name: build & push server image (GHCR)
123
+ needs: build
124
+ runs-on: ubuntu-latest
125
+ steps:
126
+ - uses: actions/checkout@v7
127
+ - uses: docker/login-action@v4
128
+ with:
129
+ registry: ghcr.io
130
+ username: ${{ github.actor }}
131
+ password: ${{ secrets.GITHUB_TOKEN }}
132
+ - id: meta
133
+ uses: docker/metadata-action@v6
134
+ with:
135
+ images: ghcr.io/${{ github.repository_owner }}/typefaster-server
136
+ tags: |
137
+ type=semver,pattern={{version}}
138
+ type=raw,value=latest
139
+ - uses: docker/build-push-action@v7
140
+ with:
141
+ context: .
142
+ file: server/Dockerfile
143
+ push: true
144
+ tags: ${{ steps.meta.outputs.tags }}
145
+ labels: ${{ steps.meta.outputs.labels }}
@@ -0,0 +1,110 @@
1
+ # CLAUDE.md
2
+
3
+ Guidance for working in this repo. TYPEFASTER is a terminal-first typing game:
4
+ an offline TUI plus an online real-time multiplayer server. Distributed via PyPI
5
+ (`typefaster-cli`) and a Homebrew tap. Hosted at $0 on an Oracle Always-Free VM.
6
+
7
+ ## Repo layout
8
+
9
+ ```
10
+ client/typefaster/ the CLI/TUI app (domain · services · infra · ui · net · assets)
11
+ server/app/ online server (FastAPI · WebSockets · Redis · routers · ws)
12
+ shared/typefaster_shared/ schemas (dto), WS events, scoring, anti-cheat — used by both
13
+ tests/ client tests (unit/ + integration/)
14
+ server/tests/ server tests (fakeredis + Starlette TestClient)
15
+ docs/ architecture, schemas, WS protocol, deploy, security, releasing
16
+ Formula lives in a SEPARATE repo: Anoshor/homebrew-typefaster (the tap).
17
+ ```
18
+
19
+ ## Architecture & layering (do not violate)
20
+
21
+ Clean layering, enforced by convention:
22
+
23
+ - **`domain/`** is pure: dataclasses, enums, and deterministic logic. It must NOT
24
+ import Rich/Textual/SQLite/httpx. Time is injected (`Clock`), so logic is
25
+ unit-testable without a real clock.
26
+ - **`services/`** orchestrate domain + repositories. UI-free, reusable headlessly.
27
+ - **`infra/`** (SQLite, config, paths, quote loader) and **`ui/`** (Textual)
28
+ depend inward only.
29
+ - **Repository port**: `infra/repository.py` is a `typing.Protocol`; the SQLite
30
+ impl satisfies it. Services depend on the Protocol, not SQLite.
31
+ - **Server is authoritative**: the WS server re-scores every race from raw inputs
32
+ using `shared/typefaster_shared/scoring.py` (the SAME math as the client's
33
+ `domain/calculators.py`) and never trusts client-reported WPM/accuracy.
34
+
35
+ When adding code, put it in the right layer and keep `domain/` pure.
36
+
37
+ ## Commands
38
+
39
+ Client (from repo root; uses `client/` on PYTHONPATH via the Makefile):
40
+ ```bash
41
+ make install # editable install + dev deps into a venv
42
+ make play # launch the TUI (offline)
43
+ make check # ruff + mypy --strict + pytest ← run before every PR
44
+ make test # pytest only
45
+ make up / down # start/stop the online stack (redis + server) via Docker
46
+ ```
47
+
48
+ Server (separate package; mirror CI exactly):
49
+ ```bash
50
+ cd server && pytest # server suite (fakeredis)
51
+ ruff check server shared --select E,F,I,UP,B --ignore UP042 # the server lint gate
52
+ ```
53
+
54
+ Notes:
55
+ - The package lives at `client/typefaster`; `make` sets `PYTHONPATH=client`. If
56
+ running tools directly, prefix with `PYTHONPATH=client`.
57
+ - Client gate = ruff + black + mypy --strict + pytest. **Server CI runs ruff +
58
+ pytest only — no mypy** (the server has known pre-existing mypy noise from the
59
+ untyped `typefaster_shared` import; don't chase it).
60
+ - Tests use markers: `unit`, `integration`, `ui` (Textual pilot smoke tests).
61
+
62
+ ## Conventions
63
+
64
+ - Add/adjust tests for any behavior change. UI gets Textual pilot smoke tests in
65
+ `tests/integration/test_ui_smoke.py`; logic gets unit/integration tests.
66
+ - Adding a TUI screen: create it under `ui/screens/`, register it in `_PANELS`
67
+ in `ui/app.py`, and add a menu row in `ui/screens/main_menu.py`. Read-only
68
+ panels subclass `PanelScreen` (`ui/screens/_base.py`); override `body()`.
69
+ - Adding a SQLite migration: append a `(version, sql)` tuple to `_MIGRATIONS` in
70
+ `infra/migrations.py` (use `IF NOT EXISTS`; never edit a shipped migration).
71
+ - Adding quotes: edit `scripts/seed_quotes.py`, run it, commit the regenerated
72
+ `client/typefaster/assets/quotes.json`.
73
+ - Conventional, focused commits. Don't commit generated artifacts (git-ignored).
74
+
75
+ ## Release flow (automated)
76
+
77
+ Per release, edit the version in **two** files that must stay in sync:
78
+ `pyproject.toml` and `client/typefaster/__init__.py`. Then:
79
+ ```bash
80
+ git tag vX.Y.Z && git push origin vX.Y.Z
81
+ ```
82
+ The tag triggers `.github/workflows/release.yml`, which: builds → publishes to
83
+ PyPI (OIDC Trusted Publishing, gated by the `pypi` environment approval) →
84
+ creates a GitHub Release → **auto-bumps the Homebrew tap formula** (needs the
85
+ `HOMEBREW_TAP_TOKEN` secret) → pushes the server image to GHCR.
86
+
87
+ ## Git / PR workflow
88
+
89
+ `main` is protected: PRs require a passing CI + a CODEOWNERS review before merge;
90
+ no force-push/deletion. As repo admin you can bypass for your own pushes, but
91
+ prefer feature branches. Branch from `main`; do not commit directly unless asked.
92
+
93
+ ## Key facts / gotchas
94
+
95
+ - **Public server**: client default `server_url` lives in
96
+ `client/typefaster/net/token_store.py` (`DEFAULT_SERVER_URL`). It self-heals a
97
+ dead `*.trycloudflare.com` URL to the default on load.
98
+ - **Online is server-authoritative** with per-IP rate limiting on auth/oauth and
99
+ app-layer flood guards (see `docs/ddos-protection.md`). A true volumetric DDoS
100
+ needs an upstream edge (Cloudflare) — app code can't stop it.
101
+ - **Daily challenge** is keyed by UTC date (`datetime.now(UTC).date()`), matching
102
+ how results are filed — don't switch it to local `date.today()`.
103
+ - **Secrets are never committed.** JWT secret + OAuth client IDs/secret live only
104
+ in `/opt/typefaster/.env` on the VM. `.env` and `docs/MARKETING.md` are
105
+ git-ignored.
106
+ - **Two repos by design**: the code repo and the mandatory `homebrew-typefaster`
107
+ tap (one formula file, auto-bumped by CI). The tap is not a second project to
108
+ maintain.
109
+ - The TUI footer credits "Anoshor Paul" intentionally; keep it.
110
+ - $0 hosting is a project goal — prefer free tiers; flag anything that adds cost.
@@ -48,6 +48,25 @@ Quality gates (all must pass):
48
48
  - **mypy --strict** — types (the `typefaster` package)
49
49
  - **pytest** — tests; add/adjust tests for any behavior change
50
50
 
51
+ ## Pull request process
52
+
53
+ `main` is a protected branch — you can't push to it directly. Work on a fork or
54
+ a feature branch and open a PR:
55
+
56
+ 1. **Fork** the repo (or branch, if you're a collaborator) and create a topic
57
+ branch: `git checkout -b fix/ghost-timing`.
58
+ 2. Make your change with tests, then run `make check` locally.
59
+ 3. **Open a PR** against `main`. CI runs automatically.
60
+ 4. **All checks must be green** — `lint · type · test (3.11/3.12)`,
61
+ `server lint · test`, and `dependency audit (pip-audit)`.
62
+ 5. A **maintainer review is required** (you'll be auto-requested via
63
+ `CODEOWNERS`), and any review conversations must be resolved before merge.
64
+ 6. Keep PRs small and focused; squash-friendly, conventional commit messages
65
+ help.
66
+
67
+ Dependency and GitHub-Actions updates are proposed automatically by Dependabot —
68
+ no action needed from contributors.
69
+
51
70
  ## Guidelines
52
71
 
53
72
  - **Keep the domain pure** and deterministic (time is injected via `Clock`).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: typefaster-cli
3
- Version: 0.2.0
3
+ Version: 0.3.1
4
4
  Summary: A terminal-first typing game inspired by MonkeyType and TypeRacer.
5
5
  Project-URL: Homepage, https://github.com/Anoshor/typefaster-cli
6
6
  Project-URL: Repository, https://github.com/Anoshor/typefaster-cli
@@ -53,14 +53,16 @@ typefaster
53
53
  **Homebrew** (macOS / Linux)
54
54
  ```bash
55
55
  brew install Anoshor/typefaster/typefaster
56
+ # or tap once, then use the short name:
57
+ brew tap anoshor/typefaster && brew install typefaster
56
58
  ```
57
59
 
58
- **pipx** (any OS with Python 3.11+) — fastest
60
+ **pipx** (any OS with Python 3.11+) — fastest install
59
61
  ```bash
60
62
  pipx install typefaster-cli
61
63
  ```
62
64
 
63
- Verify: `typefaster version`
65
+ Verify: `typefaster version` · Upgrade: `brew upgrade typefaster` or `pipx upgrade typefaster-cli`
64
66
 
65
67
  ## Play (offline — no account, no internet)
66
68
 
@@ -73,8 +75,14 @@ Keyboard-only menu:
73
75
  - **Time Attack** — type for 30 / 60 / 120s (←/→ to change the duration inline).
74
76
  - **Practice** — pick a mode/ghost.
75
77
  - **Daily Challenge** — same quote for everyone each day, local leaderboard.
78
+ - **🎯 Typing Coach** — see the keys you miss most, a per-key accuracy heatmap, and
79
+ finger-position tips; press **d** to launch a drill weighted toward your weak
80
+ keys. All computed locally — no account, no internet, no AI service.
76
81
  - **Stats / History / Profile / Leaderboard / Settings**.
77
82
 
83
+ **Practice modes (MonkeyType-style):** in **Settings**, toggle **Lowercase only**
84
+ or **Words only** (pure a–z, no punctuation or numbers) to strip the text down.
85
+
78
86
  Live WPM, accuracy, progress, and an animated ghost bar. Backspace corrects
79
87
  mistakes (original errors still count, MonkeyType-style). All progress is saved
80
88
  locally in SQLite.
@@ -136,9 +144,11 @@ Deploy guides: [`docs/deploy-oracle.md`](docs/deploy-oracle.md) (free 24/7 VM)
136
144
  ```bash
137
145
  make install # editable install + dev deps
138
146
  make play # run it
139
- make check # ruff + mypy + pytest
147
+ make check # ruff + mypy + pytest (CI parity)
140
148
  ```
141
- Contributions welcome — see [CONTRIBUTING.md](CONTRIBUTING.md).
149
+ Contributions welcome! `main` is protected fork, branch, and open a PR; CI must
150
+ pass and a maintainer review is required before merge. Full guide:
151
+ [CONTRIBUTING.md](CONTRIBUTING.md).
142
152
 
143
153
  ## License
144
154
 
@@ -21,14 +21,16 @@ typefaster
21
21
  **Homebrew** (macOS / Linux)
22
22
  ```bash
23
23
  brew install Anoshor/typefaster/typefaster
24
+ # or tap once, then use the short name:
25
+ brew tap anoshor/typefaster && brew install typefaster
24
26
  ```
25
27
 
26
- **pipx** (any OS with Python 3.11+) — fastest
28
+ **pipx** (any OS with Python 3.11+) — fastest install
27
29
  ```bash
28
30
  pipx install typefaster-cli
29
31
  ```
30
32
 
31
- Verify: `typefaster version`
33
+ Verify: `typefaster version` · Upgrade: `brew upgrade typefaster` or `pipx upgrade typefaster-cli`
32
34
 
33
35
  ## Play (offline — no account, no internet)
34
36
 
@@ -41,8 +43,14 @@ Keyboard-only menu:
41
43
  - **Time Attack** — type for 30 / 60 / 120s (←/→ to change the duration inline).
42
44
  - **Practice** — pick a mode/ghost.
43
45
  - **Daily Challenge** — same quote for everyone each day, local leaderboard.
46
+ - **🎯 Typing Coach** — see the keys you miss most, a per-key accuracy heatmap, and
47
+ finger-position tips; press **d** to launch a drill weighted toward your weak
48
+ keys. All computed locally — no account, no internet, no AI service.
44
49
  - **Stats / History / Profile / Leaderboard / Settings**.
45
50
 
51
+ **Practice modes (MonkeyType-style):** in **Settings**, toggle **Lowercase only**
52
+ or **Words only** (pure a–z, no punctuation or numbers) to strip the text down.
53
+
46
54
  Live WPM, accuracy, progress, and an animated ghost bar. Backspace corrects
47
55
  mistakes (original errors still count, MonkeyType-style). All progress is saved
48
56
  locally in SQLite.
@@ -104,9 +112,11 @@ Deploy guides: [`docs/deploy-oracle.md`](docs/deploy-oracle.md) (free 24/7 VM)
104
112
  ```bash
105
113
  make install # editable install + dev deps
106
114
  make play # run it
107
- make check # ruff + mypy + pytest
115
+ make check # ruff + mypy + pytest (CI parity)
108
116
  ```
109
- Contributions welcome — see [CONTRIBUTING.md](CONTRIBUTING.md).
117
+ Contributions welcome! `main` is protected fork, branch, and open a PR; CI must
118
+ pass and a maintainer review is required before merge. Full guide:
119
+ [CONTRIBUTING.md](CONTRIBUTING.md).
110
120
 
111
121
  ## License
112
122
 
@@ -1,3 +1,3 @@
1
1
  """TYPEFASTER-CLI — a terminal-first typing game."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.3.1"
@@ -0,0 +1,39 @@
1
+ """Adaptive drill generation — assemble practice text biased toward weak keys.
2
+
3
+ Pure and deterministic (the word list and RNG are injected). The service layer
4
+ supplies words from the quote corpus.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import random
10
+
11
+
12
+ def build_drill(
13
+ weak_keys: list[str],
14
+ words: list[str],
15
+ *,
16
+ length: int = 30,
17
+ rng: random.Random | None = None,
18
+ ) -> str:
19
+ """Return a space-joined drill of ``length`` words, weighted so words
20
+ containing the player's weak keys appear more often. Falls back to a plain
21
+ random sample when there are no weak keys or no matching words."""
22
+ rng = rng or random.Random()
23
+ clean = [w.lower() for w in words if w.isalpha()]
24
+ if not clean:
25
+ raise ValueError("no words available to build a drill")
26
+
27
+ weak = {c.lower() for c in weak_keys if c.strip()} # ignore the space key
28
+ if not weak:
29
+ return " ".join(rng.choices(clean, k=length))
30
+
31
+ # Weight each word by how many distinct weak keys it exercises.
32
+ scored = [(w, sum(1 for ch in set(w) if ch in weak)) for w in clean]
33
+ weighted = [(w, s) for w, s in scored if s > 0]
34
+ if not weighted:
35
+ weighted = [(w, 1) for w in clean]
36
+
37
+ population = [w for w, _ in weighted]
38
+ weights = [s for _, s in weighted]
39
+ return " ".join(rng.choices(population, weights=weights, k=length))
@@ -0,0 +1,74 @@
1
+ """Static QWERTY touch-typing model — which finger reaches each key and how.
2
+
3
+ Pure reference data (no I/O), used by the typing coach to give finger-position
4
+ guidance and to lay out the per-key heatmap. Keyed by the lowercase character,
5
+ matching how ``TypingEngine`` records key stats.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+
12
+ # Visual rows for the heatmap, left-to-right, top-to-bottom.
13
+ ROWS: tuple[str, ...] = ("qwertyuiop", "asdfghjkl", "zxcvbnm")
14
+
15
+ # Finger assignment for the standard touch-typing layout.
16
+ _FINGER: dict[str, str] = {
17
+ **dict.fromkeys("qaz", "left pinky"),
18
+ **dict.fromkeys("wsx", "left ring"),
19
+ **dict.fromkeys("edc", "left middle"),
20
+ **dict.fromkeys("rfvtgb", "left index"),
21
+ **dict.fromkeys("yhnujm", "right index"),
22
+ **dict.fromkeys("ik,", "right middle"),
23
+ **dict.fromkeys("ol.", "right ring"),
24
+ **dict.fromkeys("p;/", "right pinky"),
25
+ " ": "thumb",
26
+ }
27
+
28
+ # The resting (home-row) key for each finger.
29
+ _HOME: dict[str, str] = {
30
+ "left pinky": "a",
31
+ "left ring": "s",
32
+ "left middle": "d",
33
+ "left index": "f",
34
+ "right index": "j",
35
+ "right middle": "k",
36
+ "right ring": "l",
37
+ "right pinky": ";",
38
+ "thumb": "space",
39
+ }
40
+
41
+ HOME_ROW: frozenset[str] = frozenset("asdfjkl;")
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class KeyInfo:
46
+ finger: str
47
+ home_key: str
48
+ tip: str
49
+
50
+
51
+ def key_info(ch: str) -> KeyInfo | None:
52
+ """Finger/home/tip for a single key, or None if it's not on the model
53
+ (e.g. digits or symbols we don't coach)."""
54
+ ch = ch.lower()
55
+ finger = _FINGER.get(ch)
56
+ if finger is None:
57
+ return None
58
+ home = _HOME[finger]
59
+ if ch == " ":
60
+ tip = "tap with your thumb"
61
+ elif ch in HOME_ROW:
62
+ tip = f"home row — rest your {finger} here"
63
+ elif _row_of(ch) == 0:
64
+ tip = f"reach up from {home.upper()} with your {finger}"
65
+ else:
66
+ tip = f"reach down from {home.upper()} with your {finger}"
67
+ return KeyInfo(finger=finger, home_key=home, tip=tip)
68
+
69
+
70
+ def _row_of(ch: str) -> int:
71
+ for i, row in enumerate(ROWS):
72
+ if ch in row:
73
+ return i
74
+ return 1 # default to home row
@@ -135,6 +135,8 @@ class RaceResult:
135
135
  # Set when the run is implausible (paste/auto-input) — not recorded.
136
136
  suspicious: bool = False
137
137
  flags: tuple[str, ...] = ()
138
+ # Per-key (attempts, misses), folded by case — feeds the typing coach.
139
+ key_stats: dict[str, tuple[int, int]] = field(default_factory=dict)
138
140
 
139
141
  @property
140
142
  def completed(self) -> bool:
@@ -0,0 +1,34 @@
1
+ """MonkeyType-style text modifiers — pure transforms applied to race text.
2
+
3
+ These reshape the *target text* a player types (e.g. strip everything down to
4
+ lowercase words) without touching the original quote that gets persisted. Kept
5
+ dependency-free and deterministic so they're trivially unit-testable.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+
12
+ _NON_WORD = re.compile(r"[^A-Za-z ]+")
13
+ _WHITESPACE = re.compile(r"\s+")
14
+
15
+
16
+ def apply_modifiers(text: str, *, lowercase: bool, words_only: bool) -> str:
17
+ """Apply the active modifiers to ``text``.
18
+
19
+ - ``words_only`` keeps only ``[a-z]`` and single spaces (implies lowercase).
20
+ - ``lowercase`` (when words_only is off) just lowercases.
21
+
22
+ Never returns an empty string: if a transform would empty the text (e.g. a
23
+ quote made entirely of digits/punctuation), the original is returned, since
24
+ ``TypingEngine`` rejects an empty target.
25
+ """
26
+ out = text
27
+ if words_only:
28
+ out = _WHITESPACE.sub(" ", out) # tabs/newlines become separators first
29
+ out = _NON_WORD.sub("", out) # drop punctuation/digits, keep letters + space
30
+ out = _WHITESPACE.sub(" ", out).strip() # collapse any doubled spaces
31
+ out = out.lower()
32
+ elif lowercase:
33
+ out = out.lower()
34
+ return out or text
@@ -37,6 +37,11 @@ class TypingEngine:
37
37
  self._total_keystrokes = 0
38
38
  self._correct_keystrokes = 0
39
39
 
40
+ # Per-key (case-folded) attempt/miss counts for the typing coach. These
41
+ # count every keypress, so a mistake counts even if later corrected.
42
+ self._key_attempts: dict[str, int] = {}
43
+ self._key_misses: dict[str, int] = {}
44
+
40
45
  self._timeline: list[ReplayPoint] = [ReplayPoint(0, 0.0)]
41
46
  self._last_progress_pct = 0.0
42
47
 
@@ -57,6 +62,12 @@ class TypingEngine:
57
62
  if is_correct:
58
63
  self._correct_keystrokes += 1
59
64
 
65
+ # Track the *expected* key (case-folded) so the coach can rank weak keys.
66
+ k = expected.lower()
67
+ self._key_attempts[k] = self._key_attempts.get(k, 0) + 1
68
+ if not is_correct:
69
+ self._key_misses[k] = self._key_misses.get(k, 0) + 1
70
+
60
71
  self._record(t_ms)
61
72
 
62
73
  def backspace(self, t_ms: int) -> None:
@@ -95,6 +106,13 @@ class TypingEngine:
95
106
  def correct_keystrokes(self) -> int:
96
107
  return self._correct_keystrokes
97
108
 
109
+ @property
110
+ def key_stats(self) -> dict[str, tuple[int, int]]:
111
+ """Per-key (attempts, misses), case-folded, for the typing coach."""
112
+ return {
113
+ k: (attempts, self._key_misses.get(k, 0)) for k, attempts in self._key_attempts.items()
114
+ }
115
+
98
116
  @property
99
117
  def progress(self) -> float:
100
118
  """Completion fraction 0..1 based on cursor position."""
@@ -147,4 +165,5 @@ class TypingEngine:
147
165
  timeline=self.timeline,
148
166
  ghost_kind=ghost_kind,
149
167
  ghost_won=ghost_won,
168
+ key_stats=self.key_stats,
150
169
  )