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) <noreply@anthropic.com>
This commit is contained in:
parent
3209136189
commit
0e4bf65d5b
3 changed files with 65 additions and 13 deletions
18
db.py
18
db.py
|
|
@ -252,6 +252,24 @@ def get_workout_count(user_id: int) -> int:
|
||||||
return row["cnt"]
|
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:
|
def get_stats_sql(user_id: int) -> dict:
|
||||||
"""Compute stats entirely in SQL."""
|
"""Compute stats entirely in SQL."""
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
|
|
|
||||||
18
server.py
18
server.py
|
|
@ -17,7 +17,7 @@ from urllib.parse import parse_qs
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
from db import init_db, 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
|
from parser import parse_workout, format_workout
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
|
|
@ -263,18 +263,10 @@ async def api_delete_workout(request: web.Request):
|
||||||
|
|
||||||
@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 exercise names (for autocomplete), aggregated globally across
|
||||||
with get_db() as conn:
|
all users and ordered by popularity.
|
||||||
rows = conn.execute(
|
"""
|
||||||
"""SELECT DISTINCT e.name
|
return web.json_response({"exercises": get_all_exercise_names()})
|
||||||
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]})
|
|
||||||
|
|
||||||
|
|
||||||
@require_auth
|
@require_auth
|
||||||
|
|
|
||||||
|
|
@ -211,6 +211,48 @@ class TestUserNumbering:
|
||||||
assert db.resolve_user_number(user_id=2, user_number=1) is None
|
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 ───────────────────────────────────────────────
|
# ── update_workout ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue