feat(tg-fitness-bot): add telegram fitness bot with web app
Telegram workout tracker bot with Mini App web UI, SQLite database, API server, and cloudflared tunnel support. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7288d93741
commit
ae09ab2eec
14 changed files with 1892 additions and 0 deletions
1
telegram-fitness-bot/.envrc
Normal file
1
telegram-fitness-bot/.envrc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
use flake
|
||||||
19
telegram-fitness-bot/.gitignore
vendored
Normal file
19
telegram-fitness-bot/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
# Nix
|
||||||
|
result
|
||||||
|
result-*
|
||||||
|
.direnv/
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
*.db-wal
|
||||||
|
|
||||||
|
# Secrets — never commit
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Node (localtunnel cache)
|
||||||
|
node_modules/
|
||||||
108
telegram-fitness-bot/README.md
Normal file
108
telegram-fitness-bot/README.md
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
# Telegram Fitness Bot — Mini App
|
||||||
|
|
||||||
|
A Telegram bot + Mini App for tracking gym workouts. Log exercises, sets, reps, and weight right inside Telegram.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Get a bot token
|
||||||
|
|
||||||
|
Message [@BotFather](https://t.me/BotFather) on Telegram and create a new bot.
|
||||||
|
|
||||||
|
### 2. Create a `.env` file
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo 'BOT_TOKEN=your-token-here' > .env
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nix run
|
||||||
|
```
|
||||||
|
|
||||||
|
That's it. The app will:
|
||||||
|
- Load your `BOT_TOKEN` from `.env`
|
||||||
|
- Start the API server on port 8080
|
||||||
|
- Open a localtunnel to get a public HTTPS URL
|
||||||
|
- Start the bot with that URL wired in
|
||||||
|
- Create `fitness.db` in the current directory
|
||||||
|
|
||||||
|
Open your bot in Telegram and tap the **Workout** menu button.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌───────────────────────┐ ┌─────────────────────────┐
|
||||||
|
│ Telegram Bot │ │ API Server (aiohttp) │
|
||||||
|
│ (python-telegram- │ │ │
|
||||||
|
│ bot, polling) │ │ GET/POST /api/* │
|
||||||
|
│ │ │ Static /webapp/* │
|
||||||
|
│ /start /workout │ │ │
|
||||||
|
│ /finish /history │ │ ← Telegram initData │
|
||||||
|
└───────────┬───────────┘ │ validation (HMAC) │
|
||||||
|
│ └────────────┬────────────┘
|
||||||
|
│ │
|
||||||
|
└──────────┬───────────────────┘
|
||||||
|
│
|
||||||
|
┌──────┴──────┐
|
||||||
|
│ SQLite │
|
||||||
|
│ fitness.db │
|
||||||
|
└─────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
├── flake.nix # Nix flake — `nix run` entry point
|
||||||
|
├── start.py # Orchestrator: loads .env, starts server + tunnel + bot
|
||||||
|
├── bot.py # Telegram bot (commands, reminders)
|
||||||
|
├── server.py # aiohttp API + static file server
|
||||||
|
├── database.py # SQLite data layer
|
||||||
|
├── config.py # Environment-based config
|
||||||
|
├── .env # Your BOT_TOKEN (not committed)
|
||||||
|
├── .envrc # direnv — auto-activates nix develop
|
||||||
|
└── webapp/
|
||||||
|
├── index.html # Mini App entry point
|
||||||
|
├── style.css # Telegram-native themed styles
|
||||||
|
└── app.js # Frontend logic
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Enter dev shell with all dependencies
|
||||||
|
nix develop
|
||||||
|
|
||||||
|
# Or with direnv
|
||||||
|
direnv allow
|
||||||
|
|
||||||
|
# Run directly
|
||||||
|
python start.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `BOT_TOKEN` | — | Telegram bot token (required, loaded from `.env`) |
|
||||||
|
| `API_PORT` | `8080` | API server port |
|
||||||
|
| `DB_PATH` | `./fitness.db` | SQLite database file path |
|
||||||
|
|
||||||
|
`WEBAPP_URL` is set automatically by the localtunnel — you never need to touch it.
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/api/exercises` | List user's exercises |
|
||||||
|
| POST | `/api/exercises` | Create exercise `{name}` |
|
||||||
|
| DELETE | `/api/exercises/:id` | Delete exercise |
|
||||||
|
| GET | `/api/workouts` | Recent workouts with summaries |
|
||||||
|
| GET | `/api/workouts/active` | Current active workout |
|
||||||
|
| POST | `/api/workouts` | Start new workout |
|
||||||
|
| POST | `/api/workouts/:id/finish` | Finish workout |
|
||||||
|
| GET | `/api/workouts/:id/sets` | Sets in a workout |
|
||||||
|
| POST | `/api/workouts/:id/sets` | Log a set `{exercise_id, reps, weight}` |
|
||||||
|
| DELETE | `/api/sets/:id` | Delete a set |
|
||||||
|
|
||||||
|
All API requests require the `X-Telegram-Init-Data` header for authentication.
|
||||||
198
telegram-fitness-bot/bot.py
Normal file
198
telegram-fitness-bot/bot.py
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
"""
|
||||||
|
Telegram Fitness Bot — handles chat commands, reminders, and launches the Mini App.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from telegram import (
|
||||||
|
Update,
|
||||||
|
WebAppInfo,
|
||||||
|
InlineKeyboardButton,
|
||||||
|
InlineKeyboardMarkup,
|
||||||
|
MenuButtonWebApp,
|
||||||
|
)
|
||||||
|
from telegram.ext import (
|
||||||
|
Application,
|
||||||
|
CommandHandler,
|
||||||
|
ContextTypes,
|
||||||
|
MessageHandler,
|
||||||
|
filters,
|
||||||
|
)
|
||||||
|
|
||||||
|
import database as db
|
||||||
|
from config import BOT_TOKEN, WEBAPP_URL
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||||
|
level=logging.INFO,
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def ensure_user(update: Update) -> dict:
|
||||||
|
"""Create or update the user record from the Telegram message."""
|
||||||
|
tg_user = update.effective_user
|
||||||
|
return db.upsert_user(
|
||||||
|
telegram_id=tg_user.id,
|
||||||
|
first_name=tg_user.first_name or "",
|
||||||
|
username=tg_user.username or "",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def format_summary(summary: dict) -> str:
|
||||||
|
"""Format a workout summary dict into a nice chat message."""
|
||||||
|
if not summary:
|
||||||
|
return "No workout data found."
|
||||||
|
|
||||||
|
lines = [f"*Workout Summary*"]
|
||||||
|
lines.append(f"Started: {summary['started_at']}")
|
||||||
|
if summary.get("finished_at"):
|
||||||
|
lines.append(f"Finished: {summary['finished_at']}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
for exercise_name, sets in summary["exercises"].items():
|
||||||
|
lines.append(f"*{exercise_name}*")
|
||||||
|
for i, s in enumerate(sets, 1):
|
||||||
|
lines.append(f" Set {i}: {s['reps']} reps × {s['weight']} kg")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append(f"Total sets: {summary['total_sets']}")
|
||||||
|
lines.append(f"Total volume: {summary['total_volume']} kg")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Command handlers ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def cmd_start(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Greet the user and show the Mini App button."""
|
||||||
|
ensure_user(update)
|
||||||
|
webapp_btn = InlineKeyboardButton(
|
||||||
|
text="Open Workout Tracker",
|
||||||
|
web_app=WebAppInfo(url=WEBAPP_URL),
|
||||||
|
)
|
||||||
|
keyboard = InlineKeyboardMarkup([[webapp_btn]])
|
||||||
|
|
||||||
|
await update.message.reply_text(
|
||||||
|
"Hey! I'm your fitness tracker bot.\n\n"
|
||||||
|
"Tap the button below to open the workout logger, "
|
||||||
|
"or use these commands:\n"
|
||||||
|
"/workout — start a new workout via chat\n"
|
||||||
|
"/history — see your recent workouts\n"
|
||||||
|
"/help — list all commands",
|
||||||
|
reply_markup=keyboard,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_help(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||||
|
await update.message.reply_text(
|
||||||
|
"*Commands*\n"
|
||||||
|
"/start — show the Mini App button\n"
|
||||||
|
"/workout — quick-start a new workout\n"
|
||||||
|
"/finish — finish the current workout\n"
|
||||||
|
"/history — recent workout summaries\n"
|
||||||
|
"/help — this message",
|
||||||
|
parse_mode="Markdown",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_workout(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Start a new workout from chat."""
|
||||||
|
user = ensure_user(update)
|
||||||
|
active = db.get_active_workout(user["telegram_id"])
|
||||||
|
if active:
|
||||||
|
await update.message.reply_text(
|
||||||
|
"You already have an active workout! "
|
||||||
|
"Use /finish to end it, or open the Mini App to keep logging."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
workout = db.start_workout(user["telegram_id"])
|
||||||
|
webapp_btn = InlineKeyboardButton(
|
||||||
|
text="Log Sets",
|
||||||
|
web_app=WebAppInfo(url=WEBAPP_URL),
|
||||||
|
)
|
||||||
|
keyboard = InlineKeyboardMarkup([[webapp_btn]])
|
||||||
|
await update.message.reply_text(
|
||||||
|
f"Workout #{workout['id']} started! Open the app to log your sets.",
|
||||||
|
reply_markup=keyboard,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_finish(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Finish the active workout and send a summary."""
|
||||||
|
user = ensure_user(update)
|
||||||
|
active = db.get_active_workout(user["telegram_id"])
|
||||||
|
if not active:
|
||||||
|
await update.message.reply_text("No active workout to finish.")
|
||||||
|
return
|
||||||
|
|
||||||
|
db.finish_workout(active["id"], user["telegram_id"])
|
||||||
|
summary = db.get_workout_summary(active["id"])
|
||||||
|
text = format_summary(summary)
|
||||||
|
await update.message.reply_text(text, parse_mode="Markdown")
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_history(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Show recent workout summaries."""
|
||||||
|
user = ensure_user(update)
|
||||||
|
workouts = db.get_recent_workouts(user["telegram_id"], limit=5)
|
||||||
|
if not workouts:
|
||||||
|
await update.message.reply_text("No workouts yet! Tap /workout to start one.")
|
||||||
|
return
|
||||||
|
|
||||||
|
for w in workouts:
|
||||||
|
summary = db.get_workout_summary(w["id"])
|
||||||
|
text = format_summary(summary)
|
||||||
|
await update.message.reply_text(text, parse_mode="Markdown")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Web App data handler ────────────────────────────────────────
|
||||||
|
|
||||||
|
async def handle_web_app_data(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Handle data sent from the Mini App via Telegram.WebApp.sendData()."""
|
||||||
|
data = update.effective_message.web_app_data.data
|
||||||
|
logger.info("Received web app data: %s", data)
|
||||||
|
await update.message.reply_text("Got it! Your workout has been saved.")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Post-init: set the menu button ──────────────────────────────
|
||||||
|
|
||||||
|
async def post_init(app: Application):
|
||||||
|
"""Set the bot's menu button to open the Mini App."""
|
||||||
|
await app.bot.set_chat_menu_button(
|
||||||
|
menu_button=MenuButtonWebApp(
|
||||||
|
text="Workout",
|
||||||
|
web_app=WebAppInfo(url=WEBAPP_URL),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
logger.info("Menu button set to Mini App at %s", WEBAPP_URL)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Main ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def main():
|
||||||
|
db.init_db()
|
||||||
|
|
||||||
|
app = (
|
||||||
|
Application.builder()
|
||||||
|
.token(BOT_TOKEN)
|
||||||
|
.post_init(post_init)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_handler(CommandHandler("start", cmd_start))
|
||||||
|
app.add_handler(CommandHandler("help", cmd_help))
|
||||||
|
app.add_handler(CommandHandler("workout", cmd_workout))
|
||||||
|
app.add_handler(CommandHandler("finish", cmd_finish))
|
||||||
|
app.add_handler(CommandHandler("history", cmd_history))
|
||||||
|
app.add_handler(
|
||||||
|
MessageHandler(filters.StatusUpdate.WEB_APP_DATA, handle_web_app_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Bot starting...")
|
||||||
|
app.run_polling(drop_pending_updates=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
14
telegram-fitness-bot/config.py
Normal file
14
telegram-fitness-bot/config.py
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Telegram Bot Token — loaded from .env automatically by start.py
|
||||||
|
BOT_TOKEN = os.environ.get("BOT_TOKEN", "")
|
||||||
|
|
||||||
|
# Public HTTPS URL — set automatically by start.py via localtunnel
|
||||||
|
WEBAPP_URL = os.environ.get("WEBAPP_URL", "http://localhost:8080")
|
||||||
|
|
||||||
|
# API server settings
|
||||||
|
API_HOST = os.environ.get("API_HOST", "0.0.0.0")
|
||||||
|
API_PORT = int(os.environ.get("API_PORT", "8080"))
|
||||||
|
|
||||||
|
# Database path — defaults to fitness.db in the working directory
|
||||||
|
DB_PATH = os.environ.get("DB_PATH", "fitness.db")
|
||||||
245
telegram-fitness-bot/database.py
Normal file
245
telegram-fitness-bot/database.py
Normal file
|
|
@ -0,0 +1,245 @@
|
||||||
|
import sqlite3
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
from config import DB_PATH
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def get_db():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("PRAGMA foreign_keys=ON")
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
with get_db() as db:
|
||||||
|
db.executescript("""
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
telegram_id INTEGER UNIQUE NOT NULL,
|
||||||
|
first_name TEXT NOT NULL DEFAULT '',
|
||||||
|
username TEXT DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS exercises (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(telegram_id),
|
||||||
|
UNIQUE(user_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS workouts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
started_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
finished_at TEXT,
|
||||||
|
notes TEXT DEFAULT '',
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(telegram_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sets (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
workout_id INTEGER NOT NULL,
|
||||||
|
exercise_id INTEGER NOT NULL,
|
||||||
|
set_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
reps INTEGER NOT NULL,
|
||||||
|
weight REAL NOT NULL,
|
||||||
|
logged_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (workout_id) REFERENCES workouts(id),
|
||||||
|
FOREIGN KEY (exercise_id) REFERENCES exercises(id)
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
# ── User operations ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def upsert_user(telegram_id: int, first_name: str, username: str = "") -> dict:
|
||||||
|
with get_db() as db:
|
||||||
|
db.execute(
|
||||||
|
"""INSERT INTO users (telegram_id, first_name, username)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(telegram_id) DO UPDATE SET
|
||||||
|
first_name = excluded.first_name,
|
||||||
|
username = excluded.username""",
|
||||||
|
(telegram_id, first_name, username),
|
||||||
|
)
|
||||||
|
row = db.execute(
|
||||||
|
"SELECT * FROM users WHERE telegram_id = ?", (telegram_id,)
|
||||||
|
).fetchone()
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Exercise operations ──────────────────────────────────────────
|
||||||
|
|
||||||
|
def add_exercise(user_id: int, name: str) -> dict:
|
||||||
|
with get_db() as db:
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO exercises (user_id, name) VALUES (?, ?)",
|
||||||
|
(user_id, name.strip()),
|
||||||
|
)
|
||||||
|
row = db.execute(
|
||||||
|
"SELECT * FROM exercises WHERE user_id = ? AND name = ?",
|
||||||
|
(user_id, name.strip()),
|
||||||
|
).fetchone()
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
def get_exercises(user_id: int) -> list[dict]:
|
||||||
|
with get_db() as db:
|
||||||
|
rows = db.execute(
|
||||||
|
"SELECT * FROM exercises WHERE user_id = ? ORDER BY name",
|
||||||
|
(user_id,),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def delete_exercise(user_id: int, exercise_id: int) -> bool:
|
||||||
|
with get_db() as db:
|
||||||
|
cur = db.execute(
|
||||||
|
"DELETE FROM exercises WHERE id = ? AND user_id = ?",
|
||||||
|
(exercise_id, user_id),
|
||||||
|
)
|
||||||
|
return cur.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
# ── Workout operations ───────────────────────────────────────────
|
||||||
|
|
||||||
|
def start_workout(user_id: int) -> dict:
|
||||||
|
with get_db() as db:
|
||||||
|
cur = db.execute(
|
||||||
|
"INSERT INTO workouts (user_id) VALUES (?)", (user_id,)
|
||||||
|
)
|
||||||
|
row = db.execute(
|
||||||
|
"SELECT * FROM workouts WHERE id = ?", (cur.lastrowid,)
|
||||||
|
).fetchone()
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
def finish_workout(workout_id: int, user_id: int) -> dict | None:
|
||||||
|
with get_db() as db:
|
||||||
|
db.execute(
|
||||||
|
"""UPDATE workouts SET finished_at = datetime('now')
|
||||||
|
WHERE id = ? AND user_id = ?""",
|
||||||
|
(workout_id, user_id),
|
||||||
|
)
|
||||||
|
row = db.execute(
|
||||||
|
"SELECT * FROM workouts WHERE id = ?", (workout_id,)
|
||||||
|
).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def get_active_workout(user_id: int) -> dict | None:
|
||||||
|
with get_db() as db:
|
||||||
|
row = db.execute(
|
||||||
|
"""SELECT * FROM workouts
|
||||||
|
WHERE user_id = ? AND finished_at IS NULL
|
||||||
|
ORDER BY started_at DESC LIMIT 1""",
|
||||||
|
(user_id,),
|
||||||
|
).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def get_recent_workouts(user_id: int, limit: int = 10) -> list[dict]:
|
||||||
|
with get_db() as db:
|
||||||
|
rows = db.execute(
|
||||||
|
"""SELECT * FROM workouts
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY started_at DESC LIMIT ?""",
|
||||||
|
(user_id, limit),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Set operations ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
def add_set(workout_id: int, exercise_id: int, reps: int, weight: float) -> dict:
|
||||||
|
with get_db() as db:
|
||||||
|
# figure out next set_order for this exercise in this workout
|
||||||
|
row = db.execute(
|
||||||
|
"""SELECT COALESCE(MAX(set_order), 0) + 1 AS next_order
|
||||||
|
FROM sets WHERE workout_id = ? AND exercise_id = ?""",
|
||||||
|
(workout_id, exercise_id),
|
||||||
|
).fetchone()
|
||||||
|
next_order = row["next_order"]
|
||||||
|
|
||||||
|
cur = db.execute(
|
||||||
|
"""INSERT INTO sets (workout_id, exercise_id, set_order, reps, weight)
|
||||||
|
VALUES (?, ?, ?, ?, ?)""",
|
||||||
|
(workout_id, exercise_id, next_order, reps, weight),
|
||||||
|
)
|
||||||
|
new_row = db.execute(
|
||||||
|
"SELECT * FROM sets WHERE id = ?", (cur.lastrowid,)
|
||||||
|
).fetchone()
|
||||||
|
return dict(new_row)
|
||||||
|
|
||||||
|
|
||||||
|
def get_workout_sets(workout_id: int) -> list[dict]:
|
||||||
|
with get_db() as db:
|
||||||
|
rows = db.execute(
|
||||||
|
"""SELECT s.*, e.name AS exercise_name
|
||||||
|
FROM sets s
|
||||||
|
JOIN exercises e ON e.id = s.exercise_id
|
||||||
|
WHERE s.workout_id = ?
|
||||||
|
ORDER BY s.exercise_id, s.set_order""",
|
||||||
|
(workout_id,),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def delete_set(set_id: int) -> bool:
|
||||||
|
with get_db() as db:
|
||||||
|
cur = db.execute("DELETE FROM sets WHERE id = ?", (set_id,))
|
||||||
|
return cur.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
# ── Summary helpers ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_workout_summary(workout_id: int) -> dict:
|
||||||
|
"""Return a human-friendly summary of a finished workout."""
|
||||||
|
with get_db() as db:
|
||||||
|
workout = db.execute(
|
||||||
|
"SELECT * FROM workouts WHERE id = ?", (workout_id,)
|
||||||
|
).fetchone()
|
||||||
|
if not workout:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
sets = db.execute(
|
||||||
|
"""SELECT e.name, s.reps, s.weight, s.set_order
|
||||||
|
FROM sets s JOIN exercises e ON e.id = s.exercise_id
|
||||||
|
WHERE s.workout_id = ?
|
||||||
|
ORDER BY s.exercise_id, s.set_order""",
|
||||||
|
(workout_id,),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
exercises = {}
|
||||||
|
for s in sets:
|
||||||
|
name = s["name"]
|
||||||
|
if name not in exercises:
|
||||||
|
exercises[name] = []
|
||||||
|
exercises[name].append({"reps": s["reps"], "weight": s["weight"]})
|
||||||
|
|
||||||
|
total_sets = len(sets)
|
||||||
|
total_volume = sum(s["reps"] * s["weight"] for s in sets)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"workout_id": workout_id,
|
||||||
|
"started_at": workout["started_at"],
|
||||||
|
"finished_at": workout["finished_at"],
|
||||||
|
"exercises": exercises,
|
||||||
|
"total_sets": total_sets,
|
||||||
|
"total_volume": round(total_volume, 1),
|
||||||
|
}
|
||||||
87
telegram-fitness-bot/flake.nix
Normal file
87
telegram-fitness-bot/flake.nix
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
{
|
||||||
|
description = "Telegram Fitness Bot — Mini App for tracking gym workouts";
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs = { self, nixpkgs, flake-utils }:
|
||||||
|
flake-utils.lib.eachDefaultSystem (system:
|
||||||
|
let
|
||||||
|
pkgs = nixpkgs.legacyPackages.${system};
|
||||||
|
|
||||||
|
python = pkgs.python3;
|
||||||
|
|
||||||
|
pythonEnv = python.withPackages (ps: with ps; [
|
||||||
|
python-telegram-bot
|
||||||
|
aiohttp
|
||||||
|
]);
|
||||||
|
|
||||||
|
# localtunnel via npm
|
||||||
|
localtunnel = pkgs.buildNpmPackage {
|
||||||
|
pname = "localtunnel";
|
||||||
|
version = "2.0.2";
|
||||||
|
src = pkgs.fetchFromGitHub {
|
||||||
|
owner = "localtunnel";
|
||||||
|
repo = "localtunnel";
|
||||||
|
rev = "v2.0.2";
|
||||||
|
hash = "sha256-deKDwCjGT+0YjeW/AM2J6IH+hEoQrESmKKM23n0JLWY=";
|
||||||
|
};
|
||||||
|
npmDepsHash = "sha256-R9FYkEe93oGF+dR7i1MxwzEW3EM3SasH/B6LLC2CNXM=";
|
||||||
|
dontNpmBuild = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
runtimePath = pkgs.lib.makeBinPath [
|
||||||
|
pythonEnv
|
||||||
|
localtunnel
|
||||||
|
];
|
||||||
|
|
||||||
|
in {
|
||||||
|
packages.default = pkgs.stdenv.mkDerivation {
|
||||||
|
pname = "telegram-fitness-bot";
|
||||||
|
version = "0.1.0";
|
||||||
|
|
||||||
|
src = pkgs.lib.cleanSource ./.;
|
||||||
|
|
||||||
|
nativeBuildInputs = [ pkgs.makeWrapper ];
|
||||||
|
|
||||||
|
installPhase = ''
|
||||||
|
mkdir -p $out/lib/telegram-fitness-bot
|
||||||
|
cp -r start.py bot.py server.py database.py config.py webapp $out/lib/telegram-fitness-bot/
|
||||||
|
|
||||||
|
mkdir -p $out/bin
|
||||||
|
|
||||||
|
# Main entry point: `nix run` → start.py
|
||||||
|
makeWrapper ${pythonEnv}/bin/python $out/bin/telegram-fitness-bot \
|
||||||
|
--prefix PATH : ${runtimePath} \
|
||||||
|
--add-flags "$out/lib/telegram-fitness-bot/start.py"
|
||||||
|
'';
|
||||||
|
|
||||||
|
meta = with pkgs.lib; {
|
||||||
|
description = "Telegram Mini App for tracking gym workouts";
|
||||||
|
license = licenses.mit;
|
||||||
|
mainProgram = "telegram-fitness-bot";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# `nix develop` — interactive dev shell
|
||||||
|
devShells.default = pkgs.mkShell {
|
||||||
|
packages = [
|
||||||
|
pythonEnv
|
||||||
|
localtunnel
|
||||||
|
];
|
||||||
|
|
||||||
|
shellHook = ''
|
||||||
|
echo ""
|
||||||
|
echo " Fitness Bot dev shell"
|
||||||
|
echo " python: $(python --version)"
|
||||||
|
echo " lt: $(lt --version)"
|
||||||
|
echo ""
|
||||||
|
echo " Run: python start.py"
|
||||||
|
echo ""
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
2
telegram-fitness-bot/requirements.txt
Normal file
2
telegram-fitness-bot/requirements.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
python-telegram-bot>=21.0
|
||||||
|
aiohttp>=3.9
|
||||||
6
telegram-fitness-bot/run.sh
Executable file
6
telegram-fitness-bot/run.sh
Executable file
|
|
@ -0,0 +1,6 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Convenience wrapper for running without nix.
|
||||||
|
# With nix, just use: nix run
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "${BASH_SOURCE[0]}")"
|
||||||
|
exec python start.py "$@"
|
||||||
247
telegram-fitness-bot/server.py
Normal file
247
telegram-fitness-bot/server.py
Normal file
|
|
@ -0,0 +1,247 @@
|
||||||
|
"""
|
||||||
|
API + static file server for the Telegram Mini App.
|
||||||
|
Run alongside bot.py — serves the webapp/ folder and REST endpoints.
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from urllib.parse import parse_qs
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
import database as db
|
||||||
|
from config import BOT_TOKEN, API_HOST, API_PORT
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||||
|
level=logging.INFO,
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Telegram initData validation ─────────────────────────────────
|
||||||
|
|
||||||
|
def validate_init_data(init_data: str) -> dict | None:
|
||||||
|
"""
|
||||||
|
Validate the Telegram WebApp initData string.
|
||||||
|
Returns the parsed user dict if valid, None otherwise.
|
||||||
|
See: https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app
|
||||||
|
"""
|
||||||
|
if not init_data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
parsed = parse_qs(init_data, keep_blank_values=True)
|
||||||
|
received_hash = parsed.get("hash", [None])[0]
|
||||||
|
if not received_hash:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Build the data-check-string: sorted key=value pairs, excluding "hash"
|
||||||
|
data_pairs = []
|
||||||
|
for key, values in parsed.items():
|
||||||
|
if key == "hash":
|
||||||
|
continue
|
||||||
|
data_pairs.append(f"{key}={values[0]}")
|
||||||
|
data_pairs.sort()
|
||||||
|
data_check_string = "\n".join(data_pairs)
|
||||||
|
|
||||||
|
# HMAC-SHA256 with secret = HMAC-SHA256("WebAppData", bot_token)
|
||||||
|
secret_key = hmac.new(
|
||||||
|
b"WebAppData", BOT_TOKEN.encode(), hashlib.sha256
|
||||||
|
).digest()
|
||||||
|
computed_hash = hmac.new(
|
||||||
|
secret_key, data_check_string.encode(), hashlib.sha256
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
if not hmac.compare_digest(computed_hash, received_hash):
|
||||||
|
logger.warning("Invalid initData hash")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Parse the user JSON
|
||||||
|
user_json = parsed.get("user", [None])[0]
|
||||||
|
if not user_json:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
user = json.loads(user_json)
|
||||||
|
return user
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Auth middleware ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_user_id(request: web.Request) -> int | None:
|
||||||
|
"""Extract and validate the user from the request headers."""
|
||||||
|
init_data = request.headers.get("X-Telegram-Init-Data", "")
|
||||||
|
|
||||||
|
# In production, always validate. For local dev, allow a fallback.
|
||||||
|
user = validate_init_data(init_data)
|
||||||
|
if user:
|
||||||
|
# Upsert user record
|
||||||
|
db.upsert_user(
|
||||||
|
telegram_id=user["id"],
|
||||||
|
first_name=user.get("first_name", ""),
|
||||||
|
username=user.get("username", ""),
|
||||||
|
)
|
||||||
|
return user["id"]
|
||||||
|
|
||||||
|
# DEV FALLBACK: if token is placeholder, allow X-Dev-User-Id header
|
||||||
|
if BOT_TOKEN == "YOUR_BOT_TOKEN_HERE":
|
||||||
|
dev_id = request.headers.get("X-Dev-User-Id")
|
||||||
|
if dev_id:
|
||||||
|
return int(dev_id)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def require_auth(handler):
|
||||||
|
"""Decorator that rejects unauthenticated requests."""
|
||||||
|
async def wrapper(request: web.Request):
|
||||||
|
user_id = get_user_id(request)
|
||||||
|
if not user_id:
|
||||||
|
return web.json_response({"error": "Unauthorized"}, status=401)
|
||||||
|
request["user_id"] = user_id
|
||||||
|
return await handler(request)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
# ── API Routes ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Exercises
|
||||||
|
|
||||||
|
@require_auth
|
||||||
|
async def get_exercises(request: web.Request):
|
||||||
|
exercises = db.get_exercises(request["user_id"])
|
||||||
|
return web.json_response({"exercises": exercises})
|
||||||
|
|
||||||
|
|
||||||
|
@require_auth
|
||||||
|
async def create_exercise(request: web.Request):
|
||||||
|
body = await request.json()
|
||||||
|
name = body.get("name", "").strip()
|
||||||
|
if not name:
|
||||||
|
return web.json_response({"error": "Name is required"}, status=400)
|
||||||
|
try:
|
||||||
|
exercise = db.add_exercise(request["user_id"], name)
|
||||||
|
return web.json_response({"exercise": exercise}, status=201)
|
||||||
|
except Exception as e:
|
||||||
|
return web.json_response({"error": str(e)}, status=400)
|
||||||
|
|
||||||
|
|
||||||
|
@require_auth
|
||||||
|
async def delete_exercise(request: web.Request):
|
||||||
|
exercise_id = int(request.match_info["id"])
|
||||||
|
ok = db.delete_exercise(request["user_id"], exercise_id)
|
||||||
|
if not ok:
|
||||||
|
return web.json_response({"error": "Not found"}, status=404)
|
||||||
|
return web.json_response({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
# Workouts
|
||||||
|
|
||||||
|
@require_auth
|
||||||
|
async def get_workouts(request: web.Request):
|
||||||
|
workouts = db.get_recent_workouts(request["user_id"])
|
||||||
|
# Attach summaries
|
||||||
|
result = []
|
||||||
|
for w in workouts:
|
||||||
|
w["summary"] = db.get_workout_summary(w["id"])
|
||||||
|
result.append(w)
|
||||||
|
return web.json_response({"workouts": result})
|
||||||
|
|
||||||
|
|
||||||
|
@require_auth
|
||||||
|
async def get_active_workout(request: web.Request):
|
||||||
|
workout = db.get_active_workout(request["user_id"])
|
||||||
|
return web.json_response({"workout": workout})
|
||||||
|
|
||||||
|
|
||||||
|
@require_auth
|
||||||
|
async def create_workout(request: web.Request):
|
||||||
|
# Check if there's already an active one
|
||||||
|
active = db.get_active_workout(request["user_id"])
|
||||||
|
if active:
|
||||||
|
return web.json_response({"workout": active})
|
||||||
|
workout = db.start_workout(request["user_id"])
|
||||||
|
return web.json_response({"workout": workout}, status=201)
|
||||||
|
|
||||||
|
|
||||||
|
@require_auth
|
||||||
|
async def finish_workout(request: web.Request):
|
||||||
|
workout_id = int(request.match_info["id"])
|
||||||
|
workout = db.finish_workout(workout_id, request["user_id"])
|
||||||
|
if not workout:
|
||||||
|
return web.json_response({"error": "Not found"}, status=404)
|
||||||
|
return web.json_response({"workout": workout})
|
||||||
|
|
||||||
|
|
||||||
|
# Sets
|
||||||
|
|
||||||
|
@require_auth
|
||||||
|
async def get_workout_sets(request: web.Request):
|
||||||
|
workout_id = int(request.match_info["id"])
|
||||||
|
sets = db.get_workout_sets(workout_id)
|
||||||
|
return web.json_response({"sets": sets})
|
||||||
|
|
||||||
|
|
||||||
|
@require_auth
|
||||||
|
async def create_set(request: web.Request):
|
||||||
|
workout_id = int(request.match_info["id"])
|
||||||
|
body = await request.json()
|
||||||
|
|
||||||
|
exercise_id = body.get("exercise_id")
|
||||||
|
reps = body.get("reps")
|
||||||
|
weight = body.get("weight", 0)
|
||||||
|
|
||||||
|
if not exercise_id or not reps:
|
||||||
|
return web.json_response(
|
||||||
|
{"error": "exercise_id and reps are required"}, status=400
|
||||||
|
)
|
||||||
|
|
||||||
|
new_set = db.add_set(workout_id, exercise_id, int(reps), float(weight))
|
||||||
|
return web.json_response({"set": new_set}, status=201)
|
||||||
|
|
||||||
|
|
||||||
|
@require_auth
|
||||||
|
async def delete_set(request: web.Request):
|
||||||
|
set_id = int(request.match_info["id"])
|
||||||
|
ok = db.delete_set(set_id)
|
||||||
|
if not ok:
|
||||||
|
return web.json_response({"error": "Not found"}, status=404)
|
||||||
|
return web.json_response({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
# ── App setup ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def create_app() -> web.Application:
|
||||||
|
db.init_db()
|
||||||
|
|
||||||
|
app = web.Application()
|
||||||
|
|
||||||
|
# API routes
|
||||||
|
app.router.add_get("/api/exercises", get_exercises)
|
||||||
|
app.router.add_post("/api/exercises", create_exercise)
|
||||||
|
app.router.add_delete("/api/exercises/{id}", delete_exercise)
|
||||||
|
|
||||||
|
app.router.add_get("/api/workouts", get_workouts)
|
||||||
|
app.router.add_get("/api/workouts/active", get_active_workout)
|
||||||
|
app.router.add_post("/api/workouts", create_workout)
|
||||||
|
app.router.add_post("/api/workouts/{id}/finish", finish_workout)
|
||||||
|
|
||||||
|
app.router.add_get("/api/workouts/{id}/sets", get_workout_sets)
|
||||||
|
app.router.add_post("/api/workouts/{id}/sets", create_set)
|
||||||
|
app.router.add_delete("/api/sets/{id}", delete_set)
|
||||||
|
|
||||||
|
# Serve the webapp/ folder for the Mini App
|
||||||
|
import pathlib
|
||||||
|
webapp_dir = pathlib.Path(__file__).parent / "webapp"
|
||||||
|
app.router.add_static("/", webapp_dir, show_index=True)
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = create_app()
|
||||||
|
logger.info("Server starting on %s:%s", API_HOST, API_PORT)
|
||||||
|
web.run_app(app, host=API_HOST, port=API_PORT)
|
||||||
188
telegram-fitness-bot/start.py
Normal file
188
telegram-fitness-bot/start.py
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Orchestrator — the single entry point for `nix run`.
|
||||||
|
1. Loads BOT_TOKEN from .env in the current directory
|
||||||
|
2. Starts the API server
|
||||||
|
3. Starts a localtunnel to get a public HTTPS URL
|
||||||
|
4. Starts the Telegram bot with that URL
|
||||||
|
5. Cleans up everything on Ctrl+C
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
SCRIPT_DIR = pathlib.Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
|
||||||
|
def load_dotenv():
|
||||||
|
"""Load .env from the working directory (where the user ran `nix run`)."""
|
||||||
|
env_file = pathlib.Path.cwd() / ".env"
|
||||||
|
if not env_file.exists():
|
||||||
|
# Also check next to the script (for non-nix usage)
|
||||||
|
env_file = SCRIPT_DIR / ".env"
|
||||||
|
if not env_file.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"Loading secrets from {env_file}")
|
||||||
|
with open(env_file) as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
if "=" in line:
|
||||||
|
key, _, value = line.partition("=")
|
||||||
|
key = key.strip()
|
||||||
|
value = value.strip().strip("\"'")
|
||||||
|
os.environ.setdefault(key, value)
|
||||||
|
|
||||||
|
|
||||||
|
def check_token():
|
||||||
|
token = os.environ.get("BOT_TOKEN", "")
|
||||||
|
if not token or token == "YOUR_BOT_TOKEN_HERE":
|
||||||
|
print("\n No BOT_TOKEN found!\n")
|
||||||
|
print(" Create a .env file in this directory with:")
|
||||||
|
print(" BOT_TOKEN=your-token-from-@BotFather\n")
|
||||||
|
sys.exit(1)
|
||||||
|
# Mask token in logs
|
||||||
|
masked = token[:5] + "..." + token[-4:]
|
||||||
|
print(f" BOT_TOKEN: {masked}")
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def start_server(port: int) -> subprocess.Popen:
|
||||||
|
"""Start the aiohttp API server."""
|
||||||
|
env = {**os.environ, "API_PORT": str(port)}
|
||||||
|
return subprocess.Popen(
|
||||||
|
[sys.executable, str(SCRIPT_DIR / "server.py")],
|
||||||
|
env=env,
|
||||||
|
cwd=pathlib.Path.cwd(), # DB writes go to user's working directory
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def start_tunnel(port: int) -> tuple[subprocess.Popen, str]:
|
||||||
|
"""Start localtunnel and return (process, public_url)."""
|
||||||
|
print(f" Starting tunnel to port {port}...")
|
||||||
|
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
["lt", "--port", str(port)],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# localtunnel prints "your url is: https://xxx.loca.lt"
|
||||||
|
url = None
|
||||||
|
deadline = time.time() + 30
|
||||||
|
while time.time() < deadline:
|
||||||
|
line = proc.stdout.readline()
|
||||||
|
if not line:
|
||||||
|
if proc.poll() is not None:
|
||||||
|
print(" Tunnel process exited unexpectedly.")
|
||||||
|
break
|
||||||
|
continue
|
||||||
|
line = line.strip()
|
||||||
|
print(f" [tunnel] {line}")
|
||||||
|
match = re.search(r"https?://\S+", line)
|
||||||
|
if match:
|
||||||
|
url = match.group(0)
|
||||||
|
break
|
||||||
|
|
||||||
|
if not url:
|
||||||
|
proc.kill()
|
||||||
|
print("\n Could not get a tunnel URL.")
|
||||||
|
print(" Make sure localtunnel is working: lt --port 8080\n")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
return proc, url
|
||||||
|
|
||||||
|
|
||||||
|
def start_bot(webapp_url: str) -> subprocess.Popen:
|
||||||
|
"""Start the Telegram bot with the tunnel URL."""
|
||||||
|
env = {**os.environ, "WEBAPP_URL": webapp_url}
|
||||||
|
return subprocess.Popen(
|
||||||
|
[sys.executable, str(SCRIPT_DIR / "bot.py")],
|
||||||
|
env=env,
|
||||||
|
cwd=pathlib.Path.cwd(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
port = int(os.environ.get("API_PORT", "8080"))
|
||||||
|
procs: list[subprocess.Popen] = []
|
||||||
|
|
||||||
|
def cleanup(sig=None, frame=None):
|
||||||
|
print("\nShutting down...")
|
||||||
|
for p in procs:
|
||||||
|
try:
|
||||||
|
p.terminate()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
for p in procs:
|
||||||
|
try:
|
||||||
|
p.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
p.kill()
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
signal.signal(signal.SIGINT, cleanup)
|
||||||
|
signal.signal(signal.SIGTERM, cleanup)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ==========================================")
|
||||||
|
print(" Telegram Fitness Bot")
|
||||||
|
print(" ==========================================")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# 1. Load .env
|
||||||
|
load_dotenv()
|
||||||
|
check_token()
|
||||||
|
|
||||||
|
# 2. Database will be created in the working directory
|
||||||
|
db_path = os.environ.setdefault("DB_PATH", str(pathlib.Path.cwd() / "fitness.db"))
|
||||||
|
print(f" Database: {db_path}")
|
||||||
|
|
||||||
|
# 3. Start API server
|
||||||
|
print(f"\n Starting API server on port {port}...")
|
||||||
|
server = start_server(port)
|
||||||
|
procs.append(server)
|
||||||
|
time.sleep(1) # Give it a moment to bind
|
||||||
|
|
||||||
|
if server.poll() is not None:
|
||||||
|
print(" Server failed to start!")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# 4. Start tunnel
|
||||||
|
tunnel, webapp_url = start_tunnel(port)
|
||||||
|
procs.append(tunnel)
|
||||||
|
|
||||||
|
# 5. Start bot
|
||||||
|
print(f"\n WEBAPP_URL: {webapp_url}")
|
||||||
|
print(f" Starting bot...\n")
|
||||||
|
bot = start_bot(webapp_url)
|
||||||
|
procs.append(bot)
|
||||||
|
|
||||||
|
print(" ==========================================")
|
||||||
|
print(f" All systems go!")
|
||||||
|
print(f" Mini App: {webapp_url}")
|
||||||
|
print(f" API: http://localhost:{port}")
|
||||||
|
print(f" Press Ctrl+C to stop")
|
||||||
|
print(" ==========================================")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Wait for any process to exit
|
||||||
|
while True:
|
||||||
|
for p in procs:
|
||||||
|
ret = p.poll()
|
||||||
|
if ret is not None:
|
||||||
|
name = {id(server): "Server", id(tunnel): "Tunnel", id(bot): "Bot"}.get(id(p), "?")
|
||||||
|
print(f"\n {name} exited with code {ret}")
|
||||||
|
cleanup()
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
361
telegram-fitness-bot/webapp/app.js
Normal file
361
telegram-fitness-bot/webapp/app.js
Normal file
|
|
@ -0,0 +1,361 @@
|
||||||
|
// ── Telegram Web App init ───────────────────────────────────────
|
||||||
|
const tg = window.Telegram.WebApp;
|
||||||
|
tg.ready();
|
||||||
|
tg.expand();
|
||||||
|
|
||||||
|
const API = window.location.origin + "/api";
|
||||||
|
const userId = tg.initDataUnsafe?.user?.id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
document.getElementById("app").innerHTML =
|
||||||
|
'<div class="empty-state"><p>Please open this app from Telegram.</p></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── State ───────────────────────────────────────────────────────
|
||||||
|
let activeWorkout = null;
|
||||||
|
let exercises = [];
|
||||||
|
let timerInterval = null;
|
||||||
|
|
||||||
|
// ── API helpers ─────────────────────────────────────────────────
|
||||||
|
async function api(method, path, body = null) {
|
||||||
|
const opts = {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Telegram-Init-Data": tg.initData,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (body) opts.body = JSON.stringify(body);
|
||||||
|
const res = await fetch(API + path, opts);
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error || `API error ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Toast ───────────────────────────────────────────────────────
|
||||||
|
function showToast(message) {
|
||||||
|
let toast = document.querySelector(".toast");
|
||||||
|
if (!toast) {
|
||||||
|
toast = document.createElement("div");
|
||||||
|
toast.className = "toast";
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
}
|
||||||
|
toast.textContent = message;
|
||||||
|
toast.classList.add("show");
|
||||||
|
setTimeout(() => toast.classList.remove("show"), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tab navigation ──────────────────────────────────────────────
|
||||||
|
document.querySelectorAll(".tab").forEach((tab) => {
|
||||||
|
tab.addEventListener("click", () => {
|
||||||
|
document.querySelectorAll(".tab").forEach((t) => t.classList.remove("active"));
|
||||||
|
document.querySelectorAll(".view").forEach((v) => v.classList.remove("active"));
|
||||||
|
tab.classList.add("active");
|
||||||
|
document.getElementById("view-" + tab.dataset.view).classList.add("active");
|
||||||
|
tg.HapticFeedback.selectionChanged();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Timer ───────────────────────────────────────────────────────
|
||||||
|
function startTimer(startedAt) {
|
||||||
|
if (timerInterval) clearInterval(timerInterval);
|
||||||
|
const start = new Date(startedAt + "Z").getTime();
|
||||||
|
const el = document.getElementById("workout-timer");
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
const diff = Math.floor((Date.now() - start) / 1000);
|
||||||
|
const m = String(Math.floor(diff / 60)).padStart(2, "0");
|
||||||
|
const s = String(diff % 60).padStart(2, "0");
|
||||||
|
el.textContent = `${m}:${s}`;
|
||||||
|
}
|
||||||
|
tick();
|
||||||
|
timerInterval = setInterval(tick, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopTimer() {
|
||||||
|
if (timerInterval) {
|
||||||
|
clearInterval(timerInterval);
|
||||||
|
timerInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render helpers ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
function renderExerciseSelect() {
|
||||||
|
const sel = document.getElementById("sel-exercise");
|
||||||
|
sel.innerHTML = '<option value="">Select exercise…</option>';
|
||||||
|
exercises.forEach((ex) => {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = ex.id;
|
||||||
|
opt.textContent = ex.name;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSets(sets) {
|
||||||
|
const container = document.getElementById("sets-list");
|
||||||
|
container.innerHTML = "";
|
||||||
|
|
||||||
|
if (!sets || sets.length === 0) return;
|
||||||
|
|
||||||
|
// Group by exercise
|
||||||
|
const groups = {};
|
||||||
|
sets.forEach((s) => {
|
||||||
|
if (!groups[s.exercise_name]) groups[s.exercise_name] = [];
|
||||||
|
groups[s.exercise_name].push(s);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [name, groupSets] of Object.entries(groups)) {
|
||||||
|
const group = document.createElement("div");
|
||||||
|
group.className = "exercise-group";
|
||||||
|
|
||||||
|
const heading = document.createElement("div");
|
||||||
|
heading.className = "exercise-group-name";
|
||||||
|
heading.textContent = name;
|
||||||
|
group.appendChild(heading);
|
||||||
|
|
||||||
|
groupSets.forEach((s, i) => {
|
||||||
|
const item = document.createElement("div");
|
||||||
|
item.className = "set-item";
|
||||||
|
item.innerHTML = `
|
||||||
|
<span class="set-number">Set ${i + 1}</span>
|
||||||
|
<span class="set-detail">${s.reps} reps × ${s.weight} kg</span>
|
||||||
|
<button class="set-delete" data-set-id="${s.id}">×</button>
|
||||||
|
`;
|
||||||
|
group.appendChild(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
container.appendChild(group);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach delete handlers
|
||||||
|
container.querySelectorAll(".set-delete").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", async () => {
|
||||||
|
tg.HapticFeedback.impactOccurred("light");
|
||||||
|
await api("DELETE", `/sets/${btn.dataset.setId}`);
|
||||||
|
await refreshWorkout();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderExercisesList() {
|
||||||
|
const container = document.getElementById("exercises-list");
|
||||||
|
container.innerHTML = "";
|
||||||
|
|
||||||
|
exercises.forEach((ex) => {
|
||||||
|
const item = document.createElement("div");
|
||||||
|
item.className = "exercise-item";
|
||||||
|
item.innerHTML = `
|
||||||
|
<span>${ex.name}</span>
|
||||||
|
<button class="exercise-delete" data-exercise-id="${ex.id}">×</button>
|
||||||
|
`;
|
||||||
|
container.appendChild(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
container.querySelectorAll(".exercise-delete").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", async () => {
|
||||||
|
tg.HapticFeedback.impactOccurred("light");
|
||||||
|
try {
|
||||||
|
await api("DELETE", `/exercises/${btn.dataset.exerciseId}`);
|
||||||
|
await loadExercises();
|
||||||
|
showToast("Exercise deleted");
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderHistory() {
|
||||||
|
try {
|
||||||
|
const data = await api("GET", "/workouts");
|
||||||
|
const container = document.getElementById("history-list");
|
||||||
|
const noHistory = document.getElementById("no-history");
|
||||||
|
container.innerHTML = "";
|
||||||
|
|
||||||
|
if (!data.workouts || data.workouts.length === 0) {
|
||||||
|
noHistory.style.display = "block";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
noHistory.style.display = "none";
|
||||||
|
|
||||||
|
data.workouts.forEach((w) => {
|
||||||
|
const card = document.createElement("div");
|
||||||
|
card.className = "history-card";
|
||||||
|
|
||||||
|
const date = new Date(w.started_at + "Z");
|
||||||
|
const dateStr = date.toLocaleDateString(undefined, {
|
||||||
|
weekday: "short",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
|
||||||
|
let exercisesHtml = "";
|
||||||
|
if (w.summary && w.summary.exercises) {
|
||||||
|
for (const [name, sets] of Object.entries(w.summary.exercises)) {
|
||||||
|
const setsStr = sets
|
||||||
|
.map((s) => `${s.reps}×${s.weight}kg`)
|
||||||
|
.join(", ");
|
||||||
|
exercisesHtml += `
|
||||||
|
<div class="history-exercise">
|
||||||
|
<div class="history-exercise-name">${name}</div>
|
||||||
|
<div class="history-exercise-sets">${setsStr}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="history-card-header">
|
||||||
|
<span class="history-date">${dateStr}</span>
|
||||||
|
<span class="history-volume">${w.summary?.total_volume || 0} kg total</span>
|
||||||
|
</div>
|
||||||
|
${exercisesHtml}
|
||||||
|
`;
|
||||||
|
container.appendChild(card);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to load history", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Workout flow ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function showWorkoutState(active) {
|
||||||
|
document.getElementById("no-workout").style.display = active ? "none" : "block";
|
||||||
|
document.getElementById("active-workout").style.display = active ? "block" : "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshWorkout() {
|
||||||
|
try {
|
||||||
|
const data = await api("GET", "/workouts/active");
|
||||||
|
activeWorkout = data.workout;
|
||||||
|
if (activeWorkout) {
|
||||||
|
showWorkoutState(true);
|
||||||
|
startTimer(activeWorkout.started_at);
|
||||||
|
const setsData = await api("GET", `/workouts/${activeWorkout.id}/sets`);
|
||||||
|
renderSets(setsData.sets);
|
||||||
|
} else {
|
||||||
|
showWorkoutState(false);
|
||||||
|
stopTimer();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showWorkoutState(false);
|
||||||
|
stopTimer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Event listeners ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Start workout
|
||||||
|
document.getElementById("btn-start-workout").addEventListener("click", async () => {
|
||||||
|
tg.HapticFeedback.impactOccurred("medium");
|
||||||
|
try {
|
||||||
|
const data = await api("POST", "/workouts");
|
||||||
|
activeWorkout = data.workout;
|
||||||
|
showWorkoutState(true);
|
||||||
|
startTimer(activeWorkout.started_at);
|
||||||
|
showToast("Workout started!");
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Finish workout
|
||||||
|
document.getElementById("btn-finish-workout").addEventListener("click", async () => {
|
||||||
|
tg.HapticFeedback.notificationOccurred("success");
|
||||||
|
try {
|
||||||
|
await api("POST", `/workouts/${activeWorkout.id}/finish`);
|
||||||
|
activeWorkout = null;
|
||||||
|
showWorkoutState(false);
|
||||||
|
stopTimer();
|
||||||
|
showToast("Workout finished!");
|
||||||
|
renderHistory();
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add set
|
||||||
|
document.getElementById("btn-add-set").addEventListener("click", async () => {
|
||||||
|
const exerciseId = document.getElementById("sel-exercise").value;
|
||||||
|
const reps = parseInt(document.getElementById("inp-reps").value);
|
||||||
|
const weight = parseFloat(document.getElementById("inp-weight").value);
|
||||||
|
|
||||||
|
if (!exerciseId) {
|
||||||
|
showToast("Pick an exercise first");
|
||||||
|
tg.HapticFeedback.notificationOccurred("error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!reps || reps < 1) {
|
||||||
|
showToast("Enter valid reps");
|
||||||
|
tg.HapticFeedback.notificationOccurred("error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tg.HapticFeedback.impactOccurred("light");
|
||||||
|
try {
|
||||||
|
await api("POST", `/workouts/${activeWorkout.id}/sets`, {
|
||||||
|
exercise_id: parseInt(exerciseId),
|
||||||
|
reps,
|
||||||
|
weight: weight || 0,
|
||||||
|
});
|
||||||
|
await refreshWorkout();
|
||||||
|
showToast(`${reps} × ${weight} kg logged`);
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add exercise
|
||||||
|
document.getElementById("btn-add-exercise").addEventListener("click", async () => {
|
||||||
|
const input = document.getElementById("inp-exercise-name");
|
||||||
|
const name = input.value.trim();
|
||||||
|
if (!name) {
|
||||||
|
showToast("Enter exercise name");
|
||||||
|
tg.HapticFeedback.notificationOccurred("error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tg.HapticFeedback.impactOccurred("light");
|
||||||
|
try {
|
||||||
|
await api("POST", "/exercises", { name });
|
||||||
|
input.value = "";
|
||||||
|
await loadExercises();
|
||||||
|
showToast(`${name} added`);
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enter key to add exercise
|
||||||
|
document.getElementById("inp-exercise-name").addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter") document.getElementById("btn-add-exercise").click();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Data loading ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function loadExercises() {
|
||||||
|
try {
|
||||||
|
const data = await api("GET", "/exercises");
|
||||||
|
exercises = data.exercises || [];
|
||||||
|
renderExerciseSelect();
|
||||||
|
renderExercisesList();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to load exercises", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Init ────────────────────────────────────────────────────────
|
||||||
|
async function init() {
|
||||||
|
if (!userId) return;
|
||||||
|
await loadExercises();
|
||||||
|
await refreshWorkout();
|
||||||
|
await renderHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
83
telegram-fitness-bot/webapp/index.html
Normal file
83
telegram-fitness-bot/webapp/index.html
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
|
||||||
|
<title>Workout Tracker</title>
|
||||||
|
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||||
|
<link rel="stylesheet" href="style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<!-- Navigation tabs -->
|
||||||
|
<nav id="tabs">
|
||||||
|
<button class="tab active" data-view="workout">Workout</button>
|
||||||
|
<button class="tab" data-view="exercises">Exercises</button>
|
||||||
|
<button class="tab" data-view="history">History</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- ═══ WORKOUT VIEW ═══ -->
|
||||||
|
<div id="view-workout" class="view active">
|
||||||
|
<!-- No active workout state -->
|
||||||
|
<div id="no-workout">
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-icon">🏋️</div>
|
||||||
|
<p>No active workout</p>
|
||||||
|
<button id="btn-start-workout" class="btn btn-primary">Start Workout</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Active workout state -->
|
||||||
|
<div id="active-workout" style="display:none">
|
||||||
|
<div class="workout-header">
|
||||||
|
<span id="workout-timer">00:00</span>
|
||||||
|
<button id="btn-finish-workout" class="btn btn-danger btn-sm">Finish</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add set form -->
|
||||||
|
<div class="card" id="add-set-card">
|
||||||
|
<select id="sel-exercise" class="input">
|
||||||
|
<option value="">Select exercise…</option>
|
||||||
|
</select>
|
||||||
|
<div class="set-inputs">
|
||||||
|
<div class="input-group">
|
||||||
|
<label>Reps</label>
|
||||||
|
<input type="number" id="inp-reps" class="input" min="1" value="10" inputmode="numeric" />
|
||||||
|
</div>
|
||||||
|
<div class="input-group">
|
||||||
|
<label>Weight (kg)</label>
|
||||||
|
<input type="number" id="inp-weight" class="input" min="0" step="0.5" value="20" inputmode="decimal" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button id="btn-add-set" class="btn btn-primary">Log Set</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Logged sets list -->
|
||||||
|
<div id="sets-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ EXERCISES VIEW ═══ -->
|
||||||
|
<div id="view-exercises" class="view">
|
||||||
|
<div class="card">
|
||||||
|
<div class="add-exercise-row">
|
||||||
|
<input type="text" id="inp-exercise-name" class="input" placeholder="New exercise name…" />
|
||||||
|
<button id="btn-add-exercise" class="btn btn-primary btn-sm">Add</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="exercises-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ HISTORY VIEW ═══ -->
|
||||||
|
<div id="view-history" class="view">
|
||||||
|
<div id="history-list"></div>
|
||||||
|
<div id="no-history" class="empty-state">
|
||||||
|
<div class="empty-icon">📋</div>
|
||||||
|
<p>No workouts yet</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
333
telegram-fitness-bot/webapp/style.css
Normal file
333
telegram-fitness-bot/webapp/style.css
Normal file
|
|
@ -0,0 +1,333 @@
|
||||||
|
/* ── Reset & Telegram-native theming ─────────────────────────── */
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background-color: var(--tg-theme-bg-color, #ffffff);
|
||||||
|
color: var(--tg-theme-text-color, #000000);
|
||||||
|
min-height: 100vh;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tab navigation ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
#tabs {
|
||||||
|
display: flex;
|
||||||
|
background: var(--tg-theme-secondary-bg-color, #f0f0f0);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
border-bottom: 1px solid var(--tg-theme-hint-color, #999999)33;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px 0;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--tg-theme-hint-color, #999999);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s, border-color 0.2s;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
color: var(--tg-theme-button-color, #3390ec);
|
||||||
|
border-bottom-color: var(--tg-theme-button-color, #3390ec);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Views ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.view {
|
||||||
|
display: none;
|
||||||
|
padding: 16px;
|
||||||
|
padding-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Cards ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--tg-theme-secondary-bg-color, #f0f0f0);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Buttons ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 12px 20px;
|
||||||
|
width: 100%;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--tg-theme-button-color, #3390ec);
|
||||||
|
color: var(--tg-theme-button-text-color, #ffffff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: #e53935;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Inputs ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1.5px solid var(--tg-theme-hint-color, #999999)44;
|
||||||
|
background: var(--tg-theme-bg-color, #ffffff);
|
||||||
|
color: var(--tg-theme-text-color, #000000);
|
||||||
|
font-size: 15px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus {
|
||||||
|
border-color: var(--tg-theme-button-color, #3390ec);
|
||||||
|
}
|
||||||
|
|
||||||
|
select.input {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Workout view specifics ──────────────────────────────────── */
|
||||||
|
|
||||||
|
.workout-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--tg-theme-secondary-bg-color, #f0f0f0);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#workout-timer {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.set-inputs {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group label {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--tg-theme-hint-color, #999999);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Set list items ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.exercise-group {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exercise-group-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--tg-theme-hint-color, #999999);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.set-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: var(--tg-theme-secondary-bg-color, #f0f0f0);
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.set-item .set-number {
|
||||||
|
color: var(--tg-theme-hint-color, #999999);
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.set-item .set-detail {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.set-item .set-delete {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #e53935;
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Exercise list ───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.add-exercise-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-exercise-row .input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exercise-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: var(--tg-theme-secondary-bg-color, #f0f0f0);
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exercise-item .exercise-delete {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--tg-theme-hint-color, #999999);
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── History ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.history-card {
|
||||||
|
background: var(--tg-theme-secondary-bg-color, #f0f0f0);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-date {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--tg-theme-hint-color, #999999);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-volume {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--tg-theme-button-color, #3390ec);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-exercise {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-exercise-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-exercise-sets {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--tg-theme-hint-color, #999999);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Empty state ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 48px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
color: var(--tg-theme-hint-color, #999999);
|
||||||
|
font-size: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Haptic-like tap feedback ────────────────────────────────── */
|
||||||
|
|
||||||
|
.btn:active,
|
||||||
|
.tab:active,
|
||||||
|
.set-delete:active,
|
||||||
|
.exercise-delete:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Toast notification ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 24px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%) translateY(80px);
|
||||||
|
background: var(--tg-theme-text-color, #000000);
|
||||||
|
color: var(--tg-theme-bg-color, #ffffff);
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
opacity: 0;
|
||||||
|
transition: transform 0.3s, opacity 0.3s;
|
||||||
|
z-index: 1000;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.show {
|
||||||
|
transform: translateX(-50%) translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue