diff --git a/db.py b/db.py index 5b834c4..410097b 100644 --- a/db.py +++ b/db.py @@ -263,6 +263,39 @@ def resolve_user_number(user_id: int, user_number: int) -> int | None: return row["id"] if row else None +def get_last_exercise(user_id: int, name: str) -> dict | None: + """Return the most recent logged entry for an exercise (case-insensitive + name match) from this user's non-deleted workouts, or None. + + The returned dict carries the exercise fields plus the parent workout's + `timestamp`, and `sets_detail` parsed back into a list. + """ + with get_db() as conn: + row = conn.execute( + """SELECT e.name, e.machine_id, e.sets, e.reps, e.weight_kg, + e.sets_detail, w.timestamp + 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 = ? AND w.deleted_at IS NULL + AND LOWER(e.name) = LOWER(?) + ORDER BY w.timestamp DESC, e.id DESC + LIMIT 1""", + (user_id, name), + ).fetchone() + if not row: + return None + d = dict(row) + if d.get("sets_detail"): + try: + d["sets_detail"] = json.loads(d["sets_detail"]) + except json.JSONDecodeError: + d["sets_detail"] = [] + else: + d["sets_detail"] = [] + return d + + def get_workout_count(user_id: int) -> int: with get_db() as conn: row = conn.execute( diff --git a/server.py b/server.py index 8e0ee77..71d2f9a 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, save_workout, get_workouts, get_workout_count, get_stats_sql, delete_workout, update_workout, export_workouts, get_user_workout_number, get_all_exercise_names, log_event, get_settings, update_settings +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, log_event, get_settings, update_settings, get_last_exercise from parser import parse_workout, format_workout logging.basicConfig( @@ -283,6 +283,16 @@ async def api_get_exercise_names(request: web.Request): return web.json_response({"exercises": get_all_exercise_names()}) +@require_auth +async def api_get_last_exercise(request: web.Request): + """Return the user's most recent logged entry for a given exercise name.""" + name = request.query.get("name", "").strip() + if not name: + return web.json_response({"error": "Missing name"}, status=400) + last = get_last_exercise(request["user_id"], name) + return web.json_response({"last": last}) + + @require_auth async def api_get_stats(request: web.Request): """Return summary stats for the user.""" @@ -366,6 +376,7 @@ def create_app() -> web.Application: app.router.add_put("/api/workouts/{workout_id}", api_update_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/last", api_get_last_exercise) 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) diff --git a/tests/test_db.py b/tests/test_db.py index 82bfd88..d770e8e 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -253,6 +253,48 @@ class TestAllExerciseNames: assert db.get_all_exercise_names() == ["Apple", "Mango", "Zebra"] +# ── get_last_exercise ──────────────────────────────────────────── + + +class TestGetLastExercise: + def test_none_when_no_history(self, tmp_db): + assert db.get_last_exercise(1, "Bench") is None + + def test_returns_most_recent(self, tmp_db): + t = lambda d: datetime(2024, 1, d, tzinfo=timezone.utc) + db.save_workout(1, t(1), [[_make_exercise(name="Squat", weight=80.0)]]) + db.save_workout(1, t(5), [[_make_exercise(name="Squat", weight=90.0)]]) + last = db.get_last_exercise(1, "Squat") + assert last is not None + assert last["weight_kg"] == 90.0 + assert last["timestamp"].startswith("2024-01-05") + + def test_case_insensitive(self, tmp_db): + _save_simple(name="Bench Press") + assert db.get_last_exercise(1, "bench press") is not None + assert db.get_last_exercise(1, "BENCH PRESS") is not None + + def test_sets_detail_parsed(self, tmp_db): + detail = [{"reps": 8, "weight_kg": 25.0}, {"reps": 5, "weight_kg": 35.0}] + ex = { + "name": "Press", "machine_id": None, + "sets": 2, "reps": 8, "weight_kg": 25.0, + "sets_detail": detail, "raw_line": "Press: 8x25, 5x35", + } + db.save_workout(1, datetime.now(timezone.utc), [[ex]]) + last = db.get_last_exercise(1, "Press") + assert last["sets_detail"] == detail + + def test_scoped_to_user(self, tmp_db): + _save_simple(user_id=1, name="Deadlift") + assert db.get_last_exercise(2, "Deadlift") is None + + def test_ignores_deleted(self, tmp_db): + wid = _save_simple(name="Rows") + db.delete_workout(1, wid) + assert db.get_last_exercise(1, "Rows") is None + + # ── events / log_event ─────────────────────────────────────────── diff --git a/webapp/app.js b/webapp/app.js index 6559fac..7f8577a 100644 --- a/webapp/app.js +++ b/webapp/app.js @@ -125,6 +125,7 @@ function restoreDraft() { if (Array.isArray(draft.currentSets)) { draft.currentSets.forEach((s) => addSetToDOM(s.reps, s.weight_kg)); } + loadLastSession(currentExercise.name); } // Restore active tab @@ -357,10 +358,72 @@ function startExercise(name) { notesSection.classList.remove("hidden"); stopRestTimer(); syncEditorUI(); + loadLastSession(name); tg.HapticFeedback.selectionChanged(); saveDraft(); } +// ── Last-session recall ───────────────────────────────────────── +function _relativeDay(iso) { + const then = new Date(iso); + if (isNaN(then.getTime())) return ""; + const days = Math.floor((Date.now() - then.getTime()) / 86400000); + if (days <= 0) return "today"; + if (days === 1) return "yesterday"; + if (days < 7) return days + " days ago"; + if (days < 14) return "1 week ago"; + if (days < 30) return Math.floor(days / 7) + " weeks ago"; + return then.toLocaleDateString(); +} + +function _setsSummary(last) { + const detail = last.sets_detail || []; + const varied = detail.length > 0 && !detail.every( + (d) => d.reps === detail[0].reps && d.weight_kg === detail[0].weight_kg + ); + if (varied) { + return detail + .map((d) => (d.weight_kg ? `${d.reps}×${fmtWeight(d.weight_kg)}kg` : `${d.reps}`)) + .join(", "); + } + return last.weight_kg + ? `${last.sets}×${last.reps}×${fmtWeight(last.weight_kg)}kg` + : `${last.sets}×${last.reps}`; +} + +async function loadLastSession(name) { + const hint = document.getElementById("last-session-hint"); + if (hint) { + hint.classList.add("hidden"); + hint.textContent = ""; + } + try { + const data = await api("GET", "/exercises/last?name=" + encodeURIComponent(name)); + const last = data.last; + // Bail if the user has moved on to a different exercise meanwhile. + if (!last || !currentExercise || currentExercise.name !== name) return; + + if (hint) { + const when = _relativeDay(last.timestamp); + hint.textContent = "Last time: " + _setsSummary(last) + (when ? " · " + when : ""); + hint.classList.remove("hidden"); + } + + // Pre-fill the weight input with the last set's weight, but only if the + // user hasn't already started typing or logged a set. + const detail = last.sets_detail || []; + const lastWeight = detail.length + ? detail[detail.length - 1].weight_kg + : last.weight_kg; + if (lastWeight && !weightInput.value.trim() && getCurrentSets().length === 0) { + weightInput.value = String(lastWeight); + syncWeightSignUI(); + } + } catch (e) { + // Silent — recall is a convenience, never block exercise entry. + } +} + function getCurrentSets() { return Array.from(setsList.querySelectorAll(".set-entry")).map((el) => ({ reps: parseInt(el.dataset.reps), @@ -592,6 +655,14 @@ function editExercise(idx) { repsInput.value = ""; repsInput.focus(); + // The set rows in front of you are the reference here — drop any stale + // "last time" hint from an earlier startExercise. + const hint = document.getElementById("last-session-hint"); + if (hint) { + hint.classList.add("hidden"); + hint.textContent = ""; + } + stopRestTimer(); renderWorkout(); tg.HapticFeedback.selectionChanged(); diff --git a/webapp/index.html b/webapp/index.html index dc5f84a..16844cf 100644 --- a/webapp/index.html +++ b/webapp/index.html @@ -37,6 +37,7 @@ +
diff --git a/webapp/style.css b/webapp/style.css index 5e5e144..06d29c2 100644 --- a/webapp/style.css +++ b/webapp/style.css @@ -241,6 +241,15 @@ body { font-variant-numeric: tabular-nums; } +.last-session-hint { + margin-top: 6px; + font-size: 12px; + color: var(--tg-theme-hint-color, #999); + background: var(--tg-theme-bg-color, #fff); + border-radius: 8px; + padding: 6px 10px; +} + .btn-danger { color: #e53935 !important; font-size: 12px !important;