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
|
## Next
|
||||||
- [x] **#1** Hide "next exercise" affordance until current exercise has ≥1 set
|
- [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.
|
- [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)
|
- [ ] **#7** Per-user workout numbering display (global ID stays as real key, just display transform)
|
||||||
|
|
||||||
## Soon
|
## Soon
|
||||||
|
|
|
||||||
24
bot.py
24
bot.py
|
|
@ -16,7 +16,7 @@ from telegram.ext import (
|
||||||
filters,
|
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
|
from parser import parse_workout, format_workout
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
@ -92,7 +92,7 @@ async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
"<b>Commands:</b>\n"
|
"<b>Commands:</b>\n"
|
||||||
"/history \u2014 view recent workouts\n"
|
"/history \u2014 view recent workouts\n"
|
||||||
"/stats \u2014 quick summary\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"
|
"/export \u2014 export all data as JSON\n"
|
||||||
"/feedback <text> \u2014 send feedback"
|
"/feedback <text> \u2014 send feedback"
|
||||||
)
|
)
|
||||||
|
|
@ -121,7 +121,7 @@ async def cmd_history(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
parts = []
|
parts = []
|
||||||
for w in workouts:
|
for w in workouts:
|
||||||
ts = datetime.fromisoformat(w["timestamp"])
|
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"])
|
body = format_workout(w["superset_groups"])
|
||||||
parts.append(f"{header}\n{body}")
|
parts.append(f"{header}\n{body}")
|
||||||
|
|
||||||
|
|
@ -155,23 +155,24 @@ async def cmd_delete(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
|
||||||
if not context.args:
|
if not context.args:
|
||||||
await update.message.reply_text(
|
await update.message.reply_text(
|
||||||
"Usage: /delete <workout_id>\n"
|
"Usage: /delete <number>\n"
|
||||||
"Use /history to see workout IDs.",
|
"Use /history to see workout numbers.",
|
||||||
parse_mode=ParseMode.HTML,
|
parse_mode=ParseMode.HTML,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
workout_id = int(context.args[0])
|
user_number = int(context.args[0])
|
||||||
except ValueError:
|
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
|
return
|
||||||
|
|
||||||
if delete_workout(user_id, workout_id):
|
workout_id = resolve_user_number(user_id, user_number)
|
||||||
await update.message.reply_text(f"\U0001f5d1 Workout #{workout_id} deleted.")
|
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:
|
else:
|
||||||
await update.message.reply_text(
|
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]
|
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)
|
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
|
# Count totals for the confirmation
|
||||||
total_exercises = sum(len(g) for g in groups)
|
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")
|
ts_str = timestamp.strftime("%a %d %b %Y, %H:%M")
|
||||||
|
|
||||||
confirm_parts = [
|
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"\U0001f4c5 {ts_str}" + (" (from forwarded message)" if is_forwarded else ""),
|
||||||
f"\U0001f3cb\ufe0f {total_exercises} exercises, {total_sets} total sets",
|
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]:
|
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:
|
with get_db() as conn:
|
||||||
rows = conn.execute(
|
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
|
FROM workouts
|
||||||
WHERE user_id = ? AND deleted_at IS NULL
|
WHERE user_id = ? AND deleted_at IS NULL
|
||||||
ORDER BY timestamp DESC
|
ORDER BY timestamp DESC
|
||||||
|
|
@ -205,6 +210,40 @@ def get_workouts(user_id: int, limit: int = 10, offset: int = 0) -> list[dict]:
|
||||||
return workouts
|
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:
|
def get_workout_count(user_id: int) -> int:
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
|
|
|
||||||
11
server.py
11
server.py
|
|
@ -17,7 +17,7 @@ from urllib.parse import parse_qs
|
||||||
|
|
||||||
from aiohttp import web
|
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
|
from parser import parse_workout, format_workout
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
|
|
@ -227,7 +227,11 @@ async def api_save_workout(request: web.Request):
|
||||||
{"error": "Provide superset_groups or raw_text"}, status=400
|
{"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
|
@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)
|
new_id = update_workout(request["user_id"], workout_id, superset_groups, note=note)
|
||||||
if new_id is None:
|
if new_id is None:
|
||||||
return web.json_response({"error": "Not found"}, status=404)
|
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
|
@require_auth
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,70 @@ class TestDeleteWorkout:
|
||||||
assert db.delete_workout(1, wid) is False # already deleted
|
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 ───────────────────────────────────────────────
|
# ── update_workout ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -580,7 +580,7 @@ btnSaveWorkout.addEventListener("click", async () => {
|
||||||
showToast("Workout updated!");
|
showToast("Workout updated!");
|
||||||
} else {
|
} else {
|
||||||
data = await api("POST", "/workouts", { superset_groups, note });
|
data = await api("POST", "/workouts", { superset_groups, note });
|
||||||
showToast("Workout #" + data.workout_id + " saved!");
|
showToast("Workout #" + (data.user_number ?? data.workout_id) + " saved!");
|
||||||
}
|
}
|
||||||
workout = [];
|
workout = [];
|
||||||
currentExercise = null;
|
currentExercise = null;
|
||||||
|
|
@ -610,7 +610,7 @@ document.getElementById("btn-save-raw").addEventListener("click", async () => {
|
||||||
const data = await api("POST", "/workouts", { raw_text: raw });
|
const data = await api("POST", "/workouts", { raw_text: raw });
|
||||||
document.getElementById("inp-raw").value = "";
|
document.getElementById("inp-raw").value = "";
|
||||||
clearDraft();
|
clearDraft();
|
||||||
showToast("Workout #" + data.workout_id + " saved!");
|
showToast("Workout #" + (data.user_number ?? data.workout_id) + " saved!");
|
||||||
tg.HapticFeedback.notificationOccurred("success");
|
tg.HapticFeedback.notificationOccurred("success");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(e.message);
|
showToast(e.message);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue