Compare commits
No commits in common. "9f146d60fa08643e3de18e43bc7d933377b926a2" and "aa43e492c30cf6cad0222f52ea957a026e38ef20" have entirely different histories.
9f146d60fa
...
aa43e492c3
12 changed files with 408 additions and 227 deletions
52
README.md
52
README.md
|
|
@ -1,13 +1,14 @@
|
||||||
# BigBiggerBiggestBot 💪
|
# 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.
|
edit & delete, JSON/CSV export — all per-user, all in SQLite.
|
||||||
|
|
||||||
The slash-command bot was removed: the Mini App is the only interface.
|
## Format
|
||||||
A Telegram bot identity (token) is still required so the Mini App can
|
|
||||||
validate user sessions via `initData` HMAC.
|
|
||||||
|
|
||||||
## Workout text format (still supported via "Paste as text" in the Mini App)
|
Send messages like:
|
||||||
|
|
||||||
```
|
```
|
||||||
Bench press: 4x8x35
|
Bench press: 4x8x35
|
||||||
|
|
@ -22,6 +23,15 @@ Pull-ups: 3x10
|
||||||
- Blank line separates superset groups; consecutive lines form a superset
|
- Blank line separates superset groups; consecutive lines form a superset
|
||||||
- Both `,` and `.` work as decimal separators
|
- 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
|
## Run locally
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -29,9 +39,9 @@ nix run
|
||||||
```
|
```
|
||||||
|
|
||||||
This launches:
|
This launches:
|
||||||
- API + Mini App server (port 8080)
|
- API server (port 8080)
|
||||||
- cloudflared Quick Tunnel for a public HTTPS URL (skipped if `WEBAPP_URL`
|
- cloudflared tunnel for the Mini App
|
||||||
is already set in the environment, e.g. fronted by a reverse proxy)
|
- Telegram bot (polling)
|
||||||
|
|
||||||
Put your bot token (from [@BotFather](https://t.me/BotFather)) in
|
Put your bot token (from [@BotFather](https://t.me/BotFather)) in
|
||||||
`~/.secrets/bigbiggerbiggestbot` or a `.env` file:
|
`~/.secrets/bigbiggerbiggestbot` or a `.env` file:
|
||||||
|
|
@ -53,12 +63,10 @@ nix develop --command pytest tests/ -v
|
||||||
Two environments share one host (`sunken-ship`):
|
Two environments share one host (`sunken-ship`):
|
||||||
|
|
||||||
- **Production** — `fitness-bot.service`, working dir `/home/danny/tg_fitness_bot`,
|
- **Production** — `fitness-bot.service`, working dir `/home/danny/tg_fitness_bot`,
|
||||||
watches `origin/main`, served behind a stable URL via the VPS Caddy at
|
watches `origin/main`, served behind a stable URL via the VPS Caddy.
|
||||||
`https://bbbot.dannydannydanny.me`.
|
|
||||||
- **Shipyard staging** — `fitness-bot-shipyard.service`, working dir
|
- **Shipyard staging** — `fitness-bot-shipyard.service`, working dir
|
||||||
`/home/danny/tg_fitness_bot_shipyard`, watches `origin/staging`, served
|
`/home/danny/tg_fitness_bot_shipyard`, watches `origin/staging`, separate
|
||||||
by the shared `shipyard_poc_bot` Telegram bot (B3Bot beta is the active
|
bot token, ephemeral cloudflared URL each restart.
|
||||||
POC tenant).
|
|
||||||
|
|
||||||
Each has its own pull timer that fetches every ~15 minutes and restarts
|
Each has its own pull timer that fetches every ~15 minutes and restarts
|
||||||
the service when its branch has new commits.
|
the service when its branch has new commits.
|
||||||
|
|
@ -66,6 +74,7 @@ the service when its branch has new commits.
|
||||||
**Workflow:**
|
**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>:staging # → shipyard auto-deploys, test there
|
||||||
git push origin <branch>:main # → production auto-deploys
|
git push origin <branch>:main # → production auto-deploys
|
||||||
```
|
```
|
||||||
|
|
@ -75,16 +84,13 @@ so testing on shipyard never touches production data.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
- `server.py` — aiohttp REST API + static file server for the Mini App;
|
- `bot.py` — Telegram command handlers, polling, message parsing
|
||||||
validates Telegram `initData` HMACs against the bot token.
|
- `server.py` — aiohttp REST API + static file server for the Mini App
|
||||||
- `db.py` — SQLite data layer (workouts, supersets, exercises, feedback,
|
- `db.py` — SQLite data layer (workouts, supersets, exercises, feedback; soft delete)
|
||||||
events, settings; soft delete).
|
- `parser.py` — workout text → structured data
|
||||||
- `parser.py` — workout text → structured data (used by the Mini App's
|
- `webapp/` — Mini App (HTML/CSS/vanilla JS, Telegram WebApp SDK)
|
||||||
"Paste as text" path).
|
- `start.py` — orchestrator: starts server + tunnel + bot, wires up the Mini App URL
|
||||||
- `webapp/` — Mini App (HTML/CSS/vanilla JS, Telegram WebApp SDK).
|
- `tests/` — pytest suite for parser + db
|
||||||
- `start.py` — orchestrator: loads token, starts server, optionally starts
|
|
||||||
cloudflared.
|
|
||||||
- `tests/` — pytest suite for parser + db.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,8 @@
|
||||||
- [x] Global exercise name suggestions — autocomplete draws from all users' exercises, ordered by popularity, case-insensitively grouped.
|
- [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.
|
- [ ] **#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] 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.
|
- [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
|
## Later
|
||||||
- [ ] **#8** Workout templates — save/load favorite workouts
|
- [ ] **#8** Workout templates — save/load favorite workouts
|
||||||
|
|
|
||||||
336
bot.py
Normal file
336
bot.py
Normal 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 <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 <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 <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 <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
33
db.py
|
|
@ -263,39 +263,6 @@ def resolve_user_number(user_id: int, user_number: int) -> int | None:
|
||||||
return row["id"] if row else 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:
|
def get_workout_count(user_id: int) -> int:
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,7 @@
|
||||||
python = pkgs.python3;
|
python = pkgs.python3;
|
||||||
|
|
||||||
pythonEnv = python.withPackages (ps: with ps; [
|
pythonEnv = python.withPackages (ps: with ps; [
|
||||||
# python-telegram-bot was used by the now-deleted bot.py polling
|
python-telegram-bot
|
||||||
# loop; the Mini App backend doesn't need it. Kept off this list.
|
|
||||||
python-dotenv
|
python-dotenv
|
||||||
aiohttp
|
aiohttp
|
||||||
pytest
|
pytest
|
||||||
|
|
@ -28,7 +27,8 @@
|
||||||
packages = [ pythonEnv pkgs.cloudflared ];
|
packages = [ pythonEnv pkgs.cloudflared ];
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
echo "💪 BigBiggerBiggestBot dev shell"
|
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)"
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,2 @@
|
||||||
|
python-telegram-bot>=21.0
|
||||||
python-dotenv>=1.0
|
python-dotenv>=1.0
|
||||||
aiohttp>=3.9
|
|
||||||
|
|
|
||||||
13
server.py
13
server.py
|
|
@ -17,7 +17,7 @@ from urllib.parse import parse_qs
|
||||||
|
|
||||||
from aiohttp import web
|
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
|
from parser import parse_workout, format_workout
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
|
|
@ -283,16 +283,6 @@ async def api_get_exercise_names(request: web.Request):
|
||||||
return web.json_response({"exercises": get_all_exercise_names()})
|
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
|
@require_auth
|
||||||
async def api_get_stats(request: web.Request):
|
async def api_get_stats(request: web.Request):
|
||||||
"""Return summary stats for the user."""
|
"""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_put("/api/workouts/{workout_id}", api_update_workout)
|
||||||
app.router.add_delete("/api/workouts/{workout_id}", api_delete_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", 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/stats", api_get_stats)
|
||||||
app.router.add_get("/api/export/json", api_export_json)
|
app.router.add_get("/api/export/json", api_export_json)
|
||||||
app.router.add_get("/api/export/csv", api_export_csv)
|
app.router.add_get("/api/export/csv", api_export_csv)
|
||||||
|
|
|
||||||
67
start.py
67
start.py
|
|
@ -1,15 +1,11 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Orchestrator — single entry point for `nix run`.
|
Orchestrator — single entry point for `nix run`.
|
||||||
1. Loads BOT_TOKEN (env var → ~/.secrets → .env)
|
1. Loads BOT_TOKEN from ~/.secrets or .env (same as bot.py)
|
||||||
2. Starts the API + Mini App server
|
2. Starts the API server
|
||||||
3. Optionally starts a cloudflared Quick Tunnel for a public HTTPS URL
|
3. Starts localtunnel to get a public HTTPS URL
|
||||||
(skipped if WEBAPP_URL is already set, e.g. fronted by a reverse proxy)
|
4. Starts the Telegram bot with WEBAPP_URL set
|
||||||
4. Cleans up on Ctrl+C
|
5. Cleans up everything 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 os
|
||||||
import re
|
import re
|
||||||
|
|
@ -20,10 +16,6 @@ import threading
|
||||||
import time
|
import time
|
||||||
import pathlib
|
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
|
SCRIPT_DIR = pathlib.Path(__file__).resolve().parent
|
||||||
SECRETS_FILE = pathlib.Path.home() / ".secrets" / "bigbiggerbiggestbot"
|
SECRETS_FILE = pathlib.Path.home() / ".secrets" / "bigbiggerbiggestbot"
|
||||||
|
|
||||||
|
|
@ -31,8 +23,8 @@ SECRETS_FILE = pathlib.Path.home() / ".secrets" / "bigbiggerbiggestbot"
|
||||||
def load_token() -> str:
|
def load_token() -> str:
|
||||||
"""Load bot token: BOT_TOKEN env → secrets file → .env in cwd.
|
"""Load bot token: BOT_TOKEN env → secrets file → .env in cwd.
|
||||||
|
|
||||||
Env var wins so multiple instances on the same host (prod + shipyard
|
Env var wins so multiple instances on the same host (prod + shipyard)
|
||||||
staging) can each get a distinct token via systemd EnvironmentFile.
|
can each get a distinct token via systemd EnvironmentFile.
|
||||||
"""
|
"""
|
||||||
# 1. Already in environment (systemd EnvironmentFile sets this for staging)
|
# 1. Already in environment (systemd EnvironmentFile sets this for staging)
|
||||||
token = os.environ.get("BOT_TOKEN", "").strip()
|
token = os.environ.get("BOT_TOKEN", "").strip()
|
||||||
|
|
@ -40,7 +32,7 @@ def load_token() -> str:
|
||||||
print(" Token loaded from BOT_TOKEN env var")
|
print(" Token loaded from BOT_TOKEN env var")
|
||||||
return token
|
return token
|
||||||
|
|
||||||
# 2. Default secrets file
|
# 2. Secrets file (same path as bot.py uses)
|
||||||
if SECRETS_FILE.is_file():
|
if SECRETS_FILE.is_file():
|
||||||
token = SECRETS_FILE.read_text().strip()
|
token = SECRETS_FILE.read_text().strip()
|
||||||
if token:
|
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
|
# Keep draining cloudflared output so its pipe buffer doesn't fill up
|
||||||
# (which would block the process and kill the tunnel)
|
# (which would block the process and kill the tunnel)
|
||||||
def _drain():
|
def _drain():
|
||||||
for _line in proc.stdout:
|
for line in proc.stdout:
|
||||||
pass
|
pass
|
||||||
threading.Thread(target=_drain, daemon=True).start()
|
threading.Thread(target=_drain, daemon=True).start()
|
||||||
|
|
||||||
return proc, url
|
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():
|
def main():
|
||||||
port = int(os.environ.get("API_PORT", "8080"))
|
port = int(os.environ.get("API_PORT", "8080"))
|
||||||
procs: list[subprocess.Popen] = []
|
procs: list[subprocess.Popen] = []
|
||||||
|
|
@ -140,11 +140,11 @@ def main():
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print(" ==========================================")
|
print(" ==========================================")
|
||||||
print(" BigBiggerBiggest — Mini App backend")
|
print(" BigBiggerBiggest — Fitness Tracker")
|
||||||
print(" ==========================================")
|
print(" ==========================================")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
# 1. Load token (used by server.py to validate initData HMACs)
|
# 1. Load token
|
||||||
bot_token = load_token()
|
bot_token = load_token()
|
||||||
masked = bot_token[:5] + "..." + bot_token[-4:]
|
masked = bot_token[:5] + "..." + bot_token[-4:]
|
||||||
print(f" BOT_TOKEN: {masked}")
|
print(f" BOT_TOKEN: {masked}")
|
||||||
|
|
@ -159,10 +159,11 @@ def main():
|
||||||
print(" Server failed to start!")
|
print(" Server failed to start!")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# 3. Tunnel — skipped if WEBAPP_URL is already provided (e.g. fronted
|
# 3. Tunnel — skipped if WEBAPP_URL is already provided (e.g. the bot
|
||||||
# by an external reverse proxy that terminates TLS and proxies back to
|
# is fronted by an external reverse proxy that terminates TLS and
|
||||||
# localhost:$API_PORT over a private network).
|
# proxies back to localhost:$API_PORT over a private network).
|
||||||
env_webapp_url = os.environ.get("WEBAPP_URL", "").strip()
|
env_webapp_url = os.environ.get("WEBAPP_URL", "").strip()
|
||||||
|
tunnel = None
|
||||||
if env_webapp_url:
|
if env_webapp_url:
|
||||||
webapp_url = env_webapp_url
|
webapp_url = env_webapp_url
|
||||||
print(f" WEBAPP_URL from environment: {webapp_url} (skipping cloudflared)")
|
print(f" WEBAPP_URL from environment: {webapp_url} (skipping cloudflared)")
|
||||||
|
|
@ -170,21 +171,27 @@ def main():
|
||||||
tunnel, webapp_url = start_tunnel(port)
|
tunnel, webapp_url = start_tunnel(port)
|
||||||
procs.append(tunnel)
|
procs.append(tunnel)
|
||||||
|
|
||||||
print("\n ==========================================")
|
# 4. Start bot
|
||||||
print(" All systems go!")
|
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" Mini App: {webapp_url}")
|
||||||
print(f" API: http://localhost:{port}")
|
print(f" API: http://localhost:{port}")
|
||||||
print(" Press Ctrl+C to stop")
|
print(f" Press Ctrl+C to stop")
|
||||||
print(" ==========================================\n")
|
print(" ==========================================")
|
||||||
|
print()
|
||||||
proc_names = {id(server): "Server"}
|
|
||||||
if procs[-1] is not server:
|
|
||||||
proc_names[id(procs[-1])] = "Tunnel"
|
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
for p in procs:
|
for p in procs:
|
||||||
ret = p.poll()
|
ret = p.poll()
|
||||||
if ret is not None:
|
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), "?")
|
name = proc_names.get(id(p), "?")
|
||||||
print(f"\n {name} exited with code {ret}")
|
print(f"\n {name} exited with code {ret}")
|
||||||
cleanup()
|
cleanup()
|
||||||
|
|
|
||||||
|
|
@ -253,48 +253,6 @@ class TestAllExerciseNames:
|
||||||
assert db.get_all_exercise_names() == ["Apple", "Mango", "Zebra"]
|
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 ───────────────────────────────────────────
|
# ── events / log_event ───────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -125,7 +125,6 @@ function restoreDraft() {
|
||||||
if (Array.isArray(draft.currentSets)) {
|
if (Array.isArray(draft.currentSets)) {
|
||||||
draft.currentSets.forEach((s) => addSetToDOM(s.reps, s.weight_kg));
|
draft.currentSets.forEach((s) => addSetToDOM(s.reps, s.weight_kg));
|
||||||
}
|
}
|
||||||
loadLastSession(currentExercise.name);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore active tab
|
// Restore active tab
|
||||||
|
|
@ -358,72 +357,10 @@ function startExercise(name) {
|
||||||
notesSection.classList.remove("hidden");
|
notesSection.classList.remove("hidden");
|
||||||
stopRestTimer();
|
stopRestTimer();
|
||||||
syncEditorUI();
|
syncEditorUI();
|
||||||
loadLastSession(name);
|
|
||||||
tg.HapticFeedback.selectionChanged();
|
tg.HapticFeedback.selectionChanged();
|
||||||
saveDraft();
|
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() {
|
function getCurrentSets() {
|
||||||
return Array.from(setsList.querySelectorAll(".set-entry")).map((el) => ({
|
return Array.from(setsList.querySelectorAll(".set-entry")).map((el) => ({
|
||||||
reps: parseInt(el.dataset.reps),
|
reps: parseInt(el.dataset.reps),
|
||||||
|
|
@ -655,14 +592,6 @@ function editExercise(idx) {
|
||||||
repsInput.value = "";
|
repsInput.value = "";
|
||||||
repsInput.focus();
|
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();
|
stopRestTimer();
|
||||||
renderWorkout();
|
renderWorkout();
|
||||||
tg.HapticFeedback.selectionChanged();
|
tg.HapticFeedback.selectionChanged();
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,6 @@
|
||||||
</div>
|
</div>
|
||||||
<button id="btn-delete-exercise" class="btn-link btn-danger hidden">Remove exercise</button>
|
<button id="btn-delete-exercise" class="btn-link btn-danger hidden">Remove exercise</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="last-session-hint" class="last-session-hint hidden"></div>
|
|
||||||
<div id="sets-list"></div>
|
<div id="sets-list"></div>
|
||||||
<div class="set-input-row">
|
<div class="set-input-row">
|
||||||
<input type="text" id="inp-reps" class="input input-small" placeholder="Reps" inputmode="numeric" pattern="[0-9]*" />
|
<input type="text" id="inp-reps" class="input input-small" placeholder="Reps" inputmode="numeric" pattern="[0-9]*" />
|
||||||
|
|
|
||||||
|
|
@ -241,15 +241,6 @@ body {
|
||||||
font-variant-numeric: tabular-nums;
|
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 {
|
.btn-danger {
|
||||||
color: #e53935 !important;
|
color: #e53935 !important;
|
||||||
font-size: 12px !important;
|
font-size: 12px !important;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue