Compare commits
No commits in common. "9f146d60fa08643e3de18e43bc7d933377b926a2" and "d6f8fa41fb6704e45f2ebe579f795fcd509af0d6" have entirely different histories.
9f146d60fa
...
d6f8fa41fb
7 changed files with 1 additions and 169 deletions
|
|
@ -17,7 +17,6 @@
|
||||||
- [x] Interaction / event logging — structured `events` table; bot commands, workout save/update/delete, Mini App opens, and per-set additions all record events. `POST /api/events` endpoint lets the Mini App emit client-side events. Rest-timer prereq done.
|
- [x] Interaction / event logging — structured `events` table; bot commands, workout save/update/delete, Mini App opens, and per-set additions all record events. `POST /api/events` endpoint lets the Mini App emit client-side events. Rest-timer prereq done.
|
||||||
- [x] Staging via shipyard — Mini-App-only HTTP tenant under `shipyard_poc_bot` (slash-command bot deleted; phantom-ship's Shipyard owns Telegram polling). `fitness-bot-shipyard.service` on sunken-ship watches `origin/staging` from `/home/danny/tg_fitness_bot_shipyard`, listens on `:8081`, fronted by vps-relay Caddy at **https://b3.dannydannydanny.me**, validates initData against `shipyard_poc_bot`'s token (`EnvironmentFile=/home/danny/.secrets/shipyard_poc_bot.env`). Listed in `~/python-projects/26_shipyard/apps.json` as `b3bot-beta`. Workflow: `git push origin <branch>:staging` (auto-deploys ~15 min) → tap **B3Bot beta** in shipyard_poc_bot → test → `git push origin <branch>:main`.
|
- [x] Staging via shipyard — Mini-App-only HTTP tenant under `shipyard_poc_bot` (slash-command bot deleted; phantom-ship's Shipyard owns Telegram polling). `fitness-bot-shipyard.service` on sunken-ship watches `origin/staging` from `/home/danny/tg_fitness_bot_shipyard`, listens on `:8081`, fronted by vps-relay Caddy at **https://b3.dannydannydanny.me**, validates initData against `shipyard_poc_bot`'s token (`EnvironmentFile=/home/danny/.secrets/shipyard_poc_bot.env`). Listed in `~/python-projects/26_shipyard/apps.json` as `b3bot-beta`. Workflow: `git push origin <branch>:staging` (auto-deploys ~15 min) → tap **B3Bot beta** in shipyard_poc_bot → test → `git push origin <branch>:main`.
|
||||||
- [x] **feedback #9** Negative weight input — `±` sign-flip button next to the weight input handles iOS numeric keypads that have no minus key; active state indicates a negative value.
|
- [x] **feedback #9** Negative weight input — `±` sign-flip button next to the weight input handles iOS numeric keypads that have no minus key; active state indicates a negative value.
|
||||||
- [ ] Last-session recall — when starting an exercise, show the most recent sets/weights logged for it (and pre-fill the weight) so you have a reference for what to beat.
|
|
||||||
|
|
||||||
## Later
|
## Later
|
||||||
- [ ] **#8** Workout templates — save/load favorite workouts
|
- [ ] **#8** Workout templates — save/load favorite workouts
|
||||||
|
|
|
||||||
33
db.py
33
db.py
|
|
@ -263,39 +263,6 @@ def resolve_user_number(user_id: int, user_number: int) -> int | None:
|
||||||
return row["id"] if row else None
|
return row["id"] if row else None
|
||||||
|
|
||||||
|
|
||||||
def get_last_exercise(user_id: int, name: str) -> dict | None:
|
|
||||||
"""Return the most recent logged entry for an exercise (case-insensitive
|
|
||||||
name match) from this user's non-deleted workouts, or None.
|
|
||||||
|
|
||||||
The returned dict carries the exercise fields plus the parent workout's
|
|
||||||
`timestamp`, and `sets_detail` parsed back into a list.
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
row = conn.execute(
|
|
||||||
"""SELECT e.name, e.machine_id, e.sets, e.reps, e.weight_kg,
|
|
||||||
e.sets_detail, w.timestamp
|
|
||||||
FROM workouts w
|
|
||||||
JOIN superset_groups sg ON sg.workout_id = w.id
|
|
||||||
JOIN exercises e ON e.superset_group_id = sg.id
|
|
||||||
WHERE w.user_id = ? AND w.deleted_at IS NULL
|
|
||||||
AND LOWER(e.name) = LOWER(?)
|
|
||||||
ORDER BY w.timestamp DESC, e.id DESC
|
|
||||||
LIMIT 1""",
|
|
||||||
(user_id, name),
|
|
||||||
).fetchone()
|
|
||||||
if not row:
|
|
||||||
return None
|
|
||||||
d = dict(row)
|
|
||||||
if d.get("sets_detail"):
|
|
||||||
try:
|
|
||||||
d["sets_detail"] = json.loads(d["sets_detail"])
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
d["sets_detail"] = []
|
|
||||||
else:
|
|
||||||
d["sets_detail"] = []
|
|
||||||
return d
|
|
||||||
|
|
||||||
|
|
||||||
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(
|
||||||
|
|
|
||||||
13
server.py
13
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, save_workout, get_workouts, get_workout_count, get_stats_sql, delete_workout, update_workout, export_workouts, get_user_workout_number, get_all_exercise_names, log_event, get_settings, update_settings, get_last_exercise
|
from db import init_db, save_workout, get_workouts, get_workout_count, get_stats_sql, delete_workout, update_workout, export_workouts, get_user_workout_number, get_all_exercise_names, log_event, get_settings, update_settings
|
||||||
from parser import parse_workout, format_workout
|
from parser import parse_workout, format_workout
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
|
|
@ -283,16 +283,6 @@ async def api_get_exercise_names(request: web.Request):
|
||||||
return web.json_response({"exercises": get_all_exercise_names()})
|
return web.json_response({"exercises": get_all_exercise_names()})
|
||||||
|
|
||||||
|
|
||||||
@require_auth
|
|
||||||
async def api_get_last_exercise(request: web.Request):
|
|
||||||
"""Return the user's most recent logged entry for a given exercise name."""
|
|
||||||
name = request.query.get("name", "").strip()
|
|
||||||
if not name:
|
|
||||||
return web.json_response({"error": "Missing name"}, status=400)
|
|
||||||
last = get_last_exercise(request["user_id"], name)
|
|
||||||
return web.json_response({"last": last})
|
|
||||||
|
|
||||||
|
|
||||||
@require_auth
|
@require_auth
|
||||||
async def api_get_stats(request: web.Request):
|
async def api_get_stats(request: web.Request):
|
||||||
"""Return summary stats for the user."""
|
"""Return summary stats for the user."""
|
||||||
|
|
@ -376,7 +366,6 @@ def create_app() -> web.Application:
|
||||||
app.router.add_put("/api/workouts/{workout_id}", api_update_workout)
|
app.router.add_put("/api/workouts/{workout_id}", api_update_workout)
|
||||||
app.router.add_delete("/api/workouts/{workout_id}", api_delete_workout)
|
app.router.add_delete("/api/workouts/{workout_id}", api_delete_workout)
|
||||||
app.router.add_get("/api/exercises", api_get_exercise_names)
|
app.router.add_get("/api/exercises", api_get_exercise_names)
|
||||||
app.router.add_get("/api/exercises/last", api_get_last_exercise)
|
|
||||||
app.router.add_get("/api/stats", api_get_stats)
|
app.router.add_get("/api/stats", api_get_stats)
|
||||||
app.router.add_get("/api/export/json", api_export_json)
|
app.router.add_get("/api/export/json", api_export_json)
|
||||||
app.router.add_get("/api/export/csv", api_export_csv)
|
app.router.add_get("/api/export/csv", api_export_csv)
|
||||||
|
|
|
||||||
|
|
@ -253,48 +253,6 @@ class TestAllExerciseNames:
|
||||||
assert db.get_all_exercise_names() == ["Apple", "Mango", "Zebra"]
|
assert db.get_all_exercise_names() == ["Apple", "Mango", "Zebra"]
|
||||||
|
|
||||||
|
|
||||||
# ── get_last_exercise ────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
class TestGetLastExercise:
|
|
||||||
def test_none_when_no_history(self, tmp_db):
|
|
||||||
assert db.get_last_exercise(1, "Bench") is None
|
|
||||||
|
|
||||||
def test_returns_most_recent(self, tmp_db):
|
|
||||||
t = lambda d: datetime(2024, 1, d, tzinfo=timezone.utc)
|
|
||||||
db.save_workout(1, t(1), [[_make_exercise(name="Squat", weight=80.0)]])
|
|
||||||
db.save_workout(1, t(5), [[_make_exercise(name="Squat", weight=90.0)]])
|
|
||||||
last = db.get_last_exercise(1, "Squat")
|
|
||||||
assert last is not None
|
|
||||||
assert last["weight_kg"] == 90.0
|
|
||||||
assert last["timestamp"].startswith("2024-01-05")
|
|
||||||
|
|
||||||
def test_case_insensitive(self, tmp_db):
|
|
||||||
_save_simple(name="Bench Press")
|
|
||||||
assert db.get_last_exercise(1, "bench press") is not None
|
|
||||||
assert db.get_last_exercise(1, "BENCH PRESS") is not None
|
|
||||||
|
|
||||||
def test_sets_detail_parsed(self, tmp_db):
|
|
||||||
detail = [{"reps": 8, "weight_kg": 25.0}, {"reps": 5, "weight_kg": 35.0}]
|
|
||||||
ex = {
|
|
||||||
"name": "Press", "machine_id": None,
|
|
||||||
"sets": 2, "reps": 8, "weight_kg": 25.0,
|
|
||||||
"sets_detail": detail, "raw_line": "Press: 8x25, 5x35",
|
|
||||||
}
|
|
||||||
db.save_workout(1, datetime.now(timezone.utc), [[ex]])
|
|
||||||
last = db.get_last_exercise(1, "Press")
|
|
||||||
assert last["sets_detail"] == detail
|
|
||||||
|
|
||||||
def test_scoped_to_user(self, tmp_db):
|
|
||||||
_save_simple(user_id=1, name="Deadlift")
|
|
||||||
assert db.get_last_exercise(2, "Deadlift") is None
|
|
||||||
|
|
||||||
def test_ignores_deleted(self, tmp_db):
|
|
||||||
wid = _save_simple(name="Rows")
|
|
||||||
db.delete_workout(1, wid)
|
|
||||||
assert db.get_last_exercise(1, "Rows") is None
|
|
||||||
|
|
||||||
|
|
||||||
# ── events / log_event ───────────────────────────────────────────
|
# ── events / log_event ───────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -125,7 +125,6 @@ function restoreDraft() {
|
||||||
if (Array.isArray(draft.currentSets)) {
|
if (Array.isArray(draft.currentSets)) {
|
||||||
draft.currentSets.forEach((s) => addSetToDOM(s.reps, s.weight_kg));
|
draft.currentSets.forEach((s) => addSetToDOM(s.reps, s.weight_kg));
|
||||||
}
|
}
|
||||||
loadLastSession(currentExercise.name);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore active tab
|
// Restore active tab
|
||||||
|
|
@ -358,72 +357,10 @@ function startExercise(name) {
|
||||||
notesSection.classList.remove("hidden");
|
notesSection.classList.remove("hidden");
|
||||||
stopRestTimer();
|
stopRestTimer();
|
||||||
syncEditorUI();
|
syncEditorUI();
|
||||||
loadLastSession(name);
|
|
||||||
tg.HapticFeedback.selectionChanged();
|
tg.HapticFeedback.selectionChanged();
|
||||||
saveDraft();
|
saveDraft();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Last-session recall ─────────────────────────────────────────
|
|
||||||
function _relativeDay(iso) {
|
|
||||||
const then = new Date(iso);
|
|
||||||
if (isNaN(then.getTime())) return "";
|
|
||||||
const days = Math.floor((Date.now() - then.getTime()) / 86400000);
|
|
||||||
if (days <= 0) return "today";
|
|
||||||
if (days === 1) return "yesterday";
|
|
||||||
if (days < 7) return days + " days ago";
|
|
||||||
if (days < 14) return "1 week ago";
|
|
||||||
if (days < 30) return Math.floor(days / 7) + " weeks ago";
|
|
||||||
return then.toLocaleDateString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function _setsSummary(last) {
|
|
||||||
const detail = last.sets_detail || [];
|
|
||||||
const varied = detail.length > 0 && !detail.every(
|
|
||||||
(d) => d.reps === detail[0].reps && d.weight_kg === detail[0].weight_kg
|
|
||||||
);
|
|
||||||
if (varied) {
|
|
||||||
return detail
|
|
||||||
.map((d) => (d.weight_kg ? `${d.reps}×${fmtWeight(d.weight_kg)}kg` : `${d.reps}`))
|
|
||||||
.join(", ");
|
|
||||||
}
|
|
||||||
return last.weight_kg
|
|
||||||
? `${last.sets}×${last.reps}×${fmtWeight(last.weight_kg)}kg`
|
|
||||||
: `${last.sets}×${last.reps}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadLastSession(name) {
|
|
||||||
const hint = document.getElementById("last-session-hint");
|
|
||||||
if (hint) {
|
|
||||||
hint.classList.add("hidden");
|
|
||||||
hint.textContent = "";
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const data = await api("GET", "/exercises/last?name=" + encodeURIComponent(name));
|
|
||||||
const last = data.last;
|
|
||||||
// Bail if the user has moved on to a different exercise meanwhile.
|
|
||||||
if (!last || !currentExercise || currentExercise.name !== name) return;
|
|
||||||
|
|
||||||
if (hint) {
|
|
||||||
const when = _relativeDay(last.timestamp);
|
|
||||||
hint.textContent = "Last time: " + _setsSummary(last) + (when ? " · " + when : "");
|
|
||||||
hint.classList.remove("hidden");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pre-fill the weight input with the last set's weight, but only if the
|
|
||||||
// user hasn't already started typing or logged a set.
|
|
||||||
const detail = last.sets_detail || [];
|
|
||||||
const lastWeight = detail.length
|
|
||||||
? detail[detail.length - 1].weight_kg
|
|
||||||
: last.weight_kg;
|
|
||||||
if (lastWeight && !weightInput.value.trim() && getCurrentSets().length === 0) {
|
|
||||||
weightInput.value = String(lastWeight);
|
|
||||||
syncWeightSignUI();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Silent — recall is a convenience, never block exercise entry.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCurrentSets() {
|
function getCurrentSets() {
|
||||||
return Array.from(setsList.querySelectorAll(".set-entry")).map((el) => ({
|
return Array.from(setsList.querySelectorAll(".set-entry")).map((el) => ({
|
||||||
reps: parseInt(el.dataset.reps),
|
reps: parseInt(el.dataset.reps),
|
||||||
|
|
@ -655,14 +592,6 @@ function editExercise(idx) {
|
||||||
repsInput.value = "";
|
repsInput.value = "";
|
||||||
repsInput.focus();
|
repsInput.focus();
|
||||||
|
|
||||||
// The set rows in front of you are the reference here — drop any stale
|
|
||||||
// "last time" hint from an earlier startExercise.
|
|
||||||
const hint = document.getElementById("last-session-hint");
|
|
||||||
if (hint) {
|
|
||||||
hint.classList.add("hidden");
|
|
||||||
hint.textContent = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
stopRestTimer();
|
stopRestTimer();
|
||||||
renderWorkout();
|
renderWorkout();
|
||||||
tg.HapticFeedback.selectionChanged();
|
tg.HapticFeedback.selectionChanged();
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,6 @@
|
||||||
</div>
|
</div>
|
||||||
<button id="btn-delete-exercise" class="btn-link btn-danger hidden">Remove exercise</button>
|
<button id="btn-delete-exercise" class="btn-link btn-danger hidden">Remove exercise</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="last-session-hint" class="last-session-hint hidden"></div>
|
|
||||||
<div id="sets-list"></div>
|
<div id="sets-list"></div>
|
||||||
<div class="set-input-row">
|
<div class="set-input-row">
|
||||||
<input type="text" id="inp-reps" class="input input-small" placeholder="Reps" inputmode="numeric" pattern="[0-9]*" />
|
<input type="text" id="inp-reps" class="input input-small" placeholder="Reps" inputmode="numeric" pattern="[0-9]*" />
|
||||||
|
|
|
||||||
|
|
@ -241,15 +241,6 @@ body {
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
.last-session-hint {
|
|
||||||
margin-top: 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--tg-theme-hint-color, #999);
|
|
||||||
background: var(--tg-theme-bg-color, #fff);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 6px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger {
|
.btn-danger {
|
||||||
color: #e53935 !important;
|
color: #e53935 !important;
|
||||||
font-size: 12px !important;
|
font-size: 12px !important;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue