diff options
| -rw-r--r-- | README.md | 5 | ||||
| -rw-r--r-- | db.py | 51 | ||||
| -rw-r--r-- | egometrics.py | 472 | ||||
| -rw-r--r-- | models.py | 202 | ||||
| -rw-r--r-- | ui.py | 140 |
5 files changed, 743 insertions, 127 deletions
@@ -17,9 +17,10 @@ python3 egometrics.py ### Main menu -1. **Log Workout** — pick exercises, enter sets/reps/RPE/rest time/Last Set RPE for each +1. **Log Workout** — pick exercises (or start from a template), enter per-set reps/weight, rest time, Last Set RPE 2. **View Sessions** — browse past sessions, view details, delete -3. **Manage Exercises** — add, edit, list, delete exercises +3. **Manage Exercises** — add, edit, list, delete exercises (supports body-weight-relative exercises) +4. **Manage Templates** — create reusable workout templates with target sets/reps/weight/LSRPE/rest time ### Data @@ -4,38 +4,61 @@ import os DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "egometrics.db") SCHEMA = """ -CREATE TABLE IF NOT EXISTS exercises ( +CREATE TABLE IF NOT EXISTS workout_exercises ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE COLLATE NOCASE, + bw_relative BOOLEAN NOT NULL DEFAULT 0, note TEXT ); -CREATE TABLE IF NOT EXISTS sessions ( +CREATE TABLE IF NOT EXISTS workout_sessions ( id INTEGER PRIMARY KEY AUTOINCREMENT, date_time TEXT NOT NULL, note TEXT ); -CREATE TABLE IF NOT EXISTS session_exercises ( +CREATE TABLE IF NOT EXISTS workout_session_exercises ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id INTEGER NOT NULL, exercise_id INTEGER NOT NULL, position INTEGER NOT NULL, - sets INTEGER, - reps INTEGER, - rpe INTEGER, - rest_time INTEGER, - lsrpe INTEGER, + sets INTEGER NOT NULL, + reps TEXT NOT NULL, + weight TEXT NOT NULL, + rest_time TEXT, + lsrpe INTEGER NOT NULL, note TEXT, - FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE, - FOREIGN KEY (exercise_id) REFERENCES exercises(id) ON DELETE RESTRICT + FOREIGN KEY (session_id) REFERENCES workout_sessions(id) ON DELETE CASCADE, + FOREIGN KEY (exercise_id) REFERENCES workout_exercises(id) ON DELETE RESTRICT ); -CREATE INDEX IF NOT EXISTS idx_session_exercises_session - ON session_exercises(session_id); +CREATE TABLE IF NOT EXISTS workout_templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE COLLATE NOCASE +); + +CREATE TABLE IF NOT EXISTS workout_template_exercises ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + template_id INTEGER NOT NULL, + exercise_id INTEGER NOT NULL, + position INTEGER NOT NULL, + sets INTEGER NOT NULL, + reps INTEGER NOT NULL, + lsrpe INTEGER NOT NULL, + rest_time INTEGER NOT NULL, + note TEXT, + FOREIGN KEY (template_id) REFERENCES workout_templates(id) ON DELETE CASCADE, + FOREIGN KEY (exercise_id) REFERENCES workout_exercises(id) ON DELETE RESTRICT +); + +CREATE INDEX IF NOT EXISTS idx_workout_session_exercises_session + ON workout_session_exercises(session_id); + +CREATE INDEX IF NOT EXISTS idx_workout_sessions_date + ON workout_sessions(date_time); -CREATE INDEX IF NOT EXISTS idx_sessions_date - ON sessions(date_time); +CREATE INDEX IF NOT EXISTS idx_workout_template_exercises_template + ON workout_template_exercises(template_id); """ diff --git a/egometrics.py b/egometrics.py index eda2faa..d882a59 100644 --- a/egometrics.py +++ b/egometrics.py @@ -7,25 +7,33 @@ import ui def main_menu(conn: sqlite3.Connection) -> None: while True: - print("\n=== EgoMetrics ===\n") + ui.clear_screen() + print("=== EgoMetrics ===\n") print("1. Log Workout") - print("2. View Sessions") - print("3. Manage Exercises") - print("4. Quit") + print("2. View Workout Sessions") + print("3. Manage Workout Exercises") + print("4. Manage Workout Templates") + print("5. Quit") choice = input("\n> ").strip() if choice == "1": log_workout(conn) elif choice == "2": - view_sessions(conn) + view_workout_sessions(conn) elif choice == "3": - manage_exercises(conn) + manage_workout_exercises(conn) elif choice == "4": + manage_workout_templates(conn) + elif choice == "5": break -def manage_exercises(conn: sqlite3.Connection) -> None: +# --- Workout Exercises --- + + +def manage_workout_exercises(conn: sqlite3.Connection) -> None: while True: - ui.print_header("Manage Exercises") + ui.clear_screen() + ui.print_header("Manage Workout Exercises") print("1. List Exercises") print("2. Add Exercise") print("3. Edit Exercise") @@ -33,91 +41,173 @@ def manage_exercises(conn: sqlite3.Connection) -> None: print("5. Back") choice = input("\n> ").strip() if choice == "1": - list_exercises(conn) + list_workout_exercises(conn) elif choice == "2": - add_exercise(conn) + add_workout_exercise(conn) elif choice == "3": - edit_exercise(conn) + edit_workout_exercise(conn) elif choice == "4": - delete_exercise(conn) + delete_workout_exercise(conn) elif choice == "5": break -def list_exercises(conn: sqlite3.Connection) -> None: - exercises = models.list_exercises(conn) - ui.print_header("All Exercises") +def list_workout_exercises(conn: sqlite3.Connection, pause: bool = True) -> None: + exercises = models.list_workout_exercises(conn) + ui.print_header("All Workout Exercises") ui.print_table( - ["ID", "Name", "Note"], - [[str(e["id"]), e["name"], e["note"] or ""] for e in exercises], + ["ID", "Name", "BW?", "Note"], + [ + [ + str(e["id"]), + e["name"], + "Yes" if e["bw_relative"] else "No", + e["note"] or "", + ] + for e in exercises + ], ) + if pause: + ui.pause() -def add_exercise(conn: sqlite3.Connection) -> None: +def add_workout_exercise(conn: sqlite3.Connection) -> None: name = ui.prompt_str("Name: ") assert name is not None + bw_relative = ui.confirm("Is weight relative to body weight?") note = ui.prompt_str("Note (optional): ", required=False) try: - models.add_exercise(conn, name, note) + models.add_workout_exercise(conn, name, bw_relative, note) print(f'Exercise "{name}" added.') except sqlite3.IntegrityError: print(f'Exercise "{name}" already exists.') + ui.pause() -def edit_exercise(conn: sqlite3.Connection) -> None: - list_exercises(conn) +def edit_workout_exercise(conn: sqlite3.Connection) -> None: + list_workout_exercises(conn, pause=False) eid = ui.prompt_int("\nExercise ID to edit: ") assert eid is not None - ex = models.get_exercise(conn, eid) + ex = models.get_workout_exercise(conn, eid) if not ex: print("Exercise not found.") return print(f'Editing "{ex["name"]}" (leave blank to keep current value)') name = ui.prompt_str(f"Name [{ex['name']}]: ", required=False) + bw_cur = "Yes" if ex["bw_relative"] else "No" + print(f"BW-relative [{bw_cur}]: ", end="") + bw_input = input().strip().lower() + if bw_input in ("y", "yes"): + bw_relative: bool | None = True + elif bw_input in ("n", "no"): + bw_relative = False + else: + bw_relative = None # keep current note = ui.prompt_str(f"Note [{ex['note'] or ''}]: ", required=False) try: - models.update_exercise(conn, eid, name, note) + models.update_workout_exercise(conn, eid, name, bw_relative, note) print("Exercise updated.") except sqlite3.IntegrityError: print(f'An exercise named "{name}" already exists.') + ui.pause() -def delete_exercise(conn: sqlite3.Connection) -> None: - list_exercises(conn) +def delete_workout_exercise(conn: sqlite3.Connection) -> None: + list_workout_exercises(conn, pause=False) eid = ui.prompt_int("\nExercise ID to delete: ") assert eid is not None - ex = models.get_exercise(conn, eid) + ex = models.get_workout_exercise(conn, eid) if not ex: print("Exercise not found.") return if not ui.confirm(f'Delete "{ex["name"]}"?'): return try: - models.delete_exercise(conn, eid) + models.delete_workout_exercise(conn, eid) print("Exercise deleted.") except sqlite3.IntegrityError: - print(f'Cannot delete "{ex["name"]}" — it is used in existing sessions.') + print( + f'Cannot delete "{ex["name"]}" — it is used in existing sessions or templates.' + ) + ui.pause() + + +# --- Workout Sessions --- def log_workout(conn: sqlite3.Connection) -> None: - exercises = models.list_exercises(conn) + exercises = models.list_workout_exercises(conn) if not exercises: - print("No exercises defined. Add some first via Manage Exercises.") + print("No exercises defined. Add some first via Manage Workout Exercises.") return # Session metadata + ui.clear_screen() ui.print_header("Log Workout") date_time = ui.prompt_datetime("Date/Time") session_note = ui.prompt_str("Session note (optional): ", required=False) + # Check if user wants to start from a template + entries: list[dict] = [] + templates = models.list_workout_templates(conn) + if templates: + print("\nStart from a template?") + for i, t in enumerate(templates, 1): + print(f" {i}. {t['name']}") + print(" 0. No template (blank session)") + choice = input("\n> ").strip() + try: + idx = int(choice) + if 1 <= idx <= len(templates): + # Load template exercises as defaults + template_id = templates[idx - 1]["id"] + _, tmpl_entries = models.get_workout_template_detail(conn, template_id) + print(f'\nUsing template "{templates[idx - 1]["name"]}"') + print("Press Enter to accept defaults, or type new values.\n") + for te in tmpl_entries: + print(f" -- {te['exercise_name']} --") + sets = ui.prompt_int( + f" Sets [{te['sets'] or ''}]: ", + min_val=1, + default=te["sets"], + ) + assert sets is not None + reps, weight, rest_time = ui.prompt_sets_detail( + sets, + default_reps=te["reps"], + default_rest=te["rest_time"], + bw_relative=bool(te["bw_relative"]), + ) + lsrpe = ui.prompt_int(" Last Set RPE: ", min_val=1, max_val=10) + note = ui.prompt_str( + f" Note [{te['note'] or ''}]: ", + required=False, + default=te["note"], + ) + entries.append( + { + "exercise_id": te["exercise_id"], + "exercise_name": te["exercise_name"], + "bw_relative": bool(te["bw_relative"]), + "sets": sets, + "reps": reps, + "weight": weight, + "rest_time": rest_time, + "lsrpe": lsrpe, + "note": note, + } + ) + print(f' "{te["exercise_name"]}" added to session.') + except (ValueError, IndexError): + pass # Invalid input or 0 — proceed without template + # Build exercise list — collect everything in memory before saving - entries = [] while True: print("\nAvailable exercises:") for e in exercises: print(f" {e['id']}. {e['name']}") print() - choice = input("Select exercise ID ('n' = new exercise, 'd' = done): ").strip() + choice = input("Select exercise ID ('d' = done): ").strip() if choice.lower() == "d": if not entries: @@ -125,49 +215,34 @@ def log_workout(conn: sqlite3.Connection) -> None: return break - # Inline exercise creation - elif choice.lower() == "n": - name = ui.prompt_str("Exercise name: ") - assert name is not None - note = ui.prompt_str("Note (optional): ", required=False) - try: - models.add_exercise(conn, name, note) - exercises = models.list_exercises(conn) - print(f'Exercise "{name}" added.') - except sqlite3.IntegrityError: - print(f'Exercise "{name}" already exists.') - continue - # Select existing exercise and prompt for set details try: eid = int(choice) except ValueError: print("Invalid input.") continue - ex = models.get_exercise(conn, eid) + ex = models.get_workout_exercise(conn, eid) if not ex: print("Exercise not found.") continue print(f"\n -- {ex['name']} --") sets = ui.prompt_int(" Sets: ", min_val=1) - reps = ui.prompt_int(" Reps: ", min_val=1) - rpe = ui.prompt_int(" RPE (optional): ", required=False, min_val=1, max_val=10) - rest_time = ui.prompt_int( - " Rest time in seconds (optional): ", required=False, min_val=0 - ) - lsrpe = ui.prompt_int( - " Last Set RPE (optional): ", required=False, min_val=1, max_val=10 + assert sets is not None + reps, weight, rest_time = ui.prompt_sets_detail( + sets, bw_relative=bool(ex["bw_relative"]) ) + lsrpe = ui.prompt_int(" Last Set RPE: ", min_val=1, max_val=10) note = ui.prompt_str(" Note (optional): ", required=False) entries.append( { "exercise_id": eid, "exercise_name": ex["name"], + "bw_relative": bool(ex["bw_relative"]), "sets": sets, "reps": reps, - "rpe": rpe, + "weight": weight, "rest_time": rest_time, "lsrpe": lsrpe, "note": note, @@ -176,21 +251,22 @@ def log_workout(conn: sqlite3.Connection) -> None: print(f' "{ex["name"]}" added to session.') # Show summary and confirm before writing to DB + ui.clear_screen() ui.print_header("Session Summary") print(f"Date: {date_time}") if session_note: print(f"Note: {session_note}") ui.print_table( - ["#", "Exercise", "Sets", "Reps", "RPE", "Rest", "LSRPE", "Note"], + ["#", "Exercise", "Sets", "Reps", "Weight", "Rest", "LSRPE", "Note"], [ [ str(i), e["exercise_name"], str(e["sets"]), - str(e["reps"]), - str(e["rpe"] or ""), - f"{e['rest_time']}s" if e["rest_time"] else "", - str(e["lsrpe"] or ""), + str(e["reps"]).replace(",", "/"), + ui.format_weight(e["weight"], e["bw_relative"]), + ui.format_rest_time(e["rest_time"]), + str(e["lsrpe"]), e["note"] or "", ] for i, e in enumerate(entries, 1) @@ -198,20 +274,22 @@ def log_workout(conn: sqlite3.Connection) -> None: ) if ui.confirm("\nSave this session?"): - models.save_session(conn, date_time, session_note, entries) + models.save_workout_session(conn, date_time, session_note, entries) print("Session saved!") else: print("Session discarded.") + ui.pause() -def view_sessions(conn: sqlite3.Connection) -> None: +def view_workout_sessions(conn: sqlite3.Connection) -> None: while True: + ui.clear_screen() # List all sessions with exercise name preview - sessions = models.list_sessions(conn) + sessions = models.list_workout_sessions(conn) if not sessions: print("\nNo sessions recorded yet.") return - ui.print_header("Past Sessions") + ui.print_header("Past Workout Sessions") ui.print_table( ["#", "Date", "Exercises", "Note"], [ @@ -234,23 +312,24 @@ def view_sessions(conn: sqlite3.Connection) -> None: continue # Show full session detail with all exercise entries + ui.clear_screen() session_id = sessions[idx]["id"] - session, entries = models.get_session_detail(conn, session_id) + session, entries = models.get_workout_session_detail(conn, session_id) assert session is not None ui.print_header(f"Session: {session['date_time']}") if session["note"]: print(f"Note: {session['note']}\n") ui.print_table( - ["#", "Exercise", "Sets", "Reps", "RPE", "Rest", "LSRPE", "Note"], + ["#", "Exercise", "Sets", "Reps", "Weight", "Rest", "LSRPE", "Note"], [ [ str(e["position"]), e["exercise_name"], str(e["sets"]), - str(e["reps"]), - str(e["rpe"] or ""), - f"{e['rest_time']}s" if e["rest_time"] else "", - str(e["lsrpe"] or ""), + str(e["reps"]).replace(",", "/"), + ui.format_weight(str(e["weight"]), bool(e["bw_relative"])), + ui.format_rest_time(e["rest_time"]), + str(e["lsrpe"]), e["note"] or "", ] for e in entries @@ -259,8 +338,261 @@ def view_sessions(conn: sqlite3.Connection) -> None: action = input("\nActions: (b)ack, (d)elete session\n> ").strip().lower() if action == "d": if ui.confirm("Delete this session?"): - models.delete_session(conn, session_id) + models.delete_workout_session(conn, session_id) print("Session deleted.") + ui.pause() + + +# --- Workout Templates --- + + +def manage_workout_templates(conn: sqlite3.Connection) -> None: + while True: + ui.clear_screen() + ui.print_header("Manage Workout Templates") + print("1. List Templates") + print("2. Create Template") + print("3. View/Edit Template") + print("4. Delete Template") + print("5. Back") + choice = input("\n> ").strip() + if choice == "1": + list_workout_templates(conn) + elif choice == "2": + create_workout_template(conn) + elif choice == "3": + edit_workout_template(conn) + elif choice == "4": + delete_workout_template(conn) + elif choice == "5": + break + + +def list_workout_templates(conn: sqlite3.Connection, pause: bool = True) -> None: + templates = models.list_workout_templates(conn) + ui.print_header("All Workout Templates") + ui.print_table( + ["ID", "Name", "Exercises"], + [[str(t["id"]), t["name"], t["exercises"] or ""] for t in templates], + ) + if pause: + ui.pause() + + +def create_workout_template(conn: sqlite3.Connection) -> None: + name = ui.prompt_str("Template name: ") + assert name is not None + try: + template_id = models.create_workout_template(conn, name) + print(f'Template "{name}" created.') + except sqlite3.IntegrityError: + print(f'Template "{name}" already exists.') + ui.pause() + return + # Immediately enter exercise editor + print("Now add exercises to the template.") + ui.pause() + edit_template_exercises(conn, template_id) + + +def edit_workout_template(conn: sqlite3.Connection) -> None: + templates = models.list_workout_templates(conn) + if not templates: + print("\nNo templates defined.") + return + list_workout_templates(conn, pause=False) + tid = ui.prompt_int("\nTemplate ID to edit: ") + assert tid is not None + template = models.get_workout_template(conn, tid) + if not template: + print("Template not found.") + return + edit_template_exercises(conn, tid) + + +def edit_template_exercises(conn: sqlite3.Connection, template_id: int) -> None: + """Interactive editor for a template's exercise list.""" + while True: + ui.clear_screen() + template, entries = models.get_workout_template_detail(conn, template_id) + assert template is not None + ui.print_header(f'Template: {template["name"]}') + + # Build in-memory list from current DB state + exercises_data: list[dict] = [ + { + "exercise_id": e["exercise_id"], + "exercise_name": e["exercise_name"], + "bw_relative": bool(e["bw_relative"]), + "sets": e["sets"], + "reps": e["reps"], + "lsrpe": e["lsrpe"], + "rest_time": e["rest_time"], + "note": e["note"], + } + for e in entries + ] + + if exercises_data: + ui.print_table( + ["#", "Exercise", "Sets", "Reps", "LSRPE", "Rest", "Note"], + [ + [ + str(i), + e["exercise_name"], + str(e["sets"]), + str(e["reps"]), + str(e["lsrpe"]), + f"{e['rest_time']}s", + e["note"] or "", + ] + for i, e in enumerate(exercises_data, 1) + ], + ) + else: + print(" (no exercises)") + + print( + "\nActions: (a)dd exercise, (r)emove, (m)ove, (e)dit defaults, (n)ame, (b)ack" + ) + action = input("> ").strip().lower() + + if action == "b": + break + + elif action == "n": + new_name = ui.prompt_str("New template name: ") + assert new_name is not None + try: + models.update_workout_template_name(conn, template_id, new_name) + print(f'Template renamed to "{new_name}".') + except sqlite3.IntegrityError: + print(f'Template "{new_name}" already exists.') + ui.pause() + + elif action == "a": + available = models.list_workout_exercises(conn) + if not available: + print("No exercises defined. Create some first.") + continue + print("\nAvailable exercises:") + for e in available: + print(f" {e['id']}. {e['name']}") + eid = ui.prompt_int("\nExercise ID to add: ") + assert eid is not None + ex = models.get_workout_exercise(conn, eid) + if not ex: + print("Exercise not found.") + continue + print(f"\n -- {ex['name']} (defaults) --") + sets = ui.prompt_int(" Sets: ", min_val=1) + reps = ui.prompt_int(" Reps: ", min_val=1) + lsrpe = ui.prompt_int(" LSRPE: ", min_val=1, max_val=10) + rest_time = ui.prompt_int(" Rest time in seconds: ", min_val=0) + note = ui.prompt_str(" Note (optional): ", required=False) + exercises_data.append( + { + "exercise_id": eid, + "sets": sets, + "reps": reps, + "lsrpe": lsrpe, + "rest_time": rest_time, + "note": note, + } + ) + models.save_workout_template_exercises(conn, template_id, exercises_data) + print(f'"{ex["name"]}" added to template.') + ui.pause() + + elif action == "r": + if not exercises_data: + print("No exercises to remove.") + continue + pos = ui.prompt_int("Position # to remove: ", min_val=1) + assert pos is not None + if pos > len(exercises_data): + print("Invalid position.") + continue + removed = exercises_data.pop(pos - 1) + models.save_workout_template_exercises(conn, template_id, exercises_data) + print(f'Removed "{removed["exercise_name"]}".') + ui.pause() + + elif action == "m": + if len(exercises_data) < 2: + print("Need at least 2 exercises to move.") + continue + from_pos = ui.prompt_int( + "Move from position #: ", min_val=1, max_val=len(exercises_data) + ) + to_pos = ui.prompt_int( + "Move to position #: ", min_val=1, max_val=len(exercises_data) + ) + assert from_pos is not None and to_pos is not None + item = exercises_data.pop(from_pos - 1) + exercises_data.insert(to_pos - 1, item) + models.save_workout_template_exercises(conn, template_id, exercises_data) + print("Exercise moved.") + ui.pause() + + elif action == "e": + if not exercises_data: + print("No exercises to edit.") + continue + pos = ui.prompt_int("Position # to edit: ", min_val=1) + assert pos is not None + if pos > len(exercises_data): + print("Invalid position.") + continue + entry = exercises_data[pos - 1] + print(f'\n -- {entry["exercise_name"]} (edit defaults) --') + entry["sets"] = ui.prompt_int( + f" Sets [{entry['sets'] or ''}]: ", min_val=1, default=entry["sets"] + ) + entry["reps"] = ui.prompt_int( + f" Reps [{entry['reps'] or ''}]: ", min_val=1, default=entry["reps"] + ) + entry["lsrpe"] = ui.prompt_int( + f" LSRPE [{entry['lsrpe']}]: ", + min_val=1, + max_val=10, + default=entry["lsrpe"], + ) + entry["rest_time"] = ui.prompt_int( + f" Rest time in seconds [{entry['rest_time']}]: ", + min_val=0, + default=entry["rest_time"], + ) + entry["note"] = ui.prompt_str( + f" Note [{entry['note'] or ''}]: ", + required=False, + default=entry["note"], + ) + models.save_workout_template_exercises(conn, template_id, exercises_data) + print("Defaults updated.") + ui.pause() + + +def delete_workout_template(conn: sqlite3.Connection) -> None: + templates = models.list_workout_templates(conn) + if not templates: + print("\nNo templates defined.") + return + list_workout_templates(conn, pause=False) + tid = ui.prompt_int("\nTemplate ID to delete: ") + assert tid is not None + template = models.get_workout_template(conn, tid) + if not template: + print("Template not found.") + return + if not ui.confirm(f'Delete template "{template["name"]}"?'): + return + models.delete_workout_template(conn, tid) + print("Template deleted.") + ui.pause() + + +# --- Entry Point --- def main() -> None: @@ -1,62 +1,89 @@ import sqlite3 +# --- Workout Exercises --- -def add_exercise(conn: sqlite3.Connection, name: str, note: str | None = None) -> int: - cur = conn.execute("INSERT INTO exercises (name, note) VALUES (?, ?)", (name, note)) + +def add_workout_exercise( + conn: sqlite3.Connection, + name: str, + bw_relative: bool = False, + note: str | None = None, +) -> int: + cur = conn.execute( + "INSERT INTO workout_exercises (name, bw_relative, note) VALUES (?, ?, ?)", + (name, int(bw_relative), note), + ) conn.commit() assert cur.lastrowid is not None return cur.lastrowid -def list_exercises(conn: sqlite3.Connection) -> list[sqlite3.Row]: - return conn.execute("SELECT id, name, note FROM exercises ORDER BY name").fetchall() +def list_workout_exercises(conn: sqlite3.Connection) -> list[sqlite3.Row]: + return conn.execute( + "SELECT id, name, bw_relative, note FROM workout_exercises ORDER BY name" + ).fetchall() -def get_exercise(conn: sqlite3.Connection, exercise_id: int) -> sqlite3.Row | None: +def get_workout_exercise( + conn: sqlite3.Connection, exercise_id: int +) -> sqlite3.Row | None: return conn.execute( - "SELECT id, name, note FROM exercises WHERE id = ?", (exercise_id,) + "SELECT id, name, bw_relative, note FROM workout_exercises WHERE id = ?", + (exercise_id,), ).fetchone() -def update_exercise( +def update_workout_exercise( conn: sqlite3.Connection, exercise_id: int, name: str | None = None, + bw_relative: bool | None = None, note: str | None = None, ) -> bool: - ex = get_exercise(conn, exercise_id) + ex = get_workout_exercise(conn, exercise_id) if not ex: return False conn.execute( - "UPDATE exercises SET name = ?, note = ? WHERE id = ?", - (name or ex["name"], note, exercise_id), + "UPDATE workout_exercises SET name = ?, bw_relative = ?, note = ? WHERE id = ?", + ( + name or ex["name"], + int(bw_relative) if bw_relative is not None else ex["bw_relative"], + note, + exercise_id, + ), ) conn.commit() return True -def delete_exercise(conn: sqlite3.Connection, exercise_id: int) -> None: - conn.execute("DELETE FROM exercises WHERE id = ?", (exercise_id,)) +def delete_workout_exercise(conn: sqlite3.Connection, exercise_id: int) -> None: + conn.execute("DELETE FROM workout_exercises WHERE id = ?", (exercise_id,)) conn.commit() -def create_session( +# --- Workout Sessions --- + + +def create_workout_session( conn: sqlite3.Connection, date_time: str, note: str | None = None ) -> int: cur = conn.execute( - "INSERT INTO sessions (date_time, note) VALUES (?, ?)", (date_time, note) + "INSERT INTO workout_sessions (date_time, note) VALUES (?, ?)", + (date_time, note), ) assert cur.lastrowid is not None return cur.lastrowid -def list_sessions(conn: sqlite3.Connection, limit: int = 20) -> list[sqlite3.Row]: +def list_workout_sessions( + conn: sqlite3.Connection, limit: int = 20 +) -> list[sqlite3.Row]: return conn.execute( """SELECT s.id, s.date_time, s.note, GROUP_CONCAT(e.name, ', ') as exercises - FROM sessions s - LEFT JOIN session_exercises se ON s.id = se.session_id - LEFT JOIN exercises e ON se.exercise_id = e.id + FROM workout_sessions s + LEFT JOIN workout_session_exercises se ON s.id = se.session_id + LEFT JOIN workout_exercises e ON se.exercise_id = e.id GROUP BY s.id ORDER BY s.date_time DESC LIMIT ?""", @@ -64,19 +91,20 @@ def list_sessions(conn: sqlite3.Connection, limit: int = 20) -> list[sqlite3.Row ).fetchall() -def get_session_detail( +def get_workout_session_detail( conn: sqlite3.Connection, session_id: int ) -> tuple[sqlite3.Row | None, list[sqlite3.Row]]: session = conn.execute( - "SELECT id, date_time, note FROM sessions WHERE id = ?", (session_id,) + "SELECT id, date_time, note FROM workout_sessions WHERE id = ?", (session_id,) ).fetchone() if not session: return None, [] entries = conn.execute( """SELECT se.id, se.position, e.name as exercise_name, - se.sets, se.reps, se.rpe, se.rest_time, se.lsrpe, se.note - FROM session_exercises se - JOIN exercises e ON se.exercise_id = e.id + e.bw_relative, + se.sets, se.reps, se.weight, se.rest_time, se.lsrpe, se.note + FROM workout_session_exercises se + JOIN workout_exercises e ON se.exercise_id = e.id WHERE se.session_id = ? ORDER BY se.position""", (session_id,), @@ -84,49 +112,143 @@ def get_session_detail( return session, entries -def delete_session(conn: sqlite3.Connection, session_id: int) -> None: - conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,)) +def delete_workout_session(conn: sqlite3.Connection, session_id: int) -> None: + conn.execute("DELETE FROM workout_sessions WHERE id = ?", (session_id,)) conn.commit() -def add_session_exercise( +def add_workout_session_exercise( conn: sqlite3.Connection, session_id: int, exercise_id: int, position: int, sets: int, - reps: int, - rpe: int | None = None, - rest_time: int | None = None, - lsrpe: int | None = None, + reps: str, + weight: str, + rest_time: int, + lsrpe: int, note: str | None = None, ) -> int: cur = conn.execute( - """INSERT INTO session_exercises - (session_id, exercise_id, position, sets, reps, rpe, rest_time, lsrpe, note) + """INSERT INTO workout_session_exercises + (session_id, exercise_id, position, sets, reps, weight, rest_time, lsrpe, note) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", - (session_id, exercise_id, position, sets, reps, rpe, rest_time, lsrpe, note), + (session_id, exercise_id, position, sets, reps, weight, rest_time, lsrpe, note), ) assert cur.lastrowid is not None return cur.lastrowid -def save_session( - conn: sqlite3.Connection, date_time: str, note: str | None, exercises: list[dict] +def save_workout_session( + conn: sqlite3.Connection, + date_time: str, + note: str | None, + exercises: list[dict], ) -> int: - session_id = create_session(conn, date_time, note) + session_id = create_workout_session(conn, date_time, note) for i, ex in enumerate(exercises, 1): - add_session_exercise( + add_workout_session_exercise( conn, session_id, ex["exercise_id"], i, ex["sets"], ex["reps"], - ex.get("rpe"), - ex.get("rest_time"), - ex.get("lsrpe"), + ex["weight"], + ex["rest_time"], + ex["lsrpe"], ex.get("note"), ) conn.commit() return session_id + + +# --- Workout Templates --- + + +def create_workout_template(conn: sqlite3.Connection, name: str) -> int: + cur = conn.execute("INSERT INTO workout_templates (name) VALUES (?)", (name,)) + conn.commit() + assert cur.lastrowid is not None + return cur.lastrowid + + +def list_workout_templates(conn: sqlite3.Connection) -> list[sqlite3.Row]: + return conn.execute("""SELECT t.id, t.name, + GROUP_CONCAT(e.name, ', ') as exercises + FROM workout_templates t + LEFT JOIN workout_template_exercises te ON t.id = te.template_id + LEFT JOIN workout_exercises e ON te.exercise_id = e.id + GROUP BY t.id + ORDER BY t.name""").fetchall() + + +def get_workout_template( + conn: sqlite3.Connection, template_id: int +) -> sqlite3.Row | None: + return conn.execute( + "SELECT id, name FROM workout_templates WHERE id = ?", (template_id,) + ).fetchone() + + +def get_workout_template_detail( + conn: sqlite3.Connection, template_id: int +) -> tuple[sqlite3.Row | None, list[sqlite3.Row]]: + template = get_workout_template(conn, template_id) + if not template: + return None, [] + entries = conn.execute( + """SELECT te.id, te.position, e.id as exercise_id, e.name as exercise_name, + e.bw_relative, + te.sets, te.reps, te.lsrpe, te.rest_time, te.note + FROM workout_template_exercises te + JOIN workout_exercises e ON te.exercise_id = e.id + WHERE te.template_id = ? + ORDER BY te.position""", + (template_id,), + ).fetchall() + return template, entries + + +def update_workout_template_name( + conn: sqlite3.Connection, template_id: int, name: str +) -> bool: + t = get_workout_template(conn, template_id) + if not t: + return False + conn.execute( + "UPDATE workout_templates SET name = ? WHERE id = ?", (name, template_id) + ) + conn.commit() + return True + + +def delete_workout_template(conn: sqlite3.Connection, template_id: int) -> None: + conn.execute("DELETE FROM workout_templates WHERE id = ?", (template_id,)) + conn.commit() + + +def save_workout_template_exercises( + conn: sqlite3.Connection, template_id: int, exercises: list[dict] +) -> None: + """Replace all exercises in a template with the given list.""" + conn.execute( + "DELETE FROM workout_template_exercises WHERE template_id = ?", (template_id,) + ) + for i, ex in enumerate(exercises, 1): + conn.execute( + """INSERT INTO workout_template_exercises + (template_id, exercise_id, position, sets, reps, lsrpe, rest_time, note) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + ( + template_id, + ex["exercise_id"], + i, + ex["sets"], + ex["reps"], + ex["lsrpe"], + ex["rest_time"], + ex.get("note"), + ), + ) + conn.commit() @@ -1,6 +1,11 @@ +import os from datetime import datetime +def clear_screen() -> None: + os.system("cls" if os.name == "nt" else "clear") + + def print_header(title: str) -> None: print(f"\n--- {title} ---\n") @@ -23,10 +28,13 @@ def prompt_int( required: bool = True, min_val: int | None = None, max_val: int | None = None, + default: int | None = None, ) -> int | None: while True: val = input(prompt).strip() if not val: + if default is not None: + return default if not required: return None print("This field is required.") @@ -45,10 +53,44 @@ def prompt_int( return n -def prompt_str(prompt: str, required: bool = True) -> str | None: +def prompt_float( + prompt: str, + required: bool = True, + min_val: float | None = None, + max_val: float | None = None, + default: float | None = None, +) -> float | None: while True: val = input(prompt).strip() if not val: + if default is not None: + return default + if not required: + return None + print("This field is required.") + continue + try: + n = float(val) + except ValueError: + print("Please enter a valid number.") + continue + if min_val is not None and n < min_val: + print(f"Must be at least {min_val}.") + continue + if max_val is not None and n > max_val: + print(f"Must be at most {max_val}.") + continue + return round(n, 1) + + +def prompt_str( + prompt: str, required: bool = True, default: str | None = None +) -> str | None: + while True: + val = input(prompt).strip() + if not val: + if default is not None: + return default if not required: return None print("This field is required.") @@ -56,6 +98,98 @@ def prompt_str(prompt: str, required: bool = True) -> str | None: return val +def _fmt_kg(val: str) -> str: + """Format a single weight value, dropping '.0' for whole numbers.""" + n = float(val) + return str(int(n)) if n == int(n) else f"{n:.1f}" + + +def format_weight(weight_str: str, bw_relative: bool) -> str: + """Format comma-separated weight values for display.""" + values = weight_str.split(",") + if bw_relative: + parts = [] + for v in values: + n = float(v) + label = _fmt_kg(v) + if n >= 0: + parts.append(f"BW+{label}") + else: + parts.append(f"BW{label}") + return "/".join(parts) + "kg" + return "/".join(_fmt_kg(v) for v in values) + "kg" + + +def prompt_sets_detail( + sets: int, + default_reps: int | None = None, + default_weight: float | None = None, + default_rest: int | None = None, + bw_relative: bool = False, +) -> tuple[str, str, str]: + """Prompt for reps, weight (kg), and rest time on each set, return comma-separated strings.""" + weight_label = "Weight relative to BW (kg)" if bw_relative else "Weight kg" + weight_min: float | None = None if bw_relative else 0.0 + reps_list = [] + weight_list = [] + rest_list = [] + for i in range(1, sets + 1): + print(f" Set {i}:") + if default_reps is not None: + r = prompt_int( + f" Reps [{default_reps}]: ", min_val=1, default=default_reps + ) + else: + r = prompt_int(" Reps: ", min_val=1) + assert r is not None + reps_list.append(str(r)) + if default_weight is not None: + w = prompt_float( + f" {weight_label} [{_fmt_kg(str(default_weight))}]: ", + min_val=weight_min, + default=default_weight, + ) + else: + w = prompt_float(f" {weight_label}: ", min_val=weight_min) + assert w is not None + weight_list.append(f"{w:.1f}") + if default_rest is not None: + rest_val = input(f" Rest seconds [{default_rest}] (- = skip): ").strip() + if rest_val == "-": + rest_list.append("") + elif rest_val == "": + rest_list.append(str(default_rest)) + else: + try: + n = int(rest_val) + if n < 0: + print(" Must be at least 0, using default.") + rest_list.append(str(default_rest)) + else: + rest_list.append(str(n)) + except ValueError: + print(" Invalid input, using default.") + rest_list.append(str(default_rest)) + else: + rt = prompt_int(" Rest seconds (optional): ", required=False, min_val=0) + rest_list.append(str(rt) if rt is not None else "") + return ",".join(reps_list), ",".join(weight_list), ",".join(rest_list) + + +def format_rest_time(rest_str: str | None) -> str: + """Format comma-separated rest values for display, showing '-' for skipped sets.""" + if not rest_str: + return "-" + parts = [] + for v in rest_str.split(","): + v = v.strip() + if v: + parts.append(f"{v}s") + else: + parts.append("-") + return "/".join(parts) + + def prompt_datetime(prompt: str, default_now: bool = True) -> str: while True: default = datetime.now().strftime("%Y-%m-%d %H") @@ -72,6 +206,10 @@ def prompt_datetime(prompt: str, default_now: bool = True) -> str: continue +def pause() -> None: + input("\nPress Enter to continue...") + + def confirm(prompt: str) -> bool: val = input(f"{prompt} [y/N]: ").strip().lower() return val == "y" |
