From 42d79c9d9054428f0a349beae99e6cf11d6958db Mon Sep 17 00:00:00 2001 From: Danny Date: Fri, 17 Apr 2026 11:29:59 +0200 Subject: [PATCH] chore(tg-fitness-bot): remove obsolete prototype subdirectory The telegram-fitness-bot/ subdirectory was the initial scaffold, superseded by the top-level implementation and unused since. Co-Authored-By: Claude Opus 4.6 (1M context) --- telegram-fitness-bot/.envrc | 1 - telegram-fitness-bot/.gitignore | 19 -- telegram-fitness-bot/README.md | 108 -------- telegram-fitness-bot/bot.py | 198 -------------- telegram-fitness-bot/config.py | 14 - telegram-fitness-bot/database.py | 245 ----------------- telegram-fitness-bot/flake.nix | 87 ------ telegram-fitness-bot/requirements.txt | 2 - telegram-fitness-bot/run.sh | 6 - telegram-fitness-bot/server.py | 247 ----------------- telegram-fitness-bot/start.py | 188 ------------- telegram-fitness-bot/webapp/app.js | 361 ------------------------- telegram-fitness-bot/webapp/index.html | 83 ------ telegram-fitness-bot/webapp/style.css | 333 ----------------------- 14 files changed, 1892 deletions(-) delete mode 100644 telegram-fitness-bot/.envrc delete mode 100644 telegram-fitness-bot/.gitignore delete mode 100644 telegram-fitness-bot/README.md delete mode 100644 telegram-fitness-bot/bot.py delete mode 100644 telegram-fitness-bot/config.py delete mode 100644 telegram-fitness-bot/database.py delete mode 100644 telegram-fitness-bot/flake.nix delete mode 100644 telegram-fitness-bot/requirements.txt delete mode 100755 telegram-fitness-bot/run.sh delete mode 100644 telegram-fitness-bot/server.py delete mode 100644 telegram-fitness-bot/start.py delete mode 100644 telegram-fitness-bot/webapp/app.js delete mode 100644 telegram-fitness-bot/webapp/index.html delete mode 100644 telegram-fitness-bot/webapp/style.css diff --git a/telegram-fitness-bot/.envrc b/telegram-fitness-bot/.envrc deleted file mode 100644 index 3550a30..0000000 --- a/telegram-fitness-bot/.envrc +++ /dev/null @@ -1 +0,0 @@ -use flake diff --git a/telegram-fitness-bot/.gitignore b/telegram-fitness-bot/.gitignore deleted file mode 100644 index 35ff47a..0000000 --- a/telegram-fitness-bot/.gitignore +++ /dev/null @@ -1,19 +0,0 @@ -# Nix -result -result-* -.direnv/ - -# Python -__pycache__/ -*.pyc - -# Database -*.db -*.db-journal -*.db-wal - -# Secrets — never commit -.env - -# Node (localtunnel cache) -node_modules/ diff --git a/telegram-fitness-bot/README.md b/telegram-fitness-bot/README.md deleted file mode 100644 index dcfa3e0..0000000 --- a/telegram-fitness-bot/README.md +++ /dev/null @@ -1,108 +0,0 @@ -# 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. diff --git a/telegram-fitness-bot/bot.py b/telegram-fitness-bot/bot.py deleted file mode 100644 index 083d1f9..0000000 --- a/telegram-fitness-bot/bot.py +++ /dev/null @@ -1,198 +0,0 @@ -""" -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() diff --git a/telegram-fitness-bot/config.py b/telegram-fitness-bot/config.py deleted file mode 100644 index 5b39981..0000000 --- a/telegram-fitness-bot/config.py +++ /dev/null @@ -1,14 +0,0 @@ -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") diff --git a/telegram-fitness-bot/database.py b/telegram-fitness-bot/database.py deleted file mode 100644 index f2093b8..0000000 --- a/telegram-fitness-bot/database.py +++ /dev/null @@ -1,245 +0,0 @@ -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), - } diff --git a/telegram-fitness-bot/flake.nix b/telegram-fitness-bot/flake.nix deleted file mode 100644 index 06d4885..0000000 --- a/telegram-fitness-bot/flake.nix +++ /dev/null @@ -1,87 +0,0 @@ -{ - 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 "" - ''; - }; - } - ); -} diff --git a/telegram-fitness-bot/requirements.txt b/telegram-fitness-bot/requirements.txt deleted file mode 100644 index 906d091..0000000 --- a/telegram-fitness-bot/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -python-telegram-bot>=21.0 -aiohttp>=3.9 diff --git a/telegram-fitness-bot/run.sh b/telegram-fitness-bot/run.sh deleted file mode 100755 index eefed9d..0000000 --- a/telegram-fitness-bot/run.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/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 "$@" diff --git a/telegram-fitness-bot/server.py b/telegram-fitness-bot/server.py deleted file mode 100644 index 8cf1abc..0000000 --- a/telegram-fitness-bot/server.py +++ /dev/null @@ -1,247 +0,0 @@ -""" -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) diff --git a/telegram-fitness-bot/start.py b/telegram-fitness-bot/start.py deleted file mode 100644 index 1f752c6..0000000 --- a/telegram-fitness-bot/start.py +++ /dev/null @@ -1,188 +0,0 @@ -#!/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() diff --git a/telegram-fitness-bot/webapp/app.js b/telegram-fitness-bot/webapp/app.js deleted file mode 100644 index 3f8dfaa..0000000 --- a/telegram-fitness-bot/webapp/app.js +++ /dev/null @@ -1,361 +0,0 @@ -// ── 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 = - '

Please open this app from Telegram.

'; -} - -// ── 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 = ''; - 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 = ` - Set ${i + 1} - ${s.reps} reps × ${s.weight} kg - - `; - 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 = ` - ${ex.name} - - `; - 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 += ` -
-
${name}
-
${setsStr}
-
- `; - } - } - - card.innerHTML = ` -
- ${dateStr} - ${w.summary?.total_volume || 0} kg total -
- ${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(); diff --git a/telegram-fitness-bot/webapp/index.html b/telegram-fitness-bot/webapp/index.html deleted file mode 100644 index 1cb959c..0000000 --- a/telegram-fitness-bot/webapp/index.html +++ /dev/null @@ -1,83 +0,0 @@ - - - - - - Workout Tracker - - - - -
- - - - -
- -
-
-
🏋️
-

No active workout

- -
-
- - - -
- - -
-
-
- - -
-
-
-
- - -
-
-
-
📋
-

No workouts yet

-
-
-
- - - - diff --git a/telegram-fitness-bot/webapp/style.css b/telegram-fitness-bot/webapp/style.css deleted file mode 100644 index 7799334..0000000 --- a/telegram-fitness-bot/webapp/style.css +++ /dev/null @@ -1,333 +0,0 @@ -/* ── 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; -}