From 0e4bf65d5b14f806a9fafc1c1209530453e41896 Mon Sep 17 00:00:00 2001 From: Danny Date: Sun, 19 Apr 2026 13:56:53 +0200 Subject: [PATCH] feat: global exercise-name autocomplete Autocomplete now draws from every user's logged exercises, not just the requesting user's history. New users get suggestions from day one. - db.get_all_exercise_names(): case-insensitive grouping, ordered by usage count desc, alphabetical tiebreak, excludes names that only appear in soft-deleted workouts. - server.api_get_exercise_names simplified to a one-liner. Co-Authored-By: Claude Opus 4.7 (1M context) --- db.py | 18 ++++++++++++++++++ server.py | 18 +++++------------- tests/test_db.py | 42 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 13 deletions(-) diff --git a/db.py b/db.py index 24ffd29..36983c8 100644 --- a/db.py +++ b/db.py @@ -252,6 +252,24 @@ def get_workout_count(user_id: int) -> int: return row["cnt"] +def get_all_exercise_names() -> list[str]: + """Return exercise names across all users (for autocomplete), ordered by + popularity (most-used first), then alphabetically. Case-insensitive + grouping — each distinct name is returned once in its most-used casing. + """ + with get_db() as conn: + rows = conn.execute( + """SELECT e.name, COUNT(*) AS n + FROM exercises e + JOIN superset_groups sg ON sg.id = e.superset_group_id + JOIN workouts w ON w.id = sg.workout_id + WHERE w.deleted_at IS NULL + GROUP BY LOWER(e.name) + ORDER BY n DESC, LOWER(e.name) ASC""", + ).fetchall() + return [r["name"] for r in rows] + + def get_stats_sql(user_id: int) -> dict: """Compute stats entirely in SQL.""" with get_db() as conn: diff --git a/server.py b/server.py index 80f1d71..c63c062 100644 --- a/server.py +++ b/server.py @@ -17,7 +17,7 @@ from urllib.parse import parse_qs from aiohttp import web -from db import init_db, get_db, save_workout, get_workouts, get_workout_count, get_stats_sql, delete_workout, update_workout, export_workouts, get_user_workout_number +from db import init_db, save_workout, get_workouts, get_workout_count, get_stats_sql, delete_workout, update_workout, export_workouts, get_user_workout_number, get_all_exercise_names from parser import parse_workout, format_workout logging.basicConfig( @@ -263,18 +263,10 @@ async def api_delete_workout(request: web.Request): @require_auth async def api_get_exercise_names(request: web.Request): - """Return unique exercise names this user has logged (for autocomplete).""" - with get_db() as conn: - rows = conn.execute( - """SELECT DISTINCT e.name - FROM exercises e - JOIN superset_groups sg ON sg.id = e.superset_group_id - JOIN workouts w ON w.id = sg.workout_id - WHERE w.user_id = ? - ORDER BY e.name""", - (request["user_id"],), - ).fetchall() - return web.json_response({"exercises": [r["name"] for r in rows]}) + """Return exercise names (for autocomplete), aggregated globally across + all users and ordered by popularity. + """ + return web.json_response({"exercises": get_all_exercise_names()}) @require_auth diff --git a/tests/test_db.py b/tests/test_db.py index 72d6c4f..1e87115 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -211,6 +211,48 @@ class TestUserNumbering: assert db.resolve_user_number(user_id=2, user_number=1) is None +# ── global exercise names ──────────────────────────────────────── + + +class TestAllExerciseNames: + def test_empty(self, tmp_db): + assert db.get_all_exercise_names() == [] + + def test_draws_from_all_users(self, tmp_db): + _save_simple(user_id=1, name="Bench") + _save_simple(user_id=2, name="Squat") + names = db.get_all_exercise_names() + assert set(names) == {"Bench", "Squat"} + + def test_ordered_by_popularity(self, tmp_db): + # Squat appears 3x (across users); Bench 2x; Rows 1x. + for _ in range(3): + _save_simple(name="Squat") + for _ in range(2): + _save_simple(name="Bench") + _save_simple(name="Rows") + assert db.get_all_exercise_names() == ["Squat", "Bench", "Rows"] + + def test_case_insensitive_grouping(self, tmp_db): + _save_simple(name="bench press") + _save_simple(name="Bench Press") + _save_simple(name="BENCH PRESS") + names = db.get_all_exercise_names() + assert len(names) == 1 # collapsed into one group + + def test_excludes_deleted_workouts(self, tmp_db): + wid = _save_simple(name="Deadlift") + _save_simple(name="Squat") + db.delete_workout(1, wid) + assert db.get_all_exercise_names() == ["Squat"] + + def test_alphabetical_tiebreak(self, tmp_db): + # All tied at 1 usage — should come back alphabetical. + for n in ["Zebra", "Apple", "Mango"]: + _save_simple(name=n) + assert db.get_all_exercise_names() == ["Apple", "Mango", "Zebra"] + + # ── update_workout ───────────────────────────────────────────────