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,46 @@
1
+ """Local persistence of the auth token and server URL."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import asdict, dataclass
7
+ from pathlib import Path
8
+
9
+ from ..infra.paths import config_dir
10
+
11
+
12
+ def _auth_path() -> Path:
13
+ return config_dir() / "auth.json"
14
+
15
+
16
+ @dataclass(slots=True)
17
+ class Session:
18
+ server_url: str = "http://localhost:8000"
19
+ token: str | None = None
20
+ username: str | None = None
21
+
22
+ @property
23
+ def logged_in(self) -> bool:
24
+ return bool(self.token)
25
+
26
+ @classmethod
27
+ def load(cls, path: Path | None = None) -> Session:
28
+ path = path or _auth_path()
29
+ if not path.exists():
30
+ return cls()
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 _auth_path()
40
+ path.parent.mkdir(parents=True, exist_ok=True)
41
+ path.write_text(json.dumps(asdict(self), indent=2), encoding="utf-8")
42
+
43
+ def clear(self, path: Path | None = None) -> None:
44
+ self.token = None
45
+ self.username = None
46
+ self.save(path)
@@ -0,0 +1 @@
1
+ """Application services orchestrating the domain and infrastructure layers."""
@@ -0,0 +1,42 @@
1
+ """Composition root — wires settings, repository, and services together."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ from ..infra.config import Settings
9
+ from ..infra.sqlite_repository import SQLiteRepository
10
+ from .daily_service import DailyService
11
+ from .ghost_service import GhostService
12
+ from .profile_service import ProfileService
13
+ from .race_service import RaceService
14
+ from .stats_service import StatsService
15
+
16
+
17
+ @dataclass(slots=True)
18
+ class App:
19
+ settings: Settings
20
+ repo: SQLiteRepository
21
+ race: RaceService
22
+ profile: ProfileService
23
+ stats: StatsService
24
+ daily: DailyService
25
+ ghosts: GhostService
26
+
27
+ def close(self) -> None:
28
+ self.repo.close()
29
+
30
+
31
+ def build_app(db_path: Path | str | None = None) -> App:
32
+ settings = Settings.load()
33
+ repo = SQLiteRepository(db_path)
34
+ return App(
35
+ settings=settings,
36
+ repo=repo,
37
+ race=RaceService(repo, allow_backspace=settings.allow_backspace),
38
+ profile=ProfileService(repo),
39
+ stats=StatsService(repo),
40
+ daily=DailyService(repo),
41
+ ghosts=GhostService(repo),
42
+ )
@@ -0,0 +1,23 @@
1
+ """Daily challenge: deterministic shared quote + local daily leaderboard."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import date
6
+
7
+ from ..domain.models import DailyChallenge, RaceRecord
8
+ from ..infra import quote_loader
9
+ from ..infra.repository import Repository
10
+
11
+
12
+ class DailyService:
13
+ def __init__(self, repo: Repository) -> None:
14
+ self._repo = repo
15
+
16
+ def today(self, day: date | None = None) -> DailyChallenge:
17
+ day = day or date.today()
18
+ quote = quote_loader.daily_quote(day)
19
+ return self._repo.get_or_create_daily(day.isoformat(), quote)
20
+
21
+ def leaderboard(self, day: date | None = None, limit: int = 20) -> list[RaceRecord]:
22
+ day = day or date.today()
23
+ return self._repo.daily_leaderboard(day.isoformat(), limit=limit)
@@ -0,0 +1,50 @@
1
+ """Build ghost opponents from stored replays."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..domain.errors import GhostUnavailableError
6
+ from ..domain.models import Ghost, GhostKind
7
+ from ..infra.repository import Repository
8
+
9
+ _LABELS = {
10
+ GhostKind.PERSONAL_BEST: "PB",
11
+ GhostKind.LAST: "Last",
12
+ GhostKind.RANDOM: "Random",
13
+ }
14
+
15
+
16
+ class GhostService:
17
+ def __init__(self, repo: Repository) -> None:
18
+ self._repo = repo
19
+
20
+ def load(self, kind: GhostKind) -> Ghost:
21
+ """Return a ghost for the requested kind, or raise if no data exists."""
22
+ if kind is GhostKind.PERSONAL_BEST:
23
+ data = self._repo.personal_best_replay()
24
+ elif kind is GhostKind.LAST:
25
+ data = self._repo.last_replay()
26
+ else:
27
+ data = self._repo.random_replay()
28
+
29
+ if data is None:
30
+ raise GhostUnavailableError(
31
+ f"No historical run available for ghost '{kind.value}'. Finish a race first!"
32
+ )
33
+ timeline, wpm, quote = data
34
+ return Ghost(kind=kind, label=_LABELS[kind], timeline=timeline, wpm=wpm, quote=quote)
35
+
36
+ def best_for_quote(self, ext_id: str) -> Ghost | None:
37
+ """A ghost recorded on a specific quote (e.g. your best daily run)."""
38
+ data = self._repo.best_replay_for_quote(ext_id)
39
+ if data is None:
40
+ return None
41
+ timeline, wpm, quote = data
42
+ return Ghost(
43
+ kind=GhostKind.PERSONAL_BEST, label="PB", timeline=timeline, wpm=wpm, quote=quote
44
+ )
45
+
46
+ def try_load(self, kind: GhostKind) -> Ghost | None:
47
+ try:
48
+ return self.load(kind)
49
+ except GhostUnavailableError:
50
+ return None
@@ -0,0 +1,18 @@
1
+ """Profile read/repair operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..domain.models import Profile
6
+ from ..infra.repository import Repository
7
+
8
+
9
+ class ProfileService:
10
+ def __init__(self, repo: Repository) -> None:
11
+ self._repo = repo
12
+
13
+ def get(self) -> Profile:
14
+ return self._repo.get_profile()
15
+
16
+ def repair(self) -> Profile:
17
+ """Recompute denormalized aggregates from the race table."""
18
+ return self._repo.recompute_profile()
@@ -0,0 +1,152 @@
1
+ """Coordinate a race: choose the text/ghost/mode, then persist the result.
2
+
3
+ The interactive typing loop lives in the UI layer (Textual), which drives a
4
+ ``TypingEngine``. This service handles everything around that: setup and
5
+ persistence. Keeping it UI-free means the same flow can be reused headlessly
6
+ (tests) and adapted server-side in Phase 2.
7
+
8
+ Two race kinds (see ``RaceKind``):
9
+ - QUOTE: one fixed quote, raced to completion. Ghosts (same text) live here.
10
+ - TIME: type continuously for N seconds; text streams from many quotes.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+ from datetime import UTC, date, datetime
17
+
18
+ from ..domain.models import Ghost, GhostKind, Quote, RaceKind, RaceMode, RaceResult
19
+ from ..infra import quote_loader
20
+ from ..infra.repository import Repository
21
+ from .ghost_service import GhostService
22
+
23
+ # Placeholder quote row that TIME-mode races reference (they have no single text).
24
+ _TIME_QUOTE = Quote(ext_id="__time_mode__", text="(time mode)", source="Time")
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class RaceConfig:
29
+ """A request to start a race (what the UI/CLI chooses)."""
30
+
31
+ kind: RaceKind = RaceKind.QUOTE
32
+ mode: RaceMode = RaceMode.NORMAL
33
+ ghost_kind: GhostKind | None = None
34
+ daily: bool = False
35
+
36
+
37
+ @dataclass(slots=True)
38
+ class RaceSetup:
39
+ quote: Quote # the row persisted with the race
40
+ target_text: str # what the player actually types (streamed for TIME mode)
41
+ kind: RaceKind
42
+ mode: RaceMode # the time limit for TIME mode (ignored for QUOTE)
43
+ ghost: Ghost | None
44
+ allow_backspace: bool
45
+ is_daily: bool
46
+ started_at: str
47
+
48
+
49
+ @dataclass(slots=True)
50
+ class RaceSummary:
51
+ result: RaceResult
52
+ race_id: int
53
+ new_personal_best: bool
54
+ previous_best_wpm: float
55
+
56
+
57
+ class RaceService:
58
+ def __init__(self, repo: Repository, *, allow_backspace: bool = True) -> None:
59
+ self._repo = repo
60
+ self._ghosts = GhostService(repo)
61
+ self._allow_backspace = allow_backspace
62
+
63
+ def prepare(
64
+ self,
65
+ *,
66
+ kind: RaceKind = RaceKind.QUOTE,
67
+ mode: RaceMode = RaceMode.NORMAL,
68
+ ghost_kind: GhostKind | None = None,
69
+ daily: bool = False,
70
+ ) -> RaceSetup:
71
+ if kind is RaceKind.TIME:
72
+ return self._prepare_time(mode)
73
+ return self._prepare_quote(mode, ghost_kind, daily)
74
+
75
+ def _prepare_quote(
76
+ self, mode: RaceMode, ghost_kind: GhostKind | None, daily: bool
77
+ ) -> RaceSetup:
78
+ ghost: Ghost | None = None
79
+ if daily:
80
+ quote = quote_loader.daily_quote(date.today())
81
+ # Daily ghost = your best run on *today's* quote (same text), if any.
82
+ ghost = self._ghosts.best_for_quote(quote.ext_id)
83
+ elif ghost_kind is not None:
84
+ # Explicit "vs PB/Last/Random": race the ghost's exact text for a
85
+ # fair head-to-head.
86
+ ghost = self._ghosts.try_load(ghost_kind)
87
+ quote = ghost.quote if ghost and ghost.quote else quote_loader.random_quote()
88
+ else:
89
+ # Quick race: a fresh random quote every time. Attach a ghost only
90
+ # if you've raced this exact quote before.
91
+ quote = quote_loader.random_quote()
92
+ ghost = self._ghosts.best_for_quote(quote.ext_id)
93
+
94
+ return RaceSetup(
95
+ quote=quote,
96
+ target_text=quote.text,
97
+ kind=RaceKind.QUOTE,
98
+ mode=mode,
99
+ ghost=ghost,
100
+ allow_backspace=self._allow_backspace,
101
+ is_daily=daily,
102
+ started_at=datetime.now(UTC).isoformat(),
103
+ )
104
+
105
+ def _prepare_time(self, mode: RaceMode) -> RaceSetup:
106
+ # Enough text that even a very fast typist won't run out before time:
107
+ # ~300 WPM * 5 chars = 1500 chars/min, plus a safety buffer.
108
+ target_chars = int(mode.value / 60 * 1500) + 600
109
+ text = _stream_text(target_chars)
110
+ return RaceSetup(
111
+ quote=_TIME_QUOTE,
112
+ target_text=text,
113
+ kind=RaceKind.TIME,
114
+ mode=mode,
115
+ ghost=None, # ghosts are a QUOTE-mode feature
116
+ allow_backspace=self._allow_backspace,
117
+ is_daily=False,
118
+ started_at=datetime.now(UTC).isoformat(),
119
+ )
120
+
121
+ def finish(self, setup: RaceSetup, result: RaceResult) -> RaceSummary:
122
+ # Compare against the best of the *same* kind so the two don't mix.
123
+ previous_best = self._best_wpm_for_kind(result.kind)
124
+ race_id = self._repo.save_race(
125
+ result=result,
126
+ quote=setup.quote,
127
+ started_at=setup.started_at,
128
+ is_daily=setup.is_daily,
129
+ )
130
+ return RaceSummary(
131
+ result=result,
132
+ race_id=race_id,
133
+ new_personal_best=result.wpm > previous_best,
134
+ previous_best_wpm=previous_best,
135
+ )
136
+
137
+ def _best_wpm_for_kind(self, kind: RaceKind) -> float:
138
+ if kind is RaceKind.QUOTE:
139
+ best = self._repo.best_quote_run()
140
+ return best[0] if best else 0.0
141
+ return max(self._repo.best_by_mode(RaceKind.TIME).values(), default=0.0)
142
+
143
+
144
+ def _stream_text(min_chars: int) -> str:
145
+ """Concatenate random quotes into one continuous block of >= min_chars."""
146
+ parts: list[str] = []
147
+ total = 0
148
+ while total < min_chars:
149
+ q = quote_loader.random_quote()
150
+ parts.append(q.text)
151
+ total += len(q.text) + 1
152
+ return " ".join(parts)
@@ -0,0 +1,52 @@
1
+ """Read-side aggregation for the stats / history / leaderboard screens."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from ..domain.models import Profile, RaceKind, RaceRecord
8
+ from ..infra.repository import Repository
9
+
10
+
11
+ @dataclass(slots=True)
12
+ class StatsSummary:
13
+ profile: Profile
14
+ avg_wpm: float
15
+ avg_accuracy: float
16
+ time_best_by_mode: dict[int, float] # TIME mode: seconds -> best WPM
17
+ quote_best_wpm: float # QUOTE mode: best WPM
18
+ quote_best_ms: int # QUOTE mode: fastest completion (ms)
19
+ recent_wpm: list[float]
20
+
21
+
22
+ class StatsService:
23
+ def __init__(self, repo: Repository) -> None:
24
+ self._repo = repo
25
+
26
+ def summary(self) -> StatsSummary:
27
+ profile = self._repo.get_profile()
28
+ avg_wpm, avg_acc = self._repo.average_wpm_accuracy()
29
+ recent = [r.wpm for r in reversed(self._repo.list_history(limit=20))]
30
+ quote_best = self._repo.best_quote_run()
31
+ return StatsSummary(
32
+ profile=profile,
33
+ avg_wpm=round(avg_wpm, 1),
34
+ avg_accuracy=round(avg_acc, 4),
35
+ time_best_by_mode=self._repo.best_by_mode(RaceKind.TIME),
36
+ quote_best_wpm=quote_best[0] if quote_best else 0.0,
37
+ quote_best_ms=quote_best[1] if quote_best else 0,
38
+ recent_wpm=recent,
39
+ )
40
+
41
+ def history(self, limit: int = 20, offset: int = 0) -> list[RaceRecord]:
42
+ return self._repo.list_history(limit=limit, offset=offset)
43
+
44
+ def history_pages(self, page_size: int = 20) -> int:
45
+ total = self._repo.count_races()
46
+ return max(1, (total + page_size - 1) // page_size)
47
+
48
+ def top_time_runs(self, mode_seconds: int, limit: int = 10) -> list[RaceRecord]:
49
+ return self._repo.top_runs(mode_seconds, limit=limit, kind=RaceKind.TIME)
50
+
51
+ def top_quote_runs(self, limit: int = 10) -> list[RaceRecord]:
52
+ return self._repo.top_quote_runs(limit=limit)
@@ -0,0 +1 @@
1
+ """Presentation layer — Textual screens and Rich-rendered widgets."""
typefaster/ui/app.py ADDED
@@ -0,0 +1,93 @@
1
+ """The Textual application — screen orchestration and race flow."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from textual.app import App
6
+
7
+ from ..domain.models import RaceResult
8
+ from ..services.container import App as Services
9
+ from ..services.container import build_app
10
+ from ..services.race_service import RaceConfig, RaceSetup
11
+ from .screens.daily import DailyScreen
12
+ from .screens.help import HelpScreen
13
+ from .screens.history import HistoryScreen
14
+ from .screens.leaderboard import LeaderboardScreen
15
+ from .screens.main_menu import MainMenu
16
+ from .screens.practice import PracticeScreen
17
+ from .screens.profile import ProfileScreen
18
+ from .screens.race import RaceScreen
19
+ from .screens.results import ResultsScreen
20
+ from .screens.settings import SettingsScreen
21
+ from .screens.stats import StatsScreen
22
+ from .theme import APP_CSS
23
+
24
+ _PANELS = {
25
+ "practice": PracticeScreen,
26
+ "daily": DailyScreen,
27
+ "stats": StatsScreen,
28
+ "history": HistoryScreen,
29
+ "profile": ProfileScreen,
30
+ "leaderboard": LeaderboardScreen,
31
+ "settings": SettingsScreen,
32
+ "help": HelpScreen,
33
+ }
34
+
35
+
36
+ class TypefasterApp(App[None]):
37
+ """Offline TYPEFASTER terminal app."""
38
+
39
+ CSS = APP_CSS
40
+ TITLE = "TYPEFASTER"
41
+
42
+ def __init__(
43
+ self,
44
+ services: Services | None = None,
45
+ *,
46
+ initial_race: RaceConfig | None = None,
47
+ ) -> None:
48
+ super().__init__()
49
+ self.services: Services = services or build_app()
50
+ self._last_config: RaceConfig | None = None
51
+ self._initial_race = initial_race
52
+
53
+ def on_mount(self) -> None:
54
+ self.push_screen(MainMenu())
55
+ if self._initial_race is not None:
56
+ self.start_race(self._initial_race)
57
+
58
+ # ── navigation helper used by screens ──────────────────────────────
59
+ def open(self, name: str) -> None:
60
+ """Push a fresh panel screen by name so its data is always current."""
61
+ self.push_screen(_PANELS[name]())
62
+
63
+ # ── race flow ──────────────────────────────────────────────────────
64
+ def start_race(self, config: RaceConfig) -> None:
65
+ setup = self.services.race.prepare(
66
+ kind=config.kind,
67
+ mode=config.mode,
68
+ ghost_kind=config.ghost_kind,
69
+ daily=config.daily,
70
+ )
71
+ self._last_config = config
72
+ self.push_screen(RaceScreen(setup), lambda result: self._after_race(setup, result))
73
+
74
+ def _after_race(self, setup: RaceSetup, result: RaceResult | None) -> None:
75
+ if result is None:
76
+ return # race quit — return to whatever is underneath
77
+ if result.suspicious:
78
+ # Implausible run (paste/auto-input): show it, but do not record it.
79
+ self.push_screen(ResultsScreen(result, setup, summary=None), self._after_results)
80
+ return
81
+ summary = self.services.race.finish(setup, result)
82
+ self.push_screen(ResultsScreen(result, setup, summary=summary), self._after_results)
83
+
84
+ def _after_results(self, action: str | None) -> None:
85
+ if action == "again" and self._last_config is not None:
86
+ self.start_race(self._last_config)
87
+
88
+ def on_unmount(self) -> None:
89
+ self.services.close()
90
+
91
+
92
+ def run(initial_race: RaceConfig | None = None) -> None:
93
+ TypefasterApp(initial_race=initial_race).run()
@@ -0,0 +1,24 @@
1
+ """A minimal Textual app that hosts the online race screen for one lobby."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from textual.app import App
6
+
7
+ from .screens.online_race import OnlineRaceScreen
8
+ from .theme import APP_CSS
9
+
10
+
11
+ class OnlineApp(App[None]):
12
+ CSS = APP_CSS
13
+ TITLE = "TYPEFASTER · online"
14
+
15
+ def __init__(self, ws_url: str, username: str, mode_seconds: int) -> None:
16
+ super().__init__()
17
+ self._args = (ws_url, username, mode_seconds)
18
+
19
+ def on_mount(self) -> None:
20
+ self.push_screen(OnlineRaceScreen(*self._args))
21
+
22
+
23
+ def run_online(ws_url: str, username: str, mode_seconds: int) -> None:
24
+ OnlineApp(ws_url, username, mode_seconds).run()
@@ -0,0 +1 @@
1
+ """Textual screens."""
@@ -0,0 +1,30 @@
1
+ """Shared base for read-only panel screens (esc to go back)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.console import RenderableType
6
+ from rich.text import Text
7
+ from textual.app import ComposeResult
8
+ from textual.containers import Vertical, VerticalScroll
9
+ from textual.screen import Screen
10
+ from textual.widgets import Static
11
+
12
+
13
+ class PanelScreen(Screen[None]):
14
+ """A titled, scrollable panel. Subclasses implement ``body``."""
15
+
16
+ title_text: str = "PANEL"
17
+ BINDINGS = [("escape", "back", "Back"), ("q", "back", "Back")]
18
+
19
+ def compose(self) -> ComposeResult:
20
+ with Vertical(id="panel-wrap"):
21
+ yield Static(Text(self.title_text, justify="center"), id="title")
22
+ with VerticalScroll():
23
+ yield Static(self.body())
24
+ yield Static("esc back", classes="dim")
25
+
26
+ def body(self) -> RenderableType: # pragma: no cover - overridden
27
+ return Text("")
28
+
29
+ def action_back(self) -> None:
30
+ self.app.pop_screen()
@@ -0,0 +1,64 @@
1
+ """Daily challenge screen — shared quote + local daily leaderboard."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import date
6
+
7
+ from rich.console import Group
8
+ from rich.table import Table
9
+ from rich.text import Text
10
+ from textual.app import ComposeResult
11
+ from textual.containers import Vertical, VerticalScroll
12
+ from textual.screen import Screen
13
+ from textual.widgets import Static
14
+
15
+ from ...domain.models import RaceKind
16
+ from ...services.race_service import RaceConfig
17
+
18
+
19
+ class DailyScreen(Screen[None]):
20
+ BINDINGS = [
21
+ ("enter", "play", "Play"),
22
+ ("escape", "back", "Back"),
23
+ ]
24
+
25
+ def compose(self) -> ComposeResult:
26
+ with Vertical(id="panel-wrap"):
27
+ yield Static(
28
+ Text(f"DAILY CHALLENGE · {date.today().isoformat()}", justify="center"),
29
+ id="title",
30
+ )
31
+ with VerticalScroll():
32
+ yield Static(self._body())
33
+ yield Static("⏎ play today's challenge esc back", classes="dim")
34
+
35
+ def _body(self) -> Group:
36
+ svc = self.app.services # type: ignore[attr-defined]
37
+ challenge = svc.daily.today()
38
+ board = svc.daily.leaderboard(limit=10)
39
+
40
+ quote = Text()
41
+ quote.append("Today's quote (same for everyone)\n", style="grey58")
42
+ quote.append(f'"{challenge.quote.text}"\n', style="italic")
43
+ quote.append(f"— {challenge.quote.source or 'unknown'}\n", style="grey58")
44
+ quote.append(
45
+ f"\nYour best today: {challenge.best_wpm:.0f} wpm · attempts {challenge.attempts}\n",
46
+ style="bold",
47
+ )
48
+
49
+ table = Table(title="Local daily leaderboard", title_style="bold", expand=True)
50
+ table.add_column("#", justify="right")
51
+ table.add_column("WPM", justify="right")
52
+ table.add_column("Acc", justify="right")
53
+ if not board:
54
+ table.add_row("—", "—", "not played yet")
55
+ for i, r in enumerate(board, 1):
56
+ table.add_row(str(i), f"{r.wpm:.0f}", f"{r.accuracy * 100:.0f}%")
57
+ return Group(quote, table)
58
+
59
+ def action_play(self) -> None:
60
+ # Daily is a fixed quote; the ghost (if any) is your best run on it.
61
+ self.app.start_race(RaceConfig(kind=RaceKind.QUOTE, daily=True)) # type: ignore[attr-defined]
62
+
63
+ def action_back(self) -> None:
64
+ self.app.pop_screen()
@@ -0,0 +1,26 @@
1
+ """Help overlay."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.console import RenderableType
6
+ from rich.table import Table
7
+
8
+ from ._base import PanelScreen
9
+
10
+
11
+ class HelpScreen(PanelScreen):
12
+ title_text = "HELP"
13
+
14
+ def body(self) -> RenderableType:
15
+ table = Table.grid(padding=(0, 3))
16
+ table.add_column(style="bold cyan", justify="right")
17
+ table.add_column()
18
+ table.add_row("↑ / ↓ , j / k", "move between options")
19
+ table.add_row("Enter", "select")
20
+ table.add_row("Esc", "go back")
21
+ table.add_row("q", "quit")
22
+ table.add_row("?", "this help")
23
+ table.add_row("", "")
24
+ table.add_row("During a race", "just type; Backspace corrects mistakes")
25
+ table.add_row("", "Esc quits the race")
26
+ return table
@@ -0,0 +1,34 @@
1
+ """History screen — most recent races."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.console import RenderableType
6
+ from rich.table import Table
7
+ from rich.text import Text
8
+
9
+ from ._base import PanelScreen
10
+
11
+
12
+ class HistoryScreen(PanelScreen):
13
+ title_text = "HISTORY"
14
+
15
+ def body(self) -> RenderableType:
16
+ records = self.app.services.stats.history(limit=30) # type: ignore[attr-defined]
17
+ if not records:
18
+ return Text("No races yet. Press esc, then start a Quick Race!", style="grey58")
19
+
20
+ table = Table(expand=True)
21
+ table.add_column("Date")
22
+ table.add_column("Mode", justify="right")
23
+ table.add_column("WPM", justify="right")
24
+ table.add_column("Acc", justify="right")
25
+ table.add_column("Source")
26
+ for r in records:
27
+ table.add_row(
28
+ r.started_at[:16].replace("T", " "),
29
+ f"{r.mode_seconds}s",
30
+ f"{r.wpm:.0f}",
31
+ f"{r.accuracy * 100:.0f}%",
32
+ (r.quote_source or "—")[:24],
33
+ )
34
+ return table