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:
Danny 2026-03-30 14:12:50 +02:00
parent 7288d93741
commit ae09ab2eec
14 changed files with 1892 additions and 0 deletions

View file

@ -0,0 +1 @@
use flake

19
telegram-fitness-bot/.gitignore vendored Normal file
View 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/

View 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
View 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()

View 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")

View 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),
}

View 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 ""
'';
};
}
);
}

View file

@ -0,0 +1,2 @@
python-telegram-bot>=21.0
aiohttp>=3.9

6
telegram-fitness-bot/run.sh Executable file
View 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 "$@"

View 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)

View 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()

View 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();

View 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>

View 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;
}