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:
parent
8e22cdb29d
commit
bc1d44b556
6 changed files with 129 additions and 19 deletions
|
|
@ -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
24
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):
|
|||
"<b>Commands:</b>\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 <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 <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 <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
43
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(
|
||||
|
|
|
|||
11
server.py
11
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
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue