diff --git a/ROADMAP.md b/ROADMAP.md index 8ae387f..fb94fa8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,7 +6,7 @@ ## Next - [x] **#1** Hide "next exercise" affordance until current exercise has ≥1 set - [x] Version display in Mini App footer — format `YYYY-MM-DD `. Primary path uses `git log`, pure-Python fallback parses `.git/HEAD` + loose commit objects for environments without git on PATH. -- [ ] Semantic versioning tags — start tagging releases (`v0.1.0`, `v0.2.0`, ...) so the footer shows a human version instead of a SHA. +- [x] ~~Semantic versioning tags — start tagging releases (`v0.1.0`, `v0.2.0`, ...) so the footer shows a human version instead of a SHA.~~ Decided against — keeping `YYYY-MM-DD ` format. - [ ] **#7** Per-user workout numbering display (global ID stays as real key, just display transform) ## Soon diff --git a/bot.py b/bot.py index 764cca6..e08f3dc 100644 --- a/bot.py +++ b/bot.py @@ -16,7 +16,7 @@ from telegram.ext import ( filters, ) -from db import init_db, save_workout, get_workouts, get_workout_count, get_stats_sql, delete_workout, save_feedback +from db import init_db, save_workout, get_workouts, get_workout_count, get_stats_sql, delete_workout, save_feedback, get_user_workout_number, resolve_user_number from parser import parse_workout, format_workout load_dotenv() @@ -92,7 +92,7 @@ async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE): "Commands:\n" "/history \u2014 view recent workouts\n" "/stats \u2014 quick summary\n" - "/delete <id> \u2014 delete a workout\n" + "/delete <number> \u2014 delete a workout (see /history)\n" "/export \u2014 export all data as JSON\n" "/feedback <text> \u2014 send feedback" ) @@ -121,7 +121,7 @@ async def cmd_history(update: Update, context: ContextTypes.DEFAULT_TYPE): parts = [] for w in workouts: ts = datetime.fromisoformat(w["timestamp"]) - header = f"\U0001f4c5 {ts.strftime('%a %d %b %Y, %H:%M')} (#{w['id']})" + header = f"\U0001f4c5 {ts.strftime('%a %d %b %Y, %H:%M')} (#{w['user_number']})" body = format_workout(w["superset_groups"]) parts.append(f"{header}\n{body}") @@ -155,23 +155,24 @@ async def cmd_delete(update: Update, context: ContextTypes.DEFAULT_TYPE): if not context.args: await update.message.reply_text( - "Usage: /delete <workout_id>\n" - "Use /history to see workout IDs.", + "Usage: /delete <number>\n" + "Use /history to see workout numbers.", parse_mode=ParseMode.HTML, ) return try: - workout_id = int(context.args[0]) + user_number = int(context.args[0]) except ValueError: - await update.message.reply_text("Workout ID must be a number.") + await update.message.reply_text("Workout number must be a number.") return - if delete_workout(user_id, workout_id): - await update.message.reply_text(f"\U0001f5d1 Workout #{workout_id} deleted.") + workout_id = resolve_user_number(user_id, user_number) + if workout_id is not None and delete_workout(user_id, workout_id): + await update.message.reply_text(f"\U0001f5d1 Workout #{user_number} deleted.") else: await update.message.reply_text( - f"Workout #{workout_id} not found (or not yours)." + f"Workout #{user_number} not found." ) @@ -247,6 +248,7 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE): superset_dicts = [[ex.to_dict() for ex in group] for group in groups] workout_id = save_workout(user_id, timestamp, superset_dicts, raw_text=text) + user_number = get_user_workout_number(user_id, workout_id) or workout_id # Count totals for the confirmation total_exercises = sum(len(g) for g in groups) @@ -256,7 +258,7 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE): ts_str = timestamp.strftime("%a %d %b %Y, %H:%M") confirm_parts = [ - f"\u2705 Workout #{workout_id} saved!", + f"\u2705 Workout #{user_number} saved!", f"\U0001f4c5 {ts_str}" + (" (from forwarded message)" if is_forwarded else ""), f"\U0001f3cb\ufe0f {total_exercises} exercises, {total_sets} total sets", ] diff --git a/db.py b/db.py index 30ce291..24ffd29 100644 --- a/db.py +++ b/db.py @@ -159,10 +159,15 @@ def delete_workout(user_id: int, workout_id: int) -> bool: def get_workouts(user_id: int, limit: int = 10, offset: int = 0) -> list[dict]: - """Fetch recent non-deleted workouts for a user, newest first.""" + """Fetch recent non-deleted workouts for a user, newest first. + + Each workout includes a `user_number` — the per-user display rank when + ordered by timestamp ascending (1 = the user's first workout). + """ with get_db() as conn: rows = conn.execute( - """SELECT id, timestamp, note, raw_text, created_at + """SELECT id, timestamp, note, raw_text, created_at, + ROW_NUMBER() OVER (ORDER BY timestamp ASC, id ASC) AS user_number FROM workouts WHERE user_id = ? AND deleted_at IS NULL ORDER BY timestamp DESC @@ -205,6 +210,40 @@ def get_workouts(user_id: int, limit: int = 10, offset: int = 0) -> list[dict]: return workouts +def get_user_workout_number(user_id: int, workout_id: int) -> int | None: + """Return the per-user display number for a specific workout, or None + if the workout doesn't exist or is deleted. + """ + with get_db() as conn: + row = conn.execute( + """SELECT user_number FROM ( + SELECT id, ROW_NUMBER() OVER (ORDER BY timestamp ASC, id ASC) AS user_number + FROM workouts + WHERE user_id = ? AND deleted_at IS NULL + ) + WHERE id = ?""", + (user_id, workout_id), + ).fetchone() + return row["user_number"] if row else None + + +def resolve_user_number(user_id: int, user_number: int) -> int | None: + """Map a per-user display number to the global workout id, or None.""" + if user_number < 1: + return None + with get_db() as conn: + row = conn.execute( + """SELECT id FROM ( + SELECT id, ROW_NUMBER() OVER (ORDER BY timestamp ASC, id ASC) AS n + FROM workouts + WHERE user_id = ? AND deleted_at IS NULL + ) + WHERE n = ?""", + (user_id, user_number), + ).fetchone() + return row["id"] if row else None + + 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 bb65489..80f1d71 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 +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 parser import parse_workout, format_workout logging.basicConfig( @@ -227,7 +227,11 @@ async def api_save_workout(request: web.Request): {"error": "Provide superset_groups or raw_text"}, status=400 ) - return web.json_response({"workout_id": workout_id}, status=201) + user_number = get_user_workout_number(request["user_id"], workout_id) + return web.json_response( + {"workout_id": workout_id, "user_number": user_number}, + status=201, + ) @require_auth @@ -244,7 +248,8 @@ async def api_update_workout(request: web.Request): new_id = update_workout(request["user_id"], workout_id, superset_groups, note=note) if new_id is None: return web.json_response({"error": "Not found"}, status=404) - return web.json_response({"workout_id": new_id}) + user_number = get_user_workout_number(request["user_id"], new_id) + return web.json_response({"workout_id": new_id, "user_number": user_number}) @require_auth diff --git a/tests/test_db.py b/tests/test_db.py index adf322f..72d6c4f 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -147,6 +147,70 @@ class TestDeleteWorkout: assert db.delete_workout(1, wid) is False # already deleted +# ── per-user numbering ─────────────────────────────────────────── + + +class TestUserNumbering: + def test_user_number_in_get_workouts(self, tmp_db): + t = lambda d: datetime(2024, 1, d, tzinfo=timezone.utc) + _save_simple(name="First", ts=t(1)) + _save_simple(name="Second", ts=t(2)) + _save_simple(name="Third", ts=t(3)) + ws = db.get_workouts(1) # newest first + assert [w["superset_groups"][0][0]["name"] for w in ws] == ["Third", "Second", "First"] + assert [w["user_number"] for w in ws] == [3, 2, 1] + + def test_numbering_is_per_user(self, tmp_db): + t = lambda d: datetime(2024, 1, d, tzinfo=timezone.utc) + _save_simple(user_id=1, ts=t(1)) + _save_simple(user_id=2, ts=t(1)) + _save_simple(user_id=1, ts=t(2)) + _save_simple(user_id=2, ts=t(2)) + assert [w["user_number"] for w in db.get_workouts(1)] == [2, 1] + assert [w["user_number"] for w in db.get_workouts(2)] == [2, 1] + + def test_numbering_skips_deleted(self, tmp_db): + t = lambda d: datetime(2024, 1, d, tzinfo=timezone.utc) + w1 = _save_simple(ts=t(1)) + _save_simple(ts=t(2)) + _save_simple(ts=t(3)) + db.delete_workout(1, w1) + ws = db.get_workouts(1) # now 2 workouts, both shift down + assert [w["user_number"] for w in ws] == [2, 1] + + def test_get_user_workout_number(self, tmp_db): + t = lambda d: datetime(2024, 1, d, tzinfo=timezone.utc) + w1 = _save_simple(ts=t(1)) + w2 = _save_simple(ts=t(2)) + assert db.get_user_workout_number(1, w1) == 1 + assert db.get_user_workout_number(1, w2) == 2 + + def test_get_user_workout_number_missing(self, tmp_db): + assert db.get_user_workout_number(1, 9999) is None + + def test_get_user_workout_number_deleted(self, tmp_db): + wid = _save_simple() + db.delete_workout(1, wid) + assert db.get_user_workout_number(1, wid) is None + + def test_resolve_user_number(self, tmp_db): + t = lambda d: datetime(2024, 1, d, tzinfo=timezone.utc) + w1 = _save_simple(ts=t(1)) + w2 = _save_simple(ts=t(2)) + assert db.resolve_user_number(1, 1) == w1 + assert db.resolve_user_number(1, 2) == w2 + + def test_resolve_user_number_out_of_range(self, tmp_db): + _save_simple() + assert db.resolve_user_number(1, 0) is None + assert db.resolve_user_number(1, 99) is None + assert db.resolve_user_number(1, -1) is None + + def test_resolve_user_number_wrong_user(self, tmp_db): + _save_simple(user_id=1) + assert db.resolve_user_number(user_id=2, user_number=1) is None + + # ── update_workout ─────────────────────────────────────────────── diff --git a/webapp/app.js b/webapp/app.js index d4fc246..51ea3ae 100644 --- a/webapp/app.js +++ b/webapp/app.js @@ -580,7 +580,7 @@ btnSaveWorkout.addEventListener("click", async () => { showToast("Workout updated!"); } else { data = await api("POST", "/workouts", { superset_groups, note }); - showToast("Workout #" + data.workout_id + " saved!"); + showToast("Workout #" + (data.user_number ?? data.workout_id) + " saved!"); } workout = []; currentExercise = null; @@ -610,7 +610,7 @@ document.getElementById("btn-save-raw").addEventListener("click", async () => { const data = await api("POST", "/workouts", { raw_text: raw }); document.getElementById("inp-raw").value = ""; clearDraft(); - showToast("Workout #" + data.workout_id + " saved!"); + showToast("Workout #" + (data.user_number ?? data.workout_id) + " saved!"); tg.HapticFeedback.notificationOccurred("success"); } catch (e) { showToast(e.message);