aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorThomas Vanbesien <tvanbesi@proton.me>2026-03-12 18:00:45 +0100
committerThomas Vanbesien <tvanbesi@proton.me>2026-03-18 14:31:27 +0100
commit7ceb22f1e12e3a040874a43b5e1177db83be15ed (patch)
treeb7418bbe91223bd35f03548249547011e0d99bdf
parent6e7e00846e658cb79d0c23e18939c59fedba06dd (diff)
downloadEgoMetrics-7ceb22f1e12e3a040874a43b5e1177db83be15ed.tar.gz
EgoMetrics-7ceb22f1e12e3a040874a43b5e1177db83be15ed.zip
Add templates, per-set tracking, and float weight support
- Rename tables with workout_ prefix, add workout_templates and workout_template_exercises tables - Add bw_relative flag to exercises for body-weight-relative display - Store reps, weight, and rest_time as per-set comma-separated TEXT in session exercises (rest_time is optional/nullable) - Support float weight with one decimal place - Add template CRUD and template-to-session logging flow
-rw-r--r--README.md5
-rw-r--r--db.py51
-rw-r--r--egometrics.py472
-rw-r--r--models.py202
-rw-r--r--ui.py140
5 files changed, 743 insertions, 127 deletions
diff --git a/README.md b/README.md
index 4cec9e1..7955577 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/db.py b/db.py
index fa407d8..a5f3250 100644
--- a/db.py
+++ b/db.py
@@ -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:
diff --git a/models.py b/models.py
index 654b346..a844271 100644
--- a/models.py
+++ b/models.py
@@ -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()
diff --git a/ui.py b/ui.py
index 2d63c06..ec4c1ee 100644
--- a/ui.py
+++ b/ui.py
@@ -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"