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

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(