typefaster-cli 0.1.0__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.
Files changed (61) hide show
  1. typefaster/__init__.py +3 -0
  2. typefaster/__main__.py +8 -0
  3. typefaster/assets/__init__.py +1 -0
  4. typefaster/assets/quotes.json +2642 -0
  5. typefaster/cli.py +196 -0
  6. typefaster/domain/__init__.py +1 -0
  7. typefaster/domain/anti_cheat.py +46 -0
  8. typefaster/domain/calculators.py +47 -0
  9. typefaster/domain/errors.py +19 -0
  10. typefaster/domain/ghost.py +61 -0
  11. typefaster/domain/models.py +186 -0
  12. typefaster/domain/typing_engine.py +150 -0
  13. typefaster/infra/__init__.py +1 -0
  14. typefaster/infra/clock.py +36 -0
  15. typefaster/infra/config.py +41 -0
  16. typefaster/infra/db.py +31 -0
  17. typefaster/infra/migrations.py +106 -0
  18. typefaster/infra/paths.py +35 -0
  19. typefaster/infra/quote_loader.py +53 -0
  20. typefaster/infra/replay_store.py +16 -0
  21. typefaster/infra/repository.py +63 -0
  22. typefaster/infra/sqlite_repository.py +360 -0
  23. typefaster/net/__init__.py +1 -0
  24. typefaster/net/api.py +103 -0
  25. typefaster/net/commands.py +188 -0
  26. typefaster/net/token_store.py +46 -0
  27. typefaster/services/__init__.py +1 -0
  28. typefaster/services/container.py +42 -0
  29. typefaster/services/daily_service.py +23 -0
  30. typefaster/services/ghost_service.py +50 -0
  31. typefaster/services/profile_service.py +18 -0
  32. typefaster/services/race_service.py +152 -0
  33. typefaster/services/stats_service.py +52 -0
  34. typefaster/ui/__init__.py +1 -0
  35. typefaster/ui/app.py +93 -0
  36. typefaster/ui/online_app.py +24 -0
  37. typefaster/ui/screens/__init__.py +1 -0
  38. typefaster/ui/screens/_base.py +30 -0
  39. typefaster/ui/screens/daily.py +64 -0
  40. typefaster/ui/screens/help.py +26 -0
  41. typefaster/ui/screens/history.py +34 -0
  42. typefaster/ui/screens/leaderboard.py +52 -0
  43. typefaster/ui/screens/main_menu.py +96 -0
  44. typefaster/ui/screens/online_race.py +241 -0
  45. typefaster/ui/screens/practice.py +57 -0
  46. typefaster/ui/screens/profile.py +28 -0
  47. typefaster/ui/screens/race.py +238 -0
  48. typefaster/ui/screens/results.py +89 -0
  49. typefaster/ui/screens/settings.py +81 -0
  50. typefaster/ui/screens/stats.py +65 -0
  51. typefaster/ui/theme.py +82 -0
  52. typefaster/ui/widgets/__init__.py +1 -0
  53. typefaster/ui/widgets/bigtext.py +39 -0
  54. typefaster/ui/widgets/live_stats.py +19 -0
  55. typefaster/ui/widgets/progress_bars.py +44 -0
  56. typefaster/ui/widgets/typing_field.py +28 -0
  57. typefaster_cli-0.1.0.dist-info/METADATA +168 -0
  58. typefaster_cli-0.1.0.dist-info/RECORD +61 -0
  59. typefaster_cli-0.1.0.dist-info/WHEEL +4 -0
  60. typefaster_cli-0.1.0.dist-info/entry_points.txt +2 -0
  61. typefaster_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,150 @@
1
+ """The typing engine — pure state machine over a stream of keystrokes.
2
+
3
+ Time is injected (millisecond timestamps passed in), so the engine is fully
4
+ deterministic and unit-testable without a clock.
5
+
6
+ Backspace model (MonkeyType-style, the locked Phase 1 decision)
7
+ ---------------------------------------------------------------
8
+ - Typing any character advances the cursor; a wrong char is recorded in place
9
+ (shown red by the UI) and the cursor still advances.
10
+ - Backspace moves the cursor back one and clears that slot's correctness, so the
11
+ player can retype it. Backspace is *not* counted as a keystroke.
12
+ - Accuracy is measured over **every** character keystroke (including ones later
13
+ corrected), so corrections don't inflate accuracy. Completion/progress is
14
+ measured from the current buffer state.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from .calculators import accuracy, raw_wpm, wpm
20
+ from .models import GhostKind, RaceKind, RaceResult, ReplayPoint
21
+
22
+
23
+ class TypingEngine:
24
+ """Processes keystrokes against a target string."""
25
+
26
+ def __init__(self, target: str, *, allow_backspace: bool = True) -> None:
27
+ if not target:
28
+ raise ValueError("target text must not be empty")
29
+ self.target = target
30
+ self.allow_backspace = allow_backspace
31
+
32
+ # Per-index correctness of the current buffer: None=untyped, True/False.
33
+ self._states: list[bool | None] = [None] * len(target)
34
+ self._cursor = 0 # index of the next character to type
35
+
36
+ # Lifetime keystroke counters (corrections do not decrement these).
37
+ self._total_keystrokes = 0
38
+ self._correct_keystrokes = 0
39
+
40
+ self._timeline: list[ReplayPoint] = [ReplayPoint(0, 0.0)]
41
+ self._last_progress_pct = 0.0
42
+
43
+ # ── input ──────────────────────────────────────────────────────────
44
+ def type_char(self, char: str, t_ms: int) -> None:
45
+ """Apply a single character keypress at time ``t_ms``."""
46
+ if self.finished:
47
+ return
48
+ if len(char) != 1:
49
+ raise ValueError("type_char expects exactly one character")
50
+
51
+ expected = self.target[self._cursor]
52
+ is_correct = char == expected
53
+ self._states[self._cursor] = is_correct
54
+ self._cursor += 1
55
+
56
+ self._total_keystrokes += 1
57
+ if is_correct:
58
+ self._correct_keystrokes += 1
59
+
60
+ self._record(t_ms)
61
+
62
+ def backspace(self, t_ms: int) -> None:
63
+ """Move the cursor back one position and clear that slot."""
64
+ if not self.allow_backspace:
65
+ return
66
+ if self._cursor == 0:
67
+ return
68
+ self._cursor -= 1
69
+ self._states[self._cursor] = None
70
+ self._record(t_ms)
71
+
72
+ # ── derived state ──────────────────────────────────────────────────
73
+ @property
74
+ def cursor(self) -> int:
75
+ return self._cursor
76
+
77
+ @property
78
+ def states(self) -> list[bool | None]:
79
+ """Read-only view of per-index correctness (for the UI to render)."""
80
+ return list(self._states)
81
+
82
+ @property
83
+ def correct_chars(self) -> int:
84
+ return sum(1 for s in self._states if s is True)
85
+
86
+ @property
87
+ def incorrect_chars(self) -> int:
88
+ return sum(1 for s in self._states if s is False)
89
+
90
+ @property
91
+ def total_keystrokes(self) -> int:
92
+ return self._total_keystrokes
93
+
94
+ @property
95
+ def correct_keystrokes(self) -> int:
96
+ return self._correct_keystrokes
97
+
98
+ @property
99
+ def progress(self) -> float:
100
+ """Completion fraction 0..1 based on cursor position."""
101
+ return self._cursor / len(self.target)
102
+
103
+ @property
104
+ def finished(self) -> bool:
105
+ return self._cursor >= len(self.target)
106
+
107
+ def live_wpm(self, elapsed_ms: int) -> float:
108
+ return wpm(self.correct_chars, elapsed_ms)
109
+
110
+ def live_accuracy(self) -> float:
111
+ return accuracy(self._correct_keystrokes, self._total_keystrokes)
112
+
113
+ # ── timeline / result ──────────────────────────────────────────────
114
+ def _record(self, t_ms: int) -> None:
115
+ pct = round(self.progress * 100.0, 2)
116
+ if pct != self._last_progress_pct:
117
+ self._timeline.append(ReplayPoint(t_ms, pct))
118
+ self._last_progress_pct = pct
119
+
120
+ @property
121
+ def timeline(self) -> list[ReplayPoint]:
122
+ return list(self._timeline)
123
+
124
+ def result(
125
+ self,
126
+ elapsed_ms: int,
127
+ *,
128
+ kind: RaceKind = RaceKind.QUOTE,
129
+ mode_seconds: int = 0,
130
+ ghost_kind: GhostKind | None = None,
131
+ ghost_won: bool | None = None,
132
+ ) -> RaceResult:
133
+ """Snapshot the final result for an elapsed duration.
134
+
135
+ ``mode_seconds`` is the time limit for TIME mode, or 0 for QUOTE mode.
136
+ """
137
+ return RaceResult(
138
+ wpm=round(self.live_wpm(elapsed_ms), 2),
139
+ raw_wpm=round(raw_wpm(self._total_keystrokes, elapsed_ms), 2),
140
+ accuracy=round(self.live_accuracy(), 4),
141
+ correct_chars=self.correct_chars,
142
+ incorrect_chars=self.incorrect_chars,
143
+ progress=round(self.progress, 4),
144
+ duration_ms=elapsed_ms,
145
+ mode_seconds=mode_seconds,
146
+ kind=kind,
147
+ timeline=self.timeline,
148
+ ghost_kind=ghost_kind,
149
+ ghost_won=ghost_won,
150
+ )
@@ -0,0 +1 @@
1
+ """Infrastructure adapters: SQLite, config, clock, quote/replay loaders."""
@@ -0,0 +1,36 @@
1
+ """Injectable clock so timing stays out of the pure domain and tests stay
2
+ deterministic."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import time
7
+ from typing import Protocol
8
+
9
+
10
+ class Clock(Protocol):
11
+ def now_ms(self) -> int:
12
+ """Monotonic milliseconds. Only differences are meaningful."""
13
+ ...
14
+
15
+
16
+ class SystemClock:
17
+ """Production clock backed by ``time.monotonic``."""
18
+
19
+ def now_ms(self) -> int:
20
+ return int(time.monotonic() * 1000)
21
+
22
+
23
+ class FakeClock:
24
+ """Manually advanced clock for tests."""
25
+
26
+ def __init__(self, start_ms: int = 0) -> None:
27
+ self._t = start_ms
28
+
29
+ def now_ms(self) -> int:
30
+ return self._t
31
+
32
+ def advance(self, ms: int) -> None:
33
+ self._t += ms
34
+
35
+ def set(self, ms: int) -> None:
36
+ self._t = ms
@@ -0,0 +1,41 @@
1
+ """User settings persistence.
2
+
3
+ Settings (not gameplay data) are stored as a small JSON file in the OS config
4
+ dir. Gameplay data lives in SQLite per the project's storage policy.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from dataclasses import asdict, dataclass
11
+ from pathlib import Path
12
+
13
+ from .paths import config_path
14
+
15
+
16
+ @dataclass(slots=True)
17
+ class Settings:
18
+ theme: str = "dark"
19
+ default_time: int = 60
20
+ allow_backspace: bool = True
21
+ default_ghost: str = "personal-best"
22
+ sound: bool = False
23
+
24
+ @classmethod
25
+ def load(cls, path: Path | None = None) -> Settings:
26
+ path = path or config_path()
27
+ if not path.exists():
28
+ settings = cls()
29
+ settings.save(path)
30
+ return settings
31
+ try:
32
+ data = json.loads(path.read_text(encoding="utf-8"))
33
+ except (json.JSONDecodeError, OSError):
34
+ return cls()
35
+ known = set(cls.__slots__)
36
+ return cls(**{k: v for k, v in data.items() if k in known})
37
+
38
+ def save(self, path: Path | None = None) -> None:
39
+ path = path or config_path()
40
+ path.parent.mkdir(parents=True, exist_ok=True)
41
+ path.write_text(json.dumps(asdict(self), indent=2), encoding="utf-8")
typefaster/infra/db.py ADDED
@@ -0,0 +1,31 @@
1
+ """SQLite connection management with sane pragmas and a transaction helper."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sqlite3
6
+ from collections.abc import Iterator
7
+ from contextlib import contextmanager
8
+ from pathlib import Path
9
+
10
+
11
+ def connect(path: Path | str) -> sqlite3.Connection:
12
+ """Open a connection with WAL, foreign keys, and row access by name."""
13
+ conn = sqlite3.connect(str(path), isolation_level=None)
14
+ conn.row_factory = sqlite3.Row
15
+ conn.execute("PRAGMA journal_mode = WAL;")
16
+ conn.execute("PRAGMA foreign_keys = ON;")
17
+ conn.execute("PRAGMA synchronous = NORMAL;")
18
+ return conn
19
+
20
+
21
+ @contextmanager
22
+ def transaction(conn: sqlite3.Connection) -> Iterator[sqlite3.Connection]:
23
+ """Run a block inside a transaction, committing on success."""
24
+ conn.execute("BEGIN;")
25
+ try:
26
+ yield conn
27
+ except Exception:
28
+ conn.execute("ROLLBACK;")
29
+ raise
30
+ else:
31
+ conn.execute("COMMIT;")
@@ -0,0 +1,106 @@
1
+ """Ordered schema migrations, versioned via the ``schema_meta`` table."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sqlite3
6
+
7
+ # Each migration is (version, SQL). Applied in order; never edit a shipped one.
8
+ _MIGRATIONS: list[tuple[int, str]] = [
9
+ (
10
+ 1,
11
+ """
12
+ CREATE TABLE IF NOT EXISTS schema_meta (
13
+ key TEXT PRIMARY KEY,
14
+ value TEXT NOT NULL
15
+ );
16
+
17
+ CREATE TABLE IF NOT EXISTS profile (
18
+ id INTEGER PRIMARY KEY CHECK (id = 1),
19
+ display_name TEXT NOT NULL DEFAULT 'you',
20
+ created_at TEXT NOT NULL,
21
+ races_played INTEGER NOT NULL DEFAULT 0,
22
+ races_won INTEGER NOT NULL DEFAULT 0,
23
+ best_wpm REAL NOT NULL DEFAULT 0,
24
+ best_accuracy REAL NOT NULL DEFAULT 0,
25
+ total_chars INTEGER NOT NULL DEFAULT 0,
26
+ total_time_ms INTEGER NOT NULL DEFAULT 0
27
+ );
28
+
29
+ CREATE TABLE IF NOT EXISTS quote (
30
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
31
+ ext_id TEXT UNIQUE,
32
+ text TEXT NOT NULL,
33
+ source TEXT,
34
+ length INTEGER NOT NULL,
35
+ difficulty TEXT
36
+ );
37
+
38
+ CREATE TABLE IF NOT EXISTS race (
39
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
40
+ profile_id INTEGER NOT NULL REFERENCES profile(id),
41
+ quote_id INTEGER NOT NULL REFERENCES quote(id),
42
+ mode_seconds INTEGER NOT NULL,
43
+ started_at TEXT NOT NULL,
44
+ duration_ms INTEGER NOT NULL,
45
+ wpm REAL NOT NULL,
46
+ raw_wpm REAL NOT NULL,
47
+ accuracy REAL NOT NULL,
48
+ correct_chars INTEGER NOT NULL,
49
+ incorrect_chars INTEGER NOT NULL,
50
+ progress REAL NOT NULL,
51
+ is_daily INTEGER NOT NULL DEFAULT 0,
52
+ ghost_kind TEXT,
53
+ ghost_won INTEGER
54
+ );
55
+ CREATE INDEX IF NOT EXISTS idx_race_profile_time ON race(profile_id, started_at DESC);
56
+ CREATE INDEX IF NOT EXISTS idx_race_wpm ON race(profile_id, wpm DESC);
57
+ CREATE INDEX IF NOT EXISTS idx_race_daily ON race(is_daily, started_at);
58
+
59
+ CREATE TABLE IF NOT EXISTS replay (
60
+ race_id INTEGER PRIMARY KEY REFERENCES race(id) ON DELETE CASCADE,
61
+ timeline TEXT NOT NULL
62
+ );
63
+
64
+ CREATE TABLE IF NOT EXISTS daily_challenge (
65
+ day TEXT PRIMARY KEY,
66
+ quote_id INTEGER NOT NULL REFERENCES quote(id),
67
+ best_wpm REAL NOT NULL DEFAULT 0,
68
+ attempts INTEGER NOT NULL DEFAULT 0
69
+ );
70
+ """,
71
+ ),
72
+ (
73
+ 2,
74
+ # Distinguish quote-mode (finish one text) from time-mode (type for N s).
75
+ # Existing rows predate the split and were single-quote races.
76
+ """
77
+ ALTER TABLE race ADD COLUMN race_kind TEXT NOT NULL DEFAULT 'quote';
78
+ CREATE INDEX IF NOT EXISTS idx_race_kind_wpm ON race(race_kind, mode_seconds, wpm DESC);
79
+ """,
80
+ ),
81
+ ]
82
+
83
+
84
+ def _current_version(conn: sqlite3.Connection) -> int:
85
+ row = conn.execute(
86
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='schema_meta'"
87
+ ).fetchone()
88
+ if row is None:
89
+ return 0
90
+ cur = conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()
91
+ return int(cur["value"]) if cur else 0
92
+
93
+
94
+ def migrate(conn: sqlite3.Connection) -> int:
95
+ """Apply all pending migrations. Returns the resulting schema version."""
96
+ version = _current_version(conn)
97
+ for target, sql in _MIGRATIONS:
98
+ if target > version:
99
+ conn.executescript(sql)
100
+ conn.execute(
101
+ "INSERT INTO schema_meta(key, value) VALUES('schema_version', ?) "
102
+ "ON CONFLICT(key) DO UPDATE SET value=excluded.value",
103
+ (str(target),),
104
+ )
105
+ version = target
106
+ return version
@@ -0,0 +1,35 @@
1
+ """Resolve OS-appropriate data/config locations via platformdirs.
2
+
3
+ Honors ``TYPEFASTER_DATA_DIR`` for tests and portable installs.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ from pathlib import Path
10
+
11
+ from platformdirs import user_config_dir, user_data_dir
12
+
13
+ _APP = "typefaster"
14
+
15
+
16
+ def data_dir() -> Path:
17
+ override = os.environ.get("TYPEFASTER_DATA_DIR")
18
+ base = Path(override) if override else Path(user_data_dir(_APP, appauthor=False))
19
+ base.mkdir(parents=True, exist_ok=True)
20
+ return base
21
+
22
+
23
+ def config_dir() -> Path:
24
+ override = os.environ.get("TYPEFASTER_CONFIG_DIR")
25
+ base = Path(override) if override else Path(user_config_dir(_APP, appauthor=False))
26
+ base.mkdir(parents=True, exist_ok=True)
27
+ return base
28
+
29
+
30
+ def db_path() -> Path:
31
+ return data_dir() / "typefaster.db"
32
+
33
+
34
+ def config_path() -> Path:
35
+ return config_dir() / "settings.json"
@@ -0,0 +1,53 @@
1
+ """Load the bundled quote dataset and select quotes.
2
+
3
+ ``quotes.json`` ships inside the package (``typefaster/assets``). Each entry:
4
+ ``{"id": "...", "text": "...", "source": "..."}``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ import random
12
+ from datetime import date
13
+ from functools import lru_cache
14
+ from importlib.resources import files
15
+
16
+ from ..domain.errors import NoQuotesError
17
+ from ..domain.models import Difficulty, Quote
18
+
19
+
20
+ @lru_cache(maxsize=1)
21
+ def _load_raw() -> list[dict[str, str]]:
22
+ resource = files("typefaster.assets").joinpath("quotes.json")
23
+ data: list[dict[str, str]] = json.loads(resource.read_text(encoding="utf-8"))
24
+ if not data:
25
+ raise NoQuotesError("quotes.json is empty")
26
+ return data
27
+
28
+
29
+ def all_quotes() -> list[Quote]:
30
+ return [Quote(ext_id=q["id"], text=q["text"], source=q.get("source")) for q in _load_raw()]
31
+
32
+
33
+ def random_quote(rng: random.Random | None = None) -> Quote:
34
+ quotes = all_quotes()
35
+ if not quotes:
36
+ raise NoQuotesError("no quotes available")
37
+ r = rng or random
38
+ return r.choice(quotes)
39
+
40
+
41
+ def quotes_by_difficulty(difficulty: Difficulty) -> list[Quote]:
42
+ return [q for q in all_quotes() if q.difficulty == difficulty]
43
+
44
+
45
+ def daily_quote(day: date | None = None) -> Quote:
46
+ """Deterministically pick the same quote for everyone on a given UTC day."""
47
+ quotes = all_quotes()
48
+ if not quotes:
49
+ raise NoQuotesError("no quotes available")
50
+ day = day or date.today()
51
+ digest = hashlib.sha256(day.isoformat().encode("utf-8")).hexdigest()
52
+ index = int(digest, 16) % len(quotes)
53
+ return quotes[index]
@@ -0,0 +1,16 @@
1
+ """Serialize/deserialize replay timelines to the compact ``{t, p}`` JSON form."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ from ..domain.models import ReplayPoint
8
+
9
+
10
+ def serialize(timeline: list[ReplayPoint]) -> str:
11
+ return json.dumps([{"t": p.t_ms, "p": p.progress_pct} for p in timeline])
12
+
13
+
14
+ def deserialize(blob: str) -> list[ReplayPoint]:
15
+ raw = json.loads(blob)
16
+ return [ReplayPoint(t_ms=int(item["t"]), progress_pct=float(item["p"])) for item in raw]
@@ -0,0 +1,63 @@
1
+ """Repository port (interface). Services depend on this, not on SQLite directly.
2
+
3
+ Phase 2 can provide an alternative implementation (e.g. Redis-backed sync) that
4
+ satisfies the same protocol.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Protocol
10
+
11
+ from ..domain.models import (
12
+ DailyChallenge,
13
+ Profile,
14
+ Quote,
15
+ RaceKind,
16
+ RaceRecord,
17
+ RaceResult,
18
+ ReplayPoint,
19
+ )
20
+
21
+ # A loaded ghost: its replay timeline, the WPM achieved, and the exact quote.
22
+ GhostData = tuple[list[ReplayPoint], float, Quote]
23
+
24
+
25
+ class Repository(Protocol):
26
+ # profile
27
+ def get_profile(self) -> Profile: ...
28
+ def recompute_profile(self) -> Profile: ...
29
+ def delete_implausible_races(self, max_wpm: float = 300.0) -> int: ...
30
+ def wipe(self) -> None: ...
31
+
32
+ # quotes
33
+ def upsert_quote(self, quote: Quote) -> int: ...
34
+
35
+ # races
36
+ def save_race(
37
+ self,
38
+ *,
39
+ result: RaceResult,
40
+ quote: Quote,
41
+ started_at: str,
42
+ is_daily: bool = False,
43
+ ) -> int: ...
44
+
45
+ def list_history(self, limit: int = 20, offset: int = 0) -> list[RaceRecord]: ...
46
+ def count_races(self) -> int: ...
47
+ def average_wpm_accuracy(self) -> tuple[float, float]: ...
48
+ def best_by_mode(self, kind: RaceKind = ...) -> dict[int, float]: ...
49
+ def best_quote_run(self) -> tuple[float, int] | None: ...
50
+ def top_runs(
51
+ self, mode_seconds: int, limit: int = 10, kind: RaceKind = ...
52
+ ) -> list[RaceRecord]: ...
53
+ def top_quote_runs(self, limit: int = 10) -> list[RaceRecord]: ...
54
+
55
+ # ghosts (always completed, plausible, quote-mode runs — same text)
56
+ def personal_best_replay(self) -> GhostData | None: ...
57
+ def last_replay(self) -> GhostData | None: ...
58
+ def random_replay(self) -> GhostData | None: ...
59
+ def best_replay_for_quote(self, ext_id: str) -> GhostData | None: ...
60
+
61
+ # daily
62
+ def get_or_create_daily(self, day: str, quote: Quote) -> DailyChallenge: ...
63
+ def daily_leaderboard(self, day: str, limit: int = 20) -> list[RaceRecord]: ...