typefaster-cli 0.1.4__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/.gitignore +1 -0
- typefaster_cli-0.2.0/PKG-INFO +145 -0
- typefaster_cli-0.2.0/README.md +113 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/client/typefaster/__init__.py +1 -1
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/client/typefaster/net/token_store.py +1 -1
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/client/typefaster/services/daily_service.py +14 -3
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/client/typefaster/ui/app.py +4 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/client/typefaster/ui/screens/_base.py +5 -1
- typefaster_cli-0.2.0/client/typefaster/ui/screens/account.py +193 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/client/typefaster/ui/screens/daily.py +8 -6
- typefaster_cli-0.2.0/client/typefaster/ui/screens/lobby_browser.py +150 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/client/typefaster/ui/screens/main_menu.py +18 -1
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/client/typefaster/ui/screens/online_race.py +8 -4
- typefaster_cli-0.2.0/docs/DEEPDIVE.md +305 -0
- typefaster_cli-0.2.0/docs/SECURITY-REVIEW.md +74 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/pyproject.toml +1 -1
- typefaster_cli-0.1.4/PKG-INFO +0 -168
- typefaster_cli-0.1.4/README.md +0 -136
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/.dockerignore +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/.env.example +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/.github/dependabot.yml +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/.github/workflows/ci.yml +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/.github/workflows/codeql.yml +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/.github/workflows/release.yml +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/CONTRIBUTING.md +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/LICENSE +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/Makefile +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/SECURITY.md +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/client/Dockerfile +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/client/typefaster/__main__.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/client/typefaster/assets/__init__.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/client/typefaster/assets/quotes.json +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/client/typefaster/cli.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/client/typefaster/domain/__init__.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/client/typefaster/domain/anti_cheat.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/client/typefaster/domain/calculators.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/client/typefaster/domain/errors.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/client/typefaster/domain/ghost.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/client/typefaster/domain/models.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/client/typefaster/domain/typing_engine.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/client/typefaster/infra/__init__.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/client/typefaster/infra/clock.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/client/typefaster/infra/config.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/client/typefaster/infra/db.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/client/typefaster/infra/migrations.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/client/typefaster/infra/paths.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/client/typefaster/infra/quote_loader.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/client/typefaster/infra/replay_store.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/client/typefaster/infra/repository.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/client/typefaster/infra/sqlite_repository.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/client/typefaster/net/__init__.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/client/typefaster/net/api.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/client/typefaster/net/commands.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/client/typefaster/services/__init__.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/client/typefaster/services/container.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/client/typefaster/services/ghost_service.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/client/typefaster/services/profile_service.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/client/typefaster/services/race_service.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/client/typefaster/services/stats_service.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/client/typefaster/ui/__init__.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/client/typefaster/ui/online_app.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/client/typefaster/ui/screens/__init__.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/client/typefaster/ui/screens/help.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/client/typefaster/ui/screens/history.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/client/typefaster/ui/screens/leaderboard.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/client/typefaster/ui/screens/practice.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/client/typefaster/ui/screens/profile.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/client/typefaster/ui/screens/race.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/client/typefaster/ui/screens/results.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/client/typefaster/ui/screens/settings.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/client/typefaster/ui/screens/stats.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/client/typefaster/ui/theme.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/client/typefaster/ui/widgets/__init__.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/client/typefaster/ui/widgets/bigtext.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/client/typefaster/ui/widgets/live_stats.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/client/typefaster/ui/widgets/progress_bars.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/client/typefaster/ui/widgets/typing_field.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/docker-compose.yml +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/docs/INSTALL.md +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/docs/RELEASING.md +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/docs/api-spec.md +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/docs/architecture.md +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/docs/deploy-fly.md +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/docs/deploy-oracle.md +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/docs/deployment.md +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/docs/online-setup.md +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/docs/redis-schema.md +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/docs/roadmap.md +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/docs/sqlite-schema.md +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/docs/ui-design.md +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/docs/websocket-protocol.md +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/fly.toml +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/infra/README.md +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/infra/nginx/nginx.conf +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/infra/redis/redis.conf +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/packaging/homebrew/typefaster.rb +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/scripts/deploy-oracle.sh +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/scripts/seed_quotes.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/server/Dockerfile +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/server/app/__init__.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/server/app/config.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/server/app/deps.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/server/app/logging_config.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/server/app/main.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/server/app/quotes.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/server/app/redis_keys.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/server/app/repositories.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/server/app/routers/__init__.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/server/app/routers/auth.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/server/app/routers/health.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/server/app/routers/leaderboards.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/server/app/routers/lobbies.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/server/app/routers/oauth.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/server/app/security.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/server/app/ws/__init__.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/server/app/ws/manager.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/server/pyproject.toml +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/server/tests/__init__.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/server/tests/conftest.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/server/tests/test_auth.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/server/tests/test_health_leaderboards.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/server/tests/test_lobbies.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/server/tests/test_oauth.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/server/tests/test_scoring_anticheat.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/server/tests/test_ws_race.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/shared/pyproject.toml +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/shared/typefaster_shared/__init__.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/shared/typefaster_shared/anti_cheat.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/shared/typefaster_shared/dto.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/shared/typefaster_shared/events.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/shared/typefaster_shared/scoring.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/tests/conftest.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/tests/fixtures/.gitkeep +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/tests/integration/test_container_and_cli.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/tests/integration/test_migrations.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/tests/integration/test_profile_stats.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/tests/integration/test_race_service.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/tests/integration/test_race_timing.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/tests/integration/test_sqlite_repository.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/tests/integration/test_ui_smoke.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/tests/unit/test_anti_cheat.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/tests/unit/test_calculators.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/tests/unit/test_config.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/tests/unit/test_ghost.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/tests/unit/test_net.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/tests/unit/test_quote_loader.py +0 -0
- {typefaster_cli-0.1.4 → typefaster_cli-0.2.0}/tests/unit/test_typing_engine.py +0 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: typefaster-cli
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: A terminal-first typing game inspired by MonkeyType and TypeRacer.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Anoshor/typefaster-cli
|
|
6
|
+
Project-URL: Repository, https://github.com/Anoshor/typefaster-cli
|
|
7
|
+
Author: Anoshor Paul
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: cli,game,monkeytype,terminal,tui,typeracer,typing,wpm
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Topic :: Games/Entertainment
|
|
16
|
+
Requires-Python: >=3.11
|
|
17
|
+
Requires-Dist: httpx>=0.27
|
|
18
|
+
Requires-Dist: platformdirs>=4.2
|
|
19
|
+
Requires-Dist: rich>=13.7
|
|
20
|
+
Requires-Dist: textual>=0.60
|
|
21
|
+
Requires-Dist: typer>=0.12
|
|
22
|
+
Requires-Dist: websockets>=12.0
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: black>=24.0; extra == 'dev'
|
|
25
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
30
|
+
Requires-Dist: textual-dev>=1.5; extra == 'dev'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# ⌨ TYPEFASTER
|
|
34
|
+
|
|
35
|
+
[](https://github.com/Anoshor/typefaster-cli/actions/workflows/ci.yml)
|
|
36
|
+
[](https://pypi.org/project/typefaster-cli/)
|
|
37
|
+
[](https://www.python.org/downloads/)
|
|
38
|
+
[](LICENSE)
|
|
39
|
+
|
|
40
|
+
A **terminal-first** typing game inspired by MonkeyType and TypeRacer — race
|
|
41
|
+
quotes, beat your ghost, climb leaderboards, and play **live multiplayer** with
|
|
42
|
+
friends. Pure TUI, no browser, no GUI.
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
brew install Anoshor/typefaster/typefaster # or: pipx install typefaster-cli
|
|
46
|
+
typefaster
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## Install
|
|
52
|
+
|
|
53
|
+
**Homebrew** (macOS / Linux)
|
|
54
|
+
```bash
|
|
55
|
+
brew install Anoshor/typefaster/typefaster
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
**pipx** (any OS with Python 3.11+) — fastest
|
|
59
|
+
```bash
|
|
60
|
+
pipx install typefaster-cli
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Verify: `typefaster version`
|
|
64
|
+
|
|
65
|
+
## Play (offline — no account, no internet)
|
|
66
|
+
|
|
67
|
+
Just run it:
|
|
68
|
+
```bash
|
|
69
|
+
typefaster
|
|
70
|
+
```
|
|
71
|
+
Keyboard-only menu:
|
|
72
|
+
- **Quick Race** — a fresh random quote each time; race your personal-best ghost.
|
|
73
|
+
- **Time Attack** — type for 30 / 60 / 120s (←/→ to change the duration inline).
|
|
74
|
+
- **Practice** — pick a mode/ghost.
|
|
75
|
+
- **Daily Challenge** — same quote for everyone each day, local leaderboard.
|
|
76
|
+
- **Stats / History / Profile / Leaderboard / Settings**.
|
|
77
|
+
|
|
78
|
+
Live WPM, accuracy, progress, and an animated ghost bar. Backspace corrects
|
|
79
|
+
mistakes (original errors still count, MonkeyType-style). All progress is saved
|
|
80
|
+
locally in SQLite.
|
|
81
|
+
|
|
82
|
+
Direct commands too:
|
|
83
|
+
```bash
|
|
84
|
+
typefaster race # quote race
|
|
85
|
+
typefaster race --mode time --time 60
|
|
86
|
+
typefaster daily
|
|
87
|
+
typefaster stats | typefaster history
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Play online (multiplayer lobbies)
|
|
91
|
+
|
|
92
|
+
It works out of the box against the public server — **no setup**. From the main
|
|
93
|
+
menu pick **Account** to register/login, then **Play Online**:
|
|
94
|
+
|
|
95
|
+
```text
|
|
96
|
+
Account → Register / Login (password, GitHub, or Google)
|
|
97
|
+
Play Online → ➕ Create a lobby → share the join code
|
|
98
|
+
→ or type a friend's code + Enter to join
|
|
99
|
+
```
|
|
100
|
+
In the waiting room press **R** to ready; the **server** runs the countdown,
|
|
101
|
+
sends everyone the same quote, shows live progress bars, and scores results
|
|
102
|
+
authoritatively (with anti-cheat). **Esc** leaves.
|
|
103
|
+
|
|
104
|
+
Prefer the CLI?
|
|
105
|
+
```bash
|
|
106
|
+
typefaster register <name> # or: typefaster login --github / --google
|
|
107
|
+
typefaster lobby create --name "Friday" --time 60
|
|
108
|
+
typefaster lobby join ABC123
|
|
109
|
+
typefaster leaderboard global # global | daily | weekly
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Self-host the server (optional)
|
|
113
|
+
|
|
114
|
+
The game ships pointing at a public server, but you can run your own:
|
|
115
|
+
```bash
|
|
116
|
+
git clone https://github.com/Anoshor/typefaster-cli && cd typefaster-cli
|
|
117
|
+
cp .env.example .env # set TYPEFASTER_JWT_SECRET
|
|
118
|
+
make up # Redis + FastAPI server on :8000 (Docker)
|
|
119
|
+
typefaster config set-server http://localhost:8000
|
|
120
|
+
```
|
|
121
|
+
Deploy guides: [`docs/deploy-oracle.md`](docs/deploy-oracle.md) (free 24/7 VM) ·
|
|
122
|
+
[`docs/deploy-fly.md`](docs/deploy-fly.md) · TLS + hardening in
|
|
123
|
+
[`docs/SECURITY-REVIEW.md`](docs/SECURITY-REVIEW.md).
|
|
124
|
+
|
|
125
|
+
## How it works
|
|
126
|
+
|
|
127
|
+
- **Client**: Python · Typer (CLI) · Textual + Rich (TUI) · SQLite (local
|
|
128
|
+
progress) · httpx + websockets (online).
|
|
129
|
+
- **Server**: FastAPI · WebSockets · Redis · Pydantic — **server-authoritative**
|
|
130
|
+
race timing and scoring.
|
|
131
|
+
- Deep dive: [`docs/DEEPDIVE.md`](docs/DEEPDIVE.md) · architecture:
|
|
132
|
+
[`docs/architecture.md`](docs/architecture.md).
|
|
133
|
+
|
|
134
|
+
## Develop
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
make install # editable install + dev deps
|
|
138
|
+
make play # run it
|
|
139
|
+
make check # ruff + mypy + pytest
|
|
140
|
+
```
|
|
141
|
+
Contributions welcome — see [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
142
|
+
|
|
143
|
+
## License
|
|
144
|
+
|
|
145
|
+
MIT — see [LICENSE](LICENSE). Crafted by **Anoshor Paul**.
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# ⌨ TYPEFASTER
|
|
2
|
+
|
|
3
|
+
[](https://github.com/Anoshor/typefaster-cli/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/typefaster-cli/)
|
|
5
|
+
[](https://www.python.org/downloads/)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
8
|
+
A **terminal-first** typing game inspired by MonkeyType and TypeRacer — race
|
|
9
|
+
quotes, beat your ghost, climb leaderboards, and play **live multiplayer** with
|
|
10
|
+
friends. Pure TUI, no browser, no GUI.
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
brew install Anoshor/typefaster/typefaster # or: pipx install typefaster-cli
|
|
14
|
+
typefaster
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
**Homebrew** (macOS / Linux)
|
|
22
|
+
```bash
|
|
23
|
+
brew install Anoshor/typefaster/typefaster
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
**pipx** (any OS with Python 3.11+) — fastest
|
|
27
|
+
```bash
|
|
28
|
+
pipx install typefaster-cli
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Verify: `typefaster version`
|
|
32
|
+
|
|
33
|
+
## Play (offline — no account, no internet)
|
|
34
|
+
|
|
35
|
+
Just run it:
|
|
36
|
+
```bash
|
|
37
|
+
typefaster
|
|
38
|
+
```
|
|
39
|
+
Keyboard-only menu:
|
|
40
|
+
- **Quick Race** — a fresh random quote each time; race your personal-best ghost.
|
|
41
|
+
- **Time Attack** — type for 30 / 60 / 120s (←/→ to change the duration inline).
|
|
42
|
+
- **Practice** — pick a mode/ghost.
|
|
43
|
+
- **Daily Challenge** — same quote for everyone each day, local leaderboard.
|
|
44
|
+
- **Stats / History / Profile / Leaderboard / Settings**.
|
|
45
|
+
|
|
46
|
+
Live WPM, accuracy, progress, and an animated ghost bar. Backspace corrects
|
|
47
|
+
mistakes (original errors still count, MonkeyType-style). All progress is saved
|
|
48
|
+
locally in SQLite.
|
|
49
|
+
|
|
50
|
+
Direct commands too:
|
|
51
|
+
```bash
|
|
52
|
+
typefaster race # quote race
|
|
53
|
+
typefaster race --mode time --time 60
|
|
54
|
+
typefaster daily
|
|
55
|
+
typefaster stats | typefaster history
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Play online (multiplayer lobbies)
|
|
59
|
+
|
|
60
|
+
It works out of the box against the public server — **no setup**. From the main
|
|
61
|
+
menu pick **Account** to register/login, then **Play Online**:
|
|
62
|
+
|
|
63
|
+
```text
|
|
64
|
+
Account → Register / Login (password, GitHub, or Google)
|
|
65
|
+
Play Online → ➕ Create a lobby → share the join code
|
|
66
|
+
→ or type a friend's code + Enter to join
|
|
67
|
+
```
|
|
68
|
+
In the waiting room press **R** to ready; the **server** runs the countdown,
|
|
69
|
+
sends everyone the same quote, shows live progress bars, and scores results
|
|
70
|
+
authoritatively (with anti-cheat). **Esc** leaves.
|
|
71
|
+
|
|
72
|
+
Prefer the CLI?
|
|
73
|
+
```bash
|
|
74
|
+
typefaster register <name> # or: typefaster login --github / --google
|
|
75
|
+
typefaster lobby create --name "Friday" --time 60
|
|
76
|
+
typefaster lobby join ABC123
|
|
77
|
+
typefaster leaderboard global # global | daily | weekly
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Self-host the server (optional)
|
|
81
|
+
|
|
82
|
+
The game ships pointing at a public server, but you can run your own:
|
|
83
|
+
```bash
|
|
84
|
+
git clone https://github.com/Anoshor/typefaster-cli && cd typefaster-cli
|
|
85
|
+
cp .env.example .env # set TYPEFASTER_JWT_SECRET
|
|
86
|
+
make up # Redis + FastAPI server on :8000 (Docker)
|
|
87
|
+
typefaster config set-server http://localhost:8000
|
|
88
|
+
```
|
|
89
|
+
Deploy guides: [`docs/deploy-oracle.md`](docs/deploy-oracle.md) (free 24/7 VM) ·
|
|
90
|
+
[`docs/deploy-fly.md`](docs/deploy-fly.md) · TLS + hardening in
|
|
91
|
+
[`docs/SECURITY-REVIEW.md`](docs/SECURITY-REVIEW.md).
|
|
92
|
+
|
|
93
|
+
## How it works
|
|
94
|
+
|
|
95
|
+
- **Client**: Python · Typer (CLI) · Textual + Rich (TUI) · SQLite (local
|
|
96
|
+
progress) · httpx + websockets (online).
|
|
97
|
+
- **Server**: FastAPI · WebSockets · Redis · Pydantic — **server-authoritative**
|
|
98
|
+
race timing and scoring.
|
|
99
|
+
- Deep dive: [`docs/DEEPDIVE.md`](docs/DEEPDIVE.md) · architecture:
|
|
100
|
+
[`docs/architecture.md`](docs/architecture.md).
|
|
101
|
+
|
|
102
|
+
## Develop
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
make install # editable install + dev deps
|
|
106
|
+
make play # run it
|
|
107
|
+
make check # ruff + mypy + pytest
|
|
108
|
+
```
|
|
109
|
+
Contributions welcome — see [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
110
|
+
|
|
111
|
+
## License
|
|
112
|
+
|
|
113
|
+
MIT — see [LICENSE](LICENSE). Crafted by **Anoshor Paul**.
|
|
@@ -18,7 +18,7 @@ class Session:
|
|
|
18
18
|
# Default points at the public TYPEFASTER server so a fresh install can
|
|
19
19
|
# register/play with zero config. Override with `typefaster config set-server`
|
|
20
20
|
# (e.g. a local server or your own deployment).
|
|
21
|
-
server_url: str = "
|
|
21
|
+
server_url: str = "https://140.245.248.113.sslip.io"
|
|
22
22
|
token: str | None = None
|
|
23
23
|
username: str | None = None
|
|
24
24
|
|
|
@@ -2,22 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from datetime import date
|
|
5
|
+
from datetime import UTC, date, datetime
|
|
6
6
|
|
|
7
7
|
from ..domain.models import DailyChallenge, RaceRecord
|
|
8
8
|
from ..infra import quote_loader
|
|
9
9
|
from ..infra.repository import Repository
|
|
10
10
|
|
|
11
11
|
|
|
12
|
+
def _utc_today() -> date:
|
|
13
|
+
"""The daily challenge day in UTC.
|
|
14
|
+
|
|
15
|
+
Races are stored with a UTC ``started_at`` and the daily leaderboard filters
|
|
16
|
+
on that, so the day MUST be UTC too — otherwise (e.g. in IST) results land on
|
|
17
|
+
the wrong day and the challenge never updates. UTC also makes the daily
|
|
18
|
+
genuinely 'the same for everyone'.
|
|
19
|
+
"""
|
|
20
|
+
return datetime.now(UTC).date()
|
|
21
|
+
|
|
22
|
+
|
|
12
23
|
class DailyService:
|
|
13
24
|
def __init__(self, repo: Repository) -> None:
|
|
14
25
|
self._repo = repo
|
|
15
26
|
|
|
16
27
|
def today(self, day: date | None = None) -> DailyChallenge:
|
|
17
|
-
day = day or
|
|
28
|
+
day = day or _utc_today()
|
|
18
29
|
quote = quote_loader.daily_quote(day)
|
|
19
30
|
return self._repo.get_or_create_daily(day.isoformat(), quote)
|
|
20
31
|
|
|
21
32
|
def leaderboard(self, day: date | None = None, limit: int = 20) -> list[RaceRecord]:
|
|
22
|
-
day = day or
|
|
33
|
+
day = day or _utc_today()
|
|
23
34
|
return self._repo.daily_leaderboard(day.isoformat(), limit=limit)
|
|
@@ -8,10 +8,12 @@ from ..domain.models import RaceResult
|
|
|
8
8
|
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
|
+
from .screens.account import AccountScreen
|
|
11
12
|
from .screens.daily import DailyScreen
|
|
12
13
|
from .screens.help import HelpScreen
|
|
13
14
|
from .screens.history import HistoryScreen
|
|
14
15
|
from .screens.leaderboard import LeaderboardScreen
|
|
16
|
+
from .screens.lobby_browser import LobbyBrowserScreen
|
|
15
17
|
from .screens.main_menu import MainMenu
|
|
16
18
|
from .screens.practice import PracticeScreen
|
|
17
19
|
from .screens.profile import ProfileScreen
|
|
@@ -30,6 +32,8 @@ _PANELS = {
|
|
|
30
32
|
"leaderboard": LeaderboardScreen,
|
|
31
33
|
"settings": SettingsScreen,
|
|
32
34
|
"help": HelpScreen,
|
|
35
|
+
"account": AccountScreen,
|
|
36
|
+
"online": LobbyBrowserScreen,
|
|
33
37
|
}
|
|
34
38
|
|
|
35
39
|
|
|
@@ -20,11 +20,15 @@ class PanelScreen(Screen[None]):
|
|
|
20
20
|
with Vertical(id="panel-wrap"):
|
|
21
21
|
yield Static(Text(self.title_text, justify="center"), id="title")
|
|
22
22
|
with VerticalScroll():
|
|
23
|
-
yield Static(self.body())
|
|
23
|
+
yield Static(self.body(), id="panel-body")
|
|
24
24
|
yield Static("esc back", classes="dim")
|
|
25
25
|
|
|
26
26
|
def body(self) -> RenderableType: # pragma: no cover - overridden
|
|
27
27
|
return Text("")
|
|
28
28
|
|
|
29
|
+
def on_screen_resume(self) -> None:
|
|
30
|
+
# Re-query when we return here (e.g. after a race) so data is fresh.
|
|
31
|
+
self.query_one("#panel-body", Static).update(self.body())
|
|
32
|
+
|
|
29
33
|
def action_back(self) -> None:
|
|
30
34
|
self.app.pop_screen()
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""In-TUI account screen: register, login (password / GitHub / Google), logout.
|
|
2
|
+
|
|
3
|
+
Actions are an OptionList (not single-letter keys, which would collide with the
|
|
4
|
+
username/password Inputs). Network calls (blocking httpx) run in worker threads
|
|
5
|
+
and post results back via ``call_from_thread``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
import time
|
|
12
|
+
import webbrowser
|
|
13
|
+
|
|
14
|
+
from rich.text import Text
|
|
15
|
+
from textual.app import ComposeResult
|
|
16
|
+
from textual.containers import Vertical
|
|
17
|
+
from textual.screen import Screen
|
|
18
|
+
from textual.widgets import Input, OptionList, Static
|
|
19
|
+
from textual.widgets.option_list import Option
|
|
20
|
+
|
|
21
|
+
from ...net.api import ApiClient, ApiError
|
|
22
|
+
from ...net.token_store import Session
|
|
23
|
+
|
|
24
|
+
_ACTIONS = [
|
|
25
|
+
("register", "Register (username + password)"),
|
|
26
|
+
("login", "Login (username + password)"),
|
|
27
|
+
("github", "Login with GitHub (browser)"),
|
|
28
|
+
("google", "Login with Google (browser)"),
|
|
29
|
+
("setserver", "Set server URL (from field above)"),
|
|
30
|
+
("logout", "Logout"),
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AccountScreen(Screen[None]):
|
|
35
|
+
BINDINGS = [("escape", "back", "Back")]
|
|
36
|
+
|
|
37
|
+
def compose(self) -> ComposeResult:
|
|
38
|
+
with Vertical(id="panel-wrap"):
|
|
39
|
+
yield Static(Text("ACCOUNT", justify="center"), id="title")
|
|
40
|
+
yield Static(self._status_text(), id="acct-status")
|
|
41
|
+
yield Input(placeholder="username", id="user")
|
|
42
|
+
yield Input(placeholder="password", password=True, id="pw")
|
|
43
|
+
yield Input(placeholder="server url", id="server", value=Session.load().server_url)
|
|
44
|
+
yield OptionList(*[Option(label, id=key) for key, label in _ACTIONS])
|
|
45
|
+
yield Static(
|
|
46
|
+
Text("Tab between fields · pick an action below · esc back", justify="center"),
|
|
47
|
+
classes="dim",
|
|
48
|
+
)
|
|
49
|
+
yield Static("", id="acct-msg")
|
|
50
|
+
|
|
51
|
+
def on_mount(self) -> None:
|
|
52
|
+
self.query_one("#user", Input).focus()
|
|
53
|
+
|
|
54
|
+
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
55
|
+
match event.option.id:
|
|
56
|
+
case "register":
|
|
57
|
+
self._auth("register")
|
|
58
|
+
case "login":
|
|
59
|
+
self._auth("login")
|
|
60
|
+
case "github":
|
|
61
|
+
self._oauth("github")
|
|
62
|
+
case "google":
|
|
63
|
+
self._oauth("google")
|
|
64
|
+
case "setserver":
|
|
65
|
+
self._set_server()
|
|
66
|
+
case "logout":
|
|
67
|
+
self._logout()
|
|
68
|
+
|
|
69
|
+
# ── helpers ────────────────────────────────────────────────────────
|
|
70
|
+
def _status_text(self) -> Text:
|
|
71
|
+
s = Session.load()
|
|
72
|
+
who = s.username or "not logged in"
|
|
73
|
+
return Text.from_markup(f"server: [bold]{s.server_url}[/] · [bold cyan]{who}[/]")
|
|
74
|
+
|
|
75
|
+
def _msg(self, markup: str) -> None:
|
|
76
|
+
self.query_one("#acct-msg", Static).update(Text.from_markup(markup))
|
|
77
|
+
|
|
78
|
+
def _refresh_status(self) -> None:
|
|
79
|
+
self.query_one("#acct-status", Static).update(self._status_text())
|
|
80
|
+
|
|
81
|
+
def _field(self, wid: str) -> str:
|
|
82
|
+
return self.query_one(f"#{wid}", Input).value.strip()
|
|
83
|
+
|
|
84
|
+
# ── password auth ──────────────────────────────────────────────────
|
|
85
|
+
def _auth(self, kind: str) -> None:
|
|
86
|
+
user, pw = self._field("user"), self._field("pw")
|
|
87
|
+
if not user or not pw:
|
|
88
|
+
self._msg("[red]Enter a username and password first.[/]")
|
|
89
|
+
return
|
|
90
|
+
self._msg(f"[grey58]{kind.title()}ing…[/]")
|
|
91
|
+
self.run_worker(lambda: self._do_auth(kind, user, pw), thread=True, name="auth")
|
|
92
|
+
|
|
93
|
+
def _do_auth(self, kind: str, user: str, pw: str) -> None:
|
|
94
|
+
session = Session.load()
|
|
95
|
+
client = ApiClient(session)
|
|
96
|
+
try:
|
|
97
|
+
data = client.register(user, pw) if kind == "register" else client.login(user, pw)
|
|
98
|
+
session.token = data["access_token"]
|
|
99
|
+
session.username = data["username"]
|
|
100
|
+
session.save()
|
|
101
|
+
self.app.call_from_thread(self._on_auth_ok, session.username)
|
|
102
|
+
except ApiError as exc:
|
|
103
|
+
self.app.call_from_thread(self._msg, f"[red]{exc.detail}[/]")
|
|
104
|
+
finally:
|
|
105
|
+
client.close()
|
|
106
|
+
|
|
107
|
+
def _on_auth_ok(self, username: str) -> None:
|
|
108
|
+
self._refresh_status()
|
|
109
|
+
self._msg(f"[green]Logged in as[/] [bold]{username}[/]. Press esc, then 'Play Online'.")
|
|
110
|
+
|
|
111
|
+
# ── logout / server ────────────────────────────────────────────────
|
|
112
|
+
def _logout(self) -> None:
|
|
113
|
+
self.run_worker(self._do_logout, thread=True, name="logout")
|
|
114
|
+
|
|
115
|
+
def _do_logout(self) -> None:
|
|
116
|
+
session = Session.load()
|
|
117
|
+
client = ApiClient(session)
|
|
118
|
+
with contextlib.suppress(ApiError):
|
|
119
|
+
if session.logged_in:
|
|
120
|
+
client.logout()
|
|
121
|
+
client.close()
|
|
122
|
+
session.clear()
|
|
123
|
+
self.app.call_from_thread(self._after_logout)
|
|
124
|
+
|
|
125
|
+
def _after_logout(self) -> None:
|
|
126
|
+
self._refresh_status()
|
|
127
|
+
self._msg("[green]Logged out.[/]")
|
|
128
|
+
|
|
129
|
+
def _set_server(self) -> None:
|
|
130
|
+
url = self._field("server")
|
|
131
|
+
if not url:
|
|
132
|
+
return
|
|
133
|
+
session = Session.load()
|
|
134
|
+
if url.rstrip("/") != session.server_url:
|
|
135
|
+
session.token = None
|
|
136
|
+
session.username = None
|
|
137
|
+
session.server_url = url.rstrip("/")
|
|
138
|
+
session.save()
|
|
139
|
+
self._refresh_status()
|
|
140
|
+
self._msg(f"[green]Server set to[/] [bold]{session.server_url}[/]")
|
|
141
|
+
|
|
142
|
+
# ── OAuth device flow ──────────────────────────────────────────────
|
|
143
|
+
def _oauth(self, provider: str) -> None:
|
|
144
|
+
self._msg(f"[grey58]Starting {provider.title()} login…[/]")
|
|
145
|
+
self.run_worker(lambda: self._do_oauth(provider), thread=True, name="oauth")
|
|
146
|
+
|
|
147
|
+
def _do_oauth(self, provider: str) -> None:
|
|
148
|
+
session = Session.load()
|
|
149
|
+
client = ApiClient(session)
|
|
150
|
+
try:
|
|
151
|
+
start = client.oauth_start(provider)
|
|
152
|
+
except ApiError as exc:
|
|
153
|
+
self.app.call_from_thread(
|
|
154
|
+
self._msg, f"[red]{provider.title()} unavailable: {exc.detail}[/]"
|
|
155
|
+
)
|
|
156
|
+
client.close()
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
uri, code, device = start["verification_uri"], start["user_code"], start["device_code"]
|
|
160
|
+
interval = int(start.get("interval", 5))
|
|
161
|
+
deadline = time.time() + int(start.get("expires_in", 900))
|
|
162
|
+
self.app.call_from_thread(
|
|
163
|
+
self._msg,
|
|
164
|
+
f"Open [bold underline]{uri}[/] · enter code [bold cyan]{code}[/] · waiting…",
|
|
165
|
+
)
|
|
166
|
+
with contextlib.suppress(Exception):
|
|
167
|
+
webbrowser.open(uri)
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
while time.time() < deadline:
|
|
171
|
+
time.sleep(interval)
|
|
172
|
+
try:
|
|
173
|
+
r = client.oauth_poll(provider, device)
|
|
174
|
+
except ApiError as exc:
|
|
175
|
+
self.app.call_from_thread(self._msg, f"[red]Login failed: {exc.detail}[/]")
|
|
176
|
+
return
|
|
177
|
+
st = r.get("status")
|
|
178
|
+
if st == "pending":
|
|
179
|
+
continue
|
|
180
|
+
if st == "slow_down":
|
|
181
|
+
interval += 5
|
|
182
|
+
continue
|
|
183
|
+
session.token = r["access_token"]
|
|
184
|
+
session.username = r["username"]
|
|
185
|
+
session.save()
|
|
186
|
+
self.app.call_from_thread(self._on_auth_ok, session.username)
|
|
187
|
+
return
|
|
188
|
+
self.app.call_from_thread(self._msg, "[red]Login timed out.[/]")
|
|
189
|
+
finally:
|
|
190
|
+
client.close()
|
|
191
|
+
|
|
192
|
+
def action_back(self) -> None:
|
|
193
|
+
self.app.pop_screen()
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from datetime import
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
6
|
|
|
7
7
|
from rich.console import Group
|
|
8
8
|
from rich.table import Table
|
|
@@ -23,15 +23,17 @@ class DailyScreen(Screen[None]):
|
|
|
23
23
|
]
|
|
24
24
|
|
|
25
25
|
def compose(self) -> ComposeResult:
|
|
26
|
+
day = datetime.now(UTC).date().isoformat() # UTC — matches how results are filed
|
|
26
27
|
with Vertical(id="panel-wrap"):
|
|
27
|
-
yield Static(
|
|
28
|
-
Text(f"DAILY CHALLENGE · {date.today().isoformat()}", justify="center"),
|
|
29
|
-
id="title",
|
|
30
|
-
)
|
|
28
|
+
yield Static(Text(f"DAILY CHALLENGE · {day} (UTC)", justify="center"), id="title")
|
|
31
29
|
with VerticalScroll():
|
|
32
|
-
yield Static(self._body())
|
|
30
|
+
yield Static(self._body(), id="panel-body")
|
|
33
31
|
yield Static("⏎ play today's challenge esc back", classes="dim")
|
|
34
32
|
|
|
33
|
+
def on_screen_resume(self) -> None:
|
|
34
|
+
# Refresh best/attempts/leaderboard after returning from a race.
|
|
35
|
+
self.query_one("#panel-body", Static).update(self._body())
|
|
36
|
+
|
|
35
37
|
def _body(self) -> Group:
|
|
36
38
|
svc = self.app.services # type: ignore[attr-defined]
|
|
37
39
|
challenge = svc.daily.today()
|