diff --git a/README.md b/README.md index 1faba7a..144c7d6 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,13 @@ # BigBiggerBiggestBot ๐Ÿ’ช -A Telegram bot for logging gym workouts, with an embedded Mini App. - -Send workouts as plain text, forward them from Saved Messages, or tap -through a structured log form inside Telegram. History, stats, notes, +A Telegram **Mini App** for logging gym workouts. History, stats, notes, edit & delete, JSON/CSV export โ€” all per-user, all in SQLite. -## Format +The slash-command bot was removed: the Mini App is the only interface. +A Telegram bot identity (token) is still required so the Mini App can +validate user sessions via `initData` HMAC. -Send messages like: +## Workout text format (still supported via "Paste as text" in the Mini App) ``` Bench press: 4x8x35 @@ -23,15 +22,6 @@ Pull-ups: 3x10 - Blank line separates superset groups; consecutive lines form a superset - Both `,` and `.` work as decimal separators -## Commands - -- `/start` โ€” help & open Mini App -- `/history` โ€” recent workouts -- `/stats` โ€” summary (total workouts, sets, volume) -- `/delete ` โ€” soft-delete a workout -- `/export` โ€” download all data as JSON -- `/feedback ` โ€” send feedback to the bot author - ## Run locally ```bash @@ -39,9 +29,9 @@ nix run ``` This launches: -- API server (port 8080) -- cloudflared tunnel for the Mini App -- Telegram bot (polling) +- API + Mini App server (port 8080) +- cloudflared Quick Tunnel for a public HTTPS URL (skipped if `WEBAPP_URL` + is already set in the environment, e.g. fronted by a reverse proxy) Put your bot token (from [@BotFather](https://t.me/BotFather)) in `~/.secrets/bigbiggerbiggestbot` or a `.env` file: @@ -63,10 +53,12 @@ nix develop --command pytest tests/ -v Two environments share one host (`sunken-ship`): - **Production** โ€” `fitness-bot.service`, working dir `/home/danny/tg_fitness_bot`, - watches `origin/main`, served behind a stable URL via the VPS Caddy. + watches `origin/main`, served behind a stable URL via the VPS Caddy at + `https://bbbot.dannydannydanny.me`. - **Shipyard staging** โ€” `fitness-bot-shipyard.service`, working dir - `/home/danny/tg_fitness_bot_shipyard`, watches `origin/staging`, separate - bot token, ephemeral cloudflared URL each restart. + `/home/danny/tg_fitness_bot_shipyard`, watches `origin/staging`, served + by the shared `shipyard_poc_bot` Telegram bot (B3Bot beta is the active + POC tenant). Each has its own pull timer that fetches every ~15 minutes and restarts the service when its branch has new commits. @@ -74,7 +66,6 @@ the service when its branch has new commits. **Workflow:** ``` -# 1. land changes on a working branch (or main locally) git push origin :staging # โ†’ shipyard auto-deploys, test there git push origin :main # โ†’ production auto-deploys ``` @@ -84,13 +75,16 @@ so testing on shipyard never touches production data. ## Architecture -- `bot.py` โ€” Telegram command handlers, polling, message parsing -- `server.py` โ€” aiohttp REST API + static file server for the Mini App -- `db.py` โ€” SQLite data layer (workouts, supersets, exercises, feedback; soft delete) -- `parser.py` โ€” workout text โ†’ structured data -- `webapp/` โ€” Mini App (HTML/CSS/vanilla JS, Telegram WebApp SDK) -- `start.py` โ€” orchestrator: starts server + tunnel + bot, wires up the Mini App URL -- `tests/` โ€” pytest suite for parser + db +- `server.py` โ€” aiohttp REST API + static file server for the Mini App; + validates Telegram `initData` HMACs against the bot token. +- `db.py` โ€” SQLite data layer (workouts, supersets, exercises, feedback, + events, settings; soft delete). +- `parser.py` โ€” workout text โ†’ structured data (used by the Mini App's + "Paste as text" path). +- `webapp/` โ€” Mini App (HTML/CSS/vanilla JS, Telegram WebApp SDK). +- `start.py` โ€” orchestrator: loads token, starts server, optionally starts + cloudflared. +- `tests/` โ€” pytest suite for parser + db. ## License diff --git a/ROADMAP.md b/ROADMAP.md index c1afd8d..cc3c764 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -15,8 +15,9 @@ - [x] Global exercise name suggestions โ€” autocomplete draws from all users' exercises, ordered by popularity, case-insensitively grouped. - [ ] **#3** Machine-to-muscle mapping โ€” reference dataset + `/machine ` command. Seeded with gym80 IDs. - [x] Interaction / event logging โ€” structured `events` table; bot commands, workout save/update/delete, Mini App opens, and per-set additions all record events. `POST /api/events` endpoint lets the Mini App emit client-side events. Rest-timer prereq done. -- [x] Staging via shipyard โ€” second NixOS-managed systemd instance on sunken-ship (`fitness-bot-shipyard.service`) watches `origin/staging` from `/home/danny/tg_fitness_bot_shipyard`, runs on port 8081, ephemeral cloudflared tunnel, served by the shared `shipyard_poc_bot` Telegram bot. Token via `EnvironmentFile=/home/danny/.secrets/shipyard_poc_bot.env`. Workflow: `git push origin :staging` (auto-deploys ~15 min) โ†’ `/start` shipyard_poc_bot โ†’ test โ†’ `git push origin :main`. +- [x] Staging via shipyard โ€” Mini-App-only HTTP tenant under `shipyard_poc_bot` (slash-command bot deleted; phantom-ship's Shipyard owns Telegram polling). `fitness-bot-shipyard.service` on sunken-ship watches `origin/staging` from `/home/danny/tg_fitness_bot_shipyard`, listens on `:8081`, fronted by vps-relay Caddy at **https://b3.dannydannydanny.me**, validates initData against `shipyard_poc_bot`'s token (`EnvironmentFile=/home/danny/.secrets/shipyard_poc_bot.env`). Listed in `~/python-projects/26_shipyard/apps.json` as `b3bot-beta`. Workflow: `git push origin :staging` (auto-deploys ~15 min) โ†’ tap **B3Bot beta** in shipyard_poc_bot โ†’ test โ†’ `git push origin :main`. - [x] **feedback #9** Negative weight input โ€” `ยฑ` sign-flip button next to the weight input handles iOS numeric keypads that have no minus key; active state indicates a negative value. +- [ ] Last-session recall โ€” when starting an exercise, show the most recent sets/weights logged for it (and pre-fill the weight) so you have a reference for what to beat. ## Later - [ ] **#8** Workout templates โ€” save/load favorite workouts diff --git a/bot.py b/bot.py deleted file mode 100644 index d638082..0000000 --- a/bot.py +++ /dev/null @@ -1,336 +0,0 @@ -"""Telegram Fitness Bot โ€” track your workouts.""" - -import logging -import os -from datetime import datetime, timezone - -from dotenv import load_dotenv -from telegram import Update, WebAppInfo, InlineKeyboardButton, InlineKeyboardMarkup, MenuButtonWebApp -from telegram.constants import ParseMode -from telegram.ext import ( - Application, - ApplicationBuilder, - CommandHandler, - ContextTypes, - MessageHandler, - filters, -) - -from db import init_db, save_workout, get_workouts, get_workout_count, get_stats_sql, delete_workout, save_feedback, get_user_workout_number, resolve_user_number, log_event -from parser import parse_workout, format_workout - -load_dotenv() - -logging.basicConfig( - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", - level=logging.INFO, -) -logger = logging.getLogger(__name__) - -# Token resolution: BOT_TOKEN env var โ†’ secrets file -# Env var wins so multiple instances on the same host (e.g. prod + shipyard -# staging) can each point to a different token without sharing a secrets file. -SECRETS_FILE = os.path.expanduser("~/.secrets/bigbiggerbiggestbot") - - -def _load_token() -> str: - # 1. Env var (set by systemd EnvironmentFile in multi-instance setups) - token = os.environ.get("BOT_TOKEN", "").strip() - if token: - return token - # 2. Default secrets file - if os.path.isfile(SECRETS_FILE): - token = open(SECRETS_FILE).read().strip() - if token: - return token - raise RuntimeError( - f"No bot token found. Set BOT_TOKEN env var or put it in {SECRETS_FILE}." - ) - - -BOT_TOKEN = _load_token() - -# Mini App URL โ€” set automatically by start.py via tunnel -WEBAPP_URL = os.environ.get("WEBAPP_URL", "") - - -# โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - -def extract_timestamp(update: Update) -> tuple[datetime, bool]: - """ - Get the best timestamp for a workout message. - - In python-telegram-bot v21+, forwarded message info lives on - message.forward_origin (a MessageOrigin object) with a .date attribute. - - Returns (timestamp, is_forwarded). - """ - msg = update.effective_message - - # v21+: forward_origin is set when a user forwards a message - origin = getattr(msg, "forward_origin", None) - if origin is not None and hasattr(origin, "date"): - return origin.date.replace(tzinfo=timezone.utc), True - - return msg.date.replace(tzinfo=timezone.utc), False - - -# โ”€โ”€ Command handlers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - -async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE): - log_event(update.effective_user.id, "cmd.start") - text = ( - "\U0001f4aa Fitness Tracker Bot\n\n" - "Send me your workout and I'll save it!\n\n" - "Formats:\n" - "Bench press: 4x8x35\n" - "Pull-ups: 3x10 (bodyweight)\n" - "Shoulder press (3032): 8x25, 5x35, 6x40\n\n" - "Lines without a blank line between them = superset.\n" - "Machine IDs go in parentheses.\n\n" - "You can also forward messages from Saved Messages \u2014 " - "I'll use the original timestamp.\n\n" - "Commands:\n" - "/history \u2014 view recent workouts\n" - "/stats \u2014 quick summary\n" - "/delete <number> \u2014 delete a workout (see /history)\n" - "/export \u2014 export all data as JSON\n" - "/feedback <text> \u2014 send feedback" - ) - - if WEBAPP_URL: - btn = InlineKeyboardButton( - text="Open Workout Tracker", - web_app=WebAppInfo(url=WEBAPP_URL), - ) - await update.message.reply_text( - text, parse_mode=ParseMode.HTML, - reply_markup=InlineKeyboardMarkup([[btn]]), - ) - else: - await update.message.reply_text(text, parse_mode=ParseMode.HTML) - - -async def cmd_history(update: Update, context: ContextTypes.DEFAULT_TYPE): - user_id = update.effective_user.id - log_event(user_id, "cmd.history") - workouts = get_workouts(user_id, limit=5) - - if not workouts: - await update.message.reply_text("No workouts saved yet. Send me one!") - return - - parts = [] - for w in workouts: - ts = datetime.fromisoformat(w["timestamp"]) - header = f"\U0001f4c5 {ts.strftime('%a %d %b %Y, %H:%M')} (#{w['user_number']})" - body = format_workout(w["superset_groups"]) - parts.append(f"{header}\n{body}") - - text = "\n\n\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n".join(parts) - total = get_workout_count(user_id) - text += f"\n\nShowing latest 5 of {total} workouts." - - await update.message.reply_text(text, parse_mode=ParseMode.HTML) - - -async def cmd_stats(update: Update, context: ContextTypes.DEFAULT_TYPE): - user_id = update.effective_user.id - log_event(user_id, "cmd.stats") - stats = get_stats_sql(user_id) - - if stats["total_workouts"] == 0: - await update.message.reply_text("No workouts yet \u2014 send me your first one!") - return - - await update.message.reply_text( - f"\U0001f4ca Your Stats\n\n" - f" \u2022 Workouts logged: {stats['total_workouts']}\n" - f" \u2022 Unique exercises: {stats['unique_exercises']}\n" - f" \u2022 Total sets: {stats['total_sets']}\n" - f" \u2022 Total volume: {stats['total_volume']:,.0f} kg", - parse_mode=ParseMode.HTML, - ) - - -async def cmd_delete(update: Update, context: ContextTypes.DEFAULT_TYPE): - user_id = update.effective_user.id - log_event(user_id, "cmd.delete", {"args": context.args or []}) - - if not context.args: - await update.message.reply_text( - "Usage: /delete <number>\n" - "Use /history to see workout numbers.", - parse_mode=ParseMode.HTML, - ) - return - - try: - user_number = int(context.args[0]) - except ValueError: - await update.message.reply_text("Workout number must be a number.") - return - - workout_id = resolve_user_number(user_id, user_number) - if workout_id is not None and delete_workout(user_id, workout_id): - log_event(user_id, "workout.delete", {"workout_id": workout_id, "user_number": user_number}) - await update.message.reply_text(f"\U0001f5d1 Workout #{user_number} deleted.") - else: - await update.message.reply_text( - f"Workout #{user_number} not found." - ) - - -async def cmd_export(update: Update, context: ContextTypes.DEFAULT_TYPE): - """Send all workout data as a JSON file.""" - import json - import io - from db import export_workouts - - user_id = update.effective_user.id - log_event(user_id, "cmd.export") - data = export_workouts(user_id) - - if not data: - await update.message.reply_text("No workouts to export.") - return - - content = json.dumps(data, indent=2, ensure_ascii=False) - buf = io.BytesIO(content.encode("utf-8")) - buf.name = "workouts_export.json" - - await update.message.reply_document( - document=buf, - caption=f"\U0001f4e6 Exported {len(data)} exercise records.", - ) - - -async def cmd_feedback(update: Update, context: ContextTypes.DEFAULT_TYPE): - user_id = update.effective_user.id - text = " ".join(context.args) if context.args else "" - - if not text: - await update.message.reply_text( - "Usage: /feedback <your feedback>", - parse_mode=ParseMode.HTML, - ) - return - - save_feedback(user_id, text) - log_event(user_id, "cmd.feedback") - await update.message.reply_text("\U0001f4dd Feedback saved, thanks!") - - -# โ”€โ”€ Message handler (workout parsing) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - -async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE): - """Parse any text message as a potential workout.""" - text = update.effective_message.text - if not text: - return - - groups, errors = parse_workout(text) - - if not groups and not errors: - # Doesn't look like a workout at all โ€” silently ignore - return - - if not groups and errors: - # Looks like they tried but every line failed - error_lines = "\n".join(f" \u2022 {e.line}" for e in errors) - await update.message.reply_text( - f"\u26a0\ufe0f Could not parse workout. Check your format:\n\n" - f"{error_lines}\n\n" - f"Expected formats:\n" - f"Exercise: 4x8x35\n" - f"Exercise: 3x10 (bodyweight)\n" - f"Exercise: 8x25, 5x35, 6x40", - parse_mode=ParseMode.HTML, - ) - return - - user_id = update.effective_user.id - timestamp, is_forwarded = extract_timestamp(update) - - superset_dicts = [[ex.to_dict() for ex in group] for group in groups] - workout_id = save_workout(user_id, timestamp, superset_dicts, raw_text=text) - user_number = get_user_workout_number(user_id, workout_id) or workout_id - log_event(user_id, "workout.save", { - "source": "text", - "workout_id": workout_id, - "user_number": user_number, - "forwarded": is_forwarded, - }) - - # Count totals for the confirmation - total_exercises = sum(len(g) for g in groups) - total_sets = sum(ex.sets for g in groups for ex in g) - supersets = sum(1 for g in groups if len(g) > 1) - - ts_str = timestamp.strftime("%a %d %b %Y, %H:%M") - - confirm_parts = [ - f"\u2705 Workout #{user_number} saved!", - f"\U0001f4c5 {ts_str}" + (" (from forwarded message)" if is_forwarded else ""), - f"\U0001f3cb\ufe0f {total_exercises} exercises, {total_sets} total sets", - ] - if supersets: - confirm_parts.append(f"\U0001f517 {supersets} superset(s)") - - # Show errors for partially parsed workouts - if errors: - skipped = "\n".join(f" \u2022 {e.line}" for e in errors) - confirm_parts.append(f"\n\u26a0\ufe0f Skipped {len(errors)} unparseable line(s):\n{skipped}") - - confirm_parts.append(f"\n{format_workout(superset_dicts)}") - - await update.message.reply_text( - "\n".join(confirm_parts), - parse_mode=ParseMode.HTML, - ) - - -# โ”€โ”€ Main โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - -async def post_init(app: Application): - """Set the bot's menu button to open the Mini App (if URL is available).""" - if WEBAPP_URL: - 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) - else: - logger.info("No WEBAPP_URL โ€” menu button not set") - - -def main(): - init_db() - - builder = ApplicationBuilder().token(BOT_TOKEN) - if WEBAPP_URL: - builder = builder.post_init(post_init) - app = builder.build() - - app.add_handler(CommandHandler("start", cmd_start)) - app.add_handler(CommandHandler("history", cmd_history)) - app.add_handler(CommandHandler("stats", cmd_stats)) - app.add_handler(CommandHandler("delete", cmd_delete)) - app.add_handler(CommandHandler("export", cmd_export)) - app.add_handler(CommandHandler("feedback", cmd_feedback)) - - # Handle all text messages (including forwarded ones) - app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message)) - - logger.info("Bot started \u2014 polling\u2026") - app.run_polling(allowed_updates=Update.ALL_TYPES) - - -if __name__ == "__main__": - main() diff --git a/db.py b/db.py index 5b834c4..410097b 100644 --- a/db.py +++ b/db.py @@ -263,6 +263,39 @@ def resolve_user_number(user_id: int, user_number: int) -> int | None: return row["id"] if row else None +def get_last_exercise(user_id: int, name: str) -> dict | None: + """Return the most recent logged entry for an exercise (case-insensitive + name match) from this user's non-deleted workouts, or None. + + The returned dict carries the exercise fields plus the parent workout's + `timestamp`, and `sets_detail` parsed back into a list. + """ + with get_db() as conn: + row = conn.execute( + """SELECT e.name, e.machine_id, e.sets, e.reps, e.weight_kg, + e.sets_detail, w.timestamp + FROM workouts w + JOIN superset_groups sg ON sg.workout_id = w.id + JOIN exercises e ON e.superset_group_id = sg.id + WHERE w.user_id = ? AND w.deleted_at IS NULL + AND LOWER(e.name) = LOWER(?) + ORDER BY w.timestamp DESC, e.id DESC + LIMIT 1""", + (user_id, name), + ).fetchone() + if not row: + return None + d = dict(row) + if d.get("sets_detail"): + try: + d["sets_detail"] = json.loads(d["sets_detail"]) + except json.JSONDecodeError: + d["sets_detail"] = [] + else: + d["sets_detail"] = [] + return d + + def get_workout_count(user_id: int) -> int: with get_db() as conn: row = conn.execute( diff --git a/flake.nix b/flake.nix index 881d5c0..1896c3c 100644 --- a/flake.nix +++ b/flake.nix @@ -14,7 +14,8 @@ python = pkgs.python3; pythonEnv = python.withPackages (ps: with ps; [ - python-telegram-bot + # python-telegram-bot was used by the now-deleted bot.py polling + # loop; the Mini App backend doesn't need it. Kept off this list. python-dotenv aiohttp pytest @@ -27,8 +28,7 @@ packages = [ pythonEnv pkgs.cloudflared ]; shellHook = '' echo "๐Ÿ’ช BigBiggerBiggestBot dev shell" - echo " Run: python start.py (server + tunnel + bot)" - echo " Run: python bot.py (bot only, no mini app)" + echo " Run: python start.py (server + tunnel)" ''; }; diff --git a/requirements.txt b/requirements.txt index 84250d7..e4d955d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ -python-telegram-bot>=21.0 python-dotenv>=1.0 +aiohttp>=3.9 diff --git a/server.py b/server.py index 8e0ee77..71d2f9a 100644 --- a/server.py +++ b/server.py @@ -17,7 +17,7 @@ from urllib.parse import parse_qs from aiohttp import web -from db import init_db, save_workout, get_workouts, get_workout_count, get_stats_sql, delete_workout, update_workout, export_workouts, get_user_workout_number, get_all_exercise_names, log_event, get_settings, update_settings +from db import init_db, save_workout, get_workouts, get_workout_count, get_stats_sql, delete_workout, update_workout, export_workouts, get_user_workout_number, get_all_exercise_names, log_event, get_settings, update_settings, get_last_exercise from parser import parse_workout, format_workout logging.basicConfig( @@ -283,6 +283,16 @@ async def api_get_exercise_names(request: web.Request): return web.json_response({"exercises": get_all_exercise_names()}) +@require_auth +async def api_get_last_exercise(request: web.Request): + """Return the user's most recent logged entry for a given exercise name.""" + name = request.query.get("name", "").strip() + if not name: + return web.json_response({"error": "Missing name"}, status=400) + last = get_last_exercise(request["user_id"], name) + return web.json_response({"last": last}) + + @require_auth async def api_get_stats(request: web.Request): """Return summary stats for the user.""" @@ -366,6 +376,7 @@ def create_app() -> web.Application: app.router.add_put("/api/workouts/{workout_id}", api_update_workout) app.router.add_delete("/api/workouts/{workout_id}", api_delete_workout) app.router.add_get("/api/exercises", api_get_exercise_names) + app.router.add_get("/api/exercises/last", api_get_last_exercise) app.router.add_get("/api/stats", api_get_stats) app.router.add_get("/api/export/json", api_export_json) app.router.add_get("/api/export/csv", api_export_csv) diff --git a/start.py b/start.py index df1537d..6471e56 100644 --- a/start.py +++ b/start.py @@ -1,11 +1,15 @@ #!/usr/bin/env python3 """ Orchestrator โ€” single entry point for `nix run`. - 1. Loads BOT_TOKEN from ~/.secrets or .env (same as bot.py) - 2. Starts the API server - 3. Starts localtunnel to get a public HTTPS URL - 4. Starts the Telegram bot with WEBAPP_URL set - 5. Cleans up everything on Ctrl+C + 1. Loads BOT_TOKEN (env var โ†’ ~/.secrets โ†’ .env) + 2. Starts the API + Mini App server + 3. Optionally starts a cloudflared Quick Tunnel for a public HTTPS URL + (skipped if WEBAPP_URL is already set, e.g. fronted by a reverse proxy) + 4. Cleans up on Ctrl+C + +The slash-command bot was removed: the Mini App is the only interface. +The bot's identity (token) only matters now for validating Telegram WebApp +initData HMACs and for the menu-button URL โ€” both handled in server.py. """ import os import re @@ -16,6 +20,10 @@ import threading import time import pathlib +# Flush prints line-by-line so the tunnel URL etc. show up promptly in +# systemd journals (stdout is block-buffered when not connected to a TTY). +sys.stdout.reconfigure(line_buffering=True) + SCRIPT_DIR = pathlib.Path(__file__).resolve().parent SECRETS_FILE = pathlib.Path.home() / ".secrets" / "bigbiggerbiggestbot" @@ -23,8 +31,8 @@ SECRETS_FILE = pathlib.Path.home() / ".secrets" / "bigbiggerbiggestbot" def load_token() -> str: """Load bot token: BOT_TOKEN env โ†’ secrets file โ†’ .env in cwd. - Env var wins so multiple instances on the same host (prod + shipyard) - can each get a distinct token via systemd EnvironmentFile. + Env var wins so multiple instances on the same host (prod + shipyard + staging) can each get a distinct token via systemd EnvironmentFile. """ # 1. Already in environment (systemd EnvironmentFile sets this for staging) token = os.environ.get("BOT_TOKEN", "").strip() @@ -32,7 +40,7 @@ def load_token() -> str: print(" Token loaded from BOT_TOKEN env var") return token - # 2. Secrets file (same path as bot.py uses) + # 2. Default secrets file if SECRETS_FILE.is_file(): token = SECRETS_FILE.read_text().strip() if token: @@ -102,21 +110,13 @@ def start_tunnel(port: int) -> tuple[subprocess.Popen, str]: # Keep draining cloudflared output so its pipe buffer doesn't fill up # (which would block the process and kill the tunnel) def _drain(): - for line in proc.stdout: + for _line in proc.stdout: pass threading.Thread(target=_drain, daemon=True).start() return proc, url -def start_bot(bot_token: str, webapp_url: str) -> subprocess.Popen: - env = {**os.environ, "BOT_TOKEN": bot_token, "WEBAPP_URL": webapp_url} - return subprocess.Popen( - [sys.executable, str(SCRIPT_DIR / "bot.py")], - env=env, - ) - - def main(): port = int(os.environ.get("API_PORT", "8080")) procs: list[subprocess.Popen] = [] @@ -140,11 +140,11 @@ def main(): print() print(" ==========================================") - print(" BigBiggerBiggest โ€” Fitness Tracker") + print(" BigBiggerBiggest โ€” Mini App backend") print(" ==========================================") print() - # 1. Load token + # 1. Load token (used by server.py to validate initData HMACs) bot_token = load_token() masked = bot_token[:5] + "..." + bot_token[-4:] print(f" BOT_TOKEN: {masked}") @@ -159,11 +159,10 @@ def main(): print(" Server failed to start!") sys.exit(1) - # 3. Tunnel โ€” skipped if WEBAPP_URL is already provided (e.g. the bot - # is fronted by an external reverse proxy that terminates TLS and - # proxies back to localhost:$API_PORT over a private network). + # 3. Tunnel โ€” skipped if WEBAPP_URL is already provided (e.g. fronted + # by an external reverse proxy that terminates TLS and proxies back to + # localhost:$API_PORT over a private network). env_webapp_url = os.environ.get("WEBAPP_URL", "").strip() - tunnel = None if env_webapp_url: webapp_url = env_webapp_url print(f" WEBAPP_URL from environment: {webapp_url} (skipping cloudflared)") @@ -171,27 +170,21 @@ def main(): tunnel, webapp_url = start_tunnel(port) procs.append(tunnel) - # 4. Start bot - print(f"\n WEBAPP_URL: {webapp_url}") - print(" Starting bot...\n") - bot = start_bot(bot_token, webapp_url) - procs.append(bot) - - print(" ==========================================") - print(f" All systems go!") + print("\n ==========================================") + print(" All systems go!") print(f" Mini App: {webapp_url}") print(f" API: http://localhost:{port}") - print(f" Press Ctrl+C to stop") - print(" ==========================================") - print() + print(" Press Ctrl+C to stop") + print(" ==========================================\n") + + proc_names = {id(server): "Server"} + if procs[-1] is not server: + proc_names[id(procs[-1])] = "Tunnel" while True: for p in procs: ret = p.poll() if ret is not None: - proc_names = {id(server): "Server", id(bot): "Bot"} - if tunnel is not None: - proc_names[id(tunnel)] = "Tunnel" name = proc_names.get(id(p), "?") print(f"\n {name} exited with code {ret}") cleanup() diff --git a/tests/test_db.py b/tests/test_db.py index 82bfd88..d770e8e 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -253,6 +253,48 @@ class TestAllExerciseNames: assert db.get_all_exercise_names() == ["Apple", "Mango", "Zebra"] +# โ”€โ”€ get_last_exercise โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +class TestGetLastExercise: + def test_none_when_no_history(self, tmp_db): + assert db.get_last_exercise(1, "Bench") is None + + def test_returns_most_recent(self, tmp_db): + t = lambda d: datetime(2024, 1, d, tzinfo=timezone.utc) + db.save_workout(1, t(1), [[_make_exercise(name="Squat", weight=80.0)]]) + db.save_workout(1, t(5), [[_make_exercise(name="Squat", weight=90.0)]]) + last = db.get_last_exercise(1, "Squat") + assert last is not None + assert last["weight_kg"] == 90.0 + assert last["timestamp"].startswith("2024-01-05") + + def test_case_insensitive(self, tmp_db): + _save_simple(name="Bench Press") + assert db.get_last_exercise(1, "bench press") is not None + assert db.get_last_exercise(1, "BENCH PRESS") is not None + + def test_sets_detail_parsed(self, tmp_db): + detail = [{"reps": 8, "weight_kg": 25.0}, {"reps": 5, "weight_kg": 35.0}] + ex = { + "name": "Press", "machine_id": None, + "sets": 2, "reps": 8, "weight_kg": 25.0, + "sets_detail": detail, "raw_line": "Press: 8x25, 5x35", + } + db.save_workout(1, datetime.now(timezone.utc), [[ex]]) + last = db.get_last_exercise(1, "Press") + assert last["sets_detail"] == detail + + def test_scoped_to_user(self, tmp_db): + _save_simple(user_id=1, name="Deadlift") + assert db.get_last_exercise(2, "Deadlift") is None + + def test_ignores_deleted(self, tmp_db): + wid = _save_simple(name="Rows") + db.delete_workout(1, wid) + assert db.get_last_exercise(1, "Rows") is None + + # โ”€โ”€ events / log_event โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ diff --git a/webapp/app.js b/webapp/app.js index 6559fac..7f8577a 100644 --- a/webapp/app.js +++ b/webapp/app.js @@ -125,6 +125,7 @@ function restoreDraft() { if (Array.isArray(draft.currentSets)) { draft.currentSets.forEach((s) => addSetToDOM(s.reps, s.weight_kg)); } + loadLastSession(currentExercise.name); } // Restore active tab @@ -357,10 +358,72 @@ function startExercise(name) { notesSection.classList.remove("hidden"); stopRestTimer(); syncEditorUI(); + loadLastSession(name); tg.HapticFeedback.selectionChanged(); saveDraft(); } +// โ”€โ”€ Last-session recall โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +function _relativeDay(iso) { + const then = new Date(iso); + if (isNaN(then.getTime())) return ""; + const days = Math.floor((Date.now() - then.getTime()) / 86400000); + if (days <= 0) return "today"; + if (days === 1) return "yesterday"; + if (days < 7) return days + " days ago"; + if (days < 14) return "1 week ago"; + if (days < 30) return Math.floor(days / 7) + " weeks ago"; + return then.toLocaleDateString(); +} + +function _setsSummary(last) { + const detail = last.sets_detail || []; + const varied = detail.length > 0 && !detail.every( + (d) => d.reps === detail[0].reps && d.weight_kg === detail[0].weight_kg + ); + if (varied) { + return detail + .map((d) => (d.weight_kg ? `${d.reps}ร—${fmtWeight(d.weight_kg)}kg` : `${d.reps}`)) + .join(", "); + } + return last.weight_kg + ? `${last.sets}ร—${last.reps}ร—${fmtWeight(last.weight_kg)}kg` + : `${last.sets}ร—${last.reps}`; +} + +async function loadLastSession(name) { + const hint = document.getElementById("last-session-hint"); + if (hint) { + hint.classList.add("hidden"); + hint.textContent = ""; + } + try { + const data = await api("GET", "/exercises/last?name=" + encodeURIComponent(name)); + const last = data.last; + // Bail if the user has moved on to a different exercise meanwhile. + if (!last || !currentExercise || currentExercise.name !== name) return; + + if (hint) { + const when = _relativeDay(last.timestamp); + hint.textContent = "Last time: " + _setsSummary(last) + (when ? " ยท " + when : ""); + hint.classList.remove("hidden"); + } + + // Pre-fill the weight input with the last set's weight, but only if the + // user hasn't already started typing or logged a set. + const detail = last.sets_detail || []; + const lastWeight = detail.length + ? detail[detail.length - 1].weight_kg + : last.weight_kg; + if (lastWeight && !weightInput.value.trim() && getCurrentSets().length === 0) { + weightInput.value = String(lastWeight); + syncWeightSignUI(); + } + } catch (e) { + // Silent โ€” recall is a convenience, never block exercise entry. + } +} + function getCurrentSets() { return Array.from(setsList.querySelectorAll(".set-entry")).map((el) => ({ reps: parseInt(el.dataset.reps), @@ -592,6 +655,14 @@ function editExercise(idx) { repsInput.value = ""; repsInput.focus(); + // The set rows in front of you are the reference here โ€” drop any stale + // "last time" hint from an earlier startExercise. + const hint = document.getElementById("last-session-hint"); + if (hint) { + hint.classList.add("hidden"); + hint.textContent = ""; + } + stopRestTimer(); renderWorkout(); tg.HapticFeedback.selectionChanged(); diff --git a/webapp/index.html b/webapp/index.html index dc5f84a..16844cf 100644 --- a/webapp/index.html +++ b/webapp/index.html @@ -37,6 +37,7 @@ +
diff --git a/webapp/style.css b/webapp/style.css index 5e5e144..06d29c2 100644 --- a/webapp/style.css +++ b/webapp/style.css @@ -241,6 +241,15 @@ body { font-variant-numeric: tabular-nums; } +.last-session-hint { + margin-top: 6px; + font-size: 12px; + color: var(--tg-theme-hint-color, #999); + background: var(--tg-theme-bg-color, #fff); + border-radius: 8px; + padding: 6px 10px; +} + .btn-danger { color: #e53935 !important; font-size: 12px !important;