typefaster-cli 0.2.0__py3-none-any.whl → 0.3.1__py3-none-any.whl

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.
typefaster/__init__.py CHANGED
@@ -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
  )
@@ -20,6 +20,9 @@ class Settings:
20
20
  allow_backspace: bool = True
21
21
  default_ghost: str = "personal-best"
22
22
  sound: bool = False
23
+ # MonkeyType-style text modifiers (applied to the text you type).
24
+ lowercase_only: bool = False
25
+ words_only: bool = False
23
26
 
24
27
  @classmethod
25
28
  def load(cls, path: Path | None = None) -> Settings:
@@ -78,6 +78,19 @@ _MIGRATIONS: list[tuple[int, str]] = [
78
78
  CREATE INDEX IF NOT EXISTS idx_race_kind_wpm ON race(race_kind, mode_seconds, wpm DESC);
79
79
  """,
80
80
  ),
81
+ (
82
+ 3,
83
+ # Running per-key (case-folded) attempt/miss aggregate for the typing
84
+ # coach. One row per key; upserted after every race. Local-only.
85
+ """
86
+ CREATE TABLE IF NOT EXISTS key_stats (
87
+ key_char TEXT PRIMARY KEY,
88
+ attempts INTEGER NOT NULL DEFAULT 0,
89
+ misses INTEGER NOT NULL DEFAULT 0,
90
+ updated_at TEXT NOT NULL
91
+ );
92
+ """,
93
+ ),
81
94
  ]
82
95
 
83
96
 
@@ -42,6 +42,19 @@ def quotes_by_difficulty(difficulty: Difficulty) -> list[Quote]:
42
42
  return [q for q in all_quotes() if q.difficulty == difficulty]
43
43
 
44
44
 
45
+ @lru_cache(maxsize=1)
46
+ def corpus_words() -> tuple[str, ...]:
47
+ """Distinct lowercase alphabetic words from the quote corpus, for building
48
+ typing drills. Cached since the corpus is static."""
49
+ seen: set[str] = set()
50
+ for quote in all_quotes():
51
+ for raw in quote.text.split():
52
+ word = "".join(c for c in raw if c.isalpha()).lower()
53
+ if 2 <= len(word) <= 12:
54
+ seen.add(word)
55
+ return tuple(sorted(seen))
56
+
57
+
45
58
  def daily_quote(day: date | None = None) -> Quote:
46
59
  """Deterministically pick the same quote for everyone on a given UTC day."""
47
60
  quotes = all_quotes()
@@ -44,6 +44,10 @@ class Repository(Protocol):
44
44
 
45
45
  def list_history(self, limit: int = 20, offset: int = 0) -> list[RaceRecord]: ...
46
46
  def count_races(self) -> int: ...
47
+
48
+ # per-key stats (typing coach)
49
+ def get_key_stats(self) -> dict[str, tuple[int, int]]: ...
50
+ def record_key_stats(self, key_stats: dict[str, tuple[int, int]], updated_at: str) -> None: ...
47
51
  def average_wpm_accuracy(self) -> tuple[float, float]: ...
48
52
  def best_by_mode(self, kind: RaceKind = ...) -> dict[int, float]: ...
49
53
  def best_quote_run(self) -> tuple[float, int] | None: ...
@@ -60,4 +64,5 @@ class Repository(Protocol):
60
64
 
61
65
  # daily
62
66
  def get_or_create_daily(self, day: str, quote: Quote) -> DailyChallenge: ...
67
+ def daily_days_played(self) -> list[str]: ...
63
68
  def daily_leaderboard(self, day: str, limit: int = 20) -> list[RaceRecord]: ...
@@ -107,6 +107,7 @@ class SQLiteRepository:
107
107
  with transaction(self._conn):
108
108
  self._conn.execute("DELETE FROM race")
109
109
  self._conn.execute("DELETE FROM daily_challenge")
110
+ self._conn.execute("DELETE FROM key_stats")
110
111
  self.recompute_profile()
111
112
 
112
113
  # ── quotes ─────────────────────────────────────────────────────────
@@ -168,10 +169,36 @@ class SQLiteRepository:
168
169
  (race_id, replay_store.serialize(result.timeline)),
169
170
  )
170
171
  self._bump_profile(result)
172
+ self._bump_key_stats(result.key_stats, started_at)
171
173
  if is_daily:
172
174
  self._bump_daily(started_at[:10], quote_id, result.wpm)
173
175
  return race_id
174
176
 
177
+ def _bump_key_stats(self, key_stats: dict[str, tuple[int, int]], updated_at: str) -> None:
178
+ """Add this race's per-key attempts/misses into the running aggregate."""
179
+ for key_char, (attempts, misses) in key_stats.items():
180
+ self._conn.execute(
181
+ """
182
+ INSERT INTO key_stats(key_char, attempts, misses, updated_at)
183
+ VALUES(?, ?, ?, ?)
184
+ ON CONFLICT(key_char) DO UPDATE SET
185
+ attempts = attempts + excluded.attempts,
186
+ misses = misses + excluded.misses,
187
+ updated_at = excluded.updated_at
188
+ """,
189
+ (key_char, attempts, misses, updated_at),
190
+ )
191
+
192
+ def record_key_stats(self, key_stats: dict[str, tuple[int, int]], updated_at: str) -> None:
193
+ """Standalone per-key update (used by drills, which feed the coach but
194
+ are not recorded as competitive races)."""
195
+ with transaction(self._conn):
196
+ self._bump_key_stats(key_stats, updated_at)
197
+
198
+ def get_key_stats(self) -> dict[str, tuple[int, int]]:
199
+ rows = self._conn.execute("SELECT key_char, attempts, misses FROM key_stats").fetchall()
200
+ return {r["key_char"]: (r["attempts"], r["misses"]) for r in rows}
201
+
175
202
  def _bump_profile(self, result: RaceResult) -> None:
176
203
  won = 1 if result.ghost_won else 0
177
204
  self._conn.execute(
@@ -300,6 +327,13 @@ class SQLiteRepository:
300
327
  return self._replay_for("AND q.ext_id = ? ORDER BY r.wpm DESC", (ext_id,))
301
328
 
302
329
  # ── daily ──────────────────────────────────────────────────────────
330
+ def daily_days_played(self) -> list[str]:
331
+ """ISO days (YYYY-MM-DD, ascending) on which the daily was attempted."""
332
+ rows = self._conn.execute(
333
+ "SELECT day FROM daily_challenge WHERE attempts > 0 ORDER BY day"
334
+ ).fetchall()
335
+ return [r["day"] for r in rows]
336
+
303
337
  def get_or_create_daily(self, day: str, quote: Quote) -> DailyChallenge:
304
338
  quote_id = self.upsert_quote(quote)
305
339
  row = self._conn.execute("SELECT * FROM daily_challenge WHERE day = ?", (day,)).fetchone()
typefaster/net/api.py CHANGED
@@ -2,11 +2,12 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import contextlib
5
6
  from typing import Any
6
7
 
7
8
  import httpx
8
9
 
9
- from .token_store import Session
10
+ from .token_store import DEFAULT_SERVER_URL, Session
10
11
 
11
12
 
12
13
  class ApiError(Exception):
@@ -19,7 +20,11 @@ class ApiError(Exception):
19
20
  class ApiClient:
20
21
  def __init__(self, session: Session, timeout: float = 10.0) -> None:
21
22
  self.session = session
23
+ self._timeout = timeout
22
24
  self._client = httpx.Client(base_url=session.server_url, timeout=timeout)
25
+ # Set when a connection failure forced a fallback to the default server,
26
+ # so callers can surface a one-time "saved server was unreachable" notice.
27
+ self.failed_over = False
23
28
 
24
29
  def close(self) -> None:
25
30
  self._client.close()
@@ -40,7 +45,17 @@ class ApiClient:
40
45
  try:
41
46
  resp = self._client.request(method, path, **kwargs)
42
47
  except httpx.HTTPError as exc:
43
- raise ApiError(0, f"connection failed: {exc}") from exc
48
+ # A connection-level failure (DNS/unreachable) against a non-default
49
+ # server may mean the saved server is dead. Retry once on the default
50
+ # and, if that works, persist it so the user self-heals.
51
+ if self.session.server_url != DEFAULT_SERVER_URL and not self.failed_over:
52
+ self._failover_to_default()
53
+ try:
54
+ resp = self._client.request(method, path, **kwargs)
55
+ except httpx.HTTPError as exc2:
56
+ raise ApiError(0, f"connection failed: {exc2}") from exc2
57
+ else:
58
+ raise ApiError(0, f"connection failed: {exc}") from exc
44
59
  if resp.status_code >= 400:
45
60
  detail = _extract_detail(resp)
46
61
  raise ApiError(resp.status_code, detail)
@@ -48,6 +63,16 @@ class ApiClient:
48
63
  return None
49
64
  return resp.json()
50
65
 
66
+ def _failover_to_default(self) -> None:
67
+ """Switch the session to the default server, persist it, and rebuild the
68
+ underlying client so the retry targets the new base URL."""
69
+ self.session.server_url = DEFAULT_SERVER_URL
70
+ with contextlib.suppress(OSError):
71
+ self.session.save() # persistence is best-effort; in-memory switch still works
72
+ self._client.close()
73
+ self._client = httpx.Client(base_url=DEFAULT_SERVER_URL, timeout=self._timeout)
74
+ self.failed_over = True
75
+
51
76
  # ── auth ───────────────────────────────────────────────────────────
52
77
  # These return parsed JSON; typed as Any since the shape is documented
53
78
  # by the server's response models rather than enforced client-side.
@@ -93,8 +93,7 @@ def _oauth_login(provider: str) -> None:
93
93
  deadline = time.time() + int(start.get("expires_in", 900))
94
94
 
95
95
  console.print(
96
- f"\n Open [bold underline]{uri}[/]\n"
97
- f" Enter code: [bold cyan]{code}[/]\n"
96
+ f"\n Open [bold underline]{uri}[/]\n" f" Enter code: [bold cyan]{code}[/]\n"
98
97
  )
99
98
  with contextlib.suppress(Exception):
100
99
  webbrowser.open(uri)
@@ -2,29 +2,66 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import base64
5
6
  import json
7
+ import time
6
8
  from dataclasses import asdict, dataclass
7
9
  from pathlib import Path
10
+ from urllib.parse import urlsplit
8
11
 
9
12
  from ..infra.paths import config_dir
10
13
 
14
+ # Single source of truth for the public TYPEFASTER server. A fresh install can
15
+ # register/play with zero config; override with `typefaster config set-server`
16
+ # (e.g. a local server or your own deployment).
17
+ DEFAULT_SERVER_URL = "https://140.245.248.113.sslip.io"
18
+
11
19
 
12
20
  def _auth_path() -> Path:
13
21
  return config_dir() / "auth.json"
14
22
 
15
23
 
24
+ def _is_ephemeral_host(url: str) -> bool:
25
+ """True for throwaway tunnel hosts that never persist (e.g. Cloudflare
26
+ quick-tunnels). A saved URL like these is always stale and safe to reset."""
27
+ host = (urlsplit(url).hostname or "").lower()
28
+ return host.endswith(".trycloudflare.com")
29
+
30
+
31
+ def _is_token_expired(token: str | None) -> bool:
32
+ """Check if JWT token is expired by decoding and comparing exp claim.
33
+ Frontend validation for UX; server always validates for security.
34
+ Expects JWT format: header.payload.signature (3 parts, 2 dots).
35
+ Non-JWT tokens are assumed valid."""
36
+ if not token:
37
+ return True
38
+ parts = token.split(".")
39
+ if len(parts) != 3:
40
+ return False
41
+ try:
42
+ payload = parts[1]
43
+ decoded = json.loads(base64.urlsafe_b64decode(payload + "=="))
44
+ if not isinstance(decoded, dict):
45
+ return True
46
+ exp = decoded.get("exp")
47
+ if exp is None or not isinstance(exp, (int, float)):
48
+ return False
49
+ return time.time() > exp
50
+ except Exception:
51
+ return True
52
+
53
+
16
54
  @dataclass(slots=True)
17
55
  class Session:
18
- # Default points at the public TYPEFASTER server so a fresh install can
19
- # register/play with zero config. Override with `typefaster config set-server`
20
- # (e.g. a local server or your own deployment).
21
- server_url: str = "https://140.245.248.113.sslip.io"
56
+ server_url: str = DEFAULT_SERVER_URL
22
57
  token: str | None = None
23
58
  username: str | None = None
24
59
 
25
60
  @property
26
61
  def logged_in(self) -> bool:
27
- return bool(self.token)
62
+ if not self.token:
63
+ return False
64
+ return not _is_token_expired(self.token)
28
65
 
29
66
  @classmethod
30
67
  def load(cls, path: Path | None = None) -> Session:
@@ -36,7 +73,14 @@ class Session:
36
73
  except (json.JSONDecodeError, OSError):
37
74
  return cls()
38
75
  known = set(cls.__slots__)
39
- return cls(**{k: v for k, v in data.items() if k in known})
76
+ session = cls(**{k: v for k, v in data.items() if k in known})
77
+ # Self-heal: a saved ephemeral-tunnel URL (from early testing) is dead and
78
+ # can never come back. Reset it to the default so online play works again
79
+ # without the user having to run `config set-server`.
80
+ if _is_ephemeral_host(session.server_url):
81
+ session.server_url = DEFAULT_SERVER_URL
82
+ session.save(path)
83
+ return session
40
84
 
41
85
  def save(self, path: Path | None = None) -> None:
42
86
  path = path or _auth_path()
@@ -0,0 +1,52 @@
1
+ """Typing coach — deterministic analysis over local per-key stats.
2
+
3
+ Fully offline and free: ranks the keys you miss most and exposes a per-key
4
+ accuracy map. No network, no LLM. Reads the running ``key_stats`` aggregate the
5
+ repository accumulates after every race.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+
12
+ from ..infra.repository import Repository
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class KeyAccuracy:
17
+ key: str
18
+ attempts: int
19
+ misses: int
20
+ accuracy: float # 0..1
21
+
22
+
23
+ class CoachService:
24
+ # Below this many total keypresses the data is too thin to coach on.
25
+ MIN_TOTAL_ATTEMPTS = 50
26
+
27
+ def __init__(self, repo: Repository) -> None:
28
+ self._repo = repo
29
+
30
+ def enough_data(self) -> bool:
31
+ stats = self._repo.get_key_stats()
32
+ return sum(attempts for attempts, _ in stats.values()) >= self.MIN_TOTAL_ATTEMPTS
33
+
34
+ def weakest_keys(self, *, min_attempts: int = 20, limit: int = 10) -> list[KeyAccuracy]:
35
+ """Keys with the worst accuracy, ignoring keys with too few attempts so a
36
+ single typo can't dominate. Worst first; ties broken by more attempts."""
37
+ out: list[KeyAccuracy] = []
38
+ for key, (attempts, misses) in self._repo.get_key_stats().items():
39
+ if attempts < min_attempts:
40
+ continue
41
+ accuracy = (attempts - misses) / attempts
42
+ out.append(KeyAccuracy(key=key, attempts=attempts, misses=misses, accuracy=accuracy))
43
+ out.sort(key=lambda k: (k.accuracy, -k.attempts))
44
+ return out[:limit]
45
+
46
+ def heatmap(self) -> dict[str, float]:
47
+ """Per-key accuracy (0..1) for every key seen, for rendering."""
48
+ return {
49
+ key: (attempts - misses) / attempts
50
+ for key, (attempts, misses) in self._repo.get_key_stats().items()
51
+ if attempts > 0
52
+ }
@@ -7,6 +7,7 @@ from pathlib import Path
7
7
 
8
8
  from ..infra.config import Settings
9
9
  from ..infra.sqlite_repository import SQLiteRepository
10
+ from .coach_service import CoachService
10
11
  from .daily_service import DailyService
11
12
  from .ghost_service import GhostService
12
13
  from .profile_service import ProfileService
@@ -23,6 +24,7 @@ class App:
23
24
  stats: StatsService
24
25
  daily: DailyService
25
26
  ghosts: GhostService
27
+ coach: CoachService
26
28
 
27
29
  def close(self) -> None:
28
30
  self.repo.close()
@@ -34,9 +36,15 @@ def build_app(db_path: Path | str | None = None) -> App:
34
36
  return App(
35
37
  settings=settings,
36
38
  repo=repo,
37
- race=RaceService(repo, allow_backspace=settings.allow_backspace),
39
+ race=RaceService(
40
+ repo,
41
+ allow_backspace=settings.allow_backspace,
42
+ lowercase_only=settings.lowercase_only,
43
+ words_only=settings.words_only,
44
+ ),
38
45
  profile=ProfileService(repo),
39
46
  stats=StatsService(repo),
40
47
  daily=DailyService(repo),
41
48
  ghosts=GhostService(repo),
49
+ coach=CoachService(repo),
42
50
  )
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from datetime import UTC, date, datetime
5
+ from datetime import UTC, date, datetime, timedelta
6
6
 
7
7
  from ..domain.models import DailyChallenge, RaceRecord
8
8
  from ..infra import quote_loader
@@ -32,3 +32,15 @@ class DailyService:
32
32
  def leaderboard(self, day: date | None = None, limit: int = 20) -> list[RaceRecord]:
33
33
  day = day or _utc_today()
34
34
  return self._repo.daily_leaderboard(day.isoformat(), limit=limit)
35
+
36
+ def streak(self, day: date | None = None) -> int:
37
+ """Consecutive daily-challenge days ending today (or yesterday, if today
38
+ hasn't been played yet — the streak is still alive, just not extended)."""
39
+ today = day or _utc_today()
40
+ played = set(self._repo.daily_days_played())
41
+ cursor = today if today.isoformat() in played else today - timedelta(days=1)
42
+ n = 0
43
+ while cursor.isoformat() in played:
44
+ n += 1
45
+ cursor -= timedelta(days=1)
46
+ return n
@@ -13,9 +13,11 @@ Two race kinds (see ``RaceKind``):
13
13
  from __future__ import annotations
14
14
 
15
15
  from dataclasses import dataclass
16
- from datetime import UTC, date, datetime
16
+ from datetime import UTC, datetime
17
17
 
18
+ from ..domain.drills import build_drill
18
19
  from ..domain.models import Ghost, GhostKind, Quote, RaceKind, RaceMode, RaceResult
20
+ from ..domain.text_modifiers import apply_modifiers
19
21
  from ..infra import quote_loader
20
22
  from ..infra.repository import Repository
21
23
  from .ghost_service import GhostService
@@ -23,6 +25,10 @@ from .ghost_service import GhostService
23
25
  # Placeholder quote row that TIME-mode races reference (they have no single text).
24
26
  _TIME_QUOTE = Quote(ext_id="__time_mode__", text="(time mode)", source="Time")
25
27
 
28
+ # Synthetic ext_id for coach drills: they feed per-key stats but are not recorded
29
+ # as competitive races (no PB, leaderboard, or history).
30
+ _DRILL_EXT_ID = "__drill__"
31
+
26
32
 
27
33
  @dataclass(frozen=True, slots=True)
28
34
  class RaceConfig:
@@ -55,10 +61,36 @@ class RaceSummary:
55
61
 
56
62
 
57
63
  class RaceService:
58
- def __init__(self, repo: Repository, *, allow_backspace: bool = True) -> None:
64
+ def __init__(
65
+ self,
66
+ repo: Repository,
67
+ *,
68
+ allow_backspace: bool = True,
69
+ lowercase_only: bool = False,
70
+ words_only: bool = False,
71
+ ) -> None:
59
72
  self._repo = repo
60
73
  self._ghosts = GhostService(repo)
61
74
  self._allow_backspace = allow_backspace
75
+ self._lowercase_only = lowercase_only
76
+ self._words_only = words_only
77
+
78
+ def set_modifiers(self, *, lowercase_only: bool, words_only: bool) -> None:
79
+ """Update the active text modifiers live (e.g. from the Settings screen)
80
+ so a toggle applies to the next race without restarting the app."""
81
+ self._lowercase_only = lowercase_only
82
+ self._words_only = words_only
83
+
84
+ def set_backspace(self, allow: bool) -> None:
85
+ """Apply the backspace setting live, same contract as set_modifiers."""
86
+ self._allow_backspace = allow
87
+
88
+ def _modify(self, text: str) -> str:
89
+ """Apply the active text modifiers to what the player will type. The
90
+ original quote is still persisted; only the target text changes. Ghosts
91
+ stay percentage-based, so they still render over modified text (a
92
+ modified run just isn't apples-to-apples with an unmodified ghost)."""
93
+ return apply_modifiers(text, lowercase=self._lowercase_only, words_only=self._words_only)
62
94
 
63
95
  def prepare(
64
96
  self,
@@ -77,7 +109,9 @@ class RaceService:
77
109
  ) -> RaceSetup:
78
110
  ghost: Ghost | None = None
79
111
  if daily:
80
- quote = quote_loader.daily_quote(date.today())
112
+ # UTC, matching DailyService/DailyScreen — otherwise a local-midnight
113
+ # window would race a different quote than the challenge records.
114
+ quote = quote_loader.daily_quote(datetime.now(UTC).date())
81
115
  # Daily ghost = your best run on *today's* quote (same text), if any.
82
116
  ghost = self._ghosts.best_for_quote(quote.ext_id)
83
117
  elif ghost_kind is not None:
@@ -93,7 +127,7 @@ class RaceService:
93
127
 
94
128
  return RaceSetup(
95
129
  quote=quote,
96
- target_text=quote.text,
130
+ target_text=self._modify(quote.text),
97
131
  kind=RaceKind.QUOTE,
98
132
  mode=mode,
99
133
  ghost=ghost,
@@ -109,7 +143,7 @@ class RaceService:
109
143
  text = _stream_text(target_chars)
110
144
  return RaceSetup(
111
145
  quote=_TIME_QUOTE,
112
- target_text=text,
146
+ target_text=self._modify(text),
113
147
  kind=RaceKind.TIME,
114
148
  mode=mode,
115
149
  ghost=None, # ghosts are a QUOTE-mode feature
@@ -118,7 +152,28 @@ class RaceService:
118
152
  started_at=datetime.now(UTC).isoformat(),
119
153
  )
120
154
 
155
+ def prepare_drill(self, weak_keys: list[str], *, length: int = 30) -> RaceSetup:
156
+ """Build a practice race whose text is weighted toward the player's weak
157
+ keys. Drills feed the coach's per-key stats but never set records."""
158
+ text = build_drill(weak_keys, list(quote_loader.corpus_words()), length=length)
159
+ return RaceSetup(
160
+ quote=Quote(ext_id=_DRILL_EXT_ID, text=text, source="Drill"),
161
+ target_text=text,
162
+ kind=RaceKind.QUOTE,
163
+ mode=RaceMode.NORMAL,
164
+ ghost=None,
165
+ allow_backspace=self._allow_backspace,
166
+ is_daily=False,
167
+ started_at=datetime.now(UTC).isoformat(),
168
+ )
169
+
121
170
  def finish(self, setup: RaceSetup, result: RaceResult) -> RaceSummary:
171
+ if setup.quote.ext_id == _DRILL_EXT_ID:
172
+ # Practice only: improve the coach heatmap, don't record a race.
173
+ self._repo.record_key_stats(result.key_stats, setup.started_at)
174
+ return RaceSummary(
175
+ result=result, race_id=0, new_personal_best=False, previous_best_wpm=0.0
176
+ )
122
177
  # Compare against the best of the *same* kind so the two don't mix.
123
178
  previous_best = self._best_wpm_for_kind(result.kind)
124
179
  race_id = self._repo.save_race(
typefaster/ui/app.py CHANGED
@@ -9,6 +9,7 @@ from ..services.container import App as Services
9
9
  from ..services.container import build_app
10
10
  from ..services.race_service import RaceConfig, RaceSetup
11
11
  from .screens.account import AccountScreen
12
+ from .screens.coach import CoachScreen
12
13
  from .screens.daily import DailyScreen
13
14
  from .screens.help import HelpScreen
14
15
  from .screens.history import HistoryScreen
@@ -27,6 +28,7 @@ _PANELS = {
27
28
  "practice": PracticeScreen,
28
29
  "daily": DailyScreen,
29
30
  "stats": StatsScreen,
31
+ "coach": CoachScreen,
30
32
  "history": HistoryScreen,
31
33
  "profile": ProfileScreen,
32
34
  "leaderboard": LeaderboardScreen,
@@ -75,6 +77,13 @@ class TypefasterApp(App[None]):
75
77
  self._last_config = config
76
78
  self.push_screen(RaceScreen(setup), lambda result: self._after_race(setup, result))
77
79
 
80
+ def start_drill(self) -> None:
81
+ """Launch a coach drill weighted toward the player's weakest keys."""
82
+ weak = [k.key for k in self.services.coach.weakest_keys()]
83
+ setup = self.services.race.prepare_drill(weak)
84
+ self._last_config = None # drills regenerate each time, not via "again"
85
+ self.push_screen(RaceScreen(setup), lambda result: self._after_race(setup, result))
86
+
78
87
  def _after_race(self, setup: RaceSetup, result: RaceResult | None) -> None:
79
88
  if result is None:
80
89
  return # race quit — return to whatever is underneath
@@ -0,0 +1,112 @@
1
+ """Typing Coach screen — most-missed keys, a per-key heatmap, and finger tips.
2
+
3
+ All local and deterministic: it reads the offline per-key stats the coach service
4
+ aggregates. No network, no LLM.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from rich.console import Group, RenderableType
10
+ from rich.table import Table
11
+ from rich.text import Text
12
+
13
+ from ...domain import keyboard
14
+ from ...services.coach_service import KeyAccuracy
15
+ from ._base import PanelScreen
16
+
17
+
18
+ def _key_label(key: str) -> str:
19
+ return "␣" if key == " " else key.upper()
20
+
21
+
22
+ def _heat_style(accuracy: float) -> str:
23
+ if accuracy >= 0.98:
24
+ return "bold green"
25
+ if accuracy >= 0.95:
26
+ return "yellow"
27
+ return "bold red"
28
+
29
+
30
+ class CoachScreen(PanelScreen):
31
+ title_text = "TYPING COACH"
32
+ BINDINGS = [
33
+ ("escape", "back", "Back"),
34
+ ("q", "back", "Back"),
35
+ ("d", "drill", "Drill weak keys"),
36
+ ]
37
+
38
+ def action_drill(self) -> None:
39
+ coach = self.app.services.coach # type: ignore[attr-defined]
40
+ if coach.enough_data():
41
+ self.app.start_drill() # type: ignore[attr-defined]
42
+
43
+ def body(self) -> RenderableType:
44
+ coach = self.app.services.coach # type: ignore[attr-defined]
45
+ if not coach.enough_data():
46
+ return Text(
47
+ "Play a few races to unlock your coach.\n\n"
48
+ "Once you've typed enough, this shows the keys you miss most, a\n"
49
+ "keyboard heatmap, and how to position your fingers to fix them.",
50
+ justify="center",
51
+ style="grey58",
52
+ )
53
+
54
+ weak = coach.weakest_keys()
55
+ return Group(
56
+ self._weak_table(weak),
57
+ Text(""),
58
+ Text("Keyboard heatmap", style="bold"),
59
+ self._heatmap(coach.heatmap()),
60
+ Text("green ≥98% yellow ≥95% red <95% dim = not enough data", style="grey42"),
61
+ Text(""),
62
+ Text("Finger positioning", style="bold"),
63
+ self._tips(weak),
64
+ Text(""),
65
+ Text("press d to drill your weak keys", style="bold cyan", justify="center"),
66
+ )
67
+
68
+ def _weak_table(self, weak: list[KeyAccuracy]) -> Table:
69
+ tbl = Table(title="Most-missed keys", title_style="bold")
70
+ tbl.add_column("Key", justify="center")
71
+ tbl.add_column("Accuracy", justify="right")
72
+ tbl.add_column("Misses", justify="right")
73
+ tbl.add_column("Attempts", justify="right")
74
+ if not weak:
75
+ tbl.add_row("—", "great!", "0", "—")
76
+ for k in weak:
77
+ tbl.add_row(
78
+ _key_label(k.key),
79
+ Text(f"{k.accuracy * 100:.1f}%", style=_heat_style(k.accuracy)),
80
+ str(k.misses),
81
+ str(k.attempts),
82
+ )
83
+ return tbl
84
+
85
+ def _heatmap(self, heat: dict[str, float]) -> Text:
86
+ out = Text()
87
+ for indent, row in enumerate(keyboard.ROWS):
88
+ out.append(" " * indent)
89
+ for ch in row:
90
+ if ch in heat:
91
+ out.append(f"{ch.upper()} ", style=_heat_style(heat[ch]))
92
+ else:
93
+ out.append(f"{ch.upper()} ", style="grey37")
94
+ out.append("\n")
95
+ return out
96
+
97
+ def _tips(self, weak: list[KeyAccuracy]) -> RenderableType:
98
+ tbl = Table.grid(padding=(0, 2))
99
+ tbl.add_column(justify="center", style="bold cyan")
100
+ tbl.add_column(justify="left")
101
+ shown = 0
102
+ for k in weak:
103
+ info = keyboard.key_info(k.key)
104
+ if info is None:
105
+ continue
106
+ tbl.add_row(_key_label(k.key), info.tip)
107
+ shown += 1
108
+ if shown >= 5:
109
+ break
110
+ if shown == 0:
111
+ return Text("Nothing to fix — your weak keys are all solid.", style="grey58")
112
+ return tbl
@@ -47,6 +47,10 @@ class DailyScreen(Screen[None]):
47
47
  f"\nYour best today: {challenge.best_wpm:.0f} wpm · attempts {challenge.attempts}\n",
48
48
  style="bold",
49
49
  )
50
+ streak = svc.daily.streak()
51
+ if streak:
52
+ unit = "day" if streak == 1 else "days"
53
+ quote.append(f"🔥 Streak: {streak} {unit}\n", style="bold yellow")
50
54
 
51
55
  table = Table(title="Local daily leaderboard", title_style="bold", expand=True)
52
56
  table.add_column("#", justify="right")
@@ -42,6 +42,7 @@ class MainMenu(Screen[None]):
42
42
  ("online", "🌐 Play Online (multiplayer lobbies)"),
43
43
  ("account", "🌐 Account / Login"),
44
44
  ("stats", "Stats"),
45
+ ("coach", "🎯 Typing Coach"),
45
46
  ("history", "History"),
46
47
  ("profile", "Profile"),
47
48
  ("leaderboard", "Leaderboard"),
@@ -162,7 +162,11 @@ class OnlineRaceScreen(Screen[None]):
162
162
  ),
163
163
  name="progress",
164
164
  )
165
- if self._start_ms is not None and elapsed >= self.mode_seconds * 1000 and not self._finished:
165
+ if (
166
+ self._start_ms is not None
167
+ and elapsed >= self.mode_seconds * 1000
168
+ and not self._finished
169
+ ):
166
170
  self._submit_finish()
167
171
 
168
172
  def on_key(self, event: events.Key) -> None:
@@ -219,7 +223,9 @@ class OnlineRaceScreen(Screen[None]):
219
223
 
220
224
  # ── rendering ──────────────────────────────────────────────────────
221
225
  def _render_status(self) -> None:
222
- self.query_one("#net-status", Static).update(Text.from_markup(self._status, justify="center"))
226
+ self.query_one("#net-status", Static).update(
227
+ Text.from_markup(self._status, justify="center")
228
+ )
223
229
 
224
230
  def _render_lobby(self, data: dict[str, Any]) -> None:
225
231
  name = data.get("name", "Lobby")
@@ -278,7 +284,7 @@ class OnlineRaceScreen(Screen[None]):
278
284
  str(i),
279
285
  f"{row.get('username', '?')}{flag}",
280
286
  f"{row.get('wpm', 0):.0f}",
281
- f"{row.get('accuracy', 0) * 100:.0f}%",
287
+ f"{row.get('accuracy', 0) * 100:.1f}%",
282
288
  )
283
289
  self.query_one("#net-status", Static).update(
284
290
  Text("RACE COMPLETE", justify="center", style="bold green")
@@ -14,6 +14,16 @@ from ...services.race_service import RaceSetup, RaceSummary
14
14
  from ..widgets import bigtext
15
15
 
16
16
 
17
+ def _top_missed(key_stats: dict[str, tuple[int, int]], limit: int = 3) -> str:
18
+ """Format this race's most-fumbled keys, worst first — e.g. 'E x4 . R x2' (rendered with multiplication signs)."""
19
+ worst = sorted(
20
+ ((k, m) for k, (_, m) in key_stats.items() if m > 0),
21
+ key=lambda km: km[1],
22
+ reverse=True,
23
+ )[:limit]
24
+ return " · ".join(f"{'␣' if k == ' ' else k.upper()} ×{m}" for k, m in worst)
25
+
26
+
17
27
  class ResultsScreen(Screen[str]):
18
28
  """Shows the outcome. Dismisses with an action string: 'again' or 'menu'.
19
29
 
@@ -55,6 +65,12 @@ class ResultsScreen(Screen[str]):
55
65
  table.add_row("Chars", f"{r.correct_chars} correct · {r.incorrect_chars} wrong")
56
66
  table.add_row("Completion", f"{r.progress * 100:.0f}%")
57
67
  table.add_row("Time", f"{r.duration_ms / 1000:.1f}s")
68
+ missed = _top_missed(r.key_stats)
69
+ if missed:
70
+ table.add_row(
71
+ "Missed keys",
72
+ f"[bold red]{missed}[/] [grey58]· 🎯 Typing Coach can drill these[/]",
73
+ )
58
74
  if self.summary and self.summary.new_personal_best:
59
75
  table.add_row(
60
76
  "Personal best",
@@ -47,6 +47,8 @@ class SettingsScreen(Screen[None]):
47
47
  ),
48
48
  ("default_ghost", f"Default ghost ‹ {s.default_ghost} ›"),
49
49
  ("sound", f"Sound (bell) ‹ {'on' if s.sound else 'off'} ›"),
50
+ ("lowercase_only", f"Lowercase only ‹ {'on' if s.lowercase_only else 'off'} ›"),
51
+ ("words_only", f"Words only ‹ {'on' if s.words_only else 'off'} ›"),
50
52
  ]
51
53
  for key, label in rows:
52
54
  ol.add_option(Option(label, id=key))
@@ -64,7 +66,18 @@ class SettingsScreen(Screen[None]):
64
66
  s.default_ghost = self._cycle(self._GHOSTS, s.default_ghost)
65
67
  elif key == "sound":
66
68
  s.sound = not s.sound
69
+ elif key == "lowercase_only":
70
+ s.lowercase_only = not s.lowercase_only
71
+ elif key == "words_only":
72
+ s.words_only = not s.words_only
67
73
  s.save()
74
+ # Apply gameplay-affecting changes to the live race service immediately.
75
+ if key in ("lowercase_only", "words_only"):
76
+ self.app.services.race.set_modifiers( # type: ignore[attr-defined]
77
+ lowercase_only=s.lowercase_only, words_only=s.words_only
78
+ )
79
+ elif key == "allow_backspace":
80
+ self.app.services.race.set_backspace(s.allow_backspace) # type: ignore[attr-defined]
68
81
  idx = event.option_index
69
82
  self._refresh()
70
83
  self.query_one(OptionList).highlighted = idx
@@ -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
 
@@ -1,4 +1,4 @@
1
- typefaster/__init__.py,sha256=cfdYbT59dWFZ4oxJu7NWC94LrR8lKT_ZagPhY6DjWAM,78
1
+ typefaster/__init__.py,sha256=9aHe77_XFkp9l6PfpQqspmt2xKjgHMbhFUIrz3VrTw8,78
2
2
  typefaster/__main__.py,sha256=2dxSnCI2wKDhIgdMFPwlZkdbJ-GQwy6uZApfeoyFAkA,135
3
3
  typefaster/cli.py,sha256=NnKzBjvFtr36tBgA5x4XgCuqWo-QGB_40XLLy34Gu9I,6549
4
4
  typefaster/assets/__init__.py,sha256=uDRfxDegVWAPzgSUlv-yy_ILP5UTMVIa9lNTh4moyU4,43
@@ -6,58 +6,63 @@ typefaster/assets/quotes.json,sha256=ooE992FtEyzB6-cJ6BEPx_Hfzs5iSekEb8wPvqvOHP4
6
6
  typefaster/domain/__init__.py,sha256=5y9Qy9eMH_c75Gq_um7E-6qVPB1a2r0flS-yIVaCqEk,73
7
7
  typefaster/domain/anti_cheat.py,sha256=zwB5-ZPANMX63zWxwDn8d9EPtW3kf6PGQ1g-35fAQeM,1429
8
8
  typefaster/domain/calculators.py,sha256=Igj1ivDyM67Uf-7hu6hm9H2BCOAvbgaqQ_a0dU6H6t8,1438
9
+ typefaster/domain/drills.py,sha256=2RFUIv2sg19upvqhuMhS1seLqRtwtk2gy8aYPMEtvLg,1354
9
10
  typefaster/domain/errors.py,sha256=AP-wzNosxDpxAwtQ8cXfhl32xVGodm3ablp2wawJeak,493
10
11
  typefaster/domain/ghost.py,sha256=ilLKCJ24nJs3NQvpetuQwJxJaeq0t6QOrGYYEydbnvQ,2072
11
- typefaster/domain/models.py,sha256=3WfhzH2lrtWdqxuieBOX81WnprAaNuYQepS-VLMGmoE,4418
12
- typefaster/domain/typing_engine.py,sha256=D27fCvog5k7i9sygVWvchNojA4cS7Rl5noU8O5AoTAo,5559
12
+ typefaster/domain/keyboard.py,sha256=bdp_dO3DWGXFa35ZMgLEPM7jIjv-NCr1pstlIjQsFOk,2162
13
+ typefaster/domain/models.py,sha256=n3EKliBns9Ta4yRefgUmv7oWcXPX6Rzq1D4MnQT8Kqc,4567
14
+ typefaster/domain/text_modifiers.py,sha256=e-xOsl4ZfK7iKaBUVhptOQ45j-n5ckL3_9BY6X9XO2U,1288
15
+ typefaster/domain/typing_engine.py,sha256=FSwukYbdU73bBFudAuNNMLCKVDEqkv1ShaMENEC4nQY,6392
13
16
  typefaster/infra/__init__.py,sha256=i_oY7TZxRHpRRp61_m-qQWsg8qk0CTVHxHT9UGV1do4,76
14
17
  typefaster/infra/clock.py,sha256=6DZWBtaTueN0cisnFEXs9bwLuNWb1NOY8e8LiC7w8Nc,775
15
- typefaster/infra/config.py,sha256=Tz1waDd6QuvVYB0NE0rkbtS5a1mowsoLpx9KlFt4kuM,1229
18
+ typefaster/infra/config.py,sha256=nsu3P41EyBzr5dikW132qbe4Qm2U-Z9cFmc-27Mg3k4,1361
16
19
  typefaster/infra/db.py,sha256=5S2v-o__L9EYaFAyg6WmS7eRL2qszwZIv_S23IHB4AM,942
17
- typefaster/infra/migrations.py,sha256=kb6VNWw42JNck3yyF_9pQUcIVzn7EaFbJsORqZBNg2g,3995
20
+ typefaster/infra/migrations.py,sha256=JRr2CcDjK36ygIDmeNHfnhd_5sUNaQG3nASYEQSrV2s,4433
18
21
  typefaster/infra/paths.py,sha256=aH8IeRj34_-_mG4C5450bE_mvThgy3B6Xb4ZSnWxcSY,877
19
- typefaster/infra/quote_loader.py,sha256=OPfjJ-6Mn1oeaAgZMcsIG6Yt4FePeRngccdO6mX0Jk4,1622
22
+ typefaster/infra/quote_loader.py,sha256=j8QsBVNpwbm1UvQ7sZ3iubhpU0MV6vmKUs0C2BYLhG0,2083
20
23
  typefaster/infra/replay_store.py,sha256=dwkjHPFy9-yx3x9BC_THZoY8da3H3EChpQerzlnQGA8,474
21
- typefaster/infra/repository.py,sha256=WS76T0tWsySV65oB6I4C8REFLoCF3aFlciB0pDoSQ2c,2026
22
- typefaster/infra/sqlite_repository.py,sha256=XRtT8Q3tbi9cbuQIYCqfHly54rKF_koLT1Dw57R2OEM,14694
24
+ typefaster/infra/repository.py,sha256=VsW8yDb4ArZCJfrn1RvixZMYPxmzH19rjX9bkgYxwsc,2275
25
+ typefaster/infra/sqlite_repository.py,sha256=G-o-b2gu-ZnNEV7Nq2LHE1c26lljKaO2g8-RqJF8T1k,16388
23
26
  typefaster/net/__init__.py,sha256=tSx4UMBc0kUlp60corti6SvOnc9fJ_n5guJlysdRUZ0,74
24
- typefaster/net/api.py,sha256=dxYx5VUkADjK_65rKNoR2kz8V4EDPnnU3dO_fjaZ2j8,4440
25
- typefaster/net/commands.py,sha256=OBsP3DbfAHz-1LmQX0B2FZB0cVIuTQ_od2tCNfQBpy4,9791
26
- typefaster/net/token_store.py,sha256=yZsjNVot9tAEVw_G7_BoqFUKcbLHZ9kXh1WG_NQTc7Q,1504
27
+ typefaster/net/api.py,sha256=9B37OcnUqjLgqKMPWu_c8LS2LGApf8uyfHCIrpskPGM,5842
28
+ typefaster/net/commands.py,sha256=Myy-q_YCZ6Qyd2O-RCz09Z0ShytthYldfoHFbmPDP0A,9779
29
+ typefaster/net/token_store.py,sha256=uE07VnC5arw7G70ILq9SdTILSJAsFsjxAhNeAJ42G4c,3137
27
30
  typefaster/services/__init__.py,sha256=4VngeItS-jc6-I71DsWkaWnvHODCni7ot4kiDICfMaw,79
28
- typefaster/services/container.py,sha256=79AR_skykXGsneXZx-LZ6ihbV8x8yzWBBnTQIzeUzK4,1143
29
- typefaster/services/daily_service.py,sha256=xROI20AYUtoBn332Zn2QBg_4sEPU_Eptu4_O1Xap0Lk,1189
31
+ typefaster/services/coach_service.py,sha256=MgzPULFwX1Rsj-ILxvytoNv-3OdBuDbSIvgN6Sqy5y8,1854
32
+ typefaster/services/container.py,sha256=F62UVI0EG1aPA5obWA3kymG3axTR8jj6wHjex2T2cyg,1372
33
+ typefaster/services/daily_service.py,sha256=q7yh_0Hu4ltXFK1CrYXVrGeh5-N5h-XxACnBuK3EXoE,1729
30
34
  typefaster/services/ghost_service.py,sha256=U6XPMDtgi8ORvf6w-mGpyZf8aiHaDD35zZ6F3cjzSsw,1676
31
35
  typefaster/services/profile_service.py,sha256=EquonTLismSHcs3HTQlUC_Rtt3uI3CT-Q5igFwRk220,473
32
- typefaster/services/race_service.py,sha256=BmTMIpjkHX9Wom7PeaWRXHUHuZ4siaVtq-iey2tfwIQ,5406
36
+ typefaster/services/race_service.py,sha256=ujLvawa1gUOzNitS585Ku4vmquF74x4hboMEvB-IMvo,8031
33
37
  typefaster/services/stats_service.py,sha256=yLP2hwubhqUr5rPxnxWX9n5PbINpgn9dQc1RANiYBeU,1912
34
38
  typefaster/ui/__init__.py,sha256=IbB-ajsaTRD4HS4zy-fNbpkv8_broTEEhJgl_1d93q4,72
35
- typefaster/ui/app.py,sha256=wr95rqeTBYRjLMo4Mr4usGaxj9CI-QWYSDo50JbdGqg,3591
39
+ typefaster/ui/app.py,sha256=5kU_iCabm4jfws__FGKfBvpF3cvLBEstjRW3wW5lGos,4065
36
40
  typefaster/ui/online_app.py,sha256=PHycCB32LGho8hHx9sfJ-o-gyiDT82CUTRRVF5ov33Y,678
37
41
  typefaster/ui/theme.py,sha256=XzJFft9L3QP4qqUbbHmZ0v3ZeWodvcUY89PhsEHqlfs,1487
38
42
  typefaster/ui/screens/__init__.py,sha256=-L4TEWNuG14Jofv6FNRLMKYGYoNquB1HvGmYV3YXYus,23
39
43
  typefaster/ui/screens/_base.py,sha256=yNdf-HUSYgYM5-nNsM622DAiN1DvNDptY_gFH3hk0l0,1184
40
44
  typefaster/ui/screens/account.py,sha256=C9xf52ju2XGxA9mkGWqed8goTooZ7ZJmnfPYmsNGNLA,7749
41
- typefaster/ui/screens/daily.py,sha256=vjHMZ8TNXxNKDNMbyWDPvxcbQK_ff-M8YK0bRtyXuio,2580
45
+ typefaster/ui/screens/coach.py,sha256=WP65Gxh21-vk5fw3eu0moDYANkxxIPZ7m7UNKlImM5A,3835
46
+ typefaster/ui/screens/daily.py,sha256=QGOBYENF-Do4fWerHIuBNS00D06O3MLCH2qgYIPX6kM,2768
42
47
  typefaster/ui/screens/help.py,sha256=OCzQlsGWzY7CwZMAcElGxMtBmao_KRjIFse7RuABtNE,788
43
48
  typefaster/ui/screens/history.py,sha256=PQ0f5F7aHv1E6pmJvsNNYlbm59HthwAHGtYR0bX_i8s,1073
44
49
  typefaster/ui/screens/leaderboard.py,sha256=kZhuixwIZLVsHaDjwsB258sQyMBZ9dYs2Y0QdRaFpKg,1968
45
50
  typefaster/ui/screens/lobby_browser.py,sha256=5upNsBBXd_jurXOv1IuQ-C94AvdVZzOzfR68bz209q8,6102
46
- typefaster/ui/screens/main_menu.py,sha256=HW2Zl8nk_YMZ5r07J51SJ86iShaUCCMu3kl3472j1Eg,4090
47
- typefaster/ui/screens/online_race.py,sha256=oVHi2FICWMPcEHYXQ0eS8I9Fa6OZInseKSPnGoPZbk8,13302
51
+ typefaster/ui/screens/main_menu.py,sha256=CxHY-MYdlXMgvgtCgH_UPyoAocV32ftycV53S6ga7IM,4134
52
+ typefaster/ui/screens/online_race.py,sha256=RC5jqE5uAMa45WAoVUr8G2DK4dNK4H_w_eeNLSjEqnU,13372
48
53
  typefaster/ui/screens/practice.py,sha256=6gvzVFYprGrbDClPArWM4COnzRi0zASrtnKjP6AREx8,2115
49
54
  typefaster/ui/screens/profile.py,sha256=1QiUkhMBH38q3Vkw5kx4O1LUZc8vrNtKF8q4p8GABAk,995
50
55
  typefaster/ui/screens/race.py,sha256=JlCxayx2W-fjnIza4ASK4Yg-UGkWSXDQv2YkndnxOMY,9961
51
- typefaster/ui/screens/results.py,sha256=3nXSMEO9xIBEtKRoSzpGNBnRMbX3-yToXCQ1GuLJrZY,3280
52
- typefaster/ui/screens/settings.py,sha256=nz0VRoRUmJxDj6CGFHiVtL0xDaz4lCvO1qa-l5NqXBE,2812
56
+ typefaster/ui/screens/results.py,sha256=Q4YcU9O2Xe02MZYEDoxwYr-B0GyzGYDz92VnRNlhqFQ,3964
57
+ typefaster/ui/screens/settings.py,sha256=8PIecUQBXzYvSZs5LqCYaUfmOBfw-W97UcUqjrYIh7o,3605
53
58
  typefaster/ui/screens/stats.py,sha256=eePL3sU729EptA2DRJOlpezVUgOjE9zPkwT_dOSF0os,2342
54
59
  typefaster/ui/widgets/__init__.py,sha256=D2YleDqZvFyzPhI501R-V8RmwDSSvOFRasAyVHpxUBo,56
55
60
  typefaster/ui/widgets/bigtext.py,sha256=6naRaTuxke6tP0Q7j67KyvMsibTlRdhdiuEiszNdKYo,2162
56
61
  typefaster/ui/widgets/live_stats.py,sha256=Q17tvAOHanhf4qS78G59sLEY-mieAUyDjyvAZN8tAaw,683
57
62
  typefaster/ui/widgets/progress_bars.py,sha256=fZTh9a0KPVK1UnRq75Jf0YzSF4r85e3xn12oSUmIzmA,1392
58
63
  typefaster/ui/widgets/typing_field.py,sha256=gYKOjytwsMEU_k5Nu9mnYLBP4smNEw4bZ6lR27_m-k0,934
59
- typefaster_cli-0.2.0.dist-info/METADATA,sha256=ATYsxkT15-Yin4G-8cHJO8Xgbgnb0ffifEaUFblhNZ4,5014
60
- typefaster_cli-0.2.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
61
- typefaster_cli-0.2.0.dist-info/entry_points.txt,sha256=-ix_zmJiKj-cikp7-QfkxqB6MPIYCD0hiGd36Q-XlS8,50
62
- typefaster_cli-0.2.0.dist-info/licenses/LICENSE,sha256=ZrmIfNfrisYorCVzUnlZHoQZxJQB06oC_0lrNMkkXls,1079
63
- typefaster_cli-0.2.0.dist-info/RECORD,,
64
+ typefaster_cli-0.3.1.dist-info/METADATA,sha256=fNmnsbp0LykOjeWWo3k53ls72K9OTHJYjghGZ9JXeyE,5728
65
+ typefaster_cli-0.3.1.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
66
+ typefaster_cli-0.3.1.dist-info/entry_points.txt,sha256=-ix_zmJiKj-cikp7-QfkxqB6MPIYCD0hiGd36Q-XlS8,50
67
+ typefaster_cli-0.3.1.dist-info/licenses/LICENSE,sha256=ZrmIfNfrisYorCVzUnlZHoQZxJQB06oC_0lrNMkkXls,1079
68
+ typefaster_cli-0.3.1.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.30.1
2
+ Generator: hatchling 1.31.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any