feat: per-user workout numbering (#7)

Display workouts as "#N" based on each user's own ordered list of
non-deleted workouts (rank by timestamp ascending). Global auto-
increment id stays the primary key, used only internally and in
exports. User-visible surfaces now all use the per-user number:
- /history listing
- /delete now accepts the per-user number
- Save confirmations (bot text and Mini App toast)

Deleting a workout renumbers the later ones downward, as expected
for a pure display transform.

New db helpers: get_user_workout_number, resolve_user_number, and
get_workouts now includes user_number per row via SQLite window
function.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Danny 2026-04-18 22:32:24 +02:00
parent 8e22cdb29d
commit bc1d44b556
6 changed files with 129 additions and 19 deletions

View file

@ -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 <short-sha>`. 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 <short-sha>` format.
- [ ] **#7** Per-user workout numbering display (global ID stays as real key, just display transform)
## Soon

24
bot.py
View file

@ -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):
"<b>Commands:</b>\n"
"/history \u2014 view recent workouts\n"
"/stats \u2014 quick summary\n"
"/delete &lt;id&gt; \u2014 delete a workout\n"
"/delete &lt;number&gt; \u2014 delete a workout (see /history)\n"
"/export \u2014 export all data as JSON\n"
"/feedback &lt;text&gt; \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 <b>{ts.strftime('%a %d %b %Y, %H:%M')}</b> (#{w['id']})"
header = f"\U0001f4c5 <b>{ts.strftime('%a %d %b %Y, %H:%M')}</b> (#{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 &lt;workout_id&gt;\n"
"Use /history to see workout IDs.",
"Usage: /delete &lt;number&gt;\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 <b>Workout #{workout_id} saved!</b>",
f"\u2705 <b>Workout #{user_number} saved!</b>",
f"\U0001f4c5 {ts_str}" + (" (from forwarded message)" if is_forwarded else ""),
f"\U0001f3cb\ufe0f {total_exercises} exercises, {total_sets} total sets",
]

43
db.py
View file

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

View file

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

View file

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

View file

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