feat: last-session recall when starting an exercise

When you start an exercise, the Mini App now fetches the most
recent time you logged it and shows a hint line in the sets card
("Last time: 8×60, 6×60, 5×60 · 3 days ago"), plus pre-fills the
weight input with the last set's weight.

- db.get_last_exercise(user_id, name): most recent non-deleted
  entry, case-insensitive name match, sets_detail parsed.
- GET /api/exercises/last?name=<name>.
- webapp: loadLastSession() on startExercise + draft restore;
  hint cleared on editExercise (the set rows are the reference
  there). Pre-fill only when the weight field is empty and no
  sets logged yet, so it never clobbers user input.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Danny 2026-05-22 12:52:37 +02:00
parent 5e3636201f
commit 9f146d60fa
6 changed files with 168 additions and 1 deletions

33
db.py
View file

@ -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(

View file

@ -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)

View file

@ -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 ───────────────────────────────────────────

View file

@ -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();

View file

@ -37,6 +37,7 @@
</div>
<button id="btn-delete-exercise" class="btn-link btn-danger hidden">Remove exercise</button>
</div>
<div id="last-session-hint" class="last-session-hint hidden"></div>
<div id="sets-list"></div>
<div class="set-input-row">
<input type="text" id="inp-reps" class="input input-small" placeholder="Reps" inputmode="numeric" pattern="[0-9]*" />

View file

@ -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;