Compare commits

..

No commits in common. "9f146d60fa08643e3de18e43bc7d933377b926a2" and "aa43e492c30cf6cad0222f52ea957a026e38ef20" have entirely different histories.

12 changed files with 408 additions and 227 deletions

View file

@ -1,13 +1,14 @@
# BigBiggerBiggestBot 💪
A Telegram **Mini App** for logging gym workouts. History, stats, notes,
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,
edit & delete, JSON/CSV export — all per-user, all in SQLite.
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.
## Format
## Workout text format (still supported via "Paste as text" in the Mini App)
Send messages like:
```
Bench press: 4x8x35
@ -22,6 +23,15 @@ 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 <id>` — soft-delete a workout
- `/export` — download all data as JSON
- `/feedback <text>` — send feedback to the bot author
## Run locally
```bash
@ -29,9 +39,9 @@ nix run
```
This launches:
- 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)
- API server (port 8080)
- cloudflared tunnel for the Mini App
- Telegram bot (polling)
Put your bot token (from [@BotFather](https://t.me/BotFather)) in
`~/.secrets/bigbiggerbiggestbot` or a `.env` file:
@ -53,12 +63,10 @@ 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 at
`https://bbbot.dannydannydanny.me`.
watches `origin/main`, served behind a stable URL via the VPS Caddy.
- **Shipyard staging**`fitness-bot-shipyard.service`, working dir
`/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).
`/home/danny/tg_fitness_bot_shipyard`, watches `origin/staging`, separate
bot token, ephemeral cloudflared URL each restart.
Each has its own pull timer that fetches every ~15 minutes and restarts
the service when its branch has new commits.
@ -66,6 +74,7 @@ the service when its branch has new commits.
**Workflow:**
```
# 1. land changes on a working branch (or main locally)
git push origin <branch>:staging # → shipyard auto-deploys, test there
git push origin <branch>:main # → production auto-deploys
```
@ -75,16 +84,13 @@ so testing on shipyard never touches production data.
## Architecture
- `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.
- `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
## License

View file

@ -15,9 +15,8 @@
- [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 <id>` 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 — 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 <branch>:staging` (auto-deploys ~15 min) → tap **B3Bot beta** in shipyard_poc_bot → test → `git push origin <branch>:main`.
- [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 <branch>:staging` (auto-deploys ~15 min) → `/start` shipyard_poc_bot → test → `git push origin <branch>: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

336
bot.py Normal file
View file

@ -0,0 +1,336 @@
"""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 <b>Fitness Tracker Bot</b>\n\n"
"Send me your workout and I'll save it!\n\n"
"<b>Formats:</b>\n"
"<code>Bench press: 4x8x35</code>\n"
"<code>Pull-ups: 3x10</code> (bodyweight)\n"
"<code>Shoulder press (3032): 8x25, 5x35, 6x40</code>\n\n"
"Lines without a blank line between them = superset.\n"
"Machine IDs go in parentheses.\n\n"
"You can also <b>forward</b> messages from Saved Messages \u2014 "
"I'll use the original timestamp.\n\n"
"<b>Commands:</b>\n"
"/history \u2014 view recent workouts\n"
"/stats \u2014 quick summary\n"
"/delete &lt;number&gt; \u2014 delete a workout (see /history)\n"
"/export \u2014 export all data as JSON\n"
"/feedback &lt;text&gt; \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 <b>{ts.strftime('%a %d %b %Y, %H:%M')}</b> (#{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\n<i>Showing latest 5 of {total} workouts.</i>"
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 <b>Your Stats</b>\n\n"
f" \u2022 Workouts logged: <b>{stats['total_workouts']}</b>\n"
f" \u2022 Unique exercises: <b>{stats['unique_exercises']}</b>\n"
f" \u2022 Total sets: <b>{stats['total_sets']}</b>\n"
f" \u2022 Total volume: <b>{stats['total_volume']:,.0f} kg</b>",
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 &lt;number&gt;\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 &lt;your feedback&gt;",
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 <code>{e.line}</code>" 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"<b>Expected formats:</b>\n"
f"<code>Exercise: 4x8x35</code>\n"
f"<code>Exercise: 3x10</code> (bodyweight)\n"
f"<code>Exercise: 8x25, 5x35, 6x40</code>",
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 <b>Workout #{user_number} saved!</b>",
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 <code>{e.line}</code>" 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()

33
db.py
View file

@ -263,39 +263,6 @@ 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(

View file

@ -14,8 +14,7 @@
python = pkgs.python3;
pythonEnv = python.withPackages (ps: with ps; [
# 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-telegram-bot
python-dotenv
aiohttp
pytest
@ -28,7 +27,8 @@
packages = [ pythonEnv pkgs.cloudflared ];
shellHook = ''
echo "💪 BigBiggerBiggestBot dev shell"
echo " Run: python start.py (server + tunnel)"
echo " Run: python start.py (server + tunnel + bot)"
echo " Run: python bot.py (bot only, no mini app)"
'';
};

View file

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

View file

@ -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, get_last_exercise
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 parser import parse_workout, format_workout
logging.basicConfig(
@ -283,16 +283,6 @@ 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."""
@ -376,7 +366,6 @@ 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)

View file

@ -1,15 +1,11 @@
#!/usr/bin/env python3
"""
Orchestrator single entry point for `nix run`.
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.
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
"""
import os
import re
@ -20,10 +16,6 @@ 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"
@ -31,8 +23,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
staging) can each get a distinct token via systemd EnvironmentFile.
Env var wins so multiple instances on the same host (prod + shipyard)
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()
@ -40,7 +32,7 @@ def load_token() -> str:
print(" Token loaded from BOT_TOKEN env var")
return token
# 2. Default secrets file
# 2. Secrets file (same path as bot.py uses)
if SECRETS_FILE.is_file():
token = SECRETS_FILE.read_text().strip()
if token:
@ -110,13 +102,21 @@ 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 — Mini App backend")
print(" BigBiggerBiggest — Fitness Tracker")
print(" ==========================================")
print()
# 1. Load token (used by server.py to validate initData HMACs)
# 1. Load token
bot_token = load_token()
masked = bot_token[:5] + "..." + bot_token[-4:]
print(f" BOT_TOKEN: {masked}")
@ -159,10 +159,11 @@ def main():
print(" Server failed to start!")
sys.exit(1)
# 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).
# 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).
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)")
@ -170,21 +171,27 @@ def main():
tunnel, webapp_url = start_tunnel(port)
procs.append(tunnel)
print("\n ==========================================")
print(" All systems go!")
# 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(f" Mini App: {webapp_url}")
print(f" API: http://localhost:{port}")
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"
print(f" Press Ctrl+C to stop")
print(" ==========================================")
print()
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()

View file

@ -253,48 +253,6 @@ 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 ───────────────────────────────────────────

View file

@ -125,7 +125,6 @@ function restoreDraft() {
if (Array.isArray(draft.currentSets)) {
draft.currentSets.forEach((s) => addSetToDOM(s.reps, s.weight_kg));
}
loadLastSession(currentExercise.name);
}
// Restore active tab
@ -358,72 +357,10 @@ 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),
@ -655,14 +592,6 @@ 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();

View file

@ -37,7 +37,6 @@
</div>
<button id="btn-delete-exercise" class="btn-link btn-danger hidden">Remove exercise</button>
</div>
<div id="last-session-hint" class="last-session-hint hidden"></div>
<div id="sets-list"></div>
<div class="set-input-row">
<input type="text" id="inp-reps" class="input input-small" placeholder="Reps" inputmode="numeric" pattern="[0-9]*" />

View file

@ -241,15 +241,6 @@ 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;