feat(tg-fitness-bot): multi-set format, delete, export, SQL stats

Parser now supports per-set notation (8x25, 5x35, 6x40),
bodyweight exercises (3x10), and asterisk separators.
Failed parse lines get user-facing error feedback instead of
being silently ignored.

Added /delete <id> and /export commands. Stats computed in SQL
instead of loading all workouts into memory. API gains DELETE,
CSV and JSON export endpoints.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Danny 2026-04-04 13:34:02 +02:00
parent ae09ab2eec
commit a934c46746
4 changed files with 356 additions and 111 deletions

141
bot.py
View file

@ -16,7 +16,7 @@ from telegram.ext import (
filters, filters,
) )
from db import init_db, save_workout, get_workouts, get_workout_count from db import init_db, save_workout, get_workouts, get_workout_count, get_stats_sql, delete_workout
from parser import parse_workout, format_workout from parser import parse_workout, format_workout
load_dotenv() load_dotenv()
@ -48,7 +48,7 @@ def _load_token() -> str:
BOT_TOKEN = _load_token() BOT_TOKEN = _load_token()
# Mini App URL — set automatically by start.py via localtunnel # Mini App URL — set automatically by start.py via tunnel
WEBAPP_URL = os.environ.get("WEBAPP_URL", "") WEBAPP_URL = os.environ.get("WEBAPP_URL", "")
@ -79,19 +79,21 @@ def extract_timestamp(update: Update) -> tuple[datetime, bool]:
async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE): async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE):
text = ( text = (
"💪 <b>Fitness Tracker Bot</b>\n\n" "\U0001f4aa <b>Fitness Tracker Bot</b>\n\n"
"Send me your workout and I'll save it!\n\n" "Send me your workout and I'll save it!\n\n"
"<b>Format:</b>\n" "<b>Formats:</b>\n"
"<code>Bench press: 4x8x35</code>\n" "<code>Bench press: 4x8x35</code>\n"
"<code>Lateral raise: 4x8x4</code>\n\n" "<code>Pull-ups: 3x10</code> (bodyweight)\n"
"<code>Tri Press rom: 3x10x45</code>\n\n" "<code>Shoulder press (3032): 8x25, 5x35, 6x40</code>\n\n"
"Lines without a blank line between them = superset.\n" "Lines without a blank line between them = superset.\n"
"Machine IDs go in parentheses: <code>Lat pulldown (500): 3x5x45</code>\n\n" "Machine IDs go in parentheses.\n\n"
"You can also <b>forward</b> messages from Saved Messages " "You can also <b>forward</b> messages from Saved Messages \u2014 "
"I'll use the original timestamp.\n\n" "I'll use the original timestamp.\n\n"
"<b>Commands:</b>\n" "<b>Commands:</b>\n"
"/history — view recent workouts\n" "/history \u2014 view recent workouts\n"
"/stats — quick summary" "/stats \u2014 quick summary\n"
"/delete &lt;id&gt; \u2014 delete a workout\n"
"/export \u2014 export all data as JSON"
) )
if WEBAPP_URL: if WEBAPP_URL:
@ -118,11 +120,11 @@ async def cmd_history(update: Update, context: ContextTypes.DEFAULT_TYPE):
parts = [] parts = []
for w in workouts: for w in workouts:
ts = datetime.fromisoformat(w["timestamp"]) ts = datetime.fromisoformat(w["timestamp"])
header = f"📅 <b>{ts.strftime('%a %d %b %Y, %H:%M')}</b>" header = f"\U0001f4c5 <b>{ts.strftime('%a %d %b %Y, %H:%M')}</b> (#{w['id']})"
body = format_workout(w["superset_groups"]) body = format_workout(w["superset_groups"])
parts.append(f"{header}\n{body}") parts.append(f"{header}\n{body}")
text = "\n\n───────────────\n\n".join(parts) 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) total = get_workout_count(user_id)
text += f"\n\n<i>Showing latest 5 of {total} workouts.</i>" text += f"\n\n<i>Showing latest 5 of {total} workouts.</i>"
@ -131,35 +133,70 @@ async def cmd_history(update: Update, context: ContextTypes.DEFAULT_TYPE):
async def cmd_stats(update: Update, context: ContextTypes.DEFAULT_TYPE): async def cmd_stats(update: Update, context: ContextTypes.DEFAULT_TYPE):
user_id = update.effective_user.id user_id = update.effective_user.id
total = get_workout_count(user_id) stats = get_stats_sql(user_id)
if total == 0: if stats["total_workouts"] == 0:
await update.message.reply_text("No workouts yet send me your first one!") await update.message.reply_text("No workouts yet \u2014 send me your first one!")
return return
workouts = get_workouts(user_id, limit=1000)
# Collect all unique exercise names
exercise_names = set()
total_sets = 0
total_volume = 0.0
for w in workouts:
for group in w["superset_groups"]:
for ex in group:
exercise_names.add(ex["name"].lower())
total_sets += ex["sets"]
total_volume += ex["sets"] * ex["reps"] * ex["weight_kg"]
await update.message.reply_text( await update.message.reply_text(
f"📊 <b>Your Stats</b>\n\n" f"\U0001f4ca <b>Your Stats</b>\n\n"
f" • Workouts logged: <b>{total}</b>\n" f" \u2022 Workouts logged: <b>{stats['total_workouts']}</b>\n"
f" • Unique exercises: <b>{len(exercise_names)}</b>\n" f" \u2022 Unique exercises: <b>{stats['unique_exercises']}</b>\n"
f" • Total sets: <b>{total_sets}</b>\n" f" \u2022 Total sets: <b>{stats['total_sets']}</b>\n"
f" • Total volume: <b>{total_volume:,.0f} kg</b>", f" \u2022 Total volume: <b>{stats['total_volume']:,.0f} kg</b>",
parse_mode=ParseMode.HTML, parse_mode=ParseMode.HTML,
) )
async def cmd_delete(update: Update, context: ContextTypes.DEFAULT_TYPE):
user_id = update.effective_user.id
if not context.args:
await update.message.reply_text(
"Usage: /delete &lt;workout_id&gt;\n"
"Use /history to see workout IDs.",
parse_mode=ParseMode.HTML,
)
return
try:
workout_id = int(context.args[0])
except ValueError:
await update.message.reply_text("Workout ID must be a number.")
return
if delete_workout(user_id, workout_id):
await update.message.reply_text(f"\U0001f5d1 Workout #{workout_id} deleted.")
else:
await update.message.reply_text(
f"Workout #{workout_id} not found (or not yours)."
)
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
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.",
)
# ── Message handler (workout parsing) ─────────────────────────────────────── # ── Message handler (workout parsing) ───────────────────────────────────────
@ -169,9 +206,24 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
if not text: if not text:
return return
groups = parse_workout(text) groups, errors = parse_workout(text)
if not groups:
# Not a workout message — silently ignore so the bot isn't noisy 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 return
user_id = update.effective_user.id user_id = update.effective_user.id
@ -188,12 +240,17 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
ts_str = timestamp.strftime("%a %d %b %Y, %H:%M") ts_str = timestamp.strftime("%a %d %b %Y, %H:%M")
confirm_parts = [ confirm_parts = [
f" <b>Workout #{workout_id} saved!</b>", f"\u2705 <b>Workout #{workout_id} saved!</b>",
f"📅 {ts_str}" + (" (from forwarded message)" if is_forwarded else ""), f"\U0001f4c5 {ts_str}" + (" (from forwarded message)" if is_forwarded else ""),
f"🏋️ {total_exercises} exercises, {total_sets} total sets", f"\U0001f3cb\ufe0f {total_exercises} exercises, {total_sets} total sets",
] ]
if supersets: if supersets:
confirm_parts.append(f"🔗 {supersets} superset(s)") 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)}") confirm_parts.append(f"\n{format_workout(superset_dicts)}")
@ -231,11 +288,13 @@ def main():
app.add_handler(CommandHandler("start", cmd_start)) app.add_handler(CommandHandler("start", cmd_start))
app.add_handler(CommandHandler("history", cmd_history)) app.add_handler(CommandHandler("history", cmd_history))
app.add_handler(CommandHandler("stats", cmd_stats)) app.add_handler(CommandHandler("stats", cmd_stats))
app.add_handler(CommandHandler("delete", cmd_delete))
app.add_handler(CommandHandler("export", cmd_export))
# Handle all text messages (including forwarded ones) # Handle all text messages (including forwarded ones)
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message)) app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message))
logger.info("Bot started — polling…") logger.info("Bot started \u2014 polling\u2026")
app.run_polling(allowed_updates=Update.ALL_TYPES) app.run_polling(allowed_updates=Update.ALL_TYPES)

90
db.py
View file

@ -1,5 +1,6 @@
"""Database layer for the fitness bot.""" """Database layer for the fitness bot."""
import json
import sqlite3 import sqlite3
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@ -49,29 +50,34 @@ def init_db():
superset_group_id INTEGER NOT NULL REFERENCES superset_groups(id) ON DELETE CASCADE, superset_group_id INTEGER NOT NULL REFERENCES superset_groups(id) ON DELETE CASCADE,
position INTEGER NOT NULL, -- ordering within the superset group position INTEGER NOT NULL, -- ordering within the superset group
name TEXT NOT NULL, name TEXT NOT NULL,
machine_id TEXT, -- e.g. "500", "620" machine_id TEXT, -- e.g. "3032", "5014"
sets INTEGER NOT NULL, sets INTEGER NOT NULL,
reps INTEGER NOT NULL, reps INTEGER NOT NULL,
weight_kg REAL NOT NULL, weight_kg REAL NOT NULL,
raw_line TEXT -- the original line as typed raw_line TEXT, -- the original line as typed
sets_detail TEXT -- JSON array of {reps, weight_kg} per set
); );
CREATE INDEX IF NOT EXISTS idx_workouts_user CREATE INDEX IF NOT EXISTS idx_workouts_user
ON workouts(user_id, timestamp); ON workouts(user_id, timestamp);
""") """)
# Migration: add raw_text column if it doesn't exist yet # Migrations
cols = {r[1] for r in conn.execute("PRAGMA table_info(workouts)").fetchall()} cols = {r[1] for r in conn.execute("PRAGMA table_info(workouts)").fetchall()}
if "raw_text" not in cols: if "raw_text" not in cols:
conn.execute("ALTER TABLE workouts ADD COLUMN raw_text TEXT") conn.execute("ALTER TABLE workouts ADD COLUMN raw_text TEXT")
ex_cols = {r[1] for r in conn.execute("PRAGMA table_info(exercises)").fetchall()}
if "sets_detail" not in ex_cols:
conn.execute("ALTER TABLE exercises ADD COLUMN sets_detail TEXT")
def save_workout(user_id: int, timestamp: datetime, superset_groups: list[list[dict]], raw_text: str | None = None, note: str | None = None) -> int: def save_workout(user_id: int, timestamp: datetime, superset_groups: list[list[dict]], raw_text: str | None = None, note: str | None = None) -> int:
""" """
Save a parsed workout. Save a parsed workout.
superset_groups: list of groups, each group is a list of exercise dicts: superset_groups: list of groups, each group is a list of exercise dicts:
{name, machine_id, sets, reps, weight_kg, raw_line} {name, machine_id, sets, reps, weight_kg, raw_line, sets_detail}
raw_text: the full original message text, stored verbatim. raw_text: the full original message text, stored verbatim.
Returns the workout id. Returns the workout id.
@ -91,17 +97,31 @@ def save_workout(user_id: int, timestamp: datetime, superset_groups: list[list[d
group_id = cur2.lastrowid group_id = cur2.lastrowid
for ex_pos, ex in enumerate(group): for ex_pos, ex in enumerate(group):
sets_detail_json = None
if ex.get("sets_detail"):
sets_detail_json = json.dumps(ex["sets_detail"])
conn.execute( conn.execute(
"""INSERT INTO exercises """INSERT INTO exercises
(superset_group_id, position, name, machine_id, sets, reps, weight_kg, raw_line) (superset_group_id, position, name, machine_id, sets, reps, weight_kg, raw_line, sets_detail)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(group_id, ex_pos, ex["name"], ex.get("machine_id"), (group_id, ex_pos, ex["name"], ex.get("machine_id"),
ex["sets"], ex["reps"], ex["weight_kg"], ex.get("raw_line")), ex["sets"], ex["reps"], ex["weight_kg"], ex.get("raw_line"),
sets_detail_json),
) )
return workout_id return workout_id
def delete_workout(user_id: int, workout_id: int) -> bool:
"""Delete a workout by ID. Returns True if deleted, False if not found or not owned."""
with get_db() as conn:
cur = conn.execute(
"DELETE FROM workouts WHERE id = ? AND user_id = ?",
(workout_id, user_id),
)
return cur.rowcount > 0
def get_workouts(user_id: int, limit: int = 10, offset: int = 0) -> list[dict]: def get_workouts(user_id: int, limit: int = 10, offset: int = 0) -> list[dict]:
"""Fetch recent workouts for a user, newest first.""" """Fetch recent workouts for a user, newest first."""
with get_db() as conn: with get_db() as conn:
@ -119,7 +139,8 @@ def get_workouts(user_id: int, limit: int = 10, offset: int = 0) -> list[dict]:
workout = dict(row) workout = dict(row)
groups = conn.execute( groups = conn.execute(
"""SELECT sg.id as group_id, sg.position as group_pos, """SELECT sg.id as group_id, sg.position as group_pos,
e.name, e.machine_id, e.sets, e.reps, e.weight_kg, e.raw_line, e.position as ex_pos e.name, e.machine_id, e.sets, e.reps, e.weight_kg,
e.raw_line, e.position as ex_pos, e.sets_detail
FROM superset_groups sg FROM superset_groups sg
JOIN exercises e ON e.superset_group_id = sg.id JOIN exercises e ON e.superset_group_id = sg.id
WHERE sg.workout_id = ? WHERE sg.workout_id = ?
@ -132,7 +153,16 @@ def get_workouts(user_id: int, limit: int = 10, offset: int = 0) -> list[dict]:
gp = g["group_pos"] gp = g["group_pos"]
if gp not in superset_groups: if gp not in superset_groups:
superset_groups[gp] = [] superset_groups[gp] = []
superset_groups[gp].append(dict(g)) ex_dict = dict(g)
# Deserialize sets_detail JSON
if ex_dict.get("sets_detail"):
try:
ex_dict["sets_detail"] = json.loads(ex_dict["sets_detail"])
except json.JSONDecodeError:
ex_dict["sets_detail"] = []
else:
ex_dict["sets_detail"] = []
superset_groups[gp].append(ex_dict)
workout["superset_groups"] = [superset_groups[k] for k in sorted(superset_groups)] workout["superset_groups"] = [superset_groups[k] for k in sorted(superset_groups)]
workouts.append(workout) workouts.append(workout)
@ -146,3 +176,45 @@ def get_workout_count(user_id: int) -> int:
"SELECT COUNT(*) as cnt FROM workouts WHERE user_id = ?", (user_id,) "SELECT COUNT(*) as cnt FROM workouts WHERE user_id = ?", (user_id,)
).fetchone() ).fetchone()
return row["cnt"] return row["cnt"]
def get_stats_sql(user_id: int) -> dict:
"""Compute stats entirely in SQL."""
with get_db() as conn:
row = conn.execute("""
SELECT
COUNT(DISTINCT w.id) as total_workouts,
COUNT(DISTINCT LOWER(e.name)) as unique_exercises,
COALESCE(SUM(e.sets), 0) as total_sets,
COALESCE(SUM(e.sets * e.reps * e.weight_kg), 0) as total_volume
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 = ?
""", (user_id,)).fetchone()
return {
"total_workouts": row["total_workouts"],
"unique_exercises": row["unique_exercises"],
"total_sets": row["total_sets"],
"total_volume": round(row["total_volume"], 1),
}
def export_workouts(user_id: int) -> list[dict]:
"""Export all workouts as flat records for CSV/JSON export."""
with get_db() as conn:
rows = conn.execute("""
SELECT
w.id as workout_id, w.timestamp, w.created_at, w.raw_text,
sg.position as group_pos,
e.name, e.machine_id, e.sets, e.reps, e.weight_kg,
e.raw_line, e.sets_detail
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 = ?
ORDER BY w.timestamp DESC, sg.position, e.position
""", (user_id,)).fetchall()
return [dict(r) for r in rows]

164
parser.py
View file

@ -1,25 +1,39 @@
""" """
Parse workout messages into structured data. Parse workout messages into structured data.
Format per line: Supported formats per line:
Exercise Name (optional_machine_id): SETSxREPSxWEIGHT Exercise Name: SETSxREPSxWEIGHT e.g. Bench press: 4x8x35
Exercise Name: SETSxREPS bodyweight, e.g. Pull-ups: 3x10
Exercise Name: REPSxWEIGHT, REPSxWEIGHT per-set, e.g. Shoulder press: 8x25, 5x35, 6x40
Exercise Name: REPS, REPS, REPS bodyweight per-set, e.g. Pull-ups: 12, 10, 8
Exercise Name (machine_id): ... optional machine ID in parentheses
Lines with no blank line between them form a superset group. Lines with no blank line between them form a superset group.
Blank lines separate superset groups. Blank lines separate superset groups.
""" """
import re import re
from dataclasses import dataclass from dataclasses import dataclass, field
@dataclass
class SetDetail:
reps: int
weight_kg: float
def to_dict(self) -> dict:
return {"reps": self.reps, "weight_kg": self.weight_kg}
@dataclass @dataclass
class Exercise: class Exercise:
name: str name: str
machine_id: str | None machine_id: str | None
sets: int sets: int # total number of sets
reps: int reps: int # reps of first set (for backward compat / simple display)
weight_kg: float weight_kg: float # weight of first set (for backward compat / simple display)
raw_line: str raw_line: str
sets_detail: list[SetDetail] = field(default_factory=list)
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { return {
@ -29,21 +43,32 @@ class Exercise:
"reps": self.reps, "reps": self.reps,
"weight_kg": self.weight_kg, "weight_kg": self.weight_kg,
"raw_line": self.raw_line, "raw_line": self.raw_line,
"sets_detail": [s.to_dict() for s in self.sets_detail],
} }
# Matches lines like: # Header pattern: captures exercise name and optional machine ID
# Bench press: 4x8x35 HEADER_RE = re.compile(
# Lat pulldown (500): 3x5x45 r"^(?P<name>.+?)"
# Russian Twists: 3x15x0 r"(?:\s*\((?P<machine>[^)]+)\))?"
EXERCISE_RE = re.compile( r"\s*:\s*"
r"^(?P<name>.+?)" # exercise name (lazy) r"(?P<rest>.+)$",
r"(?:\s*\((?P<machine>\d+)\))?" # optional (machine_id) re.IGNORECASE,
r"\s*:\s*" # colon separator )
r"(?P<sets>\d+)\s*x\s*" # sets
r"(?P<reps>\d+)\s*x\s*" # reps # Classic format: SETSxREPSxWEIGHT (e.g. 4x8x35, 3x10)
r"(?P<weight>[\d.]+)" # weight CLASSIC_RE = re.compile(
r"\s*$", r"^(?P<sets>\d+)\s*[x*]\s*(?P<reps>\d+)"
r"(?:\s*[x*]\s*(?P<weight>[\d.]+))?"
r"$",
re.IGNORECASE,
)
# Per-set entry: REPSxWEIGHT or just REPS (e.g. 8x25, or 12)
SET_ENTRY_RE = re.compile(
r"^(?P<reps>\d+)"
r"(?:\s*[x*]\s*(?P<weight>[\d.]+))?"
r"$",
re.IGNORECASE, re.IGNORECASE,
) )
@ -54,37 +79,80 @@ def parse_exercise_line(line: str) -> Exercise | None:
if not line: if not line:
return None return None
m = EXERCISE_RE.match(line) m = HEADER_RE.match(line)
if not m: if not m:
return None return None
name = m.group("name").strip()
machine_id = m.group("machine").strip() if m.group("machine") else None
rest = m.group("rest").strip()
# Try classic format first: SETSxREPSxWEIGHT or SETSxREPS
classic = CLASSIC_RE.match(rest)
if classic:
sets = int(classic.group("sets"))
reps = int(classic.group("reps"))
weight = float(classic.group("weight")) if classic.group("weight") else 0.0
details = [SetDetail(reps=reps, weight_kg=weight)] * sets
return Exercise(
name=name,
machine_id=machine_id,
sets=sets,
reps=reps,
weight_kg=weight,
raw_line=line,
sets_detail=details,
)
# Try comma-separated per-set format: 8x25, 5x35, 6x40 or 12, 10, 8
entries = [e.strip() for e in rest.split(",")]
details = []
for entry in entries:
em = SET_ENTRY_RE.match(entry)
if not em:
return None # one bad entry invalidates the line
reps = int(em.group("reps"))
weight = float(em.group("weight")) if em.group("weight") else 0.0
details.append(SetDetail(reps=reps, weight_kg=weight))
if not details:
return None
return Exercise( return Exercise(
name=m.group("name").strip(), name=name,
machine_id=m.group("machine"), machine_id=machine_id,
sets=int(m.group("sets")), sets=len(details),
reps=int(m.group("reps")), reps=details[0].reps,
weight_kg=float(m.group("weight")), weight_kg=details[0].weight_kg,
raw_line=line, raw_line=line,
sets_detail=details,
) )
def parse_workout(text: str) -> list[list[Exercise]]: class ParseError:
"""Represents a line that looks like a workout entry but failed to parse."""
def __init__(self, line: str, reason: str):
self.line = line
self.reason = reason
def parse_workout(text: str) -> tuple[list[list[Exercise]], list[ParseError]]:
""" """
Parse a full workout message into superset groups. Parse a full workout message into superset groups.
Returns a list of groups, where each group is a list of Exercises. Returns (groups, errors):
Consecutive non-blank lines form a superset group. - groups: list of superset groups, each a list of Exercises
Blank lines separate groups. - errors: list of ParseError for lines that looked like exercises but failed
""" """
lines = text.strip().splitlines() lines = text.strip().splitlines()
groups: list[list[Exercise]] = [] groups: list[list[Exercise]] = []
current_group: list[Exercise] = [] current_group: list[Exercise] = []
errors: list[ParseError] = []
for line in lines: for line in lines:
stripped = line.strip() stripped = line.strip()
if not stripped: if not stripped:
# blank line → end current group
if current_group: if current_group:
groups.append(current_group) groups.append(current_group)
current_group = [] current_group = []
@ -93,13 +161,20 @@ def parse_workout(text: str) -> list[list[Exercise]]:
exercise = parse_exercise_line(stripped) exercise = parse_exercise_line(stripped)
if exercise: if exercise:
current_group.append(exercise) current_group.append(exercise)
# non-matching lines are silently skipped (e.g. notes, headers) elif ":" in stripped:
# Has a colon — likely an attempted exercise line that failed to parse
errors.append(ParseError(stripped, "could not parse sets/reps/weight after colon"))
# Lines without colons are silently skipped (notes, headers, etc.)
# flush last group
if current_group: if current_group:
groups.append(current_group) groups.append(current_group)
return groups return groups, errors
def _fmt_weight(w: float) -> str:
"""Format weight: 70.0 → '70', 22.5 → '22.5'."""
return str(int(w)) if w == int(w) else str(w)
def format_workout(superset_groups: list[list[dict]], include_raw: bool = False) -> str: def format_workout(superset_groups: list[list[dict]], include_raw: bool = False) -> str:
@ -107,15 +182,34 @@ def format_workout(superset_groups: list[list[dict]], include_raw: bool = False)
parts = [] parts = []
for i, group in enumerate(superset_groups): for i, group in enumerate(superset_groups):
if i > 0: if i > 0:
parts.append("") # blank line between groups parts.append("")
is_superset = len(group) > 1 is_superset = len(group) > 1
if is_superset: if is_superset:
parts.append("🔗 <b>Superset:</b>") parts.append("\U0001f517 <b>Superset:</b>")
for ex in group: for ex in group:
machine = f" ({ex['machine_id']})" if ex.get("machine_id") else "" machine = f" ({ex['machine_id']})" if ex.get("machine_id") else ""
line = f"{ex['name']}{machine}: {ex['sets']}x{ex['reps']}x{ex['weight_kg']}kg" details = ex.get("sets_detail", [])
if details and not all(
d["reps"] == details[0]["reps"] and d["weight_kg"] == details[0]["weight_kg"]
for d in details
):
# Varying sets — show each
set_strs = []
for d in details:
if d["weight_kg"]:
set_strs.append(f"{d['reps']}x{_fmt_weight(d['weight_kg'])}kg")
else:
set_strs.append(f"{d['reps']}")
line = f" \u2022 {ex['name']}{machine}: {', '.join(set_strs)}"
else:
# Uniform sets — compact format
w = ex.get('weight_kg', 0)
if w:
line = f" \u2022 {ex['name']}{machine}: {ex['sets']}x{ex['reps']}x{_fmt_weight(w)}kg"
else:
line = f" \u2022 {ex['name']}{machine}: {ex['sets']}x{ex['reps']}"
parts.append(line) parts.append(line)
return "\n".join(parts) return "\n".join(parts)

View file

@ -2,8 +2,10 @@
API + static file server for the Telegram Mini App. API + static file server for the Telegram Mini App.
Serves webapp/ and REST endpoints, using the existing db.py layer. Serves webapp/ and REST endpoints, using the existing db.py layer.
""" """
import csv
import hashlib import hashlib
import hmac import hmac
import io
import json import json
import logging import logging
import os import os
@ -11,7 +13,7 @@ from urllib.parse import parse_qs
from aiohttp import web from aiohttp import web
from db import init_db, get_db, save_workout, get_workouts, get_workout_count from db import init_db, get_db, save_workout, get_workouts, get_workout_count, get_stats_sql, delete_workout, export_workouts
from parser import parse_workout, format_workout from parser import parse_workout, format_workout
logging.basicConfig( logging.basicConfig(
@ -122,9 +124,13 @@ async def api_save_workout(request: web.Request):
) )
elif raw_text: elif raw_text:
# Text-based input (same format as sending a message to the bot) # Text-based input (same format as sending a message to the bot)
groups = parse_workout(raw_text) groups, errors = parse_workout(raw_text)
if not groups: if not groups:
return web.json_response({"error": "Could not parse workout text"}, status=400) error_lines = [e.line for e in errors] if errors else []
return web.json_response(
{"error": "Could not parse workout text", "failed_lines": error_lines},
status=400,
)
from datetime import datetime, timezone from datetime import datetime, timezone
superset_dicts = [[ex.to_dict() for ex in group] for group in groups] superset_dicts = [[ex.to_dict() for ex in group] for group in groups]
workout_id = save_workout( workout_id = save_workout(
@ -141,6 +147,15 @@ async def api_save_workout(request: web.Request):
return web.json_response({"workout_id": workout_id}, status=201) return web.json_response({"workout_id": workout_id}, status=201)
@require_auth
async def api_delete_workout(request: web.Request):
"""Delete a workout by ID."""
workout_id = int(request.match_info["workout_id"])
if delete_workout(request["user_id"], workout_id):
return web.json_response({"deleted": True})
return web.json_response({"error": "Not found"}, status=404)
@require_auth @require_auth
async def api_get_exercise_names(request: web.Request): async def api_get_exercise_names(request: web.Request):
"""Return unique exercise names this user has logged (for autocomplete).""" """Return unique exercise names this user has logged (for autocomplete)."""
@ -160,31 +175,33 @@ async def api_get_exercise_names(request: web.Request):
@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."""
user_id = request["user_id"] stats = get_stats_sql(request["user_id"])
total = get_workout_count(user_id) return web.json_response(stats)
if total == 0:
return web.json_response({
"total_workouts": 0, "unique_exercises": 0,
"total_sets": 0, "total_volume": 0,
})
workouts = get_workouts(user_id, limit=10000)
exercise_names = set()
total_sets = 0
total_volume = 0.0
for w in workouts:
for group in w["superset_groups"]:
for ex in group:
exercise_names.add(ex["name"].lower())
total_sets += ex["sets"]
total_volume += ex["sets"] * ex["reps"] * ex["weight_kg"]
return web.json_response({ @require_auth
"total_workouts": total, async def api_export_json(request: web.Request):
"unique_exercises": len(exercise_names), """Export all workouts as JSON."""
"total_sets": total_sets, data = export_workouts(request["user_id"])
"total_volume": round(total_volume, 1), return web.json_response({"records": data, "count": len(data)})
})
@require_auth
async def api_export_csv(request: web.Request):
"""Export all workouts as CSV."""
data = export_workouts(request["user_id"])
output = io.StringIO()
if data:
writer = csv.DictWriter(output, fieldnames=data[0].keys())
writer.writeheader()
writer.writerows(data)
return web.Response(
text=output.getvalue(),
content_type="text/csv",
headers={"Content-Disposition": "attachment; filename=workouts.csv"},
)
# ── App setup ──────────────────────────────────────────────────── # ── App setup ────────────────────────────────────────────────────
@ -196,8 +213,11 @@ def create_app() -> web.Application:
app.router.add_get("/api/workouts", api_get_workouts) app.router.add_get("/api/workouts", api_get_workouts)
app.router.add_post("/api/workouts", api_save_workout) app.router.add_post("/api/workouts", api_save_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/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/csv", api_export_csv)
# Serve the webapp/ folder # Serve the webapp/ folder
import pathlib import pathlib